Skip to content

13 ‐ Underwater and Behavioral Modes

gfo974 edited this page May 27, 2026 · 1 revision

13 — Underwater & Behavioral Modes (SWS, Cooldown, HAULED)

Combined reference for marine deployments: underwater detection (Saltwater Switch / SWS), surface-cycle cooldown, and HAULED-mode auto-detection. Replaces former pages 11 (UW), 13 (SWS), and 21 (HAULED).

Source files: core/services/sws_analog_service.cpp/hpp, core/services/uwdetector_service.hpp, core/services/service.cpp, core/services/hauled_mode_service.cpp/hpp.


Table of contents


Part A — Underwater detection overview

When UNDERWATER_EN=1 (UNP01), the tracker detects whether the animal is submerged. Satellite TX is impossible underwater — every service is suspended on dive and resumed on surface.

                 SURFACE                          UNDERWATER
            +-----------------+               +-----------------+
            |  GNSS active    |               |  GNSS suspended |
            |  Argos TX OK    |  SWS wet      |  Argos TX OFF   |
            |  Sensors active | ------------> |  Sensors active |
            |                 |               |  (reduced rate) |
            |                 |  SWS dry      |                 |
            |                 | <------------ |                 |
            +-----------------+               +-----------------+
                    |                                  |
                    v                                  v
              If cooldown expired:               deschedule() all
              --> reschedule Argos TX            Argos & GNSS tasks
              If cooldown active:
              --> skip (save battery)

A.1 How it works

  1. UW Detector Service samples the SWS electrode at SAMPLING_SURF_FREQ (surface) or SAMPLING_UNDER_FREQ (underwater).
  2. On confirmed state change, the scheduler is notified via notify_underwater_state().
  3. Submerged: Argos TX + GNSS descheduled (no satellite communication possible).
  4. Surfaced: after DRY_TIME_BEFORE_TX seconds, Argos TX rescheduled and GNSS allowed to acquire.

A.2 Scheduler integration

When the UW detector confirms a state change, services react as follows:

ArgosSchedulernotify_underwater_state():

  • Submerged: deschedule() cancels all pending Argos TX
  • Surfaced: m_earliest_tx = now + dry_time_before_tx, then reschedule()

GPSScheduler:

  • Submerged: cancels ongoing acquisition, suspends scheduling
  • Surfaced: resumes acquisition

A.3 Detection source

Only SWS analog is supported. Previous sources (pressure, GNSS, SWS+GNSS) have been removed. The UNDERWATER_DETECT_SOURCE parameter (UNP10) has been retired — detection is enabled via UNDERWATER_EN (UNP01) alone.

A.4 Interaction with battery modes

  • Normal: underwater suspends both GNSS and Argos TX
  • Low Battery: underwater suspends Doppler TX
  • Critical: device is off regardless

Part B — SWS algorithm

The SWS analog algorithm (SWSAnalogService) is the sole underwater detection path. Uses RC time-constant discrimination to read conductivity between two electrodes.

B.1 Hardware — RC discrimination

A 100 nF capacitor at the ADC pin charges through the medium's resistance when the SWS electrode is enabled. A 1 MΩ pull-down discharges it between reads.

     VDD
      |
   [Electrode TX] --SWS_ENABLE_PIN--> [salt water] --> [Electrode RX]
                                                            |
                                                         100 nF cap
                                                            |
                                                         ADC 14-bit
                                                            |
                                                         1 MΩ pull-down
                                                            |
                                                           GND

Time constants (τ = RC):

Medium Resistance τ @1 ms charge @2 ms
Seawater ~10 kΩ 1 ms 63 % 86 %
Wet film ~50 kΩ 5 ms 18 % 33 %
Light biofouling ~100 kΩ 10 ms 10 % 18 %
Air 0 % 0 %

The sample delay is adaptive (100–5000 µs, default 1 ms) and adjusts based on electrode contrast. Shorter delay improves water/film discrimination when biofouling narrows the gap.

Between samples (≥ 1 s), the 1 MΩ pull-down fully discharges the cap (5 × τ = 500 ms < 1000 ms).

B.2 The two core problems

1. Biofouling. After months at sea, salt/biofilm accumulates:

Month 0  (clean)    : Air ~200,   Water ~3000  -> contrast 15x
Month 6  (moderate) : Air ~1000,  Water ~2500  -> contrast 2.5x
Month 12 (severe)   : Air ~1700,  Water ~2100  -> contrast 1.2x

A fixed threshold inevitably fails. The algorithm adapts dynamically.

2. Wet electrode after exit. A wet electrode reads like submerged water without RC discrimination. The adaptive delay distinguishes them.

B.3 Architecture

UWDetectorService (parent)
        |
        v
SWSAnalogService (child)

Files: core/services/uwdetector_service.hpp/cpp (scheduling), core/services/sws_analog_service.hpp/cpp (algorithm).

Batch operation: service_next_schedule_in_ms() returns SAMPLING_SURF_FREQ or SAMPLING_UNDER_FREQ. service_initiate() calls detector_state() UW_MAX_SAMPLES times spaced by UW_SAMPLE_GAP ms. With UW_MAX_SAMPLES=1 (default), each cycle is a single call.

B.4 Detection algorithm — detector_state() flow

 1. READ ADC                  raw = read_analog_sws() (14-bit)
 2. FIRST-SAMPLE COHERENCE    rebuild cal if stored ≠ first reading
 3. FILTERING                 MA2 filter
 4. TIME TRACKING             update time_in_state, check max-dive timeout
 5. 5-LEVEL SURFACE DETECTION (see B.6)
 6. PEAK TRACKING             m_recent_peak (2%/sample decay), m_peak_adc_since_underwater
 7. SURFACE BASELINE          if surface > 10 s AND NOT in lockout: adapt air
 8. STATE DETERMINATION       hysteresis threshold + clamp
 9. UPDATE OBSERVED PEAK
