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

New controller for flexibility trading with Levl Energy #2873

Open
wants to merge 43 commits into
base: develop
Choose a base branch
from

Conversation

DennisLevl
Copy link

Adds new controller for flexibility trading with Levl Energy

General Information

This pull request introduces a new controller that enables OpenEMS and Fenecon customers to trade their flexibility using the Levl Energy platform. This functionality is currently in the beta stage and will be refined based on feedback and additional testing.

How it works

This controller receives charging/discharging instructions from Levl Energy and executes these taking into account local optimization and various constraints (such as state of charge, grid limits, etc.).

Testing

The business logic has been covered by unit tests. The controller has also been tested using multiple test scenarios within the OpenEMS test framework to ensure functionality and reliability.

Please review the changes and provide feedback. Once approved, this will be ready for merge. Thank you in advance!

LevlLuca and others added 30 commits November 13, 2024 10:08
remove physical soc bounds and target grid setpoint from config
handling in impl; move request handling to before_process_image event;
clean up files
well for realized battery value of last request
handleRequest; add logging and set log-level to debug; auto-formatting
calculation; fix swinging of remaining energy around zero;
var sellToGridLimit = this.getSellToGridLimit().getOrError();
var influenceSellToGrid = this.getInfluenceSellToGrid().getOrError();

var essCapacityWs = essCapacity * 3600L;
Copy link
Contributor

Choose a reason for hiding this comment

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

There are a number of "magic numbers" (e.g., 3600L, 100.0 for percentage conversions). These constants should be defined as named constants to enhance readability and avoid repetition.

Copy link
Author

Choose a reason for hiding this comment

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

Good point, done.

public enum ChannelId implements io.openems.edge.common.channel.ChannelId {
REMAINING_LEVL_ENERGY(Doc.of(OpenemsType.LONG).persistencePriority(PersistencePriority.HIGH)
.text("energy to be realized [Ws]")),
LEVL_SOC(Doc.of(OpenemsType.LONG).unit(Unit.WATT_HOURS).persistencePriority(PersistencePriority.HIGH)
Copy link
Contributor

Choose a reason for hiding this comment

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

Some of the channel names could be more descriptive, particularly for states or conditions. For example, LEVL_SOC could be renamed to LevlEnergyStateOfCharge for clarity.

Copy link
Author

Choose a reason for hiding this comment

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

I renamed some of them to clarify Soc = state of charge.
LevlEnergy could be confusing because it could be the energy for levl as well as the company name.
Please let me know if some channel names are still unclear.

var nextPucSocWs = pucSocWs - Math.round(Efficiency.apply(pucBatteryPower, efficiency) * cycleTimeS);

// levl calculation
var levlBatteryPower = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

The naming of variables like pucBatteryPower, levlBatteryPower, etc., is clear, but using more specific names might help others quickly understand the context (e.g., primaryUseCaseBatteryPower and levlBatteryDischargePower).

Copy link
Author

Choose a reason for hiding this comment

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

In our implementation, we followed the OpenEMS view for signs, in which OpenEMS is located in the center of the system. If something enters the system (e.g. battery is discharged), this is positive. If it leaves the system (e.g. battery is being charged), it is negative.

Writing out Puc would be an option, but we have decided to use the abbreviation for reasons of readability, variable length and the due to the number of uses and explain it accordingly at the beginning.
However, I am flexible about this :)

@@ -0,0 +1,39 @@
package io.openems.edge.levl.controller.common;

public class Efficiency {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a duplication of logic between the apply() and unapply() methods in the Efficiency class. Both methods perform similar operations based on whether the value is positive or negative, applying the efficiency percentage. This duplicated logic can be refactored into a shared private method to reduce code repetition and improve maintainability.

Copy link
Author

@DennisLevl DennisLevl Nov 15, 2024

Choose a reason for hiding this comment

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

Good point. There was also a mistake in the method description I corrected.

@@ -0,0 +1,140 @@
package io.openems.edge.levl.controller;

import com.google.gson.JsonObject;
Copy link
Contributor

Choose a reason for hiding this comment

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

Do not use gson.JsonObject. Instead use OpenEMS'es own Json Methods

Copy link
Author

Choose a reason for hiding this comment

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

Now I use JsonUtils within the parseFields method. However, the OpenEMS'es own JsonrpcRequest.getParams() returns an gson JsonObject.
If there's a OpenEMS way to avoid this, I would appreciate a short hint :)

var dischargeEnergyLowerBoundWs = pucSocWs - essCapacityWs;
var dischargeEnergyUpperBoundWs = pucSocWs;

var powerLowerBound = Efficiency.unapply(Math.round(dischargeEnergyLowerBoundWs / cycleTimeS), efficiency);
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe import Math statically? :)

Copy link
Author

Choose a reason for hiding this comment

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

Done :)

