-
Notifications
You must be signed in to change notification settings - Fork 3
13 ‐ Underwater and Behavioral Modes
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.
- Part A — Underwater detection overview
- Part B — SWS algorithm
- Part C — Safety mechanisms (M1–M12)
- Part D — Surface-cycle cooldown
- Part E — HAULED-Mode auto-detection (HMP00–HMP13)
- Part F — LED feedback & DTE commands
- Part G — Parameters reference
- Part H — Troubleshooting
- Part I — Future optimizations
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)
-
UW Detector Service samples the SWS electrode at
SAMPLING_SURF_FREQ(surface) orSAMPLING_UNDER_FREQ(underwater). - On confirmed state change, the scheduler is notified via
notify_underwater_state(). - Submerged: Argos TX + GNSS descheduled (no satellite communication possible).
-
Surfaced: after
DRY_TIME_BEFORE_TXseconds, Argos TX rescheduled and GNSS allowed to acquire.
When the UW detector confirms a state change, services react as follows:
ArgosScheduler — notify_underwater_state():
-
Submerged:
deschedule()cancels all pending Argos TX -
Surfaced:
m_earliest_tx = now + dry_time_before_tx, thenreschedule()
GPSScheduler:
- Submerged: cancels ongoing acquisition, suspends scheduling
- Surfaced: resumes acquisition
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.
- Normal: underwater suspends both GNSS and Argos TX
- Low Battery: underwater suspends Doppler TX
- Critical: device is off regardless
The SWS analog algorithm (SWSAnalogService) is the sole underwater detection path. Uses RC time-constant discrimination to read conductivity between two electrodes.
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).
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.
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.
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
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.
All levels require: currently underwater (m_current_state = true), ≥ 1 s underwater (OVERRIDE_MIN_TIME_SEC), and proximity-guard OK.
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)
| 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:
- State forced to surface
- Air EMA updated (15 % weight, bidirectional, capped at 70 % of water)
- Surface lockout activated (
UW_MIN_SURFACE_TIME, time-based) - Adaptive sample delay recalculated
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).
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
| 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).
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).
- 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 × 2for > 2 s, water baseline adapts immediately. Catches mid-operation environment changes.
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:
- noinit CRC valid → use noinit (fast, soft reset)
- SWS.CAL offsets 2-4 valid → restore running calibration
- SWS.CAL offsets 0-1 set:
- air + water set → use as-is (manual)
- water only → auto-detect air, use water hint
- nothing → full auto
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:
- Water baseline estimation
- EMA water updates
- Threshold + hysteresis
On first boot (peak=0), no capping; learned on first immersion.
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.
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 |
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.
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).
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.
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.
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.
| 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.
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.
Two-step: arm then activate.
-
Arming: a flag is set during the surfacing session, controlled by
COOLDOWN_TRIGGER_MODE(UNP30). -
Activation: on dive, if the flag is armed,
set_cycle_complete(now)starts the timer. - During cooldown: SWS is paused; GPS and TX disabled. A wake timer restarts SWS when cooldown expires.
- Expiry: SWS restarts, next surface → full cycle resumes.
| 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 |
-
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
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().
Added across commits dea5d734, 86817d09. Three audit-driven fixes:
-
Cooldown gate in 3 services:
GPSService,ArgosTxService,SWSAnalogServicecheckis_in_cooldown(now)at the top ofservice_next_schedule_in_msand returnSCHEDULE_DISABLED. Kills latent tasks posted before cycle complete (boot path, surface event arriving early) without changing FSM state. -
stopall()cancelsm_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_GNSSarm only if NOT in cooldown: previously DUTY_CYCLE / LEGACY / PASS_PREDICTION modes could re-armm_cooldown_armedduring 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 ifis_in_cooldownis 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 clearedm_is_first_time. Continuing a cycle that started before a long cooldown would produce a stale verdict. -
set_cycle_completeRTC-set guard (FIX D): refuses to anchor cooldown on virtual-epoch RTC. Anchoring at virtual epoch (≈ 1) would makeis_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_finisheddistinguishesMAC_TX_DONEfromMAC_TX_TIMEOUT/MAC_ERROR. AFTER_LAST_TX no longer arms on failed TX.
-
ArgosTx / LoRaTx / GPS / ArgosRx / CAM — suspended (the 3 schedulable services that check
is_in_cooldown(now)at top ofservice_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)
| 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 |
# 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
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).
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.
┌─────────────────────────┐
│ 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)
└─────────────────────────┘
In evaluate():
- If
now − last_uw_event_rtc ≥ HAULED_IDLE_THRESHOLD_H × 3600ANDHAULED_DETECT_EN=true:- Set
in_hauled=1, resetuw_events_since_hauled=0, persist (noinit + CRC16), log INFO
- Set
In on_underwater_event():
- If
in_hauled=1: incrementuw_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.
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.
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.
| 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 (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) |
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_fixreturns false → TX falls back toprocess_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.
-
HM-1
mark_config_mode()helper: factored mode-transition logging into a single helper. Previous code repeated theif (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_exittrace tags underVALIDATION_LOG_ENABLE. -
HM-2 ambiguous HMP12+HMP13 WARN: gated by
!=HAULEDtransition 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.
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.
HAULED relies on last_uw_event_rtc and current RTC. Two robustness paths:
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.
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();
}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.
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.
Symptom: device stays in HAULED for the whole deployment, TX cadence at HMP11 interval, no dive events in field log.
Diagnosis chain:
- If SWS analog frontend / SAADC fails while in HAULED,
read_analog_sws()returnsADC_READ_ERROR. AfterMAX_CONSECUTIVE_INVALID_ADC=60ticks (~1 min), the detector force-flips to state=0 (surface) (M1). - State=surface emits
UW=falseevents, NOTUW=true. - HAULED→AT_SEA exit requires HMP02 consecutive
UW=trueevents. - 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.
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.
Returns: air, water, threshold, hysteresis, raw_adc, filtered_adc, calibrated, underwater, time_in_state, surface_level, contrast_x10, observed_peak, sample_delay_us
-
$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,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).
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) |
| 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) |
See §E.4.
| 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 |
Check: $SWSST → threshold_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.
Check: $SWSST → surface_level. If always 0, no L-override triggers.
Common causes:
- Sample delay too high (
sample_delay_usstuck 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.
- Increase sampling frequency: lower
SAMPLING_UNDER_FREQto 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).
-
observed_peak_adccaps 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 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
$SWSCALguided calibration before first deployment for best cold-start behavior. -
$SCALR,8,2/3/4shows persisted running state.
See §E.7.
HAULED engaged with REUSE_LAST — expected behavior. Animal is likely beached/dead. Check in_hauled via STATR.
HAULED engaged correctly. Animal is inactive.
Animal genuinely inactive — check biology. Could also be SWS calibration issue (false-surface). Pre-deploy bench test recommended.
RTC rollback (virtual → real or vice versa); self-heals via M1b.
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.
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.
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.
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=ONinbuild_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.
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.
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.
- Source:
core/services/sws_analog_service.cpp/hpp,core/services/uwdetector_service.hpp/cpp,core/services/hauled_mode_service.cpp/hpp,core/services/service.cpp - Robustness audit:
.claude/sws_robustness_analysis.md - Adaptive water DOWN feature (deferred):
.claude/sws_adaptive_water_down.md - RTC strategy: 10 — GPS Guide § Part E
- Sealed-device hardening (boot-fail counter, exception barriers, defense layers summary): 07 — Architecture § Sealed-device hardening
- Rolling rate limiter: 11 — Satellite Communication § E.4
- Argos TX modes: 11 — Satellite Communication § Part B
- Cross-reference: ConfigMode cascade in
core/configuration/config_store.hpp::get_config_mode()