S12 - turned schedule status into enum and some other review comments
All checks were successful
Build, test and publish / verify (push) Successful in 7m6s

This commit is contained in:
2026-11-04 08:05:39 +01:00
parent c241392132
commit a3cff48a92
12 changed files with 112 additions and 41 deletions

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingPhase {
PREPARING,
ATTENDANCE,
SPREAD,
VARIETY
}

View File

@@ -1,4 +1,4 @@
package nl.agilecc.agiledancers.model.scheduling;
/** Progress is observational; intermediate assignments are never published or applied. */
public record SchedulingProgress(String phase, boolean feasible) {}
public record SchedulingProgress(SchedulingPhase phase, boolean feasible) {}

View File

@@ -14,6 +14,7 @@ import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
/** Owns background tasks and publishes current state independently of any HTTP connection. */
@@ -44,7 +45,8 @@ public class SchedulingRuns {
executor.submit(() -> calculate(run, input));
} catch (final RuntimeException failure) {
schools.endScheduling(showId);
publish(run.completed("SOLVER_FAILED", "Berekening kon niet starten. Bestaande indeling behouden.", null));
publish(run.completed(
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
throw failure;
}
return run;
@@ -86,7 +88,7 @@ public class SchedulingRuns {
schools.finishScheduling(run.schoolId(), input, result);
completed = latest(run)
.completed(
result.optimal() ? "OPTIMAL" : "VALID",
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
result.optimal()
? "Optimaal conceptrooster."
: "Geldig conceptrooster; optimaliteit niet bewezen.",
@@ -94,16 +96,22 @@ public class SchedulingRuns {
} catch (final ManagementException failure) {
completed = failed(run, failure);
} catch (final RuntimeException | LinkageError failure) {
completed =
latest(run).completed("SOLVER_FAILED", "Berekening mislukt. Bestaande indeling behouden.", null);
completed = latest(run)
.completed(SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null);
}
finish(completed);
}
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
final String outcome = failure instanceof final SchedulingFailure scheduling
? scheduling.getOutcome().name()
: (failure.getKind() == ManagementExceptionKind.CONFLICT ? "STALE" : "INVALID_INPUT");
final SchedulingState outcome = failure instanceof final SchedulingFailure scheduling
? switch (scheduling.getOutcome()) {
case INFEASIBLE -> SchedulingState.INFEASIBLE;
case NO_SOLUTION -> SchedulingState.NO_SOLUTION;
case SOLVER_FAILED -> SchedulingState.SOLVER_FAILED;
}
: (failure.getKind() == ManagementExceptionKind.CONFLICT
? SchedulingState.STALE
: SchedulingState.INVALID_INPUT);
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
}

View File

@@ -2,6 +2,7 @@ package nl.agilecc.agiledancers.service.model;
import java.time.Instant;
import java.util.UUID;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
@@ -13,7 +14,7 @@ public record SchedulingRun(
String showName,
Instant startedAt,
Instant finishedAt,
String state,
SchedulingState state,
SchedulingProgress progress,
String message,
SchedulingSummary summary) {
@@ -25,8 +26,8 @@ public record SchedulingRun(
name,
Instant.now(),
null,
"RUNNING",
new SchedulingProgress("PREPARING", false),
SchedulingState.RUNNING,
new SchedulingProgress(SchedulingPhase.PREPARING, false),
"Berekening gestart.",
null);
}
@@ -45,7 +46,8 @@ public record SchedulingRun(
null);
}
public SchedulingRun completed(final String outcome, final String explanation, final SchedulingSummary scores) {
public SchedulingRun completed(
final SchedulingState outcome, final String explanation, final SchedulingSummary scores) {
return new SchedulingRun(
runId,
schoolId,

View File

@@ -0,0 +1,12 @@
package nl.agilecc.agiledancers.service.model;
public enum SchedulingState {
RUNNING,
OPTIMAL,
VALID,
STALE,
INVALID_INPUT,
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -10,6 +10,7 @@ import java.util.List;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.SchedulingFailure;
@@ -46,10 +47,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
SchedulingResult best = null;
final List<LinearExpr> objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety);
final List<String> phases = List.of("ATTENDANCE", "SPREAD", "VARIETY");
final List<SchedulingPhase> phases =
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY);
for (int stage = 0; stage < objectives.size(); stage++) {
final LinearExpr objective = objectives.get(stage);
final String phase = phases.get(stage);
final SchedulingPhase phase = phases.get(stage);
observer.accept(new SchedulingProgress(phase, best != null));
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) {

View File

@@ -2,15 +2,16 @@ package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.CpSolverSolutionCallback;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */
public class SchedulingSolutionObserver extends CpSolverSolutionCallback {
private final Consumer<SchedulingProgress> observer;
private final String phase;
private final SchedulingPhase phase;
private boolean reported;
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final String phase) {
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final SchedulingPhase phase) {
this.observer = observer;
this.phase = phase;
}

View File

@@ -10,6 +10,7 @@ import java.util.Set;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
@@ -26,11 +27,11 @@ class SchedulingProgressTest {
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input, updates::add);
assertTrue(result.optimal());
assertEquals(
List.of("ATTENDANCE", "SPREAD", "VARIETY"),
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY),
updates.stream().map(SchedulingProgress::phase).distinct().toList());
assertEquals(new SchedulingProgress("ATTENDANCE", false), updates.getFirst());
assertTrue(updates.contains(new SchedulingProgress("ATTENDANCE", true)));
assertTrue(updates.contains(new SchedulingProgress("VARIETY", true)));
assertEquals(new SchedulingProgress(SchedulingPhase.ATTENDANCE, false), updates.getFirst());
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.ATTENDANCE, true)));
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.VARIETY, true)));
}
public @Test void noSolutionAndInfeasibleHaveStableDistinctOutcomes() {

View File

@@ -23,6 +23,7 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
@@ -41,7 +42,7 @@ class SchedulingRunsTest {
final Runnable unsubscribe = runs.subscribe(updates::set);
assertTrue(updates.get().isEmpty());
final SchedulingRun started = runs.start(school, show.getId());
assertEquals("RUNNING", started.state());
assertEquals(SchedulingState.RUNNING, started.state());
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(started, runs.status(school, show.getId()));
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
@@ -51,12 +52,12 @@ class SchedulingRunsTest {
assertEquals(started, reconnect.get().getFirst());
release.countDown();
final SchedulingRun finished = await(runs, show);
assertEquals("OPTIMAL", finished.state());
assertEquals(SchedulingState.OPTIMAL, finished.state());
assertEquals(0, finished.summary().performanceSpread());
assertTrue(finished.progress().feasible());
assertTrue(finished.finishedAt().compareTo(finished.startedAt()) >= 0);
assertFalse(show.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals("RUNNING", updates.get().getFirst().state());
assertEquals(SchedulingState.RUNNING, updates.get().getFirst().state());
disconnect.run();
assertNotEquals(started.runId(), runs.start(school, show.getId()).runId());
await(runs, show);
@@ -78,9 +79,9 @@ class SchedulingRunsTest {
assertTrue(entered.await(10, TimeUnit.SECONDS));
schools.touch(first);
release.countDown();
assertEquals("STALE", await(runs, first).state());
assertEquals(SchedulingState.STALE, await(runs, first).state());
assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty());
assertEquals("OPTIMAL", await(runs, second).state());
assertEquals(SchedulingState.OPTIMAL, await(runs, second).state());
} finally {
release.countDown();
runs.close();
@@ -112,7 +113,7 @@ class SchedulingRunsTest {
try {
runs.start(school, show.getId());
final SchedulingRun failed = await(runs, show);
assertNotEquals("RUNNING", failed.state());
assertNotEquals(SchedulingState.RUNNING, failed.state());
assertTrue(failed.message().contains("behouden"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
@@ -127,10 +128,11 @@ class SchedulingRunsTest {
false, new OrToolsSchedulingEngine().solve(input).assignments());
final var runs = new SchedulingRuns(schools, engine);
runs.start(school, show.getId());
assertEquals("VALID", await(runs, show).state());
assertEquals(SchedulingState.VALID, await(runs, show).state());
runs.close();
assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId()));
assertEquals("SOLVER_FAILED", runs.status(school, show.getId()).state());
assertEquals(
SchedulingState.SOLVER_FAILED, runs.status(school, show.getId()).state());
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
}
@@ -170,11 +172,11 @@ class SchedulingRunsTest {
private SchedulingRun await(final SchedulingRuns runs, final Show show) {
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
SchedulingRun status = runs.status(school, show.getId());
while (status.state().equals("RUNNING") && System.nanoTime() < deadline) {
while (status.state().equals(SchedulingState.RUNNING) && System.nanoTime() < deadline) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
status = runs.status(school, show.getId());
}
assertNotEquals("RUNNING", status.state());
assertNotEquals(SchedulingState.RUNNING, status.state());
return status;
}
}