BestTapFinderTest.java

/*
 * Copyright (c) 2021, 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.searchtreerao.linearoptimisation.algorithms;

import com.powsybl.openrao.commons.Unit;
import com.powsybl.openrao.data.crac.api.Instant;
import com.powsybl.openrao.data.crac.api.NetworkElement;
import com.powsybl.openrao.data.crac.api.State;
import com.powsybl.openrao.data.crac.api.cnec.FlowCnec;
import com.powsybl.iidm.network.TwoSides;
import com.powsybl.openrao.data.crac.api.rangeaction.PstRangeAction;
import com.powsybl.openrao.data.crac.api.rangeaction.RangeAction;
import com.powsybl.openrao.searchtreerao.commons.optimizationperimeters.OptimizationPerimeter;
import com.powsybl.openrao.searchtreerao.result.api.*;
import com.powsybl.openrao.searchtreerao.result.impl.RangeActionActivationResultImpl;
import com.powsybl.openrao.searchtreerao.result.impl.RangeActionSetpointResultImpl;
import com.powsybl.iidm.network.Network;
import com.powsybl.iidm.network.ValidationException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.*;

import static com.powsybl.openrao.searchtreerao.linearoptimisation.algorithms.IteratingLinearOptimizer.roundOtherRas;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
 * @author Joris Mancini {@literal <joris.mancini at rte-france.com>}
 */
class BestTapFinderTest {
    private static final double DOUBLE_TOLERANCE = 0.01;
    private static final double INITIAL_PST_SET_POINT = 1.2;
    private static final double REF_FLOW_1 = 100;
    private static final double REF_FLOW_2 = -400;
    private static final double SENSI_1 = 10;
    private static final double SENSI_2 = -40;
    private static int pstCounter = 0;

    private Network network;
    private RangeActionActivationResult rangeActionActivationResult;
    private LinearOptimizationResult linearOptimizationResult;
    private FlowCnec cnec1;
    private FlowCnec cnec2;
    private PstRangeAction pstRangeAction;
    private State optimizedState;
    private OptimizationPerimeter optimizationPerimeter;
    private RangeActionSetpointResult rangeActionSetpointResult;

    @BeforeEach
    public void setUp() {
        cnec1 = Mockito.mock(FlowCnec.class);
        when(cnec1.getMonitoredSides()).thenReturn(Collections.singleton(TwoSides.ONE));
        cnec2 = Mockito.mock(FlowCnec.class);
        when(cnec2.getMonitoredSides()).thenReturn(Collections.singleton(TwoSides.TWO));
        network = Mockito.mock(Network.class);

        linearOptimizationResult = mock(LinearOptimizationResult.class);
        when(linearOptimizationResult.getMostLimitingElements(anyInt())).thenReturn(List.of(cnec1, cnec2));

        when(linearOptimizationResult.getFlow(cnec1, TwoSides.ONE, Unit.MEGAWATT)).thenReturn(REF_FLOW_1);
        when(linearOptimizationResult.getFlow(cnec2, TwoSides.TWO, Unit.MEGAWATT)).thenReturn(REF_FLOW_2);

        rangeActionActivationResult = Mockito.mock(RangeActionActivationResult.class);
        pstRangeAction = createPst();
        optimizedState = Mockito.mock(State.class);
        when(optimizedState.getContingency()).thenReturn(Optional.empty());
        Instant preventiveInstant = Mockito.mock(Instant.class);
        when(optimizedState.getInstant()).thenReturn(preventiveInstant);
        optimizationPerimeter = Mockito.mock(OptimizationPerimeter.class);
        when(optimizationPerimeter.getMainOptimizationState()).thenReturn(optimizedState);
        when(optimizationPerimeter.getRangeActionOptimizationStates()).thenReturn(Set.of(optimizedState));
        rangeActionSetpointResult = Mockito.mock(RangeActionSetpointResult.class);
        when(rangeActionActivationResult.getOptimizedSetpointsOnState(optimizedState)).thenReturn(Map.of(pstRangeAction, 0.));
    }

