diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-02 11:28:00 -1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-02 11:28:00 -1000 |
| commit | 5416c8ccc9220bc36e7af3febcbdd9d86e88cf30 (patch) | |
| tree | 46e990ae9dc43f4f7b6ea75cb4e5467d6fdb6359 | |
| parent | a9d87b600848178b03b85a06ccdfd53b11e38c38 (diff) | |
fix(ui): make record track FAB visible (#1)
* Add GpsPosition data class and NMEA RMC parser with tests
- GpsPosition: latitude, longitude, sog (knots), cog (degrees true), timestampMs
- NmeaParser.parseRmc: handles GP/GN talker IDs, void status, malformed input
- SOG/COG default to 0.0 when fields absent; S/W coords are negative
- 13 unit tests: GpsPositionTest (2), NmeaParserTest (11) — all GREEN
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add harmonic tide height predictions (Section 3.2 / 4.2)
Implement offline harmonic tide prediction as specified in COMPONENT_DESIGN.md:
- TideConstituent: name, speedDegPerHour, amplitudeMeters, phaseDeg
- TidePrediction: timestampMs, heightMeters
- TideStation: id, name, lat, lon, datumOffsetMeters, constituents
- HarmonicTideCalculator: predictHeight(), predictRange(), findHighLow()
Formula: h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ]
- 15 unit tests covering all calculation paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: implement isochrone-based weather routing (Section 3.4)
* feat: implement PDF logbook export (Section 4.8)
- LogbookEntry data class: timestampMs, lat/lon, SOG, COG, wind, baro, depth, event/notes
- LogbookFormatter: UTC time, position (deg/dec-min), 16-pt compass, row/page builders
- LogbookPdfExporter: landscape A4 PDF via android.graphics.pdf.PdfDocument with column headers,
alternating row shading, and table border
- 20 unit tests covering all formatting helpers and data model behaviour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: offline GRIB staleness checker, ViewModel integration, and UI badge
- Add GribRegion, GribFile data models and GribFileManager interface
- Add InMemoryGribFileManager for testing and default use
- Add GribStalenessChecker with FreshnessResult sealed class (Fresh/Stale/NoData)
- Integrate weatherStaleness StateFlow into MainViewModel (checked after loadWeather)
- Add yellow staleness banner TextView to fragment_map.xml
- Wire staleness banner in MapFragment (shown on Stale, hidden on Fresh/NoData)
- Add GribStalenessCheckerTest (4 TDD tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* 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>
* feat: add AnchorWatchHandler UI with Depth/Rode Out inputs and suggested radius
- Add AnchorWatchState with calculateRecommendedWatchCircleRadius, which
uses ScopeCalculator.watchCircleRadius (Pythagorean scope formula) and
falls back to rode length when geometry is invalid
- Add AnchorWatchHandler Fragment with EditText inputs for Depth (m) and
Rode Out (m); updates suggested watch circle radius live via TextWatcher
- Add fragment_anchor_watch.xml layout
- Wire AnchorWatchHandler into bottom navigation (MainActivity + menu)
- Add AnchorWatchStateTest covering valid geometry, short-rode fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(safety): log wind and current conditions at MOB activation (Section 4.6)
Per COMPONENT_DESIGN.md Section 4.6, the MOB navigation view must display
wind and current conditions at the time of the event.
- MobEvent: add nullable windSpeedKt, windDirectionDeg, currentSpeedKt,
currentDirectionDeg fields captured at the exact moment of activation
- MobAlarmManager.activate(): accept optional wind/current params and
forward them into MobEvent (defaults to null for backward compatibility)
- LocationService (new): aggregates live SensorData (resolves true wind via
TrueWindCalculator) and marine-forecast current conditions; snapshot()
provides a point-in-time EnvironmentalSnapshot for safety-critical logging
- MobAlarmManagerTest: add tests for wind/current storage and null defaults
- LocationServiceTest (new): covers snapshot, true-wind resolution,
current-condition updates, and the latestSensor flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(gps): implement NMEA/Android GPS sensor fusion in LocationService
Adds priority-based selection between NMEA GPS (dedicated marine GPS,
higher priority) and Android system GPS (fallback) within LocationService.
Selection policy:
1. Prefer NMEA when its most recent fix is fresh (≤ nmeaStalenessThresholdMs, default 5 s)
2. Fall back to Android GPS when NMEA is stale
3. Use stale NMEA only when Android has never reported a fix
4. bestPosition is null until at least one source reports
New public API:
- GpsSource enum (NONE, NMEA, ANDROID)
- LocationService.updateNmeaGps(GpsPosition)
- LocationService.updateAndroidGps(GpsPosition)
- LocationService.bestPosition: StateFlow<GpsPosition?>
- LocationService.activeGpsSource: StateFlow<GpsSource>
- Injectable clockMs parameter for deterministic unit tests
Adds 7 unit tests covering: no-data state, fresh NMEA priority,
stale NMEA fallback, only-NMEA/only-Android scenarios, exact-threshold
edge case, and NMEA recovery after Android takeover.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(gps): add fix-quality (accuracy) tier to GPS sensor fusion
Extend LocationService's source-selection policy with a quality-aware
"marginal staleness" zone between the primary and a new extended
staleness threshold (default 10 s):
1. Fresh NMEA (≤ primary threshold, 5 s) → always prefer NMEA
2. Marginally stale NMEA (5–10 s) → prefer NMEA only when
GpsPosition.accuracyMeters is strictly better than Android's;
fall back to Android conservatively when accuracy data is absent
3. Very stale NMEA (> 10 s) → always prefer Android
4. Only one source available → use it regardless of age
Changes:
- GpsPosition: add nullable accuracyMeters field (default null, no
breaking change to existing callers)
- LocationService: add nmeaExtendedThresholdMs constructor parameter;
recomputeBestPosition() now implements three-tier logic; extract
GpsPosition.hasStrictlyBetterAccuracyThan() helper
- LocationServiceTest: expose nmeaExtendedThresholdMs in fusionService
helper; add posWithAccuracy helper; add 4 new test cases covering
accuracy-based NMEA preference, worse-accuracy fallback, no-accuracy
conservative fallback, and very-stale unconditional fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: add .gitignore, pull-crash-logs script, updated agent permissions
- .gitignore: exclude agent artifacts (.claudomator-env, .agent-home/, crash-logs/)
- scripts/pull-crash-logs: download Crashlytics crash reports via Firebase CLI
- .claude/settings.local.json: add Android SDK/emulator/adb permission rules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: update CI workflow to target main branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve all Kotlin compilation errors blocking CI build
- Migrate kapt → KSP (Kotlin 2.0 + kapt is broken; KSP is the supported path)
- Fix duplicate onResume() override in MainActivity
- Fix wrong package imports: com.example.androidapp.data.model → org.terst.nav.data.model
across GribFileManager, GribStalenessChecker, SatelliteGribDownloader,
LogbookFormatter, LogbookPdfExporter, IsochroneRouter, AnchorWatchHandler
- Create missing SensorData, ApparentWind, TrueWindData, TrueWindCalculator classes
- Inline missing ScopeCalculator formula (Pythagorean) in AnchorWatchState
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(track): implement GPS track recording with map overlay
- TrackRepository + TrackPoint wired into MainViewModel:
isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint
- MapHandler.updateTrackLayer(): lazily initialises a red LineLayer
and updates GeoJSON polyline from List<TrackPoint>
- fab_record_track FAB in activity_main.xml (top|end of bottom nav);
icon toggles between ic_track_record and ic_close while recording
- MainActivity feeds every GPS fix into viewModel.addGpsPoint() and
observes trackPoints to redraw the polyline in real time
- ic_track_record.xml vector drawable (red record dot)
- 8 TrackRepositoryTest tests all GREEN
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ci): share APKs between jobs and expand smoke tests
CI — build job now uploads both APKs as the 'test-apks' artifact.
smoke-test job downloads them and passes -x assembleDebug
-x assembleDebugAndroidTest to skip recompilation (~4 min saved).
Test results uploaded as 'smoke-test-results' artifact on every run.
Smoke tests expanded from 1 → 11 tests covering:
- MainActivity launches without crash
- All 4 bottom-nav tabs are displayed
- Safety tab: Safety Dashboard, ACTIVATE MOB, ANCHOR WATCH visible
- Log tab: voice-log mic FAB visible
- Instruments tab: bottom sheet displayed
- Map tab: returns from overlay, mapView visible
- MOB FAB: always visible, visible on Safety tab
- Record Track FAB: displayed, toggles to Stop Recording, toggles back
MainActivity: moved isRecording observer to initializeUI() so the
FAB content description updates without requiring GPS permission
(needed for emulator tests that run without location permission).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: address simplify review findings
TrackRepository.addPoint() now returns Boolean (true if point was
added). MainViewModel.addGpsPoint() only updates _trackPoints StateFlow
when a point is actually appended — eliminates ~3,600 no-op flow
emissions per hour when not recording.
MainActivity: loadedStyle promoted from nullable field to
MutableStateFlow<Style?>; trackPoints observer uses filterNotNull +
combine so no track points are silently dropped if the style loads
after the first GPS fix.
Smoke tests: replaced 11× ActivityScenario.launch().use{} with a
single @get:Rule ActivityScenarioRule — same isolation, less
boilerplate.
CI: removed redundant app-debug artifact upload (app-debug.apk is
already included inside the test-apks artifact).
Removed stale/placeholder comments from MainActivity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ui): anchor record track FAB to instrument sheet to prevent it being hidden
The FAB was anchored to bottom_navigation, placing it in the peek zone
of the instrument bottom sheet (120dp) and making it invisible to users.
Re-anchoring to instrument_bottom_sheet keeps the button visible and
makes it track naturally with sheet drag gestures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ui): anchor MOB FAB to instrument sheet to match record-track FAB
The fab_mob was still anchored to bottom_navigation using the same
top|start pattern that caused fab_record_track to be hidden behind
the instrument sheet's 16dp elevation. Apply the same fix so the
safety-critical MOB button is not occluded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claudomator Agent <agent@claudomator>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Agent <agent@example.com>
Co-authored-by: Claude Agent <agent@claude.ai>
64 files changed, 3481 insertions, 85 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md index e17781b..7a4467f 100644 --- a/.agent/worklog.md +++ b/.agent/worklog.md @@ -127,10 +127,23 @@ Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel - `MainViewModelTest` — 3 new tests: valid type-1 adds vessel, same MMSI deduped, non-AIS stays empty - JVM test harness: `/tmp/ais-vm-test-runner/` (3 tests — all GREEN) +### [APPROVED] TrackRepository (2026-03-25) +- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt` +- `test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt` +- `isRecording` flag; `startTrack()` clears + starts; `stopTrack()`; `addPoint()` guards on isRecording; `getPoints()` returns snapshot +- 8 tests — all GREEN (`TrackRepositoryTest`) + +### [APPROVED] Track ViewModel + Map overlay + Record FAB (2026-03-25) +- `MainViewModel`: TrackRepository wired in; exposes `isRecording: StateFlow<Boolean>`, `trackPoints: StateFlow<List<TrackPoint>>`; `startTrack()`, `stopTrack()`, `addGpsPoint(lat, lon, sogKnots, cogDeg)` +- `MapHandler.updateTrackLayer(style, points)`: lazy LineLayer init; red (#E53935) 3dp polyline from List<TrackPoint> +- `MainActivity`: stores `loadedStyle`; GPS flow feeds `viewModel.addGpsPoint()` (m/s→knots); observes `trackPoints` → `mapHandler.updateTrackLayer()`; observes `isRecording` → FAB icon toggle (ic_track_record / ic_close) +- `activity_main.xml`: `fab_record_track` FAB anchored top|end of bottom nav +- `drawable/ic_track_record.xml`: red dot record icon + ## Next 3 Specific Steps -1. **CPA/TCPA alarms** — use CpaCalculator in ViewModel to emit alarm when CPA < threshold; add UI indicator in MapFragment -2. **AISHub periodic polling** — call refreshAisFromInternet() on a timer (e.g. every 60s) when GPS position is known -3. **AIS TCP full implementation** — replace stub socket reader with NmeaStreamManager integration +1. **Persist track to GPX/Room** — export recorded track as GPX file or store in Room DB +2. **Track stats in Log tab** — show elapsed time, distance, avg SOG while recording +3. **AnchorWatchHandler UI** — wire `AnchorWatchHandler` fully into SafetyFragment (currently stub) ## Scripts Added - `test-runner/` — standalone Kotlin/JVM Gradle project; runs all 22 GPS/NMEA tests without Android SDK diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 120b3ae..1e2b206 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,32 @@ "Bash(claudomator:*)", "Bash(/workspace/nav/scripts/.claude/claudomator-db-20260315.sh deps:*)", "Bash(/workspace/nav/scripts/.claude/claudomator-db-20260315.sh task:*)", - "Bash(curl:*)" + "Bash(curl:*)", + "Bash(echo \"ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT\")", + "Bash(/opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --list)", + "Bash(export ANDROID_HOME=/opt/android-sdk)", + "Bash(yes)", + "Bash(/opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \"emulator\" \"system-images;android-33;google_apis;x86_64\")", + "Bash(/opt/android-sdk/cmdline-tools/latest/bin/avdmanager create:*)", + "Bash(/opt/android-sdk/cmdline-tools/latest/bin/avdmanager list:*)", + "Bash(export PATH=$ANDROID_HOME/platform-tools:$PATH)", + "Bash(adb wait-for-device:*)", + "Bash(adb shell:*)", + "Bash(adb devices:*)", + "Bash(adb install:*)", + "Bash(adb kill-server:*)", + "Bash(adb start-server:*)", + "Bash(adb connect:*)", + "Bash(/opt/android-sdk/platform-tools/adb devices:*)", + "Read(//run/user/0/avd/running/**)", + "Bash(netstat -tlnp)", + "Bash(screen --version)", + "Bash(screen -dmS emulator bash -c 'export ANDROID_HOME=/opt/android-sdk; export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH; /opt/android-sdk/emulator/emulator -avd nav_atd -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot 2>&1 | tee /tmp/emu.log')", + "Bash(screen -list)", + "Bash(adb logcat:*)", + "Bash(grep -v \"^--$\")", + "Bash(./gradlew assembleDebug)", + "Skill(commit-commands:commit-push-pr)" ] } } diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index aa292f2..2c28410 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,9 +2,9 @@ name: Android CI/CD on: push: - branches: [ master ] + branches: [ main ] pull_request: - branches: [ master ] + branches: [ main ] jobs: build: @@ -27,14 +27,16 @@ jobs: run: ./gradlew assembleDebug assembleDebugAndroidTest working-directory: android-app - - name: Upload artifact + - name: Upload test APKs (shared with smoke-test job) uses: actions/upload-artifact@v4 with: - name: app-debug - path: android-app/app/build/outputs/apk/debug/app-debug.apk + name: test-apks + path: | + android-app/app/build/outputs/apk/debug/app-debug.apk + android-app/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk - name: upload artifact to Firebase App Distribution - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/main' uses: wzieba/Firebase-Distribution-Github-Action@v1 with: appId: ${{secrets.FIREBASE_APP_ID}} @@ -66,7 +68,7 @@ jobs: smoke-test: runs-on: ubuntu-latest - # Run after build succeeds so we don't spin up an emulator for a broken build + # Run after build succeeds — no point spinning up an emulator for a broken build needs: build steps: @@ -82,21 +84,36 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x android-app/gradlew + # Restore pre-built APKs so the emulator job skips the compile step + - name: Download test APKs + uses: actions/download-artifact@v4 + with: + name: test-apks + path: . # preserves android-app/app/build/outputs/… directory structure + - name: Enable KVM (faster emulator) run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + # -x assembleDebug -x assembleDebugAndroidTest: skip recompile, use downloaded APKs - name: Run smoke tests on emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 30 arch: x86_64 profile: pixel_3a - script: ./gradlew connectedDebugAndroidTest + script: ./gradlew connectedDebugAndroidTest -x assembleDebug -x assembleDebugAndroidTest working-directory: android-app + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-test-results + path: android-app/app/build/outputs/androidTest-results/ + - name: Notify claudomator if: always() env: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0511807 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Agent artifacts (generated per-task by Claudomator executor) +.agent-home/ +.claudomator-env +.claudomator-instructions.txt +crash-logs/ + +# Build outputs +build/ +.gradle/ +local.properties +*.apk +*.aab +*.jks +*.keystore + +# IDE +.idea/ +*.iml diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle index 0c1a012..f6ad111 100644 --- a/android-app/app/build.gradle +++ b/android-app/app/build.gradle @@ -4,7 +4,7 @@ plugins { id 'com.google.gms.google-services' id 'com.google.firebase.appdistribution' id 'com.google.firebase.crashlytics' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' } android { @@ -94,7 +94,7 @@ dependencies { // JSON parsing implementation 'com.squareup.moshi:moshi-kotlin:1.15.0' - kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.15.0' + ksp 'com.squareup.moshi:moshi-kotlin-codegen:1.15.0' // Location implementation 'com.google.android.gms:play-services-location:21.2.0' diff --git a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt index 0824abe..fecd9cc 100644 --- a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt +++ b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt @@ -1,35 +1,113 @@ package org.terst.nav -import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** - * Smoke test: verifies MainActivity launches without crashing. + * Smoke tests: verify the main UI surfaces launch and respond correctly. + * Run without GPS permission — LocationService is not started. * - * Run on an emulator/device via: - * ./gradlew connectedDebugAndroidTest - * - * In CI, requires an emulator step before the Gradle task. + * Locally: ./gradlew connectedDebugAndroidTest + * CI: smoke-test job via android-emulator-runner */ @RunWith(AndroidJUnit4::class) class MainActivitySmokeTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + @Before fun setup() { NavApplication.isTesting = true } + // ── Launch ───────────────────────────────────────────────────────────── + @Test fun mainActivity_launches_withoutCrash() { - ActivityScenario.launch(MainActivity::class.java).use { scenario -> - // If we reach this line the activity started without throwing. - // onActivity lets us assert it is in a resumed state. - scenario.onActivity { activity -> - assert(!activity.isFinishing) { "MainActivity finished immediately after launch" } - } + activityRule.scenario.onActivity { activity -> + assert(!activity.isFinishing) { "MainActivity finished immediately after launch" } } } + + // ── Bottom nav ───────────────────────────────────────────────────────── + + @Test + fun bottomNav_allFourTabs_areDisplayed() { + onView(withText("Map")).check(matches(isDisplayed())) + onView(withText("Instruments")).check(matches(isDisplayed())) + onView(withText("Log")).check(matches(isDisplayed())) + onView(withText("Safety")).check(matches(isDisplayed())) + } + + @Test + fun bottomNav_safetyTab_showsSafetyDashboard() { + onView(withText("Safety")).perform(click()) + onView(withText("Safety Dashboard")).check(matches(isDisplayed())) + onView(withText("ACTIVATE MOB")).check(matches(isDisplayed())) + onView(withText("ANCHOR WATCH")).check(matches(isDisplayed())) + } + + @Test + fun bottomNav_logTab_showsVoiceLogUi() { + onView(withText("Log")).perform(click()) + onView(withContentDescription("Start voice recognition")).check(matches(isDisplayed())) + } + + @Test + fun bottomNav_instrumentsTab_isSelectable() { + onView(withText("Instruments")).perform(click()) + onView(withId(R.id.instrument_bottom_sheet)).check(matches(isDisplayed())) + } + + @Test + fun bottomNav_mapTab_returnsFromOverlay() { + onView(withText("Safety")).perform(click()) + onView(withText("Map")).perform(click()) + onView(withId(R.id.mapView)).check(matches(isDisplayed())) + } + + // ── Persistent FABs ──────────────────────────────────────────────────── + + @Test + fun fabMob_isAlwaysVisible() { + onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) + } + + @Test + fun fabMob_remainsVisibleOnSafetyTab() { + onView(withText("Safety")).perform(click()) + onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) + } + + // ── Track recording ──────────────────────────────────────────────────── + + @Test + fun fabRecordTrack_isDisplayedWithRecordDescription() { + onView(withContentDescription("Record Track")).check(matches(isDisplayed())) + } + + @Test + fun fabRecordTrack_togglesToStopRecording_onFirstClick() { + onView(withContentDescription("Record Track")).perform(click()) + onView(withContentDescription("Stop Recording")).check(matches(isDisplayed())) + } + + @Test + fun fabRecordTrack_togglesBackToRecord_onSecondClick() { + onView(withContentDescription("Record Track")).perform(click()) + onView(withContentDescription("Stop Recording")).perform(click()) + onView(withContentDescription("Record Track")).check(matches(isDisplayed())) + } } diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt new file mode 100644 index 0000000..d427a5d --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt @@ -0,0 +1,10 @@ +package com.example.androidapp.data.model + +data class SensorData( + val latitude: Double? = null, + val longitude: Double? = null, + val headingTrueDeg: Double? = null, + val apparentWindSpeedKt: Double? = null, + val apparentWindAngleDeg: Double? = null, + val speedOverGroundKt: Double? = null +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt new file mode 100644 index 0000000..d6f685a --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt @@ -0,0 +1,24 @@ +package com.example.androidapp.data.storage + +import org.terst.nav.data.model.GribFile +import org.terst.nav.data.model.GribRegion +import java.time.Instant + +interface GribFileManager { + fun saveMetadata(file: GribFile) + fun listFiles(region: GribRegion): List<GribFile> + fun latestFile(region: GribRegion): GribFile? + fun delete(file: GribFile): Boolean + fun purgeOlderThan(before: Instant): Int + 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/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt new file mode 100644 index 0000000..70f36d9 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt @@ -0,0 +1,36 @@ +package com.example.androidapp.data.weather + +import org.terst.nav.data.model.GribFile +import com.example.androidapp.data.storage.GribFileManager +import org.terst.nav.data.model.GribRegion +import java.time.Instant + +/** Outcome of a freshness check. */ +sealed class FreshnessResult { + /** Data is current; no user action needed. */ + object Fresh : FreshnessResult() + /** Data is stale; user should re-download. [message] is shown in the UI badge. */ + data class Stale(val file: GribFile, val message: String) : FreshnessResult() + /** No local GRIB data exists for this region. */ + object NoData : FreshnessResult() +} + +/** + * Checks whether locally-stored GRIB data for a region is fresh or stale. + * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after. + */ +class GribStalenessChecker(private val manager: GribFileManager) { + + /** + * Check freshness of the most-recent GRIB file for [region] relative to [now]. + */ + fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult { + val latest = manager.latestFile(region) ?: return FreshnessResult.NoData + return if (latest.isStale(now)) { + val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600 + FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh") + } else { + FreshnessResult.Fresh + } + } +} 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..6e565b7 --- /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 org.terst.nav.data.model.GribFile +import org.terst.nav.data.model.GribParameter +import org.terst.nav.data.model.GribRegion +import org.terst.nav.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/com/example/androidapp/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt new file mode 100644 index 0000000..cbe5c84 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt @@ -0,0 +1,10 @@ +package com.example.androidapp.gps + +data class GpsPosition( + val latitude: Double, // degrees, positive = North + val longitude: Double, // degrees, positive = East + val sog: Double, // Speed Over Ground in knots + val cog: Double, // Course Over Ground in degrees true (0-360) + val timestampMs: Long, // Unix millis UTC + val accuracyMeters: Double? = null // estimated horizontal accuracy (1-sigma); null = unknown +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt new file mode 100644 index 0000000..0a315d4 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt @@ -0,0 +1,216 @@ +package com.example.androidapp.gps + +import com.example.androidapp.data.model.SensorData +import com.example.androidapp.wind.TrueWindCalculator +import com.example.androidapp.wind.ApparentWind +import com.example.androidapp.wind.TrueWindData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Source of the currently active GPS fix. */ +enum class GpsSource { NONE, NMEA, ANDROID } + +/** + * Aggregates real-time location and environmental sensor data for use throughout + * the safety subsystem (Section 4.6 of COMPONENT_DESIGN.md). + * + * ## GPS sensor fusion + * The service accepts fixes from two independent sources: + * - **NMEA GPS** — dedicated marine GPS received via [updateNmeaGps] (higher priority) + * - **Android GPS** — device built-in location via [updateAndroidGps] (fallback) + * + * Selection policy (evaluated on every new fix): + * 1. Prefer NMEA when its most recent fix is no older than [nmeaStalenessThresholdMs]. + * 2. When NMEA is marginally stale (older than [nmeaStalenessThresholdMs] but within + * [nmeaExtendedThresholdMs]) **and** Android GPS is also available, compare + * [GpsPosition.accuracyMeters]: keep NMEA if its reported accuracy is strictly better + * (lower metres). Fall back to Android when accuracy is unavailable or Android wins. + * 3. Fall back to Android GPS when NMEA is very stale (beyond [nmeaExtendedThresholdMs]). + * 4. Use stale NMEA only when Android GPS has never provided a fix. + * 5. [bestPosition] is null until at least one source has reported. + * + * Call [updateSensorData] whenever new NMEA or Signal K sensor data arrives and + * [updateCurrentConditions] when a fresh marine-forecast response is received. + * Use [snapshot] to capture a point-in-time reading at safety-critical moments + * such as MOB activation. + * + * @param nmeaStalenessThresholdMs Maximum age (ms) of an NMEA fix before it enters the + * quality-comparison zone. Default: 5 000 ms. + * @param nmeaExtendedThresholdMs Maximum age (ms) up to which a marginally-stale NMEA fix + * can still win over Android if its [GpsPosition.accuracyMeters] is strictly better. + * Must be ≥ [nmeaStalenessThresholdMs]. Default: 10 000 ms. + * @param clockMs Injectable clock for unit-testable staleness checks. + */ +class LocationService( + private val windCalculator: TrueWindCalculator = TrueWindCalculator(), + private val nmeaStalenessThresholdMs: Long = 5_000L, + private val nmeaExtendedThresholdMs: Long = 10_000L, + private val clockMs: () -> Long = System::currentTimeMillis +) { + + private val _latestSensor = MutableStateFlow<SensorData?>(null) + /** The most recently received unified sensor reading. */ + val latestSensor: StateFlow<SensorData?> = _latestSensor.asStateFlow() + + private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null) + /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */ + val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow() + + private val _currentSpeedKt = MutableStateFlow<Double?>(null) + private val _currentDirectionDeg = MutableStateFlow<Double?>(null) + + // ── GPS sensor fusion state ─────────────────────────────────────────────── + + private var lastNmeaPosition: GpsPosition? = null + private var lastAndroidPosition: GpsPosition? = null + + private val _bestPosition = MutableStateFlow<GpsPosition?>(null) + /** + * The best available GPS fix, selected from NMEA and Android sources according + * to the fusion policy described in the class KDoc. Null until at least one + * source reports a fix. + */ + val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow() + + private val _activeGpsSource = MutableStateFlow(GpsSource.NONE) + /** The source that produced [bestPosition]. [GpsSource.NONE] before any fix arrives. */ + val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow() + + /** + * Ingest a new sensor reading. If the reading carries apparent wind, boat speed, + * and heading, true wind is resolved immediately via [TrueWindCalculator] and + * stored in [latestTrueWind]. + */ + fun updateSensorData(data: SensorData) { + _latestSensor.value = data + + val aws = data.apparentWindSpeedKt + val awa = data.apparentWindAngleDeg + val bsp = data.speedOverGroundKt // use SOG as proxy when BSP is absent + val hdg = data.headingTrueDeg + + if (aws != null && awa != null && bsp != null && hdg != null) { + _latestTrueWind.value = windCalculator.update( + apparent = ApparentWind(speedKt = aws, angleDeg = awa), + bsp = bsp, + hdgDeg = hdg + ) + } + } + + // ── GPS source ingestion ────────────────────────────────────────────────── + + /** + * Ingest a new GPS fix from the NMEA source (e.g. a marine chartplotter or + * NMEA multiplexer). Triggers a fusion re-evaluation. + */ + fun updateNmeaGps(position: GpsPosition) { + lastNmeaPosition = position + recomputeBestPosition() + } + + /** + * Ingest a new GPS fix from the Android system location provider. + * Triggers a fusion re-evaluation. + */ + fun updateAndroidGps(position: GpsPosition) { + lastAndroidPosition = position + recomputeBestPosition() + } + + /** + * Selects the best GPS fix and updates [bestPosition] / [activeGpsSource]. + * + * Priority tiers (in order): + * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs]) — always preferred. + * 2. Marginally-stale NMEA (age in (primary, extended] threshold) when Android is + * also available — keep NMEA only if its [GpsPosition.accuracyMeters] is strictly + * better than Android's; otherwise use Android. + * 3. Android GPS (any age) once NMEA is beyond the extended threshold. + * 4. Stale NMEA — used as last resort when Android has never reported. + */ + private fun recomputeBestPosition() { + val now = clockMs() + val nmea = lastNmeaPosition + val android = lastAndroidPosition + + val nmeaAge = nmea?.let { now - it.timestampMs } + val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs + val nmeaMarginallyStale = nmeaAge != null && + nmeaAge > nmeaStalenessThresholdMs && + nmeaAge <= nmeaExtendedThresholdMs + + val (best, source) = when { + nmeaFresh -> nmea!! to GpsSource.NMEA + + nmeaMarginallyStale && android != null -> + // Quality tie-break: NMEA wins only when it has a strictly better accuracy. + if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA + else android to GpsSource.ANDROID + + android != null -> android to GpsSource.ANDROID + nmea != null -> nmea to GpsSource.NMEA // only source, however stale + else -> null to GpsSource.NONE + } + + _bestPosition.value = best + _activeGpsSource.value = source + } + + // ── private helpers ─────────────────────────────────────────────────────── + + /** + * Returns true when this fix carries an accuracy estimate that is numerically + * smaller (i.e. better) than [other]'s. Returns false when either estimate is + * absent — conservatively preferring the other source when quality is unknown. + */ + private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean { + val thisAccuracy = accuracyMeters ?: return false + val otherAccuracy = other.accuracyMeters ?: return true + return thisAccuracy < otherAccuracy + } + + /** + * Update the ocean current conditions from the latest marine-forecast response. + * + * @param speedKt Current speed in knots (null to clear) + * @param directionDeg Direction the current flows TOWARD, in degrees (null to clear) + */ + fun updateCurrentConditions(speedKt: Double?, directionDeg: Double?) { + _currentSpeedKt.value = speedKt + _currentDirectionDeg.value = directionDeg + } + + /** + * Captures a snapshot of wind and current conditions at the current moment. + * + * All fields are nullable — only data that was available at snapshot time is + * populated. This snapshot is intended to be logged alongside a [MobEvent] + * at the instant of MOB activation. + */ + fun snapshot(): EnvironmentalSnapshot { + val trueWind = _latestTrueWind.value + return EnvironmentalSnapshot( + windSpeedKt = trueWind?.speedKt, + windDirectionDeg = trueWind?.directionDeg, + currentSpeedKt = _currentSpeedKt.value, + currentDirectionDeg = _currentDirectionDeg.value + ) + } +} + +/** + * Point-in-time snapshot of wind and current conditions. + * + * @param windSpeedKt True Wind Speed in knots; null if sensors were unavailable. + * @param windDirectionDeg True Wind Direction (degrees true, wind comes FROM); null if unavailable. + * @param currentSpeedKt Ocean current speed in knots; null if forecast was unavailable. + * @param currentDirectionDeg Ocean current direction (degrees, flows TOWARD); null if unavailable. + */ +data class EnvironmentalSnapshot( + val windSpeedKt: Double?, + val windDirectionDeg: Double?, + val currentSpeedKt: Double?, + val currentDirectionDeg: Double? +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt new file mode 100644 index 0000000..d4cf50d --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt @@ -0,0 +1,81 @@ +package com.example.androidapp.logbook + +import org.terst.nav.data.model.LogbookEntry +import java.util.Calendar +import java.util.TimeZone + +data class LogbookRow( + val time: String, + val position: String, + val sog: String, + val cog: String, + val wind: String, + val baro: String, + val depth: String, + val eventNotes: String +) + +data class LogbookPage( + val title: String, + val columns: List<String>, + val rows: List<LogbookRow> +) + +object LogbookFormatter { + + val COLUMNS = listOf( + "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes" + ) + + private val COMPASS_POINTS = arrayOf( + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" + ) + + fun formatTime(timestampMs: Long): String { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = timestampMs + return "%02d:%02d".format( + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE) + ) + } + + fun formatPosition(lat: Double, lon: Double): String { + val latDir = if (lat >= 0) "N" else "S" + val lonDir = if (lon >= 0) "E" else "W" + val absLat = Math.abs(lat) + val absLon = Math.abs(lon) + val latDeg = absLat.toInt() + val lonDeg = absLon.toInt() + val latMin = (absLat - latDeg) * 60.0 + val lonMin = (absLon - lonDeg) * 60.0 + return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir) + } + + fun toCompassPoint(degrees: Double): String { + val normalized = ((degrees % 360.0) + 360.0) % 360.0 + val index = ((normalized + 11.25) / 22.5).toInt() % 16 + return COMPASS_POINTS[index] + } + + fun formatWind(knots: Double?, directionDeg: Double?): String { + if (knots == null) return "" + val knotsStr = "%.0fkt".format(knots) + return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}" + } + + fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow( + time = formatTime(entry.timestampMs), + position = formatPosition(entry.lat, entry.lon), + sog = "%.1f".format(entry.sogKnots), + cog = "%.0f".format(entry.cogDegrees), + wind = formatWind(entry.windKnots, entry.windDirectionDeg), + baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "", + depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "", + eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ") + ) + + fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage = + LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) }) +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt new file mode 100644 index 0000000..78ea834 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt @@ -0,0 +1,137 @@ +package com.example.androidapp.logbook + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import org.terst.nav.data.model.LogbookEntry +import java.io.OutputStream + +/** + * Renders trip logbook entries to a formatted PDF (landscape A4). + * Section 4.8 — Trip Logging and Electronic Logbook. + */ +object LogbookPdfExporter { + + // Landscape A4 in points (1 point = 1/72 inch) + private const val PAGE_WIDTH = 842 + private const val PAGE_HEIGHT = 595 + private const val MARGIN = 36f + private const val ROW_HEIGHT = 22f + private const val HEADER_HEIGHT = 36f + private const val TITLE_SIZE = 16f + private const val CELL_TEXT_SIZE = 9f + + // Column width fractions (must sum to 1.0) + private val COL_FRACTIONS = floatArrayOf( + 0.08f, // Time + 0.18f, // Position + 0.06f, // SOG + 0.06f, // COG + 0.10f, // Wind + 0.07f, // Baro + 0.07f, // Depth + 0.38f // Event / Notes + ) + + fun export( + entries: List<LogbookEntry>, + outputStream: OutputStream, + title: String = "Trip Logbook" + ) { + val page = LogbookFormatter.toPage(entries, title) + val document = PdfDocument() + try { + val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create() + val pdfPage = document.startPage(pageInfo) + drawPage(pdfPage.canvas, page) + document.finishPage(pdfPage) + document.writeTo(outputStream) + } finally { + document.close() + } + } + + private fun drawPage(canvas: Canvas, page: LogbookPage) { + val usableWidth = PAGE_WIDTH - 2 * MARGIN + val colWidths = COL_FRACTIONS.map { it * usableWidth } + + val titlePaint = Paint().apply { + textSize = TITLE_SIZE + typeface = Typeface.DEFAULT_BOLD + color = Color.BLACK + } + val headerTextPaint = Paint().apply { + textSize = CELL_TEXT_SIZE + typeface = Typeface.DEFAULT_BOLD + color = Color.WHITE + } + val cellPaint = Paint().apply { + textSize = CELL_TEXT_SIZE + color = Color.BLACK + } + val linePaint = Paint().apply { + color = Color.LTGRAY + strokeWidth = 0.5f + } + val headerBgPaint = Paint().apply { + color = Color.rgb(41, 82, 123) + style = Paint.Style.FILL + } + val altBgPaint = Paint().apply { + color = Color.rgb(235, 242, 252) + style = Paint.Style.FILL + } + val borderPaint = Paint().apply { + color = Color.DKGRAY + strokeWidth = 1f + style = Paint.Style.STROKE + } + + var y = MARGIN + + // Title + canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint) + y += HEADER_HEIGHT + + val tableTop = y + + // Column header background + canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint) + + // Column header text + var x = MARGIN + 3f + page.columns.forEachIndexed { i, col -> + canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint) + x += colWidths[i] + } + y += ROW_HEIGHT + + // Data rows + page.rows.forEach { row -> + if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach + + if (page.rows.indexOf(row) % 2 == 1) { + canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint) + } + + val cells = listOf( + row.time, row.position, row.sog, row.cog, + row.wind, row.baro, row.depth, row.eventNotes + ) + x = MARGIN + 3f + cells.forEachIndexed { i, cell -> + val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4) + canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint) + x += colWidths[i] + } + + canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint) + y += ROW_HEIGHT + } + + // Table border + canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint) + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt new file mode 100644 index 0000000..b1b186a --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt @@ -0,0 +1,94 @@ +package com.example.androidapp.nmea + +import com.example.androidapp.gps.GpsPosition +import java.util.Calendar +import java.util.TimeZone + +class NmeaParser { + + /** + * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the + * sentence is void (status=V), malformed, or cannot be parsed. + * + * Supported talker IDs: GP, GN, and any other standard prefix. + * SOG and COG default to 0.0 when the fields are absent. + */ + fun parseRmc(sentence: String): GpsPosition? { + if (sentence.isBlank()) return null + + val body = if ('*' in sentence) sentence.substringBefore('*') else sentence + val fields = body.split(',') + if (fields.size < 10) return null + + if (!fields[0].endsWith("RMC")) return null + if (fields[2] != "A") return null + + val latStr = fields.getOrNull(3) ?: return null + val latDir = fields.getOrNull(4) ?: return null + val lonStr = fields.getOrNull(5) ?: return null + val lonDir = fields.getOrNull(6) ?: return null + + val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0 + val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0 + + val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0 + val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0 + + val timestampMs = parseTimestamp( + timeStr = fields.getOrNull(1) ?: "", + dateStr = fields.getOrNull(9) ?: "" + ) + if (timestampMs == 0L) return null + + return GpsPosition(latitude, longitude, sog, cog, timestampMs) + } + + /** + * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees. + */ + private fun parseNmeaDegrees(value: String): Double { + val raw = value.toDoubleOrNull() ?: return 0.0 + val degrees = (raw / 100.0).toInt() + val minutes = raw - degrees * 100.0 + return degrees + minutes / 60.0 + } + + /** + * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into Unix epoch millis UTC. + * Returns 0 on any parse failure. + */ + private fun parseTimestamp(timeStr: String, dateStr: String): Long { + return try { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.isLenient = false + + if (dateStr.length >= 6) { + val day = dateStr.substring(0, 2).toInt() + val month = dateStr.substring(2, 4).toInt() - 1 + val yy = dateStr.substring(4, 6).toInt() + val year = if (yy < 70) 2000 + yy else 1900 + yy + cal.set(Calendar.YEAR, year) + cal.set(Calendar.MONTH, month) + cal.set(Calendar.DAY_OF_MONTH, day) + } + + if (timeStr.length >= 6) { + val hours = timeStr.substring(0, 2).toInt() + val minutes = timeStr.substring(2, 4).toInt() + val seconds = timeStr.substring(4, 6).toInt() + val millis = if (timeStr.length > 7) { + val fracStr = timeStr.substring(7) + (("0.$fracStr").toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0 + } else 0 + cal.set(Calendar.HOUR_OF_DAY, hours) + cal.set(Calendar.MINUTE, minutes) + cal.set(Calendar.SECOND, seconds) + cal.set(Calendar.MILLISECOND, millis) + } + + cal.timeInMillis + } catch (e: Exception) { + 0L + } + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt new file mode 100644 index 0000000..60a5918 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt @@ -0,0 +1,12 @@ +package com.example.androidapp.routing + +/** + * The result of an isochrone weather routing computation. + * + * @param path Ordered list of [RoutePoint]s from the start to the destination. + * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds. + */ +data class IsochroneResult( + val path: List<RoutePoint>, + val etaMs: Long +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt new file mode 100644 index 0000000..901fdbc --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt @@ -0,0 +1,178 @@ +package com.example.androidapp.routing + +import org.terst.nav.data.model.BoatPolars +import org.terst.nav.data.model.WindForecast +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Isochrone-based weather routing engine (Section 3.4). + * + * Algorithm: + * 1. Start from a single point; expand a fan of headings at each time step. + * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind. + * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula. + * 4. Check whether the destination has been reached (within [arrivalRadiusM]). + * 5. Prune candidates: for each angular sector around the start, keep only the point that + * advanced furthest (removes dominated points). + * 6. Repeat until the destination is reached or [maxSteps] is exhausted. + * 7. Backtrace parent pointers to produce the optimal path. + */ +object IsochroneRouter { + + private const val EARTH_RADIUS_M = 6_371_000.0 + internal const val NM_TO_M = 1_852.0 + private const val KT_TO_M_PER_S = NM_TO_M / 3600.0 + + const val DEFAULT_HEADING_STEP_DEG = 5.0 + const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM + const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors + const val DEFAULT_MAX_STEPS = 200 + + /** + * Compute an optimised route from start to destination. + * + * @param startLat Start latitude (decimal degrees). + * @param startLon Start longitude (decimal degrees). + * @param destLat Destination latitude (decimal degrees). + * @param destLon Destination longitude (decimal degrees). + * @param startTimeMs Departure time as UNIX timestamp (ms). + * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours. + * @param polars Boat polar table. + * @param windAt Function returning [WindForecast] for a given position and time. + * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°. + * @param arrivalRadiusM Distance threshold to consider destination reached (metres). + * @param maxSteps Maximum number of isochrone expansions before giving up. + * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable. + */ + fun route( + startLat: Double, + startLon: Double, + destLat: Double, + destLon: Double, + startTimeMs: Long, + stepMs: Long, + polars: BoatPolars, + windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast, + headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG, + arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M, + maxSteps: Int = DEFAULT_MAX_STEPS + ): IsochroneResult? { + val start = RoutePoint(startLat, startLon, startTimeMs) + var isochrone = listOf(start) + + repeat(maxSteps) { step -> + val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs + val candidates = mutableListOf<RoutePoint>() + + for (point in isochrone) { + var heading = 0.0 + while (heading < 360.0) { + val wind = windAt(point.lat, point.lon, point.timestampMs) + val twa = ((heading - wind.twdDeg + 360.0) % 360.0) + val bspKt = polars.bsp(twa, wind.twsKt) + if (bspKt > 0.0) { + val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0) + val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM) + val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point) + + if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) { + return IsochroneResult( + path = backtrace(newPoint), + etaMs = nextTimeMs + ) + } + candidates.add(newPoint) + } + heading += headingStepDeg + } + } + + if (candidates.isEmpty()) return null + isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS) + } + + return null + } + + /** Walk parent pointers from destination back to start, then reverse. */ + internal fun backtrace(dest: RoutePoint): List<RoutePoint> { + val path = mutableListOf<RoutePoint>() + var current: RoutePoint? = dest + while (current != null) { + path.add(current) + current = current.parent + } + path.reverse() + return path + } + + /** + * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the + * start. Within each sector keep only the candidate that is furthest from the start. + */ + internal fun prune( + candidates: List<RoutePoint>, + startLat: Double, + startLon: Double, + sectors: Int + ): List<RoutePoint> { + val sectorSize = 360.0 / sectors + val best = mutableMapOf<Int, RoutePoint>() + + for (point in candidates) { + val bearing = bearingDeg(startLat, startLon, point.lat, point.lon) + val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1) + val existing = best[sector] + if (existing == null || + haversineM(startLat, startLon, point.lat, point.lon) > + haversineM(startLat, startLon, existing.lat, existing.lon) + ) { + best[sector] = point + } + } + + return best.values.toList() + } + + /** Haversine great-circle distance in metres. */ + internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2) + return 2.0 * EARTH_RADIUS_M * asin(sqrt(a)) + } + + /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */ + internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double { + val lat1 = Math.toRadians(lat1Deg) + val lat2 = Math.toRadians(lat2Deg) + val dLon = Math.toRadians(lon2Deg - lon1Deg) + val y = sin(dLon) * cos(lat2) + val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) + return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0 + } + + /** Spherical-Earth destination-point given start, bearing, and distance. */ + internal fun destinationPoint( + lat1Deg: Double, + lon1Deg: Double, + bearingDeg: Double, + distM: Double + ): Pair<Double, Double> { + val lat1 = Math.toRadians(lat1Deg) + val lon1 = Math.toRadians(lon1Deg) + val brng = Math.toRadians(bearingDeg) + val d = distM / EARTH_RADIUS_M + + val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng)) + val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2)) + + return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2)) + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt new file mode 100644 index 0000000..02988d1 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt @@ -0,0 +1,16 @@ +package com.example.androidapp.routing + +/** + * A single point in the isochrone routing tree. + * + * @param lat Latitude (decimal degrees). + * @param lon Longitude (decimal degrees). + * @param timestampMs UNIX time in milliseconds when this position is reached. + * @param parent The previous [RoutePoint] (null for the start point). + */ +data class RoutePoint( + val lat: Double, + val lon: Double, + val timestampMs: Long, + val parent: RoutePoint? = null +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt new file mode 100644 index 0000000..f544f63 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt @@ -0,0 +1,24 @@ +package com.example.androidapp.safety + +import kotlin.math.sqrt + +/** + * Holds UI-facing state for the anchor watch setup screen and provides + * the suggested watch-circle radius derived from depth and rode out. + */ +class AnchorWatchState { + + /** + * Returns the recommended watch-circle radius (metres) for the given depth + * and amount of rode deployed. + * + * Uses the Pythagorean formula sqrt(rode² - vertical²) when the geometry is + * valid (rode > depth + freeboard). Falls back to [rodeOutM] itself as the + * maximum possible swing radius when the rode is too short to form a catenary angle. + */ + fun calculateRecommendedWatchCircleRadius(depthM: Double, rodeOutM: Double): Double { + val vertical = depthM + 2.0 // 2 m default freeboard + return if (rodeOutM > vertical) sqrt(rodeOutM * rodeOutM - vertical * vertical) + else rodeOutM + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt new file mode 100644 index 0000000..2bdbf6c --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt @@ -0,0 +1,88 @@ +package com.example.androidapp.tide + +import com.example.androidapp.data.model.TidePrediction +import com.example.androidapp.data.model.TideStation +import kotlin.math.cos + +/** + * Computes harmonic tide predictions using the standard formula: + * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ] + * + * where: + * Z0 = datum offset (mean water level above chart datum, metres) + * Hi = amplitude of constituent i (metres) + * ωi = angular speed of constituent i (degrees / hour) + * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC) + * φi = phase lag (degrees) + */ +object HarmonicTideCalculator { + + /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */ + internal const val EPOCH_MS = 946_684_800_000L + + /** + * Predict the tide height at a single moment. + * + * @param station Tide station with harmonic constituents. + * @param timestampMs Unix epoch milliseconds for the desired time. + * @return Predicted height in metres above chart datum. + */ + fun predictHeight(station: TideStation, timestampMs: Long): Double { + val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0 + var height = station.datumOffsetMeters + for (c in station.constituents) { + val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg + height += c.amplitudeMeters * cos(Math.toRadians(angleDeg)) + } + return height + } + + /** + * Predict tide heights over a time range at regular intervals. + * + * @param station Tide station. + * @param fromMs Start of range (Unix milliseconds, inclusive). + * @param toMs End of range (Unix milliseconds, inclusive). + * @param intervalMs Time step in milliseconds (must be positive). + * @return List of [TidePrediction] ordered by ascending timestamp. + */ + fun predictRange( + station: TideStation, + fromMs: Long, + toMs: Long, + intervalMs: Long + ): List<TidePrediction> { + require(intervalMs > 0) { "intervalMs must be positive" } + require(fromMs <= toMs) { "fromMs must not exceed toMs" } + val predictions = mutableListOf<TidePrediction>() + var t = fromMs + while (t <= toMs) { + predictions += TidePrediction(t, predictHeight(station, t)) + t += intervalMs + } + return predictions + } + + /** + * Find high and low water events from a pre-computed prediction series. + * + * Detects local maxima (high water) and minima (low water) by comparing + * each interior sample with its immediate neighbours. + * + * @param predictions Ordered list of tide predictions (at least 3 points). + * @return Subset list containing only high/low turning points. + */ + fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> { + if (predictions.size < 3) return emptyList() + val result = mutableListOf<TidePrediction>() + for (i in 1 until predictions.size - 1) { + val prev = predictions[i - 1].heightMeters + val curr = predictions[i].heightMeters + val next = predictions[i + 1].heightMeters + val isMax = curr >= prev && curr >= next + val isMin = curr <= prev && curr <= next + if (isMax || isMin) result += predictions[i] + } + return result + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt new file mode 100644 index 0000000..289a857 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt @@ -0,0 +1,58 @@ +package com.example.androidapp.ui.anchorwatch + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import org.terst.nav.R +import org.terst.nav.databinding.FragmentAnchorWatchBinding +import com.example.androidapp.safety.AnchorWatchState + +class AnchorWatchHandler : Fragment() { + + private var _binding: FragmentAnchorWatchBinding? = null + private val binding get() = _binding!! + + private val anchorWatchState = AnchorWatchState() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) = updateSuggestedRadius() + } + binding.etDepth.addTextChangedListener(watcher) + binding.etRodeOut.addTextChangedListener(watcher) + } + + private fun updateSuggestedRadius() { + val depth = binding.etDepth.text.toString().toDoubleOrNull() + val rode = binding.etRodeOut.text.toString().toDoubleOrNull() + + if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) { + val radius = anchorWatchState.calculateRecommendedWatchCircleRadius(depth, rode) + binding.tvSuggestedRadius.text = + getString(R.string.anchor_suggested_radius_fmt, radius) + } else { + binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt new file mode 100644 index 0000000..01656a3 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt @@ -0,0 +1,3 @@ +package com.example.androidapp.wind + +data class ApparentWind(val speedKt: Double, val angleDeg: Double) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt new file mode 100644 index 0000000..db32163 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt @@ -0,0 +1,20 @@ +package com.example.androidapp.wind + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +class TrueWindCalculator { + fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData { + val awaRad = Math.toRadians(apparent.angleDeg) + val awX = apparent.speedKt * cos(awaRad) + val awY = apparent.speedKt * sin(awaRad) + val twX = awX - bsp + val twY = awY + val tws = sqrt(twX * twX + twY * twY) + val twaDeg = Math.toDegrees(atan2(twY, twX)) + val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360 + return TrueWindData(speedKt = tws, directionDeg = twdDeg) + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt new file mode 100644 index 0000000..78e9558 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt @@ -0,0 +1,3 @@ +package com.example.androidapp.wind + +data class TrueWindData(val speedKt: Double, val directionDeg: Double) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index d9dba73..f9d4dbd 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -22,7 +22,10 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.floatingactionbutton.FloatingActionButton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -45,9 +48,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var instrumentHandler: InstrumentHandler? = null private var mapHandler: MapHandler? = null private var anchorWatchHandler: AnchorWatchHandler? = null - + private val loadedStyleFlow = MutableStateFlow<Style?>(null) + private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> private lateinit var fragmentContainer: FrameLayout + private lateinit var fabRecordTrack: FloatingActionButton private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -55,6 +60,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { override fun onResume() { super.onResume() + mapView?.onResume() if (pendingServiceStart) { pendingServiceStart = false startServices() @@ -80,6 +86,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener { onActivateMob() } + + fabRecordTrack = findViewById(R.id.fab_record_track) + fabRecordTrack.setOnClickListener { + if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() + } + // Observe immediately — pure UI state, not gated on GPS permission + lifecycleScope.launch { + viewModel.isRecording.collect { recording -> + val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record + fabRecordTrack.setImageResource(icon) + fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track" + } + } } private fun setupBottomSheet() { @@ -131,17 +150,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun hideOverlays() { fragmentContainer.visibility = View.GONE - // Clear backstack if needed } override fun onActivateMob() { lifecycleScope.launch { LocationService.locationFlow.firstOrNull()?.let { gpsData -> val mediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm) - // In a real redesign, we'd show a specialized MOB fragment - // For now, keep existing handler logic but maybe toggle visibility mobHandler?.activateMob(gpsData.latitude, gpsData.longitude, mediaPlayer) - // Ensure MOB UI is visible - we might need to add it back to activity_main if removed } } } @@ -151,7 +166,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } private fun setupHandlers() { - // ... (Keep existing handler initialization, just update view IDs as needed) instrumentHandler = InstrumentHandler( valueAws = findViewById(R.id.value_aws), valueTws = findViewById(R.id.value_tws), @@ -226,10 +240,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { }, 256)) .withLayer(RasterLayer("openseamap-layer", "openseamap-source")) - maplibreMap.setStyle(style) { loadedStyle -> + maplibreMap.setStyle(style) { style -> + loadedStyleFlow.value = style val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow) - mapHandler?.setupLayers(loadedStyle, anchorBitmap, arrowBitmap) + mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap) } } } @@ -238,6 +253,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.locationFlow.collect { gpsData -> mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) + val sogKnots = gpsData.speedOverGround * 1.94384 + viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, gpsData.courseOverGround.toDouble()) } } lifecycleScope.launch { @@ -245,6 +262,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") } } + lifecycleScope.launch { + loadedStyleFlow.filterNotNull() + .combine(viewModel.trackPoints) { style, points -> style to points } + .collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) } + } } private fun startInstrumentSimulation(polarTable: PolarTable) { @@ -288,7 +310,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } override fun onStart() { super.onStart(); mapView?.onStart() } - override fun onResume() { super.onResume(); mapView?.onResume() } override fun onPause() { super.onPause(); mapView?.onPause() } override fun onStop() { super.onStop(); mapView?.onStop() } override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt new file mode 100644 index 0000000..0286ea8 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt @@ -0,0 +1,69 @@ +package org.terst.nav.data.model + +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * Boat polar speed table: maps (TWA, TWS) → BSP (boat speed through water, knots). + * + * Interpolation is bilinear — linear on TWA within a given TWS, then linear on TWS. + * Port-tack mirror: TWA > 180° is folded to 360° - TWA before lookup. + */ +data class BoatPolars( + /** Outer key: TWS in knots. Inner key: TWA in degrees [0, 180]. Value: BSP in knots. */ + val table: Map<Double, Map<Double, Double>> +) { + /** + * Returns boat speed (knots) for the given True Wind Angle and True Wind Speed. + * TWA outside [0, 360] is clamped; port/starboard symmetry is applied (>180° mirrored). + */ + fun bsp(twaDeg: Double, twsKt: Double): Double { + val twa = if (twaDeg > 180.0) 360.0 - twaDeg else twaDeg.coerceIn(0.0, 180.0) + + val twsKeys = table.keys.sorted() + if (twsKeys.isEmpty()) return 0.0 + + val twsClamped = twsKt.coerceIn(twsKeys.first(), twsKeys.last()) + val twsLow = twsKeys.lastOrNull { it <= twsClamped } ?: twsKeys.first() + val twsHigh = twsKeys.firstOrNull { it >= twsClamped } ?: twsKeys.last() + + val bspLow = bspAtTws(twa, table[twsLow] ?: return 0.0) + val bspHigh = bspAtTws(twa, table[twsHigh] ?: return 0.0) + + return if (twsHigh == twsLow) bspLow + else { + val t = (twsClamped - twsLow) / (twsHigh - twsLow) + bspLow + t * (bspHigh - bspLow) + } + } + + private fun bspAtTws(twaDeg: Double, twaMap: Map<Double, Double>): Double { + val twaKeys = twaMap.keys.sorted() + if (twaKeys.isEmpty()) return 0.0 + + val twaClamped = twaDeg.coerceIn(twaKeys.first(), twaKeys.last()) + val twaLow = twaKeys.lastOrNull { it <= twaClamped } ?: twaKeys.first() + val twaHigh = twaKeys.firstOrNull { it >= twaClamped } ?: twaKeys.last() + + val bspLow = twaMap[twaLow] ?: 0.0 + val bspHigh = twaMap[twaHigh] ?: 0.0 + + return if (twaHigh == twaLow) bspLow + else { + val t = (twaClamped - twaLow) / (twaHigh - twaLow) + bspLow + t * (bspHigh - bspLow) + } + } + + companion object { + /** Default polar for a typical 35-foot cruising sloop. */ + val DEFAULT: BoatPolars = BoatPolars( + mapOf( + 5.0 to mapOf(45.0 to 3.5, 60.0 to 4.2, 90.0 to 4.8, 120.0 to 5.0, 150.0 to 4.5, 180.0 to 4.0), + 10.0 to mapOf(45.0 to 5.5, 60.0 to 6.5, 90.0 to 7.0, 120.0 to 7.2, 150.0 to 6.8, 180.0 to 6.0), + 15.0 to mapOf(45.0 to 6.5, 60.0 to 7.5, 90.0 to 8.0, 120.0 to 8.5, 150.0 to 8.0, 180.0 to 7.0), + 20.0 to mapOf(45.0 to 7.0, 60.0 to 8.0, 90.0 to 8.5, 120.0 to 9.0, 150.0 to 8.5, 180.0 to 7.5) + ) + ) + } +} 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 index 9d284b5..715c1db 100644 --- 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 @@ -2,39 +2,8 @@ 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. - */ +data class GribFile(val region: GribRegion, val modelRunTime: Instant, val forecastHours: Int, val downloadedAt: Instant, val filePath: String, val sizeBytes: Long) { 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 + 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/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/GribRegion.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt index 5e32d6c..f960bc3 100644 --- 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 @@ -1,20 +1,6 @@ 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). */ +data class GribRegion(val name: String, val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double) { + fun contains(lat: Double, lon: Double): Boolean = lat in latMin..latMax && lon in lonMin..lonMax fun areaDegrees(): Double = (latMax - latMin) * (lonMax - lonMin) } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt new file mode 100644 index 0000000..f41e917 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt @@ -0,0 +1,19 @@ +package org.terst.nav.data.model + +/** + * A single entry in the electronic trip logbook. + * Matches the log format from Section 4.8 of COMPONENT_DESIGN.md. + */ +data class LogbookEntry( + val timestampMs: Long, + val lat: Double, + val lon: Double, + val sogKnots: Double, + val cogDegrees: Double, + val windKnots: Double? = null, + val windDirectionDeg: Double? = null, + val baroHpa: Double? = null, + val depthMeters: Double? = null, + val event: String? = null, + val notes: String? = null +) 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/main/kotlin/org/terst/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt new file mode 100644 index 0000000..deb73d6 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt @@ -0,0 +1,9 @@ +package com.example.androidapp.data.model + +/** A single harmonic tidal constituent used in harmonic tide prediction. */ +data class TideConstituent( + val name: String, // e.g. "M2", "S2", "K1" + val speedDegPerHour: Double, // angular speed in degrees per hour + val amplitudeMeters: Double, // amplitude in metres + val phaseDeg: Double // phase lag (kappa) in degrees +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt new file mode 100644 index 0000000..51eea44 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt @@ -0,0 +1,7 @@ +package com.example.androidapp.data.model + +/** A predicted tide height at a specific point in time. */ +data class TidePrediction( + val timestampMs: Long, // Unix epoch milliseconds + val heightMeters: Double // predicted water height above chart datum in metres +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt new file mode 100644 index 0000000..c9f96a6 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt @@ -0,0 +1,11 @@ +package com.example.androidapp.data.model + +/** A tide station with harmonic constituents for offline tide prediction. */ +data class TideStation( + val id: String, + val name: String, + val lat: Double, + val lon: Double, + val datumOffsetMeters: Double, // mean water level above chart datum (Z0) + val constituents: List<TideConstituent> +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt new file mode 100644 index 0000000..f009da8 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt @@ -0,0 +1,18 @@ +package org.terst.nav.data.model + +/** + * Wind conditions at a specific location and time. + * + * @param lat Latitude (decimal degrees). + * @param lon Longitude (decimal degrees). + * @param timestampMs UNIX time in milliseconds. + * @param twsKt True Wind Speed in knots. + * @param twdDeg True Wind Direction in degrees (the direction FROM which the wind blows, 0–360). + */ +data class WindForecast( + val lat: Double, + val lon: Double, + val timestampMs: Long, + val twsKt: Double, + val twdDeg: Double +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt new file mode 100644 index 0000000..d803c8c --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt @@ -0,0 +1,12 @@ +package org.terst.nav.track + +data class TrackPoint( + val lat: Double, + val lon: Double, + val sogKnots: Double, + val cogDeg: Double, + val windSpeedKnots: Double, + val windAngleDeg: Double, + val isTrueWind: Boolean, + val timestampMs: Long +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt new file mode 100644 index 0000000..7953822 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -0,0 +1,26 @@ +package org.terst.nav.track + +class TrackRepository { + + var isRecording: Boolean = false + private set + + private val points = mutableListOf<TrackPoint>() + + fun startTrack() { + points.clear() + isRecording = true + } + + fun stopTrack() { + isRecording = false + } + + fun addPoint(point: TrackPoint): Boolean { + if (!isRecording) return false + points.add(point) + return true + } + + fun getPoints(): List<TrackPoint> = points.toList() +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt index 8e84e1e..0efff52 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -6,6 +6,8 @@ import org.terst.nav.ais.AisHubSource import org.terst.nav.ais.AisRepository import org.terst.nav.ais.AisVessel import org.terst.nav.data.api.AisHubApiService +import org.terst.nav.track.TrackPoint +import org.terst.nav.track.TrackRepository import org.terst.nav.data.model.ForecastItem import org.terst.nav.data.model.WindArrow import org.terst.nav.data.repository.WeatherRepository @@ -43,6 +45,37 @@ class MainViewModel( private val aisRepository = AisRepository() + private val trackRepository = TrackRepository() + + private val _isRecording = MutableStateFlow(false) + val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow() + + private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList()) + val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow() + + fun startTrack() { + trackRepository.startTrack() + _trackPoints.value = emptyList() + _isRecording.value = true + } + + fun stopTrack() { + trackRepository.stopTrack() + _isRecording.value = false + } + + fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { + val point = TrackPoint( + lat = lat, lon = lon, + sogKnots = sogKnots, cogDeg = cogDeg, + windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false, + timestampMs = System.currentTimeMillis() + ) + if (trackRepository.addPoint(point)) { + _trackPoints.value = trackRepository.getPoints() + } + } + private val aisHubApi: AisHubApiService by lazy { Retrofit.Builder() .baseUrl("https://data.aishub.net") diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index 91569cf..cbc2e90 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -7,6 +7,7 @@ import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.RasterLayer import org.maplibre.android.style.layers.SymbolLayer @@ -15,10 +16,12 @@ import org.maplibre.android.style.sources.RasterSource import org.maplibre.android.style.sources.TileSet import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.LineString import org.maplibre.geojson.Point import org.maplibre.geojson.Polygon import org.terst.nav.AnchorWatchState import org.terst.nav.TidalCurrentState +import org.terst.nav.track.TrackPoint import kotlin.math.cos import kotlin.math.sin @@ -37,9 +40,13 @@ class MapHandler(private val maplibreMap: MapLibreMap) { private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer" private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon" + private val TRACK_SOURCE_ID = "track-source" + private val TRACK_LAYER_ID = "track-line" + private var anchorPointSource: GeoJsonSource? = null private var anchorCircleSource: GeoJsonSource? = null private var tidalCurrentSource: GeoJsonSource? = null + private var trackSource: GeoJsonSource? = null /** * Initializes map layers for anchor watch and tidal currents. @@ -127,6 +134,28 @@ class MapHandler(private val maplibreMap: MapLibreMap) { } } + /** + * Updates the GPS track polyline on the map. Lazily initialises the layer on first call. + */ + fun updateTrackLayer(style: Style, points: List<TrackPoint>) { + if (trackSource == null) { + trackSource = GeoJsonSource(TRACK_SOURCE_ID) + style.addSource(trackSource!!) + style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply { + setProperties( + PropertyFactory.lineColor("#E53935"), + PropertyFactory.lineWidth(3f) + ) + }) + } + if (points.size >= 2) { + val coords = points.map { Point.fromLngLat(it.lon, it.lat) } + trackSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords))) + } else { + trackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) + } + } + private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon { val points = mutableListOf<Point>() val degreesBetweenPoints = 8 diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt diff --git a/android-app/app/src/main/res/drawable/ic_track_record.xml b/android-app/app/src/main/res/drawable/ic_track_record.xml new file mode 100644 index 0000000..9016369 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_track_record.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <!-- Outer ring --> + <path + android:fillColor="#00000000" + android:strokeColor="@android:color/white" + android:strokeWidth="2" + android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/> + <!-- Filled red dot --> + <path + android:fillColor="#E53935" + android:pathData="M12,12m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"/> +</vector> diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 66d1abe..68abc60 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -65,7 +65,20 @@ android:contentDescription="Man Overboard" app:srcCompat="@android:drawable/ic_dialog_alert" app:backgroundTint="@color/mob_button_background" - app:layout_anchor="@id/bottom_navigation" + app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|start" /> + <!-- Record Track Button --> + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/fab_record_track" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:clickable="true" + android:focusable="true" + android:contentDescription="Record Track" + app:srcCompat="@drawable/ic_track_record" + app:layout_anchor="@id/instrument_bottom_sheet" + app:layout_anchorGravity="top|end" /> + </androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/android-app/app/src/main/res/layout/fragment_anchor_watch.xml b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml new file mode 100644 index 0000000..96b9969 --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<ScrollView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="16dp"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/anchor_watch_title" + android:textSize="20sp" + android:textStyle="bold" + android:layout_marginBottom="24dp" /> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/anchor_depth_label" + android:textSize="14sp" + android:layout_marginBottom="4dp" /> + + <EditText + android:id="@+id/etDepth" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/anchor_depth_hint" + android:inputType="numberDecimal" + android:importantForAutofill="no" + android:layout_marginBottom="16dp" /> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/anchor_rode_label" + android:textSize="14sp" + android:layout_marginBottom="4dp" /> + + <EditText + android:id="@+id/etRodeOut" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/anchor_rode_hint" + android:inputType="numberDecimal" + android:importantForAutofill="no" + android:layout_marginBottom="24dp" /> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/anchor_suggested_radius_label" + android:textSize="14sp" + android:layout_marginBottom="4dp" /> + + <TextView + android:id="@+id/tvSuggestedRadius" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/anchor_suggested_radius_empty" + android:textSize="18sp" + android:textStyle="bold" + android:layout_marginBottom="4dp" /> + + <TextView + android:id="@+id/tvSuggestedRadiusHint" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/anchor_suggested_radius_hint" + android:textSize="12sp" + android:alpha="0.6" /> + + </LinearLayout> + +</ScrollView> diff --git a/android-app/app/src/main/res/layout/fragment_map.xml b/android-app/app/src/main/res/layout/fragment_map.xml index e5b86b7..2b9b40d 100644 --- a/android-app/app/src/main/res/layout/fragment_map.xml +++ b/android-app/app/src/main/res/layout/fragment_map.xml @@ -27,4 +27,18 @@ android:textSize="14sp" android:visibility="gone" /> + <!-- Staleness banner --> + <TextView + android:id="@+id/tvStalenessWarning" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + android:background="#FFCC00" + android:textColor="#000000" + android:textStyle="bold" + android:padding="8dp" + android:gravity="center" + android:visibility="gone" + android:text="" /> + </FrameLayout> diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml index 499ba8d..756f5e3 100755 --- a/android-app/app/src/main/res/values/strings.xml +++ b/android-app/app/src/main/res/values/strings.xml @@ -58,4 +58,14 @@ <string name="temp_fmt">%.0f °C</string> <string name="precip_fmt">%d%%</string> <string name="permission_rationale">Location is needed to show weather for your current position.</string> + <string name="nav_anchor_watch">Anchor</string> + <string name="anchor_watch_title">Anchor Watch</string> + <string name="anchor_depth_label">Depth (m)</string> + <string name="anchor_depth_hint">e.g. 5.0</string> + <string name="anchor_rode_label">Rode Out (m)</string> + <string name="anchor_rode_hint">e.g. 30.0</string> + <string name="anchor_suggested_radius_label">Suggested Watch Radius</string> + <string name="anchor_suggested_radius_empty">—</string> + <string name="anchor_suggested_radius_fmt">%.1f m</string> + <string name="anchor_suggested_radius_hint">Calculated from rode and depth using Pythagorean scope formula (2 m freeboard assumed)</string> </resources> diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt new file mode 100644 index 0000000..535e46a --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt @@ -0,0 +1,91 @@ +package com.example.androidapp.data.weather + +import com.example.androidapp.data.model.GribFile +import com.example.androidapp.data.model.GribRegion +import com.example.androidapp.data.storage.InMemoryGribFileManager +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.time.Instant + +class GribStalenessCheckerTest { + + private lateinit var manager: InMemoryGribFileManager + private lateinit var checker: GribStalenessChecker + private val region = GribRegion("test", 35.0, 40.0, -125.0, -120.0) + + @Before + fun setUp() { + manager = InMemoryGribFileManager() + checker = GribStalenessChecker(manager) + } + + private fun makeFile( + modelRunTime: Instant, + forecastHours: Int, + downloadedAt: Instant = modelRunTime + ) = GribFile( + region = region, + modelRunTime = modelRunTime, + forecastHours = forecastHours, + downloadedAt = downloadedAt, + filePath = "/tmp/test.grib", + sizeBytes = 1024L + ) + + @Test + fun `check_returnsFresh_whenFileIsNotStale`() { + val now = Instant.parse("2026-03-16T12:00:00Z") + // model run at 06:00, 24h forecast → valid until 06:00 next day, well beyond now + val file = makeFile( + modelRunTime = Instant.parse("2026-03-16T06:00:00Z"), + forecastHours = 24, + downloadedAt = Instant.parse("2026-03-16T07:00:00Z") + ) + manager.saveMetadata(file) + + val result = checker.check(region, now) + + assertTrue("Expected Fresh but got $result", result is FreshnessResult.Fresh) + } + + @Test + fun `check_returnsStale_whenFileIsExpired`() { + val now = Instant.parse("2026-03-16T20:00:00Z") + // model run at 06:00, 6h forecast → valid until 12:00; now is 8h after that + val file = makeFile( + modelRunTime = Instant.parse("2026-03-16T06:00:00Z"), + forecastHours = 6, + downloadedAt = Instant.parse("2026-03-16T07:00:00Z") + ) + manager.saveMetadata(file) + + val result = checker.check(region, now) + + assertTrue("Expected Stale but got $result", result is FreshnessResult.Stale) + val stale = result as FreshnessResult.Stale + assertTrue("Message should contain hours outdated", stale.message.contains("8h")) + assertEquals(file, stale.file) + } + + @Test + fun `check_returnsNoData_whenNoFilesForRegion`() { + val otherRegion = GribRegion("other", 50.0, 55.0, -10.0, 0.0) + val file = makeFile( + modelRunTime = Instant.parse("2026-03-16T06:00:00Z"), + forecastHours = 24 + ) + manager.saveMetadata(file) + + val result = checker.check(otherRegion, Instant.parse("2026-03-16T12:00:00Z")) + + assertEquals(FreshnessResult.NoData, result) + } + + @Test + fun `check_returnsNoData_whenManagerEmpty`() { + val result = checker.check(region, Instant.now()) + + assertEquals(FreshnessResult.NoData, result) + } +} 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)) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt new file mode 100644 index 0000000..8b2753c --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt @@ -0,0 +1,33 @@ +package com.example.androidapp.gps + +import org.junit.Assert.* +import org.junit.Test + +class GpsPositionTest { + + @Test + fun `GpsPosition holds correct values`() { + val pos = GpsPosition( + latitude = 41.5, + longitude = -71.0, + sog = 5.2, + cog = 180.0, + timestampMs = 1_000L + ) + assertEquals(41.5, pos.latitude, 0.0) + assertEquals(-71.0, pos.longitude, 0.0) + assertEquals(5.2, pos.sog, 0.0) + assertEquals(180.0, pos.cog, 0.0) + assertEquals(1_000L, pos.timestampMs) + } + + @Test + fun `GpsPosition equality works as expected for data class`() { + val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L) + val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L) + val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L) + + assertEquals(pos1, pos2) + assertNotEquals(pos1, pos3) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt new file mode 100644 index 0000000..4eb9898 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt @@ -0,0 +1,317 @@ +package com.example.androidapp.gps + +import com.example.androidapp.data.model.SensorData +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class LocationServiceTest { + + private fun service() = LocationService() + + // ── snapshot with no data ───────────────────────────────────────────────── + + @Test + fun snapshot_noData_allFieldsNull() { + val snap = service().snapshot() + assertNull(snap.windSpeedKt) + assertNull(snap.windDirectionDeg) + assertNull(snap.currentSpeedKt) + assertNull(snap.currentDirectionDeg) + } + + // ── true-wind resolution ────────────────────────────────────────────────── + + @Test + fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking { + val svc = service() + // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt + // → TW comes FROM ahead at 5 kt + svc.updateSensorData( + SensorData( + headingTrueDeg = 0.0, + apparentWindSpeedKt = 10.0, + apparentWindAngleDeg = 0.0, + speedOverGroundKt = 5.0 + ) + ) + val tw = svc.latestTrueWind.first() + assertNotNull(tw) + assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0) + } + + @Test + fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking { + val svc = service() + svc.updateSensorData( + SensorData( + apparentWindSpeedKt = 10.0, + apparentWindAngleDeg = 45.0, + speedOverGroundKt = 5.0 + // headingTrueDeg omitted + ) + ) + assertNull(svc.latestTrueWind.first()) + } + + // ── current conditions ──────────────────────────────────────────────────── + + @Test + fun updateCurrentConditions_reflectedInSnapshot() { + val svc = service() + svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0) + + val snap = svc.snapshot() + assertEquals(1.5, snap.currentSpeedKt!!, 0.001) + assertEquals(135.0, snap.currentDirectionDeg!!, 0.001) + } + + @Test + fun updateCurrentConditions_nullClears() { + val svc = service() + svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0) + svc.updateCurrentConditions(speedKt = null, directionDeg = null) + + val snap = svc.snapshot() + assertNull(snap.currentSpeedKt) + assertNull(snap.currentDirectionDeg) + } + + // ── combined snapshot ───────────────────────────────────────────────────── + + @Test + fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking { + val svc = service() + + // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt + svc.updateSensorData( + SensorData( + headingTrueDeg = 90.0, + apparentWindSpeedKt = 12.0, + apparentWindAngleDeg = 45.0, + speedOverGroundKt = 6.0 + ) + ) + svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0) + + val snap = svc.snapshot() + assertNotNull(snap.windSpeedKt) + assertNotNull(snap.windDirectionDeg) + assertEquals(0.8, snap.currentSpeedKt!!, 0.001) + assertEquals(270.0, snap.currentDirectionDeg!!, 0.001) + } + + // ── latestSensor flow ───────────────────────────────────────────────────── + + @Test + fun updateSensorData_updatesLatestSensorFlow() = runBlocking { + val svc = service() + assertNull(svc.latestSensor.first()) + + val data = SensorData(latitude = 41.5, longitude = -71.3) + svc.updateSensorData(data) + + assertEquals(data, svc.latestSensor.first()) + } + + // ── GPS sensor fusion ───────────────────────────────────────────────────── + + private fun fusionService( + nmeaStalenessThresholdMs: Long = 5_000L, + nmeaExtendedThresholdMs: Long = 10_000L, + clockMs: () -> Long = System::currentTimeMillis + ) = LocationService( + nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, + nmeaExtendedThresholdMs = nmeaExtendedThresholdMs, + clockMs = clockMs + ) + + private fun pos(lat: Double, lon: Double, timestampMs: Long) = + GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) + + private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) = + GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters) + + @Test + fun noGpsData_bestPositionNullAndSourceNone() = runBlocking { + val svc = fusionService() + assertNull(svc.bestPosition.first()) + assertEquals(GpsSource.NONE, svc.activeGpsSource.first()) + } + + @Test + fun freshNmea_preferredOverAndroid() = runBlocking { + val now = 10_000L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, now) + val androidFix = pos(42.0, -72.0, now - 1_000L) + + svc.updateAndroidGps(androidFix) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun staleNmea_androidFallback() = runBlocking { + val nmeaTime = 0L + val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s) + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, nmeaTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking { + val now = 60_000L // 60 s after fix — very stale + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, 0L) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun onlyAndroidAvailable_isUsed() = runBlocking { + val svc = fusionService() + val androidFix = pos(42.0, -72.0, System.currentTimeMillis()) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking { + val fixTime = 0L + val now = 5_000L // exactly at threshold + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, fixTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + } + + // ── fix-quality (accuracy) tie-breaking ────────────────────────────────── + + @Test + fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking { + // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m. + val nmeaTime = 0L + val now = 7_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0) + val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking { + // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins. + val nmeaTime = 0L + val now = 7_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0) + val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking { + // Neither source has accuracy metadata — conservative: prefer Android. + val nmeaTime = 0L + val now = 7_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = pos(41.0, -71.0, nmeaTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + } + + @Test + fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking { + // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy. + val nmeaTime = 0L + val now = 15_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0) + val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun nmeaRecovery_switchesBackFromAndroid() = runBlocking { + var now = 0L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + // Fresh NMEA + svc.updateNmeaGps(pos(41.0, -71.0, 0L)) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + + // NMEA goes stale; Android takes over + now = 10_000L + val androidFix = pos(42.0, -72.0, 10_000L) + svc.updateAndroidGps(androidFix) + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value) + + // NMEA recovers with a fresh fix + val freshNmea = pos(41.1, -71.1, 10_000L) + svc.updateNmeaGps(freshNmea) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + assertEquals(freshNmea, svc.bestPosition.value) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt new file mode 100644 index 0000000..30b421f --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt @@ -0,0 +1,178 @@ +package com.example.androidapp.logbook + +import com.example.androidapp.data.model.LogbookEntry +import org.junit.Assert.* +import org.junit.Test + +class LogbookFormatterTest { + + // 2021-06-15 08:00:00 UTC = 1623744000000 ms + private val t0 = 1_623_744_000_000L + + private fun entry( + ts: Long = t0, + lat: Double = 41.39, + lon: Double = -71.202, + sog: Double = 6.2, + cog: Double = 225.0, + windKt: Double? = 15.0, + windDir: Double? = 225.0, + baro: Double? = 1018.0, + depth: Double? = 14.0, + event: String? = "Departed slip", + notes: String? = null + ) = LogbookEntry(ts, lat, lon, sog, cog, windKt, windDir, baro, depth, event, notes) + + // --- formatTime --- + + @Test + fun `formatTime returns HH_MM for UTC midnight`() { + // 2021-06-15 00:00:00 UTC + val ts = 1_623_715_200_000L + assertEquals("00:00", LogbookFormatter.formatTime(ts)) + } + + @Test + fun `formatTime returns correct UTC hour for known timestamp`() { + // t0 = 2021-06-15 08:00:00 UTC + assertEquals("08:00", LogbookFormatter.formatTime(t0)) + } + + @Test + fun `formatTime pads single-digit hour and minute`() { + // 2021-06-15 01:05:00 UTC = 1623715200000 + 65*60*1000 = 1623715200000 + 3900000 + val ts = 1_623_715_200_000L + 65 * 60_000L + assertEquals("01:05", LogbookFormatter.formatTime(ts)) + } + + // --- formatPosition --- + + @Test + fun `formatPosition north east`() { + // 41.39°N → 41°23.4N, 71.202°E → 71°12.1E + val result = LogbookFormatter.formatPosition(41.39, 71.202) + assertEquals("41°23.4N 71°12.1E", result) + } + + @Test + fun `formatPosition south west`() { + // -41.39°S → 41°23.4S, -71.202°W → 71°12.1W + val result = LogbookFormatter.formatPosition(-41.39, -71.202) + assertEquals("41°23.4S 71°12.1W", result) + } + + @Test + fun `formatPosition zero zero`() { + val result = LogbookFormatter.formatPosition(0.0, 0.0) + assertEquals("0°0.0N 0°0.0E", result) + } + + // --- formatWind --- + + @Test + fun `formatWind null knots returns empty string`() { + assertEquals("", LogbookFormatter.formatWind(null, null)) + } + + @Test + fun `formatWind with knots and null direction returns knots only`() { + assertEquals("15kt", LogbookFormatter.formatWind(15.0, null)) + } + + @Test + fun `formatWind 225 degrees is SW`() { + assertEquals("15kt SW", LogbookFormatter.formatWind(15.0, 225.0)) + } + + @Test + fun `formatWind 0 degrees is N`() { + assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 0.0)) + } + + @Test + fun `formatWind 360 degrees is N`() { + assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 360.0)) + } + + @Test + fun `formatWind 90 degrees is E`() { + assertEquals("8kt E", LogbookFormatter.formatWind(8.0, 90.0)) + } + + // --- toCompassPoint --- + + @Test + fun `toCompassPoint covers all 16 cardinal and intercardinal points`() { + val expected = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW") + expected.forEachIndexed { i, dir -> + val degrees = i * 22.5 + assertEquals("degrees=$degrees", dir, LogbookFormatter.toCompassPoint(degrees)) + } + } + + // --- toRow --- + + @Test + fun `toRow formats all fields correctly`() { + val row = LogbookFormatter.toRow(entry()) + assertEquals("08:00", row.time) + assertEquals("41°23.4N 71°12.1W", row.position) + assertEquals("6.2", row.sog) + assertEquals("225", row.cog) + assertEquals("15kt SW", row.wind) + assertEquals("1018", row.baro) + assertEquals("14m", row.depth) + assertEquals("Departed slip", row.eventNotes) + } + + @Test + fun `toRow combines event and notes with colon`() { + val row = LogbookFormatter.toRow(entry(event = "Reef #1", notes = "Strong gusts")) + assertEquals("Reef #1: Strong gusts", row.eventNotes) + } + + @Test + fun `toRow with only notes has no colon prefix`() { + val row = LogbookFormatter.toRow(entry(event = null, notes = "Calm seas")) + assertEquals("Calm seas", row.eventNotes) + } + + @Test + fun `toRow with null optional fields uses empty strings`() { + val e = LogbookEntry(t0, 0.0, 0.0, 0.0, 0.0) + val row = LogbookFormatter.toRow(e) + assertEquals("", row.wind) + assertEquals("", row.baro) + assertEquals("", row.depth) + assertEquals("", row.eventNotes) + } + + // --- toPage --- + + @Test + fun `toPage returns page with default title and correct column count`() { + val page = LogbookFormatter.toPage(emptyList()) + assertEquals("Trip Logbook", page.title) + assertEquals(8, page.columns.size) + } + + @Test + fun `toPage maps entries to rows in order`() { + val entries = listOf( + entry(ts = t0, event = "First"), + entry(ts = t0 + 3_600_000L, event = "Second") + ) + val page = LogbookFormatter.toPage(entries, "Voyage Log") + assertEquals("Voyage Log", page.title) + assertEquals(2, page.rows.size) + assertEquals("First", page.rows[0].eventNotes) + assertEquals("Second", page.rows[1].eventNotes) + } + + @Test + fun `toPage empty entries produces empty rows`() { + val page = LogbookFormatter.toPage(emptyList()) + assertTrue(page.rows.isEmpty()) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt new file mode 100644 index 0000000..b8a878a --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt @@ -0,0 +1,105 @@ +package com.example.androidapp.nmea + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class NmeaParserTest { + + private lateinit var parser: NmeaParser + + @Before + fun setUp() { + parser = NmeaParser() + } + + // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A + // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E + // SOG 22.4 kn, COG 84.4° + + @Test + fun `valid RMC sentence parses latitude and longitude`() { + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(48.1173, pos!!.latitude, 0.0001) + assertEquals(11.51667, pos.longitude, 0.0001) + } + + @Test + fun `valid RMC sentence parses SOG and COG`() { + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(22.4, pos!!.sog, 0.001) + assertEquals(84.4, pos.cog, 0.001) + } + + @Test + fun `void status V returns null`() { + val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W" + assertNull(parser.parseRmc(sentence)) + } + + @Test + fun `malformed sentence with too few fields returns null`() { + assertNull(parser.parseRmc("\$GPRMC,123519,A")) + } + + @Test + fun `empty string returns null`() { + assertNull(parser.parseRmc("")) + } + + @Test + fun `non-RMC sentence returns null`() { + val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,," + assertNull(parser.parseRmc(sentence)) + } + + @Test + fun `south latitude is negative`() { + // lat: -(42 + 50.5589/60) = -42.84265 + val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,," + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertTrue("South latitude must be negative", pos!!.latitude < 0) + assertEquals(-42.84265, pos.latitude, 0.0001) + } + + @Test + fun `west longitude is negative`() { + // lon: -(11 + 31.000/60) = -11.51667 + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertTrue("West longitude must be negative", pos!!.longitude < 0) + assertEquals(-11.51667, pos.longitude, 0.0001) + } + + @Test + fun `SOG and COG parse with decimal precision`() { + val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,," + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(12.345, pos!!.sog, 0.0001) + assertEquals(270.5, pos.cog, 0.0001) + } + + @Test + fun `empty SOG and COG fields default to zero`() { + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(0.0, pos!!.sog, 0.001) + assertEquals(0.0, pos.cog, 0.001) + } + + @Test + fun `GNRMC talker ID is also accepted`() { + val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(48.1173, pos!!.latitude, 0.0001) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt new file mode 100644 index 0000000..e5615e9 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt @@ -0,0 +1,169 @@ +package com.example.androidapp.routing + +import com.example.androidapp.data.model.BoatPolars +import com.example.androidapp.data.model.WindForecast +import org.junit.Assert.* +import org.junit.Test + +class IsochroneRouterTest { + + private val startTimeMs = 1_000_000_000L + private val oneHourMs = 3_600_000L + + // ── BoatPolars ──────────────────────────────────────────────────────────── + + @Test + fun `bsp returns exact value for exact twa and tws entry`() { + val polars = BoatPolars.DEFAULT + // At TWS=10, TWA=90 the table has 7.0 kt + assertEquals(7.0, polars.bsp(90.0, 10.0), 1e-9) + } + + @Test + fun `bsp interpolates between twa entries`() { + val polars = BoatPolars.DEFAULT + // At TWS=10: TWA=60 → 6.5, TWA=90 → 7.0; midpoint TWA=75 → 6.75 + assertEquals(6.75, polars.bsp(75.0, 10.0), 1e-9) + } + + @Test + fun `bsp interpolates between tws entries`() { + val polars = BoatPolars.DEFAULT + // At TWA=90: TWS=10 → 7.0, TWS=15 → 8.0; midpoint TWS=12.5 → 7.5 + assertEquals(7.5, polars.bsp(90.0, 12.5), 1e-9) + } + + @Test + fun `bsp mirrors port tack twa to starboard`() { + val polars = BoatPolars.DEFAULT + // TWA=270 should mirror to 360-270=90, giving same as TWA=90 + assertEquals(polars.bsp(90.0, 10.0), polars.bsp(270.0, 10.0), 1e-9) + } + + @Test + fun `bsp clamps tws below table minimum`() { + val polars = BoatPolars.DEFAULT + // TWS=0 clamps to minimum TWS=5 + assertEquals(polars.bsp(90.0, 5.0), polars.bsp(90.0, 0.0), 1e-9) + } + + @Test + fun `bsp clamps tws above table maximum`() { + val polars = BoatPolars.DEFAULT + // TWS=100 clamps to maximum TWS=20 + assertEquals(polars.bsp(90.0, 20.0), polars.bsp(90.0, 100.0), 1e-9) + } + + // ── IsochroneRouter geometry helpers ───────────────────────────────────── + + @Test + fun `haversineM returns zero for same point`() { + assertEquals(0.0, IsochroneRouter.haversineM(10.0, 20.0, 10.0, 20.0), 1e-3) + } + + @Test + fun `haversineM one degree of latitude is approximately 111_195 m`() { + val dist = IsochroneRouter.haversineM(0.0, 0.0, 1.0, 0.0) + assertEquals(111_195.0, dist, 50.0) + } + + @Test + fun `bearingDeg returns 0 for due north`() { + val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 1.0, 0.0) + assertEquals(0.0, bearing, 1e-6) + } + + @Test + fun `bearingDeg returns 90 for due east`() { + val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 0.0, 1.0) + assertEquals(90.0, bearing, 1e-4) + } + + @Test + fun `destinationPoint due north by 1 NM moves latitude by expected amount`() { + val (lat, lon) = IsochroneRouter.destinationPoint(0.0, 0.0, 0.0, IsochroneRouter.NM_TO_M) + assertTrue("latitude should increase", lat > 0.0) + assertEquals(0.0, lon, 1e-9) + // 1 NM ≈ 1/60 degree of latitude + assertEquals(1.0 / 60.0, lat, 1e-4) + } + + // ── Pruning ─────────────────────────────────────────────────────────────── + + @Test + fun `prune keeps only furthest point per sector`() { + // Two points both due north of origin at different distances + val close = RoutePoint(1.0, 0.0, startTimeMs) + val far = RoutePoint(2.0, 0.0, startTimeMs) + val result = IsochroneRouter.prune(listOf(close, far), 0.0, 0.0, 72) + assertEquals(1, result.size) + assertEquals(far, result[0]) + } + + @Test + fun `prune keeps points in different sectors separately`() { + // One point north, one point east — different sectors + val north = RoutePoint(1.0, 0.0, startTimeMs) + val east = RoutePoint(0.0, 1.0, startTimeMs) + val result = IsochroneRouter.prune(listOf(north, east), 0.0, 0.0, 72) + assertEquals(2, result.size) + } + + // ── Full routing ────────────────────────────────────────────────────────── + + @Test + fun `route finds path to destination with constant wind`() { + // Destination is ~5 NM due east of start; constant 10kt easterly (FROM east = 90°) + // A 10kt boat sailing downwind (TWA=180) = 6.0 kt; ~5 NM / 6 kt ≈ 50 min → 1 step + val destLat = 0.0 + val destLon = 0.0 + (5.0 / 60.0) // ~5 NM east + val constantWind = { _: Double, _: Double, _: Long -> + WindForecast(0.0, 0.0, startTimeMs, twsKt = 10.0, twdDeg = 90.0) + } + val result = IsochroneRouter.route( + startLat = 0.0, + startLon = 0.0, + destLat = destLat, + destLon = destLon, + startTimeMs = startTimeMs, + stepMs = oneHourMs, + polars = BoatPolars.DEFAULT, + windAt = constantWind, + arrivalRadiusM = 2_000.0 // 2 km arrival radius + ) + assertNotNull("Should find a route", result) + result!! + assertTrue("Path should have at least 2 points (start + arrival)", result.path.size >= 2) + assertEquals("Path should start at origin", 0.0, result.path.first().lat, 1e-6) + assertEquals("ETA should be after start", startTimeMs, result.etaMs - oneHourMs) + } + + @Test + fun `route returns null when polars produce zero speed`() { + val zeroPolar = BoatPolars(emptyMap()) + val result = IsochroneRouter.route( + startLat = 0.0, + startLon = 0.0, + destLat = 1.0, + destLon = 0.0, + startTimeMs = startTimeMs, + stepMs = oneHourMs, + polars = zeroPolar, + windAt = { _, _, _ -> WindForecast(0.0, 0.0, startTimeMs, 10.0, 0.0) }, + maxSteps = 3 + ) + assertNull("Should return null when no progress is possible", result) + } + + @Test + fun `backtrace returns path from start to arrival in order`() { + val p0 = RoutePoint(0.0, 0.0, 0L) + val p1 = RoutePoint(1.0, 0.0, 1L, parent = p0) + val p2 = RoutePoint(2.0, 0.0, 2L, parent = p1) + val path = IsochroneRouter.backtrace(p2) + assertEquals(3, path.size) + assertEquals(p0, path[0]) + assertEquals(p1, path[1]) + assertEquals(p2, path[2]) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt new file mode 100644 index 0000000..40f7df0 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt @@ -0,0 +1,32 @@ +package com.example.androidapp.safety + +import org.junit.Assert.* +import org.junit.Test +import kotlin.math.sqrt + +class AnchorWatchStateTest { + + private val state = AnchorWatchState() + + @Test + fun calculateRecommendedWatchCircleRadius_validGeometry() { + // depth=6m, rode=50m → vertical=8m, radius=sqrt(50²-8²)=sqrt(2436) + val expected = sqrt(2436.0) + val actual = state.calculateRecommendedWatchCircleRadius(depthM = 6.0, rodeOutM = 50.0) + assertEquals(expected, actual, 0.001) + } + + @Test + fun calculateRecommendedWatchCircleRadius_rodeShorterThanVertical_fallsBackToRode() { + // depth=10m, rode=5m → vertical=12m > rode, fallback returns rode + val actual = state.calculateRecommendedWatchCircleRadius(depthM = 10.0, rodeOutM = 5.0) + assertEquals(5.0, actual, 0.001) + } + + @Test + fun calculateRecommendedWatchCircleRadius_rodeEqualsVertical_fallsBackToRode() { + // depth=8m, rode=10m → vertical=10m == rode, fallback returns rode + val actual = state.calculateRecommendedWatchCircleRadius(depthM = 8.0, rodeOutM = 10.0) + assertEquals(10.0, actual, 0.001) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt new file mode 100644 index 0000000..612ae34 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt @@ -0,0 +1,135 @@ +package com.example.androidapp.tide + +import com.example.androidapp.data.model.TideConstituent +import com.example.androidapp.data.model.TideStation +import org.junit.Assert.* +import org.junit.Test + +class HarmonicTideCalculatorTest { + + // Reference epoch: 2000-01-01 00:00:00 UTC = 946_684_800_000 ms + private val epochMs = HarmonicTideCalculator.EPOCH_MS + private val oneHourMs = 3_600_000L + + private fun stationWith( + speed: Double = 30.0, + amplitude: Double = 1.0, + phase: Double = 0.0, + datum: Double = 0.0 + ) = TideStation( + id = "test", name = "Test", lat = 0.0, lon = 0.0, + datumOffsetMeters = datum, + constituents = listOf(TideConstituent("S2", speed, amplitude, phase)) + ) + + @Test + fun `predictHeight at epoch gives datum plus amplitude for zero-phase constituent`() { + val station = stationWith(speed = 30.0, amplitude = 1.5, phase = 0.0, datum = 0.5) + val height = HarmonicTideCalculator.predictHeight(station, epochMs) + assertEquals(0.5 + 1.5, height, 1e-9) // cos(0°) = 1.0 + } + + @Test + fun `predictHeight at half period gives datum minus amplitude`() { + // speed = 30 deg/hr → half period = 6 hours → cos(180°) = -1.0 + val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0) + val height = HarmonicTideCalculator.predictHeight(station, epochMs + 6 * oneHourMs) + assertEquals(-1.0, height, 1e-9) + } + + @Test + fun `predictHeight at quarter period is near zero`() { + // speed = 30 deg/hr → quarter period = 3 hours → cos(90°) ≈ 0.0 + val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0) + val height = HarmonicTideCalculator.predictHeight(station, epochMs + 3 * oneHourMs) + assertEquals(0.0, height, 1e-9) + } + + @Test + fun `predictHeight applies phase offset correctly`() { + // phase = 90 → cos(0 - 90°) = cos(-90°) ≈ 0.0 at epoch + val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 90.0, datum = 0.0) + val height = HarmonicTideCalculator.predictHeight(station, epochMs) + assertEquals(0.0, height, 1e-9) + } + + @Test + fun `predictHeight sums multiple constituents at epoch`() { + val station = TideStation( + id = "test", name = "Test", lat = 0.0, lon = 0.0, + datumOffsetMeters = 2.0, + constituents = listOf( + TideConstituent("S2", 30.0, 1.0, 0.0), // +1.0 at epoch + TideConstituent("K1", 30.0, 0.5, 0.0) // +0.5 at epoch + ) + ) + val height = HarmonicTideCalculator.predictHeight(station, epochMs) + assertEquals(3.5, height, 1e-9) // 2.0 + 1.0 + 0.5 + } + + @Test + fun `predictHeight with empty constituents returns datum offset only`() { + val station = TideStation("t", "T", 0.0, 0.0, 3.14, emptyList()) + assertEquals(3.14, HarmonicTideCalculator.predictHeight(station, epochMs), 1e-9) + } + + @Test + fun `predictRange returns correct number of predictions`() { + val station = stationWith() + val predictions = HarmonicTideCalculator.predictRange( + station, epochMs, epochMs + 3 * oneHourMs, oneHourMs + ) + assertEquals(4, predictions.size) // t=0h, 1h, 2h, 3h + } + + @Test + fun `predictRange timestamps are evenly spaced`() { + val station = stationWith() + val predictions = HarmonicTideCalculator.predictRange( + station, epochMs, epochMs + 2 * oneHourMs, oneHourMs + ) + assertEquals(epochMs, predictions[0].timestampMs) + assertEquals(epochMs + oneHourMs, predictions[1].timestampMs) + assertEquals(epochMs + 2 * oneHourMs, predictions[2].timestampMs) + } + + @Test + fun `predictRange with equal from and to returns single prediction`() { + val station = stationWith() + val predictions = HarmonicTideCalculator.predictRange(station, epochMs, epochMs, oneHourMs) + assertEquals(1, predictions.size) + assertEquals(epochMs, predictions[0].timestampMs) + } + + @Test + fun `findHighLow returns empty list for fewer than 3 predictions`() { + val station = stationWith() + val predictions = HarmonicTideCalculator.predictRange( + station, epochMs, epochMs + oneHourMs, oneHourMs + ) + assertEquals(2, predictions.size) + assertTrue(HarmonicTideCalculator.findHighLow(predictions).isEmpty()) + } + + @Test + fun `findHighLow detects high and low water events`() { + // speed = 30 deg/hr, 3-hour samples over 24 hours + // Heights: 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0, 0.0, 1.0 + // Turning points at t=6h(low), t=12h(high), t=18h(low) + val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0) + val predictions = HarmonicTideCalculator.predictRange( + station, + epochMs, + epochMs + 24 * oneHourMs, + 3 * oneHourMs + ) + val highLow = HarmonicTideCalculator.findHighLow(predictions) + assertEquals(3, highLow.size) + assertEquals(epochMs + 6 * oneHourMs, highLow[0].timestampMs) + assertEquals(-1.0, highLow[0].heightMeters, 1e-9) + assertEquals(epochMs + 12 * oneHourMs, highLow[1].timestampMs) + assertEquals(1.0, highLow[1].heightMeters, 1e-9) + assertEquals(epochMs + 18 * oneHourMs, highLow[2].timestampMs) + assertEquals(-1.0, highLow[2].heightMeters, 1e-9) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt new file mode 100644 index 0000000..fc4580c --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt @@ -0,0 +1,67 @@ +package org.terst.nav.data.model + +import org.junit.Assert.* +import org.junit.Test + +class LogbookEntryTest { + + @Test + fun `LogbookEntry holds all required fields`() { + val entry = LogbookEntry( + timestampMs = 1_000_000L, + lat = 41.39, + lon = -71.202, + sogKnots = 6.2, + cogDegrees = 225.0, + windKnots = 15.0, + windDirectionDeg = 225.0, + baroHpa = 1018.0, + depthMeters = 14.0, + event = "Departed slip", + notes = "Crew ready" + ) + assertEquals(1_000_000L, entry.timestampMs) + assertEquals(41.39, entry.lat, 1e-9) + assertEquals(-71.202, entry.lon, 1e-9) + assertEquals(6.2, entry.sogKnots, 1e-9) + assertEquals(225.0, entry.cogDegrees, 1e-9) + assertEquals(15.0, entry.windKnots) + assertEquals(225.0, entry.windDirectionDeg) + assertEquals(1018.0, entry.baroHpa) + assertEquals(14.0, entry.depthMeters) + assertEquals("Departed slip", entry.event) + assertEquals("Crew ready", entry.notes) + } + + @Test + fun `LogbookEntry optional fields default to null`() { + val entry = LogbookEntry( + timestampMs = 0L, + lat = 0.0, + lon = 0.0, + sogKnots = 0.0, + cogDegrees = 0.0 + ) + assertNull(entry.windKnots) + assertNull(entry.windDirectionDeg) + assertNull(entry.baroHpa) + assertNull(entry.depthMeters) + assertNull(entry.event) + assertNull(entry.notes) + } + + @Test + fun `LogbookEntry data class equality`() { + val a = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0) + val b = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0) + assertEquals(a, b) + } + + @Test + fun `LogbookEntry data class copy`() { + val original = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0, event = "anchor") + val copy = original.copy(sogKnots = 3.0) + assertEquals(3.0, copy.sogKnots, 1e-9) + assertEquals("anchor", copy.event) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt new file mode 100644 index 0000000..0a6f4bb --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt @@ -0,0 +1,56 @@ +package com.example.androidapp.data.model + +import org.junit.Assert.* +import org.junit.Test + +class TideModelTest { + + @Test + fun `TideConstituent holds all fields`() { + val c = TideConstituent("M2", 28.9841042, 0.85, 120.0) + assertEquals("M2", c.name) + assertEquals(28.9841042, c.speedDegPerHour, 1e-7) + assertEquals(0.85, c.amplitudeMeters, 1e-9) + assertEquals(120.0, c.phaseDeg, 1e-9) + } + + @Test + fun `TidePrediction holds timestamp and height`() { + val p = TidePrediction(1_700_000_000_000L, 2.34) + assertEquals(1_700_000_000_000L, p.timestampMs) + assertEquals(2.34, p.heightMeters, 1e-9) + } + + @Test + fun `TidePrediction data class equality`() { + val p1 = TidePrediction(1_000L, 1.5) + val p2 = TidePrediction(1_000L, 1.5) + assertEquals(p1, p2) + } + + @Test + fun `TideStation holds all fields and constituents`() { + val c = TideConstituent("K1", 15.0410686, 0.3, 45.0) + val station = TideStation( + id = "9447130", + name = "Seattle, WA", + lat = 47.602, + lon = -122.339, + datumOffsetMeters = 1.8, + constituents = listOf(c) + ) + assertEquals("9447130", station.id) + assertEquals("Seattle, WA", station.name) + assertEquals(47.602, station.lat, 1e-9) + assertEquals(-122.339, station.lon, 1e-9) + assertEquals(1.8, station.datumOffsetMeters, 1e-9) + assertEquals(1, station.constituents.size) + assertEquals("K1", station.constituents[0].name) + } + + @Test + fun `TideStation with empty constituents is valid`() { + val station = TideStation("test", "Test", 0.0, 0.0, 0.0, emptyList()) + assertTrue(station.constituents.isEmpty()) + } +} diff --git a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt diff --git a/android-app/build.gradle b/android-app/build.gradle index edacb05..195123b 100644 --- a/android-app/build.gradle +++ b/android-app/build.gradle @@ -3,6 +3,7 @@ plugins { id 'com.android.application' version '8.3.2' apply false id 'com.android.library' version '8.3.2' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false + id 'com.google.devtools.ksp' version '2.0.0-1.0.21' apply false id 'com.google.gms.google-services' version '4.4.1' apply false id 'com.google.firebase.appdistribution' version '4.0.1' apply false id 'com.google.firebase.crashlytics' version '3.0.2' apply false diff --git a/scripts/pull-crash-logs b/scripts/pull-crash-logs new file mode 100755 index 0000000..7458e18 --- /dev/null +++ b/scripts/pull-crash-logs @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# pull-crash-logs — Download recent Crashlytics crash reports via Firebase CLI +# +# Usage: ./scripts/pull-crash-logs [--limit N] [--app-id APP_ID] [--out DIR] +# Example: ./scripts/pull-crash-logs --limit 20 --out /tmp/crashes +# +# Requirements: +# - firebase-tools installed and authenticated (firebase login) +# - FIREBASE_PROJECT set in env, or edit PROJECT_ID below +# - firebase-admin or curl + gcloud token for REST fallback + +set -euo pipefail + +# ── Config ────────────────────────────────────────────────────────────────── +PROJECT_ID="${FIREBASE_PROJECT:-nav-test-c2872}" +LIMIT=10 +OUT_DIR="$(pwd)/crash-logs" + +# ── Arg parsing ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --limit) LIMIT="$2"; shift 2 ;; + --app-id) APP_ID="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +mkdir -p "$OUT_DIR" + +echo "=== Firebase Crashlytics crash log pull ===" +echo " Project : $PROJECT_ID" +echo " Limit : $LIMIT" +echo " Output : $OUT_DIR" +echo "" + +# ── Check tooling ──────────────────────────────────────────────────────────── +require() { command -v "$1" &>/dev/null || { echo "ERROR: '$1' not found. Install it first." >&2; exit 1; }; } + +# Prefer firebase CLI → fall back to REST via gcloud token +if command -v firebase &>/dev/null; then + MODE="firebase-cli" +elif command -v gcloud &>/dev/null; then + MODE="rest" +else + echo "ERROR: neither firebase-tools nor gcloud found." >&2 + echo " Install firebase-tools: npm install -g firebase-tools" >&2 + echo " Or gcloud SDK: https://cloud.google.com/sdk/docs/install" >&2 + exit 1 +fi + +echo "Mode: $MODE" +echo "" + +# ── Pull via firebase CLI ──────────────────────────────────────────────────── +if [[ "$MODE" == "firebase-cli" ]]; then + # List Android apps in project to find app ID if not provided + if [[ -z "${APP_ID:-}" ]]; then + echo "Fetching app list..." + APP_ID=$(firebase apps:list --project "$PROJECT_ID" --json 2>/dev/null \ + | python3 -c " +import json, sys +apps = json.load(sys.stdin) +android = [a for a in apps.get('result', []) if a.get('platform') == 'ANDROID'] +if not android: + print('', end='') +else: + print(android[0]['appId'], end='') +" 2>/dev/null || true) + + if [[ -z "$APP_ID" ]]; then + echo "WARNING: Could not auto-detect app ID. Set --app-id <id> manually." >&2 + echo " Run: firebase apps:list --project $PROJECT_ID" >&2 + else + echo " Auto-detected app ID: $APP_ID" + fi + fi + + # Crashlytics data via firebase crashlytics:symbols or REST + # firebase CLI doesn't expose crash issue listing directly; + # use the REST API path below with a gcloud-obtained token. + echo "" + echo "NOTE: firebase CLI does not expose crash issue listing." + echo "Switching to REST API (requires gcloud auth)..." + MODE="rest" +fi + +# ── Pull via Crashlytics REST API ──────────────────────────────────────────── +if [[ "$MODE" == "rest" ]]; then + require curl + require python3 + + # Try gcloud first, then fall back to firebase-tools stored token + TOKEN=$(gcloud auth print-access-token 2>/dev/null) || TOKEN="" + + if [[ -z "$TOKEN" ]]; then + FIREBASE_CONFIG="$HOME/.config/configstore/firebase-tools.json" + if [[ -f "$FIREBASE_CONFIG" ]]; then + TOKEN=$(python3 -c " +import json, sys +with open('$FIREBASE_CONFIG') as f: + d = json.load(f) +tokens = d.get('tokens', {}) +print(tokens.get('access_token', ''), end='') +" 2>/dev/null) + fi + fi + + if [[ -z "$TOKEN" ]]; then + echo "ERROR: Not authenticated. Run: firebase login --no-localhost" >&2 + exit 1 + fi + + if [[ -z "${APP_ID:-}" ]]; then + echo "Fetching app list from Firebase Management API..." + APPS_JSON=$(curl -s \ + -H "Authorization: Bearer $TOKEN" \ + "https://firebase.googleapis.com/v1beta1/projects/${PROJECT_ID}/androidApps") + + APP_ID=$(echo "$APPS_JSON" | python3 -c " +import json, sys +data = json.load(sys.stdin) +apps = data.get('apps', []) +if not apps: + print('', end='') +else: + print(apps[0]['appId'], end='') +" 2>/dev/null || true) + + if [[ -z "$APP_ID" ]]; then + echo "ERROR: No Android apps found in project $PROJECT_ID" >&2 + echo "Response: $APPS_JSON" >&2 + exit 1 + fi + echo " Auto-detected app ID: $APP_ID" + fi + + # Crashlytics issue list via v1alpha1 API + ISSUES_URL="https://firebasecrashlytics.googleapis.com/v1alpha1/projects/${PROJECT_ID}/apps/${APP_ID}/issues?pageSize=${LIMIT}&orderBy=eventCount+desc" + + echo "" + echo "Fetching top $LIMIT crash issues..." + ISSUES_FILE="$OUT_DIR/issues.json" + + curl -s \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$ISSUES_URL" \ + -o "$ISSUES_FILE" + + ISSUE_COUNT=$(python3 -c " +import json, sys +with open('$ISSUES_FILE') as f: + data = json.load(f) +issues = data.get('issues', []) +print(len(issues)) +" 2>/dev/null || echo "0") + + echo " Found $ISSUE_COUNT issues. Saved to $ISSUES_FILE" + echo "" + + # Pretty-print summary + python3 -c " +import json, sys + +with open('$ISSUES_FILE') as f: + data = json.load(f) + +issues = data.get('issues', []) +if not issues: + print('No crash issues found (or API returned an error).') + print('Raw response:') + print(json.dumps(data, indent=2)) + sys.exit(0) + +print(f'{'ID':<30} {'EVENTS':>8} {'USERS':>7} TITLE') +print('-' * 90) +for issue in issues: + issue_id = issue.get('issueId', 'N/A')[:30] + title = issue.get('title', 'N/A')[:50] + event_cnt = issue.get('eventCount', '?') + user_cnt = issue.get('impactedUsersCount', '?') + print(f'{issue_id:<30} {str(event_cnt):>8} {str(user_cnt):>7} {title}') +" + + # Pull individual events for each issue + echo "" + echo "Fetching events per issue..." + python3 -c " +import json, sys, subprocess, os + +with open('$ISSUES_FILE') as f: + data = json.load(f) + +issues = data.get('issues', []) +token = '$TOKEN' +project = '$PROJECT_ID' +app_id = '$APP_ID' +out_dir = '$OUT_DIR' + +for issue in issues[:5]: # cap at 5 issues for detail pull + issue_id = issue.get('issueId', '') + if not issue_id: + continue + + url = (f'https://firebasecrashlytics.googleapis.com/v1alpha1/' + f'projects/{project}/apps/{app_id}/issues/{issue_id}/events?pageSize=5') + + out_file = os.path.join(out_dir, f'events_{issue_id[:20]}.json') + result = subprocess.run( + ['curl', '-s', '-H', f'Authorization: Bearer {token}', url], + capture_output=True, text=True + ) + with open(out_file, 'w') as f: + f.write(result.stdout) + + try: + events = json.loads(result.stdout).get('events', []) + print(f' {issue_id[:30]}: {len(events)} events → {out_file}') + except Exception: + print(f' {issue_id[:30]}: parse error → {out_file}') +" +fi + +echo "" +echo "Done. Files written to: $OUT_DIR" +ls -lh "$OUT_DIR" diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt new file mode 100644 index 0000000..d803c8c --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt @@ -0,0 +1,12 @@ +package org.terst.nav.track + +data class TrackPoint( + val lat: Double, + val lon: Double, + val sogKnots: Double, + val cogDeg: Double, + val windSpeedKnots: Double, + val windAngleDeg: Double, + val isTrueWind: Boolean, + val timestampMs: Long +) diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt new file mode 100644 index 0000000..7953822 --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -0,0 +1,26 @@ +package org.terst.nav.track + +class TrackRepository { + + var isRecording: Boolean = false + private set + + private val points = mutableListOf<TrackPoint>() + + fun startTrack() { + points.clear() + isRecording = true + } + + fun stopTrack() { + isRecording = false + } + + fun addPoint(point: TrackPoint): Boolean { + if (!isRecording) return false + points.add(point) + return true + } + + fun getPoints(): List<TrackPoint> = points.toList() +} diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt new file mode 100644 index 0000000..dea19c6 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt @@ -0,0 +1,79 @@ +package org.terst.nav.track + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +private fun makePoint( + lat: Double = 37.0, + lon: Double = -122.0, + sog: Double = 5.0, + cog: Double = 90.0, + windSpeed: Double = 10.0, + windAngle: Double = 45.0, + isTrueWind: Boolean = false, + ts: Long = 1_000L +) = TrackPoint(lat, lon, sog, cog, windSpeed, windAngle, isTrueWind, ts) + +class TrackRepositoryTest { + + private lateinit var repo: TrackRepository + + @Before fun setUp() { + repo = TrackRepository() + } + + @Test fun `initial state is not recording`() { + assertFalse(repo.isRecording) + } + + @Test fun `startTrack sets isRecording to true`() { + repo.startTrack() + assertTrue(repo.isRecording) + } + + @Test fun `stopTrack sets isRecording to false`() { + repo.startTrack() + repo.stopTrack() + assertFalse(repo.isRecording) + } + + @Test fun `addPoint appends point when recording`() { + repo.startTrack() + repo.addPoint(makePoint(lat = 37.1)) + assertEquals(1, repo.getPoints().size) + assertEquals(37.1, repo.getPoints()[0].lat, 0.0001) + } + + @Test fun `addPoint is ignored when not recording`() { + repo.addPoint(makePoint()) + assertEquals(0, repo.getPoints().size) + } + + @Test fun `startTrack clears previous points`() { + repo.startTrack() + repo.addPoint(makePoint()) + repo.addPoint(makePoint()) + repo.stopTrack() + repo.startTrack() + assertEquals(0, repo.getPoints().size) + } + + @Test fun `multiple points accumulate in order`() { + repo.startTrack() + repo.addPoint(makePoint(lat = 37.0, ts = 1000L)) + repo.addPoint(makePoint(lat = 37.1, ts = 2000L)) + repo.addPoint(makePoint(lat = 37.2, ts = 3000L)) + val pts = repo.getPoints() + assertEquals(3, pts.size) + assertEquals(37.0, pts[0].lat, 0.0001) + assertEquals(37.2, pts[2].lat, 0.0001) + } + + @Test fun `getPoints returns snapshot not live list`() { + repo.startTrack() + val snapshot = repo.getPoints() + repo.addPoint(makePoint()) + assertEquals(0, snapshot.size) // snapshot taken before addPoint + } +} |
