[basicprofiles] Fix StateFilterProfile to use linked Item system unit (#18144)

* [basicprofiles] Improve StateFilterProfile unit based calculations

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
pull/18257/head
Andrew Fiddian-Green 2025-02-12 20:52:18 +00:00 committed by GitHub
parent 3b820edcb2
commit d0bac82f21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 438 additions and 180 deletions

View File

@ -242,6 +242,10 @@ The `LHS_OPERAND` and the `RHS_OPERAND` can be either one of these:
This can be customized by specifying the "window size" or sample count applicable to the function, e.g. `$MEDIAN(10)` will return the median of the last 10 values.
All the functions except `$DELTA` support a custom window size.
In the case of comparisons and calculations involving `QuantityType` values, all the values are converted to the Unit of the linked Item before the calculation and/or comparison is done.
Note: if the binding sends a value that cannot be converted to the Unit of the linked Item, then that value is excluded.
e.g. if the linked item has a Unit of `Units.METRE` and the binding sends a value of `Units.CELSIUS` then the value is ignored.
The state of one item can be compared against the state of another item by having item names on both sides of the comparison, e.g.: `Item1 > Item2`.
When `LHS_OPERAND` is omitted, e.g. `> 10, < 100`, the comparisons are applied against the input data from the binding.
The `RHS_OPERAND` can be any of the valid values listed above.

View File

@ -37,6 +37,7 @@ import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
@ -53,6 +54,8 @@ import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileCon
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.units.indriya.AbstractUnit;
/**
* Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not*
* met.
@ -60,6 +63,7 @@ import org.slf4j.LoggerFactory;
* @author Arne Seime - Initial contribution
* @author Jimmy Tanagra - Expanded the comparison types
* @author Jimmy Tanagra - Added support for functions
* @author Andrew Fiddian-Green - Normalise calculations based on the Unit of the linked Item
*/
@NonNullByDefault
public class StateFilterProfile implements StateProfile {
@ -103,48 +107,50 @@ public class StateFilterProfile implements StateProfile {
private @Nullable Item linkedItem = null;
private State newState = UnDefType.UNDEF;
// single cached numeric state for use in conjunction with DELTA and DELTA_PERCENT functions
private Optional<State> acceptedState = Optional.empty();
private LinkedList<State> previousStates = new LinkedList<>();
// cached list of prior numeric states for use in conjunction with AVG, MEDIAN, STDDEV, MIN, MAX functions
private final List<State> previousStates = new LinkedList<>();
private final int windowSize;
// reference (zero based) system unit for conversions
private @Nullable Unit<?> systemUnit = null;
private boolean systemUnitInitialized = false;
public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
this.callback = callback;
this.itemRegistry = itemRegistry;
StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
if (config != null) {
conditions = parseConditions(config.conditions, config.separator);
int maxWindowSize = 0;
if (conditions.isEmpty()) {
logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}",
callback.getItemChannelLink(), config.conditions);
} else {
for (StateCondition condition : conditions) {
if (condition.lhsState instanceof FunctionType function) {
int windowSize = function.getWindowSize();
if (windowSize > maxWindowSize) {
maxWindowSize = windowSize;
}
conditions = parseConditions(config.conditions, config.separator);
int maxWindowSize = 0;
if (conditions.isEmpty()) {
logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}",
callback.getItemChannelLink(), config.conditions);
} else {
for (StateCondition condition : conditions) {
if (condition.lhsState instanceof FunctionType function) {
int windowSize = function.getWindowSize();
if (windowSize > maxWindowSize) {
maxWindowSize = windowSize;
}
if (condition.rhsState instanceof FunctionType function) {
int windowSize = function.getWindowSize();
if (windowSize > maxWindowSize) {
maxWindowSize = windowSize;
}
}
if (condition.rhsState instanceof FunctionType function) {
int windowSize = function.getWindowSize();
if (windowSize > maxWindowSize) {
maxWindowSize = windowSize;
}
}
}
windowSize = maxWindowSize;
configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
} else {
conditions = List.of();
configMismatchState = null;
windowSize = 0;
}
windowSize = maxWindowSize;
configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
}
private List<StateCondition> parseConditions(List<String> conditions, String separator) {
@ -204,6 +210,10 @@ public class StateFilterProfile implements StateProfile {
@Override
public void onStateUpdateFromHandler(State state) {
if (!isAllowed(state)) {
logger.debug("Received non allowed state update from handler: {}, ignored", state);
return;
}
newState = state;
State resultState = checkCondition(state);
if (resultState != null) {
@ -212,7 +222,7 @@ public class StateFilterProfile implements StateProfile {
} else {
logger.debug("Received state update from handler: {}, not forwarded to item", state);
}
if (windowSize > 0 && (state instanceof DecimalType || state instanceof QuantityType)) {
if (windowSize > 0 && isCacheable(state)) {
previousStates.add(state);
if (previousStates.size() > windowSize) {
previousStates.removeFirst();
@ -230,7 +240,9 @@ public class StateFilterProfile implements StateProfile {
}
if (conditions.stream().allMatch(c -> c.check(state))) {
acceptedState = Optional.of(state);
if (isCacheable(state)) {
acceptedState = Optional.of(state);
}
return state;
} else {
return configMismatchState;
@ -316,7 +328,7 @@ public class StateFilterProfile implements StateProfile {
/**
* Check if the condition is met.
*
*
* If the lhs is empty, the condition is checked against the input state.
*
* @param input the state to check against
@ -342,6 +354,9 @@ public class StateFilterProfile implements StateProfile {
if (rhsFunction.alwaysAccept()) {
return true;
}
if (rhsFunction.getType() == FunctionType.Function.DELTA) {
isDeltaCheck = true;
}
rhsItem = getLinkedItem();
}
@ -397,10 +412,6 @@ public class StateFilterProfile implements StateProfile {
// Don't convert QuantityType to other types, so that 1500 != 1500 W
if (rhsState != null && !(rhsState instanceof QuantityType)) {
if (rhsState instanceof FunctionType rhsFunction
&& rhsFunction.getType() == FunctionType.Function.DELTA) {
isDeltaCheck = true;
}
// Try to convert it to the same type as the lhs
// This allows comparing compatible types, e.g. PercentType vs OnOffType
rhsState = rhsState.as(lhsState.getClass());
@ -438,9 +449,14 @@ public class StateFilterProfile implements StateProfile {
rhs = Objects.requireNonNull(rhsState instanceof StringType ? rhsState.toString() : rhsState);
if (isDeltaCheck && rhs instanceof QuantityType rhsQty && lhs instanceof QuantityType lhsQty) {
if (rhsQty.toUnitRelative(lhsQty.getUnit()) instanceof QuantityType relativeRhs) {
rhs = relativeRhs;
if ((rhs instanceof QuantityType rhsQty) && (lhs instanceof QuantityType lhsQty)) {
if (isDeltaCheck) {
if (rhsQty.toUnitRelative(lhsQty.getUnit()) instanceof QuantityType relativeRhs) {
rhs = relativeRhs;
}
} else if (hasSystemUnit()) {
lhs = toSystemUnitQuantityType(lhsQty) instanceof QuantityType lhsSU ? lhsSU : lhs;
rhs = toSystemUnitQuantityType(rhsQty) instanceof QuantityType rhsSU ? rhsSU : rhs;
}
}
@ -454,13 +470,14 @@ public class StateFilterProfile implements StateProfile {
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
boolean result = switch (comparisonType) {
case EQ -> lhs.equals(rhs);
case NEQ, NEQ_ALT -> !lhs.equals(rhs);
case GT -> ((Comparable) lhs).compareTo(rhs) > 0;
case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0;
case LT -> ((Comparable) lhs).compareTo(rhs) < 0;
case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0;
case GT -> ((Comparable<Object>) lhs).compareTo(rhs) > 0;
case GTE -> ((Comparable<Object>) lhs).compareTo(rhs) >= 0;
case LT -> ((Comparable<Object>) lhs).compareTo(rhs) < 0;
case LTE -> ((Comparable<Object>) lhs).compareTo(rhs) <= 0;
};
return result;
@ -551,18 +568,31 @@ public class StateFilterProfile implements StateProfile {
public @Nullable State calculate() {
logger.debug("Calculating function: {}", this);
int size = previousStates.size();
int start = windowSize.map(w -> size - w).orElse(0);
List<State> states = start <= 0 ? previousStates : previousStates.subList(start, size);
return switch (type) {
case DELTA -> calculateDelta();
case DELTA_PERCENT -> calculateDeltaPercent();
case AVG, AVERAGE -> calculateAverage(states);
case MEDIAN -> calculateMedian(states);
case STDDEV -> calculateStdDev(states);
case MIN -> calculateMin(states);
case MAX -> calculateMax(states);
};
State result;
switch (type) {
case DELTA -> result = calculateDelta();
case DELTA_PERCENT -> result = calculateDeltaPercent();
default -> {
int size = previousStates.size();
Integer start = windowSize.map(w -> size - w).orElse(0);
List<BigDecimal> values = toBigDecimals(
start == null || start <= 0 ? previousStates : previousStates.subList(start, size));
if (values.isEmpty()) {
logger.debug("Not enough states to calculate {}", type);
result = null;
} else {
switch (type) {
case AVG, AVERAGE -> result = calculateAverage(values);
case MEDIAN -> result = calculateMedian(values);
case STDDEV -> result = calculateStdDev(values);
case MIN -> result = calculateMin(values);
case MAX -> result = calculateMax(values);
default -> result = null;
}
}
}
}
return result;
}
/**
@ -579,11 +609,8 @@ public class StateFilterProfile implements StateProfile {
}
if (type == Function.DELTA_PERCENT) {
// avoid division by zero
if (acceptedState.get() instanceof QuantityType base) {
return base.toBigDecimal().compareTo(BigDecimal.ZERO) == 0;
}
if (acceptedState.get() instanceof DecimalType base) {
return base.toBigDecimal().compareTo(BigDecimal.ZERO) == 0;
if (toBigDecimal(acceptedState.get()) instanceof BigDecimal base) {
return base.compareTo(BigDecimal.ZERO) == 0;
}
}
return false;
@ -591,6 +618,7 @@ public class StateFilterProfile implements StateProfile {
@Override
public <T extends State> @Nullable T as(@Nullable Class<T> target) {
// TODO @andrewfg: do we need to change this ??
if (target == DecimalType.class || target == QuantityType.class) {
return target.cast(calculate());
}
@ -603,7 +631,7 @@ public class StateFilterProfile implements StateProfile {
// the previous state is kept in the acceptedState variable
return 0;
}
return windowSize.orElse(DEFAULT_WINDOW_SIZE);
return windowSize.isPresent() ? windowSize.get() : DEFAULT_WINDOW_SIZE;
}
public Function getType() {
@ -625,126 +653,179 @@ public class StateFilterProfile implements StateProfile {
return toFullString();
}
private @Nullable State calculateAverage(List<State> states) {
if (states.isEmpty()) {
logger.debug("Not enough states to calculate sum");
return null;
}
if (newState instanceof QuantityType newStateQuantity) {
QuantityType zero = new QuantityType(0, newStateQuantity.getUnit());
QuantityType sum = states.stream().map(s -> (QuantityType) s).reduce(zero, QuantityType::add);
return sum.divide(BigDecimal.valueOf(states.size()));
}
BigDecimal sum = states.stream().map(s -> ((DecimalType) s).toBigDecimal()).reduce(BigDecimal.ZERO,
BigDecimal::add);
return new DecimalType(sum.divide(BigDecimal.valueOf(states.size()), 2, RoundingMode.HALF_EVEN));
private @Nullable State calculateAverage(List<BigDecimal> values) {
return Optional
.ofNullable(values.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal.valueOf(values.size()), MathContext.DECIMAL32))
.map(o -> toState(o)).orElse(null);
}
private @Nullable State calculateMedian(List<State> states) {
if (states.isEmpty()) {
logger.debug("Not enough states to calculate median");
return null;
}
if (newState instanceof QuantityType newStateQuantity) {
Unit<?> unit = newStateQuantity.getUnit();
List<BigDecimal> bdStates = states.stream()
.map(s -> ((QuantityType) s).toInvertibleUnit(unit).toBigDecimal()).toList();
return Optional.ofNullable(Statistics.median(bdStates)).map(median -> new QuantityType(median, unit))
.orElse(null);
}
List<BigDecimal> bdStates = states.stream().map(s -> ((DecimalType) s).toBigDecimal()).toList();
return Optional.ofNullable(Statistics.median(bdStates)).map(median -> new DecimalType(median)).orElse(null);
private @Nullable State calculateMedian(List<BigDecimal> values) {
return Optional.ofNullable(Statistics.median(values)).map(o -> toState(o)).orElse(null);
}
private @Nullable State calculateStdDev(List<State> states) {
if (states.isEmpty()) {
logger.debug("Not enough states to calculate standard deviation");
return null;
}
if (newState instanceof QuantityType newStateQuantity) {
QuantityType average = (QuantityType) calculateAverage(states);
if (average == null) {
return null;
}
QuantityType zero = new QuantityType(0, newStateQuantity.getUnit());
QuantityType variance = states.stream() //
.map(s -> {
QuantityType delta = ((QuantityType) s).subtract(average);
return (QuantityType) delta.multiply(delta.toBigDecimal()); // don't square the unit
}) //
.reduce(zero, QuantityType::add) // This reduced into a QuantityType
.divide(BigDecimal.valueOf(states.size()));
return new QuantityType(variance.toBigDecimal().sqrt(MathContext.DECIMAL32), variance.getUnit());
}
BigDecimal average = Optional.ofNullable((DecimalType) calculateAverage(states))
.map(DecimalType::toBigDecimal).orElse(null);
if (average == null) {
return null;
}
BigDecimal variance = states.stream().map(s -> {
BigDecimal delta = ((DecimalType) s).toBigDecimal().subtract(average);
private @Nullable State calculateStdDev(List<BigDecimal> values) {
BigDecimal average = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_EVEN);
BigDecimal variance = values.stream().map(value -> {
BigDecimal delta = value.subtract(average);
return delta.multiply(delta);
}).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(states.size()),
}).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(values.size()),
MathContext.DECIMAL32);
return new DecimalType(variance.sqrt(MathContext.DECIMAL32));
return toState(variance.sqrt(MathContext.DECIMAL32));
}
private @Nullable State calculateMin(List<State> states) {
if (states.isEmpty()) {
logger.debug("Not enough states to calculate min");
return null;
}
if (newState instanceof QuantityType newStateQuantity) {
return states.stream().map(s -> (QuantityType) s).min(QuantityType::compareTo).orElse(null);
}
return states.stream().map(s -> ((DecimalType) s).toBigDecimal()).min(BigDecimal::compareTo)
.map(DecimalType::new).orElse(null);
private @Nullable State calculateMin(List<BigDecimal> values) {
return Optional.ofNullable(values.stream().min(BigDecimal::compareTo).orElse(null)).map(o -> toState(o))
.orElse(null);
}
private @Nullable State calculateMax(List<State> states) {
if (states.isEmpty()) {
logger.debug("Not enough states to calculate max");
return null;
}
if (newState instanceof QuantityType newStateQuantity) {
return states.stream().map(s -> (QuantityType) s).max(QuantityType::compareTo).orElse(null);
}
return states.stream().map(s -> ((DecimalType) s).toBigDecimal()).max(BigDecimal::compareTo)
.map(DecimalType::new).orElse(null);
private @Nullable State calculateMax(List<BigDecimal> values) {
return Optional.ofNullable(values.stream().max(BigDecimal::compareTo).orElse(null)).map(o -> toState(o))
.orElse(null);
}
private @Nullable State calculateDelta() {
if (acceptedState.isEmpty()) {
return null;
}
if (newState instanceof QuantityType newStateQuantity) {
QuantityType result = newStateQuantity.subtract((QuantityType) acceptedState.get());
return result.toBigDecimal().compareTo(BigDecimal.ZERO) < 0 ? result.negate() : result;
}
BigDecimal result = ((DecimalType) newState).toBigDecimal()
.subtract(((DecimalType) acceptedState.get()).toBigDecimal()) //
.abs();
return new DecimalType(result);
return acceptedState.isPresent() //
&& toBigDecimal(acceptedState.get()) instanceof BigDecimal acceptedValue
&& toBigDecimal(newState) instanceof BigDecimal newValue //
? toState(newValue.subtract(acceptedValue).abs())
: null;
}
private @Nullable State calculateDeltaPercent() {
if (acceptedState.isEmpty()) {
return null;
}
State calculatedDelta = calculateDelta();
BigDecimal bdDelta;
BigDecimal bdBase;
if (acceptedState.get() instanceof QuantityType acceptedStateQuantity) {
// Assume that delta and base are in the same unit
bdDelta = ((QuantityType) calculatedDelta).toBigDecimal();
bdBase = acceptedStateQuantity.toBigDecimal();
} else {
bdDelta = ((DecimalType) calculatedDelta).toBigDecimal();
bdBase = ((DecimalType) acceptedState.get()).toBigDecimal();
}
bdBase = bdBase.abs();
BigDecimal percent = bdDelta.multiply(BigDecimal.valueOf(100)).divide(bdBase, 2, RoundingMode.HALF_EVEN);
return new DecimalType(percent);
return acceptedState.isPresent() //
&& toBigDecimal(acceptedState.get()) instanceof BigDecimal acceptedValue
&& toBigDecimal(newState) instanceof BigDecimal newValue
// percent is dimension-less; we must return DecimalType
? new DecimalType(newValue.subtract(acceptedValue).multiply(BigDecimal.valueOf(100))
.divide(acceptedValue, MathContext.DECIMAL32).abs())
: null;
}
}
/**
* Return true if 'systemUnit' is defined. The first call to this method initialises 'systemUnit' to its
* (effectively) final value, so if this method returns 'true' we can safely use 'Objects.requireNonNull()'
* thereafter to assert that 'systemUnit' is indeed non- null. The {@link Unit} is initialized based on the
* system unit of the linked {@link Item}. If there is no linked Item, or it is not a {@link NumberItem} or
* if the Item does not have a {@link Unit}, then 'systemUnit' is null and this method returns false.
*/
protected synchronized boolean hasSystemUnit() {
if (!systemUnitInitialized) {
systemUnitInitialized = true;
systemUnit = getLinkedItem() instanceof NumberItem item && item.getUnit() instanceof Unit<?> unit
? unit.getSystemUnit()
: null;
}
return systemUnit != null;
}
/**
* Convert a {@link State} to a {@link BigDecimal}. If it is a {@link QuantityType} and there is a 'systemUnit' its
* value is converted (if possible) to the 'systemUnit' before converting it to a {@link BigDecimal}. Returns null
* if the {@link State} does not have a numeric value, or if the conversion to 'systemUnit' fails.
*
* @return a {@link BigDecimal} or null.
*/
protected @Nullable BigDecimal toBigDecimal(State state) {
if (state instanceof DecimalType decimalType) {
return decimalType.toBigDecimal();
}
if (state instanceof QuantityType<?> quantityType) {
return hasSystemUnit() //
? toSystemUnitQuantityType(state) instanceof QuantityType<?> suQuantityType
? suQuantityType.toBigDecimal()
: null
: quantityType.toBigDecimal();
}
return state.as(DecimalType.class) instanceof DecimalType decimalType //
? decimalType.toBigDecimal()
: null;
}
/**
* Convert a list of {@link State} to a list of {@link BigDecimal} values.
*
* @param states list of {@link State} values.
* @return list of {@link BigDecimal} values.
*/
protected List<BigDecimal> toBigDecimals(List<? extends State> states) {
return states.stream().map(s -> toBigDecimal(s)).filter(Objects::nonNull).toList();
}
/**
* Create a new {@link State} from the given {@link BigDecimal} value. If there is a 'systemUnit' it creates a
* {@link QuantityType} based on that unit. Otherwise it creates a {@link DecimalType}.
*
* @return a {@link QuantityType} or a {@link DecimalType}
*/
protected State toState(BigDecimal value) {
return hasSystemUnit() //
? new QuantityType<>(value, Objects.requireNonNull(systemUnit))
: new DecimalType(value);
}
/**
* Convert a {@link State} to a {@link QuantityType} with its value converted to the 'systemUnit'.
* Returns null if the state is not a {@link QuantityType} or it does not convert to 'systemUnit'.
*
* @return a {@link QuantityType} based on 'systemUnit'.
*/
protected @Nullable QuantityType<?> toSystemUnitQuantityType(State state) {
return state instanceof QuantityType<?> quantityType && hasSystemUnit() //
? toInvertibleUnit(quantityType, Objects.requireNonNull(systemUnit))
: null;
}
/**
* Convert the given {@link QuantityType} to an equivalent based on the target {@link Unit}. The conversion can be
* made to both inverted and non-inverted units, so invertible type conversions (e.g. Mirek <=> Kelvin) are
* supported.
* <p>
* Note: we can use {@link QuantityType.toInvertibleUnit()} if OH Core PR #4561 is merged.
*
* @param source the {@link QuantityType} to be converted.
* @param targetUnit the {@link Unit} to convert to.
*
* @return a new {@link QuantityType} based on 'systemUnit' or null.
*/
protected @Nullable QuantityType<?> toInvertibleUnit(QuantityType<?> source, Unit<?> targetUnit) {
Unit<?> sourceSystemUnit = source.getUnit().getSystemUnit();
if (!targetUnit.equals(sourceSystemUnit) && !targetUnit.isCompatible(AbstractUnit.ONE)
&& sourceSystemUnit.inverse().isCompatible(targetUnit)) {
QuantityType<?> sourceInItsSystemUnit = source.toUnit(sourceSystemUnit);
return sourceInItsSystemUnit != null ? sourceInItsSystemUnit.inverse().toUnit(targetUnit) : null;
}
return source.toUnit(targetUnit);
}
/**
* Check if the given {@link State} is allowed. Non -allowed states are those which are a {@link QuantityType}
* and if there is a 'systemUnit' not compatible with that.
*
* @param state the incoming state.
* @return true if allowed.
*/
protected boolean isAllowed(State state) {
return hasSystemUnit() //
? toSystemUnitQuantityType(state) != null
: true;
}
/**
* Check if the given {@link State} is suitable to be cached. This means it is suitable to add to the
* 'previousStates' list and/or to set to the 'acceptedState' field. This means that either there is a
* 'systemUnit' with which 'state' is compatible, or it can provide a {@link DecimalType} value.
*
* @param state the {@link State} to be tested.
* @return true if the 'state' is suitable to be cached.
*/
protected boolean isCacheable(State state) {
return hasSystemUnit() //
? toSystemUnitQuantityType(state) != null
: state.as(DecimalType.class) != null;
}
}

View File

@ -12,24 +12,24 @@
*/
package org.openhab.transform.basicprofiles.internal.profiles;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Collection;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import javax.measure.MetricPrefix;
import javax.measure.Quantity;
import javax.measure.Unit;
import javax.measure.quantity.Dimensionless;
import javax.measure.quantity.Power;
import javax.measure.quantity.Time;
import javax.measure.spi.SystemOfUnits;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
@ -63,6 +63,7 @@ import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.ImperialUnits;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.link.ItemChannelLink;
@ -106,7 +107,7 @@ public class StateFilterProfileTest {
reset(mockCallback);
reset(mockItemChannelLink);
when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink);
when(mockItemRegistry.getItem("")).thenThrow(ItemNotFoundException.class);
// when(mockItemRegistry.getItem("")).thenThrow(ItemNotFoundException.class);
}
@Test
@ -291,7 +292,7 @@ public class StateFilterProfileTest {
ContactItem contactItem = new ContactItem("contactItem");
RollershutterItem rollershutterItem = new RollershutterItem("rollershutterItem");
QuantityType q_1500W = QuantityType.valueOf("1500 W");
QuantityType<?> q_1500W = QuantityType.valueOf("1500 W");
DecimalType d_1500 = DecimalType.valueOf("1500");
StringType s_foo = StringType.valueOf("foo");
StringType s_NULL = StringType.valueOf("NULL");
@ -497,9 +498,9 @@ public class StateFilterProfileTest {
ContactItem contactItem = new ContactItem("contactItem");
ContactItem contactItem2 = new ContactItem("contactItem2");
QuantityType q_1500W = QuantityType.valueOf("1500 W");
QuantityType q_1_5kW = QuantityType.valueOf("1.5 kW");
QuantityType q_10kW = QuantityType.valueOf("10 kW");
QuantityType<?> q_1500W = QuantityType.valueOf("1500 W");
QuantityType<?> q_1_5kW = QuantityType.valueOf("1.5 kW");
QuantityType<?> q_10kW = QuantityType.valueOf("10 kW");
DecimalType d_1500 = DecimalType.valueOf("1500");
DecimalType d_2000 = DecimalType.valueOf("2000");
@ -575,7 +576,7 @@ public class StateFilterProfileTest {
StringItem stringItem = new StringItem("ItemName");
DimmerItem dimmerItem = new DimmerItem("ItemName");
QuantityType q_1500W = QuantityType.valueOf("1500 W");
QuantityType<?> q_1500W = QuantityType.valueOf("1500 W");
DecimalType d_1500 = DecimalType.valueOf("1500");
StringType s_foo = StringType.valueOf("foo");
@ -664,7 +665,6 @@ public class StateFilterProfileTest {
profile.onStateUpdateFromHandler(inputState);
reset(mockCallback);
when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink);
item.setState(state);
profile.onStateUpdateFromHandler(inputState);
@ -721,8 +721,8 @@ public class StateFilterProfileTest {
Arguments.of(decimalItem, "$DELTA_PERCENT < 10", decimals, DecimalType.valueOf("0.91"), true), //
Arguments.of(decimalItem, "$DELTA_PERCENT < 10", decimals, DecimalType.valueOf("0.89"), false), //
Arguments.of(decimalItem, "$DELTA_PERCENT < 10", negativeDecimals, DecimalType.valueOf("0"), false),
Arguments.of(decimalItem, "10 > $DELTA_PERCENT", negativeDecimals, DecimalType.valueOf("0"), false),
Arguments.of(decimalItem, "$DELTA_PERCENT < 10", negativeDecimals, DecimalType.valueOf("0"), false), //
Arguments.of(decimalItem, "10 > $DELTA_PERCENT", negativeDecimals, DecimalType.valueOf("0"), false), //
Arguments.of(decimalItem, "< 10%", decimals, DecimalType.valueOf("1.09"), true), //
Arguments.of(decimalItem, "< 10%", decimals, DecimalType.valueOf("1.11"), false), //
@ -868,7 +868,6 @@ public class StateFilterProfileTest {
}
reset(mockCallback);
when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink);
profile.onStateUpdateFromHandler(input);
verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(input);
@ -899,4 +898,178 @@ public class StateFilterProfileTest {
profile.onStateUpdateFromHandler(DecimalType.valueOf("1"));
verify(mockCallback, times(1)).sendUpdate(DecimalType.valueOf("1"));
}
public static Stream<Arguments> testMixedStates() {
NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER);
List<State> states = List.of( //
UnDefType.UNDEF, //
QuantityType.valueOf(99, SIUnits.METRE), //
QuantityType.valueOf(1, Units.WATT), //
DecimalType.valueOf("2"), //
QuantityType.valueOf(2000, MetricPrefix.MILLI(Units.WATT)), //
QuantityType.valueOf(3, Units.WATT)); //
return Stream.of(
// average function (true)
Arguments.of(powerItem, "== $AVG", states, QuantityType.valueOf("2 W"), true),
Arguments.of(powerItem, "== $AVG", states, QuantityType.valueOf("2000 mW"), true),
Arguments.of(powerItem, "== $AVERAGE", states, QuantityType.valueOf("0.002 kW"), true),
Arguments.of(powerItem, "> $AVERAGE", states, QuantityType.valueOf("3 W"), true),
// average function (false)
Arguments.of(powerItem, "> $AVERAGE", states, QuantityType.valueOf("2 W"), false),
Arguments.of(powerItem, "== $AVERAGE", states, DecimalType.valueOf("2"), false),
// min function (true)
Arguments.of(powerItem, "== $MIN", states, QuantityType.valueOf("1 W"), true),
Arguments.of(powerItem, "== $MIN", states, QuantityType.valueOf("1000 mW"), true),
// min function (false)
Arguments.of(powerItem, "== $MIN", states, DecimalType.valueOf("1"), false),
// max function (true)
Arguments.of(powerItem, "== $MAX", states, QuantityType.valueOf("3 W"), true),
Arguments.of(powerItem, "== $MAX", states, QuantityType.valueOf("0.003 kW"), true),
// max function (false)
Arguments.of(powerItem, "== $MAX", states, DecimalType.valueOf("1"), false),
// delta function (true)
Arguments.of(powerItem, "$DELTA <= 1 W", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "$DELTA > 0.5 W", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "$DELTA > 0.0005 kW", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "0.5 W < $DELTA", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "500 mW < $DELTA", states, QuantityType.valueOf("4 W"), true),
// delta function (false)
Arguments.of(powerItem, "$DELTA > 0.5 W", states, QuantityType.valueOf("3.4 W"), false),
Arguments.of(powerItem, "$DELTA > 0.5", states, QuantityType.valueOf("4 W"), false),
// delta percent function (true)
Arguments.of(powerItem, "$DELTA_PERCENT > 30", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "30 < $DELTA_PERCENT", states, QuantityType.valueOf("4 W"), true),
// delta percent function (false)
Arguments.of(powerItem, "$DELTA_PERCENT > 310", states, QuantityType.valueOf("4 W"), false),
Arguments.of(powerItem, "310 < $DELTA_PERCENT", states, QuantityType.valueOf("4 W"), false),
// unit based comparisons (true)
Arguments.of(powerItem, "> 0.5 W", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "> 500 mW", states, QuantityType.valueOf("4 W"), true),
Arguments.of(powerItem, "> 0.0005 kW", states, QuantityType.valueOf("4 W"), true),
// unit based comparisons (false)
Arguments.of(powerItem, "> 0.5 W", states, QuantityType.valueOf("0.4 W"), false),
Arguments.of(powerItem, "> 500 mW", states, QuantityType.valueOf("0.4 W"), false),
Arguments.of(powerItem, "> 0.0005 kW", states, QuantityType.valueOf("0.4 W"), false),
// percent comparisons (true)
Arguments.of(powerItem, "> 30 %", states, QuantityType.valueOf("4 W"), true),
// percent comparisons (false)
Arguments.of(powerItem, "> 310 %", states, QuantityType.valueOf("4 W"), false) //
);
}
@ParameterizedTest
@MethodSource
public void testMixedStates(Item item, String condition, List<State> states, State input, boolean expected)
throws ItemNotFoundException {
internalTestFunctions(item, condition, states, input, expected);
}
/**
* A {@link UnitProvider} that provides Units.MIRED
*/
protected static class MirekUnitProvider implements UnitProvider {
@SuppressWarnings("unchecked")
@Override
public <T extends Quantity<T>> Unit<T> getUnit(Class<T> dimension) throws IllegalArgumentException {
return (Unit<T>) Units.MIRED;
}
@Override
public SystemOfUnits getMeasurementSystem() {
return SIUnits.getInstance();
}
@Override
public Collection<Class<? extends Quantity<?>>> getAllDimensions() {
return Set.of();
}
}
public static Stream<Arguments> testColorTemperatureValues() {
NumberItem kelvinItem = new NumberItem("Number:Temperature", "kelvinItem", UNIT_PROVIDER);
NumberItem mirekItem = new NumberItem("Number:Temperature", "mirekItem", new MirekUnitProvider());
List<State> states = List.of( //
QuantityType.valueOf(500, Units.MIRED), //
QuantityType.valueOf(2000 + (1 * 100), Units.KELVIN), //
QuantityType.valueOf(1726.85 + (2 * 100), SIUnits.CELSIUS), //
QuantityType.valueOf(3140.33 + (3 * 180), ImperialUnits.FAHRENHEIT));
return Stream.of( //
// kelvin based item
Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf("2000 K"), true),
Arguments.of(kelvinItem, "== $MAX", states, QuantityType.valueOf("2300 K"), true),
Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf(500, Units.MIRED), true),
Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf(1726.85, SIUnits.CELSIUS), true),
Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf(3140.33, ImperialUnits.FAHRENHEIT),
true),
// kelvin based item average (note: actual is 2150)
Arguments.of(kelvinItem, "<= $AVG", states, QuantityType.valueOf("2149 K"), true),
Arguments.of(kelvinItem, ">= $AVG", states, QuantityType.valueOf("2151 K"), true),
// mirek based item (note: min and max are reversed
Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf("2000 K"), true),
Arguments.of(mirekItem, "== $MIN", states, QuantityType.valueOf("2300 K"), true),
Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf(500, Units.MIRED), true),
Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf(1726.85, SIUnits.CELSIUS), true),
Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf(3140.33, ImperialUnits.FAHRENHEIT),
true),
// mirek based item average (note: actual is 466.37)
Arguments.of(mirekItem, "<= $AVG", states, QuantityType.valueOf(466, Units.MIRED), true),
Arguments.of(mirekItem, ">= $AVG", states, QuantityType.valueOf(468, Units.MIRED), true) //
);
}
@ParameterizedTest
@MethodSource
public void testColorTemperatureValues(Item item, String condition, List<State> states, State input,
boolean expected) throws ItemNotFoundException {
internalTestFunctions(item, condition, states, input, expected);
}
public static Stream<Arguments> testTimeValues() {
NumberItem timeItem = new NumberItem("Number:Time", "timeItem", UNIT_PROVIDER);
QuantityType<Time> microSec = QuantityType.valueOf(1, MetricPrefix.MICRO(Units.SECOND));
QuantityType<Time> milliSec = QuantityType.valueOf(1, MetricPrefix.MILLI(Units.SECOND));
QuantityType<Time> second = QuantityType.valueOf(1000, MetricPrefix.MILLI(Units.SECOND));
QuantityType<Time> minute = QuantityType.valueOf(60000, MetricPrefix.MILLI(Units.SECOND));
QuantityType<Time> hour = QuantityType.valueOf(3600000, MetricPrefix.MILLI(Units.SECOND));
return Stream.of( //
Arguments.of(timeItem, "== $MIN", List.of(second, minute), QuantityType.valueOf("1 s"), true),
Arguments.of(timeItem, "== $MAX", List.of(second, minute), QuantityType.valueOf("1 min"), true), //
Arguments.of(timeItem, "== $MIN", List.of(microSec, milliSec), QuantityType.valueOf("1 µs"), true),
Arguments.of(timeItem, "== $MAX", List.of(microSec, milliSec), QuantityType.valueOf("1 ms"), true), //
Arguments.of(timeItem, "== $MIN", List.of(microSec, hour), QuantityType.valueOf("1 µs"), true),
Arguments.of(timeItem, "== $MAX", List.of(microSec, hour), QuantityType.valueOf("1 h"), true) //
);
}
@ParameterizedTest
@MethodSource
public void testTimeValues(Item item, String condition, List<State> states, State input, boolean expected)
throws ItemNotFoundException {
internalTestFunctions(item, condition, states, input, expected);
}
}