mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 08:21:14 +00:00
[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user