10. APPLY SURFACE OVERRIDE    L1-L5 force surface + air EMA + lockout
11. MAX DIVE TIMEOUT CASCADE  3-stage (see C.1)
12. SURFACE LOCKOUT           time-based, force surface during window
13. STATE CHANGE              reset tracking, log, LED in test mode
14. STATUS PUSH & LOG         SWSST snapshot + SWSLogEntry to flash

B.5 Dynamic threshold

threshold = air + ratio * (water - air)
hysteresis = max(10, threshold * 4%)
threshold_high = threshold + hysteresis      (above → UNDERWATER)
threshold_low  = clamp(threshold - hysteresis, ≥ air + 1)   (below → SURFACE)

Ratio adapts to contrast:

Contrast (water/air) Ratio Effect
≥ 8× (clean) 35 % Close to air — fast surface detection
≥ 4× 50 % Balanced midpoint
< 4× (biofouling) 40 % Closer to air — ensures UW still detected

Threshold underflow protection: threshold_low is clamped to never go below threshold_air + 1 when threshold_current > threshold_air. Threshold + hysteresis are also capped at observed_peak_adc so they never exceed values the ADC can actually produce.

B.6 5-level surface detection

All levels require: currently underwater (m_current_state = true), ≥ 1 s underwater (OVERRIDE_MIN_TIME_SEC), and proximity-guard OK.

Proximity guard (adaptive)

proximity_ref = max(water_baseline, peak_during_dive)
Contrast ≥ 5x  → filtered < proximity_ref * 95%   (must drop 5% below peak)
Contrast < 5x  → filtered < proximity_ref * 99%   (relaxed 1% gap, biofouled)

Levels (fastest → slowest)

Level Latency Condition Use case
L1 INSTANT 1 sample raw < prev_raw × (1 − 4%) Sharp water exit
L2 FAST 2 samples 2 consecutive raw drops, each ≥ 2 %, cumulative > 3 % Gradual exit
L3 TREND 3+ MA3 samples MA3 decreased ≥ 3× consecutively, total > 4 % Noisy gradual exit
L4 ABSOLUTE variable filtered < water_baseline × 92 % Moderate biofouling, well-calibrated baselines
L5 SAFETY NET > 10 s UW (peak_dive − filtered) / peak_dive > 10 % Last resort, all baselines drifted

Note: L2 individual step L2_MIN_STEP_PERCENT=2% filters out gradual drift (<1 %/sample salinity changes). L4 was relaxed 85 % → 92 % (many real exits drop only 10–12 %). L5 was relaxed 15 % → 10 % for better catch rate.

When any level triggers:

  1. State forced to surface
  2. Air EMA updated (15 % weight, bidirectional, capped at 70 % of water)
  3. Surface lockout activated (UW_MIN_SURFACE_TIME, time-based)
  4. Adaptive sample delay recalculated

B.7 Auto-calibration

Initial calibration (boot)

Take 10 ADC samples, 100 ms apart. If avg > 2500: assumes booted in water (swap roles, water=avg, air=avg/3). Else air=avg, water=min(air×3, air×5, observed_peak).

The water estimate is capped at air × 5 — prevents absurd estimates when air is inflated from dirty pins (air=1900 → water capped at 9500).

Water baseline (EMA, alpha = 19 %)

When confirmed underwater (filtered > threshold_high), water adapts:

new_water = 0.19 × value + 0.81 × old_water

Protection conditions (all must be true):

  • Value > threshold_current + hysteresis
  • Value ≥ air × 3 (bypassed if air ≥ 1000 to allow recovery)
  • Value ≥ 85 % of current water_baseline OR > water_baseline (relaxed when water still estimated)
  • Not in surface lockout
  • Capped at observed peak ADC

Air baseline adaptation (surface > 10 s, NOT in lockout)

Mode Trigger Formula Speed
Timed recalibration elapsed > SWS_ANALOG_CALIB_INTERVAL (3600 s) air = average Immediate
Upward (biofouling) avg > air × 1.3 AND avg < threshold air = 0.9 × air + 0.1 × avg Slow (10 %)
Downward (wet calib fix) avg < air × 0.7 AND air ≥ avg × 2 air = 0.8 × air + 0.2 × avg (min 50) Fast (20 %)

Surface-baseline tracking is blocked during lockout to prevent transitional readings from corrupting the air baseline. Downward adaptation is blocked when air is already close to actual readings (air < avg × 2) — replaces the legacy absolute-contrast gate which permanently blocked correction once water baseline was established.

Air floor: AIR_BASELINE_FLOOR = 50 ADC counts (matches empirical clean-dry-electrode range).

Surface-override air recalibration (L1-L5)

new_air = old_air × 0.85 + filtered × 0.15      (conservative EMA, 15% weight)
new_air ≤ water × 0.70                           (hard cap)
new_air ≥ AIR_BASELINE_FLOOR (50)

Replaces the legacy direct air = filtered_value which caused air ratcheting upward on repeated rapid transitions (MA2 lag).

Coherence check (first sample + continuous)

  • First sample: if stored cal is wildly inconsistent with the actual reading (e.g., stored air=1000 but reading=4800), invalidate and rebuild from current.
  • Continuous: if at surface AND raw > water_baseline × 2 for > 2 s, water baseline adapts immediately. Catches mid-operation environment changes.

Calibration persistence (3-tier)

Tier 1: noinit RAM (CRC16)     - survives soft reset
Tier 2: SWS.CAL offsets 2-4    - survives hard reset / power cycle
Tier 3: SWS.CAL offsets 0-1    - manual hints (SCALW or guided SWSCAL)

Boot priority chain:

  1. noinit CRC valid → use noinit (fast, soft reset)
  2. SWS.CAL offsets 2-4 valid → restore running calibration
  3. SWS.CAL offsets 0-1 set:
    • air + water set → use as-is (manual)
    • water only → auto-detect air, use water hint
    • nothing → full auto

B.8 Dynamic peak ADC tracking

When calibrating with wet electrodes (air ≈ 1200, water estimate air × 3 = 3600), the threshold+hysteresis can exceed actual water readings (~3000) and the device never detects UW.

