CneHelper.java

/*
 * Copyright (c) 2024, 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/.
 */
package com.powsybl.openrao.tests.utils;

import com.powsybl.openrao.commons.OpenRaoException;
import com.powsybl.openrao.data.raoresult.io.cne.core.CoreCneExporter;
import com.powsybl.openrao.data.crac.api.CracCreationContext;
import com.powsybl.openrao.data.crac.io.cim.craccreator.CimCracCreationContext;
import com.powsybl.openrao.data.crac.io.commons.api.stdcreationcontext.UcteCracCreationContext;
import com.powsybl.openrao.data.raoresult.api.RaoResult;
import com.powsybl.openrao.data.raoresult.io.cne.swe.SweCneExporter;
import com.powsybl.openrao.raoapi.parameters.RaoParameters;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xmlunit.builder.DiffBuilder;
import org.xmlunit.builder.Input;
import org.xmlunit.diff.*;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import static org.junit.jupiter.api.Assertions.assertFalse;

/**
 * Helper class for exporting and comparing CNE files
 * @author Peter Mitri {@literal <peter.mitri at rte-france.com>}
 */
public final class CneHelper {

    private static final double DEFAULT_DOUBLE_TOLERANCE = 1.1;
    private static final Map<String, Double> SPECIFIC_DOUBLE_TOLERANCE = Map.of("Z11", 1e-6);

    public enum CneVersion {
        CORE,
        SWE
    }

    private CneHelper() {
        // should not be instantiated
    }

    public static String exportCoreCne(CracCreationContext cracCreationContext, RaoResult raoResult, RaoParameters raoParameters) {
        if (!(cracCreationContext instanceof UcteCracCreationContext)) {
            throw new OpenRaoException("CORE CNE export can only handle UcteCracCreationContext");
        }
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        Properties properties = new Properties();
        fillPropertiesWithCoreCneExporterParameters(properties);
        fillPropertiesWithRaoParameters(properties, raoParameters);
        raoResult.write("CORE-CNE", cracCreationContext, properties, outputStream);
        return outputStream.toString();
    }

    private static void fillPropertiesWithCoreCneExporterParameters(Properties properties) {
        properties.setProperty("rao-result.export.core-cne.document-id", "22XCORESO------S-20211115-F299v1");
        properties.setProperty("rao-result.export.core-cne.revision-number", "1");
        properties.setProperty("rao-result.export.core-cne.domain-id", "10YDOM-REGION-1V");
        properties.setProperty("rao-result.export.core-cne.process-type", "A48");
        properties.setProperty("rao-result.export.core-cne.sender-id", "22XCORESO------S");
        properties.setProperty("rao-result.export.core-cne.sender-role", "A44");
        properties.setProperty("rao-result.export.core-cne.receiver-id", "17XTSO-CS------W");
        properties.setProperty("rao-result.export.core-cne.receiver-role", "A36");
        properties.setProperty("rao-result.export.core-cne.time-interval", "2021-10-30T22:00Z/2021-10-31T23:00Z");
    }

    private static void fillPropertiesWithRaoParameters(Properties properties, RaoParameters raoParameters) {
        switch (raoParameters.getObjectiveFunctionParameters().getType()) {
            case MAX_MIN_RELATIVE_MARGIN -> properties.setProperty("rao-result.export.core-cne.relative-positive-margins", "true");
            case MAX_MIN_MARGIN -> properties.setProperty("rao-result.export.core-cne.relative-positive-margins", "false");
        }
        if (raoParameters.getLoopFlowParameters().isPresent()) {
            properties.setProperty("rao-result.export.core-cne.with-loop-flows", "true");
        }
        if (raoParameters.getMnecParameters().isPresent()) {
            properties.setProperty("rao-result.export.core-cne.mnec-acceptable-margin-diminution", String.valueOf(raoParameters.getMnecParameters().get().getAcceptableMarginDecrease()));
        }
    }

    public static String exportSweCne(CracCreationContext cracCreationContext, RaoResult raoResult) {
        if (!(cracCreationContext instanceof CimCracCreationContext)) {
            throw new OpenRaoException("SWE CNE export can only handle CimCracCreationContext");
        }
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        Properties properties = new Properties();
        fillPropertiesWithSweCneExporterParameters(properties);
        raoResult.write("SWE-CNE", cracCreationContext, properties, outputStream);
        return outputStream.toString();
    }

