Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/StudentGroupGeneratorViewModel.cs
T
2026-09-29 22:17:49 +02:00

636 lines
29 KiB
C#

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<StudentPairRule> 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<StudentGroupMember> Members { get; } = [];
public GeneratedStudentGroup(int number, IEnumerable<Student> 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<StudentGroupMember> members)
{
Title = $"Gruppe {number}";
foreach (var member in members) Members.Add(member);
}
}
/// <summary>Erzeugt Gruppen fuer eine spaetere Planung oder aus der heutigen Anwesenheit.</summary>
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<Student> _participants = [];
private readonly Dictionary<Guid, double?> _performance = [];
private readonly HashSet<Guid> _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<StudentDrawGroup> Groups { get; } = [];
public ObservableCollection<GeneratedStudentGroup> GeneratedGroups { get; } = [];
public ObservableCollection<StudentPairCandidate> PairCandidates { get; } = [];
public ObservableCollection<StudentPairRule> ForcedPairs { get; } = [];
public ObservableCollection<StudentPairRule> ExcludedPairs { get; } = [];
public ObservableCollection<StudentGroupPlan> 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<StudentGroupMember>();
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<Student>().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<Student> FilterByTodayAttendance(Guid groupId, List<Student> 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<Guid, AttendanceStatus?>();
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<List<Student>> BuildAssignments(int[] capacities)
{
var result = capacities.Select(_ => new List<Student>()).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<List<Student>> 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<List<Student>> 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<List<Student>>? assignments = null)
{
var warnings = new List<string>();
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<StudentPairRule> target,
ObservableCollection<StudentPairRule> 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<Guid, int> 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);
}
}