Files
AR-Autopilot/arautopilot/tests/test_modbus_utils.py
T
alro65 a2f3e82f17 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>
2026-05-20 03:35:23 -04:00

186 lines
5.4 KiB
Python

"""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