mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-04-22 09:26:39 +00:00
route in progress, region stitching is disabled by default
This commit is contained in:
@@ -25,6 +25,7 @@ builder.Services.Configure<ProcessingConfig>(builder.Configuration.GetSection("P
|
||||
|
||||
builder.Services.AddSingleton<ITileRepository>(sp => new TileRepository(connectionString));
|
||||
builder.Services.AddSingleton<IRegionRepository>(sp => new RegionRepository(connectionString));
|
||||
builder.Services.AddSingleton<IRouteRepository>(sp => new RouteRepository(connectionString));
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<GoogleMapsDownloaderV2>();
|
||||
@@ -34,6 +35,7 @@ var processingConfig = builder.Configuration.GetSection("ProcessingConfig").Get<
|
||||
builder.Services.AddSingleton<IRegionRequestQueue>(sp => new RegionRequestQueue(processingConfig.QueueCapacity));
|
||||
builder.Services.AddSingleton<IRegionService, RegionService>();
|
||||
builder.Services.AddHostedService<RegionProcessingService>();
|
||||
builder.Services.AddSingleton<IRouteService, RouteService>();
|
||||
|
||||
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<IResult> 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<IResult> GetRegionStatus(Guid id, IRegionService regionService, ILogg
|
||||
}
|
||||
}
|
||||
|
||||
async Task<IResult> CreateRoute([FromBody] CreateRouteRequest request, IRouteService routeService, ILogger<Program> 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<IResult> GetRoute(Guid id, IRouteService routeService, ILogger<Program> 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<SatelliteTile> 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
|
||||
|
||||
@@ -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<RoutePoint> Points { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class RoutePoint
|
||||
{
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<RoutePointDto> Points { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace SatelliteProvider.Common.Interfaces;
|
||||
|
||||
public interface IRegionService
|
||||
{
|
||||
Task<RegionStatus> RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel);
|
||||
Task<RegionStatus> RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false);
|
||||
Task<RegionStatus?> GetRegionStatusAsync(Guid id);
|
||||
Task ProcessRegionAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Common.Interfaces;
|
||||
|
||||
public interface IRouteService
|
||||
{
|
||||
Task<RouteResponse> CreateRouteAsync(CreateRouteRequest request);
|
||||
Task<RouteResponse?> GetRouteAsync(Guid id);
|
||||
}
|
||||
|
||||
@@ -83,4 +83,39 @@ public static class GeoUtils
|
||||
|
||||
return (minLat, maxLat, minLon, maxLon);
|
||||
}
|
||||
|
||||
public static List<GeoPoint> CalculateIntermediatePoints(GeoPoint start, GeoPoint end, double maxSpacingMeters)
|
||||
{
|
||||
var direction = start.DirectionTo(end);
|
||||
var distance = direction.Distance;
|
||||
|
||||
if (distance <= maxSpacingMeters)
|
||||
{
|
||||
return new List<GeoPoint>();
|
||||
}
|
||||
|
||||
var numSegments = (int)Math.Ceiling(distance / maxSpacingMeters);
|
||||
var actualSpacing = distance / numSegments;
|
||||
|
||||
var intermediatePoints = new List<GeoPoint>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE regions ADD COLUMN stitch_tiles BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
|
||||
namespace SatelliteProvider.DataAccess.Repositories;
|
||||
|
||||
public interface IRouteRepository
|
||||
{
|
||||
Task<RouteEntity?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<RoutePointEntity>> GetRoutePointsAsync(Guid routeId);
|
||||
Task<Guid> InsertRouteAsync(RouteEntity route);
|
||||
Task InsertRoutePointsAsync(IEnumerable<RoutePointEntity> points);
|
||||
Task<int> UpdateRouteAsync(RouteEntity route);
|
||||
Task<int> DeleteRouteAsync(Guid id);
|
||||
Task LinkRouteToRegionAsync(Guid routeId, Guid regionId);
|
||||
Task<IEnumerable<Guid>> GetRegionIdsByRouteAsync(Guid routeId);
|
||||
}
|
||||
|
||||
@@ -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<Guid>(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";
|
||||
|
||||
|
||||
@@ -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<RouteEntity?> 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<RouteEntity>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RoutePointEntity>> 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<RoutePointEntity>(sql, new { RouteId = routeId });
|
||||
}
|
||||
|
||||
public async Task<Guid> 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<Guid>(sql, route);
|
||||
}
|
||||
|
||||
public async Task InsertRoutePointsAsync(IEnumerable<RoutePointEntity> 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<int> 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<int> 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<IEnumerable<Guid>> 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<Guid>(sql, new { RouteId = routeId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RoutePointInput> 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<RoutePointModel> Points { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<RoutePointInput>
|
||||
{
|
||||
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<RouteResponseModel>(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<RouteResponseModel>(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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class RegionService : IRegionService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RegionStatus> RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel)
|
||||
public async Task<RegionStatus> 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);
|
||||
|
||||
|
||||
@@ -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<RouteService> _logger;
|
||||
private const double MAX_POINT_SPACING_METERS = 200.0;
|
||||
|
||||
public RouteService(
|
||||
IRouteRepository routeRepository,
|
||||
ILogger<RouteService> logger)
|
||||
{
|
||||
_routeRepository = routeRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RouteResponse> 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<RoutePointDto>();
|
||||
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<RouteResponse?> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user