Skip to content

Commit b200f1e

Browse files
authored
Merge pull request #1846 from NASA-AMMOS/fix/scheduler-illegalstate-exception
Fix `IllegalStateException` when Simulating during Scheduling
2 parents 7b3f9a2 + 29577fd commit b200f1e

2 files changed

Lines changed: 105 additions & 19 deletions

File tree

e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/procedural/scheduling/BasicSchedulingTests.java

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import javax.json.Json;
1212
import java.io.IOException;
13+
import java.util.Comparator;
1314
import java.util.Objects;
1415

1516
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -34,7 +35,8 @@ void localBeforeEach() throws IOException {
3435
"Test Scheduling Procedure 1",
3536
dumbRecurrenceGoalJarId,
3637
specId,
37-
0
38+
0,
39+
true
3840
);
3941
}
4042

@@ -184,7 +186,7 @@ void saveActivityName() throws IOException {
184186
* Run a spec that includes unfinished activities
185187
*/
186188
@Test
187-
void IncludesUnfinishedActivities() throws IOException {
189+
void includesUnfinishedActivities() throws IOException {
188190
// Disable the recurrence goal to simplify scheduling results
189191
hasura.updateSchedulingSpecEnabled(dumbRecurrenceGoalId.invocationId(), false);
190192

@@ -226,4 +228,47 @@ void IncludesUnfinishedActivities() throws IOException {
226228
assertNull(act.duration());
227229
}
228230
}
231+
232+
/**
233+
* Scheduling can handle two activities with the same parameters located that start at the same time without
234+
* throwing an IllegalStateException.
235+
*
236+
* Uses DumbRecurrenceGoal for testing, as it will always place duplicate directives when the spec is rescheduled.
237+
*/
238+
@Test
239+
void duplicateActivitiesSupportedOnRerun() throws IOException {
240+
final var args = Json.createObjectBuilder().add("quantity", 2).add("biteSize", 1).build();
241+
hasura.updateSchedulingSpecGoalArguments(dumbRecurrenceGoalId.invocationId(), args);
242+
243+
hasura.awaitScheduling(planId);
244+
245+
final var initialActivities = hasura.getPlan(planId).activityDirectives();
246+
247+
// Rerun scheduling
248+
hasura.updatePlanRevisionSchedulingSpec(planId);
249+
hasura.awaitScheduling(planId);
250+
251+
final var updatedActivities = hasura.getPlan(planId).activityDirectives();
252+
253+
// Two new directives should have been placed on the plan
254+
assertEquals(2, initialActivities.size());
255+
assertEquals(4, updatedActivities.size());
256+
257+
// Sort the activities by start time
258+
initialActivities.sort(Comparator.comparing(Plan.ActivityDirective::startOffset));
259+
updatedActivities.sort(Comparator.comparing(Plan.ActivityDirective::startOffset));
260+
261+
// Check that the four activities are grouped into two sets of two.
262+
final var firstStartTime = initialActivities.getFirst().startOffset();
263+
assertEquals(2, updatedActivities.stream()
264+
.filter(dir -> dir.startOffset().equals(firstStartTime))
265+
.toList()
266+
.size());
267+
268+
final var secondStartTime = initialActivities.getLast().startOffset();
269+
assertEquals(2, updatedActivities.stream()
270+
.filter(dir -> dir.startOffset().equals(secondStartTime))
271+
.toList()
272+
.size());
273+
}
229274
}

scheduler-driver/src/main/java/gov/nasa/jpl/aerie/scheduler/simulation/SimulationFacade.java

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
import gov.nasa.jpl.aerie.types.ActivityDirective;
1010
import gov.nasa.jpl.aerie.types.ActivityDirectiveId;
1111

12+
import java.util.ArrayList;
1213
import java.util.Collection;
1314
import java.util.HashMap;
1415
import java.util.HashSet;
16+
import java.util.List;
1517
import java.util.Map;
1618
import java.util.Optional;
1719
import java.util.Set;
1820
import java.util.function.Supplier;
19-
import java.util.stream.Collectors;
2021

2122
public interface SimulationFacade {
2223
void setInitialSimResults(SimulationData simulationData);
@@ -82,28 +83,68 @@ public boolean equals(Object other){
8283
* `map` contains a mapping between *only the IDs that are different* between the two plans.
8384
*/
8485
public Optional<Map<ActivityDirectiveId, ActivityDirectiveId>> equalsWithIdMap(PlanSimCorrespondence other) {
86+
// If the simulations have a different amount of directives, they must not be equal
87+
if(directiveIdActivityDirectiveMap.size() != other.directiveIdActivityDirectiveMap.size()) {
88+
return Optional.empty();
89+
}
90+
91+
// Build the maps of directives -> ids from the ids -> directives map
92+
// Because multiple activities on a plan can be identical sans id, the inverted maps to a list of ids
93+
final HashMap<ActivityDirective, List<ActivityDirectiveId>> thisInverted = HashMap.newHashMap(directiveIdActivityDirectiveMap.size());
94+
directiveIdActivityDirectiveMap.forEach(
95+
(key, value) -> {
96+
thisInverted.putIfAbsent(value, new ArrayList<>());
97+
thisInverted.get(value).add(key);
98+
});
99+
100+
final HashMap<ActivityDirective, List<ActivityDirectiveId>> otherInverted = HashMap.newHashMap(other.directiveIdActivityDirectiveMap.size());
101+
other.directiveIdActivityDirectiveMap.forEach(
102+
(key, value) -> {
103+
otherInverted.putIfAbsent(value, new ArrayList<>());
104+
otherInverted.get(value).add(key);
105+
});
106+
107+
// If these maps have different sizes, then the simulations must not be equal
108+
if(thisInverted.size() != otherInverted.size()) {
109+
return Optional.empty();
110+
}
111+
112+
// Check every entry for equality while building up the return mapping
85113
final var result = new HashMap<ActivityDirectiveId, ActivityDirectiveId>();
86-
final var thisInverse = directiveIdActivityDirectiveMap.entrySet()
87-
.stream()
88-
.collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey));
89-
final var otherInverse = other.directiveIdActivityDirectiveMap.entrySet()
90-
.stream()
91-
.collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey));
92-
for (var entry : thisInverse.entrySet()) {
93-
final var otherId = otherInverse.remove(entry.getKey());
94-
if (otherId == null) {
114+
for (final var entry : thisInverted.entrySet()) {
115+
// Check that at least one instance of this directive is on the other plan
116+
if(!otherInverted.containsKey(entry.getKey())){
117+
return Optional.empty();
118+
}
119+
120+
final var thisIds = entry.getValue();
121+
final var otherIds = otherInverted.get(entry.getKey());
122+
123+
// Check that there is the same number of instances of this directive on the other plan
124+
if (thisIds.size() != otherIds.size()) {
95125
return Optional.empty();
96126
}
97-
if (!otherId.equals(entry.getValue())) {
98-
result.put(entry.getValue(), otherId);
127+
128+
// If the directive is unique (the most common case), then the two ids can be directly compared
129+
if(thisIds.size() == 1) {
130+
if(!thisIds.getFirst().equals(otherIds.getFirst())) {
131+
result.put(thisIds.getFirst(), otherIds.getFirst());
132+
}
133+
continue;
99134
}
100-
}
101135

102-
if (otherInverse.isEmpty()) {
103-
return Optional.of(result);
104-
} else {
105-
return Optional.empty();
136+
// Filter out the ids that are on both lists, as this method does not provide a mapping for ids that have not changed
137+
final List<ActivityDirectiveId> toRemove = thisIds.stream().filter(otherIds::contains).toList();
138+
thisIds.removeAll(toRemove);
139+
otherIds.removeAll(toRemove);
140+
141+
// Add the remaining mappings to the result
142+
for(int i = 0; i < thisIds.size(); ++i) {
143+
result.put(thisIds.get(i), otherIds.get(i));
144+
}
106145
}
146+
147+
return Optional.of(result);
107148
}
108149
}
109150
}

0 commit comments

Comments
 (0)