Source code for test_controller
#
# ISC License
#
# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
import numpy as np
from Basilisk.simulation import motorVoltageInterface, spacecraft
from Basilisk.utilities import simHelpers
from Basilisk.utilities.MonteCarlo.Controller import SimulationExecutor, SimulationParameters
from Basilisk.utilities.MonteCarlo.Dispersions import (
NormalVectorAngleDispersion,
NormalVectorDispersion,
UniformDispersion,
UniformVectorAngleDispersion
)
class DummyHub:
def __init__(self):
self.mHub = 1.0 # [kg]
self.r_BcB_B = [0.0, 0.0, 0.0] # [m]
self.unitVector = np.array([[1.0], [0.0], [0.0]]) # [-]
class DummyModel:
def __init__(self):
self.RNGSeed = 0
self.hub = DummyHub()
class DummyModelWithoutSeed:
pass
class DummyTask:
def __init__(self):
self.TaskModels = [DummyModel(), DummyModelWithoutSeed()]
class DummyVectorContainer:
def __init__(self):
self.values = [0.0, 0.0, 0.0]
class DummyDynModel:
def __init__(self):
self.scObject = DummyModel()
class DummySimulation:
def __init__(self):
self.TaskList = [DummyTask()]
self.vectorContainer = DummyVectorContainer()
self.dynModel = DummyDynModel()
def get_DynModel(self):
return self.dynModel
[docs]
def test_apply_modification_updates_nested_attributes():
"""Verify Monte Carlo path strings update the nested object."""
sim = DummySimulation()
mass_path = "TaskList[0].TaskModels[0].hub.mHub"
SimulationExecutor.applyModification(sim, mass_path, "25.0")
assert sim.TaskList[0].TaskModels[0].hub.mHub == 25.0
assert not hasattr(sim, mass_path)
center_of_mass_path = "TaskList[0].TaskModels[0].hub.r_BcB_B"
SimulationExecutor.applyModification(sim, center_of_mass_path, "[1.0, 2.0, 3.0]")
assert sim.TaskList[0].TaskModels[0].hub.r_BcB_B == [1.0, 2.0, 3.0]
indexed_path = "vectorContainer.values[1]"
SimulationExecutor.applyModification(sim, indexed_path, "7.5")
assert sim.vectorContainer.values == [0.0, 7.5, 0.0]
method_path = "get_DynModel().scObject.hub.mHub"
SimulationExecutor.applyModification(sim, method_path, "35.0")
assert sim.get_DynModel().scObject.hub.mHub == 35.0
assert not hasattr(sim, method_path)
[docs]
def test_indexed_modification_updates_swig_copy_on_read_container():
"""Verify indexed paths write back copy-on-read SWIG containers."""
sim = DummySimulation()
sim.rwVoltageIO = motorVoltageInterface.MotorVoltageInterface()
initial_gains = [0.02, 0.02, 0.02] # [N*m/V]
new_gain = 0.123 # [N*m/V]
sim.rwVoltageIO.setGains(initial_gains)
SimulationExecutor.applyModification(
sim,
"rwVoltageIO.voltage2TorqueGain[0]",
str(new_gain)
)
assert sim.rwVoltageIO.voltage2TorqueGain[0][0] == new_gain
[docs]
def test_double_indexed_modification_updates_swig_matrix3d():
"""Verify double-indexed paths write back SWIG Matrix3d attributes."""
sim = DummySimulation()
sim.scObject = spacecraft.Spacecraft()
inertia = [
900.0, 0.0, 0.0,
0.0, 800.0, 0.0,
0.0, 0.0, 600.0
] # [kg*m^2]
new_cross_inertia = 12.5 # [kg*m^2]
sim.scObject.hub.IHubPntBc_B = simHelpers.np2EigenMatrix3d(inertia)
SimulationExecutor.applyModification(
sim,
"scObject.hub.IHubPntBc_B[0][1]",
str(new_cross_inertia)
)
assert sim.scObject.hub.IHubPntBc_B[0][1] == new_cross_inertia
[docs]
def test_vector_angle_dispersion_reads_nested_method_path():
"""Verify vector dispersions read nested zero-argument method paths."""
sim = DummySimulation()
vector_path = "get_DynModel().scObject.hub.unitVector"
phi_bounds = [-0.001, 0.001] # [rad]
theta_bounds = [-0.001, 0.001] # [rad]
dispersion = UniformVectorAngleDispersion(
vector_path,
phiBoundsOffNom=phi_bounds,
thetaBoundsOffNom=theta_bounds
)
dispersed_vector = dispersion.generate(sim)
assert np.isclose(np.linalg.norm(dispersed_vector), 1.0)
[docs]
def test_uniform_vector_angle_dispersion_uses_bounds_off_nominal(monkeypatch):
"""Verify angle dispersions use bounds centered on the nominal vector."""
sim = DummySimulation()
vector_path = "get_DynModel().scObject.hub.unitVector"
sim.get_DynModel().scObject.hub.unitVector = np.array([
[1.0], [1.0], [1.0]
]) # [-]
phi_bounds = [-0.02, 0.03] # [rad]
theta_bounds = [-0.04, 0.05] # [rad]
uniform_calls = []
def record_uniform(lower_bound, upper_bound):
uniform_calls.append((lower_bound, upper_bound))
return (lower_bound + upper_bound) / 2.0
monkeypatch.setattr(np.random, "uniform", record_uniform)
dispersion = UniformVectorAngleDispersion(
vector_path,
phiBoundsOffNom=phi_bounds,
thetaBoundsOffNom=theta_bounds
)
dispersion.generate(sim)
nominal_vector = sim.get_DynModel().scObject.hub.unitVector
nominal_vector = nominal_vector / np.linalg.norm(nominal_vector)
nominal_spherical = dispersion.cart2Spherical(nominal_vector)
expected_phi_bounds = [
nominal_spherical[1] + phi_bounds[0],
nominal_spherical[1] + phi_bounds[1]
]
expected_theta_bounds = [
nominal_spherical[2] + theta_bounds[0],
nominal_spherical[2] + theta_bounds[1]
]
assert np.allclose(uniform_calls[0], expected_phi_bounds)
assert np.allclose(uniform_calls[1], expected_theta_bounds)
[docs]
def test_normal_vector_angle_dispersion_uses_scalar_angle_samples(monkeypatch):
"""Verify normal angle dispersions draw scalar angle samples."""
sim = DummySimulation()
vector_path = "get_DynModel().scObject.hub.unitVector"
sim.get_DynModel().scObject.hub.unitVector = np.array([
[1.0], [1.0], [1.0]
]) # [-]
phi_std = 0.02 # [rad]
theta_std = 0.03 # [rad]
normal_calls = []
def record_normal(mean, standard_deviation):
normal_calls.append((mean, standard_deviation))
return mean
monkeypatch.setattr(np.random, "normal", record_normal)
dispersion = NormalVectorAngleDispersion(
vector_path,
phiStd=phi_std,
thetaStd=theta_std,
phiBoundsOffNom=[-0.1, 0.1],
thetaBoundsOffNom=[-0.1, 0.1]
)
dispersed_vector = dispersion.generate(sim)
nominal_vector = sim.get_DynModel().scObject.hub.unitVector
nominal_vector = nominal_vector / np.linalg.norm(nominal_vector)
nominal_spherical = dispersion.cart2Spherical(nominal_vector)
assert np.isclose(np.linalg.norm(dispersed_vector), 1.0)
assert np.allclose(normal_calls[0], [nominal_spherical[1], phi_std])
assert np.allclose(normal_calls[1], [nominal_spherical[2], theta_std])
assert dispersion.getDispersionMag() == [r"0.0 $\sigma$", r"0.0 $\sigma$"]
[docs]
def test_normal_vector_dispersion_uses_configured_statistics():
"""Verify normal vector dispersions retain mean and standard deviation."""
sim = DummySimulation()
vector_path = "TaskList[0].TaskModels[0].hub.r_BcB_B"
mean = 2.5 # [m]
std_deviation = 0.0 # [m]
dispersion = NormalVectorDispersion(
vector_path,
mean=mean,
stdDeviation=std_deviation
)
dispersed_vector = dispersion.generate(sim)
assert np.allclose(dispersed_vector, [mean, mean, mean])
[docs]
def test_populate_seeds_applies_before_configure_function():
"""Verify archived RNGSeed modifications are set before configuration."""
configured_seeds = []
def create_sim():
return DummySimulation()
def configure_sim(sim):
configured_seeds.append(sim.TaskList[0].TaskModels[0].RNGSeed)
def execute_sim(sim):
assert sim.TaskList[0].TaskModels[0].RNGSeed == 8675309
assert sim.TaskList[0].TaskModels[0].hub.mHub == 42.0
sim_params = SimulationParameters(
creationFunction=create_sim,
executionFunction=execute_sim,
configureFunction=configure_sim,
retentionPolicies=[],
dispersions=[],
shouldDisperseSeeds=False,
shouldArchiveParameters=False,
filename="",
icfilename="",
index=0,
modifications={
"TaskList[0].TaskModels[0].RNGSeed": "8675309",
"TaskList[0].TaskModels[0].hub.mHub": "42.0",
}
)
result = SimulationExecutor()([sim_params, None])
assert result == (True, 0)
assert configured_seeds == [8675309]
[docs]
def test_uniform_dispersion_randomizes_nested_sim_parameter():
"""Verify generated dispersions update the live simulation parameter."""
mass_path = "TaskList[0].TaskModels[0].hub.mHub"
mass_bounds = [24.0, 26.0] # [kg]
observed_masses = []
generated_masses = []
def create_sim():
return DummySimulation()
def execute_sim(sim):
observed_masses.append(sim.TaskList[0].TaskModels[0].hub.mHub)
for run_index in range(2):
sim_params = SimulationParameters(
creationFunction=create_sim,
executionFunction=execute_sim,
configureFunction=None,
retentionPolicies=[],
dispersions=[UniformDispersion(mass_path, mass_bounds)],
shouldDisperseSeeds=False,
shouldArchiveParameters=False,
filename="",
icfilename="",
index=run_index,
modifications={}
)
result = SimulationExecutor()([sim_params, None])
generated_mass = SimulationExecutor.parseModificationValue(
sim_params.modifications[mass_path]
)
assert result == (True, run_index)
generated_masses.append(generated_mass)
assert observed_masses == generated_masses
assert observed_masses[0] != observed_masses[1]
for observed_mass in observed_masses:
assert mass_bounds[0] <= observed_mass <= mass_bounds[1]
assert observed_mass != DummyHub().mHub
[docs]
def test_uniform_dispersion_randomizes_method_path_parameter():
"""Verify generated dispersions update a zero-argument method path."""
mass_path = "get_DynModel().scObject.hub.mHub"
mass_bounds = [34.0, 36.0] # [kg]
observed_masses = []
generated_masses = []
def create_sim():
return DummySimulation()
def execute_sim(sim):
observed_masses.append(sim.get_DynModel().scObject.hub.mHub)
for run_index in range(2):
sim_params = SimulationParameters(
creationFunction=create_sim,
executionFunction=execute_sim,
configureFunction=None,
retentionPolicies=[],
dispersions=[UniformDispersion(mass_path, mass_bounds)],
shouldDisperseSeeds=False,
shouldArchiveParameters=False,
filename="",
icfilename="",
index=run_index,
modifications={}
)
result = SimulationExecutor()([sim_params, None])
generated_mass = SimulationExecutor.parseModificationValue(
sim_params.modifications[mass_path]
)
assert result == (True, run_index)
generated_masses.append(generated_mass)
assert observed_masses == generated_masses
assert observed_masses[0] != observed_masses[1]
for observed_mass in observed_masses:
assert mass_bounds[0] <= observed_mass <= mass_bounds[1]
assert observed_mass != DummyHub().mHub
[docs]
def test_disperse_seeds_only_records_seeded_models():
"""Verify random seed dispersions are recorded only for seeded models."""
sim = DummySimulation()
random_seeds = SimulationExecutor.disperseSeeds(sim)
seed_path = "TaskList[0].TaskModels[0].RNGSeed"
assert set(random_seeds.keys()) == {seed_path}
assert isinstance(
SimulationExecutor.parseModificationValue(random_seeds[seed_path]),
int
)
if __name__ == "__main__":
test_apply_modification_updates_nested_attributes()
test_indexed_modification_updates_swig_copy_on_read_container()
test_double_indexed_modification_updates_swig_matrix3d()
test_vector_angle_dispersion_reads_nested_method_path()
test_normal_vector_dispersion_uses_configured_statistics()
test_populate_seeds_applies_before_configure_function()
test_uniform_dispersion_randomizes_nested_sim_parameter()
test_uniform_dispersion_randomizes_method_path_parameter()
test_disperse_seeds_only_records_seeded_models()