import io.openems.edge.common.component.OpenemsComponent;
import io.openems.edge.controller.api.Controller;

public interface ControllerEssBalancing extends Controller, OpenemsComponent {
Copy link
Contributor

@Sn0w3y Sn0w3y Nov 15, 2024

Choose a reason for hiding this comment

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

Please use // to format the Channels properly so it is better readable (see other Channel Implementations)

This Comments is applicable for all Files

Copy link
Author

Choose a reason for hiding this comment

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

Done. Also in the OpenEMS Framework testing class.

* @param cycleTimeS the configured openems cycle time [seconds]
* @return the levl battery power [W]
*/
protected int calculateLevlBatteryPower(long remainingLevlEnergyWs, int pucBatteryPower, int minEssPower,
Copy link
Contributor

Choose a reason for hiding this comment

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

Many of the applyXYZToLevlPower methods in calculateLevlBatteryPower share similar logic and could be consolidated into a single method with parameters to increase readability and reduce code duplication.

Copy link
Author

Choose a reason for hiding this comment

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

I moved the return operation (max(min(power, upperBound), lowerBound)) into a private method called applyBound().
In my opinion, the other lines do not lend themselves to being consolidated into a method, considering readability.
But if you want to talk about a specific point, please let me know.

Thank you for your review!

@parapluplu
Copy link
Contributor

parapluplu commented Nov 20, 2024

Could you please ellaborate what the following means:

The controller has also been tested using multiple test scenarios within the OpenEMS test framework to ensure functionality and reliability.

Which test scenarios did you execute and which OpenEMS test framework do you refer to?

If you refer to the tests in BalancingImplTest then my question is just a misunterstanding. However, since you mentioned unit tests along with other types of tests, but I only see unit tests here, I wanted to ask this question.

@DennisLevl
Copy link
Author

DennisLevl commented Nov 20, 2024

Could you please ellaborate what the following means:

The controller has also been tested using multiple test scenarios within the OpenEMS test framework to ensure functionality and reliability.

Which test scenarios did you execute and which OpenEMS test framework do you refer to?

If you refer to the tests in BalancingImplTest then my question is just a misunterstanding. However, since you mentioned unit tests along with other types of tests, but I only see unit tests here, I wanted to ask this question.

@parapluplu Thanks for the question. Of course by "OpenEMS test framework" I meant the tests in BalancingImplTest using OpenEMS' own test framework "ControllerTest". It's described as a "generic test framework for OpenEMS". Since it tests the whole controller and multiple cycles, I mentioned it separately to differentiate between the single tested methods within ControllerEssBalancingImplTest. So yes, my description might be a bit misunderstood :-)

We also tested the controller within our own development environment using several simulated OpenEMS edge instances.

}

//Just for testing
public LevlControlRequest(int sellToGridLimitW, int buyFromGridLimitW, String levlRequestId, String timestamp,
Copy link
Contributor

Choose a reason for hiding this comment

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

package level visibility should be good enough then, isn't it?

Copy link
Author

Choose a reason for hiding this comment

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

protected would even do. I adjusted this.

Copy link
Contributor

Choose a reason for hiding this comment

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

protected is more than package level visibility, see https://docs.oracle.com/javase/tutorial/java/javaOO/accesscontrol.html

But protected is good as well :)

import java.time.LocalDateTime;
import java.util.Objects;