    private void setSensitivityValues(PstRangeAction pstRangeAction) {
        when(linearOptimizationResult.getSensitivityValue(cnec1, TwoSides.ONE, pstRangeAction, Unit.MEGAWATT)).thenReturn(SENSI_1);
        when(linearOptimizationResult.getSensitivityValue(cnec2, TwoSides.TWO, pstRangeAction, Unit.MEGAWATT)).thenReturn(SENSI_2);
    }

    private void mockPstRangeAction(PstRangeAction pstRangeAction) {
        when(pstRangeAction.convertTapToAngle(-3)).thenThrow(new ValidationException(() -> "header", "Out of bound"));
        when(pstRangeAction.convertTapToAngle(-2)).thenReturn(-2.5);
        when(pstRangeAction.convertTapToAngle(-1)).thenReturn(-0.75);
        when(pstRangeAction.convertTapToAngle(0)).thenReturn(0.);
        when(pstRangeAction.convertTapToAngle(1)).thenReturn(0.75);
        when(pstRangeAction.convertTapToAngle(2)).thenReturn(2.5);
        when(pstRangeAction.convertTapToAngle(3)).thenThrow(new ValidationException(() -> "header", "Out of bound"));
    }

    private void setClosestTapPosition(PstRangeAction pstRangeAction, double setPoint, int tapPosition) {
        when(pstRangeAction.convertAngleToTap(setPoint)).thenReturn(tapPosition);
    }

    private void setMarginsForTap(PstRangeAction pstRangeAction, int tap, double marginForCnec1, double marginForCnec2) {
        mockMarginOnCnec1(pstRangeAction, tap, marginForCnec1);
        mockMarginOnCnec2(pstRangeAction, tap, marginForCnec2);
    }

    private void mockMarginOnCnec1(PstRangeAction pstRangeAction, int tap, double margin) {
        double flow = REF_FLOW_1 + (pstRangeAction.convertTapToAngle(tap) - INITIAL_PST_SET_POINT) * SENSI_1;
        when(cnec1.computeMargin(flow, TwoSides.ONE, Unit.MEGAWATT)).thenReturn(margin);
    }

    private void mockMarginOnCnec2(PstRangeAction pstRangeAction, int tap, double margin) {
        double flow = REF_FLOW_2 + (pstRangeAction.convertTapToAngle(tap) - INITIAL_PST_SET_POINT) * SENSI_2;
        when(cnec2.computeMargin(flow, TwoSides.TWO, Unit.MEGAWATT)).thenReturn(margin);
    }

    private Map<Integer, Double> computeMinMarginsForBestTaps(double startingSetPoint) {
        return BestTapFinder.computeMinMarginsForBestTaps(
            network,
            pstRangeAction,
            startingSetPoint,
            linearOptimizationResult,
            Unit.MEGAWATT
        );
    }

    private RangeActionActivationResult computeUpdatedRangeActionResult() {
        RangeActionActivationResultImpl roundedResult = BestTapFinder.round(
            rangeActionActivationResult,
            network,
            optimizationPerimeter,
            rangeActionSetpointResult,
            linearOptimizationResult,
            Unit.MEGAWATT
        );
        roundOtherRas(rangeActionActivationResult, optimizationPerimeter, roundedResult);
        return roundedResult;
    }

    private PstRangeAction createPstWithGroupId(String groupId) {
        PstRangeAction pst = createPst();
        when(pst.getGroupId()).thenReturn(Optional.of(groupId));
        return pst;
    }

    private PstRangeAction createPst() {
        PstRangeAction pst = Mockito.mock(PstRangeAction.class);
        when(pst.getCurrentSetpoint(network)).thenReturn(INITIAL_PST_SET_POINT);
        when(pst.getId()).thenReturn("pst" + pstCounter);
        NetworkElement networkElement = Mockito.mock(NetworkElement.class);
        when(networkElement.getId()).thenReturn("pstNE" + pstCounter++);
        when(pst.getNetworkElements()).thenReturn(Set.of(networkElement));
        mockPstRangeAction(pst);
        setSensitivityValues(pst);
        return pst;
    }

