diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index bc49e69..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index e8671cc..8f5cb90 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ logs/ Content/ .env tiles/ -ready/ \ No newline at end of file +ready/ +.DS_Store \ No newline at end of file diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index 8c1d805..b898dac 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -36,6 +36,7 @@ builder.Services.AddSingleton(sp => new RegionRequestQueue( builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => diff --git a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs index 3ad5680..b6b3c8b 100644 --- a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs +++ b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs @@ -8,5 +8,6 @@ public class CreateRouteRequest public double RegionSizeMeters { get; set; } public int ZoomLevel { get; set; } public List Points { get; set; } = new(); + public bool RequestMaps { get; set; } = false; } diff --git a/SatelliteProvider.Common/DTO/RouteResponse.cs b/SatelliteProvider.Common/DTO/RouteResponse.cs index 579ef1a..d68b2b1 100644 --- a/SatelliteProvider.Common/DTO/RouteResponse.cs +++ b/SatelliteProvider.Common/DTO/RouteResponse.cs @@ -10,6 +10,11 @@ public class RouteResponse public double TotalDistanceMeters { get; set; } public int TotalPoints { get; set; } public List Points { get; set; } = new(); + public bool RequestMaps { get; set; } + public bool MapsReady { get; set; } + public string? CsvFilePath { get; set; } + public string? SummaryFilePath { get; set; } + public string? StitchedImagePath { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.DataAccess/Migrations/007_AddRouteMapFields.sql b/SatelliteProvider.DataAccess/Migrations/007_AddRouteMapFields.sql new file mode 100644 index 0000000..bf94254 --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/007_AddRouteMapFields.sql @@ -0,0 +1,7 @@ +ALTER TABLE routes +ADD COLUMN request_maps BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN maps_ready BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN csv_file_path VARCHAR(500), +ADD COLUMN summary_file_path VARCHAR(500), +ADD COLUMN stitched_image_path VARCHAR(500); + diff --git a/SatelliteProvider.DataAccess/Models/RouteEntity.cs b/SatelliteProvider.DataAccess/Models/RouteEntity.cs index 37cb1ce..9960dab 100644 --- a/SatelliteProvider.DataAccess/Models/RouteEntity.cs +++ b/SatelliteProvider.DataAccess/Models/RouteEntity.cs @@ -9,6 +9,11 @@ public class RouteEntity public int ZoomLevel { get; set; } public double TotalDistanceMeters { get; set; } public int TotalPoints { get; set; } + public bool RequestMaps { get; set; } + public bool MapsReady { get; set; } + public string? CsvFilePath { get; set; } + public string? SummaryFilePath { get; set; } + public string? StitchedImagePath { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs index 5f7f845..a119ffe 100644 --- a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs @@ -12,5 +12,6 @@ public interface IRouteRepository Task DeleteRouteAsync(Guid id); Task LinkRouteToRegionAsync(Guid routeId, Guid regionId); Task> GetRegionIdsByRouteAsync(Guid routeId); + Task> GetRoutesWithPendingMapsAsync(); } diff --git a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs index 6aa9ee0..227e314 100644 --- a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs @@ -19,7 +19,10 @@ public class RouteRepository : IRouteRepository const string sql = @" SELECT id, name, description, region_size_meters as RegionSizeMeters, zoom_level as ZoomLevel, total_distance_meters as TotalDistanceMeters, - total_points as TotalPoints, created_at as CreatedAt, updated_at as UpdatedAt + total_points as TotalPoints, request_maps as RequestMaps, + maps_ready as MapsReady, csv_file_path as CsvFilePath, + summary_file_path as SummaryFilePath, stitched_image_path as StitchedImagePath, + created_at as CreatedAt, updated_at as UpdatedAt FROM routes WHERE id = @Id"; @@ -45,9 +48,13 @@ public class RouteRepository : IRouteRepository using var connection = new NpgsqlConnection(_connectionString); const string sql = @" INSERT INTO routes (id, name, description, region_size_meters, zoom_level, - total_distance_meters, total_points, created_at, updated_at) + total_distance_meters, total_points, request_maps, maps_ready, + csv_file_path, summary_file_path, stitched_image_path, + created_at, updated_at) VALUES (@Id, @Name, @Description, @RegionSizeMeters, @ZoomLevel, - @TotalDistanceMeters, @TotalPoints, @CreatedAt, @UpdatedAt) + @TotalDistanceMeters, @TotalPoints, @RequestMaps, @MapsReady, + @CsvFilePath, @SummaryFilePath, @StitchedImagePath, + @CreatedAt, @UpdatedAt) RETURNING id"; return await connection.ExecuteScalarAsync(sql, route); @@ -76,6 +83,11 @@ public class RouteRepository : IRouteRepository zoom_level = @ZoomLevel, total_distance_meters = @TotalDistanceMeters, total_points = @TotalPoints, + request_maps = @RequestMaps, + maps_ready = @MapsReady, + csv_file_path = @CsvFilePath, + summary_file_path = @SummaryFilePath, + stitched_image_path = @StitchedImagePath, updated_at = @UpdatedAt WHERE id = @Id"; @@ -110,5 +122,21 @@ public class RouteRepository : IRouteRepository return await connection.QueryAsync(sql, new { RouteId = routeId }); } + + public async Task> GetRoutesWithPendingMapsAsync() + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + SELECT id, name, description, region_size_meters as RegionSizeMeters, + zoom_level as ZoomLevel, total_distance_meters as TotalDistanceMeters, + total_points as TotalPoints, request_maps as RequestMaps, + maps_ready as MapsReady, csv_file_path as CsvFilePath, + summary_file_path as SummaryFilePath, stitched_image_path as StitchedImagePath, + created_at as CreatedAt, updated_at as UpdatedAt + FROM routes + WHERE request_maps = true AND maps_ready = false"; + + return await connection.QueryAsync(sql); + } } diff --git a/SatelliteProvider.IntegrationTests/Models.cs b/SatelliteProvider.IntegrationTests/Models.cs index e50464d..371e97d 100644 --- a/SatelliteProvider.IntegrationTests/Models.cs +++ b/SatelliteProvider.IntegrationTests/Models.cs @@ -58,6 +58,7 @@ public class CreateRouteRequest public double RegionSizeMeters { get; set; } public int ZoomLevel { get; set; } public List Points { get; set; } = new(); + public bool RequestMaps { get; set; } = false; } public class RoutePointModel @@ -80,6 +81,11 @@ public class RouteResponseModel public double TotalDistanceMeters { get; set; } public int TotalPoints { get; set; } public List Points { get; set; } = new(); + public bool RequestMaps { get; set; } + public bool MapsReady { get; set; } + public string? CsvFilePath { get; set; } + public string? SummaryFilePath { get; set; } + public string? StitchedImagePath { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.IntegrationTests/Program.cs b/SatelliteProvider.IntegrationTests/Program.cs index bf4e64f..9ee57e4 100644 --- a/SatelliteProvider.IntegrationTests/Program.cs +++ b/SatelliteProvider.IntegrationTests/Program.cs @@ -34,6 +34,8 @@ class Program await RouteTests.RunSimpleRouteTest(httpClient); + await RouteTests.RunRouteWithRegionProcessingAndStitching(httpClient); + Console.WriteLine(); Console.WriteLine("========================="); Console.WriteLine("All tests completed successfully!"); diff --git a/SatelliteProvider.IntegrationTests/RouteTests.cs b/SatelliteProvider.IntegrationTests/RouteTests.cs index e57c932..dd82d9a 100644 --- a/SatelliteProvider.IntegrationTests/RouteTests.cs +++ b/SatelliteProvider.IntegrationTests/RouteTests.cs @@ -117,5 +117,171 @@ public static class RouteTests Console.WriteLine(); Console.WriteLine("Simple Route Test: PASSED"); } + + public static async Task RunRouteWithRegionProcessingAndStitching(HttpClient httpClient) + { + Console.WriteLine(); + Console.WriteLine("Test: Route with Region Processing and Full Map Stitching (Service-Level)"); + Console.WriteLine("==========================================================================="); + Console.WriteLine(); + + var routeId = Guid.NewGuid(); + var request = new CreateRouteRequest + { + Id = routeId, + Name = "Route with Region Processing", + Description = "Test route that processes regions for all points and stitches a full map", + RegionSizeMeters = 300.0, + ZoomLevel = 18, + RequestMaps = true, + Points = new List + { + new() { Latitude = 48.276067180586544, Longitude = 37.38445758819581 }, + new() { Latitude = 48.27074009522731, Longitude = 37.374029159545906 } + } + }; + + Console.WriteLine("Step 1: Creating route with RequestMaps=true"); + Console.WriteLine($" Start: ({request.Points[0].Latitude}, {request.Points[0].Longitude})"); + Console.WriteLine($" End: ({request.Points[1].Latitude}, {request.Points[1].Longitude})"); + Console.WriteLine($" Region Size: {request.RegionSizeMeters}m"); + Console.WriteLine($" Zoom Level: {request.ZoomLevel}"); + Console.WriteLine($" Request Maps: {request.RequestMaps}"); + Console.WriteLine(); + + var routeResponse = await httpClient.PostAsJsonAsync("/api/satellite/route", request); + + if (!routeResponse.IsSuccessStatusCode) + { + var errorContent = await routeResponse.Content.ReadAsStringAsync(); + throw new Exception($"Route creation failed: {routeResponse.StatusCode} - {errorContent}"); + } + + var route = await routeResponse.Content.ReadFromJsonAsync(JsonOptions); + + if (route == null) + { + throw new Exception("No route data returned"); + } + + Console.WriteLine($"✓ Route created with {route.TotalPoints} points"); + Console.WriteLine($" Distance: {route.TotalDistanceMeters:F2}m"); + Console.WriteLine($" Request Maps: {route.RequestMaps}"); + Console.WriteLine($" Maps Ready: {route.MapsReady}"); + Console.WriteLine(); + + if (!route.RequestMaps) + { + throw new Exception("Expected RequestMaps to be true"); + } + + if (route.MapsReady) + { + throw new Exception("Expected MapsReady to be false initially"); + } + + Console.WriteLine("Step 2: Waiting for route maps to be ready"); + Console.WriteLine(" (Service is processing regions and stitching automatically)"); + Console.WriteLine(); + + RouteResponseModel? finalRoute = null; + int maxAttempts = 180; + int pollInterval = 3000; + + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + await Task.Delay(pollInterval); + + var getResponse = await httpClient.GetAsync($"/api/satellite/route/{routeId}"); + + if (!getResponse.IsSuccessStatusCode) + { + throw new Exception($"Failed to get route status: {getResponse.StatusCode}"); + } + + var currentRoute = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + + if (currentRoute == null) + { + throw new Exception("No route returned"); + } + + if (currentRoute.MapsReady) + { + finalRoute = currentRoute; + Console.WriteLine($"✓ Route maps ready in approximately {attempt * pollInterval / 1000}s"); + break; + } + + if (attempt % 5 == 0) + { + Console.WriteLine($" Waiting... (attempt {attempt + 1}/{maxAttempts})"); + } + + if (attempt == maxAttempts - 1) + { + throw new Exception($"Timeout: Route maps did not become ready in {maxAttempts * pollInterval / 1000}s"); + } + } + + if (finalRoute == null) + { + throw new Exception("Final route is null"); + } + + Console.WriteLine(); + + Console.WriteLine("Step 3: Verifying generated files"); + + if (string.IsNullOrEmpty(finalRoute.CsvFilePath)) + { + throw new Exception("CSV file path is null or empty"); + } + + if (string.IsNullOrEmpty(finalRoute.SummaryFilePath)) + { + throw new Exception("Summary file path is null or empty"); + } + + if (string.IsNullOrEmpty(finalRoute.StitchedImagePath)) + { + throw new Exception("Stitched image path is null or empty"); + } + + if (!File.Exists(finalRoute.CsvFilePath)) + { + throw new Exception($"CSV file not found: {finalRoute.CsvFilePath}"); + } + + if (!File.Exists(finalRoute.SummaryFilePath)) + { + throw new Exception($"Summary file not found: {finalRoute.SummaryFilePath}"); + } + + if (!File.Exists(finalRoute.StitchedImagePath)) + { + throw new Exception($"Stitched image not found: {finalRoute.StitchedImagePath}"); + } + + var csvLines = await File.ReadAllLinesAsync(finalRoute.CsvFilePath); + var uniqueTileCount = csvLines.Length - 1; + + var stitchedInfo = new FileInfo(finalRoute.StitchedImagePath); + + Console.WriteLine("Files Generated:"); + Console.WriteLine($" ✓ CSV: {Path.GetFileName(finalRoute.CsvFilePath)} ({uniqueTileCount} tiles)"); + Console.WriteLine($" ✓ Summary: {Path.GetFileName(finalRoute.SummaryFilePath)}"); + Console.WriteLine($" ✓ Stitched Map: {Path.GetFileName(finalRoute.StitchedImagePath)} ({stitchedInfo.Length / 1024:F2} KB)"); + Console.WriteLine(); + + Console.WriteLine("Route Summary:"); + Console.WriteLine($" Route ID: {finalRoute.Id}"); + Console.WriteLine($" Route Points: {finalRoute.TotalPoints}"); + Console.WriteLine($" Distance: {finalRoute.TotalDistanceMeters:F2}m"); + Console.WriteLine($" Unique Tiles: {uniqueTileCount}"); + Console.WriteLine($" Maps Ready: {finalRoute.MapsReady}"); + Console.WriteLine(); + Console.WriteLine("✓ Route with Region Processing and Stitching Test: PASSED"); + } } diff --git a/SatelliteProvider.Services/RouteProcessingService.cs b/SatelliteProvider.Services/RouteProcessingService.cs new file mode 100644 index 0000000..4acf64e --- /dev/null +++ b/SatelliteProvider.Services/RouteProcessingService.cs @@ -0,0 +1,424 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.Utils; +using SatelliteProvider.DataAccess.Repositories; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SatelliteProvider.Services; + +public class RouteProcessingService : BackgroundService +{ + private readonly IRouteRepository _routeRepository; + private readonly IRegionRepository _regionRepository; + private readonly StorageConfig _storageConfig; + private readonly ILogger _logger; + private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(10); + + public RouteProcessingService( + IRouteRepository routeRepository, + IRegionRepository regionRepository, + IOptions storageConfig, + ILogger logger) + { + _routeRepository = routeRepository; + _regionRepository = regionRepository; + _storageConfig = storageConfig.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Route Processing Service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessPendingRoutesAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in route processing service"); + } + + await Task.Delay(_checkInterval, stoppingToken); + } + + _logger.LogInformation("Route Processing Service stopped"); + } + + private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken) + { + var pendingRoutes = await GetRoutesWithPendingMapsAsync(); + + foreach (var route in pendingRoutes) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + await ProcessRouteIfReadyAsync(route.Id, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing route {RouteId}", route.Id); + } + } + } + + private async Task> GetRoutesWithPendingMapsAsync() + { + var routes = await _routeRepository.GetRoutesWithPendingMapsAsync(); + return routes.Select(r => (r.Id, r.RequestMaps)).ToList(); + } + + private async Task ProcessRouteIfReadyAsync(Guid routeId, CancellationToken cancellationToken) + { + var route = await _routeRepository.GetByIdAsync(routeId); + if (route == null || !route.RequestMaps || route.MapsReady) + { + return; + } + + var regionIds = await _routeRepository.GetRegionIdsByRouteAsync(routeId); + if (!regionIds.Any()) + { + _logger.LogWarning("Route {RouteId} has no regions linked", routeId); + return; + } + + var allCompleted = true; + var anyFailed = false; + + foreach (var regionId in regionIds) + { + var region = await _regionRepository.GetByIdAsync(regionId); + if (region == null) + { + _logger.LogWarning("Region {RegionId} not found for route {RouteId}", regionId, routeId); + continue; + } + + if (region.Status == "failed") + { + anyFailed = true; + } + else if (region.Status != "completed") + { + allCompleted = false; + } + } + + if (!allCompleted) + { + return; + } + + if (anyFailed) + { + _logger.LogWarning("Route {RouteId} has failed regions, skipping processing", routeId); + return; + } + + _logger.LogInformation("All regions completed for route {RouteId}, starting final processing", routeId); + + await GenerateRouteMapsAsync(routeId, route, regionIds, cancellationToken); + } + + private async Task GenerateRouteMapsAsync( + Guid routeId, + DataAccess.Models.RouteEntity route, + IEnumerable regionIds, + CancellationToken cancellationToken) + { + try + { + var readyDir = _storageConfig.ReadyDirectory; + Directory.CreateDirectory(readyDir); + + var allTiles = new Dictionary(); + int totalTilesFromRegions = 0; + int duplicateTiles = 0; + + foreach (var regionId in regionIds) + { + var region = await _regionRepository.GetByIdAsync(regionId); + if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath)) + { + _logger.LogWarning("Region {RegionId} CSV not found for route {RouteId}", regionId, routeId); + continue; + } + + var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); + + foreach (var line in csvLines.Skip(1)) + { + var parts = line.Split(','); + if (parts.Length < 3) continue; + + if (!double.TryParse(parts[0], out var lat)) continue; + if (!double.TryParse(parts[1], out var lon)) continue; + var filePath = parts[2]; + + totalTilesFromRegions++; + var key = $"{lat:F6}_{lon:F6}"; + + if (!allTiles.ContainsKey(key)) + { + allTiles[key] = new TileInfo + { + Latitude = lat, + Longitude = lon, + FilePath = filePath + }; + } + else + { + duplicateTiles++; + } + } + } + + _logger.LogInformation("Route {RouteId}: Collected {UniqueCount} unique tiles ({DuplicateCount} duplicates from {TotalCount} total)", + routeId, allTiles.Count, duplicateTiles, totalTilesFromRegions); + + var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv"); + await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken); + + string? stitchedImagePath = null; + if (route.RequestMaps) + { + stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg"); + await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, cancellationToken); + } + + var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); + await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, cancellationToken); + + route.MapsReady = true; + route.CsvFilePath = csvPath; + route.SummaryFilePath = summaryPath; + route.StitchedImagePath = stitchedImagePath; + route.UpdatedAt = DateTime.UtcNow; + + await _routeRepository.UpdateRouteAsync(route); + + _logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating maps for route {RouteId}", routeId); + throw; + } + } + + private async Task GenerateRouteCsvAsync( + string filePath, + IEnumerable tiles, + CancellationToken cancellationToken) + { + var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList(); + + using var writer = new StreamWriter(filePath); + await writer.WriteLineAsync("latitude,longitude,file_path"); + + foreach (var tile in orderedTiles) + { + await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}"); + } + + _logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, orderedTiles.Count); + } + + private async Task GenerateRouteSummaryAsync( + string filePath, + DataAccess.Models.RouteEntity route, + int uniqueTiles, + int totalTilesFromRegions, + int duplicateTiles, + 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"); + } + 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 tiles, + string outputPath, + int zoomLevel, + CancellationToken cancellationToken) + { + if (tiles.Count == 0) + { + _logger.LogWarning("No tiles to stitch for route map"); + return; + } + + const int tileSizePixels = 256; + + var tileCoords = tiles.Select(t => + { + var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); + return new + { + t.Latitude, + t.Longitude, + t.FilePath, + TileX = tileX, + TileY = tileY + }; + }).Where(t => t.TileX >= 0 && t.TileY >= 0).ToList(); + + var minX = tileCoords.Min(t => t.TileX); + var maxX = tileCoords.Max(t => t.TileX); + var minY = tileCoords.Min(t => t.TileY); + var maxY = tileCoords.Max(t => t.TileY); + + var gridWidth = maxX - minX + 1; + var gridHeight = maxY - minY + 1; + var imageWidth = gridWidth * tileSizePixels; + var imageHeight = gridHeight * tileSizePixels; + + _logger.LogInformation("Stitching route map: {Width}x{Height} pixels (grid: {GridWidth}x{GridHeight} tiles)", + imageWidth, imageHeight, gridWidth, gridHeight); + _logger.LogInformation("Bounding box: top={MinY}, left={MinX}, bottom={MaxY}, right={MaxX}", + minY, minX, maxY, maxX); + + using var stitchedImage = new Image(imageWidth, imageHeight); + stitchedImage.Mutate(ctx => ctx.BackgroundColor(Color.Black)); + + var uniqueTileCoords = tileCoords + .GroupBy(t => $"{t.TileX}_{t.TileY}") + .Select(g => g.First()) + .OrderBy(t => t.TileY) + .ThenBy(t => t.TileX) + .ToList(); + + _logger.LogInformation("Unique tiles to place: {Count}", uniqueTileCoords.Count); + _logger.LogInformation("Sample tiles (first 5):"); + foreach (var sample in uniqueTileCoords.Take(5)) + { + _logger.LogInformation(" Tile ({TileX}, {TileY}) from ({Lat:F6}, {Lon:F6})", + sample.TileX, sample.TileY, sample.Latitude, sample.Longitude); + } + + int placedTiles = 0; + int missingTiles = 0; + + foreach (var tile in uniqueTileCoords) + { + var destX = (tile.TileX - minX) * tileSizePixels; + var destY = (tile.TileY - minY) * tileSizePixels; + + if (File.Exists(tile.FilePath)) + { + try + { + using var tileImage = await Image.LoadAsync(tile.FilePath, cancellationToken); + + if (tileImage.Width != tileSizePixels || tileImage.Height != tileSizePixels) + { + _logger.LogWarning("Tile {FilePath} has wrong size {Width}x{Height}, expected {Expected}x{Expected}", + tile.FilePath, tileImage.Width, tileImage.Height, tileSizePixels); + } + + stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); + placedTiles++; + + if (placedTiles <= 3) + { + _logger.LogInformation("Placed tile {Count}: ({TileX},{TileY}) at pixel ({DestX},{DestY})", + placedTiles, tile.TileX, tile.TileY, destX, destY); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load tile at {FilePath}, leaving black", tile.FilePath); + missingTiles++; + } + } + else + { + _logger.LogWarning("Tile file not found: {FilePath}, leaving black", tile.FilePath); + missingTiles++; + } + } + + await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); + + var totalPossibleTiles = gridWidth * gridHeight; + var uncoveredTiles = totalPossibleTiles - placedTiles - missingTiles; + + _logger.LogInformation("Route map stitched: {OutputPath}", outputPath); + _logger.LogInformation(" Tiles placed: {PlacedTiles}", placedTiles); + _logger.LogInformation(" Tiles missing (file issues): {MissingTiles}", missingTiles); + _logger.LogInformation(" Uncovered area (black): {UncoveredTiles} tiles", uncoveredTiles); + _logger.LogInformation(" Total canvas: {TotalTiles} tiles ({GridWidth}x{GridHeight})", + totalPossibleTiles, gridWidth, gridHeight); + } + + private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) + { + try + { + var filename = Path.GetFileNameWithoutExtension(filePath); + var parts = filename.Split('_'); + + if (parts.Length >= 4 && parts[0] == "tile") + { + if (int.TryParse(parts[2], out var tileX) && int.TryParse(parts[3], out var tileY)) + { + return (tileX, tileY); + } + } + } + catch + { + } + + return (-1, -1); + } +} + +public class TileInfo +{ + public double Latitude { get; set; } + public double Longitude { get; set; } + public string FilePath { get; set; } = string.Empty; +} + diff --git a/SatelliteProvider.Services/RouteService.cs b/SatelliteProvider.Services/RouteService.cs index 7a4f22e..6a757bf 100644 --- a/SatelliteProvider.Services/RouteService.cs +++ b/SatelliteProvider.Services/RouteService.cs @@ -10,14 +10,17 @@ namespace SatelliteProvider.Services; public class RouteService : IRouteService { private readonly IRouteRepository _routeRepository; + private readonly IRegionService _regionService; private readonly ILogger _logger; private const double MAX_POINT_SPACING_METERS = 200.0; public RouteService( IRouteRepository routeRepository, + IRegionService regionService, ILogger logger) { _routeRepository = routeRepository; + _regionService = regionService; _logger = logger; } @@ -54,10 +57,10 @@ public class RouteService : IRouteService var geoPoint = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude); double? distanceFromPrevious = null; - if (segmentIndex > 0) + if (allPoints.Count > 0) { - var prevPoint = request.Points[segmentIndex - 1]; - var prevGeoPoint = new GeoPoint(prevPoint.Latitude, prevPoint.Longitude); + var lastAddedPoint = allPoints[^1]; + var prevGeoPoint = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude); distanceFromPrevious = GeoUtils.CalculateDistance(prevGeoPoint, geoPoint); totalDistance += distanceFromPrevious.Value; } @@ -87,9 +90,8 @@ public class RouteService : IRouteService foreach (var intermediateGeo in intermediatePoints) { - var prevGeo = sequenceNumber == 1 ? startGeo : new GeoPoint( - allPoints[sequenceNumber - 1].Latitude, - allPoints[sequenceNumber - 1].Longitude); + var lastAddedPoint = allPoints[^1]; + var prevGeo = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude); var distFromPrev = GeoUtils.CalculateDistance(prevGeo, intermediateGeo); totalDistance += distFromPrev; @@ -120,6 +122,8 @@ public class RouteService : IRouteService ZoomLevel = request.ZoomLevel, TotalDistanceMeters = totalDistance, TotalPoints = allPoints.Count, + RequestMaps = request.RequestMaps, + MapsReady = false, CreatedAt = now, UpdatedAt = now }; @@ -141,6 +145,30 @@ public class RouteService : IRouteService await _routeRepository.InsertRoutePointsAsync(pointEntities); + if (request.RequestMaps) + { + _logger.LogInformation("Route {RouteId}: Requesting regions for all {Count} points", + request.Id, allPoints.Count); + + foreach (var point in allPoints) + { + var regionId = Guid.NewGuid(); + + await _regionService.RequestRegionAsync( + regionId, + point.Latitude, + point.Longitude, + request.RegionSizeMeters, + request.ZoomLevel, + stitchTiles: false); + + await _routeRepository.LinkRouteToRegionAsync(request.Id, regionId); + } + + _logger.LogInformation("Route {RouteId}: {Count} regions requested", + request.Id, allPoints.Count); + } + _logger.LogInformation("Route {RouteId} created successfully", request.Id); return new RouteResponse @@ -153,6 +181,11 @@ public class RouteService : IRouteService TotalDistanceMeters = routeEntity.TotalDistanceMeters, TotalPoints = routeEntity.TotalPoints, Points = allPoints, + RequestMaps = routeEntity.RequestMaps, + MapsReady = routeEntity.MapsReady, + CsvFilePath = routeEntity.CsvFilePath, + SummaryFilePath = routeEntity.SummaryFilePath, + StitchedImagePath = routeEntity.StitchedImagePath, CreatedAt = routeEntity.CreatedAt, UpdatedAt = routeEntity.UpdatedAt }; @@ -186,6 +219,11 @@ public class RouteService : IRouteService SegmentIndex = p.SegmentIndex, DistanceFromPrevious = p.DistanceFromPrevious }).ToList(), + RequestMaps = route.RequestMaps, + MapsReady = route.MapsReady, + CsvFilePath = route.CsvFilePath, + SummaryFilePath = route.SummaryFilePath, + StitchedImagePath = route.StitchedImagePath, CreatedAt = route.CreatedAt, UpdatedAt = route.UpdatedAt }; diff --git a/docker-compose.tests.yml b/docker-compose.tests.yml index 11b9742..5576d50 100644 --- a/docker-compose.tests.yml +++ b/docker-compose.tests.yml @@ -16,6 +16,9 @@ services: container_name: satellite-provider-integration-tests environment: - API_URL=http://api:8080 + volumes: + - ./ready:/app/ready + - ./tiles:/app/tiles depends_on: api: condition: service_started