diff --git a/benchmarks/rso_inspection.py b/benchmarks/rso_inspection.py index e55d8b6e..61e4250e 100644 --- a/benchmarks/rso_inspection.py +++ b/benchmarks/rso_inspection.py @@ -245,7 +245,7 @@ def rewarder_config( env_args = dict( satellites=[ RSOSat("RSO", sat_args=rso_sat_args), - InspectorSat("Inspector-1", sat_args=inspector_sat_args), + InspectorSat("Inspector_1", sat_args=inspector_sat_args), ], sat_arg_randomizer=sat_arg_randomizer, scenario=scene.SphericalRSO( diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index 1c1914c4..4d285329 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -5,6 +5,30 @@ Development - |version| ----------------------- .. *Release Date: MMM. DD, YYYY* +* Add :doc:`the space-to-space imaging example ` + for independently orbiting spacecraft: + replayable RSO catalogs, explicitly named observers, moving-target access, + continuous and cumulative acquisition holds, optional ground-station downlink, + independently sized target observations and image actions, owned image ledgers, configurable + acquisition/delivery rewards, and replayable policy evaluation with plots. + Acquisition currently uses a temporary BSK-RL gate; adopting the proposed + Basilisk controller update is tracked in `#358 + `_. + Existing RSO surface-point inspection is unchanged. + +* Validate satellite names at construction and renaming so simulator events use + those names directly. Names must be valid Python identifiers, for example + ``EO_1`` rather than ``EO-1``. Target catalog IDs may still contain punctuation. + RSO image rewards default to full priority credit after complete downlink; + acquisition shaping is available through explicit reward callbacks. + +* Fix incorrect eclipse-observation test assertions that assumed countdowns could + not increase across a long step. The tests now use the same simulation start + date, orbit, and random seed on every run, so the results are repeatable. + They check shadow entry and countdown rollover to later orbits. + Eclipse prediction is unchanged. + See `#359 `_. + * Fix a bug where condensing ``NO_ACTION`` steps for sMDP discounting dropped the final observation and ``d_ts``, and could give the last super-action an empty reward. See issue #248. diff --git a/examples/_default.rst b/examples/_default.rst index 95e05927..58608718 100644 --- a/examples/_default.rst +++ b/examples/_default.rst @@ -22,12 +22,13 @@ Earth Observation cloud_environment_with_reimaging aeos -RSO Inspection -~~~~~~~~~~~~~~ +RSO Missions +~~~~~~~~~~~~ .. toctree:: :maxdepth: 1 rso_inspection + space_to_space_rso_imaging Training -------- @@ -66,4 +67,4 @@ training settings. The following environments are currently available: * ``aeos:aeos_single``: A single-satellite agile Earth observation environment. * ``aeos:aeos_constellation``: A multi-satellite agile Earth observation environment. * ``rso_inspection:rso_inspection``: An RSO inspection environment with imaging constraints - and safety constraints. \ No newline at end of file + and safety constraints. diff --git a/examples/aeos.ipynb b/examples/aeos.ipynb index 56cc90aa..c08f06f7 100644 --- a/examples/aeos.ipynb +++ b/examples/aeos.ipynb @@ -352,7 +352,7 @@ "\n", "env = SatelliteTasking(\n", " satellite=power_sat_generator(n_ahead=32, include_time=False)(\n", - " name=\"EO1-power\",\n", + " name=\"EO1_power\",\n", " sat_args=SAT_ARGS_POWER,\n", " ),\n", " scenario=targets,\n", diff --git a/examples/async_multiagent_training.ipynb b/examples/async_multiagent_training.ipynb index 1ebb4aed..15d7256a 100644 --- a/examples/async_multiagent_training.ipynb +++ b/examples/async_multiagent_training.ipynb @@ -99,7 +99,7 @@ " fsw_type = fsw.ContinuousImagingFSWModel\n", "\n", "sats = [ScanningSatellite(\n", - " f\"Scanner-{i+1}\",\n", + " f\"Scanner_{i+1}\",\n", " sat_args=dict(\n", " # Data\n", " dataStorageCapacity=5000 * 8e6, # bits\n", diff --git a/examples/cloud_environment_with_reimaging.ipynb b/examples/cloud_environment_with_reimaging.ipynb index 7534ce8e..ac47621e 100644 --- a/examples/cloud_environment_with_reimaging.ipynb +++ b/examples/cloud_environment_with_reimaging.ipynb @@ -1212,7 +1212,7 @@ ")\n", "\n", "satellites = [\n", - " C_F_Bayesian(f\"EO-{i}\", sat_args, belief_update_func=belief_update_func)\n", + " C_F_Bayesian(f\"EO_{i}\", sat_args, belief_update_func=belief_update_func)\n", " for i in range(5)\n", "]\n", "\n", diff --git a/examples/communication_action.ipynb b/examples/communication_action.ipynb index 1e4f75fa..7ffb1f68 100644 --- a/examples/communication_action.ipynb +++ b/examples/communication_action.ipynb @@ -43,7 +43,10 @@ " n_ahead_observe=4,\n", " )\n", " ]\n", - " action_spec: ClassVar[list[act.Action]] = [act.Broadcast(duration=15.0), act.Image(n_ahead_image=4)]\n", + " action_spec: ClassVar[list[act.Action]] = [\n", + " act.Broadcast(duration=15.0),\n", + " act.Image(n_ahead_image=4),\n", + " ]\n", " dyn_type = dyn.FullFeaturedDynModel\n", " fsw_type = fsw.SteeringImagerFSWModel\n", "\n", @@ -122,7 +125,7 @@ "from bsk_rl import ConstellationTasking\n", "\n", "env = ConstellationTasking(\n", - " satellites=[ImagingSatellite(f\"EO-{i + 1}\", sat_args) for i in range(N_AGENTS)],\n", + " satellites=[ImagingSatellite(f\"EO_{i + 1}\", sat_args) for i in range(N_AGENTS)],\n", " scenario=scene.UniformTargets(1000),\n", " rewarder=data.UniqueImageReward(),\n", " communicator=BroadcastLOS(),\n", @@ -145,7 +148,7 @@ "metadata": {}, "outputs": [], "source": [ - "_ = env.step({\"EO-1\": ACTION_IMAGE_3, \"EO-2\": ACTION_IMAGE_2})" + "_ = env.step({\"EO_1\": ACTION_IMAGE_3, \"EO_2\": ACTION_IMAGE_2})" ] }, { @@ -161,7 +164,7 @@ "metadata": {}, "outputs": [], "source": [ - "_ = env.step({\"EO-1\": ACTION_BROADCAST, \"EO-2\": ACTION_BROADCAST})" + "_ = env.step({\"EO_1\": ACTION_BROADCAST, \"EO_2\": ACTION_BROADCAST})" ] }, { @@ -177,7 +180,7 @@ "metadata": {}, "outputs": [], "source": [ - "_ = env.step({\"EO-1\": ACTION_IMAGE_2, \"EO-2\": ACTION_BROADCAST}) # Agent 1 broadcasts" + "_ = env.step({\"EO_1\": ACTION_IMAGE_2, \"EO_2\": ACTION_BROADCAST}) # Agent 1 broadcasts" ] } ], diff --git a/examples/curriculum_learning.ipynb b/examples/curriculum_learning.ipynb index 44e7d69f..40cad0e5 100644 --- a/examples/curriculum_learning.ipynb +++ b/examples/curriculum_learning.ipynb @@ -578,7 +578,7 @@ "environment_mode = \"standard_90\"\n", "\n", "sat = ScanningSatellite(\n", - " \"Scanner-1\",\n", + " \"Scanner_1\",\n", " sat_args=dict(\n", " **sat_config[\"sat_params\"],\n", " batteryStorageCapacity=sat_config[environment_mode][\"batteryStorageCapacity\"],\n", diff --git a/examples/multiagent_envs.ipynb b/examples/multiagent_envs.ipynb index c4b2a6d5..592a1ee8 100644 --- a/examples/multiagent_envs.ipynb +++ b/examples/multiagent_envs.ipynb @@ -39,7 +39,7 @@ "class ImagingSatellite(sats.ImagingSatellite):\n", " observation_spec: ClassVar[list[obs.Observation]] = [\n", " obs.OpportunityProperties(\n", - " dict(prop=\"priority\"), \n", + " dict(prop=\"priority\"),\n", " dict(prop=\"opportunity_open\", norm=5700.0),\n", " n_ahead_observe=10,\n", " )\n", @@ -62,7 +62,6 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "from bsk_rl.utils.orbital import walker_delta_args\n", "\n", "sat_args = dict(\n", @@ -101,9 +100,9 @@ "\n", "env = GeneralSatelliteTasking(\n", " satellites=[\n", - " ImagingSatellite(\"EO-1\", sat_args),\n", - " ImagingSatellite(\"EO-2\", sat_args),\n", - " ImagingSatellite(\"EO-3\", sat_args),\n", + " ImagingSatellite(\"EO_1\", sat_args),\n", + " ImagingSatellite(\"EO_2\", sat_args),\n", + " ImagingSatellite(\"EO_3\", sat_args),\n", " ],\n", " scenario=scene.UniformTargets(1000),\n", " rewarder=data.UniqueImageReward(),\n", @@ -183,7 +182,10 @@ "metadata": {}, "outputs": [], "source": [ - "actions = [0 if info[sat.name][\"requires_retasking\"] else None for sat in env.unwrapped.satellites]\n", + "actions = [\n", + " 0 if info[sat.name][\"requires_retasking\"] else None\n", + " for sat in env.unwrapped.satellites\n", + "]\n", "actions" ] }, @@ -223,8 +225,9 @@ " log_failure=log_failure\n", " )\n", "\n", + "\n", "env.unwrapped.satellites[0].is_alive = isnt_alive\n", - "observation, reward, terminated, truncated, info = env.step([6, 7, 9])\n" + "observation, reward, terminated, truncated, info = env.step([6, 7, 9])" ] }, { @@ -249,9 +252,9 @@ "\n", "env = ConstellationTasking(\n", " satellites=[\n", - " ImagingSatellite(\"EO-1\", sat_args),\n", - " ImagingSatellite(\"EO-2\", sat_args),\n", - " ImagingSatellite(\"EO-3\", sat_args),\n", + " ImagingSatellite(\"EO_1\", sat_args),\n", + " ImagingSatellite(\"EO_2\", sat_args),\n", + " ImagingSatellite(\"EO_3\", sat_args),\n", " ],\n", " scenario=scene.UniformTargets(1000),\n", " rewarder=data.UniqueImageReward(),\n", @@ -330,7 +333,8 @@ "metadata": {}, "outputs": [], "source": [ - "observation, reward, terminated, truncated, info = env.step({\n", + "observation, reward, terminated, truncated, info = env.step(\n", + " {\n", " env.agents[0]: 7,\n", " env.agents[1]: 9,\n", " }\n", diff --git a/examples/rllib_training.ipynb b/examples/rllib_training.ipynb index c9c5da53..eb8d5146 100644 --- a/examples/rllib_training.ipynb +++ b/examples/rllib_training.ipynb @@ -58,25 +58,30 @@ "from bsk_rl.sim import dyn, fsw\n", "\n", "\n", - "class ScanningDownlinkDynModel(dyn.ContinuousImagingDynModel, dyn.GroundStationDynModel):\n", + "class ScanningDownlinkDynModel(\n", + " dyn.ContinuousImagingDynModel, dyn.GroundStationDynModel\n", + "):\n", " # Define some custom properties to be accessed in the state\n", " @property\n", " def instrument_pointing_error(self) -> float:\n", - " r_BN_P_unit = self.r_BN_P/np.linalg.norm(self.r_BN_P) \n", + " r_BN_P_unit = self.r_BN_P / np.linalg.norm(self.r_BN_P)\n", " c_hat_P = self.satellite.fsw.c_hat_P\n", " return np.arccos(np.dot(-r_BN_P_unit, c_hat_P))\n", - " \n", + "\n", " @property\n", " def solar_pointing_error(self) -> float:\n", - " a = self.world.gravFactory.spiceObject.planetStateOutMsgs[\n", - " self.world.sun_index\n", - " ].read().PositionVector\n", + " a = (\n", + " self.world.gravFactory.spiceObject.planetStateOutMsgs[self.world.sun_index]\n", + " .read()\n", + " .PositionVector\n", + " )\n", " a_hat_N = a / np.linalg.norm(a)\n", " nHat_B = self.satellite.sat_args[\"nHat_B\"]\n", " NB = np.transpose(self.BN)\n", " nHat_N = NB @ nHat_B\n", " return np.arccos(np.dot(nHat_N, a_hat_N))\n", "\n", + "\n", "class ScanningSatellite(sats.AccessSatellite):\n", " observation_spec: ClassVar[list[obs.Observation]] = [\n", " obs.SatProperties(\n", @@ -84,7 +89,7 @@ " dict(prop=\"battery_charge_fraction\"),\n", " dict(prop=\"wheel_speeds_fraction\"),\n", " dict(prop=\"instrument_pointing_error\", norm=np.pi),\n", - " dict(prop=\"solar_pointing_error\", norm=np.pi)\n", + " dict(prop=\"solar_pointing_error\", norm=np.pi),\n", " ),\n", " obs.OpportunityProperties(\n", " dict(prop=\"opportunity_open\", norm=5700),\n", @@ -120,7 +125,7 @@ "outputs": [], "source": [ "sat = ScanningSatellite(\n", - " \"Scanner-1\",\n", + " \"Scanner_1\",\n", " sat_args=dict(\n", " # Data\n", " dataStorageCapacity=5000 * 8e6, # bits\n", @@ -142,7 +147,7 @@ " maxWheelSpeed=6000.0, # RPM\n", " wheelSpeeds=lambda: np.random.uniform(-3000, 3000, 3),\n", " desatAttitude=\"nadir\",\n", - " )\n", + " ),\n", ")" ] }, @@ -163,7 +168,7 @@ "duration = 5 * 5700.0 # About 5 orbits\n", "env_args = dict(\n", " satellite=sat,\n", - " scenario=scene.UniformNadirScanning(value_per_second=1/duration),\n", + " scenario=scene.UniformNadirScanning(value_per_second=1 / duration),\n", " rewarder=data.ScanningTimeReward(),\n", " time_limit=duration,\n", " failure_penalty=-1.0,\n", @@ -207,7 +212,7 @@ " data[\"reward_per_orbit\"] = reward / orbits\n", " if not env.satellite.is_alive():\n", " data[\"orbits_complete_partial_only\"] = orbits\n", - " \n", + "\n", " return data" ] }, @@ -251,7 +256,7 @@ "config = (\n", " PPOConfig()\n", " .training(**training_args)\n", - " .env_runners(num_env_runners=N_CPUS-1, sample_timeout_s=1000.0)\n", + " .env_runners(num_env_runners=N_CPUS - 1, sample_timeout_s=1000.0)\n", " .environment(\n", " env=\"SatelliteTasking-RLlib\",\n", " env_config=dict(**env_args, episode_data_callback=episode_data_callback),\n", @@ -304,7 +309,7 @@ " config=config.to_dict(),\n", " stop={\"training_iteration\": 10}, # Adjust the number of iterations as needed\n", " checkpoint_freq=10,\n", - " checkpoint_at_end=True\n", + " checkpoint_at_end=True,\n", ")\n", "\n", "# Shutdown Ray\n", diff --git a/examples/satellite_configuration.ipynb b/examples/satellite_configuration.ipynb index 44175749..75de6c2a 100644 --- a/examples/satellite_configuration.ipynb +++ b/examples/satellite_configuration.ipynb @@ -35,7 +35,9 @@ "\n", "\n", "class SimpleSatellite(sats.Satellite):\n", - " observation_spec: ClassVar[list[obs.Observation]] = [obs.Time()] # Passed as list of instantiated classes\n", + " observation_spec: ClassVar[list[obs.Observation]] = [\n", + " obs.Time()\n", + " ] # Passed as list of instantiated classes\n", " action_spec: ClassVar[list[act.Action]] = [act.Drift()]\n", " dyn_type = dyn.BasicDynamicsModel # Passed as a type\n", " fsw_type = fsw.BasicFSWModel" @@ -75,12 +77,12 @@ "outputs": [], "source": [ "sat = SimpleSatellite(\n", - " name=\"SimpleSat-1\",\n", + " name=\"SimpleSat_1\",\n", " sat_args=dict(\n", " mass=300, # Setting a constant value\n", " dragCoeff=lambda: np.random.uniform(2.0, 2.4), # Setting a randomized value\n", " ),\n", - ")\n" + ")" ] }, { @@ -157,7 +159,7 @@ " dict(prop=\"wheel_speeds\"),\n", " # You can specify the module to use for the observation, but it is not necessary\n", " # if only one module has for the property\n", - " dict(prop=\"battery_charge_fraction\", module=\"dynamics\"), \n", + " dict(prop=\"battery_charge_fraction\", module=\"dynamics\"),\n", " # Properties can be normalized by some constant. This is generally desirable\n", " # for RL algorithms to keep values around [-1, 1].\n", " dict(prop=\"r_BN_P\", norm=7e6),\n", @@ -167,8 +169,9 @@ " dyn_type = dyn.BasicDynamicsModel\n", " fsw_type = fsw.BasicFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=SatPropsSatellite(\"PropSat-1\", {}, obs_type=dict),\n", + " satellite=SatPropsSatellite(\"PropSat_1\", {}, obs_type=dict),\n", " log_level=\"CRITICAL\",\n", ")\n", "observation, _ = env.reset()\n", @@ -193,7 +196,8 @@ " @property\n", " def meaning_of_life(self):\n", " return 42\n", - " \n", + "\n", + "\n", "class BespokeSatPropsSatellite(sats.Satellite):\n", " observation_spec: ClassVar[list[obs.Observation]] = [\n", " obs.SatProperties(dict(prop=\"meaning_of_life\"))\n", @@ -202,8 +206,9 @@ " dyn_type = dyn.BasicDynamicsModel\n", " fsw_type = BespokeFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=BespokeSatPropsSatellite(\"BespokeSat-1\", {}, obs_type=dict),\n", + " satellite=BespokeSatPropsSatellite(\"BespokeSat_1\", {}, obs_type=dict),\n", " log_level=\"CRITICAL\",\n", ")\n", "observation, _ = env.reset()\n", @@ -231,8 +236,9 @@ " dyn_type = dyn.BasicDynamicsModel\n", " fsw_type = fsw.BasicFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=CustomSatPropsSatellite(\"BespokeSat-1\", {}, obs_type=dict),\n", + " satellite=CustomSatPropsSatellite(\"BespokeSat_1\", {}, obs_type=dict),\n", " log_level=\"CRITICAL\",\n", ")\n", "observation, _ = env.reset()\n", @@ -260,7 +266,7 @@ " observation_spec: ClassVar[list[obs.Observation]] = [\n", " obs.OpportunityProperties(\n", " # Properties can be added by some default names\n", - " dict(prop=\"priority\"), \n", + " dict(prop=\"priority\"),\n", " # They can also be normalized\n", " dict(prop=\"opportunity_open\", norm=5700.0),\n", " # Or they can be specified by an arbitrary function\n", @@ -272,8 +278,9 @@ " dyn_type = dyn.ImagingDynModel\n", " fsw_type = fsw.ImagingFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=OppPropsSatellite(\"OppSat-1\", {}, obs_type=dict),\n", + " satellite=OppPropsSatellite(\"OppSat_1\", {}, obs_type=dict),\n", " scenario=scene.UniformTargets(1000),\n", " rewarder=data.UniqueImageReward(),\n", " log_level=\"CRITICAL\",\n", @@ -303,14 +310,15 @@ "class ComposedObsSatellite(sats.Satellite):\n", " observation_spec: ClassVar[list[obs.Observation]] = [\n", " obs.Eclipse(),\n", - " obs.SatProperties(dict(prop=\"battery_charge_fraction\"))\n", + " obs.SatProperties(dict(prop=\"battery_charge_fraction\")),\n", " ]\n", " action_spec: ClassVar[list[act.Action]] = [act.Drift()]\n", " dyn_type = dyn.BasicDynamicsModel\n", " fsw_type = fsw.BasicFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=ComposedObsSatellite(\"PropSat-1\", {}, obs_type=dict),\n", + " satellite=ComposedObsSatellite(\"PropSat_1\", {}, obs_type=dict),\n", " log_level=\"CRITICAL\",\n", ")\n", "observation, _ = env.reset()\n", @@ -340,7 +348,7 @@ "outputs": [], "source": [ "env = SatelliteTasking(\n", - " satellite=ComposedObsSatellite(\"PropSat-1\", {}),\n", + " satellite=ComposedObsSatellite(\"PropSat_1\", {}),\n", " log_level=\"CRITICAL\",\n", ")\n", "(env.observation_space, env.unwrapped.satellite.observation_space)" @@ -407,21 +415,24 @@ " act.Charge(duration=120.0),\n", " act.Desat(duration=60.0),\n", " # One action can be included multiple time, if different settings are desired\n", - " act.Charge(duration=600.0,),\n", + " act.Charge(\n", + " duration=600.0,\n", + " ),\n", " ]\n", " dyn_type = dyn.BasicDynamicsModel\n", " fsw_type = fsw.BasicFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=ActionSatellite(\"ActSat-1\", {}, obs_type=dict),\n", + " satellite=ActionSatellite(\"ActSat_1\", {}, obs_type=dict),\n", " log_level=\"INFO\",\n", ")\n", "env.reset()\n", "\n", "# Try each action; index corresponds to the order of addition\n", - "_ =env.step(0)\n", - "_ =env.step(1)\n", - "_ =env.step(2)" + "_ = env.step(0)\n", + "_ = env.step(1)\n", + "_ = env.step(2)" ] }, { @@ -475,8 +486,9 @@ " dyn_type = dyn.ImagingDynModel\n", " fsw_type = fsw.ImagingFSWModel\n", "\n", + "\n", "env = SatelliteTasking(\n", - " satellite=ImageActSatellite(\"ActSat-2\", {}),\n", + " satellite=ImageActSatellite(\"ActSat_2\", {}),\n", " scenario=scene.UniformTargets(1000),\n", " rewarder=data.UniqueImageReward(),\n", " log_level=\"INFO\",\n", diff --git a/examples/space_to_space_rso_imaging.ipynb b/examples/space_to_space_rso_imaging.ipynb new file mode 100644 index 00000000..0ae40aeb --- /dev/null +++ b/examples/space_to_space_rso_imaging.ipynb @@ -0,0 +1,506 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Space-to-Space RSO Imaging\n", + "\n", + "This example constructs an imager and three independently orbiting resident space objects (RSOs), takes an image, downlinks it, and replays the initial conditions. Each target is a whole spacecraft with its own propagated state. For inspecting surface points on one nearby RSO, see the [RSO inspection example](rso_inspection.ipynb).\n", + "\n", + "The notebook uses a short deterministic demonstration by default. The optional policy-evaluation section can also save time histories and plots. A trained checkpoint and Vizard are not required.\n", + "\n", + "## Acquisition-controller support\n", + "\n", + "The acquisition logic currently lives in a temporary BSK-RL gate and uses the existing Basilisk message API. This example does not require the proposed `simpleInstrumentController` extension. After that extension is merged into Basilisk, BSK-RL will switch to the native controller in a follow-up change.\n", + "\n", + "The upstream work is tracked in [Basilisk #1602](https://github.com/AVSLab/basilisk/issues/1602), and the later BSK-RL replacement in [#358](https://github.com/AVSLab/bsk_rl/issues/358).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import tempfile\n", + "from pathlib import Path\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.sim import dyn, fsw\n", + "from bsk_rl.utils.orbital import random_circular_orbit\n", + "\n", + "EPOCH = \"2026 JUN 21 00:00:00.000 (UTC)\"\n", + "RUN_EVALUATION = False # Enable the optional policy/plot run at the end.\n", + "run_directory = Path(tempfile.mkdtemp(prefix=\"bsk_rso_example_\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define the target catalog\n", + "\n", + "The catalog fixes target states and their order. Positions are Earth-centered J2000 vectors in meters, and velocities are in meters per second. Its `utc_init` is optional: when omitted, these states refer to the environment's realized epoch on each reset. An isolated random generator makes catalog sampling independent of unrelated NumPy draws.\n", + "\n", + "Here we omit the catalog epoch and specify the demonstration's fixed epoch only in the environment. The file saved below is an unpinned template. After reset we save `env.scenario.catalog` separately, which includes the actual epoch for replay. A catalog with an explicit epoch must match the environment.\n", + "\n", + "The stable target `id` identifies the mission object. `satellite_name` binds it to one BSK-RL `Satellite.name`, rather than to Basilisk's internal spacecraft name. Neither field is an array index. Names such as `debris` identify one supplied spacecraft, not a group. Satellite names must be valid Python identifiers, such as `debris_1` rather than `debris-1`. Target IDs can still contain punctuation. Names and order in the environment do not assign spacecraft roles.\n", + "\n", + "The target inclinations and orbital phases vary, while the imager will use a fixed 45-degree inclination. The catalog also retains attitude, wheel speeds, disturbance torque, physical parameters, and optional priority events." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def make_catalog(seed=0, n_targets=3):\n", + " \"\"\"Realize target conditions with an isolated RNG, then save/replay the result.\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " targets = []\n", + " for index in range(n_targets):\n", + " elements = orbitalMotion.ClassicElements()\n", + " elements.a, elements.e = 7.2e6 + index * 1e5, 0.0\n", + " elements.i = np.radians(rng.uniform(0, 90))\n", + " elements.Omega, elements.omega = 0.0, 0.0\n", + " elements.f = rng.uniform(0, 0.1)\n", + " rN, vN = orbitalMotion.elem2rv(orbitalMotion.MU_EARTH * 1e9, elements)\n", + " targets.append(\n", + " scene.RSOTarget(\n", + " f\"rso-{83 + 17 * index}\",\n", + " f\"object_{index}\",\n", + " rN,\n", + " vN,\n", + " priority=float(index + 1),\n", + " )\n", + " )\n", + " return scene.RSOTargetCatalog(tuple(targets))\n", + "\n", + "\n", + "catalog = make_catalog(seed=0)\n", + "template_path = run_directory / \"catalog_template.json\"\n", + "catalog.save(template_path)\n", + "loaded_catalog = scene.RSOTargetCatalog.load(template_path)\n", + "assert loaded_catalog == catalog\n", + "[(target.id, target.satellite_name, target.priority) for target in catalog.targets]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define passive spacecraft targets\n", + "\n", + "`RSOTargetDynModel` propagates the target's gravity, drag, attitude, and resources. Its eclipse model supplies illumination to the imager. Catalog conditions are applied to the environment's copied spacecraft on each reset.\n", + "\n", + "The targets use the ordinary `Satellite` and `FSWModel` classes with a drift action. They are passive because they are not commanded to maneuver, and remain healthy spacecraft in the simulation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class PassiveRSO(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)] # [s]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define the imager\n", + "\n", + "`SpaceToSpaceImagingDynModel` extends `ImagingDynModel`, which provides the instrument, storage, and transmitter. Ground stations are optional. This example composes `GroundStationDynModel` with it because we want to demonstrate downlink. That component connects the spacecraft transmitter to the access messages of the ground stations in the world model. The `Downlink` action enables the transmitter; data flows only during a station contact. An imaging-only mission can use `SpaceToSpaceImagingDynModel` alone.\n", + "\n", + "The compact observation below contains storage usage and four properties per target. `valid=0` identifies padded rows when fewer targets are eligible. The default `RSOTargetProperties()` instead provides the full normalized geometry row, described below.\n", + "\n", + "The three resource actions occupy indices 0, 1, and 2. `ImageRSO(3)` adds indices 3 through 5. Its integer slots select from the same ordered candidate snapshot as the observation. Explicit stable-ID tasking is also available." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class Imager(sats.AccessSatellite):\n", + " dyn_type = (dyn.SpaceToSpaceImagingDynModel, dyn.GroundStationDynModel)\n", + " fsw_type = fsw.SpaceToSpaceImagingFSWModel\n", + " observation_spec: ClassVar[list[obs.Observation]] = [\n", + " obs.SatProperties(dict(prop=\"storage_level_fraction\")),\n", + " obs.RSOTargetProperties(\n", + " dict(prop=\"valid\"),\n", + " dict(prop=\"priority\"),\n", + " dict(prop=\"target_distance\"),\n", + " dict(prop=\"target_illumination_factor\"),\n", + " n_ahead_observe=3,\n", + " ),\n", + " ]\n", + " action_spec: ClassVar[list[act.Action]] = [\n", + " act.Charge(duration=60), # [s]\n", + " act.Downlink(duration=90), # [s]\n", + " act.Desat(duration=60), # [s]\n", + " act.ImageRSO(\n", + " 3,\n", + " max_duration=300, # [s]\n", + " min_pointing_hold_s=10, # [s]\n", + " hold_mode=\"cumulative\",\n", + " require_illumination_during_hold=False,\n", + " ),\n", + " ]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Acquisition constraints\n", + "\n", + "The temporary BSK-RL acquisition gate checks spacecraft access, attitude error, optional angular-rate error, and optional target illumination. A sampled interval counts toward the hold only when both endpoints are valid. `cumulative` preserves valid intervals across interruptions, while `continuous` resets the hold after an invalid sample. A zero hold still requires valid fresh guidance.\n", + "\n", + "The gate runs before the instrument and storage in the dynamics task. It emits one image command only after the hold completes and storage is ready. Switching target or FSW mode clears unfinished hold state and the old command. Acquisition must finish before the action's exclusive `max_duration` deadline.\n", + "\n", + "The gate also verifies that a full image actually reached storage before publishing an acquisition. Records include the stable target ID, imager, capture time, size in bits, valid hold time, and mean illumination over valid hold intervals. The gate sums illumination times the length of each valid interval, then divides by the valid hold time. This gives a time-weighted average even when update intervals differ; interruptions do not add time or illumination. The result is frozen when the image is commanded.\n", + "\n", + "Here illumination is recorded for reward quality but does not interrupt pointing hold. To require a lit target throughout each valid interval, enable `require_illumination_during_hold` and choose `hold_illumination_threshold`.\n", + "\n", + "Access includes Earth occlusion using a polar radius of 0.98 times the equatorial radius. Range is unlimited by default; set `imageTargetMaximumRange` to a positive distance in meters for a finite limit. This model does not calculate optical resolution, glare, or a detailed sensor image." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Imager initial conditions\n", + "\n", + "The fixed imager orbit makes this short example repeatable. The larger battery and zero initial wheel speeds give the demonstration enough time to acquire and downlink an image. `instrumentBaudRate` follows the existing imaging-model convention: bits per image, divided internally by the dynamics period for one instrument tick." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "scanner_args = dict(\n", + " oe=random_circular_orbit(\n", + " i=45.0, alt=600.0, Omega=0.0, f=0.0\n", + " ), # [deg, km, deg, deg]\n", + " sigma_init=[0, 0, 0], # [MRP]\n", + " omega_init=[0, 0, 0], # [rad/s]\n", + " wheelSpeeds=[0.0, 0.0, 0.0], # [rpm]\n", + " maxWheelSpeed=6000.0, # [rpm]\n", + " batteryStorageCapacity=1e9, # [J]\n", + " storedCharge_Init=1e9, # [J]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Choose acquisition and delivery rewards\n", + "\n", + "The rewarder accepts separate callbacks with signature `(record, target) -> float`. Each callback can use the current target priority and the image record, for example to scale by quality or discount delivery delay. The reusable class defaults to zero acquisition reward and full current-priority reward after complete delivery.\n", + "\n", + "This example rewards the mission objective: all priority credit comes after useful complete delivery, equivalent to a delivery weight `alpha=1.0`. For an earlier training signal, explicitly choose acquisition weight `1-alpha=0.9` and delivery weight `alpha=0.1`. The optional AMOS configuration retains that training split. A quality threshold of 0.5 means the average target illumination over valid hold intervals must be at least one half. A dark product can occupy storage and be transmitted without earning useful-service reward.\n", + "\n", + "Priorities are read at each reward boundary, so acquisition and delivery can use different priorities. Delivery credit is withheld until the entire owned product has been transmitted." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def make_rewarder():\n", + " \"\"\"Keep this mission's stage weights outside the reusable rewarder.\"\"\"\n", + " return data.RSOImageReward(\n", + " acquisition_reward_fn=lambda record, target: 0.0,\n", + " delivery_reward_fn=lambda record, target: target.priority,\n", + " quality_threshold=0.5,\n", + " cooldown_s=0, # [s]\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Construct the environment\n", + "\n", + "`RSOTargets` selects imagers by explicit environment name and binds target definitions to the copied spacecraft. Extra spacecraft are permitted and do not become targets automatically. The environment resolves its world epoch before binding targets. An unpinned catalog follows that epoch on every reset without changing the original immutable template. An explicitly supplied catalog epoch is checked against it.\n", + "\n", + "Target storage partitions and access messages use explicit maps from target IDs. Each imager registers its own access outputs, so a target's catalog position is never used as a Basilisk message index.\n", + "\n", + "A catalog fixes the target spacecraft. The imager's other initial conditions and the world must also be preserved to replay the entire mission." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def make_environment(target_catalog):\n", + " \"\"\"Create fresh spacecraft and episode state from a saved target catalog.\"\"\"\n", + " scanner = Imager(\"imager\", sat_args=scanner_args)\n", + " targets = [PassiveRSO(target.satellite_name) for target in target_catalog.targets]\n", + " return ConstellationTasking(\n", + " satellites=[*targets, scanner],\n", + " scenario=scene.RSOTargets(target_catalog, [scanner.name]),\n", + " rewarder=make_rewarder(),\n", + " world_args=dict(\n", + " utc_init=target_catalog.utc_init\n", + " if target_catalog.utc_init is not None\n", + " else EPOCH\n", + " ),\n", + " time_limit=1200, # [s]\n", + " max_step_duration=300, # [s]\n", + " log_level=\"ERROR\",\n", + " )\n", + "\n", + "\n", + "env = make_environment(loaded_catalog)\n", + "initial, _ = env.reset(seed=0)\n", + "catalog_path = run_directory / \"catalog.json\"\n", + "env.scenario.catalog.save(catalog_path)\n", + "assert env.scenario.catalog.utc_init == env.world_args[\"utc_init\"]\n", + "realized_world = env.world_args.copy()\n", + "realized_satellites = {sat.name: sat.sat_args.copy() for sat in env.satellites}\n", + "print(\"Initial imager observation shape:\", initial[\"imager\"].shape)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Acquire and deliver\n", + "\n", + "The small policy below downlinks whenever the imager owns a pending image. Otherwise it images the first valid candidate or charges if there is no eligible target. This demonstrates task selection and bookkeeping without a learned policy.\n", + "\n", + "Eligibility and physical access are different. A candidate can be eligible yet occulted. Empty slots raise an error and never select a different spacecraft. If a snapshot becomes stale, `ImageRSO` warns and attempts the originally observed target ID; an unavailable or newly ineligible target still raises an error.\n", + "\n", + "A variable-duration image action stops once a physically stored acquisition is recognized, or at its deadline. The fixed-duration downlink action uses the ground-station windows provided by the composed dynamics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "reward_total = 0.0\n", + "for step in range(30):\n", + " imager = next(sat for sat in env.satellites if sat.name == \"imager\")\n", + " store = imager.data_store\n", + " if store.products:\n", + " choice = 1 # Downlink owned products.\n", + " else:\n", + " rows = imager.observation_builder.obs_dict()[\"rso_targets\"]\n", + " choice = 3 if rows[\"rso_targets_0\"][\"valid\"] else 0\n", + " _, reward, terminated, truncated, _ = env.step(\n", + " {sat.name: choice if sat.name == \"imager\" else 0 for sat in env.satellites}\n", + " )\n", + " reward_total += reward.get(\"imager\", 0.0)\n", + " if store.data.deliveries or all(terminated.values()) or all(truncated.values()):\n", + " break\n", + "\n", + "print(\n", + " dict(\n", + " sim_time_s=env.simulator.sim_time,\n", + " captures=len(env.rewarder.data.captures),\n", + " deliveries=len(env.rewarder.data.deliveries),\n", + " reward=reward_total,\n", + " )\n", + ")\n", + "assert env.rewarder.data.captures\n", + "assert env.rewarder.data.deliveries" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Product ownership, partial downlink, and cooldown\n", + "\n", + "Each image belongs to the imager that physically stored it. The datastore keeps a FIFO ledger per target partition and tracks remaining bits. A step can transmit part of one image or finish several. Partial transmission earns no completed-delivery reward.\n", + "\n", + "The transmitter uses one-bit packets by default so a partially transmitted buffer can resume across contacts. The image ledger defines complete delivery. Delivery timestamps are recognition times at environment boundaries, rather than interpolated arrival times. Removed image bits are assumed received over the existing access-gated transmitter, without packet loss modeling.\n", + "\n", + "Pending products are centrally visible to all imagers after reward calculation. This shared knowledge is a mission modeling choice, independent of the communication component. A useful full delivery establishes cooldown from its capture time. A target becomes eligible again once no products remain pending and the cooldown deadline has passed. Unsuccessful-quality delivery releases pending state without useful-service cooldown.\n", + "\n", + "For multiple imagers, choose `multi_imager_credit=\"shared\"` or `\"per_imager\"` explicitly. Shared mode splits each stage value across simultaneous qualified records. Later duplicate captures or deliveries in the same pending service do not receive extra credit. Per-imager mode credits each owner once per pending service. Qualified duplicate deliveries can extend the global cooldown to the latest qualified capture time." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Replay the initial conditions\n", + "\n", + "Load the resolved target catalog saved after reset without invoking its original randomizer, and restore the realized imager and world arguments. The saved catalog contains the exact environment epoch even though the input template omitted it. A different reset seed now gives the same initial observation. A target catalog by itself does not freeze the scanner orbit, resources, or ground stations.\n", + "\n", + "Reset rebuilds bindings against the new episode's spacecraft and clears candidate snapshots, native holds, records, action events, image ledgers, partial transmission state, pending products, cooldowns, and reward uniqueness." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "replay = make_environment(scene.RSOTargetCatalog.load(catalog_path))\n", + "replay.world_args_generator = realized_world\n", + "for satellite in replay.satellites:\n", + " satellite.sat_args_generator = realized_satellites[satellite.name]\n", + "repeated, _ = replay.reset(seed=999)\n", + "np.testing.assert_array_equal(initial[\"imager\"], repeated[\"imager\"])\n", + "print(\"Initial imager observation reproduced exactly.\")\n", + "replay.close()\n", + "env.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Other observation and priority settings\n", + "\n", + "When no properties are supplied, `RSOTargetProperties` returns this normalized 11-value row:\n", + "\n", + "| Property | Normalization | Meaning |\n", + "| --- | --- | --- |\n", + "| `priority` | 2 | Current mission priority |\n", + "| `target_elevation_angle` | 90 degrees | Elevation relative to the observer radial direction |\n", + "| `rel_pos_vector_r_BR_H` | 15960 km | Three relative-position components in the observer Hill frame |\n", + "| `rel_vel_vector_v_BR_H` | 16000 m/s | Three inertial-velocity-difference components expressed in that frame |\n", + "| `angle_to_target` | 90 degrees | Angle from the instrument boresight |\n", + "| `target_distance` | 15960 km | Range |\n", + "| `target_illumination_factor` | 1 | Illuminated fraction, zero in darkness and one in full light |\n", + "\n", + "Relative velocity is not the derivative of position in the rotating Hill frame. Padding is a zero row. Include the `valid` property when a numeric mask is useful, as in this example.\n", + "\n", + "Observation and action counts are independent. For example, `RSOTargetProperties(n_ahead_observe=20)` can provide context while `ImageRSO(10)` selects from the first ten rows. `candidate_ids()` exposes ordered stable IDs, including `None` padding, without adding numeric features. A shared snapshot keeps action decoding consistent with observed slots. Eligible targets are ordered by ascending elevation in [-21, 90] degrees, then filled with the nearest remaining targets; catalog order breaks ties.\n", + "\n", + "Priority events run at the first decision boundary at or after their requested time, after the preceding step's reward and before the next observation. Events at zero apply before the initial observation. Equal-time events follow catalog order and leave pending products and cooldowns intact." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# This alternative catalog demonstrates priority events without changing the run above.\n", + "event = scene.RSOPriorityEvent(600, ((catalog.targets[0].id, 5.0),)) # [s]\n", + "event_catalog = scene.RSOTargetCatalog(catalog.targets, catalog.utc_init, (event,))\n", + "print(event_catalog.priority_events)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Policy evaluation and time-history plots\n", + "\n", + "The companion evaluator runs a bounded episode and records attempts, physically acquired images, completed deliveries, acquisition/delivery reward, storage, battery charge, wheel usage, and the action timeline. The `priority`, `nearest`, and `random` baselines manage resources and choose only physically accessible, illuminated candidates. They are example policies, separate from optimized research heuristics.\n", + "\n", + "Set `RUN_EVALUATION=True` in the first cell to run the short evaluation below. Matplotlib is required for plots. The default notebook demonstration above already covers acquisition, delivery, and replay.\n", + "\n", + "From the repository root, a longer run can be launched with:\n", + "\n", + "```bash\n", + "python examples/space_to_space_rso_imaging_evaluation.py \\\n", + " --policy priority --seed 0 --horizon 900 --output results/priority_seed0\n", + "```\n", + "\n", + "The output includes `mission.json`, `catalog.json`, `summary.json`, action/attempt/capture/delivery CSV files, performance and action plots in PNG/PDF, and a checkpoint hash when supplied. Use `--no-plots` for data only. Output directories must be empty so earlier evidence is preserved." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if RUN_EVALUATION:\n", + " from examples.space_to_space_rso_imaging_evaluation import evaluate\n", + "\n", + " evaluation_directory = run_directory / \"evaluation\"\n", + " result = evaluate(evaluation_directory, horizon=240, policy=\"priority\", seed=0)\n", + " print(result)\n", + " print(\"Evaluation files:\", evaluation_directory)\n", + "else:\n", + " print(\"Optional evaluation skipped. Set RUN_EVALUATION=True to run it.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Full mission replay and policy adapters\n", + "\n", + "The script's `mission_manifest` records the realized target catalog/hash, all spacecraft arguments, world and ground-station settings, sampling rates, observation keys and normalization, action settings, reward callback weights, cooldown, seed and NumPy RNG state, source locations/hashes, dependency versions, and Basilisk binary fingerprints. `replay_environment` reconstructs the example's reward recipe rather than attempting to serialize arbitrary Python callbacks. Custom mission callbacks need their own replay recipe.\n", + "\n", + "Replay a whole mission while comparing policies:\n", + "\n", + "```bash\n", + "python examples/space_to_space_rso_imaging_evaluation.py \\\n", + " --policy nearest --manifest results/priority_seed0/mission.json \\\n", + " --output results/nearest_replay\n", + "```\n", + "\n", + "A fixed catalog alone can be supplied with `--catalog path/to/catalog.json`. It leaves imager and world sampling to the chosen environment configuration.\n", + "\n", + "The RLlib adapter uses a stateless PyTorch RLModule and validates its observation/action spaces before inference. It loads the module for evaluation, disables gradients and dropout, and starts no training workers. Supply the exact module directory, rather than a guessed checkpoint parent. Use `--stochastic` for categorical action sampling.\n", + "\n", + "```bash\n", + "python examples/space_to_space_rso_imaging_evaluation.py \\\n", + " --policy rllib --checkpoint /path/to/rl_module \\\n", + " --manifest results/priority_seed0/mission.json --output results/learned\n", + "```\n", + "\n", + "For a custom policy, pass `--policy custom --policy-factory module:function` or `file.py:function`. The factory receives environment, RNG, checkpoint, and stochastic settings, and returns `policy(observation, context)`. Context contains stable candidate IDs, resources, physical access, illumination, and the action layout. Return an integer action index. Invalid or padded actions fail with the decision context saved for inspection. Recurrent policies require a custom adapter.\n", + "\n", + "The optional `--amos-profile` flag lives only in the example. It selects 14 global features and ten default target rows for a 124-input, 13-action layout. The global features are storage, battery, wheel fractions, Hill-frame Sun direction, eclipse times, and ground-station windows. Charge/downlink/desaturation durations are 300/300/150 seconds. Imaging has a 300-second deadline and a 10-second cumulative hold, with illumination gating disabled. The profile preserves its research observation and action layout, while corrected physical holds, safe padding, and complete-delivery accounting can change historical results. Matching dimensions alone does not establish checkpoint equivalence.\n", + "\n", + "A short baseline run validates the example flow. It does not establish mission-scale performance or a statistical policy comparison. Mixed ground/space imaging, realistic inter-satellite communication, optical quality beyond illumination, training campaigns, and publication results require separate modeling and validation." + ] + }, + { + "cell_type": "raw", + "metadata": { + "raw_mimetype": "text/restructuredtext" + }, + "source": [ + ".. toctree::\n", + " :hidden:\n", + "\n", + " space_to_space_rso_imaging_demo\n", + " space_to_space_rso_imaging_evaluation\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/space_to_space_rso_imaging_demo.py b/examples/space_to_space_rso_imaging_demo.py new file mode 100644 index 00000000..1a1db5b3 --- /dev/null +++ b/examples/space_to_space_rso_imaging_demo.py @@ -0,0 +1,435 @@ +"""A small public space-to-space RSO imaging example. + +Run ``python examples/space_to_space_rso_imaging_demo.py`` after installing BSK-RL. +The demo uses standard ground stations and saves a replayable catalog/manifest. +It requires neither a trained policy nor Vizard. The +:doc:`accompanying notebook ` walks through each +configuration step and explains acquisition, delivery, and replay. + +Ground-station support is explicitly composed with the spacecraft imaging +dynamics. The native Basilisk instrument controller checks pointing, access, +illumination, and hold timing before the instrument generates data. The Python +image recorder confirms that a complete product reached storage. Downlink reward +is assigned only when all its bits have left the imager's storage. + +``make_rewarder`` defines the example's 90% acquisition and 10% delivery weights +as callbacks. The reusable rewarder has no mission-specific alpha parameter. +The optional AMOS profile lives in this example and preserves the 124-input, +13-action schema used by its research policies. It does not reconstruct paper +conditions without a saved mission manifest. +""" + +import hashlib +import importlib.metadata +import json +import platform +import subprocess +from pathlib import Path +from typing import ClassVar + +import numpy as np +from Basilisk import __file__ as basilisk_path +from Basilisk.utilities import orbitalMotion + +from bsk_rl import ConstellationTasking, act, data, obs, sats, scene +from bsk_rl.sim import dyn, fsw +from bsk_rl.utils.orbital import random_circular_orbit, rv2HN + +EPOCH = "2026 JUN 21 00:00:00.000 (UTC)" + + +def sun_in_hill(satellite): + """Dimensionless Sun direction in the imager Hill frame, as in AMOS v9.""" + world = satellite.simulator.world + sun = np.asarray( + world.gravFactory.spiceObject.planetStateOutMsgs[world.sun_index] + .read() + .PositionVector + ) + relative = sun - satellite.dynamics.r_BN_N + hill = rv2HN(satellite.dynamics.r_BN_N, satellite.dynamics.v_BN_N) @ relative + return hill / np.linalg.norm(hill) + + +def make_catalog(seed=0, n_targets=3): + """Realize target conditions with an isolated RNG, then save/replay the result.""" + rng = np.random.default_rng(seed) + targets = [] + for index in range(n_targets): + elements = orbitalMotion.ClassicElements() + elements.a, elements.e = 7.2e6 + index * 1e5, 0.0 + elements.i = np.radians(rng.uniform(0, 90)) + elements.Omega, elements.omega = 0.0, 0.0 + elements.f = rng.uniform(0, 0.1) + rN, vN = orbitalMotion.elem2rv(orbitalMotion.MU_EARTH * 1e9, elements) + targets.append( + scene.RSOTarget( + f"rso-{83 + 17 * index}", + f"object_{index}", + rN, + vN, + priority=float(index + 1), + ) + ) + return scene.RSOTargetCatalog(tuple(targets)) + + +def amos_observation_spec(n_candidates=10): + """AMOS v9 full-action layout: 14 global + 11*K target features.""" + return [ + obs.SatProperties( + dict(prop="storage_level_fraction"), + dict(prop="battery_charge_fraction"), + dict(prop="wheel_speeds_fraction"), + dict(prop="s_hat_H", fn=sun_in_hill), + ), + obs.Eclipse(norm=5700), + obs.OpportunityProperties( + dict(prop="opportunity_open", norm=5700), + dict(prop="opportunity_close", norm=5700), + type="ground_station", + n_ahead_observe=2, + ), + obs.RSOTargetProperties(n_ahead_observe=n_candidates), + ] + + +def make_rewarder(acquisition_weight=0.0, delivery_weight=1.0, **kwargs): + """Define this example's reward recipe outside the generic image rewarder. + + Callbacks receive an image record and the current runtime target. The default + recipe assigns all priority credit to useful complete delivery. To provide + an earlier training signal, pass acquisition_weight=0.9, delivery_weight=0.1. + The explicit weights can be saved and reconstructed by the replay helper. + """ + rewarder = data.RSOImageReward( + acquisition_reward_fn=lambda record, target: acquisition_weight + * target.priority, + delivery_reward_fn=lambda record, target: delivery_weight * target.priority, + **kwargs, + ) + rewarder.example_weights = dict( + acquisition_weight=acquisition_weight, delivery_weight=delivery_weight + ) + return rewarder + + +def build_environment(catalog=None, *, seed=0, amos_profile=False, time_limit=1200): + """Construct one imager and explicitly bound passive spacecraft targets. + + The AMOS profile preserves observation dimensions, action indices, hold mode, + and alpha mapping. Correct acquisition/delivery semantics intentionally differ + from the historical bugs; this is not a bit-for-bit research reproduction. + + Catalogs can omit their epoch. This example fixes the world epoch once, and + the scene inherits its realized value. An explicit catalog epoch is also + used for the world. Save ``env.scenario.catalog`` after reset for replay. + """ + if catalog is None: + catalog = make_catalog(seed) + count = 10 if amos_profile else 3 + + class Imager(sats.AccessSatellite): + dyn_type = (dyn.SpaceToSpaceImagingDynModel, dyn.GroundStationDynModel) + fsw_type = fsw.SpaceToSpaceImagingFSWModel + observation_spec: ClassVar[list[obs.Observation]] = ( + amos_observation_spec(count) + if amos_profile + else [ + obs.SatProperties(dict(prop="storage_level_fraction")), + obs.RSOTargetProperties( + dict(prop="valid"), + dict(prop="priority"), + dict(prop="target_distance"), + dict(prop="target_illumination_factor"), + n_ahead_observe=count, + ), + ] + ) + action_spec: ClassVar[list[act.Action]] = [ + act.Charge(duration=300 if amos_profile else 60), + act.Downlink(duration=300 if amos_profile else 90), + act.Desat(duration=150 if amos_profile else 60), + act.ImageRSO( + count, + max_duration=300, + min_pointing_hold_s=10, + hold_mode="cumulative", + require_illumination_during_hold=False, + ), + ] + + class PassiveRSO(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)] + + scanner_args = dict( + oe=random_circular_orbit(i=45.0, alt=600.0, Omega=0.0, f=0.0), + sigma_init=[0, 0, 0], + omega_init=[0, 0, 0], + wheelSpeeds=[0.0, 0.0, 0.0], + maxWheelSpeed=6000.0, + batteryStorageCapacity=1e9, + storedCharge_Init=1e9, + ) + if amos_profile: + scanner_args.update( + imageAttErrorRequirement=0.0025, + imageRateErrorRequirement=0.01, + dataStorageCapacity=50 * 4e6, + instrumentBaudRate=4e6, + transmitterBaudRate=-4e6, + batteryStorageCapacity=500 * 3600, + storedCharge_Init=500 * 3600, + basePowerDraw=-10.0, + instrumentPowerDraw=-30.0, + transmitterPowerDraw=-25.0, + thrusterPowerDraw=-80.0, + panelArea=1.0, + maxWheelSpeed=6000.0, + desatAttitude="sun", + ) + scanner = Imager( + "imager", + sat_args=scanner_args, + ) + targets = [PassiveRSO(target.satellite_name) for target in catalog.targets] + return ConstellationTasking( + satellites=[*targets, scanner], + scenario=scene.RSOTargets(catalog, [scanner.name]), + rewarder=make_rewarder( + acquisition_weight=0.9 if amos_profile else 0.0, + delivery_weight=0.1 if amos_profile else 1.0, + cooldown_s=0, + ), + world_args=dict( + utc_init=catalog.utc_init if catalog.utc_init is not None else EPOCH + ), + time_limit=time_limit, + max_step_duration=300, + log_level="ERROR", + ) + + +def mission_manifest(env, *, checkpoint=None): + """Record realized mission settings; keep checkpoint details explicit if supplied.""" + + def serializable(value): + if isinstance(value, (list, tuple, np.ndarray)): + return [serializable(item) for item in value] + if isinstance(value, dict): + return {key: serializable(item) for key, item in value.items()} + if isinstance(value, np.generic): + return value.item() + if isinstance(value, orbitalMotion.ClassicElements): + return { + "orbital_elements": { + key: float(getattr(value, key)) + for key in ("a", "e", "i", "Omega", "omega", "f") + } + } + if isinstance(value, float) and not np.isfinite(value): + return {"special_float": str(value)} + if value is None or isinstance(value, (str, float, int, bool)): + return value + raise TypeError(f"Cannot serialize mission value {type(value)}") + + imager = next( + sat for sat in env.satellites if sat.name in env.scenario.imager_names + ) + action_settings = [] + for action in imager.action_builder.action_spec: + fields = { + key: value + for key, value in vars(action).items() + if key not in ("satellite", "simulator", "event_name", "chosen_target_ids") + } + action_settings.append( + dict(type=type(action).__name__, settings=serializable(fields)) + ) + catalog = env.scenario.catalog.to_dict() + import bsk_rl + + source = Path(bsk_rl.__file__).resolve() + try: + # An installed package inside another repository's .venv must not inherit + # that repository's HEAD as its own source revision. + subprocess.check_output( + [ + "git", + "-C", + str(source.parent), + "ls-files", + "--error-unmatch", + str(source), + ], + stderr=subprocess.DEVNULL, + ) + commit = subprocess.check_output( + ["git", "-C", str(source.parent), "rev-parse", "HEAD"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except (OSError, subprocess.CalledProcessError): + commit = None + return dict( + schema_version=1, + catalog=catalog, + catalog_sha256=hashlib.sha256( + json.dumps(catalog, sort_keys=True).encode() + ).hexdigest(), + seed=env.seed, + amos_profile=imager.observation_space.shape == (124,), + numpy_random_state=serializable(np.random.get_state()), + bsk_rl_commit=commit, + bsk_rl_import_path=str(source), + bsk_rl_source_tree_sha256=hashlib.sha256( + "".join( + hashlib.sha256(path.read_bytes()).hexdigest() + + " " + + path.relative_to(source.parent).as_posix() + + "\n" + for path in sorted(source.parent.rglob("*.py")) + ).encode() + ).hexdigest(), + python=platform.python_version(), + versions={ + name: importlib.metadata.version(name) + for name in ("bsk_rl", "bsk", "numpy", "gymnasium") + }, + basilisk_import_path=basilisk_path, + basilisk_binary_hashes={ + str(path.relative_to(Path(basilisk_path).parent)): hashlib.sha256( + path.read_bytes() + ).hexdigest() + for path in sorted(Path(basilisk_path).parent.rglob("*.so")) + }, + world_args=serializable(env.world_args), + satellite_args={sat.name: serializable(sat.sat_args) for sat in env.satellites}, + rates=dict( + sim_s=env.sim_rate, + dynamics_s=imager.dynamics.dyn_rate, + fsw_s=imager.fsw.fsw_rate, + ), + time_limit_s=env.time_limit, + max_step_duration_s=env.max_step_duration, + observation_keys=imager.observation_builder.obs_array_keys(), + observation_dtype=str(imager.observation_builder.dtype), + rso_properties=[ + spec.properties + for spec in imager.observation_builder.observation_spec + if isinstance(spec, obs.RSOTargetProperties) + ], + actions=action_settings, + reward=dict( + **env.rewarder.example_weights, + quality_threshold=env.rewarder.quality_threshold, + cooldown_s=env.rewarder.cooldown_s, + multi_imager_credit=env.rewarder.multi_imager_credit, + ), + checkpoint=checkpoint, + ) + + +def replay_environment(manifest): + """Freeze scanner and world separately from the replayed target catalog. + + This helper supports this example's two profiles. For a different environment, + replay the catalog and apply the manifest to that environment's own constructor. + """ + + def restore(value): + if isinstance(value, list): + return [restore(item) for item in value] + if isinstance(value, dict): + if "orbital_elements" in value: + elements = orbitalMotion.ClassicElements() + for key, item in value["orbital_elements"].items(): + setattr(elements, key, item) + return elements + if "special_float" in value: + return float(value["special_float"]) + return {key: restore(item) for key, item in value.items()} + return value + + if manifest.get("schema_version") != 1: + raise ValueError("Unsupported mission manifest version.") + digest = hashlib.sha256( + json.dumps(manifest["catalog"], sort_keys=True).encode() + ).hexdigest() + if digest != manifest["catalog_sha256"]: + raise ValueError("Mission catalog hash does not match manifest.") + env = build_environment( + scene.RSOTargetCatalog.from_dict(manifest["catalog"]), + amos_profile=manifest["amos_profile"], + time_limit=manifest["time_limit_s"], + ) + env.world_args_generator = restore(manifest["world_args"]) + for satellite in env.satellites: + satellite.sat_args_generator = restore( + manifest["satellite_args"][satellite.name] + ) + env.sim_rate = manifest["rates"]["sim_s"] + env.max_step_duration = manifest["max_step_duration_s"] + for satellite in env.satellites: + if satellite.name in env.scenario.imager_names: + for action, saved in zip( + satellite.action_builder.action_spec, manifest["actions"] + ): + if type(action).__name__ != saved["type"]: + raise ValueError("Manifest action layout does not match example.") + for key, value in saved["settings"].items(): + setattr(action, key, restore(value)) + reward_settings = dict(manifest["reward"]) + # Replay manifests written before stage weights moved into example callbacks. + if "alpha" in reward_settings: + alpha = reward_settings.pop("alpha") + reward_settings.update(acquisition_weight=1 - alpha, delivery_weight=alpha) + env.rewarder = make_rewarder(**reward_settings) + env.rewarder.link_scenario(env.scenario) + return env + + +def run_demo(output_directory=None): + """Run a bounded acquisition/downlink demo using standard ground access.""" + catalog = scene.RSOTargetCatalog.from_dict(make_catalog().to_dict()) + env = build_environment(catalog) + env.reset(seed=0) + manifest = mission_manifest(env) + if output_directory is not None: + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=True) + env.scenario.catalog.save(output_directory / "catalog.json") + (output_directory / "mission.json").write_text( + json.dumps(manifest, indent=2) + "\n" + ) + reward_total = 0.0 + for step in range(30): + imager = next(sat for sat in env.satellites if sat.name == "imager") + store = imager.data_store + # Downlink stored images; otherwise image a valid observation slot. + if store.products: + choice = 1 + else: + row = imager.observation_builder.obs_dict()["rso_targets"]["rso_targets_0"] + choice = 3 if row["valid"] else 0 + _, reward, terminated, truncated, _ = env.step( + {sat.name: choice if sat.name == "imager" else 0 for sat in env.satellites} + ) + reward_total += reward.get("imager", 0.0) + if store.data.deliveries or all(terminated.values()) or all(truncated.values()): + break + result = dict( + sim_time_s=env.simulator.sim_time, + captures=len(env.rewarder.data.captures), + deliveries=len(env.rewarder.data.deliveries), + reward=reward_total, + ) + env.close() + return result + + +if __name__ == "__main__": + print(json.dumps(run_demo("rso_imaging_demo"), indent=2)) diff --git a/examples/space_to_space_rso_imaging_evaluation.py b/examples/space_to_space_rso_imaging_evaluation.py new file mode 100644 index 00000000..fdb00986 --- /dev/null +++ b/examples/space_to_space_rso_imaging_evaluation.py @@ -0,0 +1,706 @@ +"""Evaluate an RSO imaging policy and save a replayable mission and plots. + +Run ``python examples/space_to_space_rso_imaging_evaluation.py --help``. +The default priority baseline needs no checkpoint or learning framework. +Matplotlib is optional with ``--no-plots``. The +:doc:`space-to-space imaging notebook ` explains +the environment, reward callbacks, replay, and policy adapters. + +The priority, nearest-target, and random baselines first manage battery charge, +wheel speed, and stored images. They then choose an accessible, illuminated +candidate. A saved catalog fixes target states, while a mission manifest also +fixes the imager, world, and action/reward settings. Output CSV files distinguish +attempts, physically stored acquisitions, and fully completed deliveries. + +The RLlib adapter loads one PyTorch RLModule for inference. Custom policies use +``module:function`` factories returning ``policy(observation, context)``. +The context includes stable candidate IDs, physical access, resources, and action +indices. Empty slots remain empty. See the notebook for runnable commands. +""" + +import argparse +import csv +import hashlib +import importlib +import importlib.metadata +import importlib.util +import json +import sys +from dataclasses import asdict +from pathlib import Path + +import numpy as np + +from bsk_rl import act, obs, scene + +if __package__: + from .space_to_space_rso_imaging_demo import ( + build_environment, + make_catalog, + mission_manifest, + replay_environment, + ) +else: + from space_to_space_rso_imaging_demo import ( + build_environment, + make_catalog, + mission_manifest, + replay_environment, + ) + + +def checkpoint_fingerprint(path): + """Hash an exact checkpoint file or directory without loading its contents.""" + root = Path(path).expanduser().resolve() + if not root.exists(): + raise FileNotFoundError(root) + files = ( + [root] if root.is_file() else sorted(p for p in root.rglob("*") if p.is_file()) + ) + if not files: + raise ValueError("Checkpoint contains no files.") + rows = [ + hashlib.sha256(file.read_bytes()).hexdigest() + + " " + + (file.name if root.is_file() else file.relative_to(root).as_posix()) + + "\n" + for file in files + ] + return dict( + path=str(root), + tree_sha256=hashlib.sha256("".join(rows).encode()).hexdigest(), + hash_algorithm="SHA256 of sorted file-SHA256, two spaces, relative path, newline", + ) + + +def decision_context(env, imager): + """Expose IDs and physical diagnostics without changing observation columns.""" + target_observation = next( + spec + for spec in imager.observation_builder.observation_spec + if isinstance(spec, obs.RSOTargetProperties) + ) + candidates = [] + rows = list(target_observation.get_obs().values()) + for slot, target_id in enumerate(target_observation.candidate_ids()): + if target_id is None: + candidates.append(None) + continue + target = env.scenario.targets_by_id[target_id] + dynamics = target.target_spacecraft.dynamics + candidates.append( + dict( + target_id=target_id, + priority=target.priority, + distance_m=float( + np.linalg.norm( + np.asarray(dynamics.r_BN_N) - np.asarray(imager.dynamics.r_BN_N) + ) + ), + illumination=float(rows[slot]["target_illumination_factor"]), + has_access=bool( + imager.dynamics.rso_access_messages[target_id].read().hasAccess + ), + ) + ) + actions = [] + for action in imager.action_builder.action_spec: + for slot in range(action.n_actions): + actions.append( + dict( + type=type(action).__name__, + name=action.name, + candidate_slot=slot if isinstance(action, act.ImageRSO) else None, + ) + ) + return dict( + time_s=float(env.simulator.sim_time), + imager_name=imager.name, + candidates=candidates, + actions=actions, + storage_bits=float(imager.dynamics.storage_level), + battery_fraction=float(imager.dynamics.battery_charge_fraction), + wheel_fraction=float(np.max(np.abs(imager.dynamics.wheel_speeds_fraction))), + quality_threshold=env.rewarder.quality_threshold, + ) + + +def baseline_policy(kind, rng): + """Use resource guards, downlink stored products, then select an image slot.""" + + def policy(observation, context): + modes = { + action["type"]: index for index, action in enumerate(context["actions"]) + } + if context["battery_fraction"] < 0.3: + return modes["Charge"] + if context["wheel_fraction"] > 0.8: + return modes["Desat"] + if context["storage_bits"] > 0: + return modes["Downlink"] + eligible = [ + index + for index, action in enumerate(context["actions"]) + if action["candidate_slot"] is not None + and (candidate := context["candidates"][action["candidate_slot"]]) + is not None + and candidate["has_access"] + and candidate["illumination"] >= context["quality_threshold"] + ] + if not eligible: + return modes["Charge"] + if kind == "random": + return int(rng.choice(eligible)) + + def ranking(index): + candidate = context["candidates"][ + context["actions"][index]["candidate_slot"] + ] + return ( + -candidate["priority"] + if kind == "priority" + else candidate["distance_m"], + index, + ) + + return min(eligible, key=ranking) + + return policy + + +def load_policy( + kind, env, imager, rng, checkpoint=None, factory=None, stochastic=False +): + """Load a stateless discrete policy, without creating workers or an algorithm.""" + if kind in ("priority", "nearest", "random"): + if checkpoint or factory: + raise ValueError("Checkpoint/factory options require rllib/custom policy.") + return baseline_policy(kind, rng) + if kind == "custom": + if not factory or ":" not in factory: + raise ValueError( + "Custom policies require --policy-factory module:function or file.py:function." + ) + module_name, function_name = factory.rsplit(":", 1) + if module_name.endswith(".py"): + spec = importlib.util.spec_from_file_location( + "rso_user_policy", Path(module_name).resolve() + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + else: + module = importlib.import_module(module_name) + policy = getattr(module, function_name)( + env=env, + rng=rng, + checkpoint=checkpoint, + stochastic=stochastic, + ) + if not callable(policy): + raise TypeError( + "Policy factory must return a callable policy(observation, context)." + ) + + def wrapped(observation, context): + return policy(observation, context) + + source = Path(module.__file__).resolve() + wrapped.provenance = dict( + adapter_source=str(source), + adapter_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), + ) + if hasattr(policy, "close"): + wrapped.close = policy.close + return wrapped + if kind != "rllib" or checkpoint is None: + raise ValueError( + "RLlib policies require an exact RLModule directory via --checkpoint." + ) + import torch + from ray.rllib.core.columns import Columns + from ray.rllib.core.rl_module.rl_module import RLModule + + module = RLModule.from_checkpoint(Path(checkpoint).expanduser()) + if not isinstance(module, torch.nn.Module): + raise TypeError( + "This adapter supports PyTorch RLModules only; use a custom adapter otherwise." + ) + if module.is_stateful(): + raise ValueError("Recurrent modules require a custom policy adapter.") + # Ray 2.35 stores spaces on config; newer modules expose them directly. + spaces = module if hasattr(module, "observation_space") else module.config + if ( + spaces.observation_space.shape != imager.observation_space.shape + or spaces.action_space.n != imager.action_space.n + ): + raise ValueError( + "Checkpoint observation/action spaces do not match the environment profile." + ) + module.eval() # Disable training-time dropout as well as gradient recording. + device = next(module.parameters()).device + + def policy(observation, context): + with torch.inference_mode(): + vector = torch.as_tensor( + np.asarray(observation, dtype=np.float32), device=device + ).unsqueeze(0) + output = module.forward_inference({Columns.OBS: vector}) + logits = output[Columns.ACTION_DIST_INPUTS].detach().cpu().numpy() + if logits.shape != (1, imager.action_space.n) or not np.isfinite(logits).all(): + raise ValueError( + "Expected finite discrete logits with shape (1, action_count)." + ) + logits = logits[0] + if not stochastic: + return int(np.argmax(logits)) + probabilities = np.exp(logits - logits.max()) + return int(rng.choice(len(logits), p=probabilities / probabilities.sum())) + + policy.provenance = { + name: importlib.metadata.version(name) for name in ("ray", "torch") + } + return policy + + +def validate_action(action, context): + """Reject malformed, out-of-range and padded actions without substitution.""" + if isinstance(action, (bool, np.bool_)) or not isinstance( + action, (int, np.integer) + ): + raise TypeError("Policy must return an integer discrete action.") + if not 0 <= action < len(context["actions"]): + raise ValueError("Policy action is outside the environment action space.") + slot = context["actions"][action]["candidate_slot"] + if slot is not None and context["candidates"][slot] is None: + raise ValueError( + "Policy selected a padded image slot; no real target was substituted." + ) + return int(action) + + +def write_csv(path, rows, fields): + """Keep empty event tables readable with the same explicit schema.""" + with Path(path).open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def plot_results(directory, states, decisions): + """Plot physical products, credited rewards, resources, and action intervals.""" + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + times = np.array([row["time_s"] for row in states]) / 60 + fig, axes = plt.subplots(3, 1, sharex=True, figsize=(9, 8), constrained_layout=True) + attempt_times = [ + row["start_s"] / 60 for row in decisions if row["target_id"] is not None + ] + axes[0].step( + [times[0], *attempt_times, times[-1]], + [0, *range(1, len(attempt_times) + 1), len(attempt_times)], + where="post", + label="Imaging attempts", + ) + for key, label in ( + ("captures", "Acquired images"), + ("deliveries", "Completed deliveries"), + ): + axes[0].step(times, [row[key] for row in states], where="post", label=label) + axes[0].set_ylabel("Cumulative count") + for key, label in ( + ("acquisition_reward", "Acquisition"), + ("delivery_reward", "Delivery"), + ("total_reward", "Total (including penalties)"), + ): + axes[1].step(times, [row[key] for row in states], where="post", label=label) + axes[1].set_ylabel("Cumulative reward") + axes[2].step( + times, + [row["storage_bits"] / 1e6 for row in states], + where="post", + label="Onboard images", + ) + axes[2].set_ylabel("Storage [Mbit]") + resource_axis = axes[2].twinx() + resource_axis.plot( + times, + [row["battery_fraction"] for row in states], + color="tab:orange", + label="Battery", + ) + resource_axis.plot( + times, + [row["wheel_fraction"] for row in states], + color="tab:green", + label="Max wheel fraction", + ) + resource_axis.set_ylabel("Resource fraction") + resource_axis.legend(loc="upper right") + for axis in axes: + axis.grid(alpha=0.25) + axis.legend(loc="upper left") + axes[-1].set_xlabel("Simulation time [min]") + for suffix in ("png", "pdf"): + fig.savefig(directory / f"performance.{suffix}", dpi=160) + plt.close(fig) + modes = list(dict.fromkeys(row["action_type"] for row in decisions)) + fig, axis = plt.subplots(figsize=(9, 3), constrained_layout=True) + for row in decisions: + axis.broken_barh( + [(row["start_s"] / 60, (row["end_s"] - row["start_s"]) / 60)], + (modes.index(row["action_type"]) - 0.35, 0.7), + ) + if row["target_id"] is not None: + axis.text( + row["start_s"] / 60, + modes.index(row["action_type"]), + row["target_id"], + fontsize=8, + va="center", + ) + axis.set_yticks(range(len(modes)), modes) + axis.set_xlabel("Simulation time [min]") + axis.set_title("Commanded actions and selected target IDs") + axis.grid(axis="x", alpha=0.25) + for suffix in ("png", "pdf"): + fig.savefig(directory / f"actions.{suffix}", dpi=160) + plt.close(fig) + + +def evaluate( + output, + *, + policy="priority", + seed=0, + horizon=None, + max_steps=200, + catalog_path=None, + manifest_path=None, + profile=None, + checkpoint=None, + factory=None, + stochastic=False, + plots=True, +): + """Run one bounded episode; never overwrite an existing output directory. + + Times/resources are sampled at decision boundaries. Capture timestamps come + from the physical gate; delivery timestamps are boundary recognition times. + Catalog replay alone fixes targets; mission replay fixes scanner/world too. + """ + if horizon is not None and (not np.isfinite(horizon) or horizon <= 0): + raise ValueError("Horizon must be finite and positive.") + if not isinstance(max_steps, int) or max_steps < 1: + raise ValueError("max_steps must be a positive integer.") + if manifest_path and (catalog_path or profile): + raise ValueError("A mission manifest supplies its own catalog and profile.") + if profile not in (None, "compact", "amos"): + raise ValueError("Unknown environment profile.") + if stochastic and policy not in ("rllib", "custom", "random"): + raise ValueError("Stochastic mode requires a stochastic policy adapter.") + if plots: + import matplotlib # noqa: F401 -- fail before simulation if extra is absent + output = Path(output).expanduser().resolve() + output.mkdir(parents=True, exist_ok=False) + env = None + selected_policy = None + states, decisions, observations, capture_rows, delivery_rows = [], [], [], [], [] + summary = dict(status="error", policy=policy, seed=seed) + metadata = dict( + policy=policy, + inference_mode="stochastic" + if stochastic or policy == "random" + else "deterministic", + factory=factory, + evaluator_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + environment_source_sha256=hashlib.sha256( + Path(sys.modules[build_environment.__module__].__file__).read_bytes() + ).hexdigest(), + ) + try: + if checkpoint: + metadata["checkpoint"] = checkpoint_fingerprint(checkpoint) + if manifest_path: + saved = json.loads(Path(manifest_path).read_text()) + env = replay_environment(saved) + limit = horizon if horizon is not None else saved["time_limit_s"] + env.time_limit_generator = lambda: limit + else: + catalog = ( + scene.RSOTargetCatalog.load(catalog_path) + if catalog_path + else make_catalog(seed) + ) + env = build_environment( + catalog, + seed=seed, + amos_profile=profile == "amos", + time_limit=horizon if horizon is not None else 900, + ) + vectors, _ = env.reset(seed=seed) + if len(env.scenario.imagers) != 1: + raise ValueError("This evaluation example supports one imager per episode.") + imager = env.scenario.imagers[0] + manifest = mission_manifest(env, checkpoint=metadata) + manifest["policy_rng"] = dict( + seed=seed, generator="numpy.PCG64", independent_of_mission_rng=True + ) + env.scenario.catalog.save(output / "catalog.json") + # Keep provenance even if adapter loading fails. + (output / "policy.json").write_text(json.dumps(metadata, indent=2) + "\n") + (output / "mission.json").write_text( + json.dumps(manifest, indent=2, allow_nan=False) + "\n" + ) + np.save(output / "initial_observation.npy", vectors[imager.name]) + rng = np.random.default_rng(seed) + selected_policy = load_policy( + policy, env, imager, rng, checkpoint, factory, stochastic + ) + metadata.update(getattr(selected_policy, "provenance", {})) + (output / "mission.json").write_text( + json.dumps(manifest, indent=2, allow_nan=False) + "\n" + ) + totals = dict(acquisition_reward=0.0, delivery_reward=0.0, total_reward=0.0) + attempts = 0 + + def sample(): + context = decision_context(env, imager) + states.append( + dict( + time_s=context["time_s"], + storage_bits=context["storage_bits"], + battery_fraction=context["battery_fraction"], + wheel_fraction=context["wheel_fraction"], + attempts=attempts, + captures=len(imager.data_store.data.captures), + deliveries=len(imager.data_store.data.deliveries), + **totals, + ) + ) + return context + + context = sample() + for _ in range(max_steps): + vector = np.asarray(vectors[imager.name]) + try: + action = validate_action( + selected_policy(vector.copy(), context), context + ) + except Exception as error: + (output / "failed_decision.json").write_text( + json.dumps(dict(context=context, error=str(error)), indent=2) + "\n" + ) + raise + descriptor = context["actions"][action] + slot = descriptor["candidate_slot"] + target_id = ( + None if slot is None else context["candidates"][slot]["target_id"] + ) + before_captures = set(imager.data_store.data.captures) + before_deliveries = set(imager.data_store.data.deliveries) + stage_priorities = { + key: target.priority + for key, target in env.scenario.targets_by_id.items() + } + commanded = { + sat.name: 0 + for sat in env.satellites + if sat.name != imager.name and sat.requires_retasking + } + commanded[imager.name] = action + vectors, rewards, terminated, truncated, _ = env.step(commanded) + if env.simulator.sim_time <= context["time_s"]: + raise RuntimeError("Simulation made no time progress.") + attempts += slot is not None + components = env.rewarder.last_reward_components + totals["acquisition_reward"] += components["acquisition"].get( + imager.name, 0.0 + ) + totals["delivery_reward"] += components["delivery"].get(imager.name, 0.0) + totals["total_reward"] += rewards.get(imager.name, 0.0) + row = dict( + start_s=context["time_s"], + end_s=float(env.simulator.sim_time), + action=action, + action_type=descriptor["type"], + target_id=target_id, + reward=rewards.get(imager.name, 0.0), + acquisition_reward=components["acquisition"].get(imager.name, 0.0), + delivery_reward=components["delivery"].get(imager.name, 0.0), + candidate_ids=[ + None if item is None else item["target_id"] + for item in context["candidates"] + ], + ) + decisions.append(row) + observations.append(vector.copy()) + for records, previous, destination in ( + (imager.data_store.data.captures, before_captures, capture_rows), + (imager.data_store.data.deliveries, before_deliveries, delivery_rows), + ): + for key in sorted(set(records) - previous): + destination.append( + dict( + **asdict(records[key]), + stage_priority=stage_priorities[records[key].target_id], + ) + ) + context = sample() + if terminated.get(imager.name) or truncated.get(imager.name): + summary["status"] = ( + "terminated" if terminated.get(imager.name) else "time_limit" + ) + break + else: + summary["status"] = "max_steps" + summary.update( + states[-1], + steps=len(decisions), + pending_products=len(imager.data_store.products), + remaining_bits=float(sum(imager.data_store.remaining_bits.values())), + ) + (output / "policy.json").write_text(json.dumps(metadata, indent=2) + "\n") + except Exception as error: + summary["error"] = f"{type(error).__name__}: {error}" + raise + finally: + write_csv( + output / "states.csv", + states, + [ + "time_s", + "storage_bits", + "battery_fraction", + "wheel_fraction", + "attempts", + "captures", + "deliveries", + "acquisition_reward", + "delivery_reward", + "total_reward", + ], + ) + csv_decisions = [ + dict(row, candidate_ids=json.dumps(row["candidate_ids"])) + for row in decisions + ] + write_csv( + output / "decisions.csv", + csv_decisions, + [ + "start_s", + "end_s", + "action", + "action_type", + "target_id", + "reward", + "acquisition_reward", + "delivery_reward", + "candidate_ids", + ], + ) + event_fields = [ + "record_id", + "target_id", + "source_imager", + "capture_time", + "size_bits", + "quality", + "hold_valid_time_s", + "delivery_time", + "stage_priority", + ] + write_csv(output / "captures.csv", capture_rows, event_fields) + write_csv(output / "deliveries.csv", delivery_rows, event_fields) + if observations: + np.save(output / "decision_observations.npy", np.asarray(observations)) + (output / "summary.json").write_text( + json.dumps(summary, indent=2, allow_nan=False) + "\n" + ) + try: + if selected_policy is not None and hasattr(selected_policy, "close"): + selected_policy.close() + finally: + if env is not None: + env.close() + if plots: + try: + plot_results(output, states, decisions) + except Exception as error: + summary.update( + status="plot_error", error=f"{type(error).__name__}: {error}" + ) + (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + raise + return summary + + +def main(): + """Command-line entry point for a single reproducible evaluation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + required=True, + help="New output directory; existing directories are rejected.", + ) + parser.add_argument( + "--policy", + choices=["priority", "nearest", "random", "rllib", "custom"], + default="priority", + ) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument( + "--horizon", + type=float, + help="Simulation seconds; defaults to 900 or the replayed manifest horizon.", + ) + parser.add_argument("--max-steps", type=int, default=200) + inputs = parser.add_mutually_exclusive_group() + inputs.add_argument( + "--catalog", + dest="catalog_path", + type=Path, + help="Fix target conditions; scanner/world still use example settings.", + ) + inputs.add_argument( + "--mission", + dest="manifest_path", + type=Path, + help="Replay targets, scanner, world and configuration.", + ) + parser.add_argument( + "--profile", + choices=["compact", "amos"], + help="New mission observation/action profile; AMOS uses 124 features/13 actions.", + ) + parser.add_argument( + "--checkpoint", + type=Path, + help="Exact RLModule directory or checkpoint passed to a custom adapter.", + ) + parser.add_argument( + "--policy-factory", + dest="factory", + help="Custom adapter factory as module:function or file.py:function.", + ) + parser.add_argument( + "--stochastic", + action="store_true", + help="Sample RLlib logits using a separate seeded policy RNG.", + ) + parser.add_argument( + "--no-plots", + dest="plots", + action="store_false", + help="Write data only; no Matplotlib dependency.", + ) + print(json.dumps(evaluate(**vars(parser.parse_args())), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/time_discounted_gae.ipynb b/examples/time_discounted_gae.ipynb index f62873da..385d0de4 100644 --- a/examples/time_discounted_gae.ipynb +++ b/examples/time_discounted_gae.ipynb @@ -111,7 +111,7 @@ "\n", "\n", "sat = ScanningSatellite(\n", - " \"Scanner-1\",\n", + " \"Scanner_1\",\n", " sat_args=dict(\n", " # Data\n", " dataStorageCapacity=5000 * 8e6, # bits\n", diff --git a/src/bsk_rl/act/__init__.py b/src/bsk_rl/act/__init__.py index c191432e..009f4bc6 100644 --- a/src/bsk_rl/act/__init__.py +++ b/src/bsk_rl/act/__init__.py @@ -79,6 +79,7 @@ class MyActionSatellite(Satellite): NadirPoint, Scan, ) +from bsk_rl.act.rso_imaging import ImageRSO __doc_title__ = "Actions" __all__ = [ @@ -91,6 +92,7 @@ class MyActionSatellite(Satellite): "Desat", "Downlink", "Image", + "ImageRSO", "Scan", "Broadcast", "ContinuousAction", diff --git a/src/bsk_rl/act/rso_imaging.py b/src/bsk_rl/act/rso_imaging.py new file mode 100644 index 00000000..0fda23f9 --- /dev/null +++ b/src/bsk_rl/act/rso_imaging.py @@ -0,0 +1,140 @@ +"""Discrete actions for independent RSO spacecraft imaging.""" + +import warnings + +import numpy as np +from Basilisk.utilities import macros + +from bsk_rl.act.discrete_actions import DiscreteAction +from bsk_rl.utils.rso_imaging import candidate_snapshot + + +class ImageRSO(DiscreteAction): + """Image one of the observed candidate slots, or explicitly task a catalog ID. + + Uses :class:`~bsk_rl.sim.fsw.SpaceToSpaceImagingFSWModel` to track a whole + 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. + """ + + def __init__( + self, + n_ahead_image: int, + max_duration=300.0, + min_pointing_hold_s=10.0, + hold_mode="cumulative", + require_illumination_during_hold=True, + hold_illumination_threshold=0.5, + variable_duration_imaging=True, + name="image_rso", + ) -> None: + """Configure candidate count, deadline [s], hold [s], and quality gate.""" + if not isinstance(n_ahead_image, int) or n_ahead_image < 1: + raise ValueError("Candidate count must be a positive integer.") + if ( + not np.isfinite(max_duration) + or max_duration <= 0 + or not np.isfinite(min_pointing_hold_s) + or min_pointing_hold_s < 0 + ): + raise ValueError("Duration must be positive and hold nonnegative.") + if hold_mode not in ("cumulative", "continuous"): + raise ValueError("Hold mode must be cumulative or continuous.") + if not 0 <= hold_illumination_threshold <= 1: + raise ValueError("Illumination threshold must be in [0, 1].") + super().__init__(name, n_ahead_image) + self.max_duration = max_duration + self.min_pointing_hold_s = min_pointing_hold_s + self.hold_mode = hold_mode + self.require_illumination_during_hold = require_illumination_during_hold + self.hold_illumination_threshold = hold_illumination_threshold + self.variable_duration_imaging = variable_duration_imaging + self.reset_overwrite_previous() + + def reset_overwrite_previous(self) -> None: + """Drop event names and per-episode selection history.""" + self.event_name = None + self.chosen_target_ids = [] + + def _task(self, target) -> str: + if not self.satellite.rso_scenario.is_eligible( + target.id, self.simulator.sim_time + ): + raise ValueError("RSO target is pending or in cooldown.") + if self.event_name in self.simulator.eventMap: + self.simulator.delete_event(self.event_name) + fsw = self.satellite.fsw + fsw.action_image_rso( + target, + duration=self.max_duration, + hold_s=self.min_pointing_hold_s, + hold_mode=self.hold_mode, + require_illumination=self.require_illumination_during_hold, + illumination_threshold=self.hold_illumination_threshold, + ) + self.chosen_target_ids.append(target.id) + self.satellite.update_timed_terminal_event( + self.simulator.sim_time + self.max_duration, + info="RSO imaging deadline", + extra_actions=lambda sim: fsw.rso_capture.cancel(), + ) + if self.variable_duration_imaging: + self.event_name = f"rso_image_success_{self.satellite.name}" + self.simulator.createNewEvent( + self.event_name, + macros.sec2nano(fsw.dynamics.dyn_rate), + True, + conditionFunction=lambda sim: fsw.rso_capture.check_capture(), + actionFunction=lambda sim: setattr( + self.satellite, "requires_retasking", True + ), + terminal=self.satellite.variable_interval, + ) + return target.id + + def set_action(self, action: int, prev_action_key=None) -> str: + """Decode the same ordered snapshot used to construct observations.""" + if not 0 <= action < self.n_actions: + raise ValueError("RSO imaging action index out of range.") + snapshot = getattr(self.satellite, "_rso_candidate_snapshot", None) + if snapshot is None: + snapshot = candidate_snapshot(self.satellite, self.n_actions) + if ( + snapshot.time != self.simulator.sim_time + or snapshot.revision != self.satellite.rso_scenario.revision + ): + # Attribute the warning to the caller selecting this action. + warnings.warn( + "RSO candidates changed since the observation; attempting the " + "original target ID. Obtain a fresh observation for current candidates.", + stacklevel=2, + ) + if len(snapshot.targets) < self.n_actions: + raise ValueError("RSO snapshot does not contain all action candidates.") + target = snapshot.targets[action] + if target is None: + raise ValueError("RSO imaging action selected an empty candidate slot.") + # Preserve the observed slot's ID while using its current episode binding. + try: + target = self.satellite.rso_scenario.targets_by_id[target.id] + except KeyError as error: + raise ValueError( + "The observed RSO target is no longer in the scenario." + ) from error + return self._task(target) + + def set_action_override(self, action, prev_action_key=None) -> str: + """Task an explicitly identified target without parsing spacecraft names.""" + 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.") + try: + target = 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) + + +__doc_title__ = "Space-to-Space RSO Imaging" +__all__ = ["ImageRSO"] diff --git a/src/bsk_rl/data/__init__.py b/src/bsk_rl/data/__init__.py index 0bfdeefe..814c4065 100644 --- a/src/bsk_rl/data/__init__.py +++ b/src/bsk_rl/data/__init__.py @@ -100,11 +100,21 @@ from bsk_rl.data.nadir_data import ScanningTimeReward from bsk_rl.data.no_data import NoReward from bsk_rl.data.resource_data import ResourceReward +from bsk_rl.data.rso_imaging import ( + RSOImageData, + RSOImageRecord, + RSOImageReward, + RSOImageStore, +) from bsk_rl.data.rso_inspection import RSOInspectionReward from bsk_rl.data.unique_image_data import UniqueImageReward __doc_title__ = "Data & Reward" __all__ = [ + "RSOImageRecord", + "RSOImageData", + "RSOImageStore", + "RSOImageReward", "GlobalReward", "NoReward", "UniqueImageReward", diff --git a/src/bsk_rl/data/rso_imaging.py b/src/bsk_rl/data/rso_imaging.py new file mode 100644 index 00000000..67d6633c --- /dev/null +++ b/src/bsk_rl/data/rso_imaging.py @@ -0,0 +1,326 @@ +"""Physical image ownership, complete delivery, and centralized RSO service reward.""" + +from dataclasses import replace +from itertools import groupby + +import numpy as np + +from bsk_rl.data.base import Data, DataStore, GlobalReward +from bsk_rl.utils.rso_imaging import RSOImageRecord + + +class RSOImageData(Data): + """Mergeable event records; communicated knowledge does not move image bits.""" + + def __init__(self, captures=None, deliveries=None) -> None: + """Index immutable acquisition and completed-delivery records by record ID.""" + self.captures = {} if captures is None else dict(captures) + self.deliveries = {} if deliveries is None else dict(deliveries) + + def __add__(self, other): + """Merge idempotently, rejecting conflicting product provenance.""" + collections = [] + for first, second in ( + (self.captures, other.captures), + (self.deliveries, other.deliveries), + ): + if any( + key in first and first[key] != value for key, value in second.items() + ): + raise ValueError("Conflicting RSO image records.") + collections.append(first | second) + return RSOImageData(*collections) + + +class RSOImageStore(DataStore): + """Per-imager FIFO ledger backed by actual partitioned storage changes. + + Capture timestamps identify the acquisition command sent to the instrument. Delivery timestamps + are recognition times at environment step boundaries. Partial transmitted bits + reduce remaining bits without publishing a completed delivery. + """ + + data_type = RSOImageData + + def __init__(self, satellite, initial_data=None, scenario=None) -> None: + """Constructor configuration works identically inside composed rewards.""" + super().__init__(satellite, initial_data) + self.scenario = scenario + self.products = {} + self.remaining_bits = {} + self.is_imager = ( + scenario is not None and satellite.name in scenario.imager_names + ) + + def get_log_state(self): + """Read physical storage and this observer's confirmed captures.""" + if not self.is_imager: + return {}, () + recorder = self.satellite.fsw.rso_capture + recorder.check_capture() + message = self.satellite.dynamics.storageUnit.storageUnitDataOutMsg.read() + return dict(zip(message.storedDataName, message.storedData)), tuple( + recorder.records + ) + + def compare_log_states(self, old_state, new_state): + """Account for transmitted bits, including several products in a step.""" + old_levels, old_records = old_state + new_levels, records = new_state + new_captures = records[len(old_records) :] + captured_bits = {} + for record in new_captures: + if ( + record.source_imager != self.satellite.name + or record.record_id in self.products + ): + raise ValueError( + "Invalid capture ownership or duplicate physical image." + ) + partition = self.scenario.partition_name(record.target_id) + self.products[record.record_id] = record + self.remaining_bits[record.record_id] = record.size_bits + captured_bits[partition] = ( + captured_bits.get(partition, 0.0) + record.size_bits + ) + delivered = {} + for target_id in self.scenario.targets_by_id if self.is_imager else (): + partition = self.scenario.partition_name(target_id) + removed = ( + old_levels.get(partition, 0.0) + + captured_bits.get(partition, 0.0) + - new_levels.get(partition, 0.0) + ) + if removed <= 1e-6: + continue + onboard = sorted( + ( + product + for product in self.products.values() + if product.target_id == target_id + ), + key=lambda product: (product.capture_time, product.record_id), + ) + for product in onboard: + remaining = self.remaining_bits[product.record_id] + transmitted = min(remaining, removed) + self.remaining_bits[product.record_id] -= transmitted + removed -= transmitted + if self.remaining_bits[product.record_id] <= 1e-6: + delivered[product.record_id] = replace( + product, delivery_time=float(self.satellite.simulator.sim_time) + ) + del self.products[product.record_id] + del self.remaining_bits[product.record_id] + if removed <= 1e-6: + break + if removed > 1e-6: + raise ValueError("Storage removed unowned RSO image bits.") + return RSOImageData( + {product.record_id: product for product in new_captures}, delivered + ) + + +class RSOImageReward(GlobalReward): + """Priority-weighted acquisition and fully delivered, quality-verified service. + + Acquisition and delivery callbacks receive ``(record, target)`` and return + the scalar value of that stage. By default, acquisition earns zero and a + complete delivery earns the target's current priority. + Specify weights, illumination scaling, or time discounts in those callbacks. + Priorities are evaluated at each reward boundary, before priority events. + Pending images are centrally visible. A useful delivery establishes a global + cooldown measured from capture time. In explicit "shared" mode, captures seen + together at one boundary share + acquisition credit. Later captures while this service is pending earn no further + acquisition credit. The first useful delivery group shares delivery credit; + later duplicate deliveries earn none, including when cooldown is zero. + "per_imager" gives each owner its full stage value once per pending service. + Multiple imagers require selecting a mode explicitly. + """ + + data_store_type = RSOImageStore + + def __init__( + self, + acquisition_reward_fn=None, + delivery_reward_fn=None, + quality_threshold=0.5, + cooldown_s=0.0, + multi_imager_credit=None, + ) -> None: + """Configure stage callbacks, mean hold illumination, and cooldown. + + Args: + acquisition_reward_fn: Callable ``(record, target) -> float`` for a + useful physically acquired image. Defaults to zero. + delivery_reward_fn: Callable with the same signature for a useful + complete delivery. Defaults to current priority. + quality_threshold: Minimum mean illuminated fraction in [0, 1]. + cooldown_s: Global cooldown from capture time, in seconds. + multi_imager_credit: ``"shared"`` splits each callback's value across + simultaneous qualified records, while ``"per_imager"`` gives each + owner its callback's full value. Required for multiple imagers. + """ + super().__init__() + if ( + not 0 <= quality_threshold <= 1 + or not np.isfinite(cooldown_s) + or cooldown_s < 0 + or multi_imager_credit not in (None, "shared", "per_imager") + ): + raise ValueError("Invalid RSO image reward settings.") + for callback in (acquisition_reward_fn, delivery_reward_fn): + if callback is not None and not callable(callback): + raise TypeError("RSO stage rewards must be callable.") + self.acquisition_reward_fn = acquisition_reward_fn or ( + lambda record, target: 0.0 + ) + self.delivery_reward_fn = delivery_reward_fn or ( + lambda record, target: target.priority + ) + self.quality_threshold = float(quality_threshold) + self.cooldown_s = float(cooldown_s) + self.multi_imager_credit = multi_imager_credit + + def link_scenario(self, scenario) -> None: + """Pass configuration through constructor kwargs, including composition.""" + if len(scenario.imager_names) > 1 and self.multi_imager_credit is None: + raise ValueError( + "Multiple imagers require explicit multi_imager_credit: " + "'shared' or 'per_imager'." + ) + super().link_scenario(scenario) + self.data_store_kwargs = dict(scenario=scenario) + + def reset_overwrite_previous(self) -> None: + """Reset per-episode uniqueness accounting.""" + super().reset_overwrite_previous() + self.acquisition_credit_times = {} + self.delivery_credit_times = {} + self.acquisition_services = set() + self.delivery_services = set() + self.record_services = {} + self.active_services = {} + self.service_generation = {} + self.last_reward_components = {"acquisition": {}, "delivery": {}} + + def _credit( + self, records, credit_times, credited_services, reward_fn, reward + ) -> None: + def scope(record): + return ( + (record.target_id, record.source_imager) + if self.multi_imager_credit == "per_imager" + else record.target_id + ) + + qualified = [ + record for record in records if record.quality >= self.quality_threshold + ] + qualified.sort( + key=lambda record: ( + scope(record), + self.record_services[record.record_id], + record.capture_time, + record.source_imager, + record.record_id, + ) + ) + for (credit_scope, service), grouped in groupby( + qualified, + key=lambda record: ( + scope(record), + self.record_services[record.record_id], + ), + ): + group = list(grouped) + service_key = (credit_scope, service) + if service_key in credited_services: + continue + capture_time = min(record.capture_time for record in group) + last = credit_times.get(credit_scope) + if last is not None and ( + capture_time <= last + 1e-9 or capture_time < last + self.cooldown_s + ): + continue + target = self.scenario.targets_by_id[group[0].target_id] + for record in group: + value = float(reward_fn(record, target)) + if not np.isfinite(value): + raise ValueError("RSO stage rewards must return finite scalars.") + reward[record.source_imager] += value / len(group) + credit_times[credit_scope] = capture_time + credited_services.add(service_key) + + def calculate_reward(self, new_data_dict): + """Update centralized eligibility; credit the actual acquisition/delivery owner.""" + reward = {name: 0.0 for name in new_data_dict} + captures, deliveries = [], [] + for name, events in new_data_dict.items(): + for record in (*events.captures.values(), *events.deliveries.values()): + if ( + name != record.source_imager + or name not in self.scenario.imager_names + ): + raise ValueError("RSO reward record attributed to a non-owner.") + captures.extend(events.captures.values()) + deliveries.extend(events.deliveries.values()) + fresh_captures = [ + record + for record in captures + if record.record_id not in self.record_services + ] + for target_id in sorted({record.target_id for record in fresh_captures}): + if not self.scenario.pending.get(target_id): + generation = self.service_generation.get(target_id, 0) + 1 + self.service_generation[target_id] = generation + self.active_services[target_id] = (target_id, generation) + for record in fresh_captures: + if record.target_id == target_id: + self.record_services[record.record_id] = self.active_services[ + target_id + ] + for record in fresh_captures: + self.scenario.pending.setdefault(record.target_id, set()).add( + record.record_id + ) + for record in deliveries: + if record.record_id not in self.record_services: + raise ValueError("RSO delivery has no physically acquired product.") + self.scenario.pending.setdefault(record.target_id, set()).discard( + record.record_id + ) + if record.quality >= self.quality_threshold: + until = record.capture_time + self.cooldown_s + self.scenario.cooldown_until[record.target_id] = max( + until, self.scenario.cooldown_until.get(record.target_id, -np.inf) + ) + self._credit( + captures, + self.acquisition_credit_times, + self.acquisition_services, + self.acquisition_reward_fn, + reward, + ) + acquisition_reward = dict(reward) + self._credit( + deliveries, + self.delivery_credit_times, + self.delivery_services, + self.delivery_reward_fn, + reward, + ) + self.last_reward_components = { + "acquisition": acquisition_reward, + "delivery": { + name: value - acquisition_reward[name] for name, value in reward.items() + }, + } + if captures or deliveries: + self.scenario.revision += 1 + return reward + + +__doc_title__ = "Space-to-Space RSO Image Data" +__all__ = ["RSOImageData", "RSOImageStore", "RSOImageReward"] diff --git a/src/bsk_rl/gym.py b/src/bsk_rl/gym.py index 47901e23..a21d867c 100644 --- a/src/bsk_rl/gym.py +++ b/src/bsk_rl/gym.py @@ -160,6 +160,8 @@ def __init__( if isinstance(satellites, Satellite): satellites = [satellites] + if scenario is not None: + scenario.validate_satellite_names(satellites) self.satellites = deepcopy(satellites) self.dtype = dtype @@ -530,6 +532,7 @@ def _step(self, actions: MultiSatAct) -> None: } self.reward_dict = self.rewarder.reward(new_data) + self.scenario.after_step(self.simulator.sim_time) self.communicator.communicate() for satellite in self.satellites: diff --git a/src/bsk_rl/obs/__init__.py b/src/bsk_rl/obs/__init__.py index ed6f1e35..d758e7da 100644 --- a/src/bsk_rl/obs/__init__.py +++ b/src/bsk_rl/obs/__init__.py @@ -44,6 +44,7 @@ class MyObservationSatellite(Satellite): Time, ) from bsk_rl.obs.relative_observations import RelativeProperties +from bsk_rl.obs.rso_imaging import RSOTargetProperties __doc_title__ = "Observations" __all__ = [ @@ -54,4 +55,5 @@ class MyObservationSatellite(Satellite): "OpportunityProperties", "Eclipse", "ResourceRewardWeight", + "RSOTargetProperties", ] diff --git a/src/bsk_rl/obs/rso_imaging.py b/src/bsk_rl/obs/rso_imaging.py new file mode 100644 index 00000000..21ec087e --- /dev/null +++ b/src/bsk_rl/obs/rso_imaging.py @@ -0,0 +1,121 @@ +"""Observations of the exact independently orbiting targets decoded by ImageRSO.""" + +import numpy as np + +from bsk_rl.obs.observations import Observation +from bsk_rl.utils.orbital import rv2HN +from bsk_rl.utils.rso_imaging import ( + candidate_snapshot, + illumination_factor, + target_geometry, +) + + +def target_properties(imager, target): + """Geometric/illumination features with explicit units and no name parsing.""" + if target is None: + return dict( + valid=0.0, + priority=0.0, + target_elevation_angle=0.0, + rel_pos_vector_r_BR_H=np.zeros(3), + rel_vel_vector_v_BR_H=np.zeros(3), + angle_to_target=0.0, + target_distance=0.0, + target_illumination_factor=0.0, + ) + relative, distance, elevation = target_geometry(imager, target) + HN = rv2HN(imager.dynamics.r_BN_N, imager.dynamics.v_BN_N) + # AMOS uses the difference of inertial velocities, expressed in the observer + # Hill frame, rather than the derivative in the rotating Hill frame. + relative_velocity = np.asarray( + target.target_spacecraft.dynamics.v_BN_N + ) - np.asarray(imager.dynamics.v_BN_N) + c_hat_N = np.asarray(imager.dynamics.BN).T @ np.asarray(imager.fsw.locPoint.pHat_B) + angle = float(np.degrees(np.arccos(np.clip(relative @ c_hat_N / distance, -1, 1)))) + target_dyn = target.target_spacecraft.dynamics + illumination = illumination_factor( + target_dyn.world.eclipseObject.eclipseOutMsgs[target_dyn.eclipse_index].read() + ) + return dict( + valid=1.0, + priority=float(target.priority), + target_elevation_angle=elevation, + rel_pos_vector_r_BR_H=HN @ relative, + rel_vel_vector_v_BR_H=HN @ relative_velocity, + angle_to_target=angle, + target_distance=distance, + target_illumination_factor=illumination, + ) + + +class RSOTargetProperties(Observation): + """Fixed-size spacecraft candidate rows; empty slots are zeros with valid=0. + + The default row contains priority, elevation, relative position and velocity + in the observer Hill frame, boresight angle, distance, and illumination. + Relative velocity is the inertial velocity difference expressed in that frame, + rather than the derivative of rotating-frame position. Include ``valid`` as + a custom property when a numeric mask for padded rows is useful. + + Observation and action row counts are independent. ``ImageRSO`` selects from + the first action-count rows of this ordering; additional rows provide context. + """ + + def __init__(self, *properties, n_ahead_observe=10, name="rso_targets") -> None: + """Select named properties and optional nonzero normalization factors.""" + super().__init__(name) + if not isinstance(n_ahead_observe, int) or n_ahead_observe < 1: + raise ValueError("Candidate count must be a positive integer.") + allowed = set(target_properties(None, None)) + if not properties: + properties = ( + dict(prop="priority", norm=2), + dict(prop="target_elevation_angle", norm=90), + dict(prop="rel_pos_vector_r_BR_H", norm=15960e3), + dict(prop="rel_vel_vector_v_BR_H", norm=16000), + dict(prop="angle_to_target", norm=90), + dict(prop="target_distance", norm=15960e3), + dict(prop="target_illumination_factor"), + ) + self.properties = [] + for spec in properties: + prop, norm = spec["prop"], float(spec.get("norm", 1)) + if prop not in allowed or not np.isfinite(norm) or norm == 0: + raise ValueError("Unsupported RSO target property or normalization.") + self.properties.append((prop, spec.get("name", prop), norm)) + if len({name for _, name, _ in self.properties}) != len(self.properties): + raise ValueError("Observation property names must be unique.") + self.n_ahead_observe = n_ahead_observe + + def reset_overwrite_previous(self) -> None: + """Discard prior-episode candidates.""" + self.satellite._rso_candidate_snapshot = None + + def get_obs(self): + """Build rows from the shared decision snapshot, including explicit padding.""" + snapshot = candidate_snapshot(self.satellite, self.n_ahead_observe) + result = {} + for index, target in enumerate(snapshot.targets[: self.n_ahead_observe]): + values = target_properties(self.satellite, target) + result[f"{self.name}_{index}"] = { + name: values[prop] / norm for prop, name, norm in self.properties + } + return result + + def candidate_ids(self) -> tuple[str | None, ...]: + """Return the same ordered IDs as the observation/action slots. + + This diagnostic does not change the numeric observation schema. Padded + slots are ``None``; callers must never replace them with a real target. + """ + return tuple( + None if target is None else target.id + for target in candidate_snapshot( + self.satellite, self.n_ahead_observe + ).targets[: self.n_ahead_observe] + ) + + +__doc_title__ = "Space-to-Space RSO Observations" +__all__ = ["RSOTargetProperties"] diff --git a/src/bsk_rl/sats/satellite.py b/src/bsk_rl/sats/satellite.py index 7b032101..60c58d9c 100644 --- a/src/bsk_rl/sats/satellite.py +++ b/src/bsk_rl/sats/satellite.py @@ -1,6 +1,7 @@ """Satellites are the agents in the environment.""" import inspect +import keyword import logging from abc import ABC from typing import TYPE_CHECKING, Any, Optional, Union @@ -21,7 +22,6 @@ collect_default_args, compose_types, safe_dict_merge, - valid_func_name, ) from bsk_rl.utils.orbital import TrajectorySimulator @@ -50,6 +50,22 @@ class Satellite(ABC, Resetable): _dyn_type = None + @property + def name(self) -> str: + """Environment identifier, also used directly in simulator event names.""" + return self._name + + @name.setter + def name(self, value: str) -> None: + if not isinstance(value, str) or not value: + raise ValueError("Satellite name must be a nonempty string.") + if not value.isidentifier() or keyword.iskeyword(value): + raise ValueError( + "Satellite name must be a valid Python identifier, for example " + "'camera_a' rather than 'camera-a'." + ) + self._name = value + @classmethod def get_dyn_type(cls) -> type["dyn.DynamicsModelABC"]: """Get the dynamics model type for the satellite. @@ -128,7 +144,8 @@ def __init__( """The base satellite agent class. Args: - name: Identifier for satellite; does not need to be unique. + name: Valid Python identifier for the satellite, such as ``camera_a``. + Duplicate names receive numeric suffixes in the environment. sat_args: Arguments for :class:`~bsk_rl.sim.dyn.DynamicsModelABC` and :class:`~bsk_rl.sim.fsw.FSWModelABC` construction. Should be in the form of a dictionary with keys corresponding to the arguments of the constructor @@ -395,9 +412,7 @@ def update_timed_terminal_event( self.logger.info(f"setting timed terminal event at {t_close:.1f}") # Create new timed terminal event - self._timed_terminal_event_name = valid_func_name( - f"timed_terminal_{t_close}_{self.name}" - ) + self._timed_terminal_event_name = f"timed_terminal_{self.name}" def side_effect(sim): self.logger.info(f"timed termination at {t_close:.1f} " + info) diff --git a/src/bsk_rl/scene/__init__.py b/src/bsk_rl/scene/__init__.py index 7576c33f..ff3e9ee1 100644 --- a/src/bsk_rl/scene/__init__.py +++ b/src/bsk_rl/scene/__init__.py @@ -16,6 +16,12 @@ """ from bsk_rl.scene.rso_points import RSOPoints, SphericalRSO +from bsk_rl.scene.rso_targets import ( + RSOPriorityEvent, + RSOTarget, + RSOTargetCatalog, + RSOTargets, +) from bsk_rl.scene.scenario import Scenario, UniformNadirScanning from bsk_rl.scene.targets import CityTargets, UniformTargets @@ -27,4 +33,8 @@ "UniformNadirScanning", "RSOPoints", "SphericalRSO", + "RSOTarget", + "RSOTargetCatalog", + "RSOPriorityEvent", + "RSOTargets", ] diff --git a/src/bsk_rl/scene/rso_targets.py b/src/bsk_rl/scene/rso_targets.py new file mode 100644 index 00000000..12252cf9 --- /dev/null +++ b/src/bsk_rl/scene/rso_targets.py @@ -0,0 +1,396 @@ +"""Independent spacecraft targets for space-to-space imaging. + +Unlike :class:`~bsk_rl.scene.RSOPoints`, these targets are whole spacecraft, +not surface points on one nearby RSO. Catalog states are Earth-centered J2000, +in meters and meters per second, at the catalog's optional ``utc_init`` or the +environment's realized epoch when no catalog epoch is specified. +""" + +import json +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import Any + +import numpy as np + +from bsk_rl.scene.scenario import Scenario + + +@dataclass(frozen=True) +class RSOTarget: + """Immutable definition of one independently orbiting imaging target. + + IDs are nonempty strings; they have no relationship to simulator indices. + ``satellite_name`` is :attr:`~bsk_rl.sats.Satellite.name`, the environment's + agent identifier. It is distinct from the Basilisk spacecraft model's name + and binds exactly one explicitly supplied satellite, rather than a group. + Physical parameters are optional ``(sat_args key, scalar value)`` pairs. + """ + + id: str + satellite_name: str + rN: tuple[float, float, float] + vN: tuple[float, float, float] + priority: float = 1.0 + sigma_init: tuple[float, float, float] = (0.0, 0.0, 0.0) + omega_init: tuple[float, float, float] = (0.0, 0.0, 0.0) + wheel_speeds_rpm: tuple[float, float, float] = (0.0, 0.0, 0.0) + disturbance_torque_Nm: tuple[float, float, float] = (0.0, 0.0, 0.0) + physical_parameters: tuple[tuple[str, float], ...] = () + + def __post_init__(self) -> None: + """Validate and freeze values supplied as lists or numpy vectors.""" + for key in ("id", "satellite_name"): + if not isinstance(getattr(self, key), str) or not getattr(self, key): + raise ValueError(f"{key} must be a nonempty string.") + for key in ( + "rN", + "vN", + "sigma_init", + "omega_init", + "wheel_speeds_rpm", + "disturbance_torque_Nm", + ): + value = tuple(float(v) for v in getattr(self, key)) + if len(value) != 3 or not np.all(np.isfinite(value)): + raise ValueError(f"{key} must contain three finite values.") + object.__setattr__(self, key, value) + if np.linalg.norm(self.rN) == 0: + raise ValueError("Target position must be nonzero.") + if not np.isfinite(self.priority) or self.priority < 0: + raise ValueError("Priority must be finite and nonnegative.") + object.__setattr__(self, "priority", float(self.priority)) + from bsk_rl.sim.dyn.base import BasicDynamicsModel + from bsk_rl.utils.functional import collect_default_args + + allowed = { + "mass", + "width", + "depth", + "height", + "mu", + "min_orbital_radius", + "dragCoeff", + "panelArea", + "u_max", + } + defaults = collect_default_args(BasicDynamicsModel) + parameters = tuple( + (key, float(value)) for key, value in self.physical_parameters + ) + if len({key for key, _ in parameters}) != len(parameters): + raise ValueError("Duplicate physical parameter.") + if any( + key not in allowed or not np.isfinite(value) or value <= 0 + for key, value in parameters + ): + raise ValueError("Unsupported or invalid target physical parameter.") + realized = {key: float(defaults[key]) for key in sorted(allowed)} + realized.update(dict(parameters)) + object.__setattr__(self, "physical_parameters", tuple(realized.items())) + + def sat_args(self) -> dict[str, Any]: + """Return deterministic target dynamics arguments for this epoch.""" + return dict( + rN=list(self.rN), + vN=list(self.vN), + oe=None, + sigma_init=list(self.sigma_init), + omega_init=list(self.omega_init), + wheelSpeeds=list(self.wheel_speeds_rpm), + disturbance_vector=list(self.disturbance_torque_Nm), + basePowerDraw=0.0, + rwBasePower=0.0, + batteryStorageCapacity=288000.0, + storedCharge_Init=288000.0, + **dict(self.physical_parameters), + ) + + +@dataclass(frozen=True) +class RSOPriorityEvent: + """Priority changes applied at the first decision boundary at/after time_s. + + Changes occur after the preceding step's reward and before its observation. + Events at zero occur before the initial observation. They do not clear pending + images or cooldowns. Pairs are ``(target ID, new priority)``. + """ + + time_s: float + priorities: tuple[tuple[str, float], ...] + + def __post_init__(self) -> None: + """Freeze and validate an event.""" + if not np.isfinite(self.time_s) or self.time_s < 0: + raise ValueError("Event time must be finite and nonnegative.") + pairs = tuple((key, float(value)) for key, value in self.priorities) + if len({key for key, _ in pairs}) != len(pairs): + raise ValueError("Duplicate priority-event ID.") + if any( + not isinstance(key, str) or not key or not np.isfinite(value) or value < 0 + for key, value in pairs + ): + raise ValueError("Invalid priority-event value.") + object.__setattr__(self, "priorities", pairs) + + +@dataclass(frozen=True) +class RSOTargetCatalog: + """Replayable target definitions, ordering, optional epoch, and priority events. + + If ``utc_init`` is omitted, target states refer to the environment's realized + epoch on each reset. After reset, + :attr:`~bsk_rl.scene.RSOTargets.catalog` contains that explicit epoch and can + be saved for replay. An explicitly supplied catalog + epoch must match the environment; states are never silently retimed. + """ + + targets: tuple[RSOTarget, ...] + utc_init: str | None = None + priority_events: tuple[RSOPriorityEvent, ...] = () + + def __post_init__(self) -> None: + """Validate identities and event references without consuming randomness.""" + object.__setattr__(self, "targets", tuple(self.targets)) + object.__setattr__(self, "priority_events", tuple(self.priority_events)) + if not self.targets: + raise ValueError("A catalog requires targets.") + if self.utc_init is not None and ( + not isinstance(self.utc_init, str) or not self.utc_init + ): + raise ValueError("Catalog epoch must be a nonempty string or None.") + ids = {target.id for target in self.targets} + names = {target.satellite_name for target in self.targets} + if len(ids) != len(self.targets) or len(names) != len(self.targets): + raise ValueError("Target IDs and spacecraft names must be unique.") + if any( + key not in ids + for event in self.priority_events + for key, _ in event.priorities + ): + raise ValueError("Priority event references an unknown target.") + if list(self.priority_events) != sorted( + self.priority_events, key=lambda event: event.time_s + ): + raise ValueError("Priority events must be ordered by time.") + + def to_dict(self) -> dict[str, Any]: + """Return a versioned, JSON-compatible mission target definition.""" + return dict( + schema_version=1, + frame="Earth-centered J2000", + position_unit="m", + velocity_unit="m/s", + **asdict(self), + ) + + def save(self, path: str | Path) -> None: + """Save target definitions and their optional epoch without rounding. + + Save ``env.scenario.catalog`` after reset to include the realized epoch + when the original definition omitted it. + """ + Path(path).write_text( + json.dumps(self.to_dict(), indent=2, allow_nan=False) + "\n" + ) + + @classmethod + def from_dict(cls, values: dict[str, Any]) -> "RSOTargetCatalog": + """Validate and load the supported schema and units.""" + expected = dict( + schema_version=1, + frame="Earth-centered J2000", + position_unit="m", + velocity_unit="m/s", + ) + if any(values.get(key) != value for key, value in expected.items()): + raise ValueError("Unsupported catalog schema, frame, or units.") + targets = [] + for saved in values["targets"]: + target = dict(saved) + # Read catalogs saved before the binding field was clarified. + if "spacecraft_name" in target: + legacy_name = target.pop("spacecraft_name") + if target.get("satellite_name", legacy_name) != legacy_name: + raise ValueError( + "Conflicting satellite binding names in catalog: " + f"satellite_name={target['satellite_name']!r}, " + f"spacecraft_name={legacy_name!r}. Supply one binding name." + ) + target["satellite_name"] = legacy_name + targets.append(RSOTarget(**target)) + return cls( + tuple(targets), + values.get("utc_init"), + tuple(RSOPriorityEvent(**event) for event in values["priority_events"]), + ) + + @classmethod + def load(cls, path: str | Path) -> "RSOTargetCatalog": + """Load a saved catalog without resampling any target.""" + return cls.from_dict(json.loads(Path(path).read_text())) + + +@dataclass +class _RuntimeRSOTarget: + """Episode-local binding; never serialized in the immutable catalog.""" + + definition: RSOTarget + target_spacecraft: Any + priority: float + + @property + def id(self) -> str: + """Stable catalog ID.""" + return self.definition.id + + +class RSOTargets(Scenario): + """Bind an immutable catalog to explicitly named imagers and target satellites. + + Other spacecraft are allowed, but do not become targets automatically. + 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. + """ + + def __init__(self, catalog: RSOTargetCatalog, imager_names: list[str]) -> None: + """Select participants by explicit identity, independently of list order.""" + super().__init__() + self.catalog = catalog + self.imager_names = tuple(imager_names) + if not self.imager_names or len(set(self.imager_names)) != len( + self.imager_names + ): + raise ValueError("Imager names must be nonempty and unique.") + if any(not isinstance(name, str) or not name for name in self.imager_names): + raise ValueError("Imager names must be nonempty strings.") + if set(self.imager_names) & { + target.satellite_name for target in catalog.targets + }: + raise ValueError("An imager cannot be its own RSO target.") + self.reset_overwrite_previous() + + @property + def catalog(self) -> RSOTargetCatalog: + """Configured catalog before reset, or the epoch-resolved episode snapshot. + + The input definition remains immutable. Save this property after reset + to retain the realized epoch without pinning subsequent episode resets. + """ + if self._episode_catalog is not None: + return self._episode_catalog + return self._catalog_definition + + @catalog.setter + def catalog(self, value: RSOTargetCatalog) -> None: + """Set the reusable definition and discard the resolved episode snapshot.""" + self._catalog_definition = value + self._episode_catalog = None + + def validate_satellite_names(self, satellites) -> None: + """Reject ambiguous/missing original names before the environment renames.""" + names = [sat.name for sat in satellites] + if len(names) != len(set(names)): + raise ValueError("Satellite names must be unique.") + required = set(self.imager_names) | { + target.satellite_name for target in self.catalog.targets + } + if not required <= set(names): + raise ValueError( + f"Missing scene participants: {sorted(required - set(names))}" + ) + + def link_satellites(self, satellites) -> None: + """Bind only the copied participants used by the actual environment.""" + self.validate_satellite_names(satellites) + super().link_satellites(satellites) + + def reset_overwrite_previous(self) -> None: + """Drop runtime bindings, centralized eligibility, and event history.""" + self._episode_catalog = None + self.targets_by_id = {} + self.imagers = [] + self.pending = {} + self.cooldown_until = {} + self.applied_events = [] + self.revision = 0 + + def reset_pre_sim_init(self) -> None: + """Bind this episode's objects and override only catalog target conditions.""" + epoch = getattr(self, "utc_init", None) + if not isinstance(epoch, str) or not epoch: + raise ValueError("RSOTargets requires the environment's realized epoch.") + definition = self._catalog_definition + if definition.utc_init is not None and epoch != definition.utc_init: + raise ValueError("World epoch must match RSOTargetCatalog.utc_init.") + self._episode_catalog = ( + replace(definition, utc_init=epoch) + if definition.utc_init is None + else definition + ) + by_name = {sat.name: sat for sat in self.satellites} + self.imagers = [by_name[name] for name in self.imager_names] + for imager in self.imagers: + if not hasattr(imager.dyn_type, "bind_rso_target") or not hasattr( + imager.fsw_type, "action_image_rso" + ): + raise ValueError(f"Imager {imager.name!r} lacks RSO imaging models.") + for definition in self.catalog.targets: + satellite = by_name[definition.satellite_name] + from bsk_rl.sim.dyn.base import EclipseDynModel + + if not issubclass(satellite.dyn_type, EclipseDynModel): + raise ValueError(f"Target {satellite.name!r} lacks eclipse dynamics.") + satellite.sat_args.update(definition.sat_args()) + self.targets_by_id[definition.id] = _RuntimeRSOTarget( + definition, satellite, definition.priority + ) + for imager in self.imagers: + partitions = [ + self.partition_name(target.id) for target in self.catalog.targets + ] + if len(set(partitions)) != len(partitions): + raise ValueError("RSO storage partition names collide.") + imager.sat_args.update( + bufferNames=partitions, transmitterNumBuffers=len(partitions) + ) + imager.rso_scenario = self + imager._rso_candidate_snapshot = None + self.after_step(0.0) + + @staticmethod + def partition_name(target_id: str) -> str: + """Bounded simulator-safe partition name, independent of array position.""" + import hashlib + + return "rso_" + hashlib.sha256(target_id.encode()).hexdigest()[:32] + + def reset_during_sim_init(self) -> None: + """Register each target separately in each imager's access model.""" + for imager in self.imagers: + for target in self.targets_by_id.values(): + imager.dynamics.bind_rso_target(target) + + def is_eligible(self, target_id: str, time_s: float) -> bool: + """Centralized pending visibility and quality-verified shared cooldown.""" + return not self.pending.get(target_id) and time_s >= self.cooldown_until.get( + target_id, -np.inf + ) + + def after_step(self, sim_time: float) -> None: + """Apply due priority events after reward, before the next observation.""" + for index, event in enumerate(self.catalog.priority_events): + if ( + index in {entry[0] for entry in self.applied_events} + or event.time_s > sim_time + ): + continue + for target_id, priority in event.priorities: + self.targets_by_id[target_id].priority = priority + self.applied_events.append((index, float(sim_time))) + self.revision += 1 + + +__doc_title__ = "Space-to-Space RSO Targets" +__all__ = ["RSOTarget", "RSOTargetCatalog", "RSOPriorityEvent", "RSOTargets"] diff --git a/src/bsk_rl/scene/scenario.py b/src/bsk_rl/scene/scenario.py index 4559fc2b..09a63229 100644 --- a/src/bsk_rl/scene/scenario.py +++ b/src/bsk_rl/scene/scenario.py @@ -28,6 +28,22 @@ def link_satellites(self, satellites: list["Satellite"]) -> None: """ self.satellites = satellites + def validate_satellite_names(self, satellites: list["Satellite"]) -> None: + """Optionally validate original participant names before automatic renaming. + + The default permits existing environment name normalization. Scenarios with + explicit identity bindings can reject ambiguous original names. + """ + pass + + def after_step(self, sim_time: float) -> None: + """Update scenario conditions after reward and before communication/observation. + + The default does nothing. Scheduled scenarios can override this hook without + putting scenario-specific logic in the environment. + """ + pass + class UniformNadirScanning(Scenario): """Defines a nadir target center at the center of the planet.""" diff --git a/src/bsk_rl/sim/dyn/__init__.py b/src/bsk_rl/sim/dyn/__init__.py index 3432ab96..17eab830 100644 --- a/src/bsk_rl/sim/dyn/__init__.py +++ b/src/bsk_rl/sim/dyn/__init__.py @@ -56,6 +56,7 @@ LOSCommDynModel, MaxRangeDynModel, ) +from bsk_rl.sim.dyn.rso_imaging import RSOTargetDynModel, SpaceToSpaceImagingDynModel from bsk_rl.sim.dyn.rso_inspection import RSODynModel, RSOInspectorDynModel @@ -87,4 +88,6 @@ def __init__(self, *args, **kwargs) -> None: "FullFeaturedDynModel", "RSODynModel", "RSOInspectorDynModel", + "RSOTargetDynModel", + "SpaceToSpaceImagingDynModel", ] diff --git a/src/bsk_rl/sim/dyn/rso_imaging.py b/src/bsk_rl/sim/dyn/rso_imaging.py new file mode 100644 index 00000000..a09cf32f --- /dev/null +++ b/src/bsk_rl/sim/dyn/rso_imaging.py @@ -0,0 +1,95 @@ +"""Dynamics for imaging independent spacecraft, rather than RSO surface points.""" + +import numpy as np +from Basilisk.simulation import spacecraftLocation + +from bsk_rl.sim.dyn.base import BasicDynamicsModel +from bsk_rl.sim.dyn.ground_imaging import ImagingDynModel +from bsk_rl.utils.functional import default_args + + +class RSOTargetDynModel(BasicDynamicsModel): + """Passive target retaining AMOS gravity, drag, attitude, and resource dynamics. + + Targets run before imagers so access does not depend on satellite list order. + Use ordinary ``Satellite`` and ``FSWModel`` with ``Drift``. The catalog fixes + wheel speeds and passive power draw; no artificial failure makes targets passive. + """ + + def __init__(self, satellite, dyn_rate, priority=250, **kwargs) -> None: + """Propagate after world ephemerides (300), before observer dynamics (200).""" + super().__init__(satellite, dyn_rate, priority=priority, **kwargs) + + +class SpaceToSpaceImagingDynModel(ImagingDynModel): + """Imager with existing instrument, storage, transmitter, and RSO access. + + The Earth ellipsoid matches the AMOS and existing RSO inspection approximation: + polar radius is 0.98 times equatorial radius. Range defaults to unlimited. + Target access uses spacecraft states. Ground stations are optional: compose + this model with :class:`~bsk_rl.sim.dyn.GroundStationDynModel` to enable + downlink through :class:`~bsk_rl.sim.world.GroundStationWorldModel`. + That component connects the spacecraft's transmitter to station access + messages; the ground stations themselves belong to the world model. + """ + + @default_args(instrumentBaudRate=8e6) + def setup_instrument(self, instrumentBaudRate, **kwargs) -> None: + """Use the existing one-tick instrument; its argument denotes image bits.""" + if not np.isfinite(instrumentBaudRate) or instrumentBaudRate <= 0: + raise ValueError("RSO image size must be finite and positive.") + super().setup_instrument(instrumentBaudRate=instrumentBaudRate, **kwargs) + + @default_args( + transmitterBaudRate=-8e6, + transmitterNumBuffers=100, + transmitterPacketSize=-1, + ) + def setup_transmitter(self, transmitterPacketSize, **kwargs) -> None: + """Allow a partially transmitted image to resume across ground contacts. + + The existing transmitter otherwise requires a full packet to restart after + contact loss. One-bit packets allow partial buffers to drain; the image ledger, + rather than transmitter packet size, defines complete image delivery. + """ + super().setup_transmitter(transmitterPacketSize=transmitterPacketSize, **kwargs) + + @default_args(imageTargetMaximumRange=-1) + def setup_imaging_target( + self, imageTargetMaximumRange: float, priority=1900, **kwargs + ) -> None: + """Create per-imager access; explicitly honor an optional range [m].""" + if imageTargetMaximumRange != -1 and ( + not np.isfinite(imageTargetMaximumRange) or imageTargetMaximumRange <= 0 + ): + raise ValueError("RSO imaging range must be -1 (unlimited) or positive.") + self.targetLocation = spacecraftLocation.SpacecraftLocation() + self.targetLocation.ModelTag = "rsoAccess" + self.satellite.name + self.targetLocation.primaryScStateInMsg.subscribeTo(self.scObject.scStateOutMsg) + self.targetLocation.planetInMsg.subscribeTo( + self.world.gravFactory.spiceObject.planetStateOutMsgs[self.world.body_index] + ) + self.targetLocation.rEquator = self.world.planet.radEquator + self.targetLocation.rPolar = self.world.planet.radEquator * 0.98 + self.targetLocation.maximumRange = imageTargetMaximumRange + self.rso_access_messages = {} + self.rso_partition_names = {} + self.simulator.AddModelToTask( + self.task_name, self.targetLocation, ModelPriority=priority + ) + + def bind_rso_target(self, target) -> None: + """Register a target and retain its actual access output, independent of ID.""" + if target.id in self.rso_access_messages: + raise ValueError(f"Duplicate access binding for {target.id!r}.") + self.targetLocation.addSpacecraftToModel( + target.target_spacecraft.dynamics.scObject.scStateOutMsg + ) + self.rso_access_messages[target.id] = self.targetLocation.accessOutMsgs[-1] + self.rso_partition_names[target.id] = ( + self.satellite.rso_scenario.partition_name(target.id) + ) + + +__doc_title__ = "Space-to-Space RSO Imaging" +__all__ = ["RSOTargetDynModel", "SpaceToSpaceImagingDynModel"] diff --git a/src/bsk_rl/sim/fsw/__init__.py b/src/bsk_rl/sim/fsw/__init__.py index 4426b1ed..c45e23b4 100644 --- a/src/bsk_rl/sim/fsw/__init__.py +++ b/src/bsk_rl/sim/fsw/__init__.py @@ -43,6 +43,7 @@ SteeringImagerFSWModel, ) from bsk_rl.sim.fsw.orbital import MagicOrbitalManeuverFSWModel +from bsk_rl.sim.fsw.rso_imaging import SpaceToSpaceImagingFSWModel from bsk_rl.sim.fsw.rso_inspection import RSOInspectorFSWModel __doc_title__ = "FSW Sims" @@ -57,4 +58,5 @@ "SteeringImagerFSWModel", "MagicOrbitalManeuverFSWModel", "RSOInspectorFSWModel", + "SpaceToSpaceImagingFSWModel", ] diff --git a/src/bsk_rl/sim/fsw/rso_imaging.py b/src/bsk_rl/sim/fsw/rso_imaging.py new file mode 100644 index 00000000..b196fb38 --- /dev/null +++ b/src/bsk_rl/sim/fsw/rso_imaging.py @@ -0,0 +1,315 @@ +"""Moving-target pointing with a physical hold-before-storage acquisition gate.""" + +from weakref import proxy + +import numpy as np +from Basilisk.architecture import messaging, sysModel +from Basilisk.utilities import macros + +from bsk_rl.sim.dyn.rso_imaging import SpaceToSpaceImagingDynModel +from bsk_rl.sim.fsw.base import Task, action +from bsk_rl.sim.fsw.ground_imaging import ImagingFSWModel +from bsk_rl.utils.functional import default_args +from bsk_rl.utils.rso_imaging import RSOImageRecord, illumination_factor + + +class _AcquisitionGate(sysModel.SysModel): + """Write a one-tick instrument command only after a valid sampled hold. + + Runs in the dynamics task before instrument and storage. Hold time counts an + interval only when both endpoints are valid. Guidance must have been written + after the task started and be no older than one FSW/dynamics sampling interval. + """ + + def __init__(self, fsw) -> None: + super().__init__() + self.fsw = proxy(fsw) + self.ModelTag = "rsoAcquisitionGate" + fsw.satellite.name + self.command = messaging.DeviceCmdMsg() + self.guidance = messaging.AttGuidMsgReader() + self.guidance.subscribeTo(fsw.attGuidMsg) + self.access = messaging.AccessMsgReader() + self.illumination = messaging.EclipseMsgReader() + self.records = [] + self.sequence = 0 + self.cancel() + + def Reset(self, current_time_ns) -> None: + self.cancel() + + def cancel(self) -> None: + """Disarm the physical instrument on cancellation, switching, or deadline.""" + if getattr(self, "pulse", None) is not None: + self.check_capture() + self.target = None + self.pulse = None + self.completed = False + self.held_s = 0.0 + self.illumination_integral = 0.0 + self.previous_valid = False + self.last_time_ns = None + self.previous_illumination = 0.0 + self.command.write(messaging.DeviceCmdMsgPayload()) + + def start( + self, + target, + duration, + hold_s, + hold_mode, + require_illumination, + illumination_threshold, + ) -> None: + self.cancel() + self.target = target + # Read the gateway header: addAuthor redirects writes to this message. + self.guidance.subscribeTo(self.fsw.attGuidMsg) + self.access.subscribeTo(self.fsw.dynamics.rso_access_messages[target.id]) + target_dyn = target.target_spacecraft.dynamics + self.illumination.subscribeTo( + target_dyn.world.eclipseObject.eclipseOutMsgs[target_dyn.eclipse_index] + ) + self.start_time_ns = self.fsw.simulator.sim_time_ns + self.deadline_ns = self.start_time_ns + macros.sec2nano(duration) + self.hold_s = hold_s + self.hold_mode = hold_mode + self.require_illumination = require_illumination + self.illumination_threshold = illumination_threshold + self.completed = False + + def _partition_level(self) -> float: + message = self.fsw.dynamics.storageUnit.storageUnitDataOutMsg.read() + name = self.fsw.dynamics.rso_partition_names[self.target.id] + return dict(zip(message.storedDataName, message.storedData)).get(name, 0.0) + + def check_capture(self) -> bool: + """Confirm storage contains the full physical image before publishing it.""" + if self.pulse is not None and not self.completed: + record, initial_level = self.pulse + if self._partition_level() - initial_level >= record.size_bits - 1e-6: + self.records.append(record) + self.completed = True + return bool(getattr(self, "completed", False)) + + def _valid(self, now_ns) -> tuple[bool, float]: + guidance = self.guidance + guidance_age = now_ns - guidance.timeWritten() + fresh_guidance = ( + guidance.isWritten() + and guidance.timeWritten() > self.start_time_ns + and 0 + <= guidance_age + <= macros.sec2nano(max(self.fsw.fsw_rate, self.fsw.dynamics.dyn_rate)) + ) + payload = guidance() + controller = self.fsw.insControl + pointing = np.linalg.norm(payload.sigma_BR) <= controller.attErrTolerance + if controller.useRateTolerance: + pointing = ( + pointing + and np.linalg.norm(payload.omega_BR_B) <= controller.rateErrTolerance + ) + maximum_age = macros.sec2nano(self.fsw.dynamics.dyn_rate) + access_age = now_ns - self.access.timeWritten() + illumination_age = now_ns - self.illumination.timeWritten() + illumination = illumination_factor(self.illumination()) + illuminated = ( + not self.require_illumination or illumination >= self.illumination_threshold + ) + return bool( + fresh_guidance + and pointing + and self.access.isWritten() + and 0 <= access_age <= maximum_age + and self.access().hasAccess + and self.illumination.isWritten() + and 0 <= illumination_age <= maximum_age + and np.isfinite(illumination) + and 0 <= illumination <= 1 + and illuminated + ), illumination + + def UpdateState(self, current_time_ns) -> None: + now = current_time_ns * macros.NANO2SEC + self.command.write( + messaging.DeviceCmdMsgPayload(), current_time_ns, self.moduleID + ) + if self.target is None: + return + if self.pulse is not None: + self.check_capture() + return + if current_time_ns >= self.deadline_ns: + self.cancel() + return + valid, illumination = self._valid(current_time_ns) + if self.last_time_ns is not None and valid and self.previous_valid: + dt = (current_time_ns - self.last_time_ns) * macros.NANO2SEC + self.held_s += dt + self.illumination_integral += ( + 0.5 * (illumination + self.previous_illumination) * dt + ) + elif not valid and self.hold_mode == "continuous": + self.held_s = 0.0 + self.illumination_integral = 0.0 + self.last_time_ns = current_time_ns + self.previous_valid = valid + self.previous_illumination = illumination + if not valid or self.held_s + 1e-9 < self.hold_s: + return + dynamics = self.fsw.dynamics + size = dynamics.instrument.nodeBaudRate * dynamics.dyn_rate + if dynamics.storageUnit.storageCapacity - dynamics.storage_level < size - 1e-6: + return + quality = ( + self.illumination_integral / self.held_s + if self.held_s > 0 + else illumination + ) + self.sequence += 1 + record = RSOImageRecord( + f"{self.fsw.satellite.name}:{self.sequence}", + self.target.id, + self.fsw.satellite.name, + now, + size, + quality, + self.held_s, + ) + self.pulse = (record, self._partition_level()) + payload = messaging.DeviceCmdMsgPayload() + payload.deviceCmd = 1 + self.command.write(payload, current_time_ns, self.moduleID) + + +class SpaceToSpaceImagingFSWModel(ImagingFSWModel): + """Point at an RSO spacecraft and gate acquisition before physical storage. + + ``rso_capture`` temporarily evaluates sampled pointing, access, illumination, + and hold constraints in the dynamics task, before instrument and storage. + Continuous holds reset on a constraint break; cumulative holds pause. A zero + hold still requires fresh valid guidance, and the action deadline is exclusive. + + The gate uses the existing Basilisk message API and confirms each full stored + image once. It will be replaced by ``simpleInstrumentController`` when the + corresponding upstream extension is available. This migration is tracked in + `BSK-RL #358 `_ and + `Basilisk #1602 `_. + """ + + @classmethod + def _requires_dyn(cls): + return super()._requires_dyn() + [SpaceToSpaceImagingDynModel] + + 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 + ) + for sensor in getattr(self.satellite, "vizard_data", {}).get( + "genericSensorList", [] + ): + command = messaging.DeviceCmdMsgReader() + command.subscribeTo(self.rso_capture.command) + sensor.genericSensorCmdInMsg = command + + class LocPointTask(ImagingFSWModel.LocPointTask): + """Moving-target guidance with tolerances and an independent acquisition gate.""" + + @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.""" + self.locPoint.pHat_B = inst_pHat_B + self.locPoint.scAttInMsg.subscribeTo( + self.fsw.dynamics.simpleNavObject.attOutMsg + ) + self.locPoint.scTransInMsg.subscribeTo( + self.fsw.dynamics.simpleNavObject.transOutMsg + ) + # A harmless initial binding permits Basilisk initialization. Imaging + # replaces it with the explicitly selected target before acquisition. + self.locPoint.scTargetInMsg.subscribeTo( + self.fsw.dynamics.simpleNavObject.transOutMsg + ) + self.locPoint.useBoresightRateDamping = 1 + messaging.AttGuidMsg_C_addAuthor( + self.locPoint.attGuidOutMsg, self.fsw.attGuidMsg + ) + messaging.AttRefMsg_C_addAuthor( + self.locPoint.attRefOutMsg, self.fsw.attRefMsg + ) + self._add_model_to_task(self.locPoint, priority=1198) + + @default_args(imageAttErrorRequirement=0.01, imageRateErrorRequirement=None) + def setup_instrument_controller( + self, imageAttErrorRequirement, imageRateErrorRequirement, **kwargs + ) -> None: + """Configure tolerances; acquisition gate owns the instrument command.""" + if ( + not np.isfinite(imageAttErrorRequirement) + or imageAttErrorRequirement < 0 + ): + raise ValueError("Pointing tolerance must be finite and nonnegative.") + if imageRateErrorRequirement is not None and ( + not np.isfinite(imageRateErrorRequirement) + or imageRateErrorRequirement < 0 + ): + raise ValueError("Rate tolerance must be finite and nonnegative.") + self.insControl.attErrTolerance = imageAttErrorRequirement + self.insControl.useRateTolerance = int( + imageRateErrorRequirement is not None + ) + if imageRateErrorRequirement is not None: + self.insControl.rateErrTolerance = imageRateErrorRequirement + # The inherited controller supplies tolerance configuration; + # the physical instrument subscribes exclusively to the hold gate. + self.insControl.controllerStatus = 0 + + def reset_for_action(self) -> None: + """Clear an old acquisition before any FSW mode change.""" + self.fsw.rso_capture.check_capture() + self.fsw.rso_capture.cancel() + self.locPoint.Reset(self.fsw.simulator.sim_time_ns) + self.insControl.controllerStatus = 0 + return Task.reset_for_action(self) + + @action + def action_image_rso( + self, + target, + duration=300.0, # [s] + hold_s=10.0, # [s] + hold_mode="cumulative", + require_illumination=True, + illumination_threshold=0.5, # [-] + ) -> None: + """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( + target.target_spacecraft.dynamics.simpleNavObject.transOutMsg + ) + self.dynamics.instrument.nodeDataName = self.dynamics.rso_partition_names[ + target.id + ] + self.dynamics.instrumentPowerSink.powerStatus = 1 + self.rso_capture.start( + target, + duration, + hold_s, + hold_mode, + require_illumination, + illumination_threshold, + ) + self.simulator.enableTask(self.LocPointTask.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.") + + +__doc_title__ = "Space-to-Space RSO Imaging" +__all__ = ["SpaceToSpaceImagingFSWModel"] diff --git a/src/bsk_rl/utils/rso_imaging.py b/src/bsk_rl/utils/rso_imaging.py new file mode 100644 index 00000000..23a9f3f7 --- /dev/null +++ b/src/bsk_rl/utils/rso_imaging.py @@ -0,0 +1,108 @@ +"""Shared contracts for RSO imaging execution and candidate observations.""" + +from dataclasses import dataclass + +import numpy as np +from Basilisk.architecture import messaging + + +@dataclass(frozen=True) +class RSOImageRecord: + """Provenance of one physically stored image (sizes are in bits).""" + + record_id: str + target_id: str + source_imager: str + capture_time: float + size_bits: float + quality: float + hold_valid_time_s: float + delivery_time: float | None = None + + +@dataclass(frozen=True) +class CandidateSnapshot: + """One imager's ordered, possibly padded decision candidates.""" + + time: float + revision: int + targets: tuple + + +def target_geometry(imager, target): + """Return relative position [m], distance [m], and elevation [deg].""" + position = np.asarray(imager.dynamics.r_BN_N) + relative = np.asarray(target.target_spacecraft.dynamics.r_BN_N) - position + distance = float(np.linalg.norm(relative)) + elevation = float( + np.degrees( + np.arcsin( + np.clip( + relative @ position / (distance * np.linalg.norm(position)), + -1, + 1, + ) + ) + ) + ) + return relative, distance, elevation + + +def candidate_snapshot(imager, count: int) -> CandidateSnapshot: + """Share one selection between observations and action decoding. + + Retain AMOS ascending elevation in [-21, 90] degrees, then nearest-distance + fill. Catalog order breaks ties. Empty slots are None, never duplicated targets. + Physical access is evaluated separately by the acquisition gate. + """ + scene = imager.rso_scenario + now = float(imager.simulator.sim_time) + cached = getattr(imager, "_rso_candidate_snapshot", None) + if ( + cached is not None + and cached.time == now + and cached.revision == scene.revision + and len(cached.targets) >= count + ): + return cached + # Include all selectable slots even when an observation requests fewer rows. + from bsk_rl.act.rso_imaging import ImageRSO + + actions = getattr(getattr(imager, "action_builder", None), "action_spec", ()) + count = max( + [ + count, + *(action.n_actions for action in actions if isinstance(action, ImageRSO)), + ] + ) + rows = [] + for index, target in enumerate(scene.targets_by_id.values()): + _, distance, elevation = target_geometry(imager, target) + if scene.is_eligible(target.id, now): + rows.append((target, elevation, distance, index)) + visible = sorted( + (row for row in rows if -21 <= row[1] <= 90), key=lambda row: (row[1], row[3]) + ) + selected = [row[0] for row in visible[:count]] + selected_ids = {target.id for target in selected} + remaining = sorted( + (row for row in rows if row[0].id not in selected_ids), + key=lambda row: (row[2], row[3]), + ) + selected.extend(row[0] for row in remaining[: count - len(selected)]) + selected.extend([None] * (count - len(selected))) + snapshot = CandidateSnapshot(now, scene.revision, tuple(selected)) + imager._rso_candidate_snapshot = snapshot + return snapshot + + +__all__ = [] + + +def illumination_factor(payload): + """Read Basilisk's illuminated fraction: zero is dark, one is fully lit. + + Use the standard payload accessor because another SWIG module can return + an equivalent payload wrapper without the Python alias on its own class. + """ + return float(messaging.EclipseMsgPayload.illuminationFactor.fget(payload)) diff --git a/tests/integration/act/test_int_actions_discrete.py b/tests/integration/act/test_int_actions_discrete.py index ba8e392e..a79d5fa0 100644 --- a/tests/integration/act/test_int_actions_discrete.py +++ b/tests/integration/act/test_int_actions_discrete.py @@ -29,7 +29,7 @@ class ImageSat(sats.ImagingSatellite): env = gym.make( "SatelliteTasking-v1", satellite=ImageSat( - "EO-1", + "EO_1", sat_args=ImageSat.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.05, @@ -214,7 +214,7 @@ class ImageSat(sats.Satellite): env = gym.make( "SatelliteTasking-v1", satellite=ImageSat( - "EO-1", + "EO_1", sat_args=ImageSat.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.05, diff --git a/tests/integration/comm/test_int_communication.py b/tests/integration/comm/test_int_communication.py index c01ca045..cc3e1903 100644 --- a/tests/integration/comm/test_int_communication.py +++ b/tests/integration/comm/test_int_communication.py @@ -47,7 +47,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): def make_communication_env(oes, comm_type): satellites = [ FullFeaturedSatellite( - "EO-1", + "EO_1", sat_args=FullFeaturedSatellite.default_sat_args( oe=oe, imageAttErrorRequirement=0.05, diff --git a/tests/integration/data/test_int_data.py b/tests/integration/data/test_int_data.py index 9ca2f882..6d79cced 100644 --- a/tests/integration/data/test_int_data.py +++ b/tests/integration/data/test_int_data.py @@ -28,7 +28,7 @@ def test_multi_rewarder(): "GeneralSatelliteTasking-v1", satellites=[ FullFeaturedSatellite( - "Sentinel-2A", + "Sentinel_2A", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -36,7 +36,7 @@ def test_multi_rewarder(): ), ), FullFeaturedSatellite( - "Sentinel-2B", + "Sentinel_2B", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, diff --git a/tests/integration/obs/test_int_observations.py b/tests/integration/obs/test_int_observations.py index 78520b1f..d0999f4a 100644 --- a/tests/integration/obs/test_int_observations.py +++ b/tests/integration/obs/test_int_observations.py @@ -2,7 +2,7 @@ import gymnasium as gym import numpy as np -from pytest import approx +from pytest import approx, fixture from bsk_rl import act, data, obs, sats from bsk_rl.scene import UniformTargets @@ -31,7 +31,7 @@ class ComposedPropSat(sats.ImagingSatellite): env = gym.make( "SatelliteTasking-v1", satellite=ComposedPropSat( - "Explorer 1", + "Explorer_1", sat_args=ComposedPropSat.default_sat_args(oe=random_orbit), ), scenario=UniformTargets(n_targets=1000), @@ -157,26 +157,62 @@ class EclipseSat(sats.Satellite): observation_spec: ClassVar[list[obs.Observation]] = [obs.Eclipse()] action_spec: ClassVar[list[act.Action]] = [act.Drift()] - env = gym.make( - "SatelliteTasking-v1", - satellite=EclipseSat( - "PinkFloyd", - obs_type=list, - sat_args=EclipseSat.default_sat_args(oe=random_orbit()), - ), - scenario=UniformTargets(n_targets=0), - rewarder=data.NoReward(), - sim_rate=1.0, - max_step_duration=5700.0, - time_limit=5800.0, - disable_env_checker=True, - ) - - def test_eclipse_state(self): - observation1, _ = self.env.reset() - observation2, _reward, _terminated, _truncated, _info = self.env.step(0) - assert (observation2[0] - observation1[0]) < 0.05 - assert (observation2[1] - observation1[1]) < 0.05 + @fixture + def env(self): + # This orbit starts just before eclipse and reproduces the old failure + # after a 5700-second step, when the next ingress moves to another orbit. + env = gym.make( + "SatelliteTasking-v1", + satellite=self.EclipseSat( + "PinkFloyd", + obs_type=list, + sat_args=self.EclipseSat.default_sat_args( + oe=random_orbit(i=45, Omega=0, omega=0, f=34) + ), + ), + scenario=UniformTargets(n_targets=0), + rewarder=data.NoReward(), + world_args=dict(utc_init="2025 JAN 01 00:00:00.000 (UTC)"), + sim_rate=1.0, + max_step_duration=10.0, + time_limit=5800.0, + disable_env_checker=True, + ) + yield env + env.close() + + def test_eclipse_state(self, env): + observation1, _ = env.reset(seed=0) + assert min(observation1) > env.unwrapped.max_step_duration + observation2, _reward, _terminated, _truncated, _info = env.step(0) + elapsed = env.unwrapped.simulator.sim_time + assert elapsed == approx(10.0) + assert observation2 == approx(np.asarray(observation1) - elapsed) + + def test_eclipse_state_rolls_over_after_ingress(self, env): + observation1, _ = env.reset(seed=0) + # Cross ingress but remain before the first egress. The egress countdown + # continues, while ingress now refers to the following orbit. + env.unwrapped.simulator.max_step_duration = observation1[0] + 30.0 + observation2, _reward, _terminated, _truncated, _info = env.step(0) + elapsed = env.unwrapped.simulator.sim_time + assert observation1[0] < elapsed < observation1[1] + assert observation2[0] + elapsed > observation1[1] + assert observation2[1] == approx(observation1[1] - elapsed) + + def test_eclipse_state_after_full_orbit(self, env): + env.unwrapped.max_step_duration = 5700.0 + observation1, _ = env.reset(seed=0) + observation2, _reward, _terminated, _truncated, _info = env.step(0) + elapsed = env.unwrapped.simulator.sim_time + assert elapsed == approx(5700.0) + # Start and end are the next independent transitions, not necessarily + # the same eclipse. Their absolute times must advance past this step. + assert np.all(np.isfinite(observation2)) + assert min(observation2) > 0 + assert observation2[0] > observation1[0] + assert observation2[0] + elapsed > observation1[0] + assert observation2[1] + elapsed > observation1[1] class TestGroundStationProperties: diff --git a/tests/integration/sats/test_int_satellites.py b/tests/integration/sats/test_int_satellites.py index 49188206..b9a9d218 100644 --- a/tests/integration/sats/test_int_satellites.py +++ b/tests/integration/sats/test_int_satellites.py @@ -21,7 +21,7 @@ class ImageSat(sats.ImagingSatellite): env = gym.make( "SatelliteTasking-v1", satellite=ImageSat( - "EO-1", + "EO_1", initial_generation_duration=1000.0, generation_duration=100.0, sat_args=ImageSat.default_sat_args( diff --git a/tests/integration/scene/test_int_scenarios.py b/tests/integration/scene/test_int_scenarios.py index f820b707..5af0740a 100644 --- a/tests/integration/scene/test_int_scenarios.py +++ b/tests/integration/scene/test_int_scenarios.py @@ -18,7 +18,7 @@ class ImageSat(sats.ImagingSatellite): env = gym.make( "SatelliteTasking-v1", satellite=ImageSat( - "EO-1", + "EO_1", sat_args=ImageSat.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.05, diff --git a/tests/integration/sim/test_int_dynamics.py b/tests/integration/sim/test_int_dynamics.py index 75624df9..a73aa87a 100644 --- a/tests/integration/sim/test_int_dynamics.py +++ b/tests/integration/sim/test_int_dynamics.py @@ -36,7 +36,7 @@ class ImageSat(sats.ImagingSatellite): env = gym.make( "SatelliteTasking-v1", satellite=ImageSat( - "EO-1", + "EO_1", sat_args=ImageSat.default_sat_args( oe=random_orbit, dataStorageCapacity=storage_capacity, @@ -76,7 +76,7 @@ class ImageSat(sats.ImagingSatellite): env = gym.make( "SatelliteTasking-v1", satellite=ImageSat( - "EO-1", + "EO_1", sat_args=ImageSat.default_sat_args( oe=random_orbit, dataStorageCapacity=storage_capacity, diff --git a/tests/integration/test_int_full_environments.py b/tests/integration/test_int_full_environments.py index 87245669..c178eb96 100644 --- a/tests/integration/test_int_full_environments.py +++ b/tests/integration/test_int_full_environments.py @@ -22,7 +22,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): "GeneralSatelliteTasking-v1", satellites=[ FullFeaturedSatellite( - "Sentinel-2A", + "Sentinel_2A", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -30,7 +30,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): ), ), FullFeaturedSatellite( - "Sentinel-2B", + "Sentinel_2B", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -49,7 +49,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): parallel_env = ConstellationTasking( satellites=[ FullFeaturedSatellite( - "Sentinel-2A", + "Sentinel_2A", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -57,7 +57,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): ), ), FullFeaturedSatellite( - "Sentinel-2B", + "Sentinel_2B", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -75,7 +75,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): parallel_meta_env = ConstellationTasking( satellites=[ FullFeaturedSatellite( - "Sentinel-2A", + "Sentinel_2A", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -83,7 +83,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): ), ), FullFeaturedSatellite( - "Sentinel-2B", + "Sentinel_2B", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -91,7 +91,7 @@ class FullFeaturedSatellite(sats.ImagingSatellite): ), ), FullFeaturedSatellite( - "Sentinel-2C", + "Sentinel_2C", sat_args=FullFeaturedSatellite.default_sat_args( oe=random_orbit, imageAttErrorRequirement=0.01, @@ -105,8 +105,8 @@ class FullFeaturedSatellite(sats.ImagingSatellite): max_step_duration=1e9, time_limit=5700.0, meta_agent_groupings={ - "DoubleSat": ["Sentinel-2A", "Sentinel-2C"], - "SingleSat": ["Sentinel-2B"], + "DoubleSat": ["Sentinel_2A", "Sentinel_2C"], + "SingleSat": ["Sentinel_2B"], }, ) diff --git a/tests/integration/test_int_gym_env.py b/tests/integration/test_int_gym_env.py index dbc4c9b1..8e4a7790 100644 --- a/tests/integration/test_int_gym_env.py +++ b/tests/integration/test_int_gym_env.py @@ -102,11 +102,11 @@ class TestGeneralSatelliteTasking: "GeneralSatelliteTasking-v1", satellites=[ DoNothingSatellite( - "Sentinel-2A", + "Sentinel_2A", sat_args=DoNothingSatellite.default_sat_args(oe=random_orbit), ), DoNothingSatellite( - "Sentinel-2B", + "Sentinel_2B", sat_args=DoNothingSatellite.default_sat_args(oe=random_orbit), ), ], diff --git a/tests/integration/test_int_rso_imaging_env.py b/tests/integration/test_int_rso_imaging_env.py new file mode 100644 index 00000000..59b20be4 --- /dev/null +++ b/tests/integration/test_int_rso_imaging_env.py @@ -0,0 +1,585 @@ +"""Real Basilisk storage/hold, delivery, multi-imager identity, and reset contracts.""" + +from dataclasses import replace +from typing import ClassVar + +import numpy as np +import pytest + +from bsk_rl import ConstellationTasking, act, data, obs, sats, scene +from bsk_rl.sim import dyn, fsw +from bsk_rl.utils.rso_imaging import illumination_factor + +EPOCH = "2026 OCT 01 00:00:00.000 (UTC)" + + +class Camera(sats.Satellite): + dyn_type = (dyn.SpaceToSpaceImagingDynModel, dyn.GroundStationDynModel) + fsw_type = fsw.SpaceToSpaceImagingFSWModel + observation_spec: ClassVar[list[obs.Observation]] = [ + obs.SatProperties(dict(prop="storage_level")), + obs.RSOTargetProperties( + dict(prop="valid"), + dict(prop="priority"), + dict(prop="target_distance"), + dict(prop="target_illumination_factor"), + n_ahead_observe=2, + ), + ] + action_spec: ClassVar[list[act.Action]] = [ + act.ImageRSO( + 2, + max_duration=30, + min_pointing_hold_s=10, + require_illumination_during_hold=False, + ), + act.Downlink(duration=30), + 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)] + + +class ReorderedScene(scene.RSOTargets): + def reset_during_sim_init(self): + for observer in self.imagers: + targets = list(self.targets_by_id.values()) + if observer.name == "camera_b": + targets.reverse() + for target in targets: + observer.dynamics.bind_rso_target(target) + + +def make_env( + two=False, + reverse=False, + variable=True, + hold_s=10, + illumination=False, + events=(), + composed=False, + image_size=1000, + sim_rate=1, +): + definitions = ( + scene.RSOTarget("83", "debris", (7e6 + 1000, 0, 0), (0, 7500, 0), priority=2), + scene.RSOTarget( + "rso/209", "uncooperative", (7e6 + 2000, 0, 0), (0, 7500, 0), priority=1 + ), + ) + catalog = scene.RSOTargetCatalog(definitions, EPOCH, events) + names = ["camera_a", "camera_b"] if two else ["camera_a"] + cameras = [ + Camera( + name, + 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=image_size, + transmitterBaudRate=-100, + batteryStorageCapacity=1e9, + storedCharge_Init=1e9, + ), + ) + for name in names + ] + for camera in cameras: + imaging = camera.action_builder.action_spec[0] + imaging.variable_duration_imaging = variable + imaging.min_pointing_hold_s = hold_s + imaging.require_illumination_during_hold = illumination + satellites = [PassiveTarget("debris"), *cameras, PassiveTarget("uncooperative")] + if reverse: + satellites.reverse() + rewarder = data.RSOImageReward( + multi_imager_credit="shared", + quality_threshold=0, + acquisition_reward_fn=lambda record, target: 0.9 * target.priority, + delivery_reward_fn=lambda record, target: 0.1 * target.priority, + ) + if composed: + resource = data.ResourceReward( + reward_weight=0, + resource_fn=lambda sat: sat.dynamics.battery_charge_fraction + if hasattr(sat.dynamics, "battery_charge_fraction") + else 0, + ) + rewarder = (resource, rewarder) + return ConstellationTasking( + satellites=satellites, + scenario=ReorderedScene(catalog, names), + rewarder=rewarder, + world_args=dict(utc_init=EPOCH, gsMinimumElevation=np.radians(-90)), + time_limit=120, + max_step_duration=1, + sim_rate=sim_rate, + log_level="ERROR", + ) + + +def camera(env, name="camera_a"): + return next(sat for sat in env.satellites if sat.name == name) + + +def actions(env, imager_action=None, initial=False): + return { + sat.name: imager_action + if sat.name in env.scenario.imager_names + else (0 if initial else None) + for sat in env.satellites + } + + +def rso_store(satellite): + store = satellite.data_store + if hasattr(store, "data_stores"): + return next( + component + for component in store.data_stores + if isinstance(component, data.RSOImageStore) + ) + return store + + +def test_spacecraft_imaging_runs_without_ground_stations(): + class ImagingOnlyCamera(sats.Satellite): + dyn_type = dyn.SpaceToSpaceImagingDynModel + fsw_type = fsw.SpaceToSpaceImagingFSWModel + observation_spec: ClassVar[list[obs.Observation]] = [obs.RSOTargetProperties()] + action_spec: ClassVar[list[act.Action]] = [ + act.ImageRSO( + 2, min_pointing_hold_s=0, require_illumination_during_hold=False + ) + ] + + template = make_env() + scanner = ImagingOnlyCamera( + "camera_a", sat_args=camera(template).sat_args_generator + ) + env = ConstellationTasking( + satellites=[PassiveTarget("debris"), scanner, PassiveTarget("uncooperative")], + scenario=scene.RSOTargets(template.scenario.catalog, [scanner.name]), + rewarder=data.RSOImageReward(quality_threshold=0), + world_args=dict(utc_init=EPOCH), + time_limit=30, + max_step_duration=10, + log_level="ERROR", + ) + try: + env.reset(seed=0) + assert not hasattr(env.simulator.world, "groundStations") + env.step(actions(env, "83", True)) + observer = camera(env) + assert observer.dynamics.storage_level == 1000 + assert len(observer.fsw.rso_capture.records) == 1 + assert observer.dynamics.instrument.nodeStatusInMsg.isLinked() + finally: + env.close() + + +@pytest.mark.parametrize("use_default_epoch", [False, True]) +def test_unpinned_catalog_follows_realized_environment_epoch_on_each_reset( + use_default_epoch, tmp_path +): + env = make_env() + definition = replace(env.scenario.catalog, utc_init=None) + env.scenario.catalog = definition + if use_default_epoch: + env.world_args_generator = env.world_type.default_world_args() + else: + epochs = iter((EPOCH, "2026 OCT 02 00:00:00.000 (UTC)")) + env.world_args_generator["utc_init"] = lambda: next(epochs) + try: + snapshots = [] + for seed in (0, 1): + env.reset(seed=seed) + realized = env.scenario.catalog + assert realized.utc_init == env.world_args["utc_init"] + assert realized.utc_init == env.scenario.utc_init + assert realized.targets == definition.targets + assert definition.utc_init is None + realized.save(tmp_path / f"catalog_{seed}.json") + assert ( + scene.RSOTargetCatalog.load(tmp_path / f"catalog_{seed}.json") + == realized + ) + snapshots.append(realized) + assert snapshots[0].utc_init != snapshots[1].utc_init + assert env.scenario.catalog is snapshots[1] + finally: + env.close() + + +@pytest.mark.parametrize("variable", [False, True]) +@pytest.mark.parametrize("sim_rate", [0.5, 1.0]) +def test_real_storage_stays_empty_until_hold_and_captures_once(variable, sim_rate): + env = make_env(variable=variable, sim_rate=sim_rate) + env.reset(seed=0) + observer = camera(env) + levels = [] + env.step(actions(env, 0, True)) + for _ in range(18): + levels.append((env.simulator.sim_time, observer.dynamics.storage_level)) + env.step(actions(env)) + record = observer.fsw.rso_capture.records[0] + assert record.hold_valid_time_s >= 10 + assert all(level == 0 for time, level in levels if time < record.capture_time) + assert record.capture_time >= 10 + assert observer.dynamics.storage_level == 1000 + assert len(observer.fsw.rso_capture.records) == 1 + assert rso_store(observer).remaining_bits[record.record_id] == 1000 + env.close() + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_two_imagers_distinct_access_indices_and_credit(reverse): + env = make_env(two=True, reverse=reverse) + env.reset(seed=0) + env.step(actions(env, 0, True)) + for _ in range(15): + env.step(actions(env)) + assert sum(env.rewarder.cum_reward.values()) == pytest.approx(1.8) + for name in env.scenario.imager_names: + observer = camera(env, name) + record = observer.fsw.rso_capture.records[0] + assert record.target_id == "83" + assert record.source_imager == name + assert env.rewarder.cum_reward[name] == pytest.approx(0.9) + assert observer.dynamics.rso_access_messages["83"].read().hasAccess == 1 + assert env.scenario.pending["83"] == {"camera_a:1", "camera_b:1"} + env.close() + + +def test_real_partial_downlink_contact_interruption_and_complete_delivery(): + env = make_env(composed=True) + env.reset(seed=0) + env.step(actions(env, 0, True)) + for _ in range(12): + env.step(actions(env)) + observer = camera(env) + store = rso_store(observer) + assert len(store.products) == 1 + env.step(actions(env, 2)) + env.step(actions(env)) + env.step(actions(env)) + assert 0 < observer.dynamics.storage_level < 1000 + assert not store.data.deliveries + env.step(actions(env, 3)) + env.step(actions(env)) + env.step(actions(env, 2)) + for _ in range(20): + env.step(actions(env)) + if store.data.deliveries: + break + assert len(store.data.deliveries) == 1 + assert not store.products + assert observer.dynamics.storage_level == 0 + assert env.rewarder.cum_reward["camera_a"] == pytest.approx(2.0) + env.close() + + +def test_timeout_cancellation_and_illumination_gate(): + for illumination, hold_s in [(False, 100), (True, 10)]: + env = make_env(illumination=illumination, hold_s=hold_s) + env.reset(seed=0) + env.step(actions(env, 0, True)) + for _ in range(6): + env.step(actions(env)) + env.step(actions(env, 3)) + assert camera(env).dynamics.storage_level == 0 + assert camera(env).fsw.rso_capture.target is None + env.close() + + +def test_repeat_reset_and_priority_boundary_ordering(): + events = ( + scene.RSOPriorityEvent(0, (("83", 3),)), + scene.RSOPriorityEvent(5, (("83", 7),)), + ) + env = make_env(events=events) + for _ in range(2): + initial, _ = env.reset(seed=0) + assert env.scenario.targets_by_id["83"].priority == 3 + assert env.scenario.pending == {} + assert camera(env).fsw.rso_capture.records == [] + assert initial["camera_a"][2] == 3 + env.step(actions(env, 0, True)) + for _ in range(15): + env.step(actions(env)) + assert env.scenario.targets_by_id["83"].priority == 7 + assert env.scenario.applied_events == [(0, 0.0), (1, 5.0)] + assert env.rewarder.cum_reward["camera_a"] == pytest.approx(6.3) + env.close() + + +@pytest.mark.parametrize("mode", ["continuous", "cumulative"]) +def test_real_access_interruption_resets_only_continuous_hold(mode): + env = make_env() + camera(env).action_builder.action_spec[0].hold_mode = mode + env.reset(seed=0) + observer = camera(env) + env.step(actions(env, 0, True)) + for _ in range(5): + env.step(actions(env)) + held_before = observer.fsw.rso_capture.held_s + assert held_before > 0 + observer.dynamics.targetLocation.maximumRange = 1 + for _ in range(3): + env.step(actions(env)) + assert observer.dynamics.storage_level == 0 + assert observer.fsw.rso_capture.held_s == ( + 0 if mode == "continuous" else held_before + ) + observer.dynamics.targetLocation.maximumRange = -1 + for _ in range(20): + env.step(actions(env)) + if observer.fsw.rso_capture.records: + break + record = observer.fsw.rso_capture.records[0] + assert record.hold_valid_time_s >= 10 + assert record.capture_time >= (19 if mode == "continuous" else 15) + assert observer.dynamics.storage_level == record.size_bits + env.close() + + +def test_switch_timeout_and_zero_hold_are_physical(): + env = make_env() + env.reset(seed=0) + env.step(actions(env, 0, True)) + for _ in range(6): + env.step(actions(env)) + env.step(actions(env, "rso/209")) + assert camera(env).fsw.rso_capture.target.id == "rso/209" + assert camera(env).fsw.rso_capture.held_s == 0 + for _ in range(14): + env.step(actions(env)) + records = camera(env).fsw.rso_capture.records + assert [record.target_id for record in records] == ["rso/209"] + assert records[0].capture_time >= 17 + env.close() + + env = make_env(hold_s=100) + env.reset(seed=0) + env.step(actions(env, 0, True)) + for _ in range(35): + env.step(actions(env)) + assert camera(env).fsw.rso_capture.target is None + assert camera(env).dynamics.storage_level == 0 + env.close() + + env = make_env(hold_s=0) + env.reset(seed=0) + env.step(actions(env, 0, True)) + assert camera(env).dynamics.storage_level == 0 # no preceding valid guidance + for _ in range(4): + env.step(actions(env)) + record = camera(env).fsw.rso_capture.records[0] + assert record.hold_valid_time_s == 0 + assert camera(env).dynamics.storage_level == 1000 + env.close() + + +def test_real_illumination_and_capacity_block_storage(): + env = make_env(illumination=True) + env.reset(seed=0) + observer = camera(env) + target_dyn = env.scenario.targets_by_id["83"].target_spacecraft.dynamics + env.step(actions(env, 0, True)) + for _ in range(20): + env.step(actions(env)) + assert ( + illumination_factor( + target_dyn.world.eclipseObject.eclipseOutMsgs[ + target_dyn.eclipse_index + ].read() + ) + == 0 + ) + assert observer.dynamics.storage_level == 0 + env.close() + + env = make_env() + env.reset(seed=0) + observer = camera(env) + observer.dynamics.storageUnit.storageCapacity = 500 + env.step(actions(env, 0, True)) + for _ in range(15): + env.step(actions(env)) + assert observer.dynamics.storage_level == 0 + assert observer.fsw.rso_capture.records == [] + env.close() + + +def test_public_example_and_manifest_replay(tmp_path): + import runpy + from pathlib import Path + + example = runpy.run_path( + str(Path(__file__).parents[2] / "examples/space_to_space_rso_imaging_demo.py") + ) + result = example["run_demo"](tmp_path) + assert result["captures"] == result["deliveries"] == 1 + catalog = scene.RSOTargetCatalog.load(tmp_path / "catalog.json") + env = example["build_environment"](catalog, amos_profile=True) + initial, _ = env.reset(seed=0) + assert initial["imager"].shape == (124,) + assert camera(env, "imager").action_space.n == 13 + manifest = example["mission_manifest"](env) + env.close() + replay = example["replay_environment"](manifest) + + # Insert unrelated draws into reset's real path before satellite generation. + def extra_draws(satellites): + np.random.random(200) + return {} + + replay.sat_arg_randomizer = extra_draws + repeated, _ = replay.reset(seed=999) + np.testing.assert_array_equal(initial["imager"], repeated["imager"]) + assert replay.scenario.catalog == catalog + for satellite in replay.satellites: + assert ( + satellite.sat_args["rN"] == manifest["satellite_args"][satellite.name]["rN"] + ) + replay.close() + + +def test_priority_event_follows_reward_at_the_same_boundary(): + event = scene.RSOPriorityEvent(5, (("83", 7),)) + env = make_env(hold_s=3, events=(event,)) + env.reset(seed=0) + env.step(actions(env, 0, True)) + for _ in range(4): + env.step(actions(env)) + assert env.simulator.sim_time == 5 + assert camera(env).fsw.rso_capture.records[0].capture_time == 5 + assert env.rewarder.cum_reward["camera_a"] == pytest.approx(1.8) + assert env.scenario.targets_by_id["83"].priority == 7 + assert env.scenario.applied_events == [(0, 5.0)] + env.close() + + +def test_multiple_partition_completions_in_one_physical_step(): + env = make_env(hold_s=0) + env.reset(seed=0) + env.step(actions(env, "83", True)) + for _ in range(3): + env.step(actions(env)) + env.step(actions(env, "rso/209")) + for _ in range(3): + env.step(actions(env)) + observer = camera(env) + assert observer.dynamics.storage_level == 2000 + assert len(observer.data_store.products) == 2 + env.simulator.max_step_duration = 30 + _, reward, _, _, _ = env.step(actions(env, 2)) + assert observer.dynamics.storage_level == 0 + assert len(observer.data_store.data.deliveries) == 2 + assert reward["camera_a"] == pytest.approx(0.3) + env.close() + + +def test_multiple_imagers_keep_independent_deadlines(): + env = make_env(two=True, hold_s=100) + env.max_step_duration = 30 + try: + env.reset(seed=0) + env.step(actions(env, 0, True)) + names = [sat._timed_terminal_event_name for sat in env.scenario.imagers] + assert set(names) == {"timed_terminal_camera_a", "timed_terminal_camera_b"} + assert all(sat.requires_retasking for sat in env.scenario.imagers) + assert all(sat.fsw.rso_capture.target is None for sat in env.scenario.imagers) + finally: + env.close() + + +def test_earth_occlusion_prevents_acquisition_of_an_explicitly_tasked_target(): + from dataclasses import replace + + env = make_env(hold_s=0) + catalog = env.scenario.catalog + opposite = replace(catalog.targets[0], rN=(-7e6, 0, 0), vN=(0, -7500, 0)) + env.scenario.catalog = scene.RSOTargetCatalog((opposite, catalog.targets[1]), EPOCH) + env.reset(seed=0) + env.step(actions(env, "83", True)) + for _ in range(20): + env.step(actions(env)) + observer = camera(env) + assert observer.dynamics.rso_access_messages["83"].read().hasAccess == 0 + assert observer.dynamics.storage_level == 0 + assert observer.fsw.rso_capture.records == [] + assert env.scenario.targets_by_id["83"].target_spacecraft.is_alive() + env.close() + + +def test_evaluation_physical_products_reward_and_replay(tmp_path, monkeypatch): + import csv + import importlib + import json + from pathlib import Path + + monkeypatch.syspath_prepend(str(Path(__file__).parents[2] / "examples")) + evaluation = importlib.import_module("space_to_space_rso_imaging_evaluation") + first = tmp_path / "first" + result = evaluation.evaluate(first, horizon=240, plots=False) + assert result["captures"] >= 1 and result["deliveries"] >= 1 + assert result["status"] == "time_limit" + captures = list(csv.DictReader((first / "captures.csv").open())) + deliveries = list(csv.DictReader((first / "deliveries.csv").open())) + assert all(float(row["hold_valid_time_s"]) >= 10 for row in captures) + acquired_bits = sum(float(row["size_bits"]) for row in captures) + delivered_bits = sum(float(row["size_bits"]) for row in deliveries) + assert acquired_bits == delivered_bits + result["storage_bits"] + assert result["acquisition_reward"] + result["delivery_reward"] == pytest.approx( + result["total_reward"] + ) + replay = tmp_path / "replay" + repeated = evaluation.evaluate( + replay, manifest_path=first / "mission.json", seed=999, plots=False + ) + np.testing.assert_array_equal( + np.load(first / "initial_observation.npy"), + np.load(replay / "initial_observation.npy"), + ) + assert repeated["captures"] == result["captures"] + assert repeated["deliveries"] == result["deliveries"] + assert (first / "decisions.csv").read_text() == ( + replay / "decisions.csv" + ).read_text() + shortened = evaluation.evaluate( + tmp_path / "shortened", + manifest_path=first / "mission.json", + horizon=30, + plots=False, + ) + assert shortened["time_s"] == pytest.approx(30, abs=1e-9) + assert shortened["captures"] == shortened["deliveries"] == 0 + assert ( + json.loads((first / "mission.json").read_text())["catalog"] + == json.loads((replay / "mission.json").read_text())["catalog"] + ) + + +def test_evaluation_plots_and_step_budget(tmp_path, monkeypatch): + import importlib + from pathlib import Path + + pytest.importorskip("matplotlib") + monkeypatch.syspath_prepend(str(Path(__file__).parents[2] / "examples")) + evaluation = importlib.import_module("space_to_space_rso_imaging_evaluation") + output = tmp_path / "plots" + result = evaluation.evaluate(output, horizon=240, max_steps=1) + assert result["status"] == "max_steps" + assert result["captures"] == 1 and result["deliveries"] == 0 + for name in ("performance.png", "performance.pdf", "actions.png", "actions.pdf"): + assert (output / name).stat().st_size > 1000 diff --git a/tests/integration/test_int_space_to_space_imaging_example.py b/tests/integration/test_int_space_to_space_imaging_example.py new file mode 100644 index 00000000..3d9c8f49 --- /dev/null +++ b/tests/integration/test_int_space_to_space_imaging_example.py @@ -0,0 +1,120 @@ +"""Public example profiles expose stable mission configuration without a policy.""" + +import runpy +from pathlib import Path + +import numpy as np +import pytest + +EXAMPLE = runpy.run_path( + str(Path(__file__).parents[2] / "examples/space_to_space_rso_imaging_demo.py") +) + + +def test_catalog_sampling_is_independent_of_global_draw_order(): + first = EXAMPLE["make_catalog"](seed=0) + np.random.random(100) + assert EXAMPLE["make_catalog"](seed=0) == first + + +def test_unpinned_catalog_manifest_records_the_realized_epoch_and_replays(): + definition = EXAMPLE["make_catalog"](seed=0) + assert definition.utc_init is None + env = EXAMPLE["build_environment"](definition) + epoch = "2026 OCT 01 00:00:00.000 (UTC)" + env.world_args_generator["utc_init"] = lambda: epoch + try: + initial, _ = env.reset(seed=0) + manifest = EXAMPLE["mission_manifest"](env) + assert ( + manifest["catalog"]["utc_init"] + == manifest["world_args"]["utc_init"] + == epoch + ) + assert definition.utc_init is None + finally: + env.close() + replay = EXAMPLE["replay_environment"](manifest) + try: + repeated, _ = replay.reset(seed=999) + np.testing.assert_array_equal(initial["imager"], repeated["imager"]) + assert replay.scenario.catalog.utc_init == epoch + finally: + replay.close() + + +def test_default_example_rewards_only_complete_delivery(): + env = EXAMPLE["build_environment"]() + try: + env.reset(seed=0) + imager = next(sat for sat in env.satellites if sat.name == "imager") + for _ in range(30): + choice = 1 if imager.data_store.products else 3 + _, rewards, terminated, truncated, _ = env.step( + { + sat.name: choice if sat.name == "imager" else 0 + for sat in env.satellites + } + ) + if env.rewarder.data.deliveries: + record = next(iter(env.rewarder.data.deliveries.values())) + target = env.scenario.targets_by_id[record.target_id] + assert rewards["imager"] == pytest.approx(target.priority) + assert env.rewarder.last_reward_components["acquisition"]["imager"] == 0 + break + assert rewards["imager"] == 0 + assert not all(terminated.values()) and not all(truncated.values()) + else: + pytest.fail("Example did not deliver an image within 30 decisions.") + finally: + env.close() + + +def test_amos_profile_configuration_is_explicit(): + env = EXAMPLE["build_environment"](amos_profile=True) + imager = next(sat for sat in env.satellites if sat.name == "imager") + assert np.degrees(imager.sat_args_generator["oe"].i) == 45 + assert imager.action_space.n == 13 + assert [action.duration for action in imager.action_builder.action_spec[:3]] == [ + 300, + 300, + 150, + ] + imaging = imager.action_builder.action_spec[-1] + assert imaging.min_pointing_hold_s == 10 + assert imaging.hold_mode == "cumulative" + assert not imaging.require_illumination_during_hold + assert env.rewarder.example_weights == dict( + acquisition_weight=0.9, delivery_weight=0.1 + ) + assert imager.sat_args_generator["instrumentBaudRate"] == 4e6 + try: + env.reset(seed=0) + assert imager.observation_space.shape == (124,) + finally: + env.close() + + +def test_replay_rejects_an_unverified_catalog(): + with pytest.raises(ValueError, match="hash"): + EXAMPLE["replay_environment"]( + dict(schema_version=1, catalog={}, catalog_sha256="wrong") + ) + + +def test_manifest_does_not_attribute_an_untracked_package_to_enclosing_git_head( + monkeypatch, +): + import bsk_rl + + env = EXAMPLE["build_environment"]() + try: + env.reset(seed=0) + # The directory is in this repository, but this package file is untracked. + # This reproduces an installed wheel nested inside a checkout's .venv. + monkeypatch.setattr( + bsk_rl, "__file__", str(Path(__file__).parents[2] / "examples/__init__.py") + ) + assert EXAMPLE["mission_manifest"](env)["bsk_rl_commit"] is None + finally: + env.close() diff --git a/tests/unittest/act/test_rso_imaging_actions.py b/tests/unittest/act/test_rso_imaging_actions.py new file mode 100644 index 00000000..cce42c06 --- /dev/null +++ b/tests/unittest/act/test_rso_imaging_actions.py @@ -0,0 +1,71 @@ +"""Sampled hold before acquisition and invalid candidate handling.""" + +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from bsk_rl.act import ImageRSO +from bsk_rl.utils.rso_imaging import CandidateSnapshot + + +def test_empty_candidates_never_task_another_target(): + action = ImageRSO(2) + action.satellite = NS( + rso_scenario=NS(revision=0, targets_by_id={}), simulator=NS(sim_time=0) + ) + action.simulator = action.satellite.simulator + action._task = Mock() + with pytest.raises(ValueError, match="empty"): + action.set_action(0) + action._task.assert_not_called() + + +def test_stale_snapshot_warns_and_preserves_the_observed_target(): + action = ImageRSO(1) + action.satellite = NS( + rso_scenario=NS(revision=1, targets_by_id={"observed": NS(id="observed")}), + simulator=NS(sim_time=10), + _rso_candidate_snapshot=CandidateSnapshot(10, 0, (NS(id="observed"),)), + ) + action.simulator = action.satellite.simulator + action._task = Mock() + with pytest.warns(UserWarning, match="original target ID"): + action.set_action(0) + action._task.assert_called_once_with( + action.satellite.rso_scenario.targets_by_id["observed"] + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + dict(n_ahead_image=0), + dict(n_ahead_image=2, max_duration=0), + dict(n_ahead_image=1, min_pointing_hold_s=-1), + dict(n_ahead_image=1, hold_mode="bad"), + ], +) +def test_invalid_action_settings(kwargs): + with pytest.raises(ValueError): + ImageRSO(**kwargs) + + +def test_stale_ineligible_target_does_not_substitute_another_candidate(): + action = ImageRSO(1) + target = NS(id="observed") + action.satellite = NS( + rso_scenario=NS( + revision=1, + targets_by_id={target.id: target}, + is_eligible=lambda target_id, time: False, + ), + simulator=NS(sim_time=10), + _rso_candidate_snapshot=CandidateSnapshot(10, 0, (target,)), + ) + action.simulator = action.satellite.simulator + with ( + pytest.warns(UserWarning), + pytest.raises(ValueError, match="pending or in cooldown"), + ): + action.set_action(0) diff --git a/tests/unittest/data/test_rso_imaging_data.py b/tests/unittest/data/test_rso_imaging_data.py new file mode 100644 index 00000000..48c9c9df --- /dev/null +++ b/tests/unittest/data/test_rso_imaging_data.py @@ -0,0 +1,278 @@ +"""Ownership, complete delivery, centralized service, and reward composition.""" + +from dataclasses import replace +from types import SimpleNamespace as NS + +import pytest + +from bsk_rl.data import ( + NoReward, + RSOImageData, + RSOImageRecord, + RSOImageReward, + RSOImageStore, +) +from bsk_rl.data.composition import ComposedReward + + +def weighted_reward(alpha=0.1, **kwargs): + """Keep stage weighting in the test scenario, rather than in the rewarder.""" + return RSOImageReward( + acquisition_reward_fn=lambda record, target: (1 - alpha) * target.priority, + delivery_reward_fn=lambda record, target: alpha * target.priority, + **kwargs, + ) + + +def scene(): + return NS( + imager_names=("cam-a", "cam-b"), + targets_by_id={"odd/83": NS(priority=2)}, + partition_name=lambda _: "partition", + pending={}, + cooldown_until={}, + revision=0, + ) + + +def image(key="a:1", owner="cam-a", time=10, quality=0.8): + return RSOImageRecord(key, "odd/83", owner, time, 100, quality, 10) + + +def test_custom_stage_rewards_receive_the_record_and_current_target(): + mission = scene() + rewarder = RSOImageReward( + multi_imager_credit="shared", + acquisition_reward_fn=lambda record, target: record.quality * target.priority, + delivery_reward_fn=lambda record, target: target.priority + / (record.delivery_time - record.capture_time), + ) + rewarder.link_scenario(mission) + rewarder.reset_overwrite_previous() + captured = image() + assert rewarder.calculate_reward( + {"cam-a": RSOImageData({captured.record_id: captured})} + ) == {"cam-a": 1.6} + mission.targets_by_id[captured.target_id].priority = 5 + delivered = replace(captured, delivery_time=20) + assert rewarder.calculate_reward( + {"cam-a": RSOImageData(deliveries={captured.record_id: delivered})} + ) == {"cam-a": 0.5} + + +def test_default_reward_is_withheld_until_complete_delivery(): + mission = scene() + rewarder = RSOImageReward(multi_imager_credit="shared") + rewarder.link_scenario(mission) + rewarder.reset_overwrite_previous() + captured = image() + assert rewarder.calculate_reward( + {"cam-a": RSOImageData({captured.record_id: captured})} + ) == {"cam-a": 0} + delivered = replace(captured, delivery_time=20) + assert rewarder.calculate_reward( + {"cam-a": RSOImageData(deliveries={captured.record_id: delivered})} + ) == {"cam-a": 2} + + +def test_partial_and_multiple_delivery_ledger(): + satellite = NS(name="cam-a", simulator=NS(sim_time=40)) + store = RSOImageStore(satellite, scenario=scene()) + first, second = image(), image("a:2", time=20) + captured = store.compare_log_states( + ({"partition": 0}, ()), ({"partition": 200}, (first, second)) + ) + assert len(captured.captures) == 2 + partial = store.compare_log_states( + ({"partition": 200}, (first, second)), ({"partition": 160}, (first, second)) + ) + assert partial.deliveries == {} + assert store.remaining_bits[first.record_id] == 60 + completed = store.compare_log_states( + ({"partition": 160}, (first, second)), ({"partition": 0}, (first, second)) + ) + assert len(completed.deliveries) == 2 + assert store.products == {} + assert store.remaining_bits == {} + + +def test_owner_reward_priority_and_cooldown(): + mission = scene() + rewarder = weighted_reward(multi_imager_credit="shared", alpha=0.1, cooldown_s=100) + rewarder.link_scenario(mission) + rewarder.reset_overwrite_previous() + captured = image() + assert rewarder.calculate_reward( + {"cam-a": RSOImageData({captured.record_id: captured}), "cam-b": RSOImageData()} + ) == {"cam-a": 1.8, "cam-b": 0} + assert mission.pending["odd/83"] == {captured.record_id} + assert rewarder.last_reward_components == { + "acquisition": {"cam-a": 1.8, "cam-b": 0}, + "delivery": {"cam-a": 0, "cam-b": 0}, + } + mission.targets_by_id["odd/83"].priority = 5 + delivered = replace(captured, delivery_time=40) + result = rewarder.calculate_reward( + { + "cam-a": RSOImageData(deliveries={delivered.record_id: delivered}), + "cam-b": RSOImageData(), + } + ) + assert result == {"cam-a": 0.5, "cam-b": 0} + assert rewarder.last_reward_components == { + "acquisition": {"cam-a": 0, "cam-b": 0}, + "delivery": {"cam-a": 0.5, "cam-b": 0}, + } + assert not mission.pending["odd/83"] + assert mission.cooldown_until["odd/83"] == 110 + assert rewarder.calculate_reward( + {"cam-a": RSOImageData(deliveries={delivered.record_id: delivered})} + ) == {"cam-a": 0} + rewarder.reset_overwrite_previous() + assert rewarder.last_reward_components == {"acquisition": {}, "delivery": {}} + + +def test_simultaneous_credit_and_quality(): + rewarder = weighted_reward(multi_imager_credit="shared") + rewarder.link_scenario(scene()) + rewarder.reset_overwrite_previous() + first, second = image(), image("b:1", owner="cam-b") + result = rewarder.calculate_reward( + { + "cam-a": RSOImageData({first.record_id: first}), + "cam-b": RSOImageData({second.record_id: second}), + } + ) + assert result == {"cam-a": 0.9, "cam-b": 0.9} + bad = image("a:bad", time=200, quality=0.1) + assert rewarder.calculate_reward({"cam-a": RSOImageData({bad.record_id: bad})}) == { + "cam-a": 0 + } + with pytest.raises(ValueError, match="owner"): + rewarder.calculate_reward({"wrong": RSOImageData({first.record_id: first})}) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_composed_reward_retains_scene_and_store_ownership(reverse): + mission = scene() + rso_reward = weighted_reward( + multi_imager_credit="shared", alpha=0.3, cooldown_s=123 + ) + components = (NoReward(), rso_reward) if reverse else (rso_reward, NoReward()) + composed = ComposedReward(*components) + composed.link_scenario(mission) + composed.reset_overwrite_previous() + satellite = NS(name="cam-b") + composed.create_data_store(satellite) + store = next( + store + for store in satellite.data_store.data_stores + if isinstance(store, RSOImageStore) + ) + assert store.scenario is mission + assert store.is_imager + assert rso_reward.delivery_reward_fn( + image(), mission.targets_by_id["odd/83"] + ) == pytest.approx(0.6) + assert rso_reward.cooldown_s == 123 + + +def test_cooldown_boundary_and_failed_quality_do_not_double_credit(): + mission = scene() + rewarder = weighted_reward(multi_imager_credit="shared", alpha=0.1, cooldown_s=100) + rewarder.link_scenario(mission) + rewarder.reset_overwrite_previous() + first = image() + rewarder.calculate_reward({"cam-a": RSOImageData({first.record_id: first})}) + rewarder.calculate_reward( + { + "cam-a": RSOImageData( + deliveries={first.record_id: replace(first, delivery_time=20)} + ) + } + ) + before = image("a:2", time=109) + assert ( + rewarder.calculate_reward({"cam-a": RSOImageData({before.record_id: before})})[ + "cam-a" + ] + == 0 + ) + boundary = image("a:3", time=110) + assert ( + rewarder.calculate_reward( + {"cam-a": RSOImageData({boundary.record_id: boundary})} + )["cam-a"] + == 1.8 + ) + bad = image("a:bad", time=300, quality=0.1) + assert ( + rewarder.calculate_reward( + { + "cam-a": RSOImageData( + {bad.record_id: bad}, + {bad.record_id: replace(bad, delivery_time=400)}, + ) + } + )["cam-a"] + == 0 + ) + assert mission.cooldown_until["odd/83"] == 110 + assert bad.record_id not in mission.pending["odd/83"] + + +def test_staggered_imagers_do_not_duplicate_credit_while_service_is_pending(): + rewarder = weighted_reward(multi_imager_credit="shared", cooldown_s=0) + mission = scene() + rewarder.link_scenario(mission) + rewarder.reset_overwrite_previous() + first, second = image(), image("b:1", owner="cam-b", time=15) + assert rewarder.calculate_reward( + {"cam-a": RSOImageData({first.record_id: first})} + ) == {"cam-a": 1.8} + assert rewarder.calculate_reward( + {"cam-b": RSOImageData({second.record_id: second})} + ) == {"cam-b": 0} + assert rewarder.calculate_reward( + { + "cam-a": RSOImageData( + deliveries={first.record_id: replace(first, delivery_time=30)} + ) + } + ) == {"cam-a": 0.2} + assert rewarder.calculate_reward( + { + "cam-b": RSOImageData( + deliveries={second.record_id: replace(second, delivery_time=35)} + ) + } + ) == {"cam-b": 0} + next_service = image("b:2", owner="cam-b", time=40) + assert rewarder.calculate_reward( + {"cam-b": RSOImageData({next_service.record_id: next_service})} + ) == {"cam-b": 1.8} + + +def test_multi_imager_scientific_choice_is_required_and_per_owner_mode_works(): + with pytest.raises(ValueError, match="explicit multi_imager_credit"): + weighted_reward().link_scenario(scene()) + rewarder = weighted_reward(multi_imager_credit="per_imager") + rewarder.link_scenario(scene()) + rewarder.reset_overwrite_previous() + first, second = image(), image("b:1", owner="cam-b") + assert rewarder.calculate_reward( + { + "cam-a": RSOImageData({first.record_id: first}), + "cam-b": RSOImageData({second.record_id: second}), + } + ) == {"cam-a": 1.8, "cam-b": 1.8} + assert rewarder.calculate_reward( + { + "cam-a": RSOImageData( + deliveries={first.record_id: replace(first, delivery_time=30)} + ), + "cam-b": RSOImageData( + deliveries={second.record_id: replace(second, delivery_time=30)} + ), + } + ) == {"cam-a": 0.2, "cam-b": 0.2} diff --git a/tests/unittest/obs/test_rso_imaging_observations.py b/tests/unittest/obs/test_rso_imaging_observations.py new file mode 100644 index 00000000..b72f6799 --- /dev/null +++ b/tests/unittest/obs/test_rso_imaging_observations.py @@ -0,0 +1,159 @@ +"""Candidate snapshots are shared by observations and action selection.""" + +from types import SimpleNamespace as NS + +import numpy as np +import pytest +from Basilisk.architecture import messaging +from Basilisk.simulation import spacecraftLocation + +from bsk_rl.act import ImageRSO +from bsk_rl.obs import RSOTargetProperties +from bsk_rl.obs.rso_imaging import target_properties +from bsk_rl.utils.rso_imaging import candidate_snapshot, illumination_factor + + +def observer(): + targets = {} + for key, position, velocity in ( + ("83", [100, 7e6 + 200, 300], [-7390, 20, -30]), + ("far/blue", [-400, 7e6 - 100, -200], [-7415, -25, 40]), + ): + targets[key] = NS( + id=key, + priority=2.0, + target_spacecraft=NS( + dynamics=NS( + r_BN_N=position, + v_BN_N=velocity, + eclipse_index=0, + world=NS( + eclipseObject=NS( + eclipseOutMsgs=[ + NS( + read=lambda: messaging.EclipseMsgPayload( + illuminationFactor=0.8 + ) + ) + ] + ) + ), + ) + ), + ) + scene = NS(targets_by_id=targets, revision=0, is_eligible=lambda *_: True) + return NS( + simulator=NS(sim_time=0), + rso_scenario=scene, + dynamics=NS(r_BN_N=[0, 7e6, 0], v_BN_N=[-7400, 0, 0], BN=np.eye(3)), + fsw=NS(locPoint=NS(pHat_B=[0, 0, 1])), + ) + + +def test_observation_and_action_share_snapshot_and_explicit_padding(): + sat = observer() + observation = RSOTargetProperties(dict(prop="valid"), n_ahead_observe=3) + observation.link_satellite(sat) + values = observation.get_obs() + assert values["rso_targets_2"]["valid"] == 0 + snapshot = sat._rso_candidate_snapshot + assert observation.candidate_ids() == tuple( + None if target is None else target.id for target in snapshot.targets + ) + assert sat._rso_candidate_snapshot is snapshot + action = ImageRSO(3) + action.satellite, action.simulator = sat, sat.simulator + selected = [] + action._task = lambda target: selected.append(target.id) + action.set_action(1) + assert sat._rso_candidate_snapshot is snapshot + assert selected == [snapshot.targets[1].id] + with pytest.raises(ValueError, match="empty"): + action.set_action(2) + sat.rso_scenario.revision += 1 + assert candidate_snapshot(sat, 3) is not snapshot + + +def test_default_row_shape_and_normalization(): + sat = observer() + observation = RSOTargetProperties(n_ahead_observe=10) + observation.link_satellite(sat) + rows = observation.get_obs() + assert sum(np.size(value) for row in rows.values() for value in row.values()) == 110 + assert rows["rso_targets_0"]["priority"] == 1.0 + assert rows["rso_targets_0"]["target_illumination_factor"] == 0.8 + assert rows["rso_targets_9"]["target_distance"] == 0.0 + + +@pytest.mark.parametrize("observe_count, action_count", [(5, 1), (1, 3)]) +def test_observation_count_is_independent_of_action_count(observe_count, action_count): + sat = observer() + action = ImageRSO(action_count) + action.satellite, action.simulator = sat, sat.simulator + sat.action_builder = NS(action_spec=[action]) + observation = RSOTargetProperties(n_ahead_observe=observe_count) + observation.link_satellite(sat) + observation.reset_post_sim_init() + assert len(observation.get_obs()) == observe_count + snapshot = sat._rso_candidate_snapshot + selected = [] + action._task = lambda target: selected.append(target.id) + action.set_action(0) + assert sat._rso_candidate_snapshot is snapshot + assert selected == [observation.candidate_ids()[0]] + + +def test_observation_without_an_imaging_action(): + observation = RSOTargetProperties(n_ahead_observe=2) + observation.link_satellite(observer()) + observation.reset_post_sim_init() + assert len(observation.get_obs()) == 2 + + +@pytest.mark.parametrize( + "target_id, relative_hill, velocity_hill, relative_inertial", + [ + ("83", [200, -100, 300], [20, -10, -30], [100, 200, 300]), + ("far/blue", [-100, 400, -200], [-25, 15, 40], [-400, -100, -200]), + ], +) +def test_relative_features_with_different_positions_and_velocities( + target_id, relative_hill, velocity_hill, relative_inertial +): + sat = observer() + values = target_properties(sat, sat.rso_scenario.targets_by_id[target_id]) + # For this orbit the Hill axes are +Y, -X, +Z. These expectations are + # independent of rv2HN and expose both axis order and velocity-sign errors. + np.testing.assert_allclose(values["rel_pos_vector_r_BR_H"], relative_hill) + np.testing.assert_allclose(values["rel_vel_vector_v_BR_H"], velocity_hill) + distance = np.linalg.norm(relative_inertial) + assert values["target_distance"] == pytest.approx(distance) + assert values["target_elevation_angle"] == pytest.approx( + np.degrees(np.arcsin(relative_inertial[1] / distance)) + ) + assert values["angle_to_target"] == pytest.approx( + np.degrees(np.arccos(relative_inertial[2] / distance)) + ) + observation = RSOTargetProperties(n_ahead_observe=2) + observation.link_satellite(sat) + index = observation.candidate_ids().index(target_id) + row = observation.get_obs()[f"rso_targets_{index}"] + np.testing.assert_allclose( + row["rel_pos_vector_r_BR_H"], np.array(relative_hill) / 15960e3 + ) + np.testing.assert_allclose( + row["rel_vel_vector_v_BR_H"], np.array(velocity_hill) / 16000 + ) + + +@pytest.mark.parametrize("fraction", [0.0, 0.8, 1.0]) +def test_illumination_is_read_without_inversion(fraction): + payload = messaging.EclipseMsgPayload(illuminationFactor=fraction) + assert illumination_factor(payload) == fraction + + +def test_illumination_accessor_handles_equivalent_native_wrappers(): + # This native module exposes another wrapper for the same payload pointer. + payload = spacecraftLocation.EclipseMsgPayload() + messaging.EclipseMsgPayload.illuminationFactor.fset(payload, 0.8) + assert illumination_factor(payload) == 0.8 diff --git a/tests/unittest/sats/test_satellite.py b/tests/unittest/sats/test_satellite.py index 6905efef..a30b099b 100644 --- a/tests/unittest/sats/test_satellite.py +++ b/tests/unittest/sats/test_satellite.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from Basilisk.utilities import macros from bsk_rl import sats from bsk_rl.sim import Simulator @@ -81,6 +82,39 @@ def test_init_default(self): sat.sat_args_generator, {"a": 1, "b": 2, "c": 3, "d": np.array([4, 5])} ) + @pytest.mark.parametrize("name", ["", None, 83]) + def test_invalid_name_fails_at_construction(self, name): + with pytest.raises(ValueError, match="nonempty string"): + sats.Satellite(name=name) + + @pytest.mark.parametrize("name", ["camera-a", "camera a", "83", "class"]) + def test_invalid_identifier_fails_at_construction_and_renaming(self, name): + with pytest.raises(ValueError, match="valid Python identifier"): + sats.Satellite(name=name) + sat = sats.Satellite(name="camera_a") + with pytest.raises(ValueError, match="valid Python identifier"): + sat.name = name + assert sat.name == "camera_a" + + def test_deadline_events_use_the_validated_name_after_renaming(self): + sat = sats.Satellite(name="camera_a") + sat.simulator = MagicMock(eventMap={}, sim_rate=1.0) + sat.simulator.createNewEvent.side_effect = ( + lambda name, *args, **kwargs: sat.simulator.eventMap.setdefault( + name, MagicMock() + ) + ) + sat.update_timed_terminal_event(10.0) + assert sat._timed_terminal_event_name == "timed_terminal_camera_a" + sat.update_timed_terminal_event(20.0) + assert sat._timed_terminal_event_name == "timed_terminal_camera_a" + assert sat.simulator.createNewEvent.call_args.kwargs["conditionTime"] == ( + macros.sec2nano(20.0) + ) + sat.name = "renamed_camera" + sat.update_timed_terminal_event(30.0) + assert sat._timed_terminal_event_name == "timed_terminal_renamed_camera" + def test_generate_sat_args(self): sat = sats.Satellite( name="TestSat", diff --git a/tests/unittest/scene/test_rso_targets.py b/tests/unittest/scene/test_rso_targets.py new file mode 100644 index 00000000..1815293b --- /dev/null +++ b/tests/unittest/scene/test_rso_targets.py @@ -0,0 +1,139 @@ +"""Catalog replay and explicit spacecraft identity contracts.""" + +from dataclasses import replace +from types import SimpleNamespace as NS + +import numpy as np +import pytest + +from bsk_rl.scene import RSOPriorityEvent, RSOTarget, RSOTargetCatalog, RSOTargets + + +def catalog(): + return RSOTargetCatalog( + (RSOTarget("id-83", "debris", (7e6, 0, 0), (0, 7500, 0)),), + "2026 OCT 01 00:00:00.000 (UTC)", + (RSOPriorityEvent(0, (("id-83", 3),)), RSOPriorityEvent(10, (("id-83", 7),))), + ) + + +def test_catalog_round_trip_does_not_consume_rng(tmp_path): + original = catalog() + original.save(tmp_path / "catalog.json") + np.random.seed(52) + expected = np.random.random() + np.random.seed(52) + restored = RSOTargetCatalog.load(tmp_path / "catalog.json") + assert restored == original + assert np.random.random() == expected + assert restored.targets[0].id == "id-83" + + +def test_legacy_catalog_binding_key_is_read_without_changing_identity(): + original = catalog() + values = original.to_dict() + values["targets"][0]["spacecraft_name"] = values["targets"][0].pop("satellite_name") + assert RSOTargetCatalog.from_dict(values) == original + values["targets"][0]["satellite_name"] = "different" + with pytest.raises(ValueError, match="Conflicting"): + RSOTargetCatalog.from_dict(values) + + +@pytest.mark.parametrize("omit_epoch_key", [False, True]) +def test_catalog_epoch_can_be_omitted_from_constructor_and_json(omit_epoch_key): + original = RSOTargetCatalog(catalog().targets) + assert original.utc_init is None + values = original.to_dict() + if omit_epoch_key: + values.pop("utc_init") + assert RSOTargetCatalog.from_dict(values) == original + + +@pytest.mark.parametrize("epoch", ["", 83, False]) +def test_explicit_catalog_epoch_must_be_a_nonempty_string(epoch): + with pytest.raises(ValueError, match="epoch"): + RSOTargetCatalog(catalog().targets, utc_init=epoch) + + +@pytest.mark.parametrize( + "order", [("other", "debris", "camera"), ("camera", "debris", "other")] +) +@pytest.mark.parametrize("inherit_epoch", [False, True]) +def test_explicit_binding_and_repeat_reset(order, inherit_epoch): + definition = replace(catalog(), utc_init=None) if inherit_epoch else catalog() + scene = RSOTargets(definition, ["camera"]) + from bsk_rl.sim.dyn.base import EclipseDynModel + + class FakeDyn(EclipseDynModel): + def bind_rso_target(self, target): + pass + + class FakeFSW: + def action_image_rso(self, target): + pass + + satellites = [ + NS( + name=name, + sat_args={}, + dyn_type=FakeDyn, + fsw_type=FakeFSW, + ) + for name in order + ] + scene.link_satellites(satellites) + scene.utc_init = catalog().utc_init + scene.reset_pre_sim_init() + first_episode = scene.catalog + assert first_episode.utc_init == scene.utc_init + assert first_episode.targets == definition.targets + assert definition.utc_init == (None if inherit_epoch else catalog().utc_init) + assert scene.targets_by_id["id-83"].target_spacecraft.name == "debris" + assert scene.targets_by_id["id-83"].priority == 3 + assert scene.imagers[0].name == "camera" + assert next(sat for sat in satellites if sat.name == "other").sat_args == {} + scene.after_step(12) + assert scene.targets_by_id["id-83"].priority == 7 + assert scene.applied_events == [(0, 0.0), (1, 12.0)] + scene.pending["id-83"] = {"image"} + scene.reset_overwrite_previous() + assert scene.catalog is definition + if inherit_epoch: + scene.utc_init = "2026 OCT 02 00:00:00.000 (UTC)" + scene.reset_pre_sim_init() + assert scene.catalog.utc_init == scene.utc_init + assert first_episode.utc_init == catalog().utc_init + assert definition.utc_init == (None if inherit_epoch else catalog().utc_init) + assert scene.pending == {} + assert scene.targets_by_id["id-83"].priority == 3 + + +def test_invalid_participants_and_catalog(): + with pytest.raises(ValueError, match="Missing"): + RSOTargets(catalog(), ["camera"]).link_satellites([]) + with pytest.raises(ValueError, match="own"): + RSOTargets(catalog(), ["debris"]) + with pytest.raises(ValueError, match="unique"): + RSOTargetCatalog(catalog().targets * 2, catalog().utc_init) + with pytest.raises(ValueError, match="nonempty string"): + RSOTarget(83, "debris", (7e6, 0, 0), (0, 7500, 0)) + with pytest.raises(ValueError, match="unique"): + RSOTargets(catalog(), ["camera", "camera"]) + with pytest.raises(ValueError, match="unique"): + RSOTargets(catalog(), ["camera"]).link_satellites( + [NS(name="camera"), NS(name="camera")] + ) + scene = RSOTargets(catalog(), ["camera"]) + scene.utc_init = "wrong epoch" + with pytest.raises(ValueError, match="epoch"): + scene.reset_pre_sim_init() + + +def test_ambiguous_original_names_are_rejected_before_environment_renaming(): + from bsk_rl import GeneralSatelliteTasking + + with pytest.raises(ValueError, match="Satellite names must be unique"): + GeneralSatelliteTasking( + satellites=[NS(name="camera"), NS(name="camera"), NS(name="debris")], + scenario=RSOTargets(catalog(), ["camera_0"]), + ) diff --git a/tests/unittest/sim/test_rso_imaging_dyn.py b/tests/unittest/sim/test_rso_imaging_dyn.py new file mode 100644 index 00000000..bb15c9bc --- /dev/null +++ b/tests/unittest/sim/test_rso_imaging_dyn.py @@ -0,0 +1,50 @@ +"""Access IDs are explicit bindings, never simulator array indices.""" + +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from bsk_rl.scene import RSOTargets +from bsk_rl.sim.dyn.rso_imaging import SpaceToSpaceImagingDynModel + + +def test_access_binding_uses_registration_output(): + messages = [object(), object()] + location = NS(accessOutMsgs=[], addSpacecraftToModel=Mock()) + location.addSpacecraftToModel.side_effect = lambda _: location.accessOutMsgs.append( + messages[len(location.accessOutMsgs)] + ) + model = object.__new__(SpaceToSpaceImagingDynModel) + model.logger = Mock() + model.targetLocation = location + model.rso_access_messages = {} + model.rso_partition_names = {} + model.satellite = NS(rso_scenario=NS(partition_name=RSOTargets.partition_name)) + for key in ("83", "rso/blue"): + target = NS( + id=key, + target_spacecraft=NS(dynamics=NS(scObject=NS(scStateOutMsg=object()))), + ) + model.bind_rso_target(target) + assert model.rso_access_messages == dict(zip(("83", "rso/blue"), messages)) + assert model.rso_partition_names["83"] != model.rso_partition_names["rso/blue"] + assert len(model.rso_partition_names["rso/blue"]) < 64 + with pytest.raises(ValueError, match="Duplicate"): + model.bind_rso_target(target) + + +@pytest.mark.parametrize("value", [0, -2, float("nan")]) +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) + + +@pytest.mark.parametrize("value", [0, -1, float("nan")]) +def test_invalid_image_size(value): + model = object.__new__(SpaceToSpaceImagingDynModel) + model.logger = Mock() + with pytest.raises(ValueError, match="size"): + model.setup_instrument(instrumentBaudRate=value) diff --git a/tests/unittest/sim/test_rso_imaging_fsw.py b/tests/unittest/sim/test_rso_imaging_fsw.py new file mode 100644 index 00000000..0bdeafcb --- /dev/null +++ b/tests/unittest/sim/test_rso_imaging_fsw.py @@ -0,0 +1,232 @@ +"""Sampled acquisition contracts using real Basilisk messages.""" + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest +from Basilisk.architecture import messaging +from Basilisk.fswAlgorithms import simpleInstrumentController +from Basilisk.utilities import macros + +from bsk_rl.sim.fsw.ground_imaging import ImagingFSWModel +from bsk_rl.sim.fsw.rso_imaging import ( + SpaceToSpaceImagingFSWModel, + _AcquisitionGate, +) + + +class AcquisitionHarness: + """Supply message inputs while controlling their independent timestamps.""" + + def __init__(self): + self.satellite = SimpleNamespace(name="camera_a", vizard_data={}) + self.simulator = SimpleNamespace(sim_time_ns=0) + self.fsw_rate = 1.0 # [s] + self.attGuidMsg = messaging.AttGuidMsg() + 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.dynamics = SimpleNamespace( + dyn_rate=1.0, # [s] + instrument=SimpleNamespace(nodeBaudRate=1000.0), # [bit/s] + storageUnit=SimpleNamespace( + storageCapacity=1000.0, # [bit] + storageUnitDataOutMsg=self.storage, + ), + storage_level=0.0, # [bit] + rso_partition_names={"83": "83"}, + rso_access_messages={"83": self.access}, + ) + self.target = SimpleNamespace( + id="83", + target_spacecraft=SimpleNamespace( + dynamics=SimpleNamespace( + eclipse_index=0, + world=SimpleNamespace( + eclipseObject=SimpleNamespace( + eclipseOutMsgs=[self.illumination] + ) + ), + ) + ), + ) + self.store(0.0) # [bit] + self.gate = _AcquisitionGate(self) + self.start() + + def start(self, hold_s=3.0, mode="cumulative", duration=30.0, require_lit=True): + """Start a fresh hold; durations are in seconds.""" + self.gate.start(self.target, duration, hold_s, mode, require_lit, 0.5) + + def sample( + self, + time_s, + *, + access=True, + illumination=0.8, + sigma=(0.0, 0.0, 0.0), + omega=(0.0, 0.0, 0.0), + timestamps=None, + unwritten=None, + ): + """Write independent guidance/access/illumination inputs and run one tick.""" + now = macros.sec2nano(time_s) + self.simulator.sim_time_ns = now + timestamps = timestamps or {} + guidance = messaging.AttGuidMsgPayload() + guidance.sigma_BR = sigma + guidance.omega_BR_B = omega + access_payload = messaging.AccessMsgPayload() + access_payload.hasAccess = int(access) + illumination_payload = messaging.EclipseMsgPayload() + illumination_payload.illuminationFactor = illumination + for name, message, payload in ( + ("guidance", self.attGuidMsg, guidance), + ("access", self.access, access_payload), + ("illumination", self.illumination, illumination_payload), + ): + if name != unwritten: + message.write(payload, macros.sec2nano(timestamps.get(name, time_s))) + self.gate.UpdateState(now) + return self.gate.command.read().deviceCmd + + def store(self, amount): + """Publish a partition's physical storage amount [bit].""" + payload = messaging.DataStorageStatusMsgPayload() + payload.storedDataName.push_back("83") + payload.storedData.push_back(amount) + self.storage.write(payload) + self.dynamics.storage_level = amount + + +@pytest.fixture +def acquisition(): + return AcquisitionHarness() + + +@pytest.mark.parametrize("mode", ["continuous", "cumulative"]) +@pytest.mark.parametrize( + "constraint_break", + [ + {"access": False}, + {"sigma": (0.02, 0.0, 0.0)}, # [-, MRP norm] + {"omega": (0.2, 0.0, 0.0)}, # [rad/s] + {"illumination": 0.4}, # [-] + ], +) +def test_each_constraint_break_resets_or_pauses_hold( + acquisition, mode, constraint_break +): + acquisition.start(mode=mode) + assert acquisition.sample(1) == 0 + assert acquisition.sample(2) == 0 + assert acquisition.gate.held_s == 1 # [s] + assert acquisition.sample(3, **constraint_break) == 0 + assert acquisition.gate.held_s == (0 if mode == "continuous" else 1) + assert acquisition.sample(4) == 0 # The interrupted interval never counts. + assert acquisition.sample(5) == 0 + assert acquisition.sample(6) == int(mode == "cumulative") + if mode == "continuous": + assert acquisition.sample(7) == 1 + + +def test_irregular_intervals_weight_illumination_and_exclude_invalid_gaps(acquisition): + acquisition.start(require_lit=False) + assert acquisition.sample(1, illumination=0.2) == 0 + assert acquisition.sample(3, illumination=0.8) == 0 + assert acquisition.sample(4, access=False, illumination=1.0) == 0 + assert acquisition.sample(10, illumination=0.6) == 0 + assert acquisition.sample(11, illumination=0.4) == 1 + assert not acquisition.gate.check_capture() + acquisition.store(1000) # [bit] + assert acquisition.gate.check_capture() + record = acquisition.gate.records[0] + assert record.hold_valid_time_s == 3 # [s]: two seconds plus one second. + assert record.quality == pytest.approx(0.5) # (1.0 + 0.5) / 3 seconds. + assert record.capture_time == 11 # [s] + assert acquisition.sample(12) == 0 + assert len(acquisition.gate.records) == 1 + + +@pytest.mark.parametrize("message", ["guidance", "access", "illumination"]) +@pytest.mark.parametrize("timestamp", [None, 0.0, 4.0]) +def test_zero_hold_rejects_unwritten_stale_or_future_inputs( + acquisition, message, timestamp +): + acquisition.start(hold_s=0.0) # [s] + kwargs = ( + {"unwritten": message} + if timestamp is None + else {"timestamps": {message: timestamp}} + ) + assert acquisition.sample(2, **kwargs) == 0 + assert acquisition.sample(3) == 1 + + +@pytest.mark.parametrize("illumination", [float("nan"), -0.1, 1.1]) +def test_invalid_illumination_cannot_create_a_product(acquisition, illumination): + acquisition.start(hold_s=0.0, require_lit=False) # [s] + assert acquisition.sample(1, illumination=illumination) == 0 + assert acquisition.sample(2) == 1 + + +def test_storage_readiness_and_full_image_confirmation(acquisition): + acquisition.start(hold_s=0.0) # [s] + acquisition.dynamics.storageUnit.storageCapacity = 999 # [bit] + assert acquisition.sample(1) == 0 + acquisition.dynamics.storageUnit.storageCapacity = 1000 # [bit] + assert acquisition.sample(2) == 1 + acquisition.store(999) # [bit] + assert not acquisition.gate.check_capture() + acquisition.store(1000) # [bit] + assert acquisition.gate.check_capture() + assert acquisition.gate.check_capture() + acquisition.gate.cancel() + assert len(acquisition.gate.records) == 1 + assert acquisition.gate.command.read().deviceCmd == 0 + + +def test_deadline_is_exclusive_and_cancellation_starts_a_new_hold(acquisition): + acquisition.start(duration=4.0) # [s] + for time_s in range(1, 5): + assert acquisition.sample(time_s) == 0 + assert acquisition.gate.target is None + acquisition.start() + for time_s in (5, 6): + assert acquisition.sample(time_s) == 0 + acquisition.gate.cancel() + assert acquisition.gate.held_s == 0 + acquisition.start(hold_s=0.0) # [s] + assert acquisition.sample(6) == 0 # Guidance from the previous action. + assert acquisition.sample(7) == 1 + assert acquisition.sample(8) == 0 + + +def test_gateway_uses_existing_controller_api_and_visualizes_the_actual_command(): + fsw = object.__new__(SpaceToSpaceImagingFSWModel) + harness = AcquisitionHarness() + sensor = SimpleNamespace() + fsw.logger = Mock() + fsw.satellite = harness.satellite + fsw.satellite.vizard_data["genericSensorList"] = [sensor] + fsw.attGuidMsg = harness.attGuidMsg + fsw.insControl = harness.insControl + 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"): + fsw._set_gateway_msgs() + command = messaging.DeviceCmdMsgPayload() + command.deviceCmd = 1 + fsw.rso_capture.command.write(command) + assert fsw.dynamics.instrument.nodeStatusInMsg().deviceCmd == 1 + assert sensor.genericSensorCmdInMsg().deviceCmd == 1 + fsw.simulator.AddModelToTask.assert_called_once_with( + "cameraDynamics", fsw.rso_capture, ModelPriority=900 + ) diff --git a/tests/unittest/sim/test_simulator.py b/tests/unittest/sim/test_simulator.py index 4b7017e8..27de2f05 100644 --- a/tests/unittest/sim/test_simulator.py +++ b/tests/unittest/sim/test_simulator.py @@ -16,12 +16,15 @@ def __init__(self, sim, rate, **kwargs): self.sim = sim self.rate = rate - def mock_sim(self, **kwargs): - Simulator.InitializeSimulation = MagicMock() - Simulator.ConfigureStopTime = MagicMock() - Simulator.ExecuteSimulation = MagicMock() - Simulator.eventMap = {} + @pytest.fixture(autouse=True) + def restore_simulator_methods(self, monkeypatch): + # Restore inherited Basilisk methods after each unit test so physical + # integration tests can also run after these tests in the same process. + for name in ("InitializeSimulation", "ConfigureStopTime", "ExecuteSimulation"): + monkeypatch.setattr(Simulator, name, MagicMock()) + monkeypatch.setattr(Simulator, "eventMap", {}, raising=False) + def mock_sim(self, **kwargs): sat = MagicMock( set_dynamics=MagicMock(return_value=self.dyn), set_fsw=MagicMock(return_value=self.fsw),