Search code examples
optaplanner

Constraint for covering all shifts


In a simple rostering problem, I want OptaPlanner to return "holes" in my schedule so certain shifts are uncovered if no employees are available with the required skill.

Assume 3 basic classes and that I want to penalize uncovered shifts only with HardMediumSoftScore.ONE_SOFT.

How do I write such a constraint?

Employee.java

public class Employee {

    private long id = 0L;

    private List<Skill> skills;

Shift.java

@PlanningEntity
public class Shift {

    private RequiredSkills

    @PlanningVariable(valueRangeProviderRefs = "employeeRange")
    private Employee employee;

    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
    private OffsetDateTime start;

    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
    private OffsetDateTime end;

Schedule.java

@PlanningSolution
public class Schedule {

    @PlanningEntityCollectionProperty
    private List<Shift> shifts;

    @ProblemFactCollectionProperty
    @ValueRangeProvider(id = "employeeRange")
    private List<Employee> employees;

Assume further a simple ConstraintProvider

public class ScheduleConstraints implements ConstraintProvider {

    @Override
    public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {

        return new Constraint[]{
            requiredSkill(constraintFactory),
        };
    }

    private Constraint requiredSkill(ConstraintFactory constraintFactory) {
        return constraintFactory.from(Shift.class)
                .filter(shift -> {
                    return !shift.getEmployee().hasSkills(shift.getSkills());
                }).penalize("Required skill", HardMediumSoftScore.ONE_HARD);
    }

Here is my attempt. Is it good?

private Constraint allShiftsMustBeCovered(ConstraintFactory constraintFactory) {
        return constraintFactory.fromUnfiltered(Shift.class)
                .filter(shift-> {
                    return shift.getEmployee().getId() == 0L;
                }).penalize("All shifts must be covered", HardMediumSoftScore.ONE_SOFT);
    }

Solution

  • Unless I'm mistaken, your code will throw NullPointerExceptions. If a shift is not covered, Shift.employee is null. Your constraints assume that Employee is never null - you'll have to fix that, perhaps like this:

    // I added a null check as from() only excludes uninitialized entities.
    // If employee is nullable, from() will still return Shifts with null employees.
    private Constraint requiredSkill(ConstraintFactory constraintFactory) {
        return constraintFactory.from(Shift.class)
                .filter(shift -> shift.getEmployee() != null)
                .filter(shift -> {
                    return !shift.getEmployee().hasSkills(shift.getSkills());
                }).penalize("Required skill", HardMediumSoftScore.ONE_HARD);
    }
    
    private Constraint allShiftsMustBeCovered(ConstraintFactory constraintFactory) {
        return constraintFactory.fromUnfiltered(Shift.class)
                .filter(shift-> shift.getEmployee() == null || 
                    shift.getEmployee().getId() == 0L
                ).penalize("All shifts must be covered", HardMediumSoftScore.ONE_SOFT);
    }
    

    As null is a valid value here, make sure that Shift.employee variable is nullable. This approach is known in the docs as overconstrained planning.