CgmesTopologyKindTest.java

/**
 * Copyright (c) 2025, RTE (http://www.rte-france.com)
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 * SPDX-License-Identifier: MPL-2.0
 */
package com.powsybl.cgmes.conversion.test.export;

import com.powsybl.cgmes.conversion.CgmesExport;
import com.powsybl.cgmes.conversion.Conversion;
import com.powsybl.cgmes.conversion.test.ConversionUtil;
import com.powsybl.cgmes.extensions.CgmesTopologyKind;
import com.powsybl.cgmes.model.CgmesNames;
import com.powsybl.cgmes.model.CgmesNamespace;
import com.powsybl.commons.test.AbstractSerDeTest;
import com.powsybl.iidm.network.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import java.util.function.Predicate;

import static com.powsybl.cgmes.conversion.test.ConversionUtil.getElement;
import static com.powsybl.cgmes.conversion.test.ConversionUtil.getElementCount;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

/**
 * @author Romain Courtier {@literal <romain.courtier at rte-france.com>}
 */
class CgmesTopologyKindTest extends AbstractSerDeTest {

    @ParameterizedTest
    @EnumSource(value = CgmesTopologyKind.class, names = {"NODE_BREAKER", "BUS_BRANCH"})
    void cgmesTopologyKindTest(CgmesTopologyKind topologyKind) throws IOException {
        Network network = mixedTopologyNetwork();

        // Assert the CIM 16 and CIM 100 exports with given topology kind are valid
        assertValidExport(network, topologyKind, false);
        assertValidExport(network, topologyKind, true);
    }

    @Test
    void nonRetainedOpenTest() throws IOException {
        Properties exportParams = new Properties();
        exportParams.put(CgmesExport.TOPOLOGY_KIND, "BUS_BRANCH");

        // We start with a network that has two connected components
        Network network = nonRetainedOpenNetwork();
        assertEquals(2, network.getBusView().getConnectedComponents().size());

        Path outputCgmes = Files.createDirectories(tmpDir.resolve("cgmes-non-retained-open"));
        network.write("CGMES", exportParams, outputCgmes);
        Network network1 = Network.read(outputCgmes);
        assertEquals(2, network1.getBusView().getConnectedComponents().size());

        // If we close all switches in our original IIDM network we end up with only one connected component
        network.getSwitchStream().forEach(sw -> sw.setOpen(false));
        assertEquals(1, network.getBusView().getConnectedComponents().size());

        // Even if we close all switches in the re-imported network we will have two connected components
        // In the exported network we can not get all equipment in a single connected component
        network1.getSwitchStream().forEach(sw -> sw.setOpen(false));
        assertEquals(2, network1.getBusView().getConnectedComponents().size());
        // If we force the reconnection of the line we have 3 connected components
        network1.getLine("LN").getTerminal1().connect();
        network1.getLine("LN").getTerminal2().connect();
        assertEquals(3, network1.getBusView().getConnectedComponents().size());
    }

    @Test
    void nonRetainedClosedTest() throws IOException {
        Properties exportParams = new Properties();
        exportParams.put(CgmesExport.TOPOLOGY_KIND, "BUS_BRANCH");

        // We start with a network that has two connected components
        Network network = nonRetainedOpenNetwork();
        assertEquals(2, network.getBusView().getConnectedComponents().size());
        // Before export, we close all non-retained switches, we still have two connected components
        network.getSwitchStream().filter(Predicate.not(Switch::isRetained)).forEach(sw -> sw.setOpen(false));
        assertEquals(2, network.getBusView().getConnectedComponents().size());

        // Export to CGMES as bus/branch and recover the exported network
        Path outputCgmes = Files.createDirectories(tmpDir.resolve("cgmes-non-retained-closed"));
        network.write("CGMES", exportParams, outputCgmes);
        Network network1 = Network.read(outputCgmes);
        assertEquals(2, network1.getBusView().getConnectedComponents().size());

        // Now if we close all switches in the re-imported network and force the line as connected,
        // we end up with only one connected component
        network1.getSwitchStream().forEach(sw -> sw.setOpen(false));
        network1.getLine("LN").getTerminal1().connect();
        network1.getLine("LN").getTerminal2().connect();
        assertEquals(1, network1.getBusView().getConnectedComponents().size());
    }

