From c5ed5a74104dcb7d2e598aacbda68d2702be3430 Mon Sep 17 00:00:00 2001 From: Olivier Perrin Date: Tue, 25 Feb 2025 11:08:28 +0100 Subject: [PATCH] Add backward compatibility for CgmesControlAreas extension deserialization (#3298) * Add a backward compatibility SerDe for removed "CgmesControlAreas" extension * Skip CgmesControlArea if an Area with the same id already exists * Improve test coverage: Boundary on HVDC + side * Code review * Make CgmesControlAreasSerDe non versionable * Adaptation after rebase: TreeDataReader.skipChildNodes => skipNode Signed-off-by: Olivier Perrin --- .../compatibility/CgmesControlAreasSerDe.java | 153 ++++++++++++++++++ .../CgmesControlAreasSerDeTest.java | 125 ++++++++++++++ .../resources/eurostag_cgmes_control_area.xml | 19 +++ 3 files changed, 297 insertions(+) create mode 100644 cgmes/cgmes-extensions/src/main/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDe.java create mode 100644 cgmes/cgmes-extensions/src/test/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDeTest.java diff --git a/cgmes/cgmes-extensions/src/main/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDe.java b/cgmes/cgmes-extensions/src/main/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDe.java new file mode 100644 index 00000000000..dd564892010 --- /dev/null +++ b/cgmes/cgmes-extensions/src/main/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDe.java @@ -0,0 +1,153 @@ +/** + * 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.extensions.compatibility; + +import com.google.auto.service.AutoService; +import com.google.common.base.Strings; +import com.powsybl.cgmes.model.CgmesNames; +import com.powsybl.commons.PowsyblException; +import com.powsybl.commons.extensions.AbstractExtensionSerDe; +import com.powsybl.commons.extensions.Extension; +import com.powsybl.commons.extensions.ExtensionSerDe; +import com.powsybl.commons.io.DeserializerContext; +import com.powsybl.commons.io.SerializerContext; +import com.powsybl.commons.io.TreeDataReader; +import com.powsybl.iidm.network.*; +import com.powsybl.iidm.serde.NetworkDeserializerContext; +import com.powsybl.iidm.serde.TerminalRefSerDe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.OptionalDouble; + +/** + * @author Olivier Perrin {@literal } + */ +@AutoService(ExtensionSerDe.class) +public class CgmesControlAreasSerDe extends AbstractExtensionSerDe { + + private static final Logger LOGGER = LoggerFactory.getLogger(CgmesControlAreasSerDe.class); + + private static final String CONTROL_AREA_ROOT_ELEMENT = "controlArea"; + private static final String CONTROL_AREA_ARRAY_ELEMENT = "controlAreas"; + public static final String TERMINAL_ROOT_ELEMENT = "terminal"; + public static final String TERMINAL_ARRAY_ELEMENT = "terminals"; + public static final String BOUNDARY_ROOT_ELEMENT = "boundary"; + public static final String BOUNDARY_ARRAY_ELEMENT = "boundaries"; + + public CgmesControlAreasSerDe() { + super("cgmesControlAreas", "network", CgmesControlAreasSerDe.DummyExt.class, "cgmesControlAreas.xsd", + "http://www.powsybl.org/schema/iidm/ext/cgmes_control_areas/1_0", "cca"); + } + + @Override + public Map getArrayNameToSingleNameMap() { + return Map.of(CONTROL_AREA_ARRAY_ELEMENT, CONTROL_AREA_ROOT_ELEMENT, + TERMINAL_ARRAY_ELEMENT, TERMINAL_ROOT_ELEMENT, + BOUNDARY_ARRAY_ELEMENT, BOUNDARY_ROOT_ELEMENT); + } + + @Override + public void write(DummyExt extension, SerializerContext context) { + throw new IllegalStateException("Should not happen"); + } + + @Override + public DummyExt read(Network extendable, DeserializerContext context) { + NetworkDeserializerContext networkContext = (NetworkDeserializerContext) context; + TreeDataReader reader = networkContext.getReader(); + reader.readChildNodes(elementName -> { + if (elementName.equals(CONTROL_AREA_ROOT_ELEMENT)) { + String id = reader.readStringAttribute("id"); + if (extendable.getArea(id) == null) { + Area area = extendable.newArea() + .setAreaType(CgmesNames.CONTROL_AREA_TYPE_KIND_INTERCHANGE) + .setId(id) + .setName(reader.readStringAttribute("name")) + .setInterchangeTarget(reader.readDoubleAttribute("netInterchange")) + .add(); + + OptionalDouble pTolerance = reader.readOptionalDoubleAttribute("pTolerance"); + pTolerance.ifPresent(t -> area.setProperty(CgmesNames.P_TOLERANCE, Double.toString(t))); + + String energyIdentificationCodeEic = reader.readStringAttribute("energyIdentificationCodeEic"); + if (!Strings.isNullOrEmpty(energyIdentificationCodeEic)) { + area.addAlias(energyIdentificationCodeEic, CgmesNames.ENERGY_IDENT_CODE_EIC); + } + readBoundariesAndTerminals(networkContext, area, extendable); + } else { + LOGGER.warn("Area with id {} already exists. Skipping this CgmesControlArea.", id); + reader.skipNode(); + } + } else { + throw new PowsyblException("Unknown element name '" + elementName + "' in 'cgmesControlArea'"); + } + }); + return null; + } + + private void readBoundariesAndTerminals(NetworkDeserializerContext networkContext, Area area, Network network) { + TreeDataReader reader = networkContext.getReader(); + reader.readChildNodes(elementName -> { + switch (elementName) { + case BOUNDARY_ROOT_ELEMENT -> { + String id = networkContext.getAnonymizer().deanonymizeString(reader.readStringAttribute("id")); + Identifiable identifiable = network.getIdentifiable(id); + boolean isAc = true; // Set to "true" because this piece of data is not available + if (identifiable instanceof DanglingLine dl) { + area.newAreaBoundary() + .setAc(isAc) + .setBoundary(dl.getBoundary()) + .add(); + } else if (identifiable instanceof TieLine tl) { + TwoSides side = reader.readEnumAttribute("side", TwoSides.class); + area.newAreaBoundary() + .setAc(isAc) + .setBoundary(tl.getDanglingLine(side).getBoundary()) + .add(); + } else { + throw new PowsyblException("Unexpected Identifiable instance: " + identifiable.getClass()); + } + reader.readEndNode(); + } + case TERMINAL_ROOT_ELEMENT -> { + Terminal terminal = TerminalRefSerDe.readTerminal(networkContext, network); + area.newAreaBoundary() + .setAc(terminal.getConnectable().getType() != IdentifiableType.HVDC_CONVERTER_STATION) + .setTerminal(terminal) + .add(); + } + default -> throw new PowsyblException("Unknown element name '" + elementName + "' in 'controlArea'"); + } + }); + } + + @Override + public boolean isSerializable(CgmesControlAreasSerDe.DummyExt ext) { + return false; // Backward writing compatibility is not yet supported. + } + + // An extension is needed to define the SerDe. But it shouldn't be used otherwise. + public static class DummyExt implements Extension { + @Override + public String getName() { + return "cgmesControlAreas"; + } + + @Override + public Network getExtendable() { + throw new UnsupportedOperationException("This extension should not be used."); + } + + @Override + public void setExtendable(Network extendable) { + throw new UnsupportedOperationException("This extension should not be used."); + } + } +} diff --git a/cgmes/cgmes-extensions/src/test/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDeTest.java b/cgmes/cgmes-extensions/src/test/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDeTest.java new file mode 100644 index 00000000000..abca768419e --- /dev/null +++ b/cgmes/cgmes-extensions/src/test/java/com/powsybl/cgmes/extensions/compatibility/CgmesControlAreasSerDeTest.java @@ -0,0 +1,125 @@ +/** + * 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.extensions.compatibility; + +import com.powsybl.cgmes.model.CgmesNames; +import com.powsybl.iidm.network.Area; +import com.powsybl.iidm.network.AreaBoundary; +import com.powsybl.iidm.network.Network; +import com.powsybl.iidm.network.ThreeSides; +import org.junit.jupiter.api.Test; + +import java.util.Iterator; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Olivier Perrin {@literal } + */ +class CgmesControlAreasSerDeTest { + + private static final double EPSILON = 0.0001; + + @Test + void readingCompatibilityTest() { + Network network = Network.read("eurostag_cgmes_control_area.xml", + getClass().getResourceAsStream("/eurostag_cgmes_control_area.xml")); + + assertEquals(4, network.getAreaCount()); + + { + Area area1 = network.getArea("cgmesControlAreaId"); + assertNotNull(area1); + assertAll( + () -> assertEquals(CgmesNames.CONTROL_AREA_TYPE_KIND_INTERCHANGE, area1.getAreaType()), + () -> assertEquals("cgmesControlAreaName", area1.getNameOrId()), + () -> assertEquals(100.0, area1.getInterchangeTarget().orElse(Double.NaN), EPSILON), + () -> assertNull(area1.getProperty(CgmesNames.P_TOLERANCE)), + () -> assertEquals("energyIdentCodeEic", area1.getAliasFromType(CgmesNames.ENERGY_IDENT_CODE_EIC).orElse("")), + () -> assertEquals(2, area1.getAreaBoundaryStream().count()) + ); + Iterator boundaryIterator = area1.getAreaBoundaries().iterator(); + AreaBoundary b1 = boundaryIterator.next(); + AreaBoundary b2 = boundaryIterator.next(); + AreaBoundary boundaryOnTerminal; + AreaBoundary boundaryOnBoundary; + if (b1.getTerminal().isPresent()) { + boundaryOnTerminal = b1; + boundaryOnBoundary = b2; + } else { + boundaryOnBoundary = b1; + boundaryOnTerminal = b2; + } + assertAll( + () -> assertTrue(boundaryOnTerminal.isAc()), + () -> assertEquals("NHV1_NHV2_1", boundaryOnTerminal.getTerminal().orElseThrow().getConnectable().getId()), + () -> assertEquals(ThreeSides.ONE, boundaryOnTerminal.getTerminal().orElseThrow().getSide()), + () -> assertFalse(boundaryOnTerminal.getBoundary().isPresent()), + () -> assertTrue(boundaryOnBoundary.isAc()), + () -> assertFalse(boundaryOnBoundary.getTerminal().isPresent()), + () -> assertEquals("DL", boundaryOnBoundary.getBoundary().orElseThrow().getDanglingLine().getId()) + ); + } + + { + Area area = network.getArea("areaId-2"); + assertNotNull(area); + assertAll( + () -> assertEquals(CgmesNames.CONTROL_AREA_TYPE_KIND_INTERCHANGE, area.getAreaType()), + () -> assertEquals("area-2", area.getNameOrId()), + () -> assertEquals(90.0, area.getInterchangeTarget().orElse(Double.NaN), EPSILON), + () -> assertEquals(0.024, Double.parseDouble(area.getProperty(CgmesNames.P_TOLERANCE, "NaN")), EPSILON), + () -> assertFalse(area.getAliasFromType(CgmesNames.ENERGY_IDENT_CODE_EIC).isPresent()), + () -> assertEquals(1, area.getAreaBoundaryStream().count()) + ); + AreaBoundary b = area.getAreaBoundaries().iterator().next(); + assertAll( + () -> assertTrue(b.isAc()), + () -> assertEquals("NHV1_NHV2_2", b.getTerminal().orElseThrow().getConnectable().getId()), + () -> assertEquals(ThreeSides.TWO, b.getTerminal().orElseThrow().getSide()), + () -> assertFalse(b.getBoundary().isPresent()) + ); + } + + { + Area area = network.getArea("areaId-3"); + assertNotNull(area); + assertAll( + () -> assertEquals(CgmesNames.CONTROL_AREA_TYPE_KIND_INTERCHANGE, area.getAreaType()), + () -> assertEquals("area-3", area.getNameOrId()) + ); + AreaBoundary b = area.getAreaBoundaries().iterator().next(); + assertAll( + () -> assertTrue(b.isAc()), + () -> assertFalse(b.getTerminal().isPresent()), + () -> assertTrue(b.getBoundary().isPresent()), + () -> assertEquals("DL8", b.getBoundary().orElseThrow().getDanglingLine().getId()) + ); + } + + // When an Area with the same ID already exists, the CgmesControlArea is not imported (and the Area is not updated) + { + Area area = network.getArea("alreadyExistingArea"); + assertNotNull(area); + assertAll( + () -> assertEquals(CgmesNames.CONTROL_AREA_TYPE_KIND_INTERCHANGE, area.getAreaType()), + () -> assertEquals("area", area.getNameOrId()), + () -> assertEquals(Double.NaN, area.getInterchangeTarget().orElse(Double.NaN)), + () -> assertEquals(Double.NaN, Double.parseDouble(area.getProperty(CgmesNames.P_TOLERANCE, "NaN"))), + () -> assertFalse(area.getAliasFromType(CgmesNames.ENERGY_IDENT_CODE_EIC).isPresent()), + () -> assertEquals(1, area.getAreaBoundaryStream().count()) + ); + AreaBoundary b = area.getAreaBoundaries().iterator().next(); + assertAll( + () -> assertTrue(b.isAc()), + () -> assertEquals("DL0", b.getBoundary().orElseThrow().getDanglingLine().getId()), + () -> assertFalse(b.getTerminal().isPresent()) + ); + } + } +} diff --git a/cgmes/cgmes-extensions/src/test/resources/eurostag_cgmes_control_area.xml b/cgmes/cgmes-extensions/src/test/resources/eurostag_cgmes_control_area.xml index 6920ffd3b27..b3f367b389e 100644 --- a/cgmes/cgmes-extensions/src/test/resources/eurostag_cgmes_control_area.xml +++ b/cgmes/cgmes-extensions/src/test/resources/eurostag_cgmes_control_area.xml @@ -4,17 +4,21 @@ + + + + @@ -22,7 +26,9 @@ + + @@ -41,12 +47,25 @@ + + + + + + + + + + + + +