├── .gitignore ├── .gitmodules ├── NOTICE.txt ├── README.rst ├── __init__.py ├── article_preprint.pdf ├── docs ├── Figures │ ├── GUI_add_camera.png │ ├── GUI_add_mount.png │ ├── GUI_alignment.png │ ├── GUI_camera_settings.png │ ├── GUI_feedback.png │ ├── GUI_in_action.png │ ├── GUI_live_view.png │ ├── GUI_location.png │ ├── GUI_mount_settings.png │ ├── GUI_target.png │ ├── GUI_trackers.png │ ├── fpa_photo.jpg │ └── optical_path.png ├── Makefile ├── api_docs.rst ├── conf.py ├── fpa_build.rst ├── gui_instructions.rst ├── index.rst ├── installation.rst └── make.bat ├── examples ├── run_pypogsGUI.py └── setupCLI_runGUI.py ├── pypogs ├── __init__.py ├── _system_data │ └── finals2000A.all ├── gui.py ├── hardware.py ├── system.py └── tracking.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Documents 2 | /docs/_* 3 | /docs/*.log 4 | 5 | # Cache 6 | *__pycache__* 7 | 8 | # System data and debug 9 | /pypogs/data/* 10 | /pypogs/debug/* 11 | 12 | # Builds 13 | /build/* 14 | /dist/* 15 | *egg-info* 16 | 17 | # Settings 18 | /setup.cfg 19 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tetra3"] 2 | path = tetra3 3 | url = https://github.com/esa/tetra3.git 4 | -------------------------------------------------------------------------------- /NOTICE.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 -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Welcome to pypogs! 2 | ================== 3 | 4 | *pypogs is an automated closed-loop satellite tracker for portable telescopes written in Python.* 5 | 6 | Use it to control your optical ground station, auto-align it to the stars, and automatically acquire 7 | and track satellites with closed-loop camera feedback. Additionally we include instructions for how 8 | to build a fibre-coupling Focal Plane Assembly (FPA) replacing the eyepiece in any unmodified 9 | portable telescope. 10 | 11 | pypogs includes a platform independent Graphical User Interface (GUI) to manage alignment, tracking 12 | feedback, and hardware settings. The GUI controls the pypogs core through a public API (see 13 | documentation); pypogs may be controlled fully from the command line as well. 14 | 15 | The software is available in the `pypogs GitHub repository `_. 16 | All documentation is hosted at the 17 | `pypogs ReadTheDocs website `_. pypogs is Free and Open 18 | Source Software released by the European Space Agency under the Apache License 2.0. See NOTICE.txt 19 | in the repository for full licensing details. 20 | 21 | Performance will vary. Our testing shows approximately 1 arcsecond RMS tracking of stars and 22 | MEO/GEO satellites, with 4 arcseconds RMS tracking of LEO satellites. With this performance you 23 | can launch the received signal into a 50µm and 150µm core diameter multimode fibre respectively with 24 | the proposed FPA. We require no modifications to the telescope nor a fine steering mirror for these 25 | results; pypogs will enable the lowest cost high-performance optical ground stations yet. 26 | 27 | An article describing the system was presented at IEEE ICSOS in 2019; the paper is 28 | `available here `_. The GitHub respository 29 | includes a preprint. If you find pypogs useful in your work, please cite: 30 | 31 | G. M. Pettersson, J. Perdigues, and Z. Sodnik, "Unmodified Portable Telescope for Space-to-Ground 32 | Optical Links," in *Proc. IEEE International Conference on Space Optical Systems and Applications 33 | (ICSOS)*, 2019. 34 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .pypogs import * -------------------------------------------------------------------------------- /article_preprint.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/article_preprint.pdf -------------------------------------------------------------------------------- /docs/Figures/GUI_add_camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_add_camera.png -------------------------------------------------------------------------------- /docs/Figures/GUI_add_mount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_add_mount.png -------------------------------------------------------------------------------- /docs/Figures/GUI_alignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_alignment.png -------------------------------------------------------------------------------- /docs/Figures/GUI_camera_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_camera_settings.png -------------------------------------------------------------------------------- /docs/Figures/GUI_feedback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_feedback.png -------------------------------------------------------------------------------- /docs/Figures/GUI_in_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_in_action.png -------------------------------------------------------------------------------- /docs/Figures/GUI_live_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_live_view.png -------------------------------------------------------------------------------- /docs/Figures/GUI_location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_location.png -------------------------------------------------------------------------------- /docs/Figures/GUI_mount_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_mount_settings.png -------------------------------------------------------------------------------- /docs/Figures/GUI_target.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_target.png -------------------------------------------------------------------------------- /docs/Figures/GUI_trackers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/GUI_trackers.png -------------------------------------------------------------------------------- /docs/Figures/fpa_photo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/fpa_photo.jpg -------------------------------------------------------------------------------- /docs/Figures/optical_path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esa/pypogs/a0340bf0480b7f625c872e572173855581abfebb/docs/Figures/optical_path.png -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api_docs.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ============= 3 | 4 | This reference is auto-generated from the source code in the 5 | `pypogs GitHub repository `_. General instructions are avilable at 6 | the `pypogs ReadTheDocs website `_. 7 | 8 | .. automodule:: pypogs.system 9 | .. autoclass:: pypogs.System 10 | :members: 11 | .. autoclass:: pypogs.Alignment 12 | :members: 13 | .. autoclass:: pypogs.Target 14 | :members: 15 | 16 | .. automodule:: pypogs.tracking 17 | .. autoclass:: pypogs.ControlLoopThread 18 | :members: 19 | .. autoclass:: pypogs.TrackingThread 20 | :members: 21 | .. autoclass:: pypogs.SpotTracker 22 | :members: 23 | 24 | .. automodule:: pypogs.hardware 25 | .. autoclass:: pypogs.Camera 26 | :members: 27 | .. autoclass:: pypogs.Mount 28 | :members: 29 | .. autoclass:: pypogs.Receiver 30 | :members: 31 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import sys 10 | sys.path.append('..') 11 | master_doc = 'index' 12 | 13 | # -- Project information ----------------------------------------------------- 14 | 15 | project = 'pypogs' 16 | copyright = '2019 the European Space Agency' 17 | author = 'Gustav Pettersson @ ESA' 18 | 19 | # The full version, including alpha/beta/rc tags 20 | release = '0.1' 21 | 22 | # -- General configuration --------------------------------------------------- 23 | 24 | # Add any Sphinx extension module names here, as strings. They can be 25 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 26 | # ones. 27 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] 28 | autodoc_member_order = 'groupwise' 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # List of patterns, relative to source directory, that match files and 34 | # directories to ignore when looking for source files. 35 | # This pattern also affects html_static_path and html_extra_path. 36 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 37 | 38 | 39 | # -- Options for HTML output ------------------------------------------------- 40 | 41 | # The theme to use for HTML and HTML Help pages. See the documentation for 42 | # a list of builtin themes. 43 | # 44 | # html_theme = 'alabaster' 45 | html_theme = "sphinx_rtd_theme" 46 | html_theme_options = {'navigation_depth': 3} 47 | 48 | # Add any paths that contain custom static files (such as style sheets) here, 49 | # relative to this directory. They are copied after the builtin static files, 50 | # so a file named "default.css" will overwrite the builtin "default.css". 51 | html_static_path = [] 52 | -------------------------------------------------------------------------------- /docs/fpa_build.rst: -------------------------------------------------------------------------------- 1 | Focal Plane Assembly Build 2 | ========================== 3 | 4 | You may build a ground station for use with pypogs, with the general architecture shown below: 5 | 6 | .. image:: ./Figures/optical_path.png 7 | :target: _images/optical_path.png 8 | 9 | This can be implemented without any modifications to your telescope by replacing the eyepiece of 10 | your telescope with a focal plane assembly (FPA) show below besides an eyepiece: 11 | 12 | .. image:: ./Figures/fpa_photo.jpg 13 | :target: _images/fpa_photo.jpg 14 | 15 | The FPA costs less than 1000 euros (or dollars) to build (excl. the camera). 16 | 17 | Parts needed 18 | ------------ 19 | All optomechanics and optics may be ordered from `Thorlabs `_ except 20 | for the adapter to a telescope eyepice barrel. Your telescope vendor should have such an item. An 21 | example can be seen from 22 | `Teleskop Service `_. 24 | You may adapt this FPA to use T2-mount (instead of C-mount) adapters and to use 2" instead of 1.25" 25 | eyepiece barrels. 26 | 27 | Base items 28 | ^^^^^^^^^^ 29 | 30 | - SC6W; 16 mm Cage Cube; 1pc 31 | - SM05CP2; SM05 End Cap; 1pc 32 | - SPM2; Cage Prism Mount; 1pc 33 | - SB6C; Cube Clamp; 1pc 34 | - SR1-P4; Cage Rod, 1" Long, 4 Pack; 1pc 35 | - SSP05; XY Slip Plate; 1pc 36 | - SM05FC; FC/PC SM05 Fibre Adapter; 1pc \* 37 | - SM05A3; SM05 to SM1 Adapter; 2pc 38 | - SM1L10; SM1 Lens Tube, 1" Long; 1pc \*\* 39 | - SM1M10; SM1 Lens Tube w/o External Thread, 1" Long; 1pc \*\*\* 40 | - SM1A9; C-Mount to SM1 Adapter; 1pc 41 | - SM1A10; SM1 to C-Mount Adapter; 1pc 42 | 43 | | \* May change for fibre connector of your choosing. 44 | | \*\* May need to adjust length based on focal length `f3`. 45 | | \*\*\* May need to adjust length based on focal length `f2`. 46 | 47 | Optics and mounts 48 | ^^^^^^^^^^^^^^^^^ 49 | For lenses `f2` and `f3` a lens with diameter 1" or less (1/2" recommended) may be used. For your 50 | chosen lens diameter, get an appropriate `SM1A{...}` adapter from the category "Mounting Adapters 51 | with Internal and External Threads". You may also use pre-mounted lenses and an adapter to SM1 52 | thread in these positions. 53 | 54 | For the lens `f4` a lens with diameter 1/2" or less may be used (8mm recommended). For your chosen 55 | lens diameter, get an appropriate `SP{...}` adapter from the category "16 mm Cage Plates for 56 | Unmounted Optics" (or the SP02 for 1/2" lens diameter). You may also use pre-mounted lenses and an 57 | adapter to SM05 thread in the SP02 mount if preferred. 58 | 59 | The beam-splitter must be a 10mm cube. Thorlabs has a range of non-polarising beam-splitters with 60 | reflection:transmission of 10:90, 30:70, 50:50, 70:30, 90:10. Typically 10% to the tracking camera 61 | is used. The maximum clear aperture of the FPA is limited by the beamsplitter's clear aperture, 62 | which for Thorlabs' offerings is 8mm. 63 | 64 | There is space in the FPA to mount filters (1" or 1/2") after `f2` or before `f3` inside each lens 65 | tube, or a filter (1/2") in an SP02 cage plate before `f4` to suit your application. 66 | 67 | A recommended setup would be: 68 | 69 | - SM1A6T; SM1 Adapter for 1/2" (SM05) Optic; 2pc (`f2` and `f3` mount) 70 | - AC127-025-B; f=25mm, 1/2" Achromatic Doublet; 1pc (`f2`) 71 | - AC127-030-B; f=30mm, 1/2" Achromatic Doublet; 1pc (`f3`) 72 | - SP10; 16 mm Cage Plate for 8mm Optic; 1pc (`f4` mount) 73 | - AC080-010-B; f=10mm, 8mm Achromatic Doublet; 1pc (`f4`) 74 | - BS071; 90:10 (R:T) beam-splitter 10mm cube; 1pc 75 | 76 | Extras 77 | ^^^^^^ 78 | It is recommended to get some glue (e.g. cyanoacrylate) to permanently bond the beam-splitter to the 79 | cage prism mount for stability, and matte-black aluminium foil to shield the FPA from stray light. 80 | 81 | Useful to rotate the camera: 82 | 83 | - CMSP025; C-Mount Spacer Ring, .25mm Thick; 1pc 84 | - CMSP050; C-Mount Spacer Ring, .5mm Thick; 1pc 85 | - CMSP100; C-Mount Spacer Ring, 1mm Thick; 1pc 86 | 87 | Useful for building/aligning: 88 | 89 | - SPW602; Spanner Wrench for SM1; 1pc 90 | - SPW603; Spanner Wrench for SM05; 1pc 91 | - SR05-P4; Cage Rod, 1/2" Long, 4 Pack; 1pc 92 | - SCPA1; 16 mm Cage Alignment Plate; 1pc 93 | - CPS635R; Collimated Laser Module, 635 nm, 1.2 mW; 1pc 94 | - CPS11K(-EC); Ø11 mm Laser Diode Module Mounting Kit; 1pc 95 | - SP03; 16mm Cage Plate Clear Aperture; 1pc 96 | - MSP2(/M); Mini Pedestal Pillar Post; 1pc 97 | - MSC2; Mini Clamping Fork; 1pc 98 | 99 | Constuction 100 | ----------- 101 | 1. Add two SM05A3 adapters to opposite sides of your SC6W cage cube. 102 | 2. Add four SR1 rods to one side of your cage cube. 103 | 3. Glue your beam-splitter to the SPM2 mount and insert into cage cube (take care of orientation!). 104 | 4. Mount your camera to the SM1L10 lens tube with the SM1A9 adapter. 105 | 5. Mount your `f3` lens in its SM1A6T adapter (take care of orientation!) and place in lens tube. 106 | 107 | Optional for setting camera rotation: 108 | 109 | a. Attach the lens tube with the camera to the cage cube. 110 | b. Add CMSPxxx spacers as desired to rotate the camera. 111 | c. Remove the camera again and continue the steps. 112 | 113 | 6. Lock your `f3` lens in infinity focus (e.g. focus on something far away). 114 | 7. Mount the lens tube to the cage cube SM05A3 adapter. 115 | 8. Mount your `f4` lens in its SP10 cage plate adapter (take care of orientation!). 116 | 9. Lock the `f4` cage plate on the SR1 rods, flush with the cage cube. 117 | 10. Attach SM05FC fibre adapter in SSP05 slip plate and mount on the SR1 rods. 118 | 11. Attach a fibre and illuminate from the opposite end. You will see reflections from the 119 | beam-splitter on your camera. Rotate the beam-splitter until the reflections overlap and adjust 120 | the slip plate position until a centered sharp image of the fibre face is seen. 121 | 12. Lock your beam-splitter rotation and add SB6C clamp, lighly pressing on the beam-splitter. 122 | 13. Mount your `f2` lens in its SM1A6T adapter (take care of orientation!) and place in SM1M10 lens 123 | tube. Attach to cage cube's SM05A3 adapter. 124 | 14. Fix position of `f2` such that primary focus is a few mm outside the lens tube (typically this 125 | lens sits as close to the cage cube as possible). 126 | 15. Add SM1A10 adapter and your C-mount to telescope eyepiece barrel (e.g. 1.25"). 127 | 16. Finish by adding SM05CP2 end cap in the unused hole. 128 | -------------------------------------------------------------------------------- /docs/gui_instructions.rst: -------------------------------------------------------------------------------- 1 | Graphical User Interface Guide 2 | ============================== 3 | pypogs includes a simple yet powerful Graphical User Interface (GUI) to control a tracking session. 4 | It includes for example hardware setup, alignment, target selection and tracking, automatic or 5 | manual closed-loop acquisition, and controlling all system parameters. A typical tracking scenario 6 | may look as below: 7 | 8 | .. image:: ./Figures/GUI_in_action.png 9 | :target: _images/GUI_in_action.png 10 | 11 | Below, an explanation of the GUI features and functionality is presented. You may open an empty GUI 12 | by going to the `examples` folder in a terminal/CMD window and typing:: 13 | 14 | python run_pypogsGUI.py 15 | 16 | Note that it is entirely possible (even recommended) to first load and set up pypogs using the API 17 | before loading the GUI, to configure the system to your desires on startup. See the example file 18 | `setupCLI_runGUI.py` for how this may be done (this is in particular the setup for ESA's test 19 | system). To find appropriate settings to change for your system, however, it's a good idea to load 20 | everything through the GUI and set it up as you like, then edit the mentioned example to those 21 | settings. 22 | 23 | Hardware Setup and Properties 24 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 25 | Hardware is loaded and settings are controlled here. Clicking `Mount` will open this window: 26 | 27 | .. image:: ./Figures/GUI_add_mount.png 28 | :target: _images/GUI_add_mount.png 29 | 30 | Here, type the `Model` and `Identity` appropriate for the hardware device you want to add. See the 31 | API documentation for specific allowed values. Clicking `Add` will connect to the device. 32 | 33 | You should now see two things: 1) the `Mount` button in the main window says `Ready` and gets a 34 | green border; 2) the window changes to this: 35 | 36 | .. image:: ./Figures/GUI_mount_settings.png 37 | :target: _images/GUI_mount_settings.png 38 | 39 | Where you can now change the hardware settings. For the mount these are typically just left to 40 | default. 41 | 42 | The cameras are added similarily, with one important distinction. When adding either coarse or star 43 | camera a checkbox labelled `Link Star and Coarse` appears. Check this box if you are using the same 44 | physical device as star and coarse cameras, see below: 45 | 46 | .. image:: ./Figures/GUI_add_camera.png 47 | :target: _images/GUI_add_camera.png 48 | 49 | After clicking add, it should change to: 50 | 51 | .. image:: ./Figures/GUI_camera_settings.png 52 | :target: _images/GUI_camera_settings.png 53 | 54 | Where now many important settings are available. In particular, *the camera plate scale must be 55 | set correctly.* 56 | 57 | Loading the other cameras and a receiver is straightforward, following the same procedure as above. 58 | 59 | When you are done, all the hardware devices you wish to use should say `Ready` with green borders. 60 | 61 | Manual Control 62 | ^^^^^^^^^^^^^^ 63 | Here you may send move commands to the telescope mount. There are three coordinate systems 64 | available to choose from. They are defined more specifically in the ICSOS paper. 65 | 66 | 1. COM (commanded): Send the entered values directly to the mount. 67 | 2. MNT (mount): Go to this poisiton in the mount-local corrected coordinate system. Requires 68 | system to be aligned. 69 | 3. ENU (east north up): Go to this position in the traditional altitude-azimuth angles relative to 70 | the horizon and north direction. Requires system to be aligned and located. 71 | 72 | Pressing `Send` commands the move. Pressing `Stop` aborts the move if still in progress. 73 | 74 | Location and Alignment 75 | ^^^^^^^^^^^^^^^^^^^^^^ 76 | Here you may set the telescopes location on Earth, and the alignment. 77 | 78 | Clicking `Set Location` opens this window: 79 | 80 | .. image:: ./Figures/GUI_location.png 81 | :target: _images/GUI_location.png 82 | 83 | Where you should enter the coordinates of your telescope. Note that north and east are positive 84 | directions and that the height is the *distance above the reference ellipsoid* (not above sea 85 | level). After pressing `Set` the location should be listed in the main window. 86 | 87 | Clicking `Set Alignment` opens this window: 88 | 89 | .. image:: ./Figures/GUI_alignment.png 90 | :target: _images/GUI_alignment.png 91 | 92 | Here, you may click `Do auto-align` to do the star-camera based auto alignment procedure (takes c. 93 | 3min 15s). If you have physically aligned your telescope to the traditional ENU coordinates you may 94 | instead click `EastNorthUp`. The text in the main window should change to read "Is Aligned" when 95 | finished. (`Load from file` is not yet implemented.) 96 | 97 | Target 98 | ^^^^^^ 99 | Here you may set the tracking target. All tracking is calculated in real-time, so there are no 100 | options for pre-planning. Clicking `Set Manual` brings up this window: 101 | 102 | .. image:: ./Figures/GUI_target.png 103 | :target: _images/GUI_target.png 104 | 105 | Here you may enter either a satellite's orbit as a two-line element (TLE) or a deep-sky object by 106 | its right ascension (RA) and declination (Dec). Clicking `Set` should list the target in the main 107 | window. After setting the target you may optionally enter times (as an ISO UTC timestamp e.g. 108 | `2019-09-16 13:14:15`) for when tracking should start and stop. If you do this, pressing `Start 109 | Tracking` will slew to the start position target and then wait for the start time before tracking. 110 | 111 | (`Set from File` is not yet implemented.) 112 | 113 | Controller Properties 114 | ^^^^^^^^^^^^^^^^^^^^^ 115 | Here you can control the nitty gritty of the feedback system as well as the fine and coarse 116 | trackers. See the API reference for what each of these items do. Pressing `Feedback` opens this 117 | window: 118 | 119 | .. image:: ./Figures/GUI_feedback.png 120 | :target: _images/GUI_feedback.png 121 | 122 | where you can change each item and then press `Set`. 123 | 124 | Clicking either of `Coarse Tracker` and `Fine Tracker` similarily opens the respective windows: 125 | 126 | .. image:: ./Figures/GUI_trackers.png 127 | :target: _images/GUI_trackers.png 128 | 129 | Tracking 130 | ^^^^^^^^ 131 | Here you control the general tracking functionality. Allowing the three modes CCL (coarse closed 132 | loop), CTFSP (coarse to fine spiral), and FCL (fine closed loop) is decided here. For pypogs to 133 | switch into a mode the available track needs to fulfill the transition conditions (see Controller 134 | Properties) and the mode must be allowed. You can also allow auto-acquisition of satellites (i.e. 135 | pypogs will attempt to track bright spots that show up on the cameras and determine if they are 136 | good or not). 137 | 138 | Status 139 | ^^^^^^ 140 | This lists (in real-time) the tracking data pypogs is using/calculating. 141 | 142 | Live View and Interactive Control 143 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 144 | The majority of the GUI is dedicated to user interaction with the trackers. Typically it looks like 145 | this: 146 | 147 | .. image:: ./Figures/GUI_live_view.png 148 | :target: _images/GUI_live_view.png 149 | 150 | The top right corner allows the live view to be zoomed in. 151 | 152 | The first row below the live view controls what is displayed. The checkboxes `None`, `Star`, 153 | `Coarse`, and `Fine` show the corresponding camera. *The chosen camera also determines which tracker 154 | is controlled when interacting.* `Max Value` sets the scaling of the live view (the value which is 155 | mapped to white), it may also be set to `Auto`. `Annotate` selects if the tracking overlay is shown 156 | on the live view. 157 | 158 | Several overlays are shown if you are viewing the coarse or fine camera: 159 | 160 | - Large blue cross-hair with arrows: The position of this crosshair shows the `intercamera 161 | alignment`. (I.e. for the coarse camera this is where the target needs to be to appear on the 162 | fine camera.) The arrows show the horizontal (azimuth) and vertical (altitude) direction (i.e. 163 | if camera rotation is nonzero the crosshair will be rotated equivalently.) 164 | - Small light blue cross-hair: This shows the offset relative to the intercamera alignment, which 165 | most of the time is zero. (For details see offset description below.) 166 | - Green circle: This shows the current tracking estimate. The circle is centered at the tracking 167 | mean and has radius of the tracking standard deviation. 168 | - Green cross: This is the instant track position. 169 | - Blue circle: This shows the tracking search region. Only spots appearing inside this circle are 170 | allowed for tracking. 171 | 172 | The second row below the live view interacts with the selected tracker. There are three functions: 173 | 174 | - Manual acquisition: If you see your target satellite on the live view, check the `Manual Acquire` 175 | box and then click on the satellite. This will set the search region (blue circle) to where you 176 | clicked and the satellite will be acquired. You can also clear the current track (if it's stuck 177 | on the wrong target). 178 | - Offsets: Often there is significant error in the predicted position of the satellite. Of course, 179 | you can have pypogs closed-loop track the satellite (using acquisition as above). However, you 180 | may also offset the tracker to manually point towards a specific target without enabling the 181 | tracker. Consider, e.g., you are in OL (open loop) tracking mode, and are viewing the coarse (CCL) 182 | camera. If you check `Add Offset` and then click a point in the live view, an offset will be added 183 | to the OL tracker such that this point ends up at the large blue cross-hair. 184 | - Intercamera alignment: This is typically only used when setting up the system at the start of an 185 | observing session. Checking `Intercam Alignment` and clicking in the live view will move the large 186 | blue cross-hair to this position. Use this to align the cameras by: 1) point the telescope to 187 | something recognisable in the fine camera; 2) select the coarse camera, move the cross-hair to the 188 | same object as is shown in the fine camera; 3) do the same for the star camera. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pypogs documentation master file, created by 2 | sphinx-quickstart on Wed Jun 19 11:22:42 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. include:: ../README.rst 7 | 8 | .. toctree:: 9 | :maxdepth: 3 10 | :caption: Contents: 11 | 12 | self 13 | installation 14 | fpa_build 15 | gui_instructions 16 | api_docs 17 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Getting Python 5 | -------------- 6 | pypogs is written for Python 3.7 (and therefore runs on almost any platform) and should work with 7 | most modern Python 3 installations. However, it is strongly recommended to use a separate Python 8 | environment for pypogs to avoid issues with clashing or out-of-date requirments as well as keeping 9 | your environment clean. Therefore the preferred method of getting Python is by 10 | `downloading Miniconda for Python 3+ `_. If you are 11 | on Windows you will be given the option to `add conda to PATH` during installation. If you do not 12 | select this option, the instructions (including running Python and pypogs) which refer to the 13 | terminal/CMD window will need to be carried out in the `Anaconda Prompt` instead. 14 | 15 | Now, open a terminal/CMD window and test that you have conda and python by typing:: 16 | 17 | conda 18 | python 19 | exit() 20 | 21 | (On Windows you may be sent to the Windows Store to download Python on the second line. Do not do 22 | this, instead go to `Manage app execution aliases` in the Windows settings and disable the python 23 | and python3 aliases to use the version installed with miniconda.) 24 | 25 | Now ensure you are up to date:: 26 | 27 | conda update conda 28 | 29 | You can now run any Python script by typing ``python script_name.py``. 30 | 31 | Create environment for pypogs 32 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 33 | Now create an environment specific for pypogs by:: 34 | 35 | conda create --name pypogs_env python=3.7 pip 36 | conda activate pypogs_env 37 | 38 | You will need to activate the environment each time you restart the terminal/CMD window. (To go to 39 | your base environment type ``conda deactivate``.) 40 | 41 | Getting pypogs 42 | -------------- 43 | pypogs is not available on PIP/PyPI (the Python Package Index). Instead you need to get the software 44 | repository from GitHub and place it in the directory where you wish to use it. (I.e. as a folder 45 | next to the Python project you are writing, or alone if you will just use the provided Graphical 46 | User Interface). 47 | 48 | The quick way 49 | ^^^^^^^^^^^^^ 50 | Go to `the GitHub repository `_, click `Clone or Download` and 51 | `Download ZIP` and extract the pypogs directory to where you want to use it. You will notice that 52 | the `pypogs/tetra3` directory is empty (thus pypogs will not work). Therefore also follow the link 53 | to tetra3 in the GitHub repository, download it the same way and place the contents in the 54 | `pypogs/tetra3` directory. 55 | 56 | The good way 57 | ^^^^^^^^^^^^ 58 | To be able to easily download and contribute updates to pypogs you should install Git. Follow the 59 | instructions for your platform `over here `_. 60 | 61 | Now open a terminal/CMD window in the directory where you wish to use pypogs and clone the 62 | GitHib repository:: 63 | 64 | git clone --recurse-submodules "https://github.com/esa/pypogs.git" 65 | 66 | You should see the `pypogs` directory created for you with all neccessary files, including that the 67 | `pypogs/tetra3` directory Check the status of your repository by typing:: 68 | 69 | cd pypogs 70 | git status 71 | 72 | which should tell you that you are on the branch "master" and are up to date with the origin (which 73 | is the GitHub version of pypogs). 74 | 75 | If you find that `pypogs/tetra3` is empty (due to cloning without recursion), get it by:: 76 | 77 | git submodule update --init 78 | 79 | If a new update has come to GitHub you can update yourself by 80 | typing:: 81 | 82 | git pull --recurse-submodules 83 | 84 | If you wish to contribute (please do!) and are not familiar with Git and GitHub, start by creating 85 | a user on GitHub and setting you username and email:: 86 | 87 | git config --global user.name "your_username_here" 88 | git config --global user.email "email@domain.com" 89 | 90 | You will now also be able to push proposed changes to the software. There are many good resources 91 | for learning about Git, `the documentation `_ which includes the reference, 92 | a free book on Git, and introductory videos is a good place to start. Note that if you contribute 93 | to tetra3 you should push those changes to . 94 | 95 | Installing pypogs 96 | ----------------- 97 | To install the requirements open a terminal/CMD window in the pypogs directory and run:: 98 | 99 | pip install -r requirements.txt 100 | 101 | to install all requirements. Test that everything works by running an example:: 102 | 103 | cd examples 104 | python run_pypogsGUI.py 105 | 106 | which should create an instace of pypogs and open the Graphical User Interface for you. 107 | 108 | Adding hardware support 109 | ----------------------- 110 | To connect hardware devices you need to download and install both drivers and Python packages for 111 | the respective device. 112 | 113 | Missing support for your hardware? `File an issue `_ and we 114 | will work together to expand pypogs hardware support! 115 | 116 | Mount: Celestron, Orion, or SkyWatcher 117 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 118 | Use `model='celestron'` in pypogs. No extra drivers or packages are neccessary. 119 | 120 | Camera: FLIR/PointGrey 121 | ^^^^^^^^^^^^^^^^^^^^^^ 122 | Use `model='ptgrey'` in pypogs. You will need both the `FLIR Spinnaker` drivers and the `pyspin` 123 | Python package. How to install: 124 | 125 | 1. Go to `FLIR's Spinnaker page `_, and click 126 | `Download Now` to go to their "download box". Download 127 | `Latest Spinnaker Full SDK/SpinnakerSDK_FULL_{...}.exe` for your system 128 | (most likely Windows x64), and `Latest Python Spinnaker/spinnaker_python-{....}.zip`. Take 129 | note that the python .zip should say `cp37` (for Python 3.7) and `amd64` for x64 system or 130 | `win32` for x86 system (matching the SpinnakerSDK you downloaded previously). 131 | 2. Run the Spinnaker SDK installer, "Camera Evaluation" mode is sufficient for our needs. 132 | 3. Run the newly installed SpinView program and make sure your camera(s) work as expected. 133 | 4. Extract the contents of the spinnaker_python .zip archive. 134 | 5. Open a terminal/CMD window (or Anaconda Prompt); activate your pypogs_env environment. Go to 135 | the extracted .zip archive, where the file `spinnaker_python-{...}.whl` is located. 136 | 6. Install the package by:: 137 | 138 | pip install spinnaker_python-{...}.whl 139 | 140 | You should now be ready to load and use your FLIR/PointGrey camera! Try running the GUI and add the 141 | camera with model `ptgrey` and the serial number printed on the camera (and shown in SpinView). 142 | 143 | Receiver: National Instruments DAQ 144 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 145 | Use `model='ni_daq'` in pypogs. Yoy will need both the `NI DAQmx` drivers and the `nidaqmx` Python 146 | package. 147 | 148 | TODO: Detailed steps. 149 | 150 | If problems arise 151 | ----------------- 152 | Please get in touch by `filing an issue `_. -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /examples/run_pypogsGUI.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Run the pypogs GUI 4 | ================== 5 | 6 | Run this script (i.e. type python run_pypogsGUI.py in a termnial window) to start the pypogs Graphical User Interface. 7 | """ 8 | import sys 9 | sys.path.append('..') 10 | 11 | import pypogs 12 | 13 | sys = pypogs.System() 14 | 15 | try: 16 | pypogs.GUI(sys, 500) 17 | except Exception: 18 | raise 19 | finally: 20 | sys.deinitialize() 21 | 22 | -------------------------------------------------------------------------------- /examples/setupCLI_runGUI.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of how to set up the telescope with the CLI before starting GUI. 3 | """ 4 | import sys 5 | sys.path.append('..') 6 | 7 | import pypogs 8 | from pathlib import Path 9 | 10 | sys = pypogs.System() 11 | try: 12 | # LOAD 13 | sys.add_coarse_camera(model='ptgrey', identity='18285284') 14 | sys.add_star_camera_from_coarse() 15 | sys.add_fine_camera(model='ptgrey', identity='18285254') 16 | sys.add_mount(model='Celestron', identity=0) 17 | sys.alignment.set_location_lat_lon(lat=52.2155, lon=4.4194, height=45) #ESTEC football field (0m MSL) 18 | 19 | # COARSE/STAR 20 | sys.coarse_camera.exposure_time = 1 #450 21 | sys.coarse_camera.gain = 0 22 | sys.coarse_camera.frame_rate = 2 23 | sys.coarse_camera.binning = 2 24 | sys.coarse_camera.plate_scale = 20.3 25 | sys.coarse_track_thread.goal_x_y = [0, 0] 26 | sys.coarse_track_thread.spot_tracker.max_search_radius = 500 27 | sys.coarse_track_thread.spot_tracker.min_search_radius = 200 28 | sys.coarse_track_thread.spot_tracker.crop = (256,256) 29 | sys.coarse_track_thread.spot_tracker.spot_min_sum = 500 30 | sys.coarse_track_thread.spot_tracker.bg_subtract_mode = 'local_median' 31 | sys.coarse_track_thread.spot_tracker.sigma_mode = 'local_median_abs' 32 | sys.coarse_track_thread.spot_tracker.fails_to_drop = 10 33 | sys.coarse_track_thread.spot_tracker.smoothing_parameter = 8 34 | sys.coarse_track_thread.spot_tracker.rmse_smoothing_parameter = 8 35 | sys.coarse_track_thread.feedforward_threshold = 10 36 | sys.coarse_track_thread.img_save_frequency = 1 37 | sys.coarse_track_thread.image_folder = Path(r'C:\Users\gustav pettersson\Desktop\11th observing\imgsavefolder') 38 | 39 | # FINE 40 | sys.fine_camera.exposure_time = 20 41 | sys.fine_camera.gain = 0 42 | sys.fine_camera.frame_rate = 10 43 | sys.fine_camera.binning = 2 44 | sys.fine_camera.plate_scale = .30 45 | sys.fine_camera.flip_x = True 46 | sys.fine_track_thread.spot_tracker.max_search_radius = 100 47 | sys.fine_track_thread.spot_tracker.min_search_radius = 10 48 | sys.fine_track_thread.spot_tracker.crop = (256,256) 49 | sys.fine_track_thread.spot_tracker.spot_min_sum = 500 50 | sys.fine_track_thread.spot_tracker.image_th = 50 51 | sys.fine_track_thread.spot_tracker.bg_subtract_mode = 'global_median' 52 | sys.fine_track_thread.spot_tracker.fails_to_drop = 20 53 | sys.fine_track_thread.spot_tracker.smoothing_parameter = 20 54 | sys.fine_track_thread.spot_tracker.rmse_smoothing_parameter = 20 55 | sys.fine_track_thread.spot_tracker.spot_max_axis_ratio = None 56 | sys.fine_track_thread.feedforward_threshold = 5 57 | sys.fine_track_thread.img_save_frequency = 1 58 | sys.fine_track_thread.image_folder = Path(r'C:\Users\gustav pettersson\Desktop\11th observing\imgsavefolder') 59 | 60 | # FEEDBACK 61 | sys.control_loop_thread.integral_max_add = 30 62 | sys.control_loop_thread.integral_max_subtract = 30 63 | sys.control_loop_thread.integral_min_rate = 5 64 | sys.control_loop_thread.OL_P = 1 65 | sys.control_loop_thread.OL_I = 10 66 | sys.control_loop_thread.OL_speed_limit = 4*3600 67 | sys.control_loop_thread.CCL_P = 1 68 | sys.control_loop_thread.CCL_I = 5 69 | sys.control_loop_thread.CCL_speed_limit = 360 70 | sys.control_loop_thread.CCL_transition_th = 100 71 | sys.control_loop_thread.FCL_P = 2 72 | sys.control_loop_thread.FCL_I = 5 73 | sys.control_loop_thread.FCL_speed_limit = 180 74 | sys.control_loop_thread.FCL_transition_th = 50 75 | sys.control_loop_thread.CTFSP_spacing = 100 76 | sys.control_loop_thread.CTFSP_speed = 50 77 | sys.control_loop_thread.CTFSP_max_radius = 500 78 | sys.control_loop_thread.CTFSP_transition_th = 20 79 | sys.control_loop_thread.CTFSP_auto_update_CCL_goal = True 80 | sys.control_loop_thread.CTFSP_auto_update_CCL_goal_th = 10 81 | sys.control_loop_thread.CTFSP_disable_after_goal_update = True 82 | 83 | pypogs.GUI(sys, 500) 84 | except Exception: 85 | raise 86 | finally: 87 | gc_loop_stop = True 88 | sys.deinitialize() 89 | 90 | -------------------------------------------------------------------------------- /pypogs/__init__.py: -------------------------------------------------------------------------------- 1 | from .system import System, Alignment, Target 2 | from .tracking import ControlLoopThread, TrackingThread, SpotTracker 3 | from .hardware import Camera, Mount, Receiver 4 | from .gui import GUI 5 | 6 | __all__ = ['System', 'Alignment', 'Target' 7 | 'ControlLoopThread', 'TrackingThread', 'SpotTracker' 8 | 'Camera', 'Mount', 'Receiver'] 9 | 10 | name = 'pypogs' -------------------------------------------------------------------------------- /pypogs/system.py: -------------------------------------------------------------------------------- 1 | """High-level control of pypogs core 2 | ==================================== 3 | 4 | - :class:`pypogs.System` is the main instance for interfacing with and controlling the pypogs core. 5 | It allows the user to create and manage the hardware, tracking threads, targets etc. 6 | 7 | - :class:`pypogs.Alignment` manages the alignment and location of the system, including coordinate 8 | transformations. 9 | 10 | - :class:`pypogs.Target` manages the target and start/end times. 11 | 12 | This is Free and Open-Source Software originally written by Gustav Pettersson at ESA. 13 | 14 | License: 15 | Copyright 2019 the European Space Agency 16 | 17 | Licensed under the Apache License, Version 2.0 (the "License"); 18 | you may not use this file except in compliance with the License. 19 | You may obtain a copy of the License at 20 | 21 | https://www.apache.org/licenses/LICENSE-2.0 22 | 23 | Unless required by applicable law or agreed to in writing, software 24 | distributed under the License is distributed on an "AS IS" BASIS, 25 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 26 | See the License for the specific language governing permissions and 27 | limitations under the License. 28 | """ 29 | 30 | # Standard imports: 31 | from pathlib import Path 32 | import logging 33 | from threading import Thread 34 | from csv import writer as csv_write 35 | 36 | # External imports: 37 | import numpy as np 38 | from astropy.time import Time as apy_time 39 | from astropy import units as apy_unit, coordinates as apy_coord, utils as apy_util 40 | from skyfield import sgp4lib as sgp4 41 | from skyfield import api as sf_api 42 | from tifffile import imwrite as tiff_write 43 | 44 | # Internal imports: 45 | from tetra3 import Tetra3 46 | from .hardware import Camera, Mount, Receiver 47 | from .tracking import TrackingThread, ControlLoopThread 48 | 49 | # Useful definitions: 50 | EPS = 10**-6 # Epsilon for use in non-zero check 51 | DEG = chr(176) # Degree (unicode) character 52 | _system_data_dir = Path(__file__).parent / '_system_data' 53 | if not _system_data_dir.exists(): 54 | _system_data_dir.mkdir() 55 | 56 | 57 | class System: 58 | """Instantiate and control pypogs functionality and hardware. 59 | 60 | The first step is always to create a System instance. The hardware and all other classes 61 | are accessible as properties of the System (e.g. :attr:`coarse_camera`, 62 | :attr:`control_loop_thread`) and should be created by asking the System instance to do 63 | so. 64 | 65 | Note: 66 | Tables of Earth's rotation must be downloaded to do coordinate transforms. Calling 67 | :meth:`update_databases()` will attempt to download these from the internet (if expired). 68 | To facilitate offline use of pypogs, these will otherwise not be automatically downloaded 69 | unless strictly necessary. If you use pypogs offline do this update every few months to 70 | keep the coordinate transforms accurate. 71 | 72 | Example: 73 | :: 74 | 75 | import pypogs 76 | sys = pypogs.System() 77 | 78 | Add and control hardware: 79 | There are five possible hardware instances: 80 | 81 | - :attr:`mount` (:class:`pypogs.Mount`) 82 | 83 | - :attr:`star_camera` (:class:`pypogs.Camera`) 84 | 85 | - :attr:`coarse_camera` (:class:`pypogs.Camera`) 86 | 87 | - :attr:`fine_camera` (:class:`pypogs.Camera`) 88 | 89 | - :attr:`receiver` (:class:`pypogs.Receiver`) 90 | 91 | They should be added to the System by using an add method, e.g. :meth:`add_mount` for 92 | :attr:`mount`, and may be removed by the respective :meth:`clear_mount`. One special case 93 | is if the star and coarse cameras are the same physical camera, then use 94 | :meth:`add_star_camera_from_coarse` to copy the :attr:`coarse_camera` object to 95 | :attr:`star_camera`. After adding, see the respective class documentation for controlling 96 | the settings. 97 | 98 | Example: 99 | :: 100 | 101 | sys.add_coarse_camera(model='ptgrey', identity='18285284') 102 | sys.add_star_camera_from_coarse() 103 | sys.add_fine_camera(model='ptgrey', identity='18285254') 104 | sys.coarse_camera.exposure_time = 100 # milliseconds 105 | sys.coarse_camera.frame_rate = 2 # hertz 106 | sys.coarse_camera is sys.star_camera # returns True 107 | 108 | Settings for closed loop tracking: 109 | When a coarse or fine Camera object is added, a corresponding tracking thread (e.g. 110 | :attr:`coarse_track_thread` for :attr:`coarse_camera`) is also created. This is a 111 | :class:`pypogs.TrackingThread` object which also holds a :class:`pypogs.SpotTracker` object 112 | (accessible via :attr:`coarse_track_thread.spot_tracker`). The parameters for these (see 113 | respective documentation) are used to set up the detection for tracking. For best 114 | performance it is critical to set up these well. 115 | 116 | Further, a :class:`pypogs.ControlLoopThread` object is available as 117 | :attr:`control_loop_thread` and defines the feedback parameters used for closed loop 118 | tracking (e.g. gains, modes, switching logic). 119 | 120 | Example: 121 | :: 122 | 123 | sys.coarse_track_thread.feedforward_threshold = 10 124 | sys.coarse_track_thread.spot_tracker.bg_subtract_mode = 'global_median' 125 | sys.control_loop_thread.CCL_P = 1 126 | 127 | Set location and alignment: 128 | :attr:`alignment` references the :class:`pypogs.Alignment` instance (auto created) used to 129 | determine and calibrate the location, alignment, and mount corrections. See the class 130 | documentation for how to set location and alignments. To do auto-alignment, use the 131 | :meth:`do_auto_star_alignment` method (requires a star camera). Plate solving for the auto 132 | alignment is performed via the tetra3 package. You can set a custom instance via 133 | :attr:`tetra3`, otherwise a default instance will be created for you. 134 | 135 | If your mount has built in alignment (and/or is physically aligned to the earth) you may 136 | call :meth:`alignment.set_alignment_enu` to set the telescope alignment to East, North, Up 137 | (ENU) coordinates, which will also disable the corrections done in pypogs. ENU is the 138 | traditional astronomical coordinate system for altitude (elevation) and azimuth telescopes, 139 | measured as degrees above the horizon and degrees away from north (towards east) 140 | respectively. 141 | 142 | Example: 143 | :: 144 | 145 | sys.alignment.set_location_lat_lon(lat=52.2155, lon=4.4194, height=45) 146 | sys.do_auto_star_alignment() 147 | 148 | Set target: 149 | :attr:`target` references the :class:`pypogs.Target` instance (auto created) which holds 150 | the target and (optional) tracking start and end times. The target may be set directly to 151 | an *astropy SkyCoord* or a *skyfield EarthSatellite* by :meth:`target.set_target` or these 152 | can be created by pypogs by e.g. calling :meth:`target.set_target_from_tle` with a 153 | Two Line Element (TLE) for a satellite or :meth:`target.set_target_from_ra_dec` 154 | with right ascension and declination (in decimal degrees) for a star. 155 | 156 | Example satellite: 157 | :: 158 | 159 | tle = ['1 28647U 05016B 19180.65078896 .00000014 00000-0 19384-4 0 9991',\\ 160 | '2 28647 56.9987 238.9694 0122260 223.0550 136.0876 15.05723818 6663'] 161 | sys.target.set_target_from_tle(tle) 162 | 163 | Example star: 164 | :: 165 | 166 | sys.target.set_target_from_ra_dec(37.95456067, 89.26410897) # Polaris 167 | 168 | Control tracking: 169 | You are now ready! Just call :meth:`start_tracking` and then :meth:`stop_tracking` when you 170 | are finished. 171 | 172 | Release hardware: 173 | Before exiting, it's strongly recommended to call :meth:`deinitialize` to release the 174 | hardware resources. 175 | 176 | Args: 177 | data_folder (pathlib.Path, optional): The folder for data saving. If None (the default) the 178 | folder *pypogs*/data will be used/created. 179 | debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) 180 | the folder *pypogs*/debug will be used/created. 181 | """ 182 | 183 | @staticmethod 184 | def update_databases(): 185 | """Download and update Skyfield and Astropy databases (of earth rotation).""" 186 | sf_api.Loader(_system_data_dir).download('finals2000A.all') 187 | apy_util.data.download_file(apy_util.iers.IERS_A_URL, cache='update') 188 | 189 | def __init__(self, data_folder=None, debug_folder=None): 190 | """Create System instance. See class documentation.""" 191 | # Logger setup 192 | self._debug_folder = None 193 | if debug_folder is None: 194 | self.debug_folder = Path(__file__).parent / 'debug' 195 | else: 196 | self.debug_folder = debug_folder 197 | self._logger = logging.getLogger('pypogs.system.System') 198 | if not self._logger.hasHandlers(): 199 | # Add new handlers to the logger if there are none 200 | self._logger.setLevel(logging.DEBUG) 201 | # Console handler at INFO level 202 | ch = logging.StreamHandler() 203 | ch.setLevel(logging.INFO) 204 | # File handler at DEBUG level 205 | fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') 206 | fh.setLevel(logging.DEBUG) 207 | # Format and add 208 | formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') 209 | fh.setFormatter(formatter) 210 | ch.setFormatter(formatter) 211 | self._logger.addHandler(fh) 212 | self._logger.addHandler(ch) 213 | 214 | # Start of constructor 215 | self._logger.debug('System constructor called') 216 | # Data folder setup 217 | self._data_folder = None 218 | if data_folder is None: 219 | self.data_folder = Path(__file__).parent / 'data' 220 | else: 221 | self.data_folder = data_folder 222 | # Threads 223 | self._coarse_track_thread = None 224 | self._fine_track_thread = None 225 | self._control_loop_thread = ControlLoopThread(self) # Create the control loop thread 226 | # Hardware not managed by threads 227 | self._star_cam = None 228 | self._receiver = None 229 | self._mount = None 230 | self._alignment = Alignment() 231 | self._target = Target() 232 | # tetra3 instance used for plate solving 233 | self._tetra3 = None 234 | # Variable to stop system thread 235 | self._stop_loop = True 236 | self._thread = None 237 | import atexit 238 | import weakref 239 | atexit.register(weakref.ref(self.__del__)) 240 | self._logger.info('System instance created.') 241 | 242 | def __del__(self): 243 | """Destructor. Calls deinitialize().""" 244 | try: 245 | self._logger.debug('System destructor called') 246 | except BaseException: 247 | pass 248 | try: 249 | self.deinitialize() 250 | self._logger.debug('Deinitialised') 251 | except BaseException: 252 | pass 253 | 254 | def initialize(self): 255 | """Initialise cameras, mount, and receiver if they are not already.""" 256 | self._logger.debug('Initialise called') 257 | if self.star_camera is not None: 258 | self._logger.debug('Has star cam') 259 | if not self.star_camera.is_init: 260 | try: 261 | self.star_camera.initialize() 262 | self._logger.debug('Initialised') 263 | except BaseException: 264 | self._logger.warning('Failed to init', exc_info=True) 265 | else: 266 | self._logger.debug('Already initialised') 267 | if self.coarse_camera is not None: 268 | self._logger.debug('Has coarse cam') 269 | if not self.coarse_camera.is_init: 270 | try: 271 | self.coarse_camera.initialize() 272 | self._logger.debug('Initialised') 273 | except BaseException: 274 | self._logger.warning('Failed to init', exc_info=True) 275 | else: 276 | self._logger.debug('Already initialised') 277 | if self.fine_camera is not None: 278 | self._logger.debug('Has coarse cam') 279 | if not self.fine_camera.is_init: 280 | try: 281 | self.fine_camera.initialize() 282 | self._logger.debug('Initialised') 283 | except BaseException: 284 | self._logger.warning('Failed to init', exc_info=True) 285 | else: 286 | self._logger.debug('Already initialised') 287 | if self.mount is not None: 288 | self._logger.debug('Has mount') 289 | if not self.mount.is_init: 290 | try: 291 | self.mount.initialize() 292 | self._logger.debug('Initialised') 293 | except BaseException: 294 | self._logger.warning('Failed to init', exc_info=True) 295 | else: 296 | self._logger.debug('Already initialised') 297 | if self.receiver is not None: 298 | self._logger.debug('Has receiver') 299 | if not self.receiver.is_init: 300 | try: 301 | self.receiver.initialize() 302 | self._logger.debug('Initialised') 303 | except BaseException: 304 | self._logger.warning('Failed to init', exc_info=True) 305 | else: 306 | self._logger.debug('Already initialised') 307 | self._logger.info('System initialised') 308 | 309 | def deinitialize(self): 310 | """Deinitialise camera, mount, and receiver if they are initialised.""" 311 | self._logger.debug('Deinitialise called') 312 | if self.star_camera is not None: 313 | self._logger.debug('Has star cam') 314 | if self.star_camera.is_init: 315 | try: 316 | self.star_camera.deinitialize() 317 | self._logger.debug('Deinitialised') 318 | except BaseException: 319 | self._logger.warning('Failed to deinit', exc_info=True) 320 | else: 321 | self._logger.debug('Not initialised') 322 | if self.coarse_camera is not None: 323 | self._logger.debug('Has coarse cam') 324 | if self.coarse_camera.is_init: 325 | try: 326 | self.coarse_camera.deinitialize() 327 | self._logger.debug('Deinitialised') 328 | except BaseException: 329 | self._logger.warning('Failed to deinit', exc_info=True) 330 | else: 331 | self._logger.debug('Not initialised') 332 | if self.fine_camera is not None: 333 | self._logger.debug('Has coarse cam') 334 | if self.fine_camera.is_init: 335 | try: 336 | self.fine_camera.deinitialize() 337 | self._logger.debug('Deinitialised') 338 | except BaseException: 339 | self._logger.warning('Failed to deinit', exc_info=True) 340 | else: 341 | self._logger.debug('Not initialised') 342 | if self.mount is not None: 343 | self._logger.debug('Has mount') 344 | if self.mount.is_init: 345 | try: 346 | self.mount.deinitialize() 347 | self._logger.debug('Deinitialised') 348 | except BaseException: 349 | self._logger.warning('Failed to deinit', exc_info=True) 350 | else: 351 | self._logger.debug('Not initialised') 352 | if self.receiver is not None: 353 | self._logger.debug('Has receiver') 354 | if self.receiver.is_init: 355 | try: 356 | self.receiver.deinitialize() 357 | self._logger.debug('Deinitialised') 358 | except BaseException: 359 | self._logger.warning('Failed to deinit', exc_info=True) 360 | else: 361 | self._logger.debug('Not initialised') 362 | self._logger.info('System deinitialised') 363 | 364 | @property 365 | def is_init(self): 366 | """bool: Returns True if all attached hardware (cameras, mount, and receiver) are 367 | initialised.""" 368 | init = True 369 | if self.star_camera is not None and not self.star_camera.is_init: 370 | init = False 371 | if self.coarse_camera is not None and not self.coarse_camera.is_init: 372 | init = False 373 | if self.fine_camera is not None and not self.fine_camera.is_init: 374 | init = False 375 | if self.mount is not None and not self.mount.is_init: 376 | init = False 377 | if self.receiver is not None and not self.receiver.is_init: 378 | init = False 379 | return init 380 | 381 | @property 382 | def is_busy(self): 383 | """bool: Returns True if system is doing some control task.""" 384 | if self._thread is not None and self._thread.is_alive(): 385 | return True 386 | if self._control_loop_thread is not None and self._control_loop_thread.is_running: 387 | return True 388 | if self.mount is not None and self.mount.is_moving: 389 | return True 390 | return False 391 | 392 | @property 393 | def data_folder(self): 394 | """pathlib.Path: Get or set the path for data saving. Will create folder if not existing. 395 | """ 396 | return self._data_folder 397 | 398 | @data_folder.setter 399 | def data_folder(self, path): 400 | assert isinstance(path, Path), 'Must be pathlib.Path object' 401 | self._logger.debug('Got set data folder with: '+str(path)) 402 | if path.is_file(): 403 | path = path.parent 404 | if not path.is_dir(): 405 | path.mkdir(parents=True) 406 | self._data_folder = path 407 | self._logger.debug('Set data folder to: '+str(self._data_folder)) 408 | 409 | @property 410 | def debug_folder(self): 411 | """pathlib.Path: Get or set the path for debug logging. Will create folder if not existing. 412 | """ 413 | return self._debug_folder 414 | 415 | @debug_folder.setter 416 | def debug_folder(self, path): 417 | # Do not do logging in here! This will be called before the logger is set up 418 | assert isinstance(path, Path), 'Must be pathlib.Path object' 419 | if path.is_file(): 420 | path = path.parent 421 | if not path.is_dir(): 422 | path.mkdir(parents=True) 423 | self._debug_folder = path 424 | 425 | @property 426 | def control_loop_thread(self): 427 | """System.ControlLoopThread: Get the system control loop thread.""" 428 | return self._control_loop_thread 429 | 430 | @property 431 | def alignment(self): 432 | """pypogs.Alignment: Get the system alignment object.""" 433 | return self._alignment 434 | 435 | @property 436 | def target(self): 437 | """pypogs.Alignment: Get the system target object.""" 438 | return self._target 439 | 440 | @property 441 | def star_camera(self): 442 | """pypogs.Camera: Get or set the star camera.""" 443 | return self._star_cam 444 | 445 | @star_camera.setter 446 | def star_camera(self, cam): 447 | self._logger.debug('Got set star camera with: '+str(cam)) 448 | if self.star_camera is not None: 449 | self._logger.debug('Already have a star camera, try to clear it') 450 | try: 451 | self.star_camera.deinitialize() 452 | self._logger.debug('Deinit') 453 | except BaseException: 454 | self._logger.debug('Failed to deinit', exc_info=True) 455 | self._star_cam = None 456 | self._logger.debug('Cleared') 457 | if cam is not None: 458 | assert isinstance(cam, Camera), 'Must be pypogs.Camera' 459 | self._star_cam = cam 460 | self._logger.debug('Star camera set to: ' + str(self.star_camera)) 461 | 462 | def add_star_camera(self, model=None, identity=None, name='StarCamera', auto_init=True): 463 | """Create and set the star camera. Calls pypogs.Camera constructor with 464 | name='StarCamera' and the given arguments. 465 | 466 | Args: 467 | model (str, optional): The model used to determine the correct hardware API. Supported: 468 | 'ptgrey' for PointGrey/FLIR Machine Vision cameras (using Spinnaker/PySpin APIs). 469 | identity (str, optional): String identifying the device. For *ptgrey* this is 470 | 'serial number' *as a string*. 471 | name (str, optional): Name for the device, defaults to 'StarCamera'. 472 | auto_init (bool, optional): If both model and identity are given when creating the 473 | Camera and auto_init is True (the default), Camera.initialize() will be called 474 | after creation. 475 | """ 476 | self._logger.debug('Got add star camera with model=' + str(model) 477 | + ' identity='+str(identity) + ' auto_init=' + str(auto_init)) 478 | if self.star_camera is not None: 479 | self._logger.debug('Already have a star camera with model=' 480 | + str(self.star_camera.model) 481 | + ' identity=' + str(self.star_camera.identity)) 482 | if self.star_camera.model == model and self.star_camera.identity == identity: 483 | self._logger.debug('Already attached, just set the name') 484 | self.star_camera.name = name 485 | else: 486 | self._logger.debug('Got a different camera, clear the old one') 487 | self.star_camera = None 488 | self._logger.debug('Create new camera') 489 | self.star_camera = Camera(model=model, identity=identity, name=name, 490 | auto_init=auto_init) 491 | else: 492 | self._logger.debug('Dont have anything old to clean up, create new camera') 493 | self.star_camera = Camera(model=model, identity=identity, name=name, 494 | auto_init=auto_init) 495 | return self.star_camera 496 | 497 | def add_star_camera_from_coarse(self): 498 | """Set the star camera to be the current coarse camera object.""" 499 | try: 500 | self.star_camera = self.coarse_camera 501 | return self.star_camera 502 | except BaseException: 503 | self._logger.debug('Could not link star from coarse: ', exc_info=True) 504 | return None 505 | 506 | def clear_star_camera(self): 507 | """Set the star camera to None.""" 508 | self.star_camera = None 509 | 510 | @property 511 | def coarse_camera(self): 512 | """pypogs.Camera: Get or set the coarse camera. Will create System.coarse_track_thread if 513 | not existing. 514 | """ 515 | if self._coarse_track_thread is None: 516 | return None 517 | else: 518 | return self._coarse_track_thread.camera 519 | 520 | @coarse_camera.setter 521 | def coarse_camera(self, cam): 522 | self._logger.debug('Got set coarse camera with: ' + str(cam)) 523 | if self.coarse_camera is not None: 524 | self._logger.debug('Already have a coarse camera, try to clear it') 525 | try: 526 | self.coarse_camera.deinitialize() 527 | self._logger.debug('Deinit') 528 | except BaseException: 529 | self._logger.debug('Failed to deinit', exc_info=True) 530 | self._coarse_track_thread.camera = None 531 | self._logger.debug('Cleared') 532 | if cam is not None: 533 | assert isinstance(cam, Camera), 'Must be pypogs.Camera object' 534 | if self._coarse_track_thread is None: 535 | self._logger.info('Creating coarse tracker thread') 536 | self._coarse_track_thread = TrackingThread(name='CoarseTracker') 537 | self._coarse_track_thread.camera = cam 538 | self._logger.debug('Set coarse camera to: ' + str(self.coarse_camera)) 539 | 540 | def add_coarse_camera(self, model=None, identity=None, name='CoarseCamera', auto_init=True): 541 | """Create and set the coarse camera. Calls pypogs.Camera constructor with 542 | name='CoarseCamera' and the given arguments. 543 | 544 | Args: 545 | model (str, optional): The model used to determine the correct hardware API. Supported: 546 | 'ptgrey' for PointGrey/FLIR Machine Vision cameras (using Spinnaker/PySpin APIs). 547 | identity (str, optional): String identifying the device. For *ptgrey* this is 548 | 'serial number' *as a string*. 549 | name (str, optional): Name for the device, defaults to 'CoarseCamera'. 550 | auto_init (bool, optional): If both model and identity are given when creating the 551 | Camera and auto_init is True (the default), Camera.initialize() will be called 552 | after creation. 553 | """ 554 | self._logger.debug('Got add coarse camera with model=' + str(model) 555 | + ' identity=' + str(identity) + ' auto_init=' + str(auto_init)) 556 | 557 | if self.coarse_camera is not None: 558 | self._logger.debug('Already have a coarse camera with model=' 559 | + str(self.coarse_camera.model) + ' identity=' 560 | + str(self.coarse_camera.identity)) 561 | if self.coarse_camera.model == model and self.coarse_camera.identity == identity: 562 | self._logger.debug('Already attached, just set the name') 563 | self.coarse_camera.name = name 564 | else: 565 | self._logger.debug('Got a different camera, clear the old one') 566 | self.coarse_camera = None 567 | self._logger.debug('Create new camera') 568 | self.coarse_camera = Camera(model=model, identity=identity, name=name, 569 | auto_init=auto_init) 570 | return self.coarse_camera 571 | else: 572 | self._logger.debug('Dont have anything old to clean up, create new camera') 573 | self.coarse_camera = Camera(model=model, identity=identity, name=name, 574 | auto_init=auto_init) 575 | return self.coarse_camera 576 | 577 | def add_coarse_camera_from_star(self): 578 | """Set the coarse camera to be the current star camera object.""" 579 | try: 580 | self.coarse_camera = self.star_camera 581 | return self.coarse_camera 582 | except BaseException: 583 | self._logger.debug('Could not link coarse from star: ', exc_info=True) 584 | return None 585 | 586 | def clear_coarse_camera(self): 587 | """Set the coarse camera to None.""" 588 | self.coarse_camera = None 589 | 590 | @property 591 | def fine_camera(self): 592 | """pypogs.Camera: Get or set the fine camera. Will create System.fine_track_thread if not 593 | existing. 594 | """ 595 | if self._fine_track_thread is None: 596 | return None 597 | else: 598 | return self._fine_track_thread.camera 599 | 600 | @fine_camera.setter 601 | def fine_camera(self, cam): 602 | self._logger.debug('Got set fine camera with: ' + str(cam)) 603 | if self.fine_camera is not None: 604 | self._logger.debug('Already have a fine camera, try to clear it') 605 | try: 606 | self.fine_camera.deinitialize() 607 | self._logger.debug('Deinit') 608 | except BaseException: 609 | self._logger.debug('Failed to deinit', exc_info=True) 610 | self._fine_track_thread.camera = None 611 | self._logger.debug('Cleared') 612 | if cam is not None: 613 | assert isinstance(cam, Camera), 'Must be pypogs.Camera object' 614 | if self._fine_track_thread is None: 615 | self._logger.info('Creating fine tracker thread') 616 | self._fine_track_thread = TrackingThread(name='FineTracker') 617 | self._fine_track_thread.camera = cam 618 | self._logger.debug('Set fine camera to: ' + str(self.fine_camera)) 619 | 620 | def add_fine_camera(self, model=None, identity=None, name='FineCamera', auto_init=True): 621 | """Create and set the fine camera. Calls pypogs.Camera constructor with 622 | name='FineCamera' and the given arguments. 623 | 624 | Args: 625 | model (str, optional): The model used to determine the correct hardware API. Supported: 626 | 'ptgrey' for PointGrey/FLIR Machine Vision cameras (using Spinnaker/PySpin APIs). 627 | identity (str, optional): String identifying the device. For *ptgrey* this is 628 | 'serial number' *as a string*. name (str, optional): Name for the device, defaults 629 | to 'FineCamera'. 630 | auto_init (bool, optional): If both model and identity are given when creating the 631 | Camera and auto_init is True (the default), Camera.initialize() will be called 632 | after creation. 633 | """ 634 | self._logger.debug('Got add fine camera with model=' + str(model) + ' identity=' 635 | + str(identity) + ' auto_init=' + str(auto_init)) 636 | if self.fine_camera is not None: 637 | self._logger.debug('Already have a fine camera with model=' 638 | + str(self.fine_camera.model) 639 | + ' identity=' + str(self.fine_camera.identity)) 640 | if self.fine_camera.model == model and self.fine_camera.identity == identity: 641 | self._logger.debug('Already attached, just set the name') 642 | self.fine_camera.name = name 643 | else: 644 | self._logger.debug('Got a different camera, clear the old one') 645 | self.fine_camera = None 646 | self._logger.debug('Create new camera') 647 | self.fine_camera = Camera(model=model, identity=identity, name=name, 648 | auto_init=auto_init) 649 | return self.fine_camera 650 | else: 651 | self._logger.debug('Dont have anything old to clean up, create new camera') 652 | self.fine_camera = Camera(model=model, identity=identity, name=name, 653 | auto_init=auto_init) 654 | return self.fine_camera 655 | 656 | def clear_fine_camera(self): 657 | """Set the fine camera to None.""" 658 | self.fine_camera = None 659 | 660 | @property 661 | def coarse_track_thread(self): 662 | """pypogs.TrackingThread: Get the coarse tracking thread.""" 663 | return self._coarse_track_thread 664 | 665 | @property 666 | def fine_track_thread(self): 667 | """pypogs.TrackingThread: Get the fine tracking thread.""" 668 | return self._fine_track_thread 669 | 670 | @property 671 | def receiver(self): 672 | """Get or set a pypogs.Receiver for the telescope.""" 673 | return self._receiver 674 | 675 | @receiver.setter 676 | def receiver(self, rec): 677 | self._logger.debug('Got set receiver with: '+str(rec)) 678 | if self.receiver is not None: 679 | self._logger.debug('Already have a receiver, try to clear it') 680 | try: 681 | self.receiver.deinitialize() 682 | self._logger.debug('Deinit') 683 | except BaseException: 684 | self._logger.debug('Failed to deinit', exc_info=True) 685 | self._receiver = None 686 | self._logger.debug('Cleared') 687 | if rec is not None: 688 | assert isinstance(rec, Receiver), 'Must be pypogs.Receiver' 689 | self._receiver = rec 690 | self._logger.debug('Receiver set to: ' + str(self._receiver)) 691 | 692 | def add_receiver(self, *args, **kwargs): 693 | """Create and set a pypogs.Receiver for the system. Arguments passed to constructor. 694 | 695 | Args: 696 | model (str, optional): The model used to determine the correct hardware API. Supported: 697 | 'ni_daq' for National Instruments DAQ cards (tested on USB-6211). 698 | identity (str, optional): String identifying the device and input. For *ni_daq* this is 699 | 'device/input' eg. 'Dev1/ai1' for device 'Dev1' and analog input 1; only 700 | differential input is supported for *ni_daq*. 701 | name (str, optional): Name for the device. 702 | save_path (pathlib.Path, optional): Save path to set. See Receiver.save_path for 703 | details. 704 | auto_init (bool, optional): If both model and identity are given when creating the 705 | Receiver and auto_init is True (the default), Receiver.initialize() will be called 706 | after creation. 707 | """ 708 | self._logger.debug('Got add receiver with args: '+str(args)+' kwargs'+str(kwargs)) 709 | if self.receiver is not None: 710 | self._logger.debug('Already have a receiver, clear first') 711 | self.receiver = None 712 | self.receiver = Receiver(*args, **kwargs) 713 | return self.receiver 714 | 715 | def clear_receiver(self): 716 | """Set the receiver to None.""" 717 | self.receiver = None 718 | 719 | @property 720 | def mount(self): 721 | """pypogs.Mount: Get or set the mount.""" 722 | return self._mount 723 | 724 | @mount.setter 725 | def mount(self, mount): 726 | self._logger.debug('Got set mount with: ' + str(mount)) 727 | if self.mount is not None: 728 | self._logger.debug('Already have a mount, try to clear it') 729 | try: 730 | self.mount.deinitialize() 731 | self._logger.debug('Deinit') 732 | except BaseException: 733 | self._logger.debug('Failed to deinit', exc_info=True) 734 | self._mount = None 735 | self._logger.debug('Cleared') 736 | if mount is not None: 737 | assert isinstance(mount, Mount), 'Must be pypogs.Mount' 738 | self._mount = mount 739 | self._logger.debug('Mount set to: ' + str(self._mount)) 740 | 741 | def add_mount(self, *args, **kwargs): 742 | """Create and set a pypogs.Mount for System.mount. Arguments passed to constructor. 743 | 744 | Args: 745 | model (str, optional): The model used to determine the the hardware control interface. 746 | Supported: 'celestron' for Celestron NexStar and Orion/SkyWatcher SynScan (all the 747 | same) hand controller communication over serial. 748 | identity (str or int, optional): String or int identifying the device. For model 749 | *celestron* this can either be a string with the serial port (e.g. 'COM3' on 750 | Windows or '/dev/ttyUSB0' on Linux) or an int with the index in the list of 751 | available ports to use (e.g. identity=0 i if only one serial device is connected.) 752 | name (str, optional): Name for the device. 753 | auto_init (bool, optional): If both model and identity are given when creating the 754 | Mount and auto_init is True (the default), Mount.initialize() will be called after 755 | creation. 756 | """ 757 | self._logger.debug('Got add mount with args: '+str(args)+' kwargs'+str(kwargs)) 758 | if self.mount is not None: 759 | self._logger.debug('Already have a mount, clear first') 760 | self.mount = None 761 | self.mount = Mount(*args, **kwargs) 762 | return self.mount 763 | 764 | def clear_mount(self): 765 | """Set the mount to None.""" 766 | self.mount = None 767 | 768 | @property 769 | def tetra3(self): 770 | """tetra3.Tetra3: Get or set the tetra3 instance used for plate solving star images. Will 771 | create an instance with 'default_database' if none is set. 772 | """ 773 | if self._tetra3 is None: 774 | self._logger.debug('Loading default database tetra3') 775 | self._tetra3 = Tetra3('default_database') 776 | return self._tetra3 777 | 778 | @tetra3.setter 779 | def tetra3(self, tetra3): 780 | self._logger.debug('Got set tetra3 instance with' + str(tetra3)) 781 | assert isinstance(tetra3, Tetra3), 'Must be tetra3 instance' 782 | self._tetra3 = tetra3 783 | delf._logger.debug('Set tetra3 instace') 784 | 785 | def do_auto_star_alignment(self, max_trials=1, rate_control=True): 786 | """Do the auto star alignment procedure by taking eight star images across the sky. 787 | 788 | Will call System.Alignment.set_alignment_from_observations() with the captured images. 789 | 790 | Args: 791 | max_trials (int, optional): Maximum attempts to take each image and solve the position. 792 | Default 1. 793 | rate_control (bool, optional): If True (the default) rate control 794 | (see pypogs.Mount) is used. 795 | """ 796 | assert self.mount is not None, 'No mount' 797 | assert self.star_camera is not None, 'No star camera' 798 | assert self.is_init, 'System not initialized' 799 | assert not self.is_busy, 'System is busy' 800 | 801 | def run(): 802 | self._logger.info('Starting auto-alignment.') 803 | try: 804 | pos_list = [(40, -135), (60, -135), (60, -45), (40, -45), (40, 45), (60, 45), 805 | (60, 135), (40, 135)] 806 | alignment_list = [] 807 | start_time = apy_time.now() 808 | # Create logfile 809 | data_filename = Path(start_time.strftime('%Y-%m-%dT%H%M%S') 810 | + '_System_star_align.csv') 811 | data_file = self.data_folder / data_filename 812 | if data_file.exists(): 813 | self._log_debug('File name clash. Iterating...') 814 | append = 1 815 | while data_file.exists(): 816 | data_file = self.data_folder / (data_filename.stem + str(append) 817 | + data_filename.suffix) 818 | append += 1 819 | self._log_debug('Found allowable file: '+str(data_file)) 820 | with open(data_file, 'w') as file: 821 | writer = csv_write(file) 822 | writer.writerow(['RA', 'DEC', 'ROLL', 'FOV', 'PROB', 'TIME', 'ALT', 'AZI', 823 | 'TRIAL']) 824 | 825 | for idx, (alt, azi) in enumerate(pos_list): 826 | self._logger.info('Getting measurement at Alt: ' + str(alt) 827 | + ' Az: ' + str(azi) + '.') 828 | self.mount.move_to_alt_az(alt, azi, rate_control=rate_control, block=True) 829 | for trial in range(max_trials): 830 | assert not self._stop_loop, 'Thread stop flag is set' 831 | img = self.star_camera.get_next_image() 832 | timestamp = apy_time.now() 833 | # TODO: Test 834 | fov_estimate = self.star_camera.plate_scale * img.shape[1] / 3600 835 | solve = self.tetra3.solve_from_image(img, fov_estimate=fov_estimate, 836 | fov_max_error=.1) 837 | self._logger.debug('TIME: ' + timestamp.iso) 838 | # Save image 839 | tiff_write(self.data_folder / (start_time.strftime('%Y-%m-%dT%H%M%S') 840 | + '_Alt' + str(alt) + '_Azi' + str(azi) 841 | + '_Try' + str(trial+1) + '.tiff'), img) 842 | # Save result to logfile 843 | with open(data_file, 'a') as file: 844 | writer = csv_write(file) 845 | data = np.hstack((solve['RA'], solve['Dec'], solve['Roll'], 846 | solve['FOV'], solve['Prob'], timestamp.iso, alt, azi, 847 | trial + 1)) 848 | writer.writerow(data) 849 | if solve['RA'] is not None: 850 | break 851 | elif trial + 1 < max_trials: 852 | self._logger.debug('Failed attempt '+str(trial+1)) 853 | else: 854 | self._logger.debug('Failed attempt '+str(trial+1)+', skipping...') 855 | alignment_list.append((solve['RA'], solve['Dec'], timestamp, alt, azi)) 856 | 857 | self.mount.move_home(block=False) 858 | # Set the alignment! 859 | assert len(alignment_list) > 0, 'Did not identify any star patterns' 860 | self.alignment.set_alignment_from_observations(alignment_list) 861 | except AssertionError: 862 | self._logger.warning('Auto-align failed.', exc_info=True) 863 | self._stop_loop = True 864 | 865 | self._stop_loop = True 866 | self.mount.wait_for_move_to() 867 | self._thread = Thread(target=run) 868 | self._stop_loop = False 869 | self._thread.start() 870 | 871 | def get_alt_az_of_target(self, times=None, time_step=.1): 872 | """Get the corrected altitude and azimuth angles and rates of the target from the current 873 | alignment. 874 | 875 | Args: 876 | times (astropy.time.Time, optional): The time(s) to calculate for. If None (the 877 | default) the current time is used. If time array the calculation is done for each 878 | time in the array. 879 | time_step (float, optional): The time step in seconds used to calculate the angular 880 | rates (default .1). 881 | 882 | Returns: 883 | numpy.ndarray: Nx2 array with altitude and azimuth angles in degrees. 884 | numpy.ndarray: Nx2 array with altitude and azimuth rates in degrees per second. 885 | """ 886 | if times is None: 887 | times = apy_time.now() 888 | assert isinstance(times, apy_time), 'Times must be astropy time' 889 | # Extend the time vector by 0.1 second if only one time 890 | single_time = (times.size == 1) 891 | if single_time: 892 | times += [0, time_step] * apy_unit.second 893 | dt = (times[1:] - times[:-1]).sec 894 | assert np.all(dt > EPS), 'Times must be different!' 895 | 896 | if isinstance(self.target.target_object, sgp4.EarthSatellite): 897 | pos = self.target.get_target_itrf_xyz(times) 898 | alt_az = self.alignment.get_com_altaz_from_itrf_xyz(pos, position=True) 899 | elif isinstance(self.target.target_object, apy_coord.SkyCoord): 900 | vec = self.target.get_target_itrf_xyz(times) 901 | alt_az = self.alignment.get_com_altaz_from_itrf_xyz(vec) 902 | else: 903 | raise RuntimeError('The target is of unknown type!') 904 | angvel_alt_az = (((alt_az[:, 1:] - alt_az[:, :-1] + 180) % 360) - 180) / dt 905 | 906 | if single_time: 907 | return alt_az[:, 0], angvel_alt_az[:, 0] 908 | else: 909 | return alt_az, np.hstack((angvel_alt_az, angvel_alt_az[:, -1])) 910 | 911 | def get_itrf_direction_of_target(self, times=None): 912 | """Get direction (unit vector) in ITRF from the telescope position to the target. 913 | 914 | Args: 915 | times (astropy.time.Time, optional): The time(s) to calculate for. If None (the 916 | default) the current time is used. If time array the calculation is done for each 917 | time in the array. 918 | 919 | Returns: 920 | numpy.ndarray: Shape (3,) if single time, shape (3,N) if array time. 921 | """ 922 | assert self.target.has_target, 'No target set' 923 | if times is None: 924 | times = apy_time.now() 925 | assert isinstance(times, apy_time), 'Times must be astropy time' 926 | if isinstance(self.target.target_object, sgp4.EarthSatellite): 927 | pos = self.target.get_target_itrf_xyz(times) 928 | itrf_xyz = self.alignment.get_itrf_relative_from_position(pos) 929 | elif isinstance(self.target.target_object, apy_coord.SkyCoord): 930 | itrf_xyz = self.target.get_target_itrf_xyz(times) 931 | else: 932 | raise RuntimeError('The target is of unknown type!') 933 | itrf_xyz /= np.linalg.norm(itrf_xyz, axis=0, keepdims=True) 934 | return itrf_xyz 935 | 936 | def slew_to_target(self, time=None, block=True, rate_control=True): 937 | """Slew the mount to the defined target. 938 | 939 | Args: 940 | time (astropy.time.Time, optional): The time to calculate target position. If None (the 941 | default) the current time is used. 942 | block (bool, optional): If True (the default), execution is blocked until move 943 | finishes. 944 | rate_control (bool, optional): If True (the default) rate control 945 | (see pypogs.Mount) is used. 946 | """ 947 | assert self.mount is not None, 'No Mount' 948 | assert self.is_init, 'System not initialized' 949 | if time is None: 950 | time = apy_time.now() 951 | assert isinstance(time, apy_time), 'Times must be astropy time' 952 | if time.size == 1: 953 | alt_azi = self.get_alt_az_of_target(time)[0] 954 | else: 955 | alt_azi = self.get_alt_az_of_target(time[0])[0] 956 | 957 | self.mount.move_to_alt_az(alt_azi[0], alt_azi[1], block=block) 958 | 959 | def start_tracking(self): 960 | """Track the target, using closed loop feedback if defined. 961 | 962 | The target will be tracked between the start and end times defined in the target. To stop 963 | manually call System.stop_tracking(). 964 | """ 965 | assert self.mount is not None, 'No mount' 966 | assert self.target.has_target, 'No target set' 967 | assert self.alignment.is_aligned, 'Telescope not aligned' 968 | assert self.alignment.is_located, 'No telescope location' 969 | assert self.is_init, 'System not initialized' 970 | assert not self.is_busy, 'System is busy' 971 | self._logger.info('Starting closed loop tracking') 972 | self.control_loop_thread.start() 973 | 974 | def stop(self): 975 | """Stop all tasks.""" 976 | self._logger.info('Stop command received.') 977 | try: 978 | self.control_loop_thread.stop() 979 | except BaseException: 980 | self._logger.warning('Failed to stop control loop thread.', exc_info=True) 981 | if self._thread is not None and self._thread.is_alive: 982 | self._stop_loop = True 983 | try: 984 | self._thread.join() 985 | except BaseException: 986 | self._logger.warning('Failed to join system worker thread.', exc_info=True) 987 | if self.mount is not None and self.mount.is_init: 988 | try: 989 | self.mount.stop() 990 | except BaseException: 991 | self._logger.warning('Failed to stop mount.', exc_info=True) 992 | 993 | def do_alignment_test(self, max_trials=2, rate_control=True): 994 | """Move to 40 positions spread across the sky and measure the alignment errors. 995 | 996 | Data is saved to CSV file named with the current time and '_System_align_test_hemisp.csv'. 997 | 998 | Args: 999 | max_trials (int, optional): Maximum attempts to take each image and solve the position. 1000 | Default 2. 1001 | rate_control (bool, optional): If True (the default) rate control 1002 | (see pypogs.Mount) is used. 1003 | """ 1004 | # Took 12 min without backlash_comp (no images) 1005 | # Took 21 min with backlash_comp (no images) 1006 | assert self.alignment.is_aligned, 'Not aligned' 1007 | assert self.mount is not None, 'No mount' 1008 | assert self.star_camera is not None, 'No star camera' 1009 | assert self.is_init, 'System not initialized' 1010 | assert not self.is_busy, 'System is busy' 1011 | 1012 | self._logger.info('Starting alignment test, 2x20 positions.') 1013 | pos_LH = [(53, -16), (71, -23), (80, -9), (44, -114), (56, -135), (50, -100), (65, -65), 1014 | (26, -72), (23, -30), (59, -37), (35, -177), (47, -142), (20, -86), (38, -79), 1015 | (41, -51), (77, -2), (74, -170), (29, -44), (62, -156), (68, -163)] 1016 | pos_RH = [(80, 166), (53, 138), (56, 54), (68, 180), (35, 26), (23, 33), (44, 75), 1017 | (38, 152), (65, 19), (50, 159), (32, 82), (26, 96), (41, 110), (29, 89), 1018 | (20, 103), (77, 5), (74, 47), (59, 117), (47, 173), (71, 124)] 1019 | 1020 | test_time = apy_time.now() 1021 | # Create datafile 1022 | data_filename = Path(test_time.strftime('%Y-%m-%dT%H%M%S')+'_System_align_test_hemisp.csv') 1023 | data_file = self.data_folder / data_filename 1024 | if data_file.exists(): 1025 | self._log_debug('File name clash. Iterating...') 1026 | append = 1 1027 | while data_file.exists(): 1028 | data_file = self.data_folder / (data_filename.stem + str(append) 1029 | + data_filename.suffix) 1030 | append += 1 1031 | self._log_debug('Found allowable file: '+str(data_file)) 1032 | with open(data_file, 'w+') as file: 1033 | writer = csv_write(file) 1034 | writer.writerow(['RA', 'DEC', 'ROLL', 'FOV', 'PROB', 'TIME', 'ALT', 'AZI', 'ALT_OBS', 1035 | 'AZI_OBS', 'TRIAL']) 1036 | 1037 | for k in range(2): 1038 | if k == 0: 1039 | self._logger.info('Left half') 1040 | positions = pos_LH 1041 | (alt, azi) = (0, -90) 1042 | altaz = self.alignment.get_com_altaz_from_enu_altaz((alt, azi)) 1043 | self.mount.move_to_alt_az(*altaz, block=True) 1044 | else: 1045 | self._logger.info('Right half') 1046 | positions = pos_RH 1047 | (alt, azi) = (0, 90) 1048 | altaz = self.alignment.get_com_altaz_from_enu_altaz((alt, azi)) 1049 | self.mount.move_to_alt_az(*altaz, block=True) 1050 | for i in range(len(positions)): 1051 | (alt, azi) = positions[i] 1052 | self._logger.info('Getting measurement at Alt: ' + str(alt) + ' Az: ' + str(azi) 1053 | + ' ENU.') 1054 | altaz = self.alignment.get_com_altaz_from_enu_altaz((alt, azi)) 1055 | self.mount.move_to_alt_az(*altaz, rate_control=rate_control, block=True) 1056 | for trial in range(max_trials): 1057 | img = self.star_camera.get_next_image() 1058 | timestamp = apy_time.now() 1059 | # TODO: Test 1060 | fov_estimate = self.star_camera.plate_scale * img.shape[1] / 3600 1061 | solve = self.tetra3.solve_from_image(img, fov_estimate=fov_estimate, fov_max_error=.1) 1062 | self._logger.debug('TIME: ' + timestamp.iso) 1063 | # Save image 1064 | tiff_write(self.data_folder / (test_time.strftime('%Y-%m-%dT%H%M%S') + '_Alt' 1065 | + str(alt) + '_Azi' + str(azi) + '_Try' 1066 | + str(trial + 1) + '.tiff'), img) 1067 | if solve['RA'] is not None: 1068 | # ra,dec,time to ITRF 1069 | c = apy_coord.SkyCoord(solve['RA'], solve['Dec'], obstime=timestamp, 1070 | unit='deg') 1071 | c = c.transform_to(apy_coord.ITRS) 1072 | xyz_observed = [c.x.value, c.y.value, c.z.value] 1073 | (alt_obs, azi_obs) = self.alignment. \ 1074 | get_enu_altaz_from_itrf_xyz(xyz_observed) 1075 | err_alt = (alt_obs - alt) * 3600 1076 | err_azi = (azi_obs - azi) * 3600 1077 | self._logger.info('Observed position degrees Alt: ' 1078 | + str(round(alt_obs, 3)) 1079 | + ' Azi:' + str(round(azi_obs, 3))) 1080 | self._logger.info('Difference measured arcsec Alt: ' 1081 | + str(round(err_alt, 1)) 1082 | + ' Azi:' + str(round(err_azi, 1))) 1083 | else: 1084 | (alt_obs, azi_obs) = [None]*2 1085 | # Save result to logfile 1086 | with open(data_file, 'a') as file: 1087 | writer = csv_write(file) 1088 | data = np.hstack((solve['RA'], solve['Dec'], solve['Roll'], solve['FOV'], 1089 | solve['Prob'], timestamp.iso, alt, azi, alt_obs, azi_obs, 1090 | trial+1)) 1091 | print(data) 1092 | writer.writerow(data) 1093 | if solve['RA'] is not None: 1094 | break 1095 | elif trial + 1 < max_trials: 1096 | print('Failed attempt '+str(trial+1)) 1097 | else: 1098 | print('Failed attempt '+str(trial+1)+', skipping...') 1099 | 1100 | print('Done! Moving home') 1101 | self.mount.move_to_alt_az(0, 0, block=True) 1102 | 1103 | 1104 | class Alignment: 1105 | """Alignment and location of a telescope and coordinate transforms. 1106 | 1107 | Location is the position of the telescope on Earth. It is provided as latitude, longitude, 1108 | height relative to a reference ellipsoid. WGS84 (the GPS ellipsoid) is used in pypogs (and 1109 | almost everywhere else). 1110 | 1111 | Alignment refers to the different coordinate frames in use to get the telescope to point in the 1112 | correct direction. A direction in some coordinate frame can either be represented as a 1113 | cartesian unit vector or as two angles: altitude and azimuth. In the former case they are 1114 | appended by _xyz and in the latter by _altaz. There are four coordinate frames to consider, 1115 | ITRF, ENU, MNT, and COM, see below for descriptions. 1116 | 1117 | The fundamental coordinates used are ITRF_xyz unit vectors. They give direction in an earth 1118 | fixed cartesian frame. This can often be called the ECEF (Earth-Centered Earth-Fixed) or ECR 1119 | (Earth-Centered Rotating) frame. pypogs uses external packages (Astropy and Skyfield) to get 1120 | directions in ITRF_xyz, but the other three are managed here. 1121 | 1122 | If using auto-align, all you really need to know is the ITRF or ENU direction you want 1123 | the telescope to point towards, use this class to get COM_altaz, and send that to the mount. 1124 | 1125 | Note: 1126 | If you have a ITRF position vector (e.g. from centre of earth to a satellite) use 1127 | Alignment.get_itrf_relative_from_position() to get the unit vector direction from the 1128 | telescope location or pass the optional argument *position=True* when converting from 1129 | ITRF_xyz. 1130 | 1131 | If you have a telescope with internal alignment and corrective terms such that the communicated 1132 | values with the mount are in traditional ENU_altaz coordinates, set the location and then call 1133 | Alignment.set_alignment_enu(). This will make MNT coincide with the present ENU frame and 1134 | disable corrections. 1135 | 1136 | To understand this class deeply, one needs to understand the different coordinate systems that 1137 | are used. They are: 1138 | 1139 | 1. ITRF: *International Terrestrial Reference Frame* is the common ECEF (Earth-Centered 1140 | Earth-Fixed) cartesian coordinate frame. This gives a position in (x, y, z) where the 1141 | coordinates rotate along with Earth, so a point on Earth always has the same 1142 | coordinates. (0, 0, 0) is the centre of Earth. This is the "base" coordinate frame 1143 | in this class which everything else is referenced to. 1144 | 2. ENU: *East North Up* is the local tangent plane coordinate frame and depends on the 1145 | location of the telescope. The coordinates may be described in their cartesian 1146 | components (e, n, u) but are more commonly referenced by the two angles ENU altitude 1147 | (degrees above the horizon) and ENU azimuth (degrees rotation east from north). 1148 | ENU_altaz is the traditional astronomical coordinate frame seen e.g. in star charts. To 1149 | transform into this frame the location must be known. 1150 | 3. MNT: *Mount* is the *local* coordinate system of the telescope mount. This is another 1151 | cartesian coordinate where the coordinate axes coincide with the telescope mount axes. 1152 | In particular, MNT c is the azimuth rotation axis of the gimbal, MNT a is the altitude 1153 | rotation axis of the gimbal (when the gibals are at the zero position), and MNT b 1154 | completes the right hand system (pointing along the telescope boresight). The 1155 | coordinates are commonly expressed as MNT altitude (degrees above the plane normal to 1156 | the azimuth rotation axis) and MNT azimuth (degrees rotation around the azimuth axis). 1157 | Traditionally, telescopes are physically aligned such that MNT and ENU coincide, but we 1158 | relax this harsh constraint via software! 1159 | 4. COM: *Commanded* is the angles which must be send to the mount to actually end up at the 1160 | desired MNT_altaz. The nonperpendicularity (Cnp), vertical deflection (Cvd), and 1161 | altitude zero offset (Alt0) of the physical mount must be corrected for to get better 1162 | than about 1000 arcseconds pointing. In an ideal mount COM and MNT coincide. 1163 | 1164 | Args: 1165 | data_folder (pathlib.Path, optional): The folder for data saving. If None (the default) the 1166 | folder *pypogs*/data will be used/created. 1167 | debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) 1168 | the folder *pypogs*/debug will be used/created. 1169 | """ 1170 | def __init__(self, data_folder=None, debug_folder=None): 1171 | """Create Alignment instance. See class documentation.""" 1172 | # Logger setup 1173 | self._debug_folder = None 1174 | if debug_folder is None: 1175 | self.debug_folder = Path(__file__).parent / 'debug' 1176 | else: 1177 | self.debug_folder = debug_folder 1178 | self._logger = logging.getLogger('pypogs.system.Alignment') 1179 | if not self._logger.hasHandlers(): 1180 | # Add new handlers to the logger if there are none 1181 | self._logger.setLevel(logging.DEBUG) 1182 | # Console handler at INFO level 1183 | ch = logging.StreamHandler() 1184 | ch.setLevel(logging.INFO) 1185 | # File handler at DEBUG level 1186 | fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') 1187 | fh.setLevel(logging.DEBUG) 1188 | # Format and add 1189 | formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') 1190 | fh.setFormatter(formatter) 1191 | ch.setFormatter(formatter) 1192 | self._logger.addHandler(fh) 1193 | self._logger.addHandler(ch) 1194 | 1195 | self._logger.debug('Alignment constructor called') 1196 | # Data folder setup 1197 | self._data_folder = None 1198 | if data_folder is None: 1199 | self.data_folder = Path(__file__).parent / 'data' 1200 | else: 1201 | self.data_folder = data_folder 1202 | # Telescope location 1203 | self._telescope_ITRF = None # Tel. location ITRF (xyz) in metres 1204 | self._location = None # Telescope location astropy EarthLocation 1205 | # Transformation matrices 1206 | self._MX_itrf2enu = None # Matrix transforming vectors in ITRF-xyz to ENU-xyz 1207 | self._MX_enu2itrf = None # Inverse of above 1208 | self._MX_itrf2mnt = None # Matrix transforming vectors in ITRF-xyz to MNT-xyz 1209 | self._MX_mnt2itrf = None # Inverse of above 1210 | # Correction factors 1211 | self._Alt0 = 0.0 # Altitude zero offset 1212 | self._Cvd = 0.0 # Vertical deflection coefficient 1213 | self._Cnp = 0.0 # Nonperpendicularity 1214 | 1215 | @property 1216 | def data_folder(self): 1217 | """pathlib.Path: Get or set the path for data saving. Will create folder if not existing. 1218 | """ 1219 | return self._data_folder 1220 | 1221 | @data_folder.setter 1222 | def data_folder(self, path): 1223 | assert isinstance(path, Path), 'Must be pathlib.Path object' 1224 | self._logger.debug('Got set data folder with: '+str(path)) 1225 | if path.is_file(): 1226 | path = path.parent 1227 | if not path.is_dir(): 1228 | path.mkdir(parents=True) 1229 | self._data_folder = path 1230 | self._logger.debug('Set data folder to: '+str(self._data_folder)) 1231 | 1232 | @property 1233 | def debug_folder(self): 1234 | """pathlib.Path: Get or set the path for debug logging. Will create folder if not existing. 1235 | """ 1236 | return self._debug_folder 1237 | 1238 | @debug_folder.setter 1239 | def debug_folder(self, path): 1240 | # Do not do logging in here! This will be called before the logger is set up 1241 | assert isinstance(path, Path), 'Must be pathlib.Path object' 1242 | if path.is_file(): 1243 | path = path.parent 1244 | if not path.is_dir(): 1245 | path.mkdir(parents=True) 1246 | self._debug_folder = path 1247 | 1248 | @property 1249 | def is_aligned(self): 1250 | """bool: Returns True if telescope has location.""" 1251 | return self._MX_itrf2mnt is not None 1252 | 1253 | @property 1254 | def is_located(self): 1255 | """bool: Returns True if telescope has location.""" 1256 | return self._location is not None 1257 | 1258 | def get_location_lat_lon_height(self): 1259 | """tuple of float: Get the location in latitude (deg), longitude (deg), height (m) above 1260 | ellipsoid. 1261 | """ 1262 | assert self.is_located, 'Not located' 1263 | gd = self._location.geodetic 1264 | return (gd.lat.to_value(apy_unit.deg), 1265 | gd.lon.to_value(apy_unit.deg), 1266 | gd.height.to_value(apy_unit.m)) 1267 | 1268 | def get_location_itrf(self): 1269 | """numpy.ndarray: Get the location in ITRF x (m), y (m), z (m) as shape (3,) array.""" 1270 | assert self.is_located, 'Not located' 1271 | gc = self._location.geocentric 1272 | return np.array((gc[0].to_value(apy_unit.m), 1273 | gc[1].to_value(apy_unit.m), 1274 | gc[2].to_value(apy_unit.m))) 1275 | 1276 | def set_location_lat_lon(self, lat, lon, height=0): 1277 | """Set the location via latitude (deg), longitude (deg), height (m, default 0) above 1278 | ellipsoid. 1279 | """ 1280 | self._logger.debug('Got set location with lat=' + str(lat) + ' lon=' + str(lon) 1281 | + ' height=' + str(height)) 1282 | self._location = apy_coord.EarthLocation.from_geodetic(lat=lat, lon=lon, height=height) 1283 | self._logger.debug('Location set to: '+str(self._location)) 1284 | # Set ITRF-ENU transformations for new location 1285 | self._set_mx_enu_itrf() 1286 | 1287 | def set_location_itrf(self, x, y, z): 1288 | """Set the location via ITRF position x (m), y (m), z (m).""" 1289 | self._logger.debug('Got set location with x='+str(x)+' y='+str(y)+' z='+str(z)) 1290 | self._location = apy_coord.EarthLocation.from_geocentric(x, y, z, unit=apy_unit.m) 1291 | self._logger.debug('Location set to: '+str(self._location)) 1292 | # Set ITRF-ENU transformations for new location 1293 | self._set_mx_enu_itrf() 1294 | 1295 | def _set_mx_enu_itrf(self): 1296 | """PRIVATE: Calculate transformation matrices between ENU and ITRF based on our location. 1297 | """ 1298 | self._logger.debug('Got set MX ENU-ITRF') 1299 | # Get lat lon in radians 1300 | (lat, lon) = np.deg2rad(self.get_location_lat_lon_height()[:2]) 1301 | self._logger.debug('At lat='+str(lat)+' lon='+str(lon)+' (rad)') 1302 | # Find basis vectors 1303 | up = np.asarray([np.cos(lat)*np.cos(lon), np.cos(lat)*np.sin(lon), np.sin(lat)]) 1304 | north = np.asarray([-np.sin(lat)*np.cos(lon), -np.sin(lat)*np.sin(lon), np.cos(lat)]) 1305 | east = np.asarray([-np.sin(lon), np.cos(lon), 0]) 1306 | # Calculate matrices 1307 | self._MX_itrf2enu = np.vstack((east, north, up)) 1308 | self._MX_enu2itrf = self._MX_itrf2enu.transpose() 1309 | self._logger.debug('Set MX_enu2itrf to: '+str(self._MX_enu2itrf)) 1310 | self._logger.debug('Set MX_itrf2enu to: '+str(self._MX_itrf2enu)) 1311 | 1312 | def get_itrf_relative_from_position(self, itrf_pos): 1313 | """Get the vector in ITRF pointing from the telescope to the given position. Not 1314 | normalised. 1315 | """ 1316 | assert self.is_located, 'No location' 1317 | itrf_pos = np.asarray(itrf_pos) 1318 | if itrf_pos.size == 3: # Makes size 3 vectors into (3,1) vectors 1319 | itrf_pos = itrf_pos.reshape((3, 1)) 1320 | assert itrf_pos.shape[0] == 3, 'Input must be size 3 or shape (3,N)' 1321 | # Calculate relative vector 1322 | relative = itrf_pos - self.get_location_itrf().reshape((3, 1)) 1323 | return relative.squeeze() 1324 | 1325 | def get_itrf_xyz_from_enu_altaz(self, enu_altaz): 1326 | """Transform the given ENU AltAz coordinate to ITRF xyz. May be numpy array-like. 1327 | 1328 | Args: 1329 | enu_altaz (numpy array-like): Size 2 or shape (2,N). 1330 | 1331 | Returns: 1332 | numpy.ndarray: Shape (3,) if single input, shape (3,N) if array input. 1333 | """ 1334 | assert self.is_located, 'Not located' 1335 | enu_altaz = np.asarray(enu_altaz) 1336 | if enu_altaz.size == 2: 1337 | enu_altaz = enu_altaz.reshape((2, 1)) 1338 | # Degrees to radians 1339 | enu_alt = np.deg2rad(enu_altaz[0, :]) 1340 | enu_azi = np.deg2rad(enu_altaz[1, :]) 1341 | # AltAz to xyz 1342 | enu_xyz = np.vstack((np.cos(enu_alt) * np.sin(enu_azi), 1343 | np.cos(enu_alt) * np.cos(enu_azi), 1344 | np.sin(enu_alt))) 1345 | # ENU to ITRF 1346 | itrf_xyz = self._MX_enu2itrf @ enu_xyz 1347 | return itrf_xyz.squeeze() 1348 | 1349 | def get_enu_altaz_from_itrf_xyz(self, itrf_xyz, position=False): 1350 | """Transform the given ITRF xyz coordinates to ENU AltAz. 1351 | 1352 | Args: 1353 | itrf_xyz (numpy array-like): Size 3 or shape (3,N). 1354 | position(bool, optional): If True, itrf_xyz is treated as the position in ITRF we want 1355 | to observe, and the ENU AltAz required to see it from our location is returned. If 1356 | False (the default), itrf_xyz is treated as a vector with the desired direction to 1357 | point in ITRF. 1358 | 1359 | Returns: 1360 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1361 | """ 1362 | assert self.is_located, 'Not located' 1363 | # Make sure we have (3,N) vectors 1364 | itrf_xyz = np.asarray(itrf_xyz) 1365 | if position: # Get vector pointing in desired direction 1366 | itrf_xyz = self.get_itrf_relative_from_position(itrf_xyz) 1367 | if itrf_xyz.size == 3: # Makes size 3 vectors into (3,1) vectors 1368 | itrf_xyz = itrf_xyz.reshape((3, 1)) 1369 | assert itrf_xyz.shape[0] == 3, 'Input must be size 3 or shape (3,N)' 1370 | # Normalise 1371 | itrf_xyz /= np.linalg.norm(itrf_xyz, axis=0, keepdims=True) 1372 | # ITRF to ENU 1373 | enu_xyz = self._MX_itrf2enu @ itrf_xyz 1374 | # xyz to AltAz 1375 | alt = np.arcsin(enu_xyz[2, :]) 1376 | azi = np.arctan2(enu_xyz[0, :], enu_xyz[1, :]) 1377 | # radians to degrees 1378 | return np.rad2deg(np.vstack((alt, azi))).squeeze() 1379 | 1380 | def get_itrf_xyz_from_mnt_altaz(self, mnt_altaz): 1381 | """Transform the given MNT AltAz coordinate to ITRF xyz. May be numpy array-like. 1382 | 1383 | Args: 1384 | mnt_altaz (numpy array-like): Size 2 or shape (2,N). 1385 | 1386 | Returns: 1387 | numpy.ndarray: Shape (3,) if single input, shape (3,N) if array input. 1388 | """ 1389 | assert self.is_aligned, 'Not aligned' 1390 | mnt_altaz = np.asarray(mnt_altaz) 1391 | if mnt_altaz.size == 2: 1392 | mnt_altaz = mnt_altaz.reshape((2, 1)) 1393 | # Degrees to radians 1394 | mnt_alt = np.deg2rad(mnt_altaz[0, :]) 1395 | mnt_azi = np.deg2rad(mnt_altaz[1, :]) 1396 | # AltAz to xyz 1397 | mnt_xyz = np.asarray([np.cos(mnt_alt) * np.sin(mnt_azi), 1398 | np.cos(mnt_alt) * np.cos(mnt_azi), 1399 | np.sin(mnt_alt)]) 1400 | # ENU to ITRF 1401 | itrf_xyz = self._MX_mnt2itrf @ mnt_xyz 1402 | return itrf_xyz.squeeze() 1403 | 1404 | def get_mnt_altaz_from_itrf_xyz(self, itrf_xyz, position=False): 1405 | """Transform the given ITRF xyz coordinates to MNT AltAz. 1406 | 1407 | Args: 1408 | itrf_xyz (numpy array-like): Size 3 or shape (3,N). 1409 | position(bool, optional): If True, itrf_xyz is treated as the position in ITRF we want 1410 | to observe, and the ENU AltAz required to see it from our location is returned. If 1411 | False (the default), itrf_xyz is treated as a vector with the desired direction to 1412 | point in ITRF. 1413 | 1414 | Returns: 1415 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1416 | """ 1417 | assert self.is_aligned, 'Not aligned' 1418 | # Make sure we have (3,N) vectors 1419 | itrf_xyz = np.asarray(itrf_xyz) 1420 | if position: # Get vector pointing in desired direction 1421 | itrf_xyz = self.get_itrf_relative_from_position(itrf_xyz) 1422 | if itrf_xyz.size == 3: # Makes size 3 vectors into (3,1) vectors 1423 | itrf_xyz = itrf_xyz.reshape((3, 1)) 1424 | assert itrf_xyz.shape[0] == 3, 'Input must be size 3 or shape (3,N)' 1425 | # Normalise 1426 | itrf_xyz /= np.linalg.norm(itrf_xyz, axis=0, keepdims=True) 1427 | # ITRF to MNT 1428 | mnt_xyz = self._MX_itrf2mnt @ itrf_xyz 1429 | # xyz to AltAz 1430 | alt = np.arcsin(mnt_xyz[2, :]) 1431 | azi = np.arctan2(mnt_xyz[0, :], mnt_xyz[1, :]) 1432 | # radians to degrees 1433 | return np.rad2deg(np.vstack((alt, azi))).squeeze() 1434 | 1435 | def get_mnt_altaz_from_com_altaz(self, com_altaz): 1436 | """Transform the given COM AltAz coordinate to MNT AltAz. 1437 | 1438 | Args: 1439 | mnt_altaz (numpy array-like): Size 2 or shape (2,N). 1440 | 1441 | Returns: 1442 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1443 | """ 1444 | com_altaz = np.asarray(com_altaz) 1445 | if com_altaz.size == 2: 1446 | com_altaz = com_altaz.reshape((2, 1)) 1447 | # Undo corrections 1448 | mnt_alt = (com_altaz[0, :] - self._Alt0) / (1 + self._Cvd) 1449 | mnt_azi = com_altaz[1, :] + self._Cnp * np.tan(np.deg2rad(mnt_alt)) 1450 | return np.vstack((mnt_alt, mnt_azi)).squeeze() 1451 | 1452 | def get_com_altaz_from_mnt_altaz(self, mnt_altaz): 1453 | """Transform the given MNT AltAz coordinate to COM AltAz. 1454 | 1455 | Args: 1456 | mnt_altaz (numpy array-like): Size 2 or shape (2,N). 1457 | 1458 | Returns: 1459 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1460 | """ 1461 | mnt_altaz = np.asarray(mnt_altaz) 1462 | if mnt_altaz.size == 2: 1463 | mnt_altaz = mnt_altaz.reshape((2, 1)) 1464 | # Add corrections 1465 | com_azi = mnt_altaz[1, :] - self._Cnp * np.tan(np.deg2rad(np.clip(mnt_altaz[0, :], 1466 | -85, 85))) 1467 | com_alt = (1 + self._Cvd) * mnt_altaz[0, :] + self._Alt0 1468 | return np.vstack((com_alt, com_azi)).squeeze() 1469 | 1470 | # Below here are just longer chainings of transformations 1471 | def get_mnt_altaz_from_enu_altaz(self, enu_altaz): 1472 | """Transform the given ENU AltAz coordinate to MNT AltAz. 1473 | 1474 | Args: 1475 | enu_altaz (numpy array-like): Size 2 or shape (2,N). 1476 | 1477 | Returns: 1478 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1479 | """ 1480 | # ENU>ITRF 1481 | itrf_xyz = self.get_itrf_xyz_from_enu_altaz(enu_altaz) 1482 | # ITRF>MNT 1483 | return self.get_mnt_altaz_from_itrf_xyz(itrf_xyz) 1484 | 1485 | def get_enu_altaz_from_mnt_altaz(self, mnt_altaz): 1486 | """Transform the given MNT AltAz coordinate to ENU AltAz. 1487 | 1488 | Args: 1489 | mnt_altaz (numpy array-like): Size 2 or shape (2,N). 1490 | 1491 | Returns: 1492 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1493 | """ 1494 | # MNT>ITRF 1495 | itrf_xyz = self.get_itrf_xyz_from_mnt_altaz(mnt_altaz) 1496 | # ITRF>ENU 1497 | return self.get_enu_altaz_from_itrf_xyz(itrf_xyz) 1498 | 1499 | def get_com_altaz_from_enu_altaz(self, enu_altaz): 1500 | """Transform the given ENU AltAz coordinate to COM AltAz. 1501 | 1502 | Args: 1503 | enu_altaz (numpy array-like): Size 2 or shape (2,N). 1504 | 1505 | Returns: 1506 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1507 | """ 1508 | # ENU>MNT 1509 | mnt_altaz = self.get_mnt_altaz_from_enu_altaz(enu_altaz) 1510 | # MNT>COM 1511 | return self.get_com_altaz_from_mnt_altaz(mnt_altaz) 1512 | 1513 | def get_enu_altaz_from_com_altaz(self, com_altaz): 1514 | """Transform the given COM AltAz coordinate to ENU AltAz. 1515 | 1516 | Args: 1517 | mnt_altaz (numpy array-like): Size 2 or shape (2,N). 1518 | 1519 | Returns: 1520 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1521 | """ 1522 | # COM>MNT 1523 | mnt_altaz = self.get_mnt_altaz_from_com_altaz(com_altaz) 1524 | # COM>ENU 1525 | return self.get_enu_altaz_from_com_altaz(mnt_altaz) 1526 | 1527 | def get_com_altaz_from_itrf_xyz(self, itrf_xyz, position=False): 1528 | """Transform the given ITRF xyz coordinates to COM AltAz. 1529 | 1530 | Args: 1531 | itrf_xyz (numpy array-like): Size 3 or shape (3,N). 1532 | position(bool, optional): If True, itrf_xyz is treated as the position in ITRF we want 1533 | to observe, and the ENU AltAz required to see it from our location is returned. If 1534 | False (the default), itrf_xyz is treated as a vector with the desired direction to 1535 | point in ITRF. 1536 | 1537 | Returns: 1538 | numpy.ndarray: Shape (2,) if single input, shape (2,N) if array input. 1539 | """ 1540 | mnt_altaz = self.get_mnt_altaz_from_itrf_xyz(itrf_xyz, position=position) 1541 | return self.get_com_altaz_from_mnt_altaz(mnt_altaz) 1542 | 1543 | def set_alignment_from_observations(self, obs_data, alt0=None, Cvd=None, Cnp=None): 1544 | """Use star camera/plate solving observations to set the alignment and mount correction 1545 | terms. 1546 | 1547 | The observation data must be a list containing eight tuples, each from observing these 1548 | specific COM AltAz coordinates in order: (40,-135), (60,-135), (60,-45), (40,-45), (40,45), 1549 | (60,45), (60,135), (40,135). Each tuple in the list must be a 5-tuple with: (Right 1550 | Ascension (deg), Declination (deg), timestamp (astropy Time), COM Alt (deg), COM Az (deg)) 1551 | for the measurement. Set Right Ascension and Declination to None if the measurement failed. 1552 | 1553 | This method also solves for the mount correction terms alt0, Cvd, Cnp. If some of these are 1554 | well known pass the argument to use that value instead of solving for it. You may also pass 1555 | zeros to disable the corrections. 1556 | 1557 | Args: 1558 | obs_data (list of tuple): See specifics above. The correct sequence is generated from 1559 | System.do_auto_star_alignment(). 1560 | alt0 (float, optional): Altitude offset (deg). If None (default) it will be solved for. 1561 | Cvd (float, optional): Vertical deflecion coefficient. If None (default) it will be 1562 | solved for. 1563 | Cnp (float, optional): Axes nonperpendicularity (deg). If None (default) it will be 1564 | solved for. 1565 | """ 1566 | self._logger.debug('Got set alignment from obs with: obs_data=' + str(obs_data) + ' alt0=' 1567 | + str(alt0) + ' Cvd=' + str(Cvd) + ' Cnp=' + str(Cvd)) 1568 | assert len(obs_data) == 8, 'Must have 8 observations' 1569 | assert all(isinstance(obs, tuple) and len(obs) == 5 for obs in obs_data), \ 1570 | 'Observation must be tuple with [RA,DEC,TIME,ALT,AZ]' 1571 | assert all(isinstance(obs[2], apy_time) for obs in obs_data), \ 1572 | 'Timestamp must be astropy object' 1573 | self._logger.info('Calculating alignment and corrections from observations.') 1574 | # Check which have data 1575 | valid = np.fromiter((k[0] is not None for k in obs_data), bool) 1576 | self._logger.debug('Valid: ' + str(valid)) 1577 | # Convert measurements to ITRF: 1578 | obs_in_itrf = np.zeros((3, 8)) 1579 | for obs in range(len(valid)): 1580 | if valid[obs]: 1581 | # Convert Ra,Dec,Time to ECEF: 1582 | c = apy_coord.SkyCoord(obs_data[obs][0], obs_data[obs][1], 1583 | obstime=obs_data[obs][2], unit='deg', frame='icrs') 1584 | c = c.transform_to(apy_coord.ITRS) 1585 | obs_in_itrf[:, obs] = [c.x.value, c.y.value, c.z.value] 1586 | else: 1587 | self._logger.debug('Skipping observation ' + str(obs)) 1588 | self._logger.debug('In ITRF: ' + str(obs_in_itrf)) 1589 | # Pairs with same alt and opposite azi 1590 | opposing_pairs = ([0, 4], [1, 5], [3, 7], [2, 6]) 1591 | # Pairs with same azi different alt 1592 | azi_pairs = ([0, 1], [2, 3], [4, 5], [6, 7]) 1593 | 1594 | # 1) Estimate azimuth axis 1595 | Mz_obs = np.zeros((3, len(opposing_pairs))) 1596 | n = 0 1597 | for pos in opposing_pairs: 1598 | if np.all(valid[pos]): 1599 | Mz_obs[:, n] = np.sum(obs_in_itrf[:, pos], axis=1) 1600 | Mz_obs[:, n] = Mz_obs[:, n] / np.linalg.norm(Mz_obs[:, n]) 1601 | n += 1 1602 | assert n > 1, 'Less than two valid opposing pairs' 1603 | Mz_obs = Mz_obs[:, :n] # Trim the end! 1604 | self._logger.debug('c-axis estimates: ' + str(Mz_obs)) 1605 | # Average 1606 | Mz = np.sum(Mz_obs, axis=1) 1607 | Mz = Mz / np.linalg.norm(Mz) 1608 | # Find residuals 1609 | Mz_sstd = np.sqrt(np.sum(np.rad2deg(np.arccos(np.dot(Mz, Mz_obs)))**2) / (n - 1)) 1610 | self._logger.info('Solved MNT c axis: ' + str(np.round(Mz, 3).squeeze()) + ' | RMS: ' 1611 | + str(round(Mz_sstd * 3600, 1)) + ' asec (n=' + str(n) + ').') 1612 | # 2) Calculate true alt and vector projected into plane perp to c for observations 1613 | alt_meas = np.zeros((len(valid),)) 1614 | V_perp = np.zeros((3, len(valid))) 1615 | for obs in range(len(valid)): 1616 | if valid[obs]: 1617 | alt_meas[obs] = 90 - np.rad2deg(np.arccos(np.dot(Mz, obs_in_itrf[:, obs]))) 1618 | V_perp[:, obs] = obs_in_itrf[:, obs] - np.dot(obs_in_itrf[:, obs], Mz) * Mz 1619 | V_perp[:, obs] = V_perp[:, obs] / np.linalg.norm(V_perp[:, obs]) 1620 | self._logger.debug('Measured MNT Alt: ' + str(alt_meas)) 1621 | self._logger.debug('Perpendicular vector: ' + str(V_perp)) 1622 | # 3) Calculate vertical deflection 1623 | if Cvd is None: 1624 | Cvd_obs = np.zeros((len(azi_pairs),)) 1625 | n = 0 1626 | for pos in azi_pairs: 1627 | if np.all(valid[pos]): 1628 | Cvd_obs[n] = (obs_data[pos[0]][3] - obs_data[pos[1]][3]) \ 1629 | / (alt_meas[pos[0]] - alt_meas[pos[1]]) - 1 1630 | n += 1 1631 | assert n > 1, 'Less than two valid stacked pairs' 1632 | Cvd_obs = Cvd_obs[:n] # Trim the end! 1633 | self._logger.debug('Cvd estimates: ' + str(Cvd_obs)) 1634 | # Average and find residuals 1635 | Cvd = np.mean(Cvd_obs) 1636 | Cvd_sstd = np.sqrt(np.sum((Cvd_obs - Cvd)**2) / (n - 1)) 1637 | self._logger.info('Solved Vert. Defl.: ' + str(round(Cvd * 100, 3)) + '% | RMS: ' 1638 | + str(round(Cvd_sstd * 100, 3)) + '% (n=' + str(n) + ').') 1639 | else: 1640 | self._logger.info('Given Vert. Defl.: ' + str(np.round(Cvd * 100, 3)) + '%.') 1641 | Cvd_sstd = -1 1642 | # 4) Find alt offset 1643 | if alt0 is None: 1644 | alt0_obs = np.zeros((len(valid),)) 1645 | n = 0 1646 | for obs in range(len(valid)): 1647 | if valid[obs]: 1648 | alt0_obs[n] = obs_data[obs][3] - (1 + Cvd)*alt_meas[obs] 1649 | n += 1 1650 | alt0_obs = alt0_obs[:n] # Trim the end! 1651 | self._logger.debug('Alt0 estimates: ' + str(alt0_obs)) 1652 | # Average and find residuals 1653 | alt0 = np.mean(alt0_obs) 1654 | alt0_sstd = np.sqrt(np.sum((alt0_obs - alt0)**2) / (n - 1)) 1655 | self._logger.info('Solved Alt. Zero: ' + str(round(alt0, 4)) + DEG + ' | RMS: ' 1656 | + str(round(alt0_sstd, 4)) + DEG + ' (n=' + str(n) + ').') 1657 | else: 1658 | self._logger.info('Given Alt. Zero: ' + str(round(alt0, 4)) + DEG + '.') 1659 | alt0_sstd = -1 1660 | # 5) Find nonperpendicularity 1661 | if Cnp is None: 1662 | Cnp_obs = np.zeros((len(azi_pairs),)) 1663 | n = 0 1664 | for pos in azi_pairs: 1665 | if np.all(valid[pos]): 1666 | d_Azi = np.rad2deg(np.arcsin(np.dot(Mz, np.cross(V_perp[:, pos[0]], 1667 | V_perp[:, pos[1]])))) 1668 | Cnp_obs[n] = d_Azi / (np.tan(np.deg2rad(alt_meas[pos[0]])) 1669 | - np.tan(np.deg2rad(alt_meas[pos[1]]))) 1670 | n += 1 1671 | Cnp_obs = Cnp_obs[:n] # Trim the end! 1672 | self._logger.debug('Cnp estimates: ' + str(Cnp_obs)) 1673 | # Average and find residuals 1674 | Cnp = np.mean(Cnp_obs) 1675 | Cnp_sstd = np.sqrt(np.sum((Cnp_obs - Cnp)**2) / (n - 1)) 1676 | self._logger.info('Solved Nonperp.: ' + str(round(Cnp, 4)) + DEG + ' | RMS: ' 1677 | + str(round(Cnp_sstd, 4)) + DEG + ' (n=' + str(n) + ').') 1678 | else: 1679 | self._logger.info('Given Nonperp.: ' + str(round(Cnp, 4)) + DEG + '.') 1680 | Cnp_sstd = -1 1681 | # 6) Backcalculate azi correction 1682 | azi_backcalc = np.zeros((len(valid),)) 1683 | for obs in range(len(valid)): 1684 | azi_backcalc[obs] = obs_data[obs][4] + Cnp * np.tan(np.deg2rad(alt_meas[obs])) 1685 | self._logger.debug('Backcalculated MNT Azi: ' + str(azi_backcalc)) 1686 | # 7) Rotate around azi axis to find y 1687 | My_obs = np.zeros((3, len(valid))) 1688 | n = 0 1689 | for obs in range(len(valid)): 1690 | if valid[obs]: 1691 | rad = np.deg2rad(azi_backcalc[obs]) 1692 | My_obs[:, n] = (V_perp[:, obs] * np.cos(rad) 1693 | + (np.cross(Mz, V_perp[:, obs])) * np.sin(rad)) 1694 | n += 1 1695 | My_obs = My_obs[:, :n] # Trim the end! 1696 | self._logger.debug('b-axis estimates: ' + str(My_obs)) 1697 | # Average 1698 | My = np.sum(My_obs, axis=1) 1699 | My = My / np.linalg.norm(My) 1700 | # Find residuals 1701 | My_sstd = np.sqrt(np.sum(np.rad2deg(np.arccos(np.dot(My, My_obs)))**2) / (n - 1)) 1702 | self._logger.info('Solved MNT b axis: ' + str(np.round(My, 3).squeeze()) + ' | RMS: ' 1703 | + str(round(My_sstd * 3600, 1)) + ' asec (n=' + str(n) + ').') 1704 | # 8) Cross product for last axis 1705 | Mx = np.cross(My, Mz) 1706 | self._logger.info('MNT b cross c gives a: ' + str(np.round(Mx, 3).squeeze()) + '.') 1707 | # Set the values 1708 | self._MX_itrf2mnt = np.vstack((Mx, My, Mz)) 1709 | self._MX_mnt2itrf = self._MX_itrf2mnt.transpose() 1710 | self._Alt0 = alt0 1711 | self._Cvd = Cvd 1712 | self._Cnp = Cnp 1713 | 1714 | # Create logfile 1715 | log_path = self.data_folder / (apy_time.now().strftime('%Y-%m-%dT%H%M%S') 1716 | + '_Alignment_from_obs.csv') 1717 | with open(log_path, 'w') as logfile: 1718 | logwriter = csv_write(logfile) 1719 | logwriter.writerow(['Ma', 'Mb', 'Mc', 'Alt0', 'Cvd', 'Cnp', 'Mz_std', 'My_std', 1720 | 'Alt0_std', 'Cvd_std', 'Cnp_std']) 1721 | logwriter.writerow([Mx, My, Mz, alt0, Cvd, Cnp, Mz_sstd, My_sstd, 1722 | alt0_sstd, Cvd_sstd, Cnp_sstd]) 1723 | 1724 | # Find residuals! 1725 | compare = self.get_com_altaz_from_itrf_xyz(obs_in_itrf[:, valid]) 1726 | n = 0 1727 | for i in range(len(valid)): 1728 | if valid[i]: 1729 | d_alt = compare[0, n] - obs_data[i][3] 1730 | d_azi = compare[1, n] - obs_data[i][4] 1731 | self._logger.info('Residual for measurement ' + str(i + 1) 1732 | + ': Alt: ' + str(np.round(d_alt * 3600, 1)) 1733 | + ' Azi: ' + str(np.round(d_azi * 3600, 1)) + ' (asec).') 1734 | n += 1 1735 | else: 1736 | self._logger.info('Residual for measurement ' + str(i + 1) 1737 | + ': Alt: ---- Azi: ---- .') 1738 | self._logger.info('Aligned.') 1739 | 1740 | def set_alignment_enu(self): 1741 | """Set the MNT frame equal to the current ENU frame and zero all correction terms. 1742 | 1743 | Note: 1744 | If the location is changed after set_alignment_enu() the MNT frame will not be updated 1745 | automatically to coincide with the new ENU. 1746 | """ 1747 | assert self.is_located, 'No location' 1748 | self._MX_itrf2mnt = self._MX_itrf2enu 1749 | self._MX_mnt2itrf = self._MX_enu2itrf 1750 | self._Alt0 = 0 1751 | self._Cvd = 0 1752 | self._Cnp = 0 1753 | 1754 | 1755 | class Target: 1756 | """Target to track and start and end times. 1757 | 1758 | Two types of targets are supported, Astropy *SkyCoord* (any deep space object) and Skyfield 1759 | *EarthSatellite* (an Earth orbiting satellite). 1760 | 1761 | The target can be set by the property Target.target_object to one of the supported types. It 1762 | can also be created from the right ascension and declination (for deep sky) or the Two Line 1763 | Element (TLE) for a satellite. See Target.set_target_from_ra_dec() and 1764 | Target.set_target_from_tle() respectively. 1765 | 1766 | You may also give a start and end time (e.g. useful for satellite rise and set times) when 1767 | creating the target or by the method Target.set_start_end_time(). 1768 | 1769 | With a target set, get the ITRF_xyz coordinates at your preferred times with 1770 | Target.get_target_itrf_xyz(). 1771 | 1772 | Note: 1773 | - If the target is a SkyCoord, the ITRF coordinates will be a unit vector in the direction 1774 | of the target. 1775 | - If the target is an EarthSatellite, the ITRF coordinates will be an absolute position (in 1776 | metres) from the centre of Earth to the satellite. 1777 | """ 1778 | 1779 | _allowed_types = (apy_coord.SkyCoord, sgp4.EarthSatellite) 1780 | 1781 | def __init__(self): 1782 | """Create Alignment instance. See class documentation.""" 1783 | self._target = None 1784 | self._rise_time = None 1785 | self._set_time = None 1786 | 1787 | self._tle_line1 = None 1788 | self._tle_line2 = None 1789 | self._skyfield_ts = sf_api.Loader(_system_data_dir, expire=False).timescale() 1790 | 1791 | @property 1792 | def has_target(self): 1793 | """bool: Returns True if a target is set.""" 1794 | return self._target is not None 1795 | 1796 | @property 1797 | def target_object(self): 1798 | """Astropy *SkyCoord* or Skyfield *EarthSatellite* or None: Get or set the target object. 1799 | """ 1800 | return self._target 1801 | 1802 | @target_object.setter 1803 | def target_object(self, target): 1804 | if target is None: 1805 | self._target = None 1806 | else: 1807 | assert isinstance(target, self._allowed_types), \ 1808 | 'Must be None or of type ' + str(self._allowed_types) 1809 | self._target = target 1810 | 1811 | def set_target_from_ra_dec(self, ra, dec, start_time=None, end_time=None): 1812 | """Create an Astropy *SkyCoord* and set as the target. 1813 | 1814 | Args: 1815 | ra (float): Right ascension in decimal degrees. 1816 | dec (float): Declination in decimal degrees. 1817 | start_time (astropy *Time*, optional): The start time to set. 1818 | end_time (astropy *Time*, optional): The end time to set. 1819 | """ 1820 | self.target_object = apy_coord.SkyCoord(ra, dec, unit='deg') 1821 | self.set_start_end_time(start_time, end_time) 1822 | 1823 | def set_target_deep_by_name(self, name, start_time=None, end_time=None): 1824 | """Use Astropy name lookup for setting a SkyCoord deep sky target. 1825 | 1826 | Args: 1827 | name (str): Name to search for. 1828 | start_time (astropy *Time*, optional): The start time to set. 1829 | end_time (astropy *Time*, optional): The end time to set. 1830 | """ 1831 | self.target_object = apy_coord.SkyCoord.from_name(name) 1832 | self.set_start_end_time(start_time, end_time) 1833 | 1834 | def set_target_from_tle(self, tle, start_time=None, end_time=None): 1835 | """Create a Skyfield *EarthSatellite* and set as the target. 1836 | 1837 | Args: 1838 | tle (tuple or str): 2-tuple with the two TLE rows or a string with newline character 1839 | between lines. 1840 | start_time (astropy *Time*, optional): The start time to set. 1841 | end_time (astropy *Time*, optional): The end time to set. 1842 | """ 1843 | if isinstance(tle, str): 1844 | tle = tle.splitlines() 1845 | tle = tuple(tle) 1846 | self.target_object = sgp4.EarthSatellite(tle[0], tle[1]) 1847 | self._tle_line1 = tle[0].strip() 1848 | self._tle_line2 = tle[1].strip() 1849 | self.set_start_end_time(start_time, end_time) 1850 | 1851 | @property 1852 | def start_time(self): 1853 | """astropy *Time* or None: Get or set the tracking start time.""" 1854 | return self._rise_time 1855 | 1856 | @start_time.setter 1857 | def start_time(self, time): 1858 | if time is None: 1859 | self._rise_time = None 1860 | elif isinstance(time, apy_time): 1861 | self._rise_time = time 1862 | else: 1863 | self._rise_time = apy_time(time) 1864 | 1865 | @property 1866 | def end_time(self): 1867 | """astropy *Time* or None: Get or set the tracking end time.""" 1868 | return self._set_time 1869 | 1870 | @end_time.setter 1871 | def end_time(self, time): 1872 | if time is None: 1873 | self._set_time = None 1874 | elif isinstance(time, apy_time): 1875 | self._set_time = time 1876 | else: 1877 | self._set_time = apy_time(time) 1878 | 1879 | def set_start_end_time(self, start_time=None, end_time=None): 1880 | """Set the start and end times. 1881 | 1882 | Args: 1883 | start_time (astropy *Time*, optional): The start time to set. 1884 | end_time (astropy *Time*, optional): The end time to set. 1885 | """ 1886 | self.start_time = start_time 1887 | self.end_time = end_time 1888 | 1889 | def clear_start_end_time(self): 1890 | """Set both start and end time to None.""" 1891 | self.start_time = None 1892 | self.end_time = None 1893 | 1894 | def get_tle_raw(self): 1895 | """Get the TLE of the target. 1896 | 1897 | Returns: 1898 | tuple of str 1899 | 1900 | Raises: 1901 | AssertionError: If the current target is not a Skyfield *EarthSatellite*. 1902 | """ 1903 | assert isinstance(self._target, sgp4.EarthSatellite), \ 1904 | 'Current target is not a Skyfield EarthSatellite.' 1905 | return (self._tle_line1, self._tle_line2) 1906 | 1907 | def get_short_string(self): 1908 | """Get a short descriptive string of the target. 1909 | 1910 | Returns: 1911 | str 1912 | """ 1913 | if self._target is None: 1914 | return 'No target' 1915 | elif isinstance(self._target, sgp4.EarthSatellite): 1916 | return 'TLE #' + str(self._target.model.satnum) 1917 | elif isinstance(self._target, apy_coord.SkyCoord): 1918 | return 'RA:' + str(round(self._target.ra.to_value('deg'), 2)) + DEG \ 1919 | + ' D:' + str(round(self._target.dec.to_value('deg'), 2)) + DEG 1920 | 1921 | def get_target_itrf_xyz(self, times=None): 1922 | """Get the ITRF_xyz position vector from the centre of Earth to the EarthSatellite (in 1923 | metres) or the ITRF_xyz unit vector to the SkyCoord. 1924 | 1925 | Args: 1926 | times (Astropy *Time*): Single or array Time for when to calculate the vector. 1927 | 1928 | Returns: 1929 | numpy.ndarray: Shape (3,) if single input, shape (3,N) if array input. 1930 | """ 1931 | assert self.has_target, 'No target set.' 1932 | if times is None: 1933 | times = apy_time.now() 1934 | if isinstance(self._target, apy_coord.SkyCoord): 1935 | itrs = self._target.transform_to(apy_coord.ITRS(obstime=times)) 1936 | return np.array(itrs.data.xyz) 1937 | elif isinstance(self._target, sgp4.EarthSatellite): 1938 | ts_time = self._skyfield_ts.from_astropy(times) 1939 | itrf_xyz = (self._target.ITRF_position_velocity_error(ts_time)[0] 1940 | * apy_unit.au.in_units(apy_unit.m)) 1941 | return itrf_xyz 1942 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astropy==4.3.post1 2 | numpy==1.21.1 3 | pillow==8.3.1 4 | pyserial==3.5 5 | scipy==1.7.1 6 | skyfield==1.39 7 | tifffile==2021.7.30 8 | --------------------------------------------------------------------------------