m_observed_peak_adc (persistent in noinit + CRC) records the highest ADC value ever observed. It caps:

  1. Water baseline estimation
  2. EMA water updates
  3. Threshold + hysteresis

On first boot (peak=0), no capping; learned on first immersion.

B.9 Adaptive sample delay

Bounds: 100 µs floor (noise) to 5000 µs ceiling (biofouled signals converge). Default 1000 µs.

Contrast×10 Direction Adjustment Reasoning
< 50 (< 5×) Decrease −25 % Shorter delay → better film/water discrimination
> 100 (> 10×) Increase +10 % Longer delay → stronger signal on clean electrode
50–100 No change Good operating range

Seeded from UW_PIN_SAMPLE_DELAY_US (UNP08), clamped by UNP09/UNP10. Adjusts after every air recalibration event. Implementation uses PMU::delay_us() for microsecond precision.


Part C — Safety mechanisms (M1–M12)

The SWS detection chain has 12 independent safety mechanisms catalogued in the 2026-05 audit (sws_robustness_analysis.md). They protect against environmental, hardware, and corruption failures.

# Mechanism Failure handled Recovery time
M1 MAX_CONSECUTIVE_INVALID_ADC=60 → force surface ADC frozen / silicon failure ~1 min
M2 AIR_BASELINE_FLOOR=50 clamp Air collapse below ADC noise floor Immediate
M3 THRESHOLD_MIN_ABOVE_AIR=20 gap Threshold colliding with air baseline Immediate
M4 MIN_WATER_AIR_RATIO=3× floor on water Water collapse below 3× air Immediate
M5 5-level surface detection (L1–L5) False UW from waves / splash Per-sample
M6 Stuck-state recovery (air collapsed + surface) Dry-electrode death spiral After AIR_COLLAPSE_RECOVERY_SAMPLES=5
M7 Dive-timeout cascade (3-stage) Stuck UW indefinitely Max 3 × UW_MAX_DIVE_TIME (6 h default)
M8 UW_MIN_SURFACE_TIME lockout (5 s) Surface ↔ UW flapping in waves Lockout absorbs the bounce
M9 CRC16 validation on calibration data RAM / flash bit-flip corruption Immediate (struct zeroed on mismatch)
M10 Debounced flash writes Flash wear from stuck-low electrode ~1/N samples — 100k cycles last > 1 year
M11 Periodic AIR recalibration (1 h) Slow biofouling drift on air Every SWS_ANALOG_CALIB_INTERVAL
M12 Adaptive AIR UP/DOWN Air baseline correction either direction Bounded EMA, no runaway

M1 — ADC stuck-failure escape (added 2026-05, audit FIX M1)

detector_state() returned m_current_state on any invalid ADC read, pinning the device in "underwater" forever if the SAADC silicon / SWS frontend died underwater. After MAX_CONSECUTIVE_INVALID_ADC=60 consecutive invalids (~1 min at default 1 s sampling), state is forced to surface and a WARN logged. Counter resets on first valid read.

M2 — Stuck-low electrode debounce (added 2026-05, audit FIX M2)

Stuck-low electrodes (raw ≈ 0, valid surface) triggered the air-collapse recovery every ~50 s; each recovery wrote calibration to flash un-debounced → ~1700 writes/day → exhausted the 100k-cycle endurance in ~60 days. Swapped to save_calibration_to_flash_debounced() (1-line fix at sws_analog_detection.cpp:526).

M3 — UNP25 clamp ≥ 1 (added 2026-05, audit FIX M3)

service_init() now clamps UW_MIN_SURFACE_TIME (UNP25) to ≥ 1 s. The OVERRIDE_MIN_TIME_SEC=1 backstop covered most rapid-flap risk if a user misconfigured UNP25=0, but a direct lockout floor is a cleaner defense.

M7 — Dive-timeout cascade (escalating)

Default UW_MAX_DIVE_TIME = 7200 s (2 h). When exceeded:

Timeout #1   → recalibrate water baseline UP from current filtered ADC. Reset timer. Stay UW.
Timeout #2   → same. Counter = 2.
Timeout #3   → Force surface + 30 s lockout (SURFACE_LOCKOUT_DURATION_SEC).
               Reset peak ADC + spike counter. Save calibration. Reset cascade.

Guarantees no matter what sensor anomaly occurs, the device returns to a surface-emitting state within 3 × UW_MAX_DIVE_TIME (default 6 h). Counter resets on any real state change (L1-L5 or threshold).

⚠ Setting UW_MAX_DIVE_TIME = 0 disables the timeout entirely — removes the last safety net against biofouling-induced permanent UW lock. Never deploy with 0 on marine trackers.

F-SWS-1 double-sample reject — REVERTED 2026-05

A "double-sample sanity reject" (> 12 % delta → reject the pair) was added during the broad robustness audit to catch transient Vbatt sag during SMD/GPS power-on inrush. Real-saltwater field tests showed natural sample-to-sample variation in the RC discrimination circuit (100 nF cap charging through seawater at 500 µs intervals) is routinely > 12 % due to electrode contact noise, micro-bubbles, biofouling, and conductivity gradients. F-SWS-1 rejected ~95 % of legitimate samples → SWS could never produce a valid reading → state stuck. Reverted in favor of the existing L1-L5 + hysteresis + coherence + peak validation + M1-M12 mesh which already handles real-world noise correctly.

Coverage matrix (20 scenarios)

Category Scenarios Auto-recovered Bounded Non-recoverable
ADC hardware 3 2 1
Calibration drift 4 3 1
Environmental 4 3 1
Memory / flash 3 3
Exotic modes 4 4
Hardware permanent 2 2 (S-19, S-20)
Total 20 15 (75 %) 3 (15 %) 2 (10 %)

The 2 non-recoverable scenarios are physical hardware failures (SWS pin disconnected, severe biofouling sealing the electrode). The firmware degrades to "always surface" mode in those cases and keeps transmitting; only position quality is affected. No scenario found where both surface and underwater become simultaneously undetectable.


Part D — Surface-cycle cooldown

