diff --git a/docs/source/Support/bskReleaseNotesSnippets/downlinkHandling.rst b/docs/source/Support/bskReleaseNotesSnippets/downlinkHandling.rst new file mode 100644 index 00000000000..a08ec8a2d74 --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/downlinkHandling.rst @@ -0,0 +1,3 @@ +- Added :ref:`downlinkHandling` with a validated configuration interface (setters/getters), finite-value guards, and bounded outputs to prevent non-physical downlink rates. +- Added :ref:`DownlinkHandlingMsgPayload` diagnostics and dedicated unit-test coverage for equation parity, receiver-path selection, storage-limited behavior, and invalid-input handling. +- Improved storage-target selection robustness across connected storage status messages and aligned module documentation with implemented behavior and validation interface. diff --git a/src/architecture/msgPayloadDefC/DownlinkHandlingMsgPayload.h b/src/architecture/msgPayloadDefC/DownlinkHandlingMsgPayload.h new file mode 100644 index 00000000000..0760ef467f9 --- /dev/null +++ b/src/architecture/msgPayloadDefC/DownlinkHandlingMsgPayload.h @@ -0,0 +1,59 @@ +/* + ISC License + +Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#ifndef BASILISK_DOWNLINKHANDLINGMSG_H +#define BASILISK_DOWNLINKHANDLINGMSG_H + +#include + +/*! @brief Message for reporting downlink performance and data handling metrics */ +typedef struct { + uint32_t linkActive; //!< [-] 1 if link-quality inputs are valid for downlink calculations + uint32_t receiverIndex; //!< [-] Selected receiver antenna index (1, 2) or 0 if none + uint64_t maxRetransmissions; //!< [-] Maximum ARQ retransmission attempts per packet + uint32_t removalPolicy; //!< [-] Storage-removal policy (0=REMOVE_ATTEMPTED, 1=REMOVE_DELIVERED_ONLY) + char transmitterAntennaName[20]; //!< [-] Name of the selected transmitting antenna + char receiverAntennaName[20]; //!< [-] Name of the selected receiving antenna + char dataName[128]; //!< [-] Name of the data partition currently downlinked + double timeStep; //!< [s] Module integration timestep + double bandwidth; //!< [Hz] Link overlap bandwidth used in the calculation + double bitRateRequest; //!< [bit/s] Requested channel bit rate + double packetSizeBits; //!< [bit] Packet size used for BER-to-PER conversion + double cnr; //!< [-] Selected C/N ratio (linear) + double cnr_dB; //!< [dB] Selected C/N ratio in decibel + double cNo_dBHz; //!< [dBHz] Carrier-to-noise density ratio + double ebN0_dB; //!< [dB] Energy-per-bit to noise-density ratio + double ber; //!< [-] Bit error rate + double per; //!< [-] Packet error rate + double packetSuccessProb; //!< [-] Packet success probability within max retransmissions + double packetDropProb; //!< [-] Packet drop probability within max retransmissions + double expectedAttemptsPerPacket; //!< [-] Expected number of attempts needed to complete one source packet + double attemptedDataRate; //!< [bit/s] Attempted channel transmission rate including retransmissions + double storageRemovalRate; //!< [bit/s] Data removed from spacecraft storage per selected removal policy + double deliveredDataRate; //!< [bit/s] Data delivered successfully to receiver + double droppedDataRate; //!< [bit/s] Data dropped after reaching retransmission limit + double availableDataBits; //!< [bit] Data available in selected storage partition at start of step + double estimatedRemainingDataBits; //!< [bit] Estimated remaining data in selected partition after this step + double cumulativeAttemptedBits; //!< [bit] Cumulative attempted bits including retransmissions + double cumulativeRemovedBits; //!< [bit] Cumulative bits removed from storage + double cumulativeDeliveredBits; //!< [bit] Cumulative successfully delivered bits + double cumulativeDroppedBits; //!< [bit] Cumulative dropped bits +} DownlinkHandlingMsgPayload; + +#endif diff --git a/src/simulation/communication/downlinkHandling/Custom.cmake b/src/simulation/communication/downlinkHandling/Custom.cmake new file mode 100644 index 00000000000..295150487c6 --- /dev/null +++ b/src/simulation/communication/downlinkHandling/Custom.cmake @@ -0,0 +1 @@ +target_link_libraries(${TARGET_NAME} PRIVATE onboardDataHandlingLib) diff --git a/src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingFlow.svg b/src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingFlow.svg new file mode 100644 index 00000000000..e31654c270d --- /dev/null +++ b/src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingFlow.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + downlinkHandling Integration and Message Flow + + + simpleAntenna #1 + AntennaLogMsgPayload + (state, gain/noise, pointing) + + + simpleAntenna #2 + AntennaLogMsgPayload + (state, gain/noise, pointing) + + + linkBudget + LinkBudgetMsgPayload + CNR1, CNR2, bandwidth, + antennaState1, antennaState2 + + + downlinkHandling + CNR → BER → PER → ARQ + attempt/remove/deliver/drop + storage saturation + packet gate + nodeDataOutMsg + diagnostics + + + Storage Unit + DataStorageStatus + partition bits/names + consumes + DataNodeUsage + + + DownlinkHandlingMsgPayload (Diagnostics) + linkActive, receiverIndex, BER, PER, packetSuccess/drop, expectedAttempts + attempted/removal/delivered/dropped rates, remaining bits, cumulative counters + + + + + + + + diff --git a/src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingReliabilityChain.svg b/src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingReliabilityChain.svg new file mode 100644 index 00000000000..2675b003aca --- /dev/null +++ b/src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingReliabilityChain.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + CNR to Effective Throughput Pipeline in downlinkHandling + + + Link Input + selected CNR + overlap bandwidth + C/N0 = CNR + 10log10(B) + + + Bit-Energy Domain + user bit rate Rb + Eb/N0 = C/N0 - 10log10(Rb) + BER = 0.5*erfc(sqrt(Eb/N0)) + + + Packet Reliability + packet size L + PER = 1 - (1-BER)^L + Pdrop = PER^N + Psuccess = 1 - Pdrop + + + Rate Mapping + E[A] = Psuccess / (1-PER) + Rremove = Rb / E[A] + Rdeliv = Rremove * Psuccess + Rdrop = Rremove - Rdeliv + + + Storage Gate + full-packet check + dt + available bits + scale in [0, 1] + + + Outputs Written Each Step + DataNodeUsageMsgPayload: nodeDataOutMsg.baudRate = -Rremove_scaled + DownlinkHandlingMsgPayload: BER, PER, success/drop probs, rates, storage estimates, cumulative counters + These outputs drive onboard buffer depletion and provide telemetry for fault diagnosis pipelines + + + + + + + + diff --git a/src/simulation/communication/downlinkHandling/_UnitTest/test_downlinkHandling.py b/src/simulation/communication/downlinkHandling/_UnitTest/test_downlinkHandling.py new file mode 100644 index 00000000000..390b0797cda --- /dev/null +++ b/src/simulation/communication/downlinkHandling/_UnitTest/test_downlinkHandling.py @@ -0,0 +1,758 @@ +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado Boulder +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +""" +Unit tests for downlinkHandling. + +Coverage: +- Formula parity against the Python-equivalent BER/PER/ARQ model. +- Zero-throughput behavior when link quality inputs are invalid. +- Retry-cap effects on storage drawdown and packet drop probability. +- Removal-policy behavior (attempted removal vs delivered-only removal). +- Storage-limited operation and remaining-data estimate behavior. +- Automatic receiver-path selection from linkBudget antenna states/CNR values. + +Debug toggle: +- Default is off. +- Set ``BSK_DOWNLINK_TEST_DEBUG=1`` to print case setup and actual-vs-expected metrics. +- Or run this file directly with ``--debug``. +""" + +import math +import importlib +import os +import sys + +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import downlinkHandling +from Basilisk.simulation import simpleStorageUnit +from Basilisk.utilities import SimulationBaseClass +from Basilisk.utilities import macros + +DownlinkHandlingMsgPayload = importlib.import_module("Basilisk.architecture.messaging.DownlinkHandlingMsgPayload") +LinkBudgetMsgPayload = importlib.import_module("Basilisk.architecture.messaging.LinkBudgetMsgPayload") +DataStorageStatusMsgPayload = importlib.import_module("Basilisk.architecture.messaging.DataStorageStatusMsgPayload") + +REMOVE_ATTEMPTED = 0 +REMOVE_DELIVERED_ONLY = 1 + +# Debug is false by default; can be enabled by environment variable or --debug when running this file directly. +DEBUG_DOWNLINK_TEST = False +if os.getenv("BSK_DOWNLINK_TEST_DEBUG", "0").strip().lower() in {"1", "true", "yes", "on"}: + DEBUG_DOWNLINK_TEST = True + + +def debug_print(msg): + if DEBUG_DOWNLINK_TEST: + print(f"[downlinkHandlingTest] {msg}") + + +def debug_compare(name, actual, expected): + if DEBUG_DOWNLINK_TEST: + abs_err = abs(actual - expected) + rel_err = abs_err / max(abs(expected), 1.0e-30) + print( + f"[downlinkHandlingTest] {name}: " + f"actual={actual:.16e}, expected={expected:.16e}, " + f"abs_err={abs_err:.3e}, rel_err={rel_err:.3e}" + ) + + +def q_function(x): + return 0.5 * math.erfc(x / math.sqrt(2.0)) + + +def python_equivalent_from_link(cnr_linear, bandwidth_hz, bit_rate_bps, packet_bits, max_retx, removal_policy=REMOVE_ATTEMPTED): + cnr_db = 10.0 * math.log10(cnr_linear) + c_n0_dbhz = cnr_db + 10.0 * math.log10(bandwidth_hz) + eb_n0_db = c_n0_dbhz - 10.0 * math.log10(bit_rate_bps) + + eb_n0_linear = 10.0 ** (eb_n0_db / 10.0) + ber = q_function(math.sqrt(2.0 * eb_n0_linear)) + per = 1.0 - (1.0 - ber) ** packet_bits + + one_try_success = 1.0 - per + packet_drop = per ** max_retx + packet_success = 1.0 - packet_drop + + if one_try_success <= 0.0: + expected_attempts = float(max_retx) + else: + expected_attempts = packet_success / one_try_success + + modeled_storage_removal_rate = bit_rate_bps / expected_attempts + delivered_rate = modeled_storage_removal_rate * packet_success + dropped_rate = modeled_storage_removal_rate - delivered_rate + if removal_policy == REMOVE_DELIVERED_ONLY: + storage_removal_rate = delivered_rate + else: + storage_removal_rate = modeled_storage_removal_rate + + return { + "c_n0_dbhz": c_n0_dbhz, + "eb_n0_db": eb_n0_db, + "ber": ber, + "per": per, + "packet_success": packet_success, + "packet_drop": packet_drop, + "expected_attempts": expected_attempts, + "storage_removal_rate": storage_removal_rate, + "delivered_rate": delivered_rate, + "dropped_rate": dropped_rate + } + + +def make_storage_status_msg(storage_level_bits, partition_entries): + payload = DataStorageStatusMsgPayload.DataStorageStatusMsgPayload() + payload.storageLevel = storage_level_bits # [bit] + + names = DataStorageStatusMsgPayload.StringVector() + values = DataStorageStatusMsgPayload.DoubleVector() + for name, bits in partition_entries: + names.push_back(name) + values.push_back(bits) # [bit] + + payload.storedDataName = names + payload.storedData = values + return DataStorageStatusMsgPayload.DataStorageStatusMsg().write(payload) + + +def run_downlink_case( + cnr1=0.0, + cnr2=0.5, + ant_state1=2, + ant_state2=1, + bandwidth_hz=1.0e6, + bit_rate_bps=1.0e5, + packet_bits=256.0, + max_retx=10, + receiver_index=2, + removal_policy=REMOVE_ATTEMPTED, + initial_bits=1.0e9, + task_dt_s=1.0, + stop_time_s=3.0 +): + debug_print( + "run_downlink_case: " + f"cnr1={cnr1}, cnr2={cnr2}, bandwidth_hz={bandwidth_hz}, " + f"bit_rate_bps={bit_rate_bps}, packet_bits={packet_bits}, " + f"max_retx={max_retx}, receiver_index={receiver_index}, " + f"removal_policy={removal_policy}, " + f"initial_bits={initial_bits}, task_dt_s={task_dt_s}, stop_time_s={stop_time_s}" + ) + unit_task_name = "unitTask" + unit_process_name = "unitProcess" + + unit_test_sim = SimulationBaseClass.SimBaseClass() + test_proc = unit_test_sim.CreateNewProcess(unit_process_name) + test_proc.addTask(unit_test_sim.CreateNewTask(unit_task_name, macros.sec2nano(task_dt_s))) + + test_module = downlinkHandling.DownlinkHandling() + test_module.ModelTag = "downlink" + assert test_module.setBitRateRequest(bit_rate_bps) + assert test_module.setPacketSizeBits(packet_bits) + assert test_module.setMaxRetransmissions(max_retx) + assert test_module.setReceiverAntenna(receiver_index) + assert test_module.setRemovalPolicy(removal_policy) + test_module.setRequireFullPacket(True) + unit_test_sim.AddModelToTask(unit_task_name, test_module) + + data_storage = simpleStorageUnit.SimpleStorageUnit() + data_storage.ModelTag = "storage" + data_storage.storageCapacity = int(max(2.0 * initial_bits, initial_bits + 1.0e6)) + data_storage.addDataNodeToModel(test_module.nodeDataOutMsg) + unit_test_sim.AddModelToTask(unit_task_name, data_storage) + test_module.addStorageUnitToDownlink(data_storage.storageUnitDataOutMsg) + data_storage.setDataBuffer(int(initial_bits)) + + link_payload = LinkBudgetMsgPayload.LinkBudgetMsgPayload() + link_payload.antennaName1 = "TX_Ant" + link_payload.antennaName2 = "RX_Ant" + link_payload.antennaState1 = ant_state1 + link_payload.antennaState2 = ant_state2 + link_payload.CNR1 = cnr1 + link_payload.CNR2 = cnr2 + link_payload.bandwidth = bandwidth_hz + link_payload.frequency = 2.2e9 + link_msg = LinkBudgetMsgPayload.LinkBudgetMsg().write(link_payload) + test_module.linkBudgetInMsg.subscribeTo(link_msg) + + node_log = test_module.nodeDataOutMsg.recorder() + storage_log = data_storage.storageUnitDataOutMsg.recorder() + downlink_reader = DownlinkHandlingMsgPayload.DownlinkHandlingMsgReader() + downlink_reader.subscribeTo(test_module.downlinkOutMsg) + downlink_log = downlink_reader.recorder() + + unit_test_sim.AddModelToTask(unit_task_name, node_log) + unit_test_sim.AddModelToTask(unit_task_name, storage_log) + unit_test_sim.AddModelToTask(unit_task_name, downlink_log) + + unit_test_sim.InitializeSimulation() + unit_test_sim.ConfigureStopTime(macros.sec2nano(stop_time_s)) + unit_test_sim.ExecuteSimulation() + + if DEBUG_DOWNLINK_TEST: + debug_print( + "case results: " + f"linkActive={downlink_log.linkActive[-1]}, " + f"removalPolicy={downlink_log.removalPolicy[-1]}, " + f"receiverIndex={downlink_log.receiverIndex[-1]}, " + f"ber={downlink_log.ber[-1]:.16e}, per={downlink_log.per[-1]:.16e}, " + f"storageRemovalRate={downlink_log.storageRemovalRate[-1]:.16e}, " + f"deliveredRate={downlink_log.deliveredDataRate[-1]:.16e}, " + f"droppedRate={downlink_log.droppedDataRate[-1]:.16e}, " + f"nodeBaud={node_log.baudRate[-1]:.16e}, " + f"storageLevel={storage_log.storageLevel[-1]:.16e}" + ) + + return test_module, node_log, storage_log, downlink_log + + +def test_downlink_matches_python_equivalent(): + """Verify C++ module outputs match the Python-equivalent BER/PER/ARQ model.""" + debug_print("test_downlink_matches_python_equivalent") + cnr = 0.5 + bandwidth = 1.0e6 + bit_rate = 1.0e5 + packet_bits = 256.0 + max_retx = 10 + + _, node_log, _, downlink_log = run_downlink_case( + cnr2=cnr, + bandwidth_hz=bandwidth, + bit_rate_bps=bit_rate, + packet_bits=packet_bits, + max_retx=max_retx, + initial_bits=1.0e9 + ) + + expected = python_equivalent_from_link(cnr, bandwidth, bit_rate, packet_bits, max_retx) + debug_compare("ber", downlink_log.ber[-1], expected["ber"]) + debug_compare("per", downlink_log.per[-1], expected["per"]) + debug_compare("expectedAttemptsPerPacket", downlink_log.expectedAttemptsPerPacket[-1], expected["expected_attempts"]) + debug_compare("deliveredDataRate", downlink_log.deliveredDataRate[-1], expected["delivered_rate"]) + debug_compare("storageRemovalRate", downlink_log.storageRemovalRate[-1], expected["storage_removal_rate"]) + debug_compare("droppedDataRate", downlink_log.droppedDataRate[-1], expected["dropped_rate"]) + debug_compare("nodeBaudRate", node_log.baudRate[-1], -expected["storage_removal_rate"]) + + assert downlink_log.linkActive[-1] == 1 + assert downlink_log.removalPolicy[-1] == REMOVE_ATTEMPTED + assert downlink_log.ber[-1] == pytest.approx(expected["ber"], rel=1e-12, abs=1e-15) + assert downlink_log.per[-1] == pytest.approx(expected["per"], rel=1e-12, abs=1e-15) + assert downlink_log.expectedAttemptsPerPacket[-1] == pytest.approx(expected["expected_attempts"], rel=1e-12, abs=1e-15) + assert downlink_log.deliveredDataRate[-1] == pytest.approx(expected["delivered_rate"], rel=1e-12, abs=1e-9) + assert downlink_log.storageRemovalRate[-1] == pytest.approx(expected["storage_removal_rate"], rel=1e-12, abs=1e-9) + assert downlink_log.droppedDataRate[-1] == pytest.approx(expected["dropped_rate"], rel=1e-12, abs=1e-9) + assert node_log.baudRate[-1] == pytest.approx(-expected["storage_removal_rate"], rel=1e-12, abs=1e-9) + + +def test_downlink_invalid_link_outputs_zero_flow(): + """Verify invalid link inputs produce zero link-active flag and zero throughput.""" + debug_print("test_downlink_invalid_link_outputs_zero_flow") + _, node_log, _, downlink_log = run_downlink_case( + cnr2=0.0, + bandwidth_hz=1.0e6, + bit_rate_bps=1.0e5, + packet_bits=256.0, + max_retx=10, + initial_bits=1.0e9 + ) + + assert downlink_log.linkActive[-1] == 0 + assert downlink_log.storageRemovalRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.deliveredDataRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.droppedDataRate[-1] == pytest.approx(0.0, abs=1e-12) + assert node_log.baudRate[-1] == pytest.approx(0.0, abs=1e-12) + + +def test_downlink_disabling_node_clears_diagnostics(): + """Verify disabling the node zeroes downlink diagnostics instead of republishing stale values.""" + debug_print("test_downlink_disabling_node_clears_diagnostics") + unit_task_name = "unitTask" + unit_process_name = "unitProcess" + + unit_test_sim = SimulationBaseClass.SimBaseClass() + test_proc = unit_test_sim.CreateNewProcess(unit_process_name) + test_proc.addTask(unit_test_sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s] + + test_module = downlinkHandling.DownlinkHandling() + test_module.ModelTag = "downlink" + assert test_module.setBitRateRequest(1.0e5) # [bit/s] + assert test_module.setPacketSizeBits(256.0) # [bit] + assert test_module.setMaxRetransmissions(4) # [-] + assert test_module.setReceiverAntenna(2) # [-] + unit_test_sim.AddModelToTask(unit_task_name, test_module) + + data_storage = simpleStorageUnit.SimpleStorageUnit() + data_storage.ModelTag = "storage" + data_storage.storageCapacity = int(2.0e9) # [bit] + data_storage.addDataNodeToModel(test_module.nodeDataOutMsg) + unit_test_sim.AddModelToTask(unit_task_name, data_storage) + test_module.addStorageUnitToDownlink(data_storage.storageUnitDataOutMsg) + data_storage.setDataBuffer(int(1.0e9)) # [bit] + + link_payload = LinkBudgetMsgPayload.LinkBudgetMsgPayload() + link_payload.antennaName1 = "TX_Ant" + link_payload.antennaName2 = "RX_Ant" + link_payload.antennaState1 = 2 # [-] TX + link_payload.antennaState2 = 1 # [-] RX + link_payload.CNR1 = 0.0 # [-] + link_payload.CNR2 = 0.5 # [-] + link_payload.bandwidth = 1.0e6 # [Hz] + link_payload.frequency = 2.2e9 # [Hz] + link_msg = LinkBudgetMsgPayload.LinkBudgetMsg().write(link_payload) + test_module.linkBudgetInMsg.subscribeTo(link_msg) + + status_payload = messaging.DeviceCmdMsgPayload() + status_payload.deviceCmd = 1 # [-] on + status_msg = messaging.DeviceCmdMsg() + status_msg.write(status_payload) + test_module.nodeStatusInMsg.subscribeTo(status_msg) + + node_log = test_module.nodeDataOutMsg.recorder() + downlink_reader = DownlinkHandlingMsgPayload.DownlinkHandlingMsgReader() + downlink_reader.subscribeTo(test_module.downlinkOutMsg) + downlink_log = downlink_reader.recorder() + unit_test_sim.AddModelToTask(unit_task_name, node_log) + unit_test_sim.AddModelToTask(unit_task_name, downlink_log) + + unit_test_sim.InitializeSimulation() + unit_test_sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s] + unit_test_sim.ExecuteSimulation() + + assert downlink_log.linkActive[-1] == 1 + assert downlink_log.storageRemovalRate[-1] > 0.0 + assert node_log.baudRate[-1] < 0.0 + + status_payload.deviceCmd = 0 # [-] off + status_msg.write(status_payload, unit_test_sim.TotalSim.CurrentNanos) + unit_test_sim.ConfigureStopTime(macros.sec2nano(2.0)) # [s] + unit_test_sim.ExecuteSimulation() + + assert downlink_log.linkActive[-1] == 0 + assert downlink_log.receiverIndex[-1] == 0 + assert downlink_log.storageRemovalRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.deliveredDataRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.droppedDataRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.ber[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.per[-1] == pytest.approx(0.0, abs=1e-12) + assert node_log.baudRate[-1] == pytest.approx(0.0, abs=1e-12) + + +def test_downlink_reenable_does_not_integrate_disabled_downtime(): + """Verify re-enabling after several disabled ticks does not collapse off-time into one large downlink step.""" + debug_print("test_downlink_reenable_does_not_integrate_disabled_downtime") + unit_task_name = "unitTask" + unit_process_name = "unitProcess" + task_dt_s = 1.0 # [s] + + unit_test_sim = SimulationBaseClass.SimBaseClass() + test_proc = unit_test_sim.CreateNewProcess(unit_process_name) + test_proc.addTask(unit_test_sim.CreateNewTask(unit_task_name, macros.sec2nano(task_dt_s))) + + test_module = downlinkHandling.DownlinkHandling() + test_module.ModelTag = "downlink" + assert test_module.setBitRateRequest(1.0e5) # [bit/s] + assert test_module.setPacketSizeBits(256.0) # [bit] + assert test_module.setMaxRetransmissions(4) # [-] + assert test_module.setReceiverAntenna(2) # [-] + unit_test_sim.AddModelToTask(unit_task_name, test_module) + + data_storage = simpleStorageUnit.SimpleStorageUnit() + data_storage.ModelTag = "storage" + data_storage.storageCapacity = int(2.0e9) # [bit] + data_storage.addDataNodeToModel(test_module.nodeDataOutMsg) + unit_test_sim.AddModelToTask(unit_task_name, data_storage) + test_module.addStorageUnitToDownlink(data_storage.storageUnitDataOutMsg) + data_storage.setDataBuffer(int(1.0e9)) # [bit] + + link_payload = LinkBudgetMsgPayload.LinkBudgetMsgPayload() + link_payload.antennaName1 = "TX_Ant" + link_payload.antennaName2 = "RX_Ant" + link_payload.antennaState1 = 2 # [-] TX + link_payload.antennaState2 = 1 # [-] RX + link_payload.CNR1 = 0.0 # [-] + link_payload.CNR2 = 0.5 # [-] + link_payload.bandwidth = 1.0e6 # [Hz] + link_payload.frequency = 2.2e9 # [Hz] + link_msg = LinkBudgetMsgPayload.LinkBudgetMsg().write(link_payload) + test_module.linkBudgetInMsg.subscribeTo(link_msg) + + status_payload = messaging.DeviceCmdMsgPayload() + status_payload.deviceCmd = 1 # [-] on + status_msg = messaging.DeviceCmdMsg() + status_msg.write(status_payload) + test_module.nodeStatusInMsg.subscribeTo(status_msg) + + downlink_reader = DownlinkHandlingMsgPayload.DownlinkHandlingMsgReader() + downlink_reader.subscribeTo(test_module.downlinkOutMsg) + downlink_log = downlink_reader.recorder() + unit_test_sim.AddModelToTask(unit_task_name, downlink_log) + + unit_test_sim.InitializeSimulation() + + # Active baseline tick + unit_test_sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s] + unit_test_sim.ExecuteSimulation() + baseline_rate = downlink_log.storageRemovalRate[-1] # [bit/s] + baseline_cum_removed = downlink_log.cumulativeRemovedBits[-1] # [bit] + assert downlink_log.timeStep[-1] == pytest.approx(task_dt_s, abs=1e-12) + assert baseline_rate > 0.0 + + # Disable for multiple ticks and verify cumulative removal does not change. + status_payload.deviceCmd = 0 # [-] off + status_msg.write(status_payload, unit_test_sim.TotalSim.CurrentNanos) + unit_test_sim.ConfigureStopTime(macros.sec2nano(4.0)) # [s] + unit_test_sim.ExecuteSimulation() + cum_removed_after_off = downlink_log.cumulativeRemovedBits[-1] # [bit] + assert cum_removed_after_off == pytest.approx(baseline_cum_removed, abs=1e-9) + + # Re-enable and verify single-step timing/removal behavior. + status_payload.deviceCmd = 1 # [-] on + status_msg.write(status_payload, unit_test_sim.TotalSim.CurrentNanos) + unit_test_sim.ConfigureStopTime(macros.sec2nano(5.0)) # [s] + unit_test_sim.ExecuteSimulation() + + assert downlink_log.timeStep[-1] == pytest.approx(task_dt_s, abs=1e-12) + assert downlink_log.storageRemovalRate[-1] == pytest.approx(baseline_rate, rel=1e-12, abs=1e-9) + reenable_delta_removed = downlink_log.cumulativeRemovedBits[-1] - cum_removed_after_off # [bit] + assert reenable_delta_removed == pytest.approx(baseline_rate * task_dt_s, rel=1e-12, abs=1e-6) + + +def test_downlink_retry_limit_changes_storage_draw_not_goodput(): + """Verify higher retry caps reduce storage draw while preserving expected delivered-rate level.""" + debug_print("test_downlink_retry_limit_changes_storage_draw_not_goodput") + common = dict( + cnr2=0.5, + bandwidth_hz=1.0e6, + bit_rate_bps=1.0e5, + packet_bits=256.0, + initial_bits=1.0e9 + ) + + _, _, _, downlink_log_m1 = run_downlink_case(max_retx=1, **common) + _, _, _, downlink_log_m8 = run_downlink_case(max_retx=8, **common) + debug_print( + "retry comparison: " + f"m1(storageRemovalRate={downlink_log_m1.storageRemovalRate[-1]:.16e}, " + f"packetDropProb={downlink_log_m1.packetDropProb[-1]:.16e}, " + f"deliveredDataRate={downlink_log_m1.deliveredDataRate[-1]:.16e}) " + f"m8(storageRemovalRate={downlink_log_m8.storageRemovalRate[-1]:.16e}, " + f"packetDropProb={downlink_log_m8.packetDropProb[-1]:.16e}, " + f"deliveredDataRate={downlink_log_m8.deliveredDataRate[-1]:.16e})" + ) + + assert downlink_log_m1.storageRemovalRate[-1] > downlink_log_m8.storageRemovalRate[-1] + assert downlink_log_m1.packetDropProb[-1] > downlink_log_m8.packetDropProb[-1] + assert downlink_log_m1.deliveredDataRate[-1] == pytest.approx(downlink_log_m8.deliveredDataRate[-1], rel=1e-12, abs=1e-9) + + +def test_downlink_remove_delivered_only_retains_undelivered_bits(): + """Verify delivered-only removal mode keeps dropped/undelivered bits onboard.""" + debug_print("test_downlink_remove_delivered_only_retains_undelivered_bits") + common = dict( + cnr2=0.5, + bandwidth_hz=1.0e6, + bit_rate_bps=1.0e5, + packet_bits=256.0, + max_retx=4, + initial_bits=1.0e9, + stop_time_s=3.0 + ) + + _, node_attempted, storage_attempted, downlink_attempted = run_downlink_case( + removal_policy=REMOVE_ATTEMPTED, **common + ) + _, node_delivered, storage_delivered, downlink_delivered = run_downlink_case( + removal_policy=REMOVE_DELIVERED_ONLY, **common + ) + + assert downlink_attempted.removalPolicy[-1] == REMOVE_ATTEMPTED + assert downlink_delivered.removalPolicy[-1] == REMOVE_DELIVERED_ONLY + + assert downlink_attempted.attemptedDataRate[-1] == pytest.approx( + downlink_delivered.attemptedDataRate[-1], rel=1e-12, abs=1e-9 + ) + assert downlink_attempted.deliveredDataRate[-1] == pytest.approx( + downlink_delivered.deliveredDataRate[-1], rel=1e-12, abs=1e-9 + ) + assert downlink_attempted.droppedDataRate[-1] == pytest.approx( + downlink_delivered.droppedDataRate[-1], rel=1e-12, abs=1e-9 + ) + + assert downlink_attempted.storageRemovalRate[-1] > downlink_delivered.storageRemovalRate[-1] + assert downlink_delivered.storageRemovalRate[-1] == pytest.approx( + downlink_delivered.deliveredDataRate[-1], rel=1e-12, abs=1e-9 + ) + + assert node_attempted.baudRate[-1] == pytest.approx(-downlink_attempted.storageRemovalRate[-1], rel=1e-12, abs=1e-9) + assert node_delivered.baudRate[-1] == pytest.approx(-downlink_delivered.storageRemovalRate[-1], rel=1e-12, abs=1e-9) + assert storage_delivered.storageLevel[-1] > storage_attempted.storageLevel[-1] + + +def test_downlink_storage_limited_case_caps_rate_and_drains_storage(): + """Verify storage-limited operation caps removal rate and drains remaining data correctly.""" + debug_print("test_downlink_storage_limited_case_caps_rate_and_drains_storage") + initial_bits = 300.0 + _, _, storage_log, downlink_log = run_downlink_case( + cnr2=100.0, + bandwidth_hz=1.0e6, + bit_rate_bps=1.0e5, + packet_bits=256.0, + max_retx=10, + initial_bits=initial_bits, + stop_time_s=1.0 + ) + + expected_cap_rate = initial_bits / downlink_log.timeStep[-1] + debug_compare("storage_limited_rate_cap", downlink_log.storageRemovalRate[-1], expected_cap_rate) + debug_print( + "storage-limited results: " + f"remainingBits={downlink_log.estimatedRemainingDataBits[-1]:.16e}, " + f"storageLevel={storage_log.storageLevel[-1]:.16e}" + ) + assert downlink_log.storageRemovalRate[-1] == pytest.approx(expected_cap_rate, rel=1e-12, abs=1e-9) + assert downlink_log.estimatedRemainingDataBits[-1] == pytest.approx(0.0, abs=1.0) + assert storage_log.storageLevel[-1] == pytest.approx(0.0, abs=1.0) + + +def test_downlink_auto_receiver_selects_valid_rx_path(): + """Verify auto receiver selection chooses a valid RX path with nonzero CNR.""" + debug_print("test_downlink_auto_receiver_selects_valid_rx_path") + _, _, _, downlink_log = run_downlink_case( + cnr1=0.8, + cnr2=0.0, + ant_state1=1, + ant_state2=2, + receiver_index=0, + initial_bits=1.0e9 + ) + debug_print(f"auto receiver selected index={downlink_log.receiverIndex[-1]}") + + assert downlink_log.linkActive[-1] == 1 + assert downlink_log.receiverIndex[-1] == 1 + + +def test_downlink_setter_validation_rejects_invalid_inputs(): + """Check that module setters reject invalid values and preserve prior valid configuration.""" + debug_print("test_downlink_setter_validation_rejects_invalid_inputs") + test_module = downlinkHandling.DownlinkHandling() + + assert test_module.setBitRateRequest(1.0e5) # [bit/s] + assert test_module.setPacketSizeBits(1024.0) # [bit] + assert test_module.setMaxRetransmissions(6) # [-] + assert test_module.setReceiverAntenna(2) # [-] + assert test_module.setRemovalPolicy(REMOVE_DELIVERED_ONLY) + test_module.setRequireFullPacket(True) + + with pytest.raises(Exception): + test_module.setBitRateRequest(-1.0) # [bit/s] + with pytest.raises(Exception): + test_module.setBitRateRequest(float("nan")) # [bit/s] + with pytest.raises(Exception): + test_module.setPacketSizeBits(0.0) # [bit] + with pytest.raises(Exception): + test_module.setPacketSizeBits(float("inf")) # [bit] + with pytest.raises(Exception): + test_module.setMaxRetransmissions(0) # [-] + with pytest.raises(Exception): + test_module.setReceiverAntenna(4) # [-] + with pytest.raises(Exception): + test_module.setRemovalPolicy(2) # [-] + + assert test_module.getBitRateRequest() == pytest.approx(1.0e5, abs=1e-12) + assert test_module.getPacketSizeBits() == pytest.approx(1024.0, abs=1e-12) + assert test_module.getMaxRetransmissions() == 6 + assert test_module.getReceiverAntenna() == 2 + assert test_module.getRemovalPolicy() == REMOVE_DELIVERED_ONLY + assert test_module.getRequireFullPacket() is True + + +def test_downlink_forced_receiver_invalid_path_disables_link(): + """Verify that forcing an unavailable receiver path yields no selected receiver and zero throughput.""" + debug_print("test_downlink_forced_receiver_invalid_path_disables_link") + _, node_log, _, downlink_log = run_downlink_case( + cnr1=0.8, + cnr2=0.0, + ant_state1=1, + ant_state2=2, + receiver_index=2, + initial_bits=1.0e8 + ) + + assert downlink_log.receiverIndex[-1] == 0 + assert downlink_log.linkActive[-1] == 0 + assert downlink_log.storageRemovalRate[-1] == pytest.approx(0.0, abs=1e-12) + assert node_log.baudRate[-1] == pytest.approx(0.0, abs=1e-12) + + +def test_downlink_duplicate_storage_message_is_rejected(): + """Verify duplicate storage message registration is rejected by the module.""" + debug_print("test_downlink_duplicate_storage_message_is_rejected") + + test_module = downlinkHandling.DownlinkHandling() + storage_msg = make_storage_status_msg(100.0, [("DATA", 100.0)]) # [bit] + + assert test_module.addStorageUnitToDownlink(storage_msg) is True + assert test_module.addStorageUnitToDownlink(storage_msg) is False + + +def test_downlink_selects_largest_partition_across_storage_messages(): + """Verify storage selection uses the largest partition across all linked storage status inputs.""" + debug_print("test_downlink_selects_largest_partition_across_storage_messages") + + unit_task_name = "unitTask" + unit_process_name = "unitProcess" + + unit_test_sim = SimulationBaseClass.SimBaseClass() + test_proc = unit_test_sim.CreateNewProcess(unit_process_name) + test_proc.addTask(unit_test_sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s] + + test_module = downlinkHandling.DownlinkHandling() + test_module.ModelTag = "downlink" + assert test_module.setBitRateRequest(1.0e5) # [bit/s] + assert test_module.setPacketSizeBits(256.0) # [bit] + assert test_module.setMaxRetransmissions(4) # [-] + assert test_module.setReceiverAntenna(2) # [-] + test_module.setRequireFullPacket(True) # [-] + unit_test_sim.AddModelToTask(unit_task_name, test_module) + + storage_msg_a = make_storage_status_msg( + 900.0, # [bit] + [("A_BIG", 400.0), ("A_SMALL", 100.0)] # [bit] + ) + storage_msg_b = make_storage_status_msg( + 2000.0, # [bit] + [("B_TOP", 1200.0), ("B_OTHER", 800.0)] # [bit] + ) + assert test_module.addStorageUnitToDownlink(storage_msg_a) + assert test_module.addStorageUnitToDownlink(storage_msg_b) + + link_payload = LinkBudgetMsgPayload.LinkBudgetMsgPayload() + link_payload.antennaName1 = "TX_Ant" + link_payload.antennaName2 = "RX_Ant" + link_payload.antennaState1 = 2 # [-] TX + link_payload.antennaState2 = 1 # [-] RX + link_payload.CNR1 = 0.0 # [-] + link_payload.CNR2 = 20.0 # [-] + link_payload.bandwidth = 1.0e6 # [Hz] + link_payload.frequency = 2.2e9 # [Hz] + link_msg = LinkBudgetMsgPayload.LinkBudgetMsg().write(link_payload) + test_module.linkBudgetInMsg.subscribeTo(link_msg) + + downlink_reader = DownlinkHandlingMsgPayload.DownlinkHandlingMsgReader() + downlink_reader.subscribeTo(test_module.downlinkOutMsg) + downlink_log = downlink_reader.recorder() + unit_test_sim.AddModelToTask(unit_task_name, downlink_log) + + unit_test_sim.InitializeSimulation() + unit_test_sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s] + unit_test_sim.ExecuteSimulation() + + assert downlink_log.availableDataBits[-1] == pytest.approx(1200.0, abs=1e-12) + assert downlink_log.dataName[-1] == "B_TOP" + + +def test_downlink_ambiguous_multi_storage_name_blocks_removal(): + """Verify duplicate partition names across linked storage units force removal rate to zero.""" + debug_print("test_downlink_ambiguous_multi_storage_name_blocks_removal") + + unit_task_name = "unitTask" + unit_process_name = "unitProcess" + + unit_test_sim = SimulationBaseClass.SimBaseClass() + test_proc = unit_test_sim.CreateNewProcess(unit_process_name) + test_proc.addTask(unit_test_sim.CreateNewTask(unit_task_name, macros.sec2nano(1.0))) # [s] + + test_module = downlinkHandling.DownlinkHandling() + test_module.ModelTag = "downlink" + assert test_module.setBitRateRequest(1.0e5) # [bit/s] + assert test_module.setPacketSizeBits(256.0) # [bit] + assert test_module.setMaxRetransmissions(4) # [-] + assert test_module.setReceiverAntenna(2) # [-] + test_module.setRequireFullPacket(True) # [-] + unit_test_sim.AddModelToTask(unit_task_name, test_module) + + storage_msg_a = make_storage_status_msg( + 800.0, # [bit] + [("STORED DATA", 400.0)] # [bit] + ) + storage_msg_b = make_storage_status_msg( + 3000.0, # [bit] + [("STORED DATA", 1200.0)] # [bit] + ) + assert test_module.addStorageUnitToDownlink(storage_msg_a) + assert test_module.addStorageUnitToDownlink(storage_msg_b) + + link_payload = LinkBudgetMsgPayload.LinkBudgetMsgPayload() + link_payload.antennaName1 = "TX_Ant" + link_payload.antennaName2 = "RX_Ant" + link_payload.antennaState1 = 2 # [-] TX + link_payload.antennaState2 = 1 # [-] RX + link_payload.CNR1 = 0.0 # [-] + link_payload.CNR2 = 20.0 # [-] + link_payload.bandwidth = 1.0e6 # [Hz] + link_payload.frequency = 2.2e9 # [Hz] + link_msg = LinkBudgetMsgPayload.LinkBudgetMsg().write(link_payload) + test_module.linkBudgetInMsg.subscribeTo(link_msg) + + node_log = test_module.nodeDataOutMsg.recorder() + downlink_reader = DownlinkHandlingMsgPayload.DownlinkHandlingMsgReader() + downlink_reader.subscribeTo(test_module.downlinkOutMsg) + downlink_log = downlink_reader.recorder() + unit_test_sim.AddModelToTask(unit_task_name, node_log) + unit_test_sim.AddModelToTask(unit_task_name, downlink_log) + + unit_test_sim.InitializeSimulation() + unit_test_sim.ConfigureStopTime(macros.sec2nano(1.0)) # [s] + unit_test_sim.ExecuteSimulation() + + assert downlink_log.linkActive[-1] == 1 + assert downlink_log.dataName[-1] == "STORED DATA" + assert downlink_log.availableDataBits[-1] == pytest.approx(1200.0, abs=1e-12) + assert downlink_log.storageRemovalRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.deliveredDataRate[-1] == pytest.approx(0.0, abs=1e-12) + assert downlink_log.droppedDataRate[-1] == pytest.approx(0.0, abs=1e-12) + assert node_log.baudRate[-1] == pytest.approx(0.0, abs=1e-12) + + +if __name__ == "__main__": + # Allow direct execution: + # python test_downlinkHandling.py -k test_name -q -s --debug + args = sys.argv[1:] + debug_flags = {"--debug", "--debug-downlink", "--debug-downlink-test"} + filtered_args = [] + debug_requested = False + + for arg in args: + if arg in debug_flags: + debug_requested = True + else: + filtered_args.append(arg) + + # If no explicit test file/path is passed, run this file only. + explicit_target = any(a.endswith(".py") or os.path.exists(a) for a in filtered_args if not a.startswith("-")) + if not explicit_target: + filtered_args.append(os.path.abspath(__file__)) + + if debug_requested: + if "-s" not in filtered_args: + filtered_args.append("-s") + os.environ["BSK_DOWNLINK_TEST_DEBUG"] = "1" + DEBUG_DOWNLINK_TEST = True + raise SystemExit(pytest.main(filtered_args)) diff --git a/src/simulation/communication/downlinkHandling/downlinkHandling.cpp b/src/simulation/communication/downlinkHandling/downlinkHandling.cpp new file mode 100644 index 00000000000..cd1d6390c64 --- /dev/null +++ b/src/simulation/communication/downlinkHandling/downlinkHandling.cpp @@ -0,0 +1,635 @@ +/* + ISC License + +Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#include "simulation/communication/downlinkHandling/downlinkHandling.h" + +#include +#include +#include + +#include "architecture/utilities/macroDefinitions.h" + +/*! Constructor */ +DownlinkHandling::DownlinkHandling() +{ + this->bitRateRequest = 0.0; // [bit/s] + this->packetSizeBits = 256.0; // [bit] + this->maxRetransmissions = 10; // [-] + this->receiverAntenna = 0; // [-] + this->removalPolicy = RemovalPolicy::REMOVE_ATTEMPTED; // [-] + this->requireFullPacket = true; // [-] + + this->previousTime = 0.0; // [s] + this->currentTimeStep = 0.0; // [s] + this->availableDataBits = 0.0; // [bit] + + this->cumulativeAttemptedBits = 0.0; // [bit] + this->cumulativeRemovedBits = 0.0; // [bit] + this->cumulativeDeliveredBits = 0.0; // [bit] + this->cumulativeDroppedBits = 0.0; // [bit] + + this->linkBudgetValid = false; + this->linkBudgetBuffer = {}; + this->downlinkOutBuffer = this->downlinkOutMsg.zeroMsgPayload; + + std::strncpy(this->nodeDataName, "STORED DATA", sizeof(this->nodeDataName) - 1); + this->nodeDataName[sizeof(this->nodeDataName) - 1] = '\0'; +} + +/*! Add a storage-status message to the module input list */ +bool +DownlinkHandling::addStorageUnitToDownlink(Message* tmpStorageUnitMsg) +{ + if (tmpStorageUnitMsg == nullptr) { + bskLogger.bskLog(BSK_ERROR, "DownlinkHandling.addStorageUnitToDownlink: null message pointer."); + return false; + } + + for (auto* msgPtr : this->storageUnitMsgPtrs) { + if (msgPtr == tmpStorageUnitMsg) { + bskLogger.bskLog(BSK_WARNING, + "DownlinkHandling.addStorageUnitToDownlink: duplicate storage message ignored."); + return false; + } + } + + this->storageUnitMsgPtrs.push_back(tmpStorageUnitMsg); + this->storageUnitInMsgs.push_back(tmpStorageUnitMsg->addSubscriber()); + return true; +} + +/*! Set requested raw bit rate [bit/s] */ +bool +DownlinkHandling::setBitRateRequest(double bitRateRequest) +{ + if (!this->isFiniteNonNegative(bitRateRequest)) { + bskLogger.bskLog(BSK_ERROR, "DownlinkHandling.setBitRateRequest: value must be finite and >= 0 [bit/s]."); + return false; + } + + this->bitRateRequest = bitRateRequest; + return true; +} + +/*! Set packet size used for BER-to-PER conversion [bit] */ +bool +DownlinkHandling::setPacketSizeBits(double packetSizeBits) +{ + if (!this->isFinitePositive(packetSizeBits)) { + bskLogger.bskLog(BSK_ERROR, "DownlinkHandling.setPacketSizeBits: value must be finite and > 0 [bit]."); + return false; + } + + this->packetSizeBits = packetSizeBits; + return true; +} + +/*! Set retry-cap used by the ARQ expectation model [-] */ +bool +DownlinkHandling::setMaxRetransmissions(int64_t maxRetransmissions) +{ + if (maxRetransmissions < 1) { + bskLogger.bskLog(BSK_ERROR, "DownlinkHandling.setMaxRetransmissions: value must be >= 1."); + return false; + } + + this->maxRetransmissions = static_cast(maxRetransmissions); + return true; +} + +/*! Set receiver-selection mode: 0=auto, 1=path1, 2=path2 [-] */ +bool +DownlinkHandling::setReceiverAntenna(int64_t receiverAntenna) +{ + if (receiverAntenna != 0 && receiverAntenna != 1 && receiverAntenna != 2) { + bskLogger.bskLog(BSK_ERROR, "DownlinkHandling.setReceiverAntenna: value must be 0 (auto), 1, or 2."); + return false; + } + + this->receiverAntenna = receiverAntenna; + return true; +} + +/*! Set storage-removal policy: 0=remove attempted, 1=remove delivered only */ +bool +DownlinkHandling::setRemovalPolicy(int64_t removalPolicy) +{ + if (removalPolicy == static_cast(RemovalPolicy::REMOVE_ATTEMPTED)) { + this->removalPolicy = RemovalPolicy::REMOVE_ATTEMPTED; + return true; + } + if (removalPolicy == static_cast(RemovalPolicy::REMOVE_DELIVERED_ONLY)) { + this->removalPolicy = RemovalPolicy::REMOVE_DELIVERED_ONLY; + return true; + } + + bskLogger.bskLog( + BSK_ERROR, "DownlinkHandling.setRemovalPolicy: value must be 0 (REMOVE_ATTEMPTED) or 1 (REMOVE_DELIVERED_ONLY)."); + return false; +} + +/*! Set packet-gating behavior flag */ +void +DownlinkHandling::setRequireFullPacket(bool requireFullPacket) +{ + this->requireFullPacket = requireFullPacket; +} + +/*! Module reset hook */ +void +DownlinkHandling::customReset(uint64_t CurrentClock) +{ + this->previousTime = static_cast(CurrentClock) * NANO2SEC; + this->currentTimeStep = 0.0; // [s] + + this->cumulativeAttemptedBits = 0.0; // [bit] + this->cumulativeRemovedBits = 0.0; // [bit] + this->cumulativeDeliveredBits = 0.0; // [bit] + this->cumulativeDroppedBits = 0.0; // [bit] + this->lastAmbiguousRouteName.clear(); + + this->downlinkOutBuffer = this->downlinkOutMsg.zeroMsgPayload; + + if (!this->validateConfiguration()) { + bskLogger.bskLog(BSK_WARNING, "DownlinkHandling: invalid configuration detected at reset; restoring defaults."); + this->bitRateRequest = 0.0; // [bit/s] + this->packetSizeBits = 256.0; // [bit] + this->maxRetransmissions = 10; // [-] + this->receiverAntenna = 0; // [-] + this->removalPolicy = RemovalPolicy::REMOVE_ATTEMPTED; // [-] + this->requireFullPacket = true; // [-] + } + + if (!this->linkBudgetInMsg.isLinked()) { + bskLogger.bskLog(BSK_WARNING, "DownlinkHandling.linkBudgetInMsg is not linked."); + } + if (this->storageUnitInMsgs.empty()) { + bskLogger.bskLog(BSK_WARNING, "DownlinkHandling has no storageUnitInMsgs linked."); + } +} + +/*! Read custom input messages */ +bool +DownlinkHandling::customReadMessages() +{ + this->storageUnitMsgsBuffer.clear(); + for (auto& msg : this->storageUnitInMsgs) { + this->storageUnitMsgsBuffer.push_back(msg()); + } + + this->linkBudgetValid = this->linkBudgetInMsg.isLinked() && this->linkBudgetInMsg.isWritten(); + if (this->linkBudgetValid) { + this->linkBudgetBuffer = this->linkBudgetInMsg(); + } else { + this->linkBudgetBuffer = {}; + } + + return true; +} + +/*! Write custom output messages */ +void +DownlinkHandling::customWriteMessages(uint64_t CurrentClock) +{ + if (this->dataStatus <= 0) { + // When DataNodeBase skips evaluateDataModel() (node disabled), avoid publishing stale diagnostics. + // Also advance timing state so a later re-enable does not integrate disabled downtime in one step. + this->previousTime = static_cast(CurrentClock) * NANO2SEC; // [s] + this->currentTimeStep = 0.0; // [s] + this->downlinkOutBuffer = this->downlinkOutMsg.zeroMsgPayload; + this->downlinkOutBuffer.maxRetransmissions = this->maxRetransmissions; + this->downlinkOutBuffer.removalPolicy = this->getRemovalPolicy(); + this->downlinkOutBuffer.bitRateRequest = this->bitRateRequest; + this->downlinkOutBuffer.packetSizeBits = this->packetSizeBits; + this->downlinkOutBuffer.cumulativeAttemptedBits = this->sanitizeNonNegative(this->cumulativeAttemptedBits); + this->downlinkOutBuffer.cumulativeRemovedBits = this->sanitizeNonNegative(this->cumulativeRemovedBits); + this->downlinkOutBuffer.cumulativeDeliveredBits = this->sanitizeNonNegative(this->cumulativeDeliveredBits); + this->downlinkOutBuffer.cumulativeDroppedBits = this->sanitizeNonNegative(this->cumulativeDroppedBits); + } + this->downlinkOutMsg.write(&this->downlinkOutBuffer, this->moduleID, CurrentClock); +} + +/*! Core data-model evaluation */ +void +DownlinkHandling::evaluateDataModel(DataNodeUsageMsgPayload* dataUsageMsg, double currentTime) +{ + *dataUsageMsg = this->nodeDataOutMsg.zeroMsgPayload; + + double computedTimeStep = currentTime - this->previousTime; // [s] + if (!this->isFiniteNonNegative(computedTimeStep)) { + computedTimeStep = 0.0; // [s] + } + this->currentTimeStep = computedTimeStep; + + this->downlinkOutBuffer = this->downlinkOutMsg.zeroMsgPayload; + this->downlinkOutBuffer.timeStep = this->currentTimeStep; + this->downlinkOutBuffer.maxRetransmissions = this->maxRetransmissions; + this->downlinkOutBuffer.bitRateRequest = this->bitRateRequest; + this->downlinkOutBuffer.packetSizeBits = this->packetSizeBits; + this->downlinkOutBuffer.removalPolicy = this->getRemovalPolicy(); + this->downlinkOutBuffer.bandwidth = this->sanitizeNonNegative(this->linkBudgetBuffer.bandwidth); + + const StorageSelection selection = this->selectStorageSelection(); + this->availableDataBits = selection.availableBits; + this->setDataNameFromStorageSelection(selection, this->nodeDataName, sizeof(this->nodeDataName)); + std::strncpy(dataUsageMsg->dataName, this->nodeDataName, sizeof(dataUsageMsg->dataName) - 1); + dataUsageMsg->dataName[sizeof(dataUsageMsg->dataName) - 1] = '\0'; + + std::strncpy(this->downlinkOutBuffer.dataName, this->nodeDataName, sizeof(this->downlinkOutBuffer.dataName) - 1); + this->downlinkOutBuffer.dataName[sizeof(this->downlinkOutBuffer.dataName) - 1] = '\0'; + this->downlinkOutBuffer.availableDataBits = this->availableDataBits; + const bool uniqueStorageRoute = this->isStorageRouteUnique(selection, this->nodeDataName); + if (!uniqueStorageRoute) { + if (this->lastAmbiguousRouteName != this->nodeDataName) { + bskLogger.bskLog(BSK_WARNING, + "DownlinkHandling: selected dataName '%s' is present in multiple linked storage units; " + "storage removal is forced to zero to avoid ambiguous routing.", + this->nodeDataName); + this->lastAmbiguousRouteName = this->nodeDataName; + } + } else { + this->lastAmbiguousRouteName.clear(); + } + + double selectedCnr = 0.0; // [-] + uint32_t selectedReceiver = 0; // [-] + this->selectReceiver(&selectedCnr, &selectedReceiver); + this->downlinkOutBuffer.receiverIndex = selectedReceiver; + this->downlinkOutBuffer.cnr = this->sanitizeNonNegative(selectedCnr); + + if (selectedReceiver == 1) { + std::strncpy(this->downlinkOutBuffer.receiverAntennaName, + this->linkBudgetBuffer.antennaName1, + sizeof(this->downlinkOutBuffer.receiverAntennaName) - 1); + std::strncpy(this->downlinkOutBuffer.transmitterAntennaName, + this->linkBudgetBuffer.antennaName2, + sizeof(this->downlinkOutBuffer.transmitterAntennaName) - 1); + } else if (selectedReceiver == 2) { + std::strncpy(this->downlinkOutBuffer.receiverAntennaName, + this->linkBudgetBuffer.antennaName2, + sizeof(this->downlinkOutBuffer.receiverAntennaName) - 1); + std::strncpy(this->downlinkOutBuffer.transmitterAntennaName, + this->linkBudgetBuffer.antennaName1, + sizeof(this->downlinkOutBuffer.transmitterAntennaName) - 1); + } + this->downlinkOutBuffer.receiverAntennaName[sizeof(this->downlinkOutBuffer.receiverAntennaName) - 1] = '\0'; + this->downlinkOutBuffer.transmitterAntennaName[sizeof(this->downlinkOutBuffer.transmitterAntennaName) - 1] = '\0'; + + const bool validParams = this->linkBudgetValid && this->isFinitePositive(selectedCnr) && + this->isFinitePositive(this->downlinkOutBuffer.bandwidth) && + this->isFinitePositive(this->bitRateRequest) && + this->isFinitePositive(this->packetSizeBits); + this->downlinkOutBuffer.linkActive = validParams ? 1 : 0; + + double ber = 0.0; // [-] + double per = 0.0; // [-] + double packetSuccessProb = 0.0; // [-] + double packetDropProb = 0.0; // [-] + double expectedAttemptsPerPacket = static_cast(this->maxRetransmissions); // [-] + + double attemptedRatePotential = 0.0; // [bit/s] + double storageRemovalRatePotential = 0.0; // [bit/s] + double deliveredRatePotential = 0.0; // [bit/s] + double droppedRatePotential = 0.0; // [bit/s] + + if (validParams) { + // Convert RF quality to per-bit energy so BER reflects user-selected bit rate. + this->downlinkOutBuffer.cnr_dB = 10.0 * std::log10(selectedCnr); + this->downlinkOutBuffer.cNo_dBHz = + this->downlinkOutBuffer.cnr_dB + 10.0 * std::log10(this->downlinkOutBuffer.bandwidth); + this->downlinkOutBuffer.ebN0_dB = this->downlinkOutBuffer.cNo_dBHz - 10.0 * std::log10(this->bitRateRequest); + + ber = this->clampProbability(this->computeBerFromEbN0dB(this->downlinkOutBuffer.ebN0_dB)); + + // Packet fails if any bit is wrong (checksum detect model). + if (ber >= 1.0) { + per = 1.0; + } else if (ber <= 0.0) { + per = 0.0; + } else { + per = 1.0 - std::exp(this->packetSizeBits * std::log1p(-ber)); + per = this->clampProbability(per); + } + + const double maxRetxAsDouble = static_cast(this->maxRetransmissions); + const double successOneAttempt = this->clampProbability(1.0 - per); + packetDropProb = this->clampProbability(std::pow(per, maxRetxAsDouble)); + packetSuccessProb = this->clampProbability(1.0 - packetDropProb); + + if (successOneAttempt <= 0.0) { + expectedAttemptsPerPacket = maxRetxAsDouble; + } else { + expectedAttemptsPerPacket = packetSuccessProb / successOneAttempt; + expectedAttemptsPerPacket = std::clamp(expectedAttemptsPerPacket, 1.0, maxRetxAsDouble); + } + + attemptedRatePotential = this->bitRateRequest; + storageRemovalRatePotential = attemptedRatePotential / expectedAttemptsPerPacket; + deliveredRatePotential = storageRemovalRatePotential * packetSuccessProb; + droppedRatePotential = storageRemovalRatePotential - deliveredRatePotential; + } + + this->downlinkOutBuffer.ber = ber; + this->downlinkOutBuffer.per = per; + this->downlinkOutBuffer.packetSuccessProb = packetSuccessProb; + this->downlinkOutBuffer.packetDropProb = packetDropProb; + this->downlinkOutBuffer.expectedAttemptsPerPacket = expectedAttemptsPerPacket; + + double scale = 0.0; // [-] + // Optional packet gating plus storage saturation: never remove more than available this step. + const bool enoughForPacket = (!this->requireFullPacket) || (this->availableDataBits >= this->packetSizeBits); + if (storageRemovalRatePotential > 0.0 && this->currentTimeStep > 0.0 && enoughForPacket && uniqueStorageRoute) { + const double availableRemovalRate = this->availableDataBits / this->currentTimeStep; // [bit/s] + if (this->isFiniteNonNegative(availableRemovalRate)) { + scale = std::clamp(availableRemovalRate / storageRemovalRatePotential, 0.0, 1.0); + } + } + + const double attemptedDataRate = this->sanitizeNonNegative(attemptedRatePotential * scale); // [bit/s] + const double modeledStorageRemovalRate = this->sanitizeNonNegative(storageRemovalRatePotential * scale); // [bit/s] + const double deliveredDataRate = this->sanitizeNonNegative(deliveredRatePotential * scale); // [bit/s] + const double droppedDataRate = this->sanitizeNonNegative(droppedRatePotential * scale); // [bit/s] + + // Removal policy controls what is actually deleted onboard: + // attempted mode removes delivered+drop-limited bits, delivered-only keeps dropped bits for retry/future attempts. + const bool removeDeliveredOnly = (this->removalPolicy == RemovalPolicy::REMOVE_DELIVERED_ONLY); + const double storageRemovalRate = removeDeliveredOnly ? deliveredDataRate : modeledStorageRemovalRate; // [bit/s] + dataUsageMsg->baudRate = -storageRemovalRate; + + this->downlinkOutBuffer.attemptedDataRate = attemptedDataRate; + this->downlinkOutBuffer.storageRemovalRate = storageRemovalRate; + this->downlinkOutBuffer.deliveredDataRate = deliveredDataRate; + this->downlinkOutBuffer.droppedDataRate = droppedDataRate; + + const double removedBitsThisStep = storageRemovalRate * this->currentTimeStep; // [bit] + this->downlinkOutBuffer.estimatedRemainingDataBits = + this->sanitizeNonNegative(this->availableDataBits - removedBitsThisStep); + + if (this->currentTimeStep > 0.0) { + this->cumulativeAttemptedBits += attemptedDataRate * this->currentTimeStep; + this->cumulativeRemovedBits += storageRemovalRate * this->currentTimeStep; + this->cumulativeDeliveredBits += deliveredDataRate * this->currentTimeStep; + this->cumulativeDroppedBits += droppedDataRate * this->currentTimeStep; + } + + this->downlinkOutBuffer.cumulativeAttemptedBits = this->sanitizeNonNegative(this->cumulativeAttemptedBits); + this->downlinkOutBuffer.cumulativeRemovedBits = this->sanitizeNonNegative(this->cumulativeRemovedBits); + this->downlinkOutBuffer.cumulativeDeliveredBits = this->sanitizeNonNegative(this->cumulativeDeliveredBits); + this->downlinkOutBuffer.cumulativeDroppedBits = this->sanitizeNonNegative(this->cumulativeDroppedBits); + + if (this->isFinite(currentTime)) { + this->previousTime = currentTime; + } else { + this->previousTime += this->currentTimeStep; + } +} + +/*! Select storage target with the largest finite data across linked units. + * + * The function prioritizes per-partition values when available and only falls back to + * aggregate ``storageLevel`` for messages that provide no partition vector. + */ +DownlinkHandling::StorageSelection +DownlinkHandling::selectStorageSelection() const +{ + StorageSelection selection; + selection.storageUnitIndex = -1; + selection.partitionIndex = -1; + selection.availableBits = 0.0; // [bit] + + for (std::size_t unitIdx = 0; unitIdx < this->storageUnitMsgsBuffer.size(); unitIdx++) { + const auto& storage = this->storageUnitMsgsBuffer[unitIdx]; + + bool foundFinitePartition = false; // [-] + for (std::size_t partitionIdx = 0; partitionIdx < storage.storedData.size(); partitionIdx++) { + const double partitionBits = this->sanitizeNonNegative(storage.storedData[partitionIdx]); // [bit] + foundFinitePartition = true; + if (partitionBits > selection.availableBits) { + selection.storageUnitIndex = static_cast(unitIdx); // [-] + selection.partitionIndex = static_cast(partitionIdx); // [-] + selection.availableBits = partitionBits; // [bit] + } + } + + // Fallback to aggregate storage-level only when no partition vector is provided. + if (!foundFinitePartition) { + const double storageLevelBits = this->sanitizeNonNegative(storage.storageLevel); // [bit] + if (storageLevelBits > selection.availableBits) { + selection.storageUnitIndex = static_cast(unitIdx); // [-] + selection.partitionIndex = -1; // [-] + selection.availableBits = storageLevelBits; // [bit] + } + } + } + + return selection; +} + +/*! Return true only when selected dataName identifies exactly one linked storage unit */ +bool +DownlinkHandling::isStorageRouteUnique(const StorageSelection& selection, const char* dataName) const +{ + if (selection.storageUnitIndex < 0) { + return true; + } + if (dataName == nullptr || dataName[0] == '\0') { + return false; + } + + // Aggregate-only messages (no partition vector) cannot be uniquely routed across multiple units. + if (selection.partitionIndex < 0) { + return this->storageUnitMsgsBuffer.size() <= 1; + } + + std::size_t unitsWithMatch = 0; // [-] + for (const auto& storage : this->storageUnitMsgsBuffer) { + bool hasMatch = false; // [-] + for (const auto& name : storage.storedDataName) { + if (name == dataName) { + hasMatch = true; + break; + } + } + if (hasMatch) { + unitsWithMatch++; + if (unitsWithMatch > 1) { + return false; + } + } + } + + return unitsWithMatch == 1; +} + +/*! Set data name based on selected storage unit and partition */ +void +DownlinkHandling::setDataNameFromStorageSelection(const StorageSelection& selection, + char* buffer, + std::size_t bufferSize) const +{ + if (bufferSize == 0) { + return; + } + + std::strncpy(buffer, "STORED DATA", bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + + if (selection.storageUnitIndex < 0) { + return; + } + const auto selectedUnit = static_cast(selection.storageUnitIndex); + if (selectedUnit >= this->storageUnitMsgsBuffer.size()) { + return; + } + + const auto& storage = this->storageUnitMsgsBuffer[selectedUnit]; + if (selection.partitionIndex < 0) { + return; + } + + const auto selectedPartition = static_cast(selection.partitionIndex); + if (selectedPartition < storage.storedDataName.size()) { + const auto& name = storage.storedDataName[selectedPartition]; + if (!name.empty()) { + std::strncpy(buffer, name.c_str(), bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + } + } +} + +/*! Select receiver CNR from link budget payload */ +void +DownlinkHandling::selectReceiver(double* cnrLinear, uint32_t* receiverIndex) const +{ + *cnrLinear = 0.0; + *receiverIndex = 0; + + if (!this->linkBudgetValid) { + return; + } + + const bool rx1 = this->isReceiverState(this->linkBudgetBuffer.antennaState1); + const bool rx2 = this->isReceiverState(this->linkBudgetBuffer.antennaState2); + const double cnr1 = + (rx1 && this->isFinitePositive(this->linkBudgetBuffer.CNR1)) ? this->linkBudgetBuffer.CNR1 : 0.0; + const double cnr2 = + (rx2 && this->isFinitePositive(this->linkBudgetBuffer.CNR2)) ? this->linkBudgetBuffer.CNR2 : 0.0; + + if (this->receiverAntenna == 1) { + if (cnr1 > 0.0) { + *receiverIndex = 1; + *cnrLinear = cnr1; + } + return; + } + if (this->receiverAntenna == 2) { + if (cnr2 > 0.0) { + *receiverIndex = 2; + *cnrLinear = cnr2; + } + return; + } + + if (cnr1 >= cnr2 && cnr1 > 0.0) { + *receiverIndex = 1; + *cnrLinear = cnr1; + } else if (cnr2 > 0.0) { + *receiverIndex = 2; + *cnrLinear = cnr2; + } +} + +/*! Validate module configuration values */ +bool +DownlinkHandling::validateConfiguration() const +{ + return this->isFiniteNonNegative(this->bitRateRequest) && this->isFinitePositive(this->packetSizeBits) && + this->maxRetransmissions >= 1 && + (this->receiverAntenna == 0 || this->receiverAntenna == 1 || this->receiverAntenna == 2) && + (this->removalPolicy == RemovalPolicy::REMOVE_ATTEMPTED || + this->removalPolicy == RemovalPolicy::REMOVE_DELIVERED_ONLY); +} + +/*! Return true if `value` is finite */ +bool +DownlinkHandling::isFinite(double value) +{ + return std::isfinite(value); +} + +/*! Return true if `value` is finite and >= 0 */ +bool +DownlinkHandling::isFiniteNonNegative(double value) +{ + return std::isfinite(value) && value >= 0.0; +} + +/*! Return true if `value` is finite and > 0 */ +bool +DownlinkHandling::isFinitePositive(double value) +{ + return std::isfinite(value) && value > 0.0; +} + +/*! Check if antenna state allows receiving */ +bool +DownlinkHandling::isReceiverState(uint32_t state) +{ + const auto antennaState = static_cast(state); + return antennaState == AntennaTypes::AntennaStateEnum::ANTENNA_RX || + antennaState == AntennaTypes::AntennaStateEnum::ANTENNA_RXTX; +} + +/*! Compute BER for BPSK over AWGN from Eb/N0 [dB] */ +double +DownlinkHandling::computeBerFromEbN0dB(double ebN0_dB) +{ + if (!std::isfinite(ebN0_dB)) { + return 0.5; + } + + const double ebN0Linear = std::pow(10.0, ebN0_dB / 10.0); + if (!std::isfinite(ebN0Linear)) { + return 0.0; + } + if (ebN0Linear <= 0.0) { + return 0.5; + } + return 0.5 * std::erfc(std::sqrt(ebN0Linear)); +} + +/*! Clamp probabilities to [0, 1] */ +double +DownlinkHandling::clampProbability(double value) +{ + if (!std::isfinite(value)) { + return 0.0; + } + return std::clamp(value, 0.0, 1.0); +} + +/*! Clamp all invalid/non-physical nonnegative outputs to 0 */ +double +DownlinkHandling::sanitizeNonNegative(double value) +{ + if (!std::isfinite(value) || value < 0.0) { + return 0.0; + } + return value; +} diff --git a/src/simulation/communication/downlinkHandling/downlinkHandling.h b/src/simulation/communication/downlinkHandling/downlinkHandling.h new file mode 100644 index 00000000000..860e608f0c2 --- /dev/null +++ b/src/simulation/communication/downlinkHandling/downlinkHandling.h @@ -0,0 +1,202 @@ +/* + ISC License + +Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +#ifndef DOWNLINKHANDLING_H +#define DOWNLINKHANDLING_H + +#include +#include +#include +#include + +#include "architecture/msgPayloadDefC/DownlinkHandlingMsgPayload.h" +#include "architecture/msgPayloadDefC/LinkBudgetMsgPayload.h" +#include "architecture/utilities/bskLogging.h" +#include "simulation/communication/_GeneralModuleFiles/AntennaDefinitions.h" +#include "simulation/onboardDataHandling/_GeneralModuleFiles/dataNodeBase.h" + +/*! @brief Downlink data-handling model that maps link quality to reliable throughput */ +class DownlinkHandling : public DataNodeBase +{ + public: + /*! @brief Storage removal policy selector. + * + * ``REMOVE_ATTEMPTED`` removes both delivered and drop-limited data from storage. + * ``REMOVE_DELIVERED_ONLY`` removes only successfully delivered data. + */ + enum class RemovalPolicy : uint32_t + { + REMOVE_ATTEMPTED = 0, //!< [-] Remove delivered + drop-limited bits (current default behavior) + REMOVE_DELIVERED_ONLY = 1 //!< [-] Remove only successfully delivered bits + }; + + /*! @brief Construct the downlink handling module with default parameters */ + DownlinkHandling(); + /*! @brief Default destructor */ + ~DownlinkHandling() = default; + + /*! @brief Register a storage-status input message. + * + * Duplicate and null pointers are rejected. + * + * @param tmpStorageUnitMsg Pointer to a storage status output message + * @return ``true`` if the message was added, ``false`` otherwise + */ + bool addStorageUnitToDownlink(Message* tmpStorageUnitMsg); + + /*! @brief Set requested raw channel bit rate. + * @param bitRateRequest Requested bit rate [bit/s], must be finite and :math:`\ge 0` + * @return ``true`` if accepted, ``false`` otherwise + */ + bool setBitRateRequest(double bitRateRequest); + /*! @brief Set packet size used by BER-to-PER conversion. + * @param packetSizeBits Packet size [bit], must be finite and :math:`> 0` + * @return ``true`` if accepted, ``false`` otherwise + */ + bool setPacketSizeBits(double packetSizeBits); + /*! @brief Set retry cap used by the ARQ model. + * @param maxRetransmissions Maximum packet transmission attempts, must be :math:`\ge 1` + * @return ``true`` if accepted, ``false`` otherwise + */ + bool setMaxRetransmissions(int64_t maxRetransmissions); + /*! @brief Select receiver path mode. + * @param receiverAntenna ``0`` = auto, ``1`` = force path 1, ``2`` = force path 2 + * @return ``true`` if accepted, ``false`` otherwise + */ + bool setReceiverAntenna(int64_t receiverAntenna); + /*! @brief Set storage-removal policy. + * @param removalPolicy ``0`` = remove attempted, ``1`` = remove delivered only + * @return ``true`` if accepted, ``false`` otherwise + */ + bool setRemovalPolicy(int64_t removalPolicy); + /*! @brief Set packet gating behavior. + * @param requireFullPacket If ``true``, require at least one full packet before downlink + */ + void setRequireFullPacket(bool requireFullPacket); + + /*! @brief Get requested raw channel bit rate. + * @return Requested bit rate [bit/s] + */ + double getBitRateRequest() const { return this->bitRateRequest; } + /*! @brief Get configured packet size. + * @return Packet size [bit] + */ + double getPacketSizeBits() const { return this->packetSizeBits; } + /*! @brief Get retry cap used by ARQ model. + * @return Maximum transmission attempts per packet [-] + */ + uint64_t getMaxRetransmissions() const { return this->maxRetransmissions; } + /*! @brief Get receiver selection mode. + * @return ``0`` (auto), ``1`` (path 1), or ``2`` (path 2) + */ + int64_t getReceiverAntenna() const { return this->receiverAntenna; } + /*! @brief Get storage-removal policy. + * @return ``0`` (remove attempted) or ``1`` (remove delivered only) + */ + uint32_t getRemovalPolicy() const { return static_cast(this->removalPolicy); } + /*! @brief Get packet gating mode. + * @return ``true`` if one full packet is required before downlink + */ + bool getRequireFullPacket() const { return this->requireFullPacket; } + + ReadFunctor linkBudgetInMsg; //!< Link-budget input message + Message downlinkOutMsg; //!< Downlink performance output message + + BSKLogger bskLogger; //!< BSK logging interface + + /*! @brief Return 1 if link-quality inputs were valid on the last update, else 0 */ + uint32_t getCurrentLinkActive() const { return this->downlinkOutBuffer.linkActive; } + /*! @brief Return selected receiver index on the last update (0=no receiver, 1/2=receiver path) */ + uint32_t getCurrentReceiverIndex() const { return this->downlinkOutBuffer.receiverIndex; } + /*! @brief Return last module time step [s] */ + double getCurrentTimeStep() const { return this->downlinkOutBuffer.timeStep; } + /*! @brief Return last computed bit-error-rate (BER) [-] */ + double getCurrentBer() const { return this->downlinkOutBuffer.ber; } + /*! @brief Return last computed packet-error-rate (PER) [-] */ + double getCurrentPer() const { return this->downlinkOutBuffer.per; } + /*! @brief Return last packet drop probability within retry cap [-] */ + double getCurrentPacketDropProb() const { return this->downlinkOutBuffer.packetDropProb; } + /*! @brief Return last expected attempts per packet under retry cap [-] */ + double getCurrentExpectedAttemptsPerPacket() const { return this->downlinkOutBuffer.expectedAttemptsPerPacket; } + /*! @brief Return last data-removal rate from storage [bit/s] */ + double getCurrentStorageRemovalRate() const { return this->downlinkOutBuffer.storageRemovalRate; } + /*! @brief Return last successfully delivered data rate [bit/s] */ + double getCurrentDeliveredDataRate() const { return this->downlinkOutBuffer.deliveredDataRate; } + /*! @brief Return last dropped data rate after retries [bit/s] */ + double getCurrentDroppedDataRate() const { return this->downlinkOutBuffer.droppedDataRate; } + /*! @brief Return estimated remaining bits in selected data partition [bit] */ + double getCurrentEstimatedRemainingDataBits() const { return this->downlinkOutBuffer.estimatedRemainingDataBits; } + + private: + struct StorageSelection + { + int64_t storageUnitIndex; //!< [-] storage-unit index in `storageUnitMsgsBuffer` + int64_t partitionIndex; //!< [-] partition index in the selected storage unit + double availableBits; //!< [bit] available bits in selected unit/partition + }; + + bool customReadMessages() override; + void customWriteMessages(uint64_t CurrentClock) override; + void customReset(uint64_t CurrentClock) override; + void evaluateDataModel(DataNodeUsageMsgPayload* dataUsageMsg, double currentTime) override; + + StorageSelection selectStorageSelection() const; + bool isStorageRouteUnique(const StorageSelection& selection, const char* dataName) const; + void setDataNameFromStorageSelection(const StorageSelection& selection, char* buffer, std::size_t bufferSize) const; + void selectReceiver(double* cnrLinear, uint32_t* receiverIndex) const; + bool validateConfiguration() const; + static bool isFinite(double value); + static bool isFiniteNonNegative(double value); + static bool isFinitePositive(double value); + static bool isReceiverState(uint32_t state); + static double computeBerFromEbN0dB(double ebN0_dB); + static double clampProbability(double value); + static double sanitizeNonNegative(double value); + + private: + double bitRateRequest; //!< [bit/s] Raw requested channel bit-rate + double packetSizeBits; //!< [bit] Packet size for BER-to-PER conversion + uint64_t maxRetransmissions; //!< [-] Maximum ARQ transmission attempts per packet + int64_t receiverAntenna; //!< [-] 0=auto, 1=receiver antenna 1, 2=receiver antenna 2 + RemovalPolicy removalPolicy; //!< [-] Storage-removal mode for downlinked data + bool requireFullPacket; //!< [-] If true, wait for >=1 full packet before downlinking + + std::vector*> + storageUnitMsgPtrs; //!< Storage-msg pointer list for duplicate checks + std::vector> storageUnitInMsgs; //!< Storage status subscribers + std::vector storageUnitMsgsBuffer; //!< Local storage status buffers + + LinkBudgetMsgPayload linkBudgetBuffer; //!< Local copy of link-budget message + bool linkBudgetValid; //!< True when linkBudget message is linked and written + + DownlinkHandlingMsgPayload downlinkOutBuffer; //!< Local copy of downlink output message + + double previousTime; //!< [s] previous simulation time + double currentTimeStep; //!< [s] current integration step + double availableDataBits; //!< [bit] selected storage data available + + double cumulativeAttemptedBits; //!< [bit] attempted channel bits (includes retransmissions) + double cumulativeRemovedBits; //!< [bit] bits removed from storage + double cumulativeDeliveredBits; //!< [bit] successfully delivered bits + double cumulativeDroppedBits; //!< [bit] dropped bits after retransmission limit + + std::string lastAmbiguousRouteName; //!< [-] last dataName warned for ambiguous multi-storage routing +}; + +#endif diff --git a/src/simulation/communication/downlinkHandling/downlinkHandling.i b/src/simulation/communication/downlinkHandling/downlinkHandling.i new file mode 100644 index 00000000000..5f4cab0bfd5 --- /dev/null +++ b/src/simulation/communication/downlinkHandling/downlinkHandling.i @@ -0,0 +1,87 @@ +/* + ISC License + +Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado Boulder + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + */ + +%module downlinkHandling + +%include "architecture/utilities/bskException.swg" +%default_bsk_exception(); + +%{ + #include "downlinkHandling.h" +%} + +%include "swig_common_model.i" +%include "carrays.i" +%include "sys_model.i" + +%include "simulation/onboardDataHandling/_GeneralModuleFiles/dataNodeBase.h" +%include "downlinkHandling.h" + +%include "architecture/msgPayloadDefC/DataNodeUsageMsgPayload.h" +struct DataNodeUsageMsg_C; +%include "architecture/msgPayloadDefC/DeviceCmdMsgPayload.h" +struct DeviceCmdMsg_C; +%include "architecture/msgPayloadDefC/LinkBudgetMsgPayload.h" +struct LinkBudgetMsg_C; +%include "architecture/msgPayloadDefC/DownlinkHandlingMsgPayload.h" +struct DownlinkHandlingMsg_C; +%include "architecture/msgPayloadDefCpp/DataStorageStatusMsgPayload.h" + +%pythoncode %{ +def _set_bit_rate_request(self, value): + if not self.setBitRateRequest(value): + raise ValueError("bitRateRequest must be finite and >= 0 [bit/s].") + + +def _set_packet_size_bits(self, value): + if not self.setPacketSizeBits(value): + raise ValueError("packetSizeBits must be finite and > 0 [bit].") + + +def _set_max_retransmissions(self, value): + if not self.setMaxRetransmissions(value): + raise ValueError("maxRetransmissions must be >= 1.") + + +def _set_receiver_antenna(self, value): + if not self.setReceiverAntenna(value): + raise ValueError("receiverAntenna must be 0 (auto), 1, or 2.") + + +def _set_removal_policy(self, value): + if not self.setRemovalPolicy(value): + raise ValueError("removalPolicy must be 0 (REMOVE_ATTEMPTED) or 1 (REMOVE_DELIVERED_ONLY).") + + +def _set_require_full_packet(self, value): + self.setRequireFullPacket(bool(value)) + + +DownlinkHandling.bitRateRequest = property(DownlinkHandling.getBitRateRequest, _set_bit_rate_request) +DownlinkHandling.packetSizeBits = property(DownlinkHandling.getPacketSizeBits, _set_packet_size_bits) +DownlinkHandling.maxRetransmissions = property(DownlinkHandling.getMaxRetransmissions, _set_max_retransmissions) +DownlinkHandling.receiverAntenna = property(DownlinkHandling.getReceiverAntenna, _set_receiver_antenna) +DownlinkHandling.removalPolicy = property(DownlinkHandling.getRemovalPolicy, _set_removal_policy) +DownlinkHandling.requireFullPacket = property(DownlinkHandling.getRequireFullPacket, _set_require_full_packet) +%} + +%pythoncode %{ +import sys +protectAllClasses(sys.modules[__name__]) +%} diff --git a/src/simulation/communication/downlinkHandling/downlinkHandling.rst b/src/simulation/communication/downlinkHandling/downlinkHandling.rst new file mode 100644 index 00000000000..9089df9bc58 --- /dev/null +++ b/src/simulation/communication/downlinkHandling/downlinkHandling.rst @@ -0,0 +1,347 @@ +.. Warning:: + + :beta:`downlinkHandling` is a beta module in an initial public release. This module might be + subject to changes in future releases. + +Executive Summary +----------------- +This document describes how the downlinkHandling module maps radio link quality into realistic +data transfer outcomes. + +At each simulation step, the module: + +- reads link quality from :ref:`linkBudget` +- converts CNR into BER and packet error rate +- applies retry-limited ARQ reliability +- removes data from onboard storage at the resulting effective rate +- publishes detailed diagnostics for analysis and fault studies + +The module is designed to sit between RF-link modeling (:ref:`simpleAntenna`, :ref:`linkBudget`) +and onboard data-buffer dynamics (:ref:`DataStorageStatusMsgPayload`, :ref:`DataNodeUsageMsgPayload`). + +System Role and Data Flow +------------------------- + +.. _downlinkhandling-figure-flow: +.. figure:: /../../src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingFlow.svg + :width: 90% + :align: center + :alt: downlinkHandling integration flow with simpleAntenna, linkBudget, and storage + + Figure 1: Integration flow and message-level role of downlinkHandling. + +Message Connection Descriptions +------------------------------- +The following table lists all module input and output messages. +The message connection is set by the user from Python. + +.. list-table:: Module I/O Messages + :widths: 22 22 36 20 + :header-rows: 1 + + * - Msg Variable Name + - Msg Type + - Description + - Note + * - ``linkBudgetInMsg`` + - :ref:`LinkBudgetMsgPayload` + - Link-quality input from :ref:`linkBudget` (receiver states, ``CNR1``, ``CNR2``, overlap bandwidth). + - Required for non-zero downlink + * - ``storageUnitInMsgs`` (via ``addStorageUnitToDownlink``) + - :ref:`DataStorageStatusMsgPayload` + - Storage state input (partition names, partition bits, total storage level). + - Required for actual data removal + * - ``nodeDataOutMsg`` + - :ref:`DataNodeUsageMsgPayload` + - Data-node output inherited from ``DataNodeBase``. Negative baud rate removes bits from storage. + - Output + * - ``downlinkOutMsg`` + - :ref:`DownlinkHandlingMsgPayload` + - Diagnostics output with link metrics, reliability metrics, rates, and cumulative counters. + - Output + +Detailed Module Description +--------------------------- +The module extends ``DataNodeBase`` and runs once per simulation step. + +The per-step sequence is: + +1. Read ``LinkBudgetMsgPayload`` and all connected storage status messages. +2. Select one storage target (largest finite partition across all connected storage units; use ``storageLevel`` only when a message has no partition vector). +3. Select receiver path (forced receiver index or auto mode). +4. Convert selected CNR and overlap bandwidth into :math:`C/N_0`. +5. Convert :math:`C/N_0` and requested bit rate into :math:`E_b/N_0`. +6. Compute BER and PER. +7. Apply retry-limited ARQ model to obtain success/drop probability and expected attempts. +8. Compute attempted, removed, delivered, and dropped rates. +9. Apply packet gating and storage saturation. +10. Write ``nodeDataOutMsg`` and ``downlinkOutMsg``. + +Configurable Parameters +^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: Module Parameters + :widths: 22 14 14 50 + :header-rows: 1 + + * - Parameter + - Default + - Unit + - Description + * - ``bitRateRequest`` + - 0.0 + - bit/s + - Requested raw channel bit rate :math:`R_b`. If :math:`R_b \le 0`, throughput is zero. + * - ``packetSizeBits`` + - 256.0 + - bit + - Packet length :math:`L` for BER-to-PER conversion. + * - ``maxRetransmissions`` + - 10 + - - + - Retry cap used in the ARQ model. Current implementation enforces :math:`N \ge 1` and treats :math:`N` as maximum transmission attempts. + * - ``receiverAntenna`` + - 0 + - - + - Receiver selection: 0=auto, 1=use receiver path 1, 2=use receiver path 2. + * - ``removalPolicy`` + - ``REMOVE_ATTEMPTED`` (0) + - - + - Storage removal mode: ``REMOVE_ATTEMPTED`` removes delivered+drop-limited bits, ``REMOVE_DELIVERED_ONLY`` removes only successfully delivered bits. + * - ``requireFullPacket`` + - ``True`` + - bool + - If ``True``, downlink waits until selected storage has at least one full packet. + +Configuration Interface and Validation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The module exposes validated setters in C++/Python: + +- ``setBitRateRequest(bitRateRequest)`` with :math:`bitRateRequest \ge 0` +- ``setPacketSizeBits(packetSizeBits)`` with :math:`packetSizeBits > 0` +- ``setMaxRetransmissions(maxRetransmissions)`` with :math:`maxRetransmissions \ge 1` +- ``setReceiverAntenna(receiverAntenna)`` with ``receiverAntenna in {0,1,2}`` +- ``setRemovalPolicy(removalPolicy)`` with ``removalPolicy in {0,1}`` +- ``setRequireFullPacket(requireFullPacket)`` + +If a setter receives an invalid value, the module rejects it and keeps the last valid value. +The Python wrapper also maps ``bitRateRequest``, ``packetSizeBits``, ``maxRetransmissions``, +``receiverAntenna``, ``removalPolicy``, and ``requireFullPacket`` to these validated setter/getter paths. + +Receiver Selection and CNR1/CNR2 Usage +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The module uses receiver-specific fields from :ref:`LinkBudgetMsgPayload`: + +- ``CNR1`` corresponds to receiver path 1 +- ``CNR2`` corresponds to receiver path 2 + +These are not duplicates. They represent two possible receiving directions/modes in the bidirectional +link-budget result. + +Selection behavior: + +- ``receiverAntenna = 1``: use path 1 (only if antenna state 1 is RX or RXTX) +- ``receiverAntenna = 2``: use path 2 (only if antenna state 2 is RX or RXTX) +- ``receiverAntenna = 0``: auto-select valid RX path with higher CNR + +If no valid receiver path exists, the link is treated as inactive for that step. + +Reliability and Throughput Model +-------------------------------- + +.. _downlinkhandling-figure-model: +.. figure:: /../../src/simulation/communication/downlinkHandling/_Documentation/Images/DownlinkHandlingReliabilityChain.svg + :width: 95% + :align: center + :alt: CNR to BER to PER to ARQ to effective throughput chain + + Figure 2: Computation chain from RF link quality to storage removal and delivered data rates. + +For valid inputs (linked/written link budget, selected CNR :math:`>0`, overlap bandwidth :math:`>0`, +:math:`R_b>0`, packet size :math:`>0`): + +.. math:: + + \mathrm{CNR}_{dB} = 10\log_{10}(\mathrm{CNR}) + +.. math:: + + \frac{C}{N_0}\,[dBHz] = \mathrm{CNR}_{dB} + 10\log_{10}(B_{\mathrm{overlap}}) + +.. math:: + + \frac{E_b}{N_0}\,[dB] = \frac{C}{N_0}[dBHz] - 10\log_{10}(R_b) + +Current BER model (BPSK over AWGN): + +.. math:: + + \mathrm{BER} = Q\left(\sqrt{2E_b/N_0}\right) + = \frac{1}{2}\,\mathrm{erfc}\left(\sqrt{E_b/N_0}\right) + +Packet error model (independent bit errors, any bit error fails packet): + +.. math:: + + \mathrm{PER} = 1 - (1-\mathrm{BER})^L + +Let :math:`N=\max(1,\texttt{maxRetransmissions})`. Retry-limited ARQ model: + +.. math:: + + P_{\mathrm{drop}} = \mathrm{PER}^{N} + +.. math:: + + P_{\mathrm{success}} = 1 - P_{\mathrm{drop}} + +Expected attempts per source packet (truncated geometric expectation): + +.. math:: + + \mathbb{E}[A] = + \begin{cases} + \dfrac{P_{\mathrm{success}}}{1-\mathrm{PER}}, & \mathrm{PER}<1 \\ + N, & \mathrm{PER}=1 + \end{cases} + +Unscaled rates: + +.. math:: + + R_{\mathrm{attempt,pot}} = R_b + +.. math:: + + R_{\mathrm{remove,pot}} = \frac{R_{\mathrm{attempt,pot}}}{\mathbb{E}[A]} + +.. math:: + + R_{\mathrm{delivered,pot}} = R_{\mathrm{remove,pot}}\,P_{\mathrm{success}} + +.. math:: + + R_{\mathrm{dropped,pot}} = R_{\mathrm{remove,pot}} - R_{\mathrm{delivered,pot}} + +Storage and packet gating scale factor: + +.. math:: + + s = \mathrm{clamp}\!\left( + \frac{B_{\mathrm{available}}/\Delta t}{R_{\mathrm{remove,pot}}}, + 0, 1\right) + +with additional logic: + +- if ``requireFullPacket`` is ``True``, enforce :math:`B_{\mathrm{available}} \ge L` +- if :math:`\Delta t \le 0` or :math:`R_{\mathrm{remove,pot}} \le 0`, set :math:`s=0` + +Final rates: + +.. math:: + + R_{\mathrm{attempt}} = s\,R_{\mathrm{attempt,pot}}, \quad + R_{\mathrm{remove,modeled}} = s\,R_{\mathrm{remove,pot}}, \quad + R_{\mathrm{delivered}} = s\,R_{\mathrm{delivered,pot}}, \quad + R_{\mathrm{dropped}} = s\,R_{\mathrm{dropped,pot}} + +Actual storage removal follows ``removalPolicy``: + +.. math:: + + R_{\mathrm{remove}} = + \begin{cases} + R_{\mathrm{remove,modeled}}, & \text{REMOVE\_ATTEMPTED} \\ + R_{\mathrm{delivered}}, & \text{REMOVE\_DELIVERED\_ONLY} + \end{cases} + +The value written to storage through ``nodeDataOutMsg`` is then: + +.. math:: + + \texttt{nodeDataOutMsg.baudRate} = -R_{\mathrm{remove}} + +Output Diagnostics +------------------ +The custom output :ref:`DownlinkHandlingMsgPayload` reports: + +- link/selection state (``linkActive``, ``receiverIndex``, antenna names, ``removalPolicy``) +- physical-layer quality terms (CNR, :math:`C/N_0`, :math:`E_b/N_0`, BER, PER) +- ARQ reliability terms (success/drop probabilities, expected attempts) +- rate terms (attempted, removed, delivered, dropped) +- storage terms (available and estimated remaining bits) +- cumulative counters (attempted/removed/delivered/dropped bits) + +Integration with simpleAntenna and linkBudget +---------------------------------------------- +Typical chain: + +1. :ref:`simpleAntenna` modules compute antenna logs. +2. :ref:`linkBudget` computes overlap bandwidth and CNR per receiver path. +3. :ref:`downlinkHandling` converts link quality to effective data transfer and storage removal. +4. Storage modules consume ``nodeDataOutMsg`` and reduce onboard buffered data. + +.. warning:: + + Also important integration note: + Do not run ``spaceToGroundTransmitter`` and ``downlinkHandling`` as competing downlink removers + on the same storage partitions. Pick one downlink path. + +This separation is useful for fault modeling: upstream RF degradation (pointing, frequency mismatch, +atmospheric attenuation, receive-state changes) naturally propagates into BER/PER and delivered data. + +Assumptions and Current Limits +------------------------------ + +- BER model is analytic BPSK/AWGN. +- Bit errors are independent. +- Any bit error fails the packet. +- ARQ is expectation-based, not packet-by-packet Monte Carlo. +- No explicit ACK latency, coding gain, framing overhead, or adaptive coding/modulation. +- ``REMOVE_DELIVERED_ONLY`` preserves dropped/undelivered bits onboard, but the module still uses an expected-rate ARQ model instead of explicit packet ACK/NACK timelines. +- Storage target selection prioritizes per-partition values. ``storageLevel`` is only used as fallback for messages that do not provide ``storedData`` entries. +- ``nodeDataOutMsg`` identifies storage by ``dataName`` only. If multiple linked storage units reuse the selected partition name, downlinkHandling forces removal to zero for that step to avoid ambiguous multi-unit draining. For multi-storage use, keep partition names globally unique across linked storage units. + +Unit Test Coverage +------------------ +Test file: + +``src/simulation/communication/downlinkHandling/_UnitTest/test_downlinkHandling.py`` + +The tests verify: + +- equation parity versus a Python-equivalent BER/PER/ARQ model +- zero-flow behavior for invalid link inputs +- retry-cap effects on drop probability and removal/delivery behavior +- removal-policy behavior (``REMOVE_ATTEMPTED`` vs ``REMOVE_DELIVERED_ONLY``) +- storage-limited rate capping and drain behavior +- automatic receiver selection from antenna RX states and CNR values +- duplicate-storage input rejection +- storage-target selection across multiple storage status messages +- ambiguous duplicate partition-name behavior across multiple storage status messages + +User Guide +---------- + +Basic setup example: + +.. code-block:: python + + from Basilisk.simulation import downlinkHandling, simpleStorageUnit + + dlh = downlinkHandling.DownlinkHandling() + dlh.setBitRateRequest(1.0e5) # [bit/s] + dlh.setPacketSizeBits(1024.0) # [bit] + dlh.setMaxRetransmissions(8) # [-] + dlh.setReceiverAntenna(0) # [-] auto-select valid RX path with highest CNR + dlh.setRemovalPolicy(0) # [-] 0=REMOVE_ATTEMPTED, 1=REMOVE_DELIVERED_ONLY + dlh.setRequireFullPacket(True) # [-] + + storage = simpleStorageUnit.SimpleStorageUnit() + storage.storageCapacity = int(8e9) + storage.addDataNodeToModel(dlh.nodeDataOutMsg) + dlh.addStorageUnitToDownlink(storage.storageUnitDataOutMsg) + + # linkBudgetOutPayload is produced by the linkBudget module: + # dlh.linkBudgetInMsg.subscribeTo(linkBudgetModule.linkBudgetOutPayload)