1use crate::{IndexCore, IndexedNetwork, SparseMatrix};
22use powerio_core::Error;
23use powerio_tx::BusId;
24
25use powerio_prob::diagnostics::codes;
26use powerio_prob::{AcPfInstance, BalancedOperatingPointQuantity, OperatingPoint};
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum VoltageCoordinates {
32 #[default]
34 Polar,
35 Cartesian,
37}
38
39#[derive(Clone, Debug)]
42pub struct PowerFlowJacobian {
43 coordinates: VoltageCoordinates,
44 bus_ids: Vec<BusId>,
45 matrix: SparseMatrix,
46 conductance: SparseMatrix,
48 susceptance: SparseMatrix,
49 index_core: IndexCore,
54}
55
56impl PowerFlowJacobian {
57 #[must_use]
61 pub const fn matrix(&self) -> &SparseMatrix {
62 &self.matrix
63 }
64
65 #[must_use]
68 pub fn bus_ids(&self) -> &[BusId] {
69 &self.bus_ids
70 }
71
72 #[must_use]
74 pub const fn coordinates(&self) -> VoltageCoordinates {
75 self.coordinates
76 }
77
78 pub fn update(
86 &mut self,
87 instance: &AcPfInstance,
88 point: &OperatingPoint<powerio_tx::BalancedNetwork>,
89 ) -> Result<(), Error> {
90 let view = IndexedNetwork::with_core(instance.network(), &self.index_core);
95 if view.n() != self.bus_ids.len() {
99 return Err(Error::new(
100 &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
101 "the instance's lowered bus axis does not match the assembled Jacobian's",
102 ));
103 }
104 let voltages = point_voltages(instance, point, &self.bus_ids, &view, false)?;
109 fill_values(
110 &mut self.matrix,
111 &self.conductance,
112 &self.susceptance,
113 &voltages,
114 self.coordinates,
115 );
116 Ok(())
117 }
118}
119
120struct Voltages {
122 magnitude: Vec<f64>,
123 angle: Vec<f64>,
124}
125
126pub fn calc_power_flow_jacobian(
134 instance: &AcPfInstance,
135 point: &OperatingPoint<powerio_tx::BalancedNetwork>,
136 coordinates: VoltageCoordinates,
137) -> Result<PowerFlowJacobian, Error> {
138 let network = instance.network();
139 let index_core = IndexCore::build(network);
143 let view = IndexedNetwork::with_core(network, &index_core);
148 let bus_ids: Vec<BusId> = (0..view.n()).map(|idx| view.bus_id(idx)).collect();
149 let voltages = point_voltages(instance, point, &bus_ids, &view, true)?;
152
153 let parts = crate::calc_admittance_matrix(
154 &view,
155 &crate::BuildOptions {
156 skip_zero_impedance: false,
157 ..Default::default()
158 },
159 )
160 .map_err(|error| {
161 Error::new(
162 &codes::BUILD_OPERATOR_ZERO_IMPEDANCE,
163 format!(
164 "the admittance matrix the Jacobian derives from cannot be built: {error}; resolve zero impedance branches explicitly with merge_zero_impedance_buses"
165 ),
166 )
167 })?;
168
169 let n = bus_ids.len();
170 let mut pattern = crate::matrix::triplet::CooBuilder::new(2 * n);
174 for (row, row_vec) in parts.g.outer_iterator().enumerate() {
175 for (column, _) in row_vec.iter() {
176 pattern.add(row, column, 1.0);
177 pattern.add(row, n + column, 1.0);
178 pattern.add(n + row, column, 1.0);
179 pattern.add(n + row, n + column, 1.0);
180 }
181 }
182 for (row, row_vec) in parts.b.outer_iterator().enumerate() {
183 for (column, _) in row_vec.iter() {
184 pattern.add(row, column, 1.0);
185 pattern.add(row, n + column, 1.0);
186 pattern.add(n + row, column, 1.0);
187 pattern.add(n + row, n + column, 1.0);
188 }
189 }
190 for row in 0..n {
191 pattern.add(row, row, 1.0);
192 pattern.add(row, n + row, 1.0);
193 pattern.add(n + row, row, 1.0);
194 pattern.add(n + row, n + row, 1.0);
195 }
196
197 let mut jacobian = PowerFlowJacobian {
198 coordinates,
199 bus_ids,
200 matrix: pattern.finish_csr(),
201 conductance: parts.g,
202 susceptance: parts.b,
203 index_core,
204 };
205 fill_values(
206 &mut jacobian.matrix,
207 &jacobian.conductance,
208 &jacobian.susceptance,
209 &voltages,
210 coordinates,
211 );
212 Ok(jacobian)
213}
214
215fn point_voltages(
226 instance: &AcPfInstance,
227 point: &OperatingPoint<powerio_tx::BalancedNetwork>,
228 bus_ids: &[BusId],
229 view: &IndexedNetwork<'_>,
230 validate_identities: bool,
231) -> Result<Voltages, Error> {
232 let raw_buses = instance.network().buses();
240 let read_bus_values = |values: powerio_prob::OperatingPointValues<'_>| {
241 if values.len() != raw_buses.len() {
242 return Err(Error::new(
243 &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
244 "the operating point's network does not share the instance's bus identities",
245 ));
246 }
247 let identities_match = values
248 .clone()
249 .zip(raw_buses)
250 .all(|((id, _), bus)| id.parse::<usize>().is_ok_and(|id| id == bus.id.0));
251 if validate_identities && !identities_match {
252 return Err(Error::new(
253 &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
254 "the operating point's network does not share the instance's bus identities",
255 ));
256 } else if !validate_identities {
257 debug_assert!(
258 identities_match,
259 "operating point bus identities do not match the instance's; this pairing \
260 is checked once when the Jacobian is built and trusted on every refresh"
261 );
262 }
263 Ok(values.map(|(_, value)| value).collect::<Vec<_>>())
264 };
265
266 let (Some(magnitude_values), Some(angle_values)) = (
269 point.values(BalancedOperatingPointQuantity::BusVoltageMagnitude),
270 point.values(BalancedOperatingPointQuantity::BusVoltageAngle),
271 ) else {
272 return Err(Error::new(
273 &codes::BUILD_OPERATING_POINT_SHAPE_MISMATCH,
274 "the operating point does not state a complete complex voltage at every bus; \
275 the Jacobian needs both quantities",
276 ));
277 };
278
279 let raw_n = raw_buses.len();
280 let mut magnitude = read_bus_values(magnitude_values)?;
281 let mut angle = read_bus_values(angle_values)?;
282 magnitude.reserve(bus_ids.len() - raw_n);
283 angle.reserve(bus_ids.len() - raw_n);
284 for (idx, &bus) in bus_ids.iter().enumerate().skip(raw_n) {
285 let star = view
289 .network()
290 .buses()
291 .get(idx)
292 .filter(|star| star.id == bus);
293 let Some(star) = star else {
294 return Err(Error::new(
295 &codes::BUILD_OPERATING_POINT_IDENTITY_UNKNOWN,
296 "the operating point's network does not share the instance's bus identities",
297 ));
298 };
299 magnitude.push(star.vm);
300 angle.push(view.to_radians(star.va));
301 }
302 Ok(Voltages { magnitude, angle })
303}
304
305#[allow(clippy::many_single_char_names)] #[allow(clippy::too_many_lines)] #[allow(clippy::match_same_arms)] fn fill_values(
313 matrix: &mut SparseMatrix,
314 conductance: &SparseMatrix,
315 susceptance: &SparseMatrix,
316 voltages: &Voltages,
317 coordinates: VoltageCoordinates,
318) {
319 let n = voltages.magnitude.len();
320 let vm = &voltages.magnitude;
321 let va = &voltages.angle;
322
323 let mut admittance_rows: Vec<Vec<(usize, f64, f64)>> = vec![Vec::new(); n];
325 for (row, row_vec) in conductance.outer_iterator().enumerate() {
326 for (column, &g) in row_vec.iter() {
327 admittance_rows[row].push((column, g, 0.0));
328 }
329 }
330 for (row, row_vec) in susceptance.outer_iterator().enumerate() {
331 for (column, &b) in row_vec.iter() {
332 match admittance_rows[row].binary_search_by_key(&column, |entry| entry.0) {
333 Ok(position) => admittance_rows[row][position].2 = b,
334 Err(position) => admittance_rows[row].insert(position, (column, 0.0, b)),
335 }
336 }
337 }
338 let admittance_at = |k: usize, m: usize| -> (f64, f64) {
339 match admittance_rows[k].binary_search_by_key(&m, |entry| entry.0) {
340 Ok(position) => {
341 let (_, g, b) = admittance_rows[k][position];
342 (g, b)
343 }
344 Err(_) => (0.0, 0.0),
345 }
346 };
347
348 let mut current_re = vec![0.0; n];
351 let mut current_im = vec![0.0; n];
352 for (k, row) in admittance_rows.iter().enumerate() {
353 let mut ir = 0.0;
354 let mut ii = 0.0;
355 for &(m, g, b) in row {
356 let (sin, cos) = va[m].sin_cos();
357 let vr = vm[m] * cos;
358 let vi = vm[m] * sin;
359 ir += g * vr - b * vi;
360 ii += g * vi + b * vr;
361 }
362 current_re[k] = ir;
363 current_im[k] = ii;
364 }
365 let p: Vec<f64> = (0..n)
366 .map(|k| vm[k] * va[k].cos() * current_re[k] + vm[k] * va[k].sin() * current_im[k])
367 .collect();
368 let q: Vec<f64> = (0..n)
369 .map(|k| vm[k] * va[k].sin() * current_re[k] - vm[k] * va[k].cos() * current_im[k])
370 .collect();
371
372 for (row, mut row_vec) in matrix.outer_iterator_mut().enumerate() {
373 let k = row % n;
374 let reactive_row = row >= n;
375 for (column, value) in row_vec.iter_mut() {
376 let m = column % n;
377 let magnitude_column = column >= n;
378 let (g, b) = admittance_at(k, m);
379 *value = match coordinates {
380 VoltageCoordinates::Polar => {
381 if k == m {
382 match (reactive_row, magnitude_column) {
384 (false, false) => -q[k] - b * vm[k] * vm[k],
385 (true, false) => p[k] - g * vm[k] * vm[k],
386 (false, true) => {
387 let over = if vm[k] == 0.0 { 0.0 } else { p[k] / vm[k] };
388 over + g * vm[k]
389 }
390 (true, true) => {
391 let over = if vm[k] == 0.0 { 0.0 } else { q[k] / vm[k] };
392 over - b * vm[k]
393 }
394 }
395 } else {
396 let (sin, cos) = (va[k] - va[m]).sin_cos();
397 let odd = g * sin - b * cos;
398 let even = g * cos + b * sin;
399 match (reactive_row, magnitude_column) {
400 (false, false) => vm[k] * vm[m] * odd,
401 (true, false) => -vm[k] * vm[m] * even,
402 (false, true) => vm[k] * even,
403 (true, true) => vm[k] * odd,
404 }
405 }
406 }
407 VoltageCoordinates::Cartesian => {
408 let vr_k = vm[k] * va[k].cos();
411 let vi_k = vm[k] * va[k].sin();
412 let real_part = vr_k * g + vi_k * b;
413 let imag_part = vi_k * g - vr_k * b;
414 let mut entry = match (reactive_row, magnitude_column) {
415 (false, false) => real_part,
416 (true, false) => imag_part,
417 (false, true) => imag_part,
418 (true, true) => -real_part,
419 };
420 if k == m {
421 entry += match (reactive_row, magnitude_column) {
422 (false, false) => current_re[k],
423 (true, false) => -current_im[k],
424 (false, true) => current_im[k],
425 (true, true) => current_re[k],
426 };
427 }
428 entry
429 }
430 };
431 }
432 }
433}