    private void assertValidExport(Network network, CgmesTopologyKind topologyKind, boolean cim100Export) throws IOException {
        // Build the export parameters
        Properties exportParams = new Properties();
        exportParams.put(CgmesExport.TOPOLOGY_KIND, topologyKind.name());
        if (cim100Export) {
            exportParams.put(CgmesExport.CIM_VERSION, "100");
        }

        // Export to CGMES
        String eqFile = ConversionUtil.writeCgmesProfile(network, "EQ", tmpDir, exportParams);
        String sshFile = ConversionUtil.writeCgmesProfile(network, "SSH", tmpDir, exportParams);
        String svFile = ConversionUtil.writeCgmesProfile(network, "SV", tmpDir, exportParams);
        String tpFile = ConversionUtil.writeCgmesProfile(network, "TP", tmpDir, exportParams);

        // Assert the exports are valid
        assertValidProfileInHeader(eqFile, topologyKind, cim100Export);
        assertValidCim16EquipmentOperationElements(eqFile, sshFile, svFile, topologyKind, cim100Export);
        assertValidConnectivityElements(eqFile, topologyKind, cim100Export);
        assertValidTopologyElements(tpFile, topologyKind, cim100Export);
    }

    private void assertValidProfileInHeader(String eqFile, CgmesTopologyKind topologyKind, boolean cim100Export) {
        if (topologyKind == CgmesTopologyKind.NODE_BREAKER && !cim100Export) {
            assertTrue(eqFile.contains(CgmesNamespace.CIM_16_EQ_OPERATION_PROFILE));
        } else {
            assertFalse(eqFile.contains(CgmesNamespace.CIM_16_EQ_OPERATION_PROFILE));
            assertFalse(eqFile.contains(CgmesNamespace.CIM_100_EQ_OPERATION_PROFILE));
        }
    }

    private void assertValidCim16EquipmentOperationElements(String eqFile, String sshFile, String svFile, CgmesTopologyKind topologyKind, boolean cim100Export) {
        if (topologyKind == CgmesTopologyKind.NODE_BREAKER || cim100Export) {
            assertEquals(1, getElementCount(eqFile, "CurrentLimit"));
            assertEquals(1, getElementCount(eqFile, "ActivePowerLimit"));
            assertEquals(2, getElementCount(eqFile, "ApparentPowerLimit"));
            assertEquals(1, getElementCount(eqFile, "StationSupply"));
            assertEquals(1, getElementCount(eqFile, "GroundDisconnector"));
            assertEquals(1, getElementCount(eqFile, "LoadArea"));
            assertEquals(1, getElementCount(eqFile, "SubLoadArea"));
            assertTrue(getElement(eqFile, "ConformLoadGroup", "ConformLoad_LG").contains("cim:LoadGroup.SubLoadArea"));
            assertTrue(getElement(eqFile, "NonConformLoadGroup", "NonConformLoad_LG").contains("cim:LoadGroup.SubLoadArea"));
            assertTrue(getElement(eqFile, "ControlArea", "Interchange").contains("cim:ControlArea.EnergyArea"));
            assertEquals(1, getElementCount(sshFile, "StationSupply"));
            assertEquals(1, getElementCount(sshFile, "GroundDisconnector"));
            assertEquals(7, getElementCount(svFile, "SvStatus"));
        } else {
            assertEquals(1, getElementCount(eqFile, "CurrentLimit")); // CurrentLimit are NOT part of CIM16 EQ_OP
            assertEquals(0, getElementCount(eqFile, "ActivePowerLimit"));
            assertEquals(0, getElementCount(eqFile, "ApparentPowerLimit"));
            assertEquals(0, getElementCount(eqFile, "StationSupply"));
            assertEquals(0, getElementCount(eqFile, "GroundDisconnector"));
            assertEquals(0, getElementCount(eqFile, "LoadArea"));
            assertEquals(0, getElementCount(eqFile, "SubLoadArea"));
            assertFalse(getElement(eqFile, "ConformLoadGroup", "ConformLoad_LG").contains("cim:LoadGroup.SubLoadArea"));
            assertFalse(getElement(eqFile, "NonConformLoadGroup", "NonConformLoad_LG").contains("cim:LoadGroup.SubLoadArea"));
            assertFalse(getElement(eqFile, "ControlArea", "Interchange").contains("cim:ControlArea.EnergyArea"));
            assertEquals(0, getElementCount(sshFile, "StationSupply"));
            assertEquals(0, getElementCount(sshFile, "GroundDisconnector"));
            assertEquals(0, getElementCount(svFile, "SvStatus"));
        }
    }

