diff --git a/LehrerApp.Desktop.Tests/StudentGroupGeneratorViewModelTests.cs b/LehrerApp.Desktop.Tests/StudentGroupGeneratorViewModelTests.cs new file mode 100644 index 0000000..f779928 --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentGroupGeneratorViewModelTests.cs @@ -0,0 +1,185 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class StudentGroupGeneratorViewModelTests +{ + private static (StudentGroupGeneratorViewModel Vm, List Students, LearningGroup Group, + FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10) + { + var group = new LearningGroup { Name = "8a", GradingSystem = GradingSystem.Grades1To6 }; + var students = Enumerable.Range(1, studentCount).Select(i => new Student + { + FirstName = $"Kind{i:00}", LastName = "Test", + }).ToList(); + var memberships = students.Select(s => new GroupMembership + { + GroupId = group.Id, StudentId = s.Id, + }).ToList(); + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var vm = new StudentGroupGeneratorViewModel( + new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships), + sessions, entries, new FakeGrades(), group.Id, new Random(7)); + return (vm, students, group, sessions, entries); + } + + [Fact] + public void FesteZielgroesse_ErzeugtPassendeAnzahlMitKleinerAusnahme() + { + var (vm, _, _, _, _) = Build(10); + vm.TargetGroupSize = 4; + vm.TargetGroupCount = 2; + vm.SizePriority = 100; + vm.CountPriority = 20; + + vm.GenerateCommand.Execute(null); + + Assert.Equal(3, vm.GeneratedGroups.Count); + Assert.Equal(new[] { 3, 3, 4 }, vm.GeneratedGroups.Select(g => g.Members.Count).OrderBy(x => x)); + Assert.Contains("nicht gleichzeitig", vm.WarningText); + } + + [Fact] + public void FesteGruppenanzahl_HatVorrangVorZielgroesse() + { + var (vm, _, _, _, _) = Build(10); + vm.TargetGroupSize = 3; + vm.TargetGroupCount = 2; + vm.SizePriority = 20; + vm.CountPriority = 100; + + vm.GenerateCommand.Execute(null); + + Assert.Equal(2, vm.GeneratedGroups.Count); + Assert.All(vm.GeneratedGroups, group => Assert.Equal(5, group.Members.Count)); + } + + [Fact] + public void AdHoc_SchliesstHeuteAbwesendeAus() + { + var (vm, students, group, sessions, entries) = Build(6); + var session = new ParticipationSession { GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today) }; + sessions.Save(session); + entries.Add(new ParticipationEntry + { + SessionId = session.Id, StudentId = students[0].Id, Attendance = AttendanceStatus.Excused, + }); + vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel; + vm.TargetGroupCount = 2; + vm.CountPriority = 100; + + vm.GenerateCommand.Execute(null); + + var names = vm.GeneratedGroups.SelectMany(g => g.Members).Select(m => m.Name).ToList(); + Assert.Equal(5, names.Count); + Assert.DoesNotContain(students[0].FullName, names); + Assert.Contains("fehlt heute", vm.WarningText); + } + + [Fact] + public void AdHoc_OhneHeutigeErfassung_WarntUndNutztAlleAktiven() + { + var (vm, students, _, _, _) = Build(6); + vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel; + + vm.GenerateCommand.Execute(null); + + Assert.Equal(students.Count, vm.GeneratedGroups.SelectMany(g => g.Members).Count()); + Assert.Contains("keine Anwesenheit", vm.WarningText); + } + + [Fact] + public void Leistungsverteilung_UnterscheidetHomogeneUndHeterogeneGruppen() + { + var group = new LearningGroup { Name = "Kurs", GradingSystem = GradingSystem.Grades1To6 }; + var students = new[] { "A", "B", "C", "D" }.Select(name => new Student + { + FirstName = name, LastName = "Test", + }).ToList(); + var memberships = students.Select(s => new GroupMembership { GroupId = group.Id, StudentId = s.Id }).ToList(); + var grades = new FakeGrades(); + var values = new[] { "1", "2", "5", "6" }; + for (var i = 0; i < students.Count; i++) + grades.Add(new Grade { GroupId = group.Id, StudentId = students[i].Id, Value = values[i] }); + var vm = new StudentGroupGeneratorViewModel( + new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships), + new FakeSessions([]), new FakeEntries(), grades, group.Id, new Random(3)) + { + TargetGroupCount = 2, + CountPriority = 100, + PerformanceDistribution = -100, + }; + + vm.GenerateCommand.Execute(null); + var homogeneousFirst = vm.GeneratedGroups[0].Members.Select(m => m.Name).ToHashSet(); + Assert.Contains(students[0].FullName, homogeneousFirst); + Assert.Contains(students[1].FullName, homogeneousFirst); + + vm.PerformanceDistribution = 100; + vm.GenerateCommand.Execute(null); + Assert.All(vm.GeneratedGroups, generated => + { + var names = generated.Members.Select(m => m.Name).ToHashSet(); + Assert.True(names.Overlaps([students[0].FullName, students[1].FullName])); + Assert.True(names.Overlaps([students[2].FullName, students[3].FullName])); + }); + } + + [Fact] + public void Paarregeln_ErzwingenZusammenUndTrennenMitHoherPrioritaet() + { + var (vm, students, _, _, _) = Build(6); + vm.TargetGroupCount = 2; + vm.CountPriority = 100; + vm.PerformanceDistribution = 0; + vm.ForcedPairPriority = 100; + vm.SelectedPairStudentA = vm.PairCandidates.Single(s => s.Id == students[0].Id); + vm.SelectedPairStudentB = vm.PairCandidates.Single(s => s.Id == students[1].Id); + vm.AddForcedPairCommand.Execute(null); + vm.SelectedPairStudentA = vm.PairCandidates.Single(s => s.Id == students[2].Id); + vm.SelectedPairStudentB = vm.PairCandidates.Single(s => s.Id == students[3].Id); + vm.AddExcludedPairCommand.Execute(null); + + vm.GenerateCommand.Execute(null); + + var groupByName = vm.GeneratedGroups.SelectMany((group, index) => + group.Members.Select(member => (member.Name, index))) + .ToDictionary(x => x.Name, x => x.index); + Assert.Equal(groupByName[students[0].FullName], groupByName[students[1].FullName]); + Assert.NotEqual(groupByName[students[2].FullName], groupByName[students[3].FullName]); + } + + [Fact] + public void FoerderschuelerWerdenMoeglichstGleichmaessigVerteilt() + { + var group = new LearningGroup { Name = "Kurs" }; + var students = Enumerable.Range(1, 8).Select(i => new Student + { + FirstName = $"Kind{i}", LastName = "Test", + }).ToList(); + var memberships = students.Select((student, index) => new GroupMembership + { + GroupId = group.Id, + StudentId = student.Id, + Niveau = index < 4 ? Niveau.Foerder : null, + }).ToList(); + var vm = new StudentGroupGeneratorViewModel( + new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships), + new FakeSessions([]), new FakeEntries(), new FakeGrades(), group.Id, new Random(4)) + { + TargetGroupCount = 2, + CountPriority = 100, + PerformanceDistribution = -100, + DistributeSupportStudents = true, + }; + + vm.GenerateCommand.Execute(null); + + var supportNames = students.Take(4).Select(s => s.FullName).ToHashSet(); + Assert.All(vm.GeneratedGroups, generated => + Assert.Equal(2, generated.Members.Count(member => supportNames.Contains(member.Name)))); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/StudentGroupGeneratorViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/StudentGroupGeneratorViewModel.cs new file mode 100644 index 0000000..2a2983b --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/StudentGroupGeneratorViewModel.cs @@ -0,0 +1,486 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public sealed record StudentGroupMember(string Name); +public sealed record StudentPairCandidate(Guid Id, string Name); + +public sealed class StudentPairRule +{ + public Guid FirstId { get; } + public Guid SecondId { get; } + public string Display { get; } + public IRelayCommand RemoveCommand { get; } + + public StudentPairRule(StudentPairCandidate first, StudentPairCandidate second, Action remove) + { + FirstId = first.Id; + SecondId = second.Id; + Display = $"{first.Name} + {second.Name}"; + RemoveCommand = new RelayCommand(() => remove(this)); + } + + public bool Matches(Guid first, Guid second) => + FirstId == first && SecondId == second || FirstId == second && SecondId == first; +} + +public sealed class GeneratedStudentGroup +{ + public string Title { get; } + public ObservableCollection Members { get; } = []; + + public GeneratedStudentGroup(int number, IEnumerable students) + { + Title = $"Gruppe {number}"; + foreach (var student in students.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase)) + Members.Add(new StudentGroupMember(student.FullName)); + } +} + +/// Erzeugt Gruppen fuer eine spaetere Planung oder aus der heutigen Anwesenheit. +public partial class StudentGroupGeneratorViewModel : ObservableObject +{ + public const string FutureModeLabel = "Für später · alle Aktiven"; + public const string AdHocModeLabel = "Ad hoc · heute Anwesende"; + + private readonly IStudentRepository _students; + private readonly IGroupMembershipRepository _memberships; + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _participation; + private readonly IGradeRepository _grades; + private readonly IGroupRepository _groupRepository; + private readonly Random _random; + private readonly List _participants = []; + private readonly Dictionary _performance = []; + private readonly HashSet _supportStudentIds = []; + private string _attendanceWarning = ""; + + [ObservableProperty] private StudentDrawGroup? _selectedGroup; + [ObservableProperty] private string _participantModeName = FutureModeLabel; + [ObservableProperty] private int _targetGroupSize = 4; + [ObservableProperty] private int _targetGroupCount = 6; + [ObservableProperty] private double _sizePriority = 75; + [ObservableProperty] private double _countPriority = 50; + [ObservableProperty] private double _performanceDistribution = 65; + [ObservableProperty] private bool _distributeSupportStudents = true; + [ObservableProperty] private StudentPairCandidate? _selectedPairStudentA; + [ObservableProperty] private StudentPairCandidate? _selectedPairStudentB; + [ObservableProperty] private double _forcedPairPriority = 85; + [ObservableProperty] private double _excludedPairPriority = 85; + [ObservableProperty] private string _pairingMessage = ""; + [ObservableProperty] private string _statusText = "Optionen wählen und Gruppen zusammenstellen."; + [ObservableProperty] private string _warningText = ""; + [ObservableProperty] private bool _hasGeneratedGroups; + + public ObservableCollection Groups { get; } = []; + public ObservableCollection GeneratedGroups { get; } = []; + public ObservableCollection PairCandidates { get; } = []; + public ObservableCollection ForcedPairs { get; } = []; + public ObservableCollection ExcludedPairs { get; } = []; + public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel]; + public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2; + public string SizePriorityLabel => PriorityLabel(SizePriority); + public string CountPriorityLabel => PriorityLabel(CountPriority); + public string PerformanceDistributionLabel => PerformanceDistribution switch + { + <= -70 => "stark homogen", + < -20 => "eher homogen", + <= 20 => "zufällig gemischt", + < 70 => "eher heterogen", + _ => "stark heterogen", + }; + public string ForcedPairPriorityLabel => PriorityLabel(ForcedPairPriority); + public string ExcludedPairPriorityLabel => PriorityLabel(ExcludedPairPriority); + + public StudentGroupGeneratorViewModel( + IStudentRepository students, + IGroupRepository groups, + IGroupMembershipRepository memberships, + IParticipationSessionRepository sessions, + IParticipationRepository participation, + IGradeRepository grades, + Guid? initialGroupId = null, + Random? random = null) + { + _students = students; + _groupRepository = groups; + _memberships = memberships; + _sessions = sessions; + _participation = participation; + _grades = grades; + _random = random ?? Random.Shared; + + foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)) + Groups.Add(new StudentDrawGroup(group.Id, group.Name)); + SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault(); + } + + partial void OnSelectedGroupChanged(StudentDrawGroup? value) + { + ForcedPairs.Clear(); + ExcludedPairs.Clear(); + ReloadParticipants(); + } + partial void OnParticipantModeNameChanged(string value) => ReloadParticipants(); + partial void OnTargetGroupSizeChanged(int value) => InvalidateResult(); + partial void OnTargetGroupCountChanged(int value) => InvalidateResult(); + partial void OnSizePriorityChanged(double value) + { + OnPropertyChanged(nameof(SizePriorityLabel)); + InvalidateResult(); + } + partial void OnCountPriorityChanged(double value) + { + OnPropertyChanged(nameof(CountPriorityLabel)); + InvalidateResult(); + } + partial void OnPerformanceDistributionChanged(double value) + { + OnPropertyChanged(nameof(PerformanceDistributionLabel)); + InvalidateResult(); + } + partial void OnDistributeSupportStudentsChanged(bool value) => InvalidateResult(); + partial void OnForcedPairPriorityChanged(double value) + { + OnPropertyChanged(nameof(ForcedPairPriorityLabel)); + InvalidateResult(); + } + partial void OnExcludedPairPriorityChanged(double value) + { + OnPropertyChanged(nameof(ExcludedPairPriorityLabel)); + InvalidateResult(); + } + + [RelayCommand] + private void AddForcedPair() => AddPairRule(ForcedPairs, ExcludedPairs); + + [RelayCommand] + private void AddExcludedPair() => AddPairRule(ExcludedPairs, ForcedPairs); + + [RelayCommand(CanExecute = nameof(CanGenerate))] + private void Generate() + { + var groupCount = ResolveGroupCount(_participants.Count); + var capacities = Enumerable.Range(0, groupCount) + .Select(i => _participants.Count / groupCount + (i < _participants.Count % groupCount ? 1 : 0)) + .ToArray(); + var assignments = BuildAssignments(capacities); + + GeneratedGroups.Clear(); + for (var i = 0; i < assignments.Count; i++) + GeneratedGroups.Add(new GeneratedStudentGroup(i + 1, assignments[i])); + + HasGeneratedGroups = true; + StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " + + string.Join("/", capacities.Distinct().OrderBy(x => x)); + UpdateWarning(groupCount, assignments); + } + + private void ReloadParticipants() + { + _participants.Clear(); + _performance.Clear(); + _supportStudentIds.Clear(); + PairCandidates.Clear(); + _attendanceWarning = ""; + + if (SelectedGroup is not null) + { + var today = DateOnly.FromDateTime(DateTime.Today); + var activeMemberships = _memberships.GetByGroup(SelectedGroup.Id) + .Where(m => GroupMembershipService.IsActiveOn(m, today)) + .ToList(); + var activeIds = activeMemberships.Select(m => m.StudentId).ToHashSet(); + foreach (var membership in activeMemberships.Where(m => m.Niveau == Niveau.Foerder)) + _supportStudentIds.Add(membership.StudentId); + var active = _students.GetByGroup(SelectedGroup.Id) + .Where(s => s.IsActive && activeIds.Contains(s.Id)).ToList(); + _participants.AddRange(ParticipantModeName == AdHocModeLabel + ? FilterByTodayAttendance(SelectedGroup.Id, active, today) + : active); + LoadPerformance(SelectedGroup.Id); + foreach (var student in _participants.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + PairCandidates.Add(new StudentPairCandidate(student.Id, student.FullName)); + } + + SelectedPairStudentA = null; + SelectedPairStudentB = null; + + InvalidateResult(); + OnPropertyChanged(nameof(CanGenerate)); + GenerateCommand.NotifyCanExecuteChanged(); + } + + private IEnumerable FilterByTodayAttendance(Guid groupId, List active, DateOnly today) + { + var todaySessions = _sessions.GetByGroup(groupId).Where(s => s.Date == today) + .OrderBy(s => s.CreatedAt).ToList(); + if (todaySessions.Count == 0) + { + _attendanceWarning = "Heute wurde noch keine Anwesenheit erfasst; deshalb werden alle aktiven Schüler einbezogen."; + return active; + } + + var latestStatus = new Dictionary(); + foreach (var session in todaySessions) + foreach (var entry in _participation.GetBySession(session.Id)) + if (entry.Attendance is not null) latestStatus[entry.StudentId] = entry.Attendance; + + var unknown = active.Count(s => !latestStatus.ContainsKey(s.Id)); + if (unknown > 0) + _attendanceWarning = $"Für {unknown} Schüler fehlt heute ein Anwesenheitsstatus; sie werden vorsichtshalber einbezogen."; + return active.Where(s => !latestStatus.TryGetValue(s.Id, out var status) || status == AttendanceStatus.Present); + } + + private void LoadPerformance(Guid groupId) + { + var system = _groupRepository.GetById(groupId)?.GradingSystem ?? GradingSystem.Grades1To6; + var gradesByStudent = _grades.GetByGroup(groupId).GroupBy(g => g.StudentId) + .ToDictionary(g => g.Key, g => g.Select(x => ParseGradeScore(x.Value, system)).Where(x => x.HasValue) + .Select(x => x!.Value).ToList()); + var entries = _sessions.GetByGroup(groupId).SelectMany(s => _participation.GetBySession(s.Id)).ToList(); + + foreach (var student in _participants) + { + var signals = gradesByStudent.GetValueOrDefault(student.Id, []).ToList(); + signals.AddRange(entries.Where(e => e.StudentId == student.Id).SelectMany(e => e.Ratings) + .Select(r => (Math.Clamp(r.Value, -2, 2) + 2) / 4.0)); + _performance[student.Id] = signals.Count == 0 ? null : signals.Average(); + } + } + + private List> BuildAssignments(int[] capacities) + { + var result = capacities.Select(_ => new List()).ToList(); + var strength = Math.Abs(PerformanceDistribution) / 100.0; + var mean = _performance.Values.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0.5).Average(); + var ranked = _participants.Select(s => new + { + Student = s, + Score = (_performance.GetValueOrDefault(s.Id) ?? mean) * strength + _random.NextDouble() * (1 - strength), + }).OrderByDescending(x => x.Score).ToList(); + + if (PerformanceDistribution < -20) + { + var cursor = 0; + for (var i = 0; i < result.Count; i++) + for (var j = 0; j < capacities[i]; j++) result[i].Add(ranked[cursor++].Student); + } + else if (PerformanceDistribution > 20) + { + var sums = new double[result.Count]; + foreach (var item in ranked) + { + var target = Enumerable.Range(0, result.Count).Where(i => result[i].Count < capacities[i]) + .OrderBy(i => result[i].Count == 0 ? -1 : sums[i] / result[i].Count) + .ThenBy(_ => _random.NextDouble()).First(); + result[target].Add(item.Student); + sums[target] += item.Score; + } + } + else + { + var shuffled = ranked.OrderBy(_ => _random.Next()).Select(x => x.Student).ToList(); + var cursor = 0; + for (var i = 0; i < result.Count; i++) + for (var j = 0; j < capacities[i]; j++) result[i].Add(shuffled[cursor++]); + } + OptimizeAssignments(result); + return result; + } + + private void OptimizeAssignments(List> groups) + { + var best = AssignmentPenalty(groups); + var improved = true; + var passes = 0; + while (improved && passes++ < 100) + { + improved = false; + (int AGroup, int AIndex, int BGroup, int BIndex, double Score)? bestSwap = null; + for (var firstGroup = 0; firstGroup < groups.Count; firstGroup++) + for (var secondGroup = firstGroup + 1; secondGroup < groups.Count; secondGroup++) + for (var firstIndex = 0; firstIndex < groups[firstGroup].Count; firstIndex++) + for (var secondIndex = 0; secondIndex < groups[secondGroup].Count; secondIndex++) + { + (groups[firstGroup][firstIndex], groups[secondGroup][secondIndex]) = + (groups[secondGroup][secondIndex], groups[firstGroup][firstIndex]); + var score = AssignmentPenalty(groups); + (groups[firstGroup][firstIndex], groups[secondGroup][secondIndex]) = + (groups[secondGroup][secondIndex], groups[firstGroup][firstIndex]); + if (score + 0.000001 < best && (bestSwap is null || score < bestSwap.Value.Score)) + bestSwap = (firstGroup, firstIndex, secondGroup, secondIndex, score); + } + if (bestSwap is { } swap) + { + (groups[swap.AGroup][swap.AIndex], groups[swap.BGroup][swap.BIndex]) = + (groups[swap.BGroup][swap.BIndex], groups[swap.AGroup][swap.AIndex]); + best = swap.Score; + improved = true; + } + } + } + + private double AssignmentPenalty(List> groups) + { + var mean = _performance.Values.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0.5).Average(); + var distributionWeight = Math.Abs(PerformanceDistribution) / 100.0; + double performancePenalty; + if (PerformanceDistribution < -20) + { + performancePenalty = groups.Sum(group => + { + var values = group.Select(s => _performance.GetValueOrDefault(s.Id) ?? mean).ToList(); + var groupMean = values.Average(); + return values.Sum(value => Math.Pow(value - groupMean, 2)); + }); + } + else if (PerformanceDistribution > 20) + { + var averages = groups.Select(group => group.Average(s => _performance.GetValueOrDefault(s.Id) ?? mean)).ToList(); + performancePenalty = averages.Sum(value => Math.Pow(value - averages.Average(), 2)); + } + else performancePenalty = 0; + + var supportPenalty = 0.0; + if (DistributeSupportStudents && _supportStudentIds.Count > 0) + { + var counts = groups.Select(g => g.Count(s => _supportStudentIds.Contains(s.Id))).ToList(); + supportPenalty = counts.Sum(count => Math.Pow(count - counts.Average(), 2)) * 2.0; + } + + var groupByStudent = groups.SelectMany((group, index) => group.Select(s => (s.Id, index))) + .ToDictionary(x => x.Id, x => x.index); + var forcedPenalty = ForcedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: true)) * + PairWeight(ForcedPairPriority); + var excludedPenalty = ExcludedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: false)) * + PairWeight(ExcludedPairPriority); + return performancePenalty * distributionWeight + supportPenalty + forcedPenalty + excludedPenalty; + } + + private int ResolveGroupCount(int participantCount) + { + var maxGroups = Math.Max(1, participantCount / 2); + var requestedCount = Math.Clamp(TargetGroupCount, 1, maxGroups); + var sizeBasedCount = Math.Clamp((int)Math.Ceiling(participantCount / (double)Math.Max(2, TargetGroupSize)), 1, maxGroups); + if (CountPriority >= 99 && SizePriority < 99) return requestedCount; + if (SizePriority >= 99 && CountPriority < 99) return sizeBasedCount; + + return Enumerable.Range(1, maxGroups).MinBy(count => + { + var sizeDeviation = Math.Abs(participantCount / (double)count - TargetGroupSize) / Math.Max(2, TargetGroupSize); + var countDeviation = Math.Abs(count - requestedCount) / (double)Math.Max(1, requestedCount); + return sizeDeviation * (0.05 + SizePriority / 100.0) + + countDeviation * (0.05 + CountPriority / 100.0); + }); + } + + private void InvalidateResult() + { + GeneratedGroups.Clear(); + HasGeneratedGroups = false; + StatusText = _participants.Count >= 2 + ? $"{_participants.Count} Schüler stehen zur Einteilung bereit." + : "Für eine Gruppeneinteilung werden mindestens zwei Schüler benötigt."; + UpdateWarning(null); + } + + private void UpdateWarning(int? resolvedCount, List>? assignments = null) + { + var warnings = new List(); + if (_attendanceWarning.Length > 0) warnings.Add(_attendanceWarning); + if (_participants.Count >= 2) + { + if (_participants.Count != Math.Max(2, TargetGroupSize) * TargetGroupCount) + { + var resolution = resolvedCount is null ? "" : $" Verwendet werden {resolvedCount} Gruppen."; + warnings.Add($"Zielgröße {TargetGroupSize} und {TargetGroupCount} Gruppen sind bei {_participants.Count} Schülern nicht gleichzeitig exakt erfüllbar.{resolution}"); + } + } + if (assignments is not null) + { + var groupByStudent = assignments.SelectMany((group, index) => group.Select(s => (s.Id, index))) + .ToDictionary(x => x.Id, x => x.index); + var unavailable = ForcedPairs.Concat(ExcludedPairs).Count(rule => + !groupByStudent.ContainsKey(rule.FirstId) || !groupByStudent.ContainsKey(rule.SecondId)); + var forcedOpen = ForcedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: true)); + var excludedOpen = ExcludedPairs.Count(rule => PairViolated(rule, groupByStudent, mustBeTogether: false)); + if (unavailable > 0) warnings.Add($"{unavailable} Paarregel(n) sind wegen fehlender Teilnehmender heute nicht anwendbar."); + if (forcedOpen > 0) warnings.Add($"{forcedOpen} gewünschte Paarung(en) konnten mit den aktuellen Prioritäten nicht erfüllt werden."); + if (excludedOpen > 0) warnings.Add($"{excludedOpen} ausgeschlossene Paarung(en) konnten mit den aktuellen Prioritäten nicht getrennt werden."); + } + WarningText = string.Join(" ", warnings); + } + + private void AddPairRule(ObservableCollection target, + ObservableCollection opposite) + { + PairingMessage = ""; + if (SelectedPairStudentA is null || SelectedPairStudentB is null) + { + PairingMessage = "Bitte zwei Schüler auswählen."; + return; + } + if (SelectedPairStudentA.Id == SelectedPairStudentB.Id) + { + PairingMessage = "Bitte zwei verschiedene Schüler auswählen."; + return; + } + if (target.Any(rule => rule.Matches(SelectedPairStudentA.Id, SelectedPairStudentB.Id))) + { + PairingMessage = "Diese Paarregel ist bereits vorhanden."; + return; + } + var conflicting = opposite.FirstOrDefault(rule => rule.Matches(SelectedPairStudentA.Id, SelectedPairStudentB.Id)); + if (conflicting is not null) opposite.Remove(conflicting); + StudentPairRule? created = null; + created = new StudentPairRule(SelectedPairStudentA, SelectedPairStudentB, rule => + { + target.Remove(rule); + InvalidateResult(); + }); + target.Add(created); + SelectedPairStudentA = null; + SelectedPairStudentB = null; + InvalidateResult(); + } + + private static bool PairViolated(StudentPairRule rule, IReadOnlyDictionary groups, + bool mustBeTogether) + { + if (!groups.TryGetValue(rule.FirstId, out var first) || !groups.TryGetValue(rule.SecondId, out var second)) + return false; + return mustBeTogether ? first != second : first == second; + } + + private static double PairWeight(double priority) => 0.05 + priority / 100.0 * 8.0; + + private static string PriorityLabel(double value) => value switch + { + >= 90 => "nahezu fix", + >= 65 => "hohe Priorität", + >= 35 => "mittlere Priorität", + >= 10 => "flexibel", + _ => "nur Richtwert", + }; + + private static double? ParseGradeScore(string value, GradingSystem system) + { + var text = value.Trim(); + var tendency = 0.0; + if (text.EndsWith('+')) { tendency = system == GradingSystem.Grades1To6 ? -1.0 / 3 : 1.0 / 3; text = text[..^1]; } + else if (text.EndsWith('-')) { tendency = system == GradingSystem.Grades1To6 ? 1.0 / 3 : -1.0 / 3; text = text[..^1]; } + if (!double.TryParse(text, System.Globalization.NumberStyles.Number, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return null; + parsed += tendency; + return system == GradingSystem.Grades1To6 + ? Math.Clamp((6 - parsed) / 5.0, 0, 1) + : Math.Clamp(parsed / 15.0, 0, 1); + } +} diff --git a/LehrerApp.Desktop/Views/Groups/StudentGroupGeneratorWindow.axaml b/LehrerApp.Desktop/Views/Groups/StudentGroupGeneratorWindow.axaml new file mode 100644 index 0000000..767da29 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/StudentGroupGeneratorWindow.axaml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +