OR-Tools Day13

개발공부를해보자·2025년 6월 24일
  • 실제 상황에서 사용할 수 있도록
  • 필요한 정보들을 .xlsx 파일에서 불러오도록 했다.
  • 만족할만한 해가 나왔다!!
  • 이제 이걸... 유연하게 많은 리팩토링을 하고.. 코딩을 몰라도 쓸 수 있게 UI를 만들어야 한다.
  • 산넘어 산이군..
  • 커서가 좋다던데 한 번 써볼까싶다.

실습 코드

# ------------------ Day13(25.06.24) ------------------
from ortools.sat.python import cp_model
import pandas as pd
import itertools
import json

# ---------------------------
# 1. 데이터 로드 (이전 코드 그대로)
# ---------------------------

### 분반배정표 읽기

# 1. 데이터 읽기 (C14부터 유효 데이터)
file = 'bunbanbaejeongpyo.xlsx'
sheet = '분반배정표'

# 과목명: 1행 C열부터
df_header = pd.read_excel(file, sheet_name=sheet, header=None, nrows=1)
subject_cols = df_header.iloc[0, 2:].tolist()  # C열부터 끝까지

# 학생명: A열 14행부터
df_students = pd.read_excel(file, sheet_name=sheet, header=None)
student_rows = df_students.iloc[13:, :]  # 0-based index, 14행부터
student_names = student_rows.iloc[:, 0].tolist()  # A열

# 수강 데이터: 14행부터, C열부터
enroll_matrix = student_rows.iloc[:, 2:]
enroll_matrix.columns = subject_cols
enroll_matrix.index = student_names

# 빈값 미수강, 아니면 수강
enroll_bool = ~enroll_matrix.isna()

# 1번 딕셔너리: 과목별로 겹칠 수 없는 과목 리스트
student_conflict_dict = {subj: [] for subj in subject_cols}

# 2번 딕셔너리: {A: {B: [학생1, 학생2, ...]}}
double_enroll_dict = {subj: {} for subj in subject_cols}

# 모든 과목 쌍에 대해 검사
for subj1, subj2 in itertools.combinations(subject_cols, 2):
    # 두 과목을 모두 듣는 학생 리스트
    both_students = enroll_bool.index[(enroll_bool[subj1]) & (enroll_bool[subj2])].tolist()
    if both_students:
        # 1번: conflict_dict에 양방향 추가
        student_conflict_dict[subj1].append(subj2)
        student_conflict_dict[subj2].append(subj1)
        # 2번: double_enroll_dict에 양방향 추가
        double_enroll_dict[subj1][subj2] = both_students
        double_enroll_dict[subj2][subj1] = both_students

# 결과 예시 출력
# print("1. 과목별 겹칠 수 없는 과목 리스트 예시:")
# for k, v in list(conflict_dict.items())[:5]:
#     print(f"{k}: {v[:5]}... (총 {len(v)}개)")
#
# print("\n2. 두 과목을 동시에 듣는 학생 딕셔너리 예시:")
# for k, v in list(double_enroll_dict.items())[:2]:
#     for k2, v2 in list(v.items())[:2]:
#         print(f"{k} & {k2}: {v2[:5]}... (총 {len(v2)}명)")

# 필요시 저장
with open('학생_충돌_딕셔너리.json', 'w', encoding='utf-8') as f:
    json.dump(student_conflict_dict, f, ensure_ascii=False, indent=2)
with open('과목쌍_공동수강학생_딕셔너리.json', 'w', encoding='utf-8') as f:
    json.dump(double_enroll_dict, f, ensure_ascii=False, indent=2)


### 시험 범위 읽기
# 파일 및 시트명
file = '시험 범위.xlsx'
sheet = 0  # 첫 시트라면 0, 아니면 이름 입력

# 데이터 읽기 (C열 3행부터)
df = pd.read_excel(file, sheet_name=sheet, header=None)

