a2f3e82f17
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>
186 lines
5.4 KiB
Python
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
|