はじめに
本記事は「Julia×JuMPで病棟シフト最適化」シリーズの最終回です。
これまで蓄積してきた機能をまとめ、看護師シフト表の実務レベル対応力をさらに高める最終版です。
ただし、運用スタイルや人数構成は病院ごとに異なるため、本モデルをベースとしてパラメータや制約の微調整が必要となります。
特徴
- Excelファイル上の勤務希望・属性・目標値を直接読み込む
- 休/日勤/夜勤/夜勤明け/早番/遅番/出張/デイリーダー/長日勤を網羅
- 禁止シフトパターン、NGペア、男女比、連勤・連休抑制など実務制約をカバー
- 今回追加の最終機能を実装
入力ファイル構成
追加機能のまとめ
1. シフト区分の拡張
前回までのシフトパターンは以下の5種類でした。
- 休/日/夜/明/早
より現実的な運用に対応するため、下記の4種類を追加し、合計9種類のシフトを扱います。
- 遅:遅番業務
- 張:出張(勤務回数には含むが、通常の日勤要員とは別カウント)
- L:デイリーダー(1日の日勤業務を統括。Excel上でスタッフごとに可否を設定)
- 長:長日勤(2交代制の変則的日勤で、8時ごろから19~21時ごろまでの勤務)
※2交代制ベースの構成です。
2. 夜勤前の長日勤優先配置
- スタッフによって、「夜勤の前は休みが良い」等の好みがあるが、今回は長日勤→夜勤のシフトパターンを最大化する形でモデル化
- 患者の状態把握や引き継ぎを円滑にし、安全な夜勤運営を支援
3. 経験による夜勤制限
- Excelで「ベテラン」「中堅」「若手」を指定
- 1日の夜勤に就く若手看護師は1名までに制限
- 将来的には、日勤/夜勤ともにスタッフの経験バランスを最適化する機能の追加も可能
これらの追加機能により、実務現場の細かな運用ルールや管理要件にも対応可能な高制約モデルを実現しています。
実行コード
using JuMP
using HiGHS
using DataFrames
using IterTools
using PrettyTables
using XLSX
using Dates
# シフト区分の定義(休/日勤/夜勤入り/夜勤明け/早番/遅番/出張/デイリーダー/長日勤)
# デイリーダー:日勤業務を統括するスタッフ。毎日1人必ず設置。(勤務時間8:00~16:45)スタッフごとに可・不可を設定
# 長日勤:勤務時間8:00~19:00 毎日3人設定
const SHIFT_TYPE = Symbol.(['休', '日', '夜', '明', '早', '遅', '張', 'L', '長'])
# 禁止シフトの連続パターン
const KINSHI_SHIFT = Symbol.(["夜夜", "休明", "明明", "明夜",
"夜休", "夜日", "日明", "明日",
"夜早","早明","明早",
"夜遅","遅明","明遅",
"夜張","張明","明張",
"夜L","L明","明L",
"夜長","長明","明長",
"長長"
])
# Excel 上のセル範囲定義
const DATES_CELL = "H2:AL2"
const HEADERS_CELL = "B2:AO2"
const DATA_CELL = "B3:AO27"
const NIKKIN_TARGET_CELL = "H28:AL28"
const NIKKIN_KYOYOCHI_CELL = "H29:AL29"
const HAYABAN_TARGET_CELL = "H30:AL30"
const OSOBAN_TARGET_CELL = "H31:AL31"
# Excel からメインシートを読み込み
shift_file = "shift_kibou.xlsx"
ws = XLSX.readxlsx(shift_file)["メインシート"]
# 日付リストを取得
days_list = vec(ws[DATES_CELL])
headers = DataFrame(ws[HEADERS_CELL], :auto)[1, :]
staff_data = coalesce.(DataFrame(ws[DATA_CELL], Symbol.([h for h in headers])), "")
# NGペアリストシートを読み込み
ng_ws = XLSX.readxlsx(shift_file)["NGペアリスト"]
ng_headers = DataFrame(ng_ws["A1:B1"], :auto)[1, :]
ng_data = coalesce.(DataFrame(ng_ws["A2:B10"], Symbol.([h for h in ng_headers])), "")
# 最適化モデルの作成
model = Model(HiGHS.Optimizer)
@variable(model, x[staff_data.id, days_list, k in SHIFT_TYPE], Bin)
# ──────────────────────────────────────────────
# 1. 1日につき必ず1つのシフトを割当
# ──────────────────────────────────────────────
for s in staff_data.id, d in days_list
@constraint(model, sum(x[s, d, k] for k in SHIFT_TYPE) == 1)
end
# ──────────────────────────────────────────────
# 2. エクセルから取得した勤務希望を反映
# ──────────────────────────────────────────────
for (s, d) in product(staff_data.id, days_list)
request = staff_data[staff_data.id .== s, string(d)][1]
if request !== ""
if occursin(",", request)
kibou_list = []
for kibou in split(request,",")
if !occursin("×", kibou)
push!(kibou_list, kibou)
end
end
if length(kibou_list) !== 0
@constraint(model, sum(x[s, d, Symbol(k)] for k in kibou_list) == 1)
end
else
@constraint(model, x[s, d, Symbol(request)] == 1)
end
end
end
# ──────────────────────────────────────────────
# 3. 目標休日日数の確保
# ──────────────────────────────────────────────
for staff in eachrow(staff_data)
s = staff.id
target_num = Int64(staff.目標休み日数)
off_shift_count = @expression(model, sum(x[s, d, SHIFT_TYPE[1]] for d in days_list)) - target_num
if occursin("+", staff.目標休み許容値)
@constraint(model, off_shift_count <= parse(Int64, replace(staff.目標休み許容値, "+" => "")))
@constraint(model, off_shift_count >= 0)
else
@constraint(model, off_shift_count == 0)
end
end
# ──────────────────────────────────────────────
# 4. 夜勤・明け要員数の確保(毎日3名ずつ)
# ──────────────────────────────────────────────
for d in days_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for s in staff_data.id) == 3)
@constraint(model, sum(x[s, d, SHIFT_TYPE[4]] for s in staff_data.id) == 3)
end
# ──────────────────────────────────────────────
# 5. 早番要員数の確保
# ──────────────────────────────────────────────
for d in days_list
requests = staff_data[:, string(d)]
count = sum(requests .== string(SHIFT_TYPE[5]))
#指定が無い場合は基本1人/day
num = 1
if ws[HAYABAN_TARGET_CELL][d] isa Number
num = ws[HAYABAN_TARGET_CELL][d]
elseif count !== 0
num = count
end
@constraint(model, sum(x[s, d, SHIFT_TYPE[5]] for s in staff_data.id) == num)
end
# ──────────────────────────────────────────────
# 6. 遅番要員数の確保
# ──────────────────────────────────────────────
for d in days_list
requests = staff_data[:, string(d)]
count = sum(requests .== string(SHIFT_TYPE[6]))
#指定が無い場合は基本1人/day
num = 1
if ws[OSOBAN_TARGET_CELL][d] isa Number
num = ws[OSOBAN_TARGET_CELL][d]
elseif count !== 0
num = count
end
@constraint(model, sum(x[s, d, SHIFT_TYPE[6]] for s in staff_data.id) == num)
end
# ──────────────────────────────────────────────
# 7. 禁止シフトパターンの適用
# ──────────────────────────────────────────────
for (k, s, d) in product(KINSHI_SHIFT, staff_data.id, days_list)
k = String(k)
t = length(k) - 1
if d > t
@constraint(model, sum(x[s, d - t + h , Symbol(k[nextind(k, h)])] for h in 0:t) <= t)
end
request = staff_data[staff_data.id .== s, string(d)][1]
#出張希望の日以外は禁止
if request != "張"
@constraint(model, x[s, d, SHIFT_TYPE[7]] == 0)
end
end
# ──────────────────────────────────────────────
# 8. スタッフ毎の夜勤回数の適用
# ──────────────────────────────────────────────
@variable(model, 0 <= max_yakin <= 5)
for staff in eachrow(staff_data)
s = staff.id
if staff.目標夜勤日数 == ""
@constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for d in days_list) <= max_yakin)
else
@constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for d in days_list) == staff.目標夜勤日数)
end
end
# ──────────────────────────────────────────────
# 9. 日中勤務者(日勤+早番+遅番+デイリーダー)の人数設定
# ──────────────────────────────────────────────
@variable(model, 8 <= max_nikkin <= 15)
@variable(model, 2 <= min_nikkin <= 10)
kinmu = [SHIFT_TYPE[2], SHIFT_TYPE[5], SHIFT_TYPE[6], SHIFT_TYPE[8], SHIFT_TYPE[9]]
for d in days_list
day_shift_count = @expression(model, sum(x[s, d, k] for s in staff_data.id for k in kinmu))
if ws[NIKKIN_TARGET_CELL][d] isa Number
if ismissing(ws[NIKKIN_KYOYOCHI_CELL][d]) || ws[NIKKIN_KYOYOCHI_CELL][d] == "以上"
@constraint(model, day_shift_count <= max_nikkin)
@constraint(model, day_shift_count >= ws[NIKKIN_TARGET_CELL][d])
elseif ws[NIKKIN_KYOYOCHI_CELL][d] == "±0"
@constraint(model, day_shift_count == ws[NIKKIN_TARGET_CELL][d])
end
else
@constraint(model, day_shift_count <= max_nikkin)
@constraint(model, day_shift_count >= min_nikkin)
end
end
# ──────────────────────────────────────────────
# 10. 5連勤以内制約
# ──────────────────────────────────────────────
max = 5
for s in staff_data.id
for d in max+1:days_list[end]
@constraint(model, sum(x[s, d - h, k] for h in 0:max for k in SHIFT_TYPE[2:end]) <= max)
end
end
# ──────────────────────────────────────────────
# 11. NGペア同時夜勤禁止
# ──────────────────────────────────────────────
for staff in eachrow(ng_data)
if staff.スタッフ1 !== ""
for d in days_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for s in values(staff)) <= 1)
end
end
end
# ──────────────────────────────────────────────
# 12. 男性スタッフのみの夜勤禁止
# ──────────────────────────────────────────────
filtered_staff = staff_data[staff_data.性別 .== "男", :id]
for d in days_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for s in filtered_staff) <= 2)
end
# ──────────────────────────────────────────────
# 13. 3連休抑制
# ──────────────────────────────────────────────
@variable(model, is_renkyu[s in staff_data.id, d in days_list], Bin)
renkyu_days = 3
renkyu_weight = 1
for s in staff_data.id
for d in days_list[1:end-(renkyu_days-1)]
no_work_count = @expression(model, sum(x[s, d + h, SHIFT_TYPE[1]] for h in 0:(renkyu_days-1)))
@constraint(model, no_work_count - (renkyu_days-1) <= is_renkyu[s, d])
@constraint(model, no_work_count - renkyu_days + 0.001 >= -renkyu_days * (1 - is_renkyu[s, d]))
end
end
renkyu = sum(is_renkyu[s,d] for s in staff_data.id for d in days_list[1:end-(renkyu_days-1)]) * renkyu_weight
# ──────────────────────────────────────────────
# 14. 5連勤抑制
# ──────────────────────────────────────────────
@variable(model, is_renkin[s in staff_data.id, d in days_list[1:end-4]], Bin)
for s in staff_data.id
for d in days_list[1:end-4]
work_count = @expression(model, sum(x[s, d+h, k] for h in 0:4 for k in SHIFT_TYPE[2:end]))
@constraint(model, work_count - 4 <= is_renkin[s, d])
@constraint(model, work_count - 5 + 0.001 >= -5 * (1 - is_renkin[s, d]))
end
end
renkin = sum(is_renkin[s,d] for s in staff_data.id for d in days_list[1:end-4])
# ──────────────────────────────────────────────
# 15. 夜勤前はできる限り長日勤にする
# ──────────────────────────────────────────────
@variable(model, nya[s in staff_data.id, d in days_list[2:end]], Bin)
for s in staff_data.id
for d in days_list[2:end]
@constraint(model, nya[s, d] <= x[s, d, SHIFT_TYPE[3]])
@constraint(model, nya[s, d] <= x[s, d-1, SHIFT_TYPE[9]])
@constraint(model, nya[s, d] >= x[s, d, SHIFT_TYPE[3]] + x[s, d-1, SHIFT_TYPE[9]] -1)
end
end
nya_shift = sum(nya[s,d] for s in staff_data.id, d in days_list[2:end]) * -1
# ──────────────────────────────────────────────
# 16. デイリーダー勤務の設定
# ──────────────────────────────────────────────
@variable(model, 0 <= max_leader <= 15)
day_leader_list = staff_data[staff_data.デイリーダー .== "可", :id]
non_day_leader_list = staff_data[staff_data.デイリーダー .!= "可", :id]
@constraint(model, sum(x[s, d, SHIFT_TYPE[8]] for d in days_list for s in non_day_leader_list) == 0)
for d in days_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[8]] for s in day_leader_list) == 1)
end
for s in day_leader_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[8]] for d in days_list) <= max_leader)
end
# ──────────────────────────────────────────────
# 17. 若手二人以上の夜勤を禁止
# ──────────────────────────────────────────────
staff_rookie_list = staff_data[staff_data.経験 .== "若手", :id]
for d in days_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for s in staff_rookie_list) <= 1)
end
# ──────────────────────────────────────────────
# 18. 長日勤と夜勤のバランス制約
# 長日勤のと夜勤の回数はイコールとする
# ──────────────────────────────────────────────
for staff in eachrow(staff_data)
s = staff.id
@constraint(model, sum(x[s, d, SHIFT_TYPE[9]] - x[s, d, SHIFT_TYPE[3]] for d in days_list) == 0)
end
# ──────────────────────────────────────────────
# 19. 長日勤は夜勤者数と同数とする(今回は3)
# ──────────────────────────────────────────────
for d in days_list
@constraint(model, sum(x[s, d, SHIFT_TYPE[9]] for s in staff_data.id) == 3)
end
# 目的関数の定義
@objective(model, Min, max_yakin + max_nikkin - min_nikkin + renkyu + renkin+ nya_shift + max_leader)
# モデルの最適化実行
optimize!(model)
println("終了ステータス: ", termination_status(model))
# 最適値・各変数の値を出力
obj_value = objective_value(model)
println("最適値: ", obj_value)
println("max_yakin = ", value(max_yakin))
println("max_nikkin = ", value(max_nikkin))
println("min_nikkin = ", value(min_nikkin))
println("renkyu = ", value(renkyu))
println("nya_shift = ", value(nya_shift))
println("max_leader = ", value(max_leader))
# ──────────────────────────────────────────────
# 結果の整形と出力
# ──────────────────────────────────────────────
result = Array{Symbol}(undef, staff_data.id[end], days_list[end])
for (s, d, k) in product(staff_data.id, days_list, SHIFT_TYPE)
# if round(value(x[s, d, k])) == 1
if round(value(x[s, d, k])) == 1
result[s, d] = k
end
end
df = DataFrame(result, Symbol.(string.(days_list) .* "日"))
df[!, :名前] = Symbol.("看護師" .* string.(staff_data.id))
sorted_cols = [:名前]
append!(sorted_cols, [Symbol(string(day) .* "日") for day in sort(days_list)])
df = df[:, sorted_cols]
insertcols!(df, 2, :性別 => staff_data.性別)
insertcols!(df, 3, :デイリーダー => staff_data.デイリーダー)
insertcols!(df, 4, :経験 => staff_data.経験)
# # 各勤務区分ごとの集計行を末尾に追加
for i in 1:length(SHIFT_TYPE)
push!(df, Symbol.([count(==(SHIFT_TYPE[i]), df[!, col]) for col in names(df)]), promote=true)
df[nrow(df), :名前] = SHIFT_TYPE[i]
end
# 「休み」「夜勤」合計列を追加
df[:, "休み"] = [count(x -> !ismissing(x) && (x == Symbol("休")), row[2:end]) for row in eachrow(df)]
df[:, "夜勤"] = [count(x -> !ismissing(x) && (x == Symbol("夜")), row[2:end]) for row in eachrow(df)]
df[:, "L"] = [count(x -> !ismissing(x) && (x == Symbol("L")), row[2:end]) for row in eachrow(df)]
renkyu_count = []
renkin_count = []
renkin6_count = []
for row in eachrow(df)
yasumi_binary = join([(string(row[string(i).*"日"]) == "休" ? "1" : "0") for i in days_list], "")
push!(renkyu_count, length(collect(eachmatch(r"(?<!1)(1){3}(?!1)", yasumi_binary))))
not_yasumi_binary = join([(string(row[string(i).*"日"]) == "休" ? "0" : "1") for i in days_list], "")
push!(renkin_count, length(collect(eachmatch(r"(?<!1)(1){5}(?!1)", not_yasumi_binary))))
not_yasumi6_binary = join([(string(row[string(i).*"日"]) == "休" ? "0" : "1") for i in days_list], "")
push!(renkin6_count, length(collect(eachmatch(r"(1){6,}", not_yasumi_binary))))
end
df[:, "3連休"] = renkyu_count
df[:, "5連勤"] = renkin_count
df[:, "6連勤↑"] = renkin6_count
# Markdown形式の表として表示
pretty_table(df, backend = Val(:markdown))
まとめ
今回でJulia×JuMPで病棟シフト最適化シリーズはいったん簡潔となります。本モデルでは多様な勤務区分や連続勤務制御、経験バランス調整などの実務要件を盛り込み、Excel入力→JuMP+HiGHS最適化→出力までを実装しました。各病院の運用ルールに合わせて柔軟にカスタマイズできるベースラインとして活用してください。
次回予告
次回は本記事で完結させたJulia版モデルをPythonへ移植します
関連記事
-
‹‹ 前回:「Julia×JuMPで病棟シフト最適化④:夜勤相性制御・連休推奨/非推奨機能の追加」
https://qiita.com/kaz_ict_nurse/items/55c5127c5979bc31faaf -
›› 次回: 「Pythonで病棟シフト最適化」
https://qiita.com/kaz_ict_nurse/items/74e574545702a610703c

