- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments. - Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate. - Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments. - Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects. - Improved localization strings to reflect changes in staffing and payroll terminology.
366 lines
13 KiB
C#
366 lines
13 KiB
C#
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);
|
||
|
||
demand.Hours = leftover;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Picks the slot, and the order days are tried in is the whole difference between a timetable
|
||
/// and a pile of lessons. Scanning day 0 upwards and taking the first free slot produced blocks
|
||
/// of the same subject and a week that ran out by Thursday: Monday held 77 lessons, Friday 16.
|
||
///
|
||
/// So days are ranked instead: first those that do not have this subject yet, then the emptiest
|
||
/// one for this class. Periods stay ascending inside the chosen day, so a day has no holes.
|
||
/// </summary>
|
||
private static bool TryPlaceOne(
|
||
Occupancy occupancy,
|
||
string classId,
|
||
string subject,
|
||
string teacherId,
|
||
IReadOnlyList<RoomNode> rooms,
|
||
int weekDays,
|
||
int lessonCount,
|
||
List<LessonPlacement> placed)
|
||
{
|
||
var days = new int[weekDays];
|
||
for (var day = 0; day < weekDays; day++)
|
||
{
|
||
days[day] = day;
|
||
}
|
||
|
||
Array.Sort(days, (left, right) =>
|
||
{
|
||
var bySubject = occupancy.SubjectLoad(classId, subject, left)
|
||
.CompareTo(occupancy.SubjectLoad(classId, subject, right));
|
||
if (bySubject != 0)
|
||
{
|
||
return bySubject;
|
||
}
|
||
|
||
var byDay = occupancy.DayLoad(classId, left).CompareTo(occupancy.DayLoad(classId, right));
|
||
return byDay != 0 ? byDay : left.CompareTo(right);
|
||
});
|
||
|
||
foreach (var day in days)
|
||
{
|
||
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 = [];
|
||
private readonly Dictionary<(string ClassId, int Day), int> _dayLoad = [];
|
||
private readonly Dictionary<(string ClassId, string Subject, int Day), int> _subjectLoad = [];
|
||
|
||
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));
|
||
|
||
/// <summary>How many lessons this class already has that day — counted, not recomputed.</summary>
|
||
public int DayLoad(string classId, int day) =>
|
||
_dayLoad.TryGetValue((classId, day), out var count) ? count : 0;
|
||
|
||
/// <summary>How many lessons of this subject this class already has that day.</summary>
|
||
public int SubjectLoad(string classId, string subject, int day) =>
|
||
_subjectLoad.TryGetValue((classId, subject, day), out var count) ? count : 0;
|
||
|
||
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));
|
||
|
||
var day = (lesson.ClassId, lesson.Day);
|
||
_dayLoad[day] = (_dayLoad.TryGetValue(day, out var lessons) ? lessons : 0) + 1;
|
||
|
||
var subject = (lesson.ClassId, lesson.Subject, lesson.Day);
|
||
_subjectLoad[subject] = (_subjectLoad.TryGetValue(subject, out var same) ? same : 0) + 1;
|
||
}
|
||
}
|
||
}
|