SIL Test Suite for a Flight Controller
Build a spacecraft attitude controller and verify it with automated SIL tests.
Introduction
30 Second Summary
Before a spacecraft launches, every line of its flight software is tested on the ground using simulations. Engineers inject sensor data, run the controller in a loop, and verify that actuator commands stay within hardware limits.
In this project, you will build a complete Software-in-the-Loop test environment for a spacecraft attitude controller. You'll simulate a tumbling spacecraft, write automated tests that map to mission requirements, and generate a professional HTML verification report.
What You'll Build
When you run your test suite, pytest executes each verification test, checks the controller's outputs against mission requirements, and generates an HTML report you can open in your browser to see exactly which requirements passed.
By the end of this project, you'll have:
- A spacecraft simulation that models angular velocity and reaction wheel torque. You can watch the spacecraft stabilize in printed output as the controller damps its spin.
- An automated test suite where each test maps to a specific flight software requirement. Run the suite and instantly see which requirements pass and which fail.
- A designed failure where one test catches the controller exceeding actuator torque limits. This proves your test harness works before you fix the defect.
- An HTML verification report with a requirements traceability column that you could show in an interview.
- Secret Mission: Add fault injection testing that simulates a gyroscope failure and verifies the controller enters safe mode.
Are there any prerequisites?
Basic familiarity with embedded systems concepts (sensors, actuators, control loops) is helpful but not required. Python beginner level is fine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the project goal before any hands-on work begins.
Set Up Your Test Environment
Before you can write and run verification tests, you need an isolated environment with the right testing tools. Python's virtual environment keeps your project dependencies separate from your system Python installation.
In this step, you'll create the project folder, install pytest for automated test execution, and install pytest-html for generating professional verification reports.
In this step, get ready to:
- Create the spacecraft-sil project folder with a virtual environment.
- Install pytest and pytest-html.
- Set up the project file structure.
Create the project folder and open it in VS Code
Your project needs its own folder on the Desktop so you can find it easily later.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir spacecraft-sil
cd spacecraft-sil
What do these commands do?
- cd ~/Desktop moves your terminal to the Desktop folder.
- mkdir spacecraft-sil creates a new folder called spacecraft-sil.
- cd spacecraft-sil moves into that new folder so everything you do next happens inside it.
- Confirm you're in the right folder by running:
ls
The output should be empty because the folder is brand new.
Seeing 'No such file or directory'?
- Make sure you typed mkdir spacecraft-sil (not mkdr or mkidr).
- On Windows, use cd %USERPROFILE%\Desktop if cd ~/Desktop doesn't work in Command Prompt.
Help me troubleshoot creating my project folder.
- Now open the spacecraft-sil folder in VS Code.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the spacecraft-sil folder, and click Open.
VS Code now shows the empty spacecraft-sil folder in the Explorer sidebar on the left.
- Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
A terminal panel opens at the bottom of VS Code, already pointed at your spacecraft-sil folder.
Create and activate a virtual environment
A virtual environment gives your project its own isolated copy of Python and installed packages. This prevents conflicts between projects that need different versions of the same library.
- Create a virtual environment by running this command in VS Code's terminal:
python -m venv venv
- Confirm the virtual environment was created by running:
ls
You should see a venv folder listed in the output.
Don't see the venv folder?
- Make sure your terminal is inside the spacecraft-sil folder. Run pwd (macOS) or cd (Windows) to check your current location.
- If you see a Python version error, confirm you have Python 3.13 by running python --version.
Help me troubleshoot my virtual environment creation.
- Activate the virtual environment so that any packages you install stay inside this project.
macOS
- Run this command to activate:
source venv/bin/activate
Windows
- Run this command to activate:
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.
Not seeing (venv) in your prompt?
- On Windows, if you see a permissions error, open PowerShell as Administrator and run Set-ExecutionPolicy RemoteSigned. Then try activating again.
- On macOS, make sure you typed source before the path. Without it, the shell tries to execute the file directly.
Help me activate my virtual environment.
Install test dependencies
With the virtual environment active, you can install pytest (the test runner) and pytest-html (the report generator) without affecting anything else on your system.
- Install both testing packages by running:
pip install pytest==9.1.1 pytest-html==4.2.0
You'll see download progress bars as pip fetches and installs the packages.
- Before you verify the install, what do you think pytest --version will display?
- Verify the installation by running:
pytest --version
You should see pytest 9.1.1 printed in the output.
Seeing 'command not found' for pytest?
- Make sure your virtual environment is still active. Check for (venv) in your terminal prompt.
- If the prompt doesn't show (venv), re-activate using the platform-specific command above.
Help me fix my pytest installation.
Create the project file structure
Your project needs a src/ folder for the flight controller code and a tests/ folder for verification tests. Each folder needs an empty __init__.py file so Python treats them as importable packages.
- Create the two folders by running:
mkdir src tests
- Create the empty __init__.py files in each folder:
macOS
touch src/__init__.py tests/__init__.py
Windows
New-Item src/__init__.py
New-Item tests/__init__.py
- Confirm your file structure by running:
ls
You should see src, tests, and venv listed in the output.
- Now create requirements.txt to record your project's dependencies. In VS Code's Explorer sidebar, right-click in the empty space below the folders.
- Select New File.
- Name the file requirements.txt and press Enter.
- Paste the following content into the file:
pytest==9.1.1
pytest-html==4.2.0
What is requirements.txt?
This file lists every Python package your project depends on, pinned to exact versions. Anyone who clones your project can recreate your environment by running pip install -r requirements.txt.
- Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you've saved requirements.txt and your Explorer sidebar shows both src/ and tests/ folders with __init__.py files inside each.
ⓧ I'd like to double check the full code
Your requirements.txt should contain exactly this:
pytest==9.1.1
pytest-html==4.2.0
Your src/__init__.py and tests/__init__.py files should both be empty (no content needed).
Your Explorer sidebar should show this structure:
- spacecraft-sil/
- src/__init__.py
- tests/__init__.py
- requirements.txt
- venv/ (this folder is large, that's normal)
Your test environment is ready. Next up, you'll build the flight controller and spacecraft simulation that your test suite will verify.
Build and Run the Flight Controller Simulation
Your test environment is ready with pytest and a clean project structure. Now you need something to actually test.
In this step, you'll build the "system under test" for your SIL harness: a rate-damping flight controller and a physics simulation. By the end, you'll watch a tumbling spacecraft stabilize itself.
In this step, get ready to:
- Create a rate-damping controller that commands reaction wheel torque.
- Build a spacecraft physics simulation that models single-axis rotation.
- Run the simulation and confirm the spacecraft detumbles from 2.0 rad/s to near zero.
Create the flight controller module
A rate-damping controller applies torque in the opposite direction of rotation to slow the spacecraft down. The math is simple: torque = -Kp * angular_velocity. A higher Kp means more aggressive braking.
- Create a new file called controller.py inside the src/ folder.
- Paste the following code into it:
import math
class RateDampingController:
"""Single-axis rate damping controller for spacecraft attitude stabilization."""
def __init__(self, kp=0.5, max_torque=0.1):
self.kp = kp
self.max_torque = max_torque
self.safe_mode = False
def compute_torque(self, angular_velocity):
"""Compute reaction wheel torque command to damp angular velocity.
Args:
angular_velocity: Current angular velocity in rad/s.
Returns:
Commanded torque in Nm, NOT yet clamped to actuator limits.
"""
if self.safe_mode:
return 0.0
raw_torque = -self.kp * angular_velocity
return raw_torque
What does this code do?
- kp=0.5 is the proportional gain. It controls how aggressively the controller brakes. A spacecraft spinning at 2.0 rad/s gets a raw torque command of -1.0 Nm.
- max_torque=0.1 stores the reaction wheel's physical limit (0.1 Nm). The controller stores this value but doesn't enforce it yet.
- compute_torque() multiplies the angular velocity by -Kp. The negative sign means the torque always opposes the current spin direction.
- Save src/controller.py.
- Verify the module loads correctly by running this in your terminal:
python -c "from src.controller import RateDampingController; c = RateDampingController(); print(c.compute_torque(2.0))"
You should see -1.0 printed. That's the raw torque command for a spacecraft spinning at 2.0 rad/s.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
- Check that src/__init__.py exists. Python needs this file to treat src/ as a package.
- Confirm you're running the command from the spacecraft-sil root folder, not from inside src/.
Why can't Python find my controller module?
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import math
class RateDampingController:
"""Single-axis rate damping controller for spacecraft attitude stabilization."""
def __init__(self, kp=0.5, max_torque=0.1):
self.kp = kp
self.max_torque = max_torque
self.safe_mode = False
def compute_torque(self, angular_velocity):
"""Compute reaction wheel torque command to damp angular velocity.
Args:
angular_velocity: Current angular velocity in rad/s.
Returns:
Commanded torque in Nm, NOT yet clamped to actuator limits.
"""
if self.safe_mode:
return 0.0
raw_torque = -self.kp * angular_velocity
return raw_torque
Create the spacecraft simulation
The controller tells the reaction wheels what to do. Now you need a physics model that simulates how the spacecraft responds to those torque commands over time.
- Create a new file called simulation.py inside the src/ folder.
- Start by pasting the class definition, constructor, and step method:
import math
class SpacecraftSim:
"""Single-axis spacecraft rotation simulation."""
def __init__(self, inertia=10.0, dt=0.1):
self.inertia = inertia
self.dt = dt
self.angular_velocity = 0.0
self.time = 0.0
self.history = []
def step(self, torque):
"""Advance simulation by one timestep.
Args:
torque: Applied torque in Nm.
"""
angular_acceleration = torque / self.inertia
self.angular_velocity += angular_acceleration * self.dt
self.time += self.dt
self.history.append({
"time": self.time,
"angular_velocity": self.angular_velocity,
"torque": torque,
})
What does this code do?
- inertia=10.0 represents the spacecraft's resistance to rotation (10 kg*m²). Higher inertia means slower response to torque.
- dt=0.1 is the simulation timestep (0.1 seconds). Each call to step() advances the clock by this amount.
- The physics equation is: angular_acceleration = torque / inertia. Then angular_velocity updates by acceleration * dt. This is Euler integration of Newton's rotational law.
- history records every timestep so your tests can inspect the full trajectory later.
- Now add the run method below the step method in the same file:
def run(self, controller, duration, fault_time=None):
"""Run closed-loop simulation for a given duration.
Args:
controller: Controller with compute_torque method.
duration: Simulation duration in seconds.
fault_time: Time in seconds to inject gyro fault (NaN reading).
Returns:
List of state history dictionaries.
"""
self.history = []
steps = int(duration / self.dt)
for _ in range(steps):
sensor_reading = self.angular_velocity
if fault_time is not None and self.time >= fault_time:
sensor_reading = math.nan
if hasattr(controller, "check_sensor_validity"):
controller.check_sensor_validity(sensor_reading)
torque = controller.compute_torque(sensor_reading)
self.step(torque)
return self.history
What does the run method do?
- This is the closed-loop simulation. Each timestep reads the current angular velocity, passes it to the controller, gets a torque command back, and applies it to the physics model.
- The fault_time and check_sensor_validity hooks are extension points you'll use later. For now they have no effect.
- The method returns the full history list so tests can analyze every timestep after the simulation finishes.
- Save src/simulation.py.
- Confirm the module loads without errors by running:
python -c "from src.simulation import SpacecraftSim; s = SpacecraftSim(); print(f'Sim ready: inertia={s.inertia}, dt={s.dt}')"
You should see Sim ready: inertia=10.0, dt=0.1 printed.
Seeing a SyntaxError or IndentationError?
- The run method must be indented at the same level as step (one level inside the class). Check that both use 4 spaces of indentation from the class body.
- Make sure there's no extra blank line or missing colon after the def run(...) line.
Help me fix my simulation.py syntax error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
Your src/simulation.py is built from both chunks above. Scroll up and confirm you have:
- The import math line at the top.
- The SpacecraftSim class with __init__ and step methods (chunk 1).
- The run method at the same indentation level as step (chunk 2).
Create the runner script and execute the simulation
You have a controller and a physics model. Now you'll wire them together in a script that simulates a spacecraft tumbling at 2.0 rad/s and prints how it stabilizes over 60 seconds.
- Create a new file called run_sim.py in the project root (the spacecraft-sil folder, not inside src/).
- Paste the following code:
from src.controller import RateDampingController
from src.simulation import SpacecraftSim
def main():
controller = RateDampingController(kp=0.5, max_torque=0.1)
sim = SpacecraftSim(inertia=10.0, dt=0.1)
sim.angular_velocity = 2.0
print(f"Initial angular velocity: {sim.angular_velocity:.3f} rad/s")
print("Running simulation for 60 seconds...")
print()
history = sim.run(controller, duration=60.0)
print(f"Final angular velocity: {sim.angular_velocity:.4f} rad/s")
print(f"Total timesteps: {len(history)}")
print()
checkpoints = [0, 99, 199, 299, 399, 499, 599]
print("Time (s) | Angular Velocity (rad/s) | Torque (Nm)")
print("-" * 55)
for i in checkpoints:
if i < len(history):
h = history[i]
print(f" {h['time']:5.1f} | {h['angular_velocity']:22.4f} | {h['torque']:8.4f}")
if __name__ == "__main__":
main()
What does this script do?
- It creates a controller and simulation, then sets the spacecraft's initial spin to 2.0 rad/s (a tumbling satellite after deployment).
- sim.run(controller, duration=60.0) closes the loop for 60 seconds. Each timestep, the controller reads the spin rate and commands a braking torque.
- The checkpoint table prints angular velocity and torque at key moments so you can watch the spacecraft slow down.
- Save run_sim.py.
Before you run this, what do you think will happen to the angular velocity over 60 seconds? Will it reach zero, overshoot into negative values, or settle somewhere above zero?
- Run the simulation by executing:
python run_sim.py
You should see the angular velocity dropping from 2.0 rad/s toward zero. The final value should be well below 0.01 rad/s. Your spacecraft detumbled.
What should I see?
The table shows the spacecraft braking hard at first (large negative torque values), then easing off as it slows down. The final angular velocity is near zero, meaning the controller successfully stabilized the spin.
Notice the torque values in the early timesteps are around -1.0 Nm. The reaction wheel limit is only 0.1 Nm. The controller is commanding far more torque than the hardware can deliver. This will matter soon.
Script not producing output?
- Check that run_sim.py is in the project root (spacecraft-sil/), not inside src/.
- Make sure the if __name__ == "__main__": block is at the bottom of the file with no extra indentation.
- If you see an ImportError, confirm both src/controller.py and src/simulation.py are saved.
Why isn't my run_sim.py producing any output?
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
from src.controller import RateDampingController
from src.simulation import SpacecraftSim
def main():
controller = RateDampingController(kp=0.5, max_torque=0.1)
sim = SpacecraftSim(inertia=10.0, dt=0.1)
sim.angular_velocity = 2.0
print(f"Initial angular velocity: {sim.angular_velocity:.3f} rad/s")
print("Running simulation for 60 seconds...")
print()
history = sim.run(controller, duration=60.0)
print(f"Final angular velocity: {sim.angular_velocity:.4f} rad/s")
print(f"Total timesteps: {len(history)}")
print()
checkpoints = [0, 99, 199, 299, 399, 499, 599]
print("Time (s) | Angular Velocity (rad/s) | Torque (Nm)")
print("-" * 55)
for i in checkpoints:
if i < len(history):
h = history[i]
print(f" {h['time']:5.1f} | {h['angular_velocity']:22.4f} | {h['torque']:8.4f}")
if __name__ == "__main__":
main()
Your spacecraft is stabilizing and you have a working simulation. Next up, you'll write automated tests that verify this controller meets its mission requirements. One of those tests will expose a real defect hiding in the code.
Write Automated Verification Tests
Running the sim manually tells you the controller works once. But in aerospace verification, you need to prove the controller meets specific requirements every time the code changes.
In this step, you'll write automated tests that each map to a flight software requirement. You'll also discover something important: one requirement fails because the controller commands more torque than the reaction wheels can physically deliver.
In this step, get ready to:
- Define flight software requirements as test docstrings with traceability markers.
- Write three automated verification tests that exercise the controller.
- Run the test suite and discover a real design defect.
Define your verification requirements
Every flight software test traces back to a specific requirement. This is called requirements traceability. Each test you write will carry a marker linking it to the requirement it verifies.
Here are the three requirements your test suite will verify:
- REQ-AOCS-001: The controller shall reduce angular velocity below 0.01 rad/s within 60 seconds.
- REQ-AOCS-002: The controller shall not command torque exceeding 0.1 Nm (reaction wheel limit).
- REQ-AOCS-003: The controller shall produce zero torque when angular velocity is zero.
Why requirements traceability?
NASA SWE-072 requires bidirectional traceability between test procedures and software requirements. Each test must prove a specific requirement is met, and each requirement must have a test that exercises it.
Your pytest markers will provide this link. When you generate an HTML report later, the requirement ID appears right next to the test result.
Create the test file
- Create a new file called test_controller.py inside the tests/ folder.
- Add the imports and first test by copying this code into the file:
import pytest
from src.controller import RateDampingController
from src.simulation import SpacecraftSim
@pytest.mark.requirement("REQ-AOCS-001")
def test_detumble_convergence():
"""REQ-AOCS-001: Controller shall reduce angular velocity below 0.01 rad/s within 60s."""
controller = RateDampingController(kp=0.5, max_torque=0.1)
sim = SpacecraftSim(inertia=10.0, dt=0.1)
sim.angular_velocity = 2.0
# Run the full 60-second simulation
sim.run(controller, duration=60.0)
# Verify the spacecraft angular velocity converged below threshold
assert abs(sim.angular_velocity) < 0.01, (
f"Angular velocity {sim.angular_velocity:.4f} rad/s exceeds 0.01 rad/s threshold"
)
What does this code do?
- @pytest.mark.requirement("REQ-AOCS-001") attaches a custom marker to the test, linking it to the requirement it verifies.
- The test sets up the same scenario as your manual simulation: a spacecraft tumbling at 2.0 rad/s.
- After 60 seconds of closed-loop control, it asserts the angular velocity dropped below 0.01 rad/s.
- Save tests/test_controller.py.
- Run the single test to confirm it passes by running this command:
pytest tests/ -v
You should see one test collected and one PASSED result for test_detumble_convergence.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
- Check that you're running the command from the spacecraft-sil root folder, not from inside tests/.
- Confirm src/__init__.py and tests/__init__.py both exist (they can be empty).
Help me debug a ModuleNotFoundError when running pytest
- Now add the second test below your first test in tests/test_controller.py:
@pytest.mark.requirement("REQ-AOCS-002")
def test_torque_within_actuator_limits():
"""REQ-AOCS-002: Controller shall not command torque exceeding 0.1 Nm."""
controller = RateDampingController(kp=0.5, max_torque=0.1)
sim = SpacecraftSim(inertia=10.0, dt=0.1)
sim.angular_velocity = 2.0
# Record the full history to check every torque command
history = sim.run(controller, duration=60.0)
# Every single torque value must stay within hardware limits
max_torque_limit = 0.1
for record in history:
assert abs(record["torque"]) <= max_torque_limit, (
f"Torque {record['torque']:.4f} Nm exceeds limit of {max_torque_limit} Nm "
f"at time {record['time']:.1f}s"
)
What does this test check?
This test iterates through every timestep's torque command and asserts none exceed the reaction wheel's physical limit of 0.1 Nm. On a real spacecraft, commanding torque beyond what the wheels can deliver causes wheel saturation and loss of attitude control.
- Add the third and final test below the second one in tests/test_controller.py:
@pytest.mark.requirement("REQ-AOCS-003")
def test_zero_output_at_rest():
"""REQ-AOCS-003: Controller shall produce zero torque when angular velocity is zero."""
controller = RateDampingController(kp=0.5, max_torque=0.1)
# A spacecraft at rest should receive no torque command
torque = controller.compute_torque(0.0)
assert torque == 0.0, f"Expected zero torque at rest, got {torque}"
What does this test check?
This is a boundary condition test. When the spacecraft is already at rest (angular velocity of zero), the controller must not apply any torque. A non-zero output at rest would cause the spacecraft to start spinning unnecessarily.
- Save tests/test_controller.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file with all three tests before moving on.
ⓧ I'd like to double check the full code
Your tests/test_controller.py should contain these sections in order. Scroll up and confirm each is present:
- Imports (pytest, RateDampingController, SpacecraftSim).
- test_detumble_convergence with REQ-AOCS-001 marker.
- test_torque_within_actuator_limits with REQ-AOCS-002 marker.
- test_zero_output_at_rest with REQ-AOCS-003 marker.
Run the test suite and discover the defect
All three tests are written. Before you run them, think about this: your controller applies torque as -Kp * angular_velocity. With Kp = 0.5 and an initial angular velocity of 2.0 rad/s, what torque does it command at the first timestep?
- Run the full test suite by executing this command:
pytest tests/ -v
Two tests pass (green) and one test fails (red). The test_torque_within_actuator_limits test caught the defect.
The failure message shows the controller commanded -1.0 Nm at the first timestep. That's -0.5 * 2.0 = -1.0 Nm. The reaction wheel limit is only 0.1 Nm.
Why is this failure important?
On a real spacecraft, commanding 1.0 Nm to a wheel rated for 0.1 Nm causes wheel saturation. The wheel spins at maximum speed and can no longer absorb momentum. The spacecraft loses attitude control.
Your automated test just caught this defect before it reached hardware. This is exactly the value of SIL testing. You'll fix this in the next step.
All three tests passing?
- Check your src/controller.py file. The compute_torque method should return raw_torque directly without any clamping logic. If you already added max() and min() calls, remove them for now. The fix comes in the next step.
Help me understand why all my tests are passing when one should fail
Your test suite just proved its worth. It automatically caught a defect that would cause hardware damage on a real mission. Next up, you'll fix the controller and get all three tests passing.
Fix the Defect and Verify the Fix
Your test suite just exposed a real defect. The controller commanded 1.0 Nm of torque when the reaction wheel hardware can only deliver 0.1 Nm.
On a real spacecraft, commanding torque beyond what reaction wheels can deliver causes wheel saturation and loss of attitude control. You'll add saturation logic to the controller and confirm the entire test suite passes.
In this step, get ready to:
- Add torque clamping to prevent actuator saturation.
- Verify the simulation still converges with the torque limit in place.
- Re-run the full test suite and confirm all 3 tests pass.
Add torque saturation to the controller
The fix is a one-line addition that clamps the raw torque value to the reaction wheel's physical limits. This is exactly how real flight software enforces actuator constraints.
- In src/controller.py, find the compute_torque method.
- Locate these lines at the end of the method:
raw_torque = -self.kp * angular_velocity
return raw_torque
- Replace those two lines with the following, which clamps the output to the actuator limit:
raw_torque = -self.kp * angular_velocity
clamped_torque = max(-self.max_torque, min(raw_torque, self.max_torque))
return clamped_torque
What does the clamp do?
The expression max(-self.max_torque, min(raw_torque, self.max_torque)) forces the torque value to stay within the range -0.1 to +0.1 Nm.
When the spacecraft is spinning fast (omega = 2.0), the raw torque would be -1.0 Nm. The clamp caps it at -0.1 Nm, which is all the reaction wheel can physically deliver.
- Also update the docstring in the same method. Find this line:
Commanded torque in Nm, NOT yet clamped to actuator limits.
- Replace it with:
Commanded torque in Nm, clamped to actuator limits.
- Save src/controller.py.
Re-run the simulation to confirm convergence
The controller now respects hardware limits. The spacecraft should still stabilize, but it will take longer because torque is capped.
- Run the simulation again by executing:
python run_sim.py
The output shows the angular velocity still drops from 2.0 rad/s to below 0.01 rad/s. You'll also notice the torque column never exceeds 0.1 Nm.
Why does convergence take longer now?
Before the fix, the controller could command 1.0 Nm to brake a 2.0 rad/s spin. Now it's limited to 0.1 Nm. Less braking torque means a slower deceleration, but the spacecraft still reaches rest.
This tradeoff is expected. The controller is now respecting hardware limits instead of commanding impossible torques.
Simulation not converging?
Make sure you kept the raw_torque = -self.kp * angular_velocity line and added the clamp on a new line below it. If you accidentally replaced the raw torque calculation, the controller won't produce any output.
help me debug why my simulation isn't converging after adding the torque clamp
Re-run the full test suite
The simulation converges, which is a good sign. Now for the real question: does the fix satisfy all three requirements?
Before you run the tests, predict: do you think all 3 will pass now, or will the torque clamp break the convergence test?
- Run the full test suite by executing:
pytest tests/ -v
All 3 tests pass. The torque limit test now passes because no command exceeds 0.1 Nm.
What does this prove?
You just completed a full defect resolution cycle: detect the failure through automated testing, fix the root cause, and re-verify. This is the exact workflow that SIL testing enables in aerospace software development.
Still seeing a test failure?
Check that return clamped_torque replaced return raw_torque. If you still return raw_torque, the clamp calculation runs but has no effect.
help me figure out why my test_torque_within_actuator_limits test is still failing
✔️ Awesome, I've got everything!
Great. Double check you've saved src/controller.py.
ⓧ I'd like to double check the full code
Here is the complete src/controller.py after the fix:
import math
class RateDampingController:
"""Single-axis rate damping controller for spacecraft attitude stabilization."""
def __init__(self, kp=0.5, max_torque=0.1):
self.kp = kp
self.max_torque = max_torque
self.safe_mode = False
def compute_torque(self, angular_velocity):
"""Compute reaction wheel torque command to damp angular velocity.
Args:
angular_velocity: Current angular velocity in rad/s.
Returns:
Commanded torque in Nm, clamped to actuator limits.
"""
if self.safe_mode:
return 0.0
raw_torque = -self.kp * angular_velocity
clamped_torque = max(-self.max_torque, min(raw_torque, self.max_torque))
return clamped_torque
You've fixed the defect and proven the fix with automated tests. Next up, you'll generate a formal HTML verification report with requirements traceability, the kind of artifact that aerospace V&V teams deliver to prove their software meets mission requirements.
Generate an HTML Verification Report with Traceability
Your tests pass in a terminal, and that proves the controller works right now. But aerospace V&V requires a formal, auditable record of which requirements were verified, when they were tested, and whether they passed.
NASA SWE-072 requires bidirectional traceability between test procedures and requirements. In this step, you'll configure pytest-html to produce an HTML verification report with a dedicated column that maps each test back to the requirement it verifies.
In this step, get ready to:
- Hook into pytest-html to add a Requirement traceability column.
- Configure pytest to automatically generate an HTML report on every run.
- Generate and view the verification report in your browser.
Add a requirements traceability column to the HTML report
pytest-html generates test reports, but it doesn't know about your requirement markers by default. You need a conftest.py file that tells pytest-html how to extract the requirement ID from each test and display it in a new column.
- Create a new file called conftest.py inside the tests/ folder.
- Paste the following code into it:
import pytest
def pytest_configure(config):
# Register the custom 'requirement' marker so pytest doesn't warn about it
config.addinivalue_line("markers", "requirement(id): mark test with requirement ID")
def pytest_html_results_table_header(cells):
# Add a 'Requirement' column header to the HTML report table
cells.insert(2, "<th>Requirement</th>")
def pytest_html_results_table_row(report, cells):
# Insert the requirement ID value into the matching column
cells.insert(2, f"<td>{report.requirement}</td>")
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
# After each test runs, attach the requirement ID to the report object
outcome = yield
report = outcome.get_result()
marker = item.iter_markers("requirement")
first_marker = next(marker, None)
if first_marker:
report.requirement = first_marker.args[0]
else:
report.requirement = ""
What does this code do?
This file uses four pytest hooks that run automatically during test execution:
- pytest_configure registers your custom requirement marker so pytest doesn't throw an unknown-marker warning.
- pytest_html_results_table_header and pytest_html_results_table_row insert a new "Requirement" column into the HTML report table.
- pytest_runtest_makereport reads the @pytest.mark.requirement() marker from each test and attaches the ID to the report so the row hook can display it.
- Save tests/conftest.py.
- Confirm the hooks load without errors by running your tests:
pytest tests/ -v
All 3 tests still pass. The hooks are loaded and ready.
Seeing an import error or hook warning?
- Make sure the file is named exactly conftest.py (not conftest.py.txt or config.py). Pytest auto-discovers this filename.
- Confirm the file lives inside the tests/ folder, not the project root.
- Check that your virtual environment is still active. You should see (venv) in your terminal prompt.
Help me debug my conftest.py hook errors.
Configure pytest to generate the HTML report
Right now, pytest runs your tests but doesn't produce an HTML file. A pytest.ini file tells pytest to automatically generate the report every time you run the suite.
- Create a new file called pytest.ini in the project root (the spacecraft-sil folder, not inside tests/).
- Paste the following configuration:
[pytest]
addopts = --html=reports/verification_report.html --self-contained-html
markers =
requirement(id): mark test with requirement ID for traceability
What does this configuration do?
- addopts automatically appends these flags every time you run pytest. You no longer need to type --html=... manually.
- --self-contained-html embeds all CSS and JavaScript into a single file, so you can share the report without extra assets.
- markers formally declares the requirement marker in pytest's configuration (a backup to the conftest.py registration).
- Save pytest.ini.
- Create the output directory for the report by running:
mkdir reports
Generate and view the report
Everything is wired up. Before you run the suite, what do you expect the HTML report to show for each test?
- Generate the verification report by running:
pytest tests/
All 3 tests pass, and pytest now generates the HTML file at reports/verification_report.html.
- Open reports/verification_report.html in your browser.
You see a table with all 3 tests passing and a "Requirement" column showing REQ-AOCS-001, REQ-AOCS-002, and REQ-AOCS-003. That is your traceability matrix.
Why does this matter for aerospace V&V?
NASA SWE-072 requires bidirectional traceability between test procedures and software requirements. Your HTML report serves as that artifact. An auditor can open it and immediately see which requirement each test verifies, whether it passed, and when it ran.
Report missing or no Requirement column?
- If you get a "directory not found" error, make sure you created the reports/ folder with mkdir reports.
- If the Requirement column is empty or missing, check that tests/conftest.py uses cells.insert(2, ...) in both the header and row hooks.
- Confirm pytest.ini is in the project root (same level as src/ and tests/), not inside a subfolder.
Help me debug my HTML verification report.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files and the report opened in your browser.
ⓧ I'd like to double check the full code
Here is the complete tests/conftest.py:
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "requirement(id): mark test with requirement ID")
def pytest_html_results_table_header(cells):
cells.insert(2, "<th>Requirement</th>")
def pytest_html_results_table_row(report, cells):
cells.insert(2, f"<td>{report.requirement}</td>")
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
marker = item.iter_markers("requirement")
first_marker = next(marker, None)
if first_marker:
report.requirement = first_marker.args[0]
else:
report.requirement = ""
And the complete pytest.ini:
[pytest]
addopts = --html=reports/verification_report.html --self-contained-html
markers =
requirement(id): mark test with requirement ID for traceability
You now have a full SIL verification pipeline: a controller, a physics sim, automated tests with requirement markers, and an HTML report that proves traceability. This is the exact artifact structure that aerospace V&V teams produce for flight software certification.
Secret mission
Add Fault Injection Testing
Real flight software must handle hardware failures gracefully. Gyroscopes can fail mid-mission, and the controller must detect this and enter a safe mode. Can you add fault injection to your SIL harness and verify the controller responds correctly?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The spacecraft-sil project folder on your Desktop (contains all source code, tests, and reports).
- A Python virtual environment (venv) inside the project folder.
- The pytest and pytest-html packages installed inside the virtual environment.
Keep everything running
No action needed. Choose this if you're still actively building on this project or want to keep it as a portfolio piece.
- Your project folder stays on your Desktop for future reference.
- You can reactivate the virtual environment any time by running venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux) from inside the spacecraft-sil folder.
- Regenerate your HTML report at any time by running pytest tests/ with the virtual environment active.
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your files stay intact for when you return.
- Deactivate the virtual environment by running:
deactivate
- Your project folder and all files remain on your Desktop.
- When you come back, navigate to the spacecraft-sil folder and reactivate the virtual environment.
Delete - I don't want to use this again
Remove all project files and the virtual environment from your machine.
- Deactivate the virtual environment first by running:
deactivate
- Navigate to your Desktop and delete the project folder.
On macOS/Linux:
cd ~/Desktop
rm -rf spacecraft-sil
On Windows:
cd ~\Desktop
rmdir /s /q spacecraft-sil
- This removes all source code, tests, the virtual environment, and the HTML report.
- Python 3.13 and VS Code remain on your machine since they were installed before this project.
Nice Work!
Nice Work!
Nice work! You just built a complete Software-in-the-Loop verification environment for a spacecraft attitude controller, from physics simulation to automated test reporting.
You've learned how to:
- Build a SIL test environment with a rate-damping controller and physics simulation that models spacecraft detumble from 2.0 rad/s to rest.
- Discover and fix a real defect (actuator saturation) through automated verification tests mapped to mission requirements REQ-AOCS-001 through REQ-AOCS-003.
- Generate an HTML verification report with a requirements traceability column per NASA SWE-072 guidance.
- Secret Mission: Implemented fault injection testing for gyroscope failure and safe mode verification (REQ-AOCS-004).
Ready to quiz yourself?