public class LevlControlRequest {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be good to put a version on this request, so it's easier to change the API version eventually

Copy link
Author

Choose a reason for hiding this comment

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

You're right. It's definitely something we'll add at a later date. For the beta it will be fine.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm okay with this approach, but it eases it if it's built in from the beginning

import java.time.LocalDateTime;
import java.util.Objects;

public class LevlControlRequest {
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the motivation to perform parts of the controlling via an external API and not within OpenEMS?

Copy link
Contributor

@parapluplu parapluplu Nov 21, 2024

Choose a reason for hiding this comment

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

Yes, it's about virtual power plants. I can imagine this being implemented within OpenEMS, with a virtual power plant coordinator running OpenEMS and multiple batteries running OpenEMS connected to the power plant. That’s the reason for my question.

This question isn’t intended to block or hinder the merge request; it’s simply out of curiosity.

Copy link
Author

Choose a reason for hiding this comment

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

Hey guys, sorry for the late response. Levl enables battery owners to participate in the electricity market, making more efficient use of the batteries and reducing amortisation costs. At the same time, the electricity grid is relieved as electricity is bought when it is cheap (a lot of renewables energy in the grid) and sold when demand is high (coal and gas is needed).

Unfortunately, this is not quite so simple from a regulatory perspective, as battery storage systems behind-the-meter, especially with a primary use case such as optimising self-consumption, requires a distinction to be made between self-consumption and temporarily stored energy. This is the only way the energy supplier can correctly bill the quantities purchased. For this reason, we act as an aggregator with Levl.

In the end, this controller is comparable to various other controllers that make it possible to use an external service on the hardware and/or software side to optimise your own system.

To come back to your idea @parapluplu: If regulation were left out of the equation, that might be possible. But you would still need someone to run and manage this coordinator centrally. However, Levl's long-term goal is to enable all battery owners, regardless of the EMS used, to become part of a virtual power plant with their system.
At the moment we're working with a lot of battery owners who are using OpenEMS as well as working directly with Fenecon. For this reason we're looking forward to becoming part of the OpenEMS community :-)

Copy link
Contributor

@DerStoecki DerStoecki left a comment

Choose a reason for hiding this comment

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

Hey,
Just had a quick glance.
Here are my 2 cents, listed down below.

Also: Please consider to rename the package to something like
"io.openems.edge.controller.ess.balancing.levl"
or something like that,
instead of io.openems.levl.controller
Helps to identify and categorize the controller

Comment on lines 56 to 58

protected static Clock clock = Clock.systemDefaultZone();

Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of useing Clock.systemDefaultZone(),
Consider useing the clock of the ComponentManager , since (all) components (FeneconBatteryExample), useing a clock, refer to the Clock of the ComponentManager (or a ClockProvider in general).
Consider applying this to the LevelControlRequest as well, if needed/possible.

Also: This allows compatability JUnit Tests and TimeLeapClock (example, FeneconBatteryTest).

Comment on lines 590 to 592
private long applyBound(long power, long lowerBound, long upperBound) {
return max(min(power, upperBound), lowerBound);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider useing the TypeUtils.fitWithin() methods
And introduce a new TypeUtils.fitWithin() method returning and useing long values.

Comment on lines 45 to 47
@Designate(ocd = Config.class, factory = true)
@Component(name = "Controller.Levl.Symmetric.Balancing", immediate = true, configurationPolicy = ConfigurationPolicy.REQUIRE)
@EventTopics({ EdgeEventConstants.TOPIC_CYCLE_BEFORE_PROCESS_IMAGE, EdgeEventConstants.TOPIC_CYCLE_AFTER_WRITE, })
Copy link
Contributor

Choose a reason for hiding this comment

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

CodeFormatting, See FeneconBatteryExample

Copy link
Contributor

Choose a reason for hiding this comment

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

maybe a stupid question, but if checkstyle does not complain code formatting should be ok, shouldn't it?

Copy link
Contributor

Choose a reason for hiding this comment

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

@parapluplu You are right. If checkstyle does not complain, the code formatting should be ok.

Regardless of this, using the code formatting like in FeneconBatteryExample has some advantages. For example, for me it took 2-5s to understand the configuration of the fenecon battery. In your code it took me 15-20s. This is, because it took me some time to mentally divide the lines into several lines. This is how may brain is trained (because of other OpenEMS code). Other developers may have the same problem.
As we all want to work efficiently on the same code base it would be nice if we format the code more or less identical.
And in the end this issue should be reflected by an updated set of checkstyle rules..

@DennisLevl
Copy link
Author

Hey, Just had a quick glance. Here are my 2 cents, listed down below.

Also: Please consider to rename the package to something like "io.openems.edge.controller.ess.balancing.levl" or something like that, instead of io.openems.levl.controller Helps to identify and categorize the controller

Hey @DerStoecki, thanks for your 2 cents and advice. I have implemented your notes into the controller. Especially the TimeLeapClock was new to me, so thank you very much. I think we will use this in a later version for some further testing.

On the package renaming: We used io.openems.edge.levl.controller as the package name because we were inspired by other controllers like io.openems.edge.kostal.piko.
io.openems.edge.levl because it's a new custom controller for the edge.
To me, "io.openems.edge.controller.ess.balancing.levl" would suggest that it's a core module of openems, and probably a part or sub-implementation of the "io.openems.edge.controller.ess.balancing" package.
However, if you think this would be a better name for identity and categorisation reasons, I'm free to change it.

@sfeilmeier
Copy link
Contributor

Hi @DennisLevl, can you please allow Commits for me. This would allow me to effectively work on the PR. Thanks!

See for details:
https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork

@DennisLevl
Copy link
Author

DennisLevl commented Jan 7, 2025

Hi @DennisLevl, can you please allow Commits for me. This would allow me to effectively work on the PR. Thanks!

See for details: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork

Unfortunately, this does not work for forks owned by an org (in this case, levlenergy. See https://github.com/orgs/community/discussions/5634). However, I have added you to the fork as a member.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants