From 7c91a3e0b9e2362d32bb4d8f3b005b4027de1817 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:02:07 +0545 Subject: [PATCH] fix(adar1000): populate VM_I/VM_Q phase tables; remove dead VM_GAIN The ADAR1000 vector-modulator I/Q lookup tables VM_I[128] and VM_Q[128] were declared but defined as empty initialiser lists since the first commit (5fbe97f). Every call to adarSetRxPhase / adarSetTxPhase therefore wrote (I=0x00, Q=0x00) to registers 0x21/0x23 (Rx) and 0x32/0x34 (Tx) regardless of the requested phase state, leaving beam steering completely non-functional in firmware. This commit: * Populates VM_I[128] and VM_Q[128] from ADAR1000 datasheet Rev. B Tables 13-16 (p.34) on a uniform 2.8125 deg grid (360 / 128 states). Byte format: bits[7:6] reserved 0, bit[5] polarity (1 = positive lobe), bits[4:0] 5-bit unsigned magnitude - exactly as specified. * Removes VM_GAIN[128] declaration and (empty) definition. The ADAR1000 has no separate VM gain register; per-channel VGA gain is set via CHx_RX_GAIN (0x10-0x13) / CHx_TX_GAIN (0x1C-0x1F) by adarSetRxVgaGain / adarSetTxVgaGain. VM_GAIN was never populated, never read anywhere in the firmware, and its presence falsely suggested a missing scaling step in the signal path. * Adds 9_Firmware/tests/cross_layer/adar1000_vm_reference.py: an independently-derived ground-truth module containing the full datasheet table plus byte-format / uniform-grid / quadrant-symmetry / cardinal-point invariant checkers and a tolerant C array parser. * Adds TestTier2Adar1000VmTableGroundTruth (9 tests) to test_cross_layer_contract.py, including a tokenising C/C++ comment+string stripper used by the VM_GAIN reintroduction guard, and an adversarial self-test that corrupts one byte and asserts the comparison detects it (defends against silent bypass via future fixture/parser refactors). Adversarially validated: removing the firmware definitions, flipping a single byte, or reintroducing VM_GAIN as code each cause the suite to fail; restoring causes it to pass. VM_GAIN appearing inside string literals or comments correctly does NOT trip the guard. Closes the empty-table half of the ADAR1000 phase-control bug class. The separate channel-rotation issue (#90) will be addressed in a follow-up PR. Refs: 7_Components Datasheets and Application notes/ADAR1000.pdf Rev. B Tables 13-16 p.34 --- .../ADAR1000_Manager.cpp | 65 ++++- .../9_1_1_C_Cpp_Libraries/ADAR1000_Manager.h | 8 +- .../cross_layer/adar1000_vm_reference.py | 216 ++++++++++++++ .../cross_layer/test_cross_layer_contract.py | 271 ++++++++++++++++++ 4 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 9_Firmware/tests/cross_layer/adar1000_vm_reference.py diff --git a/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.cpp b/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.cpp index 316cb75..e8a49fc 100644 --- a/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.cpp +++ b/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.cpp @@ -20,18 +20,71 @@ static const struct { {ADAR_4_CS_3V3_GPIO_Port, ADAR_4_CS_3V3_Pin} // ADAR1000 #4 }; -// Vector Modulator lookup tables +// ADAR1000 Vector Modulator lookup tables (128-state phase grid, 2.8125 deg step). +// +// Source: Analog Devices ADAR1000 datasheet Rev. B, Tables 13-16, page 34 +// (7_Components Datasheets and Application notes/ADAR1000.pdf) +// Cross-checked against the ADI Linux mainline driver (GPL-2.0, NOT vendored): +// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/ +// drivers/iio/beamformer/adar1000.c (adar1000_phase_values[]) +// The 128 byte values themselves are factual data from the datasheet and are +// not subject to copyright; only the ADI driver code is GPL. +// +// Byte format (per datasheet): +// bit [7:6] reserved (0) +// bit [5] polarity: 1 = positive lobe (sign(I) or sign(Q) >= 0) +// 0 = negative lobe +// bits [4:0] 5-bit unsigned magnitude (0..31) +// At magnitude=0 the polarity bit is physically meaningless; the datasheet +// uses POL=1 (e.g. VM_Q at 0 deg = 0x20, VM_I at 90 deg = 0x21). +// +// Index mapping is uniform: VM_I[k] / VM_Q[k] correspond to phase angle +// k * 360/128 = k * 2.8125 degrees. Callers index as VM_*[phase % 128]. const uint8_t ADAR1000Manager::VM_I[128] = { - // ... (same as in your original file) + 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3E, 0x3E, 0x3D, // [ 0] 0.0000 deg + 0x3D, 0x3C, 0x3C, 0x3B, 0x3A, 0x39, 0x38, 0x37, // [ 8] 22.5000 deg + 0x36, 0x35, 0x34, 0x33, 0x32, 0x30, 0x2F, 0x2E, // [ 16] 45.0000 deg + 0x2C, 0x2B, 0x2A, 0x28, 0x27, 0x25, 0x24, 0x22, // [ 24] 67.5000 deg + 0x21, 0x01, 0x03, 0x04, 0x06, 0x07, 0x08, 0x0A, // [ 32] 90.0000 deg + 0x0B, 0x0D, 0x0E, 0x0F, 0x11, 0x12, 0x13, 0x14, // [ 40] 112.5000 deg + 0x16, 0x17, 0x18, 0x19, 0x19, 0x1A, 0x1B, 0x1C, // [ 48] 135.0000 deg + 0x1C, 0x1D, 0x1E, 0x1E, 0x1E, 0x1F, 0x1F, 0x1F, // [ 56] 157.5000 deg + 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1E, 0x1E, 0x1D, // [ 64] 180.0000 deg + 0x1D, 0x1C, 0x1C, 0x1B, 0x1A, 0x19, 0x18, 0x17, // [ 72] 202.5000 deg + 0x16, 0x15, 0x14, 0x13, 0x12, 0x10, 0x0F, 0x0E, // [ 80] 225.0000 deg + 0x0C, 0x0B, 0x0A, 0x08, 0x07, 0x05, 0x04, 0x02, // [ 88] 247.5000 deg + 0x01, 0x21, 0x23, 0x24, 0x26, 0x27, 0x28, 0x2A, // [ 96] 270.0000 deg + 0x2B, 0x2D, 0x2E, 0x2F, 0x31, 0x32, 0x33, 0x34, // [104] 292.5000 deg + 0x36, 0x37, 0x38, 0x39, 0x39, 0x3A, 0x3B, 0x3C, // [112] 315.0000 deg + 0x3C, 0x3D, 0x3E, 0x3E, 0x3E, 0x3F, 0x3F, 0x3F, // [120] 337.5000 deg }; const uint8_t ADAR1000Manager::VM_Q[128] = { - // ... (same as in your original file) + 0x20, 0x21, 0x23, 0x24, 0x26, 0x27, 0x28, 0x2A, // [ 0] 0.0000 deg + 0x2B, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x33, 0x34, // [ 8] 22.5000 deg + 0x35, 0x36, 0x37, 0x38, 0x38, 0x39, 0x3A, 0x3A, // [ 16] 45.0000 deg + 0x3B, 0x3C, 0x3C, 0x3C, 0x3D, 0x3D, 0x3D, 0x3D, // [ 24] 67.5000 deg + 0x3D, 0x3D, 0x3D, 0x3D, 0x3D, 0x3C, 0x3C, 0x3C, // [ 32] 90.0000 deg + 0x3B, 0x3A, 0x3A, 0x39, 0x38, 0x38, 0x37, 0x36, // [ 40] 112.5000 deg + 0x35, 0x34, 0x33, 0x31, 0x30, 0x2F, 0x2E, 0x2D, // [ 48] 135.0000 deg + 0x2B, 0x2A, 0x28, 0x27, 0x26, 0x24, 0x23, 0x21, // [ 56] 157.5000 deg + 0x20, 0x01, 0x03, 0x04, 0x06, 0x07, 0x08, 0x0A, // [ 64] 180.0000 deg + 0x0B, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x13, 0x14, // [ 72] 202.5000 deg + 0x15, 0x16, 0x17, 0x18, 0x18, 0x19, 0x1A, 0x1A, // [ 80] 225.0000 deg + 0x1B, 0x1C, 0x1C, 0x1C, 0x1D, 0x1D, 0x1D, 0x1D, // [ 88] 247.5000 deg + 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x1C, 0x1C, 0x1C, // [ 96] 270.0000 deg + 0x1B, 0x1A, 0x1A, 0x19, 0x18, 0x18, 0x17, 0x16, // [104] 292.5000 deg + 0x15, 0x14, 0x13, 0x11, 0x10, 0x0F, 0x0E, 0x0D, // [112] 315.0000 deg + 0x0B, 0x0A, 0x08, 0x07, 0x06, 0x04, 0x03, 0x01, // [120] 337.5000 deg }; -const uint8_t ADAR1000Manager::VM_GAIN[128] = { - // ... (same as in your original file) -}; +// NOTE: a VM_GAIN[128] table previously existed here as a placeholder but was +// never populated and never read. The ADAR1000 vector modulator has no +// separate gain register: phase-state magnitude is encoded directly in +// bits [4:0] of the VM_I/VM_Q bytes above. Per-channel VGA gain is a +// distinct register (CHx_RX_GAIN at 0x10-0x13, CHx_TX_GAIN at 0x1C-0x1F) +// written with the user-supplied byte directly by adarSetRxVgaGain() / +// adarSetTxVgaGain(). Do not reintroduce a VM_GAIN[] array. ADAR1000Manager::ADAR1000Manager() { for (int i = 0; i < 4; ++i) { diff --git a/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.h b/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.h index 506e0d8..ae3d570 100644 --- a/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.h +++ b/9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.h @@ -116,10 +116,12 @@ public: bool beam_sweeping_active_ = false; uint32_t last_beam_update_time_ = 0; - // Lookup tables - static const uint8_t VM_I[128]; + // Vector Modulator lookup tables (see ADAR1000_Manager.cpp for provenance). + // Indexed as VM_*[phase % 128] on a uniform 2.8125 deg grid. + // No VM_GAIN[] table exists: VM magnitude is bits [4:0] of the I/Q bytes + // themselves; per-channel VGA gain uses a separate register. + static const uint8_t VM_I[128]; static const uint8_t VM_Q[128]; - static const uint8_t VM_GAIN[128]; // Named defaults for the ADTR1107 and ADAR1000 power sequence. static constexpr uint8_t kDefaultTxVgaGain = 0x7F; diff --git a/9_Firmware/tests/cross_layer/adar1000_vm_reference.py b/9_Firmware/tests/cross_layer/adar1000_vm_reference.py new file mode 100644 index 0000000..0f897d7 --- /dev/null +++ b/9_Firmware/tests/cross_layer/adar1000_vm_reference.py @@ -0,0 +1,216 @@ +"""ADAR1000 vector-modulator ground-truth table and firmware parser. + +This module is a pure data + helpers library imported by the cross-layer +test suite (`9_Firmware/tests/cross_layer/test_cross_layer_contract.py`, +class `TestTier2Adar1000VmTableGroundTruth`). It has no CLI entry point +and no side effects on import beyond the structural assertion on the +table length. + +Ground-truth source +------------------- +The 128-entry `(I, Q)` byte pairs below are transcribed from the ADAR1000 +datasheet Rev. B, Tables 13-16, page 34 ("Phase Shifter Programming"), +which is the primary normative reference. The same values appear in the +Analog Devices Linux beamformer driver +(`drivers/iio/beamformer/adar1000.c`, `adar1000_phase_values[]`) and were +cross-checked against that driver as a secondary, independent +transcription. The byte values are factual data (5-bit unsigned magnitude +in bits[4:0], polarity bit at bit[5], bits[7:6] reserved zero); no +copyrightable creative expression. Only the datasheet is the +licensing-relevant source. + +PLFM_RADAR firmware indexing convention +--------------------------------------- +`adarSetRxPhase` / `adarSetTxPhase` in +`9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.cpp` +write `VM_I[phase % 128]` and `VM_Q[phase % 128]` to the chip. Each index +N corresponds to commanded beam phase `N * 360/128 = N * 2.8125 deg`. The +ADI table is also on a uniform 2.8125 deg grid (verified by +`check_uniform_2p8125_deg_step` below), so a 1:1 mapping is correct: +PLFM index N == ADI table row N. +""" + +from __future__ import annotations + +import re + +# ---------------------------------------------------------------------------- +# Ground truth: ADAR1000 datasheet Rev. B Tables 13-16 p.34 +# Each entry: (angle_int_deg, angle_frac_x10000, vm_byte_I, vm_byte_Q) +# ---------------------------------------------------------------------------- +GROUND_TRUTH: list[tuple[int, int, int, int]] = [ + (0, 0, 0x3F, 0x20), (2, 8125, 0x3F, 0x21), (5, 6250, 0x3F, 0x23), + (8, 4375, 0x3F, 0x24), (11, 2500, 0x3F, 0x26), (14, 625, 0x3E, 0x27), + (16, 8750, 0x3E, 0x28), (19, 6875, 0x3D, 0x2A), (22, 5000, 0x3D, 0x2B), + (25, 3125, 0x3C, 0x2D), (28, 1250, 0x3C, 0x2E), (30, 9375, 0x3B, 0x2F), + (33, 7500, 0x3A, 0x30), (36, 5625, 0x39, 0x31), (39, 3750, 0x38, 0x33), + (42, 1875, 0x37, 0x34), (45, 0, 0x36, 0x35), (47, 8125, 0x35, 0x36), + (50, 6250, 0x34, 0x37), (53, 4375, 0x33, 0x38), (56, 2500, 0x32, 0x38), + (59, 625, 0x30, 0x39), (61, 8750, 0x2F, 0x3A), (64, 6875, 0x2E, 0x3A), + (67, 5000, 0x2C, 0x3B), (70, 3125, 0x2B, 0x3C), (73, 1250, 0x2A, 0x3C), + (75, 9375, 0x28, 0x3C), (78, 7500, 0x27, 0x3D), (81, 5625, 0x25, 0x3D), + (84, 3750, 0x24, 0x3D), (87, 1875, 0x22, 0x3D), (90, 0, 0x21, 0x3D), + (92, 8125, 0x01, 0x3D), (95, 6250, 0x03, 0x3D), (98, 4375, 0x04, 0x3D), + (101, 2500, 0x06, 0x3D), (104, 625, 0x07, 0x3C), (106, 8750, 0x08, 0x3C), + (109, 6875, 0x0A, 0x3C), (112, 5000, 0x0B, 0x3B), (115, 3125, 0x0D, 0x3A), + (118, 1250, 0x0E, 0x3A), (120, 9375, 0x0F, 0x39), (123, 7500, 0x11, 0x38), + (126, 5625, 0x12, 0x38), (129, 3750, 0x13, 0x37), (132, 1875, 0x14, 0x36), + (135, 0, 0x16, 0x35), (137, 8125, 0x17, 0x34), (140, 6250, 0x18, 0x33), + (143, 4375, 0x19, 0x31), (146, 2500, 0x19, 0x30), (149, 625, 0x1A, 0x2F), + (151, 8750, 0x1B, 0x2E), (154, 6875, 0x1C, 0x2D), (157, 5000, 0x1C, 0x2B), + (160, 3125, 0x1D, 0x2A), (163, 1250, 0x1E, 0x28), (165, 9375, 0x1E, 0x27), + (168, 7500, 0x1E, 0x26), (171, 5625, 0x1F, 0x24), (174, 3750, 0x1F, 0x23), + (177, 1875, 0x1F, 0x21), (180, 0, 0x1F, 0x20), (182, 8125, 0x1F, 0x01), + (185, 6250, 0x1F, 0x03), (188, 4375, 0x1F, 0x04), (191, 2500, 0x1F, 0x06), + (194, 625, 0x1E, 0x07), (196, 8750, 0x1E, 0x08), (199, 6875, 0x1D, 0x0A), + (202, 5000, 0x1D, 0x0B), (205, 3125, 0x1C, 0x0D), (208, 1250, 0x1C, 0x0E), + (210, 9375, 0x1B, 0x0F), (213, 7500, 0x1A, 0x10), (216, 5625, 0x19, 0x11), + (219, 3750, 0x18, 0x13), (222, 1875, 0x17, 0x14), (225, 0, 0x16, 0x15), + (227, 8125, 0x15, 0x16), (230, 6250, 0x14, 0x17), (233, 4375, 0x13, 0x18), + (236, 2500, 0x12, 0x18), (239, 625, 0x10, 0x19), (241, 8750, 0x0F, 0x1A), + (244, 6875, 0x0E, 0x1A), (247, 5000, 0x0C, 0x1B), (250, 3125, 0x0B, 0x1C), + (253, 1250, 0x0A, 0x1C), (255, 9375, 0x08, 0x1C), (258, 7500, 0x07, 0x1D), + (261, 5625, 0x05, 0x1D), (264, 3750, 0x04, 0x1D), (267, 1875, 0x02, 0x1D), + (270, 0, 0x01, 0x1D), (272, 8125, 0x21, 0x1D), (275, 6250, 0x23, 0x1D), + (278, 4375, 0x24, 0x1D), (281, 2500, 0x26, 0x1D), (284, 625, 0x27, 0x1C), + (286, 8750, 0x28, 0x1C), (289, 6875, 0x2A, 0x1C), (292, 5000, 0x2B, 0x1B), + (295, 3125, 0x2D, 0x1A), (298, 1250, 0x2E, 0x1A), (300, 9375, 0x2F, 0x19), + (303, 7500, 0x31, 0x18), (306, 5625, 0x32, 0x18), (309, 3750, 0x33, 0x17), + (312, 1875, 0x34, 0x16), (315, 0, 0x36, 0x15), (317, 8125, 0x37, 0x14), + (320, 6250, 0x38, 0x13), (323, 4375, 0x39, 0x11), (326, 2500, 0x39, 0x10), + (329, 625, 0x3A, 0x0F), (331, 8750, 0x3B, 0x0E), (334, 6875, 0x3C, 0x0D), + (337, 5000, 0x3C, 0x0B), (340, 3125, 0x3D, 0x0A), (343, 1250, 0x3E, 0x08), + (345, 9375, 0x3E, 0x07), (348, 7500, 0x3E, 0x06), (351, 5625, 0x3F, 0x04), + (354, 3750, 0x3F, 0x03), (357, 1875, 0x3F, 0x01), +] + +assert len(GROUND_TRUTH) == 128, f"GROUND_TRUTH must have 128 entries, has {len(GROUND_TRUTH)}" + +VM_I_REF: list[int] = [row[2] for row in GROUND_TRUTH] +VM_Q_REF: list[int] = [row[3] for row in GROUND_TRUTH] + + +# ---------------------------------------------------------------------------- +# Structural-invariant checks on the embedded ground-truth transcription. +# These defend against typos during the copy-paste from the datasheet / ADI +# driver. Each function returns a list of error strings (empty == pass) so +# callers (the pytest class) can assert-on-empty with a useful message. +# ---------------------------------------------------------------------------- +def check_byte_format(label: str, table: list[int]) -> list[str]: + """Each byte must have bits[7:6] == 0 (reserved).""" + errors = [] + for i, byte in enumerate(table): + if byte & 0xC0: + errors.append(f"{label}[{i}]=0x{byte:02X}: reserved bits[7:6] non-zero") + return errors + + +def check_uniform_2p8125_deg_step() -> list[str]: + """Angles must form a uniform 2.8125 deg grid: angle[N] == N * 2.8125.""" + errors = [] + for i, (deg_int, deg_frac, _, _) in enumerate(GROUND_TRUTH): + # angle in units of 1/10000 degree; 2.8125 deg = 28125/10000 exactly + angle_e4 = deg_int * 10000 + deg_frac + expected_e4 = i * 28125 + if angle_e4 != expected_e4: + errors.append( + f"GROUND_TRUTH[{i}]: angle {deg_int}.{deg_frac:04d} deg " + f"(={angle_e4}/10000) != expected {expected_e4}/10000 " + f"(=i*2.8125)" + ) + return errors + + +def check_quadrant_symmetry() -> list[str]: + """Angle and angle+180 deg must have inverted polarity bits but identical + magnitudes. Index offset 64 corresponds to 180 deg on the 128-step grid. + + Exemption: when magnitude is zero the polarity bit is physically + meaningless (sign of zero is undefined for the IQ phasor projection). + The datasheet uses POL=1 for both 0 and 180 deg Q components (both + encode Q=0). Skip the polarity assertion for zero-magnitude entries. + """ + errors = [] + POL = 0x20 + MAG = 0x1F + for i in range(64): + j = i + 64 + mag_i_a, mag_i_b = VM_I_REF[i] & MAG, VM_I_REF[j] & MAG + if mag_i_a != mag_i_b: + errors.append( + f"VM_I[{i}]=0x{VM_I_REF[i]:02X} vs VM_I[{j}]=0x{VM_I_REF[j]:02X}: " + f"180 deg pair has different magnitude" + ) + if mag_i_a != 0 and (VM_I_REF[i] & POL) == (VM_I_REF[j] & POL): + errors.append( + f"VM_I[{i}]=0x{VM_I_REF[i]:02X} vs VM_I[{j}]=0x{VM_I_REF[j]:02X}: " + f"180 deg pair has same polarity (should be inverted, mag={mag_i_a})" + ) + mag_q_a, mag_q_b = VM_Q_REF[i] & MAG, VM_Q_REF[j] & MAG + if mag_q_a != mag_q_b: + errors.append( + f"VM_Q[{i}]=0x{VM_Q_REF[i]:02X} vs VM_Q[{j}]=0x{VM_Q_REF[j]:02X}: " + f"180 deg pair has different magnitude" + ) + if mag_q_a != 0 and (VM_Q_REF[i] & POL) == (VM_Q_REF[j] & POL): + errors.append( + f"VM_Q[{i}]=0x{VM_Q_REF[i]:02X} vs VM_Q[{j}]=0x{VM_Q_REF[j]:02X}: " + f"180 deg pair has same polarity (should be inverted, mag={mag_q_a})" + ) + return errors + + +def check_cardinal_points() -> list[str]: + """Spot-check cardinal phase points against datasheet expectations.""" + errors = [] + expectations = [ + (0, 0x3F, 0x20, "0 deg: max +I, ~zero Q"), + (32, 0x21, 0x3D, "90 deg: ~zero I, max +Q"), + (64, 0x1F, 0x20, "180 deg: max -I, ~zero Q"), + (96, 0x01, 0x1D, "270 deg: ~zero I, max -Q"), + ] + for idx, exp_i, exp_q, desc in expectations: + if VM_I_REF[idx] != exp_i or VM_Q_REF[idx] != exp_q: + errors.append( + f"index {idx} ({desc}): expected (0x{exp_i:02X}, 0x{exp_q:02X}), " + f"got (0x{VM_I_REF[idx]:02X}, 0x{VM_Q_REF[idx]:02X})" + ) + return errors + + +# ---------------------------------------------------------------------------- +# Parse VM_I[] / VM_Q[] from firmware C++ source. +# ---------------------------------------------------------------------------- +ARRAY_RE = re.compile( + r"const\s+uint8_t\s+ADAR1000Manager::(?PVM_I|VM_Q|VM_GAIN)\s*" + r"\[\s*128\s*\]\s*=\s*\{(?P[^}]*)\}\s*;", + re.DOTALL, +) +HEX_RE = re.compile(r"0[xX][0-9a-fA-F]{1,2}") + + +def parse_array(source: str, name: str) -> list[int] | None: + """Extract a 128-entry uint8_t array from C++ source by name. + + Returns None if the array is not found. Returns a list (possibly shorter + than 128) of the parsed bytes if found; caller is responsible for length + validation. + + LIMITATION (intentional, see PR fix/adar1000-vm-tables review finding #2): + ARRAY_RE uses `[^}]*` for the body, which terminates at the first `}`. + This is sufficient for the *flat* `const uint8_t NAME[128] = { ... };` + declarations VM_I/VM_Q use today, but it would mis-parse if the array + body ever contained nested braces (e.g. designated initialisers, struct + aggregates, or macro-expansions producing braces). If the firmware ever + needs such a form for the VM tables, replace ARRAY_RE with a balanced + brace-counting parser. Until then, the current regex is preferred for + its simplicity and the round-trip tests will catch any silent breakage. + """ + for m in ARRAY_RE.finditer(source): + if m.group("name") != name: + continue + body = m.group("body") + body = re.sub(r"//[^\n]*", "", body) + body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL) + return [int(tok, 16) for tok in HEX_RE.findall(body)] + return None diff --git a/9_Firmware/tests/cross_layer/test_cross_layer_contract.py b/9_Firmware/tests/cross_layer/test_cross_layer_contract.py index 780f15f..53b1554 100644 --- a/9_Firmware/tests/cross_layer/test_cross_layer_contract.py +++ b/9_Firmware/tests/cross_layer/test_cross_layer_contract.py @@ -41,6 +41,7 @@ import sys THIS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(THIS_DIR)) import contract_parser as cp # noqa: E402 +import adar1000_vm_reference as adar_vm # noqa: E402 # Also add the GUI dir to import radar_protocol sys.path.insert(0, str(cp.GUI_DIR)) @@ -77,6 +78,78 @@ if _in_ci: ) +def _strip_cxx_comments_and_strings(src: str) -> str: + """Return src with all C/C++ comments and string/char literals removed. + + Tokenising state machine with four states: + * CODE — default; watches for `"`, `'`, `//`, `/*` + * STRING ("...") — handles `\\"` and `\\\\` escapes + * CHAR ('...') — handles `\\'` and `\\\\` escapes + * LINE_COMMENT — until next `\\n` + * BLOCK_COMMENT — until next `*/` + + Used by test_vm_gain_table_is_not_reintroduced to ensure the substring + "VM_GAIN" appearing only inside an explanatory comment or a string + literal does NOT count as code reintroduction. We replace stripped + regions with a single space so token boundaries (and line counts, by + approximation — newlines preserved) are not collapsed. + """ + out: list[str] = [] + i = 0 + n = len(src) + CODE, STRING, CHAR, LINE_C, BLOCK_C = 0, 1, 2, 3, 4 + state = CODE + while i < n: + c = src[i] + nxt = src[i + 1] if i + 1 < n else "" + if state == CODE: + if c == "/" and nxt == "/": + state = LINE_C + i += 2 + elif c == "/" and nxt == "*": + state = BLOCK_C + i += 2 + elif c == '"': + state = STRING + i += 1 + elif c == "'": + state = CHAR + i += 1 + else: + out.append(c) + i += 1 + elif state == STRING: + if c == "\\" and i + 1 < n: + i += 2 # skip escape pair (handles \" and \\) + elif c == '"': + state = CODE + i += 1 + else: + i += 1 + elif state == CHAR: + if c == "\\" and i + 1 < n: + i += 2 + elif c == "'": + state = CODE + i += 1 + else: + i += 1 + elif state == LINE_C: + if c == "\n": + out.append("\n") # preserve line numbering + state = CODE + i += 1 + elif state == BLOCK_C: + if c == "*" and nxt == "/": + state = CODE + i += 2 + else: + if c == "\n": + out.append("\n") + i += 1 + return "".join(out) + + def _parse_hex_results(text: str) -> list[dict[str, str]]: """Parse space-separated hex lines from TB output files.""" rows = [] @@ -665,6 +738,204 @@ class TestTier1STM32SettingsPacket: assert flag == [23, 46, 158, 237], f"Start flag: {flag}" +# =================================================================== +# TIER 2: ADAR1000 Vector Modulator Lookup-Table Ground Truth +# =================================================================== +# +# Cross-layer contract: the firmware constants +# ADAR1000Manager::VM_I[128] / VM_Q[128] +# (in 9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/ADAR1000_Manager.cpp) +# MUST equal the byte values published in the ADAR1000 datasheet Rev. B, +# Tables 13-16 page 34 ("Phase Shifter Programming"), on a uniform 2.8125 deg +# grid (index N == phase N * 360/128 deg). +# +# Independent ground truth lives in tools/verify_adar1000_vm_tables.py +# (transcribed from the datasheet, cross-checked against the ADI Linux +# beamformer driver as a secondary source). This test imports that +# reference and asserts a byte-exact match. +# +# Historical bug guarded against: from initial commit through PR #94 the +# arrays shipped as empty placeholders ("// ... (same as in your original +# file)"), so every adarSetRxPhase / adarSetTxPhase call wrote I=Q=0 and +# beam steering was non-functional. A separate VM_GAIN[128] table was +# declared but never read anywhere; this test also enforces its removal so +# it cannot be reintroduced and silently shadow real bugs. + +class TestTier2Adar1000VmTableGroundTruth: + """Firmware ADAR1000 VM_I/VM_Q must match datasheet ground truth byte-exact.""" + + @pytest.fixture(scope="class") + def cpp_source(self): + path = ( + cp.REPO_ROOT + / "9_Firmware" + / "9_1_Microcontroller" + / "9_1_1_C_Cpp_Libraries" + / "ADAR1000_Manager.cpp" + ) + assert path.is_file(), f"Firmware source missing: {path}" + return path.read_text() + + def test_ground_truth_table_shape(self): + """Sanity-check the imported reference (defends against import-path mishap).""" + gt = adar_vm.GROUND_TRUTH + assert len(gt) == 128, "Ground-truth table must have exactly 128 entries" + # Each row is (deg_int, deg_frac_e4, vm_i_byte, vm_q_byte) + for k, row in enumerate(gt): + assert len(row) == 4, f"Row {k} malformed: {row}" + assert 0 <= row[2] <= 0xFF, f"VM_I[{k}] out of byte range: {row[2]:#x}" + assert 0 <= row[3] <= 0xFF, f"VM_Q[{k}] out of byte range: {row[3]:#x}" + # Byte format: bits[7:6] reserved zero, bits[5] polarity, bits[4:0] mag + assert (row[2] & 0xC0) == 0, f"VM_I[{k}] reserved bits set: {row[2]:#x}" + assert (row[3] & 0xC0) == 0, f"VM_Q[{k}] reserved bits set: {row[3]:#x}" + + def test_ground_truth_byte_format(self): + """Transcription self-check: every VM_I/VM_Q byte has reserved bits clear.""" + errors = adar_vm.check_byte_format("VM_I_REF", adar_vm.VM_I_REF) + errors += adar_vm.check_byte_format("VM_Q_REF", adar_vm.VM_Q_REF) + assert not errors, ( + "Byte-format violations in embedded GROUND_TRUTH (likely transcription " + "typo from ADAR1000 datasheet Tables 13-16):\n " + "\n ".join(errors) + ) + + def test_ground_truth_uniform_2p8125_deg_grid(self): + """Transcription self-check: angles form a uniform 2.8125 deg grid. + + This is the assumption that lets the firmware use `VM_*[phase % 128]` + as a direct index (no nearest-neighbour search). If the embedded + angles drift off the grid, the firmware's indexing model is wrong. + """ + errors = adar_vm.check_uniform_2p8125_deg_step() + assert not errors, ( + "Non-uniform angle grid in GROUND_TRUTH:\n " + "\n ".join(errors) + ) + + def test_ground_truth_quadrant_symmetry(self): + """Transcription self-check: phi and phi+180 deg have same magnitude, + opposite polarity. Catches swapped/rotated rows in the table. + """ + errors = adar_vm.check_quadrant_symmetry() + assert not errors, ( + "Quadrant-symmetry violation in GROUND_TRUTH (table rows may be " + "transposed or mis-transcribed):\n " + "\n ".join(errors) + ) + + def test_ground_truth_cardinal_points(self): + """Transcription self-check: the four cardinal phases (0, 90, 180, + 270 deg) match the datasheet-published extrema exactly. + """ + errors = adar_vm.check_cardinal_points() + assert not errors, ( + "Cardinal-point mismatch in GROUND_TRUTH vs ADAR1000 datasheet " + "Tables 13-16:\n " + "\n ".join(errors) + ) + + def test_firmware_vm_i_matches_datasheet(self, cpp_source): + gt = adar_vm.GROUND_TRUTH + firmware = adar_vm.parse_array(cpp_source, "VM_I") + assert firmware is not None, ( + "Could not parse VM_I[128] from ADAR1000_Manager.cpp; " + "definition pattern may have drifted" + ) + assert len(firmware) == 128, ( + f"VM_I has {len(firmware)} entries, expected 128. " + "Empty placeholder regression — every phase write would emit I=0 " + "and beam steering would be silently broken." + ) + mismatches = [ + (k, firmware[k], gt[k][2]) + for k in range(128) + if firmware[k] != gt[k][2] + ] + assert not mismatches, ( + f"VM_I diverges from datasheet at {len(mismatches)} indices; " + f"first 5: {mismatches[:5]}" + ) + + def test_firmware_vm_q_matches_datasheet(self, cpp_source): + gt = adar_vm.GROUND_TRUTH + firmware = adar_vm.parse_array(cpp_source, "VM_Q") + assert firmware is not None, ( + "Could not parse VM_Q[128] from ADAR1000_Manager.cpp; " + "definition pattern may have drifted" + ) + assert len(firmware) == 128, ( + f"VM_Q has {len(firmware)} entries, expected 128. " + "Empty placeholder regression — every phase write would emit Q=0." + ) + mismatches = [ + (k, firmware[k], gt[k][3]) + for k in range(128) + if firmware[k] != gt[k][3] + ] + assert not mismatches, ( + f"VM_Q diverges from datasheet at {len(mismatches)} indices; " + f"first 5: {mismatches[:5]}" + ) + + def test_vm_gain_table_is_not_reintroduced(self, cpp_source): + """Dead-code regression guard: VM_GAIN[128] must not exist as code. + + The ADAR1000 vector modulator has no separate gain register; magnitude + is bits[4:0] of the I/Q bytes themselves. Per-channel VGA gain uses + registers CHx_RX_GAIN (0x10-0x13) / CHx_TX_GAIN (0x1C-0x1F) written + directly by adarSetRxVgaGain / adarSetTxVgaGain. A VM_GAIN[] array + was declared in early development, never populated, never read, and + was removed in PR fix/adar1000-vm-tables. Reintroducing it would + suggest (falsely) that an extra lookup is needed and could mask the + real signal path. + + Uses a tokenising comment/string stripper so that the historical + explanation comment in the cpp file, as well as any string literal + containing the substring "VM_GAIN", does not trip the check. + """ + stripped = _strip_cxx_comments_and_strings(cpp_source) + assert "VM_GAIN" not in stripped, ( + "VM_GAIN symbol reappeared in ADAR1000_Manager.cpp executable code. " + "This array has no hardware backing and must not be reintroduced. " + "If you need to scale phase-state magnitude, modify VM_I/VM_Q " + "bits[4:0] directly per the datasheet." + ) + + def test_adversarial_corruption_is_detected(self): + """Adversarial self-test: a flipped byte in firmware MUST fail comparison. + + Defends against silent bypass — e.g. a future refactor that mocks + parse_array() or compares len() only. We synthesise a corrupted cpp + source string, run the same parser, and assert mismatch is detected. + """ + gt = adar_vm.GROUND_TRUTH + # Build a minimal valid-looking cpp snippet with one corrupted byte. + good_i = ", ".join(f"0x{gt[k][2]:02X}" for k in range(128)) + good_q = ", ".join(f"0x{gt[k][3]:02X}" for k in range(128)) + snippet_good = ( + f"const uint8_t ADAR1000Manager::VM_I[128] = {{ {good_i} }};\n" + f"const uint8_t ADAR1000Manager::VM_Q[128] = {{ {good_q} }};\n" + ) + # Sanity: the unmodified snippet must parse and match. + parsed_i = adar_vm.parse_array(snippet_good, "VM_I") + assert parsed_i is not None and len(parsed_i) == 128 + assert all(parsed_i[k] == gt[k][2] for k in range(128)), ( + "Self-test setup error: golden snippet does not match GROUND_TRUTH" + ) + # Now flip the low bit of VM_I[42] and confirm detection. + corrupted_byte = gt[42][2] ^ 0x01 + bad_i = ", ".join( + f"0x{(corrupted_byte if k == 42 else gt[k][2]):02X}" + for k in range(128) + ) + snippet_bad = ( + f"const uint8_t ADAR1000Manager::VM_I[128] = {{ {bad_i} }};\n" + f"const uint8_t ADAR1000Manager::VM_Q[128] = {{ {good_q} }};\n" + ) + parsed_bad = adar_vm.parse_array(snippet_bad, "VM_I") + assert parsed_bad is not None and len(parsed_bad) == 128 + assert parsed_bad[42] != gt[42][2], ( + "Adversarial self-test FAILED: corrupted byte at index 42 was " + "not detected by parse_array. The cross-layer test is bypassable." + ) + + # =================================================================== # TIER 2: Verilog Cosimulation # ===================================================================