Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package org.openremote.extension.ems.agent;

import jakarta.persistence.Entity;
import java.util.Optional;
import org.openremote.model.asset.Asset;
import org.openremote.model.asset.AssetDescriptor;
import org.openremote.model.attribute.MetaItem;
import org.openremote.model.value.AttributeDescriptor;
import org.openremote.model.value.MetaItemType;
import org.openremote.model.value.ValueType;

@Entity
public class EmsDistroEnergyAsset extends Asset<EmsDistroEnergyAsset> {

public static final AttributeDescriptor<String> PORTFOLIO =
new AttributeDescriptor<>("portfolio", ValueType.TEXT);

/** When the last run that submitted at least one market day finished. */
public static final AttributeDescriptor<Long> LAST_SUBMISSION =
new AttributeDescriptor<>(
"lastSubmission", ValueType.TIMESTAMP, new MetaItem<>(MetaItemType.READ_ONLY));

/** Number of market days the last run submitted, i.e. the forecast horizon in days. */
public static final AttributeDescriptor<Integer> DAYS_SUBMITTED =
new AttributeDescriptor<>(
"daysSubmitted", ValueType.POSITIVE_INTEGER, new MetaItem<>(MetaItemType.READ_ONLY));

public static final AssetDescriptor<EmsDistroEnergyAsset> DESCRIPTOR =
new AssetDescriptor<>("transmission-tower", null, EmsDistroEnergyAsset.class);

protected EmsDistroEnergyAsset() {}

public EmsDistroEnergyAsset(String name) {
super(name);
}

public Optional<String> getPortfolio() {
return getAttributes().getValue(PORTFOLIO);
}

public void setPortfolio(String portfolio) {
getAttributes().getOrCreate(PORTFOLIO).setValue(portfolio);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.camel.builder.RouteBuilder;
import org.openremote.container.message.MessageBrokerService;
import org.openremote.container.timer.TimerService;
import org.openremote.extension.ems.agent.EmsDistroEnergyAsset;
import org.openremote.extension.ems.agent.EmsElectricityBatteryAsset;
import org.openremote.extension.ems.agent.EmsEnergyOptimisationAsset;
import org.openremote.extension.ems.agent.EmsGOPACSAsset;
import org.openremote.extension.ems.manager.distroenergy.DistroEnergyHandler;
import org.openremote.extension.ems.manager.gopacs.GOPACSHandler;
import org.openremote.extension.ems.manager.gopacs.GOPACSRedispatchHandler;
import org.openremote.manager.asset.AssetProcessingService;
Expand All @@ -55,6 +58,7 @@
import org.openremote.model.asset.AssetFilter;
import org.openremote.model.attribute.Attribute;
import org.openremote.model.attribute.AttributeEvent;
import org.openremote.model.attribute.AttributeRef;
import org.openremote.model.datapoint.ValueDatapoint;
import org.openremote.model.datapoint.query.AssetDatapointAllQuery;
import org.openremote.model.query.AssetQuery;
Expand All @@ -68,13 +72,18 @@ public class EmsOptimisationService extends RouteBuilder implements ContainerSer

protected GOPACSHandler.Factory gopacsHandlerFactory;
protected GOPACSRedispatchHandler.Factory gopacsRedispatchHandlerFactory;
protected DistroEnergyHandler.Factory distroEnergyHandlerFactory;

private final Map<String, ScheduledFuture<?>> energyOptimisationAssetsMap =
new ConcurrentHashMap<>();
private final Map<String, Long> energyOptimisationTimersMap = new HashMap<>();
private final Map<String, GOPACSHandler> gopacsHandlerMap = new HashMap<>();
private final Map<String, GOPACSRedispatchHandler> gopacsRedispatchHandlerMap = new HashMap<>();

// Keyed by asset id rather than portfolio: unlike GOPACS there is no external routing key, and
// the asset id stays stable when the portfolio is edited.
private final Map<String, DistroEnergyHandler> distroEnergyHandlerMap = new HashMap<>();

@SuppressWarnings("unchecked")
@Override
public void configure() throws Exception {
Expand Down Expand Up @@ -103,6 +112,7 @@ public void init(Container container) throws Exception {

gopacsHandlerFactory = new GOPACSHandler.Factory(container);
gopacsRedispatchHandlerFactory = new GOPACSRedispatchHandler.Factory(container);
distroEnergyHandlerFactory = new DistroEnergyHandler.Factory(container);
}

@Override
Expand Down Expand Up @@ -169,11 +179,23 @@ public void start(Container container) throws Exception {
}
});

// Start Distro Energy handler for all Distro Energy assets
services
.getAssetStorageService()
.findAll(
new AssetQuery()
.types(EmsDistroEnergyAsset.class)
.attributeName(EmsDistroEnergyAsset.PORTFOLIO.getName()))
.stream()
.map(asset -> (EmsDistroEnergyAsset) asset)
.forEach(this::startDistroEnergyHandler);

// List of asset types that are part of the core EMS service
String[] assetTypes = {
EmsElectricityBatteryAsset.DESCRIPTOR.getName(),
EmsEnergyOptimisationAsset.DESCRIPTOR.getName(),
EmsGOPACSAsset.DESCRIPTOR.getName()
EmsGOPACSAsset.DESCRIPTOR.getName(),
EmsDistroEnergyAsset.DESCRIPTOR.getName(),
};

// Listen to attribute events of listed asset types
Expand All @@ -189,6 +211,8 @@ public void start(Container container) throws Exception {
public void stop(Container container) throws Exception {
gopacsRedispatchHandlerMap.forEach((ean, handler) -> handler.stopPolling());
gopacsRedispatchHandlerMap.clear();
distroEnergyHandlerMap.forEach((assetId, handler) -> handler.undeploy());
distroEnergyHandlerMap.clear();
energyOptimisationAssetsMap.forEach((assetId, scheduledFuture) -> stopOptimisation(assetId));
energyOptimisationTimersMap.clear();
}
Expand Down Expand Up @@ -352,6 +376,72 @@ private void stopRedispatchHandler(String contractedEan) {
}
}

private void startDistroEnergyHandler(EmsDistroEnergyAsset distroEnergyAsset) {
String assetId = distroEnergyAsset.getId();
String portfolio = distroEnergyAsset.getPortfolio().orElse("");

if (portfolio.isBlank()) {
LOG.warning(
"Unable to deploy Distro Energy because portfolio is blank for asset: " + assetId);
return;
}
Comment thread
Miggets7 marked this conversation as resolved.

// The forecast being submitted is the parent optimisation asset's net power.
String energyOptimisationAssetId = distroEnergyAsset.getParentId();
if (energyOptimisationAssetId == null) {
LOG.warning(
String.format(
"Unable to deploy Distro Energy for portfolio '%s'; asset '%s' has no parent '%s'",
portfolio, assetId, EmsEnergyOptimisationAsset.class.getSimpleName()));
return;
}

// find(..., EmsEnergyOptimisationAsset.class) returns null both for a missing asset and for one
// of the wrong type, which are the same problem here: there is no net power forecast to submit.
if (services
.getAssetStorageService()
.find(energyOptimisationAssetId, false, EmsEnergyOptimisationAsset.class)
== null) {
LOG.warning(
String.format(
"Unable to deploy Distro Energy for portfolio '%s'; parent '%s' of asset '%s' is not"
+ " an existing '%s'",
portfolio,
energyOptimisationAssetId,
assetId,
EmsEnergyOptimisationAsset.class.getSimpleName()));
return;
}

LOG.fine("Deploying Distro Energy for portfolio: " + portfolio);
DistroEnergyHandler handler;
try {
handler =
distroEnergyHandlerFactory.createHandler(
assetId,
new AttributeRef(
energyOptimisationAssetId, EmsEnergyOptimisationAsset.POWER_NET.getName()),
portfolio);
} catch (Exception e) {
// A missing client key or an unusable base URL must not take down the rest of the EMS
// service.
LOG.log(Level.WARNING, "Failed to deploy Distro Energy for portfolio: " + portfolio, e);
return;
}
// Registered before the schedule starts, so a handler whose deploy() is rejected during
// shutdown is still reachable from stop() and gets its client closed.
distroEnergyHandlerMap.put(assetId, handler);
handler.deploy();
LOG.fine("Deployed Distro Energy for portfolio: " + portfolio);
}

private void stopDistroEnergyHandler(String assetId) {
DistroEnergyHandler existing = distroEnergyHandlerMap.remove(assetId);
if (existing != null) {
existing.undeploy();
}
}

protected void processAssetChange(PersistenceEvent<?> persistenceEvent) {
if (persistenceEvent.getEntity()
instanceof EmsEnergyOptimisationAsset emsEnergyOptimisationAsset) {
Expand Down Expand Up @@ -382,6 +472,21 @@ protected void processAssetChange(PersistenceEvent<?> persistenceEvent) {
// Redispatch handler is managed via attribute events (redispatchEnabled)
}
});
} else if (persistenceEvent.getEntity() instanceof EmsDistroEnergyAsset emsDistroEnergyAsset) {
// distroEnergyHandlerMap is keyed by asset id, not portfolio: stop by asset id so this
// actually finds the handler to remove, and so DELETE cleans up even if the portfolio
// attribute is absent on the entity snapshot.
String assetId = emsDistroEnergyAsset.getId();
if (persistenceEvent.getCause() == PersistenceEvent.Cause.DELETE) {
stopDistroEnergyHandler(assetId);
}
if (persistenceEvent.getCause() == PersistenceEvent.Cause.CREATE) {
startDistroEnergyHandler(emsDistroEnergyAsset);
}
if (persistenceEvent.getCause() == PersistenceEvent.Cause.UPDATE) {
stopDistroEnergyHandler(assetId);
startDistroEnergyHandler(emsDistroEnergyAsset);
}
}
}

