summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 01:10:08 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 01:10:08 +0000
commit9f694c364e547cb767a8f76a0cabf25ee06d6cc7 (patch)
treee9a3a096dd21333227264121583e24f5b702062f
parent0ded60427f27d2f69ae235633c7a7ad1ece1cd9c (diff)
parentdebd559f7cfff790bdb91f883f3d2cd6ee7dddca (diff)
Merge branch 'master' of /site/git.terst.org/repos/nav
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt40
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt42
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt63
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt36
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/data/model/GribFile.kt40
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt20
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt42
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt63
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt36
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/data/storage/GribFileManagerTest.kt100
11 files changed, 502 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
new file mode 100644
index 0000000..9d284b5
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
@@ -0,0 +1,40 @@
+package org.terst.nav.data.model
+
+import java.time.Instant
+
+/**
+ * Metadata record for a locally-stored GRIB2 file.
+ *
+ * @param region The geographic region this file covers.
+ * @param modelRunTime When the NWP model run that produced this file started (UTC).
+ * @param forecastHours Number of forecast hours included in this file.
+ * @param downloadedAt Wall-clock time when the file was saved locally.
+ * @param filePath Absolute path to the GRIB2 file on the device filesystem.
+ * @param sizeBytes File size in bytes.
+ */
+data class GribFile(
+ val region: GribRegion,
+ val modelRunTime: Instant,
+ val forecastHours: Int,
+ val downloadedAt: Instant,
+ val filePath: String,
+ val sizeBytes: Long
+) {
+ /**
+ * The wall-clock time at which this GRIB file's forecast data expires.
+ * Per design doc §6.3: valid until model run + forecast hours.
+ */
+ fun validUntil(): Instant = modelRunTime.plusSeconds(forecastHours.toLong() * 3600)
+
+ /**
+ * Returns true if the data has expired relative to [now].
+ * Per design doc §6.3: stale after model run + forecast hour.
+ */
+ fun isStale(now: Instant = Instant.now()): Boolean = now.isAfter(validUntil())
+
+ /**
+ * Age of the download in seconds.
+ */
+ fun ageSeconds(now: Instant = Instant.now()): Long =
+ now.epochSecond - downloadedAt.epochSecond
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
new file mode 100644
index 0000000..5e32d6c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.data.model
+
+/**
+ * Geographic bounding box used to identify a GRIB download region.
+ * @param name Human-readable region name (e.g. "North Atlantic", "English Channel")
+ */
+data class GribRegion(
+ val name: String,
+ val latMin: Double,
+ val latMax: Double,
+ val lonMin: Double,
+ val lonMax: Double
+) {
+ /** True if [lat]/[lon] falls within this region's bounding box. */
+ fun contains(lat: Double, lon: Double): Boolean =
+ lat in latMin..latMax && lon in lonMin..lonMax
+
+ /** Area in square degrees (rough proxy for download size estimate). */
+ fun areaDegrees(): Double = (latMax - latMin) * (lonMax - lonMin)
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
new file mode 100644
index 0000000..e17e5ca
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
@@ -0,0 +1,42 @@
+package org.terst.nav.data.storage
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+interface GribFileManager {
+ /** Save metadata for a newly-downloaded GRIB file. */
+ fun saveMetadata(file: GribFile)
+ /** Return all stored GRIB files for [region], newest first. */
+ fun listFiles(region: GribRegion): List<GribFile>
+ /** Return the most-recently-downloaded GRIB file for [region], or null if none. */
+ fun latestFile(region: GribRegion): GribFile?
+ /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */
+ fun delete(file: GribFile): Boolean
+ /** Delete all GRIB files older than [before]. Returns count of deleted files. */
+ fun purgeOlderThan(before: Instant): Int
+ /** Total size in bytes of all stored GRIB files. */
+ fun totalSizeBytes(): Long
+}
+
+class InMemoryGribFileManager : GribFileManager {
+ private val files = mutableListOf<GribFile>()
+
+ override fun saveMetadata(file: GribFile) { files.add(file) }
+
+ override fun listFiles(region: GribRegion): List<GribFile> =
+ files.filter { it.region.name == region.name }
+ .sortedByDescending { it.downloadedAt }
+
+ override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
+
+ override fun delete(file: GribFile): Boolean = files.remove(file)
+
+ override fun purgeOlderThan(before: Instant): Int {
+ val toRemove = files.filter { it.downloadedAt.isBefore(before) }
+ files.removeAll(toRemove)
+ return toRemove.size
+ }
+
+ override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt
new file mode 100644
index 0000000..ef8bf7f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt
@@ -0,0 +1,63 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.time.Instant
+
+class GribFileTest {
+
+ private val region = GribRegion(
+ name = "Test Region",
+ latMin = 40.0,
+ latMax = 60.0,
+ lonMin = -10.0,
+ lonMax = 10.0
+ )
+
+ private val modelRun = Instant.parse("2026-03-15T00:00:00Z")
+ private val downloadedAt = Instant.parse("2026-03-15T01:00:00Z")
+
+ private fun makeFile(forecastHours: Int = 48) = GribFile(
+ region = region,
+ modelRunTime = modelRun,
+ forecastHours = forecastHours,
+ downloadedAt = downloadedAt,
+ filePath = "/data/grib/test.grib2",
+ sizeBytes = 1_000_000L
+ )
+
+ @Test
+ fun `validUntil_equalsModelRunPlusForecastHours`() {
+ val file = makeFile(forecastHours = 48)
+ val expected = modelRun.plusSeconds(48L * 3600)
+ assertEquals(expected, file.validUntil())
+ }
+
+ @Test
+ fun `isStale_returnsFalse_whenNowBeforeValidUntil`() {
+ val file = makeFile(forecastHours = 48)
+ val now = modelRun.plusSeconds(24L * 3600) // 24h in, still 24h to go
+ assertFalse(file.isStale(now))
+ }
+
+ @Test
+ fun `isStale_returnsTrue_whenNowAfterValidUntil`() {
+ val file = makeFile(forecastHours = 48)
+ val now = modelRun.plusSeconds(49L * 3600) // 1h past expiry
+ assertTrue(file.isStale(now))
+ }
+
+ @Test
+ fun `isStale_returnsFalse_whenNowExactlyAtValidUntil`() {
+ val file = makeFile(forecastHours = 48)
+ val now = file.validUntil() // exactly at boundary — not yet stale
+ assertFalse(file.isStale(now))
+ }
+
+ @Test
+ fun `ageSeconds_computesElapsedTime`() {
+ val file = makeFile()
+ val now = downloadedAt.plusSeconds(3600) // 1h after download
+ assertEquals(3600L, file.ageSeconds(now))
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt
new file mode 100644
index 0000000..a9a0334
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt
@@ -0,0 +1,36 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class GribRegionTest {
+
+ private val region = GribRegion(
+ name = "Test Region",
+ latMin = 40.0,
+ latMax = 60.0,
+ lonMin = -10.0,
+ lonMax = 10.0
+ )
+
+ @Test
+ fun `contains_returnsTrue_whenPointInsideBounds`() {
+ assertTrue(region.contains(50.0, 0.0))
+ }
+
+ @Test
+ fun `contains_returnsFalse_whenLatOutOfRange`() {
+ assertFalse(region.contains(70.0, 0.0))
+ }
+
+ @Test
+ fun `contains_returnsFalse_whenLonOutOfRange`() {
+ assertFalse(region.contains(50.0, 20.0))
+ }
+
+ @Test
+ fun `areaDegrees_computesCorrectly`() {
+ val area = region.areaDegrees()
+ assertEquals(400.0, area, 0.0001)
+ }
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/data/model/GribFile.kt b/test-runner/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
new file mode 100644
index 0000000..9d284b5
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
@@ -0,0 +1,40 @@
+package org.terst.nav.data.model
+
+import java.time.Instant
+
+/**
+ * Metadata record for a locally-stored GRIB2 file.
+ *
+ * @param region The geographic region this file covers.
+ * @param modelRunTime When the NWP model run that produced this file started (UTC).
+ * @param forecastHours Number of forecast hours included in this file.
+ * @param downloadedAt Wall-clock time when the file was saved locally.
+ * @param filePath Absolute path to the GRIB2 file on the device filesystem.
+ * @param sizeBytes File size in bytes.
+ */
+data class GribFile(
+ val region: GribRegion,
+ val modelRunTime: Instant,
+ val forecastHours: Int,
+ val downloadedAt: Instant,
+ val filePath: String,
+ val sizeBytes: Long
+) {
+ /**
+ * The wall-clock time at which this GRIB file's forecast data expires.
+ * Per design doc §6.3: valid until model run + forecast hours.
+ */
+ fun validUntil(): Instant = modelRunTime.plusSeconds(forecastHours.toLong() * 3600)
+
+ /**
+ * Returns true if the data has expired relative to [now].
+ * Per design doc §6.3: stale after model run + forecast hour.
+ */
+ fun isStale(now: Instant = Instant.now()): Boolean = now.isAfter(validUntil())
+
+ /**
+ * Age of the download in seconds.
+ */
+ fun ageSeconds(now: Instant = Instant.now()): Long =
+ now.epochSecond - downloadedAt.epochSecond
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt b/test-runner/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
new file mode 100644
index 0000000..5e32d6c
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.data.model
+
+/**
+ * Geographic bounding box used to identify a GRIB download region.
+ * @param name Human-readable region name (e.g. "North Atlantic", "English Channel")
+ */
+data class GribRegion(
+ val name: String,
+ val latMin: Double,
+ val latMax: Double,
+ val lonMin: Double,
+ val lonMax: Double
+) {
+ /** True if [lat]/[lon] falls within this region's bounding box. */
+ fun contains(lat: Double, lon: Double): Boolean =
+ lat in latMin..latMax && lon in lonMin..lonMax
+
+ /** Area in square degrees (rough proxy for download size estimate). */
+ fun areaDegrees(): Double = (latMax - latMin) * (lonMax - lonMin)
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/test-runner/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
new file mode 100644
index 0000000..e17e5ca
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
@@ -0,0 +1,42 @@
+package org.terst.nav.data.storage
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+interface GribFileManager {
+ /** Save metadata for a newly-downloaded GRIB file. */
+ fun saveMetadata(file: GribFile)
+ /** Return all stored GRIB files for [region], newest first. */
+ fun listFiles(region: GribRegion): List<GribFile>
+ /** Return the most-recently-downloaded GRIB file for [region], or null if none. */
+ fun latestFile(region: GribRegion): GribFile?
+ /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */
+ fun delete(file: GribFile): Boolean
+ /** Delete all GRIB files older than [before]. Returns count of deleted files. */
+ fun purgeOlderThan(before: Instant): Int
+ /** Total size in bytes of all stored GRIB files. */
+ fun totalSizeBytes(): Long
+}
+
+class InMemoryGribFileManager : GribFileManager {
+ private val files = mutableListOf<GribFile>()
+
+ override fun saveMetadata(file: GribFile) { files.add(file) }
+
+ override fun listFiles(region: GribRegion): List<GribFile> =
+ files.filter { it.region.name == region.name }
+ .sortedByDescending { it.downloadedAt }
+
+ override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
+
+ override fun delete(file: GribFile): Boolean = files.remove(file)
+
+ override fun purgeOlderThan(before: Instant): Int {
+ val toRemove = files.filter { it.downloadedAt.isBefore(before) }
+ files.removeAll(toRemove)
+ return toRemove.size
+ }
+
+ override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt b/test-runner/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt
new file mode 100644
index 0000000..ef8bf7f
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt
@@ -0,0 +1,63 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.time.Instant
+
+class GribFileTest {
+
+ private val region = GribRegion(
+ name = "Test Region",
+ latMin = 40.0,
+ latMax = 60.0,
+ lonMin = -10.0,
+ lonMax = 10.0
+ )
+
+ private val modelRun = Instant.parse("2026-03-15T00:00:00Z")
+ private val downloadedAt = Instant.parse("2026-03-15T01:00:00Z")
+
+ private fun makeFile(forecastHours: Int = 48) = GribFile(
+ region = region,
+ modelRunTime = modelRun,
+ forecastHours = forecastHours,
+ downloadedAt = downloadedAt,
+ filePath = "/data/grib/test.grib2",
+ sizeBytes = 1_000_000L
+ )
+
+ @Test
+ fun `validUntil_equalsModelRunPlusForecastHours`() {
+ val file = makeFile(forecastHours = 48)
+ val expected = modelRun.plusSeconds(48L * 3600)
+ assertEquals(expected, file.validUntil())
+ }
+
+ @Test
+ fun `isStale_returnsFalse_whenNowBeforeValidUntil`() {
+ val file = makeFile(forecastHours = 48)
+ val now = modelRun.plusSeconds(24L * 3600) // 24h in, still 24h to go
+ assertFalse(file.isStale(now))
+ }
+
+ @Test
+ fun `isStale_returnsTrue_whenNowAfterValidUntil`() {
+ val file = makeFile(forecastHours = 48)
+ val now = modelRun.plusSeconds(49L * 3600) // 1h past expiry
+ assertTrue(file.isStale(now))
+ }
+
+ @Test
+ fun `isStale_returnsFalse_whenNowExactlyAtValidUntil`() {
+ val file = makeFile(forecastHours = 48)
+ val now = file.validUntil() // exactly at boundary — not yet stale
+ assertFalse(file.isStale(now))
+ }
+
+ @Test
+ fun `ageSeconds_computesElapsedTime`() {
+ val file = makeFile()
+ val now = downloadedAt.plusSeconds(3600) // 1h after download
+ assertEquals(3600L, file.ageSeconds(now))
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt b/test-runner/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt
new file mode 100644
index 0000000..a9a0334
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt
@@ -0,0 +1,36 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class GribRegionTest {
+
+ private val region = GribRegion(
+ name = "Test Region",
+ latMin = 40.0,
+ latMax = 60.0,
+ lonMin = -10.0,
+ lonMax = 10.0
+ )
+
+ @Test
+ fun `contains_returnsTrue_whenPointInsideBounds`() {
+ assertTrue(region.contains(50.0, 0.0))
+ }
+
+ @Test
+ fun `contains_returnsFalse_whenLatOutOfRange`() {
+ assertFalse(region.contains(70.0, 0.0))
+ }
+
+ @Test
+ fun `contains_returnsFalse_whenLonOutOfRange`() {
+ assertFalse(region.contains(50.0, 20.0))
+ }
+
+ @Test
+ fun `areaDegrees_computesCorrectly`() {
+ val area = region.areaDegrees()
+ assertEquals(400.0, area, 0.0001)
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/data/storage/GribFileManagerTest.kt b/test-runner/src/test/kotlin/org/terst/nav/data/storage/GribFileManagerTest.kt
new file mode 100644
index 0000000..95ca546
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/data/storage/GribFileManagerTest.kt
@@ -0,0 +1,100 @@
+package org.terst.nav.data.storage
+
+import org.junit.Assert.*
+import org.junit.Test
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+private fun makeGribFile(
+ region: GribRegion,
+ downloadedAt: Instant,
+ sizeBytes: Long = 1024L
+): GribFile = GribFile(
+ region = region,
+ modelRunTime = downloadedAt.minusSeconds(6 * 3600),
+ forecastHours = 48,
+ downloadedAt = downloadedAt,
+ filePath = "/tmp/grib_${region.name}_${downloadedAt.epochSecond}.grb2",
+ sizeBytes = sizeBytes
+)
+
+class GribFileManagerTest {
+ private val manager = InMemoryGribFileManager()
+ private val regionA = GribRegion("Atlantic", 30.0, 60.0, -50.0, 0.0)
+ private val regionB = GribRegion("Pacific", 20.0, 50.0, -160.0, -100.0)
+ private val baseTime = Instant.parse("2026-01-01T12:00:00Z")
+
+ @Test
+ fun saveMetadata_addsFileToList() {
+ val file = makeGribFile(regionA, baseTime)
+ manager.saveMetadata(file)
+ assertEquals(listOf(file), manager.listFiles(regionA))
+ }
+
+ @Test
+ fun listFiles_returnsOnlyFilesForRegion() {
+ val fileA = makeGribFile(regionA, baseTime)
+ val fileB = makeGribFile(regionB, baseTime)
+ manager.saveMetadata(fileA)
+ manager.saveMetadata(fileB)
+ assertEquals(listOf(fileA), manager.listFiles(regionA))
+ assertEquals(listOf(fileB), manager.listFiles(regionB))
+ }
+
+ @Test
+ fun listFiles_returnsNewestFirst() {
+ val older = makeGribFile(regionA, baseTime)
+ val newer = makeGribFile(regionA, baseTime.plusSeconds(3600))
+ manager.saveMetadata(older)
+ manager.saveMetadata(newer)
+ assertEquals(listOf(newer, older), manager.listFiles(regionA))
+ }
+
+ @Test
+ fun latestFile_returnsNull_whenNoFiles() {
+ assertNull(manager.latestFile(regionA))
+ }
+
+ @Test
+ fun latestFile_returnsMostRecent() {
+ val older = makeGribFile(regionA, baseTime)
+ val newer = makeGribFile(regionA, baseTime.plusSeconds(3600))
+ manager.saveMetadata(older)
+ manager.saveMetadata(newer)
+ assertEquals(newer, manager.latestFile(regionA))
+ }
+
+ @Test
+ fun delete_removesFile_returnsTrue() {
+ val file = makeGribFile(regionA, baseTime)
+ manager.saveMetadata(file)
+ assertTrue(manager.delete(file))
+ assertTrue(manager.listFiles(regionA).isEmpty())
+ }
+
+ @Test
+ fun delete_returnsFalse_whenNotPresent() {
+ val file = makeGribFile(regionA, baseTime)
+ assertFalse(manager.delete(file))
+ }
+
+ @Test
+ fun purgeOlderThan_removesOldFiles_returnsCount() {
+ val old = makeGribFile(regionA, baseTime)
+ val recent = makeGribFile(regionA, baseTime.plusSeconds(7200))
+ manager.saveMetadata(old)
+ manager.saveMetadata(recent)
+ val cutoff = baseTime.plusSeconds(3600)
+ val deleted = manager.purgeOlderThan(cutoff)
+ assertEquals(1, deleted)
+ assertEquals(listOf(recent), manager.listFiles(regionA))
+ }
+
+ @Test
+ fun totalSizeBytes_sumsAllFiles() {
+ manager.saveMetadata(makeGribFile(regionA, baseTime, sizeBytes = 1000L))
+ manager.saveMetadata(makeGribFile(regionB, baseTime, sizeBytes = 2000L))
+ assertEquals(3000L, manager.totalSizeBytes())
+ }
+}