From d6b4b63f19817bbf003a1967aa5b8f7c5ce47568 Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Wed, 7 Oct 2026 14:25:37 -0600 Subject: [PATCH 1/4] [#360] Allows using multiple scenarios in the same env --- src/bsk_rl/comm/communication.py | 2 + src/bsk_rl/data/__init__.py | 4 + src/bsk_rl/data/composition.py | 296 ++++++++++++++++++++++++++- src/bsk_rl/data/unique_image_data.py | 4 +- src/bsk_rl/scene/__init__.py | 2 + src/bsk_rl/scene/composition.py | 94 +++++++++ 6 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 src/bsk_rl/scene/composition.py diff --git a/src/bsk_rl/comm/communication.py b/src/bsk_rl/comm/communication.py index 91d5ede6..878fc8af 100644 --- a/src/bsk_rl/comm/communication.py +++ b/src/bsk_rl/comm/communication.py @@ -90,6 +90,8 @@ def _communicate_all(self): final_data += satellite.data_store.data for satellite in self.satellites: satellite.data_store.data = copy(final_data) + if hasattr(satellite.data_store, "pass_data"): + satellite.data_store.pass_data() class NoCommunication(CommunicationMethod): diff --git a/src/bsk_rl/data/__init__.py b/src/bsk_rl/data/__init__.py index 814c4065..aefc46c8 100644 --- a/src/bsk_rl/data/__init__.py +++ b/src/bsk_rl/data/__init__.py @@ -97,6 +97,7 @@ """ from bsk_rl.data.base import GlobalReward +from bsk_rl.data.composition import MixedData, MixedDataStore, MixedReward from bsk_rl.data.nadir_data import ScanningTimeReward from bsk_rl.data.no_data import NoReward from bsk_rl.data.resource_data import ResourceReward @@ -111,6 +112,9 @@ __doc_title__ = "Data & Reward" __all__ = [ + "MixedData", + "MixedDataStore", + "MixedReward", "RSOImageRecord", "RSOImageData", "RSOImageStore", diff --git a/src/bsk_rl/data/composition.py b/src/bsk_rl/data/composition.py index 8e6a2e1a..6830af67 100644 --- a/src/bsk_rl/data/composition.py +++ b/src/bsk_rl/data/composition.py @@ -1,11 +1,13 @@ """Data composition classes.""" import logging +from collections.abc import Mapping, Sequence +from copy import copy from typing import TYPE_CHECKING, Optional from bsk_rl.data.base import Data, DataStore, GlobalReward from bsk_rl.sats import Satellite -from bsk_rl.scene.scenario import Scenario +from bsk_rl.scene import MixedScenario, Scenario if TYPE_CHECKING: from bsk_rl.sats import Satellite @@ -187,13 +189,13 @@ def reset_overwrite_previous(self) -> None: rewarder.reset_overwrite_previous() def link_scenario(self, scenario: Scenario) -> None: - """Link the rewarder to the scenario.""" + """Link every component to the shared scenario.""" super().link_scenario(scenario) for rewarder in self.rewarders: rewarder.link_scenario(scenario) def initial_data(self, satellite: Satellite) -> ComposedData: - """Furnish the DataStore with :class:`ComposedData`.""" + """Furnish every component's initial data in a fixed order.""" return ComposedData( *[rewarder.initial_data(satellite) for rewarder in self.rewarders] ) @@ -251,5 +253,291 @@ def is_terminated(self, satellite) -> bool: return any(rewarder.is_terminated(satellite) for rewarder in self.rewarders) +class MixedData(Data): + """Knowledge indexed by reward channel, independent of local capabilities. + + Channel IDs distinguish independent rewarders even when they use the same data + class. An absent channel contributes no data; an empty MixedData is the identity. + """ + + def __init__(self, data: Optional[Mapping[str, Data]] = None) -> None: + """Copy the channel mapping; values follow the Data copying contract.""" + self.data = {} if data is None else dict(data) + + def __add__(self, other: "MixedData") -> "MixedData": + """Merge matching channels and copy channels present on only one side.""" + if not isinstance(other, MixedData): + return NotImplemented + merged = {key: copy(value) for key, value in self.data.items()} + for key, value in other.data.items(): + if key in self.data: + if type(self.data[key]) is not type(value): + raise TypeError(f"Incompatible data types for channel {key!r}.") + merged[key] = self.data[key] + value + else: + merged[key] = copy(value) + return MixedData(merged) + + def __getattr__(self, name: str): + """Forward unambiguous attributes for existing observations and actions.""" + # Avoid recursion when deepcopy probes an incompletely constructed object. + data = self.__dict__.get("data", {}) + matches = [value for value in data.values() if hasattr(value, name)] + if len(matches) == 1: + return getattr(matches[0], name) + if matches: + raise AttributeError( + f"Ambiguous attribute {name!r}; select a MixedData channel explicitly." + ) + raise AttributeError(f"No Data in MixedData has attribute {name!r}.") + + def __repr__(self) -> str: + """Represent the channel mapping.""" + return f"MixedData({self.data!r})" + + +class MixedDataStore(DataStore): + """Poll local channel stores while retaining any communicated channels.""" + + data_type = MixedData + + def __init__( + self, + satellite: Satellite, + data_stores: Mapping[str, DataStore], + initial_data: Optional[MixedData] = None, + ) -> None: + """Initialize explicit channel or scenario bindings.""" + self.data_stores = dict(data_stores) + if initial_data is None: + initial_data = MixedData( + {key: store.data for key, store in self.data_stores.items()} + ) + super().__init__(satellite, initial_data) + self.pass_data() + + def pass_data(self) -> None: + """Synchronize component data with the parent channel mapping.""" + for key, store in self.data_stores.items(): + store.data = self.data.data[key] + + def __getattr__(self, name: str): + """Forward attributes only when one local component provides them.""" + stores = self.__dict__.get("data_stores", {}) + matches = [store for store in stores.values() if hasattr(store, name)] + if len(matches) == 1: + return getattr(matches[0], name) + if matches: + raise AttributeError( + f"Ambiguous attribute {name!r}; select a datastore channel explicitly." + ) + raise AttributeError(f"No DataStore in MixedDataStore has attribute {name!r}.") + + def get_log_state(self) -> dict: + """Collect log states from locally active channels.""" + return {key: store.get_log_state() for key, store in self.data_stores.items()} + + def compare_log_states(self, prev_state: dict, new_state: dict) -> MixedData: + """Generate deltas only for locally active channels.""" + return MixedData( + { + key: store.compare_log_states(prev_state[key], new_state[key]) + for key, store in self.data_stores.items() + } + ) + + def update_from_logs(self) -> MixedData: + """Update local knowledge and synchronize component stores.""" + self.pass_data() + new_data = super().update_from_logs() + self.pass_data() + return new_data + + def update_with_communicated_data(self) -> None: + """Merge received knowledge and synchronize local stores.""" + super().update_with_communicated_data() + self.pass_data() + + +class MixedReward(GlobalReward): + """Route named reward channels to selected satellites and scenarios. + + Args: + rewarders: Channel ID to independent rewarder instance. + satellite_mapping: Channel ID to names of satellites producing that data. + Satellites may belong to several channels or none. + scenario_mapping: Channel ID to scenario ID in MixedScenario. For an ordinary + Scenario this may be omitted, and every channel uses that scenario. + """ + + data_store_type = MixedDataStore + + def __init__( + self, + rewarders: Mapping[str, GlobalReward], + satellite_mapping: Mapping[str, Sequence[str]], + scenario_mapping: Optional[Mapping[str, str]] = None, + ) -> None: + """Initialize explicit channel or scenario bindings.""" + super().__init__() + self.rewarders = dict(rewarders) + self.satellite_mapping = { + key: frozenset(names) for key, names in satellite_mapping.items() + } + self.scenario_mapping = ( + None if scenario_mapping is None else dict(scenario_mapping) + ) + if self.rewarders.keys() != self.satellite_mapping.keys(): + raise ValueError("Every reward channel must have a satellite mapping.") + if len({id(r) for r in self.rewarders.values()}) != len(self.rewarders): + raise ValueError( + "Each reward channel requires an independent rewarder instance." + ) + if ( + self.scenario_mapping is not None + and self.rewarders.keys() != self.scenario_mapping.keys() + ): + raise ValueError("Every reward channel must have a scenario mapping.") + + def link_scenario(self, scenario: Scenario) -> None: + """Bind reward channels to the selected scenario instances.""" + super().link_scenario(scenario) + if isinstance(scenario, MixedScenario): + if self.scenario_mapping is None: + raise ValueError("MixedScenario requires an explicit scenario mapping.") + known_names = {sat.name for sat in scenario.satellites} + for key, rewarder in self.rewarders.items(): + scene_id = self.scenario_mapping[key] + if scene_id not in scenario.scenarios: + raise ValueError( + f"Unknown scenario {scene_id!r} for channel {key!r}." + ) + names = self.satellite_mapping[key] + if not names <= known_names: + raise ValueError( + f"Unknown satellites for channel {key!r}: {sorted(names - known_names)}" + ) + if not names <= set(scenario.satellite_mapping[scene_id]): + raise ValueError( + f"Reward channel {key!r} contains satellites outside its scenario." + ) + rewarder.link_scenario(scenario.scenarios[scene_id]) + else: + if self.scenario_mapping is not None: + raise ValueError("Scenario IDs require a MixedScenario.") + known_names = {sat.name for sat in scenario.satellites} + for key, rewarder in self.rewarders.items(): + if not self.satellite_mapping[key] <= known_names: + raise ValueError(f"Unknown satellites for channel {key!r}.") + rewarder.link_scenario(scenario) + + def pass_data(self) -> None: + """Synchronize component data with the parent channel mapping.""" + for key, rewarder in self.rewarders.items(): + if key not in self.data.data: + self.data.data[key] = rewarder.data_type() + rewarder.data = self.data.data[key] + + def reset_overwrite_previous(self) -> None: + """Clear episode state and reset each component once.""" + super().reset_overwrite_previous() + for rewarder in self.rewarders.values(): + rewarder.reset_overwrite_previous() + self.pass_data() + + def reset_pre_sim_init(self) -> None: + """Prepare each component before simulator construction.""" + super().reset_pre_sim_init() + self.pass_data() + for rewarder in self.rewarders.values(): + rewarder.reset_pre_sim_init() + + def reset_during_sim_init(self) -> None: + """Prepare each component during simulator construction.""" + super().reset_during_sim_init() + self.pass_data() + for rewarder in self.rewarders.values(): + rewarder.reset_during_sim_init() + + def reset_post_sim_init(self) -> None: + """Finalize component setup after simulator initialization.""" + super().reset_post_sim_init() + self.pass_data() + for rewarder in self.rewarders.values(): + rewarder.reset_post_sim_init() + + def initial_data(self, satellite: Satellite) -> MixedData: + """Provide initial knowledge for the satellite's assigned channels.""" + return MixedData( + { + key: rewarder.initial_data(satellite) + for key, rewarder in self.rewarders.items() + if satellite.name in self.satellite_mapping[key] + } + ) + + def create_data_store(self, satellite: Satellite) -> None: + """Create assigned stores using each rewarder's setup hooks.""" + stores = {} + for key, rewarder in self.rewarders.items(): + if satellite.name in self.satellite_mapping[key]: + # Preserve rewarder-specific setup (e.g. imaging access filters). + rewarder.create_data_store(satellite) + stores[key] = satellite.data_store + satellite.data_store = MixedDataStore(satellite, stores) + self.cum_reward[satellite.name] = 0.0 + + def calculate_reward(self, new_data_dict: dict[str, MixedData]) -> dict[str, float]: + """Route local deltas and sum rewards against previous global data.""" + self.pass_data() + reward = {name: 0.0 for name in new_data_dict} + for key, rewarder in self.rewarders.items(): + channel_data = { + name: delta.data[key] + for name, delta in new_data_dict.items() + if name in self.satellite_mapping[key] and key in delta.data + } + if not channel_data: + continue + reward_i = rewarder.calculate_reward(channel_data) + for name, value in reward_i.items(): + if name not in channel_data: + raise ValueError( + f"Channel {key!r} returned reward for unassigned satellite {name!r}." + ) + reward[name] += value + rewarder.cum_reward[name] += value + return reward + + def reward(self, new_data_dict: dict[str, MixedData]) -> dict[str, float]: + """Calculate rewards, merge global deltas, and synchronize child data.""" + reward = super().reward(new_data_dict) + self.pass_data() + return reward + + def is_truncated(self, satellite: Satellite) -> bool: + """Check only rewarders assigned to this satellite.""" + return any( + r.is_truncated(satellite) + for key, r in self.rewarders.items() + if satellite.name in self.satellite_mapping[key] + ) + + def is_terminated(self, satellite: Satellite) -> bool: + """Check only rewarders assigned to this satellite.""" + return any( + r.is_terminated(satellite) + for key, r in self.rewarders.items() + if satellite.name in self.satellite_mapping[key] + ) + + __doc_title__ = "Data Composition" -__all__ = ["ComposedReward", "ComposedDataStore", "ComposedData"] +__all__ = [ + "ComposedReward", + "ComposedDataStore", + "ComposedData", + "MixedReward", + "MixedDataStore", + "MixedData", +] diff --git a/src/bsk_rl/data/unique_image_data.py b/src/bsk_rl/data/unique_image_data.py index 9e8cc68b..7574b5ed 100644 --- a/src/bsk_rl/data/unique_image_data.py +++ b/src/bsk_rl/data/unique_image_data.py @@ -159,9 +159,11 @@ def create_data_store(self, satellite: "Satellite") -> None: """Override the access filter in addition to creating the data store.""" super().create_data_store(satellite) + data_store = satellite.data_store + def unique_target_filter(opportunity): if opportunity["type"] == "target": - return opportunity["object"] not in satellite.data_store.data.imaged + return opportunity["object"] not in data_store.data.imaged return True satellite.add_access_filter(unique_target_filter) diff --git a/src/bsk_rl/scene/__init__.py b/src/bsk_rl/scene/__init__.py index ff3e9ee1..c62e0a23 100644 --- a/src/bsk_rl/scene/__init__.py +++ b/src/bsk_rl/scene/__init__.py @@ -15,6 +15,7 @@ These RSO scenarios can be used with :class:`RSOInspectionReward`. """ +from bsk_rl.scene.composition import MixedScenario from bsk_rl.scene.rso_points import RSOPoints, SphericalRSO from bsk_rl.scene.rso_targets import ( RSOPriorityEvent, @@ -27,6 +28,7 @@ __doc_title__ = "Scenario" __all__ = [ + "MixedScenario", "Scenario", "UniformTargets", "CityTargets", diff --git a/src/bsk_rl/scene/composition.py b/src/bsk_rl/scene/composition.py new file mode 100644 index 00000000..3a2e0242 --- /dev/null +++ b/src/bsk_rl/scene/composition.py @@ -0,0 +1,94 @@ +"""Independent scenarios bound to named groups of participants.""" + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING + +from bsk_rl.scene.scenario import Scenario + +if TYPE_CHECKING: + from bsk_rl.sats import Satellite + + +class MixedScenario(Scenario): + """Run scenarios with explicit IDs and separate participant groups. + + Participants include physical targets as well as agents. Reward-producing + satellites are configured independently by MixedReward. + """ + + def __init__( + self, + scenarios: Mapping[str, Scenario], + satellite_mapping: Mapping[str, Sequence[str]], + ) -> None: + """Bind independent scenes to explicit participant names. + + Args: + scenarios: Scenario ID to scenario instance. + satellite_mapping: Scenario ID to participant names, including any + physical target spacecraft. Groups may overlap. + """ + super().__init__() + self.scenarios = dict(scenarios) + self.satellite_mapping = { + key: tuple(names) for key, names in satellite_mapping.items() + } + if self.scenarios.keys() != self.satellite_mapping.keys(): + raise ValueError("Every scenario must have a participant mapping.") + + def _groups(self): + # An instance referenced by several IDs receives each lifecycle hook once. + groups = {} + for key, scenario in self.scenarios.items(): + if id(scenario) not in groups: + groups[id(scenario)] = (scenario, set()) + groups[id(scenario)][1].update(self.satellite_mapping[key]) + return groups.values() + + def validate_satellite_names(self, satellites: list["Satellite"]) -> None: + """Reject ambiguous or missing names before environment copying.""" + names = [sat.name for sat in satellites] + if len(names) != len(set(names)): + raise ValueError("MixedScenario requires unique satellite names.") + for scenario, participants in self._groups(): + missing = participants - set(names) + if missing: + raise ValueError(f"Unknown scene participants: {sorted(missing)}") + scenario.validate_satellite_names( + [sat for sat in satellites if sat.name in participants] + ) + + def link_satellites(self, satellites: list["Satellite"]) -> None: + """Bind each scenario to its copied participants.""" + self.validate_satellite_names(satellites) + super().link_satellites(satellites) + for scenario, participants in self._groups(): + scenario.link_satellites( + [sat for sat in satellites if sat.name in participants] + ) + + def after_step(self, sim_time: float) -> None: + """Advance each distinct scenario once after reward calculation.""" + for scenario, _ in self._groups(): + scenario.after_step(sim_time) + + def reset_overwrite_previous(self) -> None: + """Clear episode state and reset each component once.""" + for scenario, _ in self._groups(): + scenario.reset_overwrite_previous() + + def reset_pre_sim_init(self) -> None: + """Prepare each component before simulator construction.""" + for scenario, _ in self._groups(): + scenario.utc_init = self.utc_init + scenario.reset_pre_sim_init() + + def reset_during_sim_init(self) -> None: + """Prepare each component during simulator construction.""" + for scenario, _ in self._groups(): + scenario.reset_during_sim_init() + + def reset_post_sim_init(self) -> None: + """Finalize component setup after simulator initialization.""" + for scenario, _ in self._groups(): + scenario.reset_post_sim_init() From e576ad763f81d21a438acb6c3a40ee1b70e8116b Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Wed, 7 Oct 2026 14:27:07 -0600 Subject: [PATCH 2/4] [#360] Add tests --- tests/integration/test_int_mixed.py | 234 +++++++++++++++++++++++ tests/unittest/data/test_mixed.py | 285 ++++++++++++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 tests/integration/test_int_mixed.py create mode 100644 tests/unittest/data/test_mixed.py diff --git a/tests/integration/test_int_mixed.py b/tests/integration/test_int_mixed.py new file mode 100644 index 00000000..55df2e08 --- /dev/null +++ b/tests/integration/test_int_mixed.py @@ -0,0 +1,234 @@ +"""Physical imaging across distinct scenes and overlapping reward assignments.""" + +from typing import ClassVar + +import numpy as np +import pytest +from Basilisk.utilities import orbitalMotion + +from bsk_rl import ConstellationTasking, act, data, obs, sats, scene +from bsk_rl.comm import FreeCommunication, NoCommunication +from bsk_rl.scene.targets import Target +from bsk_rl.sim import dyn, fsw + +EPOCH = "2026 OCT 01 00:00:00.000 (UTC)" + + +class FixedGroundTargets(scene.UniformTargets): + """Put a ground target directly below the initial imaging satellite.""" + + def regenerate_targets(self): + self.targets = [ + Target("city", [orbitalMotion.REQ_EARTH * 1e3, 0, 0], priority=3) + ] + + def reset_post_sim_init(self): + satellite = self.satellites[0] + position_P = satellite.dynamics.world.PN @ satellite.dynamics.r_BN_N + # Access registration retains this array; update it before windows are + # generated, accounting for the realized Earth orientation at the epoch. + self.targets[0].r_LP_P[:] = ( + self.radius * position_P / np.linalg.norm(position_P) + ) + + +class GroundObserver(sats.ImagingSatellite): + dyn_type = dyn.GroundStationDynModel + fsw_type = fsw.ImagingFSWModel + observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] + action_spec: ClassVar[list[act.Action]] = [ + act.Image(1, max_duration=30), + act.Charge(duration=10), + ] + + +class RSOObserver(sats.Satellite): + dyn_type = dyn.SpaceToSpaceImagingDynModel + fsw_type = fsw.SpaceToSpaceImagingFSWModel + observation_spec: ClassVar[list[obs.Observation]] = [ + obs.Time(), + obs.RSOTargetProperties(n_ahead_observe=1), + ] + action_spec: ClassVar[list[act.Action]] = [ + act.ImageRSO( + 1, + max_duration=30, + min_pointing_hold_s=0, + require_illumination_during_hold=False, + ), + act.Charge(duration=10), + ] + + +class PassiveTarget(sats.Satellite): + dyn_type = dyn.RSOTargetDynModel + fsw_type = fsw.FSWModel + observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] + action_spec: ClassVar[list[act.Action]] = [act.Drift(duration=1e9)] + + +def make_env(overlap, communicate): + satellite_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, + batteryStorageCapacity=1e9, + storedCharge_Init=1e9, + dataStorageCapacity=1e6, + storageInit=0, + ) + ground = GroundObserver("ground", sat_args=satellite_args) + rso = RSOObserver("observer", sat_args=satellite_args) + target = PassiveTarget("debris") + catalog = scene.RSOTargetCatalog( + ( + scene.RSOTarget( + "object", "debris", (7e6 + 1000, 0, 0), (0, 7500, 0), priority=2 + ), + ), + EPOCH, + ) + scenarios = { + "ground_scene": FixedGroundTargets(1), + "rso_scene": scene.RSOTargets(catalog, ["observer"]), + } + participants = {"ground_scene": ["ground"], "rso_scene": ["observer", "debris"]} + rewarders = { + "ground_images": data.UniqueImageReward(), + "rso_images": data.RSOImageReward( + quality_threshold=0, + acquisition_reward_fn=lambda record, target: target.priority, + ), + } + assignments = {"ground_images": ["ground"], "rso_images": ["observer"]} + scenario_mapping = {"ground_images": "ground_scene", "rso_images": "rso_scene"} + if overlap: + # One physical agent participates in two scenes. The extra scene measures + # elapsed time; dual ground/RSO imaging requires additional physical models. + scenarios["clock_scene"] = scene.Scenario() + participants["clock_scene"] = ["ground"] + rewarders["elapsed_time"] = data.ResourceReward( + reward_weight=-0.25, + resource_fn=lambda sat: sat.simulator.sim_time, + ) + assignments["elapsed_time"] = ["ground"] + scenario_mapping["elapsed_time"] = "clock_scene" + return ConstellationTasking( + satellites=[ground, rso, target], + scenario=scene.MixedScenario(scenarios, participants), + rewarder=data.MixedReward(rewarders, assignments, scenario_mapping), + communicator=FreeCommunication() if communicate else NoCommunication(), + world_args=dict(utc_init=EPOCH), + time_limit=60, + max_step_duration=1, + sim_rate=1, + log_level="ERROR", + ) + + +@pytest.mark.parametrize("overlap", [False, True]) +@pytest.mark.parametrize("communicate", [False, True]) +@pytest.mark.parametrize("charge", [False, True]) +def test_distinct_scenes_and_overlapping_agent(overlap, communicate, charge): + """Preserve physical attribution, global history, communication, and reset.""" + env = make_env(overlap, communicate) + try: + for seed in (0, 1): + env.reset(seed=seed) + by_name = {sat.name: sat for sat in env.satellites} + ground, rso, target = ( + by_name[name] for name in ("ground", "observer", "debris") + ) + ground_channels = ( + {"ground_images", "elapsed_time"} if overlap else {"ground_images"} + ) + assert set(ground.data_store.data_stores) == ground_channels + assert set(rso.data_store.data_stores) == {"rso_images"} + assert target.data_store.data_stores == {} + assert ( + env.rewarder.rewarders["ground_images"].scenario + is env.scenario.scenarios["ground_scene"] + ) + assert ( + env.rewarder.rewarders["rso_images"].scenario + is env.scenario.scenarios["rso_scene"] + ) + if overlap: + assert env.scenario.scenarios["clock_scene"].satellites == [ground] + assert ( + env.rewarder.rewarders["elapsed_time"].scenario + is env.scenario.scenarios["clock_scene"] + ) + totals = {sat.name: 0.0 for sat in env.satellites} + for step in range(8): + previous_time = env.simulator.sim_time + action = 1 if charge else 0 + actions = { + name: action if step == 0 else None + for name in ("ground", "observer") + } + actions["debris"] = 0 if step == 0 else None + _, rewards, _, _, _ = env.step(actions) + if charge: + # Charging produces no imaging reward. The independent clock + # channel still penalizes elapsed time in overlapping cases. + time_penalty = ( + -0.25 * (env.simulator.sim_time - previous_time) + if overlap + else 0 + ) + assert rewards == pytest.approx( + {"ground": time_penalty, "observer": 0, "debris": 0} + ) + assert not ground.data_store.new_data.data["ground_images"].imaged + assert not rso.data_store.new_data.data["rso_images"].captures + for name, value in rewards.items(): + totals[name] += value + expected_images = 0 if charge else 1 + ground_image_reward = 0 if charge else 3 + rso_image_reward = 0 if charge else 2 + assert ( + len(ground.data_store.data.data["ground_images"].imaged) + == expected_images + ) + assert ( + len(rso.data_store.data.data["rso_images"].captures) == expected_images + ) + assert env.rewarder.rewarders["ground_images"].cum_reward == { + "ground": ground_image_reward + } + assert env.rewarder.rewarders["rso_images"].cum_reward == { + "observer": rso_image_reward + } + expected_ground = ground_image_reward - ( + 0.25 * env.simulator.sim_time if overlap else 0 + ) + assert totals == pytest.approx( + {"ground": expected_ground, "observer": rso_image_reward, "debris": 0} + ) + assert env.rewarder.cum_reward == pytest.approx(totals) + if overlap: + assert env.rewarder.rewarders["elapsed_time"].cum_reward[ + "ground" + ] == pytest.approx(-0.25 * env.simulator.sim_time) + for sat in env.satellites: + assert set(sat.data_store.new_data.data) == set( + sat.data_store.data_stores + ) + for key, store in sat.data_store.data_stores.items(): + assert store.data is sat.data_store.data.data[key] + if communicate: + assert set(sat.data_store.data.data) == set(env.rewarder.rewarders) + if communicate: + assert target.data_store.new_data.data == {} + # Communicated RSO records never become ground acquisition deltas. + assert "rso_images" not in ground.data_store.new_data.data + assert "ground_images" not in rso.data_store.new_data.data + finally: + if hasattr(env, "simulator"): + env.close() diff --git a/tests/unittest/data/test_mixed.py b/tests/unittest/data/test_mixed.py new file mode 100644 index 00000000..6595ffc2 --- /dev/null +++ b/tests/unittest/data/test_mixed.py @@ -0,0 +1,285 @@ +"""Heterogeneous reward routing, knowledge sharing, and scenario bindings.""" + +from copy import copy, deepcopy +from types import SimpleNamespace as NS +from unittest.mock import MagicMock + +import pytest + +from bsk_rl.comm import FreeCommunication +from bsk_rl.data import ( + MixedData, + MixedReward, + RSOImageData, + RSOImageRecord, + RSOImageReward, +) +from bsk_rl.data.base import Data, DataStore, GlobalReward +from bsk_rl.data.unique_image_data import UniqueImageData, UniqueImageReward +from bsk_rl.scene import MixedScenario, Scenario +from bsk_rl.scene.targets import Target + + +class CountData(Data): + def __init__(self, count=0): + self.count = count + + def __add__(self, other): + return type(self)(self.count + other.count) + + +class CountStore(DataStore): + data_type = CountData + + def __init__(self, satellite, initial_data=None, increment=1): + super().__init__(satellite, initial_data) + self.increment = increment + + def get_log_state(self): + return self.satellite.value + + def compare_log_states(self, old_state, new_state): + return CountData((new_state - old_state) * self.increment) + + +class CountReward(GlobalReward): + data_store_type = CountStore + + def __init__(self, increment=1): + super().__init__() + self.data_store_kwargs = {"increment": increment} + self.calls = [] + + def create_data_store(self, satellite): + super().create_data_store(satellite) + satellite.hooks.append(self) + + def calculate_reward(self, new_data_dict): + self.calls.append(new_data_dict) + return {name: value.count for name, value in new_data_dict.items()} + + def is_terminated(self, satellite): + return True + + +def make_mixed(): + satellites = [NS(name=name, value=0, hooks=[]) for name in ("eo", "sda", "target")] + scenario = MixedScenario( + {"ground": Scenario(), "rso": Scenario()}, + {"ground": ["eo"], "rso": ["sda", "target"]}, + ) + scenario.link_satellites(satellites) + rewarder = MixedReward( + {"ground": CountReward(2), "rso": CountReward(3)}, + {"ground": ["eo"], "rso": ["sda"]}, + {"ground": "ground", "rso": "rso"}, + ) + rewarder.link_scenario(scenario) + rewarder.reset_overwrite_previous() + for sat in satellites: + rewarder.create_data_store(sat) + rewarder.data += sat.data_store.data + sat.data_store.update_from_logs() + return satellites, scenario, rewarder + + +def test_mixed_data_union_identity_and_copy(): + first = MixedData({"ground": UniqueImageData(duplicates=2)}) + second = MixedData({"rso": RSOImageData()}) + merged = first + second + assert set(merged.data) == {"ground", "rso"} + assert merged.data["ground"].duplicates == 2 + assert merged.data["ground"] is not first.data["ground"] + assert (merged + MixedData()).data.keys() == merged.data.keys() + assert (MixedData() + merged).data.keys() == merged.data.keys() + assert copy(merged).data["ground"] is not merged.data["ground"] + assert deepcopy(merged).data.keys() == merged.data.keys() + assert MixedData().__add__(CountData()) is NotImplemented + with pytest.raises(TypeError, match="ground"): + first + MixedData({"ground": RSOImageData()}) + + +def test_same_type_channels_stay_separate_and_shared_channels_add(): + first = MixedData({"a": CountData(2), "b": CountData(10)}) + result = first + MixedData({"a": CountData(3)}) + assert result.data["a"].count == 5 + assert result.data["b"].count == 10 + with pytest.raises(AttributeError, match="Ambiguous"): + _ = result.count + + +def test_reward_routing_hooks_kwargs_passive_satellites_and_reset(): + satellites, scenario, rewarder = make_mixed() + for sat in satellites: + sat.value = 1 + deltas = {sat.name: sat.data_store.update_from_logs() for sat in satellites} + assert rewarder.reward(deltas) == {"eo": 2, "sda": 3, "target": 0} + assert len(satellites[0].hooks) == len(satellites[1].hooks) == 1 + assert satellites[2].hooks == [] + assert satellites[2].data_store.data_stores == {} + assert rewarder.rewarders["ground"].scenario is scenario.scenarios["ground"] + assert set(rewarder.rewarders["rso"].calls[-1]) == {"sda"} + assert rewarder.rewarders["ground"].data.count == 2 + assert rewarder.rewarders["rso"].data.count == 3 + assert rewarder.is_terminated(satellites[0]) + assert not rewarder.is_terminated(satellites[2]) + assert rewarder.reward({}) == {} + rewarder.reset_overwrite_previous() + assert rewarder.data.data["ground"].count == 0 + assert rewarder.rewarders["rso"].cum_reward == {} + + +@pytest.mark.parametrize("all_to_all", [False, True]) +def test_communicated_knowledge_does_not_become_local_delta(all_to_all): + satellites, _, rewarder = make_mixed() + eo, sda, _ = satellites + eo.value = 1 + sda.value = 1 + for sat in satellites: + sat.data_store.update_from_logs() + if all_to_all: + communicator = FreeCommunication() + communicator.link_satellites(satellites) + communicator._communicate_all() + else: + eo.data_store.stage_communicated_data(sda.data_store.data) + eo.data_store.update_with_communicated_data() + assert set(eo.data_store.data.data) == {"ground", "rso"} + assert eo.data_store.data_stores["ground"].data is eo.data_store.data.data["ground"] + eo.value = 2 + delta = eo.data_store.update_from_logs() + assert set(delta.data) == {"ground"} + assert rewarder.reward({"eo": delta}) == {"eo": 2} + assert rewarder.rewarders["rso"].calls == [] + + +def test_overlapping_assignments_sum_rewards(): + sat = NS(name="both", value=0, hooks=[]) + scenario = MixedScenario( + {"a": Scenario(), "b": Scenario()}, + {"a": ["both"], "b": ["both"]}, + ) + scenario.link_satellites([sat]) + rewarder = MixedReward( + {"a": CountReward(2), "b": CountReward(3)}, + {"a": ["both"], "b": ["both"]}, + {"a": "a", "b": "b"}, + ) + rewarder.link_scenario(scenario) + rewarder.reset_overwrite_previous() + rewarder.create_data_store(sat) + sat.data_store.update_from_logs() + sat.value = 1 + assert rewarder.reward({"both": sat.data_store.update_from_logs()}) == {"both": 5} + assert len(sat.hooks) == 2 + assert rewarder.rewarders["a"].scenario is scenario.scenarios["a"] + assert rewarder.rewarders["b"].scenario is scenario.scenarios["b"] + assert rewarder.rewarders["a"].scenario is not rewarder.rewarders["b"].scenario + + +def test_unique_image_hook_remains_bound_to_its_channel_after_communication(): + target = Target("city", [1, 0, 0], priority=1) + sat = NS(name="eo", add_access_filter=MagicMock()) + scenario = Scenario() + scenario.targets = [target] + scenario.link_satellites([sat]) + rewarder = MixedReward({"images": UniqueImageReward()}, {"images": ["eo"]}) + rewarder.link_scenario(scenario) + rewarder.reset_overwrite_previous() + rewarder.create_data_store(sat) + rewarder.data += sat.data_store.data + rewarder.reset_post_sim_init() + assert target in rewarder.rewarders["images"].data.known + access_filter = sat.add_access_filter.call_args.args[0] + assert access_filter({"type": "target", "object": target}) + sat.data_store.stage_communicated_data( + MixedData( + { + "images": UniqueImageData(imaged={target}), + "other_images": UniqueImageData(), + } + ) + ) + sat.data_store.update_with_communicated_data() + assert not access_filter({"type": "target", "object": target}) + + +def test_mixed_scenario_same_class_and_shared_instance_lifecycle(): + first, second = Scenario(), Scenario() + first.after_step = MagicMock() + second.after_step = MagicMock() + mixed = MixedScenario( + {"a": first, "alias": first, "b": second}, + {"a": ["a"], "alias": ["alias"], "b": ["b"]}, + ) + satellites = [NS(name=name) for name in ("a", "alias", "b")] + mixed.link_satellites(satellites) + assert {sat.name for sat in first.satellites} == {"a", "alias"} + assert second.satellites == [satellites[2]] + mixed.after_step(10) + first.after_step.assert_called_once_with(10) + second.after_step.assert_called_once_with(10) + with pytest.raises(ValueError, match="unique"): + mixed.validate_satellite_names([NS(name="a"), NS(name="a")]) + with pytest.raises(ValueError, match="Unknown"): + mixed.validate_satellite_names(satellites[:1]) + + +def test_reward_mapping_rejects_wrong_scene_membership(): + _, scenario, _ = make_mixed() + rewarder = MixedReward( + {"wrong": CountReward()}, {"wrong": ["target"]}, {"wrong": "ground"} + ) + with pytest.raises(ValueError, match="outside"): + rewarder.link_scenario(scenario) + with pytest.raises(ValueError, match="explicit"): + MixedReward({"a": CountReward()}, {"a": ["eo"]}).link_scenario(scenario) + repeated = CountReward() + with pytest.raises(ValueError, match="independent"): + MixedReward({"a": repeated, "b": repeated}, {"a": [], "b": []}) + + +def test_actual_ground_and_rso_rewards_keep_independent_global_history(): + target = Target("city", [1, 0, 0], priority=3) + satellites = [ + NS(name=name, add_access_filter=MagicMock()) for name in ("eo", "sda", "target") + ] + ground, rso = Scenario(), Scenario() + ground.targets = [target] + rso.imager_names = ("sda",) + rso.targets_by_id = {"object": NS(priority=2)} + rso.pending = {} + rso.cooldown_until = {} + rso.revision = 0 + scenario = MixedScenario( + {"ground": ground, "rso": rso}, + {"ground": ["eo"], "rso": ["sda", "target"]}, + ) + scenario.link_satellites(satellites) + rewarder = MixedReward( + { + "ground": UniqueImageReward(), + "rso": RSOImageReward( + acquisition_reward_fn=lambda record, target: 0.9 * target.priority, + delivery_reward_fn=lambda record, target: 0.1 * target.priority, + ), + }, + {"ground": ["eo"], "rso": ["sda"]}, + {"ground": "ground", "rso": "rso"}, + ) + rewarder.link_scenario(scenario) + rewarder.reset_overwrite_previous() + for sat in satellites: + rewarder.create_data_store(sat) + rewarder.data += sat.data_store.data + record = RSOImageRecord("sda:1", "object", "sda", 10, 100, 0.8, 10) + deltas = { + "eo": MixedData({"ground": UniqueImageData(imaged={target})}), + "sda": MixedData({"rso": RSOImageData(captures={record.record_id: record})}), + "target": MixedData(), + } + assert rewarder.reward(deltas) == {"eo": 3, "sda": 1.8, "target": 0} + assert target in rewarder.rewarders["ground"].data.known + assert target in rewarder.rewarders["ground"].data.imaged + assert record.record_id in rewarder.rewarders["rso"].data.captures + assert rewarder.reward(deltas) == {"eo": 0, "sda": 0, "target": 0} From 1ce3ec8ba346049fbac79c2faf3b588bc330cfc5 Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Wed, 7 Oct 2026 14:34:32 -0600 Subject: [PATCH 3/4] [#360] Add example script --- examples/_default.rst | 1 + examples/mixed_environment.ipynb | 267 +++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 examples/mixed_environment.ipynb diff --git a/examples/_default.rst b/examples/_default.rst index 58608718..a8945013 100644 --- a/examples/_default.rst +++ b/examples/_default.rst @@ -9,6 +9,7 @@ Environments simple_environment satellite_configuration multiagent_envs + mixed_environment fault_environment communication_action cloud_environment diff --git a/examples/mixed_environment.ipynb b/examples/mixed_environment.ipynb new file mode 100644 index 00000000..f9c2d5bb --- /dev/null +++ b/examples/mixed_environment.ipynb @@ -0,0 +1,267 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Mixed Ground and Space Imaging\n", + "\n", + "This minimal example uses three spacecraft: a ground imager, a space-to-space imager, and one passive RSO target. Each observer has its own scenario and reward channel. `MixedReward` keeps the different data types separate while `FreeCommunication` shares knowledge between spacecraft.\n", + "\n", + "BSK-RL and its dependencies must be installed. The fixed geometry and permissive imaging tolerances keep this a short configuration demonstration; they are not mission settings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "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.comm import FreeCommunication\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", + "metadata": {}, + "source": [ + "## Define the spacecraft\n", + "\n", + "The observers use different dynamics, flight software, observations, and imaging actions. The passive spacecraft is a participant in the RSO scenario but produces no reward data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class GroundObserver(sats.ImagingSatellite):\n", + " dyn_type = dyn.GroundStationDynModel\n", + " fsw_type = fsw.ImagingFSWModel\n", + " observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()]\n", + " action_spec: ClassVar[list[act.Action]] = [\n", + " act.Image(1, max_duration=30),\n", + " act.Charge(duration=10),\n", + " ]\n", + "\n", + "\n", + "class RSOObserver(sats.Satellite):\n", + " dyn_type = (dyn.SpaceToSpaceImagingDynModel, dyn.GroundStationDynModel)\n", + " fsw_type = fsw.SpaceToSpaceImagingFSWModel\n", + " observation_spec: ClassVar[list[obs.Observation]] = [\n", + " obs.Time(),\n", + " obs.RSOTargetProperties(n_ahead_observe=1),\n", + " ]\n", + " action_spec: ClassVar[list[act.Action]] = [\n", + " act.ImageRSO(\n", + " 1,\n", + " max_duration=30,\n", + " min_pointing_hold_s=0,\n", + " require_illumination_during_hold=False,\n", + " ),\n", + " act.Charge(duration=10),\n", + " ]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define the targets\n", + "\n", + "Both ground (`FixedGroundTargets`) and space (`PassiveTarget`) targets are defined. `FixedGroundTargets` places one ground target below the ground observer after the Earth's orientation is initialized." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class FixedGroundTargets(scene.UniformTargets):\n", + " \"\"\"Put a ground target directly below the initial imaging satellite.\"\"\"\n", + "\n", + " def regenerate_targets(self):\n", + " self.targets = [\n", + " Target(\"city\", [orbitalMotion.REQ_EARTH * 1e3, 0, 0], priority=3)\n", + " ]\n", + "\n", + " def reset_post_sim_init(self):\n", + " satellite = self.satellites[0]\n", + " position_P = satellite.dynamics.world.PN @ satellite.dynamics.r_BN_N\n", + " # Access registration retains this array; update it before windows are\n", + " # generated, accounting for the realized Earth orientation at the epoch.\n", + " self.targets[0].r_LP_P[:] = (\n", + " self.radius * position_P / np.linalg.norm(position_P)\n", + " )\n", + "\n", + "\n", + "class PassiveTarget(sats.Satellite):\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)]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Bind scenarios and reward channels\n", + "\n", + "`MixedScenario.satellite_mapping` lists all participants, including the passive RSO. `MixedReward.satellite_mapping` lists only the satellites producing each reward channel, and `scenario_mapping` binds each channel to its scenario ID. Channel IDs are explicit names, independent of data classes.\n", + "\n", + "The ground target has priority 3 and the RSO has priority 2. The RSO reward callback awards its priority at acquisition for this example; the default RSO reward instead requires complete downlink." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Use a nearby RSO and a ground target below the ground observer so that\n", + "# this short demonstration can acquire both images.\n", + "satellite_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, # Permissive pointing tolerance for this demo.\n", + " instrumentBaudRate=1000,\n", + " batteryStorageCapacity=1e9,\n", + " storedCharge_Init=1e9,\n", + " dataStorageCapacity=1e6,\n", + " storageInit=0,\n", + ")\n", + "satellites = [\n", + " GroundObserver(\"ground\", sat_args=satellite_args),\n", + " RSOObserver(\"observer\", sat_args=satellite_args),\n", + " PassiveTarget(\"debris\"),\n", + "]\n", + "catalog = scene.RSOTargetCatalog(\n", + " (\n", + " scene.RSOTarget(\n", + " \"object\", \"debris\", (7e6 + 1000, 0, 0), (0, 7500, 0), priority=2\n", + " ),\n", + " ),\n", + " EPOCH,\n", + ")\n", + "\n", + "mixed_scene = scene.MixedScenario(\n", + " scenarios={\n", + " \"ground_scene\": FixedGroundTargets(1),\n", + " \"rso_scene\": scene.RSOTargets(catalog, [\"observer\"]),\n", + " },\n", + " satellite_mapping={\n", + " \"ground_scene\": [\"ground\"],\n", + " \"rso_scene\": [\"observer\", \"debris\"],\n", + " },\n", + ")\n", + "mixed_reward = data.MixedReward(\n", + " rewarders={\n", + " \"ground_images\": data.UniqueImageReward(),\n", + " \"rso_images\": data.RSOImageReward(\n", + " quality_threshold=0,\n", + " acquisition_reward_fn=lambda record, target: target.priority,\n", + " ),\n", + " },\n", + " satellite_mapping={\n", + " \"ground_images\": [\"ground\"],\n", + " \"rso_images\": [\"observer\"],\n", + " },\n", + " scenario_mapping={\n", + " \"ground_images\": \"ground_scene\",\n", + " \"rso_images\": \"rso_scene\",\n", + " },\n", + ")\n", + "env = ConstellationTasking(\n", + " satellites=satellites,\n", + " scenario=mixed_scene,\n", + " rewarder=mixed_reward,\n", + " communicator=FreeCommunication(),\n", + " world_args=dict(utc_init=EPOCH),\n", + " time_limit=60,\n", + " max_step_duration=1,\n", + " sim_rate=1,\n", + " log_level=\"INFO\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Run the environment\n", + "\n", + "Both observers image their first candidate. This fixed geometry ensures that the RSO candidate slot is populated; general policies must avoid selecting empty RSO slots.\n", + "\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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " observations, info = env.reset(seed=0)\n", + " # Action 0 images a target on each observer, and drifts on the passive RSO.\n", + " # None continues the current action on subsequent steps.\n", + " for step in range(8):\n", + " actions = {sat.name: 0 if step == 0 else None for sat in env.satellites}\n", + " observations, rewards, terminated, truncated, info = env.step(actions)\n", + "\n", + " print(\"Cumulative rewards:\", env.rewarder.cum_reward)\n", + " print(\"Ground images:\", len(env.rewarder.data.data[\"ground_images\"].imaged))\n", + " print(\"RSO captures:\", len(env.rewarder.data.data[\"rso_images\"].captures))\n", + " for sat in env.satellites:\n", + " print(\n", + " sat.name,\n", + " \"produces\",\n", + " list(sat.data_store.data_stores),\n", + " \"knows\",\n", + " list(sat.data_store.data.data),\n", + " )\n", + "finally:\n", + " env.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv_issue_360 (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": 4 +} From 52d8abfa441f760d66a6cec75d23cbcffab83020 Mon Sep 17 00:00:00 2001 From: LorenzzoQM Date: Wed, 7 Oct 2026 14:35:34 -0600 Subject: [PATCH 4/4] [#360] Update release notes --- docs/source/release_notes.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index 52df9631..c15d8790 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -5,6 +5,13 @@ Development - |version| ----------------------- .. *Release Date: MMM. DD, YYYY* +* Add :class:`~bsk_rl.scene.MixedScenario` and :class:`~bsk_rl.data.MixedReward` + to combine independent scenarios and rewarders with different data types. + Explicit scenario and reward-channel IDs bind named satellite groups, including + overlapping assignments and passive target spacecraft. Communication shares + channel data while preserving local data production and reward attribution. + See :doc:`the mixed ground and space imaging example `. + * Add :doc:`the space-to-space imaging example ` for independently orbiting spacecraft: replayable RSO catalogs, explicitly named observers, moving-target access,