For animals that surface frequently (sea turtles: up to 500 surfacings/day), running a full GNSS + TX cycle on every surfacing drains the battery in months. The cooldown mechanism caps how often full cycles execute.

D.1 Lifecycle

Two-step: arm then activate.

  1. Arming: a flag is set during the surfacing session, controlled by COOLDOWN_TRIGGER_MODE (UNP30).
  2. Activation: on dive, if the flag is armed, set_cycle_complete(now) starts the timer.
  3. During cooldown: SWS is paused; GPS and TX disabled. A wake timer restarts SWS when cooldown expires.
  4. Expiry: SWS restarts, next surface → full cycle resumes.

D.2 Trigger modes (UNP30)

Value Mode Arms when
0 AT_SURFACE At surface detection
1 END_OF_DOPPLER End of Doppler burst (GNSS fix or max msg)
2 AFTER_FIRST_GNSS After 1st GNSS TX of SURFACING_BURST
3 AFTER_LAST_TX (default) At every TX-complete — timer restarts each time

D.3 During cooldown

  • SWS: stopped via pause_for_cooldown() — no sampling, minimal power
  • GPS / Argos / LoRa TX: not rescheduled
  • Other sensors (pressure, AXL, thermistor): continue if configured for UW operation
  • Reed switch: always active (not a scheduled service)
  • Wake timer: programmed for remaining cooldown, restarts SWS on expiry

D.4 Persistence (CRC16)

struct CooldownNoinit {
    std::time_t last_cycle_time;
    uint16_t    passive_count;
    uint16_t    crc;
};

.noinit RAM, survives soft reset / WDT, lost on power-off. Saved at cycle end + every passive surfacing during cooldown. Restored at ServiceManager::startall().

D.5 Defensive gates (added 2026-05)

Added across commits dea5d734, 86817d09. Three audit-driven fixes:

  • Cooldown gate in 3 services: GPSService, ArgosTxService, SWSAnalogService check is_in_cooldown(now) at the top of service_next_schedule_in_ms and return SCHEDULE_DISABLED. Kills latent tasks posted before cycle complete (boot path, surface event arriving early) without changing FSM state.
  • stopall() cancels m_cooldown_wake_task: FSM transit out of Operational (reed magnet) would otherwise leave a stale lambda firing after re-entering Operational, undoing a fresh cooldown's SWS pause.
  • AFTER_LAST_TX / AFTER_FIRST_GNSS arm only if NOT in cooldown: previously DUTY_CYCLE / LEGACY / PASS_PREDICTION modes could re-arm m_cooldown_armed during cooldown (TX is satellite-scheduled and not cooldown-gated in these modes), creeping the cooldown timer forward by one full interval per cycle. SURFACING_BURST was already protected.
  • restore_cooldown_state() schedules wake task at boot (FIX B, audit batch 2): boot mid-cooldown without re-arming the wake task left all services dormant until an external event (magnet, AXL wakeup). Sealed turtle = dormant for the rest of the day. Now arms the wake task immediately if is_in_cooldown is still true at boot.
  • UWDetector full reset on cooldown-exit (FIX C): reset_state_for_cooldown_exit() now resets the full sample-iteration state (m_sample_iteration, m_dry_count, m_pending_state) — previously only cleared m_is_first_time. Continuing a cycle that started before a long cooldown would produce a stale verdict.
  • set_cycle_complete RTC-set guard (FIX D): refuses to anchor cooldown on virtual-epoch RTC. Anchoring at virtual epoch (≈ 1) would make is_in_cooldown() compare "now" against virtual epoch — once GPS syncs, "now" jumps to real time and elapsed becomes massive (cooldown looks expired immediately). Caller (ArgosTxService dive handler) retries on next dive event.
  • AFTER_LAST_TX cooldown only on TX-DONE (commit dbcca4fe): is_tx_finished distinguishes MAC_TX_DONE from MAC_TX_TIMEOUT/MAC_ERROR. AFTER_LAST_TX no longer arms on failed TX.

D.6 Per-service behavior during cooldown

  • ArgosTx / LoRaTx / GPS / ArgosRx / CAM — suspended (the 3 schedulable services that check is_in_cooldown(now) at top of service_next_schedule_in_ms + the others gated by it)
  • SWSAnalog / UWDetector — paused on first surface during cooldown (via GPSService::enter_cooldown_sleep); wake-timer resumes them at expiry, re-emits surface state for clean re-arm
  • AXL / Pressure / Thermistor / PH / SeaTemp / ALS / Mortality (RSPB) / BatteryMonitor — continue sampling (intentional — feed the depth pile for the next burst)

D.7 Energy impact

Cooldown Cycles/day Battery life (est.)
0 (off) ~500 ~5 months
1200 (20 min) ~50 ~9 months
2700 (45 min, default) ~32 ~14 months
3600 (60 min) ~24 ~16 months

D.8 Configuration

# 45 min cooldown with trigger after first GNSS TX
$PARMW#010;UNP20=2700,UNP30=2\r

# 45 min cooldown with default trigger (after last TX)
$PARMW#00A;UNP20=2700\r

Part E — HAULED-Mode auto-detection (HMP00–HMP13)

Automatic detection of an animal hauled out (beached / on land / dead in a tree) vs. at sea. When HAULED engages, the device overrides a subset of comm params to lower cadence to stretch battery during prolonged inactive phases.

Added: Plan 1 (commit a54a06f2, 2026-05). Audit follow-up (29af5362, 2026-05).

E.1 Why this matters

Three real-world scenarios:

  • Bird tracker hooked in a tree — RSPB / bird deployments where the animal dies or gets stuck dry. Without HAULED, the device keeps surfacing-burst Argos at full cadence until the battery dies.
  • Turtle in a fisherman's boat / on a beach — keep tracking (we want to know where it is) but at frugal cadence.
  • End-of-mission recovery in shipping container — biologists ship trackers back, devices stay dry for weeks before reuse.

HAULED engages only after at-sea detection has been failing for hours, and disengages cleanly when normal dive events resume. Fully reversible — no permanent state change.

