From a35e8aceca118a2b11af0d6157797934e1d7db75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 11:14:13 +0200 Subject: [PATCH 1/7] chore(release): bump version to 0.2.1 Also tightens the dependency specs found broken while validating the release environment: - pykep was unbounded at >=2.6, so conda resolves it to 3.0, an incompatible API rewrite that PASEOS does not support. - matplotlib was unbounded at >=3.6.0, so it resolves to 3.11, which requires numpy>=1.25 while numpy is hard-pinned to 1.23.5 -- the environment could not be imported at all. - tqdm is not imported anywhere in the project. environment.yml additionally gains the dev tools CI relies on (pytest-cov, pytest-timeout, ruff) so a conda dev env can run the same checks. --- docs/source/conf.py | 2 +- environment.yml | 8 +++++--- pyproject.toml | 7 +++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 2af7d1a5..9ece2973 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 8fb1b7ad..18ec8a06 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/pyproject.toml b/pyproject.toml index 3e5b756d..f9506fd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ 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" } @@ -23,14 +23,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", ] From 0ce71ed66d539c79d9e19989b468aeee8ad326d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 12:02:52 +0200 Subject: [PATCH 2/7] ci: authenticate PyPI uploads with API tokens Both deploy workflows sent PYPI_USERNAME/PYPI_PASSWORD, but (Test)PyPI removed password-based uploads, so twine got a bare 403 Forbidden after a successful build and upload transfer. Switch to the only supported scheme: the literal user '__token__' with an API token as the password. Test PyPI and PyPI are separate services with separate accounts, so they need separate token secrets rather than the one shared credential pair used before. --- .github/workflows/deploy_to_pypi.yml | 6 ++++-- .github/workflows/deploy_to_test_pypi.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy_to_pypi.yml b/.github/workflows/deploy_to_pypi.yml index 52d03e4d..188a141a 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 1cf9b5e3..bf626145 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/* From 7d0587e8c41aee2144425cf60b4521e5fa2266a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 12:47:13 +0200 Subject: [PATCH 3/7] docs: fix README examples that do not run as printed Roughly a third of the runnable snippets failed when executed verbatim against an installed 0.2.1. Fixes, in order of appearance: - known-actors snippet referenced an undefined 'earth' - TLE and radiation snippets used 'pk' without importing pykep - 'accessing the orbit' used pk.epoch(), which pykep 2.x has no constructor for; epoch_from_string is what the README itself uses two sections later - power and thermal snippets attached models to actors with no orbit, so both raised on the very next line - model_data_corruption was documented with exposure_time_in_s; the parameter is exposure_period_in_s - load_default_cfg was called without being imported in four snippets - the 'using the cfg' snippet built a cfg and then called init_sim without it, defeating the point of the section - the constraint-function snippet read temperature_in_K without ever setting a thermal model, though the prose says one is required - simulation_time was described as 'seconds since the start'; with the default cfg it is seconds since MJD2000 - the visualization notebook is under examples/, not paseos/ - the logging_interval example set a value below cfg.sim.dt, where it cannot take effect - the two-body vs SGP4 deviation figures were overstated at short horizons; measured drift is tens of km in the first hours Also drops the stale 'singleton' claim from init_sim's docstring, which contradicted the README's documented multi-instance support. --- README.md | 68 ++++++++++++++++++++++++++++++++++++++-------- paseos/__init__.py | 3 +- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e0f2c683..c3773f81 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 @@ -626,7 +646,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,23 +672,28 @@ 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` counts from `cfg.sim.start_time`, which defaults to 0 at MJD2000. With the default cfg it is therefore seconds since MJD2000, not seconds since your simulation started. 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 + 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 paseos_instance = paseos.init_sim(my_local_actor, cfg) # initialize paseos instance @@ -682,7 +707,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 +906,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 +1020,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](examples/visualization) 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 +1052,14 @@ 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 + 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/paseos/__init__.py b/paseos/__init__.py index ef5e38ab..7e3e3ffc 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: From cb284590c402c7bf3569a7d61d9d99ec9b23be59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 13:08:26 +0200 Subject: [PATCH 4/7] fix: repeated activities and logging interval Found while validating the 0.2.1 artifact from TestPyPI. Both predate 0.2.0; neither is a regression from this release. - perform_activity() only worked once per process on Python 3.10+. asyncio.run() unsets the current event loop, so the next call's asyncio.get_event_loop() raised RuntimeError instead of silently creating a new loop as it did on 3.8/3.9. Ask get_running_loop() whether we are already inside a loop instead. - The status log fired every third step at dt == logging_interval: the elapsed-time counter was only incremented in the else branch, so the logging step itself did not count, and the comparison was strict. Also caps requires-python at <3.12, which the hard numpy==1.23.5 pin has always implied -- numpy 1.23.5 has no cp312 wheels and its sdist does not build there, so pip install could never succeed on 3.12+ on any platform. CI gains a conda job on the Python that environment.yml resolves to. The pip job is pinned to 3.8 by pykep's wheels, which is precisely the version where the event-loop bug does not reproduce, so the fix would otherwise have no coverage. --- .github/workflows/run_tests.yml | 27 +++++++++++++ environment.yml | 1 + paseos/activities/activity_manager.py | 15 ++++--- paseos/paseos.py | 10 +++-- paseos/tests/release_regression_test.py | 54 +++++++++++++++++++++++++ pyproject.toml | 7 +++- 6 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 paseos/tests/release_regression_test.py diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 2ec1e1b4..957686a7 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -62,3 +62,30 @@ 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 + - name: Report resolved versions + shell: bash -el {0} + run: python -c "import sys, pykep, numpy; print(sys.version); print(pykep.__version__, numpy.__version__)" + - name: Test with pytest + shell: bash -el {0} + run: pytest -v --timeout=180 paseos/tests/ diff --git a/environment.yml b/environment.yml index 18ec8a06..76f3cb1e 100644 --- a/environment.yml +++ b/environment.yml @@ -11,6 +11,7 @@ dependencies: - pyquaternion>=0.9.9 # core non-optional dependency - pytest # for tests - pytest-asyncio # for tests involving activities + - pytest-timeout # tests are run with --timeout to catch hangs - 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 diff --git a/paseos/activities/activity_manager.py b/paseos/activities/activity_manager.py index ce14d193..9cc6ba40 100644 --- a/paseos/activities/activity_manager.py +++ b/paseos/activities/activity_manager.py @@ -148,11 +148,16 @@ 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(): - asyncio.gather(job()) - else: + # 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() + except RuntimeError: asyncio.run(job()) + else: + asyncio.gather(job()) logger.info(f"Activity {activity} completed.") diff --git a/paseos/paseos.py b/paseos/paseos.py index b9e4fe17..75b1ad4a 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) diff --git a/paseos/tests/release_regression_test.py b/paseos/tests/release_regression_test.py new file mode 100644 index 00000000..f5496c80 --- /dev/null +++ b/paseos/tests/release_regression_test.py @@ -0,0 +1,54 @@ +"""Regression tests for bugs found while validating the 0.2.1 release.""" + +import pykep as pk +import pytest +from test_utils import get_default_instance + +import paseos +from paseos import ActorBuilder, SpacecraftActor, load_default_cfg + + +def test_perform_activity_twice_in_a_row(): + """Two consecutive perform_activity calls have to work. + + asyncio.run() unsets the current event loop, so the previous + asyncio.get_event_loop() lookup raised RuntimeError on the second call + from Python 3.10 onwards. Only reproduces on 3.10+, hence the conda CI job. + """ + 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." + + +def test_logging_interval_is_respected(): + """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 = 10.0 + cfg.io.logging_interval = 10.0 + 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. + # Before the fix this logged at 10, 40, 70, 100 -- every 30s. + timesteps = sim.monitor["timesteps"] + assert timesteps == pytest.approx([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), ( + f"Expected a log every 10s but got {timesteps}" + ) diff --git a/pyproject.toml b/pyproject.toml index f9506fd3..8d80ec38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,10 @@ 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 = [ @@ -19,6 +22,8 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ] dependencies = [ "dotmap>=1.3.30", From 555d329e6fb740280d2bed00fc72f3ec3b023c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 13:09:53 +0200 Subject: [PATCH 5/7] docs: fix time_multiplier and mesh line-of-sight snippets check_cfg validates float entries strictly, so time_multiplier needs 10.0 rather than 10, and is_in_line_of_sight takes a required epoch. Both were briefly going to be changed on the library side instead; fixing the snippets is the right way round. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c3773f81..096ce92a 100644 --- a/README.md +++ b/README.md @@ -587,7 +587,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 @@ -695,7 +695,7 @@ import paseos from paseos import load_default_cfg 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 ``` From 5e155531c31272e8828db0d54b619b25710ff47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 13:21:20 +0200 Subject: [PATCH 6/7] fix: address review on logging oracle, loop detection and metadata - Parametrize the logging test over (dt, logging_interval). The single interval == dt case it had could not tell the fix apart from logging unconditionally on every step; (10, 30) can. - Move both tests into the feature modules the repo organises by (activity_test, operations_monitor_test) instead of a bucket named after when the bugs were found. - Assert Python 3.10+ in the conda job. environment.yml only asks for >=3.8, so without this the job could silently stop covering the version the event-loop fix is about. - Hoist the running-loop detection out of the except handler so a RuntimeError from user activity code is not chained onto 'no running event loop'. - Drop the duplicate pytest-timeout; #226 already added it. - Drop the 3.10/3.11 classifiers. requires-python and classifiers describe the pip path, where pykep's 2.x wheels stop at 3.8, so advertising them is the same inconsistency the <3.12 cap removes. --- .github/workflows/run_tests.yml | 9 ++++- environment.yml | 1 - paseos/activities/activity_manager.py | 10 ++++- paseos/tests/activity_test.py | 23 +++++++++++ paseos/tests/operations_monitor_test.py | 35 ++++++++++++++++ paseos/tests/release_regression_test.py | 54 ------------------------- pyproject.toml | 2 - 7 files changed, 73 insertions(+), 61 deletions(-) delete mode 100644 paseos/tests/release_regression_test.py diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 957686a7..78ebf40e 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -83,9 +83,14 @@ jobs: environment-file: environment.yml activate-environment: paseos miniforge-version: latest - - name: Report resolved versions + # 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__)" + 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/environment.yml b/environment.yml index 76f3cb1e..18ec8a06 100644 --- a/environment.yml +++ b/environment.yml @@ -11,7 +11,6 @@ dependencies: - pyquaternion>=0.9.9 # core non-optional dependency - pytest # for tests - pytest-asyncio # for tests involving activities - - pytest-timeout # tests are run with --timeout to catch hangs - 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 diff --git a/paseos/activities/activity_manager.py b/paseos/activities/activity_manager.py index 9cc6ba40..9c2f9165 100644 --- a/paseos/activities/activity_manager.py +++ b/paseos/activities/activity_manager.py @@ -155,9 +155,15 @@ async def job(): # perform_activity() after the first one fail on Python 3.10+. try: asyncio.get_running_loop() + loop_is_running = True except RuntimeError: - asyncio.run(job()) - else: + 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()) logger.info(f"Activity {activity} completed.") diff --git a/paseos/tests/activity_test.py b/paseos/tests/activity_test.py index acafa461..157515a4 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 2e4463c3..264806a3 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/paseos/tests/release_regression_test.py b/paseos/tests/release_regression_test.py deleted file mode 100644 index f5496c80..00000000 --- a/paseos/tests/release_regression_test.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Regression tests for bugs found while validating the 0.2.1 release.""" - -import pykep as pk -import pytest -from test_utils import get_default_instance - -import paseos -from paseos import ActorBuilder, SpacecraftActor, load_default_cfg - - -def test_perform_activity_twice_in_a_row(): - """Two consecutive perform_activity calls have to work. - - asyncio.run() unsets the current event loop, so the previous - asyncio.get_event_loop() lookup raised RuntimeError on the second call - from Python 3.10 onwards. Only reproduces on 3.10+, hence the conda CI job. - """ - 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." - - -def test_logging_interval_is_respected(): - """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 = 10.0 - cfg.io.logging_interval = 10.0 - 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. - # Before the fix this logged at 10, 40, 70, 100 -- every 30s. - timesteps = sim.monitor["timesteps"] - assert timesteps == pytest.approx([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), ( - f"Expected a log every 10s but got {timesteps}" - ) diff --git a/pyproject.toml b/pyproject.toml index 8d80ec38..5472ece1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,6 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", ] dependencies = [ "dotmap>=1.3.30", From 984e2f07df617234254051b4e464905afa458fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Sat, 22 Aug 2026 13:28:58 +0200 Subject: [PATCH 7/7] docs: correct docstrings and links flagged in review - set_orbit and simulation_time docstrings carried the same wrong claims this PR corrects in the README prose. They are what users see via help() and autodoc, so the repo would otherwise contradict itself. - simulation_time is cfg.sim.start_time plus elapsed, and start_time is not 0 by default: init_sim overwrites it with the local actor's epoch when no cfg is passed. Reworded the N.B. accordingly. - The visualization notebook link is now absolute. This README is the PyPI long_description and is included into the Sphinx docs, where a repo-relative link 404s. - my_local_actor is never defined anywhere in the README, so two snippets still could not run as printed; both now use the '(...) # actor definition etc., see above' convention already in use. - The mesh snippet used pickle and np without importing them. --- README.md | 11 +++++++++-- paseos/actors/actor_builder.py | 8 ++++---- paseos/paseos.py | 8 ++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 096ce92a..a2aabf32 100644 --- a/README.md +++ b/README.md @@ -560,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 @@ -683,7 +686,7 @@ simulation_time_in_s = sim.simulation_time current_epoch = sim.local_time ``` -N.B. `sim.simulation_time` counts from `cfg.sim.start_time`, which defaults to 0 at MJD2000. With the default cfg it is therefore seconds since MJD2000, not seconds since your simulation started. Subtract `cfg.sim.start_time` if you want elapsed 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 @@ -694,6 +697,8 @@ In some cases, you may be interested to simulate your spacecraft operating for a 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.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 @@ -1020,7 +1025,7 @@ sim.perform_activity("activity_A_with_termination_function", #### Visualization -Navigate to [examples/visualization](examples/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. @@ -1055,6 +1060,8 @@ To evaluate your results, you will likely want to track the operational paramete import paseos from paseos import load_default_cfg +(...) # actor definition etc., see above + cfg = load_default_cfg() # loading cfg to modify defaults # 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. diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 9fcff96b..0ad68996 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 b9e4fe17..7d22a0ef 100644 --- a/paseos/paseos.py +++ b/paseos/paseos.py @@ -239,10 +239,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