[AZ-364] [AZ-360] Refactor C11+C08: decompose RouteProcessingService

Extracts RouteRegionMatcher, RouteCsvWriter, RouteSummaryWriter,
RouteImageRenderer, TilesZipBuilder, RegionFileCleaner from the
~750-LOC RouteProcessingService god-class. Moves TileInfo to its
own file as a sealed record. Replaces IServiceProvider scope-
locator with a direct IRegionService injection (folds AZ-360 / C08).
Updates DI registration and tests.

Tests: 133 / 133 unit + 5 / 5 smoke green; integration suite exit 0.
Pixel-equivalent stitched route image and byte-equivalent CSV /
summary / ZIP outputs verified through the smoke run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 03:12:49 +03:00
parent 70a0a2c4d5
commit 6f23120c49
16 changed files with 1181 additions and 439 deletions
@@ -0,0 +1,66 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.DataAccess.Models;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService.CleanupRegionFilesAsync.
// Deletes per-region files (CSV, summary, stitched image) once a route has
// successfully consumed them. Each delete is best-effort: a failed delete is
// logged at warning level and does not abort the cleanup of the remaining
// files. Stitched image path is reconstructed from regionId + ReadyDirectory
// because regions historically did not always persist that path on the entity.
public class RegionFileCleaner
{
private readonly StorageConfig _storageConfig;
private readonly ILogger<RegionFileCleaner> _logger;
public RegionFileCleaner(IOptions<StorageConfig> storageConfig, ILogger<RegionFileCleaner> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public Task CleanupAsync(IEnumerable<RegionEntity> regions, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(regions);
foreach (var region in regions)
{
if (cancellationToken.IsCancellationRequested)
break;
if (region == null) continue;
DeleteIfPresent(region.CsvFilePath, "region CSV file");
DeleteIfPresent(region.SummaryFilePath, "region summary file");
var stitchedImagePath = Path.Combine(_storageConfig.ReadyDirectory, $"region_{region.Id}_stitched.jpg");
if (File.Exists(stitchedImagePath))
{
DeleteIfPresent(stitchedImagePath, "region stitched image");
}
}
return Task.CompletedTask;
}
private void DeleteIfPresent(string? path, string label)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
return;
}
try
{
File.Delete(path);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete {Label}: {FilePath}", label, path);
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.Utils;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService.GenerateRouteCsvAsync.
// Owns the route_<id>_ready.csv path computation and delegates the actual
// rows-to-CSV serialization to the shared TileCsvWriter (Common). Returns
// the produced file path so the orchestrator can persist it on the route.
public class RouteCsvWriter
{
private readonly StorageConfig _storageConfig;
private readonly ILogger<RouteCsvWriter> _logger;
public RouteCsvWriter(IOptions<StorageConfig> storageConfig, ILogger<RouteCsvWriter> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public async Task<string> WriteAsync(
Guid routeId,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tiles);
Directory.CreateDirectory(_storageConfig.ReadyDirectory);
var filePath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_ready.csv");
var rows = tiles.Select(t => new TileCsvRow(t.Latitude, t.Longitude, t.FilePath)).ToList();
await new TileCsvWriter().WriteAsync(filePath, rows, cancellationToken);
_logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, rows.Count);
return filePath;
}
}
@@ -0,0 +1,153 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Imaging;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService.StitchRouteTilesAsync.
// Owns the route_<id>_stitched.jpg path computation, the per-tile filename
// parsing into TilePlacement, the call into the shared TileGridStitcher
// (Common), and the route-specific overlays (yellow geofence rectangles,
// red 50-arm crosses on each route point). Behavior preserved verbatim per
// AC-2 (pixel-for-pixel identical output for existing scenarios).
public class RouteImageRenderer
{
private const int TileSizePixels = 256;
private readonly StorageConfig _storageConfig;
private readonly ILogger<RouteImageRenderer> _logger;
public RouteImageRenderer(IOptions<StorageConfig> storageConfig, ILogger<RouteImageRenderer> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public async Task<string?> RenderAsync(
Guid routeId,
IReadOnlyList<TileInfo> tiles,
int zoomLevel,
IReadOnlyList<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
IReadOnlyList<RoutePointEntity> routePoints,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tiles);
ArgumentNullException.ThrowIfNull(geofencePolygonBounds);
ArgumentNullException.ThrowIfNull(routePoints);
if (tiles.Count == 0)
{
_logger.LogWarning("No tiles to stitch for route map");
return null;
}
var placements = tiles
.Select(t =>
{
var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath);
return new TilePlacement(tileX, tileY, t.FilePath);
})
.Where(p => p.TileX >= 0 && p.TileY >= 0)
.ToList();
if (placements.Count == 0)
{
_logger.LogWarning("No tiles with extractable coordinates to stitch for route map");
return null;
}
Directory.CreateDirectory(_storageConfig.ReadyDirectory);
var outputPath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_stitched.jpg");
var stitcher = new TileGridStitcher();
var result = await stitcher.StitchAsync(
placements,
TileSizePixels,
deduplicateByTileCoords: true,
swallowTileLoadErrors: true,
cancellationToken);
using var stitchedImage = result.Image;
foreach (var missing in result.MissingTiles)
{
if (missing.Reason == MissingTileReason.LoadFailed)
{
_logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", missing.Tile.FilePath);
}
else
{
_logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath);
}
}
var minX = result.MinX;
var minY = result.MinY;
var imageWidth = result.ImageWidth;
var imageHeight = result.ImageHeight;
var yellow = new Rgb24(255, 255, 0);
var red = new Rgb24(255, 0, 0);
foreach (var (geoMinX, geoMinY, geoMaxX, geoMaxY) in geofencePolygonBounds)
{
var x1 = (geoMinX - minX) * TileSizePixels;
var y1 = (geoMinY - minY + 1) * TileSizePixels;
var x2 = (geoMaxX - minX + 2) * TileSizePixels - 1;
var y2 = (geoMaxY - minY + 1) * TileSizePixels - 1;
x1 = Math.Max(0, Math.Min(x1, imageWidth - 1));
y1 = Math.Max(0, Math.Min(y1, imageHeight - 1));
x2 = Math.Max(0, Math.Min(x2, imageWidth - 1));
y2 = Math.Max(0, Math.Min(y2, imageHeight - 1));
if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1)
{
stitcher.DrawRectangleBorder(
stitchedImage,
new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1),
yellow);
}
}
foreach (var point in routePoints)
{
var geoPoint = new GeoPoint(point.Latitude, point.Longitude);
var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel);
var pixelX = (tileX - minX) * TileSizePixels + TileSizePixels / 2;
var pixelY = (tileY - minY) * TileSizePixels + TileSizePixels / 2;
if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight)
{
stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50);
}
}
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
return outputPath;
}
// Parses tile_<zoom>_<x>_<y>_<timestamp>.jpg via StorageConfig.TryExtractTileCoordinates.
// On parse failure, logs a warning and returns the (-1, -1) sentinel — the
// RenderAsync pipeline filters those out before stitching. ArgumentNullException
// on null input is propagated from StorageConfig.
internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
{
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_<zoom>_<x>_<y>_<timestamp>",
filePath);
return (-1, -1);
}
}
@@ -8,6 +8,11 @@ public static class RouteManagementServiceCollectionExtensions
{
public static IServiceCollection AddRouteManagement(this IServiceCollection services)
{
services.AddSingleton<RouteCsvWriter>();
services.AddSingleton<RouteSummaryWriter>();
services.AddSingleton<RouteImageRenderer>();
services.AddSingleton<TilesZipBuilder>();
services.AddSingleton<RegionFileCleaner>();
services.AddSingleton<IRouteService, RouteService>();
services.AddHostedService<RouteProcessingService>();
return services;
@@ -1,37 +1,53 @@
using System.IO.Compression;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.Imaging;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: thin orchestrator after the god-class decomposition.
// Polls the route repository, classifies region statuses, queues region work
// via IRegionService (AZ-360 / C08 — direct dependency, no IServiceProvider),
// and dispatches output generation to the per-concern collaborators
// (RouteCsvWriter, RouteSummaryWriter, RouteImageRenderer, TilesZipBuilder,
// RegionFileCleaner, RouteRegionMatcher).
public class RouteProcessingService : BackgroundService
{
private readonly IRouteRepository _routeRepository;
private readonly IRegionRepository _regionRepository;
private readonly IServiceProvider _serviceProvider;
private readonly StorageConfig _storageConfig;
private readonly IRegionService _regionService;
private readonly RouteCsvWriter _routeCsvWriter;
private readonly RouteSummaryWriter _routeSummaryWriter;
private readonly RouteImageRenderer _routeImageRenderer;
private readonly TilesZipBuilder _tilesZipBuilder;
private readonly RegionFileCleaner _regionFileCleaner;
private readonly RouteRegionMatcher _routeRegionMatcher;
private readonly ILogger<RouteProcessingService> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
public RouteProcessingService(
IRouteRepository routeRepository,
IRegionRepository regionRepository,
IServiceProvider serviceProvider,
IOptions<StorageConfig> storageConfig,
IRegionService regionService,
RouteCsvWriter routeCsvWriter,
RouteSummaryWriter routeSummaryWriter,
RouteImageRenderer routeImageRenderer,
TilesZipBuilder tilesZipBuilder,
RegionFileCleaner regionFileCleaner,
ILogger<RouteProcessingService> logger)
{
_routeRepository = routeRepository;
_regionRepository = regionRepository;
_serviceProvider = serviceProvider;
_storageConfig = storageConfig.Value;
_regionService = regionService;
_routeCsvWriter = routeCsvWriter;
_routeSummaryWriter = routeSummaryWriter;
_routeImageRenderer = routeImageRenderer;
_tilesZipBuilder = tilesZipBuilder;
_regionFileCleaner = regionFileCleaner;
_routeRegionMatcher = new RouteRegionMatcher();
_logger = logger;
}
@@ -58,7 +74,7 @@ public class RouteProcessingService : BackgroundService
private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken)
{
var pendingRoutes = await GetRoutesWithPendingMapsAsync();
var pendingRoutes = await _routeRepository.GetRoutesWithPendingMapsAsync();
foreach (var route in pendingRoutes)
{
@@ -76,12 +92,6 @@ public class RouteProcessingService : BackgroundService
}
}
private async Task<List<(Guid Id, bool RequestMaps)>> GetRoutesWithPendingMapsAsync()
{
var routes = await _routeRepository.GetRoutesWithPendingMapsAsync();
return routes.Select(r => (r.Id, r.RequestMaps)).ToList();
}
private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken)
{
var route = await _routeRepository.GetByIdAsync(routeId);
@@ -90,7 +100,7 @@ public class RouteProcessingService : BackgroundService
_logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId);
return;
}
if (!route.RequestMaps || route.MapsReady)
{
return;
@@ -99,33 +109,30 @@ public class RouteProcessingService : BackgroundService
var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList();
var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList();
var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList();
var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList();
if (regionIdsList.Count == 0 && routePointsList.Count > 0)
{
using var scope = _serviceProvider.CreateScope();
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
foreach (var point in routePointsList)
{
var regionId = Guid.NewGuid();
await regionService.RequestRegionAsync(
await _regionService.RequestRegionAsync(
regionId,
point.Latitude,
point.Longitude,
route.RegionSizeMeters,
route.ZoomLevel,
stitchTiles: false);
await _routeRepository.LinkRouteToRegionAsync(routeId, regionId);
}
return;
}
var regions = new List<DataAccess.Models.RegionEntity>();
var regions = new List<RegionEntity>();
foreach (var regionId in allRegionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
@@ -134,106 +141,99 @@ public class RouteProcessingService : BackgroundService
regions.Add(region);
}
}
var completedRegions = regions.Where(r => r.Status == "completed").ToList();
var failedRegions = regions.Where(r => r.Status == "failed").ToList();
var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList();
var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList();
var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList();
var hasRoutePointRegions = regionIdsList.Count > 0;
var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count;
var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count;
var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions;
var activeRegions = completedRegions.Count + processingRegions.Count;
var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count;
if (hasEnoughCompleted)
{
var orderedRouteRegions = MatchRegionsToRoutePoints(routePointsList, completedRoutePointRegions, routeId);
var routeRegionIds = orderedRouteRegions.Select(r => r.Id).ToList();
var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct();
var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList();
await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken);
var orderedRouteRegions = _routeRegionMatcher.Match(routePointsList, completedRoutePointRegions);
var regionsForOutput = orderedRouteRegions.Concat(completedGeofenceRegions)
.GroupBy(r => r.Id)
.Select(g => g.First())
.ToList();
await GenerateRouteMapsAsync(routeId, route, regionsForOutput, completedGeofenceRegions, routePointsList, cancellationToken);
return;
}
if (shouldRetryFailed)
{
using var scope = _serviceProvider.CreateScope();
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
foreach (var failedRegion in failedRegions)
{
var newRegionId = Guid.NewGuid();
await regionService.RequestRegionAsync(
await _regionService.RequestRegionAsync(
newRegionId,
failedRegion.Latitude,
failedRegion.Longitude,
failedRegion.SizeMeters,
failedRegion.ZoomLevel,
stitchTiles: false);
await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId);
}
return;
}
var anyProcessing = processingRegions.Count > 0;
if (anyProcessing)
{
return;
}
_logger.LogWarning("Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}",
_logger.LogWarning(
"Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}",
routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing);
}
private async Task GenerateRouteMapsAsync(
Guid routeId,
DataAccess.Models.RouteEntity route,
IEnumerable<Guid> regionIds,
List<Guid> geofenceRegionIds,
List<DataAccess.Models.RoutePointEntity> routePoints,
RouteEntity route,
IReadOnlyList<RegionEntity> regionsForOutput,
IReadOnlyList<RegionEntity> geofenceRegions,
IReadOnlyList<RoutePointEntity> routePoints,
CancellationToken cancellationToken)
{
try
{
var readyDir = _storageConfig.ReadyDirectory;
Directory.CreateDirectory(readyDir);
var allTiles = new Dictionary<string, TileInfo>();
int totalTilesFromRegions = 0;
int duplicateTiles = 0;
foreach (var regionId in regionIds)
foreach (var region in regionsForOutput)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
if (string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
{
_logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId);
_logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, region.Id);
continue;
}
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
var lineNumber = 0;
foreach (var line in csvLines.Skip(1))
{
lineNumber++;
var parts = line.Split(',');
if (parts.Length < 3) continue;
if (!double.TryParse(parts[0], out var lat))
if (!double.TryParse(parts[0], out var lat))
{
continue;
}
if (!double.TryParse(parts[1], out var lon))
if (!double.TryParse(parts[1], out var lon))
{
continue;
}
@@ -244,12 +244,7 @@ public class RouteProcessingService : BackgroundService
if (!allTiles.ContainsKey(key))
{
allTiles[key] = new TileInfo
{
Latitude = lat,
Longitude = lon,
FilePath = filePath
};
allTiles[key] = new TileInfo(lat, lon, filePath);
}
else
{
@@ -258,65 +253,36 @@ public class RouteProcessingService : BackgroundService
}
}
var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv");
await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken);
var tileList = allTiles.Values.ToList();
var csvPath = await _routeCsvWriter.WriteAsync(routeId, tileList, cancellationToken);
string? stitchedImagePath = null;
if (route.RequestMaps)
{
var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>();
var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId);
foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key))
{
int? minX = null, minY = null, maxX = null, maxY = null;
foreach (var geofenceId in polygonRegionIds)
{
var region = await _regionRepository.GetByIdAsync(geofenceId);
if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
{
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
foreach (var line in csvLines.Skip(1))
{
var parts = line.Split(',');
if (parts.Length >= 3)
{
if (double.TryParse(parts[0], out var lat) && double.TryParse(parts[1], out var lon))
{
var tile = GeoUtils.WorldToTilePos(new Common.DTO.GeoPoint { Lat = lat, Lon = lon }, route.ZoomLevel);
minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x);
minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y);
maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x);
maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y);
}
}
}
}
}
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
{
geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
}
}
stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg");
await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken);
var geofencePolygonBounds = await ComputeGeofencePolygonBoundsAsync(routeId, route.ZoomLevel, cancellationToken);
stitchedImagePath = await _routeImageRenderer.RenderAsync(
routeId,
tileList,
route.ZoomLevel,
geofencePolygonBounds,
routePoints,
cancellationToken);
}
string? tilesZipPath = null;
if (route.CreateTilesZip)
{
tilesZipPath = Path.Combine(readyDir, $"route_{routeId}_tiles.zip");
await CreateTilesZipAsync(tilesZipPath, allTiles.Values, cancellationToken);
tilesZipPath = await _tilesZipBuilder.BuildAsync(routeId, tileList, cancellationToken);
}
var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt");
await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, tilesZipPath, cancellationToken);
var summaryPath = await _routeSummaryWriter.WriteAsync(
route,
tileList.Count,
totalTilesFromRegions,
duplicateTiles,
tilesZipPath,
cancellationToken);
route.MapsReady = true;
route.CsvFilePath = csvPath;
@@ -327,7 +293,7 @@ public class RouteProcessingService : BackgroundService
await _routeRepository.UpdateRouteAsync(route);
await CleanupRegionFilesAsync(regionIds, cancellationToken);
await _regionFileCleaner.CleanupAsync(regionsForOutput, cancellationToken);
_logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId);
}
@@ -338,313 +304,51 @@ public class RouteProcessingService : BackgroundService
}
}
private async Task CleanupRegionFilesAsync(IEnumerable<Guid> regionIds, CancellationToken cancellationToken)
{
foreach (var regionId in regionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region == null) continue;
if (!string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
{
try
{
File.Delete(region.CsvFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete region CSV file: {FilePath}", region.CsvFilePath);
}
}
if (!string.IsNullOrEmpty(region.SummaryFilePath) && File.Exists(region.SummaryFilePath))
{
try
{
File.Delete(region.SummaryFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete region summary file: {FilePath}", region.SummaryFilePath);
}
}
var readyDir = _storageConfig.ReadyDirectory;
var stitchedImagePath = Path.Combine(readyDir, $"region_{regionId}_stitched.jpg");
if (File.Exists(stitchedImagePath))
{
try
{
File.Delete(stitchedImagePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete region stitched image: {FilePath}", stitchedImagePath);
}
}
}
}
private async Task GenerateRouteCsvAsync(
string filePath,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken)
{
var rows = tiles.Select(t => new TileCsvRow(t.Latitude, t.Longitude, t.FilePath)).ToList();
await new TileCsvWriter().WriteAsync(filePath, rows, cancellationToken);
_logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, rows.Count);
}
private async Task GenerateRouteSummaryAsync(
string filePath,
DataAccess.Models.RouteEntity route,
int uniqueTiles,
int totalTilesFromRegions,
int duplicateTiles,
string? tilesZipPath,
CancellationToken cancellationToken)
{
var summary = new System.Text.StringBuilder();
summary.AppendLine("Route Maps Summary");
summary.AppendLine("==================");
summary.AppendLine($"Route ID: {route.Id}");
summary.AppendLine($"Route Name: {route.Name}");
if (!string.IsNullOrEmpty(route.Description))
{
summary.AppendLine($"Description: {route.Description}");
}
summary.AppendLine($"Total Points: {route.TotalPoints}");
summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters");
summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters");
summary.AppendLine($"Zoom Level: {route.ZoomLevel}");
summary.AppendLine();
summary.AppendLine("Tile Statistics:");
summary.AppendLine($"- Unique Tiles: {uniqueTiles}");
summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}");
summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}");
summary.AppendLine();
summary.AppendLine("Files Created:");
summary.AppendLine($"- CSV: route_{route.Id}_ready.csv");
summary.AppendLine($"- Summary: route_{route.Id}_summary.txt");
if (route.RequestMaps)
{
summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg");
}
if (tilesZipPath != null)
{
summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip");
}
summary.AppendLine();
summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
_logger.LogInformation("Route summary generated: {FilePath}", filePath);
}
private async Task StitchRouteTilesAsync(
List<TileInfo> tiles,
string outputPath,
private async Task<List<(int MinX, int MinY, int MaxX, int MaxY)>> ComputeGeofencePolygonBoundsAsync(
Guid routeId,
int zoomLevel,
List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
List<DataAccess.Models.RoutePointEntity> routePoints,
CancellationToken cancellationToken)
{
if (tiles.Count == 0)
var bounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>();
var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId);
foreach (var (_, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key))
{
_logger.LogWarning("No tiles to stitch for route map");
return;
}
int? minX = null, minY = null, maxX = null, maxY = null;
const int tileSizePixels = 256;
var placements = tiles
.Select(t =>
foreach (var geofenceId in polygonRegionIds)
{
var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath);
return new TilePlacement(tileX, tileY, t.FilePath);
})
.Where(p => p.TileX >= 0 && p.TileY >= 0)
.ToList();
if (placements.Count == 0)
{
_logger.LogWarning("No tiles with extractable coordinates to stitch for route map");
return;
}
var stitcher = new TileGridStitcher();
var result = await stitcher.StitchAsync(
placements,
tileSizePixels,
deduplicateByTileCoords: true,
swallowTileLoadErrors: true,
cancellationToken);
using var stitchedImage = result.Image;
foreach (var missing in result.MissingTiles)
{
if (missing.Reason == MissingTileReason.LoadFailed)
{
_logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", missing.Tile.FilePath);
}
else
{
_logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath);
}
}
var minX = result.MinX;
var minY = result.MinY;
var imageWidth = result.ImageWidth;
var imageHeight = result.ImageHeight;
var yellow = new Rgb24(255, 255, 0);
var red = new Rgb24(255, 0, 0);
foreach (var (geoMinX, geoMinY, geoMaxX, geoMaxY) in geofencePolygonBounds)
{
var x1 = (geoMinX - minX) * tileSizePixels;
var y1 = (geoMinY - minY + 1) * tileSizePixels;
var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1;
var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1;
x1 = Math.Max(0, Math.Min(x1, imageWidth - 1));
y1 = Math.Max(0, Math.Min(y1, imageHeight - 1));
x2 = Math.Max(0, Math.Min(x2, imageWidth - 1));
y2 = Math.Max(0, Math.Min(y2, imageHeight - 1));
if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1)
{
stitcher.DrawRectangleBorder(
stitchedImage,
new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1),
yellow);
}
}
foreach (var point in routePoints)
{
var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude };
var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel);
var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2;
var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2;
if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight)
{
stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50);
}
}
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
}
private List<DataAccess.Models.RegionEntity> MatchRegionsToRoutePoints(
List<DataAccess.Models.RoutePointEntity> routePoints,
List<DataAccess.Models.RegionEntity> regions,
Guid routeId)
{
var orderedRegions = new List<DataAccess.Models.RegionEntity>();
var availableRegions = new List<DataAccess.Models.RegionEntity>(regions);
foreach (var point in routePoints)
{
var pointGeo = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude };
var matchedRegion = availableRegions
.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;
}
internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
{
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_<zoom>_<x>_<y>_<timestamp>",
filePath);
return (-1, -1);
}
private Task CreateTilesZipAsync(
string zipFilePath,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken)
{
return Task.Run(() =>
{
if (File.Exists(zipFilePath))
{
File.Delete(zipFilePath);
}
using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create);
int addedFiles = 0;
int missingFiles = 0;
var tilesBasePath = _storageConfig.TilesDirectory;
var normalizedBasePath = Path.GetFullPath(tilesBasePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (var tile in tiles)
{
if (cancellationToken.IsCancellationRequested)
break;
if (File.Exists(tile.FilePath))
var region = await _regionRepository.GetByIdAsync(geofenceId);
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
{
try
{
var fullPath = Path.GetFullPath(tile.FilePath);
string entryName;
if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase))
{
var relativePath = fullPath.Substring(normalizedBasePath.Length + 1);
relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/');
entryName = "tiles/" + relativePath;
}
else
{
entryName = "tiles/" + Path.GetFileName(tile.FilePath);
}
zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal);
addedFiles++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath);
}
continue;
}
else
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
foreach (var line in csvLines.Skip(1))
{
_logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath);
missingFiles++;
var parts = line.Split(',');
if (parts.Length < 3) continue;
if (!double.TryParse(parts[0], out var lat) || !double.TryParse(parts[1], out var lon))
{
continue;
}
var tile = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), zoomLevel);
minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x);
minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y);
maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x);
maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y);
}
}
_logger.LogInformation("Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)",
zipFilePath, addedFiles, missingFiles);
}, cancellationToken);
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
{
bounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
}
}
return bounds;
}
}
public class TileInfo
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string FilePath { get; set; } = string.Empty;
}
@@ -0,0 +1,39 @@
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService.MatchRegionsToRoutePoints.
// Pure: given a list of route points and the regions known to be completed,
// return the regions ordered to match the route point sequence using nearest-
// neighbour Haversine distance. Each region is consumed at most once.
public class RouteRegionMatcher
{
public List<RegionEntity> Match(
IReadOnlyList<RoutePointEntity> routePoints,
IReadOnlyList<RegionEntity> regions)
{
ArgumentNullException.ThrowIfNull(routePoints);
ArgumentNullException.ThrowIfNull(regions);
var orderedRegions = new List<RegionEntity>(routePoints.Count);
var availableRegions = new List<RegionEntity>(regions);
foreach (var point in routePoints)
{
var pointGeo = new GeoPoint(point.Latitude, point.Longitude);
var matchedRegion = availableRegions
.OrderBy(r => GeoUtils.CalculateDistance(pointGeo, new GeoPoint(r.Latitude, r.Longitude)))
.FirstOrDefault();
if (matchedRegion != null)
{
orderedRegions.Add(matchedRegion);
availableRegions.Remove(matchedRegion);
}
}
return orderedRegions;
}
}
@@ -0,0 +1,76 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.DataAccess.Models;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService.GenerateRouteSummaryAsync.
// Owns the route_<id>_summary.txt path and the StringBuilder block that
// describes the route, tile statistics, and produced files. Output content
// preserved verbatim to satisfy AC-2 (byte-identical for existing scenarios).
public class RouteSummaryWriter
{
private readonly StorageConfig _storageConfig;
private readonly ILogger<RouteSummaryWriter> _logger;
public RouteSummaryWriter(IOptions<StorageConfig> storageConfig, ILogger<RouteSummaryWriter> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public async Task<string> WriteAsync(
RouteEntity route,
int uniqueTiles,
int totalTilesFromRegions,
int duplicateTiles,
string? tilesZipPath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(route);
Directory.CreateDirectory(_storageConfig.ReadyDirectory);
var filePath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{route.Id}_summary.txt");
var summary = new StringBuilder();
summary.AppendLine("Route Maps Summary");
summary.AppendLine("==================");
summary.AppendLine($"Route ID: {route.Id}");
summary.AppendLine($"Route Name: {route.Name}");
if (!string.IsNullOrEmpty(route.Description))
{
summary.AppendLine($"Description: {route.Description}");
}
summary.AppendLine($"Total Points: {route.TotalPoints}");
summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters");
summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters");
summary.AppendLine($"Zoom Level: {route.ZoomLevel}");
summary.AppendLine();
summary.AppendLine("Tile Statistics:");
summary.AppendLine($"- Unique Tiles: {uniqueTiles}");
summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}");
summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}");
summary.AppendLine();
summary.AppendLine("Files Created:");
summary.AppendLine($"- CSV: route_{route.Id}_ready.csv");
summary.AppendLine($"- Summary: route_{route.Id}_summary.txt");
if (route.RequestMaps)
{
summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg");
}
if (tilesZipPath != null)
{
summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip");
}
summary.AppendLine();
summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
_logger.LogInformation("Route summary generated: {FilePath}", filePath);
return filePath;
}
}
@@ -0,0 +1,7 @@
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService where it lived as a
// trailing public class. Carries the per-tile information that the route
// pipeline accumulates from per-region CSVs and feeds into RouteCsvWriter,
// RouteImageRenderer, and TilesZipBuilder.
public sealed record TileInfo(double Latitude, double Longitude, string FilePath);
@@ -0,0 +1,95 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
namespace SatelliteProvider.Services.RouteManagement;
// AZ-364 / C11: extracted from RouteProcessingService.CreateTilesZipAsync.
// Owns the route_<id>_tiles.zip path computation and the entry-name
// resolution (relative to StorageConfig.TilesDirectory when the tile lives
// under that root, or by file name otherwise). Behavior preserved verbatim:
// existing zip is overwritten, missing tiles are logged but not fatal.
public class TilesZipBuilder
{
private readonly StorageConfig _storageConfig;
private readonly ILogger<TilesZipBuilder> _logger;
public TilesZipBuilder(IOptions<StorageConfig> storageConfig, ILogger<TilesZipBuilder> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public Task<string> BuildAsync(
Guid routeId,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tiles);
Directory.CreateDirectory(_storageConfig.ReadyDirectory);
var zipFilePath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_tiles.zip");
return Task.Run(() =>
{
if (File.Exists(zipFilePath))
{
File.Delete(zipFilePath);
}
using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create);
int addedFiles = 0;
int missingFiles = 0;
var tilesBasePath = _storageConfig.TilesDirectory;
var normalizedBasePath = Path.GetFullPath(tilesBasePath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (var tile in tiles)
{
if (cancellationToken.IsCancellationRequested)
break;
if (File.Exists(tile.FilePath))
{
try
{
var fullPath = Path.GetFullPath(tile.FilePath);
string entryName;
if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase))
{
var relativePath = fullPath.Substring(normalizedBasePath.Length + 1);
relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/');
entryName = "tiles/" + relativePath;
}
else
{
entryName = "tiles/" + Path.GetFileName(tile.FilePath);
}
zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal);
addedFiles++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath);
}
}
else
{
_logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath);
missingFiles++;
}
}
_logger.LogInformation(
"Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)",
zipFilePath, addedFiles, missingFiles);
return zipFilePath;
}, cancellationToken);
}
}