Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MEDIAN group function #4344

Merged
merged 3 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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