/* ==================================================================
* DemandBalancer.java - Mar 23, 2014 3:58:26 PM
*
* Copyright 2007-2014 SolarNetwork.net Dev Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 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
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
* 02111-1307 USA
* ==================================================================
*/
package net.solarnetwork.node.control.demandbalancer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.solarnetwork.domain.NodeControlInfo;
import net.solarnetwork.node.DatumDataSource;
import net.solarnetwork.node.MultiDatumDataSource;
import net.solarnetwork.node.NodeControlProvider;
import net.solarnetwork.node.domain.ACEnergyDatum;
import net.solarnetwork.node.domain.ACPhase;
import net.solarnetwork.node.domain.EnergyDatum;
import net.solarnetwork.node.reactor.Instruction;
import net.solarnetwork.node.reactor.InstructionHandler;
import net.solarnetwork.node.reactor.InstructionStatus;
import net.solarnetwork.node.reactor.support.BasicInstruction;
import net.solarnetwork.node.reactor.support.InstructionUtils;
import net.solarnetwork.node.settings.KeyedSettingSpecifier;
import net.solarnetwork.node.settings.SettingSpecifier;
import net.solarnetwork.node.settings.SettingSpecifierProvider;
import net.solarnetwork.node.settings.support.BasicTextFieldSettingSpecifier;
import net.solarnetwork.node.settings.support.BasicToggleSettingSpecifier;
import net.solarnetwork.util.FilterableService;
import net.solarnetwork.util.OptionalService;
import net.solarnetwork.util.OptionalServiceCollection;
import net.solarnetwork.util.StaticOptionalService;
import net.solarnetwork.util.StringUtils;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
/**
* Basic service to monitor demand conditions (consumption) and generation
* (power) and send out {@link InstructionHandler#TOPIC_DEMAND_BALANCE}
* instructions to a specific control to limit generation to an amount that
* keeps generation at or below current consumption levels.
*
* <p>
* The configurable properties of this class are:
* </p>
*
* <dl class="class-properties">
* <dt>powerControlId</dt>
* <dd>The ID of the control that should respond to the
* {@link InstructionHandler#TOPIC_DEMAND_BALANCE} instruction to match
* generation levels to consumption levels.</dd>
*
* <dt>powerControl</dt>
* <dd>The {@link NodeControlProvider} that manages the configured
* {@code powerControlId}, and can report back its current status, whose value
* must be provided as an integer percentage of the maximum allowable generation
* level. <b>Note</b> that this object must also implement
* {@link FilterableService} and will automatically have a filter property set
* for the {@code availableControlIds} property to match the
* {@code powerControlId} value.</dd>
*
* <dt>powerDataSource</dt>
* <dd>The collection of {@link DatumDataSource} that provide real-time power
* generation data. If more than one {@code DatumDataSource} is configured the
* effective generation will be aggregated as a sum total of all of them.</dd>
* <dt>powerMaximumWatts</dt>
*
* <dd>The maximum watts the configured {@code powerDataSource} is capable of
* producing. This value is used to calculate the output percentage level passed
* on {@link InstructionHandler#TOPIC_DEMAND_BALANCE} instructions. For example,
* if the {@code powerMaximumWatts} is {@bold 1000} and the current
* consumption is {@bold 800} then the demand balance will be requested
* as {@bold 80%}.</dd>
*
* <dt>consumptionDataSource</dt>
* <dd>The collection of {@link DatumDataSource} that provide real-time
* consumption generation data. If more than one {@code DatumDataSource} is
* configured the effective demand will be aggregated as a sum total of all of
* them.</dd>
*
* <dt>balanceStrategy</dt>
* <dd>The strategy implementation to use to decide how to balance the demand
* and generation. Defaults to {@link SimpleDemandBalanceStrategy}.</dd>
*
* <dt>instructionHandlers</dt>
* <dd>A collection of {@link InstructionHandler} instances. When
* {@link #evaluateBalance()} is called, if a balancing adjustment is necessary
* then the instruction will be passed to each of these handlers, with the first
* to process it being assumed the only handler that need respond.</dd>
* </dl>
*
* <dt>collectPower</dt> <dd>If <em>true</em> then collect {@link PowerDatum}
* from all configured data sources for passing to the
* {@link DemandBalanceStrategy}. Not all strategies need power information, and
* it may take too long to collect this information, however, so this can be
* turned off by setting to <em>false</em>. When disabled, <b>-1</b> is passed
* for the {@code generationWatts} parameter on
* {@link DemandBalanceStrategy#evaluateBalance(String, int, int, int, int)}.
* Defaults to <em>false</em>.</dd>
*
* @author matt
* @version 1.2
*/
public class DemandBalancer implements SettingSpecifierProvider {
private static final String ERROR_NO_DATA_RETURNED = "No data returned.";
/**
* The EventAdmin topic used to post events with statistics on balance
* execution.
*/
public static final String EVENT_TOPIC_STATISTICS = "net/solarnetwork/node/control/demandbalancer/DemandBalancer/STATISTICS";
public static final String STAT_LAST_CONSUMPTION_COLLECTION_DATE = "ConsumptionCollectionDate";
public static final String STAT_LAST_CONSUMPTION_COLLECTION_ERROR = "ConsumptionCollectionError";
public static final String STAT_LAST_POWER_COLLECTION_DATE = "PowerCollectionDate";
public static final String STAT_LAST_POWER_COLLECTION_ERROR = "PowerCollectionError";
public static final String STAT_LAST_POWER_CONTROL_COLLECTION_DATE = "PowerControlCollectionDate";
public static final String STAT_LAST_POWER_CONTROL_COLLECTION_ERROR = "PowerControlCollectionError";
public static final String STAT_LAST_POWER_CONTROL_MODIFY_DATE = "PowerControlModifyDate";
public static final String STAT_LAST_POWER_CONTROL_MODIFY_ERROR = "PowerControlModifyError";
private String powerControlId = "/power/pcm/1?percent";
private OptionalService<EventAdmin> eventAdmin;
private OptionalService<NodeControlProvider> powerControl;
private OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> powerDataSource;
private int powerMaximumWatts = 1000;
private OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> consumptionDataSource;
private OptionalService<DemandBalanceStrategy> balanceStrategy = new StaticOptionalService<DemandBalanceStrategy>(
new SimpleDemandBalanceStrategy());
private Collection<InstructionHandler> instructionHandlers = Collections.emptyList();
private MessageSource messageSource;
private boolean collectPower = false;
private Set<ACPhase> acEnergyPhaseFilter = EnumSet.copyOf(Collections.singleton(ACPhase.Total));
final Map<String, Object> stats = new LinkedHashMap<String, Object>(8);
private final Logger log = LoggerFactory.getLogger(getClass());
/**
* Evaluate current demand (consumption) and generation (power) and attempt
* to maximize power generation up to the current demand level.
*/
public void evaluateBalance() {
final Integer demandWatts = collectDemandWatts();
final Integer generationWatts = collectGenerationWatts();
final Integer generationLimitPercent = readCurrentGenerationLimitPercent();
log.debug("Current demand: {}, generation: {}, capacity: {}, limit: {}",
(demandWatts == null ? "N/A" : demandWatts.toString()), (generationWatts == null ? "N/A"
: generationWatts.toString()), powerMaximumWatts,
(generationLimitPercent == null ? "N/A" : generationLimitPercent + "%"));
executeDemandBalanceStrategy(demandWatts, generationWatts, generationLimitPercent);
postStatisticsEvent();
}
/**
* Get a message for an exception. This will try to return the root cause's
* message. If that is not available the name of the root cause's class will
* be returned.
*
* @param t
* the exception
* @return message
*/
private String messageForException(Throwable t) {
Throwable root = t;
while ( root.getCause() != null ) {
root = root.getCause();
}
String msg = root.getMessage();
if ( msg == null || msg.length() < 1 ) {
msg = t.getMessage();
if ( msg == null || msg.length() < 1 ) {
msg = root.getClass().getName();
}
}
return msg;
}
private Integer collectDemandWatts() {
log.debug("Collecting current consumption data to inform demand balancer...");
Iterable<EnergyDatum> demand = null;
try {
demand = getCurrentDatum(consumptionDataSource);
if ( demand.iterator().hasNext() ) {
stats.put(STAT_LAST_CONSUMPTION_COLLECTION_DATE, System.currentTimeMillis());
stats.remove(STAT_LAST_CONSUMPTION_COLLECTION_ERROR);
} else {
stats.put(STAT_LAST_CONSUMPTION_COLLECTION_ERROR, ERROR_NO_DATA_RETURNED);
}
} catch ( RuntimeException e ) {
log.error("Error collecting consumption data: {}", e.getMessage());
stats.put(STAT_LAST_CONSUMPTION_COLLECTION_ERROR, messageForException(e));
}
return wattsForEnergyDatum(demand);
}
private Integer collectGenerationWatts() {
final Integer generationWatts;
if ( collectPower ) {
log.debug("Collecting current generation data to inform demand balancer...");
Iterable<EnergyDatum> generation = null;
try {
generation = getCurrentDatum(powerDataSource);
if ( generation.iterator().hasNext() ) {
stats.put(STAT_LAST_POWER_COLLECTION_DATE, System.currentTimeMillis());
stats.remove(STAT_LAST_POWER_COLLECTION_ERROR);
} else {
stats.put(STAT_LAST_POWER_COLLECTION_ERROR, ERROR_NO_DATA_RETURNED);
}
} catch ( RuntimeException e ) {
log.error("Error collecting generation data: {}", e.getMessage());
stats.put(STAT_LAST_POWER_COLLECTION_ERROR, messageForException(e));
}
generationWatts = wattsForEnergyDatum(generation);
} else {
generationWatts = null;
}
return generationWatts;
}
private Integer readCurrentGenerationLimitPercent() {
log.debug("Reading current {} value to inform demand balancer...", powerControlId);
NodeControlInfo generationLimit = null;
try {
generationLimit = getCurrentControlValue(powerControl, powerControlId);
if ( generationLimit != null ) {
stats.put(STAT_LAST_POWER_CONTROL_COLLECTION_DATE, System.currentTimeMillis());
stats.remove(STAT_LAST_POWER_CONTROL_COLLECTION_ERROR);
} else {
stats.put(STAT_LAST_POWER_CONTROL_COLLECTION_ERROR, ERROR_NO_DATA_RETURNED);
}
} catch ( RuntimeException e ) {
log.error("Error collecting {} data: {}", powerControlId, e.getMessage());
stats.put(STAT_LAST_POWER_CONTROL_COLLECTION_ERROR, messageForException(e));
}
return percentForLimit(generationLimit);
}
private void executeDemandBalanceStrategy(final Integer demandWatts, final Integer generationWatts,
final Integer generationLimitPercent) {
try {
InstructionStatus.InstructionState result = evaluateBalance((demandWatts == null ? -1
: demandWatts.intValue()),
(generationWatts == null ? -1 : generationWatts.intValue()),
(generationLimitPercent == null ? -1 : generationLimitPercent.intValue()));
if ( result != null ) {
stats.put(STAT_LAST_POWER_CONTROL_MODIFY_DATE, System.currentTimeMillis());
}
if ( result == null || result == InstructionStatus.InstructionState.Completed ) {
stats.remove(STAT_LAST_POWER_CONTROL_MODIFY_ERROR);
} else {
stats.put(STAT_LAST_POWER_CONTROL_MODIFY_ERROR, "Instruction result not Completed: "
+ result);
}
} catch ( RuntimeException e ) {
log.error("Error modifying power control {}: {}", powerControlId, e.getMessage());
stats.put(STAT_LAST_POWER_CONTROL_MODIFY_ERROR, messageForException(e));
}
}
/**
* Evaluate current demand and generation conditions, and apply an
* adjustment if necessary.
*
* @param demandWatts
* the current demand, in watts
* @param generationWatts
* the current generation, in watts
* @param currentLimit
* the current generation limit, as an integer percentage
* @return the result of adjusting the generation limit, or <em>null</em> if
* no adjustment was made
*/
private InstructionStatus.InstructionState evaluateBalance(final int demandWatts,
final int generationWatts, final int currentLimit) {
DemandBalanceStrategy strategy = getDemandBalanceStrategy();
if ( strategy == null ) {
throw new RuntimeException("No DemandBalanceStrategy configured.");
}
int desiredLimit = strategy.evaluateBalance(powerControlId, demandWatts, generationWatts,
powerMaximumWatts, currentLimit);
if ( desiredLimit > 0 && desiredLimit != currentLimit ) {
log.info("Demand of {} with generation {} (capacity {}) will be adjusted from {}% to {}%",
demandWatts, powerControlId, powerMaximumWatts, currentLimit, desiredLimit);
InstructionStatus.InstructionState result = adjustLimit(desiredLimit);
log.info("Demand adjumstment instruction result: {}", result);
return result;
}
return null;
}
/**
* Adjust the generation limit. If no handlers are available, or no handlers
* acknowledge handling the instruction,
* {@link InstructionStatus.InstructionState#Declined} will be returned.
*
* @param desiredLimit
* the desired limit, as an integer percentage
* @return the result of handling the adjustment instruction, never
* <em>null</em>
*/
private InstructionStatus.InstructionState adjustLimit(final int desiredLimit) {
final BasicInstruction instr = new BasicInstruction(InstructionHandler.TOPIC_DEMAND_BALANCE,
new Date(), Instruction.LOCAL_INSTRUCTION_ID, Instruction.LOCAL_INSTRUCTION_ID, null);
instr.addParameter(powerControlId, String.valueOf(desiredLimit));
final InstructionStatus.InstructionState result = InstructionUtils.handleInstruction(
instructionHandlers, instr);
return (result == null ? InstructionStatus.InstructionState.Declined : result);
}
private void postStatisticsEvent() {
if ( eventAdmin == null ) {
return;
}
final EventAdmin admin = eventAdmin.service();
if ( admin == null ) {
return;
}
admin.postEvent(new Event(EVENT_TOPIC_STATISTICS, stats));
}
private Integer percentForLimit(NodeControlInfo limit) {
if ( limit == null || limit.getValue() == null ) {
return null;
}
try {
return Integer.valueOf(limit.getValue());
} catch ( NumberFormatException e ) {
log.warn("Error parsing limit value as integer percentage: {}", e.getMessage());
}
return null;
}
private Integer wattsForEnergyDatum(EnergyDatum datum) {
if ( datum == null ) {
return null;
}
if ( datum.getWatts() != null ) {
return datum.getWatts();
}
return null;
}
private Integer wattsForEnergyDatum(Iterable<? extends EnergyDatum> datums) {
if ( datums == null ) {
return null;
}
int total = -1;
for ( EnergyDatum datum : datums ) {
if ( datum instanceof ACEnergyDatum && acEnergyPhaseFilter != null
&& acEnergyPhaseFilter.size() > 0 ) {
ACPhase phase = ((ACEnergyDatum) datum).getPhase();
if ( !acEnergyPhaseFilter.contains(phase) ) {
continue;
}
}
Integer w = wattsForEnergyDatum(datum);
if ( w != null ) {
if ( total < 0 ) {
total = w;
} else {
total += w;
}
}
}
return (total < 0 ? null : total);
}
private NodeControlInfo getCurrentControlValue(OptionalService<NodeControlProvider> service,
String controlId) {
if ( service == null ) {
return null;
}
NodeControlProvider provider = service.service();
if ( provider == null ) {
return null;
}
return provider.getCurrentControlInfo(controlId);
}
private Iterable<EnergyDatum> getCurrentDatum(
OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> service) {
if ( service == null ) {
return null;
}
Iterable<DatumDataSource<? extends EnergyDatum>> dataSources = service.services();
List<EnergyDatum> results = new ArrayList<EnergyDatum>();
for ( DatumDataSource<? extends EnergyDatum> dataSource : dataSources ) {
if ( dataSource instanceof MultiDatumDataSource<?> ) {
@SuppressWarnings("unchecked")
Collection<? extends EnergyDatum> datums = ((MultiDatumDataSource<? extends EnergyDatum>) dataSource)
.readMultipleDatum();
if ( datums != null ) {
for ( EnergyDatum datum : datums ) {
results.add(datum);
}
}
} else {
EnergyDatum datum = dataSource.readCurrentDatum();
if ( datum != null ) {
results.add(datum);
}
}
}
return results;
}
private DemandBalanceStrategy getDemandBalanceStrategy() {
if ( balanceStrategy == null ) {
return null;
}
return balanceStrategy.service();
}
/**
* Getter for the current {@link DemandBalanceStrategy}.
*
* @return the strategy
*/
public DemandBalanceStrategy getStrategy() {
return getDemandBalanceStrategy();
}
// SettingSpecifierProvider
@Override
public String getSettingUID() {
return "net.solarnetwork.node.control.demandbalancer";
}
@Override
public String getDisplayName() {
return "Demand Balancer";
}
@Override
public MessageSource getMessageSource() {
return messageSource;
}
@Override
public List<SettingSpecifier> getSettingSpecifiers() {
DemandBalancer defaults = new DemandBalancer();
List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(6);
results.add(new BasicTextFieldSettingSpecifier("balanceStrategy.propertyFilters['UID']",
"Default"));
results.add(new BasicTextFieldSettingSpecifier("consumptionDataSource.propertyFilters['UID']",
"Main"));
results.add(new BasicTextFieldSettingSpecifier(
"consumptionDataSource.propertyFilters['groupUID']", ""));
results.add(new BasicTextFieldSettingSpecifier("acEnergyPhaseFilter", defaults
.getAcEnergyPhaseFilterValue()));
results.add(new BasicToggleSettingSpecifier("collectPower", defaults.isCollectPower()));
results.add(new BasicTextFieldSettingSpecifier("powerDataSource.propertyFilters['UID']", "Main"));
results.add(new BasicTextFieldSettingSpecifier("powerDataSource.propertyFilters['groupUID']", ""));
results.add(new BasicTextFieldSettingSpecifier("powerControlId", defaults.powerControlId));
results.add(new BasicTextFieldSettingSpecifier("powerMaximumWatts", String
.valueOf(defaults.powerMaximumWatts)));
DemandBalanceStrategy strategy = getDemandBalanceStrategy();
if ( strategy instanceof SettingSpecifierProvider ) {
SettingSpecifierProvider stratSettingProvider = (SettingSpecifierProvider) strategy;
List<SettingSpecifier> strategySpecifiers = stratSettingProvider.getSettingSpecifiers();
if ( strategySpecifiers != null && strategySpecifiers.size() > 0 ) {
for ( SettingSpecifier spec : strategySpecifiers ) {
if ( spec instanceof KeyedSettingSpecifier<?> ) {
KeyedSettingSpecifier<?> keyedSpec = (KeyedSettingSpecifier<?>) spec;
results.add(keyedSpec.mappedTo("strategy."));
} else {
results.add(spec);
}
}
}
}
return results;
}
// Accessors
public void setPowerControlId(String powerControlId) {
this.powerControlId = powerControlId;
if ( this.powerControl != null ) {
// automatically enforce filter
((FilterableService) this.powerControl).setPropertyFilter("availableControlIds",
this.powerControlId);
}
}
public void setPowerControl(OptionalService<NodeControlProvider> powerControl) {
if ( !(powerControl instanceof FilterableService) ) {
throw new IllegalArgumentException("OptionalService must also implement "
+ FilterableService.class.getName());
}
((FilterableService) powerControl).setPropertyFilter("availableControlIds", this.powerControlId);
this.powerControl = powerControl;
}
public void setPowerDataSource(
OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> powerDataSource) {
this.powerDataSource = powerDataSource;
}
public void setPowerMaximumWatts(int powerMaximumWatts) {
this.powerMaximumWatts = powerMaximumWatts;
}
public void setConsumptionDataSource(
OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> consumptionDataSource) {
this.consumptionDataSource = consumptionDataSource;
}
public void setBalanceStrategy(OptionalService<DemandBalanceStrategy> balanceStrategy) {
this.balanceStrategy = balanceStrategy;
}
public void setInstructionHandlers(Collection<InstructionHandler> instructionHandlers) {
this.instructionHandlers = instructionHandlers;
}
public void setMessageSource(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String getPowerControlId() {
return powerControlId;
}
public OptionalService<NodeControlProvider> getPowerControl() {
return powerControl;
}
public OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> getPowerDataSource() {
return powerDataSource;
}
public int getPowerMaximumWatts() {
return powerMaximumWatts;
}
public OptionalServiceCollection<DatumDataSource<? extends EnergyDatum>> getConsumptionDataSource() {
return consumptionDataSource;
}
public OptionalService<DemandBalanceStrategy> getBalanceStrategy() {
return balanceStrategy;
}
public Collection<InstructionHandler> getInstructionHandlers() {
return instructionHandlers;
}
public boolean isCollectPower() {
return collectPower;
}
public void setCollectPower(boolean collectPower) {
this.collectPower = collectPower;
}
public OptionalService<EventAdmin> getEventAdmin() {
return eventAdmin;
}
public void setEventAdmin(OptionalService<EventAdmin> eventAdmin) {
this.eventAdmin = eventAdmin;
}
public Set<ACPhase> getAcEnergyPhaseFilter() {
return acEnergyPhaseFilter;
}
public void setAcEnergyPhaseFilter(Set<ACPhase> acEnergyPhaseFilter) {
this.acEnergyPhaseFilter = acEnergyPhaseFilter;
}
/**
* Get the value of the {@code acEnergyPhaseFilter} property as a
* comma-delimited string.
*
* @return the AC phase as a delimited string
*/
public String getAcEnergyPhaseFilterValue() {
return (acEnergyPhaseFilter == null ? null : StringUtils
.commaDelimitedStringFromCollection(acEnergyPhaseFilter));
}
/**
* Set the {@code acEnergyPhaseFilter} property via a comma-delimited
* string.
*
* @param value
* the comma delimited string
* @see #getAcEnergyTotalPhaseOnlyPropertiesValue()
*/
public void getAcEnergyPhaseFilterValue(String value) {
Set<String> set = StringUtils.commaDelimitedStringToSet(value);
if ( set == null ) {
acEnergyPhaseFilter = null;
return;
}
Set<ACPhase> result = new LinkedHashSet<ACPhase>(set.size());
for ( String phase : set ) {
try {
ACPhase p = ACPhase.valueOf(phase);
result.add(p);
} catch ( IllegalArgumentException e ) {
log.warn("Ignoring unsupported ACPhase value [{}]", phase);
}
}
acEnergyPhaseFilter = EnumSet.copyOf(result);
}
}