スケジュールナースで時間割作成出来る?

Pythonまで使えば、フレキシブルに作成することが出来ると思います。

例として、Qiita記事 数理最適化による時間割作成 でのデータを基に実装してみます。
スケジュールナースの記述能力の高さを示すデモプロジェクトです。

マッピング

スケジュールナース、シフト勤務表では、スタッフ、Dayおよびシフトが変数対象となります。当然、そのままでは、時間割のイメージとにマッチしません。
そこで時間割を次のようにマッピングします。

 時間割  スケジュールナースプロジェクト
 1    クラス   スタッフ   
 2  限     曜日
 3  曜日   週      
 4  科目   シフト   



クラス名の定義

スタッフプロパティシート上に記述します。



科目の定義

空シフトは、休み用に定義しています。



限、曜日の定義

1週間を7限と考えます。従い7限目まで対応可能です。Qiita記事では、月曜から金曜日まで定義されているので、

 時間割  スケジュールナース
 1   月曜    第一週    
2 火曜    第二週
3 水曜   第三週    
4 木曜   第四週   
5 金曜   第五週   



カレンダ設定は、どこでもよいのですが、5週間を定義しました。



Qiita記事では、6限目まで定義されているので、7限目は、空シフトで埋めています。



各教科は、1週間の必要授業数を行う

時間割上の1週間は、プロジェクト上今月期間全体になります。英国数社理については、週あたり4コマを制約します。以下同様に制約します。



各教科は、1日の授業数の上下限を守る

Qiita記事を参照し、各科目の1日の授業数は、1以下としました。時間割上の1日は、プロジェクト上では、1週間です。これを時間割月~金まで記述することになるので、5週間に渡って同様の記述を行います。



体育など移動教室は連続しない

体育、音楽、技術、家庭の全ての組合わせを禁止します。上で、同一科目は、時間割1日上で禁止しており、例えば体体は、記述する必要がありません。



総合と道徳は6限に行う

列制約を用いて記述します。6限以外の総合と道徳は禁止します。



総合と道徳は学年で曜日を統一して行う

ペア制約を用いて記述します。最初の行は、3年のクラスのうち一つでも(または)道徳ならば、全3年クラスが(かつ)道徳になる、という制約です。



総合と道徳は異なる学年で同じ時間には行わない

列制約を用いて記述します。上で、学年を統一した動きとしているので、学年を代表する1クラスについて制約すれば十分です。



ここまでは、GUIで記述できました。GUIで記述する利点は、一つ一つ記述しながら、即求解し、記述の正当性を確認しながら進められることです。この作業は、意外に楽しいです。

以降は、GUIで記述するのが難しい、もしくは、Pythonで記述した方が楽なので、Pythonで記述します。



教員の制約

Qiita記事中のソースで、csvファイルをダウンロードしています。

lesson_df = pd.read_csv("https://raw.githubusercontent.com/ryosuke0010/opt_test/master/composition.csv")

このファイルを眺めてみると、grが学年列、clがクラス名であり、例えば3年1組の英語は、教員6という具合に、クラスが決まれば、担当科目の教員はこの表により決まることが分かります。
教員6は、3年1組以外にも全ての3年のクラスの英語授業を担当し、3年3組の総合と、道徳に関しても担当することが分かります。

gr,cl,英語,数学,国語  理科,社会,美術,音楽,体育,技術,家庭科,総合,道徳
3,1,教員6,教員9,教員15,教員14,教員18,教員0,教員1,教員2,教員5,教員21,教員9,教員9
3,2,教員6,教員9,教員15,教員14,教員20,教員0,教員1,教員2,教員5,教員21,教員2,教員2
3,3,教員6,教員11,教員15,教員13,教員18,教員0,教員1,教員2,教員5,教員21,教員6,教員6
3,4,教員6,教員9,教員15,教員13,教員18,教員0,教員1,教員2,教員5,教員21,教員18,教員18
3,5,教員6,教員9,教員15,教員13,教員18,教員0,教員1,教員2,教員5,教員21,教員15,教員15
2,1,教員7,教員10,教員16,教員12,教員19,教員0,教員1,教員3,教員5,教員21,教員3,教員3
2,2,教員7,教員10,教員16,教員12,教員19,教員0,教員1,教員3,教員5,教員21,教員19,教員19
2,3,教員7,教員10,教員16,教員12,教員19,教員0,教員1,教員3,教員5,教員21,教員10,教員10
2,4,教員7,教員10,教員16,教員12,教員19,教員0,教員1,教員3,教員5,教員21,教員7,教員7
1,1,教員8,教員11,教員17,教員13,教員20,教員0,教員1,教員4,教員5,教員21,教員11,教員11
1,2,教員8,教員11,教員17,教員13,教員20,教員0,教員1,教員4,教員5,教員21,教員17,教員17
1,3,教員8,教員11,教員17,教員14,教員20,教員0,教員1,教員4,教員5,教員21,教員14,教員14
1,4,教員8,教員11,教員17,教員14,教員20,教員0,教員1,教員4,教員5,教員21,教員8,教員8
1教員が同一限に担当するのは、1科目

1教員が同一限に2個以上担当することがないようにします。

各教員のあり得るシフト(科目)を列挙して、同一限で、その総和が1以下となるように制約します。下記ソースがその記述部です。


def empty_work(dic,dic_units,week):#1教員あたり、同一限で2個以上の授業は不可 1日あたりの授業数平準化 
    for name in dic:
        week_vlist=[]
        for day in week:
            if day in 木:
                continue
            vlist=[]
            for tp in dic[name]:
                subject=list_of_rows[0][tp[1]]
                Class=tp[0]-1
                #print(Class,day,subject)
                v=sc3.GetShiftVar(Class,day,subject)
                vlist.append(v)
            sc3.AddHard(sc3.SeqLE(0,1,vlist),'教員ひとりにつき同一コマは一個以下_'+name)
            v=sc3.Or(vlist)
            week_vlist.append(v)
        div=dic_units[name]/5
        print(name+' コマ数平準化',math.floor(div),math.ceil(div))
        sc3.AddHard(sc3.SeqLE(math.floor(div),math.ceil(div),week_vlist),name)
        



各教師が1日に行う授業数は平準化を行う。各曜日で大きく違わないようにする

これは、私が勝手に想像した制約で、Qiita記事ではありません。各教員毎に担当コマ数は異なるので、各教員毎の平準化を行った方がよいだろうという、勝手な判断によるものです。

教師が決まれば、時間割一日あたりの平均コマ数は確定します。単純に平均値のfloorとceilにより上限下限をハード制約で記述しています。今回は、ハード制約で記述していて、解が存在しました。ここで解がないようなら、ソフト制約に変更する必要がありましたが、解は存在したので、ハード制約のまま、としています。 以上の記述も上のソースで行っています。



各学年毎に、所属学年の教師が、各限に行う授業数は、平準化を行う。(空コマ数の各限毎の平準化と同義)

これも、Qiita記事にはありません。各学年毎の各コマ毎の授業数上の平準化を行うものです。学年毎に、各コマの平均コマ数は決まるので、単純に平均値のfloorとceilにより上限下限をハード制約で記述しています。今回は、ハード制約で記述していて、解が存在しました。ここで解がないようなら、ソフト制約に変更する必要がありましたが、解は存在したので、ハード制約のまま、としています。 以上の記述を下のソースで行っています。

def empty_frame_avg(dic,grade_list,empty_avg_frames,week):#学年毎の1限あたりの空き教員数の平準化
    for day in week:
        Vlist={}
        if day in 木:
            continue
        for name in dic:
            vlist=[]     
            for tp in dic[name]:
                subject=list_of_rows[0][tp[1]]
                Class=tp[0]-1
                #print(Class,day,subject)
                v=sc3.GetShiftVar(Class,day,subject)
                vlist.append(v)
            
            v=sc3.Or(vlist)
            grade=get_grade(name,grade_list)
            if grade not in Vlist:
                list=[]
                list.append(~v)
                Vlist[grade]=list
            else:
                Vlist[grade].append(~v)
        for item in Vlist:
            f=empty_avg_frames[item]
            s=str(item)+'年空き教員数平準化 '+get_period(day)
            print(s,math.floor(f),math.ceil(f))
            sc3.AddHard(sc3.SeqLE(math.floor(f),math.ceil(f),Vlist[item]),s)



全体ソース
import sc3
import csv
import urllib.request
import math
import re

