Add timetable planning and related tests to school simulation
- Introduced a new project for timetable planning, dependent on HSchool.Content. - Updated documentation to reflect the addition of timetable planning and its dependencies. - Added tests for timetable planning to ensure deterministic behavior with the same staff and map. - Revised architecture documentation to include the new HSchool.Schedule component and its interactions.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Schedule;
|
||||
|
||||
/// <summary>
|
||||
/// Lays the curriculum onto the week. Deterministic: same catalog, map, staff, locks and week
|
||||
/// length always yield the same table. No clock, no world, no host.
|
||||
/// </summary>
|
||||
public static class TimetablePlanner
|
||||
{
|
||||
public static Timetable Build(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
IReadOnlyList<PlannerClass> classes,
|
||||
IReadOnlyList<PlannerTeacher> teachers,
|
||||
IReadOnlyList<LessonPlacement>? locked = null,
|
||||
int weekDays = 5)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(map);
|
||||
ArgumentNullException.ThrowIfNull(classes);
|
||||
ArgumentNullException.ThrowIfNull(teachers);
|
||||
if (weekDays is < 5 or > 7)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 5–7 days.");
|
||||
}
|
||||
|
||||
var frame = catalog.DayFrame;
|
||||
var lessonCount = frame?.LessonCount ?? 0;
|
||||
var rooms = map.Rooms.ToDictionary(room => room.Id, StringComparer.Ordinal);
|
||||
var classById = classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
||||
var occupancy = new Occupancy();
|
||||
var placed = new List<LessonPlacement>();
|
||||
|
||||
foreach (var lesson in Ordered(locked ?? []))
|
||||
{
|
||||
if (TryKeepLock(catalog, rooms, classById, teachers, occupancy, weekDays, lessonCount, lesson))
|
||||
{
|
||||
occupancy.Add(lesson);
|
||||
placed.Add(lesson with { Locked = true });
|
||||
}
|
||||
}
|
||||
|
||||
var remaining = RemainingHours(catalog, classes, placed);
|
||||
foreach (var demand in remaining)
|
||||
{
|
||||
var leftover = PlaceDemand(
|
||||
catalog,
|
||||
rooms,
|
||||
classById[demand.ClassId],
|
||||
teachers,
|
||||
occupancy,
|
||||
weekDays,
|
||||
lessonCount,
|
||||
demand.Subject,
|
||||
demand.Hours,
|
||||
placed);
|
||||
|
||||
if (leftover > 0)
|
||||
{
|
||||
demand.Hours = leftover;
|
||||
}
|
||||
else
|
||||
{
|
||||
demand.Hours = 0;
|
||||
}
|
||||
}
|
||||
|
||||
var uncovered = remaining
|
||||
.Where(row => row.Hours > 0)
|
||||
.Select(row => new UncoveredDemand(row.ClassId, row.Subject, row.Hours))
|
||||
.OrderBy(row => row.ClassId, StringComparer.Ordinal)
|
||||
.ThenBy(row => row.Subject, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var lessons = placed
|
||||
.OrderBy(lesson => lesson.Day)
|
||||
.ThenBy(lesson => lesson.Period)
|
||||
.ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
|
||||
.ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new Timetable(lessons, uncovered);
|
||||
}
|
||||
|
||||
private sealed class Remaining
|
||||
{
|
||||
public required string ClassId { get; init; }
|
||||
|
||||
public required string Subject { get; init; }
|
||||
|
||||
public int Hours { get; set; }
|
||||
}
|
||||
|
||||
private static List<Remaining> RemainingHours(
|
||||
DefCatalog catalog,
|
||||
IReadOnlyList<PlannerClass> classes,
|
||||
IReadOnlyList<LessonPlacement> placed)
|
||||
{
|
||||
var remaining = new List<Remaining>();
|
||||
foreach (var schoolClass in classes.OrderBy(item => item.Year).ThenBy(item => item.Letter, StringComparer.Ordinal).ThenBy(item => item.Id, StringComparer.Ordinal))
|
||||
{
|
||||
foreach (var subject in catalog.Subjects.Values
|
||||
.Where(def => !def.Abstract && def.HoursPerWeek > 0 && schoolClass.Year >= def.Grades.Min && schoolClass.Year <= def.Grades.Max)
|
||||
.OrderBy(def => def.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
var already = placed.Count(lesson =>
|
||||
lesson.ClassId == schoolClass.Id && lesson.Subject == subject.DefName);
|
||||
var hours = subject.HoursPerWeek - already;
|
||||
if (hours > 0)
|
||||
{
|
||||
remaining.Add(new Remaining
|
||||
{
|
||||
ClassId = schoolClass.Id,
|
||||
Subject = subject.DefName,
|
||||
Hours = hours,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private static int PlaceDemand(
|
||||
DefCatalog catalog,
|
||||
IReadOnlyDictionary<string, RoomNode> rooms,
|
||||
PlannerClass schoolClass,
|
||||
IReadOnlyList<PlannerTeacher> teachers,
|
||||
Occupancy occupancy,
|
||||
int weekDays,
|
||||
int lessonCount,
|
||||
string subjectName,
|
||||
int hours,
|
||||
List<LessonPlacement> placed)
|
||||
{
|
||||
if (!catalog.Subjects.TryGetValue(subjectName, out var subject) || hours <= 0)
|
||||
{
|
||||
return hours;
|
||||
}
|
||||
|
||||
var candidates = RoomsFor(catalog, rooms, schoolClass, subject);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return hours;
|
||||
}
|
||||
|
||||
var leftover = hours;
|
||||
foreach (var teacher in TeachersFor(teachers, subjectName, placed))
|
||||
{
|
||||
while (leftover > 0 && TryPlaceOne(occupancy, schoolClass.Id, subjectName, teacher.Id, candidates, weekDays, lessonCount, placed))
|
||||
{
|
||||
leftover--;
|
||||
}
|
||||
|
||||
if (leftover == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return leftover;
|
||||
}
|
||||
|
||||
private static bool TryPlaceOne(
|
||||
Occupancy occupancy,
|
||||
string classId,
|
||||
string subject,
|
||||
string teacherId,
|
||||
IReadOnlyList<RoomNode> rooms,
|
||||
int weekDays,
|
||||
int lessonCount,
|
||||
List<LessonPlacement> placed)
|
||||
{
|
||||
for (var day = 0; day < weekDays; day++)
|
||||
{
|
||||
for (var period = 1; period <= lessonCount; period++)
|
||||
{
|
||||
foreach (var room in rooms)
|
||||
{
|
||||
if (!occupancy.IsFree(classId, teacherId, room.Id, day, period))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var lesson = new LessonPlacement(classId, subject, teacherId, room.Id, day, period);
|
||||
occupancy.Add(lesson);
|
||||
placed.Add(lesson);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryKeepLock(
|
||||
DefCatalog catalog,
|
||||
IReadOnlyDictionary<string, RoomNode> rooms,
|
||||
IReadOnlyDictionary<string, PlannerClass> classes,
|
||||
IReadOnlyList<PlannerTeacher> teachers,
|
||||
Occupancy occupancy,
|
||||
int weekDays,
|
||||
int lessonCount,
|
||||
LessonPlacement lesson)
|
||||
{
|
||||
if (lesson.Day < 0 || lesson.Day >= weekDays || lesson.Period < 1 || lesson.Period > lessonCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!classes.TryGetValue(lesson.ClassId, out var schoolClass))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
|
||||
|| subject.Abstract
|
||||
|| schoolClass.Year < subject.Grades.Min
|
||||
|| schoolClass.Year > subject.Grades.Max)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!teachers.Any(teacher => teacher.Id == lesson.TeacherId && HasSubject(teacher, lesson.Subject)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!rooms.TryGetValue(lesson.RoomId, out var room) || !RoomMatches(catalog, room, schoolClass, subject))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return occupancy.IsFree(lesson.ClassId, lesson.TeacherId, lesson.RoomId, lesson.Day, lesson.Period);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PlannerTeacher> TeachersFor(
|
||||
IReadOnlyList<PlannerTeacher> teachers,
|
||||
string subject,
|
||||
IReadOnlyList<LessonPlacement> placed)
|
||||
{
|
||||
return teachers
|
||||
.Where(teacher => HasSubject(teacher, subject))
|
||||
.OrderBy(teacher => placed.Count(lesson => lesson.TeacherId == teacher.Id))
|
||||
.ThenBy(teacher => teacher.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool HasSubject(PlannerTeacher teacher, string subject) =>
|
||||
teacher.Subjects.Contains(subject, StringComparer.Ordinal);
|
||||
|
||||
private static IReadOnlyList<RoomNode> RoomsFor(
|
||||
DefCatalog catalog,
|
||||
IReadOnlyDictionary<string, RoomNode> rooms,
|
||||
PlannerClass schoolClass,
|
||||
SubjectDef subject)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subject.Room))
|
||||
{
|
||||
return rooms.Values
|
||||
.Where(room => RoomMatches(catalog, room, schoolClass, subject))
|
||||
.OrderBy(room => room.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (rooms.TryGetValue(schoolClass.HomeroomId, out var homeroom)
|
||||
&& RoomMatches(catalog, homeroom, schoolClass, subject))
|
||||
{
|
||||
return [homeroom];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static bool RoomMatches(DefCatalog catalog, RoomNode room, PlannerClass schoolClass, SubjectDef subject)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(subject.Room))
|
||||
{
|
||||
if (!room.Def.Equals(subject.Room, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!room.Id.Equals(schoolClass.HomeroomId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ClassFits(catalog, room, schoolClass.PupilCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A room with no seated pupil slots (the gym) still hosts a class. A lab with twelve
|
||||
/// computers does not host sixteen pupils.
|
||||
/// </summary>
|
||||
private static bool ClassFits(DefCatalog catalog, RoomNode room, int pupilCount)
|
||||
{
|
||||
var seats = RoomOccupancy.PupilSlots(catalog, room);
|
||||
return seats == 0 || pupilCount <= seats;
|
||||
}
|
||||
|
||||
private static IEnumerable<LessonPlacement> Ordered(IEnumerable<LessonPlacement> lessons) =>
|
||||
lessons
|
||||
.OrderBy(lesson => lesson.Day)
|
||||
.ThenBy(lesson => lesson.Period)
|
||||
.ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
|
||||
.ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
|
||||
.ThenBy(lesson => lesson.RoomId, StringComparer.Ordinal);
|
||||
|
||||
private sealed class Occupancy
|
||||
{
|
||||
private readonly HashSet<(string Id, int Day, int Period)> _classes = [];
|
||||
private readonly HashSet<(string Id, int Day, int Period)> _teachers = [];
|
||||
private readonly HashSet<(string Id, int Day, int Period)> _rooms = [];
|
||||
|
||||
public bool IsFree(string classId, string teacherId, string roomId, int day, int period) =>
|
||||
!_classes.Contains((classId, day, period))
|
||||
&& !_teachers.Contains((teacherId, day, period))
|
||||
&& !_rooms.Contains((roomId, day, period));
|
||||
|
||||
public void Add(LessonPlacement lesson)
|
||||
{
|
||||
_classes.Add((lesson.ClassId, lesson.Day, lesson.Period));
|
||||
_teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period));
|
||||
_rooms.Add((lesson.RoomId, lesson.Day, lesson.Period));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user