# 과목명 추출 (C열 3행부터)
subject_names = df.iloc[2:, 2].dropna().astype(str).tolist()

# 과목 정보 딕셔너리
subject_info_dict = {}

for idx, subject in enumerate(subject_names):
    row_idx = idx + 2  # 0-based index, 실제 엑셀 3행부터 시작

    # F열: 시간
    time_val = df.iloc[row_idx, 5]
    if pd.isna(time_val):
        time_processed = None
    elif isinstance(time_val, str):
        try:
            time_processed = int(time_val)
        except ValueError:
            time_processed = time_val  # 변환 불가시 원본 저장
    else:
        time_processed = int(time_val)

    # G열: 듣기
    listen_val = df.iloc[row_idx, 6]
    if str(listen_val).strip().upper() in ['X', '0', '']:
        listen_processed = 0
    elif str(listen_val).strip().upper() in ['O', '1']:
        listen_processed = 1
    else:
        listen_processed = 0

    # H열: 자율
    self_val = df.iloc[row_idx, 7]
    if str(self_val).strip().upper() in ['X', '0', '']:
        self_processed = 0
    elif str(self_val).strip().upper() in ['O', '1']:
        self_processed = 1
    else:
        self_processed = 0

    # N열: 학년
    grade_val = df.iloc[row_idx, 13]

    # J~M열: 담당교사
    teacher_cells = df.iloc[row_idx, 9:13]
    teacher_list = []
    for cell in teacher_cells:
        if pd.isna(cell):
            continue
        # 콤마로 분리
        teachers = [t.strip() for t in str(cell).split(',') if t.strip()]
        teacher_list.extend(teachers)

    # 딕셔너리 저장
    subject_info_dict[subject] = {
        '시간': time_processed,
        '듣기': listen_processed,
        '자율': self_processed,
        '학년': grade_val,
        '담당교사': teacher_list
    }

# 결과 예시 출력
# for subj, info in list(subject_info_dict.items())[:5]:
#     print(f"{subj}: {info}")

# 필요시 저장
with open('subject_info.json', 'w', encoding='utf-8') as f:
    json.dump(subject_info_dict, f, ensure_ascii=False, indent=2)


# 1. 듣기평가가 있는 과목 리스트 추출
listening_subjects = [subject for subject, info in subject_info_dict.items() if info['듣기'] == 1]

# 2. 듣기평가 충돌 딕셔너리 생성
listening_conflict_dict = {subject: [] for subject in listening_subjects}
for subj1, subj2 in itertools.combinations(listening_subjects, 2):
    listening_conflict_dict[subj1].append(subj2)
    listening_conflict_dict[subj2].append(subj1)

# 3. 담당교사 충돌 딕셔너리 생성
teacher_conflict_dict = {subject: [] for subject in subject_info_dict}
subjects = list(subject_info_dict.keys())
for i, subj1 in enumerate(subjects):
    teachers1 = set(subject_info_dict[subj1]['담당교사'])
    for subj2 in subjects[i+1:]:
        teachers2 = set(subject_info_dict[subj2]['담당교사'])
        if teachers1 & teachers2:  # 교사가 겹치면
            teacher_conflict_dict[subj1].append(subj2)
            teacher_conflict_dict[subj2].append(subj1)

# 4. 예시 출력
# print("\n듣기평가 충돌 listening_conflict_dict 예시:")
# for subj, conflicts in list(listening_conflict_dict.items())[:5]:
#     print(f"{subj}: {conflicts}")
#
# print("\n담당교사 충돌 teacher_conflict_dict 예시:")
# for subj, conflicts in list(teacher_conflict_dict.items())[:5]:
#     print(f"{subj}: {conflicts}")

# 5. 필요시 저장
with open('listening_conflict_dict.json', 'w', encoding='utf-8') as f:
    json.dump(listening_conflict_dict, f, ensure_ascii=False, indent=2)
with open('teacher_conflict_dict.json', 'w', encoding='utf-8') as f:
    json.dump(teacher_conflict_dict, f, ensure_ascii=False, indent=2)

### 시험 정보.xlsx 읽기
file = '시험 정보.xlsx'
sheet = 0  # 첫 시트라면 0, 아니면 시트명 입력

# 1. 파일 전체 읽기 (헤더 없이)
df = pd.read_excel(file, sheet_name=sheet, header=None)

# 2. 1~3행: 년, 학기, 고사 종류
exam_year = str(df.iloc[0, 1]).strip()    # 1행 B열
exam_semester = str(df.iloc[1, 1]).strip()  # 2행 B열
exam_type = str(df.iloc[2, 1]).strip()    # 3행 B열

# 3. 4~9행: 제1일~제6일 시험 날짜
exam_dates = {}
for i in range(6):  # 0~5
    day_label = str(df.iloc[3 + i, 0]).strip()   # 4~9행 A열: '제1일', '제2일', ...
    date_val = str(df.iloc[3 + i, 1]).strip()   # 4~9행 B열: '6월 14일' 등
    exam_dates[day_label] = date_val

# 4. 10~33행: 각 시험 타임(제n일 m교시)의 시작/종료/진행시간
exam_times = {}
for i in range(24):  # 0~23
    row = 9 + i  # 10~33행
    time_label = str(df.iloc[row, 0]).strip()  # A열: '제1일1교시' 등
    start_time = str(df.iloc[row, 1]).strip()  # B열: 시작시간
    end_time = str(df.iloc[row, 2]).strip()    # C열: 종료시간
    duration = int(df.iloc[row, 3]) if not pd.isna(df.iloc[row, 3]) else None  # D열: 진행시간(분)
    exam_times[time_label] = {
        '시작': start_time,
        '종료': end_time,
        '진행시간': duration
    }

# 5. 결과 예시 출력
# print(f"학년도: {exam_year}")
# print(f"학기: {exam_semester}")
# print(f"고사 종류: {exam_type}")
# print(f"시험 날짜: {exam_dates}")
# print("시험 타임 예시:")
# for k, v in list(exam_times.items())[:3]:
#     print(f"{k}: {v}")

# 6. 딕셔너리로 묶어서 저장(필요시)
exam_info = {
    '학년도': exam_year,
    '학기': exam_semester,
    '고사종류': exam_type,
    '시험날짜': exam_dates,
    '시험타임': exam_times
}

import json
with open('exam_info.json', 'w', encoding='utf-8') as f:
    json.dump(exam_info, f, ensure_ascii=False, indent=2)

### 시험 불가 교사 읽기
# 파일 읽기
file = '시험 불가 교사.xlsx'
sheet = 0  # 첫 시트라면 0, 아니면 시트명 입력

df = pd.read_excel(file, sheet_name=sheet, header=None)

# A2부터 아래로 교사명
teacher_names = df.iloc[1:, 0].dropna().astype(str).tolist()

# B~Y열: 24개 슬롯 (제1일1교시~제6일4교시)
slot_codes = [
    f'{day}{period}교시'
    for day in range(1, 7)       # 1~6일
    for period in range(1, 5)    # 1~4교시
]
# ['11교시', '12교시', ..., '64교시']

# 제n일m교시 → '제n일m교시'
slot_labels = [f'제{code[0]}{code[1]}교시' for code in slot_codes]

# # 실제로는 ['제1일1교시', '제1일2교시', ...] 형태
# slot_labels = []
# for day in range(1, 7):
#     for period in range(1, 5):
#         slot_labels.append(f'제{day}일{period}교시')

teacher_unavailable_dates = {}

for i, teacher in enumerate(teacher_names):
    # B~Y열 (1~24)
    unavailable_slots = []
    for j, slot_label in enumerate(slot_labels):
        val = df.iloc[i+1, j+1]  # i+1: 데이터는 2행부터, j+1: B열부터
        if not pd.isna(val) and int(val) == 1:
            unavailable_slots.append(slot_label)
    teacher_unavailable_dates[teacher] = unavailable_slots

# 저장
with open('teacher_unavailable_dates.json', 'w', encoding='utf-8') as f:
    json.dump(teacher_unavailable_dates, f, ensure_ascii=False, indent=2)

# ---------------------------
# 2. 변수 선언
# ---------------------------
# subjects, subject_info_dict, student_conflict_dict, listening_conflict_dict, teacher_conflict_dict
# exam_times, exam_dates, student_names, enroll_bool

# ---------------------------
# 2. 시험 슬롯/타임 정의
# ---------------------------
# 5일 × 3교시 = 15개 슬롯
days = list(exam_dates.keys())[:5]  # ['제1일', ..., '제5일']
periods = ['1교시', '2교시', '3교시']
slots = [f'{day}{period}' for day in days for period in periods]
max_num_of_exam_for_student = 3 # 학생별 하루 최대 시험 개수
max_num_of_hard_exam_for_student = 2 # 학생별 하루 최대 60분 이상 시험 개수
hard_exam_period = 60 # 어려운 시험 기준은 60분

# 각 slot별 허용 최대 시간(분)
slot_to_period_limit = {}
for slot in slots:
    period = slot[-3:]  # '1교시', '2교시', '3교시'
    if period == '1교시':
        slot_to_period_limit[slot] = 80
    elif period == '2교시':
        slot_to_period_limit[slot] = 50
    elif period == '3교시':
        slot_to_period_limit[slot] = 100

# slot -> day 매핑
slot_to_day = {slot: slot[:3] for slot in slots}

# ---------------------------
# 3. OR-Tools 모델 생성
# ---------------------------
model = cp_model.CpModel()

# 변수 생성 (시간 제한 미리 필터링)
exam_slot_vars = {}
for subject in subject_info_dict.keys():
    duration = subject_info_dict[subject]['시간']
    valid_slots = [
        slot for slot in slots
        if duration is None or duration <= slot_to_period_limit[slot]
    ]
    exam_slot_vars[subject] = {
        slot: model.NewBoolVar(f'{subject}_{slot}')
        for slot in valid_slots
    }

# 제약조건: 각 과목 1회 배정
for subject, var_dict in exam_slot_vars.items():
    model.Add(sum(var_dict.values()) == 1)

# 제약조건: 충돌 방지 (수정 버전)
for slot in slots:
    # 현재 슬롯에 변수가 존재하는 과목들만 필터링
    subjects_in_slot = [
        subj for subj, var_dict in exam_slot_vars.items()
        if slot in var_dict
    ]

    for i in range(len(subjects_in_slot)):
        subj1 = subjects_in_slot[i]
        # 학생 충돌
        for conflict in student_conflict_dict.get(subj1, []):
            if conflict in exam_slot_vars and slot in exam_slot_vars[conflict]:
                if subj1 < conflict:
                    model.Add(
                        exam_slot_vars[subj1][slot] +
                        exam_slot_vars[conflict][slot] <= 1
                    )
        # 듣기 충돌
        for conflict in listening_conflict_dict.get(subj1, []):
            if conflict in exam_slot_vars and slot in exam_slot_vars[conflict]:
                if subj1 < conflict:
                    model.Add(
                        exam_slot_vars[subj1][slot] +
                        exam_slot_vars[conflict][slot] <= 1
                    )
        # 교사 충돌
        for conflict in teacher_conflict_dict.get(subj1, []):
            if conflict in exam_slot_vars and slot in exam_slot_vars[conflict]:
                if subj1 < conflict:
                    model.Add(
                        exam_slot_vars[subj1][slot] +
                        exam_slot_vars[conflict][slot] <= 1
                    )

# (3) slot별 시간 제한 (1교시: 80분, 2교시: 50분, 3교시: 100분)
for subject in subject_info_dict.keys():
    duration = subject_info_dict[subject]['시간']
    for slot in slots:
        if slot not in exam_slot_vars[subject]:
            continue  # 변수 자체가 없으면 건너뜀
        if duration is not None and duration > slot_to_period_limit[slot]:
            model.Add(exam_slot_vars[subject][slot] == 0)

# (4) 교사별 불가능 날짜 반영
for subject in subject_info_dict.keys():
    teachers = subject_info_dict[subject]['담당교사']
    for teacher in teachers:
        if teacher not in teacher_unavailable_dates:
            continue
        for slot in teacher_unavailable_dates[teacher]:
            if slot in exam_slot_vars[subject]:
                model.Add(exam_slot_vars[subject][slot] == 0)

# (5) 학생별 하루 시험 수/60분 이상 시험수 제한
student_subjects = {
    student: [subject for subject in subject_info_dict.keys() if enroll_bool.loc[student, subject]]
    for student in student_names if student in enroll_bool.index
}

# 하루에 최대 3과목, 60분 이상 과목은 최대 2개
for student in student_subjects:
    for day in days:
        exams_today = [
            exam_slot_vars[subject][slot]
            for subject in student_subjects[student]
            for slot in slots
            if slot_to_day[slot] == day and slot in exam_slot_vars[subject]
        ]
        model.Add(sum(exams_today) <= max_num_of_exam_for_student)

        long_exams_today = [
            exam_slot_vars[subject][slot]
            for subject in student_subjects[student]
            for slot in slots
            if (
                slot_to_day[slot] == day
                and subject_info_dict[subject]['시간'] is not None
                and subject_info_dict[subject]['시간'] >= 60
                and slot in exam_slot_vars[subject]
            )
        ]
        model.Add(sum(long_exams_today) <= max_num_of_hard_exam_for_student)


# ---------------------------
# 5. 목적함수 (max_num_of_exam_for_student, max_num_of_hard_exam_for_student 최소화)
# ---------------------------


students_with_m = []
students_with_n = []

for student in student_subjects:
    # 각 학생의 day별 시험 수, 60분 이상 시험 수
    exams_per_day = []
    long_exams_per_day = []
    for day in days:
        exams_today = [
            exam_slot_vars[subject][slot]
            for subject in student_subjects[student]
            for slot in slots
            if slot_to_day[slot] == day and slot in exam_slot_vars[subject]
        ]
        long_exams_today = [
            exam_slot_vars[subject][slot]
            for subject in student_subjects[student]
            for slot in slots
            if (
                slot_to_day[slot] == day
                and subject_info_dict[subject]['시간'] is not None
                and subject_info_dict[subject]['시간'] >= hard_exam_period
                and slot in exam_slot_vars[subject]
            )
        ]
        exams_per_day.append(sum(exams_today))
        long_exams_per_day.append(sum(long_exams_today))
    max_exam = model.NewIntVar(0, max_num_of_exam_for_student, f'max_exam_{student}')
    max_long_exam = model.NewIntVar(0, max_num_of_hard_exam_for_student, f'max_long_exam_{student}')
    model.AddMaxEquality(max_exam, exams_per_day)
    model.AddMaxEquality(max_long_exam, long_exams_per_day)
    # m, n값 학생 수 변수
    is_m = model.NewBoolVar(f'is_m_{student}')
    is_n = model.NewBoolVar(f'is_n_{student}')
    model.Add(max_exam == max_num_of_exam_for_student).OnlyEnforceIf(is_m)
    model.Add(max_exam != max_num_of_exam_for_student).OnlyEnforceIf(is_m.Not())
    model.Add(max_long_exam == max_num_of_hard_exam_for_student).OnlyEnforceIf(is_n)
    model.Add(max_long_exam != max_num_of_hard_exam_for_student).OnlyEnforceIf(is_n.Not())
    students_with_m.append(is_m)
    students_with_n.append(is_n)


