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(Guid Id, 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 partial class GeneratedStudentGroup : ObservableObject { [ObservableProperty] private bool _isDropTarget; 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.Id, student.FullName)); } public GeneratedStudentGroup(int number, IEnumerable members) { Title = $"Gruppe {number}"; foreach (var member in members) Members.Add(member); } } /// 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 IStudentGroupPlanRepository? _savedPlans; private readonly Random _random; private readonly List _participants = []; private readonly Dictionary _performance = []; private readonly HashSet _supportStudentIds = []; private Guid? _editingPlanId; 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; [ObservableProperty] private StudentGroupPlan? _selectedSavedPlan; [ObservableProperty] private string _planName = ""; [ObservableProperty] private string _saveStatus = ""; [ObservableProperty] private bool _hasUnsavedChanges; public ObservableCollection Groups { get; } = []; public ObservableCollection GeneratedGroups { get; } = []; public ObservableCollection PairCandidates { get; } = []; public ObservableCollection ForcedPairs { get; } = []; public ObservableCollection ExcludedPairs { get; } = []; public ObservableCollection SavedPlans { get; } = []; public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel]; public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2; public bool CanSave => _savedPlans is not null && SelectedGroup is not null && HasGeneratedGroups && !string.IsNullOrWhiteSpace(PlanName); public bool CanLoadSavedPlan => SelectedSavedPlan is not null; public bool CanDeleteSavedPlan => SelectedSavedPlan is not null; public bool HasSavedPlans => SavedPlans.Count > 0; 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, IStudentGroupPlanRepository? savedPlans = null) { _students = students; _groupRepository = groups; _memberships = memberships; _sessions = sessions; _participation = participation; _grades = grades; _random = random ?? Random.Shared; _savedPlans = savedPlans; foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)) Groups.Add(new StudentDrawGroup(group.Id, group.Name)); if (initialGroupId is { } requestedId && Groups.All(g => g.Id != requestedId) && groups.GetById(requestedId) is { } archived) Groups.Add(new StudentDrawGroup(archived.Id, $"{archived.Name} (archiviert)")); SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault(); } partial void OnSelectedGroupChanged(StudentDrawGroup? value) { ForcedPairs.Clear(); ExcludedPairs.Clear(); ReloadParticipants(); ReloadSavedPlans(); _editingPlanId = null; PlanName = ""; } partial void OnParticipantModeNameChanged(string value) => ReloadParticipants(); partial void OnSelectedSavedPlanChanged(StudentGroupPlan? value) { OnPropertyChanged(nameof(CanLoadSavedPlan)); OnPropertyChanged(nameof(CanDeleteSavedPlan)); LoadSavedPlanCommand.NotifyCanExecuteChanged(); DeleteSavedPlanCommand.NotifyCanExecuteChanged(); } partial void OnPlanNameChanged(string value) { SavePlanCommand.NotifyCanExecuteChanged(); if (HasGeneratedGroups) HasUnsavedChanges = true; } partial void OnHasGeneratedGroupsChanged(bool value) => SavePlanCommand.NotifyCanExecuteChanged(); 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; _editingPlanId = null; SelectedSavedPlan = null; PlanName = $"Einteilung {DateTime.Now:dd.MM.yyyy HH:mm:ss}"; HasUnsavedChanges = true; SaveStatus = "Noch nicht gespeichert."; StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " + string.Join("/", capacities.Distinct().OrderBy(x => x)); UpdateWarning(groupCount, assignments); } [RelayCommand(CanExecute = nameof(CanSave))] private void SavePlan() { if (_savedPlans is null || SelectedGroup is null) return; var plan = new StudentGroupPlan { Id = _editingPlanId ?? Guid.NewGuid(), GroupId = SelectedGroup.Id, Name = PlanName, Groups = GeneratedGroups.Select(group => new StudentGroupPlanGroup { StudentIds = group.Members.Select(member => member.Id).ToList(), }).ToList(), }; try { _savedPlans.Save(plan); _editingPlanId = plan.Id; ReloadSavedPlans(plan.Id); HasUnsavedChanges = false; SaveStatus = "Einteilung gespeichert."; } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { SaveStatus = ex.Message; } } [RelayCommand(CanExecute = nameof(CanLoadSavedPlan))] private void LoadSavedPlan() { if (_savedPlans is null || SelectedSavedPlan is not { } selected || SelectedGroup?.Id != selected.GroupId) return; var plan = _savedPlans.GetById(selected.Id); if (plan is null) { ReloadSavedPlans(); return; } _editingPlanId = plan.Id; var missing = 0; GeneratedGroups.Clear(); foreach (var group in plan.Groups ?? []) { var members = new List(); foreach (var id in group.StudentIds ?? []) { if (_students.GetById(id) is { } student) members.Add(new StudentGroupMember(id, student.FullName)); else missing++; } GeneratedGroups.Add(new GeneratedStudentGroup(GeneratedGroups.Count + 1, members)); } HasGeneratedGroups = GeneratedGroups.Count > 0; PlanName = plan.Name; HasUnsavedChanges = missing > 0; SaveStatus = missing > 0 ? $"{missing} nicht mehr vorhandene Schüler wurden ausgelassen. Bitte erneut speichern." : "Gespeicherte Einteilung geladen."; StatusText = $"{GeneratedGroups.Sum(group => group.Members.Count)} Schüler · " + $"{GeneratedGroups.Count} Gruppen · gespeichert am {plan.UpdatedAt.ToLocalTime():dd.MM.yyyy}"; WarningText = missing > 0 ? SaveStatus : ""; } [RelayCommand(CanExecute = nameof(CanDeleteSavedPlan))] private void DeleteSavedPlan() { if (_savedPlans is null || SelectedSavedPlan is not { } plan) return; _savedPlans.Delete(plan.Id); var deletedCurrent = _editingPlanId == plan.Id; if (deletedCurrent) _editingPlanId = null; ReloadSavedPlans(); if (deletedCurrent) HasUnsavedChanges = HasGeneratedGroups; SaveStatus = "Gespeicherte Einteilung gelöscht."; } public bool MoveStudent(StudentGroupMember member, GeneratedStudentGroup target) { if (!HasGeneratedGroups || !GeneratedGroups.Contains(target)) return false; var source = GeneratedGroups.FirstOrDefault(group => group.Members.Contains(member)); if (source is null || source == target || target.Members.Any(m => m.Id == member.Id)) return false; source.Members.Remove(member); target.Members.Add(member); HasUnsavedChanges = true; SaveStatus = "Änderungen noch nicht gespeichert."; StatusText = $"{GeneratedGroups.Sum(group => group.Members.Count)} Schüler · " + $"{GeneratedGroups.Count} Gruppen · manuell angepasst"; var assignments = GeneratedGroups.Select(group => group.Members .Select(m => _students.GetById(m.Id)).OfType().ToList()).ToList(); UpdateWarning(GeneratedGroups.Count, assignments); return true; } private void ReloadSavedPlans(Guid? selectedId = null) { SavedPlans.Clear(); SelectedSavedPlan = null; if (_savedPlans is not null && SelectedGroup is not null) { foreach (var plan in _savedPlans.GetByGroup(SelectedGroup.Id)) SavedPlans.Add(plan); SelectedSavedPlan = SavedPlans.FirstOrDefault(plan => plan.Id == selectedId); } OnPropertyChanged(nameof(HasSavedPlans)); } 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; HasUnsavedChanges = false; SaveStatus = ""; 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); } }