2121- Formula parity against the Python-equivalent BER/PER/ARQ model.
2222- Zero-throughput behavior when link quality inputs are invalid.
2323- Retry-cap effects on storage drawdown and packet drop probability.
24+ - Removal-policy behavior (attempted removal vs delivered-only removal).
2425- Storage-limited operation and remaining-data estimate behavior.
2526- Automatic receiver-path selection from linkBudget antenna states/CNR values.
2627
4546
4647DownlinkHandlingMsgPayload = importlib .import_module ("Basilisk.architecture.messaging.DownlinkHandlingMsgPayload" )
4748LinkBudgetMsgPayload = importlib .import_module ("Basilisk.architecture.messaging.LinkBudgetMsgPayload" )
49+ DataStorageStatusMsgPayload = importlib .import_module ("Basilisk.architecture.messaging.DataStorageStatusMsgPayload" )
50+
51+ REMOVE_ATTEMPTED = 0
52+ REMOVE_DELIVERED_ONLY = 1
4853
4954# Debug is false by default; can be enabled by environment variable or --debug when running this file directly.
5055DEBUG_DOWNLINK_TEST = False
@@ -72,7 +77,7 @@ def q_function(x):
7277 return 0.5 * math .erfc (x / math .sqrt (2.0 ))
7378
7479
75- def python_equivalent_from_link (cnr_linear , bandwidth_hz , bit_rate_bps , packet_bits , max_retx ):
80+ def python_equivalent_from_link (cnr_linear , bandwidth_hz , bit_rate_bps , packet_bits , max_retx , removal_policy = REMOVE_ATTEMPTED ):
7681 cnr_db = 10.0 * math .log10 (cnr_linear )
7782 c_n0_dbhz = cnr_db + 10.0 * math .log10 (bandwidth_hz )
7883 eb_n0_db = c_n0_dbhz - 10.0 * math .log10 (bit_rate_bps )
@@ -90,9 +95,13 @@ def python_equivalent_from_link(cnr_linear, bandwidth_hz, bit_rate_bps, packet_b
9095 else :
9196 expected_attempts = packet_success / one_try_success
9297
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
98+ modeled_storage_removal_rate = bit_rate_bps / expected_attempts
99+ delivered_rate = modeled_storage_removal_rate * packet_success
100+ dropped_rate = modeled_storage_removal_rate - delivered_rate
101+ if removal_policy == REMOVE_DELIVERED_ONLY :
102+ storage_removal_rate = delivered_rate
103+ else :
104+ storage_removal_rate = modeled_storage_removal_rate
96105
97106 return {
98107 "c_n0_dbhz" : c_n0_dbhz ,
@@ -108,6 +117,21 @@ def python_equivalent_from_link(cnr_linear, bandwidth_hz, bit_rate_bps, packet_b
108117 }
109118
110119
120+ def make_storage_status_msg (storage_level_bits , partition_entries ):
121+ payload = DataStorageStatusMsgPayload .DataStorageStatusMsgPayload ()
122+ payload .storageLevel = storage_level_bits # [bit]
123+
124+ names = DataStorageStatusMsgPayload .StringVector ()
125+ values = DataStorageStatusMsgPayload .DoubleVector ()
126+ for name , bits in partition_entries :
127+ names .push_back (name )
128+ values .push_back (bits ) # [bit]
129+
130+ payload .storedDataName = names
131+ payload .storedData = values
132+ return DataStorageStatusMsgPayload .DataStorageStatusMsg ().write (payload )
133+
134+
111135def run_downlink_case (
112136 cnr1 = 0.0 ,
113137 cnr2 = 0.5 ,
@@ -118,6 +142,7 @@ def run_downlink_case(
118142 packet_bits = 256.0 ,
119143 max_retx = 10 ,
120144 receiver_index = 2 ,
145+ removal_policy = REMOVE_ATTEMPTED ,
121146 initial_bits = 1.0e9 ,
122147 task_dt_s = 1.0 ,
123148 stop_time_s = 3.0
@@ -127,6 +152,7 @@ def run_downlink_case(
127152 f"cnr1={ cnr1 } , cnr2={ cnr2 } , bandwidth_hz={ bandwidth_hz } , "
128153 f"bit_rate_bps={ bit_rate_bps } , packet_bits={ packet_bits } , "
129154 f"max_retx={ max_retx } , receiver_index={ receiver_index } , "
155+ f"removal_policy={ removal_policy } , "
130156 f"initial_bits={ initial_bits } , task_dt_s={ task_dt_s } , stop_time_s={ stop_time_s } "
131157 )
132158 unit_task_name = "unitTask"
@@ -138,11 +164,12 @@ def run_downlink_case(
138164
139165 test_module = downlinkHandling .DownlinkHandling ()
140166 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
167+ assert test_module .setBitRateRequest (bit_rate_bps )
168+ assert test_module .setPacketSizeBits (packet_bits )
169+ assert test_module .setMaxRetransmissions (max_retx )
170+ assert test_module .setReceiverAntenna (receiver_index )
171+ assert test_module .setRemovalPolicy (removal_policy )
172+ test_module .setRequireFullPacket (True )
146173 unit_test_sim .AddModelToTask (unit_task_name , test_module )
147174
148175 data_storage = simpleStorageUnit .SimpleStorageUnit ()
@@ -183,6 +210,7 @@ def run_downlink_case(
183210 debug_print (
184211 "case results: "
185212 f"linkActive={ downlink_log .linkActive [- 1 ]} , "
213+ f"removalPolicy={ downlink_log .removalPolicy [- 1 ]} , "
186214 f"receiverIndex={ downlink_log .receiverIndex [- 1 ]} , "
187215 f"ber={ downlink_log .ber [- 1 ]:.16e} , per={ downlink_log .per [- 1 ]:.16e} , "
188216 f"storageRemovalRate={ downlink_log .storageRemovalRate [- 1 ]:.16e} , "
@@ -196,6 +224,7 @@ def run_downlink_case(
196224
197225
198226def test_downlink_matches_python_equivalent ():
227+ """Verify C++ module outputs match the Python-equivalent BER/PER/ARQ model."""
199228 debug_print ("test_downlink_matches_python_equivalent" )
200229 cnr = 0.5
201230 bandwidth = 1.0e6
@@ -222,6 +251,7 @@ def test_downlink_matches_python_equivalent():
222251 debug_compare ("nodeBaudRate" , node_log .baudRate [- 1 ], - expected ["storage_removal_rate" ])
223252
224253 assert downlink_log .linkActive [- 1 ] == 1
254+ assert downlink_log .removalPolicy [- 1 ] == REMOVE_ATTEMPTED
225255 assert downlink_log .ber [- 1 ] == pytest .approx (expected ["ber" ], rel = 1e-12 , abs = 1e-15 )
226256 assert downlink_log .per [- 1 ] == pytest .approx (expected ["per" ], rel = 1e-12 , abs = 1e-15 )
227257 assert downlink_log .expectedAttemptsPerPacket [- 1 ] == pytest .approx (expected ["expected_attempts" ], rel = 1e-12 , abs = 1e-15 )
@@ -232,6 +262,7 @@ def test_downlink_matches_python_equivalent():
232262
233263
234264def test_downlink_invalid_link_outputs_zero_flow ():
265+ """Verify invalid link inputs produce zero link-active flag and zero throughput."""
235266 debug_print ("test_downlink_invalid_link_outputs_zero_flow" )
236267 _ , node_log , _ , downlink_log = run_downlink_case (
237268 cnr2 = 0.0 ,
@@ -250,6 +281,7 @@ def test_downlink_invalid_link_outputs_zero_flow():
250281
251282
252283def test_downlink_retry_limit_changes_storage_draw_not_goodput ():
284+ """Verify higher retry caps reduce storage draw while preserving expected delivered-rate level."""
253285 debug_print ("test_downlink_retry_limit_changes_storage_draw_not_goodput" )
254286 common = dict (
255287 cnr2 = 0.5 ,
@@ -276,7 +308,51 @@ def test_downlink_retry_limit_changes_storage_draw_not_goodput():
276308 assert downlink_log_m1 .deliveredDataRate [- 1 ] == pytest .approx (downlink_log_m8 .deliveredDataRate [- 1 ], rel = 1e-12 , abs = 1e-9 )
277309
278310
311+ def test_downlink_remove_delivered_only_retains_undelivered_bits ():
312+ """Verify delivered-only removal mode keeps dropped/undelivered bits onboard."""
313+ debug_print ("test_downlink_remove_delivered_only_retains_undelivered_bits" )
314+ common = dict (
315+ cnr2 = 0.5 ,
316+ bandwidth_hz = 1.0e6 ,
317+ bit_rate_bps = 1.0e5 ,
318+ packet_bits = 256.0 ,
319+ max_retx = 4 ,
320+ initial_bits = 1.0e9 ,
321+ stop_time_s = 3.0
322+ )
323+
324+ _ , node_attempted , storage_attempted , downlink_attempted = run_downlink_case (
325+ removal_policy = REMOVE_ATTEMPTED , ** common
326+ )
327+ _ , node_delivered , storage_delivered , downlink_delivered = run_downlink_case (
328+ removal_policy = REMOVE_DELIVERED_ONLY , ** common
329+ )
330+
331+ assert downlink_attempted .removalPolicy [- 1 ] == REMOVE_ATTEMPTED
332+ assert downlink_delivered .removalPolicy [- 1 ] == REMOVE_DELIVERED_ONLY
333+
334+ assert downlink_attempted .attemptedDataRate [- 1 ] == pytest .approx (
335+ downlink_delivered .attemptedDataRate [- 1 ], rel = 1e-12 , abs = 1e-9
336+ )
337+ assert downlink_attempted .deliveredDataRate [- 1 ] == pytest .approx (
338+ downlink_delivered .deliveredDataRate [- 1 ], rel = 1e-12 , abs = 1e-9
339+ )
340+ assert downlink_attempted .droppedDataRate [- 1 ] == pytest .approx (
341+ downlink_delivered .droppedDataRate [- 1 ], rel = 1e-12 , abs = 1e-9
342+ )
343+
344+ assert downlink_attempted .storageRemovalRate [- 1 ] > downlink_delivered .storageRemovalRate [- 1 ]
345+ assert downlink_delivered .storageRemovalRate [- 1 ] == pytest .approx (
346+ downlink_delivered .deliveredDataRate [- 1 ], rel = 1e-12 , abs = 1e-9
347+ )
348+
349+ assert node_attempted .baudRate [- 1 ] == pytest .approx (- downlink_attempted .storageRemovalRate [- 1 ], rel = 1e-12 , abs = 1e-9 )
350+ assert node_delivered .baudRate [- 1 ] == pytest .approx (- downlink_delivered .storageRemovalRate [- 1 ], rel = 1e-12 , abs = 1e-9 )
351+ assert storage_delivered .storageLevel [- 1 ] > storage_attempted .storageLevel [- 1 ]
352+
353+
279354def test_downlink_storage_limited_case_caps_rate_and_drains_storage ():
355+ """Verify storage-limited operation caps removal rate and drains remaining data correctly."""
280356 debug_print ("test_downlink_storage_limited_case_caps_rate_and_drains_storage" )
281357 initial_bits = 300.0
282358 _ , _ , storage_log , downlink_log = run_downlink_case (
@@ -302,6 +378,7 @@ def test_downlink_storage_limited_case_caps_rate_and_drains_storage():
302378
303379
304380def test_downlink_auto_receiver_selects_valid_rx_path ():
381+ """Verify auto receiver selection chooses a valid RX path with nonzero CNR."""
305382 debug_print ("test_downlink_auto_receiver_selects_valid_rx_path" )
306383 _ , _ , _ , downlink_log = run_downlink_case (
307384 cnr1 = 0.8 ,
@@ -317,6 +394,126 @@ def test_downlink_auto_receiver_selects_valid_rx_path():
317394 assert downlink_log .receiverIndex [- 1 ] == 1
318395
319396
397+ def test_downlink_setter_validation_rejects_invalid_inputs ():
398+ """Check that module setters reject invalid values and preserve prior valid configuration."""
399+ debug_print ("test_downlink_setter_validation_rejects_invalid_inputs" )
400+ test_module = downlinkHandling .DownlinkHandling ()
401+
402+ assert test_module .setBitRateRequest (1.0e5 ) # [bit/s]
403+ assert test_module .setPacketSizeBits (1024.0 ) # [bit]
404+ assert test_module .setMaxRetransmissions (6 ) # [-]
405+ assert test_module .setReceiverAntenna (2 ) # [-]
406+ assert test_module .setRemovalPolicy (REMOVE_DELIVERED_ONLY )
407+ test_module .setRequireFullPacket (True )
408+
409+ with pytest .raises (Exception ):
410+ test_module .setBitRateRequest (- 1.0 ) # [bit/s]
411+ with pytest .raises (Exception ):
412+ test_module .setBitRateRequest (float ("nan" )) # [bit/s]
413+ with pytest .raises (Exception ):
414+ test_module .setPacketSizeBits (0.0 ) # [bit]
415+ with pytest .raises (Exception ):
416+ test_module .setPacketSizeBits (float ("inf" )) # [bit]
417+ with pytest .raises (Exception ):
418+ test_module .setMaxRetransmissions (0 ) # [-]
419+ with pytest .raises (Exception ):
420+ test_module .setReceiverAntenna (4 ) # [-]
421+ with pytest .raises (Exception ):
422+ test_module .setRemovalPolicy (2 ) # [-]
423+
424+ assert test_module .getBitRateRequest () == pytest .approx (1.0e5 , abs = 1e-12 )
425+ assert test_module .getPacketSizeBits () == pytest .approx (1024.0 , abs = 1e-12 )
426+ assert test_module .getMaxRetransmissions () == 6
427+ assert test_module .getReceiverAntenna () == 2
428+ assert test_module .getRemovalPolicy () == REMOVE_DELIVERED_ONLY
429+ assert test_module .getRequireFullPacket () is True
430+
431+
432+ def test_downlink_forced_receiver_invalid_path_disables_link ():
433+ """Verify that forcing an unavailable receiver path yields no selected receiver and zero throughput."""
434+ debug_print ("test_downlink_forced_receiver_invalid_path_disables_link" )
435+ _ , node_log , _ , downlink_log = run_downlink_case (
436+ cnr1 = 0.8 ,
437+ cnr2 = 0.0 ,
438+ ant_state1 = 1 ,
439+ ant_state2 = 2 ,
440+ receiver_index = 2 ,
441+ initial_bits = 1.0e8
442+ )
443+
444+ assert downlink_log .receiverIndex [- 1 ] == 0
445+ assert downlink_log .linkActive [- 1 ] == 0
446+ assert downlink_log .storageRemovalRate [- 1 ] == pytest .approx (0.0 , abs = 1e-12 )
447+ assert node_log .baudRate [- 1 ] == pytest .approx (0.0 , abs = 1e-12 )
448+
449+
450+ def test_downlink_duplicate_storage_message_is_rejected ():
451+ """Verify duplicate storage message registration is rejected by the module."""
452+ debug_print ("test_downlink_duplicate_storage_message_is_rejected" )
453+
454+ test_module = downlinkHandling .DownlinkHandling ()
455+ storage_msg = make_storage_status_msg (100.0 , [("DATA" , 100.0 )]) # [bit]
456+
457+ assert test_module .addStorageUnitToDownlink (storage_msg ) is True
458+ assert test_module .addStorageUnitToDownlink (storage_msg ) is False
459+
460+
461+ def test_downlink_selects_largest_partition_across_storage_messages ():
462+ """Verify storage selection uses the largest partition across all linked storage status inputs."""
463+ debug_print ("test_downlink_selects_largest_partition_across_storage_messages" )
464+
465+ unit_task_name = "unitTask"
466+ unit_process_name = "unitProcess"
467+
468+ unit_test_sim = SimulationBaseClass .SimBaseClass ()
469+ test_proc = unit_test_sim .CreateNewProcess (unit_process_name )
470+ test_proc .addTask (unit_test_sim .CreateNewTask (unit_task_name , macros .sec2nano (1.0 ))) # [s]
471+
472+ test_module = downlinkHandling .DownlinkHandling ()
473+ test_module .ModelTag = "downlink"
474+ assert test_module .setBitRateRequest (1.0e5 ) # [bit/s]
475+ assert test_module .setPacketSizeBits (256.0 ) # [bit]
476+ assert test_module .setMaxRetransmissions (4 ) # [-]
477+ assert test_module .setReceiverAntenna (2 ) # [-]
478+ test_module .setRequireFullPacket (True ) # [-]
479+ unit_test_sim .AddModelToTask (unit_task_name , test_module )
480+
481+ storage_msg_a = make_storage_status_msg (
482+ 900.0 , # [bit]
483+ [("A_BIG" , 400.0 ), ("A_SMALL" , 100.0 )] # [bit]
484+ )
485+ storage_msg_b = make_storage_status_msg (
486+ 2000.0 , # [bit]
487+ [("B_TOP" , 1200.0 ), ("B_OTHER" , 800.0 )] # [bit]
488+ )
489+ assert test_module .addStorageUnitToDownlink (storage_msg_a )
490+ assert test_module .addStorageUnitToDownlink (storage_msg_b )
491+
492+ link_payload = LinkBudgetMsgPayload .LinkBudgetMsgPayload ()
493+ link_payload .antennaName1 = "TX_Ant"
494+ link_payload .antennaName2 = "RX_Ant"
495+ link_payload .antennaState1 = 2 # [-] TX
496+ link_payload .antennaState2 = 1 # [-] RX
497+ link_payload .CNR1 = 0.0 # [-]
498+ link_payload .CNR2 = 20.0 # [-]
499+ link_payload .bandwidth = 1.0e6 # [Hz]
500+ link_payload .frequency = 2.2e9 # [Hz]
501+ link_msg = LinkBudgetMsgPayload .LinkBudgetMsg ().write (link_payload )
502+ test_module .linkBudgetInMsg .subscribeTo (link_msg )
503+
504+ downlink_reader = DownlinkHandlingMsgPayload .DownlinkHandlingMsgReader ()
505+ downlink_reader .subscribeTo (test_module .downlinkOutMsg )
506+ downlink_log = downlink_reader .recorder ()
507+ unit_test_sim .AddModelToTask (unit_task_name , downlink_log )
508+
509+ unit_test_sim .InitializeSimulation ()
510+ unit_test_sim .ConfigureStopTime (macros .sec2nano (1.0 )) # [s]
511+ unit_test_sim .ExecuteSimulation ()
512+
513+ assert downlink_log .availableDataBits [- 1 ] == pytest .approx (1200.0 , abs = 1e-12 )
514+ assert downlink_log .dataName [- 1 ] == "B_TOP"
515+
516+
320517if __name__ == "__main__" :
321518 # Allow direct execution:
322519 # python test_downlinkHandling.py -k test_name -q -s --debug
0 commit comments