diff --git a/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/tree/RollingThreshold.java b/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/tree/RollingThreshold.java index dae1a69eb0..f48ba31b7d 100644 --- a/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/tree/RollingThreshold.java +++ b/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/tree/RollingThreshold.java @@ -9,6 +9,7 @@ import gov.nasa.jpl.aerie.constraints.time.Interval; import gov.nasa.jpl.aerie.constraints.time.Segment; import gov.nasa.jpl.aerie.constraints.time.Spans; +import gov.nasa.jpl.aerie.constraints.time.Windows; import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; import java.util.ArrayList; @@ -18,14 +19,24 @@ public record RollingThreshold(Expression spans, Expression width, Expression threshold, RollingThresholdAlgorithm algorithm) implements Expression { public enum RollingThresholdAlgorithm { - InputSpans, - Hull + ExcessSpans, + ExcessHull, + DeficitSpans, + DeficitHull } @Override public ConstraintResult evaluate(SimulationResults results, final Interval bounds, EvaluationEnvironment environment) { final var width = this.width.evaluate(results, bounds, environment); var spans = this.spans.evaluate(results, bounds, environment); + + final Spans reportedSpans; + if (algorithm == RollingThresholdAlgorithm.ExcessHull || algorithm == RollingThresholdAlgorithm.ExcessSpans) { + reportedSpans = spans; + } else { + reportedSpans = spans.intoWindows().not().intoSpans(bounds); + } + final var threshold = this.threshold.evaluate(results, bounds, environment); final var accDuration = spans.accumulatedDuration(threshold); @@ -33,25 +44,51 @@ public ConstraintResult evaluate(SimulationResults results, final Interval bound final var localAccDuration = shiftedBack.plus(accDuration.times(-1)); - final var leftViolatingBounds = localAccDuration.greaterThan(new LinearProfile(Segment.of(Interval.FOREVER, new LinearEquation(Duration.ZERO, 1, 0)))); - + final Windows leftViolatingBounds; final var violations = new ArrayList(); - for (final var leftViolatingBound: leftViolatingBounds.iterateEqualTo(true)) { - final var expandedInterval = Interval.between(leftViolatingBound.start, leftViolatingBound.startInclusivity, leftViolatingBound.end.plus(width), leftViolatingBound.endInclusivity); + + final var thresholdEq = new LinearProfile(Segment.of( + Interval.FOREVER, + new LinearEquation( + Duration.ZERO, + 1, + 0 + ) + )); + + if (algorithm == RollingThresholdAlgorithm.ExcessHull || algorithm == RollingThresholdAlgorithm.ExcessSpans) { + leftViolatingBounds = localAccDuration.greaterThan(thresholdEq); + } else { + leftViolatingBounds = localAccDuration.lessThan(thresholdEq).select( + Interval.between( + bounds.start, + bounds.startInclusivity, + bounds.end.minus(width), + bounds.endInclusivity + ) + ); + } + + for (final var leftViolatingBound : leftViolatingBounds.iterateEqualTo(true)) { + final var expandedInterval = Interval.between( + leftViolatingBound.start, + leftViolatingBound.startInclusivity, + leftViolatingBound.end.plus(width), + leftViolatingBound.endInclusivity); final var violationIntervals = new ArrayList(); final var violationActivityIds = new ArrayList(); - for (final var span: spans) { + for (final var span : reportedSpans) { if (!Interval.intersect(span.interval(), expandedInterval).isEmpty()) { violationIntervals.add(span.interval()); span.value().ifPresent(m -> violationActivityIds.add(m.activityInstance().id)); } } - if (this.algorithm == RollingThresholdAlgorithm.Hull) { + if (this.algorithm == RollingThresholdAlgorithm.ExcessHull || this.algorithm == RollingThresholdAlgorithm.DeficitHull) { final var hull = Interval.between( violationIntervals.get(0).start, violationIntervals.get(0).startInclusivity, - violationIntervals.get(violationIntervals.size()-1).end, - violationIntervals.get(violationIntervals.size()-1).endInclusivity + violationIntervals.get(violationIntervals.size() - 1).end, + violationIntervals.get(violationIntervals.size() - 1).endInclusivity ); violationIntervals.clear(); violationIntervals.add(hull); @@ -59,7 +96,6 @@ public ConstraintResult evaluate(SimulationResults results, final Interval bound final var violation = new Violation(violationIntervals, violationActivityIds); violations.add(violation); } - return new ConstraintResult(violations, List.of()); } diff --git a/constraints/src/test/java/gov/nasa/jpl/aerie/constraints/tree/ASTTests.java b/constraints/src/test/java/gov/nasa/jpl/aerie/constraints/tree/ASTTests.java index 37193bd08c..e5a49edba2 100644 --- a/constraints/src/test/java/gov/nasa/jpl/aerie/constraints/tree/ASTTests.java +++ b/constraints/src/test/java/gov/nasa/jpl/aerie/constraints/tree/ASTTests.java @@ -1128,7 +1128,7 @@ public void testShiftWindowsEdgesBoundsAdjustment() { } @Test - public void testRollingThreshold() { + public void testRollingThresholdExcess() { final var simResults = new SimulationResults( Instant.EPOCH, Interval.between(0, 20, SECONDS), List.of(), @@ -1150,10 +1150,10 @@ public void testRollingThreshold() { Supplier.of(spans), Supplier.of(Duration.of(10, SECONDS)), Supplier.of(Duration.of(2500, MILLISECOND)), - RollingThreshold.RollingThresholdAlgorithm.Hull + RollingThreshold.RollingThresholdAlgorithm.ExcessHull ).evaluate(simResults); - final var expected = new ConstraintResult( + final var expected1 = new ConstraintResult( List.of( new Violation(List.of(Interval.between(0, 5, SECONDS)), List.of()), new Violation(List.of(Interval.between(14, 19, SECONDS)), List.of()) @@ -1161,7 +1161,93 @@ public void testRollingThreshold() { List.of() ); - assertEquals(expected, result1); + assertEquals(expected1, result1); + + final var result2 = new RollingThreshold( + Supplier.of(spans), + Supplier.of(Duration.of(10, SECONDS)), + Supplier.of(Duration.of(2500, MILLISECOND)), + RollingThreshold.RollingThresholdAlgorithm.ExcessSpans + ).evaluate(simResults); + + final var expected2 = new ConstraintResult( + List.of( + new Violation( + List.of( + Interval.between(0, 1, SECONDS), + Interval.between(2, 3, SECONDS), + Interval.between(4, 5, SECONDS) + ), List.of() + ), + new Violation( + List.of( + Interval.between(14, 15, SECONDS), + Interval.between(16, 17, SECONDS), + Interval.between(18, 19, SECONDS) + ), List.of() + ) + ), List.of() + ); + + assertEquals(expected2, result2); + } + + @Test + public void tesRollingThresholdDeficit() { + final var simResults = new SimulationResults( + Instant.EPOCH, Interval.between(0, 20, SECONDS), + List.of(), + Map.of(), + Map.of() + ); + + final var spans = new Spans( + Interval.between(0, 1, SECONDS), + Interval.between(2, 3, SECONDS), + Interval.between(4, 5, SECONDS), + + Interval.between(14, 15, SECONDS), + Interval.between(16, 17, SECONDS), + Interval.between(18, 19, SECONDS) + ); + + final var result1 = new RollingThreshold( + Supplier.of(spans), + Supplier.of(Duration.of(10, SECONDS)), + Supplier.of(Duration.of(2500, MILLISECOND)), + RollingThreshold.RollingThresholdAlgorithm.DeficitHull + ).evaluate(simResults); + + final var expected1 = new ConstraintResult( + List.of( + new Violation(List.of(Interval.between(1, Exclusive, 18, Exclusive, SECONDS)), List.of()) + ), + List.of() + ); + + assertEquals(expected1, result1); + + final var result2 = new RollingThreshold( + Supplier.of(spans), + Supplier.of(Duration.of(10, SECONDS)), + Supplier.of(Duration.of(2500, MILLISECOND)), + RollingThreshold.RollingThresholdAlgorithm.DeficitSpans + ).evaluate(simResults); + + final var expected2 = new ConstraintResult( + List.of( + new Violation(List.of( + Interval.between(1, Exclusive, 2, Exclusive, SECONDS), + Interval.between(3, Exclusive, 4, Exclusive, SECONDS), + Interval.between(5, Exclusive, 14, Exclusive, SECONDS), + Interval.between(15, Exclusive, 16, Exclusive, SECONDS), + Interval.between(17, Exclusive, 18, Exclusive, SECONDS) + ), List.of()) + ), + List.of() + ); + + assertEquals(expected2, result2); } /** diff --git a/merlin-server/constraints-dsl-compiler/src/libs/constraints-edsl-fluent-api.ts b/merlin-server/constraints-dsl-compiler/src/libs/constraints-edsl-fluent-api.ts index 50105ef61d..d26800e8ac 100644 --- a/merlin-server/constraints-dsl-compiler/src/libs/constraints-edsl-fluent-api.ts +++ b/merlin-server/constraints-dsl-compiler/src/libs/constraints-edsl-fluent-api.ts @@ -71,11 +71,17 @@ export class Constraint { } /** - * Detect when a spans object's cumulative duration exceeds a threshold within any interval of a given width. + * Detect when a spans object's cumulative duration either exceeds or falls short of a threshold within any interval of a given width. * - * Violations can be reported in two different ways by setting the `algorithm` argument: - * - `RollingThresholdAlgorithm.Spans` highlights the individual spans that contributed to the threshold violation. - * - `RollingThresholdAlgorithm.Hull` highlights the single interval that contains all the violating spans. + * Violations can be reported in various ways by setting the `algorithm` argument: + * - `ExcessSpans` detects times when the duration exceeds the threshold and highlights the individual spans that + * contributed to the threshold violation. + * - `ExcessHull` detects times when the duration exceeds the threshold and highlights the whole group of spans that + * contributed to the threshold violation in one interval. + * - `DeficitSpans` detects times when the duration falls short of the threshold and highlights the individual gaps between spans + * that contributed to the threshold violation. + * - `ExcessHull` detects times when the duration falls short of the threshold and highlights the whole group of gaps between + * spans that contributed to the threshold violation in one interval. * * @param spans spans object to detect threshold events on * @param width width of the rolling interval @@ -101,8 +107,10 @@ export class Constraint { /** Algorithm to use when reporting violations from rolling threshold */ export enum RollingThresholdAlgorithm { - Spans = 'Spans', - Hull = 'Hull' + ExcessSpans = 'ExcessSpans', + ExcessHull = 'ExcessHull', + DeficitSpans = 'DeficitSpans', + DeficitHull = 'DeficitHull' } /** A boolean profile; a function from time to truth values. */ @@ -1091,11 +1099,17 @@ declare global { ): Constraint; /** - * Detect when a spans object's cumulative duration exceeds a threshold within any interval of a given width. + * Detect when a spans object's cumulative duration either exceeds or falls short of a threshold within any interval of a given width. * - * Violations can be reported in two different ways by setting the `algorithm` argument: - * - `RollingThresholdAlgorithm.Spans` highlights the individual spans that contributed to the threshold violation. - * - `RollingThresholdAlgorithm.Hull` highlights the single interval that contains all the violating spans. + * Violations can be reported in various ways by setting the `algorithm` argument: + * - `ExcessSpans` detects times when the duration exceeds the threshold and highlights the individual spans that + * contributed to the threshold violation. + * - `ExcessHull` detects times when the duration exceeds the threshold and highlights the whole group of spans that + * contributed to the threshold violation in one interval. + * - `DeficitSpans` detects times when the duration falls short of the threshold and highlights the individual gaps between spans + * that contributed to the threshold violation. + * - `ExcessHull` detects times when the duration falls short of the threshold and highlights the whole group of gaps between + * spans that contributed to the threshold violation in one interval. * * @param spans spans object to detect threshold events on * @param width width of the rolling interval @@ -1113,9 +1127,11 @@ declare global { /** Algorithm to use when reporting violations from rolling threshold */ export enum RollingThresholdAlgorithm { - Spans = 'Spans', - Hull = 'Hull' - } + ExcessSpans = 'ExcessSpans', + ExcessHull = 'ExcessHull', + DeficitSpans = 'DeficitSpans', + DeficitHull = 'DeficitHull' +} /** A boolean profile; a function from time to truth values. */ export class Windows { diff --git a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationServiceTests.java b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationServiceTests.java index 2c7765d3f5..345e7a6215 100644 --- a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationServiceTests.java +++ b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/services/ConstraintsDSLCompilationServiceTests.java @@ -56,8 +56,13 @@ import java.io.IOException; import java.time.Instant; +import java.util.Map; import java.util.Optional; +import static gov.nasa.jpl.aerie.constraints.tree.RollingThreshold.RollingThresholdAlgorithm.DeficitHull; +import static gov.nasa.jpl.aerie.constraints.tree.RollingThreshold.RollingThresholdAlgorithm.DeficitSpans; +import static gov.nasa.jpl.aerie.constraints.tree.RollingThreshold.RollingThresholdAlgorithm.ExcessHull; +import static gov.nasa.jpl.aerie.constraints.tree.RollingThreshold.RollingThresholdAlgorithm.ExcessSpans; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; @@ -1278,28 +1283,36 @@ export default() => { @Test void testRollingThreshold() { - checkSuccessfulCompilation( - """ - export default () => { - return Constraint.RollingThreshold( - Spans.ForEachActivity(ActivityType.activity), - Temporal.Duration.from({hours: 1}), - Temporal.Duration.from({minutes: 5}), - RollingThresholdAlgorithm.Hull - ); - } - """, - new RollingThreshold( - new ForEachActivitySpans( - "activity", - "span activity alias 0", - new ActivitySpan("span activity alias 0") - ), - new DurationLiteral(Duration.of(1, Duration.HOUR)), - new DurationLiteral(Duration.of(5, Duration.MINUTE)), - RollingThreshold.RollingThresholdAlgorithm.Hull - ) - ); + final var algs = Map.of( + "ExcessHull", ExcessHull, + "ExcessSpans", ExcessSpans, + "DeficitHull", DeficitHull, + "DeficitSpans", DeficitSpans + ); + for (final var entry: algs.entrySet()) { + checkSuccessfulCompilation( + """ + export default () => { + return Constraint.RollingThreshold( + Spans.ForEachActivity(ActivityType.activity), + Temporal.Duration.from({hours: 1}), + Temporal.Duration.from({minutes: 5}), + RollingThresholdAlgorithm.%s + ); + } + """.formatted(entry.getKey()), + new RollingThreshold( + new ForEachActivitySpans( + "activity", + "span activity alias 0", + new ActivitySpan("span activity alias 0") + ), + new DurationLiteral(Duration.of(1, Duration.HOUR)), + new DurationLiteral(Duration.of(5, Duration.MINUTE)), + entry.getValue() + ) + ); + } } }