    @Test
    void testMarginWhenTheSetPointIsTooFarFromTheMiddle() {
        // Set point is really close to tap 1, so there is no computation and margin is considered the best for tap 1
        double startingSetPoint = 0.8;
        setClosestTapPosition(pstRangeAction, startingSetPoint, 1);
        setMarginsForTap(pstRangeAction, 1, 100, 120);
        setMarginsForTap(pstRangeAction, 2, 150, 50);

        Map<Integer, Double> marginsForBestTaps = computeMinMarginsForBestTaps(startingSetPoint);

        assertEquals(1, marginsForBestTaps.size());
        assertEquals(Double.MAX_VALUE, marginsForBestTaps.get(1), DOUBLE_TOLERANCE);
    }

    @Test
    void testMarginsWithOtherTapDecreasingTheMinMargin() {
        // Set point is close enough to the middle of the range between tap 1 and 2, so we consider the two taps
        // The closest tap is still 1, and the next tap worsen the margin so it is not considered
        double startingSetPoint = 1.5;
        setClosestTapPosition(pstRangeAction, startingSetPoint, 1);
        setMarginsForTap(pstRangeAction, 1, 100, 120);
        setMarginsForTap(pstRangeAction, 2, 150, 50);

        Map<Integer, Double> marginsForBestTaps = computeMinMarginsForBestTaps(startingSetPoint);

        assertEquals(1, marginsForBestTaps.size());
        assertEquals(Double.MAX_VALUE, marginsForBestTaps.get(1), DOUBLE_TOLERANCE);
    }

    @Test
    void testMarginsWithOtherTapIncreasingTheMinMargin() {
        // Set point is close enough to the middle of the range between tap 1 and 2, so we consider the two taps
        // The closest tap is still 1, and the other tap increases the margin so it is considered
        double startingSetPoint = 1.5;
        setClosestTapPosition(pstRangeAction, startingSetPoint, 1);
        setMarginsForTap(pstRangeAction, 1, 100, 120);
        setMarginsForTap(pstRangeAction, 2, 150, 120);

        Map<Integer, Double> marginsForBestTaps = computeMinMarginsForBestTaps(startingSetPoint);

        assertEquals(2, marginsForBestTaps.size());
        assertEquals(100, marginsForBestTaps.get(1), DOUBLE_TOLERANCE);
        assertEquals(120, marginsForBestTaps.get(2), DOUBLE_TOLERANCE);
    }

    @Test
    void testMarginsWithOtherTapIncreasingTheMinMarginWithNegativeMargins() {
        // Set point is close enough to the middle of the range between tap 1 and 2, so we consider the two taps
        // The closest tap is still 1, and the next tap increase the margin so it is considered
        double startingSetPoint = 1.5;
        setClosestTapPosition(pstRangeAction, startingSetPoint, 1);
        setMarginsForTap(pstRangeAction, 1, -200, -250);
        setMarginsForTap(pstRangeAction, 2, 100, -120);

        Map<Integer, Double> marginsForBestTaps = computeMinMarginsForBestTaps(startingSetPoint);

        assertEquals(2, marginsForBestTaps.size());
        assertEquals(-250, marginsForBestTaps.get(1), DOUBLE_TOLERANCE);
        assertEquals(-120, marginsForBestTaps.get(2), DOUBLE_TOLERANCE);
    }

