diff --git a/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs index 80ab3b0..7722cbe 100644 --- a/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs +++ b/LehrerApp.Api.Tests/DwdWeatherServiceTests.cs @@ -55,6 +55,29 @@ public sealed class DwdWeatherServiceTests Assert.Equal(61, hour.WeatherCode); } + [Fact] + public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche() + { + var start = DateTime.UtcNow.AddHours(1); + var timeSteps = string.Join("", Enumerable.Range(0, 100) + .Select(i => $"{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}")); + var values = string.Join(' ', Enumerable.Repeat("293.15", 100)); + var kml = $$""" + + + + {{timeSteps}} + + {{values}} + + + """; + + var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml)); + + Assert.Equal(100, parsed.Hours.Count); + } + [Fact] public void ParseCapArchive_OrdnetWarnpolygonGeografischZu() { diff --git a/LehrerApp.Api/DwdWeatherService.cs b/LehrerApp.Api/DwdWeatherService.cs index 83911d5..6f6aa45 100644 --- a/LehrerApp.Api/DwdWeatherService.cs +++ b/LehrerApp.Api/DwdWeatherService.cs @@ -138,7 +138,9 @@ public sealed class DwdWeatherService(HttpClient http) ? series[i] : null; var hours = new List(); var earliest = DateTime.UtcNow.AddHours(-1); - for (var i = 0; i < times.Count && hours.Count < 72; i++) + // MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung + // sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann. + for (var i = 0; i < times.Count && hours.Count < 240; i++) { if (times[i] < earliest) continue; hours.Add(new WeatherForecastHour diff --git a/LehrerApp.Desktop.Tests/SchoolDayWeatherSummaryTests.cs b/LehrerApp.Desktop.Tests/SchoolDayWeatherSummaryTests.cs new file mode 100644 index 0000000..a453f81 --- /dev/null +++ b/LehrerApp.Desktop.Tests/SchoolDayWeatherSummaryTests.cs @@ -0,0 +1,102 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class SchoolDayWeatherSummaryTests +{ + private static readonly DateOnly Day = new(2026, 8, 24); + + [Fact] + public void From_GewitterHatVorrangVorSturmUndRegen() + { + var snapshot = Snapshot( + Hour(8, code: 61, rain: 2, gust: 70), + Hour(10, code: 95, rain: 1, gust: 80)); + + var summary = SchoolDayWeatherSummary.From(snapshot, Day); + + Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind); + Assert.Equal("⛈️", summary.Symbol); + } + + [Fact] + public void From_BoeAbBeaufortAcht_WirdAlsSturmAngezeigt() + { + var summary = SchoolDayWeatherSummary.From(Snapshot(Hour(10, gust: 62)), Day); + + Assert.Equal(SchoolDayWeatherKind.Storm, summary!.Kind); + Assert.Equal("💨", summary.Symbol); + } + + [Fact] + public void From_NaechtlicheBoeBeeinflusstSchultagNicht() + { + var snapshot = Snapshot(Hour(10, cloud: 10), Hour(23, gust: 90)); + + var summary = SchoolDayWeatherSummary.From(snapshot, Day); + + Assert.Equal(20, summary!.MaxWindGustKmh); + Assert.Equal(SchoolDayWeatherKind.Sunny, summary!.Kind); + } + + [Fact] + public void From_AmtlicheGewitterwarnung_PraegtSymbolUndTooltip() + { + var snapshot = Snapshot(Hour(10, cloud: 20)); + snapshot.Warnings.Add(new WeatherWarning + { + Identifier = "w1", Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter", + Instruction = "Aufenthalt im Freien vermeiden.", Severity = "Severe", + Onset = Day.ToDateTime(new TimeOnly(12, 0)), Expires = Day.ToDateTime(new TimeOnly(16, 0)), + }); + + var summary = SchoolDayWeatherSummary.From(snapshot, Day); + + Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind); + Assert.Contains("Amtliche Warnung", summary.Tooltip); + Assert.Contains("Aufenthalt im Freien", summary.Tooltip); + } + + [Fact] + public void From_RegenSummiertStundenUndZeigtTemperaturspanne() + { + var snapshot = Snapshot( + Hour(8, temperature: 12, rain: 0.1), + Hour(12, temperature: 17, rain: 0.4)); + + var summary = SchoolDayWeatherSummary.From(snapshot, Day); + + Assert.Equal(SchoolDayWeatherKind.Rain, summary!.Kind); + Assert.Equal(0.5, summary.PrecipitationMm); + Assert.Contains("12–17 °C", summary.Tooltip); + } + + [Fact] + public void WarningTouchesDate_MehrtaegigeVorwarnung_TrifftAlleUeberlapptenTage() + { + var warning = new WeatherWarning + { + Onset = Day.ToDateTime(new TimeOnly(18, 0)), + Expires = Day.AddDays(2).ToDateTime(new TimeOnly(8, 0)), + }; + + Assert.True(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(1))); + Assert.False(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(3))); + } + + private static WeatherSnapshot Snapshot(params WeatherForecastHour[] hours) => new() + { + RetrievedAt = DateTime.UtcNow, + Forecast = hours.ToList(), + }; + + private static WeatherForecastHour Hour(int hour, int? code = 0, double? temperature = 20, + double? rain = 0, double? gust = 20, double? cloud = 20) => new() + { + ValidAt = Day.ToDateTime(new TimeOnly(hour, 0)), WeatherCode = code, + TemperatureC = temperature, PrecipitationMm = rain, WindGustKmh = gust, + CloudCoverPercent = cloud, + }; +} diff --git a/LehrerApp.Desktop/ViewModels/Planning/SchoolDayWeatherSummary.cs b/LehrerApp.Desktop/ViewModels/Planning/SchoolDayWeatherSummary.cs new file mode 100644 index 0000000..dce154a --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/SchoolDayWeatherSummary.cs @@ -0,0 +1,154 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +public enum SchoolDayWeatherKind { Sunny, Cloudy, Rain, Storm, Thunderstorm } + +/// Verdichtet die stündliche MOSMIX-Prognose auf ein planungsrelevantes Schultagsymbol. +/// Extremere Bedingungen haben bewusst Vorrang vor Mittelwerten. +public sealed class SchoolDayWeatherSummary +{ + public DateOnly Date { get; init; } + public SchoolDayWeatherKind Kind { get; init; } + public double? MinTemperatureC { get; init; } + public double? MaxTemperatureC { get; init; } + public double PrecipitationMm { get; init; } + public double? MaxWindGustKmh { get; init; } + public IReadOnlyList Warnings { get; init; } = []; + + public string Symbol => Kind switch + { + SchoolDayWeatherKind.Thunderstorm => "⛈️", + SchoolDayWeatherKind.Storm => "💨", + SchoolDayWeatherKind.Rain => "🌧️", + SchoolDayWeatherKind.Cloudy => "☁️", + _ => "☀️", + }; + + public string Label => Kind switch + { + SchoolDayWeatherKind.Thunderstorm => "Gewitter", + SchoolDayWeatherKind.Storm => "Sturm", + SchoolDayWeatherKind.Rain => "Regen", + SchoolDayWeatherKind.Cloudy => "Bewölkt", + _ => "Sonnig", + }; + + public string Tooltip + { + get + { + var lines = new List { Label }; + if (MinTemperatureC is { } min && MaxTemperatureC is { } max) + lines.Add(Math.Abs(max - min) < 0.05 ? $"{min:0.#} °C" : $"{min:0.#}–{max:0.#} °C"); + if (PrecipitationMm >= 0.05) lines.Add($"Niederschlag: {PrecipitationMm:0.#} mm"); + if (MaxWindGustKmh is { } gust) lines.Add($"Max. Böen: {gust:0.#} km/h"); + foreach (var warning in Warnings) + { + var period = WarningPeriod(warning); + lines.Add($"⚠ {warning.Headline}{(period.Length > 0 ? $" ({period})" : "")}"); + if (!string.IsNullOrWhiteSpace(warning.Instruction)) lines.Add(warning.Instruction.Trim()); + } + return string.Join("\n", lines); + } + } + + public static SchoolDayWeatherSummary? From(WeatherSnapshot snapshot, DateOnly date) + { + var allHours = snapshot.Forecast + .Where(x => DateOnly.FromDateTime(Local(x.ValidAt)) == date) + .ToList(); + // Für die Unterrichtsplanung zählt vor allem der Zeitraum, in dem Schule stattfindet. + // Falls MOSMIX für diesen Zeitraum noch keine Werte liefert, bleibt der Tagesrest nutzbar. + var schoolHours = allHours.Where(x => + { + var hour = Local(x.ValidAt).Hour; + return hour >= 6 && hour <= 18; + }).ToList(); + var hours = schoolHours.Count > 0 ? schoolHours : allHours; + if (hours.Count == 0) return null; + + var warnings = snapshot.Warnings.Where(x => WarningTouchesDate(x, date)).ToList(); + var codes = hours.Where(x => x.WeatherCode.HasValue).Select(x => x.WeatherCode!.Value).ToList(); + var precipitation = hours.Sum(x => x.PrecipitationMm ?? 0); + var gust = hours.Where(x => x.WindGustKmh.HasValue).Select(x => x.WindGustKmh!.Value).DefaultIfEmpty().Max(); + var hasGust = hours.Any(x => x.WindGustKmh.HasValue); + var warningText = string.Join(" ", warnings.SelectMany(x => new[] { x.Event, x.Headline })); + + var kind = codes.Any(x => x is >= 95 and <= 99) || + warningText.Contains("GEWITTER", StringComparison.OrdinalIgnoreCase) + ? SchoolDayWeatherKind.Thunderstorm + : (hasGust && gust >= 62) || ContainsStormWarning(warningText) + ? SchoolDayWeatherKind.Storm + : precipitation >= 0.2 || codes.Any(IsRainCode) + ? SchoolDayWeatherKind.Rain + : IsCloudy(hours, codes) + ? SchoolDayWeatherKind.Cloudy + : SchoolDayWeatherKind.Sunny; + + var temperatures = hours.Where(x => x.TemperatureC.HasValue) + .Select(x => x.TemperatureC!.Value).ToList(); + return new SchoolDayWeatherSummary + { + Date = date, + Kind = kind, + MinTemperatureC = temperatures.Count > 0 ? temperatures.Min() : null, + MaxTemperatureC = temperatures.Count > 0 ? temperatures.Max() : null, + PrecipitationMm = Math.Round(precipitation, 1, MidpointRounding.AwayFromZero), + MaxWindGustKmh = hasGust ? Math.Round(gust, 1, MidpointRounding.AwayFromZero) : null, + Warnings = warnings, + }; + } + + public static bool WarningTouchesDate(WeatherWarning warning, DateOnly date) + { + if (warning.Onset is null && warning.Expires is null) return false; + var start = warning.Onset is { } onset ? Local(onset) : Local(warning.Expires!.Value).AddHours(-12); + var end = warning.Expires is { } expires ? Local(expires) : start.AddHours(12); + var dayStart = date.ToDateTime(TimeOnly.MinValue); + var dayEnd = date.ToDateTime(TimeOnly.MaxValue); + return start <= dayEnd && end >= dayStart; + } + + public static string WarningPeriod(WeatherWarning warning) + { + var onset = warning.Onset is { } start ? Local(start) : (DateTime?)null; + var expires = warning.Expires is { } end ? Local(end) : (DateTime?)null; + return (onset, expires) switch + { + ({ } a, { } b) when DateOnly.FromDateTime(a) == DateOnly.FromDateTime(b) => + $"{a:dd.MM., HH:mm}–{b:HH:mm} Uhr", + ({ } a, { } b) => $"{a:dd.MM., HH:mm}–{b:dd.MM., HH:mm} Uhr", + ({ } a, null) => $"ab {a:dd.MM., HH:mm} Uhr", + (null, { } b) => $"bis {b:dd.MM., HH:mm} Uhr", + _ => "", + }; + } + + private static bool IsRainCode(int code) => code is >= 51 and <= 67 or >= 80 and <= 82; + + private static bool IsCloudy(IEnumerable hours, IEnumerable codes) + { + var cloudValues = hours.Where(x => x.CloudCoverPercent.HasValue) + .Select(x => x.CloudCoverPercent!.Value).ToList(); + return codes.Any(x => x is 2 or 3 or 45 or 48) || + cloudValues.Count > 0 && cloudValues.Average() >= 60; + } + + private static bool ContainsStormWarning(string text) => + text.Contains("STURM", StringComparison.OrdinalIgnoreCase) || + text.Contains("ORKAN", StringComparison.OrdinalIgnoreCase) || + text.Contains("WIND", StringComparison.OrdinalIgnoreCase) || + text.Contains("BÖEN", StringComparison.OrdinalIgnoreCase); + + private static DateTime Local(DateTime value) => value.Kind == DateTimeKind.Utc + ? value.ToLocalTime() : value; +} + +public sealed class WeekWeatherWarningItem(WeatherWarning warning) +{ + public string Headline { get; } = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline; + public string PeriodDisplay { get; } = SchoolDayWeatherSummary.WarningPeriod(warning); + public string Tooltip { get; } = string.Join("\n", new[] { warning.Description, warning.Instruction } + .Where(x => !string.IsNullOrWhiteSpace(x))); +} diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index faf2441..67f6822 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -65,6 +65,9 @@ public partial class TimetableViewModel : ObservableObject private readonly ISubstitutionEntryRepository _substitutions; private readonly IUntisSlotMappingRepository _untisMappings; private readonly WebUntisSettingsService _untisSettings; + private readonly SchoolWeatherService? _schoolWeather; + private readonly SemaphoreSlim _weatherGate = new(1, 1); + private WeatherSnapshot? _weatherSnapshot; public ObservableCollection Cells { get; } = []; public ObservableCollection WeekItems { get; } = []; @@ -73,6 +76,7 @@ public partial class TimetableViewModel : ObservableObject public ObservableCollection TodaySupervisions { get; } = []; public ObservableCollection TodaySpecialAssignments { get; } = []; public ObservableCollection UpcomingExams { get; } = []; + public ObservableCollection WeekWeatherWarnings { get; } = []; /// Cells/WeekItems zeilenweise gruppiert (GridColumns Zellen je Zeile), zusätzlich zur /// flachen Liste - Grundlage für die zeilenweise Höhensteuerung in der View (Aufsicht-Zeilen @@ -108,13 +112,15 @@ public partial class TimetableViewModel : ObservableObject ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolYearService schoolYear, ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions, - IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings) + IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings, + SchoolWeatherService? schoolWeather = null) { _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; _publicHolidays = publicHolidays; _schoolYear = schoolYear; _supervisionDuties = supervisionDuties; _substitutions = substitutions; _untisMappings = untisMappings; _untisSettings = untisSettings; + _schoolWeather = schoolWeather; Load(); } @@ -152,12 +158,58 @@ public partial class TimetableViewModel : ObservableObject BuildGrid(holidayBadges, duties); BuildWeekOverview(today, publicHolidayDates, duties); + ApplyWeatherToWeekHeaders(today); + _ = LoadWeatherAsync(today); BuildToday(today); BuildHoursWarnings(); BuildUpcomingExams(today); LoadUntisMismatch(); } + private async Task LoadWeatherAsync(DateOnly today) + { + if (_schoolWeather?.IsAvailable != true) return; + if (_weatherSnapshot is not null && + DateTime.UtcNow - _weatherSnapshot.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5)) + return; + if (!await _weatherGate.WaitAsync(0)) return; + try + { + _weatherSnapshot = await _schoolWeather.GetWeatherAsync(); + ApplyWeatherToWeekHeaders(today); + } + catch (SchoolWeatherException) { /* Wetter ist eine optionale Planungshilfe. */ } + finally { _weatherGate.Release(); } + } + + private void ApplyWeatherToWeekHeaders(DateOnly today) + { + WeekWeatherWarnings.Clear(); + var datedHeaders = WeekItems.Where(x => x.IsHeader && x.Date.HasValue).ToList(); + foreach (var header in datedHeaders) + { + var date = header.Date!.Value; + var warnings = _weatherSnapshot?.Warnings + .Where(x => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date)) + .ToList() ?? []; + var summary = date >= today && _weatherSnapshot is not null + ? SchoolDayWeatherSummary.From(_weatherSnapshot, date) + : null; + header.SetWeather(summary, warnings); + } + + if (_weatherSnapshot is null || datedHeaders.Count == 0) return; + var from = datedHeaders.Min(x => x.Date!.Value); + var until = datedHeaders.Max(x => x.Date!.Value); + foreach (var warning in _weatherSnapshot.Warnings + .Where(x => Enumerable.Range(0, until.DayNumber - from.DayNumber + 1) + .Select(from.AddDays) + .Any(date => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date))) + .DistinctBy(x => x.Identifier) + .OrderBy(x => x.Onset)) + WeekWeatherWarnings.Add(new WeekWeatherWarningItem(warning)); + } + private void LoadUntisMismatch() { if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; } @@ -689,7 +741,7 @@ public class TimetableRowItem(IReadOnlyList cells) } /// Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht. -public class WeekCellItem +public partial class WeekCellItem : ObservableObject { public bool IsHeader { get; private init; } public bool IsPeriodLabel { get; private init; } @@ -705,6 +757,7 @@ public class WeekCellItem public DayOfWeek? Weekday { get; private init; } public int PeriodNumber { get; private init; } public bool IsToday { get; private init; } + public DateOnly? Date { get; private init; } public Guid GroupId { get; private init; } public string SubjectLabel { get; private init; } = ""; public string GroupName { get; private init; } = ""; @@ -726,6 +779,20 @@ public class WeekCellItem /// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2). public Lesson? Lesson { get; private init; } public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe"; + [ObservableProperty] private string _weatherSymbol = ""; + [ObservableProperty] private string _weatherTooltip = ""; + [ObservableProperty] private bool _hasWeatherWarning; + public bool HasWeather => WeatherSymbol.Length > 0; + + partial void OnWeatherSymbolChanged(string value) => OnPropertyChanged(nameof(HasWeather)); + + public void SetWeather(SchoolDayWeatherSummary? summary, IReadOnlyList warnings) + { + WeatherSymbol = summary?.Symbol ?? ""; + WeatherTooltip = summary?.Tooltip ?? string.Join("\n", warnings.Select(x => + $"⚠ {x.Headline} ({SchoolDayWeatherSummary.WarningPeriod(x)})")); + HasWeatherWarning = warnings.Count > 0; + } public static WeekCellItem Corner() => new() { IsHeader = true }; @@ -733,6 +800,7 @@ public class WeekCellItem { IsHeader = true, IsToday = isToday, + Date = date, Text = (day switch { DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", diff --git a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml index a0f6cd9..4f33eb8 100644 --- a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml +++ b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml @@ -154,6 +154,24 @@ ToolTip.Tip="Einstellungen (Ferien, Aufsichten, Stundenraster)"/> + + + + + + + + + + + + + + + +