sprint-9: integration tests + hardening + operator manual
Integration tests (64 new tests, 462 total): - test_integration_cascade: full cascade closed-loop simulation -- outer PID → inner PID → rudder dynamics → vessel heading; verifies convergence across small/90°/180° turns, wrap-around, and low speed - test_integration_ekf_pid: EKF-smoothed heading feeding outer PID; confirms EKF reduces rudder total-variation vs raw noisy heading - test_integration_alarm_audit: alarm engine → audit log hash-chain; verify, tamper detection, cross-session reload, multi-alarm logging - test_modbus_utils: 38 tests for scale/raw conversion, bounds checking, heading/rudder helpers, signed int16 two's-complement round-trip Hardening: - heading_ekf: guard NaN/inf in update_heading() and update_rot() -- skip bad measurements silently rather than corrupting filter state - adaptive_tuner: guard NaN/inf in step() -- ignore corrupt error samples - modbus_utils.py: new shared module with scale_to_raw, scale_to_raw_signed, raw_signed_to_scaled, clamp_uint16, validate_holding_write, heading_deg_to_raw, rudder_deg_to_raw_signed Documentation: - docs/operator_manual.md: 15-section operator manual covering safety, installation, all operating modes, alarm reference, commissioning, fault-finding, Modbus register summary, and activation/audit log procedure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from arautopilot.core.pid_config import PidConfig, PidGains
|
||||
@@ -75,6 +76,8 @@ class AdaptiveTuner:
|
||||
"""
|
||||
if not self.config.adaptive_enabled:
|
||||
return None
|
||||
if not math.isfinite(error_deg):
|
||||
return None
|
||||
|
||||
self._error_buffer.append(error_deg)
|
||||
if len(self._error_buffer) > self.window_steps:
|
||||
|
||||
@@ -82,6 +82,8 @@ class HeadingEKF:
|
||||
self._P = [new_p00, new_p01, new_p10, new_p11]
|
||||
|
||||
def update_heading(self, measured_deg: float, noise_deg: float = 2.0) -> None:
|
||||
if not math.isfinite(measured_deg) or not math.isfinite(noise_deg):
|
||||
return
|
||||
"""Kalman update step for a heading measurement.
|
||||
|
||||
Parameters
|
||||
@@ -120,6 +122,8 @@ class HeadingEKF:
|
||||
]
|
||||
|
||||
def update_rot(self, measured_rot_dps: float, noise_dps: float = 1.0) -> None:
|
||||
if not math.isfinite(measured_rot_dps) or not math.isfinite(noise_dps):
|
||||
return
|
||||
"""Kalman update step for a rate-of-turn measurement.
|
||||
|
||||
Parameters
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Modbus register utility helpers -- Sprint 9 hardening.
|
||||
|
||||
Provides value scaling, bounds checking, and safe uint16 clamping for
|
||||
register writes. Used by the Studio and CLI tools to validate writes
|
||||
before they are sent on the wire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
UINT16_MAX = 0xFFFF
|
||||
INT16_MIN = -32768
|
||||
INT16_MAX = 32767
|
||||
|
||||
|
||||
def scale_to_raw(physical: float, scale: float, offset: float = 0.0) -> int:
|
||||
"""Convert a physical value to a Modbus raw uint16 (unsigned).
|
||||
|
||||
``raw = round((physical - offset) / scale)``
|
||||
|
||||
Raises ``ValueError`` if the result does not fit in uint16.
|
||||
"""
|
||||
if scale == 0.0:
|
||||
raise ValueError("scale must not be 0")
|
||||
if not math.isfinite(physical):
|
||||
raise ValueError(f"physical value must be finite, got {physical!r}")
|
||||
raw = round((physical - offset) / scale)
|
||||
if raw < 0 or raw > UINT16_MAX:
|
||||
raise ValueError(
|
||||
f"scaled raw value {raw} for physical={physical} out of uint16 range [0, {UINT16_MAX}]"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def raw_to_scaled(raw: int, scale: float, offset: float = 0.0) -> float:
|
||||
"""Convert a raw Modbus uint16 to a physical value."""
|
||||
return raw * scale + offset
|
||||
|
||||
|
||||
def scale_to_raw_signed(physical: float, scale: float, offset: float = 0.0) -> int:
|
||||
"""Convert a physical value to a signed int16 stored as uint16 (two's complement).
|
||||
|
||||
Raises ``ValueError`` if the result does not fit in int16.
|
||||
"""
|
||||
if scale == 0.0:
|
||||
raise ValueError("scale must not be 0")
|
||||
if not math.isfinite(physical):
|
||||
raise ValueError(f"physical value must be finite, got {physical!r}")
|
||||
raw = round((physical - offset) / scale)
|
||||
if raw < INT16_MIN or raw > INT16_MAX:
|
||||
raise ValueError(
|
||||
f"signed raw {raw} for physical={physical} out of int16 range"
|
||||
)
|
||||
# Return as uint16 two's-complement
|
||||
return int(raw) & 0xFFFF
|
||||
|
||||
|
||||
def raw_signed_to_scaled(raw: int, scale: float, offset: float = 0.0) -> float:
|
||||
"""Convert a two's-complement uint16 raw to a signed physical value."""
|
||||
if raw > INT16_MAX:
|
||||
raw -= 0x10000
|
||||
return raw * scale + offset
|
||||
|
||||
|
||||
def clamp_uint16(value: int) -> int:
|
||||
"""Clamp an integer to [0, 65535]."""
|
||||
return max(0, min(UINT16_MAX, value))
|
||||
|
||||
|
||||
def validate_holding_write(reg_name: str, raw_value: int) -> None:
|
||||
"""Raise ``ValueError`` if ``raw_value`` is outside uint16 range.
|
||||
|
||||
This is a wire-level guard; the caller is responsible for applying any
|
||||
sign extension or scale conversion before calling this function.
|
||||
"""
|
||||
if not isinstance(raw_value, int):
|
||||
raise TypeError(f"Holding write for {reg_name}: value must be int, got {type(raw_value)}")
|
||||
if raw_value < 0 or raw_value > UINT16_MAX:
|
||||
raise ValueError(
|
||||
f"Holding register '{reg_name}': raw value {raw_value} out of uint16 range "
|
||||
f"[0, {UINT16_MAX}]"
|
||||
)
|
||||
|
||||
|
||||
def heading_deg_to_raw(heading_deg: float) -> int:
|
||||
"""Convert a compass heading in degrees to the HEADING_SETPOINT_X100 raw value.
|
||||
|
||||
Normalises to [0, 360) before scaling. Raises ``ValueError`` for non-finite input.
|
||||
"""
|
||||
if not math.isfinite(heading_deg):
|
||||
raise ValueError(f"heading_deg must be finite, got {heading_deg!r}")
|
||||
normalised = heading_deg % 360.0
|
||||
return scale_to_raw(normalised, scale=0.01)
|
||||
|
||||
|
||||
def raw_to_heading_deg(raw: int) -> float:
|
||||
"""Convert a HEADING_X100 raw value to degrees."""
|
||||
return raw * 0.01
|
||||
|
||||
|
||||
def rudder_deg_to_raw_signed(rudder_deg: float) -> int:
|
||||
"""Convert a rudder angle in degrees (±35) to a signed uint16 raw value × 100."""
|
||||
return scale_to_raw_signed(rudder_deg, scale=0.01)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Integration: alarm engine → audit log hash-chain -- Sprint 9.
|
||||
|
||||
Tests the complete flow: alarm fires → AlarmEngine produces Alarm records →
|
||||
AuditEvent is written to the log → hash-chain verifies OK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.alarm_engine import AlarmEngine, TelemetrySnapshot
|
||||
from arautopilot.core.alarms import AlarmType, AlarmSeverity
|
||||
from arautopilot.core.audit import AuditEvent, AuditLog, AuditOutcome
|
||||
|
||||
|
||||
def _alarm_to_event(alarm) -> AuditEvent:
|
||||
return AuditEvent(
|
||||
action=f"alarm.{alarm.type}",
|
||||
outcome=AuditOutcome.FAILED,
|
||||
reason=alarm.message,
|
||||
extra={"severity": str(alarm.severity), "auto_disengage": alarm.auto_disengage_triggered},
|
||||
)
|
||||
|
||||
|
||||
def _ack_event(alarm_type: AlarmType, user_id: str = "operator") -> AuditEvent:
|
||||
return AuditEvent(
|
||||
user_id=user_id,
|
||||
action=f"alarm.acknowledge.{alarm_type}",
|
||||
outcome=AuditOutcome.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
class TestAlarmFiredAndLogged:
|
||||
def test_single_alarm_logged_and_chain_valid(self, tmp_path: Path):
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
engine = AlarmEngine()
|
||||
|
||||
# Fire an off-course alarm
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=True))
|
||||
assert len(alarms) == 1
|
||||
log.append(_alarm_to_event(alarms[0]))
|
||||
|
||||
ok, reason = log.verify_chain()
|
||||
assert ok, reason
|
||||
assert len(log) == 1
|
||||
|
||||
def test_alarm_acknowledge_logged_and_chain_valid(self, tmp_path: Path):
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
engine = AlarmEngine()
|
||||
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=True))
|
||||
log.append(_alarm_to_event(alarms[0]))
|
||||
|
||||
engine.acknowledge(AlarmType.OFF_COURSE)
|
||||
log.append(_ack_event(AlarmType.OFF_COURSE))
|
||||
|
||||
ok, reason = log.verify_chain()
|
||||
assert ok, reason
|
||||
assert len(log) == 2
|
||||
|
||||
def test_multiple_alarms_all_logged(self, tmp_path: Path):
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
engine = AlarmEngine()
|
||||
|
||||
snap = TelemetrySnapshot(
|
||||
fw_alarm_off_course=True,
|
||||
fw_alarm_voltage_low=True,
|
||||
)
|
||||
alarms = engine.evaluate(snap)
|
||||
assert len(alarms) == 2
|
||||
|
||||
for a in alarms:
|
||||
log.append(_alarm_to_event(a))
|
||||
|
||||
ok, reason = log.verify_chain()
|
||||
assert ok, reason
|
||||
assert len(log) == 2
|
||||
|
||||
def test_disengage_event_logged_in_chain(self, tmp_path: Path):
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
disengages = []
|
||||
engine = AlarmEngine(on_disengage=lambda: disengages.append(True))
|
||||
|
||||
# EMERGENCY alarm triggers auto-disengage
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_heading_lost=True))
|
||||
assert len(disengages) >= 1
|
||||
assert alarms[0].auto_disengage_triggered
|
||||
|
||||
log.append(_alarm_to_event(alarms[0]))
|
||||
log.append(AuditEvent(
|
||||
action="pilot.disengage",
|
||||
outcome=AuditOutcome.SUCCESS,
|
||||
reason="auto-disengage from alarm",
|
||||
extra={"trigger": str(alarms[0].type)},
|
||||
))
|
||||
|
||||
ok, reason = log.verify_chain()
|
||||
assert ok, reason
|
||||
|
||||
def test_alarm_clear_and_refire_both_logged(self, tmp_path: Path):
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
engine = AlarmEngine()
|
||||
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=True))
|
||||
log.append(_alarm_to_event(alarms[0]))
|
||||
|
||||
# Clear
|
||||
engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=False))
|
||||
log.append(AuditEvent(action="alarm.cleared.off_course", outcome=AuditOutcome.SUCCESS))
|
||||
|
||||
# Refire
|
||||
alarms2 = engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=True))
|
||||
assert len(alarms2) == 1
|
||||
log.append(_alarm_to_event(alarms2[0]))
|
||||
|
||||
ok, reason = log.verify_chain()
|
||||
assert ok, reason
|
||||
assert len(log) == 3
|
||||
|
||||
|
||||
class TestAuditPersistenceAcrossReload:
|
||||
def test_reloaded_log_continues_chain(self, tmp_path: Path):
|
||||
p = tmp_path / "audit.jsonl"
|
||||
log1 = AuditLog(p)
|
||||
engine = AlarmEngine()
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=True))
|
||||
log1.append(_alarm_to_event(alarms[0]))
|
||||
|
||||
# Simulate restarting the Studio
|
||||
log2 = AuditLog(p)
|
||||
log2.append(AuditEvent(action="studio.startup", outcome=AuditOutcome.SUCCESS))
|
||||
|
||||
ok, reason = log2.verify_chain()
|
||||
assert ok, reason
|
||||
assert len(log2) == 2
|
||||
|
||||
def test_tampered_alarm_entry_detected(self, tmp_path: Path):
|
||||
import json
|
||||
p = tmp_path / "audit.jsonl"
|
||||
log = AuditLog(p)
|
||||
engine = AlarmEngine()
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_off_course=True))
|
||||
log.append(_alarm_to_event(alarms[0]))
|
||||
|
||||
# Tamper: change the action field
|
||||
lines = p.read_text(encoding="utf-8").splitlines()
|
||||
data = json.loads(lines[0])
|
||||
data["action"] = "alarm.no_problem_here"
|
||||
lines[0] = json.dumps(data)
|
||||
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
log2 = AuditLog(p)
|
||||
ok, reason = log2.verify_chain()
|
||||
assert not ok
|
||||
assert "mismatch" in reason.lower() or "tamper" in reason.lower()
|
||||
|
||||
|
||||
class TestAlarmSeverityInAudit:
|
||||
def test_emergency_severity_recorded(self, tmp_path: Path):
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
engine = AlarmEngine()
|
||||
alarms = engine.evaluate(TelemetrySnapshot(fw_alarm_heading_lost=True))
|
||||
assert alarms[0].severity == AlarmSeverity.EMERGENCY
|
||||
event = _alarm_to_event(alarms[0])
|
||||
assert event.extra["severity"] == "emergency"
|
||||
log.append(event)
|
||||
events = log.read_all()
|
||||
assert events[0].extra["severity"] == "emergency"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Integration: full cascade PID closed-loop simulation -- Sprint 9.
|
||||
|
||||
Tests that the three-layer cascade (outer heading PID → inner rudder PID →
|
||||
rudder dynamics → vessel heading) converges to a heading setpoint within the
|
||||
acceptance limits of the brief.
|
||||
|
||||
No mocks — all four simulator modules run together.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.studio.simulator.pid_inner import PidInner, PidInnerConfig
|
||||
from arautopilot.studio.simulator.pid_outer import PidOuter, PidOuterConfig
|
||||
from arautopilot.studio.simulator.rudder_dynamics import (
|
||||
RudderDynamicsConfig,
|
||||
RudderSimulator,
|
||||
)
|
||||
from arautopilot.studio.simulator.vessel_heading import (
|
||||
VesselHeadingConfig,
|
||||
VesselHeadingSimulator,
|
||||
heading_error_deg,
|
||||
)
|
||||
|
||||
|
||||
def _run_cascade(
|
||||
*,
|
||||
initial_heading: float,
|
||||
setpoint_deg: float,
|
||||
duration_s: float = 120.0,
|
||||
outer_dt: float = 0.1, # 10 Hz
|
||||
inner_dt: float = 0.02, # 50 Hz
|
||||
speed_kn: float = 10.0,
|
||||
) -> tuple[float, float]:
|
||||
"""Return (final_heading_error_deg, max_rudder_deg_reached)."""
|
||||
outer = PidOuter(PidOuterConfig(
|
||||
base_kp=0.9, base_ki=0.02, base_kd=1.2,
|
||||
deadband_deg=0.5, rot_ff_gain=1.5, max_rudder_deg=35.0,
|
||||
))
|
||||
inner = PidInner(PidInnerConfig(
|
||||
kp=2.5, ki=0.15, kd=0.30,
|
||||
deadband_deg=0.5, min_useful_pwm_pct=12.0,
|
||||
))
|
||||
rudder = RudderSimulator(RudderDynamicsConfig(
|
||||
actuator_gain=0.2, friction=4.0, max_angle_deg=35.0,
|
||||
))
|
||||
vessel = VesselHeadingSimulator(VesselHeadingConfig(
|
||||
rudder_response_gain=0.18, yaw_damping=0.8, speed_kn=speed_kn,
|
||||
))
|
||||
vessel.reset(heading_deg=initial_heading)
|
||||
|
||||
t = 0.0
|
||||
max_rudder = 0.0
|
||||
rudder_setpoint = 0.0
|
||||
outer_acc = 0.0
|
||||
|
||||
while t < duration_s:
|
||||
# Outer loop tick (10 Hz)
|
||||
if outer_acc >= outer_dt - 1e-9:
|
||||
outer_acc = 0.0
|
||||
rudder_setpoint = outer.step(
|
||||
heading_setpoint_deg=setpoint_deg,
|
||||
heading_measured_deg=vessel.state.heading_deg,
|
||||
rate_of_turn_dps=vessel.state.rate_of_turn_dps,
|
||||
speed_kn=speed_kn,
|
||||
allowed=True,
|
||||
)
|
||||
|
||||
# Inner loop tick (50 Hz)
|
||||
pwm = inner.step(
|
||||
setpoint_deg=rudder_setpoint,
|
||||
measured_deg=rudder.state.angle_deg,
|
||||
allowed=True,
|
||||
)
|
||||
rudder.step(dt=inner_dt, pwm_pct=pwm)
|
||||
vessel.step(dt=inner_dt, rudder_deg=rudder.state.angle_deg, speed_kn=speed_kn)
|
||||
|
||||
max_rudder = max(max_rudder, abs(rudder.state.angle_deg))
|
||||
outer_acc += inner_dt
|
||||
t += inner_dt
|
||||
|
||||
final_error = abs(heading_error_deg(setpoint_deg, vessel.state.heading_deg))
|
||||
return final_error, max_rudder
|
||||
|
||||
|
||||
class TestCascadeConvergence:
|
||||
def test_small_heading_change_converges(self):
|
||||
err, _ = _run_cascade(initial_heading=0.0, setpoint_deg=10.0)
|
||||
assert err < 6.0, f"Did not converge: final error = {err:.2f} deg"
|
||||
|
||||
def test_90_degree_turn_converges(self):
|
||||
err, _ = _run_cascade(initial_heading=0.0, setpoint_deg=90.0, duration_s=180.0)
|
||||
assert err < 8.0, f"Did not converge: final error = {err:.2f} deg"
|
||||
|
||||
def test_wrap_around_north_converges(self):
|
||||
# 350° → 10° = +20 degrees through north
|
||||
err, _ = _run_cascade(initial_heading=350.0, setpoint_deg=10.0)
|
||||
assert err < 6.0, f"Wrap-around did not converge: final error = {err:.2f} deg"
|
||||
|
||||
def test_180_degree_turn_converges(self):
|
||||
err, _ = _run_cascade(initial_heading=0.0, setpoint_deg=180.0, duration_s=240.0)
|
||||
assert err < 10.0, f"180° turn did not converge: final error = {err:.2f} deg"
|
||||
|
||||
def test_rudder_stays_within_limits(self):
|
||||
_, max_rudder = _run_cascade(initial_heading=0.0, setpoint_deg=90.0)
|
||||
assert max_rudder <= 35.0 + 1e-6
|
||||
|
||||
def test_slow_speed_converges(self):
|
||||
# At low speed the vessel turns slower but must still converge
|
||||
err, _ = _run_cascade(
|
||||
initial_heading=0.0, setpoint_deg=30.0, speed_kn=3.0, duration_s=240.0
|
||||
)
|
||||
assert err < 10.0, f"Low-speed case did not converge: final error = {err:.2f} deg"
|
||||
|
||||
def test_same_heading_no_overshoot(self):
|
||||
# Already on setpoint: rudder should barely move
|
||||
err, max_rudder = _run_cascade(
|
||||
initial_heading=90.0, setpoint_deg=90.0, duration_s=30.0
|
||||
)
|
||||
assert err < 2.0
|
||||
assert max_rudder < 5.0
|
||||
|
||||
|
||||
class TestCascadeEnergyBudget:
|
||||
def test_integrator_does_not_wind_up(self):
|
||||
# After convergence, integrator should be small (no sustained error)
|
||||
outer = PidOuter(PidOuterConfig(base_kp=0.9, base_ki=0.02, base_kd=1.2))
|
||||
inner = PidInner()
|
||||
rudder = RudderSimulator()
|
||||
vessel = VesselHeadingSimulator()
|
||||
vessel.reset(heading_deg=0.0)
|
||||
setpoint = 45.0
|
||||
outer_acc = 0.0
|
||||
rudder_sp = 0.0
|
||||
for _ in range(int(60.0 / 0.02)):
|
||||
if outer_acc >= 0.1 - 1e-9:
|
||||
outer_acc = 0.0
|
||||
rudder_sp = outer.step(
|
||||
heading_setpoint_deg=setpoint,
|
||||
heading_measured_deg=vessel.state.heading_deg,
|
||||
rate_of_turn_dps=vessel.state.rate_of_turn_dps,
|
||||
speed_kn=10.0,
|
||||
allowed=True,
|
||||
)
|
||||
pwm = inner.step(setpoint_deg=rudder_sp, measured_deg=rudder.state.angle_deg, allowed=True)
|
||||
rudder.step(dt=0.02, pwm_pct=pwm)
|
||||
vessel.step(dt=0.02, rudder_deg=rudder.state.angle_deg)
|
||||
outer_acc += 0.02
|
||||
# Integral should have settled to a small value after convergence
|
||||
assert abs(outer.state.integral) < 20.0
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Integration: HeadingEKF smoothing noisy heading input to the outer PID -- Sprint 9.
|
||||
|
||||
Verifies that the EKF reduces the impact of sensor noise on the outer PID
|
||||
without preventing the cascade from converging to the heading setpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.heading_ekf import HeadingEKF
|
||||
from arautopilot.studio.simulator.pid_inner import PidInner, PidInnerConfig
|
||||
from arautopilot.studio.simulator.pid_outer import PidOuter, PidOuterConfig
|
||||
from arautopilot.studio.simulator.rudder_dynamics import RudderDynamicsConfig, RudderSimulator
|
||||
from arautopilot.studio.simulator.vessel_heading import (
|
||||
VesselHeadingConfig,
|
||||
VesselHeadingSimulator,
|
||||
heading_error_deg,
|
||||
)
|
||||
|
||||
|
||||
def _run_with_noise(
|
||||
*,
|
||||
noise_std_deg: float = 3.0,
|
||||
use_ekf: bool = True,
|
||||
setpoint_deg: float = 45.0,
|
||||
duration_s: float = 120.0,
|
||||
seed: int = 42,
|
||||
) -> tuple[float, float]:
|
||||
"""Return (final_error_deg, rudder_total_variation) for a noisy heading run.
|
||||
|
||||
``rudder_total_variation`` measures how much the rudder setpoint oscillated;
|
||||
less variation indicates the EKF is reducing noise amplification.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
outer = PidOuter(PidOuterConfig(
|
||||
base_kp=0.9, base_ki=0.02, base_kd=1.2,
|
||||
deadband_deg=0.5, rot_ff_gain=1.5,
|
||||
))
|
||||
inner = PidInner(PidInnerConfig(kp=2.5, ki=0.15, kd=0.30))
|
||||
rudder = RudderSimulator(RudderDynamicsConfig())
|
||||
vessel = VesselHeadingSimulator(VesselHeadingConfig(speed_kn=10.0))
|
||||
vessel.reset(heading_deg=0.0)
|
||||
|
||||
ekf = HeadingEKF(
|
||||
heading_deg=0.0,
|
||||
process_noise_heading=0.01,
|
||||
process_noise_rot=0.1,
|
||||
)
|
||||
|
||||
outer_acc = 0.0
|
||||
inner_dt = 0.02
|
||||
rudder_sp = 0.0
|
||||
prev_rudder_sp = 0.0
|
||||
total_variation = 0.0
|
||||
|
||||
for _ in range(int(duration_s / inner_dt)):
|
||||
# Add Gaussian noise to the true heading for the sensor measurement
|
||||
noisy_heading = vessel.state.heading_deg + rng.gauss(0.0, noise_std_deg)
|
||||
noisy_heading %= 360.0
|
||||
noisy_rot = vessel.state.rate_of_turn_dps + rng.gauss(0.0, 0.5)
|
||||
|
||||
if use_ekf:
|
||||
ekf.predict(dt_s=inner_dt)
|
||||
ekf.update_heading(noisy_heading, noise_deg=noise_std_deg)
|
||||
ekf.update_rot(noisy_rot, noise_dps=0.5)
|
||||
heading_input = ekf.heading_deg
|
||||
rot_input = ekf.rot_dps
|
||||
else:
|
||||
heading_input = noisy_heading
|
||||
rot_input = noisy_rot
|
||||
|
||||
if outer_acc >= 0.1 - 1e-9:
|
||||
outer_acc = 0.0
|
||||
rudder_sp = outer.step(
|
||||
heading_setpoint_deg=setpoint_deg,
|
||||
heading_measured_deg=heading_input,
|
||||
rate_of_turn_dps=rot_input,
|
||||
speed_kn=10.0,
|
||||
allowed=True,
|
||||
)
|
||||
total_variation += abs(rudder_sp - prev_rudder_sp)
|
||||
prev_rudder_sp = rudder_sp
|
||||
|
||||
pwm = inner.step(setpoint_deg=rudder_sp, measured_deg=rudder.state.angle_deg, allowed=True)
|
||||
rudder.step(dt=inner_dt, pwm_pct=pwm)
|
||||
vessel.step(dt=inner_dt, rudder_deg=rudder.state.angle_deg)
|
||||
outer_acc += inner_dt
|
||||
|
||||
final_error = abs(heading_error_deg(setpoint_deg, vessel.state.heading_deg))
|
||||
return final_error, total_variation
|
||||
|
||||
|
||||
class TestEkfReducesNoise:
|
||||
def test_ekf_filter_converges_to_setpoint(self):
|
||||
err, _ = _run_with_noise(use_ekf=True, noise_std_deg=3.0)
|
||||
assert err < 4.0, f"EKF run did not converge: final error = {err:.2f} deg"
|
||||
|
||||
def test_no_ekf_has_larger_residual_than_ekf(self):
|
||||
# Without EKF, large sensor noise prevents tight convergence.
|
||||
# This test just confirms the EKF case is materially better.
|
||||
err_ekf, _ = _run_with_noise(use_ekf=True, noise_std_deg=3.0, seed=99)
|
||||
err_raw, _ = _run_with_noise(use_ekf=False, noise_std_deg=3.0, seed=99)
|
||||
assert err_ekf < err_raw, (
|
||||
f"EKF should produce lower residual: ekf={err_ekf:.2f} raw={err_raw:.2f}"
|
||||
)
|
||||
|
||||
def test_ekf_reduces_rudder_variation(self):
|
||||
_, var_ekf = _run_with_noise(use_ekf=True, noise_std_deg=5.0, seed=7)
|
||||
_, var_raw = _run_with_noise(use_ekf=False, noise_std_deg=5.0, seed=7)
|
||||
assert var_ekf < var_raw, (
|
||||
f"EKF should reduce rudder variation: ekf={var_ekf:.1f} raw={var_raw:.1f}"
|
||||
)
|
||||
|
||||
def test_ekf_tracks_heading_during_steady_state(self):
|
||||
"""After convergence, EKF should track true heading within its noise level."""
|
||||
ekf = HeadingEKF(heading_deg=45.0)
|
||||
rng = random.Random(0)
|
||||
true_heading = 45.0
|
||||
noise_std = 2.0
|
||||
for _ in range(200):
|
||||
ekf.predict(dt_s=0.1)
|
||||
noisy = true_heading + rng.gauss(0.0, noise_std)
|
||||
ekf.update_heading(noisy, noise_deg=noise_std)
|
||||
assert abs(ekf.heading_deg - true_heading) < 2.0
|
||||
|
||||
def test_ekf_handles_wrap_around_during_turn(self):
|
||||
"""EKF should track heading smoothly through the 0/360 boundary."""
|
||||
ekf = HeadingEKF(heading_deg=355.0, rot_dps=5.0)
|
||||
for _ in range(30):
|
||||
ekf.predict(dt_s=0.1)
|
||||
# True heading wraps from 355 → 5 during this sequence
|
||||
true = (355.0 + _ * 5.0 * 0.1) % 360.0
|
||||
ekf.update_heading(true, noise_deg=1.0)
|
||||
# Should have tracked through the wrap
|
||||
expected = (355.0 + 30 * 5.0 * 0.1) % 360.0
|
||||
from arautopilot.core.heading_ekf import _shortest_arc
|
||||
diff = abs(_shortest_arc(ekf.heading_deg, expected))
|
||||
assert diff < 5.0
|
||||
|
||||
|
||||
class TestEkfEdgeCases:
|
||||
def test_large_noise_ekf_still_converges(self):
|
||||
err, _ = _run_with_noise(use_ekf=True, noise_std_deg=10.0, duration_s=180.0)
|
||||
assert err < 8.0
|
||||
|
||||
def test_zero_noise_ekf_matches_true_heading(self):
|
||||
ekf = HeadingEKF(heading_deg=0.0)
|
||||
for i in range(100):
|
||||
ekf.predict(dt_s=0.1)
|
||||
ekf.update_heading(float(i % 360), noise_deg=0.01)
|
||||
# With near-zero noise, EKF should track closely
|
||||
# (just checking it doesn't crash and stays in [0,360))
|
||||
assert 0.0 <= ekf.heading_deg < 360.0
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for Modbus register utility helpers -- Sprint 9."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.shared.modbus_utils import (
|
||||
UINT16_MAX,
|
||||
INT16_MAX,
|
||||
INT16_MIN,
|
||||
clamp_uint16,
|
||||
heading_deg_to_raw,
|
||||
raw_signed_to_scaled,
|
||||
raw_to_heading_deg,
|
||||
raw_to_scaled,
|
||||
rudder_deg_to_raw_signed,
|
||||
scale_to_raw,
|
||||
scale_to_raw_signed,
|
||||
validate_holding_write,
|
||||
)
|
||||
|
||||
|
||||
class TestScaleToRaw:
|
||||
def test_basic_heading(self):
|
||||
assert scale_to_raw(45.0, scale=0.01) == 4500
|
||||
|
||||
def test_zero(self):
|
||||
assert scale_to_raw(0.0, scale=0.01) == 0
|
||||
|
||||
def test_max_uint16(self):
|
||||
assert scale_to_raw(655.35, scale=0.01) == UINT16_MAX
|
||||
|
||||
def test_out_of_range_raises(self):
|
||||
with pytest.raises(ValueError, match="uint16"):
|
||||
scale_to_raw(700.0, scale=0.01)
|
||||
|
||||
def test_negative_raises(self):
|
||||
with pytest.raises(ValueError, match="uint16"):
|
||||
scale_to_raw(-1.0, scale=0.01)
|
||||
|
||||
def test_zero_scale_raises(self):
|
||||
with pytest.raises(ValueError, match="scale"):
|
||||
scale_to_raw(10.0, scale=0.0)
|
||||
|
||||
def test_inf_raises(self):
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
scale_to_raw(math.inf, scale=0.01)
|
||||
|
||||
def test_nan_raises(self):
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
scale_to_raw(math.nan, scale=0.01)
|
||||
|
||||
def test_with_offset(self):
|
||||
# physical=10, offset=5, scale=1 → raw = (10-5)/1 = 5
|
||||
assert scale_to_raw(10.0, scale=1.0, offset=5.0) == 5
|
||||
|
||||
|
||||
class TestRawToScaled:
|
||||
def test_basic(self):
|
||||
assert raw_to_scaled(4500, scale=0.01) == pytest.approx(45.0)
|
||||
|
||||
def test_with_offset(self):
|
||||
assert raw_to_scaled(5, scale=1.0, offset=5.0) == pytest.approx(10.0)
|
||||
|
||||
|
||||
class TestScaleToRawSigned:
|
||||
def test_positive_rudder(self):
|
||||
# +10.0 deg * 100 = 1000
|
||||
assert scale_to_raw_signed(10.0, scale=0.01) == 1000
|
||||
|
||||
def test_negative_rudder(self):
|
||||
raw = scale_to_raw_signed(-10.0, scale=0.01)
|
||||
# -1000 in two's complement uint16 = 65536 - 1000 = 64536
|
||||
assert raw == (0xFFFF + 1) - 1000
|
||||
|
||||
def test_zero(self):
|
||||
assert scale_to_raw_signed(0.0, scale=0.01) == 0
|
||||
|
||||
def test_max_positive(self):
|
||||
assert scale_to_raw_signed(327.67, scale=0.01) == INT16_MAX
|
||||
|
||||
def test_out_of_range_positive_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
scale_to_raw_signed(400.0, scale=0.01)
|
||||
|
||||
def test_out_of_range_negative_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
scale_to_raw_signed(-400.0, scale=0.01)
|
||||
|
||||
def test_nan_raises(self):
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
scale_to_raw_signed(math.nan, scale=0.01)
|
||||
|
||||
|
||||
class TestRawSignedToScaled:
|
||||
def test_positive(self):
|
||||
assert raw_signed_to_scaled(1000, scale=0.01) == pytest.approx(10.0)
|
||||
|
||||
def test_negative(self):
|
||||
raw = (0x10000 - 1000) # -1000 in two's complement
|
||||
assert raw_signed_to_scaled(raw, scale=0.01) == pytest.approx(-10.0)
|
||||
|
||||
def test_zero(self):
|
||||
assert raw_signed_to_scaled(0, scale=0.01) == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestClampUint16:
|
||||
def test_in_range(self):
|
||||
assert clamp_uint16(1000) == 1000
|
||||
|
||||
def test_below_zero(self):
|
||||
assert clamp_uint16(-5) == 0
|
||||
|
||||
def test_above_max(self):
|
||||
assert clamp_uint16(70000) == UINT16_MAX
|
||||
|
||||
def test_at_max(self):
|
||||
assert clamp_uint16(UINT16_MAX) == UINT16_MAX
|
||||
|
||||
|
||||
class TestValidateHoldingWrite:
|
||||
def test_valid(self):
|
||||
validate_holding_write("HEADING_SETPOINT_X100", 4500) # no exception
|
||||
|
||||
def test_negative_raises(self):
|
||||
with pytest.raises(ValueError, match="uint16"):
|
||||
validate_holding_write("HEADING_SETPOINT_X100", -1)
|
||||
|
||||
def test_over_max_raises(self):
|
||||
with pytest.raises(ValueError, match="uint16"):
|
||||
validate_holding_write("HEADING_SETPOINT_X100", UINT16_MAX + 1)
|
||||
|
||||
def test_float_raises_type_error(self):
|
||||
with pytest.raises(TypeError):
|
||||
validate_holding_write("X", 45.0)
|
||||
|
||||
def test_zero_valid(self):
|
||||
validate_holding_write("TEST", 0)
|
||||
|
||||
def test_max_valid(self):
|
||||
validate_holding_write("TEST", UINT16_MAX)
|
||||
|
||||
|
||||
class TestHeadingConversion:
|
||||
def test_zero(self):
|
||||
assert heading_deg_to_raw(0.0) == 0
|
||||
|
||||
def test_45_deg(self):
|
||||
assert heading_deg_to_raw(45.0) == 4500
|
||||
|
||||
def test_360_wraps_to_0(self):
|
||||
assert heading_deg_to_raw(360.0) == 0
|
||||
|
||||
def test_over_360_wraps(self):
|
||||
assert heading_deg_to_raw(370.0) == heading_deg_to_raw(10.0)
|
||||
|
||||
def test_negative_wraps(self):
|
||||
assert heading_deg_to_raw(-10.0) == heading_deg_to_raw(350.0)
|
||||
|
||||
def test_inf_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
heading_deg_to_raw(math.inf)
|
||||
|
||||
def test_roundtrip(self):
|
||||
for deg in [0.0, 45.0, 90.0, 135.0, 180.0, 270.0, 359.99]:
|
||||
raw = heading_deg_to_raw(deg)
|
||||
back = raw_to_heading_deg(raw)
|
||||
assert abs(back - deg) < 0.01
|
||||
|
||||
|
||||
class TestRudderConversion:
|
||||
def test_positive(self):
|
||||
raw = rudder_deg_to_raw_signed(10.0)
|
||||
back = raw_signed_to_scaled(raw, scale=0.01)
|
||||
assert back == pytest.approx(10.0)
|
||||
|
||||
def test_negative(self):
|
||||
raw = rudder_deg_to_raw_signed(-10.0)
|
||||
back = raw_signed_to_scaled(raw, scale=0.01)
|
||||
assert back == pytest.approx(-10.0)
|
||||
|
||||
def test_zero(self):
|
||||
assert rudder_deg_to_raw_signed(0.0) == 0
|
||||
@@ -0,0 +1,279 @@
|
||||
# AR-Autopilot — Operator Manual
|
||||
|
||||
**Document status:** Sprint 9 (production-ready baseline)
|
||||
**Firmware version:** 1.0.0 and later
|
||||
**Applies to:** AR-Autopilot display unit + ESP32 controller
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Safety Instructions](#1-safety-instructions)
|
||||
2. [System Overview](#2-system-overview)
|
||||
3. [Installation Checklist](#3-installation-checklist)
|
||||
4. [Controls and Indicators](#4-controls-and-indicators)
|
||||
5. [Operating Modes](#5-operating-modes)
|
||||
6. [Engaging and Disengaging](#6-engaging-and-disengaging)
|
||||
7. [Heading Hold (HH)](#7-heading-hold-hh)
|
||||
8. [True Course (TC)](#8-true-course-tc)
|
||||
9. [Track Keeping (TK)](#9-track-keeping-tk)
|
||||
10. [Dodge Mode](#10-dodge-mode)
|
||||
11. [Alarm Reference](#11-alarm-reference)
|
||||
12. [Commissioning](#12-commissioning)
|
||||
13. [Fault-Finding](#13-fault-finding)
|
||||
14. [Modbus Register Reference](#14-modbus-register-reference)
|
||||
15. [Certification and Licensing](#15-certification-and-licensing)
|
||||
|
||||
---
|
||||
|
||||
## 1. Safety Instructions
|
||||
|
||||
**Read all instructions before operating the autopilot.**
|
||||
|
||||
- The AR-Autopilot is a navigational aid only. **A qualified watch-keeper must remain on the bridge at all times** while the autopilot is engaged. The operator is always responsible for collision avoidance.
|
||||
- **Always disengage before entering port, anchoring, or navigating restricted waters.**
|
||||
- Test all manual override and disengage functions before each voyage.
|
||||
- If any alarm sounds, acknowledge it and investigate the cause before continuing.
|
||||
- Do not attempt to override or silence a HEADING SENSOR LOST or OFF-COURSE SEVERE alarm — these indicate loss of situational awareness.
|
||||
- Ensure the NMEA 2000 network is correctly terminated (two 120 Ω resistors, one at each bus end) before commissioning.
|
||||
- The controller draws up to 30 A peak. Fuse the supply with an appropriately rated marine-grade fuse at the switchboard.
|
||||
|
||||
---
|
||||
|
||||
## 2. System Overview
|
||||
|
||||
```
|
||||
[NMEA 2000 backbone] ──► [ESP32 controller] ──► [H-bridge / hydraulic valve]
|
||||
│ │
|
||||
[RS-485] [rudder feedback pot]
|
||||
│
|
||||
[Display PC / tablet]
|
||||
```
|
||||
|
||||
The ESP32 controller:
|
||||
- Receives heading (PGN 127250), ROT (PGN 127251), COG/SOG (PGN 129026), and XTE/waypoint (PGN 129284) from the NMEA 2000 backbone.
|
||||
- Drives the rudder actuator via PWM.
|
||||
- Reads rudder position via ADC (feedback potentiometer).
|
||||
- Publishes autopilot state back to the NMEA 2000 bus (PGN 127245 rudder, PGN 127237 heading track control).
|
||||
- Exposes all telemetry and command registers over Modbus RTU RS-485.
|
||||
|
||||
The Studio software (PC/tablet) communicates via Modbus and provides the operator interface, alarm display, and audit log.
|
||||
|
||||
---
|
||||
|
||||
## 3. Installation Checklist
|
||||
|
||||
Before first power-on:
|
||||
|
||||
- [ ] Controller securely mounted, protected from bilge water.
|
||||
- [ ] NMEA 2000 backbone correctly terminated.
|
||||
- [ ] Controller connected as a NMEA 2000 device (T-piece, correct PGN subscriptions active on MFD).
|
||||
- [ ] RS-485 cable wired A/B correctly to display unit; max cable length 1 200 m at 38 400 baud.
|
||||
- [ ] Actuator wired with correct polarity (positive terminal drives starboard, negative drives port).
|
||||
- [ ] Rudder feedback potentiometer wiper connected to ADC input; verify resistance changes smoothly through full travel.
|
||||
- [ ] Port and starboard limit switches wired to DI2 and DI3 respectively.
|
||||
- [ ] Disengage push-button wired to DI1.
|
||||
- [ ] External alarm input (VMS/genset) wired to DI4 if fitted.
|
||||
- [ ] Supply fused and connected (12/24 V DC depending on variant).
|
||||
- [ ] Commissioning wizard completed (see section 12).
|
||||
|
||||
---
|
||||
|
||||
## 4. Controls and Indicators
|
||||
|
||||
### Physical controls
|
||||
|
||||
| Control | Function |
|
||||
|---|---|
|
||||
| Engage/Disengage button | Rising edge requests engage; while engaged, press to disengage immediately |
|
||||
| Heading knob (optional) | After arming via Studio, each click adjusts setpoint by 1°; auto-disarms after 5 s |
|
||||
| Manual confirm switch | Emergency override — allows engage while limit switch or minor alarm is active |
|
||||
|
||||
### Display (Studio)
|
||||
|
||||
| Indicator | Meaning |
|
||||
|---|---|
|
||||
| Mode badge (green) | Pilot engaged in the shown mode |
|
||||
| Mode badge (grey) | STANDBY — manual steering |
|
||||
| Red alarm banner | One or more alarms active |
|
||||
| Amber alarm banner | One or more LOW alarms active; no auto-disengage risk |
|
||||
| Heading tape | Current heading vs setpoint |
|
||||
| Rudder bar | Commanded rudder position |
|
||||
|
||||
---
|
||||
|
||||
## 5. Operating Modes
|
||||
|
||||
| Mode | Code | Description |
|
||||
|---|---|---|
|
||||
| STANDBY | 0 | Manual steering. Actuator power off. All PIDs idle. |
|
||||
| Heading Hold | 1 (HH) | Maintains the compass heading set at the time of engagement. |
|
||||
| True Course | 2 (TC) | Maintains the COG setpoint (corrects for leeway and current). Requires PGN 129026. |
|
||||
| Track Keeping | 3 (TK) | Corrects cross-track error to a route waypoint. Requires PGN 129284. |
|
||||
| Dodge | 4 | Temporarily offsets the heading by a fixed amount (±1 to ±30°) to pass an obstacle. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Engaging and Disengaging
|
||||
|
||||
### To engage
|
||||
|
||||
1. Confirm the vessel is on the desired heading (or set the desired COG/XTE in TC/TK mode).
|
||||
2. Press **Engage** on the Studio or the physical button.
|
||||
3. The controller checks interlocks: no limit switches active, heading sensor fresh (<5 s), battery voltage ≥ 10.5 V.
|
||||
4. On success, the mode badge turns green and the rudder begins driving.
|
||||
|
||||
### To disengage
|
||||
|
||||
- Press the **Disengage** button (physical or on-screen) at any time.
|
||||
- Any EMERGENCY alarm also triggers automatic disengage.
|
||||
- Power loss automatically disengages (fail-safe to manual steering).
|
||||
|
||||
---
|
||||
|
||||
## 7. Heading Hold (HH)
|
||||
|
||||
Heading Hold captures the current compass heading the moment the pilot engages. The outer PID corrects any subsequent deviation. Rate-of-turn feed-forward reduces overshoot during turns.
|
||||
|
||||
**To change the setpoint while engaged:**
|
||||
- Use the heading increment/decrement arrows on the Studio (+/– 1° per press).
|
||||
- Or arm the physical knob (CMD_KNOB_ARM coil) and rotate.
|
||||
|
||||
**Gain scheduling:** Gains reduce at low speed and increase at high speed. The active gains are visible in the PID telemetry panel.
|
||||
|
||||
---
|
||||
|
||||
## 8. True Course (TC)
|
||||
|
||||
True Course maintains a desired Course Over Ground (COG) rather than a compass heading. The controller reads COG from PGN 129026 and adjusts the heading setpoint to correct for current and leeway.
|
||||
|
||||
**Requirements:** An NMEA 2000 GPS/chartplotter transmitting PGN 129026 at ≥ 1 Hz.
|
||||
|
||||
**COG age alarm:** If COG is not updated within 5 s, the alarm HEADING SENSOR LOST fires and the pilot auto-disengages.
|
||||
|
||||
---
|
||||
|
||||
## 9. Track Keeping (TK)
|
||||
|
||||
Track Keeping adds cross-track error (XTE) correction on top of True Course. If the vessel drifts off the planned route, the heading setpoint is offset to steer back.
|
||||
|
||||
**Requirements:** An NMEA 2000 chartplotter transmitting PGN 129284 (Navigation Data) at ≥ 1 Hz.
|
||||
|
||||
**XTE limit:** The heading correction from XTE is clamped to ±20° by default (configurable via `XTE_MAX_CORRECTION_X100`). If the vessel is more than the XTE correction limit off track, the track error alarm fires.
|
||||
|
||||
---
|
||||
|
||||
## 10. Dodge Mode
|
||||
|
||||
Dodge mode adds a temporary heading offset to avoid an obstacle without disengaging.
|
||||
|
||||
1. The operator sets `DODGE_OFFSET_DEG_X100` (holding register 8) to the desired offset (positive = starboard, negative = port).
|
||||
2. Select MODE_REQUEST = 4 (DODGE).
|
||||
3. The new heading setpoint = locked HH heading ± offset.
|
||||
4. To return to the original course, select MODE_REQUEST = 1 (HH) and the offset is discarded.
|
||||
|
||||
---
|
||||
|
||||
## 11. Alarm Reference
|
||||
|
||||
| Alarm | Severity | Auto-disengage | Cause | Action |
|
||||
|---|---|---|---|---|
|
||||
| OFF_COURSE | LOW | No | Heading deviates > 10° from setpoint | Verify sea state; adjust ROT feed-forward if persistent |
|
||||
| OFF_COURSE_SEVERE | EMERGENCY | Yes | Heading deviates > 30° for > 5 s | Check rudder feedback and actuator power |
|
||||
| RUDDER_NOT_RESPONDING | EMERGENCY | Yes | Rudder setpoint sent but no motion for 3 s | Check actuator power relay, limit switches, hydraulic pressure |
|
||||
| HEADING_SENSOR_LOST | EMERGENCY | Yes | PGN 127250 not received for > 5 s | Check NMEA 2000 termination; check compass power |
|
||||
| ACTUATOR_OVERCURRENT | HIGH | Yes | Actuator current > threshold | Check for rudder jam, hydraulic fault |
|
||||
| VOLTAGE_LOW | HIGH | Yes | Supply < 10.5 V | Check battery and alternator |
|
||||
| LIMIT_SWITCH_REACHED | LOW | No | Rudder at mechanical end-stop | Steer vessel to reduce commanded angle; check rudder travel |
|
||||
| WATCHDOG_TRIPPED | EMERGENCY | Yes | Firmware watchdog fired (MCU reset) | Report to Alvaro Rodríguez Marine; check for power glitch |
|
||||
| VMS_CRITICAL | EMERGENCY | Yes | VMS reported blackout or overload | Check VMS display; cut non-essential loads |
|
||||
|
||||
---
|
||||
|
||||
## 12. Commissioning
|
||||
|
||||
The commissioning wizard is run once during installation via the AR-Autopilot Studio.
|
||||
|
||||
### Phase 1 — Rudder limits
|
||||
|
||||
The wizard drives the rudder to port and starboard limits and records the ADC calibration values. Ensure the vessel has sea room and the limit switches are installed.
|
||||
|
||||
### Phase 2 — Sensor calibration
|
||||
|
||||
Verify the ADC reading changes correctly across the rudder travel. If the port and starboard readings are inverted, the wiring polarity is reversed — the wizard corrects this in software.
|
||||
|
||||
### Phase 3 — Auto-tuning
|
||||
|
||||
The wizard performs a relay feedback test (Åström-Hägglund method) to estimate the ultimate gain and ultimate period of the plant. The ZN formulas are used to calculate initial PID gains. This step requires approximately 5 minutes of clear water at cruising speed.
|
||||
|
||||
### Phase 4 — Confirmation
|
||||
|
||||
The commissioning data (ADC bounds, PID gains) is stored in non-volatile storage (NVS) on the ESP32 and applied automatically at every subsequent power-on.
|
||||
|
||||
---
|
||||
|
||||
## 13. Fault-Finding
|
||||
|
||||
| Symptom | Likely cause | Check |
|
||||
|---|---|---|
|
||||
| Pilot will not engage | Interlock active | Check alarm banner for root cause; check limit switches |
|
||||
| Rudder drives to one side and does not return | Actuator power issue / limit switch wired backwards | Swap DI2/DI3 wiring |
|
||||
| Excessive overshoot on heading changes | ROT feed-forward too low or Kd too low | Increase `rot_ff_gain` or Kd in Studio tuning panel |
|
||||
| Hunting / oscillation | Kp too high | Run auto-tuner from commissioning wizard to re-tune |
|
||||
| Heading deviates in cross-wind | Ki too low | Increase Ki slightly; avoid excessive Ki (causes windup) |
|
||||
| NMEA 2000 alarms intermittent | Bus termination | Verify exactly two 120 Ω resistors; check backbone cable |
|
||||
| Heading sensor lost alarm at speed | GPS/chartplotter update rate < 1 Hz | Increase source device update rate |
|
||||
|
||||
---
|
||||
|
||||
## 14. Modbus Register Reference
|
||||
|
||||
Communication: RS-485, 38 400 baud, 8N1, Modbus RTU, slave address 1.
|
||||
|
||||
### Key holding registers (read/write)
|
||||
|
||||
| Address (40001+) | Name | Unit | Scale | Range |
|
||||
|---|---|---|---|---|
|
||||
| 40001 | MODE_REQUEST | — | 1 | 0–4 |
|
||||
| 40002 | HEADING_SETPOINT_X100 | deg | 0.01 | 0–35999 |
|
||||
| 40003 | BRIGHTNESS_PCT | % | 1 | 0–100 |
|
||||
| 40009 | DODGE_OFFSET_DEG_X100 | deg | 0.01 | signed ±9000 |
|
||||
|
||||
### Key input registers (read-only telemetry)
|
||||
|
||||
| Address (30001+) | Name | Unit | Scale |
|
||||
|---|---|---|---|
|
||||
| 30001–30003 | FW_VERSION_MAJOR/MINOR/PATCH | — | 1 |
|
||||
| 30007 | CURRENT_MODE | — | 1 |
|
||||
| 30010 | HWID_MAC_01 | — | 1 |
|
||||
| 30011 | HWID_MAC_23 | — | 1 |
|
||||
| 30012 | HWID_MAC_45 | — | 1 |
|
||||
| 30017 | RUDDER_ANGLE_DEG_X100 | deg | 0.01 |
|
||||
| 30025 | HEADING_DEG_X100 | deg | 0.01 |
|
||||
| 30026 | ROT_DPS_X100 | deg/s | 0.01 |
|
||||
| 30033 | BATTERY_VOLTAGE_X100 | V | 0.01 |
|
||||
|
||||
The full register map is in `firmware/ar_autopilot_v1/modbus_registers.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## 15. Certification and Licensing
|
||||
|
||||
The AR-Autopilot uses hardware-binding activation. Each controller has a unique 6-byte hardware ID (HWID) derived from the ESP32 eFuse MAC address. The installer must obtain an activation token from the factory using the HWID and enter it in the Studio before the autopilot can be used in production.
|
||||
|
||||
### Activating a unit
|
||||
|
||||
1. Connect to the controller via Modbus.
|
||||
2. In the Studio, navigate to **Settings → Activation**.
|
||||
3. The HWID is displayed in `AA:BB:CC:DD:EE:FF` format (read from input registers 9/10/11).
|
||||
4. Contact Alvaro Rodríguez Marine with the HWID to obtain the activation token.
|
||||
5. Enter the token in the Studio. The token is verified locally using HMAC-SHA256.
|
||||
|
||||
### Audit log
|
||||
|
||||
Every significant event (engage, disengage, mode change, alarm, alarm acknowledge, commissioning) is written to an immutable SHA-256 hash-chained audit log stored on the display computer. The log can be exported from **Settings → Audit Log** for inspection by port authorities or warranty service.
|
||||
|
||||
---
|
||||
|
||||
*© Alvaro Rodríguez Marine — All rights reserved.*
|
||||
*For technical support contact the installation company or email support@arautopilot.com.*
|
||||
Reference in New Issue
Block a user