146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Pure numerical contracts for matched H3 feedback and packet semantics."""
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
|
||
|
|
CODE_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
sys.path.insert(0, str(CODE_ROOT))
|
||
|
|
|
||
|
|
from core.feedback_protocol import ( # noqa: E402
|
||
|
|
ForwardPacket,
|
||
|
|
MapPolicy,
|
||
|
|
MapRegistry,
|
||
|
|
MapSnapshot,
|
||
|
|
MappingKind,
|
||
|
|
PacketRejectReason,
|
||
|
|
PacketState,
|
||
|
|
ReturnPacket,
|
||
|
|
map_return_feedback,
|
||
|
|
normalized_actual_power_mismatch,
|
||
|
|
)
|
||
|
|
from core.network_emulator import ( # noqa: E402
|
||
|
|
DeterministicChannel,
|
||
|
|
NetworkTraceEntry,
|
||
|
|
PacketReceiver,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class FeedbackProtocolTest(unittest.TestCase):
|
||
|
|
def make_packet(self, seq=3, map_id=1):
|
||
|
|
wrench = np.array([1.0, -2.0, 0.5, 0.2, -0.3, 0.1])
|
||
|
|
Js = np.arange(42, dtype=float).reshape(6, 7) / 50.0
|
||
|
|
return ReturnPacket(
|
||
|
|
seq=seq,
|
||
|
|
source_index=10 + seq,
|
||
|
|
source_time=0.02 * seq,
|
||
|
|
echoed_map_id=map_id,
|
||
|
|
residual=np.linspace(-1.0, 1.0, 7),
|
||
|
|
wrench=wrench,
|
||
|
|
js_t_wrench=Js.T @ wrench,
|
||
|
|
qd_slave_actual=np.linspace(0.1, 0.7, 7),
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_matched_conditions_share_one_input_hash(self):
|
||
|
|
registry = MapRegistry()
|
||
|
|
A = np.eye(7)
|
||
|
|
registry.add(MapSnapshot(1, 2, 0.04, A))
|
||
|
|
packet = self.make_packet()
|
||
|
|
Jm = np.arange(42, dtype=float).reshape(6, 7) / 70.0
|
||
|
|
|
||
|
|
differential = map_return_feedback(
|
||
|
|
kind=MappingKind.MATCHED_DIFFERENTIAL_WRENCH,
|
||
|
|
packet=packet,
|
||
|
|
master_jacobian=Jm,
|
||
|
|
maps=registry,
|
||
|
|
)
|
||
|
|
direct = map_return_feedback(
|
||
|
|
kind=MappingKind.DIRECT_MASTER_JACOBIAN,
|
||
|
|
packet=packet,
|
||
|
|
master_jacobian=Jm,
|
||
|
|
maps=registry,
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(
|
||
|
|
differential.matched_input_hash, direct.matched_input_hash
|
||
|
|
)
|
||
|
|
np.testing.assert_allclose(
|
||
|
|
differential.tau_master_raw, A.T @ packet.js_t_wrench
|
||
|
|
)
|
||
|
|
np.testing.assert_allclose(
|
||
|
|
direct.tau_master_raw, Jm.T @ packet.wrench
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_source_stamped_policy_uses_echoed_map(self):
|
||
|
|
registry = MapRegistry()
|
||
|
|
registry.add(MapSnapshot(1, 0, 0.0, np.eye(7)))
|
||
|
|
registry.add(MapSnapshot(2, 1, 0.02, 2.0 * np.eye(7)))
|
||
|
|
packet = self.make_packet(map_id=1)
|
||
|
|
Jm = np.zeros((6, 7))
|
||
|
|
source = map_return_feedback(
|
||
|
|
kind=MappingKind.MATCHED_DIFFERENTIAL_WRENCH,
|
||
|
|
packet=packet,
|
||
|
|
master_jacobian=Jm,
|
||
|
|
maps=registry,
|
||
|
|
map_policy=MapPolicy.SOURCE_STAMPED,
|
||
|
|
)
|
||
|
|
current = map_return_feedback(
|
||
|
|
kind=MappingKind.MATCHED_DIFFERENTIAL_WRENCH,
|
||
|
|
packet=packet,
|
||
|
|
master_jacobian=Jm,
|
||
|
|
maps=registry,
|
||
|
|
map_policy=MapPolicy.CURRENT,
|
||
|
|
)
|
||
|
|
self.assertEqual(source.selected_map_id, 1)
|
||
|
|
self.assertEqual(current.selected_map_id, 2)
|
||
|
|
np.testing.assert_allclose(
|
||
|
|
current.tau_master_raw, 2.0 * source.tau_master_raw
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_power_endpoint_is_zero_for_exact_source_alignment(self):
|
||
|
|
tau_s = np.array([[1.0, 2.0], [-1.0, 0.5]])
|
||
|
|
qd_s = np.array([[0.3, -0.1], [0.2, 0.4]])
|
||
|
|
tau_m = tau_s.copy()
|
||
|
|
qd_m = qd_s.copy()
|
||
|
|
mismatch = normalized_actual_power_mismatch(
|
||
|
|
tau_m, qd_m, tau_s, qd_s, np.array([0.01, 0.02])
|
||
|
|
)
|
||
|
|
self.assertAlmostEqual(mismatch, 0.0)
|
||
|
|
|
||
|
|
def test_duplicate_out_of_order_and_timeout_are_explicit(self):
|
||
|
|
packets = [
|
||
|
|
ForwardPacket(i, i, i * 0.01, i, np.zeros(2), np.zeros(2))
|
||
|
|
for i in range(3)
|
||
|
|
]
|
||
|
|
# seq 0 arrives after seq 1, and seq 1 is duplicated.
|
||
|
|
channel = DeterministicChannel(
|
||
|
|
[
|
||
|
|
NetworkTraceEntry(0, 0.04),
|
||
|
|
NetworkTraceEntry(1, 0.01, duplicate=True),
|
||
|
|
NetworkTraceEntry(2, 0.02),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
for packet in packets:
|
||
|
|
channel.send(packet, packet.source_time)
|
||
|
|
receiver = PacketReceiver(timeout_s=0.05)
|
||
|
|
receptions = []
|
||
|
|
for delivery in channel.poll(0.05):
|
||
|
|
receptions.append(receiver.accept(delivery))
|
||
|
|
|
||
|
|
self.assertTrue(receptions[0].accepted)
|
||
|
|
self.assertIn(
|
||
|
|
PacketRejectReason.DUPLICATE_OR_STALE,
|
||
|
|
[item.reason for item in receptions if not item.accepted],
|
||
|
|
)
|
||
|
|
held = receiver.sample(0.051)
|
||
|
|
self.assertIn(held.state, (PacketState.ACTIVE, PacketState.RECOVERING))
|
||
|
|
self.assertEqual(receiver.sample(0.2).state, PacketState.TIMED_OUT)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|