├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── README.md ├── config.ini ├── docs └── screenshot.jpg ├── requirements.txt └── satellite-data-visualizer.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 160 4 | # max-complexity = 18 5 | # select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | /tledata 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.2.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/pycqa/isort 9 | rev: 5.10.1 10 | hooks: 11 | - id: isort 12 | name: isort (python) 13 | - repo: https://gitlab.com/pycqa/flake8 14 | rev: 3.9.2 15 | hooks: 16 | - id: flake8 17 | # - repo: https://github.com/psf/black 18 | # rev: 21.12b0 19 | # hooks: 20 | # - id: black 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Satellite Data Visualizer for Python 2 | 3 | A Python 3.6+ tool for visualizing satellite positions from using TLE (two-line element set) formatted data using matplotlib and your choice of graphical backends (Tcl/Tk by default). 4 | 5 | ![Sample screenshot](/docs/screenshot.jpg?raw=true) 6 | 7 | More about TLE data at https://en.wikipedia.org/wiki/Two-line_element_set 8 | 9 | Note: This is developed and tested using Python 3.6 on Windows and cross-tested using Python 3.6 on OS X, but as long as the same libraries are available in your OS this should work there as well. 10 | 11 | ## Installation 12 | 13 | Install Python 3.6 or later 14 | 15 | Optional: [use `venv` to create a workspace](https://docs.python.org/3/library/venv.html) (it's a nice, clean way to manage application-specific dependencies) 16 | 17 | Install requirements: `pip install -U -r requirements.txt` 18 | 19 | Select a backend: This application initially uses the Tcl/Tk ('TkAgg') backend that's available on most systems by default, but I've successfully used the Qt5 ('Qt5Agg') and wxPython ('WxAgg') backends instead. You can change this in `config.ini`. Additional requirements as follows: 20 | 21 | * Qt5Agg: `pip install -U pyqt5` 22 | * WxAgg: `pip install -U wxpython` 23 | 24 | OS X note: If you see `"urllib.error.URLError: `Python 3.7` (or later) 28 | - Run `Install Certificates.command` 29 | 30 | ## Usage 31 | 32 | For geocoding data (converting an address to coordinates and elevation), you'll need [a Google Geocoding API key](https://developers.google.com/maps/documentation/geocoding/get-api-key). There appears to be [a generous monthly credit](https://cloud.google.com/maps-platform/pricing/) that negates the marginal cost of this. Enter the key when prompted, or set it in your environment (e.g., `export GOOGLE_API_KEY=[secret_key]` in Linux/OSX or `set GOOGLE_API_KEY=[secret_key]` in Windows) before running the app. 33 | 34 | Reminder: you can always just enter your coordinates (and optional elevation) directly to avoid this. 35 | 36 | Run: `satellite-data-visualizer.py` 37 | 38 | Enter a location (in quotes on the command line, or at the prompt): 39 | - H:M:S coordinates (specify N/S and E/W, or use -/+ degrees relative to N,E) 40 | - Decimal coordinates (-/+ degrees relative to N,E) 41 | - Elevation is optional with coordinate entries (default is 0.0m) 42 | - or a place name, e.g., "Seattle" 43 | - Elevation is automatically looked up for place name entries 44 | 45 | Interesting locations (elevations for coordinate pairs are up to the user): 46 | - 90:00:00.0N, 0:00:00.0W = Geographic North Pole 47 | - 90:00:00.0S, 0:00:00.0E = Geographic South Pole 48 | - 86:17:24.0N, 160:03:36.0W = North Magnetic Pole (2015) 49 | - 64:31:48.0S, 137:51:36.0E = South Magnetic Pole (2015) 50 | - 80:30:00.0N, 72:48:00.0W = North Geomagnetic Pole (2017) 51 | - 80:30:00.0S, 107:12:00.0E = South Geomagnetic Pole (2017) 52 | - 00:00:00.0N, xxx:xx:xx.xE = Longitudes along the equator 53 | - "Pontianak, Indonesia" and "Quito, Ecuador" are places right on the equator 54 | 55 | ## Credits 56 | 57 | This project is a fork of the code provided on Reddit by /u/chknoodle_aggie in the thread https://www.reddit.com/r/Python/comments/3gwzjr/using_pyephem_i_just_plotted_every_tleinfo/ 58 | 59 | This project also includes elements of the Python 3 port of this code by pklaus as published at https://gist.github.com/pklaus/469e603b105905170992 60 | 61 | Some TLE data is sourced from http://www.tle.info/ - this site appears to be IPv6-only now so if you have problems downloading from there be sure you are using an IPv6-capable connection. 62 | 63 | ## TODO 64 | 65 | - Add an input box! 66 | - Make the list font smaller 67 | - Exclude objects that have decayed by this time (https://celestrak.com/satcat/decayed.php) 68 | - Show TLE epoch in list view (https://space.stackexchange.com/questions/13825/how-to-obtain-utc-of-the-epoch-time-in-a-satellite-tle-two-line-element) 69 | - Add links in list view to https://www.n2yo.com or similar 70 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | # 2 | # Satellite Data Visualizer for Python config file 3 | # Location and etag/size data will update automatically 4 | # 5 | # If you have a string with commas in it, quote it. Escape quotes. 6 | # 7 | 8 | [main] 9 | # If secs_per_step != 0, this simulates a faster step rate 10 | # Negative values to watch them go backwards... 11 | secs_per_step = 0 12 | 13 | # "San Francisco, CA, USA" 14 | # "37.7749295, -122.4194155, 15.60" 15 | # "37:46:29.7N, -122:25:09.9E, 15.60" 16 | default_location = "37.7749295, -122.4194155, 15.60" 17 | 18 | color_outline = "#808080" 19 | color_alpha = 0.75 20 | user_agent = Mozilla/5.0 21 | window_size = 1700, 1000 22 | update_pause_ms = 20 23 | 24 | # Default backend 25 | mpl_backend = TkAgg 26 | # mpl_backend = WxAgg 27 | # mpl_backend = Qt5Agg 28 | 29 | [source 1] 30 | name = AUS-CITY all 31 | url = http://www.tle.info/data/ALL_TLE.ZIP 32 | file = ALL_TLE.ZIP 33 | color = "#ffffff" 34 | etag = 626b8f4f-14ed12 35 | size = 1371410 36 | 37 | [source 2] 38 | name = Celestrak visual 39 | url = http://www.celestrak.com/NORAD/elements/visual.txt 40 | file = visual.txt 41 | color = "#0000ff" 42 | etag = 568db8418f5bd81:0 43 | size = 27048 44 | 45 | [source 3] 46 | name = AUS-CITY GPS 47 | url = http://www.tle.info/data/gps-ops.txt 48 | file = gps-ops.txt 49 | color = "#00ff00" 50 | etag = 626b8fce-13dc 51 | size = 5084 52 | 53 | [source 4] 54 | name = McCants classifieds 55 | url = https://www.prismnet.com/~mmccants/tles/classfd.zip 56 | file = classfd.zip 57 | color = "#ff0000" 58 | etag = 2fad-5ddb8d57bc83d 59 | size = 12205 60 | 61 | [source 5] 62 | name = Planet Labs 63 | url = http://ephemerides.planet-labs.com/planet_mc.tle 64 | file = planet_mc.tle 65 | color = "#00ffff" 66 | etag = 567356c2f98a73752ef4a1da7caa63b1 67 | size = 56767 68 | 69 | #[source 6] 70 | # name = "Celestrak BREEZE-M radius/B" 71 | # url = http://www.celestrak.com/NORAD/elements/2012-044.txt 72 | # file = 2012-044.txt 73 | # color = "#0000ff" 74 | # etag = _ 75 | # size = 0 76 | -------------------------------------------------------------------------------- /docs/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartyMacGyver/satellite-data-visualizer/f3998c97a18044a6b798f85465bcbcfa62f09698/docs/screenshot.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | configobj 2 | ephem 3 | geocoder 4 | matplotlib 5 | numpy 6 | -------------------------------------------------------------------------------- /satellite-data-visualizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Satellite Data Visualizer for Python 5 | --------------------------------------------------------------------------- 6 | 7 | Copyright (c) 2015-2022 Martin F. Falatic 8 | 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | 21 | --------------------------------------------------------------------------- 22 | Author: Martin Falatic, 2015-10-15 23 | Based on code by user /u/chknoodle_aggie 2015-08-14 as posted in 24 | https://www.reddit.com/radius/Python/comments/3gwzjr/using_pyephem_i_just_plotted_every_tleinfo/ 25 | 26 | More about TLE: 27 | https://en.wikipedia.org/wiki/Two-line_element_set 28 | http://spaceflight.nasa.gov/realdata/sightings/SSapplications/Post/JavaSSOP/SSOP_Help/tle_def.html 29 | 30 | """ 31 | 32 | import errno 33 | import getpass 34 | import math 35 | import os 36 | import os.path 37 | import sys 38 | import threading 39 | import time 40 | import urllib 41 | import warnings 42 | import zipfile 43 | from datetime import datetime, timedelta 44 | from urllib.request import Request, urlopen 45 | 46 | import ephem 47 | import geocoder 48 | import matplotlib as mpl 49 | import matplotlib.pyplot 50 | import numpy as np 51 | from configobj import ConfigObj 52 | 53 | SECRET_API_KEY = '' 54 | RETRY_DELAY = 0.5 55 | MAX_RETRIES = 10 56 | DEFAULT_ELEVATION = 0.0 57 | 58 | 59 | def mkdir_checked(path): 60 | try: 61 | os.makedirs(path) 62 | except OSError as exception: 63 | if exception.errno != errno.EEXIST: 64 | raise 65 | 66 | 67 | def sanitize_filename(name): 68 | ok_chars = list(r"""._' ,;[](){}!@#%^&""") 69 | return "".join(c for c in name if c.isalnum() or c in ok_chars).rstrip() 70 | 71 | 72 | def dequote(s): 73 | """ 74 | From https://stackoverflow.com/a/20577580/760905 75 | If a string has single or double quotes around it, remove them. 76 | Make sure the pair of quotes match. 77 | If a matching pair of quotes is not found, return the string unchanged. 78 | """ 79 | if (s[0] == s[-1]) and s.startswith(("'", '"')): 80 | return s[1:-1] 81 | return s 82 | 83 | 84 | class SatDataViz(object): 85 | def __init__(self, win_label=None, config_file=None): 86 | if win_label: 87 | self.win_label = win_label 88 | else: 89 | self.win_label = "Satellite Data Visualizer for Python" 90 | print(self.win_label) 91 | print() 92 | self.click_wait_s = 0.10 93 | self.data_dir = "tledata" 94 | self.savedsats = None 95 | self.curr_time = None 96 | self.curr_date = None 97 | self.home = None 98 | self.latitude = None 99 | self.longitude = None 100 | self.location = None 101 | self.friendly_location = None 102 | self.elevation = None 103 | mkdir_checked(self.data_dir) 104 | # Config file defaults 105 | self.secs_per_step = 0 106 | self.default_location = "San Francisco, CA, USA" 107 | self.color_outline = "#808080" 108 | self.color_alpha = 0.75 109 | self.user_agent = "Mozilla/5.0" 110 | self.window_size = [1700, 1000] 111 | self.update_pause_ms = 20 112 | self.mpl_backend = 'TkAgg' 113 | if config_file: 114 | self.config_file = config_file 115 | else: 116 | self.config_file = 'config.ini' 117 | self._load_config() 118 | print("Selected the '{}' backend for MatPlotLib".format(self.mpl_backend)) 119 | print() 120 | mpl.use(self.mpl_backend) # Must happen before pyplot import! 121 | self.plt = matplotlib.pyplot 122 | 123 | def _load_config(self, config_file=None): 124 | if not config_file: 125 | config_file = self.config_file 126 | self.config = ConfigObj(config_file, encoding='UTF8') 127 | self.secs_per_step = self._verify_config_item( 128 | self.secs_per_step, 'secs_per_step', int) 129 | self.default_location = self._verify_config_item( 130 | self.default_location, 'default_location', str) 131 | self.color_outline = self._verify_config_item( 132 | self.color_outline, 'color_outline', str) 133 | self.color_alpha = self._verify_config_item( 134 | self.color_alpha, 'color_alpha', float) 135 | self.user_agent = self._verify_config_item( 136 | self.user_agent, 'user_agent', str) 137 | self.window_size = self._verify_config_item( 138 | self.window_size, 'window_size', list) 139 | self.update_pause_ms = self._verify_config_item( 140 | self.update_pause_ms, 'update_pause_ms', int) 141 | self.mpl_backend = self._verify_config_item( 142 | self.mpl_backend, 'mpl_backend', str) 143 | 144 | def _verify_config_item(self, default, item_name, item_type): 145 | try: 146 | rval = item_type(self.config['main'][item_name]) 147 | except KeyError: 148 | print("Config item '{:s}' not found, using default value ({})".format( 149 | item_name, default)) 150 | rval = self.config['main'][item_name] = default 151 | except ValueError: 152 | print("Config item '{:s}' malformed, using default value ({})".format( 153 | item_name, default)) 154 | rval = self.config['main'][item_name] = default 155 | return rval 156 | 157 | def save_config(self, config_file=None): 158 | if config_file: 159 | self.config.filename = config_file 160 | # self.config.unrepr = True 161 | self.config.write() 162 | 163 | def readTLEfile(self, source): 164 | ''' Get and read a TLE file (unzip if necessary) ''' 165 | source_name = source['name'] 166 | source_file = os.path.join(self.data_dir, sanitize_filename(source['file'])) 167 | source_url = source['url'] 168 | print('Querying TLE data source \"{}\" at {}'.format(source_name, source_url)) 169 | fallback_mode = False 170 | new_etag = '' 171 | try: 172 | req = Request(source_url, headers={'User-Agent': self.user_agent}) 173 | response = urlopen(req) 174 | headers = response.info() 175 | new_etag = dequote(headers["ETag"]) 176 | new_size = int(headers["Content-Length"]) 177 | except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e: 178 | print("Error: Failed to query url ({})".format(e)) 179 | fallback_mode = True 180 | curr_size = 0 181 | if os.path.isfile(source_file): 182 | curr_size = os.path.getsize(source_file) 183 | curr_modtime = time.ctime(os.path.getmtime(source_file)) 184 | print('Checking local TLE data {} ({}, {})'.format( 185 | source_file, curr_size, curr_modtime)) 186 | else: 187 | if fallback_mode: 188 | print('Cannot access current or cached TLE data for this site, skipping') 189 | return None 190 | if fallback_mode or ( 191 | source['etag'] == new_etag and curr_size == new_size): 192 | print('Using existing TLE data') 193 | else: 194 | print('Retrieving TLE data') 195 | try: 196 | data = response.read() 197 | except urllib.error.HTTPError as e: 198 | print("Error: Failed to download data ({})".format(e)) 199 | print("Will use existing data if present") 200 | else: 201 | source['etag'] = new_etag 202 | source['size'] = new_size 203 | with open(source_file, 'wb') as f: 204 | f.write(data) 205 | print('{} updated'.format(source_file)) 206 | if source_file.lower().endswith('.zip'): 207 | print('Unzipping {}...'.format(source_file)) 208 | zip_data = zipfile.ZipFile(source_file) 209 | zip_data.extractall(path=self.data_dir) 210 | source_file = os.path.join(self.data_dir, sanitize_filename(zip_data.namelist()[0])) 211 | print('Extracted {}'.format(zip_data.namelist())) 212 | temp_content = [] 213 | with open(source_file) as f: 214 | for aline in f: 215 | temp_content.append(aline.replace('\n', '')) 216 | print(len(temp_content) // 3, 217 | 'TLEs loaded from {}'.format(source_file)) 218 | return temp_content 219 | 220 | def process_tle_data(self): 221 | ''' Process each TLE entry ''' 222 | self.savedsats = [] 223 | bodies_dedup = {} 224 | tleSources = [s for s in self.config.sections if s.startswith('source ')] 225 | for source_section in tleSources: 226 | source = self.config[source_section] 227 | print("Processing {}".format(source['name'])) 228 | temp_content = self.readTLEfile(source=source) 229 | if temp_content: 230 | i_name = 0 231 | while 3 * i_name + 2 <= len(temp_content): 232 | rawTLEname = temp_content[3 * i_name + 0] 233 | rawTLEdat1 = temp_content[3 * i_name + 1] 234 | rawTLEdat2 = temp_content[3 * i_name + 2] 235 | partsTLEdat1 = rawTLEdat1.split() 236 | try: 237 | body = ephem.readtle(rawTLEname, rawTLEdat1, rawTLEdat2) 238 | except ValueError: 239 | print("Error: line does not conform to tle format") 240 | print(" " + rawTLEname) 241 | print(" " + rawTLEdat1) 242 | print(" " + rawTLEdat2) 243 | print() 244 | else: 245 | number = partsTLEdat1[1] 246 | designator = partsTLEdat1[2] 247 | (body_namepart, body_datapart) = body.writedb().split(',', 1) 248 | new_sat = { 249 | 'name': body.name, 250 | 'number': number, 251 | 'designator': designator, 252 | 'source_num': source_section.split(' ', 1)[1], 253 | 'source_name': source['name'], 254 | 'color': source['color'], 255 | 'body': body, 256 | 'picked': False, 257 | } 258 | # # Handling specially selected objects 259 | # if new_sat['name'] == 'TIANGONG 1': 260 | # new_sat['color'] = '#FFFF00' 261 | # print(new_sat) 262 | if body_datapart in bodies_dedup: 263 | sat_index = bodies_dedup[body_datapart] 264 | self.savedsats[sat_index] = new_sat 265 | # print("Updated idx {} for '{}'".format(sat_index, body_namepart)) 266 | print("Updated entry for '{}'".format(body_namepart)) 267 | else: 268 | self.savedsats.append(new_sat) 269 | sat_index = len(self.savedsats) - 1 270 | bodies_dedup[body_datapart] = sat_index 271 | i_name += 1 272 | print() 273 | 274 | def _parse_coords(self, coords): 275 | coord_parts = [s.strip() for s in coords.split(',')] 276 | coord_parts_parsed = [] 277 | test_ephem = ephem.Observer() 278 | if 2 <= len(coord_parts) <= 3: 279 | for idx, part in enumerate(coord_parts): 280 | try: 281 | if part[-1].upper() in list('SW'): 282 | part = '-' + part 283 | if idx == 0: 284 | test_ephem.lat = str(part) # pylint: disable=assigning-non-slot 285 | elif idx == 1: 286 | test_ephem.lon = str(part) # pylint: disable=assigning-non-slot 287 | elif idx == 2: 288 | test_ephem.elevation = float(part) # pylint: disable=assigning-non-slot 289 | except ValueError: 290 | return None 291 | coord_parts_parsed.append(part) 292 | if len(coord_parts) == 2: 293 | coord_parts_parsed.append(0.0) # Default elevation 294 | return coord_parts_parsed 295 | 296 | def get_location(self, given_location=None): 297 | ''' Get user location based on input ''' 298 | print('Location input examples:') 299 | print(' "San Francisco, CA, USA"') 300 | print(' "37.7749295, -122.4194155, 15.60"') 301 | print(' "37:46:29.7N, -122:25:09.9E, 15.60"') 302 | print('') 303 | input_function = input 304 | self.home = ephem.Observer() 305 | coords = None 306 | while True: 307 | if given_location: 308 | print('Given location: "{}"'.format(given_location)) 309 | location_keyword = given_location 310 | given_location = None 311 | else: 312 | location_keyword = input_function( 313 | 'Enter location ["{}"]: '.format(self.default_location)) 314 | if not location_keyword or location_keyword.isspace(): 315 | location_keyword = self.default_location 316 | coords = self._parse_coords(location_keyword) 317 | if coords: 318 | self.location = location_keyword 319 | (self.latitude, self.longitude, self.elevation) = coords 320 | self.elevation = float(self.elevation) 321 | self.friendly_location = "coordinates" 322 | break 323 | else: 324 | gloc = geocoder.google(location_keyword, key=SECRET_API_KEY) 325 | print(location_keyword, gloc.status) 326 | if gloc.status != 'OK': 327 | print('Location not found: "{}"'.format(location_keyword)) 328 | location_keyword = None 329 | else: 330 | self.location = gloc.address 331 | if SECRET_API_KEY == '': 332 | print("No API key - using default elevation {}m".format(DEFAULT_ELEVATION)) 333 | self.elevation = DEFAULT_ELEVATION 334 | else: 335 | for _ in range(MAX_RETRIES): 336 | self.elevation = geocoder.elevation(gloc.latlng, key=SECRET_API_KEY).meters 337 | if self.elevation is not None: 338 | break 339 | time.sleep(RETRY_DELAY) 340 | else: 341 | print("Retries exceeded - using default elevation {}m".format(DEFAULT_ELEVATION)) 342 | self.elevation = DEFAULT_ELEVATION 343 | 344 | (self.latitude, self.longitude) = gloc.latlng 345 | # (self.latitude, self.longitude) = (gloc.lat, gloc.lng) 346 | self.friendly_location = u"{}".format(self.location) 347 | # print(gloc.json) 348 | print() 349 | break 350 | self.config['main']['default_location'] = self.location 351 | print(self.elevation) 352 | self.home.elevation = self.elevation # meters 353 | self.home.lat = str(self.latitude) # +N 354 | self.home.lon = str(self.longitude) # +E 355 | print("Found: {}N, {}E, {:0.2f}m".format( 356 | self.home.lat, self.home.lon, self.home.elevation)) 357 | self.friendly_location = "{} ({:4.7f}N, {:4.7f}E) {:0.2f}m".format( 358 | self.friendly_location, 359 | self.home.lat / ephem.degree, 360 | self.home.lon / ephem.degree, 361 | self.home.elevation) 362 | print("Location: {}".format(self.friendly_location)) 363 | print() 364 | 365 | def plot_sats(self): 366 | warnings.filterwarnings( 367 | "ignore", 368 | ".*Using default event loop until function specific to this GUI is implemented") 369 | print('-' * 79) 370 | print() 371 | self.plt.rcParams['toolbar'] = 'None' 372 | fig = self.plt.figure(1) 373 | DPI = fig.get_dpi() 374 | fig.set_size_inches( 375 | int(self.window_size[0]) / float(DPI), 376 | int(self.window_size[1]) / float(DPI)) 377 | # mng = self.plt.get_current_fig_manager() 378 | # mng.resize(1600,900) 379 | fig.canvas.set_window_title(self.win_label) 380 | self.curr_time = time.time() 381 | self.curr_date = datetime.utcnow() 382 | errored_sats = set() 383 | picked_sats = [] 384 | plotted_sats = [] 385 | last_picked = [None] # Keep data mutable 386 | update_lock = threading.Lock() 387 | close_event = threading.Event() 388 | ax = None 389 | 390 | def handle_close(event): 391 | print() 392 | print("Event received ({:s})".format(event.name)) 393 | close_event.set() 394 | fig.canvas.mpl_connect('close_event', handle_close) 395 | 396 | def onpick(event): 397 | ''' These *only* happen with data points get clicked by any button ''' 398 | # print("Picked in", time.time(), event.mouseevent) 399 | last_picked[0] = event.mouseevent 400 | if time.time() - self.curr_time < self.click_wait_s: # Rate limiting 401 | return 402 | self.curr_time = time.time() 403 | update_lock.acquire(True) 404 | for plot_idx in event.ind: 405 | satdata = plotted_sats[plot_idx] 406 | # print(satdata['name'], "plot_idx=", satdata['plot_idx']) 407 | if satdata['picked']: 408 | satdata['picked'] = False 409 | if satdata in picked_sats: 410 | picked_sats.remove(satdata) 411 | else: 412 | satdata['picked'] = True 413 | picked_sats.append(satdata) 414 | update_lock.release() 415 | # print("Picked out", time.time(), event.mouseevent) 416 | fig.canvas.mpl_connect('pick_event', onpick) 417 | 418 | def onclick(event): 419 | ''' These follow onpick() events as well ''' 420 | # print("Clicked at", time.time(), event) 421 | if time.time() - self.curr_time < self.click_wait_s: # Rate limiting 422 | return 423 | self.curr_time = time.time() 424 | update_lock.acquire(True) 425 | if last_picked[0] == event: 426 | pass # print("Part of last pick") 427 | else: 428 | if event.button == 3: 429 | for satdata in picked_sats[:]: 430 | satdata['picked'] = False 431 | del picked_sats[:] 432 | update_lock.release() 433 | fig.canvas.mpl_connect('button_press_event', onclick) 434 | 435 | while True: 436 | if close_event.is_set(): 437 | self.plt.close(fig) 438 | self.plt.close() 439 | break 440 | update_lock.acquire(True) 441 | if self.secs_per_step: 442 | self.curr_date += timedelta(seconds=self.secs_per_step) 443 | else: 444 | self.curr_date = datetime.utcnow() 445 | self.home.date = self.curr_date 446 | theta_plot = [] 447 | radius_plot = [] 448 | colors = [] 449 | plot_idx = 0 450 | noted_sats = [] 451 | plotted_sats = [] 452 | for satdata in self.savedsats: # for each satellite in the savedsats list 453 | satdata['plot_idx'] = None 454 | try: 455 | satdata['body'].compute(self.home) 456 | alt = satdata['body'].alt 457 | except ValueError: 458 | # print("Date out of range") 459 | pass 460 | except RuntimeError: 461 | if satdata['number'] not in errored_sats: 462 | errored_sats.add(satdata['number']) 463 | print("Cannot compute position for {} {} {} - has it deorbited?".format( 464 | satdata['name'], satdata['number'], satdata['designator'])) 465 | else: 466 | if math.degrees(alt) > 0.0: 467 | satdata['plot_idx'] = plot_idx 468 | radius_plot.append(math.cos(satdata['body'].alt)) 469 | theta_plot.append(satdata['body'].az) 470 | if satdata['picked']: 471 | colors.append("#000000") 472 | else: 473 | colors.append(satdata['color']) 474 | if satdata['picked']: 475 | noted_sats.append(satdata) # Finalized data here 476 | plotted_sats.append(satdata) 477 | plot_idx += 1 478 | # plot initialization and display 479 | if not ax: 480 | ax = self.plt.subplot(111, polar=True) 481 | self.plt.subplots_adjust(left=0.05, right=0.6) 482 | ax.cla() # a bit less heavy than self.plt.cla()? 483 | 484 | # ax2 = self.plt.axes([0.1, 0.05, 0.5, 0.075]) #([0.81, 0.05, 0.1, 0.075]) 485 | # #mpl.widgets.Button(ax2, "aButton") 486 | 487 | # def submit(text): 488 | # print(text) 489 | 490 | # text_box = mpl.widgets.TextBox(ax2, 'textbox', initial="some text") 491 | # text_box.on_submit(submit) 492 | 493 | marker = mpl.markers.MarkerStyle(marker='o', fillstyle='full') 494 | # Note: you can't currently pass multiple marker styles in an array 495 | ax.scatter(theta_plot, radius_plot, marker=marker, 496 | picker=1, # This sets the tolerance for clicking on a point 497 | c=colors, edgecolors=self.color_outline, alpha=self.color_alpha, 498 | ) 499 | title_locn = self.friendly_location 500 | title_date = "{}.{:02d} UTC".format( 501 | self.curr_date.strftime('%Y-%m-%d %H:%M:%S'), 502 | int(round(self.curr_date.microsecond / 10000.0))) 503 | title_stat = "Satellites overhead: {}".format(len(plotted_sats)) 504 | ax.set_title('\n'.join([title_locn, title_date, title_stat]), va='bottom') 505 | ax.set_facecolor('ivory') 506 | ax.set_theta_offset(np.pi / 2.0) # Top = 0 deg = North 507 | ax.set_theta_direction(-1) # clockwise 508 | ax.set_rmax(1.0) 509 | ax.grid(True) 510 | ax.xaxis.set_ticklabels(['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']) 511 | ax.yaxis.set_ticklabels([]) # hide radial tick labels 512 | self.notate_sat_data(ax=ax, noted_sats=picked_sats) 513 | update_lock.release() 514 | try: 515 | self.plt.pause(self.update_pause_ms / 1000.0) 516 | except Exception as e: # pylint: disable=broad-except 517 | # Ignore this specific tkinter error on close (intrinsic to pyplot) 518 | if not(repr(e).startswith("TclError") 519 | and str(e).startswith("can't invoke \"update\" command:") # noqa: W503 520 | ): 521 | raise e 522 | break 523 | 524 | def notate_sat_data(self, ax, noted_sats): 525 | notes = ["Tracking list:\n"] 526 | for satdata in noted_sats: 527 | notes.append( 528 | '[{:s}] "{:s}" [{:s}/{:s}] (alt={:0.2f} az={:0.2f}) (ra={:0.2f} dec={:0.2f})'.format( 529 | satdata['source_num'], 530 | satdata['name'], 531 | satdata['number'], 532 | satdata['designator'], 533 | math.degrees(satdata['body'].alt), 534 | math.degrees(satdata['body'].az), 535 | math.degrees(satdata['body'].ra), 536 | math.degrees(satdata['body'].dec), 537 | ) 538 | ) 539 | if len(notes) <= 1: 540 | notes.append("(none)") 541 | ax.annotate( 542 | '\n'.join(notes), 543 | xy=(0.0, 0.0), # theta, radius 544 | xytext=(math.pi / 2.0, 1.25), # fraction, fraction 545 | horizontalalignment='left', 546 | verticalalignment='center', 547 | ) 548 | 549 | def get_api_key(self): 550 | global SECRET_API_KEY 551 | if 'GOOGLE_API_KEY' in os.environ and os.environ['GOOGLE_API_KEY']: 552 | print(os.environ['GOOGLE_API_KEY']) 553 | SECRET_API_KEY = os.environ['GOOGLE_API_KEY'] 554 | else: 555 | if SECRET_API_KEY == '': 556 | default_text = '' 557 | else: 558 | default_text = '' 559 | new_key = getpass.getpass("Enter Google Maps Geocoding API key [{}]: ".format(default_text)) 560 | if new_key != '': 561 | SECRET_API_KEY = new_key 562 | 563 | 564 | if __name__ == "__main__": 565 | print() 566 | print('-' * 79) 567 | 568 | location_arg = None 569 | if len(sys.argv) > 1: 570 | location_arg = sys.argv[1] 571 | 572 | sdv = SatDataViz() 573 | sdv.get_api_key() 574 | sdv.get_location(given_location=location_arg) 575 | sdv.process_tle_data() 576 | sdv.save_config() 577 | sdv.plot_sats() 578 | 579 | print() 580 | print("Exiting...") 581 | --------------------------------------------------------------------------------