diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index 4d285329..52df9631 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -42,6 +42,7 @@ Development - |version| * Add a CI smoke test that builds and steps each benchmark environment. See issue #340. * Update files for compatibility with Ruff 0.16.0's new default lint rules, excluding C408. * Update ``np2EigenMatrix3d`` and ``np2EigenVectorXd`` import path to prevent deprecation warning while preserving compatibility with older versions of Basilisk. +* Add support for configurable RSO attitude dynamics and new relative attitude observations, enabling inspection of spinning and tumbling targets. Update the `RSO Inspection `_ example to demonstrate non-nadir-pointing targets. See issue #355. Version 1.3.0 ------------- diff --git a/examples/rso_inspection.ipynb b/examples/rso_inspection.ipynb index a53848ac..7db3b6cd 100644 --- a/examples/rso_inspection.ipynb +++ b/examples/rso_inspection.ipynb @@ -14,10 +14,23 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "4209273f", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:58:45.610095Z", + "start_time": "2026-10-07T18:58:45.595259Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/Users/afgh1891/Repositories/Pull_request/.venv_new/bin/python\n" + ] + } + ], "source": [ "from functools import partial\n", "from importlib.metadata import version\n", @@ -26,11 +39,11 @@ "import numpy as np\n", "from Basilisk.architecture import bskLogging\n", "from Basilisk.utilities.orbitalMotion import elem2rv\n", - "from Basilisk.utilities.RigidBodyKinematics import C2MRP\n", "\n", "from bsk_rl import ConstellationTasking, act, data, obs, sats, scene\n", "from bsk_rl.obs.relative_observations import rso_imaged_regions\n", "from bsk_rl.sim import dyn, fsw\n", + "from bsk_rl.utils.attitude import hill_frame_attitude\n", "from bsk_rl.utils.orbital import (\n", " fibonacci_sphere,\n", " random_orbit,\n", @@ -38,7 +51,7 @@ " relative_to_chief,\n", ")\n", "\n", - "bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)\n" + "bskLogging.setDefaultLogLevel(bskLogging.BSK_WARNING)" ] }, { @@ -52,10 +65,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "fde78b08", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:00.150824Z", + "start_time": "2026-10-07T18:59:00.138053Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'2.35.0'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "version(\"ray\") # Parent package of RLlib" ] @@ -72,9 +101,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "48b72ea2", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:02.401144Z", + "start_time": "2026-10-07T18:59:02.398418Z" + } + }, "outputs": [], "source": [ "class RSOSat(sats.Satellite):\n", @@ -83,7 +117,7 @@ " ]\n", " action_spec: ClassVar[list[act.Action]] = [act.NadirPoint(duration=1e9)]\n", " dyn_type = (dyn.ConjunctionDynModel, dyn.RSODynModel)\n", - " fsw_type = fsw.FSWModel\n" + " fsw_type = fsw.FSWModel" ] }, { @@ -96,9 +130,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "8459a5d1", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:04.318429Z", + "start_time": "2026-10-07T18:59:04.316216Z" + } + }, "outputs": [], "source": [ "rso_sat_args = dict(conjunction_radius=2.0)" @@ -115,9 +154,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "e01b5686", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:05.901309Z", + "start_time": "2026-10-07T18:59:05.899355Z" + } + }, "outputs": [], "source": [ "def sun_hat_chief(self, other):\n", @@ -133,7 +177,7 @@ " r_SB_N = r_SN_N - r_BN_N\n", " r_SB_N_hat = r_SB_N / np.linalg.norm(r_SB_N)\n", " HN = other.dynamics.HN\n", - " return HN @ r_SB_N_hat\n" + " return HN @ r_SB_N_hat" ] }, { @@ -149,9 +193,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "f3cb39ce", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:16.127999Z", + "start_time": "2026-10-07T18:59:16.115250Z" + } + }, "outputs": [], "source": [ "class InspectorSat(sats.Satellite):\n", @@ -170,6 +219,8 @@ " obs.RelativeProperties(\n", " dict(prop=\"r_DC_Hc\", norm=500),\n", " dict(prop=\"v_DC_Hc\", norm=5),\n", + " dict(prop=\"rso_sigma_BH\", norm=1.0),\n", + " dict(prop=\"rso_omega_BH\", norm=0.01),\n", " dict(\n", " prop=\"rso_imaged_regions\",\n", " fn=partial(\n", @@ -197,7 +248,7 @@ " fsw.SteeringFSWModel,\n", " fsw.MagicOrbitalManeuverFSWModel,\n", " fsw.RSOInspectorFSWModel,\n", - " )\n" + " )" ] }, { @@ -211,9 +262,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "4e999a09", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:20.973320Z", + "start_time": "2026-10-07T18:59:20.966452Z" + } + }, "outputs": [], "source": [ "inspector_sat_args = dict(\n", @@ -241,18 +297,22 @@ "A satellite argument randomizer is defined to configure the initial state of the satellites.\n", "The RSO is put into a random orbit with an apogee and perigee between 500 km and 1100 km.\n", "The inspector is placed in the region 250 to 750 meters from the RSO, with up to 1 m/s of\n", - "relative velocity. Finally, the RSO's attitude and body rate are set up to be in the\n", - "nadir-pointing initial configuration." + "relative velocity. Finally, the RSO's attitude and body rate are set up to be in the nadir-pointing initial configuration." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "f6e96922", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:27.598797Z", + "start_time": "2026-10-07T18:59:27.584625Z" + } + }, "outputs": [], "source": [ - "def sat_arg_randomizer(satellites):\n", + "def sat_arg_randomizer(satellites, spin_config=None):\n", " # Generate the RSO orbit\n", " R_E = 6371.0 # km\n", " a = R_E + np.random.uniform(500, 1100)\n", @@ -279,27 +339,15 @@ " )\n", " args.update(relative_randomizer([rso, inspector]))\n", "\n", - " # Align RSO Hill frame for initial nadir pointing\n", + " # Configure the RSO attitude. Zero relative spin preserves nadir pointing.\n", " mu = rso.sat_args_generator[\"mu\"]\n", " r_N, v_N = elem2rv(mu, args[rso][\"oe\"])\n", "\n", - " r_hat = r_N / np.linalg.norm(r_N)\n", - " v_hat = v_N / np.linalg.norm(v_N)\n", - " x = r_hat\n", - " z = np.cross(r_hat, v_hat)\n", - " z = z / np.linalg.norm(z)\n", - " y = np.cross(z, x)\n", - " HN = np.array([x, y, z])\n", - " BH = np.eye(3)\n", + " sigma_init, omega_init = hill_frame_attitude(r_N, v_N, spin_config=spin_config)\n", + " args[rso][\"sigma_init\"] = sigma_init\n", + " args[rso][\"omega_init\"] = omega_init\n", "\n", - " a = chief_orbit.a\n", - " T = np.sqrt(a**3 / mu) * 2 * np.pi\n", - " omega_BN_N = z * 2 * np.pi / T\n", - "\n", - " args[rso][\"sigma_init\"] = C2MRP(BH @ HN)\n", - " args[rso][\"omega_init\"] = BH @ HN @ omega_BN_N\n", - "\n", - " return args\n" + " return args" ] }, { @@ -315,9 +363,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "aa79834c", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:30.039680Z", + "start_time": "2026-10-07T18:59:30.027169Z" + } + }, "outputs": [], "source": [ "scenario = scene.SphericalRSO(\n", @@ -342,9 +395,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "02c7d5d1", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:34.184265Z", + "start_time": "2026-10-07T18:59:34.174431Z" + } + }, "outputs": [], "source": [ "rewarders = (\n", @@ -353,9 +411,11 @@ " completion_threshold=0.90,\n", " ),\n", " data.ResourceReward(\n", - " resource_fn=lambda sat: sat.fsw.dv_available\n", - " if isinstance(sat.fsw, fsw.MagicOrbitalManeuverFSWModel)\n", - " else 0.0,\n", + " resource_fn=lambda sat: (\n", + " sat.fsw.dv_available\n", + " if isinstance(sat.fsw, fsw.MagicOrbitalManeuverFSWModel)\n", + " else 0.0\n", + " ),\n", " reward_weight=np.random.uniform(0.0, 0.5),\n", " ),\n", ")" @@ -365,15 +425,37 @@ "cell_type": "markdown", "id": "025e1466", "metadata": {}, + "source": "By default, the RSO is initialized in a nadir-pointing attitude. Alternatively, `spin_config` can be used to specify a fixed or uniformly sampled body rate about a radial, tangential, normal, velocity-aligned, random, or user-defined Hill-frame axis. All body rates are specified in deg/s. Leaving ``rso_spin_config`` as ``None`` retains nadir pointing for this example." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3c64b98a530c0ca", + "metadata": {}, + "outputs": [], "source": [ - "With all the components defined, the environment can be instantiated." + "# For example, use {\"type\": \"fixed\", \"rate\": 1.0, \"axis\": \"normal\"} to add a 1 deg/s spin about the orbit-normal axis,\n", + "# or {\"type\": \"uniform\", \"low\": 0.0, \"high\": 1.0, \"axis\": \"normal\"} to set a random spin rate within a specific bound\n", + "rso_spin_config = None\n", + "configured_sat_arg_randomizer = partial(sat_arg_randomizer, spin_config=rso_spin_config)" ] }, + { + "cell_type": "markdown", + "id": "2783a78f35ca0fa8", + "metadata": {}, + "source": "With all the components defined, the environment can be instantiated." + }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "f19447ce", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:40.469489Z", + "start_time": "2026-10-07T18:59:40.457921Z" + } + }, "outputs": [], "source": [ "env = ConstellationTasking(\n", @@ -381,7 +463,7 @@ " RSOSat(\"RSO\", sat_args=rso_sat_args),\n", " InspectorSat(\"Inspector\", sat_args=inspector_sat_args, obs_type=dict),\n", " ],\n", - " sat_arg_randomizer=sat_arg_randomizer,\n", + " sat_arg_randomizer=configured_sat_arg_randomizer,\n", " scenario=scenario,\n", " rewarder=rewarders,\n", " time_limit=60000,\n", @@ -408,10 +490,83 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "0fcfc081", - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-10-07T18:59:47.406059Z", + "start_time": "2026-10-07T18:59:42.718129Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\u001b[90;3m2026-10-07 12:59:42,721 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[mResetting environment with seed=3806700768\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,190 \u001b[0m\u001b[mutils.orbital \u001b[0m\u001b[93mWARNING \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[93mCould not find eclipse transitions in next 12000.0 seconds\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,195 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[mEnvironment reset\u001b[0m\n", + "/Users/afgh1891/Repositories/Pull_request/.venv_new/lib/python3.12/site-packages/gymnasium/spaces/box.py:231: UserWarning: \u001b[33mWARN: Box low's precision lowered by casting to float32, current low.dtype=float64\u001b[0m\n", + " gym.logger.warn(\n", + "/Users/afgh1891/Repositories/Pull_request/.venv_new/lib/python3.12/site-packages/gymnasium/spaces/box.py:297: UserWarning: \u001b[33mWARN: Box high's precision lowered by casting to float32, current high.dtype=float64\u001b[0m\n", + " gym.logger.warn(\n", + "\u001b[90;3m2026-10-07 12:59:46,495 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[93;1m=== STARTING STEP ===\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,496 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[maction_nadir_point tasked for 1000000000.0 seconds\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,496 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[msetting timed terminal event at 1000000000.0\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,497 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mThrust clamped from 1.1321751672541251 m/s to 1.0 m/s.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,498 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mThrusting with inertial dV [ 0.33530239 -0.94189951 0.01994027] with 1783.0487060546875 second drift.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,500 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[msetting timed terminal event at 1783.0\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,500 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<0.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mFSW action action_inspect_rso activated.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,552 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mExceeded maximum range of 1000 m from RSO\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,557 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mInspected 8 points this step\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,558 \u001b[0m\u001b[mdata.rso_inspection \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mInspector inspected 8 new points.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,558 \u001b[0m\u001b[mdata.rso_inspection \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mInspected/Illuminated/Total: 8/35/100\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,558 \u001b[0m\u001b[mdata.composition \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mRSOInspectionReward reward: {'Inspector': 0.08}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,559 \u001b[0m\u001b[mdata.composition \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mResourceReward reward: {'Inspector': np.float64(-0.3615391244763904)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,559 \u001b[0m\u001b[mdata.base \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mTotal reward: {'Inspector': np.float64(-0.2815391244763904)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,561 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[93mWARNING \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[93mfailed range_valid check\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,570 \u001b[0m\u001b[mutils.orbital \u001b[0m\u001b[93mWARNING \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[93mCould not find eclipse transitions in next 12000.0 seconds\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,571 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mStep reward: {'Inspector': np.float64(-1.2815391244763905)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,571 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[mEpisode terminated: ['Inspector']\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,572 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[93;1m=== STARTING STEP ===\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,573 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[maction_nadir_point tasked for 1000000000.0 seconds\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,574 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[msetting timed terminal event at 1000001575.0\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,575 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mThrusting with inertial dV [-0.10399702 -0.16877615 0.55939106] with 10681.8955078125 second drift.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,576 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[msetting timed terminal event at 12256.9\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,576 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<1575.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mFSW action action_inspect_rso activated.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,927 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mtimed termination at 12256.9 \u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,942 \u001b[0m\u001b[mdata.rso_inspection \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[mInspected/Illuminated/Total: 8/46/100\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,943 \u001b[0m\u001b[mdata.composition \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[mResourceReward reward: {'Inspector': np.float64(-0.21456642807097814)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,944 \u001b[0m\u001b[mdata.base \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[mTotal reward: {'Inspector': np.float64(-0.21456642807097814)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,944 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mSatellite Inspector requires retasking\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,946 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[mStep reward: {}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,947 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[93;1m=== STARTING STEP ===\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,948 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[maction_nadir_point tasked for 1000000000.0 seconds\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,948 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[msetting timed terminal event at 1000012260.0\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,949 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mThrusting with inertial dV [-0.05931276 -0.43832896 -0.21176288] with 6205.38525390625 second drift.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,950 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[msetting timed terminal event at 18465.4\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:46,951 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<12260.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mFSW action action_inspect_rso activated.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,244 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mtimed termination at 18465.4 \u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,258 \u001b[0m\u001b[mdata.rso_inspection \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[mInspected/Illuminated/Total: 8/46/100\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,259 \u001b[0m\u001b[mdata.composition \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[mResourceReward reward: {'Inspector': np.float64(-0.17729938984206417)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,260 \u001b[0m\u001b[mdata.base \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[mTotal reward: {'Inspector': np.float64(-0.17729938984206417)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,261 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mSatellite Inspector requires retasking\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,262 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[mStep reward: {}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,263 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[93;1m=== STARTING STEP ===\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,265 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[maction_nadir_point tasked for 1000000000.0 seconds\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,265 \u001b[0m\u001b[36msats.satellite.RSO \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[36mRSO: \u001b[0m\u001b[msetting timed terminal event at 1000018470.0\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,267 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mThrusting with inertial dV [-0.84690159 -0.02538404 -0.33268091] with 3320.605224609375 second drift.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,268 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[msetting timed terminal event at 21790.6\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,269 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<18470.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mFSW action action_inspect_rso activated.\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,392 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<21795.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mtimed termination at 21790.6 \u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,401 \u001b[0m\u001b[mdata.rso_inspection \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<21795.00> \u001b[0m\u001b[mInspected/Illuminated/Total: 8/46/100\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,401 \u001b[0m\u001b[mdata.composition \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<21795.00> \u001b[0m\u001b[mResourceReward reward: {'Inspector': np.float64(-0.32909261306083193)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,402 \u001b[0m\u001b[mdata.base \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<21795.00> \u001b[0m\u001b[mTotal reward: {'Inspector': np.float64(-0.32909261306083193)}\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,402 \u001b[0m\u001b[92msats.satellite.Inspector \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<21795.00> \u001b[0m\u001b[92mInspector: \u001b[0m\u001b[mSatellite Inspector requires retasking\u001b[0m\n", + "\u001b[90;3m2026-10-07 12:59:47,403 \u001b[0m\u001b[mgym \u001b[0m\u001b[mINFO \u001b[0m\u001b[33m<21795.00> \u001b[0m\u001b[mStep reward: {}\u001b[0m\n" + ] + } + ], "source": [ "env.reset()\n", "for i in range(4):\n", @@ -421,7 +576,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -435,7 +590,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.10" + "version": "3.12.6" } }, "nbformat": 4, diff --git a/src/bsk_rl/obs/relative_observations.py b/src/bsk_rl/obs/relative_observations.py index cd5af610..10472def 100644 --- a/src/bsk_rl/obs/relative_observations.py +++ b/src/bsk_rl/obs/relative_observations.py @@ -3,7 +3,7 @@ from typing import Any import numpy as np -from Basilisk.utilities.RigidBodyKinematics import MRP2C +from Basilisk.utilities.RigidBodyKinematics import C2MRP, MRP2C from bsk_rl.obs import Observation from bsk_rl.utils.orbital import rv2HN, rv2omega @@ -87,6 +87,19 @@ def sigma_HdHc(deputy, chief): # frame to express in) +def rso_sigma_BH(_, rso): + """RSO body attitude relative to its Hill frame as MRPs.""" + BN = rso.dynamics.BN + HN = rso.dynamics.HN + BH = BN @ HN.T + return C2MRP(BH) + + +def rso_omega_BH(_, rso): + """RSO body rate relative to its Hill frame in Hill coordinates [rad/s].""" + return rso.dynamics.omega_BH_H + + def rso_imaged_regions( servicer, chief, @@ -236,5 +249,7 @@ def get_obs(self) -> dict[str, Any]: "sigma_DHc", "sigma_HdC", "sigma_HdHc", + "rso_sigma_BH", + "rso_omega_BH", "rso_imaged_regions", ] diff --git a/src/bsk_rl/utils/attitude.py b/src/bsk_rl/utils/attitude.py index 502ff2f8..c74d3c0c 100644 --- a/src/bsk_rl/utils/attitude.py +++ b/src/bsk_rl/utils/attitude.py @@ -1,6 +1,9 @@ """``bsk_rl.utils.attitude``: Attitude dynamics related utilities.""" +from typing import Any, Iterable, Optional + import numpy as np +from Basilisk.utilities.RigidBodyKinematics import C2MRP def random_tumble(maxSpinRate: float = 0.001): @@ -20,5 +23,97 @@ def random_tumble(maxSpinRate: float = 0.001): return sigma_bn, omega_bn +def hill_frame_attitude( + r_N: Iterable[float], + v_N: Iterable[float], + spin_config: Optional[dict[str, Any]] = None, +) -> tuple[np.ndarray, np.ndarray]: + """Creates an initially nadir-pointing attitude with configurable body spin. + + The body frame is initially aligned with the Hill frame and points nadir. Spin + rates in "spin_config" are specified in degrees per second. + + Args: + r_N: [m] Inertial position of the spacecraft. + v_N: [m/s] Inertial velocity of the spacecraft. + spin_config: Body spin relative to the Hill frame. + type can be "fixed" with a rate, or "uniform" with low and high rate bounds. + axis can be "radial", "tangential", "normal", "velocity", "random", or a three-element Hill-frame vector. + Defaults to zero spin about the radial axis. + + Returns: + Initial body attitude MRPs and body angular rate (sigma_BN, omega_BN_B). + + Raises: + ValueError: If an input is not a three-vector, the orbit does not define a + Hill frame, or the spin configuration is invalid. + """ + r_N = np.asarray(r_N, dtype=float) + v_N = np.asarray(v_N, dtype=float) + if r_N.shape != (3,) or v_N.shape != (3,): + raise ValueError("r_N and v_N must be three-element vectors") + + r_norm = np.linalg.norm(r_N) + h_N = np.cross(r_N, v_N) + h_norm = np.linalg.norm(h_N) + if np.isclose(r_norm, 0.0) or np.isclose(h_norm, 0.0): + raise ValueError("r_N and v_N must define a valid Hill frame") + + x = r_N / r_norm + z = h_N / h_norm + y = np.cross(z, x) + HN = np.array([x, y, z]) + BH = np.eye(3) + + config = {"type": "fixed", "rate": 0.0, "axis": "radial"} + if spin_config is not None: + unknown_keys = set(spin_config) - {"type", "rate", "low", "high", "axis"} + if unknown_keys: + raise ValueError(f"Unknown spin configuration keys: {unknown_keys}") + config.update(spin_config) + + if config["type"] == "fixed": + spin_rate = config["rate"] + elif config["type"] == "uniform": + if "low" not in config or "high" not in config: + raise ValueError("Uniform spin requires low and high rate bounds") + if config["low"] > config["high"]: + raise ValueError("Uniform spin low bound must not exceed high bound") + spin_rate = np.random.uniform(config["low"], config["high"]) + else: + raise ValueError(f"Unknown spin type: {config['type']}") + + axis = config["axis"] + if isinstance(axis, str): + named_axes = { + "radial": np.array([1.0, 0.0, 0.0]), + "tangential": np.array([0.0, 1.0, 0.0]), + "normal": np.array([0.0, 0.0, 1.0]), + "velocity": HN @ v_N, + } + if axis == "random": + spin_axis_H = np.random.normal(size=3) + elif axis in named_axes: + spin_axis_H = named_axes[axis] + else: + raise ValueError(f"Unknown spin axis: {axis}") + else: + spin_axis_H = np.asarray(axis, dtype=float) + + if spin_axis_H.shape != (3,): + raise ValueError("Spin axis must be a three-element vector") + axis_norm = np.linalg.norm(spin_axis_H) + if np.isclose(axis_norm, 0.0): + raise ValueError("Spin axis must be nonzero") + + # The instantaneous Hill-frame rate is valid for circular and eccentric orbits. + spin_rate_rad = np.radians(spin_rate) + omega_HN_N = h_N / r_norm**2 + omega_BH_H = spin_rate_rad * spin_axis_H / axis_norm + omega_BN_H = BH @ HN @ omega_HN_N + omega_BH_H + + return C2MRP(BH @ HN), omega_BN_H + + __doc_title__ = "Attitude" -__all__ = ["random_tumble"] +__all__ = ["hill_frame_attitude", "random_tumble"] diff --git a/tests/integration/test_int_rso_env.py b/tests/integration/test_int_rso_env.py index 194da30e..01b18c71 100644 --- a/tests/integration/test_int_rso_env.py +++ b/tests/integration/test_int_rso_env.py @@ -22,6 +22,8 @@ class RSOSat(sats.Satellite): class InspectorSat(sats.Satellite): observation_spec: ClassVar[list[obs.Observation]] = [ obs.RelativeProperties( + dict(prop="rso_sigma_BH", norm=1.0), + dict(prop="rso_omega_BH", norm=0.01), dict( prop="rso_imaged_regions", fn=partial( @@ -95,6 +97,12 @@ def test_inspection(): ) observation, _ = env.reset() + assert observation["Inspector"]["rel_props"]["rso_sigma_BH"].shape == (3,) + assert observation["Inspector"]["rel_props"]["rso_omega_BH_normd"].shape == (3,) + assert np.all(np.isfinite(observation["Inspector"]["rel_props"]["rso_sigma_BH"])) + assert np.all( + np.isfinite(observation["Inspector"]["rel_props"]["rso_omega_BH_normd"]) + ) in_eclipse_start = ( observation["Inspector"]["eclipse"][0] > observation["Inspector"]["eclipse"][1] ) diff --git a/tests/unittest/obs/test_relative_observations.py b/tests/unittest/obs/test_relative_observations.py index cfe61666..74dd1bee 100644 --- a/tests/unittest/obs/test_relative_observations.py +++ b/tests/unittest/obs/test_relative_observations.py @@ -4,7 +4,11 @@ import pytest from bsk_rl.obs import RelativeProperties -from bsk_rl.obs.relative_observations import rso_imaged_regions +from bsk_rl.obs.relative_observations import ( + rso_imaged_regions, + rso_omega_BH, + rso_sigma_BH, +) class TestRelativeProperties: @@ -54,6 +58,21 @@ def test_custom_prop(self): assert ob.get_obs() == {"prop": 3.0} +class TestRSOAttitudeProperties: + def test_sigma_BH(self): + rso = MagicMock() + rso.dynamics.BN = np.eye(3) + rso.dynamics.HN = np.eye(3) + + np.testing.assert_allclose(rso_sigma_BH(None, rso), np.zeros(3)) + + def test_omega_BH(self): + rso = MagicMock() + rso.dynamics.omega_BH_H = np.array([0.1, 0.2, 0.3]) + + np.testing.assert_allclose(rso_omega_BH(None, rso), [0.1, 0.2, 0.3]) + + class TestRSOImagedRegions: def test_one_region(self): servicer = MagicMock() diff --git a/tests/unittest/utils/test_attitude.py b/tests/unittest/utils/test_attitude.py new file mode 100644 index 00000000..0d75f13e --- /dev/null +++ b/tests/unittest/utils/test_attitude.py @@ -0,0 +1,107 @@ +import numpy as np +import pytest + +from bsk_rl.utils.attitude import hill_frame_attitude + + +class TestHillFrameAttitude: + def test_default_is_nadir_pointing(self): + sigma_BN, omega_BN_B = hill_frame_attitude( + r_N=[2.0, 0.0, 0.0], + v_N=[0.0, 4.0, 0.0], + ) + + np.testing.assert_allclose(sigma_BN, np.zeros(3), atol=1e-15) + np.testing.assert_allclose(omega_BN_B, [0.0, 0.0, 2.0]) + + def test_fixed_spin_with_custom_axis(self): + _, omega_BN_B = hill_frame_attitude( + r_N=[2.0, 0.0, 0.0], + v_N=[0.0, 4.0, 0.0], + spin_config={"type": "fixed", "rate": 0.5, "axis": [2.0, 0.0, 0.0]}, + ) + + np.testing.assert_allclose(omega_BN_B, [np.radians(0.5), 0.0, 2.0]) + + def test_rotated_hill_frame(self): + sigma_BN, omega_BN_B = hill_frame_attitude( + r_N=[0.0, 2.0, 0.0], + v_N=[-4.0, 0.0, 0.0], + spin_config={"type": "fixed", "rate": 0.5, "axis": "tangential"}, + ) + + assert not np.allclose(sigma_BN, np.zeros(3)) + np.testing.assert_allclose(omega_BN_B, [0.0, np.radians(0.5), 2.0]) + + def test_uniform_spin(self): + np.random.seed(0) + _, omega_BN_B = hill_frame_attitude( + r_N=[2.0, 0.0, 0.0], + v_N=[0.0, 4.0, 0.0], + spin_config={ + "type": "uniform", + "low": 1.0, + "high": 2.0, + "axis": "normal", + }, + ) + + relative_rate = np.degrees(omega_BN_B[2] - 2.0) + assert 1.0 <= relative_rate <= 2.0 + + def test_velocity_axis_for_eccentric_orbit(self): + _, omega_BN_B = hill_frame_attitude( + r_N=[2.0, 0.0, 0.0], + v_N=[1.0, 4.0, 0.0], + spin_config={"type": "fixed", "rate": 1.0, "axis": "velocity"}, + ) + + relative_rate_H = omega_BN_B - np.array([0.0, 0.0, 2.0]) + expected_axis_H = np.array([1.0, 4.0, 0.0]) / np.sqrt(17.0) + np.testing.assert_allclose(relative_rate_H, np.radians(1.0) * expected_axis_H) + + def test_random_axis_has_requested_rate(self): + np.random.seed(0) + _, omega_BN_B = hill_frame_attitude( + r_N=[2.0, 0.0, 0.0], + v_N=[0.0, 4.0, 0.0], + spin_config={"type": "fixed", "rate": 1.0, "axis": "random"}, + ) + + relative_rate = omega_BN_B - np.array([0.0, 0.0, 2.0]) + assert np.linalg.norm(relative_rate) == pytest.approx(np.radians(1.0)) + + @pytest.mark.parametrize( + "kwargs,match", + [ + ({"r_N": [0.0, 0.0, 0.0], "v_N": [0.0, 1.0, 0.0]}, "Hill frame"), + ({"r_N": [1.0, 0.0], "v_N": [0.0, 1.0, 0.0]}, "three-element"), + ( + { + "r_N": [1.0, 0.0, 0.0], + "v_N": [0.0, 1.0, 0.0], + "spin_config": {"axis": [0.0, 0.0, 0.0]}, + }, + "nonzero", + ), + ( + { + "r_N": [1.0, 0.0, 0.0], + "v_N": [0.0, 1.0, 0.0], + "spin_config": {"type": "unknown"}, + }, + "Unknown spin type", + ), + ( + { + "r_N": [1.0, 0.0, 0.0], + "v_N": [0.0, 1.0, 0.0], + "spin_config": {"axis": "unknown"}, + }, + "Unknown spin axis", + ), + ], + ) + def test_invalid_inputs(self, kwargs, match): + with pytest.raises(ValueError, match=match): + hill_frame_attitude(**kwargs)