using System.Globalization; using System.IO.Compression; using System.Text.RegularExpressions; using System.Xml.Linq; using LehrerApp.Core.Models; namespace LehrerApp.Api; /// Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden /// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im /// SchoolWeatherStore persistiert. public sealed class DwdWeatherService(HttpClient http) { private const string StationCatalogUrl = "https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication"; private const string ForecastUrl = "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz"; private const string WarningsUrl = "https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip"; private readonly SemaphoreSlim _gate = new(1, 1); private List? _stations; private DateTime _stationsLoadedAt; private readonly Dictionary _forecastCache = []; private CachedWarnings? _warningsCache; public async Task GetAsync(SchoolLocationProfile location, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken); try { var stations = await GetStationsAsync(cancellationToken); var station = FindNearestStation(stations, location.Latitude, location.Longitude) ?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen."); var forecast = await GetForecastAsync(station, cancellationToken); var warnings = await GetWarningsAsync(cancellationToken); return new WeatherSnapshot { StationId = station.Id, StationName = station.Name, StationDistanceKm = Math.Round(DistanceKm( location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1), ForecastIssuedAt = forecast.IssuedAt, RetrievedAt = DateTime.UtcNow, Forecast = forecast.Hours, Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude)) .Select(w => w.Warning) .OrderByDescending(w => SeverityRank(w.Severity)) .ThenBy(w => w.Onset) .ToList(), }; } finally { _gate.Release(); } } private async Task> GetStationsAsync(CancellationToken cancellationToken) { if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7)) return _stations; var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken); _stations = ParseStationCatalog(text); _stationsLoadedAt = DateTime.UtcNow; return _stations; } private async Task GetForecastAsync(MosmixStation station, CancellationToken cancellationToken) { if (_forecastCache.TryGetValue(station.Id, out var cached) && DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45)) return cached.Forecast; var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture, ForecastUrl, station.Id), cancellationToken); var parsed = ParseMosmix(bytes); _forecastCache[station.Id] = new(DateTime.UtcNow, parsed); return parsed; } private async Task> GetWarningsAsync(CancellationToken cancellationToken) { if (_warningsCache is not null && DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5)) return _warningsCache.Warnings; var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken); var warnings = ParseCapArchive(bytes, DateTime.UtcNow); _warningsCache = new(DateTime.UtcNow, warnings); return warnings; } public static List ParseStationCatalog(string text) { var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled); var result = new List(); foreach (var line in text.Split('\n')) { var match = regex.Match(line.TrimEnd('\r')); if (!match.Success || !double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) || !double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon)) continue; result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon)); } return result; } public static MosmixStation? FindNearestStation(IEnumerable stations, double latitude, double longitude) => stations.MinBy(s => DistanceKm(latitude, longitude, s.Latitude, s.Longitude)); public static ParsedForecast ParseMosmix(byte[] kmz) { using var memory = new MemoryStream(kmz); using var archive = new ZipArchive(memory, ZipArchiveMode.Read); var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument."); using var stream = entry.Open(); var document = XDocument.Load(stream); XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd"; var times = document.Descendants(dwd + "TimeStep") .Select(x => ParseDate(x.Value)) .Where(x => x.HasValue) .Select(x => x!.Value) .ToList(); if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte."); var values = document.Descendants(dwd + "Forecast") .Where(x => x.Attribute(dwd + "elementName") is not null) .ToDictionary( x => x.Attribute(dwd + "elementName")!.Value, x => ParseValues(x.Element(dwd + "value")?.Value ?? ""), StringComparer.Ordinal); double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count ? series[i] : null; var hours = new List(); var earliest = DateTime.UtcNow.AddHours(-1); // 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 { ValidAt = times[i], TemperatureC = Round(Value("TTT", i) - 273.15, 1), WindSpeedKmh = Round(Value("FF", i) * 3.6, 1), WindGustKmh = Round(Value("FX1", i) * 3.6, 1), PrecipitationMm = Round(Value("RR1c", i), 1), // Beide Zusatzwerte stammen aus derselben MOSMIX-L-Datei. R101 bezeichnet die // Wahrscheinlichkeit für >0,1 mm in der letzten Stunde, SunD1 liegt in Sekunden vor. PrecipitationProbabilityPercent = Round(Value("R101", i), 0), SunshineDurationMinutes = Round(Value("SunD1", i) / 60, 0), CloudCoverPercent = Round(Value("Neff", i), 0), WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null, }); } return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours); } public static List ParseCapArchive(byte[] zip, DateTime nowUtc) { var result = new List(); using var memory = new MemoryStream(zip); using var archive = new ZipArchive(memory, ZipArchiveMode.Read); foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))) { using var stream = entry.Open(); var document = XDocument.Load(stream); XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2"; var root = document.Root; if (root is null || root.Element(cap + "status")?.Value != "Actual") continue; var info = root.Elements(cap + "info").FirstOrDefault(x => x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true) ?? root.Elements(cap + "info").FirstOrDefault(); if (info is null) continue; var expires = ParseDate(info.Element(cap + "expires")?.Value); if (expires is { } until && until < nowUtc) continue; var polygons = info.Elements(cap + "area") .SelectMany(x => x.Elements(cap + "polygon")) .Select(x => ParsePolygon(x.Value)) .Where(x => x.Count >= 3) .ToList(); // Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht // zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt. if (polygons.Count == 0) continue; result.Add(new ParsedWarning(new WeatherWarning { Identifier = root.Element(cap + "identifier")?.Value ?? "", Event = info.Element(cap + "event")?.Value ?? "", Headline = info.Element(cap + "headline")?.Value ?? "", Description = info.Element(cap + "description")?.Value ?? "", Instruction = info.Element(cap + "instruction")?.Value ?? "", Severity = info.Element(cap + "severity")?.Value ?? "Unknown", Onset = ParseDate(info.Element(cap + "onset")?.Value), Expires = expires, }, polygons)); } return result; } private static List ParseValues(string raw) => raw .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) .Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? (double?)value : null) .ToList(); private static List ParsePolygon(string raw) => raw .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Split(',')) .Where(x => x.Length >= 2 && double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) && double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _)) .Select(x => new GeoPoint( double.Parse(x[0], CultureInfo.InvariantCulture), double.Parse(x[1], CultureInfo.InvariantCulture))) .ToList(); public static bool PointInPolygon(IReadOnlyList polygon, double latitude, double longitude) { var inside = false; for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++) { var a = polygon[i]; var b = polygon[j]; if ((a.Latitude > latitude) != (b.Latitude > latitude) && longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) / (b.Latitude - a.Latitude) + a.Longitude) inside = !inside; } return inside; } private static bool Contains(IEnumerable> polygons, double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude)); private static double DistanceKm(double lat1, double lon1, double lat2, double lon2) { const double earthRadiusKm = 6371.0; static double Rad(double degrees) => degrees * Math.PI / 180.0; var dLat = Rad(lat2 - lat1); var dLon = Rad(lon2 - lon1); var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); } private static DateTime? ParseDate(string? value) => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var parsed) ? parsed.UtcDateTime : null; private static double? Round(double? value, int digits) => value is null ? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero); private static int SeverityRank(string severity) => severity switch { "Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0, }; public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude); public sealed record ParsedForecast(DateTime? IssuedAt, List Hours); public sealed record GeoPoint(double Latitude, double Longitude); public sealed record ParsedWarning(WeatherWarning Warning, List> Polygons); private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast); private sealed record CachedWarnings(DateTime LoadedAt, List Warnings); }