diff --git a/pom.xml b/pom.xml index eb780e5168..ac7d12a5a0 100644 --- a/pom.xml +++ b/pom.xml @@ -895,6 +895,7 @@ tensorflow-java testing-modules testing-modules/mockito-simple + timefold-solver vaadin vavr-modules vertx-modules @@ -1139,6 +1140,7 @@ tensorflow-java testing-modules testing-modules/mockito-simple + timefold-solver vaadin vavr-modules vertx-modules diff --git a/timefold-solver/README.md b/timefold-solver/README.md new file mode 100644 index 0000000000..1abc4d4ca0 --- /dev/null +++ b/timefold-solver/README.md @@ -0,0 +1,6 @@ +## Timefold Solver + +This module contains articles about (Timefold Solver)[https://timefold.ai]. + +### Relevant articles + diff --git a/timefold-solver/pom.xml b/timefold-solver/pom.xml new file mode 100644 index 0000000000..a16afb9e54 --- /dev/null +++ b/timefold-solver/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + timefold-solver + timefold-solver + + + com.baeldung + parent-modules + 1.0.0-SNAPSHOT + + + + + + ai.timefold.solver + timefold-solver-bom + ${version.ai.timefold.solver} + pom + import + + + + + + ai.timefold.solver + timefold-solver-core + + + ai.timefold.solver + timefold-solver-test + test + + + + + 17 + 17 + 1.4.0 + + + diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java new file mode 100644 index 0000000000..ef4752a8c6 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java @@ -0,0 +1,28 @@ +package com.baeldung.timefoldsolver; + +import java.util.Set; + +public class Employee { + + private String name; + private Set skills; + + public Employee(String name, Set skills) { + this.name = name; + this.skills = skills; + } + + @Override + public String toString() { + return name; + } + + public String getName() { + return name; + } + + public Set getSkills() { + return skills; + } + +} diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java new file mode 100644 index 0000000000..2ef2f2a1b9 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java @@ -0,0 +1,54 @@ +package com.baeldung.timefoldsolver; + +import java.time.LocalDateTime; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; + +@PlanningEntity +public class Shift { + + private LocalDateTime start; + private LocalDateTime end; + private String requiredSkill; + + @PlanningVariable + private Employee employee; + + // A no-arg constructor is required for @PlanningEntity annotated classes + public Shift() { + } + + public Shift(LocalDateTime start, LocalDateTime end, String requiredSkill) { + this(start, end, requiredSkill, null); + } + + public Shift(LocalDateTime start, LocalDateTime end, String requiredSkill, Employee employee) { + this.start = start; + this.end = end; + this.requiredSkill = requiredSkill; + this.employee = employee; + } + + @Override + public String toString() { + return start + " - " + end; + } + + public LocalDateTime getStart() { + return start; + } + + public LocalDateTime getEnd() { + return end; + } + + public String getRequiredSkill() { + return requiredSkill; + } + + public Employee getEmployee() { + return employee; + } + +} diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java new file mode 100644 index 0000000000..794c31e5b7 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java @@ -0,0 +1,43 @@ +package com.baeldung.timefoldsolver; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore; + +@PlanningSolution +public class ShiftSchedule { + + @ValueRangeProvider + private List employees; + @PlanningEntityCollectionProperty + private List shifts; + + @PlanningScore + private HardSoftScore score; + + // A no-arg constructor is required for @PlanningSolution annotated classes + public ShiftSchedule() { + } + + public ShiftSchedule(List employees, List shifts) { + this.employees = employees; + this.shifts = shifts; + } + + public List getEmployees() { + return employees; + } + + public List getShifts() { + return shifts; + } + + public HardSoftScore getScore() { + return score; + } + +} diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java new file mode 100644 index 0000000000..bd308dacbf --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java @@ -0,0 +1,35 @@ +package com.baeldung.timefoldsolver; + +import static ai.timefold.solver.core.api.score.stream.Joiners.equal; + +import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +public class ShiftScheduleConstraintProvider implements ConstraintProvider { + + @Override + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] { atMostOneShiftPerDay(constraintFactory), requiredSkill(constraintFactory) }; + } + + public Constraint atMostOneShiftPerDay(ConstraintFactory constraintFactory) { + return constraintFactory.forEach(Shift.class) + .join(Shift.class, equal(shift -> shift.getStart() + .toLocalDate()), equal(Shift::getEmployee)) + .filter((shift1, shift2) -> shift1 != shift2) + .penalize(HardSoftScore.ONE_HARD) + .asConstraint("At most one shift per day"); + } + + public Constraint requiredSkill(ConstraintFactory constraintFactory) { + return constraintFactory.forEach(Shift.class) + .filter(shift -> !shift.getEmployee() + .getSkills() + .contains(shift.getRequiredSkill())) + .penalize(HardSoftScore.ONE_HARD) + .asConstraint("Required skill"); + } + +} diff --git a/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderUnitTest.java b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderUnitTest.java new file mode 100644 index 0000000000..c8ec5b53c4 --- /dev/null +++ b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderUnitTest.java @@ -0,0 +1,52 @@ +package com.baeldung.timefoldsolver; + +import java.time.LocalDate; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import ai.timefold.solver.test.api.score.stream.ConstraintVerifier; + +class ShiftScheduleConstraintProviderUnitTest { + + private static final LocalDate MONDAY = LocalDate.of(2030, 4, 1); + private static final LocalDate TUESDAY = LocalDate.of(2030, 4, 2); + + ConstraintVerifier constraintVerifier = ConstraintVerifier.build(new ShiftScheduleConstraintProvider(), + ShiftSchedule.class, Shift.class); + + @Test + void givenTwoShiftsOnOneDay_whenApplyingAtMostOneShiftPerDayConstraint_thenPenalize() { + Employee ann = new Employee("Ann", null); + constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::atMostOneShiftPerDay) + .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), null, ann), new Shift(MONDAY.atTime(14, 0), MONDAY.atTime(22, 0), null, ann)) + // Penalizes by 2 because both {shiftA, shiftB} and {shiftB, shiftA} match. + // To avoid that, use forEachUniquePair(Shift) instead of forEach(Shift).join(Shift) in ShiftScheduleConstraintProvider.atMostOneShiftPerDay(). + .penalizesBy(2); + } + + @Test + void givenTwoShiftsOnDifferentDays_whenApplyingAtMostOneShiftPerDayConstraint_thenDoNotPenalize() { + Employee ann = new Employee("Ann", null); + constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::atMostOneShiftPerDay) + .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), null, ann), new Shift(TUESDAY.atTime(14, 0), TUESDAY.atTime(22, 0), null, ann)) + .penalizesBy(0); + } + + @Test + void givenEmployeeLacksRequiredSkill_whenApplyingRequiredSkillConstraint_thenPenalize() { + Employee ann = new Employee("Ann", Set.of("Waiter")); + constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill) + .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Cook", ann)) + .penalizesBy(1); + } + + @Test + void givenEmployeeHasRequiredSkill_whenApplyingRequiredSkillConstraint_thenDoNotPenalize() { + Employee ann = new Employee("Ann", Set.of("Waiter")); + constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill) + .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Waiter", ann)) + .penalizesBy(0); + } + +} diff --git a/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleSolverUnitTest.java b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleSolverUnitTest.java new file mode 100644 index 0000000000..873ce1a853 --- /dev/null +++ b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleSolverUnitTest.java @@ -0,0 +1,65 @@ +package com.baeldung.timefoldsolver; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore; +import ai.timefold.solver.core.api.solver.Solver; +import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; + +public class ShiftScheduleSolverUnitTest { + + private static final Logger logger = LoggerFactory.getLogger(ShiftScheduleSolverUnitTest.class); + + @Test + public void given3Employees5Shifts_whenSolve_thenScoreIsOptimalAndAllShiftsAssigned() { + SolverFactory solverFactory = SolverFactory.create(new SolverConfig().withSolutionClass(ShiftSchedule.class) + .withEntityClasses(Shift.class) + .withConstraintProviderClass(ShiftScheduleConstraintProvider.class) + // For this dataset, we know the optimal score in advance (which is normally not the case). + // So we can use .withBestScoreLimit() instead of .withTerminationSpentLimit(). + .withTerminationConfig(new TerminationConfig().withBestScoreLimit("0hard/0soft"))); + Solver solver = solverFactory.buildSolver(); + + ShiftSchedule problem = loadProblem(); + ShiftSchedule solution = solver.solve(problem); + assertThat(solution.getScore()).isEqualTo(HardSoftScore.ZERO); + assertThat(solution.getShifts().size()).isNotZero(); + for (Shift shift : solution.getShifts()) { + assertThat(shift.getEmployee()).isNotNull(); + } + printSolution(solution); + } + + private ShiftSchedule loadProblem() { + LocalDate monday = LocalDate.of(2030, 4, 1); + LocalDate tuesday = LocalDate.of(2030, 4, 2); + return new ShiftSchedule( + List.of(new Employee("Ann", Set.of("Bartender")), new Employee("Beth", Set.of("Waiter", "Bartender")), new Employee("Carl", Set.of("Waiter"))), + List.of(new Shift(monday.atTime(6, 0), monday.atTime(14, 0), "Waiter"), new Shift(monday.atTime(9, 0), monday.atTime(17, 0), "Bartender"), + new Shift(monday.atTime(14, 0), monday.atTime(22, 0), "Bartender"), new Shift(tuesday.atTime(6, 0), tuesday.atTime(14, 0), "Waiter"), + new Shift(tuesday.atTime(14, 0), tuesday.atTime(22, 0), "Bartender"))); + } + + private void printSolution(ShiftSchedule solution) { + logger.info("Shift assignments"); + for (Shift shift : solution.getShifts()) { + logger.info(" " + shift.getStart() + .toLocalDate() + " " + shift.getStart() + .toLocalTime() + " - " + shift.getEnd() + .toLocalTime() + ": " + shift.getEmployee() + .getName()); + } + } + +}