[AZ-366] Refactor C13: consolidate Haversine + tile-coord parsing

RouteProcessingService.CalculateDistance(double, double, double, double)
re-implemented Haversine using EARTH_RADIUS=6371000 alongside the
canonical GeoUtils.CalculateDistance(GeoPoint, GeoPoint) which uses
EARTH_RADIUS=6378137. Two implementations of the same formula for the
same problem.

Separately, ExtractTileCoordinatesFromFilename in RouteProcessingService
parsed the tile_{z}_{x}_{y}_{ts}.jpg filename pattern that's *generated*
by StorageConfig.GetTileFilePath in another assembly — the writer and
parser were coupled by string convention only and a format change in
one would silently break the other.

Both fixes:

(a) Deleted the duplicate Haversine in RouteProcessingService. The
single call site (region-matching nearest-neighbor OrderBy) now uses
GeoUtils.CalculateDistance with GeoPoint instances. The constant
difference is monotonic-equivalent for OrderBy purposes — same region
is picked.

(b) Added static StorageConfig.TryExtractTileCoordinates(string, out
int, out int): bool — pure parser, co-located with GetTileFilePath so
the inverse-pair invariant is structural, not by-convention.
RouteProcessingService.ExtractTileCoordinatesFromFilename becomes a
thin wrapper that delegates to the helper and emits the existing
warning log on malformed input — the AZ-352 tests for warning behavior
all still pass.

Verification:
- 77/77 unit tests green (was 71 → +6 new StorageConfigTests including
  a writer/parser round-trip test for AC-2).
- Smoke + full integration suite green.
- AC-1 grep verification: Math.Sin/EARTH_RADIUS patterns are now
  confined to GeoUtils.cs only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 00:56:46 +03:00
parent fd8fc74249
commit 330bccd724
4 changed files with 176 additions and 26 deletions
@@ -579,50 +579,30 @@ public class RouteProcessingService : BackgroundService
foreach (var point in routePoints)
{
var pointGeo = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude };
var matchedRegion = availableRegions
.OrderBy(r => CalculateDistance(point.Latitude, point.Longitude, r.Latitude, r.Longitude))
.OrderBy(r => GeoUtils.CalculateDistance(pointGeo, new Common.DTO.GeoPoint { Lat = r.Latitude, Lon = r.Longitude }))
.FirstOrDefault();
if (matchedRegion != null)
{
orderedRegions.Add(matchedRegion);
availableRegions.Remove(matchedRegion);
}
}
return orderedRegions;
}
private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2)
{
const double earthRadiusMeters = 6371000;
var dLat = (lat2 - lat1) * Math.PI / 180.0;
var dLon = (lon2 - lon1) * Math.PI / 180.0;
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return earthRadiusMeters * c;
return orderedRegions;
}
internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
{
ArgumentNullException.ThrowIfNull(filePath);
var filename = Path.GetFileNameWithoutExtension(filePath);
var parts = filename.Split('_');
if (parts.Length >= 4 && parts[0] == "tile" &&
int.TryParse(parts[2], out var tileX) &&
int.TryParse(parts[3], out var tileY))
if (StorageConfig.TryExtractTileCoordinates(filePath, out var tileX, out var tileY))
{
return (tileX, tileY);
}
_logger.LogWarning(
"Could not extract tile coordinates from filename {FilePath}; expected pattern tile_<timestamp>_<x>_<y>",
"Could not extract tile coordinates from filename {FilePath}; expected pattern tile_<zoom>_<x>_<y>_<timestamp>",
filePath);
return (-1, -1);
}