#Issues with Contract Work Percentage Constraint in Employee Scheduling

153 messages · Page 1 of 1 (latest)

still crystal
#

Hi,

I'm working on an employee scheduling system using Timefold and have encountered an issue with implementing a contract work percentage constraint. The goal is to ensure employees are scheduled according to their contract work percentage, but I'm facing a couple of challenges:

  1. Employees with 0% Contract Work Percentage:

    • Currently, employees with a 0% contract work percentage are still being assigned shifts. I want to ensure they are not assigned any shifts at all.
  2. Updating Contract Work Percentage:

    • I'm considering updating the employee's contract work percentage dynamically based on certain conditions. Any advice on best practices for this?

Here's my current constraint implementation:

public Constraint workPercentage(ConstraintFactory constraintFactory) {
    return constraintFactory.forEach(Employee.class)
            .join(Shift.class, equal(Employee::getName, Shift::getEmployee))
            .groupBy(
                    (employee, shift) -> employee,
                    ConstraintCollectors.sumDuration((employee, shift) -> 
                        Duration.between(shift.getStart(), shift.getEnd()))
                )
            .filter((employee, totalWorkedHours) -> {
                double fullTimeHours = 40.0;
                double desiredHours = employee.getWorkPercentage() * fullTimeHours;
                return totalWorkedHours.toHours() != desiredHours;
            })
            .penalize(HardSoftBigDecimalScore.ONE_HARD, (employee, totalWorkedHours) -> {
                return (int) totalWorkedHours.toHours() - employee.getWorkPercentage() * 40;
            })
            .asConstraint("Employee work percentage not matched");
}
opal vergeBOT
#

This post has been reserved for your question.

Hey @still crystal! Please use /close or the Close Post button above when your problem is solved. Please remember to follow the help guidelines. This post will be automatically marked as dormant after 300 minutes of inactivity.

TIP: Narrow down your issue to simple and precise questions to maximize the chance that others will reply in here.

still crystal
#

Questions:

  1. How can I modify the constraint to ensure employees with 0% contract work percentage are not assigned any shifts?
  2. Is there a recommended way to update the employee's contract work percentage dynamically within the constraint?

Additional Context:

  • I'm using Timefold 1.19.0 with Quarkus.
  • Other constraints, like shift preferences, are working fine.

Any insights or suggestions would be greatly appreciated!

Thank you!

opal vergeBOT
deft portal
#

in the .filter() part

still crystal
#

@deft portal It didnt work, even when you filter, it's still assigned the employee.

upbeat elk
#

Can you show your modified filtering?

still crystal
#

@upbeat elk


.filter((employee, totalWorkedHours) -> {
                        double fullTimeHours = 160; // Assume 40 hours as full-time
                        double maxAllowedHours = (employee.getWorkPercentage() * fullTimeHours) / 100;    
                        return (totalWorkedHours > maxAllowedHours) || employee.getWorkPercentage() == 0;  // Penalize if worked hours exceed allowed hours
                    })
upbeat elk
#

normally, the filter gives you the elements that match the condition

still crystal
#

I got this from doing PUT

#

And this is the reworked code

.filter((employee, totalWorkedHours) -> {
    double fullTimeHours = 160; // Assume 40 hours as full-time
    double maxAllowedHours = (employee.getWorkPercentage() * fullTimeHours) / 100;
    return totalWorkedHours <= maxAllowedHours && employee.getWorkPercentage() > 0; // The changed correct logic
})
upbeat elk
#

Does that match your expectations?

still crystal
#

@upbeat elk still Alice assigned to one, when their work percentage is 0:

