diff --git a/.github/workflows/autoblack.yml b/.github/workflows/autoblack.yml new file mode 100644 index 00000000..bbccefad --- /dev/null +++ b/.github/workflows/autoblack.yml @@ -0,0 +1,15 @@ +name: Black Format Check + +on: [pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: psf/black@stable + with: + options: "--check --line-length 100" + src: "." + jupyter: false + version: "23.3.0" \ No newline at end of file diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 4834ec5e..a609f667 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -25,7 +25,10 @@ jobs: build: runs-on: ubuntu-latest - + permissions: + pull-requests: write + contents: read + id-token: write steps: - uses: actions/checkout@v2 - name: Set up Python 3.9 @@ -47,6 +50,18 @@ jobs: shell: bash -l {0} run: | micromamba activate paseos - cd paseos/tests - micromamba install pytest - pytest + micromamba install pytest pytest-cov pytest-asyncio + pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=paseos paseos/tests/ | tee pytest-coverage.txt + - name: Pytest coverage comment + uses: MishaKav/pytest-coverage-comment@main + if: github.event_name == 'pull_request' + with: + pytest-coverage-path: ./pytest-coverage.txt + title: Coverage Report + badge-title: Overall Coverage + hide-badge: false + hide-report: false + create-new-comment: false + hide-comment: false + report-only-changed-files: false + junitxml-path: ./pytest.xml diff --git a/.gitignore b/.gitignore index 94f0321e..33bb3551 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,7 @@ examples/Sentinel_2_example_notebook/La_Palma_02.tif paseos/tests/de421.bsp test.csv thermal_test.csv -results \ No newline at end of file +results +pytest-coverage.txt +pytest.xml +examples/Orekit_example/orekit-data.zip \ No newline at end of file diff --git a/README.md b/README.md index 5905d248..53bf786f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ ![GitHub](https://img.shields.io/github/license/aidotse/PASEOS?style=flat-square) ![GitHub contributors](https://img.shields.io/github/contributors/aidotse/PASEOS?style=flat-square) ![GitHub issues](https://img.shields.io/github/issues/aidotse/PASEOS?style=flat-square) ![GitHub pull requests](https://img.shields.io/github/issues-pr/aidotse/PASEOS?style=flat-square) +![Conda](https://img.shields.io/conda/dn/conda-forge/paseos?style=flat-square) ![PyPI - Downloads](https://img.shields.io/pypi/dm/paseos?style=flat-square) ![Alt Text](resources/images/sat_gif.gif) @@ -40,6 +41,8 @@ Disclaimer: This project is currently under development. Use at your own risk.
  • How to add a power device
  • Thermal Modelling
  • Radiation Modelling
  • +
  • Custom Modelling
  • +
  • Custom Central Bodies
  • Simulation Settings
  • +
  • Wrapping Other Software and Tools
  • + -
  • System Design of PASEOS
  • Glossary
  • +
  • Contributing
  • License
  • Contact
  • @@ -89,24 +100,27 @@ PASEOS allows simulating the effect of onboard and operational constraints on us ### pip / conda -`conda` support will follow in the near future. +The recommended way to install PASEOS is via [conda](https://docs.conda.io/en/latest/) / [mamba](https://github.com/conda-forge/miniforge#mambaforge) using + +``` -On Linux you can install via `pip` using +conda install paseos -c conda-forge ``` -pip install paseos +Alternatively, on Linux you can install via `pip` using ``` -This requires `Python 3.8.16` due to [pykep's limited support of pip](https://esa.github.io/pykep/installation.html). +pip install paseos -On Windows / OS X or if you encounter problems, please consider [setting up a dedicated](https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#creating-an-environment-from-an-environment-yml-file) `conda` environment to install dependencies with the provided `environment.yml` +``` +The pip version requires `Python 3.8.16` due to [pykep's limited support of pip](https://esa.github.io/pykep/installation.html). ### Building from source -For now, first of all clone the [GitHub](https://github.com/aidotse/PASEOS.git) repository as follows ([Git](https://git-scm.com/) required): +To build from source, first of all clone the [GitHub](https://github.com/aidotse/PASEOS.git) repository as follows ([Git](https://git-scm.com/) required): ``` git clone https://github.com/aidotse/PASEOS.git @@ -132,7 +146,9 @@ Alternatively, you can install PASEOS by using [pip](https://www.pypy.org/) as f cd PASEOS pip install -e . ``` + ### Using Docker + Two [Docker](https://www.docker.com/) images are available: * [paseos](https://hub.docker.com/r/gabrielemeoni/paseos): corresponding to the latest release. * [paseos-nightly](https://hub.docker.com/r/gabrielemeoni/paseos-nightly): based on the latest commit on the branch `main`. @@ -149,6 +165,7 @@ Comprehensive, self-contained examples can also be found in the `examples` folde * Modelling distributed learning on heterogeneous data in a constellation * Using PASEOS with MPI to run PASEOS on supercomputers * Using PASEOS to model the task of onboard satellite volcanic eruptions detection +* An example showing how total ionizing dose could be considered using a PASEOS [custom property](#customproperty) The following are small snippets on specific topics. @@ -174,7 +191,7 @@ sat_actor = ActorBuilder.get_actor_scaffold(name="mySat", #### Local and Known Actors -Once you have instantiated a [PASEOS simulation](#initializing-paseos) to know how to create an instance of PASEOS)), you can add other PASEOS [actors](#actor) ([Known actors](#known-actors)) to the simulation. You can use this, e.g., to facilitate communications between actors and to automatically monitor communication windows.
    +Once you have instantiated a [PASEOS simulation](#initializing-paseos) you can add other PASEOS [actors](#actor) ([Known actors](#known-actors)) to the simulation. You can use this, e.g., to study communications between actors and to automatically monitor communication windows.
    The next code snippet will add both a [SpacecraftActor](#spacecraftactor) and a [GroundstationActor](#ground-stationactor) (`other_sat`). An orbit is set for `other_sat`, which is placed around Earth at position `(x,y,z)=(-10000,0,0)` and velocity `(vx,vy,vz)=(0,-8000,0)` at epoch `epoch=pk.epoch(0)`. The latter (`grndStation`) will be placed at coordinates `(lat,lon)=(79.002723, 14.642972)` and elevation of 0 m.
    You cannot add a power device and an orbit to a `GroundstationActor`. @@ -182,18 +199,18 @@ The latter (`grndStation`) will be placed at coordinates `(lat,lon)=(79.002723, import pykep as pk import paseos from paseos import ActorBuilder, SpacecraftActor, GroundstationActor -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat and its orbit local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) -# Let's set the orbit of local_actor. -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=pk.epoch(0), - central_body=pk.epoch(0)) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # Initialize PASEOS simulation sim = paseos.init_sim(local_actor) @@ -207,7 +224,7 @@ 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=pk.epoch(0)) + epoch=pk.epoch(0), central_body=earth) #Create GroundstationActor grndStation = GroundstationActor(name="grndStation", epoch=pk.epoch(0)) @@ -230,7 +247,11 @@ sim.add_known_actor(grndStation) #### Set an orbit for a PASEOS SpacecraftActor -Once you have defined a [SpacecraftActor](#spacecraftactor), you can assign a [Keplerian orbit](https://en.wikipedia.org/wiki/Kepler_orbit) to it. To this aim, you need to define the central body the [SpacecraftActor](#spacecraftactor) is orbiting around and specify its position and velocity (in the central body's [inertial frame](https://en.wikipedia.org/wiki/Inertial_frame_of_reference)) and an epoch. In this case, we will use `Earth` as a central body. +Once you have defined a [SpacecraftActor](#spacecraftactor), you can assign a [Keplerian orbit](https://en.wikipedia.org/wiki/Kepler_orbit) or use [SGP4 (Earth orbit only)](https://en.wikipedia.org/wiki/Simplified_perturbations_models). + +##### Keplerian Orbit + +To this aim, you need to define the central body the [SpacecraftActor](#spacecraftactor) is orbiting around and specify its position and velocity (in the central body's [inertial frame](https://en.wikipedia.org/wiki/Inertial_frame_of_reference)) and an epoch. In this case, we will use `Earth` as a central body. ```py import pykep as pk @@ -250,6 +271,61 @@ ActorBuilder.set_orbit(actor=sat_actor, epoch=pk.epoch(0), central_body=earth) ``` +##### 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 +from paseos import ActorBuilder, SpacecraftActor +# Define an actor of type SpacecraftActor +sat_actor = ActorBuilder.get_actor_scaffold(name="Sentinel-2A", + actor_type=SpacecraftActor, + epoch=pk.epoch(0)) + +# Specify your TLE +line1 = "1 40697U 15028A 23188.15862373 .00000171 00000+0 81941-4 0 9994" +line2 = "2 40697 98.5695 262.3977 0001349 91.8221 268.3116 14.30817084419867" + +# Set the orbit of the actor +ActorBuilder.set_TLE(sat_actor, line1, line2) +``` + +##### Custom Propagators + +You can define any kind of function you would like to determine actor positions and velocities. This allows integrating more sophisticated propagators such as [orekit](https://www.orekit.org/). A dedicated example on this topic can be found in the `examples` folder. + +In short, you need to define a propagator function that returns the position and velocity of the actor at a given time. The function shall take the current epoch as arguments. You can then set the propagator function with + +```py +import pykep as pk +from paseos import ActorBuilder, SpacecraftActor +# Create a SpacecraftActor +starting_epoch = pk.epoch(42) +my_sat = ActorBuilder.get_actor_scaffold( + name="my_sat", actor_type=SpacecraftActor, epoch=starting_epoch +) + +# Define a custom propagator function that just returns a sinus position +def my_propagator(epoch: pk.epoch): + position,velocity = your_external_propagator(epoch) + return position,velocity + +# Set the custom propagator +ActorBuilder.set_custom_orbit(my_sat, my_propagator, starting_epoch) +``` + +##### Accessing the orbit +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) +print(sat_actor.get_position(t0)) +print(sat_actor.get_position_velocity(t0)) +print(sat_actor.get_altitude(t0)) +``` + + #### How to add a communication device The following code snippet shows how to add a communication device to a [SpacecraftActors] (#spacecraftactor). A communication device is needed to model the communication between [SpacecraftActors] (#spacecraftactor) or a [SpacecraftActor](#spacecraftactor) and [GroundstationActor](#ground-stationactor). Currently, given the maximum transmission data rate of a communication device, PASEOS calculates the maximum data that can be transmitted by multiplying the transmission data rate by the length of the communication window. The latter is calculated by taking the period for which two actors are in line-of-sight into account. @@ -313,7 +389,7 @@ The model is only available for a [SpacecraftActor](#spacecraftactor) and (like The following parameters have to be specified for this: -- Spacecraft mass [kg], initial temperature [K], emissive area (for heat disspiation) and thermal capacity [J / (kg * K)] +- Spacecraft mass [kg], initial temperature [K], emissive area (for heat dissipation) and thermal capacity [J / (kg * K)] - Spacecraft absorptance of Sun light, infrared light. [0 to 1] - Spacecraft area [m^2] facing Sun and central body, respectively - Solar irradiance in this orbit [W] (defaults to 1360W) @@ -329,7 +405,7 @@ my_actor = ActorBuilder.get_actor_scaffold("my_actor", SpacecraftActor, pk.epoch ActorBuilder.set_thermal_model( actor=my_actor, actor_mass=50.0, # Setting mass to 50kg - actor_initial_temperature_in_K=273.15, # Setting initialtemperature to 0°C + actor_initial_temperature_in_K=273.15, # Setting initial temperature to 0°C actor_sun_absorptance=1.0, # Depending on material, define absorptance actor_infrared_absorptance=1.0, # Depending on material, define absorptance actor_sun_facing_area=1.0, # Area in m2 @@ -382,6 +458,127 @@ mask = paseos_instance.model_data_corruption(data_shape=your_data_shape, exposure_time_in_s=your_time) ``` +#### Custom Modelling + +Beyond the default supported physical quantities (power, thermal, etc.) it possible to model any type of parameter by using custom properties. These are defined by a name, an update function and an initial value. The initial value is used to initialize the property. As for the other physical models, you can specify an update rate via the `cfg.sim.dt` [cfg parameter](#using-the-cfg). + +Custom properties are automatically logged in the [operations monitor](##monitoring-simulation-status). +Below is a simple example tracking actor altitude. + +```py +import pykep as pk +from paseos import ActorBuilder, SpacecraftActor + +# Define the local actor as a SpacecraftActor of name mySat and some orbit +local_actor = ActorBuilder.get_actor_scaffold( + name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0) +) + +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) + + +# Define the update function for the custom property +# PASEOS will always pass you the actor, the time step and the current power consumption +# The function shall return the new value of the custom property +def update_function(actor, dt, power_consumption): + return actor.get_altitude() # get current altitude + + +# Add the custom property to the actor, defining name, update fn and initial value +ActorBuilder.add_custom_property( + actor=local_actor, + property_name="altitude", + update_function=update_function, + initial_value=local_actor.get_altitude(), +) + +# One can easily access the property at any point with +print(local_actor.get_custom_property("altitude")) +``` + +#### Custom Central Bodies + +In most examples here you will see Earth via the pykep API being used as a spherical, central body for Keplerian orbits. For Keplerian orbits around spherical bodies, you can simply use pykep with an type of [pykep planet](https://esa.github.io/pykep/documentation/planets.html) just as the above examples used Earth. E.g. + +```py +import pykep as pk +from paseos import ActorBuilder, SpacecraftActor +# Define an actor of type SpacecraftActor of name mySat +sat_actor = ActorBuilder.get_actor_scaffold(name="mySat", + actor_type=SpacecraftActor, + epoch=pk.epoch(0)) + +# Define the central body as Mars by using pykep APIs. +mars = pk.planet.jpl_lp("mars") + +# Let's set the orbit of sat_actor. +ActorBuilder.set_orbit(actor=sat_actor, + position=[10000000, 1, 1], + velocity=[1, 1000.0, 1], + epoch=pk.epoch(0), + central_body=mars) +``` + +However, you can also use any other central body defined via a mesh. This is especially useful in conjunction with [custom propagators](#custom-propagators). To use a custom central body, you need to define a mesh and add it to the simulation configuration. The following example shows how to do this for the comet 67P/Churyumov–Gerasimenko. + +We assume `polyhedral_propagator` to be a custom propagator as explained in [Custom Propagators](#custom-propagators). + +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 pykep as pk +from paseos import ActorBuilder, SpacecraftActor + +# Define the epoch and orbital elements +epoch = pk.epoch(2460000.5, "jd") +elements = (3.457 * pk.AU, 0.64989, 3.8719 * pk.DEG2RAD, 36.33 * pk.DEG2RAD, 22.15 * pk.DEG2RAD, 73.57 * pk.DEG2RAD) + +# Create a planet object from pykep for 67P +comet = pk.planet.keplerian(epoch, elements, pk.MU_SUN, 666.19868, 2000, 2000, "67P") + +# Load the 67P mesh with pickle +with open(mesh_path, "rb") as f: + mesh_points, mesh_triangles = pickle.load(f) + mesh_points = np.array(mesh_points) + mesh_triangles = np.array(mesh_triangles) + +# Define local actor +my_sat = ActorBuilder.get_actor_scaffold("my_sat", SpacecraftActor, epoch=epoch) + +# Set the custom propagator +ActorBuilder.set_custom_orbit(my_sat, polyhedral_propagator, epoch) + +# Set the mesh +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)) + +# You could even specify a rotation of the central body. +# Set a rotation period of 1 second around the z axis +ActorBuilder.set_central_body( + my_sat, + comet, + (mesh_points, mesh_triangles), + rotation_declination=90, + rotation_right_ascension=0, + rotation_period=1, +) + +``` + +This is particularly useful if you want to use a central body that is not included in pykep or if you want to use a central body that is not a planet (e.g. an asteroid). + +N.B. `get_altitude` computes the altitude above [0,0,0] in the central body's frame, thus is not affected by the central body's rotation or mesh. +N.B. #2 Any custom central body still has to orbit the Sun for PASEOS to function correctly. + ### Simulation Settings #### Initializing PASEOS @@ -394,20 +591,17 @@ We will now show how to create an instance of PASEOS. An instance of PASEOS shal import pykep as pk import paseos from paseos import ActorBuilder, SpacecraftActor -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat and its orbit local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) -# Define the central body as Earth by using pykep APIs. -earth = pk.planet.jpl_lp("earth") - -# Let's set the orbit of local_actor. -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=pk.epoch(0), - central_body=earth) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # initialize PASEOS simulation sim = paseos.init_sim(local_actor) @@ -430,23 +624,21 @@ from paseos import ActorBuilder, SpacecraftActor #please, refer to https://esa.github.io/pykep/documentation/core.html#pykep.epoch today = pk.epoch_from_string('2022-06-16 00:00:00.000') -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat # pk.epoch is set to today local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=today) -# Define the central body as Earth by using pykep APIs. -earth = pk.planet.jpl_lp("earth") - # Let's set the orbit of local_actor. # pk.epoch is set to today -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=today, - central_body=earth) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # Loading cfg to modify defaults cfg=load_default_cfg() @@ -488,19 +680,19 @@ Alternatively, you can rely on an event-based mode where PASEOS will simulate th earth = pk.planet.jpl_lp("earth") # Define a satellite with some orbit and simple power model - my_sat = ActorBuilder.get_actor_scaffold("MySat", 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, 1000, 1) + local_actor = ActorBuilder.get_actor_scaffold("MySat", SpacecraftActor, pk.epoch(0)) + ActorBuilder.set_orbit(local_actor, [10000000, 0, 0], [0, 8000.0, 0], pk.epoch(0), earth) + ActorBuilder.set_power_devices(local_actor, 500, 1000, 1) # Abort when sat is at 10% battery def constraint_func(): - return sat1.state_of_charge > 0.1 + return local_actor.state_of_charge > 0.1 # Set some settings to control evaluation of the constraint cfg = load_default_cfg() # loading cfg to modify defaults cfg.sim.dt = 0.1 # setting timestep of physical models (power, thermal, ...) cfg.sim.activity_timestep = 1.0 # how often constraint func is evaluated - sim = paseos.init_sim(sat1, cfg) # Init simulation + sim = paseos.init_sim(local_actor, cfg) # Init simulation # Advance for a long time, will interrupt much sooner due to constraint function sim.advance_time(3600, 10, constraint_function=constraint_func) @@ -529,21 +721,18 @@ import pykep as pk import paseos from paseos import ActorBuilder, SpacecraftActor import asyncio -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat and its orbit local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) -# Define the central body as Earth by using pykep APIs. -earth = pk.planet.jpl_lp("earth") - -# Let's set the orbit of sat_actor. -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=pk.epoch(0), - central_body=earth) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # Add a power device ActorBuilder.set_power_devices(actor=local_actor, @@ -594,21 +783,18 @@ import pykep as pk import paseos from paseos import ActorBuilder, SpacecraftActor import asyncio -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat and its orbit local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) -# Define the central body as Earth by using pykep APIs. -earth = pk.planet.jpl_lp("earth") - -# Let's set the orbit of sat_actor. -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=pk.epoch(0), - entral_body=earth) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # Add a power device ActorBuilder.set_power_devices(actor=local_actor, @@ -665,21 +851,18 @@ import pykep as pk import paseos from paseos import ActorBuilder, SpacecraftActor import asyncio -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat and its orbit local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) -# Define the central body as Earth by using pykep APIs. -earth = pk.planet.jpl_lp("earth") - -# Let's set the orbit of sat_actor. -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=pk.epoch(0), - central_body=earth) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # Add a power device ActorBuilder.set_power_devices(actor=local_actor, @@ -734,21 +917,18 @@ import pykep as pk import paseos from paseos import ActorBuilder, SpacecraftActor import asyncio -# Define an actor of type SpacecraftActor of name mySat -# (that will be the local actor) +# Define the local actor as a SpacecraftActor of name mySat and its orbit local_actor = ActorBuilder.get_actor_scaffold(name="mySat", actor_type=SpacecraftActor, epoch=pk.epoch(0)) -# Define the central body as Earth by using pykep APIs. -earth = pk.planet.jpl_lp("earth") - -# Let's set the orbit of sat_actor. -ActorBuilder.set_orbit(actor=local_actor, - position=[10000000, 0, 0], - velocity=[0, 8000.0, 0], - epoch=pk.epoch(0), - central_body=earth) +ActorBuilder.set_orbit( + actor=local_actor, + position=[10000000, 0, 0], + velocity=[0, 8000.0, 0], + epoch=pk.epoch(0), + central_body=pk.planet.jpl_lp("earth"), # use Earth from pykep +) # Add a power device ActorBuilder.set_power_devices(actor=local_actor, @@ -823,7 +1003,7 @@ state_of_charge = instance.monitor["state_of_charge"] #### Writing Simulation Results to a File -To evaluate your results, you will likely want to track the operational parameters, such as actor battery status, currently running activitiy 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. +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 cfg = load_default_cfg() # loading cfg to modify defaults @@ -835,10 +1015,98 @@ paseos_instance = paseos.init_sim(my_local_actor, cfg) # initialize paseos insta paseos_instance.save_status_log_csv("output.csv") ``` +### Wrapping Other Software and Tools + +PASEOS is designed to allow easily wrapping other software and tools to, e.g., use more sophisticated models for specific aspects of interest to the user. There are three ways to do this: + +* [Via Activities](#via-activities) - An [activity](#simple-activity) using an external software is registered and executed as any other [activity](#activity), e.g. to perform some computations while tracking runtime of that operation. +* [Via Constraint Functions](#via-constraint-functions) - A [constraint function](#constraint-function) using an external software. This is useful to use a more sophisticated model to check whether, e.g., a physical constraint modelled outside of PASEOS is met. +* [Via Custom Properties](#via-custom-properties) - A [custom property](#custom-property) using an external software. This is useful to, e.g., use a more sophisticated model for a physical quantity such as total ionization dose or current channel bandwidth. + +#### Via Activities + +The wrapping via activities is quite straight forward. Follow the [instructions on registering and performing activities](#simple-activity) and make use of your external software inside the activity function. + +```py +import my_external_software +#Activity function +async def activity_function_A(args): + my_external_software.complex_task_to_model() + await asyncio.sleep(0.01) +``` + +#### Via Constraint Functions + +Inside constraint functions, external software can be used to check whether a constraint is met or not. This works both for [activity constraints](#constraint-function) and for [constraints in event-based mode](#event-based-mode). + + +The constraint function should return `True` if the constraint is met and `False` otherwise. + +```py +import pykep as pk +from paseos import ActorBuilder, SpacecraftActor + +import my_complex_radiation_model + +# Defining a local actor +local_actor = ActorBuilder.get_actor_scaffold("MySat", SpacecraftActor, pk.epoch(0)) + +def constraint_func(): + t = local_actor.local_time + device_has_failed = my_complex_radiation_model.check_for_device_failure(t) + return not device_has_failed + +# Can be passed either with event-based mode, will run until constraint is not met +sim.advance_time(3600, 10, constraint_function=constraint_func) + +# (...) + +# or via activity constraints, will run until constraint is not met +# N.B: this is an excerpt follow the #constraint-function link for more details +sim.register_activity( + "activity_A_with_constraint_function", + activity_function=activity_function_A, + power_consumption_in_watt=10, + constraint_function=constraint_func +) +``` + +#### Via Custom Properties + +Finally, [custom properties](#custom-modelling) can be used to wrap external software. This is useful to use a more sophisticated model for a physical quantity, e.g. one could use a simulator like [ns-3](https://www.nsnam.org/) to model the current channel bandwidth. + +For more details see [custom properties](#custom-modelling). + +```py +import my_channel_model + +# Will be automatically called during PASEOS simulation +def update_function(actor, dt, power_consumption): + # Get the current channel bandwidth from the external model + channel_bandwidth = my_channel_model.get_channel_bandwidth(actor) + return channel_bandwidth + +# Add the custom property to the actor, defining name, update fn and initial value +ActorBuilder.add_custom_property( + actor=local_actor, + property_name="channel_bandwidth", + update_function=update_function, + initial_value=1000, +) + +# (... run simulation) + +# One can easily access the property at any point with +print(local_actor.get_custom_property("channel_bandwidth")) + +``` + + ## Glossary @@ -859,6 +1127,10 @@ paseos_instance.save_status_log_csv("output.csv") A constraint function is an asynchronous function that can be used by the PASEOS user to specify some constraints that shall be met during the execution of an activity. +- ### Custom Property + + Users can define their own physical quantity to track parameters not natively simulated by PASEOS. This is described in detail [above](#custom-modelling) and in a dedicated example notebook on modelling total ionizing dose. + - ### GroundstationActor `PASEOS actor` emulating a ground station. @@ -878,6 +1150,34 @@ paseos_instance.save_status_log_csv("output.csv") - ### SpacecraftActor PASEOS [actor](#actor) emulating a spacecraft or a satellite. +### Physical Model Parameters + +Description of the physical model parameters and default values in PASEOS with indications on sensitivity of parameters and suggested ranges. + +| Name | Datatype | Description | Default | Suggested Range | Sensitivity | +| :-------------------------------: | :------: | :-------------------------------------------------------------------------: | :--------: | :-------------: | :---------: | +| Battery Level [Ws] | float | Current battery level | - | > 0 | high | +| Maximum Battery Level [Ws] | float | Maximum battery level | - | > 0 | high | +| Charging Rate [W] | float | Charging rate of the battery | - | > 0 | high | +| Power Device Type | enum | Type of power device. Can be either "SolarPanel" or "RTG" | SolarPanel | - | medium | +| Data Corruption Events [Hz] | float | Rate of single bit of data being corrupted, i.e. a Single Event Upset (SEU) | - | >= 0 | low | +| Restart Events [Hz] | float | Rate of device restart being triggered | - | >= 0 | medium | +| Failure Events [Hz] | float | Rate of complete device failure due to a Single Event Latch-Up (SEL) | - | >= 0 | high | +| Mass [kg] | float | Actor's mass | - | > 0 | low | +| Initial Temperature [K] | float | Actor's initial temperature | - | >= 0 | medium | +| Sun Absorptance | float | Actor's absorptance of solar light | - | [0,1] | high | +| Infrared Absorptance | float | Actor's absportance of infrared light | - | [0,1] | medium | +| Sun-Facing Area [$m^2$] | float | Actor's area facing the sun | - | >= 0 | high | +| Central Body-Facing Area [$m^2$] | float | Actor's area facing central body | - | >= 0 | medium | +| Emissive Area [$m^2$] | float | Actor's area emitting (radiating) heat | - | >= 0 | high | +| Thermal Capacity [$J / (kg * K)$] | float | Actor's thermal capacity | - | >= 0 | low | +| Body Solar Irradiance [W] | float | Irradiance from the sun | 1360 | >= 0 | medium | +| Body Surface Temperature [K] | float | Central body surface temperature | 288 | >= 0 | low | +| Body Emissivity | float | Central body emissivity in infrared | 0.6 | [0,1] | medium | +| Body Reflectance | float | Central body reflectance of sunlight | 0.3 | [0,1] | medium | +| Heat Conversion Ratio [-] | float | Conversion ratio for activities, 0 leads to know heat-up due to activity | 0.5 | [0,1] | high | + + ## Contributing The `PASEOS` project is open to contributions. To contribute, you can open an [issue](https://github.com/gomezzz/MSMatch/issues) to report a bug or to request a new feature. If you prefer discussing new ideas and applications, you can contact us via email (please, refer to [Contact](#contact)). @@ -898,7 +1198,7 @@ Distributed under the GPL-3.0 License. Created by $\Phi$[-lab@Sweden](https://www.ai.se/en/data-factory/f-lab-sweden). - Pablo Gómez - pablo.gomez at esa.int, pablo.gomez at ai.se -- Gabriele Meoni - gabriele.meoni at esa.int, gabriele.meoni at ai.se +- Gabriele Meoni - gabriele.meoni at esa.int, g.meoni at tudelft.nl - Johan Östman - johan.ostman at ai.se - Vinutha Magal Shreenath - vinutha at ai.se diff --git a/environment.yml b/environment.yml index fcdd166c..4a6c4891 100644 --- a/environment.yml +++ b/environment.yml @@ -8,6 +8,7 @@ dependencies: - numpy==1.23.5 # core non-optional depedency - myst-parser # for markdown math in docs - pykep>=2.6 # core non-optional dependency + - pyquaternion>=0.9.9 # core non-optional dependency - pytest # for tests - pytest-asyncio # for tests involving activities - python>=3.8 # core non-optional dependency diff --git a/examples/Constellation_example/constellation_example_utils.py b/examples/Constellation_example/constellation_example_utils.py index a75d86b2..1fb578f6 100644 --- a/examples/Constellation_example/constellation_example_utils.py +++ b/examples/Constellation_example/constellation_example_utils.py @@ -18,7 +18,6 @@ def get_closest_entry(df, t, id): def get_analysis_df(df, timestep=60, orbital_period=1): - t = np.round(np.linspace(0, df.Time.max(), int(df.Time.max() // timestep))) sats = df.ID.unique() df["known_actors"] = pd.Categorical(df.known_actors) diff --git a/examples/Learning_example/simple_neural_network.py b/examples/Learning_example/simple_neural_network.py index 5cb7f301..41e867d7 100644 --- a/examples/Learning_example/simple_neural_network.py +++ b/examples/Learning_example/simple_neural_network.py @@ -57,9 +57,7 @@ def __len__(self): # Instantiate training and test data X, y = make_circles(n_samples=10000, noise=0.05, random_state=26) - X_train, X_test, y_train, y_test = train_test_split( - X, y, test_size=0.33, random_state=26 - ) + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=26) # divide the training set so none of the peers have the same data if self.node_id == 1: @@ -75,12 +73,8 @@ def __len__(self): # Create dataloaders train_data = Data(X_train, y_train) test_data = Data(X_test, y_test) - self.train_dataloader = DataLoader( - dataset=train_data, batch_size=64, shuffle=True - ) - self.test_dataloader = DataLoader( - dataset=test_data, batch_size=64, shuffle=True - ) + self.train_dataloader = DataLoader(dataset=train_data, batch_size=64, shuffle=True) + self.test_dataloader = DataLoader(dataset=test_data, batch_size=64, shuffle=True) def forward(self, x): """Do inference on model diff --git a/examples/Learning_example/simple_node.py b/examples/Learning_example/simple_node.py index 33eb70a7..8269601a 100644 --- a/examples/Learning_example/simple_node.py +++ b/examples/Learning_example/simple_node.py @@ -13,13 +13,10 @@ class Node: """ def __init__(self, node_id, pos_and_vel, paseos_cfg, power_consumption_in_watt): - # Create PASEOS instance to node earth = pk.planet.jpl_lp("earth") self.node_id = node_id - sat = ActorBuilder.get_actor_scaffold( - f"sat{node_id}", SpacecraftActor, pk.epoch(0) - ) + sat = ActorBuilder.get_actor_scaffold(f"sat{node_id}", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit(sat, pos_and_vel[0], pos_and_vel[1], pk.epoch(0), earth) ActorBuilder.set_power_devices( actor=sat, @@ -37,8 +34,7 @@ def __init__(self, node_id, pos_and_vel, paseos_cfg, power_consumption_in_watt): transmit_bits = self.model_size() self.transmit_duration = transmit_bits / ( - 1000 - * self.paseos.local_actor.communication_devices["link"].bandwidth_in_kbps + 1000 * self.paseos.local_actor.communication_devices["link"].bandwidth_in_kbps ) self.current_activity = "train" @@ -70,9 +66,7 @@ def model_size(self): # Return model parameters as a list of NumPy ndarrays bytestream = b"" # Empty byte represenation for _, val in self.model.state_dict().items(): # go over each layer - bytestream += ( - val.cpu().numpy().tobytes() - ) # convert layer to bytes and concatenate + bytestream += val.cpu().numpy().tobytes() # convert layer to bytes and concatenate return len(bytestream) * 8 def local_time(self): @@ -112,9 +106,7 @@ def transmission_is_feasible(self, target_node): target_actor = target_node.paseos.local_actor local_actor = self.paseos.local_actor - transmit_end = pk.epoch( - self.local_time().mjd2000 + self.transmit_duration * pk.SEC2DAY - ) + transmit_end = pk.epoch(self.local_time().mjd2000 + self.transmit_duration * pk.SEC2DAY) los_end = local_actor.is_in_line_of_sight(target_actor, transmit_end) return los_end diff --git a/examples/MPI_example/mpi_example.py b/examples/MPI_example/mpi_example.py index 1370c35e..a9f6ad4d 100644 --- a/examples/MPI_example/mpi_example.py +++ b/examples/MPI_example/mpi_example.py @@ -26,9 +26,7 @@ try: from mpi4py import MPI except: - print( - "This example requires mpi4py. Please install with conda install mpi4py -c conda-forge" - ) + print("This example requires mpi4py. Please install with conda install mpi4py -c conda-forge") import pykep as pk import paseos @@ -44,9 +42,7 @@ # Now we will initialize MPI, for more details please refer to the mpi4py docs. # In MPI "rank" indicates the index of the compute node (so 0-3 in our example). comm = MPI.COMM_WORLD -assert ( - comm.Get_size() == 4 -), "Please run the example with mpiexec -n 4 python mpi_example.py" +assert comm.Get_size() == 4, "Please run the example with mpiexec -n 4 python mpi_example.py" rank = comm.Get_rank() other_ranks = [x for x in range(4) if x != rank] print(f"Started rank {rank}, other ranks are {other_ranks}") @@ -65,9 +61,7 @@ planet_list, sats_pos_and_v, _ = get_constellation( altitude, inclination, nSats, nPlanes, t0, verbose=False ) -print( - f"Rank {rank} set up its orbit with altitude={altitude}m and inclination={inclination}deg" -) +print(f"Rank {rank} set up its orbit with altitude={altitude}m and inclination={inclination}deg") ############ PASEOS INIT ############# # We will now initialize the PASEOS instance on each rank @@ -78,9 +72,7 @@ local_actor = ActorBuilder.get_actor_scaffold( name="Sat_" + str(rank), actor_type=SpacecraftActor, epoch=t0 ) -ActorBuilder.set_orbit( - actor=local_actor, position=pos, velocity=v, epoch=t0, central_body=earth -) +ActorBuilder.set_orbit(actor=local_actor, position=pos, velocity=v, epoch=t0, central_body=earth) paseos_instance = paseos.init_sim(local_actor=local_actor) print(f"Rank {rank} set up its PASEOS instance for its local actor {local_actor}") @@ -97,6 +89,7 @@ # Let's define the variable to track the actors we see total_seen_actors = 0 + # We will (ab)use PASEOS constraint function to track all the actors # we see in an evaluation window (see timestep below). # Turn on SHOW_ALL_WINDOWS if you want to see each window @@ -135,7 +128,6 @@ def constraint_func(verbose=SHOW_ALL_WINDOWS): # Run until end of simulation while t <= simulation_time: - # Advance the simulation state of this rank # Note how we pass the "constraint_func" to tell paseos # to track windows @@ -147,9 +139,7 @@ def constraint_func(verbose=SHOW_ALL_WINDOWS): sys.stdout.flush() # update prints to better see parallelism # Exchange actors between all ranks - exchange_actors( - comm, paseos_instance, local_actor, other_ranks, rank, verbose=SHOW_ALL_COMMS - ) + exchange_actors(comm, paseos_instance, local_actor, other_ranks, rank, verbose=SHOW_ALL_COMMS) # Wait until all ranks finished print(f"Rank {rank} finished the simulation. Waiting for all to finish.") diff --git a/examples/MPI_example/mpi_utility_func.py b/examples/MPI_example/mpi_utility_func.py index 71c67fed..4952af79 100644 --- a/examples/MPI_example/mpi_utility_func.py +++ b/examples/MPI_example/mpi_utility_func.py @@ -48,9 +48,7 @@ def _parse_actor_data(actor_data): return actor -def exchange_actors( - comm, paseos_instance, local_actor, other_ranks, rank, verbose=False -): +def exchange_actors(comm, paseos_instance, local_actor, other_ranks, rank, verbose=False): """This function exchanges the states of various actors among all MPI ranks. Args: @@ -69,9 +67,7 @@ def exchange_actors( # Send local actor to other ranks for i in other_ranks: actor_data = _encode_actor(local_actor) - send_requests.append( - comm.isend(actor_data, dest=i, tag=int(str(rank) + str(i))) - ) + send_requests.append(comm.isend(actor_data, dest=i, tag=int(str(rank) + str(i)))) # Receive from other ranks for i in other_ranks: diff --git a/examples/Orekit_example/orekit_integration.ipynb b/examples/Orekit_example/orekit_integration.ipynb new file mode 100644 index 00000000..631e1ae0 --- /dev/null +++ b/examples/Orekit_example/orekit_integration.ipynb @@ -0,0 +1,256 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# PASEOS Orekit integration example\n", + "\n", + "This example requires an [orekit installation](https://gitlab.orekit.org/orekit-labs/python-wrapper/-/wikis/installation) via conda. (Note that orekit requires Java)\n", + "\n", + "It showcases how to adapt an example from the [orekit documentation](https://gitlab.orekit.org/orekit-labs/python-wrapper/-/blob/master/examples/Example_numerical_prop.ipynb ) to utilize orekit for position and velocity calculations inside PASEOS.\n", + "\n", + "Additionally, you will need to place the orekit [data](https://gitlab.orekit.org/orekit/orekit-data) as `orekit-data.zip` in the same folder as this notebook. (N.B. don't decompress the zip file)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "import sys\n", + "\n", + "sys.path.append(\"..\")\n", + "sys.path.append(\"../..\")\n", + "\n", + "import paseos\n", + "from paseos import ActorBuilder, SpacecraftActor\n", + "\n", + "import pykep as pk\n", + "import matplotlib.pyplot as plt\n", + "\n", + "import orekit\n", + "\n", + "from orekit.pyhelpers import setup_orekit_curdir\n", + "from orekit_propagator import OrekitPropagator\n", + "from org.orekit.time import AbsoluteDate, TimeScalesFactory\n", + "from orekit.pyhelpers import absolutedate_to_datetime\n", + "\n", + "\n", + "# Initialize the orekit virtual machine and set the current directory\n", + "vm = orekit.initVM()\n", + "setup_orekit_curdir()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Initialize the propagator" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# Set a start date\n", + "utc = TimeScalesFactory.getUTC()\n", + "epoch = AbsoluteDate(2020, 1, 1, 0, 0, 00.000, utc)\n", + "\n", + "# Let's define some orbit and a satellite mass of 1000 kg\n", + "# We use an orbit with a semi-major axis of 10000 km, a eccentricity of 0.75 and an inclination of 1e-4°\n", + "orbital_elements = [10000000.0, 0.75, 0.0001, 0.0, 0.0, 0.0]\n", + "satellite_mass = 1000.0\n", + "\n", + "# Initialize the propagator\n", + "propagator = OrekitPropagator(orbital_elements, epoch, satellite_mass)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Check the orbit propagation" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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m16vx7Rp09CzZ08EBQUBALKysmBhYQF7e3ujtm5ubsjKyjK0ub3grlxfue5ObdRqNUpLS2FlZVUlHo1GA41GY3isVqvv7wUSERHVI57pJiIiojuKiorCmTNnsGHDBlOHAuDWIG9KpdJw8/b2NnVIRERE/4hFNxEREf2jSZMmYfv27YiOjoaXl5dhubu7O8rLy1FQUGDUPjs7G+7u7oY2fx/NvPLx3drY2dlVe5YbAGbNmgWVSmW4paWl3ddrJCIiqk8suomIiKgKIQQmTZqELVu2YN++ffD39zda37lzZ5ibm2Pv3r2GZUlJSUhNTUX37t0BAN27d8fp06eRk5NjaLNnzx7Y2dmhffv2hja376OyTeU+qiOXy2FnZ2d0IyIiaqw4ejkRERFV8dprr2HdunXYtm0bAgMDDcuVSqXhDPTEiROxc+dOrF69GnZ2dpg8eTIA4PDhwwBuTRkWGhoKT09PLF68GFlZWRg1ahReeuklLFiwAMCtKcOCgoIQFRWFcePGYd++fZgyZQp27NiB/v371yhWjl5ORESmUNPjD4tuIiIiqkIikVS7fNWqVRgzZgwAoKysDK+//jrWr18PjUaD/v3748svvzR0HQeAlJQUTJw4ETExMbCxscHo0aOxaNEiyGR/jeUaExOD6dOn49y5c/Dy8sLs2bMNz1ETLLqJiMgUWHQTERFRs8Cim4iITKGmxx9OGUZERERNWuX5A04dRkREDanyuHO389gsuomIiKhJKywsBABOHUZERCZRWFgIpVL5j+vZvZyIiIiaNL1ej4yMDNja2v7jteg1pVar4e3tjbS0NHZVrwHm694wX/eOObs3zNe9u5+cCSFQWFgIT09PSKX/PDEYz3QTERFRkyaVSo3mEK8LnIrs3jBf94b5unfM2b1hvu5dbXN2pzPclThPNxEREREREVE9YdFNREREREREVE9YdBMRERH9SS6XY+7cuZDL5aYOpUlgvu4N83XvmLN7w3zdu4bIGQdSIyIiIiIiIqonPNNNREREREREVE9YdBMRERERERHVExbdRERERERERPWERTcRERERgC+++AJ+fn6wtLREeHg4jh07ZuqQGoWFCxfioYcegq2tLVxdXTF06FAkJSUZtSkrK0NUVBScnJygUCjw1FNPITs720QRNy6LFi2CRCLBtGnTDMuYr6rS09PxwgsvwMnJCVZW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", + "text/plain": [ + "
    " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# We can check it works by propagating a bit\n", + "r,v = [], []\n", + "for t in range(0, 10000, 100):\n", + " state = propagator.eph(float(t))\n", + " r.append(state.getPVCoordinates().getPosition())\n", + " v.append(state.getPVCoordinates().getVelocity())\n", + "\n", + "# Plot the orbit and velocity magnitude\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 5))\n", + "ax[0].plot([i.getX() for i in r], [i.getY() for i in r])\n", + "ax[0].set_xlabel(\"X [m]\")\n", + "ax[0].set_ylabel(\"Y [m]\")\n", + "ax[0].set_title(\"Orbit\")\n", + "ax[0].set_aspect(\"equal\", \"box\")\n", + "ax[1].plot([i.getNorm() for i in v])\n", + "ax[1].set_xlabel(\"Time [s]\")\n", + "ax[1].set_ylabel(\"Velocity [m/s]\")\n", + "ax[1].set_title(\"Velocity magnitude\")\n", + "fig.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using it with PASEOS" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Convert to pykep epoch\n", + "date_string = absolutedate_to_datetime(epoch).strftime(\"%Y-%m-%d %H:%M:%S\")\n", + "pykep_epoch = pk.epoch_from_string(date_string)\n", + "\n", + "# Create a spacecraft actor\n", + "my_sat = ActorBuilder.get_actor_scaffold(name=\"my_sat\", actor_type=SpacecraftActor, epoch=pykep_epoch)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we need to define a function that returns position and velocity given an epoch. We will use our propagator for this." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "def propagator_function(epoch: pk.epoch):\n", + " \"\"\"Propagator function for the spacecraft actor.\n", + "\n", + " Args:\n", + " epoch (pk.epoch): Epoch to propagate to. (can be expensive!)\n", + "\n", + " Returns:\n", + " list: List of position and velocity.\n", + " \"\"\"\n", + " # Compute the time since the start of the simulation\n", + " time_since_start = (epoch.mjd2000 - pykep_epoch.mjd2000) * pk.DAY2SEC\n", + "\n", + " # Propagate the orbit\n", + " state = propagator.eph(time_since_start)\n", + "\n", + " # Return the position and velocity as a list\n", + " r = list(state.getPVCoordinates().getPosition().toArray())\n", + " v = list(state.getPVCoordinates().getVelocity().toArray())\n", + " \n", + " return r, v\n", + "\n", + "# Set the custom orbit\n", + "ActorBuilder.set_custom_orbit(my_sat, propagator_function, pykep_epoch)\n", + "\n", + "# Create a PASEOS instance\n", + "instance = paseos.init_sim(local_actor=my_sat)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's check if it works by running the PASEOS simulation for this actor and get the position as before.\n", + "\n", + "If everything went well, you should see the same plot again, except this time it's happening inside PASEOS." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAHqCAYAAAAZLi26AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjYuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/av/WaAAAACXBIWXMAAA9hAAAPYQGoP6dpAADMXUlEQVR4nOzdd3xT5RoH8F+S7r0XnZTRsqEMyxKkbJAlClYQQdAryFJQHIjjgnBxsARRGSogyhIFCmWvyi5llLIKLd2lI51pmpz7R5pAbYECbU+S/r6fTz63PedN8qRykzznfd/nkQiCIICIiIiIiIiIqp1U7ACIiIiIiIiIjBWTbiIiIiIiIqIawqSbiIiIiIiIqIYw6SYiIiIiIiKqIUy6iYiIiIiIiGoIk24iIiIiIiKiGsKkm4iIiIiIiKiGMOkmIiIiIiIiqiFMuomIiIiIiIhqCJNuIiIiIqJKSCQSzJkzp0Ye+9atW5BIJFizZk2NPL6xmDNnDiQSSa09X03+N6e6i0k3ERERERm0559/HlZWVsjLy3vgmPDwcJiZmeHu3bu1GNnj2blzJxO+Kpg7dy62bdsmdhhEVcakm4iIiIgMWnh4OIqKirB169ZKzxcWFuLPP/9Enz594OzsXMvRVc7Pzw9FRUUYNWqU7tjOnTvx6aefihiV/vnoo49QVFRU7hiTbjI0TLqJiIiIyKA9//zzsLW1xfr16ys9/+eff6KgoADh4eG1HNmDSSQSWFhYQCaTiR2KXjMxMYGFhYXYYRA9FSbdRERERGTQLC0tMXToUOzbtw/p6ekVzq9fvx62trZ4/vnnAQA5OTmYOnUqfHx8YG5ujgYNGmD+/PlQq9WPfK5z586hb9++sLOzg42NDXr06IF//vmnwricnBxMmzYN/v7+MDc3h7e3N0aPHo3MzEwAFfd0jxkzBsuWLQOgSci1N0EQ4O/vj0GDBlV4juLiYtjb2+ONN954aMwSiQSTJk3CH3/8gSZNmsDS0hKhoaG4cOECAOD7779HgwYNYGFhgW7duuHWrVvl7n/kyBEMHz4cvr6+MDc3h4+PD6ZNm1ZhBhqA7jksLCzQrFkzbN26FWPGjIG/v79ujPa1L1y4ECtXrkRgYCDMzc3Rrl07nDp1qtzj/XtPt0QiQUFBAdauXav7G40ZM0b3N7z/eR70GACgUCgwbdo0uLq66v5t3Llzp9K/X1JSEsaOHQt3d3eYm5ujadOmWLVq1YP+3EQVmIgdABERERHR0woPD8fatWvx+++/Y9KkSbrjWVlZ2L17N0aOHAlLS0sUFhbi2WefRVJSEt544w34+vri+PHjmDVrFlJSUvDtt98+8DkuXbqELl26wM7ODjNnzoSpqSm+//57dOvWDYcOHUKHDh0AAPn5+ejSpQtiY2MxduxYtGnTBpmZmdi+fTvu3LkDFxeXCo/9xhtvIDk5GZGRkfjll190xyUSCV555RUsWLAAWVlZcHJy0p3766+/IJfL8corrzzy73PkyBFs374dEydOBADMmzcPAwYMwMyZM/Hdd9/hrbfeQnZ2NhYsWICxY8di//79uvv+8ccfKCwsxH/+8x84Ozvj5MmTWLJkCe7cuYM//vhDN27Hjh146aWX0Lx5c8ybNw/Z2dkYN24c6tWrV2lM69evR15eHt544w1IJBIsWLAAQ4cOxc2bN2FqalrpfX755Re8/vrraN++PSZMmAAACAwMfOTr/7fXX38dv/76K15++WV07NgR+/fvR//+/SuMS0tLwzPPPKO7cOHq6opdu3Zh3LhxkMvlmDp16mM/N9VBAhERERGRgSstLRU8PT2F0NDQcsdXrFghABB2794tCIIgfP7554K1tbVw9erVcuPef/99QSaTCQkJCbpjAIRPPvlE9/vgwYMFMzMz4caNG7pjycnJgq2trdC1a1fdsdmzZwsAhC1btlSIU61WC4IgCPHx8QIAYfXq1bpzEydOFCr7eh4XFycAEJYvX17u+PPPPy/4+/vrHvNBAAjm5uZCfHy87tj3338vABA8PDwEuVyuOz5r1iwBQLmxhYWFFR5z3rx5gkQiEW7fvq071rx5c8Hb21vIy8vTHTt48KAAQPDz89Md0752Z2dnISsrS3f8zz//FAAIf/31l+7YJ598UuFvYm1tLbz66qsVYnr11VfLPc+DHiM6OloAILz11lvlxr388ssV/puPGzdO8PT0FDIzM8uNHTFihGBvb1/p34bo37i8nIiIiIgMnkwmw4gRIxAVFVVuefT69evh7u6OHj16ANDM2nbp0gWOjo7IzMzU3cLCwqBSqXD48OFKH1+lUmHPnj0YPHgw6tevrzvu6emJl19+GUePHoVcLgcAbN68GS1btsSQIUMqPM6TtL9q1KgROnTogHXr1umOZWVlYdeuXQgPD6/SY/bo0aPc0mvtrPywYcNga2tb4fjNmzd1xywtLXU/FxQUIDMzEx07doQgCDh37hwAIDk5GRcuXMDo0aNhY2OjG//ss8+iefPmlcb00ksvwdHRUfd7ly5dKjx3Tdi5cycAYPLkyeWO/3vWWhAEbN68GQMHDoQgCOX+vfTu3Ru5ubk4e/ZsjcZKxoFJNxEREREZBW2hNG1BtTt37uDIkSMYMWKErmDZtWvXEBERAVdX13K3sLAwAKh0TzgAZGRkoLCwEI0bN65wLjg4GGq1GomJiQCAGzduoFmzZtX62kaPHo1jx47h9u3bADQXD5RKZbnq5w/j6+tb7nd7e3sAgI+PT6XHs7OzdccSEhIwZswYODk5wcbGBq6urnj22WcBALm5uQCgi6tBgwYVnruyY5XFpE3A73/umnD79m1IpdIKy9L//d82IyMDOTk5WLlyZYV/L6+99hqAB/97Ibof93QTERERkVEICQlBUFAQNmzYgA8++AAbNmyAIAjlqpar1Wr07NkTM2fOrPQxGjVqVFvhPpYRI0Zg2rRpWLduHT744AP8+uuvaNu2baUXASrzoCrpDzouCAIAzQx/z549kZWVhffeew9BQUGwtrZGUlISxowZU6Xic48bk/a5H9eDZvxVKtUTPZ72tb3yyit49dVXKx3TokWLJ3psqluYdBMRERGR0QgPD8fHH3+MmJgYrF+/Hg0bNkS7du105wMDA5Gfn6+b2a4qV1dXWFlZIS4ursK5K1euQCqV6maNAwMDcfHixceO/WHLxJ2cnNC/f3+sW7cO4eHhOHbs2EOLvlWXCxcu4OrVq1i7di1Gjx6tOx4ZGVlunJ+fHwDg+vXrFR6jsmNP40F/J0dHR+Tk5FQ4rp2F1/Lz84NarcaNGzfKXbT4939bbWVzlUr12P9eiO7H5eVEREREZDS0s9qzZ89GdHR0hd7cL774IqKiorB79+4K983JyUFpaWmljyuTydCrVy/8+eef5faMp6WlYf369ejcuTPs7OwAaPZJnz9/Hlu3bq3wOA+bxbW2ttbFUZlRo0bh8uXLmDFjhm4Pe03TzkbfH7cgCFi0aFG5cV5eXmjWrBl+/vln5Ofn644fOnRI15qsulhbW1f6NwoMDERubi5iYmJ0x1JSUir8d+jbty8AYPHixeWO//sihkwmw7Bhw7B58+ZKL6JkZGQ84SuguoYz3URERERkNAICAtCxY0f8+eefAFAh6Z4xYwa2b9+OAQMGYMyYMQgJCUFBQQEuXLiATZs24datW5W29AKAL774ApGRkejcuTPeeustmJiY4Pvvv4dCocCCBQvKPcemTZswfPhwjB07FiEhIcjKysL27duxYsUKtGzZstLHDwkJAaAp8NW7d+8KiXX//v3h7OyMP/74A3379oWbm9tT/a2qIigoCIGBgXj33XeRlJQEOzs7bN68udJ913PnzsWgQYPQqVMnvPbaa8jOzsbSpUvRrFmzcon40woJCcHevXvx9ddfw8vLCwEBAejQoQNGjBiB9957D0OGDMHkyZNRWFiI5cuXo1GjRuUKnrVq1QojR47Ed999h9zcXHTs2BH79u2rdEb+yy+/xIEDB9ChQweMHz8eTZo0QVZWFs6ePYu9e/ciKyur2l4XGS/OdBMRERGRUdEm2u3bt69QxMvKygqHDh3CjBkzcPDgQUyZMgVffvklrl27hk8//VRXSKwyTZs2xZEjR9CsWTPMmzcPn376Kfz8/HRJmZaNjQ2OHDmC//znP9i5cycmT56M7777Do0bN4a3t/cDH3/o0KF4++23ERERgVGjRmHkyJHlzpuZmeGll14CgCoXUHtapqam+Ouvv9CqVSvda27YsCF+/vnnCmMHDhyIDRs2oKSkBO+//z62bNmCNWvWoHHjxrCwsKi2mL7++muEhITgo48+wsiRI7F8+XIAgLOzM7Zu3QorKyvMnDkTa9euxbx58zBw4MAKj7Fq1SpMnjwZERERmDlzJpRKJXbs2FFhnLu7O06ePInXXnsNW7ZswaRJk7Bo0SJkZWVh/vz51faayLhJhCetVEBERERERLVq2rRp+Omnn5CamgorKyuxw6mSVq1awdXVtcI+cKK6gjPdREREREQGoLi4GL/++iuGDRumlwm3UqmssCf+4MGDOH/+PLp16yZOUER6gHu6iYiIiIj0WHp6Ovbu3YtNmzbh7t27mDJlitghVSopKQlhYWF45ZVX4OXlhStXrmDFihXw8PDAm2++KXZ4RKJh0k1EREREpMcuX76M8PBwuLm5YfHixWjVqpXYIVXK0dERISEh+PHHH5GRkQFra2v0798fX375JZydncUOj0g03NNNREREREREVEO4p5uIiIiIiIiohjDpJiIiIiIiIqoh3NNNREREBk2tViM5ORm2traQSCRih0NERHWEIAjIy8uDl5cXpNIHz2cz6SYiIiKDlpycDB8fH7HDICKiOioxMRHe3t4PPM+km4iIiAyara0tAM2XHjs7O5GjISKiukIul8PHx0f3OfQgTLqJiIjIoGmXlNvZ2THpJiKiWveorU0spEZERERERERUQ5h0ExEREREREdUQJt1ERERERERENYRJNxEREREREVENYdJNREREREREVEOYdBMRERERERHVECbdRERERERERDWESTcRERERERFRDWHSTURERERERFRDmHQTERERERER1RAm3UREREREREQ1hEk3ERERERERUQ1h0k1ERERERERUQ5h0ExEREREREdUQE7EDICIiItIHWQUlOHM7G2YmUjzbyFXscIiIyEhwppuIiIgIwN7LaRj/82msOHhD7FCIiMiIMOkmIiIiAtDEyw4AcCk5F4IgiBwNEREZCybdRERERAAaudvCVCaBvLgUd7KLxA6HiIiMBJNuIiIiIgBmJlI0crcFoJntJiIiqg5MuomIiIjKNPOyBwBcTJKLHAkRET2Jr/bE4Z3fz0NRqhI7FB1WLyciIiIq07SeHXCaM91ERIaoWKnCkv3XAQAutmaY1TdY5Ig0ONNNREREVKapdqY7mTPdRESGJjnnXj2OlYdv4p+bd0WM5h4m3URERERlgj1tIZUAGXkKpMuLxQ6HiIgeQ3LOvfdtQQDe+f085MVKESPSYNJNREREVMbKzAT1XW0AAJc4201EZFC0M91t/Rzh62SFpJwizPnzkshRMekmIiIiKqdZWb/ui0nc101EZEjulCXdDd1t8c1LLSGVAFvOJeHvmGRR42LSTURERHQf7b5uznQTERkW7Uy3t6MlQvycMLF7AwDAgog4McNi0k1ERER0v6b1yma6WcGciMigaJNuLwcLAMDoUH8AQGJ2IZQqtVhhMekmIiIiup92pvtOdhFyC8UvwENERFWjS7rtLQEAztZmkEklEAQgM18hWlxMuomIiIjuY29pCh8nzRc29usmIjIMarWA5FxN9XIvB817uFQqgZutOQAgTc6km4iIiGrR4cOHMXDgQHh5eUEikWDbtm0VxsTGxuL555+Hvb09rK2t0a5dOyQkJOjOFxcXY+LEiXB2doaNjQ2GDRuGtLS0co+RkJCA/v37w8rKCm5ubpgxYwZKS0vLjTl48CDatGkD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oqCgABMmTEBOTg46d+6MiIgIXY9uAFi3bh0mTZqEHj16QCqVYtiwYVi8eLHuvL29Pfbs2YOJEyciJCQELi4umD17tq5dGAB07NgR69evx0cffYQPPvgADRs2xLZt2wy+R/f9tPu6WcGciKi8+5eWG3N9sMdOuidPnowDBw6ge/fucHZ2rrU/TklJCc6cOYNZs2bpjkmlUoSFhSEqKqpWYtASBOFe0m1rGHvKiIioau5vV3IpORf9motfwVzL398fY8eOxZgxY+Dr6/tUj9WtWzcIwoOXz0skEnz22Wf47LPPHjjGyckJ69evf+jztGjRAkeOHHnomOHDh2P48OEPD9iABXtqku6raXlQq4VyVfKJiOqye0XUjHdpOfAESffatWuxefNm9O/fvybieaDMzEyoVKpK24pcuXKl0vvUVB/PPEUpFKWaVmkuNky6iYiMiV25pFu/+j9PnToVa9aswWeffYbu3btj3LhxGDJkiMEUFaur/J2tYWYiRWGJConZhUa7eoKI6HHVhSJqwBPs6XZyckJgYGBNxFLt5s2bB3t7e92tutqJaGe5bcxNYGkmq5bHJCIi/WBnce969NA23iJGUtHUqVMRHR2NkydPIjg4GG+//TY8PT0xadIknD17Vuzw6AFMZFI0cNV0O+EScyIiDZVaQGyK5j2RSfe/zJkzB5988gkKC2u3uIyLiwtkMtljtRWZNWsWcnNzdbfExMRqiSWvuBRA+SWIRERkHLTv7cGedni+pZfI0VSuTZs2WLx4MZKTk/HJJ5/gxx9/RLt27dCqVSusWrXqocvGSRwspkZEVF58ZgGKlCpYmsoQYORtmB97efnixYtx48YNuLu7w9/fv0IhtZq60m5mZoaQkBDs27dPVzlVrVZj3759D6xwWlN9PItKVADAWW4iIiOkTbrlRcpHjBSPUqnE1q1bsXr1akRGRuKZZ57BuHHjcOfOHXzwwQfYu3fvI/daU+0K8rQFzgGxKfq1ZYGISCzapeVBnraQGXmti8dOurUJrximT5+OV199FW3btkX79u3x7bffoqCgQFfNvLYUK8uSblMm3URExsZOj5Pus2fPYvXq1diwYQOkUilGjx6Nb775BkFBQboxQ4YMQbt27USMkirTrKz/bMydXJEjISLSD5d1RdSMe2k58ARJ9yeffFITcVTJSy+9hIyMDMyePRupqalo1aoVIiIiKhRXq2lFZUm3heljr84nIiI9p53pzlOUQqUW9Orqe7t27dCzZ08sX74cgwcPrrDaDAACAgIwYsQIEaKjh2lezx4SCZCUU4T0vGK42bLlKBHVbXWlcjnwBEm32CZNmvTA5eS1RTvTbW7CmW4iImNjZ3Evkc0rVsLByuwho2vXzZs34efn99Ax1tbWWL16dS1FRFVla2GKhm42uJqWj/OJuejZhEk3EdVdgiDUmcrlQBULqTk5OSEzM7PKD+rr64vbt28/cVD6TlrWm1wAC9UQERkbMxOpbvtQrp4tMe/evTvu3r1b4XhOTg7q168vQkT0OFp6OwAAzifmiBoHEZHYUnKLkV2ohIlUgkbutmKHU+OqNNOdk5ODXbt2wd6+alP/d+/ehUqleqrA9JmJTJN0l6qYdBMRGSN7S1MUKVV6l3TfunWr0s9XhUKBpKQkESKix9HK1wF/nLmDaCbdRFTHXUzSzHI3cLOBRR2ok1Xl5eWvvvpqTcZhUEzK9vep1Ey6iYiMkb2lKVLlxXqTdG/fvl338+7du8tdBFepVNi3bx/8/f1FiIweh26m+04O1GoBUj2qF0BEVJvq0n5uoIpJt1qtruk4DIqJVLMqX8mkm4jIKNlZaj4e5UWlIkeioe0cIpFIKlwENzU1hb+/P7766isRIqPH0djDFuYmUuQVl+JmZgEauBl3X1oioge5VIcqlwMGWEhNH5iZaJJuhdJ4l9ATEdVlQtk11WI9eZ/XXvwOCAjAqVOn4OLiInJE9CRMZVI0r2eP07ezcT4xh0k3EdVZdamIGlDFQmpUnoOVprKtviw7JCKi6iMvVur23Ib4OYobzL/Ex8cz4TZwLX0cAGiWmBMR1UXJOUVIyS2GTCpBs3pcXk4P4FjWPia7sETkSIiIqLoduZqJUrWA+q7W8HexFjscLF68GBMmTICFhQUWL1780LGTJ0+upajoSbUqS7pZTI2I6qozt7MBAMGetrA2rxvpaJVfZXJyMry8vGoyFoNhXzbTXaxUo1ipqhMV94iI6op9sWkAgB5BbiJHovHNN98gPDwcFhYW+Oabbx44TiKRMOk2ANqkOzZFzu8QRFQnaZPutn5OIkdSe6qcdDdt2hTLli3Dyy+/XJPxGARbcxPIpBKo1AJyCpXwsOcHJhGRMVCpBRyISwcA9Ah2Fzkajfj4+Ep/JsPk7WgJZ2sz3C0oweUUOdr46tcWBiKimqZNuvVtC1dNqvKe7v/+97944403MHz4cGRlZdVkTHpPIpHAzdYcAJCSWyRyNEREVF2iE7ORXaiEnYVJnfoyQLVHIpHc29fNJeZEVMcUKEpxOUVTubwufc5WOel+6623EBMTg7t376JJkyb466+/ajIuveftaAkASMxm0k1EZCz2xmpmuZ9t7AZTmf7VGh02bBjmz59f4fiCBQswfPhwESKiJ8F93URUV52/kwOVWoCXvQW8HCzFDqfWPNbO9YCAAOzfvx9Lly7F0KFDERwcDBOT8g9x9uzZag1QX/k4WuHUrWzcyS4UOxQiIqom+8uS7rBg/djP/W+HDx/GnDlzKhzv27cv+3QbEM50E1FddeZW2dJy/7qznxt4gurlt2/fxpYtW+Do6IhBgwZVSLrrCm8nKwBAYhZnuomIjEFiViHi0vIglQDPNnIVO5xK5efnw8zMrMJxU1NTyOVyESKiJ9HSW9Mi59bdQmQXlMDRuuJ/UyIiY3Rau5/b10HcQGrZY2XMP/zwA9555x2EhYXh0qVLcHXVzy8ltcGnbHn57bsFIkdCRETVYf8VzSx3Wz8nOFjpZxLUvHlzbNy4EbNnzy53/LfffkOTJk1Eiooel4OVGQJcrBGfWYDzd3LQrbF+rqwgIqpOarWAswlllcs50125Pn364OTJk1i6dClGjx5dkzEZhEbutgCAuNQ8CIIAiUQickRERPQ09l3RVi3X3wTo448/xtChQ3Hjxg0899xzAIB9+/Zhw4YN+OOPP0SOjh5HS297xGcW4GwCk24iqhuupecjr7gUVmYyBHnYih1Orapy0q1SqRATEwNvb++ajMdgNPawhVQC3C0oQUaeAm52FmKHRERET6hAUYp/btwFoN9J98CBA7Ft2zbMnTsXmzZtgqWlJVq0aIG9e/fi2WefFTs8egztA5yxLToZJ27eFTsUIqJacfq2pgNWKx8HmOhhsdKaVOWkOzIysibjMDgWpjIEuFjjRkYBLqXImXQTERmwI9cyUaJSw8/ZCoGuNmKH81D9+/dH//79xQ6DntIz9TVLK88l5KBYqYKFqUzkiIiIapa2P3fbOtQqTKtuXWKoZsGedgCA2BQWryEiMmT7r6QBAJ4LcjOI7UJnzpzBr7/+il9//RXnzp0TOxx6AgEu1nCzNUeJSq3b40hEZMy0SXcbJt30OJrV01QfZcsPIiLDpVYL2H8lAwDQI8hd5GgeLj09Hc899xzatWuHyZMnY/LkyQgJCUGPHj2QkZEhdnj0GCQSCUIDnQFAt7WBiMhYZeQpcPtuISQSJt30mNqVVd07GZ8FQRBEjoaIiJ5ETFIuMvMVsDE3QfsA/a6m+vbbbyMvLw+XLl1CVlYWsrKycPHiRcjlckyePFns8OgxPVO/LOm+mSVyJERENUs7y93Y3RZ2FqYiR1P76maT7WrSvJ49LEylyC5U4np6Phq6160qfERExmB/rGZpeddGLjAz0e9r0REREdi7dy+Cg4N1x5o0aYJly5ahV69eIkZGTyK0LOmOTsxBUYkKlmbc101ExulMWRG1ujjLDXCm+6mYmUjRxlfzD+dEPK9SExEZIm2rsOf0fGk5AKjVapiaVpwhMDU1hVqtFiEiehp+zlbwsLPgvm4iMnony3KlulhEDWDS/dS0S8yZdBMRGZ6U3CJcSpZDIgG6N3YVO5xHeu655zBlyhQkJyfrjiUlJWHatGno0aOHiJHRk5BIJLoq5v+wdRgRGamcwhLEJOUCADo1cBE5GnEw6X5KnRtq/uEcvpqBUhVnGYiIDMn+slnu1j4OcLYxFzmaR1u6dCnkcjn8/f0RGBiIwMBABAQEQC6XY8mSJWKHR09AV0yNSTcRGanjN+5CEICGbjZwr6Ntlrmn+ym18XWEg5UpcgqVOHM7Gx3K9mcREZH+2x+rSbp7BOv/0nIA8PHxwdmzZ7F3715cuXIFABAcHIywsDCRI6Mn9Qz3dRORkTtyLRPAvcnKuohJ91OSSSXo3tgNW88lYd+VdCbdREQGoqhEhaPXNV8EegS7iRxN1UkkEvTs2RM9e/YUOxSqBr5OVvCyt0BybjHO3M6u019Kicg4Hb2uaWnZpQ6/vzHprgY9gjVJ997YNHzQL/jRdyAiItEdv5EJRaka9Rws0ViPu08sXry4ymPZNszwaPZ1O2PLuSRE3cxk0k1ERiXhbiESs4pgKpOgQ0DdnZw0iKT71q1b+Pzzz7F//36kpqbCy8sLr7zyCj788EOYmZmJHR66NnKFqUyCmxkFuJqWh0Z6/OWNiIg07lUtd4NEIhE5mgf75ptvqjROIpEw6TZQ2qSb/bqJyNgcKZvlbu3rCGtzg0g9a4RBvPIrV65ArVbj+++/R4MGDXDx4kWMHz8eBQUFWLhwodjhwc7CFM82csPe2DRsO5eEmX2CxA6JiIgeQhCE+/Zz6/fS8vj4eLFDoBqmLaZ2PjEHhSWlsDIziK9nRESPdFS7n7uOVi3XMojq5X369MHq1avRq1cv1K9fH88//zzeffddbNmyRezQdIa0rgcA+DM6GWq1IHI0RET0MJeS5UiVF8PSVKYrZGVISkpKEBcXh9LSUrFDoWrg7WiJeg6WKFULul62RESGTqUWcPyGpjNDXd86YxBJd2Vyc3Ph5OQkdhg6PYLdYGtugqScIpy6xQ9MIiJ9pm0V1rmhCyxMDadadGFhIcaNGwcrKys0bdoUCQkJAIC3334bX375pcjR0ZOSSCS6WaCDcRkiR0NEVD0uJuUit0gJWwsTtKhnL3Y4ojLIpPv69etYsmQJ3njjjYeOUygUkMvl5W41xcJUhr7NPQAAW88l1djzEBHR09sXmwYACNPzpeX/NmvWLJw/fx4HDx6EhcW9XqdhYWHYuHGjiJHR0+oepPm3eCAuHYLAFXNEZPi0HUI6BjrDRGaQaWe1EfXVv//++5BIJA+9afuQaiUlJaFPnz4YPnw4xo8f/9DHnzdvHuzt7XU3Hx+fmnw5GNbGGwCw/Xwy5MXKGn0uIiJ6Mul5xTh/JxcA0L2xYSXd27Ztw9KlS9G5c+dyxd+aNm2KGzduiBgZPa3ODV1gKpPg9t1C3MwsEDscIqKnduSaZuVO54auIkciPlGT7nfeeQexsbEPvdWvX183Pjk5Gd27d0fHjh2xcuXKRz7+rFmzkJubq7slJibW5MtB+wAnNHK3QWGJCpvP3KnR5yIioifzx2nN+3MLb3u42Vk8YrR+ycjIgJtbxQsFBQUF1V6BXaVS4eOPP0ZAQAAsLS0RGBiIzz//vNwsrCAImD17Njw9PWFpaYmwsDBcu3at3ONkZWUhPDwcdnZ2cHBwwLhx45Cfn19uTExMDLp06QILCwv4+PhgwYIF1fpaDIGNuYmunc6Bsu0PRESGqrCkFGduZwNgETVA5KTb1dUVQUFBD71pW4IlJSWhW7duCAkJwerVqyGVPjp0c3Nz2NnZlbvVJIlEglGh/gCAX6Jus6AaEZGeic8swOJ9mqRw1DN+Ikfz+Nq2bYsdO3boftcm2j/++CNCQ0Or9bnmz5+P5cuXY+nSpYiNjcX8+fOxYMECLFmyRDdmwYIFWLx4MVasWIETJ07A2toavXv3RnFxsW5MeHg4Ll26hMjISPz99984fPgwJkyYoDsvl8vRq1cv+Pn54cyZM/jf//6HOXPmVOniurG5f4k5EZEhOxGfBaVKQD0HS/g7W4kdjugMoieFNuH28/PDwoULkZFxr8iIh4eHiJFVNLR1PSzYdQU3Mwtw7EYmunA5BRGRXlCrBby3OQaKUjW6NHTBCyHeYof02ObOnYu+ffvi8uXLKC0txaJFi3D58mUcP34chw4dqtbnOn78OAYNGoT+/fsDAPz9/bFhwwacPHkSgGaW+9tvv8VHH32EQYMGAQB+/vlnuLu7Y9u2bRgxYgRiY2MRERGBU6dOoW3btgCAJUuWoF+/fli4cCG8vLywbt06lJSUYNWqVTAzM0PTpk0RHR2Nr7/+ulxyXhc8F+SGz/++jJPxWchXlMKmDve0JSLDdviqJl/r0tCl2ldiGSKD2NEeGRmJ69evY9++ffD29oanp6fupm+szU0wrOyL3I9H2FuViEhfrD+ZgJPxWbAyk2HukOYG9SXg4sWLAIDOnTsjOjoapaWlaN68Ofbs2QM3NzdERUUhJCSkWp+zY8eO2LdvH65evQoAOH/+PI4ePYq+ffsC0PQPT01NRVhYmO4+9vb26NChA6KiogAAUVFRcHBw0CXcgKbom1QqxYkTJ3RjunbtqlvZBgC9e/dGXFwcsrOzq/U16bsAF2v4O1tBqRJw9BqrmBORYRIEAZGXNQVLnwsyrNopNcUgLqGOGTMGY8aMETuMKhvbKQC//HMbh65m4MKdXDT3rtsl8omIxJacU4Qvd2kKc87o3Rg+Toa11K1FixZo164dXn/9dYwYMQI//PBDjT/n+++/D7lcjqCgIMhkMqhUKvz3v/9FeHg4ACA1NRUA4O7uXu5+7u7uunOpqakV9qCbmJjAycmp3JiAgIAKj6E95+joWCE2hUIBhUKh+70mu5PUtu5Bblh97Bb2X0lHn2b6N7lARPQosSl5uJNdBAtTKVf9ljGImW5D4+tshUEtvQAAyw5cFzkaIqK6TRAEfLj1AvIVpWjj64DRZbU3DMmhQ4fQtGlTvPPOO/D09MSYMWNw5MiRGn3O33//HevWrcP69etx9uxZrF27FgsXLsTatWtr9Hmrora7k9Sm53T7ujNYG4aIDJJ2lrtzA1dYmslEjkY/MOmuIW91D4REAkRcSsXVtDyxwyEiqrP+jE7GgbgMmMmkWPBCC8ikhrOsXKtLly5YtWoVUlJSsGTJEsTHx+PZZ59Fo0aNMH/+fN2scXWaMWMG3n//fYwYMQLNmzfHqFGjMG3aNMybNw/AvZoqaWlp5e6XlpamO+fh4YH09PJFwUpLS5GVlVVuTGWPcf9z/FttdyepTe0DnGBlJkNGngKXko1nBp+I6o7IWM1nUq+m7o8YWXcw6a4hDdxs0aep5svC0v2c7SYiEkNmvgKf/nUJADC5RwM0cLMVOaKnY21tjddeew2HDh3C1atXMXz4cCxbtgy+vr54/vnnq/W5CgsLK3QKkclkUKvVAICAgAB4eHhg3759uvNyuRwnTpzQVVIPDQ1FTk4Ozpw5oxuzf/9+qNVqdOjQQTfm8OHDUCqVujGRkZFo3LhxpUvLgdrvTlKbzE1kuvY6rGJORIYmKacIF5PkkEqAHtzPrcOkuwZNeq4BAGD7+WRcTMoVORoiorpnzvZLyC5UItjTDm88Gyh2ONWqQYMG+OCDD/DRRx/B1ta2XCux6jBw4ED897//xY4dO3Dr1i1s3boVX3/9NYYMGQJA065s6tSp+OKLL7B9+3ZcuHABo0ePhpeXFwYPHgwACA4ORp8+fTB+/HicPHkSx44dw6RJkzBixAh4eWm2Yb388sswMzPDuHHjcOnSJWzcuBGLFi3C9OnTq/X1GBLtEvP97NdNRAZmb9nS8hA/RzjbmIscjf4wiEJqhqqplz0GtfLCn9HJmB9xBb+M6yB2SEREdcaeS6n4OyYFMqkE/3uhBUxlxnOd+fDhw1i1ahU2b94MqVSKF198EePGjavW51iyZAk+/vhjvPXWW0hPT4eXlxfeeOMNzJ49Wzdm5syZKCgowIQJE5CTk4POnTsjIiICFhYWujHr1q3DpEmT0KNHD0ilUgwbNgyLFy/Wnbe3t8eePXswceJEhISEwMXFBbNnz65z7cLup+3Xff5ODjLyFHC15RdXIjIM2v3cvZroV1tnsUkEQagzVTrkcjns7e2Rm5tba0vRErMK0eOrQyhRqfHz2Pbo2ogV/IiIalpukRI9vz6E9DwF3nw2EO/3DRI1nur4/ElOTsaaNWuwZs0aXL9+HR07dsS4cePw4osvwtraupojNixifL7XtEFLj+L8nVx8MbgZXnnGT+xwiIgeKbdIiZDPI1GqFnDw3W7wdzH+z6aqfv4Yz2V/PeXjZIVRoZoPy3m7rkDFSqRERDVu3s5YpOcpEOBijalhDcUO56n17dsXfn5+WLJkCYYMGYLY2FgcPXoUr732Wp1PuI1Vv+aadmE7YlJEjoSIqGoOxqWjVC2goZtNnUi4HweT7lowqXsD2FqYIDZFjt9OJYgdDhGRUTt2PRO/ndJUs54/rAUsTA2/XYmpqSk2bdqEO3fuYP78+WjcuLHYIVEN0ybdJ+LvIiNP8YjRRETi26NdWs6q5RUw6a4FjtZmmN6zEQBgQUQcMvP54UlEVBMKS0rx/pYYAMCoZ/zQPsBJ5Iiqx/bt2zFo0CDIZIZ/AYGqxsfJCi19HKAWNO1HiYj0maJUhYNlxR97cj93BUy6a8moZ/zQ1MsOuUVKzNt5RexwiIiM0ld7riIxqwhe9haY2YezwWTY+jfXfHHdEZMsciRERA93/MZdFJSo4GZrjhb17MUOR+8w6a4lJjIpvhjcDBIJsPnsHZy4eVfskIiIjMrZhGysOhYPAPjv0OawtTAVOSKip3NviXkW0vOKRY6GiOjB/orWXBzs08wDUqlE5Gj0D5PuWtTa1xEj2/sCAD7YegHFSpXIERERGQdFqQrvbYqBIABDW9dD98ZuYodE9NS8HTVLzAUB2H2RS8yJSD8Vlaiwu2wbzKBW9USORj8x6a5l7/UOgqutOW5kFODryKtih0NEZBSWHbiBa+n5cLExw8cDmogdDlG1GVA22/03q5gTkZ7aG5uGghIVvB0t0cbXQexw9BKT7lpmb2WKeUOaAwB+OHITp25liRwREZFhi02R47sD1wEAnz7fDI7WZiJHRFR9+pbt6z55Kwvpci4xJyL982fZ0vJBrbwgkXBpeWWYdIsgrIk7hod4QxCAd34/jwJFqdghEREZpFKVGu9tjkGpWkCvJu7o15wVU8m4eDtaoVXZEnNWMScifZNTWIJDVzVVywdzafkDMekWyeyBTVDPwRIJWYX4785YscMhIjJIq47FI+ZOLmwtTMqKVfIKOxmfAS3Klpif5xJzItIvOy+kQqkSEOxph4butmKHo7eYdIvE1sIUC15oAQBYfyIBO7hXi4josdzKLMBXezS1MT7u3wRudhYiR0RUM/qW7es+eSsLiVmFIkdDRHTPn9FJAIDBrbxEjkS/MekWUacGLvhPt0AAwHubY3Ars0DkiIiIDINaLeC9zTFQlKrRuYELhrf1FjskohpTz8ESHQOdAQBbziaJHA0RkUZyThFOltWnGtiSSffDMOkW2Ts9G6GdvyPyFaV4a91ZthEjIqqCxfuv4UR8FixNZZg3tDmXlZPR015Y2nQ2EWq1IHI0RETAX+eTIQhA+wAneDlYih2OXmPSLTITmRRLRraBk7UZLqfI8fnfl8UOiYhIry3Zdw3f7r0GAPiwfzB8nKxEjoio5vVp6gkbcxMkZhXhRDw7nxCR+LRVy1lA7dGYdOsBD3sLfPNSKwDAuhMJ+O1kgrgBERHpqaX7r+GrSM0+7pl9GuOVZ/xEjoiodliayXQF1f44kyhyNERU18Wl5uFyihymMgk7h1QBk2498WwjV0wLawQA+PjPizhx867IERER6Zel+69h4Z57Cfdb3RqIHBFR7dIuMd91IRX5bDdKRCLaUDZJ2CPIHQ5WZiJHo/+YdOuRyT0aoH8LTyhVAv6z7iwrlBIRlVl24Lou4Z7Rmwk31U1tfB1R39UaRUoVdrLrCRGJpFipwpazdwAAIzv4ihyNYWDSrUckEgkWvtASzerZIaugBK+vPc0r2URU5y07cB3/2x0HQJNwT+zOhJvqJolEghdCNLPdXGJORGLZeSEF8uJS1HOwRJcGLmKHYxCYdOsZSzMZfhjdFq625ohLy8PEdWehVKnFDouISBRMuInKG9raG1IJcOpWNuLZapSIRKBdWj6yvQ+kUnYPqQom3XrI094SP4xuCwtTKQ5dzcDMTTFsD0JEdc53B5lwE/2bh70FujR0BQBs4mw3EdWy6+l5OHUrGzKpBMPb+ogdjsFg0q2nWvk4YHl4CGRSCbaeS8K8XbFih0REVGu+O3gdCyI0Cfe7vRox4Sa6z4tlX3R/P30HJaVcDUdEtWfDSc3FvueC3OBuZyFyNIaDSbce6x7khgXDWgAAfjgSj5WHb4gcERFRzVt+8Ea5hHvScw1FjohIv/Rs4g5XW3Nk5CkQcSlV7HCIqI4oVqqwuayA2svtWUDtcRhc0q1QKNCqVStIJBJER0eLHU6NGxbijQ/6BQEA5u68wh7eRGTUlh+8gfkRVwAA7/Rkwk1UGTMTKcLLKgb/fPyWuMEQUZ2x+1IqcgqV8LK3QNdGrmKHY1AMLumeOXMmvLy8xA6jVk3oGogJXesDAGZtvYDfT3EPFxEZnxWHyifcb/dgwk30IC+394WJVILTt7NxMSlX7HCIqA7QFlB7sZ0PZCyg9lgMKunetWsX9uzZg4ULF4odSq2b1TcIYzr6QxCA97bE4PfTTLyJyHisOHQDX+7SJNzTmXATPZKbnQX6NfcEAPwcdUvcYIjI6F1JleOfm1mQSu7VlaCqM5ikOy0tDePHj8cvv/wCKyurKt1HoVBALpeXuxkqiUSCTwY2wehQP03ivTkGm87cETssIqKn9v2/Eu7JTLiJquTVjn4AgD+jk5FdUCJyNERkzH46Eg8A6NPMA14OliJHY3gMIukWBAFjxozBm2++ibZt21b5fvPmzYO9vb3u5uNj2FdlJBIJPn2+KV55xheCAMzYdJ5LzYnIoH1/6AbmlSXc08KYcBM9jja+jmjqZQdFqRobuQKOiGpIRp4Cf0YnAwDGda4vcjSGSdSk+/3334dEInno7cqVK1iyZAny8vIwa9asx3r8WbNmITc3V3dLTDT8DySJRILPnm+G8A6axHvm5hhWNScig7TycPmEe0oYE26ixyGRSPBqR38AwC9Rt6FSC+IGRERG6dd/bqNEpUYrHweE+DmKHY5BMhHzyd955x2MGTPmoWPq16+P/fv3IyoqCubm5uXOtW3bFuHh4Vi7dm2l9zU3N69wH2MglUrwxeBmsDE3wfeHb2LuzivIKlDivT6NIZGwqAER6b+Vh29g7k5Nwj01rCETbqIn9HxLL8zbGYuknCLsi01Dr6YeYodEREakWKnCr//cBgCM6xwgcjSGS9Sk29XVFa6ujy43v3jxYnzxxRe635OTk9G7d29s3LgRHTp0qMkQ9ZZEIsGsfsFwtDbDl7uuYMWhG8gtKsEXg5uzmiAR6S1BEPDdwRv4325NH+6pYQ0xNayRyFERGS4LUxleaueLFYdu4Mej8Uy6iaha/RmdhLsFJfCyt0DfZnx/eVKiJt1V5etbvvm6jY0NACAwMBDe3t5ihKQ33nw2EA6Wpvhg6wVsOJmI7AIlvnmpFSzNZGKHRkRUTnZBCWZsOo+9sekAgCk9mHATVYfXOvlj1dF4nIzPwpnbWQjxcxI7JCIyAoIg4KejmgJqr3b0h4nMIMqB6SX+5YzAiPa++C68DcxkUkRcSsWIlVFIlxeLHRYRkc7J+Cz0W3wEe2PTYSaT4rNBTTGtJxNuourgbmeBoW3qAQCWH2SdFyKqHkevZ+JqWj6szGQY0d730XegBzLIpNvf3x+CIKBVq1Zih6I3+jTzxK+vd4CjlSnO38nF4GXHcDnZcFukEZFxUKkFLN53DSNWRiEltxgBLtbY8lZHjA71Fzs0IqMyoWt9SCTA3th0xKXmiR0OERmBH8rahL3Y1gf2lqYiR2PYDDLppsq1D3DCtomdEOhqjeTcYgxfcRz7YtPEDouI6qg0eTFe+fEEvo68CrUADG1dD3+93RnN6tmLHRqR0anvaqPbb/n9Ic52E9HTOZ+Yg8NXMyCTSvBaJ3+xwzF4TLqNjJ+zNbb8pxM6NXBGQYkK438+jRWHbkAQ2EaEiGrPgbh09Ft0BFE378LKTIavhrfE1y+1go25QZQSITJIbz4bCAD483wy7mQXihwNERmyJfuvAwAGtfSCn7O1yNEYPibdRsjeyhRrXmuPke19oRaAL3ddwX9+PYu8YqXYoRGRkSspVWPuzli8tvoU7haUINjTDn+93RnDQup20Uui2tDC2wGdG7hApRbwY9myUCKix3U5WY69sWmQSIC3ujcQOxyjwKTbSJnKpJg7pBm+GNwMpjIJIi6lYtDSY7iaxn1eRFQzEu4WYvj3UVh5+CYAYHSoH7a+1RGBrjYiR0ZUd/ynm2a2+7dTCbibrxA5GiIyREsPXAMA9G/uiQZu/AyvDky6jZhEIsErz/jh9zdC4WlvgZuZBRi09Bi2n08WOzQiMjI7YlLQf/ERnE/MgZ2FCVa8EoLPBjWDhSnbFxLVpo6BzmjhbY9ipVpXBImIqKqupeVh18VUAMDbzzUUORrjwaS7Dmjt64i/3+6MTg2cUaRUYfKGc/hw6wUUlajEDo2IDFyxUoVZWy5g4vqzyFOUoo2vA3ZO6YI+ZQWdiKh2SSQSTC77orz2+C2k57GFKBFV3dID1yEIQJ+mHmjsYSt2OEaDSXcd4Wxjjp/HdsBbZcvO1p1IwMClR9lWjIie2LW0PAxaegwbTiZo9n11C8TGN0Lh7WgldmhEdVqPYDe09HFAkVLFvt1EVGU3M/LxV9mK2EnPcS93dWLSXYfIpBLM7BOEX8a1h6utOa6n52PwsmP48chNqNWsbk5EVSMIAjae0ly4i0vLg4uNGX4e2x4z+wTBVMaPFSKxSSQSvNurEQBg3T8JSM4pEjkiIjIES/Zfh1oAegS5sb1nNeO3ozqoS0NXREzpgrBgN5So1PhiRyzGrDmFdDmXoBHRw+UVKzHlt2i8t/kCipVqdGnogp1TuqBLQ1exQ6MakJSUhFdeeQXOzs6wtLRE8+bNcfr0ad15QRAwe/ZseHp6wtLSEmFhYbh27Vq5x8jKykJ4eDjs7Ozg4OCAcePGIT8/v9yYmJgYdOnSBRYWFvDx8cGCBQtq5fUZs84NXNA+wAklKjWWHrgudjhEpOdiU+TYFp0EAJga1kjkaIwPk+46ytnGHD+MbovPBzeDuYkUh69moNe3h7H13B329CaiSp1NyMaAJUex/Xxy2cqZxlj7Wnu42VqIHRrVgOzsbHTq1AmmpqbYtWsXLl++jK+++gqOjo66MQsWLMDixYuxYsUKnDhxAtbW1ujduzeKi+9dxA0PD8elS5cQGRmJv//+G4cPH8aECRN05+VyOXr16gU/Pz+cOXMG//vf/zBnzhysXLmyVl+vsZFIJHinp+aL8++nEpFwl327iejBFkRcgSAA/Vt4ork3Z7mrm0SoQxmWXC6Hvb09cnNzYWdnJ3Y4euNaWh6mbozGpbL93c8FueG/Q5rB095S5MiISB+k5hZjQcQVbDmnuQJez8ESi0e2Qoifk8iRGQ5D/Px5//33cezYMRw5cqTS84IgwMvLC++88w7effddAEBubi7c3d2xZs0ajBgxArGxsWjSpAlOnTqFtm3bAgAiIiLQr18/3LlzB15eXli+fDk+/PBDpKamwszMTPfc27Ztw5UrV6oUqyH+fWvLqJ9O4Mi1TAxr442vXmwpdjhEpIf+uXkXI1b+AxOpBJHTn0WAi7XYIRmMqn7+cKab0NDdFtsmdsK7vRrBTCbF/ivp6PX1YWw8lcBZb6I6rKhEhUV7r6H7woO6hHtYG2/snNyFCXcdsH37drRt2xbDhw+Hm5sbWrdujR9++EF3Pj4+HqmpqQgLC9Mds7e3R4cOHRAVFQUAiIqKgoODgy7hBoCwsDBIpVKcOHFCN6Zr1666hBsAevfujbi4OGRnZ1cam0KhgFwuL3ejyr3TqzEAYOu5O7iWlidyNESkbwRBwJe7NBc4X2rnw4S7hjDpJgCAqUyKSc81xN+TO6OljwPyFKV4b/MFjPrpJG7fLRA7PCKqRYIg4M/oJDz31UF8s/cqipQqtPVzxJ8TO+GrF1vC3spU7BCpFty8eRPLly9Hw4YNsXv3bvznP//B5MmTsXbtWgBAaqqmj6u7u3u5+7m7u+vOpaamws3Nrdx5ExMTODk5lRtT2WPc/xz/Nm/ePNjb2+tuPj4+T/lqjVcrHwf0auIOtQDM3RkrdjhEpGd2X0pDdGIOLE1lmNKDfblrCpNuKqeRuy02vxmKD/oFwdxEiqPXM9Hzm8P4JvIqipXs601k7M4lZGPo8uOY8ls0UnKLUc/BEktGtsYfb4aipY+D2OFRLVKr1WjTpg3mzp2L1q1bY8KECRg/fjxWrFghdmiYNWsWcnNzdbfExESxQ9Jrs/oFw0QqwYG4DBy+miF2OESkJ0pVavxvt2aWe1znALjZsUZLTWHSTRWYyKSY0DUQu6Z0QecGLigpVWPRvmvo+c0h7ItNEzs8IqoBKblFmLYxGkO+O45zCTmwMpPh3V6NsO+dZzGwpRckEonYIVIt8/T0RJMmTcodCw4ORkJCAgDAw8MDAJCWVv5zIS0tTXfOw8MD6enp5c6XlpYiKyur3JjKHuP+5/g3c3Nz2NnZlbvRgwW4WGN0qD8A4Isdl1GqUosbEBHphd9P38GNjAI4WpliwrP1xQ7HqDHppgeq72qDX8a1x7KX28DDzgKJWUUYt/Y0Xl97ColZrIJKZAy0+7afW3gIW8v2bb8Q4o0D73bDpOcawsJUJnKEJJZOnTohLi6u3LGrV6/Cz88PABAQEAAPDw/s27dPd14ul+PEiRMIDQ0FAISGhiInJwdnzpzRjdm/fz/UajU6dOigG3P48GEolUrdmMjISDRu3LhcpXR6OpN7NIC9pSmupuXj99N3xA6HiESWW6TEwj2a9/hJzzWEnQW3jtUkJt30UBKJBP1beGLfO8/ijWfrw0Qqwd7YdIR9fQgLIq5AXqx89IMQkd550L7t7ZM6YeHwlnDnErM6b9q0afjnn38wd+5cXL9+HevXr8fKlSsxceJEAJrPh6lTp+KLL77A9u3bceHCBYwePRpeXl4YPHgwAM3MeJ8+fTB+/HicPHkSx44dw6RJkzBixAh4eXkBAF5++WWYmZlh3LhxuHTpEjZu3IhFixZh+vTpYr10o+RgZabbr/l1ZBzy+PlNVKd9u/cqsgpK0MDNBqND/cQOx+ixZRg9luvpeZj95yUcv3EXAOBkrfkQf7mDL0xlvIZDZAjOJWTjs78v41xCDgBNC7BZ/YLQv7knl5HXEEP9/Pn7778xa9YsXLt2DQEBAZg+fTrGjx+vOy8IAj755BOsXLkSOTk56Ny5M7777js0atRINyYrKwuTJk3CX3/9BalUimHDhmHx4sWwsbHRjYmJicHEiRNx6tQpuLi44O2338Z7771X5TgN9e9b20pK1ej97WHEZxbgrW6BmNknSOyQiEgE19Ly0GfREajUAn4Z1x5dGrqKHZLBqurnD5NuemyCIGBvbDq+3BWLGxmayuYBLtZ4r09j9G7qwS/tRHoqJbcICyLidMvIrcxkmNi9AcZ1DuAy8hrGz5+axb9v1UVeTsP4n0/DzESKPVO7wp/tgYjqFEEQMOqnkzh6PRO9mrhj5ei2j74TPRD7dFONkUgk6NnEHbundsUXg5vBxcYM8ZkFePPXs3hhRRRO3LwrdohEdJ/cIiW+ibyK7gsPltu3ffDdbpjYvQETbqI6JCzYDV0aaoqkzt5+CXVo7oWIAOy5nIaj1zNhZiLFR/2bPPoOVC1MxA6ADJeJTIpXnvHD4Nb1sPLQDaw8chNnbmfjpZX/oGOgM6aGNUL7ACexwySqsxKzCrH62C1sPJWAghJNy792/o6YPaAp/t/efYc1ea5/AP8mBMIICXvJdoADAbXiqNbBcRysq7bVeqyzw+Kqtsf6O0et1qqtnXZol+Lp0draam1dPVZBq+ICUXHgQkC2IAmbkDy/PyypKVQRgYB8P9eVS/K8z/vmzm3Iy/2O5wnyVJk4OiIyBYlEgiXDO2LIB7/h4KVc7E7Mwt+D3E0dFhE1gjKtDst2ngcAPN/HH96O1iaOqOVg0U0PTCGXYe6gAIzv4YPV+y7ju5NpOHI1D0euxqJ3G0e8HN4O3XxZfBM1ljM3CvDFb8nYdTYTOv3ts1iBbraYOaAt/h7EW0CIWjp/ZwVe7Ncaq/ddxtKfz6NvO2co5PyTkOhh90n0FaTll8JNaYmX+rc2dTgtCr9hqd64Ki3x5qggTO/XGp/GXMWWk2k4fCUPh6/Eok9bJ8we2JbFN1ED0esFYi7l4POD13D0Wr6hvU9bJzzXxx992jqx2CYig5f6tcb2hHSk5JXg/b2XsHAYLzMlepglZRViTcxVAMDrwzvA2oJlYGPiQGrUYG7cKsEn0Vew5eQNVP5+tq2bjz2e7+uP8PaukEpZABA9qDKtDj+eSseXh5JxJacIACCTSjA82APT+vijgwe/65oC7n8aFvNbNzFJOZi0/gTMpBL8PONRfl8QPaT0eoExa48gPrUAf+vgis8ndOWB+HrC0ctrwJ2yaaTl3y6+t8ano0KnBwD4O9vguT7+GBXaioM4EdXBreIK/PdoCjbEXsfNogoAgK1chmfCvDGpty/cVVYmjpDuxP1Pw2J+6+6ljXHYdTYLIV52+GF6L5jxgDjRQ+fr2OtYuP0cbCzM8Ou8x/g3Qj1i0V0D7pRNK0dThvVHruO/R1NQWFYJAHBSyDGplw/+0cMHdtYWJo6QqOlLySvGV4eS8d3JNJRpbx/E8lBZYsqjfnj6ES/YWpqbOEKqCfc/DYv5rbssdRn+9t4BFJZX4t8R7TGtj7+pQyKiepSlLkP4ewdQVF6JJcM7YmIvX1OH9FBh0V0D7pSbhqLySmw+nop1h5KRoS4DAFiaSzE82AMTevhyVGWiGsSn3sIXB69hz7ksVH1rd3BX4oXH/PH3IHeYm3EGyKaM+5+Gxfw+mM3HU/Ha1rOQy6TYPbsP/J0Vpg6JiOqBEAIv/jcOv5zL5tUsDeShnKd7586dCAsLg5WVFezt7TFy5EhTh0R1oJDLMK2PPw78sz8+eDoE7d2VKNPq8d3JG3j840MY8fEhbDmZhjKtztShEpmUukSLTcdSMfrTwxj96RHsTrxdcPcLcMamaWHYOetRjAhpxYKbiB7I0494oU9bJ5RX6jH/hzPQ61vM+Riih9rPZzLxy7lsyKQSrBgdxILbhJrNsHU//PADnnvuOSxfvhwDBgxAZWUlEhMTTR0WPQBzMylGhrbCiBAPxKXcwtdHU7DrbCZO31Dj9PdnsGznBTzZ1RPje/jAz8nG1OESNYqKSj2ik3Lw46l07LuQYxgHwdxMgpEhrTCtjz8C3GxNHCURPUwkktt/kA9+/yBOXL+FDbHXMbm3n6nDIqIHkK0pw8Ifb9dKkf3boL07rwIypWZxeXllZSV8fX2xZMkSTJ06tc7b4eVnTd/NonJ8dzING4+mIr2g1ND+iK89nujiib93doeS96zSQ0YIgfjUW9h2Kh07zmSioERrWBbgaotRXVphdGgruCgtTRglPQjufxoW81s/vj6agoU/JsLK3Ax75vSBjyMPeBM1R0IITI46gZikXHRqpcS2l3rzqrgGUtv9T7M40x0fH4/09HRIpVKEhoYiKysLISEhWLVqFTp16mTq8KgeOSnkeKlfG7zQtzUOXMrB17EpiLmUixPXb+HE9VtY/NM5DO7ohjFdPdG7jRMvk6Fm7frNYmw7lY4ff58rt4qLrRwjQjwwKtQT7d1tOa0HETWK8d29sfNMBo5ey8e8705j8/M9IOMf6kTNzrcn0hCTlAsLmRTvPRXCgrsJaBZF97Vr1wAAr7/+Ot577z34+vri3XffRb9+/XDp0iU4ODjUuF55eTnKy8sNzzUaTaPESw/OTCrBgEBXDAh0RZa6DNtOpeOH+Bu4klOEn05n4KfTGXBVyjEytBVGhbZCgCsLE2oe8osrsPNMBraeSsep1AJDu7WFGYZ0dMOoLq3QqzUPKBFR45NKJVg1JhhDP/wNJ1NuYU3MVcwc2NbUYRHRfUjLL8EbO84DAF4Z1A7tXHlLWlNg0svLX3vtNbz11lt37XPhwgXEx8dj/Pjx+Oyzz/D8888DuF1Qe3p6YtmyZXjhhRdqXPf111/HkiVLqrXz8rPmSQiBMzfU+CH+Bn46nWF0Ca6/sw0igtwxtJM7zwxSk1Om1WH/xRxsjU9HTFIOKn8fpEgqAfq0dcao0FYY1NEV1hbN4jgo1QEvf25YzG/92hp/A3O/Ow0zqQTfv9gTod72pg6JiGpBpxcY98VRHE/OxyO+9tj8fE8exG9gzWLKsNzcXOTl5d21j7+/Pw4fPowBAwbgt99+w6OPPmpYFhYWhvDwcLz55ps1rlvTmW4vLy/ulB8C5ZU6RF/MwQ/x6ThwKRcVlXrDMl9Ha/w9yB1/D3JHRw8lC3AyiRxNGWIu5eJAUi4OXspFYXmlYVmnVkqMDGmF4SEecLHlfdotAYvChsX81i8hBGZtTsDPpzPg42iNnbP6QCHnQUGipu7DXy/j/V8vwdrCDLtnc1yGxtAs7ul2dnaGs7PzPft17doVcrkcSUlJhqJbq9Xi+vXr8PHx+cv15HI55HJ5vcVLTYdcZoYhndwxpJM7Csu02H8xBzvPZCLmUi6u55Xg05ir+DTmKrwdrDGogysGBLqgm68DLGS8p4UaRqVOj4S0AsQk5SI6KQfnMoxvZ/FQWWJE6O0B0dryUi8iasIkEgmWjeyE+JRbSMkrwZKfzmHVk8GmDouI7uLYtTx8uO8SAODNUZ1YcDcxzeKwpVKpxIsvvojFixfDy8sLPj4+WLVqFQDgySefNHF0ZGq2luYYEdIKI0Jaoai8EtEXc7DrbCaik3KQml+CLw8l48tDyVDIZXi0jRP6BzqjX4ALXDkSND2g3MJyHLiUi5ikHPx2+SbUpX/c8iCRAJ1bqdAvwAX9ApwR7GkHKS/xIqJmQmVljvefDsHYz2OxJe4G+rZzxuPBHqYOi4hqcKu4AnO+TYBeAE908cSoUE9Th0R/0iyKbgBYtWoVZDIZJkyYgNLSUoSFhWH//v2wt+d9RvQHhVyGx4M98HiwB0oqKhGTlIt9F3Jw4FIObhZVYM+5LOw5lwUA6OCuNBTgwZ52PAtO96TTCySkFeBAUg6ik3JxNl1ttFxlZY7H2jmjX4Az+rZzhpOCV9oQUfPV3c8BL/Vrg4+jr+C1H86gg4cSrZ0Vpg6LiO4ghMCr359BproM/k42WDqio6lDoho0i3m66wvv+Wq59HqBxAw1oi/mYn9SDs7cKMCdn3wrczN087VHD39H9PB3RGdPFadXIOj1AldyixCfcgtHrubh4OVcowH8gNv3Z/cPcEG/ABeEeNlxwBKqEfc/DYv5bTiVOj3Gf3kMx5LzEeBqix8je8PKwszUYRHR79YfTsaSn8/DwkyKrS/1QqdWKlOH1KI0i4HUGht3ylTlZlE5Dl7KRXRSLg5dzsWtPxVS1hZm6Opzuwjv2doRQa1YhLcEmjItElILEJ96C3Ept5CQVoDCskqjPraWMvRt54x+7ZzxWIAzB0KjWuH+p2Exvw0rR1OGv68+hJtF5RjT1RPv8P5uoiYhLiUfT392FJV6gcWPd8Dk3n6mDqnFaRYDqRGZipNCjtFdPDG6iyf0eoFLOYU4ejUPR6/l41hyHm6VaPHb5Zv47fJNAICluRSdPFQI8bJDsJcdQrzs4GlvxZHRmzG9XuDazWLEp95CfMotxKfewuWcIvz5MKSVuRmCvVTo5uOAxwKcEeplBxkPwBBRC+KitMRH40Ix/suj+D7uBrr7OuCpR7xMHRZRi5ZTWIbp/41HpV5gWGd3TOrla+qQ6C5YdFOLJ5VKEOimRKCbEpN6+xmK8NireTh6LQ/HkvNRUKLFyZRbOJlyy7Ceo42FoQAP9rJDsKcKdtYWJnwn9FeEEMgrrsDFzMLbRXbqLZxKLTAa+KyKt4M1unjboYuPPbp42yPQzZZFNhG1eD1bO2LeoACs+iUJC7cnooOHkpexEpmIVqfHjI2nkFNYjrYuCrz1RGeeCGrieHk50T1UnRE9nVaAhLQCnL5RgAuZGmh11X913JSWaOdmi0A3W7Rzvf1vGxcFLM15/1tjuVlUjkvZhbiSU4RL2YW4lF2Ey9mF1W4hAAC5TIpgTzuE+tihi/ftItvZloOfUf3j/qdhMb+NQ68XmLrhBKKTcuGhssRPMx/lgJFEJrD05/NYd/j2zDzbZ/TmAIcmxHu6a8CdMtWXMq0O5zM1fxTiaQW4nldSY1+pBPB1tEHA74V4axcFfBys4eNozTPjDyCvqPx2QZ1TiMvZtwvsyzlFyC+uqLG/RAJ42Vsj2MsOXbzt0NXHHu3dlbxXnxoF9z8Ni/ltPOpSLUZ9chjXbhbjEV97bJzWg7N/EDWi7QnpmL05AQDw2YSuGNzRzbQBtXAsumvAnTI1JE2ZFpezC5GUVYSkLA2SsguRlFXzGdYqSksZvB2t4eNgA29Ha3g7WMPHwRrejtZwV1m12JGw9XqBm0XlyFCXIaOg9PfH7z+rS3HjVuk9i+t2rgq0cbFFO1fF7YMdzgqOuEsmw/1Pw2J+G9eVnCKM+uQwCssr8UyYN5aPCjJ1SEQtQnzqLYz9/CgqKvV4qV9r/HNIoKlDavE4kBpRI1NamqOrjwO6+jgY2oQQyC0qR1JWoeFxPa8YKXklyCksh6asEonpGiSma6ptTyoBHBVyuNjefjjbyuFiawkXZdVzS0N7c7l8vUyrQ0GJFupSLQpKKlBQqsXNonJk/l5QpxeUIlNdhkx1aY2X79+pqrhu66JAW1cW10REjaWNiwKrx4ViyoYT2HQsFe3dlZjQw8fUYRE91NILSvH8f+JQUalHeHtXzBsUYOqQ6D6w6CZqQBKJ5HahbGuJPm2djZaVVuiQml+C1PwSpOQVG35OzStB2q0SaHUCuYXlyC0sx7l7vI5cJoWtpTmUljLYWsqgsJTBVm4OW0sZbC3NobCUGZZZyKQwk0phLpXATCqBzEwCmVQKmVQCmZn0dtvv7RJIoNXpodXpUaHTo6JSD61O3H5eebtNa2jXo0yrh7q0qqjWQlOqRUFphaHQLq/U1zp3UgngqrSEh53V7Yfqjp/tLOHnZANrC36FERGZQv9AF/xzcCDe2nMRS346B19H62r7OSKqH8XllZi24SRuFpWjvbsSH44NabFXQzZX/IuVyESsLMwQ4GaLADfbast0eoG8onLkFJYjp7AMuYXlyNH88Tzn9+e5heWo0OlRXqlHeVE5bhaVm+Cd3B8zqQR2VuZQWZlDZW0ORxsLuKv+KKarCmtXWzlHDSciasJefMwfSVka/JiQgZf+G48t03si0I2X9xPVJ71eYPbmBFzI1MBJYYEvJ3aDjZwlXHPD/zGiJshMKoGL0hIuSksAfz0lixACmtJKaMq0KCyrROHv/xaV3/5ZU1b5+/Oq5ZXQ6vSo1Ano9AJavR46vUClTqBSr0fl7z/r9Lef6wVgYSaFuZkEFjIpzM1uPyxk0mrtFmZSyM2lUP5eUNtZWcDO+vfi2src8LNCLuO0FkREDwGJRIK3xnRGhroMx5PzMWX9CWyL7A1XpaWpQyN6KAghsHTHefx6IRsWMik+f7YbWtlZmTosqgMW3UTNmEQigcr69hljIiKixiaXmeHzCV0xes0RXMstxpSoE/juhZ48E0dUD7747RqijlwHALzzZDC6eNubNiCqM167SURERER1ZmdtgahJ3eFoY4FzGRpEboqHVlf7MTyIqLrtCelYvusiAOBff2+P4cEeJo6IHgSLbiIiIiJ6IN6O1vhq0iOwNJciJikXr245Db2+xcxKS1SvDl+5iVe2nAYATOnth2l9/EwcET0oFt1ERERE9MBCvOywZnxXyKQS/JiQgaU7zkMIFt5E9+PsDTVe+DoOWp1ARGd3/DuiPcfCeQiw6CYiIiKietE/0AXvPhUMAIg6ch2r910xcUREzcel7EI8u+4Yisor0cPfAe8+GQwppwZ7KLDoJiIiIqJ6MyKkFV5/vAMA4P1fL2HD7wNBEdFfS8krxj++PIZbJVoEe9nhy4mPwNLczNRhUT1h0U1ERERE9WpSbz/MHtgWALD4p3PYdCzVxBERNV2Z6lI888Ux5BSWI9DNFhsmPwIFZwB4qLDoJiIiIqJ6Nye8LaY9ensAqP/bdhbfnUwzcURETU+OpgzjvziG9IJS+DnZ4D9Tu8PO2sLUYVE9Y9FNRERERPVOIpHgXxHtMamXLwBg/g9nsO3UDdMGRdSEZGvKMPbzo7h2sxit7Kzw32lhcLG1NHVY1ABYdBMRERFRg5BIJFj8eAeMD/OGEMC8705je0K6qcMiMrlsTRnG3VFwb36+B1rZWZk6LGogLLqJiIiIqMFIJBK8MaITnu7mBb0A5nybwEvNqUXLUhuf4d78fA94OVibOixqQC3qDv2quSI1Go2JIyEiopakar/TnOcsXrlyJRYsWIDZs2fjgw8+AACUlZVh3rx52Lx5M8rLyzF48GB8+umncHV1NayXmpqK6dOnIzo6GgqFAhMnTsSKFSsgk/3xJ0hMTAzmzp2Lc+fOwcvLC//+978xadKkRn6H1JCkUglWjA6CmZkEm46l4p/fn0G5VocJPX1NHRpRo0rLL8H4L48hNb+EBXcL0qKK7sLCQgCAl5eXiSMhIqKWqLCwECqVytRh3LcTJ07gs88+Q+fOnY3aX375ZezcuRNbtmyBSqXCjBkzMHr0aBw+fBgAoNPpEBERATc3Nxw5cgSZmZl49tlnYW5ujuXLlwMAkpOTERERgRdffBEbN27Evn37MG3aNLi7u2Pw4MGN/l6p4UilErw5shPkMinWH76OhdvPobxSj2l9/E0dGlGjuJJTiH98eRxZmjJ4OVhh0zQW3C2FRDTnw+73Sa/XIyMjA7a2tpBIajfRvEajgZeXF9LS0qBUKhs4wvrH+E2L8ZsW4zctxv8HIQQKCwvh4eEBqbR53dlVVFSELl264NNPP8WyZcsQEhKCDz74AGq1Gs7Ozti0aRPGjBkDALh48SLat2+P2NhY9OjRA7t378awYcOQkZFhOPu9du1azJ8/H7m5ubCwsMD8+fOxc+dOJCYmGl5z7NixKCgowJ49e2oVo0ajgUqlglqtbpaftZZGCIG3f0nCmpirAIBZA9vi5fC2tf7bjKg5SkxX49l1x5FfXIG2Lgp8PTUMbioOmtbc1Xb/06LOdEulUnh6etZpXaVS2ax35IzftBi/aTF+02L8tzXHM9wAEBkZiYiICISHh2PZsmWG9ri4OGi1WoSHhxvaAgMD4e3tbSi6Y2NjERQUZHS5+eDBgzF9+nScO3cOoaGhiI2NNdpGVZ85c+Y0+Hsj05BIJPjn4ABYmZvhvb2XsHrfZdwsKscbIzrBTMrCmx4+x5PzMTXqBArLKxHUSoUNU7rDwYbTgrUkLaroJiIiotrbvHkz4uPjceLEiWrLsrKyYGFhATs7O6N2V1dXZGVlGfrcWXBXLa9adrc+Go0GpaWlsLKqPppveXk5ysvLDc85VkvzI5FIMGtgW9jbWGDR9kRsOpaKW8UVeP/pEFiam5k6PKJ6s/tsJmZ/m4CKSj26+zngq4ndYGtpbuqwqJE1r2vciIiIqFGkpaVh9uzZ2LhxIywtm9YlkCtWrIBKpTI8OFZL8zWhhw8+HtcFFmZS7E7MwuT1J6Au1Zo6LKJ6EXU4GS9tikdFpR5/6+CKDZO7s+BuoVh034NcLsfixYshl8tNHUqdMH7TYvymxfhNi/E3b3FxccjJyUGXLl0gk8kgk8lw4MABrF69GjKZDK6urqioqEBBQYHRetnZ2XBzcwMAuLm5ITs7u9ryqmV366NUKms8yw0ACxYsgFqtNjzS0jj9VHMW0dkdUZMfgUIuQ+y1PIxZcwQ3bpWYOiyiOtPrBVbuvojXfz4PIYDxYd5Y+4+usLLgVRwtVYsaSI2IiIhqp7CwECkpKUZtkydPRmBgIObPnw8vLy84Ozvjm2++wRNPPAEASEpKQmBgYLWB1DIzM+Hi4gIA+Pzzz/Hqq68iJycHcrkc8+fPx65du3D27FnD6zzzzDPIz8/nQGotzLkMNaZEnUC2phxOCjm+mtgNwV52pg6L6L6UaXWYt+U0dp7JBAC8OjgAL/VrzYECH1K13f+w6CYiIqJa6devn2H0cgCYPn06du3ahaioKCiVSsycORMAcOTIEQC3pwwLCQmBh4cH3n77bWRlZWHChAmYNm2a0ZRhnTp1QmRkJKZMmYL9+/dj1qxZ2LlzZ62nDGPR/fDIVJdi8voTuJhVCEtzKT4cG4rBHd1MHRZRreRoyvDc13E4nVYAczMJVozujDFd6zaIMzUPtd3/8PJyIiIiqpP3338fw4YNwxNPPIG+ffvCzc0NW7duNSw3MzPDjh07YGZmhp49e+If//gHnn32WSxdutTQx8/PDzt37sTevXsRHByMd999F19++SXn6G6h3FVW+H56LzzWzhllWj1e/G8cPom+Ap4joqbuXIYaIz45jNNpBbCzNsfXU8NYcJMBz3QTERFRs8Yz3Q+fSp0eS3ecx39ib9/iENHZHavGdIa1BSfeoaZn19lMvLLlNEoqdPB3tsG6iY/A18nG1GFRI+CZ7lp688030atXL1hbW1eb9uSvSCSSGh+rVq0y9PH19a22fOXKlU0i/kmTJlWLbciQIUZ98vPzMX78eCiVStjZ2WHq1KkoKioyefxarRbz589HUFAQbGxs4OHhgWeffRYZGRlG/Zpy/oUQWLRoEdzd3WFlZYXw8HBcvnzZqE9j5f9+X+f69et/+fnfsmWLoV9Nyzdv3mzy+IHbl8f+ObYXX3zRqE9qaioiIiJgbW0NFxcXvPrqq6isrDR5/Pn5+Zg5cyYCAgJgZWUFb29vzJo1C2q12qhfQ+X/k08+ga+vLywtLREWFobjx4/ftf+WLVsQGBgIS0tLBAUFYdeuXUbLa/O7UJ/uJ/4vvvgCffr0gb29Pezt7REeHl6tf22+S4mobmRmUiwd0QnLRwVBJpVg55lMjFkTi/SCUlOHRmSg0wu8veciXtoYj5IKHXq3ccS26b1ZcFM1Lb7orqiowJNPPonp06fXep3MzEyjx7p16yCRSAwDyVRZunSpUb+qe93qU13iB4AhQ4YYxfbNN98YLR8/fjzOnTuHvXv3YseOHTh48CCef/75+gwdwP3HX1JSgvj4eCxcuBDx8fHYunUrkpKSMHz48Gp9m2r+3377baxevRpr167FsWPHYGNjg8GDB6OsrMzQp7Hyf7+v4+XlVe3zv2TJEigUCgwdOtSo7/r16436jRw50uTxV3nuueeMYnv77bcNy3Q6HSIiIlBRUYEjR45gw4YNiIqKwqJFi0wef0ZGBjIyMvDOO+8gMTERUVFR2LNnD6ZOnVqtb33n/9tvv8XcuXOxePFixMfHIzg4GIMHD0ZOTk6N/Y8cOYJx48Zh6tSpOHXqFEaOHImRI0ciMTHR0Kc2vwv15X7jj4mJwbhx4xAdHY3Y2Fh4eXlh0KBBSE9PN+p3r+9SInowz4R5Y9NzPeBoY4HzmRo8/tEhHLp809RhEUFdosWUqBP4NOYqAGDao37YMLk7VNacEoxqIEgIIcT69euFSqWq07ojRowQAwYMMGrz8fER77///oMHVkv3E//EiRPFiBEj/nL5+fPnBQBx4sQJQ9vu3buFRCIR6enpDxhpzR4k/8ePHxcAREpKiqGtqeZfr9cLNzc3sWrVKkNbQUGBkMvl4ptvvhFCNF7+6+t1QkJCxJQpU4zaAIht27bVV6g1qmv8jz32mJg9e/ZfLt+1a5eQSqUiKyvL0LZmzRqhVCpFeXl5vcQuRP3l/7vvvhMWFhZCq9Ua2hoi/927dxeRkZGG5zqdTnh4eIgVK1bU2P+pp54SERERRm1hYWHihRdeEELU7nfBlPH/WWVlpbC1tRUbNmwwtN3ru5Qaj1qtFgCEWq02dSjUQG7cKhERqw8Kn/k7hO9rO8RH+y4JnU5v6rCohTp7o0D0eWu/8Jm/QwT8e5f48dQNU4dEJlLb/U+LP9P9oLKzs7Fz584azzStXLkSjo6OCA0NxapVqxrk8tS6iomJgYuLCwICAjB9+nTk5eUZlsXGxsLOzg7dunUztIWHh0MqleLYsWOmCPeu1Go1JBJJtcu7m2L+k5OTkZWVhfDwcEObSqVCWFgYYmNjATRe/uvjdeLi4pCQkFDj5z8yMhJOTk7o3r071q1bV++D4DxI/Bs3boSTkxM6deqEBQsWoKTkj/lgY2NjERQUBFdXV0Pb4MGDodFocO7cuSYR/52q7iGSyYzvc6zP/FdUVCAuLs7ocyuVShEeHm743P5ZbGysUX/gdh6r+tfmd6G+1CX+PyspKYFWq4WDg4NR+92+S4mo/rSys8L3L/bCuO5eEAJ453+XMO0/J1FQUmHq0KgFEULg69jrGP3pEaTml8DT3gpbp/fGiJBWpg6NmjiORvGANmzYAFtbW4wePdqofdasWejSpQscHBxw5MgRLFiwAJmZmXjvvfdMFOkfhgwZgtGjR8PPzw9Xr17F//3f/2Ho0KGIjY2FmZkZsrKyDPOpVpHJZHBwcEBWVpaJoq5ZWVkZ5s+fj3HjxhkNXtBU81+VvzsLuqrnVcsaK//18TpfffUV2rdvj169ehm1L126FAMGDIC1tTX+97//4aWXXkJRURFmzZpl8vifeeYZ+Pj4wMPDA2fOnMH8+fORlJRkGHE5Kyurxv+fqmWmjv9ON2/exBtvvFHtkvT6zv/Nmzeh0+lqzMvFixdrXOev8njn57yq7a/61Je6xP9n8+fPh4eHh1Hhfq/vUiKqX5bmZlgxujNCve2x8MdE7L+Yg79/+Bs+HBeKR3wd7r0BogdQWKbFgq1nseP3+bf/1sEV74wJ5uXkVCsPZdH92muv4a233rprnwsXLiAwMPCBX2vdunUYP348LC0tjdrnzp1r+Llz586wsLDACy+8gBUrVkAul991mw0d/9ixYw0/BwUFoXPnzmjdujViYmIwcODAOm3zTo2Vf61Wi6eeegpCCKxZs8ZoWVPOf0OrbfwPqrS0FJs2bcLChQurLbuzLTQ0FMXFxVi1alWtir6Gjv/OAjUoKAju7u4YOHAgrl69itatW9d5u1UaK/8ajQYRERHo0KEDXn/9daNlD5J/qm7lypXYvHkzYmJijL7rG/q7lIhq9lQ3L3T0UGLGplNIvlmMpz+LxZzwdojs3wZmUompw6OHUEJaAWZvPoWUvBLIpBK8NjQQUx/1g0TCzxvVzkNZdM+bNw+TJk26ax9/f/8Hfp3ffvsNSUlJ+Pbbb+/ZNywsDJWVlbh+/ToCAgLu2rex4r9zW05OTrhy5QoGDhwINze3aoMLVVZWIj8/H25ubvfcXmPEX1Vwp6SkYP/+/fecIqap5L8qf9nZ2XB3dze0Z2dnIyQkxNCnMfL/oK/z/fffo6SkBM8+++w9+4aFheGNN95AeXn5PQ96NFb8d8YGAFeuXEHr1q3h5uZWbZTq7OxsAGgy+S8sLMSQIUNga2uLbdu2wdz87kfZ7yf/NXFycoKZmZkhD1Wys7P/MlY3N7e79q/N70J9qUv8Vd555x2sXLkSv/76Kzp37nzXvn/+LiWihtPRQ4WfZz6KRT8mYuupdLy39xIOX7mJ958OgYedlanDo4eETi+wJuYK3v/1MnR6AQ+VJT56pgu6+tibOjRqZh7KotvZ2RnOzs4N/jpfffUVunbtiuDg4Hv2TUhIgFQqrXY5aU0aK/4qN27cQF5enuEP3549e6KgoABxcXHo2rUrAGD//v3Q6/WGAuVuGjr+qoL78uXLiI6OhqOj4z3XaSr59/Pzg5ubG/bt22coLDQaDY4dO2YYAb2x8v+gr/PVV19h+PDhtXqthIQE2Nvb16rga6z474wNgNHn/80330ROTo7h87J3714olUp06NDB5PFrNBoMHjwYcrkcP/30U7WrbGpyP/mviYWFBbp27Yp9+/YZRkHX6/XYt28fZsyYUeM6PXv2xL59+zBnzhxD2969e9GzZ08AtftdqC91iR+4Pbr6m2++iV9++cXo3vu/8ufvUiJqWAq5DO89HYJH2zrh3z8m4lhyPgZ/cBBvjOiEESEePAtJDyS9oBQvf5uA48n5AG7PFb98ZBAvJ6e6aYRB3Zq0lJQUcerUKbFkyRKhUCjEqVOnxKlTp0RhYaGhT0BAgNi6davRemq1WlhbW4s1a9ZU2+aRI0fE+++/LxISEsTVq1fFf//7X+Hs7CyeffZZk8dfWFgoXnnlFREbGyuSk5PFr7/+Krp06SLatm0rysrKDOsMGTJEhIaGimPHjolDhw6Jtm3binHjxpk8/oqKCjF8+HDh6ekpEhISRGZmpuFRNbJ0U86/EEKsXLlS2NnZie3bt4szZ86IESNGCD8/P1FaWmro01j5v9fr3LhxQwQEBIhjx44ZrXf58mUhkUjE7t27q23zp59+El988YU4e/asuHz5svj000+FtbW1WLRokcnjv3Llili6dKk4efKkSE5OFtu3bxf+/v6ib9++hnUqKytFp06dxKBBg0RCQoLYs2ePcHZ2FgsWLDB5/Gq1WoSFhYmgoCBx5coVo89/ZWWlEKLh8r9582Yhl8tFVFSUOH/+vHj++eeFnZ2dYZT3CRMmiNdee83Q//Dhw0Imk4l33nlHXLhwQSxevFiYm5uLs2fPGvrU5nehvtxv/CtXrhQWFhbi+++/N8pz1e92bb9LqXFw9HK6llskhn98SPjM3yF85u8QL/03TuQX1d+ME9Ry6PV68e3xVNFp0R7hM3+H6LBwt9hyMk3o9Rwtn6qr7f6nxRfdEydOFACqPaKjow19AIj169cbrffZZ58JKysrUVBQUG2bcXFxIiwsTKhUKmFpaSnat28vli9f3iB/iN1v/CUlJWLQoEHC2dlZmJubCx8fH/Hcc88ZTY8khBB5eXli3LhxQqFQCKVSKSZPnmxUSJoq/uTk5Br737lOU86/ELe/zBcuXChcXV2FXC4XAwcOFElJSUbbbaz83+t1qvJ95/sRQogFCxYILy8vodPpqm1z9+7dIiQkRCgUCmFjYyOCg4PF2rVra+zb2PGnpqaKvn37CgcHByGXy0WbNm3Eq6++Wu2L8vr162Lo0KHCyspKODk5iXnz5hlNyWWq+KOjo//y85+cnCyEaNj8f/TRR8Lb21tYWFiI7t27i6NHjxqWPfbYY2LixIlG/b/77jvRrl07YWFhITp27Ch27txptLw2vwv16X7i9/HxqTHPixcvFkLU/ruUGgeLbhJCCG2lTnyw95LwX7BT+MzfIR5Ztlf87xx/J6n2stSlYtK6Y4aDN6M+OSSu3ywydVjUhNV2/yMRop7n8SEiIiJqRBqNBiqVyjCFHrVsZ24U4OVvE3A1txgAMDzYA4sf7wBHRd1usaGHnxAC38fdwLKdF6Au1cJCJsW8v7XDtD7+HJyP7qq2+x8W3URERNSsseimPyvT6vD+r5fwxcFr0AvAwcYCrw/viMc7u/NebzKSkleM/9t2Foev5AEAOnuq8O6TwWjramviyKg5YNFNRERELQKLbvorp9MK8M/vzyApuxAA0LedM94Y0RE+jjYmjoxMTavT46tDyfjg10so0+ohl0nx8t/aYdqjfpCZSU0dHjUTLLqJiIioRWDRTXdTUanHmpir+CT6Cip0eljIpJjRvw1eeMwfcpmZqcMjEzh6LQ+LtifiUnYRAKB3G0csHxXEgzF031h0ExERUYvAoptq41puERZtP4dDV24CAPycbLBwWHsMCHQ1cWTUWHI0ZXhz1wVsT8gAcPu2gwVDAzGmqydvO6A6YdFNRERELQKLbqotIQR+PpOJN3acR25hOQCgX4Az/h3RAW1cFCaOjhpKmVaHrw4l49PoKyiu0EEiAcaHeeOVQQGws7YwdXjUjLHoJiIiohaBRTfdr8IyLT7efwXrDidDqxOQSSWY0NMHMwe0hYMNi7CHRdVBlrd2X0R6QSkAINjLDstGdEKQp8rE0dHDgEU3ERERtQgsuqmuruUW4c2dF7DvYg4AwFYuw4v9WmNKbz9YWfB+7+Ys9moe3tpzEQlpBQAAd5Ul5g8JxPBgD0g5DRjVk9rufzg0HxERtXgHDx7E448/Dg8PD0gkEvz444/3tf7rr78OiURS7WFjw0F5iJoyf2cFvpr0CL6e2h0d3JUoLK/Eql+S0P+dGGw8loKKSr2pQ6T7lJiuxrPrjmPcF0eRkFYAK3MzzP1bO+yf1w8jQ1ux4CaTYNFNRDXy9fU1FA4FBQUPtK1+/foZtpWQkFAv8RHVp+LiYgQHB+OTTz6p0/qvvPIKMjMzjR4dOnTAk08+Wc+RElFD6NPWGTtmPor3nw5GKzsrZGnK8K9tiRjwbgy+O5mGSh2L76buQqYGL22Mw7CPDuHgpVzIpBI829MHB/7ZD7MGtuWVC2RSLLqJHmI6nQ69evXC6NGjjdrVajW8vLzwr3/9667rL126FJmZmVCpHuy+p61bt+L48eMPtA2ihjR06FAsW7YMo0aNqnF5eXk5XnnlFbRq1Qo2NjYICwtDTEyMYblCoYCbm5vhkZ2djfPnz2Pq1KmN9A6I6EFJpRKMCvXEvnmPYdGwDnBSyHHjVin++f0ZhL93AN+dSOOZ7yboXIYaL3x9EkM//A27zmZBIgFGhnhg/7x+WDqiE1xsLU0dIhFkpg6AiBqOmZkZoqKiEBISgo0bN2L8+PEAgJkzZ8LBwQGLFy++6/q2trZwc3N74DgcHByg0WgeeDtEpjJjxgycP38emzdvhoeHB7Zt24YhQ4bg7NmzaNu2bbX+X375Jdq1a4c+ffqYIFoiehCW5maY8qgfxnX3xtdHr2PtgWu4nleCf/5wBu//egnP9/XH2Ee8eebUhIQQOJ6cj88OXsP+3+/Hl0iAvwe5Y9aAtghwszVxhETGeKab6CHXrl07rFy5EjNnzkRmZia2b9+OzZs34z//+Q8sLO5vhNaoqCjY2dlhx44dCAgIgLW1NcaMGYOSkhJs2LABvr6+sLe3x6xZs6DT6RroHRE1rtTUVKxfvx5btmxBnz590Lp1a7zyyit49NFHsX79+mr9y8rKsHHjRp7lJmrmrCzM8Hzf1vjtn/3xr7+3h4utHJnqMiz5+Tx6v7Uf7/4vCTmaMlOH2aLo9AJ7EjMx6tMjePrzo9h/MQcSCTA82AP/m9MXnzzThQU3NUk8003UAsycORPbtm3DhAkTcPbsWSxatAjBwcF12lZJSQlWr16NzZs3o7CwEKNHj8aoUaNgZ2eHXbt24dq1a3jiiSfQu3dvPP300/X8Toga39mzZ6HT6dCuXTuj9vLycjg6Olbrv23bNhQWFmLixImNFSIRNSAbuQzP9fXHhJ4+2BqfjrUHriI1vwQf7b+CtQeu4vHOHpjc249TUDWgW8UV+PZkGr6OTTFM/WUhk+LJrp6Y1scffk4ctJKaNhbdRC2ARCLBmjVr0L59ewQFBeG1116r87a0Wi3WrFmD1q1bAwDGjBmDr7/+GtnZ2VAoFOjQoQP69++P6OhoFt30UCgqKoKZmRni4uJgZmZ8OalCoajW/8svv8SwYcPg6uraWCESUSOwNDfDM2HeeKqbJ/aez8ZXh5JxMuUWtp5Kx9ZT6Qj2VGF8mA+GBbvD2oJ/Yj8oIQQS0grwzfFUbE/IQPnv99PbW5tjfJgPJvbyhbOt3MRREtUOvxGIWoh169bB2toaycnJuHHjBnx9feu0HWtra0PBDQCurq7w9fU1Kj5cXV2Rk5PzoCETNQmhoaHQ6XTIycm55z3aycnJiI6Oxk8//dRI0RFRY5OZSTE0yB1Dg9xxOq0A6w4nY9fZTJy+ocbpG2fwxs7zGBnSCk909USwpwoSCaeouh8FJRXYdiod355Iw8WsQkN7Rw8lJvbyxfBgD1ia8356al5YdBO1AEeOHMH777+P//3vf1i2bBmmTp2KX3/9tU5/CJibmxs9l0gkNbbp9RzhlZqPoqIiXLlyxfA8OTkZCQkJcHBwQLt27TB+/Hg8++yzePfddxEaGorc3Fzs27cPnTt3RkREhGG9devWwd3dHUOHDjXF2yCiRhbsZYcPx4Zi4bAO+D7uBjYdS0Vqfgm+PpqCr4+moI2LAk908cTwEA+0srMydbhNVplWh/0Xc/DjqXREJ+VAqxMAALlMioggd4wL80Y3H3sewKBmi0U30UOupKQEkyZNwvTp09G/f3/4+fkhKCgIa9euxfTp000dHlGTcPLkSfTv39/wfO7cuQCAiRMnIioqCuvXr8eyZcswb948pKenw8nJCT169MCwYcMM6+j1ekRFRWHSpEnVLkMnooebk0KOFx9rjef7+OPw1Zv4Pu4G9iRm4UpOEd7acxFv7bmILt52iOjsgb8HucFdxQK8TKvDb5dvYk9iFv53LguF5ZWGZR09lBj7iBeGh7SCysr8Llshah5YdBM95BYsWAAhBFauXAkA8PX1xTvvvINXXnkFQ4cOrfNl5kQPk379+kEI8ZfLzc3NsWTJEixZsuQv+0ilUqSlpTVEeETUTEilEvRp64w+bZ2hKdNi15lMbD2VjhPX8xGfWoD41AK8seM8OnuqMDDQFQPbu6Cjh7LFnMHNKyrHwcu5+PV8DqKTclBS8cdMJ63srDA8xAMjQ1pxBHJ66LDoJnqIHThwAJ988gliYmJgbW1taH/hhRewdevWB7rMnIiIiP6a0tIcY7t7Y2x3b+RoyrA7MQs7z2TiREo+ztxQ48wNNd7/9RLclJZ4tK0THm3jhF5tHOFia2nq0OtNeaUOp9PUOHzlJmIu5eLMjQLceXzTQ2WJwZ3cMLSTO7r52EMq5d8j9HCSiLsd2ieiFsvX1xdz5szBnDlz6mV7169fh5+fH06dOoWQkJB62SYREQBoNBqoVCqo1WoolUpTh0N0VzmFZYi+mINfL+Tg0OWbKNXqjJa3dVGgm689uvo4oJuPPXwcrZvNwXF1iRYJNwpwKvUWjifnIy7llmHU8Srt3ZXoH+CMwR3d0JkDzVEzV9v9D4tuIqqRr68vMjMzYW5ujvT0dKhUdZ9/dOjQoTh48CBKSkpYdBNRvWPRTc1VmVaHE9fzcejKTRy+chOJ6ZpqfeytzdHRQ4WOrZTo6KFCgKstfJ2sIZeZbuwIIQRyCstxIVODpKxCXMjU4MwNNa7dLK7W10khR5i/Ax5r64zHApzhqnx4zuQTsegmogeSkpICrVYLAPD394dUKq3zttLT01FaWgoA8Pb2hoWFRb3ESEQEsOimh0d+ccXt+79TbuFkyi2cvaFGha76bCBSCeDlYI3Wzgp42Vuhlb0VWtlZw93OEk42cjgqLGBtYVbns8iVOj1ulWiRrSlDTmEZstTlyCgoxfW8YqTkleB6XjEKyyprXNfH0RohXnbo5uuAnv4OaO2s4Nlsemix6CYiIqIWgUU3PazKK3W4lFWExAw1zmWokZiuwdWcIqORvv+KXCaFnbU5bCxksJabwdpcBguZFFKpBFIJIJVIoNXpUV6pR0WlHmVaHQrLKqEu1aKoFtuXSgB/ZwUC3GzR3s0WHT1UCPayg4MND6xTy1Hb/Q8HUiMiIiIiaoLkMjMEeaoQ5PnHLV5CCOQWluNKbhGu5RYjvaAU6bdKkVFQikx1GfKKy1GmvV1MZ2vKAZTX+fWdFBZwVVrCTWkJN5UlfB1t4ONoDT8nG3g5WMPSnNMjEtUGi24iIiIiomZCIpHARWkJF6UlerV2qrFPSUUl8ooqoC7VoqRCh+KKSpSU66DV6aEXAnoB6IWATCqBXGYGuUwKC5kUtpYyqKzMYWdtAaWlDDKzut9aRkR/YNFNRERERPQQsbaQwdpBBi9TB0JEAAAeviIiIiIiIiJqICy6iYiIiIiIiBoIi24iIiIiIiKiBsKim4iIiIiIiKiBsOgmIiKialasWIFHHnkEtra2cHFxwciRI5GUlGTUp6ysDJGRkXB0dIRCocATTzyB7Oxsoz6pqamIiIiAtbU1XFxc8Oqrr6Ky0ngO4JiYGHTp0gVyuRxt2rRBVFRUQ789IiKiRsOim4iIiKo5cOAAIiMjcfToUezduxdarRaDBg1CcXGxoc/LL7+Mn3/+GVu2bMGBAweQkZGB0aNHG5brdDpERESgoqICR44cwYYNGxAVFYVFixYZ+iQnJyMiIgL9+/dHQkIC5syZg2nTpuGXX35p1PdLRETUUCRCCGHqIIiIiKhpy83NhYuLCw4cOIC+fftCrVbD2dkZmzZtwpgxYwAAFy9eRPv27REbG4sePXpg9+7dGDZsGDIyMuDq6goAWLt2LebPn4/c3FxYWFhg/vz52LlzJxITEw2vNXbsWBQUFGDPnj21ik2j0UClUkGtVkOpVNb/myciIqpBbfc/PNNNRERE96RWqwEADg4OAIC4uDhotVqEh4cb+gQGBsLb2xuxsbEAgNjYWAQFBRkKbgAYPHgwNBoNzp07Z+hz5zaq+lRtg4iIqLmTmToAIiIiatr0ej3mzJmD3r17o1OnTgCArKwsWFhYwM7Ozqivq6srsrKyDH3uLLirllctu1sfjUaD0tJSWFlZVYunvLwc5eXlhucajebB3iAREVED4pluIiIiuqvIyEgkJiZi8+bNpg4FwO1B3lQqleHh5eVl6pCIiIj+EotuIiIi+kszZszAjh07EB0dDU9PT0O7m5sbKioqUFBQYNQ/Ozsbbm5uhj5/Hs286vm9+iiVyhrPcgPAggULoFarDY+0tLQHeo9EREQNiUU3ERERVSOEwIwZM7Bt2zbs378ffn5+Rsu7du0Kc3Nz7Nu3z9CWlJSE1NRU9OzZEwDQs2dPnD17Fjk5OYY+e/fuhVKpRIcOHQx97txGVZ+qbdRELpdDqVQaPYiIiJoqjl5ORERE1bz00kvYtGkTtm/fjoCAAEO7SqUynIGePn06du3ahaioKCiVSsycORMAcOTIEQC3pwwLCQmBh4cH3n77bWRlZWHChAmYNm0ali9fDuD2lGGdOnVCZGQkpkyZgv3792PWrFnYuXMnBg8eXKtYOXo5ERGZQm33Pyy6iYiIqBqJRFJj+/r16zFp0iQAQFlZGebNm4dvvvkG5eXlGDx4MD799FPDpeMAkJKSgunTpyMmJgY2NjaYOHEiVq5cCZnsj7FcY2Ji8PLLL+P8+fPw9PTEwoULDa9RGyy6iYjIFFh0ExERUYvAopuIiEyhtvsfThlGREREzVrV+QNOHUZERI2par9zr/PYLLqJiIioWSssLAQATh1GREQmUVhYCJVK9ZfLeXk5ERERNWt6vR4ZGRmwtbX9y3vRa0uj0cDLywtpaWm8VL0WmK/7w3zdP+bs/jBf9+9BciaEQGFhITw8PCCV/vXEYDzTTURERM2aVCo1mkO8PnAqsvvDfN0f5uv+MWf3h/m6f3XN2d3OcFfhPN1EREREREREDYRFNxEREREREVEDYdFNRERE9Du5XI7FixdDLpebOpRmgfm6P8zX/WPO7g/zdf8aI2ccSI2IiIiIiIiogfBMNxEREREREVEDYdFNRERERERE1EBYdBMRERERERE1EBbdRERERAA++eQT+Pr6wtLSEmFhYTh+/LipQ2oSVqxYgUceeQS2trZwcXHByJEjkZSUZNSnrKwMkZGRcHR0hEKhwBNPPIHs7GwTRdy0rFy5EhKJBHPmzDG0MV/Vpaen4x//+AccHR1hZWWFoKAgnDx50rBcCIFFixbB3d0dVlZWCA8Px+XLl00YsenodDosXLgQfn5+sLKyQuvWrfHGG2/gzqG6Wnq+Dh48iMcffxweHh6QSCT48ccfjZbXJj/5+fkYP348lEol7OzsMHXqVBQVFdUpHhbdRERE1OJ9++23mDt3LhYvXoz4+HgEBwdj8ODByMnJMXVoJnfgwAFERkbi6NGj2Lt3L7RaLQYNGoTi4mJDn5dffhk///wztmzZggMHDiAjIwOjR482YdRNw4kTJ/DZZ5+hc+fORu3Ml7Fbt26hd+/eMDc3x+7du3H+/Hm8++67sLe3N/R5++23sXr1aqxduxbHjh2DjY0NBg8ejLKyMhNGbhpvvfUW1qxZg48//hgXLlzAW2+9hbfffhsfffSRoU9Lz1dxcTGCg4PxySef1Li8NvkZP348zp07h71792LHjh04ePAgnn/++boFJIiIiIhauO7du4vIyEjDc51OJzw8PMSKFStMGFXTlJOTIwCIAwcOCCGEKCgoEObm5mLLli2GPhcuXBAARGxsrKnCNLnCwkLRtm1bsXfvXvHYY4+J2bNnCyGYr5rMnz9fPProo3+5XK/XCzc3N7Fq1SpDW0FBgZDL5eKbb75pjBCblIiICDFlyhSjttGjR4vx48cLIZivPwMgtm3bZnhem/ycP39eABAnTpww9Nm9e7eQSCQiPT39vmPgmW4iIiJq0SoqKhAXF4fw8HBDm1QqRXh4OGJjY00YWdOkVqsBAA4ODgCAuLg4aLVao/wFBgbC29u7RecvMjISERERRnkBmK+a/PTTT+jWrRuefPJJuLi4IDQ0FF988YVheXJyMrKysoxyplKpEBYW1iJz1qtXL+zbtw+XLl0CAJw+fRqHDh3C0KFDATBf91Kb/MTGxsLOzg7dunUz9AkPD4dUKsWxY8fu+zVlDx42ERERUfN18+ZN6HQ6uLq6GrW7urri4sWLJoqqadLr9ZgzZw569+6NTp06AQCysrJgYWEBOzs7o76urq7IysoyQZSmt3nzZsTHx+PEiRPVljFf1V27dg1r1qzB3Llz8X//9384ceIEZs2aBQsLC0ycONGQl5p+R1tizl577TVoNBoEBgbCzMwMOp0Ob775JsaPHw8AzNc91CY/WVlZcHFxMVouk8ng4OBQpxyy6CYiIiKiWomMjERiYiIOHTpk6lCarLS0NMyePRt79+6FpaWlqcNpFvR6Pbp164bly5cDAEJDQ5GYmIi1a9di4sSJJo6u6fnuu++wceNGbNq0CR07dkRCQgLmzJkDDw8P5quJ4uXlRERE1KI5OTnBzMys2ujR2dnZcHNzM1FUTc+MGTOwY8cOREdHw9PT09Du5uaGiooKFBQUGPVvqfmLi4tDTk4OunTpAplMBplMhgMHDmD16tWQyWRwdXVlvv7E3d0dHTp0MGpr3749UlNTAcCQF/6O3vbqq6/itddew9ixYxEUFIQJEybg5ZdfxooVKwAwX/dSm/y4ublVG0izsrIS+fn5dcohi24iIiJq0SwsLNC1a1fs27fP0KbX67Fv3z707NnThJE1DUIIzJgxA9u2bcP+/fvh5+dntLxr164wNzc3yl9SUhJSU1NbZP4GDhyIs2fPIiEhwfDo1q0bxo8fb/iZ+TLWu3fvatPQXbp0CT4+PgAAPz8/uLm5GeVMo9Hg2LFjLTJnJSUlkEqNyzgzMzPo9XoAzNe91CY/PXv2REFBAeLi4gx99u/fD71ej7CwsPt+TV5eTkRERC3e3LlzMXHiRHTr1g3du3fHBx98gOLiYkyePNnUoZlcZGQkNm3ahO3bt8PW1tZwP6NKpYKVlRVUKhWmTp2KuXPnwsHBAUqlEjNnzkTPnj3Ro0cPE0ff+GxtbQ33u1exsbGBo6OjoZ35Mvbyyy+jV69eWL58OZ566ikcP34cn3/+OT7//HMAMMxzvmzZMrRt2xZ+fn5YuHAhPDw8MHLkSNMGbwKPP/443nzzTXh7e6Njx444deoU3nvvPUyZMgUA8wUARUVFuHLliuF5cnIyEhIS4ODgAG9v73vmp3379hgyZAiee+45rF27FlqtFjNmzMDYsWPh4eFx/wHVeex1IiIioofIRx99JLy9vYWFhYXo3r27OHr0qKlDahIA1PhYv369oU9paal46aWXhL29vbC2thajRo0SmZmZpgu6iblzyjAhmK+a/Pzzz6JTp05CLpeLwMBA8fnnnxst1+v1YuHChcLV1VXI5XIxcOBAkZSUZKJoTUuj0YjZs2cLb29vYWlpKfz9/cW//vUvUV5ebujT0vMVHR1d4/fWxIkThRC1y09eXp4YN26cUCgUQqlUismTJ4vCwsI6xSMRQogHOYpARERERERERDXjPd1EREREREREDYRFNxEREREREVEDYdFNRERERERE1EBYdBMRERERERE1EBbdRERERERERA2ERTcRERERERFRA2HRTURERERERNRAWHQTERERERERNRAW3UREREREzcikSZMwcuTIRn/dqKgoSCQSSCQSzJkzp1brTJo0ybDOjz/+2KDxETVVMlMHQEREREREt0kkkrsuX7x4MT788EMIIRopImNKpRJJSUmwsbGpVf8PP/wQK1euhLu7ewNHRtR0segmIiIiImoiMjMzDT9/++23WLRoEZKSkgxtCoUCCoXCFKEBuH1QwM3Nrdb9VSoVVCpVA0ZE1PTx8nIiIiIioibCzc3N8FCpVIYit+qhUCiqXV7er18/zJw5E3PmzIG9vT1cXV3xxRdfoLi4GJMnT4atrS3atGmD3bt3G71WYmIihg4dCoVCAVdXV0yYMAE3b96875g//fRTtG3bFpaWlnB1dcWYMWMeNA1EDxUW3UREREREzdyGDRvg5OSE48ePY+bMmZg+fTqefPJJ9OrVC/Hx8Rg0aBAmTJiAkpISAEBBQQEGDBiA0NBQnDx5Env27EF2djaeeuqp+3rdkydPYtasWVi6dCmSkpKwZ88e9O3btyHeIlGzxcvLiYiIiIiaueDgYPz73/8GACxYsAArV66Ek5MTnnvuOQDAokWLsGbNGpw5cwY9evTAxx9/jNDQUCxfvtywjXXr1sHLywuXLl1Cu3btavW6qampsLGxwbBhw2BrawsfHx+EhobW/xskasZ4ppuIiIiIqJnr3Lmz4WczMzM4OjoiKCjI0Obq6goAyMnJAQCcPn0a0dHRhnvEFQoFAgMDAQBXr16t9ev+7W9/g4+PD/z9/TFhwgRs3LjRcDadiG5j0U1ERERE1MyZm5sbPZdIJEZtVaOi6/V6AEBRUREef/xxJCQkGD0uX758X5eH29raIj4+Ht988w3c3d2xaNEiBAcHo6Cg4MHfFNFDgpeXExERERG1MF26dMEPP/wAX19fyGQPVhLIZDKEh4cjPDwcixcvhp2dHfbv34/Ro0fXU7REzRvPdBMRERERtTCRkZHIz8/HuHHjcOLECVy9ehW//PILJk+eDJ1OV+vt7NixA6tXr0ZCQgJSUlLwn//8B3q9HgEBAQ0YPVHzwqKbiIiIiKiF8fDwwOHDh6HT6TBo0CAEBQVhzpw5sLOzg1Ra+xLBzs4OW7duxYABA9C+fXusXbsW33zzDTp27NiA0RM1LxIhhDB1EERERERE1LRFRUVhzpw5dbpfWyKRYNu2bUbzixO1FDzTTUREREREtaJWq6FQKDB//vxa9X/xxRehUCgaOCqipo1nuomIiIiI6J4KCwuRnZ0N4PZl5U5OTvdcJycnBxqNBgDg7u4OGxubBo2RqCli0U1ERERERETUQHh5OREREREREVEDYdFNRERERERE1EBYdBMRERERERE1EBbdRERERERERA2ERTcRERERERFRA2HRTURERERERNRAWHQTERERERERNRAW3UREREREREQNhEU3ERERERERUQP5f0mfQqj6LhVgAAAAAElFTkSuQmCC", + "text/plain": [ + "
    " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import numpy as np\n", + "# We can check it works by propagating a bit\n", + "r,v = [], []\n", + "for _ in range(0, 10000, 100):\n", + " current_epoch = instance.local_time\n", + " r_t,v_t = my_sat.get_position_velocity(current_epoch)\n", + " r.append(r_t)\n", + " v.append(v_t)\n", + " instance.advance_time(time_to_advance=100.0, current_power_consumption_in_W=0.0)\n", + "\n", + "# Plot the orbit and velocity magnitude\n", + "fig, ax = plt.subplots(1, 2, figsize=(10, 5))\n", + "ax[0].plot([i[0] for i in r], [i[1] for i in r])\n", + "ax[0].set_xlabel(\"X [m]\")\n", + "ax[0].set_ylabel(\"Y [m]\")\n", + "ax[0].set_title(\"Orbit\")\n", + "ax[0].set_aspect(\"equal\", \"box\")\n", + "ax[1].plot([np.linalg.norm(i) for i in v])\n", + "ax[1].set_xlabel(\"Time [s]\")\n", + "ax[1].set_ylabel(\"Velocity [m/s]\")\n", + "ax[1].set_title(\"Velocity magnitude\")\n", + "fig.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "paseos", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/Orekit_example/orekit_propagator.py b/examples/Orekit_example/orekit_propagator.py new file mode 100644 index 00000000..5617b843 --- /dev/null +++ b/examples/Orekit_example/orekit_propagator.py @@ -0,0 +1,108 @@ +from org.orekit.orbits import KeplerianOrbit, PositionAngle +from org.orekit.time import AbsoluteDate +from org.orekit.utils import Constants +from org.orekit.frames import FramesFactory +from org.orekit.orbits import OrbitType +from org.orekit.propagation.numerical import NumericalPropagator +from org.hipparchus.ode.nonstiff import DormandPrince853Integrator +from org.orekit.propagation import SpacecraftState +from org.orekit.utils import IERSConventions +from org.orekit.forces.gravity.potential import GravityFieldFactory +from org.orekit.forces.gravity import HolmesFeatherstoneAttractionModel + +from orekit import JArray_double + + +class OrekitPropagator: + """This class serves as a wrapper to orekit. It initializes the orekit + virtual machine and provides a method to propagate a satellite orbit. + + It follows the example from the orekit documentation: + + https://gitlab.orekit.org/orekit-labs/python-wrapper/-/blob/master/examples/Propagation.ipynb + """ + + # Constants for the numerical propagator, see orekit docs for details + minStep = 0.0001 + maxstep = 1000.0 + initStep = 60.0 + positionTolerance = 1.0 + + def __init__(self, orbital_elements: list, epoch: AbsoluteDate, satellite_mass: float) -> None: + """Initialize the propagator. + + Args: + orbital_elements (list): List of orbital elements. + epoch (AbsoluteDate): Epoch of the orbit. + satellite_mass (float): Mass of the satellite. + """ + + # Inertial frame where the satellite is defined + inertialFrame = FramesFactory.getEME2000() + + # Unpack the orbital elements + a, e, i, omega, raan, lv = orbital_elements + + self.initialDate = epoch + + # Orbit construction as Keplerian + initialOrbit = KeplerianOrbit( + a, + e, + i, + omega, + raan, + lv, + PositionAngle.TRUE, + inertialFrame, + epoch, + Constants.WGS84_EARTH_MU, + ) + + # Set up the numerical propagator tolerance + tolerances = NumericalPropagator.tolerances( + self.positionTolerance, initialOrbit, initialOrbit.getType() + ) + + # Set up the numerical integrator + integrator = DormandPrince853Integrator( + self.minStep, + self.maxstep, + JArray_double.cast_( + tolerances[0] + ), # Double array of doubles needs to be casted in Python + JArray_double.cast_(tolerances[1]), + ) + integrator.setInitialStepSize(self.initStep) + + # Define the initial state of the spacecraft + satellite_mass = 100.0 # The models need a spacecraft mass, unit kg. + initialState = SpacecraftState(initialOrbit, satellite_mass) + + # Set up the numerical propagator + self.propagator_num = NumericalPropagator(integrator) + self.propagator_num.setOrbitType(OrbitType.CARTESIAN) + self.propagator_num.setInitialState(initialState) + + # Add the force models + gravityProvider = GravityFieldFactory.getNormalizedProvider(10, 10) + self.propagator_num.addForceModel( + HolmesFeatherstoneAttractionModel( + FramesFactory.getITRF(IERSConventions.IERS_2010, True), gravityProvider + ) + ) + + def eph(self, time_since_epoch_in_seconds: float): + """Get the position and velocity of the satellite at a given time since epoch. + + Args: + time_since_epoch_in_seconds (float): Time since epoch in seconds. + + Returns: + orekit SpacecraftState: The position and velocity of the satellite. + """ + state = self.propagator_num.propagate( + self.initialDate, self.initialDate.shiftedBy(time_since_epoch_in_seconds) + ) + + return state diff --git a/examples/Sentinel_2_example_notebook/Sentinel2_example_notebook.ipynb b/examples/Sentinel_2_example_notebook/Sentinel2_example_notebook.ipynb index 20103021..52a24775 100644 --- a/examples/Sentinel_2_example_notebook/Sentinel2_example_notebook.ipynb +++ b/examples/Sentinel_2_example_notebook/Sentinel2_example_notebook.ipynb @@ -25,7 +25,6 @@ "import os\n", "sys.path.insert(1, os.path.join(\"..\",\"..\"))\n", "import pykep as pk\n", - "import numpy as np\n", "import paseos\n", "from paseos import ActorBuilder, SpacecraftActor\n", "from utils import s2pix_detector, acquire_data\n", @@ -55,8 +54,12 @@ "metadata": {}, "outputs": [], "source": [ + "#Define today as pykep epoch (27-10-22)\n", + "#please, refer to https://esa.github.io/pykep/documentation/core.html#pykep.epoch\n", + "today = pk.epoch_from_string('2022-10-27 12:00:00.000')\n", + "\n", "# Define local actor\n", - "S2B = ActorBuilder.get_actor_scaffold(name=\"Sentinel2-B\", actor_type=SpacecraftActor, epoch=pk.epoch(0))" + "S2B = ActorBuilder.get_actor_scaffold(name=\"Sentinel2-B\", actor_type=SpacecraftActor, epoch=today)" ] }, { @@ -65,30 +68,7 @@ "metadata": {}, "source": [ "#### 1.a) - Add an orbit for S2B\n", - "\n", - "Since **S2B** is orbiting around Earth, let's define `earth` as `pykep.planet` object. \n", - "\n", - "Internally, paseos uses pykep.planet objects to model gravitational acceleration and Keplerian orbits." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30691b25", - "metadata": {}, - "outputs": [], - "source": [ - "# Define central body\n", - "earth = pk.planet.jpl_lp(\"earth\")" - ] - }, - { - "cell_type": "markdown", - "id": "013bf2e2", - "metadata": {}, - "source": [ - "To find realistic orbits for **S2B**, we can exploit `Two Line Elements (TLEs)` (Downloaded on 27-10-2022). This would allow finding their ephemerides at time = 27-10-2022 12:00:00.\n", - "Please, refer to [Two-line_element_set](https://en.wikipedia.org/wiki/Two-line_element_set) to know more about TLEs." + "We can make use of the [two-line element](https://en.wikipedia.org/wiki/Two-line_element_set) actor creation [inside PASEOS](https://github.com/aidotse/PASEOS/tree/allow_using_TLE#sgp4--two-line-element-tle) for set the orbit of the PASEOS actor (TLE Downloaded on 27-10-2022)." ] }, { @@ -98,38 +78,10 @@ "metadata": {}, "outputs": [], "source": [ - "#Define today as pykep epoch (27-10-22)\n", - "#please, refer to https://esa.github.io/pykep/documentation/core.html#pykep.epoch\n", - "today = pk.epoch_from_string('2022-10-27 12:00:00.000')\n", - "\n", "sentinel2B_line1 = \"1 42063U 17013A 22300.18652110 .00000099 00000+0 54271-4 0 9998\"\n", "sentinel2B_line2 = \"2 42063 98.5693 13.0364 0001083 104.3232 255.8080 14.30819357294601\"\n", - "sentinel2B = pk.planet.tle(sentinel2B_line1, sentinel2B_line2)\n", "\n", - "#Calculating S2B ephemerides.\n", - "sentinel2B_eph=sentinel2B.eph(today)\n" - ] - }, - { - "cell_type": "markdown", - "id": "8eab58f6", - "metadata": {}, - "source": [ - "Now we define the actor's orbit for **S2B**." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3567ebb8", - "metadata": {}, - "outputs": [], - "source": [ - "#Adding orbit around Earth based on previously calculated ephemerides\n", - "ActorBuilder.set_orbit(actor=S2B, \n", - " position=sentinel2B_eph[0], \n", - " velocity=sentinel2B_eph[1], \n", - " epoch=today, central_body=earth)" + "ActorBuilder.set_TLE(S2B, sentinel2B_line1, sentinel2B_line2)" ] }, { @@ -463,7 +415,9 @@ "source": [ "### 3.d) - Showing detected volcanic eruptions\n", "\n", - "The next plot will show an example of onboard coarse volcanic eruptions detection on some Sentinel-2 L1C tiles. The different eruptions will be surrounded a bounding box, and their coordinates will be printed to raise an alert." + "The next plot will show an example of onboard coarse volcanic eruptions detection on some Sentinel-2 L1C tiles. The different eruptions will be surrounded a bounding box, and their coordinates will be printed to raise an alert.\n", + "\n", + "The execution and rendering of the images may take a few minutes." ] }, { @@ -480,7 +434,9 @@ "cell_type": "code", "execution_count": null, "id": "85bbd3ac", - "metadata": {}, + "metadata": { + "scrolled": false + }, "outputs": [], "source": [ "fig, ax=plt.subplots(1,3,figsize=(10,4))\n", @@ -502,7 +458,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.10.6 ('paseos')", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, diff --git a/examples/Sentinel_2_example_notebook/utils.py b/examples/Sentinel_2_example_notebook/utils.py index f44db5e6..2b6d1956 100644 --- a/examples/Sentinel_2_example_notebook/utils.py +++ b/examples/Sentinel_2_example_notebook/utils.py @@ -96,12 +96,8 @@ def get_thresholds( alpha = np.logical_and( np.where(sentinel_img[:, :, 2] >= alpha_thr[2], 1, 0), np.logical_and( - np.where( - sentinel_img[:, :, 2] / sentinel_img[:, :, 1] >= alpha_thr[0], 1, 0 - ), - np.where( - sentinel_img[:, :, 2] / sentinel_img[:, :, 0] >= alpha_thr[1], 1, 0 - ), + np.where(sentinel_img[:, :, 2] / sentinel_img[:, :, 1] >= alpha_thr[0], 1, 0), + np.where(sentinel_img[:, :, 2] / sentinel_img[:, :, 0] >= alpha_thr[1], 1, 0), ), ) beta = np.logical_and( @@ -159,9 +155,7 @@ def get_alert_matrix_and_thresholds( numpy.array: gamma threshold map. """ - alpha, beta, S, gamma = get_thresholds( - sentinel_img, alpha_thr, beta_thr, S_thr, gamma_thr - ) + alpha, beta, S, gamma = get_thresholds(sentinel_img, alpha_thr, beta_thr, S_thr, gamma_thr) alert_matrix = np.logical_or(np.logical_or(np.logical_or(alpha, beta), gamma), S) return alert_matrix, alpha, beta, S, gamma diff --git a/examples/TID_model_custom_property/Modelling_TID_TNID_example_notebook.ipynb b/examples/TID_model_custom_property/Modelling_TID_TNID_example_notebook.ipynb new file mode 100644 index 00000000..b9ef5532 --- /dev/null +++ b/examples/TID_model_custom_property/Modelling_TID_TNID_example_notebook.ipynb @@ -0,0 +1,398 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "79556fc6", + "metadata": {}, + "source": [ + "# Model Total Ionization Dose (TID) example notebook\n", + "\n", + "This notebook showcases how to model **Total Ionization Dose (TID)** inside `PASEOS`. To this aim, we will use a **custom property** to model the accumulated dose and the consequent risk of failure.
    \n", + "We used [SPENVIS](https://www.spenvis.oma.be/) to estimate the total dose and the electron and protons fluxes over 30 mission days on the [Sentinel-2B](https://sentinel.esa.int/web/sentinel/missions/sentinel-2) sun-synchronous orbit. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33e7c0be", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "import sys \n", + "import os\n", + "sys.path.insert(1, os.path.join(\"..\",\"..\"))\n", + "import pykep as pk\n", + "import numpy as np\n", + "import paseos\n", + "from paseos import ActorBuilder, SpacecraftActor\n", + "from paseos.utils.load_default_cfg import load_default_cfg\n", + "import matplotlib.pyplot as plt\n" + ] + }, + { + "cell_type": "markdown", + "id": "3670f211", + "metadata": {}, + "source": [ + "# 1) - Instantiate a space actor\n", + "\n", + "We can now create a space actor **sat** and assign it to the **Sentinel-2B** sun-synchronous orbit with starting date at 27-10-2022, 12:00. For a more detailed discussion on actors, scaffolds, and how to retrieve the Sentinel-2B ephemerides, please check our [Sentinel-2B example notebook](https://github.com/aidotse/PASEOS/blob/main/examples/Sentinel_2_example_notebook/Sentinel2_example_notebook.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3567ebb8", + "metadata": {}, + "outputs": [], + "source": [ + "# Define central body\n", + "earth = pk.planet.jpl_lp(\"earth\")\n", + "\n", + "# Define local actor\n", + "sat = ActorBuilder.get_actor_scaffold(name=\"sat\", actor_type=SpacecraftActor, epoch=pk.epoch(0))\n", + "\n", + "#Define start_date as pykep epoch (27-10-22)\n", + "#please, refer to https://esa.github.io/pykep/documentation/core.html#pykep.epoch\n", + "start_date = pk.epoch_from_string('2022-10-27 12:00:00.000')\n", + "\n", + "#Adding orbit around Earth based on previously calculated ephemerides\n", + "ActorBuilder.set_orbit(actor=sat, \n", + " position=[-6912275.638799771, -1753638.1454079857, 734768.7737737056], \n", + " velocity=[-1015.9539197253205, 894.2090554272667, -7334.877725365646], \n", + " epoch=start_date, central_body=earth)" + ] + }, + { + "cell_type": "markdown", + "id": "a1f00fd5", + "metadata": {}, + "source": [ + "# 2) - Instantiate PASEOS simulation\n", + "\n", + "To instantiate `PASEOS`, we consider **sat** as `local_actor`. The initial time is set to `start_date`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14f3a552", + "metadata": {}, + "outputs": [], + "source": [ + "cfg=load_default_cfg() # loading cfg to modify defaults\n", + "cfg.sim.start_time=start_date.mjd2000 * pk.DAY2SEC # convert epoch to seconds\n", + "# update frequency of the constraint function. **N.B** this determines how often the fault probability will be checked.\n", + "cfg.sim.activity_timestep = 12 * 3600.0 \n", + "sim = paseos.init_sim(sat, cfg)" + ] + }, + { + "cell_type": "markdown", + "id": "7105d9ff", + "metadata": {}, + "source": [ + "# 3) - Modelling TID effects in PASEOS" + ] + }, + { + "cell_type": "markdown", + "id": "66a7bb54", + "metadata": {}, + "source": [ + "PASEOS does not offer a suit to calculate the total dose for different mission scenarios. However, through PASEOS you can easily model its effects once you have calculated the total dose by using other tools.
    \n", + "To this aim, we simulated 30 mission days starting from `start_date` by using [SPENVIS](https://www.spenvis.oma.be/). \n", + "In particular, we proceeded as follows: \n", + "1. We calculated the trapped protons and electrons fluxes by using respectively the `AP-8` (solar minimum condition) and `AE-8` (solar mximum condition) models with 1 cm**2/s threshold flux for esposure (default value). \n", + "\n", + "2. By using the extimated electrons and proton fluxes, we assumed 2.5mm spherical shielding wrapping the electronic device and calculated the total dose over the 30-days mission by using the SHIELDOSE-2 dose model (v.2.1.0). \n", + "\n", + "Results suggest that the mission total dose is of **3.247E+02 rad** (electrons and protons)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32017753", + "metadata": {}, + "outputs": [], + "source": [ + "dose_30_days_rad=3.247E+02" + ] + }, + { + "cell_type": "markdown", + "id": "ec6c3cec", + "metadata": {}, + "source": [ + "To update the dose over time, we first iterated the procedure above to calculate the total mission dose for different mission lengths (e.g, [1,2,4,8,16,30] days). We, then, used a linear interpolation to get the increment of the dose over time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f147b1b9", + "metadata": {}, + "outputs": [], + "source": [ + "mission_days=np.array([1,2,4,8,16,30])\n", + "mission_dose_days_rad=np.array([2.626E+01,3.850E+01,6.402E+01,1.153E+02,2.172E+02,3.247E+02])\n", + "dose_0_rad,dose_per_day_in_rad=np.linalg.lstsq(np.array([np.ones_like(mission_days), mission_days]).T, mission_dose_days_rad, rcond='warn')[0]\n", + "interp=dose_0_rad+dose_per_day_in_rad * mission_days\n", + "plt.figure(0)\n", + "plt.stem(mission_days, mission_dose_days_rad)\n", + "plt.plot(mission_days, interp, color=\"red\")\n", + "plt.xlabel(\"mission days\")\n", + "plt.ylabel(\"TID [rad]\")" + ] + }, + { + "cell_type": "markdown", + "id": "677eafc9", + "metadata": {}, + "source": [ + "To model TID in PASEOS, we can use custom properties. First of all, we define a property called `total_ionizing_dose`. We, then, create a dedicated update function `prop_update_fn` to compute the TID over time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6edda0b7", + "metadata": {}, + "outputs": [], + "source": [ + "# Add a custom property which tracks the TID\n", + "prop_name = \"total_ionizing_dose\"\n", + "# Define the update function\n", + "def prop_update_fn(actor, dt, power_consumption):\n", + " return actor.get_custom_property(prop_name) + dose_per_day_in_rad/(24 * 3600) * dt" + ] + }, + { + "cell_type": "markdown", + "id": "3bb1bcd8", + "metadata": {}, + "source": [ + "We can now add a custom property that models the `total_ionizing_dose`, which is updated as described by `prop_update_fn`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5032177b", + "metadata": {}, + "outputs": [], + "source": [ + "ActorBuilder.add_custom_property(actor=sat, property_name=prop_name, update_function=prop_update_fn, initial_value=dose_0_rad)" + ] + }, + { + "cell_type": "markdown", + "id": "d7892551", + "metadata": {}, + "source": [ + "# 4) - Calculate the probability of a failure due to the TID" + ] + }, + { + "cell_type": "markdown", + "id": "970f4e65", + "metadata": {}, + "source": [ + "According to the works [Wang, Jian-zhao et al.](https://www.sciencedirect.com/science/article/pii/S0026271422002712?casa_token=PbtCHwrwtKwAAAAA:hddUtlxhJM_CC-nUZBuxG2qZ7793olmDjTO7ZyztDxjjsteCl1fxvlh88SjEiRIeGVR430Po4EJW), [Xapsos, M. A., et al.](https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=7563310), the failure probability due to TID ($P_{failure}$) can be calculates as follows: \n", + "\n", + "
    $P_{failure}=\\int_{0}^{dose(t)} (1-H(x)) \\cdot g(x) \\cdot dx $\n", + "\n", + "where:\n", + "\n", + "- $g(x)$ is the Probability Density Function of the devices' failure doses.\n", + "- $H(x)$ is the cumulative distribution function of radiation dose from space during the mission period. " + ] + }, + { + "cell_type": "markdown", + "id": "af15c026", + "metadata": {}, + "source": [ + "To estimate $H(dose)$, we centered a lognormal distribution $h(dose)$ in the value of the dose [Wang, Jian-zhao et al.](https://www.sciencedirect.com/science/article/pii/S0026271422002712?casa_token=PbtCHwrwtKwAAAAA:hddUtlxhJM_CC-nUZBuxG2qZ7793olmDjTO7ZyztDxjjsteCl1fxvlh88SjEiRIeGVR430Po4EJW). Then, we calculated the cumulative distributed function $H(dose)$.
    To estimate the variability of the curve as defined in [Wang, Jian-zhao et al.](https://www.sciencedirect.com/science/article/pii/S0026271422002712?casa_token=PbtCHwrwtKwAAAAA:hddUtlxhJM_CC-nUZBuxG2qZ7793olmDjTO7ZyztDxjjsteCl1fxvlh88SjEiRIeGVR430Po4EJW), we used as sample the values reported in the sun-synchronous orbit for 100 mils reported in [Xapsos, M. A., et al.](https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=7563310)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec8df14a", + "metadata": {}, + "outputs": [], + "source": [ + "def get_lognormal_pdf(mean, std, domain):\n", + " #Calculate h(dose)\n", + " return (np.exp(-(np.log(domain) - mean)**2 / (2 * std**2))\n", + " / (domain * std * np.sqrt(2 * np.pi)))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95f0915a", + "metadata": {}, + "outputs": [], + "source": [ + "#Dose values\n", + "dose_span=np.linspace(1e-15, 1009*dose_30_days_rad, 20000)\n", + "\n", + "# h power density function\n", + "pdf_h=get_lognormal_pdf(np.log(dose_30_days_rad/2), np.log(dose_30_days_rad/100), dose_span)\n", + "\n", + "# Calculate the H cumulative distribution function\n", + "cdf_h=np.zeros_like(dose_span)\n", + "for n in range(len(dose_span)):\n", + " cdf_h[n]=np.trapz(pdf_h[:n], dose_span[:n])" + ] + }, + { + "cell_type": "markdown", + "id": "359f8c1b", + "metadata": {}, + "source": [ + "$g(dose)$ shall be specified by the user depending on the target component. We used a lognormal Probability Density Function centered on the average TID value.
    For this example, we use an unreasonably low TID value (i.e., 200 rad) to possibly experience a failure during the mission in a short time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8938176b", + "metadata": {}, + "outputs": [], + "source": [ + "TID_rad = 200\n", + "pdf_g=get_lognormal_pdf(np.log(TID_rad), np.log(TID_rad/100), dose_span)\n", + "\n", + "\n", + "plt.figure(2)\n", + "fig, ax=plt.subplots(1,2)\n", + "ax[0].semilogx(dose_span, pdf_g, color=\"red\")\n", + "ax[0].set_xlabel(\"TID [rad]\")\n", + "ax[0].set_ylabel(\"g(TID)\")\n", + "ax[1].semilogx(dose_span, cdf_h, color=\"blue\")\n", + "ax[1].set_xlabel(\"TID [rad]\")\n", + "ax[1].set_ylabel(\"H(TID)\")\n", + "fig.tight_layout()\n", + "plt.show()\n", + "\n", + "H=dict(zip(dose_span, cdf_h))\n", + "g= dict(zip(dose_span, pdf_g))" + ] + }, + { + "cell_type": "markdown", + "id": "120304f5", + "metadata": {}, + "source": [ + "We can now generate a [constraint function](https://github.com/aidotse/PASEOS#constraint-function) that checks if a failure event happens." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e21c5b2a", + "metadata": {}, + "outputs": [], + "source": [ + "#Seeding np.random\n", + "np.random.seed(26)\n", + "\n", + "def constraint_func_TID_failure():\n", + " dose=sat.get_custom_property(\"total_ionizing_dose\")\n", + " print(\"Time:\", sat.local_time, \"TID [rad]: \", dose)\n", + " \n", + " #Finding the closest domain value to the dose\n", + " dose_nearest_idx = (np.abs(dose_span - dose)).argmin()\n", + " \n", + " #Calculating the probability \n", + " y=np.zeros_like(dose_span)\n", + " \n", + " for n in range(dose_nearest_idx):\n", + " y[n]=(1 - H[dose_span[n]]) * g[dose_span[n]]\n", + " \n", + " p=np.trapz(y, dose_span)\n", + " \n", + " #Sampling uniform distribution probability \n", + " p_s = np.random.randint(1, 100000)\n", + " \n", + " \n", + " if (p_s < int(round(p*100000))):\n", + " print(\"------------------------------------------------\")\n", + " print(\"Detected fault due to TID!\")\n", + " print(\"Damage probability: \", p)\n", + " print(\"Time:\", sat.local_time)\n", + " return False\n", + " else:\n", + " return True" + ] + }, + { + "cell_type": "markdown", + "id": "fab5abf6", + "metadata": {}, + "source": [ + "# 5) - Let's test it out" + ] + }, + { + "cell_type": "markdown", + "id": "2e1b25a2", + "metadata": {}, + "source": [ + "Let's advance the time of 30 days and check if an interrupt happens." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1246e04f", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "sim.advance_time(3600 * 24 * 30, 10, constraint_function=constraint_func_TID_failure)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63d51303", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + }, + "vscode": { + "interpreter": { + "hash": "cec805858b69cacb2b7ad611a1d16c309b9d5c2fd3283013a8f0cd0423ba3fc5" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/paseos/__init__.py b/paseos/__init__.py index 5319f18e..e9025da1 100644 --- a/paseos/__init__.py +++ b/paseos/__init__.py @@ -9,9 +9,11 @@ from .actors.actor_builder import ActorBuilder from .actors.ground_station_actor import GroundstationActor from .actors.spacecraft_actor import SpacecraftActor +from .central_body.central_body import CentralBody from .communication.get_communication_window import get_communication_window from .communication.find_next_window import find_next_window from .power.power_device_type import PowerDeviceType +from .utils.reference_frame import ReferenceFrame from .utils.set_log_level import set_log_level from .visualization.plot import plot, PlotType @@ -21,9 +23,7 @@ logger.debug("Loaded module.") -def init_sim( - local_actor: BaseActor, cfg: DotMap = None, starting_epoch: pk.epoch = None -): +def init_sim(local_actor: BaseActor, cfg: DotMap = None, starting_epoch: pk.epoch = None): """Initializes PASEOS Args: @@ -58,6 +58,7 @@ def init_sim( __all__ = [ "ActorBuilder", "BaseActor", + "CentralBody", "find_next_window", "get_communication_window", "GroundstationActor", @@ -66,6 +67,7 @@ def init_sim( "plot", "PlotType", "PowerDeviceType", + "ReferenceFrame", "set_log_level", "SpacecraftActor", ] diff --git a/paseos/activities/activity_manager.py b/paseos/activities/activity_manager.py index 117e4b85..6be65b96 100644 --- a/paseos/activities/activity_manager.py +++ b/paseos/activities/activity_manager.py @@ -43,9 +43,7 @@ def remove_activity(self, name: str): name (str): Name of the activity. """ if name not in self._activities.keys(): - raise ValueError( - "Trying to remove non-existing activity with name: " + name - ) + raise ValueError("Trying to remove non-existing activity with name: " + name) else: del self._activities[name] diff --git a/paseos/activities/activity_processor.py b/paseos/activities/activity_processor.py index a9b07839..6e712e58 100644 --- a/paseos/activities/activity_processor.py +++ b/paseos/activities/activity_processor.py @@ -89,9 +89,7 @@ async def _update(self, elapsed_time: float): logger.debug(f"Time since last update: {elapsed_time}s") logger.trace(f"Applying time multiplier of {self._time_multiplier}") elapsed_time *= self._time_multiplier - self._paseos_instance.advance_time( - elapsed_time, self._power_consumption_in_watt - ) + self._paseos_instance.advance_time(elapsed_time, self._power_consumption_in_watt) async def _run(self): """Main processor loop. Will track time, update paseos and check constraints of the activity.""" diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index d6f3f595..1a535320 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -1,4 +1,7 @@ +from typing import Callable, Any + from loguru import logger +import numpy as np from dotmap import DotMap import pykep as pk from skyfield.api import wgs84 @@ -6,6 +9,7 @@ from .base_actor import BaseActor from .spacecraft_actor import SpacecraftActor from .ground_station_actor import GroundstationActor +from ..central_body.central_body import CentralBody from ..thermal.thermal_model import ThermalModel from ..power.power_device_type import PowerDeviceType from ..radiation.radiation_model import RadiationModel @@ -14,18 +18,19 @@ class ActorBuilder: """This class is used to construct actors.""" - def __new__(self): - if not hasattr(self, "instance"): - self.instance = super(ActorBuilder, self).__new__(self) + def __new__(cls): + if not hasattr(cls, "instance"): + cls.instance = super(ActorBuilder, cls).__new__(cls) else: logger.debug( "Tried to create another instance of ActorBuilder. Keeping original one..." ) - return self.instance + return cls.instance def __init__(self): logger.trace("Initializing ActorBuilder") + @staticmethod def get_actor_scaffold(name: str, actor_type: object, epoch: pk.epoch): """Initiates an actor with minimal properties. @@ -48,6 +53,7 @@ def get_actor_scaffold(name: str, actor_type: object, epoch: pk.epoch): return actor_type(name, epoch) + @staticmethod def set_ground_station_location( actor: GroundstationActor, latitude: float, @@ -79,6 +85,175 @@ def set_ground_station_location( ) actor._minimum_altitude_angle = minimum_altitude_angle + @staticmethod + def set_central_body( + actor: SpacecraftActor, + pykep_planet: pk.planet, + mesh: tuple = None, + radius: float = None, + rotation_declination: float = None, + rotation_right_ascension: float = None, + rotation_period: float = None, + ): + """Define the central body of the actor. This is the body the actor is orbiting around. + + If a mesh is provided, it will be used to compute visibility and eclipse checks. + Otherwise, a sphere with the provided radius will be used. One of the two has to be provided. + + Note the specification here will not affect the actor orbit. + For that, use set_orbit, set_TLE or set_custom_orbit. + + Args: + actor (SpacecraftActor): Actor to update. + pykep_planet (pk.planet): Central body as a pykep planet in heliocentric frame. + mesh (tuple): A tuple of vertices and triangles defining a mesh. + radius (float): Radius of the central body in meters. Only used if no mesh is provided. + rotation_declination (float): Declination of the rotation axis in degrees in the + central body's inertial frame. Rotation at current actor local time is presumed to be 0. + rotation_right_ascension (float): Right ascension of the rotation axis in degrees in + the central body's inertial frame. Rotation at current actor local time is presumed to be 0. + rotation_period (float): Rotation period in seconds. Rotation at current actor local time is presumed to be 0. + """ + assert isinstance( + actor, SpacecraftActor + ), "Central body only supported for SpacecraftActors" + + # Fuzzy type check for pykep planet + assert "pykep.planet" in str(type(pykep_planet)), "pykep_planet has to be a pykep planet." + assert mesh is not None or radius is not None, "Either mesh or radius has to be provided." + assert mesh is None or radius is None, "Either mesh or radius has to be provided, not both." + + # Check rotation parameters + if rotation_declination is not None: + assert ( + rotation_declination >= -90 and rotation_declination <= 90 + ), "Rotation declination has to be -90 <= dec <= 90" + if rotation_right_ascension is not None: + assert ( + rotation_right_ascension >= -180 and rotation_right_ascension <= 180 + ), "Rotation right ascension has to be -180 <= ra <= 180" + if rotation_period is not None: + assert rotation_period > 0, "Rotation period has to be > 0" + + # Check if rotation parameters are set + if ( + rotation_period is not None + or rotation_right_ascension is not None + or rotation_declination is not None + ): + assert ( + rotation_right_ascension is not None + ), "Rotation right ascension has to be set for rotation." + assert ( + rotation_declination is not None + ), "Rotation declination has to be set. for rotation." + assert rotation_period is not None, "Rotation period has to be set for rotation." + assert mesh is not None, "Radius cannot only be set for mesh-defined bodies." + + if mesh is not None: + # Check mesh + assert isinstance(mesh, tuple), "Mesh has to be a tuple." + assert len(mesh) == 2, "Mesh has to be a tuple of length 2." + assert isinstance(mesh[0], np.ndarray), "Mesh vertices have to be a numpy array." + assert isinstance(mesh[1], np.ndarray), "Mesh triangles have to be a numpy array." + assert len(mesh[0].shape) == 2, "Mesh vertices have to be a numpy array of shape (n,3)." + assert ( + len(mesh[1].shape) == 2 + ), "Mesh triangles have to be a numpy array of shape (n,3)." + + # Check if pykep planet is either orbiting the sun or is the sunitself + # by comparing mu values + assert np.isclose(pykep_planet.mu_central_body, 1.32712440018e20) or np.isclose( + pykep_planet.mu_self, 1.32712440018e20 + ), "Central body has to either be the sun or orbiting the sun." + + # Check if the actor already had a central body + if actor.has_central_body: + logger.warning( + "The actor already had a central body. Only one central body is supported. Overriding old body." + ) + + # Set central body + actor._central_body = CentralBody( + planet=pykep_planet, + initial_epoch=actor.local_time, + mesh=mesh, + encompassing_sphere_radius=radius, + rotation_declination=rotation_declination, + rotation_right_ascension=rotation_right_ascension, + rotation_period=rotation_period, + ) + + logger.debug(f"Added central body {pykep_planet} to actor {actor}") + + @staticmethod + def set_custom_orbit(actor: SpacecraftActor, propagator_func: Callable, epoch: pk.epoch): + """Define the orbit of the actor using a custom propagator function. + The custom function has to return position and velocity in meters + and meters per second respectively. The function will be called with the + current epoch as the only parameter. + + Args: + actor (SpacecraftActor): Actor to update. + propagator_func (Callable): Function to propagate the orbit. + epoch (pk.epoch): Current epoch. + """ + assert callable(propagator_func), "propagator_func has to be callable." + assert isinstance(epoch, pk.epoch), "epoch has to be a pykep epoch." + assert isinstance(actor, SpacecraftActor), "Orbit only supported for SpacecraftActors" + assert actor._orbital_parameters is None, "Actor already has an orbit." + assert np.isclose( + actor.local_time.mjd2000, epoch.mjd2000 + ), "The initial epoch has to match actor's local time." + actor._custom_orbit_propagator = propagator_func + + # Try evaluating position and velocity to check if the function works + try: + position, velocity = actor.get_position_velocity(epoch) + assert len(position) == 3, "Position has to be list of 3 floats." + assert all( + [isinstance(val, float) for val in position] + ), "Position has to be list of 3 floats." + assert len(velocity) == 3, "Velocity has to be list of 3 floats." + assert all( + [isinstance(val, float) for val in velocity] + ), "Velocity has to be list of 3 floats." + except Exception as e: + logger.error(f"Error evaluating custom orbit propagator function: {e}") + raise RuntimeError("Error evaluating custom orbit propagator function.") + + logger.debug(f"Added custom orbit propagator to actor {actor}") + + @staticmethod + def set_TLE( + actor: SpacecraftActor, + line1: str, + line2: str, + ): + """Define the orbit of the actor using a TLE. For more information on TLEs see + https://en.wikipedia.org/wiki/Two-line_element_set . + + TLEs can be obtained from https://www.space-track.org/ or https://celestrak.com/NORAD/elements/ + + Args: + actor (SpacecraftActor): Actor to update. + line1 (str): First line of the TLE. + line2 (str): Second line of the TLE. + + Raises: + RuntimeError: If the TLE could not be read. + """ + try: + actor._orbital_parameters = pk.planet.tle(line1, line2) + # TLE only works around Earth + ActorBuilder.set_central_body(actor, pk.planet.jpl_lp("earth"), radius=6371000) + except RuntimeError: + logger.error("Error reading TLE \n", line1, "\n", line2) + raise RuntimeError("Error reading TLE") + + logger.debug(f"Added TLE to actor {actor}") + + @staticmethod def set_orbit( actor: SpacecraftActor, position, @@ -95,13 +270,9 @@ def set_orbit( epoch (pk.epoch): Time of position / velocity. central_body (pk.planet): Central body around which the actor is orbiting as a pykep planet. """ - # TODO Add checks for sensibility of orbit + assert isinstance(actor, SpacecraftActor), "Orbit only supported for SpacecraftActors" - assert isinstance( - actor, SpacecraftActor - ), "Orbit only supported for SpacecraftActors" - - actor._central_body = central_body + ActorBuilder.set_central_body(actor, central_body, radius=central_body.radius) actor._orbital_parameters = pk.planet.keplerian( epoch, position, @@ -115,8 +286,9 @@ def set_orbit( logger.debug(f"Added orbit to actor {actor}") + @staticmethod def set_position(actor: BaseActor, position: list): - """Sets the actors position. Use this if you do not want the actor to have a keplerian orbit around a central body. + """Sets the actors position. Use this if you do *not* want the actor to have a keplerian orbit around a central body. Args: actor (BaseActor): Actor set the position on. @@ -133,6 +305,7 @@ def set_position(actor: BaseActor, position: list): actor._position = position logger.debug(f"Setting position {position} on actor {actor}") + @staticmethod def set_power_devices( actor: SpacecraftActor, battery_level_in_Ws: float, @@ -157,6 +330,11 @@ def set_power_devices( actor, SpacecraftActor ), "Power devices are only supported for SpacecraftActors" + # If solar panel, check if the actor has a central body + # to check eclipse + if power_device_type == PowerDeviceType.SolarPanel: + assert actor.has_central_body, "Solar panels require a central body to check eclipse." + # Check if the actor already had a power device if actor.has_power_model: logger.warning( @@ -182,6 +360,7 @@ def set_power_devices( + f"ChargingRate={charging_rate_in_W}W to actor {actor}" ) + @staticmethod def set_radiation_model( actor: SpacecraftActor, data_corruption_events_per_s: float, @@ -204,9 +383,7 @@ def set_radiation_model( actor, SpacecraftActor ), "Radiation models are only supported for SpacecraftActors" - assert ( - data_corruption_events_per_s >= 0 - ), "data_corruption_events_per_s cannot be negative." + assert data_corruption_events_per_s >= 0, "data_corruption_events_per_s cannot be negative." assert restart_events_per_s >= 0, "restart_events_per_s cannot be negative." assert failure_events_per_s >= 0, "failure_events_per_s cannot be negative." @@ -217,6 +394,7 @@ def set_radiation_model( ) logger.debug(f"Added radiation model to actor {actor}.") + @staticmethod def set_thermal_model( actor: SpacecraftActor, actor_mass: float, @@ -271,14 +449,11 @@ def set_thermal_model( assert actor_mass > 0, "Actor mass has to be positive." assert ( - 0 <= power_consumption_to_heat_ratio - and power_consumption_to_heat_ratio <= 1.0 + 0 <= power_consumption_to_heat_ratio and power_consumption_to_heat_ratio <= 1.0 ), "Heat ratio has to be 0 to 1." logger.trace("Checking actor thermal values for sensibility.") - assert ( - 0 <= actor_initial_temperature_in_K - ), "Actor initial temperature cannot be below 0K." + assert 0 <= actor_initial_temperature_in_K, "Actor initial temperature cannot be below 0K." assert ( 0 <= actor_sun_absorptance and actor_sun_absorptance <= 1.0 ), "Absorptance has to be 0 to 1." @@ -292,12 +467,8 @@ def set_thermal_model( logger.trace("Checking body thermal values for sensibility.") assert 0 < body_solar_irradiance, "Solar irradiance has to be > 0." - assert ( - 0 <= body_surface_temperature_in_K - ), "Body surface temperature cannot be below 0K." - assert ( - 0 <= body_emissivity and body_emissivity <= 1.0 - ), "Body emissivity has to be 0 to 1" + assert 0 <= body_surface_temperature_in_K, "Body surface temperature cannot be below 0K." + assert 0 <= body_emissivity and body_emissivity <= 1.0, "Body emissivity has to be 0 to 1" assert ( 0 <= body_reflectance and body_reflectance <= 1.0 ), "Body reflectance has to be 0 to 1" @@ -319,6 +490,7 @@ def set_thermal_model( power_consumption_to_heat_ratio=power_consumption_to_heat_ratio, ) + @staticmethod def add_comm_device(actor: BaseActor, device_name: str, bandwidth_in_kbps: float): """Creates a communication device. @@ -332,10 +504,65 @@ def add_comm_device(actor: BaseActor, device_name: str, bandwidth_in_kbps: float + device_name ) - actor._communication_devices[device_name] = DotMap( - bandwidth_in_kbps=bandwidth_in_kbps - ) + actor._communication_devices[device_name] = DotMap(bandwidth_in_kbps=bandwidth_in_kbps) - logger.debug( - f"Added comm device with bandwith={bandwidth_in_kbps} kbps to actor {actor}." - ) + logger.debug(f"Added comm device with bandwith={bandwidth_in_kbps} kbps to actor {actor}.") + + @staticmethod + def add_custom_property( + actor: BaseActor, property_name: str, initial_value: Any, update_function: Callable + ): + """Adds a custom property to the actor. This e.g. allows tracking any physical + the user would like to track. + + The update functions needs to take three parameters as input: the actor, + the time to advance the state / model and the current_power_consumption_in_W + and return the new value of the custom property. + The function will be called with (actor,0,0) to check correctness. + + Args: + actor (BaseActor): The actor to add the custom property to. + property_name (str): The name of the custom property. + initial_value (Any): The initial value of the custom property. + update_function (Callable): The function to update the custom property. + """ + if property_name in actor._custom_properties: + raise ValueError(f"Custom property '{property_name}' already exists for actor {actor}.") + + # Already adding property but will remove if the update function fails + actor._custom_properties[property_name] = initial_value + + # Check if the update function accepts the required parameters + try: + logger.trace(f"Checking update function for actor {actor} with time 0 and power 0.") + new_value = update_function(actor, 0, 0) + logger.debug( + f"Update function returned {new_value} for actor {actor} with time 0 and power 0." + ) + except TypeError as e: + logger.error(e) + # remove property if this failed + del actor._custom_properties[property_name] + raise TypeError( + "Update function must accept three parameters: actor, time_to_advance, current_power_consumption_in_W." + ) + + # Check that the update function returns a value of the same type as the initial value + if type(new_value) is not type(initial_value): + # remove property if this failed + del actor._custom_properties[property_name] + raise TypeError( + f"Update function must return a value of type {type(initial_value)} matching initial vaue." + ) + + # Check that the initial value is the same as the value returned by the update function with time 0 + if new_value != initial_value: + # remove property if this failed + del actor._custom_properties[property_name] + raise ValueError( + "Update function must return the existing value when called with unchanged time (dt = 0)." + ) + + actor._custom_properties_update_function[property_name] = update_function + + logger.debug(f"Added custom property '{property_name}' to actor {actor}.") diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index b305eb27..bae022cd 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -1,12 +1,13 @@ from abc import ABC +from typing import Callable, Any from loguru import logger import pykep as pk import numpy as np from dotmap import DotMap -from ..communication.is_in_line_of_sight import is_in_line_of_sight -from ..power.is_in_eclipse import is_in_eclipse +from ..central_body.is_in_line_of_sight import is_in_line_of_sight +from ..central_body.central_body import CentralBody class BaseActor(ABC): @@ -24,18 +25,25 @@ class BaseActor(ABC): # Orbital parameters of the actor, stored in a pykep planet object _orbital_parameters = None + # Custom function for orbital propagation + _custom_orbit_propagator = None + # Position if not defined by orbital parameters _position = None - # Is specified by user / paseos instance but required for line of sight computations - _central_body_sphere = None - # Central body this actor is orbiting _central_body = None # Communication links dictionary _communication_devices = DotMap(_dynamic=False) + # Tracks user-defined custom properties + _custom_properties = DotMap(_dynamic=False) + + # Tracks the update function of user-defined custom properties + # Which is updated in advance_time in paseos.py + _custom_properties_update_function = DotMap(_dynamic=False) + # Tracks the current activity _current_activity = None @@ -61,6 +69,67 @@ def __init__(self, name: str, epoch: pk.epoch) -> None: self._communication_devices = DotMap(_dynamic=False) + def get_custom_property(self, property_name: str) -> Any: + """Returns the value of the specified custom property. + + Args: + property_name (str): The name of the custom property. + + Returns: + Any: The value of the custom property. + """ + if property_name not in self._custom_properties: + raise ValueError(f"Custom property '{property_name}' does not exist for actor {self}.") + + return self._custom_properties[property_name] + + @property + def custom_properties(self): + """Returns a dictionary of custom properties for this actor.""" + return self._custom_properties.toDict() + + @property + def central_body(self) -> CentralBody: + """Returns the central body this actor is orbiting.""" + return self._central_body + + def set_custom_property(self, property_name: str, value: Any) -> None: + """Sets the value of the specified custom property. + + Args: + property_name (str): The name of the custom property. + value (Any): The value to set for the custom property. + """ + if property_name not in self._custom_properties: + raise ValueError(f"Custom property '{property_name}' does not exist for actor {self}.") + + self._custom_properties[property_name] = value + + logger.debug(f"Set custom property '{property_name}' to {value} for actor {self}.") + + def get_custom_property_update_function(self, property_name: str) -> Callable: + """Returns the update function for the specified custom property. + + Args: + property_name (str): The name of the custom property. + + Returns: + Callable: The update function for the custom property. + """ + if property_name not in self._custom_properties_update_function: + raise ValueError(f"Custom property '{property_name}' does not exist for actor {self}.") + + return self._custom_properties_update_function[property_name] + + @property + def has_central_body(self) -> bool: + """Returns true if actor has a central body, else false. + + Returns: + bool: bool indicating presence. + """ + return hasattr(self, "_central_body") and self._central_body is not None + @property def has_power_model(self) -> bool: """Returns true if actor's battery is modeled, else false. @@ -68,10 +137,7 @@ def has_power_model(self) -> bool: Returns: bool: bool indicating presence. """ - return ( - hasattr(self, "_battery_level_in_Ws") - and self._battery_level_in_Ws is not None - ) + return hasattr(self, "_battery_level_in_Ws") and self._battery_level_in_Ws is not None @property def has_radiation_model(self) -> bool: @@ -145,14 +211,6 @@ def _check_init_value_sensibility( def __str__(self): return self.name - def set_central_body_shape(self, sphere) -> None: - """Sets the central body of this actor. - - Args: - sphere (skspatial.Sphere): Sphere blocking line of sight. - """ - self._central_body_sphere = sphere - def set_time(self, t: pk.epoch): """Updates the local time of the actor. @@ -180,8 +238,7 @@ def discharge(self, consumption_rate_in_W: float, duration_in_s: float): """ pass - @property - def altitude( + def get_altitude( self, t0: pk.epoch = None, ) -> float: @@ -195,15 +252,10 @@ def altitude( """ if t0 is None: t0 = self._local_time - if ( - t0.mjd2000 == self._time_of_previous_position - and self._previous_altitude is not None - ): + if t0.mjd2000 == self._time_of_previous_position and self._previous_altitude is not None: return self._previous_altitude else: - self._previous_altitude = np.sqrt( - np.sum(np.power(self.get_position(t0), 2)) - ) + self._previous_altitude = np.sqrt(np.sum(np.power(self.get_position(t0), 2))) return self._previous_altitude def get_position(self, epoch: pk.epoch): @@ -216,24 +268,24 @@ def get_position(self, epoch: pk.epoch): np.array: [x,y,z] in meters """ logger.trace( - "Computing " - + self._orbital_parameters.name - + " position at time " - + str(epoch.mjd2000) - + " (mjd2000)." + "Computing " + self.name + " position at time " + str(epoch.mjd2000) + " (mjd2000)." ) - if self._orbital_parameters is not None and self._position is not None: + if ( + self._orbital_parameters is not None or self._custom_orbit_propagator is not None + ) and self._position is not None: raise ValueError( "Ambiguous position definition. Either set an orbit OR position with ActorBuilder." ) # If the actor has no orbit, return position - if self._orbital_parameters is None: + if self._orbital_parameters is None and self._custom_orbit_propagator is None: if self._position is not None: self._previous_position = self._position self._time_of_previous_position = epoch.mjd2000 return self._position + elif self._custom_orbit_propagator is not None: + return self._custom_orbit_propagator(epoch)[0] else: return self._orbital_parameters.eph(epoch)[0] @@ -250,19 +302,27 @@ def get_position_velocity(self, epoch: pk.epoch): Returns: np.array: [x,y,z] in meters """ - if self._orbital_parameters is None: + + if self._orbital_parameters is None and self._custom_orbit_propagator is None: raise NotImplementedError( "No suitable way added to determine actor velocity. Set an orbit with ActorBuilder." ) logger.trace( "Computing " - + self._orbital_parameters.name + + self.name + " position / velocity at time " + str(epoch.mjd2000) + " (mjd2000)." ) - pos, vel = self._orbital_parameters.eph(epoch) + + # Use either custom propagator or pykep to compute position / velocity + if self._custom_orbit_propagator is not None: + pos, vel = self._custom_orbit_propagator(epoch) + else: + pos, vel = self._orbital_parameters.eph(epoch) + + # Store the position / velocity for later use self._previous_position = pos self._previous_velocity = vel self._time_of_previous_position = epoch.mjd2000 @@ -288,9 +348,7 @@ def is_in_line_of_sight( Returns: bool: true if in line-of-sight. """ - return is_in_line_of_sight( - self, other_actor, epoch, minimum_altitude_angle, plot - ) + return is_in_line_of_sight(self, other_actor, epoch, minimum_altitude_angle, plot) def is_in_eclipse(self, t: pk.epoch = None): """Checks if the actors is in eclipse at the specified time. @@ -303,6 +361,6 @@ def is_in_eclipse(self, t: pk.epoch = None): if t.mjd2000 == self._time_of_previous_eclipse_status: return self._previous_eclipse_status else: - self._previous_eclipse_status = is_in_eclipse(self, self._central_body, t) + self._previous_eclipse_status = self._central_body.blocks_sun(self, t) self._time_of_last_eclipse_status = t.mjd2000 return self._previous_eclipse_status diff --git a/paseos/central_body/central_body.py b/paseos/central_body/central_body.py new file mode 100644 index 00000000..d844525f --- /dev/null +++ b/paseos/central_body/central_body.py @@ -0,0 +1,212 @@ +"""This file serves to collect functionality related to central bodies.""" + +from math import radians, pi + +from loguru import logger +from pyquaternion import Quaternion +from skspatial.objects import Sphere +import pykep as pk +import numpy as np + +from paseos.central_body.sphere_between_points import sphere_between_points +from paseos.central_body.mesh_between_points import mesh_between_points +from paseos.utils.reference_frame import ReferenceFrame + + +class CentralBody: + """Class representing a central body. This can be the Earth + but also any other user-defined body in the solar system.""" + + # A mesh of the body, used for visibility checks if provided + _mesh = None + + # A sphere encompassing the body, used for visibility checks if provided + # and no mesh is provided + _encompassing_sphere = None + + # The planet object from pykep, will be used to compute + # heliocentric positions + _planet = None + + # Rotation parameters (optional) + _rotation_axis = None + _rotation_angular_velocity = None + + def __init__( + self, + planet: pk.planet, + initial_epoch: pk.epoch, + mesh: tuple = None, + encompassing_sphere_radius: float = None, + rotation_declination: float = None, + rotation_right_ascension: float = None, + rotation_period: float = None, + ): + """Initializes a central body + + Args: + planet (pk.planet): The planet object from pykep. + initial_epoch (pk.epoch): The initial epoch of the simulation (important rotation computation). + mesh (tuple, optional): A tuple containing the vertices and faces of the mesh. Defaults to None. + encompassing_sphere_radius (float, optional): The radius of the encompassing sphere. Defaults to None. + rotation_declination (float, optional): The declination of the rotation axis. Defaults to None. + rotation_right_ascension (float, optional): The right ascension of the rotation axis. Defaults to None. + rotation_period (float, optional): The rotation period of the body. Defaults to None. + """ + + self._planet = planet + self._initial_epoch = initial_epoch + self._mesh = mesh + if encompassing_sphere_radius is not None: + self._encompassing_sphere = Sphere([0, 0, 0], encompassing_sphere_radius) + if ( + rotation_declination is not None + and rotation_right_ascension is not None + and rotation_period is not None + ): + if mesh is None or encompassing_sphere_radius is not None: + logger.warning( + "You provided rotation parameters but no mesh. This will result in a non-rotating body." + ) + + # Convert to rad + rotation_declination = radians(rotation_declination) + rotation_right_ascension = radians(rotation_right_ascension) + # Define the rotation axis as a unit vector + self._rotation_axis = np.array( + [ + np.cos(rotation_declination) * np.cos(rotation_right_ascension), + np.cos(rotation_declination) * np.sin(rotation_right_ascension), + np.sin(rotation_declination), + ] + ) + + self._rotation_angular_velocity = 2.0 * pi / rotation_period + + @property + def planet(self): + return self._planet + + def blocks_sun(self, actor, t: pk.epoch, plot=False) -> bool: + """Checks whether the central body blocks the sun for the given actor. + + Args: + actor (SpacecraftActor): The actor to check + t (pk.epoch): Epoch at which to check + plot (bool): Whether to plot a diagram illustrating the positions. + + Returns: + bool: True if the central body blocks the sun + """ + logger.debug(f"Checking whether {actor} is in eclipse at {t}.") + + # Compute central body position in solar reference frame + r_central_body_heliocentric, _ = np.array(self._planet.eph(t)) + logger.trace("r_central_body_heliocentric is" + str(r_central_body_heliocentric)) + + # Compute satellite / actor position in solar reference frame + r_sat_central_body_frame = np.array(actor.get_position(t)) + logger.trace("r_sat_central_body_frame is" + str(r_sat_central_body_frame)) + r_sat_heliocentric = r_central_body_heliocentric + r_sat_central_body_frame + logger.trace("r_sat_heliocentric is" + str(r_sat_heliocentric)) + + return self.is_between_points( + [0, 0, 0], r_sat_heliocentric, t, ReferenceFrame.Heliocentric, plot + ) + + def is_between_actors(self, actor_1, actor_2, t: pk.epoch, plot=False) -> bool: + """Checks whether the central body is between the two actors. + + Args: + actor_1 (SpacecraftActor): First actor + actor_2 (SpacecraftActor): Second actor + t (pk.epoch): Epoch at which to check + plot (bool): Whether to plot a diagram illustrating the positions. + + Returns: + bool: True if the central body is between the two actors + """ + logger.debug("Computing line of sight between actors: " + str(actor_1) + " " + str(actor_2)) + pos_1 = actor_1.get_position_velocity(t) + pos_2 = actor_2.get_position_velocity(t) + + return self.is_between_points(pos_1[0], pos_2[0], t, plot) + + def is_between_points( + self, + point_1, + point_2, + t: pk.epoch, + reference_frame: ReferenceFrame = ReferenceFrame.CentralBodyInertial, + plot: bool = False, + ) -> bool: + """Checks whether the central body is between the two points. + + Args: + point_1 (np.array): First point + point_2 (np.array): Second point + t (pk.epoch): Epoch at which to check + reference_frame (ReferenceFrame, optional): Reference frame of the points. + Defaults to ReferenceFrame.CentralBodyInertial. + plot (bool): Whether to plot a diagram illustrating the positions. + + Returns: + bool: True if the central body is between the two points + """ + logger.debug("Computing line of sight between points: " + str(point_1) + " " + str(point_2)) + + point_1 = np.array(point_1) + point_2 = np.array(point_2) + + # Convert to CentralBodyInertial reference frame () + if reference_frame == ReferenceFrame.Heliocentric: + point_1 = point_1 - np.array(self._planet.eph(t)[0]) + point_2 = point_2 - np.array(self._planet.eph(t)[0]) + + if self._encompassing_sphere is not None: + return sphere_between_points( + point_1=point_1, + point_2=point_2, + sphere=self._encompassing_sphere, + plot=plot, + ) + elif self._mesh is not None: + # Apply rotation if specified + if self._rotation_axis is not None: + # We rotate the points to the central body's rotated frame + point_1 = self._apply_rotation(point=point_1, epoch=t) + point_2 = self._apply_rotation(point=point_2, epoch=t) + return mesh_between_points( + point_1=point_1, + point_2=point_2, + mesh_vertices=self._mesh[0], + mesh_triangles=self._mesh[1], + ) + else: + logger.error("No mesh or encompassing sphere provided. Cannot check visibility.") + raise ValueError("No mesh or encompassing sphere provided. Cannot check visibility.") + + def _apply_rotation(self, point, epoch: pk.epoch): + """Applies the inverse rotation of the central body to the given point. This way + the point will be in the central body's rotated frame. Avoids having to rotate + the central body's mesh. + + Args: + point (np.array): Point to rotate + epoch (pk.epoch): Epoch at which to rotate + + Returns: + np.array: Rotated point + """ + + # Compute rotation angle + angle = ( + (epoch.mjd2000 - self._initial_epoch.mjd2000) + * pk.DAY2SEC + * self._rotation_angular_velocity + * -1.0 # Inverse rotation + ) + + # Rotate point + q = Quaternion(axis=self._rotation_axis, angle=angle) + return q.rotate(point) diff --git a/paseos/communication/is_in_line_of_sight.py b/paseos/central_body/is_in_line_of_sight.py similarity index 72% rename from paseos/communication/is_in_line_of_sight.py rename to paseos/central_body/is_in_line_of_sight.py index 95a4b1c6..e66230bd 100644 --- a/paseos/communication/is_in_line_of_sight.py +++ b/paseos/central_body/is_in_line_of_sight.py @@ -2,15 +2,11 @@ import pykep as pk import os import numpy as np -from skspatial.objects import Line from skyfield.units import AU_M from skyfield.api import load from skyfield.vectorlib import VectorFunction - -_SKYFIELD_EARTH_PATH = os.path.join( - os.path.dirname(__file__) + "/../resources/", "de421.bsp" -) +_SKYFIELD_EARTH_PATH = os.path.join(os.path.dirname(__file__) + "/../resources/", "de421.bsp") # Skyfield Earth, in the future we may not always want to load this. _SKYFIELD_EARTH = load(_SKYFIELD_EARTH_PATH)["earth"] @@ -37,9 +33,7 @@ def _at(self, t): return self.r, v, self.center, "SkyfieldSkyCoordinate" -def _is_in_line_of_sight_spacecraft_to_spacecraft( - actor, other_actor, epoch: pk.epoch, plot=False -): +def _is_in_line_of_sight_spacecraft_to_spacecraft(actor, other_actor, epoch: pk.epoch, plot=False): """Determines whether a position is in line of sight of this actor Args: @@ -51,48 +45,11 @@ def _is_in_line_of_sight_spacecraft_to_spacecraft( Returns: bool: true if in line-of-sight. """ - logger.debug( - "Computing line of sight between actors: " + str(actor) + " " + str(other_actor) - ) - my_pos, _ = actor.get_position_velocity(epoch) - other_actor_pos, _ = other_actor.get_position_velocity(epoch) - - logger.trace( - "Computed positions for actors are " - + str(my_pos) - + " and " - + str(other_actor_pos) - ) - line_between_actors = Line( - my_pos, - [ - other_actor_pos[0] - my_pos[0], - other_actor_pos[1] - my_pos[1], - other_actor_pos[2] - my_pos[2], - ], - ) - if plot: - from skspatial.plotting import plot_3d - - # Currently skspatial throws a ValueError if there is no intersection so we have to use this rather ugly way. - try: - p1, p2 = actor._central_body_sphere.intersect_line(line_between_actors) - logger.trace("Intersections observed at " + str(p1) + " and " + str(p2)) - if plot: - plot_3d( - line_between_actors.plotter(t_1=0, t_2=1, c="k"), - actor._central_body_sphere.plotter(alpha=0.2), - p1.plotter(c="r", s=100), - p2.plotter(c="r", s=100), - ) - except ValueError: - if plot: - plot_3d( - line_between_actors.plotter(t_1=0, t_2=1, c="k"), - actor._central_body_sphere.plotter(alpha=0.2), - ) - return True - return False + # Check actor has central body + assert ( + actor.central_body is not None + ), f"Please set the central body on actor {actor} for line of sight computations." + return not actor.central_body.is_between_actors(actor, other_actor, epoch, plot) def _is_in_line_of_sight_ground_station_to_spacecraft( @@ -121,10 +78,7 @@ def _is_in_line_of_sight_ground_station_to_spacecraft( ), "0 < Minimum altitude angle < 90" logger.debug( - "Computing line of sight between actors: " - + str(ground_station) - + " " - + str(spacecraft) + "Computing line of sight between actors: " + str(ground_station) + " " + str(spacecraft) ) # Converting time to skyfield to use its API @@ -195,16 +149,16 @@ def is_in_line_of_sight( """ # Can't import types given circular import then, thus check with names + # Delegate call to correct function, ground stations are done with skyfield + # and only work with Earth as central body for now. if ( type(actor).__name__ == "SpacecraftActor" and type(other_actor).__name__ == "SpacecraftActor" ): assert ( - actor._central_body_sphere is not None - ), f"Please set the central sphere on actor {actor} for line of sight computations." - return _is_in_line_of_sight_spacecraft_to_spacecraft( - actor, other_actor, epoch, plot - ) + actor.central_body is not None + ), f"Please set the central body on actor {actor} for line of sight computations." + return _is_in_line_of_sight_spacecraft_to_spacecraft(actor, other_actor, epoch, plot) elif ( type(actor).__name__ == "GroundstationActor" and type(other_actor).__name__ == "SpacecraftActor" @@ -212,8 +166,8 @@ def is_in_line_of_sight( if minimum_altitude_angle is None: minimum_altitude_angle = actor._minimum_altitude_angle assert ( - other_actor._central_body_sphere is not None - ), f"Please set the central sphere on actor {other_actor} for line of sight computations." + other_actor.central_body.planet.name.lower() == "earth" + ), f"Ground stations can only be used with Earth for now (not {other_actor.central_body.planet.name})." return _is_in_line_of_sight_ground_station_to_spacecraft( actor, other_actor, epoch, minimum_altitude_angle, plot ) @@ -223,9 +177,9 @@ def is_in_line_of_sight( ): if minimum_altitude_angle is None: minimum_altitude_angle = other_actor._minimum_altitude_angle - assert ( - actor._central_body_sphere is not None - ), f"Please set the central sphere on actor {actor} for line of sight computations." + assert actor.central_body is not None, ( + other_actor.central_body.planet.name.lower() == "earth" + ) return _is_in_line_of_sight_ground_station_to_spacecraft( other_actor, actor, epoch, minimum_altitude_angle, plot ) diff --git a/paseos/central_body/mesh_between_points.py b/paseos/central_body/mesh_between_points.py new file mode 100644 index 00000000..157404f5 --- /dev/null +++ b/paseos/central_body/mesh_between_points.py @@ -0,0 +1,89 @@ +import numpy as np +from loguru import logger + + +def mesh_between_points( + point_1: np.array, point_2: np.array, mesh_vertices: np.array, mesh_triangles: np.array +) -> bool: + """Checks whether the mesh is between the two points using ray-triangle + intersection with Möller-Trumbore algorithm. + + Args: + point_1 (np.array): First point + point_2 (np.array): Second point + mesh_vertices (np.array): Vertices of the mesh + mesh_triangles (np.array): Triangles of the mesh + + Returns: + bool: True if the mesh is between the two points + """ + logger.trace("Computing if mesh lies between points: " + str(point_1) + " " + str(point_2)) + + # Compute line between points + direction = point_2 - point_1 + direction = direction / np.linalg.norm(direction) + + intersect_point = None + + # Iterate over triangles to find any intersection + for t in mesh_triangles: + intersect, intersect_t = _rays_triangle_intersect( + point_1, direction, mesh_vertices[t[0]], mesh_vertices[t[1]], mesh_vertices[t[2]] + ) + # If / When found break the loop + if intersect: + intersect_point = point_1 + intersect_t * direction + break + + # Check that the computed intersection is actually on the linesegment not infinite line + if intersect_point is None: + return False + # True if intersection and between the points, otherwise not on the line segment + return np.linalg.norm(intersect_point - point_1) < np.linalg.norm(point_2 - point_1) + + +def _rays_triangle_intersect(ray_o, ray_d, v0, v1, v2): + """Möller-Trumbore intersection algorithm (vectorized). + + Computes whether a ray intersects a triangle. + + Taken from https://github.com/gomezzz/geodesyNets/blob/master/gravann/util/_hulls.py + + Args: + ray_o (3D np.array): origin of the ray. + ray_d (3D np.array): direction of the ray. + v0, v1, v2 (3D np.array): triangle vertices + + Returns: + boolean value if the intersection exist (includes the edges) and the t value + of the intersection. (0 if no intersection) + + See: https://en.wikipedia.org/wiki/M%C3%B6ller%E2%80%93Trumbore_intersection_algorithm + """ + if ray_o.shape != (3,): + raise ValueError("Shape f ray_o input should be (3,)") + edge1 = v1 - v0 + edge2 = v2 - v0 + h = np.cross(ray_d, edge2) + + a = np.dot(edge1, h) + + if a < 0.000001 and a > -0.000001: + return False, 0 + + f = 1.0 / a + s = ray_o - v0 + u = np.dot(s, h) * f + + if u < 0 or u > 1: + return False, 0 + + q = np.cross(s, edge1) + v = np.dot(ray_d, q) * f + + if v < 0 or u + v > 1: + return False, 0 + + t = f * np.dot(edge2, q) + + return t > 0, t diff --git a/paseos/central_body/sphere_between_points.py b/paseos/central_body/sphere_between_points.py new file mode 100644 index 00000000..86497333 --- /dev/null +++ b/paseos/central_body/sphere_between_points.py @@ -0,0 +1,66 @@ +from skspatial.objects import Sphere, LineSegment, Line, Point +from loguru import logger + + +def sphere_between_points(point_1, point_2, sphere: Sphere, plot=False) -> bool: + """Determines whether a sphere is between two points. + + Args: + point_1 (np.array): First point + point_2 (np.array): Second point + sphere (Sphere): Sphere to check + plot (bool): Whether to plot a diagram illustrating the positions. + + Returns: + bool: true if blocking line-of-sight. + """ + # Compute line between points + line_between_points = Line( + point=point_1, + direction=point_2 - point_1, + ) + + # Specify line segment to see if intersections are on this segment + linesegment_between_points = LineSegment( + point_1, + point_2, + ) + + # Currently skspatial throws a ValueError if there is no intersection + # so we have to use this rather ugly way. + try: + p1, p2 = sphere.intersect_line(line_between_points) + logger.trace("Intersections observed at " + str(p1) + " and " + str(p2)) + if plot: + from skspatial.plotting import plot_3d + + sat_point = Point(point_2) + plot_3d( + line_between_points.plotter(t_1=0, t_2=0.001, c="k"), + sphere.plotter(alpha=0.4), + sat_point.plotter(c="b", s=100), + p1.plotter(c="r", s=100), + p2.plotter(c="r", s=100), + ) + except ValueError: + logger.trace("No intersections observed.") + if plot: + from skspatial.plotting import plot_3d + + sat_point = Point(point_2) + plot_3d( + line_between_points.plotter(c="k"), + sphere.plotter(alpha=0.4), + sat_point.plotter(c="b", s=100), + ) + # No intersection + return False + # Check that the computed intersection is actually on the linesegment not infinite line + if linesegment_between_points.contains_point(p1): + logger.trace(f"p1={p1} is on the line between the intersection points.") + return True + elif linesegment_between_points.contains_point(p2): + logger.trace(f"p2={p2} is on the line between the intersection points.") + return True + else: + return False diff --git a/paseos/communication/find_next_window.py b/paseos/communication/find_next_window.py index e27b6ce7..1a5ed688 100644 --- a/paseos/communication/find_next_window.py +++ b/paseos/communication/find_next_window.py @@ -32,9 +32,7 @@ def find_next_window( "Trying to use a not-existing communication link with the name: " + local_actor.communication_devices ) - local_actor_comm_link = local_actor.communication_devices[ - local_actor_communication_link_name - ] + local_actor_comm_link = local_actor.communication_devices[local_actor_communication_link_name] assert local_actor_comm_link.bandwidth_in_kbps > 0, "Bandiwidth has to be positive." assert search_step_size > 0, "dt has to be positive." diff --git a/paseos/communication/get_communication_window.py b/paseos/communication/get_communication_window.py index d55be37b..dc7ed768 100644 --- a/paseos/communication/get_communication_window.py +++ b/paseos/communication/get_communication_window.py @@ -41,33 +41,30 @@ def get_communication_window( "Trying to use a not-existing communication link with the name: " + local_actor.communication_devices ) - local_actor_comm_link = local_actor.communication_devices[ - local_actor_communication_link_name - ] + local_actor_comm_link = local_actor.communication_devices[local_actor_communication_link_name] assert local_actor_comm_link.bandwidth_in_kbps > 0, "Bandiwidth has to be positive." assert dt > 0, "dt has to be positive." assert data_to_send_in_b > 0, "data_to_send_in_b has to be positive." # Getting t0 in s - t0_in_s = (t0.mjd2000 - pk.epoch(0).mjd2000) / pk.SEC2DAY transmitted_data_in_b = 0 - current_time_in_s = t0_in_s + current_epoch = t0 + window_length_in_s = 0 while ( - local_actor.is_in_line_of_sight( - target_actor, pk.epoch(current_time_in_s * pk.SEC2DAY) - ) - ) and (current_time_in_s - t0_in_s < window_timeout_value_in_s): - current_time_in_s += dt - transmitted_data_in_b += int( - local_actor_comm_link.bandwidth_in_kbps * dt * 1000 - ) # (This is the quantum of information that you can transmit) + local_actor.is_in_line_of_sight(target_actor, current_epoch) + and window_length_in_s < window_timeout_value_in_s + ): + window_length_in_s += dt + current_epoch = pk.epoch(t0.mjd2000 + window_length_in_s * pk.SEC2DAY) + # (This is the quantum of information that you can transmit) + transmitted_data_in_b += int(local_actor_comm_link.bandwidth_in_kbps * dt * 1000) - if current_time_in_s - t0_in_s >= window_timeout_value_in_s: + if window_length_in_s >= window_timeout_value_in_s: logger.debug("Timeout reached for the estimation of the communication window.") return ( t0, - pk.epoch(current_time_in_s * pk.SEC2DAY), + current_epoch, min(transmitted_data_in_b, data_to_send_in_b), ) diff --git a/paseos/paseos.py b/paseos/paseos.py index 11222b3e..94661f00 100644 --- a/paseos/paseos.py +++ b/paseos/paseos.py @@ -5,7 +5,6 @@ from dotmap import DotMap from loguru import logger import pykep as pk -from skspatial.objects import Sphere from paseos.actors.base_actor import BaseActor from paseos.activities.activity_manager import ActivityManager @@ -28,9 +27,6 @@ class PASEOS: # The actor of the device this is running on _local_actor = None - # TODO replace this in the future depending on central body - _central_body_sphere = None - # Handles registered activities _activity_manager = None @@ -45,24 +41,22 @@ class PASEOS: _time_since_previous_log = sys.float_info.max - def __init__(self, local_actor: BaseActor, cfg=None): + def __init__(self, local_actor: BaseActor, cfg): """Initalize PASEOS Args: local_actor (BaseActor): local actor. - cfg (DotMap, optional): simulation configuration. Defaults to None. + cfg (DotMap): simulation configuration. """ logger.trace("Initializing PASEOS") self._cfg = cfg - self._central_body_sphere = Sphere([0, 0, 0], cfg.comm.central_body_LOS_radius) self._state = DotMap(_dynamic=False) self._state.time = self._cfg.sim.start_time self._known_actors = {} self._local_actor = local_actor # Update local actor time to simulation start time. self.local_actor.set_time(pk.epoch(self._cfg.sim.start_time * pk.SEC2DAY)) - # Set line of sight blocking sphere - self.local_actor.set_central_body_shape(self._central_body_sphere) + self._activity_manager = ActivityManager( self, self._cfg.sim.activity_timestep, self._cfg.sim.time_multiplier ) @@ -110,9 +104,7 @@ def advance_time( self._is_advancing_time = True assert time_to_advance > 0, "Time to advance has to be positive." - assert ( - current_power_consumption_in_W >= 0 - ), "Power consumption cannot be negative." + assert current_power_consumption_in_W >= 0, "Power consumption cannot be negative." # Check constraint function returns something if constraint_function is not None: @@ -150,14 +142,10 @@ def advance_time( # check for device and / or activity failure if self.local_actor.has_radiation_model: if self.local_actor.is_dead: - logger.warning( - f"Tried to advance time on dead actor {self.local_actor}." - ) + logger.warning(f"Tried to advance time on dead actor {self.local_actor}.") return max(target_time - self._state.time, 0) if self.local_actor._radiation_model.did_device_restart(dt): - logger.info( - f"Actor {self.local_actor} interrupted during advance_time." - ) + logger.info(f"Actor {self.local_actor} interrupted during advance_time.") self.local_actor.set_was_interrupted() return max(target_time - self._state.time, 0) if self.local_actor._radiation_model.did_device_experience_failure(dt): @@ -179,6 +167,14 @@ def advance_time( if self.local_actor.has_power_model: self.local_actor.discharge(current_power_consumption_in_W, dt) + # Update user-defined properties in the actor + for property_name in self.local_actor.custom_properties.keys(): + update_function = self.local_actor.get_custom_property_update_function( + property_name + ) + new_value = update_function(self.local_actor, dt, current_power_consumption_in_W) + self.local_actor.set_custom_property(property_name, new_value) + self._state.time += dt time_since_constraint_check += dt self.local_actor.set_time(pk.epoch(self._state.time * pk.SEC2DAY)) @@ -204,9 +200,7 @@ def add_known_actor(self, actor: BaseActor): logger.debug("Current actors: " + str(self._known_actors.keys())) # Check for duplicate actors by name if actor.name in self._known_actors.keys(): - raise ValueError( - "Trying to add already existing actor with name: " + actor.name - ) + raise ValueError("Trying to add already existing actor with name: " + actor.name) # Else add self._known_actors[actor.name] = actor @@ -402,15 +396,6 @@ def perform_activity( constraint_func_args=constraint_func_args, ) - def set_central_body(self, planet: pk.planet): - """Sets the central body of the simulation for the orbit simulation - - Args: - planet (pk.planet): The central body as a pykep planet - """ - logger.debug("Setting central body to " + planet) - self._state.central_body = planet - def get_cfg(self) -> DotMap: """Returns the current cfg of the simulation diff --git a/paseos/power/charge_model.py b/paseos/power/charge_model.py index 78d98a78..41088dd0 100644 --- a/paseos/power/charge_model.py +++ b/paseos/power/charge_model.py @@ -3,7 +3,6 @@ import pykep as pk from paseos.power.power_device_type import PowerDeviceType -from paseos.power.is_in_eclipse import is_in_eclipse def charge( @@ -37,18 +36,14 @@ def charge( # If solar panels are used, check for eclipse if actor.power_device_type == PowerDeviceType.SolarPanel: # Check for eclipse at start / end - if is_in_eclipse( - actor, central_body=actor._central_body, t=actor.local_time - ) or is_in_eclipse(actor, central_body=actor._central_body, t=t1): + if actor.is_in_eclipse() or actor.is_in_eclipse(t1): logger.debug("Actor is in eclipse, not charging.") return actor # Apply specified charging model if model == "simple": actor._battery_level_in_Ws += actor._charging_rate_in_W * charging_time_in_s - actor._battery_level_in_Ws = min( - actor.battery_level_in_Ws, actor._max_battery_level_in_Ws - ) + actor._battery_level_in_Ws = min(actor.battery_level_in_Ws, actor._max_battery_level_in_Ws) return actor else: raise NotImplementedError("Unknown charging model " + model) diff --git a/paseos/power/is_in_eclipse.py b/paseos/power/is_in_eclipse.py deleted file mode 100644 index 6a5c5d25..00000000 --- a/paseos/power/is_in_eclipse.py +++ /dev/null @@ -1,93 +0,0 @@ -"""This file provides method for computing whether an actor is currently in eclipse""" -import pykep as pk -import numpy as np -from skspatial.objects import LineSegment, Line, Sphere -from loguru import logger - - -def is_in_eclipse( - actor, - central_body: pk.planet, - t: pk.epoch, - plot=False, -) -> bool: - """Checks whether the actor is currently in eclipse of central body. - - Args: - actor: Actor to check - central_body (pk.planet): Central body of the actor - t (pk.epoch): Current time to check at - plot (bool, optional): If true will plot visualization. Defaults to False. - - Returns: - bool: True if actor is in eclipse - """ - # on OSX this can throw an error it seems. - try: - logger.debug(f"Checking whether {actor} is in eclipse at {t}.") - except RuntimeError: - logger.debug( - f"Checking whether {actor} is in eclipse at {t.mjd2000} (mjd2000)." - ) - - # Compute central body position in solar reference frame - r_central_body_heliocentric, _ = np.array(central_body.eph(t)) - logger.trace("r_central_body_heliocentric is" + str(r_central_body_heliocentric)) - central_body_sphere = Sphere( - r_central_body_heliocentric, actor._central_body_sphere.radius - ) - - # Compute satellite / actor position in solar reference frame - r_sat_central_body_frame = np.array(actor.get_position(t)) - logger.trace("r_sat_central_body_frame is" + str(r_sat_central_body_frame)) - r_sat_heliocentric = r_central_body_heliocentric + r_sat_central_body_frame - logger.trace("r_sat_heliocentric is" + str(r_sat_heliocentric)) - - # Compute line between actor and sun - line_between_sun_and_actor = Line( - [0, 0, 0], - r_sat_heliocentric, - ) - - # Specify line segment to see if intersections are on this segment - linesegment_between_sun_and_actor = LineSegment( - [0, 0, 0], - r_sat_heliocentric, - ) - - if plot: - from skspatial.plotting import plot_3d - from skspatial.objects import Point - - # Currently skspatial throws a ValueError if there is no intersection so we have to use this rather ugly way. - try: - p1, p2 = central_body_sphere.intersect_line(line_between_sun_and_actor) - logger.trace("Intersections observed at " + str(p1) + " and " + str(p2)) - if plot: - sat_point = Point(r_sat_heliocentric) - plot_3d( - line_between_sun_and_actor.plotter(t_1=0, t_2=0.001, c="k"), - central_body_sphere.plotter(alpha=0.4), - sat_point.plotter(c="b", s=100), - p1.plotter(c="r", s=100), - p2.plotter(c="r", s=100), - ) - except ValueError: - if plot: - sat_point = Point(r_sat_heliocentric) - plot_3d( - line_between_sun_and_actor.plotter(c="k"), - central_body_sphere.plotter(alpha=0.4), - sat_point.plotter(c="b", s=100), - ) - # No intersection, no eclipse - return False - # Check that the computed intersection is actually on the linesegment not infinite line - if linesegment_between_sun_and_actor.contains_point(p1): - logger.trace(f"p1={p1} is on the line between Sun and actor.") - return True - elif linesegment_between_sun_and_actor.contains_point(p2): - logger.trace(f"p2={p2} is on the line between Sun and actor.") - return True - else: - return False diff --git a/paseos/radiation/radiation_model.py b/paseos/radiation/radiation_model.py index 0c7f7076..7c360863 100644 --- a/paseos/radiation/radiation_model.py +++ b/paseos/radiation/radiation_model.py @@ -18,9 +18,7 @@ def __init__( restart_events_per_s (float): Device restart being triggered, events per second. failure_events_per_s (float): Complete device failure, events per second. """ - assert ( - data_corruption_events_per_s >= 0 - ), "data_corruption_events_per_s cannot be negative." + assert data_corruption_events_per_s >= 0, "data_corruption_events_per_s cannot be negative." assert restart_events_per_s >= 0, "restart_events_per_s cannot be negative." assert failure_events_per_s >= 0, "failure_events_per_s cannot be negative." @@ -89,9 +87,7 @@ def did_device_restart(self, interval_in_s: float): bool: Whether restart event occurred. """ assert interval_in_s > 0, "Time interval must be positive." - return RadiationModel._sample_poisson_process( - self._restart_events_per_s, interval_in_s - ) + return RadiationModel._sample_poisson_process(self._restart_events_per_s, interval_in_s) def did_device_experience_failure(self, interval_in_s: float): """Models whether the device experienced a failure in this interval. @@ -103,6 +99,4 @@ def did_device_experience_failure(self, interval_in_s: float): bool: Whether restart event occurred. """ assert interval_in_s > 0, "Time interval must be positive." - return RadiationModel._sample_poisson_process( - self._failure_events_per_s, interval_in_s - ) + return RadiationModel._sample_poisson_process(self._failure_events_per_s, interval_in_s) diff --git a/paseos/tests/activity_test.py b/paseos/tests/activity_test.py index b7e81fb1..969b07b2 100644 --- a/paseos/tests/activity_test.py +++ b/paseos/tests/activity_test.py @@ -54,9 +54,7 @@ async def func(args): await asyncio.sleep(0.2) # Register an activity that draws 10 watt per second - sim.register_activity( - "Testing", activity_function=func, power_consumption_in_watt=10 - ) + sim.register_activity("Testing", activity_function=func, power_consumption_in_watt=10) # Run the activity sim.perform_activity("Testing", activity_func_args=[test_val]) @@ -81,7 +79,7 @@ async def func(args): @pytest.mark.asyncio async def test_running_two_activities(): """This test ensures that you cannot run two activities at the same time.""" - sim, sat1, earth = get_default_instance() + sim, _, _ = get_default_instance() # Declare two activities, one calls the other test_value = [0] @@ -113,7 +111,7 @@ async def test_activity_constraints(): Here we start a function that counts up until we stop charging our solar panels and then prints the value. """ - sim, sat1, earth = get_default_instance() + sim, sat1, _ = get_default_instance() assert sat1.battery_level_in_Ws == 500 # Out test case is a function that increments a value, genius. @@ -126,7 +124,7 @@ async def func(args): args[0][0] += 1 await asyncio.sleep(1.0) - # Constraint that becomes false once the actor has charge it's battery over 510 + # Constraint that becomes false once the actor has charged it's battery over 510 async def constraint(args): local_actor: SpacecraftActor = args[0] return local_actor.battery_level_in_Ws < 505 diff --git a/paseos/tests/actor_builder_test.py b/paseos/tests/actor_builder_test.py index b5160922..7dafab5b 100644 --- a/paseos/tests/actor_builder_test.py +++ b/paseos/tests/actor_builder_test.py @@ -5,11 +5,53 @@ sys.path.append("../..") -from paseos import ActorBuilder +from paseos import ActorBuilder, SpacecraftActor from test_utils import get_default_instance +def test_set_TLE(): + """Check if we can set a TLE correctly""" + + _, sentinel2a, earth = get_default_instance() + # Set the TLE + line1 = "1 40697U 15028A 23188.15862373 .00000171 00000+0 81941-4 0 9994" + line2 = "2 40697 98.5695 262.3977 0001349 91.8221 268.3116 14.30817084419867" + ActorBuilder.set_TLE(sentinel2a, line1, line2) + + # Check that get_altitude returns a sensible value + earth_radius = 6371000 + assert sentinel2a.get_altitude() > earth_radius + 780000 + assert sentinel2a.get_altitude() < earth_radius + 820000 + + # Check that get_position_velocity returns sensible values + position, velocity = sentinel2a.get_position_velocity(sentinel2a.local_time) + assert position is not None + assert velocity is not None + + # Create an actor with a keplerian orbit and check that the position and velocity + # diverge over time + s2a_kep = ActorBuilder.get_actor_scaffold("s2a_kep", SpacecraftActor, sentinel2a.local_time) + ActorBuilder.set_orbit(s2a_kep, position, velocity, sentinel2a.local_time, earth) + + # After some orbits the differences should be significant + # since the TLE uses SGP4 and the other actor uses Keplerian elements + t0_later = pk.epoch(sentinel2a.local_time.mjd2000 + 1) + r, v = sentinel2a.get_position_velocity(t0_later) + r_kep, v_kep = s2a_kep.get_position_velocity(t0_later) + print("r,v SGP4 after 1 day") + print(r) + print(v) + print("r,v Kep after 1 day") + print(r_kep) + print(v_kep) + print("Differences in r and v") + print(np.linalg.norm(np.array(r) - np.array(r_kep))) + print(np.linalg.norm(np.array(v) - np.array(v_kep))) + assert np.linalg.norm(np.array(r) - np.array(r_kep)) > 100000 + assert np.linalg.norm(np.array(v) - np.array(v_kep)) > 400 + + def test_set_orbit(): """Check if we can specify an orbit correctly""" _, sat1, earth = get_default_instance() diff --git a/paseos/tests/communication_window_test.py b/paseos/tests/communication_window_test.py index cc950309..8b1fc525 100644 --- a/paseos/tests/communication_window_test.py +++ b/paseos/tests/communication_window_test.py @@ -1,10 +1,4 @@ """Test to check the communication function(s)""" -import sys - -sys.path.append("../..") - -from skspatial.objects import Sphere - from paseos import ( SpacecraftActor, GroundstationActor, @@ -16,12 +10,6 @@ import pykep as pk import numpy as np -from test_utils import _PASEOS_TESTS_EARTH_RADIUS - - -def from_epoch_to_s(epoch: pk.epoch): - return (epoch.mjd2000 - pk.epoch(0).mjd2000) / pk.SEC2DAY - def setup_sentinel_example(t0): """Sets up the example with sentinel2B and maspolamas ground station.""" @@ -30,12 +18,8 @@ def setup_sentinel_example(t0): # Define Sentinel 2 orbit sentinel2B = ActorBuilder.get_actor_scaffold("Sentinel2B", SpacecraftActor, t0) - sentinel2B_line1 = ( - "1 42063U 17013A 22300.18652110 .00000099 00000+0 54271-4 0 9998" - ) - sentinel2B_line2 = ( - "2 42063 98.5693 13.0364 0001083 104.3232 255.8080 14.30819357294601" - ) + sentinel2B_line1 = "1 42063U 17013A 22300.18652110 .00000099 00000+0 54271-4 0 9998" + sentinel2B_line2 = "2 42063 98.5693 13.0364 0001083 104.3232 255.8080 14.30819357294601" s2b = pk.planet.tle(sentinel2B_line1, sentinel2B_line2) # Calculating S2B ephemerides. @@ -49,15 +33,13 @@ def setup_sentinel_example(t0): central_body=earth, ) - sentinel2B.set_central_body_shape(Sphere([0, 0, 0], _PASEOS_TESTS_EARTH_RADIUS)) - # Define ground station maspalomas_groundstation = ActorBuilder.get_actor_scaffold( name="maspalomas_groundstation", actor_type=GroundstationActor, epoch=t0 ) ActorBuilder.set_ground_station_location( - maspalomas_groundstation, -15.6338, 27.7629, 205.1, minimum_altitude_angle=5 + maspalomas_groundstation, 27.7629, -15.6338, 205.1, minimum_altitude_angle=5 ) # Add communication link @@ -66,22 +48,21 @@ def setup_sentinel_example(t0): def test_find_next_window(): - # Test window from other test is found - t0 = pk.epoch_from_string("2022-Oct-27 21:00:00") + t0 = pk.epoch_from_string("2022-Oct-27 22:57:00") sentinel2B, maspalomas_groundstation = setup_sentinel_example(t0) start, length, transmittable_data = find_next_window( sentinel2B, local_actor_communication_link_name="link1", target_actor=maspalomas_groundstation, - search_window_in_s=600, + search_window_in_s=360, t0=t0, ) - assert np.isclose(start.mjd2000, 8335.87835648148) - assert np.isclose(length, 731.9999999657739, rtol=0.01, atol=3.0) - assert np.isclose(transmittable_data, 732000, rtol=0.01, atol=3000) + assert np.isclose(start.mjd2000, 8335.957060185183) + assert np.isclose(length, 740.0000001071021, rtol=0.01, atol=3.0) + assert np.isclose(transmittable_data, 740000, rtol=0.01, atol=3000) # Test correct return if no window found t0 = pk.epoch_from_string("2022-Oct-27 20:00:00") @@ -91,7 +72,7 @@ def test_find_next_window(): sentinel2B, local_actor_communication_link_name="link1", target_actor=maspalomas_groundstation, - search_window_in_s=300, + search_window_in_s=360, t0=t0, ) @@ -104,7 +85,7 @@ def test_communication_link_sat_to_ground(): """This test checks if the communication window between Sentinel and one of it's ground stations matches """ - t0 = pk.epoch_from_string("2022-Oct-27 21:04:45") + t0 = pk.epoch_from_string("2022-Oct-27 22:58:09") sentinel2B, maspalomas_groundstation = setup_sentinel_example(t0) # Check again after communication_window_end_time @@ -123,13 +104,14 @@ def test_communication_link_sat_to_ground(): window_in_s = ( communication_window_end_time.mjd2000 - communication_window_start_time.mjd2000 ) * pk.DAY2SEC - expected_window_in_s = 731.9999999657739 + expected_window_in_s = 739.0000000305008 assert np.isclose(expected_window_in_s, window_in_s) def test_communication_link_sat_to_sat(): # create satellites where sat1 and sat2 starts from the same point but move along different orbit. # At t=1470s they will not be in line of sight anymore. + earth = pk.planet.jpl_lp("earth") sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) @@ -149,9 +131,6 @@ def test_communication_link_sat_to_sat(): central_body=earth, ) - sat1.set_central_body_shape(Sphere([0, 0, 0], _PASEOS_TESTS_EARTH_RADIUS)) - sat2.set_central_body_shape(Sphere([0, 0, 0], _PASEOS_TESTS_EARTH_RADIUS)) - # Add communication link ActorBuilder.add_comm_device(sat1, device_name="link1", bandwidth_in_kbps=1) @@ -169,7 +148,7 @@ def test_communication_link_sat_to_sat(): data_to_send_in_b=10000, ) - assert (from_epoch_to_s(communication_window_end_time) >= 10) and ( + assert (communication_window_end_time.mjd2000 * pk.DAY2SEC >= 10) and ( transmitted_data_in_b == 10000 ) @@ -187,9 +166,8 @@ def test_communication_link_sat_to_sat(): data_to_send_in_b=10000, ) - assert (transmitted_data_in_b == 0) and ( - from_epoch_to_s(communication_window_end_time) - == from_epoch_to_s(communication_window_start_time) + assert (transmitted_data_in_b == 0) and np.isclose( + communication_window_end_time.mjd2000, communication_window_start_time.mjd2000 ) diff --git a/paseos/tests/custom_propagator_test.py b/paseos/tests/custom_propagator_test.py new file mode 100644 index 00000000..293c12c6 --- /dev/null +++ b/paseos/tests/custom_propagator_test.py @@ -0,0 +1,48 @@ +"""Test the ability to use a custom propagator in a paseos simulation.""" +import sys + +sys.path.append("../..") + +import numpy as np +import pykep as pk +from paseos import SpacecraftActor, ActorBuilder + + +def test_custom_propagator(): + """Test if we can just determine position as static with a custom propagator""" + + # Create a spacecraft actor + starting_epoch = pk.epoch(42) + my_sat = ActorBuilder.get_actor_scaffold( + name="my_sat", actor_type=SpacecraftActor, epoch=starting_epoch + ) + + # Define a custom propagator function that just returns a sinus position + def my_propagator(epoch: pk.epoch): + """Custom propagator that returns a sinus position""" + time_since_epoch_in_seconds = (epoch.mjd2000 - starting_epoch.mjd2000) * pk.DAY2SEC + + r = [np.sin(time_since_epoch_in_seconds), 0.0, 0.0] + v = [42.0, 42.0, 42.0] + + return r, v + + # Set the custom propagator + ActorBuilder.set_custom_orbit(my_sat, my_propagator, starting_epoch) + + # Check that the position is correct + r = my_sat.get_position(starting_epoch) + assert np.allclose(r, [0, 0, 0]) + + r, v = my_sat.get_position_velocity(starting_epoch) + assert np.allclose(r, [0, 0, 0]) + assert np.allclose(v, [42, 42, 42]) + + # Check that the position is correct at a later time + later = pk.epoch(42 + 0.5 * np.pi * pk.SEC2DAY) + r = my_sat.get_position(later) + assert np.allclose(r, [1, 0, 0]) + + r, v = my_sat.get_position_velocity(later) + assert np.allclose(r, [1, 0, 0]) + assert np.allclose(v, [42, 42, 42]) diff --git a/paseos/tests/custom_property_test.py b/paseos/tests/custom_property_test.py new file mode 100644 index 00000000..839ee312 --- /dev/null +++ b/paseos/tests/custom_property_test.py @@ -0,0 +1,49 @@ +"""This test checks whether power charging is performed correctly""" + +from test_utils import get_default_instance + +import paseos +from paseos import ActorBuilder, SpacecraftActor + +import pykep as pk +import numpy as np + + +def test_custom_power_consumption_property(): + """Checks whether we can create a custom property to track power consumption of an actor""" + + # Initialize the actor + sim, sat1, earth = get_default_instance() + 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, 50000, 1000000, 1, paseos.PowerDeviceType.SolarPanel) + + # Add a custom property which tracks the total power consumption + prop_name = "total_power_consumption" + + # Define the update function + def prop_update_fn(actor, dt, power_consumption): + return actor.get_custom_property(prop_name) + power_consumption * dt + + ActorBuilder.add_custom_property( + actor=sat1, property_name=prop_name, update_function=prop_update_fn, initial_value=0 + ) + print(f"Actor custom properties are now {sat1.custom_properties}") + + # init simulation + sim = paseos.init_sim(sat1) + + # simulate a bit + sim.advance_time(100, 10) + + # check consumed power + assert np.isclose(sat1.get_custom_property(prop_name), 100 * 10) + + # check set function + sat1.set_custom_property(prop_name, 0) + assert sat1.get_custom_property(prop_name) == 0 + + # check quantity is tracked by operations monitor + sim.monitor[prop_name] + sim.monitor.plot(prop_name) + sim.save_status_log_csv("test.csv") diff --git a/paseos/tests/eclipse_test.py b/paseos/tests/eclipse_test.py index 1952082b..784b530c 100644 --- a/paseos/tests/eclipse_test.py +++ b/paseos/tests/eclipse_test.py @@ -3,8 +3,6 @@ sys.path.append("../..") -from paseos.power.is_in_eclipse import is_in_eclipse - import pykep as pk from test_utils import get_default_instance @@ -14,8 +12,8 @@ def test_eclipse(): """Get the default satellite and see if is in eclipse and getting out of it""" _, sat1, earth = get_default_instance() - assert not is_in_eclipse(sat1, earth, pk.epoch(0), plot=True) - assert is_in_eclipse(sat1, earth, pk.epoch(0.5), plot=True) + assert not sat1.is_in_eclipse(pk.epoch(0)) + assert sat1.is_in_eclipse(pk.epoch(0.5)) if __name__ == "__main__": diff --git a/paseos/tests/line_of_sight_test.py b/paseos/tests/line_of_sight_test.py index 28349a10..f5fc9b89 100644 --- a/paseos/tests/line_of_sight_test.py +++ b/paseos/tests/line_of_sight_test.py @@ -3,14 +3,12 @@ sys.path.append("../..") -from skspatial.objects import Sphere - from paseos import SpacecraftActor, ActorBuilder, GroundstationActor -from paseos.communication.is_in_line_of_sight import is_in_line_of_sight +from paseos.central_body.is_in_line_of_sight import is_in_line_of_sight import pykep as pk -from test_utils import get_default_instance, _PASEOS_TESTS_EARTH_RADIUS +from test_utils import get_default_instance def test_los_between_sats(): @@ -20,10 +18,8 @@ def test_los_between_sats(): sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit(sat2, [0, 10000000, 0], [0, 0, 8000.0], pk.epoch(0), earth) - sat2.set_central_body_shape(Sphere([0, 0, 0], _PASEOS_TESTS_EARTH_RADIUS)) sat3 = ActorBuilder.get_actor_scaffold("sat3", SpacecraftActor, pk.epoch(0)) - sat3.set_central_body_shape(Sphere([0, 0, 0], _PASEOS_TESTS_EARTH_RADIUS)) ActorBuilder.set_orbit(sat3, [0, -10000000, 0], [0, 0, -8000.0], pk.epoch(0), earth) # check that LOS is correct diff --git a/paseos/tests/mesh_test.py b/paseos/tests/mesh_test.py new file mode 100644 index 00000000..974b395e --- /dev/null +++ b/paseos/tests/mesh_test.py @@ -0,0 +1,226 @@ +"""Test using a mesh for the central body.""" + +import numpy as np +import pickle +import pykep as pk + +from paseos import ActorBuilder, SpacecraftActor +import paseos + +mesh_path = "paseos/tests/test_data/67P_low_poly.pk" + +# Orbital elements / ephemeris for 67P +# from https://en.wikipedia.org/wiki/67P/Churyumov%E2%80%93Gerasimenko +epoch = pk.epoch(2460000.5, "jd") +a = 3.457 * pk.AU +e = 0.64989 +i = 3.8719 * pk.DEG2RAD +W = 36.33 * pk.DEG2RAD +w = 22.15 * pk.DEG2RAD +M = 73.57 * pk.DEG2RAD +# We overestimate MU due to a bug in pykep +# https://github.com/esa/pykep/issues/167 +# MU = 666.19868 +MU = 1e5 + +plot = False + + +def get_default_setup(): + paseos.set_log_level("INFO") + + # Create a planet object from pykep for 67P + comet = pk.planet.keplerian(epoch, (a, e, i, W, w, M), pk.MU_SUN, MU, 2000, 2000, "67P") + + # Load the 67P mesh with pickle + with open(mesh_path, "rb") as f: + mesh_points, mesh_triangles = pickle.load(f) + # The mesh file for the test is normalized to -1,1 + # Thus we convert back to meters + mesh_points = np.array(mesh_points) * 3126.6064453124995 + mesh_triangles = np.array(mesh_triangles) + + # Define local actor + sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, epoch=epoch) + + # Set a keplerian orbit around 67P + # Note that keplerian is not very realistic for 67P, just for testing + ActorBuilder.set_orbit(sat1, [4000, 1, 1], [0.0, 5, 0.0], epoch, comet) + + # Set the mesh and create some more satellites around 67P + ActorBuilder.set_central_body(sat1, comet, (mesh_points, mesh_triangles)) + + # init simulation + sim = paseos.init_sim(sat1, starting_epoch=epoch) + + return sim, sat1, comet, mesh_points, mesh_triangles + + +def test_mesh_for_central_body(): + """Checks if we can create an actor with a mesh for the central body.""" + + _, _, _, _, _ = get_default_setup() + + +def test_mesh_los(): + """Checks if we can compute line of sight using a mesh for the central body.""" + + sim, sat1, comet, mesh_points, mesh_triangles = get_default_setup() + + sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, epoch=epoch) + ActorBuilder.set_orbit(sat2, [-4000, 1, 1], [5, 0, 0], epoch, comet) + ActorBuilder.set_central_body(sat2, comet, (mesh_points, mesh_triangles)) + + sat3 = ActorBuilder.get_actor_scaffold("sat3", SpacecraftActor, epoch=epoch) + ActorBuilder.set_orbit(sat3, [1, 4000, 1], [-5, 0, 0], epoch, comet) + ActorBuilder.set_central_body(sat3, comet, (mesh_points, mesh_triangles)) + + sat4 = ActorBuilder.get_actor_scaffold("sat4", SpacecraftActor, epoch=epoch) + ActorBuilder.set_orbit(sat4, [1, -4000, 1], [0, -5, 0], epoch, comet) + ActorBuilder.set_central_body(sat4, comet, (mesh_points, mesh_triangles)) + + sat5 = ActorBuilder.get_actor_scaffold("sat5", SpacecraftActor, epoch=epoch) + ActorBuilder.set_orbit(sat5, [1, 1, 4000], [5, 0, 0], epoch, comet) + ActorBuilder.set_central_body(sat5, comet, (mesh_points, mesh_triangles)) + + sat6 = ActorBuilder.get_actor_scaffold("sat6", SpacecraftActor, epoch=epoch) + ActorBuilder.set_orbit(sat6, [1, 1, -4000], [-5, 0, 0], epoch, comet) + ActorBuilder.set_central_body(sat6, comet, (mesh_points, mesh_triangles)) + + sim.add_known_actor(sat2) + sim.add_known_actor(sat3) + sim.add_known_actor(sat4) + sim.add_known_actor(sat5) + sim.add_known_actor(sat6) + + assert not sat1.is_in_line_of_sight(sat2, epoch) + assert not sat3.is_in_line_of_sight(sat4, epoch) + assert not sat5.is_in_line_of_sight(sat6, epoch) + + assert sat1.is_in_line_of_sight(sat3, epoch) + assert sat1.is_in_line_of_sight(sat4, epoch) + assert sat1.is_in_line_of_sight(sat5, epoch) + assert sat1.is_in_line_of_sight(sat6, epoch) + + assert sat2.is_in_line_of_sight(sat3, epoch) + assert sat2.is_in_line_of_sight(sat4, epoch) + assert sat2.is_in_line_of_sight(sat5, epoch) + assert sat2.is_in_line_of_sight(sat6, epoch) + + assert sat3.is_in_line_of_sight(sat5, epoch) + assert sat3.is_in_line_of_sight(sat6, epoch) + + assert sat4.is_in_line_of_sight(sat5, epoch) + assert sat4.is_in_line_of_sight(sat6, epoch) + + # Write a plot to file, for debugging + if plot: + paseos.plot(sim, paseos.PlotType.SpacePlot, "results/mesh_test.png") + + # Propagate for a bit, just to check we can + sim.advance_time(600, 0) + + +def test_mesh_eclipse(): + """Checks if we can compute eclipse correctly using a mesh for the central body.""" + sim, sat1, _, _, _ = get_default_setup() + + # Add a power device + ActorBuilder.set_power_devices( + actor=sat1, battery_level_in_Ws=500, max_battery_level_in_Ws=1000, charging_rate_in_W=10 + ) + + assert not sat1.is_in_eclipse(epoch) + assert sat1.battery_level_in_Ws == 500 + if plot: + paseos.plot(sim, paseos.PlotType.SpacePlot, "results/mesh_test_no_eclipse.png") + + # Advance time and consume power we are generating + sim.advance_time(2260, 10) + + assert sat1.is_in_eclipse() + assert np.isclose(sat1.battery_level_in_Ws, 400) + if plot: + paseos.plot(sim, paseos.PlotType.SpacePlot, "results/mesh_test_eclipse.png") + + +def test_mesh_rotation(): + sim, sat1, comet, mesh_points, mesh_triangles = get_default_setup() + + # Set a rotation period of 1 second around the z axis + ActorBuilder.set_central_body( + sat1, + comet, + (mesh_points, mesh_triangles), + rotation_declination=90, + rotation_right_ascension=0, + rotation_period=1, + ) + + # Epochs after a quarter and half rotation + epoch_quarter = pk.epoch(epoch.mjd2000 + 0.25 * pk.SEC2DAY, "mjd2000") + epoch_half = pk.epoch(epoch.mjd2000 + 0.5 * pk.SEC2DAY, "mjd2000") + + central_body = sat1.central_body + + # NOTE the we move the points in the opposite direction of the rotation + # to avoid rotating the mesh + rotate_p = central_body._apply_rotation([1, 0, 0], epoch_quarter) + assert all(np.isclose(rotate_p, [0, -1, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 1, 0], epoch_quarter) + assert all(np.isclose(rotate_p, [1, 0, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, -1, 0], epoch_quarter) + assert all(np.isclose(rotate_p, [-1, 0, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 1, 0], epoch_half) + assert all(np.isclose(rotate_p, [0, -1, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([-1, 0, 0], epoch_half) + assert all(np.isclose(rotate_p, [1, 0, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 0, 1], epoch_quarter) + assert all(np.isclose(rotate_p, [0, 0, 1], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 0, -1], epoch_half) + assert all(np.isclose(rotate_p, [0, 0, -1], atol=1e-6)) + + # Set a rotation period of 2 second around the y axis + ActorBuilder.set_central_body( + sat1, + comet, + (mesh_points, mesh_triangles), + rotation_declination=0, + rotation_right_ascension=90, + rotation_period=2, + ) + + # Epochs after a quarter and half rotation + epoch_quarter = pk.epoch(epoch.mjd2000 + 0.5 * pk.SEC2DAY, "mjd2000") + epoch_half = pk.epoch(epoch.mjd2000 + 1 * pk.SEC2DAY, "mjd2000") + + central_body = sat1.central_body + + # NOTE the we move the points in the opposite direction of the rotation + # to avoid rotating the mesh + rotate_p = central_body._apply_rotation([1, 0, 0], epoch_quarter) + assert all(np.isclose(rotate_p, [0, 0, 1], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 0, -1], epoch_quarter) + assert all(np.isclose(rotate_p, [1, 0, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 0, 1], epoch_quarter) + assert all(np.isclose(rotate_p, [-1, 0, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 0, 1], epoch_half) + assert all(np.isclose(rotate_p, [0, 0, -1], atol=1e-6)) + + rotate_p = central_body._apply_rotation([-1, 0, 0], epoch_half) + assert all(np.isclose(rotate_p, [1, 0, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, 1, 0], epoch_quarter) + assert all(np.isclose(rotate_p, [0, 1, 0], atol=1e-6)) + + rotate_p = central_body._apply_rotation([0, -1, 0], epoch_half) + assert all(np.isclose(rotate_p, [0, -1, 0], atol=1e-6)) diff --git a/paseos/tests/operations_monitor_test.py b/paseos/tests/operations_monitor_test.py index 4da86ba7..0622f5fc 100644 --- a/paseos/tests/operations_monitor_test.py +++ b/paseos/tests/operations_monitor_test.py @@ -37,13 +37,9 @@ async def func2(args): await asyncio.sleep(1.0) # Register an activity that draws 10 watt per second - sim.register_activity( - "Activity_1", activity_function=func1, power_consumption_in_watt=2 - ) + sim.register_activity("Activity_1", activity_function=func1, power_consumption_in_watt=2) - sim.register_activity( - "Activity_2", activity_function=func2, power_consumption_in_watt=10 - ) + sim.register_activity("Activity_2", activity_function=func2, power_consumption_in_watt=10) # Run the activity sim.perform_activity("Activity_1") diff --git a/paseos/tests/power_test.py b/paseos/tests/power_test.py index b9b48b18..c85cc23a 100644 --- a/paseos/tests/power_test.py +++ b/paseos/tests/power_test.py @@ -4,7 +4,6 @@ import paseos from paseos import ActorBuilder, SpacecraftActor -from paseos.power.is_in_eclipse import is_in_eclipse import pykep as pk @@ -18,15 +17,10 @@ def test_power_charging(): sim.advance_time(42, 0) assert sat1.battery_level_in_Ws == 542 - # Define central body - earth = pk.planet.jpl_lp("earth") - # Define local actor 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, paseos.PowerDeviceType.SolarPanel - ) + ActorBuilder.set_power_devices(sat1, 500, 10000, 1, paseos.PowerDeviceType.SolarPanel) # init simulation sim = paseos.init_sim(sat1) @@ -35,7 +29,7 @@ def test_power_charging(): sim.advance_time(12 * 3600, 0) # Check we are in eclipse - assert is_in_eclipse(sat1, earth, sat1.local_time, plot=True) + assert sat1.is_in_eclipse() # Check we are fully charged assert sat1.battery_level_in_Ws == 10000 @@ -47,7 +41,6 @@ def test_power_charging(): def test_RTG_charging_in_eclipse(): - # Define central body earth = pk.planet.jpl_lp("earth") @@ -63,7 +56,7 @@ def test_RTG_charging_in_eclipse(): sim.advance_time(12 * 3600, 1) # Check we are in eclipse - assert is_in_eclipse(sat1, earth, sat1.local_time, plot=True) + assert sat1.is_in_eclipse() # Initial power was 500m check charging works assert sat1.battery_level_in_Ws == 500 diff --git a/paseos/tests/radiation_test.py b/paseos/tests/radiation_test.py index 9667aeb5..b5fd5993 100644 --- a/paseos/tests/radiation_test.py +++ b/paseos/tests/radiation_test.py @@ -11,7 +11,7 @@ def test_radiation_model(): - paseos.set_log_level("TRACE") + paseos.set_log_level("INFO") # Has to be seeded for reproducibility np.random.seed(42) @@ -79,9 +79,7 @@ async def func(args): await asyncio.sleep(1.0) # Register an activity that draws 10 watt per second - sim.register_activity( - "Testing", activity_function=func, power_consumption_in_watt=0 - ) + sim.register_activity("Testing", activity_function=func, power_consumption_in_watt=0) # Run the activity sim.perform_activity("Testing") @@ -118,9 +116,7 @@ async def func(args): await asyncio.sleep(0.2) # Register an activity that draws 10 watt per second - sim.register_activity( - "Testing", activity_function=func, power_consumption_in_watt=0 - ) + sim.register_activity("Testing", activity_function=func, power_consumption_in_watt=0) # Run the activity sim.perform_activity("Testing", activity_func_args=[test_val]) diff --git a/paseos/tests/test_data/67P_low_poly.pk b/paseos/tests/test_data/67P_low_poly.pk new file mode 100644 index 00000000..4a456dfd Binary files /dev/null and b/paseos/tests/test_data/67P_low_poly.pk differ diff --git a/paseos/tests/test_utils.py b/paseos/tests/test_utils.py index b475a394..fa04da63 100644 --- a/paseos/tests/test_utils.py +++ b/paseos/tests/test_utils.py @@ -9,11 +9,16 @@ import pykep as pk -_PASEOS_TESTS_EARTH_RADIUS = 6371000 - def get_default_instance() -> (paseos.PASEOS, SpacecraftActor, pk.planet): - """Sets up a instance of paseos with a satellite in orbit around Earth""" + """Sets up a instance of paseos with a satellite in orbit around Earth + + Returns: + (paseos.PASEOS, SpacecraftActor, pk.planet): The simulation, the satellite and the central body + """ + + # Set log level for tests low + paseos.set_log_level("TRACE") # Define central body earth = pk.planet.jpl_lp("earth") diff --git a/paseos/tests/thermal_model_test.py b/paseos/tests/thermal_model_test.py index 2c9977b0..0744a6e3 100644 --- a/paseos/tests/thermal_model_test.py +++ b/paseos/tests/thermal_model_test.py @@ -47,9 +47,7 @@ async def func(args): await asyncio.sleep(16.0) # Register an activity that draws 10 watt per second - sim.register_activity( - "Activity_1", activity_function=func, power_consumption_in_watt=10 - ) + sim.register_activity("Activity_1", activity_function=func, power_consumption_in_watt=10) # Run the activity sim.perform_activity("Activity_1") diff --git a/paseos/tests/time_multiplier_test.py b/paseos/tests/time_multiplier_test.py index bbd8c513..1623a5ab 100644 --- a/paseos/tests/time_multiplier_test.py +++ b/paseos/tests/time_multiplier_test.py @@ -39,9 +39,7 @@ async def func(args): await asyncio.sleep(0.2) # Register an activity that draws 10 watt per second - sim.register_activity( - "Testing", activity_function=func, power_consumption_in_watt=10 - ) + sim.register_activity("Testing", activity_function=func, power_consumption_in_watt=10) # Run the activity sim.perform_activity("Testing", activity_func_args=[test_val]) @@ -56,4 +54,4 @@ async def func(args): # 1s real time equals 5s simulation # And discharge 10W per second # So should be roughly 50W - 5W consumed from starting 500 - assert sat1.battery_level_in_Ws > 350 and sat1.battery_level_in_Ws <= 455 + assert sat1.battery_level_in_Ws > 350 and sat1.battery_level_in_Ws <= 456 diff --git a/paseos/tests/visualization_test.py b/paseos/tests/visualization_test.py index b3dddc22..100156b7 100644 --- a/paseos/tests/visualization_test.py +++ b/paseos/tests/visualization_test.py @@ -3,11 +3,9 @@ sys.path.append("../..") -from skspatial.objects import Sphere - from paseos import ActorBuilder, SpacecraftActor from paseos.visualization.space_animation import SpaceAnimation -from test_utils import get_default_instance, _PASEOS_TESTS_EARTH_RADIUS +from test_utils import get_default_instance import pykep as pk @@ -18,7 +16,6 @@ def test_animation(): sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit(sat2, [0, 10000000, 0], [0, 0, 8000.0], pk.epoch(0), earth) ActorBuilder.set_power_devices(sat2, 5000, 10000, 1) - sat2.set_central_body_shape(Sphere([0, 0, 0], _PASEOS_TESTS_EARTH_RADIUS)) sim.add_known_actor(sat2) anim = SpaceAnimation(sim) diff --git a/paseos/thermal/thermal_model.py b/paseos/thermal/thermal_model.py index c9c59aba..e7b0c634 100644 --- a/paseos/thermal/thermal_model.py +++ b/paseos/thermal/thermal_model.py @@ -75,7 +75,7 @@ def __init__( """ logger.trace("Initializing thermal model.") self._actor = local_actor - self._body_radius = self._actor._central_body.radius + self._body_radius = self._actor._central_body._planet.radius self._power_consumption_to_heat_ratio = power_consumption_to_heat_ratio @@ -100,9 +100,7 @@ def _initialize_constants(self): # EQ 15 in source self._C_solar_input = ( - self._actor_sun_absorptance - * self._actor_sun_facing_area - * self._body_solar_irradiance + self._actor_sun_absorptance * self._actor_sun_facing_area * self._body_solar_irradiance ) logger.trace(f"self._C_solar_input={self._C_solar_input}") @@ -127,9 +125,7 @@ def _initialize_constants(self): # EQ 20 self._C_actor_emission = ( - self._actor_infrared_absorptance - * self._actor_emissive_area - * self._boltzmann_constant + self._actor_infrared_absorptance * self._actor_emissive_area * self._boltzmann_constant ) logger.trace(f"self._C_actor_emission={self._C_actor_emission}") @@ -139,7 +135,7 @@ def _compute_body_view_from_actor(self) -> None: Returns: float: constant from above defined EQs. """ - h = self._actor.altitude / self._body_radius + h = self._actor.get_altitude() / self._body_radius return 1.0 / (h * h) def _compute_solar_input(self): @@ -205,11 +201,11 @@ def update_temperature(self, dt: float, current_power_consumption: float = 0): logger.debug(f"Actor's old temperature was {self._actor_temperature_in_K}.") logger.trace(f"Actor in eclipse: {self._actor.is_in_eclipse()}") - logger.trace(f"Actor altitude: {self._actor.altitude}") + logger.trace(f"Actor altitude: {self._actor.get_altitude()}") - self._actor_temperature_in_K = self._actor_temperature_in_K + ( - dt * total_change_in_W - ) / (self._actor.mass * self._actor_thermal_capacity) + self._actor_temperature_in_K = self._actor_temperature_in_K + (dt * total_change_in_W) / ( + self._actor.mass * self._actor_thermal_capacity + ) # Ensure value cannot go below 0 self._actor_temperature_in_K = max(0.0, self._actor_temperature_in_K) diff --git a/paseos/utils/check_cfg.py b/paseos/utils/check_cfg.py index 16f2f1f3..51d506e8 100644 --- a/paseos/utils/check_cfg.py +++ b/paseos/utils/check_cfg.py @@ -43,9 +43,7 @@ def _check_for_keys(cfg: DotMap, major_categories: list) -> None: for category in major_categories: for key in cfg[category].keys(): if key not in required_keys: - raise KeyError( - f"CFG Key {key} is not a valid key. Valid are {required_keys}" - ) + raise KeyError(f"CFG Key {key} is not a valid key. Valid are {required_keys}") def _check_entry_types(cfg: DotMap, major_categories: list) -> None: @@ -65,9 +63,7 @@ def _check_entry_types(cfg: DotMap, major_categories: list) -> None: for key in float_keys: for category in major_categories: - if key in cfg[category].keys() and not isinstance( - cfg[category][key], float - ): + if key in cfg[category].keys() and not isinstance(cfg[category][key], float): raise TypeError(f"{key} must be a float") for key in boolean_keys: @@ -88,7 +84,8 @@ def _check_entry_types(cfg: DotMap, major_categories: list) -> None: def _check_value_ranges(cfg: DotMap, major_categories: list) -> None: """Check that all values in the config are within the correct range. - This throws runtime errors as ValueErrors are caught in training to avoid NaNs crashing the training.""" + This throws runtime errors as ValueErrors are caught in training to avoid NaNs crashing the training. + """ # fmt: off positive_value_keys = ["dt","activity_timestep","time_multiplier","logging_interval",] # noqa diff --git a/paseos/utils/load_default_cfg.py b/paseos/utils/load_default_cfg.py index eb310d21..3459b42f 100644 --- a/paseos/utils/load_default_cfg.py +++ b/paseos/utils/load_default_cfg.py @@ -7,9 +7,7 @@ def load_default_cfg(): """Loads the default toml config file from the cfg folder.""" - path = os.path.join( - os.path.dirname(__file__) + "/../resources/", "default_cfg.toml" - ) + path = os.path.join(os.path.dirname(__file__) + "/../resources/", "default_cfg.toml") logger.debug(f"loading default cfg from path: {path}") with open(path) as cfg: diff --git a/paseos/utils/operations_monitor.py b/paseos/utils/operations_monitor.py index 799df9d3..51850e47 100644 --- a/paseos/utils/operations_monitor.py +++ b/paseos/utils/operations_monitor.py @@ -28,6 +28,7 @@ def __init__(self, actor_name): self._log.known_actors = [] self._log.position = [] self._log.velocity = [] + self._log.custom_properties = DotMap(_dynamic=False) def __getitem__(self, item): """Get a logged attributes values. @@ -36,18 +37,21 @@ def __getitem__(self, item): item (str): Name of item. Available are "timesteps","current_activity","state_of_charge", "is_in_eclipse","known_actors","position","velocity","temperature" """ - assert item in self._log.keys(), ( - 'Untracked quantity. Available are "timesteps","current_activity","state_of_charge",' - + '"is_in_eclipse","known_actors","position","velocity","temperature"' - ) + assert item in ( + list(self._log.keys()) + list(self._log.custom_properties.keys()) + ), f"Untracked quantity. Available are {self._log.keys() + self._log.custom_properties.keys()}" + if item in self._log.custom_properties.keys(): + return self._log.custom_properties[item] return self._log[item] def plot(self, item): - assert item in self._log.keys(), ( - 'Untracked quantity. Available are "timesteps","current_activity","state_of_charge",' - + '"is_in_eclipse","known_actors","position","velocity","temperature"' - ) - values = self._log[item] + assert item in ( + list(self._log.keys()) + list(self._log.custom_properties.keys()) + ), f"Untracked quantity. Available are {self._log.keys() + self._log.custom_properties.keys()}" + if item in self._log.custom_properties.keys(): + values = self._log.custom_properties[item] + else: + values = self._log[item] plt.Figure(figsize=(6, 2), dpi=150) t = self._log.timesteps plt.plot(t, values) @@ -66,9 +70,7 @@ def log( known_actors (list): List of names of the known actors. """ logger.trace("Logging iteration") - assert local_actor.name == self._actor_name, ( - "Expected actor's name was" + self._actor_name - ) + assert local_actor.name == self._actor_name, "Expected actor's name was" + self._actor_name self._log.timesteps.append(local_actor.local_time.mjd2000 * pk.DAY2SEC) self._log.current_activity.append(local_actor.current_activity) self._log.position.append(local_actor._previous_position) @@ -88,6 +90,13 @@ def log( else: self._log.is_in_eclipse.append(local_actor._previous_eclipse_status) + # Track all custom properties + for key, value in local_actor.custom_properties.items(): + if key not in self._log.custom_properties.keys(): + logger.info(f"Property {key} was not tracked beforem, adding now.") + self._log.custom_properties[key] = [] + self._log.custom_properties[key].append(value) + def save_to_csv(self, filename): """Write the created log file to a csv file. @@ -96,7 +105,7 @@ def save_to_csv(self, filename): """ logger.trace("Writing status log file to " + filename) with open(filename, "w", newline="") as f: - w = csv.DictWriter(f, self._log.keys()) + w = csv.DictWriter(f, list(self._log.keys()) + list(self._log.custom_properties.keys())) w.writeheader() for i in range(len(self._log.timesteps)): row = { @@ -109,4 +118,17 @@ def save_to_csv(self, filename): "state_of_charge": self._log.state_of_charge[i], "is_in_eclipse": self._log.is_in_eclipse[i], } + # Append custom properties + for key, value in self._log.custom_properties.items(): + # If quantity only started to be track during simulation + # we need to fill the previous values with None + if len(value) < len(self._log.timesteps): + # Add None to the beginning of the list + if i < len(self._log.timesteps) - len(value): + row[key] = None + else: + row[key] = value[i - (len(self._log.timesteps) - len(value))] + else: + row[key] = value[i] + w.writerow(row) diff --git a/paseos/utils/reference_frame.py b/paseos/utils/reference_frame.py new file mode 100644 index 00000000..2199858e --- /dev/null +++ b/paseos/utils/reference_frame.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class ReferenceFrame(Enum): + """Enum for used reference frames.""" + + # CentralBodyInertial Reference Frame (i.e. non-rotating) + CentralBodyInertial = 1 + # Heliocentric Reference Frame (i.e. Sun Centered Inertial, non-rotating) + Heliocentric = 2 diff --git a/paseos/visualization/plot.py b/paseos/visualization/plot.py index 580cae60..59774ad7 100644 --- a/paseos/visualization/plot.py +++ b/paseos/visualization/plot.py @@ -12,12 +12,13 @@ class PlotType(Enum): SpacePlot = 1 -def plot(sim: PASEOS, plot_type: PlotType): +def plot(sim: PASEOS, plot_type: PlotType, filename: str = None): """Creates the animation object Args: sim (PASEOS): simulation object plot_type (PlotType): enum deciding what kind of plot object to be made + filename (str, optional): filename to save the animation to. Defaults to None. Raises: ValueError: supplied plot type not supported @@ -26,8 +27,6 @@ def plot(sim: PASEOS, plot_type: PlotType): Animation: Animation object """ if plot_type is PlotType.SpacePlot: - return SpaceAnimation(sim) + return SpaceAnimation(sim, filename=filename) else: - raise ValueError( - f"PlotType {plot_type} not known. Available are {[e for e in PlotType]}" - ) + raise ValueError(f"PlotType {plot_type} not known. Available are {[e for e in PlotType]}") diff --git a/paseos/visualization/space_animation.py b/paseos/visualization/space_animation.py index 6ea72924..013bcada 100644 --- a/paseos/visualization/space_animation.py +++ b/paseos/visualization/space_animation.py @@ -18,26 +18,26 @@ class SpaceAnimation(Animation): """This class visualizes the central body, local actor and known actors over time.""" - def __init__(self, sim: PASEOS, n_trajectory: int = 32) -> None: + def __init__(self, sim: PASEOS, n_trajectory: int = 32, filename: str = None) -> None: """Initialize the space animation object Args: sim (PASEOS): simulation object n_trajectory (int): number of samples in tail of actor + filename (str, optional): filename to save the animation to. Defaults to None. """ super().__init__(sim) logger.debug("Initializing animation") + self.comm_lines = [] + # Create list of objects to be plotted current_actors = self._make_actor_list(sim) - self._norm_coeff = self._local_actor._central_body.radius for known_actor in current_actors: - pos = known_actor.get_position(self._local_actor.local_time) - pos_norm = [x / self._norm_coeff for x in pos] - self.objects.append(DotMap(actor=known_actor, positions=np.array(pos_norm))) + pos = np.array(known_actor.get_position(self._local_actor.local_time)) + self.objects.append(DotMap(actor=known_actor, positions=pos)) with plt.style.context("dark_background"): - # Create figure for 3d animation self.fig, default_ax = plt.subplots( 1, @@ -85,6 +85,7 @@ def __init__(self, sim: PASEOS, n_trajectory: int = 32) -> None: self._plot_actors() los_matrix = self._get_los_matrix(current_actors) self._plot_los(los_matrix) + self._plot_comm_lines() # Write text labels self.date_label = plt.annotate( @@ -103,16 +104,64 @@ def __init__(self, sim: PASEOS, n_trajectory: int = 32) -> None: plt.show() plt.pause(0.0001) - def _plot_central_body(self) -> None: - """Plot the central object as a sphere of radius 1""" - central_body = self._local_actor._central_body - central_body.radius + if filename is not None: + logger.debug("Saving figure to file " + filename) + plt.savefig(filename, dpi=300, bbox_inches="tight") + + def _plot_comm_lines(self): + # Clear old + for idx in range(len(self.comm_lines)): + self.comm_lines[idx][0].set_visible(False) + del self.comm_lines + self.comm_lines = [] + + # Create lines between connected actors + for i in range(len(self.objects)): + for j in range(i + 1, len(self.objects)): + if isinstance(self.objects[i].actor, GroundstationActor) and isinstance( + self.objects[j].actor, GroundstationActor + ): + continue + elif self.objects[i].actor.is_in_line_of_sight( + self.objects[j].actor, self.objects[i].actor.local_time + ): + pos_i, pos_j = self.objects[i].positions, self.objects[j].positions + if isinstance(pos_i[0], np.ndarray): + pos_i = pos_i[-1] + pos_j = pos_j[-1] + x1x2 = [pos_i[0], pos_j[0]] + y1y2 = [pos_i[1], pos_j[1]] + z1z2 = [pos_i[2], pos_j[2]] + + self.comm_lines.append( + self.ax_3d.plot3D( + x1x2, y1y2, z1z2, "--", color="green", linewidth=0.5, zorder=10 + ) + ) - u, v = np.mgrid[0 : 2 * np.pi : 30j, 0 : np.pi : 20j] - x = np.cos(u) * np.sin(v) - y = np.sin(u) * np.sin(v) - z = np.cos(v) - self.ax_3d.plot_surface(x, y, z, color="blue", alpha=0.5) + def _plot_central_body(self) -> None: + """Plot the central object""" + + # Plot mesh if available + if self._local_actor._central_body._mesh is not None: + mesh_points, mesh_triangles = self._local_actor._central_body._mesh + self.ax_3d.plot_trisurf( + mesh_points[:, 0], + mesh_points[:, 1], + mesh_points[:, 2], + triangles=mesh_triangles, + edgecolor=[[0.75, 0.75, 0.75]], + linewidth=0.2, + alpha=0.0, + shade=False, + ) + else: + radius = self._local_actor._central_body._planet.radius + u, v = np.mgrid[0 : 2 * np.pi : 30j, 0 : np.pi : 20j] + x = np.cos(u) * np.sin(v) * radius + y = np.sin(u) * np.sin(v) * radius + z = np.cos(v) * radius + self.ax_3d.plot_surface(x, y, z, color="blue", alpha=0.5) def _get_los_matrix(self, current_actors: List[BaseActor]) -> np.ndarray: """Compute line-of-sight (LOS) between all actors @@ -129,9 +178,7 @@ def _get_los_matrix(self, current_actors: List[BaseActor]) -> np.ndarray: for i, a1 in enumerate(current_actors): for j, a2 in enumerate(current_actors[i + 1 :]): # Skip LOS between groundstations (leads to crash) - if isinstance(a1, GroundstationActor) and isinstance( - a2, GroundstationActor - ): + if isinstance(a1, GroundstationActor) and isinstance(a2, GroundstationActor): continue elif a1.is_in_line_of_sight(a2, local_time) is True: los_matrix[i, j + i + 1] = 1.0 @@ -156,11 +203,12 @@ def _populate_textbox(self, actor: BaseActor) -> str: info_str += f"\nBattery: {battery_level:.0f}%" if actor.has_thermal_model: - info_str += f"\nTemperature: {actor.temperature_in_K:.2f}K,{actor.temperature_in_K-273.15:.2f}C" + info_str += f"\nTemp.: {actor.temperature_in_K-273.15:.2f}C" - for name in actor.communication_devices.keys(): - info = actor.communication_devices[name] - info_str += f"\nCommDevice1: {info.bandwidth_in_kbps} kbps" + # Disabled for now as fixed values atm + # for name in actor.communication_devices.keys(): + # info = actor.communication_devices[name] + # info_str += f"\nCommDevice1: {info.bandwidth_in_kbps} kbps" else: raise NotImplementedError( "SpacePlot is currently not implemented for actor type" + type(actor) @@ -197,9 +245,9 @@ def _plot_actors(self) -> None: data = obj.positions if data.ndim == 1: data = data[..., np.newaxis].T + logger.trace(f"Position for object: {data}") n_points = np.minimum(data.shape[0], self.n_trajectory) - logger.trace(f"Position for object: {data}") if "plot" in obj.keys(): # spacecraft and ground stations behave differently and are plotted separately if isinstance(obj.actor, SpacecraftActor) or isinstance( @@ -223,9 +271,7 @@ def _plot_actors(self) -> None: if isinstance(obj.actor, SpacecraftActor) or isinstance( obj.actor, GroundstationActor ): - trajectory = self.ax_3d.plot3D(data[0, 0], data[0, 1], data[0, 2])[ - 0 - ] + trajectory = self.ax_3d.plot3D(data[0, 0], data[0, 1], data[0, 2])[0] obj.plot.trajectory = trajectory obj.plot.point = self.ax_3d.plot( data[0, 0], @@ -301,9 +347,7 @@ def update(self, sim: PASEOS, creating_animation=False) -> None: # if known_actor does not exist in objects, add the actors and update in plot objects_to_add = list(current_actors.difference(objects_in_plot)) - plot_objects_to_remove = [ - x for x in self.objects if x.actor in objects_to_remove - ] + plot_objects_to_remove = [x for x in self.objects if x.actor in objects_to_remove] for obj in plot_objects_to_remove: obj.plot.trajectory.remove() obj.plot.point.remove() @@ -318,16 +362,15 @@ def update(self, sim: PASEOS, creating_animation=False) -> None: for known_actor in current_actors: for obj in self.objects: if obj.actor == known_actor: - pos = known_actor.get_position(self._local_actor.local_time) - pos_norm = [x / self._norm_coeff for x in pos] + pos = np.array(known_actor.get_position(self._local_actor.local_time)) if "positions" in obj: if obj.positions.shape[0] > self.n_trajectory: obj.positions = np.roll(obj.positions, shift=-1, axis=0) - obj.positions[-1, :] = pos_norm + obj.positions[-1, :] = pos else: - obj.positions = np.vstack((obj.positions, pos_norm)) + obj.positions = np.vstack((obj.positions, pos)) else: - obj.positions = np.array(pos_norm) + obj.positions = np.array(pos) self._plot_actors() # Step through trajectories to find max and min values in each direction @@ -352,6 +395,7 @@ def update(self, sim: PASEOS, creating_animation=False) -> None: # Update LOS heatmap current_actors = list(current_actors) los_matrix = self._get_los_matrix(current_actors) + self._plot_comm_lines() self._los_plot.set_data(los_matrix) xaxis = np.arange(len(current_actors)) self.ax_los.set_xticks(xaxis) @@ -371,9 +415,7 @@ def update(self, sim: PASEOS, creating_animation=False) -> None: except RuntimeError: logger.trace("Animation date label could not be updated.") - self.time_label.set_text( - f"t={sim._state.time - sim._cfg.sim.start_time:<10.2e}" - ) + self.time_label.set_text(f"t={sim._state.time - sim._cfg.sim.start_time:<10.2e}") logger.debug("Plot updated.") @@ -404,9 +446,7 @@ def _animation_wrapper(self, step: int, sim: PASEOS, dt: float) -> List[Artist]: """ return self._animate(sim, dt) - def animate( - self, sim: PASEOS, dt: float, steps: int = 1, save_to_file: str = None - ) -> None: + def animate(self, sim: PASEOS, dt: float, steps: int = 1, save_to_file: str = None) -> None: """Animates paseos for a given number of steps with dt in each step. Args: diff --git a/requirements.txt b/requirements.txt index 5fdb6817..6ec5b862 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ loguru>=0.6.0 matplotlib>=3.6.0 numpy==1.23.5 pykep>=2.6 +pyquaternion>=0.9.9 scikit-spatial>=6.5.0 skyfield>=1.45 toml>=0.10.2 diff --git a/resources/images/sat_gif.gif b/resources/images/sat_gif.gif index 9c40d5e0..5d49eda1 100644 Binary files a/resources/images/sat_gif.gif and b/resources/images/sat_gif.gif differ diff --git a/setup.py b/setup.py index 2ea359f1..38d1435c 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ "matplotlib>=3.6.0", "numpy==1.23.5", "pykep>=2.6", + "pyquaternion>=0.9.9", "scikit-spatial>=6.5.0", "skyfield>=1.45", "toml>=0.10.2",