    private void assertValidConnectivityElements(String eqFile, CgmesTopologyKind topologyKind, boolean cim100Export) {
        if (topologyKind == CgmesTopologyKind.NODE_BREAKER) {
            assertEquals(5, getElementCount(eqFile, "ConnectivityNode"));
            assertEquals(3, getElementCount(eqFile, "Breaker"));
        } else {
            // In case of a CIM16 bus-branch export, the buses from the BusBreakerView aren't exported as ConnectivityNode
            int connectivityNodesCount = cim100Export ? 4 : 0;
            assertEquals(connectivityNodesCount, getElementCount(eqFile, "ConnectivityNode"));
            assertEquals(1, getElementCount(eqFile, "Breaker")); // because one is non-retained
        }
        assertValidTerminalCount(eqFile, topologyKind, cim100Export);
    }

    private void assertValidTopologyElements(String tpFile, CgmesTopologyKind topologyKind, boolean cim100Export) {
        // The number of topological nodes is independent from the topology kind or cim version
        assertEquals(4, getElementCount(tpFile, "TopologicalNode"));
        assertValidTerminalCount(tpFile, topologyKind, cim100Export);
    }

    private void assertValidTerminalCount(String eqOrTpFile, CgmesTopologyKind topologyKind, boolean cim100Export) {
        // BusbarSection (2), Generator (1), ACLineSegment (2), ConformLoad (1), NonConformLoad (1), retained Breaker (2)
        // terminals are always exported
        int terminalCount = 9;
        if (topologyKind == CgmesTopologyKind.NODE_BREAKER || cim100Export) {
            // StationSupply (1), GroundDisconnector (2) terminals are exported if not a CIM16 bus-branch export
            terminalCount += 3;
        }
        if (topologyKind == CgmesTopologyKind.NODE_BREAKER) {
            // non-retained Breaker (4) terminals are exported if not a bus-branch export
            terminalCount += 4;
        }

        assertEquals(terminalCount, getElementCount(eqOrTpFile, "Terminal"));
    }

