mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[basicprofiles] Time-weighted average profile added (#19753)
* initial version Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
@@ -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" ]
|
||||
}
|
||||
```
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
+10
-3
@@ -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;
|
||||
}
|
||||
|
||||
+241
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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>
|
||||
+4
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+172
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user