From d122497b5017aaf414fc18ee1731bc04598ee000 Mon Sep 17 00:00:00 2001 From: Anton Martynenko Date: Wed, 19 Nov 2025 17:26:23 +0100 Subject: [PATCH] geo fences - wip --- .dockerignore | 16 + SatelliteProvider.Api/Program.cs | 43 ++- .../SatelliteProvider-geofences.http | 102 +++++++ .../DTO/CreateRouteRequest.cs | 7 + SatelliteProvider.Common/DTO/GeoPoint.cs | 10 +- .../DTO/GeofencePolygon.cs | 19 ++ SatelliteProvider.Common/DTO/RoutePoint.cs | 5 + SatelliteProvider.Common/Utils/GeoUtils.cs | 12 + .../008_AddGeofenceFlagToRouteRegions.sql | 2 + .../Repositories/IRouteRepository.cs | 3 +- .../Repositories/RegionRepository.cs | 18 +- .../Repositories/RouteRepository.cs | 47 ++- .../Repositories/TileRepository.cs | 18 +- SatelliteProvider.IntegrationTests/Models.cs | 30 +- SatelliteProvider.IntegrationTests/Program.cs | 14 +- .../RouteTests.cs | 136 +++++---- .../GoogleMapsDownloaderV2.cs | 9 +- .../RegionRequestQueue.cs | 11 - SatelliteProvider.Services/RegionService.cs | 9 + .../RouteProcessingService.cs | 283 +++++++++++++++++- SatelliteProvider.Services/RouteService.cs | 76 ++++- SatelliteProvider.Services/TileService.cs | 10 + 22 files changed, 766 insertions(+), 114 deletions(-) create mode 100644 .dockerignore create mode 100644 SatelliteProvider.Api/SatelliteProvider-geofences.http create mode 100644 SatelliteProvider.Common/DTO/GeofencePolygon.cs create mode 100644 SatelliteProvider.DataAccess/Migrations/008_AddGeofenceFlagToRouteRegions.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39c9f50 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.vscode/ +**/*.user +**/node_modules/ +logs/ +tiles/ +ready/ +.git/ +.gitignore +*.md +Dockerfile* +docker-compose* +.dockerignore + diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index 92cd622..90d16ca 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -23,9 +23,9 @@ builder.Services.Configure(builder.Configuration.GetSection("MapConfi builder.Services.Configure(builder.Configuration.GetSection("StorageConfig")); builder.Services.Configure(builder.Configuration.GetSection("ProcessingConfig")); -builder.Services.AddSingleton(sp => new TileRepository(connectionString)); -builder.Services.AddSingleton(sp => new RegionRepository(connectionString)); -builder.Services.AddSingleton(sp => new RouteRepository(connectionString)); +builder.Services.AddSingleton(sp => new TileRepository(connectionString, sp.GetRequiredService>())); +builder.Services.AddSingleton(sp => new RegionRepository(connectionString, sp.GetRequiredService>())); +builder.Services.AddSingleton(sp => new RouteRepository(connectionString, sp.GetRequiredService>())); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); @@ -42,6 +42,12 @@ builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + options.SerializerOptions.PropertyNameCaseInsensitive = true; +}); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -236,8 +242,35 @@ async Task CreateRoute([FromBody] CreateRouteRequest request, IRouteSer { try { - logger.LogInformation("Route creation request: ID={Id}, Name={Name}, Points={PointCount}, RegionSize={RegionSize}m, Zoom={Zoom}", - request.Id, request.Name, request.Points.Count, request.RegionSizeMeters, request.ZoomLevel); + logger.LogInformation("Route creation request: ID={Id}, Name={Name}, Points={PointCount}, RegionSize={RegionSize}m, Zoom={Zoom}, RequestMaps={RequestMaps}", + request.Id, request.Name, request.Points.Count, request.RegionSizeMeters, request.ZoomLevel, request.RequestMaps); + + if (request.Points.Count > 0) + { + var firstPoint = request.Points[0]; + logger.LogInformation("First point: Lat={Lat}, Lon={Lon}", firstPoint.Latitude, firstPoint.Longitude); + } + + if (request.Geofences != null) + { + logger.LogInformation("Geofences object received: Polygons count = {Count}", request.Geofences.Polygons?.Count ?? 0); + + if (request.Geofences.Polygons != null) + { + for (int i = 0; i < request.Geofences.Polygons.Count; i++) + { + var polygon = request.Geofences.Polygons[i]; + logger.LogInformation("Geofence {Index}: NorthWest = {NW}, SouthEast = {SE}", + i, + polygon.NorthWest is not null ? $"({polygon.NorthWest.Lat}, {polygon.NorthWest.Lon})" : "null", + polygon.SouthEast is not null ? $"({polygon.SouthEast.Lat}, {polygon.SouthEast.Lon})" : "null"); + } + } + } + else + { + logger.LogInformation("No geofences provided (Geofences is null)"); + } var route = await routeService.CreateRouteAsync(request); return Results.Ok(route); diff --git a/SatelliteProvider.Api/SatelliteProvider-geofences.http b/SatelliteProvider.Api/SatelliteProvider-geofences.http new file mode 100644 index 0000000..5423299 --- /dev/null +++ b/SatelliteProvider.Api/SatelliteProvider-geofences.http @@ -0,0 +1,102 @@ +### Create Route with Geofences +POST http://localhost:5000/api/satellite/route +Content-Type: application/json + +{ + "id": "{{$guid}}", + "name": "Route with Geofences", + "description": "Test route with two geofence regions", + "regionSizeMeters": 500, + "zoomLevel": 18, + "requestMaps": true, + "geofences": { + "polygons": [ + { + "northWest": { + "lat": 48.28022277841604, + "lon": 37.37548828125001 + }, + "southEast": { + "lat": 48.2720540660028, + "lon": 37.3901653289795 + } + }, + { + "northWest": { + "lat": 48.2614270732573, + "lon": 37.35239982604981 + }, + "southEast": { + "lat": 48.24988342757033, + "lon": 37.37943649291993 + } + } + ] + }, + "points": [ + { + "lat": 48.276067180586544, + "lon": 37.38445758819581 + }, + { + "lat": 48.27074009522731, + "lon": 37.374029159545906 + }, + { + "lat": 48.263312668696855, + "lon": 37.37707614898682 + }, + { + "lat": 48.26539817051818, + "lon": 37.36587524414063 + }, + { + "lat": 48.25851283439989, + "lon": 37.35952377319337 + }, + { + "lat": 48.254426906081555, + "lon": 37.374801635742195 + }, + { + "lat": 48.25914140977405, + "lon": 37.39068031311036 + }, + { + "lat": 48.25354110233028, + "lon": 37.401752471923835 + }, + { + "lat": 48.25902712391726, + "lon": 37.416257858276374 + }, + { + "lat": 48.26828345053738, + "lon": 37.402009963989265 + } + ] +} + +### Create Route without Geofences (backward compatible) +POST http://localhost:5000/api/satellite/route +Content-Type: application/json + +{ + "id": "{{$guid}}", + "name": "Simple Route", + "description": "Route without geofences", + "regionSizeMeters": 500, + "zoomLevel": 18, + "requestMaps": true, + "points": [ + { + "lat": 48.276067180586544, + "lon": 37.38445758819581 + }, + { + "lat": 48.27074009522731, + "lon": 37.374029159545906 + } + ] +} + diff --git a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs index b6b3c8b..fd486f8 100644 --- a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs +++ b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace SatelliteProvider.Common.DTO; public class CreateRouteRequest @@ -7,7 +9,12 @@ public class CreateRouteRequest public string? Description { get; set; } public double RegionSizeMeters { get; set; } public int ZoomLevel { get; set; } + public List Points { get; set; } = new(); + + [JsonPropertyName("geofences")] + public Geofences? Geofences { get; set; } + public bool RequestMaps { get; set; } = false; } diff --git a/SatelliteProvider.Common/DTO/GeoPoint.cs b/SatelliteProvider.Common/DTO/GeoPoint.cs index cc03696..1f44222 100644 --- a/SatelliteProvider.Common/DTO/GeoPoint.cs +++ b/SatelliteProvider.Common/DTO/GeoPoint.cs @@ -1,10 +1,16 @@ +using System.Text.Json.Serialization; + namespace SatelliteProvider.Common.DTO; public class GeoPoint { const double PRECISION_TOLERANCE = 0.00005; - public double Lat { get; } - public double Lon { get; } + + [JsonPropertyName("lat")] + public double Lat { get; set; } + + [JsonPropertyName("lon")] + public double Lon { get; set; } public GeoPoint() { } diff --git a/SatelliteProvider.Common/DTO/GeofencePolygon.cs b/SatelliteProvider.Common/DTO/GeofencePolygon.cs new file mode 100644 index 0000000..a9965d1 --- /dev/null +++ b/SatelliteProvider.Common/DTO/GeofencePolygon.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace SatelliteProvider.Common.DTO; + +public class GeofencePolygon +{ + [JsonPropertyName("northWest")] + public GeoPoint? NorthWest { get; set; } + + [JsonPropertyName("southEast")] + public GeoPoint? SouthEast { get; set; } +} + +public class Geofences +{ + [JsonPropertyName("polygons")] + public List Polygons { get; set; } = new(); +} + diff --git a/SatelliteProvider.Common/DTO/RoutePoint.cs b/SatelliteProvider.Common/DTO/RoutePoint.cs index fcff6a4..0a8c122 100644 --- a/SatelliteProvider.Common/DTO/RoutePoint.cs +++ b/SatelliteProvider.Common/DTO/RoutePoint.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; + namespace SatelliteProvider.Common.DTO; public class RoutePoint { + [JsonPropertyName("lat")] public double Latitude { get; set; } + + [JsonPropertyName("lon")] public double Longitude { get; set; } } diff --git a/SatelliteProvider.Common/Utils/GeoUtils.cs b/SatelliteProvider.Common/Utils/GeoUtils.cs index 6f23237..6aa5fcc 100644 --- a/SatelliteProvider.Common/Utils/GeoUtils.cs +++ b/SatelliteProvider.Common/Utils/GeoUtils.cs @@ -118,4 +118,16 @@ public static class GeoUtils { return p1.DirectionTo(p2).Distance; } + + public static GeoPoint CalculateCenter(GeoPoint northWest, GeoPoint southEast) + { + var centerLat = (northWest.Lat + southEast.Lat) / 2.0; + var centerLon = (northWest.Lon + southEast.Lon) / 2.0; + return new GeoPoint(centerLat, centerLon); + } + + public static double CalculatePolygonDiagonalDistance(GeoPoint northWest, GeoPoint southEast) + { + return CalculateDistance(northWest, southEast); + } } \ No newline at end of file diff --git a/SatelliteProvider.DataAccess/Migrations/008_AddGeofenceFlagToRouteRegions.sql b/SatelliteProvider.DataAccess/Migrations/008_AddGeofenceFlagToRouteRegions.sql new file mode 100644 index 0000000..fcf82f7 --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/008_AddGeofenceFlagToRouteRegions.sql @@ -0,0 +1,2 @@ +ALTER TABLE route_regions ADD COLUMN is_geofence BOOLEAN NOT NULL DEFAULT false; + diff --git a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs index a119ffe..0fb6d03 100644 --- a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs @@ -10,8 +10,9 @@ public interface IRouteRepository Task InsertRoutePointsAsync(IEnumerable points); Task UpdateRouteAsync(RouteEntity route); Task DeleteRouteAsync(Guid id); - Task LinkRouteToRegionAsync(Guid routeId, Guid regionId); + Task LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence = false); Task> GetRegionIdsByRouteAsync(Guid routeId); + Task> GetGeofenceRegionIdsByRouteAsync(Guid routeId); Task> GetRoutesWithPendingMapsAsync(); } diff --git a/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs b/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs index aecb4d2..af5e21e 100644 --- a/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using Microsoft.Extensions.Logging; using Npgsql; using SatelliteProvider.DataAccess.Models; @@ -7,10 +8,12 @@ namespace SatelliteProvider.DataAccess.Repositories; public class RegionRepository : IRegionRepository { private readonly string _connectionString; + private readonly ILogger _logger; - public RegionRepository(string connectionString) + public RegionRepository(string connectionString, ILogger logger) { _connectionString = connectionString; + _logger = logger; } public async Task GetByIdAsync(Guid id) @@ -26,7 +29,15 @@ public class RegionRepository : IRegionRepository FROM regions WHERE id = @Id"; - return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + var region = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + + if (region != null) + { + _logger.LogInformation("RegionRepository - Read region {RegionId} from DB: Lat={Lat:F12}, Lon={Lon:F12}, Status={Status}", + id, region.Latitude, region.Longitude, region.Status); + } + + return region; } public async Task> GetByStatusAsync(string status) @@ -60,6 +71,9 @@ public class RegionRepository : IRegionRepository @CreatedAt, @UpdatedAt) RETURNING id"; + _logger.LogInformation("RegionRepository - Inserting region {RegionId} to DB: Lat={Lat:F12}, Lon={Lon:F12}, Status={Status}", + region.Id, region.Latitude, region.Longitude, region.Status); + return await connection.ExecuteScalarAsync(sql, region); } diff --git a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs index 227e314..35a357f 100644 --- a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using Microsoft.Extensions.Logging; using Npgsql; using SatelliteProvider.DataAccess.Models; @@ -7,10 +8,12 @@ namespace SatelliteProvider.DataAccess.Repositories; public class RouteRepository : IRouteRepository { private readonly string _connectionString; + private readonly ILogger _logger; - public RouteRepository(string connectionString) + public RouteRepository(string connectionString, ILogger logger) { _connectionString = connectionString; + _logger = logger; } public async Task GetByIdAsync(Guid id) @@ -40,7 +43,16 @@ public class RouteRepository : IRouteRepository WHERE route_id = @RouteId ORDER BY sequence_number"; - return await connection.QueryAsync(sql, new { RouteId = routeId }); + var points = (await connection.QueryAsync(sql, new { RouteId = routeId })).ToList(); + + if (points.Any()) + { + _logger.LogInformation("RouteRepository - Read {Count} points from DB for route {RouteId}. First: Lat={Lat:F12}, Lon={Lon:F12}, Last: Lat={LastLat:F12}, Lon={LastLon:F12}", + points.Count, routeId, points[0].Latitude, points[0].Longitude, + points[^1].Latitude, points[^1].Longitude); + } + + return points; } public async Task InsertRouteAsync(RouteEntity route) @@ -69,7 +81,15 @@ public class RouteRepository : IRouteRepository VALUES (@Id, @RouteId, @SequenceNumber, @Latitude, @Longitude, @PointType, @SegmentIndex, @DistanceFromPrevious, @CreatedAt)"; - await connection.ExecuteAsync(sql, points); + var pointsList = points.ToList(); + if (pointsList.Any()) + { + _logger.LogInformation("RouteRepository - Inserting {Count} points to DB. First: Lat={Lat:F12}, Lon={Lon:F12}, Last: Lat={LastLat:F12}, Lon={LastLon:F12}", + pointsList.Count, pointsList[0].Latitude, pointsList[0].Longitude, + pointsList[^1].Latitude, pointsList[^1].Longitude); + } + + await connection.ExecuteAsync(sql, pointsList); } public async Task UpdateRouteAsync(RouteEntity route) @@ -101,15 +121,15 @@ public class RouteRepository : IRouteRepository return await connection.ExecuteAsync(sql, new { Id = id }); } - public async Task LinkRouteToRegionAsync(Guid routeId, Guid regionId) + public async Task LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence = false) { using var connection = new NpgsqlConnection(_connectionString); const string sql = @" - INSERT INTO route_regions (route_id, region_id, created_at) - VALUES (@RouteId, @RegionId, @CreatedAt) + INSERT INTO route_regions (route_id, region_id, is_geofence, created_at) + VALUES (@RouteId, @RegionId, @IsGeofence, @CreatedAt) ON CONFLICT (route_id, region_id) DO NOTHING"; - await connection.ExecuteAsync(sql, new { RouteId = routeId, RegionId = regionId, CreatedAt = DateTime.UtcNow }); + await connection.ExecuteAsync(sql, new { RouteId = routeId, RegionId = regionId, IsGeofence = isGeofence, CreatedAt = DateTime.UtcNow }); } public async Task> GetRegionIdsByRouteAsync(Guid routeId) @@ -118,7 +138,18 @@ public class RouteRepository : IRouteRepository const string sql = @" SELECT region_id FROM route_regions - WHERE route_id = @RouteId"; + WHERE route_id = @RouteId AND (is_geofence = false OR is_geofence IS NULL)"; + + return await connection.QueryAsync(sql, new { RouteId = routeId }); + } + + public async Task> GetGeofenceRegionIdsByRouteAsync(Guid routeId) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + SELECT region_id + FROM route_regions + WHERE route_id = @RouteId AND is_geofence = true"; return await connection.QueryAsync(sql, new { RouteId = routeId }); } diff --git a/SatelliteProvider.DataAccess/Repositories/TileRepository.cs b/SatelliteProvider.DataAccess/Repositories/TileRepository.cs index 1515c5f..760ad9b 100644 --- a/SatelliteProvider.DataAccess/Repositories/TileRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/TileRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using Microsoft.Extensions.Logging; using Npgsql; using SatelliteProvider.DataAccess.Models; @@ -7,10 +8,12 @@ namespace SatelliteProvider.DataAccess.Repositories; public class TileRepository : ITileRepository { private readonly string _connectionString; + private readonly ILogger _logger; - public TileRepository(string connectionString) + public TileRepository(string connectionString, ILogger logger) { _connectionString = connectionString; + _logger = logger; } public async Task GetByIdAsync(Guid id) @@ -24,7 +27,15 @@ public class TileRepository : ITileRepository FROM tiles WHERE id = @Id"; - return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + var tile = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + + if (tile != null) + { + _logger.LogInformation("TileRepository - Read tile {TileId} from DB: Lat={Lat:F12}, Lon={Lon:F12}, Zoom={Zoom}", + id, tile.Latitude, tile.Longitude, tile.ZoomLevel); + } + + return tile; } public async Task FindExistingTileAsync(double latitude, double longitude, double tileSizeMeters, int zoomLevel, int version) @@ -105,6 +116,9 @@ public class TileRepository : ITileRepository updated_at = EXCLUDED.updated_at RETURNING id"; + _logger.LogInformation("TileRepository - Inserting tile {TileId} to DB: Lat={Lat:F12}, Lon={Lon:F12}, Zoom={Zoom}", + tile.Id, tile.Latitude, tile.Longitude, tile.ZoomLevel); + return await connection.ExecuteScalarAsync(sql, tile); } diff --git a/SatelliteProvider.IntegrationTests/Models.cs b/SatelliteProvider.IntegrationTests/Models.cs index 39b714b..0ac9ac9 100644 --- a/SatelliteProvider.IntegrationTests/Models.cs +++ b/SatelliteProvider.IntegrationTests/Models.cs @@ -39,8 +39,32 @@ public record RegionStatusResponse public class RoutePointInput { - public double Latitude { get; set; } - public double Longitude { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("lat")] + public double Lat { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("lon")] + public double Lon { get; set; } +} + +public class GeoPointInput +{ + [System.Text.Json.Serialization.JsonPropertyName("lat")] + public double Lat { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("lon")] + public double Lon { get; set; } +} + +public class GeofencePolygonInput +{ + [System.Text.Json.Serialization.JsonPropertyName("northWest")] + public GeoPointInput? NorthWest { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("southEast")] + public GeoPointInput? SouthEast { get; set; } +} + +public class GeofencesInput +{ + [System.Text.Json.Serialization.JsonPropertyName("polygons")] + public List Polygons { get; set; } = new(); } public class CreateRouteRequest @@ -51,6 +75,8 @@ public class CreateRouteRequest public double RegionSizeMeters { get; set; } public int ZoomLevel { get; set; } public List Points { get; set; } = new(); + [System.Text.Json.Serialization.JsonPropertyName("geofences")] + public GeofencesInput? Geofences { get; set; } public bool RequestMaps { get; set; } = false; } diff --git a/SatelliteProvider.IntegrationTests/Program.cs b/SatelliteProvider.IntegrationTests/Program.cs index 4fc9587..fb7156d 100644 --- a/SatelliteProvider.IntegrationTests/Program.cs +++ b/SatelliteProvider.IntegrationTests/Program.cs @@ -24,21 +24,21 @@ class Program Console.WriteLine("✓ API is ready"); Console.WriteLine(); - await TileTests.RunGetTileByLatLonTest(httpClient); + // await TileTests.RunGetTileByLatLonTest(httpClient); - await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient); + // await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient); - await RegionTests.RunRegionProcessingTest_400m_Zoom17(httpClient); + // await RegionTests.RunRegionProcessingTest_400m_Zoom17(httpClient); - await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient); + // await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient); - await RouteTests.RunSimpleRouteTest(httpClient); + // await RouteTests.RunSimpleRouteTest(httpClient); - await RouteTests.RunRouteWithRegionProcessingAndStitching(httpClient); + // await RouteTests.RunRouteWithRegionProcessingAndStitching(httpClient); await RouteTests.RunComplexRouteWithStitching(httpClient); - await RouteTests.RunExtendedRouteEast(httpClient); + // await RouteTests.RunExtendedRouteEast(httpClient); Console.WriteLine(); Console.WriteLine("========================="); diff --git a/SatelliteProvider.IntegrationTests/RouteTests.cs b/SatelliteProvider.IntegrationTests/RouteTests.cs index 700ecc5..099b1d9 100644 --- a/SatelliteProvider.IntegrationTests/RouteTests.cs +++ b/SatelliteProvider.IntegrationTests/RouteTests.cs @@ -10,6 +10,11 @@ public static class RouteTests PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + public static async Task RunSimpleRouteTest(HttpClient httpClient) { Console.WriteLine("Test: Create Simple Route with Two Points"); @@ -25,19 +30,19 @@ public static class RouteTests ZoomLevel = 18, Points = new List { - new() { Latitude = 48.276067180586544, Longitude = 37.38445758819581 }, - new() { Latitude = 48.27074009522731, Longitude = 37.374029159545906 } + new() { Lat = 48.276067180586544, Lon = 37.38445758819581 }, + new() { Lat = 48.27074009522731, Lon = 37.374029159545906 } } }; Console.WriteLine($"Creating route with 2 points:"); - Console.WriteLine($" Start: ({request.Points[0].Latitude}, {request.Points[0].Longitude})"); - Console.WriteLine($" End: ({request.Points[1].Latitude}, {request.Points[1].Longitude})"); + Console.WriteLine($" Start: ({request.Points[0].Lat}, {request.Points[0].Lon})"); + Console.WriteLine($" End: ({request.Points[1].Lat}, {request.Points[1].Lon})"); Console.WriteLine($" Region Size: {request.RegionSizeMeters}m"); Console.WriteLine($" Zoom Level: {request.ZoomLevel}"); Console.WriteLine(); - var response = await httpClient.PostAsJsonAsync("/api/satellite/route", request); + var response = await httpClient.PostAsJsonAsync("/api/satellite/route", request, JsonWriteOptions); if (!response.IsSuccessStatusCode) { @@ -136,20 +141,20 @@ public static class RouteTests RequestMaps = true, Points = new List { - new() { Latitude = 48.276067180586544, Longitude = 37.38445758819581 }, - new() { Latitude = 48.27074009522731, Longitude = 37.374029159545906 } + new() { Lat = 48.276067180586544, Lon = 37.38445758819581 }, + new() { Lat = 48.27074009522731, Lon = 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($" Start: ({request.Points[0].Lat}, {request.Points[0].Lon})"); + Console.WriteLine($" End: ({request.Points[1].Lat}, {request.Points[1].Lon})"); 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); + var routeResponse = await httpClient.PostAsJsonAsync("/api/satellite/route", request, JsonWriteOptions); if (!routeResponse.IsSuccessStatusCode) { @@ -287,48 +292,72 @@ public static class RouteTests public static async Task RunComplexRouteWithStitching(HttpClient httpClient) { Console.WriteLine(); - Console.WriteLine("Test: Complex Route with 10 Points - Region Processing and Stitching"); - Console.WriteLine("======================================================================="); + Console.WriteLine("Test: Complex Route with 10 Points + 2 Geofences - Region Processing and Stitching"); + Console.WriteLine("======================================================================================"); Console.WriteLine(); var routeId = Guid.NewGuid(); var request = new CreateRouteRequest { Id = routeId, - Name = "Complex Route with 10 Points", - Description = "Test route with 10 action points for complex map stitching", + Name = "Complex Route with 10 Points + 2 Geofences", + Description = "Test route with 10 action points and 2 geofence regions for complex map stitching", RegionSizeMeters = 300.0, ZoomLevel = 18, RequestMaps = true, Points = new List { - new() { Latitude = 48.276067180586544, Longitude = 37.38445758819581 }, - new() { Latitude = 48.27074009522731, Longitude = 37.374029159545906 }, - new() { Latitude = 48.263312668696855, Longitude = 37.37707614898682 }, - new() { Latitude = 48.26539817051818, Longitude = 37.36587524414063 }, - new() { Latitude = 48.25851283439989, Longitude = 37.35952377319337 }, - new() { Latitude = 48.254426906081555, Longitude = 37.374801635742195 }, - new() { Latitude = 48.25914140977405, Longitude = 37.39068031311036 }, - new() { Latitude = 48.25354110233028, Longitude = 37.401752471923835 }, - new() { Latitude = 48.25902712391726, Longitude = 37.416257858276374 }, - new() { Latitude = 48.26828345053738, Longitude = 37.402009963989265 } + new() { Lat = 48.276067180586544, Lon = 37.38445758819581 }, + new() { Lat = 48.27074009522731, Lon = 37.374029159545906 }, + new() { Lat = 48.263312668696855, Lon = 37.37707614898682 }, + new() { Lat = 48.26539817051818, Lon = 37.36587524414063 }, + new() { Lat = 48.25851283439989, Lon = 37.35952377319337 }, + new() { Lat = 48.254426906081555, Lon = 37.374801635742195 }, + new() { Lat = 48.25914140977405, Lon = 37.39068031311036 }, + new() { Lat = 48.25354110233028, Lon = 37.401752471923835 }, + new() { Lat = 48.25902712391726, Lon = 37.416257858276374 }, + new() { Lat = 48.26828345053738, Lon = 37.402009963989265 } + }, + Geofences = new GeofencesInput + { + Polygons = new List + { + new() + { + NorthWest = new GeoPointInput { Lat = 48.28022277841604, Lon = 37.37548828125001 }, + SouthEast = new GeoPointInput { Lat = 48.2720540660028, Lon = 37.3901653289795 } + }, + new() + { + NorthWest = new GeoPointInput { Lat = 48.2614270732573, Lon = 37.35239982604981 }, + SouthEast = new GeoPointInput { Lat = 48.24988342757033, Lon = 37.37943649291993 } + } + } } }; - Console.WriteLine("Step 1: Creating complex route with RequestMaps=true"); + Console.WriteLine("Step 1: Creating complex route with RequestMaps=true and 2 geofences"); Console.WriteLine($" Action Points: {request.Points.Count}"); + Console.WriteLine($" Geofence Regions: {request.Geofences.Polygons.Count}"); Console.WriteLine($" Region Size: {request.RegionSizeMeters}m"); Console.WriteLine($" Zoom Level: {request.ZoomLevel}"); Console.WriteLine($" Request Maps: {request.RequestMaps}"); Console.WriteLine(); + Console.WriteLine("Geofence Regions:"); + for (int i = 0; i < request.Geofences.Polygons.Count; i++) + { + var polygon = request.Geofences.Polygons[i]; + Console.WriteLine($" {i + 1}. NW: ({polygon.NorthWest.Lat}, {polygon.NorthWest.Lon}) -> SE: ({polygon.SouthEast.Lat}, {polygon.SouthEast.Lon})"); + } + Console.WriteLine(); Console.WriteLine("Route Path:"); for (int i = 0; i < request.Points.Count; i++) { - Console.WriteLine($" {i + 1}. ({request.Points[i].Latitude}, {request.Points[i].Longitude})"); + Console.WriteLine($" {i + 1}. ({request.Points[i].Lat}, {request.Points[i].Lon})"); } Console.WriteLine(); - var routeResponse = await httpClient.PostAsJsonAsync("/api/satellite/route", request); + var routeResponse = await httpClient.PostAsJsonAsync("/api/satellite/route", request, JsonWriteOptions); if (!routeResponse.IsSuccessStatusCode) { @@ -387,7 +416,7 @@ public static class RouteTests } Console.WriteLine("Step 2: Waiting for complex route maps to be ready"); - Console.WriteLine(" (Processing 56 regions SEQUENTIALLY to avoid API throttling)"); + Console.WriteLine(" (Processing route point regions + 2 geofence regions SEQUENTIALLY to avoid API throttling)"); Console.WriteLine(" (This will take several minutes as each region is processed one at a time)"); Console.WriteLine(); @@ -486,13 +515,14 @@ public static class RouteTests Console.WriteLine($" Total Points: {finalRoute.TotalPoints}"); Console.WriteLine($" Action Points: {actionPoints}"); Console.WriteLine($" Distance: {finalRoute.TotalDistanceMeters:F2}m"); + Console.WriteLine($" Geofence Regions: 2"); Console.WriteLine($" Unique Tiles: {uniqueTileCount}"); Console.WriteLine($" Maps Ready: {finalRoute.MapsReady}"); Console.WriteLine(); if (uniqueTileCount < 10) { - throw new Exception($"Expected at least 10 unique tiles for complex route, got {uniqueTileCount}"); + throw new Exception($"Expected at least 10 unique tiles for complex route with geofences, got {uniqueTileCount}"); } if (stitchedInfo.Length < 1024) @@ -500,7 +530,7 @@ public static class RouteTests throw new Exception($"Stitched image seems too small: {stitchedInfo.Length} bytes"); } - Console.WriteLine("✓ Complex Route with 10 Points Test: PASSED"); + Console.WriteLine("✓ Complex Route with 10 Points + 2 Geofences Test: PASSED"); } public static async Task RunExtendedRouteEast(HttpClient httpClient) @@ -521,26 +551,26 @@ public static class RouteTests RequestMaps = true, Points = new List { - new() { Latitude = 48.276067180586544, Longitude = 37.51945758819581 }, - new() { Latitude = 48.27074009522731, Longitude = 37.509029159545906 }, - new() { Latitude = 48.263312668696855, Longitude = 37.51207614898682 }, - new() { Latitude = 48.26539817051818, Longitude = 37.50087524414063 }, - new() { Latitude = 48.25851283439989, Longitude = 37.49452377319337 }, - new() { Latitude = 48.254426906081555, Longitude = 37.509801635742195 }, - new() { Latitude = 48.25914140977405, Longitude = 37.52568031311036 }, - new() { Latitude = 48.25354110233028, Longitude = 37.536752471923835 }, - new() { Latitude = 48.25902712391726, Longitude = 37.551257858276374 }, - new() { Latitude = 48.26828345053738, Longitude = 37.537009963989265 }, - new() { Latitude = 48.27421563182974, Longitude = 37.52345758819581 }, - new() { Latitude = 48.26889854647051, Longitude = 37.513029159545906 }, - new() { Latitude = 48.26147111993905, Longitude = 37.51607614898682 }, - new() { Latitude = 48.26355662176038, Longitude = 37.50487524414063 }, - new() { Latitude = 48.25667128564209, Longitude = 37.49852377319337 }, - new() { Latitude = 48.25258535732375, Longitude = 37.513801635742195 }, - new() { Latitude = 48.25729986101625, Longitude = 37.52968031311036 }, - new() { Latitude = 48.25169955357248, Longitude = 37.540752471923835 }, - new() { Latitude = 48.25718557515946, Longitude = 37.555257858276374 }, - new() { Latitude = 48.26644190177958, Longitude = 37.541009963989265 } + new() { Lat = 48.276067180586544, Lon = 37.51945758819581 }, + new() { Lat = 48.27074009522731, Lon = 37.509029159545906 }, + new() { Lat = 48.263312668696855, Lon = 37.51207614898682 }, + new() { Lat = 48.26539817051818, Lon = 37.50087524414063 }, + new() { Lat = 48.25851283439989, Lon = 37.49452377319337 }, + new() { Lat = 48.254426906081555, Lon = 37.509801635742195 }, + new() { Lat = 48.25914140977405, Lon = 37.52568031311036 }, + new() { Lat = 48.25354110233028, Lon = 37.536752471923835 }, + new() { Lat = 48.25902712391726, Lon = 37.551257858276374 }, + new() { Lat = 48.26828345053738, Lon = 37.537009963989265 }, + new() { Lat = 48.27421563182974, Lon = 37.52345758819581 }, + new() { Lat = 48.26889854647051, Lon = 37.513029159545906 }, + new() { Lat = 48.26147111993905, Lon = 37.51607614898682 }, + new() { Lat = 48.26355662176038, Lon = 37.50487524414063 }, + new() { Lat = 48.25667128564209, Lon = 37.49852377319337 }, + new() { Lat = 48.25258535732375, Lon = 37.513801635742195 }, + new() { Lat = 48.25729986101625, Lon = 37.52968031311036 }, + new() { Lat = 48.25169955357248, Lon = 37.540752471923835 }, + new() { Lat = 48.25718557515946, Lon = 37.555257858276374 }, + new() { Lat = 48.26644190177958, Lon = 37.541009963989265 } } }; @@ -554,16 +584,16 @@ public static class RouteTests Console.WriteLine("Route Path (first 5 and last 5 points):"); for (int i = 0; i < Math.Min(5, request.Points.Count); i++) { - Console.WriteLine($" {i + 1}. ({request.Points[i].Latitude}, {request.Points[i].Longitude})"); + Console.WriteLine($" {i + 1}. ({request.Points[i].Lat}, {request.Points[i].Lon})"); } Console.WriteLine(" ..."); for (int i = Math.Max(5, request.Points.Count - 5); i < request.Points.Count; i++) { - Console.WriteLine($" {i + 1}. ({request.Points[i].Latitude}, {request.Points[i].Longitude})"); + Console.WriteLine($" {i + 1}. ({request.Points[i].Lat}, {request.Points[i].Lon})"); } Console.WriteLine(); - var routeResponse = await httpClient.PostAsJsonAsync("/api/satellite/route", request); + var routeResponse = await httpClient.PostAsJsonAsync("/api/satellite/route", request, JsonWriteOptions); if (!routeResponse.IsSuccessStatusCode) { diff --git a/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs b/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs index 86c9347..e8ac341 100644 --- a/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs +++ b/SatelliteProvider.Services/GoogleMapsDownloaderV2.cs @@ -262,6 +262,12 @@ public class GoogleMapsDownloaderV2 { var tileCenter = GeoUtils.TileToWorldPos(x, y, zoomLevel); + if ((x == xMin && y == yMin) || (x == xMax && y == yMax)) + { + _logger.LogInformation("GoogleMapsDownloader - Tile ({X}, {Y}) center calculated: Lat={Lat:F12}, Lon={Lon:F12}", + x, y, tileCenter.Lat, tileCenter.Lon); + } + var existingTile = existingTiles.FirstOrDefault(t => Math.Abs(t.Latitude - tileCenter.Lat) < 0.0001 && Math.Abs(t.Longitude - tileCenter.Lon) < 0.0001 && @@ -373,9 +379,7 @@ public class GoogleMapsDownloaderV2 int totalTiles, CancellationToken token) { - _logger.LogDebug("Tile ({X},{Y}) [{Index}/{Total}]: Waiting for semaphore slot", x, y, tileIndex + 1, totalTiles); await _downloadSemaphore.WaitAsync(token); - _logger.LogDebug("Tile ({X},{Y}) [{Index}/{Total}]: Acquired semaphore slot, starting download", x, y, tileIndex + 1, totalTiles); try { @@ -451,7 +455,6 @@ public class GoogleMapsDownloaderV2 } finally { - _logger.LogDebug("Tile ({X},{Y}) [{Index}/{Total}]: Releasing semaphore slot", x, y, tileIndex + 1, totalTiles); _downloadSemaphore.Release(); } } diff --git a/SatelliteProvider.Services/RegionRequestQueue.cs b/SatelliteProvider.Services/RegionRequestQueue.cs index 0864def..aba9e94 100644 --- a/SatelliteProvider.Services/RegionRequestQueue.cs +++ b/SatelliteProvider.Services/RegionRequestQueue.cs @@ -25,16 +25,8 @@ public class RegionRequestQueue : IRegionRequestQueue public async ValueTask EnqueueAsync(RegionRequest request, CancellationToken cancellationToken = default) { - var queueDepthBefore = Count; _totalEnqueued++; - _logger?.LogDebug("Enqueuing region {RegionId} (queue depth before: {Depth}, total enqueued: {Total})", - request.Id, queueDepthBefore, _totalEnqueued); - await _queue.Writer.WriteAsync(request, cancellationToken); - - var queueDepthAfter = Count; - _logger?.LogDebug("Enqueued region {RegionId} (queue depth after: {Depth})", - request.Id, queueDepthAfter); } public async ValueTask DequeueAsync(CancellationToken cancellationToken = default) @@ -44,9 +36,6 @@ public class RegionRequestQueue : IRegionRequestQueue if (_queue.Reader.TryRead(out var request)) { _totalDequeued++; - var queueDepth = Count; - _logger?.LogDebug("Dequeued region {RegionId} (queue depth: {Depth}, total dequeued: {Total})", - request.Id, queueDepth, _totalDequeued); return request; } } diff --git a/SatelliteProvider.Services/RegionService.cs b/SatelliteProvider.Services/RegionService.cs index c892a02..82645a7 100644 --- a/SatelliteProvider.Services/RegionService.cs +++ b/SatelliteProvider.Services/RegionService.cs @@ -36,6 +36,9 @@ public class RegionService : IRegionService public async Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false) { + _logger.LogInformation("RegionService - Requesting region {RegionId}: Lat={Lat:F12}, Lon={Lon:F12}, Size={Size}m, Zoom={Zoom}", + id, latitude, longitude, sizeMeters, zoomLevel); + var now = DateTime.UtcNow; var region = new RegionEntity { @@ -353,6 +356,12 @@ public class RegionService : IRegionService { var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList(); + if (orderedTiles.Any()) + { + _logger.LogInformation("RegionService - Writing CSV with {Count} tiles. First tile: Lat={Lat:F12}, Lon={Lon:F12}", + orderedTiles.Count, orderedTiles[0].Latitude, orderedTiles[0].Longitude); + } + using var writer = new StreamWriter(filePath); await writer.WriteLineAsync("latitude,longitude,file_path"); diff --git a/SatelliteProvider.Services/RouteProcessingService.cs b/SatelliteProvider.Services/RouteProcessingService.cs index 742c9dc..6e24f2a 100644 --- a/SatelliteProvider.Services/RouteProcessingService.cs +++ b/SatelliteProvider.Services/RouteProcessingService.cs @@ -58,6 +58,12 @@ public class RouteProcessingService : BackgroundService private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken) { var pendingRoutes = await GetRoutesWithPendingMapsAsync(); + + if (pendingRoutes.Count > 0) + { + _logger.LogInformation("Processing {Count} route(s) with pending maps: {RouteIds}", + pendingRoutes.Count, string.Join(", ", pendingRoutes.Select(r => r.Id))); + } foreach (var route in pendingRoutes) { @@ -84,16 +90,37 @@ public class RouteProcessingService : BackgroundService private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken) { var route = await _routeRepository.GetByIdAsync(routeId); - if (route == null || !route.RequestMaps || route.MapsReady) + if (route == null) { + _logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId); return; } + + if (!route.RequestMaps) + { + _logger.LogInformation("Route {RouteId}: RequestMaps=false, skipping processing", routeId); + return; + } + + if (route.MapsReady) + { + _logger.LogInformation("Route {RouteId}: MapsReady=true, skipping processing", routeId); + return; + } + + _logger.LogInformation("Route {RouteId}: Starting processing check - RequestMaps={RequestMaps}, MapsReady={MapsReady}", + routeId, route.RequestMaps, route.MapsReady); var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList(); var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList(); + var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList(); - if (regionIdsList.Count == 0) + var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList(); + + if (regionIdsList.Count == 0 && routePointsList.Count > 0) { + _logger.LogInformation("Route {RouteId}: No route point regions linked yet. Will create regions for {PointCount} route points", routeId, routePointsList.Count); + using var scope = _serviceProvider.CreateScope(); var regionService = scope.ServiceProvider.GetRequiredService(); @@ -106,6 +133,9 @@ public class RouteProcessingService : BackgroundService { var regionId = Guid.NewGuid(); + _logger.LogInformation("RouteProcessingService - Creating region {RegionId} for route {RouteId} at point: Lat={Lat:F12}, Lon={Lon:F12}", + regionId, routeId, point.Latitude, point.Longitude); + await regionService.RequestRegionAsync( regionId, point.Latitude, @@ -124,7 +154,7 @@ public class RouteProcessingService : BackgroundService } var regions = new List(); - foreach (var regionId in regionIdsList) + foreach (var regionId in allRegionIds) { var region = await _regionRepository.GetByIdAsync(regionId); if (region != null) @@ -137,16 +167,36 @@ public class RouteProcessingService : BackgroundService var failedRegions = regions.Where(r => r.Status == "failed").ToList(); var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList(); - var hasEnoughCompleted = completedRegions.Count >= routePointsList.Count; + var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList(); + var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList(); + + _logger.LogInformation("Route {RouteId}: Region counts - Total allRegionIds={AllCount}, regionIdsList={RoutePointCount}, geofenceRegionIdsList={GeofenceCount}", + routeId, allRegionIds.Count, regionIdsList.Count, geofenceRegionIdsList.Count); + _logger.LogInformation("Route {RouteId}: Status breakdown - Completed={Completed} (RoutePoint={CompletedRP}, Geofence={CompletedGF}), Failed={Failed}, Processing={Processing}", + routeId, completedRegions.Count, completedRoutePointRegions.Count, completedGeofenceRegions.Count, failedRegions.Count, processingRegions.Count); + + 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; + + _logger.LogInformation("Route {RouteId}: Condition checks - hasRoutePointRegions={HasRP}, hasEnoughRoutePointRegions={HasEnoughRP} (need {NeedRP}), hasAllGeofenceRegions={HasAllGF} (need {NeedGF}), hasEnoughCompleted={HasEnough}", + routeId, hasRoutePointRegions, hasEnoughRoutePointRegions, routePointsList.Count, hasAllGeofenceRegions, geofenceRegionIdsList.Count, hasEnoughCompleted); + var activeRegions = completedRegions.Count + processingRegions.Count; - var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < routePointsList.Count; + var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count; if (hasEnoughCompleted) { - _logger.LogInformation("Route {RouteId}: Have {Completed} completed regions (required: {Required}). Generating final maps. Ignoring {Processing} processing and {Failed} failed regions.", - routeId, completedRegions.Count, routePointsList.Count, processingRegions.Count, failedRegions.Count); + _logger.LogInformation("Route {RouteId}: Have {RoutePointCompleted}/{RoutePointRequired} route point regions and {GeofenceCompleted}/{GeofenceRequired} geofence regions completed. Generating final maps. Ignoring {Processing} processing and {Failed} failed regions.", + routeId, completedRoutePointRegions.Count, routePointsList.Count, completedGeofenceRegions.Count, geofenceRegionIdsList.Count, processingRegions.Count, failedRegions.Count); - await GenerateRouteMapsAsync(routeId, route, completedRegions.Take(routePointsList.Count).Select(r => r.Id), cancellationToken); + 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, cancellationToken); return; } @@ -182,16 +232,20 @@ public class RouteProcessingService : BackgroundService var anyProcessing = processingRegions.Count > 0; if (anyProcessing) { - _logger.LogInformation("Route {RouteId}: Progress - {Completed}/{Required} regions completed, {Processing} still processing, {Failed} failed (will retry if needed)", - routeId, completedRegions.Count, routePointsList.Count, processingRegions.Count, failedRegions.Count); + _logger.LogInformation("Route {RouteId}: Progress - {RoutePointCompleted}/{RoutePointRequired} route point regions, {GeofenceCompleted}/{GeofenceRequired} geofence regions completed, {Processing} still processing, {Failed} failed (will retry if needed)", + routeId, completedRoutePointRegions.Count, routePointsList.Count, completedGeofenceRegions.Count, geofenceRegionIdsList.Count, processingRegions.Count, failedRegions.Count); return; } + + _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 regionIds, + List geofenceRegionIds, CancellationToken cancellationToken) { try @@ -208,21 +262,42 @@ public class RouteProcessingService : BackgroundService 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); + _logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId); continue; } + + var isGeofence = geofenceRegionIds.Contains(regionId); + _logger.LogInformation("Route {RouteId}: Processing region {RegionId} ({Type}) - Lat={Lat}, Lon={Lon}, Size={Size}m, CSV={CsvPath}", + routeId, regionId, isGeofence ? "GEOFENCE" : "RoutePoint", + region.Latitude, region.Longitude, region.SizeMeters, region.CsvFilePath); 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)) continue; - if (!double.TryParse(parts[1], out var lon)) continue; + if (!double.TryParse(parts[0], out var lat)) + { + _logger.LogWarning("Route {RouteId} - Failed to parse latitude from CSV line {LineNumber}: {Line}", routeId, lineNumber, line); + continue; + } + if (!double.TryParse(parts[1], out var lon)) + { + _logger.LogWarning("Route {RouteId} - Failed to parse longitude from CSV line {LineNumber}: {Line}", routeId, lineNumber, line); + continue; + } var filePath = parts[2]; + if (lineNumber <= 3) + { + _logger.LogInformation("Route {RouteId} - Reading tile from region {RegionId} CSV: Lat={Lat:F12}, Lon={Lon:F12}", + routeId, regionId, lat, lon); + } + totalTilesFromRegions++; var key = $"{lat:F6}_{lon:F6}"; @@ -251,8 +326,54 @@ public class RouteProcessingService : BackgroundService string? stitchedImagePath = null; if (route.RequestMaps) { + var geofenceTileBounds = new List<(Guid RegionId, int MinX, int MinY, int MaxX, int MaxY)>(); + + foreach (var geofenceId in geofenceRegionIds) + { + var region = await _regionRepository.GetByIdAsync(geofenceId); + if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath)) + { + _logger.LogInformation("Route {RouteId}: Loading geofence region {RegionId} tile bounds", + routeId, region.Id); + + var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); + int? minX = null, minY = null, maxX = null, maxY = null; + + 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) + { + geofenceTileBounds.Add((region.Id, minX.Value, minY.Value, maxX.Value, maxY.Value)); + _logger.LogInformation("Route {RouteId}: Geofence {RegionId} tile bounds: X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]", + routeId, region.Id, minX.Value, maxX.Value, minY.Value, maxY.Value); + } + } + else + { + _logger.LogWarning("Route {RouteId}: Geofence region {RegionId} CSV not found", + routeId, geofenceId); + } + } + + _logger.LogInformation("Route {RouteId}: Starting stitching with {GeofenceCount} geofence regions", + routeId, geofenceTileBounds.Count); + stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg"); - await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, cancellationToken); + await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofenceTileBounds, cancellationToken); } var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); @@ -339,6 +460,7 @@ public class RouteProcessingService : BackgroundService List tiles, string outputPath, int zoomLevel, + List<(Guid RegionId, int MinX, int MinY, int MaxX, int MaxY)> geofenceTileBounds, CancellationToken cancellationToken) { if (tiles.Count == 0) @@ -437,6 +559,48 @@ public class RouteProcessingService : BackgroundService } } + if (geofenceTileBounds.Count > 0) + { + _logger.LogInformation("Drawing {Count} geofence borders on image {Width}x{Height} (grid: minX={MinX}, minY={MinY})", + geofenceTileBounds.Count, imageWidth, imageHeight, minX, minY); + + foreach (var (regionId, geoMinX, geoMinY, geoMaxX, geoMaxY) in geofenceTileBounds) + { + _logger.LogInformation("Geofence {RegionId}: Tile range - X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]", + regionId, geoMinX, geoMaxX, geoMinY, geoMaxY); + + var x1 = (geoMinX - minX) * tileSizePixels; + var y1 = (geoMinY - minY) * tileSizePixels; + var x2 = (geoMaxX - minX + 1) * tileSizePixels - 1; + var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1; + + _logger.LogInformation("Geofence {RegionId}: Pixel coords before clipping - ({X1},{Y1}) to ({X2},{Y2})", + regionId, x1, y1, x2, y2); + + 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) + { + _logger.LogInformation("Geofence {RegionId}: Drawing border at pixel coords ({X1},{Y1}) to ({X2},{Y2})", + regionId, x1, y1, x2, y2); + + DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0)); + + _logger.LogInformation("Successfully drew geofence border for region {RegionId}", regionId); + } + else + { + _logger.LogWarning("Geofence {RegionId}: Border out of bounds or invalid - ({X1},{Y1}) to ({X2},{Y2}), image size: {Width}x{Height}", + regionId, x1, y1, x2, y2, imageWidth, imageHeight); + } + } + + _logger.LogInformation("Completed drawing all geofence borders, now saving image..."); + } + await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); var totalPossibleTiles = gridWidth * gridHeight; @@ -450,6 +614,54 @@ public class RouteProcessingService : BackgroundService totalPossibleTiles, gridWidth, gridHeight); } + private List MatchRegionsToRoutePoints( + List routePoints, + List regions, + Guid routeId) + { + var orderedRegions = new List(); + var availableRegions = new List(regions); + + foreach (var point in routePoints) + { + var matchedRegion = availableRegions + .OrderBy(r => CalculateDistance(point.Latitude, point.Longitude, r.Latitude, r.Longitude)) + .FirstOrDefault(); + + if (matchedRegion != null) + { + orderedRegions.Add(matchedRegion); + availableRegions.Remove(matchedRegion); + + var distance = CalculateDistance(point.Latitude, point.Longitude, matchedRegion.Latitude, matchedRegion.Longitude); + _logger.LogInformation("Route {RouteId}: Matched route point Seq={Seq} ({Lat:F6},{Lon:F6}) to region {RegionId} ({RegLat:F6},{RegLon:F6}), distance={Distance:F2}m", + routeId, point.SequenceNumber, point.Latitude, point.Longitude, + matchedRegion.Id, matchedRegion.Latitude, matchedRegion.Longitude, distance); + } + else + { + _logger.LogWarning("Route {RouteId}: No region found for route point Seq={Seq} ({Lat:F6},{Lon:F6})", + routeId, point.SequenceNumber, point.Latitude, point.Longitude); + } + } + + return orderedRegions; + } + + private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2) + { + const double earthRadiusMeters = 6371000; + var dLat = (lat2 - lat1) * Math.PI / 180.0; + var dLon = (lon2 - lon1) * Math.PI / 180.0; + + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + + return earthRadiusMeters * c; + } + private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) { try @@ -471,6 +683,49 @@ public class RouteProcessingService : BackgroundService return (-1, -1); } + + private static (double NorthLat, double SouthLat, double WestLon, double EastLon) CalculateGeofenceCorners( + double centerLat, + double centerLon, + double halfSizeMeters) + { + var center = new Common.DTO.GeoPoint { Lat = centerLat, Lon = centerLon }; + + var north = GeoUtils.GoDirection(center, new Common.DTO.Direction { Distance = halfSizeMeters, Azimuth = 0 }); + var south = GeoUtils.GoDirection(center, new Common.DTO.Direction { Distance = halfSizeMeters, Azimuth = 180 }); + var east = GeoUtils.GoDirection(center, new Common.DTO.Direction { Distance = halfSizeMeters, Azimuth = 90 }); + var west = GeoUtils.GoDirection(center, new Common.DTO.Direction { Distance = halfSizeMeters, Azimuth = 270 }); + + return (north.Lat, south.Lat, west.Lon, east.Lon); + } + + private static void DrawRectangleBorder(Image image, int x1, int y1, int x2, int y2, Rgb24 color) + { + const int thickness = 5; + + for (int t = 0; t < thickness; t++) + { + for (int x = x1; x <= x2; x++) + { + int topY = y1 + t; + int bottomY = y2 - t; + if (x >= 0 && x < image.Width && topY >= 0 && topY < image.Height) + image[x, topY] = color; + if (x >= 0 && x < image.Width && bottomY >= 0 && bottomY < image.Height) + image[x, bottomY] = color; + } + + for (int y = y1; y <= y2; y++) + { + int leftX = x1 + t; + int rightX = x2 - t; + if (leftX >= 0 && leftX < image.Width && y >= 0 && y < image.Height) + image[leftX, y] = color; + if (rightX >= 0 && rightX < image.Width && y >= 0 && y < image.Height) + image[rightX, y] = color; + } + } + } } public class TileInfo diff --git a/SatelliteProvider.Services/RouteService.cs b/SatelliteProvider.Services/RouteService.cs index ca0bd86..3dd4cc2 100644 --- a/SatelliteProvider.Services/RouteService.cs +++ b/SatelliteProvider.Services/RouteService.cs @@ -41,8 +41,12 @@ public class RouteService : IRouteService throw new ArgumentException("Route name is required"); } - _logger.LogInformation("Creating route {RouteId} with {PointCount} original points", - request.Id, request.Points.Count); + _logger.LogInformation("Creating route {RouteId} with {PointCount} original points and {GeofenceCount} geofences", + request.Id, request.Points.Count, request.Geofences?.Polygons?.Count ?? 0); + + _logger.LogInformation("Route {RouteId} - Input coordinates: First point ({Lat}, {Lon}), Last point ({LastLat}, {LastLon})", + request.Id, request.Points[0].Latitude, request.Points[0].Longitude, + request.Points[^1].Latitude, request.Points[^1].Longitude); var allPoints = new List(); var totalDistance = 0.0; @@ -67,7 +71,7 @@ public class RouteService : IRouteService var pointType = isStart ? "start" : (isEnd ? "end" : "action"); - allPoints.Add(new RoutePointDto + var routePointDto = new RoutePointDto { Latitude = currentPoint.Latitude, Longitude = currentPoint.Longitude, @@ -75,7 +79,15 @@ public class RouteService : IRouteService SequenceNumber = sequenceNumber++, SegmentIndex = segmentIndex, DistanceFromPrevious = distanceFromPrevious - }); + }; + + if (segmentIndex == 0 || segmentIndex == request.Points.Count - 1) + { + _logger.LogInformation("Route {RouteId} - Creating {PointType} point: Lat={Lat:F12}, Lon={Lon:F12}", + request.Id, pointType, routePointDto.Latitude, routePointDto.Longitude); + } + + allPoints.Add(routePointDto); if (!isEnd) { @@ -143,8 +155,64 @@ public class RouteService : IRouteService CreatedAt = now }).ToList(); + _logger.LogInformation("Route {RouteId} - Saving {Count} route points to DB. First: Lat={Lat:F12}, Lon={Lon:F12}, Last: Lat={LastLat:F12}, Lon={LastLon:F12}", + request.Id, pointEntities.Count, pointEntities[0].Latitude, pointEntities[0].Longitude, + pointEntities[^1].Latitude, pointEntities[^1].Longitude); + await _routeRepository.InsertRoutePointsAsync(pointEntities); + if (request.Geofences?.Polygons != null && request.Geofences.Polygons.Count > 0) + { + _logger.LogInformation("Route {RouteId}: Processing {GeofenceCount} geofence polygons", + request.Id, request.Geofences.Polygons.Count); + + foreach (var polygon in request.Geofences.Polygons) + { + if (polygon.NorthWest is null || polygon.SouthEast is null) + { + throw new ArgumentException("Geofence polygon coordinates are required"); + } + + if ((Math.Abs(polygon.NorthWest.Lat) < 0.0001 && Math.Abs(polygon.NorthWest.Lon) < 0.0001) || + (Math.Abs(polygon.SouthEast.Lat) < 0.0001 && Math.Abs(polygon.SouthEast.Lon) < 0.0001)) + { + throw new ArgumentException("Geofence polygon coordinates cannot be (0,0)"); + } + + if (polygon.NorthWest.Lat < -90 || polygon.NorthWest.Lat > 90 || + polygon.SouthEast.Lat < -90 || polygon.SouthEast.Lat > 90 || + polygon.NorthWest.Lon < -180 || polygon.NorthWest.Lon > 180 || + polygon.SouthEast.Lon < -180 || polygon.SouthEast.Lon > 180) + { + throw new ArgumentException("Geofence polygon coordinates must be valid (lat: -90 to 90, lon: -180 to 180)"); + } + + if (polygon.NorthWest.Lat <= polygon.SouthEast.Lat) + { + throw new ArgumentException("Geofence northWest latitude must be greater than southEast latitude"); + } + + var center = GeoUtils.CalculateCenter(polygon.NorthWest, polygon.SouthEast); + var diagonalDistance = GeoUtils.CalculatePolygonDiagonalDistance(polygon.NorthWest, polygon.SouthEast); + var geofenceRegionSize = Math.Max(diagonalDistance * 0.6, request.RegionSizeMeters); + + var geofenceRegionId = Guid.NewGuid(); + + _logger.LogInformation("Route {RouteId}: Requesting geofence region {RegionId} at center ({Lat}, {Lon}) with size {Size}m", + request.Id, geofenceRegionId, center.Lat, center.Lon, geofenceRegionSize); + + await _regionService.RequestRegionAsync( + geofenceRegionId, + center.Lat, + center.Lon, + geofenceRegionSize, + request.ZoomLevel, + stitchTiles: false); + + await _routeRepository.LinkRouteToRegionAsync(request.Id, geofenceRegionId, isGeofence: true); + } + } + if (request.RequestMaps) { _logger.LogInformation("Route {RouteId}: Maps requested. Regions will be processed sequentially by background service.", diff --git a/SatelliteProvider.Services/TileService.cs b/SatelliteProvider.Services/TileService.cs index a1fcef0..0f6c840 100644 --- a/SatelliteProvider.Services/TileService.cs +++ b/SatelliteProvider.Services/TileService.cs @@ -46,6 +46,9 @@ public class TileService : ITileService } var centerPoint = new GeoPoint(latitude, longitude); + _logger.LogInformation("TileService - Downloading tiles for center: Lat={Lat:F12}, Lon={Lon:F12}, Radius={Radius}m, Zoom={Zoom}", + latitude, longitude, sizeMeters / 2, zoomLevel); + var downloadedTiles = await _downloader.GetTilesWithMetadataAsync( centerPoint, sizeMeters / 2, @@ -67,6 +70,10 @@ public class TileService : ITileService foreach (var downloadedTile in downloadedTiles) { var now = DateTime.UtcNow; + + _logger.LogInformation("TileService - Preparing to save tile: CenterLat={CenterLat:F12}, CenterLon={CenterLon:F12} from downloader", + downloadedTile.CenterLatitude, downloadedTile.CenterLongitude); + var tileEntity = new TileEntity { Id = Guid.NewGuid(), @@ -83,6 +90,9 @@ public class TileService : ITileService UpdatedAt = now }; + _logger.LogInformation("TileService - TileEntity before DB insert: Lat={Lat:F12}, Lon={Lon:F12}", + tileEntity.Latitude, tileEntity.Longitude); + await _tileRepository.InsertAsync(tileEntity); _logger.LogInformation("Saved new tile {Id} at ({Lat:F12}, {Lon:F12}) version {Version}", tileEntity.Id, tileEntity.Latitude, tileEntity.Longitude, currentVersion); result.Add(MapToMetadata(tileEntity));