    @Test
    void testMarginsWithOtherTapIncreasingTheMinMarginOnUpperBound() {
        // Set point is close enough to the middle of the range between tap 1 and 2, so we consider the two taps
        // The closest tap is 2 which is the upper bound, and the other tap increases the margin so it is considered
        double startingSetPoint = 1.7;
        setClosestTapPosition(pstRangeAction, startingSetPoint, 2);
        setMarginsForTap(pstRangeAction, 1, 140, 150);
        setMarginsForTap(pstRangeAction, 2, 150, 120);

        Map<Integer, Double> marginsForBestTaps = computeMinMarginsForBestTaps(startingSetPoint);

        assertEquals(2, marginsForBestTaps.size());
        assertEquals(140, marginsForBestTaps.get(1), DOUBLE_TOLERANCE);
        assertEquals(120, marginsForBestTaps.get(2), DOUBLE_TOLERANCE);
    }

    @Test
    void testMarginsWithOtherTapIncreasingTheMinMarginOnLowerBound() {
        // Set point is close enough to the middle of the range between tap -1 and -2, so we consider the two taps
        // The closest tap is -2 which is the lower bound, and the other tap increases the margin so it is considered
        double startingSetPoint = -1.7;
        setClosestTapPosition(pstRangeAction, startingSetPoint, -2);
        setMarginsForTap(pstRangeAction, -1, 140, 150);
        setMarginsForTap(pstRangeAction, -2, 150, 120);

        Map<Integer, Double> marginsForBestTaps = computeMinMarginsForBestTaps(startingSetPoint);

        assertEquals(2, marginsForBestTaps.size());
        assertEquals(140, marginsForBestTaps.get(-1), DOUBLE_TOLERANCE);
        assertEquals(120, marginsForBestTaps.get(-2), DOUBLE_TOLERANCE);
    }

    @Test
    void testComputeBestTapPerPstGroup() {
        PstRangeAction pst1 = createPst();
        PstRangeAction pst2 = createPstWithGroupId("group1");
        PstRangeAction pst3 = createPstWithGroupId("group1");
        PstRangeAction pst4 = createPstWithGroupId("group2");
        PstRangeAction pst5 = createPstWithGroupId("group2");
        PstRangeAction pst6 = createPstWithGroupId("group2");
        PstRangeAction pst7 = createPstWithGroupId("group2");

        Map<PstRangeAction, Map<Integer, Double>> minMarginPerTap = new HashMap<>();
        minMarginPerTap.put(pst1, Map.of(3, 100., 4, 500.));

        minMarginPerTap.put(pst2, Map.of(3, 100., 4, 500.));
        minMarginPerTap.put(pst3, Map.of(3, 110., 4, 50.));

        minMarginPerTap.put(pst4, Map.of(-10, -30., -11, -80.));
        minMarginPerTap.put(pst5, Map.of(-10, -40., -11, -20.));
        minMarginPerTap.put(pst6, Map.of(-10, -70., -11, 200.));
        minMarginPerTap.put(pst7, Map.of(-11, Double.MAX_VALUE));

        Map<String, Integer> bestTapPerPstGroup = BestTapFinder.computeBestTapPerPstGroup(minMarginPerTap);
        assertEquals(2, bestTapPerPstGroup.size());
        assertEquals(3, bestTapPerPstGroup.get("group1").intValue());
        assertEquals(-10, bestTapPerPstGroup.get("group2").intValue());
    }

    @Test
    void testUpdatedRangeActionResultWithOtherTapSelected() {
        double startingSetPoint = 0.;
        double notRoundedSetpoint = 1.7;
        setClosestTapPosition(pstRangeAction, notRoundedSetpoint, 2);
        setMarginsForTap(pstRangeAction, 1, 140, 150); // Tap 1 should be selected because min margin is 140
        setMarginsForTap(pstRangeAction, 2, 150, 120);

        RangeAction<?> activatedRangeActionOtherThanPst = Mockito.mock(RangeAction.class);
        when(activatedRangeActionOtherThanPst.getId()).thenReturn("notPst");
        NetworkElement networkElementOtherThanPst = Mockito.mock(NetworkElement.class);
        when(networkElementOtherThanPst.getId()).thenReturn("notPstNE");
        when(activatedRangeActionOtherThanPst.getNetworkElements()).thenReturn(Set.of(networkElementOtherThanPst));

        rangeActionSetpointResult = new RangeActionSetpointResultImpl(Map.of(
            pstRangeAction, startingSetPoint,
            activatedRangeActionOtherThanPst, startingSetPoint
        ));
        rangeActionActivationResult = new RangeActionActivationResultImpl(rangeActionSetpointResult);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(pstRangeAction, optimizedState, notRoundedSetpoint);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(activatedRangeActionOtherThanPst, optimizedState, 200.);
        when(optimizationPerimeter.getRangeActionsPerState()).thenReturn(Map.of(
            optimizedState, Set.of(pstRangeAction, activatedRangeActionOtherThanPst)
        ));

        RangeActionActivationResult updatedRangeActionActivationResult = computeUpdatedRangeActionResult();

        assertEquals(0.75, updatedRangeActionActivationResult.getOptimizedSetpoint(pstRangeAction, optimizedState), DOUBLE_TOLERANCE);
        assertEquals(200., updatedRangeActionActivationResult.getOptimizedSetpoint(activatedRangeActionOtherThanPst, optimizedState), DOUBLE_TOLERANCE);
    }

    @Test
    void testUpdatedRangeActionResultWithClosestTapSelected() {
        double startingSetPoint = 0.;
        double notRoundedSetpoint = 1.7;
        setClosestTapPosition(pstRangeAction, notRoundedSetpoint, 2);
        setMarginsForTap(pstRangeAction, 1, 120, 150);
        setMarginsForTap(pstRangeAction, 2, 150, 140); // Tap 2 should be selected because min margin is 140

        RangeAction<?> activatedRangeActionOtherThanPst = Mockito.mock(RangeAction.class);
        when(activatedRangeActionOtherThanPst.getId()).thenReturn("notPst");
        NetworkElement networkElementOtherThanPst = Mockito.mock(NetworkElement.class);
        when(networkElementOtherThanPst.getId()).thenReturn("notPstNE");
        when(activatedRangeActionOtherThanPst.getNetworkElements()).thenReturn(Set.of(networkElementOtherThanPst));

        rangeActionSetpointResult = new RangeActionSetpointResultImpl(Map.of(
            pstRangeAction, startingSetPoint,
            activatedRangeActionOtherThanPst, startingSetPoint
        ));
        rangeActionActivationResult = new RangeActionActivationResultImpl(rangeActionSetpointResult);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(pstRangeAction, optimizedState, notRoundedSetpoint);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(activatedRangeActionOtherThanPst, optimizedState, 200.);
        when(optimizationPerimeter.getRangeActionsPerState()).thenReturn(Map.of(
            optimizedState, Set.of(pstRangeAction, activatedRangeActionOtherThanPst)
        ));

        RangeActionActivationResult updatedRangeActionActivationResult = computeUpdatedRangeActionResult();

        assertEquals(2.5, updatedRangeActionActivationResult.getOptimizedSetpoint(pstRangeAction, optimizedState), DOUBLE_TOLERANCE);
        assertEquals(200., updatedRangeActionActivationResult.getOptimizedSetpoint(activatedRangeActionOtherThanPst, optimizedState), DOUBLE_TOLERANCE);
    }

