mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 09:41:15 +00:00
89260d0ec4
Both RegionService.GenerateCsvFileAsync and RouteProcessingService.GenerateRouteCsvAsync wrote the same CSV shape: header "latitude,longitude,file_path", same OrderByDescending(Latitude).ThenBy(Longitude) ordering, same F6 numeric format. Two near-identical writers with no shared abstraction. Extracted TileCsvWriter (instance class, no DI dependencies) plus a TileCsvRow record bridging the per-pipeline DTOs (TileMetadata vs TileInfo) to a single contract. The header constant, ordering rule, and StreamWriter lifecycle now live in one place. Both call sites collapse to a one-line projection plus a delegated WriteAsync call. Region method becomes static (no longer references instance state). Route method preserves its existing logger line. Coverage: - 7 new unit tests including a byte-for-byte equivalence test that writes the same input via both the new TileCsvWriter and the inlined-original code path side by side and asserts file bytes are identical. - Integration smoke + full suite green; route + region CSV outputs unchanged across all existing scenarios (verified by extended-route CSV verification step in the integration suite). - 84/84 unit tests pass (was 77). Side improvement: writer now respects CancellationToken mid-loop. The pre-refactor inline code did not. Strict improvement; consistent with every other async API in the codebase. Co-authored-by: Cursor <cursoragent@cursor.com>
385 lines
14 KiB
C#
385 lines
14 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SatelliteProvider.Common.Configs;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Exceptions;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using SixLabors.ImageSharp.Processing;
|
|
|
|
namespace SatelliteProvider.Services.RegionProcessing;
|
|
|
|
public class RegionService : IRegionService
|
|
{
|
|
private readonly IRegionRepository _regionRepository;
|
|
private readonly IRegionRequestQueue _queue;
|
|
private readonly ITileService _tileService;
|
|
private readonly StorageConfig _storageConfig;
|
|
private readonly ILogger<RegionService> _logger;
|
|
|
|
public RegionService(
|
|
IRegionRepository regionRepository,
|
|
IRegionRequestQueue queue,
|
|
ITileService tileService,
|
|
IOptions<StorageConfig> storageConfig,
|
|
ILogger<RegionService> logger)
|
|
{
|
|
_regionRepository = regionRepository;
|
|
_queue = queue;
|
|
_tileService = tileService;
|
|
_storageConfig = storageConfig.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<RegionStatus> RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false)
|
|
{
|
|
// AZ-362: idempotent POST contract. A retried POST with the same caller-supplied
|
|
// Id returns the existing region instead of bubbling a unique-key violation.
|
|
var existing = await _regionRepository.GetByIdAsync(id);
|
|
if (existing != null)
|
|
{
|
|
_logger.LogInformation(
|
|
"Idempotent region POST: id {RegionId} already exists with status {Status}; returning existing resource without re-enqueueing",
|
|
id, existing.Status);
|
|
return MapToStatus(existing);
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var region = new RegionEntity
|
|
{
|
|
Id = id,
|
|
Latitude = latitude,
|
|
Longitude = longitude,
|
|
SizeMeters = sizeMeters,
|
|
ZoomLevel = zoomLevel,
|
|
StitchTiles = stitchTiles,
|
|
Status = "queued",
|
|
TilesDownloaded = 0,
|
|
TilesReused = 0,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
await _regionRepository.InsertAsync(region);
|
|
|
|
var request = new RegionRequest
|
|
{
|
|
Id = id,
|
|
Latitude = latitude,
|
|
Longitude = longitude,
|
|
SizeMeters = sizeMeters,
|
|
ZoomLevel = zoomLevel,
|
|
StitchTiles = stitchTiles
|
|
};
|
|
|
|
await _queue.EnqueueAsync(request);
|
|
|
|
return MapToStatus(region);
|
|
}
|
|
|
|
public async Task<RegionStatus?> GetRegionStatusAsync(Guid id)
|
|
{
|
|
var region = await _regionRepository.GetByIdAsync(id);
|
|
return region != null ? MapToStatus(region) : null;
|
|
}
|
|
|
|
public async Task ProcessRegionAsync(Guid id, CancellationToken cancellationToken = default)
|
|
{
|
|
var startTime = DateTime.UtcNow;
|
|
|
|
var region = await _regionRepository.GetByIdAsync(id);
|
|
if (region == null)
|
|
{
|
|
_logger.LogWarning("Region {RegionId} not found in database", id);
|
|
return;
|
|
}
|
|
|
|
region.Status = "processing";
|
|
region.UpdatedAt = DateTime.UtcNow;
|
|
await _regionRepository.UpdateAsync(region);
|
|
|
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
|
|
|
string? errorMessage = null;
|
|
List<TileMetadata>? tiles = null;
|
|
int tilesDownloaded = 0;
|
|
int tilesReused = 0;
|
|
|
|
try
|
|
{
|
|
var processingStartTime = DateTime.UtcNow;
|
|
|
|
var tilesBeforeDownload = await _tileService.GetTilesByRegionAsync(
|
|
region.Latitude,
|
|
region.Longitude,
|
|
region.SizeMeters,
|
|
region.ZoomLevel);
|
|
var existingTileIds = new HashSet<Guid>(tilesBeforeDownload.Select(t => t.Id));
|
|
|
|
tiles = await _tileService.DownloadAndStoreTilesAsync(
|
|
region.Latitude,
|
|
region.Longitude,
|
|
region.SizeMeters,
|
|
region.ZoomLevel,
|
|
linkedCts.Token);
|
|
|
|
tilesDownloaded = tiles.Count(t => !existingTileIds.Contains(t.Id));
|
|
tilesReused = tiles.Count(t => existingTileIds.Contains(t.Id));
|
|
|
|
var readyDir = _storageConfig.ReadyDirectory;
|
|
Directory.CreateDirectory(readyDir);
|
|
|
|
var csvPath = Path.Combine(readyDir, $"region_{id}_ready.csv");
|
|
var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt");
|
|
string? stitchedImagePath = null;
|
|
|
|
await GenerateCsvFileAsync(csvPath, tiles, linkedCts.Token);
|
|
|
|
if (region.StitchTiles)
|
|
{
|
|
stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg");
|
|
await StitchTilesAsync(tiles, region.Latitude, region.Longitude, region.ZoomLevel, stitchedImagePath, linkedCts.Token);
|
|
}
|
|
|
|
await GenerateSummaryFileAsync(summaryPath, id, region, tiles, tilesDownloaded, tilesReused, stitchedImagePath, processingStartTime, linkedCts.Token, errorMessage);
|
|
|
|
region.Status = "completed";
|
|
region.CsvFilePath = csvPath;
|
|
region.SummaryFilePath = summaryPath;
|
|
region.TilesDownloaded = tilesDownloaded;
|
|
region.TilesReused = tilesReused;
|
|
region.UpdatedAt = DateTime.UtcNow;
|
|
await _regionRepository.UpdateAsync(region);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var classification = RegionFailureClassifier.Classify(ex, timeoutCts, cancellationToken);
|
|
errorMessage = classification.ErrorMessage;
|
|
_logger.LogError(
|
|
ex,
|
|
"Region {RegionId} processing failed (category={Category}): {ErrorMessage}",
|
|
id,
|
|
classification.Category,
|
|
classification.ErrorMessage);
|
|
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
|
|
}
|
|
}
|
|
|
|
private async Task HandleProcessingFailureAsync(
|
|
Guid id,
|
|
RegionEntity region,
|
|
DateTime startTime,
|
|
List<TileMetadata>? tiles,
|
|
int tilesDownloaded,
|
|
int tilesReused,
|
|
string errorMessage)
|
|
{
|
|
region.Status = "failed";
|
|
region.UpdatedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
var readyDir = _storageConfig.ReadyDirectory;
|
|
Directory.CreateDirectory(readyDir);
|
|
|
|
var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt");
|
|
region.SummaryFilePath = summaryPath;
|
|
|
|
await GenerateSummaryFileAsync(
|
|
summaryPath,
|
|
id,
|
|
region,
|
|
tiles ?? new List<TileMetadata>(),
|
|
tilesDownloaded,
|
|
tilesReused,
|
|
null,
|
|
startTime,
|
|
CancellationToken.None,
|
|
errorMessage);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to generate error summary for region {RegionId}", id);
|
|
}
|
|
|
|
await _regionRepository.UpdateAsync(region);
|
|
}
|
|
|
|
private async Task<string> StitchTilesAsync(
|
|
List<TileMetadata> tiles,
|
|
double centerLatitude,
|
|
double centerLongitude,
|
|
int zoomLevel,
|
|
string outputPath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (tiles.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No tiles to stitch");
|
|
}
|
|
|
|
var tileSizePixels = tiles.First().TileSizePixels;
|
|
|
|
var tileCoords = tiles.Select(t =>
|
|
{
|
|
var (x, y) = GeoUtils.WorldToTilePos(new GeoPoint(t.Latitude, t.Longitude), zoomLevel);
|
|
return (x, y, t.FilePath);
|
|
}).ToList();
|
|
|
|
var minX = tileCoords.Min(t => t.x);
|
|
var maxX = tileCoords.Max(t => t.x);
|
|
var minY = tileCoords.Min(t => t.y);
|
|
var maxY = tileCoords.Max(t => t.y);
|
|
|
|
var gridWidth = maxX - minX + 1;
|
|
var gridHeight = maxY - minY + 1;
|
|
var imageWidth = gridWidth * tileSizePixels;
|
|
var imageHeight = gridHeight * tileSizePixels;
|
|
|
|
using var stitchedImage = new Image<Rgb24>(imageWidth, imageHeight);
|
|
|
|
foreach (var (x, y, filePath) in tileCoords)
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
_logger.LogWarning("Tile file not found: {FilePath}", filePath);
|
|
continue;
|
|
}
|
|
|
|
using var tileImage = await Image.LoadAsync<Rgb24>(filePath, cancellationToken);
|
|
|
|
var destX = (x - minX) * tileSizePixels;
|
|
var destY = (y - minY) * tileSizePixels;
|
|
|
|
stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f));
|
|
}
|
|
|
|
var (centerTileX, centerTileY) = GeoUtils.WorldToTilePos(new GeoPoint(centerLatitude, centerLongitude), zoomLevel);
|
|
|
|
var n = Math.Pow(2.0, zoomLevel);
|
|
var centerTilePixelX = ((centerLongitude + 180.0) / 360.0 * n - centerTileX) * tileSizePixels;
|
|
var centerTilePixelY = ((1.0 - Math.Log(Math.Tan(centerLatitude * Math.PI / 180.0) + 1.0 / Math.Cos(centerLatitude * Math.PI / 180.0)) / Math.PI) / 2.0 * n - centerTileY) * tileSizePixels;
|
|
|
|
var crossX = (int)Math.Round((centerTileX - minX) * tileSizePixels + centerTilePixelX);
|
|
var crossY = (int)Math.Round((centerTileY - minY) * tileSizePixels + centerTilePixelY);
|
|
|
|
var red = new Rgb24(255, 0, 0);
|
|
stitchedImage.Mutate(ctx =>
|
|
{
|
|
for (int i = -5; i < 5; i++)
|
|
{
|
|
var hx = crossX + i;
|
|
var vy = crossY + i;
|
|
|
|
if (hx >= 0 && hx < imageWidth && crossY >= 0 && crossY < imageHeight)
|
|
{
|
|
stitchedImage[hx, crossY] = red;
|
|
}
|
|
|
|
if (crossX >= 0 && crossX < imageWidth && vy >= 0 && vy < imageHeight)
|
|
{
|
|
stitchedImage[crossX, vy] = red;
|
|
}
|
|
}
|
|
});
|
|
|
|
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
|
|
|
|
return outputPath;
|
|
}
|
|
|
|
private static async Task GenerateCsvFileAsync(string filePath, List<TileMetadata> tiles, CancellationToken cancellationToken)
|
|
{
|
|
var rows = tiles.Select(t => new TileCsvRow(t.Latitude, t.Longitude, t.FilePath));
|
|
await new TileCsvWriter().WriteAsync(filePath, rows, cancellationToken);
|
|
}
|
|
|
|
private async Task GenerateSummaryFileAsync(
|
|
string filePath,
|
|
Guid regionId,
|
|
RegionEntity region,
|
|
List<TileMetadata> tiles,
|
|
int tilesDownloaded,
|
|
int tilesReused,
|
|
string? stitchedImagePath,
|
|
DateTime startTime,
|
|
CancellationToken cancellationToken,
|
|
string? errorMessage = null)
|
|
{
|
|
var endTime = DateTime.UtcNow;
|
|
var processingTime = (endTime - startTime).TotalSeconds;
|
|
|
|
var summary = new System.Text.StringBuilder();
|
|
summary.AppendLine("Region Processing Summary");
|
|
summary.AppendLine("========================");
|
|
summary.AppendLine($"Region ID: {regionId}");
|
|
summary.AppendLine($"Center: {region.Latitude:F6}, {region.Longitude:F6}");
|
|
summary.AppendLine($"Size: {region.SizeMeters:F0} meters");
|
|
summary.AppendLine($"Zoom Level: {region.ZoomLevel}");
|
|
summary.AppendLine($"Status: {region.Status}");
|
|
summary.AppendLine();
|
|
|
|
if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
summary.AppendLine("ERROR:");
|
|
summary.AppendLine(errorMessage);
|
|
summary.AppendLine();
|
|
}
|
|
|
|
summary.AppendLine("Processing Statistics:");
|
|
summary.AppendLine($"- Tiles Downloaded: {tilesDownloaded}");
|
|
summary.AppendLine($"- Tiles Reused from Cache: {tilesReused}");
|
|
summary.AppendLine($"- Total Tiles: {tiles.Count}");
|
|
summary.AppendLine($"- Processing Time: {processingTime:F2} seconds");
|
|
summary.AppendLine($"- Started: {startTime:yyyy-MM-dd HH:mm:ss} UTC");
|
|
|
|
if (region.Status == "completed")
|
|
{
|
|
summary.AppendLine($"- Completed: {endTime:yyyy-MM-dd HH:mm:ss} UTC");
|
|
}
|
|
else
|
|
{
|
|
summary.AppendLine($"- Failed: {endTime:yyyy-MM-dd HH:mm:ss} UTC");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
summary.AppendLine("Files Created:");
|
|
|
|
if (tiles.Count > 0)
|
|
{
|
|
summary.AppendLine($"- CSV: {Path.GetFileName(filePath).Replace("_summary.txt", "_ready.csv")}");
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(stitchedImagePath))
|
|
{
|
|
summary.AppendLine($"- Stitched Image: {Path.GetFileName(stitchedImagePath)}");
|
|
summary.AppendLine($"- Stitched Image Path: {stitchedImagePath}");
|
|
}
|
|
|
|
summary.AppendLine($"- Summary: {Path.GetFileName(filePath)}");
|
|
|
|
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
|
|
}
|
|
|
|
private static RegionStatus MapToStatus(RegionEntity region)
|
|
{
|
|
return new RegionStatus
|
|
{
|
|
Id = region.Id,
|
|
Status = region.Status,
|
|
CsvFilePath = region.CsvFilePath,
|
|
SummaryFilePath = region.SummaryFilePath,
|
|
TilesDownloaded = region.TilesDownloaded,
|
|
TilesReused = region.TilesReused,
|
|
CreatedAt = region.CreatedAt,
|
|
UpdatedAt = region.UpdatedAt
|
|
};
|
|
}
|
|
}
|
|
|