Skip to main content

surge_solution/
power_flow.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Power flow solution representation.
3
4use std::collections::{HashMap, HashSet};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use thiserror::Error;
8
9use surge_network::Network;
10
11/// Error returned when querying a [`PfSolution`].
12#[derive(Debug, Error)]
13pub enum SolutionError {
14    #[error("branch count mismatch: solution has {solution} branches, network has {network}")]
15    BranchCountMismatch { solution: usize, network: usize },
16    #[error(
17        "stored from-end branch flow vectors have mismatched lengths (p_from={p_from}, q_from={q_from})"
18    )]
19    BranchFlowVectorMismatch { p_from: usize, q_from: usize },
20    #[error("stored to-end branch flow vectors have mismatched lengths (p_to={p_to}, q_to={q_to})")]
21    BranchToEndFlowVectorMismatch { p_to: usize, q_to: usize },
22}
23
24// -----------------------------------------------------------------------
25// Area interchange control results
26// -----------------------------------------------------------------------
27
28/// How a particular area's interchange mismatch was dispatched.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum AreaDispatchMethod {
31    /// Distributed via APF-weighted regulating generators across the area.
32    Apf,
33    /// Fell back to headroom-proportional dispatch at the area slack bus
34    /// (no generators with `agc_participation_factor > 0` found in the area).
35    SlackBusFallback,
36    /// Area was within tolerance — no adjustment needed.
37    Converged,
38}
39
40/// Per-area interchange enforcement result.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AreaInterchangeEntry {
43    /// Area number.
44    pub area: u32,
45    /// Net scheduled interchange in MW (p_desired_mw + bilateral transfers).
46    pub scheduled_mw: f64,
47    /// Actual net interchange in MW (sum of tie-line flows).
48    pub actual_mw: f64,
49    /// Mismatch: scheduled − actual (MW).
50    pub error_mw: f64,
51    /// How the mismatch was dispatched for this area.
52    pub dispatch_method: AreaDispatchMethod,
53}
54
55/// Aggregate result of area interchange enforcement.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct AreaInterchangeResult {
58    /// Per-area results.
59    pub areas: Vec<AreaInterchangeEntry>,
60    /// Number of outer-loop iterations used.
61    pub iterations: usize,
62    /// True if all areas converged within tolerance.
63    pub converged: bool,
64}
65
66/// Status of a power flow solve.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
68pub enum SolveStatus {
69    /// Converged to a solution within tolerance.
70    Converged,
71    /// Maximum iterations reached without convergence.
72    MaxIterations,
73    /// Diverged (numerical instability).
74    Diverged,
75    /// Not yet solved.
76    #[default]
77    Unsolved,
78}
79
80/// Shared power-flow model family for a solved state.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "snake_case")]
83pub enum PfModel {
84    /// AC power flow or any nonlinear/complex-voltage formulation.
85    #[default]
86    Ac,
87    /// DC B-theta power flow.
88    Dc,
89}
90
91/// Result of a power flow computation.
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93pub struct PfSolution {
94    /// Physical model family used to produce this solved state.
95    pub pf_model: PfModel,
96    /// Solve status.
97    pub status: SolveStatus,
98    /// Number of iterations taken.
99    pub iterations: u32,
100    /// Maximum power mismatch at convergence (per-unit).
101    #[serde(
102        serialize_with = "serialize_max_mismatch",
103        deserialize_with = "deserialize_max_mismatch"
104    )]
105    pub max_mismatch: f64,
106    /// Solve time in seconds.
107    pub solve_time_secs: f64,
108    /// Bus voltage magnitudes in per-unit (indexed by internal bus order).
109    pub voltage_magnitude_pu: Vec<f64>,
110    /// Bus voltage angles in radians (indexed by internal bus order).
111    pub voltage_angle_rad: Vec<f64>,
112    /// Real power injection at each bus in per-unit.
113    pub active_power_injection_pu: Vec<f64>,
114    /// Reactive power injection at each bus in per-unit.
115    pub reactive_power_injection_pu: Vec<f64>,
116    /// Real power flow at each branch from-end in MW.
117    pub branch_p_from_mw: Vec<f64>,
118    /// Real power flow at each branch to-end in MW.
119    pub branch_p_to_mw: Vec<f64>,
120    /// Reactive power flow at each branch from-end in MVAr.
121    pub branch_q_from_mvar: Vec<f64>,
122    /// Reactive power flow at each branch to-end in MVAr.
123    pub branch_q_to_mvar: Vec<f64>,
124    /// External bus numbers (for joining with reference solutions).
125    pub bus_numbers: Vec<u32>,
126    /// Island membership for each bus (0-indexed island ID, per internal bus order).
127    ///
128    /// Empty when island detection was not performed. When populated, `island_ids[i]`
129    /// gives the connected-component index for internal bus `i`. Isolated buses
130    /// (degree 0) form their own single-bus island.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub island_ids: Vec<usize>,
133
134    // -----------------------------------------------------------------------
135    // AC-02: Q-limit PV→PQ bus switching metadata
136    // -----------------------------------------------------------------------
137    /// External bus numbers of all buses that were switched from PV to PQ by
138    /// reactive power limit enforcement during this solve.
139    ///
140    /// Empty when the underlying AC solver did not enforce Q limits or no limits
141    /// were violated. For DC solves this is always empty.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub q_limited_buses: Vec<u32>,
144
145    /// Total number of bus type switches (PV→PQ or PQ→PV) performed by
146    /// Q-limit enforcement across all outer NR iterations.
147    ///
148    /// Zero when no limits were enforced. For DC solves this is always zero.
149    #[serde(default)]
150    pub n_q_limit_switches: u32,
151
152    // -----------------------------------------------------------------------
153    // AC-03: Distributed slack contribution metadata
154    // -----------------------------------------------------------------------
155    /// Active power contribution of each generator to the slack balancing
156    /// redistribution, in MW (indexed by `network.generators` order).
157    ///
158    /// Non-zero only when the originating AC solver used distributed slack.
159    /// When multiple generators share a participating bus, the bus-level slack
160    /// share is split per the solver's slack-attribution policy instead of
161    /// being duplicated onto every generator at that bus.
162    /// For DC solves this is empty by construction.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub gen_slack_contribution_mw: Vec<f64>,
165
166    // -----------------------------------------------------------------------
167    // Convergence diagnostics
168    // -----------------------------------------------------------------------
169    /// Per-iteration convergence history: `(iteration_number, max_mismatch_pu)`.
170    ///
171    /// Empty unless the originating solver recorded it.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub convergence_history: Vec<(u32, f64)>,
174
175    /// External bus number with the largest power mismatch on the final solve
176    /// iteration. `None` on successful convergence.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub worst_mismatch_bus: Option<u32>,
179
180    // -----------------------------------------------------------------------
181    // Area interchange enforcement results
182    // -----------------------------------------------------------------------
183    /// Area interchange enforcement results. `None` when interchange
184    /// enforcement was not active.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub area_interchange: Option<AreaInterchangeResult>,
187}
188
189impl PfSolution {
190    fn validate_from_end_branch_vectors(&self) -> Result<(), SolutionError> {
191        if self.branch_p_from_mw.len() != self.branch_q_from_mvar.len() {
192            return Err(SolutionError::BranchFlowVectorMismatch {
193                p_from: self.branch_p_from_mw.len(),
194                q_from: self.branch_q_from_mvar.len(),
195            });
196        }
197        Ok(())
198    }
199
200    /// Create a diverged solution with flat voltage profile (Vm=1.0, Va=0.0).
201    pub fn diverged(n_buses: usize, n_branches: usize, pf_model: PfModel) -> Self {
202        Self {
203            pf_model,
204            status: SolveStatus::Diverged,
205            voltage_magnitude_pu: vec![1.0; n_buses],
206            voltage_angle_rad: vec![0.0; n_buses],
207            active_power_injection_pu: vec![0.0; n_buses],
208            reactive_power_injection_pu: vec![0.0; n_buses],
209            branch_p_from_mw: vec![0.0; n_branches],
210            branch_p_to_mw: vec![0.0; n_branches],
211            branch_q_from_mvar: vec![0.0; n_branches],
212            branch_q_to_mvar: vec![0.0; n_branches],
213            max_mismatch: f64::INFINITY,
214            iterations: 0,
215            solve_time_secs: 0.0,
216            bus_numbers: Vec::new(),
217            island_ids: Vec::new(),
218            q_limited_buses: Vec::new(),
219            n_q_limit_switches: 0,
220            gen_slack_contribution_mw: Vec::new(),
221            convergence_history: Vec::new(),
222            worst_mismatch_bus: None,
223            area_interchange: None,
224        }
225    }
226
227    /// Create an unsolved solution with flat voltage profile (Vm=1.0, Va=0.0).
228    pub fn flat_start(n_buses: usize, n_branches: usize, pf_model: PfModel) -> Self {
229        let mut sol = Self::diverged(n_buses, n_branches, pf_model);
230        sol.status = SolveStatus::Unsolved;
231        sol.max_mismatch = 0.0;
232        sol
233    }
234
235    /// Number of distinct islands detected in this solve.
236    pub fn n_islands(&self) -> usize {
237        self.island_ids
238            .iter()
239            .copied()
240            .collect::<HashSet<_>>()
241            .len()
242    }
243
244    /// Estimate per-generator reactive power output (MVAr) in `network.generators` order.
245    ///
246    /// This reconstructs generator reactive dispatch from the solved bus-level
247    /// reactive injections, fixed bus shunts, and static bus load withdrawals.
248    /// For buses with multiple in-service generators, the bus-level reactive
249    /// output is apportioned by reactive capability range `(qmax - qmin)` when
250    /// available; otherwise it is split evenly across the in-service units at
251    /// that bus.
252    pub fn generator_reactive_power_mvar(&self, network: &Network) -> Vec<f64> {
253        let base = network.base_mva;
254        let bus_map = network.bus_index_map();
255        let load_q = network.bus_load_q_mvar_with_map(&bus_map);
256
257        let mut bus_qg: HashMap<u32, f64> = HashMap::new();
258        for (bus_idx, bus) in network.buses.iter().enumerate() {
259            let Some(&solution_bus_idx) = bus_map.get(&bus.number) else {
260                continue;
261            };
262            if solution_bus_idx >= self.reactive_power_injection_pu.len()
263                || solution_bus_idx >= self.voltage_magnitude_pu.len()
264            {
265                continue;
266            }
267            let vm = self.voltage_magnitude_pu[solution_bus_idx];
268            let qd = load_q.get(bus_idx).copied().unwrap_or(0.0);
269            let qg_bus = self.reactive_power_injection_pu[solution_bus_idx] * base + qd
270                - bus.shunt_susceptance_mvar * vm * vm;
271            bus_qg.insert(bus.number, qg_bus);
272        }
273
274        let mut bus_range: HashMap<u32, f64> = HashMap::new();
275        let mut bus_count: HashMap<u32, usize> = HashMap::new();
276        for generator in network
277            .generators
278            .iter()
279            .filter(|generator| generator.in_service)
280        {
281            *bus_range.entry(generator.bus).or_insert(0.0) +=
282                (generator.qmax - generator.qmin).max(0.0);
283            *bus_count.entry(generator.bus).or_insert(0) += 1;
284        }
285
286        network
287            .generators
288            .iter()
289            .map(|generator| {
290                if !generator.in_service {
291                    return 0.0;
292                }
293                let total_qg = bus_qg.get(&generator.bus).copied().unwrap_or(0.0);
294                let total_range = bus_range.get(&generator.bus).copied().unwrap_or(0.0);
295                let generator_range = (generator.qmax - generator.qmin).max(0.0);
296                if total_range > 1e-6 {
297                    total_qg * generator_range / total_range
298                } else {
299                    let units_at_bus = bus_count.get(&generator.bus).copied().unwrap_or(1).max(1);
300                    total_qg / units_at_bus as f64
301                }
302            })
303            .collect()
304    }
305
306    /// Compute apparent power flow |S_ij| on each branch in MVA.
307    ///
308    /// Uses the stored from-end branch P/Q values (MW/MVAr).
309    pub fn branch_apparent_power(&self) -> Vec<f64> {
310        self.validate_from_end_branch_vectors()
311            .expect("stored from-end branch flow vectors must have matching lengths");
312        self.branch_p_from_mw
313            .iter()
314            .zip(self.branch_q_from_mvar.iter())
315            .map(|(&p, &q)| (p * p + q * q).sqrt())
316            .collect()
317    }
318
319    /// Compute branch loading as percentage of Rate A for each branch.
320    ///
321    /// Returns `max(|S_from|, |S_to|) / rate_a * 100`. Branches with
322    /// `rate_a <= 0` return 0.0.
323    pub fn branch_loading_pct(&self, network: &Network) -> Result<Vec<f64>, SolutionError> {
324        if self.branch_p_from_mw.len() != network.branches.len() {
325            return Err(SolutionError::BranchCountMismatch {
326                solution: self.branch_p_from_mw.len(),
327                network: network.branches.len(),
328            });
329        }
330        if self.branch_p_to_mw.len() != network.branches.len() {
331            return Err(SolutionError::BranchCountMismatch {
332                solution: self.branch_p_to_mw.len(),
333                network: network.branches.len(),
334            });
335        }
336        self.validate_from_end_branch_vectors()?;
337        if self.branch_p_to_mw.len() != self.branch_q_to_mvar.len() {
338            return Err(SolutionError::BranchToEndFlowVectorMismatch {
339                p_to: self.branch_p_to_mw.len(),
340                q_to: self.branch_q_to_mvar.len(),
341            });
342        }
343        Ok(self
344            .branch_p_from_mw
345            .iter()
346            .zip(self.branch_q_from_mvar.iter())
347            .zip(self.branch_p_to_mw.iter().zip(self.branch_q_to_mvar.iter()))
348            .zip(network.branches.iter())
349            .map(|(((p_from, q_from), (p_to, q_to)), branch)| {
350                let from_s = (*p_from * *p_from + *q_from * *q_from).sqrt();
351                let to_s = (*p_to * *p_to + *q_to * *q_to).sqrt();
352                let flow = from_s.max(to_s);
353                if branch.rating_a_mva > 0.0 {
354                    flow / branch.rating_a_mva * 100.0
355                } else {
356                    0.0
357                }
358            })
359            .collect())
360    }
361
362    /// Return the stored from-end real and reactive power flows in MW/MVAr.
363    pub fn branch_pq_flows(&self) -> Vec<(f64, f64)> {
364        self.validate_from_end_branch_vectors()
365            .expect("stored from-end branch flow vectors must have matching lengths");
366        self.branch_p_from_mw
367            .iter()
368            .zip(self.branch_q_from_mvar.iter())
369            .map(|(&p, &q)| (p, q))
370            .collect()
371    }
372}
373
374fn serialize_max_mismatch<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
375where
376    S: Serializer,
377{
378    if value.is_finite() {
379        serializer.serialize_some(value)
380    } else {
381        serializer.serialize_none()
382    }
383}
384
385fn deserialize_max_mismatch<'de, D>(deserializer: D) -> Result<f64, D::Error>
386where
387    D: Deserializer<'de>,
388{
389    Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::INFINITY))
390}
391
392/// Compute exact branch power flows from a solved bus voltage state.
393///
394/// Returns `(pf, pt, qf, qt)` in branch order (MW / MVAr), where:
395/// - `pf[i]` / `qf[i]` are the from-end flows for branch `i`
396/// - `pt[i]` / `qt[i]` are the to-end flows for branch `i`
397pub fn compute_branch_power_flows(
398    network: &Network,
399    voltage_magnitude_pu: &[f64],
400    voltage_angle_rad: &[f64],
401    base_mva: f64,
402) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
403    assert_eq!(
404        voltage_magnitude_pu.len(),
405        network.n_buses(),
406        "voltage_magnitude_pu has {} entries, expected {}",
407        voltage_magnitude_pu.len(),
408        network.n_buses()
409    );
410    assert_eq!(
411        voltage_angle_rad.len(),
412        network.n_buses(),
413        "voltage_angle_rad has {} entries, expected {}",
414        voltage_angle_rad.len(),
415        network.n_buses()
416    );
417
418    let n_branches = network.n_branches();
419    let bus_map = network.bus_index_map();
420    let mut pf = vec![0.0; n_branches];
421    let mut pt = vec![0.0; n_branches];
422    let mut qf = vec![0.0; n_branches];
423    let mut qt = vec![0.0; n_branches];
424
425    for (branch_idx, branch) in network.branches.iter().enumerate() {
426        if !branch.in_service {
427            continue;
428        }
429
430        let from_idx = bus_map[&branch.from_bus];
431        let to_idx = bus_map[&branch.to_bus];
432
433        let vf = voltage_magnitude_pu[from_idx];
434        let vt = voltage_magnitude_pu[to_idx];
435        let theta_ft = voltage_angle_rad[from_idx] - voltage_angle_rad[to_idx];
436        let flows = branch.power_flows_pu(vf, vt, theta_ft, 1e-40);
437
438        pf[branch_idx] = flows.p_from_pu * base_mva;
439        qf[branch_idx] = flows.q_from_pu * base_mva;
440        pt[branch_idx] = flows.p_to_pu * base_mva;
441        qt[branch_idx] = flows.q_to_pu * base_mva;
442    }
443
444    (pf, pt, qf, qt)
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use serde_json::Value;
451    use surge_network::network::branch::Branch;
452    use surge_network::network::bus::{Bus, BusType};
453    use surge_network::network::generator::Generator;
454    use surge_network::network::load::Load;
455
456    fn two_bus_network(rating_a: f64) -> Network {
457        let mut net = Network::new("test");
458        net.buses.push(Bus::new(1, BusType::Slack, 138.0));
459        net.buses.push(Bus::new(2, BusType::PQ, 138.0));
460        let mut br = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
461        br.rating_a_mva = rating_a;
462        net.branches.push(br);
463        net
464    }
465
466    fn two_bus_solution(
467        pf_model: PfModel,
468        p_from_mw: f64,
469        q_from_mvar: f64,
470        p_to_mw: f64,
471        q_to_mvar: f64,
472    ) -> PfSolution {
473        PfSolution {
474            pf_model,
475            status: SolveStatus::Converged,
476            voltage_magnitude_pu: vec![1.0, 1.0],
477            voltage_angle_rad: vec![0.0, 0.0],
478            active_power_injection_pu: vec![0.0, 0.0],
479            reactive_power_injection_pu: vec![0.0, 0.0],
480            branch_p_from_mw: vec![p_from_mw],
481            branch_p_to_mw: vec![p_to_mw],
482            branch_q_from_mvar: vec![q_from_mvar],
483            branch_q_to_mvar: vec![q_to_mvar],
484            ..Default::default()
485        }
486    }
487
488    fn two_bus_generator_network() -> Network {
489        let mut net = two_bus_network(100.0);
490        let mut gen_a = Generator::with_id("g1", 1, 50.0, 1.0);
491        gen_a.qmin = -10.0;
492        gen_a.qmax = 20.0;
493        let mut gen_b = Generator::with_id("g2", 1, 25.0, 1.0);
494        gen_b.qmin = -5.0;
495        gen_b.qmax = 5.0;
496        net.generators.push(gen_a);
497        net.generators.push(gen_b);
498        net.loads.push(Load::new(2, 60.0, 15.0));
499        net
500    }
501
502    #[test]
503    fn test_branch_apparent_power_uses_stored_values() {
504        // 60 MW, 80 MVAr → |S| = 100 MVA
505        let sol = two_bus_solution(PfModel::Ac, 60.0, 80.0, -58.0, -77.0);
506        let flows = sol.branch_apparent_power();
507        assert_eq!(flows.len(), 1);
508        assert!((flows[0] - 100.0).abs() < 1e-9);
509    }
510
511    #[test]
512    fn test_branch_loading_pct_zero_rating() {
513        let net = two_bus_network(0.0);
514        let sol = two_bus_solution(PfModel::Ac, 50.0, 0.0, -50.0, 0.0);
515        let loading = sol.branch_loading_pct(&net).unwrap();
516        assert_eq!(loading, vec![0.0]);
517    }
518
519    #[test]
520    fn test_branch_loading_pct_normal_operation() {
521        let net = two_bus_network(200.0);
522        // 60 MW, 80 MVAr → |S| = 100 MVA → 50% of 200 MVA rating
523        let sol = two_bus_solution(PfModel::Ac, 60.0, 80.0, -58.0, -77.0);
524        let loading = sol.branch_loading_pct(&net).unwrap();
525        assert_eq!(loading.len(), 1);
526        assert!((loading[0] - 50.0).abs() < 1e-9);
527    }
528
529    #[test]
530    fn test_branch_loading_pct_uses_larger_end() {
531        let net = two_bus_network(200.0);
532        let sol = two_bus_solution(PfModel::Ac, 30.0, 40.0, 80.0, 60.0);
533        let loading = sol.branch_loading_pct(&net).unwrap();
534        assert_eq!(loading.len(), 1);
535        assert!((loading[0] - 50.0).abs() < 1e-9);
536    }
537
538    #[test]
539    fn test_branch_pq_flows_returns_stored_from_end_values() {
540        let sol = two_bus_solution(PfModel::Dc, 42.0, 0.0, -42.0, 0.0);
541        let pq = sol.branch_pq_flows();
542        assert_eq!(pq, vec![(42.0, 0.0)]);
543    }
544
545    #[test]
546    fn test_branch_loading_mismatch_returns_error() {
547        let net = two_bus_network(100.0);
548        let sol = PfSolution {
549            branch_p_from_mw: vec![],
550            ..Default::default()
551        };
552        assert!(sol.branch_loading_pct(&net).is_err());
553    }
554
555    #[test]
556    #[should_panic(expected = "stored from-end branch flow vectors must have matching lengths")]
557    fn test_branch_apparent_power_panics_on_mismatched_vectors() {
558        let sol = PfSolution {
559            branch_p_from_mw: vec![10.0],
560            branch_q_from_mvar: vec![],
561            ..Default::default()
562        };
563        let _ = sol.branch_apparent_power();
564    }
565
566    #[test]
567    #[should_panic(expected = "stored from-end branch flow vectors must have matching lengths")]
568    fn test_branch_pq_flows_panics_on_mismatched_vectors() {
569        let sol = PfSolution {
570            branch_p_from_mw: vec![10.0],
571            branch_q_from_mvar: vec![],
572            ..Default::default()
573        };
574        let _ = sol.branch_pq_flows();
575    }
576
577    #[test]
578    fn test_n_islands_empty() {
579        let sol = PfSolution::default();
580        assert_eq!(sol.n_islands(), 0);
581    }
582
583    #[test]
584    fn test_n_islands_single() {
585        let sol = PfSolution {
586            island_ids: vec![0, 0, 0],
587            ..Default::default()
588        };
589        assert_eq!(sol.n_islands(), 1);
590    }
591
592    #[test]
593    fn test_n_islands_multiple() {
594        let sol = PfSolution {
595            island_ids: vec![0, 1, 2, 0, 1],
596            ..Default::default()
597        };
598        assert_eq!(sol.n_islands(), 3);
599    }
600
601    #[test]
602    fn test_n_islands_non_dense_labels() {
603        let sol = PfSolution {
604            island_ids: vec![2, 4, 2, 9],
605            ..Default::default()
606        };
607        assert_eq!(sol.n_islands(), 3);
608    }
609
610    #[test]
611    fn test_n_islands_single_bus() {
612        let sol = PfSolution {
613            island_ids: vec![0],
614            ..Default::default()
615        };
616        assert_eq!(sol.n_islands(), 1);
617    }
618
619    #[test]
620    fn test_diverged_creates_flat_profile() {
621        let sol = PfSolution::diverged(3, 2, PfModel::Ac);
622        assert_eq!(sol.pf_model, PfModel::Ac);
623        assert_eq!(sol.status, SolveStatus::Diverged);
624        assert_eq!(sol.voltage_magnitude_pu, vec![1.0, 1.0, 1.0]);
625        assert_eq!(sol.voltage_angle_rad, vec![0.0, 0.0, 0.0]);
626        assert_eq!(sol.active_power_injection_pu, vec![0.0, 0.0, 0.0]);
627        assert_eq!(sol.reactive_power_injection_pu, vec![0.0, 0.0, 0.0]);
628        assert_eq!(sol.branch_p_from_mw, vec![0.0, 0.0]);
629        assert_eq!(sol.branch_q_from_mvar, vec![0.0, 0.0]);
630        assert_eq!(sol.max_mismatch, f64::INFINITY);
631        assert_eq!(sol.iterations, 0);
632    }
633
634    #[test]
635    fn test_diverged_serializes_max_mismatch_as_null() {
636        let sol = PfSolution::diverged(1, 0, PfModel::Ac);
637        let json = serde_json::to_value(&sol).unwrap();
638        assert_eq!(json.get("max_mismatch"), Some(&Value::Null));
639
640        let roundtrip: PfSolution = serde_json::from_value(json).unwrap();
641        assert!(roundtrip.max_mismatch.is_infinite());
642    }
643
644    #[test]
645    fn test_flat_start_creates_unsolved_profile() {
646        let sol = PfSolution::flat_start(2, 1, PfModel::Dc);
647        assert_eq!(sol.pf_model, PfModel::Dc);
648        assert_eq!(sol.status, SolveStatus::Unsolved);
649        assert_eq!(sol.voltage_magnitude_pu, vec![1.0, 1.0]);
650        assert_eq!(sol.voltage_angle_rad, vec![0.0, 0.0]);
651        assert_eq!(sol.branch_p_from_mw, vec![0.0]);
652        assert_eq!(sol.max_mismatch, 0.0);
653    }
654
655    #[test]
656    fn test_generator_reactive_power_mvar_apportions_by_capability() {
657        let net = two_bus_generator_network();
658        let sol = PfSolution {
659            status: SolveStatus::Converged,
660            voltage_magnitude_pu: vec![1.0, 1.0],
661            voltage_angle_rad: vec![0.0, 0.0],
662            active_power_injection_pu: vec![0.0, 0.0],
663            reactive_power_injection_pu: vec![0.40, -0.15],
664            bus_numbers: vec![1, 2],
665            ..Default::default()
666        };
667
668        let qg = sol.generator_reactive_power_mvar(&net);
669        assert_eq!(qg.len(), 2);
670        assert!(
671            (qg[0] - 30.0).abs() < 1e-9,
672            "first generator should absorb 30 MVAr-equivalent share"
673        );
674        assert!(
675            (qg[1] - 10.0).abs() < 1e-9,
676            "second generator should absorb 10 MVAr-equivalent share"
677        );
678    }
679
680    #[test]
681    fn test_generator_reactive_power_mvar_splits_evenly_without_capability_range() {
682        let mut net = two_bus_network(100.0);
683        let mut gen_a = Generator::with_id("g1", 1, 50.0, 1.0);
684        gen_a.qmin = 0.0;
685        gen_a.qmax = 0.0;
686        let mut gen_b = Generator::with_id("g2", 1, 25.0, 1.0);
687        gen_b.qmin = 0.0;
688        gen_b.qmax = 0.0;
689        net.generators.push(gen_a);
690        net.generators.push(gen_b);
691        let sol = PfSolution {
692            status: SolveStatus::Converged,
693            voltage_magnitude_pu: vec![1.0, 1.0],
694            voltage_angle_rad: vec![0.0, 0.0],
695            active_power_injection_pu: vec![0.0, 0.0],
696            reactive_power_injection_pu: vec![0.12, 0.0],
697            bus_numbers: vec![1, 2],
698            ..Default::default()
699        };
700
701        let qg = sol.generator_reactive_power_mvar(&net);
702        assert_eq!(qg, vec![6.0, 6.0]);
703    }
704
705    #[test]
706    fn test_generator_reactive_power_mvar_accounts_for_bus_shunt_without_extra_base_factor() {
707        let mut net = two_bus_network(100.0);
708        net.buses[0].shunt_susceptance_mvar = -100.0;
709        net.generators.push(Generator::with_id("g1", 1, 50.0, 1.0));
710        let sol = PfSolution {
711            status: SolveStatus::Converged,
712            voltage_magnitude_pu: vec![1.0, 1.0],
713            voltage_angle_rad: vec![0.0, 0.0],
714            active_power_injection_pu: vec![0.0, 0.0],
715            reactive_power_injection_pu: vec![0.0, 0.0],
716            bus_numbers: vec![1, 2],
717            ..Default::default()
718        };
719
720        let qg = sol.generator_reactive_power_mvar(&net);
721        assert_eq!(qg, vec![100.0]);
722    }
723
724    #[test]
725    fn test_convergence_history_length_matches_iterations() {
726        let sol = PfSolution {
727            status: SolveStatus::Converged,
728            iterations: 4,
729            convergence_history: vec![(0, 1.0), (1, 0.5), (2, 0.1), (3, 0.01), (4, 1e-8)],
730            ..Default::default()
731        };
732        assert_eq!(sol.convergence_history.len(), (sol.iterations + 1) as usize);
733    }
734
735    #[test]
736    fn test_worst_mismatch_bus_populated_on_divergence() {
737        let sol = PfSolution {
738            status: SolveStatus::Diverged,
739            worst_mismatch_bus: Some(42),
740            max_mismatch: 1e3,
741            ..Default::default()
742        };
743        assert_eq!(sol.worst_mismatch_bus, Some(42));
744        assert_eq!(sol.status, SolveStatus::Diverged);
745    }
746
747    #[test]
748    fn test_worst_mismatch_bus_none_on_convergence() {
749        let sol = PfSolution {
750            status: SolveStatus::Converged,
751            worst_mismatch_bus: None,
752            max_mismatch: 1e-10,
753            ..Default::default()
754        };
755        assert!(sol.worst_mismatch_bus.is_none());
756    }
757}