    private static void fillPropertiesWithSweCneExporterParameters(Properties properties) {
        properties.setProperty("rao-result.export.swe-cne.document-id", "documentId");
        properties.setProperty("rao-result.export.swe-cne.revision-number", "3");
        properties.setProperty("rao-result.export.swe-cne.domain-id", "domainId");
        properties.setProperty("rao-result.export.swe-cne.process-type", "A48");
        properties.setProperty("rao-result.export.swe-cne.sender-id", "senderId");
        properties.setProperty("rao-result.export.swe-cne.sender-role", "A44");
        properties.setProperty("rao-result.export.swe-cne.receiver-id", "receiverId");
        properties.setProperty("rao-result.export.swe-cne.receiver-role", "A36");
        properties.setProperty("rao-result.export.swe-cne.time-interval", "2021-04-02T12:00:00Z/2021-04-02T13:00:00Z");
    }

    public static boolean isCoreCneValid(String cneContent) {
        return CoreCneExporter.validateCNESchema(cneContent);
    }

    public static boolean isSweCneValid(String cneContent) {
        return SweCneExporter.validateCNESchema(cneContent);
    }

    /**
     * Function to compare CNE files
     * @param expectedCneInputStream: input stream with the expected contents of the CNE file
     * @param actualCneInputStream: input stream with the actual contents of the CNE file
     * @param onlySimilarity: set to true to check only for similarity, ie ignore nodes' order in the document
     * @param cneVersion: CNE version, to apply specific compare rules
     * @throws AssertionError: if the files are not similar, with a list of the differences
     */
    public static void compareCneFiles(InputStream expectedCneInputStream, InputStream actualCneInputStream, boolean onlySimilarity, CneVersion cneVersion) throws AssertionError {
        DiffBuilder db = DiffBuilder
                .compare(Input.fromStream(expectedCneInputStream))
                .withTest(Input.fromStream(actualCneInputStream))
                .ignoreComments()
                .withDifferenceEvaluator(new DoubleElementDifferenceEvaluator("analogValues.value", DEFAULT_DOUBLE_TOLERANCE, SPECIFIC_DOUBLE_TOLERANCE));
        if (onlySimilarity) {
            db.checkForSimilar().withNodeMatcher(new DefaultNodeMatcher(new CneDocumentElementSelector()));
        }
        switch (cneVersion) {
            case CORE:
                db.withNodeFilter(CneHelper::shouldCompareNodeForCore);
                break;
            case SWE:
                db.withNodeFilter(CneHelper::shouldCompareNodeForSwe);
                break;
            default:
                throw new OpenRaoException(String.format("Unknown CNE version %s", cneVersion));
        }
        Diff d = db.build();

        if (d.hasDifferences()) {
            DefaultComparisonFormatter formatter = new DefaultComparisonFormatter();
            StringBuffer buffer = new StringBuffer();
            for (Difference ds : d.getDifferences()) {
                buffer.append(formatter.getDescription(ds.getComparison()) + "\n");
            }
            throw new AssertionError("There are XML differences in CNE files\n" + buffer.toString());
        }
        assertFalse(d.hasDifferences());
    }

    /**
     * This class tells XMLUnit which nodes it should compare in the CNE file, and which nodes it should ignore
     * For example, XMLUnit should ignore nodes with random content, and nodes containing the computation timestamp
     * This applies for CORE
     */
    private static boolean shouldCompareNodeForCore(Node node) {
        if (node.getNodeName().equals("mRID")) {
            // For the following fields, mRID is generated randomly as per the CNE specifications
            // We should not compare them with the test file
            return !node.getParentNode().getNodeName().equals("TimeSeries")
                && (!node.getParentNode().getNodeName().equals("Constraint_Series") || !getChildElementValue(node.getParentNode(), "businessType").equals("B56"));
        } else {
            return !(node.getNodeName().equals("createdDateTime"));
        }
    }

    /**
     * This class tells XMLUnit which nodes it should compare in the CNE file, and which nodes it should ignore
     * For example, XMLUnit should ignore nodes with random content, and nodes containing the computation timestamp
     * This applies for SWE
     */
    private static boolean shouldCompareNodeForSwe(Node node) {
        if (node.getNodeName().equals("mRID")) {
            // For the following fields, mRID is generated randomly as per the CNE specifications
            // We should not compare them with the test file
            return !node.getParentNode().getNodeName().equals("Constraint_Series");
        } else {
            return !(node.getNodeName().equals("createdDateTime"));
        }
    }

    /**
     * This class helps XMLUnit select nodes that can be compared, since some nodes do not have name or id attributes
     * It tells XMLUnit how nodes are unique depending on their content, in order for it to be able to ignore the sequence in the XML file
     * It is only used in case of an 'only similarity' comparison
     */
    private static class CneDocumentElementSelector implements ElementSelector {
        ByNameAndTextRecSelector byNameAndTextRecSelector = new ByNameAndTextRecSelector();

        @Override
        public boolean canBeCompared(Element e1, Element e2) {
            if (e1.getNodeName().equals("Constraint_Series") && e2.getNodeName().equals("Constraint_Series")) {
                return canBeComparedConstraintSeries(e1, e2);
            } else if (e1.getNodeName().equals("RemedialAction_Series") && e2.getNodeName().equals("RemedialAction_Series")) {
                return byNameAndTextRecSelector.canBeCompared(e1, e2);
            } else {
                return ElementSelectors.byName.canBeCompared(e1, e2);
            }
        }

        private boolean canBeComparedConstraintSeries(Element e1, Element e2) {
            String type1 = getChildElementValue(e1, "businessType");
            String type2 = getChildElementValue(e2, "businessType");
            if (!type1.equals(type2)) {
                return false;
            }
            switch (type1) {
                case "B54", "B57", "B88":
                    String cnecId1 = getChildElementValue(e1, "Monitored_Series", "RegisteredResource", "mRID");
                    String cnecId2 = getChildElementValue(e2, "Monitored_Series", "RegisteredResource", "mRID");
                    return cnecId1.equals(cnecId2);
                case "B56":
                    // check that the two nodes contain the same remedial actions (no other way)
                    return getB56RemedialActions(e1).equals(getB56RemedialActions(e2));
                default:
                    return true; // should not happen
            }
        }

        private Set<String> getB56RemedialActions(Element e) {
            Set<String> raNames = new HashSet<>();
            NodeList nodeList = e.getElementsByTagName("RemedialAction_Series");
            for (int i = 0; i < nodeList.getLength(); i++) {
                raNames.add(getChildElementValue(nodeList.item(i), "name"));
            }
            return raNames;
        }
    }

    private static String getChildElementValue(Node e, String... childTags) {
        Node currentNode = e;
        for (String childTag : childTags) {
            currentNode = getChildElement(currentNode, childTag);
        }
        return currentNode.getTextContent();
    }

    private static Node getChildElement(Node e, String childTag) {
        for (int i = 0; i < e.getChildNodes().getLength(); i++) {
            if (e.getChildNodes().item(i).getNodeName().equals(childTag)) {
                return e.getChildNodes().item(i);
            }
        }
        throw new IllegalArgumentException("Could not find child element " + childTag);
    }

    /**
     * This class is helpful in order to tolerate some differences in double values (which are rounded and can easily change when the RAO changes slightly)
     */
    private static class DoubleElementDifferenceEvaluator implements DifferenceEvaluator {
        private String elementName;
        private double defaultTolerance;
        private Map<String, Double> specificMeasurementTypeTolerance;

        public DoubleElementDifferenceEvaluator(String elementName, double defaultTolerance, Map<String, Double> specificMeasurementTypeTolerance) {
            this.elementName = elementName;
            this.defaultTolerance = defaultTolerance;
            this.specificMeasurementTypeTolerance = specificMeasurementTypeTolerance;
        }

        @Override
        public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome) {
            if (outcome == ComparisonResult.EQUAL) {
                return outcome; // only evaluate differences.
            }
            if (comparison.getType() == ComparisonType.CHILD_NODELIST_SEQUENCE) {
                return ComparisonResult.SIMILAR;
            }
            final Node controlNode = comparison.getControlDetails().getTarget();
            final Node testNode = comparison.getTestDetails().getTarget();
            if (controlNode != null && controlNode.getParentNode() instanceof Element
                    && testNode != null && testNode.getParentNode() instanceof Element) {
                Element controlElement = (Element) controlNode.getParentNode();
                Element testElement = (Element) testNode.getParentNode();
                if (controlElement.getNodeName().equals(elementName)) {
                    final double controlValue = Double.parseDouble(controlElement.getTextContent());
                    final double testValue = Double.parseDouble(testElement.getTextContent());
                    String measurementType = getMeasurementType(controlElement);
                    double tolerance = specificMeasurementTypeTolerance.containsKey(measurementType) ? specificMeasurementTypeTolerance.get(measurementType) : defaultTolerance;
                    if (Math.abs(controlValue - testValue) < tolerance) {
                        return ComparisonResult.EQUAL;
                    }
                }
            }
            return outcome;
        }

        private String getMeasurementType(Element analogValue) {
            String measurementType = null;
            Node sibling = analogValue.getPreviousSibling();
            while (sibling != null) {
                if (sibling.getNodeName().equals("measurementType")) {
                    measurementType = sibling.getTextContent();
                    break;
                }
                sibling = sibling.getPreviousSibling();
            }
            return measurementType;
        }
    }
}