    @Test
    void testUpdatedRangeActionResultNoOptimizationOfTheTap() {
        double startingSetPoint = 0.;
        double notRoundedSetpoint = 0.8;
        // Starting point is really close to set point of tap 1 so it will be set to tap 1
        setClosestTapPosition(pstRangeAction, notRoundedSetpoint, 1);
        setMarginsForTap(pstRangeAction, 1, 120, 150);
        setMarginsForTap(pstRangeAction, 2, 150, 140); // Tap 2 would be ignored even if result is better

        RangeAction<?> activatedRangeActionOtherThanPst = Mockito.mock(RangeAction.class);
        when(activatedRangeActionOtherThanPst.getId()).thenReturn("notPst");
        NetworkElement networkElementOtherThanPst = Mockito.mock(NetworkElement.class);
        when(networkElementOtherThanPst.getId()).thenReturn("notPstNE");
        when(activatedRangeActionOtherThanPst.getNetworkElements()).thenReturn(Set.of(networkElementOtherThanPst));

        rangeActionSetpointResult = new RangeActionSetpointResultImpl(Map.of(
            pstRangeAction, startingSetPoint,
            activatedRangeActionOtherThanPst, startingSetPoint
        ));
        rangeActionActivationResult = new RangeActionActivationResultImpl(rangeActionSetpointResult);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(pstRangeAction, optimizedState, notRoundedSetpoint);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(activatedRangeActionOtherThanPst, optimizedState, 200.);
        when(optimizationPerimeter.getRangeActionsPerState()).thenReturn(Map.of(
            optimizedState, Set.of(pstRangeAction, activatedRangeActionOtherThanPst)
        ));

        RangeActionActivationResult updatedRangeActionActivationResult = computeUpdatedRangeActionResult();

        assertEquals(0.75, updatedRangeActionActivationResult.getOptimizedSetpoint(pstRangeAction, optimizedState), DOUBLE_TOLERANCE);
        assertEquals(200., updatedRangeActionActivationResult.getOptimizedSetpoint(activatedRangeActionOtherThanPst, optimizedState), DOUBLE_TOLERANCE);
    }

    @Test
    void testUpdatedRangeActionResultWithGroups() {
        double startingSetPoint = 0.;
        double notRoundedSetpoint = 0.8;
        setClosestTapPosition(pstRangeAction, notRoundedSetpoint, 2);
        setMarginsForTap(pstRangeAction, 1, 120, 150);
        setMarginsForTap(pstRangeAction, 2, 150, 140); // Tap 2 should be selected because min margin is 140

        PstRangeAction pstGroup1 = createPstWithGroupId("group1");
        PstRangeAction pstGroup2 = createPstWithGroupId("group1");
        double groupNotRoundedSetpoint = -0.4;
        setClosestTapPosition(pstGroup1, groupNotRoundedSetpoint, -1);
        setMarginsForTap(pstGroup1, -1, 120, 150);
        setMarginsForTap(pstGroup1, 0, 150, 140); // Tap 0 should be selected because min margin is 140
        setClosestTapPosition(pstGroup2, groupNotRoundedSetpoint, -1);
        setMarginsForTap(pstGroup2, -1, 120, 150);
        setMarginsForTap(pstGroup2, 0, 150, 140); // Tap 0 should be selected because min margin is 140

        rangeActionSetpointResult = new RangeActionSetpointResultImpl(Map.of(
            pstRangeAction, startingSetPoint,
            pstGroup1, startingSetPoint,
            pstGroup2, startingSetPoint
        ));
        rangeActionActivationResult = new RangeActionActivationResultImpl(rangeActionSetpointResult);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(pstRangeAction, optimizedState, notRoundedSetpoint);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(pstGroup1, optimizedState, groupNotRoundedSetpoint);
        ((RangeActionActivationResultImpl) rangeActionActivationResult).putResult(pstGroup2, optimizedState, groupNotRoundedSetpoint);
        when(optimizationPerimeter.getRangeActionsPerState()).thenReturn(Map.of(
            optimizedState, Set.of(pstRangeAction, pstGroup1, pstGroup2)
        ));

        RangeActionActivationResult updatedRangeActionActivationResult = computeUpdatedRangeActionResult();

        assertEquals(2.5, updatedRangeActionActivationResult.getOptimizedSetpoint(pstRangeAction, optimizedState), DOUBLE_TOLERANCE);
        assertEquals(0, updatedRangeActionActivationResult.getOptimizedSetpoint(pstGroup1, optimizedState), DOUBLE_TOLERANCE);
        assertEquals(0, updatedRangeActionActivationResult.getOptimizedSetpoint(pstGroup2, optimizedState), DOUBLE_TOLERANCE);
    }
}