Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/deploy_to_pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
6 changes: 4 additions & 2 deletions .github/workflows/deploy_to_test_pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
32 changes: 32 additions & 0 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
79 changes: 65 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

```
Expand All @@ -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")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (...)
Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------
Expand Down
8 changes: 5 additions & 3 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion paseos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 14 additions & 3 deletions paseos/activities/activity_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
8 changes: 4 additions & 4 deletions paseos/actors/actor_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions paseos/paseos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading