gr-apollo/tests/test_uplink_encoder.py
Ryan Malloy 0ee7ff0ad7 Implement full Apollo USB downlink decoder chain
Complete signal processing pipeline from complex baseband to decoded
PCM telemetry, verified against the 1965 NAA Study Guide (A-624):

Core demod (Phase 1):
  - PM demodulator with carrier PLL recovery
  - 1.024 MHz subcarrier extractor (bandpass + downconvert)
  - BPSK demodulator with Costas loop + symbol sync
  - Convenience hier_block2 combining subcarrier + BPSK

PCM frame processing (Phase 2):
  - 32-bit frame sync with Hamming distance correlator
  - SEARCH/VERIFY/LOCKED state machine, complement-on-odd handling
  - Frame demultiplexer (128-word, A/D voltage scaling)
  - AGC downlink decoder (15-bit word reassembly from DNTM1/DNTM2)

Voice and analog (Phase 3):
  - 1.25 MHz FM voice subcarrier demod to 8 kHz audio
  - SCO demodulator for 9 analog sensor channels (14.5-165 kHz)

Virtual AGC integration (Phase 4):
  - TCP bridge client with auto-reconnect and channel filtering
  - DSKY uplink encoder (VERB/NOUN/DATA command sequences)

Top-level receiver (Phase 5):
  - usb_downlink_receiver hier_block2: one block, complex in, PDUs out
  - 14 GRC block YAML definitions for GNU Radio Companion
  - Example scripts for signal analysis and full-chain demo

Infrastructure:
  - constants.py with all timing/frequency/frame parameters
  - protocol.py for sync word + AGC packet encode/decode
  - Synthetic USB signal generator for testing
  - 222 tests passing, ruff lint clean
2026-02-20 13:18:42 -07:00

307 lines
10 KiB
Python

