Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix limits handling in slack distribution on generators #1041

Merged
merged 6 commits into from
Jul 1, 2024

Conversation

jeandemanged
Copy link
Member

@jeandemanged jeandemanged commented Jun 3, 2024

Please check if the PR fulfills these requirements

  • The commit message follows our guidelines
  • Tests for the changes have been added (for bug fixes / features)
  • Docs have been added / updated (for bug fixes / features)

Does this PR already have an issue describing the problem?

No

What kind of change does this PR introduce?

Bug fix

What is the current behavior?

We can observe a nasty "hysteresis" effect on active power slack distribution on generators.

Slack distribution on loads does not exhibit this behavior.

It is quite common on large networks that DistributedSlack outerloop first move generation in one direction, and then goes back in the other direction, in particular due to PV-PQ switching.
In the current implementation, the DistributedSlack outerloop always restart from the current generator targetP. Because of the effects of the active power limits, the solution becomes "solution path dependent". Ultimately, a perfectly initially balanced input network may end up having some of the generators being moved upward and other generators being moved downward.

Here the current behavior (click to expand)
  @Test
  void testSlackMismatchChangingSign() {
      parameters.setUseReactiveLimits(true).getExtension(OpenLoadFlowParameters.class).setSlackBusPMaxMismatch(0.0001);
      Network network = DistributedSlackNetworkFactory.createWithLossesAndPvPqTypeSwitch();
      Generator g1 = network.getGenerator("g1");
      Generator g2 = network.getGenerator("g2");
      Generator g3 = network.getGenerator("g3");
      Generator g4 = network.getGenerator("g4");

      parameters.setBalanceType(LoadFlowParameters.BalanceType.PROPORTIONAL_TO_GENERATION_PARTICIPATION_FACTOR);
      network.getGeneratorStream().map(g -> (ActivePowerControl) g.getExtension(ActivePowerControl.class)).forEach(e -> e.setParticipationFactor(1.0));
      g1.setMaxP(110.0);
      g3.setMaxP(110.0);
      g4.setMaxP(110.0);
      LoadFlowResult result = loadFlowRunner.run(network, parameters);
      assertTrue(result.isFullyConverged());

      var expectedDistributedActivePower = -network.getGeneratorStream().mapToDouble(g -> g.getTargetP() + g.getTerminal().getP()).sum();
      assertEquals(120.1988, expectedDistributedActivePower, LoadFlowAssert.DELTA_POWER);
      assertEquals(expectedDistributedActivePower, result.getComponentResults().get(0).getDistributedActivePower(), LoadFlowAssert.DELTA_POWER);

      // All generators have the same participation factor, and should increase generation by 120.1988 MW
      // generator | targetP | maxP
      // ----------|---------|-------
      //   g1      |  100    |  110  --> expected to hit limit 110MW with 10MW distributed
      //   g2      |   90    |  300  --> expected to pick up the remaining slack 70.1988 MW
      //   g3      |   90    |  110  --> expected to hit limit 110MW with 20MW distributed
      //   g4      |   90    |  110  --> expected to hit limit 110MW with 20MW distributed
      assertActivePowerEquals(-106.931, g1.getTerminal()); // FIXME should be -110
      assertActivePowerEquals(-279.404, g2.getTerminal()); // FIXME should be -270.1988
      assertActivePowerEquals(-106.931, g3.getTerminal()); // FIXME should be -110
      assertActivePowerEquals(-106.931, g4.getTerminal()); // FIXME should be -110
  }

What is the new behavior (if this is a feature change)?
Active power distribution on generators now always restarts from the same initial point, as defined by LfGenerator.initialTargetP, solving the issue.

