diff --git a/docs/loadflow/loadflow.md b/docs/loadflow/loadflow.md index 82d7716139..8cbe23319b 100644 --- a/docs/loadflow/loadflow.md +++ b/docs/loadflow/loadflow.md @@ -237,7 +237,7 @@ Where: * "Interchange Target" is the interchange target parameter of the area. * "Slack Injection" is the active power mismatch of the slack bus(es) present in the area (see [Slack bus mismatch attribution](#slack-bus-mismatch-attribution)). -The outer loop iterates until this mismatch is below the configured [parameter `areaInterchangePMaxMismatch`](parameters.md) for all areas. +The outer loop iterates until the absolute value of this mismatch is below the configured [parameter `areaInterchangePMaxMismatch`](parameters.md) for all areas. When it is the case, "interchange only" mismatch is computed for all areas: @@ -245,11 +245,11 @@ $$ Interchange Mismatch = Interchange - Interchange Target $$ -If this mismatch for all areas and the slack injection of the buses without area are below the configured [parameter `slackBusPMaxMismatch`](parameters.md), then the outer loop declares a stable status, meaning that the interchanges are correct and the slack bus active power is distributed. +If the absolute value of this mismatch is below the [parameter `areaInterchangePMaxMismatch`](parameters.md) for all areas and the absolute value of slack bus active power mismatch is below the [parameter `slackBusPMaxMismatch`](parameters.md), then the outer loop declares a stable status, meaning that the interchanges are correct and the slack bus active power is distributed. If not, the remaining slack bus mismatch is first distributed over the buses that have no area. -If some slack bus mismatch still remains, it is distributed over all buses of the network. +If some slack bus mismatch still remains, it is distributed over all the areas (see [Remaining slack bus mismatch distribution](#Remaining-slack-bus-mismatch-distribution)). ### Areas validation There are some cases where areas are considered invalid and will not be considered for the area interchange control: @@ -277,6 +277,18 @@ Indeed, in this case the slack injection can be seen as an interchange to 'the v - All connected branches are boundaries of those areas: Not attributed to anyone, the mismatch will already be present in the interchange mismatch - Some connected branches are not declared as boundaries of the areas: Amount of mismatch to distribute is split equally among the areas (added to their "total mismatch") +### Remaining slack bus mismatch distribution +This section covers the case where the "total mismatch" of all areas is in [-[`areaInterchangePMaxMismatch`](parameters.md);[`areaInterchangePMaxMismatch`](parameters.md)], but some slack bus active power mismatch remains (even after trying to distribute on buses with no area). +This remaining slack bus active power mismatch will be distributed by all areas, each one will get a share of this mismatch to distribute. + +This distribution will affect each area's interchange and will not necessarily make it closer to its target. +The distribution factor of each area will be computed in a way that minimises chances of having the area increase its interchange mismatch up to more than [`areaInterchangePMaxMismatch`](parameters.md) in absolute value. +So the factor is proportional to the "margin" of active power that the area can distribute while keeping $-areaInterchangePMaxMismatch < Area Total Mismatch < areaInterchangePMaxMismatch$. + +It is computed like this: +$Factor = sign(Slack Bus Mismatch) * Area Total Mismatch + areaInterchangePMaxMismatch $ +Then factors are normalized to have sum of factors equal to 1. + ### Zero impedance boundary branches The following applies when the [`lowImpedanceBranchMode`](parameters.md) is set to `REPLACE_BY_ZERO_IMPEDANCE_LINE`. Currently, computations involving zero-impedance branches used as boundary branches are not supported. diff --git a/src/main/java/com/powsybl/openloadflow/lf/outerloop/AbstractAreaInterchangeControlOuterLoop.java b/src/main/java/com/powsybl/openloadflow/lf/outerloop/AbstractAreaInterchangeControlOuterLoop.java index 4e91cd5888..77d9fb42e3 100644 --- a/src/main/java/com/powsybl/openloadflow/lf/outerloop/AbstractAreaInterchangeControlOuterLoop.java +++ b/src/main/java/com/powsybl/openloadflow/lf/outerloop/AbstractAreaInterchangeControlOuterLoop.java @@ -22,8 +22,11 @@ import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -47,7 +50,7 @@ public abstract class AbstractAreaInterchangeControlOuterLoop< private final Logger logger; - protected static final String FAILED_TO_DISTRIBUTE_INTERCHANGE_ACTIVE_POWER_MISMATCH = "Failed to distribute interchange active power mismatch"; + protected static final String FAILED_TO_DISTRIBUTE_ACTIVE_POWER_MISMATCH = "Failed to distribute active power mismatch"; protected static final String DEFAULT_NO_AREA_NAME = "BUSES_WITH_NO_AREA"; @@ -56,6 +59,12 @@ public abstract class AbstractAreaInterchangeControlOuterLoop< protected final ActivePowerDistribution activePowerDistribution; protected final OuterLoop noAreaOuterLoop; + private enum ActivePowerDistributionType { + AREA_INTERCHANGE, SLACK + } + + private record AreaActivePowerDistributionResult(String areaId, ActivePowerDistributionType type, double initialMismatch, ActivePowerDistribution.Result distributionResult) { } + protected AbstractAreaInterchangeControlOuterLoop(ActivePowerDistribution activePowerDistribution, OuterLoop noAreaOuterLoop, double slackBusPMaxMismatch, double areaInterchangePMaxMismatch, Logger logger) { this.activePowerDistribution = Objects.requireNonNull(activePowerDistribution); this.slackBusPMaxMismatch = slackBusPMaxMismatch; @@ -83,11 +92,11 @@ public OuterLoopResult check(O context, ReportNode reportNode) { } double slackBusActivePowerMismatch = getSlackBusActivePowerMismatch(context); AreaInterchangeControlContextData contextData = (AreaInterchangeControlContextData) context.getData(); - Map areaSlackDistributionParticipationFactor = contextData.getAreaSlackDistributionParticipationFactor(); + Map slackDistributionFactorByAreaId = contextData.getSlackDistributionFactorByAreaId(); // First, we balance the areas that have a mismatch in their interchange power flow, and take the slack mismatch into account. Map areaInterchangeWithSlackMismatches = network.getAreaStream() - .collect(Collectors.toMap(area -> area, area -> getInterchangeMismatchWithSlack(area, slackBusActivePowerMismatch, areaSlackDistributionParticipationFactor))); + .collect(Collectors.toMap(area -> area, area -> getInterchangeMismatchWithSlack(area, slackBusActivePowerMismatch, slackDistributionFactorByAreaId))); List areasToBalance = areaInterchangeWithSlackMismatches.entrySet().stream() .filter(entry -> { double areaActivePowerMismatch = entry.getValue(); @@ -114,39 +123,82 @@ public OuterLoopResult check(O context, ReportNode reportNode) { // Which should at the end of the day end up by not having interchange mismatches. Set busesWithoutArea = contextData.getBusesWithoutArea(); Map, Double>> busesNoAreaDistributionMap = Map.of(DEFAULT_NO_AREA_NAME, Pair.of(busesWithoutArea, slackBusActivePowerMismatch)); - Map busesNoAreaDistributionResult = distributeActivePower(busesNoAreaDistributionMap); - - double remainingSlackBusMismatch = busesNoAreaDistributionResult.get(DEFAULT_NO_AREA_NAME).remainingMismatch(); + List busesNoAreaDistributionResult = distributeActivePower(busesNoAreaDistributionMap); + double remainingSlackBusMismatch = busesNoAreaDistributionResult.get(0).distributionResult.remainingMismatch(); if (lessThanSlackBusMaxMismatch(remainingSlackBusMismatch)) { - return buildOuterLoopResult(busesNoAreaDistributionMap, busesNoAreaDistributionResult, reportNode, context); + return buildOuterLoopResult(busesNoAreaDistributionResult, reportNode, context); } else { - // If some slack mismatch still remains (when there is no buses without area that participate for example), we distribute the remaining slack mismatch on all buses. + // If some slack mismatch still remains (when there is no buses without area that participate for example), we distribute the remaining slack mismatch on all areas. // If this mismatch is small, distribution will not change much the interchange of areas: zero, one or two more iterations should be needed to have a successful result. // If this mismatch is too high and distribution changes a lot the interchanges of areas, then it is very likely that there is no feasible solution that matches the interchange target inputs. - Map, Double>> allBusesDistributionMap = Map.of("ALL_NETWORK", Pair.of(new HashSet<>(network.getBuses()), remainingSlackBusMismatch)); - Map allBusesDistributionResult = distributeActivePower(allBusesDistributionMap); - return buildOuterLoopResult(allBusesDistributionMap, allBusesDistributionResult, reportNode, context); + List areaResults = distributeRemainingSlackMismatch(remainingSlackBusMismatch, network, slackDistributionFactorByAreaId); + return buildOuterLoopResult(areaResults, reportNode, context); } } return new OuterLoopResult(this, OuterLoopStatus.STABLE); } - Map, Double>> areasDistributionMap = areasToBalance.stream() - .collect(Collectors.toMap(LfArea::getId, area -> Pair.of(area.getBuses(), getInterchangeMismatchWithSlack(area, slackBusActivePowerMismatch, areaSlackDistributionParticipationFactor)))); - Map areasDistributionResults = distributeActivePower(areasDistributionMap); - return buildOuterLoopResult(areasDistributionMap, areasDistributionResults, reportNode, context); + .collect(Collectors.toMap(LfArea::getId, area -> Pair.of(area.getBuses(), getInterchangeMismatchWithSlack(area, slackBusActivePowerMismatch, slackDistributionFactorByAreaId)))); + List areasDistributionResults = distributeActivePower(areasDistributionMap); + return buildOuterLoopResult(areasDistributionResults, reportNode, context); } - protected Map distributeActivePower(Map, Double>> areas) { - Map resultByArea = new HashMap<>(); + private List distributeActivePower(Map, Double>> areas) { + List areaResults = new ArrayList<>(); for (Map.Entry, Double>> e : areas.entrySet()) { double areaActivePowerMismatch = e.getValue().getRight(); - ActivePowerDistribution.Result result = activePowerDistribution.run(null, e.getValue().getLeft(), areaActivePowerMismatch); - resultByArea.put(e.getKey(), result); + ActivePowerDistribution.Result distributionResult = activePowerDistribution.run(null, e.getValue().getLeft(), areaActivePowerMismatch); + areaResults.add(new AreaActivePowerDistributionResult(e.getKey(), ActivePowerDistributionType.AREA_INTERCHANGE, areaActivePowerMismatch, distributionResult)); + } + return areaResults; + } + + private List distributeRemainingSlackMismatch(double mismatch, LfNetwork network, Map slackDistributionFactorByAreaId) { + List resultByArea = new ArrayList<>(); + + Map distributionFactorByArea = getSlackDistributionFactorByArea(mismatch, network.getAreas(), slackDistributionFactorByAreaId); + + Comparator> mismatchComparator = Comparator.comparingDouble(Map.Entry::getValue); + Iterator areaIteratorSortedByFactor = distributionFactorByArea.entrySet().stream(). + sorted(mismatchComparator.reversed()) + .map(Map.Entry::getKey) + .iterator(); + + double remainingMismatch = mismatch; + while (areaIteratorSortedByFactor.hasNext() && Math.abs(remainingMismatch) > ActivePowerDistribution.P_RESIDUE_EPS) { + LfArea area = areaIteratorSortedByFactor.next(); + double factor = distributionFactorByArea.get(area); + double areaActivePowerMismatch; + if (Math.abs(mismatch * factor) > ActivePowerDistribution.P_RESIDUE_EPS) { + areaActivePowerMismatch = mismatch * factor; + } else { + // If the mismatch is too small we make sure that all mismatch is distributed by making the areas higher on the list distribute more. + // Add this small 1.01 factor to make sure to be above epsilon and distribute some power + areaActivePowerMismatch = Math.signum(mismatch) * 1.01 * ActivePowerDistribution.P_RESIDUE_EPS; + } + ActivePowerDistribution.Result distributionResult = activePowerDistribution.run(null, area.getBuses(), areaActivePowerMismatch); + resultByArea.add(new AreaActivePowerDistributionResult(area.getId(), ActivePowerDistributionType.SLACK, areaActivePowerMismatch, distributionResult)); + remainingMismatch = remainingMismatch - areaActivePowerMismatch + distributionResult.remainingMismatch(); } return resultByArea; } + private Map getSlackDistributionFactorByArea(double mismatch, List areas, Map slackDistributionFactorByAreaId) { + // Compute the "margin" that has the area = the amount of power it can distribute and still have target - maxMismatch < interchange < target + maxMismatch + // We use the interchangeMismatchWithSlack here because: + // For areas without slack bus it changes nothing compared to use interchangeMismatch + // For areas with slack bus, the interchangeMismatchWithSlack is the interchange it would have if all the slack was distributed. + Map interchangeMarginByArea = areas.stream() + .collect(Collectors.toMap( + a -> a, + a -> Math.signum(mismatch) * getInterchangeMismatchWithSlack(a, mismatch, slackDistributionFactorByAreaId) + this.areaInterchangePMaxMismatch / PerUnit.SB)); + + // normalize factors + double sumMargin = interchangeMarginByArea.values().stream().mapToDouble(aDouble -> aDouble).sum(); + return interchangeMarginByArea.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue() / sumMargin)); + } + protected boolean lessThanInterchangeMaxMismatch(double mismatch) { return Math.abs(mismatch) <= this.areaInterchangePMaxMismatch / PerUnit.SB || Math.abs(mismatch) <= ActivePowerDistribution.P_RESIDUE_EPS; } @@ -159,43 +211,42 @@ protected double getInterchangeMismatch(LfArea area) { return area.getInterchange() - area.getInterchangeTarget(); } - protected double getInterchangeMismatchWithSlack(LfArea area, double slackBusActivePowerMismatch, Map areaSlackDistributionParticipationFactor) { - return area.getInterchange() - area.getInterchangeTarget() + getSlackInjection(area.getId(), slackBusActivePowerMismatch, areaSlackDistributionParticipationFactor); + protected double getInterchangeMismatchWithSlack(LfArea area, double slackBusActivePowerMismatch, Map slackDistributionFactorByAreaId) { + return area.getInterchange() - area.getInterchangeTarget() + getSlackInjection(area.getId(), slackBusActivePowerMismatch, slackDistributionFactorByAreaId); + } + + protected double getSlackInjection(String areaId, double slackBusActivePowerMismatch, Map slackDistributionFactorByAreaId) { + return slackDistributionFactorByAreaId.getOrDefault(areaId, 0.0) * slackBusActivePowerMismatch; } - protected double getSlackInjection(String areaId, double slackBusActivePowerMismatch, Map areaSlackDistributionParticipationFactor) { - return areaSlackDistributionParticipationFactor.getOrDefault(areaId, 0.0) * slackBusActivePowerMismatch; + private boolean hasRemainingMismatch(AreaActivePowerDistributionResult areaResult) { + return switch (areaResult.type) { + case SLACK -> !lessThanSlackBusMaxMismatch(areaResult.distributionResult.remainingMismatch()); + case AREA_INTERCHANGE -> !lessThanInterchangeMaxMismatch(areaResult.distributionResult.remainingMismatch()); + }; } - protected OuterLoopResult buildOuterLoopResult(Map, Double>> areas, Map resultByArea, ReportNode reportNode, O context) { - Map remainingMismatchByArea = new HashMap<>(); - Map iterationsByArea = new HashMap<>(); + private OuterLoopResult buildOuterLoopResult(List areaResults, ReportNode reportNode, O context) { + List remainingMismatches = new ArrayList<>(); double totalDistributedActivePower = 0.0; boolean movedBuses = false; - for (Map.Entry e : resultByArea.entrySet()) { - String area = e.getKey(); - ActivePowerDistribution.Result result = e.getValue(); - if (!lessThanInterchangeMaxMismatch(result.remainingMismatch())) { - remainingMismatchByArea.put(area, result.remainingMismatch()); + for (AreaActivePowerDistributionResult areaResult : areaResults) { + if (hasRemainingMismatch(areaResult)) { + remainingMismatches.add(areaResult); } - totalDistributedActivePower += areas.get(area).getRight() - result.remainingMismatch(); - movedBuses |= result.movedBuses(); - iterationsByArea.put(area, result.iteration()); + ActivePowerDistribution.Result distributionResult = areaResult.distributionResult; + totalDistributedActivePower += areaResult.initialMismatch - distributionResult.remainingMismatch(); + movedBuses |= distributionResult.movedBuses(); } ReportNode iterationReportNode = Reports.createOuterLoopIterationReporter(reportNode, context.getOuterLoopTotalIterations() + 1); AreaInterchangeControlContextData contextData = (AreaInterchangeControlContextData) context.getData(); contextData.addDistributedActivePower(totalDistributedActivePower); - if (!remainingMismatchByArea.isEmpty()) { - logger.error(FAILED_TO_DISTRIBUTE_INTERCHANGE_ACTIVE_POWER_MISMATCH); - ReportNode failureReportNode = Reports.reportAreaInterchangeControlDistributionFailure(iterationReportNode); - remainingMismatchByArea.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { - logger.error("Remaining mismatch for Area {}: {} MW", entry.getKey(), entry.getValue() * PerUnit.SB); - Reports.reportAreaInterchangeControlAreaMismatch(failureReportNode, entry.getKey(), entry.getValue() * PerUnit.SB); - }); + if (!remainingMismatches.isEmpty()) { + reportAndLogAreaActivePowerDistributionFailure(iterationReportNode, remainingMismatches); switch (context.getLoadFlowContext().getParameters().getSlackDistributionFailureBehavior()) { case THROW -> - throw new PowsyblException(FAILED_TO_DISTRIBUTE_INTERCHANGE_ACTIVE_POWER_MISMATCH); + throw new PowsyblException(FAILED_TO_DISTRIBUTE_ACTIVE_POWER_MISMATCH); case LEAVE_ON_SLACK_BUS -> { return new OuterLoopResult(this, movedBuses ? OuterLoopStatus.UNSTABLE : OuterLoopStatus.STABLE); } @@ -204,16 +255,13 @@ protected OuterLoopResult buildOuterLoopResult(Map, Doub // Since we will not be re-running the solver, revert distributedActivePower reporting which would otherwise be misleading. // Said differently, we report that we didn't distribute anything, and this is indeed consistent with the network state. contextData.addDistributedActivePower(-totalDistributedActivePower); - return new OuterLoopResult(this, OuterLoopStatus.FAILED, FAILED_TO_DISTRIBUTE_INTERCHANGE_ACTIVE_POWER_MISMATCH); + return new OuterLoopResult(this, OuterLoopStatus.FAILED, FAILED_TO_DISTRIBUTE_ACTIVE_POWER_MISMATCH); } default -> throw new IllegalStateException("Unexpected SlackDistributionFailureBehavior value"); } } else { if (movedBuses) { - areas.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { - logger.info("Area {} interchange mismatch ({} MW) distributed in {} distribution iteration(s)", entry.getKey(), entry.getValue().getValue() * PerUnit.SB, iterationsByArea.get(entry.getKey())); - Reports.reportAreaInterchangeControlAreaDistributionSuccess(iterationReportNode, entry.getKey(), entry.getValue().getValue() * PerUnit.SB, iterationsByArea.get(entry.getKey())); - }); + reportAndLogAreaActivePowerDistributionSuccess(areaResults, iterationReportNode); return new OuterLoopResult(this, OuterLoopStatus.UNSTABLE); } else { return new OuterLoopResult(this, OuterLoopStatus.STABLE); @@ -221,6 +269,29 @@ protected OuterLoopResult buildOuterLoopResult(Map, Doub } } + private void reportAndLogAreaActivePowerDistributionSuccess(List areaResults, ReportNode iterationReportNode) { + areaResults.stream().sorted(Comparator.comparing(areaResult -> areaResult.areaId)).forEach(areaResult -> { + boolean isInterchangeDistribution = ActivePowerDistributionType.AREA_INTERCHANGE.equals(areaResult.type); + String distributionType = isInterchangeDistribution ? "interchange mismatch" : "slack distribution share"; + logger.info("Area {} {} ({} MW) distributed in {} distribution iteration(s)", areaResult.areaId, distributionType, areaResult.initialMismatch * PerUnit.SB, areaResult.distributionResult.iteration()); + Reports.reportAicAreaDistributionSuccess(iterationReportNode, areaResult.areaId, areaResult.initialMismatch * PerUnit.SB, areaResult.distributionResult.iteration(), isInterchangeDistribution); + }); + } + + private void reportAndLogAreaActivePowerDistributionFailure(ReportNode iterationReportNode, List remainingMismatches) { + logger.error(FAILED_TO_DISTRIBUTE_ACTIVE_POWER_MISMATCH); + ReportNode failureReportNode = Reports.reportAreaInterchangeControlDistributionFailure(iterationReportNode); + remainingMismatches.stream() + .sorted(Comparator.comparing(areaResult -> areaResult.areaId)) + .forEach(areaResult -> { + boolean isInterchangeDistribution = ActivePowerDistributionType.AREA_INTERCHANGE.equals(areaResult.type); + String mismatchType = isInterchangeDistribution ? "interchange" : "slack distribution"; + double remainingMismatch = areaResult.distributionResult.remainingMismatch() * PerUnit.SB; + logger.error("Remaining {} mismatch for Area {}: {} MW", mismatchType, areaResult.areaId, remainingMismatch); + Reports.reportAicAreaDistributionMismatch(failureReportNode, areaResult.areaId, remainingMismatch, isInterchangeDistribution); + }); + } + protected Set listBusesWithoutArea(LfNetwork network) { return network.getBuses().stream() .filter(b -> b.getArea().isEmpty()) @@ -229,13 +300,13 @@ protected Set listBusesWithoutArea(LfNetwork network) { } protected Map allocateSlackDistributionParticipationFactors(LfNetwork lfNetwork) { - Map areaSlackDistributionParticipationFactor = new HashMap<>(); + Map slackDistributionFactorByAreaId = new HashMap<>(); List slackBuses = lfNetwork.getSlackBuses(); int totalSlackBusCount = slackBuses.size(); for (LfBus slackBus : slackBuses) { Optional areaOpt = slackBus.getArea(); if (areaOpt.isPresent()) { - areaSlackDistributionParticipationFactor.put(areaOpt.get().getId(), areaSlackDistributionParticipationFactor.getOrDefault(areaOpt.get().getId(), 0.0) + 1.0 / totalSlackBusCount); + slackDistributionFactorByAreaId.put(areaOpt.get().getId(), slackDistributionFactorByAreaId.getOrDefault(areaOpt.get().getId(), 0.0) + 1.0 / totalSlackBusCount); } else { // When a bus is connected to one or multiple Areas but the flow through the bus is not considered for those areas' interchange power flow, // its slack injection should be considered for the slack of some Areas that it is connected to. @@ -255,16 +326,16 @@ protected Map allocateSlackDistributionParticipationFactors(LfNe .filter(area -> area.getBoundaries().stream().noneMatch(boundary -> connectedBranches.contains(boundary.getBranch()))) .collect(Collectors.toSet()); if (!areasSharingSlack.isEmpty()) { - areasSharingSlack.forEach(area -> areaSlackDistributionParticipationFactor.put(area.getId(), areaSlackDistributionParticipationFactor.getOrDefault(area.getId(), 0.0) + 1.0 / areasSharingSlack.size() / totalSlackBusCount)); + areasSharingSlack.forEach(area -> slackDistributionFactorByAreaId.put(area.getId(), slackDistributionFactorByAreaId.getOrDefault(area.getId(), 0.0) + 1.0 / areasSharingSlack.size() / totalSlackBusCount)); logger.warn("Slack bus {} is not in any Area and is connected to Areas: {}. Areas {} are not considering the flow through this bus for their interchange flow. The slack will be distributed between those areas.", slackBus.getId(), connectedAreas.stream().map(LfArea::getId).toList(), areasSharingSlack.stream().map(LfArea::getId).toList()); } else { - areaSlackDistributionParticipationFactor.put(DEFAULT_NO_AREA_NAME, areaSlackDistributionParticipationFactor.getOrDefault(DEFAULT_NO_AREA_NAME, 0.0) + 1.0 / totalSlackBusCount); + slackDistributionFactorByAreaId.put(DEFAULT_NO_AREA_NAME, slackDistributionFactorByAreaId.getOrDefault(DEFAULT_NO_AREA_NAME, 0.0) + 1.0 / totalSlackBusCount); } } } - return areaSlackDistributionParticipationFactor; + return slackDistributionFactorByAreaId; } @Override diff --git a/src/main/java/com/powsybl/openloadflow/lf/outerloop/AreaInterchangeControlContextData.java b/src/main/java/com/powsybl/openloadflow/lf/outerloop/AreaInterchangeControlContextData.java index f07c8008d2..9ccb0f5ab4 100644 --- a/src/main/java/com/powsybl/openloadflow/lf/outerloop/AreaInterchangeControlContextData.java +++ b/src/main/java/com/powsybl/openloadflow/lf/outerloop/AreaInterchangeControlContextData.java @@ -24,20 +24,20 @@ public class AreaInterchangeControlContextData extends DistributedSlackContextDa /** * The part of the total slack active power mismatch that should be added to the Area's net interchange mismatch, ie the part of the slack that should be distributed in the Area. */ - private final Map areaSlackDistributionParticipationFactor; + private final Map slackDistributionFactorByAreaId; public AreaInterchangeControlContextData(Set busesWithoutArea, Map areaSlackDistributionParticipationFactor) { super(); this.busesWithoutArea = new HashSet<>(busesWithoutArea); - this.areaSlackDistributionParticipationFactor = new HashMap<>(areaSlackDistributionParticipationFactor); + this.slackDistributionFactorByAreaId = new HashMap<>(areaSlackDistributionParticipationFactor); } public Set getBusesWithoutArea() { return busesWithoutArea; } - public Map getAreaSlackDistributionParticipationFactor() { - return areaSlackDistributionParticipationFactor; + public Map getSlackDistributionFactorByAreaId() { + return slackDistributionFactorByAreaId; } } diff --git a/src/main/java/com/powsybl/openloadflow/util/Reports.java b/src/main/java/com/powsybl/openloadflow/util/Reports.java index a806291945..93b4be820e 100644 --- a/src/main/java/com/powsybl/openloadflow/util/Reports.java +++ b/src/main/java/com/powsybl/openloadflow/util/Reports.java @@ -217,18 +217,20 @@ public static ReportNode reportAreaInterchangeControlDistributionFailure(ReportN .add(); } - public static void reportAreaInterchangeControlAreaMismatch(ReportNode reportNode, String area, double mismatch) { + public static void reportAicAreaDistributionMismatch(ReportNode reportNode, String area, double mismatch, boolean isInterchangeDistribution) { + String messageTemplate = isInterchangeDistribution ? "olf.areaInterchangeControlAreaMismatch" : "olf.areaInterchangeControlAreaSlackMismatch"; reportNode.newReportNode() - .withMessageTemplate("olf.areaInterchangeControlAreaMismatch") + .withMessageTemplate(messageTemplate) .withUntypedValue("area", area) .withTypedValue(MISMATCH, mismatch, OpenLoadFlowReportConstants.MISMATCH_TYPED_VALUE) .withSeverity(TypedValue.WARN_SEVERITY) .add(); } - public static void reportAreaInterchangeControlAreaDistributionSuccess(ReportNode reportNode, String area, double mismatch, int iterationCount) { + public static void reportAicAreaDistributionSuccess(ReportNode reportNode, String area, double mismatch, int iterationCount, boolean isInterchangeDistribution) { + String messageTemplate = isInterchangeDistribution ? "olf.areaInterchangeControlAreaDistributionSuccess" : "olf.areaInterchangeControlSlackDistributionSuccess"; reportNode.newReportNode() - .withMessageTemplate("olf.areaInterchangeControlAreaDistributionSuccess") + .withMessageTemplate(messageTemplate) .withUntypedValue("area", area) .withTypedValue(MISMATCH, mismatch, OpenLoadFlowReportConstants.MISMATCH_TYPED_VALUE) .withUntypedValue(ITERATION_COUNT, iterationCount) diff --git a/src/main/resources/com/powsybl/openloadflow/reports.properties b/src/main/resources/com/powsybl/openloadflow/reports.properties index 328a92b85b..978db18e66 100644 --- a/src/main/resources/com/powsybl/openloadflow/reports.properties +++ b/src/main/resources/com/powsybl/openloadflow/reports.properties @@ -13,8 +13,10 @@ olf.acSecurityAnalysis = AC security analysis on network '${networkId}' olf.activePowerControlPstsChangedTaps = ${numOfActivePowerControlPstsThatChangedTap} active power control PST(s) changed taps olf.angleReferenceBusSelection = Angle reference bus: ${referenceBus} olf.areaInterchangeControlAreaDistributionSuccess = Area ${area} interchange mismatch (${mismatch} MW) distributed in ${iterationCount} distribution iteration(s) -olf.areaInterchangeControlAreaMismatch = Remaining mismatch for Area ${area}: ${mismatch} MW +olf.areaInterchangeControlAreaMismatch = Remaining interchange mismatch for Area ${area}: ${mismatch} MW +olf.areaInterchangeControlAreaSlackMismatch = Remaining slack distribution mismatch for Area ${area}: ${mismatch} MW olf.areaInterchangeControlDistributionFailure = Failed to distribute interchange active power mismatch +olf.areaInterchangeControlSlackDistributionSuccess = Area ${area} slack distribution share (${mismatch} MW) distributed in ${iterationCount} distribution iteration(s) olf.areaNoInterchangeControlMissingBuses = Area ${area} will not be considered in area interchange control, reason: Area does not have all its boundary buses in the same connected component or synchronous component olf.areaNoInterchangeControlNoBoundary = Area ${area} will not be considered in area interchange control, reason: Area does not have any area boundary olf.areaNoInterchangeControlNoInterchangeTarget = Area ${area} will not be considered in area interchange control, reason: Area does not have an interchange target diff --git a/src/main/resources/com/powsybl/openloadflow/reports_fr.properties b/src/main/resources/com/powsybl/openloadflow/reports_fr.properties index eb227c4594..9b468679b8 100644 --- a/src/main/resources/com/powsybl/openloadflow/reports_fr.properties +++ b/src/main/resources/com/powsybl/openloadflow/reports_fr.properties @@ -14,7 +14,9 @@ olf.activePowerControlPstsChangedTaps = ${numOfActivePowerControlPstsThatChanged olf.angleReferenceBusSelection = Noeud de référence des angles : ${referenceBus} olf.areaInterchangeControlAreaDistributionSuccess = Zone ${area) - écart de ${mismatch} MW par rapport à la cible des échanges interzones compensé en ${iterationCount} itération(s) olf.areaInterchangeControlAreaMismatch = Zone ${area) - écart restant par rapport à la cible des échanges interzones: ${mismatch} MW +olf.areaInterchangeControlAreaSlackMismatch = Zone ${area} - échec de la participation à la compensation de puissance active au noeud bilan, ${mismatch} MW restants. olf.areaInterchangeControlDistributionFailure = Échec de la compensation d'échange interzones +olf.areaInterchangeControlSlackDistributionSuccess = Zone ${area) - participation de ${mismatch} MW à la compensation de puissance active du noeud bilan réalisée en ${iterationCount} itération(s) olf.areaNoInterchangeControlMissingBuses = Zone ${area} - zone non prise en compte dans le contrôle des échanges interzones, raison : La zone n'a pas tous ses noeuds frontières sur le même composant connecté ou le même composant synchrone. olf.areaNoInterchangeControlNoBoundary = Zone ${area} - zone non prise en compte dans le contrôle des échanges interzones, raison : La zone n'a pas de limite de zone olf.areaNoInterchangeControlNoInterchangeTarget = Zone ${area} - zone non prise en compte dans le contrôle des échanges interzones, raison : La zone n'a pas de cible d'échange diff --git a/src/test/java/com/powsybl/openloadflow/ac/AreaInterchangeControlTest.java b/src/test/java/com/powsybl/openloadflow/ac/AreaInterchangeControlTest.java index 548b8010ec..06248859e5 100644 --- a/src/test/java/com/powsybl/openloadflow/ac/AreaInterchangeControlTest.java +++ b/src/test/java/com/powsybl/openloadflow/ac/AreaInterchangeControlTest.java @@ -7,22 +7,28 @@ */ package com.powsybl.openloadflow.ac; +import com.powsybl.commons.report.ReportNode; +import com.powsybl.commons.test.PowsyblTestReportResourceBundle; import com.powsybl.iidm.network.Area; import com.powsybl.iidm.network.Network; import com.powsybl.loadflow.LoadFlow; import com.powsybl.loadflow.LoadFlowParameters; import com.powsybl.loadflow.LoadFlowResult; +import com.powsybl.loadflow.LoadFlowRunParameters; import com.powsybl.math.matrix.DenseMatrixFactory; import com.powsybl.openloadflow.OpenLoadFlowParameters; import com.powsybl.openloadflow.OpenLoadFlowProvider; import com.powsybl.openloadflow.network.*; import com.powsybl.openloadflow.network.impl.Networks; +import com.powsybl.openloadflow.util.LoadFlowAssert; +import com.powsybl.openloadflow.util.report.PowsyblOpenLoadFlowReportResourceBundle; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.io.IOException; import java.util.List; import java.util.concurrent.CompletionException; import java.util.stream.Stream; @@ -37,12 +43,15 @@ class AreaInterchangeControlTest { private LoadFlow.Runner loadFlowRunner; private LoadFlowParameters parameters; + private LoadFlowRunParameters runParameters; + private OpenLoadFlowParameters parametersExt; @BeforeEach void setUp() { loadFlowRunner = new LoadFlow.Runner(new OpenLoadFlowProvider(new DenseMatrixFactory())); parameters = new LoadFlowParameters(); + runParameters = new LoadFlowRunParameters().setParameters(parameters); parametersExt = OpenLoadFlowParameters.create(parameters) .setAreaInterchangeControl(true) .setSlackBusPMaxMismatch(1e-3) @@ -67,7 +76,7 @@ void twoAreasWithUnpairedDanglingLine() { Network network = MultiAreaNetworkFactory.createTwoAreasWithDanglingLine(); double interchangeTarget1 = -60; // area a1 has a boundary that is an unpaired dangling line with P0 = 20MW double interchangeTarget2 = 40; - runLfTwoAreas(network, interchangeTarget1, interchangeTarget2, -10, 4); + runLfTwoAreas(network, interchangeTarget1, interchangeTarget2, -10, 3); parameters.setDc(true); runLfTwoAreas(network, interchangeTarget1, interchangeTarget2, -10, 0); } @@ -151,7 +160,7 @@ private void runLfOneAreaSlackDistributionFailure(OpenLoadFlowParameters.SlackDi assertEquals(expectedDistributedP, mainComponentResult.getDistributedActivePower(), 1e-3); } else { CompletionException thrown = assertThrows(CompletionException.class, () -> loadFlowRunner.run(network, parameters)); - assertEquals("Failed to distribute interchange active power mismatch", thrown.getCause().getMessage()); + assertEquals("Failed to distribute active power mismatch", thrown.getCause().getMessage()); } } @@ -276,6 +285,89 @@ void tenAreasSlackDistribution() { assertEquals(0, result.getComponentResults().get(0).getSlackBusResults().get(0).getActivePowerMismatch(), parametersExt.getSlackBusPMaxMismatch()); } + @Test + void remainingSlackDistribution() throws IOException { + Network network = MultiAreaNetworkFactory.createTwoAreasWithXNodeHighZ(); + ReportNode node = ReportNode.newRootReportNode() + .withResourceBundles(PowsyblOpenLoadFlowReportResourceBundle.BASE_NAME, PowsyblTestReportResourceBundle.TEST_BASE_NAME) + .withMessageTemplate("test") + .build(); + parametersExt.setAreaInterchangePMaxMismatch(0.5); + var result = loadFlowRunner.run(network, runParameters.setReportNode(node)); + assertEquals(LoadFlowResult.Status.FULLY_CONVERGED, result.getStatus()); + assertEquals(0, result.getComponentResults().get(0).getSlackBusResults().get(0).getActivePowerMismatch(), parametersExt.getSlackBusPMaxMismatch()); + String expectedReport = """ + + test + + Load flow on network 'areas' + + Network CC0 SC0 + + Network info + Network has 4 buses and 3 branches + Network balance: active generation=140 MW, active load=110 MW, reactive generation=0 MVar, reactive load=15 MVar + Angle reference bus: bx1_vl_0 + Slack bus: bx1_vl_0 + + Outer loop AreaInterchangeControl + + Outer loop iteration 1 + Area a1 interchange mismatch (10.168852 MW) distributed in 1 distribution iteration(s) + Area a2 interchange mismatch (-39.924115 MW) distributed in 1 distribution iteration(s) + + Outer loop iteration 2 + Area a1 slack distribution share (0.293861 MW) distributed in 1 distribution iteration(s) + Area a2 slack distribution share (0.331121 MW) distributed in 1 distribution iteration(s) + Outer loop VoltageMonitoring + Outer loop ReactiveLimits + Outer loop AreaInterchangeControl + Outer loop VoltageMonitoring + Outer loop ReactiveLimits + AC load flow completed successfully (solverStatus=CONVERGED, outerloopStatus=STABLE) + """; + LoadFlowAssert.assertTxtReportEquals(expectedReport, node); + } + + @Test + void remainingSlackDistributionShares() throws IOException { + Network network = MultiAreaNetworkFactory.createTwoAreasWithXNode(); + ReportNode node = ReportNode.newRootReportNode() + .withResourceBundles(PowsyblOpenLoadFlowReportResourceBundle.BASE_NAME, PowsyblTestReportResourceBundle.TEST_BASE_NAME) + .withMessageTemplate("test") + .build(); + + // slack bus in area a1 + parameters.setReadSlackBus(true); + parametersExt.setSlackBusSelectionMode(SlackBusSelectionMode.NAME); + parametersExt.setSlackBusId("vl1_0"); + + network.getGenerator("g1").setTargetP(70.4); + network.getGenerator("gen3").setTargetP(40); + + parametersExt.setAreaInterchangePMaxMismatch(0.5); + network.getArea("a1").setInterchangeTarget(-10); // will have a margin of 0.9 for slack distribution (interchange with slack =-10.4 and max acceptable interchange = -9.5) + network.getArea("a2").setInterchangeTarget(9.51); // will have a margin of 0.01 for slack distribution (interchange with slack = 10 and max acceptable interchange = 10.01) + + var result = loadFlowRunner.run(network, runParameters.setReportNode(node)); + assertEquals(LoadFlowResult.Status.FULLY_CONVERGED, result.getStatus()); + assertEquals(0, result.getComponentResults().get(0).getSlackBusResults().get(0).getActivePowerMismatch(), parametersExt.getSlackBusPMaxMismatch()); + String expectedReport = """ + + test + + Load flow on network 'areas' + + Network CC0 SC0 + + Network info + Network has 4 buses and 3 branches + Network balance: active generation=110.4 MW, active load=110 MW, reactive generation=0 MVar, reactive load=15 MVar + Angle reference bus: vl1_0 + Slack bus: vl1_0 + + Outer loop AreaInterchangeControl + + Outer loop iteration 1 + Area a1 slack distribution share (-0.395604 MW) distributed in 1 distribution iteration(s) + Area a2 slack distribution share (-0.004396 MW) distributed in 1 distribution iteration(s) + Outer loop VoltageMonitoring + Outer loop ReactiveLimits + Outer loop AreaInterchangeControl + Outer loop VoltageMonitoring + Outer loop ReactiveLimits + AC load flow completed successfully (solverStatus=CONVERGED, outerloopStatus=STABLE) + """; + LoadFlowAssert.assertTxtReportEquals(expectedReport, node); + } + private LoadFlowResult runLfTwoAreas(Network network, double interchangeTarget1, double interchangeTarget2, double expectedDistributedP, int expectedIterationCount) { Area area1 = network.getArea("a1"); Area area2 = network.getArea("a2"); diff --git a/src/test/java/com/powsybl/openloadflow/dc/DcLoadFlowTest.java b/src/test/java/com/powsybl/openloadflow/dc/DcLoadFlowTest.java index f463917a42..be935f24db 100644 --- a/src/test/java/com/powsybl/openloadflow/dc/DcLoadFlowTest.java +++ b/src/test/java/com/powsybl/openloadflow/dc/DcLoadFlowTest.java @@ -488,7 +488,7 @@ void outerLoopFailedTest() { assertFalse(result.isFullyConverged()); assertEquals(LoadFlowResult.ComponentResult.Status.FAILED, result.getComponentResults().get(0).getStatus()); - assertEquals("Outer loop failed: Failed to distribute interchange active power mismatch", result.getComponentResults().get(0).getStatusText()); + assertEquals("Outer loop failed: Failed to distribute active power mismatch", result.getComponentResults().get(0).getStatusText()); } @Test diff --git a/src/test/java/com/powsybl/openloadflow/network/MultiAreaNetworkFactory.java b/src/test/java/com/powsybl/openloadflow/network/MultiAreaNetworkFactory.java index 3e14d2672a..9291dd44da 100644 --- a/src/test/java/com/powsybl/openloadflow/network/MultiAreaNetworkFactory.java +++ b/src/test/java/com/powsybl/openloadflow/network/MultiAreaNetworkFactory.java @@ -206,6 +206,13 @@ public static Network createTwoAreasWithXNode() { return network; } + public static Network createTwoAreasWithXNodeHighZ() { + Network network = createTwoAreasWithXNode(); + network.getLine("l23_A1").setX(7).setR(10); + network.getLine("l23_A2").setX(7).setR(10); + return network; + } + /** * g1 100 MW gen3 40MW * | |