170 lines
8.1 KiB
C#
170 lines
8.1 KiB
C#
using LehrerApp.Core.Models;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Planning;
|
||
|
||
public enum SchoolDayWeatherKind { Sunny, Cloudy, Rain, Storm, Thunderstorm }
|
||
|
||
/// <summary>Verdichtet die stündliche MOSMIX-Prognose auf ein planungsrelevantes Schultagsymbol.
|
||
/// Extremere Bedingungen haben bewusst Vorrang vor Mittelwerten.</summary>
|
||
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<WeatherWarning> 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<string> { $"{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<WeatherForecastHour> hours, IEnumerable<int> 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)));
|
||
}
|