def get_grade(name,grade_list):
    t_ind = int(re.sub(r"\D", "", name))
    grade=grade_list[t_ind]
    return grade

def get_period(day):
    if day in 金:
        return '1限'
    elif day in 土:
        return '2限'
    elif day in 日:
        return '3限'
    elif day in 月:
        return '4限'
    elif day in 火:
        return '5限'
    elif day in 水:
        return '6限'
 

def empty_work(dic,dic_units,week):#1教員あたり、同一限で2個以上の授業は不可 1日あたりの授業数平準化 
    for name in dic:
        week_vlist=[]
        for day in week:
            if day in 木:
                continue
            vlist=[]
            for tp in dic[name]:
                subject=list_of_rows[0][tp[1]]
                Class=tp[0]-1
                #print(Class,day,subject)
                v=sc3.GetShiftVar(Class,day,subject)
                vlist.append(v)
            sc3.AddHard(sc3.SeqLE(0,1,vlist),'教員ひとりにつき同一コマは一個以下_'+name)
            v=sc3.Or(vlist)
            week_vlist.append(v)
        div=dic_units[name]/5
        print(name+' コマ数平準化',math.floor(div),math.ceil(div))
        sc3.AddHard(sc3.SeqLE(math.floor(div),math.ceil(div),week_vlist),name)
        

def empty_frame_avg(dic,grade_list,empty_avg_frames,week):#学年毎の1限あたりの空き教員数の平準化
    for day in week:
        Vlist={}
        if day in 木:
            continue
        for name in dic:
            vlist=[]     
            for tp in dic[name]:
                subject=list_of_rows[0][tp[1]]
                Class=tp[0]-1
                #print(Class,day,subject)
                v=sc3.GetShiftVar(Class,day,subject)
                vlist.append(v)
            
            v=sc3.Or(vlist)
            grade=get_grade(name,grade_list)
            if grade not in Vlist:
                list=[]
                list.append(~v)
                Vlist[grade]=list
            else:
                Vlist[grade].append(~v)
        for item in Vlist:
            f=empty_avg_frames[item]
            s=str(item)+'年空き教員数平準化 '+get_period(day)
            print(s,math.floor(f),math.ceil(f))
            sc3.AddHard(sc3.SeqLE(math.floor(f),math.ceil(f),Vlist[item]),s)

def get_list_of_rows():
    url="https://raw.githubusercontent.com/ryosuke0010/opt_test/master/composition.csv"
    response = urllib.request.urlopen(url)
    lines = [l.decode('utf-8') for l in response.readlines()]
    #print(lines)
    reader=csv.reader(lines)
    list_of_rows=list(reader)
    return list_of_rows

def get_teacher(Class,subject,list_of_rows):
    ind= list_of_rows[0].index(subject)
    return list_of_rows[Class+1][ind]

def get_teacher_ind(Class,subject,list_of_rows):
    name=get_teacher(Class,subject,list_of_rows)
    t_ind = int(re.sub(r"\D", "", name))
    return t_ind

def post_main():
    print('Executing Post Main')
    grade_list=[3,3,3,2,1,1,3,2,1,3,2,1,2,1,1,3,2,1,3,2,3,2]
    list_of_rows=get_list_of_rows()
    tmap={}
    grade_map={}
    for Class in 全スタッフ:
        
        for day in 今月:
            if day in 木:
                continue
            subject=shift_solution[Class][day]
            t_ind=get_teacher_ind(Class,subject,list_of_rows)
            grade=grade_list[t_ind]
            if grade not in grade_map:
                list=[0]* len(今月)
                list[day]+=1
                grade_map[grade]=list
            else:
                grade_map[grade][day]+=1

            #print(teacher,subject)
            Day=int(day/7)
            if t_ind not in tmap:
                list=[0,0,0,0,0]
                list[Day]+=1
                tmap[t_ind]=list
            else:
                tmap[t_ind][Day]+=1
 
    tmap2=sorted(tmap.items())
    #print(tmap2)
    #print(grade_map)
    print('各教員の各曜日に対する授業数平準化結果')
    print('        月 火 水 木 金')
    for t in tmap2:
        print('教員'+str(t))

    print('')
    print('各学年毎の各限に対する授業数の平準化=空き授業数の平準化結果')
    for g in grade_map:
        print(str(g)+'学年:')
        print('    月 火 水 木 金')
        
        for i in range(6):
            print(str(i+1)+'限  ',end='')
            for day in range(5):
                print(grade_map[g][day*7+i],' ',end='')
            print('')
        print('')

