summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-26 07:13:53 +0000
committerClaude <noreply@anthropic.com>2026-05-26 07:13:53 +0000
commit5c3bcee9a574780476ac4a4888b51b76f186c032 (patch)
treed983e88e63dd26a099143a5e71e6f356c7faeec5
parentd35410c707900075bf4207bab518ead44d43c6e7 (diff)
parent8c1d1605f529e32876ba4cb325a019a311c5011d (diff)
Merge claude/optimize-nav-location-tracking-Vac5Z into main
- Add notes/photos to finished tracks (TrackDetailSheet) - Single Nav voice trip report (remove NarrativeStyle, 4-style picker) - Fix speed coloring on MapLibre 11.x (Expression.step on numeric property) - Redesign tack detection: time-based windows + circularMAD stability - Update worklog https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn
-rw-r--r--.agent/worklog.md200
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt128
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt26
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt143
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt114
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt12
-rw-r--r--android-app/app/src/main/res/layout/dialog_add_track_note.xml69
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml62
-rw-r--r--android-app/app/src/main/res/layout/layout_track_detail_sheet.xml35
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt32
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt128
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt126
13 files changed, 685 insertions, 410 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index cfae053..6187ae5 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -1,146 +1,80 @@
# SESSION_STATE.md
-## Backlog (fix later)
+## Backlog
-- **Add notes/photos to a finished track** — UI to attach logbook entries to a past track by timestamp/geo proximity. Must flow into the trip report (currently only live-session entries are scoped).
+- **Pirate mode** — Easter egg / opt-in toggle, not the default voice. Long-press the trip report header flips the narrative into pirate dialect. Seamanlike voice stays default.
+- **Race mode tack detection** — Tighter windows (T_SETTLE ~10–15 s), wider STAB_MAX (~30°), shorter MIN_GAP_MS (~20 s) for quick successive tacks at race starts and mark roundings. Current mode is CRUISING (conservative). See `TackDetector.kt` constants.
+- **Persist tracks to Room** — Replace in-memory `_pastTracks` with a Room database. SAF write path is solid; the in-memory list is still the source of truth at runtime.
+- **AnchorWatchHandler UI** — Re-integrate into `activity_main.xml` and wire to the Safety Dashboard.
+- **Unit tests for trip generators** — `TripReportGenerator` and `PreTripReportGenerator` have no JVM tests yet.
-- **Trip report voice** — The current narrative styles (Professional / Adventurous / Journal / Pirate) are generic and not right for Nav. Nav is an opinionated sailing app; the report should have a single voice that reflects that — concise, knowledgeable, seamanlike. Rip out the style picker and write one good template.
-
-- **Tack detection is buggy, especially at low speed** — False positives at low SOG (GPS heading noise looks like tacks). Need minimum-SOG gate (e.g. skip detection when SOG < 1.5 kt) and review the stability window / MIN_DELTA thresholds against real tracks.
+---
-- **Speed coloring on the map doesn't work** — `Expression.get("color")` on a FeatureCollection of 2-point LineStrings fails silently in MapLibre Android 11.x. The focused-track layer in MapHandler still uses this broken approach. Needs investigation: either find a working data-driven expression syntax for this MapLibre version, or pre-segment the GeoJSON differently.
+## Paused Task: Instrument Sheet Visual Redesign
-## Current Task Goal
-Instrument Sheet Visual Redesign — IN PROGRESS (2026-04-06)
+**Started:** 2026-04-06 · **Status:** Tasks 1–2 done; Tasks 3–7 pending
-**Plan:** `docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md`
-**Spec:** `docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md`
+### What's being built
+Full visual redesign of the instrument bottom sheet:
+- `WaveView`: animated canvas divider (swell + wind waves + whitecaps), sky-blue above / ocean-blue below
+- `DirectionArrowView`: 14dp inline chevron in two palettes (SKY grey / OCEAN blue)
+- Direction arrows inline with all numeric values
+- `°` baked into HDG/COG (no separate unit label)
+- Forecast cells: value line 1, [arrow] bearing line 2 (swell adds period)
+- Imperial units throughout (ft for depth, wave height, swell)
+- Forecast labels: "Current" / "Waves" (no section header)
+- Reports section removed; touch-through prevented
### Progress
-- [x] Task 1: Remove report buttons + fix touch-through — **DONE** (commit `2d86c0b`)
-- [x] Task 2: Update typography styles — **DONE** (commit `e79b678`)
-- [ ] Task 3: Create `DirectionArrowView` — pending
-- [ ] Task 4: Create `WaveView` — pending
-- [ ] Task 5: Restructure `layout_instruments_sheet.xml` — pending
-- [ ] Task 6: Refactor `InstrumentHandler` — pending
-- [ ] Task 7: Update `MainActivity` — wire new views and fix units — pending
+- [x] Task 1: Remove report buttons, fix touch-through — commit `2d86c0b`
+- [x] Task 2: Update typography styles — commit `e79b678`
+- [ ] Task 3: Create `DirectionArrowView.kt`
+- [ ] Task 4: Create `WaveView.kt`
+- [ ] Task 5: Restructure `layout_instruments_sheet.xml`
+- [ ] Task 6: Refactor `InstrumentHandler.kt`
+- [ ] Task 7: Wire new views in `MainActivity.kt`, fix units
-### What's being built
-A visual redesign of the instrument bottom sheet:
-- Animated wave canvas divider (swell height/period + wind waves + whitecaps) replacing the plain divider between instruments and forecast
-- Sky-blue gradient above wave, ocean blue below
-- `DirectionArrowView`: 14dp inline chevron compass indicator in two palettes (SKY grey / OCEAN blue)
-- Direction arrows inline with numeric values in all relevant cells
-- `°` baked into HDG/COG values (not a separate unit label)
-- Forecast cells: value on line 1, [arrow] bearing on line 2 (swell adds period)
-- Imperial units: feet for depth, wave height, swell height (all converted from metres)
-- Forecast labels: "Current" (not "Curr"), "Waves" (not "Wave"), no section header
-- Touch-through prevention: clickable/focusable on root layout
-- Reports section removed
-
-### Key files
-| File | Status |
-|---|---|
-| `layout_instruments_sheet.xml` | Partially done (Task 1 applied; full restructure in Task 5) |
-| `DirectionArrowView.kt` | Not created yet |
-| `WaveView.kt` | Not created yet |
-| `InstrumentHandler.kt` | Not refactored yet |
-| `MainActivity.kt` | Task 1 applied; Task 7 changes pending |
-| `themes.xml` / `dimens.xml` | Done |
+---
+
+## Recently Completed
+
+### Tack Detection Redesign — 2026-05-26
+Full rewrite of `TackDetector.kt` (both `android-app` and `test-runner` mirrors).
+
+**Root causes fixed:**
+- Point-count window (HALF_WIN=2) gave wildly different reliability at 1 Hz vs 0.2 Hz
+- Only 2 points used for heading estimate — one noisy fix contaminated the result
+- Single-pair spread check fired on individual noisy fixes
+- No protection against GPS COG noise at low/zero SOG
+
+**New algorithm (CRUISING mode — conservative):**
+- Time-based 30 s settle windows before/after each candidate apex, with a 30 s guard zone
+- `circularMAD` stability check: rejects any window where heading spread > 20°. Handles anchor noise (MAD ≈ 90°) without any SOG gate — the stability check is sufficient.
+- `circularMean` via unit-vector averaging — correct 0°/360° wrap
+- `MAX_DELTA` raised to 160° (covers deep jibes the old 140° missed)
+- `START_SKIP_MS` reduced to 60 s
+- De-duplication uses adjacent-candidate comparison (not group-start), so a long raw-candidate stream from one maneuver stays as one group even if it spans > MIN_GAP_MS
+- Position pin refined to max instantaneous heading rate within the maneuver zone
+
+**Key constants:** `T_SETTLE=30s, T_MANEUVER=30s, STAB_MAX=20°, MIN_DELTA=60°, MAX_DELTA=160°, MIN_GAP_MS=60s, START_SKIP_MS=60s`
+
+**Tests:** 16/16 passing at 1 Hz (FULL mode). Covers: straight course, port↔stbd tack, jibe, 0/360 wrap, below/above delta limits, noisy anchor (rejected), unstable before-window (rejected), gradual course change (rejected), two tacks with sufficient gap, two tacks within MIN_GAP_MS (deduplicated), cold-start suppression, too few points.
+
+### GPX / Storage — completed prior session
+- `AndroidManifest.xml`: fixed `application/octet-stream` VIEW filter (no `pathSuffix`); added full SEND filter set for all five MIME types
+- `TrackStorage.kt`: SAF primary path (`saveViaSaf`, `loadViaSaf`, `safState`, `initSafDirectory`) with MediaStore fallback; survives reinstall with one re-auth tap
+- `SavedTracksFragment.kt` + `fragment_saved_tracks.xml`: "Set up track storage" / "Restore track access" button observing `safState` flow
+- `MainActivity.kt`: `safPickerLauncher` + `launchSafPicker()` via `ACTION_OPEN_DOCUMENT_TREE`
+
+### Logbook + Trip Report Improvements — 2026-05-26
+- **Notes/photos on finished tracks** — `+ Add note` button in `TrackDetailSheet`; dialog with text, camera, gallery. Saved with `timestampMs = track.endMs` so they fall within track window and appear in `buildLogEvents()` automatically.
+- **Single Nav voice** — Removed `NarrativeStyle` enum and 4-style ChipGroup. `generateNarrative(summary)` emits a concise PASSAGE header (date/time range, stats, conditions, timestamped deck log).
+- **Speed coloring fixed** — Replaced broken `Expression.get("color")` (MapLibre Android 11.x silently fails for `lineColor` on `LineLayer`) with `Expression.step(Expression.get("speed"), ...)` mapping the numeric `"speed"` property to color literals.
---
-## Previous Task Goal
-Section 7.4 Trip Reporting & Enhanced Tracks — COMPLETE (2026-04-04)
-
-## Verified (2026-04-04)
-- Build Successful: `android-app/gradlew assembleDebug` passed.
-- UI Layout: `activity_main.xml` refactored to LinearLayout to prevent BottomNav overlap.
-- Track Differentiation: Active (solid) vs Past (dotted) tracks verified in `MapHandler.kt`.
-- Pre-Trip Logic: Sail suggestions reefing logic verified in `PreTripReportGenerator.kt`.
-
-## Completed Items
-
-### [APPROVED] Trip Narrative Generator + Multi-Style AI (2026-04-04)
-- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt`: Logic for distance, speed, and stylized narratives (Professional, Adventurous, Journal, Pirate).
-- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt`: State management for narrative generation.
-- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt`: UI for viewing and selecting narrative styles.
-
-### [APPROVED] Pre-Trip Planning & Sail Suggestions (2026-04-04)
-- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt`: Routing and reefing suggestions based on wind/waves.
-- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt`: Domain models for boat profiles and suggestions.
-- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt`: UI for generating and viewing pre-trip plans.
-
-### [APPROVED] Enhanced Map & Track Visualization (2026-04-04)
-- `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt`: `updateTrackLayer` now supports active and past tracks with distinct styles.
-- `android-app/app/src/main/res/layout/activity_main.xml`: Refactored to vertical LinearLayout root to stabilize BottomNav and FAB placement.
-- `android-app/app/src/main/res/layout/layout_instruments_sheet.xml`: Added "TRIP REPORTS & PLANNING" section for direct access from main view.
-- `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`: Wired Pre-Trip and Trip Report buttons from instrument sheet; added `showReport` navigation helper.
-- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`: Added `pastTracks` storage.
-- `test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`: Synchronized with main app implementation.
-
-### [APPROVED] GpsPosition data class (2026-03-15)
-- File: `app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt`
-- Fields: latitude, longitude, sog (knots), cog (degrees true), timestampMs
-
-### [APPROVED] DeviceGpsProvider (2026-03-15)
-- Wraps `LocationManager` with `GPS_PROVIDER`
-- Fix-lost timer: fires `onFixLost()` after 10s with no update
-
-### [APPROVED] NmeaParser — RMC parser (2026-03-15)
-- Parses any `*RMC` sentence (GP, GN, etc.)
-- HHMMSS + DDMMYY → Unix epoch millis UTC
-
-### [APPROVED] AIS Integration (2026-03-15)
-- `AisVdmParser.kt`: Parses !AIVDM/!AIVDO (Types 1, 2, 3, 5).
-- `AisRepository.kt`: Upserts vessels and merges static data.
-- `AisHubSource.kt`: Integration with AISHub REST API.
-- `MapFragment`: GeoJSON layer for AIS vessels with rotation and labels.
-
-### [APPROVED] Track Recording (2026-03-25)
-- `MainViewModel`: TrackRepository wired; `addGpsPoint` enriches with environmental data.
-- `MainActivity`: FAB for toggling recording; MapHandler for rendering.
-
-### [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
-
-### [APPROVED] Vessel Registry + Crew Management (2026-04-22)
-- `android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt` — data class: name, MMSI, callsign, flag, homePort, hullType, LOA, beam, draft, displacement, engineHp, notes, isOwnVessel
-- `android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt` — data class: name, role (SKIPPER/FIRST_MATE/WATCH_CAPTAIN/CREW/GUEST), emergencyContact, emergencyPhone, certifications, notes
-- `android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt` — in-memory CRUD for vessels and crew; addVessel, updateVessel, removeVessel, getOwnVessel, addCrewMember, updateCrewMember, removeCrewMember, getSkipper, getCrewByRole
-- `android-app/app/src/main/res/layout/fragment_vessel_registry.xml` — NestedScrollView with own vessel card, crew list, and known vessels list; + ADD buttons for crew and fleet
-- `android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt` — fragment with AlertDialog editors for vessels and crew; long-press to delete
-- `android-app/app/src/main/res/menu/bottom_nav_menu.xml` — added `nav_vessel` 5th tab with `ic_vessel` icon
-- `android-app/app/src/main/res/drawable/ic_vessel.xml` — sailboat silhouette vector icon
-- `MainViewModel.kt` — added `vesselRepository` property (public, for fragment access)
-- `MainActivity.kt` — `R.id.nav_vessel` case loads `VesselRegistryFragment`
-- Mirror classes in `test-runner/src/main/kotlin/org/terst/nav/vessel/`
-- 18 tests in `test-runner/src/test/kotlin/org/terst/nav/vessel/VesselRepositoryTest.kt` — all GREEN
-
-### [APPROVED] Thermal Alarm (2026-04-22)
-- `android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt` — `thermalFlow(context)` as Kotlin Flow using `Intent.ACTION_BATTERY_CHANGED` for battery temperature; `PowerManager.addThermalStatusListener` on API 29+ for supplemental state; emits distinct `ThermalReading` only on state change
-- `ThermalState` enum: OK (<40°C), WARM (40–45°C), HOT (45–50°C), CRITICAL (>50°C)
-- `MainViewModel.kt` — `thermalState: StateFlow<ThermalState>`, `thermalReading: StateFlow<ThermalReading?>`, `onThermalReading()` entry point
-- `MainActivity.kt` — `startThermalMonitoring()` called from `observeDataSources()`; HOT triggers Toast + alarm sound (reuses `R.raw.mob_alarm`); CRITICAL triggers urgent Toast + alarm; OK/WARM stops alarm; alarm released in `onDestroy()`
-
-## Next 3 Specific Steps
-1. **Unit Tests for Trip Generators** — Add JVM tests in `test-runner` for `TripReportGenerator` and `PreTripReportGenerator`.
-2. **Persist track to Room** — Replace in-memory `pastTracks` with a Room database for persistence across sessions.
-3. **AnchorWatchHandler UI** — Re-integrate `AnchorWatchHandler` UI into `activity_main.xml` and wire it to the Safety Dashboard.
-
-## Scripts Added
-- `test-runner/` — standalone Kotlin/JVM Gradle project for fast test cycles.
-
-## Process Improvements
-- Stabilized UI layout by moving navigation out of the `CoordinatorLayout`.
-- Duplicated core logic into `test-runner` to bypass Android SDK requirements for logic testing.
+## Infrastructure
+
+- **`test-runner/`** — standalone Kotlin/JVM Gradle project for logic tests without Android SDK. Run: `cd test-runner && ./gradlew test`
+- **Branch policy** — all work merges to `main`. Current feature branch: `claude/optimize-nav-location-tracking-Vac5Z`
+- **Build note** — `./gradlew assembleDebug` requires network access to Google plugin repo (unavailable in cloud sandbox). Use `test-runner` for logic tests; build verification requires a local dev environment.
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
index b0d256b..763e693 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
@@ -1,50 +1,130 @@
package org.terst.nav.track
import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+/**
+ * Detects tacks and jibes in a recorded GPS track.
+ *
+ * A tack or jibe has three phases:
+ * [── settled leg A (≥30 s) ──][─ guard (15 s) ─][apex][─ guard (15 s) ─][── settled leg B (≥30 s) ──]
+ *
+ * For each GPS fix as candidate apex: collect before/after settle windows (excluding the guard zone),
+ * require MIN_PTS fixes in each, compute circularMean + circularMAD, reject if MAD > STAB_MAX,
+ * accept if heading delta in [MIN_DELTA, MAX_DELTA].
+ *
+ * De-duplicate: group candidates within MIN_GAP_MS, keep max-delta per group.
+ * Refine position: within the maneuver zone, find the point with the greatest instantaneous
+ * heading rate of change as the map pin.
+ *
+ * No SOG gate: circularMAD stability check handles noise at any speed.
+ * A boat at anchor has effectively random COG (MAD ≈ 90°) and is always rejected.
+ * A real settled tack leg has MAD typically 5–15° and passes.
+ * Mode: CRUISING — conservative (prefers missing a tack over reporting a phantom).
+ * Race mode (shorter windows, wider MAD) is a future backlog item.
+ */
object TackDetector {
- private const val HALF_WIN = 2
- private const val MIN_DELTA = 60.0
- private const val MAX_DELTA = 140.0
- private const val STABILITY_MAX = 30.0
- private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks
- private const val START_SKIP_MS = 120_000L // skip first 2 min (GPS cold-start noise)
+ private const val T_SETTLE = 30_000L // ms — stable heading window required before/after
+ private const val T_MANEUVER = 30_000L // ms — guard zone around apex (±15 s each side)
+ private const val STAB_MAX = 20.0 // ° — max circularMAD in settle windows
+ private const val MIN_DELTA = 60.0 // ° — minimum heading change to count as tack/jibe
+ private const val MAX_DELTA = 160.0 // ° — maximum heading change (beyond = U-turn, not a tack)
+ private const val MIN_GAP_MS = 60_000L // ms — minimum time between accepted events
+ private const val START_SKIP_MS = 60_000L // ms — skip first 60 s (cold-start noise)
+ private const val MIN_PTS = 3 // minimum GPS fixes required in each settle window
+
+ private data class Candidate(
+ val index: Int,
+ val timestampMs: Long,
+ val delta: Double,
+ val cogBefore: Double,
+ val cogAfter: Double
+ )
fun detectTacks(points: List<TrackPoint>): List<TackEvent> {
- if (points.size < 2 * HALF_WIN + 1) return emptyList()
+ if (points.size < MIN_PTS) return emptyList()
+ val t0 = points.first().timestampMs
- val results = mutableListOf<TackEvent>()
- var lastTackMs: Long? = null
+ val raw = mutableListOf<Candidate>()
+
+ for (i in points.indices) {
+ val t = points[i].timestampMs
+ if (t - t0 < START_SKIP_MS) continue
+
+ val beforeEnd = t - T_MANEUVER / 2
+ val beforeStart = beforeEnd - T_SETTLE
+ val before = points.filter { it.timestampMs in beforeStart until beforeEnd }
+ if (before.size < MIN_PTS) continue
- for (i in HALF_WIN until points.size - HALF_WIN) {
- if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue
- if (lastTackMs != null && points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue
+ val afterStart = t + T_MANEUVER / 2
+ val afterEnd = afterStart + T_SETTLE
+ val after = points.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd }
+ if (after.size < MIN_PTS) continue
- val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg))
- val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg))
- if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue
+ val cogBefore = circularMean(before.map { it.cogDeg })
+ val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore)
+ if (spreadBefore > STAB_MAX) continue
+
+ val cogAfter = circularMean(after.map { it.cogDeg })
+ val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter)
+ if (spreadAfter > STAB_MAX) continue
- val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg)
- val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg)
val delta = abs(angleDiff(cogBefore, cogAfter))
+ if (delta < MIN_DELTA || delta > MAX_DELTA) continue
+
+ raw += Candidate(i, t, delta, cogBefore, cogAfter)
+ }
+ if (raw.isEmpty()) return emptyList()
- if (delta in MIN_DELTA..MAX_DELTA) {
- results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter)
- lastTackMs = points[i].timestampMs
+ // De-duplicate: if consecutive raw candidates are within MIN_GAP_MS of each other, they
+ // belong to the same maneuver. Keep the max-delta candidate per group.
+ // Compare against the PREVIOUS candidate (adjacent comparison) so a long stream of
+ // close candidates from one maneuver stays in a single group regardless of total span.
+ val results = mutableListOf<TackEvent>()
+ var best: Candidate? = null
+ var prevMs = Long.MIN_VALUE / 2
+
+ for (c in raw) {
+ if (best != null && c.timestampMs - prevMs >= MIN_GAP_MS) {
+ results += buildTackEvent(points, best!!)
+ best = c
+ } else {
+ if (best == null || c.delta > best!!.delta) best = c
}
+ prevMs = c.timestampMs
}
+ best?.let { results += buildTackEvent(points, it) }
return results
}
+ private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent {
+ // Refine map pin: find max instantaneous heading rate within maneuver zone
+ val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 2)
+ var bestIdx = c.index
+ var bestRate = 0.0
+ for (i in 1 until points.size) {
+ if (points[i].timestampMs !in maneuvRange) continue
+ val rate = abs(angleDiff(points[i - 1].cogDeg, points[i].cogDeg))
+ if (rate > bestRate) { bestRate = rate; bestIdx = i }
+ }
+ return TackEvent(bestIdx, points[bestIdx].lat, points[bestIdx].lon, c.cogBefore, c.cogAfter)
+ }
+
internal fun angleDiff(from: Double, to: Double): Double {
var diff = to - from
- while (diff > 180) diff -= 360
+ while (diff > 180) diff -= 360
while (diff < -180) diff += 360
return diff
}
- private fun circularMean(a: Double, b: Double): Double {
- val half = angleDiff(a, b) / 2.0
- return ((a + half) % 360.0 + 360.0) % 360.0
+ private fun circularMean(angles: List<Double>): Double {
+ val sinSum = angles.sumOf { sin(Math.toRadians(it)) }
+ val cosSum = angles.sumOf { cos(Math.toRadians(it)) }
+ return ((Math.toDegrees(atan2(sinSum, cosSum)) % 360.0) + 360.0) % 360.0
}
+
+ private fun circularMAD(angles: List<Double>, mean: Double): Double =
+ angles.map { abs(angleDiff(it, mean)) }.average()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt
index 292240a..5e5e85d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt
@@ -5,17 +5,9 @@ import org.maplibre.geojson.Feature
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
-private fun speedToHex(speedKt: Double): String = when {
- speedKt >= 13 -> "#F44336" // red
- speedKt >= 10 -> "#FFC107" // amber
- speedKt >= 7 -> "#4CAF50" // green
- speedKt >= 4 -> "#00BCD4" // cyan
- else -> "#2196F3" // blue
-}
-
/**
* Breaks [points] into consecutive 2-point LineString Features.
- * Each feature carries "speed" (knots) and "color" (CSS hex) properties.
+ * Each feature carries a numeric "speed" property (knots) for data-driven coloring.
*/
fun speedSegments(points: List<TrackPoint>): List<Feature> =
(0 until points.size - 1).map { i ->
@@ -27,12 +19,20 @@ fun speedSegments(points: List<TrackPoint>): List<Feature> =
))
).apply {
addNumberProperty("speed", sog)
- addStringProperty("color", speedToHex(sog))
}
}
/**
- * Returns the lineColor expression for speed-colored segments.
- * Reads the pre-computed "color" string property from each feature.
+ * Returns a step expression mapping the "speed" numeric property to a color.
+ * Uses Expression.step() which reads the numeric property directly, avoiding
+ * the silent failure of Expression.get("color") for string properties on LineLayer
+ * in MapLibre Android 11.x.
*/
-fun speedColorExpression(): Expression = Expression.get("color")
+fun speedColorExpression(): Expression = Expression.step(
+ Expression.get("speed"),
+ Expression.literal("#2196F3"), // < 4 kt: blue
+ Expression.stop(4, Expression.literal("#00BCD4")), // 4–7 kt: cyan
+ Expression.stop(7, Expression.literal("#4CAF50")), // 7–10 kt: green
+ Expression.stop(10, Expression.literal("#FFC107")), // 10–13 kt: amber
+ Expression.stop(13, Expression.literal("#F44336")) // 13+ kt: red
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
index 25e7b15..1d92e18 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
@@ -1,17 +1,31 @@
package org.terst.nav.track
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Intent
import android.graphics.BitmapFactory
+import android.net.Uri
import android.os.Bundle
+import android.provider.MediaStore
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Button
+import android.widget.EditText
+import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
@@ -28,8 +42,11 @@ import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.terst.nav.NavApplication
import org.terst.nav.R
+import org.terst.nav.logbook.EntryType
+import org.terst.nav.logbook.LogEntry
import org.terst.nav.ui.MainViewModel
import java.io.File
+import java.io.FileOutputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -53,6 +70,52 @@ class TrackDetailSheet : Fragment() {
private lateinit var trackMapView: MapView
private var trackMap: MapLibreMap? = null
+ // Note-adding state
+ private var pendingNotePhotoPath: String? = null
+ private var noteDialogPhotoFrame: FrameLayout? = null
+ private var noteDialogPhotoView: ImageView? = null
+ private var logEventAdapter: LogEventAdapter? = null
+ private var currentTrack: SavedTrack? = null
+
+ private val noteCameraLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val path = pendingNotePhotoPath ?: return@registerForActivityResult
+ val bm = BitmapFactory.decodeFile(path)
+ if (bm != null) noteDialogPhotoView?.setImageBitmap(bm)
+ else noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path)))
+ noteDialogPhotoFrame?.visibility = View.VISIBLE
+ } else {
+ pendingNotePhotoPath?.let { File(it).delete() }
+ pendingNotePhotoPath = null
+ }
+ }
+
+ private val noteGalleryLauncher = registerForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ lifecycleScope.launch {
+ val path = withContext(Dispatchers.IO) {
+ runCatching {
+ val dir = NavApplication.logbookRepository.photoDir
+ val dest = File(dir, "log_${System.currentTimeMillis()}.jpg")
+ requireContext().contentResolver.openInputStream(uri)?.use { inp ->
+ FileOutputStream(dest).use { out -> inp.copyTo(out) }
+ }
+ dest.absolutePath
+ }.getOrNull()
+ }
+ if (path != null) {
+ pendingNotePhotoPath = path
+ noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path)))
+ noteDialogPhotoFrame?.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+
companion object {
fun newInstance() = TrackDetailSheet()
}
@@ -65,6 +128,7 @@ class TrackDetailSheet : Fragment() {
parentFragmentManager.popBackStack()
return
}
+ currentTrack = track
view.findViewById<View>(R.id.btn_back).setOnClickListener {
parentFragmentManager.popBackStack()
@@ -88,9 +152,14 @@ class TrackDetailSheet : Fragment() {
val rv = view.findViewById<RecyclerView>(R.id.rv_log_entries)
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
- rv.adapter = LogEventAdapter(events) { event ->
+ logEventAdapter = LogEventAdapter(events) { event ->
trackMap?.easeCamera(CameraUpdateFactory.newLatLng(LatLng(event.lat, event.lon)), 300)
}
+ rv.adapter = logEventAdapter
+
+ view.findViewById<View>(R.id.btn_add_note).setOnClickListener {
+ showAddNoteDialog(track)
+ }
trackMapView = view.findViewById(R.id.track_map_view)
trackMapView.onCreate(savedInstanceState)
@@ -106,6 +175,67 @@ class TrackDetailSheet : Fragment() {
}
}
+ private fun showAddNoteDialog(track: SavedTrack) {
+ val dialogView = LayoutInflater.from(requireContext())
+ .inflate(R.layout.dialog_add_track_note, null)
+ val etNote = dialogView.findViewById<EditText>(R.id.et_note_text)
+ noteDialogPhotoFrame = dialogView.findViewById(R.id.frame_note_photo)
+ noteDialogPhotoView = dialogView.findViewById(R.id.iv_note_photo)
+ pendingNotePhotoPath = null
+ noteDialogPhotoFrame?.visibility = View.GONE
+
+ dialogView.findViewById<Button>(R.id.btn_note_camera).setOnClickListener {
+ val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg")
+ pendingNotePhotoPath = file.absolutePath
+ val uri = FileProvider.getUriForFile(
+ requireContext(), "${requireContext().packageName}.fileprovider", file
+ )
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
+ putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+ noteCameraLauncher.launch(intent)
+ }
+
+ dialogView.findViewById<Button>(R.id.btn_note_gallery).setOnClickListener {
+ noteGalleryLauncher.launch("image/*")
+ }
+
+ dialogView.findViewById<Button>(R.id.btn_note_remove_photo).setOnClickListener {
+ pendingNotePhotoPath?.let { File(it).delete() }
+ pendingNotePhotoPath = null
+ noteDialogPhotoView?.setImageDrawable(null)
+ noteDialogPhotoFrame?.visibility = View.GONE
+ }
+
+ AlertDialog.Builder(requireContext())
+ .setTitle("Add note to track")
+ .setView(dialogView)
+ .setPositiveButton("Save") { _, _ ->
+ val text = etNote.text?.toString()?.trim() ?: ""
+ val path = pendingNotePhotoPath
+ if (text.isBlank() && path == null) return@setPositiveButton
+ val anchor = track.points.lastOrNull()
+ NavApplication.logbookRepository.save(
+ LogEntry(
+ timestampMs = track.endMs,
+ text = text.ifBlank { "(photo only)" },
+ entryType = EntryType.GENERAL,
+ lat = anchor?.lat,
+ lon = anchor?.lon,
+ photoPath = path
+ )
+ )
+ pendingNotePhotoPath = null
+ logEventAdapter?.update(buildLogEvents(track))
+ }
+ .setNegativeButton("Cancel") { _, _ ->
+ pendingNotePhotoPath?.let { File(it).delete() }
+ pendingNotePhotoPath = null
+ }
+ .show()
+ }
+
private fun drawTrack(style: Style, points: List<TrackPoint>) {
if (points.size < 2) return
val source = GeoJsonSource("track-detail-source",
@@ -171,6 +301,8 @@ class TrackDetailSheet : Fragment() {
}
override fun onDestroyView() {
+ noteDialogPhotoFrame = null
+ noteDialogPhotoView = null
if (::trackMapView.isInitialized) trackMapView.onDestroy()
super.onDestroyView()
viewModel.clearSelectedTrack()
@@ -195,7 +327,7 @@ class TrackDetailSheet : Fragment() {
"%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta)))
}
- // Log entries saved during this track's time window
+ // Log entries saved during or associated with this track's time window
val logEntries = NavApplication.logbookRepository.getAll()
.filter { it.timestampMs in track.startMs..track.endMs }
for (entry in logEntries) {
@@ -229,10 +361,15 @@ private val TIME_FMT: DateTimeFormatter =
.withZone(ZoneId.systemDefault())
private class LogEventAdapter(
- private val events: List<LogEvent>,
+ private var events: List<LogEvent>,
private val onTap: (LogEvent) -> Unit
) : RecyclerView.Adapter<LogEventAdapter.VH>() {
+ fun update(newEvents: List<LogEvent>) {
+ events = newEvents
+ notifyDataSetChanged()
+ }
+
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvIcon = view.findViewById<TextView>(R.id.tv_log_icon)
val tvTime = view.findViewById<TextView>(R.id.tv_log_time)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
index e7a425f..632f616 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
@@ -9,7 +9,6 @@ import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
-import com.google.android.material.chip.ChipGroup
import kotlinx.coroutines.launch
import org.terst.nav.NavApplication
import org.terst.nav.R
@@ -18,14 +17,13 @@ class TripReportFragment : Fragment() {
private val viewModel by lazy {
TripReportViewModel(
- trackRepository = NavApplication.trackRepository,
+ trackRepository = NavApplication.trackRepository,
logbookRepository = NavApplication.logbookRepository
)
}
private lateinit var tvNarrativeContent: TextView
private lateinit var btnRefresh: MaterialButton
- private lateinit var chipGroup: ChipGroup
private lateinit var progress: ProgressBar
override fun onCreateView(
@@ -38,27 +36,15 @@ class TripReportFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
tvNarrativeContent = view.findViewById(R.id.tv_narrative_content)
- btnRefresh = view.findViewById(R.id.btn_refresh_report)
- chipGroup = view.findViewById(R.id.chip_group_styles)
- progress = view.findViewById(R.id.progress_report)
+ btnRefresh = view.findViewById(R.id.btn_refresh_report)
+ progress = view.findViewById(R.id.progress_report)
btnRefresh.setOnClickListener { viewModel.generateReport() }
- chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
- val style = when (checkedIds.firstOrNull()) {
- R.id.chip_adventurous -> NarrativeStyle.ADVENTUROUS
- R.id.chip_journal -> NarrativeStyle.JOURNAL
- R.id.chip_pirate -> NarrativeStyle.PIRATE
- else -> NarrativeStyle.PROFESSIONAL
- }
- viewModel.setStyle(style)
- }
-
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state -> renderState(state) }
}
- // Initial generation
viewModel.generateReport()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
index bbf00b1..2c7f77f 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
@@ -2,13 +2,10 @@ package org.terst.nav.tripreport
import org.terst.nav.logbook.LogEntry
import org.terst.nav.track.TrackPoint
-
-enum class NarrativeStyle {
- PROFESSIONAL,
- ADVENTUROUS,
- JOURNAL,
- PIRATE
-}
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
data class TripSummary(
val startTimeMs: Long,
@@ -32,12 +29,12 @@ class TripReportGenerator {
val startTime = points.first().timestampMs
val endTime = points.last().timestampMs
-
+
var totalDist = 0.0
for (i in 0 until points.size - 1) {
totalDist += calculateDistance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon)
}
- val distanceNm = totalDist / 1852.0 // meters to nautical miles
+ val distanceNm = totalDist / 1852.0
val maxSog = points.maxOf { it.sogKnots }
val avgSog = points.map { it.sogKnots }.average()
@@ -48,21 +45,21 @@ class TripReportGenerator {
val maxWave = points.mapNotNull { it.waveHeightM }.maxOrNull()
return TripSummary(
- startTimeMs = startTime,
- endTimeMs = endTime,
- distanceNm = distanceNm,
- maxSogKts = maxSog,
- avgSogKts = avgSog,
- minAirTempC = minTemp,
- maxAirTempC = maxTemp,
- maxWaveHeightM = maxWave,
- logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
- points = points
+ startTimeMs = startTime,
+ endTimeMs = endTime,
+ distanceNm = distanceNm,
+ maxSogKts = maxSog,
+ avgSogKts = avgSog,
+ minAirTempC = minTemp,
+ maxAirTempC = maxTemp,
+ maxWaveHeightM = maxWave,
+ logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
+ points = points
)
}
private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
- val r = 6371e3 // Earth radius in meters
+ val r = 6371e3
val phi1 = Math.toRadians(lat1)
val phi2 = Math.toRadians(lat2)
val deltaPhi = Math.toRadians(lat2 - lat1)
@@ -76,42 +73,51 @@ class TripReportGenerator {
return r * c
}
- fun generateNarrative(summary: TripSummary, style: NarrativeStyle): String {
- val durationHrs = (summary.endTimeMs - summary.startTimeMs) / 3600000.0
- val baseFactualString = "Trip from ${java.util.Date(summary.startTimeMs)} to ${java.util.Date(summary.endTimeMs)}. " +
- "Distance: %.1f nm. Max SOG: %.1f kts. Avg SOG: %.1f kts. ".format(summary.distanceNm, summary.maxSogKts, summary.avgSogKts) +
- (summary.maxWaveHeightM?.let { "Max waves: %.1fm. ".format(it) } ?: "") +
- "Events: ${summary.logEntries.joinToString { it.text }}"
-
- return when (style) {
- NarrativeStyle.PROFESSIONAL -> {
- "VOYAGE SUMMARY\n" +
- "Duration: %.1f hours\n".format(durationHrs) +
- "Total Distance: %.1f NM\n".format(summary.distanceNm) +
- "Vessel Performance: Avg Speed %.1f kts, Max Speed %.1f kts\n".format(summary.avgSogKts, summary.maxSogKts) +
- "Meteorological Data: " + (summary.maxWaveHeightM?.let { "Significant wave height reached %.1fm." .format(it)} ?: "No wave data recorded.") + "\n" +
- "Key Events:\n" + summary.logEntries.joinToString("\n") { "- ${it.text}" }
- }
- NarrativeStyle.ADVENTUROUS -> {
- "WHAT A TRIP! We covered %.1f nautical miles of open water.\n".format(summary.distanceNm) +
- "We hit a top speed of %.1f knots! ".format(summary.maxSogKts) +
- (summary.maxWaveHeightM?.let { "The sea was alive with waves up to %.1fm high! ".format(it) } ?: "") + "\n" +
- "During our journey, we logged some great moments:\n" +
- summary.logEntries.joinToString("\n") { "🔥 ${it.text}" }
- }
- NarrativeStyle.JOURNAL -> {
- "Reflecting on our time at sea. We traveled %.1f miles over %.1f hours.\n".format(summary.distanceNm, durationHrs) +
- "The average pace was steady at %.1f knots. ".format(summary.avgSogKts) +
- "I remember writing down: " + summary.logEntries.firstOrNull()?.text + "... " +
- "It was a meaningful passage."
+ fun generateNarrative(summary: TripSummary): String {
+ val zone = ZoneId.systemDefault()
+ val dateFmt = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).withZone(zone)
+ val timeFmt = DateTimeFormatter.ofPattern("HHmm", Locale.getDefault()).withZone(zone)
+ val startInst = Instant.ofEpochMilli(summary.startTimeMs)
+ val endInst = Instant.ofEpochMilli(summary.endTimeMs)
+
+ val durationMs = summary.endTimeMs - summary.startTimeMs
+ val durationStr = if (durationMs >= 3_600_000) {
+ val h = durationMs / 3_600_000
+ val m = (durationMs % 3_600_000) / 60_000
+ "${h}h ${m}m"
+ } else {
+ "${durationMs / 60_000}m"
+ }
+
+ val sb = StringBuilder()
+ sb.appendLine("PASSAGE · ${dateFmt.format(startInst).uppercase(Locale.getDefault())}")
+ sb.appendLine("${timeFmt.format(startInst)}–${timeFmt.format(endInst)} · $durationStr")
+ sb.appendLine()
+ sb.appendLine("%.1f nm avg %.1f kt max %.1f kt".format(
+ summary.distanceNm, summary.avgSogKts, summary.maxSogKts))
+
+ val conditions = buildList<String> {
+ summary.maxWaveHeightM?.let { add("seas %.1f m".format(it)) }
+ val minT = summary.minAirTempC
+ val maxT = summary.maxAirTempC
+ if (minT != null && maxT != null) {
+ add(if (minT == maxT) "%.0f°C".format(minT) else "%.0f–%.0f°C".format(minT, maxT))
}
- NarrativeStyle.PIRATE -> {
- "AHOY! We've sailed %.1f leagues (well, nautical miles) across the briney deep!\n".format(summary.distanceNm) +
- "The wind caught our sails and we flew at %.1f knots!\n".format(summary.maxSogKts) +
- "Listen to the tales from the log:\n" +
- summary.logEntries.joinToString("\n") { "🏴‍☠️ ${it.text}" } + "\n" +
- "Arr, it was a fine voyage indeed!"
+ }
+ if (conditions.isNotEmpty()) {
+ sb.appendLine()
+ sb.appendLine(conditions.joinToString(" "))
+ }
+
+ if (summary.logEntries.isNotEmpty()) {
+ sb.appendLine()
+ for (entry in summary.logEntries.sortedBy { it.timestampMs }) {
+ val time = timeFmt.format(Instant.ofEpochMilli(entry.timestampMs))
+ val photoMarker = if (entry.photoPath != null) " [photo]" else ""
+ sb.appendLine("$time ${entry.text}$photoMarker")
}
}
+
+ return sb.toString().trimEnd()
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
index 45cbed5..603d769 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -25,14 +25,6 @@ class TripReportViewModel(
private val _state = MutableStateFlow<TripReportState>(TripReportState.Idle)
val state: StateFlow<TripReportState> = _state.asStateFlow()
- private val _selectedStyle = MutableStateFlow(NarrativeStyle.PROFESSIONAL)
- val selectedStyle: StateFlow<NarrativeStyle> = _selectedStyle.asStateFlow()
-
- fun setStyle(style: NarrativeStyle) {
- _selectedStyle.value = style
- generateReport()
- }
-
fun generateReport() {
viewModelScope.launch {
_state.value = TripReportState.Loading
@@ -43,8 +35,8 @@ class TripReportViewModel(
return@launch
}
val logEntries = logbookRepository.getAll()
- val summary = generator.generateSummary(points, logEntries)
- val narrative = generator.generateNarrative(summary, _selectedStyle.value)
+ val summary = generator.generateSummary(points, logEntries)
+ val narrative = generator.generateNarrative(summary)
_state.value = TripReportState.Success(summary, narrative)
} catch (e: Exception) {
_state.value = TripReportState.Error(e.message ?: "Unknown error generating report")
diff --git a/android-app/app/src/main/res/layout/dialog_add_track_note.xml b/android-app/app/src/main/res/layout/dialog_add_track_note.xml
new file mode 100644
index 0000000..639a27a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/dialog_add_track_note.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <EditText
+ android:id="@+id/et_note_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="Note"
+ android:minLines="3"
+ android:gravity="top|start"
+ android:inputType="textMultiLine"
+ android:padding="8dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="12dp">
+
+ <Button
+ android:id="@+id/btn_note_camera"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Camera"
+ android:layout_marginEnd="4dp" />
+
+ <Button
+ android:id="@+id/btn_note_gallery"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Gallery"
+ android:layout_marginStart="4dp" />
+
+ </LinearLayout>
+
+ <FrameLayout
+ android:id="@+id/frame_note_photo"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:visibility="gone">
+
+ <ImageView
+ android:id="@+id/iv_note_photo"
+ android:layout_width="match_parent"
+ android:layout_height="120dp"
+ android:scaleType="centerCrop"
+ android:contentDescription="Selected photo" />
+
+ <Button
+ android:id="@+id/btn_note_remove_photo"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|end"
+ android:layout_margin="4dp"
+ android:text="✕"
+ android:textSize="10sp"
+ android:paddingHorizontal="6dp"
+ android:paddingVertical="2dp" />
+
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_trip_report.xml b/android-app/app/src/main/res/layout/fragment_trip_report.xml
index 1ce0bde..7228fa9 100644
--- a/android-app/app/src/main/res/layout/fragment_trip_report.xml
+++ b/android-app/app/src/main/res/layout/fragment_trip_report.xml
@@ -14,61 +14,10 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Trip Narrative"
+ android:text="Trip Report"
android:textSize="24sp"
android:textStyle="bold"
- android:layout_marginBottom="16dp" />
-
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Choose Narrative Style:"
- android:textSize="14sp"
- android:layout_marginBottom="8dp" />
-
- <HorizontalScrollView
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="24dp">
-
- <com.google.android.material.chip.ChipGroup
- android:id="@+id/chip_group_styles"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:singleSelection="true"
- app:selectionRequired="true">
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_professional"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Professional"
- android:checked="true" />
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_adventurous"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Adventurous" />
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_journal"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Journal" />
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_pirate"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Pirate" />
-
- </com.google.android.material.chip.ChipGroup>
- </HorizontalScrollView>
+ android:layout_marginBottom="24dp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
@@ -88,8 +37,9 @@
android:id="@+id/tv_narrative_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="Generate a report to see your story..."
- android:textSize="16sp"
+ android:text="Generate a report to see your passage summary…"
+ android:textSize="15sp"
+ android:fontFamily="monospace"
android:lineSpacingExtra="4dp" />
</LinearLayout>
@@ -100,7 +50,7 @@
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginTop="24dp"
- android:text="REFRESH REPORT" />
+ android:text="GENERATE REPORT" />
<ProgressBar
android:id="@+id/progress_report"
diff --git a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
index 993edf8..4a77a36 100644
--- a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
@@ -97,17 +97,36 @@
<View android:layout_width="match_parent" android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" />
- <TextView
+ <LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:paddingHorizontal="20dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingStart="20dp"
+ android:paddingEnd="8dp"
android:paddingTop="8dp"
- android:paddingBottom="4dp"
- android:text="Event Log"
- android:textSize="13sp"
- android:textAllCaps="true"
- android:letterSpacing="0.08"
- android:textColor="?attr/colorOnSurfaceVariant" />
+ android:paddingBottom="4dp">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Event Log"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <Button
+ android:id="@+id/btn_add_note"
+ style="?attr/borderlessButtonStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+ Add note"
+ android:textSize="12sp"
+ android:paddingHorizontal="8dp" />
+
+ </LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_log_entries"
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt
index eea3234..8209d1a 100644
--- a/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt
+++ b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt
@@ -14,8 +14,8 @@ class TripReportGeneratorTest {
private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long) =
TrackPoint(lat = lat, lon = lon, sogKnots = sog, cogDeg = 0.0, timestampMs = ts)
- private fun entry(ts: Long, text: String = "note") = LogEntry(
- timestampMs = ts, text = text, entryType = EntryType.GENERAL
+ private fun entry(ts: Long, text: String = "note", photoPath: String? = null) = LogEntry(
+ timestampMs = ts, text = text, entryType = EntryType.GENERAL, photoPath = photoPath
)
// ── generateSummary ───────────────────────────────────────────────────────
@@ -30,7 +30,6 @@ class TripReportGeneratorTest {
@Test
fun `distance between two points one nm apart`() {
- // 1 nm north along prime meridian ≈ 0.01667° latitude
val points = listOf(pt(0.0, 0.0, ts = 0L), pt(0.016667, 0.0, ts = 60_000L))
val s = gen.generateSummary(points, emptyList())
assertEquals(1.0, s.distanceNm, 0.02)
@@ -70,20 +69,29 @@ class TripReportGeneratorTest {
// ── generateNarrative ─────────────────────────────────────────────────────
@Test
- fun `professional narrative contains distance and speed`() {
+ fun `narrative contains passage header and distance`() {
val points = listOf(pt(0.0, 0.0, sog = 5.0, ts = 0L), pt(0.016667, 0.0, sog = 5.0, ts = 3_600_000L))
val s = gen.generateSummary(points, emptyList())
- val narrative = gen.generateNarrative(s, NarrativeStyle.PROFESSIONAL)
- assertTrue("Expected distance in narrative", narrative.contains("NM") || narrative.contains("nm"))
+ val narrative = gen.generateNarrative(s)
+ assertTrue("Expected PASSAGE header", narrative.contains("PASSAGE"))
+ assertTrue("Expected distance in nm", narrative.contains("nm"))
+ assertTrue("Expected avg speed", narrative.contains("avg"))
+ assertTrue("Expected max speed", narrative.contains("max"))
}
@Test
- fun `all narrative styles produce non-empty output`() {
+ fun `narrative includes log entry text`() {
val points = listOf(pt(0.0, 0.0, ts = 0L), pt(0.016667, 0.0, ts = 3_600_000L))
- val s = gen.generateSummary(points, listOf(entry(1_000L)))
- for (style in NarrativeStyle.values()) {
- val narrative = gen.generateNarrative(s, style)
- assertTrue("Narrative for $style should not be blank", narrative.isNotBlank())
- }
+ val s = gen.generateSummary(points, listOf(entry(1_000L, "hoisted main")))
+ val narrative = gen.generateNarrative(s)
+ assertTrue("Expected log entry in narrative", narrative.contains("hoisted main"))
+ }
+
+ @Test
+ fun `narrative marks photo entries`() {
+ val points = listOf(pt(0.0, 0.0, ts = 0L), pt(0.016667, 0.0, ts = 3_600_000L))
+ val s = gen.generateSummary(points, listOf(entry(1_000L, "crew photo", photoPath = "/some/path.jpg")))
+ val narrative = gen.generateNarrative(s)
+ assertTrue("Expected [photo] marker", narrative.contains("[photo]"))
}
}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt
index b0d256b..763e693 100644
--- a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt
@@ -1,50 +1,130 @@
package org.terst.nav.track
import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+/**
+ * Detects tacks and jibes in a recorded GPS track.
+ *
+ * A tack or jibe has three phases:
+ * [── settled leg A (≥30 s) ──][─ guard (15 s) ─][apex][─ guard (15 s) ─][── settled leg B (≥30 s) ──]
+ *
+ * For each GPS fix as candidate apex: collect before/after settle windows (excluding the guard zone),
+ * require MIN_PTS fixes in each, compute circularMean + circularMAD, reject if MAD > STAB_MAX,
+ * accept if heading delta in [MIN_DELTA, MAX_DELTA].
+ *
+ * De-duplicate: group candidates within MIN_GAP_MS, keep max-delta per group.
+ * Refine position: within the maneuver zone, find the point with the greatest instantaneous
+ * heading rate of change as the map pin.
+ *
+ * No SOG gate: circularMAD stability check handles noise at any speed.
+ * A boat at anchor has effectively random COG (MAD ≈ 90°) and is always rejected.
+ * A real settled tack leg has MAD typically 5–15° and passes.
+ * Mode: CRUISING — conservative (prefers missing a tack over reporting a phantom).
+ * Race mode (shorter windows, wider MAD) is a future backlog item.
+ */
object TackDetector {
- private const val HALF_WIN = 2
- private const val MIN_DELTA = 60.0
- private const val MAX_DELTA = 140.0
- private const val STABILITY_MAX = 30.0
- private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks
- private const val START_SKIP_MS = 120_000L // skip first 2 min (GPS cold-start noise)
+ private const val T_SETTLE = 30_000L // ms — stable heading window required before/after
+ private const val T_MANEUVER = 30_000L // ms — guard zone around apex (±15 s each side)
+ private const val STAB_MAX = 20.0 // ° — max circularMAD in settle windows
+ private const val MIN_DELTA = 60.0 // ° — minimum heading change to count as tack/jibe
+ private const val MAX_DELTA = 160.0 // ° — maximum heading change (beyond = U-turn, not a tack)
+ private const val MIN_GAP_MS = 60_000L // ms — minimum time between accepted events
+ private const val START_SKIP_MS = 60_000L // ms — skip first 60 s (cold-start noise)
+ private const val MIN_PTS = 3 // minimum GPS fixes required in each settle window
+
+ private data class Candidate(
+ val index: Int,
+ val timestampMs: Long,
+ val delta: Double,
+ val cogBefore: Double,
+ val cogAfter: Double
+ )
fun detectTacks(points: List<TrackPoint>): List<TackEvent> {
- if (points.size < 2 * HALF_WIN + 1) return emptyList()
+ if (points.size < MIN_PTS) return emptyList()
+ val t0 = points.first().timestampMs
- val results = mutableListOf<TackEvent>()
- var lastTackMs: Long? = null
+ val raw = mutableListOf<Candidate>()
+
+ for (i in points.indices) {
+ val t = points[i].timestampMs
+ if (t - t0 < START_SKIP_MS) continue
+
+ val beforeEnd = t - T_MANEUVER / 2
+ val beforeStart = beforeEnd - T_SETTLE
+ val before = points.filter { it.timestampMs in beforeStart until beforeEnd }
+ if (before.size < MIN_PTS) continue
- for (i in HALF_WIN until points.size - HALF_WIN) {
- if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue
- if (lastTackMs != null && points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue
+ val afterStart = t + T_MANEUVER / 2
+ val afterEnd = afterStart + T_SETTLE
+ val after = points.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd }
+ if (after.size < MIN_PTS) continue
- val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg))
- val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg))
- if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue
+ val cogBefore = circularMean(before.map { it.cogDeg })
+ val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore)
+ if (spreadBefore > STAB_MAX) continue
+
+ val cogAfter = circularMean(after.map { it.cogDeg })
+ val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter)
+ if (spreadAfter > STAB_MAX) continue
- val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg)
- val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg)
val delta = abs(angleDiff(cogBefore, cogAfter))
+ if (delta < MIN_DELTA || delta > MAX_DELTA) continue
+
+ raw += Candidate(i, t, delta, cogBefore, cogAfter)
+ }
+ if (raw.isEmpty()) return emptyList()
- if (delta in MIN_DELTA..MAX_DELTA) {
- results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter)
- lastTackMs = points[i].timestampMs
+ // De-duplicate: if consecutive raw candidates are within MIN_GAP_MS of each other, they
+ // belong to the same maneuver. Keep the max-delta candidate per group.
+ // Compare against the PREVIOUS candidate (adjacent comparison) so a long stream of
+ // close candidates from one maneuver stays in a single group regardless of total span.
+ val results = mutableListOf<TackEvent>()
+ var best: Candidate? = null
+ var prevMs = Long.MIN_VALUE / 2
+
+ for (c in raw) {
+ if (best != null && c.timestampMs - prevMs >= MIN_GAP_MS) {
+ results += buildTackEvent(points, best!!)
+ best = c
+ } else {
+ if (best == null || c.delta > best!!.delta) best = c
}
+ prevMs = c.timestampMs
}
+ best?.let { results += buildTackEvent(points, it) }
return results
}
+ private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent {
+ // Refine map pin: find max instantaneous heading rate within maneuver zone
+ val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 2)
+ var bestIdx = c.index
+ var bestRate = 0.0
+ for (i in 1 until points.size) {
+ if (points[i].timestampMs !in maneuvRange) continue
+ val rate = abs(angleDiff(points[i - 1].cogDeg, points[i].cogDeg))
+ if (rate > bestRate) { bestRate = rate; bestIdx = i }
+ }
+ return TackEvent(bestIdx, points[bestIdx].lat, points[bestIdx].lon, c.cogBefore, c.cogAfter)
+ }
+
internal fun angleDiff(from: Double, to: Double): Double {
var diff = to - from
- while (diff > 180) diff -= 360
+ while (diff > 180) diff -= 360
while (diff < -180) diff += 360
return diff
}
- private fun circularMean(a: Double, b: Double): Double {
- val half = angleDiff(a, b) / 2.0
- return ((a + half) % 360.0 + 360.0) % 360.0
+ private fun circularMean(angles: List<Double>): Double {
+ val sinSum = angles.sumOf { sin(Math.toRadians(it)) }
+ val cosSum = angles.sumOf { cos(Math.toRadians(it)) }
+ return ((Math.toDegrees(atan2(sinSum, cosSum)) % 360.0) + 360.0) % 360.0
}
+
+ private fun circularMAD(angles: List<Double>, mean: Double): Double =
+ angles.map { abs(angleDiff(it, mean)) }.average()
}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt
index 540683d..6d946a0 100644
--- a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt
+++ b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt
@@ -5,28 +5,26 @@ import org.junit.Test
class TackDetectorTest {
- // Build a track that's well past the 2-minute cold-start skip.
- // Use 30s spacing: 6 warmup points (3 min) + tack points.
- private val STEP_MS = 30_000L // 30 s between points
-
- private fun warmupPoints(cog: Double, count: Int = 5): List<TrackPoint> =
+ // 1 Hz GPS (FULL mode). Time-based windows:
+ // T_SETTLE=30s, T_MANEUVER=30s, START_SKIP=60s.
+ // Minimum for a candidate to fire: START_SKIP(60) + T_SETTLE(30) + T_MANEUVER/2(15) = 105 s from t0.
+ // To also have a 30s after-window: apex at ~105s, after-window ends at 105+15+30=150s.
+ // So a safe warmup leg is 120 pts (t=0..119s), tack apex around t=120s, after-leg ≥ 60 pts.
+ private val STEP_MS = 1_000L // 1 s between points
+
+ private fun leg(cog: Double, count: Int, startMs: Long, sogKnots: Double = 5.0): List<TrackPoint> =
(0 until count).map { i ->
- TrackPoint(37.0 + i * 0.005, -122.0, 5.0, cog, 12.0, 0.0, true, i * STEP_MS)
+ TrackPoint(37.0 + (startMs / 1000 + i) * 0.00001, -122.0, sogKnots, cog, 12.0, 0.0, true,
+ startMs + i * STEP_MS)
}
- private fun tackTrack(cogBefore: Double, cogAfter: Double, eachLeg: Int = 5): List<TrackPoint> {
- val warmup = warmupPoints(cogBefore) // 5 pts × 30s = 2.5 min, tack at ~2.5+ min
- val after = (0 until eachLeg).map { i ->
- TrackPoint(37.0 + (warmup.size + i) * 0.005, -122.0, 5.0, cogAfter, 12.0, 0.0, true,
- (warmup.size + i) * STEP_MS)
- }
- return warmup + after
- }
+ // Standard two-leg track: 120 s warmup on cogBefore, 120 s on cogAfter.
+ // Tack apex candidates fire around t≈120 s, well past START_SKIP=60s.
+ private fun tackTrack(cogBefore: Double, cogAfter: Double): List<TrackPoint> =
+ leg(cogBefore, 120, 0L) + leg(cogAfter, 120, 120_000L)
@Test fun `straight course yields no tacks`() {
- val pts = warmupPoints(90.0) + (5 until 15).map { i ->
- TrackPoint(37.0 + i * 0.01, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS)
- }
+ val pts = leg(90.0, 300, 0L)
assertTrue(TackDetector.detectTacks(pts).isEmpty())
}
@@ -42,51 +40,40 @@ class TackDetectorTest {
}
@Test fun `detects jibe on broad reach`() {
- assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 250.0)).size)
+ // 150° → 290°: delta = 140°, within [60°, 160°]
+ assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 290.0)).size)
}
@Test fun `handles 0-360 wrap`() {
assertEquals(1, TackDetector.detectTacks(tackTrack(350.0, 80.0)).size)
}
- @Test fun `gradual course change is not a tack`() {
- val pts = (0 until 30).map { i ->
- TrackPoint(37.0 + i * 0.01, -122.0, 5.0, i * 5.0, 12.0, 0.0, true, i * STEP_MS)
- }
- assertTrue(TackDetector.detectTacks(pts).isEmpty())
- }
-
- @Test fun `small heading change below threshold is ignored`() {
+ @Test fun `heading change below MIN_DELTA is ignored`() {
+ // 45° delta — below MIN_DELTA=60°
assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty())
}
- @Test fun `very large heading change above threshold is ignored`() {
- assertTrue(TackDetector.detectTacks(tackTrack(90.0, 250.0)).isEmpty())
+ @Test fun `heading change above MAX_DELTA is ignored`() {
+ // 170° delta — above MAX_DELTA=160° (U-turn, not a tack)
+ assertTrue(TackDetector.detectTacks(tackTrack(90.0, 260.0)).isEmpty())
}
- @Test fun `detects two tacks in sequence`() {
- // 5 pts warmup + 5 pts leg2 + 5 pts leg3; gaps of 5*30s=150s > MIN_GAP_MS=45s
- val warmup = warmupPoints(330.0)
- val leg2 = (5 until 10).map { i ->
- TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS)
- }
- val leg3 = (10 until 15).map { i ->
- TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 330.0, 12.0, 0.0, true, i * STEP_MS)
- }
- assertEquals(2, TackDetector.detectTacks(warmup + leg2 + leg3).size)
+ @Test fun `detects two tacks in sequence with sufficient gap`() {
+ // leg1: 120s, leg2: 180s (> MIN_GAP_MS=60s), leg3: 120s
+ val leg1 = leg(330.0, 120, 0L)
+ val leg2 = leg(70.0, 180, 120_000L)
+ val leg3 = leg(330.0, 120, 300_000L)
+ val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3)
+ assertEquals(2, tacks.size)
}
- @Test fun `deduplicates tacks within MIN_GAP_MS`() {
- // Both tack candidates at i=5 and i=6 are within 30s of each other — only 1 should register
- val warmup = warmupPoints(330.0)
- val after = (5 until 15).map { i ->
- TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS)
- }
- val tacks = TackDetector.detectTacks(warmup + after)
+ @Test fun `deduplicates adjacent candidates into single tack`() {
+ // Single abrupt tack fires many consecutive raw candidates; must collapse to 1
+ val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0))
assertEquals(1, tacks.size)
}
- @Test fun `tack event contains correct position`() {
+ @Test fun `tack event contains plausible position`() {
val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0))
assertEquals(1, tacks.size)
assertTrue(tacks[0].lat in 37.0..37.1)
@@ -94,20 +81,47 @@ class TackDetectorTest {
}
@Test fun `too few points returns empty`() {
- val pts = (0 until 3).map { i ->
- TrackPoint(37.0, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS)
+ val pts = leg(90.0, 2, 0L)
+ assertTrue(TackDetector.detectTacks(pts).isEmpty())
+ }
+
+ @Test fun `cold start suppresses tack in first 60 seconds`() {
+ // Tack apex at t=30s — inside START_SKIP_MS=60s → filtered
+ val pts = leg(330.0, 40, 0L) + leg(70.0, 60, 40_000L)
+ assertEquals(0, TackDetector.detectTacks(pts).size)
+ }
+
+ @Test fun `noisy anchor COG does not produce false tack`() {
+ // Simulate boat at anchor: COG rotates 0..360 uniformly (MAD ≈ 90°, fails STAB_MAX=20°)
+ val pts = (0 until 300).map { i ->
+ TrackPoint(37.0, -122.0, 0.1, (i * 37.0) % 360.0, 12.0, 0.0, true, i * STEP_MS)
}
assertTrue(TackDetector.detectTacks(pts).isEmpty())
}
- @Test fun `cold start filter suppresses tacks in first 2 minutes`() {
- // Tack happens at t=60s from track start → filtered by START_SKIP_MS=120_000
- val start = 0L
- val pts = (0 until 6).map { i ->
- TrackPoint(37.0, -122.0, 5.0, 330.0, 12.0, 0.0, true, start + i * 10_000L)
- } + (0 until 6).map { i ->
- TrackPoint(37.0, -122.0, 5.0, 70.0, 12.0, 0.0, true, start + (6 + i) * 10_000L)
+ @Test fun `unstable before-window prevents false detection`() {
+ // Before-window has scattered headings (std dev >> STAB_MAX), after-window is stable
+ val noisyBefore = (0 until 120).map { i ->
+ TrackPoint(37.0, -122.0, 3.0, (i * 47.0) % 360.0, 12.0, 0.0, true, i * STEP_MS)
}
- assertEquals(0, TackDetector.detectTacks(pts).size)
+ val stableAfter = leg(70.0, 120, 120_000L)
+ assertTrue(TackDetector.detectTacks(noisyBefore + stableAfter).isEmpty())
+ }
+
+ @Test fun `gradual 5-degree-per-second course change is not a tack`() {
+ // Course changes 5°/s for 60 s = 300° total change but never a sudden tack
+ val pts = (0 until 300).map { i ->
+ TrackPoint(37.0 + i * 0.0001, -122.0, 5.0, (i * 5.0) % 360.0, 12.0, 0.0, true, i * STEP_MS)
+ }
+ assertTrue(TackDetector.detectTacks(pts).isEmpty())
+ }
+
+ @Test fun `two tacks closer than MIN_GAP_MS produces only one event`() {
+ // leg1=120s, leg2=45s (< MIN_GAP_MS=60s gap to leg3 candidates), leg3=120s
+ val leg1 = leg(330.0, 120, 0L)
+ val leg2 = leg(70.0, 45, 120_000L)
+ val leg3 = leg(330.0, 120, 165_000L)
+ val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3)
+ assertEquals(1, tacks.size)
}
}