排课优化 #3

Merged
biss merged 12 commits from codex/timetable-soft-constraints into master 2026-08-17 19:36:49 +08:00
3 changed files with 348 additions and 73 deletions
Showing only changes of commit 94bc064697 - Show all commits

No files matched your search

@@ -63,9 +63,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
.FirstOrDefaultAsync(x => x.AcademicTermId == plan.AcademicTermId, cancellationToken) .FirstOrDefaultAsync(x => x.AcademicTermId == plan.AcademicTermId, cancellationToken)
?? ScheduleOptimizationSettings.CreateDefault(plan.AcademicTermId); ?? ScheduleOptimizationSettings.CreateDefault(plan.AcademicTermId);
var created = 0;
var completedTasks = 0;
var processedTasks = 0;
var messages = new List<string>(); var messages = new List<string>();
if (reportProgress is not null) if (reportProgress is not null)
{ {
@@ -74,87 +71,364 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
cancellationToken); cancellationToken);
} }
foreach (var task in tasks) var optimizer = new GeneticScheduleOptimizer(
plan.Id,
tasks,
constraints,
activePeriods,
classrooms,
entries,
optimization);
var result = optimizer.Optimize(cancellationToken);
db.ScheduleEntries.AddRange(result.Entries);
messages.AddRange(result.Messages);
if (reportProgress is not null)
{ {
cancellationToken.ThrowIfCancellationRequested(); await reportProgress(
constraints.TryGetValue(task.Id, out var constraint); new(tasks.Count, tasks.Count, result.Entries.Count, result.CompletedTasks),
var taskCompleted = true; cancellationToken);
foreach (var kind in new[] }
{ if (result.Entries.Count > 0 && saveChanges)
ScheduleEntryKind.Lecture, await db.SaveChangesAsync(cancellationToken);
ScheduleEntryKind.Experiment return new(
}) result.Entries.Count,
result.CompletedTasks,
messages,
tasks.Count,
tasks.Count);
}
/// <summary>
/// 遗传搜索以完整排课方案作为个体。候选生成、交叉和变异均经修复,
/// 因而冲突、容量和课程可用范围始终是不可违反的硬约束。
/// </summary>
private sealed class GeneticScheduleOptimizer(
Guid planId,
IReadOnlyList<TeachingTask> tasks,
IReadOnlyDictionary<Guid, TeachingTaskScheduleConstraint> constraints,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> baselineEntries,
ScheduleOptimizationSettings settings)
{
private const int MinimumPopulationSize = 18;
private const int MaximumPopulationSize = 36;
private readonly Random random = new();
public GeneticScheduleOptimizationResult Optimize(CancellationToken cancellationToken)
{
var messages = new List<string>();
var invalidTaskIds = new HashSet<Guid>();
var demands = BuildDemands(messages, invalidTaskIds);
if (demands.Count == 0)
return new([], tasks.Count - invalidTaskIds.Count, messages);
var candidates = demands.Select(BuildCandidates).ToList();
var populationSize = Math.Clamp(demands.Count * 2, MinimumPopulationSize, MaximumPopulationSize);
var generationCount = Math.Clamp(demands.Count * 3, 36, 120);
var population = Enumerable.Range(0, populationSize)
.Select(_ => CreateIndividual(demands, candidates, cancellationToken))
.ToList();
for (var generation = 0; generation < generationCount; generation++)
{ {
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind); cancellationToken.ThrowIfCancellationRequested();
var scheduledHours = entries population = population.OrderBy(individual => individual.Fitness).ToList();
.Where(x => var nextGeneration = new List<GeneticIndividual>
x.TeachingTaskId == task.Id &&
x.Kind == kind)
.Sum(TeachingTaskHours.ScheduledHours);
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
if (scheduledHours > targetHours)
{ {
messages.Add( population[0].Clone(),
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," + population[1].Clone()
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。"); };
taskCompleted = false; while (nextGeneration.Count < populationSize)
continue;
}
var remainingHours = targetHours - scheduledHours;
while (remainingHours > 0)
{ {
var candidate = FindBestCandidateForHours( var firstParent = SelectParent(population);
plan.Id, var secondParent = SelectParent(population);
task, var child = Crossover(firstParent, secondParent, demands, candidates);
constraint, Mutate(child, candidates);
kind, child.Fitness = CalculateFitness(child.Genes);
remainingHours, nextGeneration.Add(child);
activePeriods,
classrooms,
entries,
optimization,
cancellationToken);
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
}
if (remainingHours > 0)
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
(kind == ScheduleEntryKind.Experiment
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
: "请检查教师/班级冲突或场地与时间约束。"));
taskCompleted = false;
} }
population = nextGeneration;
} }
if (taskCompleted) completedTasks++; var best = population.MinBy(individual => individual.Fitness)!;
var unscheduled = demands.Select((demand, index) => new { demand, entry = best.Genes[index] })
processedTasks++; .Where(item => item.entry is null)
if (reportProgress is not null) .GroupBy(item => new { item.demand.Task, item.demand.Kind });
foreach (var group in unscheduled)
{ {
await reportProgress( invalidTaskIds.Add(group.Key.Task.Id);
new(tasks.Count, processedTasks, created, completedTasks), var hours = group.Sum(item => item.demand.PeriodCount * item.demand.OccurrenceCount);
cancellationToken); var label = group.Key.Kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
messages.Add(
$"{group.Key.Task.TaskNumber} · {group.Key.Task.Name} 仍有 {hours} 个{label}学时无法安排," +
(group.Key.Kind == ScheduleEntryKind.Experiment
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
: "请检查教师/班级冲突或场地与时间约束。"));
}
return new(
best.Genes.Where(entry => entry is not null).Select(entry => entry!).ToList(),
CountCompletedTasks(best.Genes, invalidTaskIds, demands),
messages);
}
private List<ScheduleDemand> BuildDemands(
ICollection<string> messages,
ISet<Guid> invalidTaskIds)
{
var result = new List<ScheduleDemand>();
foreach (var task in tasks)
{
constraints.TryGetValue(task.Id, out var constraint);
foreach (var kind in new[] { ScheduleEntryKind.Lecture, ScheduleEntryKind.Experiment })
{
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
var scheduledHours = baselineEntries
.Where(entry => entry.TeachingTaskId == task.Id && entry.Kind == kind)
.Sum(TeachingTaskHours.ScheduledHours);
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
if (scheduledHours > targetHours)
{
invalidTaskIds.Add(task.Id);
messages.Add(
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
continue;
}
var remainingHours = targetHours - scheduledHours;
var weekCount = task.EndWeek - task.StartWeek + 1;
while (remainingHours > 0)
{
var periodCount = remainingHours >= 2 ? 2 : 1;
var occurrenceCount = Math.Min(weekCount, remainingHours / periodCount);
if (occurrenceCount == 0)
{
periodCount = 1;
occurrenceCount = 1;
}
result.Add(new(task, constraint, kind, periodCount, occurrenceCount));
remainingHours -= periodCount * occurrenceCount;
}
}
}
return result;
}
private List<ScheduleEntry> BuildCandidates(ScheduleDemand demand)
{
var allowedDays = ParseAllowedDays(demand.Constraint?.AllowedDayOfWeeks);
var firstPeriod = demand.Constraint?.EarliestPeriod ?? activePeriods.Min();
var lastPeriod = demand.Constraint?.LatestPeriod ?? activePeriods.Max();
var rooms = EligibleRooms(demand.Task, demand.Constraint, demand.Kind, classrooms);
if ((demand.Constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
return [];
var candidates = new List<ScheduleEntry>();
for (var startWeek = demand.Task.StartWeek;
startWeek + demand.OccurrenceCount - 1 <= demand.Task.EndWeek;
startWeek++)
foreach (var day in allowedDays)
for (var start = firstPeriod; start + demand.PeriodCount - 1 <= lastPeriod; start++)
{
if (Enumerable.Range(start, demand.PeriodCount).Any(period => !activePeriods.Contains(period)))
continue;
var roomOptions = demand.Kind != ScheduleEntryKind.Experiment &&
demand.Constraint?.RequiresClassroom == false
? new Classroom?[] { null }
: rooms.Cast<Classroom?>().ToArray();
foreach (var room in roomOptions)
{
var candidate = CreateEntry(demand, startWeek, day, start, room);
if (CanAdd(candidate, baselineEntries))
candidates.Add(candidate);
}
}
return candidates;
}
private GeneticIndividual CreateIndividual(
IReadOnlyList<ScheduleDemand> demands,
IReadOnlyList<List<ScheduleEntry>> candidates,
CancellationToken cancellationToken)
{
var genes = new ScheduleEntry?[demands.Count];
var state = baselineEntries.ToList();
foreach (var index in Enumerable.Range(0, demands.Count)
.OrderBy(index => candidates[index].Count)
.ThenBy(_ => random.Next()))
{
cancellationToken.ThrowIfCancellationRequested();
var selected = SelectFeasibleCandidate(candidates[index], state);
if (selected is null) continue;
genes[index] = selected;
state.Add(selected);
}
return new(genes) { Fitness = CalculateFitness(genes) };
}
private GeneticIndividual Crossover(
GeneticIndividual first,
GeneticIndividual second,
IReadOnlyList<ScheduleDemand> demands,
IReadOnlyList<List<ScheduleEntry>> candidates)
{
var genes = new ScheduleEntry?[demands.Count];
var state = baselineEntries.ToList();
foreach (var index in Enumerable.Range(0, demands.Count)
.OrderBy(index => candidates[index].Count)
.ThenBy(_ => random.Next()))
{
var firstChoice = random.Next(2) == 0 ? first.Genes[index] : second.Genes[index];
var secondChoice = ReferenceEquals(firstChoice, first.Genes[index])
? second.Genes[index]
: first.Genes[index];
var selected = TryAddClone(firstChoice, state) ??
TryAddClone(secondChoice, state) ??
SelectFeasibleCandidate(candidates[index], state);
if (selected is null) continue;
genes[index] = selected;
state.Add(selected);
}
return new(genes);
}
private void Mutate(GeneticIndividual individual, IReadOnlyList<List<ScheduleEntry>> candidates)
{
var mutationCount = Math.Max(1, individual.Genes.Length / 10);
for (var mutation = 0; mutation < mutationCount; mutation++)
{
var index = random.Next(individual.Genes.Length);
var state = baselineEntries.Concat(individual.Genes
.Where((_, geneIndex) => geneIndex != index)
.Where(entry => entry is not null)
.Select(entry => entry!)).ToList();
var replacement = SelectFeasibleCandidate(candidates[index], state);
if (replacement is not null) individual.Genes[index] = replacement;
} }
} }
if (created > 0 && saveChanges) private GeneticIndividual SelectParent(IReadOnlyList<GeneticIndividual> population)
await db.SaveChangesAsync(cancellationToken); {
return new( var first = population[random.Next(population.Count)];
created, var second = population[random.Next(population.Count)];
completedTasks, return first.Fitness <= second.Fitness ? first : second;
messages, }
tasks.Count,
processedTasks); private ScheduleEntry? SelectFeasibleCandidate(
IReadOnlyList<ScheduleEntry> candidates,
IReadOnlyList<ScheduleEntry> state)
{
var feasible = candidates.Where(candidate => CanAdd(candidate, state))
.OrderBy(candidate => CandidateScore(candidate, state))
.Take(8)
.ToList();
return feasible.Count == 0
? null
: CloneEntry(feasible[random.Next(feasible.Count)]);
}
private int CandidateScore(ScheduleEntry candidate, IReadOnlyList<ScheduleEntry> state) =>
CalculateSoftConstraintPenalty(
candidate.TeachingTask!,
candidate,
state,
settings,
activePeriods.Min(),
activePeriods.Max());
private int CalculateFitness(IEnumerable<ScheduleEntry?> genes)
{
var scheduled = genes.Where(entry => entry is not null).Select(entry => entry!).ToList();
var missingPenalty = (genes.Count(entry => entry is null)) * 1_000_000;
var score = scheduled.Sum(entry => CandidateScore(entry, baselineEntries.Concat(scheduled)
.Where(other => !ReferenceEquals(other, entry)).ToList()));
return missingPenalty + score;
}
private int CountCompletedTasks(
IReadOnlyList<ScheduleEntry?> genes,
ISet<Guid> invalidTaskIds,
IReadOnlyList<ScheduleDemand> demands)
{
var missingTaskIds = demands.Where((_, index) => genes[index] is null)
.Select(demand => demand.Task.Id)
.ToHashSet();
return tasks.Count(task => !invalidTaskIds.Contains(task.Id) &&
!missingTaskIds.Contains(task.Id));
}
private static bool CanAdd(ScheduleEntry candidate, IEnumerable<ScheduleEntry> state) =>
!state.Any(existing =>
ScheduleConflictDetector.TimeOverlaps(existing, candidate) &&
ScheduleConflictDetector.ConflictReason(existing, candidate) is not null);
private static ScheduleEntry? TryAddClone(ScheduleEntry? candidate, IReadOnlyList<ScheduleEntry> state) =>
candidate is not null && CanAdd(candidate, state) ? CloneEntry(candidate) : null;
private ScheduleEntry CreateEntry(
ScheduleDemand demand,
int startWeek,
int day,
int startPeriod,
Classroom? room) => new()
{
SchedulePlanId = planId,
TeachingTaskId = demand.Task.Id,
TeachingTask = demand.Task,
Kind = demand.Kind,
ClassroomId = room?.Id,
DayOfWeek = day,
StartPeriod = startPeriod,
PeriodCount = demand.PeriodCount,
StartWeek = startWeek,
EndWeek = startWeek + demand.OccurrenceCount - 1,
WeekPattern = WeekPattern.All,
Notes = demand.Kind == ScheduleEntryKind.Experiment
? "自动排课 · 遗传算法 · 实验课"
: "自动排课 · 遗传算法 · 理论课"
};
private static ScheduleEntry CloneEntry(ScheduleEntry source) => new()
{
SchedulePlanId = source.SchedulePlanId,
TeachingTaskId = source.TeachingTaskId,
TeachingTask = source.TeachingTask,
Kind = source.Kind,
ClassroomId = source.ClassroomId,
DayOfWeek = source.DayOfWeek,
StartPeriod = source.StartPeriod,
PeriodCount = source.PeriodCount,
StartWeek = source.StartWeek,
EndWeek = source.EndWeek,
WeekPattern = source.WeekPattern,
Notes = source.Notes
};
private sealed record ScheduleDemand(
TeachingTask Task,
TeachingTaskScheduleConstraint? Constraint,
ScheduleEntryKind Kind,
int PeriodCount,
int OccurrenceCount);
private sealed class GeneticIndividual(ScheduleEntry?[] genes)
{
public ScheduleEntry?[] Genes { get; } = genes;
public int Fitness { get; set; }
public GeneticIndividual Clone() => new(Genes.Select(entry => entry is null ? null : CloneEntry(entry)).ToArray())
{
Fitness = Fitness
};
}
} }
private sealed record GeneticScheduleOptimizationResult(
IReadOnlyList<ScheduleEntry> Entries,
int CompletedTasks,
IReadOnlyList<string> Messages);
private static ScheduleEntry? FindBestCandidateForHours( private static ScheduleEntry? FindBestCandidateForHours(
Guid planId, Guid planId,
TeachingTask task, TeachingTask task,
@@ -346,6 +346,7 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.Equal(1, entry.StartPeriod); Assert.Equal(1, entry.StartPeriod);
Assert.Equal(2, entry.PeriodCount); Assert.Equal(2, entry.PeriodCount);
Assert.Null(entry.ClassroomId); Assert.Null(entry.ClassroomId);
Assert.Contains("遗传算法", entry.Notes);
} }
[Fact] [Fact]
+2 -2
View File
@@ -1252,8 +1252,8 @@ onBeforeUnmount(() => {
<el-tab-pane label="自动排课优化" name="optimization"> <el-tab-pane label="自动排课优化" name="optimization">
<div class="settings-lead"> <div class="settings-lead">
<div> <div>
<b>自动排课优化策略</b> <b>遗传算法优化策略</b>
<span>硬约束始终优先;数值越高,自动排课越会规避对应情况。设为 0 可关闭该偏好。</span> <span>自动排课以遗传算法搜索全局方案;硬约束始终优先,数值越高越会规避对应情况。设为 0 可关闭该偏好。</span>
</div> </div>
<el-button type="primary" :loading="optimizationSaving" @click="saveOptimizationSettings"> <el-button type="primary" :loading="optimizationSaving" @click="saveOptimizationSettings">
保存优化策略 保存优化策略