├── MANIFEST.in ├── space ├── __init__.py ├── tle │ ├── common.py │ ├── spacetrack.py │ ├── celestrak.py │ ├── __init__.py │ └── db.py ├── static │ ├── earth.png │ ├── mars.png │ ├── moon.png │ └── venus.png ├── map │ ├── background.py │ ├── __init__.py │ ├── wephem.py │ └── map.py ├── clock.py ├── phase.py ├── events.py ├── station.py ├── __main__.py ├── planet.py ├── utils.py ├── wspace.py ├── passes.py ├── config.py └── sat.py ├── setup.py ├── readthedocs.yml ├── tests ├── data │ └── de403_2000-2020.bsp ├── test_utils.py ├── conftest.py ├── test_main.py ├── test_planets.py ├── test_clock.py ├── test_tle.py ├── test_passes.py ├── test_config.py ├── test_ccsds.py └── test_sat.py ├── .gitignore ├── tox.ini ├── doc ├── Makefile └── source │ ├── conf.py │ └── index.rst ├── LICENSE.md ├── setup.cfg └── README.rst /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include space/static/* -------------------------------------------------------------------------------- /space/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.7.3" 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | setup() 3 | -------------------------------------------------------------------------------- /space/tle/common.py: -------------------------------------------------------------------------------- 1 | from ..wspace import ws 2 | 3 | TMP_FOLDER = ws.folder / "tmp" / "tle" 4 | -------------------------------------------------------------------------------- /space/static/earth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/galactics/space-command/HEAD/space/static/earth.png -------------------------------------------------------------------------------- /space/static/mars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/galactics/space-command/HEAD/space/static/mars.png -------------------------------------------------------------------------------- /space/static/moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/galactics/space-command/HEAD/space/static/moon.png -------------------------------------------------------------------------------- /space/static/venus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/galactics/space-command/HEAD/space/static/venus.png -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | version: 3.7 5 | install: 6 | - method: pip 7 | path: . 8 | -------------------------------------------------------------------------------- /tests/data/de403_2000-2020.bsp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/galactics/space-command/HEAD/tests/data/de403_2000-2020.bsp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.egg-info 3 | dist/ 4 | build/ 5 | htmlcov/ 6 | doc/build/ 7 | .tox/ 8 | .pytest_cache/ 9 | .coverage 10 | .coverage.* 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, py39, py310, py311 3 | 4 | [testenv] 5 | skip_install = true 6 | commands = 7 | pip install -e .[tests] 8 | pytest {posargs} 9 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pytest import mark 3 | 4 | from space.utils import circle 5 | 6 | 7 | @mark.skip 8 | def test_circle(): 9 | 10 | alt = 410000 + 6400000 11 | 12 | sat_circle = circle(alt, 0, 0.) 13 | 14 | 15 | assert len(sat_circle) == 360 16 | 17 | azims = np.array([0, 90, 122, 147, 164, 188, 203, 215, 239, 247]) 18 | elevs = np.array([80, 36, 2, 7.4, 9.5, 16.1, 10.7, 15.7, 20, 20.2]) 19 | 20 | mask = (2 * np.pi - np.radians(azims[::-1])), np.radians(elevs[::-1]) 21 | sat_circle = circle(alt, 0., 0., mask=mask) 22 | 23 | assert len(sat_circle) == 10 24 | 25 | sat_circle = circle(alt, 0, np.radians(70.)) 26 | 27 | assert len(sat_circle) == 360 28 | 29 | # import matplotlib.pyplot as plt 30 | # lon, lat = np.degrees(list(zip(*sat_circle))) 31 | # lon = ((lon + 180) % 360) - 180 32 | # plt.plot(lon, lat, '.', ms=2) 33 | # plt.xlim([-180, 180]) 34 | # plt.ylim([-90, 90]) 35 | # plt.show() -------------------------------------------------------------------------------- /space/map/background.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pathlib import Path 3 | import matplotlib.pyplot as plt 4 | 5 | from ..station import StationDb 6 | 7 | 8 | def set_background(stations="black"): 9 | """Display the map background (earth and stations) 10 | 11 | Args: 12 | stations (str): If non empty, provides the matplotlib color to be 13 | used for stations marks. To disable stations marks, set to 14 | ``False`` 15 | """ 16 | 17 | path = Path(__file__).parent.parent / "static/earth.png" 18 | im = plt.imread(str(path)) 19 | plt.imshow(im, extent=[-180, 180, -90, 90]) 20 | plt.xlim([-180, 180]) 21 | plt.ylim([-90, 90]) 22 | plt.grid(True, linestyle=":", alpha=0.4) 23 | plt.xticks(range(-180, 181, 30)) 24 | plt.yticks(range(-90, 91, 30)) 25 | 26 | if stations: 27 | for station in StationDb.list().values(): 28 | lat, lon = np.degrees(station.latlonalt[:-1]) 29 | plt.plot([lon], [lat], "+", color=stations) 30 | plt.text(lon + 1, lat + 1, station.abbr, color=stations) 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jules David 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /space/map/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | import matplotlib.pyplot as plt 4 | 5 | from ..utils import docopt, parse_date 6 | from ..sat import Sat 7 | 8 | from .map import MapAnim 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def space_map(*argv): 14 | """Animated map of earth with ground track of satellites 15 | 16 | Usage: 17 | space-map (- | ...) [options] 18 | 19 | Option: 20 | Name of the satellites you want to display. 21 | - If used, the orbit should be provided as stdin in 22 | TLE or CCSDS format 23 | -d, --date Date from which to start the animation [default: now] 24 | --no-ground-track Hide ground-track by default 25 | --no-circle hide circle of visibility by default 26 | """ 27 | 28 | args = docopt(space_map.__doc__) 29 | try: 30 | sats = list( 31 | Sat.from_command( 32 | *args[""], text=sys.stdin.read() if args["-"] else "" 33 | ) 34 | ) 35 | except ValueError as e: 36 | log.error(e) 37 | sys.exit(1) 38 | 39 | sat_anim = MapAnim( 40 | sats, 41 | parse_date(args["--date"]), 42 | groundtrack=not args["--no-ground-track"], 43 | circle=not args["--no-circle"], 44 | ) 45 | 46 | plt.show() 47 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | from io import StringIO 4 | from pytest import yield_fixture, fixture 5 | 6 | from space.wspace import switch_workspace 7 | from space.tle import TleDb 8 | from space.sat import sync 9 | 10 | 11 | @fixture 12 | def space_tmpdir(): 13 | 14 | name = 'tmp-pytest' 15 | 16 | with switch_workspace(name, init=True, delete=True) as ws: 17 | 18 | TleDb().insert("""ISS (ZARYA) 19 | 1 25544U 98067A 18297.55162980 .00001655 00000-0 32532-4 0 9999 20 | 2 25544 51.6407 94.0557 0003791 332.0725 138.3982 15.53858634138630""", src='stdin') 21 | 22 | # Synchronize the Satellite database with the TleDatabase 23 | sync() 24 | yield ws 25 | 26 | 27 | @fixture 28 | def run(script_runner, space_tmpdir): 29 | """Launch the space command in the dedicated tmp workdir 30 | """ 31 | 32 | def _run(args, stdin=None): 33 | 34 | # kwargs = {'cwd': str(space_tmpdir)} 35 | kwargs = {} 36 | 37 | if stdin: 38 | kwargs['stdin'] = StringIO(stdin) 39 | 40 | kwargs['env'] = os.environ.copy() 41 | kwargs['env']['SPACE_WORKSPACE'] = 'tmp-pytest' 42 | 43 | if isinstance(args, str): 44 | args = args.split() 45 | elif isinstance(args, tuple): 46 | args = list(args) 47 | 48 | # Disable colored output 49 | args.append("--no-color") 50 | 51 | return script_runner.run(*args, **kwargs) 52 | 53 | return _run 54 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import space 2 | import beyond 3 | 4 | 5 | def test_as_package(run): 6 | """Invocation of the space command via python packages 7 | 8 | use the ```if __name__ == "__main__"``` at the bottom of the 9 | __main__.py file 10 | """ 11 | 12 | r = run("python -m space") 13 | assert r.stdout 14 | assert not r.stderr 15 | assert not r.success 16 | 17 | 18 | def test_list_subcommands(run): 19 | """The space command without argument display the list of 20 | available subcommands and options 21 | """ 22 | 23 | r = run("space") 24 | 25 | data = {} 26 | mode = "subcommands" 27 | for line in r.stdout.splitlines()[3:]: 28 | if not line: 29 | continue 30 | elif line == "Available addons sub-commands :": 31 | mode = "addons" 32 | continue 33 | elif line == "Options :": 34 | mode = "options" 35 | continue 36 | if line[0] != " ": 37 | # If the line is not indented, then it's not a valid subcommand 38 | # or option, but a mere text 39 | continue 40 | 41 | subdict = data.setdefault(mode, {}) 42 | 43 | k, _, v = line.strip().partition(" ") 44 | subdict[k] = v.strip() 45 | 46 | assert list(sorted(data['subcommands'].keys())) == [ 47 | "clock", "config", "events", "log", "map", "oem", "opm", 48 | "passes", "phase", "planet", "sat", "station", "tle" 49 | ] 50 | 51 | assert list(sorted(data['options'].keys())) == [ 52 | "--no-color", "--pdb", "--version", "-v,", "-w," 53 | ] 54 | 55 | assert not r.stderr 56 | assert not r.success 57 | 58 | 59 | def test_version(run): 60 | 61 | r = run("space --version") 62 | 63 | lines = r.stdout.splitlines() 64 | 65 | assert len(lines) == 2 66 | assert lines[0].split() == ["space-command" , space.__version__] 67 | assert lines[1].split() == ["beyond" , beyond.__version__] 68 | 69 | assert not r.stderr 70 | assert not r.success 71 | -------------------------------------------------------------------------------- /space/tle/spacetrack.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | from .db import TleDb 5 | from .common import TMP_FOLDER 6 | from ..wspace import ws 7 | 8 | SPACETRACK_URL_AUTH = "https://www.space-track.org/ajaxauth/login" 9 | SPACETRACK_URL = "https://www.space-track.org/basicspacedata/query/class/tle_latest/{mode}/{selector}/orderby/ORDINAL%20asc/limit/1/format/3le/emptyresult/show" 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def fetch(*sats): 14 | 15 | try: 16 | auth = ws.config["spacetrack"] 17 | except KeyError: 18 | raise ValueError("No login information available for spacetrack") 19 | 20 | _conv = { 21 | "norad_id": "NORAD_CAT_ID", 22 | "cospar_id": "INTLDES", 23 | "name": "OBJECT_NAME", 24 | } 25 | 26 | log.debug("Authentication to Space-Track website") 27 | init = requests.post(SPACETRACK_URL_AUTH, auth) 28 | 29 | try: 30 | init.raise_for_status() 31 | except requests.exceptions.HTTPError as e: 32 | log.error("Authentication failed") 33 | log.exception(e) 34 | raise 35 | 36 | if init.text != '""': 37 | log.error("Authentication failed") 38 | log.debug("Response from authentication page '%s'", init.text) 39 | return 40 | 41 | log.debug("Authorized to proceed") 42 | 43 | text = "" 44 | for sat in sats: 45 | key = next(iter(sat.keys())) 46 | 47 | if key == "cospar_id": 48 | # The COSPAR ID should be formated as in TLE 49 | # i.e. "2019-097A" becomes "19097A" 50 | sat[key] = sat[key][2:].replace("-", "") 51 | 52 | url = SPACETRACK_URL.format(mode=_conv[key], selector=sat[key]) 53 | 54 | log.debug("Request at %s", url) 55 | full = requests.get(url, cookies=init.cookies) 56 | 57 | try: 58 | full.raise_for_status() 59 | except Exception as e: 60 | log.error(e) 61 | else: 62 | text += full.text 63 | 64 | cache = TMP_FOLDER / "spacetrack.txt" 65 | log.debug("Caching results into %s", cache) 66 | with cache.open("w") as fp: 67 | fp.write(text) 68 | 69 | TleDb().insert(text, "spacetrack.txt") 70 | -------------------------------------------------------------------------------- /tests/test_planets.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pytest import mark, fixture 3 | from pathlib import Path 4 | 5 | 6 | @fixture 7 | def jpl(run): 8 | r = run("space config unlock", stdin="yes") 9 | assert r.success 10 | r = run("space config set beyond.env.jpl.files --append {}".format(Path(__file__).parent / "data" / "de403_2000-2020.bsp")) 11 | assert r.success 12 | 13 | r = run("space clock set-date 2020-01-01T00:00:00.000") 14 | assert r.success 15 | 16 | yield run 17 | 18 | r = run("space clock sync") 19 | assert r.success 20 | 21 | 22 | def test_list_analytical(run): 23 | 24 | r = run("space planet") 25 | assert r.stdout == "List of all available bodies\n Sun\n Moon\n" 26 | assert not r.stderr 27 | assert r.success 28 | 29 | 30 | @mark.skipif(sys.version_info < (3,6), reason="Unpredictible order before 3.6") 31 | def test_list_jpl(jpl, run): 32 | 33 | r = run("space planet") 34 | assert r.stdout == """List of all available bodies 35 | EarthBarycenter 36 | ├─ SolarSystemBarycenter 37 | │ ├─ MercuryBarycenter 38 | │ │ └─ Mercury 39 | │ ├─ VenusBarycenter 40 | │ │ └─ Venus 41 | │ ├─ MarsBarycenter 42 | │ │ └─ Mars 43 | │ ├─ JupiterBarycenter 44 | │ ├─ SaturnBarycenter 45 | │ ├─ UranusBarycenter 46 | │ ├─ NeptuneBarycenter 47 | │ ├─ PlutoBarycenter 48 | │ └─ Sun 49 | ├─ Moon 50 | Earth 51 | 52 | """ 53 | 54 | assert not r.stderr 55 | assert r.success 56 | 57 | 58 | def test_ephem_analytical(run): 59 | 60 | r = run("space planet Sun") 61 | lines = r.stdout.splitlines() 62 | assert len(lines) == 89 63 | assert lines[0] == "CCSDS_OEM_VERS = 2.0" 64 | assert not r.stderr 65 | assert r.success 66 | 67 | r = run("space planet Mars") 68 | assert not r.stdout 69 | assert r.stderr == "Unknown body 'Mars'\n" 70 | assert not r.success 71 | 72 | 73 | def test_ephem_jpl(jpl, run): 74 | 75 | r = run("space planet Mars") 76 | lines = r.stdout.splitlines() 77 | assert len(lines) == 89 78 | assert lines[0] == "CCSDS_OEM_VERS = 2.0" 79 | assert not r.stderr 80 | assert r.success 81 | 82 | 83 | @mark.skip 84 | def test_fetch(run): 85 | pass 86 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = space-command 3 | version = attr: space.__version__ 4 | description = Space Command 5 | long_description = file: README.rst 6 | keywords = flight dynamic, satellite, space 7 | author = Jules David 8 | author_email = jules@onada.fr 9 | license = MIT License 10 | classifiers = 11 | Development Status :: 2 - Pre-Alpha 12 | Intended Audience :: Science/Research 13 | License :: OSI Approved :: MIT License 14 | Programming Language :: Python :: 3.7 15 | Programming Language :: Python :: 3.8 16 | Programming Language :: Python :: 3.9 17 | Programming Language :: Python :: 3.10 18 | Programming Language :: Python :: 3.11 19 | Topic :: Scientific/Engineering :: Astronomy 20 | Topic :: Scientific/Engineering :: Physics 21 | 22 | [options] 23 | packages = find: 24 | include_package_data = True 25 | zip_safe = False 26 | install_requires = 27 | beyond 28 | peewee 29 | requests 30 | aiohttp 31 | docopt 32 | matplotlib 33 | pyyaml 34 | beautifulsoup4 35 | 36 | [options.extras_require] 37 | tests = 38 | pytest 39 | pytest-cov 40 | pytest-console-scripts 41 | 42 | [options.entry_points] 43 | console_scripts = 44 | space = space.__main__:main 45 | wspace = space.wspace:wspace 46 | space.commands = 47 | clock = space.clock:space_clock 48 | config = space.config:space_config 49 | events = space.events:space_events 50 | oem = space.ccsds:space_oem 51 | opm = space.ccsds:space_opm 52 | log = space.config:space_log 53 | map = space.map:space_map 54 | passes = space.passes:space_passes 55 | phase = space.phase:space_phase 56 | planet = space.planet:space_planet 57 | sat = space.sat:space_sat 58 | station = space.station:space_station 59 | tle = space.tle:space_tle 60 | space.wshook = 61 | 00 = space.config:wshook 62 | 10 = space.tle:wshook 63 | 20 = space.station:wshook 64 | 30 = space.sat:wshook 65 | 66 | [tool:pytest] 67 | addopts = -v --cov space --cov-report html --doctest-modules space/ tests/ 68 | script_launch_mode = subprocess 69 | filterwarnings = 70 | ignore: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated, and in 3.8 it will stop working -------------------------------------------------------------------------------- /space/clock.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | from beyond.dates import Date as LegacyDate, timedelta 5 | 6 | from .wspace import ws 7 | from .utils import parse_timedelta 8 | 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class Date(LegacyDate): 14 | 15 | CONFIG_FIELD = "clock-offset" 16 | 17 | @classmethod 18 | def _clock_offset(cls): 19 | return timedelta(seconds=ws.config.get(cls.CONFIG_FIELD, fallback=0)) 20 | 21 | @classmethod 22 | def now(cls, *args, **kwargs): 23 | return cls(super().now(*args, **kwargs) + cls._clock_offset()) 24 | 25 | 26 | def sync(): 27 | """Synchronise the system date and the clock date""" 28 | ws.config.set(Date.CONFIG_FIELD, 0, save=True) 29 | log.info("Clock set to system time") 30 | 31 | 32 | def set_date(date, ref): 33 | """ 34 | Args: 35 | date (Date) 36 | ref (Date) 37 | """ 38 | 39 | # the timedelta is here to take the UTC-TAI into account 40 | # see beyond.dates.date for informations 41 | offset = date - ref - timedelta(seconds=date._offset - ref._offset) 42 | ws.config.set(Date.CONFIG_FIELD, offset.total_seconds(), save=True) 43 | log.info("Clock date set to {}".format(date)) 44 | 45 | 46 | def set_offset(offset): 47 | """ 48 | Args 49 | offset (timedelta) 50 | """ 51 | ws.config.set(Date.CONFIG_FIELD, offset.total_seconds(), save=True) 52 | log.info("Clock offset set to {}".format(offset)) 53 | 54 | 55 | def space_clock(*argv): 56 | """Time control 57 | 58 | Usage: 59 | space-clock 60 | space-clock sync 61 | space-clock set-date [] 62 | space-clock set-offset 63 | 64 | Options: 65 | sync Set the time to be the same as the system 66 | set-date Define the date 67 | set-offset Define offset 68 | New date to set (%Y-%m-%dT%H:%M:%S.%f) 69 | Date at witch the new date is set (same format as ). 70 | If absent, the current system time is used 71 | Offset in seconds 72 | """ 73 | 74 | from space.utils import docopt 75 | 76 | args = docopt(space_clock.__doc__, options_first=True) 77 | 78 | if args["sync"]: 79 | sync() 80 | print(file=sys.stderr) 81 | elif args["set-date"]: 82 | if args[""] is None: 83 | ref = LegacyDate.now() 84 | else: 85 | ref = LegacyDate.strptime(args[""], "%Y-%m-%dT%H:%M:%S.%f") 86 | date = LegacyDate.strptime(args[""], "%Y-%m-%dT%H:%M:%S.%f") 87 | 88 | set_date(date, ref) 89 | 90 | print(file=sys.stderr) 91 | elif args["set-offset"]: 92 | offset = parse_timedelta(args[""], negative=True) 93 | set_offset(offset) 94 | print(file=sys.stderr) 95 | 96 | now = Date.now() 97 | print("System Date : {}".format(now - Date._clock_offset())) 98 | print("Clock Date : {}".format(now)) 99 | print("Offset : {}".format(now._clock_offset())) 100 | -------------------------------------------------------------------------------- /tests/test_clock.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def test_display(run): 4 | 5 | r = run("space clock") 6 | 7 | lines = r.stdout.splitlines() 8 | 9 | assert len(lines) == 3 10 | assert lines[0].startswith("System Date") 11 | assert lines[1].startswith("Clock Date") 12 | # System date and clock date are identical 13 | assert lines[0].split(":")[-1].strip() == lines[1].split(":")[-1].strip() 14 | assert lines[2] == "Offset : 0:00:00" 15 | 16 | assert not r.stderr 17 | assert r.success 18 | 19 | 20 | def test_set_date(run): 21 | 22 | # Set the clock to a future, happy date 23 | r = run("space clock set-date 2018-12-25T00:00:00.000000 2018-11-01T00:00:00.000000") 24 | 25 | assert r.stderr == "Clock date set to 2018-12-25T00:00:00 UTC\n\n" 26 | 27 | lines = r.stdout.splitlines() 28 | assert len(lines) == 3 29 | assert lines[0].startswith("System Date") 30 | assert lines[1].startswith("Clock Date") 31 | assert lines[2] == "Offset : 54 days, 0:00:00" 32 | assert r.success 33 | 34 | # For this test case, as it is dependant of the system time, 35 | # it is very hard to test 36 | r = run("space clock set-date 2018-12-25T00:00:00.000000") 37 | 38 | assert r.stderr.startswith("Clock date set to 2018-12-25T00:00:00 UTC") 39 | 40 | lines = r.stdout.splitlines() 41 | assert len(lines) == 3 42 | assert lines[0].startswith("System Date") 43 | assert lines[1].startswith("Clock Date") 44 | assert lines[2].startswith("Offset") 45 | assert r.success 46 | 47 | 48 | def test_set_offset(run): 49 | 50 | r = run("space clock set-offset 500s") 51 | 52 | assert r.stderr == "Clock offset set to 0:08:20\n\n" 53 | 54 | lines = r.stdout.splitlines() 55 | assert len(lines) == 3 56 | assert lines[0].startswith("System Date") 57 | assert lines[1].startswith("Clock Date") 58 | assert lines[2] == "Offset : 0:08:20" 59 | assert r.success 60 | 61 | # Negative offset 62 | r = run("space clock set-offset -500s") 63 | 64 | assert r.stderr.startswith("Clock offset set to -1 day, 23:51:40\n\n") 65 | 66 | lines = r.stdout.splitlines() 67 | assert len(lines) == 3 68 | assert lines[0].startswith("System Date") 69 | assert lines[1].startswith("Clock Date") 70 | assert lines[2] == "Offset : -1 day, 23:51:40" 71 | assert r.success 72 | 73 | 74 | def test_set_then_sync(run): 75 | 76 | # Set the clock to a future, happy date 77 | r = run("space clock set-date 2018-12-25T00:00:00.000000 2018-11-01T00:00:00.000000") 78 | 79 | assert r.stderr == "Clock date set to 2018-12-25T00:00:00 UTC\n\n" 80 | 81 | lines = r.stdout.splitlines() 82 | assert len(lines) == 3 83 | assert lines[0].startswith("System Date") 84 | assert lines[1].startswith("Clock Date") 85 | assert lines[2] == "Offset : 54 days, 0:00:00" 86 | assert r.success 87 | 88 | r = run("space clock sync") 89 | lines = r.stdout.splitlines() 90 | 91 | assert len(lines) == 3 92 | assert lines[0].startswith("System Date") 93 | assert lines[1].startswith("Clock Date") 94 | # System date and clock date are identical 95 | assert lines[0].split(":")[-1].strip() == lines[1].split(":")[-1].strip() 96 | assert lines[2] == "Offset : 0:00:00" 97 | 98 | assert r.stderr == "Clock set to system time\n\n" 99 | assert r.success 100 | -------------------------------------------------------------------------------- /tests/test_tle.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | from pathlib import Path 3 | 4 | 5 | tle1 = """ISS (ZARYA) 6 | 1 25544U 98067A 18297.55162980 .00001655 00000-0 32532-4 0 9999 7 | 2 25544 51.6407 94.0557 0003791 332.0725 138.3982 15.53858634138630""" 8 | 9 | tle2 = """ISS (ZARYA) 10 | 1 25544U 98067A 17343.27310274 .00004170 00000-0 70208-4 0 9997 11 | 2 25544 51.6420 245.4915 0003135 211.8338 242.8677 15.54121086 88980""" 12 | 13 | 14 | def test_get(run): 15 | 16 | r = run(("space", "tle", "get", "ISS (ZARYA)")) 17 | assert not r.stderr 18 | assert r.stdout.strip() == tle1 19 | assert r.success 20 | 21 | 22 | r = run("space tle get cospar=1998-067A") 23 | assert not r.stderr 24 | assert r.stdout.strip() == tle1 25 | assert r.success 26 | 27 | 28 | r = run("space tle get norad=25544") 29 | assert not r.stderr 30 | assert r.stdout.strip() == tle1 31 | assert r.success 32 | 33 | 34 | r = run("space tle get UNKNOWN") 35 | assert not r.success 36 | assert r.stderr == "No satellite corresponding to name=UNKNOWN\n" 37 | assert not r.stdout 38 | 39 | 40 | def test_insert(run): 41 | 42 | filepath = Path(__file__).parent / "data" / "visual.txt" 43 | 44 | r = run("space tle insert {}".format(filepath.absolute())) 45 | assert r.stderr.startswith("visual.txt") 46 | assert not r.stdout 47 | assert r.success 48 | 49 | # globbing 50 | filepath = Path(__file__).parent / "data" / "*.txt" 51 | 52 | r = run("space tle insert {}".format(filepath.absolute())) 53 | assert not r.stdout 54 | assert r.stderr.startswith("visual.txt") 55 | assert r.success 56 | 57 | # from STDIN 58 | new_tle = tle2 59 | 60 | r = run("space tle insert -", stdin=new_tle) 61 | assert r.success 62 | assert not r.stdout 63 | assert r.stderr.startswith("stdin") 64 | 65 | 66 | def test_find(run): 67 | 68 | r = run("space tle find zarya") 69 | assert r.stderr == "==> 1 entries found for 'zarya'\n" 70 | assert r.stdout.strip() == tle1 71 | assert r.success 72 | 73 | r = run("space tle find unknown") 74 | assert r.stderr == "No TLE containing 'unknown'\n" 75 | assert not r.stdout 76 | assert not r.success 77 | 78 | 79 | def test_stats(run): 80 | 81 | r = run("space tle stats") 82 | assert not r.stderr 83 | 84 | data = {} 85 | for line in r.stdout.splitlines(): 86 | k, _, v = line.partition(":") 87 | data[k.strip().lower()] = v.strip() 88 | 89 | assert int(data["objects"]) >= 1 90 | assert int(data["tle"]) >= 1 91 | assert data['first fetch'] 92 | assert data['last fetch'] 93 | 94 | assert r.success 95 | 96 | 97 | def test_dump(run): 98 | 99 | r = run("space tle dump") 100 | 101 | assert not r.stderr 102 | assert len(r.stdout.splitlines()) == 4 # Exactly one TLE 103 | assert r.stdout.strip() == tle1 104 | assert r.success 105 | 106 | 107 | def test_history(run): 108 | 109 | r = run("space tle insert - ", stdin=tle2) 110 | assert r.success 111 | 112 | r = run("space tle history norad=25544") 113 | assert not r.stderr 114 | assert len(r.stdout.splitlines()) == 8 115 | assert r.success 116 | 117 | r = run("space tle history UNKNOWN") 118 | assert r.stderr == "No satellite corresponding to name=UNKNOWN\n" 119 | assert not r.stdout 120 | assert not r.success 121 | 122 | 123 | @mark.skip 124 | def test_celestrak_fetch(run): 125 | pass 126 | 127 | 128 | @mark.skip 129 | def test_celestrak_fetch_list(run): 130 | pass 131 | 132 | 133 | @mark.skip 134 | def test_spacetrack_fetch(run): 135 | pass 136 | -------------------------------------------------------------------------------- /tests/test_passes.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | 4 | @fixture 5 | def run_date(run): 6 | run("space clock set-date 2018-11-01T09:00:00.000000") 7 | return run 8 | 9 | 10 | def test_simple(run_date): 11 | 12 | r = run_date("space passes TLS ISS --csv") 13 | lines = r.stdout.strip().splitlines() 14 | assert len(lines) == 26 15 | assert lines[1].split(',')[1] == "AOS 0 TLS" 16 | assert lines[13].split(',')[1] == "MAX TLS" 17 | assert lines[-1].split(',')[1] == "LOS 0 TLS" 18 | assert not r.stderr 19 | assert r.success 20 | 21 | # Call containing two satellites (here it is the same one) 22 | r = run_date("space passes TLS ISS ISS --csv") 23 | lines = r.stdout.strip().splitlines() 24 | assert len(lines) == 53 25 | assert lines[1].split(',')[1] == "AOS 0 TLS" 26 | assert lines[13].split(',')[1] == "MAX TLS" 27 | assert lines[-1].split(',')[1] == "LOS 0 TLS" 28 | assert not r.stderr 29 | assert r.success 30 | 31 | r = run_date("space passes UNKNOWN ISS --csv") 32 | lines = r.stdout.strip().splitlines() 33 | assert r.stderr == "Unknwon station 'UNKNOWN'\n" 34 | assert not r.stdout 35 | assert not r.success 36 | 37 | r = run_date("space passes TLS UNKNOWN --csv") 38 | lines = r.stdout.strip().splitlines() 39 | assert r.stderr == "No satellite corresponding to name=UNKNOWN\n" 40 | assert not r.stdout 41 | assert not r.success 42 | 43 | 44 | def test_date(run_date): 45 | 46 | r = run_date("space passes TLS ISS --date 2018-11-01T11:00:00 --csv") 47 | 48 | lines = r.stdout.strip().splitlines() 49 | 50 | assert len(lines) == 22 51 | assert lines[1].split(',')[1] == "AOS 0 TLS" 52 | assert lines[11].split(',')[1] == "MAX TLS" 53 | assert lines[-1].split(',')[1] == "LOS 0 TLS" 54 | assert not r.stderr 55 | assert r.success 56 | 57 | r = run_date("space passes TLS ISS --date 2018-11-01 11:00:00 --csv") 58 | assert not r.success 59 | assert r.stderr == "No satellite corresponding to name=11:00:00\n" 60 | assert not r.stdout 61 | 62 | 63 | def test_step(run_date): 64 | 65 | r = run_date("space passes TLS ISS --step 10s --csv") 66 | 67 | lines = r.stdout.strip().splitlines() 68 | 69 | assert len(lines) == 68 70 | assert lines[1].split(',')[1] == "AOS 0 TLS" 71 | assert lines[34].split(',')[1] == "MAX TLS" 72 | assert lines[-1].split(',')[1] == "LOS 0 TLS" 73 | assert not r.stderr 74 | assert r.success 75 | 76 | r = run_date("space passes TLS ISS -s hello --csv") 77 | assert not r.success 78 | assert r.stderr == "No timedelta found in 'hello'\n" 79 | assert not r.stdout 80 | 81 | 82 | def test_events_only(run_date): 83 | 84 | r = run_date("space passes TLS ISS --events-only --csv") 85 | 86 | lines = r.stdout.strip().splitlines() 87 | 88 | assert len(lines) == 4 89 | assert lines[1].split(',')[1] == "AOS 0 TLS" 90 | assert lines[2].split(',')[1] == "MAX TLS" 91 | assert lines[3].split(',')[1] == "LOS 0 TLS" 92 | assert not r.stderr 93 | assert r.success 94 | 95 | 96 | def test_no_events(run_date): 97 | 98 | r = run_date("space passes TLS ISS --no-events --csv") 99 | lines = r.stdout.strip().splitlines() 100 | assert not lines[2].startswith("AOS 0 TLS") 101 | assert not lines[-1].startswith("LOS 0 TLS") 102 | assert not r.stderr 103 | assert r.success 104 | 105 | 106 | def test_light(run_date): 107 | 108 | # This test is computed for an other pass, in order to actually compute 109 | # the 'light' events 110 | r = run_date("space passes TLS ISS --events-only --light --date 2018-11-01T03:00:00 --csv") 111 | 112 | lines = r.stdout.strip().splitlines() 113 | 114 | assert len(lines) == 6 115 | assert lines[1].split(',')[1] == "AOS 0 TLS" 116 | assert lines[2].split(',')[1] == "MAX TLS" 117 | assert lines[3].split(',')[1] == "Umbra exit" 118 | assert lines[4].split(',')[1] == "Penumbra exit" 119 | assert lines[5].split(',')[1] == "LOS 0 TLS" 120 | 121 | assert not r.stderr 122 | assert r.success -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import pytest 4 | 5 | @pytest.mark.skipif(sys.version_info < (3,6), reason="before 3.6 dict where unordered") 6 | def test_get(run): 7 | 8 | # Get full config dict 9 | r = run("space config") 10 | out = r.stdout.splitlines() 11 | assert out[0].startswith("config :") 12 | assert "\n".join(out[1:]) == """beyond: 13 | eop: 14 | missing_policy: pass 15 | stations: 16 | TLS: 17 | latlonalt: 18 | 43.604482 19 | 1.443962 20 | 172.0 21 | name: Toulouse 22 | parent_frame: WGS84""" 23 | assert r.stderr == "" 24 | assert r.success 25 | 26 | r = run("space config get beyond.eop.missing_policy") 27 | assert r.stdout == "pass\n" 28 | assert not r.stderr 29 | assert r.success 30 | 31 | # without the 'get' parameter 32 | r = run("space config stations.TLS.name") 33 | assert r.success 34 | assert r.stdout == "Toulouse\n" 35 | assert not r.stderr 36 | 37 | r = run("space config stations.TEST.name") 38 | assert not r.success 39 | assert r.stdout == "" 40 | assert r.stderr == "Unknown field 'TEST'\n" 41 | 42 | 43 | def test_reinit(run): 44 | pass 45 | # # Try to init a second time 46 | # r = run("space config init") 47 | # out = r.stdout 48 | # err = r.stderr 49 | # assert not out 50 | # assert err.startswith("config file already exists at ") 51 | # assert r.success 52 | 53 | 54 | def test_unlock(run): 55 | # Unlocking the config file 56 | r = run("space config unlock --yes") 57 | out = r.stdout.splitlines() 58 | err = r.stderr 59 | assert err.startswith("Unlocking") 60 | assert r.success 61 | 62 | # Try to unlock the config file, then cancel 63 | r = run("space config unlock", stdin="no") 64 | out = r.stdout.splitlines() 65 | err = r.stderr 66 | assert out[0] == "Are you sure you want to unlock the config file ?" 67 | assert out[1] == " yes/[no] " 68 | assert not err 69 | assert r.success 70 | 71 | # Try to unlock the config file, but hit wrong keys 72 | r = run("space config unlock", stdin="dummy") 73 | out = r.stdout.splitlines() 74 | err = r.stderr 75 | assert out[0] == "Are you sure you want to unlock the config file ?" 76 | assert out[1] == " yes/[no] " 77 | assert err == "unknown answer 'dummy'\n" 78 | assert not r.success 79 | 80 | # Lock file 81 | r = run("space config lock") 82 | out = r.stdout 83 | err = r.stderr 84 | assert not out 85 | assert err == "Locking the config file\n" 86 | assert r.success 87 | 88 | r = run("space config lock") 89 | out = r.stdout 90 | err = r.stderr 91 | assert not out 92 | assert err == "The config file is already locked\n" 93 | assert r.success 94 | 95 | 96 | def test_set(run): 97 | 98 | # Modifying the value in the config file without unlocking 99 | r = run("space config set stations.TLS.name Anywhere") 100 | assert not r.stdout 101 | assert r.stderr == "Config file locked. Please use 'space config unlock' first\n" 102 | assert not r.success 103 | 104 | # Unlocking the config file 105 | r = run("space config unlock", stdin="yes") 106 | out = r.stdout.splitlines() 107 | err = r.stderr.splitlines() 108 | assert out[0] == "Are you sure you want to unlock the config file ?" 109 | assert out[1] == " yes/[no] " 110 | assert err[0].startswith("Unlocking") 111 | assert r.success 112 | 113 | # Modifying the value in the config file 114 | r = run("space config set stations.TLS.name Anywhere") 115 | assert not r.stdout 116 | assert not r.stderr 117 | assert r.success 118 | 119 | # Verifying 120 | r = run("space config stations.TLS.name") 121 | assert r.stdout == "Anywhere\n" 122 | assert not r.stderr 123 | assert r.success 124 | 125 | # Locking 126 | r = run("space config lock") 127 | assert not r.stdout 128 | assert r.stderr == "Locking the config file\n" 129 | assert r.success 130 | 131 | # Testing that the lock is well in place 132 | r = run("space config set stations.TLS.name Nowhere") 133 | assert not r.stdout 134 | assert r.stderr == "Config file locked. Please use 'space config unlock' first\n" 135 | assert not r.success -------------------------------------------------------------------------------- /space/map/wephem.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from beyond.orbits import Ephem 4 | 5 | from ..utils import orb2lonlat 6 | 7 | 8 | class WindowEphem(Ephem): 9 | """Ephemeris used to display the ground-track of the orbit 10 | 11 | When propagating, this ephemeris keeps +/- one orbital period 12 | around the given date 13 | 14 | Includes a caching mechanism, in order to keep in memory 15 | locations already computed. 16 | """ 17 | 18 | def __init__(self, orb, ref_orb): 19 | """ 20 | Args: 21 | orb (Orbit) : Used as cursor 22 | ref_orb (Orbit or Ephem): Used to propagate 23 | """ 24 | 25 | self.span = orb.infos.period * 2 26 | start = orb.date - self.span / 2 27 | stop = start + self.span 28 | self.orb = ref_orb 29 | self.step = orb.infos.period / 100 30 | 31 | orbs = ref_orb.ephemeris(start=start, stop=stop, step=self.step, strict=False) 32 | super().__init__(orbs) 33 | 34 | def propagate(self, date): 35 | 36 | if self.start < date < self.stop: 37 | 38 | # The new date is between already computed points 39 | # We only need to compute new points at the edges 40 | 41 | date_diff = (date - self.start) / self.step 42 | date_i = int(date_diff) 43 | mid = len(self) // 2 44 | new = (date_i - mid) * self.step 45 | 46 | if date_i > mid: 47 | # Future 48 | orbs = list( 49 | self.orb.ephemeris( 50 | start=self.stop + self.step, 51 | stop=new, 52 | step=self.step, 53 | strict=False, 54 | ) 55 | ) 56 | for x in orbs: 57 | self._orbits.pop(0) 58 | self._orbits.append(x) 59 | elif date_i < mid - 1: 60 | # Past 61 | orbs = list( 62 | self.orb.ephemeris( 63 | start=self.start - self.step, 64 | stop=new, 65 | step=-self.step, 66 | strict=False, 67 | ) 68 | ) 69 | for x in orbs: 70 | self._orbits.pop() 71 | self._orbits.insert(0, x) 72 | else: 73 | # The new date is not between already computed points 74 | # We have to compute a new set of points from the orbit 75 | self._orbits = list( 76 | self.orb.ephemeris( 77 | start=date - self.span / 2, 78 | stop=self.span, 79 | step=self.step, 80 | strict=False, 81 | ) 82 | ) 83 | 84 | def segments(self): 85 | """Cut the ephemeris in segments for easy display""" 86 | 87 | lons, lats = [], [] 88 | segments = [] 89 | prev_lon, prev_lat = None, None 90 | for win_orb in self: 91 | lon, lat = orb2lonlat(win_orb) 92 | 93 | # Creation of multiple segments in order to not have a ground track 94 | # doing impossible paths 95 | if prev_lon is None: 96 | lons = [] 97 | lats = [] 98 | segments.append((lons, lats)) 99 | elif win_orb.infos.kep.i < np.pi / 2 and ( 100 | np.sign(prev_lon) == 1 and np.sign(lon) == -1 101 | ): 102 | lons.append(lon + 360) 103 | lats.append(lat) 104 | lons = [prev_lon - 360] 105 | lats = [prev_lat] 106 | segments.append((lons, lats)) 107 | elif win_orb.infos.kep.i > np.pi / 2 and ( 108 | np.sign(prev_lon) == -1 and np.sign(lon) == 1 109 | ): 110 | lons.append(lon - 360) 111 | lats.append(lat) 112 | lons = [prev_lon + 360] 113 | lats = [prev_lat] 114 | segments.append((lons, lats)) 115 | elif abs(prev_lon) > 150 and (np.sign(prev_lon) != np.sign(lon)): 116 | lons.append(lon - 360) 117 | lats.append(lat) 118 | lons = [prev_lon + 360] 119 | lats = [prev_lat] 120 | segments.append((lons, lats)) 121 | 122 | lons.append(lon) 123 | lats.append(lat) 124 | prev_lon = lon 125 | prev_lat = lat 126 | 127 | return segments 128 | -------------------------------------------------------------------------------- /space/tle/celestrak.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import aiohttp 4 | import async_timeout 5 | import re 6 | import requests 7 | from bs4 import BeautifulSoup 8 | 9 | from .common import TMP_FOLDER 10 | from ..wspace import ws 11 | from .db import TleDb 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | TMP_FOLDER = TMP_FOLDER / "celestrak" 16 | CELESTRAK_LIST = "http://celestrak.org/NORAD/elements/" 17 | CELESTRAK_URL = "http://celestrak.org/NORAD/elements/gp.php?FORMAT=3le&GROUP={}" 18 | PAGE_LIST_CONFIG = ("celestrak", "page-list") 19 | DEFAULT_FILES = [ 20 | "stations", 21 | "last-30-days", 22 | "visual", 23 | "weather", 24 | "noaa", 25 | "goes", 26 | "resource", 27 | "sarsat", 28 | "dmc", 29 | "tdrss", 30 | "argos", 31 | "geo", 32 | "intelsat", 33 | "gorizont", 34 | "raduga", 35 | "molniya", 36 | "iridium", 37 | "orbcomm", 38 | "globalstar", 39 | "amateur", 40 | "x-comm", 41 | "other-comm", 42 | "gps-ops", 43 | "glo-ops", 44 | "galileo", 45 | "beidou", 46 | "sbas", 47 | "nnss", 48 | "musson", 49 | "science", 50 | "geodetic", 51 | "engineering", 52 | "education", 53 | "military", 54 | "radar", 55 | "cubesat", 56 | "other", 57 | "active", 58 | "analyst", 59 | "planet", 60 | "spire", 61 | "ses", 62 | "iridium-NEXT", 63 | ] 64 | 65 | 66 | def fetch(files=None): 67 | """Main function to retrieve celestrak pages 68 | 69 | Args: 70 | files (List[str]) : List of files to download 71 | if ``None`, all pages are downloaded 72 | """ 73 | loop = asyncio.get_event_loop() 74 | try: 75 | loop.run_until_complete(_fetch(files)) 76 | except aiohttp.ClientError as e: 77 | log.error(e) 78 | 79 | 80 | def fetch_list(): 81 | """Retrieve list of available celestrak files""" 82 | 83 | log.info("Retrieving list of available celestrak files") 84 | 85 | log.debug("Downloading from %s", CELESTRAK_LIST) 86 | page = requests.get(CELESTRAK_LIST) 87 | 88 | files = [] 89 | bs = BeautifulSoup(page.text, features="lxml") 90 | for link in bs.body.find_all("a"): 91 | if "href" in link.attrs: 92 | linkmatch = re.fullmatch( 93 | r"gp\.php\?GROUP=([A-Za-z0-9\-]+)&FORMAT=tle", link["href"] 94 | ) 95 | if linkmatch is not None: 96 | files.append(linkmatch.group(1) + "") 97 | 98 | log.info("%d celestrak files found", len(files)) 99 | 100 | if not TMP_FOLDER.exists(): 101 | TMP_FOLDER.mkdir(parents=True) 102 | 103 | celestrak_pages = ws.config.get(*PAGE_LIST_CONFIG, fallback=DEFAULT_FILES) 104 | 105 | for p in set(celestrak_pages).difference(files): 106 | log.debug("Removing '%s' from the list of authorized celestrak pages", p) 107 | 108 | for p in set(files).difference(celestrak_pages): 109 | log.debug("Adding '%s' to the list of authorized celestrak pages", p) 110 | 111 | ws.config.set(*PAGE_LIST_CONFIG, files, save=True) 112 | 113 | 114 | async def _fetch_file(session, filename): 115 | """Coroutine to retrieve the specified page 116 | 117 | When the page is totally retrieved, the function will call insert 118 | """ 119 | with async_timeout.timeout(30): 120 | async with session.get(CELESTRAK_URL.format(filename)) as response: 121 | text = await response.text() 122 | 123 | filepath = TMP_FOLDER / filename 124 | 125 | if not TMP_FOLDER.exists(): 126 | TMP_FOLDER.mkdir(parents=True) 127 | 128 | with filepath.open("w") as fp: 129 | fp.write(text) 130 | 131 | return TleDb().insert(text, filename) 132 | 133 | 134 | async def _fetch(files=None): 135 | """Retrieve TLE from the celestrak.com website asynchronously""" 136 | 137 | celestrak_pages = ws.config.get(*PAGE_LIST_CONFIG, fallback=DEFAULT_FILES) 138 | 139 | if files is None: 140 | filelist = celestrak_pages 141 | else: 142 | if isinstance(files, str): 143 | files = [files] 144 | # Filter out file not included in the base list 145 | files = set(files) 146 | filelist = files.intersection(celestrak_pages) 147 | remaining = files.difference(celestrak_pages) 148 | 149 | for p in remaining: 150 | log.warning("Unknown celestrak pages '%s'", p) 151 | 152 | if not filelist: 153 | raise ValueError("No file to download") 154 | 155 | async with aiohttp.ClientSession(trust_env=True) as session: 156 | # Task list initialisation 157 | tasks = [_fetch_file(session, f) for f in filelist] 158 | 159 | mysum = await asyncio.gather(*tasks) 160 | log.debug(f"{sum(mysum)} TLE inserted in total") 161 | -------------------------------------------------------------------------------- /tests/test_ccsds.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture, mark 2 | 3 | 4 | opm = """CCSDS_OPM_VERS = 2.0 5 | CREATION_DATE = 2020-09-21T21:08:52.972990 6 | ORIGINATOR = N/A 7 | 8 | META_START 9 | OBJECT_NAME = ISS (ZARYA) 10 | OBJECT_ID = 1998-067A 11 | CENTER_NAME = EARTH 12 | REF_FRAME = TEME 13 | TIME_SYSTEM = UTC 14 | META_STOP 15 | 16 | COMMENT State Vector 17 | EPOCH = 2020-09-21T21:08:52.000000 18 | X = 5689.991323 [km] 19 | Y = -517.601324 [km] 20 | Z = 3632.861669 [km] 21 | X_DOT = 3.298781 [km/s] 22 | Y_DOT = 5.367726 [km/s] 23 | Z_DOT = -4.383843 [km/s] 24 | 25 | COMMENT Keplerian elements 26 | SEMI_MAJOR_AXIS = 6775.311044 [km] 27 | ECCENTRICITY = 0.001442 28 | INCLINATION = 51.641968 [deg] 29 | RA_OF_ASC_NODE = 205.014391 [deg] 30 | ARG_OF_PERICENTER = 75.306756 [deg] 31 | TRUE_ANOMALY = 61.515845 [deg] 32 | GM = 398600.9368 [km**3/s**2] 33 | 34 | USER_DEFINED_PROPAGATOR = KeplerNum 35 | USER_DEFINED_PROPAGATOR_STEP_SECONDS = 60.000 36 | USER_DEFINED_PROPAGATOR_METHOD = rk4 37 | 38 | """ 39 | 40 | oem = """CCSDS_OEM_VERS = 2.0 41 | CREATION_DATE = 2020-09-21T21:17:49.633392 42 | ORIGINATOR = N/A 43 | 44 | META_START 45 | OBJECT_NAME = ISS (ZARYA) 46 | OBJECT_ID = 1998-067A 47 | CENTER_NAME = EARTH 48 | REF_FRAME = TEME 49 | TIME_SYSTEM = UTC 50 | START_TIME = 2020-09-21T21:08:52.000000 51 | STOP_TIME = 2020-09-21T22:08:52.000000 52 | INTERPOLATION = LAGRANGE 53 | INTERPOLATION_DEGREE = 8 54 | META_STOP 55 | 56 | 2020-09-21T21:08:52.000000 5689.991323 -517.601324 3632.861669 3.298781 5.367726 -4.383843 57 | 2020-09-21T21:11:52.000000 6161.765801 452.628043 2773.758009 1.924976 5.375256 -5.128616 58 | 2020-09-21T21:14:52.000000 6378.189208 1404.099824 1799.376321 0.471416 5.160035 -5.660241 59 | 2020-09-21T21:17:52.000000 6330.321701 2297.391535 750.218778 -1.001360 4.731078 -5.956571 60 | 2020-09-21T21:20:52.000000 6020.235937 3095.521594 -330.107056 -2.431979 4.106404 -6.005387 61 | 2020-09-21T21:23:52.000000 5460.924688 3765.507642 -1396.733532 -3.760924 3.312256 -5.804946 62 | 2020-09-21T21:26:52.000000 4675.729838 4279.745106 -2405.428362 -4.933137 2.381918 -5.364016 63 | 2020-09-21T21:29:52.000000 3697.323491 4617.143235 -3314.451298 -5.900366 1.354207 -4.701397 64 | 2020-09-21T21:32:52.000000 2566.300291 4763.967959 -4086.281823 -6.623130 0.271751 -3.844994 65 | 2020-09-21T21:35:52.000000 1329.454636 4714.363947 -4689.146194 -7.072226 -0.820846 -2.830507 66 | 2020-09-21T21:38:52.000000 37.833934 4470.545361 -5098.282524 -7.229733 -1.878825 -1.699870 67 | 2020-09-21T21:41:52.000000 -1255.348375 4042.663793 -5296.908407 -7.089554 -2.859031 -0.499511 68 | 2020-09-21T21:44:52.000000 -2496.855763 3448.371858 -5276.867037 -6.657525 -3.721537 0.721466 69 | 2020-09-21T21:47:52.000000 -3635.558985 2712.105208 -5038.941232 -5.951138 -4.431136 1.913150 70 | 2020-09-21T21:50:52.000000 -4624.481460 1864.111713 -4592.829744 -4.998909 -4.958663 3.026699 71 | 2020-09-21T21:53:52.000000 -5422.700128 939.255275 -3956.785599 -3.839386 -5.282167 4.016216 72 | 2020-09-21T21:56:52.000000 -5997.033426 -24.370498 -3156.920500 -2.519786 -5.387861 4.840584 73 | 2020-09-21T21:59:52.000000 -6323.450638 -986.980404 -2226.184403 -1.094239 -5.270818 5.465212 74 | 2020-09-21T22:02:52.000000 -6388.129844 -1908.730613 -1203.047199 0.378335 -4.935345 5.863597 75 | 2020-09-21T22:05:52.000000 -6188.104114 -2751.382937 -129.923846 1.836671 -4.394945 6.018597 76 | 2020-09-21T22:08:52.000000 -5731.446653 -3479.926595 948.597946 3.219819 -3.671853 5.923313 77 | """ 78 | 79 | 80 | @fixture(params=["opm", "oem"]) 81 | def cmd(request, run): 82 | r = run(("space", request.param, "compute", "ISS@tle", "--insert")) 83 | assert r.success 84 | return request.param 85 | 86 | 87 | def test_compute_opm(run): 88 | r = run(("space", "opm", "compute", "ISS@tle", "-d", "2020-09-21T21:08:52")) 89 | assert r.success 90 | assert r.stdout.splitlines()[2:] == opm.splitlines()[2:] 91 | 92 | r = run(("space", "opm", "compute", "ISS@tle", "--frame", "EME2000")) 93 | assert r.success 94 | 95 | 96 | def test_compute_oem(run): 97 | r = run(("space", "oem", "compute", "ISS@tle", "-d", "2020-09-21T21:08:52", "-r", "1h")) 98 | assert r.success 99 | assert r.stdout.splitlines()[2:] == oem.splitlines()[2:] 100 | 101 | def test_list(run, cmd): 102 | r = run(("space", cmd, "list", "ISS")) 103 | 104 | assert r.success 105 | 106 | 107 | def test_get(run, cmd): 108 | r = run(("space", cmd, "get", "ISS")) 109 | assert r.success 110 | -------------------------------------------------------------------------------- /space/phase.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | import logging 4 | from pathlib import Path 5 | import matplotlib.pyplot as plt 6 | 7 | from beyond.env import jpl 8 | from beyond.env import solarsystem as solar 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def compute_phase(first, second, center): 14 | first = first.copy(form="spherical", frame=center) 15 | second = second.copy(form="spherical", frame=center) 16 | 17 | # Phase computation is just the difference between the right ascension 18 | return second.theta - first.theta 19 | 20 | 21 | def illumination(phase): 22 | return (1 - np.cos(phase)) / 2 23 | 24 | 25 | def draw_umbra(phase, radius): 26 | 27 | phase_norm = (phase % (2 * np.pi)) / (2 * np.pi) 28 | 29 | y = np.linspace(-radius, radius, 100) 30 | x = np.sqrt(radius**2 - y**2) 31 | r = 2 * x 32 | 33 | if phase_norm < 0.5: 34 | left = 2 * phase_norm * r - r + x 35 | right = x 36 | else: 37 | left = -x 38 | right = 2 * phase_norm * r - r - x 39 | 40 | return np.concatenate([left, right]), np.concatenate([y, y[::-1]]) 41 | 42 | 43 | def draw_phase(date, phase, body="Moon", filepath=False): 44 | 45 | path = Path(__file__).parent 46 | 47 | fig = plt.figure() 48 | ax = plt.subplot(111) 49 | 50 | radius = 138 51 | 52 | img_path = (path / "static" / body.lower()).with_suffix(".png") 53 | if img_path.exists(): 54 | im = plt.imread(str(img_path)) 55 | img = plt.imshow(im, extent=[-150, 150, -150, 150]) 56 | else: 57 | # im = plt.imread(str(path / "static/moon.png")) 58 | circle = plt.Circle((0, 0), radius, color="orange") 59 | ax.add_artist(circle) 60 | ax.set_aspect("equal") 61 | 62 | x, y = draw_umbra(phase, radius) 63 | plt.fill_between(x, y, color="k", lw=0, alpha=0.8, zorder=100) 64 | 65 | plt.xlim([-150, 150]) 66 | plt.ylim([-150, 150]) 67 | plt.axis("off") 68 | 69 | x_text = -140 70 | 71 | date_txt = "{} - {:%d/%m %H:%M:%S} - {:.1f}%".format( 72 | body, date, illumination(phase) * 100 73 | ) 74 | plt.text(x_text, 140, date_txt, color="white") 75 | 76 | plt.tight_layout() 77 | 78 | if filepath: 79 | plt.savefig(filepath, bbox_inches="tight") 80 | log.info("file saved at {}".format(Path(filepath).absolute())) 81 | else: 82 | plt.show() 83 | 84 | plt.close() 85 | 86 | 87 | def space_phase(*argv): 88 | """Compute the phase of the moon or other solar system bodies 89 | 90 | Usage: 91 | space-phase [] [--graph] [--frame ] [-a] [--file ] 92 | 93 | Options: 94 | Body 95 | Date to compute the moon phase at [default: now] 96 | (format %Y-%m-%dT%H:%M:%S) 97 | -g, --graph Display the moon phase 98 | -a, --analytical Use analytical model instead of JPL files 99 | --file File 100 | """ 101 | import sys 102 | from .utils import docopt, parse_date 103 | from .station import StationDb 104 | 105 | args = docopt(space_phase.__doc__) 106 | 107 | if args[""] is None: 108 | args[""] = "now" 109 | 110 | try: 111 | date = parse_date(args[""]) 112 | except ValueError as e: 113 | print(e, file=sys.stderr) 114 | sys.exit(1) 115 | 116 | StationDb.list() 117 | 118 | body = args[""] 119 | 120 | if body == "Moon": 121 | center = "EME2000" 122 | second = "Sun" 123 | else: 124 | center = body 125 | body = "Sun" 126 | second = "Earth" 127 | 128 | if args["--analytical"] and body.lower() == "moon": 129 | first = solar.get_body(body).propagate(date) 130 | second = solar.get_body(second).propagate(first.date) 131 | src = "analytical" 132 | else: 133 | src = "JPL" 134 | if args["--analytical"]: 135 | log.warning( 136 | "No analytical model available for '{}'. Switching to JPL source".format( 137 | body 138 | ) 139 | ) 140 | jpl.create_frames() 141 | first = jpl.get_orbit(body, date) 142 | second = jpl.get_orbit(second, first.date) 143 | 144 | if body == "Moon": 145 | phase = compute_phase(first, second, center) 146 | else: 147 | phase = np.pi - compute_phase(first, second, center) 148 | body = center 149 | 150 | illumin = illumination(phase) 151 | 152 | log.debug("Computing {} phase using source '{}'".format(body, src)) 153 | log.info("{} at {:%Y-%m-%dT%H:%M:%S} : {:0.2f}%".format(body, date, illumin * 100)) 154 | 155 | if args["--graph"] or args["--file"]: 156 | draw_phase(date, phase, body=args[""], filepath=args["--file"]) 157 | -------------------------------------------------------------------------------- /space/events.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import numpy as np 3 | from beyond.propagators.listeners import ( 4 | NodeListener, 5 | ApsideListener, 6 | LightListener, 7 | stations_listeners, 8 | SignalEvent, 9 | MaxEvent, 10 | TerminatorListener, 11 | AnomalyListener, 12 | RadialVelocityListener, 13 | events_iterator, 14 | ) 15 | 16 | from .station import StationDb 17 | from .sat import Sat 18 | from .utils import parse_date, parse_timedelta, docopt 19 | 20 | 21 | def complete_iterator(satlist, start, stop, step, listeners): 22 | """Iterate over satellite list and date range 23 | 24 | Its only use is to be fed to sorted() when this is requested 25 | """ 26 | for sat in satlist: 27 | iterator = sat.orb.iter(start=start, stop=stop, step=step, listeners=listeners) 28 | for orb in events_iterator(iterator): 29 | yield sat, orb 30 | 31 | 32 | def space_events(*argv): 33 | """Compute events for a given satellite 34 | 35 | Usage: 36 | space-events (- | ...) [options] 37 | 38 | Options: 39 | Name of the satellite 40 | -d, --date Starting date of the computation [default: now] 41 | -r, --range Range of the computation [default: 6h] 42 | -s, --step Step of the conmputation [default: 3m] 43 | -e, --events Selected events, space separated [default: all] 44 | --csv Data in CSV 45 | --sep Separator [default: ,] 46 | --sort If multiple satellites are provided, sort all results by date 47 | 48 | Available events: 49 | station= Display AOS, LOS and max elevation events for a station 50 | station Same but for all stations 51 | light Display umbra and penumbra events 52 | node Display ascending and descending nodes events 53 | apside Display periapsis and apoapsis events 54 | terminator Display terminator crossing event 55 | aol= Display crossing of an Argument of Latitude (in deg) 56 | radial= Display radial velocity crossing event 57 | all Display all non-specific events (station, light, node 58 | apside, and terminator) 59 | Example: 60 | space events ISS --events "aol=90 apside node" 61 | """ 62 | 63 | args = docopt(space_events.__doc__, argv=argv) 64 | 65 | try: 66 | satlist = Sat.from_command( 67 | *args[""], text=sys.stdin.read() if args["-"] else "" 68 | ) 69 | start = parse_date(args["--date"]) 70 | stop = parse_timedelta(args["--range"]) 71 | step = parse_timedelta(args["--step"]) 72 | except ValueError as e: 73 | print(e, file=sys.stdout) 74 | sys.exit(1) 75 | 76 | listeners = [] 77 | 78 | if "station" in args["--events"] or args["--events"] == "all": 79 | if "station=" in args["--events"]: 80 | for x in args["--events"].split(): 81 | if x.strip().startswith("station="): 82 | name = x.partition("station=")[2].strip() 83 | listeners.extend(stations_listeners(StationDb.get(name))) 84 | else: 85 | for sta in StationDb.list().values(): 86 | listeners.extend(stations_listeners(sta)) 87 | if "light" in args["--events"] or args["--events"] == "all": 88 | listeners.append(LightListener()) 89 | listeners.append(LightListener("penumbra")) 90 | if "node" in args["--events"] or args["--events"] == "all": 91 | listeners.append(NodeListener()) 92 | if "apside" in args["--events"] or args["--events"] == "all": 93 | listeners.append(ApsideListener()) 94 | if "terminator" in args["--events"] or args["--events"] == "all": 95 | listeners.append(TerminatorListener()) 96 | for x in args["--events"].split(): 97 | if x.strip().startswith("radial="): 98 | name = x.partition("radial=")[2].strip() 99 | listeners.append(RadialVelocityListener(StationDb.get(name), sight=True)) 100 | elif x.strip().startswith("aol="): 101 | v = float(x.partition("aol=")[2]) 102 | listeners.append(AnomalyListener(np.radians(v), anomaly="aol")) 103 | 104 | try: 105 | if args["--sort"]: 106 | iterator = sorted( 107 | complete_iterator(satlist, start, stop, step, listeners), 108 | key=lambda x: x[1].date, 109 | ) 110 | else: 111 | iterator = complete_iterator(satlist, start, stop, step, listeners) 112 | 113 | for sat, orb in iterator: 114 | 115 | if isinstance(orb.event, (MaxEvent, SignalEvent)): 116 | if isinstance(orb.event, SignalEvent) and orb.event.elev == 0: 117 | # Discard comments for null elevation 118 | comment = "" 119 | else: 120 | orb2 = orb.copy(frame=orb.event.station, form="spherical") 121 | comment = "{:0.2f} deg".format(np.degrees(orb2.phi)) 122 | else: 123 | comment = "" 124 | 125 | if args["--csv"]: 126 | sep = args["--sep"] 127 | print_str = f"{orb.date:%Y-%m-%dT%H:%M:%S.%f}{sep}{sat.name}{sep}{getattr(orb.event, 'station', '')}{sep}{orb.event}{sep}{comment}" 128 | else: 129 | print_str = f"{orb.date:%Y-%m-%dT%H:%M:%S.%f} {sat.name} {orb.event} {comment}" 130 | 131 | print(print_str) 132 | 133 | except KeyboardInterrupt: 134 | print("\r ") 135 | print("Interrupted") 136 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'space-command' 23 | copyright = '2019, Jules DAVID' 24 | author = 'Jules DAVID' 25 | 26 | import space 27 | # The short X.Y version 28 | version = space.__version__ 29 | # The full version, including alpha/beta/rc tags 30 | release = space.__version__ 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = "en" 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path. 68 | exclude_patterns = [] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = None 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'alabaster' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | html_theme_options = { 86 | "fixed_sidebar": True 87 | } 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | html_static_path = [] 93 | 94 | # Custom sidebar templates, must be a dictionary that maps document names 95 | # to template names. 96 | # 97 | # The default sidebars (for documents that don't match any pattern) are 98 | # defined by theme itself. Builtin themes are using these templates by 99 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 100 | # 'searchbox.html']``. 101 | # 102 | # html_sidebars = {} 103 | 104 | 105 | # -- Options for HTMLHelp output --------------------------------------------- 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'space-commanddoc' 109 | 110 | 111 | # -- Options for LaTeX output ------------------------------------------------ 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | # 116 | # 'papersize': 'letterpaper', 117 | 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | 126 | # Latex figure (float) alignment 127 | # 128 | # 'figure_align': 'htbp', 129 | } 130 | 131 | # Grouping the document tree into LaTeX files. List of tuples 132 | # (source start file, target name, title, 133 | # author, documentclass [howto, manual, or own class]). 134 | latex_documents = [ 135 | (master_doc, 'space-command.tex', 'space-command Documentation', 136 | 'Jules DAVID', 'manual'), 137 | ] 138 | 139 | 140 | # -- Options for manual page output ------------------------------------------ 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [ 145 | (master_doc, 'space-command', 'space-command Documentation', 146 | [author], 1) 147 | ] 148 | 149 | 150 | # -- Options for Texinfo output ---------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | (master_doc, 'space-command', 'space-command Documentation', 157 | author, 'space-command', 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | # -- Options for Epub output ------------------------------------------------- 163 | 164 | # Bibliographic Dublin Core info. 165 | epub_title = project 166 | 167 | # The unique identifier of the text. This can be a ISBN number 168 | # or the project homepage. 169 | # 170 | # epub_identifier = '' 171 | 172 | # A unique identification for the text. 173 | # 174 | # epub_uid = '' 175 | 176 | # A list of files that should not be packed into the epub file. 177 | epub_exclude_files = ['search.html'] 178 | 179 | 180 | # -- Extension configuration ------------------------------------------------- -------------------------------------------------------------------------------- /space/station.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from numpy import degrees, pi, radians 3 | 4 | from beyond.frames import get_frame, create_station 5 | from beyond.errors import UnknownFrameError 6 | 7 | from .wspace import ws 8 | from .utils import dms2deg, deg2dms 9 | 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class StationDb: 15 | def __new__(cls): 16 | 17 | if not hasattr(cls, "_instance"): 18 | # Singleton 19 | cls._instance = super().__new__(cls) 20 | 21 | return cls._instance 22 | 23 | @classmethod 24 | def list(cls): 25 | 26 | self = cls() 27 | 28 | if not hasattr(self, "_stations"): 29 | 30 | self._stations = {} 31 | for abbr, charact in ws.config["stations"].items(): 32 | 33 | charact["parent_frame"] = get_frame(charact["parent_frame"]) 34 | full_name = charact.pop("name") 35 | mask = charact.get("mask") 36 | if mask: 37 | # reverse direction of the mask to put it in counterclockwise 38 | # to comply with the mathematical definition 39 | charact["mask"] = ( 40 | (2 * pi - radians(mask["azims"][::-1])), 41 | radians(mask["elevs"][::-1]), 42 | ) 43 | 44 | # Deletion of all unknown characteristics from the charact dict 45 | # and conversion to object attributes (they may be used by addons) 46 | extra_charact = {} 47 | for key in list(charact.keys()): 48 | if key not in ("parent_frame", "latlonalt", "mask"): 49 | extra_charact[key] = charact.pop(key) 50 | 51 | self._stations[abbr] = create_station(abbr, **charact) 52 | self._stations[abbr].abbr = abbr 53 | self._stations[abbr].full_name = full_name 54 | 55 | for key, value in extra_charact.items(): 56 | setattr(self._stations[abbr], key, value) 57 | 58 | return self._stations 59 | 60 | @classmethod 61 | def get(cls, name): 62 | 63 | self = cls() 64 | 65 | try: 66 | return get_frame(name) 67 | except UnknownFrameError: 68 | if name not in self.list().keys(): 69 | raise 70 | return self.list()[name] 71 | 72 | @classmethod 73 | def save(cls, station): 74 | self = cls() 75 | 76 | ws.config["stations"].update(station) 77 | ws.config.save() 78 | 79 | if hasattr(self, "_stations"): 80 | del self._stations 81 | 82 | 83 | def wshook(cmd, *args, **kwargs): 84 | 85 | if cmd in ("init", "full-init"): 86 | name = "TLS" 87 | 88 | ws.config.setdefault("stations", {}) 89 | 90 | try: 91 | StationDb.get(name) 92 | except UnknownFrameError: 93 | StationDb.save( 94 | { 95 | name: { 96 | "latlonalt": [43.604482, 1.443962, 172.0], 97 | "name": "Toulouse", 98 | "parent_frame": "WGS84", 99 | } 100 | } 101 | ) 102 | log.info("Station {} created".format(name)) 103 | else: 104 | log.warning("Station {} already exists".format(name)) 105 | 106 | 107 | def space_station(*argv): 108 | """Stations management 109 | 110 | Usage: 111 | space-station list [--map] [] 112 | space-station create -- 113 | 114 | Options: 115 | list List available stations 116 | create Interactively create a station 117 | Abbreviation 118 | Name of the station 119 | Latitude in degrees 120 | Longitude in degrees 121 | Altitude in meters 122 | -m, --map Display the station on a map 123 | 124 | Latitude and longitude both accept degrees as float or as 125 | degrees, minutes and seconds of arc (e.g. 43°25"12') 126 | 127 | The "--" delimiter is here to allow for negative coordinates. 128 | Without it, any negative number would be interpreted as a command 129 | line option. 130 | 131 | Example: 132 | $ space station create FWS FortWorth -- 32 -97 207 133 | """ 134 | 135 | from pathlib import Path 136 | import matplotlib.pyplot as plt 137 | 138 | from .utils import docopt 139 | from .map.background import set_background 140 | 141 | args = docopt(space_station.__doc__) 142 | 143 | station = StationDb() 144 | 145 | if args["create"]: 146 | abbr = args[""] 147 | name = args[""] 148 | latitude = args[""] 149 | longitude = args[""] 150 | altitude = args[""] 151 | 152 | if "°" in latitude: 153 | latitude = dms2deg(latitude) 154 | else: 155 | latitude = float(latitude) 156 | 157 | if "°" in longitude: 158 | longitude = dms2deg(longitude) 159 | else: 160 | longitude = float(longitude) 161 | 162 | altitude = float(altitude) 163 | 164 | log.info("Creation of station '{}' ({})".format(name, abbr)) 165 | log.debug( 166 | "{} {}, altitude : {} m".format( 167 | deg2dms(latitude, "lat"), deg2dms(longitude, "lon"), altitude 168 | ) 169 | ) 170 | StationDb.save( 171 | { 172 | abbr: { 173 | "name": name, 174 | "latlonalt": (latitude, longitude, altitude), 175 | "parent_frame": "WGS84", 176 | } 177 | } 178 | ) 179 | else: 180 | 181 | stations = [] 182 | 183 | for station in sorted(station.list().values(), key=lambda x: x.abbr): 184 | 185 | if args[""] and station.abbr != args[""]: 186 | continue 187 | 188 | print(station.name) 189 | print("-" * len(station.name)) 190 | lat, lon, alt = station.latlonalt 191 | lat, lon = degrees([lat, lon]) 192 | print("name: {}".format(station.full_name)) 193 | print( 194 | "altitude: {} m\nposition: {}, {}".format( 195 | alt, deg2dms(lat, "lat"), deg2dms(lon, "lon") 196 | ) 197 | ) 198 | print() 199 | 200 | stations.append((station.name, lat, lon)) 201 | 202 | if args["--map"]: 203 | plt.figure(figsize=(15.2, 8.2)) 204 | set_background() 205 | plt.subplots_adjust(left=0.02, right=0.98, top=0.98, bottom=0.02) 206 | plt.show() 207 | -------------------------------------------------------------------------------- /space/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Space Command, a command for space application 4 | """ 5 | 6 | import os 7 | import sys 8 | import logging 9 | from docopt import DocoptExit 10 | 11 | if sys.version_info.minor >= 8: 12 | from importlib.metadata import entry_points as vanilla_entry_points 13 | 14 | if sys.version_info.minor >= 10: 15 | entry_points = vanilla_entry_points 16 | else: 17 | # Creating a custom filtering function to circumvent the lack of filtering 18 | # of the entry_points function in python 3.8 and 3.9 19 | def entry_points(group=None): 20 | entries = vanilla_entry_points() 21 | if group: 22 | entries = entries[group] 23 | return entries 24 | 25 | else: 26 | from pkg_resources import iter_entry_points 27 | 28 | entry_points = lambda group=None: iter_entry_points(group) 29 | 30 | import beyond 31 | 32 | from . import __version__ 33 | from .wspace import ws 34 | 35 | log = logging.getLogger(__package__) 36 | 37 | 38 | def list_subcommands(): 39 | subcommands = {} 40 | for entry in entry_points(group="space.commands"): 41 | subcommands[entry.name] = entry.load 42 | return subcommands 43 | 44 | 45 | def pm_on_crash(type, value, tb): 46 | """Exception hook, in order to start pdb when an exception occurs""" 47 | import pdb 48 | import traceback 49 | 50 | traceback.print_exception(type, value, tb) 51 | pdb.pm() 52 | 53 | 54 | def log_on_crash(type, value, tb): 55 | """Uncaught exceptions handler""" 56 | log.exception(value, exc_info=(type, value, tb)) 57 | # sys.__excepthook__(type, value, tb) 58 | 59 | 60 | def get_doc(func): 61 | return func.__doc__.splitlines()[0] if func.__doc__ is not None else "" 62 | 63 | 64 | def main(): 65 | """Direct the user to the right subcommand""" 66 | 67 | if "--pdb" in sys.argv: 68 | sys.argv.remove("--pdb") 69 | func = pm_on_crash 70 | else: 71 | func = log_on_crash 72 | 73 | sys.excepthook = func 74 | 75 | if "--version" in sys.argv: 76 | print("space-command {}".format(__version__)) 77 | print("beyond {}".format(beyond.__version__)) 78 | sys.exit(127) 79 | 80 | # Set logging verbosity level. 81 | # This setting will be overridden when loading the workspace (see `ws.init()` below) 82 | # but it allow to have a crude logging of all the initialization process. 83 | if "-v" in sys.argv or "--verbose" in sys.argv: 84 | if "-v" in sys.argv: 85 | sys.argv.remove("-v") 86 | else: 87 | sys.argv.remove("--verbose") 88 | 89 | verbose = True 90 | logging.basicConfig(level=logging.DEBUG, format="%(message)s") 91 | log.debug("Verbose mode activated") 92 | else: 93 | verbose = False 94 | logging.basicConfig(level=logging.INFO, format="%(message)s") 95 | 96 | colors = True 97 | if "--no-color" in sys.argv: 98 | log.debug("Disable colors on logging") 99 | colors = False 100 | sys.argv.remove("--no-color") 101 | 102 | # Retrieve the workspace if defined both as a command argument or as a 103 | # environment variable. The command line argument takes precedence 104 | if "-w" in sys.argv or "--workspace" in sys.argv: 105 | idx = ( 106 | sys.argv.index("-w") if "-w" in sys.argv else sys.argv.index("--workspace") 107 | ) 108 | sys.argv.pop(idx) 109 | ws.name = sys.argv.pop(idx) 110 | elif "SPACE_WORKSPACE" in os.environ: 111 | ws.name = os.environ["SPACE_WORKSPACE"] 112 | 113 | log.debug("workspace '{}'".format(ws.name)) 114 | 115 | # List of available subcommands 116 | commands = list_subcommands() 117 | 118 | if len(sys.argv) <= 1 or sys.argv[1] not in commands: 119 | # No or wrong subcommand 120 | 121 | helper = "Available sub-commands :\n" 122 | addons = "" 123 | 124 | _max = len(max(commands.keys(), key=len)) 125 | 126 | for name, func_loader in sorted(commands.items()): 127 | helper += " {:<{}} {}\n".format(name, _max, get_doc(func_loader())) 128 | 129 | print(__doc__) 130 | print(helper) 131 | 132 | print("Options :") 133 | print( 134 | " --pdb Launch the python debugger when an exception is raised" 135 | ) 136 | print(" --version Show the version of the space-command utility") 137 | print(" -v, --verbose Show DEBUG level messages") 138 | print(" -w, --workspace Select the workspace to use") 139 | print(" --no-color Disable colored output") 140 | print() 141 | print( 142 | "To list, create and delete workspaces, use the companion command 'wspace'" 143 | ) 144 | print() 145 | sys.exit(1) 146 | 147 | # retrieve the subcommand and its arguments 148 | _, command, *args = sys.argv 149 | 150 | if command == "log": 151 | # disable logging when using the log command 152 | ws.config["logging"] = {} 153 | ws.config.verbose = verbose 154 | ws.config.colors = colors 155 | 156 | # Before loading the workspace, no file logging is initialized, so any logging will 157 | # only be reported on console thanks to the `logging.basicConfig()` above 158 | try: 159 | ws.load() 160 | except FileNotFoundError: 161 | log.error("It seems you are running 'space' for the first time") 162 | log.error( 163 | "To initialize the workspace '{}', please use the command 'wspace'".format( 164 | ws.name 165 | ) 166 | ) 167 | sys.exit(1) 168 | 169 | log.debug("=== starting command '{}' ===".format(command)) 170 | log.debug(f"beyond {beyond.__version__} / space {__version__}") 171 | log.debug(f"args : space {command} {' '.join(args)}") 172 | 173 | # get the function associated with the subcommand 174 | func = commands[command]() 175 | 176 | try: 177 | # Call the function associated with the subcommand 178 | func(*args) 179 | except DocoptExit as e: 180 | # Docopt override the SystemExit exception with its own subclass, and 181 | # pass a string containing the usage of the command as argument. 182 | # This benefit from the behavior of the SystemExit exception which 183 | # when not catched, print any non-integer argument and exit with code 1 184 | 185 | # So we have to catch the DocoptExit in order to modify the return code 186 | # and override it with a decent value. 187 | print(e, file=sys.stderr) 188 | log.debug(f"=== command '{command}' failed with return code 2 ===") 189 | sys.exit(2) 190 | except SystemExit as e: 191 | log.debug(f"=== command '{command}' failed with return code {e.code} ===") 192 | raise 193 | else: 194 | log.debug(f"=== command '{command}' exited with return code 0 ===") 195 | 196 | 197 | if __name__ == "__main__": 198 | main() 199 | -------------------------------------------------------------------------------- /space/tle/__init__.py: -------------------------------------------------------------------------------- 1 | """This package handle retrieval and archive of TLEs 2 | """ 3 | 4 | import sys 5 | import logging 6 | from glob import glob 7 | 8 | from ..wspace import ws 9 | from ..utils import docopt 10 | from ..sat import Sat, Request, sync 11 | from .db import TleDb, TleNotFound 12 | from . import celestrak 13 | from . import spacetrack 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | def wshook(cmd, *args, **kwargs): 19 | """Workspace hook 20 | 21 | will be executed during worspace creation or status check 22 | """ 23 | 24 | if cmd == "full-init": 25 | try: 26 | TleDb.get(norad_id=25544) 27 | except TleNotFound: 28 | celestrak.fetch() 29 | log.info("TLE database initialized") 30 | else: 31 | log.info("TLE database already exists") 32 | elif cmd == "status": 33 | print() 34 | print("TLE") 35 | print("---") 36 | TleDb().print_stats() 37 | 38 | 39 | def space_tle(*argv): 40 | """TLE Database from Space-Track and Celestrak websites 41 | 42 | Usage: 43 | space-tle get ... 44 | space-tle find ... 45 | space-tle history [--last ] ... 46 | space-tle stats [--graph] 47 | space-tle dump [--all] 48 | space-tle insert (-|...) 49 | space-tle celestrak fetch [...] 50 | space-tle celestrak fetch-list 51 | space-tle spacetrack fetch ... 52 | 53 | Options: 54 | get Display the last TLE of a selected object 55 | find Search for a string in the database of TLE (case insensitive) 56 | history Display all the recorded TLEs for a given object 57 | stats Display statistics on the database 58 | dump Display the last TLE for each object 59 | insert Insert TLEs into the database (from file or stdin) 60 | celestrak 61 | fetch Retrieve TLEs from Celestrak website 62 | fetch-list Retrieve the list of available files from Celestrak 63 | spacetrack 64 | fetch Retrieve a single TLE per object from the Space-Track 65 | website. This request needs login informations (see below) 66 | Selector of the object, see `space sat` 67 | File to insert in the database 68 | -l, --last Get the last TLE 69 | -a, --all Display the entirety of the database, instead of only 70 | the last TLE of each object 71 | -g, --graph Display statistics graphically 72 | 73 | Examples: 74 | space tle celestrak fetch # Retrieve all the TLEs from celestrak 75 | space tle celestrak fetch visual.txt # Retrieve a single file from celestrak 76 | space tle spacetrack fetch norad=25544 # Retrieve a single TLE from spacetrack 77 | space tle get norad=25544 # Display the TLE of the ISS 78 | space tle get cospar=1998-067A # Display the TLE of the ISS, too 79 | space tle insert file.txt # Insert all TLEs from the file 80 | echo "..." | space tle insert # Insert TLEs from stdin 81 | 82 | Configuration: 83 | The Space-Track website only allows TLE downloads from logged-in requests. 84 | To do this, the config file should contain 85 | spacetrack: 86 | identity: 87 | password: 88 | 89 | Every time you retrieve or insert TLE in the database, the satellite database 90 | is updated. To disable this behaviour add the following to the config file 91 | satellites: 92 | auto-sync-tle: False 93 | """ 94 | 95 | args = docopt(space_tle.__doc__, argv=argv) 96 | 97 | db = TleDb() 98 | 99 | if args["celestrak"]: 100 | 101 | if args["fetch"]: 102 | log.info("Retrieving TLEs from celestrak") 103 | 104 | try: 105 | celestrak.fetch(*args[""]) 106 | except ValueError as e: 107 | log.error(e) 108 | finally: 109 | if ws.config.get("satellites", "auto-sync-tle", fallback=True): 110 | # Update the Satellite DB 111 | sync("tle") 112 | elif args["fetch-list"]: 113 | celestrak.fetch_list() 114 | elif args["spacetrack"] and args["fetch"]: 115 | log.info("Retrieving TLEs from spacetrack") 116 | 117 | sats = [] 118 | 119 | for sel in args[""]: 120 | desc = Request.from_text(sel) 121 | sats.append({desc.selector: desc.value}) 122 | 123 | try: 124 | spacetrack.fetch(*sats) 125 | except ValueError as e: 126 | log.error(e) 127 | finally: 128 | if ws.config.get("satellites", "auto-sync-tle", fallback=True): 129 | # Update the Satellite DB 130 | sync("tle") 131 | elif args["insert"]: 132 | # Process the file list provided by the command line 133 | if args[""]: 134 | files = [] 135 | for f in args[""]: 136 | files.extend(glob(f)) 137 | 138 | # Insert each file into the database 139 | for file in files: 140 | try: 141 | db.load(file) 142 | except Exception as e: 143 | log.error(e) 144 | 145 | elif args["-"] and not sys.stdin.isatty(): 146 | try: 147 | # Insert the content of stdin into the database 148 | db.insert(sys.stdin.read(), "stdin") 149 | except Exception as e: 150 | log.error(e) 151 | else: 152 | log.error("No TLE provided") 153 | sys.exit(1) 154 | 155 | if ws.config.get("satellites", "auto-sync-tle", fallback=True): 156 | # Update the Satellite DB 157 | sync() 158 | 159 | elif args["find"]: 160 | txt = " ".join(args[""]) 161 | try: 162 | result = db.find(txt) 163 | except TleNotFound as e: 164 | log.error(str(e)) 165 | sys.exit(1) 166 | 167 | for tle in result: 168 | print(str(tle), end="\n\n") 169 | 170 | log.info("==> %d entries found for '%s'", len(result), txt) 171 | elif args["dump"]: 172 | for tle in db.dump(all=args["--all"]): 173 | print(tle, end="\n\n") 174 | elif args["stats"]: 175 | db.print_stats(args["--graph"]) 176 | else: 177 | try: 178 | sats = list(Sat.from_selectors(*args[""], src="tle")) 179 | except ValueError as e: 180 | log.error(str(e)) 181 | sys.exit(1) 182 | 183 | for sat in sats: 184 | try: 185 | if args["history"]: 186 | number = int(args["--last"]) if args["--last"] is not None else None 187 | tles = db.history(number=number, cospar_id=sat.cospar_id) 188 | 189 | for tle in tles: 190 | print(str(tle), end="\n\n") 191 | else: 192 | print(str(sat.orb.tle), end="\n\n") 193 | except TleNotFound as e: 194 | log.error(str(e)) 195 | sys.exit(1) 196 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Space Command 2 | ============= 3 | 4 | .. image:: http://readthedocs.org/projects/space-command/badge/?version=latest 5 | :alt: Documentation Status 6 | :target: https://space-command.readthedocs.io/en/latest/?badge=latest 7 | 8 | .. image:: https://img.shields.io/pypi/v/space-command.svg 9 | :alt: PyPi version 10 | :target: https://pypi.python.org/pypi/space-command 11 | 12 | .. image:: https://img.shields.io/pypi/pyversions/space-command.svg 13 | :alt: Python versions 14 | :target: https://pypi.python.org/pypi/space-command 15 | 16 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 17 | :target: https://github.com/psf/black 18 | 19 | The space command allows to compute the position of satellites and their passes above our head. 20 | 21 | In order to do this, it uses the `beyond `__ library. 22 | 23 | Installation 24 | ------------ 25 | 26 | For the stable release 27 | 28 | .. code-block:: shell 29 | 30 | $ pip install space-command 31 | 32 | For the latest development version 33 | 34 | .. code-block:: shell 35 | 36 | $ pip install git+https://github.com/galactics/beyond 37 | $ pip install git+https://github.com/galactics/space-command 38 | 39 | Features 40 | -------- 41 | 42 | * Retrieve orbits as TLE from `Celestrak `__ or `Space-Track `__ 43 | * Compute visibility from a given point of observation 44 | * Compute phases of the Moon and other solar system bodies 45 | * Animated map of the orbit of satellites 46 | * Compute events for a given satellite (day/night, node, AOS/LOS, etc.) 47 | * Retrieve Solar System bodies ephemeris 48 | 49 | See `documentation `__ for a 50 | list of all the features. 51 | 52 | Changelog 53 | --------- 54 | 55 | [0.7.3] - 2023-08-19 56 | ^^^^^^^^^^^^^^^^^^^^ 57 | 58 | **Added** 59 | 60 | - Python 3.11 support 61 | - ``space tle`` displays total insertions from celestrak 62 | - ``space station`` creation can now handle negative coordinates 63 | - ``space log`` show current versions of beyond and space-command 64 | 65 | **Modified** 66 | 67 | - Subcommands are now listed without discrimination between official and third-party entry points. 68 | 69 | **Removed** 70 | 71 | - Python 3.6 support 72 | 73 | [0.7.2] - 2022-10-14 74 | ^^^^^^^^^^^^^^^^^^^^ 75 | 76 | **Added** 77 | 78 | - Python 3.9 and 3.10 support 79 | - Option to force CCSDS format (XML or KVN) 80 | - ``space map`` add pause button 81 | - ``space passes`` elevation graph 82 | - ``space log`` colorized log 83 | - ``space tle`` now parses GP data instead of old-style files 84 | - ``space tle`` now inserts tles in chuncks to avoid sqlite locks (@hamarituc) 85 | 86 | [0.7.1] - 2020-09-13 87 | ^^^^^^^^^^^^^^^^^^^^ 88 | 89 | **Modified** 90 | 91 | - follow ``beyond`` refactoring of listeners 92 | 93 | [0.7] - 2020-08-11 94 | ^^^^^^^^^^^^^^^^^^ 95 | 96 | **Added** 97 | 98 | - ``space tle`` history range selection 99 | - ``wspace backup`` command to create, list and restore workspaces backups 100 | - ``orb2circle()`` function to quickly compute the circle of visibility of a spacecraft 101 | - ``space opm`` and ``space oem`` commands for OPM and OEM handling. 102 | - ``tox`` passes command-line arguments to ``pytest`` if provided after ``--`` 103 | 104 | **Modified** 105 | 106 | - refactoring of ``space map``, as a subpackage 107 | - ``parse_date()`` tries both default date format ("%Y-%m-%dT%H:%M:%S" and "%Y-%m-%d"), 108 | allowing for more relaxed dates command arguments 109 | - refactoring ``space sat`` with documentation on each function 110 | 111 | **Removed** 112 | 113 | - ``space ephem`` is replaced by ``space oem`` 114 | - ``space station`` does not allow interactive station creation anymore 115 | 116 | [0.6] - 2020-01-01 117 | ^^^^^^^^^^^^^^^^^^ 118 | 119 | **Added** 120 | 121 | - `black `__ code style 122 | - Retrieve available pages from Celestrak 123 | - Parse time scale of a datetime argument (i.e. "2020-01-01T14:36:00 TAI") 124 | - ``wspace`` can list and restore backups 125 | - ``space planet`` display the download progress 126 | - Support of Python 3.8 127 | - ``space events`` can compute Argument Of Latitude, and specific stations events 128 | - ``space map`` command arguments to start at a given date, disable ground track or disable visibility circle 129 | 130 | **Modified** 131 | 132 | - ``Sat.from_selector`` take a single selector and return a single Sat instance. 133 | Use ``Sat.from_selectors()`` for a generator. 134 | - Refactoring the *space.tle* module into a subpackage 135 | 136 | **Fixed** 137 | 138 | - Correction of sorting algorithm for ``space tle`` 139 | - ``space passes`` header 140 | - Support of environment variable to set a proxy, even in async code 141 | - ``map`` does not crash when an ephemeris is out of bound 142 | 143 | **Removed** 144 | 145 | - Support of python 3.5 146 | - Unused imports 147 | 148 | [0.5] - 2019-07-30 149 | ^^^^^^^^^^^^^^^^^^ 150 | 151 | **Added** 152 | 153 | - ``space map`` shows groundtrack 154 | - ``space events`` can selectively display one type of event 155 | - ``space sat`` subcommand to handle the satellite database 156 | - ``space ephem`` subcommand to handle ephemerides 157 | - ``wspace`` for workspace management 158 | - ``space passes`` now has a csv output format 159 | - ``space planet`` is able to fetch any bsp file defined in the config file 160 | 161 | **Modified** 162 | 163 | - Time span inputs normalized for all commands (20s, 3d12h5m, etc.) 164 | - Satellites can now be accessed by other identifiers than name (norad=25544 and cospar=1998-067A are equivalent to "ISS (ZARYA)"). See ``space sat`` 165 | - Logging is now with a timed rotating file 166 | 167 | [0.4.2] - 2019-02-23 168 | ^^^^^^^^^^^^^^^^^^^^ 169 | 170 | **Added** 171 | 172 | - Logging 173 | - Tests 174 | - ``space events`` subcommand computes all orbital events of a satellite (AOS/LOS, Apogee/Perigee, etc.) 175 | - ``space phase`` to compute the phase of available planets and moons 176 | - groundtracks optional on map 177 | 178 | **Removed** 179 | 180 | - ``space moon`` subcommand. This is now handled by the more generic ``space phase`` 181 | 182 | [0.4.1] - 2018-11-01 183 | ^^^^^^^^^^^^^^^^^^^^ 184 | 185 | **Added** 186 | 187 | - TLE database dump and statistics 188 | - Station map 189 | - Stations' characteristics defined in config file are now set as attributes of the 190 | station object 191 | 192 | [0.4] - 2018-10-20 193 | ^^^^^^^^^^^^^^^^^^ 194 | 195 | **Added** 196 | 197 | - Compute ephemeris of solar system bodies (Moon, Mars, Jupiter, Titan, etc.) 198 | - Moon phase computation 199 | - Centralized date handling, via command ``space clock`` 200 | - Allow TLE retrieval from Space-Track 201 | 202 | **Changed** 203 | 204 | - Database classes are now suffixed with *Db* 205 | - Subcommand retrieving data from the web now use the argument **fetch** instead of get. 206 | 207 | **Removed** 208 | 209 | - Light propagation delay no longer taken into account. 210 | The computation was tedious, and has been removed from the beyond library 211 | 212 | [v0.3] - 2018-07-24 213 | ^^^^^^^^^^^^^^^^^^^ 214 | 215 | **Added** 216 | 217 | - Possibility to create your own commands with the ``space.command`` `entry point `__. 218 | - Search TLE containing a string 219 | - Retrieve all chronological TLE of an object 220 | - ``space map`` displays real-time position of objects 221 | - Compute moon phase 222 | - Every command taking object names can also take TLE or CCSDS ephemeris via stdin 223 | - add mask handling for stations 224 | - Passes zenithal display optional 225 | 226 | **Changed** 227 | 228 | - MIT license replace GPLv3 229 | 230 | **Removed** 231 | 232 | - EOP database disabled by default. -------------------------------------------------------------------------------- /tests/test_sat.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from space.sat import Sat, Request 4 | from space.clock import Date 5 | 6 | 7 | tle2 = """ISS (ZARYA) 8 | 1 25544U 98067A 17343.27310274 .00004170 00000-0 70208-4 0 9997 9 | 2 25544 51.6420 245.4915 0003135 211.8338 242.8677 15.54121086 88980 10 | 11 | ISS (ZARYA) 12 | 1 25544U 98067A 19161.23866414 .00001034 00000-0 25191-4 0 9992 13 | 2 25544 51.6452 35.7838 0007986 32.5664 120.5149 15.51186426174176""" 14 | 15 | 16 | ephem = """CCSDS_OEM_VERS = 2.0 17 | CREATION_DATE = 2019-07-21T09:16:27 18 | ORIGINATOR = N/A 19 | 20 | META_START 21 | OBJECT_NAME = ISS (ZARYA) 22 | OBJECT_ID = 1998-067A 23 | CENTER_NAME = EARTH 24 | REF_FRAME = TEME 25 | TIME_SYSTEM = UTC 26 | START_TIME = 2019-07-21T00:00:00.000000 27 | STOP_TIME = 2019-07-22T00:00:00.000000 28 | INTERPOLATION = LAGRANGE 29 | INTERPOLATION_DEGREE = 7 30 | META_STOP 31 | 32 | 2019-07-21T00:00:00.000000 -4875.590974 -3634.626046 3023.580912 5.153972 -2.780719 4.941938 33 | 2019-07-21T02:00:00.000000 5699.191993 -1430.367113 3390.330521 3.936478 4.677821 -4.632831 34 | 2019-07-21T04:00:00.000000 1841.570824 4367.844267 -4869.836690 -7.247029 0.345635 -2.439117 35 | 2019-07-21T06:00:00.000000 -6702.230934 -815.971640 -792.346816 -0.107302 -4.830923 5.940230 36 | 2019-07-21T08:00:00.000000 1680.296984 -3917.098062 5276.568272 7.324701 2.141783 -0.746824 37 | 2019-07-21T10:00:00.000000 5804.888514 2850.542792 -2073.094357 -3.787444 3.702763 -5.539485 38 | 2019-07-21T12:00:00.000000 -4775.312315 2441.646806 -4180.032353 -5.325998 -4.038731 3.723697 39 | 2019-07-21T14:00:00.000000 -3307.821071 -4082.900715 4297.416402 6.605372 -1.608710 3.538657 40 | 2019-07-21T16:00:00.000000 6521.974258 -312.607361 1847.672709 1.838475 4.862894 -5.639923 41 | 2019-07-21T18:00:00.000000 -164.416166 4239.669699 -5310.711926 -7.581000 -0.909015 -0.497700 42 | 2019-07-21T20:00:00.000000 -6461.450141 -1859.259067 986.720983 2.169861 -4.373022 5.902908 43 | 2019-07-21T22:00:00.000000 3566.153656 -3256.158597 4763.011449 6.456894 3.159068 -2.673499 44 | 2019-07-22T00:00:00.000000 4558.774191 3548.133531 -3574.431589 -5.603176 2.722130 -4.457432 45 | 46 | META_START 47 | OBJECT_NAME = ISS (ZARYA) 48 | OBJECT_ID = 1998-067A 49 | CENTER_NAME = EARTH 50 | REF_FRAME = TEME 51 | TIME_SYSTEM = UTC 52 | START_TIME = 2019-07-19T00:00:00.000000 53 | STOP_TIME = 2019-07-20T00:00:00.000000 54 | INTERPOLATION = LAGRANGE 55 | INTERPOLATION_DEGREE = 7 56 | META_STOP 57 | 58 | 2019-07-19T00:00:00.000000 -5277.564610 -3867.226652 1829.038578 4.198623 -3.041824 5.641019 59 | 2019-07-19T02:00:00.000000 4991.230571 -1587.024500 4309.640613 4.634132 4.995956 -3.521679 60 | 2019-07-19T04:00:00.000000 2626.870601 4674.829056 -4173.543939 -6.669929 0.422433 -3.736285 61 | 2019-07-19T06:00:00.000000 -6417.088455 -833.533894 -2087.243417 -1.120062 -5.177436 5.526344 62 | 2019-07-19T08:00:00.000000 734.631058 -4203.713568 5273.129212 7.287480 2.254143 0.773542 63 | 2019-07-19T10:00:00.000000 6030.748709 3020.974680 -776.913862 -2.749036 3.977229 -5.950206 64 | 2019-07-19T12:00:00.000000 -3950.047806 2621.141859 -4874.886965 -5.847620 -4.294378 2.425832 65 | 2019-07-19T14:00:00.000000 -3981.489128 -4344.975078 3372.903458 5.844775 -1.731774 4.644455 66 | 2019-07-19T16:00:00.000000 6054.717093 -345.293803 3040.852794 2.780118 5.174557 -4.933402 67 | 2019-07-19T18:00:00.000000 761.677399 4505.822052 -5031.306243 -7.329232 -0.963374 -1.980331 68 | 2019-07-19T20:00:00.000000 -6494.420028 -1974.687665 -357.930008 1.089140 -4.640865 5.993711 69 | 2019-07-19T22:00:00.000000 2650.327499 -3449.024539 5204.019797 6.784764 3.354157 -1.234971 70 | 2019-07-20T00:00:00.000000 5087.211254 3762.134249 -2469.553745 -4.699560 2.871919 -5.327809 71 | 72 | """ 73 | 74 | def test_parse_request(space_tmpdir): 75 | 76 | request = Request.from_text("ISS (ZARYA)") 77 | 78 | assert request.selector == "name" 79 | assert request.value == "ISS (ZARYA)" 80 | assert request.offset == 0 81 | assert request.src == "tle" 82 | # assert request.limit == "" 83 | 84 | request = Request.from_text("norad=25544~3@oem") 85 | 86 | assert request.selector == "norad_id" 87 | assert request.value == "25544" 88 | assert request.offset == 3 89 | assert request.src == "oem" 90 | 91 | # Selection by alias 92 | request = Request.from_text("ISS") 93 | 94 | assert request.selector == "norad_id" 95 | assert request.value == "25544" 96 | assert request.offset == 0 97 | assert request.src == "tle" 98 | 99 | request = Request.from_text("norad=25544?2019-02-27") 100 | 101 | assert request.selector == "norad_id" 102 | assert request.value == "25544" 103 | assert request.limit == "before" 104 | assert request.date == Date(2019, 2, 27) 105 | 106 | request = Request.from_text("norad=25544^2019-02-27T12:00:00") 107 | 108 | assert request.selector == "norad_id" 109 | assert request.value == "25544" 110 | assert request.limit == "after" 111 | assert request.date == Date(2019, 2, 27, 12) 112 | 113 | with raises(ValueError): 114 | request = Request.from_text("norod=25544") 115 | 116 | 117 | def test_get_sat(space_tmpdir): 118 | 119 | sat = Sat.from_selector("ISS", orb=False) 120 | 121 | assert sat.name == "ISS (ZARYA)" 122 | assert sat.cospar_id == "1998-067A" 123 | assert sat.norad_id == 25544 124 | assert sat.orb == None 125 | 126 | with raises(ValueError): 127 | sat = Sat.from_selector("XMM", orb=False) 128 | 129 | 130 | def test_get_tle(space_tmpdir, run): 131 | 132 | r = run("space tle insert - ", stdin=tle2) 133 | assert r.success 134 | 135 | sat = Sat.from_selector("ISS") 136 | 137 | assert sat.name == "ISS (ZARYA)" 138 | assert sat.cospar_id == "1998-067A" 139 | assert sat.norad_id == 25544 140 | assert sat.orb.date == Date(2019, 6, 10, 5, 43, 40, 581696) 141 | 142 | sat = Sat.from_selector("ISS~") 143 | 144 | assert sat.name == "ISS (ZARYA)" 145 | assert sat.cospar_id == "1998-067A" 146 | assert sat.norad_id == 25544 147 | assert sat.orb.date == Date(2018, 10, 24, 13, 14, 20, 814720) 148 | 149 | with raises(ValueError): 150 | sat = Sat.from_selector("ISS~3") 151 | 152 | # After the date 153 | sat = Sat.from_selector("ISS^2018-01-01") 154 | assert sat.orb.date == Date(2018, 10, 24, 13, 14, 20, 814720) 155 | 156 | # Before the date 157 | sat = Sat.from_selector("ISS?2018-01-01") 158 | assert sat.orb.date == Date(2017, 12, 9, 6, 33, 16, 76736) 159 | 160 | 161 | def test_get_ephem(space_tmpdir, run): 162 | 163 | r = run("space oem insert -", stdin=ephem) 164 | assert r.success 165 | 166 | sat = Sat.from_selector('ISS@oem') 167 | 168 | assert sat.name == "ISS (ZARYA)" 169 | assert sat.cospar_id == "1998-067A" 170 | assert sat.norad_id == 25544 171 | assert sat.orb.start == Date(2019, 7, 21) 172 | assert sat.orb.stop == Date(2019, 7, 22) 173 | 174 | sat = Sat.from_selector('ISS@oem~') 175 | 176 | assert sat.orb.start == Date(2019, 7, 19) 177 | assert sat.orb.stop == Date(2019, 7, 20) 178 | 179 | with raises(ValueError): 180 | sat = Sat.from_selector("ISS@oem~3") 181 | 182 | sat = Sat.from_selector('ISS@oem^2019-07-20') 183 | assert sat.orb.start == Date(2019, 7, 21) 184 | assert sat.orb.stop == Date(2019, 7, 22) 185 | 186 | sat = Sat.from_selector('ISS@oem?2019-07-20') 187 | assert sat.orb.start == Date(2019, 7, 19) 188 | assert sat.orb.stop == Date(2019, 7, 20) 189 | 190 | with raises(ValueError): 191 | sat = Sat.from_selector('ISS@oem?2019-07-19') -------------------------------------------------------------------------------- /space/planet.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from beyond.errors import UnknownBodyError, UnknownFrameError 5 | from beyond.frames import get_frame 6 | 7 | import beyond.env.jpl as jpl 8 | import beyond.env.solarsystem as solar 9 | import beyond.io.ccsds as ccsds 10 | 11 | from .utils import docopt 12 | from .station import StationDb 13 | from .utils import parse_date, parse_timedelta, humanize 14 | from .wspace import ws 15 | 16 | 17 | def recurse(frame, already, level=""): 18 | """Function allowing to draw a tree showing relations between the different 19 | bodies included in the .bsp files 20 | """ 21 | 22 | bodies = list(frame.neighbors.keys()) 23 | 24 | txt = "" 25 | for n in bodies: 26 | if (frame, n) not in already and (n, frame) not in already: 27 | 28 | if level: 29 | if n == bodies[-1]: 30 | txt += " {}└─ {}\n".format(level[:-2], n.name) 31 | else: 32 | txt += " {}├─ {}\n".format(level[:-2], n.name) 33 | else: 34 | txt += " {}\n".format(n.name) 35 | 36 | already.add((frame, n)) 37 | filler = level + " │ " 38 | txt += recurse(n, already, filler) 39 | 40 | return txt 41 | 42 | 43 | def space_planet(*args): 44 | """Compute position of a planet of the solar system and its major moons 45 | 46 | Usage: 47 | space-planet 48 | space-planet fetch 49 | space-planet ... [options] 50 | 51 | Options: 52 | fetch Retrieve .bsp file 53 | Names of the planet to compute the ephemeris of. If 54 | absent, list all bodies available 55 | -f, --frame Frame in which to display the ephemeris to 56 | [default: EME2000] 57 | -d, --date Start date of the ephem (%Y-%m-%d) [default: midnight] 58 | -r, --range Duration of extrapolation [default: 3d] 59 | -s, --step Step size of the ephemeris. [default: 60m] 60 | -a, --analytical Force analytical model instead of .bsp files 61 | 62 | Example: 63 | space-planet Mars # Position of Mars in EME2000 64 | space-planet Moon -f Phobos # Position of the moon as seen from Phobos 65 | 66 | This command relies on .bsp files, parsed by the incredible jplephem lib. 67 | Bsp file can be retrieved at 68 | 69 | https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/ 70 | 71 | Files examples: 72 | 73 | de432s.bsp Moon, Sun, Mercury, Venus and main bodies barycentre 74 | mar097.bsp Mars, Phobos and Deimos 75 | jup310.bsp Jupiter and its major moons 76 | sat360xl.bsp Saturn and its major moons 77 | 78 | The 'beyond.env.jpl.files' config variable must be set to a list of bsp files 79 | paths. See beyond documentation about JPL files: 80 | 81 | http://beyond.readthedocs.io/en/latest/api/env.html#module-beyond.env.jpl 82 | 83 | If no .bsp file is provided, the command falls back to analytical methods 84 | for Moon and Sun. Other bodies are not provided. 85 | """ 86 | 87 | import requests 88 | 89 | from logging import getLogger 90 | 91 | log = getLogger(__name__) 92 | 93 | args = docopt(space_planet.__doc__) 94 | 95 | if args["fetch"]: 96 | 97 | folder = ws.folder / "jpl" 98 | 99 | if not folder.exists(): 100 | folder.mkdir() 101 | 102 | naif = "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/" 103 | baseurl = { 104 | "de403_2000-2020.bsp": naif 105 | + "spk/planets/a_old_versions/", # Data until 2020-01-01 106 | "de430.bsp": naif + "spk/planets/", 107 | "de432s.bsp": naif + "spk/planets/", 108 | "de435.bsp": naif + "spk/planets/", 109 | "jup310.bsp": naif + "spk/satellites/", 110 | "sat360xl.bsp": naif + "spk/satellites/", 111 | "mar097.bsp": naif + "spk/satellites/", 112 | "pck00010.tpc": naif + "pck/", 113 | "gm_de431.tpc": naif + "pck/", 114 | } 115 | 116 | success = [] 117 | 118 | filelist = ws.config.get("beyond", "env", "jpl", "files", fallback="de432s.bsp") 119 | if not isinstance(filelist, list): 120 | filelist = [filelist] 121 | 122 | for filepath in filelist: 123 | 124 | filepath = Path(filepath) 125 | if not filepath.is_absolute(): 126 | filepath = folder / filepath 127 | 128 | if not filepath.exists(): 129 | 130 | url = baseurl.get(filepath.name, "") + str(filepath.name) 131 | log.info("Fetching {}".format(filepath.name)) 132 | log.debug(url) 133 | 134 | try: 135 | r = requests.get(url, stream=True) 136 | except requests.exceptions.ConnectionError as e: 137 | log.error(e) 138 | else: 139 | try: 140 | r.raise_for_status() 141 | except requests.exceptions.HTTPError as e: 142 | log.error("{} {}".format(filepath.name, e)) 143 | else: 144 | total = int(r.headers.get("content-length", 0)) 145 | size = 0 146 | with filepath.open("bw") as fp: 147 | for chunk in r.iter_content(chunk_size=1024): 148 | fp.write(chunk) 149 | if total: 150 | size += len(chunk) 151 | print( 152 | "\r> {:6.2f} % {} / {}".format( 153 | 100 * size / total, 154 | humanize(size), 155 | humanize(total), 156 | ), 157 | end="", 158 | ) 159 | if total: 160 | print("\r", " " * 40, end="\r") 161 | success.append(str(filepath.absolute())) 162 | log.debug( 163 | "Adding {} to the list of jpl files".format(filepath.name) 164 | ) 165 | else: 166 | success.append(str(filepath.absolute())) 167 | log.info("File {} already downloaded".format(filepath.name)) 168 | 169 | # Adding the file to the list and saving the new state of configuration 170 | if success: 171 | ws.config.set("beyond", "env", "jpl", "files", success) 172 | 173 | ws.config.save() 174 | 175 | elif args[""]: 176 | 177 | try: 178 | date = parse_date(args["--date"], fmt="date") 179 | stop = parse_timedelta(args["--range"]) 180 | step = parse_timedelta(args["--step"]) 181 | except ValueError as e: 182 | print(e, file=sys.stderr) 183 | sys.exit(1) 184 | 185 | if not args["--analytical"]: 186 | # Create all frames from .bsp files, if they are available 187 | try: 188 | jpl.create_frames() 189 | except jpl.JplError: 190 | jpl_error = True 191 | else: 192 | jpl_error = False 193 | 194 | # Create all frames from stations database 195 | StationDb.list() 196 | 197 | try: 198 | frame = get_frame(args["--frame"]) 199 | except UnknownFrameError as e: 200 | print(e, file=sys.stderr) 201 | sys.exit(1) 202 | 203 | # Computation 204 | ephems = [] 205 | 206 | for body_name in args[""]: 207 | try: 208 | if args["--analytical"] or jpl_error: 209 | body = solar.get_body(body_name).propagate(date) 210 | else: 211 | body = jpl.get_orbit(body_name, date) 212 | except UnknownBodyError as e: 213 | print(e, file=sys.stderr) 214 | sys.exit(1) 215 | 216 | ephem = body.ephem(start=date, stop=stop, step=step) 217 | ephem.frame = frame 218 | ephem.name = body_name 219 | ephems.append(ephem) 220 | else: 221 | print(ccsds.dumps(ephems)) 222 | else: 223 | print("List of all available bodies") 224 | try: 225 | jpl.create_frames() 226 | txt = recurse(jpl.get_frame("Earth").center.node, set()) 227 | except jpl.JplError as e: 228 | print(" Sun") 229 | print(" Moon") 230 | else: 231 | print(txt) 232 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. space-command documentation master file, created by 2 | sphinx-quickstart on Sun Feb 24 21:11:04 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | space-command documentation 7 | =========================== 8 | 9 | :Version: |release| 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :hidden: 14 | 15 | index 16 | 17 | Features 18 | -------- 19 | 20 | * Retrieve orbits as TLE from `Celestrak `__ or `Space-Track `__ 21 | * Compute visibility from a given point of observation 22 | * Compute phases of the Moon and other solar system bodies 23 | * Animated map of the orbit of satellites 24 | * Compute events for a given satellite (day/night, node, AOS/LOS, etc.) 25 | * Retrieve Solar System bodies ephemeris 26 | 27 | Installation 28 | ------------ 29 | 30 | .. code-block:: shell 31 | 32 | $ pip install space-command 33 | 34 | If you need the last development version, make sure to also install 35 | the last version of `beyond `__, which space 36 | relies heavily upon. 37 | 38 | .. code-block:: shell 39 | 40 | $ pip install git+https://github.com/galactics/beyond 41 | $ pip install git+https://github.com/galactics/space-command 42 | 43 | Quickstart 44 | ---------- 45 | 46 | .. code-block:: shell 47 | 48 | $ wspace init # Create the empty workspace structure 49 | $ space tle fetch # Retrieve orbital data for major satellites 50 | 51 | Stations are accessed by their abbreviation, and by default there is only 52 | one declared: TLS. As it is not likely you live in this area, you need to 53 | declare a new station of observation. 54 | 55 | .. code-block:: shell 56 | 57 | $ space station create # Interactively create a station 58 | $ space station --map # Check if your station is well where you want it to be 59 | $ space passes "ISS (ZARYA)" # Compute the next pass of the ISS from your location 60 | 61 | Available commands 62 | ------------------ 63 | 64 | For full details on a command, use ``-h`` or ``--help`` arguments 65 | 66 | ``space events`` : Compute events encountered by the satellite : day/night transitions, AOS/LOS from stations, Node crossing, Apoapsis and Periapsis, etc. 67 | 68 | ``space map`` : Display an animated map of Earth with the satellites 69 | 70 | ``space passes`` : Compute visibility geometry (azimuth/elevation) from a given ground station 71 | 72 | ``space phase`` : Compute and display the phase of the Moon and other solar system bodies 73 | 74 | ``space planet`` : Compute the position of planets 75 | 76 | ``space station`` : Create and display ground stations 77 | 78 | ``space sat`` : Satellite database management 79 | 80 | ``space tle`` : Retrieve TLEs from Celestrak or Space-Track, store them and consult them 81 | 82 | ``space ephem`` : Compute Ephemeris and manage Ephemeris database 83 | 84 | In addition, the following commands allow you to access non orbital informations 85 | 86 | ``space clock`` : Handle the time 87 | 88 | ``space config`` : Allow to get and set different values to change the way the space command behaves 89 | 90 | ``space log`` : Access the log of all space commands 91 | 92 | Command argmuents 93 | ----------------- 94 | 95 | Dates 96 | ^^^^^ 97 | Unless otherwise specified, dates should be given following the ISO 8601 98 | format ``%Y-%m-%dT%H:%M:%S``. You can also use the keywords 'now', 'midnight' and 'tomorrow'. 99 | All dates are expressed in UTC 100 | 101 | Example: It is *2019-07-04T20:11:37*, ``now`` will yield *2019-07-04T20:11:37*, ``midnight`` will yield *2019-07-04T00:00:00*, and ``tomorrow`` will yield *2019-07-05T00:00:00*. 102 | 103 | Dates are generally used to give the starting point of a computation. 104 | 105 | Time range 106 | ^^^^^^^^^^ 107 | Time ranges may be expressed in weeks (*w*), days (*d*), hours (*h*), minutes (*m*) or seconds (*s*). 108 | All descriptors except weeks accept decimals: 109 | 110 | - ``600s`` is 600 seconds (10 minutes) 111 | - ``2w7h`` is 2 weeks and 7 hours 112 | - ``3h20.5m`` is 3 hours 20 minutes and 30 seconds 113 | 114 | Time ranges are generally used to give the ending point and the step size of a computation. 115 | 116 | Station selection 117 | ^^^^^^^^^^^^^^^^^ 118 | Station selection is done using the abbreviation of the station. By default, only the station 119 | ``TLS`` (located in Toulouse, France) is present. 120 | 121 | Satellite selection 122 | ^^^^^^^^^^^^^^^^^^^ 123 | Satellite selection, or rather *Orbit selection* can be made multiple ways. 124 | First you have to pick the descriptor of the satellite. 125 | For instance, the International Space Station (ISS) can be accessed by its name 126 | (``ISS (ZARYA)``), NORAD ID (``25544``), or COSPAR ID ``1998-067A``. The following 127 | commands are equivalent 128 | 129 | .. code-block:: bash 130 | 131 | $ space passes TLS name="ISS (ZARYA)" 132 | $ space passes TLS "ISS (ZARYA)" # default to name field 133 | $ space passes TLS norad=25544 134 | $ space passes TLS cospar=1998-067A 135 | 136 | As this could be a bit tiresome, it is possible to define aliases. 137 | 138 | .. code-block:: shell 139 | 140 | $ space sat alias ISS norad=25544 141 | 142 | The ``ISS`` alias is already defined 143 | 144 | Then, you have to decide which source you want to compute from. 145 | By default, space-command uses TLE previously fetched, but this behaviour 146 | can be overridden. 147 | In some cases, it is not possible to retrieve TLEs for a given object, particularly 148 | if this object is an interplanetary spacecraft. In this case, we have to rely on 149 | ephemeris files (OEM). 150 | 151 | **Examples** 152 | 153 | .. code-block:: bash 154 | 155 | $ space passes TLS ISS # Use the latest TLE 156 | $ space passes TLS ISS@tle # Use the latest TLE 157 | $ space passes TLS ISS@oem # Use the latest OEM 158 | 159 | .. code-block:: text 160 | 161 | ISS : latest TLE of ISS 162 | norad=25544 : latest TLE of ISS selected by norad number 163 | cospar=2018-027A : latest TLE of GSAT-6A selected by COSPAR ID 164 | ISS@oem : latest OEM 165 | ISS@tle : latest TLE 166 | ISS~ : one before last TLE 167 | ISS~~ : 2nd before last TLE 168 | ISS@oem~25 : 25th before last OEM 169 | ISS@oem^2018-12-25 : first OEM after the date 170 | ISS@tle?2018-12-25 : first tle before the date 171 | 172 | .. _pipping: 173 | 174 | Piping commands 175 | --------------- 176 | 177 | It is possible to chain commands in order to feed a result from one to another. 178 | Generaly, this is used to provide orbital data (TLE, ephemeris, etc.). 179 | In this case, the name of the satellite should be replaced by ``-`` in the second 180 | command. 181 | 182 | .. code-block:: shell 183 | 184 | $ # Compute the pass of Mars above a station 185 | $ space planet Mars | space passes TLS - -s 600s -g 186 | 187 | $ # Search for TLEs and display them on a map 188 | $ space tle find tintin | space map - 189 | 190 | Workspaces 191 | ---------- 192 | 193 | Workspaces allow the user to work on non-colluding databases. The default workspace is 194 | *main*. 195 | The companion command ``wspace`` allow to list, create or delete workspaces. 196 | To actually use a workspace during a computation, you can use the ``SPACE_WORKSPACE`` 197 | environment variable, or directly in the command line, with the ``-w`` or ``--workspace`` options 198 | 199 | .. code-block:: bash 200 | 201 | $ export SPACE_WORKSPACE=test # all commands coming after will be in the 'test' workspace 202 | $ space passes TLS ISS 203 | $ space events ISS 204 | ... 205 | $ unset SPACE_WORKSPACE # Disable the 'test' workspace, return to 'main' 206 | 207 | # The above is equivalent to 208 | $ space passes TLS ISS -w test 209 | $ space -w test events ISS 210 | 211 | It is also possible to use the ``wspace on`` command. It will open a new shell 212 | with the *SPACE_WORKSPACE* variable set. 213 | 214 | .. code-block:: bash 215 | 216 | $ wspace on test-workspace # Open the test workspace 217 | $ space passes TLS ISS 218 | $ space events ISS 219 | $ exit # back to the main workspace 220 | 221 | When using ``wspace on``, you can add the following lines to your ``.bashrc`` file 222 | to help visualize when working on workspaces 223 | 224 | .. code-block:: bash 225 | 226 | if [[ -n $SPACE_WORKSPACE ]]; then 227 | PS1="$PS1(\033[32m$SPACE_WORKSPACE\033[39m) " 228 | fi 229 | 230 | By default all workspaces are located in the ``.space/`` folder in the home directory. 231 | It is possible to change the location with the ``SPACE_WORKSPACES_FOLDER`` environment variable. 232 | 233 | Extension 234 | --------- 235 | 236 | It is possible to create your own scripts and extensions to this framework. 237 | 238 | To do that you have to create a ``space.commands`` `entry point `__. 239 | This will declare the extension to space-command, and make it available as an 240 | additional subcommand. 241 | 242 | If you need to extend the initialisation process (``wspace init``), the entry point 243 | is ``space.wshook``. 244 | 245 | Proxy 246 | ----- 247 | 248 | By setting up the ``HTTP_PROXY`` and ``HTTPS_PROXY`` variables to correct 249 | values, space-command should be able to retrieve any file from the web 250 | 251 | .. code-block:: bash 252 | 253 | $ export HTTP_PROXY=http://:@: 254 | $ export HTTPS_PROXY=https://:@: 255 | -------------------------------------------------------------------------------- /space/tle/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from datetime import datetime 4 | import numpy as np 5 | from peewee import ( 6 | Model, 7 | IntegerField, 8 | CharField, 9 | TextField, 10 | DateTimeField, 11 | SqliteDatabase, 12 | fn, 13 | ) 14 | import matplotlib.pyplot as plt 15 | 16 | from beyond.io.tle import Tle 17 | from beyond.dates import Date, timedelta 18 | 19 | from ..wspace import ws 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | 24 | class TleNotFound(Exception): 25 | def __init__(self, selector, mode=None): 26 | self.selector = selector 27 | self.mode = mode 28 | 29 | def __str__(self): 30 | if self.mode: 31 | return "No TLE for {obj.mode} = '{obj.selector}'".format(obj=self) 32 | 33 | return "No TLE containing '{}'".format(self.selector) 34 | 35 | 36 | class TleDb: 37 | 38 | db = SqliteDatabase(None) 39 | 40 | def __new__(cls, *args, **kwargs): 41 | if not hasattr(cls, "_instance"): 42 | cls._instance = super().__new__(cls, *args, **kwargs) 43 | return cls._instance 44 | 45 | def __init__(self): 46 | 47 | self._cache = {} 48 | self.model = TleModel 49 | self.db.init(str(ws.folder / "space.db")) 50 | self.model.create_table(safe=True) 51 | 52 | def dump(self, all=False): 53 | 54 | bd_request = self.model.select().order_by(self.model.norad_id, self.model.epoch) 55 | 56 | if not all: 57 | bd_request = bd_request.group_by(self.model.norad_id) 58 | 59 | for tle in bd_request: 60 | yield Tle("%s\n%s" % (tle.name, tle.data), src=tle.src) 61 | 62 | @classmethod 63 | def get(cls, **kwargs): 64 | """Retrieve one TLE from the table from one of the available fields 65 | 66 | Keyword Arguments: 67 | norad_id (int) 68 | cospar_id (str) 69 | name (str) 70 | Return: 71 | Tle: 72 | """ 73 | entity = cls()._get_last_raw(**kwargs) 74 | return Tle("%s\n%s" % (entity.name, entity.data), src=entity.src) 75 | 76 | def _get_last_raw(self, **kwargs): 77 | """ 78 | Keyword Arguments: 79 | norad_id (int) 80 | cospar_id (str) 81 | name (str) 82 | Return: 83 | TleModel: 84 | """ 85 | 86 | try: 87 | return ( 88 | self.model.select() 89 | .filter(**kwargs) 90 | .order_by(self.model.epoch.desc()) 91 | .get() 92 | ) 93 | except TleModel.DoesNotExist as e: 94 | mode, selector = kwargs.popitem() 95 | raise TleNotFound(selector, mode=mode) from e 96 | 97 | @classmethod 98 | def get_dated(cls, limit=None, date=None, **kwargs): 99 | 100 | self = cls() 101 | 102 | if limit == "after": 103 | r = ( 104 | self.model.select() 105 | .where(self.model.epoch >= date) 106 | .order_by(self.model.epoch.asc()) 107 | ) 108 | else: 109 | r = ( 110 | self.model.select() 111 | .where(self.model.epoch <= date) 112 | .order_by(self.model.epoch.desc()) 113 | ) 114 | 115 | try: 116 | entity = r.filter(**kwargs).get() 117 | except self.model.DoesNotExist: 118 | mode, selector = kwargs.popitem() 119 | raise TleNotFound(selector, mode=mode) from e 120 | else: 121 | return Tle("%s\n%s" % (entity.name, entity.data), src=entity.src) 122 | 123 | def history(self, *, number=None, start=None, stop=None, **kwargs): 124 | """Retrieve all the TLE of a given object 125 | 126 | Keyword Arguments: 127 | norad_id (int) 128 | cospar_id (str) 129 | name (str) 130 | number (int): Number of TLE to retrieve (unlimited if None) 131 | start (Date): Beginning of the range (- infinity if None) 132 | stop (Date): End of the range (now if None) 133 | Yield: 134 | TleModel: 135 | """ 136 | 137 | query = self.model.select().filter(**kwargs).order_by(self.model.epoch) 138 | 139 | if start: 140 | query = query.where(self.model.epoch >= start.datetime) 141 | if stop: 142 | query = query.where(self.model.epoch <= stop.datetime) 143 | 144 | if not query: 145 | mode, selector = kwargs.popitem() 146 | raise TleNotFound(selector, mode=mode) 147 | 148 | number = 0 if number is None else number 149 | 150 | for el in query[-number:]: 151 | yield Tle("%s\n%s" % (el.name, el.data), src=el.src) 152 | 153 | def load(self, filepath): 154 | """Insert the TLEs contained in a file in the database""" 155 | with open(filepath) as fh: 156 | self.insert(fh.read(), os.path.basename(filepath)) 157 | 158 | def insert(self, tles, src=None): 159 | """ 160 | Args: 161 | tles (str or List[Tle]): text containing the TLEs 162 | src (str): Where those TLEs come from 163 | Return: 164 | 2-tuple: Number of tle inserted, total tle found in the text 165 | """ 166 | 167 | if isinstance(tles, str): 168 | tles = Tle.from_string(tles) 169 | 170 | with self.db.atomic(): 171 | entities = [] 172 | i = None 173 | for i, tle in enumerate(tles): 174 | try: 175 | # An entry in the table correponding to this TLE has been 176 | # found, there is no need to update it 177 | entity = self.model.get( 178 | self.model.norad_id == tle.norad_id, 179 | self.model.epoch == tle.epoch.datetime, 180 | ) 181 | continue 182 | except TleModel.DoesNotExist: 183 | # This TLE is not registered yet, lets go ! 184 | entity = { 185 | "norad_id": int(tle.norad_id), 186 | "cospar_id": tle.cospar_id, 187 | "name": tle.name, 188 | "data": tle.text, 189 | "epoch": tle.epoch.datetime, 190 | "src": src, 191 | "insert_date": datetime.now(), 192 | } 193 | entities.append(entity) 194 | 195 | if entities: 196 | # Split up the list into chunks of at most 999 items, as SQlite 197 | # can only handle a limited number of elements per operation. 198 | for idx in range(0, len(entities), 999): 199 | TleModel.insert_many(entities[idx:idx + 999]).execute() 200 | elif i is None: 201 | raise ValueError("{} contains no TLE".format(src)) 202 | 203 | log.info("{} {:>3}/{}".format(src, len(entities), i + 1)) 204 | return len(entities) 205 | 206 | def find(self, txt): 207 | """Retrieve every TLE containing a string. For each object, only get the 208 | last TLE in database 209 | 210 | Args: 211 | txt (str) 212 | Return: 213 | Tle: 214 | """ 215 | 216 | entities = ( 217 | self.model.select() 218 | .where(self.model.data.contains(txt) | self.model.name.contains(txt)) 219 | .order_by(self.model.norad_id, self.model.epoch.desc()) 220 | .group_by(self.model.norad_id) 221 | ) 222 | 223 | sats = [] 224 | for entity in entities: 225 | sats.append(Tle("%s\n%s" % (entity.name, entity.data), src=entity.src)) 226 | if not sats: 227 | raise TleNotFound(txt) 228 | 229 | return sats 230 | 231 | def print_stats(self, graph=False): 232 | 233 | first = self.model.select(fn.MIN(TleModel.insert_date)).scalar() 234 | last = self.model.select(fn.MAX(TleModel.insert_date)).scalar() 235 | objects_nb = self.model.select().group_by(TleModel.norad_id).count() 236 | tles_nb = self.model.select().count() 237 | 238 | ages = [] 239 | now = Date.now() 240 | for tle in self.model.select().group_by(self.model.norad_id): 241 | ages.append((now - tle.epoch).total_seconds() / 86400) 242 | 243 | print(f"Objects : {objects_nb}") 244 | print(f"TLE : {tles_nb}") 245 | print(f"First fetch : {first:%F %T} ({now.datetime - first} ago)") 246 | print(f"Last fetch : {last:%F %T} ({now.datetime - last} ago)") 247 | print(f"Median age : {timedelta(np.median(ages))} (last TLE for each object)") 248 | 249 | if graph: 250 | plt.figure() 251 | plt.hist(ages, range(30), rwidth=0.9) 252 | plt.grid(linestyle=":", color="#666666") 253 | plt.title("Repartition of the last available TLE for each object") 254 | plt.gca().set_yscale("log") 255 | plt.xlabel("days") 256 | plt.ylabel("number") 257 | plt.tight_layout() 258 | plt.show() 259 | 260 | 261 | class TleModel(Model): 262 | """Peewee description of the database structure for storing TLEs""" 263 | 264 | norad_id = IntegerField() 265 | cospar_id = CharField() 266 | name = CharField() 267 | data = TextField() 268 | epoch = DateTimeField() 269 | src = TextField() 270 | insert_date = DateTimeField() 271 | 272 | class Meta: 273 | database = TleDb.db 274 | 275 | 276 | TleModel.add_index(TleModel.norad_id.desc(), TleModel.epoch.desc(), unique=True) 277 | -------------------------------------------------------------------------------- /space/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from textwrap import dedent 4 | from docopt import docopt as true_docopt 5 | from numpy import cos, sin, arccos, arcsin, pi, ones, linspace, copysign, degrees 6 | 7 | from beyond.constants import Earth 8 | 9 | 10 | __all__ = ["circle", "orb2circle", "docopt", "parse_date", "parse_timedelta"] 11 | 12 | 13 | def parse_date(txt, fmt=None): 14 | """ 15 | 16 | Args: 17 | txt (str): Text to convert to a date 18 | fmt (str): Format in which the date is expressed. if ``None`` 19 | tries %Y-%m-%dT%H:%M:%S, and then %Y-%m-%d 20 | Return: 21 | beyond.dates.date.Date: 22 | Raise: 23 | ValueError 24 | 25 | Examples: 26 | >>> print(parse_date("2018-12-25T00:23:56")) 27 | 2018-12-25T00:23:56 UTC 28 | >>> print(".", parse_date("now")) # The dot is here to trigger the ellipsis 29 | . ... UTC 30 | >>> print(".", parse_date("midnight")) # The dot is here to trigger the ellipsis 31 | . ...T00:00:00 UTC 32 | >>> print(parse_date("naw")) 33 | Traceback (most recent call last): 34 | ... 35 | ValueError: time data 'naw' does not match formats '%Y-%m-%d' or '%Y-%m-%dT%H:%M:%S' 36 | """ 37 | 38 | from .clock import Date 39 | 40 | fmts = {"full": "%Y-%m-%dT%H:%M:%S", "date": "%Y-%m-%d"} 41 | 42 | if not isinstance(txt, str): 43 | raise TypeError("type 'str' expected, got '{}' instead".format(type(txt))) 44 | 45 | txt, _, scale = txt.partition(" ") 46 | 47 | if not scale: 48 | scale = Date.DEFAULT_SCALE 49 | 50 | if txt == "now": 51 | date = Date.now(scale=scale) 52 | elif txt == "midnight": 53 | date = Date(Date.now().d, scale=scale) 54 | elif txt == "tomorrow": 55 | date = Date(Date.now().d + 1, scale=scale) 56 | elif fmt is None: 57 | try: 58 | date = Date.strptime(txt, fmts["full"], scale=scale) 59 | except ValueError: 60 | try: 61 | date = Date.strptime(txt, fmts["date"], scale=scale) 62 | except ValueError as e: 63 | raise ValueError( 64 | "time data '{0}' does not match formats '{1[date]}' or '{1[full]}'".format( 65 | txt, fmts 66 | ) 67 | ) 68 | 69 | else: 70 | date = Date.strptime(txt, fmts.get(fmt, fmt), scale=scale) 71 | 72 | return date 73 | 74 | 75 | def parse_timedelta(txt, negative=False, zero=False): 76 | """Convert a timedelta input string into a timedelta object 77 | 78 | Args: 79 | txt (str): 80 | negative (bool): Allow for negative value 81 | zero (bool) : Allow for zero timedelta 82 | Return: 83 | timedelta: 84 | Raise: 85 | ValueError: nothing can be parsed from the string 86 | 87 | Examples: 88 | >>> print(parse_timedelta('1w3d6h25.52s')) 89 | 10 days, 6:00:25.520000 90 | >>> print(parse_timedelta('2d12m30s')) 91 | 2 days, 0:12:30 92 | >>> print(parse_timedelta('20s')) 93 | 0:00:20 94 | >>> print(parse_timedelta('')) 95 | Traceback (most recent call last): 96 | ... 97 | ValueError: No timedelta found in '' 98 | >>> print(parse_timedelta('20')) 99 | Traceback (most recent call last): 100 | ... 101 | ValueError: No timedelta found in '20' 102 | """ 103 | 104 | from .clock import timedelta 105 | 106 | m = re.search( 107 | r"(?P-)?((?P\d+)w)?((?P[\d.]+)d)?((?P[\d.]+)h)?((?P[\d.]+)m)?((?P[\d.]+)s)?", 108 | txt, 109 | ) 110 | 111 | sign = 1 112 | if negative and m.group("sign") is not None: 113 | sign = -1 114 | weeks = float(m.group("weeks")) if m.group("weeks") is not None else 0 115 | days = float(m.group("days")) if m.group("days") is not None else 0 116 | hours = float(m.group("hours")) if m.group("hours") is not None else 0 117 | minutes = float(m.group("minutes")) if m.group("minutes") is not None else 0 118 | seconds = float(m.group("seconds")) if m.group("seconds") is not None else 0 119 | 120 | days += 7 * weeks 121 | seconds += minutes * 60 + hours * 3600 122 | 123 | if not zero and (days == seconds == 0): 124 | raise ValueError("No timedelta found in '{}'".format(txt)) 125 | 126 | return sign * timedelta(days, seconds) 127 | 128 | 129 | def docopt(doc, argv=None, **kwargs): 130 | argv = argv if argv else sys.argv[2:] 131 | return true_docopt(dedent(" " + doc), argv=argv, **kwargs) 132 | 133 | 134 | def orb2circle(orb, mask=0): 135 | """Compute a circle of visibility of an orbit 136 | 137 | Args: 138 | orb (Orbit): 139 | mark (float): 140 | Return: 141 | list: List of longitude/latitude couple (in radians) 142 | """ 143 | return circle(*orb.copy(form="spherical")[:3]) 144 | 145 | 146 | def circle(alt, lon, lat, mask=0): 147 | """Compute the visibility circle 148 | 149 | This function may be used for both station and satellites visibility 150 | circles. 151 | 152 | Args: 153 | alt (float): Altitude of the satellite 154 | lon (float): Longitude of the center of the circle in radians 155 | lat (float): Latitude of the center of the circle in radians 156 | mask (float): 157 | Returns: 158 | list: List of longitude/latitude couple (in radians) 159 | """ 160 | 161 | if isinstance(mask, (int, float)): 162 | mask = linspace(0, pi * 2, 360), ones(360) * mask 163 | 164 | result = [] 165 | 166 | # we go through the azimuts 167 | for theta, phi in zip(*mask): 168 | 169 | # half-angle of sight 170 | alpha = arccos(Earth.r * cos(phi) / alt) - phi 171 | 172 | theta += 0.0001 # for nan avoidance 173 | 174 | # Latitude 175 | point_lat = arcsin(sin(lat) * cos(alpha) + cos(lat) * sin(alpha) * cos(theta)) 176 | 177 | # Longitude 178 | dlon = arccos( 179 | -(sin(point_lat) * sin(lat) - cos(alpha)) / (cos(point_lat) * cos(lat)) 180 | ) 181 | 182 | if theta < pi: 183 | point_lon = lon - dlon 184 | elif abs(lat) + alpha >= pi / 2: 185 | # if the circle includes a pole 186 | point_lon = lon + dlon 187 | else: 188 | point_lon = lon - (2 * pi - dlon) 189 | 190 | result.append((point_lon, point_lat)) 191 | 192 | return result 193 | 194 | 195 | def orb2lonlat(orb): 196 | orb = orb.copy(form="spherical", frame="ITRF") 197 | lon, lat = degrees(orb[1:3]) 198 | lon = ((lon + 180) % 360) - 180 199 | return lon, lat 200 | 201 | 202 | def deg2dmstuple(deg): 203 | sign = copysign(1, deg) 204 | mnt, sec = divmod(abs(deg) * 3600, 60) 205 | deg, mnt = divmod(mnt, 60) 206 | return int(sign * deg), int(mnt), sec 207 | 208 | 209 | def deg2dms(deg, heading=None, sec_prec=3): 210 | """Convert from degrees to degrees, minutes seconds 211 | 212 | Args: 213 | deg (float): angle in degrees 214 | heading (str): 'latitude' or 'longitude' 215 | sec_prec (int): precision given to the seconds 216 | return: 217 | str: String representation of the angle 218 | 219 | Example: 220 | >>> print(deg2dms(43.56984611, 'latitude')) 221 | N43°34'11.446" 222 | >>> print(deg2dms(-43.56984611, 'latitude')) 223 | S43°34'11.446" 224 | >>> print(deg2dms(3.77059194, 'longitude')) 225 | E3°46'14.131" 226 | >>> print(deg2dms(-3.77059194, 'longitude')) 227 | W3°46'14.131" 228 | """ 229 | 230 | d, m, s = deg2dmstuple(deg) 231 | 232 | if heading: 233 | if "lon" in heading: 234 | head = "E" if d >= 0 else "W" 235 | elif "lat" in heading: 236 | head = "N" if d >= 0 else "S" 237 | 238 | txt = "{}{}°{}'{:0.{}f}\"".format(head, abs(d), m, s, sec_prec) 239 | else: 240 | txt = "{}°{}'{:0.{}f}\"".format(d, m, s, sec_prec) 241 | 242 | return txt 243 | 244 | 245 | def dms_split(dms): 246 | if "S" in dms or "W" in dms: 247 | sign = -1 248 | else: 249 | sign = 1 250 | 251 | dms = dms.strip().strip("NSEW") 252 | 253 | d, _, rest = dms.partition("°") 254 | m, _, rest = rest.partition("'") 255 | s, _, rest = rest.partition('"') 256 | 257 | return int(d), int(m), float(s), sign 258 | 259 | 260 | def dms2deg(dms): 261 | """Convert from degrees, minutes, seconds text representation to degrees 262 | 263 | Args: 264 | dms (str): String to be converted to degrees 265 | Return: 266 | float: signed with respect to N-S/E-W 267 | Example: 268 | >>> print("{:0.8f}".format(dms2deg("N43°34'11.446\\""))) 269 | 43.56984611 270 | >>> print("{:0.8f}".format(dms2deg("S43°34'11.446\\""))) 271 | -43.56984611 272 | >>> print("{:0.8f}".format(dms2deg("E3°46'14.131\\""))) 273 | 3.77059194 274 | >>> print("{:0.8f}".format(dms2deg("W3°46'14.131\\""))) 275 | -3.77059194 276 | """ 277 | d, m, s, sign = dms_split(dms) 278 | 279 | return sign * (d + m / 60.0 + s / 3600.0) 280 | 281 | 282 | def hms2deg(h, m, s): 283 | """Convert from hour, minutes, seconds to degrees 284 | 285 | Args: 286 | h (int) 287 | m (int) 288 | s (float) 289 | Return: 290 | float 291 | """ 292 | 293 | return h * 360 / 24 + m / 60.0 + s / 3600.0 294 | 295 | 296 | def humanize(byte_size): 297 | 298 | cursor = byte_size 299 | divisor = 1024 300 | 301 | for i in "B KiB MiB GiB".split(): 302 | cursor, remainder = divmod(byte_size, divisor) 303 | if cursor < 1: 304 | break 305 | byte_size = cursor + remainder / divisor 306 | 307 | return "{:7.2f} {}".format(byte_size, i) 308 | -------------------------------------------------------------------------------- /space/map/map.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pathlib import Path 3 | 4 | import matplotlib as mpl 5 | import matplotlib.pyplot as plt 6 | from matplotlib.animation import FuncAnimation 7 | from matplotlib.widgets import Button 8 | 9 | from beyond.constants import Earth 10 | from beyond.env.solarsystem import get_body 11 | 12 | from ..utils import circle, orb2circle, orb2lonlat 13 | from ..clock import Date, timedelta 14 | 15 | from .wephem import WindowEphem 16 | from .background import set_background 17 | 18 | 19 | class MapAnim: 20 | 21 | COLORS = "r", "g", "b", "c", "m", "y", "k", "w" 22 | 23 | def __init__(self, sats, date, groundtrack=True, circle=True): 24 | self.sats = sats 25 | self.multiplier = None 26 | self.interval = 200 27 | self.circle = circle 28 | self.groundtrack = groundtrack 29 | 30 | if abs(date - Date.now()).total_seconds() > 1: 31 | self.date = date 32 | self.multiplier = 1 33 | 34 | mpl.rcParams["toolbar"] = "None" 35 | 36 | self.fig = plt.figure(figsize=(15.2, 8.2)) 37 | self.ax = plt.subplot(111) 38 | set_background() 39 | plt.subplots_adjust(left=0.02, right=0.98, top=0.98, bottom=0.1) 40 | 41 | self.make_empty_plots() 42 | self.make_buttons() 43 | 44 | self.ani = FuncAnimation(self.fig, self, interval=self.interval, blit=True) 45 | 46 | def make_empty_plots(self): 47 | 48 | (self.sun,) = plt.plot( 49 | [], [], "yo", markersize=10, markeredgewidth=0, animated=True, zorder=2 50 | ) 51 | (self.moon,) = plt.plot( 52 | [], [], "wo", markersize=10, markeredgewidth=0, animated=True, zorder=2 53 | ) 54 | self.night = plt.fill_between( 55 | [], [], color="k", alpha=0.3, lw=0, animated=True, zorder=1 56 | ) 57 | self.date_text = plt.text(-175, 80, "") 58 | 59 | # For each satellite, initialisation of graphical representation 60 | for i, sat in enumerate(self.sats): 61 | color = self.COLORS[i % len(self.COLORS)] 62 | 63 | (sat.point,) = plt.plot( 64 | [], [], "o", ms=5, color=color, animated=True, zorder=10 65 | ) 66 | (sat.circle,) = plt.plot( 67 | [], [], ".", ms=2, color=color, animated=True, zorder=10 68 | ) 69 | sat.text = plt.text(0, 0, sat.name, color=color, animated=True, zorder=10) 70 | sat.win_ephem = None if self.groundtrack else False 71 | 72 | def make_buttons(self): 73 | self.breverse = Button(plt.axes([0.02, 0.02, 0.04, 0.05]), "Reverse") 74 | self.breverse.on_clicked(self.reverse) 75 | self.bslow = Button(plt.axes([0.07, 0.02, 0.04, 0.05]), "Slower") 76 | self.bslow.on_clicked(self.slower) 77 | self.breal = Button(plt.axes([0.12, 0.02, 0.08, 0.05]), "Real Time") 78 | self.breal.on_clicked(self.real) 79 | self.bplay = Button(plt.axes([0.21, 0.02, 0.04, 0.05]), "x1") 80 | self.bplay.on_clicked(self.reset) 81 | self.bfast = Button(plt.axes([0.26, 0.02, 0.04, 0.05]), "Faster") 82 | self.bfast.on_clicked(self.faster) 83 | self.bpause = Button(plt.axes([0.31, 0.02, 0.04, 0.05]), "Pause") 84 | self.bpause.on_clicked(self.pause) 85 | 86 | self.bcircle = Button(plt.axes([0.8, 0.02, 0.08, 0.05]), "Circle") 87 | self.bcircle.on_clicked(self.toggle_circle) 88 | self.ground = Button(plt.axes([0.9, 0.02, 0.08, 0.05]), "Ground-Track") 89 | self.ground.on_clicked(self.toggle_groundtrack) 90 | 91 | def propagate(self): 92 | 93 | if self.multiplier is None: 94 | self.date = Date.now() 95 | else: 96 | self.date += self.increment 97 | 98 | for sat in self.sats: 99 | try: 100 | sat.propagated = sat.orb.propagate(self.date) 101 | except ValueError: 102 | sat.propagated = None 103 | else: 104 | # Ground track 105 | if sat.win_ephem is None: 106 | try: 107 | sat.win_ephem = WindowEphem(sat.propagated, sat.orb) 108 | except ValueError: 109 | # In case of faulty windowed ephemeris, disable groundtrack 110 | # altogether 111 | sat.win_ephem = False 112 | 113 | if sat.win_ephem: 114 | sat.win_ephem.propagate(self.date) 115 | 116 | def update_text(self): 117 | if self.multiplier is None: 118 | text = "real time" 119 | elif self.multiplier == 0: 120 | text = "paused" 121 | else: 122 | if abs(self.multiplier) == 1: 123 | adj = "" 124 | value = abs(self.multiplier) 125 | elif abs(self.multiplier) > 1: 126 | adj = "faster" 127 | value = abs(self.multiplier) 128 | else: 129 | adj = "slower" 130 | value = 1 / abs(self.multiplier) 131 | sign = "" if self.multiplier > 0 else "-" 132 | text = "{}x{:0.0f} {}".format(sign, value, adj) 133 | 134 | self.date_text.set_text("{:%Y-%m-%d %H:%M:%S}\n{}".format(self.date, text)) 135 | return self.date_text 136 | 137 | def update_sats(self): 138 | 139 | plot_list = [] 140 | 141 | for i, sat in enumerate(self.sats): 142 | color = self.COLORS[i % len(self.COLORS)] 143 | # Updating position of the satellite 144 | 145 | if sat.propagated is None: 146 | continue 147 | 148 | orb = sat.propagated 149 | 150 | orb_sph = orb.copy(form="spherical", frame="ITRF") 151 | lon, lat = orb2lonlat(orb_sph) 152 | sat.point.set_data([lon], [lat]) 153 | plot_list.append(sat.point) 154 | 155 | # Updating the label 156 | sat.text.set_position((lon + 0.75, lat + 0.75)) 157 | plot_list.append(sat.text) 158 | 159 | if self.circle: 160 | # Updating the circle of visibility 161 | lonlat = np.degrees(orb2circle(orb_sph)) 162 | lonlat[:, 0] = ((lonlat[:, 0] + 180) % 360) - 180 163 | sat.circle.set_data(lonlat[:, 0], lonlat[:, 1]) 164 | plot_list.append(sat.circle) 165 | 166 | if sat.win_ephem: 167 | # Ground-track 168 | sat.gt = [] 169 | for lons, lats in sat.win_ephem.segments(): 170 | sat.gt.append( 171 | self.ax.plot( 172 | lons, lats, color=color, alpha=0.5, lw=2, animated=True 173 | )[0] 174 | ) 175 | plot_list.append(sat.gt[-1]) 176 | 177 | return plot_list 178 | 179 | def update_bodies(self): 180 | 181 | plot_list = [] 182 | 183 | # Updating the sun 184 | sun = get_body("Sun").propagate(self.date).copy(form="spherical", frame="ITRF") 185 | lon, lat = orb2lonlat(sun) 186 | self.sun.set_data([lon], [lat]) 187 | plot_list.append(self.sun) 188 | 189 | # Updating the night 190 | lonlat = np.degrees(orb2circle(sun)) 191 | lonlat[:, 0] = ((lonlat[:, 0] + 180) % 360) - 180 192 | season = -95 if lat > 0 else 95 193 | lonlat = lonlat[lonlat[:, 0].argsort()] # Sorting array by ascending longitude 194 | 195 | lonlat = np.concatenate( 196 | [ 197 | [[-185, season], [-185, lonlat[0, 1]]], 198 | lonlat, 199 | [[185, lonlat[-1, 1]], [185, season]], 200 | ] 201 | ) 202 | 203 | verts = [lonlat] 204 | 205 | # Eclipse (part of the orbit when the satellite is not illuminated by 206 | # the sun) 207 | if len(self.sats) == 1: 208 | orb_sph = self.sats[0].propagated.copy(form="spherical", frame="ITRF") 209 | virt_alt = Earth.r * orb_sph.r / np.sqrt(orb_sph.r**2 - Earth.r**2) 210 | theta = sun.theta + np.pi 211 | phi = -sun.phi 212 | lonlat = np.degrees(circle(virt_alt, theta, phi)) 213 | lonlat[:, 0] = ((lonlat[:, 0] + 180) % 360) - 180 214 | 215 | if all(abs(lonlat[:, 0]) < 175): 216 | # This deals with the case when the umbra is between -180 and 180° of 217 | # longitude 218 | verts.append(lonlat) 219 | else: 220 | pos_lonlat = lonlat.copy() 221 | neg_lonlat = lonlat.copy() 222 | 223 | pos_lonlat[pos_lonlat[:, 0] < 0, 0] += 360 224 | neg_lonlat[neg_lonlat[:, 0] > 0, 0] -= 360 225 | 226 | min_lon = min(pos_lonlat[:, 0]) 227 | max_lon = max(neg_lonlat[:, 0]) 228 | 229 | lonlat = np.concatenate([neg_lonlat, pos_lonlat]) 230 | 231 | if abs(min_lon - max_lon) > 30: 232 | # This deals with the case when the umbra is spread between 233 | # the east-west edges of the map, but not the north and south 234 | # ones 235 | verts.append(lonlat) 236 | else: 237 | # This deals with the case when the umbra is spread between 238 | # east west and also north or south 239 | 240 | # sort by ascending longitude 241 | lonlat = lonlat[lonlat[:, 0].argsort()] 242 | 243 | west_lat = lonlat[0, 1] 244 | east_lat = lonlat[-1, 1] 245 | 246 | v = np.concatenate( 247 | [ 248 | [[-360, season], [-360, west_lat]], 249 | lonlat, 250 | [[360, east_lat], [360, season]], 251 | ] 252 | ) 253 | 254 | verts.append(v) 255 | 256 | self.night.set_verts(verts) 257 | plot_list.append(self.night) 258 | 259 | # Updating the moon 260 | moon = ( 261 | get_body("Moon").propagate(self.date).copy(frame="ITRF", form="spherical") 262 | ) 263 | lon, lat = orb2lonlat(moon) 264 | self.moon.set_data([lon], [lat]) 265 | plot_list.append(self.moon) 266 | 267 | return plot_list 268 | 269 | def __call__(self, frame): 270 | 271 | self.propagate() 272 | 273 | plot_list = [] 274 | plot_list.append(self.update_text()) 275 | plot_list.extend(self.update_sats()) 276 | plot_list.extend(self.update_bodies()) 277 | 278 | return plot_list 279 | 280 | @property 281 | def increment(self): 282 | return timedelta(seconds=self.multiplier * self.interval / 1000) 283 | 284 | def real(self, *args, **kwargs): 285 | self.multiplier = None 286 | 287 | def reset(self, *args, **kwargs): 288 | self.multiplier = 1 289 | 290 | def faster(self, *args, **kwargs): 291 | 292 | steps = [2, 2.5, 2] 293 | 294 | if not hasattr(self, "_step"): 295 | self._step = 0 296 | else: 297 | self._step += 1 298 | 299 | if self.multiplier is None: 300 | self.multiplier = 2 301 | elif self.multiplier == 0: 302 | self.multiplier = 1 303 | else: 304 | self.multiplier *= steps[self._step % len(steps)] 305 | 306 | def slower(self, *args, **kwargs): 307 | steps = [2, 2.5, 2] 308 | 309 | if not hasattr(self, "_step"): 310 | self._step = 0 311 | else: 312 | self._step += 1 313 | 314 | if self.multiplier is None: 315 | self.multiplier = 1 / 2 316 | elif self.multiplier == 0: 317 | self.multiplier = 1 318 | else: 319 | self.multiplier /= steps[self._step % len(steps)] 320 | 321 | def pause(self, *args, **kwargs): 322 | self.multiplier = 0 323 | 324 | def reverse(self, *args, **kwargs): 325 | if self.multiplier is None: 326 | self.multiplier = -1 327 | else: 328 | self.multiplier *= -1 329 | 330 | def toggle_groundtrack(self, *args, **kwargs): 331 | status = isinstance(self.sats[0].win_ephem, WindowEphem) 332 | 333 | for sat in self.sats: 334 | if status: 335 | sat.win_ephem = False 336 | else: 337 | sat.win_ephem = None 338 | # Force recomputation of the window ephemeris 339 | 340 | def toggle_circle(self, *args, **kwargs): 341 | 342 | if self.circle is True: 343 | for sat in self.sats: 344 | sat.circle.set_data([], []) 345 | 346 | self.circle ^= True 347 | -------------------------------------------------------------------------------- /space/wspace.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import uuid 4 | import shutil 5 | import logging 6 | import tarfile 7 | import subprocess 8 | from pathlib import Path 9 | from datetime import datetime 10 | from contextlib import contextmanager 11 | from pkg_resources import iter_entry_points 12 | from peewee import SqliteDatabase 13 | 14 | from .utils import docopt, humanize 15 | from .config import SpaceConfig 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | def pm_on_crash(type, value, tb): 21 | """Exception hook, in order to start pdb when an exception occurs""" 22 | import pdb 23 | import traceback 24 | 25 | traceback.print_exception(type, value, tb) 26 | pdb.pm() 27 | 28 | 29 | @contextmanager 30 | def switch_workspace(name, init=False, delete=False): 31 | """Temporarily switch workspace, with a context manager 32 | 33 | Args: 34 | name (str): Name of the workspace to temporarily load 35 | init (bool): If ``True``, this will perform an init of the workspace 36 | delete (bool): At the end of the use of the workspace, delete it 37 | Yield: 38 | Workspace 39 | """ 40 | old_name = ws.name 41 | ws.name = name 42 | 43 | try: 44 | if init: 45 | ws.init() 46 | 47 | ws.load() 48 | yield ws 49 | 50 | if delete: 51 | ws.delete() 52 | finally: 53 | ws.name = old_name 54 | 55 | 56 | class Workspace: 57 | """Workspace handling class""" 58 | 59 | WORKSPACES = Path( 60 | os.environ.get("SPACE_WORKSPACES_FOLDER", Path.home() / ".space/") 61 | ) 62 | HOOKS = ("init", "status", "full-init") 63 | DEFAULT = "main" 64 | BACKUP_FOLDER = WORKSPACES / "_backup" 65 | 66 | def __new__(cls, *args, **kwargs): 67 | # Singleton 68 | if not hasattr(cls, "_instance"): 69 | cls._instance = super().__new__(cls, *args, **kwargs) 70 | return cls._instance 71 | 72 | def __init__(self, name=None): 73 | self.db = SqliteDatabase(None) 74 | self.db.ws = self 75 | self.config = SpaceConfig(self) 76 | self.name = name if name is not None else self.DEFAULT 77 | 78 | def __repr__(self): 79 | return "".format(self.name) 80 | 81 | @classmethod 82 | def list(cls): 83 | """List available workspaces""" 84 | for _ws in cls.WORKSPACES.iterdir(): 85 | if _ws.is_dir(): 86 | if _ws.name == "_backup": 87 | continue 88 | yield _ws 89 | 90 | def _db_init(self): 91 | filepath = self.folder / "space.db" 92 | self.db.init(str(filepath)) 93 | log.debug("{} database initialized".format(filepath.name)) 94 | 95 | def load(self): 96 | """Load the workspace""" 97 | self.config.load() 98 | log.debug("{} loaded".format(self.config.filepath.name)) 99 | self._db_init() 100 | log.debug("workspace '{}' loaded".format(self.name)) 101 | 102 | def delete(self): 103 | if not self.folder.exists(): 104 | raise FileNotFoundError(self.folder) 105 | 106 | shutil.rmtree(str(self.folder)) 107 | log.info("Workspace '{}' deleted".format(self.name)) 108 | 109 | @property 110 | def folder(self): 111 | """Path to the folder of this workspace, as a pathlib.Path object""" 112 | return self.WORKSPACES / self.name 113 | 114 | def exists(self): 115 | return self.folder.exists() 116 | 117 | def init(self, full=False): 118 | """Initilize the workspace""" 119 | 120 | print("Initializing workspace '{}'".format(self.name)) 121 | if not self.exists(): 122 | self.folder.mkdir(parents=True) 123 | 124 | # Due to the way peewee works, we have to initialize the database 125 | # even before the creation of any file 126 | self._db_init() 127 | 128 | if full: 129 | self._trigger("full-init") 130 | else: 131 | self._trigger("init") 132 | 133 | log.debug("{} workspace initialized".format(self.name)) 134 | 135 | def status(self): 136 | log.info("Workspace '{}'".format(self.name)) 137 | log.info("folder {}".format(self.folder)) 138 | log.info("db {}".format(self.db.database)) 139 | log.info("config {}".format(self.config.filepath.name)) 140 | self._trigger("status") 141 | 142 | def _trigger(self, cmd): 143 | if cmd not in self.HOOKS: 144 | raise ValueError("Unknown workspace command '{}'".format(cmd)) 145 | 146 | # Each command is responsible of its own initialization, logging and error handling 147 | for entry in sorted(iter_entry_points("space.wshook"), key=lambda x: x.name): 148 | entry.load()(cmd) 149 | 150 | @classmethod 151 | def get_unique_name(self): 152 | while True: 153 | path = self.WORKSPACES.joinpath(str(uuid.uuid4()).split("-")[0]) 154 | if not path.exists(): 155 | break 156 | 157 | return path 158 | 159 | def backup(self, filepath=None): 160 | """Backup the current workspace into a tar.gz file""" 161 | 162 | if filepath is None: 163 | name = "{}-{:%Y%m%d_%H%M%S}.tar.gz".format(self.name, datetime.utcnow()) 164 | filepath = self.BACKUP_FOLDER / name 165 | if not filepath.parent.exists(): 166 | filepath.parent.mkdir(parents=True) 167 | 168 | def _filter(tarinfo): 169 | """Filtering function""" 170 | if "tmp" in tarinfo.name or "jpl" in tarinfo.name: 171 | return None 172 | else: 173 | return tarinfo 174 | 175 | log.info("Creating backup for workspace '{}'".format(self.name)) 176 | with tarfile.open(filepath, "w:gz") as tar: 177 | tar.add(self.folder, arcname=self.name, filter=_filter) 178 | 179 | log.info("Backup created at {}".format(filepath)) 180 | 181 | def restore(self, name, rename=None): 182 | filepath = self.BACKUP_FOLDER / name 183 | 184 | directory = self.get_unique_name() if rename else self.WORKSPACES 185 | 186 | with tarfile.open(filepath, "r:gz") as tar: 187 | tar.extractall(directory) 188 | 189 | if rename: 190 | directory.joinpath(self.name).replace(self.WORKSPACES.joinpath(rename)) 191 | directory.rmdir() 192 | 193 | def list_backups(self): 194 | for bkp in self.BACKUP_FOLDER.glob("{}-*.tar.gz".format(self.name)): 195 | yield bkp 196 | 197 | 198 | ws = Workspace() 199 | 200 | 201 | def wspace(*argv): 202 | """Workspace management for the space command 203 | 204 | Usage: 205 | wspace list 206 | wspace status [] 207 | wspace init [--full] [] 208 | wspace backup create [] 209 | wspace backup restore [] [] [--rename ] 210 | wspace backup [] 211 | wspace delete 212 | wspace on [--init] 213 | wspace tmp [--init] 214 | 215 | Options: 216 | init Initialize workspace 217 | list List existing workspaces 218 | delete Delete a workspace 219 | status Print informations on a workspace 220 | backup List available backups for a workspace 221 | create Make a new backup 222 | restore Restore a backup (possibly in a new workspace) 223 | Name of the workspace to work in 224 | --full When initializating the workspace, retrieve data to fill it 225 | (download TLEs from celestrak) 226 | --rename 227 | 228 | Examples 229 | $ export SPACE_WORKSPACE=test # switch workspace 230 | $ wspace init # Create empty data structures 231 | $ space tle fetch # Retrieve TLEs from celestrak 232 | is equivalent to: 233 | $ wspace init test 234 | $ space tle fetch -w test 235 | """ 236 | 237 | logging.basicConfig(level=logging.INFO, format="%(message)s") 238 | 239 | if "--pdb" in sys.argv: 240 | sys.argv.remove("--pdb") 241 | sys.excepthook = pm_on_crash 242 | 243 | args = docopt(wspace.__doc__, argv=sys.argv[1:]) 244 | 245 | if args["on"] or args["tmp"]: 246 | if "SPACE_WORKSPACE" in os.environ: 247 | log.error("Nested workspace activation prohibited") 248 | sys.exit(-1) 249 | 250 | if args["on"]: 251 | ws.name = args[""] 252 | else: 253 | # Temporary workspace 254 | ws.name = ws.get_unique_name() 255 | 256 | if args["--init"]: 257 | ws.init() 258 | elif ws.name not in [w.name for w in Workspace.list()]: 259 | log.warning("The workspace '{}' is not initialized.".format(ws.name)) 260 | log.warning("Run 'wspace init' to start working") 261 | 262 | # Duplication of environment variables, to add the SPACE_WORKSPACE variable 263 | env = os.environ.copy() 264 | env["SPACE_WORKSPACE"] = ws.name 265 | shell = env["SHELL"] 266 | 267 | subprocess.run([shell], env=env) 268 | 269 | if args["tmp"]: 270 | if ws.exists(): 271 | ws.delete() 272 | 273 | elif args["delete"]: 274 | # Deletion of a workspace 275 | ws.name = args[""] 276 | if not ws.exists(): 277 | log.error("The workspace '{}' does not exist".format(args[""])) 278 | else: 279 | print( 280 | "If you are sure to delete the workspace '{}', please enter its name".format( 281 | args[""] 282 | ) 283 | ) 284 | 285 | answer = input("> ") 286 | if answer == args[""]: 287 | ws.delete() 288 | else: 289 | print("Deletion canceled") 290 | else: 291 | # The following commands need the `config.workspace` variable set 292 | # in order to work properly 293 | 294 | if args[""]: 295 | name = args[""] 296 | elif "SPACE_WORKSPACE" in os.environ: 297 | name = os.environ["SPACE_WORKSPACE"] 298 | else: 299 | name = Workspace.DEFAULT 300 | 301 | if args["list"] and not args["backup"]: 302 | for _ws in Workspace.list(): 303 | if name == _ws.name: 304 | mark = "*" 305 | color = "\033[32m" 306 | endc = "\033[39m" 307 | else: 308 | mark = "" 309 | color = "" 310 | endc = "" 311 | print("{:1} {}{}{}".format(mark, color, _ws.name, endc)) 312 | sys.exit(0) 313 | else: 314 | ws.name = name 315 | 316 | if args["init"]: 317 | ws.init(args["--full"]) 318 | elif args["backup"]: 319 | if args["create"]: 320 | ws.backup() 321 | elif args["restore"]: 322 | backups = sorted(ws.list_backups(), reverse=True) 323 | # Restore the last backup available 324 | 325 | if not backups: 326 | log.error( 327 | "No backup available for workspace {}".format(ws.name) 328 | ) 329 | sys.exit(1) 330 | elif args[""] is None: 331 | args[""] = backups[0].name 332 | elif args[""] not in [x.name for x in backups]: 333 | log.error( 334 | "{} is not a valid backup for workspace {}".format( 335 | args[""], ws.name 336 | ) 337 | ) 338 | sys.exit(1) 339 | 340 | print( 341 | "Are you sure you want to restore the workspace '{}' with the backup {}".format( 342 | args["--rename"] if args["--rename"] else ws.name, 343 | args[""], 344 | ) 345 | ) 346 | answer = input("yes/NO > ") 347 | if answer == "yes": 348 | ws.restore(args[""], args["--rename"]) 349 | else: 350 | print("Restoration canceled") 351 | sys.exit(1) 352 | else: 353 | for bkp in sorted(ws.list_backups()): 354 | print( 355 | " {} {:%Y-%m-%d %H:%M} {}".format( 356 | bkp.name, 357 | datetime.fromtimestamp(bkp.stat().st_mtime), 358 | humanize(bkp.stat().st_size), 359 | ) 360 | ) 361 | else: 362 | ws.load() 363 | ws.status() 364 | -------------------------------------------------------------------------------- /space/passes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | 4 | import sys 5 | import logging 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | import matplotlib.dates as mdates 9 | from pathlib import Path 10 | 11 | from beyond.propagators.listeners import LightListener, RadialVelocityListener 12 | from beyond.errors import UnknownFrameError 13 | from beyond.env.solarsystem import get_body 14 | from beyond.dates import timedelta 15 | 16 | from .utils import circle, docopt, parse_date, parse_timedelta 17 | from .station import StationDb 18 | from .sat import Sat 19 | from .map.background import set_background 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | 24 | def space_passes(*argv): 25 | """Compute and plot passes geometry 26 | 27 | Usage: 28 | space-passes (- | ...) [options] 29 | 30 | Option: 31 | -h --help Show this help 32 | Location from which the satellite is tracked 33 | Satellite to track. 34 | - If used the orbit should be provided as stdin in TLE 35 | or CCSDS format (see example) 36 | -d --date Starting date of the computation [default: now] 37 | (format: "%Y-%m-%dT%H:%M:%S") 38 | -r --range Range of the computation [default: 1d] 39 | -n --no-events Don't compute AOS, MAX and LOS 40 | -e --events-only Only show AOS, MAX and LOS 41 | -l --light Compute day/penumbra/umbra transitions 42 | -s --step Step-size [default: 30s] 43 | -p --passes Number of passes to display [default: 1] 44 | -g --graphs Display graphics with matplotlib 45 | -z --zenital Reverse direction of azimut angle on the polar plot 46 | to show as the passes as seen from the station 47 | looking to the sky 48 | --el --elevation Plot elevation graphs 49 | --radial Compute radial velocity nullation point 50 | --csv Print in CSV format 51 | --sep= CSV separator [default: ,] 52 | 53 | Examples: 54 | Simple computation of the ISS, TLS is the name of my station 55 | 56 | $ space passes TLS ISS 57 | 58 | Hubble is not part of my satellite database, but I want to compute its 59 | visibility just once 60 | 61 | $ space tle norad 20580 | space passes TLS 62 | 63 | """ 64 | 65 | args = docopt(space_passes.__doc__) 66 | 67 | try: 68 | start = parse_date(args["--date"]) 69 | step = parse_timedelta(args["--step"]) 70 | stop = parse_timedelta(args["--range"]) 71 | pass_nb = int(args["--passes"]) 72 | sats = Sat.from_command( 73 | *args[""], text=sys.stdin.read() if args["-"] else "" 74 | ) 75 | except ValueError as e: 76 | log.error(e) 77 | sys.exit(1) 78 | 79 | try: 80 | station = StationDb.get(args[""]) 81 | except UnknownFrameError: 82 | log.error("Unknwon station '{}'".format(args[""])) 83 | sys.exit(1) 84 | 85 | events = not args["--no-events"] 86 | 87 | light = LightListener() 88 | 89 | if args["--light"] and events: 90 | events = [light, LightListener(LightListener.PENUMBRA)] 91 | if args["--radial"]: 92 | rad = RadialVelocityListener(station, sight=True) 93 | if isinstance(events, list): 94 | events.append(rad) 95 | else: 96 | events = rad 97 | 98 | # Computation of the passes 99 | for i_sat, sat in enumerate(sats): 100 | 101 | lats, lons = [], [] 102 | lats_e, lons_e = [], [] 103 | azims, elevs = [], [] 104 | azims_e, elevs_e, text_e = [], [], [] 105 | 106 | info_size = 0 107 | if args["--csv"]: 108 | print( 109 | args["--sep"].join( 110 | [ 111 | "date", 112 | "event", 113 | "name", 114 | "azimut", 115 | "elevation", 116 | "distance", 117 | "light", 118 | ] 119 | ) 120 | ) 121 | else: 122 | info_size = len(station.name) + 10 123 | header = "Time Infos{} Sat{} Azim Elev Dist (km) Light ".format( 124 | " " * (info_size - 5), " " * (len(sat.name) - 3) 125 | ) 126 | 127 | print(header) 128 | print("=" * len(header)) 129 | 130 | count = 0 131 | dates = [] 132 | lights = [] 133 | for orb in station.visibility( 134 | sat.orb, start=start, stop=stop, step=step, events=events 135 | ): 136 | 137 | if args["--events-only"] and orb.event is None: 138 | continue 139 | 140 | azim = -np.degrees(orb.theta) % 360 141 | elev = np.degrees(orb.phi) 142 | azims.append(azim) 143 | elevs.append(90 - elev) 144 | dates.append(orb.date) 145 | r = orb.r / 1000.0 146 | 147 | if orb.event: 148 | azims_e.append(azim) 149 | elevs_e.append(90 - elev) 150 | 151 | light_info = "Umbra" if light(orb) <= 0 else "Light" 152 | lights.append(light_info) 153 | 154 | if args["--csv"]: 155 | fmt = [ 156 | "{orb.date:%Y-%m-%dT%H:%M:%S.%f}", 157 | "{event}", 158 | "{sat.name}", 159 | "{azim:.2f}", 160 | "{elev:.2f}", 161 | "{r:.2f}", 162 | "{light}", 163 | ] 164 | fmt = args["--sep"].join(fmt) 165 | else: 166 | fmt = "{orb.date:%Y-%m-%dT%H:%M:%S.%f} {event:{info_size}} {sat.name} {azim:7.2f} {elev:7.2f} {r:10.2f} {light}" 167 | 168 | print( 169 | fmt.format( 170 | orb=orb, 171 | r=r, 172 | azim=azim, 173 | elev=elev, 174 | light=light_info, 175 | sat=sat, 176 | event=orb.event if orb.event is not None else "", 177 | info_size=info_size, 178 | ) 179 | ) 180 | 181 | orb_itrf = orb.copy(frame="ITRF") 182 | lon, lat = np.degrees(orb_itrf[1:3]) 183 | lats.append(lat) 184 | lons.append(lon) 185 | 186 | if orb.event: 187 | lats_e.append(lat) 188 | lons_e.append(lon) 189 | text_e.append(orb.event.info) 190 | 191 | if ( 192 | orb.event is not None 193 | and orb.event.info == "LOS" 194 | and orb.event.elev == 0 195 | ): 196 | print() 197 | count += 1 198 | if count == pass_nb: 199 | break 200 | 201 | # Plotting 202 | if args["--graphs"] and azims: 203 | 204 | # Polar plot of the passes 205 | plt.figure(f"{sat.name}", figsize=(15.2, 8.2)) 206 | 207 | ax = plt.subplot(121, projection="polar") 208 | ax.set_theta_zero_location("N") 209 | 210 | plt.title("{} from {}".format(sat.name, station)) 211 | if not args["--zenital"]: 212 | ax.set_theta_direction(-1) 213 | 214 | plt.plot(np.radians(azims), elevs, ".") 215 | 216 | for azim, elev, txt in zip(azims_e, elevs_e, text_e): 217 | plt.plot(np.radians(azim), elev, "ro") 218 | plt.text(np.radians(azim), elev, txt, color="r") 219 | 220 | if station.mask is not None: 221 | 222 | m_azims = np.arange(0, 2 * np.pi, np.pi / 180.0) 223 | m_elevs = [90 - np.degrees(station.get_mask(azim)) for azim in m_azims] 224 | 225 | plt.plot(-m_azims, m_elevs) 226 | 227 | # Add the Moon and Sun traces 228 | bodies = (("Sun", "yo", None), ("Moon", "wo", "k")) 229 | bodies_ephem = {} 230 | 231 | for body, marker, edge in bodies: 232 | 233 | b_ephem = get_body(body).propagate(orb.date).ephem(dates=dates) 234 | bodies_ephem[body] = b_ephem 235 | mazim, melev = [], [] 236 | for m in station.visibility(b_ephem): 237 | mazim.append(-m.theta) 238 | melev.append(90 - np.degrees(m.phi)) 239 | 240 | plt.plot(mazim, melev, marker, mec=edge, mew=0.5) 241 | 242 | ax.set_yticks(range(0, 90, 20)) 243 | ax.set_yticklabels(map(str, range(90, 0, -20))) 244 | ax.set_xticklabels(["N", "NE", "E", "SE", "S", "SW", "W", "NW"]) 245 | ax.set_rmax(90) 246 | 247 | plt.tight_layout() 248 | 249 | plt.subplot(122) 250 | # Ground-track of the passes 251 | set_background() 252 | plt.plot(lons, lats, "b.") 253 | 254 | plt.plot(lons_e, lats_e, "r.") 255 | 256 | color = "#202020" 257 | 258 | # Ground Station 259 | sta_lat, sta_lon = np.degrees(station.latlonalt[:-1]) 260 | 261 | # Visibility circle 262 | lon, lat = np.degrees( 263 | list(zip(*circle(orb_itrf.r, *station.latlonalt[-2::-1]))) 264 | ) 265 | lon = ((lon + 180) % 360) - 180 266 | plt.plot(lon, lat, ".", color=color, ms=2) 267 | 268 | # Mask 269 | if station.mask is not None: 270 | m_azims = np.arange(0, 2 * np.pi, np.pi / 180.0) 271 | m_elevs = [station.get_mask(azim) for azim in m_azims] 272 | mask = [m_azims, m_elevs] 273 | 274 | lon, lat = np.degrees( 275 | list( 276 | zip( 277 | *circle( 278 | orb_itrf.r, 279 | np.radians(sta_lon), 280 | np.radians(sta_lat), 281 | mask=mask, 282 | ) 283 | ) 284 | ) 285 | ) 286 | lon = ((lon + 180) % 360) - 180 287 | plt.plot(lon, lat, color="c", ms=2) 288 | 289 | # Add the moon and sun traces 290 | for body, marker, edge in bodies: 291 | b_itrf = np.asarray( 292 | bodies_ephem[body].copy(frame="ITRF", form="spherical") 293 | ) 294 | lon = ((np.degrees(b_itrf[:, 1]) + 180) % 360) - 180 295 | lat = np.degrees(b_itrf[:, 2]) 296 | plt.plot(lon, lat, marker, mec=edge, mew=0.5) 297 | 298 | plt.xlim([-180, 180]) 299 | plt.ylim([-90, 90]) 300 | plt.grid(linestyle=":") 301 | plt.xticks(range(-180, 181, 30)) 302 | plt.yticks(range(-90, 91, 30)) 303 | plt.tight_layout() 304 | plt.subplots_adjust(left=0.02, right=0.98, top=0.98, bottom=0.02) 305 | 306 | if args["--elevation"]: 307 | 308 | plt.figure("Baleine", figsize=(12, 7)) 309 | 310 | if "axb" not in locals(): 311 | axb = None 312 | 313 | axb = plt.subplot(len(sats), 1, i_sat + 1, sharex=axb) 314 | 315 | plt.plot(dates, 90 - np.array(elevs), label="passes") 316 | light_curve = np.repeat(np.nan, len(dates)) 317 | for i, l in enumerate(lights): 318 | if l == "Light": 319 | light_curve[i] = 90 - elevs[i] 320 | 321 | plt.plot(dates, light_curve, "r", label="illuminated passes") 322 | 323 | ylim = plt.ylim() 324 | xlim = plt.xlim() 325 | sun_orb = get_body("Sun").propagate(start) 326 | sun_dates = [start] 327 | 328 | if sun_orb.copy(frame=station, form="spherical").phi > 0: 329 | sun = [0] 330 | else: 331 | sun = [91] 332 | 333 | for s in station.visibility( 334 | sun_orb, start=start, stop=stop, step=timedelta(minutes=20), events=True 335 | ): 336 | if not s.event or s.event.info not in ("AOS", "LOS"): 337 | continue 338 | 339 | sun_dates.append(s.date) 340 | if s.event.info == "LOS": 341 | sun.append(91) 342 | else: 343 | sun.append(0) 344 | 345 | if ( 346 | sun_orb.propagate(start + stop) 347 | .copy(frame=station, form="spherical") 348 | .phi 349 | > 0 350 | ): 351 | sun.append(0) 352 | else: 353 | sun.append(91) 354 | sun_dates.append(start + stop) 355 | 356 | plt.fill_between( 357 | sun_dates, sun, step="post", color="k", alpha=0.2, zorder=-100 358 | ) 359 | 360 | plt.ylim(0, ylim[1]) 361 | # plt.xlim(*xlim) 362 | plt.grid(ls=":") 363 | plt.ylabel(sat.name) 364 | 365 | if i_sat != len(sats) - 1: 366 | plt.xticks(color="w") 367 | 368 | axb.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) 369 | 370 | plt.suptitle(station.name) 371 | plt.tight_layout() 372 | plt.subplots_adjust(hspace=0) 373 | 374 | if args["--graphs"] or args["--elevation"]: 375 | plt.show() 376 | -------------------------------------------------------------------------------- /space/config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import yaml 3 | import shutil 4 | import logging.config 5 | from pathlib import Path 6 | from copy import copy 7 | from textwrap import indent 8 | from datetime import datetime, timedelta 9 | 10 | from beyond.config import config as beyond_config, Config as BeyondConfig 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class SpaceFilter(logging.Filter): 16 | """Specific logging filter in order to keep messages from space commands 17 | and the beyond library 18 | """ 19 | 20 | def filter(self, record): 21 | pkg, sep, rest = record.name.partition(".") 22 | return pkg in ("space", "beyond") 23 | 24 | 25 | class ColoredFormatter(logging.Formatter): 26 | 27 | RESET = "\033[0m" 28 | 29 | COLORS = { 30 | "WARNING": "\033[33m", 31 | "INFO": "", 32 | "DEBUG": "\033[37m", 33 | "ERROR": "\033[91m", 34 | "CRITICAL": "\033[31m", 35 | } 36 | 37 | def format(self, record): 38 | if record.levelname in self.COLORS: 39 | # Create a copy of the record in order to not modify the original record, 40 | # as it may be used by other formatters/loggers 41 | record = copy(record) 42 | 43 | if record.levelname != "INFO": 44 | c = self.COLORS[record.levelname] 45 | r = self.RESET 46 | else: 47 | c = r = "" 48 | 49 | record.levelname = f"{c}{record.levelname}{r}" 50 | record.msg = f"{c}{record.msg}{r}" 51 | 52 | return super().format(record) 53 | 54 | 55 | class SpaceConfig(BeyondConfig): 56 | 57 | verbose = False 58 | colors = True 59 | 60 | def __init__(self, workspace): 61 | self.workspace = workspace 62 | 63 | @property 64 | def filepath(self): 65 | return self.workspace.folder / "config.yml" 66 | 67 | def set(self, *args, save=False): 68 | super().set(*args) 69 | if save: 70 | self.save() 71 | 72 | def init(self): 73 | """Initialize a given workspace folder and config file""" 74 | 75 | if self.filepath.exists(): 76 | raise FileExistsError(self.filepath) 77 | 78 | self.update({"beyond": {"eop": {"missing_policy": "pass"}}}) 79 | self.save() 80 | 81 | def load(self): 82 | """Load the config file and create missing directories""" 83 | 84 | data = yaml.safe_load(self.filepath.open()) 85 | self.update(data) 86 | 87 | beyond_config.update(self["beyond"]) 88 | 89 | if "logging" in self.keys(): 90 | logging_dict = self["logging"] 91 | else: 92 | logging_dict = { 93 | "disable_existing_loggers": False, 94 | "filters": {"space_filter": {"()": SpaceFilter}}, 95 | "formatters": { 96 | "dated": { 97 | "format": "%(asctime)s.%(msecs)03d :: %(name)s :: %(levelname)s :: %(message)s", 98 | "datefmt": "%Y-%m-%dT%H:%M:%S", 99 | }, 100 | "simple": {"format": "%(message)s"}, 101 | "colored": {"()": ColoredFormatter}, 102 | }, 103 | "handlers": { 104 | "console": { 105 | "class": "logging.StreamHandler", 106 | "formatter": "colored" if self.colors else "simple", 107 | "level": "INFO" if not self.verbose else "DEBUG", 108 | "filters": ["space_filter"], 109 | }, 110 | "debug_file_handler": { 111 | "backupCount": 5, 112 | "when": "W2", 113 | "class": "logging.handlers.TimedRotatingFileHandler", 114 | "encoding": "utf8", 115 | "filename": str(self.filepath.parent / "space.log"), 116 | "filters": ["space_filter"], 117 | "formatter": "dated", 118 | "level": "DEBUG", 119 | }, 120 | }, 121 | "loggers": { 122 | "space_logger": { 123 | "handlers": ["console", "debug_file_handler"], 124 | "level": "DEBUG", 125 | "propagate": False, 126 | } 127 | }, 128 | "root": { 129 | "handlers": ["console", "debug_file_handler"], 130 | "level": "DEBUG", 131 | }, 132 | "version": 1, 133 | } 134 | 135 | if logging_dict: 136 | logging.config.dictConfig(logging_dict) 137 | 138 | def save(self): 139 | yaml.safe_dump(dict(self), self.filepath.open("w"), indent=4) 140 | 141 | 142 | def get_dict(d): 143 | txt = [] 144 | for k, v in d.items(): 145 | if isinstance(v, dict): 146 | txt.append("%s:\n%s" % (k, indent(get_dict(v), " " * 4))) 147 | elif isinstance(v, (list, tuple)): 148 | txt.append("%s:\n%s" % (k, indent("\n".join([str(x) for x in v]), " " * 4))) 149 | else: 150 | txt.append("%s: %s" % (k, v)) 151 | return "\n".join(txt) 152 | 153 | 154 | class Lock: 155 | 156 | fmt = "%Y-%m-%dT%H:%M:%S" 157 | duration = timedelta(minutes=5) 158 | 159 | def __init__(self, file): 160 | self.file = Path(file) 161 | 162 | @property 163 | def lock_file(self): 164 | return self.file.with_name(".unlock_" + self.file.stem) 165 | 166 | @property 167 | def backup(self): 168 | return self.file.with_suffix(self.file.suffix + ".backup") 169 | 170 | def unlock(self): 171 | 172 | until = datetime.now() + self.duration 173 | 174 | with self.lock_file.open("w") as fp: 175 | fp.write(until.strftime(self.fmt)) 176 | 177 | shutil.copy2(str(self.file), str(self.backup)) 178 | 179 | log.info("Unlocking {} until {:{}}".format(self.file, until, self.fmt)) 180 | log.debug( 181 | "A backup of the current config file has been created at {}".format( 182 | self.backup 183 | ) 184 | ) 185 | 186 | def lock(self): 187 | if self.lock_file.exists(): 188 | self.lock_file.unlink() 189 | log.info("Locking the config file") 190 | else: 191 | log.info("The config file is already locked") 192 | 193 | def locked(self): 194 | if self.lock_file.exists(): 195 | txt = self.lock_file.open().read().strip() 196 | until = datetime.strptime(txt, self.fmt) 197 | return until < datetime.now() 198 | 199 | return True 200 | 201 | 202 | def wshook(cmd): 203 | 204 | from .wspace import ws 205 | 206 | if cmd in ("init", "full-init"): 207 | 208 | try: 209 | ws.config.init() 210 | except FileExistsError as e: 211 | ws.config.load() 212 | log.warning( 213 | "config file already exists at '{}'".format(Path(str(e)).absolute()) 214 | ) 215 | else: 216 | ws.config.load() # Load the newly created config file 217 | log.info("config creation at {}".format(ws.config.filepath.absolute())) 218 | 219 | 220 | def space_config(*argv): 221 | """Configuration handling 222 | 223 | Usage: 224 | space-config edit 225 | space-config set [--append] 226 | space-config unlock [--yes] 227 | space-config lock 228 | space-config [get] [] 229 | 230 | Options: 231 | unlock Enable command-line config alterations for the next 5 minutes 232 | lock Disable command-line config alterations 233 | get Print the value of the selected fields 234 | set Set the value of the selected field (needs unlock) 235 | edit Open the text editor defined via $EDITOR env variable (needs unlock) 236 | Field selector, in the form of key1.key2.key3... 237 | Value to set the field to 238 | Folder in which to create the config file 239 | --append Append the value to a list 240 | 241 | Examples: 242 | space config set aliases.ISS 25544 243 | space config aliases.ISS 244 | 245 | """ 246 | 247 | import os 248 | from subprocess import run 249 | 250 | from .utils import docopt 251 | from space.wspace import ws 252 | 253 | args = docopt(space_config.__doc__) 254 | 255 | lock = Lock(ws.config.filepath) 256 | 257 | if args["edit"]: 258 | if not lock.locked(): 259 | run([os.environ["EDITOR"], str(ws.config.filepath)]) 260 | if lock.file.read_text() == lock.backup.read_text(): 261 | log.info("Unchanged config file") 262 | else: 263 | log.info("Config file modified") 264 | else: 265 | print( 266 | "Config file locked. Please use 'space config unlock' first", 267 | file=sys.stderr, 268 | ) 269 | sys.exit(1) 270 | elif args["set"]: 271 | if not lock.locked(): 272 | try: 273 | keys = args[""].split(".") 274 | if args["--append"]: 275 | prev = ws.config.get(*keys, fallback=[]) 276 | if not isinstance(prev, list): 277 | if isinstance(prev, str): 278 | prev = [prev] 279 | else: 280 | prev = list(prev) 281 | prev.append(args[""]) 282 | ws.config.set(*keys, prev, save=False) 283 | else: 284 | ws.config.set(*keys, args[""], save=False) 285 | except TypeError as e: 286 | # For some reason we don't have the right to set this 287 | # value 288 | print(e, file=sys.stderr) 289 | sys.exit(1) 290 | else: 291 | # If everything went fine, we save the file in its new state 292 | ws.config.save() 293 | log.debug( 294 | "'{}' now set to '{}'".format(args[""], args[""]) 295 | ) 296 | else: 297 | print( 298 | "Config file locked. Please use 'space config unlock' first", 299 | file=sys.stderr, 300 | ) 301 | sys.exit(1) 302 | 303 | elif args["unlock"]: 304 | if args["--yes"]: 305 | lock.unlock() 306 | else: 307 | print("Are you sure you want to unlock the config file ?") 308 | ans = input(" yes/[no] ") 309 | 310 | if ans.lower() == "yes": 311 | lock.unlock() 312 | elif ans.lower() != "no": 313 | print("unknown answer '{}'".format(ans), file=sys.stderr) 314 | sys.exit(1) 315 | elif args["lock"]: 316 | lock.lock() 317 | else: 318 | 319 | subdict = ws.config 320 | 321 | try: 322 | if args[""]: 323 | for k in args[""].split("."): 324 | subdict = subdict[k] 325 | except KeyError as e: 326 | print("Unknown field", e, file=sys.stderr) 327 | sys.exit(1) 328 | 329 | if hasattr(subdict, "filepath"): 330 | print("config :", ws.config.filepath) 331 | if isinstance(subdict, dict): 332 | # print a part of the dict 333 | print(get_dict(subdict)) 334 | else: 335 | # Print a single value 336 | print(subdict) 337 | 338 | 339 | def space_log(*argv): 340 | """Display the log, with colors 341 | 342 | Usage: 343 | space-log list 344 | space-log [-fn] [--file ] 345 | 346 | Options: 347 | list List available log files 348 | -F, --file Selected file [default: space.log] 349 | -f, --follow Start directly in tail mode 350 | -n, --no-color Disable log highlight 351 | """ 352 | import subprocess 353 | 354 | from space.utils import docopt 355 | from space.wspace import ws 356 | 357 | args = docopt(space_log.__doc__, argv=argv) 358 | 359 | if args["list"]: 360 | for f in sorted(ws.folder.glob("space.log*")): 361 | print(f.name) 362 | else: 363 | 364 | logfile = ws.folder / args["--file"] 365 | # This is not working in case of live view of log (-f option) 366 | less_args = "-KS" 367 | 368 | if not args["--no-color"] and not args["--follow"]: 369 | less_args += "R" 370 | 371 | colorized = ws.folder / "tmp" / "colorize.log" 372 | with colorized.open("w") as fp: 373 | for line in logfile.open().read().splitlines(): 374 | fields = line.split(" :: ") 375 | if len(fields) > 2 and fields[2] in ColoredFormatter.COLORS: 376 | line = f"{ColoredFormatter.COLORS[fields[2]]}{line}{ColoredFormatter.RESET}" 377 | elif line == "Traceback (most recent call last):": 378 | line = f"\033[1;4m{line}\033[0m" 379 | elif "Error" in line: 380 | line = f"\033[1m{line}\033[0m" 381 | 382 | fp.write(f"{line}\n") 383 | 384 | logfile = colorized 385 | 386 | try: 387 | # Handling the arguments of less 388 | # +G is for going directly at the bottom of the file 389 | # +F is for tail mode 390 | # -K is for "quit on iterrupt" 391 | # -S chops long lines 392 | opt = "+F" if args["--follow"] else "+G" 393 | f = subprocess.call(["less", less_args, opt, str(logfile)]) 394 | except KeyboardInterrupt: 395 | pass 396 | -------------------------------------------------------------------------------- /space/sat.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import logging 4 | from peewee import ( 5 | Model, 6 | IntegerField, 7 | CharField, 8 | TextField, 9 | ) 10 | 11 | from beyond.orbits import Ephem, Orbit, StateVector 12 | from beyond.io.tle import Tle 13 | 14 | from .clock import Date 15 | from .utils import parse_date 16 | from .wspace import ws 17 | from . import ccsds 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | class NoDataError(ValueError): 23 | def __init__(self, req): 24 | self.req = req 25 | 26 | def __str__(self): 27 | return "No data for {req}".format(req=self.req) 28 | 29 | 30 | class Request: 31 | """Request desrciptor 32 | 33 | Contain all the necessary elements to perform a search on satellite and orbit 34 | (TLE, OEM) databases 35 | """ 36 | 37 | def __init__(self, selector, value, src, offset, limit, date): 38 | self.selector = selector 39 | """Field of selection""" 40 | 41 | self.value = value 42 | """Value of the field""" 43 | 44 | self.src = src 45 | """Source of orbit ('oem' or 'tle')""" 46 | 47 | self.offset = offset 48 | """Offset from the last orbit""" 49 | 50 | self.limit = limit 51 | """If a date is provided, define if the search should be done 'before' or 'after'""" 52 | 53 | self.date = date 54 | """Date of the research""" 55 | 56 | def __str__(self): 57 | 58 | txt = "{o.selector}={o.value}" 59 | 60 | if self.src is not None: 61 | txt += "@{o.src}" 62 | if self.offset > 0: 63 | txt += "~{o.offset}" 64 | if isinstance(self.date, Date): 65 | txt += "{limit}{o.date:%FT%T}" 66 | 67 | return txt.format(o=self, limit="^" if self.limit == "after" else "?") 68 | 69 | def __repr__(self): 70 | return """Request: 71 | selector: {self.selector} 72 | value: {self.value} 73 | src: {self.src} 74 | offset: {self.offset} 75 | limit: {self.limit} 76 | date: {self.date} 77 | """.format( 78 | self=self 79 | ) 80 | 81 | @classmethod 82 | def from_text(cls, txt, alias=True, **kwargs): 83 | """Convert a strin 'cospar=1998-067A@tle~~' to a Request object 84 | 85 | Any kwargs matching Request object attributes will override the text value 86 | For example ``Request.from_text("ISS@tle", src='oem')`` will deliver a 87 | request object with ``req.src == 'oem'``. 88 | 89 | Inseparable pairs are selector/value and limit/date respectively 90 | """ 91 | 92 | delimiters = r"[@~\^\?\$]" 93 | 94 | if "selector" in kwargs and "value" in kwargs: 95 | selector = kwargs["selector"] 96 | value = kwargs["value"] 97 | else: 98 | selector_str = re.split(delimiters, txt)[0] 99 | 100 | if "=" in selector_str: 101 | selector, value = selector_str.split("=") 102 | else: 103 | selector, value = "name", selector_str 104 | 105 | if alias and selector == "name": 106 | # Retrieve alias if it exists 107 | rq = Alias.select().where(Alias.name == value) 108 | if rq.exists(): 109 | selector, value = rq.get().selector.split("=") 110 | 111 | if selector in ("norad", "cospar"): 112 | selector += "_id" 113 | 114 | if selector not in ("name", "cospar_id", "norad_id"): 115 | raise ValueError("Unknown selector '{}'".format(selector)) 116 | 117 | if "offset" in kwargs: 118 | offset = kwargs["offset"] 119 | else: 120 | # Compute the offset 121 | offset = 0 122 | if "~" in txt: 123 | offset = txt.count("~") 124 | if offset == 1: 125 | m = re.search(r"~(\d+)", txt) 126 | if m: 127 | offset = int(m.group(1)) 128 | 129 | if "src" in kwargs: 130 | src = kwargs["src"] 131 | else: 132 | m = re.search(r"@([a-zA-Z0-9]+)", txt, flags=re.I) 133 | if m: 134 | src = m.group(1).lower() 135 | else: 136 | src = ws.config.get("satellites", "default-orbit-type", fallback="tle") 137 | 138 | if "limit" in kwargs and "date" in kwargs: 139 | limit = kwargs["limit"] 140 | date = kwargs["date"] 141 | else: 142 | limit = "any" 143 | date = "any" 144 | m = re.search(r"(\^|\?)([0-9-T:.]+)", txt) 145 | if m: 146 | if m.group(1) == "^": 147 | limit = "after" 148 | elif m.group(1) == "?": 149 | limit = "before" 150 | 151 | try: 152 | date = parse_date(m.group(2), fmt="date") 153 | except ValueError: 154 | date = parse_date(m.group(2)) 155 | 156 | return cls(selector, value, src, offset, limit, date) 157 | 158 | 159 | class MyModel(Model): 160 | """Generic database object""" 161 | 162 | class Meta: 163 | database = ws.db 164 | 165 | 166 | class SatModel(MyModel): 167 | """Satellite object to interact with the database, not directly given to the 168 | user 169 | """ 170 | 171 | norad_id = IntegerField(null=True) 172 | cospar_id = CharField(null=True) 173 | name = CharField() 174 | comment = TextField(null=True) 175 | 176 | def exists(self): 177 | return SatModel.select().where(SatModel.cospar_id == self.cospar_id).exists() 178 | 179 | class Meta: 180 | table_name = "sat" 181 | 182 | 183 | class Alias(MyModel): 184 | name = CharField(unique=True) 185 | selector = CharField() 186 | 187 | 188 | class Sat: 189 | def __init__(self, model): 190 | self.model = model 191 | self.orb = None 192 | self.req = None 193 | 194 | def __repr__(self): 195 | return "" % self.name 196 | 197 | @classmethod 198 | def from_orb(cls, orb): 199 | """From an Orbit or Ephem object""" 200 | try: 201 | model = SatModel.select().where(SatModel.cospar_id == orb.cospar_id).get() 202 | except SatModel.DoesNotExist: 203 | model = SatModel( 204 | norad_id=getattr(orb, "norad_id", None), 205 | cospar_id=getattr(orb, "cospar_id", None), 206 | name=getattr(orb, "name", None), 207 | ) 208 | 209 | if isinstance(orb, Tle): 210 | orb = orb.orbit() 211 | 212 | sat = cls(model) 213 | sat.orb = orb 214 | return sat 215 | 216 | @classmethod 217 | def from_selector(cls, string, **kwargs): 218 | """Method to parse a selector string such as 'norad=25544@oem~~' 219 | 220 | This method is oriented toward developers, to allow them to access 221 | quickly and simply an orbital object. 222 | 223 | Args: 224 | string (str): Selector string 225 | Keyword Args: 226 | temporary (bool) : if True, a Sat object is created even if there is no match 227 | for the request in the database. This Sat object is not saved into the database. 228 | False by default. 229 | save (bool) : if True, a Sat object is created even if there is no match 230 | for the request in the database. This Sat object is then saved into the database. 231 | False by default. 232 | orb (bool) : if False, do not retrieve the orbital data associated with the request. 233 | True by default. 234 | alias (bool) : if False, disable aliases lookup. True by default. 235 | Return: 236 | Sat 237 | 238 | Any field of the selector string can be overridden by its associated 239 | keyword argument. 240 | 241 | Keyword Args: 242 | selector (str) : "name", "cospar" or "norad" 243 | value (str) : The value associated with the selector 244 | offset (int) : offset of orbital data to retrieve. for example offset=1 will get you 245 | the first before last orbital data available 246 | src (str) : Orbital data source ("oem", "opm" or "tle") 247 | limit (str) : Date action. "after", "before" or "any" 248 | date (Date) : 249 | 250 | Example: 251 | # retrieve the latest TLE of the ISS 252 | Sat.from_selector("ISS@tle") 253 | # retrieve the first TLE of the year (after 2020-01-01 at midnight) 254 | Sat.from_selector("ISS@tle^2020-01-01") 255 | """ 256 | req = Request.from_text(string, **kwargs) 257 | return cls._from_request(req, **kwargs) 258 | 259 | @classmethod 260 | def from_selectors(cls, *selectors, **kwargs): 261 | """Retrieve multiples satellites at once""" 262 | for sel in selectors: 263 | yield cls.from_selector(sel, **kwargs) 264 | 265 | @classmethod 266 | def from_text(cls, text): 267 | """This method is used to parse an orbit from stdin""" 268 | sats = [cls.from_orb(tle) for tle in Tle.from_string(text)] 269 | 270 | if not sats: 271 | try: 272 | orb = ccsds.loads(text) 273 | except ValueError: 274 | raise ValueError("No valid TLE nor CCSDS") 275 | else: 276 | if isinstance(orb, (Ephem, StateVector)): 277 | sats = [cls.from_orb(orb)] 278 | else: 279 | sats = [cls.from_orb(ephem) for ephem in orb] 280 | 281 | return sats 282 | 283 | @classmethod 284 | def from_command(cls, *selector, text="", **kwargs): 285 | """This method is intended to be used as parser of command line arguments 286 | to handle both selector strings ("norad=25544@tle~32") as well as stdin 287 | inputs. 288 | 289 | The canonical way to use this method is: 290 | 291 | Sat.from_command( 292 | *args[""], 293 | text=sys.stdin.read() if args["-"] else "" 294 | ) 295 | 296 | see :py:meth:`Sat.from_selector` for the others keyword arguments 297 | """ 298 | 299 | sats = list(cls.from_selectors(*selector, **kwargs)) 300 | 301 | if not sats: 302 | if text: 303 | sats = cls.from_text(text) 304 | else: 305 | raise ValueError("No satellite found") 306 | 307 | return sats 308 | 309 | @classmethod 310 | def _from_request(cls, req, create=False, temporary=False, orb=True, **kwargs): 311 | """This method convert a Request object to a Sat object with the 312 | associated orbital data (TLE, OEM or OPM) 313 | """ 314 | 315 | if create or temporary: 316 | orb = False 317 | 318 | from .tle import TleDb, TleNotFound 319 | 320 | try: 321 | model = SatModel.select().filter(**{req.selector: req.value}).get() 322 | except SatModel.DoesNotExist: 323 | if temporary or create: 324 | model = SatModel(**{req.selector: req.value}) 325 | if create: 326 | model.save() 327 | else: 328 | raise ValueError( 329 | "No satellite corresponding to {0.selector}={0.value}".format(req) 330 | ) 331 | 332 | sat = cls(model) 333 | sat.req = req 334 | 335 | if orb: 336 | if sat.req.src == "tle": 337 | if sat.req.limit == "any": 338 | try: 339 | tles = list( 340 | TleDb().history( 341 | **{sat.req.selector: sat.req.value}, 342 | number=sat.req.offset + 1, 343 | ) 344 | ) 345 | except TleNotFound: 346 | raise NoDataError(sat.req) 347 | 348 | if len(tles) <= sat.req.offset: 349 | raise NoDataError(sat.req) 350 | 351 | sat.orb = tles[0].orbit() 352 | else: 353 | try: 354 | tle = TleDb.get_dated( 355 | limit=sat.req.limit, 356 | date=sat.req.date.datetime, 357 | **{sat.req.selector: sat.req.value}, 358 | ) 359 | except TleNotFound: 360 | raise NoDataError(sat.req) 361 | else: 362 | sat.orb = tle.orbit() 363 | elif sat.req.src in ("oem", "opm"): 364 | pattern = "*.{}".format(sat.req.src) 365 | if sat.folder.exists(): 366 | try: 367 | sat.orb = ccsds.CcsdsDb.get(sat) 368 | except ValueError: 369 | raise NoDataError(sat.req) 370 | else: 371 | raise NoDataError(sat.req) 372 | else: 373 | tags = ccsds.CcsdsDb.tags(sat) 374 | if sat.req.src in tags.keys(): 375 | sat.orb = ccsds.load(tags[sat.req.src].open()) 376 | else: 377 | raise NoDataError(sat.req) 378 | 379 | return sat 380 | 381 | @property 382 | def name(self): 383 | return self.model.name 384 | 385 | @property 386 | def cospar_id(self): 387 | return self.model.cospar_id 388 | 389 | @property 390 | def norad_id(self): 391 | return self.model.norad_id 392 | 393 | @property 394 | def folder(self): 395 | year, idx = self.cospar_id.split("-") 396 | return ws.folder / "satdb" / year / idx 397 | 398 | 399 | def sync(source="all"): 400 | """Update the database from the content of the TLE database and/or 401 | the Ephem database 402 | 403 | Args: 404 | source (str): 'all', 'tle', 'ephem' 405 | """ 406 | 407 | new = [] 408 | update = [] 409 | 410 | if source in ("all", "tle"): 411 | from .tle import TleDb 412 | 413 | # The resoning of the Tle Database is by NORAD ID 414 | all_sats = {sat.norad_id: sat for sat in SatModel.select()} 415 | 416 | for tle in TleDb().dump(): 417 | if tle.norad_id not in all_sats: 418 | sat = SatModel( 419 | name=tle.name, cospar_id=tle.cospar_id, norad_id=tle.norad_id 420 | ) 421 | new.append(sat) 422 | log.debug( 423 | "{sat.norad_id} added (name='{sat.name}' cospar_id='{sat.cospar_id}')".format( 424 | sat=sat 425 | ) 426 | ) 427 | else: 428 | sat = all_sats[tle.norad_id] 429 | if tle.name != sat.name or tle.cospar_id != sat.cospar_id: 430 | log.debug( 431 | "{sat.norad_id} updated. name='{sat.name}'-->'{tle.name}' cospar_id='{sat.cospar_id}'-->'{tle.cospar_id}' ".format( 432 | sat=sat, tle=tle 433 | ) 434 | ) 435 | sat.name = tle.name 436 | sat.cospar_id = tle.cospar_id 437 | update.append(sat) 438 | 439 | log.debug("{} new satellites found in the TLE database".format(len(new))) 440 | log.debug("{} satellites to update from the TLE database".format(len(update))) 441 | 442 | new_idx = 0 443 | if source in ("all", "ephem"): 444 | 445 | # The organization of the ephem database is by COSPAR ID 446 | all_sats = {sat.cospar_id: sat for sat in SatModel.select()} 447 | 448 | folders = {} 449 | for folder in ws.folder.joinpath("satdb").glob("*/*"): 450 | cospar_id = "{}-{}".format(folder.parent.name, folder.name) 451 | folders[cospar_id] = list(folder.glob("*.oem")) 452 | 453 | # Filtering out satellites for which an entry in the Sat DB already exists 454 | cospar_ids = set(folders.keys()).difference(all_sats.keys()) 455 | 456 | for cospar_id in cospar_ids: 457 | # print(cospar_id, folders[cospar_id]) 458 | files = folders[cospar_id] 459 | if files: 460 | name = ccsds.load(files[0].open()).name 461 | else: 462 | name = "UNKNOWN" 463 | # log.debug() 464 | 465 | log.debug( 466 | "New satellite '{}' ({}) found in ephem file".format(name, cospar_id) 467 | ) 468 | new.append(SatModel(cospar_id=cospar_id, name=name)) 469 | new_idx += 1 470 | 471 | if not cospar_ids: 472 | log.debug("{} new satellites found in ephem files".format(new_idx)) 473 | 474 | with ws.db.atomic(): 475 | for sat in update + new: 476 | sat.save() 477 | 478 | log.info( 479 | "{} new satellites registered, {} satellites updated".format( 480 | len(new), len(update) 481 | ) 482 | ) 483 | 484 | 485 | def wshook(cmd, *args, **kwargs): 486 | 487 | if cmd in ("init", "full-init"): 488 | 489 | SatModel.create_table(safe=True) 490 | Alias.create_table(safe=True) 491 | 492 | if SatModel.select().exists(): 493 | log.warning("SatDb already initialized") 494 | else: 495 | log.debug("Populating SatDb with TLE") 496 | sync() 497 | 498 | if not Alias.select().where(Alias.name == "ISS").exists(): 499 | Alias(name="ISS", selector="norad_id=25544").save() 500 | log.info("Creating ISS alias") 501 | 502 | 503 | def space_sat(*argv): 504 | """Get sat infos 505 | 506 | Usage: 507 | space-sat alias [--force] 508 | space-sat list-aliases 509 | space-sat orb 510 | space-sat sync 511 | space-sat infos 512 | 513 | Options: 514 | alias Create an alias for quick access 515 | orb Display the orbit corresponding to the selector 516 | list-aliases List existing aliases 517 | sync Update satellite database with existing TLEs 518 | infos Display informations about a satellite 519 | See below 520 | 521 | Satellite selectors 522 | ISS : latest TLE of ISS 523 | norad=25544 : latest TLE of ISS selected by norad number 524 | cospar=2018-027A : latest TLE of GSAT-6A 525 | ISS@oem : latest OEM 526 | ISS@tle : latest TLE 527 | ISS~ : before last TLE 528 | ISS~~ : 2nd before last TLE 529 | ISS@oem~25 : 25th before last OEM 530 | ISS@oem^2018-12-25 : first OEM after the date 531 | ISS@tle?2018-12-25 : first TLE before the date 532 | """ 533 | # TODO 534 | # ISS@opm : latest OPM 535 | 536 | from .utils import docopt 537 | 538 | args = docopt(space_sat.__doc__, argv=argv) 539 | 540 | if args["alias"]: 541 | selector = args[""] 542 | name = args[""] 543 | 544 | try: 545 | sat = Sat.from_selector(selector, orb=False) 546 | except ValueError as e: 547 | log.error("Unknown satellite '{}'".format(selector)) 548 | sys.exit(1) 549 | 550 | q = Alias.select().where(Alias.name == name) 551 | if q.exists(): 552 | if args["--force"]: 553 | alias = q.get() 554 | alias.selector = selector 555 | alias.save() 556 | log.info("Alias '{}' ({}) created".format(name, selector)) 557 | else: 558 | log.error( 559 | "Alias '{}' already exists for '{}'".format(name, q.get().selector) 560 | ) 561 | sys.exit() 562 | else: 563 | Alias(selector=selector, name=name).save() 564 | log.info("Alias '{}' ({}) created".format(name, selector)) 565 | 566 | elif args["list-aliases"]: 567 | for alias in Alias: 568 | print("{:20} {}".format(alias.name, alias.selector)) 569 | 570 | elif args["sync"]: 571 | sync() 572 | 573 | elif args["orb"]: 574 | try: 575 | sat = Sat.from_selector(args[""]) 576 | except ValueError as e: 577 | log.error(e) 578 | sys.exit(1) 579 | 580 | if hasattr(sat.orb, "tle"): 581 | print("{0.name}\n{0}".format(sat.orb.tle)) 582 | else: 583 | print(ccsds.dumps(sat.orb)) 584 | 585 | elif args["infos"]: 586 | try: 587 | (sat,) = Sat.from_command(args[""], orb=False) 588 | print( 589 | """name {0.name} 590 | cospar id {0.cospar_id} 591 | norad id {0.norad_id} 592 | folder {0.folder} 593 | """.format( 594 | sat 595 | ) 596 | ) 597 | except ValueError as e: 598 | log.error(e) 599 | sys.exit(1) 600 | --------------------------------------------------------------------------------