Skip to content

Commit

Permalink
chore: removing Nurse Rostering code (#799)
Browse files Browse the repository at this point in the history
This PR removes the Nurse Rostering source code.
  • Loading branch information
zepfred authored Apr 18, 2024
1 parent b5ff711 commit 7aadd5c
Show file tree
Hide file tree
Showing 156 changed files with 68 additions and 239,179 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ image::constraints-and-score/overview/scoreWeighting.png[align="center"]
Score weighting is easy in use cases where you can __put a price tag on everything__.
In that case, the positive constraints maximize revenue and the negative constraints minimize expenses, so together they maximize profit.
Alternatively, score weighting is also often used to create social xref:constraints-and-score/performance.adoc#fairnessScoreConstraints[fairness].
For example, a nurse, who requests a free day, pays a higher weight on New Years eve than on a normal day.
For example, an employee, who requests a free day, pays a higher weight on New Years eve than on a normal day.

The weight of a constraint match can depend on the planning entities involved.
For example, in vehicle routing,
Expand All @@ -111,8 +111,8 @@ Most use cases use a `Score` with `int` weights, such as xref:constraints-and-sc

Sometimes a score constraint outranks another score constraint, no matter how many times the latter is broken.
In that case, those score constraints are in different levels.
For example, a nurse cannot do two shifts at the same time (due to the constraints of physical reality),
so this outranks all nurse happiness constraints.
For example, an employee cannot do two shifts at the same time (due to the constraints of physical reality),
so this outranks all employee happiness constraints.

Most use cases have only two score levels, hard and soft.
The levels of two scores are compared lexicographically.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -757,70 +757,52 @@ If performance is a concern, avoid these collectors.
[#collectorsConsecutive]
==== Consecutive collectors

Certain constraints, such as maximum consecutive working days for an employee,
require ordering those shifts in sequences and penalizing sequences exceeding a certain threshold.
Certain constraints, such as maximum consecutive working days for an employee
or the number of matches played at home in a sports league,
require ordering those shifts or matches in sequences and penalizing sequences exceeding a certain threshold.
If those sequences can be of arbitrary length unknown at the time of writing the constraints,
you can implement this pattern using the `ConstraintCollectors.consecutive(...)` collector:

[source,java,options="nowrap"]
----
Constraint consecutiveWorkingDays(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(MinMaxContractLine.class)
.filter(minMaxContractLine ->
minMaxContractLine.getContractLineType() == ContractLineType.CONSECUTIVE_WORKING_DAYS
&& minMaxContractLine.isEnabled())
.join(ShiftAssignment.class,
Joiners.equal(ContractLine::getContract, ShiftAssignment::getContract))
.groupBy((contract, shift) -> shift.getEmployee(),
(contract, shift) -> contract,
ConstraintCollectors.toConsecutiveSequences(
(contract, shift) -> shift.getShiftDate(),
ShiftDate::getDayIndex))
.flattenLast(SequenceChain::getConsecutiveSequences)
.map((employee, contract, shifts) -> employee,
(employee, contract, shifts) -> contract,
(employee, contract, shifts) -> contract.getViolationAmount(shifts.getLength()))
.filter((contract, employee, violationAmount) -> violationAmount != 0)
.penalize(HardSoftScore.ONE_SOFT,
(contract, employee, violationAmount) -> violationAmount)
.indictWith((contract, employee, violationAmount) -> Arrays.asList(employee, contract))
.asConstraint("consecutiveWorkingDays");
Constraint multipleConsecutiveHomeMatches(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Match.class)
.join(Team.class, equal(Match::getHomeTeam, Function.identity()))
.groupBy((match, team) -> team,
ConstraintCollectors.toConsecutiveSequences((match, team) -> match.getRound(), Round::getIndex))
.flattenLast(SequenceChain::getConsecutiveSequences)
.filter((team, matches) -> matches.getCount() >= MAX_CONSECUTIVE_MATCHES)
.penalize(HardSoftScore.ONE_HARD, (team, matches) -> matches.getCount())
.asConstraint("4 or more consecutive home matches");
}
----

Let's take a closer look at the crucial part of this constraint:

[source,java,options="nowrap"]
----
.groupBy((contract, shift) -> shift.getEmployee(),
(contract, shift) -> contract,
ConstraintCollectors.toConsecutiveSequences(
(contract, shift) -> shift.getShiftDate(),
ShiftDate::getDayIndex))
.groupBy((match, team) -> team,
ConstraintCollectors.toConsecutiveSequences((match, team) -> match.getRound(), Round::getIndex))
.flattenLast(SequenceChain::getConsecutiveSequences)
.map((employee, contract, shifts) -> employee,
(employee, contract, shifts) -> contract,
(employee, contract, shifts) -> contract.getViolationAmount(shifts.getLength()))
.filter((contract, employee, violationAmount) -> violationAmount != 0)
.filter((team, matches) -> matches.getCount() >= MAX_CONSECUTIVE_MATCHES)
----

The `groupBy()` building block groups all shifts by employee and contract,
The `groupBy()` building block groups all matches by team,
and for every such pair it creates a `SequenceChain` instance.
The shifts will be put into sequence by their date,
and the day index will be used to identify consecutive sequences and breaks between them.
Imagine the collector putting each shift on a number line,
where the number is the day index of the shift.

The `SequenceChain` then has several useful methods to not only get the list of shifts,
but also to get any and all breaks between those shifts.
In this case, we are only interested in the sequences of shifts themselves,
The matches will be put into sequence by round date,
and the round index will be used to identify consecutive sequences and breaks between them.
Imagine the collector putting each match on a number line,
where the number is the round day index of the match.

The `SequenceChain` then has several useful methods to not only get the list of matches,
but also to get any and all breaks between those matches.
In this case, we are only interested in the sequences of matches themselves,
and we use <<constraintStreamsFlattening,flattening>> to convert each to its own tuple.

Finally, we use <<constraintStreamsMappingTuples,mapping>> to calculate the violation amount for each sequence.
For example, if the contract says that an employee can work at most 5 consecutive days,
and `shifts` contains 6 shifts, then `violationAmount` will be `1`.
If the amount is `0`, then the sequence is not violating the contract and we can filter it out.

Finally, if a league requires that a team can play at most 5 consecutive matches (`MAX_CONSECUTIVE_MATCHES`) in home,
and `matches` contains 6 matches, then the `weight` penalty will be `6`.
If the number of consecutive matches is `4`,
then the sequence is not violating the league requirement, and we can filter it out.

[#collectorsConditional]
==== Conditional collectors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1088,17 +1088,17 @@ Advanced configuration:
----
<pillarChangeMoveSelector>
<subPillarType>SEQUENCE</subPillarType>
<subPillarSequenceComparatorClass>ai.timefold.solver.examples.nurserostering.domain.ShiftAssignmentComparator</subPillarSequenceComparatorClass>
<subPillarSequenceComparatorClass>...ShiftComparator</subPillarSequenceComparatorClass>
... <!-- Normal selector properties -->
<pillarSelector>
<entitySelector>
<entityClass>...ShiftAssignment</entityClass>
<entityClass>...Shift</entityClass>
...
</entitySelector>
<minimumSubPillarSize>1</minimumSubPillarSize>
<maximumSubPillarSize>1000</maximumSubPillarSize>
</pillarSelector>
<valueSelector variableName="room">
<valueSelector variableName="employee">
...
</valueSelector>
</pillarChangeMoveSelector>
Expand Down Expand Up @@ -1131,11 +1131,11 @@ Advanced configuration:
----
<pillarSwapMoveSelector>
<subPillarType>SEQUENCE</subPillarType>
<subPillarSequenceComparatorClass>ai.timefold.solver.examples.nurserostering.domain.ShiftAssignmentComparator</subPillarSequenceComparatorClass>
<subPillarSequenceComparatorClass>...ShiftComparator</subPillarSequenceComparatorClass>
... <!-- Normal selector properties -->
<pillarSelector>
<entitySelector>
<entityClass>...ShiftAssignment</entityClass>
<entityClass>...Shift</entityClass>
...
</entitySelector>
<minimumSubPillarSize>1</minimumSubPillarSize>
Expand Down Expand Up @@ -1188,7 +1188,8 @@ Therefore a `pillarSelector` only supports <<justInTimeRandomSelection,JIT rando

Sub pillars can be sorted with a `Comparator`. A sequential sub pillar is a continuous subset of its sorted base pillar.

For example if a nurse has shifts on Monday (`M`), Tuesday (`T`), and Wednesday (`W`), they are a pillar and only the following are its sequential sub pillars: `[M], [T], [W], [M, T], [T, W], [M, T, W]`.
For example, if an employee has shifts on Monday (`M`), Tuesday (`T`), and Wednesday (`W`),
they are a pillar and only the following are its sequential sub pillars: `[M], [T], [W], [M, T], [T, W], [M, T, W]`.
But `[M, W]` is not a sub pillar in this case, as there is a gap on Tuesday.

Sequential sub pillars apply to both <<pillarChangeMoveSelector,Pillar change move>> and
Expand All @@ -1210,7 +1211,7 @@ An advanced configuration looks like this:
<pillar...MoveSelector>
...
<subPillarType>SEQUENCE</subPillarType>
<subPillarSequenceComparatorClass>ai.timefold.solver.examples.nurserostering.domain.ShiftAssignmentComparator</subPillarSequenceComparatorClass>
<subPillarSequenceComparatorClass>...ShiftComparator</subPillarSequenceComparatorClass>
<pillarSelector>
...
<minimumSubPillarSize>1</minimumSubPillarSize>
Expand All @@ -1220,7 +1221,9 @@ An advanced configuration looks like this:
</pillar...MoveSelector>
----

In this case, the entity being operated on need not be `Comparable`. The given `subPillarSequenceComparatorClass` is used to establish the sequence instead. Also, the size of the sub pillars is limited in length of up to 1000 entities.
In this case, the entity being operated on need not be `Comparable`.
The given `subPillarSequenceComparatorClass` is used to establish the sequence instead.
Also, the size of the sub pillars is limited in length of up to 1000 entities.

[#listMoveSelectors]
==== Move selectors for list variables
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ Immutable past time periods.
It contains only pinned entities.
+
** Recent historic entities can also affect score constraints that apply to movable entities.
For example, in nurse rostering, a nurse that has worked the last three historic weekends in a row should not be assigned to three more weekends in a row, because she requires a one free weekend per month.
For example, in employee rostering, an employee that has worked the last three historic weekends in a row
should not be assigned to three more weekends in a row, because he requires a one free weekend per month.
** Do not load all historic entities in memory:
even though pinned entities do not affect solving performance, they can cause out of memory problems when the data grows to years.
Only load those that might still affect the current constraints with a good safety margin.
Expand All @@ -155,7 +156,8 @@ Upcoming time periods that have been published.
They contain only <<pinnedPlanningEntities,pinned>> and/or <<nonvolatileReplanning,semi-movable>> planning entities.
+
** The published schedule has been shared with the business.
For example, in nurse rostering, the nurses will use this schedule to plan their personal lives, so they require a publish notice of for example 3 weeks in advance.
For example, in employee rostering, the employees will use this schedule to plan their personal lives,
so they require a publish notice of for example 3 weeks in advance.
Normal planning will not change that part of schedule.
+
Changing that schedule later is disruptive, but were exceptions force us to do them anyway (for example someone calls in sick), do change this part of the planning while minimizing disruption with <<nonvolatileReplanning,non-disruptive replanning>>.
Expand All @@ -167,10 +169,11 @@ They contain movable planning entities, except for any that are pinned for other
+
** The first part of the draft, called _the final draft_, will be published, so these planning entities can change one last time.
The publishing frequency, for example once per week, determines the number of time periods that change from _draft_ to _published_.
** The latter time periods of the _draft_ are likely change again in later planning efforts, especially if some of the problem facts change by then (for example nurse Ann doesn't want to work on one of those days).
** The latter time periods of the _draft_ are likely change again in later planning efforts,
especially if some of the problem facts change by then (for example employee Ann can't work on one of those days).
+
Despite that these latter planning entities might still change a lot, we can't leave them out for later, because we would risk _painting ourselves into a corner_.
For example, in employee rostering we could have all our rare skilled employees working the last 5 days of the week that gets published,
For example, in employee rostering, we could have all our rare skilled employees working the last 5 days of the week that gets published,
which won't reduce the score of that week, but will make it impossible for us to deliver a feasible schedule the next week.
So the draft length needs to be longer than the part that will be published first.
** That draft part is usually not shared with the business yet, because it is too volatile and it would only raise false expectations.
Expand Down Expand Up @@ -226,29 +229,28 @@ the lecture will not be assigned to another period or room (even if the current
Alternatively, to pin some planning entities down, add a `PinningFilter` that returns `true` if an entity is pinned, and `false` if it is movable.
This is more flexible and more verbose than the `@PlanningPin` approach.
For example on the nurse rostering example:
For example, on the employee scheduling quickstart:
. Add the `PinningFilter`:
+
[source,java,options="nowrap"]
----
public class ShiftAssignmentPinningFilter implements PinningFilter<NurseRoster, ShiftAssignment> {
public class ShiftPinningFilter implements PinningFilter<EmployeeSchedule, Shift> {

@Override
public boolean accept(NurseRoster nurseRoster, ShiftAssignment shiftAssignment) {
ShiftDate shiftDate = shiftAssignment.getShift().getShiftDate();
return nurseRoster.getNurseRosterInfo().isInPlanningWindow(shiftDate);
public boolean accept(EmployeeSchedule employeeSchedule, Shift shift) {
ScheduleState scheduleState = employeeSchedule.getScheduleState();
return !scheduleState.isDraft(shift);
}

}
----
. Configure the `PinningFilter`:
+
[source,java,options="nowrap"]
----
@PlanningEntity(pinningFilter = ShiftAssignmentPinningFilter.class)
public class ShiftAssignment {
@PlanningEntity(pinningFilter = ShiftPinningFilter.class)
public class Shift {
...
}
----
Expand Down Expand Up @@ -581,25 +583,25 @@ The Recommended Fit API requires one uninitialized entity to be present in the s
[source,java,options="nowrap"]
----
NurseRoster nurseRoster = ...; // Our planning solution.
ShiftAssignment unassignedShift = new ShiftAssignment(...); // A new shift needs to be assigned.
nurseRoster.getShiftAssignmentList().add(unassignedShift);
EmployeeSchedule employeeSchedule = ...; // Our planning solution.
Shift unassignedShift = new Shift(...); // A new shift needs to be assigned.
employeeSchedule.getShifts().add(unassignedShift);
----
The `SolutionManager` is then used to retrieve the recommended fit for the uninitialized entity:
[source,java,options="nowrap"]
----
SolutionManager<NurseRoster, HardSoftScore> solutionManager = ...;
SolutionManager<EmployeeSchedule, HardSoftScore> solutionManager = ...;
List<RecommendedFit<Employee, HardSoftScore>> recommendations =
solutionManager.recommendFit(nurseRoster, unassignedShift, ShiftAssignment::getEmployee);
solutionManager.recommendFit(employeeSchedule, unassignedShift, Shift::getEmployee);
----
Breaking this down, we have:
- `nurseRoster`, the planning solution.
- `employeeSchedule`, the planning solution.
- `unassignedShift`, the uninitialized entity, which is part of the planning solution.
- `ShiftAssignment::getEmployee`, a function extracting the planning variable from the entity,
- `Shift::getEmployee`, a function extracting the planning variable from the entity,
also called a "proposition function".
- `List<RecommendedFit<Employee, HardSoftScore>>`, the list of recommended employees to assign to the shift,
in the order of decreasing preference.
Expand Down Expand Up @@ -656,12 +658,12 @@ Consider the following example:
[source,java,options="nowrap"]
----
SolutionManager<NurseRoster, HardSoftScore> solutionManager = ...;
List<RecommendedFit<Employee, HardSoftScore>> recommendations =
solutionManager.recommendFit(nurseRoster, unassignedShift, shift -> shift);
SolutionManager<EmployeeSchedule, HardSoftScore> solutionManager = ...;
List<RecommendedFit<Shift, HardSoftScore>> recommendations =
solutionManager.recommendFit(employeeSchedule, unassignedShift, shift -> shift);
----
The proposition function (`shift -> shift`) returns the entire `ShiftAssignment` entity.
The proposition function (`shift -> shift`) returns the entire `Shift` entity.
Because of the behavior described above,
every `RecommendedFit` in the `recommendations` list will point to the same `unassignedShift`,
and its `employee` variable will be `null`.
Expand Down
22 changes: 0 additions & 22 deletions examples/data/nurserostering/export/solution.xsd

This file was deleted.

Loading

0 comments on commit 7aadd5c

Please sign in to comment.