diff --git a/scheduler-driver/src/main/java/gov/nasa/jpl/aerie/scheduler/constraints/timeexpressions/TimeExpressionBetween.java b/scheduler-driver/src/main/java/gov/nasa/jpl/aerie/scheduler/constraints/timeexpressions/TimeExpressionBetween.java new file mode 100644 index 0000000000..57cb6979ef --- /dev/null +++ b/scheduler-driver/src/main/java/gov/nasa/jpl/aerie/scheduler/constraints/timeexpressions/TimeExpressionBetween.java @@ -0,0 +1,23 @@ +package gov.nasa.jpl.aerie.scheduler.constraints.timeexpressions; + +import gov.nasa.jpl.aerie.constraints.model.SimulationResults; +import gov.nasa.jpl.aerie.constraints.time.Interval; +import gov.nasa.jpl.aerie.scheduler.model.Plan; + +public class TimeExpressionBetween extends TimeExpression { + + private final TimeExpressionRelativeFixed lowerBound; + private final TimeExpressionRelativeFixed upperBound; + + public TimeExpressionBetween(TimeExpressionRelativeFixed lowerBound, TimeExpressionRelativeFixed upperBound) { + this.lowerBound = lowerBound; + this.upperBound = upperBound; + } + + @Override + public Interval computeTime(final SimulationResults simulationResults, final Plan plan, final Interval interval) { + final var interval1 = lowerBound.computeTime(simulationResults, plan, interval); + final var interval2 = upperBound.computeTime(simulationResults, plan, interval); + return Interval.between(interval1.start, interval2.end); + } +} diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/models/SchedulingDSL.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/models/SchedulingDSL.java index 5c3a21d2a0..8fef7b3b97 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/models/SchedulingDSL.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/models/SchedulingDSL.java @@ -3,6 +3,7 @@ import gov.nasa.jpl.aerie.constraints.time.Windows; import gov.nasa.jpl.aerie.constraints.tree.Expression; import gov.nasa.jpl.aerie.constraints.tree.StructExpressionAt; +import gov.nasa.jpl.aerie.json.Convert; import gov.nasa.jpl.aerie.json.JsonObjectParser; import gov.nasa.jpl.aerie.json.JsonParser; import gov.nasa.jpl.aerie.json.SumParsers; @@ -12,6 +13,7 @@ import gov.nasa.jpl.aerie.scheduler.constraints.timeexpressions.TimeAnchor; import gov.nasa.jpl.aerie.scheduler.server.http.ActivityTemplateJsonParser; import gov.nasa.jpl.aerie.scheduler.server.services.MissionModelService; +import org.apache.commons.lang3.tuple.Pair; import java.util.List; import java.util.Optional; @@ -90,16 +92,25 @@ private static JsonObjectParser recurren ConstraintExpression.WindowsExpression::new, ConstraintExpression.WindowsExpression::expression)); - public static final JsonParser activityTimingConstraintP = + public static final JsonParser activityTimingConstraintP = productP .field("windowProperty", enumP(TimeAnchor.class, Enum::name)) .field("operator", enumP(TimeUtility.Operator.class, Enum::name)) .field("operand", durationP) .field("singleton", boolP) .map( - untuple(ActivityTimingConstraint::new), + untuple(TimingConstraint.ActivityTimingConstraint::new), $ -> tuple($.windowProperty(), $.operator(), $.operand(), $.singleton())); + public static final JsonParser activityTimingConstraintFlexibleRangeP = + productP + .field("lowerBound", activityTimingConstraintP) + .field("upperBound", activityTimingConstraintP) + .field("singleton", boolP) + .map( + untuple(TimingConstraint.ActivityTimingConstraintFlexibleRange::new), + (TimingConstraint.ActivityTimingConstraintFlexibleRange $) -> tuple($.lowerBound(), $.upperBound(), $.singleton())); + private static final JsonObjectParser coexistenceGoalDefinitionP( MissionModelService.MissionModelTypes activityTypes) { @@ -109,20 +120,31 @@ private static final JsonObjectParser c .optionalField("activityFinder", activityExpressionP) .field("alias", stringP) .field("forEach", constraintExpressionP) - .optionalField("startConstraint", activityTimingConstraintP) - .optionalField("endConstraint", activityTimingConstraintP) + .optionalField("startConstraint", (JsonParser) chooseP(activityTimingConstraintP, activityTimingConstraintFlexibleRangeP)) + .optionalField("endConstraint", (JsonParser) chooseP(activityTimingConstraintP, activityTimingConstraintFlexibleRangeP)) .field("shouldRollbackIfUnsatisfied", boolP) - .map( - untuple(GoalSpecifier.CoexistenceGoalDefinition::new), - goalDefinition -> tuple( - goalDefinition.activityTemplate(), - goalDefinition.activityFinder(), - goalDefinition.alias(), - goalDefinition.forEach(), - goalDefinition.startConstraint(), - goalDefinition.endConstraint(), - goalDefinition.shouldRollbackIfUnsatisfied())); + .map(coexistenceGoalTransform()); + } + + /** + * This convert is in a helper function in order to define the generic variables T1 and T2 + */ + private static + Convert< + Pair>, String>, ConstraintExpression>, Optional>, Optional>, Boolean>, + GoalSpecifier.CoexistenceGoalDefinition> + coexistenceGoalTransform() { + return Convert.between(untuple(GoalSpecifier.CoexistenceGoalDefinition::new), (GoalSpecifier.CoexistenceGoalDefinition $) -> tuple( + $.activityTemplate(), + $.activityFinder(), + $.alias, + $.forEach, + (Optional) $.startConstraint, + (Optional) $.endConstraint, + $.shouldRollbackIfUnsatisfied + )); } + private static final JsonObjectParser cardinalityGoalDefinitionP( MissionModelService.MissionModelTypes activityTypes) { return @@ -248,8 +270,8 @@ record CoexistenceGoalDefinition( Optional activityFinder, String alias, ConstraintExpression forEach, - Optional startConstraint, - Optional endConstraint, + Optional startConstraint, + Optional endConstraint, boolean shouldRollbackIfUnsatisfied ) implements GoalSpecifier {} record CardinalityGoalDefinition( @@ -277,5 +299,19 @@ public sealed interface ConstraintExpression { record ActivityExpression(String type, Optional arguments) implements ConstraintExpression {} record WindowsExpression(Expression expression) implements ConstraintExpression {} } - public record ActivityTimingConstraint(TimeAnchor windowProperty, TimeUtility.Operator operator, Duration operand, boolean singleton) {} + + public sealed interface TimingConstraint { + record ActivityTimingConstraint( + TimeAnchor windowProperty, + TimeUtility.Operator operator, + Duration operand, + boolean singleton + ) implements TimingConstraint {} + + record ActivityTimingConstraintFlexibleRange( + ActivityTimingConstraint lowerBound, + ActivityTimingConstraint upperBound, + boolean singleton + ) implements TimingConstraint {} + } } diff --git a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GoalBuilder.java b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GoalBuilder.java index 42e2fd6e9c..8e36a0f015 100644 --- a/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GoalBuilder.java +++ b/scheduler-server/src/main/java/gov/nasa/jpl/aerie/scheduler/server/remotes/postgres/GoalBuilder.java @@ -13,6 +13,7 @@ import gov.nasa.jpl.aerie.scheduler.Range; import gov.nasa.jpl.aerie.scheduler.constraints.activities.ActivityCreationTemplate; import gov.nasa.jpl.aerie.scheduler.constraints.activities.ActivityExpression; +import gov.nasa.jpl.aerie.scheduler.constraints.timeexpressions.TimeExpressionBetween; import gov.nasa.jpl.aerie.scheduler.constraints.timeexpressions.TimeExpressionRelativeFixed; import gov.nasa.jpl.aerie.scheduler.goals.CardinalityGoal; import gov.nasa.jpl.aerie.scheduler.goals.CoexistenceGoal; @@ -25,6 +26,7 @@ import gov.nasa.jpl.aerie.scheduler.server.models.SchedulingDSL; import gov.nasa.jpl.aerie.scheduler.server.models.Timestamp; import gov.nasa.jpl.aerie.scheduler.server.services.UnexpectedSubtypeError; +import org.jetbrains.annotations.NotNull; import java.util.Map; import java.util.function.Function; @@ -65,21 +67,23 @@ public static Goal goalOfGoalSpecifier( .aliasForAnchors(g.alias()); if (g.startConstraint().isPresent()) { final var startConstraint = g.startConstraint().get(); - final var timeExpression = new TimeExpressionRelativeFixed( - startConstraint.windowProperty(), - startConstraint.singleton() - ); - timeExpression.addOperation(startConstraint.operator(), startConstraint.operand()); - builder.startsAt(timeExpression); + if (startConstraint instanceof SchedulingDSL.TimingConstraint.ActivityTimingConstraint s) { + builder.startsAt(makeTimeExpressionRelativeFixed(s)); + } else if (startConstraint instanceof SchedulingDSL.TimingConstraint.ActivityTimingConstraintFlexibleRange s) { + builder.startsAt(new TimeExpressionBetween(makeTimeExpressionRelativeFixed(s.lowerBound()), makeTimeExpressionRelativeFixed(s.upperBound()))); + } else { + throw new UnexpectedSubtypeError(SchedulingDSL.TimingConstraint.class, startConstraint); + } } if (g.endConstraint().isPresent()) { - final var startConstraint = g.endConstraint().get(); - final var timeExpression = new TimeExpressionRelativeFixed( - startConstraint.windowProperty(), - startConstraint.singleton() - ); - timeExpression.addOperation(startConstraint.operator(), startConstraint.operand()); - builder.endsAt(timeExpression); + final var endConstraint = g.endConstraint().get(); + if (endConstraint instanceof SchedulingDSL.TimingConstraint.ActivityTimingConstraint e) { + builder.endsAt(makeTimeExpressionRelativeFixed(e)); + } else if (endConstraint instanceof SchedulingDSL.TimingConstraint.ActivityTimingConstraintFlexibleRange e) { + builder.endsAt(new TimeExpressionBetween(makeTimeExpressionRelativeFixed(e.lowerBound()), makeTimeExpressionRelativeFixed(e.upperBound()))); + } else { + throw new UnexpectedSubtypeError(SchedulingDSL.TimingConstraint.class, endConstraint); + } } if (g.startConstraint().isEmpty() && g.endConstraint().isEmpty()) { throw new Error("Both start and end constraints were empty. This should have been disallowed at the type level."); @@ -146,6 +150,16 @@ else if(goalSpecifier instanceof SchedulingDSL.GoalSpecifier.CardinalityGoalDefi } } + @NotNull + private static TimeExpressionRelativeFixed makeTimeExpressionRelativeFixed(final SchedulingDSL.TimingConstraint.ActivityTimingConstraint s) { + final var timeExpression = new TimeExpressionRelativeFixed( + s.windowProperty(), + s.singleton() + ); + timeExpression.addOperation(s.operator(), s.operand()); + return timeExpression; + } + private static ActivityExpression buildActivityExpression(SchedulingDSL.ConstraintExpression.ActivityExpression activityExpr, final Function lookupActivityType){ final var builder = new ActivityExpression.Builder().ofType(lookupActivityType.apply(activityExpr.type())); diff --git a/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-ast.ts b/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-ast.ts index c53ada2278..bbe7585ba6 100644 --- a/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-ast.ts +++ b/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-ast.ts @@ -76,8 +76,8 @@ export interface ActivityCoexistenceGoal { activityFinder: ActivityExpression | undefined, alias: string, forEach: WindowsExpressions.WindowsExpression | ActivityExpression, - startConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | undefined, - endConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | undefined, + startConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | ActivityTimingConstraintInterval | undefined, + endConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | ActivityTimingConstraintInterval | undefined, shouldRollbackIfUnsatisfied: boolean } @@ -138,6 +138,11 @@ export interface ActivityTimingConstraintRange { singleton: false } +export interface ActivityTimingConstraintInterval { + lowerBound: ActivityTimingConstraintSingleton; + upperBound: ActivityTimingConstraintSingleton; + singleton: false; +} export type GoalSpecifier = | Goal | GoalComposition diff --git a/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-edsl-fluent-api.ts b/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-edsl-fluent-api.ts index 37a193d82a..8df710466c 100644 --- a/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-edsl-fluent-api.ts +++ b/scheduler-worker/scheduling-dsl-compiler/src/libs/scheduler-edsl-fluent-api.ts @@ -528,11 +528,11 @@ export class Goal { /** * An StartTimingConstraint is a constraint applying on the start time of an activity template. */ -export type StartTimingConstraint = { startsAt: SingletonTimingConstraint | SingletonTimingConstraintNoOperator } | { startsWithin: RangeTimingConstraint } +export type StartTimingConstraint = { startsAt: SingletonTimingConstraint | SingletonTimingConstraintNoOperator } | { startsWithin: RangeTimingConstraint | FlexibleRangeTimingConstraint} /** * An EndTimingConstraint is a constraint applying on the end time of an activity template. */ -export type EndTimingConstraint = { endsAt: SingletonTimingConstraint | SingletonTimingConstraintNoOperator } | {endsWithin: RangeTimingConstraint } +export type EndTimingConstraint = { endsAt: SingletonTimingConstraint | SingletonTimingConstraintNoOperator } | { endsWithin: RangeTimingConstraint | FlexibleRangeTimingConstraint } /** * An CoexistenceGoalTimingConstraints is a constraint that can be used to constrain the start or end times of activities in coexistence goals. */ @@ -619,6 +619,20 @@ export class TimingConstraint { singleton: false }) } + + /** + * The bound timing constraint is used to represent time intervals used to define temporal constraints between goals (e.g. A before[lowerbound, upperbound] B) + * . + * @param lowerBound represents the (inclusive) lower bound of the time interval + * @param upperBound represents the (inclusive) upper bound of the time interval + */ + public static bounds(lowerBound: SingletonTimingConstraint, upperBound: SingletonTimingConstraint): FlexibleRangeTimingConstraint { + return FlexibleRangeTimingConstraint.new({ + lowerBound: lowerBound.__astNode, + upperBound: upperBound.__astNode, + singleton: false + }) + } } /** @@ -697,6 +711,22 @@ export class RangeTimingConstraint { } } +/** + * A FlexibleRange timing constraint specifies that the start or the end time of an activity must be within certain bounds. Use {@link TimingConstraint.bounds} to specify the bounds. + */ +export class FlexibleRangeTimingConstraint { + /** @internal **/ + public readonly __astNode: AST.ActivityTimingConstraintInterval + /** @internal **/ + private constructor(__astNode: AST.ActivityTimingConstraintInterval) { + this.__astNode = __astNode; + } + /** @internal **/ + public static new(__astNode: AST.ActivityTimingConstraintInterval): FlexibleRangeTimingConstraint { + return new FlexibleRangeTimingConstraint(__astNode); + } +} + declare global { class GlobalSchedulingCondition { @@ -824,6 +854,14 @@ declare global { * @param operand the duration offset */ public static range(windowProperty: WindowProperty, operator: TimingConstraintOperator, operand: Temporal.Duration): RangeTimingConstraint + + /** + * The bound timing constraint is used to represent time intervals used to define temporal constraints between goals (e.g. A before[lowerbound, upperbound] B) + * . + * @param lowerBound represents the (inclusive) lower bound of the time interval + * @param upperBound represents the (inclusive) upper bound of the time interval + */ + public static bounds(lowerBound: SingletonTimingConstraint, upperBound: SingletonTimingConstraint): FlexibleRangeTimingConstraint } var WindowProperty: typeof AST.WindowProperty var Operator: typeof AST.TimingConstraintOperator @@ -847,4 +885,14 @@ export interface ClosedOpenInterval extends AST.ClosedOpenInterval {} export interface ActivityTemplate extends AST.ActivityTemplate {} // Make Goal available on the global object -Object.assign(globalThis, { GlobalSchedulingCondition, Goal, ActivityExpression, TimingConstraint: TimingConstraint, WindowProperty: AST.WindowProperty, Operator: AST.TimingConstraintOperator, ActivityTypes: WindowsEDSL.Gen.ActivityType}); +Object.assign(globalThis, + { + GlobalSchedulingCondition, + Goal, + ActivityExpression, + TimingConstraint: TimingConstraint, + WindowProperty: AST.WindowProperty, + Operator: AST.TimingConstraintOperator, + ActivityTypes: WindowsEDSL.Gen.ActivityType, + FlexibleRangeTimingConstraint + }); diff --git a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/MockMerlinService.java b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/MockMerlinService.java index 597751b875..cc99078002 100644 --- a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/MockMerlinService.java +++ b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/MockMerlinService.java @@ -42,6 +42,7 @@ record MissionModelInfo(Path libPath, Path modelPath, String modelName, MissionM private Optional missionModelInfo = Optional.empty(); private MerlinPlan initialPlan; Collection updatedPlan; + Plan plan; MockMerlinService() { this.initialPlan = new MerlinPlan(); @@ -123,6 +124,7 @@ public Map updatePlanActivityD ) { this.updatedPlan = extractActivityDirectives(plan); + this.plan = plan; final var res = new HashMap(); for (final var activity : plan.getActivities()) { res.put(activity, new ActivityDirectiveId(activity.id().id())); diff --git a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java index 8ac852ed8b..2cd4c35afc 100644 --- a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java +++ b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingDSLCompilationServiceTests.java @@ -42,6 +42,7 @@ import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.HOUR; import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.SECOND; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.SECONDS; import static gov.nasa.jpl.aerie.scheduler.server.services.TypescriptCodeGenerationServiceTestFixtures.MISSION_MODEL_TYPES; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -470,7 +471,51 @@ export default function() { Optional.empty(), "coexistence activity alias 0", new SchedulingDSL.ConstraintExpression.ActivityExpression("SampleActivity2", Optional.empty()), - Optional.of(new SchedulingDSL.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), + Optional.empty(), + false + ), + r.value() + ); + } else if (result instanceof SchedulingDSLCompilationService.SchedulingDSLCompilationResult.Error r) { + fail(r.toString()); + } + } + + @Test + void testCoexistenceGoalFlexibleTimingConstraint() { + final var result = schedulingDSLCompilationService.compileSchedulingGoalDSL( + missionModelService, + PLAN_ID, """ + export default function() { + return Goal.CoexistenceGoal({ + activityTemplate: (span) => ActivityTemplates.SampleActivity1({ + variant: 'option2', + fancy: { subfield1: 'value1', subfield2: [{subsubfield1: 2.0}]}, + duration: Temporal.Duration.from({ hours : 1 }) + }), + forEach: ActivityExpression.ofType(ActivityTypes.SampleActivity2), + startsWithin: TimingConstraint.bounds( + TimingConstraint.singleton(WindowProperty.START).plus(Temporal.Duration.from({ seconds : 1 })), + TimingConstraint.singleton(WindowProperty.START).plus(Temporal.Duration.from({ seconds : 5 }))) + }) + } + """); + + if (result instanceof SchedulingDSLCompilationService.SchedulingDSLCompilationResult.Success r) { + assertEquals( + new SchedulingDSL.GoalSpecifier.CoexistenceGoalDefinition( + new SchedulingDSL.ActivityTemplate("SampleActivity1", + getSampleActivity1Parameters() + ), + Optional.empty(), + "coexistence activity alias 0", + new SchedulingDSL.ConstraintExpression.ActivityExpression("SampleActivity2", Optional.empty()), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraintFlexibleRange( + new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true), + new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, Duration.of(5, SECONDS), true), + false + )), Optional.empty(), false ), @@ -511,7 +556,7 @@ export default function() { ))), "coexistence activity alias 0", new SchedulingDSL.ConstraintExpression.ActivityExpression("SampleActivity2", Optional.empty()), - Optional.of(new SchedulingDSL.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), Optional.empty(), false ), @@ -558,7 +603,7 @@ export default function() { Optional.empty(), "coexistence activity alias 0", new SchedulingDSL.ConstraintExpression.ActivityExpression("SampleActivity2", Optional.empty()), - Optional.of(new SchedulingDSL.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), Optional.empty(), false ), @@ -605,7 +650,7 @@ export default function() { Optional.empty(), "coexistence activity alias 0", new SchedulingDSL.ConstraintExpression.ActivityExpression("SampleActivity2", Optional.empty()), - Optional.of(new SchedulingDSL.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.START, TimeUtility.Operator.PLUS, SECOND, true)), Optional.empty(), false ), @@ -760,7 +805,7 @@ export default function() { Optional.empty(), "coexistence interval alias 0", new SchedulingDSL.ConstraintExpression.WindowsExpression(new LongerThan(new GreaterThan(new RealResource("/sample/resource/1"), new RealValue(50.0)), new DurationLiteral(Duration.of(10, Duration.MICROSECOND)))), - Optional.of(new SchedulingDSL.ActivityTimingConstraint(TimeAnchor.END, TimeUtility.Operator.PLUS, Duration.ZERO, true)), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.END, TimeUtility.Operator.PLUS, Duration.ZERO, true)), Optional.empty(), false ), @@ -800,7 +845,7 @@ export default function() { Optional.empty(), "coexistence interval alias 0", new SchedulingDSL.ConstraintExpression.WindowsExpression(new LongerThan(new GreaterThan(new RealResource("/sample/resource/1"), new RealValue(50.0)), new DurationLiteral(Duration.of(10, Duration.MICROSECOND)))), - Optional.of(new SchedulingDSL.ActivityTimingConstraint(TimeAnchor.END, TimeUtility.Operator.PLUS, Duration.ZERO, true)), + Optional.of(new SchedulingDSL.TimingConstraint.ActivityTimingConstraint(TimeAnchor.END, TimeUtility.Operator.PLUS, Duration.ZERO, true)), Optional.empty(), false ), diff --git a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingIntegrationTests.java b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingIntegrationTests.java index 4f82be4c57..41e2648c67 100644 --- a/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingIntegrationTests.java +++ b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingIntegrationTests.java @@ -48,6 +48,8 @@ import gov.nasa.jpl.aerie.scheduler.server.services.RevisionData; import gov.nasa.jpl.aerie.scheduler.server.services.ScheduleRequest; import gov.nasa.jpl.aerie.scheduler.server.services.ScheduleResults; +import gov.nasa.jpl.aerie.scheduler.model.Plan; +import gov.nasa.jpl.aerie.scheduler.model.SchedulingActivityDirectiveId; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -816,6 +818,445 @@ export default () => Goal.CoexistenceGoal({ assertTrue(growBanana.startOffset().plus(growBananaDuration).noLongerThan(peelBanana.startOffset())); } + /** + * Allen Relation Before. GrowBanana finish between [5,10] before PeelBanana starts + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenBefore() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.PeelBanana({peelDirection: "fromStem"}), + startsWithin: TimingConstraint.bounds(TimingConstraint.singleton(WindowProperty.END).plus(Temporal.Duration.from({ minutes : 5})), TimingConstraint.singleton(WindowProperty.END).plus(Temporal.Duration.from({ minutes : 10}))) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var peelBananas = planByActivityType.get("PeelBanana"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, peelBananas.size()); + assertEquals(1, growBananas.size()); + final var peelBanana = peelBananas.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + assertEquals(SerializedValue.of("fromStem"), peelBanana.serializedActivity().getArguments().get("peelDirection")); + assertEquals(SerializedValue.of(1), growBanana.serializedActivity().getArguments().get("quantity")); + + // Checking peelBanana starts between [5, 10] time units after growBanana finishes + assertTrue(peelBanana.startOffset().noShorterThan(growBanana.startOffset().plus(growBananaDuration).plus(Duration.of(5, Duration.MINUTES)))); + assertTrue(peelBanana.startOffset().noLongerThan(growBanana.startOffset().plus(growBananaDuration).plus(Duration.of(10, Duration.MINUTES)))); + } + + /** + * Allen Relation Equals. GrowBanana and DurationParameterActivity start and finish at the same time (they have equal durations) + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenEquals() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.DurationParameterActivity({duration: Temporal.Duration.from({ hours : 1})}), + startsAt: TimingConstraint.singleton(WindowProperty.START), + endsAt: TimingConstraint.singleton(WindowProperty.END) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var durativeActivities = planByActivityType.get("DurationParameterActivity"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, durativeActivities.size()); + assertEquals(1, growBananas.size()); + final var durativeActivity = durativeActivities.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + assertEquals(SerializedValue.of(1), growBanana.serializedActivity().getArguments().get("quantity")); + + // Checking both activities start at the same time + assertTrue(durativeActivity.startOffset().isEqualTo(growBanana.startOffset())); + + // Checking both activities end at the same time + final var activitytype = results.plan.getActivitiesByType().keySet().stream().filter(w->w.getName().equals("DurationParameterActivity")).findFirst().get(); + assertTrue(durativeActivity.startOffset().plus(results.plan.getActivitiesByType().get(activitytype).get(0).duration()).noShorterThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(10, Duration.MINUTES)))); + } + + /** + * Allen Relation Meets. peelBanana activity starts at the time growBanana finishes + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenMeets() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.PeelBanana({peelDirection: "fromStem"}), + startsAt: TimingConstraint.singleton(WindowProperty.END) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var peelBananas = planByActivityType.get("PeelBanana"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, peelBananas.size()); + assertEquals(1, growBananas.size()); + final var peelBanana = peelBananas.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + assertEquals(SerializedValue.of("fromStem"), peelBanana.serializedActivity().getArguments().get("peelDirection")); + assertEquals(SerializedValue.of(1), growBanana.serializedActivity().getArguments().get("quantity")); + + // Checking start of peelBanana corresponds to end of growBanana + assertTrue(peelBanana.startOffset().isEqualTo(growBanana.startOffset().plus(growBananaDuration))); + } + + /** + * Allen Relation Overlaps. DurationParameterActivity starts within [5,10] units of time before GrowBanana finishes + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenOverlaps() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.DurationParameterActivity({duration: Temporal.Duration.from({ hours : 1})}), + startsWithin: TimingConstraint.bounds(TimingConstraint.singleton(WindowProperty.END).minus(Temporal.Duration.from({ minutes : 10})), TimingConstraint.singleton(WindowProperty.END).minus(Temporal.Duration.from({minutes : 5}))) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var durationParameterActivities = planByActivityType.get("DurationParameterActivity"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, durationParameterActivities.size()); + assertEquals(1, growBananas.size()); + final var durationParameterActivity = durationParameterActivities.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + // Checking that DurationParameterActivity starts between [5,10] units of time before growBanana ends + assertTrue(durationParameterActivity.startOffset().noShorterThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(10, Duration.MINUTES)))); + assertTrue(durationParameterActivity.startOffset().noLongerThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(5, Duration.MINUTES)))); + } + + /** + * Allen Relation Contains. DurationParameterActivity starts within [5,10] units of time after growBanana starts and finishes within [5,10] units of time before growBanana finishes + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenContains() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.DurationParameterActivity({duration: Temporal.Duration.from({ minutes : 50})}), + startsWithin: TimingConstraint.bounds(TimingConstraint.singleton(WindowProperty.START).plus(Temporal.Duration.from({ minutes : 5})), TimingConstraint.singleton(WindowProperty.START).plus(Temporal.Duration.from({minutes : 10}))), + endsWithin: TimingConstraint.bounds(TimingConstraint.singleton(WindowProperty.END).minus(Temporal.Duration.from({ minutes : 10})), TimingConstraint.singleton(WindowProperty.END).minus(Temporal.Duration.from({ minutes : 5}))) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var durationParameterActivities = planByActivityType.get("DurationParameterActivity"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, durationParameterActivities.size()); + assertEquals(1, growBananas.size()); + final var durationParameterActivity = durationParameterActivities.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + + // Checking left margin of Contains relation + assertTrue(durationParameterActivity.startOffset().noShorterThan(growBanana.startOffset().plus(Duration.of(5, Duration.MINUTES)))); + assertTrue(durationParameterActivity.startOffset().noLongerThan(growBanana.startOffset().plus(Duration.of(10, Duration.MINUTES)))); + + // Checking right margin of Contains relation + final var activitytype = results.plan.getActivitiesByType().keySet().stream().filter(w->w.getName().equals("DurationParameterActivity")).findFirst(); + if (activitytype.isEmpty()) + fail("Could not find Coexistence Goal activity type"); + assertTrue(durationParameterActivity.startOffset().plus(results.plan.getActivitiesByType().get(activitytype.get()).get(0).duration()).noShorterThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(10, Duration.MINUTES)))); + assertTrue(durationParameterActivity.startOffset().plus(results.plan.getActivitiesByType().get(activitytype.get()).get(0).duration()).noLongerThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(5, Duration.MINUTES)))); + } + + /** + * Allen Relation Starts. peelBanana starts within [5, 10] units of time after growBanana starts + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenStarts() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.PeelBanana({peelDirection: "fromStem"}), + startsWithin: TimingConstraint.bounds(TimingConstraint.singleton(WindowProperty.START).plus(Temporal.Duration.from({ minutes : 5})), TimingConstraint.singleton(WindowProperty.START).plus(Temporal.Duration.from({ minutes : 10}))) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var peelBananas = planByActivityType.get("PeelBanana"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, peelBananas.size()); + assertEquals(1, growBananas.size()); + final var peelBanana = peelBananas.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + assertEquals(SerializedValue.of("fromStem"), peelBanana.serializedActivity().getArguments().get("peelDirection")); + assertEquals(SerializedValue.of(1), growBanana.serializedActivity().getArguments().get("quantity")); + + // Checking that peelBanana starts at the same time as growBanana + assertTrue(peelBanana.startOffset().noShorterThan(growBanana.startOffset().plus(Duration.of(5, Duration.MINUTES)))); + assertTrue(peelBanana.startOffset().noLongerThan(growBanana.startOffset().plus(Duration.of(10, Duration.MINUTES)))); + } + + + /** + * Allen Relation Contains. DurationParameterActivity finishes at the same time as growBanana + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenFinishesAt() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.DurationParameterActivity({duration: Temporal.Duration.from({ minutes : 50})}), + endsAt: TimingConstraint.singleton(WindowProperty.END) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var durationParameterActivities = planByActivityType.get("DurationParameterActivity"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, durationParameterActivities.size()); + assertEquals(1, growBananas.size()); + final var durationParameterActivity = durationParameterActivities.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + + // Checking that durationParameterActivities finishes at the same time as growBanana + final var activitytype = results.plan.getActivitiesByType().keySet().stream().filter(w->w.getName().equals("DurationParameterActivity")).findFirst(); + if (activitytype.isEmpty()) + fail("Could not find Coexistence Goal activity type"); + assertTrue(durationParameterActivity.startOffset().plus(results.plan.getActivitiesByType().get(activitytype.get()).get(0).duration()).isEqualTo(growBanana.startOffset().plus(growBananaDuration))); + } + + /** + * Allen Relation Contains. DurationParameterActivity finishes within [5, 10] units of time before growBanana finishes + */ + @Test + void testSingleActivityPlanSimpleCoexistenceGoal_AllenFinishesWithin() { + final var growBananaDuration = Duration.of(1, Duration.HOUR); + final var results = runScheduler( + BANANANATION, + List.of( + new ActivityDirective( + Duration.of(5, Duration.MINUTES), + "GrowBanana", + Map.of( + "quantity", SerializedValue.of(1), + "growingDuration", SerializedValue.of(growBananaDuration.in(Duration.MICROSECONDS))), + null, + true)), + List.of(new SchedulingGoal(new GoalId(0L), """ + export default () => Goal.CoexistenceGoal({ + forEach: ActivityExpression.ofType(ActivityTypes.GrowBanana), + activityTemplate: (span) => ActivityTemplates.DurationParameterActivity({duration: Temporal.Duration.from({ minutes : 50})}), + endsWithin: TimingConstraint.bounds(TimingConstraint.singleton(WindowProperty.END).minus(Temporal.Duration.from({ minutes : 10})), TimingConstraint.singleton(WindowProperty.END).minus(Temporal.Duration.from({ minutes : 5}))) + }) + """, true)), + PLANNING_HORIZON); + + assertEquals(1, results.scheduleResults.goalResults().size()); + final var goalResult = results.scheduleResults.goalResults().get(new GoalId(0L)); + + assertTrue(goalResult.satisfied()); + assertEquals(1, goalResult.createdActivities().size()); + for (final var activity : goalResult.createdActivities()) { + assertNotNull(activity); + } + for (final var activity : goalResult.satisfyingActivities()) { + assertNotNull(activity); + } + + final var planByActivityType = partitionByActivityType(results.updatedPlan()); + final var durationParameterActivities = planByActivityType.get("DurationParameterActivity"); + final var growBananas = planByActivityType.get("GrowBanana"); + assertEquals(1, durationParameterActivities.size()); + assertEquals(1, growBananas.size()); + final var durationParameterActivity = durationParameterActivities.iterator().next(); + final var growBanana = growBananas.iterator().next(); + + + // Checking that durationParameterActivities finishes at the same time as growBanana + final var activitytype = results.plan.getActivitiesByType().keySet().stream().filter(w->w.getName().equals("DurationParameterActivity")).findFirst(); + if (activitytype.isEmpty()) + fail("Could not find Coexistence Goal activity type"); + assertTrue(durationParameterActivity.startOffset().plus(results.plan.getActivitiesByType().get(activitytype.get()).get(0).duration()).noShorterThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(10, Duration.MINUTES)))); + assertTrue(durationParameterActivity.startOffset().plus(results.plan.getActivitiesByType().get(activitytype.get()).get(0).duration()).noLongerThan(growBanana.startOffset().plus(growBananaDuration).minus(Duration.of(5, Duration.MINUTES)))); + } + @Test void testStateCoexistenceGoal_greaterThan() { // Initial plant count is 200 in default configuration @@ -1540,10 +1981,10 @@ private SchedulingRunResults runScheduler( System.err.println(serializedReason); fail(serializedReason); } - return new SchedulingRunResults(((MockResultsProtocolWriter.Result.Success) result).results(), mockMerlinService.updatedPlan, plannedActivities); + return new SchedulingRunResults(((MockResultsProtocolWriter.Result.Success) result).results(), mockMerlinService.updatedPlan, mockMerlinService.plan, plannedActivities); } - record SchedulingRunResults(ScheduleResults scheduleResults, Collection updatedPlan, Map idToAct) {} + record SchedulingRunResults(ScheduleResults scheduleResults, Collection updatedPlan, Plan plan, Map idToAct) {} static MissionModelService.MissionModelTypes loadMissionModelTypesFromJar( final String jarPath,