From 1d377976a335be3b2bac92850a9dfa016c5c66ed Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Tue, 20 Oct 2026 14:19:09 -0600 Subject: [PATCH 1/4] [#366] Add mixed ground and rso imaging --- src/bsk_rl/act/__init__.py | 3 +- src/bsk_rl/act/actions.py | 11 +++- src/bsk_rl/act/discrete_actions.py | 82 +++++++++++++++++++++-------- src/bsk_rl/act/rso_imaging.py | 20 +++++-- src/bsk_rl/sats/access_satellite.py | 20 ++++--- src/bsk_rl/scene/rso_targets.py | 13 +++++ src/bsk_rl/sim/dyn/rso_imaging.py | 11 +++- src/bsk_rl/sim/fsw/rso_imaging.py | 66 +++++++++++++++++------ 8 files changed, 175 insertions(+), 51 deletions(-) diff --git a/src/bsk_rl/act/__init__.py b/src/bsk_rl/act/__init__.py index 009f4bc6..575b13b0 100644 --- a/src/bsk_rl/act/__init__.py +++ b/src/bsk_rl/act/__init__.py @@ -60,7 +60,7 @@ class MyActionSatellite(Satellite): """ -from bsk_rl.act.actions import Action +from bsk_rl.act.actions import Action, ActionNotHandled from bsk_rl.act.continuous_actions import ( AttitudeSetpoint, ContinuousAction, @@ -84,6 +84,7 @@ class MyActionSatellite(Satellite): __doc_title__ = "Actions" __all__ = [ "Action", + "ActionNotHandled", "DiscreteAction", "DiscreteFSWAction", "Charge", diff --git a/src/bsk_rl/act/actions.py b/src/bsk_rl/act/actions.py index 29814b41..4fc378a3 100644 --- a/src/bsk_rl/act/actions.py +++ b/src/bsk_rl/act/actions.py @@ -13,6 +13,14 @@ from bsk_rl.sim import Simulator +class ActionNotHandled(ValueError): + """Decline an override target so another action may handle it. + + Raise this only before changing satellite state. Errors while executing a + recognized target must propagate rather than trigger another action. + """ + + def select_action_builder(satellite: "Satellite") -> "ActionBuilder": """Identify the proper action builder based on a satellite's action spec. @@ -30,7 +38,6 @@ def select_action_builder(satellite: "Satellite") -> "ActionBuilder": class ActionBuilder(ABC, Resetable): - def __init__(self, satellite: "Satellite") -> None: """Base class for all action builders. @@ -123,4 +130,4 @@ def set_action(self, action: Any) -> None: # pragma: no cover __doc_title__ = "Backend" -__all__ = ["ActionBuilder"] +__all__ = ["ActionBuilder", "ActionNotHandled"] diff --git a/src/bsk_rl/act/discrete_actions.py b/src/bsk_rl/act/discrete_actions.py index 6cd15a8e..91329da2 100644 --- a/src/bsk_rl/act/discrete_actions.py +++ b/src/bsk_rl/act/discrete_actions.py @@ -7,7 +7,7 @@ import numpy as np from gymnasium import spaces -from bsk_rl.act.actions import Action, ActionBuilder +from bsk_rl.act.actions import Action, ActionBuilder, ActionNotHandled if TYPE_CHECKING: # pragma: no cover from bsk_rl.sats import Satellite @@ -25,11 +25,13 @@ def __init__(self, satellite: "Satellite") -> None: """ super().__init__(satellite) self.prev_action_key = None + self._prev_action = None def reset_post_sim_init(self) -> None: """Log previous action key.""" super().reset_post_sim_init() self.prev_action_key = None + self._prev_action = None @property def action_space(self) -> spaces.Discrete: @@ -47,37 +49,63 @@ def action_description(self) -> list[str]: actions.extend([f"{act.name}_{i}" for i in range(act.n_actions)]) return actions - def set_action(self, action: int) -> None: + def set_action(self, action: Any) -> None: """Sets the action based on the integer index. - If the action is not an integer, the satellite will attempt to call ``set_action_override`` - for each action, in order, until one works. + Non-integer targets are resolved before tasking. Unknown targets may be + declined with ``ActionNotHandled``; execution errors propagate. A string + identifying distinct targets is ambiguous and requires a target object. + Previous keys are forwarded only to the action that produced them, so + matching IDs in different modes cannot suppress a mode change. """ - self.satellite.disable_timed_terminal_event() if not np.issubdtype(type(action), np.integer): - logger.warning( - f"Action '{action}' is not an integer. Will attempt to use compatible set_action_override method." + logger.debug( + "Resolving target override %r against the action specification.", action ) + candidates, resolved_targets, rejections = [], [], [] for act in self.action_spec: + if not callable(getattr(act, "set_action_override", None)): + continue + resolve = getattr(act, "resolve_action_override", None) try: - self.prev_action_key = act.set_action_override( - action, prev_action_key=self.prev_action_key - ) - return - except AttributeError: - pass - except TypeError: - pass - else: + target = resolve(action) if callable(resolve) else action + except ActionNotHandled as error: + rejections.append(str(error)) + continue + candidates.append((act, target)) + if callable(resolve): + resolved_targets.append(target) + if len({id(target) for target in resolved_targets}) > 1: raise ValueError( - f"Action '{action}' is not an integer and no compatible set_action_override method found." + f"Ambiguous target {action!r}; pass a target object instead of its ID." ) + for act, target in candidates: + try: + key = act.set_action_override( + target, + prev_action_key=self.prev_action_key + if self._prev_action is act + else None, + ) + except ActionNotHandled as error: + rejections.append(str(error)) + continue + self.prev_action_key, self._prev_action = key, act + return + raise ValueError( + f"No compatible action can handle target {action!r}. " + + " ".join(rejections) + ) index = 0 for act in self.action_spec: if index + act.n_actions > action: self.prev_action_key = act.set_action( - action - index, prev_action_key=self.prev_action_key + action - index, + prev_action_key=self.prev_action_key + if self._prev_action is act + else None, ) + self._prev_action = act return index += act.n_actions else: @@ -95,7 +123,10 @@ def __init__(self, name: str = "discrete_act", n_actions: int = 1): Optionally, discrete actions may have a ``set_action_override`` function defined. If the action passed to the satellite is not an integer, the satellite will iterate over the ``action_spec`` and attempt to call ``set_action_override`` on each action - until one is successful. + until one is successful. Raise :class:`~bsk_rl.act.ActionNotHandled` before + changing state to decline a target; other errors propagate. An optional + ``resolve_action_override`` method resolves targets without tasking, allowing + the builder to reject ambiguous IDs before changing satellite state. Args: name: Name of the action. @@ -257,7 +288,9 @@ def __init__( taken, or when the the target exits the field of regard. This action implements a ``set_action_override`` that allows a target to be tasked - based on the target's ID string or the Target object. + based on the target's ID string or the Target object. Ground and RSO IDs + may be used together regardless of action order. If an ID names a target + in both catalogs, pass the target object to disambiguate it. Args: n_ahead_image: Number of unimaged, along-track targets to consider. @@ -315,7 +348,14 @@ def set_action_override( :meta_private: """ - return self.image(action, prev_action_key) + return self.image(self.resolve_action_override(action), prev_action_key) + + def resolve_action_override(self, action): + """Resolve a ground target without tasking, or decline another target type.""" + try: + return self.satellite.parse_target_selection(action) + except (TypeError, ValueError) as error: + raise ActionNotHandled(str(error)) from error class Broadcast(DiscreteAction): diff --git a/src/bsk_rl/act/rso_imaging.py b/src/bsk_rl/act/rso_imaging.py index 0fda23f9..9f6a9317 100644 --- a/src/bsk_rl/act/rso_imaging.py +++ b/src/bsk_rl/act/rso_imaging.py @@ -5,6 +5,7 @@ import numpy as np from Basilisk.utilities import macros +from bsk_rl.act.actions import ActionNotHandled from bsk_rl.act.discrete_actions import DiscreteAction from bsk_rl.utils.rso_imaging import candidate_snapshot @@ -16,6 +17,10 @@ class ImageRSO(DiscreteAction): spacecraft. For surface inspection of a nearby RSO, see :class:`~bsk_rl.sim.fsw.RSOInspectorFSWModel`. An empty candidate slot raises ``ValueError``; it never tasks a duplicated or padded target. + + ID overrides coexist with ground :class:`~bsk_rl.act.Image` actions in either + order. If both catalogs contain the same ID, use a target object instead. + Eligibility errors for a recognized RSO propagate without trying another mode. """ def __init__( @@ -126,14 +131,21 @@ def set_action(self, action: int, prev_action_key=None) -> str: def set_action_override(self, action, prev_action_key=None) -> str: """Task an explicitly identified target without parsing spacecraft names.""" + return self._task(self.resolve_action_override(action)) + + def resolve_action_override(self, action): + """Resolve a catalog ID or RSO object without changing the active task.""" + from bsk_rl.scene.targets import Target + + if isinstance(action, Target): + raise ActionNotHandled("Ground targets are not handled by ImageRSO.") target_id = action if isinstance(action, str) else getattr(action, "id", None) if target_id is None: - raise TypeError("Expected an RSO target ID or RSOTarget definition.") + raise ActionNotHandled("Expected an RSO target ID or RSOTarget definition.") try: - target = self.satellite.rso_scenario.targets_by_id[target_id] + return self.satellite.rso_scenario.targets_by_id[target_id] except KeyError as error: - raise ValueError(f"Unknown RSO target {target_id!r}.") from error - return self._task(target) + raise ActionNotHandled(f"Unknown RSO target {target_id!r}.") from error __doc_title__ = "Space-to-Space RSO Imaging" diff --git a/src/bsk_rl/sats/access_satellite.py b/src/bsk_rl/sats/access_satellite.py index e4972d9f..159f9b24 100644 --- a/src/bsk_rl/sats/access_satellite.py +++ b/src/bsk_rl/sats/access_satellite.py @@ -589,12 +589,20 @@ def reset_overwrite_previous(self) -> None: self.missed = 0 def reset_pre_sim_init(self) -> None: - """Set the buffer parameters based on computed windows. + """Reserve partition zero for ground images and retain other named buffers. :meta private: """ super().reset_pre_sim_init() - self.sat_args["bufferNames"] = [self.buffer_name] + # Merge rather than replace so scenario and satellite reset hooks may + # register their partitions in either order. Ground acquisition tracking + # reads partition zero directly, independent of the number of RSO targets. + existing = self.sat_args.get("bufferNames") + self.sat_args["bufferNames"] = list( + dict.fromkeys( + [self.buffer_name, *(existing if existing is not None else [])] + ) + ) self.sat_args["transmitterNumBuffers"] = len(self.sat_args["bufferNames"]) def _update_image_event(self, target: "Target") -> None: @@ -623,10 +631,10 @@ def side_effect(sim): self._image_event_name, macros.sec2nano(self.fsw.fsw_rate), True, - conditionFunction=lambda sim: self.dynamics.storageUnit.storageUnitDataOutMsg.read().storedData[ - 0 - ] - > current_data_level, + conditionFunction=lambda sim: ( + self.dynamics.storageUnit.storageUnitDataOutMsg.read().storedData[0] + > current_data_level + ), actionFunction=side_effect, terminal=self.variable_interval, ) diff --git a/src/bsk_rl/scene/rso_targets.py b/src/bsk_rl/scene/rso_targets.py index 708a524f..8d808389 100644 --- a/src/bsk_rl/scene/rso_targets.py +++ b/src/bsk_rl/scene/rso_targets.py @@ -249,6 +249,9 @@ class RSOTargets(Scenario): """Bind an immutable catalog to explicitly named imagers and target satellites. Other spacecraft are allowed, but do not become targets automatically. + RSO storage partitions are appended to existing named partitions. An + ``ImagingSatellite`` reserves partition zero for ground images independently + of whether its reset hook runs before or after this scenario's hook. An omitted catalog epoch follows the environment on each reset; an explicit epoch must match it. Targets must provide ``rN``, ``vN``, ``oe``, and attitude arguments and an ``EclipseDynModel`` for illumination. @@ -368,6 +371,16 @@ def reset_pre_sim_init(self) -> None: ] if len(set(partitions)) != len(partitions): raise ValueError("RSO storage partition names collide.") + if getattr(imager, "buffer_name", None) in partitions: + raise ValueError( + "RSO storage partition collides with the ground buffer." + ) + existing = imager.sat_args.get("bufferNames") + partitions = list( + dict.fromkeys( + [*(existing if existing is not None else []), *partitions] + ) + ) imager.sat_args.update( bufferNames=partitions, transmitterNumBuffers=len(partitions) ) diff --git a/src/bsk_rl/sim/dyn/rso_imaging.py b/src/bsk_rl/sim/dyn/rso_imaging.py index a09cf32f..6b0e2df9 100644 --- a/src/bsk_rl/sim/dyn/rso_imaging.py +++ b/src/bsk_rl/sim/dyn/rso_imaging.py @@ -22,7 +22,10 @@ def __init__(self, satellite, dyn_rate, priority=250, **kwargs) -> None: class SpaceToSpaceImagingDynModel(ImagingDynModel): - """Imager with existing instrument, storage, transmitter, and RSO access. + """Ground and RSO imager sharing an instrument, storage, and transmitter. + + Ground imaging access is retained; ``setup_rso_access`` adds spacecraft access + independently. ``imageTargetMaximumRange`` applies to both access models. The Earth ellipsoid matches the AMOS and existing RSO inspection approximation: polar radius is 0.98 times equatorial radius. Range defaults to unlimited. @@ -54,8 +57,12 @@ def setup_transmitter(self, transmitterPacketSize, **kwargs) -> None: """ super().setup_transmitter(transmitterPacketSize=transmitterPacketSize, **kwargs) + def _setup_dynamics_objects(self, **kwargs) -> None: + super()._setup_dynamics_objects(**kwargs) + self.setup_rso_access(**kwargs) + @default_args(imageTargetMaximumRange=-1) - def setup_imaging_target( + def setup_rso_access( self, imageTargetMaximumRange: float, priority=1900, **kwargs ) -> None: """Create per-imager access; explicitly honor an optional range [m].""" diff --git a/src/bsk_rl/sim/fsw/rso_imaging.py b/src/bsk_rl/sim/fsw/rso_imaging.py index b196fb38..8f3159e5 100644 --- a/src/bsk_rl/sim/fsw/rso_imaging.py +++ b/src/bsk_rl/sim/fsw/rso_imaging.py @@ -4,6 +4,7 @@ import numpy as np from Basilisk.architecture import messaging, sysModel +from Basilisk.fswAlgorithms import locationPointing, simpleInstrumentController from Basilisk.utilities import macros from bsk_rl.sim.dyn.rso_imaging import SpaceToSpaceImagingDynModel @@ -102,7 +103,7 @@ def _valid(self, now_ns) -> tuple[bool, float]: <= macros.sec2nano(max(self.fsw.fsw_rate, self.fsw.dynamics.dyn_rate)) ) payload = guidance() - controller = self.fsw.insControl + controller = self.fsw.rsoInsControl pointing = np.linalg.norm(payload.sigma_BR) <= controller.attErrTolerance if controller.useRateTolerance: pointing = ( @@ -183,7 +184,13 @@ def UpdateState(self, current_time_ns) -> None: class SpaceToSpaceImagingFSWModel(ImagingFSWModel): - """Point at an RSO spacecraft and gate acquisition before physical storage. + """Extend ground imaging with a separate RSO pointing and acquisition task. + + ``action_image`` retains ground imaging and ``action_image_rso`` tracks an + orbiting spacecraft. Both modes share the instrument and attitude controller; + each action selects its pointing task and instrument command source. + ``locPoint`` and ``insControl`` retain their ground roles; ``rsoLocPoint`` and + ``rsoInsControl`` provide RSO guidance and acquisition tolerances. ``rso_capture`` temporarily evaluates sampled pointing, access, illumination, and hold constraints in the dynamics task, before instrument and storage. @@ -201,23 +208,47 @@ class SpaceToSpaceImagingFSWModel(ImagingFSWModel): def _requires_dyn(cls): return super()._requires_dyn() + [SpaceToSpaceImagingDynModel] + def _make_task_list(self) -> list[Task]: + return super()._make_task_list() + [self.RSOPointTask(self)] + def _set_gateway_msgs(self) -> None: super()._set_gateway_msgs() self.rso_capture = _AcquisitionGate(self) - self.dynamics.instrument.nodeStatusInMsg.subscribeTo(self.rso_capture.command) self.simulator.AddModelToTask( self.dynamics.task_name, self.rso_capture, ModelPriority=900 ) + + def _select_instrument_command(self, command) -> None: + """Connect the shared instrument and visualization to the active mode.""" + self.dynamics.instrument.nodeStatusInMsg.subscribeTo(command) for sensor in getattr(self.satellite, "vizard_data", {}).get( "genericSensorList", [] ): - command = messaging.DeviceCmdMsgReader() - command.subscribeTo(self.rso_capture.command) - sensor.genericSensorCmdInMsg = command + reader = messaging.DeviceCmdMsgReader() + reader.subscribeTo(command) + sensor.genericSensorCmdInMsg = reader - class LocPointTask(ImagingFSWModel.LocPointTask): + class RSOPointTask(Task): """Moving-target guidance with tolerances and an independent acquisition gate.""" + name = "rsoPointTask" + + def __init__(self, fsw, priority=96) -> None: + """Create RSO guidance independently of the ground pointing task.""" + super().__init__(fsw, priority) + + def _create_module_data(self) -> None: + self.locPoint = self.fsw.rsoLocPoint = locationPointing.locationPointing() + self.locPoint.ModelTag = "rsoLocPoint" + self.insControl = self.fsw.rsoInsControl = ( + simpleInstrumentController.simpleInstrumentController() + ) + self.insControl.ModelTag = "rsoInstrumentController" + + def _setup_fsw_objects(self, **kwargs) -> None: + self.setup_location_pointing(**kwargs) + self.setup_instrument_controller(**kwargs) + @default_args(inst_pHat_B=[0, 0, 1]) def setup_location_pointing(self, inst_pHat_B, **kwargs) -> None: """Subscribe to observer navigation; target is bound when tasked.""" @@ -263,8 +294,8 @@ def setup_instrument_controller( ) if imageRateErrorRequirement is not None: self.insControl.rateErrTolerance = imageRateErrorRequirement - # The inherited controller supplies tolerance configuration; - # the physical instrument subscribes exclusively to the hold gate. + # This controller supplies RSO tolerance configuration only; + # the acquisition gate generates the physical instrument command. self.insControl.controllerStatus = 0 def reset_for_action(self) -> None: @@ -273,6 +304,10 @@ def reset_for_action(self) -> None: self.fsw.rso_capture.cancel() self.locPoint.Reset(self.fsw.simulator.sim_time_ns) self.insControl.controllerStatus = 0 + # Neither an idle mode nor a newly selected ground action may replay + # the previous ground controller's last acquisition pulse. + self.fsw.insControl.deviceCmdOutMsg.write(messaging.DeviceCmdMsgPayload()) + self.fsw._select_instrument_command(self.fsw.rso_capture.command) return Task.reset_for_action(self) @action @@ -288,7 +323,7 @@ def action_image_rso( """Track a bound target; keep the instrument gated until acquisition.""" if target.id not in self.dynamics.rso_access_messages: raise ValueError("Target is not bound to this observer.") - self.locPoint.scTargetInMsg.subscribeTo( + self.rsoLocPoint.scTargetInMsg.subscribeTo( target.target_spacecraft.dynamics.simpleNavObject.transOutMsg ) self.dynamics.instrument.nodeDataName = self.dynamics.rso_partition_names[ @@ -303,12 +338,13 @@ def action_image_rso( require_illumination, illumination_threshold, ) - self.simulator.enableTask(self.LocPointTask.name + self.satellite.name) + self._select_instrument_command(self.rso_capture.command) + self.simulator.enableTask(self.RSOPointTask.name + self.satellite.name) - @action - def action_image(self, *args, **kwargs) -> None: - """Reject ground-target actions on this spacecraft-specific FSW model.""" - raise NotImplementedError("Use ImageRSO with SpaceToSpaceImagingFSWModel.") + def action_image(self, r_LP_P, data_name) -> None: + """Task ground imaging with its controller driving the shared instrument.""" + super().action_image(r_LP_P, data_name) + self._select_instrument_command(self.insControl.deviceCmdOutMsg) __doc_title__ = "Space-to-Space RSO Imaging" From bd3c697fe87846655f8960853dde2ec519af6a5c Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Tue, 20 Oct 2026 14:19:33 -0600 Subject: [PATCH 2/4] [#366] Add tests --- tests/integration/test_int_mixed.py | 438 +++++++++++++++++++++ tests/unittest/act/test_actions.py | 60 ++- tests/unittest/scene/test_rso_targets.py | 72 ++++ tests/unittest/sim/test_rso_imaging_dyn.py | 2 +- tests/unittest/sim/test_rso_imaging_fsw.py | 28 +- 5 files changed, 589 insertions(+), 11 deletions(-) diff --git a/tests/integration/test_int_mixed.py b/tests/integration/test_int_mixed.py index 55df2e08..2be81a61 100644 --- a/tests/integration/test_int_mixed.py +++ b/tests/integration/test_int_mixed.py @@ -67,6 +67,444 @@ class PassiveTarget(sats.Satellite): action_spec: ClassVar[list[act.Action]] = [act.Drift(duration=1e9)] +class DualObserver(sats.ImagingSatellite): + """Use both pointing tasks and the library's combined buffer configuration.""" + + dyn_type = (dyn.SpaceToSpaceImagingDynModel, dyn.GroundStationDynModel) + fsw_type = fsw.SpaceToSpaceImagingFSWModel + observation_spec: ClassVar[list[obs.Observation]] = [ + obs.OpportunityProperties(dict(prop="priority"), n_ahead_observe=1), + obs.RSOTargetProperties(dict(prop="valid"), n_ahead_observe=1), + ] + action_spec: ClassVar[list[act.Action]] = [ + act.Image(1, max_duration=30), + act.ImageRSO( + 1, + max_duration=30, + min_pointing_hold_s=0, + require_illumination_during_hold=False, + ), + act.Charge(duration=10), + act.Downlink(duration=30), + ] + + +class DualGroundTargets(FixedGroundTargets): + """Retain an unobserved candidate after each ground capture.""" + + def regenerate_targets(self): + super().regenerate_targets() + self.targets.extend( + Target(f"city_{i}", self.targets[0].r_LP_P.copy(), priority=3) + for i in range(2) + ) + + def reset_post_sim_init(self): + super().reset_post_sim_init() + for target in self.targets[1:]: + target.r_LP_P[:] = self.targets[0].r_LP_P + + +def make_dual_env(n_rso=1, **sat_args): + observer = DualObserver( + "camera", + obs_type=dict, + sat_args=dict( + rN=[7e6, 0, 0], + vN=[0, 7500, 0], + oe=None, + sigma_init=[0, 0, 0], + omega_init=[0, 0, 0], + wheelSpeeds=[0.0, 0.0, 0.0], + imageAttErrorRequirement=2.0, + instrumentBaudRate=1000, + transmitterBaudRate=-1000, + batteryStorageCapacity=1e9, + storedCharge_Init=1e9, + dataStorageCapacity=1e6, + storageInit=0, + ) + | sat_args, + ) + target_names = ["debris", *(f"debris_{i}" for i in range(1, n_rso))] + catalog = scene.RSOTargetCatalog( + tuple( + scene.RSOTarget( + "object" if i == 0 else f"object_{i}", + name, + (7e6 + (i + 1) * 1000, 0, 0), + (0, 7500, 0), + ) + for i, name in enumerate(target_names) + ), + EPOCH, + ) + return ConstellationTasking( + satellites=[observer, *(PassiveTarget(name) for name in target_names)], + scenario=scene.MixedScenario( + { + "ground": DualGroundTargets(3), + "rso": scene.RSOTargets(catalog, ["camera"]), + }, + {"ground": ["camera"], "rso": ["camera", *target_names]}, + ), + rewarder=data.MixedReward( + { + "ground": data.UniqueImageReward(), + "rso": data.RSOImageReward( + quality_threshold=0, + acquisition_reward_fn=lambda record, target: 0.9 * target.priority, + delivery_reward_fn=lambda record, target: 0.1 * target.priority, + ), + }, + {"ground": ["camera"], "rso": ["camera"]}, + {"ground": "ground", "rso": "rso"}, + ), + world_args=dict(utc_init=EPOCH, gsMinimumElevation=np.radians(-90)), + time_limit=120, + max_step_duration=1, + sim_rate=1, + log_level="ERROR", + ) + + +@pytest.mark.parametrize("ground_first", [False, True]) +def test_same_satellite_switches_ground_and_rso_imaging(ground_first): + """Separate physical captures, cancel interrupted holds, deliver, and reset.""" + env = make_dual_env() + try: + for seed in (0, 1): + observations, _ = env.reset(seed=seed) + observer = env.satellites[0] + flight_software = observer.fsw + stores = observer.data_store.data_stores + expected_partitions = [ + observer.buffer_name, + observer.rso_scenario.partition_name("object"), + ] + assert observer.sat_args["bufferNames"] == expected_partitions + assert observer.sat_args["transmitterNumBuffers"] == len( + expected_partitions + ) + assert ( + list( + observer.dynamics.storageUnit.storageUnitDataOutMsg.read().storedDataName + ) + == expected_partitions + ) + assert set(observations["camera"]) == {"target", "rso_targets"} + assert observer.action_space.n == 4 + assert flight_software.locPoint is not flight_software.rsoLocPoint + assert flight_software.insControl is not flight_software.rsoInsControl + assert len({task.name for task in flight_software.tasks}) == len( + flight_software.tasks + ) + assert ( + observer.dynamics.imagingTarget is not observer.dynamics.targetLocation + ) + + def step(action=None): + return env.step( + { + "camera": action, + "debris": 0 if env.simulator.sim_time == 0 else None, + } + ) + + def capture(action, stores=stores): + def count(): + if action == 0: + return len(stores["ground"].data.imaged) + return len(stores["rso"].data.captures) + + previous = count() + step(action) + for _ in range(8): + if count() > previous: + break + step() + assert count() == previous + 1 + + expected_ground, expected_rso = 0, 0 + for action in [0, 1] if ground_first else [1, 0]: + capture(action) + if action == 0: + expected_ground += 1 + assert flight_software.rso_capture.target is None + else: + expected_rso += 1 + assert len(stores["ground"].data.imaged) == expected_ground + assert len(stores["rso"].data.captures) == expected_rso + + assert env.rewarder.rewarders["ground"].cum_reward["camera"] == 3 + assert env.rewarder.rewarders["rso"].cum_reward["camera"] == pytest.approx( + 0.9 + ) + step(3) # Deliver both image types through the shared transmitter. + for _ in range(8): + if stores["rso"].data.deliveries: + break + step() + assert len(stores["rso"].data.deliveries) == 1 + assert observer.dynamics.storage_level == 0 + assert env.rewarder.rewarders["rso"].cum_reward["camera"] == pytest.approx( + 1 + ) + + imaging_action = observer.action_builder.action_spec[1] + imaging_action.min_pointing_hold_s = 100 + step(1) + step() + step() + assert flight_software.rso_capture.held_s > 0 + capture(0) # A ground command interrupts the unfinished RSO hold. + assert flight_software.rso_capture.target is None + assert flight_software.rso_capture.held_s == 0 + assert len(stores["rso"].data.captures) == 1 + assert env.rewarder.rewarders["ground"].cum_reward["camera"] == 6 + + level = observer.dynamics.storage_level + step(2) + for _ in range(3): + step() + assert observer.dynamics.storage_level == level + imaging_action.min_pointing_hold_s = 0 + capture(1) + assert len(stores["ground"].data.imaged) == 2 + assert len(stores["rso"].data.captures) == 2 + finally: + env.close() + + +@pytest.mark.parametrize("reverse_actions", [False, True]) +@pytest.mark.parametrize("ground_first", [False, True]) +@pytest.mark.parametrize("target_form", ["id", "definition", "runtime"]) +def test_combined_target_overrides(reverse_actions, ground_first, target_form): + """IDs and objects route to their owning mode independently of action order.""" + env = make_dual_env() + observer = env.satellites[0] + if reverse_actions: + observer.action_builder.action_spec[:2] = observer.action_builder.action_spec[ + :2 + ][::-1] + try: + env.reset(seed=0) + stores = observer.data_store.data_stores + ground = env.scenario.scenarios["ground"].targets[0] + definition = observer.rso_scenario.catalog.targets[0] + runtime = observer.rso_scenario.targets_by_id[definition.id] + ground_input = ground.id if target_form == "id" else ground + rso_input = {"id": definition.id, "definition": definition, "runtime": runtime}[ + target_form + ] + tasks = [(ground_input, "ground"), (rso_input, "rso")] + if not ground_first: + tasks.reverse() + for target, channel in tasks: + env.step( + {"camera": target, "debris": 0 if env.simulator.sim_time == 0 else None} + ) + for _ in range(8): + acquired = ( + stores[channel].data.imaged + if channel == "ground" + else stores[channel].data.captures + ) + if acquired: + break + env.step({"camera": None, "debris": None}) + assert acquired + assert len(stores["ground"].data.imaged) == 1 + assert { + record.target_id for record in stores["rso"].data.captures.values() + } == {definition.id} + assert env.rewarder.rewarders["ground"].cum_reward["camera"] == 3 + assert env.rewarder.rewarders["rso"].cum_reward["camera"] == pytest.approx(0.9) + + # Invalid tasking must retain the active mode, deadline, and previous key. + previous_key = observer.action_builder.prev_action_key + event_name = observer._timed_terminal_event_name + deadline = env.simulator.eventMap[event_name] + data_name = observer.dynamics.instrument.nodeDataName + with pytest.raises(ValueError, match="pending or in cooldown"): + observer.set_action(rso_input) + with pytest.raises(ValueError, match="No compatible action"): + observer.set_action("missing_target") + assert env.simulator.eventMap[event_name] is deadline + assert observer.action_builder.prev_action_key == previous_key + assert observer.dynamics.instrument.nodeDataName == data_name + finally: + env.close() + + +@pytest.mark.parametrize("reverse_actions", [False, True]) +def test_overlapping_ids_require_objects_and_do_not_skip_mode_switches(reverse_actions): + """Reject ambiguous strings before tasking, while typed objects remain distinct.""" + env = make_dual_env() + observer = env.satellites[0] + if reverse_actions: + observer.action_builder.action_spec[:2] = observer.action_builder.action_spec[ + :2 + ][::-1] + try: + env.reset(seed=0) + ground = env.scenario.scenarios["ground"].targets[0] + rso = observer.rso_scenario.catalog.targets[0] + ground._id = rso.id + with pytest.raises(ValueError, match="Ambiguous target"): + observer.set_action(rso.id) + assert observer.action_builder.prev_action_key is None + assert observer.fsw.rso_capture.target is None + assert observer.latest_target is None + observer.set_action(ground) + assert observer.dynamics.instrument.nodeDataName == observer.buffer_name + observer.set_action(rso) + assert observer.fsw.rso_capture.target.id == rso.id + observer.set_action(ground) + assert observer.fsw.rso_capture.target is None + assert observer.dynamics.instrument.nodeDataName == observer.buffer_name + # Selecting the same target again retains the existing ground task. + observer.set_action(ground) + assert observer.action_builder.prev_action_key == ground.id + finally: + env.close() + + +@pytest.mark.parametrize("extra_partitions", [None, ["aux"]]) +def test_ground_and_multiple_rsos_share_capacity_and_partial_downlink(extra_partitions): + """Keep named per-target accounting within one capacity and transmitter.""" + env = make_dual_env( + n_rso=2, + bufferNames=extra_partitions, + dataStorageCapacity=3000, + transmitterBaudRate=-100, + ) + try: + env.reset(seed=0) + observer = env.satellites[0] + stores = observer.data_store.data_stores + rso_scene = observer.rso_scenario + expected_names = [ + observer.buffer_name, + *(extra_partitions or []), + *( + rso_scene.partition_name(target_id) + for target_id in rso_scene.targets_by_id + ), + ] + assert observer.sat_args["bufferNames"] == expected_names + assert observer.sat_args["transmitterNumBuffers"] == len(expected_names) + assert observer.dynamics.transmitter.numBuffers == len(expected_names) + assert observer.dynamics.storageUnit.storageCapacity == 3000 + + def step(action=None): + result = env.step( + { + sat.name: action + if sat is observer + else (0 if env.simulator.sim_time == 0 else None) + for sat in env.satellites + } + ) + message = observer.dynamics.storageUnit.storageUnitDataOutMsg.read() + assert list(message.storedDataName) == expected_names + assert observer.dynamics.storage_level == pytest.approx( + sum(message.storedData) + ) + assert observer.dynamics.storage_level_fraction == pytest.approx( + observer.dynamics.storage_level / 3000 + ) + return result + + for action, expected_level in ((0, 1000), (1, 2000), (1, 3000)): + step(action) + for _ in range(8): + if observer.dynamics.storage_level == expected_level: + break + step() + assert observer.dynamics.storage_level == expected_level + assert len(stores["ground"].data.imaged) == 1 + assert { + record.target_id for record in stores["rso"].data.captures.values() + } == {"object", "object_1"} + assert observer.dynamics.storage_level_fraction == 1 + + step(3) + for _ in range(20): + if any(0 < bits < 1000 for bits in stores["rso"].remaining_bits.values()): + break + step() + assert any(0 < bits < 1000 for bits in stores["rso"].remaining_bits.values()) + assert len(stores["rso"].data.deliveries) < 2 + level = observer.dynamics.storage_level + remaining = stores["rso"].remaining_bits.copy() + step(2) # Stop downlink while retaining partially transmitted products. + step() + assert observer.dynamics.storage_level == level + assert stores["rso"].remaining_bits == remaining + step(3) + for _ in range(40): + if observer.dynamics.storage_level == 0: + break + step() + assert observer.dynamics.storage_level == 0 + assert len(stores["ground"].data.imaged) == 1 + assert len(stores["rso"].data.deliveries) == 2 + assert stores["rso"].products == {} + assert stores["rso"].remaining_bits == {} + assert env.rewarder.rewarders["ground"].cum_reward["camera"] == 3 + assert env.rewarder.rewarders["rso"].cum_reward["camera"] == pytest.approx(2) + finally: + env.close() + + +def test_ground_image_uses_capacity_needed_for_rso_acquisition(): + """RSO readiness uses shared free space, and ground downlink earns no RSO credit.""" + env = make_dual_env(dataStorageCapacity=1500) + try: + env.reset(seed=0) + observer = env.satellites[0] + stores = observer.data_store.data_stores + + def step(action=None): + return env.step( + {"camera": action, "debris": 0 if env.simulator.sim_time == 0 else None} + ) + + step(0) + for _ in range(8): + if stores["ground"].data.imaged: + break + step() + assert observer.dynamics.storage_level == 1000 + step(1) + for _ in range(5): + step() + assert observer.dynamics.storage_level == 1000 + assert observer.fsw.rso_capture.records == [] + assert not stores["rso"].data.captures + step(3) + for _ in range(8): + if observer.dynamics.storage_level == 0: + break + step() + assert observer.dynamics.storage_level == 0 + assert not stores["rso"].data.deliveries + assert env.rewarder.rewarders["rso"].cum_reward["camera"] == 0 + step(1) + for _ in range(8): + if stores["rso"].data.captures: + break + step() + assert len(stores["rso"].data.captures) == 1 + assert observer.dynamics.storage_level == 1000 + assert len(stores["ground"].data.imaged) == 1 + assert env.rewarder.rewarders["ground"].cum_reward["camera"] == 3 + assert env.rewarder.rewarders["rso"].cum_reward["camera"] == pytest.approx(0.9) + finally: + env.close() + + def make_env(overlap, communicate): satellite_args = dict( rN=[7e6, 0, 0], diff --git a/tests/unittest/act/test_actions.py b/tests/unittest/act/test_actions.py index 848ef769..51544ed2 100644 --- a/tests/unittest/act/test_actions.py +++ b/tests/unittest/act/test_actions.py @@ -59,17 +59,21 @@ def test_set_action(self): ab.set_action(0) assert ab.action_spec[0].set_action.call_args == call(0, prev_action_key=None) ab.set_action(1) - assert ab.action_spec[1].set_action.call_args == call(0, prev_action_key="foo") + assert ab.action_spec[1].set_action.call_args == call(0, prev_action_key=None) ab.set_action(2) assert ab.action_spec[1].set_action.call_args == call(1, prev_action_key="bar") ab.set_action(3) - assert ab.action_spec[2].set_action.call_args == call(0, prev_action_key="bar") + assert ab.action_spec[2].set_action.call_args == call(0, prev_action_key=None) def test_set_action_override(self): satellite = MagicMock( action_spec=[ MagicMock(n_actions=1, set_action_override=None), - MagicMock(n_actions=2, set_action_override=MagicMock()), + MagicMock( + n_actions=2, + resolve_action_override=None, + set_action_override=MagicMock(), + ), ] ) ab = DiscreteActionBuilder(satellite) @@ -78,6 +82,51 @@ def test_set_action_override(self): "foo", prev_action_key=None ) + @pytest.mark.parametrize("error_type", [TypeError, AttributeError, ValueError]) + def test_override_execution_errors_do_not_fall_through(self, error_type): + satellite = MagicMock( + action_spec=[ + MagicMock( + n_actions=1, + resolve_action_override=None, + set_action_override=MagicMock( + side_effect=error_type("task failed") + ), + ), + MagicMock(n_actions=1, resolve_action_override=None), + ] + ) + builder = DiscreteActionBuilder(satellite) + with pytest.raises(error_type, match="task failed"): + builder.set_action("target") + builder.action_spec[1].set_action_override.assert_not_called() + satellite.disable_timed_terminal_event.assert_not_called() + assert builder.prev_action_key is None + + def test_explicit_decline_tries_the_next_override(self): + satellite = MagicMock( + action_spec=[ + MagicMock( + n_actions=1, + resolve_action_override=None, + set_action_override=MagicMock( + side_effect=act.ActionNotHandled("wrong target") + ), + ), + MagicMock( + n_actions=1, + resolve_action_override=None, + set_action_override=MagicMock(return_value="target"), + ), + ] + ) + builder = DiscreteActionBuilder(satellite) + builder.set_action("target") + builder.action_spec[1].set_action_override.assert_called_once_with( + "target", prev_action_key=None + ) + assert builder.prev_action_key == "target" + class TestDiscreteFSWAction: def test_set_action(self): @@ -148,7 +197,10 @@ def test_set_action_override(self): image.satellite = MagicMock() image.image = MagicMock() image.set_action_override("image") - image.image.assert_called_once_with("image", None) + image.satellite.parse_target_selection.assert_called_once_with("image") + image.image.assert_called_once_with( + image.satellite.parse_target_selection.return_value, None + ) class TestContinuousActionBuilder: diff --git a/tests/unittest/scene/test_rso_targets.py b/tests/unittest/scene/test_rso_targets.py index 4de476b4..d53abcec 100644 --- a/tests/unittest/scene/test_rso_targets.py +++ b/tests/unittest/scene/test_rso_targets.py @@ -2,6 +2,8 @@ from dataclasses import replace from types import SimpleNamespace as NS +from typing import ClassVar +from unittest.mock import patch import numpy as np import pytest @@ -180,3 +182,73 @@ def test_pending_selection_setting_survives_episode_reset(block_pending_targets) assert scene.is_eligible("id-83", 0) scene.pending["id-83"] = {"camera:2"} assert scene.is_eligible("id-83", 0) is (not block_pending_targets) +@pytest.mark.parametrize("scene_first", [False, True]) +@pytest.mark.parametrize("ground_name", ["image_buffer", "custom_ground"]) +@pytest.mark.parametrize("n_rso", [2, 200]) +def test_partitions_merge_independently_of_reset_order(scene_first, ground_name, n_rso): + from bsk_rl import act, obs, sats + from bsk_rl.sim import dyn, fsw + + class Camera(sats.ImagingSatellite): + dyn_type = dyn.SpaceToSpaceImagingDynModel + fsw_type = fsw.SpaceToSpaceImagingFSWModel + observation_spec: ClassVar[list] = [obs.Time()] + action_spec: ClassVar[list] = [act.Image(1), act.ImageRSO(1)] + buffer_name = ground_name + + imager = Camera("camera") + definition = RSOTargetCatalog( + tuple( + RSOTarget(f"object_{i}", f"debris_{i}", (7e6, 0, 0), (0, 7500, 0)) + for i in range(n_rso) + ), + catalog().utc_init, + ) + scene = RSOTargets(definition, [imager.name]) + scene.utc_init = definition.utc_init + scene.link_satellites( + [imager] + + [ + NS(name=target.satellite_name, dyn_type=dyn.RSOTargetDynModel, sat_args={}) + for target in definition.targets + ] + ) + rso_partitions = [scene.partition_name(target.id) for target in definition.targets] + initial_partitions = ["aux", ground_name, rso_partitions[0], "aux"] + imager.sat_args = {"bufferNames": initial_partitions} + hooks = [scene.reset_pre_sim_init, imager.reset_pre_sim_init] + if not scene_first: + hooks.reverse() + with patch.object(sats.Satellite, "reset_pre_sim_init"): + for _ in range(2): + for hook in hooks: + hook() + assert imager.sat_args["bufferNames"] == [ + ground_name, + "aux", + *rso_partitions, + ] + assert imager.sat_args["transmitterNumBuffers"] == n_rso + 2 + # Registration must not mutate the configured list used on subsequent resets. + assert initial_partitions == ["aux", ground_name, rso_partitions[0], "aux"] + + +def test_rso_partition_cannot_alias_a_custom_ground_buffer(): + from bsk_rl.sim import dyn, fsw + + scene = RSOTargets(catalog(), ["camera"]) + scene.utc_init = catalog().utc_init + scene.link_satellites( + [ + NS( + name="camera", + dyn_type=dyn.SpaceToSpaceImagingDynModel, + fsw_type=fsw.SpaceToSpaceImagingFSWModel, + sat_args={}, + buffer_name=scene.partition_name("id-83"), + ), + NS(name="debris", dyn_type=dyn.RSOTargetDynModel, sat_args={}), + ] + ) + with pytest.raises(ValueError, match="ground buffer"): + scene.reset_pre_sim_init() diff --git a/tests/unittest/sim/test_rso_imaging_dyn.py b/tests/unittest/sim/test_rso_imaging_dyn.py index bb15c9bc..2dbdf2a2 100644 --- a/tests/unittest/sim/test_rso_imaging_dyn.py +++ b/tests/unittest/sim/test_rso_imaging_dyn.py @@ -39,7 +39,7 @@ def test_invalid_range_is_not_silently_ignored(value): model = object.__new__(SpaceToSpaceImagingDynModel) model.logger = Mock() with pytest.raises(ValueError, match="range"): - model.setup_imaging_target(imageTargetMaximumRange=value) + model.setup_rso_access(imageTargetMaximumRange=value) @pytest.mark.parametrize("value", [0, -1, float("nan")]) diff --git a/tests/unittest/sim/test_rso_imaging_fsw.py b/tests/unittest/sim/test_rso_imaging_fsw.py index 0bdeafcb..36609afa 100644 --- a/tests/unittest/sim/test_rso_imaging_fsw.py +++ b/tests/unittest/sim/test_rso_imaging_fsw.py @@ -26,10 +26,10 @@ def __init__(self): self.access = messaging.AccessMsg() self.illumination = messaging.EclipseMsg() self.storage = messaging.DataStorageStatusMsg() - self.insControl = simpleInstrumentController.simpleInstrumentController() - self.insControl.attErrTolerance = 0.01 # [-, MRP norm] - self.insControl.useRateTolerance = 1 - self.insControl.rateErrTolerance = 0.1 # [rad/s] + self.rsoInsControl = simpleInstrumentController.simpleInstrumentController() + self.rsoInsControl.attErrTolerance = 0.01 # [-, MRP norm] + self.rsoInsControl.useRateTolerance = 1 + self.rsoInsControl.rateErrTolerance = 0.1 # [rad/s] self.dynamics = SimpleNamespace( dyn_rate=1.0, # [s] instrument=SimpleNamespace(nodeBaudRate=1000.0), # [bit/s] @@ -214,17 +214,33 @@ def test_gateway_uses_existing_controller_api_and_visualizes_the_actual_command( fsw.satellite = harness.satellite fsw.satellite.vizard_data["genericSensorList"] = [sensor] fsw.attGuidMsg = harness.attGuidMsg - fsw.insControl = harness.insControl + fsw.rsoInsControl = harness.rsoInsControl + ground_command = messaging.DeviceCmdMsg() fsw.satellite.simulator = SimpleNamespace(AddModelToTask=Mock()) fsw.satellite.dynamics = SimpleNamespace( task_name="cameraDynamics", instrument=SimpleNamespace(nodeStatusInMsg=messaging.DeviceCmdMsgReader()), ) - with patch.object(ImagingFSWModel, "_set_gateway_msgs"): + with patch.object( + ImagingFSWModel, + "_set_gateway_msgs", + side_effect=lambda: fsw.dynamics.instrument.nodeStatusInMsg.subscribeTo( + ground_command + ), + ): fsw._set_gateway_msgs() command = messaging.DeviceCmdMsgPayload() command.deviceCmd = 1 fsw.rso_capture.command.write(command) + # Constructing the RSO gate must not replace the inherited ground binding. + assert fsw.dynamics.instrument.nodeStatusInMsg().deviceCmd == 0 + fsw._select_instrument_command(fsw.rso_capture.command) + assert fsw.dynamics.instrument.nodeStatusInMsg().deviceCmd == 1 + assert sensor.genericSensorCmdInMsg().deviceCmd == 1 + fsw._select_instrument_command(ground_command) + assert fsw.dynamics.instrument.nodeStatusInMsg().deviceCmd == 0 + assert sensor.genericSensorCmdInMsg().deviceCmd == 0 + ground_command.write(command) assert fsw.dynamics.instrument.nodeStatusInMsg().deviceCmd == 1 assert sensor.genericSensorCmdInMsg().deviceCmd == 1 fsw.simulator.AddModelToTask.assert_called_once_with( From 23e1c28c39da5a5bdb11d8729666d41c36e9d825 Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Tue, 20 Oct 2026 14:20:11 -0600 Subject: [PATCH 3/4] [#366] Add example script --- examples/_default.rst | 1 + examples/mixed_environment.ipynb | 2 +- examples/mixed_ground_space_imaging.ipynb | 595 ++++++++++++++++++++++ 3 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 examples/mixed_ground_space_imaging.ipynb diff --git a/examples/_default.rst b/examples/_default.rst index a8945013..7eea6160 100644 --- a/examples/_default.rst +++ b/examples/_default.rst @@ -10,6 +10,7 @@ Environments satellite_configuration multiagent_envs mixed_environment + mixed_ground_space_imaging fault_environment communication_action cloud_environment diff --git a/examples/mixed_environment.ipynb b/examples/mixed_environment.ipynb index f9c2d5bb..ac86e3aa 100644 --- a/examples/mixed_environment.ipynb +++ b/examples/mixed_environment.ipynb @@ -210,7 +210,7 @@ "\n", "Expected cumulative rewards are `ground: 3`, `observer: 2`, and `debris: 0`. Every spacecraft learns both channels through communication, but each observer generates data only for its assigned channel. Access heterogeneous data explicitly through `data.data[channel_id]`.\n", "\n", - "Mappings can overlap to assign several reward channels and scenarios to one satellite, provided that satellite implements the corresponding models and actions. Combining ground and RSO imaging on the same physical satellite requires additional model integration." + "Mappings can overlap to assign several reward channels and scenarios to one satellite. See `mixed_ground_space_imaging.ipynb` for one satellite with both ground and RSO actions and observations, shared storage, and tasking by target ID." ] }, { diff --git a/examples/mixed_ground_space_imaging.ipynb b/examples/mixed_ground_space_imaging.ipynb new file mode 100644 index 00000000..0377e893 --- /dev/null +++ b/examples/mixed_ground_space_imaging.ipynb @@ -0,0 +1,595 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "introduction", + "metadata": {}, + "source": [ + "# Ground and RSO Imaging on One Satellite\n", + "\n", + "This example configures **one imager** with ground and space-to-space imaging actions and observations. Two passive spacecraft serve as RSO targets. We task one ground target and both RSOs by ID, then downlink through their shared storage unit and transmitter.\n", + "\n", + "The nearby targets, permissive pointing tolerance, disabled RSO illumination gating, and ground-station elevation limit of -90 degrees keep this configuration demonstration short. They are demonstration settings rather than mission constraints. The ground scene retains spare candidates after acquisition so its observation continues to have upcoming opportunities." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/lorenzzo/Local/repo_updates/issue_366/.venv_366/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "from typing import ClassVar\n", + "\n", + "import numpy as np\n", + "from Basilisk.utilities import orbitalMotion\n", + "\n", + "from bsk_rl import ConstellationTasking, act, data, obs, sats, scene\n", + "from bsk_rl.scene.targets import Target\n", + "from bsk_rl.sim import dyn, fsw\n", + "\n", + "EPOCH = \"2026 OCT 01 00:00:00.000 (UTC)\"" + ] + }, + { + "cell_type": "markdown", + "id": "mixed-spacecraft-explanation", + "metadata": {}, + "source": [ + "## Define the mixed imager\n", + "\n", + "`ImagingSatellite` supplies ground opportunity selection and acquisition tracking. `SpaceToSpaceImagingDynModel` and `SpaceToSpaceImagingFSWModel` retain ground imaging and add RSO access and a separate pointing task. Composing `GroundStationDynModel` also enables downlink.\n", + "\n", + "The observation combines resource state, two ground-opportunity rows, and two RSO-candidate rows. RSO rows include a `valid` flag for empty candidate slots. The dictionary format makes these groups easy to inspect; use `obs_type=np.ndarray` for a flattened policy input.\n", + "\n", + "| Integer action | Meaning |\n", + "| --- | --- |\n", + "| 0 | Charge |\n", + "| 1 | Downlink |\n", + "| 2–3 | Image a ground candidate |\n", + "| 4–5 | Image an RSO candidate |\n", + "\n", + "IDs and target objects can also select either imaging mode. ID dispatch works with either ordering of `Image` and `ImageRSO`; if both catalogues contain the same ID, pass a target object to disambiguate it." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "mixed-spacecraft", + "metadata": {}, + "outputs": [], + "source": [ + "class MixedImager(sats.ImagingSatellite):\n", + " \"\"\"Share the instrument, attitude controller, storage, and transmitter.\"\"\"\n", + "\n", + " dyn_type = (dyn.SpaceToSpaceImagingDynModel, dyn.GroundStationDynModel)\n", + " fsw_type = fsw.SpaceToSpaceImagingFSWModel\n", + " observation_spec: ClassVar[list[obs.Observation]] = [\n", + " obs.SatProperties(\n", + " dict(prop=\"storage_level_fraction\"),\n", + " dict(prop=\"battery_charge_fraction\"),\n", + " ),\n", + " obs.OpportunityProperties(\n", + " dict(prop=\"priority\"),\n", + " dict(prop=\"opportunity_open\", norm=60),\n", + " dict(prop=\"opportunity_close\", norm=60),\n", + " n_ahead_observe=2,\n", + " name=\"ground_targets\",\n", + " ),\n", + " obs.RSOTargetProperties(\n", + " dict(prop=\"valid\"),\n", + " dict(prop=\"priority\"),\n", + " dict(prop=\"target_distance\", norm=1e3),\n", + " dict(prop=\"target_illumination_factor\"),\n", + " n_ahead_observe=2,\n", + " ),\n", + " ]\n", + " # Integer indices: charge=0, downlink=1, ground candidates=2..3,\n", + " # RSO candidates=4..5.\n", + " action_spec: ClassVar[list[act.Action]] = [\n", + " act.Charge(duration=10),\n", + " act.Downlink(duration=60),\n", + " act.Image(n_ahead_image=2, max_duration=30),\n", + " act.ImageRSO(\n", + " n_ahead_image=2,\n", + " max_duration=30,\n", + " min_pointing_hold_s=2,\n", + " require_illumination_during_hold=False,\n", + " ),\n", + " ]" + ] + }, + { + "cell_type": "markdown", + "id": "target-models-explanation", + "metadata": {}, + "source": [ + "## Define the target models\n", + "\n", + "The passive RSO spacecraft propagate normally and expose only a drift action. They participate in the RSO scene without producing imaging rewards.\n", + "\n", + "For this short example, three ground candidates are placed below the imager at the initial epoch. The scene updates their planet-fixed positions after the simulator knows the Earth's orientation and before ground access windows are calculated." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "target-models", + "metadata": {}, + "outputs": [], + "source": [ + "class PassiveTarget(sats.Satellite):\n", + " \"\"\"Propagate a catalog spacecraft without giving it imaging actions.\"\"\"\n", + "\n", + " dyn_type = dyn.RSOTargetDynModel\n", + " fsw_type = fsw.FSWModel\n", + " observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()]\n", + " action_spec: ClassVar[list[act.Action]] = [act.Drift(duration=1e9)]\n", + "\n", + "\n", + "class DemoGroundTargets(scene.UniformTargets):\n", + " \"\"\"Place three ground candidates below the imager at the initial epoch.\"\"\"\n", + "\n", + " def regenerate_targets(self):\n", + " self.targets = [\n", + " Target(\n", + " f\"ground_{index}\",\n", + " [orbitalMotion.REQ_EARTH * 1e3, 0, 0],\n", + " priority=3,\n", + " )\n", + " for index in range(3)\n", + " ]\n", + "\n", + " def reset_post_sim_init(self):\n", + " # Account for the realized Earth orientation. Access registration retains\n", + " # each position array, so update it before satellite windows are generated.\n", + " imager = self.satellites[0]\n", + " position_P = imager.dynamics.world.PN @ imager.dynamics.r_BN_N\n", + " for target in self.targets:\n", + " target.r_LP_P[:] = self.radius * position_P / np.linalg.norm(position_P)" + ] + }, + { + "cell_type": "markdown", + "id": "mission-configuration-explanation", + "metadata": {}, + "source": [ + "## Configure the mission\n", + "\n", + "The imager has a **total capacity of 5,000 bits**. Each image contains 1,000 bits, and the shared transmitter drains data at 100 bits/s. The RSO catalogue explicitly binds each ID to a passive target spacecraft." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "mission-configuration", + "metadata": {}, + "outputs": [], + "source": [ + "imager = MixedImager(\n", + " \"imager\",\n", + " # A dictionary keeps the two target observation groups easy to inspect.\n", + " # Use obs_type=np.ndarray to flatten them into a policy input vector.\n", + " obs_type=dict,\n", + " sat_args=dict(\n", + " rN=[7e6, 0, 0],\n", + " vN=[0, 7500, 0],\n", + " oe=None,\n", + " sigma_init=[0, 0, 0],\n", + " omega_init=[0, 0, 0],\n", + " wheelSpeeds=[0.0, 0.0, 0.0],\n", + " imageAttErrorRequirement=2.0,\n", + " instrumentBaudRate=1000, # Image size [bits], for either imaging mode.\n", + " transmitterBaudRate=-100, # Downlink [bits/s], shared across partitions.\n", + " dataStorageCapacity=5000, # Total capacity, not capacity per partition.\n", + " storageInit=0,\n", + " batteryStorageCapacity=1e9,\n", + " storedCharge_Init=1e9,\n", + " ),\n", + ")\n", + "\n", + "catalog = scene.RSOTargetCatalog(\n", + " (\n", + " scene.RSOTarget(\n", + " \"rso_a\", \"debris_a\", (7e6 + 1000, 0, 0), (0, 7500, 0), priority=2\n", + " ),\n", + " scene.RSOTarget(\n", + " \"rso_b\", \"debris_b\", (7e6 + 2000, 0, 0), (0, 7500, 0), priority=1\n", + " ),\n", + " ),\n", + " EPOCH,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "mappings-explanation", + "metadata": {}, + "source": [ + "## Bind scenarios and reward channels\n", + "\n", + "The imager belongs to both scenes and both reward channels. Scenario participants also include the physical RSO target spacecraft; reward-channel assignments contain only the imager. `scenario_mapping` links each reward channel to its corresponding scene.\n", + "\n", + "Ground images earn their priority once on acquisition. For this example, RSO callbacks award **90% of priority on acquisition and 10% on complete delivery**. The generic RSO rewarder defaults to delivery-only credit; the 90/10 split is an explicit choice here.\n", + "\n", + "No custom reset or manual buffer allocation is needed. The library keeps one ground partition at index zero, followed by one named partition per RSO, within the same storage unit. All partitions share its total capacity." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "mappings", + "metadata": {}, + "outputs": [], + "source": [ + "mixed_scene = scene.MixedScenario(\n", + " scenarios={\n", + " \"ground_scene\": DemoGroundTargets(3),\n", + " \"rso_scene\": scene.RSOTargets(catalog, [imager.name]),\n", + " },\n", + " satellite_mapping={\n", + " \"ground_scene\": [imager.name],\n", + " \"rso_scene\": [\n", + " imager.name,\n", + " *(target.satellite_name for target in catalog.targets),\n", + " ],\n", + " },\n", + ")\n", + "\n", + "mixed_reward = data.MixedReward(\n", + " rewarders={\n", + " \"ground_images\": data.UniqueImageReward(),\n", + " \"rso_images\": data.RSOImageReward(\n", + " acquisition_reward_fn=lambda record, target: 0.9 * target.priority,\n", + " delivery_reward_fn=lambda record, target: 0.1 * target.priority,\n", + " quality_threshold=0,\n", + " ),\n", + " },\n", + " satellite_mapping={\"ground_images\": [imager.name], \"rso_images\": [imager.name]},\n", + " scenario_mapping={\"ground_images\": \"ground_scene\", \"rso_images\": \"rso_scene\"},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "environment-explanation", + "metadata": {}, + "source": [ + "## Construct the environment\n", + "\n", + "Only the imager has mixed actions. The two additional satellites are its passive RSO targets. The environment advances at most one second per step, making the capture and delivery boundaries easy to inspect." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "environment", + "metadata": {}, + "outputs": [], + "source": [ + "env = ConstellationTasking(\n", + " satellites=[\n", + " imager,\n", + " *(PassiveTarget(target.satellite_name) for target in catalog.targets),\n", + " ],\n", + " scenario=mixed_scene,\n", + " rewarder=mixed_reward,\n", + " world_args=dict(utc_init=EPOCH, gsMinimumElevation=np.radians(-90)),\n", + " time_limit=180,\n", + " max_step_duration=1,\n", + " sim_rate=1,\n", + " log_level=\"ERROR\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "reset-explanation", + "metadata": {}, + "source": [ + "## Reset and inspect the observation and action spaces\n", + "\n", + "The environment copies its configured participants and scenes. After reset, use the environment's satellite and target objects. The printed partition names show the ground buffer and the two RSO buffers sharing one storage unit." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "reset-and-inspect", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Observation groups: ['sat_props', 'ground_targets', 'rso_targets']\n", + "Ground candidate rows:\n", + "{'ground_targets_0': {'opportunity_close_normd': 1.4230079841995078,\n", + " 'opportunity_open_normd': np.float64(0.0),\n", + " 'priority': 3.0},\n", + " 'ground_targets_1': {'opportunity_close_normd': 1.4230079841995078,\n", + " 'opportunity_open_normd': np.float64(0.0),\n", + " 'priority': 3.0}}\n", + "RSO candidate rows:\n", + "{'rso_targets_0': {'priority': 2.0,\n", + " 'target_distance': 1.0,\n", + " 'target_illumination_factor': 0.0,\n", + " 'valid': 1.0},\n", + " 'rso_targets_1': {'priority': 1.0,\n", + " 'target_distance': 2.0,\n", + " 'target_illumination_factor': 0.0,\n", + " 'valid': 1.0}}\n", + "Integer actions:\n", + "{0: 'action_charge',\n", + " 1: 'action_downlink',\n", + " 2: 'action_image_0',\n", + " 3: 'action_image_1',\n", + " 4: 'image_rso_0',\n", + " 5: 'image_rso_1'}\n", + "Shared storage partitions: ['image_buffer', 'rso_f9ec1e33845a6f42682e0ef304bc4b6e', 'rso_3b64ca2a775eaf93edec3dce613b3749']\n" + ] + } + ], + "source": [ + "seed = 0\n", + "observations, _ = env.reset(seed=seed)\n", + "imager = next(satellite for satellite in env.satellites if satellite.name == \"imager\")\n", + "stores = imager.data_store.data_stores\n", + "rso_scene = env.scenario.scenarios[\"rso_scene\"]\n", + "ground = env.scenario.scenarios[\"ground_scene\"].targets[0]\n", + "partitions = list(\n", + " imager.dynamics.storageUnit.storageUnitDataOutMsg.read().storedDataName\n", + ")\n", + "\n", + "print(\"Observation groups:\", list(observations[imager.name]))\n", + "print(\"Ground candidate rows:\")\n", + "pprint(observations[imager.name][\"ground_targets\"])\n", + "print(\"RSO candidate rows:\")\n", + "pprint(observations[imager.name][\"rso_targets\"])\n", + "print(\"Integer actions:\")\n", + "pprint(dict(enumerate(imager.action_description)))\n", + "print(\"Shared storage partitions:\", partitions)" + ] + }, + { + "cell_type": "markdown", + "id": "stepping-explanation", + "metadata": {}, + "source": [ + "## Step until a scripted task completes\n", + "\n", + "`None` continues the current action. The helper below starts each requested task once, keeps the passive spacecraft drifting, and records total storage usage while the simulation advances. Its step limit reports a failure if a demonstration task cannot finish." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "stepping", + "metadata": {}, + "outputs": [], + "source": [ + "history = []\n", + "\n", + "\n", + "def advance_until(action, finished, max_steps=60):\n", + " for index in range(max_steps):\n", + " _, _, terminated, truncated, _ = env.step(\n", + " {\n", + " satellite.name: (action if index == 0 else None)\n", + " if satellite is imager\n", + " else (0 if env.simulator.sim_time == 0 else None)\n", + " for satellite in env.satellites\n", + " }\n", + " )\n", + " history.append(\n", + " dict(\n", + " time_s=float(env.simulator.sim_time),\n", + " storage_bits=float(imager.dynamics.storage_level),\n", + " )\n", + " )\n", + " if finished():\n", + " return\n", + " if terminated[imager.name] or truncated[imager.name]:\n", + " raise RuntimeError(\"The example ended before completing its scripted task.\")\n", + " raise RuntimeError(\n", + " f\"The scripted task {action!r} did not finish in {max_steps} steps.\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "ground-tasking-explanation", + "metadata": {}, + "source": [ + "## Acquire a ground target by ID\n", + "\n", + "This passes the ground target's ID through the ordinary action builder. The acquisition is detected from an increase in the ground buffer, with no search through the RSO partitions." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "ground-tasking", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Acquired ground target by ID: ground_0_4976925184\n", + "Ground acquisition reward: 3.0\n" + ] + } + ], + "source": [ + "advance_until(ground.id, lambda: ground in stores[\"ground_images\"].data.imaged)\n", + "print(\"Acquired ground target by ID:\", ground.id)\n", + "print(\n", + " \"Ground acquisition reward:\",\n", + " env.rewarder.rewarders[\"ground_images\"].cum_reward[imager.name],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "rso-tasking-explanation", + "metadata": {}, + "source": [ + "## Acquire the RSO targets by ID\n", + "\n", + "The same action interface routes each RSO ID to `ImageRSO`. Switching modes cancels any previous acquisition hold and selects the RSO instrument command source. Each image is recorded only after a complete product has reached its target's storage partition." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "rso-tasking", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Acquired RSO target by ID: rso_a\n", + "Acquired RSO target by ID: rso_b\n", + "RSO acquisition reward: 2.7\n" + ] + } + ], + "source": [ + "for target_id in rso_scene.targets_by_id:\n", + " advance_until(\n", + " target_id,\n", + " lambda target_id=target_id: any(\n", + " record.target_id == target_id\n", + " for record in stores[\"rso_images\"].data.captures.values()\n", + " ),\n", + " )\n", + " print(\"Acquired RSO target by ID:\", target_id)\n", + "print(\n", + " \"RSO acquisition reward:\",\n", + " env.rewarder.rewarders[\"rso_images\"].cum_reward[imager.name],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "storage-explanation", + "metadata": {}, + "source": [ + "## Inspect shared storage before downlink\n", + "\n", + "There should now be three 1,000-bit images: one in the ground buffer and one in each RSO partition. Their combined usage is 3,000 bits of the single 5,000-bit capacity." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "storage", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'image_buffer': 1000.0,\n", + " 'rso_3b64ca2a775eaf93edec3dce613b3749': 1000.0,\n", + " 'rso_f9ec1e33845a6f42682e0ef304bc4b6e': 1000.0}\n", + "Total storage [bits]: 3000.0\n", + "Storage fraction: 0.6\n" + ] + } + ], + "source": [ + "message = imager.dynamics.storageUnit.storageUnitDataOutMsg.read()\n", + "pprint(dict(zip(message.storedDataName, message.storedData)))\n", + "print(\"Total storage [bits]:\", imager.dynamics.storage_level)\n", + "print(\"Storage fraction:\", imager.dynamics.storage_level_fraction)" + ] + }, + { + "cell_type": "markdown", + "id": "downlink-explanation", + "metadata": {}, + "source": [ + "## Downlink and inspect the rewards\n", + "\n", + "Integer action `1` selects downlink. It drains the partitions through the shared transmitter. Partial RSO transmissions remain in the product ledger; delivery credit is awarded only when all bits of an image have left storage.\n", + "\n", + "Expected results are one ground image, two acquired and delivered RSO images, zero final storage, and cumulative rewards of **3 for ground imaging and 3 for RSO imaging**. The full sequence takes 40 simulated seconds in this configuration." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "downlink-and-summary", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'elapsed_s': 40.0,\n", + " 'final_storage_bits': 0.0,\n", + " 'ground_images': 1,\n", + " 'peak_storage_bits': 3000.0,\n", + " 'reward_by_channel': {'ground_images': 3.0, 'rso_images': 3.0000000000000004},\n", + " 'rso_captures': 2,\n", + " 'rso_deliveries': 2,\n", + " 'storage_partitions': ['image_buffer',\n", + " 'rso_f9ec1e33845a6f42682e0ef304bc4b6e',\n", + " 'rso_3b64ca2a775eaf93edec3dce613b3749']}\n" + ] + } + ], + "source": [ + "advance_until(1, lambda: imager.dynamics.storage_level == 0)\n", + "summary = dict(\n", + " ground_images=len(stores[\"ground_images\"].data.imaged),\n", + " rso_captures=len(stores[\"rso_images\"].data.captures),\n", + " rso_deliveries=len(stores[\"rso_images\"].data.deliveries),\n", + " storage_partitions=partitions,\n", + " peak_storage_bits=max(row[\"storage_bits\"] for row in history),\n", + " final_storage_bits=float(imager.dynamics.storage_level),\n", + " reward_by_channel={\n", + " channel: float(rewarder.cum_reward[imager.name])\n", + " for channel, rewarder in env.rewarder.rewarders.items()\n", + " },\n", + " elapsed_s=float(env.simulator.sim_time),\n", + ")\n", + "pprint(summary)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv_366 (3.12.7)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From b20be4c629113d488bcdcb21f881683a0a032b3d Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Tue, 20 Oct 2026 14:20:31 -0600 Subject: [PATCH 4/4] [#366] Update release notes --- docs/source/release_notes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index 7d62bbde..14bca451 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -5,6 +5,9 @@ Development - |version| ----------------------- .. *Release Date: MMM. DD, YYYY* +* Allow one satellite to image ground and RSO targets with separate pointing + tasks, combined observations and ID tasking, and shared partitioned storage. + See :doc:`the combined imaging notebook `. * Add ``block_pending_targets=False`` to :class:`~bsk_rl.scene.RSOTargets` to allow reimaging before downlink, preserving cooldowns and image/reward bookkeeping. Pending targets remain blocked by default. See issue #364.