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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7a26002a63
commit
1b13c45cac
@@ -0,0 +1,358 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user