E.2 State machine

                   ┌─────────────────────────┐
                   │       AT_SEA            │   (default at boot)
                   │  last_uw_event_rtc=now  │
                   └────────────┬────────────┘
                                │
                          (no UW for HAULED_IDLE_THRESHOLD_H hours)
                                ▼
                   ┌─────────────────────────┐
                   │       HAULED            │   (engaged)
                   │  HMP-overrides ACTIVE   │
                   └────────────┬────────────┘
                                │
                          (HAULED_RETURN_EVENTS consecutive dives)
                                ▼
                   ┌─────────────────────────┐
                   │       AT_SEA            │   (resumed)
                   └─────────────────────────┘

Engagement (AT_SEA → HAULED)

In evaluate():

  • If now − last_uw_event_rtc ≥ HAULED_IDLE_THRESHOLD_H × 3600 AND HAULED_DETECT_EN=true:
    • Set in_hauled=1, reset uw_events_since_hauled=0, persist (noinit + CRC16), log INFO

Disengagement (HAULED → AT_SEA)

In on_underwater_event():

  • If in_hauled=1: increment uw_events_since_hauled. If ≥ HAULED_RETURN_EVENTS (default 3): reset, persist, log.

The 3-event return threshold is intentional: a single spurious UW event (humidity, fog, false trigger) should NOT take the device back to AT_SEA.

Safety drop

Setting HAULED_DETECT_EN=false mid-deployment while in HAULED → evaluate() immediately drops back to AT_SEA. Prevents users getting stuck after disabling the feature.

E.3 ConfigMode priority cascade

1. LOW_BATTERY        (LBP01=1 + battery < LBP02)
2. HAULED             (HauledModeService.in_hauled=1)
3. OUT_OF_ZONE        (geofence)
4. AT_SEA_SEQUENCED   (Plan 2 stub — reserved)
5. NORMAL             (default)

Each mode can override ArgosConfig and GNSSConfig. For HAULED, only a subset is overridden — see §E.4.

E.4 Parameters (HMP00–HMP13)

Key Name Type Default Range Meaning
HMP00 HAULED_DETECT_EN BOOL false 0/1 Master enable (off by default)
HMP01 HAULED_IDLE_THRESHOLD_H UINT 24 1–8760 Hours of no UW before engaging
HMP02 HAULED_RETURN_EVENTS UINT 3 1–10 Consecutive UW events to disengage
HMP10 HAULED_ARGOS_MODE ENUM 2 (LEGACY) 0–4 Argos mode override. SURFACING_BURST not allowed (auto-promoted to LEGACY at config read for legacy persisted configs; DTE PARMW rejects value 5). Allowed: LEGACY, DUTY_CYCLE, DOPPLER, PASS_PREDICTION.
HMP11 HAULED_TR_NOM UINT 7200 60–86400 TX interval override (s). Applies to LEGACY/DUTY_CYCLE/DOPPLER.
HMP12 HAULED_GNSS_EN BOOL false 0/1 GNSS enable when HAULED. Default off to save battery.
HMP13 HAULED_GNSS_STRAT UINT 1 (REUSE_LAST) 0–2 GNSS strategy: 0=FRESH, 1=REUSE_LAST, 2=OFF

HMP12 vs HMP13 interaction (⚠ important)

HMP12 (HAULED_GNSS_EN) is only consulted by config_store when HMP13 == FRESH (0). For HMP13 == REUSE_LAST or OFF, the strategy forces gnss_en=false regardless of HMP12. A runtime WARN fires on HAULED entry if HMP12=true is combined with HMP13 ≠ FRESH, so post-deploy logs flag ambiguous configs (added in audit follow-up 29af5362).

HMP13 HMP12 Effective gnss_en TX dispatch
0 FRESH true true → GPS wakes process_gnss_burst (full acquisition + fix)
0 FRESH false false → GPS off process_doppler_burst (no position)
1 REUSE_LAST * (ignored) false → GPS off process_gnss_burst_from_cached → fallback Doppler if cache > GNP50
2 OFF * (ignored) false → GPS off process_doppler_burst (no cache lookup)

HAULED + REUSE_LAST cache lifecycle

When HMP13=REUSE_LAST, GPS never powers on during HAULED. TX uses the most recent FIX/UPDATE entry from the depth pile (age-checked vs GNP50 GNSS_REUSE_FIX_MAX_AGE_S).

The cache CANNOT refresh during HAULED. Once the animal stops diving:

  • HAULED engages after HMP01 hours of dry idle
  • TX continues at HMP11 interval, using the last sea-phase fix indefinitely
  • After GNP50 seconds (default 24 h), cache is "stale" → read_cached_last_fix returns false → TX falls back to process_doppler_burst
  • Persists until the animal genuinely returns to sea (HMP02 dive events)

Intentional battery-saving behavior — a hauled / stranded / dead animal doesn't change position. Avoids waking GPS for hours/days on a stationary device. Doppler fallback still gives a satellite-derived position (less accurate but enough for recovery).

If you want fresh GPS positions during HAULED at the cost of battery, set HMP13=FRESH AND HMP12=true. Expect ~30-60× the battery cost vs REUSE_LAST.

Audit follow-up improvements (2026-05, commit 29af5362)

  • HM-1 mark_config_mode() helper: factored mode-transition logging into a single helper. Previous code repeated the if (m_last_config_mode != X) { log; set; } pattern in 6 places and never emitted an explicit "HAULED EXITED" log on transit-out. Helper now logs the destination mode and, when the previous mode was HAULED, an explicit "HAULED EXITED -> X" line for post-deploy forensics. Also adds [VAL-HAULED] mode_enter / mode_exit trace tags under VALIDATION_LOG_ENABLE.
  • HM-2 ambiguous HMP12+HMP13 WARN: gated by !=HAULED transition check, so no log spam — fires once on HAULED entry pointing to which DTE param to flip.
  • HM-4 short-window (500 ms) cache on AT_SEA→HAULED threshold evaluation: evaluate() is called from every config read so it fires many times per second. HMP00=disabled fast-path (immediate exit on DTE toggle) and the in_hauled hysteresis check stay un-cached for instant response. Threshold itself is in hours, so a sub-second cache cannot meaningfully delay engagement. Disabled in CPPUTEST so unit tests stay deterministic.

