mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 06:51:13 +00:00
c646aa93e2
Foundation half of original AZ-503 (split during /autodev step 10 batch 2
on user choice; deferred work moved to AZ-505 with a Blocks link).
Adds deterministic tile identity (UUIDv5 over (z, x, y, source, flight_id))
shared cross-repo with gps-denied-onboard via the pinned TileNamespace
5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c, switches the tiles UPSERT key from
floats to integers with per-flight separation, plumbs FlightId through
UavTileMetadata + handler, and writes UAV evidence to per-flight
on-disk directories so two flights at the same (z, x, y) coexist.
- Common: pure-C# RFC 9562 Uuidv5 (no third-party dep) + FlightId DTO
field; 10 Python-reference unit vectors verify byte parity.
- DataAccess: migration 014 adds flight_id (uuid NULL), location_hash
(uuid NOT NULL, backfilled via session-scoped pg_temp.uuidv5),
content_sha256 (bytea NULL), legacy_id (uuid NULL = preserves
pre-AZ-503 random id one cycle); drops idx_tiles_unique_location_source
(AZ-484) and adds idx_tiles_unique_identity keyed on
(tile_zoom, tile_x, tile_y, tile_size_meters, source,
COALESCE(flight_id, '00000000-...'::uuid)) + idx_tiles_location_hash.
- TileRepository: ColumnList + UPSERT updated; id never updated on
conflict (preserves AC-2 idempotence). UpdateAsync extended.
- Services: TileService and UavTileUploadHandler compute deterministic
Id + LocationHash + ContentSha256 before insert; UAV file path
becomes ./tiles/uav/{flight_id or 'none'}/{z}/{x}/{y}.jpg.
- Tests: Uuidv5Tests (10 reference vectors), UavTileFilePathTests
(per-flight + anonymous paths), UavTileUploadHandlerTests (AC-2,
AC-3, AC-7, AC-11 unit-level), UavUploadTests (AC-3 + AC-4
integration: multi-flight DB coexistence with shared location_hash
+ distinct file_path; float-different lat/lon collapse to 1 row),
MigrationTests (column shape, idx_tiles_unique_identity supersedes
AZ-484 index, deterministic backfill).
- IntegrationTests project references Common to reuse Uuidv5 in raw
SQL seeds.
- AZ-488 MultiSourceCoexistence seed fixed to populate location_hash
(otherwise migration 014's NOT NULL constraint fails).
ACs covered: AC-1, AC-2, AC-3, AC-4, AC-7, AC-8, AC-11.
ACs deferred to AZ-505: AC-5, AC-6, AC-9, AC-10, AC-12.
Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
8.1 KiB
C#
218 lines
8.1 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SatelliteProvider.Common.Configs;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Enums;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
|
|
namespace SatelliteProvider.Services.TileDownloader;
|
|
|
|
public class TileService : ITileService
|
|
{
|
|
private static readonly TimeSpan TileCacheAbsolute = TimeSpan.FromHours(1);
|
|
private static readonly TimeSpan TileCacheSliding = TimeSpan.FromMinutes(30);
|
|
private static readonly TimeSpan TileResponseMaxAge = TimeSpan.FromDays(1);
|
|
private const string TileImageContentType = "image/jpeg";
|
|
|
|
private readonly ISatelliteDownloader _downloader;
|
|
private readonly ITileRepository _tileRepository;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly MapConfig _mapConfig;
|
|
private readonly ILogger<TileService> _logger;
|
|
|
|
public TileService(
|
|
ISatelliteDownloader downloader,
|
|
ITileRepository tileRepository,
|
|
IMemoryCache cache,
|
|
IOptions<MapConfig> mapConfig,
|
|
ILogger<TileService> logger)
|
|
{
|
|
_downloader = downloader;
|
|
_tileRepository = tileRepository;
|
|
_cache = cache;
|
|
_mapConfig = mapConfig.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<List<TileMetadata>> DownloadAndStoreTilesAsync(
|
|
double latitude,
|
|
double longitude,
|
|
double sizeMeters,
|
|
int zoomLevel,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
|
var existingTilesList = existingTiles.ToList();
|
|
|
|
var centerPoint = new GeoPoint(latitude, longitude);
|
|
|
|
var existingTileInfos = existingTilesList
|
|
.Select(t => new ExistingTileInfo(t.Latitude, t.Longitude, t.TileZoom))
|
|
.ToList();
|
|
|
|
var downloadedTiles = await _downloader.GetTilesWithMetadataAsync(
|
|
centerPoint,
|
|
sizeMeters / 2,
|
|
zoomLevel,
|
|
existingTileInfos,
|
|
cancellationToken);
|
|
|
|
var result = new List<TileMetadata>();
|
|
|
|
foreach (var existingTile in existingTilesList)
|
|
{
|
|
result.Add(MapToMetadata(existingTile));
|
|
}
|
|
|
|
foreach (var downloadedTile in downloadedTiles)
|
|
{
|
|
var tileEntity = BuildTileEntity(downloadedTile);
|
|
await _tileRepository.InsertAsync(tileEntity);
|
|
result.Add(MapToMetadata(tileEntity));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<TileMetadata?> GetTileAsync(Guid id)
|
|
{
|
|
var tile = await _tileRepository.GetByIdAsync(id);
|
|
return tile != null ? MapToMetadata(tile) : null;
|
|
}
|
|
|
|
public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
|
|
double latitude,
|
|
double longitude,
|
|
double sizeMeters,
|
|
int zoomLevel)
|
|
{
|
|
var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
|
|
return tiles.Select(MapToMetadata);
|
|
}
|
|
|
|
public async Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default)
|
|
{
|
|
var cacheKey = $"tile_{z}_{x}_{y}";
|
|
var etag = $"\"{z}_{x}_{y}\"";
|
|
|
|
if (_cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null)
|
|
{
|
|
return new TileBytes(cachedBytes, TileImageContentType, etag, TileResponseMaxAge);
|
|
}
|
|
|
|
string filePath;
|
|
var existing = await _tileRepository.GetByTileCoordinatesAsync(z, x, y);
|
|
if (existing != null && File.Exists(existing.FilePath))
|
|
{
|
|
filePath = existing.FilePath;
|
|
}
|
|
else
|
|
{
|
|
var tileCenter = GeoUtils.TileToWorldPos(x, y, z);
|
|
var downloaded = await _downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z, cancellationToken);
|
|
var entity = BuildTileEntity(downloaded);
|
|
await _tileRepository.InsertAsync(entity);
|
|
filePath = entity.FilePath;
|
|
}
|
|
|
|
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
|
_cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions
|
|
{
|
|
AbsoluteExpirationRelativeToNow = TileCacheAbsolute,
|
|
SlidingExpiration = TileCacheSliding
|
|
});
|
|
|
|
return new TileBytes(bytes, TileImageContentType, etag, TileResponseMaxAge);
|
|
}
|
|
|
|
public async Task<TileMetadata> DownloadAndStoreSingleTileAsync(
|
|
double latitude,
|
|
double longitude,
|
|
int zoomLevel,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var downloaded = await _downloader.DownloadSingleTileAsync(latitude, longitude, zoomLevel, cancellationToken);
|
|
var entity = BuildTileEntity(downloaded);
|
|
await _tileRepository.InsertAsync(entity);
|
|
return MapToMetadata(entity);
|
|
}
|
|
|
|
private TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var source = TileSourceConverter.ToWireValue(TileSource.GoogleMaps);
|
|
// AZ-503: deterministic UUIDv5 over (z, x, y, source, flight_id-or-zero).
|
|
// google_maps tiles have no flight_id so the name fragment uses the
|
|
// canonical all-zeros UUID; the same Python-side serialization in
|
|
// gps-denied-onboard produces byte-identical IDs.
|
|
var idName = string.Create(CultureInfo.InvariantCulture,
|
|
$"{downloaded.ZoomLevel}/{downloaded.X}/{downloaded.Y}/{source}/{Guid.Empty}");
|
|
var locationHashName = string.Create(CultureInfo.InvariantCulture,
|
|
$"{downloaded.ZoomLevel}/{downloaded.X}/{downloaded.Y}");
|
|
var id = Uuidv5.Create(Uuidv5.TileNamespace, idName);
|
|
var locationHash = Uuidv5.Create(Uuidv5.TileNamespace, locationHashName);
|
|
// content_sha256 is computed from the actual JPEG body on disk. Google Maps
|
|
// downloads land on disk before this method runs (FilePath is set by the
|
|
// downloader), so a single read here is safe and avoids re-streaming. If
|
|
// the file is missing for any reason, leave ContentSha256 null and rely on
|
|
// the application invariant of "NOT NULL for AZ-503+ inserts" surfacing
|
|
// the problem in tests rather than silently inserting a sentinel digest.
|
|
byte[]? contentSha256 = null;
|
|
if (File.Exists(downloaded.FilePath))
|
|
{
|
|
using var stream = File.OpenRead(downloaded.FilePath);
|
|
contentSha256 = SHA256.HashData(stream);
|
|
}
|
|
|
|
return new TileEntity
|
|
{
|
|
Id = id,
|
|
TileZoom = downloaded.ZoomLevel,
|
|
TileX = downloaded.X,
|
|
TileY = downloaded.Y,
|
|
Latitude = downloaded.CenterLatitude,
|
|
Longitude = downloaded.CenterLongitude,
|
|
TileSizeMeters = downloaded.TileSizeMeters,
|
|
TileSizePixels = _mapConfig.TileSizePixels,
|
|
ImageType = "jpg",
|
|
MapsVersion = null,
|
|
Version = null,
|
|
FilePath = downloaded.FilePath,
|
|
Source = source,
|
|
CapturedAt = now,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
FlightId = null,
|
|
LocationHash = locationHash,
|
|
ContentSha256 = contentSha256,
|
|
LegacyId = null
|
|
};
|
|
}
|
|
|
|
private static TileMetadata MapToMetadata(TileEntity entity)
|
|
{
|
|
return new TileMetadata
|
|
{
|
|
Id = entity.Id,
|
|
TileZoom = entity.TileZoom,
|
|
TileX = entity.TileX,
|
|
TileY = entity.TileY,
|
|
Latitude = entity.Latitude,
|
|
Longitude = entity.Longitude,
|
|
TileSizeMeters = entity.TileSizeMeters,
|
|
TileSizePixels = entity.TileSizePixels,
|
|
ImageType = entity.ImageType,
|
|
Version = entity.Version,
|
|
FilePath = entity.FilePath,
|
|
CreatedAt = entity.CreatedAt,
|
|
UpdatedAt = entity.UpdatedAt
|
|
};
|
|
}
|
|
}
|