Files
dmc/app/lib/services/wind/wind_service.dart
T
Constantin LeueandClaude Sonnet 5 1b13c45cac Add wind analysis: header pill, fetch/validate menu, speed-plot overlay
Portiert das Wind-System des HTML-Demonstrators (Doku 3.8) vollstaendig:
Open-Meteo-Winddaten, interpoliert zwischen Druckflaechen- und festen
Nabenhoehen-Stuetzpunkten auf die tatsaechliche Zielhoehe.

Domain (lib/domain/wind/wind_math.dart, reines Dart): Hoehen-
Interpolation (linear + zirkulaer fuer Windrichtung), Peilung zwischen
zwei Punkten, Kopf-/Rueckenwind-Komponente pro Flugleg - 1:1 uebernommen
aus interpolateWindAtHeight()/bearingBetween()/headTailwindComponent()
des Demonstrators, per Unit-Tests abgesichert.

Service (lib/services/wind/wind_service.dart): zwei getrennte,
fokussierte Open-Meteo-Requests statt eines kombinierten (Doku 4.10 -
ein 35-Variablen-Request verursachte im Demonstrator Modellauswahl-
Fehler). Drei Verbraucher: Kopfleisten-Pille (120 m AGL ueber
Referenzort), gebuendelter Wegpunkt-Fetch (ein Request fuer alle
Koordinaten via Open-Meteos Mehrfach-Location-Syntax) und das
Validierungspanel (rohe Messwerte je Nabenhoehe/Druckflaeche). Per
MockClient getestet (kein echter Netzwerkzugriff in Tests).

UI:
- HeaderWindPill (TopModeBar, nur ausserhalb Fly-Modus): zeigt
  Windrichtung/-geschwindigkeit, Antippen schaltet perspektivisch die
  Wind-Marker der Wegpunkte um (Zustand vorbereitet, HTML-Demonstrator:
  showPerWaypointWind).
- WaypointListPanel: "Fetch wind"/"Validate"-Buttons im Panel-Header,
  neue WIND-Spalte in der Liste.
- WindValidatePanel: neue Vollbild-Route (gleiches Muster wie
  WaypointListPanel) mit Rohdaten je Hoehenstufe inkl. Boeen (Doku 4.9:
  Boeen nur hier, nicht in der Wegpunktanzeige) und Interpolationsergebnis.
- FullValueChart (Speed-Tab): Kopf-/Rueckenwind-Diamanten pro Leg samt
  gestrichelter Nulllinie sowie gestrichelte Bodengeschwindigkeits-
  Stufenlinie.

Bug gefunden und gefixt waehrend der Emulator-Verifikation: die
Y-Achsen-Autoskalierung klemmte weiterhin auf den gueltigen Drehrad-
Wertebereich (13-25 m/s), wodurch nahe Null liegende Windkomponenten
(bei schwachem Wind der Normalfall) ausserhalb des sichtbaren Bereichs
lagen und weder Diamanten noch Nulllinie zu sehen waren. Der HTML-
Demonstrator klemmt computeSpeedChartRange() bewusst nicht auf SPD_MIN/
SPD_MAX; das Klemmen in FullValueChart._range() jetzt entsprechend
entfernt.

Waypoint-Modell um windSpeedMs/windDirFromDeg/windElevationM erweitert
(nullable, reine Planungshilfe - der Encoder liest diese Felder nie,
Doku 3.2). currentMissionProvider bekommt replaceAll() fuer den
gebuendelten Wind-Fetch, der alle Wegpunkte gleichzeitig aktualisiert.

Verifiziert: flutter analyze (0 issues), flutter test (44/44, davon 14
neue Wind-Mathematik- und 6 neue WindService-Tests), manuell auf
Pixel_10a-Emulator mit echtem Open-Meteo-Netzwerkzugriff - Kopfleisten-
Pille zeigt Live-Wind, Fetch/Validate fuellen Liste bzw. oeffnen das
Validierungspanel mit echten Messwerten, Speed-Plot zeigt nach dem Fix
Diamanten/Nulllinie/Bodengeschwindigkeit korrekt skaliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:15 +02:00