Sensible HAULED profile examples

Conservative ("stay alive as long as possible"):

HMP00=true, HMP01=48, HMP02=3, HMP10=2 (LEGACY), HMP11=14400, HMP12=false, HMP13=2 (OFF)

6 Doppler-only TX/day at ~70 µAh each = ~0.4 mAh/day. Survives years.

Moderate ("track location at low cadence"):

HMP00=true, HMP01=24, HMP02=3, HMP10=2, HMP11=7200, HMP12=true, HMP13=1 (REUSE_LAST)

12 GNSS+Doppler TX/day, cached fixes only. ~1 mAh/day.

Off (default): HMP00=false — no HAULED ever.

E.5 RTC robustness

HAULED relies on last_uw_event_rtc and current RTC. Two robustness paths:

Virtual RTC fallback (Plan 1 prerequisite)

Works with the virtual RTC fallback. Even if GNSS has never fixed and rtc->gettime() returns 1 + uptime_seconds, the relative-time delta now − last_uw_event_rtc remains meaningful.

RTC rollback handling (M1b)

If now < last_uw_event_rtc (e.g. WDT reset + virtual RTC re-initializes to 1 while noinit retains last_uw_event_rtc = 3600):

Before: blind return — froze the HAULED state machine for the rest of the boot session.

After (M1b): re-baseline last_uw_event_rtc = now. Time count restarts from the new RTC frame, but in_hauled is preserved. After HMP01 hours from this point, HAULED can re-engage normally.

if (now < s_noinit.last_uw_event_rtc) {
    DEBUG_WARN("HauledModeService: RTC rollback detected — re-baselining");
    s_noinit.last_uw_event_rtc = now;
    persist();
}

K+L hooks — virtual→real RTC sync

When m10qasync.cpp::on_fix transitions virtual RTC → real RTC, it calls HauledModeService::reset_for_rtc_sync() and RateLimiter::reset_for_rtc_sync(). Without these, noinit timestamps stamped in virtual epoch (~uptime seconds) would appear billions of seconds in the past after the jump to real UTC.

E.6 Noinit state

struct HauledModeNoinit {
    std::time_t last_uw_event_rtc;
    uint8_t     in_hauled;          // 0 = AT_SEA, 1 = HAULED
    uint8_t     uw_events_since_hauled;
    uint8_t     _pad[2];
    uint16_t    crc;
};

Survives WDT reset, lost on power-off (clean restart from AT_SEA at next cold boot). CRC mismatch → struct zeroed, AT_SEA from boot.

E.7 Stuck-HAULED scenario (SWS hardware failure)

Symptom: device stays in HAULED for the whole deployment, TX cadence at HMP11 interval, no dive events in field log.

Diagnosis chain:

  1. If SWS analog frontend / SAADC fails while in HAULED, read_analog_sws() returns ADC_READ_ERROR. After MAX_CONSECUTIVE_INVALID_ADC=60 ticks (~1 min), the detector force-flips to state=0 (surface) (M1).
  2. State=surface emits UW=false events, NOT UW=true.
  3. HAULED→AT_SEA exit requires HMP02 consecutive UW=true events.
  4. Stuck HAULED for the rest of the mission.

Consequences:

  • Device continues to TX at HMP11 interval (battery-friendly)
  • Strategy HMP13 is honored: REUSE_LAST uses stale cache → Doppler fallback; OFF stays Doppler-only
  • Battery survives much longer than nominal
  • Animal still trackable (Doppler-only)

Recovery options (none automatic):

  • Magnet → ConfigurationState → DTE → PARMW HMP00 0 (disables detection, exits HAULED) OR factory reset
  • Boot-fail counter does NOT help — stuck HAULED is not a crash, just degraded

Pre-deploy bench test recommended (biology team): submerge SWS electrode, check UW=true emission. SWS dead-on-arrival deployment appears hauled from day-1.


Part F — LED feedback & DTE commands

F.1 LED feedback on state transitions

When LED_MODE ≠ OFF, the tracker flashes a brief LED on each underwater state change:

Transition Color Duration Description
Surface detected Green flash 100 ms Animal surfaced — TX will resume
Dive detected Blue flash 100 ms Animal submerged — TX suspended

Single 100 ms flashes (~0.2 µAh per event) — negligible power impact.

Color rationale: blue (not cyan) for dive — cyan is reserved for GNSS acquisition (slow flash). Blue is not used in Operational state (only in Configuration for BLE), so no ambiguity.

When LED_MODE=OFF, no LED fires. The SWS test mode (SWSTST) provides continuous LED feedback independently.

During SWSTST, the test mode overwrites LED state every ~2 s. Reed switch feedback (white) may be briefly visible but gets overwritten. The reed switch itself continues to function — only the LED feedback is affected.

F.2 DTE test commands

$SWSST — read SWS status

Returns: air, water, threshold, hysteresis, raw_adc, filtered_adc, calibrated, underwater, time_in_state, surface_level, contrast_x10, observed_peak, sample_delay_us

$SWSTST — start/stop test mode

  • $SWSTST,1 — start (continuous sampling with async SWSST push, LED feedback)
  • $SWSTST,0 — stop (restores normal LED via on_test_stop callback)

In test mode: SWSST data pushed every sample regardless of state change. LED shows current state: BLUE = underwater, YELLOW = surface.

$SWSCAL — guided calibration (LED-assisted)

  • $SWSCAL,1 — start; $SWSCAL,0 — cancel
Phase 1 GREEN flashing  → "place device in AIR"   → 10 stable samples
Phase 2 BLUE flashing   → "place device in WATER" → 10 stable samples
Result  WHITE flash     → success
        RED flash       → failure (water not > air)

