summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-16 00:45:53 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:55:20 +0000
commit31b1b3a05d2100ada78042770d62c824d47603ec (patch)
tree28e92deebf67ffb49bce9c659561d5f6bf24e61e /android-app/app/src/main
parentafe94c5a2ce33c7f98d85b287ebbe07488dc181f (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/app/src/main')
-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
3 files changed, 185 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" }
+ }
+}