model.Minimize(sum(students_with_m) + sum(students_with_n))

# ---------------------------
# 6. 풀이 및 결과 출력
# ---------------------------
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 120  # 시간 제한
status = solver.Solve(model)

if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
    print("시험 시간표 배정 결과:")
    for slot in slots:
        assigned_subjects = [
            subject for subject in subject_info_dict.keys()
            if slot in exam_slot_vars[subject] and solver.Value(exam_slot_vars[subject][slot])
        ]
        if assigned_subjects:
            print(f"{slot}: {', '.join(assigned_subjects)}")

    # print("\n학생별 하루 최대 시험 수/60분 이상 시험 수:")
    # 학생별 day별 시험수, 60분 이상 시험수 저장
    student_max_per_day = {}
    student_max_long_per_day = {}
    student_exam_subjects_per_day = {}
    student_long_exam_subjects_per_day = {}

    for student in student_subjects:
        exams_per_day = []
        long_exams_per_day = []
        exam_subjects_per_day = []
        long_exam_subjects_per_day = []
        for day in days:
            # 오늘 배정된 과목
            subjects_today = [
                subject for subject in student_subjects[student]
                for slot in slots
                if slot_to_day[slot] == day and slot in exam_slot_vars[subject]
                and solver.Value(exam_slot_vars[subject][slot])
            ]
            exams_today = len(subjects_today)
            # 오늘 배정된 60분 이상 과목
            long_subjects_today = [
                subject for subject in student_subjects[student]
                for slot in slots
                if (
                    slot_to_day[slot] == day
                    and subject_info_dict[subject]['시간'] is not None
                    and subject_info_dict[subject]['시간'] >= hard_exam_period
                    and slot in exam_slot_vars[subject]
                    and solver.Value(exam_slot_vars[subject][slot])
                )
            ]
            long_exams_today = len(long_subjects_today)
            exams_per_day.append(exams_today)
            long_exams_per_day.append(long_exams_today)
            exam_subjects_per_day.append(subjects_today)
            long_exam_subjects_per_day.append(long_subjects_today)
        student_max_per_day[student] = max(exams_per_day)
        student_max_long_per_day[student] = max(long_exams_per_day)
        student_exam_subjects_per_day[student] = exam_subjects_per_day
        student_long_exam_subjects_per_day[student] = long_exam_subjects_per_day
        # print(f"{student}: {exams_per_day} (60분↑: {long_exams_per_day})")

    # 하루에 3, 2과목 치는 학생 명단
    for num in [3, 2]:
        students_with_num = [
            student for student in student_max_per_day
            if student_max_per_day[student] == num
        ]
        print(f"\n하루에 {num}과목 치는 학생 수: {len(students_with_num)}명")
        for student in students_with_num:
            # 어떤 날에 3/2/1과목 쳤는지, 그 과목들 출력
            for i, subjects_today in enumerate(student_exam_subjects_per_day[student]):
                if len(subjects_today) == num:
                    day_str = days[i]
                    print(f"{student} ({day_str}): {', '.join(subjects_today)}")

    # 하루에 60분 이상 과목 3, 2과목 치는 학생 명단
    for num in [3, 2]:
        students_with_long_num = [
            student for student in student_max_long_per_day
            if student_max_long_per_day[student] == num
        ]
        print(f"\n하루에 60분 이상 과목 {num}과목 치는 학생 수: {len(students_with_long_num)}명")
        for student in students_with_long_num:
            for i, long_subjects_today in enumerate(student_long_exam_subjects_per_day[student]):
                if len(long_subjects_today) == num:
                    day_str = days[i]
                    print(f"{student} ({day_str}): {', '.join(long_subjects_today)}")
else:
    print("해를 찾지 못했습니다. 상태:", solver.StatusName(status))
profile
개발 공부하는 30대 비전공자 직장인

0개의 댓글