/* ================================================================== * 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); } }