diff --git a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/Items.xtext b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/Items.xtext index fabd011d13c..266d585cc73 100644 --- a/bundles/org.openhab.core.model.item/src/org/openhab/core/model/Items.xtext +++ b/bundles/org.openhab.core.model.item/src/org/openhab/core/model/Items.xtext @@ -27,7 +27,7 @@ ModelGroupItem: ; enum ModelGroupFunction: - EQUALITY='EQUALITY' | AND='AND' | OR='OR' | NAND='NAND' | NOR='NOR' | AVG='AVG' | SUM='SUM' | MAX='MAX' | MIN='MIN' | COUNT='COUNT' | LATEST='LATEST' | EARLIEST='EARLIEST' + EQUALITY='EQUALITY' | AND='AND' | OR='OR' | NAND='NAND' | NOR='NOR' | AVG='AVG' | MEDIAN='MEDIAN' | SUM='SUM' | MAX='MAX' | MIN='MIN' | COUNT='COUNT' | LATEST='LATEST' | EARLIEST='EARLIEST' ; ModelNormalItem: diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/GroupFunctionHelper.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/GroupFunctionHelper.java index d8caa8b30ea..0fdc4e165be 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/GroupFunctionHelper.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/GroupFunctionHelper.java @@ -91,6 +91,8 @@ private GroupFunction createDimensionGroupFunction(GroupFunctionDTO function, @N switch (functionName.toUpperCase()) { case "AVG": return new QuantityTypeArithmeticGroupFunction.Avg(dimension); + case "MEDIAN": + return new QuantityTypeArithmeticGroupFunction.Median(dimension, baseItem); case "SUM": return new QuantityTypeArithmeticGroupFunction.Sum(dimension); case "MIN": @@ -148,6 +150,8 @@ private GroupFunction createDefaultGroupFunction(GroupFunctionDTO function, @Nul break; case "AVG": return new ArithmeticGroupFunction.Avg(); + case "MEDIAN": + return new ArithmeticGroupFunction.Median(); case "SUM": return new ArithmeticGroupFunction.Sum(); case "MIN": diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/ArithmeticGroupFunction.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/ArithmeticGroupFunction.java index 25cf7ab9f0a..9abec496149 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/ArithmeticGroupFunction.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/ArithmeticGroupFunction.java @@ -14,6 +14,8 @@ import java.math.BigDecimal; import java.math.MathContext; +import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -24,6 +26,7 @@ import org.openhab.core.items.Item; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; +import org.openhab.core.util.Statistics; /** * This interface is only a container for functions that require the core type library @@ -253,6 +256,43 @@ public State[] getParameters() { } } + /** + * This calculates the numeric median over all item states of decimal type. + */ + class Median implements GroupFunction { + + public Median() { + } + + @Override + public State calculate(@Nullable Set items) { + if (items != null) { + List states = items.stream().map(item -> item.getStateAs(DecimalType.class)) + .filter(Objects::nonNull).map(DecimalType::toBigDecimal).toList(); + BigDecimal median = Statistics.median(states); + if (median != null) { + return new DecimalType(median); + } + } + return UnDefType.UNDEF; + } + + @Override + public @Nullable T getStateAs(@Nullable Set items, Class stateClass) { + State state = calculate(items); + if (stateClass.isInstance(state)) { + return stateClass.cast(state); + } else { + return null; + } + } + + @Override + public State[] getParameters() { + return new State[0]; + } + } + /** * This calculates the numeric sum over all item states of decimal type. */ diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunction.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunction.java index 5854d13968f..bede03de9dc 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunction.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunction.java @@ -14,9 +14,12 @@ import java.math.BigDecimal; import java.math.MathContext; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import javax.measure.Quantity; +import javax.measure.Unit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -26,6 +29,7 @@ import org.openhab.core.library.items.NumberItem; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; +import org.openhab.core.util.Statistics; /** * This interface is a container for dimension based functions that require {@link QuantityType}s for its calculations. @@ -111,6 +115,53 @@ public State calculate(@Nullable Set items) { } } + /** + * This calculates the numeric median over all item states of {@link QuantityType}. + */ + class Median extends DimensionalGroupFunction { + + private @Nullable Item baseItem; + + public Median(Class> dimension, @Nullable Item baseItem) { + super(dimension); + this.baseItem = baseItem; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public State calculate(@Nullable Set items) { + if (items != null) { + List values = new ArrayList<>(); + Unit unit = null; + if (baseItem instanceof NumberItem numberItem) { + unit = numberItem.getUnit(); + } + for (Item item : items) { + if (!isSameDimension(item)) { + continue; + } + QuantityType itemState = item.getStateAs(QuantityType.class); + if (itemState == null) { + continue; + } + if (unit == null) { + unit = itemState.getUnit(); // set it to the first item's unit + } + values.add(itemState.toInvertibleUnit(unit).toBigDecimal()); + } + + if (!values.isEmpty()) { + BigDecimal median = Statistics.median(values); + if (median != null) { + return new QuantityType<>(median, unit); + } + + } + } + return UnDefType.UNDEF; + } + } + /** * This calculates the numeric sum over all item states of {@link QuantityType}. */ diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/ArithmeticGroupFunctionTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/ArithmeticGroupFunctionTest.java index 6a125683216..dd15f2553e0 100644 --- a/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/ArithmeticGroupFunctionTest.java +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/ArithmeticGroupFunctionTest.java @@ -16,13 +16,20 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.number.IsCloseTo.closeTo; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.params.provider.Arguments.arguments; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.openhab.core.items.GenericItem; import org.openhab.core.items.GroupFunction; import org.openhab.core.items.Item; @@ -215,6 +222,44 @@ public void testAvgFunction() { assertThat(((DecimalType) state).doubleValue(), is(closeTo(78.32, 0.01d))); } + static Stream testMedianFunction() { + return Stream.of( // + arguments( // + List.of(new DecimalType("23.54"), UnDefType.NULL, new DecimalType("22"), UnDefType.UNDEF, + new DecimalType("122.41"), new DecimalType("89")), // + new DecimalType("56.27")), // + arguments( // + List.of(new DecimalType("23.54"), UnDefType.NULL, new DecimalType("89"), UnDefType.UNDEF, + new DecimalType("122.41")), // + new DecimalType("89.0")), // + arguments( // + List.of(new DecimalType("23.54"), UnDefType.NULL, new DecimalType("89"), UnDefType.UNDEF), // + new DecimalType("56.27")), // + arguments( // + List.of(new DecimalType("23.54")), // + new DecimalType("23.54")), // + arguments( // + List.of(), // + UnDefType.UNDEF) // + ); + } + + @ParameterizedTest + @MethodSource + public void testMedianFunction(List states, State expected) { + AtomicInteger index = new AtomicInteger(1); + Set items = states.stream().map(state -> new TestItem("TestItem" + index.getAndIncrement(), state)) + .collect(Collectors.toSet()); + + GroupFunction function = new ArithmeticGroupFunction.Median(); + State state = function.calculate(items); + + assertEquals(state.getClass(), expected.getClass()); + if (expected instanceof DecimalType expectedDecimalType) { + assertThat(((DecimalType) state).doubleValue(), is(closeTo(expectedDecimalType.doubleValue(), 0.01d))); + } + } + @Test public void testSumFunction() { Set items = new HashSet<>(); diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunctionTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunctionTest.java index 333b13f3e0d..81ac00b7b33 100644 --- a/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunctionTest.java +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/QuantityTypeArithmeticGroupFunctionTest.java @@ -12,11 +12,18 @@ */ package org.openhab.core.library.types; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.number.IsCloseTo.closeTo; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.params.provider.Arguments.arguments; import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.measure.Quantity; @@ -28,6 +35,7 @@ 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; @@ -177,6 +185,51 @@ public void testAvgFunctionQuantityTypeIncompatibleUnits(Locale locale) { assertEquals(new QuantityType<>("23.54 °C"), state); } + static Stream medianTestSource() { + return Stream.of( // + arguments( // + List.of(new QuantityType("100 °C"), UnDefType.NULL, new QuantityType("200 °C"), UnDefType.UNDEF, + new QuantityType("300 °C"), new QuantityType("400 °C")), // + new QuantityType("250 °C")), // + // mixed units. 200 °C = 392 °F + arguments( // + List.of(new QuantityType("100 °C"), UnDefType.NULL, new QuantityType("392 °F"), UnDefType.UNDEF, + new QuantityType("300 °C"), new QuantityType("400 °C")), // + new QuantityType("250 °C")), // + arguments( // + List.of(new QuantityType("100 °C"), UnDefType.NULL, new QuantityType("200 °C"), UnDefType.UNDEF, + new QuantityType("300 °C")), // + new QuantityType("200 °C")), // + arguments( // + List.of(new QuantityType("100 °C"), UnDefType.NULL, new QuantityType("200 °C")), // + new QuantityType("150 °C")), // + arguments( // + List.of(new QuantityType("100 °C"), UnDefType.NULL), // + new QuantityType("100 °C")), // + arguments( // + List.of(), // + UnDefType.UNDEF) // + ); + } + + @ParameterizedTest + @MethodSource("medianTestSource") + public void testMedianFunctionQuantityType(List states, State expected) { + AtomicInteger index = new AtomicInteger(1); + Set items = states.stream() + .map(state -> createNumberItem("TestItem" + index.getAndIncrement(), Temperature.class, state)) + .collect(Collectors.toSet()); + + GroupFunction function = new QuantityTypeArithmeticGroupFunction.Median(Temperature.class, null); + State state = function.calculate(items); + + assertEquals(state.getClass(), expected.getClass()); + if (expected instanceof QuantityType expectedQuantityType) { + QuantityType stateQuantityType = ((QuantityType) state).toInvertibleUnit(expectedQuantityType.getUnit()); + assertThat(stateQuantityType.doubleValue(), is(closeTo(expectedQuantityType.doubleValue(), 0.01d))); + } + } + @ParameterizedTest @MethodSource("locales") public void testMaxFunctionQuantityType(Locale locale) {