476 lines
21 KiB
Python
476 lines
21 KiB
Python
from sys import exit
|
|
from PySide6 import QtCore, QtWidgets, QtGui
|
|
from PySide6.QtGui import QFont
|
|
from utils import read_excel, save_to_excel
|
|
from solve import solve_program
|
|
from datetime import datetime
|
|
from traceback import format_exc
|
|
|
|
# 约束条件配置
|
|
CONSTRAINTS_CONFIG = [
|
|
("每班次人数:", "5", "8"),
|
|
("每班次电脑或电器的老人数:", "", "2"),
|
|
("每班次老人数:", "1", ""),
|
|
("每班次小朋友数:", "2", "")
|
|
]
|
|
|
|
# 权重条件配置
|
|
WEIGHTS_CONFIG = [
|
|
("目标 1 权重:每班人数平均程度", "1.0"),
|
|
("目标 2 权重:每班技术部老人", "1.0"),
|
|
("目标 3 权重:每班技术部小朋友", "1.0"),
|
|
("目标 4 权重:每班人资部小朋友", "0.5"),
|
|
("目标 5 权重:每班部门平均程度", "0.5")
|
|
]
|
|
|
|
# 参数调优顺序和尝试值
|
|
PARAM_CONFIGS = [
|
|
('num_min', [5, 4, 3, 2, 1]),
|
|
('num_max', [8, 9, 10, 11, 12, 13, 14, 15]),
|
|
('num_old_min', [2, 1, 0]),
|
|
('num_old_max', [3, 4, 5, 6, 7, 8]),
|
|
('num_new_min', [1, 0]),
|
|
('num_tech_old_min', [1, 0]),
|
|
('num_tech_old_max', [1, 2, 3, 4, 5, 6, 7, 8]),
|
|
]
|
|
|
|
# 工作线程类,用于在后台执行求解任务
|
|
class SolverThread(QtCore.QThread):
|
|
# 定义信号用于线程与主界面通信
|
|
log_signal = QtCore.Signal(str) # 发送日志消息
|
|
finished_signal = QtCore.Signal(object, dict) # 发送求解结果 (vars, params)
|
|
error_signal = QtCore.Signal(str) # 发送错误消息
|
|
|
|
def __init__(self, preference_mat, depart_mat, want_num_array, is_new_array,
|
|
auto_mode, manual_params=None, exempt_shifts=None, parent=None):
|
|
super().__init__(parent)
|
|
self.preference_mat = preference_mat
|
|
self.depart_mat = depart_mat
|
|
self.want_num_array = want_num_array
|
|
self.is_new_array = is_new_array
|
|
self.auto_mode = auto_mode
|
|
self.manual_params = manual_params or {}
|
|
self.exempt_shifts = exempt_shifts or []
|
|
|
|
def run(self):
|
|
try:
|
|
vars = None
|
|
final_params = {}
|
|
|
|
if self.auto_mode:
|
|
self.log_signal.emit("自动调参开始...")
|
|
|
|
auto_params = {
|
|
'num_min': None, 'num_max': None,
|
|
'num_tech_old_min': None, 'num_tech_old_max': None,
|
|
'num_old_min': None, 'num_old_max': None,
|
|
'num_new_min': None, 'num_new_max': None,
|
|
'weights': self.manual_params.get('weights', [1.0, 1.0, 1.0, 0.5, 0.5])
|
|
}
|
|
|
|
# 逐步优化参数
|
|
for param_name, try_values in PARAM_CONFIGS:
|
|
value = None
|
|
vars = None
|
|
for try_value in try_values:
|
|
self.log_signal.emit(f"尝试 {param_name} = {try_value}...")
|
|
test_params = auto_params.copy()
|
|
test_params[param_name] = try_value
|
|
vars = solve_program(
|
|
preference_mat=self.preference_mat,
|
|
depart_mat=self.depart_mat,
|
|
want_num_array=self.want_num_array,
|
|
is_new_array=self.is_new_array,
|
|
exempt_shifts=self.exempt_shifts,
|
|
**test_params
|
|
)
|
|
if vars:
|
|
value = try_value
|
|
self.log_signal.emit(f"✓ {param_name} = {try_value} 成功")
|
|
break
|
|
else:
|
|
self.log_signal.emit(f"✗ {param_name} = {try_value} 失败")
|
|
if value:
|
|
auto_params[param_name] = value
|
|
else:
|
|
break
|
|
|
|
final_params = auto_params
|
|
|
|
else:
|
|
self.log_signal.emit("计算最优解中...")
|
|
vars = solve_program(
|
|
preference_mat=self.preference_mat,
|
|
want_num_array=self.want_num_array,
|
|
depart_mat=self.depart_mat,
|
|
is_new_array=self.is_new_array,
|
|
exempt_shifts=self.exempt_shifts,
|
|
**self.manual_params
|
|
)
|
|
final_params = self.manual_params
|
|
|
|
# 发送完成信号
|
|
self.finished_signal.emit(vars, final_params)
|
|
|
|
except Exception as e:
|
|
self.error_signal.emit(format_exc())
|
|
|
|
class MyWidget(QtWidgets.QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
self.solver_thread = None # 用于存储求解线程
|
|
|
|
self.main_layout = QtWidgets.QVBoxLayout(self)
|
|
|
|
bold_font = QFont()
|
|
bold_font.setBold(True)
|
|
|
|
thin_font = QFont()
|
|
thin_font.setBold(False)
|
|
|
|
# 选择文件部分
|
|
self._excel_dir = None
|
|
self.group_box_1 = QtWidgets.QGroupBox("Step 1. 选择问卷星导出的表格文件")
|
|
self.group_box_1.setFont(bold_font)
|
|
|
|
self.openfile_layout = QtWidgets.QVBoxLayout()
|
|
self.button_openfile = QtWidgets.QPushButton("选择文件")
|
|
self.button_openfile.setFont(thin_font)
|
|
self.button_openfile.clicked.connect(self.open_file)
|
|
self.label_openfile = QtWidgets.QLabel("未选择文件")
|
|
self.label_openfile.setFont(thin_font)
|
|
|
|
self.openfile_layout.addWidget(self.button_openfile)
|
|
self.openfile_layout.addWidget(self.label_openfile)
|
|
self.group_box_1.setLayout(self.openfile_layout)
|
|
self.main_layout.addWidget(self.group_box_1)
|
|
|
|
# 限制条件部分
|
|
self.group_box_2 = QtWidgets.QGroupBox("Step 2. 输入限制条件,权重参数均已归一化")
|
|
self.group_box_2.setFont(bold_font)
|
|
self.cond_layout_overall = QtWidgets.QVBoxLayout()
|
|
self.group_box_2.setLayout(self.cond_layout_overall)
|
|
|
|
# 创建权重输入框
|
|
self.weight_widgets = {}
|
|
|
|
for i, (label_text, value) in enumerate(WEIGHTS_CONFIG, 1):
|
|
layout, edit = self._create_weight_row(label_text, value, thin_font)
|
|
self.weight_widgets[f'layout_{i}'] = layout
|
|
self.weight_widgets[f'edit_{i}'] = edit
|
|
self.cond_layout_overall.addLayout(layout)
|
|
|
|
# 自动模式开关
|
|
self._auto_mode = True
|
|
self.cond_layout_0 = QtWidgets.QVBoxLayout()
|
|
self.auto_switch = QtWidgets.QCheckBox("自动调参模式")
|
|
self.auto_switch.setTristate(False) # 禁用三态模式
|
|
self.auto_switch.setChecked(True)
|
|
self.auto_switch.stateChanged.connect(self.on_switch_toggled)
|
|
self.auto_switch.setFont(thin_font)
|
|
|
|
self.cond_layout_0.addWidget(self.auto_switch)
|
|
self.cond_layout_overall.addLayout(self.cond_layout_0)
|
|
|
|
# 创建限制条件输入框
|
|
self.constraint_widgets = {}
|
|
|
|
for i, (label_text, min_value, max_value) in enumerate(CONSTRAINTS_CONFIG, 1):
|
|
layout, min_edit, max_edit = self._create_constraint_row(label_text, min_value, max_value, thin_font)
|
|
self.constraint_widgets[f'layout_{i}'] = layout
|
|
self.constraint_widgets[f'min_{i}'] = min_edit
|
|
self.constraint_widgets[f'max_{i}'] = max_edit
|
|
self.cond_layout_overall.addLayout(layout)
|
|
|
|
self.main_layout.addWidget(self.group_box_2)
|
|
|
|
# 处理文件部分
|
|
self.group_box_3 = QtWidgets.QGroupBox("Step 3. 获取最优结果")
|
|
self.group_box_3.setFont(bold_font)
|
|
|
|
self.button_solve = QtWidgets.QPushButton("开始排班!")
|
|
self.button_solve.setFont(thin_font)
|
|
self.text_solve = QtWidgets.QTextEdit(self)
|
|
self.text_solve.setFont(thin_font)
|
|
self.text_solve.setReadOnly(True) # 设置为只读模式
|
|
|
|
# 创建 QScrollArea 并将 QTextEdit 添加进去
|
|
self.scroll_area = QtWidgets.QScrollArea(self)
|
|
self.scroll_area.setWidget(self.text_solve) # 将 QTextEdit 设置为 QScrollArea 的内容
|
|
self.scroll_area.setWidgetResizable(True) # 允许 QTextEdit 根据 QScrollArea 的大小自动调整
|
|
|
|
self.button_solve.clicked.connect(self.magic)
|
|
|
|
self.solve_layout = QtWidgets.QVBoxLayout()
|
|
self.solve_layout.addWidget(self.scroll_area)
|
|
self.solve_layout.addWidget(self.button_solve)
|
|
self.group_box_3.setLayout(self.solve_layout)
|
|
self.main_layout.addWidget(self.group_box_3)
|
|
|
|
def _create_constraint_row(self, label_text, min_value, max_value, font):
|
|
"""创建一行限制条件输入框"""
|
|
layout = QtWidgets.QHBoxLayout()
|
|
|
|
label = QtWidgets.QLabel(label_text, self)
|
|
label.setFont(font)
|
|
|
|
# 创建输入框的辅助函数
|
|
def create_input(value):
|
|
edit = QtWidgets.QLineEdit(self)
|
|
edit.setFont(font)
|
|
edit.setValidator(QtGui.QIntValidator())
|
|
edit.setPlaceholderText("无限制")
|
|
if value:
|
|
edit.setText(value)
|
|
edit.setEnabled(False)
|
|
return edit
|
|
|
|
min_edit = create_input(min_value)
|
|
to_label = QtWidgets.QLabel("到", self)
|
|
to_label.setFont(font)
|
|
max_edit = create_input(max_value)
|
|
|
|
layout.addWidget(label)
|
|
layout.addWidget(min_edit)
|
|
layout.addWidget(to_label)
|
|
layout.addWidget(max_edit)
|
|
|
|
return layout, min_edit, max_edit
|
|
|
|
def _create_weight_row(self, label_text, value, font):
|
|
"""创建一行权重输入框"""
|
|
layout = QtWidgets.QHBoxLayout()
|
|
|
|
label = QtWidgets.QLabel(label_text, self)
|
|
label.setFont(font)
|
|
|
|
edit = QtWidgets.QLineEdit(self)
|
|
edit.setFont(font)
|
|
edit.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 2, self))
|
|
edit.setText(str(value))
|
|
|
|
layout.addWidget(label)
|
|
layout.addWidget(edit)
|
|
|
|
return layout, edit
|
|
|
|
def open_file(self):
|
|
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "打开文件", "", "所有文件 (*.*);;文本文件 (*.txt)")
|
|
if file_path:
|
|
self.label_openfile.setText(f"选中文件: {file_path}")
|
|
self._excel_dir = file_path
|
|
|
|
def on_switch_toggled(self, state):
|
|
enabled = state == 0
|
|
self._auto_mode = not enabled
|
|
|
|
for i in range(1, len(CONSTRAINTS_CONFIG) + 1):
|
|
self.constraint_widgets[f'min_{i}'].setEnabled(enabled)
|
|
self.constraint_widgets[f'max_{i}'].setEnabled(enabled)
|
|
|
|
@QtCore.Slot()
|
|
def magic(self):
|
|
try:
|
|
self.text_solve.append(f"******** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ********")
|
|
|
|
# 读取文件
|
|
if self._excel_dir is None:
|
|
self.text_solve.append("请先选择文件!")
|
|
return
|
|
|
|
# 禁用按钮,防止重复点击
|
|
self.button_solve.setEnabled(False)
|
|
self.button_solve.setText("计算中...")
|
|
|
|
self.text_solve.append("开始排班...")
|
|
self.text_solve.append("读取文件中...")
|
|
self.all_data, self.index_to_name_dict, preference_mat, depart_mat, want_num_array, is_new_array = read_excel(self._excel_dir)
|
|
self.text_solve.append("读取文件成功!")
|
|
|
|
# 计算并打印统计信息
|
|
is_tech_array = [depart_mat[i][0] == 1 or depart_mat[i][1] == 1 for i in range(len(depart_mat))]
|
|
stu_num = len(self.index_to_name_dict)
|
|
class_num = len(preference_mat[0])
|
|
tech_sum = sum(want_num_array[i] for i in range(len(preference_mat))
|
|
if not is_new_array[i] and is_tech_array[i])
|
|
all_sum = sum(want_num_array)
|
|
min_prefer = min(sum(preference_mat[i][j] for i in range(len(preference_mat)))
|
|
for j in range(class_num))
|
|
|
|
self.text_solve.append("信息统计:")
|
|
self.text_solve.append(f"\t学生总人数:{stu_num}")
|
|
self.text_solve.append(f"\t班次总数:{class_num}")
|
|
self.text_solve.append(f"\t电脑部或电器部的所有老人的意愿班次之和:{tech_sum}")
|
|
self.text_solve.append(f"\t所有人的意愿班次之和:{all_sum}")
|
|
self.text_solve.append(f"\t平均每班人数:{all_sum/20}")
|
|
self.text_solve.append(f"\t所有班次中最少拥有意愿数:{min_prefer}")
|
|
|
|
# 检查是否有班次没有任何同学选择
|
|
CLASS_NAMES = [
|
|
"周一第一班", "周一第二班", "周一第三班",
|
|
"周二第一班", "周二第二班", "周二第三班",
|
|
"周三第一班", "周三第二班", "周三第三班",
|
|
"周四第一班", "周四第二班", "周四第三班",
|
|
"周五第一班", "周五第二班", "周五第三班",
|
|
"周六第一班", "周六第二班", "周六第三班",
|
|
"周日第一班", "周日第二班"
|
|
]
|
|
empty_classes = [
|
|
j for j in range(class_num)
|
|
if sum(preference_mat[i][j] for i in range(len(preference_mat))) == 0
|
|
]
|
|
if empty_classes:
|
|
empty_names = [
|
|
CLASS_NAMES[j] if j < len(CLASS_NAMES) else f"第{j+1}班"
|
|
for j in empty_classes
|
|
]
|
|
warning_detail = "、".join(empty_names)
|
|
self.text_solve.append(f"警告:以下班次没有任何同学选择:{warning_detail}")
|
|
reply = QtWidgets.QMessageBox.question(
|
|
self,
|
|
"警告:存在无人选择的班次",
|
|
f"以下班次没有任何同学选择:\n\n{warning_detail}\n\n"
|
|
f"点击「确定」将这些班次视为无人值班并继续排班,\n"
|
|
f"点击「取消」放弃排班。",
|
|
QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel,
|
|
QtWidgets.QMessageBox.StandardButton.Cancel
|
|
)
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Cancel:
|
|
self.text_solve.append("排班已取消。")
|
|
self.button_solve.setEnabled(True)
|
|
self.button_solve.setText("开始排班!")
|
|
return
|
|
self.text_solve.append(f"继续排班,以下班次将视为无人值班:{warning_detail}")
|
|
|
|
# 检查是否有同学选择的可用班次少于期望值班次数
|
|
want_num_adjusted = list(want_num_array)
|
|
infeasible_students = []
|
|
for i in range(len(preference_mat)):
|
|
avail = sum(preference_mat[i][j] for j in range(class_num))
|
|
want = want_num_array[i]
|
|
min_want = 2 if want == 3 else want
|
|
if avail < min_want:
|
|
infeasible_students.append((i, self.index_to_name_dict[i], avail, want))
|
|
|
|
if infeasible_students:
|
|
student_details = "\n".join(
|
|
f" {name}:期望 {want} 班,仅选了 {avail} 个可用班次"
|
|
for _, name, avail, want in infeasible_students
|
|
)
|
|
self.text_solve.append(f"警告:以下同学的可用班次数少于期望值班次数:{', '.join(n for _, n, _, _ in infeasible_students)}")
|
|
reply = QtWidgets.QMessageBox.question(
|
|
self,
|
|
"警告:存在同学可用班次不足",
|
|
f"以下同学选择的可用班次数少于其期望值班次数:\n\n{student_details}\n\n"
|
|
f"点击「确定」按实际可用班次数安排他们并继续排班,\n"
|
|
f"点击「取消」放弃排班。",
|
|
QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel,
|
|
QtWidgets.QMessageBox.StandardButton.Cancel
|
|
)
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Cancel:
|
|
self.text_solve.append("排班已取消。")
|
|
self.button_solve.setEnabled(True)
|
|
self.button_solve.setText("开始排班!")
|
|
return
|
|
for i, name, avail, want in infeasible_students:
|
|
want_num_adjusted[i] = avail
|
|
self.text_solve.append(f"调整:{name} 的期望班次由 {want} 调整为 {avail}")
|
|
|
|
def get_weight_value(key):
|
|
text = self.weight_widgets[key].text()
|
|
return float(text) if text else 0.0
|
|
|
|
def get_constraint_value(key):
|
|
text = self.constraint_widgets[key].text()
|
|
return int(text) if text else None
|
|
|
|
# 准备参数
|
|
params = {
|
|
'num_min': get_constraint_value('min_1'),
|
|
'num_max': get_constraint_value('max_1'),
|
|
'num_tech_old_min': get_constraint_value('min_2'),
|
|
'num_tech_old_max': get_constraint_value('max_2'),
|
|
'num_old_min': get_constraint_value('min_3'),
|
|
'num_old_max': get_constraint_value('max_3'),
|
|
'num_new_min': get_constraint_value('min_4'),
|
|
'num_new_max': get_constraint_value('max_4'),
|
|
'weights': [get_weight_value(f'edit_{i}') for i in range(1, len(WEIGHTS_CONFIG) + 1)]
|
|
}
|
|
|
|
# 创建并启动求解线程
|
|
self.solver_thread = SolverThread(
|
|
preference_mat, depart_mat, want_num_adjusted, is_new_array,
|
|
self._auto_mode, params, empty_classes, self
|
|
)
|
|
|
|
# 连接信号
|
|
self.solver_thread.log_signal.connect(self.on_log_message)
|
|
self.solver_thread.finished_signal.connect(self.on_solve_finished)
|
|
self.solver_thread.error_signal.connect(self.on_solve_error)
|
|
|
|
# 启动线程
|
|
self.solver_thread.start()
|
|
|
|
except Exception:
|
|
self.text_solve.append("程序出现严重错误,请联系开发者解决问题!!!")
|
|
self.text_solve.append(f"Error Details:\n{format_exc()}")
|
|
self.button_solve.setEnabled(True)
|
|
self.button_solve.setText("开始排班!")
|
|
|
|
@QtCore.Slot(str)
|
|
def on_log_message(self, message):
|
|
"""接收线程发送的日志消息并实时显示"""
|
|
self.text_solve.append(message)
|
|
|
|
@QtCore.Slot(object, dict)
|
|
def on_solve_finished(self, vars, params):
|
|
"""求解完成后的回调"""
|
|
try:
|
|
if vars is None:
|
|
if self._auto_mode:
|
|
self.text_solve.append("自动求解失败!请尝试手动调参!")
|
|
else:
|
|
self.text_solve.append("在目前限制条件下无解!请尝试更改限制条件!")
|
|
else:
|
|
if self._auto_mode:
|
|
self.text_solve.append("自动计算成功!")
|
|
self.text_solve.append("参数:")
|
|
self.text_solve.append(f"\t每班人数:[{params['num_min']}, {params['num_max']}]")
|
|
self.text_solve.append(f"\t每班电脑或电器的老人数:[{params['num_tech_old_min']}, {params['num_tech_old_max']}]")
|
|
self.text_solve.append(f"\t每班老人数:[{params['num_old_min']}, {params['num_old_max']}]")
|
|
self.text_solve.append(f"\t每班小朋友数:[{params['num_new_min']}, inf]")
|
|
else:
|
|
self.text_solve.append("计算最优解成功!")
|
|
|
|
# 保存结果到 excel
|
|
self.text_solve.append("保存结果中...")
|
|
save_dir = f"result_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
save_to_excel(vars, self.all_data, self.index_to_name_dict, save_dir)
|
|
self.text_solve.append(f"保存结果成功!保存路径:{save_dir}")
|
|
|
|
except Exception:
|
|
self.text_solve.append("保存结果时出现错误!")
|
|
self.text_solve.append(f"Error Details:\n{format_exc()}")
|
|
finally:
|
|
# 恢复按钮状态
|
|
self.button_solve.setEnabled(True)
|
|
self.button_solve.setText("开始排班!")
|
|
|
|
@QtCore.Slot(str)
|
|
def on_solve_error(self, error_msg):
|
|
"""处理求解过程中的错误"""
|
|
self.text_solve.append("程序出现严重错误,请联系开发者解决问题!!!")
|
|
self.text_solve.append(f"Error Details:\n{error_msg}")
|
|
self.button_solve.setEnabled(True)
|
|
self.button_solve.setText("开始排班!")
|
|
|
|
if __name__ == "__main__":
|
|
app = QtWidgets.QApplication([])
|
|
|
|
widget = MyWidget()
|
|
widget.setWindowTitle("EVA 值班排班软件")
|
|
widget.resize(600, 600)
|
|
widget.show()
|
|
|
|
exit(app.exec()) |