diff --git a/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/json/ConstraintParsers.java b/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/json/ConstraintParsers.java index fad9655a9b..10017ac713 100644 --- a/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/json/ConstraintParsers.java +++ b/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/json/ConstraintParsers.java @@ -617,9 +617,26 @@ private static JsonParser> spansExpressionF(JsonParser new ViolationsOfWindows(expression)), $ -> tuple(Unit.UNIT, $.expression)); + public static final JsonParser rollingThresholdAlgorithmP = + enumP(RollingThreshold.RollingThresholdAlgorithm.class, Enum::name); + + static final JsonParser rollingThresholdP = + productP + .field("kind", literalP("RollingThreshold")) + .field("spans", spansExpressionP) + .field("width", durationExprP) + .field("threshold", durationExprP) + .field("algorithm", rollingThresholdAlgorithmP) + .map( + untuple((kind, spans, width, threshold, alg) -> new RollingThreshold(spans, width, threshold, alg)), + $ -> tuple(Unit.UNIT, $.spans(), $.width(), $.threshold(), $.algorithm()) + ); + + public static final JsonParser> constraintP = recursiveP(selfP -> chooseP( forEachActivityViolationsF(selfP), windowsExpressionP.map(ViolationsOfWindows::new, $ -> $.expression), - violationsOfP)); + violationsOfP, + rollingThresholdP)); } 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 new file mode 100644 index 0000000000..2c01d07d68 --- /dev/null +++ b/constraints/src/main/java/gov/nasa/jpl/aerie/constraints/tree/RollingThreshold.java @@ -0,0 +1,122 @@ +package gov.nasa.jpl.aerie.constraints.tree; + +import gov.nasa.jpl.aerie.constraints.model.ConstraintResult; +import gov.nasa.jpl.aerie.constraints.model.EvaluationEnvironment; +import gov.nasa.jpl.aerie.constraints.model.LinearEquation; +import gov.nasa.jpl.aerie.constraints.model.LinearProfile; +import gov.nasa.jpl.aerie.constraints.model.SimulationResults; +import gov.nasa.jpl.aerie.constraints.model.Violation; +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; +import java.util.List; +import java.util.Set; + +public record RollingThreshold(Expression spans, Expression width, Expression threshold, RollingThresholdAlgorithm algorithm) implements Expression { + + public enum RollingThresholdAlgorithm { + ExcessSpans, + ExcessHull, + DeficitSpans, + DeficitHull + } + + @Override + public ConstraintResult evaluate(SimulationResults results, final Interval bounds, EvaluationEnvironment environment) { + final var width = this.width.evaluate(results, bounds, environment); + final var spans = this.spans.evaluate(results, bounds, environment); + + final Spans reportedSpans; + if (algorithm == RollingThresholdAlgorithm.ExcessHull || algorithm == RollingThresholdAlgorithm.ExcessSpans) { + reportedSpans = spans; + } else if (algorithm == RollingThresholdAlgorithm.DeficitHull || algorithm == RollingThresholdAlgorithm.DeficitSpans) { + reportedSpans = spans.intoWindows().not().intoSpans(bounds); + } else { + throw new IllegalArgumentException("Algorithm not supported: " + algorithm); + } + + final var threshold = this.threshold.evaluate(results, bounds, environment); + + final var accDuration = spans.accumulatedDuration(threshold); + final var shiftedBack = accDuration.shiftBy(Duration.negate(width)); + + final var localAccDuration = shiftedBack.plus(accDuration.times(-1)); + + final Windows leftViolatingBounds; + final var violations = new ArrayList(); + + 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 if (algorithm == RollingThresholdAlgorithm.DeficitHull || algorithm == RollingThresholdAlgorithm.DeficitSpans) { + leftViolatingBounds = localAccDuration.lessThan(thresholdEq).select( + Interval.between( + bounds.start, + bounds.startInclusivity, + bounds.end.minus(width), + bounds.endInclusivity + ) + ); + } else { + throw new IllegalArgumentException("Algorithm not supported: " + algorithm); + } + + 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 : 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.ExcessHull || this.algorithm == RollingThresholdAlgorithm.DeficitHull) { + var hull = violationIntervals.get(0); + for (final var interval: violationIntervals.subList(1, violationIntervals.size())) { + hull = Interval.unify(hull, interval); + } + violationIntervals.clear(); + violationIntervals.add(hull); + } + final var violation = new Violation(violationIntervals, violationActivityIds); + violations.add(violation); + } + return new ConstraintResult(violations, List.of()); + } + + @Override + public void extractResources(final Set names) { + this.spans.extractResources(names); + this.width.extractResources(names); + this.threshold.extractResources(names); + } + + @Override + public String prettyPrint(final String prefix) { + return String.format( + "\n%s(rolling-threshold on %s, width %s, threshold %s, algorithm %s)", + prefix, + this.spans.prettyPrint(prefix + " "), + this.width.prettyPrint(prefix + " "), + this.threshold.prettyPrint(prefix + " "), + this.algorithm + ); + } +} 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 8a0fdc264c..c6ac01d3cf 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 @@ -33,6 +33,7 @@ import static gov.nasa.jpl.aerie.constraints.time.Interval.at; import static gov.nasa.jpl.aerie.constraints.time.Interval.interval; import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.MICROSECONDS; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.MILLISECOND; import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.SECONDS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; @@ -1126,6 +1127,130 @@ public void testShiftWindowsEdgesBoundsAdjustment() { assertIterableEquals(expected2, result2); } + @Test + public void testRollingThresholdExcess() { + final var simResults = new SimulationResults( + Instant.EPOCH, Interval.between(0, 20, SECONDS), + List.of(), + Map.of(), + Map.of() + ); + + final var spans = new Spans( + // These two are out of order to make sure RollingThreshold's hull operation correctly handles unsorted spans. + Interval.between(4, 5, SECONDS), + Interval.between(0, 1, SECONDS), + Interval.between(2, 3, 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.ExcessHull + ).evaluate(simResults); + + 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()) + ), + 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.ExcessSpans + ).evaluate(simResults); + + final var expected2 = new ConstraintResult( + List.of( + new Violation( + List.of( + Interval.between(4, 5, SECONDS), + Interval.between(0, 1, SECONDS), + Interval.between(2, 3, 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); + } + /** * An expression that yields the same aliased object every time it is evaluated. */ diff --git a/merlin-server/constraints-dsl-compiler/src/libs/constraints-ast.ts b/merlin-server/constraints-dsl-compiler/src/libs/constraints-ast.ts index 369aeda9c6..b00da24a60 100644 --- a/merlin-server/constraints-dsl-compiler/src/libs/constraints-ast.ts +++ b/merlin-server/constraints-dsl-compiler/src/libs/constraints-ast.ts @@ -47,10 +47,11 @@ export enum NodeKind { ViolationsOf = 'ViolationsOf', AbsoluteInterval = 'AbsoluteInterval', IntervalAlias = 'IntervalAlias', - IntervalDuration = 'IntervalDuration' + IntervalDuration = 'IntervalDuration', + RollingThreshold = 'RollingThreshold' } -export type Constraint = ViolationsOf | WindowsExpression | SpansExpression | ForEachActivityConstraints; +export type Constraint = ViolationsOf | WindowsExpression | SpansExpression | ForEachActivityConstraints | RollingThreshold; export interface ViolationsOf { kind: NodeKind.ViolationsOf; @@ -71,6 +72,14 @@ export interface ForEachActivitySpans { expression: SpansExpression; } +export interface RollingThreshold { + kind: NodeKind.RollingThreshold; + spans: SpansExpression, + width: Duration, + threshold: Duration, + algorithm: API.RollingThresholdAlgorithm +} + export interface AssignGapsExpression

{ kind: NodeKind.AssignGapsExpression, originalProfile: P, 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 fbf6348777..4c996b643f 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 @@ -69,6 +69,48 @@ export class Constraint { expression: expression(new ActivityInstance(activityType, alias)).__astNode, }); } + + /** + * 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 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. + * - `DeficitHull` 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 + * @param threshold maximum allowable duration within any `width` interval + * @param algorithm algorithm for reporting violations + * @constructor + */ + public static RollingThreshold( + spans: Spans, + width: AST.Duration, + threshold: AST.Duration, + algorithm: RollingThresholdAlgorithm + ): Constraint { + return new Constraint({ + kind: AST.NodeKind.RollingThreshold, + spans: spans.__astNode, + width, + threshold, + algorithm + }); + } +} + +/** Algorithm to use when reporting violations from rolling threshold */ +export enum RollingThresholdAlgorithm { + ExcessSpans = 'ExcessSpans', + ExcessHull = 'ExcessHull', + DeficitSpans = 'DeficitSpans', + DeficitHull = 'DeficitHull' } /** A boolean profile; a function from time to truth values. */ @@ -105,7 +147,7 @@ export class Windows { public static During(...activityTypes: Gen.ActivityType[]) : Windows { return Windows.Or( ...activityTypes.map((activityType) => - Spans.ForEachActivity(activityType, (activity) => activity.span()).windows()) + Spans.ForEachActivity(activityType).windows()) ); } @@ -464,13 +506,14 @@ export class Spans { * Check a constraint for each instance of an activity type. * * @param activityType activity type to check - * @param expression function of an activity instance that returns a Constraint + * @param expression function of an activity instance that returns a Constraint; default returns the instance's span. * @constructor */ public static ForEachActivity( activityType: A, - expression: (instance: ActivityInstance) => Spans, + expression?: (instance: ActivityInstance) => Spans, ): Spans { + if (expression === undefined) expression = instance => instance.span(); let alias = 'span activity alias ' + Spans.__numGeneratedAliases; Spans.__numGeneratedAliases += 1; return new Spans({ @@ -1039,8 +1082,8 @@ declare global { * @constructor */ public static ForbiddenActivityOverlap( - activityType1: Gen.ActivityType, - activityType2: Gen.ActivityType, + activityType1: Gen.ActivityType, + activityType2: Gen.ActivityType, ): Constraint; /** @@ -1054,8 +1097,42 @@ declare global { activityType: A, expression: (instance: ActivityInstance) => Constraint, ): Constraint; + + /** + * 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 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. + * - `DeficitHull` 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 + * @param threshold maximum allowable duration within any `width` interval + * @param algorithm algorithm for reporting violations + * @constructor + */ + public static RollingThreshold( + spans: Spans, + width: AST.Duration, + threshold: AST.Duration, + algorithm: RollingThresholdAlgorithm + ): Constraint; } + /** Algorithm to use when reporting violations from rolling threshold */ + export enum RollingThresholdAlgorithm { + ExcessSpans = 'ExcessSpans', + ExcessHull = 'ExcessHull', + DeficitSpans = 'DeficitSpans', + DeficitHull = 'DeficitHull' +} + /** A boolean profile; a function from time to truth values. */ export class Windows { /** Internal AST Node */ @@ -1261,12 +1338,12 @@ declare global { * Applies an expression producing spans for each instance of an activity type and returns the aggregated set of spans. * * @param activityType activity type to check - * @param expression function of an activity instance that returns a Spans + * @param expression function of an activity instance that returns a Spans; default returns the instance's span. * @constructor */ public static ForEachActivity( activityType: A, - expression: (instance: ActivityInstance) => Spans, + expression?: (instance: ActivityInstance) => Spans, ): Spans; /** @@ -1523,4 +1600,4 @@ declare global { } // Make Constraint available on the global object -Object.assign(globalThis, { Constraint, Windows, Spans, Real, Discrete, Inclusivity, Interval }); +Object.assign(globalThis, { Constraint, Windows, Spans, Real, Discrete, Inclusivity, Interval, RollingThresholdAlgorithm }); 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 709a400273..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 @@ -31,6 +31,7 @@ import gov.nasa.jpl.aerie.constraints.tree.RealParameter; import gov.nasa.jpl.aerie.constraints.tree.RealResource; import gov.nasa.jpl.aerie.constraints.tree.RealValue; +import gov.nasa.jpl.aerie.constraints.tree.RollingThreshold; import gov.nasa.jpl.aerie.constraints.tree.ShiftBy; import gov.nasa.jpl.aerie.constraints.tree.ShiftWindowsEdges; import gov.nasa.jpl.aerie.constraints.tree.ShorterThan; @@ -55,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; @@ -1275,4 +1281,38 @@ export default() => { ); } + @Test + void testRollingThreshold() { + 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() + ) + ); + } + } + }