359 lines
12 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart';
/// Kombinierte Stuetzpunkte fuer die Windinterpolation an einem Ort, plus die
/// dort zugrunde liegende Bodenhoehe (ASL) - analog buildCombinedWindLevels()
/// im HTML-Demonstrator (Doku 3.8).
class WindCombinedLevels {
const WindCombinedLevels({required this.levels, required this.elevationM});
final List<WindLevel> levels;
final double elevationM;
}
class WindValidationFixedReading {
const WindValidationFixedReading({
required this.heightAglM,
this.speedMs,
this.dirFromDeg,
});
final int heightAglM;
final double? speedMs;
final double? dirFromDeg;
}
class WindValidationPressureReading {
const WindValidationPressureReading({
required this.pressureHpa,
this.heightAmslM,
this.speedMs,
this.dirFromDeg,
required this.excluded,
});
final int pressureHpa;
final double? heightAmslM;
final double? speedMs;
final double? dirFromDeg;
/// True, wenn dieser Stuetzpunkt unvollstaendig ist oder unterhalb der
/// Bodenhoehe liegt und daher von der Interpolation ausgeschlossen wurde.
final bool excluded;
}
/// Ergebnis des Wind-Validierungspanels (Doku 3.8): rohe Messwerte auf
/// festen Nabenhoehen und Druckflaechen, damit nachvollziehbar bleibt, wie
/// der interpolierte Wert an [interpolatedAt120] zustande kam.
class WindValidationResult {
const WindValidationResult({
required this.lat,
required this.lon,
required this.elevationM,
required this.fixedRequestFailed,
required this.fixedReadings,
this.gustMs,
required this.pressureRequestFailed,
required this.pressureReadings,
this.interpolatedAt120,
});
final double lat;
final double lon;
final double elevationM;
final bool fixedRequestFailed;
final List<WindValidationFixedReading> fixedReadings;
final double? gustMs;
final bool pressureRequestFailed;
final List<WindValidationPressureReading> pressureReadings;
final WindSample? interpolatedAt120;
}
/// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte,
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
/// Modellauswahl-Fehler (Doku 4.10).
class WindService {
WindService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const _pressureLevels = [1000, 975, 950, 925, 900, 850, 800, 750, 700];
static const _fixedHeights = [10, 80, 120, 180];
Uri _pressureUri(String lats, String lons) {
final vars = _pressureLevels
.map((p) =>
'wind_speed_${p}hPa,wind_direction_${p}hPa,geopotential_height_${p}hPa')
.join(',');
return Uri.https('api.open-meteo.com', '/v1/forecast', {
'latitude': lats,
'longitude': lons,
'hourly': vars,
'wind_speed_unit': 'ms',
'timezone': 'UTC',
'forecast_days': '1',
});
}
Uri _fixedUri(String lats, String lons) {
final vars =
'${_fixedHeights.map((h) => 'wind_speed_${h}m,wind_direction_${h}m').join(',')},wind_gusts_10m';
return Uri.https('api.open-meteo.com', '/v1/forecast', {
'latitude': lats,
'longitude': lons,
'hourly': vars,
'wind_speed_unit': 'ms',
'timezone': 'UTC',
'forecast_days': '1',
});
}
Future<dynamic> _fetchJson(Uri uri) async {
try {
final response =
await _client.get(uri).timeout(const Duration(seconds: 8));
if (response.statusCode != 200) return null;
return jsonDecode(response.body);
} catch (_) {
return null;
}
}
/// Open-Meteo liefert bei einem einzelnen Ort ein Objekt, bei mehreren
/// kommagetrennten Koordinaten ein Array - hier auf eine einheitliche
/// Liste fester Laenge normalisiert.
List<Map<String, dynamic>?> _asResultList(dynamic decoded, int expectedCount) {
if (decoded is List) {
return List.generate(
expectedCount,
(i) => i < decoded.length ? decoded[i] as Map<String, dynamic>? : null,
);
}
if (decoded is Map<String, dynamic>) {
return [decoded, ...List<Map<String, dynamic>?>.filled(expectedCount - 1, null)];
}
return List<Map<String, dynamic>?>.filled(expectedCount, null);
}
int _nearestHourIndex(List<dynamic> time) {
final now = DateTime.now().toUtc();
var bestIdx = 0;
var bestDiffMs = double.infinity;
for (var i = 0; i < time.length; i++) {
final t = DateTime.parse('${time[i]}:00Z');
final diffMs = t.difference(now).inMilliseconds.abs().toDouble();
if (diffMs < bestDiffMs) {
bestDiffMs = diffMs;
bestIdx = i;
}
}
return bestIdx;
}
/// Vereint Druckflaechen- und feste Nabenhoehen-Daten zu einer
/// gemeinsamen Stuetzpunktliste (AGL-Hoehen der Nabenhoehen werden dazu in
/// ASL umgerechnet). Entweder Quelle darf fehlen (null); genutzt wird, was
/// vorhanden ist - analog buildCombinedWindLevels() im HTML-Demonstrator.
WindCombinedLevels buildCombinedWindLevels(
Map<String, dynamic>? pressureRes,
Map<String, dynamic>? fixedRes,
) {
final levels = <WindLevel>[];
var elevation = 0.0;
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
final pTime = pHourly?['time'] as List<dynamic>?;
if (pHourly != null && pTime != null && pTime.isNotEmpty) {
final idx = _nearestHourIndex(pTime);
elevation = (pressureRes?['elevation'] as num?)?.toDouble() ?? 0.0;
for (final p in _pressureLevels) {
final h = (pHourly['geopotential_height_${p}hPa'] as List?)?[idx];
final s = (pHourly['wind_speed_${p}hPa'] as List?)?[idx];
final d = (pHourly['wind_direction_${p}hPa'] as List?)?[idx];
if (h is num && s is num && d is num && h >= elevation) {
levels.add(WindLevel(
heightAmslM: h.toDouble(),
speedMs: s.toDouble(),
dirFromDeg: d.toDouble(),
));
}
}
}
final fHourly = fixedRes?['hourly'] as Map<String, dynamic>?;
final fTime = fHourly?['time'] as List<dynamic>?;
if (fHourly != null && fTime != null && fTime.isNotEmpty) {
final idx = _nearestHourIndex(fTime);
if (elevation == 0.0) {
elevation = (fixedRes?['elevation'] as num?)?.toDouble() ?? 0.0;
}
for (final h in _fixedHeights) {
final s = (fHourly['wind_speed_${h}m'] as List?)?[idx];
final d = (fHourly['wind_direction_${h}m'] as List?)?[idx];
if (s is num && d is num) {
levels.add(WindLevel(
heightAmslM: elevation + h,
speedMs: s.toDouble(),
dirFromDeg: d.toDouble(),
));
}
}
}
return WindCombinedLevels(levels: levels, elevationM: elevation);
}
/// Wind, interpoliert auf 120 m AGL ueber dem Referenzort (erster
/// Wegpunkt, sonst Kartenzentrum) - fuer die Kopfleisten-Pille.
Future<WindSample?> fetchHeaderWind(double lat, double lon) async {
final results = await Future.wait([
_fetchJson(_pressureUri(lat.toStringAsFixed(5), lon.toStringAsFixed(5))),
_fetchJson(_fixedUri(lat.toStringAsFixed(5), lon.toStringAsFixed(5))),
]);
final pressureRes = _asResultList(results[0], 1)[0];
final fixedRes = _asResultList(results[1], 1)[0];
final combined = buildCombinedWindLevels(pressureRes, fixedRes);
if (combined.levels.isEmpty) return null;
return interpolateWindAtHeight(combined.levels, combined.elevationM + 120);
}
/// Holt Wind fuer alle Wegpunkte in einem gebuendelten Request (Open-Meteo
/// akzeptiert kommagetrennte Koordinatenlisten) und interpoliert jeweils
/// auf die tatsaechliche Zielhoehe (Bodenhoehe + Soll-Altitude) - analog
/// fetchWindForWaypoints() im HTML-Demonstrator.
Future<List<Waypoint>> fetchWindForWaypoints(List<Waypoint> waypoints) async {
if (waypoints.isEmpty) return waypoints;
final lats = waypoints.map((w) => w.lat.toStringAsFixed(5)).join(',');
final lons = waypoints.map((w) => w.lon.toStringAsFixed(5)).join(',');
final results = await Future.wait([
_fetchJson(_pressureUri(lats, lons)),
_fetchJson(_fixedUri(lats, lons)),
]);
final pressureResults = _asResultList(results[0], waypoints.length);
final fixedResults = _asResultList(results[1], waypoints.length);
return [
for (var i = 0; i < waypoints.length; i++)
_applyWind(waypoints[i], pressureResults[i], fixedResults[i]),
];
}
Waypoint _applyWind(
Waypoint wp,
Map<String, dynamic>? pressureRes,
Map<String, dynamic>? fixedRes,
) {
final combined = buildCombinedWindLevels(pressureRes, fixedRes);
if (combined.levels.isEmpty) return wp;
final result = interpolateWindAtHeight(
combined.levels,
combined.elevationM + wp.altitudeM,
);
if (result == null) return wp;
return wp.copyWith(
windSpeedMs: result.speedMs,
windDirFromDeg: result.dirFromDeg,
windElevationM: combined.elevationM,
);
}
/// Detaillierte Rohdaten fuer das Wind-Validierungspanel: einzelne
/// Messwerte je Nabenhoehe/Druckflaeche, damit nachvollziehbar bleibt, wie
/// der interpolierte 120-m-Wert zustande kam (Doku 3.8/4.9: Boeen nur
/// hier, nicht in der Wegpunktanzeige).
Future<WindValidationResult> fetchValidation(double lat, double lon) async {
final lats = lat.toStringAsFixed(5);
final lons = lon.toStringAsFixed(5);
final results = await Future.wait([
_fetchJson(_fixedUri(lats, lons)),
_fetchJson(_pressureUri(lats, lons)),
]);
final fixedRes = _asResultList(results[0], 1)[0];
final pressureRes = _asResultList(results[1], 1)[0];
final elevation = ((fixedRes?['elevation'] as num?) ??
(pressureRes?['elevation'] as num?))
?.toDouble() ??
0.0;
var fixedReadings = <WindValidationFixedReading>[];
double? gust;
final fHourly = fixedRes?['hourly'] as Map<String, dynamic>?;
final fTime = fHourly?['time'] as List<dynamic>?;
final fixedFailed = fHourly == null || fTime == null || fTime.isEmpty;
if (!fixedFailed) {
final idx = _nearestHourIndex(fTime);
fixedReadings = [
for (final h in _fixedHeights)
WindValidationFixedReading(
heightAglM: h,
speedMs: ((fHourly['wind_speed_${h}m'] as List?)?[idx] as num?)
?.toDouble(),
dirFromDeg: ((fHourly['wind_direction_${h}m'] as List?)?[idx] as num?)
?.toDouble(),
),
];
gust = ((fHourly['wind_gusts_10m'] as List?)?[idx] as num?)?.toDouble();
}
var pressureReadings = <WindValidationPressureReading>[];
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
final pTime = pHourly?['time'] as List<dynamic>?;
final pressureFailed = pHourly == null || pTime == null || pTime.isEmpty;
if (!pressureFailed) {
final idx = _nearestHourIndex(pTime);
pressureReadings = [
for (final p in _pressureLevels)
_pressureReadingAt(pHourly, idx, p, elevation),
];
}
final combined = buildCombinedWindLevels(pressureRes, fixedRes);
final interpolated = combined.levels.isEmpty
? null
: interpolateWindAtHeight(combined.levels, elevation + 120);
return WindValidationResult(
lat: lat,
lon: lon,
elevationM: elevation,
fixedRequestFailed: fixedFailed,
fixedReadings: fixedReadings,
gustMs: gust,
pressureRequestFailed: pressureFailed,
pressureReadings: pressureReadings,
interpolatedAt120: interpolated,
);
}
WindValidationPressureReading _pressureReadingAt(
Map<String, dynamic> hourly,
int idx,
int pressureHpa,
double elevationM,
) {
final h = (hourly['geopotential_height_${pressureHpa}hPa'] as List?)?[idx];
final s = (hourly['wind_speed_${pressureHpa}hPa'] as List?)?[idx];
final d = (hourly['wind_direction_${pressureHpa}hPa'] as List?)?[idx];
final heightAmslM = h is num ? h.toDouble() : null;
final speedMs = s is num ? s.toDouble() : null;
final dirFromDeg = d is num ? d.toDouble() : null;
final excluded = heightAmslM == null ||
speedMs == null ||
dirFromDeg == null ||
heightAmslM < elevationM;
return WindValidationPressureReading(
pressureHpa: pressureHpa,
heightAmslM: heightAmslM,
speedMs: speedMs,
dirFromDeg: dirFromDeg,
excluded: excluded,
);
}
}