Skip to content

Commit 98d2b6e

Browse files
committed
[#1323] add module unit test
1 parent 76a77ff commit 98d2b6e

1 file changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
# ISC License
2+
#
3+
# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado Boulder
4+
#
5+
# Permission to use, copy, modify, and/or distribute this software for any
6+
# purpose with or without fee is hereby granted, provided that the above
7+
# copyright notice and this permission notice appear in all copies.
8+
#
9+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16+
17+
"""
18+
Unit tests for downlinkHandling.
19+
20+
Coverage:
21+
- Formula parity against the Python-equivalent BER/PER/ARQ model.
22+
- Zero-throughput behavior when link quality inputs are invalid.
23+
- Retry-cap effects on storage drawdown and packet drop probability.
24+
- Storage-limited operation and remaining-data estimate behavior.
25+
- Automatic receiver-path selection from linkBudget antenna states/CNR values.
26+
27+
Debug toggle:
28+
- Default is off.
29+
- Set ``BSK_DOWNLINK_TEST_DEBUG=1`` to print case setup and actual-vs-expected metrics.
30+
- Or run this file directly with ``--debug``.
31+
"""
32+
33+
import math
34+
import importlib
35+
import os
36+
import sys
37+
38+
import pytest
39+
40+
from Basilisk.architecture import messaging
41+
from Basilisk.simulation import downlinkHandling
42+
from Basilisk.simulation import simpleStorageUnit
43+
from Basilisk.utilities import SimulationBaseClass
44+
from Basilisk.utilities import macros
45+
46+
DownlinkHandlingMsgPayload = importlib.import_module("Basilisk.architecture.messaging.DownlinkHandlingMsgPayload")
47+
LinkBudgetMsgPayload = importlib.import_module("Basilisk.architecture.messaging.LinkBudgetMsgPayload")
48+
49+
# Debug is false by default; can be enabled by environment variable or --debug when running this file directly.
50+
DEBUG_DOWNLINK_TEST = False
51+
if os.getenv("BSK_DOWNLINK_TEST_DEBUG", "0").strip().lower() in {"1", "true", "yes", "on"}:
52+
DEBUG_DOWNLINK_TEST = True
53+
54+
55+
def debug_print(msg):
56+
if DEBUG_DOWNLINK_TEST:
57+
print(f"[downlinkHandlingTest] {msg}")
58+
59+
60+
def debug_compare(name, actual, expected):
61+
if DEBUG_DOWNLINK_TEST:
62+
abs_err = abs(actual - expected)
63+
rel_err = abs_err / max(abs(expected), 1.0e-30)
64+
print(
65+
f"[downlinkHandlingTest] {name}: "
66+
f"actual={actual:.16e}, expected={expected:.16e}, "
67+
f"abs_err={abs_err:.3e}, rel_err={rel_err:.3e}"
68+
)
69+
70+
71+
def q_function(x):
72+
return 0.5 * math.erfc(x / math.sqrt(2.0))
73+
74+
75+
def python_equivalent_from_link(cnr_linear, bandwidth_hz, bit_rate_bps, packet_bits, max_retx):
76+
cnr_db = 10.0 * math.log10(cnr_linear)
77+
c_n0_dbhz = cnr_db + 10.0 * math.log10(bandwidth_hz)
78+
eb_n0_db = c_n0_dbhz - 10.0 * math.log10(bit_rate_bps)
79+
80+
eb_n0_linear = 10.0 ** (eb_n0_db / 10.0)
81+
ber = q_function(math.sqrt(2.0 * eb_n0_linear))
82+
per = 1.0 - (1.0 - ber) ** packet_bits
83+
84+
one_try_success = 1.0 - per
85+
packet_drop = per ** max_retx
86+
packet_success = 1.0 - packet_drop
87+
88+
if one_try_success <= 0.0:
89+
expected_attempts = float(max_retx)
90+
else:
91+
expected_attempts = packet_success / one_try_success
92+
93+
storage_removal_rate = bit_rate_bps / expected_attempts
94+
delivered_rate = storage_removal_rate * packet_success
95+
dropped_rate = storage_removal_rate - delivered_rate
96+
97+
return {
98+
"c_n0_dbhz": c_n0_dbhz,
99+
"eb_n0_db": eb_n0_db,
100+
"ber": ber,
101+
"per": per,
102+
"packet_success": packet_success,
103+
"packet_drop": packet_drop,
104+
"expected_attempts": expected_attempts,
105+
"storage_removal_rate": storage_removal_rate,
106+
"delivered_rate": delivered_rate,
107+
"dropped_rate": dropped_rate
108+
}
109+
110+
111+
def run_downlink_case(
112+
cnr1=0.0,
113+
cnr2=0.5,
114+
ant_state1=2,
115+
ant_state2=1,
116+
bandwidth_hz=1.0e6,
117+
bit_rate_bps=1.0e5,
118+
packet_bits=256.0,
119+
max_retx=10,
120+
receiver_index=2,
121+
initial_bits=1.0e9,
122+
task_dt_s=1.0,
123+
stop_time_s=3.0
124+
):
125+
debug_print(
126+
"run_downlink_case: "
127+
f"cnr1={cnr1}, cnr2={cnr2}, bandwidth_hz={bandwidth_hz}, "
128+
f"bit_rate_bps={bit_rate_bps}, packet_bits={packet_bits}, "
129+
f"max_retx={max_retx}, receiver_index={receiver_index}, "
130+
f"initial_bits={initial_bits}, task_dt_s={task_dt_s}, stop_time_s={stop_time_s}"
131+
)
132+
unit_task_name = "unitTask"
133+
unit_process_name = "unitProcess"
134+
135+
unit_test_sim = SimulationBaseClass.SimBaseClass()
136+
test_proc = unit_test_sim.CreateNewProcess(unit_process_name)
137+
test_proc.addTask(unit_test_sim.CreateNewTask(unit_task_name, macros.sec2nano(task_dt_s)))
138+
139+
test_module = downlinkHandling.DownlinkHandling()
140+
test_module.ModelTag = "downlink"
141+
test_module.bitRateRequest = bit_rate_bps
142+
test_module.packetSizeBits = packet_bits
143+
test_module.maxRetransmissions = max_retx
144+
test_module.receiverAntenna = receiver_index
145+
test_module.requireFullPacket = True
146+
unit_test_sim.AddModelToTask(unit_task_name, test_module)
147+
148+
data_storage = simpleStorageUnit.SimpleStorageUnit()
149+
data_storage.ModelTag = "storage"
150+
data_storage.storageCapacity = int(max(2.0 * initial_bits, initial_bits + 1.0e6))
151+
data_storage.addDataNodeToModel(test_module.nodeDataOutMsg)
152+
unit_test_sim.AddModelToTask(unit_task_name, data_storage)
153+
test_module.addStorageUnitToDownlink(data_storage.storageUnitDataOutMsg)
154+
data_storage.setDataBuffer(int(initial_bits))
155+
156+
link_payload = LinkBudgetMsgPayload.LinkBudgetMsgPayload()
157+
link_payload.antennaName1 = "TX_Ant"
158+
link_payload.antennaName2 = "RX_Ant"
159+
link_payload.antennaState1 = ant_state1
160+
link_payload.antennaState2 = ant_state2
161+
link_payload.CNR1 = cnr1
162+
link_payload.CNR2 = cnr2
163+
link_payload.bandwidth = bandwidth_hz
164+
link_payload.frequency = 2.2e9
165+
link_msg = LinkBudgetMsgPayload.LinkBudgetMsg().write(link_payload)
166+
test_module.linkBudgetInMsg.subscribeTo(link_msg)
167+
168+
node_log = test_module.nodeDataOutMsg.recorder()
169+
storage_log = data_storage.storageUnitDataOutMsg.recorder()
170+
downlink_reader = DownlinkHandlingMsgPayload.DownlinkHandlingMsgReader()
171+
downlink_reader.subscribeTo(test_module.downlinkOutMsg)
172+
downlink_log = downlink_reader.recorder()
173+
174+
unit_test_sim.AddModelToTask(unit_task_name, node_log)
175+
unit_test_sim.AddModelToTask(unit_task_name, storage_log)
176+
unit_test_sim.AddModelToTask(unit_task_name, downlink_log)
177+
178+
unit_test_sim.InitializeSimulation()
179+
unit_test_sim.ConfigureStopTime(macros.sec2nano(stop_time_s))
180+
unit_test_sim.ExecuteSimulation()
181+
182+
if DEBUG_DOWNLINK_TEST:
183+
debug_print(
184+
"case results: "
185+
f"linkActive={downlink_log.linkActive[-1]}, "
186+
f"receiverIndex={downlink_log.receiverIndex[-1]}, "
187+
f"ber={downlink_log.ber[-1]:.16e}, per={downlink_log.per[-1]:.16e}, "
188+
f"storageRemovalRate={downlink_log.storageRemovalRate[-1]:.16e}, "
189+
f"deliveredRate={downlink_log.deliveredDataRate[-1]:.16e}, "
190+
f"droppedRate={downlink_log.droppedDataRate[-1]:.16e}, "
191+
f"nodeBaud={node_log.baudRate[-1]:.16e}, "
192+
f"storageLevel={storage_log.storageLevel[-1]:.16e}"
193+
)
194+
195+
return test_module, node_log, storage_log, downlink_log
196+
197+
198+
def test_downlink_matches_python_equivalent():
199+
debug_print("test_downlink_matches_python_equivalent")
200+
cnr = 0.5
201+
bandwidth = 1.0e6
202+
bit_rate = 1.0e5
203+
packet_bits = 256.0
204+
max_retx = 10
205+
206+
_, node_log, _, downlink_log = run_downlink_case(
207+
cnr2=cnr,
208+
bandwidth_hz=bandwidth,
209+
bit_rate_bps=bit_rate,
210+
packet_bits=packet_bits,
211+
max_retx=max_retx,
212+
initial_bits=1.0e9
213+
)
214+
215+
expected = python_equivalent_from_link(cnr, bandwidth, bit_rate, packet_bits, max_retx)
216+
debug_compare("ber", downlink_log.ber[-1], expected["ber"])
217+
debug_compare("per", downlink_log.per[-1], expected["per"])
218+
debug_compare("expectedAttemptsPerPacket", downlink_log.expectedAttemptsPerPacket[-1], expected["expected_attempts"])
219+
debug_compare("deliveredDataRate", downlink_log.deliveredDataRate[-1], expected["delivered_rate"])
220+
debug_compare("storageRemovalRate", downlink_log.storageRemovalRate[-1], expected["storage_removal_rate"])
221+
debug_compare("droppedDataRate", downlink_log.droppedDataRate[-1], expected["dropped_rate"])
222+
debug_compare("nodeBaudRate", node_log.baudRate[-1], -expected["storage_removal_rate"])
223+
224+
assert downlink_log.linkActive[-1] == 1
225+
assert downlink_log.ber[-1] == pytest.approx(expected["ber"], rel=1e-12, abs=1e-15)
226+
assert downlink_log.per[-1] == pytest.approx(expected["per"], rel=1e-12, abs=1e-15)
227+
assert downlink_log.expectedAttemptsPerPacket[-1] == pytest.approx(expected["expected_attempts"], rel=1e-12, abs=1e-15)
228+
assert downlink_log.deliveredDataRate[-1] == pytest.approx(expected["delivered_rate"], rel=1e-12, abs=1e-9)
229+
assert downlink_log.storageRemovalRate[-1] == pytest.approx(expected["storage_removal_rate"], rel=1e-12, abs=1e-9)
230+
assert downlink_log.droppedDataRate[-1] == pytest.approx(expected["dropped_rate"], rel=1e-12, abs=1e-9)
231+
assert node_log.baudRate[-1] == pytest.approx(-expected["storage_removal_rate"], rel=1e-12, abs=1e-9)
232+
233+
234+
def test_downlink_invalid_link_outputs_zero_flow():
235+
debug_print("test_downlink_invalid_link_outputs_zero_flow")
236+
_, node_log, _, downlink_log = run_downlink_case(
237+
cnr2=0.0,
238+
bandwidth_hz=1.0e6,
239+
bit_rate_bps=1.0e5,
240+
packet_bits=256.0,
241+
max_retx=10,
242+
initial_bits=1.0e9
243+
)
244+
245+
assert downlink_log.linkActive[-1] == 0
246+
assert downlink_log.storageRemovalRate[-1] == pytest.approx(0.0, abs=1e-12)
247+
assert downlink_log.deliveredDataRate[-1] == pytest.approx(0.0, abs=1e-12)
248+
assert downlink_log.droppedDataRate[-1] == pytest.approx(0.0, abs=1e-12)
249+
assert node_log.baudRate[-1] == pytest.approx(0.0, abs=1e-12)
250+
251+
252+
def test_downlink_retry_limit_changes_storage_draw_not_goodput():
253+
debug_print("test_downlink_retry_limit_changes_storage_draw_not_goodput")
254+
common = dict(
255+
cnr2=0.5,
256+
bandwidth_hz=1.0e6,
257+
bit_rate_bps=1.0e5,
258+
packet_bits=256.0,
259+
initial_bits=1.0e9
260+
)
261+
262+
_, _, _, downlink_log_m1 = run_downlink_case(max_retx=1, **common)
263+
_, _, _, downlink_log_m8 = run_downlink_case(max_retx=8, **common)
264+
debug_print(
265+
"retry comparison: "
266+
f"m1(storageRemovalRate={downlink_log_m1.storageRemovalRate[-1]:.16e}, "
267+
f"packetDropProb={downlink_log_m1.packetDropProb[-1]:.16e}, "
268+
f"deliveredDataRate={downlink_log_m1.deliveredDataRate[-1]:.16e}) "
269+
f"m8(storageRemovalRate={downlink_log_m8.storageRemovalRate[-1]:.16e}, "
270+
f"packetDropProb={downlink_log_m8.packetDropProb[-1]:.16e}, "
271+
f"deliveredDataRate={downlink_log_m8.deliveredDataRate[-1]:.16e})"
272+
)
273+
274+
assert downlink_log_m1.storageRemovalRate[-1] > downlink_log_m8.storageRemovalRate[-1]
275+
assert downlink_log_m1.packetDropProb[-1] > downlink_log_m8.packetDropProb[-1]
276+
assert downlink_log_m1.deliveredDataRate[-1] == pytest.approx(downlink_log_m8.deliveredDataRate[-1], rel=1e-12, abs=1e-9)
277+
278+
279+
def test_downlink_storage_limited_case_caps_rate_and_drains_storage():
280+
debug_print("test_downlink_storage_limited_case_caps_rate_and_drains_storage")
281+
initial_bits = 300.0
282+
_, _, storage_log, downlink_log = run_downlink_case(
283+
cnr2=100.0,
284+
bandwidth_hz=1.0e6,
285+
bit_rate_bps=1.0e5,
286+
packet_bits=256.0,
287+
max_retx=10,
288+
initial_bits=initial_bits,
289+
stop_time_s=1.0
290+
)
291+
292+
expected_cap_rate = initial_bits / downlink_log.timeStep[-1]
293+
debug_compare("storage_limited_rate_cap", downlink_log.storageRemovalRate[-1], expected_cap_rate)
294+
debug_print(
295+
"storage-limited results: "
296+
f"remainingBits={downlink_log.estimatedRemainingDataBits[-1]:.16e}, "
297+
f"storageLevel={storage_log.storageLevel[-1]:.16e}"
298+
)
299+
assert downlink_log.storageRemovalRate[-1] == pytest.approx(expected_cap_rate, rel=1e-12, abs=1e-9)
300+
assert downlink_log.estimatedRemainingDataBits[-1] == pytest.approx(0.0, abs=1.0)
301+
assert storage_log.storageLevel[-1] == pytest.approx(0.0, abs=1.0)
302+
303+
304+
def test_downlink_auto_receiver_selects_valid_rx_path():
305+
debug_print("test_downlink_auto_receiver_selects_valid_rx_path")
306+
_, _, _, downlink_log = run_downlink_case(
307+
cnr1=0.8,
308+
cnr2=0.0,
309+
ant_state1=1,
310+
ant_state2=2,
311+
receiver_index=0,
312+
initial_bits=1.0e9
313+
)
314+
debug_print(f"auto receiver selected index={downlink_log.receiverIndex[-1]}")
315+
316+
assert downlink_log.linkActive[-1] == 1
317+
assert downlink_log.receiverIndex[-1] == 1
318+
319+
320+
if __name__ == "__main__":
321+
# Allow direct execution:
322+
# python test_downlinkHandling.py -k test_name -q -s --debug
323+
args = sys.argv[1:]
324+
debug_flags = {"--debug", "--debug-downlink", "--debug-downlink-test"}
325+
filtered_args = []
326+
debug_requested = False
327+
328+
for arg in args:
329+
if arg in debug_flags:
330+
debug_requested = True
331+
else:
332+
filtered_args.append(arg)
333+
334+
# If no explicit test file/path is passed, run this file only.
335+
explicit_target = any(a.endswith(".py") or os.path.exists(a) for a in filtered_args if not a.startswith("-"))
336+
if not explicit_target:
337+
filtered_args.append(os.path.abspath(__file__))
338+
339+
if debug_requested:
340+
if "-s" not in filtered_args:
341+
filtered_args.append("-s")
342+
os.environ["BSK_DOWNLINK_TEST_DEBUG"] = "1"
343+
DEBUG_DOWNLINK_TEST = True
344+
raise SystemExit(pytest.main(filtered_args))

0 commit comments

Comments
 (0)