From 8714a4817dd85c4a58644ce9304b420b45281b5b Mon Sep 17 00:00:00 2001 From: Anton Martynenko Date: Sat, 1 Nov 2025 15:55:41 +0100 Subject: [PATCH] route in progress, region stitching is disabled by default --- SatelliteProvider.Api/Program.cs | 59 +++++- .../DTO/CreateRouteRequest.cs | 12 ++ SatelliteProvider.Common/DTO/RegionRequest.cs | 1 + SatelliteProvider.Common/DTO/RoutePoint.cs | 8 + SatelliteProvider.Common/DTO/RoutePointDto.cs | 12 ++ SatelliteProvider.Common/DTO/RouteResponse.cs | 16 ++ .../Interfaces/IRegionService.cs | 2 +- .../Interfaces/IRouteService.cs | 10 + SatelliteProvider.Common/Utils/GeoUtils.cs | 35 ++++ .../Migrations/005_CreateRoutesTables.sql | 37 ++++ .../006_AddStitchTilesToRegions.sql | 2 + .../Models/RegionEntity.cs | 1 + .../Models/RouteEntity.cs | 15 ++ .../Models/RoutePointEntity.cs | 15 ++ .../Repositories/IRouteRepository.cs | 16 ++ .../Repositories/RegionRepository.cs | 9 +- .../Repositories/RouteRepository.cs | 114 ++++++++++ SatelliteProvider.IntegrationTests/Models.cs | 40 ++++ SatelliteProvider.IntegrationTests/Program.cs | 2 + .../RegionTests.cs | 20 +- .../RouteTests.cs | 121 +++++++++++ SatelliteProvider.Services/RegionService.cs | 20 +- SatelliteProvider.Services/RouteService.cs | 194 ++++++++++++++++++ 23 files changed, 743 insertions(+), 18 deletions(-) create mode 100644 SatelliteProvider.Common/DTO/CreateRouteRequest.cs create mode 100644 SatelliteProvider.Common/DTO/RoutePoint.cs create mode 100644 SatelliteProvider.Common/DTO/RoutePointDto.cs create mode 100644 SatelliteProvider.Common/DTO/RouteResponse.cs create mode 100644 SatelliteProvider.Common/Interfaces/IRouteService.cs create mode 100644 SatelliteProvider.DataAccess/Migrations/005_CreateRoutesTables.sql create mode 100644 SatelliteProvider.DataAccess/Migrations/006_AddStitchTilesToRegions.sql create mode 100644 SatelliteProvider.DataAccess/Models/RouteEntity.cs create mode 100644 SatelliteProvider.DataAccess/Models/RoutePointEntity.cs create mode 100644 SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs create mode 100644 SatelliteProvider.DataAccess/Repositories/RouteRepository.cs create mode 100644 SatelliteProvider.IntegrationTests/RouteTests.cs create mode 100644 SatelliteProvider.Services/RouteService.cs diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index 3765936..8c1d805 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -25,6 +25,7 @@ builder.Services.Configure(builder.Configuration.GetSection("P builder.Services.AddSingleton(sp => new TileRepository(connectionString)); builder.Services.AddSingleton(sp => new RegionRepository(connectionString)); +builder.Services.AddSingleton(sp => new RouteRepository(connectionString)); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); @@ -34,6 +35,7 @@ var processingConfig = builder.Configuration.GetSection("ProcessingConfig").Get< builder.Services.AddSingleton(sp => new RegionRequestQueue(processingConfig.QueueCapacity)); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => @@ -103,6 +105,12 @@ app.MapPost("/api/satellite/request", RequestRegion) app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus) .WithOpenApi(op => new(op) { Summary = "Get region status and file paths" }); +app.MapPost("/api/satellite/route", CreateRoute) + .WithOpenApi(op => new(op) { Summary = "Create a route with intermediate points" }); + +app.MapGet("/api/satellite/route/{id:guid}", GetRoute) + .WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" }); + app.Run(); IResult GetSatelliteTilesByLatLon(double lat, double lon, double squareSideMeters) @@ -187,15 +195,16 @@ async Task RequestRegion([FromBody] RequestRegionRequest request, IRegi return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" }); } - logger.LogInformation("Region request received: ID={Id}, Lat={Lat}, Lon={Lon}, Size={Size}m, Zoom={Zoom}", - request.Id, request.Latitude, request.Longitude, request.SizeMeters, request.ZoomLevel); + logger.LogInformation("Region request received: ID={Id}, Lat={Lat}, Lon={Lon}, Size={Size}m, Zoom={Zoom}, Stitch={Stitch}", + request.Id, request.Latitude, request.Longitude, request.SizeMeters, request.ZoomLevel, request.StitchTiles); var status = await regionService.RequestRegionAsync( request.Id, request.Latitude, request.Longitude, request.SizeMeters, - request.ZoomLevel); + request.ZoomLevel, + request.StitchTiles); return Results.Ok(status); } @@ -226,6 +235,48 @@ async Task GetRegionStatus(Guid id, IRegionService regionService, ILogg } } +async Task CreateRoute([FromBody] CreateRouteRequest request, IRouteService routeService, ILogger logger) +{ + 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); + + var route = await routeService.CreateRouteAsync(request); + return Results.Ok(route); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Invalid route request"); + return Results.BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create route"); + return Results.Problem(detail: ex.Message, statusCode: 500); + } +} + +async Task GetRoute(Guid id, IRouteService routeService, ILogger logger) +{ + try + { + var route = await routeService.GetRouteAsync(id); + + if (route == null) + { + return Results.NotFound(new { error = $"Route {id} not found" }); + } + + return Results.Ok(route); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get route"); + return Results.Problem(detail: ex.Message, statusCode: 500); + } +} + public record GetSatelliteTilesResponse { public List Tiles { get; set; } = new(); @@ -317,6 +368,8 @@ public record RequestRegionRequest [Required] public int ZoomLevel { get; set; } = 18; + + public bool StitchTiles { get; set; } = false; } public class ParameterDescriptionFilter : IOperationFilter diff --git a/SatelliteProvider.Common/DTO/CreateRouteRequest.cs b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs new file mode 100644 index 0000000..3ad5680 --- /dev/null +++ b/SatelliteProvider.Common/DTO/CreateRouteRequest.cs @@ -0,0 +1,12 @@ +namespace SatelliteProvider.Common.DTO; + +public class CreateRouteRequest +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public double RegionSizeMeters { get; set; } + public int ZoomLevel { get; set; } + public List Points { get; set; } = new(); +} + diff --git a/SatelliteProvider.Common/DTO/RegionRequest.cs b/SatelliteProvider.Common/DTO/RegionRequest.cs index 920a68d..e784fb3 100644 --- a/SatelliteProvider.Common/DTO/RegionRequest.cs +++ b/SatelliteProvider.Common/DTO/RegionRequest.cs @@ -7,5 +7,6 @@ public class RegionRequest public double Longitude { get; set; } public double SizeMeters { get; set; } public int ZoomLevel { get; set; } + public bool StitchTiles { get; set; } } diff --git a/SatelliteProvider.Common/DTO/RoutePoint.cs b/SatelliteProvider.Common/DTO/RoutePoint.cs new file mode 100644 index 0000000..fcff6a4 --- /dev/null +++ b/SatelliteProvider.Common/DTO/RoutePoint.cs @@ -0,0 +1,8 @@ +namespace SatelliteProvider.Common.DTO; + +public class RoutePoint +{ + public double Latitude { get; set; } + public double Longitude { get; set; } +} + diff --git a/SatelliteProvider.Common/DTO/RoutePointDto.cs b/SatelliteProvider.Common/DTO/RoutePointDto.cs new file mode 100644 index 0000000..39b654a --- /dev/null +++ b/SatelliteProvider.Common/DTO/RoutePointDto.cs @@ -0,0 +1,12 @@ +namespace SatelliteProvider.Common.DTO; + +public class RoutePointDto +{ + public double Latitude { get; set; } + public double Longitude { get; set; } + public string PointType { get; set; } = string.Empty; + public int SequenceNumber { get; set; } + public int SegmentIndex { get; set; } + public double? DistanceFromPrevious { get; set; } +} + diff --git a/SatelliteProvider.Common/DTO/RouteResponse.cs b/SatelliteProvider.Common/DTO/RouteResponse.cs new file mode 100644 index 0000000..579ef1a --- /dev/null +++ b/SatelliteProvider.Common/DTO/RouteResponse.cs @@ -0,0 +1,16 @@ +namespace SatelliteProvider.Common.DTO; + +public class RouteResponse +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public double RegionSizeMeters { get; set; } + public int ZoomLevel { get; set; } + public double TotalDistanceMeters { get; set; } + public int TotalPoints { get; set; } + public List Points { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + diff --git a/SatelliteProvider.Common/Interfaces/IRegionService.cs b/SatelliteProvider.Common/Interfaces/IRegionService.cs index 8d72469..e375466 100644 --- a/SatelliteProvider.Common/Interfaces/IRegionService.cs +++ b/SatelliteProvider.Common/Interfaces/IRegionService.cs @@ -4,7 +4,7 @@ namespace SatelliteProvider.Common.Interfaces; public interface IRegionService { - Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel); + Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false); Task GetRegionStatusAsync(Guid id); Task ProcessRegionAsync(Guid id, CancellationToken cancellationToken = default); } diff --git a/SatelliteProvider.Common/Interfaces/IRouteService.cs b/SatelliteProvider.Common/Interfaces/IRouteService.cs new file mode 100644 index 0000000..9d1356e --- /dev/null +++ b/SatelliteProvider.Common/Interfaces/IRouteService.cs @@ -0,0 +1,10 @@ +using SatelliteProvider.Common.DTO; + +namespace SatelliteProvider.Common.Interfaces; + +public interface IRouteService +{ + Task CreateRouteAsync(CreateRouteRequest request); + Task GetRouteAsync(Guid id); +} + diff --git a/SatelliteProvider.Common/Utils/GeoUtils.cs b/SatelliteProvider.Common/Utils/GeoUtils.cs index e85c6b1..6f23237 100644 --- a/SatelliteProvider.Common/Utils/GeoUtils.cs +++ b/SatelliteProvider.Common/Utils/GeoUtils.cs @@ -83,4 +83,39 @@ public static class GeoUtils return (minLat, maxLat, minLon, maxLon); } + + public static List CalculateIntermediatePoints(GeoPoint start, GeoPoint end, double maxSpacingMeters) + { + var direction = start.DirectionTo(end); + var distance = direction.Distance; + + if (distance <= maxSpacingMeters) + { + return new List(); + } + + var numSegments = (int)Math.Ceiling(distance / maxSpacingMeters); + var actualSpacing = distance / numSegments; + + var intermediatePoints = new List(); + + for (int i = 1; i < numSegments; i++) + { + var segmentDistance = actualSpacing * i; + var intermediateDirection = new Direction + { + Distance = segmentDistance, + Azimuth = direction.Azimuth + }; + var intermediatePoint = start.GoDirection(intermediateDirection); + intermediatePoints.Add(intermediatePoint); + } + + return intermediatePoints; + } + + public static double CalculateDistance(GeoPoint p1, GeoPoint p2) + { + return p1.DirectionTo(p2).Distance; + } } \ No newline at end of file diff --git a/SatelliteProvider.DataAccess/Migrations/005_CreateRoutesTables.sql b/SatelliteProvider.DataAccess/Migrations/005_CreateRoutesTables.sql new file mode 100644 index 0000000..a1e13b3 --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/005_CreateRoutesTables.sql @@ -0,0 +1,37 @@ +CREATE TABLE routes ( + id UUID PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + region_size_meters DOUBLE PRECISION NOT NULL, + zoom_level INT NOT NULL, + total_distance_meters DOUBLE PRECISION NOT NULL, + total_points INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE route_points ( + id UUID PRIMARY KEY, + route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + sequence_number INT NOT NULL, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + point_type VARCHAR(20) NOT NULL, + segment_index INT NOT NULL, + distance_from_previous DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(route_id, sequence_number) +); + +CREATE TABLE route_regions ( + route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + region_id UUID NOT NULL REFERENCES regions(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (route_id, region_id) +); + +CREATE INDEX idx_route_points_route ON route_points(route_id, sequence_number); +CREATE INDEX idx_route_points_coords ON route_points(latitude, longitude); +CREATE INDEX idx_route_regions_route ON route_regions(route_id); +CREATE INDEX idx_route_regions_region ON route_regions(region_id); + diff --git a/SatelliteProvider.DataAccess/Migrations/006_AddStitchTilesToRegions.sql b/SatelliteProvider.DataAccess/Migrations/006_AddStitchTilesToRegions.sql new file mode 100644 index 0000000..2aee7ae --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/006_AddStitchTilesToRegions.sql @@ -0,0 +1,2 @@ +ALTER TABLE regions ADD COLUMN stitch_tiles BOOLEAN NOT NULL DEFAULT false; + diff --git a/SatelliteProvider.DataAccess/Models/RegionEntity.cs b/SatelliteProvider.DataAccess/Models/RegionEntity.cs index b1bb61b..548b156 100644 --- a/SatelliteProvider.DataAccess/Models/RegionEntity.cs +++ b/SatelliteProvider.DataAccess/Models/RegionEntity.cs @@ -12,6 +12,7 @@ public class RegionEntity public string? SummaryFilePath { get; set; } public int TilesDownloaded { get; set; } public int TilesReused { get; set; } + public bool StitchTiles { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/SatelliteProvider.DataAccess/Models/RouteEntity.cs b/SatelliteProvider.DataAccess/Models/RouteEntity.cs new file mode 100644 index 0000000..37cb1ce --- /dev/null +++ b/SatelliteProvider.DataAccess/Models/RouteEntity.cs @@ -0,0 +1,15 @@ +namespace SatelliteProvider.DataAccess.Models; + +public class RouteEntity +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public double RegionSizeMeters { get; set; } + public int ZoomLevel { get; set; } + public double TotalDistanceMeters { get; set; } + public int TotalPoints { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + diff --git a/SatelliteProvider.DataAccess/Models/RoutePointEntity.cs b/SatelliteProvider.DataAccess/Models/RoutePointEntity.cs new file mode 100644 index 0000000..7fb6a28 --- /dev/null +++ b/SatelliteProvider.DataAccess/Models/RoutePointEntity.cs @@ -0,0 +1,15 @@ +namespace SatelliteProvider.DataAccess.Models; + +public class RoutePointEntity +{ + public Guid Id { get; set; } + public Guid RouteId { get; set; } + public int SequenceNumber { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public string PointType { get; set; } = string.Empty; + public int SegmentIndex { get; set; } + public double? DistanceFromPrevious { get; set; } + public DateTime CreatedAt { get; set; } +} + diff --git a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs new file mode 100644 index 0000000..5f7f845 --- /dev/null +++ b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs @@ -0,0 +1,16 @@ +using SatelliteProvider.DataAccess.Models; + +namespace SatelliteProvider.DataAccess.Repositories; + +public interface IRouteRepository +{ + Task GetByIdAsync(Guid id); + Task> GetRoutePointsAsync(Guid routeId); + Task InsertRouteAsync(RouteEntity route); + Task InsertRoutePointsAsync(IEnumerable points); + Task UpdateRouteAsync(RouteEntity route); + Task DeleteRouteAsync(Guid id); + Task LinkRouteToRegionAsync(Guid routeId, Guid regionId); + Task> GetRegionIdsByRouteAsync(Guid routeId); +} + diff --git a/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs b/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs index a4333ea..aecb4d2 100644 --- a/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/RegionRepository.cs @@ -21,6 +21,7 @@ public class RegionRepository : IRegionRepository zoom_level as ZoomLevel, status, csv_file_path as CsvFilePath, summary_file_path as SummaryFilePath, tiles_downloaded as TilesDownloaded, tiles_reused as TilesReused, + stitch_tiles as StitchTiles, created_at as CreatedAt, updated_at as UpdatedAt FROM regions WHERE id = @Id"; @@ -36,6 +37,7 @@ public class RegionRepository : IRegionRepository zoom_level as ZoomLevel, status, csv_file_path as CsvFilePath, summary_file_path as SummaryFilePath, tiles_downloaded as TilesDownloaded, tiles_reused as TilesReused, + stitch_tiles as StitchTiles, created_at as CreatedAt, updated_at as UpdatedAt FROM regions WHERE status = @Status @@ -50,10 +52,12 @@ public class RegionRepository : IRegionRepository const string sql = @" INSERT INTO regions (id, latitude, longitude, size_meters, zoom_level, status, csv_file_path, summary_file_path, - tiles_downloaded, tiles_reused, created_at, updated_at) + tiles_downloaded, tiles_reused, stitch_tiles, + created_at, updated_at) VALUES (@Id, @Latitude, @Longitude, @SizeMeters, @ZoomLevel, @Status, @CsvFilePath, @SummaryFilePath, - @TilesDownloaded, @TilesReused, @CreatedAt, @UpdatedAt) + @TilesDownloaded, @TilesReused, @StitchTiles, + @CreatedAt, @UpdatedAt) RETURNING id"; return await connection.ExecuteScalarAsync(sql, region); @@ -73,6 +77,7 @@ public class RegionRepository : IRegionRepository summary_file_path = @SummaryFilePath, tiles_downloaded = @TilesDownloaded, tiles_reused = @TilesReused, + stitch_tiles = @StitchTiles, updated_at = @UpdatedAt WHERE id = @Id"; diff --git a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs new file mode 100644 index 0000000..6aa9ee0 --- /dev/null +++ b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs @@ -0,0 +1,114 @@ +using Dapper; +using Npgsql; +using SatelliteProvider.DataAccess.Models; + +namespace SatelliteProvider.DataAccess.Repositories; + +public class RouteRepository : IRouteRepository +{ + private readonly string _connectionString; + + public RouteRepository(string connectionString) + { + _connectionString = connectionString; + } + + public async Task GetByIdAsync(Guid id) + { + 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, created_at as CreatedAt, updated_at as UpdatedAt + FROM routes + WHERE id = @Id"; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetRoutePointsAsync(Guid routeId) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + SELECT id, route_id as RouteId, sequence_number as SequenceNumber, + latitude, longitude, point_type as PointType, segment_index as SegmentIndex, + distance_from_previous as DistanceFromPrevious, created_at as CreatedAt + FROM route_points + WHERE route_id = @RouteId + ORDER BY sequence_number"; + + return await connection.QueryAsync(sql, new { RouteId = routeId }); + } + + public async Task InsertRouteAsync(RouteEntity route) + { + 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) + VALUES (@Id, @Name, @Description, @RegionSizeMeters, @ZoomLevel, + @TotalDistanceMeters, @TotalPoints, @CreatedAt, @UpdatedAt) + RETURNING id"; + + return await connection.ExecuteScalarAsync(sql, route); + } + + public async Task InsertRoutePointsAsync(IEnumerable points) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + INSERT INTO route_points (id, route_id, sequence_number, latitude, longitude, + point_type, segment_index, distance_from_previous, created_at) + VALUES (@Id, @RouteId, @SequenceNumber, @Latitude, @Longitude, + @PointType, @SegmentIndex, @DistanceFromPrevious, @CreatedAt)"; + + await connection.ExecuteAsync(sql, points); + } + + public async Task UpdateRouteAsync(RouteEntity route) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + UPDATE routes + SET name = @Name, + description = @Description, + region_size_meters = @RegionSizeMeters, + zoom_level = @ZoomLevel, + total_distance_meters = @TotalDistanceMeters, + total_points = @TotalPoints, + updated_at = @UpdatedAt + WHERE id = @Id"; + + return await connection.ExecuteAsync(sql, route); + } + + public async Task DeleteRouteAsync(Guid id) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = "DELETE FROM routes WHERE id = @Id"; + return await connection.ExecuteAsync(sql, new { Id = id }); + } + + public async Task LinkRouteToRegionAsync(Guid routeId, Guid regionId) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + INSERT INTO route_regions (route_id, region_id, created_at) + VALUES (@RouteId, @RegionId, @CreatedAt) + ON CONFLICT (route_id, region_id) DO NOTHING"; + + await connection.ExecuteAsync(sql, new { RouteId = routeId, RegionId = regionId, CreatedAt = DateTime.UtcNow }); + } + + public async Task> GetRegionIdsByRouteAsync(Guid routeId) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + SELECT region_id + FROM route_regions + WHERE route_id = @RouteId"; + + return await connection.QueryAsync(sql, new { RouteId = routeId }); + } +} + diff --git a/SatelliteProvider.IntegrationTests/Models.cs b/SatelliteProvider.IntegrationTests/Models.cs index 383eb74..e50464d 100644 --- a/SatelliteProvider.IntegrationTests/Models.cs +++ b/SatelliteProvider.IntegrationTests/Models.cs @@ -29,6 +29,7 @@ public record RequestRegionRequest public double Longitude { get; set; } public double SizeMeters { get; set; } public int ZoomLevel { get; set; } + public bool StitchTiles { get; set; } = false; } public record RegionStatusResponse @@ -43,3 +44,42 @@ public record RegionStatusResponse public DateTime UpdatedAt { get; set; } } +public class RoutePointInput +{ + public double Latitude { get; set; } + public double Longitude { get; set; } +} + +public class CreateRouteRequest +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public double RegionSizeMeters { get; set; } + public int ZoomLevel { get; set; } + public List Points { get; set; } = new(); +} + +public class RoutePointModel +{ + public double Latitude { get; set; } + public double Longitude { get; set; } + public string PointType { get; set; } = string.Empty; + public int SequenceNumber { get; set; } + public int SegmentIndex { get; set; } + public double? DistanceFromPrevious { get; set; } +} + +public class RouteResponseModel +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public double RegionSizeMeters { get; set; } + public int ZoomLevel { get; set; } + public double TotalDistanceMeters { get; set; } + public int TotalPoints { get; set; } + public List Points { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/SatelliteProvider.IntegrationTests/Program.cs b/SatelliteProvider.IntegrationTests/Program.cs index 360fa34..bf4e64f 100644 --- a/SatelliteProvider.IntegrationTests/Program.cs +++ b/SatelliteProvider.IntegrationTests/Program.cs @@ -32,6 +32,8 @@ class Program await RegionTests.RunRegionProcessingTest_500m_Zoom18(httpClient); + await RouteTests.RunSimpleRouteTest(httpClient); + Console.WriteLine(); Console.WriteLine("========================="); Console.WriteLine("All tests completed successfully!"); diff --git a/SatelliteProvider.IntegrationTests/RegionTests.cs b/SatelliteProvider.IntegrationTests/RegionTests.cs index e78ca2a..462e0ed 100644 --- a/SatelliteProvider.IntegrationTests/RegionTests.cs +++ b/SatelliteProvider.IntegrationTests/RegionTests.cs @@ -20,8 +20,9 @@ public static class RegionTests const double longitude = 37.647063; const double sizeMeters = 200; const int zoomLevel = 18; + const bool stitchTiles = false; - await RunRegionProcessingTest(httpClient, latitude, longitude, sizeMeters, zoomLevel); + await RunRegionProcessingTest(httpClient, latitude, longitude, sizeMeters, zoomLevel, stitchTiles); Console.WriteLine(); Console.WriteLine("Region Processing Test (200m, Zoom 18): PASSED"); @@ -37,8 +38,9 @@ public static class RegionTests const double longitude = 37.647063; const double sizeMeters = 400; const int zoomLevel = 17; + const bool stitchTiles = false; - await RunRegionProcessingTest(httpClient, latitude, longitude, sizeMeters, zoomLevel); + await RunRegionProcessingTest(httpClient, latitude, longitude, sizeMeters, zoomLevel, stitchTiles); Console.WriteLine(); Console.WriteLine("Region Processing Test (400m, Zoom 17): PASSED"); @@ -47,18 +49,19 @@ public static class RegionTests public static async Task RunRegionProcessingTest_500m_Zoom18(HttpClient httpClient) { Console.WriteLine(); - Console.WriteLine("Test: Region Processing 500m at Zoom 18"); + Console.WriteLine("Test: Region Processing 500m at Zoom 18 with Stitching"); Console.WriteLine("------------------------------------------------------------------"); const double latitude = 47.461747; const double longitude = 37.647063; const double sizeMeters = 500; const int zoomLevel = 18; + const bool stitchTiles = true; - await RunRegionProcessingTest(httpClient, latitude, longitude, sizeMeters, zoomLevel); + await RunRegionProcessingTest(httpClient, latitude, longitude, sizeMeters, zoomLevel, stitchTiles); Console.WriteLine(); - Console.WriteLine("Region Processing Test (500m, Zoom 18): PASSED"); + Console.WriteLine("Region Processing Test (500m, Zoom 18 with Stitching): PASSED"); } private static async Task RunRegionProcessingTest( @@ -66,7 +69,8 @@ public static class RegionTests double latitude, double longitude, double sizeMeters, - int zoomLevel) + int zoomLevel, + bool stitchTiles) { var regionId = Guid.NewGuid(); @@ -74,6 +78,7 @@ public static class RegionTests Console.WriteLine($" Coordinates: ({latitude}, {longitude})"); Console.WriteLine($" Size: {sizeMeters}m"); Console.WriteLine($" Zoom Level: {zoomLevel}"); + Console.WriteLine($" Stitch Tiles: {stitchTiles}"); Console.WriteLine(); var requestRegion = new RequestRegionRequest @@ -82,7 +87,8 @@ public static class RegionTests Latitude = latitude, Longitude = longitude, SizeMeters = sizeMeters, - ZoomLevel = zoomLevel + ZoomLevel = zoomLevel, + StitchTiles = stitchTiles }; var requestResponse = await httpClient.PostAsJsonAsync("/api/satellite/request", requestRegion); diff --git a/SatelliteProvider.IntegrationTests/RouteTests.cs b/SatelliteProvider.IntegrationTests/RouteTests.cs new file mode 100644 index 0000000..e57c932 --- /dev/null +++ b/SatelliteProvider.IntegrationTests/RouteTests.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace SatelliteProvider.IntegrationTests; + +public static class RouteTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public static async Task RunSimpleRouteTest(HttpClient httpClient) + { + Console.WriteLine("Test: Create Simple Route with Two Points"); + Console.WriteLine("-----------------------------------------"); + + var routeId = Guid.NewGuid(); + var request = new CreateRouteRequest + { + Id = routeId, + Name = "Simple Test Route", + Description = "Test route with 2 points", + RegionSizeMeters = 500.0, + ZoomLevel = 18, + Points = new List + { + new() { Latitude = 48.276067180586544, Longitude = 37.38445758819581 }, + new() { Latitude = 48.27074009522731, Longitude = 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($" Region Size: {request.RegionSizeMeters}m"); + Console.WriteLine($" Zoom Level: {request.ZoomLevel}"); + Console.WriteLine(); + + var response = await httpClient.PostAsJsonAsync("/api/satellite/route", request); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new Exception($"API returned error status {response.StatusCode}: {errorContent}"); + } + + var route = await response.Content.ReadFromJsonAsync(JsonOptions); + + if (route == null) + { + throw new Exception("No route data returned from API"); + } + + Console.WriteLine("Route Details:"); + Console.WriteLine($" ID: {route.Id}"); + Console.WriteLine($" Name: {route.Name}"); + Console.WriteLine($" Total Points: {route.TotalPoints}"); + Console.WriteLine($" Total Distance: {route.TotalDistanceMeters:F2}m"); + Console.WriteLine(); + + var startPoints = route.Points.Count(p => p.PointType == "start"); + var endPoints = route.Points.Count(p => p.PointType == "end"); + var intermediatePoints = route.Points.Count(p => p.PointType == "intermediate"); + + Console.WriteLine("Point Types:"); + Console.WriteLine($" Start points: {startPoints}"); + Console.WriteLine($" Intermediate points: {intermediatePoints}"); + Console.WriteLine($" End points: {endPoints}"); + Console.WriteLine(); + + if (startPoints != 1) + { + throw new Exception($"Expected 1 start point, got {startPoints}"); + } + + if (endPoints != 1) + { + throw new Exception($"Expected 1 end point, got {endPoints}"); + } + + Console.WriteLine("Point spacing validation:"); + for (int i = 1; i < route.Points.Count; i++) + { + var point = route.Points[i]; + if (point.DistanceFromPrevious.HasValue) + { + if (point.DistanceFromPrevious.Value > 200.0) + { + throw new Exception($"Point {i} is {point.DistanceFromPrevious.Value:F2}m from previous, exceeds 200m limit"); + } + Console.WriteLine($" Point {i} ({point.PointType}): {point.DistanceFromPrevious.Value:F2}m from previous"); + } + } + Console.WriteLine(); + + Console.WriteLine("Retrieving route by ID..."); + var getResponse = await httpClient.GetAsync($"/api/satellite/route/{routeId}"); + + if (!getResponse.IsSuccessStatusCode) + { + throw new Exception($"Failed to retrieve route: {getResponse.StatusCode}"); + } + + var retrievedRoute = await getResponse.Content.ReadFromJsonAsync(JsonOptions); + + if (retrievedRoute == null || retrievedRoute.Id != routeId) + { + throw new Exception("Retrieved route does not match created route"); + } + + Console.WriteLine($"✓ Route retrieved successfully"); + Console.WriteLine($"✓ Retrieved {retrievedRoute.Points.Count} points"); + Console.WriteLine(); + Console.WriteLine("✓ Route created successfully"); + Console.WriteLine("✓ All point spacing validated (≤ 200m)"); + Console.WriteLine(); + Console.WriteLine("Simple Route Test: PASSED"); + } +} + diff --git a/SatelliteProvider.Services/RegionService.cs b/SatelliteProvider.Services/RegionService.cs index 7b4401f..7614d7b 100644 --- a/SatelliteProvider.Services/RegionService.cs +++ b/SatelliteProvider.Services/RegionService.cs @@ -34,7 +34,7 @@ public class RegionService : IRegionService _logger = logger; } - public async Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel) + public async Task RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false) { var now = DateTime.UtcNow; var region = new RegionEntity @@ -44,6 +44,7 @@ public class RegionService : IRegionService Longitude = longitude, SizeMeters = sizeMeters, ZoomLevel = zoomLevel, + StitchTiles = stitchTiles, Status = "queued", TilesDownloaded = 0, TilesReused = 0, @@ -59,7 +60,8 @@ public class RegionService : IRegionService Latitude = latitude, Longitude = longitude, SizeMeters = sizeMeters, - ZoomLevel = zoomLevel + ZoomLevel = zoomLevel, + StitchTiles = stitchTiles }; await _queue.EnqueueAsync(request); @@ -134,12 +136,20 @@ public class RegionService : IRegionService var csvPath = Path.Combine(readyDir, $"region_{id}_ready.csv"); var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt"); - var stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg"); + string? stitchedImagePath = null; await GenerateCsvFileAsync(csvPath, tiles, linkedCts.Token); - _logger.LogInformation("Stitching tiles for region {RegionId}", id); - await StitchTilesAsync(tiles, region.Latitude, region.Longitude, region.ZoomLevel, stitchedImagePath, linkedCts.Token); + if (region.StitchTiles) + { + stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg"); + _logger.LogInformation("Stitching tiles for region {RegionId}", id); + await StitchTilesAsync(tiles, region.Latitude, region.Longitude, region.ZoomLevel, stitchedImagePath, linkedCts.Token); + } + else + { + _logger.LogInformation("Skipping tile stitching for region {RegionId}", id); + } await GenerateSummaryFileAsync(summaryPath, id, region, tiles, tilesDownloaded, tilesReused, stitchedImagePath, processingStartTime, linkedCts.Token, errorMessage); diff --git a/SatelliteProvider.Services/RouteService.cs b/SatelliteProvider.Services/RouteService.cs new file mode 100644 index 0000000..7a4f22e --- /dev/null +++ b/SatelliteProvider.Services/RouteService.cs @@ -0,0 +1,194 @@ +using Microsoft.Extensions.Logging; +using SatelliteProvider.Common.DTO; +using SatelliteProvider.Common.Interfaces; +using SatelliteProvider.Common.Utils; +using SatelliteProvider.DataAccess.Models; +using SatelliteProvider.DataAccess.Repositories; + +namespace SatelliteProvider.Services; + +public class RouteService : IRouteService +{ + private readonly IRouteRepository _routeRepository; + private readonly ILogger _logger; + private const double MAX_POINT_SPACING_METERS = 200.0; + + public RouteService( + IRouteRepository routeRepository, + ILogger logger) + { + _routeRepository = routeRepository; + _logger = logger; + } + + public async Task CreateRouteAsync(CreateRouteRequest request) + { + if (request.Points.Count < 2) + { + throw new ArgumentException("Route must have at least 2 points"); + } + + if (request.RegionSizeMeters < 100 || request.RegionSizeMeters > 10000) + { + throw new ArgumentException("Region size must be between 100 and 10000 meters"); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new ArgumentException("Route name is required"); + } + + _logger.LogInformation("Creating route {RouteId} with {PointCount} original points", + request.Id, request.Points.Count); + + var allPoints = new List(); + var totalDistance = 0.0; + var sequenceNumber = 0; + + for (int segmentIndex = 0; segmentIndex < request.Points.Count; segmentIndex++) + { + var currentPoint = request.Points[segmentIndex]; + var isStart = segmentIndex == 0; + var isEnd = segmentIndex == request.Points.Count - 1; + + var geoPoint = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude); + + double? distanceFromPrevious = null; + if (segmentIndex > 0) + { + var prevPoint = request.Points[segmentIndex - 1]; + var prevGeoPoint = new GeoPoint(prevPoint.Latitude, prevPoint.Longitude); + distanceFromPrevious = GeoUtils.CalculateDistance(prevGeoPoint, geoPoint); + totalDistance += distanceFromPrevious.Value; + } + + var pointType = isStart ? "start" : (isEnd ? "end" : "waypoint"); + + allPoints.Add(new RoutePointDto + { + Latitude = currentPoint.Latitude, + Longitude = currentPoint.Longitude, + PointType = pointType, + SequenceNumber = sequenceNumber++, + SegmentIndex = segmentIndex, + DistanceFromPrevious = distanceFromPrevious + }); + + if (!isEnd) + { + var nextPoint = request.Points[segmentIndex + 1]; + var startGeo = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude); + var endGeo = new GeoPoint(nextPoint.Latitude, nextPoint.Longitude); + + var intermediatePoints = GeoUtils.CalculateIntermediatePoints(startGeo, endGeo, MAX_POINT_SPACING_METERS); + + _logger.LogInformation("Segment {SegmentIndex}: Adding {Count} intermediate points", + segmentIndex, intermediatePoints.Count); + + foreach (var intermediateGeo in intermediatePoints) + { + var prevGeo = sequenceNumber == 1 ? startGeo : new GeoPoint( + allPoints[sequenceNumber - 1].Latitude, + allPoints[sequenceNumber - 1].Longitude); + + var distFromPrev = GeoUtils.CalculateDistance(prevGeo, intermediateGeo); + totalDistance += distFromPrev; + + allPoints.Add(new RoutePointDto + { + Latitude = intermediateGeo.Lat, + Longitude = intermediateGeo.Lon, + PointType = "intermediate", + SequenceNumber = sequenceNumber++, + SegmentIndex = segmentIndex, + DistanceFromPrevious = distFromPrev + }); + } + } + } + + _logger.LogInformation("Route {RouteId}: Total {TotalPoints} points (original + intermediate), distance {Distance:F2}m", + request.Id, allPoints.Count, totalDistance); + + var now = DateTime.UtcNow; + var routeEntity = new RouteEntity + { + Id = request.Id, + Name = request.Name, + Description = request.Description, + RegionSizeMeters = request.RegionSizeMeters, + ZoomLevel = request.ZoomLevel, + TotalDistanceMeters = totalDistance, + TotalPoints = allPoints.Count, + CreatedAt = now, + UpdatedAt = now + }; + + await _routeRepository.InsertRouteAsync(routeEntity); + + var pointEntities = allPoints.Select(p => new RoutePointEntity + { + Id = Guid.NewGuid(), + RouteId = request.Id, + SequenceNumber = p.SequenceNumber, + Latitude = p.Latitude, + Longitude = p.Longitude, + PointType = p.PointType, + SegmentIndex = p.SegmentIndex, + DistanceFromPrevious = p.DistanceFromPrevious, + CreatedAt = now + }).ToList(); + + await _routeRepository.InsertRoutePointsAsync(pointEntities); + + _logger.LogInformation("Route {RouteId} created successfully", request.Id); + + return new RouteResponse + { + Id = routeEntity.Id, + Name = routeEntity.Name, + Description = routeEntity.Description, + RegionSizeMeters = routeEntity.RegionSizeMeters, + ZoomLevel = routeEntity.ZoomLevel, + TotalDistanceMeters = routeEntity.TotalDistanceMeters, + TotalPoints = routeEntity.TotalPoints, + Points = allPoints, + CreatedAt = routeEntity.CreatedAt, + UpdatedAt = routeEntity.UpdatedAt + }; + } + + public async Task GetRouteAsync(Guid id) + { + var route = await _routeRepository.GetByIdAsync(id); + if (route == null) + { + return null; + } + + var points = await _routeRepository.GetRoutePointsAsync(id); + + return new RouteResponse + { + Id = route.Id, + Name = route.Name, + Description = route.Description, + RegionSizeMeters = route.RegionSizeMeters, + ZoomLevel = route.ZoomLevel, + TotalDistanceMeters = route.TotalDistanceMeters, + TotalPoints = route.TotalPoints, + Points = points.Select(p => new RoutePointDto + { + Latitude = p.Latitude, + Longitude = p.Longitude, + PointType = p.PointType, + SequenceNumber = p.SequenceNumber, + SegmentIndex = p.SegmentIndex, + DistanceFromPrevious = p.DistanceFromPrevious + }).ToList(), + CreatedAt = route.CreatedAt, + UpdatedAt = route.UpdatedAt + }; + } +} +