summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt27
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt180
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))
+ }
+}