"shifts": [
        {
            "id": "2027-02-01-night1",
            "start": "2025-02-01T07:00:00",
            "end": "2025-02-01T10:00:00",
            "location": "Hospital",
            "requiredSkill": "Nursing",
            "shiftType": "MORNING",
            "employee": {
                "name": "Alice",
                "skills": [
                    "Nursing",
                    "CPR"
                ],
                "unavailableDates": [],
                "undesiredDates": [],
                "desiredDates": [],
                "shiftPreferences": [
                    "NIGHT",
                    "MORNING"
                ],
                "workPercentage": 0
            }
        },

Both should've been assigned to Bob

still crystal
near parcelBOT
upbeat elk
#

Is that the result of filtering?

still crystal
#

My friend will enter the conversation, we're working together @upbeat elk, he can explain better 😄

gloomy wharf
near parcelBOT
#

"shifts": [ ```java

    {
        "id": "2027-02-01-night1",
        "start": "2025-02-01T07:00:00",
        "end": "2025-02-01T10:00:00",
        "location": "Hospital",
        "requiredSkill": "Nursing",
        "shiftType": "MORNING",
        "employee": {
            "name": "Alice",
            "skills": [
                "Nursing",
                "CPR"
            ],
            "unavailableDates": [],
            "undesiredDates": [],
            "desiredDates": [],
            "shiftPreferences": [
                "NIGHT",
                "MORNING"
            ],
            "workPercentage": 0
        }
    }, ```

This message has been formatted automatically. You can disable this using /preferences.

upbeat elk
#

It does say "solverStatus": "NOT_SOLVING"

#

For this shift, the end is before the start

        {
            "id": "2027-02-01-night2",
            "start": "2025-02-01T22:00:00",
            "end": "2025-02-01T00:00:00",
            "location": "Hospital",
            "requiredSkill": "Nursing",
            "shiftType": "NIGHT",
            "employee": {
                "name": "Bob",
                "skills": [
                    "Nursing",
                    "Medical Assistance"
                ],
                "unavailableDates": [],
                "undesiredDates": [],
                "desiredDates": [],
                "shiftPreferences": [
                    "NIGHT",
                    "MORNING"
                ],
                "workPercentage": 100
            }
        }
#

Are you using some linear programming/linear constrained optimization library?

gloomy wharf
#

"solverStatus": "SOLVING_ACTIVE"

#

while its still solving

still crystal
# upbeat elk Are you using some linear programming/linear constrained optimization library?

The code is using a library called Timefold Solver, which is a constraint solver. It is used for solving planning and scheduling problems by defining constraints and optimizing solutions based on those constraints. The constraints are defined in our EmployeeSchedulingConstraintProvider class, using the Timefold Solver's API, which includes classes like ConstraintFactory, ConstraintCollectors, and HardSoftBigDecimalScore. These are used to define and manage constraints for employee scheduling, such as ensuring no overlapping shifts, respecting employee preferences, and balancing shift assignments.

upbeat elk
#

Can I see the exact input?

near parcelBOT
#
{
  "employees": [
    {
      "name": "Alice",
      "skills": ["Nursing", "CPR"],
      "unavailableDates": [],
      "undesiredDates": [],
      "desiredDates": [],
      "shiftPreferences": ["MORNING","NIGHT"],
      "workPercentage": 0
    },
    {
      "name": "Bob",
      "skills": ["Medical Assistance", "Nursing"],
      "unavailableDates": [],
      "undesiredDates": [],
      "desiredDates": [],
      "shiftPreferences": ["NIGHT","MORNING"],
      "workPercentage": 100
    }
  ],
  "shifts": [
    {
      "id": "2027-02-03-night1",
      "start": "2025-02-03T07:00",
      "end": "2025-02-03T10:00",
      "location": "Hospital",
      "requiredSkill": "Nursing"
    },
        {
      "id": "2027-02-01-night2",
      "start": "2025-02-01T22:00",
      "end": "2025-02-01T23:59",
      "location": "Hospital",
      "requiredSkill": "Nursing"
    }
  ]
} ```

This message has been formatted automatically. You can disable this using /preferences.

still crystal
#

@upbeat elk

upbeat elk
#

Can you show the Shift and Employee classes?

deft portal
#

shouldnt you add like

#

a check

#

where you do

#

current worked hours + the hours you gonna work

#

and check if its above max allowed hours

#

because if im getting it right

#

if you are at lets say 159 worked hours

#

and max allowed is 160

#

then you will be able to work the whole shift anyway

still crystal
#

@deft portal so doing this?

.filter((employee, totalWorkedHours) -> {
    double fullTimeHours = 160; // Assume 40 hours as full-time
    double maxAllowedHours = (employee.getWorkPercentage() * fullTimeHours) / 100;
    double currentShiftHours = Duration.between(shift.getStart(), shift.getEnd()).toHours();
    return (totalWorkedHours + currentShiftHours) <= maxAllowedHours && employee.getWorkPercentage() > 0;
})
gloomy wharf
# deft portal current worked hours + the hours you gonna work

I think the logic is correct, but the current approach first calculates the shift's hours, then checks if the employee's work percentage allows them to take the shift. Instead, it should update the employee's remaining work percentage after assigning them a shift.

#

totalWorkedHours is the shift hours

deft portal
#

im too low on sunlight today

#

😭

#

disaster right

gloomy wharf
#

.groupBy(
(employee, shift) -> employee,
ConstraintCollectors.sum ((employee, shift) ->
(int) Duration.between(shift.getStart(), shift.getEnd()).toHours())
calculate the shift hours

deft portal
#

hmm

#

maybe

#
.filter((employee, totalWorkedHours) -> {
    double fullTimeHours = 160; // Assume 40 hours as full-time
    double maxAllowedHours = (employee.getWorkPercentage() * fullTimeHours) / 100;
    return totalWorkedHours <= maxAllowedHours && employee.getWorkPercentage() > 0; // The changed correct logic
})
#

so is this one correct or not

#

if i look at it without thinking much i would say u need the total worked hours + current shift hours check

gloomy wharf
#

Duration.between(shift.getStart(), shift.getEnd()).toHours() represents the hours of a single shift
and totalWorkedHours should be the sum of all assigned shift hours for an employee.
then

#

this should be correct i think

deft portal
#

yea so basically what i said ig

upbeat elk
deft portal
#

total worked hours + duration of single shift <= max allowed hours

deft portal
gloomy wharf
gloomy wharf
#

but how

upbeat elk
deft portal
#

well

#

if i was the employee

#

1 hour and 1 minute would count as 2 hours of work

gloomy wharf
gloomy wharf
upbeat elk
#

or 30min?

#

currently you are using integer hours for everything so you cannot do scheduling with anything except hours

deft portal
#

i meaaaaaaaaaaaaaaaaaaaaaaaan

#

if its a normal job

#

ur paid by hour

#

so

#

ig counting the hours is the best option

#

like even if its 1 hour 10 minutes count it as 2 h

upbeat elk
#

like if you have 30min on one day and 30min on another day, you have 1h in total

deft portal
#

depends on the company ig then

#

so in that case

#

i would count the minutes

#

instead of hours

#

same for

gloomy wharf
deft portal
#

totalworkedminutes instead of hours

gloomy wharf
upbeat elk
still crystal
#

@upbeat elk But even if we count in minutes instead of hours, shouldn't workpercentage being 0 mean that no shifts are assigned to that particular employee?

#

The problem could lie somewhere in the logic here

public Constraint workPercentage(ConstraintFactory constraintFactory) {
    return constraintFactory.forEach(Employee.class)
            .join(Shift.class, equal(Employee::getName, Shift::getEmployee))
            .groupBy(
                    (employee, shift) -> employee,
                    ConstraintCollectors.sumDuration((employee, shift) -> 
                        Duration.between(shift.getStart(), shift.getEnd()))
                )
            .filter((employee, totalWorkedHours) -> {
                double fullTimeHours = 40.0;
                double desiredHours = employee.getWorkPercentage() * fullTimeHours;
                return totalWorkedHours.toHours() != desiredHours;
            })
            .penalize(HardSoftBigDecimalScore.ONE_HARD, (employee, totalWorkedHours) -> {
                return (int) totalWorkedHours.toHours() - employee.getWorkPercentage() * 40;
            })
            .asConstraint("Employee work percentage not matched");
}

(from the OP)

upbeat elk
still crystal
#

Yeah, but do you know what could be the issue instead? Should we do more logging somewhere to see what could be causing the issue along the way?

upbeat elk
#

Does the library allow doing custom logging?

#

oh wait

#

you are using != on doubles

#

don't do that

#

doubles aren't exact values

#

I think return totalWorkedHours.toHours() < desiredHours should be ok

#

or return totalWorkedHours.toHours() < desiredHours - 0.0001 or similar

still crystal
#

I think return totalWorkedHours.toHours() < desiredHours should be ok
we've tried this but it didn't work

#

But why

return totalWorkedHours.toHours() < desiredHours - 0.0001

upbeat elk
still crystal
#

Yeah

#

Like why do we subtract 0.0001 from desiredHours?

upbeat elk
#

for example if you compute 0.1+0.2, you'll get something like 0.2999999... and not 0.3

deft portal
#

or maybe just use bigdouble

upbeat elk
#

and I'm subtracting a delta to ensure it's actually smaller

deft portal
#

for calculations

#

or whatever that class was called

upbeat elk
#

tbh fixed point mathw ith long might also work lol

deft portal
#

or just swap to minutes

#

😱

#

so u dont have 1.1 minute

#

ezzzzzzzzzzz

upbeat elk
#

the only thing where decimal numbers are coming from here is the percentages lol

deft portal
#

cant u just floor it

upbeat elk
deft portal
#

oh yea

#

i meant the round

#

l0l

upbeat elk
#

the solutions is comparing stuff with a delta

deft portal
#

disaster then

upbeat elk
#

using a delta is no problem

deft portal
#

delta force

gloomy wharf
#

im rewriting the whole constriant hopes it becomes better

near parcelBOT
#
Constraint workPercentage(ConstraintFactory constraintFactory) {
    return constraintFactory.forEach(Employee.class)
            .join(Shift.class, equal(Employee::getName, Shift::getEmployee)) // Join Employee and Shift on Employee
            .filter((employee, shift) -> {
                long fullTimeMinutes = 160 * 60; // 40 hours/week * 60 minutes/hour (2400 minutes)
                // Get the employee's total work time in minutes based on their work percentage
                long employeeWorkMinutes = (employee.getWorkPercentage() * fullTimeMinutes) / 100;
                
                // Get the shift duration in minutes
                long shiftMinutes = Duration.between(shift.getStart(), shift.getEnd()).toMinutes();
                
                // Calculate the new available work time in minutes after the shift is assigned
                long newEmployeeWorkMinutes = employeeWorkMinutes - shiftMinutes;  // Subtract shift duration
                
                // Ensure that the employee's total work minutes doesn't go below 0
                return newEmployeeWorkMinutes >= 0;
            })
            .penalize(HardSoftBigDecimalScore.ONE_HARD)
            .asConstraint("Employee work percentage exceeded");
} ```

This message has been formatted automatically. You can disable this using /preferences.

gloomy wharf
#

what do you think? is that better way?

upbeat elk
#

looks about fine

gloomy wharf
# upbeat elk looks about fine

i subtract the shiftminutes from the employeeworkminutes "workpercentage"
then im thinking about updating the working percentage each time a shift assign to the employee

upbeat elk
#

You can try logging the employee, shift and the returned value in every filter() call

still crystal
#

Hmm I am not sure how that'd be done. Any suggestions?

upbeat elk
#
boolean result = newEmployeeWorkMinutes >= 0;
System.out.println(employee + " | " + shift + ": " + result);
return result;
near parcelBOT
#
Constraint workPercentage(ConstraintFactory constraintFactory) {
        return constraintFactory.forEach(Shift.class)
                .filter(shift -> shift.getEmployee() != null) // Ensure shift has an employee
                .groupBy(shift -> shift.getEmployee(), // Group shifts by employee
                ConstraintCollectors.sumLong(shift -> Duration.between(shift.getStart(), shift.getEnd()).toMinutes())) // Sum total assigned minutes
                .filter((employee, totalShiftMinutes) -> {
                        long fullTimeMinutes = 160 * 60; // 9600 minutes per month (assuming full-time is 160 hours)
                        long employeeAvailableMinutes = (employee.getWorkPercentage() * fullTimeMinutes) / 100;
                        return totalShiftMinutes > employeeAvailableMinutes; // Penalize if assigned shifts exceed allowed minutes
                })
                .penalize(HardSoftBigDecimalScore.ONE_HARD)
                .asConstraint("Employee work percentage exceeded");
    } ```
Finally its working :3 thank you for your help

This message has been formatted automatically. You can disable this using /preferences.

gloomy wharf
#

it loop through Shift.class instead of Employee.class because we want to check actual assignments.
then it check if the shift has an assigned employee (shift.getEmployee() != null).
if it does then we go through the same logic

still crystal
#

@upbeat elk

#

We'll verify that employees do not exceed their assigned work percentage. For example, if an employee has a 1% work percentage, they should only be assigned shifts that fit within that limit.