sprint-5: True Course + Track Keeping + XTE + PGN 129026/129284

- Python: NmeaNavData (COG/SOG/XTE data models with staleness tracking)
- Python: TrueCoursePilot with TRUE_COURSE and TRACK_KEEPING modes
- Python: 26 new tests (test_nmea_data, test_true_course)
- Modbus: COG/SOG/XTE input registers + TC setpoint/XTE-gain holdings
- Firmware: nmea2000_consumer handles PGN 129026 + 129284
- Firmware: pid_outer_task wired for TC + TK modes with live SOG scheduling
- YAML regenerated; 284 tests pass, firmware compiles clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:12:57 -04:00
parent 0be60c5161
commit 0f00ad10da
12 changed files with 1051 additions and 101 deletions
+117
View File
@@ -0,0 +1,117 @@
"""NMEA 2000 navigation data models.
Holds the live data received from PGN 127250/127251 (heading/ROT -- Sprint 3)
and the new PGN 129026/129284 (COG+SOG / XTE+waypoint -- Sprint 5).
All timestamps are float seconds (time.monotonic()).
NaN means the value has never been received.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
@dataclass
class HeadingData:
"""PGN 127250 + 127251 -- magnetic heading and rate of turn."""
heading_deg: float = math.nan
rot_dps: float = 0.0
timestamp: float = field(default_factory=lambda: 0.0)
max_age_s: float = 5.0
def update(self, heading_deg: float, rot_dps: float) -> None:
self.heading_deg = heading_deg
self.rot_dps = rot_dps
self.timestamp = time.monotonic()
@property
def is_valid(self) -> bool:
if math.isnan(self.heading_deg):
return False
return (time.monotonic() - self.timestamp) < self.max_age_s
@property
def age_ms(self) -> int:
return int((time.monotonic() - self.timestamp) * 1000)
@dataclass
class CogSogData:
"""PGN 129026 -- Course Over Ground (true) and Speed Over Ground.
COG is in degrees true (0..360).
SOG is in knots.
"""
cog_deg: float = math.nan
sog_kn: float = math.nan
timestamp: float = field(default_factory=lambda: 0.0)
max_age_s: float = 5.0
def update(self, cog_deg: float, sog_kn: float) -> None:
self.cog_deg = cog_deg % 360.0
self.sog_kn = sog_kn
self.timestamp = time.monotonic()
@property
def is_valid(self) -> bool:
if math.isnan(self.cog_deg) or math.isnan(self.sog_kn):
return False
return (time.monotonic() - self.timestamp) < self.max_age_s
@property
def age_ms(self) -> int:
return int((time.monotonic() - self.timestamp) * 1000)
@dataclass
class XteData:
"""PGN 129284 -- Cross Track Error and active waypoint.
xte_m: distance off track in metres.
Positive → vessel is to starboard of the track line (steer port).
Negative → vessel is to port of the track line (steer starboard).
waypoint_name: free text from the chart plotter, empty if not available.
dtw_m: distance to waypoint in metres (NaN if unknown).
"""
xte_m: float = math.nan
dtw_m: float = math.nan
waypoint_name: str = ""
timestamp: float = field(default_factory=lambda: 0.0)
max_age_s: float = 5.0
def update(
self,
xte_m: float,
dtw_m: float = math.nan,
waypoint_name: str = "",
) -> None:
self.xte_m = xte_m
self.dtw_m = dtw_m
self.waypoint_name = waypoint_name
self.timestamp = time.monotonic()
@property
def is_valid(self) -> bool:
if math.isnan(self.xte_m):
return False
return (time.monotonic() - self.timestamp) < self.max_age_s
@property
def age_ms(self) -> int:
return int((time.monotonic() - self.timestamp) * 1000)
@dataclass
class NmeaNavData:
"""Aggregated live navigation data used by the autopilot control loop."""
heading: HeadingData = field(default_factory=HeadingData)
cog_sog: CogSogData = field(default_factory=CogSogData)
xte: XteData = field(default_factory=XteData)
@@ -91,6 +91,12 @@ INPUTS: dict[str, Reg] = {
"PID_OUTER_KP_X1000": Reg(addr=54, name="PID_OUTER_KP_X1000", desc='Outer-loop active kp * 1000', unit="", scale=0.001, offset=0.0),
"PID_OUTER_KI_X1000": Reg(addr=55, name="PID_OUTER_KI_X1000", desc='Outer-loop active ki * 1000', unit="", scale=0.001, offset=0.0),
"PID_OUTER_KD_X1000": Reg(addr=56, name="PID_OUTER_KD_X1000", desc='Outer-loop active kd * 1000', unit="", scale=0.001, offset=0.0),
"COG_DEG_X100": Reg(addr=60, name="COG_DEG_X100", desc='Course Over Ground (true) from PGN 129026, deg*100 (0..35999)', unit="deg", scale=0.01, offset=0.0),
"SOG_KN_X10": Reg(addr=61, name="SOG_KN_X10", desc='Speed Over Ground from PGN 129026, knots*10 (unsigned)', unit="kn", scale=0.1, offset=0.0),
"COG_AGE_MS": Reg(addr=62, name="COG_AGE_MS", desc='Milliseconds since last COG/SOG update (0..60000)', unit="ms", scale=1.0, offset=0.0),
"XTE_DM_SIGNED": Reg(addr=63, name="XTE_DM_SIGNED", desc='Cross Track Error from PGN 129284, decimetres (signed int16, +stbd/-port)', unit="dm", scale=0.1, offset=0.0),
"XTE_AGE_MS": Reg(addr=64, name="XTE_AGE_MS", desc='Milliseconds since last XTE update (0..60000)', unit="ms", scale=1.0, offset=0.0),
"DTW_M": Reg(addr=65, name="DTW_M", desc='Distance to next waypoint, metres (unsigned int16, 0..65535)', unit="m", scale=1.0, offset=0.0),
}
# HOLDINGS
@@ -109,6 +115,9 @@ HOLDINGS: dict[str, Reg] = {
"PID_OUTER_KP_REQ_X1000": Reg(addr=26, name="PID_OUTER_KP_REQ_X1000", desc='Requested outer-loop base kp * 1000', unit="", scale=0.001, offset=0.0),
"PID_OUTER_KI_REQ_X1000": Reg(addr=27, name="PID_OUTER_KI_REQ_X1000", desc='Requested outer-loop base ki * 1000', unit="", scale=0.001, offset=0.0),
"PID_OUTER_KD_REQ_X1000": Reg(addr=28, name="PID_OUTER_KD_REQ_X1000", desc='Requested outer-loop base kd * 1000', unit="", scale=0.001, offset=0.0),
"TRUE_COURSE_SP_X100": Reg(addr=32, name="TRUE_COURSE_SP_X100", desc='Desired COG setpoint for TRUE_COURSE / TRACK_KEEPING, deg*100 (0..35999)', unit="deg", scale=0.01, offset=0.0),
"XTE_GAIN_X1000": Reg(addr=33, name="XTE_GAIN_X1000", desc='XTE correction gain * 1000 (deg of heading correction per metre of XTE)', unit="", scale=0.001, offset=0.0),
"XTE_MAX_CORRECTION_X100": Reg(addr=34, name="XTE_MAX_CORRECTION_X100", desc='Maximum XTE heading correction, deg*100 (default 2000 = 20 deg)', unit="deg", scale=0.01, offset=0.0),
}
ALL_GROUPS: dict[str, dict[str, Reg]] = {
+170
View File
@@ -0,0 +1,170 @@
"""True Course and Track Keeping pilot -- Sprint 5.
TRUE_COURSE mode
----------------
Holds a fixed Course Over Ground (COG) instead of compass heading.
This compensates for current/wind drift: if the vessel is pushed sideways,
COG drifts and the outer PID corrects heading to restore the intended track.
Input: cog_setpoint_deg, measured_cog_deg, sog_kn, rot_dps
Output: rudder_setpoint_deg (passed to inner PID)
TRACK_KEEPING mode
------------------
Follows an ECDIS/chart-plotter route using XTE feedback.
An XTE proportional term adds a heading correction to the COG setpoint:
xte_correction_deg = -xte_gain * xte_m (clamped ± max_correction)
effective_setpoint = cog_setpoint_deg + xte_correction_deg
Sign convention (NMEA 0183 / IEC 61162-1):
XTE > 0 → vessel is to starboard of track → steer port → negative correction
XTE < 0 → vessel is to port of track → steer stbd → positive correction
The correction is clamped so the autopilot cannot be tricked into dangerous
large heading swings by a stale or erroneous XTE feed.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from arautopilot.studio.simulator.pid_outer import (
PidOuter,
PidOuterConfig,
PidOuterState,
)
from arautopilot.studio.simulator.vessel_heading import heading_error_deg
@dataclass
class TrueCourseConfig:
"""Configuration for both TRUE_COURSE and TRACK_KEEPING modes."""
# Inner PID outer loop config (reused — same dynamics, different input)
pid: PidOuterConfig = field(default_factory=PidOuterConfig)
# XTE correction parameters (TRACK_KEEPING only)
xte_gain: float = 0.5 # deg of heading correction per metre of XTE
xte_max_correction_deg: float = 20.0 # hard clamp on XTE term
xte_max_age_s: float = 5.0 # treat XTE as stale beyond this
@dataclass
class TrueCourseState:
xte_correction_deg: float = 0.0
effective_setpoint_deg: float = 0.0
class TrueCoursePilot:
"""Outer-loop pilot for TRUE_COURSE and TRACK_KEEPING modes.
Wraps PidOuter, substituting COG for heading and optionally adding
an XTE proportional correction to the setpoint.
"""
def __init__(self, config: TrueCourseConfig | None = None) -> None:
self.config = config or TrueCourseConfig()
self._pid = PidOuter(self.config.pid)
self.tc_state = TrueCourseState()
def reset(self) -> None:
self._pid.reset()
self.tc_state = TrueCourseState()
def update_config(self, config: TrueCourseConfig) -> None:
self.config = config
self._pid.update_config(config.pid)
# ------------------------------------------------------------------
# TRUE_COURSE step
# ------------------------------------------------------------------
def step_true_course(
self,
*,
cog_setpoint_deg: float,
measured_cog_deg: float,
sog_kn: float,
rot_dps: float = 0.0,
allowed: bool = True,
) -> float:
"""Pure COG-hold step (no XTE). Returns rudder setpoint (deg)."""
self.tc_state.effective_setpoint_deg = cog_setpoint_deg
self.tc_state.xte_correction_deg = 0.0
return self._pid.step(
heading_setpoint_deg=cog_setpoint_deg,
heading_measured_deg=measured_cog_deg,
rate_of_turn_dps=rot_dps,
speed_kn=sog_kn,
allowed=allowed,
)
# ------------------------------------------------------------------
# TRACK_KEEPING step
# ------------------------------------------------------------------
def step_track_keeping(
self,
*,
cog_setpoint_deg: float,
measured_cog_deg: float,
sog_kn: float,
rot_dps: float = 0.0,
xte_m: float = math.nan,
xte_age_s: float = 0.0,
allowed: bool = True,
) -> float:
"""COG-hold with XTE correction. Returns rudder setpoint (deg).
If XTE is NaN or stale the correction is zeroed and the pilot
falls back to pure COG hold — safer than going to STANDBY.
"""
cfg = self.config
xte_valid = (
not math.isnan(xte_m)
and xte_age_s < cfg.xte_max_age_s
)
if xte_valid:
raw_correction = -cfg.xte_gain * xte_m
correction = _clamp(
raw_correction,
-cfg.xte_max_correction_deg,
cfg.xte_max_correction_deg,
)
else:
correction = 0.0
self.tc_state.xte_correction_deg = correction
effective_sp = (cog_setpoint_deg + correction) % 360.0
self.tc_state.effective_setpoint_deg = effective_sp
return self._pid.step(
heading_setpoint_deg=effective_sp,
heading_measured_deg=measured_cog_deg,
rate_of_turn_dps=rot_dps,
speed_kn=sog_kn,
allowed=allowed,
)
# ------------------------------------------------------------------
# Accessors
# ------------------------------------------------------------------
@property
def pid_state(self) -> PidOuterState:
return self._pid.state
@property
def last_rudder_setpoint_deg(self) -> float:
return self._pid.state.last_output_deg
def _clamp(x: float, lo: float, hi: float) -> float:
if x < lo:
return lo
if x > hi:
return hi
return x
+100
View File
@@ -0,0 +1,100 @@
"""Tests for arautopilot.core.nmea_data -- Sprint 5."""
from __future__ import annotations
import math
import time
from unittest.mock import patch
import pytest
from arautopilot.core.nmea_data import CogSogData, HeadingData, NmeaNavData, XteData
class TestCogSogData:
def test_initial_state_invalid(self):
d = CogSogData()
assert not d.is_valid
assert math.isnan(d.cog_deg)
assert math.isnan(d.sog_kn)
def test_update_marks_valid(self):
d = CogSogData()
d.update(270.0, 8.5)
assert d.is_valid
assert d.cog_deg == pytest.approx(270.0)
assert d.sog_kn == pytest.approx(8.5)
def test_cog_wraps_360(self):
d = CogSogData()
d.update(370.0, 5.0)
assert d.cog_deg == pytest.approx(10.0)
def test_cog_zero_stays_zero(self):
d = CogSogData()
d.update(0.0, 5.0)
assert d.cog_deg == pytest.approx(0.0)
def test_stale_after_max_age(self):
d = CogSogData(max_age_s=1.0)
d.update(90.0, 6.0)
assert d.is_valid
with patch("arautopilot.core.nmea_data.time") as mock_time:
mock_time.monotonic.return_value = d.timestamp + 2.0
assert not d.is_valid
def test_age_ms(self):
d = CogSogData()
d.update(180.0, 10.0)
assert d.age_ms >= 0
class TestXteData:
def test_initial_invalid(self):
d = XteData()
assert not d.is_valid
assert math.isnan(d.xte_m)
def test_update_positive_xte(self):
"""Positive XTE = vessel to starboard of track."""
d = XteData()
d.update(xte_m=5.0, dtw_m=200.0, waypoint_name="WP01")
assert d.is_valid
assert d.xte_m == pytest.approx(5.0)
assert d.dtw_m == pytest.approx(200.0)
assert d.waypoint_name == "WP01"
def test_update_negative_xte(self):
d = XteData()
d.update(xte_m=-3.5)
assert d.xte_m == pytest.approx(-3.5)
def test_stale_after_max_age(self):
d = XteData(max_age_s=2.0)
d.update(1.0)
with patch("arautopilot.core.nmea_data.time") as mock_time:
mock_time.monotonic.return_value = d.timestamp + 3.0
assert not d.is_valid
def test_update_without_optional_fields(self):
d = XteData()
d.update(xte_m=0.0)
assert d.is_valid
assert math.isnan(d.dtw_m)
assert d.waypoint_name == ""
class TestNmeaNavData:
def test_aggregate_has_all_fields(self):
nav = NmeaNavData()
assert isinstance(nav.heading, HeadingData)
assert isinstance(nav.cog_sog, CogSogData)
assert isinstance(nav.xte, XteData)
def test_independent_updates(self):
nav = NmeaNavData()
nav.cog_sog.update(45.0, 7.0)
nav.xte.update(2.5)
assert nav.cog_sog.is_valid
assert nav.xte.is_valid
assert not nav.heading.is_valid
+180
View File
@@ -0,0 +1,180 @@
"""Tests for TrueCoursePilot -- Sprint 5."""
from __future__ import annotations
import math
import pytest
from arautopilot.studio.simulator.true_course import TrueCoursePilot, TrueCourseConfig
from arautopilot.studio.simulator.pid_outer import PidOuterConfig
def _pilot(xte_gain: float = 0.5, max_corr: float = 20.0) -> TrueCoursePilot:
cfg = TrueCourseConfig(
pid=PidOuterConfig(base_kp=1.0, base_ki=0.0, base_kd=0.0,
rot_ff_gain=0.0, rate_limit_dps=360.0),
xte_gain=xte_gain,
xte_max_correction_deg=max_corr,
)
return TrueCoursePilot(cfg)
# ---------------------------------------------------------------------------
# TRUE_COURSE mode
# ---------------------------------------------------------------------------
class TestTrueCoursePilot:
def test_zero_error_zero_output(self):
p = _pilot()
out = p.step_true_course(
cog_setpoint_deg=90.0,
measured_cog_deg=90.0,
sog_kn=8.0,
)
assert out == pytest.approx(0.0, abs=0.6)
def test_positive_error_steer_starboard(self):
"""COG is west of setpoint → steer to starboard (positive rudder)."""
p = _pilot()
out = p.step_true_course(
cog_setpoint_deg=90.0,
measured_cog_deg=80.0, # 10° behind
sog_kn=8.0,
)
assert out > 0.0
def test_negative_error_steer_port(self):
p = _pilot()
out = p.step_true_course(
cog_setpoint_deg=90.0,
measured_cog_deg=100.0,
sog_kn=8.0,
)
assert out < 0.0
def test_360_boundary_crossing(self):
"""Setpoint=5°, measured=355° → shortest arc = +10° → steer stbd."""
p = _pilot()
out = p.step_true_course(
cog_setpoint_deg=5.0,
measured_cog_deg=355.0,
sog_kn=8.0,
)
assert out > 0.0
def test_not_allowed_returns_zero(self):
p = _pilot()
out = p.step_true_course(
cog_setpoint_deg=90.0,
measured_cog_deg=80.0,
sog_kn=8.0,
allowed=False,
)
assert out == pytest.approx(0.0)
def test_xte_correction_zero_in_true_course_mode(self):
p = _pilot()
p.step_true_course(cog_setpoint_deg=90.0, measured_cog_deg=90.0, sog_kn=8.0)
assert p.tc_state.xte_correction_deg == pytest.approx(0.0)
# ---------------------------------------------------------------------------
# TRACK_KEEPING mode
# ---------------------------------------------------------------------------
class TestTrackKeeping:
def test_no_xte_correction_when_nan(self):
p = _pilot()
p.step_track_keeping(
cog_setpoint_deg=90.0,
measured_cog_deg=90.0,
sog_kn=8.0,
xte_m=math.nan,
)
assert p.tc_state.xte_correction_deg == pytest.approx(0.0)
assert p.tc_state.effective_setpoint_deg == pytest.approx(90.0)
def test_positive_xte_steers_port(self):
"""XTE>0 = vessel to starboard → correction negative → setpoint shifted port."""
p = _pilot(xte_gain=1.0)
p.step_track_keeping(
cog_setpoint_deg=90.0,
measured_cog_deg=90.0,
sog_kn=8.0,
xte_m=5.0, # 5 m to starboard
)
assert p.tc_state.xte_correction_deg == pytest.approx(-5.0)
assert p.tc_state.effective_setpoint_deg == pytest.approx(85.0)
def test_negative_xte_steers_starboard(self):
p = _pilot(xte_gain=1.0)
p.step_track_keeping(
cog_setpoint_deg=90.0,
measured_cog_deg=90.0,
sog_kn=8.0,
xte_m=-5.0,
)
assert p.tc_state.xte_correction_deg == pytest.approx(5.0)
assert p.tc_state.effective_setpoint_deg == pytest.approx(95.0)
def test_xte_correction_clamped(self):
p = _pilot(xte_gain=1.0, max_corr=10.0)
p.step_track_keeping(
cog_setpoint_deg=90.0,
measured_cog_deg=90.0,
sog_kn=8.0,
xte_m=50.0, # huge XTE → clamp to 10°
)
assert p.tc_state.xte_correction_deg == pytest.approx(-10.0)
def test_stale_xte_ignored(self):
"""If xte_age_s > max_age_s, correction must be zero."""
p = _pilot(xte_gain=1.0)
p.step_track_keeping(
cog_setpoint_deg=90.0,
measured_cog_deg=90.0,
sog_kn=8.0,
xte_m=10.0,
xte_age_s=10.0, # stale (default max_age_s=5)
)
assert p.tc_state.xte_correction_deg == pytest.approx(0.0)
def test_convergence_to_track(self):
"""Simulate 30 steps: XTE should shrink (very basic closed-loop sanity)."""
from arautopilot.studio.simulator.vessel_heading import heading_error_deg
p = TrueCoursePilot(TrueCourseConfig(
pid=PidOuterConfig(base_kp=2.0, base_ki=0.0, base_kd=0.0,
rot_ff_gain=0.0, rate_limit_dps=360.0,
max_rudder_deg=35.0),
xte_gain=0.5,
xte_max_correction_deg=20.0,
))
xte = 20.0 # 20 m to starboard
cog = 90.0 # vessel COG
dt = 0.1 # 10 Hz
sog = 5.0 # kn ≈ 2.57 m/s
for _ in range(200):
rudder = p.step_track_keeping(
cog_setpoint_deg=90.0,
measured_cog_deg=cog,
sog_kn=sog,
xte_m=xte,
)
cog_change = rudder * 0.5 * dt
cog = (cog + cog_change) % 360.0
# Cross-track rate: -SOG * cos(COG) for east-going track (COG=90 nominal)
xte -= sog * 0.5144 * math.cos(math.radians(cog)) * dt
assert abs(xte) < 18.0 # must have improved from initial 20 m
def test_reset_clears_state(self):
p = _pilot()
p.step_track_keeping(
cog_setpoint_deg=90.0, measured_cog_deg=80.0,
sog_kn=8.0, xte_m=5.0,
)
p.reset()
assert p.pid_state.integral == pytest.approx(0.0)
@@ -112,6 +112,14 @@ inputs:
- { addr: 55, name: PID_OUTER_KI_X1000, desc: "Outer-loop active ki * 1000", scale: 0.001 }
- { addr: 56, name: PID_OUTER_KD_X1000, desc: "Outer-loop active kd * 1000", scale: 0.001 }
# ----- Sprint 5: True Course + Track Keeping (PGN 129026 / 129284) -----
- { addr: 60, name: COG_DEG_X100, desc: "Course Over Ground (true) from PGN 129026, deg*100 (0..35999)", unit: "deg", scale: 0.01 }
- { addr: 61, name: SOG_KN_X10, desc: "Speed Over Ground from PGN 129026, knots*10 (unsigned)", unit: "kn", scale: 0.1 }
- { addr: 62, name: COG_AGE_MS, desc: "Milliseconds since last COG/SOG update (0..60000)", unit: "ms" }
- { addr: 63, name: XTE_DM_SIGNED, desc: "Cross Track Error from PGN 129284, decimetres (signed int16, +stbd/-port)", unit: "dm", scale: 0.1 }
- { addr: 64, name: XTE_AGE_MS, desc: "Milliseconds since last XTE update (0..60000)", unit: "ms" }
- { addr: 65, name: DTW_M, desc: "Distance to next waypoint, metres (unsigned int16, 0..65535)", unit: "m" }
# -----------------------------------------------------------------------------
# Holding registers (read-write 16-bit words) -- setpoints and config
# -----------------------------------------------------------------------------
@@ -134,3 +142,8 @@ holdings:
- { addr: 26, name: PID_OUTER_KP_REQ_X1000, desc: "Requested outer-loop base kp * 1000", scale: 0.001 }
- { addr: 27, name: PID_OUTER_KI_REQ_X1000, desc: "Requested outer-loop base ki * 1000", scale: 0.001 }
- { addr: 28, name: PID_OUTER_KD_REQ_X1000, desc: "Requested outer-loop base kd * 1000", scale: 0.001 }
# ----- Sprint 5: True Course + Track Keeping -----
- { addr: 32, name: TRUE_COURSE_SP_X100, desc: "Desired COG setpoint for TRUE_COURSE / TRACK_KEEPING, deg*100 (0..35999)", unit: "deg", scale: 0.01 }
- { addr: 33, name: XTE_GAIN_X1000, desc: "XTE correction gain * 1000 (deg of heading correction per metre of XTE)", scale: 0.001 }
- { addr: 34, name: XTE_MAX_CORRECTION_X100, desc: "Maximum XTE heading correction, deg*100 (default 2000 = 20 deg)", unit: "deg", scale: 0.01 }
@@ -1,10 +1,11 @@
// =============================================================================
// pid_outer_task.cpp -- 10 Hz outer-loop (heading control) task
// pid_outer_task.cpp -- 10 Hz outer-loop task (Sprint 3 / Sprint 5)
// =============================================================================
#include "pid_outer_task.h"
#include <Arduino.h>
#include <cmath>
#include "../modes/standby.h"
#include "../protocols/nmea2000_consumer.h"
@@ -20,10 +21,28 @@ constexpr const char* TAG = "AR/PID";
portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED;
PidOuter g_outer{};
float g_heading_setpoint_deg = 0.0f;
float g_heading_setpoint_deg = 0.0f;
float g_cog_setpoint_deg = 0.0f;
float g_xte_gain = 0.5f; // deg / metre
float g_xte_max_correction = 20.0f; // deg
float g_last_rudder_setpoint_deg = 0.0f;
float g_last_error_deg = 0.0f;
float g_speed_kn = 15.0f; // Sprint 3 default until PGN 129026 wiring (Sprint 5)
float g_last_error_deg = 0.0f;
float g_last_xte_correction_deg = 0.0f;
float g_speed_kn = 15.0f;
// ---------------------------------------------------------------------------
// XTE correction helper (TRACK_KEEPING)
// xte_m > 0 → vessel to starboard → steer port → negative correction
// ---------------------------------------------------------------------------
static float _xte_correction(float xte_m, float gain, float max_deg) {
if (std::isnan(xte_m)) return 0.0f;
float c = -gain * xte_m;
if (c > max_deg) c = max_deg;
if (c < -max_deg) c = -max_deg;
return c;
}
void OuterLoopTask(void* /*pv*/) {
AR_LOGI(TAG, "pid_outer task started on core %d (10 Hz)", xPortGetCoreID());
@@ -31,46 +50,93 @@ void OuterLoopTask(void* /*pv*/) {
TickType_t last_wake = xTaskGetTickCount();
for (;;) {
// Snapshot inputs we need atomically.
float setpoint;
float speed_kn;
float heading_sp, cog_sp, xte_gain, xte_max, speed_kn;
portENTER_CRITICAL(&g_mux);
setpoint = g_heading_setpoint_deg;
speed_kn = g_speed_kn;
heading_sp = g_heading_setpoint_deg;
cog_sp = g_cog_setpoint_deg;
xte_gain = g_xte_gain;
xte_max = g_xte_max_correction;
speed_kn = g_speed_kn;
portEXIT_CRITICAL(&g_mux);
const auto n2k = protocols::nmea2000::nmea2000_latest();
const auto n2k = protocols::nmea2000::nmea2000_latest();
const auto cog_sog = protocols::nmea2000::nmea2000_cog_sog();
const auto nav_data = protocols::nmea2000::nmea2000_nav_data();
const auto mode = modes::current_mode();
// Only active in HEADING_HOLD with valid heading sensor.
const bool in_hh =
modes::current_mode() == modes::Mode::HEADING_HOLD;
const bool allowed = in_hh && n2k.heading_valid;
const bool in_hh = (mode == modes::Mode::HEADING_HOLD);
const bool in_tc = (mode == modes::Mode::TRUE_COURSE);
const bool in_tk = (mode == modes::Mode::TRACK_KEEPING);
const float rudder_sp = g_outer.step(
setpoint,
n2k.heading_deg,
n2k.rot_valid ? n2k.rate_of_turn_dps : 0.0f,
speed_kn,
allowed
);
float rudder_sp = 0.0f;
float error = 0.0f;
float xte_corr = 0.0f;
// Always push the outer-loop output downstream. If `allowed` is
// false the output is zero, which corresponds to "rudder centred";
// the inner loop will pursue that or its own externally-supplied
// setpoint depending on the cascade configuration. In HH mode,
// the outer loop owns the inner setpoint.
if (in_hh) {
// ---------------------------------------------------------------
// HEADING_HOLD: feedback = magnetic/true heading (PGN 127250)
// ---------------------------------------------------------------
const bool allowed = n2k.heading_valid;
rudder_sp = g_outer.step(
heading_sp,
n2k.heading_deg,
n2k.rot_valid ? n2k.rate_of_turn_dps : 0.0f,
speed_kn,
allowed
);
error = allowed ? (heading_sp - n2k.heading_deg) : 0.0f;
pid_inner_set_setpoint_deg(rudder_sp);
}
const float err =
n2k.heading_valid
? (setpoint - n2k.heading_deg)
: 0.0f;
} else if (in_tc) {
// ---------------------------------------------------------------
// TRUE_COURSE: feedback = COG (PGN 129026)
// ---------------------------------------------------------------
const bool allowed = cog_sog.valid;
const float sog = cog_sog.valid ? cog_sog.sog_kn : speed_kn;
rudder_sp = g_outer.step(
cog_sp,
cog_sog.cog_deg,
n2k.rot_valid ? n2k.rate_of_turn_dps : 0.0f,
sog,
allowed
);
error = allowed ? (cog_sp - cog_sog.cog_deg) : 0.0f;
pid_inner_set_setpoint_deg(rudder_sp);
} else if (in_tk) {
// ---------------------------------------------------------------
// TRACK_KEEPING: COG hold + XTE proportional correction
// ---------------------------------------------------------------
const bool cog_ok = cog_sog.valid;
const bool xte_ok = nav_data.valid;
xte_corr = xte_ok ? _xte_correction(nav_data.xte_m, xte_gain, xte_max) : 0.0f;
const float eff_sp = fmodf(cog_sp + xte_corr + 360.0f, 360.0f);
const float sog = cog_ok ? cog_sog.sog_kn : speed_kn;
rudder_sp = g_outer.step(
eff_sp,
cog_sog.cog_deg,
n2k.rot_valid ? n2k.rate_of_turn_dps : 0.0f,
sog,
cog_ok
);
error = cog_ok ? (cog_sp - cog_sog.cog_deg) : 0.0f;
pid_inner_set_setpoint_deg(rudder_sp);
} else {
// ---------------------------------------------------------------
// STANDBY / DODGE: idle tick — bleed integrator, don't push
// ---------------------------------------------------------------
g_outer.step(0.0f, 0.0f, 0.0f, speed_kn, false);
}
portENTER_CRITICAL(&g_mux);
g_last_rudder_setpoint_deg = rudder_sp;
g_last_error_deg = err;
g_last_error_deg = error;
g_last_xte_correction_deg = xte_corr;
if (in_tc || in_tk) {
// Update SOG from live data
if (cog_sog.valid) g_speed_kn = cog_sog.sog_kn;
}
portEXIT_CRITICAL(&g_mux);
safety::watchdog_feed();
@@ -80,15 +146,17 @@ void OuterLoopTask(void* /*pv*/) {
} // namespace
// ---------------------------------------------------------------------------
// Init / start
// ---------------------------------------------------------------------------
void pid_outer_task_init() {
PidOuterConfig cfg;
// Seed the 3-point gain schedule from the 30 m yacht profile so the
// firmware has sensible defaults out of the box.
cfg.schedule_size = 3;
cfg.schedule[0] = {5.0f, 1.20f, 0.03f, 0.80f};
cfg.schedule[1] = {15.0f, 0.90f, 0.02f, 1.20f};
cfg.schedule[2] = {28.0f, 0.55f, 0.01f, 1.80f};
cfg.rot_ff_gain = 1.5f;
cfg.schedule_size = 3;
cfg.schedule[0] = {5.0f, 1.20f, 0.03f, 0.80f};
cfg.schedule[1] = {15.0f, 0.90f, 0.02f, 1.20f};
cfg.schedule[2] = {28.0f, 0.55f, 0.01f, 1.80f};
cfg.rot_ff_gain = 1.5f;
g_outer.update_config(cfg);
g_outer.reset();
AR_LOGI(TAG,
@@ -102,10 +170,12 @@ void pid_outer_task_start() {
AR_TASK_CORE_REALTIME);
}
void pid_outer_set_heading_setpoint_deg(float setpoint_deg) {
// Normalise to [0, 360).
float sp = setpoint_deg;
while (sp < 0.0f) sp += 360.0f;
// ---------------------------------------------------------------------------
// Setpoints
// ---------------------------------------------------------------------------
void pid_outer_set_heading_setpoint_deg(float sp) {
while (sp < 0.0f) sp += 360.0f;
while (sp >= 360.0f) sp -= 360.0f;
portENTER_CRITICAL(&g_mux);
g_heading_setpoint_deg = sp;
@@ -119,6 +189,58 @@ float pid_outer_heading_setpoint_deg() {
return v;
}
void pid_outer_set_cog_setpoint_deg(float sp) {
while (sp < 0.0f) sp += 360.0f;
while (sp >= 360.0f) sp -= 360.0f;
portENTER_CRITICAL(&g_mux);
g_cog_setpoint_deg = sp;
portEXIT_CRITICAL(&g_mux);
}
float pid_outer_cog_setpoint_deg() {
portENTER_CRITICAL(&g_mux);
float v = g_cog_setpoint_deg;
portEXIT_CRITICAL(&g_mux);
return v;
}
// ---------------------------------------------------------------------------
// XTE parameters
// ---------------------------------------------------------------------------
void pid_outer_set_xte_gain(float gain) {
if (gain < 0.0f) gain = 0.0f;
portENTER_CRITICAL(&g_mux);
g_xte_gain = gain;
portEXIT_CRITICAL(&g_mux);
}
float pid_outer_xte_gain() {
portENTER_CRITICAL(&g_mux);
float v = g_xte_gain;
portEXIT_CRITICAL(&g_mux);
return v;
}
void pid_outer_set_xte_max_correction(float deg) {
if (deg < 0.0f) deg = 0.0f;
if (deg > 90.0f) deg = 90.0f;
portENTER_CRITICAL(&g_mux);
g_xte_max_correction = deg;
portEXIT_CRITICAL(&g_mux);
}
float pid_outer_xte_max_correction() {
portENTER_CRITICAL(&g_mux);
float v = g_xte_max_correction;
portEXIT_CRITICAL(&g_mux);
return v;
}
// ---------------------------------------------------------------------------
// Telemetry
// ---------------------------------------------------------------------------
float pid_outer_last_rudder_setpoint_deg() {
portENTER_CRITICAL(&g_mux);
float v = g_last_rudder_setpoint_deg;
@@ -133,8 +255,19 @@ float pid_outer_last_error_deg() {
return v;
}
float pid_outer_last_xte_correction_deg() {
portENTER_CRITICAL(&g_mux);
float v = g_last_xte_correction_deg;
portEXIT_CRITICAL(&g_mux);
return v;
}
// ---------------------------------------------------------------------------
// SOG / gain scheduling
// ---------------------------------------------------------------------------
void pid_outer_set_speed_kn(float speed_kn) {
if (speed_kn < 0.0f) speed_kn = 0.0f;
if (speed_kn < 0.0f) speed_kn = 0.0f;
if (speed_kn > 80.0f) speed_kn = 80.0f;
portENTER_CRITICAL(&g_mux);
g_speed_kn = speed_kn;
@@ -150,15 +283,14 @@ float pid_outer_speed_kn() {
void pid_outer_update_gains(float kp, float ki, float kd) {
PidOuterConfig cfg = g_outer.config();
cfg.base_kp = kp;
cfg.base_ki = ki;
cfg.base_kd = kd;
cfg.schedule_size = 0; // explicit gains override the schedule
cfg.base_kp = kp;
cfg.base_ki = ki;
cfg.base_kd = kd;
cfg.schedule_size = 0; // explicit gains disable the schedule
portENTER_CRITICAL(&g_mux);
g_outer.update_config(cfg);
portEXIT_CRITICAL(&g_mux);
AR_LOGI(TAG, "pid_outer base gains updated: kp=%.3f ki=%.3f kd=%.3f "
"(schedule disabled)", kp, ki, kd);
AR_LOGI(TAG, "pid_outer gains updated: kp=%.3f ki=%.3f kd=%.3f", kp, ki, kd);
}
void pid_outer_get_gains(float& kp, float& ki, float& kd) {
@@ -1,11 +1,14 @@
// =============================================================================
// pid_outer_task.h -- 10 Hz outer-loop (heading control) task (Sprint 3)
// pid_outer_task.h -- 10 Hz outer-loop task (Sprint 3 / Sprint 5)
// =============================================================================
//
// Reads heading + ROT from the NMEA 2000 snapshot, computes a rudder
// setpoint, hands it off to the inner loop. Active only in HEADING_HOLD
// mode; in any other mode the task ticks idle (allowed=false) which
// bleeds the integrator.
// Sprint 3: HEADING_HOLD -- holds a magnetic/true heading setpoint.
// Sprint 5: TRUE_COURSE -- holds a COG setpoint (compensates current/wind).
// TRACK_KEEPING -- COG hold + XTE proportional correction.
//
// In all modes the output is a rudder setpoint handed to the inner loop.
// In any other mode (STANDBY, DODGE) the task ticks idle (allowed=false),
// bleeding the integrator so the transition back to active is smooth.
// =============================================================================
#pragma once
@@ -17,24 +20,30 @@ namespace arautopilot::pid {
void pid_outer_task_init();
void pid_outer_task_start();
/// Update the heading the outer loop pursues (degrees, 0..360).
void pid_outer_set_heading_setpoint_deg(float setpoint_deg);
/// Read the active heading setpoint (thread-safe).
// ----- HEADING_HOLD setpoint -----
void pid_outer_set_heading_setpoint_deg(float setpoint_deg);
float pid_outer_heading_setpoint_deg();
/// Read the last rudder setpoint the outer loop produced.
// ----- TRUE_COURSE / TRACK_KEEPING setpoint -----
void pid_outer_set_cog_setpoint_deg(float setpoint_deg);
float pid_outer_cog_setpoint_deg();
// ----- XTE parameters (TRACK_KEEPING) -----
void pid_outer_set_xte_gain(float gain); ///< deg correction / metre
void pid_outer_set_xte_max_correction(float deg); ///< hard clamp
float pid_outer_xte_gain();
float pid_outer_xte_max_correction();
// ----- Telemetry -----
float pid_outer_last_rudder_setpoint_deg();
/// Read the last heading error the controller saw.
float pid_outer_last_error_deg();
float pid_outer_last_xte_correction_deg(); ///< 0 in non-TK modes
/// Override the SOG used for gain scheduling (Sprint 3 keeps a default of
/// 15 kn until PGN 129026 wiring lands in Sprint 5).
void pid_outer_set_speed_kn(float speed_kn);
// ----- SOG / gain scheduling -----
void pid_outer_set_speed_kn(float speed_kn);
float pid_outer_speed_kn();
void pid_outer_update_gains(float kp, float ki, float kd);
void pid_outer_get_gains(float& kp, float& ki, float& kd);
void pid_outer_update_gains(float kp, float ki, float kd);
void pid_outer_get_gains(float& kp, float& ki, float& kd);
} // namespace arautopilot::pid
@@ -77,8 +77,8 @@ constexpr uint16_t COIL_CMD_ACK_ALL_ALARMS = 2;
constexpr uint16_t COIL_CMD_KNOB_ARM = 3;
// ----- Input registers (read-only words) -----
constexpr uint16_t INPUT_COUNT = 30;
constexpr uint16_t INPUT_MAX_ADDR = 56;
constexpr uint16_t INPUT_COUNT = 36;
constexpr uint16_t INPUT_MAX_ADDR = 65;
// Firmware major version
constexpr uint16_t INPUT_FW_VERSION_MAJOR = 0;
@@ -164,10 +164,28 @@ constexpr uint16_t INPUT_PID_OUTER_KI_X1000 = 55;
// Outer-loop active kd * 1000
// scale=0.001
constexpr uint16_t INPUT_PID_OUTER_KD_X1000 = 56;
// Course Over Ground (true) from PGN 129026, deg*100 (0..35999)
// unit=deg, scale=0.01
constexpr uint16_t INPUT_COG_DEG_X100 = 60;
// Speed Over Ground from PGN 129026, knots*10 (unsigned)
// unit=kn, scale=0.1
constexpr uint16_t INPUT_SOG_KN_X10 = 61;
// Milliseconds since last COG/SOG update (0..60000)
// unit=ms
constexpr uint16_t INPUT_COG_AGE_MS = 62;
// Cross Track Error from PGN 129284, decimetres (signed int16, +stbd/-port)
// unit=dm, scale=0.1
constexpr uint16_t INPUT_XTE_DM_SIGNED = 63;
// Milliseconds since last XTE update (0..60000)
// unit=ms
constexpr uint16_t INPUT_XTE_AGE_MS = 64;
// Distance to next waypoint, metres (unsigned int16, 0..65535)
// unit=m
constexpr uint16_t INPUT_DTW_M = 65;
// ----- Holding registers (read-write words) -----
constexpr uint16_t HOLDING_COUNT = 14;
constexpr uint16_t HOLDING_MAX_ADDR = 28;
constexpr uint16_t HOLDING_COUNT = 17;
constexpr uint16_t HOLDING_MAX_ADDR = 34;
// Mode requested by operator (0=STANDBY,1=HH,2=TC,3=TK,4=DODGE)
constexpr uint16_t HOLDING_MODE_REQUEST = 0;
@@ -210,5 +228,14 @@ constexpr uint16_t HOLDING_PID_OUTER_KI_REQ_X1000 = 27;
// Requested outer-loop base kd * 1000
// scale=0.001
constexpr uint16_t HOLDING_PID_OUTER_KD_REQ_X1000 = 28;
// Desired COG setpoint for TRUE_COURSE / TRACK_KEEPING, deg*100 (0..35999)
// unit=deg, scale=0.01
constexpr uint16_t HOLDING_TRUE_COURSE_SP_X100 = 32;
// XTE correction gain * 1000 (deg of heading correction per metre of XTE)
// scale=0.001
constexpr uint16_t HOLDING_XTE_GAIN_X1000 = 33;
// Maximum XTE heading correction, deg*100 (default 2000 = 20 deg)
// unit=deg, scale=0.01
constexpr uint16_t HOLDING_XTE_MAX_CORRECTION_X100 = 34;
} // namespace arautopilot::protocols::modbus
@@ -66,6 +66,10 @@ struct HoldingStorage {
uint16_t pid_outer_kp_req_x1000 = 0;
uint16_t pid_outer_ki_req_x1000 = 0;
uint16_t pid_outer_kd_req_x1000 = 0;
// Sprint 5: True Course + Track Keeping
uint16_t true_course_sp_x100 = 0;
uint16_t xte_gain_x1000 = 500; // 0.5 deg/m default
uint16_t xte_max_correction_x100 = 2000; // 20.0 deg default
};
HoldingStorage g_holding;
@@ -209,6 +213,51 @@ uint16_t read_input_register(uint16_t addr) {
return (uint16_t)scaled;
}
// ----- Sprint 5: COG / SOG / XTE telemetry -----
case INPUT_COG_DEG_X100: {
auto cs = nmea2000::nmea2000_cog_sog();
if (!cs.valid) return 0;
int v = (int)(cs.cog_deg * 100.0f);
if (v < 0) v = 0;
if (v > 35999) v = 35999;
return (uint16_t)v;
}
case INPUT_SOG_KN_X10: {
auto cs = nmea2000::nmea2000_cog_sog();
if (!cs.valid) return 0;
int v = (int)(cs.sog_kn * 10.0f);
if (v < 0) v = 0;
if (v > 65535) v = 65535;
return (uint16_t)v;
}
case INPUT_COG_AGE_MS: {
auto cs = nmea2000::nmea2000_cog_sog();
uint32_t age = cs.valid ? (millis() - cs.age_ms) : 60000;
if (age > 60000) age = 60000;
return (uint16_t)age;
}
case INPUT_XTE_DM_SIGNED: {
auto nd = nmea2000::nmea2000_nav_data();
if (!nd.valid) return 0;
int v = (int)(nd.xte_m * 10.0f); // metres → decimetres
if (v < -32768) v = -32768;
if (v > 32767) v = 32767;
return (uint16_t)(int16_t)v;
}
case INPUT_XTE_AGE_MS: {
auto nd = nmea2000::nmea2000_nav_data();
uint32_t age = nd.valid ? (millis() - nd.age_ms) : 60000;
if (age > 60000) age = 60000;
return (uint16_t)age;
}
case INPUT_DTW_M: {
auto nd = nmea2000::nmea2000_nav_data();
if (!nd.valid) return 0;
uint32_t dtw = (uint32_t)nd.dtw_m;
if (dtw > 65535) dtw = 65535;
return (uint16_t)dtw;
}
default:
return 0;
}
@@ -258,6 +307,9 @@ uint16_t read_holding(uint16_t addr) {
case HOLDING_PID_OUTER_KP_REQ_X1000: return g_holding.pid_outer_kp_req_x1000;
case HOLDING_PID_OUTER_KI_REQ_X1000: return g_holding.pid_outer_ki_req_x1000;
case HOLDING_PID_OUTER_KD_REQ_X1000: return g_holding.pid_outer_kd_req_x1000;
case HOLDING_TRUE_COURSE_SP_X100: return g_holding.true_course_sp_x100;
case HOLDING_XTE_GAIN_X1000: return g_holding.xte_gain_x1000;
case HOLDING_XTE_MAX_CORRECTION_X100: return g_holding.xte_max_correction_x100;
default: return 0;
}
}
@@ -348,6 +400,25 @@ Modbus::Error write_holding(uint16_t addr, uint16_t value) {
pid::pid_inner_update_gains(kp, ki, kd);
return Modbus::Error::SUCCESS;
}
// ----- Sprint 5: True Course + XTE parameters -----
case HOLDING_TRUE_COURSE_SP_X100: {
if (value > 35999) return Modbus::Error::ILLEGAL_DATA_VALUE;
g_holding.true_course_sp_x100 = value;
pid::pid_outer_set_cog_setpoint_deg((float)value * 0.01f);
return Modbus::Error::SUCCESS;
}
case HOLDING_XTE_GAIN_X1000: {
g_holding.xte_gain_x1000 = value;
pid::pid_outer_set_xte_gain((float)value * 0.001f);
return Modbus::Error::SUCCESS;
}
case HOLDING_XTE_MAX_CORRECTION_X100: {
if (value > 9000) return Modbus::Error::ILLEGAL_DATA_VALUE; // 90 deg cap
g_holding.xte_max_correction_x100 = value;
pid::pid_outer_set_xte_max_correction((float)value * 0.01f);
return Modbus::Error::SUCCESS;
}
default:
return Modbus::Error::ILLEGAL_DATA_ADDRESS;
}
@@ -25,6 +25,7 @@ constexpr const char* TAG = "AR/N2K";
constexpr uint32_t STALE_THRESHOLD_MS = 5000;
portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED;
HeadingSnapshot g_snap{
.heading_deg = 0.0f,
.is_true = false,
@@ -35,6 +36,21 @@ HeadingSnapshot g_snap{
.rot_valid = false,
};
CogSogSnapshot g_cog_sog{
.cog_deg = NAN,
.sog_kn = NAN,
.age_ms = 0,
.valid = false,
};
NavDataSnapshot g_nav_data{
.xte_m = NAN,
.dtw_m = NAN,
.wp_name = {},
.age_ms = 0,
.valid = false,
};
float rad_to_deg_pos(float rad) {
float d = rad * (180.0f / (float)M_PI);
// Normalise to 0..360.
@@ -90,19 +106,62 @@ void HandleROT(const tN2kMsg& msg) {
AR_LOGV(TAG, "PGN 127251 ROT=%.3f deg/s", rot_dps);
}
// One global dispatcher because NMEA2000.SetMsgHandler() takes a single
// callback that we have to filter by PGN ourselves.
void HandleCogSog(const tN2kMsg& msg) {
unsigned char sid;
tN2kHeadingReference ref;
double cog = 0.0, sog = 0.0;
if (!ParseN2kCOGSOGRapid(msg, sid, ref, cog, sog)) return;
if (cog > 1e8 || sog > 1e8) return;
const float cog_deg = rad_to_deg_pos((float)cog);
const float sog_kn = (float)(sog * 1.94384); // m/s → knots
const uint32_t now = millis();
portENTER_CRITICAL(&g_mux);
g_cog_sog.cog_deg = cog_deg;
g_cog_sog.sog_kn = sog_kn;
g_cog_sog.age_ms = now;
g_cog_sog.valid = true;
portEXIT_CRITICAL(&g_mux);
AR_LOGV(TAG, "PGN 129026 COG=%.2f deg SOG=%.2f kn", cog_deg, sog_kn);
}
void HandleNavData(const tN2kMsg& msg) {
unsigned char sid;
double dist = 0.0, xte = 0.0;
double origLat = 0.0, origLon = 0.0, destLat = 0.0, destLon = 0.0;
double closingVel = 0.0;
tN2kHeadingReference bearingRef = N2khr_Unavailable;
tN2kDistanceCalculationType calcType = N2kdct_GreatCircle;
bool perpCrossed = false, arrivalAlarm = false;
int16_t origWpNum = 0;
uint32_t destWpNum = 0, eta = 0;
if (!ParseN2kNavigationInfo(
msg, sid, dist, bearingRef,
perpCrossed, arrivalAlarm, calcType, xte,
origWpNum, origLat, origLon,
destWpNum, eta, destLat, destLon, closingVel)) {
return;
}
if (xte > 1e8 || xte < -1e8) return;
const float xte_m = (float)xte;
const float dtw_m = (dist < 1e8) ? (float)dist : NAN;
const uint32_t now = millis();
portENTER_CRITICAL(&g_mux);
g_nav_data.xte_m = xte_m;
g_nav_data.dtw_m = dtw_m;
g_nav_data.age_ms = now;
g_nav_data.valid = true;
portEXIT_CRITICAL(&g_mux);
AR_LOGV(TAG, "PGN 129284 XTE=%.2f m DTW=%.0f m", xte_m, dtw_m);
}
void MessageHandler(const tN2kMsg& msg) {
switch (msg.PGN) {
case 127250L:
HandleHeading(msg);
break;
case 127251L:
HandleROT(msg);
break;
default:
// Sprint 1: ignore everything else.
break;
case 127250L: HandleHeading(msg); break;
case 127251L: HandleROT(msg); break;
case 129026L: HandleCogSog(msg); break;
case 129284L: HandleNavData(msg); break;
default: break;
}
}
@@ -110,7 +169,6 @@ void RxTask(void* /*pv*/) {
AR_LOGI(TAG, "nmea2000_consumer task started on core %d", xPortGetCoreID());
for (;;) {
NMEA2000.ParseMessages();
// Update validity flags based on age.
const uint32_t now = millis();
portENTER_CRITICAL(&g_mux);
if (g_snap.heading_valid && (now - g_snap.heading_age_ms) > STALE_THRESHOLD_MS) {
@@ -120,6 +178,15 @@ void RxTask(void* /*pv*/) {
g_snap.rot_valid = false;
}
portEXIT_CRITICAL(&g_mux);
// Update COG/SOG and nav-data stale flags.
portENTER_CRITICAL(&g_mux);
if (g_cog_sog.valid && (now - g_cog_sog.age_ms) > STALE_THRESHOLD_MS) {
g_cog_sog.valid = false;
}
if (g_nav_data.valid && (now - g_nav_data.age_ms) > STALE_THRESHOLD_MS) {
g_nav_data.valid = false;
}
portEXIT_CRITICAL(&g_mux);
// 100 Hz polling is plenty -- the CAN driver buffers incoming frames.
vTaskDelay(pdMS_TO_TICKS(10));
}
@@ -170,9 +237,29 @@ HeadingSnapshot nmea2000_latest() {
return copy;
}
CogSogSnapshot nmea2000_cog_sog() {
CogSogSnapshot copy;
portENTER_CRITICAL(&g_mux);
copy = g_cog_sog;
portEXIT_CRITICAL(&g_mux);
return copy;
}
NavDataSnapshot nmea2000_nav_data() {
NavDataSnapshot copy;
portENTER_CRITICAL(&g_mux);
copy = g_nav_data;
portEXIT_CRITICAL(&g_mux);
return copy;
}
bool nmea2000_is_stale() {
const auto s = nmea2000_latest();
return !s.heading_valid;
}
bool nmea2000_cog_is_stale() {
return !nmea2000_cog_sog().valid;
}
} // namespace arautopilot::protocols::nmea2000
@@ -2,34 +2,61 @@
// nmea2000_consumer.h -- NMEA 2000 backbone consumer
// =============================================================================
//
// Sprint 1 subscribes to PGN 127250 (Vessel Heading) and PGN 127251
// (Rate of Turn) from the boat's NMEA 2000 backbone. The latest values
// are stashed in a thread-safe snapshot that the Modbus slave (and later
// the PID outer loop) read from.
// Sprint 1: PGN 127250 (Heading) + 127251 (ROT).
// Sprint 5: PGN 129026 (COG/SOG) + 129284 (Navigation Data / XTE).
//
// Later sprints will also subscribe to PGN 129025/129029 (Position),
// 129026 (COG/SOG), 129284 (Navigation Data), 127257 (Attitude).
//
// This module owns the NMEA2000 instance and its dedicated FreeRTOS task
// pinned to Core 0.
// All snapshots are updated from the NMEA 2000 task (Core 0) and
// read from the PID outer-loop task (Core 1) via a portMUX spinlock.
// =============================================================================
#pragma once
#include <cstdint>
#include <cmath>
namespace arautopilot::protocols::nmea2000 {
// ---------------------------------------------------------------------------
// PGN 127250 / 127251 -- heading + ROT
// ---------------------------------------------------------------------------
struct HeadingSnapshot {
float heading_deg; ///< 0..360, magnetic or true depending on source
bool is_true; ///< true if reference is "true north", false if magnetic
float rate_of_turn_dps; ///< signed deg/s; positive = turning to starboard
uint32_t heading_age_ms; ///< millis() at the last 127250 update
uint32_t rot_age_ms; ///< millis() at the last 127251 update
bool heading_valid; ///< true if the heading is fresh (<5 s old)
bool rot_valid; ///< true if the ROT is fresh (<5 s old)
float heading_deg; ///< 0..360, magnetic or true per is_true flag
bool is_true; ///< true = true north reference
float rate_of_turn_dps; ///< signed deg/s; positive = turning to stbd
uint32_t heading_age_ms; ///< millis() at last 127250 update
uint32_t rot_age_ms; ///< millis() at last 127251 update
bool heading_valid; ///< fresh (<5 s)
bool rot_valid; ///< fresh (<5 s)
};
// ---------------------------------------------------------------------------
// PGN 129026 -- Course Over Ground (true) + Speed Over Ground
// ---------------------------------------------------------------------------
struct CogSogSnapshot {
float cog_deg; ///< 0..360, degrees true; NAN until first message
float sog_kn; ///< knots; NAN until first message
uint32_t age_ms; ///< millis() at last update
bool valid; ///< fresh (<5 s) and non-NaN
};
// ---------------------------------------------------------------------------
// PGN 129284 -- Navigation Data (XTE + waypoint)
// ---------------------------------------------------------------------------
struct NavDataSnapshot {
float xte_m; ///< cross-track error, metres; +stbd / -port; NAN if unknown
float dtw_m; ///< distance to waypoint, metres; NAN if unknown
char wp_name[16]; ///< null-terminated waypoint name (truncated at 15 chars)
uint32_t age_ms; ///< millis() at last update
bool valid; ///< fresh (<5 s) and non-NaN
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Initialise the NMEA2000 stack with our PGN handlers.
void nmea2000_consumer_init();
@@ -39,8 +66,16 @@ void nmea2000_consumer_start_task();
/// Thread-safe read of the latest heading + ROT snapshot.
HeadingSnapshot nmea2000_latest();
/// True if either heading_age_ms or rot_age_ms exceeds the stale-threshold
/// (default 5 s, brief section 7).
/// Thread-safe read of the latest COG/SOG snapshot (PGN 129026).
CogSogSnapshot nmea2000_cog_sog();
/// Thread-safe read of the latest navigation data snapshot (PGN 129284).
NavDataSnapshot nmea2000_nav_data();
/// True if heading age or ROT age exceeds 5 s.
bool nmea2000_is_stale();
/// True if COG/SOG age exceeds 5 s or data was never received.
bool nmea2000_cog_is_stale();
} // namespace arautopilot::protocols::nmea2000