Here the new fixed behavior (click to expand)
  @Test
  void testSlackMismatchChangingSign() {
      parameters.setUseReactiveLimits(true).getExtension(OpenLoadFlowParameters.class).setSlackBusPMaxMismatch(0.0001);
      network = DistributedSlackNetworkFactory.createWithLossesAndPvPqTypeSwitch();
      g1 = network.getGenerator("g1");
      g2 = network.getGenerator("g2");
      g3 = network.getGenerator("g3");
      g4 = network.getGenerator("g4");

      parameters.setBalanceType(LoadFlowParameters.BalanceType.PROPORTIONAL_TO_GENERATION_PARTICIPATION_FACTOR);
      for (var g : network.getGenerators()) {
          ActivePowerControl<Generator> ext = g.getExtension(ActivePowerControl.class);
          ext.setParticipationFactor(1.0);
      }

      g1.setMaxP(110.0);
      g3.setMaxP(110.0);
      g4.setMaxP(110.0);
      LoadFlowResult result = loadFlowRunner.run(network, parameters);
      assertTrue(result.isFullyConverged());

      var expectedDistributedActivePower = -network.getGeneratorStream().mapToDouble(g -> g.getTargetP() + g.getTerminal().getP()).sum();
      assertEquals(120.1976, expectedDistributedActivePower, LoadFlowAssert.DELTA_POWER);
      assertEquals(expectedDistributedActivePower, result.getComponentResults().get(0).getDistributedActivePower(), LoadFlowAssert.DELTA_POWER);

      // All generators have the same participation factor, and should increase generation by 120.1976 MW
      // generator | targetP | maxP
      // ----------|---------|-------
      //   g1      |  100    |  110  --> expected to hit limit 110MW with 10MW distributed
      //   g2      |   90    |  300  --> expected to pick up the remaining slack 70.1976 MW
      //   g3      |   90    |  110  --> expected to hit limit 110MW with 20MW distributed
      //   g4      |   90    |  110  --> expected to hit limit 110MW with 20MW distributed
      assertActivePowerEquals(-110.000, g1.getTerminal());
      assertActivePowerEquals(-270.1976, g2.getTerminal());
      assertActivePowerEquals(-110.000, g3.getTerminal());
      assertActivePowerEquals(-110.000, g4.getTerminal());
  }

In the context of security analysis, still the previous behavior needs to be maintained, this was done as follows:

  • after computing N state (a.k.a. basecase state, a.k.a. pre-contingency state), generators' initial target P are set to solved targetP, doing this sets a new "starting point" for slack distribution for post-contingency states. Doing this maintains previous behavior.
  • similarly, after computing N-k states (a.k.a. post-contingency state), generators' initial target P are set to solved targetP, doing this sets a new "starting point" for slack distribution for operator strategy states. Same, doing this maintains previous behavior.
  • regarding Generators' actions (as part of Operator Strategies): the action is set to modify not only the targetP but also the initialTargetP. Same, doing this maintains previous behavior.

Does this PR introduce a breaking change or deprecate an API?

  • No
    ⚠️ but a behavioral change to be approved by the community

Extra Information
No existing unit test were harmed with this bugfix, in particular:

  • load flow slack distribution tests
  • security analysis with operator strategies
  • HVDC were up to now the only "users" of initialTargetP field

Signed-off-by: Damien Jeandemange <[email protected]>
Signed-off-by: Damien Jeandemange <[email protected]>
Signed-off-by: Damien Jeandemange <[email protected]>
Comment on lines +59 to +60
double absMismatch = Math.abs(slackBusActivePowerMismatch);
boolean shouldDistributeSlack = absMismatch > slackBusPMaxMismatch / PerUnit.SB && absMismatch > ActivePowerDistribution.P_RESIDUE_EPS;
Copy link
Member Author

@jeandemanged jeandemanged Jun 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Note to reviewers: This part could be a separate BugFix PR... let me know...

if slackBusPMaxMismatch is small in comparison with ActivePowerDistribution.P_RESIDUE_EPS, the outerloop becomes unstable because 1/ ActivePowerDistribution will not distribute anything and 2/ Outerloop says something has to be distributed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for catching this! I think we can let it in this PR.

