Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: penalizing or rewarding constraints with overlapping time for Employee Scheduling #484

Merged
merged 7 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.acme.employeescheduling.domain;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.Objects;

import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
Expand Down Expand Up @@ -89,6 +92,20 @@ public void setEmployee(Employee employee) {
this.employee = employee;
}

public long getOverlappingDurationInMinutes(LocalDate date) {
LocalDateTime startDateTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endDateTime = LocalDateTime.of(date, LocalTime.MAX);
return getOverlappingDurationInMinutes(startDateTime, endDateTime, getStart(), getEnd());
}

private long getOverlappingDurationInMinutes(LocalDateTime firstStartDateTime, LocalDateTime firstEndDateTime,
LocalDateTime secondStartDateTime, LocalDateTime secondEndDateTime) {
LocalDateTime maxStartTime = firstStartDateTime.isAfter(secondStartDateTime) ? firstStartDateTime : secondStartDateTime;
LocalDateTime minEndTime = firstEndDateTime.isBefore(secondEndDateTime) ? firstEndDateTime : secondEndDateTime;
long minutes = maxStartTime.until(minEndTime, ChronoUnit.MINUTES);
return minutes > 0 ? minutes : 0;
zepfred marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
public String toString() {
return location + " " + start + "-" + end;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package org.acme.employeescheduling.solver;

import static ai.timefold.solver.core.api.score.stream.Joiners.equal;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.function.Function;

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 ai.timefold.solver.core.api.score.stream.Joiners;

import org.acme.employeescheduling.domain.Employee;
import org.acme.employeescheduling.domain.Shift;

public class EmployeeSchedulingConstraintProvider implements ConstraintProvider {
Expand All @@ -27,10 +29,6 @@ private static int getMinuteOverlap(Shift shift1, Shift shift2) {
(shift1End.isBefore(shift2End)) ? shift1End : shift2End).toMinutes();
}

private static int getShiftDurationInMinutes(Shift shift) {
return (int) Duration.between(shift.getStart(), shift.getEnd()).toMinutes();
}

@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[] {
Expand All @@ -54,18 +52,19 @@ Constraint requiredSkill(ConstraintFactory constraintFactory) {
}

Constraint noOverlappingShifts(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class, Joiners.equal(Shift::getEmployee),
Joiners.overlapping(Shift::getStart, Shift::getEnd))
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
Joiners.overlapping(Shift::getStart, Shift::getEnd))
.penalize(HardSoftScore.ONE_HARD,
EmployeeSchedulingConstraintProvider::getMinuteOverlap)
.asConstraint("Overlapping shift");
}

Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class,
Joiners.equal(Shift::getEmployee),
Joiners.lessThanOrEqual(Shift::getEnd, Shift::getStart))
.filter((firstShift, secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 10)
equal(Shift::getEmployee),
Joiners.lessThanOrEqual(Shift::getEnd, Shift::getStart))
triceo marked this conversation as resolved.
Show resolved Hide resolved
.filter((firstShift,
secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 10)
.penalize(HardSoftScore.ONE_HARD,
(firstShift, secondShift) -> {
int breakLength = (int) Duration.between(firstShift.getEnd(), secondShift.getStart()).toMinutes();
Expand All @@ -75,49 +74,44 @@ Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {
}

Constraint oneShiftPerDay(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class, Joiners.equal(Shift::getEmployee),
Joiners.equal(shift -> shift.getStart().toLocalDate()))
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
equal(shift -> shift.getStart().toLocalDate()))
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Max one shift per day");
}

Constraint unavailableEmployee(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> {
Set<LocalDate> unavailableDates = shift.getEmployee().getUnavailableDates();
return unavailableDates.contains(shift.getStart().toLocalDate())
// The contains() check is ignored for a shift ends at midnight (00:00:00).
|| (shift.getEnd().isAfter(shift.getStart().toLocalDate().plusDays(1).atStartOfDay())
&& unavailableDates.contains(shift.getEnd().toLocalDate()));
})
.penalize(HardSoftScore.ONE_HARD, EmployeeSchedulingConstraintProvider::getShiftDurationInMinutes)
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
.flattenLast(Employee::getUnavailableDates)
.filter((shift, unavailableDate) -> shift.getStart().toLocalDate().equals(unavailableDate)
|| shift.getEnd().toLocalDate().equals(unavailableDate))
zepfred marked this conversation as resolved.
Show resolved Hide resolved
.penalize(HardSoftScore.ONE_HARD,
(shift, unavailableDate) -> (int) shift.getOverlappingDurationInMinutes(unavailableDate))
.asConstraint("Unavailable employee");
}

