From 08a12742c762097ed4027cc74affd3b3503f123b Mon Sep 17 00:00:00 2001 From: Todd Date: Fri, 27 Dec 2024 08:50:27 -0500 Subject: [PATCH] Implement movingSum, movingSumBy, movingProduct, and movingProductBy --- CHANGELOG.md | 2 + README.md | 4 + .../BigDecimalMovingProductGatherer.java | 93 +++++++++ .../BigDecimalMovingSumGatherer.java | 88 +++++++++ .../com/ginsberg/gatherers4j/Gatherers4j.java | 35 +++- .../BigDecimalMovingProductGathererTest.java | 185 ++++++++++++++++++ .../BigDecimalMovingSumGathererTest.java | 185 ++++++++++++++++++ 7 files changed, 583 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGatherer.java create mode 100644 src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGatherer.java create mode 100644 src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGathererTest.java create mode 100644 src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGathererTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b7be962..f0753f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ + Use greedy integrators where possible (Fixes #57) + Add [JSpecify](https://jspecify.dev/) annotations for static analysis + Implement `orderByFrequencyAscending()` and `orderByFrequencyDescending()` ++ Implement `movingProduct()` and `movingProductBy()` ++ Implement `movingSum()` and `movingSumBy()` ### 0.6.0 + Implement `dropLast(n)` diff --git a/README.md b/README.md index 77feea1..dc7e5b2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ implementation("com.ginsberg:gatherers4j:0.7.0") | Function | Purpose | |--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| `movingProduct(window)` | Create a moving product of `BigDecimal` values over the previous `window` values. | +| `movingProductBy(fn, window)` | Create a moving product of `BigDecimal` values over the previous `window` values, as mapped via `fn`. | +| `movingSum(window)` | Create a moving sum of `BigDecimal` values over the previous `window` values. | +| `movingSumBy(fn, window)` | Create a moving sum of `BigDecimal` values over the previous `window` values, as mapped via `fn`. | | `runningPopulationStandardDeviation()` | Create a stream of `BigDecimal` objects representing the running population standard deviation. | | `runningPopulationStandardDeviationBy(fn)` | Create a stream of `BigDecimal` objects as mapped from the input via `fn`, representing the running population standard deviation. | | `runningProduct()` | Create a stream of `BigDecimal` objects representing the running product. | | diff --git a/src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGatherer.java b/src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGatherer.java new file mode 100644 index 0000000..883383a --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGatherer.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Todd Ginsberg + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ginsberg.gatherers4j; + +import org.jspecify.annotations.Nullable; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.util.Arrays; +import java.util.function.Function; +import java.util.function.Supplier; + +public class BigDecimalMovingProductGatherer + extends BigDecimalGatherer { + + private final int windowSize; + private boolean includePartialValues = false; + + BigDecimalMovingProductGatherer( + final Function mappingFunction, + final int windowSize) { + super(mappingFunction); + if (windowSize <= 0) { + throw new IllegalArgumentException("Window size must be positive"); + } + this.windowSize = windowSize; + } + + @Override + public Supplier initializer() { + return () -> new BigDecimalMovingProductGatherer.State(windowSize, includePartialValues); + } + + /// When creating a moving product and the full size of the window has not yet been reached, the + /// gatherer should emit the product for what it has. + /// + /// For example, if the trailing product is over 10 values, but the stream has only emitted two + /// values, the gatherer should calculate the two values and emit the answer. The default is to not + /// emit anything until the full size of the window has been seen. + public BigDecimalMovingProductGatherer includePartialValues() { + includePartialValues = true; + return this; + } + + /// When encountering a `null` value in a stream, treat it as `BigDecimal.ONE` instead. + public BigDecimalGatherer treatNullAsOne() { + return treatNullAs(BigDecimal.ONE); + } + + static class State implements BigDecimalGatherer.State { + final boolean includePartialValues; + final BigDecimal[] series; + BigDecimal product = BigDecimal.ONE; + int index = 0; + + private State(final int lookBack, final boolean includePartialValues) { + this.includePartialValues = includePartialValues; + this.series = new BigDecimal[lookBack]; + Arrays.fill(series, BigDecimal.ONE); + } + + @Override + public boolean canCalculate() { + return includePartialValues || index >= series.length; + } + + @Override + public void add(final BigDecimal element, final MathContext mathContext) { + product = product.divide(series[index % series.length], mathContext).multiply(element, mathContext); + series[index % series.length] = element; + index++; + } + + @Override + public BigDecimal calculate() { + return product; + } + } +} diff --git a/src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGatherer.java b/src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGatherer.java new file mode 100644 index 0000000..60f4e93 --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGatherer.java @@ -0,0 +1,88 @@ +/* + * Copyright 2024 Todd Ginsberg + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ginsberg.gatherers4j; + +import org.jspecify.annotations.Nullable; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.util.Arrays; +import java.util.function.Function; +import java.util.function.Supplier; + +public class BigDecimalMovingSumGatherer + extends BigDecimalGatherer { + + private final int windowSize; + private boolean includePartialValues = false; + + BigDecimalMovingSumGatherer( + final Function mappingFunction, + final int windowSize) { + super(mappingFunction); + if (windowSize <= 0) { + throw new IllegalArgumentException("Window size must be positive"); + } + this.windowSize = windowSize; + } + + @Override + public Supplier initializer() { + return () -> new BigDecimalMovingSumGatherer.State(windowSize, includePartialValues); + } + + /// When creating a moving sum and the full size of the window has not yet been reached, the + /// gatherer should emit the sum of what it has. + /// + /// For example, if the trailing sum is over 10 values, but the stream has only emitted two + /// values, the gatherer should calculate the two values and emit the answer. The default is to not + /// emit anything until the full size of the window has been seen. + public BigDecimalMovingSumGatherer includePartialValues() { + includePartialValues = true; + return this; + } + + static class State implements BigDecimalGatherer.State { + final boolean includePartialValues; + final BigDecimal[] series; + BigDecimal sum = BigDecimal.ZERO; + int index = 0; + + private State(final int lookBack, final boolean includePartialValues) { + this.includePartialValues = includePartialValues; + this.series = new BigDecimal[lookBack]; + Arrays.fill(series, BigDecimal.ZERO); + } + + @Override + public boolean canCalculate() { + return includePartialValues || index >= series.length; + } + + @Override + public void add(final BigDecimal element, final MathContext mathContext) { + sum = sum.subtract(series[index % series.length]).add(element, mathContext); + series[index % series.length] = element; + index++; + } + + @Override + public BigDecimal calculate() { + return sum; + } + } +} diff --git a/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java b/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java index b8c869b..328962d 100644 --- a/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java +++ b/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java @@ -94,7 +94,7 @@ public static SizeGatherer exactSize(final long size) { /// and its index. /// /// @param predicate A non-null `BiPredicate` where the `Long` is the zero-based index of the element - /// being filtered, and the `INPUT` is the element itself. + /// being filtered, and the `INPUT` is the element itself. /// @param Type of elements in the input stream /// @return A non-null `FilteringWithIndexGatherer` public static FilteringWithIndexGatherer filterWithIndex( @@ -188,6 +188,23 @@ public static > MinMaxGatherer(false, mappingFunction); } + public static BigDecimalMovingProductGatherer movingProduct(int windowSize) { + return movingProductBy(Function.identity(), windowSize); + } + + public static BigDecimalMovingProductGatherer movingProductBy(Function mappingFunction, int windowSize) { + return new BigDecimalMovingProductGatherer<>(mappingFunction, windowSize); + } + + public static BigDecimalMovingSumGatherer movingSum(int windowSize) { + return movingSumBy(Function.identity(), windowSize); + } + + public static BigDecimalMovingSumGatherer movingSumBy(Function mappingFunction, int windowSize) { + return new BigDecimalMovingSumGatherer<>(mappingFunction, windowSize); + } + + /// Emit elements in the input stream ordered by frequency from least frequently occurring /// to most frequently occurring. Elements are emitted wrapped in `WithCount` objects @@ -200,8 +217,8 @@ public static > MinMaxGatherer FrequencyGatherer orderByFrequencyAscending() { /// .toList(); /// /// // Produces: - /// [WithCount("A", 4), WithCount("B", 2), WithCount("C", 1)] - /// ``` + ///[WithCount("A", 4), WithCount("B", 2), WithCount("C", 1)] + ///``` /// /// Note: This consumes the entire stream and holds it in memory, so it will not work on infinite /// streams and may cause memory pressure on very large streams. @@ -261,7 +278,7 @@ public static BigDecimalStandardDeviationGatherer runningPopulationS /// objects mapped from a `Stream` via a `mappingFunction`. /// /// @param mappingFunction A function to map `` objects to `BigDecimal`, the results of which will be used - /// in the standard deviation calculation + /// in the standard deviation calculation /// @param Type of elements in the input stream, to be remapped to `BigDecimal` by the `mappingFunction` /// @return A non-null `BigDecimalStandardDeviationGatherer` public static BigDecimalStandardDeviationGatherer runningPopulationStandardDeviationBy( @@ -284,7 +301,7 @@ public static BigDecimalProductGatherer runningProduct() { /// from a `Stream` via a `mappingFunction`. /// /// @param mappingFunction A function to map `` objects to `BigDecimal`, the results of which will be used - /// in the product calculation + /// in the product calculation /// @param Type of elements in the input stream, to be remapped to `BigDecimal` by the `mappingFunction` /// @return A non-null `BigDecimalProductGatherer` public static BigDecimalProductGatherer runningProductBy( @@ -307,7 +324,7 @@ public static BigDecimalStandardDeviationGatherer runningSampleStand /// from a `Stream` via a `mappingFunction`. /// /// @param mappingFunction A function to map `` objects to `BigDecimal`, the results of which will be used - /// in the standard deviation calculation + /// in the standard deviation calculation /// @param Type of elements in the input stream, to be remapped to `BigDecimal` by the `mappingFunction` /// @return A non-null `BigDecimalStandardDeviationGatherer` public static BigDecimalStandardDeviationGatherer runningSampleStandardDeviationBy( @@ -330,7 +347,7 @@ public static BigDecimalSumGatherer runningSum() { /// from a `Stream` via a `mappingFunction`. /// /// @param mappingFunction A function to map `` objects to `BigDecimal`, the results of which will be used - /// in the running sum calculation + /// in the running sum calculation /// @param Type of elements in the input stream, to be remapped to `BigDecimal` by the `mappingFunction` /// @return A non-null `BigDecimalSumGatherer` public static BigDecimalSumGatherer runningSumBy( diff --git a/src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGathererTest.java b/src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGathererTest.java new file mode 100644 index 0000000..975fe88 --- /dev/null +++ b/src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingProductGathererTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2024 Todd Ginsberg + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ginsberg.gatherers4j; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Stream; + +import static com.ginsberg.gatherers4j.TestUtils.BIG_DECIMAL_RECURSIVE_COMPARISON; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BigDecimalMovingProductGathererTest { + + @Test + void ignoresNulls() { + // Arrange + final Stream input = Stream.of(null, BigDecimal.TWO, BigDecimal.TWO, BigDecimal.TEN); + + // Act + final List output = input + .gather(Gatherers4j.movingProduct(2)) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("4"), + new BigDecimal("20") + ); + } + + @Test + @SuppressWarnings("DataFlowIssue") + void mathContextCannotBeNull() { + assertThatThrownBy(() -> + Stream.of(BigDecimal.ONE).gather(Gatherers4j.movingProduct(2).withMathContext(null)) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + + @Test + void movingProduct() { + // Arrange + final Stream input = Stream.of("1", "2", "3", "4").map(BigDecimal::new); + + // Act + final List output = input + .gather(Gatherers4j.movingProduct(2)) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("2"), + new BigDecimal("6"), + new BigDecimal("12") + ); + } + + @Test + void movingProductBy() { + // Arrange + final List input = List.of( + new TestValueHolder(1, new BigDecimal("1")), + new TestValueHolder(2, new BigDecimal("2")), + new TestValueHolder(3, new BigDecimal("10")), + new TestValueHolder(4, new BigDecimal("20")), + new TestValueHolder(5, new BigDecimal("30")) + ); + + // Act + final List output = input.stream() + .gather(Gatherers4j.movingProductBy(TestValueHolder::value, 2)) + .toList(); + + // Assert + assertThat(output) + .usingRecursiveFieldByFieldElementComparator(BIG_DECIMAL_RECURSIVE_COMPARISON) + .containsExactly( + new BigDecimal("2"), + new BigDecimal("20"), + new BigDecimal("200"), + new BigDecimal("600") + ); + } + + @Test + void movingProductWithPartials() { + // Arrange + final Stream input = Stream.of("1", "2", "3", "4").map(BigDecimal::new); + + // Act + final List output = input + .gather(Gatherers4j.movingProduct(2).includePartialValues()) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("1"), + new BigDecimal("2"), + new BigDecimal("6"), + new BigDecimal("12") + ); + } + + @Test + void movingProductWithPartialsWithOriginal() { + // Arrange + final Stream input = Stream.of("1", "2", "3", "4").map(BigDecimal::new); + + // Act + final List> output = input + .gather(Gatherers4j.movingProduct(2) + .includePartialValues() + .withOriginal() + ) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new WithOriginal<>(new BigDecimal("1"), new BigDecimal("1")), + new WithOriginal<>(new BigDecimal("2"), new BigDecimal("2")), + new WithOriginal<>(new BigDecimal("3"), new BigDecimal("6")), + new WithOriginal<>(new BigDecimal("4"), new BigDecimal("12")) + ); + } + + @Test + void treatNullAsOne() { + // Arrange + final Stream input = Stream.of( + new BigDecimal("2"), + new BigDecimal("3"), + null, + new BigDecimal("4") + ); + + // Act + final List output = input + .gather(Gatherers4j.movingProduct(2).treatNullAsOne()) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("6"), + new BigDecimal("3"), + new BigDecimal("4") + ); + } + + @Test + void windowSizeMustBePositive() { + assertThatThrownBy(() -> + Stream.of(BigDecimal.ONE).gather(Gatherers4j.movingProduct(0)) + ).isExactlyInstanceOf(IllegalArgumentException.class); + + assertThatThrownBy(() -> + Stream.of(BigDecimal.ONE).gather(Gatherers4j.movingProduct(-1)) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGathererTest.java b/src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGathererTest.java new file mode 100644 index 0000000..d998748 --- /dev/null +++ b/src/test/java/com/ginsberg/gatherers4j/BigDecimalMovingSumGathererTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2024 Todd Ginsberg + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ginsberg.gatherers4j; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Stream; + +import static com.ginsberg.gatherers4j.TestUtils.BIG_DECIMAL_RECURSIVE_COMPARISON; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BigDecimalMovingSumGathererTest { + + + @Test + void ignoresNulls() { + // Arrange + final Stream input = Stream.of(null, BigDecimal.TWO, BigDecimal.TWO, BigDecimal.TEN); + + // Act + final List output = input + .gather(Gatherers4j.movingSum(2)) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("4"), + new BigDecimal("12") + ); + } + + @Test + @SuppressWarnings("DataFlowIssue") + void mathContextCannotBeNull() { + assertThatThrownBy(() -> + Stream.of(BigDecimal.ONE).gather(Gatherers4j.movingSum(2).withMathContext(null)) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + + @Test + void movingSum() { + // Arrange + final Stream input = Stream.of("1", "2", "3", "4").map(BigDecimal::new); + + // Act + final List output = input.gather(Gatherers4j.movingSum(2)).toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("3"), + new BigDecimal("5"), + new BigDecimal("7") + ); + } + + + @Test + void movingSumBy() { + // Arrange + final List input = List.of( + new TestValueHolder(1, new BigDecimal("1")), + new TestValueHolder(2, new BigDecimal("2")), + new TestValueHolder(3, new BigDecimal("10")), + new TestValueHolder(4, new BigDecimal("20")), + new TestValueHolder(5, new BigDecimal("30")) + ); + + // Act + final List output = input.stream() + .gather(Gatherers4j.movingSumBy(TestValueHolder::value, 2)) + .toList(); + + // Assert + assertThat(output) + .usingRecursiveFieldByFieldElementComparator(BIG_DECIMAL_RECURSIVE_COMPARISON) + .containsExactly( + new BigDecimal("3"), + new BigDecimal("12"), + new BigDecimal("30"), + new BigDecimal("50") + ); + } + + + @Test + void movingSumWithPartials() { + // Arrange + final Stream input = Stream.of("1", "2", "3", "4").map(BigDecimal::new); + + // Act + final List output = input + .gather(Gatherers4j.movingSum(2).includePartialValues()) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("1"), + new BigDecimal("3"), + new BigDecimal("5"), + new BigDecimal("7") + ); + } + + @Test + void movingSumWithPartialsWithOriginal() { + // Arrange + final Stream input = Stream.of("1", "2", "3", "4").map(BigDecimal::new); + + // Act + final List> output = input + .gather(Gatherers4j.movingSum(2) + .includePartialValues() + .withOriginal()) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new WithOriginal<>(new BigDecimal("1"), new BigDecimal("1")), + new WithOriginal<>(new BigDecimal("2"), new BigDecimal("3")), + new WithOriginal<>(new BigDecimal("3"), new BigDecimal("5")), + new WithOriginal<>(new BigDecimal("4"), new BigDecimal("7")) + ); + } + + @Test + void treatNullAsOne() { + // Arrange + final Stream input = Stream.of( + new BigDecimal("2"), + new BigDecimal("3"), + null, + new BigDecimal("4") + ); + + // Act + final List output = input + .gather(Gatherers4j.movingSum(2).treatNullAsZero()) + .toList(); + + // Assert + assertThat(output) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactly( + new BigDecimal("5"), + new BigDecimal("3"), + new BigDecimal("4") + ); + } + + @Test + void windowSizeMustBePositive() { + assertThatThrownBy(() -> + Stream.of(BigDecimal.ONE).gather(Gatherers4j.movingSum(0)) + ).isExactlyInstanceOf(IllegalArgumentException.class); + + assertThatThrownBy(() -> + Stream.of(BigDecimal.ONE).gather(Gatherers4j.movingSum(-1)) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file