An automated nurse schedule optimization system designed for diverse and complex real-world requirements.
- Stable version hosted at nursescheduling.org.
- Latest development features hosted at dev.nursescheduling.org.
- Versioned releases remain available at URLs such as release-0-2.nursescheduling.org.
- Documentation hosted at nursescheduling.org/docs.
- Source code hosted on GitHub.
The nurse scheduling (or employee scheduling) problem is a well-known problem in the field of operations research (OR) and can be (approximately) solved efficiently by constrained optimization.
However, constraints can differ greatly between hospitals and wards, and there is currently no unified framework for modeling these diverse requirements. Most existing literature focuses on modeling an over-simplified constraint set, which is not applicable to real-world situations. Therefore, in practice, the problem is still often solved by hand with the help of Excel, which is often extremely time-consuming. The entire process requires several hours or even more than ten hours, depending on the problem complexity (e.g., co-scheduling of multiple understaffed wards).
This project (Nurse Scheduling System, or 護理排班系統 in Mandarin) provides a flexible web app and framework for automating schedule optimization across real-world scenarios. It has generated schedules used by real wards with minimal post-adjustment. We keep the main deployment stable, publish the latest features separately, retain versioned releases, and strive to preserve backward compatibility.
Development builds may introduce breaking changes. Use the stable or versioned deployments when repeatability is important.
This project focuses on the difficult and time-consuming part of rostering: turning staffing requirements, rules, and individual preferences into a good schedule. A scheduler still needs to define the ward's concrete rules. Infeasible staffing requirements may also require the scheduler to decide what can safely be relaxed. We continue to improve the ease and flexibility of expressing these constraints. Automated re-optimization also makes changed requests less costly and allows more preferences to be considered than a manual process often can.
The system complements rather than replaces a hospital's existing coordination and workforce-management processes. It intentionally does not prescribe user accounts, leave approvals, shift swaps, schedule publication, audit trails, or other self-service and governance workflows. Preferences can come from an existing hospital system, a spreadsheet, or paper and then be entered by the person preparing the schedule. In our on-site discussions, a head nurse or senior ward member typically owned this task, collecting requests was not the main bottleneck, and constructing or revising the schedule could take several hours or more than ten hours. Organizations can retain any required approvals and audit records in their existing systems.
Keeping request coordination separate makes the optimizer hospital-system agnostic and easier to adopt. Hospitals that need direct integration can import data into the scheduling format and export the result to their systems. Please open an issue to discuss additional import or export requirements.
Two hosted optimization servers are provided as free, shared, best-effort services. The lower-capacity secondary server is available as a fallback. Please use them fairly and do not abuse them. You can also self-host the backend software from this repository.
The hosted application anonymizes individual people IDs and removes descriptions by default before sending a schedule for optimization. A schedule without direct identifiers may not identify anyone by itself, but dates, groups, and patterns can still be sensitive in context. Use nicknames or non-identifying IDs when in doubt. For greater control, self-host the open-source frontend and backend so your organization can inspect the code and apply its own security and retention policies. See Privacy and Data Handling for details.
During the evaluation period, the hosted AI assistant is gated by an API key by default.
To request access for experimentation, email admin@nursescheduling.org from your institution email address. Include your institution's name and a short description of how you plan to evaluate the assistant.
Before requesting or using access, review Privacy and Data Handling. Do not submit personal, confidential, regulated, or otherwise sensitive information.
For general questions, open a GitHub issue. For personal questions, email admin@nursescheduling.org.
- bun (for frontend development).
- uv (for backend development).
- Docker (optional, for Docker-based development environment and GPU solver).
- NVIDIA Container Toolkit (optional, for GPU solver).
These are not hard requirements. If you know what you are doing, you can also use other tools to manage dependencies, such as nvm/npm for Next.js, and virtualenv or conda for Python.
Clone the repository:
git clone https://github.com/j3soon/nurse-scheduling.git
cd nurse-schedulingStart frontend:
cd web-frontend
bun install
bun run devIn a new terminal, start backend:
cd core
uv venv --python 3.12
source .venv/bin/activate
uv pip install -r requirements.txt
fastapi dev nurse_scheduling/serve.pymacOS support is experimental.
Start frontend:
cd web-frontend
bun install
bun run devIn a new terminal, start backend:
cd core
uv venv --python 3.12
source .venv/bin/activate
uv pip install -r requirements.txt
fastapi dev nurse_scheduling/serve.pyWindows OS support is experimental.
Start frontend:
cd web-frontend
bun install
bun run devIn a new terminal, start backend:
cd core
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
uv venv --python 3.12
.venv\Scripts\Activate.ps1
uv pip install -r requirements.txt
fastapi dev nurse_scheduling\serve.pyThe commands below are Linux-focused reference material for setup, testing, and Docker.
For Linux only: to quickly set up all local environments (core, web-frontend, and docs) in one go, run:
./scripts/setup_env.shFor Docker-based development environment:
CPU image:
# build image
docker build -f docker/Dockerfile -t j3soon/nurse-scheduling:dev .# persist Codex/Claude Code/OpenCode/Pi auth/config across containers
mkdir -p ~/docker/.codex
mkdir -p ~/docker/.claude
touch ~/docker/.claude.json
mkdir -p ~/docker/opencode/.config/opencode
mkdir -p ~/docker/opencode/.local/share/opencode
mkdir -p ~/docker/pi/agent
# mount project files and Codex/Claude Code/OpenCode/Pi config
docker run --rm -it --network=host \
-v $(pwd):/app \
-v ~/docker/.codex:/root/.codex \
-v ~/docker/.claude:/root/.claude \
-v ~/docker/.claude.json:/root/.claude.json \
-v ~/docker/opencode/.config/opencode:/root/.config/opencode \
-v ~/docker/opencode/.local/share/opencode:/root/.local/share/opencode \
-v ~/docker/pi/agent:/root/.pi/agent \
-v /etc/localtime:/etc/localtime:ro \
-v /etc/timezone:/etc/timezone:ro \
j3soon/nurse-scheduling:devGPU image with cuOpt support:
# build image with cuOpt support
docker build -f docker/Dockerfile.cuopt -t j3soon/nurse-scheduling:dev-cuopt .The cuOpt image omits highspy because the pinned release has no CPython 3.14
wheel. Use another environment for the pulp/highs solver.
# persist Codex/Claude Code/OpenCode/Pi auth/config across containers
mkdir -p ~/docker/.codex
mkdir -p ~/docker/.claude
touch ~/docker/.claude.json
mkdir -p ~/docker/opencode/.config/opencode
mkdir -p ~/docker/opencode/.local/share/opencode
mkdir -p ~/docker/pi/agent
# mount project files and Codex/Claude Code/OpenCode/Pi config
docker run --rm -it --gpus all --network=host \
-v $(pwd):/app \
-v ~/docker/.codex:/root/.codex \
-v ~/docker/.claude:/root/.claude \
-v ~/docker/.claude.json:/root/.claude.json \
-v ~/docker/opencode/.config/opencode:/root/.config/opencode \
-v ~/docker/opencode/.local/share/opencode:/root/.local/share/opencode \
-v ~/docker/pi/agent:/root/.pi/agent \
-v /etc/localtime:/etc/localtime:ro \
-v /etc/timezone:/etc/timezone:ro \
j3soon/nurse-scheduling:dev-cuoptAfter entering a container, use the Core commands to run the CLI or
the Web Backend commands to start a server. Use the GPU image
for pulp/cuopt.
or with X11 forwarding for running Playwright interactive mode in the container:
xhost +local:docker
mkdir -p ~/docker/.codex
mkdir -p ~/docker/.claude
touch ~/docker/.claude.json
mkdir -p ~/docker/opencode/.config/opencode
mkdir -p ~/docker/opencode/.local/share/opencode
mkdir -p ~/docker/pi/agent
# mount project files and Codex/Claude Code/OpenCode/Pi config, and forward X11 display
docker run --rm -it --network=host \
-v $(pwd):/app \
-v ~/docker/.codex:/root/.codex \
-v ~/docker/.claude:/root/.claude \
-v ~/docker/.claude.json:/root/.claude.json \
-v ~/docker/opencode/.config/opencode:/root/.config/opencode \
-v ~/docker/opencode/.local/share/opencode:/root/.local/share/opencode \
-v ~/docker/pi/agent:/root/.pi/agent \
-v /etc/localtime:/etc/localtime:ro \
-v /etc/timezone:/etc/timezone:ro \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
j3soon/nurse-scheduling:devMay need to run
rm -rf .nextinweb-frontendto clear the Next.js cache when switching between host and Docker environments.
The commands below are tested on Linux only.
cd web-frontend
bun install
bun run devRun frontend unit/component tests:
cd web-frontend
bun run testRun frontend coverage:
cd web-frontend
bun run test:coverageRun frontend browser integration tests:
cd web-frontend
bunx playwright install-deps chromium
bunx playwright install chromium
bun run test:e2e
# or in interactive UI mode:
bun run test:e2e:uiWhen using the repository docker/Dockerfile, Chromium is preinstalled in the image at
build time using the frontend's locked Playwright version. If you rebuild the
image after Playwright version changes, bun run test:e2e and
bun run test:e2e:ui should not require rerunning bunx playwright install chromium
inside each new docker run --rm container.
In GitHub Actions, frontend browser integration tests run after frontend unit/coverage tests. The workflow uploads Playwright reports as build artifacts so failed CI runs keep browser traces and reports for debugging.
Generate a separate browser-flow coverage report from Playwright:
cd web-frontend
bun run test:e2e:coverage
bun run coverage:e2e:reportThis writes a separate report under web-frontend/coverage-e2e/ and does not replace the main Vitest coverage report under web-frontend/coverage/.
For building static site, run:
cd web-frontend
bun run buildThe root netlify.toml builds the static frontend into
web-frontend/out and publishes the documentation under /docs. After linking
the repository to a Netlify project, open Project configuration → Environment
variables and configure these variables with the Builds scope:
| Variable | Value | Sensitive |
|---|---|---|
NEXT_PUBLIC_SENTRY_DSN |
Public DSN for the frontend Sentry project. | No |
SENTRY_ENVIRONMENT |
production for the production deploy context. Use a distinct value such as staging for branch deploys. |
No |
SENTRY_PROJECT |
Slug of the frontend Sentry project. | No |
SENTRY_AUTH_TOKEN |
Sentry organization auth token allowed to create releases and upload source maps for the frontend project. | Yes |
Mark SENTRY_AUTH_TOKEN as Contains secret values in Netlify. Never prefix
it with NEXT_PUBLIC_, put it in netlify.toml, or commit it to an environment
file. The Next.js Sentry build plugin reads it only while building, then uploads
the release and source maps. Without it, the site still builds and browser
events still reach Sentry through NEXT_PUBLIC_SENTRY_DSN, but production stack
traces may remain minified.
To create the token:
- Follow Sentry's auth-token instructions to create an organization token through an internal integration.
- Grant
org:cifor release and source-map operations. Ensure the integration can access the team that ownsSENTRY_PROJECT. - Copy the generated token into Netlify as
SENTRY_AUTH_TOKEN, select the Builds scope, and mark it as Contains secret values.
Trigger a new deploy after changing any build environment variable. Configure
different SENTRY_ENVIRONMENT values per Netlify deploy context when production
and branch deploys share the same frontend Sentry project.
For linting, run:
cd web-frontend
bun run lint -- --fix
buncan be replaced directly withnpmfor the basic Next.js workflow, but the documented project scripts assume Bun.
The experimental chat answers questions about the schedule currently open in
the frontend. Image and UTF-8 TXT, Markdown, and CSV attachments are enabled by
default. Disable them independently with AI_ATTACHMENT_MODE=none and
AI_DOCUMENT_ATTACHMENT_MODE=none. The chat runs as a separate backend
process and sends the schedule and enabled inputs to an OpenAI-compatible
provider.
Create a local configuration file. The real docker/.env file is ignored by Git:
cp docker/.env.example docker/.env
# Review and update the AI values. Set AI_AUTH_REQUIRED=false and leave
# AI_AUTH_TOKEN and AI_AUTH_TOKENS empty only for intentional local no-auth use.Start the AI backend and frontend in separate terminals:
./scripts/start_ai_backend.sh
./scripts/start_frontend.sh --hostname 0.0.0.0Open http://localhost:3000/experimental-ai, select Change, then select
Use localhost. The local AI backend listens on http://localhost:8001.
The page otherwise uses https://api.nursescheduling.org/ai by default. See the
AI assistant backend guide
for container commands, configuration, security notes, and focused tests.
The main solver paths are:
ortools/cp-sat, labeled OR-Tools | CP-SAT, is the recommended CPU solver and the default.pulp/cuopt, labeled PuLP | cuOpt, is the experimental GPU solver. It requires the NVIDIA cuOpt runtime and a supported GPU.
See the solver reference for the full experimental solver matrix, platform requirements, runtime capabilities, and test coverage.
cd core
# create virtual environment
uv venv --python 3.12
# activate virtual environment
source .venv/bin/activate
# install dependencies
uv pip install -r requirements.txt
# run the CPU solver, OR-Tools | CP-SAT is the default
python -m nurse_scheduling.cli <input_file_path> [output_csv_path] --solver ortools/cp-sat
# for example:
python -m nurse_scheduling.cli tests/testcases/basics/01_1nurse_1shift_1day.yaml
# run the GPU solver, PuLP | cuOpt
python -m nurse_scheduling.cli <input_file_path> [output_csv_path] --solver pulp/cuopt
# run CLI with prettify and verbose
python -m nurse_scheduling.cli <input_file_path> [output_xlsx_path] --verbose --prettify
# record solver progress as JSON Lines for later plotting
python -m nurse_scheduling.cli tests/testcases/real/large-ward-with-87-people-2025-11.yaml --verbose --prettify --timeout 180 --progress-output progress.jsonlRun tests:
cd core
# run the normal core test suite
pytest --log-cli-level=INFO
# run focused OR-Tools | CP-SAT tests
pytest --log-cli-level=INFO \
tests/test_solver_ortools_cp_sat.py \
tests/test_schedule_ortools_cp_sat.py
# run focused PuLP | cuOpt tests in the GPU environment
pytest --log-cli-level=INFO \
tests/test_solver_pulp_cuopt.py \
tests/test_schedule_pulp_cuopt.py
# run Python lint checks for core
ruff check nurse_scheduling tests
# auto-fix lint issues when possible
ruff check --fix nurse_scheduling tests
# apply consistent formatting
ruff format nurse_scheduling testsGenerate coverage report:
cd core
# terminal summary
pytest --cov=nurse_scheduling
# HTML report for local inspection
pytest --cov=nurse_scheduling --cov-report=html
# open report at:
# htmlcov/index.htmlFor more debugging output when a test fails:
cd core
pytest --log-cli-level=INFO tests/test_solver_ortools_cp_sat.py
pytest --log-cli-level=INFO tests/test_schedule_ortools_cp_sat.py
pytest --log-cli-level=INFO tests/test_solver_pulp_cuopt.py
pytest --log-cli-level=INFO tests/test_schedule_pulp_cuopt.pyNote that setting WRITE_TO_CSV=True in core/tests/schedule_test_helper.py is often useful for creating new test cases.
The checks under core/tests/real/ intentionally omit pytest's test_ filename prefix so they are not included in the
normal core suite. They solve larger real-world scenarios with fixed optimization budgets and run in the separate
test-core-real.yaml GitHub Actions workflow.
Note: The frontend now has Vitest coverage plus Playwright browser integration tests. The root GitHub Actions badge currently still points at the core workflow.
The commands below are tested on Linux only.
cd core/nurse_scheduling
# development mode
fastapi dev serve.py
cd ..
# run curl (needs to be run after the server is running)
./tests/test_serve_curl.sh
# run serve tests (don't need to be run after the server is running)
python tests/test_serve.py
# or
pytest tests/test_serve.py --log-cli-level=INFOBy default, the server exposes only OR-Tools | CP-SAT and keeps job state in process-local memory:
cd core
JOB_BACKEND=memory \
OPTIMIZE_SOLVERS=ortools/cp-sat \
OPTIMIZE_DEFAULT_SOLVER=ortools/cp-sat \
uvicorn nurse_scheduling.serve:app --no-access-logTo expose a GPU-only PuLP | cuOpt server, run this command in the cuOpt environment or GPU development container:
cd core
JOB_BACKEND=memory \
OPTIMIZE_SOLVERS=pulp/cuopt \
OPTIMIZE_DEFAULT_SOLVER=pulp/cuopt \
uvicorn nurse_scheduling.serve:app --no-access-logFor multiple Uvicorn workers or multiple backend machines, use Redis-backed job state. Redis stores job metadata,
queued job IDs, YAML inputs, XLSX artifacts, and replayable optimization events. Each backend process still runs at
most one optimization job locally, so --workers 3 allows up to three simultaneous jobs across those worker processes.
cd core
JOB_BACKEND=redis \
JOB_REDIS_URL=redis://localhost:6379/0 \
JOB_REDIS_KEY_PREFIX=nurse_scheduling:jobs:v0 \
uvicorn nurse_scheduling.serve:app --workers 3 --no-access-logThe optional JOB_WORKER_LEASE_SECONDS setting defaults to 90 seconds. Keep it
long enough to tolerate brief Redis interruptions. Every worker renews its
presence lease every third of that interval, including while idle.
Replayable event history is capped at 1,000 events per job. Set
JOB_MAX_EVENTS_PER_JOB to choose a different positive limit.
The backend is the source of truth for the optimization controls shown by the
frontend. GET /optimize/options returns the allowed solvers, integer timeout
range, running-job controls, and prettify default. Configure them with:
export OPTIMIZE_SOLVERS=ortools/cp-sat,pulp/cuopt
export OPTIMIZE_DEFAULT_SOLVER=ortools/cp-sat
export OPTIMIZE_MIN_TIMEOUT_SECONDS=1
export OPTIMIZE_DEFAULT_TIMEOUT_SECONDS=300
export OPTIMIZE_MAX_TIMEOUT_SECONDS=3600
export OPTIMIZE_DEFAULT_PRETTIFY=trueThe server is unauthenticated by default, which suits local development. Set
the legacy API_AUTH_TOKEN or a JSON object such as
API_AUTH_TOKENS='{"institution-a":"key"}' to require a bearer key on every
application route except /info and /ready:
cd core
API_AUTH_TOKEN="$(openssl rand -base64 32)" \
uvicorn nurse_scheduling.serve:app --no-access-logGET /info reports auth.required so the frontend can prompt for a key.
The generated /openapi.json, /docs, and /redoc routes are disabled while
authentication is configured.
The images under docker/ set API_AUTH_REQUIRED=true, so a deployed backend
refuses to start without a configured key. Serving one without authentication
requires API_AUTH_REQUIRED=false.
Only advertise solvers available on that machine. The server validates the configured runtimes at startup.
Without Docker, install and start Redis with your operating system package manager.
Ubuntu/Debian:
sudo apt-get update
sudo apt-get install redis-server
redis-server --daemonize yes
redis-cli pingmacOS with Homebrew:
brew install redis
brew services start redis
redis-cli pingRun the Redis backend tests against a local Redis database:
cd core
JOB_REDIS_TEST_URL=redis://localhost:6379/15 pytest --log-cli-level=INFO tests/test_optimize_job_backends.pyFor Docker Compose deployment, docker/compose.backend.yml starts a Redis
service and configures the backend to use it:
cd docker
docker compose -f compose.backend.yml up -d --buildThe Compose deployment uses Redis database 0 and the key prefix
nurse_scheduling:jobs:v0. Open redis-cli from the Redis container:
docker compose -f compose.backend.yml exec redis redis-cli -n 0Useful inspection commands include:
DBSIZE
SCAN 0 MATCH nurse_scheduling:jobs:v0:* COUNT 100
ZRANGE nurse_scheduling:jobs:v0:jobs 0 -1 WITHSCORES
ZRANGE nurse_scheduling:jobs:v0:queue 0 -1 WITHSCORES
SMEMBERS nurse_scheduling:jobs:v0:pending
ZRANGE nurse_scheduling:jobs:v0:workers:leases 0 -1 WITHSCORES
HGETALL nurse_scheduling:jobs:v0:workers:tokens
HGETALL nurse_scheduling:jobs:v0:workers:active
GET nurse_scheduling:jobs:v0:job:<job-id>
GET nurse_scheduling:jobs:v0:job:<job-id>:input
XRANGE nurse_scheduling:jobs:v0:job:<job-id>:events - + COUNT 20
HGETALL nurse_scheduling:jobs:v0:job:<job-id>:artifact_metadata
Use SCAN instead of KEYS * on a busy database. Job artifacts are binary and
are better inspected through the API download endpoint.
To run one backend worker with process-local memory and no Redis service, use the pre-Redis deployment configuration:
cd docker
docker compose -f compose.backend.memory.yml up -d --buildThe bundled Redis service persists an AOF with appendfsync everysec and keeps
an RDB fallback after six hours when at least one write has occurred. This
limits the usual abrupt-failure exposure to approximately the latest second,
while an RDB-only recovery can be up to six hours behind. Redis installed
outside the bundled Compose deployment keeps its system persistence policy.
The commands below are tested on Linux only.
# create virtual environment
uv venv --python 3.12 docs/.venv
# activate virtual environment
source docs/.venv/bin/activate
# install dependencies
uv pip install -r docs/requirements.txt
# preview documentation on the port used by local page-help links
zensical serveFor building static site, run:
zensical build --clean --strictThis project would not have been possible without the contributors in CONTRIBUTORS.md.
See ACKNOWLEDGMENTS.md for the free services this project relies on.
This project is licensed under the AGPL-3.0 License.