Constraint undesiredDayForEmployee(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> {
Set<LocalDate> undesiredDates = shift.getEmployee().getUndesiredDates();
return undesiredDates.contains(shift.getStart().toLocalDate())
// The contains() check is ignored for a shift ends at midnight (00:00:00).
|| (shift.getEnd().isAfter(shift.getStart().toLocalDate().plusDays(1).atStartOfDay())
&& undesiredDates.contains(shift.getEnd().toLocalDate()));
})
.penalize(HardSoftScore.ONE_SOFT, EmployeeSchedulingConstraintProvider::getShiftDurationInMinutes)
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
.flattenLast(Employee::getUndesiredDates)
.filter((shift, undesiredDate) -> shift.getStart().toLocalDate().equals(undesiredDate)
|| shift.getEnd().toLocalDate().equals(undesiredDate))
.penalize(HardSoftScore.ONE_SOFT,
(shift, undesiredDate) -> (int) shift.getOverlappingDurationInMinutes(undesiredDate))
.asConstraint("Undesired day for employee");

}

Constraint desiredDayForEmployee(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> {
Set<LocalDate> desiredDates = shift.getEmployee().getDesiredDates();
return desiredDates.contains(shift.getStart().toLocalDate())
// The contains() check is ignored for a shift ends at midnight (00:00:00).
|| (shift.getEnd().isAfter(shift.getStart().toLocalDate().plusDays(1).atStartOfDay())
&& desiredDates.contains(shift.getEnd().toLocalDate()));
})
.reward(HardSoftScore.ONE_SOFT, EmployeeSchedulingConstraintProvider::getShiftDurationInMinutes)
.asConstraint("Desired day for employee");
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
.flattenLast(Employee::getDesiredDates)
.filter((shift, desiredDate) -> shift.getStart().toLocalDate().equals(desiredDate)
|| shift.getEnd().toLocalDate().equals(desiredDate))
.reward(HardSoftScore.ONE_SOFT,
(shift, desiredDate) -> (int) shift.getOverlappingDurationInMinutes(desiredDate))
.asConstraint("Desired day for employee");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@QuarkusTest
class EmployeeSchedulingConstraintProviderTest {
private static final LocalDate DAY_1 = LocalDate.of(2021, 2, 1);
private static final LocalDate DAY_3 = LocalDate.of(2021, 2, 3);

private static final LocalDateTime DAY_START_TIME = DAY_1.atTime(LocalTime.of(9, 0));
private static final LocalDateTime DAY_END_TIME = DAY_1.atTime(LocalTime.of(17, 0));
Expand Down Expand Up @@ -129,7 +130,7 @@ void atLeast10HoursBetweenConsecutiveShifts() {

@Test
void unavailableEmployee() {
Employee employee1 = new Employee("Amy", null, Set.of(DAY_1), null, null);
Employee employee1 = new Employee("Amy", null, Set.of(DAY_1, DAY_3), null, null);
Employee employee2 = new Employee("Beth", null, Set.of(), null, null);
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::unavailableEmployee)
.given(employee1, employee2,
Expand All @@ -138,7 +139,7 @@ void unavailableEmployee() {
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::unavailableEmployee)
.given(employee1, employee2,
new Shift("1", DAY_START_TIME.minusDays(1), DAY_END_TIME, "Location", "Skill", employee1))
.penalizesBy((int) Duration.ofHours(32).toMinutes());
.penalizesBy((int) Duration.ofHours(17).toMinutes());
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::unavailableEmployee)
.given(employee1, employee2,
new Shift("1", DAY_START_TIME.plusDays(1), DAY_END_TIME.plusDays(1), "Location", "Skill", employee1))
Expand All @@ -151,7 +152,7 @@ void unavailableEmployee() {

@Test
void undesiredDayForEmployee() {
Employee employee1 = new Employee("Amy", null, null, Set.of(DAY_1), null);
Employee employee1 = new Employee("Amy", null, null, Set.of(DAY_1, DAY_3), null);
Employee employee2 = new Employee("Beth", null, null, Set.of(), null);
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::undesiredDayForEmployee)
.given(employee1, employee2,
Expand All @@ -160,7 +161,7 @@ void undesiredDayForEmployee() {
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::undesiredDayForEmployee)
.given(employee1, employee2,
new Shift("1", DAY_START_TIME.minusDays(1), DAY_END_TIME, "Location", "Skill", employee1))
.penalizesBy((int) Duration.ofHours(32).toMinutes());
.penalizesBy((int) Duration.ofHours(17).toMinutes());
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::undesiredDayForEmployee)
.given(employee1, employee2,
new Shift("1", DAY_START_TIME.plusDays(1), DAY_END_TIME.plusDays(1), "Location", "Skill", employee1))
Expand All @@ -173,7 +174,7 @@ void undesiredDayForEmployee() {

@Test
void desiredDayForEmployee() {
Employee employee1 = new Employee("Amy", null, null, null, Set.of(DAY_1));
Employee employee1 = new Employee("Amy", null, null, null, Set.of(DAY_1, DAY_3));
Employee employee2 = new Employee("Beth", null, null, null, Set.of());
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::desiredDayForEmployee)
.given(employee1, employee2,
Expand All @@ -182,7 +183,7 @@ void desiredDayForEmployee() {
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::desiredDayForEmployee)
.given(employee1, employee2,
new Shift("1", DAY_START_TIME.minusDays(1), DAY_END_TIME, "Location", "Skill", employee1))
.rewardsWith((int) Duration.ofHours(32).toMinutes());
.rewardsWith((int) Duration.ofHours(17).toMinutes());
constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::desiredDayForEmployee)
.given(employee1, employee2,
new Shift("1", DAY_START_TIME.plusDays(1), DAY_END_TIME.plusDays(1), "Location", "Skill", employee1))
Expand Down
51 changes: 30 additions & 21 deletions python/employee-scheduling/src/employee_scheduling/constraints.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from timefold.solver.score import (constraint_provider, ConstraintFactory, Joiners, HardSoftScore)
from datetime import datetime, time, timedelta
from datetime import datetime, time, timedelta, date

from .domain import Employee, Shift

Expand All @@ -8,8 +8,20 @@ def get_minute_overlap(shift1: Shift, shift2: Shift) -> int:
return (min(shift1.end, shift2.end) - max(shift1.start, shift2.start)).total_seconds() // 60


def get_shift_duration_in_minutes(shift: Shift) -> int:
return (shift.end - shift.start).total_seconds() // 60
def overlapping_in_minutes(first_start_datetime: datetime, first_end_datetime: datetime,
second_start_datetime: datetime, second_end_datetime: datetime) -> int:
latest_start = max(first_start_datetime, second_start_datetime)
earliest_end = min(first_end_datetime, second_end_datetime)
delta = (earliest_end - latest_start).total_seconds() / 60
return max(0, delta)


def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
overlap = 0
start_date_time = datetime.combine(dt, datetime.max.time())
end_date_time = datetime.combine(dt, datetime.min.time())
overlap += overlapping_in_minutes(start_date_time, end_date_time, shift.start, shift.end)
return overlap


@constraint_provider
Expand Down Expand Up @@ -73,38 +85,35 @@ def one_shift_per_day(constraint_factory: ConstraintFactory):

def unavailable_employee(constraint_factory: ConstraintFactory):
return (constraint_factory.for_each(Shift)
.filter(lambda shift: shift.start.date() in shift.employee.unavailable_dates or (
# The in check is ignored for a shift ends at midnight (00:00:00).
shift.end.time() != datetime.min.time()
and shift.end.date() in shift.employee.unavailable_dates)
)
.join(Employee, Joiners.equal(lambda shift: shift.employee, lambda employee: employee))
.flatten_last(lambda employee: employee.unavailable_dates)
.filter(
lambda shift, unavailable_date: shift.start.date() == unavailable_date or shift.end.date() == unavailable_date)
.penalize(HardSoftScore.ONE_HARD,
lambda shift: get_shift_duration_in_minutes(shift))
lambda shift, unavailable_date: get_shift_overlapping_duration_in_minutes(shift,
unavailable_date))
.as_constraint("Unavailable employee")
)


def undesired_day_for_employee(constraint_factory: ConstraintFactory):
return (constraint_factory.for_each(Shift)
.filter(lambda shift: shift.start.date() in shift.employee.undesired_dates or (
# The in check is ignored for a shift ends at midnight (00:00:00).
shift.end.time() != datetime.min.time()
and shift.end.date() in shift.employee.undesired_dates)
)
.join(Employee, Joiners.equal(lambda shift: shift.employee, lambda employee: employee))
.flatten_last(lambda employee: employee.undesired_dates)
.filter(
lambda shift, undesired_date: shift.start.date() == undesired_date or shift.end.date() == undesired_date)
.penalize(HardSoftScore.ONE_SOFT,
lambda shift: get_shift_duration_in_minutes(shift))
lambda shift, undesired_date: get_shift_overlapping_duration_in_minutes(shift, undesired_date))
.as_constraint("Undesired day for employee")
)


def desired_day_for_employee(constraint_factory: ConstraintFactory):
return (constraint_factory.for_each(Shift)
.filter(lambda shift: shift.start.date() in shift.employee.desired_dates or (
# The in check is ignored for a shift ends at midnight (00:00:00).
shift.end.time() != datetime.min.time()
and shift.end.date() in shift.employee.desired_dates)
)
.join(Employee, Joiners.equal(lambda shift: shift.employee, lambda employee: employee))
.flatten_last(lambda employee: employee.desired_dates)
.filter(lambda shift, desired_date: shift.start.date() == desired_date or shift.end.date() == desired_date)
.reward(HardSoftScore.ONE_SOFT,
lambda shift: get_shift_duration_in_minutes(shift))
lambda shift, desired_date: get_shift_overlapping_duration_in_minutes(shift, desired_date))
.as_constraint("Desired day for employee")
)
Loading
Loading