diff --git a/.github/workflows/deploy_to_pypi.yml b/.github/workflows/deploy_to_pypi.yml index 52d03e4..188a141 100644 --- a/.github/workflows/deploy_to_pypi.yml +++ b/.github/workflows/deploy_to_pypi.yml @@ -21,8 +21,10 @@ jobs: pip install build twine - name: Build and publish env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + # PyPI no longer accepts username/password uploads; the only supported + # credential is an API token sent as the `__token__` user. + TWINE_USERNAME: "__token__" + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* diff --git a/.github/workflows/deploy_to_test_pypi.yml b/.github/workflows/deploy_to_test_pypi.yml index 1cf9b5e..bf62614 100644 --- a/.github/workflows/deploy_to_test_pypi.yml +++ b/.github/workflows/deploy_to_test_pypi.yml @@ -21,8 +21,10 @@ jobs: pip install build twine - name: Build and publish env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + # (Test)PyPI no longer accepts username/password uploads; the only + # supported credential is an API token sent as the `__token__` user. + TWINE_USERNAME: "__token__" + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} run: | python -m build twine upload -r testpypi dist/* diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 2ec1e1b..78ebf40 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -62,3 +62,35 @@ jobs: hide-comment: false report-only-changed-files: false junitxml-path: ./pytest.xml + + # The pip job is pinned to Python 3.8 by pykep's wheels, but that is exactly the + # version where asyncio.get_event_loop() still papers over a missing loop, so the + # 3.10+ path would otherwise go untested. conda gets us a modern Python and + # doubles as a check that environment.yml still solves. + build-conda: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + MPLBACKEND: Agg + PYTHONUNBUFFERED: "1" + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Set up conda environment from environment.yml + uses: conda-incubator/setup-miniconda@v3 + with: + environment-file: environment.yml + activate-environment: paseos + miniforge-version: latest + # environment.yml only says python>=3.8, so guard the reason this job exists: + # if conda ever resolves back to <3.10 the event-loop regression test stops + # covering anything and CI would stay green. + - name: Report resolved versions and assert Python 3.10+ + shell: bash -el {0} + run: | + python -c "import sys, pykep, numpy; print(sys.version); print(pykep.__version__, numpy.__version__)" + python -c "import sys; assert sys.version_info >= (3, 10), f'conda resolved {sys.version_info}, this job must run 3.10+'" + - name: Test with pytest + shell: bash -el {0} + run: pytest -v --timeout=180 paseos/tests/ diff --git a/README.md b/README.md index e0f2c68..a2aabf3 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ other_spacraft_actor = ActorBuilder.get_actor_scaffold(name="other_sat", ActorBuilder.set_orbit(actor=other_spacraft_actor, position=[-10000000, 0, 0], velocity=[0, -8000.0, 0], - epoch=pk.epoch(0), central_body=earth) + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth")) #Create GroundstationActor grndStation = GroundstationActor(name="grndStation", epoch=pk.epoch(0)) @@ -277,13 +278,14 @@ ActorBuilder.set_orbit(actor=sat_actor, epoch=pk.epoch(0), central_body=earth) ``` -N.B. `set_orbit` creates an analytical [two-body](https://en.wikipedia.org/wiki/Two-body_problem) orbit. Perturbations such as Earth oblateness (J2) and atmospheric drag are not modelled, so propagated positions deviate from real satellite trajectories as the propagation horizon grows — for a satellite in low Earth orbit, typically hundreds of kilometers within a few hours and thousands of kilometers within a few days relative to the corresponding SGP4/TLE trajectory. If your orbit data comes from a TLE, prefer `set_TLE` below. +N.B. `set_orbit` creates an analytical [two-body](https://en.wikipedia.org/wiki/Two-body_problem) orbit. Perturbations such as Earth oblateness (J2) and atmospheric drag are not modelled, so propagated positions deviate from real satellite trajectories as the propagation horizon grows — for a satellite in low Earth orbit, typically tens of kilometers within the first few hours, hundreds within a day and thousands within a few days relative to the corresponding SGP4/TLE trajectory. If your orbit data comes from a TLE, prefer `set_TLE` below. ##### SGP4 / Two-line element (TLE) For using SGP4 / [Two-line element (TLE)](https://en.wikipedia.org/wiki/Two-line_element_set) you need to specify the TLE of the [SpacecraftActor](#spacecraftactor). In this case, we will use the TLE of the [Sentinel-2A](https://en.wikipedia.org/wiki/Sentinel-2) satellite from [celestrak](https://celestrak.com/). ```py +import pykep as pk from paseos import ActorBuilder, SpacecraftActor # Define an actor of type SpacecraftActor sat_actor = ActorBuilder.get_actor_scaffold(name="Sentinel-2A", @@ -327,7 +329,7 @@ You can access the orbit of a [SpacecraftActor](#spacecraftactor) with ```py # Position, velocity and altitude can be accessed like this -t0 = pk.epoch("2022-06-16 00:00:00.000") # Define the time (epoch) +t0 = pk.epoch_from_string("2022-06-16 00:00:00.000") # Define the time (epoch) print(sat_actor.get_position(t0)) print(sat_actor.get_position_velocity(t0)) print(sat_actor.get_altitude(t0)) @@ -366,6 +368,14 @@ from paseos import ActorBuilder, SpacecraftActor sat_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) + +# Solar panels need a central body to determine eclipses, so set an orbit first. +ActorBuilder.set_orbit(actor=sat_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth")) + # Add a power device ActorBuilder.set_power_devices(actor=sat_actor, battery_level_in_Ws=100, # current level @@ -408,8 +418,17 @@ The following parameters have to be specified for this: To use it, simply equip your [SpacecraftActor](#spacecraftactor) with a thermal model with: ```py +import pykep as pk from paseos import SpacecraftActor, ActorBuilder my_actor = ActorBuilder.get_actor_scaffold("my_actor", SpacecraftActor, pk.epoch(0)) + +# The thermal model needs a central body for albedo and IR flux, so set an orbit first. +ActorBuilder.set_orbit(actor=my_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth")) + ActorBuilder.set_thermal_model( actor=my_actor, actor_mass=50.0, # Setting mass to 50kg @@ -441,6 +460,7 @@ PASEOS models three types of radiation effects. You can add a radiation model affecting the operations of the devices you are interested in with ```py + import pykep as pk from paseos import SpacecraftActor, ActorBuilder my_actor = ActorBuilder.get_actor_scaffold("my_actor", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_radiation_model( @@ -463,7 +483,7 @@ To get a binary mask to model data corruption on the [local actor](#local-actor) ```py mask = paseos_instance.model_data_corruption(data_shape=your_data_shape, - exposure_time_in_s=your_time) + exposure_period_in_s=your_time) ``` #### Custom Modelling @@ -540,6 +560,9 @@ We assume `polyhedral_propagator` to be a custom propagator as explained in [Cus To correctly compute eclipses, we also need to know the orbit of the custom central body around the Sun. In this case we use the [orbital elements](https://en.wikipedia.org/wiki/Orbital_elements) one [can find online for 67P/Churyumov–Gerasimenko](https://en.wikipedia.org/wiki/67P/Churyumov–Gerasimenko). ```py +import pickle + +import numpy as np import pykep as pk from paseos import ActorBuilder, SpacecraftActor @@ -567,7 +590,7 @@ ActorBuilder.set_central_body(my_sat, comet, (mesh_points, mesh_triangles)) # Below computations will now use the mesh instead spherical approximations print(my_sat.is_in_eclipse()) -print(my_sat.is_in_line_of_sight(some_other_actor)) +print(my_sat.is_in_line_of_sight(some_other_actor, epoch)) # You could even specify a rotation of the central body. # Set a rotation period of 1 second around the z axis @@ -626,7 +649,7 @@ The next code snippet will show how to start the PASEOS simulation with a time d ```py import pykep as pk import paseos -from paseos import ActorBuilder, SpacecraftActor +from paseos import ActorBuilder, SpacecraftActor, load_default_cfg #Define today as pykep epoch (16-06-22) #please, refer to https://esa.github.io/pykep/documentation/core.html#pykep.epoch @@ -652,25 +675,32 @@ ActorBuilder.set_orbit( cfg=load_default_cfg() # Set simulation starting time by converting epoch to seconds cfg.sim.start_time=today.mjd2000 * pk.DAY2SEC -# initialize PASEOS simulation -sim = paseos.init_sim(local_actor) +# initialize PASEOS simulation with the modified cfg +sim = paseos.init_sim(local_actor, cfg) ``` -You can access the current simulation time (seconds since the start) and the current epoch like this: +You can access the current simulation time and the current epoch like this: ```py -time_since_start_in_s = sim.simulation_time +simulation_time_in_s = sim.simulation_time current_epoch = sim.local_time ``` +N.B. `sim.simulation_time` is `cfg.sim.start_time` plus the elapsed simulation time, not time since your simulation started. Since `start_time` is normally derived from an epoch (`init_sim` uses the local actor's epoch when you pass no cfg), it usually reads as seconds since MJD2000. Subtract `cfg.sim.start_time` if you want elapsed time. + #### Faster than real-time execution In some cases, you may be interested to simulate your spacecraft operating for an extended period. By default, PASEOS operates in real-time, thus this would take a lot of time. However, you can increase the rate of time passing (i.e. the spacecraft moving, power being charged / consumed etc.) using the `time_multiplier` parameter. Set it as follows when initializing PASEOS. ```py +import paseos +from paseos import load_default_cfg + +(...) # actor definition etc., see above + cfg = load_default_cfg() # loading cfg to modify defaults -cfg.sim.time_multiplier = 10 # setting the parameter so that in 1s real time, paseos models 10s having passed +cfg.sim.time_multiplier = 10.0 # setting the parameter so that in 1s real time, paseos models 10s having passed paseos_instance = paseos.init_sim(my_local_actor, cfg) # initialize paseos instance ``` @@ -682,7 +712,7 @@ Alternatively, you can rely on an event-based mode where PASEOS will simulate th ```py import pykep as pk import paseos - from paseos import ActorBuilder, SpacecraftActor + from paseos import ActorBuilder, SpacecraftActor, load_default_cfg # Define the central body as Earth by using pykep APIs. earth = pk.planet.jpl_lp("earth") @@ -881,6 +911,19 @@ ActorBuilder.set_power_devices(actor=local_actor, # Charging rate in W charging_rate_in_W=10) +# The constraint below reads the actor temperature, so a thermal model is required. +ActorBuilder.set_thermal_model( + actor=local_actor, + actor_mass=50.0, + actor_initial_temperature_in_K=273.15, + actor_sun_absorptance=1.0, + actor_infrared_absorptance=1.0, + actor_sun_facing_area=1.0, + actor_central_body_facing_area=1.0, + actor_emissive_area=1.0, + actor_thermal_capacity=1000, +) + # initialize PASEOS simulation sim = paseos.init_sim(local_actor) @@ -982,7 +1025,7 @@ sim.perform_activity("activity_A_with_termination_function", #### Visualization -Navigate to paseos/visualization to find a jupyter notebook containing examples of how to visualize PASEOS. +Navigate to [examples/visualization/example_jupyter.ipynb](https://github.com/aidotse/PASEOS/blob/main/examples/visualization/example_jupyter.ipynb) to find a jupyter notebook containing examples of how to visualize PASEOS. Visualization can be done in interactive mode or as an animation that is saved to your disc. In the figure below, Earth is visualized in the centre as a blue sphere with different spacecraft in orbit. Each spacecraft has a name and if provided, a battery level and a communications device. @@ -1014,8 +1057,16 @@ state_of_charge = instance.monitor["state_of_charge"] To evaluate your results, you will likely want to track the operational parameters, such as actor battery status, currently running activity etc. of actors over the course of your simulation. By default, PASEOS will log the current actor status every 10 seconds, however you can change that rate by editing the default configuration, as explained in [How to use the cfg](#how-to-use-the-cfg). You can save the current log to a \*.csv file at any point. ```py +import paseos +from paseos import load_default_cfg + +(...) # actor definition etc., see above + cfg = load_default_cfg() # loading cfg to modify defaults -cfg.io.logging_interval = 0.25 # log every 0.25 seconds +# Log every 0.25s. The interval is checked once per physics timestep, so it cannot be +# finer than cfg.sim.dt - lower dt as well if you want a sub-second logging rate. +cfg.sim.dt = 0.25 +cfg.io.logging_interval = 0.25 paseos_instance = paseos.init_sim(my_local_actor, cfg) # initialize paseos instance # Performing activities, running the simulation (...) diff --git a/docs/source/conf.py b/docs/source/conf.py index 2af7d1a..9ece297 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,7 +27,7 @@ author = "Pablo Gómez, Gabriele Meoni, Johan Östman, Vinutha Magal Shreenath" # The full version, including alpha/beta/rc tags -release = "v0.2.0" +release = "v0.2.1" # -- General configuration --------------------------------------------------- diff --git a/environment.yml b/environment.yml index 8fb1b7a..18ec8a0 100644 --- a/environment.yml +++ b/environment.yml @@ -4,18 +4,20 @@ channels: dependencies: - dotmap>=1.3.30 # core non-optional dependency - loguru>=0.6.0 # core non-optional dependency - - matplotlib>=3.6.0 # core non-optional dependency + - matplotlib>=3.6.0,<3.11 # core non-optional dependency; 3.11 requires numpy>=1.25 - numpy==1.23.5 # core non-optional depedency - myst-parser # for markdown math in docs - - pykep>=2.6 # core non-optional dependency + - pykep>=2.6,<3 # core non-optional dependency; PASEOS uses the 2.x API - pyquaternion>=0.9.9 # core non-optional dependency - pytest # for tests - pytest-asyncio # for tests involving activities + - pytest-cov # for coverage reports in CI-equivalent test runs + - pytest-timeout # tests are run with --timeout to catch hangs - python>=3.8 # core non-optional dependency + - ruff # for linting and formatting - scikit-spatial>=6.5.0 # core non-optional dependency - skyfield>=1.45 # core non-optional dependency - sphinx # for docs - sphinx_rtd_theme # for docs - toml>=0.10.2 # core non-optional dependency - - tqdm>=4.64.1 # core non-optional dependency - trimesh>=4.0.7 # for geometric model \ No newline at end of file diff --git a/paseos/__init__.py b/paseos/__init__.py index ef5e38a..7e3e3ff 100644 --- a/paseos/__init__.py +++ b/paseos/__init__.py @@ -30,7 +30,8 @@ def init_sim(local_actor: BaseActor, cfg: DotMap = None, starting_epoch: pk.epoc cfg (DotMap, optional): Configuration file. If None, default configuration will be used. Defaults to None. starting_epoch(pk.epoch): Starting epoch of the simulation. Will override cfg and local actor one. Returns: - PASEOS: Instance of the simulation (only one can exist, singleton) + PASEOS: Instance of the simulation. One instance per actor you wish to model; + several can coexist in the same process. """ logger.debug("Initializing simulation.") if cfg is None: diff --git a/paseos/activities/activity_manager.py b/paseos/activities/activity_manager.py index ce14d19..9c2f916 100644 --- a/paseos/activities/activity_manager.py +++ b/paseos/activities/activity_manager.py @@ -148,9 +148,20 @@ async def job(): # Workaround to avoid error when executed in a Jupyter notebook. self._paseos_instance._local_actor._current_activity = name - # Run activity and processor - loop = asyncio.get_event_loop() - if loop.is_running(): + # Run activity and processor. asyncio.run() refuses to nest inside an + # already-running loop (e.g. a Jupyter notebook), so ask whether one is + # running rather than using get_event_loop(): the latter raises once + # asyncio.run() has torn down and unset the loop, which made every + # perform_activity() after the first one fail on Python 3.10+. + try: + asyncio.get_running_loop() + loop_is_running = True + except RuntimeError: + loop_is_running = False + + # Deliberately outside the except block: running the activity inside the + # handler would chain any RuntimeError it raises onto "no running event loop". + if loop_is_running: asyncio.gather(job()) else: asyncio.run(job()) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 9fcff96..0ad6899 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -273,10 +273,10 @@ def set_orbit( are not modelled by this orbit, so propagated positions increasingly deviate from real satellite trajectories as the propagation horizon grows. For a satellite in low Earth orbit, the deviation from the corresponding SGP4/TLE - trajectory typically reaches hundreds of kilometers within a few hours and - thousands of kilometers within a few days. If your position / velocity come - from a TLE, use set_TLE instead to propagate with SGP4. For higher-fidelity - propagators, use set_custom_orbit. + trajectory is typically tens of kilometers within the first few hours, + hundreds within a day and thousands within a few days. If your position / + velocity come from a TLE, use set_TLE instead to propagate with SGP4. + For higher-fidelity propagators, use set_custom_orbit. Args: actor (BaseActor): The actor to define on diff --git a/paseos/paseos.py b/paseos/paseos.py index b9e4fe1..69c89a2 100644 --- a/paseos/paseos.py +++ b/paseos/paseos.py @@ -189,12 +189,14 @@ def advance_time( time_since_constraint_check += dt self.local_actor.set_time(pk.epoch(self._state.time * pk.SEC2DAY)) - # Check if we should update the status log - if self._time_since_previous_log > self._cfg.io.logging_interval: + # Check if we should update the status log. The elapsed time has to + # accumulate on every step, including the one that logs -- incrementing + # only in the else branch cost one extra step per interval, so a 10s + # interval at dt=10 logged every 30s. + self._time_since_previous_log += dt + if self._time_since_previous_log >= self._cfg.io.logging_interval: self.log_status() self._time_since_previous_log = 0 - else: - self._time_since_previous_log += dt logger.debug("New time is: " + str(self._state.time) + " s.") return max(target_time - self._state.time, 0) @@ -239,10 +241,14 @@ def model_data_corruption(self, data_shape: list, exposure_period_in_s: float): @property def simulation_time(self) -> float: - """Get the current simulation time of this paseos instance in seconds since start. + """Get the current simulation time of this paseos instance. + + This is cfg.sim.start_time plus the elapsed simulation time, not elapsed + time on its own -- for an epoch-derived start time it reads as seconds + since MJD2000. Returns: - float: Time since start in seconds. + float: Simulation time in seconds. """ return self._state.time diff --git a/paseos/tests/activity_test.py b/paseos/tests/activity_test.py index acafa46..157515a 100644 --- a/paseos/tests/activity_test.py +++ b/paseos/tests/activity_test.py @@ -156,3 +156,26 @@ async def on_termination(args): assert test_value == test_value2 assert sat1.battery_level_in_Ws >= 505 + + +def test_perform_activity_twice_in_a_row(): + """Two consecutive perform_activity calls have to work. + + Deliberately a sync test: the async tests above run inside an event loop and so + take the asyncio.gather branch, which is why this survived. In the sync branch + asyncio.run() unsets the current event loop, so the old asyncio.get_event_loop() + lookup raised RuntimeError on the second call from Python 3.10 onwards. + """ + sim, _, _ = get_default_instance() + + results = [] + + async def func(args): + results.append(1) + + sim.register_activity("Testing", activity_function=func, power_consumption_in_watt=10) + + sim.perform_activity("Testing") + sim.perform_activity("Testing") + + assert len(results) == 2, "Both activity runs should have executed." diff --git a/paseos/tests/operations_monitor_test.py b/paseos/tests/operations_monitor_test.py index 2e4463c..264806a 100644 --- a/paseos/tests/operations_monitor_test.py +++ b/paseos/tests/operations_monitor_test.py @@ -54,3 +54,38 @@ async def func2(args): sim.monitor.plot("state_of_charge") sim.save_status_log_csv("test.csv") + + +@pytest.mark.parametrize( + "dt,logging_interval,expected", + [ + # Interval equal to the timestep: a log on every step. + (10.0, 10.0, [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), + # Interval spanning several steps. This is the case that distinguishes the + # fix from "log unconditionally": before it, the step that logged did not + # count towards the next interval, stretching every gap by one dt. + (10.0, 30.0, [10, 40, 70, 100]), + (1.0, 10.0, [1, 11, 21, 31, 41, 51, 61, 71, 81, 91]), + ], +) +def test_logging_interval_is_respected(dt, logging_interval, expected): + """The status log has to fire at the configured interval, not a multiple of it.""" + earth = pk.planet.jpl_lp("earth") + sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + ActorBuilder.set_orbit(sat1, [10000000, 0, 0], [0, 8000.0, 0], pk.epoch(0), earth) + ActorBuilder.set_power_devices(sat1, 500, 10000, 1) + + cfg = load_default_cfg() + cfg.sim.start_time = 0.0 + cfg.sim.dt = dt + cfg.io.logging_interval = logging_interval + sim = paseos.init_sim(sat1, cfg) + + sim.advance_time(100.0, 0) + + # Compared with a tolerance: the simulation time accumulates dt step by step, + # so exact equality would trip over float representation. + timesteps = sim.monitor["timesteps"] + assert timesteps == pytest.approx(expected), ( + f"With dt={dt} and interval={logging_interval} expected logs at {expected}, got {timesteps}" + ) diff --git a/pyproject.toml b/pyproject.toml index 3e5b756..5472ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "paseos" -version = "0.2.0" +version = "0.2.1" description = "A package which simulates the space environment for operating multiple spacecraft." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.8" +# Upper bound follows the hard numpy==1.23.5 pin below: numpy 1.23.5 has no +# wheels for 3.12+ and its sdist does not build there, so an uncapped +# requires-python advertises installs that cannot possibly succeed. +requires-python = ">=3.8,<3.12" authors = [{ name = "Φ-lab@Sweden", email = "pablo.gomez@esa.int" }] keywords = ["spacecraft", "simulation", "space", "satellite", "constellation"] classifiers = [ @@ -23,14 +26,13 @@ classifiers = [ dependencies = [ "dotmap>=1.3.30", "loguru>=0.6.0", - "matplotlib>=3.6.0", + "matplotlib>=3.6.0,<3.11", "numpy==1.23.5", - "pykep>=2.6", + "pykep>=2.6,<3", "pyquaternion>=0.9.9", "scikit-spatial>=6.5.0", "skyfield>=1.45", "toml>=0.10.2", - "tqdm>=4.64.1", "trimesh>=4.0.7", ]