diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRange.java b/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRange.java index 5447a72e5ac..ba75e7712a3 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRange.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRange.java @@ -1,5 +1,9 @@ package ai.timefold.solver.core.api.score.stream.common; +import java.util.Collection; +import java.util.function.ToIntBiFunction; +import java.util.function.ToIntFunction; + import org.jspecify.annotations.NonNull; /** @@ -46,6 +50,26 @@ public interface ConnectedRange, Diffe */ int getMaximumOverlap(); + /** + * Get the maximum sum of a function amongst distinct ranges of overlapping values + * amongst all points contained by this {@link ConnectedRange}. + * + * @return get the maximum sum of a function amongst distinct ranges of overlapping values + * for any point contained by this {@link ConnectedRange}. + */ + int getMaximumValue(ToIntFunction functionSupplier); + + /** + * Get the maximum sum of a function amongst distinct ranges of overlapping values + * amongst all points contained by this {@link ConnectedRange}. This method allows you to use + * a function that takes all active ranges as an input. Use {@link ::getMaximumValue} if possible + * for efficiency. + * + * @return get the maximum sum of a function amongst distinct ranges of overlapping values + * for any point contained by this {@link ConnectedRange}. + */ + int getMaximumValueForDistinctRanges(ToIntBiFunction, Difference_> functionSupplier); + /** * Get the length of this {@link ConnectedRange}. * diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRangeChain.java b/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRangeChain.java index a492eee1794..7cc9c515fd1 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRangeChain.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/stream/common/ConnectedRangeChain.java @@ -1,5 +1,9 @@ package ai.timefold.solver.core.api.score.stream.common; +import java.util.Collection; +import java.util.function.ToIntBiFunction; +import java.util.function.ToIntFunction; + import org.jspecify.annotations.NonNull; /** @@ -24,4 +28,24 @@ public interface ConnectedRangeChain, */ @NonNull Iterable> getGaps(); + + /** + * Get the maximum sum of a function amongst distinct ranges of overlapping values + * amongst all points contained by this {@link ConnectedRange}. + * + * @return get the maximum sum of a function amongst distinct ranges of overlapping values + * for any point contained by this {@link ConnectedRange}. + */ + int getMaximumValue(ToIntFunction functionSupplier); + + /** + * Get the maximum sum of a function amongst distinct ranges of overlapping values + * amongst all points contained by this {@link ConnectedRange}. This method allows you to use + * a function that takes all active ranges as an input. Use {@link ::getMaximumValue} if possible + * for efficiency. + * + * @return get the maximum sum of a function amongst distinct ranges of overlapping values + * for any point contained by this {@link ConnectedRange}. + */ + int getMaximumValueForDistinctRanges(ToIntBiFunction, Difference_> functionSupplier); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeChainImpl.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeChainImpl.java index fa494084c86..a3dcbcb6d53 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeChainImpl.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeChainImpl.java @@ -1,10 +1,13 @@ package ai.timefold.solver.core.impl.score.stream.collector.connected_ranges; +import java.util.Collection; import java.util.NavigableMap; import java.util.NavigableSet; import java.util.Objects; import java.util.TreeMap; import java.util.function.BiFunction; +import java.util.function.ToIntBiFunction; +import java.util.function.ToIntFunction; import ai.timefold.solver.core.api.score.stream.common.ConnectedRange; import ai.timefold.solver.core.api.score.stream.common.ConnectedRangeChain; @@ -251,6 +254,38 @@ void removeRange(Range range) { return (Iterable) startSplitPointToNextGap.values(); } + /** + * Get the maximum sum of a function amongst distinct ranges of overlapping values + * amongst all points contained by this {@link ConnectedRangeChain}. + * + * @return get the maximum sum of a function amongst distinct ranges of overlapping values + * for any point contained by this {@link ConnectedRangeChain}. + */ + public int getMaximumValue(ToIntFunction functionSupplier) { + var max = 0; + for (ConnectedRange range : getConnectedRanges()) { + max = Math.max(range.getMaximumValue(functionSupplier), max); + } + return max; + } + + /** + * Get the maximum sum of a function amongst distinct ranges of overlapping values + * amongst all points contained by this {@link ConnectedRangeChain}. This method allows you to use + * a function that takes all active ranges as an input. Use {@link ::getMaximumValue} if possible + * for efficiency. + * + * @return get the maximum sum of a function amongst distinct ranges of overlapping values + * for any point contained by this {@link ConnectedRangeChain}. + */ + public int getMaximumValueForDistinctRanges(ToIntBiFunction, Difference_> functionSupplier) { + var max = 0; + for (ConnectedRange range : getConnectedRanges()) { + max = Math.max(range.getMaximumValueForDistinctRanges(functionSupplier), max); + } + return max; + } + @Override public boolean equals(Object o) { if (this == o) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeImpl.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeImpl.java index 57e316f502a..9f7e5a5d06b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeImpl.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeImpl.java @@ -1,9 +1,14 @@ package ai.timefold.solver.core.impl.score.stream.collector.connected_ranges; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.Iterator; import java.util.NavigableSet; import java.util.Objects; import java.util.function.BiFunction; +import java.util.function.ToIntBiFunction; +import java.util.function.ToIntFunction; import ai.timefold.solver.core.api.score.stream.common.ConnectedRange; @@ -134,6 +139,43 @@ public int getMaximumOverlap() { return maximumOverlap; } + @Override + public int getMaximumValue(ToIntFunction functionSupplier) { + var current = startSplitPoint; + var activeRangeCount = 0; + var maxValue = 0; + var activeValue = 0; + do { + activeRangeCount += current.rangesStartingAtSplitPointSet.size() - current.rangesEndingAtSplitPointSet.size(); + activeValue += current.rangesStartingAtSplitPointSet.stream().map(Range::getValue).mapToInt(functionSupplier).sum(); + activeValue -= current.rangesEndingAtSplitPointSet.stream().map(Range::getValue).mapToInt(functionSupplier).sum(); + maxValue = Math.max(maxValue, activeValue); + current = splitPointSet.higher(current); + } while (activeRangeCount > 0 && current != null); + return maxValue; + } + + @Override + public int getMaximumValueForDistinctRanges(ToIntBiFunction, Difference_> functionSupplier) { + var current = startSplitPoint; + var activeValue = 0; + var activeRanges = new ArrayList<>(); + var next = splitPointSet.higher(current); + while (next != null) { + activeRanges.addAll(current.rangesStartingAtSplitPointSet); + activeRanges.removeAll(current.rangesEndingAtSplitPointSet); + activeValue = Math.max( + functionSupplier.applyAsInt( + Collections.unmodifiableList(activeRanges), + differenceFunction.apply(current.splitPoint, next.splitPoint)), + activeValue); + current = next; + next = splitPointSet.higher(current); + } + ; + return activeValue; + } + @Override public @NonNull Point_ getStart() { return startSplitPoint.splitPoint; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeTrackerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeTrackerTest.java index 8c9ebd8a549..98c1933b544 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeTrackerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/collector/connected_ranges/ConnectedRangeTrackerTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -67,6 +68,10 @@ private ConnectedRangeTracker getIntegerConnectedRa return new ConnectedRangeTracker<>(TestRange::getStart, TestRange::getEnd, (a, b) -> b - a); } + static int rangeMaxFunction(Collection ranges, int length) { + return 100 * ranges.size() / length; + } + @Test void testNonConsecutiveRanges() { ConnectedRangeTracker tree = getIntegerConnectedRangeTracker(); @@ -96,6 +101,10 @@ void testNonConsecutiveRanges() { assertThat(connectedRangeList.get(2).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRangeList.get(2).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRangeList.get(2).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRangeList.get(2).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(50); + verifyGaps(tree); } @@ -116,6 +125,14 @@ void testConsecutiveRanges() { assertThat(connectedRangeList.get(0)).containsExactly(new TestRange(0, 2), new TestRange(2, 4), new TestRange(4, 7)); assertThat(connectedRangeList.get(0).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRangeList.get(0).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRangeList.get(0).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRangeList.get(0).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(50); + + assertThat(tree.getConnectedRangeChain().getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(tree.getConnectedRangeChain().getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(50); + verifyGaps(tree); } @@ -135,9 +152,20 @@ void testDuplicateRanges() { assertThat(connectedRangeList.get(0)).containsExactly(a.getValue(), a.getValue()); assertThat(connectedRangeList.get(0).getMinimumOverlap()).isEqualTo(2); assertThat(connectedRangeList.get(0).getMaximumOverlap()).isEqualTo(2); + assertThat(connectedRangeList.get(0).getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(connectedRangeList.get(0).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); assertThat(connectedRangeList.get(1)).containsExactly(b.getValue()); assertThat(connectedRangeList.get(1).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRangeList.get(1).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRangeList.get(1).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRangeList.get(1).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(33); + + assertThat(tree.getConnectedRangeChain().getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(tree.getConnectedRangeChain().getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); + verifyGaps(tree); } @@ -221,16 +249,29 @@ void testOverlappingRange() { assertThat(connectedRanges.get(0).hasOverlap()).isTrue(); assertThat(connectedRanges.get(0).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(0).getMaximumOverlap()).isEqualTo(2); + assertThat(connectedRanges.get(0).getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(connectedRanges.get(0).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(200); assertThat(connectedRanges.get(1)).containsExactly(d.getValue()); assertThat(connectedRanges.get(1).hasOverlap()).isFalse(); assertThat(connectedRanges.get(1).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(1).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRanges.get(1).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRanges.get(1).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); assertThat(connectedRanges.get(2)).containsExactly(e.getValue(), removedTestRange2); assertThat(connectedRanges.get(2).hasOverlap()).isTrue(); assertThat(connectedRanges.get(2).getMinimumOverlap()).isEqualTo(2); assertThat(connectedRanges.get(2).getMaximumOverlap()).isEqualTo(2); + assertThat(connectedRanges.get(2).getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(connectedRanges.get(2).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); + + assertThat(tree.getConnectedRangeChain().getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(tree.getConnectedRangeChain().getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(200); verifyGaps(tree); @@ -247,16 +288,29 @@ void testOverlappingRange() { assertThat(connectedRanges.get(0).hasOverlap()).isFalse(); assertThat(connectedRanges.get(0).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(0).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRanges.get(0).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRanges.get(0).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); assertThat(connectedRanges.get(1)).containsExactly(d.getValue()); assertThat(connectedRanges.get(1).hasOverlap()).isFalse(); assertThat(connectedRanges.get(1).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(1).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRanges.get(1).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRanges.get(1).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); assertThat(connectedRanges.get(2)).containsExactly(e.getValue(), removedTestRange2); assertThat(connectedRanges.get(2).hasOverlap()).isTrue(); assertThat(connectedRanges.get(2).getMinimumOverlap()).isEqualTo(2); assertThat(connectedRanges.get(2).getMaximumOverlap()).isEqualTo(2); + assertThat(connectedRanges.get(2).getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(connectedRanges.get(2).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); + + assertThat(tree.getConnectedRangeChain().getMaximumValue(i -> 1)).isEqualTo(2); + assertThat(tree.getConnectedRangeChain().getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); verifyGaps(tree); @@ -272,16 +326,29 @@ void testOverlappingRange() { assertThat(connectedRanges.get(0).hasOverlap()).isFalse(); assertThat(connectedRanges.get(0).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(0).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRanges.get(0).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRanges.get(0).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); assertThat(connectedRanges.get(1)).containsExactly(d.getValue()); assertThat(connectedRanges.get(1).hasOverlap()).isFalse(); assertThat(connectedRanges.get(1).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(1).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRanges.get(1).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRanges.get(1).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); assertThat(connectedRanges.get(2)).containsExactly(e.getValue()); assertThat(connectedRanges.get(2).hasOverlap()).isFalse(); assertThat(connectedRanges.get(2).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(2).getMaximumOverlap()).isEqualTo(1); + assertThat(connectedRanges.get(2).getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(connectedRanges.get(2).getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(50); + + assertThat(tree.getConnectedRangeChain().getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(tree.getConnectedRangeChain().getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); verifyGaps(tree); Range g = tree.getRange(new TestRange(6, 7)); @@ -298,6 +365,10 @@ void testOverlappingRange() { assertThat(connectedRanges.get(1).hasOverlap()).isFalse(); assertThat(connectedRanges.get(1).getMinimumOverlap()).isEqualTo(1); assertThat(connectedRanges.get(1).getMaximumOverlap()).isEqualTo(1); + + assertThat(tree.getConnectedRangeChain().getMaximumValue(i -> 1)).isEqualTo(1); + assertThat(tree.getConnectedRangeChain().getMaximumValueForDistinctRanges(ConnectedRangeTrackerTest::rangeMaxFunction)) + .isEqualTo(100); } void verifyGaps(ConnectedRangeTracker tree) { diff --git a/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc b/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc index 25c20b46ab3..42e5ec9cddc 100644 --- a/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc +++ b/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc @@ -1066,6 +1066,37 @@ Java:: ---- ==== +In the event that data about distinct ranges of overlapping values is needed beyond the total count, `ConnectedRange` provides methods to calculate a max sum +of an integer function over each distinct arrangement of overlapping entities: + +[tabs] +==== +Java:: ++ +[source,java,options="nowrap"] +---- +.groupBy(Job::getEquipment, + ConstraintCollectors.toConnectedRanges( + Job::getStart, + Job::getEnd + ) +).expand( + connectedRangeChain -> connectedRangeChain.getMaximumValueForDistinctRanges( + (activeJobList, duration) -> { + var required = activeJobList.stream().mapToInt(Job::getCapacityRequired).sum() + var capacity = equipment.getCapacity() - (activeJobList.size() - 1); // Lose a little capacity per additional job + return Math.max(0, capacity - required); + } + ) +) +.filter((connectedRangeChain, amountOverCapacity) -> amountOverCapacity > 0) +.penalize((connectedRangeChain, amountOverCapacity) -> amountOverCapacity) +) +) +---- +==== + + [#collectorsLoadBalance] ==== Load balancing collectors