Skip to content

Commit

Permalink
Add MEDIAN group function (#4344)
Browse files Browse the repository at this point in the history
* Add MEDIAN group function

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
  • Loading branch information
jimtng authored Aug 24, 2024
1 parent e8e5544 commit 8d54cce
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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<Item> items) {
if (items != null) {
List<BigDecimal> 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 extends State> T getStateAs(@Nullable Set<Item> items, Class<T> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -111,6 +115,53 @@ public State calculate(@Nullable Set<Item> items) {
}
}

/**
* This calculates the numeric median over all item states of {@link QuantityType}.
*/
class Median extends DimensionalGroupFunction {

private @Nullable Item baseItem;

public Median(Class<? extends Quantity<?>> dimension, @Nullable Item baseItem) {
super(dimension);
this.baseItem = baseItem;
}

@Override
@SuppressWarnings({ "unchecked", "rawtypes" })
public State calculate(@Nullable Set<Item> items) {
if (items != null) {
List<BigDecimal> 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}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -215,6 +222,44 @@ public void testAvgFunction() {
assertThat(((DecimalType) state).doubleValue(), is(closeTo(78.32, 0.01d)));
}

static Stream<Arguments> 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<State> states, State expected) {
AtomicInteger index = new AtomicInteger(1);
Set<Item> 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<Item> items = new HashSet<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -177,6 +185,51 @@ public void testAvgFunctionQuantityTypeIncompatibleUnits(Locale locale) {
assertEquals(new QuantityType<>("23.54 °C"), state);
}

static Stream<Arguments> 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<State> states, State expected) {
AtomicInteger index = new AtomicInteger(1);
Set<Item> 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) {
Expand Down

0 comments on commit 8d54cce

Please sign in to comment.