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? MaxHourlyPrecipitationProbabilityPercent { get; init; } public double? SunshineDurationHours { 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} · 08–16 Uhr" }; if (MinTemperatureC is { } min && MaxTemperatureC is { } max) lines.Add("Temperatur: " + (Math.Abs(max - min) < 0.05 ? $"{min:0.#} °C" : $"{min:0.#}–{max:0.#} °C")); if (MaxHourlyPrecipitationProbabilityPercent is { } probability) lines.Add($"Regenwahrscheinlichkeit: bis {probability:0} % (stündlich)"); if (PrecipitationMm >= 0.05) lines.Add($"Niederschlag: {PrecipitationMm:0.#} mm"); if (SunshineDurationHours is { } sunshine) lines.Add($"Sonnenschein: {sunshine:0.#} h"); 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 >= 8 && hour <= 16; }).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 probabilities = hours.Where(x => x.PrecipitationProbabilityPercent.HasValue) .Select(x => x.PrecipitationProbabilityPercent!.Value).ToList(); var sunshineMinutes = hours.Where(x => x.SunshineDurationMinutes.HasValue) .Select(x => x.SunshineDurationMinutes!.Value).ToList(); 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), MaxHourlyPrecipitationProbabilityPercent = probabilities.Count > 0 ? Math.Round(probabilities.Max(), 0, MidpointRounding.AwayFromZero) : null, SunshineDurationHours = sunshineMinutes.Count > 0 ? Math.Round(sunshineMinutes.Sum() / 60, 1, MidpointRounding.AwayFromZero) : null, 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))); }