    private Network mixedTopologyNetwork() {
        Network network = NetworkFactory.findDefault().createNetwork("network", "test");

        //    VL_1: Bus-Breaker                            VL_2: Node-Breaker
        //
        //                           ________LN________
        //                           |                |    BBS_2A    __BK2__     BBS_2B
        // ____(GEN-BUS)____BK1____(BUS)_           _(1)____(0)______|     |______(3)________GRDIS__
        //    |         |                                    |       |_BK3_|       |           |
        //    |         |                                   (2)                   (4)         (5)
        //   GEN       AUX                                  LD_C                 LD_NC

        // Create Substation 1 with a Generator and a station supply Load
        Substation substation1 = network.newSubstation()
                .setId("ST_1")
                .add();
        VoltageLevel voltageLevel1 = substation1.newVoltageLevel()
                .setId("VL_1")
                .setNominalV(400.0)
                .setTopologyKind(TopologyKind.BUS_BREAKER)
                .add();
        voltageLevel1.getBusBreakerView().newBus()
                .setId("BUS")
                .add();
        voltageLevel1.getBusBreakerView().newBus()
                .setId("GEN-BUS")
                .add();
        voltageLevel1.getBusBreakerView().newSwitch()
                .setId("BK1")
                .setBus1("BUS")
                .setBus2("GEN-BUS")
                .setOpen(false)
                .add();
        voltageLevel1.newGenerator()
                .setId("GEN")
                .setBus("GEN-BUS")
                .setTargetP(1.0)
                .setTargetQ(1.0)
                .setMinP(0.0)
                .setMaxP(2.0)
                .setVoltageRegulatorOn(false)
                .add();
        voltageLevel1.newLoad()
                .setId("AUX")
                .setBus("GEN-BUS")
                .setP0(0.0)
                .setQ0(0.0)
                .setLoadType(LoadType.AUXILIARY)
                .add()
                .setProperty(Conversion.PROPERTY_CGMES_ORIGINAL_CLASS, CgmesNames.STATION_SUPPLY);

        // Create Substation 2 with a BusbarSection, a Load and a GroundDisconnector
        Substation substation2 = network.newSubstation()
                .setId("ST_2")
                .add();
        VoltageLevel voltageLevel2 = substation2.newVoltageLevel()
                .setId("VL_2")
                .setNominalV(400.0)
                .setTopologyKind(TopologyKind.NODE_BREAKER)
                .add();
        voltageLevel2.getNodeBreakerView().newBusbarSection()
                .setId("BBS_2A")
                .setNode(0)
                .add();
        voltageLevel2.getNodeBreakerView().newBusbarSection()
                .setId("BBS_2B")
                .setNode(3)
                .add();
        voltageLevel2.getNodeBreakerView().newSwitch()
                .setId("BK2")
                .setNode1(0)
                .setNode2(3)
                .setKind(SwitchKind.BREAKER)
                .setOpen(false)
                .setRetained(false)
                .add();
        voltageLevel2.getNodeBreakerView().newSwitch()
                .setId("BK3")
                .setNode1(0)
                .setNode2(3)
                .setKind(SwitchKind.BREAKER)
                .setOpen(false)
                .setRetained(true) // will be considered non-retained by the export
                                   // because it has the same topological nodes on each side
                .add();
        voltageLevel2.newLoad()
                .setId("LD_C")
                .setNode(2)
                .setP0(1.0)
                .setQ0(0.0)
                .add()
                .setProperty(Conversion.PROPERTY_CGMES_ORIGINAL_CLASS, CgmesNames.CONFORM_LOAD);
        voltageLevel2.newLoad()
                .setId("LD_NC")
                .setNode(4)
                .setP0(0.0)
                .setQ0(1.0)
                .add()
                .setProperty(Conversion.PROPERTY_CGMES_ORIGINAL_CLASS, CgmesNames.NONCONFORM_LOAD);
        voltageLevel2.getNodeBreakerView().newSwitch()
                .setId("GRDIS")
                .setNode1(3)
                .setNode2(5)
                .setKind(SwitchKind.DISCONNECTOR)
                .setOpen(false)
                .setRetained(true)
                .add()
                .setProperty(Conversion.PROPERTY_CGMES_ORIGINAL_CLASS, "GroundDisconnector");

        // Create a Line between substations 1 and 2
        Line line = network.newLine()
                .setId("LN")
                .setR(0.1)
                .setX(1.0)
                .setG1(0.0)
                .setG2(0.0)
                .setB1(0.0)
                .setB2(0.0)
                .setVoltageLevel1("VL_1")
                .setVoltageLevel2("VL_2")
                .setBus1("BUS")
                .setNode2(1)
                .add();

        // Create ControlArea
        network.newArea()
                .setId("Interchange")
                .setAreaType("ControlAreaTypeKind.Interchange")
                .add();

        // Create connectivity
        voltageLevel2.getNodeBreakerView().newInternalConnection().setNode1(0).setNode2(1).add();
        voltageLevel2.getNodeBreakerView().newInternalConnection().setNode1(0).setNode2(2).add();
        voltageLevel2.getNodeBreakerView().newInternalConnection().setNode1(3).setNode2(4).add();

        // Add limits
        line.newCurrentLimits1().setPermanentLimit(100).add();
        line.newApparentPowerLimits1().setPermanentLimit(100).add();
        line.newActivePowerLimits2().setPermanentLimit(100).add();
        line.newApparentPowerLimits2().setPermanentLimit(100).add();

        return network;
    }

