Skip to main content

powerio_dist/
load_voltage.rs

1//! Voltage-dependent load laws, expressed as multipliers of nominal P and Q.
2
3use crate::DistLoadVoltageModel;
4
5impl DistLoadVoltageModel {
6    /// Evaluate active/reactive power multipliers at a nonnegative voltage
7    /// magnitude in volts. Coefficient and nominal-voltage arrays follow
8    /// active load branches, rather than bus conductor order.
9    ///
10    /// For a bounded ZIP law, let `u = voltage / v_nom`. Below `v_low_pu`,
11    /// both factors are `u²`. Between that threshold and `v_min_pu`, the
12    /// constant-power/current contributions interpolate current magnitude
13    /// from `v_low_pu` to respectively `1/v_min_pu` and `1`; the impedance
14    /// term stays `u²`. Above `v_max_pu`, their power factors are respectively
15    /// `(u/v_max_pu)²` and `u²/v_max_pu`. Inside the band they are `1` and `u`.
16    /// Above the low threshold, a positive cutoff multiplies the result by
17    /// `(1 + tanh(500 * (u - cutoff_pu))) / 2`.
18    ///
19    /// For a bounded exponential law the middle-band factors are
20    /// `u^gamma_p` and `u^gamma_q`. Both factors become `u²` below the low
21    /// threshold, `u * (low + (1/min - low) * (u-low)/(min-low))` between
22    /// low and min, and `(u/max)²` above max (using `u²` when max is zero).
23    /// These continuations need not join the exponential law continuously.
24    ///
25    /// For bounded constant power, the scale is `(u/min)²` at or below
26    /// min, `1` inside the band, and `(u/max)²` above max. An optional
27    /// current limit caps this scale at `u * current_limit_pu`.
28    ///
29    /// Returns an error for missing coefficients, invalid parameters or
30    /// nonfinite evaluation instead of manufacturing a nominal load law.
31    #[expect(
32        clippy::too_many_lines,
33        reason = "keep all branches of the bounded voltage law together for comparison with its documented equations"
34    )]
35    pub fn power_scale(&self, phase: usize, voltage: f64) -> Result<[f64; 2], String> {
36        if !voltage.is_finite() || voltage < 0.0 {
37            return Err("load voltage magnitude must be finite and nonnegative".into());
38        }
39        if matches!(self, Self::ConstantPower { .. }) {
40            return Ok([1.0; 2]);
41        }
42        let nominal = *self
43            .v_nom()
44            .get(phase)
45            .ok_or("missing nominal load voltage")?;
46        if !nominal.is_finite() || nominal <= 0.0 {
47            return Err("nominal load voltage must be positive and finite".into());
48        }
49        let voltage_pu = voltage / nominal;
50        let at = |v: &[f64]| -> Result<f64, String> {
51            v.get(phase)
52                .copied()
53                .filter(|v| v.is_finite())
54                .ok_or_else(|| "missing or nonfinite load coefficient".into())
55        };
56        let value = match self {
57            Self::ConstantPower { .. } => unreachable!(),
58            Self::ConstantCurrent { .. } => [voltage_pu; 2],
59            Self::ConstantImpedance { .. } => [voltage_pu * voltage_pu; 2],
60            Self::Exponential {
61                gamma_p, gamma_q, ..
62            } => [voltage_pu.powf(at(gamma_p)?), voltage_pu.powf(at(gamma_q)?)],
63            Self::BoundedConstantPower {
64                v_min_pu,
65                v_max_pu,
66                current_limit_pu,
67                ..
68            } => {
69                if !v_min_pu.is_finite()
70                    || *v_min_pu <= 0.0
71                    || !v_max_pu.is_finite()
72                    || *v_max_pu < *v_min_pu
73                    || current_limit_pu.is_some_and(|v| !v.is_finite() || v <= 0.0)
74                {
75                    return Err("constant-power bounds and current limit must be positive, finite and ordered".into());
76                }
77                let scale = if voltage_pu <= *v_min_pu {
78                    (voltage_pu / v_min_pu).powi(2)
79                } else if voltage_pu > *v_max_pu {
80                    (voltage_pu / v_max_pu).powi(2)
81                } else {
82                    1.0
83                };
84                [current_limit_pu.map_or(scale, |limit| scale.min(voltage_pu * limit)); 2]
85            }
86            Self::BoundedExponential {
87                gamma_p,
88                gamma_q,
89                v_low_pu,
90                v_min_pu,
91                v_max_pu,
92                ..
93            } => bounded_exponential_scale(
94                voltage_pu,
95                [at(gamma_p)?, at(gamma_q)?],
96                [*v_low_pu, *v_min_pu, *v_max_pu],
97            )?,
98            Self::Zip {
99                alpha_z,
100                alpha_i,
101                alpha_p,
102                beta_z,
103                beta_i,
104                beta_p,
105                ..
106            }
107            | Self::BoundedZip {
108                alpha_z,
109                alpha_i,
110                alpha_p,
111                beta_z,
112                beta_i,
113                beta_p,
114                ..
115            } => {
116                let active = [at(alpha_z)?, at(alpha_i)?, at(alpha_p)?];
117                let reactive = [at(beta_z)?, at(beta_i)?, at(beta_p)?];
118                let (impedance_scale, current_scale, power_scale, dropout) =
119                    if let Self::BoundedZip {
120                        v_low_pu: low,
121                        v_min_pu: min,
122                        v_max_pu: max,
123                        cutoff_pu: cutoff,
124                        ..
125                    } = self
126                    {
127                        if [low, min, max, cutoff]
128                            .iter()
129                            .any(|v| !v.is_finite() || **v < 0.0)
130                        {
131                            return Err(
132                                "load voltage thresholds must be finite and nonnegative".into()
133                            );
134                        }
135                        if voltage_pu <= *low {
136                            // Native low-voltage continuation is the full nominal
137                            // impedance, even when ZIP coefficients do not sum to one.
138                            return if (voltage_pu * voltage_pu).is_finite() {
139                                Ok([voltage_pu * voltage_pu; 2])
140                            } else {
141                                Err("load power evaluation became nonfinite".into())
142                            };
143                        }
144                        let (current_scale, power_scale) = if voltage_pu <= *min {
145                            let weight = (voltage_pu - low) / (min - low);
146                            (
147                                voltage_pu * (low + (1.0 - low) * weight),
148                                voltage_pu * (low + (1.0 / min - low) * weight),
149                            )
150                        } else if voltage_pu > *max {
151                            (
152                                voltage_pu * voltage_pu / if *max == 0.0 { 1.0 } else { *max },
153                                voltage_pu * voltage_pu / if *max == 0.0 { 1.0 } else { max * max },
154                            )
155                        } else {
156                            (voltage_pu, 1.0)
157                        };
158                        let dropout = if *cutoff > 0.0 {
159                            f64::midpoint(1.0, (500.0 * (voltage_pu - cutoff)).tanh())
160                        } else {
161                            1.0
162                        };
163                        (voltage_pu * voltage_pu, current_scale, power_scale, dropout)
164                    } else {
165                        (voltage_pu * voltage_pu, voltage_pu, 1.0, 1.0)
166                    };
167                [
168                    active[0] * impedance_scale
169                        + active[1] * current_scale
170                        + active[2] * power_scale,
171                    reactive[0] * impedance_scale
172                        + reactive[1] * current_scale
173                        + reactive[2] * power_scale,
174                ]
175                .map(|v| v * dropout)
176            }
177        };
178        if value.iter().any(|v| !v.is_finite()) {
179            Err("load power evaluation became nonfinite".into())
180        } else {
181            Ok(value)
182        }
183    }
184
185    /// Nominal law without voltage bounds: the middle-band law, with the
186    /// low-voltage transitions, high-voltage continuation and dropout removed.
187    /// Any other law is returned unchanged. A caller uses this only with an
188    /// explicit diagnostic when the destination cannot carry the full law.
189    #[must_use]
190    #[allow(clippy::float_cmp)] // Only exact coefficient identities permit this projection.
191    pub fn without_voltage_bounds(&self) -> Self {
192        if let Self::BoundedConstantPower { v_nom, .. } = self {
193            return Self::ConstantPower {
194                v_nom: v_nom.clone(),
195            };
196        }
197        if let Self::BoundedExponential {
198            v_nom,
199            gamma_p,
200            gamma_q,
201            ..
202        } = self
203        {
204            return Self::Exponential {
205                v_nom: v_nom.clone(),
206                gamma_p: gamma_p.clone(),
207                gamma_q: gamma_q.clone(),
208            };
209        }
210        let Self::BoundedZip {
211            v_nom,
212            alpha_z,
213            alpha_i,
214            alpha_p,
215            beta_z,
216            beta_i,
217            beta_p,
218            cutoff_pu,
219            ..
220        } = self
221        else {
222            return self.clone();
223        };
224        let all = |xs: &[f64], x| !xs.is_empty() && xs.iter().all(|v| *v == x);
225        if *cutoff_pu == 0.0 && all(alpha_z, 0.0) && all(beta_z, 0.0) {
226            if all(alpha_i, 0.0) && all(beta_i, 0.0) && all(alpha_p, 1.0) && all(beta_p, 1.0) {
227                return Self::ConstantPower {
228                    v_nom: v_nom.clone(),
229                };
230            }
231            if all(alpha_p, 0.0) && all(beta_p, 0.0) && all(alpha_i, 1.0) && all(beta_i, 1.0) {
232                return Self::ConstantCurrent {
233                    v_nom: v_nom.clone(),
234                };
235            }
236        }
237        Self::Zip {
238            v_nom: v_nom.clone(),
239            alpha_z: alpha_z.clone(),
240            alpha_i: alpha_i.clone(),
241            alpha_p: alpha_p.clone(),
242            beta_z: beta_z.clone(),
243            beta_i: beta_i.clone(),
244            beta_p: beta_p.clone(),
245        }
246    }
247}
248
249fn bounded_exponential_scale(
250    u: f64,
251    gamma: [f64; 2],
252    bounds: [f64; 3],
253) -> Result<[f64; 2], String> {
254    if bounds.iter().any(|v| !v.is_finite() || *v < 0.0) {
255        return Err("load voltage thresholds must be finite and nonnegative".into());
256    }
257    let [low, min, max] = bounds;
258    // Preserve the native branch order even when thresholds overlap.
259    let scale = if u <= low {
260        u * u
261    } else if u <= min {
262        u * (low + (1.0 / min - low) * (u - low) / (min - low))
263    } else if u > max {
264        u * u / if max == 0.0 { 1.0 } else { max * max }
265    } else {
266        return Ok(gamma.map(|exponent| u.powf(exponent)));
267    };
268    Ok([scale; 2])
269}