diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index af8aa696..bc88abf6 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -63,6 +63,10 @@ Development - |version| * Update files for compatibility with Ruff 0.16.0's new default lint rules, excluding C408. * Update ``np2EigenMatrix3d`` and ``np2EigenVectorXd`` import path to prevent deprecation warning while preserving compatibility with older versions of Basilisk. * Add support for configurable RSO attitude dynamics and new relative attitude observations, enabling inspection of spinning and tumbling targets. Update the `RSO Inspection `_ example to demonstrate non-nadir-pointing targets. See issue #355. +* Allow :func:`~bsk_rl.utils.functional.aliveness_checker` to run as a continuous Basilisk + event that interrupts the environment step on failure. Battery charge and reaction + wheel speed checks use this behavior by default, at the simulator rate. See issues + #337 and #59. Version 1.3.0 ------------- diff --git a/src/bsk_rl/sats/satellite.py b/src/bsk_rl/sats/satellite.py index 60c58d9c..5dffffd7 100644 --- a/src/bsk_rl/sats/satellite.py +++ b/src/bsk_rl/sats/satellite.py @@ -19,9 +19,12 @@ from bsk_rl.utils.functional import ( AbstractClassProperty, Resetable, + _log_aliveness_failure, collect_default_args, compose_types, + is_property, safe_dict_merge, + valid_func_name, ) from bsk_rl.utils.orbital import TrajectorySimulator @@ -275,6 +278,46 @@ def set_fsw(self, fsw_rate: float) -> "fsw.FSWModelABC": self.fsw = proxy(fsw) return fsw + def setup_aliveness_events(self, log_failure: bool = True) -> None: + """Create simulator events for continuous aliveness checkers. + + :meta private: + """ + for model in (self.dynamics, self.fsw): + for name in dir(model): + if ( + not name.startswith("__") + and not is_property(model, name) + and callable(getattr(model, name)) + and hasattr(getattr(model, name), "is_aliveness_checker") + and getattr(getattr(model, name), "continuous", False) + ): + checker = getattr(model, name) + check_rate = checker.check_rate + if check_rate is None: + check_rate = self.simulator.sim_rate + + def condition(sim, checker=checker): + return self._is_alive and not checker() + + def side_effect(sim, checker_name=checker.__name__): + if log_failure: + _log_aliveness_failure(self, checker_name) + self._is_alive = False + self.record_death(sim.sim_time) + + self.simulator.createNewEvent( + valid_func_name( + f"aliveness_{self.name}_{model.__class__.__name__}_{name}" + ), + macros.sec2nano(check_rate), + True, + conditionFunction=condition, + actionFunction=side_effect, + terminal=True, + exactRateMatch=False, + ) + def reset_during_sim_init(self) -> None: """Called during environment reset, during Basilisk simulation initialization.""" self.observation_builder.reset_during_sim_init() @@ -285,6 +328,7 @@ def reset_post_sim_init(self) -> None: """Called during environment reset, after Basilisk simulation initialization.""" self.observation_builder.reset_post_sim_init() self.action_builder.reset_post_sim_init() + self.setup_aliveness_events() @property def observation_space(self) -> spaces.Space: diff --git a/src/bsk_rl/sim/dyn/__init__.py b/src/bsk_rl/sim/dyn/__init__.py index 17eab830..46a28b53 100644 --- a/src/bsk_rl/sim/dyn/__init__.py +++ b/src/bsk_rl/sim/dyn/__init__.py @@ -31,8 +31,13 @@ ------------------ Certain functions in the dynamics model are decorated with the :func:`~bsk_rl.utils.functional.aliveness_checker` -decorator. These functions are called at each step to check if the satellite is still -operational, returning true if the satellite is still alive. +decorator. These functions are called at each environment step to check if the satellite +is still operational, returning true if the satellite is still alive. Passing +``continuous=True`` to the decorator also evaluates the checker during integration and +stops the environment step if it fails. + +Battery charge and reaction wheel speed checks use continuous checking by default, +at the simulator rate. """ diff --git a/src/bsk_rl/sim/dyn/base.py b/src/bsk_rl/sim/dyn/base.py index 3508c652..5b1b5f1b 100644 --- a/src/bsk_rl/sim/dyn/base.py +++ b/src/bsk_rl/sim/dyn/base.py @@ -623,9 +623,9 @@ def setup_reaction_wheel_dyn_effector( self.task_name, self.rwStateEffector, ModelPriority=priority ) - @aliveness_checker + @aliveness_checker(continuous=True) def rw_speeds_valid(self) -> bool: - """Check if any wheel speed exceeds the ``maxWheelSpeed``.""" + """Check wheel speeds against ``maxWheelSpeed`` during integration.""" valid = all( abs(speed) < self.maxWheelSpeed * macros.rpm2radsec for speed in self.wheel_speeds @@ -742,13 +742,12 @@ def setup_battery( self.task_name, self.powerMonitor, ModelPriority=priority ) - @aliveness_checker + @aliveness_checker(continuous=True) def battery_valid(self) -> bool: """Check if the battery has charge remaining. - Note that this check is instantaneous. If a satellite runs out of power during a - environment step but then recharges to have positive power at the end of the step, - the satellite will still be considered alive. + Evaluated during integration so battery depletion interrupts the environment + step, even if the satellite would recharge later in the step. """ return self.battery_charge > 0 diff --git a/src/bsk_rl/sim/fsw/__init__.py b/src/bsk_rl/sim/fsw/__init__.py index c45e23b4..8021ab86 100644 --- a/src/bsk_rl/sim/fsw/__init__.py +++ b/src/bsk_rl/sim/fsw/__init__.py @@ -25,8 +25,10 @@ ------------------ Certain functions in the FSW models are decorated with the :func:`~bsk_rl.utils.functional.aliveness_checker` -decorator. These functions are called at each step to check if the satellite is still -operational, returning true if the satellite is still alive. +decorator. These functions are called at each environment step to check if the satellite +is still operational, returning true if the satellite is still alive. Passing +``continuous=True`` to the decorator also evaluates the checker during integration and +stops the environment step if it fails. """ from bsk_rl.sim.fsw.base import ( diff --git a/src/bsk_rl/utils/functional.py b/src/bsk_rl/utils/functional.py index b17c6674..25203ef9 100644 --- a/src/bsk_rl/utils/functional.py +++ b/src/bsk_rl/utils/functional.py @@ -5,7 +5,7 @@ from copy import deepcopy from functools import wraps from types import new_class -from typing import Any, Callable, ParamSpec, TypeVar +from typing import Any, Callable, Optional, ParamSpec, TypeVar from unittest.mock import MagicMock import numpy as np @@ -115,23 +115,47 @@ def vectorize_nested_dict(dictionary: dict) -> tuple[list[str], np.ndarray]: return list(np.concatenate(keys)), np.concatenate(values) -def aliveness_checker(func: Callable[..., bool]) -> Callable[..., bool]: - """Decorate function to evaluate when checking for satellite aliveness.""" +def _log_aliveness_failure(satellite: Any, checker_name: str) -> None: + """Log a failed aliveness check without evaluating it again.""" + satellite.logger.warning(f"failed {checker_name} check") - @wraps(func) - def inner(*args, log_failure=False, **kwargs) -> bool: - self = args[0] - alive = func(*args, **kwargs) - if not alive and log_failure: - self.satellite.logger.warning(f"failed {func.__name__} check") - return alive - inner.__doc__ = ( - "*Decorated with* :class:`~bsk_rl.utils.functional.aliveness_checker`\n\n" - + str(func.__doc__) - ) - inner.is_aliveness_checker = True - return inner +def aliveness_checker( + func: Optional[Callable[..., bool]] = None, + *, + continuous: bool = False, + check_rate: Optional[float] = None, +) -> Callable: + """Decorate function to evaluate when checking for satellite aliveness. + + Checkers are evaluated at environment steps. Set ``continuous=True`` to also + evaluate the checker during integration via a Basilisk event that terminates + the environment step on failure. ``check_rate`` (seconds) sets the minimum + interval between evaluations and defaults to the simulator rate. Checks run + at simulation timesteps, so the actual interval may be longer. + """ + + def decorator(func: Callable[..., bool]) -> Callable[..., bool]: + @wraps(func) + def inner(*args, log_failure=False, **kwargs) -> bool: + self = args[0] + alive = func(*args, **kwargs) + if not alive and log_failure: + _log_aliveness_failure(self.satellite, func.__name__) + return alive + + inner.__doc__ = ( + "*Decorated with* :class:`~bsk_rl.utils.functional.aliveness_checker`\n\n" + + str(func.__doc__) + ) + inner.is_aliveness_checker = True + inner.continuous = continuous + inner.check_rate = check_rate + return inner + + if func is None: + return decorator + return decorator(func) def check_aliveness_checkers(model: Any, log_failure=False) -> bool: @@ -158,7 +182,7 @@ def check_aliveness_checkers(model: Any, log_failure=False) -> bool: def is_property(obj: Any, attr_name: str) -> bool: """Check if obj has an ``@property`` called ``attr_name`` without calling it.""" - cls = type(obj) + cls = obj.__class__ attribute = getattr(cls, attr_name, None) return attribute is not None and isinstance(attribute, property) diff --git a/tests/integration/sim/test_int_dynamics.py b/tests/integration/sim/test_int_dynamics.py index a73aa87a..e6aabdf6 100644 --- a/tests/integration/sim/test_int_dynamics.py +++ b/tests/integration/sim/test_int_dynamics.py @@ -2,9 +2,11 @@ import gymnasium as gym import pytest +from Basilisk.utilities import macros from bsk_rl import act, data, obs, sats, scene from bsk_rl.sim import dyn, fsw +from bsk_rl.utils.functional import aliveness_checker from bsk_rl.utils.orbital import random_orbit @@ -240,3 +242,109 @@ class DeputySat(sats.Satellite): assert sat2.dynamics.out_of_ranges == [] assert not terminated["Chief"] assert not terminated["Deputy"] + + +class TestContinuousAlivenessChecker: + @pytest.mark.parametrize( + "continuous,check_rate,expected_time", + [ + (False, None, 100.0), + (True, None, 5.0), + (True, 0.5, 5.0), + (True, 2.0, 6.0), + (True, 2.5, 6.0), + (True, 3.0, 6.0), + ], + ) + def test_step_vs_continuous( + self, continuous, check_rate, expected_time, caplog + ): + class BoomDyn(dyn.DynamicsModel): + @aliveness_checker(continuous=continuous, check_rate=check_rate) + def time_valid(self): + return self.simulator.sim_time < 5.0 + + class BoomSat(sats.Satellite): + fsw_type = fsw.FSWModel + dyn_type = BoomDyn + observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] + action_spec: ClassVar[list[act.Action]] = [act.Drift()] + + env = gym.make( + "SatelliteTasking-v1", + satellite=BoomSat("Boom", sat_args=dict(oe=random_orbit)), + sim_rate=1.0, + time_limit=100.0, + max_step_duration=100.0, + disable_env_checker=True, + ) + + env.reset(seed=0) + _, _, terminated, _, _ = env.step(0) + + assert env.unwrapped.simulator.sim_time == pytest.approx(expected_time) + assert not env.unwrapped.satellite.is_alive() + assert env.unwrapped.satellite.time_of_death == pytest.approx(expected_time) + assert terminated + assert caplog.messages.count("failed time_valid check") == 1 + env.close() + + @pytest.mark.parametrize("checker_name", ["battery_valid", "rw_speeds_valid"]) + @pytest.mark.parametrize("continuous", [False, True]) + def test_transient_failure(self, checker_name, continuous, caplog): + class TransientFailureDyn(dyn.BasicDynamicsModel): + @property + def battery_charge(self): + if ( + checker_name == "battery_valid" + and 5.0 <= self.simulator.sim_time < 10.0 + ): + return 0.0 + return super().battery_charge + + @property + def wheel_speeds(self): + if ( + checker_name == "rw_speeds_valid" + and 5.0 <= self.simulator.sim_time < 10.0 + ): + return [1.1 * self.maxWheelSpeed * macros.rpm2radsec] * 3 + return super().wheel_speeds + + if not continuous: + checker = getattr(TransientFailureDyn, checker_name) + setattr( + TransientFailureDyn, + checker_name, + aliveness_checker(checker.__wrapped__), + ) + + class FailureSat(sats.Satellite): + dyn_type = TransientFailureDyn + fsw_type = fsw.BasicFSWModel + observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] + action_spec: ClassVar[list[act.Action]] = [act.Drift()] + + env = gym.make( + "SatelliteTasking-v1", + satellite=FailureSat("Failure", sat_args=dict(oe=random_orbit)), + sim_rate=1.0, + time_limit=100.0, + max_step_duration=100.0, + disable_env_checker=True, + ) + + env.reset(seed=0) + _, _, terminated, _, _ = env.step(0) + + expected_time = 5.0 if continuous else 100.0 + assert env.unwrapped.simulator.sim_time == pytest.approx(expected_time) + assert terminated is continuous + assert env.unwrapped.satellite.is_alive() is not continuous + if continuous: + assert env.unwrapped.satellite.time_of_death == pytest.approx(5.0) + assert caplog.messages.count(f"failed {checker_name} check") == 1 + else: + assert env.unwrapped.satellite.time_of_death is None + assert f"failed {checker_name} check" not in caplog.messages + env.close() diff --git a/tests/integration/sim/test_int_fsw.py b/tests/integration/sim/test_int_fsw.py index 27265764..60c490fc 100644 --- a/tests/integration/sim/test_int_fsw.py +++ b/tests/integration/sim/test_int_fsw.py @@ -1 +1,55 @@ -# For fsw models not tested in other tests +from typing import ClassVar + +import gymnasium as gym +import pytest + +from bsk_rl import act, obs, sats +from bsk_rl.sim import dyn, fsw +from bsk_rl.utils.functional import aliveness_checker +from bsk_rl.utils.orbital import random_orbit + + +class TestContinuousAlivenessChecker: + @pytest.mark.parametrize( + "continuous,check_rate,expected_time", + [ + (False, None, 100.0), + (True, None, 5.0), + (True, 0.5, 5.0), + (True, 2.0, 6.0), + (True, 2.5, 6.0), + (True, 3.0, 6.0), + ], + ) + def test_step_vs_continuous( + self, continuous, check_rate, expected_time, caplog + ): + class BoomFSW(fsw.FSWModel): + @aliveness_checker(continuous=continuous, check_rate=check_rate) + def time_valid(self): + return self.simulator.sim_time < 5.0 + + class BoomSat(sats.Satellite): + fsw_type = BoomFSW + dyn_type = dyn.DynamicsModel + observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] + action_spec: ClassVar[list[act.Action]] = [act.Drift()] + + env = gym.make( + "SatelliteTasking-v1", + satellite=BoomSat("Boom", sat_args=dict(oe=random_orbit)), + sim_rate=1.0, + time_limit=100.0, + max_step_duration=100.0, + disable_env_checker=True, + ) + + env.reset(seed=0) + _, _, terminated, _, _ = env.step(0) + + assert env.unwrapped.simulator.sim_time == pytest.approx(expected_time) + assert not env.unwrapped.satellite.is_alive() + assert env.unwrapped.satellite.time_of_death == pytest.approx(expected_time) + assert terminated + assert caplog.messages.count("failed time_valid check") == 1 + env.close() diff --git a/tests/unittest/sats/test_satellite.py b/tests/unittest/sats/test_satellite.py index a30b099b..c7b8ec0f 100644 --- a/tests/unittest/sats/test_satellite.py +++ b/tests/unittest/sats/test_satellite.py @@ -1,5 +1,6 @@ from functools import partial from unittest.mock import MagicMock, patch +from weakref import proxy import numpy as np import pytest @@ -8,6 +9,7 @@ from bsk_rl import sats from bsk_rl.sim import Simulator from bsk_rl.sim.fsw import Task +from bsk_rl.utils import functional class MockDynType: @@ -99,10 +101,8 @@ def test_invalid_identifier_fails_at_construction_and_renaming(self, name): def test_deadline_events_use_the_validated_name_after_renaming(self): sat = sats.Satellite(name="camera_a") sat.simulator = MagicMock(eventMap={}, sim_rate=1.0) - sat.simulator.createNewEvent.side_effect = ( - lambda name, *args, **kwargs: sat.simulator.eventMap.setdefault( - name, MagicMock() - ) + sat.simulator.createNewEvent.side_effect = lambda name, *args, **kwargs: ( + sat.simulator.eventMap.setdefault(name, MagicMock()) ) sat.update_timed_terminal_event(10.0) assert sat._timed_terminal_event_name == "timed_terminal_camera_a" @@ -177,6 +177,92 @@ def test_satellite_command(self): def test_update_timed_terminal_event(self): pass # Probably better with integration testing + @pytest.mark.parametrize("model_name", ["dynamics", "fsw"]) + @pytest.mark.parametrize("log_failure", [True, False]) + def test_setup_aliveness_events(self, model_name, log_failure): + sat = sats.Satellite(name="TestSat", sat_args={}) + sat._is_alive = True + sat.time_of_death = None + sat.simulator = MagicMock(sim_rate=2.0, sim_time=12.0) + sat.logger = MagicMock() + + class Dyn: + def __init__(self, satellite): + self.satellite = satellite + self.fail_continuous = False + self.check_count = 0 + + @property + def should_not_run(self): + raise RuntimeError("property should not be accessed") + + @functional.aliveness_checker + def step_valid(self): + return True + + @functional.aliveness_checker(continuous=True, check_rate=0.25) + def continuous_valid(self): + self.check_count += 1 + return not self.fail_continuous + + class Fsw(Dyn): + @functional.aliveness_checker(continuous=True) + def continuous_valid(self): + self.check_count += 1 + return not self.fail_continuous + + dyn = Dyn(sat) + fsw = Fsw(sat) + sat.dynamics = proxy(dyn) + sat.fsw = proxy(fsw) + sat.setup_aliveness_events(log_failure=log_failure) + + assert sat.simulator.createNewEvent.call_count == 2 + event_names = [ + call.args[0] for call in sat.simulator.createNewEvent.call_args_list + ] + assert any("continuous_valid" in name and "Dyn" in name for name in event_names) + assert any("continuous_valid" in name and "Fsw" in name for name in event_names) + assert len(set(event_names)) == 2 + assert not any("ProxyType" in name for name in event_names) + + dyn_call = sat.simulator.createNewEvent.call_args_list[0] + fsw_call = sat.simulator.createNewEvent.call_args_list[1] + assert dyn_call.args[1] == macros.sec2nano(0.25) + assert fsw_call.args[1] == macros.sec2nano(sat.simulator.sim_rate) + for event_call in (dyn_call, fsw_call): + assert event_call.kwargs["terminal"] is True + assert event_call.kwargs["exactRateMatch"] is False + assert event_call.kwargs["conditionFunction"](sat.simulator) is False + sat.logger.warning.assert_not_called() + + model = getattr(sat, model_name) + event_call = dyn_call if model_name == "dynamics" else fsw_call + model.fail_continuous = True + assert event_call.kwargs["conditionFunction"](sat.simulator) is True + event_call.kwargs["actionFunction"](sat.simulator) + assert sat._is_alive is False + assert sat.time_of_death == 12.0 + assert model.check_count == 2 + if log_failure: + sat.logger.warning.assert_called_once_with("failed continuous_valid check") + else: + sat.logger.warning.assert_not_called() + + other_model = fsw if model_name == "dynamics" else dyn + other_model.fail_continuous = True + sat.simulator.sim_time = 15.0 + for event_call in (dyn_call, fsw_call): + assert event_call.kwargs["conditionFunction"](sat.simulator) is False + assert model.check_count == 2 + assert other_model.check_count == 1 + assert not sat.is_alive() + assert sat.time_of_death == 12.0 + if log_failure: + sat.logger.warning.assert_called_once_with("failed continuous_valid check") + else: + sat.logger.warning.assert_not_called() + def test_disable_timed_event(self): sat = sats.Satellite(name="TestSat", sat_args={}) sat.simulator = MagicMock(eventMap={"some_event": 1}) diff --git a/tests/unittest/utils/test_functional.py b/tests/unittest/utils/test_functional.py index c6ddfab1..cf1b8649 100644 --- a/tests/unittest/utils/test_functional.py +++ b/tests/unittest/utils/test_functional.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock +from weakref import proxy import numpy as np import pytest @@ -125,6 +126,43 @@ def test_dead(self, type): assert functional.check_aliveness_checkers(d, log_failure=True) is False d.satellite.logger.warning.assert_called_with("failed is_living check") + def test_continuous_defaults(self): + a = self.Alive() + assert a.is_alive.continuous is False + assert a.is_alive.check_rate is None + + def test_continuous_kwargs(self): + class Continuous: + @functional.aliveness_checker(continuous=True, check_rate=0.5) + def still_alive(self): + return True + + c = Continuous() + assert c.still_alive.continuous is True + assert c.still_alive.check_rate == 0.5 + assert c.still_alive() + + @pytest.mark.parametrize("alive", [True, False]) + def test_empty_parentheses(self, alive): + class DefaultChecker: + satellite = MagicMock() + + @functional.aliveness_checker() + def still_alive(self): + return alive + + model = DefaultChecker() + assert model.still_alive.is_aliveness_checker is True + assert model.still_alive.continuous is False + assert model.still_alive.check_rate is None + assert functional.check_aliveness_checkers(model, log_failure=True) is alive + if alive: + model.satellite.logger.warning.assert_not_called() + else: + model.satellite.logger.warning.assert_called_once_with( + "failed still_alive check" + ) + @pytest.mark.parametrize("prop_name,expected", [("prop", True), ("not_a_prop", False)]) def test_is_property(prop_name, expected): @@ -139,3 +177,4 @@ def prop(self): c = Class() assert functional.is_property(c, prop_name) == expected + assert functional.is_property(proxy(c), prop_name) == expected