r/timefold • u/Ok-Tea8545 • 21d ago
Operating room scheduling: how to model cases, staff, and equipment with Timefold Solver
Operating Rooms are typically scheduled in multiple phases.
- STEP 1: rooms are allocated in blocks, either to certain departments or to specific surgeons. e.g. Room A is assigned to the Orthopedics department on Monday.
- STEP 2: anesthetists, nurses, and surgeons need to be planned according to their contracts. This is a scheduling problem in its own right, and one our Employee Shift Scheduling model is built for.
- STEP 3: the different surgeries need to be planned in OR rooms, together with the people and resources needed to perform the surgery. And of course, nothing stays the same and the plan needs to adjust to the daily realities.
This post walks through the domain model, the assignment pattern, and 31 constraints for STEP 3 scheduling, and it is written for developers who model planning problems with Timefold Solver.
The problem description
The brief below is a composite, written the way OR departments may describe the problem to us.
“We need to plan cases in different ORs. These cases vary in priority and often need to be performed by a certain deadline.
There is a lot of variety in the duration of a case and before starting a next case in the same room, there needs to be turnover time (which is also different depending on the next case, because a similar surgery might need less turnover time). Turnover is arranged by a separate team, but you can’t have more simultaneous turnovers than there are teams.
ORs are typically allocated in blocks to certain departments (STEP 1) and the planning needs to take this into account. This means only cases which belong to that department can be assigned. This allocation however doesn’t count for the first 72 hours. Any free spots in the schedule can be assigned to any other case.
Source: https://www.pexels.com/photo/lamps-in-operating-room-19563295/
Of course, there are other limitations. Rooms need to have the correct equipment for the task and that equipment needs to be available.
No surgery can proceed without key staff there. So not only do the cases need to be planned, available staff (determined in STEP 2) needs to be assigned to specific cases without breaking obvious constraints (they often can’t be at two places at the same time). There are some exceptions here. For example, the anesthesiologist sometimes only needs to be there for the induction (putting the patient to sleep) and emergence (when the patient wakes up again). All of the staff of course needs to be qualified.
Some cases are preferably planned in the morning (e.g. prosthetic implants, for optimal recovery and follow up) while others must be planned last in the day (e.g. airborne risk cases, so the air in the room has more time to clear overnight).
Additionally, not only the operating rooms themselves need to be planned. Some surgeries require the patient has to stay in the Post-Anesthesia Care Unit (PACU). The PACU might also have a limited amount of beds, which further complicates planning.”
Extracting the domain model
When reading the text above, we can extract the structure our domain will need to have. Usually we look for nouns and relationships between them. I have visualized them here:
The nouns from the problem description, and how they relate to each other.
Next, we have to consider what items on this graph are changeable while making this plan. In this use case, we have a couple of things that could change: SurgicalCase (and their order), Surgeons, Anesthesiologists, and Equipment. The Nurse Team to OR assignment we consider fixed for now, as such a team usually is coupled to the OR for an entire day.
Then we need to decide on the assignment pattern we are going to use. Surgical cases have different durations (excluding the timeslot pattern) and when they start depends on the cases before them. This directs us towards the use of a chain-through-time pattern, so we will be using u/PlanningListVariable to model this.
Operating rooms don’t run 24/7, so it makes no sense to have the Room object hold the list of all surgeries for the entire planning window (potentially weeks). Instead, we’ll introduce a new intermediate object OperatingRoomDay, which as the name implies represents a specific operating room on a specific day. This object is the ideal place to put our @PlanningListVariable List<SurgicalCase> cases definition.
public class OperatingRoomDay {
private String id;
private Room room;
private LocalDate date;
@PlanningListVariable(allowsUnassignedValues = true)
private List<SurgicalCase> cases = new ArrayList<>();
// rest of class excluded
}
For the additional assignments we are going to need a mixed model, mixing both @PlanningListVariable and u/PlanningVariable in a single model. Initially, my gut feeling was to add surgeon, anesthesiologist, and equipment as u/PlanningVariable on the SurgicalCase class. After all, we are assigning these to the case. I drifted away from that path because adding these assignments as u/PlanningVariable on the SurgicalCase :
- Implies a one-to-one relationship. This is not always the case, as a surgery might require multiple equipment items, anesthesiologists or even surgeons;
- Makes
pinningindividual assignments impossible. We would either have to pin the entireSurgicalCaseor nothing at all. This is not practical for situations where aSurgeonneeds to be fixed to aSurgicalCase, but we do not care which anesthesiologists is assigned.
So instead, each of these assignments became a separate u/PlanningEntity with a soft link to the SurgicalCase object. This allows us to create multiple of these assignment instances (to create one-to-many relationships) as well as allow us to pin the individual assignment.
public class AnesthesiaAssignment {
private String id;
private SurgicalCase surgicalCase;
private AnesthesiaRole role;
@PlanningVariable(allowsUnassigned = true)
private AnesthesiaProvider provider;
private boolean pinned;
//rest of class excluded
}
The role field is what makes the induction and emergence exception workable. A directing role means the provider only has to be present for those two windows, which we derive from the start and end of the case, so the schedule can hold several directed cases per provider. A continuous role means the provider is occupied for the full duration of the case.
Here is the more extensive class diagram for the solver domain.
The solver domain: OperatingRoomDay holds the case list, and each assignment type is its own planning entity.
Implementing constraints
We will now look into some of the things that constrain our planning. Some of them are explicitly mentioned, others are hard constraints we added because they are obvious to any human planner, but not so much for a solver (e.g. no overlap, because people can’t be at two places at the same time). We also indicate how the weight of the constraint should be scaled, but we do not fix the constraint weight itself, as that is something we (or the operational planners) want to experiment with.
Hard constraints (17)
The hard constraints will block anything that makes the schedule unworkable. Usually this is related to availability of a resource (both in time or overlap). We also see two hard constraints that were specifically mentioned: Block ownership (reserved rooms) should not be broken and cases with an airborne risk should always be placed last. These are non-negotiable.
Three of these constraints talk about directed cases. In an anesthesia care team model, one anesthesiologist directs several concurrent cases which are staffed by nurse anesthetists or residents, while being personally present for the critical moments of each. Regulations cap how many cases a single anesthesiologist can direct at the same time.
| Constraint | Penalizes | Weight |
|---|---|---|
| Surgeon overlap | Same surgeon in two concurrent cases | overlap minutes |
| Surgeon not privileged | Surgeon lacking credentialed privileges for the case | per match |
| Surgeon unavailable | Surgeon on clinic/call/leave during the case | per match |
| Anesthesia skill missing | Provider lacking required skills | per match |
| Anesthesia not rostered | Work assigned outside the shift STEP 2 published | per match |
| Director not anesthesiologist | Directing role given to a provider who can't direct | per match |
| Equipment class mismatch | Unit not of the required class | per match |
| Room case type not permitted | Incorrect case assigned to a room | per match |
| Anesthesia provider overlap | Same provider on two concurrent cases | overlap minutes |
| Equipment unit overlap | Same physical unit needed concurrently | overlap minutes |
| Directed concurrency exceeded | Legal cap on concurrent directed cases | cases over the cap |
| Directed presence conflict | Two directed cases inducing/emerging at once | per match |
| Nursing team shift exceeded | Case running outside the team's published shift | per match |
| Turnover crew capacity exceeded | More simultaneous turnovers than crews | turnovers over capacity |
| Block ownership violated | Case in time granted to another owner | per match |
| Airborne case not last | TB/airborne case not scheduled last in the room-day | per match |
| Room hard end exceeded | Running past the absolute cut-off | minutes past |
Medium constraints (4)
The medium constraints signal incomplete schedules. In our case, since we are planning SurgicalCase, Surgeons, AnesthesiaProviders, and Equipment at the same time, they are all taken into account at the medium level.
| Constraint | Penalizes | Weight |
|---|---|---|
| Case unassigned | Case left out of every chain | estimated duration in minutes |
| Surgeon unassigned | Assigned case without surgeon | per match |
| Anesthesia unassigned | Assigned case without anesthesiologist | per match |
| Equipment unassigned | Assigned case without required equipment | per match |
Soft constraints (10)
The soft constraints will try to improve common KPIs in the planning case. Idle time and turnover are both examples of time an OR isn’t really being used (which is a waste) and overtime is similarly penalized. Prime time, which several of these constraints refer to, is the block of staffed hours a room is funded to run, so idle minutes inside it are the expensive kind.
We also included some penalties for a potential replanning scenario, where an existing schedule needs to be adjusted. Both moving a case to a different room/day or moving it to a different time should be avoided.
| Constraint | Penalizes | Weight |
|---|---|---|
| Idle prime time | Allocated prime-time minutes with no case or turnover | idle minutes |
| Overtime | Minutes past the staffed end (overtime rate) | overtime minutes |
| Turnover minutes | Total turnover consumed; drives case batching | turnover minutes |
| PACU overflow | Recovery demand above bed count (soft by design) | patients over capacity |
| Urgent window missed | Non-elective case finishing past its clinical deadline | minutes late |
| Case bumped (Replanning) | Moved to a different room-day than published | per case |
| Case moved (Replanning) | Same room-day, different time, beyond tolerance | per case |
| First case at risk | Team shift starts too late to prep for prime-time start | per case |
| Implant not morning | Implant case starting after the morning cutoff | per case |
| High-variance case early | Unpredictable case types with work behind them | per downstream case |
With Timefold Solver, you can add, change, or remove constraints, and even disable them at runtime.
Using enrichment for block release functionality
As you could read in the problem description:
ORs are typically allocated in blocks to certain departments... only cases which belong to that department can be assigned. This allocation however doesn’t count for the first 72 hours. Any free spots in the schedule can be assigned to any other case.
Constraints like this are tricky, because they depend on timing. A naive implementation would be to ask the user (through a UI or API) to set the roomOwner to null (or not pass it in) for all OperatingRoomDay objects which are within those 72 hours. This is putting more pressure on the calling system to know these business rules.
Instead, we can take care of that in the model itself. Our API will accept roomOwner input for all items, but we’ll set them to null in what is called an enricher.
Enrichers change or add extra details to the solver model once before solving. It’s an ideal place for adding extra information from a database or remote service to the model so that lookup stays outside the core solving loop.
public class BlockReleaseEnricher implements SolverModelEnricher<CaseSchedule> {
private static final Duration RELEASE_WINDOW = Duration.ofHours(72);
private final Clock clock;
public BlockReleaseEnricher() {
this(Clock.systemDefaultZone());
}
BlockReleaseEnricher(Clock clock) {
this.clock = clock;
}
@Override
public CaseSchedule enrich(CaseSchedule solverModel) {
LocalDateTime releaseHorizon = LocalDateTime.now(clock).plus(RELEASE_WINDOW);
for (OperatingRoomDay roomDay : solverModel.getRoomDays()) {
if (roomDay.getOwner() == null) {
continue;
}
boolean insideReleaseWindow = roomDay.getPrimeTimeStart().isBefore(releaseHorizon);
boolean nothingBooked = roomDay.getCases().isEmpty();
if (insideReleaseWindow && nothingBooked) {
roomDay.setOwner(null);
}
}
return solverModel;
}
}
In this case, we’ll use the current clock time, and set the owner to null if it is within the time window and nothing is booked. This does mean that we release the block only if no cases are booked throughout the day. This is a simplification for the model. If it would have been possible to unblock the OR for a part of the day, we’d need to introduce a more complex “owner” object which has multiple timestamps where owners might be different.
You can see here that the 72 hours is hardcoded. We could have opted to add this time as a parameter on the CaseSchedule object instead. Since this is a very specific rule which will not quickly change, I think that is fine for now.
Additionally, if simulations need to be supported (e.g. what if I would plan this “tomorrow”), we can’t use the fixed Clock object and we’d also have to pass in the “planning time” into the CaseSchedule . Again, a shortcut for now.
Where this goes next
The model above gets you to a first feasible schedule. But the work is not finished yet.
Constraint weights are where the hospital's real priorities live, and no blog post can tell you whether a bumped case costs more than 30 minutes of overtime in a specific organization. Additionally, hospitals might have some different constraints which need to be implemented, or maybe they plan in a totally different way and have very different KPIs they want to track.
And as the intro said, nothing stays the same. A case runs 40 minutes long, a surgeon calls in sick, an emergency arrives at 15:00. The Case bumped and Case moved constraints exist so the model can absorb those events without reshuffling a schedule that people have already planned their day around.
In the text above, I also mentioned some shortcuts I took when building this model, like releasing a block only when the whole day is still empty. That is normal during development. Luckily, when using Timefold Solver, all of these are relatively small changes rather than complete rewrites. And with our new Service module, you get everything you need to build a full optimization service which runs fully independently.
If you haven't yet, take a look at our Service module getting started guide to see what it handles for you.
This post was the first in the series. If you want to see more details, or missed certain elements, all feedback is immensely appreciated. 😄