Expand All @@ -397,6 +502,10 @@ private void processAttributeEvent(AttributeEvent attributeEvent) {
processAttributeEventEmsGOPACSAsset(attributeEvent);
return;
}

if (assetType.equals(EmsDistroEnergyAsset.DESCRIPTOR.getName())) {
processAttributeEventEmsDistroEnergyAsset(attributeEvent);
}
}

private void processAttributeEventEmsEnergyOptimisationAsset(AttributeEvent attributeEvent) {
Expand Down Expand Up @@ -794,6 +903,36 @@ private void processAttributeEventEmsGOPACSAsset(AttributeEvent attributeEvent)
}
}

private void processAttributeEventEmsDistroEnergyAsset(AttributeEvent attributeEvent) {
String assetId = attributeEvent.getId();

// Get asset from database
EmsDistroEnergyAsset emsDistroEnergyAsset =
(EmsDistroEnergyAsset) services.getAssetStorageService().find(assetId);

// Check if asset exists
if (emsDistroEnergyAsset == null) {
return;
}

String attributeName = attributeEvent.getName();

if (attributeName.equals(EmsDistroEnergyAsset.PORTFOLIO.getName())) {
attributeEvent
.getOldValue(String.class)
.ifPresent(
oldPortfolio -> {
stopDistroEnergyHandler(assetId);
});
attributeEvent
.getValue(String.class)
.ifPresent(
portfolio -> {
startDistroEnergyHandler(emsDistroEnergyAsset);
});
}
}

private void updatePowerLimitProfileManualForecasts(
EmsEnergyOptimisationAsset energyOptimisationAsset) {
String logPrefix =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package org.openremote.extension.ems.manager.distroenergy;

import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;

import jakarta.ws.rs.*;
import org.openremote.extension.ems.manager.distroenergy.dto.DayAheadSubmission;

@Path("trader")
public interface DayAheadResource {

@POST
@Consumes({APPLICATION_JSON})
@Path("{portfolio}/day-ahead/data")
void postDayAhead(
@PathParam("portfolio") String portfolio,
@HeaderParam("x-client-key") String clientKey,
DayAheadSubmission dayAheadSubmission);
}
Loading
Loading