[basicprofiles] Time-weighted average profile added (#19753)

* initial version

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
Bernd Weymann
2025-12-25 11:55:07 +01:00
committed by GitHub
parent e221c64270
commit ec466c470f
8 changed files with 508 additions and 4 deletions
@@ -14,6 +14,8 @@ This bundle provides a list of useful Profiles:
| [Time Range Command Profile](#time-range-command-profile) | An enhanced implementation of a follow profile which converts `OnOffType` to a `PercentType` |
| [State Filter Profile](#state-filter-profile) | Filters input data using arithmetic comparison conditions |
| [Inactivity Profile](#inactivity-profile) | Sets the linked Item On or Off depending whether the Channel has recently produced data |
| [Time-weighted Average Profile](#time-weighted-average-profile) | Collects updates for given duration to calculate time-weighted average value |
## Generic Command Profile
@@ -361,3 +363,37 @@ Switch myChannelActivityStatus {
channel="mybinding:mything:mychannel" [ profile="basic-profiles:inactivity", timeout="1d", inverted=true ]
}
```
## Time-weighted Average Profile
This profile aggregates all state updates within the configured duration and outputs a single time-weighted average value at the end of that period.
It is useful for reducing high frequency data before it reaches your persistence database, such as measurements from:
- inverters
- smart meters
- power plugs with electric measurement
This profile is only applicable to numeric channels, with or without unit metadata.
The average is calculated solely from state updates sent from the handler to the item.
Commands and item updates originating from rules or the UI are not affected.
Check the semantics of the channel if this profile fits.
E.g. a channel providing _power measurement_ values is a valid candidate to build averages.
A channel providing a status or accumulated value like _total energy production today_ is not a good candidate.
The optional `delta` parameter is used to identify increases / decreases of state values above or equal the configured delta.
This will break the steady time frame but reports rapid changes e.g. for power consumption immediately.
### Time-weighted Average Profile Configuration
| Configuration Parameter | Type | Description |
|-------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| `duration` | text | Duration of the time frame to collect state updates. See [format examples](https://www.openhab.org/docs/configuration/items.html#parameter-expire). |
| `delta` | decimal | Optional: If state change increases or decreases above configured delta it's published immediately. |
### Time-weighted Average Profile Example
```java
Number:Power SmartmeterPower {
channel="mybinding:mything:mychannel" [ profile="basic-profiles:time-weighted-average", duration="1m" ]
}
```
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.basicprofiles.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Configuration for {@link org.openhab.transform.basicprofiles.internal.profiles.TimeweightedAverageStateProfile}.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class TimeweightedAverageProfileConfig {
public String duration = "60s";
public double delta = 0.0;
}
@@ -51,6 +51,7 @@ import org.openhab.transform.basicprofiles.internal.profiles.RoundStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.StateFilterProfile;
import org.openhab.transform.basicprofiles.internal.profiles.ThresholdStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.TimeRangeCommandProfile;
import org.openhab.transform.basicprofiles.internal.profiles.TimeweightedAverageStateProfile;
import org.osgi.framework.Bundle;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@@ -75,6 +76,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
public static final ProfileTypeUID TIME_RANGE_COMMAND_UID = new ProfileTypeUID(SCOPE, "time-range-command");
public static final ProfileTypeUID STATE_FILTER_UID = new ProfileTypeUID(SCOPE, "state-filter");
public static final ProfileTypeUID INACTIVITY_UID = new ProfileTypeUID(SCOPE, "inactivity");
public static final ProfileTypeUID TIME_WEIGHTED_AVERAGE_UID = new ProfileTypeUID(SCOPE, "time-weighted-average");
private static final ProfileType PROFILE_TYPE_GENERIC_COMMAND = ProfileTypeBuilder
.newTrigger(GENERIC_COMMAND_UID, "Generic Command") //
@@ -110,17 +112,20 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
.build();
private static final ProfileType PROFILE_STATE_FILTER = ProfileTypeBuilder
.newState(STATE_FILTER_UID, "State Filter").build();
private static final ProfileType PROFILE_TYPE_INACTIVITY = ProfileTypeBuilder
.newState(INACTIVITY_UID, "No Input Activity").withSupportedItemTypes(CoreItemFactory.SWITCH).build();
private static final ProfileType PROFILE_TIME_WEIGHTED_AVERAGE = ProfileTypeBuilder
.newState(TIME_WEIGHTED_AVERAGE_UID, "Time-Weighted Average")
.withSupportedItemTypesOfChannel(CoreItemFactory.NUMBER).withSupportedItemTypes(CoreItemFactory.NUMBER)
.build();
private static final Set<ProfileTypeUID> SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GENERIC_COMMAND_UID,
GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, INVERT_UID, ROUND_UID, THRESHOLD_UID,
TIME_RANGE_COMMAND_UID, STATE_FILTER_UID, INACTIVITY_UID);
TIME_RANGE_COMMAND_UID, STATE_FILTER_UID, INACTIVITY_UID, TIME_WEIGHTED_AVERAGE_UID);
private static final Set<ProfileType> SUPPORTED_PROFILE_TYPES = Set.of(PROFILE_TYPE_GENERIC_COMMAND,
PROFILE_TYPE_GENERIC_TOGGLE_SWITCH, PROFILE_TYPE_DEBOUNCE_COUNTING, PROFILE_TYPE_DEBOUNCE_TIME,
PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND,
PROFILE_STATE_FILTER, PROFILE_TYPE_INACTIVITY);
PROFILE_STATE_FILTER, PROFILE_TYPE_INACTIVITY, PROFILE_TIME_WEIGHTED_AVERAGE);
private final Map<LocalizedKey, ProfileType> localizedProfileTypeCache = new ConcurrentHashMap<>();
@@ -165,6 +170,8 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
return new StateFilterProfile(callback, context, itemRegistry);
} else if (INACTIVITY_UID.equals(profileTypeUID)) {
return new InactivityProfile(callback, context, linkRegistry);
} else if (TIME_WEIGHTED_AVERAGE_UID.equals(profileTypeUID)) {
return new TimeweightedAverageStateProfile(callback, context);
}
return null;
}
@@ -0,0 +1,241 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.basicprofiles.internal.profiles;
import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.TIME_WEIGHTED_AVERAGE_UID;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.core.thing.profiles.StateProfile;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.util.DurationUtils;
import org.openhab.transform.basicprofiles.internal.config.TimeweightedAverageProfileConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Build time-weighted average {@link State} values.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class TimeweightedAverageStateProfile implements StateProfile {
private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(1);
private final Logger logger = LoggerFactory.getLogger(TimeweightedAverageStateProfile.class);
private final TreeMap<Instant, State> timeframe = new TreeMap<>();
private final TimeweightedAverageProfileConfig config;
private final ScheduledExecutorService scheduler;
private final ProfileCallback callback;
private boolean streamingInTimeframe = false;
private Duration scheduleDuration;
private String itemName;
private @Nullable ScheduledFuture<?> twaJob;
private @Nullable State latestState;
private @Nullable Unit<?> stateUnit;
public TimeweightedAverageStateProfile(ProfileCallback callback, ProfileContext context) {
this.callback = callback;
this.scheduler = context.getExecutorService();
this.config = context.getConfiguration().as(TimeweightedAverageProfileConfig.class);
itemName = callback.getItemChannelLink().getItemName();
try {
scheduleDuration = DurationUtils.parse(config.duration);
} catch (IllegalArgumentException e) {
scheduleDuration = DEFAULT_TIMEOUT;
logger.warn("Invalid duration configuration {} for item {}. Fallback to {}", config.duration, itemName,
DEFAULT_TIMEOUT);
}
}
@Override
public ProfileTypeUID getProfileTypeUID() {
return TIME_WEIGHTED_AVERAGE_UID;
}
@Override
public void onStateUpdateFromHandler(State state) {
logger.trace("Received {} {}", itemName, state);
// first call initialization and threshold check
// synchronize access to timeframe and latestState to avoid parallel execution of delivery by twaJob
synchronized (timeframe) {
if (latestState == null) {
init(state);
}
// if state change is above delta threshold, deliver immediately collected values plus latest reported state
if (deltaExceeded(state)) {
deliver();
callback.sendUpdate(state);
}
// start new time frame
startJob();
timeframe.put(Instant.now(), state);
latestState = state;
streamingInTimeframe = true;
}
}
private void init(State first) {
if (first instanceof QuantityType<?> qtState) {
stateUnit = qtState.getUnit();
logger.debug("Profile initialized for {} with QuantityType {}", itemName, stateUnit);
} else if (first instanceof DecimalType) {
// nothing to do, stateUnit stays empty
logger.debug("Profile initialized for {} with DecimalType", itemName);
} else {
logger.warn("Time-weighted average profile not applicable for {} with class {}", itemName,
first.getClass());
}
}
private boolean deltaExceeded(State state) {
if (config.delta > 0) {
State localLatestState = latestState;
if (localLatestState != null) {
double delta = Math.abs(state2Double(state) - state2Double(localLatestState));
if (delta >= config.delta) {
logger.debug("{} rapid change from {} to {}", itemName, latestState, state);
return true;
}
}
}
return false;
}
private void deliver() {
TreeMap<Instant, State> delivery = prepareDelivery();
if (delivery.size() <= 1) {
logger.debug("Cannot calculate time-weighted average for item {} with {} elements", itemName,
delivery.size());
} else {
callback.sendUpdate(getState(average(delivery)));
}
}
private TreeMap<Instant, State> prepareDelivery() {
TreeMap<Instant, State> delivery;
// synchronize access to timeframe and latestState to prepare delivery without parallel execution of
// onStateUpdateFromHandler
synchronized (timeframe) {
resetJob();
delivery = new TreeMap<>(timeframe);
// add termination element
Instant now = Instant.now();
delivery.put(now, DecimalType.ZERO);
// clear time frame and put latest reported state as start point of the next calculation
timeframe.clear();
State localState = latestState;
if (localState != null) {
if (streamingInTimeframe) {
// state updates retrieved in time frame, start new job
timeframe.put(now, localState);
streamingInTimeframe = false;
startJob();
} else {
// no new states received during this time frame
logger.debug("No new states for {} received, wait for next state update", itemName);
}
}
}
return delivery;
}
private void startJob() {
if (twaJob == null) {
logger.trace("Start next time frame for {} with delay {} ms", itemName, scheduleDuration.toMillis());
twaJob = scheduler.schedule(this::deliver, scheduleDuration.toMillis(), TimeUnit.MILLISECONDS);
}
}
private void resetJob() {
ScheduledFuture<?> localTwaJob = twaJob;
if (localTwaJob != null) {
localTwaJob.cancel(false);
twaJob = null;
}
}
private double state2Double(State state) {
DecimalType as = state.as(DecimalType.class);
if (as == null) {
// may happen if state delivery contains NULL or UNDEF states
logger.warn("Cannot convert state {} of item {} to DecimalType for average calculation", state, itemName);
return 0;
}
return as.doubleValue();
}
public double average(TreeMap<Instant, State> values) {
Instant previousTimestamp = null;
State previousValue = DecimalType.ZERO;
double totalWeightedValue = 0;
long totalDurationMs = 0;
for (Map.Entry<Instant, State> entry : values.entrySet()) {
if (previousTimestamp != null) {
long durationMs = Duration.between(previousTimestamp, entry.getKey()).toMillis();
totalWeightedValue += state2Double(previousValue) * durationMs;
totalDurationMs += durationMs;
}
previousTimestamp = entry.getKey();
previousValue = entry.getValue();
}
double average = (totalDurationMs > 0) ? totalWeightedValue / totalDurationMs : 0;
logger.debug("Average {} is {} for {} updates", itemName, average, values.size());
return average;
}
private State getState(double average) {
Unit<?> localUnit = stateUnit;
if (localUnit == null) {
return new DecimalType(average);
} else {
return new QuantityType<>(average, localUnit);
}
}
@Override
public void onStateUpdateFromItem(State state) {
// no-op
}
@Override
public void onCommandFromItem(Command command) {
// no-op
}
@Override
public void onCommandFromHandler(Command command) {
// no-op
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="profile:basic-profiles:time-weighted-average">
<parameter name="duration" type="text" required="true">
<label>Duration</label>
<description>Duration of the time frame to collect state updates</description>
<default>1m</default>
</parameter>
<parameter name="delta" type="decimal" min="0" step="1" required="false">
<label>Delta</label>
<description>If state change increases or decreases above configured delta it's published immediately</description>
</parameter>
</config-description>
</config-description:config-descriptions>
@@ -65,6 +65,10 @@ profile.config.basic-profiles.time-range-command.restoreValue.option.PREVIOUS =
profile.config.basic-profiles.time-range-command.restoreValue.option.NOTHING = Do nothing
profile.config.basic-profiles.time-range-command.start.label = Start Time
profile.config.basic-profiles.time-range-command.start.description = The start time of the day (hh:mm).
profile.config.basic-profiles.time-weighted-average.delta.label = Delta
profile.config.basic-profiles.time-weighted-average.delta.description = If state change increases or decreases above configured delta it's published immediately
profile.config.basic-profiles.time-weighted-average.duration.label = Duration
profile.config.basic-profiles.time-weighted-average.duration.description = Duration of the time frame to collect state updates
profile.config.basic-profiles.toggle-switch.events.label = Events
profile.config.basic-profiles.toggle-switch.events.description = Comma separated list of events to which the profile should listen.
@@ -57,7 +57,7 @@ import org.openhab.transform.basicprofiles.internal.profiles.TimeRangeCommandPro
@NonNullByDefault
public class BasicProfilesFactoryTest {
private static final int NUMBER_OF_PROFILES = 10;
private static final int NUMBER_OF_PROFILES = 11;
private static final Map<String, Object> PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15,
RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003",
@@ -0,0 +1,172 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.basicprofiles.internal.profiles;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.time.Instant;
import java.util.List;
import java.util.TreeMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.link.ItemChannelLink;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Unit test for {@link TimeweightedAverageStateProfile}.
*
* @author Bernd Weymann - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
class TimeweightedAverageProfileTest {
private @NonNullByDefault({}) @Mock ProfileCallback mockCallback;
private @NonNullByDefault({}) @Mock ProfileContext mockContext;
private @NonNullByDefault({}) @Mock ScheduledExecutorService mockScheduler;
private final String testItemName = "testItem";
private final ChannelUID testChannelUID = new ChannelUID("this:test:channel:uid");
private ItemChannelLink testLink = new ItemChannelLink(testItemName, testChannelUID);
private TimeweightedAverageStateProfile initTWAProfile(String duration, double delta) {
Configuration config = new Configuration();
config.put("duration", duration);
if (delta > 0) {
config.put("delta", delta);
}
reset(mockContext);
reset(mockCallback);
reset(mockScheduler);
when(mockContext.getExecutorService()).thenReturn(mockScheduler);
when(mockContext.getConfiguration()).thenReturn(config);
testLink = new ItemChannelLink(testItemName, testChannelUID, config);
when(mockCallback.getItemChannelLink()).thenReturn(testLink);
return new TimeweightedAverageStateProfile(mockCallback, mockContext);
}
public static Stream<Arguments> testTWATimeframe() {
return Stream.of( //
Arguments.of("10s", 10 * 1000), //
Arguments.of("", 60 * 1000), //
Arguments.of(null, 60 * 1000), //
Arguments.of("500ms", 500), //
Arguments.of("7m", 7 * 60 * 1000));
}
@ParameterizedTest
@MethodSource
public void testTWATimeframe(String timeout, long expectedSchedule) {
TimeweightedAverageStateProfile profile = initTWAProfile(timeout, 0);
profile.onStateUpdateFromHandler(DecimalType.ZERO);
verify(mockScheduler, times(1)).schedule(any(Runnable.class), eq(expectedSchedule), eq(TimeUnit.MILLISECONDS));
reset(mockScheduler);
}
public static Stream<Arguments> testTWAAverages() {
return Stream.of( //
Arguments.of(List.of("2024-01-01T00:00:00Z", "2024-01-01T00:01:00Z"), List.of("750 W", "0 W"), 750.0), //
Arguments.of(List.of("2024-01-01T00:00:00Z", "2024-01-01T00:00:30Z", "2024-01-01T00:01:00Z"),
List.of("750 W", "1000 W", "0 W"), 875.0), //
Arguments.of(List.of("2024-01-01T00:00:00Z", "2024-01-01T00:00:20Z", "2024-01-01T00:00:40Z",
"2024-01-01T00:01:00Z"), List.of("300 W", "900 W", "600 W", "0 W"), 600.0) //
);
}
@ParameterizedTest
@MethodSource
public void testTWAAverages(List<String> timeStrings, List<String> stateStrings, double expectedAverage) {
TimeweightedAverageStateProfile profile = initTWAProfile("10s", 0);
TreeMap<Instant, State> testData = new TreeMap<>();
for (int i = 0; i < timeStrings.size(); i++) {
testData.put(Instant.parse(timeStrings.get(i)), QuantityType.valueOf(stateStrings.get(i)));
}
assertEquals(expectedAverage, profile.average(testData));
}
public static Stream<Arguments> testTWADelta() {
return Stream.of( //
Arguments.of(List.of("750 W", "1000 W", "900 W", "800 W"), 0), //
Arguments.of(List.of("750 W", "1000 W", "900 W", "800 W", "1300 W"), 2), //
Arguments.of(List.of("750 W", "1000 W", "900 W", "800 W", "300 W"), 2), //
Arguments.of(
List.of("750 W", "1000 W", "900 W", "800 W", "1500 W", "1400 W", "1300 W", "1100 W", "500 W"),
4) //
);
}
@ParameterizedTest
@MethodSource
public void testTWADelta(List<String> stateStrings, int expectedCallbacks) {
TimeweightedAverageStateProfile profile = initTWAProfile("1h", 500);
for (String stateString : stateStrings) {
profile.onStateUpdateFromHandler(QuantityType.valueOf(stateString));
try {
Thread.sleep(25); // ensure different time stamps
} catch (InterruptedException e) {
fail(e.getMessage());
}
}
verify(mockCallback, times(expectedCallbacks)).sendUpdate(any());
reset(mockCallback);
}
public static Stream<Arguments> testTWAInvalidStates() {
List<State> statesWithNull = List.of(QuantityType.valueOf("700 W"), UnDefType.NULL,
QuantityType.valueOf("900 W"), QuantityType.valueOf("900 W"));
List<State> statesWithUndef = List.of(QuantityType.valueOf("700 W"), UnDefType.UNDEF,
QuantityType.valueOf("900 W"), QuantityType.valueOf("900 W"));
List<State> statesWithBoth = List.of(QuantityType.valueOf("700 W"), UnDefType.UNDEF, UnDefType.NULL,
UnDefType.UNDEF, QuantityType.valueOf("900 W"), QuantityType.valueOf("900 W"));
return Stream.of(Arguments.of(statesWithNull, 4), //
Arguments.of(statesWithUndef, 4), //
Arguments.of(statesWithBoth, 4) //
);
}
@ParameterizedTest
@MethodSource
public void testTWAInvalidStates(List<State> states, int expectedCallbacks) {
TimeweightedAverageStateProfile profile = initTWAProfile("1h", 500);
for (State state : states) {
profile.onStateUpdateFromHandler(state);
try {
Thread.sleep(25); // ensure different time stamps
} catch (InterruptedException e) {
fail(e.getMessage());
}
}
verify(mockCallback, times(expectedCallbacks)).sendUpdate(any());
reset(mockCallback);
}
}