list_of_rows=get_list_of_rows()
print(list_of_rows)
dic={}
dic_units={}
grade_units={}
grade_list=[3,3,3,2,1,1,3,2,1,3,2,1,2,1,1,3,2,1,3,2,3,2]
for list in list_of_rows:
   col=0
   subject_units=[0,0,4,4,4,4,4,2,2,2,1,1,1,1]
   
   for item in list:
        if '教員' in item:
            if item not in dic:
                l=[]
                c=list.index(item)
                dic_units[item]=subject_units[c]
                l.append((list_of_rows.index(list),list.index(item)))
                dic[item]=l
            else:
                dic_units[item]+=subject_units[col]
                dic[item].append((list_of_rows.index(list),col))
        col+=1
for item in dic_units:
    grade=get_grade(item,grade_list)
    if grade not in grade_units:
        grade_units[grade]=dic_units[item]
    else:
        grade_units[grade]+=dic_units[item]
            
print(grade_units)
print(dic)
print(dic_units)
empty_work(dic,dic_units,第一週)#月
empty_work(dic,dic_units,第二週)#火
empty_work(dic,dic_units,第三週)#水
empty_work(dic,dic_units,第四週)#木
empty_work(dic,dic_units,第五週)#金

teachers={1:grade_list.count(1),2:grade_list.count(2),3:grade_list.count(3)}
print(teachers)
empty_avg_frames={}
for t in teachers:
    empty_avg_frames[t]=(teachers[t]*6*5-grade_units[t])/(6*5)#平均空きコマ数を求めて、そのfloor,ceilを制約範囲とする
print(empty_avg_frames)
empty_frame_avg(dic,grade_list,empty_avg_frames,第一週)#月
empty_frame_avg(dic,grade_list,empty_avg_frames,第二週)#火
empty_frame_avg(dic,grade_list,empty_avg_frames,第三週)#水
empty_frame_avg(dic,grade_list,empty_avg_frames,第四週)#木
empty_frame_avg(dic,grade_list,empty_avg_frames,第五週)#金





検証

解が出た後に、ポスト処理で、解の妥当性について検証しています。

最初は、各教員の月~金授業数に偏りがないかのチェックです。平均値についてfloor,ceilしているので、偏差は1以内となっています。

次に、各学年の授業数の偏りチェックです。これも平均値についてfloor,ceilしているので、偏差は1以内となっています。
制約した通りに動いているようです。

以上のpython記述部は、post_mainです。


	Algorithm 1 Solving Process Started..
	Python プロパティファイルの生成が終わりました。
 _____________________________________
|           |           |             |
|   Weight  |   Errors  |    Cost     |
|___________|___________|_____________|
|           |           |             |
|___________|___________|_____________|
|                       |             |
|         Total         |           0 |
|_______________________|_____________|
	*********UB=0(0)  1.651(cpu sec)
o 0(0)
解探索が終了しました。 3 (秒)
解が得られました。
ポスト処理を実行します。ソルバを呼び出し中です。
Executing Post Main
各教員の各曜日に対する授業数平準化結果
        月 火 水 木 金
教員(0, [6, 5, 5, 5, 5])
教員(1, [5, 5, 5, 6, 5])
教員(2, [2, 3, 2, 3, 2])
教員(3, [2, 2, 2, 2, 2])
教員(4, [1, 2, 2, 2, 1])
教員(5, [2, 3, 2, 3, 3])
教員(6, [4, 5, 4, 4, 5])
教員(7, [4, 4, 3, 4, 3])
教員(8, [4, 4, 4, 3, 3])
教員(9, [4, 3, 4, 3, 4])
教員(10, [4, 3, 4, 3, 4])
教員(11, [4, 4, 5, 4, 5])
教員(12, [4, 3, 3, 3, 3])
教員(13, [4, 4, 4, 4, 4])
教員(14, [4, 3, 3, 4, 4])
教員(15, [5, 4, 5, 4, 4])
教員(16, [3, 3, 3, 4, 3])
教員(17, [3, 4, 4, 3, 4])
教員(18, [3, 3, 4, 4, 4])
教員(19, [4, 4, 4, 3, 3])
教員(20, [4, 4, 4, 4, 4])
教員(21, [2, 3, 2, 3, 3])

