From ffc3bb8b6b4c65f9767472f2ac3b3756082725c8 Mon Sep 17 00:00:00 2001 From: Strtus Date: Sat, 22 Aug 2026 12:13:25 +0800 Subject: [PATCH 1/4] [#337] Allow continuous aliveness checkers --- docs/source/release_notes.rst | 4 ++ src/bsk_rl/sats/satellite.py | 40 +++++++++++++++++ src/bsk_rl/sim/dyn/__init__.py | 6 ++- src/bsk_rl/sim/fsw/__init__.py | 6 ++- src/bsk_rl/sim/simulator.py | 1 + src/bsk_rl/utils/functional.py | 50 ++++++++++++++------- tests/integration/sim/test_int_dynamics.py | 32 +++++++++++++ tests/unittest/sats/test_satellite.py | 52 ++++++++++++++++++++++ tests/unittest/utils/test_functional.py | 16 +++++++ 9 files changed, 187 insertions(+), 20 deletions(-) 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..390562b7 100644 --- a/src/bsk_rl/sats/satellite.py +++ b/src/bsk_rl/sats/satellite.py @@ -21,6 +21,7 @@ Resetable, collect_default_args, compose_types, + is_property, safe_dict_merge, ) from bsk_rl.utils.orbital import TrajectorySimulator @@ -275,6 +276,45 @@ def set_fsw(self, fsw_rate: float) -> "fsw.FSWModelABC": self.fsw = proxy(fsw) return fsw + def setup_aliveness_events(self) -> 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 not checker() + + def side_effect(sim, checker=checker): + checker(log_failure=True) + self._is_alive = False + self.record_death(sim.sim_time) + + self.simulator.createNewEvent( + valid_func_name( + f"aliveness_{self.name}_{type(model).__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() diff --git a/src/bsk_rl/sim/dyn/__init__.py b/src/bsk_rl/sim/dyn/__init__.py index 17eab830..77c67cf5 100644 --- a/src/bsk_rl/sim/dyn/__init__.py +++ b/src/bsk_rl/sim/dyn/__init__.py @@ -31,8 +31,10 @@ ------------------ 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. """ 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/sim/simulator.py b/src/bsk_rl/sim/simulator.py index bca98b07..7aa99df5 100644 --- a/src/bsk_rl/sim/simulator.py +++ b/src/bsk_rl/sim/simulator.py @@ -62,6 +62,7 @@ def __init__( satellite.set_simulator(self) self.dynamics_list[satellite.name] = satellite.set_dynamics(self.sim_rate) self.fsw_list[satellite.name] = satellite.set_fsw(self.sim_rate) + satellite.setup_aliveness_events() def finish_init(self) -> None: """Finish simulator initialization.""" diff --git a/src/bsk_rl/utils/functional.py b/src/bsk_rl/utils/functional.py index b17c6674..54ad874e 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,41 @@ 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 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. - @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 + 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 how often a + continuous checker is evaluated and defaults to the simulator rate. + """ + + 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: + 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 + inner.continuous = continuous + inner.check_rate = check_rate + return inner - inner.__doc__ = ( - "*Decorated with* :class:`~bsk_rl.utils.functional.aliveness_checker`\n\n" - + str(func.__doc__) - ) - inner.is_aliveness_checker = True - return inner + if func is None: + return decorator + return decorator(func) def check_aliveness_checkers(model: Any, log_failure=False) -> bool: diff --git a/tests/integration/sim/test_int_dynamics.py b/tests/integration/sim/test_int_dynamics.py index a73aa87a..0c933425 100644 --- a/tests/integration/sim/test_int_dynamics.py +++ b/tests/integration/sim/test_int_dynamics.py @@ -5,6 +5,7 @@ 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 +241,34 @@ class DeputySat(sats.Satellite): assert sat2.dynamics.out_of_ranges == [] assert not terminated["Chief"] assert not terminated["Deputy"] + + +class TestContinuousAlivenessChecker: + @pytest.mark.parametrize("continuous,expected_time", [(False, 100.0), (True, 5.0)]) + def test_step_vs_continuous(self, continuous, expected_time): + class BoomDyn(dyn.DynamicsModel): + @aliveness_checker(continuous=continuous) + 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() + _, _, terminated, _, _ = env.step(0) + + assert env.unwrapped.simulator.sim_time == pytest.approx(expected_time) + assert not env.unwrapped.satellite.is_alive() + assert terminated diff --git a/tests/unittest/sats/test_satellite.py b/tests/unittest/sats/test_satellite.py index a30b099b..444bde99 100644 --- a/tests/unittest/sats/test_satellite.py +++ b/tests/unittest/sats/test_satellite.py @@ -8,6 +8,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: @@ -177,6 +178,57 @@ def test_satellite_command(self): def test_update_timed_terminal_event(self): pass # Probably better with integration testing + def test_setup_aliveness_events(self): + sat = sats.Satellite(name="TestSat", sat_args={}) + sat._is_alive = True + sat.time_of_death = None + sat.simulator = MagicMock(sim_rate=1.0, sim_time=12.0) + + class Dyn: + def __init__(self, satellite): + self.satellite = satellite + self.fail_continuous = False + + @functional.aliveness_checker + def step_valid(self): + return True + + @functional.aliveness_checker(continuous=True, check_rate=0.25) + def continuous_valid(self): + return not self.fail_continuous + + class Fsw: + def __init__(self, satellite): + self.satellite = satellite + + @functional.aliveness_checker(continuous=True) + def fsw_valid(self): + return True + + sat.dynamics = Dyn(sat) + sat.fsw = Fsw(sat) + sat.setup_aliveness_events() + + 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 for name in event_names) + assert any("fsw_valid" in name for name in event_names) + + dyn_call = sat.simulator.createNewEvent.call_args_list[0] + assert "continuous_valid" in dyn_call.args[0] + assert dyn_call.args[1] == macros.sec2nano(0.25) + assert dyn_call.kwargs["terminal"] is True + assert dyn_call.kwargs["exactRateMatch"] is False + + assert dyn_call.kwargs["conditionFunction"](sat.simulator) is False + sat.dynamics.fail_continuous = True + assert dyn_call.kwargs["conditionFunction"](sat.simulator) is True + dyn_call.kwargs["actionFunction"](sat.simulator) + assert sat._is_alive is False + assert sat.time_of_death == 12.0 + 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..479d49ba 100644 --- a/tests/unittest/utils/test_functional.py +++ b/tests/unittest/utils/test_functional.py @@ -125,6 +125,22 @@ 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("prop_name,expected", [("prop", True), ("not_a_prop", False)]) def test_is_property(prop_name, expected): From c8db3a1a8bd3842f1746a764a8607869760974fc Mon Sep 17 00:00:00 2001 From: Strtus Date: Sat, 22 Aug 2026 21:35:39 +0800 Subject: [PATCH 2/4] [#337] Use model class for aliveness event setup --- src/bsk_rl/sats/satellite.py | 6 +++--- src/bsk_rl/utils/functional.py | 2 +- tests/unittest/sats/test_satellite.py | 16 ++++++++++++---- tests/unittest/utils/test_functional.py | 2 ++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/bsk_rl/sats/satellite.py b/src/bsk_rl/sats/satellite.py index 390562b7..0d857d6b 100644 --- a/src/bsk_rl/sats/satellite.py +++ b/src/bsk_rl/sats/satellite.py @@ -298,14 +298,14 @@ def setup_aliveness_events(self) -> None: def condition(sim, checker=checker): return not checker() - def side_effect(sim, checker=checker): - checker(log_failure=True) + def side_effect(sim, name=name): + self.logger.warning(f"failed {name} check") self._is_alive = False self.record_death(sim.sim_time) self.simulator.createNewEvent( valid_func_name( - f"aliveness_{self.name}_{type(model).__name__}_{name}" + f"aliveness_{self.name}_{model.__class__.__name__}_{name}" ), macros.sec2nano(check_rate), True, diff --git a/src/bsk_rl/utils/functional.py b/src/bsk_rl/utils/functional.py index 54ad874e..af986414 100644 --- a/src/bsk_rl/utils/functional.py +++ b/src/bsk_rl/utils/functional.py @@ -176,7 +176,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/unittest/sats/test_satellite.py b/tests/unittest/sats/test_satellite.py index 444bde99..7cc27a38 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 @@ -189,6 +190,10 @@ def __init__(self, satellite): self.satellite = satellite self.fail_continuous = False + @property + def should_not_run(self): + raise RuntimeError("property should not be accessed") + @functional.aliveness_checker def step_valid(self): return True @@ -205,16 +210,19 @@ def __init__(self, satellite): def fsw_valid(self): return True - sat.dynamics = Dyn(sat) - sat.fsw = Fsw(sat) + dyn = Dyn(sat) + fsw = Fsw(sat) + sat.dynamics = proxy(dyn) + sat.fsw = proxy(fsw) sat.setup_aliveness_events() 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 for name in event_names) - assert any("fsw_valid" in name for name in event_names) + assert any("continuous_valid" in name and "Dyn" in name for name in event_names) + assert any("fsw_valid" in name and "Fsw" in name for name in event_names) + assert not any("ProxyType" in name for name in event_names) dyn_call = sat.simulator.createNewEvent.call_args_list[0] assert "continuous_valid" in dyn_call.args[0] diff --git a/tests/unittest/utils/test_functional.py b/tests/unittest/utils/test_functional.py index 479d49ba..9a2f0131 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 @@ -155,3 +156,4 @@ def prop(self): c = Class() assert functional.is_property(c, prop_name) == expected + assert functional.is_property(proxy(c), prop_name) == expected From 88d1f33c06fc7e8acee781db4cd5c9feb8e84570 Mon Sep 17 00:00:00 2001 From: Strtus Date: Fri, 23 Oct 2026 18:43:13 +0800 Subject: [PATCH 3/4] [#337] Enable continuous battery and wheel checks --- src/bsk_rl/sats/satellite.py | 8 +- src/bsk_rl/sim/dyn/__init__.py | 3 + src/bsk_rl/sim/dyn/base.py | 11 ++- src/bsk_rl/utils/functional.py | 12 ++- tests/integration/sim/test_int_dynamics.py | 94 ++++++++++++++++++++-- tests/unittest/sats/test_satellite.py | 61 +++++++++----- tests/unittest/utils/test_functional.py | 21 +++++ 7 files changed, 171 insertions(+), 39 deletions(-) diff --git a/src/bsk_rl/sats/satellite.py b/src/bsk_rl/sats/satellite.py index 0d857d6b..3252bc1b 100644 --- a/src/bsk_rl/sats/satellite.py +++ b/src/bsk_rl/sats/satellite.py @@ -19,10 +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 @@ -296,10 +298,10 @@ def setup_aliveness_events(self) -> None: check_rate = self.simulator.sim_rate def condition(sim, checker=checker): - return not checker() + return self._is_alive and not checker() - def side_effect(sim, name=name): - self.logger.warning(f"failed {name} check") + def side_effect(sim, checker_name=checker.__name__): + _log_aliveness_failure(self, checker_name) self._is_alive = False self.record_death(sim.sim_time) diff --git a/src/bsk_rl/sim/dyn/__init__.py b/src/bsk_rl/sim/dyn/__init__.py index 77c67cf5..46a28b53 100644 --- a/src/bsk_rl/sim/dyn/__init__.py +++ b/src/bsk_rl/sim/dyn/__init__.py @@ -36,6 +36,9 @@ ``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. + """ from deprecated import deprecated 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/utils/functional.py b/src/bsk_rl/utils/functional.py index af986414..25203ef9 100644 --- a/src/bsk_rl/utils/functional.py +++ b/src/bsk_rl/utils/functional.py @@ -115,6 +115,11 @@ def vectorize_nested_dict(dictionary: dict) -> tuple[list[str], np.ndarray]: return list(np.concatenate(keys)), np.concatenate(values) +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") + + def aliveness_checker( func: Optional[Callable[..., bool]] = None, *, @@ -125,8 +130,9 @@ def aliveness_checker( 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 how often a - continuous checker is evaluated and defaults to the simulator rate. + 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]: @@ -135,7 +141,7 @@ 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") + _log_aliveness_failure(self.satellite, func.__name__) return alive inner.__doc__ = ( diff --git a/tests/integration/sim/test_int_dynamics.py b/tests/integration/sim/test_int_dynamics.py index 0c933425..2765536b 100644 --- a/tests/integration/sim/test_int_dynamics.py +++ b/tests/integration/sim/test_int_dynamics.py @@ -2,6 +2,7 @@ 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 @@ -244,16 +245,34 @@ class DeputySat(sats.Satellite): class TestContinuousAlivenessChecker: - @pytest.mark.parametrize("continuous,expected_time", [(False, 100.0), (True, 5.0)]) - def test_step_vs_continuous(self, continuous, expected_time): + @pytest.mark.parametrize("model_name", ["dynamics", "fsw"]) + @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, model_name, continuous, check_rate, expected_time, caplog + ): class BoomDyn(dyn.DynamicsModel): - @aliveness_checker(continuous=continuous) + @aliveness_checker(continuous=continuous, check_rate=check_rate) + def time_valid(self): + return self.simulator.sim_time < 5.0 + + 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 = fsw.FSWModel - dyn_type = BoomDyn + fsw_type = BoomFSW if model_name == "fsw" else fsw.FSWModel + dyn_type = BoomDyn if model_name == "dynamics" else dyn.DynamicsModel observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] action_spec: ClassVar[list[act.Action]] = [act.Drift()] @@ -266,9 +285,72 @@ class BoomSat(sats.Satellite): disable_env_checker=True, ) - env.reset() + 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/unittest/sats/test_satellite.py b/tests/unittest/sats/test_satellite.py index 7cc27a38..f2f20fa3 100644 --- a/tests/unittest/sats/test_satellite.py +++ b/tests/unittest/sats/test_satellite.py @@ -101,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" @@ -179,16 +177,19 @@ def test_satellite_command(self): def test_update_timed_terminal_event(self): pass # Probably better with integration testing - def test_setup_aliveness_events(self): + @pytest.mark.parametrize("model_name", ["dynamics", "fsw"]) + def test_setup_aliveness_events(self, model_name): sat = sats.Satellite(name="TestSat", sat_args={}) sat._is_alive = True sat.time_of_death = None - sat.simulator = MagicMock(sim_rate=1.0, sim_time=12.0) + 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): @@ -200,15 +201,14 @@ def step_valid(self): @functional.aliveness_checker(continuous=True, check_rate=0.25) def continuous_valid(self): + self.check_count += 1 return not self.fail_continuous - class Fsw: - def __init__(self, satellite): - self.satellite = satellite - + class Fsw(Dyn): @functional.aliveness_checker(continuous=True) - def fsw_valid(self): - return True + def continuous_valid(self): + self.check_count += 1 + return not self.fail_continuous dyn = Dyn(sat) fsw = Fsw(sat) @@ -221,21 +221,40 @@ def fsw_valid(self): 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("fsw_valid" in name and "Fsw" 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] - assert "continuous_valid" in dyn_call.args[0] + fsw_call = sat.simulator.createNewEvent.call_args_list[1] assert dyn_call.args[1] == macros.sec2nano(0.25) - assert dyn_call.kwargs["terminal"] is True - assert dyn_call.kwargs["exactRateMatch"] is False - - assert dyn_call.kwargs["conditionFunction"](sat.simulator) is False - sat.dynamics.fail_continuous = True - assert dyn_call.kwargs["conditionFunction"](sat.simulator) is True - dyn_call.kwargs["actionFunction"](sat.simulator) + 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 + sat.logger.warning.assert_called_once_with("failed continuous_valid check") + + 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 + sat.logger.warning.assert_called_once_with("failed continuous_valid check") def test_disable_timed_event(self): sat = sats.Satellite(name="TestSat", sat_args={}) diff --git a/tests/unittest/utils/test_functional.py b/tests/unittest/utils/test_functional.py index 9a2f0131..cf1b8649 100644 --- a/tests/unittest/utils/test_functional.py +++ b/tests/unittest/utils/test_functional.py @@ -142,6 +142,27 @@ def still_alive(self): 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): From 605d62a7658c94303960384423a022826aa0d7f4 Mon Sep 17 00:00:00 2001 From: Yumeka Nagano Date: Tue, 3 Nov 2026 14:51:05 -0700 Subject: [PATCH 4/4] [#337] Make continuous failure logging configurable and separate FSW tests --- src/bsk_rl/sats/satellite.py | 6 ++- src/bsk_rl/sim/simulator.py | 1 - tests/integration/sim/test_int_dynamics.py | 12 ++--- tests/integration/sim/test_int_fsw.py | 56 +++++++++++++++++++++- tests/unittest/sats/test_satellite.py | 15 ++++-- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/bsk_rl/sats/satellite.py b/src/bsk_rl/sats/satellite.py index 3252bc1b..5dffffd7 100644 --- a/src/bsk_rl/sats/satellite.py +++ b/src/bsk_rl/sats/satellite.py @@ -278,7 +278,7 @@ def set_fsw(self, fsw_rate: float) -> "fsw.FSWModelABC": self.fsw = proxy(fsw) return fsw - def setup_aliveness_events(self) -> None: + def setup_aliveness_events(self, log_failure: bool = True) -> None: """Create simulator events for continuous aliveness checkers. :meta private: @@ -301,7 +301,8 @@ def condition(sim, checker=checker): return self._is_alive and not checker() def side_effect(sim, checker_name=checker.__name__): - _log_aliveness_failure(self, checker_name) + if log_failure: + _log_aliveness_failure(self, checker_name) self._is_alive = False self.record_death(sim.sim_time) @@ -327,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/simulator.py b/src/bsk_rl/sim/simulator.py index 7aa99df5..bca98b07 100644 --- a/src/bsk_rl/sim/simulator.py +++ b/src/bsk_rl/sim/simulator.py @@ -62,7 +62,6 @@ def __init__( satellite.set_simulator(self) self.dynamics_list[satellite.name] = satellite.set_dynamics(self.sim_rate) self.fsw_list[satellite.name] = satellite.set_fsw(self.sim_rate) - satellite.setup_aliveness_events() def finish_init(self) -> None: """Finish simulator initialization.""" diff --git a/tests/integration/sim/test_int_dynamics.py b/tests/integration/sim/test_int_dynamics.py index 2765536b..e6aabdf6 100644 --- a/tests/integration/sim/test_int_dynamics.py +++ b/tests/integration/sim/test_int_dynamics.py @@ -245,7 +245,6 @@ class DeputySat(sats.Satellite): class TestContinuousAlivenessChecker: - @pytest.mark.parametrize("model_name", ["dynamics", "fsw"]) @pytest.mark.parametrize( "continuous,check_rate,expected_time", [ @@ -258,21 +257,16 @@ class TestContinuousAlivenessChecker: ], ) def test_step_vs_continuous( - self, model_name, continuous, check_rate, expected_time, caplog + 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 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 if model_name == "fsw" else fsw.FSWModel - dyn_type = BoomDyn if model_name == "dynamics" else dyn.DynamicsModel + fsw_type = fsw.FSWModel + dyn_type = BoomDyn observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] action_spec: ClassVar[list[act.Action]] = [act.Drift()] 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 f2f20fa3..c7b8ec0f 100644 --- a/tests/unittest/sats/test_satellite.py +++ b/tests/unittest/sats/test_satellite.py @@ -178,7 +178,8 @@ def test_update_timed_terminal_event(self): pass # Probably better with integration testing @pytest.mark.parametrize("model_name", ["dynamics", "fsw"]) - def test_setup_aliveness_events(self, model_name): + @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 @@ -214,7 +215,7 @@ def continuous_valid(self): fsw = Fsw(sat) sat.dynamics = proxy(dyn) sat.fsw = proxy(fsw) - sat.setup_aliveness_events() + sat.setup_aliveness_events(log_failure=log_failure) assert sat.simulator.createNewEvent.call_count == 2 event_names = [ @@ -243,7 +244,10 @@ def continuous_valid(self): assert sat._is_alive is False assert sat.time_of_death == 12.0 assert model.check_count == 2 - sat.logger.warning.assert_called_once_with("failed continuous_valid check") + 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 @@ -254,7 +258,10 @@ def continuous_valid(self): assert other_model.check_count == 1 assert not sat.is_alive() assert sat.time_of_death == 12.0 - sat.logger.warning.assert_called_once_with("failed continuous_valid check") + 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={})