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)]);
});
});
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
import 'package:dmc_app/services/terrain/terrain_service.dart';
/// Baut eine 256x256-PNG, deren Pixel alle dieselbe Terrarium-kodierte
/// Hoehe tragen - reicht aus, um die komplette Pipeline (HTTP-Fetch,
/// PNG-Decodierung, Terrarium-Formel, Relativierung) zu pruefen, ohne dass
/// die exakte Pixelposition innerhalb der Kachel eine Rolle spielt.
Future<Uint8List> _uniformElevationPng(int r, int g, int b) async {
const size = 256;
final pixels = Uint8List(size * size * 4);
for (var i = 0; i < size * size; i++) {
pixels[i * 4] = r;
pixels[i * 4 + 1] = g;
pixels[i * 4 + 2] = b;
pixels[i * 4 + 3] = 255;
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
pixels,
size,
size,
ui.PixelFormat.rgba8888,
completer.complete,
);
final image = await completer.future;
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const waypoints = [
Waypoint(lat: 48.1351, lon: 11.5820, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 48.1360, lon: 11.5830, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
test('fetchProfile decodiert die Terrarium-PNG in relative Hoehen', () async {
// R=128, G=100, B=0 -> (128*256 + 100 + 0/256) - 32768 = 100.
final pngBytes = await _uniformElevationPng(128, 100, 0);
final client = MockClient((request) async => http.Response.bytes(pngBytes, 200));
final service = TerrainService(client: client);
final profile = await service.fetchProfile(waypoints);
expect(profile, isNotNull);
expect(profile!.points, isNotEmpty);
// Gleichfoermiges Gelaende: jedes Sample hat dieselbe absolute Hoehe
// wie das erste gueltige -> relative Hoehe ueberall 0.
for (final p in profile.points) {
expect(p.elevationRelM, isNotNull);
expect(p.elevationRelM, closeTo(0, 0.01));
}
expect(profile.points.first.distanceM, 0);
});
test('fetchProfile liefert null, wenn keine Kachel geladen werden kann', () async {
final client = MockClient((request) async => http.Response('', 500));
final service = TerrainService(client: client);
expect(await service.fetchProfile(waypoints), isNull);
});
test('fetchProfile liefert null fuer eine einzelne Wegpunkt-Route', () async {
final client = MockClient((request) async => http.Response('', 500));
final service = TerrainService(client: client);
expect(await service.fetchProfile([waypoints.first]), isNull);
});
}