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 2c82abf818..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 @@ -79,8 +79,8 @@ public static Goal goalOfGoalSpecifier( final var endConstraint = g.endConstraint().get(); if (endConstraint instanceof SchedulingDSL.TimingConstraint.ActivityTimingConstraint e) { builder.endsAt(makeTimeExpressionRelativeFixed(e)); - } if (endConstraint instanceof SchedulingDSL.TimingConstraint.ActivityTimingConstraintFlexibleRange e) { - builder.startsAt(new TimeExpressionBetween(makeTimeExpressionRelativeFixed(e.lowerBound()), makeTimeExpressionRelativeFixed(e.upperBound()))); + } 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); } 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 45dc9f8ba0..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 | ActivityTimingConstraintFlexibleRange | undefined, - endConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | ActivityTimingConstraintFlexibleRange | undefined, + startConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | ActivityTimingConstraintInterval | undefined, + endConstraint: ActivityTimingConstraintSingleton | ActivityTimingConstraintRange | ActivityTimingConstraintInterval | undefined, shouldRollbackIfUnsatisfied: boolean } @@ -138,12 +138,11 @@ export interface ActivityTimingConstraintRange { singleton: false } -export interface ActivityTimingConstraintFlexibleRange { - lowerBound: ActivityTimingConstraintSingleton, - upperBound: ActivityTimingConstraintSingleton, - 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 4735fd0e54..cce1dc6196 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 @@ -10,7 +10,6 @@ import * as WindowsEDSL from "./constraints-edsl-fluent-api.js"; import {ActivityInstance} from "./constraints-edsl-fluent-api.js"; import * as ConstraintsAST from "./constraints-ast.js"; import {makeArgumentsDiscreteProfiles} from "./scheduler-mission-model-generated-code"; -import type {ActivityTimingConstraintFlexibleRange} from "./scheduler-ast.js"; type WindowProperty = AST.WindowProperty type TimingConstraintOperator = AST.TimingConstraintOperator @@ -520,7 +519,7 @@ 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 | FlexibleRangeTimingConstraint } +export type StartTimingConstraint = { startsAt: SingletonTimingConstraint | SingletonTimingConstraintNoOperator } | { startsWithin: RangeTimingConstraint | FlexibleRangeTimingConstraint} /** * An EndTimingConstraint is a constraint applying on the end time of an activity template. */ @@ -698,17 +697,17 @@ export class RangeTimingConstraint { } /** - * A flexible range timing constraint specifies that the start or the end time of an activity must be within certain bounds. Use {@link TimingConstraint.singleton} to specify the bounds. + * 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.ActivityTimingConstraintFlexibleRange + public readonly __astNode: AST.ActivityTimingConstraintInterval /** @internal **/ - private constructor(__astNode: AST.ActivityTimingConstraintFlexibleRange) { + private constructor(__astNode: AST.ActivityTimingConstraintInterval) { this.__astNode = __astNode; } /** @internal **/ - public static new(__astNode: AST.ActivityTimingConstraintFlexibleRange): FlexibleRangeTimingConstraint { + public static new(__astNode: AST.ActivityTimingConstraintInterval): FlexibleRangeTimingConstraint { return new FlexibleRangeTimingConstraint(__astNode); } } 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 567795aecd..c8f6439d6c 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 @@ -39,6 +39,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(); @@ -87,7 +88,7 @@ public PlanMetadata getPlanMetadata(final PlanId planId) this.missionModelInfo.get().config()); } - @Override + @Override public MerlinPlan getPlanActivityDirectives(final PlanMetadata planMetadata, final Problem mission) { // TODO this gets the planMetadata from above @@ -120,6 +121,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/SchedulingIntegrationTests.java b/scheduler-worker/src/test/java/gov/nasa/jpl/aerie/scheduler/worker/services/SchedulingIntegrationTests.java index ccc775b0fe..4a65e7c366 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 @@ -46,6 +46,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; @@ -744,6 +746,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 @@ -1468,10 +1909,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,