diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs index 70da674..0e6d922 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs @@ -63,9 +63,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) .FirstOrDefaultAsync(x => x.AcademicTermId == plan.AcademicTermId, cancellationToken) ?? ScheduleOptimizationSettings.CreateDefault(plan.AcademicTermId); - var created = 0; - var completedTasks = 0; - var processedTasks = 0; var messages = new List(); if (reportProgress is not null) { @@ -74,87 +71,364 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) 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(); - constraints.TryGetValue(task.Id, out var constraint); - var taskCompleted = true; - foreach (var kind in new[] - { - ScheduleEntryKind.Lecture, - ScheduleEntryKind.Experiment - }) + await reportProgress( + new(tasks.Count, tasks.Count, result.Entries.Count, result.CompletedTasks), + cancellationToken); + } + if (result.Entries.Count > 0 && saveChanges) + await db.SaveChangesAsync(cancellationToken); + return new( + result.Entries.Count, + result.CompletedTasks, + messages, + tasks.Count, + tasks.Count); + } + + /// + /// 遗传搜索以完整排课方案作为个体。候选生成、交叉和变异均经修复, + /// 因而冲突、容量和课程可用范围始终是不可违反的硬约束。 + /// + private sealed class GeneticScheduleOptimizer( + Guid planId, + IReadOnlyList tasks, + IReadOnlyDictionary constraints, + HashSet activePeriods, + IReadOnlyList classrooms, + IReadOnlyList 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(); + var invalidTaskIds = new HashSet(); + 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); - var scheduledHours = entries - .Where(x => - x.TeachingTaskId == task.Id && - x.Kind == kind) - .Sum(TeachingTaskHours.ScheduledHours); - var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课"; - if (scheduledHours > targetHours) + cancellationToken.ThrowIfCancellationRequested(); + population = population.OrderBy(individual => individual.Fitness).ToList(); + var nextGeneration = new List { - messages.Add( - $"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," + - $"超过课程规定的 {targetHours} 学时,请先删除多余课次。"); - taskCompleted = false; - continue; - } - - var remainingHours = targetHours - scheduledHours; - while (remainingHours > 0) + population[0].Clone(), + population[1].Clone() + }; + while (nextGeneration.Count < populationSize) { - var candidate = FindBestCandidateForHours( - plan.Id, - task, - constraint, - kind, - remainingHours, - 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; + var firstParent = SelectParent(population); + var secondParent = SelectParent(population); + var child = Crossover(firstParent, secondParent, demands, candidates); + Mutate(child, candidates); + child.Fitness = CalculateFitness(child.Genes); + nextGeneration.Add(child); } + population = nextGeneration; } - if (taskCompleted) completedTasks++; - - processedTasks++; - if (reportProgress is not null) + var best = population.MinBy(individual => individual.Fitness)!; + var unscheduled = demands.Select((demand, index) => new { demand, entry = best.Genes[index] }) + .Where(item => item.entry is null) + .GroupBy(item => new { item.demand.Task, item.demand.Kind }); + foreach (var group in unscheduled) { - await reportProgress( - new(tasks.Count, processedTasks, created, completedTasks), - cancellationToken); + invalidTaskIds.Add(group.Key.Task.Id); + var hours = group.Sum(item => item.demand.PeriodCount * item.demand.OccurrenceCount); + 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 BuildDemands( + ICollection messages, + ISet invalidTaskIds) + { + var result = new List(); + 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 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(); + 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().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 demands, + IReadOnlyList> 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 demands, + IReadOnlyList> 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> 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) - await db.SaveChangesAsync(cancellationToken); - return new( - created, - completedTasks, - messages, - tasks.Count, - processedTasks); + private GeneticIndividual SelectParent(IReadOnlyList population) + { + var first = population[random.Next(population.Count)]; + var second = population[random.Next(population.Count)]; + return first.Fitness <= second.Fitness ? first : second; + } + + private ScheduleEntry? SelectFeasibleCandidate( + IReadOnlyList candidates, + IReadOnlyList 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 state) => + CalculateSoftConstraintPenalty( + candidate.TeachingTask!, + candidate, + state, + settings, + activePeriods.Min(), + activePeriods.Max()); + + private int CalculateFitness(IEnumerable 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 genes, + ISet invalidTaskIds, + IReadOnlyList 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 state) => + !state.Any(existing => + ScheduleConflictDetector.TimeOverlaps(existing, candidate) && + ScheduleConflictDetector.ConflictReason(existing, candidate) is not null); + + private static ScheduleEntry? TryAddClone(ScheduleEntry? candidate, IReadOnlyList 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 Entries, + int CompletedTasks, + IReadOnlyList Messages); + private static ScheduleEntry? FindBestCandidateForHours( Guid planId, TeachingTask task, diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs index c2e1220..a43503b 100644 --- a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -346,6 +346,7 @@ public sealed class AutomaticScheduleGeneratorTests Assert.Equal(1, entry.StartPeriod); Assert.Equal(2, entry.PeriodCount); Assert.Null(entry.ClassroomId); + Assert.Contains("遗传算法", entry.Notes); } [Fact] diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 247ec91..36f85b5 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -1252,8 +1252,8 @@ onBeforeUnmount(() => {
- 自动排课优化策略 - 硬约束始终优先;数值越高,自动排课越会规避对应情况。设为 0 可关闭该偏好。 + 遗传算法优化策略 + 自动排课以遗传算法搜索全局方案;硬约束始终优先,数值越高越会规避对应情况。设为 0 可关闭该偏好。
保存优化策略