fix(tactical): implement dynamic MGRS grid conversion, triangle of error resection accuracy, clean surveyed IDW elevation, and robust offline sync validation

This commit is contained in:
Hamza-Ayed
2026-09-14 14:21:30 +03:00
parent 17e8f8eeac
commit aa2b9f131f
4 changed files with 253 additions and 41 deletions
@@ -3,11 +3,13 @@ import '../models/landmark.dart';
class ResectionCalculator {
static const double earthRadiusKm = 6371.0;
static const double magneticDeclinationJordan = 4.8; // +4.8° East avg in Jordan
static const double magneticDeclinationJordan = 5.5; // +5.5° East avg in Jordan
/// Calculates true azimuth from magnetic compass heading.
static double getTrueAzimuth(double magneticHeadingDeg) {
return (magneticHeadingDeg + magneticDeclinationJordan) % 360.0;
/// When Magnetic North is East of True North (Jordan ~ +5.5° East),
/// True Grid Azimuth = (Magnetic Heading - Declination).
static double getTrueAzimuth(double magneticHeadingDeg, [double customOffset = 5.5]) {
return (magneticHeadingDeg - customOffset + 360.0) % 360.0;
}
/// Calculates observer position from 2 or more landmark observations using triangulation resection.
@@ -55,8 +57,14 @@ class ResectionCalculator {
var calcLng = xIntersect / cosLat;
var calcLat = yIntersect;
// If 3rd landmark observation exists, calculate centroid / weighted least-squares refinement
double estimatedAccuracy = 50.0; // Base 50m accuracy for 2 landmarks
// Base angular GDOP for 2 landmarks based on cut angle (sin(det)) and landmark distances
final cutAngleSin = det.abs();
final distADeg = math.sqrt(math.pow((xIntersect - xA), 2) + math.pow((yIntersect - yA), 2));
final distBDeg = math.sqrt(math.pow((xIntersect - xB), 2) + math.pow((yIntersect - yB), 2));
final approxDistMeters = ((distADeg + distBDeg) / 2.0) * 111320.0;
// Compass measurement uncertainty (~1.5 deg = ~0.026 rad) scaled by GDOP (1 / sin(cutAngle))
double estimatedAccuracy = math.max(10.0, (approxDistMeters * 0.026) / math.max(0.2, cutAngleSin));
if (observations.length >= 3) {
final obsC = observations[2];
@@ -70,19 +78,37 @@ class ResectionCalculator {
// Solve intersection of B and C
final detBC = sinB * cosC - cosB * sinC;
if (detBC.abs() >= 0.0001) {
// Solve intersection of A and C
final detAC = sinA * cosC - cosA * sinC;
if (detBC.abs() >= 0.0001 && detAC.abs() >= 0.0001) {
final dxBC = xC - xB;
final dyBC = yC - yB;
final tB = (dxBC * cosC - dyBC * sinC) / detBC;
final xIntersectBC = xB + tB * sinB;
final yIntersectBC = yB + tB * cosB;
final xBC = xB + tB * sinB;
final yBC = yB + tB * cosB;
// Weighted centroid average of the triangle of error
calcLng = ((xIntersect + xIntersectBC) / 2.0) / cosLat;
calcLat = (yIntersect + yIntersectBC) / 2.0;
final dxAC = xC - xA;
final dyAC = yC - yA;
final tAc = (dxAC * cosC - dyAC * sinC) / detAC;
final xAC = xA + tAc * sinA;
final yAC = yA + tAc * cosA;
// 3 sightings tighten the error ellipse to ~20-30 meters
estimatedAccuracy = 25.0;
// Centroid of the classic military "Triangle of Error" (مثلث الخطأ)
final xCentroid = (xIntersect + xBC + xAC) / 3.0;
final yCentroid = (yIntersect + yBC + yAC) / 3.0;
calcLng = xCentroid / cosLat;
calcLat = yCentroid;
// Compute actual radius of Triangle of Error in meters
final d1 = math.sqrt(math.pow(xIntersect - xCentroid, 2) + math.pow(yIntersect - yCentroid, 2));
final d2 = math.sqrt(math.pow(xBC - xCentroid, 2) + math.pow(yBC - yCentroid, 2));
final d3 = math.sqrt(math.pow(xAC - xCentroid, 2) + math.pow(yAC - yCentroid, 2));
final triangleRadiusMeters = ((d1 + d2 + d3) / 3.0) * 111320.0;
// Estimated accuracy is bound by triangle size + residual optical error
estimatedAccuracy = math.max(5.0, double.parse(triangleRadiusMeters.toStringAsFixed(1)));
}
}
@@ -131,4 +157,72 @@ class ResectionCalculator {
final bearingRad = math.atan2(y, x);
return (bearingRad * (180.0 / math.pi) + 360.0) % 360.0;
}
/// Single Landmark Rangefinder (Parallax Triangulation from baseline step-off)
/// حساب المسافة والإحداثيات من معلم جغرافي وحيد بالتحرك على خط أساس
///
/// [landmark]: المعلم الجغرافي المرصود
/// [azimuth1Deg]: زاوية الرصد الأولى (بالدرجات)
/// [azimuth2Deg]: زاوية الرصد الثانية بعد التحرك (بالدرجات)
/// [baselineMeters]: مسافة التحرك العمودي على خط النظر (مثلاً 10م، 11م، 20م، 50م)
static ResectionResult? calculateSingleLandmarkPolar({
required TacticalLandmark landmark,
required double azimuth1Deg,
required double azimuth2Deg,
required double baselineMeters,
}) {
final trueAzimuth1 = getTrueAzimuth(azimuth1Deg);
final trueAzimuth2 = getTrueAzimuth(azimuth2Deg);
// Angular parallax difference in degrees
var deltaAngleDeg = (trueAzimuth2 - trueAzimuth1).abs();
if (deltaAngleDeg > 180.0) {
deltaAngleDeg = 360.0 - deltaAngleDeg;
}
if (deltaAngleDeg < 0.05) {
return null;
}
final deltaAngleRad = deltaAngleDeg * (math.pi / 180.0);
// Exact trigonometric range: D = Baseline / tan(deltaAngle)
final distanceMeters = baselineMeters / math.tan(deltaAngleRad);
final distanceKm = distanceMeters / 1000.0;
// Project observer position from Landmark along Back-Azimuth
final backBearingDeg = (trueAzimuth1 + 180.0) % 360.0;
final backBearingRad = backBearingDeg * (math.pi / 180.0);
final latRad = landmark.lat * (math.pi / 180.0);
final angularDist = distanceKm / earthRadiusKm;
final obsLatRad = math.asin(
math.sin(latRad) * math.cos(angularDist) +
math.cos(latRad) * math.sin(angularDist) * math.cos(backBearingRad)
);
final obsLngRad = (landmark.lng * (math.pi / 180.0)) +
math.atan2(
math.sin(backBearingRad) * math.sin(angularDist) * math.cos(latRad),
math.cos(angularDist) - math.sin(latRad) * math.sin(obsLatRad)
);
final calcLat = obsLatRad * (180.0 / math.pi);
final calcLng = obsLngRad * (180.0 / math.pi);
final obs = ResectionObservation(
landmark: landmark,
observedAzimuthDeg: azimuth1Deg,
trueAzimuthDeg: trueAzimuth1,
);
return ResectionResult(
lat: double.parse(calcLat.toStringAsFixed(6)),
lng: double.parse(calcLng.toStringAsFixed(6)),
estimatedAccuracyMeters: math.max(15.0, distanceMeters * 0.03),
observations: [obs],
distanceToLandmarksKm: {landmark.id: distanceKm},
);
}
}