Files
satellite-provider/SatelliteProvider.Services/RegionService.cs
T
Anton Martynenko 48ebad0609 less logs
2025-11-19 18:18:19 +01:00

418 lines
16 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
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;
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)
{
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 (TaskCanceledException ex) when (timeoutCts.IsCancellationRequested)
{
errorMessage = "Processing timed out after 5 minutes. Unable to download tiles within the time limit.";
_logger.LogError(ex, "Region {RegionId} processing timed out after 5 minutes", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (TaskCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
errorMessage = "Processing was cancelled externally (likely application shutdown).";
_logger.LogError(ex, "Region {RegionId} processing was cancelled externally", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (TaskCanceledException ex)
{
errorMessage = $"Request cancelled or timed out: {ex.Message}. This may indicate HttpClient timeout or network issues.";
_logger.LogError(ex, "Region {RegionId} processing was cancelled (TaskCanceledException)", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
{
errorMessage = "Processing timed out after 5 minutes. Unable to download tiles within the time limit.";
_logger.LogError(ex, "Region {RegionId} processing timed out after 5 minutes", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (OperationCanceledException ex)
{
errorMessage = $"Operation cancelled: {ex.Message}";
_logger.LogError(ex, "Region {RegionId} processing was cancelled", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (RateLimitException ex)
{
errorMessage = $"Rate limit exceeded: {ex.Message}. Google Maps API rate limit was reached and retries were exhausted.";
_logger.LogError(ex, "Rate limit exceeded for region {RegionId}. Google is throttling requests. Consider reducing request rate.", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (HttpRequestException ex)
{
errorMessage = $"Network error (HTTP {ex.StatusCode}): {ex.Message}. Failed to download tiles from Google Maps.";
_logger.LogError(ex, "Network error processing region {RegionId}. StatusCode: {StatusCode}, Message: {Message}",
id, ex.StatusCode, ex.Message);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (Exception ex)
{
errorMessage = $"Unexpected error ({ex.GetType().Name}): {ex.Message}";
_logger.LogError(ex, "Failed to process region {RegionId}. Type: {ExceptionType}, Message: {Message}",
id, ex.GetType().Name, ex.Message);
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 async Task GenerateCsvFileAsync(string filePath, List<TileMetadata> tiles, CancellationToken cancellationToken)
{
var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList();
using var writer = new StreamWriter(filePath);
await writer.WriteLineAsync("latitude,longitude,file_path");
foreach (var tile in orderedTiles)
{
await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}");
}
}
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
};
}
}