"""Tests for UplinkEncoder — AGC INLINK command formatting.
Verifies that DSKY command sequences (VERB, NOUN, DATA, PROCEED) are
correctly encoded as (channel, value) pairs for delivery to AGC channel 045.
No GNU Radio required.
"""
import pytest
from apollo.constants import AGC_CH_INLINK
from apollo.protocol import form_io_packet, parse_io_packet
from apollo.uplink_encoder import (
KEYCODE_DIGITS,
KEYCODE_ENTER,
KEYCODE_MINUS,
KEYCODE_NOUN,
KEYCODE_PLUS,
KEYCODE_VERB,
UplinkEncoder,
)
@pytest.fixture
def encoder():
return UplinkEncoder()
class TestKeycodeEncoding:
"""Basic keycode → (channel, value) encoding."""
def test_channel_is_inlink(self, encoder):
"""All encoded pairs use the INLINK channel by default."""
ch, _ = encoder.encode_keycode(KEYCODE_VERB)
assert ch == AGC_CH_INLINK
def test_custom_channel(self):
"""Custom channel overrides the default."""
enc = UplinkEncoder(channel=99)
ch, _ = enc.encode_keycode(KEYCODE_VERB)
assert ch == 99
def test_keycode_in_upper_bits(self, encoder):
"""Keycode occupies bits 14-10 of the 15-bit value."""
_, value = encoder.encode_keycode(KEYCODE_VERB)
extracted = (value >> 10) & 0x1F
assert extracted == KEYCODE_VERB
def test_lower_bits_zero(self, encoder):
"""Bits 9-0 are zero for a simple keypress."""
_, value = encoder.encode_keycode(KEYCODE_NOUN)
assert (value & 0x3FF) == 0
def test_value_is_15_bit(self, encoder):
"""Encoded value fits in 15 bits."""
_, value = encoder.encode_keycode(0x1F) # max 5-bit keycode
assert 0 <= value <= 0x7FFF
class TestDigitEncoding:
"""Digit (0-9) keycode encoding."""
def test_all_digits_encode(self, encoder):
"""Each digit 0-9 produces a valid (channel, value) pair."""
for d in range(10):
ch, val = encoder.encode_digit(d)
assert ch == AGC_CH_INLINK
assert 0 <= val <= 0x7FFF
def test_digit_keycodes_unique(self, encoder):
"""Each digit maps to a distinct keycode/value."""
values = set()
for d in range(10):
_, val = encoder.encode_digit(d)
values.add(val)
assert len(values) == 10
def test_invalid_digit(self, encoder):
with pytest.raises(ValueError):
encoder.encode_digit(10)
with pytest.raises(ValueError):
encoder.encode_digit(-1)
class TestVerbEncoding:
"""VERB command encoding (VERB key + 2 digit keys)."""
def test_verb_sequence_length(self, encoder):
"""V37 produces 3 pairs: VERB + digit + digit."""
pairs = encoder.encode_verb(37)
assert len(pairs) == 3
def test_verb_key_first(self, encoder):
"""First pair in the sequence is the VERB keycode."""
pairs = encoder.encode_verb(37)
_, value = pairs[0]
assert (value >> 10) & 0x1F == KEYCODE_VERB
def test_verb_digits_correct(self, encoder):
"""Digits encode the verb number."""
pairs = encoder.encode_verb(37)
_, d1_val = pairs[1]
_, d2_val = pairs[2]
# Digit 3
assert (d1_val >> 10) & 0x1F == KEYCODE_DIGITS[3]
# Digit 7
assert (d2_val >> 10) & 0x1F == KEYCODE_DIGITS[7]
def test_verb_zero_padded(self, encoder):
"""V06 encodes as VERB, 0, 6."""
pairs = encoder.encode_verb(6)
_, d1_val = pairs[1]
_, d2_val = pairs[2]
assert (d1_val >> 10) & 0x1F == KEYCODE_DIGITS[0]
assert (d2_val >> 10) & 0x1F == KEYCODE_DIGITS[6]
def test_verb_boundary_values(self, encoder):
pairs_0 = encoder.encode_verb(0)
assert len(pairs_0) == 3
pairs_99 = encoder.encode_verb(99)
assert len(pairs_99) == 3
def test_verb_out_of_range(self, encoder):
with pytest.raises(ValueError):
encoder.encode_verb(100)
with pytest.raises(ValueError):
encoder.encode_verb(-1)
class TestNounEncoding:
"""NOUN command encoding."""
def test_noun_sequence_length(self, encoder):
pairs = encoder.encode_noun(1)
assert len(pairs) == 3
def test_noun_key_first(self, encoder):
pairs = encoder.encode_noun(1)
_, value = pairs[0]
assert (value >> 10) & 0x1F == KEYCODE_NOUN
def test_noun_digits(self, encoder):
pairs = encoder.encode_noun(47)
_, d1_val = pairs[1]
_, d2_val = pairs[2]
assert (d1_val >> 10) & 0x1F == KEYCODE_DIGITS[4]
assert (d2_val >> 10) & 0x1F == KEYCODE_DIGITS[7]
def test_noun_out_of_range(self, encoder):
with pytest.raises(ValueError):
encoder.encode_noun(100)
class TestDataEncoding:
"""Signed/unsigned data entry encoding."""
def test_positive_data_starts_with_plus(self, encoder):
pairs = encoder.encode_data(12345)
_, sign_val = pairs[0]
assert (sign_val >> 10) & 0x1F == KEYCODE_PLUS
def test_negative_data_starts_with_minus(self, encoder):
pairs = encoder.encode_data(-12345)
_, sign_val = pairs[0]
assert (sign_val >> 10) & 0x1F == KEYCODE_MINUS
def test_signed_data_length(self, encoder):
"""Signed data: sign + 5 digits = 6 pairs."""
pairs = encoder.encode_data(12345)
assert len(pairs) == 6
def test_unsigned_data_length(self, encoder):
"""Unsigned data: 5 digits only = 5 pairs."""
pairs = encoder.encode_data(12345, signed=False)
assert len(pairs) == 5
def test_data_digits(self, encoder):
"""Verify digit sequence for +00042."""
pairs = encoder.encode_data(42)
# Skip sign (index 0), check digits 0, 0, 0, 4, 2
expected_digits = [0, 0, 0, 4, 2]
for i, expected_d in enumerate(expected_digits):
_, val = pairs[i + 1]
actual_keycode = (val >> 10) & 0x1F
assert actual_keycode == KEYCODE_DIGITS[expected_d], (
f"digit {i}: expected {expected_d} (keycode {KEYCODE_DIGITS[expected_d]}), "
f"got keycode {actual_keycode}"
)
def test_data_zero(self, encoder):
"""Zero encodes as +00000."""
pairs = encoder.encode_data(0)
assert len(pairs) == 6 # sign + 5 digits
def test_data_max_value(self, encoder):
pairs = encoder.encode_data(99999)
assert len(pairs) == 6
def test_data_out_of_range(self, encoder):
with pytest.raises(ValueError):
encoder.encode_data(100000)
with pytest.raises(ValueError):
encoder.encode_data(-100000)
class TestProceedEncoding:
"""PROCEED/ENTER key encoding."""
def test_proceed_single_pair(self, encoder):
pairs = encoder.encode_proceed()
assert len(pairs) == 1
def test_proceed_is_enter_key(self, encoder):
pairs = encoder.encode_proceed()
_, value = pairs[0]
assert (value >> 10) & 0x1F == KEYCODE_ENTER
class TestCommandDispatch:
"""High-level encode_command() dispatch."""
def test_verb_dispatch(self, encoder):
pairs = encoder.encode_command("VERB", 37)
assert len(pairs) == 3
def test_noun_dispatch(self, encoder):
pairs = encoder.encode_command("NOUN", 1)
assert len(pairs) == 3
def test_data_dispatch(self, encoder):
pairs = encoder.encode_command("DATA", 42)
assert len(pairs) == 6
def test_proceed_dispatch(self, encoder):
pairs = encoder.encode_command("PROCEED")
assert len(pairs) == 1
def test_case_insensitive(self, encoder):
"""Command type matching is case-insensitive."""
p1 = encoder.encode_command("verb", 37)
p2 = encoder.encode_command("Verb", 37)
p3 = encoder.encode_command("VERB", 37)
assert p1 == p2 == p3
def test_unknown_command(self, encoder):
with pytest.raises(ValueError, match="unknown command type"):
encoder.encode_command("ABORT", 0)
def test_missing_data_for_verb(self, encoder):
with pytest.raises(ValueError, match="VERB requires"):
encoder.encode_command("VERB")
def test_missing_data_for_noun(self, encoder):
with pytest.raises(ValueError, match="NOUN requires"):
encoder.encode_command("NOUN")
def test_missing_data_for_data(self, encoder):
with pytest.raises(ValueError, match="DATA requires"):
encoder.encode_command("DATA")
class TestVerbNounConvenience:
"""encode_verb_noun() full command sequence."""
def test_full_sequence_length(self, encoder):
"""V37N01 ENTER = VERB+3+7 + NOUN+0+1 + ENTER = 7 pairs."""
pairs = encoder.encode_verb_noun(37, 1)
assert len(pairs) == 7
def test_sequence_structure(self, encoder):
"""Verify key ordering: VERB, d, d, NOUN, d, d, ENTER."""
pairs = encoder.encode_verb_noun(16, 65)
keycodes = [(v >> 10) & 0x1F for _, v in pairs]
assert keycodes[0] == KEYCODE_VERB
assert keycodes[1] == KEYCODE_DIGITS[1]
assert keycodes[2] == KEYCODE_DIGITS[6]
assert keycodes[3] == KEYCODE_NOUN
assert keycodes[4] == KEYCODE_DIGITS[6]
assert keycodes[5] == KEYCODE_DIGITS[5]
assert keycodes[6] == KEYCODE_ENTER
def test_all_pairs_use_inlink(self, encoder):
pairs = encoder.encode_verb_noun(37, 1)
for ch, _ in pairs:
assert ch == AGC_CH_INLINK
class TestPacketCompatibility:
"""Verify encoded values survive the AGC packet protocol roundtrip."""
def test_keycode_survives_packet_roundtrip(self, encoder):
"""Each (channel, value) pair can be packed/unpacked via form_io_packet."""
pairs = encoder.encode_verb_noun(37, 1)
for channel, value in pairs:
packet = form_io_packet(channel, value)
got_ch, got_val, _ = parse_io_packet(packet)
assert got_ch == channel
assert got_val == value
def test_data_value_survives_packet_roundtrip(self, encoder):
"""Data encoding survives the 15-bit packet protocol."""
pairs = encoder.encode_data(54321)
for channel, value in pairs:
packet = form_io_packet(channel, value)
got_ch, got_val, _ = parse_io_packet(packet)
assert got_ch == channel
assert got_val == value