[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
parent
3b820edcb2
commit
d0bac82f21
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue