diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-16 00:45:53 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:55:20 +0000 |
| commit | 31b1b3a05d2100ada78042770d62c824d47603ec (patch) | |
| tree | 28e92deebf67ffb49bce9c659561d5f6bf24e61e /android-app | |
| parent | afe94c5a2ce33c7f98d85b287ebbe07488dc181f (diff) | |
feat: satellite GRIB download with bandwidth optimisation (§9.1)
Implements weather data download over Iridium satellite links:
- GribParameter enum with SATELLITE_MINIMAL set (wind + pressure only)
- SatelliteDownloadRequest data class (region, params, forecast hours, resolution)
- SatelliteGribDownloader: size/time estimation, abort-on-oversized, pluggable fetcher
- 8 unit tests covering estimation scaling, minimal param set, and download outcomes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app')
4 files changed, 365 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt new file mode 100644 index 0000000..e2c884a --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt @@ -0,0 +1,134 @@ +package com.example.androidapp.data.weather + +import com.example.androidapp.data.model.GribFile +import com.example.androidapp.data.model.GribParameter +import com.example.androidapp.data.model.GribRegion +import com.example.androidapp.data.model.SatelliteDownloadRequest +import com.example.androidapp.data.storage.GribFileManager +import java.time.Instant +import kotlin.math.ceil +import kotlin.math.floor + +/** + * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1). + * + * Provides size and time estimates before fetching, and aborts if the download + * would exceed the configured size limit (default 2 MB — the upper bound stated + * in §9.1 for typical offshore GRIBs on satellite). + * + * The actual network fetch is supplied as a [fetcher] lambda so the class remains + * testable without network access. + */ +class SatelliteGribDownloader(private val fileManager: GribFileManager) { + + companion object { + /** Iridium data link speed in bits per second. */ + const val SATELLITE_BANDWIDTH_BPS = 2400L + + /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */ + private const val BYTES_PER_GRID_POINT = 2L + + /** Per-message header overhead in GRIB2 format (section 0-4). */ + private const val HEADER_BYTES_PER_MESSAGE = 100L + + /** Forecast time step used for size estimation (3-hourly is standard GFS output). */ + private const val TIME_STEP_HOURS = 3 + + /** Default maximum download size; abort if estimate exceeds this. */ + const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L + } + + /** + * Estimates the GRIB file size in bytes for [request]. + * + * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead + * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1). + */ + fun estimateSizeBytes(request: SatelliteDownloadRequest): Long { + val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1 + val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1 + val gridPoints = latPoints * lonPoints + val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong() + val paramCount = request.parameters.size.toLong() + val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT + val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE + return dataBytes + headerBytes + } + + /** + * Estimates how many seconds the download will take at [bandwidthBps] bits/second. + */ + fun estimatedDownloadSeconds( + request: SatelliteDownloadRequest, + bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS + ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong() + + /** + * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal + * satellite parameter set (wind speed + direction + surface pressure only). + */ + fun buildMinimalRequest( + region: GribRegion, + forecastHours: Int, + resolutionDeg: Double = 1.0 + ): SatelliteDownloadRequest = SatelliteDownloadRequest( + region = region, + parameters = GribParameter.SATELLITE_MINIMAL, + forecastHours = forecastHours, + resolutionDeg = resolutionDeg + ) + + /** Result of a satellite GRIB download attempt. */ + sealed class DownloadResult { + /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */ + data class Success(val file: GribFile) : DownloadResult() + /** The [fetcher] returned no data or an unexpected error occurred. */ + data class Failed(val reason: String) : DownloadResult() + /** + * Download was aborted before starting because the estimated size + * [estimatedBytes] exceeds the configured limit. + */ + data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult() + } + + /** + * Downloads GRIB data for [request]. + * + * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes]. + * 2. Calls [fetcher] to retrieve raw bytes. + * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success]. + * + * @param request The bandwidth-optimised download request. + * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure. + * @param outputPath Local file path where the caller will persist the bytes. + * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]). + * @param now Timestamp injected for testing. + */ + fun download( + request: SatelliteDownloadRequest, + fetcher: (SatelliteDownloadRequest) -> ByteArray?, + outputPath: String, + sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES, + now: Instant = Instant.now() + ): DownloadResult { + val estimated = estimateSizeBytes(request) + if (estimated > sizeLimitBytes) { + return DownloadResult.Aborted( + "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " + + "reduce region, resolution, or forecast hours", + estimated + ) + } + val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data") + val gribFile = GribFile( + region = request.region, + modelRunTime = now, + forecastHours = request.forecastHours, + downloadedAt = now, + filePath = outputPath, + sizeBytes = bytes.size.toLong() + ) + fileManager.saveMetadata(gribFile) + return DownloadResult.Success(gribFile) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt new file mode 100644 index 0000000..a322ea9 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt @@ -0,0 +1,24 @@ +package org.terst.nav.data.model + +/** GRIB meteorological/oceanographic parameters that can be requested for download. */ +enum class GribParameter { + WIND_SPEED, + WIND_DIRECTION, + SURFACE_PRESSURE, + TEMPERATURE_2M, + PRECIPITATION, + WAVE_HEIGHT, + WAVE_DIRECTION; + + companion object { + /** + * Minimal parameter set for satellite (Iridium) links: wind speed, wind direction, + * and surface pressure only. Per §9.1: skip temperature/clouds to minimise bandwidth. + */ + val SATELLITE_MINIMAL: Set<GribParameter> = setOf( + WIND_SPEED, + WIND_DIRECTION, + SURFACE_PRESSURE + ) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt new file mode 100644 index 0000000..d14c9da --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt @@ -0,0 +1,27 @@ +package org.terst.nav.data.model + +/** + * A bandwidth-optimised GRIB download request for satellite (Iridium) links. + * + * Per §9.1: crop to needed region and request only essential parameters + * (wind, pressure) to fit within the ~2.4 Kbps Iridium budget. + * + * @param region Geographic area to download (cropped to route corridor + 200 nm buffer). + * @param parameters GRIB parameters to include. Use [GribParameter.SATELLITE_MINIMAL] + * for satellite links. + * @param forecastHours Number of forecast hours to request (e.g. 24, 48, 120). + * @param resolutionDeg Grid spacing in degrees. Coarser grids produce smaller files; + * 1.0° is typical for satellite; 0.25° for WiFi/cellular. + */ +data class SatelliteDownloadRequest( + val region: GribRegion, + val parameters: Set<GribParameter>, + val forecastHours: Int, + val resolutionDeg: Double = 1.0 +) { + init { + require(forecastHours > 0) { "forecastHours must be positive" } + require(resolutionDeg > 0.0) { "resolutionDeg must be positive" } + require(parameters.isNotEmpty()) { "parameters must not be empty" } + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt new file mode 100644 index 0000000..4bf7985 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt @@ -0,0 +1,180 @@ +package com.example.androidapp.data.weather + +import com.example.androidapp.data.model.GribParameter +import com.example.androidapp.data.model.GribRegion +import com.example.androidapp.data.model.SatelliteDownloadRequest +import com.example.androidapp.data.storage.InMemoryGribFileManager +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.time.Instant + +class SatelliteGribDownloaderTest { + + private lateinit var manager: InMemoryGribFileManager + private lateinit var downloader: SatelliteGribDownloader + + // 10°×10° region at 1°: 11×11 = 121 grid points + private val region10x10 = GribRegion("atlantic", 30.0, 40.0, -70.0, -60.0) + + @Before + fun setUp() { + manager = InMemoryGribFileManager() + downloader = SatelliteGribDownloader(manager) + } + + // ------------------------------------------------------------------ size estimation + + @Test + fun `estimateSizeBytes_scalesWithRegionArea`() { + // 10°×10° region: 11×11 = 121 grid points + val req10 = SatelliteDownloadRequest( + region = region10x10, + parameters = GribParameter.SATELLITE_MINIMAL, + forecastHours = 24 + ) + // 20°×20° region: 21×21 = 441 grid points — roughly 3.6× more grid points + val region20x20 = GribRegion("bigger", 20.0, 40.0, -80.0, -60.0) + val req20 = SatelliteDownloadRequest( + region = region20x20, + parameters = GribParameter.SATELLITE_MINIMAL, + forecastHours = 24 + ) + + val size10 = downloader.estimateSizeBytes(req10) + val size20 = downloader.estimateSizeBytes(req20) + + assertTrue("Larger region must produce larger estimate", size20 > size10) + } + + @Test + fun `estimateSizeBytes_scalesWithParameterCount`() { + val minimalReq = SatelliteDownloadRequest( + region = region10x10, + parameters = GribParameter.SATELLITE_MINIMAL, // 3 params + forecastHours = 24 + ) + val fullReq = SatelliteDownloadRequest( + region = region10x10, + parameters = GribParameter.values().toSet(), // all 7 params + forecastHours = 24 + ) + + val sizeMinimal = downloader.estimateSizeBytes(minimalReq) + val sizeFull = downloader.estimateSizeBytes(fullReq) + + assertTrue("More parameters must produce larger estimate", sizeFull > sizeMinimal) + } + + @Test + fun `estimateSizeBytes_coarserResolutionProducesSmallerFile`() { + val finReq = SatelliteDownloadRequest( + region = region10x10, + parameters = GribParameter.SATELLITE_MINIMAL, + forecastHours = 24, + resolutionDeg = 1.0 + ) + val coarseReq = SatelliteDownloadRequest( + region = region10x10, + parameters = GribParameter.SATELLITE_MINIMAL, + forecastHours = 24, + resolutionDeg = 2.0 + ) + + val sizeFine = downloader.estimateSizeBytes(finReq) + val sizeCoarse = downloader.estimateSizeBytes(coarseReq) + + assertTrue("Coarser resolution must produce smaller estimate", sizeCoarse < sizeFine) + } + + @Test + fun `estimatedDownloadSeconds_atIridiumBandwidth`() { + // 10°×10°, 3 params, 24h at 1° → known estimate + val req = SatelliteDownloadRequest( + region = region10x10, + parameters = GribParameter.SATELLITE_MINIMAL, + forecastHours = 24 + ) + val estBytes = downloader.estimateSizeBytes(req) + val expectedSeconds = Math.ceil(estBytes * 8.0 / SatelliteGribDownloader.SATELLITE_BANDWIDTH_BPS).toLong() + + val actualSeconds = downloader.estimatedDownloadSeconds(req) + + assertEquals(expectedSeconds, actualSeconds) + // Sanity: should be > 0 seconds and less than 10 minutes for a small region + assertTrue("Download estimate must be positive", actualSeconds > 0) + assertTrue("Small 10°×10° should complete in under 10 min at 2.4kbps", actualSeconds < 600) + } + + // ------------------------------------------------------------------ buildMinimalRequest + + @Test + fun `buildMinimalRequest_containsOnlyWindAndPressure`() { + val req = downloader.buildMinimalRequest(region10x10, 48) + + assertEquals(GribParameter.SATELLITE_MINIMAL, req.parameters) + assertTrue(req.parameters.contains(GribParameter.WIND_SPEED)) + assertTrue(req.parameters.contains(GribParameter.WIND_DIRECTION)) + assertTrue(req.parameters.contains(GribParameter.SURFACE_PRESSURE)) + assertFalse(req.parameters.contains(GribParameter.TEMPERATURE_2M)) + assertFalse(req.parameters.contains(GribParameter.PRECIPITATION)) + assertEquals(region10x10, req.region) + assertEquals(48, req.forecastHours) + } + + // ------------------------------------------------------------------ download() + + @Test + fun `download_abortsWhenEstimatedSizeExceedsLimit`() { + val req = downloader.buildMinimalRequest(region10x10, 24) + var fetcherCalled = false + + val result = downloader.download( + request = req, + fetcher = { fetcherCalled = true; ByteArray(100) }, + outputPath = "/tmp/test.grib", + sizeLimitBytes = 1L // ridiculously small limit + ) + + assertTrue("Should abort without calling fetcher", result is SatelliteGribDownloader.DownloadResult.Aborted) + assertFalse("Fetcher must not be called when aborting", fetcherCalled) + val aborted = result as SatelliteGribDownloader.DownloadResult.Aborted + assertTrue("Should report estimated bytes", aborted.estimatedBytes > 0) + } + + @Test + fun `download_returnsFailedWhenFetcherReturnsNull`() { + val req = downloader.buildMinimalRequest(region10x10, 24) + + val result = downloader.download( + request = req, + fetcher = { null }, + outputPath = "/tmp/test.grib" + ) + + assertTrue("Should fail when fetcher returns null", result is SatelliteGribDownloader.DownloadResult.Failed) + } + + @Test + fun `download_savesMetadataAndReturnsSuccessOnValidFetch`() { + val req = downloader.buildMinimalRequest(region10x10, 24) + val fakeBytes = ByteArray(8208) { 0x00 } + val now = Instant.parse("2026-03-16T12:00:00Z") + + val result = downloader.download( + request = req, + fetcher = { fakeBytes }, + outputPath = "/tmp/atlantic.grib", + now = now + ) + + assertTrue("Should succeed", result is SatelliteGribDownloader.DownloadResult.Success) + val success = result as SatelliteGribDownloader.DownloadResult.Success + assertEquals(region10x10, success.file.region) + assertEquals(24, success.file.forecastHours) + assertEquals(fakeBytes.size.toLong(), success.file.sizeBytes) + assertEquals("/tmp/atlantic.grib", success.file.filePath) + // Metadata must be persisted in the manager + assertNotNull(manager.latestFile(region10x10)) + } +} |
