From 76a7e39a4bdb15db8b782ac0c469b26bfc7d7ab6 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 16 Mar 2026 00:02:55 +0000 Subject: feat: add GribRegion and GribFile data models with staleness logic --- .../kotlin/org/terst/nav/data/model/GribFile.kt | 40 ++++++++++++++ .../kotlin/org/terst/nav/data/model/GribRegion.kt | 20 +++++++ .../org/terst/nav/data/model/GribFileTest.kt | 63 ++++++++++++++++++++++ .../org/terst/nav/data/model/GribRegionTest.kt | 36 +++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 test-runner/src/main/kotlin/org/terst/nav/data/model/GribFile.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/data/model/GribFileTest.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/data/model/GribRegionTest.kt (limited to 'test-runner/src') 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/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) + } +} -- cgit v1.2.3 From debd559f7cfff790bdb91f883f3d2cd6ee7dddca Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 16 Mar 2026 00:45:04 +0000 Subject: feat: add GribFileManager interface and InMemoryGribFileManager --- .../org/terst/nav/data/storage/GribFileManager.kt | 42 +++++++++ .../org/terst/nav/data/storage/GribFileManager.kt | 42 +++++++++ .../terst/nav/data/storage/GribFileManagerTest.kt | 100 +++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/data/storage/GribFileManagerTest.kt (limited to 'test-runner/src') 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 + /** 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() + + override fun saveMetadata(file: GribFile) { files.add(file) } + + override fun listFiles(region: GribRegion): List = + 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/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 + /** 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() + + override fun saveMetadata(file: GribFile) { files.add(file) } + + override fun listFiles(region: GribRegion): List = + 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/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()) + } +} -- cgit v1.2.3