290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Driver maths and register sequences -- no hardware, no USB."""
|
|
|
|
import pytest
|
|
|
|
import rm3100
|
|
from conftest import FakeBus
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Gain and resolution
|
|
# --------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("cycle_count, table_lsb_per_ut", [
|
|
(50, 20), (100, 38), (200, 75),
|
|
])
|
|
def test_gain_fit_reproduces_table_3_1(cycle_count, table_lsb_per_ut):
|
|
"""The fit exists to reproduce Table 3-1's three points within a count."""
|
|
fitted = rm3100.gain_lsb_per_tesla(cycle_count) / rm3100.UT_PER_TESLA
|
|
assert abs(fitted - table_lsb_per_ut) < 1.0
|
|
|
|
|
|
def test_tesla_per_count_inverts_the_gain():
|
|
for cc in (30, 100, 400, 65535):
|
|
assert rm3100.tesla_per_count(cc) * rm3100.gain_lsb_per_tesla(cc) == pytest.approx(1.0)
|
|
|
|
|
|
def test_higher_cycle_count_means_finer_lsb():
|
|
steps = [rm3100.tesla_per_count(cc) for cc in (50, 100, 200, 400)]
|
|
assert steps == sorted(steps, reverse=True)
|
|
|
|
|
|
def test_expected_noise_follows_inverse_sqrt():
|
|
"""Table 3-1's 30/20/15 nT at 50/100/200 is a K/sqrt(cc) law."""
|
|
assert rm3100.expected_noise_nt(50) == pytest.approx(29.4, abs=0.7)
|
|
assert rm3100.expected_noise_nt(100) == pytest.approx(20.8, abs=0.9)
|
|
assert rm3100.expected_noise_nt(200) == pytest.approx(14.7, abs=0.4)
|
|
# Quadrupling the cycle count halves the noise.
|
|
assert (rm3100.expected_noise_nt(100)
|
|
== pytest.approx(2 * rm3100.expected_noise_nt(400)))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Timing model
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_sample_period_matches_the_documented_model():
|
|
expected = 3 * (100 / rm3100.COUNTS_PER_SECOND + rm3100.AXIS_OVERHEAD_S)
|
|
assert rm3100.sample_period(100) == pytest.approx(expected)
|
|
# The default configuration is quoted at ~282 Hz throughout the docs.
|
|
assert 1 / rm3100.sample_period(100) == pytest.approx(282, abs=1)
|
|
|
|
|
|
def test_integration_time_excludes_overhead():
|
|
assert rm3100.integration_time(100) < rm3100.sample_period(100)
|
|
assert rm3100.integration_time(100) == pytest.approx(3 * 100 / 90000.0)
|
|
|
|
|
|
def test_duty_rises_with_cycle_count():
|
|
"""Fixed per-axis overhead is a shrinking share as integration grows."""
|
|
duties = [rm3100.integration_time(cc) / rm3100.sample_period(cc)
|
|
for cc in (50, 100, 200, 800)]
|
|
assert duties == sorted(duties)
|
|
assert duties[-1] < 1.0
|
|
|
|
|
|
@pytest.mark.parametrize("rate", [0.5, 1, 8, 32, 128, 282, 500])
|
|
def test_cycle_count_for_rate_inverts_sample_period(rate):
|
|
cc = rm3100.cycle_count_for_rate(rate)
|
|
achieved = 1.0 / rm3100.sample_period(cc)
|
|
# Rounding to an integer cycle count is the only error here.
|
|
assert achieved == pytest.approx(rate, rel=0.01)
|
|
|
|
|
|
def test_cycle_count_for_rate_clamps_to_the_register_width():
|
|
assert rm3100.cycle_count_for_rate(1e9) == 1
|
|
assert rm3100.cycle_count_for_rate(1e-9) == rm3100.MAX_CYCLE_COUNT
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# plan(): every branch, since it is the only place configuration is resolved
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_plan_default_is_the_documented_configuration():
|
|
cfg = rm3100.plan()
|
|
assert cfg.cycle_count == rm3100.DEFAULT_CYCLE_COUNT
|
|
assert cfg.tmrc == rm3100.TMRC_FASTEST
|
|
assert cfg.governed_by == "cycle count"
|
|
assert cfg.predicted_hz == pytest.approx(282, abs=1)
|
|
assert cfg.duty == pytest.approx(0.94, abs=0.01)
|
|
assert cfg.notes == []
|
|
|
|
|
|
def test_plan_from_rate_derives_the_cycle_count():
|
|
cfg = rm3100.plan(rate_hz=50)
|
|
assert cfg.predicted_hz == pytest.approx(50, rel=0.01)
|
|
assert cfg.governed_by == "cycle count"
|
|
assert cfg.notes == []
|
|
|
|
|
|
def test_plan_from_cycle_count_ignores_the_rate_knob():
|
|
cfg = rm3100.plan(cycle_count=400)
|
|
assert cfg.cycle_count == 400
|
|
assert cfg.predicted_hz == pytest.approx(1 / rm3100.sample_period(400))
|
|
|
|
|
|
def test_plan_raises_a_cycle_count_below_the_quantisation_floor():
|
|
cfg = rm3100.plan(cycle_count=5)
|
|
assert cfg.cycle_count == rm3100.MIN_CYCLE_COUNT
|
|
assert any("quantisation floor" in n for n in cfg.notes)
|
|
|
|
|
|
def test_plan_clamps_a_cycle_count_above_the_register_width():
|
|
cfg = rm3100.plan(cycle_count=100_000)
|
|
assert cfg.cycle_count == rm3100.MAX_CYCLE_COUNT
|
|
|
|
|
|
def test_plan_below_the_cycle_count_floor_hands_the_cadence_to_tmrc():
|
|
"""Under ~0.46 Hz the 16-bit register runs out and only TMRC can go slower."""
|
|
rate = rm3100.MIN_RATE_BY_CYCLE_COUNT / 2
|
|
cfg = rm3100.plan(rate_hz=rate)
|
|
assert cfg.cycle_count == rm3100.MAX_CYCLE_COUNT
|
|
assert cfg.governed_by == "TMRC"
|
|
assert cfg.tmrc == min(rm3100.TMRC_RATES,
|
|
key=lambda t: abs(rm3100.TMRC_RATES[t] - rate))
|
|
assert any("below the" in n for n in cfg.notes)
|
|
|
|
|
|
def test_plan_warns_when_tmrc_governs_and_the_sensor_idles():
|
|
"""cc=50 can run at 534 Hz; TMRC 0x99 asks for 4.5, so it idles ~99%."""
|
|
cfg = rm3100.plan(cycle_count=50, tmrc=0x99)
|
|
assert cfg.governed_by == "TMRC"
|
|
assert cfg.predicted_hz == pytest.approx(4.5)
|
|
assert cfg.duty < 0.02
|
|
assert any("idle" in n for n in cfg.notes)
|
|
|
|
|
|
def test_plan_notes_a_rate_it_cannot_reach():
|
|
cfg = rm3100.plan(rate_hz=5000)
|
|
assert cfg.predicted_hz < 5000
|
|
assert any("faster than this configuration can reach" in n for n in cfg.notes)
|
|
|
|
|
|
def test_plan_duty_is_integration_over_the_achieved_period():
|
|
cfg = rm3100.plan(cycle_count=200)
|
|
assert cfg.duty == pytest.approx(
|
|
rm3100.integration_time(200) * cfg.predicted_hz)
|
|
|
|
|
|
def test_plan_rejects_an_unknown_tmrc():
|
|
with pytest.raises(KeyError):
|
|
rm3100.plan(tmrc=0x00)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Measurement decoding -- 24-bit two's complement, big-endian
|
|
# --------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("raw, expected", [
|
|
(b"\x00\x00\x00" * 3, (0, 0, 0)),
|
|
(b"\x00\x00\x01" * 3, (1, 1, 1)),
|
|
(b"\xff\xff\xff" * 3, (-1, -1, -1)),
|
|
(b"\x7f\xff\xff" * 3, (8388607, 8388607, 8388607)), # positive rail
|
|
(b"\x80\x00\x00" * 3, (-8388608, -8388608, -8388608)), # negative rail
|
|
])
|
|
def test_decode_measurements_sign_extends(raw, expected):
|
|
assert rm3100.decode_measurements(raw) == expected
|
|
|
|
|
|
def test_decode_measurements_keeps_axes_in_order():
|
|
assert rm3100.decode_measurements(
|
|
bytes([0, 0, 1, 0, 0, 2, 255, 255, 255])) == (1, 2, -1)
|
|
|
|
|
|
@pytest.mark.parametrize("length", [0, 8, 10, 27])
|
|
def test_decode_measurements_rejects_a_wrong_length(length):
|
|
with pytest.raises(ValueError, match="9 measurement bytes"):
|
|
rm3100.decode_measurements(bytes(length))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Register access
|
|
# --------------------------------------------------------------------------
|
|
|
|
def test_set_cycle_counts_writes_all_three_axes_big_endian():
|
|
bus = FakeBus()
|
|
sensor = rm3100.RM3100(bus, 0x23)
|
|
sensor.set_cycle_counts(0x0190)
|
|
assert bus.writes == [(0x23, bytes([rm3100.REG_CCX]) + b"\x01\x90" * 3)]
|
|
assert sensor.get_cycle_counts() == (400, 400, 400)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [-1, 0x10000, 999999])
|
|
def test_set_cycle_counts_rejects_out_of_range(bad):
|
|
sensor = rm3100.RM3100(FakeBus(), 0x23)
|
|
with pytest.raises(ValueError, match="outside 0..65535"):
|
|
sensor.set_cycle_counts(bad)
|
|
|
|
|
|
def test_set_rate_rejects_a_value_outside_the_tmrc_table():
|
|
sensor = rm3100.RM3100(FakeBus(), 0x23)
|
|
with pytest.raises(ValueError, match="not one of"):
|
|
sensor.set_rate(0x91)
|
|
sensor.set_rate(rm3100.TMRC_FASTEST) # a valid one must not raise
|
|
|
|
|
|
def test_configure_accepts_a_correct_hshake_readback():
|
|
bus = FakeBus({rm3100.REG_HSHAKE: 0x1B})
|
|
rm3100.RM3100(bus, 0x23).configure()
|
|
assert bus.registers[rm3100.REG_HSHAKE] == rm3100.HSHAKE_DRDY_ON_READ_ONLY
|
|
|
|
|
|
def test_configure_ignores_the_read_only_nack_bits():
|
|
"""Bits 4-6 are NACK status, so they may read back set without meaning failure."""
|
|
bus = FakeBus()
|
|
sensor = rm3100.RM3100(bus, 0x23)
|
|
|
|
def read_reg(reg, count=1):
|
|
return bytes([rm3100.HSHAKE_DRDY_ON_READ_ONLY | 0x70]), 0.0, 0.0
|
|
|
|
sensor.read_reg = read_reg
|
|
sensor.configure() # must not raise
|
|
|
|
|
|
def test_configure_raises_when_drc1_did_not_take():
|
|
"""Exactly-once sampling depends on DRC1, so a bad readback is fatal."""
|
|
sensor = rm3100.RM3100(FakeBus(), 0x23)
|
|
sensor.read_reg = lambda reg, count=1: (b"\x1b", 0.0, 0.0)
|
|
with pytest.raises(IOError, match="HSHAKE did not take"):
|
|
sensor.configure()
|
|
|
|
|
|
def test_read_reg_prefers_a_combined_transaction():
|
|
bus = FakeBus({rm3100.REG_REVID: rm3100.EXPECTED_REVID})
|
|
sensor = rm3100.RM3100(bus, 0x23)
|
|
assert sensor.revid() == rm3100.EXPECTED_REVID
|
|
# One transaction, not a separate write then read.
|
|
assert len(bus.writes) == 1
|
|
|
|
|
|
def test_read_reg_falls_back_when_the_bus_cannot_do_repeated_start():
|
|
bus = FakeBus({rm3100.REG_REVID: rm3100.EXPECTED_REVID}, combined=False)
|
|
sensor = rm3100.RM3100(bus, 0x23)
|
|
assert sensor.revid() == rm3100.EXPECTED_REVID
|
|
|
|
|
|
def test_read_raw_decodes_from_the_measurement_registers():
|
|
registers = {rm3100.REG_MX + i: b for i, b in enumerate(
|
|
[0x00, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0x7F, 0xFF, 0xFF])}
|
|
sensor = rm3100.RM3100(FakeBus(registers), 0x23)
|
|
counts, mono, wall = sensor.read_raw()
|
|
assert counts == (1, -1, 8388607)
|
|
assert mono > 0 and wall > mono # the stamp travels with the data
|
|
|
|
|
|
def test_read_measurements_converts_with_the_active_cycle_count():
|
|
registers = {rm3100.REG_MX + i: b for i, b in enumerate(
|
|
[0x00, 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])}
|
|
sensor = rm3100.RM3100(FakeBus(registers), 0x23)
|
|
sensor.cycle_count = 100
|
|
counts, tesla = sensor.read_measurements()
|
|
assert counts[0] == 0x26
|
|
# 38 LSB/uT at cc=100, so 0x26 counts is very close to 1 uT.
|
|
assert tesla[0] == pytest.approx(1e-6, rel=0.01)
|
|
|
|
|
|
def test_data_ready_reads_the_drdy_bit():
|
|
sensor = rm3100.RM3100(FakeBus({rm3100.REG_STATUS: 0x80}), 0x23)
|
|
assert sensor.data_ready() is True
|
|
sensor = rm3100.RM3100(FakeBus({rm3100.REG_STATUS: 0x00}), 0x23)
|
|
assert sensor.data_ready() is False
|
|
|
|
|
|
def test_wait_for_data_times_out_without_drdy():
|
|
sensor = rm3100.RM3100(FakeBus({rm3100.REG_STATUS: 0x00}), 0x23)
|
|
assert sensor.wait_for_data(timeout=0.01, interval=0.001) is False
|
|
|
|
|
|
def test_cmm_start_and_stop_write_the_documented_values():
|
|
bus = FakeBus()
|
|
sensor = rm3100.RM3100(bus, 0x23)
|
|
sensor.start_cmm()
|
|
sensor.stop_cmm()
|
|
assert bus.writes == [
|
|
(0x23, bytes([rm3100.REG_CMM, rm3100.CMM_ALL_AXES])),
|
|
(0x23, bytes([rm3100.REG_CMM, rm3100.CMM_OFF])),
|
|
]
|
|
|
|
|
|
def test_addresses_cover_both_strap_pins():
|
|
assert list(rm3100.RM3100.ADDRESSES) == [0x20, 0x21, 0x22, 0x23]
|