From 460258839dd3268eb4c37600c2497dec55ebe6d9 Mon Sep 17 00:00:00 2001 From: Daniel Huterer Prats Date: Mon, 9 Nov 2026 08:23:28 -0700 Subject: [PATCH 1/4] [#1671] Forward the ground surface normal through simpleAntenna --- .../communication/simpleAntenna/simpleAntenna.cpp | 1 + .../communication/simpleAntenna/simpleAntenna.rst | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/simulation/communication/simpleAntenna/simpleAntenna.cpp b/src/simulation/communication/simpleAntenna/simpleAntenna.cpp index f1f44ef42de..0374b4fd5f7 100644 --- a/src/simulation/communication/simpleAntenna/simpleAntenna.cpp +++ b/src/simulation/communication/simpleAntenna/simpleAntenna.cpp @@ -518,6 +518,7 @@ void SimpleAntenna::populateOutputMsg(AntennaLogMsgPayload* output) output->r_AP_N[0] = this->r_AP_N[0]; output->r_AP_N[1] = this->r_AP_N[1]; output->r_AP_N[2] = this->r_AP_N[2]; + eigenVector3d2CArray(cArray2EigenVector3d(this->groundState.rHat_LP_N), output->nHat_LP_N); } else { // Raise error (should not happen due to checks in Reset) bskLogger.bskError("SimpleAntenna: Unable to populate output message due to unknown environment."); diff --git a/src/simulation/communication/simpleAntenna/simpleAntenna.rst b/src/simulation/communication/simpleAntenna/simpleAntenna.rst index 8b675a2086f..1a27056dfae 100644 --- a/src/simulation/communication/simpleAntenna/simpleAntenna.rst +++ b/src/simulation/communication/simpleAntenna/simpleAntenna.rst @@ -483,6 +483,13 @@ The spacecraft state message is of type :ref:`SCStatesMsgPayload`. The ground state message is of type :ref:`GroundStateMsgPayload`. +Schedule the ground-state producer before the antenna. Each antenna update +copies the ground position and the inertial surface normal +``GroundStateMsgPayload.rHat_LP_N`` to its output. The surface normal is published +as ``AntennaLogMsgPayload.nHat_LP_N`` for the atmospheric slant-path calculation +in :ref:`linkBudget`. It describes the local surface normal, independently of +the antenna boresight. + Setting Required Antenna Parameters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From c7dfed96e2125f61ba1559dd2a2e2448fe935c2e Mon Sep 17 00:00:00 2001 From: Daniel Huterer Prats Date: Mon, 9 Nov 2026 08:23:30 -0700 Subject: [PATCH 2/4] [#1671] Initialize linkBudget from published producer outputs --- .../linkBudget/_UnitTest/CMakeLists.txt | 23 ++ .../_UnitTest/test_antennaProducerChain.cpp | 326 ++++++++++++++++++ .../communication/linkBudget/linkBudget.cpp | 70 +++- .../communication/linkBudget/linkBudget.h | 5 + .../communication/linkBudget/linkBudget.rst | 32 +- 5 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerChain.cpp diff --git a/src/simulation/communication/linkBudget/_UnitTest/CMakeLists.txt b/src/simulation/communication/linkBudget/_UnitTest/CMakeLists.txt index fceb307c334..36ea74b3770 100644 --- a/src/simulation/communication/linkBudget/_UnitTest/CMakeLists.txt +++ b/src/simulation/communication/linkBudget/_UnitTest/CMakeLists.txt @@ -19,3 +19,26 @@ if(CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL "arm64" AND CMAKE_GENERATOR STREQUAL "Xc else() gtest_discover_tests(test_linkBudgetAtmosphere) endif() + +add_executable(test_antennaProducerChain + test_antennaProducerChain.cpp + ../linkBudget.cpp + ../../simpleAntenna/simpleAntenna.cpp + ../../../environment/groundLocation/groundLocation.cpp) +target_link_libraries(test_antennaProducerChain PRIVATE GTest::gtest_main architectureLib ArchitectureUtilities) + +if(WIN32) + add_custom_command( + TARGET test_antennaProducerChain + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS) +endif() + +if(CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL "arm64" AND CMAKE_GENERATOR STREQUAL "Xcode") + gtest_discover_tests(test_antennaProducerChain DISCOVERY_MODE PRE_TEST) +else() + gtest_discover_tests(test_antennaProducerChain) +endif() diff --git a/src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerChain.cpp b/src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerChain.cpp new file mode 100644 index 00000000000..6fd16dd27f7 --- /dev/null +++ b/src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerChain.cpp @@ -0,0 +1,326 @@ +/* + ISC License + + Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at 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/linkBudget/linkBudget.h" +#include "simulation/communication/simpleAntenna/simpleAntenna.h" +#include "simulation/environment/groundLocation/groundLocation.h" +#include +#include + +#include +#include +#include + +namespace { +const double earthRadius = REQ_EARTH * 1.0e3; // [m] +const double frequency = 20.0e9; // [Hz] +const double bandwidth = 8.0e6; // [Hz] +const double directivity = 30.0; // [dBi] +const double efficiency = 0.65; // [-] +const double transmitPower = 5.0; // [W] +const double receiverTemperature = 120.0; // [K] +const double ambientTemperature = 150.0; // [K] +const double distance = 2000.0e3; // [m] + +/*! @brief Exercise native producers in ground, antenna, then link execution order. */ +class AntennaProducerChain : public ::testing::TestWithParam> +{ + protected: + GroundLocation ground; + SimpleAntenna transmitter; + SimpleAntenna receiver; + LinkBudget budget; + Message spacecraftState; + Message planetState; + Eigen::Vector3d expectedNormal_N; + Eigen::Vector3d expectedGroundPosition_N; + double pointingOffset = 0.0; // [rad] + + /*! @brief Configure a tilted planet and an independently constructed antenna boresight. */ + void SetUp() override + { + const auto [altitude, groundFirst, offset] = GetParam(); // [m, flag, rad] + pointingOffset = offset; + ground.planetRadius = earthRadius; + ground.addSpacecraftToModel(&spacecraftState); + ground.planetInMsg.subscribeTo(&planetState); + transmitter.scStateInMsg.subscribeTo(&spacecraftState); + receiver.groundStateInMsg.subscribeTo(&ground.currentGroundStateOutMsg); + configureAntenna(transmitter, "space"); + configureAntenna(receiver, "ground"); + transmitter.setAntennaState(AntennaTypes::ANTENNA_TX); + receiver.setAntennaState(AntennaTypes::ANTENNA_RX); + budget.antennaInPayload_1.subscribeTo(groundFirst ? &receiver.antennaOutMsg : &transmitter.antennaOutMsg); + budget.antennaInPayload_2.subscribeTo(groundFirst ? &transmitter.antennaOutMsg : &receiver.antennaOutMsg); + budget.atmosAtt = true; + setGeometry(altitude); + } + + /*! @brief Set identical RF properties without sky-map or ephemeris-file dependencies. + * @param antenna Antenna producer to configure. + * @param name Antenna name. + */ + static void configureAntenna(SimpleAntenna& antenna, const char* name) + { + antenna.setAntennaName(name); + antenna.setAntennaFrequency(frequency); + antenna.setAntennaBandwidth(bandwidth); + antenna.setAntennaDirectivity_dB(directivity); + antenna.setAntennaHpbwRatio(1.0); // [-] + antenna.setAntennaP_Tx(transmitPower); + antenna.setAntennaP_Rx(5.0); // [W] + antenna.setAntennaEquivalentNoiseTemp(receiverTemperature); + antenna.setAntennaRadEfficiency(efficiency); + antenna.setAntennaEnvironmentTemperature(ambientTemperature); + } + + /*! @brief Change the ground altitude and publish only the external source states. + * @param altitude Ground station altitude in meters. + */ + void setGeometry(double altitude) + { + const double latitude = 0.35; // [rad] + const double longitude = -0.6; // [rad] + const double tilt = 0.4; // [rad] + const double elevation = 0.7; // [rad] + ground.specifyLocation(latitude, longitude, altitude); + const Eigen::Matrix3d dcm_PN = Eigen::AngleAxisd(tilt, Eigen::Vector3d::UnitY()).toRotationMatrix(); + SpicePlanetStateMsgPayload planet = {}; + eigenMatrix3d2CArray(dcm_PN, *planet.J20002Pfix); + planetState.write(&planet, 0, 0); + const Eigen::Vector3d normal_P(std::cos(latitude) * std::cos(longitude), + std::cos(latitude) * std::sin(longitude), + std::sin(latitude)); // [-] + expectedNormal_N = dcm_PN.transpose() * normal_P; + expectedGroundPosition_N = (earthRadius + altitude) * expectedNormal_N; + const Eigen::Vector3d east_P(-std::sin(longitude), std::cos(longitude), 0.0); // [-] + const Eigen::Vector3d ray_N = + std::sin(elevation) * expectedNormal_N + std::cos(elevation) * dcm_PN.transpose() * east_P; // [-] + const Eigen::Matrix3d aligned_NB = + Eigen::Quaterniond::FromTwoVectors(Eigen::Vector3d::UnitZ(), -ray_N).toRotationMatrix(); + const Eigen::Matrix3d dcm_NB = + aligned_NB * Eigen::AngleAxisd(pointingOffset, Eigen::Vector3d::UnitY()).toRotationMatrix(); + const Eigen::MRPd sigma_BN = eigenC2MRP(dcm_NB.transpose()); + SCStatesMsgPayload spacecraft = {}; + eigenVector3d2CArray(expectedGroundPosition_N + distance * ray_N, spacecraft.r_BN_N); + eigenMRPd2CArray(sigma_BN, spacecraft.sigma_BN); + spacecraftState.write(&spacecraft, 0, 0); + } + + /*! @brief Reset in producer order while the ground output is initially unwritten. */ + void resetChain() + { + ground.Reset(0); + transmitter.Reset(0); + receiver.Reset(0); + budget.Reset(0); + } + + /*! @brief Execute one producer-before-consumer tick without a warmup or extra reset. */ + void updateChain() + { + ground.UpdateState(0); + transmitter.UpdateState(0); + receiver.UpdateState(0); + budget.UpdateState(0); + } + + /*! @brief Compare physical outputs with independent geometry and a written-payload link reference. */ + void checkLink() + { + const auto [altitude, groundFirst, offset] = GetParam(); // [m, flag, rad] + auto groundOutput = ground.currentGroundStateOutMsg.addSubscriber()(); + auto receiveOutput = receiver.antennaOutMsg.addSubscriber()(); + auto transmitOutput = transmitter.antennaOutMsg.addSubscriber()(); + EXPECT_NEAR(cArray2EigenVector3d(groundOutput.rHat_LP_N).dot(expectedNormal_N), 1.0, 1.0e-14); + EXPECT_NEAR((cArray2EigenVector3d(receiveOutput.nHat_LP_N) - expectedNormal_N).norm(), 0.0, 1.0e-14); + EXPECT_NEAR((cArray2EigenVector3d(receiveOutput.r_AP_N) - expectedGroundPosition_N).norm(), 0.0, 1.0e-8); // [m] + EXPECT_EQ(receiveOutput.environment, static_cast(AntennaTypes::ENVIRONMENT_EARTH)); + EXPECT_EQ(transmitOutput.environment, static_cast(AntennaTypes::ENVIRONMENT_SPACE)); + EXPECT_TRUE(budget.atmosAtt); + + // Supply the reference normal and altitude independently of antenna output fields. + auto referenceGround = receiveOutput; + eigenVector3d2CArray(expectedNormal_N, referenceGround.nHat_LP_N); + eigenVector3d2CArray(expectedGroundPosition_N, referenceGround.r_AP_N); + Message groundMessage; + Message spaceMessage; + groundMessage.write(&referenceGround, 0, 0); + spaceMessage.write(&transmitOutput, 0, 0); + LinkBudget reference; + reference.atmosAtt = true; + reference.antennaInPayload_1.subscribeTo(groundFirst ? &groundMessage : &spaceMessage); + reference.antennaInPayload_2.subscribeTo(groundFirst ? &spaceMessage : &groundMessage); + reference.Reset(0); + reference.UpdateState(0); + ASSERT_GT(reference.getL_atm(), 0.0); // [dB] + EXPECT_NEAR(budget.getL_atm(), reference.getL_atm(), 1.0e-12); // [dB] + + const double beamwidth = std::sqrt(16.0 * std::log(2.0) / std::pow(10.0, directivity / 10.0)); // [rad] + const double pointingLoss = 40.0 * std::log(2.0) / std::log(10.0) * std::pow(offset / beamwidth, 2); // [dB] + const double fspl = 20.0 * std::log10(4.0 * MPI * distance * frequency / SPEED_LIGHT); // [dB] + const double noise = K_BOLTZMANN * + (receiverTemperature + (1.0 - efficiency) * ambientTemperature + efficiency * 200.0) * + bandwidth; // [W] Native ground sky is 200 K. + const double eirp = 10.0 * std::log10(transmitPower * efficiency) + directivity; // [dBW] + const double received = + eirp + directivity + 10.0 * std::log10(efficiency) - fspl - pointingLoss - reference.getL_atm(); // [dBW] + const double cnr = std::pow(10.0, received / 10.0) / noise; // [-] + EXPECT_NEAR(budget.getL_FSPL(), fspl, 1.0e-10); // [dB] + EXPECT_NEAR(budget.getL_point(), pointingLoss, 1.0e-10); // [dB] + const auto output = budget.linkBudgetOutPayload.addSubscriber()(); + EXPECT_NEAR(groundFirst ? output.CNR1 : output.CNR2, cnr, cnr * 1.0e-10); + EXPECT_EQ(groundFirst ? output.CNR2 : output.CNR1, 0.0); + } +}; + +/*! @brief A real producer chain must work on its first tick, including elevated stations and tilted normals. */ +TEST_P(AntennaProducerChain, InitializesFromFirstProducerTick) +{ + resetChain(); + updateChain(); + checkLink(); +} + +/*! @brief Missing antenna publications must produce zero service until both producers have run. */ +TEST_P(AntennaProducerChain, WaitsForBothAntennaMessages) +{ + resetChain(); + EXPECT_NO_THROW(budget.UpdateState(0)); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().CNR1, 0.0); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().CNR2, 0.0); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().bandwidth, 0.0); // [Hz] + ground.UpdateState(0); + transmitter.UpdateState(0); + EXPECT_NO_THROW(budget.UpdateState(0)); + EXPECT_TRUE(budget.atmosAtt); + EXPECT_EQ(budget.getCNR1(), 0.0); + EXPECT_EQ(budget.getCNR2(), 0.0); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().bandwidth, 0.0); // [Hz] + receiver.UpdateState(0); + budget.UpdateState(0); + checkLink(); +} + +/*! @brief An antenna that runs before the ground model must not cache sea-level atmosphere. */ +TEST_P(AntennaProducerChain, WaitsForGroundPositionWhenAntennaRunsFirst) +{ + resetChain(); + transmitter.UpdateState(0); + receiver.UpdateState(0); + ground.UpdateState(0); + budget.UpdateState(0); + EXPECT_TRUE(budget.atmosAtt); + EXPECT_EQ(budget.getL_atm(), 0.0); // [dB] + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().CNR1, 0.0); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().CNR2, 0.0); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().bandwidth, 0.0); // [Hz] + transmitter.UpdateState(0); + receiver.UpdateState(0); + ground.UpdateState(0); + budget.UpdateState(0); + checkLink(); +} + +/*! @brief Reset must discard old results and rebuild atmosphere after the station altitude changes. */ +TEST_P(AntennaProducerChain, RebuildsAfterStationAltitudeChanges) +{ + resetChain(); + updateChain(); + checkLink(); + const double firstLoss = budget.getL_atm(); // [dB] + const auto [altitude, groundFirst, offset] = GetParam(); // [m, flag, rad] + const double nextAltitude = altitude + 2000.0; // [m] + setGeometry(nextAltitude); + resetChain(); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().CNR1, 0.0); + EXPECT_EQ(budget.linkBudgetOutPayload.addSubscriber()().CNR2, 0.0); + EXPECT_EQ(budget.getL_atm(), 0.0); // [dB] + updateChain(); + checkLink(); + EXPECT_LT(budget.getL_atm(), firstLoss); +} + +INSTANTIATE_TEST_SUITE_P(GroundGeometry, + AntennaProducerChain, + ::testing::Combine(::testing::Values(0.0, 1624.0), // [m] + ::testing::Bool(), + ::testing::Values(0.0, 0.02))); // [rad] + +/*! @brief Written space-to-space inputs must still disable atmosphere and produce a valid link. */ +TEST(LinkBudgetProducerInputs, InitializesSpaceToSpaceLink) +{ + AntennaLogMsgPayload first = {}; + first.environment = static_cast(AntennaTypes::ENVIRONMENT_SPACE); + first.antennaState = static_cast(AntennaTypes::ANTENNA_RXTX); + first.frequency = frequency; + first.B = bandwidth; + first.eta_r = efficiency; + first.P_N = 1.0e-14; // [W] + first.P_eirp_dB = 20.0; // [dBW] + first.DdB = directivity; + first.r_AN_N[0] = earthRadius + 800.0e3; // [m] + auto second = first; + second.r_AN_N[0] += distance; + Message firstMessage; + Message secondMessage; + firstMessage.write(&first, 0, 0); + secondMessage.write(&second, 0, 0); + LinkBudget model; + model.atmosAtt = true; + model.pointingLoss = false; + model.antennaInPayload_1.subscribeTo(&firstMessage); + model.antennaInPayload_2.subscribeTo(&secondMessage); + model.Reset(0); + model.UpdateState(0); + EXPECT_FALSE(model.atmosAtt); + EXPECT_EQ(model.getL_atm(), 0.0); // [dB] + EXPECT_GT(model.getCNR1(), 0.0); + EXPECT_GT(model.getCNR2(), 0.0); +} + +/*! @brief Links without atmosphere do not cache altitude, so they must not wait for a ground position. */ +TEST(LinkBudgetProducerInputs, GroundPositionOnlyGatesAtmosphere) +{ + AntennaLogMsgPayload space = {}; + space.environment = static_cast(AntennaTypes::ENVIRONMENT_SPACE); + space.antennaState = static_cast(AntennaTypes::ANTENNA_RXTX); + space.frequency = frequency; + space.B = bandwidth; + space.eta_r = efficiency; + space.P_N = 1.0e-14; // [W] + space.P_eirp_dB = 20.0; // [dBW] + space.DdB = directivity; + space.r_AN_N[0] = earthRadius + distance; // [m] + auto groundAntenna = space; + groundAntenna.environment = static_cast(AntennaTypes::ENVIRONMENT_EARTH); + groundAntenna.r_AN_N[0] = earthRadius; // [m] + Message spaceMessage; + Message groundMessage; + spaceMessage.write(&space, 0, 0); + groundMessage.write(&groundAntenna, 0, 0); + LinkBudget model; + model.pointingLoss = false; + model.antennaInPayload_1.subscribeTo(&spaceMessage); + model.antennaInPayload_2.subscribeTo(&groundMessage); + model.Reset(0); + model.UpdateState(0); + EXPECT_GT(model.getCNR1(), 0.0); + EXPECT_GT(model.getCNR2(), 0.0); +} +} // namespace diff --git a/src/simulation/communication/linkBudget/linkBudget.cpp b/src/simulation/communication/linkBudget/linkBudget.cpp index f5ea8b69628..e8f0758d904 100644 --- a/src/simulation/communication/linkBudget/linkBudget.cpp +++ b/src/simulation/communication/linkBudget/linkBudget.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -72,9 +73,10 @@ LinkBudget::LinkBudget() // correct states, etc.) } -/*! This method is used to reset the module and checks that required input messages are connect. +/*! @brief Check input connections and clear link results before deferred initialization. + * @param CurrentSimNanos Current simulation time in nanoseconds. */ -void LinkBudget::Reset(uint64_t CurrentSimNanos [[maybe_unused]]) +void LinkBudget::Reset(uint64_t CurrentSimNanos) { // check that required input messages are connected if (!this->antennaInPayload_1.isLinked()) { @@ -83,7 +85,26 @@ void LinkBudget::Reset(uint64_t CurrentSimNanos [[maybe_unused]]) if (!this->antennaInPayload_2.isLinked()) { bskLogger.bskError("LinkBudget.antennaInPayload_2 was not linked."); } - this->initialization(); + // Upstream models need not publish during Reset. Even written inputs can + // still contain the previous run's geometry until the producers update. + this->initialized = false; + this->waitingWarningIssued = false; + this->gndAntPnt = nullptr; + this->scAntPnt = nullptr; + this->linkValid = false; + this->L_FSPL = 0.0; // [dB] + this->L_atm = 0.0; // [dB] + this->L_freq = 0.0; // [dB] + this->L_point = 0.0; // [dB] + this->P_Rx1 = 0.0; // [W] + this->P_Rx2 = 0.0; // [W] + this->CNR1 = 0.0; // [-] + this->CNR2 = 0.0; // [-] + this->distance = 0.0; // [m] + this->B_overlap = 0.0; // [Hz] + this->centerfreq = 0.0; // [Hz] + this->linkBudgetOutPayloadBuffer = this->linkBudgetOutPayload.zeroMsgPayload; + this->linkBudgetOutPayload.write(&this->linkBudgetOutPayloadBuffer, this->moduleID, CurrentSimNanos); } void LinkBudget::initialization() @@ -156,12 +177,32 @@ void LinkBudget::initialization() } } -/*! This is the main method that gets called every time the module is updated. Provide an appropriate description. +/*! @brief Initialize from written antenna inputs, then calculate and publish the current link budget. + * @param CurrentSimNanos Current simulation time in nanoseconds. */ void LinkBudget::UpdateState(uint64_t CurrentSimNanos) { - // read in the input messages - this->readMessages(); + const bool inputsWritten = this->antennaInPayload_1.isWritten() && this->antennaInPayload_2.isWritten(); + if (inputsWritten) { + // read in the input messages + this->readMessages(); + } + if (!inputsWritten || (!this->initialized && !this->atmosphereInputsReady())) { + if (!this->waitingWarningIssued) { + bskLogger.bskLog(BSK_WARNING, + "LinkBudget: Waiting for written antenna inputs and a ground position. Check the order " + "of the ground, antenna, and link-budget models if this link never starts."); + this->waitingWarningIssued = true; + } + this->linkBudgetOutPayloadBuffer = this->linkBudgetOutPayload.zeroMsgPayload; + this->linkBudgetOutPayload.write(&this->linkBudgetOutPayloadBuffer, this->moduleID, CurrentSimNanos); + return; + } + + if (!this->initialized) { + this->initialization(); + this->initialized = true; + } // FUTURE IMPROVEMENT: calculate doppler shift here @@ -171,6 +212,23 @@ void LinkBudget::UpdateState(uint64_t CurrentSimNanos) this->writeOutputMessages(CurrentSimNanos); } +/*! @brief Check that every ground antenna has published the position used to cache atmospheric conditions. + * @return True when atmosphere is disabled or all ground antennas have a nonzero position. + */ +bool LinkBudget::atmosphereInputsReady() const +{ + if (!this->atmosAtt) { + return true; + } + for (const AntennaLogMsgPayload* antenna : { &this->antennaIn_1, &this->antennaIn_2 }) { + if (antenna->environment == static_cast(AntennaTypes::EnvironmentType::ENVIRONMENT_EARTH) && + antenna->r_AP_N[0] == 0.0 && antenna->r_AP_N[1] == 0.0 && antenna->r_AP_N[2] == 0.0) { + return false; + } + } + return true; +} + void LinkBudget::readMessages() { // read in the input messages diff --git a/src/simulation/communication/linkBudget/linkBudget.h b/src/simulation/communication/linkBudget/linkBudget.h index 83d8af4d6b9..854dce397e2 100644 --- a/src/simulation/communication/linkBudget/linkBudget.h +++ b/src/simulation/communication/linkBudget/linkBudget.h @@ -28,8 +28,10 @@ #include "architecture/msgPayloadDefC/LinkBudgetMsgPayload.h" #include "architecture/msgPayloadDefC/SpicePlanetStateMsgPayload.h" #include "architecture/utilities/astroConstants.h" +#include "architecture/utilities/avsEigenMRP.h" #include "architecture/utilities/avsEigenSupport.h" #include "architecture/utilities/bskLogging.h" +#include #include #include #include @@ -153,6 +155,8 @@ class LinkBudget : public SysModel std::string getWaterVaporLookupFilePath() const { return this->waterVaporLookupFilePath; } private: + bool initialized = false; //!< Whether link placement and atmospheric tables are initialized. + bool waitingWarningIssued = false; //!< Whether the wait for producer data has been reported since the last reset. LinkBudgetTypes::AntennaPlacement antennaPlacement; //!< [-] Antenna placement type AntennaTypes::EnvironmentType env1; //!< [-] Antenna environment of antenna 1: space, 2: ground AntennaTypes::EnvironmentType env2; //!< [-] Antenna environment of antenna 2: space, 2: ground @@ -190,6 +194,7 @@ class LinkBudget : public SysModel private: void readMessages(); + bool atmosphereInputsReady() const; void initialization(); void calculateLinkBudget(); void calculateFSPL(); diff --git a/src/simulation/communication/linkBudget/linkBudget.rst b/src/simulation/communication/linkBudget/linkBudget.rst index 7d35b5d6d93..dbe3698c035 100644 --- a/src/simulation/communication/linkBudget/linkBudget.rst +++ b/src/simulation/communication/linkBudget/linkBudget.rst @@ -11,6 +11,26 @@ The module computes the end-to-end link budget accounting for free space path lo This module requires two connected antenna output messages (e.g. from :ref:`simpleAntenna` modules). The link budget calculation automatically determines the link configuration (space-to-space or space-to-ground) based on the antenna environment types. +Initialization and Execution Order +---------------------------------- + +Schedule the spacecraft and ground-state producers before the antenna modules, +and schedule both antenna modules before ``linkBudget``. ``Reset`` checks that +both antenna inputs are connected and clears the previous link results. Link +placement and atmospheric lookup tables are initialized on the first update +after both antenna inputs have been written. When atmospheric attenuation is +enabled, initialization also waits for a nonzero ground antenna position. Until +then, the output contains zero carrier-to-noise ratios and no usable link +bandwidth, and the module logs one warning per reset. + +This order allows :ref:`groundLocation` to publish its position before the +atmosphere is initialized, including the altitude of an elevated station. No +manual warmup or second reset is required. Each subsequent reset also defers +initialization until execution resumes, so a changed station altitude is read +after the ground and antenna producers have updated their outputs. A later +reset cannot distinguish a previous run's written antenna position from a new +one, so the required model order still applies after the first run. + Message Connection Descriptions ------------------------------- The following table lists all the module input and output messages. @@ -125,7 +145,11 @@ error near gas absorption lines. Each cell's slant elevation is calculated from the ground ray invariant before its attenuation is accumulated. .. note:: - Atmospheric attenuation lookup tables are precomputed at initialization, using the ground antenna position and frequency and re-used during simulation. Changes in frequency during simulation will not update the atmospheric attenuation profile. + Atmospheric attenuation lookup tables are precomputed on the first update + with written antenna inputs, using the ground antenna position and frequency, + and reused during simulation. Changes in frequency during simulation will + not update the atmospheric attenuation profile. Reset the module and update + its producers before the next link-budget update to rebuild the profile. For more information the reader is referred to the following ITU-R recommendations: @@ -473,7 +497,11 @@ Atmospheric attenuation is disabled by default. To enable it for space-to-ground .. warning:: - The atmospheric attenuation lookup table is precomputed during module initialization using the ground antenna's initial position and frequency. Changes to the antenna frequency during simulation will **not** update the atmospheric attenuation profile. If frequency changes are required, the simulation must be re-initialized. + The atmospheric attenuation lookup table uses the ground position and + frequency available on the first update with written antenna inputs. Changes + to altitude or frequency during execution do not rebuild this table. After + changing these quantities, reset the module and run the producers before its + next update. Accessing Link Budget Results ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 5b0fdabc2e06874ee731296f845146e3d1a74f1c Mon Sep 17 00:00:00 2001 From: Daniel Huterer Prats Date: Mon, 9 Nov 2026 08:23:32 -0700 Subject: [PATCH 3/4] [#1671] Add antenna initialization release and known-issue notes --- .../1671-antenna-producer-initialization.rst | 15 +++++++++++++ .../1671-antenna-producer-initialization.rst | 1 + .../communication/linkBudget/linkBudget.rst | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 docs/source/Support/bskKnownIssuesSnippets/1671-antenna-producer-initialization.rst create mode 100644 docs/source/Support/bskReleaseNotesSnippets/1671-antenna-producer-initialization.rst diff --git a/docs/source/Support/bskKnownIssuesSnippets/1671-antenna-producer-initialization.rst b/docs/source/Support/bskKnownIssuesSnippets/1671-antenna-producer-initialization.rst new file mode 100644 index 00000000000..0cd22ae0888 --- /dev/null +++ b/docs/source/Support/bskKnownIssuesSnippets/1671-antenna-producer-initialization.rst @@ -0,0 +1,15 @@ +- GitHub issue 1671: In Basilisk 2.10.0 through 2.12.2, :ref:`linkBudget` could classify unwritten + antenna messages as a space-to-space link during reset, disable atmospheric + attenuation, and apply an erroneous ground-antenna pointing loss. Separately, + :ref:`simpleAntenna` did not forward the ground surface normal needed for + atmospheric slant paths, so attenuation did not vary with elevation. + Publishing antenna outputs during reset alone could also cache sea-level + conditions before an elevated ground station published its position. The + modules now forward the surface normal and initialize the link on the first + update with written antenna inputs and, when atmosphere is enabled, a nonzero + ground position. Schedule ground and spacecraft producers before antennas, + and antennas before the link budget. For older versions, explicitly populate + the normal and initialize the link only after all upstream producers have + published their states. See :ref:`linkbudget-initialization` for compatibility + changes to output publication during reset and the timing of initialization + errors. diff --git a/docs/source/Support/bskReleaseNotesSnippets/1671-antenna-producer-initialization.rst b/docs/source/Support/bskReleaseNotesSnippets/1671-antenna-producer-initialization.rst new file mode 100644 index 00000000000..f4e5704eeeb --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/1671-antenna-producer-initialization.rst @@ -0,0 +1 @@ +- GitHub issue 1671: Fixed :ref:`linkBudget` initialization with antenna producers by waiting for written inputs and a ground position during execution, rebuilding atmospheric tables after reset using the current ground altitude, and forwarding the ground surface normal through :ref:`simpleAntenna`. See :ref:`linkbudget-initialization` for compatibility changes to output publication during reset and the timing of initialization errors. diff --git a/src/simulation/communication/linkBudget/linkBudget.rst b/src/simulation/communication/linkBudget/linkBudget.rst index dbe3698c035..75a67425108 100644 --- a/src/simulation/communication/linkBudget/linkBudget.rst +++ b/src/simulation/communication/linkBudget/linkBudget.rst @@ -11,6 +11,8 @@ The module computes the end-to-end link budget accounting for free space path lo This module requires two connected antenna output messages (e.g. from :ref:`simpleAntenna` modules). The link budget calculation automatically determines the link configuration (space-to-space or space-to-ground) based on the antenna environment types. +.. _linkbudget-initialization: + Initialization and Execution Order ---------------------------------- @@ -31,6 +33,26 @@ after the ground and antenna producers have updated their outputs. A later reset cannot distinguish a previous run's written antenna position from a new one, so the required model order still applies after the first run. +.. note:: + + Deferred initialization changes two aspects of the module lifecycle: + + - ``Reset`` publishes a zero-filled ``linkBudgetOutPayload`` at the reset + time. After ``InitializeSimulation()``, a subscriber's ``isWritten()`` + therefore returns ``True`` even before the first update. The reset output + has zero ``CNR1``, ``CNR2`` and ``bandwidth``. Consumers must check the + receiving antenna's CNR and the link bandwidth when determining whether + service is available. :ref:`downlinkHandling` treats reset and waiting + outputs as no service. + + - Antenna placement is validated on the first ``UpdateState`` call with + ready inputs. Errors such as an unsupported ground-to-ground link or an + unrecognized antenna environment are raised during that update. Atmospheric + lookup-table loading and its errors also occur at this point. Applications + that handle these errors must include ``ExecuteSimulation()`` or direct + ``UpdateState`` calls in their error-handling path. Unlinked antenna inputs + are still reported by ``Reset`` during ``InitializeSimulation()``. + Message Connection Descriptions ------------------------------- The following table lists all the module input and output messages. From 58f7ef57763d80a823069b95b7d756d1f34639ba Mon Sep 17 00:00:00 2001 From: Hanspeter Schaub Date: Mon, 9 Nov 2026 12:11:38 -0700 Subject: [PATCH 4/4] [#1671] Add scheduler-level antenna producer integration test --- .../test_antennaProducerScheduling.py | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerScheduling.py diff --git a/src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerScheduling.py b/src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerScheduling.py new file mode 100644 index 00000000000..06c36d37154 --- /dev/null +++ b/src/simulation/communication/linkBudget/_UnitTest/test_antennaProducerScheduling.py @@ -0,0 +1,310 @@ +# ISC License +# +# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at 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. + +"""Exercise antenna initialization through the simulation scheduler. + +Real spacecraft, ground, antenna, link-budget, downlink and storage modules run +on ordered tasks. Delaying either the ground or antenna task exercises the +waiting state over multiple timestamps. Reinitializing the same simulation +after an altitude change exercises previously written producer messages. +""" + +import numpy as np +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import ( + downlinkHandling, + groundLocation, + linkBudget, + simpleAntenna, + simpleStorageUnit, + spacecraft, +) +from Basilisk.utilities import RigidBodyKinematics as rbk +from Basilisk.utilities import SimulationBaseClass, macros, orbitalMotion + + +TASK_PERIOD = 1.0 # [s] +FREQUENCY = 20.0e9 # [Hz] +BANDWIDTH = 8.0e6 # [Hz] +INITIAL_ALTITUDE = 1624.0 # [m] +ALTITUDE_CHANGE = 2000.0 # [m] +LINK_DISTANCE = 2000.0e3 # [m] +INITIAL_DATA = 10000000 # [bit] + + +def configure_antenna(antenna, name, state): + """Configure an antenna without sky-map or ephemeris dependencies. + + :param antenna: Antenna module to configure. + :param name: Published antenna identifier. + :param state: Transmit or receive antenna state. + """ + antenna.ModelTag = name + antenna.setAntennaName(name) + antenna.setAntennaState(state) + antenna.setAntennaFrequency(FREQUENCY) + antenna.setAntennaBandwidth(BANDWIDTH) + antenna.setAntennaDirectivity_dB(30.0) # [dBi] + antenna.setAntennaHpbwRatio(1.0) # [-] + antenna.setAntennaP_Tx(5.0) # [W] + antenna.setAntennaP_Rx(5.0) # [W] + antenna.setAntennaEquivalentNoiseTemp(120.0) # [K] + antenna.setAntennaRadEfficiency(0.65) # [-] + antenna.setAntennaEnvironmentTemperature(150.0) # [K] + + +class ScheduledLink: + """Keep the scheduled producer chain and its recorders alive together.""" + + def __init__(self, ground_first): + """Connect the chain with explicit task and model priorities. + + :param ground_first: Whether the ground antenna uses link input one. + """ + self.sim = SimulationBaseClass.SimBaseClass() + process = self.sim.CreateNewProcess("communication") + for name, priority in ( + ("spacecraft", 40), ("ground", 30), ("antennas", 20), ("link", 10) + ): + task = self.sim.CreateNewTask(name, macros.sec2nano(TASK_PERIOD)) + process.addTask(task, priority) + + self.spacecraft = spacecraft.Spacecraft() + self.spacecraft.ModelTag = "spacecraft" + self.ground = groundLocation.GroundLocation() + self.ground.ModelTag = "ground" + self.ground.planetRadius = orbitalMotion.REQ_EARTH*1.0e3 # [m] + self.ground.addSpacecraftToModel(self.spacecraft.scStateOutMsg) + self.transmitter = simpleAntenna.SimpleAntenna() + self.receiver = simpleAntenna.SimpleAntenna() + configure_antenna(self.transmitter, "space", simpleAntenna.ANTENNA_TX) + configure_antenna(self.receiver, "ground", simpleAntenna.ANTENNA_RX) + self.transmitter.scStateInMsg.subscribeTo(self.spacecraft.scStateOutMsg) + self.receiver.groundStateInMsg.subscribeTo(self.ground.currentGroundStateOutMsg) + + self.budget = linkBudget.LinkBudget() + self.budget.ModelTag = "linkBudget" + self.budget.atmosAtt = True + antennas = (self.receiver, self.transmitter) if ground_first else (self.transmitter, self.receiver) + self.budget.antennaInPayload_1.subscribeTo(antennas[0].antennaOutMsg) + self.budget.antennaInPayload_2.subscribeTo(antennas[1].antennaOutMsg) + self.downlink = downlinkHandling.DownlinkHandling() + self.downlink.ModelTag = "downlink" + self.downlink.setBitRateRequest(100000.0) # [bit/s] + self.downlink.linkBudgetInMsg.subscribeTo(self.budget.linkBudgetOutPayload) + self.storage = simpleStorageUnit.SimpleStorageUnit() + self.storage.ModelTag = "storage" + self.storage.storageCapacity = 2*INITIAL_DATA + self.storage.setDataBuffer(INITIAL_DATA) + self.storage.addDataNodeToModel(self.downlink.nodeDataOutMsg) + self.downlink.addStorageUnitToDownlink(self.storage.storageUnitDataOutMsg) + + for task, model, priority in ( + ("spacecraft", self.spacecraft, 10), + ("ground", self.ground, 10), + ("antennas", self.transmitter, 20), + ("antennas", self.receiver, 10), + ("link", self.budget, 30), + ("link", self.downlink, 20), + ("link", self.storage, 10), + ): + self.sim.AddModelToTask(task, model, ModelPriority=priority) + self.link_log = self.budget.linkBudgetOutPayload.recorder() + self.antenna_log = self.receiver.antennaOutMsg.recorder() + self.downlink_log = self.downlink.downlinkOutMsg.recorder() + self.node_log = self.downlink.nodeDataOutMsg.recorder() + self.storage_log = self.storage.storageUnitDataOutMsg.recorder() + self.loss_log = self.budget.logger(["L_atm", "L_point"]) + for recorder in ( + self.link_log, self.antenna_log, self.downlink_log, + self.node_log, self.storage_log, self.loss_log, + ): + self.sim.AddModelToTask("link", recorder, ModelPriority=0) + self.set_geometry(INITIAL_ALTITUDE) + + def set_geometry(self, altitude): + """Set stationary geometry with the spacecraft boresight at the station. + + :param altitude: Ground station altitude in meters. + """ + latitude = 0.35 # [rad] + longitude = -0.6 # [rad] + elevation = 0.7 # [rad] + self.ground.specifyLocation(latitude, longitude, altitude) + self.normal = np.array([ + np.cos(latitude)*np.cos(longitude), + np.cos(latitude)*np.sin(longitude), np.sin(latitude), + ]) # [-] + self.ground_position = (self.ground.planetRadius + altitude)*self.normal + east = np.array([-np.sin(longitude), np.cos(longitude), 0.0]) # [-] + ray = np.sin(elevation)*self.normal + np.cos(elevation)*east + self.spacecraft_position = self.ground_position + LINK_DISTANCE*ray + self.spacecraft.hub.r_CN_NInit = self.spacecraft_position + boresight = -ray + axis_y = np.cross(boresight, self.normal) + axis_y /= np.linalg.norm(axis_y) + axis_x = np.cross(axis_y, boresight) + self.spacecraft.hub.sigma_BNInit = rbk.C2MRP(np.array([axis_x, axis_y, boresight])) + + def run_until(self, time_seconds): + """Advance the scheduler without manually updating any producer. + + :param time_seconds: Absolute stop time in seconds. + """ + self.sim.ConfigureStopTime(macros.sec2nano(time_seconds)) + self.sim.ExecuteSimulation() + + def assert_reset_output(self): + """Check that reset publishes zero service even before the first tick.""" + assert self.downlink.linkBudgetInMsg.isWritten() + output = self.budget.linkBudgetOutPayload.read() + assert (output.CNR1, output.CNR2, output.bandwidth) == (0.0, 0.0, 0.0) + assert self.budget.getL_atm() == 0.0 + + def assert_ready(self, first_sample, ground_first): + """Compare scheduled results with an explicitly primed link reference. + + The reference uses published RF properties but independently supplied + positions and surface normal. Existing unit tests validate the RF and + atmospheric formulas; this test validates their scheduled initialization. + + :param first_sample: First recorder index with ready producer inputs. + :param ground_first: Whether the ground antenna uses link input one. + :return: Reference atmospheric attenuation in decibels. + """ + ground_payload = self.receiver.antennaOutMsg.read() + ground_payload.r_AN_N = self.ground_position + ground_payload.r_AP_N = self.ground_position + ground_payload.nHat_LP_N = self.normal + space_payload = self.transmitter.antennaOutMsg.read() + space_payload.r_AN_N = self.spacecraft_position + ground_msg = messaging.AntennaLogMsg().write(ground_payload) + space_msg = messaging.AntennaLogMsg().write(space_payload) + reference = linkBudget.LinkBudget() + reference.atmosAtt = True + reference.antennaInPayload_1.subscribeTo(space_msg) + reference.antennaInPayload_2.subscribeTo(ground_msg) + reference.Reset(0) # [ns] + reference.UpdateState(0) # [ns] + expected_cnr = reference.getCNR2() + expected_loss = reference.getL_atm() # [dB] + assert expected_cnr > 0.0 and expected_loss > 0.0 + assert self.budget.atmosAtt + ready = slice(first_sample, None) + cnr = self.link_log.CNR1 if ground_first else self.link_log.CNR2 + unused_cnr = self.link_log.CNR2 if ground_first else self.link_log.CNR1 + np.testing.assert_allclose(cnr[ready], expected_cnr, rtol=1.0e-10) + np.testing.assert_array_equal(unused_cnr, 0.0) + np.testing.assert_allclose( + self.loss_log.L_atm[ready], expected_loss, atol=1.0e-12, rtol=0.0 + ) # [dB] + np.testing.assert_allclose(self.loss_log.L_point[ready], 0.0, atol=1.0e-12) # [dB] + # Match geometry samples to the timestamps at which the link is ready. + antenna_ready = self.antenna_log.times() >= self.link_log.times()[first_sample] + np.testing.assert_array_equal(self.antenna_log.times()[antenna_ready], self.link_log.times()[ready]) + np.testing.assert_allclose( + self.antenna_log.nHat_LP_N[antenna_ready] - self.normal, 0.0, atol=1.0e-14 + ) + np.testing.assert_allclose( + self.antenna_log.r_AP_N[antenna_ready] - self.ground_position, 0.0, atol=1.0e-8 + ) # [m] + np.testing.assert_array_equal(self.link_log.bandwidth[ready], BANDWIDTH) + np.testing.assert_array_equal(self.downlink_log.linkActive[ready], 1) + np.testing.assert_array_equal(self.downlink_log.receiverIndex[ready], 1 if ground_first else 2) + np.testing.assert_allclose(self.downlink_log.cnr[ready], expected_cnr, rtol=1.0e-10) + np.testing.assert_array_equal(self.link_log.timesWritten(), self.link_log.times()) + np.testing.assert_array_equal(self.downlink_log.timesWritten(), self.link_log.times()) + np.testing.assert_allclose(self.node_log.baudRate, -self.downlink_log.storageRemovalRate) + elapsed = np.diff(self.link_log.times(), prepend=0)*macros.NANO2SEC # [s] + np.testing.assert_allclose(self.downlink_log.timeStep, elapsed) + # At time zero the elapsed interval is zero; later ready ticks must deliver data immediately. + flow_start = max(first_sample, 1) + assert np.all(self.downlink_log.deliveredDataRate[flow_start:] > 0.0) + assert np.all(self.node_log.baudRate[flow_start:] < 0.0) + assert self.storage_log.storageLevel[-1] < self.storage_log.storageLevel[0] + removed = np.sum(self.downlink_log.storageRemovalRate*self.downlink_log.timeStep) # [bit] + assert self.downlink_log.cumulativeRemovedBits[-1] == pytest.approx(removed) + assert self.storage_log.storageLevel[0] - self.storage_log.storageLevel[-1] == pytest.approx( + removed, abs=len(self.storage_log.times()) + ) # [bit] Storage rounds each integrated update to an integer number of bits. + return expected_loss + + +@pytest.mark.parametrize("ground_first", [False, True], ids=["ground-second", "ground-first"]) +@pytest.mark.parametrize( + "delayed_task", [None, "antennas", "ground"], + ids=["ordered", "delayed-antennas", "delayed-ground"], +) +def test_scheduled_antenna_initialization(ground_first, delayed_task): + """Validate initialization, waiting, downstream service and reset scheduling. + + Validation Test Description + --------------------------- + Delayed tasks remain disabled for two ticks while consumers keep running, + then resume in producer order. A full simulation reset must subsequently + rebuild the atmosphere at the changed altitude on its first scheduled tick. + + Test Parameter Discussion + ------------------------- + Both link input orders are exercised with normal startup, delayed antenna + publication, and antennas publishing before the ground position is ready. + + Description of Variables Being Tested + ------------------------------------- + Check CNR, atmospheric and pointing loss, ground normal and position, + message timestamps, downlink service and integrated storage removal. + + :param ground_first: Whether the ground antenna uses link input one. + :param delayed_task: Producer task to delay, or ``None`` for normal startup. + """ + chain = ScheduledLink(ground_first) + chain.sim.InitializeSimulation() + assert not chain.budget.antennaInPayload_1.isWritten() + assert not chain.budget.antennaInPayload_2.isWritten() + chain.assert_reset_output() + if delayed_task is not None: + chain.sim.disableTask(delayed_task) + chain.run_until(TASK_PERIOD) + if delayed_task is not None: + for values in ( + chain.link_log.CNR1, chain.link_log.CNR2, chain.link_log.bandwidth, + chain.loss_log.L_atm, chain.downlink_log.linkActive, + chain.downlink_log.deliveredDataRate, chain.downlink_log.cumulativeRemovedBits, + chain.node_log.baudRate, + ): + np.testing.assert_array_equal(values, 0.0) + np.testing.assert_array_equal(chain.storage_log.storageLevel, INITIAL_DATA) + chain.sim.enableTask(delayed_task) + chain.run_until(3*TASK_PERIOD) + expected_times = np.arange(4)*macros.sec2nano(TASK_PERIOD) # [ns] + np.testing.assert_array_equal(chain.link_log.times(), expected_times) + first_loss = chain.assert_ready(0 if delayed_task is None else 2, ground_first) + + previous_position = chain.ground_position.copy() + chain.set_geometry(INITIAL_ALTITUDE + ALTITUDE_CHANGE) + chain.sim.InitializeSimulation() + chain.assert_reset_output() + # The old antenna publication survives reset; the next ordered tick must replace it. + np.testing.assert_allclose(chain.receiver.antennaOutMsg.read().r_AP_N, previous_position) + chain.run_until(2*TASK_PERIOD) + np.testing.assert_array_equal(chain.link_log.times(), expected_times[:3]) + assert chain.assert_ready(0, ground_first) < first_loss + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__]))