Async response: $O;SWSCAL;<status>,<air>,<water> (status: 0=in_progress, 1=success, 2=failed, 3=cancelled).

$SCALW/$SCALR — manual calibration

Device ID 8 = SWS.

$SCALW,8,0,2500    → set expected water = 2500 ADC
$SCALW,8,1,50      → set expected air = 50 ADC
$SCALR,8,0..4      → read stored / running values + observed peak
Offset Content Written by
0 Manual water hint $SCALW or $SWSCAL
1 Manual air hint $SCALW or $SWSCAL
2 Running water baseline Auto (state transitions)
3 Running air baseline Auto (state transitions)
4 Observed peak ADC Auto (when peak changes)

Part G — Parameters reference

Underwater detection (UNP)

Parameter DTE Key Type Default Description
UNDERWATER_EN UNP01 BOOL 0 Enable underwater detection (SWS analog)
DRY_TIME_BEFORE_TX UNP02 UINT 0 Seconds at surface before TX allowed
SAMPLING_UNDER_FREQ UNP03 FLOAT 1.0 Sampling interval underwater (s, ≥ 0.1)
SAMPLING_SURF_FREQ UNP04 FLOAT 10.0 Sampling interval at surface (s, ≥ 0.1)
UW_PIN_SAMPLE_DELAY_US UNP08 UINT 1000 Initial RC charge time (µs). Adaptive 100–5000.
UNDERWATER_DETECT_THRESH UNP11 FLOAT 1.1 Pressure-sensor logging threshold
MIN_SURFACE_CYCLE_INTERVAL_S UNP20 UINT 2700 Cooldown duration (s). 0 = disabled.
SWS_ANALOG_HYSTERESIS UNP22 UINT 4 % Hysteresis (% of threshold)
SWS_ANALOG_CALIB_INTERVAL UNP23 UINT 3600 Air baseline timed recalibration (s, 1 h)
UW_MAX_DIVE_TIME UNP24 UINT 7200 Escalating dive timeout (s, 2 h). ⚠ 0 = disabled — removes safety net.
UW_MIN_SURFACE_TIME UNP25 UINT 5 Surface lockout after detection (s). Clamped ≥ 1.
COOLDOWN_TRIGGER_MODE UNP30 ENUM 3 (AFTER_LAST_TX) When to arm cooldown (see D.2)

HAULED (HMP)

See §E.4.

Internal constants (sws_analog_service.cpp)

Constant Value Description
ADC_INVALID_MAX 16383 14-bit ADC maximum
ADC_HISTORY_SIZE 2 MA filter window
DEFAULT_THRESHOLD_RATIO_PERCENT 35 % Ratio (clean electrode)
DEFAULT_ALPHA_PERCENT 19 % EMA alpha for water
SAMPLE_DELAY_MIN_US / MAX_US / DEFAULT_US 100 / 5000 / 1000 Adaptive delay bounds
CONTRAST_LOW_THRESHOLD / HIGH_THRESHOLD 50 / 100 Adaptive delay triggers (×10)
MIN_WATER_AIR_RATIO 3 Water ≥ 3× air (bypass if air ≥ 1000)
AIR_BASELINE_FLOOR 50 Min air baseline (ADC counts)
AIR_RECALIB_EMA_WEIGHT 0.15 L1-L5 air recalib EMA weight
AIR_RECALIB_MAX_RATIO 0.70 Air ≤ 70 % of water (hard cap)
L1_DROP_PERCENT 4 % L1 trigger threshold
L2_DROP_PERCENT / L2_MIN_STEP_PERCENT 3 % / 2 % L2 cumulative / per-step
L3_DROP_PERCENT / L3_MIN_CONSECUTIVE 4 % / 3 L3 MA3 trend
L4_DROP_PERCENT 8 % L4 drop below water baseline
L5_DROP_PERCENT / L5_MIN_TIME_SEC 10 % / 10 L5 peak-relative / min time
OVERRIDE_MIN_TIME_SEC 1 Min UW time before L-override
PROXIMITY_GUARD_PERCENT / _BIOFOULING 95 % / 99 % L-override block thresholds
SURFACE_LOCKOUT_DURATION_SEC 30 Lockout after max-dive cascade
WATER_DETECT_HEURISTIC 2500 Boot calibration: avg > X = booted in water
MAX_CONSECUTIVE_INVALID_ADC 60 Force surface threshold (M1)
AIR_COLLAPSE_RECOVERY_SAMPLES 5 M6 trigger

Part H — Troubleshooting

Device never detects underwater (always surface)

Check: $SWSSTthreshold_high vs raw_adc when submerged.

Common causes:

  • threshold_high > raw_adc: threshold too high. Check if air baseline is inflated (wet electrode calibration). Downward air adaptation should fix it over time.
  • observed_peak = 0: first boot, no peak learned. First immersion sets it.
  • Very low contrast (water/air < 2×): check electrode connections, conductivity.

Device never detects surface (always underwater)

Check: $SWSSTsurface_level. If always 0, no L-override triggers.

Common causes:

  • Sample delay too high (sample_delay_us stuck at 5000 µs). Recalibration cycle will adjust.
  • Proximity guard blocking (readings too close to water peak). Adaptive 95 % / 99 %.
  • Air baseline too low → threshold far below water, readings stay in hysteresis zone.

Slow surface detection (> 5 s)

  • Increase sampling frequency: lower SAMPLING_UNDER_FREQ to 1–2 s (or 0.1 for sub-second).
  • L1 should trigger for any drop > 4 % from previous raw. If L1 doesn't fire, readings drift slowly → L2/L3 catches in 2–5 s.
  • Check proximity guard. Check sample_delay_us (high value → poor water/film discrimination).

Threshold/hysteresis exceed actual water readings

  • observed_peak_adc caps threshold+hysteresis. Check its value in $SWSST.
  • If peak = 0 (first boot), one immersion learns it.
  • If peak correct but threshold still too high: air baseline inflated → ratio produces inflated threshold.

