From d9249967019a9e9138ae720d8be53e06c46a9052 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:36:17 -0600 Subject: [PATCH 01/36] Add C++ GoogleTest target for the encoder --- .../deviceInterface/encoder/CMakeLists.txt | 2 + .../encoder/_UnitTest/CMakeLists.txt | 13 +++++ .../encoder/_UnitTest/encoderTestHelpers.hpp | 48 +++++++++++++++++++ .../encoder/_UnitTest/test_encoder.cpp | 20 ++++++++ 4 files changed, 83 insertions(+) create mode 100644 src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt create mode 100644 src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp create mode 100644 src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp diff --git a/src/simulation/deviceInterface/encoder/CMakeLists.txt b/src/simulation/deviceInterface/encoder/CMakeLists.txt index f46db9b8f8..921a49b818 100644 --- a/src/simulation/deviceInterface/encoder/CMakeLists.txt +++ b/src/simulation/deviceInterface/encoder/CMakeLists.txt @@ -8,3 +8,5 @@ xmera_add_swig_module("${module}") target_sources("${module}" PRIVATE encoder.cpp ) + +add_subdirectory(_UnitTest) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt b/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt new file mode 100644 index 0000000000..0a47461756 --- /dev/null +++ b/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt @@ -0,0 +1,13 @@ +# The encoder .cpp is compiled only into the SWIG Python module (not a linkable +# C++ library), so pull the source in directly here. +add_executable(test_encoder + ../encoder.cpp + test_encoder.cpp +) +target_include_directories(test_encoder PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(test_encoder PRIVATE + GTest::gtest_main + Xmera::Core + ArchitectureUtilities +) +gtest_discover_tests(test_encoder) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp new file mode 100644 index 0000000000..4b2ca0d099 --- /dev/null +++ b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: ISC +// Copyright (c) 2026, Laboratory for Atmospheric and Space Physics, University of Colorado at Boulder + +#ifndef ENCODER_TEST_HELPERS_HPP +#define ENCODER_TEST_HELPERS_HPP + +#include +#include + +#include + +#include +#include + +namespace encodertest { + //! One second in nanoseconds. The tests use this value as the time step. + constexpr uint64_t oneSecond = 1'000'000'000ULL; + + //! This harness connects an encoder to an input speed message and an output reader. + class EncoderHarness { + public: + //! Make an encoder with the given wheel count and clicks per rotation, then connect its messages. + EncoderHarness(int numRW, int clicksPerRotation) { + this->encoder.numRW = numRW; + this->encoder.clicksPerRotation = clicksPerRotation; + this->encoder.rwSpeedInMsg.subscribeTo(&this->speedInMsg); + this->speedOut = this->encoder.rwSpeedOutMsg.addSubscriber(); + } + + EncoderHarness(EncoderHarness const &) = delete; + EncoderHarness &operator=(EncoderHarness const &) = delete; + + //! Write the wheel speeds to the input message, update the encoder at time t, and read the output. + RWSpeedMsgPayload step(std::vector const &wheelSpeeds, uint64_t t) { + RWSpeedMsgPayload payload{}; + for (std::size_t i = 0; i < wheelSpeeds.size(); ++i) { payload.wheelSpeeds[i] = wheelSpeeds[i]; } + this->speedInMsg.write(payload, 0, t); + this->encoder.updateState(t); + return this->speedOut(); + } + + Encoder encoder; //!< encoder under test + Message speedInMsg; //!< input wheel speed message + ReadFunctor speedOut; //!< reader of the encoder output message + }; +} // namespace encodertest + +#endif // ENCODER_TEST_HELPERS_HPP diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp new file mode 100644 index 0000000000..7f84ad8bb2 --- /dev/null +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: ISC +// Copyright (c) 2026, Laboratory for Atmospheric and Space Physics, University of Colorado at Boulder + +#include "encoderTestHelpers.hpp" + +#include + +using encodertest::EncoderHarness; + +//! On the first step the time step is zero, so the encoder sends the true wheel speeds. +TEST(Encoder, firstStepSendsTrueSpeeds) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + + RWSpeedMsgPayload const out = harness.step({100.0, 200.0, 300.0}, 0); + + EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], 100.0); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], 200.0); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 300.0); +} From eb1809965f6d2fba8d7fc7b9ca9923a7d6d29430 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:36:36 -0600 Subject: [PATCH 02/36] Add gtest for speed quantization over consecutive steps --- .../encoder/_UnitTest/test_encoder.cpp | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 7f84ad8bb2..92c33468cc 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -5,7 +5,15 @@ #include +#include + using encodertest::EncoderHarness; +using encodertest::oneSecond; + +namespace { + constexpr double pi = std::numbers::pi; + constexpr double tolerance = 1e-9; +} // namespace //! On the first step the time step is zero, so the encoder sends the true wheel speeds. TEST(Encoder, firstStepSendsTrueSpeeds) { @@ -18,3 +26,22 @@ TEST(Encoder, firstStepSendsTrueSpeeds) { EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], 200.0); EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 300.0); } + +//! With two clicks per rotation, each output speed is a whole number of clicks times pi rad/s. +//! The encoder keeps the fraction of a click that remains and adds it to the next step. +TEST(Encoder, quantizesSpeedOverConsecutiveSteps) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + std::vector const speeds{100.0, 200.0, 300.0}; + + harness.step(speeds, 0); + RWSpeedMsgPayload const first = harness.step(speeds, oneSecond); + RWSpeedMsgPayload const second = harness.step(speeds, 2 * oneSecond); + + EXPECT_NEAR(first.wheelSpeeds[0], 31.0 * pi, tolerance); + EXPECT_NEAR(first.wheelSpeeds[1], 63.0 * pi, tolerance); + EXPECT_NEAR(first.wheelSpeeds[2], 95.0 * pi, tolerance); + EXPECT_NEAR(second.wheelSpeeds[0], 32.0 * pi, tolerance); + EXPECT_NEAR(second.wheelSpeeds[1], 64.0 * pi, tolerance); + EXPECT_NEAR(second.wheelSpeeds[2], 95.0 * pi, tolerance); +} From 5e0620fb18499c79e3eb31ce003a884d8bafc365 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:37:01 -0600 Subject: [PATCH 03/36] Add gtest for the off signal zeroing the output --- .../encoder/_UnitTest/test_encoder.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 92c33468cc..ece12f7e4a 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -2,6 +2,7 @@ // Copyright (c) 2026, Laboratory for Atmospheric and Space Physics, University of Colorado at Boulder #include "encoderTestHelpers.hpp" +#include #include @@ -45,3 +46,19 @@ TEST(Encoder, quantizesSpeedOverConsecutiveSteps) { EXPECT_NEAR(second.wheelSpeeds[1], 64.0 * pi, tolerance); EXPECT_NEAR(second.wheelSpeeds[2], 95.0 * pi, tolerance); } + +//! When the signal is off, the encoder sends zero speed. +TEST(Encoder, offSignalSendsZeroSpeed) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + std::vector const speeds{100.0, 200.0, 300.0}; + harness.step(speeds, 0); + harness.step(speeds, oneSecond); + + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_OFF; } + RWSpeedMsgPayload const out = harness.step(speeds, 2 * oneSecond); + + EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], 0.0); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], 0.0); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 0.0); +} From 56fad43460f132fa42514ac9ebf77f673f57e277 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:37:03 -0600 Subject: [PATCH 04/36] Add gtest for the nominal signal after an off signal --- .../encoder/_UnitTest/test_encoder.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index ece12f7e4a..10f065d3af 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -62,3 +62,22 @@ TEST(Encoder, offSignalSendsZeroSpeed) { EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], 0.0); EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 0.0); } + +//! An off signal erases the remaining clicks. After the signal is nominal again, the count starts from zero. +TEST(Encoder, nominalSignalAfterOffStartsFromZeroClicks) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + std::vector const speeds{100.0, 200.0, 300.0}; + harness.step(speeds, 0); + harness.step(speeds, oneSecond); + harness.step(speeds, 2 * oneSecond); + + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_OFF; } + harness.step(speeds, 3 * oneSecond); + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_NOMINAL; } + RWSpeedMsgPayload const out = harness.step({500.0, 400.0, 300.0}, 4 * oneSecond); + + EXPECT_NEAR(out.wheelSpeeds[0], 159.0 * pi, tolerance); + EXPECT_NEAR(out.wheelSpeeds[1], 127.0 * pi, tolerance); + EXPECT_NEAR(out.wheelSpeeds[2], 95.0 * pi, tolerance); +} From 9e60fa5d38b5335163abc26d3be8adb6b2013b4b Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:37:05 -0600 Subject: [PATCH 05/36] Add gtest for the stuck signal holding the previous output --- .../encoder/_UnitTest/test_encoder.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 10f065d3af..04504d64ea 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -81,3 +81,18 @@ TEST(Encoder, nominalSignalAfterOffStartsFromZeroClicks) { EXPECT_NEAR(out.wheelSpeeds[1], 127.0 * pi, tolerance); EXPECT_NEAR(out.wheelSpeeds[2], 95.0 * pi, tolerance); } + +//! When the signal is stuck, the encoder sends the speeds of the previous step and ignores the new input. +TEST(Encoder, stuckSignalHoldsPreviousSpeed) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + harness.step({500.0, 400.0, 300.0}, 0); + RWSpeedMsgPayload const before = harness.step({500.0, 400.0, 300.0}, oneSecond); + + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_STUCK; } + RWSpeedMsgPayload const out = harness.step({100.0, 200.0, 300.0}, 2 * oneSecond); + + EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], before.wheelSpeeds[0]); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], before.wheelSpeeds[1]); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], before.wheelSpeeds[2]); +} From d962c270a2e9f25d2d0f81a7795cfeb56aa8b07c Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:41:32 -0600 Subject: [PATCH 06/36] Rename the Python encoder test to snake_case and remove boilerplate --- .../encoder/_UnitTest/test_encoder.py | 228 ++++-------------- 1 file changed, 50 insertions(+), 178 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py index deb65c87bb..5f15e6c5a7 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py @@ -4,211 +4,83 @@ # """ -Module Name: motorThermal +Module Name: encoder """ -import inspect -import os - import numpy as np -import pytest - -filename = inspect.getframeinfo(inspect.currentframe()).filename -path = os.path.dirname(os.path.abspath(filename)) -bskName = 'xmera' -splitPath = path.split(bskName) - -# Import all of the modules that we are going to be called in this simulation +from xmera.architecture import messaging +from xmera.simulation import encoder from xmera.utilities import SimulationBaseClass -from xmera.simulation import encoder # import the module that is to be tested -from xmera.architecture import messaging # import the message definitions from xmera.utilities import macros -from xmera.utilities import unitTestSupport +def write_speed_message(wheel_speeds): + """Make a reaction wheel speed message that contains the given wheel speeds.""" + payload = messaging.RWSpeedMsgPayload() + payload.wheelSpeeds = wheel_speeds + return messaging.RWSpeedMsg().write(payload) -# Uncomment this line is this test is to be skipped in the global unit test run, adjust message as needed. -# @pytest.mark.skipif(conditionstring) -# Uncomment this line if this test has an expected failure, adjust message as needed. -# @pytest.mark.xfail(conditionstring) -@pytest.mark.parametrize("accuracy", [1e-8]) -def test_encoder(show_plots, accuracy): +def test_encoder(): r""" **Validation Test Description** - This unit test script tests the the features of an encoder, namely discretization and signal failures. It sets up - the reaction wheel speed message and encoder modules and runs the simulation for a number of steps. The script tests - each functionality of the module, including the discretization of the wheel speeds and the signal failures, such as - the signal being turned off or being stuck at the previous iteration. - - In the reaction wheel message, three wheels are considered with varying values. Throughout the simulation, the - encoder module assumes different operating states, such as nominal (everything works as intended), off (wheel speeds - are off) and stuck (wheel speeds remain constant). - - The limitations of this test are the same as the ones discussed on the module's .rst file. Also, the value of the - accuracy used is the limit for the test to pass. - - **Test Parameters** - - Args: - accuracy (float): absolute accuracy value used in the validation tests + This test simulates the encoder for six steps with three reaction wheels. The encoder uses two clicks for each + rotation. The test changes the input speeds and the signal state between the steps. The encoder output must + agree with the known output speeds for the nominal, off and stuck signal states. **Description of Variables Being Tested** - In this file we are checking the values of the variables - - - ``wheelSpeedsEncoded`` - - which represents the array of reaction wheel speeds. This array is compared to the ``truewheelSpeedsEncoded`` array, - which contains the expected values of the wheel speeds after encoding. + The test compares the ``wheelSpeeds`` field of the encoder output message with the known values. """ - [testResults, testMessage] = encoderTest(show_plots, accuracy) - assert testResults < 1, testMessage - - -def encoderTest(show_plots, accuracy): - testFailCount = 0 # zero unit test result counter - testMessages = [] # create empty list to store test log messages - - # Create simulation variable names - unitTaskName = "unitTask" # arbitrary name (don't change) - unitProcessName = "TestProcess" # arbitrary name (don't change) - - # Create a sim module as an empty container - unitTestSim = SimulationBaseClass.SimBaseClass() - - # - # create the simulation process - # - - testProcessRate = macros.sec2nano(1) # update process rate update time - testProc = unitTestSim.CreateNewProcess(unitProcessName) - testProc.addTask(unitTestSim.CreateNewTask(unitTaskName, testProcessRate)) - - # - # setup the simulation tasks/objects - # + task_name = "unitTask" + process_name = "TestProcess" + num_rw = 3 - # - # Create RW speed message - # + sim = SimulationBaseClass.SimBaseClass() + process = sim.CreateNewProcess(process_name) + process.addTask(sim.CreateNewTask(task_name, macros.sec2nano(1))) - speedMsgData = messaging.RWSpeedMsgPayload() - speedMsgData.wheelSpeeds = [100, 200, 300] - speedMsg = messaging.RWSpeedMsg().write(speedMsgData) + speed_msg = write_speed_message([100, 200, 300]) - numRW = 3 - - # - # Setup the reaction wheel speed encoder - # + wheel_speed_encoder = encoder.Encoder() + wheel_speed_encoder.modelTag = "rwSpeedsEncoder" + wheel_speed_encoder.clicksPerRotation = 2 + wheel_speed_encoder.numRW = num_rw + wheel_speed_encoder.rwSpeedInMsg.subscribeTo(speed_msg) + sim.AddModelToTask(task_name, wheel_speed_encoder) - wheelSpeedEncoder = encoder.Encoder() - wheelSpeedEncoder.modelTag = 'rwSpeedsEncoder' - wheelSpeedEncoder.clicksPerRotation = 2 - wheelSpeedEncoder.numRW = numRW - wheelSpeedEncoder.rwSpeedInMsg.subscribeTo(speedMsg) + encoded_log = wheel_speed_encoder.rwSpeedOutMsg.recorder() + sim.AddModelToTask(task_name, encoded_log) - # Add test module to runtime call list - unitTestSim.AddModelToTask(unitTaskName, wheelSpeedEncoder) + sim.InitializeSimulation() + for _ in range(3): + sim.TotalSim.singleStepProcesses() - # - # log data - # + wheel_speed_encoder.rwSignalState = [encoder.SIGNAL_OFF] * num_rw + sim.TotalSim.singleStepProcesses() - # log the RW speeds - wheelSpeedEncodedLog = wheelSpeedEncoder.rwSpeedOutMsg.recorder() - unitTestSim.AddModelToTask(unitTaskName, wheelSpeedEncodedLog) + speed_msg = write_speed_message([500, 400, 300]) + wheel_speed_encoder.rwSpeedInMsg.subscribeTo(speed_msg) + wheel_speed_encoder.rwSignalState = [encoder.SIGNAL_NOMINAL] * num_rw + sim.TotalSim.singleStepProcesses() - # - # initialize Simulation - # + speed_msg = write_speed_message([100, 200, 300]) + wheel_speed_encoder.rwSpeedInMsg.subscribeTo(speed_msg) + wheel_speed_encoder.rwSignalState = [encoder.SIGNAL_STUCK] * num_rw + sim.TotalSim.singleStepProcesses() - unitTestSim.InitializeSimulation() - numSteps = 0 + encoded_speeds = np.array(encoded_log.wheelSpeeds)[:, 0:num_rw] + true_encoded_speeds = np.array([[100.0, 200.0, 300.0], + [31.0 * np.pi, 63.0 * np.pi, 95.0 * np.pi], + [32.0 * np.pi, 64.0 * np.pi, 95.0 * np.pi], + [0.0, 0.0, 0.0], + [159.0 * np.pi, 127.0 * np.pi, 95.0 * np.pi], + [159.0 * np.pi, 127.0 * np.pi, 95.0 * np.pi]]) - # run the sim - unitTestSim.TotalSim.singleStepProcesses() - numSteps += 1 - unitTestSim.TotalSim.singleStepProcesses() - numSteps += 1 - unitTestSim.TotalSim.singleStepProcesses() - numSteps += 1 + np.testing.assert_allclose(encoded_speeds, true_encoded_speeds, rtol=0.0, atol=1e-8) - # update the encoder to be OFF - wheelSpeedEncoder.rwSignalState = [encoder.SIGNAL_OFF]*numRW - # run the sim - unitTestSim.TotalSim.singleStepProcesses() - numSteps += 1 - - # update the wheel speeds - speedMsgData.wheelSpeeds = [500, 400, 300] - speedMsg = messaging.RWSpeedMsg().write(speedMsgData) - wheelSpeedEncoder.rwSpeedInMsg.subscribeTo(speedMsg) - wheelSpeedEncoder.rwSignalState = [encoder.SIGNAL_NOMINAL] * numRW - - # run the sim - unitTestSim.TotalSim.singleStepProcesses() - numSteps += 1 - - # change the wheel speeds but make the encoder stuck - speedMsgData.wheelSpeeds = [100, 200, 300] - speedMsg = messaging.RWSpeedMsg().write(speedMsgData) - wheelSpeedEncoder.rwSpeedInMsg.subscribeTo(speedMsg) - wheelSpeedEncoder.rwSignalState = [encoder.SIGNAL_STUCK] * numRW - - # run the sim - unitTestSim.TotalSim.singleStepProcesses() - numSteps += 1 - - # - # retrieve the logged data - # - - wheelSpeedsEncoded = np.array(wheelSpeedEncodedLog.wheelSpeeds) - - # - # set the truth vectors - # - - trueWheelSpeedsEncoded = np.array([[100., 200., 300.], - [ 97.38937226, 197.92033718, 298.45130209], - [100.53096491, 201.06192983, 298.45130209], - [0., 0., 0.], - [499.51323192, 398.98226701, 298.45130209], - [499.51323192, 398.98226701, 298.45130209]]) - - # - # compare the module results to the true values - # - - fail = 0 - for i in range(numSteps): - # check a vector values - if not unitTestSupport.isArrayEqual(wheelSpeedsEncoded[i, 0:3], trueWheelSpeedsEncoded[i, :], 3, accuracy): - fail += 1 - - if fail > 0: - testFailCount += 1 - - if not testFailCount: - print("PASSED") - else: - testMessages.append("FAILED: Encoder Test") - - # each test method requires a single assert method to be called - # this check below just makes sure no sub-test failures were found - return [testFailCount, ''.join(testMessages)] - - -# -# Run this unitTest as a stand-along python script -# if __name__ == "__main__": - test_encoder( - False, # show_plots - 1e-8 # accuracy - ) + test_encoder() From e3722a5bb5726943929435976c0733f8477f7030 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:41:44 -0600 Subject: [PATCH 07/36] Reduce the Python encoder test to a smoke test of the bindings --- .../encoder/_UnitTest/test_encoder.py | 45 +++++-------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py index 5f15e6c5a7..8ca5ada3d4 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py @@ -15,34 +15,30 @@ from xmera.utilities import macros -def write_speed_message(wheel_speeds): - """Make a reaction wheel speed message that contains the given wheel speeds.""" - payload = messaging.RWSpeedMsgPayload() - payload.wheelSpeeds = wheel_speeds - return messaging.RWSpeedMsg().write(payload) - - def test_encoder(): r""" **Validation Test Description** - This test simulates the encoder for six steps with three reaction wheels. The encoder uses two clicks for each - rotation. The test changes the input speeds and the signal state between the steps. The encoder output must - agree with the known output speeds for the nominal, off and stuck signal states. + This test makes sure that the Python bindings of the encoder operate in a simulation. The C++ unit tests in + this folder examine the encoder behavior in more detail. + + The test connects the encoder to a reaction wheel speed message and simulates two steps of one second. The + encoder uses two clicks for each rotation. **Description of Variables Being Tested** The test compares the ``wheelSpeeds`` field of the encoder output message with the known values. """ task_name = "unitTask" - process_name = "TestProcess" num_rw = 3 sim = SimulationBaseClass.SimBaseClass() - process = sim.CreateNewProcess(process_name) + process = sim.CreateNewProcess("TestProcess") process.addTask(sim.CreateNewTask(task_name, macros.sec2nano(1))) - speed_msg = write_speed_message([100, 200, 300]) + speed_payload = messaging.RWSpeedMsgPayload() + speed_payload.wheelSpeeds = [100, 200, 300] + speed_msg = messaging.RWSpeedMsg().write(speed_payload) wheel_speed_encoder = encoder.Encoder() wheel_speed_encoder.modelTag = "rwSpeedsEncoder" @@ -55,29 +51,12 @@ def test_encoder(): sim.AddModelToTask(task_name, encoded_log) sim.InitializeSimulation() - for _ in range(3): - sim.TotalSim.singleStepProcesses() - - wheel_speed_encoder.rwSignalState = [encoder.SIGNAL_OFF] * num_rw - sim.TotalSim.singleStepProcesses() - - speed_msg = write_speed_message([500, 400, 300]) - wheel_speed_encoder.rwSpeedInMsg.subscribeTo(speed_msg) - wheel_speed_encoder.rwSignalState = [encoder.SIGNAL_NOMINAL] * num_rw - sim.TotalSim.singleStepProcesses() - - speed_msg = write_speed_message([100, 200, 300]) - wheel_speed_encoder.rwSpeedInMsg.subscribeTo(speed_msg) - wheel_speed_encoder.rwSignalState = [encoder.SIGNAL_STUCK] * num_rw - sim.TotalSim.singleStepProcesses() + sim.ConfigureStopTime(macros.sec2nano(1)) + sim.ExecuteSimulation() encoded_speeds = np.array(encoded_log.wheelSpeeds)[:, 0:num_rw] true_encoded_speeds = np.array([[100.0, 200.0, 300.0], - [31.0 * np.pi, 63.0 * np.pi, 95.0 * np.pi], - [32.0 * np.pi, 64.0 * np.pi, 95.0 * np.pi], - [0.0, 0.0, 0.0], - [159.0 * np.pi, 127.0 * np.pi, 95.0 * np.pi], - [159.0 * np.pi, 127.0 * np.pi, 95.0 * np.pi]]) + [31.0 * np.pi, 63.0 * np.pi, 95.0 * np.pi]]) np.testing.assert_allclose(encoded_speeds, true_encoded_speeds, rtol=0.0, atol=1e-8) From 443429b0f392c963534343f314d6c6a87351fe44 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:42:39 -0600 Subject: [PATCH 08/36] Remove the empty destructor and redundant return statements --- .../deviceInterface/encoder/encoder.cpp | 16 ---------------- src/simulation/deviceInterface/encoder/encoder.h | 1 - 2 files changed, 17 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 937a0b5608..1e4dbfa5a9 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -14,13 +14,6 @@ Encoder::Encoder() { this->numRW = -1; // set the number of reaction wheels to -1 to throw a warning if not set this->clicksPerRotation = -1; - - return; -} - -/*! Module Destructor. */ -Encoder::~Encoder() { - return; } /*! This method is used to reset the module. @@ -56,8 +49,6 @@ void Encoder::reset(uint64_t currentSimNanos) { // set the remaining clicks to zero this->remainingClicks[i] = 0.0; } - - return; } /*! This method reads the speed input message @@ -65,8 +56,6 @@ void Encoder::reset(uint64_t currentSimNanos) { void Encoder::readInputMessages() { // read the incoming power message this->rwSpeedBuffer = this->rwSpeedInMsg(); - - return; } /*! This method writes encoded the wheel speed message. @@ -75,8 +64,6 @@ void Encoder::readInputMessages() { */ void Encoder::writeOutputMessages(uint64_t CurrentClock) { this->rwSpeedOutMsg.write(this->rwSpeedConverted, this->moduleID, CurrentClock); - - return; } /*! This method applies an encoder to the reaction wheel speeds. @@ -128,7 +115,6 @@ void Encoder::encode(uint64_t currentSimNanos) { } } } - return; } /*! This method runs the encoder module in the sim. @@ -139,6 +125,4 @@ void Encoder::updateState(uint64_t currentSimNanos) { this->writeOutputMessages(currentSimNanos); this->prevTime = currentSimNanos; - - return; } diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 8a1a713f1f..823663be92 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -16,7 +16,6 @@ class Encoder : public SysModel { public: Encoder(); - ~Encoder(); void reset(uint64_t currentSimNanos); void updateState(uint64_t currentSimNanos); From 9012e0328a356645c7490c8e024c6d4fbd246b72 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:43:10 -0600 Subject: [PATCH 09/36] Mark the inherited virtual methods override --- src/simulation/deviceInterface/encoder/encoder.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 823663be92..45fd53b5cc 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -17,8 +17,8 @@ class Encoder : public SysModel { public: Encoder(); - void reset(uint64_t currentSimNanos); - void updateState(uint64_t currentSimNanos); + void reset(uint64_t currentSimNanos) override; + void updateState(uint64_t currentSimNanos) override; void readInputMessages(); void writeOutputMessages(uint64_t CurrentClock); void encode(uint64_t currentSimNanos); From 598a3582d29266cd66235415ac4c8fad7c0fac88 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:43:48 -0600 Subject: [PATCH 10/36] Use std::trunc and std::numbers::pi from the C++ headers --- src/simulation/deviceInterface/encoder/encoder.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 1e4dbfa5a9..ca300e1271 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -7,7 +7,8 @@ #include #include -#include +#include +#include /*! This is the constructor for the module class. It sets default variable values and initializes the various parts of the model */ @@ -75,7 +76,7 @@ void Encoder::encode(uint64_t currentSimNanos) { double angle; // convert clicks per rotation to clicks per radian - clicksPerRadian = this->clicksPerRotation / (2 * M_PI); + clicksPerRadian = this->clicksPerRotation / (2 * std::numbers::pi); // set the time step timeStep = (currentSimNanos - this->prevTime) * NANO2SEC; @@ -92,7 +93,7 @@ void Encoder::encode(uint64_t currentSimNanos) { angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; // calculate the number of clicks - numberClicks = trunc(angle * clicksPerRadian + this->remainingClicks[i]); + numberClicks = std::trunc(angle * clicksPerRadian + this->remainingClicks[i]); // update the remaining clicks this->remainingClicks[i] = angle * clicksPerRadian + this->remainingClicks[i] - numberClicks; From fbb023cfbc4fe296df86d7f7662c0648e0d10f7d Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:44:18 -0600 Subject: [PATCH 11/36] Declare the encode local variables const at first use --- .../deviceInterface/encoder/encoder.cpp | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index ca300e1271..ecc603a2c1 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -70,16 +70,11 @@ void Encoder::writeOutputMessages(uint64_t CurrentClock) { /*! This method applies an encoder to the reaction wheel speeds. */ void Encoder::encode(uint64_t currentSimNanos) { - double timeStep; - double numberClicks; - double clicksPerRadian; - double angle; - // convert clicks per rotation to clicks per radian - clicksPerRadian = this->clicksPerRotation / (2 * std::numbers::pi); + double const clicksPerRadian = this->clicksPerRotation / (2 * std::numbers::pi); // set the time step - timeStep = (currentSimNanos - this->prevTime) * NANO2SEC; + double const timeStep = (currentSimNanos - this->prevTime) * NANO2SEC; // at the beginning of the simulation, the encoder simply outputs the true RW speeds if (timeStep == 0.0) { @@ -90,13 +85,14 @@ void Encoder::encode(uint64_t currentSimNanos) { // check if encoder is operational if (this->rwSignalState[i] == SIGNAL_NOMINAL) { // calculate the angle sweeped by the reaction wheel during the time step - angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; + double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; // calculate the number of clicks - numberClicks = std::trunc(angle * clicksPerRadian + this->remainingClicks[i]); + double const totalClicks = angle * clicksPerRadian + this->remainingClicks[i]; + double const numberClicks = std::trunc(totalClicks); // update the remaining clicks - this->remainingClicks[i] = angle * clicksPerRadian + this->remainingClicks[i] - numberClicks; + this->remainingClicks[i] = totalClicks - numberClicks; // calculate the discretized angular velocity this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); From 3e556d7549bf4d59b905f4a759bb465e543ea5be Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:44:40 -0600 Subject: [PATCH 12/36] Add class member initializers --- .../deviceInterface/encoder/encoder.cpp | 7 ------- src/simulation/deviceInterface/encoder/encoder.h | 16 ++++++++-------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index ecc603a2c1..e911aa326d 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -10,13 +10,6 @@ #include #include -/*! This is the constructor for the module class. It sets default variable - values and initializes the various parts of the model */ -Encoder::Encoder() { - this->numRW = -1; // set the number of reaction wheels to -1 to throw a warning if not set - this->clicksPerRotation = -1; -} - /*! This method is used to reset the module. @return void */ diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 45fd53b5cc..f46b7d6b0f 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -15,7 +15,7 @@ /*! @brief wheel speed encoder module class */ class Encoder : public SysModel { public: - Encoder(); + Encoder() = default; void reset(uint64_t currentSimNanos) override; void updateState(uint64_t currentSimNanos) override; @@ -26,17 +26,17 @@ class Encoder : public SysModel { public: Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message ReadFunctor rwSpeedInMsg; //!< [rad/s] reaction wheel speed input message - int rwSignalState[RW_EFF_CNT]; //!< vector of reaction wheel signal states - int clicksPerRotation; //!< number of clicks per full rotation - int numRW; //!< number of reaction wheels + int rwSignalState[RW_EFF_CNT]{}; //!< vector of reaction wheel signal states + int clicksPerRotation = -1; //!< number of clicks per full rotation + int numRW = -1; //!< number of reaction wheels BSKLogger bskLogger; //!< -- BSK Logging private: - RWSpeedMsgPayload rwSpeedBuffer; //!< reaction wheel speed buffer for internal calculations - RWSpeedMsgPayload rwSpeedConverted; //!< reaction wheel speed buffer for converted values - double remainingClicks[RW_EFF_CNT]; //!< remaining clicks from the previous iteration + RWSpeedMsgPayload rwSpeedBuffer{}; //!< reaction wheel speed buffer for internal calculations + RWSpeedMsgPayload rwSpeedConverted{}; //!< reaction wheel speed buffer for converted values + double remainingClicks[RW_EFF_CNT]{}; //!< remaining clicks from the previous iteration - uint64_t prevTime; //!< -- Previous simulation time observed + uint64_t prevTime = 0; //!< -- Previous simulation time observed }; #endif From b3dfdcc7c4fdc5f63245afba9833bbf66cd98d0c Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:44:55 -0600 Subject: [PATCH 13/36] Use the buffered input message for the zero time step output --- src/simulation/deviceInterface/encoder/encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index e911aa326d..be712e4042 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -71,7 +71,7 @@ void Encoder::encode(uint64_t currentSimNanos) { // at the beginning of the simulation, the encoder simply outputs the true RW speeds if (timeStep == 0.0) { - this->rwSpeedConverted = this->rwSpeedInMsg(); + this->rwSpeedConverted = this->rwSpeedBuffer; } else { // loop through the RW for (int i = 0; i < this->numRW; i++) { From a95b5fad5319da476729714610180cb13a149a81 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:45:00 -0600 Subject: [PATCH 14/36] Correct the comment on the input message read --- src/simulation/deviceInterface/encoder/encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index be712e4042..915722cd45 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -48,7 +48,7 @@ void Encoder::reset(uint64_t currentSimNanos) { /*! This method reads the speed input message */ void Encoder::readInputMessages() { - // read the incoming power message + // read the incoming wheel speed message this->rwSpeedBuffer = this->rwSpeedInMsg(); } From 984c63ab8314c1501075c4546b15a3fff2a1c7f4 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:45:39 -0600 Subject: [PATCH 15/36] Add EncoderSignal enum class for the wheel signal states --- .../encoder/_UnitTest/test_encoder.cpp | 9 ++++----- .../deviceInterface/encoder/encoder.cpp | 15 +++++++++------ src/simulation/deviceInterface/encoder/encoder.h | 9 ++++++++- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 04504d64ea..1862e559b2 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -2,7 +2,6 @@ // Copyright (c) 2026, Laboratory for Atmospheric and Space Physics, University of Colorado at Boulder #include "encoderTestHelpers.hpp" -#include #include @@ -55,7 +54,7 @@ TEST(Encoder, offSignalSendsZeroSpeed) { harness.step(speeds, 0); harness.step(speeds, oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_OFF; } + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Off; } RWSpeedMsgPayload const out = harness.step(speeds, 2 * oneSecond); EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], 0.0); @@ -72,9 +71,9 @@ TEST(Encoder, nominalSignalAfterOffStartsFromZeroClicks) { harness.step(speeds, oneSecond); harness.step(speeds, 2 * oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_OFF; } + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Off; } harness.step(speeds, 3 * oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_NOMINAL; } + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Nominal; } RWSpeedMsgPayload const out = harness.step({500.0, 400.0, 300.0}, 4 * oneSecond); EXPECT_NEAR(out.wheelSpeeds[0], 159.0 * pi, tolerance); @@ -89,7 +88,7 @@ TEST(Encoder, stuckSignalHoldsPreviousSpeed) { harness.step({500.0, 400.0, 300.0}, 0); RWSpeedMsgPayload const before = harness.step({500.0, 400.0, 300.0}, oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = SIGNAL_STUCK; } + for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Stuck; } RWSpeedMsgPayload const out = harness.step({100.0, 200.0, 300.0}, 2 * oneSecond); EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], before.wheelSpeeds[0]); diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 915722cd45..24280d58bb 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -39,7 +39,7 @@ void Encoder::reset(uint64_t currentSimNanos) { // Loop through the RW to set some internal parameters to default for (int i = 0; i < RW_EFF_CNT; i++) { // set all reaction wheels signal to nominal - this->rwSignalState[i] = SIGNAL_NOMINAL; + this->rwSignalState[i] = EncoderSignal::Nominal; // set the remaining clicks to zero this->remainingClicks[i] = 0.0; } @@ -76,7 +76,7 @@ void Encoder::encode(uint64_t currentSimNanos) { // loop through the RW for (int i = 0; i < this->numRW; i++) { // check if encoder is operational - if (this->rwSignalState[i] == SIGNAL_NOMINAL) { + if (this->rwSignalState[i] == EncoderSignal::Nominal) { // calculate the angle sweeped by the reaction wheel during the time step double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; @@ -91,17 +91,20 @@ void Encoder::encode(uint64_t currentSimNanos) { this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); } // check if encoder is off - else if (this->rwSignalState[i] == SIGNAL_OFF) { + else if (this->rwSignalState[i] == EncoderSignal::Off) { // set the outgoing reaction wheel speed to 0 this->rwSpeedConverted.wheelSpeeds[i] = 0.0; // reset the remaining clicks this->remainingClicks[i] = 0; - } else if (this->rwSignalState[i] == SIGNAL_STUCK) { + } else if (this->rwSignalState[i] == EncoderSignal::Stuck) { // if the encoder is stuck, it will output the previous results } else { - bskLogger - .bskLog(BSK_ERROR, "encoder: un-modeled encoder signal mode %d selected.", this->rwSignalState[i]); + bskLogger.bskLog( + BSK_ERROR, + "encoder: un-modeled encoder signal mode %d selected.", + static_cast(this->rwSignalState[i]) + ); } } } diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index f46b7d6b0f..8c7dbfb734 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -12,6 +12,13 @@ #include +/*! @brief Signal state of one wheel encoder. */ +enum class EncoderSignal { + Nominal, //!< The encoder operates correctly. + Off, //!< The encoder sends zero speed. + Stuck //!< The encoder sends the speed of the previous step. +}; + /*! @brief wheel speed encoder module class */ class Encoder : public SysModel { public: @@ -26,7 +33,7 @@ class Encoder : public SysModel { public: Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message ReadFunctor rwSpeedInMsg; //!< [rad/s] reaction wheel speed input message - int rwSignalState[RW_EFF_CNT]{}; //!< vector of reaction wheel signal states + EncoderSignal rwSignalState[RW_EFF_CNT]{}; //!< vector of reaction wheel signal states int clicksPerRotation = -1; //!< number of clicks per full rotation int numRW = -1; //!< number of reaction wheels BSKLogger bskLogger; //!< -- BSK Logging From a2a92445d78aa818e1372cd22641b7e611c44b28 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:45:54 -0600 Subject: [PATCH 16/36] Remove the unused signal state macros from simDefinitions.h --- src/architecture/utilities/simDefinitions.h | 6 +----- src/simulation/deviceInterface/encoder/encoder.cpp | 1 - src/simulation/deviceInterface/encoder/encoder.i | 1 - 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/architecture/utilities/simDefinitions.h b/src/architecture/utilities/simDefinitions.h index 4dcd3dfbd2..07147011cd 100644 --- a/src/architecture/utilities/simDefinitions.h +++ b/src/architecture/utilities/simDefinitions.h @@ -5,15 +5,11 @@ #ifndef SIM_MACROS_H #define SIM_MACROS_H -#define EPOCH_YEAR 2019 +#define EPOCH_YEAR 2'019 #define EPOCH_MONTH 01 #define EPOCH_DAY 01 #define EPOCH_HOUR 00 #define EPOCH_MIN 00 #define EPOCH_SEC 0.00 -#define SIGNAL_NOMINAL 0 -#define SIGNAL_OFF 1 -#define SIGNAL_STUCK 2 - #endif // SIM_MACROS_H diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 24280d58bb..30de6eb123 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -5,7 +5,6 @@ #include "encoder.h" #include -#include #include #include diff --git a/src/simulation/deviceInterface/encoder/encoder.i b/src/simulation/deviceInterface/encoder/encoder.i index 337ac5dac8..5a0d2d9d86 100644 --- a/src/simulation/deviceInterface/encoder/encoder.i +++ b/src/simulation/deviceInterface/encoder/encoder.i @@ -11,7 +11,6 @@ %include %include -%include %include %include "encoder.h" From 8a08a4a74a0ae81a9fac7bcb55c97e615c148046 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:46:20 -0600 Subject: [PATCH 17/36] Forward C++ exceptions from the encoder to Python --- src/simulation/deviceInterface/encoder/encoder.i | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/encoder.i b/src/simulation/deviceInterface/encoder/encoder.i index 5a0d2d9d86..de2b2d83a3 100644 --- a/src/simulation/deviceInterface/encoder/encoder.i +++ b/src/simulation/deviceInterface/encoder/encoder.i @@ -7,6 +7,18 @@ #include "encoder.h" %} +%include "exception.i" + +%exception { + try { + $action + } catch (const std::invalid_argument& e) { + SWIG_exception(SWIG_ValueError, e.what()); + } catch (const std::exception& e) { + SWIG_exception(SWIG_RuntimeError, e.what()); + } +} + %include %include From 7c25aacf58ae7858c0ba2b53f0e3ce31fe03fdc5 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:46:37 -0600 Subject: [PATCH 18/36] Throw from reset when the speed input message is not linked --- .../deviceInterface/encoder/_UnitTest/test_encoder.cpp | 10 ++++++++++ src/simulation/deviceInterface/encoder/encoder.cpp | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 1862e559b2..3db484daec 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -6,6 +6,7 @@ #include #include +#include using encodertest::EncoderHarness; using encodertest::oneSecond; @@ -95,3 +96,12 @@ TEST(Encoder, stuckSignalHoldsPreviousSpeed) { EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], before.wheelSpeeds[1]); EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], before.wheelSpeeds[2]); } + +//! The encoder cannot operate without an input message. Reset rejects an encoder with no connected input message. +TEST(Encoder, resetRejectsUnlinkedInputMessage) { + Encoder encoder; + encoder.numRW = 3; + encoder.clicksPerRotation = 2; + + EXPECT_THROW(encoder.reset(0), std::invalid_argument); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 30de6eb123..9d3fdd2979 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -8,13 +8,14 @@ #include #include +#include /*! This method is used to reset the module. @return void */ void Encoder::reset(uint64_t currentSimNanos) { // check if input message is linked - if (!this->rwSpeedInMsg.isLinked()) { bskLogger.bskLog(BSK_ERROR, "encoder.rwSpeedInMsg is not linked."); } + if (!this->rwSpeedInMsg.isLinked()) { throw std::invalid_argument("encoder: rwSpeedInMsg is not linked."); } // if the number of clicks is not greater than 0, throw a warning message if (this->clicksPerRotation <= 0) { From 9fe9e31801525efee4aeb1e360722334846149cb Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:47:32 -0600 Subject: [PATCH 19/36] Replace the public encoder config members with validated setters --- .../encoder/_UnitTest/encoderTestHelpers.hpp | 7 +++-- .../encoder/_UnitTest/test_encoder.cpp | 12 ++++++-- .../deviceInterface/encoder/encoder.cpp | 30 +++++++++++++++---- .../deviceInterface/encoder/encoder.h | 20 +++++++++++-- .../deviceInterface/encoder/encoder.i | 5 ++++ 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp index 4b2ca0d099..6fbc637780 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp @@ -9,6 +9,7 @@ #include +#include #include #include @@ -20,9 +21,9 @@ namespace encodertest { class EncoderHarness { public: //! Make an encoder with the given wheel count and clicks per rotation, then connect its messages. - EncoderHarness(int numRW, int clicksPerRotation) { - this->encoder.numRW = numRW; - this->encoder.clicksPerRotation = clicksPerRotation; + EncoderHarness(std::size_t numRW, std::uint32_t clicksPerRotation) { + this->encoder.setNumRW(numRW); + this->encoder.setClicksPerRotation(clicksPerRotation); this->encoder.rwSpeedInMsg.subscribeTo(&this->speedInMsg); this->speedOut = this->encoder.rwSpeedOutMsg.addSubscriber(); } diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 3db484daec..b4779b2dc5 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -100,8 +100,16 @@ TEST(Encoder, stuckSignalHoldsPreviousSpeed) { //! The encoder cannot operate without an input message. Reset rejects an encoder with no connected input message. TEST(Encoder, resetRejectsUnlinkedInputMessage) { Encoder encoder; - encoder.numRW = 3; - encoder.clicksPerRotation = 2; + encoder.setNumRW(3); + encoder.setClicksPerRotation(2); EXPECT_THROW(encoder.reset(0), std::invalid_argument); } + +//! The setters reject zero for the wheel count and for the clicks per rotation. +TEST(Encoder, settersRejectZero) { + Encoder encoder; + + EXPECT_THROW(encoder.setNumRW(0), std::invalid_argument); + EXPECT_THROW(encoder.setClicksPerRotation(0), std::invalid_argument); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 9d3fdd2979..5458ded089 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -17,13 +17,13 @@ void Encoder::reset(uint64_t currentSimNanos) { // check if input message is linked if (!this->rwSpeedInMsg.isLinked()) { throw std::invalid_argument("encoder: rwSpeedInMsg is not linked."); } - // if the number of clicks is not greater than 0, throw a warning message - if (this->clicksPerRotation <= 0) { + // if the number of clicks is not set, log an error + if (this->clicksPerRotation == 0) { bskLogger.bskLog(BSK_ERROR, "encoder: number of clicks must be a positive integer."); } - // if the number of reaction wheels is not greater than 0, throw a warning message - if (this->numRW <= 0) { + // if the number of reaction wheels is not set, log an error + if (this->numRW == 0) { bskLogger.bskLog( BSK_ERROR, "encoder: number of reaction wheels must be a positive integer. It may not have been set." @@ -64,7 +64,7 @@ void Encoder::writeOutputMessages(uint64_t CurrentClock) { */ void Encoder::encode(uint64_t currentSimNanos) { // convert clicks per rotation to clicks per radian - double const clicksPerRadian = this->clicksPerRotation / (2 * std::numbers::pi); + double const clicksPerRadian = static_cast(this->clicksPerRotation) / (2 * std::numbers::pi); // set the time step double const timeStep = (currentSimNanos - this->prevTime) * NANO2SEC; @@ -74,7 +74,7 @@ void Encoder::encode(uint64_t currentSimNanos) { this->rwSpeedConverted = this->rwSpeedBuffer; } else { // loop through the RW - for (int i = 0; i < this->numRW; i++) { + for (std::size_t i = 0; i < this->numRW; i++) { // check if encoder is operational if (this->rwSignalState[i] == EncoderSignal::Nominal) { // calculate the angle sweeped by the reaction wheel during the time step @@ -119,3 +119,21 @@ void Encoder::updateState(uint64_t currentSimNanos) { this->prevTime = currentSimNanos; } + +void Encoder::setNumRW(std::size_t const numRW) { + if (numRW == 0) { throw std::invalid_argument("encoder: number of reaction wheels must be more than zero."); } + this->numRW = numRW; +} + +std::size_t Encoder::getNumRW() const { + return this->numRW; +} + +void Encoder::setClicksPerRotation(std::uint32_t const clicksPerRotation) { + if (clicksPerRotation == 0) { throw std::invalid_argument("encoder: clicks per rotation must be more than zero."); } + this->clicksPerRotation = clicksPerRotation; +} + +std::uint32_t Encoder::getClicksPerRotation() const { + return this->clicksPerRotation; +} diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 8c7dbfb734..57e70246a9 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -12,6 +12,9 @@ #include +#include +#include + /*! @brief Signal state of one wheel encoder. */ enum class EncoderSignal { Nominal, //!< The encoder operates correctly. @@ -30,15 +33,28 @@ class Encoder : public SysModel { void writeOutputMessages(uint64_t CurrentClock); void encode(uint64_t currentSimNanos); + /*! @brief Set the number of reaction wheels that the encoder reads. + @param numRW number of reaction wheels. Zero is not permitted. + @throws std::invalid_argument if numRW is zero. */ + void setNumRW(std::size_t numRW); + /*! @brief Get the number of reaction wheels that the encoder reads. */ + std::size_t getNumRW() const; + /*! @brief Set the number of encoder clicks in one full wheel rotation. + @param clicksPerRotation number of clicks in one rotation. Zero is not permitted. + @throws std::invalid_argument if clicksPerRotation is zero. */ + void setClicksPerRotation(std::uint32_t clicksPerRotation); + /*! @brief Get the number of encoder clicks in one full wheel rotation. */ + std::uint32_t getClicksPerRotation() const; + public: Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message ReadFunctor rwSpeedInMsg; //!< [rad/s] reaction wheel speed input message EncoderSignal rwSignalState[RW_EFF_CNT]{}; //!< vector of reaction wheel signal states - int clicksPerRotation = -1; //!< number of clicks per full rotation - int numRW = -1; //!< number of reaction wheels BSKLogger bskLogger; //!< -- BSK Logging private: + std::size_t numRW = 0; //!< number of reaction wheels, zero until set + std::uint32_t clicksPerRotation = 0; //!< number of clicks per full rotation, zero until set RWSpeedMsgPayload rwSpeedBuffer{}; //!< reaction wheel speed buffer for internal calculations RWSpeedMsgPayload rwSpeedConverted{}; //!< reaction wheel speed buffer for converted values double remainingClicks[RW_EFF_CNT]{}; //!< remaining clicks from the previous iteration diff --git a/src/simulation/deviceInterface/encoder/encoder.i b/src/simulation/deviceInterface/encoder/encoder.i index de2b2d83a3..d2ebc1b963 100644 --- a/src/simulation/deviceInterface/encoder/encoder.i +++ b/src/simulation/deviceInterface/encoder/encoder.i @@ -23,6 +23,11 @@ %include %include + +%include +%include +%attribute(Encoder, std::size_t, numRW, getNumRW, setNumRW) +%attribute(Encoder, std::uint32_t, clicksPerRotation, getClicksPerRotation, setClicksPerRotation) %include %include "encoder.h" From 06a09b7a8da0a6e59003a6e8543ea70f2fd74c43 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:47:38 -0600 Subject: [PATCH 20/36] Reject a wheel count above RW_EFF_CNT --- .../deviceInterface/encoder/_UnitTest/test_encoder.cpp | 9 +++++++++ src/simulation/deviceInterface/encoder/encoder.cpp | 3 +++ src/simulation/deviceInterface/encoder/encoder.h | 4 ++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index b4779b2dc5..e6efc759b6 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -113,3 +113,12 @@ TEST(Encoder, settersRejectZero) { EXPECT_THROW(encoder.setNumRW(0), std::invalid_argument); EXPECT_THROW(encoder.setClicksPerRotation(0), std::invalid_argument); } + +//! The output message has RW_EFF_CNT wheel slots. The wheel count setter rejects a count that is more than RW_EFF_CNT. +TEST(Encoder, setNumRWRejectsCountAboveRwEffCnt) { + Encoder encoder; + + EXPECT_NO_THROW(encoder.setNumRW(RW_EFF_CNT)); + EXPECT_THROW(encoder.setNumRW(RW_EFF_CNT + 1), std::invalid_argument); + EXPECT_EQ(encoder.getNumRW(), static_cast(RW_EFF_CNT)); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 5458ded089..3c1b7b7fe7 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -122,6 +122,9 @@ void Encoder::updateState(uint64_t currentSimNanos) { void Encoder::setNumRW(std::size_t const numRW) { if (numRW == 0) { throw std::invalid_argument("encoder: number of reaction wheels must be more than zero."); } + if (numRW > RW_EFF_CNT) { + throw std::invalid_argument("encoder: number of reaction wheels must not be more than RW_EFF_CNT."); + } this->numRW = numRW; } diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 57e70246a9..29ad9babb4 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -34,8 +34,8 @@ class Encoder : public SysModel { void encode(uint64_t currentSimNanos); /*! @brief Set the number of reaction wheels that the encoder reads. - @param numRW number of reaction wheels. Zero is not permitted. - @throws std::invalid_argument if numRW is zero. */ + @param numRW number of reaction wheels, from one to RW_EFF_CNT. + @throws std::invalid_argument if numRW is zero or more than RW_EFF_CNT. */ void setNumRW(std::size_t numRW); /*! @brief Get the number of reaction wheels that the encoder reads. */ std::size_t getNumRW() const; From e1c38c8cf8e8948eec5f7c922cd2f269af8fa3c4 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:48:19 -0600 Subject: [PATCH 21/36] Require the wheel count and clicks per rotation in the constructor --- .../encoder/_UnitTest/encoderTestHelpers.hpp | 4 +--- .../encoder/_UnitTest/test_encoder.cpp | 8 +++----- .../encoder/_UnitTest/test_encoder.py | 4 +--- .../deviceInterface/encoder/encoder.cpp | 18 +++++------------- .../deviceInterface/encoder/encoder.h | 10 +++++++--- 5 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp index 6fbc637780..82d51c0328 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp @@ -21,9 +21,7 @@ namespace encodertest { class EncoderHarness { public: //! Make an encoder with the given wheel count and clicks per rotation, then connect its messages. - EncoderHarness(std::size_t numRW, std::uint32_t clicksPerRotation) { - this->encoder.setNumRW(numRW); - this->encoder.setClicksPerRotation(clicksPerRotation); + EncoderHarness(std::size_t numRW, std::uint32_t clicksPerRotation) : encoder(numRW, clicksPerRotation) { this->encoder.rwSpeedInMsg.subscribeTo(&this->speedInMsg); this->speedOut = this->encoder.rwSpeedOutMsg.addSubscriber(); } diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index e6efc759b6..f8178cb381 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -99,16 +99,14 @@ TEST(Encoder, stuckSignalHoldsPreviousSpeed) { //! The encoder cannot operate without an input message. Reset rejects an encoder with no connected input message. TEST(Encoder, resetRejectsUnlinkedInputMessage) { - Encoder encoder; - encoder.setNumRW(3); - encoder.setClicksPerRotation(2); + Encoder encoder(3, 2); EXPECT_THROW(encoder.reset(0), std::invalid_argument); } //! The setters reject zero for the wheel count and for the clicks per rotation. TEST(Encoder, settersRejectZero) { - Encoder encoder; + Encoder encoder(3, 2); EXPECT_THROW(encoder.setNumRW(0), std::invalid_argument); EXPECT_THROW(encoder.setClicksPerRotation(0), std::invalid_argument); @@ -116,7 +114,7 @@ TEST(Encoder, settersRejectZero) { //! The output message has RW_EFF_CNT wheel slots. The wheel count setter rejects a count that is more than RW_EFF_CNT. TEST(Encoder, setNumRWRejectsCountAboveRwEffCnt) { - Encoder encoder; + Encoder encoder(3, 2); EXPECT_NO_THROW(encoder.setNumRW(RW_EFF_CNT)); EXPECT_THROW(encoder.setNumRW(RW_EFF_CNT + 1), std::invalid_argument); diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py index 8ca5ada3d4..337990efe6 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.py @@ -40,10 +40,8 @@ def test_encoder(): speed_payload.wheelSpeeds = [100, 200, 300] speed_msg = messaging.RWSpeedMsg().write(speed_payload) - wheel_speed_encoder = encoder.Encoder() + wheel_speed_encoder = encoder.Encoder(num_rw, 2) wheel_speed_encoder.modelTag = "rwSpeedsEncoder" - wheel_speed_encoder.clicksPerRotation = 2 - wheel_speed_encoder.numRW = num_rw wheel_speed_encoder.rwSpeedInMsg.subscribeTo(speed_msg) sim.AddModelToTask(task_name, wheel_speed_encoder) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 3c1b7b7fe7..c3ba4a1189 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -10,6 +10,11 @@ #include #include +Encoder::Encoder(std::size_t const numRW, std::uint32_t const clicksPerRotation) { + this->setNumRW(numRW); + this->setClicksPerRotation(clicksPerRotation); +} + /*! This method is used to reset the module. @return void */ @@ -17,19 +22,6 @@ void Encoder::reset(uint64_t currentSimNanos) { // check if input message is linked if (!this->rwSpeedInMsg.isLinked()) { throw std::invalid_argument("encoder: rwSpeedInMsg is not linked."); } - // if the number of clicks is not set, log an error - if (this->clicksPerRotation == 0) { - bskLogger.bskLog(BSK_ERROR, "encoder: number of clicks must be a positive integer."); - } - - // if the number of reaction wheels is not set, log an error - if (this->numRW == 0) { - bskLogger.bskLog( - BSK_ERROR, - "encoder: number of reaction wheels must be a positive integer. It may not have been set." - ); - } - // reset the previous time this->prevTime = currentSimNanos; diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 29ad9babb4..5ed940fb0f 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -25,7 +25,11 @@ enum class EncoderSignal { /*! @brief wheel speed encoder module class */ class Encoder : public SysModel { public: - Encoder() = default; + /*! @brief Make an encoder for the given wheel count and resolution. + @param numRW number of reaction wheels, from one to RW_EFF_CNT. + @param clicksPerRotation number of clicks in one rotation. Zero is not permitted. + @throws std::invalid_argument if a parameter is not in its permitted range. */ + Encoder(std::size_t numRW, std::uint32_t clicksPerRotation); void reset(uint64_t currentSimNanos) override; void updateState(uint64_t currentSimNanos) override; @@ -53,8 +57,8 @@ class Encoder : public SysModel { BSKLogger bskLogger; //!< -- BSK Logging private: - std::size_t numRW = 0; //!< number of reaction wheels, zero until set - std::uint32_t clicksPerRotation = 0; //!< number of clicks per full rotation, zero until set + std::size_t numRW = 0; //!< number of reaction wheels + std::uint32_t clicksPerRotation = 0; //!< number of clicks per full rotation RWSpeedMsgPayload rwSpeedBuffer{}; //!< reaction wheel speed buffer for internal calculations RWSpeedMsgPayload rwSpeedConverted{}; //!< reaction wheel speed buffer for converted values double remainingClicks[RW_EFF_CNT]{}; //!< remaining clicks from the previous iteration From 1df44b2eea8e502885141006d98d17a5bbc865d0 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:48:32 -0600 Subject: [PATCH 22/36] Replace the public signal state array with per-wheel accessors --- .../encoder/_UnitTest/test_encoder.cpp | 18 ++++++++--- .../deviceInterface/encoder/encoder.cpp | 32 ++++++++++++++++--- .../deviceInterface/encoder/encoder.h | 22 +++++++++---- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index f8178cb381..38bdd94af6 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -55,7 +55,7 @@ TEST(Encoder, offSignalSendsZeroSpeed) { harness.step(speeds, 0); harness.step(speeds, oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Off; } + for (std::size_t i = 0; i < 3; ++i) { harness.encoder.setSignalState(i, EncoderSignal::Off); } RWSpeedMsgPayload const out = harness.step(speeds, 2 * oneSecond); EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], 0.0); @@ -72,9 +72,9 @@ TEST(Encoder, nominalSignalAfterOffStartsFromZeroClicks) { harness.step(speeds, oneSecond); harness.step(speeds, 2 * oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Off; } + for (std::size_t i = 0; i < 3; ++i) { harness.encoder.setSignalState(i, EncoderSignal::Off); } harness.step(speeds, 3 * oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Nominal; } + for (std::size_t i = 0; i < 3; ++i) { harness.encoder.setSignalState(i, EncoderSignal::Nominal); } RWSpeedMsgPayload const out = harness.step({500.0, 400.0, 300.0}, 4 * oneSecond); EXPECT_NEAR(out.wheelSpeeds[0], 159.0 * pi, tolerance); @@ -89,7 +89,7 @@ TEST(Encoder, stuckSignalHoldsPreviousSpeed) { harness.step({500.0, 400.0, 300.0}, 0); RWSpeedMsgPayload const before = harness.step({500.0, 400.0, 300.0}, oneSecond); - for (int i = 0; i < 3; ++i) { harness.encoder.rwSignalState[i] = EncoderSignal::Stuck; } + for (std::size_t i = 0; i < 3; ++i) { harness.encoder.setSignalState(i, EncoderSignal::Stuck); } RWSpeedMsgPayload const out = harness.step({100.0, 200.0, 300.0}, 2 * oneSecond); EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], before.wheelSpeeds[0]); @@ -120,3 +120,13 @@ TEST(Encoder, setNumRWRejectsCountAboveRwEffCnt) { EXPECT_THROW(encoder.setNumRW(RW_EFF_CNT + 1), std::invalid_argument); EXPECT_EQ(encoder.getNumRW(), static_cast(RW_EFF_CNT)); } + +//! The signal state setter rejects a wheel index that is not less than the wheel count, and a state that is not an +//! EncoderSignal value. +TEST(Encoder, setSignalStateRejectsInvalidInput) { + Encoder encoder(3, 2); + + EXPECT_THROW(encoder.setSignalState(3, EncoderSignal::Off), std::invalid_argument); + EXPECT_THROW(encoder.setSignalState(0, static_cast(7)), std::invalid_argument); + EXPECT_EQ(encoder.getSignalState(0), EncoderSignal::Nominal); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index c3ba4a1189..c68ea89798 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -10,6 +10,17 @@ #include #include +namespace { + bool isKnownSignal(EncoderSignal const state) { + switch (state) { + case EncoderSignal::Nominal: + case EncoderSignal::Off: + case EncoderSignal::Stuck: return true; + } + return false; + } +} // namespace + Encoder::Encoder(std::size_t const numRW, std::uint32_t const clicksPerRotation) { this->setNumRW(numRW); this->setClicksPerRotation(clicksPerRotation); @@ -31,7 +42,7 @@ void Encoder::reset(uint64_t currentSimNanos) { // Loop through the RW to set some internal parameters to default for (int i = 0; i < RW_EFF_CNT; i++) { // set all reaction wheels signal to nominal - this->rwSignalState[i] = EncoderSignal::Nominal; + this->signalStates[i] = EncoderSignal::Nominal; // set the remaining clicks to zero this->remainingClicks[i] = 0.0; } @@ -68,7 +79,7 @@ void Encoder::encode(uint64_t currentSimNanos) { // loop through the RW for (std::size_t i = 0; i < this->numRW; i++) { // check if encoder is operational - if (this->rwSignalState[i] == EncoderSignal::Nominal) { + if (this->signalStates[i] == EncoderSignal::Nominal) { // calculate the angle sweeped by the reaction wheel during the time step double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; @@ -83,19 +94,19 @@ void Encoder::encode(uint64_t currentSimNanos) { this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); } // check if encoder is off - else if (this->rwSignalState[i] == EncoderSignal::Off) { + else if (this->signalStates[i] == EncoderSignal::Off) { // set the outgoing reaction wheel speed to 0 this->rwSpeedConverted.wheelSpeeds[i] = 0.0; // reset the remaining clicks this->remainingClicks[i] = 0; - } else if (this->rwSignalState[i] == EncoderSignal::Stuck) { + } else if (this->signalStates[i] == EncoderSignal::Stuck) { // if the encoder is stuck, it will output the previous results } else { bskLogger.bskLog( BSK_ERROR, "encoder: un-modeled encoder signal mode %d selected.", - static_cast(this->rwSignalState[i]) + static_cast(this->signalStates[i]) ); } } @@ -132,3 +143,14 @@ void Encoder::setClicksPerRotation(std::uint32_t const clicksPerRotation) { std::uint32_t Encoder::getClicksPerRotation() const { return this->clicksPerRotation; } + +void Encoder::setSignalState(std::size_t const wheel, EncoderSignal const state) { + if (wheel >= this->numRW) { throw std::invalid_argument("encoder: wheel index must be less than numRW."); } + if (!isKnownSignal(state)) { throw std::invalid_argument("encoder: signal state is not a known EncoderSignal."); } + this->signalStates[wheel] = state; +} + +EncoderSignal Encoder::getSignalState(std::size_t const wheel) const { + if (wheel >= this->numRW) { throw std::invalid_argument("encoder: wheel index must be less than numRW."); } + return this->signalStates[wheel]; +} diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 5ed940fb0f..33b089c85d 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -12,6 +12,7 @@ #include +#include #include #include @@ -49,19 +50,28 @@ class Encoder : public SysModel { void setClicksPerRotation(std::uint32_t clicksPerRotation); /*! @brief Get the number of encoder clicks in one full wheel rotation. */ std::uint32_t getClicksPerRotation() const; + /*! @brief Set the signal state of one wheel encoder. + @param wheel index of the reaction wheel, less than the wheel count. + @param state signal state of the encoder. + @throws std::invalid_argument if the wheel index or the state is not valid. */ + void setSignalState(std::size_t wheel, EncoderSignal state); + /*! @brief Get the signal state of one wheel encoder. + @param wheel index of the reaction wheel, less than the wheel count. + @throws std::invalid_argument if the wheel index is not valid. */ + EncoderSignal getSignalState(std::size_t wheel) const; public: Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message ReadFunctor rwSpeedInMsg; //!< [rad/s] reaction wheel speed input message - EncoderSignal rwSignalState[RW_EFF_CNT]{}; //!< vector of reaction wheel signal states BSKLogger bskLogger; //!< -- BSK Logging private: - std::size_t numRW = 0; //!< number of reaction wheels - std::uint32_t clicksPerRotation = 0; //!< number of clicks per full rotation - RWSpeedMsgPayload rwSpeedBuffer{}; //!< reaction wheel speed buffer for internal calculations - RWSpeedMsgPayload rwSpeedConverted{}; //!< reaction wheel speed buffer for converted values - double remainingClicks[RW_EFF_CNT]{}; //!< remaining clicks from the previous iteration + std::size_t numRW = 0; //!< number of reaction wheels + std::uint32_t clicksPerRotation = 0; //!< number of clicks per full rotation + std::array signalStates{}; //!< signal state of each wheel encoder + RWSpeedMsgPayload rwSpeedBuffer{}; //!< reaction wheel speed buffer for internal calculations + RWSpeedMsgPayload rwSpeedConverted{}; //!< reaction wheel speed buffer for converted values + double remainingClicks[RW_EFF_CNT]{}; //!< remaining clicks from the previous iteration uint64_t prevTime = 0; //!< -- Previous simulation time observed }; From 66f2859407ca1153b7477d1dfb4808d2eee1fc56 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:51:20 -0600 Subject: [PATCH 23/36] Add a signalStates attribute that sets all wheel signal states at once --- .../encoder/_UnitTest/test_encoder.cpp | 14 +++++++++ .../deviceInterface/encoder/encoder.cpp | 18 ++++++++++++ .../deviceInterface/encoder/encoder.h | 7 +++++ .../deviceInterface/encoder/encoder.i | 29 +++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 38bdd94af6..80cafd3921 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -130,3 +130,17 @@ TEST(Encoder, setSignalStateRejectsInvalidInput) { EXPECT_THROW(encoder.setSignalState(0, static_cast(7)), std::invalid_argument); EXPECT_EQ(encoder.getSignalState(0), EncoderSignal::Nominal); } + +//! The signal states setter rejects a list with a size that is not equal to the wheel count. The encoder keeps its +//! states. +TEST(Encoder, setSignalStatesRejectsWrongSize) { + Encoder encoder(3, 2); + encoder.setSignalStates({EncoderSignal::Off, EncoderSignal::Stuck, EncoderSignal::Nominal}); + + EXPECT_THROW(encoder.setSignalStates(std::vector(4, EncoderSignal::Off)), std::invalid_argument); + EXPECT_THROW(encoder.setSignalStates({EncoderSignal::Off}), std::invalid_argument); + EXPECT_EQ( + encoder.getSignalStates(), + (std::vector{EncoderSignal::Off, EncoderSignal::Stuck, EncoderSignal::Nominal}) + ); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index c68ea89798..ab23c4acfe 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -6,7 +6,9 @@ #include +#include #include +#include #include #include @@ -154,3 +156,19 @@ EncoderSignal Encoder::getSignalState(std::size_t const wheel) const { if (wheel >= this->numRW) { throw std::invalid_argument("encoder: wheel index must be less than numRW."); } return this->signalStates[wheel]; } + +void Encoder::setSignalStates(std::vector const &states) { + if (states.size() != this->numRW) { + throw std::invalid_argument("encoder: number of signal states must be equal to numRW."); + } + for (EncoderSignal const state : states) { + if (!isKnownSignal(state)) { + throw std::invalid_argument("encoder: signal state is not a known EncoderSignal."); + } + } + std::copy(states.begin(), states.end(), this->signalStates.begin()); +} + +std::vector Encoder::getSignalStates() const { + return {this->signalStates.begin(), this->signalStates.begin() + static_cast(this->numRW)}; +} diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 33b089c85d..086d24a781 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -15,6 +15,7 @@ #include #include #include +#include /*! @brief Signal state of one wheel encoder. */ enum class EncoderSignal { @@ -59,6 +60,12 @@ class Encoder : public SysModel { @param wheel index of the reaction wheel, less than the wheel count. @throws std::invalid_argument if the wheel index is not valid. */ EncoderSignal getSignalState(std::size_t wheel) const; + /*! @brief Set the signal states of all wheel encoders. + @param states one signal state for each reaction wheel. The size must be equal to the wheel count. + @throws std::invalid_argument if the size or a state is not valid. The encoder keeps its states. */ + void setSignalStates(std::vector const &states); + /*! @brief Get the signal states of all wheel encoders, one for each reaction wheel. */ + std::vector getSignalStates() const; public: Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message diff --git a/src/simulation/deviceInterface/encoder/encoder.i b/src/simulation/deviceInterface/encoder/encoder.i index d2ebc1b963..918b545e57 100644 --- a/src/simulation/deviceInterface/encoder/encoder.i +++ b/src/simulation/deviceInterface/encoder/encoder.i @@ -28,6 +28,35 @@ %include %attribute(Encoder, std::size_t, numRW, getNumRW, setNumRW) %attribute(Encoder, std::uint32_t, clicksPerRotation, getClicksPerRotation, setClicksPerRotation) + +%include + +// These traits change an EncoderSignal to and from a Python integer. +// Thus SWIG can change a Python list to a std::vector. +%fragment(SWIG_Traits_frag(EncoderSignal), "header", fragment="StdTraits", fragment=SWIG_AsVal_frag(long), fragment=SWIG_From_frag(long)) { +namespace swig { + template <> struct traits { + typedef value_category category; + static const char* type_name() { return "EncoderSignal"; } + }; + template <> struct traits_asval { + typedef EncoderSignal value_type; + static int asval(PyObject* obj, value_type* val) { + long value = 0; + int const result = SWIG_AsVal(long)(obj, &value); + if (SWIG_IsOK(result) && val) { *val = static_cast(value); } + return result; + } + }; + template <> struct traits_from { + static PyObject* from(EncoderSignal const& value) { return SWIG_From(long)(static_cast(value)); } + }; +} +} +%template(EncoderSignalVector) std::vector; +%naturalvar Encoder::signalStates; +%attributestring(Encoder, std::vector, signalStates, getSignalStates, setSignalStates) + %include %include "encoder.h" From 10d90fbab4516e6a3ce9f5ece77c91be1e4e54de Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:51:49 -0600 Subject: [PATCH 24/36] Remove the per-step error for signal states the setters now reject --- .../deviceInterface/encoder/encoder.cpp | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index ab23c4acfe..d10777dcf9 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -80,8 +80,8 @@ void Encoder::encode(uint64_t currentSimNanos) { } else { // loop through the RW for (std::size_t i = 0; i < this->numRW; i++) { - // check if encoder is operational - if (this->signalStates[i] == EncoderSignal::Nominal) { + switch (this->signalStates[i]) { + case EncoderSignal::Nominal: { // calculate the angle sweeped by the reaction wheel during the time step double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; @@ -94,22 +94,16 @@ void Encoder::encode(uint64_t currentSimNanos) { // calculate the discretized angular velocity this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); + break; } - // check if encoder is off - else if (this->signalStates[i] == EncoderSignal::Off) { - // set the outgoing reaction wheel speed to 0 + case EncoderSignal::Off: + // set the outgoing reaction wheel speed to 0 and reset the remaining clicks this->rwSpeedConverted.wheelSpeeds[i] = 0.0; - - // reset the remaining clicks - this->remainingClicks[i] = 0; - } else if (this->signalStates[i] == EncoderSignal::Stuck) { + this->remainingClicks[i] = 0.0; + break; + case EncoderSignal::Stuck: // if the encoder is stuck, it will output the previous results - } else { - bskLogger.bskLog( - BSK_ERROR, - "encoder: un-modeled encoder signal mode %d selected.", - static_cast(this->signalStates[i]) - ); + break; } } } From 6c0f50af0c202f94ea63a9fb93e67502b4b6e414 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:51:55 -0600 Subject: [PATCH 25/36] Remove the unused encoder logger --- src/simulation/deviceInterface/encoder/encoder.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index 086d24a781..b8aa3d6c2d 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -8,7 +8,6 @@ #include #include #include -#include #include @@ -70,7 +69,6 @@ class Encoder : public SysModel { public: Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message ReadFunctor rwSpeedInMsg; //!< [rad/s] reaction wheel speed input message - BSKLogger bskLogger; //!< -- BSK Logging private: std::size_t numRW = 0; //!< number of reaction wheels From f19226f01b11b1330cae35df6237f6bef2d229c6 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:52:14 -0600 Subject: [PATCH 26/36] Keep the configured signal states across reset --- .../encoder/_UnitTest/test_encoder.cpp | 12 ++++++++++++ src/simulation/deviceInterface/encoder/encoder.cpp | 9 ++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 80cafd3921..ef3565f25d 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -144,3 +144,15 @@ TEST(Encoder, setSignalStatesRejectsWrongSize) { (std::vector{EncoderSignal::Off, EncoderSignal::Stuck, EncoderSignal::Nominal}) ); } + +//! Reset keeps the signal states that the user sets before the simulation starts. +TEST(Encoder, resetKeepsConfiguredSignalStates) { + EncoderHarness harness(3, 2); + harness.encoder.setSignalStates({EncoderSignal::Off, EncoderSignal::Stuck, EncoderSignal::Nominal}); + + harness.encoder.reset(0); + + EXPECT_EQ(harness.encoder.getSignalState(0), EncoderSignal::Off); + EXPECT_EQ(harness.encoder.getSignalState(1), EncoderSignal::Stuck); + EXPECT_EQ(harness.encoder.getSignalState(2), EncoderSignal::Nominal); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index d10777dcf9..9742c4d687 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -41,13 +41,8 @@ void Encoder::reset(uint64_t currentSimNanos) { // zero the RW wheel output message buffer // this->rwSpeedConverted = RWSpeedMsgPayload{}; - // Loop through the RW to set some internal parameters to default - for (int i = 0; i < RW_EFF_CNT; i++) { - // set all reaction wheels signal to nominal - this->signalStates[i] = EncoderSignal::Nominal; - // set the remaining clicks to zero - this->remainingClicks[i] = 0.0; - } + // set the remaining clicks to zero, and keep the configured signal states + for (double &clicks : this->remainingClicks) { clicks = 0.0; } } /*! This method reads the speed input message From 589523ab4130da548ac961f032596e3a4e0eed85 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:52:37 -0600 Subject: [PATCH 27/36] Pass the wheel thetas through the encoder on every step --- .../encoder/_UnitTest/test_encoder.cpp | 20 +++++++++++++++++++ .../deviceInterface/encoder/encoder.cpp | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index ef3565f25d..d169d97973 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -156,3 +156,23 @@ TEST(Encoder, resetKeepsConfiguredSignalStates) { EXPECT_EQ(harness.encoder.getSignalState(1), EncoderSignal::Stuck); EXPECT_EQ(harness.encoder.getSignalState(2), EncoderSignal::Nominal); } + +//! The encoder does not measure the wheel angles. It sends the input wheel angles unchanged on each step. +TEST(Encoder, sendsWheelThetasOnEachStep) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + harness.step({100.0, 200.0, 300.0}, 0); + + RWSpeedMsgPayload payload{}; + payload.wheelSpeeds[0] = 100.0; + payload.wheelThetas[0] = 0.25; + payload.wheelThetas[1] = -1.5; + payload.wheelThetas[2] = 3.0; + harness.speedInMsg.write(payload, 0, oneSecond); + harness.encoder.updateState(oneSecond); + RWSpeedMsgPayload const out = harness.speedOut(); + + EXPECT_DOUBLE_EQ(out.wheelThetas[0], 0.25); + EXPECT_DOUBLE_EQ(out.wheelThetas[1], -1.5); + EXPECT_DOUBLE_EQ(out.wheelThetas[2], 3.0); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 9742c4d687..3156eb5bca 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -69,6 +70,13 @@ void Encoder::encode(uint64_t currentSimNanos) { // set the time step double const timeStep = (currentSimNanos - this->prevTime) * NANO2SEC; + // the encoder does not measure the wheel angles, so send them unchanged + std::copy( + std::begin(this->rwSpeedBuffer.wheelThetas), + std::end(this->rwSpeedBuffer.wheelThetas), + std::begin(this->rwSpeedConverted.wheelThetas) + ); + // at the beginning of the simulation, the encoder simply outputs the true RW speeds if (timeStep == 0.0) { this->rwSpeedConverted = this->rwSpeedBuffer; From 278d9b4b76a3fcbb89e856d745638911fe25ef7e Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:53:06 -0600 Subject: [PATCH 28/36] Apply the signal state on steps with a zero time step --- .../encoder/_UnitTest/test_encoder.cpp | 13 ++++ .../deviceInterface/encoder/encoder.cpp | 59 ++++++++++--------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index d169d97973..04d166d61f 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -176,3 +176,16 @@ TEST(Encoder, sendsWheelThetasOnEachStep) { EXPECT_DOUBLE_EQ(out.wheelThetas[1], -1.5); EXPECT_DOUBLE_EQ(out.wheelThetas[2], 3.0); } + +//! The signal state also applies on a step with a zero time step. An off encoder sends zero speed on the first step. +TEST(Encoder, offSignalAppliesOnFirstStep) { + EncoderHarness harness(3, 2); + harness.encoder.setSignalState(1, EncoderSignal::Off); + harness.encoder.reset(0); + + RWSpeedMsgPayload const out = harness.step({100.0, 200.0, 300.0}, 0); + + EXPECT_DOUBLE_EQ(out.wheelSpeeds[0], 100.0); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], 0.0); + EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 300.0); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 3156eb5bca..db153c3657 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -77,37 +77,38 @@ void Encoder::encode(uint64_t currentSimNanos) { std::begin(this->rwSpeedConverted.wheelThetas) ); - // at the beginning of the simulation, the encoder simply outputs the true RW speeds - if (timeStep == 0.0) { - this->rwSpeedConverted = this->rwSpeedBuffer; - } else { - // loop through the RW - for (std::size_t i = 0; i < this->numRW; i++) { - switch (this->signalStates[i]) { - case EncoderSignal::Nominal: { - // calculate the angle sweeped by the reaction wheel during the time step - double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; - - // calculate the number of clicks - double const totalClicks = angle * clicksPerRadian + this->remainingClicks[i]; - double const numberClicks = std::trunc(totalClicks); - - // update the remaining clicks - this->remainingClicks[i] = totalClicks - numberClicks; - - // calculate the discretized angular velocity - this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); - break; - } - case EncoderSignal::Off: - // set the outgoing reaction wheel speed to 0 and reset the remaining clicks - this->rwSpeedConverted.wheelSpeeds[i] = 0.0; - this->remainingClicks[i] = 0.0; - break; - case EncoderSignal::Stuck: - // if the encoder is stuck, it will output the previous results + // loop through the RW + for (std::size_t i = 0; i < this->numRW; i++) { + switch (this->signalStates[i]) { + case EncoderSignal::Nominal: { + // with a zero time step there are no clicks to count, so the encoder outputs the true RW speed + if (timeStep == 0.0) { + this->rwSpeedConverted.wheelSpeeds[i] = this->rwSpeedBuffer.wheelSpeeds[i]; break; } + + // calculate the angle sweeped by the reaction wheel during the time step + double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; + + // calculate the number of clicks + double const totalClicks = angle * clicksPerRadian + this->remainingClicks[i]; + double const numberClicks = std::trunc(totalClicks); + + // update the remaining clicks + this->remainingClicks[i] = totalClicks - numberClicks; + + // calculate the discretized angular velocity + this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); + break; + } + case EncoderSignal::Off: + // set the outgoing reaction wheel speed to 0 and reset the remaining clicks + this->rwSpeedConverted.wheelSpeeds[i] = 0.0; + this->remainingClicks[i] = 0.0; + break; + case EncoderSignal::Stuck: + // if the encoder is stuck, it will output the previous results + break; } } } From 1aae2a8bca9294b296bd65cde8aa57b39fa104b0 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 16:22:59 -0600 Subject: [PATCH 29/36] Set the output of unused wheels to zero when the wheel count changes --- .../encoder/_UnitTest/test_encoder.cpp | 20 +++++++++++++++++++ .../deviceInterface/encoder/encoder.cpp | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 04d166d61f..4c3fd7b320 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -189,3 +189,23 @@ TEST(Encoder, offSignalAppliesOnFirstStep) { EXPECT_DOUBLE_EQ(out.wheelSpeeds[1], 0.0); EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 300.0); } + +//! When the wheel count decreases, the wheels that the encoder does not read send zero speed. When the wheel count +//! increases again, the count of these wheels starts from zero clicks. +TEST(Encoder, setNumRWSetsUnusedWheelsToZero) { + EncoderHarness harness(3, 2); + harness.encoder.reset(0); + std::vector const speeds{100.0, 200.0, 300.0}; + harness.step(speeds, 0); + harness.step(speeds, oneSecond); + + harness.encoder.setNumRW(1); + RWSpeedMsgPayload const reduced = harness.step(speeds, 2 * oneSecond); + harness.encoder.setNumRW(3); + RWSpeedMsgPayload const restored = harness.step(speeds, 3 * oneSecond); + + EXPECT_DOUBLE_EQ(reduced.wheelSpeeds[1], 0.0); + EXPECT_DOUBLE_EQ(reduced.wheelSpeeds[2], 0.0); + EXPECT_NEAR(restored.wheelSpeeds[1], 63.0 * pi, tolerance); + EXPECT_NEAR(restored.wheelSpeeds[2], 95.0 * pi, tolerance); +} diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index db153c3657..bbeac1cad7 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -129,6 +129,12 @@ void Encoder::setNumRW(std::size_t const numRW) { throw std::invalid_argument("encoder: number of reaction wheels must not be more than RW_EFF_CNT."); } this->numRW = numRW; + + // The wheels that the encoder does not read send zero speed and have no remaining part of a click. + for (std::size_t i = numRW; i < RW_EFF_CNT; ++i) { + this->rwSpeedConverted.wheelSpeeds[i] = 0.0; + this->remainingClicks[i] = 0.0; + } } std::size_t Encoder::getNumRW() const { From 61e08b8e9de19affe1b2f156e97776d9fab3e2a9 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:55:26 -0600 Subject: [PATCH 30/36] Add encoder fuzz target with an angle conservation property --- .../encoder/_UnitTest/CMakeLists.txt | 20 +++++ .../encoder/_UnitTest/test_encoder_fuzz.cpp | 74 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt b/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt index 0a47461756..54436c9890 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt +++ b/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt @@ -11,3 +11,23 @@ target_link_libraries(test_encoder PRIVATE ArchitectureUtilities ) gtest_discover_tests(test_encoder) + +if(XMERA_ENABLE_FUZZTESTS) + fuzztest_setup_fuzzing_flags() + + add_executable(test_encoder_fuzz + ../encoder.cpp + test_encoder_fuzz.cpp + ) + target_include_directories(test_encoder_fuzz PRIVATE "${CMAKE_SOURCE_DIR}") + target_link_libraries(test_encoder_fuzz PRIVATE + Xmera::Core + ArchitectureUtilities + fuzztest::fuzztest + fuzztest::fuzztest_gtest_main + ) + gtest_discover_tests(test_encoder_fuzz + PROPERTIES + LABELS "fuzz;fuzz-smoke" + ) +endif() diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp new file mode 100644 index 0000000000..819616a331 --- /dev/null +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: ISC +// Copyright (c) 2026, Laboratory for Atmospheric and Space Physics, University of Colorado at Boulder + +#include "encoderTestHelpers.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + using encodertest::EncoderHarness; + + constexpr std::size_t maxFuzzWheels = 4; + constexpr std::size_t maxFuzzSteps = 50; + constexpr double maxWheelSpeed = 1000.0; // [rad/s] + constexpr uint64_t minTimeStep = 1'000'000ULL; // [ns] 1 ms + constexpr uint64_t maxTimeStep = 10'000'000'000ULL; // [ns] 10 s + + //! One step of fuzz input: the speed of all wheels and the time step in nanoseconds. + using SpeedStep = std::pair; + + auto speedSteps() { + return fuzztest::VectorOf( + fuzztest::PairOf( + fuzztest::InRange(-maxWheelSpeed, maxWheelSpeed), + fuzztest::InRange(minTimeStep, maxTimeStep) + ) + ) + .WithMaxSize(maxFuzzSteps); + } + + //! For nominal encoders, the angle that the encoder output shows and the true wheel angle differ by less than one + //! click. The encoder keeps the remaining part of a click for the next step, so the error does not increase. + void angleErrorStaysBelowOneClick( + std::size_t numRW, + std::uint32_t clicksPerRotation, + std::vector const &steps + ) { + EncoderHarness harness(numRW, clicksPerRotation); + harness.encoder.reset(0); + harness.step(std::vector(numRW, 0.0), 0); + + double const clickAngle = 2.0 * std::numbers::pi / clicksPerRotation; + std::vector trueAngle(numRW, 0.0); + std::vector encodedAngle(numRW, 0.0); + double angleScale = 1.0; + uint64_t t = 0; + for (auto const &[speed, timeStepNanos] : steps) { + t += timeStepNanos; + double const timeStep = static_cast(timeStepNanos) * 1.0e-9; + RWSpeedMsgPayload const out = harness.step(std::vector(numRW, speed), t); + angleScale += std::abs(speed * timeStep); + for (std::size_t i = 0; i < numRW; ++i) { + ASSERT_TRUE(std::isfinite(out.wheelSpeeds[i])); + trueAngle[i] += speed * timeStep; + encodedAngle[i] += out.wheelSpeeds[i] * timeStep; + EXPECT_LT(std::abs(encodedAngle[i] - trueAngle[i]), clickAngle + 1.0e-12 * angleScale); + } + } + } + + FUZZ_TEST(EncoderFuzz, angleErrorStaysBelowOneClick) + .WithDomains( + fuzztest::InRange(1, maxFuzzWheels), + fuzztest::InRange(1, 4'096), + speedSteps() + ); +} // namespace From f7f57116803470436a3c1ddb1fa3f6abe4ca9881 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:55:48 -0600 Subject: [PATCH 31/36] Add fuzz property for the signal state effects --- .../encoder/_UnitTest/test_encoder_fuzz.cpp | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp index 819616a331..aedcca01d3 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -71,4 +72,46 @@ namespace { fuzztest::InRange(1, 4'096), speedSteps() ); + + //! One step of fuzz input for the signal state property: the speed of all wheels, the time step in nanoseconds, + //! and the signal state of each wheel as an integer. + using SignalStep = std::tuple>; + + //! An off encoder sends zero speed. A stuck encoder sends the speed of the previous step. All output speeds are + //! finite for all sequences of signal states. + void signalStatesHaveTheirEffect(std::vector const &steps) { + std::size_t const numRW = maxFuzzWheels; + EncoderHarness harness(numRW, 64); + harness.encoder.reset(0); + RWSpeedMsgPayload previous = harness.step(std::vector(numRW, 0.0), 0); + + uint64_t t = 0; + for (auto const &[speed, timeStepNanos, states] : steps) { + t += timeStepNanos; + for (std::size_t i = 0; i < numRW; ++i) { + harness.encoder.setSignalState(i, static_cast(states[i])); + } + RWSpeedMsgPayload const out = harness.step(std::vector(numRW, speed), t); + for (std::size_t i = 0; i < numRW; ++i) { + ASSERT_TRUE(std::isfinite(out.wheelSpeeds[i])); + if (states[i] == static_cast(EncoderSignal::Off)) { EXPECT_EQ(out.wheelSpeeds[i], 0.0); } + if (states[i] == static_cast(EncoderSignal::Stuck)) { + EXPECT_EQ(out.wheelSpeeds[i], previous.wheelSpeeds[i]); + } + } + previous = out; + } + } + + FUZZ_TEST(EncoderFuzz, signalStatesHaveTheirEffect) + .WithDomains( + fuzztest::VectorOf( + fuzztest::TupleOf( + fuzztest::InRange(-maxWheelSpeed, maxWheelSpeed), + fuzztest::InRange(minTimeStep, maxTimeStep), + fuzztest::VectorOf(fuzztest::InRange(0, 2)).WithSize(maxFuzzWheels) + ) + ) + .WithMaxSize(maxFuzzSteps) + ); } // namespace From 529e12752f45a0fa961a0f0013be860a4dafafd7 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:56:12 -0600 Subject: [PATCH 32/36] Add fuzz property that wheels beyond numRW stay zero --- .../encoder/_UnitTest/test_encoder_fuzz.cpp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp index aedcca01d3..1901ae8bbb 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp @@ -114,4 +114,28 @@ namespace { ) .WithMaxSize(maxFuzzSteps) ); + + //! The encoder writes only the speeds of the first numRW wheels. The speeds of the other wheels in the output + //! message stay zero, also on the first step. + void + unusedWheelsStayZero(std::size_t numRW, std::vector const &speeds, std::vector const &timeSteps) { + EncoderHarness harness(numRW, 64); + harness.encoder.reset(0); + + uint64_t t = 0; + RWSpeedMsgPayload out = harness.step(speeds, t); + for (std::size_t i = numRW; i < RW_EFF_CNT; ++i) { EXPECT_EQ(out.wheelSpeeds[i], 0.0); } + for (uint64_t const timeStepNanos : timeSteps) { + t += timeStepNanos; + out = harness.step(speeds, t); + for (std::size_t i = numRW; i < RW_EFF_CNT; ++i) { EXPECT_EQ(out.wheelSpeeds[i], 0.0); } + } + } + + FUZZ_TEST(EncoderFuzz, unusedWheelsStayZero) + .WithDomains( + fuzztest::InRange(1, RW_EFF_CNT - 1), + fuzztest::VectorOf(fuzztest::InRange(-maxWheelSpeed, maxWheelSpeed)).WithSize(RW_EFF_CNT), + fuzztest::VectorOf(fuzztest::InRange(minTimeStep, maxTimeStep)).WithMaxSize(maxFuzzSteps) + ); } // namespace From a8085682a92bdb99a529877f150355485f06d974 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:57:35 -0600 Subject: [PATCH 33/36] Add Doxygen comments to the encoder public API --- .../deviceInterface/encoder/encoder.cpp | 17 +----- .../deviceInterface/encoder/encoder.h | 54 ++++++++++++------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index bbeac1cad7..4beda441ea 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -29,9 +29,6 @@ Encoder::Encoder(std::size_t const numRW, std::uint32_t const clicksPerRotation) this->setClicksPerRotation(clicksPerRotation); } -/*! This method is used to reset the module. - @return void - */ void Encoder::reset(uint64_t currentSimNanos) { // check if input message is linked if (!this->rwSpeedInMsg.isLinked()) { throw std::invalid_argument("encoder: rwSpeedInMsg is not linked."); } @@ -46,23 +43,15 @@ void Encoder::reset(uint64_t currentSimNanos) { for (double &clicks : this->remainingClicks) { clicks = 0.0; } } -/*! This method reads the speed input message - */ void Encoder::readInputMessages() { // read the incoming wheel speed message this->rwSpeedBuffer = this->rwSpeedInMsg(); } -/*! This method writes encoded the wheel speed message. - @return void - @param CurrentClock The clock time associated with the model call - */ -void Encoder::writeOutputMessages(uint64_t CurrentClock) { - this->rwSpeedOutMsg.write(this->rwSpeedConverted, this->moduleID, CurrentClock); +void Encoder::writeOutputMessages(uint64_t currentClock) { + this->rwSpeedOutMsg.write(this->rwSpeedConverted, this->moduleID, currentClock); } -/*! This method applies an encoder to the reaction wheel speeds. - */ void Encoder::encode(uint64_t currentSimNanos) { // convert clicks per rotation to clicks per radian double const clicksPerRadian = static_cast(this->clicksPerRotation) / (2 * std::numbers::pi); @@ -113,8 +102,6 @@ void Encoder::encode(uint64_t currentSimNanos) { } } -/*! This method runs the encoder module in the sim. - */ void Encoder::updateState(uint64_t currentSimNanos) { this->readInputMessages(); this->encode(currentSimNanos); diff --git a/src/simulation/deviceInterface/encoder/encoder.h b/src/simulation/deviceInterface/encoder/encoder.h index b8aa3d6c2d..7c58e1a5a6 100644 --- a/src/simulation/deviceInterface/encoder/encoder.h +++ b/src/simulation/deviceInterface/encoder/encoder.h @@ -23,62 +23,76 @@ enum class EncoderSignal { Stuck //!< The encoder sends the speed of the previous step. }; -/*! @brief wheel speed encoder module class */ +/*! @brief Reaction wheel speed encoder. + + The encoder counts the clicks of each wheel in each time step. It sends the wheel speed that agrees with that + count. It also simulates encoder failures with a signal state for each wheel. */ class Encoder : public SysModel { public: - /*! @brief Make an encoder for the given wheel count and resolution. + /*! @brief Makes an encoder for the given wheel count and resolution. All signal states are nominal. @param numRW number of reaction wheels, from one to RW_EFF_CNT. @param clicksPerRotation number of clicks in one rotation. Zero is not permitted. @throws std::invalid_argument if a parameter is not in its permitted range. */ Encoder(std::size_t numRW, std::uint32_t clicksPerRotation); + /*! @brief Sets the remaining clicks and the output speeds to zero. The signal states do not change. + @param currentSimNanos [ns] simulation time + @throws std::invalid_argument if rwSpeedInMsg is not linked. */ void reset(uint64_t currentSimNanos) override; + /*! @brief Reads the input message, calculates the encoder output, and writes the output message. + @param currentSimNanos [ns] simulation time */ void updateState(uint64_t currentSimNanos) override; + /*! @brief Reads the reaction wheel speed input message. */ void readInputMessages(); - void writeOutputMessages(uint64_t CurrentClock); + /*! @brief Writes the encoder output speeds to the output message. + @param currentClock [ns] simulation time */ + void writeOutputMessages(uint64_t currentClock); + /*! @brief Calculates the encoder output speeds from the input speeds and the signal states. + @param currentSimNanos [ns] simulation time */ void encode(uint64_t currentSimNanos); - /*! @brief Set the number of reaction wheels that the encoder reads. + /*! @brief Sets the number of reaction wheels that the encoder reads. The output speeds and the remaining clicks of + the other wheels change to zero. @param numRW number of reaction wheels, from one to RW_EFF_CNT. @throws std::invalid_argument if numRW is zero or more than RW_EFF_CNT. */ void setNumRW(std::size_t numRW); - /*! @brief Get the number of reaction wheels that the encoder reads. */ + /*! @brief Gets the number of reaction wheels that the encoder reads. */ std::size_t getNumRW() const; - /*! @brief Set the number of encoder clicks in one full wheel rotation. + /*! @brief Sets the number of encoder clicks in one full wheel rotation. @param clicksPerRotation number of clicks in one rotation. Zero is not permitted. @throws std::invalid_argument if clicksPerRotation is zero. */ void setClicksPerRotation(std::uint32_t clicksPerRotation); - /*! @brief Get the number of encoder clicks in one full wheel rotation. */ + /*! @brief Gets the number of encoder clicks in one full wheel rotation. */ std::uint32_t getClicksPerRotation() const; - /*! @brief Set the signal state of one wheel encoder. + /*! @brief Sets the signal state of one wheel encoder. @param wheel index of the reaction wheel, less than the wheel count. @param state signal state of the encoder. - @throws std::invalid_argument if the wheel index or the state is not valid. */ + @throws std::invalid_argument if the wheel index or the state is incorrect. */ void setSignalState(std::size_t wheel, EncoderSignal state); - /*! @brief Get the signal state of one wheel encoder. + /*! @brief Gets the signal state of one wheel encoder. @param wheel index of the reaction wheel, less than the wheel count. - @throws std::invalid_argument if the wheel index is not valid. */ + @throws std::invalid_argument if the wheel index is incorrect. */ EncoderSignal getSignalState(std::size_t wheel) const; - /*! @brief Set the signal states of all wheel encoders. + /*! @brief Sets the signal states of all wheel encoders. @param states one signal state for each reaction wheel. The size must be equal to the wheel count. - @throws std::invalid_argument if the size or a state is not valid. The encoder keeps its states. */ + @throws std::invalid_argument if the size or a state is incorrect. The encoder keeps its states. */ void setSignalStates(std::vector const &states); - /*! @brief Get the signal states of all wheel encoders, one for each reaction wheel. */ + /*! @brief Gets the signal states of all wheel encoders, one for each reaction wheel. */ std::vector getSignalStates() const; public: - Message rwSpeedOutMsg; //!< [rad/s] reaction wheel speed output message - ReadFunctor rwSpeedInMsg; //!< [rad/s] reaction wheel speed input message + Message rwSpeedOutMsg; //!< [rad/s] encoder output wheel speeds + ReadFunctor rwSpeedInMsg; //!< [rad/s] input wheel speeds private: std::size_t numRW = 0; //!< number of reaction wheels std::uint32_t clicksPerRotation = 0; //!< number of clicks per full rotation std::array signalStates{}; //!< signal state of each wheel encoder - RWSpeedMsgPayload rwSpeedBuffer{}; //!< reaction wheel speed buffer for internal calculations - RWSpeedMsgPayload rwSpeedConverted{}; //!< reaction wheel speed buffer for converted values - double remainingClicks[RW_EFF_CNT]{}; //!< remaining clicks from the previous iteration + RWSpeedMsgPayload rwSpeedBuffer{}; //!< [rad/s] input wheel speeds of this step + RWSpeedMsgPayload rwSpeedConverted{}; //!< [rad/s] encoder output wheel speeds + double remainingClicks[RW_EFF_CNT]{}; //!< remaining part of a click from the previous step - uint64_t prevTime = 0; //!< -- Previous simulation time observed + uint64_t prevTime = 0; //!< [ns] simulation time of the previous step }; #endif From daf6cc8d24db8afec11052a3adf555aa921e32e6 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:58:14 -0600 Subject: [PATCH 34/36] Simplify the language of the encoder comments and docs --- .../encoder/_UnitTest/CMakeLists.txt | 4 +-- .../encoder/_UnitTest/encoderTestHelpers.hpp | 6 ++-- .../encoder/_UnitTest/test_encoder.cpp | 9 +++--- .../encoder/_UnitTest/test_encoder_fuzz.cpp | 4 +-- .../deviceInterface/encoder/encoder.cpp | 32 +++++++++---------- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt b/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt index 54436c9890..3d6569d52b 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt +++ b/src/simulation/deviceInterface/encoder/_UnitTest/CMakeLists.txt @@ -1,5 +1,5 @@ -# The encoder .cpp is compiled only into the SWIG Python module (not a linkable -# C++ library), so pull the source in directly here. +# The SWIG Python module is the only target that compiles encoder.cpp. The tests +# cannot link to it as a C++ library. Thus the tests compile encoder.cpp directly. add_executable(test_encoder ../encoder.cpp test_encoder.cpp diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp index 82d51c0328..c0a6c961ed 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/encoderTestHelpers.hpp @@ -20,7 +20,7 @@ namespace encodertest { //! This harness connects an encoder to an input speed message and an output reader. class EncoderHarness { public: - //! Make an encoder with the given wheel count and clicks per rotation, then connect its messages. + //! Makes an encoder with the given wheel count and clicks per rotation, and connects its messages. EncoderHarness(std::size_t numRW, std::uint32_t clicksPerRotation) : encoder(numRW, clicksPerRotation) { this->encoder.rwSpeedInMsg.subscribeTo(&this->speedInMsg); this->speedOut = this->encoder.rwSpeedOutMsg.addSubscriber(); @@ -29,7 +29,7 @@ namespace encodertest { EncoderHarness(EncoderHarness const &) = delete; EncoderHarness &operator=(EncoderHarness const &) = delete; - //! Write the wheel speeds to the input message, update the encoder at time t, and read the output. + //! Writes the wheel speeds to the input message, updates the encoder at time t, and reads the output. RWSpeedMsgPayload step(std::vector const &wheelSpeeds, uint64_t t) { RWSpeedMsgPayload payload{}; for (std::size_t i = 0; i < wheelSpeeds.size(); ++i) { payload.wheelSpeeds[i] = wheelSpeeds[i]; } @@ -38,7 +38,7 @@ namespace encodertest { return this->speedOut(); } - Encoder encoder; //!< encoder under test + Encoder encoder; //!< encoder that the tests examine Message speedInMsg; //!< input wheel speed message ReadFunctor speedOut; //!< reader of the encoder output message }; diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp index 4c3fd7b320..97746becc0 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder.cpp @@ -16,7 +16,7 @@ namespace { constexpr double tolerance = 1e-9; } // namespace -//! On the first step the time step is zero, so the encoder sends the true wheel speeds. +//! On the first step, the time step is zero. Thus the encoder sends the input wheel speeds. TEST(Encoder, firstStepSendsTrueSpeeds) { EncoderHarness harness(3, 2); harness.encoder.reset(0); @@ -28,8 +28,8 @@ TEST(Encoder, firstStepSendsTrueSpeeds) { EXPECT_DOUBLE_EQ(out.wheelSpeeds[2], 300.0); } -//! With two clicks per rotation, each output speed is a whole number of clicks times pi rad/s. -//! The encoder keeps the fraction of a click that remains and adds it to the next step. +//! With two clicks per rotation, each output speed is pi rad/s multiplied by an integer number of clicks. +//! The encoder keeps the remaining part of a click and adds it to the next step. TEST(Encoder, quantizesSpeedOverConsecutiveSteps) { EncoderHarness harness(3, 2); harness.encoder.reset(0); @@ -177,7 +177,8 @@ TEST(Encoder, sendsWheelThetasOnEachStep) { EXPECT_DOUBLE_EQ(out.wheelThetas[2], 3.0); } -//! The signal state also applies on a step with a zero time step. An off encoder sends zero speed on the first step. +//! The encoder also uses the signal state on a step with a zero time step. An off encoder sends zero speed on the +//! first step. TEST(Encoder, offSignalAppliesOnFirstStep) { EncoderHarness harness(3, 2); harness.encoder.setSignalState(1, EncoderSignal::Off); diff --git a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp index 1901ae8bbb..b015b3f691 100644 --- a/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp +++ b/src/simulation/deviceInterface/encoder/_UnitTest/test_encoder_fuzz.cpp @@ -36,8 +36,8 @@ namespace { .WithMaxSize(maxFuzzSteps); } - //! For nominal encoders, the angle that the encoder output shows and the true wheel angle differ by less than one - //! click. The encoder keeps the remaining part of a click for the next step, so the error does not increase. + //! For nominal encoders, the difference between the encoder output angle and the input wheel angle is less than one + //! click. The encoder keeps the remaining part of a click for the next step. Thus the error does not increase. void angleErrorStaysBelowOneClick( std::size_t numRW, std::uint32_t clicksPerRotation, diff --git a/src/simulation/deviceInterface/encoder/encoder.cpp b/src/simulation/deviceInterface/encoder/encoder.cpp index 4beda441ea..369455c79e 100644 --- a/src/simulation/deviceInterface/encoder/encoder.cpp +++ b/src/simulation/deviceInterface/encoder/encoder.cpp @@ -30,21 +30,21 @@ Encoder::Encoder(std::size_t const numRW, std::uint32_t const clicksPerRotation) } void Encoder::reset(uint64_t currentSimNanos) { - // check if input message is linked + // The input message must be linked. if (!this->rwSpeedInMsg.isLinked()) { throw std::invalid_argument("encoder: rwSpeedInMsg is not linked."); } - // reset the previous time + // The previous time starts at the reset time. this->prevTime = currentSimNanos; - // zero the RW wheel output message buffer // + // The output speeds start at zero. this->rwSpeedConverted = RWSpeedMsgPayload{}; - // set the remaining clicks to zero, and keep the configured signal states + // The remaining clicks start at zero. The signal states do not change. for (double &clicks : this->remainingClicks) { clicks = 0.0; } } void Encoder::readInputMessages() { - // read the incoming wheel speed message + // The input message gives the wheel speeds and wheel angles of this step. this->rwSpeedBuffer = this->rwSpeedInMsg(); } @@ -53,50 +53,50 @@ void Encoder::writeOutputMessages(uint64_t currentClock) { } void Encoder::encode(uint64_t currentSimNanos) { - // convert clicks per rotation to clicks per radian + // This value is the number of clicks in one radian. double const clicksPerRadian = static_cast(this->clicksPerRotation) / (2 * std::numbers::pi); - // set the time step + // The time step is the time since the previous step. double const timeStep = (currentSimNanos - this->prevTime) * NANO2SEC; - // the encoder does not measure the wheel angles, so send them unchanged + // The encoder does not measure the wheel angles. The module sends them unchanged. std::copy( std::begin(this->rwSpeedBuffer.wheelThetas), std::end(this->rwSpeedBuffer.wheelThetas), std::begin(this->rwSpeedConverted.wheelThetas) ); - // loop through the RW + // The module calculates the output speed of each wheel that the encoder reads. for (std::size_t i = 0; i < this->numRW; i++) { switch (this->signalStates[i]) { case EncoderSignal::Nominal: { - // with a zero time step there are no clicks to count, so the encoder outputs the true RW speed + // With a zero time step, the encoder cannot count clicks. Thus it sends the input wheel speed. if (timeStep == 0.0) { this->rwSpeedConverted.wheelSpeeds[i] = this->rwSpeedBuffer.wheelSpeeds[i]; break; } - // calculate the angle sweeped by the reaction wheel during the time step + // The wheel turns through this angle during the time step. double const angle = this->rwSpeedBuffer.wheelSpeeds[i] * timeStep; - // calculate the number of clicks + // The encoder counts only an integer number of clicks. double const totalClicks = angle * clicksPerRadian + this->remainingClicks[i]; double const numberClicks = std::trunc(totalClicks); - // update the remaining clicks + // The module keeps the remaining part of a click for the next step. this->remainingClicks[i] = totalClicks - numberClicks; - // calculate the discretized angular velocity + // The output speed agrees with the number of clicks that the encoder counts. this->rwSpeedConverted.wheelSpeeds[i] = numberClicks / (clicksPerRadian * timeStep); break; } case EncoderSignal::Off: - // set the outgoing reaction wheel speed to 0 and reset the remaining clicks + // An off encoder sends zero speed and erases the remaining part of a click. this->rwSpeedConverted.wheelSpeeds[i] = 0.0; this->remainingClicks[i] = 0.0; break; case EncoderSignal::Stuck: - // if the encoder is stuck, it will output the previous results + // A stuck encoder sends the output speed of the previous step. break; } } From 656357b3674887ed1fced7571bf393791b686ef1 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:58:24 -0600 Subject: [PATCH 35/36] Correct the truncation wording in the encoder documentation --- src/simulation/deviceInterface/encoder/encoder.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.rst b/src/simulation/deviceInterface/encoder/encoder.rst index 12a4c79201..138f5b1344 100644 --- a/src/simulation/deviceInterface/encoder/encoder.rst +++ b/src/simulation/deviceInterface/encoder/encoder.rst @@ -42,7 +42,7 @@ clicks per rotation that the sensor can handle. Let :math:`N` be the number of c N = \texttt{trunc}(\Omega_{\text{in}}\Delta t \frac{n}{2\pi} + \Delta N) where :math:`\Omega_{\text{in}}` is the input wheel speed, :math:`\Delta t` is the time step, :math:`n` is the number of clicks per rotation and :math:`\Delta N` is the remaining clicks that were not accounted for -in the previous iteration. The ``trunc()`` function truncates the result, effectively rounding down to the nearest integer. The output wheel speed :math:`\Omega_{\text{out}}` is computed using the +in the previous iteration. The ``trunc()`` function removes the part of the result after the decimal point. Thus it moves the result toward zero, also for a negative wheel speed. The output wheel speed :math:`\Omega_{\text{out}}` is computed using the following equation: .. math:: From 69995e5d82db1c4b535627883bb3802b0724e266 Mon Sep 17 00:00:00 2001 From: patrick kenneally Date: Mon, 28 Sep 2026 15:59:37 -0600 Subject: [PATCH 36/36] Document the encoder constructor, setters, signal states and errors --- .../deviceInterface/encoder/encoder.rst | 115 ++++++++++++------ 1 file changed, 81 insertions(+), 34 deletions(-) diff --git a/src/simulation/deviceInterface/encoder/encoder.rst b/src/simulation/deviceInterface/encoder/encoder.rst index 138f5b1344..ae1d2807c4 100644 --- a/src/simulation/deviceInterface/encoder/encoder.rst +++ b/src/simulation/deviceInterface/encoder/encoder.rst @@ -1,13 +1,13 @@ Executive Summary ----------------- -Module that acts as an encoder to the reaction wheel speeds. It models both the discretization of the speed measurement, as well as signal status such as nominal, off and stuck. +The encoder module simulates a reaction wheel speed encoder. It counts the encoder clicks of each wheel in each time step. Then it sends the wheel speed that agrees with that count. +The module also simulates encoder failures with a signal state for each wheel. Message Connection Descriptions ------------------------------- -The following table lists all the module input and output messages. The module msg connection is set by the -user from python. The msg type contains a link to the message structure definition, while the description -provides information on what this message is used for. +The table that follows shows the input and output messages of the module. The user connects the messages in Python. +The message type column links to the message structure definition. The description column tells what each message contains. .. list-table:: Module I/O Messages :widths: 25 25 50 @@ -18,49 +18,63 @@ provides information on what this message is used for. - Description * - rwSpeedInMsg - :ref:`RWSpeedMsgPayload` - - Input reaction wheel speed message. + - Input reaction wheel speeds and wheel angles. * - rwSpeedOutMsg - :ref:`RWSpeedMsgPayload` - - Output reaction wheel speed message. + - Encoder output wheel speeds and the input wheel angles. Detailed Module Description --------------------------- -This module simulates two different encoder features: discretization and signal status. Discretization truncates the output reaction wheel speed to a set resolution. As for the signal status, it can be set as ``SIGNAL_NOMINAL`` for normal working conditions, but can also simulate failures -of the type ``SIGNAL_OFF`` (output wheel speed is 0) and ``SIGNAL_STUCK`` (output wheel speed is constant and equal to the one given at the previous iteration). +The module simulates two encoder features for each wheel: discretization and signal state. Discretization changes the output wheel speed to a multiple of the encoder resolution. +The signal state simulates a nominal encoder or an encoder failure. -This module applies each of its features to each wheel individually, and so it is important to set the number of reaction wheels in use. It is also important to note that, because -for the first iteration the time step is set at 0, the wheel speeds will not be discretized for the first time step and the encoder will output the true wheel speeds. +The module operates only on the first ``numRW`` wheels of the input message. The output speeds of the other wheels are zero. Discretization ~~~~~~~~~~~~~~ -Under normal operating conditions, the discretizer works by setting the wheel speed as a multiple of the resolution of the sensor. This resolution is calculated through the number of -clicks per rotation that the sensor can handle. Let :math:`N` be the number of clicks measured during a time step: +The number of clicks per rotation :math:`n` sets the resolution of the encoder. Let :math:`N` be the number of clicks that the encoder counts in one time step: .. math:: N = \texttt{trunc}(\Omega_{\text{in}}\Delta t \frac{n}{2\pi} + \Delta N) -where :math:`\Omega_{\text{in}}` is the input wheel speed, :math:`\Delta t` is the time step, :math:`n` is the number of clicks per rotation and :math:`\Delta N` is the remaining clicks that were not accounted for -in the previous iteration. The ``trunc()`` function removes the part of the result after the decimal point. Thus it moves the result toward zero, also for a negative wheel speed. The output wheel speed :math:`\Omega_{\text{out}}` is computed using the -following equation: +Here, :math:`\Omega_{\text{in}}` is the input wheel speed and :math:`\Delta t` is the time step. :math:`\Delta N` is the remaining part of a click from the previous step. +The ``trunc()`` function removes the part of the result after the decimal point. Thus it moves the result toward zero, also for a negative wheel speed. +The module calculates the output wheel speed :math:`\Omega_{\text{out}}` with this equation: .. math:: \Omega_{\text{out}} = N\frac{2\pi}{n\Delta t} -The remaining clicks value is also updated as follows: +Then the module keeps the remaining part of a click for the next step: .. math:: \Delta N = \Omega_{\text{in}}\Delta t \frac{n}{2\pi} + \Delta N - N -As expected, the smaller the value of :math:`n`, the greater the discretization errors will be. Also, the discretization will be more noticeable when the wheel speeds are close to 0. +The value of :math:`\Delta N` is always more than -1 and less than 1. Thus the difference between the output wheel angle and the input wheel angle is always less than one click. +A small value of :math:`n` causes large discretization errors. The errors are also large when the wheel speed is near zero. -Signal Status -~~~~~~~~~~~~~ +Zero Time Step +~~~~~~~~~~~~~~ -The module accepts three different signal status, which are ``SIGNAL_NOMINAL``, ``SIGNAL_OFF`` and ``SIGNAL_STUCK``. When the signal is nominal, all the previous features apply and the wheel -speeds are only affected by the discretization. When the signal is off, both the wheel speed and the remaining clicks are set to zero, which models a shutdown of the encoder. -Finally, when the signal is stuck, all the variables remain constant from the previous iteration, including the wheel speeds and the remaining clicks. +The module cannot count clicks on a step with a zero time step. This occurs on the first step after reset. +On such a step, a nominal encoder sends the input wheel speed without discretization. The module uses the off and stuck signal states as on all other steps. + +Signal State +~~~~~~~~~~~~ + +Each wheel has a signal state of the type ``EncoderSignal``: + +- ``Nominal``: The encoder operates correctly. The module uses the discretization above. +- ``Off``: The output wheel speed is zero. The module also sets the remaining part of a click to zero. This simulates an encoder that is off. +- ``Stuck``: The output wheel speed and the remaining part of a click do not change from the previous step. + +All wheels start in the ``Nominal`` state. Reset does not change the signal states. Thus the user can set a signal state before the simulation starts. + +Wheel Angles +~~~~~~~~~~~~ + +The encoder does not measure the wheel angles. The module sends the input ``wheelThetas`` unchanged on each step. Model Functions @@ -68,39 +82,72 @@ Model Functions The functions of the encoder model are: -- **Discretization:** Discretizes the wheel speeds in terms of the given resolution. -- **Signal Status:** Changes the output wheel speed depending on the encoder signal for each wheel. +- **Discretization:** The module changes each wheel speed to a multiple of the encoder resolution. +- **Signal State:** The module changes the output wheel speed of each wheel to agree with its signal state. Model Assumptions and Limitations --------------------------------- -This code makes the following assumptions: +The module uses these assumptions: -- **Wheel speed is constant for each time step:** A simple Euler integration is used to compute the number of clicks. -- **All encoders precision are the same:** The encoder module aplies the same resolution to each reaction wheel. +- **The wheel speed is constant during each time step:** The module uses one Euler integration step to calculate the number of clicks. +- **All encoders have the same resolution:** The module uses one value of clicks per rotation for all reaction wheels. User Guide ---------- -This section contains conceptual overviews of the code and clear examples for the prospective user. +This section shows examples of the module setup. Module Setup ~~~~~~~~~~~~ -The encoder module is created in python using: +The constructor has two necessary parameters: the number of reaction wheels and the number of clicks per rotation. +The number of reaction wheels must be from 1 to ``RW_EFF_CNT``. The number of clicks per rotation must be more than zero. + +.. code-block:: python + :linenos: + + wheelSpeedEncoder = encoder.Encoder(numRW, 2048) + wheelSpeedEncoder.modelTag = "rwSpeedsEncoder" + wheelSpeedEncoder.rwSpeedInMsg.subscribeTo(rwSpeedMsg) + +You can change the two parameters after the construction: .. code-block:: python :linenos: - wheelSpeedEncoder = encoder.Encoder() - wheelSpeedEncoder.modelTag = 'rwSpeedsEncoder' + wheelSpeedEncoder.numRW = 4 + wheelSpeedEncoder.clicksPerRotation = 1024 + +Signal States +~~~~~~~~~~~~~ -A sample setup is done using: +To set the signal state of one wheel, use ``setSignalState``. The wheel index must be less than ``numRW``. .. code-block:: python :linenos: - wheelSpeedEncoder.clicksPerRotation = 2048 - wheelSpeedEncoder.numRW = numRW + wheelSpeedEncoder.setSignalState(1, encoder.EncoderSignal_Stuck) + +To set the signal states of all wheels at the same time, use the ``signalStates`` attribute. The list must contain one state for each wheel. + +.. code-block:: python + :linenos: + + wheelSpeedEncoder.signalStates = [encoder.EncoderSignal_Off] * numRW + +Incorrect Values +~~~~~~~~~~~~~~~~ + +The module rejects an incorrect value when you set it, and keeps the previous value. In Python, an incorrect value causes a ``ValueError``. +In C++, it causes a ``std::invalid_argument`` exception. These values are incorrect: + +- A wheel count of zero, or a wheel count that is more than ``RW_EFF_CNT``. +- Zero clicks per rotation. +- A wheel index that is not less than ``numRW``. +- A signal state that is not an ``EncoderSignal`` value. +- A ``signalStates`` list with a size that is not equal to ``numRW``. + +If ``rwSpeedInMsg`` is not linked, reset causes an exception. In Python, ``InitializeSimulation`` then stops with a ``RuntimeError``.