Hospital Staffing
Assign nurses to shifts to minimize overtime cost and overflow penalties from unmet patient demand.
Assign nurses to shifts to minimize overtime cost and overflow penalties from unmet patient demand.
Explore the tradeoff between overtime cost and patient service level using bi-objective optimization with epsilon constraint.
Browse files
Browse files
Browse files
What this template is for
Hospitals must schedule nurses across shifts while keeping overtime costs low and ensuring patients receive care. This template models assigning 6 nurses to 3 shifts (Morning, Afternoon, Night), where each shift has patient demand that depends on staffing levels.
Nurses can work extra shifts beyond their regular hours, but at a premium rate (1.5x). When staffing falls short and patient demand goes unmet, the hospital incurs an overflow penalty reflecting missed care ratios, throughput shortfalls, and regulatory risk. This template uses RelationalAI’s prescriptive reasoner to find the assignment of nurses to shifts that minimizes the combined cost of overtime and overflow, while respecting availability, safety limits, and skill requirements.
Prescriptive reasoning helps you:
- Control overtime cost: Identify the lowest-cost overtime assignments when extra coverage is needed.
- Avoid costly overflow: Ensure high-demand shifts get additional staff to prevent unmet patient demand.
- Quantify trade-offs: Determine whether overtime cost is justified by the overflow penalty it avoids.
- Enforce safety limits: Guarantee no nurse works more than 2 shifts (16 hours).
Who this is for
- You want an end-to-end example of prescriptive reasoning (optimization) with RelationalAI.
- You’re comfortable with basic Python and mixed-integer optimization concepts.
What you’ll build
- A semantic model of nurses, shifts, and availability using concepts and properties.
- A MILP model with binary assignment variables, continuous overtime hours, patient throughput, and unmet demand variables.
- Nine constraints covering availability, coverage, skill requirements, overtime, demand, capacity, and overflow.
- An objective that minimizes overtime cost plus an overflow penalty for unserved patients.
- A solver that uses the HiGHS backend and prints overtime assignments, patient throughput, and staff schedules.
What’s included
- Model + solve script:
hospital_staffing.py - Sample data:
data/nurses.csv,data/shifts.csv,data/availability.csv
Prerequisites
Access
- A Snowflake account that has the RAI Native App installed.
- A Snowflake user with permissions to access the RAI Native App.
Tools
- Python >= 3.10
Quickstart
Follow these steps to run the template with the included sample data. You can customize the data and model as needed after you have it running end-to-end.
-
Download the ZIP file for this template and extract it:
Terminal window curl -O https://private.relational.ai/templates/zips/v0.13/hospital_staffing.zipunzip hospital_staffing.zipcd hospital_staffing -
Create and activate a virtual environment
Terminal window python -m venv .venvsource .venv/bin/activatepython -m pip install -U pip -
Install dependencies
From this folder:
Terminal window python -m pip install . -
Configure Snowflake connection and RAI profile
Terminal window rai init -
Run the template
Terminal window python hospital_staffing.py -
Expected output
Status: OPTIMALTotal cost: $616.00Overtime assignments:nurse overtime_hoursNurse_B 8.0Nurse_C 8.0Patient throughput by shift:shift patients_served patient_demand unmet_demandMorning 32.0 45 13.0Afternoon 60.0 60 0.0Night 24.0 25 1.0Total patients served: 116 / 130Total unmet demand: 14 patientsStaff assignments:nurse shiftNurse_A MorningNurse_A AfternoonNurse_B MorningNurse_B AfternoonNurse_C AfternoonNurse_C NightNurse_D MorningNurse_E MorningNurse_E NightNurse_F Night
Template structure
.├─ README.md├─ pyproject.toml├─ hospital_staffing.py # main runner / entrypoint└─ data/ # sample input data ├─ nurses.csv ├─ shifts.csv └─ availability.csvStart here: hospital_staffing.py
Sample data
Data files are in data/.
nurses.csv
Staff members with skill ratings, pay rates, and overtime parameters.
| Column | Meaning |
|---|---|
id | Unique nurse identifier |
name | Nurse name |
skill_level | Skill rating (1=basic, 2=intermediate, 3=advanced) |
hourly_cost | Cost per hour ($) |
regular_hours | Standard hours before overtime kicks in (8) |
overtime_multiplier | Overtime pay rate multiplier (1.5x) |
shifts.csv
Time periods with staffing requirements and patient demand.
| Column | Meaning |
|---|---|
id | Unique shift identifier |
name | Shift name (Morning, Afternoon, Night) |
start_hour | Shift start time (24-hour format) |
duration | Shift length in hours |
min_nurses | Minimum nurses required per shift |
min_skill | Minimum skill level required for at least one nurse |
patient_demand | Total patients needing care during this shift |
patients_per_nurse_hour | Patients one nurse can serve per hour |
availability.csv
Links nurses to shifts, indicating which assignments are feasible.
| Column | Meaning |
|---|---|
nurse_id | Foreign key to nurses.csv.id |
shift_id | Foreign key to shifts.csv.id |
available | 1 if the nurse can work this shift, 0 otherwise |
Model overview
The semantic model for this template is built around four concepts.
Nurse
A staff member with a skill level, hourly cost, and overtime parameters.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
id | int | Yes | Loaded as the key from data/nurses.csv |
name | string | No | Human-readable nurse name |
skill_level | int | No | Used in skill-requirement constraints |
hourly_cost | float | No | Cost per hour, used in overtime cost calculation |
regular_hours | int | No | Hours before overtime applies (8) |
overtime_multiplier | float | No | Overtime pay multiplier (1.5x) |
overtime_hours | float | No | Continuous decision variable ( |
Shift
A time period with coverage requirements and patient demand.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
id | int | Yes | Loaded as the key from data/shifts.csv |
name | string | No | Human-readable shift name |
start_hour | int | No | Shift start time (24-hour format) |
duration | int | No | Shift length in hours |
min_nurses | int | No | Minimum nurses required |
min_skill | int | No | Minimum skill level for at least one assigned nurse |
patient_demand | int | No | Total patients needing care |
patients_per_nurse_hour | float | No | Patients one nurse can serve per hour |
patients_served | float | No | Continuous decision variable ( |
unmet_demand | float | No | Continuous decision variable ( |
Availability
A nurse-shift pair indicating whether the nurse can work that shift.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
nurse | Nurse | Part of compound key | Joined via data/availability.csv.nurse_id |
shift | Shift | Part of compound key | Joined via data/availability.csv.shift_id |
available | int | No | 1 if available, 0 otherwise |
Assignment
A decision concept created for each Availability row; the solver chooses whether to assign.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
availability | Availability | Yes | One assignment per nurse-shift pair |
assigned | float | No | Binary decision variable (0/1) |
How it works
This section walks through the highlights in hospital_staffing.py.
Import libraries and configure inputs
First, the script imports the Semantics APIs (Model, data, define, where, require, sum) and configures local inputs like DATA_DIR:
from pathlib import Path
import pandasfrom pandas import read_csv
from relationalai.semantics import Model, data, define, require, select, sum, wherefrom relationalai.semantics.reasoners.optimization import Solver, SolverModel
# --------------------------------------------------# Configure inputs# --------------------------------------------------
DATA_DIR = Path(__file__).parent / "data"
# Disable pandas inference of string types. This ensures that string columns# in the CSVs are loaded as object dtype. This is only required when using# relationalai versions prior to v1.0.pandas.options.future.infer_string = False
# --------------------------------------------------# Define semantic model & load data# --------------------------------------------------
model = Model("hospital_staffing", config=globals().get("config", None), use_lqp=False)Define concepts and load CSV data
Next, it declares the Nurse and Shift concepts and loads nurses.csv and shifts.csv via data(...).into(...):
# Nurse concept: staff members with skill level, cost, and overtime parameters.Nurse = model.Concept("Nurse")Nurse.id = model.Property("{Nurse} has {id:int}")Nurse.name = model.Property("{Nurse} has {name:string}")Nurse.skill_level = model.Property("{Nurse} has {skill_level:int}")Nurse.hourly_cost = model.Property("{Nurse} has {hourly_cost:float}")Nurse.regular_hours = model.Property("{Nurse} has {regular_hours:int}")Nurse.overtime_multiplier = model.Property("{Nurse} has {overtime_multiplier:float}")Nurse.x_overtime_hours = model.Property("{Nurse} has {overtime_hours:float}")
# Load nurse data from CSV and create Nurse entities.data(read_csv(DATA_DIR / "nurses.csv")).into(Nurse, keys=["id"])
# Shift concept: time periods with coverage requirements and patient demand.Shift = model.Concept("Shift")Shift.id = model.Property("{Shift} has {id:int}")Shift.name = model.Property("{Shift} has {name:string}")Shift.start_hour = model.Property("{Shift} has {start_hour:int}")Shift.duration = model.Property("{Shift} has {duration:int}")Shift.min_nurses = model.Property("{Shift} has {min_nurses:int}")Shift.min_skill = model.Property("{Shift} has {min_skill:int}")Shift.patient_demand = model.Property("{Shift} has {patient_demand:int}")Shift.patients_per_nurse_hour = model.Property("{Shift} has {patients_per_nurse_hour:float}")Shift.x_patients_served = model.Property("{Shift} has {patients_served:float}")Shift.x_unmet_demand = model.Property("{Shift} has {unmet_demand:float}")
# Load shift data from CSV and create Shift entities.data(read_csv(DATA_DIR / "shifts.csv")).into(Shift, keys=["id"])availability.csv contains foreign keys (nurse_id, shift_id). The template resolves them into Nurse and Shift instances and creates an Availability concept per row using where(...).define(...):
# Availability concept: links nurses to shifts they can work.Availability = model.Concept("Availability")Availability.nurse = model.Property("{Availability} for {nurse:Nurse}")Availability.shift = model.Property("{Availability} in {shift:Shift}")Availability.available = model.Property("{Availability} is {available:int}")
# Load availability data from CSV.avail_data = data(read_csv(DATA_DIR / "availability.csv"))
# Define Availability entities by joining nurse/shift IDs from the CSV with the# Nurse and Shift concepts.where( Nurse.id == avail_data.nurse_id, Shift.id == avail_data.shift_id,).define( Availability.new(nurse=Nurse, shift=Shift, available=avail_data.available))Define decision variables, constraints, and objective
An Assignment decision concept is created for every Availability row. The script then declares four decision variables on a SolverModel: binary assignment flags, continuous overtime hours, patients served per shift, and unmet demand per shift:
# Assignment decision concept: represents the decision variables for assigning# nurses to shifts. Each Assignment is linked to an Availability entity, which# indicates whether the nurse can work that shift.Assignment = model.Concept("Assignment")Assignment.availability = model.Property("{Assignment} uses {availability:Availability}")Assignment.x_assigned = model.Property("{Assignment} is {assigned:float}")define(Assignment.new(availability=Availability))
Asn = Assignment.ref()
s = SolverModel(model, "cont")
# Variable: binary assignment (nurse to shift)s.solve_for( Assignment.x_assigned, type="bin", name=[ "x", Assignment.availability.nurse.name, Assignment.availability.shift.name, ],)
# Variable: overtime hours per nurse (continuous >= 0)s.solve_for(Nurse.x_overtime_hours, type="cont", name=["ot", Nurse.name], lower=0)
# Variable: patients served per shift (continuous >= 0)s.solve_for(Shift.x_patients_served, type="cont", name=["pt", Shift.name], lower=0)
# Variable: unmet patient demand per shift (continuous >= 0)s.solve_for(Shift.x_unmet_demand, type="cont", name=["ud", Shift.name], lower=0)Then it adds nine constraints using require(...) and s.satisfy(...). The constraints enforce availability, minimum and maximum shifts per nurse, coverage and skill requirements, overtime accounting, demand and capacity caps, and the definition of unmet demand:
# Constraint: can only assign if availablemust_be_available = require(Assignment.x_assigned <= Assignment.availability.available)s.satisfy(must_be_available)
# Constraint: every nurse works at least one shiftnurse_shift_count = sum(Asn.assigned).where(Asn.availability.nurse == Nurse).per(Nurse)min_one_shift = require(nurse_shift_count >= 1)s.satisfy(min_one_shift)
# Constraint: max 2 shifts per nurse (safety limit: 16 hours max)max_two_shifts = require(nurse_shift_count <= 2)s.satisfy(max_two_shifts)
# Constraint: minimum nurses per shiftshift_staff_count = sum(Asn.assigned).where(Asn.availability.shift == Shift).per(Shift)min_coverage = require(shift_staff_count >= Shift.min_nurses)s.satisfy(min_coverage)
# Constraint: at least one nurse with required skill level per shiftskilled_coverage = sum(Asn.assigned).where( Asn.availability.shift == Shift, Asn.availability.nurse.skill_level >= Shift.min_skill,).per(Shift)min_skilled = require(skilled_coverage >= 1)s.satisfy(min_skilled)
# Constraint: overtime >= total hours worked - regular hourstotal_hours_worked = sum(Asn.assigned * Asn.availability.shift.duration).where( Asn.availability.nurse == Nurse).per(Nurse)overtime_def = require(Nurse.x_overtime_hours >= total_hours_worked - Nurse.regular_hours)s.satisfy(overtime_def)
# Constraint: patients served <= patient demand per shiftdemand_cap = require(Shift.x_patients_served <= Shift.patient_demand)s.satisfy(demand_cap)
# Constraint: patients served <= nursing capacity per shiftshift_nursing_capacity = shift_staff_count * Shift.patients_per_nurse_hour * Shift.durationcapacity_cap = require(Shift.x_patients_served <= shift_nursing_capacity)s.satisfy(capacity_cap)
# Constraint: unmet demand >= patient demand - patients servedunmet_def = require(Shift.x_unmet_demand >= Shift.patient_demand - Shift.x_patients_served)s.satisfy(unmet_def)With the feasible region defined, the objective minimizes overtime cost plus an overflow penalty for unmet patient demand. The overflow_penalty_per_patient parameter ($20/patient) represents the cost of failing to serve a patient:
# Objective: minimize overtime cost + overflow penalty for unmet patient demand.# overflow_penalty_per_patient represents the cost of failing to serve a patient# (missed care ratios, throughput shortfall, regulatory risk).overflow_penalty_per_patient = 20overtime_cost = sum(Nurse.x_overtime_hours * Nurse.hourly_cost * Nurse.overtime_multiplier)total_overflow_penalty = overflow_penalty_per_patient * sum(Shift.x_unmet_demand)s.minimize(overtime_cost + total_overflow_penalty)Solve and print results
Finally, the script solves with the HiGHS backend and prints overtime assignments (nurses with more than 0.5 overtime hours), patient throughput by shift, and the staff assignment schedule:
solver = Solver("highs")s.solve(solver, time_limit_sec=60)
print(f"Status: {s.termination_status}")print(f"Total cost: ${s.objective_value:.2f}")
# Overtime summaryovertime = select( Nurse.name.alias("nurse"), Nurse.x_overtime_hours.alias("overtime_hours"),).where(Nurse.x_overtime_hours > 0.5).to_df()
if not overtime.empty: print("\nOvertime assignments:") print(overtime.to_string(index=False))else: print("\nNo overtime assigned.")
# Throughput and overflow summarythroughput = select( Shift.name.alias("shift"), Shift.x_patients_served.alias("patients_served"), Shift.patient_demand.alias("patient_demand"), Shift.x_unmet_demand.alias("unmet_demand"),).to_df()
print("\nPatient throughput by shift:")print(throughput.to_string(index=False))print(f"Total patients served: {throughput['patients_served'].sum():.0f} / {throughput['patient_demand'].sum()}")print(f"Total unmet demand: {throughput['unmet_demand'].sum():.0f} patients")
# Staff assignmentsassignments = select( Assignment.availability.nurse.name.alias("nurse"), Assignment.availability.shift.name.alias("shift"),).where(Assignment.x_assigned > 0.5).to_df()
print("\nStaff assignments:")print(assignments.to_string(index=False))Customize this template
Here are some ideas for how to customize and extend this template to fit your specific use case.
Tune parameters
The overflow_penalty_per_patient parameter controls the trade-off between overtime cost and unmet patient demand.
| Parameter | Default | Effect |
|---|---|---|
overflow_penalty_per_patient | 20 | Higher values push the optimizer to staff up at overtime cost; lower values tolerate more unmet demand |
Use your own data
- Replace the CSVs in
data/with your own, keeping the same column names (or update the loading logic inhospital_staffing.py). - Ensure that
availability.csvonly references validnurse_idandshift_idvalues. - Each nurse needs a
regular_hoursvalue (standard hours before overtime) and anovertime_multiplier. - Each shift needs
patient_demandandpatients_per_nurse_hourvalues for throughput modeling.
Extend the model
- Add shift preferences or fairness constraints (e.g., balanced workload across nurses).
- Add per-shift overtime caps or total overtime budget limits.
- Model multi-day scheduling by adding a date dimension to shifts.
- Add break/rest constraints between consecutive shifts.
Scale up and productionize
- Replace CSV ingestion with Snowflake sources.
- Write staff assignments and overtime plans back to Snowflake after solving.
Troubleshooting
Why does authentication/configuration fail?
- Run
rai initto create/updateraiconfig.toml. - If you have multiple profiles, set
RAI_PROFILEor switch profiles in your config.
Why does the script fail to connect to the RAI Native App?
- Verify the Snowflake account/role/warehouse and
rai_app_nameare correct inraiconfig.toml. - Ensure the RAI Native App is installed and you have access.
Why do I get ModuleNotFoundError?
- Confirm your virtual environment is activated.
- Install the template dependencies from this folder:
python -m pip install .
Why do I get Status: INFEASIBLE?
- Check that the combination of nurse availability and shift requirements is feasible. Every shift needs
min_nursesavailable nurses, and every nurse must have at least one available shift. - Confirm that at least one nurse with the required
min_skilllevel is available for each shift. - If you modified the data, ensure
regular_hoursandovertime_multiplierare positive.
Why is the overtime summary empty?
- The script filters nurses with
Nurse.x_overtime_hours > 0.5. If no nurse works beyond their regular hours, the “No overtime assigned” message is printed instead. - This is expected when staffing requirements can be met without overtime.
Why are staff assignments missing or incomplete?
- The output filters on
Assignment.x_assigned > 0.5(binary threshold). If values are near zero, inspect the availability data and constraints. - Confirm
availability.csvwas read correctly and contains rows withavailable = 1.
CSV loading fails (missing file or column)
- Confirm the CSVs exist under
data/and the filenames match. - Ensure the headers match the expected schema:
nurses.csv:id,name,skill_level,hourly_cost,regular_hours,overtime_multipliershifts.csv:id,name,start_hour,duration,min_nurses,min_skill,patient_demand,patients_per_nurse_houravailability.csv:nurse_id,shift_id,available
What this template is for
Hospitals must schedule nurses across shifts while keeping overtime costs low and ensuring patients receive care. This template models assigning 6 nurses to 3 shifts (Morning, Afternoon, Night), where each shift has patient demand that depends on staffing levels.
Nurses can work extra shifts beyond their regular hours, but at a premium rate (1.5x). When staffing falls short and patient demand goes unmet, the hospital incurs an overflow penalty reflecting missed care ratios, throughput shortfalls, and regulatory risk.
This template uses RelationalAI’s Prescriptive reasoning capabilities to find the assignment of nurses to shifts that minimizes the combined cost of overtime and overflow, while respecting availability, safety limits, and skill requirements.
Prescriptive reasoning helps you:
- Control overtime cost: Identify the lowest-cost overtime assignments when extra coverage is needed.
- Avoid costly overflow: Ensure high-demand shifts get additional staff to prevent unmet patient demand.
- Quantify trade-offs: Determine whether overtime cost is justified by the overflow penalty it avoids.
- Enforce safety limits: Guarantee no nurse works more than 2 shifts (16 hours).
Who this is for
- You want an end-to-end example of prescriptive reasoning (optimization) with RelationalAI.
- You’re comfortable with basic Python and mixed-integer optimization concepts.
What you’ll build
- A semantic model of nurses, shifts, and availability using concepts and properties.
- A MILP model with binary assignment variables, continuous overtime hours, patient throughput, and unmet demand variables.
- Nine constraints covering availability, coverage, skill requirements, overtime, demand, capacity, and overflow.
- An objective that minimizes overtime cost plus an overflow penalty for unserved patients.
- A solver that uses the HiGHS backend and prints overtime assignments, patient throughput, and staff schedules.
What’s included
- Model + solve script:
hospital_staffing.py - Sample data:
data/nurses.csv,data/shifts.csv,data/availability.csv
Prerequisites
Access
- A Snowflake account that has the RAI Native App installed.
- A Snowflake user with permissions to access the RAI Native App.
Tools
- Python >= 3.10
Quickstart
Follow these steps to run the template with the included sample data. You can customize the data and model as needed after you have it running end-to-end.
-
Download the ZIP file for this template and extract it:
Terminal window curl -O https://private.relational.ai/templates/zips/v0.14/hospital_staffing.zipunzip hospital_staffing.zipcd hospital_staffing -
Create and activate a virtual environment
Terminal window python -m venv .venvsource .venv/bin/activatepython -m pip install -U pip -
Install dependencies
From this folder:
Terminal window python -m pip install . -
Configure Snowflake connection and RAI profile
Terminal window rai init -
Run the template
Terminal window python hospital_staffing.py -
Expected output
Status: OPTIMALTotal cost: $616.00Overtime assignments:nurse overtime_hoursNurse_B 8.0Nurse_C 8.0Patient throughput by shift:shift patients_served patient_demand unmet_demandMorning 32.0 45 13.0Afternoon 60.0 60 0.0Night 24.0 25 1.0Total patients served: 116 / 130Total unmet demand: 14 patientsStaff assignments:nurse shiftNurse_A MorningNurse_A AfternoonNurse_B MorningNurse_B AfternoonNurse_C AfternoonNurse_C NightNurse_D MorningNurse_E MorningNurse_E NightNurse_F Night
Template structure
.├─ README.md├─ pyproject.toml├─ hospital_staffing.py # main runner / entrypoint└─ data/ # sample input data ├─ nurses.csv ├─ shifts.csv └─ availability.csvStart here: hospital_staffing.py
Sample data
Data files are in data/.
nurses.csv
Staff members with skill ratings, pay rates, and overtime parameters.
| Column | Meaning |
|---|---|
id | Unique nurse identifier |
name | Nurse name |
skill_level | Skill rating (1=basic, 2=intermediate, 3=advanced) |
hourly_cost | Cost per hour ($) |
regular_hours | Standard hours before overtime kicks in (8) |
overtime_multiplier | Overtime pay rate multiplier (1.5x) |
shifts.csv
Time periods with staffing requirements and patient demand.
| Column | Meaning |
|---|---|
id | Unique shift identifier |
name | Shift name (Morning, Afternoon, Night) |
start_hour | Shift start time (24-hour format) |
duration | Shift length in hours |
min_nurses | Minimum nurses required per shift |
min_skill | Minimum skill level required for at least one nurse |
patient_demand | Total patients needing care during this shift |
patients_per_nurse_hour | Patients one nurse can serve per hour |
availability.csv
Links nurses to shifts, indicating which assignments are feasible.
| Column | Meaning |
|---|---|
nurse_id | Foreign key to nurses.csv.id |
shift_id | Foreign key to shifts.csv.id |
available | 1 if the nurse can work this shift, 0 otherwise |
Model overview
The semantic model for this template is built around four concepts.
Nurse
A staff member with a skill level, hourly cost, and overtime parameters.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
id | int | Yes | Loaded as the key from data/nurses.csv |
name | string | No | Human-readable nurse name |
skill_level | int | No | Used in skill-requirement constraints |
hourly_cost | float | No | Cost per hour, used in overtime cost calculation |
regular_hours | int | No | Hours before overtime applies (8) |
overtime_multiplier | float | No | Overtime pay multiplier (1.5x) |
overtime_hours | float | No | Continuous decision variable ( |
Shift
A time period with coverage requirements and patient demand.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
id | int | Yes | Loaded as the key from data/shifts.csv |
name | string | No | Human-readable shift name |
start_hour | int | No | Shift start time (24-hour format) |
duration | int | No | Shift length in hours |
min_nurses | int | No | Minimum nurses required |
min_skill | int | No | Minimum skill level for at least one assigned nurse |
patient_demand | int | No | Total patients needing care |
patients_per_nurse_hour | float | No | Patients one nurse can serve per hour |
patients_served | float | No | Continuous decision variable ( |
unmet_demand | float | No | Continuous decision variable ( |
Availability
A nurse-shift pair indicating whether the nurse can work that shift.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
nurse | Nurse | Part of compound key | Joined via data/availability.csv.nurse_id |
shift | Shift | Part of compound key | Joined via data/availability.csv.shift_id |
available | int | No | 1 if available, 0 otherwise |
Assignment
A decision concept created for each Availability row; the solver chooses whether to assign.
| Property | Type | Identifying? | Notes |
|---|---|---|---|
availability | Availability | Yes | One assignment per nurse-shift pair |
assigned | float | No | Binary decision variable (0/1) |
How it works
This section walks through the highlights in hospital_staffing.py.
Import libraries and configure inputs
First, the script imports the Semantics APIs (Model, Relationship, data, define, where, require, sum, select) and configures local inputs like DATA_DIR:
from pathlib import Path
import pandasfrom pandas import read_csv
from relationalai.semantics import Model, Relationship, data, define, require, select, sum, wherefrom relationalai.semantics.reasoners.optimization import Solver, SolverModel
# --------------------------------------------------# Configure inputs# --------------------------------------------------
DATA_DIR = Path(__file__).parent / "data"
# Disable pandas inference of string types. This ensures that string columns# in the CSVs are loaded as object dtype. This is only required when using# relationalai versions prior to v1.0.pandas.options.future.infer_string = False
# --------------------------------------------------# Define semantic model & load data# --------------------------------------------------
model = Model("hospital_staffing", config=globals().get("config", None))Define concepts and load CSV data
Next, it declares the Nurse and Shift concepts and loads nurses.csv and shifts.csv via data(...).into(...):
# Nurse concept: staff members with skill level, cost, and overtime parameters.Nurse = model.Concept("Nurse")Nurse.id = model.Property("{Nurse} has {id:int}")Nurse.name = model.Property("{Nurse} has {name:string}")Nurse.skill_level = model.Property("{Nurse} has {skill_level:int}")Nurse.hourly_cost = model.Property("{Nurse} has {hourly_cost:float}")Nurse.regular_hours = model.Property("{Nurse} has {regular_hours:int}")Nurse.overtime_multiplier = model.Property("{Nurse} has {overtime_multiplier:float}")Nurse.x_overtime_hours = model.Property("{Nurse} has {overtime_hours:float}")
# Load nurse data from CSV and create Nurse entities.data(read_csv(DATA_DIR / "nurses.csv")).into(Nurse, keys=["id"])
# Shift concept: time periods with coverage requirements and patient demand.Shift = model.Concept("Shift")Shift.id = model.Property("{Shift} has {id:int}")Shift.name = model.Property("{Shift} has {name:string}")Shift.start_hour = model.Property("{Shift} has {start_hour:int}")Shift.duration = model.Property("{Shift} has {duration:int}")Shift.min_nurses = model.Property("{Shift} has {min_nurses:int}")Shift.min_skill = model.Property("{Shift} has {min_skill:int}")Shift.patient_demand = model.Property("{Shift} has {patient_demand:int}")Shift.patients_per_nurse_hour = model.Property("{Shift} has {patients_per_nurse_hour:float}")Shift.x_patients_served = model.Property("{Shift} has {patients_served:float}")Shift.x_unmet_demand = model.Property("{Shift} has {unmet_demand:float}")
# Load shift data from CSV and create Shift entities.data(read_csv(DATA_DIR / "shifts.csv")).into(Shift, keys=["id"])availability.csv contains foreign keys (nurse_id, shift_id). The template resolves them into Nurse and Shift instances and creates an Availability concept per row using where(...).define(...):
# Availability concept: links nurses to shifts they can work.Availability = model.Concept("Availability")Availability.nurse = model.Relationship("{Availability} for {nurse:Nurse}")Availability.shift = model.Relationship("{Availability} in {shift:Shift}")Availability.available = model.Property("{Availability} is {available:int}")
# Load availability data from CSV.avail_data = data(read_csv(DATA_DIR / "availability.csv"))
# Define Availability entities by joining nurse/shift IDs from the CSV with the# Nurse and Shift concepts.where( Nurse.id == avail_data.nurse_id, Shift.id == avail_data.shift_id,).define( Availability.new(nurse=Nurse, shift=Shift, available=avail_data.available))Define decision variables, constraints, and objective
An Assignment decision concept is created for every Availability row. The script then declares four decision variables on a SolverModel: binary assignment flags, continuous overtime hours, patients served per shift, and unmet demand per shift:
# Assignment decision concept: represents the decision variables for assigning# nurses to shifts. Each Assignment is linked to an Availability entity, which# indicates whether the nurse can work that shift.Assignment = model.Concept("Assignment")Assignment.availability = model.Relationship("{Assignment} uses {availability:Availability}")Assignment.x_assigned = model.Property("{Assignment} is {assigned:float}")define(Assignment.new(availability=Availability))
AssignmentRef = Assignment.ref()
s = SolverModel(model, "cont")
# Variable: binary assignment (nurse to shift)s.solve_for( Assignment.x_assigned, type="bin", name=[ "x", Assignment.availability.nurse.name, Assignment.availability.shift.name, ],)
# Variable: overtime hours per nurse (continuous >= 0)s.solve_for(Nurse.x_overtime_hours, type="cont", name=["ot", Nurse.name], lower=0)
# Variable: patients served per shift (continuous >= 0)s.solve_for(Shift.x_patients_served, type="cont", name=["pt", Shift.name], lower=0)
# Variable: unmet patient demand per shift (continuous >= 0)s.solve_for(Shift.x_unmet_demand, type="cont", name=["ud", Shift.name], lower=0)Then it adds nine constraints using require(...) and s.satisfy(...). The constraints enforce availability, minimum and maximum shifts per nurse, coverage and skill requirements, overtime accounting, demand and capacity caps, and the definition of unmet demand:
# Constraint: can only assign if availablemust_be_available = require(Assignment.x_assigned <= Assignment.availability.available)s.satisfy(must_be_available)
# Constraint: every nurse works at least one shiftnurse_shift_count = sum(AssignmentRef.x_assigned).where(AssignmentRef.availability.nurse == Nurse).per(Nurse)min_one_shift = require(nurse_shift_count >= 1)s.satisfy(min_one_shift)
# Constraint: max 2 shifts per nurse (safety limit: 16 hours max)max_two_shifts = require(nurse_shift_count <= 2)s.satisfy(max_two_shifts)
# Constraint: minimum nurses per shiftshift_staff_count = sum(AssignmentRef.x_assigned).where(AssignmentRef.availability.shift == Shift).per(Shift)min_coverage = require(shift_staff_count >= Shift.min_nurses)s.satisfy(min_coverage)
# Constraint: at least one nurse with required skill level per shiftskilled_coverage = sum(AssignmentRef.x_assigned).where( AssignmentRef.availability.shift == Shift, AssignmentRef.availability.nurse.skill_level >= Shift.min_skill,).per(Shift)min_skilled = require(skilled_coverage >= 1)s.satisfy(min_skilled)
# Constraint: overtime >= total hours worked - regular hourstotal_hours_worked = sum(AssignmentRef.x_assigned * AssignmentRef.availability.shift.duration).where( AssignmentRef.availability.nurse == Nurse).per(Nurse)overtime_def = require(Nurse.x_overtime_hours >= total_hours_worked - Nurse.regular_hours)s.satisfy(overtime_def)
# Constraint: patients served <= patient demand per shiftdemand_cap = require(Shift.x_patients_served <= Shift.patient_demand)s.satisfy(demand_cap)
# Constraint: patients served <= nursing capacity per shiftshift_nursing_capacity = shift_staff_count * Shift.patients_per_nurse_hour * Shift.durationcapacity_cap = require(Shift.x_patients_served <= shift_nursing_capacity)s.satisfy(capacity_cap)
# Constraint: unmet demand >= patient demand - patients servedunmet_def = require(Shift.x_unmet_demand >= Shift.patient_demand - Shift.x_patients_served)s.satisfy(unmet_def)With the feasible region defined, the objective minimizes overtime cost plus an overflow penalty for unmet patient demand. The overflow_penalty_per_patient parameter ($20/patient) represents the cost of failing to serve a patient:
# Objective: minimize overtime cost + overflow penalty for unmet patient demand.# overflow_penalty_per_patient represents the cost of failing to serve a patient# (missed care ratios, throughput shortfall, regulatory risk).overflow_penalty_per_patient = 20overtime_cost = sum(Nurse.x_overtime_hours * Nurse.hourly_cost * Nurse.overtime_multiplier)total_overflow_penalty = overflow_penalty_per_patient * sum(Shift.x_unmet_demand)s.minimize(overtime_cost + total_overflow_penalty)Solve and print results
Finally, the script solves with the HiGHS backend and prints overtime assignments (nurses with more than 0.5 overtime hours), patient throughput by shift, and the staff assignment schedule:
solver = Solver("highs")s.solve(solver, time_limit_sec=60)
print(f"Status: {s.termination_status}")print(f"Total cost: ${s.objective_value:.2f}")
# Overtime summaryovertime = select( Nurse.name.alias("nurse"), Nurse.x_overtime_hours.alias("overtime_hours"),).where(Nurse.x_overtime_hours > 0.5).to_df()
if not overtime.empty: print("\nOvertime assignments:") print(overtime.to_string(index=False))else: print("\nNo overtime assigned.")
# Throughput and overflow summarythroughput = select( Shift.name.alias("shift"), Shift.x_patients_served.alias("patients_served"), Shift.patient_demand.alias("patient_demand"), Shift.x_unmet_demand.alias("unmet_demand"),).to_df()
print("\nPatient throughput by shift:")print(throughput.to_string(index=False))print(f"Total patients served: {throughput['patients_served'].sum():.0f} / {throughput['patient_demand'].sum()}")print(f"Total unmet demand: {throughput['unmet_demand'].sum():.0f} patients")
# Staff assignmentsassignments = select( Assignment.availability.nurse.name.alias("nurse"), Assignment.availability.shift.name.alias("shift"),).where(Assignment.x_assigned > 0.5).to_df()
print("\nStaff assignments:")print(assignments.to_string(index=False))Customize this template
Here are some ideas for how to customize and extend this template to fit your specific use case.
Tune parameters
The overflow_penalty_per_patient parameter controls the trade-off between overtime cost and unmet patient demand.
| Parameter | Default | Effect |
|---|---|---|
overflow_penalty_per_patient | 20 | Higher values push the optimizer to staff up at overtime cost; lower values tolerate more unmet demand |
Use your own data
- Replace the CSVs in
data/with your own, keeping the same column names (or update the loading logic inhospital_staffing.py). - Ensure that
availability.csvonly references validnurse_idandshift_idvalues. - Each nurse needs a
regular_hoursvalue (standard hours before overtime) and anovertime_multiplier. - Each shift needs
patient_demandandpatients_per_nurse_hourvalues for throughput modeling.
Extend the model
- Add shift preferences or fairness constraints (e.g., balanced workload across nurses).
- Add per-shift overtime caps or total overtime budget limits.
- Model multi-day scheduling by adding a date dimension to shifts.
- Add break/rest constraints between consecutive shifts.
Scale up and productionize
- Replace CSV ingestion with Snowflake sources.
- Write staff assignments and overtime plans back to Snowflake after solving.
Troubleshooting
Why does authentication/configuration fail?
- Run
rai initto create/updateraiconfig.toml. - If you have multiple profiles, set
RAI_PROFILEor switch profiles in your config.
Why does the script fail to connect to the RAI Native App?
- Verify the Snowflake account/role/warehouse and
rai_app_nameare correct inraiconfig.toml. - Ensure the RAI Native App is installed and you have access.
Why do I get ModuleNotFoundError?
- Confirm your virtual environment is activated.
- Install the template dependencies from this folder:
python -m pip install .
Why do I get Status: INFEASIBLE?
- Check that the combination of nurse availability and shift requirements is feasible. Every shift needs
min_nursesavailable nurses, and every nurse must have at least one available shift. - Confirm that at least one nurse with the required
min_skilllevel is available for each shift. - If you modified the data, ensure
regular_hoursandovertime_multiplierare positive.
Why is the overtime summary empty?
- The script filters nurses with
Nurse.x_overtime_hours > 0.5. If no nurse works beyond their regular hours, the “No overtime assigned” message is printed instead. - This is expected when staffing requirements can be met without overtime.
Why are staff assignments missing or incomplete?
- The output filters on
Assignment.x_assigned > 0.5(binary threshold). If values are near zero, inspect the availability data and constraints. - Confirm
availability.csvwas read correctly and contains rows withavailable = 1.
CSV loading fails (missing file or column)
- Confirm the CSVs exist under
data/and the filenames match. - Ensure the headers match the expected schema:
nurses.csv:id,name,skill_level,hourly_cost,regular_hours,overtime_multipliershifts.csv:id,name,start_hour,duration,min_nurses,min_skill,patient_demand,patients_per_nurse_houravailability.csv:nurse_id,shift_id,available
What this template is for
This template uses prescriptive reasoning (optimization) to frame hospital nurse scheduling as a bi-objective problem with two competing objectives: minimize overtime cost versus minimize unmet patient demand. The original single-objective formulation bundled both goals into one weighted penalty sum, forcing the modeler to choose a penalty weight up front. This version unbundles them using the epsilon constraint method: it sweeps a range of caps on allowable unmet demand, and at each cap the solver minimizes overtime cost subject to that service-level constraint.
The result is a Pareto frontier that reveals exactly how much overtime cost each level of patient service requires — making the tradeoff explicit and auditable rather than hidden inside a penalty weight.
Who this is for
- Healthcare operations managers building nurse scheduling systems
- Data engineers integrating optimization into hospital workforce platforms
- Developers learning to model staffing problems with mixed binary and continuous variables
- Anyone exploring multi-objective optimization with coverage and skill constraints
What you’ll build
- A nurse-to-shift assignment model with binary decision variables
- Overtime tracking with continuous variables and cost multipliers
- Patient throughput and unmet demand calculations per shift
- Minimum staffing and skill-level coverage constraints
- Epsilon constraint method sweeping unmet demand caps to trace the cost-service frontier
- Pareto analysis with marginal cost per patient and knee detection
What’s included
hospital_staffing.py— Main script with model definition, epsilon constraint sweep, and Pareto analysis- Runbook:
runbook.md— a paste-testable walkthrough that reproduces the template step by step with the RAI skills; as important a reference as the script itself. data/nurses.csv— Nurse roster with skill levels, hourly costs, and overtime parametersdata/shifts.csv— Shift definitions with timing, staffing requirements, and patient demanddata/availability.csv— Nurse-to-shift availability matrixpyproject.toml— Python project configuration with dependencies
Prerequisites
Access
- A Snowflake account that has the RAI Native App installed.
- A Snowflake user with permissions to access the RAI Native App.
Tools
- Python >= 3.10
- RelationalAI Python SDK (
relationalai) >= 1.0.14
Quickstart
-
Download ZIP:
Terminal window curl -O https://docs.relational.ai/templates/zips/v1/hospital_staffing.zipunzip hospital_staffing.zipcd hospital_staffing -
Create venv:
Terminal window python -m venv .venvsource .venv/bin/activatepython -m pip install --upgrade pip -
Install:
Terminal window python -m pip install . -
Configure:
Terminal window rai init -
Run:
Terminal window python hospital_staffing.py -
Expected output:
======================================================================ANCHOR SOLVE 1: Minimize overtime cost (no unmet demand constraint)======================================================================Overtime cost: $0.00Unmet demand: 130.0 patients======================================================================ANCHOR SOLVE 2: Minimize unmet demand (no overtime cost objective)======================================================================Min unmet demand: 0.0 patientsFeasible unmet demand range: [0.0, 130.0]======================================================================EPSILON SWEEP: 5 interior pointsUnmet demand targets: ['108.3', '86.7', '65.0', '43.3', '21.7']============================================================================================================================================EFFICIENT FRONTIER: Overtime Cost vs Patient Service======================================================================# Label Unmet Demand Overtime Cost------------------------------------------------1 cheapest 130.0 $ 0.002 eps_1 108.3 $ 0.003 eps_2 86.7 $ 0.004 eps_3 65.0 $ 0.005 eps_4 43.3 $ 0.006 eps_5 21.7 $ 336.007 best_service 0.0 $ 1728.00Overtime Cost$ 1,728.00 |7 || || || || || || || || 6 || || |$ 0.00 | 5 4 3 2 1|+--------------------------------------------------+0 130 patientsUnmet DemandMarginal analysis (cost of reducing unmet demand by 1 patient):cheapest -> eps_4 : $0.00/patient (free capacity available)eps_4 -> eps_5 : $15.51/patienteps_5 -> best_service : $64.25/patientKnee: Point 5 (eps_4) -- marginal cost jumps 15.5x beyond this pointRecommendation: Target 43 unmet patients at $0.00 overtime cost --further service improvement costs significantly more per patient.Knee-point assignments:A_Afternoon: NurseB_Night: NurseC_Afternoon: NurseD_Morning: NurseE_Morning: NurseF_Night: NurseThe Pareto frontier reveals a sharp knee at point 5: the first 67% of demand reduction (130 to 43 patients) is free, but reducing the last 43 patients costs
0 to 64.25 per patient.
Template structure
.├── README.md├── pyproject.toml├── hospital_staffing.py└── data/ ├── nurses.csv ├── shifts.csv └── availability.csvStart here: run python hospital_staffing.py for the full anchor-solve, epsilon-sweep, and Pareto analysis end to end, or follow runbook.md to reproduce it step by step with the RAI skills.
Sample data
The bundled data is small and illustrative — a single hospital day with a handful of nurses and shifts, sized so the whole epsilon sweep solves in seconds. It is designed to make the cost-service tradeoff visible, not to match a specific hospital’s roster.
nurses.csv— the nurse roster: skill level, hourly cost, regular-hour limit, and an overtime pay multiplier per nurse.shifts.csv— shift definitions: start hour, duration, minimum nurses, minimum skill, patient demand, and the patients each nurse-hour can serve.availability.csv— a nurse-to-shift availability matrix;available = 1means the nurse can work that shift.
Model overview
The model is a small optimization ontology: two entities (nurses, shifts) linked by an availability matrix, with a per-availability assignment decision the solver fills in.
- Key entities:
Nurse— a nurse with a skill level and cost parameters (the solver adds overtime hours);Shift— a shift with coverage requirements and patient demand (the solver adds patients-served and unmet-demand quantities);Availability— a nurse’s eligibility for a shift;Assignment— the decision to staff a nurse on a shift, the MILP’s decision space. - Primary identifiers:
Nurse.idandShift.id(integers);Availabilityis keyed by the(nurse_id, shift_id)pair;Assignmentis keyed by itsAvailability. - Important invariants: overtime hours, patients served, and unmet demand are non-negative continuous quantities; each nurse works one to two shifts; every shift meets its minimum-nurse and minimum-skill floor; assignment decisions are binary.
For the full concept and property definitions, see hospital_staffing.py; runbook.md builds them step by step with the RAI skills.
How it works
Define concepts and load CSV data. The model defines three core concepts: nurses with skill levels and cost parameters, shifts with coverage requirements and patient demand, and an availability relationship linking nurses to shifts. A per-availability Assignment carries the binary staffing decision the solver fills in.
Define decision variables, constraints, and objective. A solve_staffing helper encapsulates the full formulation. It registers four variable types — the binary assignment, each nurse’s overtime hours, and each shift’s patients-served and unmet-demand quantities — and applies all constraints: availability, minimum staffing, skill coverage, overtime tracking, and patient-demand accounting (each nurse works one to two shifts; every shift meets its minimum-nurse floor). The original single-objective template bundled overtime cost and unmet demand into one weighted penalty sum, forcing the modeler to pick a penalty weight up front. The bi-objective version splits them: the primary objective minimizes overtime cost, while unmet demand is bounded by an epsilon constraint that caps total unmet demand across all shifts. This eliminates the arbitrary penalty weight and reveals the true tradeoff.
Solve anchor points and run the epsilon sweep. Two anchor solves establish the feasible unmet-demand range: anchor 1 minimizes overtime with no demand constraint (the cheapest schedule, which may leave patients unserved), and anchor 2 minimizes unmet demand (the best achievable service level). The epsilon sweep then traces interior points between the anchors, each solve minimizing overtime cost subject to a progressively tighter cap on unmet demand.
Pareto analysis output. The script prints the efficient frontier showing how overtime cost rises as the unmet-demand cap tightens. Marginal analysis computes the cost of reducing unmet demand by one patient, and a knee detector flags the point where that marginal cost jumps sharply — recommending the best cost-service balance.
See hospital_staffing.py for the implementation, and runbook.md for the skill-driven reproduction.
Customize this template
Focus on the first changes most users will make.
Use your own data
- Replace the CSVs in
data/with your own roster, shifts, and availability; keep the column names listed in Sample data above. - Ensure
availability.csvonly references valid nurse and shift IDs, and that available nurses provide enough coverage to meet each shift’smin_nursesandmin_skillrequirements. - Add more nurses or shifts simply by appending rows to the CSVs.
Tune parameters
- Frontier resolution — increase
n_interiorfor a finer-grained Pareto frontier (more interior epsilon points between the two anchors). - Solve budget —
time_limit_seconproblem.solve("highs", ...)caps each solve; raise it if larger rosters time out.
Extend the model
- Add shift preferences by introducing a preference weight per nurse-shift pair and folding it into the objective.
- Model consecutive-shift restrictions by adding constraints that prevent nurses from working back-to-back shifts without rest.
- Introduce part-time nurses with different regular-hour limits and availability patterns.
Scale up / productionize
- Swap the
read_csv(...)loads formodel.data(snowflake_table)calls to run against roster and demand tables maintained in Snowflake. - Pin
relationalaiinpyproject.tomlfor reproducible solves, and schedule the run to refresh the frontier as demand forecasts update.
Troubleshooting
Solver returns INFEASIBLE
Check that nurse availability in availability.csv provides enough coverage to meet the minimum staffing requirements in shifts.csv. With the current data, each shift requires at least 2 nurses, so ensure enough nurses are available per shift.
High unmet demand in the solution
Tighten the epsilon constraint by reducing the unmet demand cap (or increase n_interior to explore finer gradations). If even the best-service anchor shows high unmet demand, the nurse roster may need more staff or broader availability.
Connection or authentication errors
Run rai init to configure your Snowflake connection. Verify that the RAI Native App is installed and your user has the required permissions.
ModuleNotFoundError for relationalai
Ensure you activated the virtual environment and ran python -m pip install . to install all dependencies listed in pyproject.toml.
Learn more
Core concepts
- Prescriptive reasoning — the
ProblemAPI, decision variables, constraints, and objectives. - PyRel v1 modeling — concepts, properties, and loading CSV data into relations.
Modeling reference
- Multi-objective optimization — trading off competing objectives, including epsilon-constraint sweeps.
Deeper dives
- Sensitivity and marginal analysis — reading shadow prices and marginal costs, as the Pareto knee detection does here.
Support
- File issues at the RelationalAI templates repository.