    private Network nonRetainedOpenNetwork() {
        Network network = NetworkFactory.findDefault().createNetwork("network-non-retained-open", "test");

        //  --------------  LN  ------------------
        //  |                                    |
        // (2)                                  (2)
        //  |                                    |
        // [ ] BK_1, retained, open             [ ] BK_2, retained, open
        //  |                                    |
        // (1)                                  (1)
        //  |                                    |
        //  / DIS_1, open                        / DIS_2, open
        //  |                                    |
        // (0)== BB_1                           (0)== BB_2
        //  |                                    |
        // (10)                                 (10)
        //  |                                    |
        // GEN                                  LOAD

        Substation substation1 = network.newSubstation().setId("ST_1").add();
        VoltageLevel voltageLevel1 = substation1.newVoltageLevel().setId("VL_1").setNominalV(400.0)
                .setTopologyKind(TopologyKind.NODE_BREAKER).add();
        voltageLevel1.getNodeBreakerView().newBusbarSection().setId("BB_1")
                .setNode(0).add();
        voltageLevel1.getNodeBreakerView().newSwitch().setId("DIS_1")
                .setNode1(0)
                .setNode2(1)
                .setOpen(true).setRetained(false).setKind(SwitchKind.DISCONNECTOR).add();
        voltageLevel1.getNodeBreakerView().newSwitch().setId("BK_1")
                .setNode1(1)
                .setNode2(2)
                .setOpen(true).setRetained(true).setKind(SwitchKind.BREAKER).add();
        voltageLevel1.newGenerator().setId("GEN")
                .setNode(10)
                .setTargetP(1.0).setTargetQ(1.0).setMinP(0.0).setMaxP(2.0).setVoltageRegulatorOn(false).add();
        voltageLevel1.getNodeBreakerView().newInternalConnection()
                .setNode1(0)
                .setNode2(10).add();

        Substation substation2 = network.newSubstation().setId("ST_2").add();
        VoltageLevel voltageLevel2 = substation2.newVoltageLevel().setId("VL_2").setNominalV(400.0)
                .setTopologyKind(TopologyKind.NODE_BREAKER).add();
        voltageLevel2.getNodeBreakerView().newBusbarSection().setId("BB_2")
                .setNode(0).add();
        voltageLevel2.getNodeBreakerView().newSwitch().setId("DIS_2")
                .setNode1(0)
                .setNode2(1)
                .setOpen(true).setRetained(false).setKind(SwitchKind.DISCONNECTOR).add();
        voltageLevel2.getNodeBreakerView().newSwitch().setId("BK_2")
                .setNode1(1)
                .setNode2(2)
                .setOpen(true).setRetained(true).setKind(SwitchKind.BREAKER).add();
        voltageLevel2.newLoad().setId("LOAD")
                .setNode(10)
                .setP0(1.0).setQ0(1.0).add();
        voltageLevel2.getNodeBreakerView().newInternalConnection().setNode1(0).setNode2(10).add();

        network.newLine().setId("LN")
                .setVoltageLevel1("VL_1")
                .setVoltageLevel2("VL_2")
                .setNode1(2)
                .setNode2(2)
                .setR(0.1).setX(1.0).setG1(0.0).setG2(0.0).setB1(0.0).setB2(0.0).add();

        return network;
    }
}