powerio-dist 0.11.7

Multiconductor distribution network model and lossless converters for OpenDSS, PMD JSON, BMOPF JSON, and distribution CIM.
Documentation
use super::{Writer, complex_literal, flags, phases, unsupported};
use crate::glm::connections::{EXPLICIT, reference};
use crate::{Configuration, DistLoad, DistLoadVoltageModel, DistLocationTarget};
use num_complex::Complex64;
use powerio_core::Error;
use std::fmt::Write as _;

impl Writer<'_> {
    pub(super) fn pair_load(&mut self, load: &DistLoad) -> Result<(), Error> {
        self.pair_load_with_target(load, DistLocationTarget::Load)
    }

    pub(super) fn pair_load_with_target(
        &mut self,
        load: &DistLoad,
        target: DistLocationTarget,
    ) -> Result<(), Error> {
        let connections = connections(load)?;
        let active = phases(
            &load
                .terminal_map
                .iter()
                .filter(|t| t.as_str() != "n")
                .cloned()
                .collect::<Vec<_>>(),
        )?;
        let available = self
            .phases
            .get(load.bus.as_str())
            .ok_or_else(|| unsupported(format!("load {} has no exact bus identity", load.name)))?;
        if active.iter().any(|p| !available.contains(p))
            || !load.extras.is_empty()
            || load.p_nom.len() != connections.len()
            || load.q_nom.len() != connections.len()
        {
            return Err(unsupported(format!(
                "load {} has incomplete terminal/power vectors or uninterpreted extras",
                load.name
            )));
        }
        let (law, nominal) = match &load.voltage_model {
            DistLoadVoltageModel::ConstantPower { v_nom } => ("power", v_nom),
            DistLoadVoltageModel::ConstantCurrent { v_nom } => ("current", v_nom),
            DistLoadVoltageModel::ConstantImpedance { v_nom } => ("impedance", v_nom),
            _ => {
                return Err(unsupported(
                    "phase-pair load needs another voltage-law mapping",
                ));
            }
        };
        if law != "power"
            && (nominal.len() != connections.len()
                || nominal.iter().any(|v| !v.is_finite() || *v <= 0.0))
        {
            return Err(unsupported(
                "phase-pair load needs positive finite per-connection nominal voltages",
            ));
        }
        let _ = writeln!(
            self.out,
            "object load {{ name \"{}\"; parent \"{}\"; phases {}N; nominal_voltage {};",
            load.name,
            load.bus,
            flags(&active),
            self.nominal[load.bus.as_str()]
        );
        self.coordinates(target, &load.name);
        for (i, (a, b)) in connections.iter().enumerate() {
            let (suffix, from, to) = EXPLICIT
                .into_iter()
                .find(|(_, from, to)| {
                    let x = (from + 1).to_string();
                    let y = to.map_or_else(|| "n".to_owned(), |p| (p + 1).to_string());
                    (a.as_str() == x && b.as_str() == y) || (a.as_str() == y && b.as_str() == x)
                })
                .ok_or_else(|| unsupported("phase-pair load has no native explicit connection"))?;
            if a.as_str() != (from + 1).to_string() {
                self.diagnostics.push(&crate::diagnostics::codes::EMIT_GLM_VALUE_REBASED,
                    format!("load {} channel {i}: terminal orientation normalized to {suffix}; its two-terminal power law is unchanged", load.name));
            }
            let power = Complex64::new(load.p_nom[i], load.q_nom[i]);
            let base = reference(1.0, from, to);
            let value = match law {
                "power" => power,
                "current" => base / base.norm() * power.conj() / nominal[i],
                _ if power == Complex64::default() => Complex64::default(),
                _ => nominal[i] * nominal[i] / power.conj(),
            };
            if power != Complex64::default() && value == Complex64::default() {
                return Err(unsupported(
                    "phase-pair load coefficient underflows to zero",
                ));
            }
            let _ = writeln!(
                self.out,
                "    constant_{law}_{suffix} {};",
                complex_literal(value)?
            );
        }
        self.out.push_str("}\n");
        if connections.len() > 1 {
            self.diagnostics.push(&crate::diagnostics::codes::EMIT_GLM_VALUE_REBASED,
                format!("load {}: delta channels emitted as explicit phase-pair properties; reading separates their component identities", load.name));
        }
        Ok(())
    }
}

fn connections(load: &DistLoad) -> Result<Vec<(&String, &String)>, Error> {
    let result = match (load.configuration, load.terminal_map.as_slice()) {
        (Configuration::SinglePhase | Configuration::Delta, [a, b]) => vec![(a, b)],
        (Configuration::Delta, [a, b, c]) => vec![(a, b), (b, c), (c, a)],
        (Configuration::Wye, map) if map.len() >= 2 && map.last().is_some_and(|n| n == "n") => map
            [..map.len() - 1]
            .iter()
            .map(|p| (p, &map[map.len() - 1]))
            .collect(),
        _ => {
            return Err(unsupported(format!(
                "load {} needs an additional connection model",
                load.name
            )));
        }
    };
    if load.configuration == Configuration::Wye
        && result
            .iter()
            .map(|(p, _)| *p)
            .collect::<std::collections::BTreeSet<_>>()
            .len()
            != result.len()
    {
        return Err(unsupported("wye injection has repeated phase connections"));
    }
    Ok(result)
}