Calibration corrupted after reset

  • Calibration is in noinit RAM with CRC16. CRC fails → SWS.CAL backup used.
  • Power cycle clears noinit → SWS.CAL offsets 2–4 restore running calibration.
  • SWS.CAL also missing → manual hints or full auto.
  • Use $SWSCAL guided calibration before first deployment for best cold-start behavior.
  • $SCALR,8,2/3/4 shows persisted running state.

Stuck HAULED

See §E.7.

Device sends every 2 h with cached GNSS that doesn't change

HAULED engaged with REUSE_LAST — expected behavior. Animal is likely beached/dead. Check in_hauled via STATR.

Device stops sending position after ~1 day, then sends every 2 h with no GNSS

HAULED engaged correctly. Animal is inactive.

HAULED engaged within 24 h of deployment

Animal genuinely inactive — check biology. Could also be SWS calibration issue (false-surface). Pre-deploy bench test recommended.

last_uw_event_rtc huge (~billion seconds)

RTC rollback (virtual → real or vice versa); self-heals via M1b.


Part I — Future optimizations

Underwater the device wakes periodically to sample SWS (default SAMPLING_UNDER_FREQ = 1 s). Each sample = ~10–20 ms peak at ~20 mA from SAADC + RC charge. Net average ~200–400 µA underwater — dominant power source during dives. 3 optimization paths sorted by complexity / risk.

I.1 Adaptive sampling rate (firmware-only, recommended first)

A turtle underwater for > 2 min won't surface in the next second. Slow down SWS sampling progressively:

0-2 min underwater   → every 1-5 s   (reactive, default)
2-15 min underwater  → every 30 s    (typical dive depth)
> 15 min underwater  → every 1-2 min (long dive, max saving)
  • Implementation: ~30 lines in SWSAnalogService::service_next_schedule_in_ms — track time since last surface, scale return value.
  • Gain: ~60–80 % reduction in average UW current.
  • Risk: surface-detection latency at deep dives — bounded by configured slow rate (worst case 1-2 min).
  • No HW change required.

I.2 AC-coupled SWS circuit (hardware revision, next PCB rev)

Current SWS biases electrodes with DC pulses (~1 ms HIGH on SWS_ENABLE_PIN, charges cap, SAADC reads, LOW). Brief DC still causes galvanic corrosion — slow electrochemical drift over deployment lifetime.

Existing HW mitigation (LinkIt V4 board): cap in parallel + Schottky diode snubber. The diode on the current board batch was reflowed reversed by the manufacturer and had to be removed — that protection is currently absent on field hardware. Re-spin the PCB with correct orientation before next mass-flash.

Proposed AC-coupled topology (next revision):

nRF PWM (1-10 kHz) → SWS_ENABLE (square wave 0/3V3)
                         │
                  ┌──────┴──────┐
              Electrode 1    Electrode 2 (GND)
                  │
              Series cap (~1 µF)  ← BLOCKS DC entirely
                  │
              Rectifier (diode + cap to GND)
                  │
              SAADC SWS_IN
  • Benefits: zero DC → no corrosion (proven on Aanderaa-style sensors). Same SAADC + algo compatible.
  • BOM: ~$0.30 extra.
  • FW effort: ~50 lines (PWM driver).
  • Tradeoff: requires PCB respin; won't help current deployed devices.

I.3 AXL-triggered SWS (battery saving, untested)

A still turtle (resting, sleeping) isn't going to surface in 5 s. BMA400 fires INT only on motion → sample SWS only when AXL detects motion.

Prerequisites (currently absent on LoRa build):

  • ENABLE_AXL_SENSOR=ON in build_linkitv4_lora.sh (currently OFF)
  • INT1_IMU wired to BMA400 (already routed on V4 PCB)
  • BMA400 motion-detect mode (~14 µA continuous)
Underwater state entered
  ├─ Configure BMA400 motion threshold + duration
  ├─ Enable BMA400 motion-detect interrupt (P1.02 wakeup)
  └─ STOP periodic SWS scheduler  ← key saving

BMA400 interrupt fires (turtle moves)
  ├─ Wake nRF
  ├─ Trigger ONE SWS sample
  ├─ Algo decides surface vs underwater
  └─ Re-arm BMA400 (or fallback to periodic if surface)

Fallback safety timer
  └─ Force SWS sample every N min even without AXL
      (covers "turtle floats motionless at surface" case)

Expected gain:

  • Quiescent turtle: ~95 % saving (AXL = 14 µA vs periodic SWS ~200 µA).
  • Active turtle: ~0 % saving (AXL fires constantly).
  • Realistic average: ~30–50 % saving on UW SWS budget.

Risks:

Scenario Severity Mitigation
Turtle surfaces calmly with little motion → no AXL → miss surface Medium Fallback timer SWS_AXL_FALLBACK_S
AXL false triggers from currents/drift Low Net: no saving, no harm
Algo cal drift from irregular sampling Low-Medium Force periodic cal samples (every 30 min)
Motion threshold tuning Medium Bench-test with turtle motion data

Status: TODO — battery-saving experiment, ~1 week dev + bench + field.


SWS logger — flash log entry format

When ENABLE_SWS_LOG is defined, every detector_state() call writes a SWSLogEntry to flash via the Logger framework.

CSV format:

log_datetime,raw_adc,filtered_adc,threshold,hysteresis,air,water,calibrated,underwater,time_in_state,surface_level,contrast_x10,observed_peak,sample_delay_us

Retrieve via DTE: $DUMPD with the SWS log type.


Test coverage

The SWSAnalog and SWSAnalogFlash test groups (35 + 27 = 62 tests) cover the safety mechanisms:

Scenario Validating tests
ADC invalid handling QA13, QA16
Biofouling progression QA6, QA18
Air baseline floor QA8, QA14
Dive timeout cascade QA12, QA15
Wave splash QA5
Spike rejection QA3A/B, QA11
CRC validation QA19, QA17
Persistence FlashPersistence_SurvivesReboot
Recovery cycles RCode06, QA20

All 62 tests pass at the latest validation.


References

Clone this wiki locally