各学年毎の各限に対する授業数の平準化=空き授業数の平準化結果
1学年:
    月 火 水 木 金
1限  4  4  4  4  4  
2限  4  4  4  4  4  
3限  3  4  4  4  4  
4限  4  4  4  3  4  
5限  4  4  4  4  4  
6限  3  4  4  4  4  

3学年:
    月 火 水 木 金
1限  5  5  5  5  5  
2限  5  5  6  5  5  
3限  6  5  5  6  6  
4限  6  6  6  6  6  
5限  5  5  6  6  5  
6限  6  6  5  5  6  

2学年:
    月 火 水 木 金
1限  4  4  4  4  4  
2限  4  4  3  4  4  
3限  4  4  4  3  3  
4限  3  3  3  4  3  
5限  4  4  3  3  4  
6限  4  3  4  4  3  

ポスト処理を終了しました。 1 (秒)

求解速度

アルゴリズム 速度 備考
 1 1.6秒
 2(Highs) 982秒
Pulp(CBC) 3.3秒 下記ソース(timetable3.py)参照 python3 timetable3.py
Pulp->MPS->Highs 11秒



#ライブラリのインポート
import pandas as pd
import numpy as np
import pulp
import math

#基本情報のデータ
teacher_list = [f'教員{i}' for i in range(22)]
subject_list = ["英語","数学","国語","理科","社会","美術","音楽","体育","技術","家庭科","総合","道徳"]
grade_list = [1,2,3]
class_dict = {3:[1,2,3,4,5],2:[1,2,3,4],1:[1,2,3,4]}
teacher_dict = {t:g for t,g in zip(teacher_list,[3,3,3,2,1,1,3,2,1,3,2,1,2,1,1,3,2,1,3,2,3,2])} #教員の所属学年
period = [1,2,3,4,5,6]
week = ["月","火","水","木","金"]
Classroom_mobility = ["美術","音楽","体育","技術","家庭科"] #移動教室授業
six_period = ["総合","道徳"] #6限のみの授業
subject_dict = {s:n for s,n in zip(subject_list,[4,4,4,4,4,2,2,2,1,1,1,1])} #必要授業数
lesson_df = pd.read_csv("https://raw.githubusercontent.com/ryosuke0010/opt_test/master/composition.csv")
model = pulp.LpProblem("model",pulp.LpMinimize)
x = {}

#x_曜日_時限_学年_クラス_授業
for d in week:
    for p in period:
        for g in grade_list:
            for c in class_dict[g]:
                for s in subject_list:
                    x[d,p,g,c,s] = pulp.LpVariable(cat="Binary",name=f"x_{d}_{p}_{g}_{c}_{s}")



#(1)1 つの時限では必ず 1 つ授業を行う
for d in week:
    for p in period:
        for g in grade_list:
            for c in class_dict[g]:
                model += pulp.lpSum([x[d,p,g,c,s] for s in subject_list]) == 1

#(2)各教科sは1週間の必要授業数だけ行う
for g in grade_list:
    for c in class_dict[g]:
        for s in subject_list:
            model += pulp.lpSum([x[d,p,g,c,s] for d in week for p in period]) == subject_dict[s]

#(3)教科は 1 日の授業数の上下限を守る
for d in week:
    for g in grade_list:
        for c in class_dict[g]:
            for s in subject_list:
                model += pulp.lpSum([x[d,p,g,c,s] for p in period]) <= 1

#(4)体育など移動教室は連続しない
for d in week:
    for p in period[:5]:
        for g in grade_list:
            for c in class_dict[g]:
                model += pulp.lpSum([x[d,p,g,c,s] + x[d,p+1,g,c,s] for s in Classroom_mobility]) <= 1

#(5)総合と道徳の制約
#➀総合と道徳は6限
for d in week:
    for p in period[:5]:
        for g in grade_list:
            for c in class_dict[g]:
                model += pulp.lpSum([x[d,p,g,c,s] for s in six_period]) == 0

