Add terrain/elevation data fetching (Doku 3.9)

Rundet das Altitude-Tab um ein reales Gelaendeprofil ab, damit sichtbar
wird, ob die geplante Flughoehe ausreichend Bodenabstand haelt:

- domain/mission/terrain_math.dart: reine Sampling-/Geometrie-Funktionen
  (routeKeyForTerrain fuer Caching, distanzbasiertes Sampling alle 30m,
  lineare Sollhoehen-Interpolation, terrainDangerRanges fuer Abschnitte
  mit < 10m Bodenabstand) - unabhaengig testbar ohne Netzwerk/UI.
- services/terrain/terrain_service.dart: laedt AWS-Terrarium-PNG-Kacheln
  (elevation-tiles-prod, zoom 13) und dekodiert sie ueber dart:ui
  (instantiateImageCodec/toByteData), ohne zusaetzliches Bildpaket.
  Elevation = R*256 + G + B/256 - 32768 pro Pixel; Kacheln werden pro
  Route nur einmal geladen (nach Kachel gruppierte Sample-Punkte).
- ui/providers/terrain_provider.dart: cached das geladene Profil ueber
  routeKeyForTerrain - reine Werteaenderungen (Hoehe/Speed) loesen keinen
  erneuten Kachel-Download aus, nur eine tatsaechliche Ortsverschiebung.
- ui/widgets/full_value_chart.dart: _paintDangerBands/_paintTerrain
  zeichnen rote Gefahrenbaender bzw. die braune Gelaende-Flaeche, in der
  gleichen Reihenfolge wie im HTML-Demonstrator (dangerHtml, grid,
  terrainHtml, dann Soll-Kurve obenauf).
- ui/widgets/waypoint_list_panel.dart: triggert ensureFor() beim
  Wechsel auf den Altitude-Tab sowie bei Routenaenderungen waehrend
  dieser aktiv ist (ref.listenManual auf currentMissionProvider).

Getestet: 13 neue Unit-Tests fuer die reine Geometrie/Sampling-Logik,
3 Service-Tests mit einer zur Testzeit synthetisch erzeugten PNG
(dart:ui-Encoding, keine Testasset-Datei noetig). Alle 80 Tests sowie
flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert:
Altitude-Tab zeigt das reale Amsterdam-Gelaendeprofil (nahe Meereshoehe)
korrekt als Flaeche unter der Sollhoehen-Linie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-28 18:38:23 +02:00
co-authored by Claude Sonnet 5
parent 5505bb3741
commit b176ad294e
7 changed files with 692 additions and 20 deletions
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/domain/mission/terrain_math.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
void main() {
group('routeKeyForTerrain', () {
test('aendert sich, wenn sich ein Wegpunkt verschiebt', () {
const a = [
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
const b = [
Waypoint(lat: 52.001, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
expect(routeKeyForTerrain(a), isNot(routeKeyForTerrain(b)));
});
test('bleibt gleich bei reinen Wertaenderungen (Hoehe/Geschwindigkeit)', () {
const a = [
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
const b = [
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 120, speedMs: 20, catchRadiusM: 60),
];
expect(routeKeyForTerrain(a), routeKeyForTerrain(b));
});
});
group('sampleCountFor', () {
test('klemmt sehr kurze Routen auf mindestens 10 Samples', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0.0001, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
expect(sampleCountFor(waypoints), 10);
});
test('folgt der 30-m-Sampling-Distanz fuer laengere Routen', () {
// 1 Grad Breite ~ 111.320 km -> 0.01 Grad ~ 1113 m.
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0.01, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
final count = sampleCountFor(waypoints);
expect(count, greaterThan(10));
expect(count, closeTo(1113 / terrainSampleSpacingM, 2));
});
test('liefert 0 fuer eine einzelne Wegpunkt-Route', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
expect(sampleCountFor(waypoints), 0);
});
});
group('sampleRouteByDistance', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0, lon: 0.01, altitudeM: 80, speedMs: 15, catchRadiusM: 60),
];
test('erstes und letztes Sample entsprechen den Wegpunkten', () {
final samples = sampleRouteByDistance(waypoints, 10);
expect(samples, hasLength(11));
expect(samples.first.distanceM, 0);
expect(samples.first.point.latitude, closeTo(0, 1e-9));
expect(samples.first.point.longitude, closeTo(0, 1e-9));
expect(samples.last.point.longitude, closeTo(0.01, 1e-9));
});
test('Samples sind gleichmaessig nach Distanz verteilt', () {
final samples = sampleRouteByDistance(waypoints, 4);
final total = samples.last.distanceM;
for (var i = 0; i < samples.length; i++) {
expect(samples[i].distanceM, closeTo(total * i / 4, 1e-6));
}
});
});
group('plannedAltitudeAt', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0, lon: 0.01, altitudeM: 120, speedMs: 15, catchRadiusM: 60),
];
test('interpoliert linear zwischen zwei Wegpunkten', () {
final total = sampleRouteByDistance(waypoints, 1).last.distanceM;
expect(plannedAltitudeAt(waypoints, total / 2), closeTo(90, 0.5));
});
test('klemmt vor dem ersten und nach dem letzten Wegpunkt', () {
expect(plannedAltitudeAt(waypoints, -100), 60);
final total = sampleRouteByDistance(waypoints, 1).last.distanceM;
expect(plannedAltitudeAt(waypoints, total + 1000), 120);
});
});
group('terrainDangerRanges', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0, lon: 0.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
test('meldet keinen gefaehrlichen Bereich bei ausreichendem Abstand', () {
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: 0),
TerrainSamplePoint(distanceM: 500, elevationRelM: 0),
TerrainSamplePoint(distanceM: 1000, elevationRelM: 0),
]);
expect(terrainDangerRanges(waypoints, profile), isEmpty);
});
test('markiert den Bereich, in dem der Bodenabstand unterschritten wird', () {
// Geplante Hoehe konstant 60m; Clearance = 60 - elevRel.
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: 0), // clearance 60
TerrainSamplePoint(distanceM: 300, elevationRelM: 55), // clearance 5 (< 10)
TerrainSamplePoint(distanceM: 600, elevationRelM: 52), // clearance 8 (< 10)
TerrainSamplePoint(distanceM: 900, elevationRelM: 0), // clearance 60
]);
final ranges = terrainDangerRanges(waypoints, profile);
expect(ranges, [(300.0, 900.0)]);
});
test('ignoriert Samples ohne geladene Hoehe', () {
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: null),
TerrainSamplePoint(distanceM: 500),
]);
expect(terrainDangerRanges(waypoints, profile), isEmpty);
});
test('schliesst einen bis zum letzten Sample andauernden Bereich ab', () {
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: 55), // clearance 5
TerrainSamplePoint(distanceM: 500, elevationRelM: 55),
]);
expect(terrainDangerRanges(waypoints, profile), [(0.0, 500.0)]);
});
});
}