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:
co-authored by
Claude Sonnet 5
parent
5505bb3741
commit
b176ad294e
@@ -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)]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user