#➁総合と道徳は学年で曜日を統一して行う
for d in week:
    for g in grade_list:
        for c in class_dict[g][:-1]:
            for s in six_period:
                model += x[d,6,g,c,s] == x[d,6,g,c+1,s]
 
#➂総合と道徳は異なる学年で同じ時間には行わない
for d in week:
    for s in six_period:
        model += pulp.lpSum(x[d,6,g,1,s] for g in grade_list) <= 1

flag={}
t_units={}
for g in grade_list:
    for c in class_dict[g]:
        for t in teacher_list:
            for s in subject_list:
                flag[g,c,t,s]=False

g_units={}

for g in grade_list:
    for c in class_dict[g]:
        for s in subject_list:
            df = lesson_df[lesson_df["gr"] == g]
            t = df[df["cl"] == c][s].values[0]
            grade=teacher_dict[t]
            flag[g,c,t,s]=True
            if t not in t_units:
               t_units[t]=subject_dict[s]
            else:
               t_units[t]+=subject_dict[s]
            if grade not in g_units:
               g_units[grade]=subject_dict[s]
            else:
               g_units[grade]+=subject_dict[s]
               
#print(flag)

#(6)1教員が1コマあたりに行う授業は1以下

for t in teacher_list:
    for d in week:
        for p in period:
            model += pulp.lpSum(x[d,p,g,c,s] for g in grade_list for c in class_dict[g] for s in subject_list if flag[g,c,t,s]) <=1
        
#print(model)

#(7)1教員が1日に行う授業数の上下限は、平均値のfloor/ceilとする

#print(t_units)
print(g_units)
for t in teacher_list:
    for d in week:
        days=len(week)
        avg=t_units[t]/days
        model += pulp.lpSum(x[d,p,g,c,s] for p in period for g in grade_list for c in class_dict[g] for s in subject_list if flag[g,c,t,s]) <=math.ceil(avg)
        model += pulp.lpSum(x[d,p,g,c,s] for p in period for g in grade_list for c in class_dict[g] for s in subject_list if flag[g,c,t,s]) >=math.floor(avg)


#(8)各学年の教員が1コマあたりに行う授業数の上下限は、平均値のfloor/ceilとする

for g in grade_list:
    for d in week:
        for p in period:
            days=len(week)
            periods=len(period)
            avg=g_units[g]/(days*periods)
            model += pulp.lpSum(x[d,p,g,c,s] for c in class_dict[g] for s in subject_list if flag[g,c,t,s]) <=math.ceil(avg)
            model += pulp.lpSum(x[d,p,g,c,s] for c in class_dict[g] for s in subject_list if flag[g,c,t,s]) <=math.floor(avg)
               

model.solve()
print(pulp.LpStatus[model.status])

def export_table(g,c):
    timetable_df = pd.DataFrame(index=period,columns=week)
    
    for d in week:
        for p in period:
            for s in subject_list:
                if x[d,p,g,c,s].value() == 1.0:
                    timetable_df[d][p] = s

    print(timetable_df)

export_table(3,1)
export_table(3,2)
export_table(3,3)
export_table(3,4)
export_table(3,5)
export_table(2,1)
export_table(2,2)
export_table(2,3)
export_table(2,4)
export_table(1,1)
export_table(1,2)
export_table(1,3)
export_table(1,4)

model.writeMPS("test.mps")

tmap={}
gmap={}
for t in teacher_list:
    for d in week:
        for p in period:
            for g in grade_list:
                for c in class_dict[g]:
                    for s in subject_list:
                        if x[d,p,g,c,s].value() == 1.0 and flag[g,c,t,s]:
                           if t in tmap:
                               if d not in tmap[t]:
                                    tmap[t][d]=1
                               else:
                                    tmap[t][d] +=1	
                           else:
                               tmap[t]={}
                               tmap[t][d]=1
                           if g in gmap:
                              if d not in gmap[g]:
                                   gmap[g][d]={}
                                   gmap[g][d][p]=1
                              else:
                                   if p not in gmap[g][d]:
                                      gmap[g][d][p]=1
                                   else:
                                      gmap[g][d][p]+=1
                           else:
                               gmap[g]={}
                               gmap[g][d]={}
                               gmap[g][d][p]=1

print(tmap)
print(gmap)



プロジェクト

プロジェクトは、以下です。

ダウンロード して、実装の参考にしてください。

以上です。