diff --git a/pom.xml b/pom.xml index 7099dd8cbc..8729aa8f5c 100644 --- a/pom.xml +++ b/pom.xml @@ -894,6 +894,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..b7a2dbe14b --- /dev/null +++ b/timefold-solver/README.md @@ -0,0 +1,7 @@ +## OptaPlanner + +This module contains articles about OptaPlanner. + +### Relevant articles + +- [A Guide to OptaPlanner](https://www.baeldung.com/opta-planner) diff --git a/timefold-solver/pom.xml b/timefold-solver/pom.xml new file mode 100644 index 0000000000..2a219a593c --- /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..c755896201 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java @@ -0,0 +1,32 @@ +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; + } + + // ************************************************************************ + // Getters and setters + // ************************************************************************ + + 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..04087134c6 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java @@ -0,0 +1,57 @@ +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; + } + + // ************************************************************************ + // Getters and setters + // ************************************************************************ + + 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..85abc8eb66 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java @@ -0,0 +1,47 @@ +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 = null; + + // A no-arg constructor is required for @PlanningSolution annotated classes + public ShiftSchedule() { + } + + public ShiftSchedule(List employees, List shifts) { + this.employees = employees; + this.shifts = shifts; + } + + // ************************************************************************ + // Getters and setters + // ************************************************************************ + + 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/ShiftScheduleApp.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleApp.java new file mode 100644 index 0000000000..9c25922dea --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleApp.java @@ -0,0 +1,54 @@ +package com.baeldung.timefoldsolver; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import ai.timefold.solver.core.api.solver.Solver; +import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.config.solver.SolverConfig; + +public class ShiftScheduleApp { + + public static void main(String[] args) { + SolverFactory solverFactory = SolverFactory.create(new SolverConfig() + .withSolutionClass(ShiftSchedule.class) + .withEntityClasses(Shift.class) + .withConstraintProviderClass(ShiftScheduleConstraintProvider.class) + // The solver runs only for 5 seconds on this small dataset. + // It's recommended to run for at least 5 minutes ("5m") otherwise. + .withTerminationSpentLimit(Duration.ofSeconds(5))); + Solver solver = solverFactory.buildSolver(); + + ShiftSchedule problem = loadProblem(); + ShiftSchedule solution = solver.solve(problem); + printSolution(solution); + } + + private static 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 static void printSolution(ShiftSchedule solution) { + System.out.println("Shift assignments"); + for (Shift shift : solution.getShifts()) { + System.out.println(" " + shift.getStart().toLocalDate() + + " " + shift.getStart().toLocalTime() + " - " + shift.getEnd().toLocalTime() + + ": " + shift.getEmployee().getName()); + } + } + +} 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..0846429460 --- /dev/null +++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java @@ -0,0 +1,37 @@ +package com.baeldung.timefoldsolver; + +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; + +import static ai.timefold.solver.core.api.score.stream.Joiners.equal; + +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/ShiftScheduleConstraintProviderTest.java b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderTest.java new file mode 100644 index 0000000000..9979f5d5e0 --- /dev/null +++ b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderTest.java @@ -0,0 +1,50 @@ +package com.baeldung.timefoldsolver; + +import java.time.LocalDate; +import java.util.Set; + +import ai.timefold.solver.test.api.score.stream.ConstraintVerifier; +import org.junit.jupiter.api.Test; + +class ShiftScheduleConstraintProviderTest { + + 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 atMostOneShiftPerDay() { + 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 both A-B and B-A. To avoid that, use forEachUniquePair() in the constraint instead. + .penalizesBy(2); + 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 requiredSkill() { + 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); + constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill) + .given( + ann, + new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Waiter", ann)) + .penalizesBy(0); + } + +}