@jeandemanged jeandemanged changed the title [WIP] Fix limits handling in slack distribution on generators Fix limits handling in slack distribution on generators Jun 25, 2024
@jeandemanged jeandemanged added bug Something isn't working ready-for-review labels Jun 25, 2024
Comment on lines +69 to +78
double mismatch = remainingMismatch;
if (iteration == 0) {
// "undo" everything from targetP to go back to initialP
for (ParticipatingElement participatingGenerator : participatingElements) {
LfGenerator generator = (LfGenerator) participatingGenerator.getElement();
done += generator.getInitialTargetP() - generator.getTargetP();
mismatch -= generator.getInitialTargetP() - generator.getTargetP();
generator.setTargetP(generator.getInitialTargetP());
}
}
Copy link
Member Author

@jeandemanged jeandemanged Jun 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Note to reviewers

This is the way I found to fix the issue AND also avoiding a massive refactor: today ActivePowerDistribution is completely stateless. Making ActivePowerDistribution aware of a "starting point" would require more work / refactorings...

Note also that such refactoring's will need to be addressed for future developments planned in #979 and powsybl-entsoe/# - planned end of this year / Q4-2024 on our side.
Indeed, imagine a merit order rebalancing, the starting point is crucial, otherwise you would end up with both generators from upward list moved and generators from downward list moved ...

@jeandemanged
Copy link
Member Author

@vmouradian I added you as reviewer because I noticed that this change if not properly implemented would break what you did in #1017 - unit tests are unchanged, but your double check is welcomed.

if (stepResult.movedBuses()) {
movedBuses = true;
}
double done = step.run(participatingElements, iteration, remainingMismatch);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"powerDistributed" might be a clearer name than "done", don't you agree?

}

@Override
public ActivePowerDistribution.StepResult run(List<ParticipatingElement> participatingElements, int iteration, double remainingMismatch) {
public double run(List<ParticipatingElement> participatingElements, int iteration, double remainingMismatch) {
// normalize participation factors at each iteration start as some
// generators might have reach a limit and have been discarded
ParticipatingElement.normalizeParticipationFactors(participatingElements);

double done = 0d;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, another name than "done" might be better (even if the name "done" was not introduced in this PR).

@vmouradian
Copy link
Member

@vmouradian I added you as reviewer because I noticed that this change if not properly implemented would break what you did in #1017 - unit tests are unchanged, but your double check is welcomed.

I double checked for the SA generator action part, and everything seems indeed to work as expected

@annetill
Copy link
Member

As a general comment, I think we have to use our summer time to add functional documentation about this kind of features. We can add new pages in the web-site about features and robustness. @jeandemanged What do you thing?

@@ -334,6 +334,7 @@ private void applyGeneratorChange(LfNetworkParameters networkParameters) {
if (!generator.isDisabled()) {
double newTargetP = generatorChange.isRelative() ? generator.getTargetP() + generatorChange.activePowerValue() : generatorChange.activePowerValue();
generator.setTargetP(newTargetP);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was about to merge the PR but I have a doubt, why you don't change these lines too in a LfContingency:

        Set<LfBus> generatorBuses = new HashSet<>();
        for (LfGenerator generator : lostGenerators) {
            **generator.setTargetP(0);** -> initial targetP to zero too ?
            LfBus bus = generator.getBus();
            generatorBuses.add(bus);
            generator.setParticipating(false);
            generator.setDisabled(true);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked: indeed the generator.setParticipating(false); solved this issue.

Copy link
Member Author

@jeandemanged jeandemanged Jul 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed, but adding setInitialTargetP(0) would improve code clarity and won't harm, I am adding it.

Signed-off-by: Damien Jeandemange <[email protected]>
Copy link

sonarqubecloud bot commented Jul 1, 2024

@annetill annetill merged commit a143c2b into main Jul 1, 2024
7 checks passed
@annetill annetill deleted the fix-slack-distrib branch July 1, 2024 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants