celestsp 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- celestsp-0.2.0/.gitignore +10 -0
- celestsp-0.2.0/LICENSE +21 -0
- celestsp-0.2.0/PKG-INFO +193 -0
- celestsp-0.2.0/README.md +155 -0
- celestsp-0.2.0/images/results_20250216_223009.png +0 -0
- celestsp-0.2.0/noxfile.py +36 -0
- celestsp-0.2.0/pyproject.toml +46 -0
- celestsp-0.2.0/sources/m_0322_1830.txt +65 -0
- celestsp-0.2.0/sources/m_0323_0300.txt +40 -0
- celestsp-0.2.0/sources/m_0323_0500.txt +5 -0
- celestsp-0.2.0/src/celestsp/__init__.py +1 -0
- celestsp-0.2.0/src/celestsp/main.py +356 -0
- celestsp-0.2.0/test.py +365 -0
celestsp-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Rio Fujita
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
celestsp-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: celestsp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Celestial TSP is a Python script that calculates the optimal order of celestial bodies for observation based on their coordinates.
|
|
5
|
+
Project-URL: Homepage, https://github.com/rioriost/homebrew-celestsp
|
|
6
|
+
Project-URL: Issues, https://github.com/rioriost/homebrew-celestsp/issues
|
|
7
|
+
Author-email: Rio Fujita <rifujita@microsoft.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Rio Fujita
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Python: >=3.13
|
|
31
|
+
Requires-Dist: astropy>=6.1.7
|
|
32
|
+
Requires-Dist: matplotlib>=3.10.0
|
|
33
|
+
Requires-Dist: networkx>=3.4.2
|
|
34
|
+
Requires-Dist: pandas>=2.2.3
|
|
35
|
+
Requires-Dist: requests>=2.32.3
|
|
36
|
+
Requires-Dist: scipy>=1.14.1
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# Celestial TSP
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
## Overview
|
|
44
|
+
|
|
45
|
+
Celestial TSP is a Python script that calculates the optimal order of celestial bodies for observation based on their coordinates.
|
|
46
|
+
The script uses the Traveling Salesman Problem (TSP) algorithm to find the shortest path between celestial bodies and generates a spherical image showing the optimal order.
|
|
47
|
+
|
|
48
|
+
## Table of Contents
|
|
49
|
+
|
|
50
|
+
- [Overview](#overview)
|
|
51
|
+
- [Table of Contents](#table-of-contents)
|
|
52
|
+
- [Installation](#installation)
|
|
53
|
+
- [Usage](#usage)
|
|
54
|
+
- [Results](#results)
|
|
55
|
+
- [License](#license)
|
|
56
|
+
- [Contact](#contact)
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
brew tap rioriost/celestsp
|
|
62
|
+
brew install celestsp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
celestsp --help
|
|
69
|
+
usage: celestsp [-h] [--lat LAT] [--lon LON] [--height HEIGHT] [--date DATE] [--time TIME] [--tz TZ] [--output OUTPUT] [--first_body FIRST_BODY] input_file_path
|
|
70
|
+
|
|
71
|
+
Celestial TSP Planner
|
|
72
|
+
|
|
73
|
+
positional arguments:
|
|
74
|
+
input_file_path Input file path with celestial coordinates.
|
|
75
|
+
|
|
76
|
+
options:
|
|
77
|
+
-h, --help show this help message and exit
|
|
78
|
+
--lat LAT Latitude of observation location.
|
|
79
|
+
--lon LON Longitude of observation location.
|
|
80
|
+
--height HEIGHT Height of observation location (in meters).
|
|
81
|
+
--date DATE Observation date (YYYY-MM-DD).
|
|
82
|
+
--time TIME Observation time (HH:MM:SS).
|
|
83
|
+
--tz TZ Time zone offset (e.g., +9 for JST).
|
|
84
|
+
--output OUTPUT Filename for the output image.
|
|
85
|
+
--first_body FIRST_BODY
|
|
86
|
+
Name of the celestial body to start the TSP from.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Provide the input file containing celestial coordinates and specify the observation location and time.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
celestsp --lat 34.863 --lon 138.843 --height 1000 --date 2025-03-22 --time 18:30:00 --tz +9 sources/m_0322_1830.txt
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cat sources/m_0322_1830.txt
|
|
97
|
+
M74
|
|
98
|
+
M33
|
|
99
|
+
M32
|
|
100
|
+
M31
|
|
101
|
+
M110
|
|
102
|
+
M76
|
|
103
|
+
M103
|
|
104
|
+
......
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Results
|
|
108
|
+
```
|
|
109
|
+
Location: Lat: 34.863, Lon: 138.843, 1000.0m
|
|
110
|
+
Observation Date/Time: 2025-03-22 18:30:00 +9
|
|
111
|
+
|
|
112
|
+
Optimal Order of Celestial Bodies:
|
|
113
|
+
Name: M39, RA: 322.89, Dec:48.25, Altitude:1.12, Azimuth:333.80, Times to set:0.22 Observable: True
|
|
114
|
+
Name: M52, RA: 351.19, Dec:61.59, Altitude:21.74, Azimuth:331.37, Times to set:inf Observable: True
|
|
115
|
+
Name: M103, RA: 23.34, Dec:60.66, Altitude:35.73, Azimuth:324.02, Times to set:inf Observable: True
|
|
116
|
+
Name: M76, RA: 25.58, Dec:51.58, Altitude:35.16, Azimuth:312.79, Times to set:5.09 Observable: True
|
|
117
|
+
Name: M110, RA: 10.09, Dec:41.69, Altitude:21.71, Azimuth:306.68, Times to set:2.50 Observable: True
|
|
118
|
+
Name: M31, RA: 10.68, Dec:41.27, Altitude:21.91, Azimuth:306.06, Times to set:2.50 Observable: True
|
|
119
|
+
Name: M32, RA: 10.67, Dec:40.87, Altitude:21.71, Azimuth:305.68, Times to set:2.45 Observable: True
|
|
120
|
+
Name: M33, RA: 23.46, Dec:30.66, Altitude:26.30, Azimuth:290.58, Times to set:2.45 Observable: True
|
|
121
|
+
Name: M74, RA: 24.17, Dec:15.78, Altitude:19.54, Azimuth:276.16, Times to set:1.63 Observable: True
|
|
122
|
+
Name: M77, RA: 40.67, Dec:-0.01, Altitude:23.89, Azimuth:252.15, Times to set:1.97 Observable: True
|
|
123
|
+
Name: M45, RA: 56.60, Dec:24.11, Altitude:50.17, Azimuth:266.82, Times to set:4.25 Observable: True
|
|
124
|
+
Name: M36, RA: 84.08, Dec:34.13, Altitude:76.00, Azimuth:271.95, Times to set:6.75 Observable: True
|
|
125
|
+
Name: M38, RA: 82.17, Dec:35.82, Altitude:74.58, Azimuth:279.12, Times to set:6.75 Observable: True
|
|
126
|
+
Name: M37, RA: 88.07, Dec:32.55, Altitude:78.95, Azimuth:261.61, Times to set:6.89 Observable: True
|
|
127
|
+
Name: M1, RA: 83.63, Dec:22.02, Altitude:70.04, Azimuth:234.63, Times to set:5.93 Observable: True
|
|
128
|
+
Name: M35, RA: 92.27, Dec:24.34, Altitude:76.98, Azimuth:218.37, Times to set:6.63 Observable: True
|
|
129
|
+
Name: M78, RA: 86.69, Dec:0.08, Altitude:52.69, Azimuth:204.35, Times to set:5.05 Observable: True
|
|
130
|
+
Name: M43, RA: 83.88, Dec:-5.27, Altitude:46.70, Azimuth:205.57, Times to set:4.61 Observable: True
|
|
131
|
+
Name: M42, RA: 83.82, Dec:-5.39, Altitude:46.57, Azimuth:205.59, Times to set:4.59 Observable: True
|
|
132
|
+
Name: M79, RA: 81.04, Dec:-24.52,Altitude:27.63, Azimuth:200.75, Times to set:3.41 Observable: True
|
|
133
|
+
Name: M41, RA: 101.50, Dec:-20.72,Altitude:34.39, Azimuth:179.68, Times to set:5.00 Observable: True
|
|
134
|
+
Name: M50, RA: 105.68, Dec:-8.37, Altitude:46.52, Azimuth:173.52, Times to set:5.91 Observable: True
|
|
135
|
+
Name: M47, RA: 114.15, Dec:-14.49,Altitude:39.08, Azimuth:163.77, Times to set:6.17 Observable: True
|
|
136
|
+
Name: M46, RA: 115.44, Dec:-14.84,Altitude:38.42, Azimuth:162.32, Times to set:6.22 Observable: True
|
|
137
|
+
Name: M93, RA: 116.14, Dec:-23.85,Altitude:29.54, Azimuth:164.30, Times to set:5.79 Observable: True
|
|
138
|
+
Name: M48, RA: 123.41, Dec:-5.73, Altitude:44.25, Azimuth:148.28, Times to set:7.21 Observable: True
|
|
139
|
+
Name: M67, RA: 132.85, Dec:11.81, Altitude:53.09, Azimuth:121.02, Times to set:8.65 Observable: True
|
|
140
|
+
Name: M44, RA: 130.05, Dec:19.62, Altitude:60.21, Azimuth:113.38, Times to set:8.86 Observable: True
|
|
141
|
+
Name: M95, RA: 160.99, Dec:11.70, Altitude:31.23, Azimuth:97.85, Times to set:10.52 Observable: True
|
|
142
|
+
Name: M96, RA: 161.69, Dec:11.82, Altitude:30.72, Azimuth:97.29, Times to set:10.57 Observable: True
|
|
143
|
+
Name: M105, RA: 161.96, Dec:12.58, Altitude:30.93, Azimuth:96.38, Times to set:10.62 Observable: True
|
|
144
|
+
Name: M65, RA: 169.73, Dec:13.09, Altitude:24.84, Azimuth:91.23, Times to set:11.17 Observable: True
|
|
145
|
+
Name: M66, RA: 170.06, Dec:12.99, Altitude:24.52, Azimuth:91.13, Times to set:11.20 Observable: True
|
|
146
|
+
Name: M98, RA: 183.45, Dec:14.90, Altitude:14.60, Azimuth:81.99, Times to set:12.18 Observable: True
|
|
147
|
+
Name: M99, RA: 184.71, Dec:14.42, Altitude:13.31, Azimuth:81.72, Times to set:12.23 Observable: True
|
|
148
|
+
Name: M100, RA: 185.73, Dec:15.82, Altitude:13.25, Azimuth:79.96, Times to set:12.37 Observable: True
|
|
149
|
+
Name: M85, RA: 186.35, Dec:18.19, Altitude:14.04, Azimuth:77.58, Times to set:12.54 Observable: True
|
|
150
|
+
Name: M88, RA: 188.00, Dec:14.42, Altitude:10.65, Azimuth:79.93, Times to set:12.44 Observable: True
|
|
151
|
+
Name: M91, RA: 188.86, Dec:14.50, Altitude:10.00, Azimuth:79.40, Times to set:12.52 Observable: True
|
|
152
|
+
Name: M90, RA: 189.21, Dec:13.16, Altitude:8.98, Azimuth:80.33, Times to set:12.47 Observable: True
|
|
153
|
+
Name: M89, RA: 188.92, Dec:12.56, Altitude:8.87, Azimuth:81.00, Times to set:12.42 Observable: True
|
|
154
|
+
Name: M58, RA: 189.43, Dec:11.82, Altitude:8.04, Azimuth:81.33, Times to set:12.42 Observable: True
|
|
155
|
+
Name: M59, RA: 190.51, Dec:11.65, Altitude:7.07, Azimuth:80.88, Times to set:12.47 Observable: True
|
|
156
|
+
Name: M60, RA: 190.92, Dec:11.55, Altitude:6.69, Azimuth:80.73, Times to set:12.49 Observable: True
|
|
157
|
+
Name: M87, RA: 187.71, Dec:12.39, Altitude:9.76, Azimuth:81.81, Times to set:12.32 Observable: True
|
|
158
|
+
Name: M86, RA: 186.55, Dec:12.95, Altitude:11.01, Azimuth:81.97, Times to set:12.28 Observable: True
|
|
159
|
+
Name: M84, RA: 186.27, Dec:12.89, Altitude:11.21, Azimuth:82.18, Times to set:12.25 Observable: True
|
|
160
|
+
Name: M49, RA: 187.44, Dec:8.00, Altitude:7.52, Azimuth:85.63, Times to set:12.11 Observable: True
|
|
161
|
+
Name: M61, RA: 185.48, Dec:4.47, Altitude:7.13, Azimuth:89.68, Times to set:11.80 Observable: True
|
|
162
|
+
Name: M53, RA: 198.23, Dec:18.17, Altitude:4.65, Azimuth:71.23, Times to set:13.31 Observable: True
|
|
163
|
+
Name: M64, RA: 194.18, Dec:21.68, Altitude:9.77, Azimuth:70.48, Times to set:13.24 Observable: True
|
|
164
|
+
Name: M3, RA: 205.55, Dec:28.38, Altitude:5.24, Azimuth:58.98, Times to set:14.39 Observable: True
|
|
165
|
+
Name: M63, RA: 198.96, Dec:42.03, Altitude:17.41, Azimuth:50.62, Times to set:15.06 Observable: True
|
|
166
|
+
Name: M94, RA: 192.72, Dec:41.12, Altitude:20.99, Azimuth:53.93, Times to set:14.56 Observable: True
|
|
167
|
+
Name: M106, RA: 184.74, Dec:47.30, Altitude:28.79, Azimuth:50.44, Times to set:14.80 Observable: True
|
|
168
|
+
Name: M109, RA: 179.40, Dec:53.37, Altitude:33.91, Azimuth:44.92, Times to set:15.78 Observable: True
|
|
169
|
+
Name: M97, RA: 168.70, Dec:55.02, Altitude:40.35, Azimuth:44.26, Times to set:15.95 Observable: True
|
|
170
|
+
Name: M108, RA: 167.88, Dec:55.67, Altitude:40.87, Azimuth:43.45, Times to set:inf Observable: True
|
|
171
|
+
Name: M40, RA: 185.55, Dec:58.08, Altitude:31.81, Azimuth:38.43, Times to set:inf Observable: True
|
|
172
|
+
Name: M101, RA: 210.80, Dec:54.35, Altitude:17.65, Azimuth:35.32, Times to set:18.28 Observable: True
|
|
173
|
+
Name: M51, RA: 202.47, Dec:47.20, Altitude:18.02, Azimuth:44.63, Times to set:15.95 Observable: True
|
|
174
|
+
Name: M102, RA: 226.62, Dec:55.76, Altitude:11.79, Azimuth:28.05, Times to set:inf Observable: True
|
|
175
|
+
Name: M82, RA: 148.97, Dec:69.68, Altitude:46.66, Azimuth:22.22, Times to set:inf Observable: True
|
|
176
|
+
Name: M81, RA: 148.89, Dec:69.07, Altitude:46.97, Azimuth:23.00, Times to set:inf Observable: True
|
|
177
|
+
Name: M34, RA: 40.53, Dec:42.72, Altitude:43.19, Azimuth:298.84, Times to set:4.64 Observable: True
|
|
178
|
+
Plot saved as results_20250216_223009.png
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+

|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
186
|
+
|
|
187
|
+
## Contact
|
|
188
|
+
|
|
189
|
+
If you have any questions or suggestions, feel free to contact me.
|
|
190
|
+
|
|
191
|
+
- Name: Rio Fujita
|
|
192
|
+
- Email: rifujita@microsoft.com
|
|
193
|
+
- GitHub: [github-profile](https://github.com/rioriost)
|
celestsp-0.2.0/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Celestial TSP
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Celestial TSP is a Python script that calculates the optimal order of celestial bodies for observation based on their coordinates.
|
|
8
|
+
The script uses the Traveling Salesman Problem (TSP) algorithm to find the shortest path between celestial bodies and generates a spherical image showing the optimal order.
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
- [Overview](#overview)
|
|
13
|
+
- [Table of Contents](#table-of-contents)
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
- [Usage](#usage)
|
|
16
|
+
- [Results](#results)
|
|
17
|
+
- [License](#license)
|
|
18
|
+
- [Contact](#contact)
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
brew tap rioriost/celestsp
|
|
24
|
+
brew install celestsp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
celestsp --help
|
|
31
|
+
usage: celestsp [-h] [--lat LAT] [--lon LON] [--height HEIGHT] [--date DATE] [--time TIME] [--tz TZ] [--output OUTPUT] [--first_body FIRST_BODY] input_file_path
|
|
32
|
+
|
|
33
|
+
Celestial TSP Planner
|
|
34
|
+
|
|
35
|
+
positional arguments:
|
|
36
|
+
input_file_path Input file path with celestial coordinates.
|
|
37
|
+
|
|
38
|
+
options:
|
|
39
|
+
-h, --help show this help message and exit
|
|
40
|
+
--lat LAT Latitude of observation location.
|
|
41
|
+
--lon LON Longitude of observation location.
|
|
42
|
+
--height HEIGHT Height of observation location (in meters).
|
|
43
|
+
--date DATE Observation date (YYYY-MM-DD).
|
|
44
|
+
--time TIME Observation time (HH:MM:SS).
|
|
45
|
+
--tz TZ Time zone offset (e.g., +9 for JST).
|
|
46
|
+
--output OUTPUT Filename for the output image.
|
|
47
|
+
--first_body FIRST_BODY
|
|
48
|
+
Name of the celestial body to start the TSP from.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Provide the input file containing celestial coordinates and specify the observation location and time.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
celestsp --lat 34.863 --lon 138.843 --height 1000 --date 2025-03-22 --time 18:30:00 --tz +9 sources/m_0322_1830.txt
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cat sources/m_0322_1830.txt
|
|
59
|
+
M74
|
|
60
|
+
M33
|
|
61
|
+
M32
|
|
62
|
+
M31
|
|
63
|
+
M110
|
|
64
|
+
M76
|
|
65
|
+
M103
|
|
66
|
+
......
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Results
|
|
70
|
+
```
|
|
71
|
+
Location: Lat: 34.863, Lon: 138.843, 1000.0m
|
|
72
|
+
Observation Date/Time: 2025-03-22 18:30:00 +9
|
|
73
|
+
|
|
74
|
+
Optimal Order of Celestial Bodies:
|
|
75
|
+
Name: M39, RA: 322.89, Dec:48.25, Altitude:1.12, Azimuth:333.80, Times to set:0.22 Observable: True
|
|
76
|
+
Name: M52, RA: 351.19, Dec:61.59, Altitude:21.74, Azimuth:331.37, Times to set:inf Observable: True
|
|
77
|
+
Name: M103, RA: 23.34, Dec:60.66, Altitude:35.73, Azimuth:324.02, Times to set:inf Observable: True
|
|
78
|
+
Name: M76, RA: 25.58, Dec:51.58, Altitude:35.16, Azimuth:312.79, Times to set:5.09 Observable: True
|
|
79
|
+
Name: M110, RA: 10.09, Dec:41.69, Altitude:21.71, Azimuth:306.68, Times to set:2.50 Observable: True
|
|
80
|
+
Name: M31, RA: 10.68, Dec:41.27, Altitude:21.91, Azimuth:306.06, Times to set:2.50 Observable: True
|
|
81
|
+
Name: M32, RA: 10.67, Dec:40.87, Altitude:21.71, Azimuth:305.68, Times to set:2.45 Observable: True
|
|
82
|
+
Name: M33, RA: 23.46, Dec:30.66, Altitude:26.30, Azimuth:290.58, Times to set:2.45 Observable: True
|
|
83
|
+
Name: M74, RA: 24.17, Dec:15.78, Altitude:19.54, Azimuth:276.16, Times to set:1.63 Observable: True
|
|
84
|
+
Name: M77, RA: 40.67, Dec:-0.01, Altitude:23.89, Azimuth:252.15, Times to set:1.97 Observable: True
|
|
85
|
+
Name: M45, RA: 56.60, Dec:24.11, Altitude:50.17, Azimuth:266.82, Times to set:4.25 Observable: True
|
|
86
|
+
Name: M36, RA: 84.08, Dec:34.13, Altitude:76.00, Azimuth:271.95, Times to set:6.75 Observable: True
|
|
87
|
+
Name: M38, RA: 82.17, Dec:35.82, Altitude:74.58, Azimuth:279.12, Times to set:6.75 Observable: True
|
|
88
|
+
Name: M37, RA: 88.07, Dec:32.55, Altitude:78.95, Azimuth:261.61, Times to set:6.89 Observable: True
|
|
89
|
+
Name: M1, RA: 83.63, Dec:22.02, Altitude:70.04, Azimuth:234.63, Times to set:5.93 Observable: True
|
|
90
|
+
Name: M35, RA: 92.27, Dec:24.34, Altitude:76.98, Azimuth:218.37, Times to set:6.63 Observable: True
|
|
91
|
+
Name: M78, RA: 86.69, Dec:0.08, Altitude:52.69, Azimuth:204.35, Times to set:5.05 Observable: True
|
|
92
|
+
Name: M43, RA: 83.88, Dec:-5.27, Altitude:46.70, Azimuth:205.57, Times to set:4.61 Observable: True
|
|
93
|
+
Name: M42, RA: 83.82, Dec:-5.39, Altitude:46.57, Azimuth:205.59, Times to set:4.59 Observable: True
|
|
94
|
+
Name: M79, RA: 81.04, Dec:-24.52,Altitude:27.63, Azimuth:200.75, Times to set:3.41 Observable: True
|
|
95
|
+
Name: M41, RA: 101.50, Dec:-20.72,Altitude:34.39, Azimuth:179.68, Times to set:5.00 Observable: True
|
|
96
|
+
Name: M50, RA: 105.68, Dec:-8.37, Altitude:46.52, Azimuth:173.52, Times to set:5.91 Observable: True
|
|
97
|
+
Name: M47, RA: 114.15, Dec:-14.49,Altitude:39.08, Azimuth:163.77, Times to set:6.17 Observable: True
|
|
98
|
+
Name: M46, RA: 115.44, Dec:-14.84,Altitude:38.42, Azimuth:162.32, Times to set:6.22 Observable: True
|
|
99
|
+
Name: M93, RA: 116.14, Dec:-23.85,Altitude:29.54, Azimuth:164.30, Times to set:5.79 Observable: True
|
|
100
|
+
Name: M48, RA: 123.41, Dec:-5.73, Altitude:44.25, Azimuth:148.28, Times to set:7.21 Observable: True
|
|
101
|
+
Name: M67, RA: 132.85, Dec:11.81, Altitude:53.09, Azimuth:121.02, Times to set:8.65 Observable: True
|
|
102
|
+
Name: M44, RA: 130.05, Dec:19.62, Altitude:60.21, Azimuth:113.38, Times to set:8.86 Observable: True
|
|
103
|
+
Name: M95, RA: 160.99, Dec:11.70, Altitude:31.23, Azimuth:97.85, Times to set:10.52 Observable: True
|
|
104
|
+
Name: M96, RA: 161.69, Dec:11.82, Altitude:30.72, Azimuth:97.29, Times to set:10.57 Observable: True
|
|
105
|
+
Name: M105, RA: 161.96, Dec:12.58, Altitude:30.93, Azimuth:96.38, Times to set:10.62 Observable: True
|
|
106
|
+
Name: M65, RA: 169.73, Dec:13.09, Altitude:24.84, Azimuth:91.23, Times to set:11.17 Observable: True
|
|
107
|
+
Name: M66, RA: 170.06, Dec:12.99, Altitude:24.52, Azimuth:91.13, Times to set:11.20 Observable: True
|
|
108
|
+
Name: M98, RA: 183.45, Dec:14.90, Altitude:14.60, Azimuth:81.99, Times to set:12.18 Observable: True
|
|
109
|
+
Name: M99, RA: 184.71, Dec:14.42, Altitude:13.31, Azimuth:81.72, Times to set:12.23 Observable: True
|
|
110
|
+
Name: M100, RA: 185.73, Dec:15.82, Altitude:13.25, Azimuth:79.96, Times to set:12.37 Observable: True
|
|
111
|
+
Name: M85, RA: 186.35, Dec:18.19, Altitude:14.04, Azimuth:77.58, Times to set:12.54 Observable: True
|
|
112
|
+
Name: M88, RA: 188.00, Dec:14.42, Altitude:10.65, Azimuth:79.93, Times to set:12.44 Observable: True
|
|
113
|
+
Name: M91, RA: 188.86, Dec:14.50, Altitude:10.00, Azimuth:79.40, Times to set:12.52 Observable: True
|
|
114
|
+
Name: M90, RA: 189.21, Dec:13.16, Altitude:8.98, Azimuth:80.33, Times to set:12.47 Observable: True
|
|
115
|
+
Name: M89, RA: 188.92, Dec:12.56, Altitude:8.87, Azimuth:81.00, Times to set:12.42 Observable: True
|
|
116
|
+
Name: M58, RA: 189.43, Dec:11.82, Altitude:8.04, Azimuth:81.33, Times to set:12.42 Observable: True
|
|
117
|
+
Name: M59, RA: 190.51, Dec:11.65, Altitude:7.07, Azimuth:80.88, Times to set:12.47 Observable: True
|
|
118
|
+
Name: M60, RA: 190.92, Dec:11.55, Altitude:6.69, Azimuth:80.73, Times to set:12.49 Observable: True
|
|
119
|
+
Name: M87, RA: 187.71, Dec:12.39, Altitude:9.76, Azimuth:81.81, Times to set:12.32 Observable: True
|
|
120
|
+
Name: M86, RA: 186.55, Dec:12.95, Altitude:11.01, Azimuth:81.97, Times to set:12.28 Observable: True
|
|
121
|
+
Name: M84, RA: 186.27, Dec:12.89, Altitude:11.21, Azimuth:82.18, Times to set:12.25 Observable: True
|
|
122
|
+
Name: M49, RA: 187.44, Dec:8.00, Altitude:7.52, Azimuth:85.63, Times to set:12.11 Observable: True
|
|
123
|
+
Name: M61, RA: 185.48, Dec:4.47, Altitude:7.13, Azimuth:89.68, Times to set:11.80 Observable: True
|
|
124
|
+
Name: M53, RA: 198.23, Dec:18.17, Altitude:4.65, Azimuth:71.23, Times to set:13.31 Observable: True
|
|
125
|
+
Name: M64, RA: 194.18, Dec:21.68, Altitude:9.77, Azimuth:70.48, Times to set:13.24 Observable: True
|
|
126
|
+
Name: M3, RA: 205.55, Dec:28.38, Altitude:5.24, Azimuth:58.98, Times to set:14.39 Observable: True
|
|
127
|
+
Name: M63, RA: 198.96, Dec:42.03, Altitude:17.41, Azimuth:50.62, Times to set:15.06 Observable: True
|
|
128
|
+
Name: M94, RA: 192.72, Dec:41.12, Altitude:20.99, Azimuth:53.93, Times to set:14.56 Observable: True
|
|
129
|
+
Name: M106, RA: 184.74, Dec:47.30, Altitude:28.79, Azimuth:50.44, Times to set:14.80 Observable: True
|
|
130
|
+
Name: M109, RA: 179.40, Dec:53.37, Altitude:33.91, Azimuth:44.92, Times to set:15.78 Observable: True
|
|
131
|
+
Name: M97, RA: 168.70, Dec:55.02, Altitude:40.35, Azimuth:44.26, Times to set:15.95 Observable: True
|
|
132
|
+
Name: M108, RA: 167.88, Dec:55.67, Altitude:40.87, Azimuth:43.45, Times to set:inf Observable: True
|
|
133
|
+
Name: M40, RA: 185.55, Dec:58.08, Altitude:31.81, Azimuth:38.43, Times to set:inf Observable: True
|
|
134
|
+
Name: M101, RA: 210.80, Dec:54.35, Altitude:17.65, Azimuth:35.32, Times to set:18.28 Observable: True
|
|
135
|
+
Name: M51, RA: 202.47, Dec:47.20, Altitude:18.02, Azimuth:44.63, Times to set:15.95 Observable: True
|
|
136
|
+
Name: M102, RA: 226.62, Dec:55.76, Altitude:11.79, Azimuth:28.05, Times to set:inf Observable: True
|
|
137
|
+
Name: M82, RA: 148.97, Dec:69.68, Altitude:46.66, Azimuth:22.22, Times to set:inf Observable: True
|
|
138
|
+
Name: M81, RA: 148.89, Dec:69.07, Altitude:46.97, Azimuth:23.00, Times to set:inf Observable: True
|
|
139
|
+
Name: M34, RA: 40.53, Dec:42.72, Altitude:43.19, Azimuth:298.84, Times to set:4.64 Observable: True
|
|
140
|
+
Plot saved as results_20250216_223009.png
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+

|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
148
|
+
|
|
149
|
+
## Contact
|
|
150
|
+
|
|
151
|
+
If you have any questions or suggestions, feel free to contact me.
|
|
152
|
+
|
|
153
|
+
- Name: Rio Fujita
|
|
154
|
+
- Email: rifujita@microsoft.com
|
|
155
|
+
- GitHub: [github-profile](https://github.com/rioriost)
|
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import nox
|
|
2
|
+
|
|
3
|
+
nox.options.python = "3.13"
|
|
4
|
+
nox.options.default_venv_backend = "uv"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# @nox.session(python=["3.13"], tags=["lint"])
|
|
8
|
+
def lint(session):
|
|
9
|
+
session.install("ruff")
|
|
10
|
+
session.run("uv", "run", "ruff", "check")
|
|
11
|
+
session.run("uv", "run", "ruff", "format")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# @nox.session(python=["3.13"], tags=["mypy"])
|
|
15
|
+
def mypy(session):
|
|
16
|
+
session.install(".")
|
|
17
|
+
session.install(
|
|
18
|
+
"mypy", "types-requests", "pandas-stubs", "types-networkx", "scipy-stubs"
|
|
19
|
+
)
|
|
20
|
+
session.run("uv", "run", "mypy", "src")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@nox.session(python=["3.13"], tags=["pytest"])
|
|
24
|
+
def pytest(session):
|
|
25
|
+
session.install(".")
|
|
26
|
+
session.install("pytest", "pytest-cov")
|
|
27
|
+
test_files = ["test.py"]
|
|
28
|
+
session.run(
|
|
29
|
+
"uv",
|
|
30
|
+
"run",
|
|
31
|
+
"pytest",
|
|
32
|
+
"--maxfail=1",
|
|
33
|
+
"--cov=celestsp",
|
|
34
|
+
"--cov-report=term",
|
|
35
|
+
*test_files,
|
|
36
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "celestsp"
|
|
3
|
+
authors = [
|
|
4
|
+
{name = "Rio Fujita", email = "rifujita@microsoft.com"},
|
|
5
|
+
]
|
|
6
|
+
version = "0.2.0"
|
|
7
|
+
license = {file = "LICENSE"}
|
|
8
|
+
description = "Celestial TSP is a Python script that calculates the optimal order of celestial bodies for observation based on their coordinates."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"astropy>=6.1.7",
|
|
14
|
+
"matplotlib>=3.10.0",
|
|
15
|
+
"networkx>=3.4.2",
|
|
16
|
+
"pandas>=2.2.3",
|
|
17
|
+
"requests>=2.32.3",
|
|
18
|
+
"scipy>=1.14.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/rioriost/homebrew-celestsp"
|
|
23
|
+
Issues = "https://github.com/rioriost/homebrew-celestsp/issues"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
celestsp = "celestsp.main:main"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/celestsp"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = [
|
|
37
|
+
"src/celestsp/*.py",
|
|
38
|
+
"*.py",
|
|
39
|
+
"images/*",
|
|
40
|
+
"sources/*",
|
|
41
|
+
]
|
|
42
|
+
exclude = [
|
|
43
|
+
"celestsp.rb",
|
|
44
|
+
"uv.lock",
|
|
45
|
+
"dist/.DS_Store",
|
|
46
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
M74
|
|
2
|
+
M33
|
|
3
|
+
M32
|
|
4
|
+
M31
|
|
5
|
+
M110
|
|
6
|
+
M76
|
|
7
|
+
M103
|
|
8
|
+
M34
|
|
9
|
+
M45
|
|
10
|
+
M1
|
|
11
|
+
M35
|
|
12
|
+
M37
|
|
13
|
+
M36
|
|
14
|
+
M38
|
|
15
|
+
M78
|
|
16
|
+
M43
|
|
17
|
+
M42
|
|
18
|
+
M79
|
|
19
|
+
M41
|
|
20
|
+
M50
|
|
21
|
+
M47
|
|
22
|
+
M46
|
|
23
|
+
M93
|
|
24
|
+
M48
|
|
25
|
+
M67
|
|
26
|
+
M44
|
|
27
|
+
M95
|
|
28
|
+
M96
|
|
29
|
+
M105
|
|
30
|
+
M65
|
|
31
|
+
M66
|
|
32
|
+
M98
|
|
33
|
+
M99
|
|
34
|
+
M100
|
|
35
|
+
M85
|
|
36
|
+
M88
|
|
37
|
+
M91
|
|
38
|
+
M90
|
|
39
|
+
M89
|
|
40
|
+
M58
|
|
41
|
+
M59
|
|
42
|
+
M60
|
|
43
|
+
M87
|
|
44
|
+
M86
|
|
45
|
+
M84
|
|
46
|
+
M49
|
|
47
|
+
M61
|
|
48
|
+
M53
|
|
49
|
+
M64
|
|
50
|
+
M3
|
|
51
|
+
M63
|
|
52
|
+
M51
|
|
53
|
+
M101
|
|
54
|
+
M102
|
|
55
|
+
M39
|
|
56
|
+
M52
|
|
57
|
+
M94
|
|
58
|
+
M106
|
|
59
|
+
M109
|
|
60
|
+
M40
|
|
61
|
+
M97
|
|
62
|
+
M108
|
|
63
|
+
M81
|
|
64
|
+
M82
|
|
65
|
+
M77
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
M104
|
|
2
|
+
M68
|
|
3
|
+
M83
|
|
4
|
+
M80
|
|
5
|
+
M4
|
|
6
|
+
M19
|
|
7
|
+
M62
|
|
8
|
+
M6
|
|
9
|
+
M7
|
|
10
|
+
M69
|
|
11
|
+
M70
|
|
12
|
+
M54
|
|
13
|
+
M22
|
|
14
|
+
M28
|
|
15
|
+
M8
|
|
16
|
+
M20
|
|
17
|
+
M21
|
|
18
|
+
M23
|
|
19
|
+
M24
|
|
20
|
+
M18
|
|
21
|
+
M17
|
|
22
|
+
M16
|
|
23
|
+
M25
|
|
24
|
+
M26
|
|
25
|
+
M11
|
|
26
|
+
M14
|
|
27
|
+
M10
|
|
28
|
+
M12
|
|
29
|
+
M107
|
|
30
|
+
M9
|
|
31
|
+
M5
|
|
32
|
+
M13
|
|
33
|
+
M92
|
|
34
|
+
M57
|
|
35
|
+
M56
|
|
36
|
+
M27
|
|
37
|
+
M71
|
|
38
|
+
M29
|
|
39
|
+
M15
|
|
40
|
+
M75
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import requests
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
from matplotlib.projections.polar import PolarAxes
|
|
12
|
+
import networkx as nx
|
|
13
|
+
from scipy.spatial import distance_matrix
|
|
14
|
+
from astropy.coordinates import SkyCoord, EarthLocation, AltAz # type: ignore
|
|
15
|
+
from astropy.time import Time # type: ignore
|
|
16
|
+
from astropy import units as u # type: ignore
|
|
17
|
+
from typing import cast
|
|
18
|
+
import datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CelestialTSP:
|
|
22
|
+
def __init__(self, args: argparse.Namespace):
|
|
23
|
+
self.args = args
|
|
24
|
+
self.location = EarthLocation(lat=args.lat, lon=args.lon, height=args.height)
|
|
25
|
+
self.location_str = f"Lat: {args.lat}, Lon: {args.lon}, Height: {args.height}m"
|
|
26
|
+
# If a “default date/time” was provided then no timezone conversion is required.
|
|
27
|
+
if args.default_datetime:
|
|
28
|
+
self.observation_time = Time(f"{args.date} {args.time}")
|
|
29
|
+
else:
|
|
30
|
+
self.observation_time = (
|
|
31
|
+
Time(f"{args.date} {args.time}") - int(args.tz) * u.hour
|
|
32
|
+
)
|
|
33
|
+
self.df: pd.DataFrame = pd.DataFrame()
|
|
34
|
+
|
|
35
|
+
def run(self):
|
|
36
|
+
# 1. Read celestial names/coordinates from file.
|
|
37
|
+
self.df = self.read_celestial_names(self.args.input_file_path)
|
|
38
|
+
|
|
39
|
+
# 2. Check if first_body name is provided and valid.
|
|
40
|
+
if self.args.first_body:
|
|
41
|
+
# Instead of int(self.df.index[...][0]), we explicitly get the index.
|
|
42
|
+
matching_indices = self.df.index[self.df["Name"] == self.args.first_body]
|
|
43
|
+
if matching_indices.empty:
|
|
44
|
+
print(f"Error: {self.args.first_body} is not in the input file.")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
first_index = int(matching_indices[0]) # type: ignore
|
|
47
|
+
else:
|
|
48
|
+
first_index = self.find_first_body()
|
|
49
|
+
|
|
50
|
+
print(f"Location: {self.location_str}")
|
|
51
|
+
print(
|
|
52
|
+
f"Observation Date/Time: {self.args.date} {self.args.time} {self.args.tz}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 3. Build a graph for all celestial bodies:
|
|
56
|
+
# We use their (Altitude, Azimuth) values.
|
|
57
|
+
coordinates = self.df[["Altitude", "Azimuth"]].to_numpy()
|
|
58
|
+
dmatrix = distance_matrix(coordinates, coordinates)
|
|
59
|
+
if first_index != -1:
|
|
60
|
+
graph = self.make_graph(coordinates, dmatrix)
|
|
61
|
+
tsp_path = nx.approximation.greedy_tsp(graph, source=first_index)
|
|
62
|
+
df_ordered = self.df.iloc[tsp_path].reset_index(drop=True)
|
|
63
|
+
self.show_results(df_ordered)
|
|
64
|
+
self.save_spherical_image(
|
|
65
|
+
df_ordered, self.location_str, self.observation_time, self.args.output
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
print(
|
|
69
|
+
"Could not find the celestial body closest to the west (270° azimuth)."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def read_celestial_names(self, file_path: str) -> pd.DataFrame:
|
|
73
|
+
"""
|
|
74
|
+
Reads a file with celestial object names and retrieves their RA/Dec using astropy.
|
|
75
|
+
Exits with error if file cannot be found or no valid data is returned.
|
|
76
|
+
"""
|
|
77
|
+
if not os.path.exists(file_path):
|
|
78
|
+
print(f"Input file {file_path} does not exist.")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
records = []
|
|
82
|
+
with open(file_path, "r") as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
name = line.strip()
|
|
85
|
+
try:
|
|
86
|
+
coord = SkyCoord.from_name(name)
|
|
87
|
+
records.append(
|
|
88
|
+
{"Name": name, "RA": coord.ra.deg, "Dec": coord.dec.deg}
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f"Error looking up {name}: {e}")
|
|
92
|
+
continue
|
|
93
|
+
df = pd.DataFrame(records)
|
|
94
|
+
if df.empty:
|
|
95
|
+
print("Input file is empty or contains no valid celestial names.")
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
return df
|
|
98
|
+
|
|
99
|
+
def find_first_body(self) -> int:
|
|
100
|
+
"""
|
|
101
|
+
Identify the celestial body that will set first (i.e. has the shortest time until setting)
|
|
102
|
+
when observed from self.location at self.observation_time.
|
|
103
|
+
The method also adds several columns to self.df: Altitude, Azimuth, TimeToSet, Observable.
|
|
104
|
+
Returns the row index of this first body.
|
|
105
|
+
"""
|
|
106
|
+
altaz_frame = AltAz(obstime=self.observation_time, location=self.location)
|
|
107
|
+
|
|
108
|
+
# Pre-create lists (can be replaced with vectorized operations if desired)
|
|
109
|
+
altitudes = []
|
|
110
|
+
azimuths = []
|
|
111
|
+
times_to_set = []
|
|
112
|
+
observables = []
|
|
113
|
+
shortest_time = np.inf
|
|
114
|
+
first_index = -1
|
|
115
|
+
|
|
116
|
+
# For each row we can optimize by processing in bulk. However, because the time-to-set
|
|
117
|
+
# calculation uses a simulated time grid, we loop for clarity.
|
|
118
|
+
for i, row in self.df.iterrows():
|
|
119
|
+
sky_coord = SkyCoord(ra=row["RA"], dec=row["Dec"], unit="deg")
|
|
120
|
+
altaz = sky_coord.transform_to(altaz_frame)
|
|
121
|
+
alt = altaz.alt.deg
|
|
122
|
+
az = altaz.az.deg
|
|
123
|
+
altitudes.append(alt)
|
|
124
|
+
azimuths.append(az)
|
|
125
|
+
observables.append(self.is_observable(altaz))
|
|
126
|
+
|
|
127
|
+
# If object is currently above horizon compute when it will set
|
|
128
|
+
if alt > 0:
|
|
129
|
+
# Create a time grid (1000 steps within 24h)
|
|
130
|
+
delta_hours = np.linspace(0, 24, 1000) * u.hour
|
|
131
|
+
future_times = self.observation_time + delta_hours
|
|
132
|
+
future_altaz = sky_coord.transform_to(
|
|
133
|
+
AltAz(obstime=future_times, location=self.location)
|
|
134
|
+
)
|
|
135
|
+
future_alts = future_altaz.alt.deg
|
|
136
|
+
# Find first time when altitude goes non-positive (object sets)
|
|
137
|
+
set_indices = np.where(future_alts <= 0)[0]
|
|
138
|
+
if set_indices.size > 0:
|
|
139
|
+
t_set = (
|
|
140
|
+
(future_times[set_indices[0]] - self.observation_time)
|
|
141
|
+
.to(u.hour)
|
|
142
|
+
.value
|
|
143
|
+
)
|
|
144
|
+
times_to_set.append(t_set)
|
|
145
|
+
if t_set < shortest_time:
|
|
146
|
+
shortest_time = t_set
|
|
147
|
+
first_index = i # type: ignore
|
|
148
|
+
else:
|
|
149
|
+
times_to_set.append(np.inf)
|
|
150
|
+
else:
|
|
151
|
+
times_to_set.append(np.inf)
|
|
152
|
+
|
|
153
|
+
self.df["Altitude"] = altitudes
|
|
154
|
+
self.df["Azimuth"] = azimuths
|
|
155
|
+
self.df["TimeToSet"] = times_to_set
|
|
156
|
+
self.df["Observable"] = observables
|
|
157
|
+
|
|
158
|
+
return first_index
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def is_observable(altaz_coord, min_altitude=0) -> bool:
|
|
162
|
+
"""Return True if the altitude is above min_altitude (default=0 deg)."""
|
|
163
|
+
return altaz_coord.alt.deg > min_altitude
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def make_graph(coordinates: np.ndarray, dist_matrix: np.ndarray) -> nx.Graph:
|
|
167
|
+
"""
|
|
168
|
+
Builds a fully-connected NetworkX graph where each node represents a celestial object.
|
|
169
|
+
Node positions (for plotting) are stored in the 'pos' attribute; edge weights are from distance matrix.
|
|
170
|
+
"""
|
|
171
|
+
G: nx.Graph = nx.Graph()
|
|
172
|
+
n = len(coordinates)
|
|
173
|
+
for i in range(n):
|
|
174
|
+
G.add_node(i, pos=(coordinates[i][0], coordinates[i][1]))
|
|
175
|
+
# Use upper-triangle of matrix (graph undirected)
|
|
176
|
+
for i in range(n):
|
|
177
|
+
for j in range(i + 1, n):
|
|
178
|
+
G.add_edge(i, j, weight=dist_matrix[i, j])
|
|
179
|
+
return G
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def show_results(df: pd.DataFrame) -> None:
|
|
183
|
+
"""Prints the optimal order of celestial bodies."""
|
|
184
|
+
print("\nOptimal Order of Celestial Bodies:")
|
|
185
|
+
for i, row in df.iterrows():
|
|
186
|
+
if int(i) + 1 == len(df): # type: ignore
|
|
187
|
+
continue
|
|
188
|
+
name = row["Name"]
|
|
189
|
+
ra = f"{row['RA']:.2f}"
|
|
190
|
+
dec = f"{row['Dec']:.2f}"
|
|
191
|
+
alt = f"{row['Altitude']:.2f}"
|
|
192
|
+
azimuth = f"{row['Azimuth']:.2f}"
|
|
193
|
+
tset = f"{row['TimeToSet']:.2f}"
|
|
194
|
+
observable = row["Observable"]
|
|
195
|
+
print(
|
|
196
|
+
f"Name: {name:<10} RA: {ra:<7} Dec: {dec:<7} Altitude: {alt:<7} Azimuth: {azimuth:<7} Time to set: {tset:<7} Observable: {observable}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def save_spherical_image(
|
|
201
|
+
df: pd.DataFrame, location_str: str, observation_time: Time, filename: str
|
|
202
|
+
) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Generate and save a spherical (polar) plot showing celestial object positions
|
|
205
|
+
and the TSP path.
|
|
206
|
+
"""
|
|
207
|
+
fig = plt.figure(figsize=(8, 8))
|
|
208
|
+
ax = cast(PolarAxes, fig.add_subplot(111, projection="polar"))
|
|
209
|
+
|
|
210
|
+
az_radians = np.deg2rad(df["Azimuth"])
|
|
211
|
+
alt_radians = np.deg2rad(90 - df["Altitude"])
|
|
212
|
+
|
|
213
|
+
ax.scatter(az_radians, alt_radians, c="blue", label="Celestial Bodies")
|
|
214
|
+
for i, row in df.iterrows():
|
|
215
|
+
ax.annotate(
|
|
216
|
+
row["Name"], (az_radians[i], alt_radians[i]), fontsize=8, ha="right"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
for i in range(len(df) - 1):
|
|
220
|
+
start_az, start_alt = az_radians[i], alt_radians[i]
|
|
221
|
+
end_az, end_alt = az_radians[i + 1], alt_radians[i + 1]
|
|
222
|
+
ax.plot([start_az, end_az], [start_alt, end_alt], "r-")
|
|
223
|
+
|
|
224
|
+
if len(df) > 0:
|
|
225
|
+
start_az, start_alt = az_radians.iloc[0], alt_radians.iloc[0]
|
|
226
|
+
ax.annotate(
|
|
227
|
+
"Start",
|
|
228
|
+
xy=(start_az, start_alt),
|
|
229
|
+
xytext=(start_az, start_alt + 0.1),
|
|
230
|
+
fontsize=12,
|
|
231
|
+
color="green",
|
|
232
|
+
ha="center",
|
|
233
|
+
)
|
|
234
|
+
if len(df) > 1:
|
|
235
|
+
second_az, second_alt = az_radians.iloc[1], alt_radians.iloc[1]
|
|
236
|
+
ax.annotate(
|
|
237
|
+
"",
|
|
238
|
+
xy=(second_az, second_alt),
|
|
239
|
+
xytext=(start_az, start_alt),
|
|
240
|
+
arrowprops=dict(facecolor="red", arrowstyle="->", lw=2.5),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
ax.set_title("Optimal Order of Celestial Bodies (Spherical Projection)", pad=30)
|
|
244
|
+
ax.set_theta_zero_location("N")
|
|
245
|
+
ax.set_theta_direction(-1)
|
|
246
|
+
|
|
247
|
+
obs_time_str = (
|
|
248
|
+
observation_time.iso if observation_time is not None else "Unknown"
|
|
249
|
+
)
|
|
250
|
+
fig.text(
|
|
251
|
+
0.5,
|
|
252
|
+
0.01,
|
|
253
|
+
f"Location: {location_str} | Observation Time: {obs_time_str} UTC",
|
|
254
|
+
ha="center",
|
|
255
|
+
fontsize=10,
|
|
256
|
+
)
|
|
257
|
+
now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
258
|
+
try:
|
|
259
|
+
plt.savefig(f"{filename}_{now}.png")
|
|
260
|
+
print(f"Plot saved as {filename}_{now}.png")
|
|
261
|
+
except Exception as e:
|
|
262
|
+
print(f"Error saving plot: {e}")
|
|
263
|
+
plt.close()
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def get_location() -> tuple:
|
|
267
|
+
"""
|
|
268
|
+
Obtain the current location by using ip-api.com.
|
|
269
|
+
Returns a tuple (latitude, longitude) or (None, None) if unavailable.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
response = requests.get("http://ip-api.com/json/")
|
|
273
|
+
data = response.json()
|
|
274
|
+
if data.get("status") == "success":
|
|
275
|
+
return float(data.get("lat")), float(data.get("lon"))
|
|
276
|
+
else:
|
|
277
|
+
print("Error: Unable to get location data")
|
|
278
|
+
return None, None
|
|
279
|
+
except Exception:
|
|
280
|
+
return None, None
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def build_arg_parser(cls) -> argparse.Namespace:
|
|
284
|
+
"""
|
|
285
|
+
Set up and parse command-line arguments.
|
|
286
|
+
"""
|
|
287
|
+
latitude, longitude = cls.get_location()
|
|
288
|
+
# Get current time string for default values.
|
|
289
|
+
now = Time.now().iso.split(".")[0]
|
|
290
|
+
date_default, time_default = now.split(" ")
|
|
291
|
+
parser = argparse.ArgumentParser(description="Celestial TSP Planner")
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"input_file_path",
|
|
294
|
+
type=str,
|
|
295
|
+
help="Input file path with celestial coordinates.",
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument(
|
|
298
|
+
"--lat",
|
|
299
|
+
type=float,
|
|
300
|
+
default=latitude if latitude is not None else 0,
|
|
301
|
+
help="Latitude of observation location.",
|
|
302
|
+
)
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"--lon",
|
|
305
|
+
type=float,
|
|
306
|
+
default=longitude if longitude is not None else 0,
|
|
307
|
+
help="Longitude of observation location.",
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--height",
|
|
311
|
+
type=float,
|
|
312
|
+
default=0,
|
|
313
|
+
help="Height of observation location (in meters).",
|
|
314
|
+
)
|
|
315
|
+
parser.add_argument(
|
|
316
|
+
"--date",
|
|
317
|
+
type=str,
|
|
318
|
+
default=date_default,
|
|
319
|
+
help="Observation date (YYYY-MM-DD).",
|
|
320
|
+
)
|
|
321
|
+
parser.add_argument(
|
|
322
|
+
"--time",
|
|
323
|
+
type=str,
|
|
324
|
+
default=time_default,
|
|
325
|
+
help="Observation time (HH:MM:SS).",
|
|
326
|
+
)
|
|
327
|
+
parser.add_argument(
|
|
328
|
+
"--tz", type=str, default="+9", help="Time zone offset (e.g., +9 for JST)."
|
|
329
|
+
)
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"--output",
|
|
332
|
+
type=str,
|
|
333
|
+
default="results",
|
|
334
|
+
help="Filename for the output image.",
|
|
335
|
+
)
|
|
336
|
+
parser.add_argument(
|
|
337
|
+
"--first_body",
|
|
338
|
+
type=str,
|
|
339
|
+
default="",
|
|
340
|
+
help="Name of the celestial body to start the TSP from.",
|
|
341
|
+
)
|
|
342
|
+
args, _ = parser.parse_known_args()
|
|
343
|
+
args.default_datetime = args.date == date_default and args.time == time_default
|
|
344
|
+
return args
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def main():
|
|
348
|
+
# Parse command line arguments
|
|
349
|
+
args = CelestialTSP.build_arg_parser()
|
|
350
|
+
# Create and run the CelestialTSP planner
|
|
351
|
+
planner = CelestialTSP(args)
|
|
352
|
+
planner.run()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
main()
|
celestsp-0.2.0/test.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import datetime
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from contextlib import redirect_stdout
|
|
12
|
+
from unittest.mock import patch, MagicMock
|
|
13
|
+
|
|
14
|
+
import networkx as nx
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from astropy.coordinates import SkyCoord
|
|
18
|
+
from astropy.time import Time
|
|
19
|
+
import astropy.units as u
|
|
20
|
+
|
|
21
|
+
# Insert the src directory (which contains the macocr package) into sys.path.
|
|
22
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
|
23
|
+
|
|
24
|
+
# Import the module under test
|
|
25
|
+
from celestsp.main import CelestialTSP, main
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# This dummy object will emulate the result of SkyCoord.transform_to.
|
|
29
|
+
class DummyAltAz:
|
|
30
|
+
def __init__(self, alt):
|
|
31
|
+
# alt is assumed to be a scalar (float) altitude in degrees.
|
|
32
|
+
self.alt = type("dummy_alt", (), {"deg": alt})
|
|
33
|
+
self.az = type("dummy_az", (), {"deg": 100.0})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# A dummy version of SkyCoord.transform_to.
|
|
37
|
+
def dummy_transform_to(self, frame):
|
|
38
|
+
"""
|
|
39
|
+
This dummy inspects self.ra.deg: if it is less than 1 then returns an altitude of 10,
|
|
40
|
+
otherwise returns an altitude of 5.
|
|
41
|
+
For vectorized calls (when frame.obstime is an array) the altitude will linearly decrease
|
|
42
|
+
from the chosen value to below 0 so that the first time a set condition is met can be computed.
|
|
43
|
+
"""
|
|
44
|
+
dummy_alt = 10.0 if self.ra.deg < 1.0 else 5.0
|
|
45
|
+
# If the frame has an "obstime" attribute, check if it is array-like.
|
|
46
|
+
if hasattr(frame, "obstime"):
|
|
47
|
+
try:
|
|
48
|
+
# if frame.obstime is iterable (vectorized call)
|
|
49
|
+
n = len(frame.obstime)
|
|
50
|
+
except TypeError:
|
|
51
|
+
n = None
|
|
52
|
+
if n is not None:
|
|
53
|
+
# Create a linearly decreasing altitude array:
|
|
54
|
+
# altitude will drop from dummy_alt at t=0 to -1 at t=end.
|
|
55
|
+
alts = dummy_alt - (dummy_alt + 1) * np.linspace(0, 1, n)
|
|
56
|
+
dummy_alt_obj = type("DummyAltArray", (), {})()
|
|
57
|
+
dummy_alt_obj.deg = alts
|
|
58
|
+
dummy_az_obj = type("DummyAzArray", (), {})()
|
|
59
|
+
dummy_az_obj.deg = np.full(n, 100.0)
|
|
60
|
+
dummy = type("Dummy", (), {})()
|
|
61
|
+
dummy.alt = dummy_alt_obj
|
|
62
|
+
dummy.az = dummy_az_obj
|
|
63
|
+
return dummy
|
|
64
|
+
else:
|
|
65
|
+
# Scalar call: return a constant dummy object.
|
|
66
|
+
return DummyAltAz(dummy_alt)
|
|
67
|
+
else:
|
|
68
|
+
return DummyAltAz(dummy_alt)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# A dummy version of SkyCoord.from_name for testing read_celestial_names.
|
|
72
|
+
def dummy_from_name(name):
|
|
73
|
+
# Return a dummy SkyCoord with RA and Dec based on the name string.
|
|
74
|
+
# For example, if name can be converted to a float then use that value;
|
|
75
|
+
# otherwise use fixed numbers.
|
|
76
|
+
try:
|
|
77
|
+
ra_val = float(name)
|
|
78
|
+
except ValueError:
|
|
79
|
+
ra_val = 15.0
|
|
80
|
+
# Use dec = ra/2 for testing.
|
|
81
|
+
dec_val = ra_val / 2.0
|
|
82
|
+
dummy = SkyCoord(ra=ra_val * u.deg, dec=dec_val * u.deg)
|
|
83
|
+
return dummy
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# A dummy requests.get to simulate get_location.
|
|
87
|
+
def dummy_requests_get_success(url):
|
|
88
|
+
# Simulate a successful response.
|
|
89
|
+
response = MagicMock()
|
|
90
|
+
response.json.return_value = {"status": "success", "lat": 35.0, "lon": -120.0}
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def dummy_requests_get_fail(url):
|
|
95
|
+
# Simulate a failed location response.
|
|
96
|
+
response = MagicMock()
|
|
97
|
+
response.json.return_value = {"status": "fail"}
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- The test class --- #
|
|
102
|
+
class TestCelestialTSP(unittest.TestCase):
|
|
103
|
+
def setUp(self):
|
|
104
|
+
# Create dummy arguments similar to build_arg_parser but overriding defaults.
|
|
105
|
+
self.temp_input = tempfile.NamedTemporaryFile(
|
|
106
|
+
delete=False, mode="w", encoding="utf-8"
|
|
107
|
+
)
|
|
108
|
+
# Write some dummy celestial names (each name on its own line).
|
|
109
|
+
# We choose names that when passed to dummy_from_name produce specific RA/Dec values.
|
|
110
|
+
self.temp_input.write(
|
|
111
|
+
"0\n1\nNonNumeric"
|
|
112
|
+
) # first two numeric, third will use default 15.0.
|
|
113
|
+
self.temp_input.close()
|
|
114
|
+
# Build a dummy argparse.Namespace
|
|
115
|
+
self.args = argparse.Namespace(
|
|
116
|
+
input_file_path=self.temp_input.name,
|
|
117
|
+
lat=0.0,
|
|
118
|
+
lon=0.0,
|
|
119
|
+
height=0.0,
|
|
120
|
+
date="2023-01-01",
|
|
121
|
+
time="00:00:00",
|
|
122
|
+
tz="+0",
|
|
123
|
+
output="test_output",
|
|
124
|
+
first_body="",
|
|
125
|
+
default_datetime=False,
|
|
126
|
+
)
|
|
127
|
+
# Create a CelestialTSP instance with dummy args.
|
|
128
|
+
self.planner = CelestialTSP(self.args)
|
|
129
|
+
|
|
130
|
+
def tearDown(self):
|
|
131
|
+
if os.path.exists(self.temp_input.name):
|
|
132
|
+
os.unlink(self.temp_input.name)
|
|
133
|
+
# Remove any files created by save_spherical_image if necessary.
|
|
134
|
+
for f in os.listdir("."):
|
|
135
|
+
if f.startswith(self.args.output) and f.endswith(".png"):
|
|
136
|
+
os.unlink(f)
|
|
137
|
+
|
|
138
|
+
@patch("celestsp.main.SkyCoord.from_name", side_effect=dummy_from_name)
|
|
139
|
+
def test_read_celestial_names_success(self, mock_from_name):
|
|
140
|
+
# Test that read_celestial_names returns a DataFrame with valid data.
|
|
141
|
+
df = self.planner.read_celestial_names(self.args.input_file_path)
|
|
142
|
+
self.assertFalse(df.empty)
|
|
143
|
+
self.assertIn("Name", df.columns)
|
|
144
|
+
self.assertIn("RA", df.columns)
|
|
145
|
+
self.assertIn("Dec", df.columns)
|
|
146
|
+
# Three lines in our file.
|
|
147
|
+
self.assertEqual(len(df), 3)
|
|
148
|
+
|
|
149
|
+
def test_read_celestial_names_file_not_exist(self):
|
|
150
|
+
# Provide a non-existent file path.
|
|
151
|
+
fake_path = "nonexistent_file.txt"
|
|
152
|
+
with (
|
|
153
|
+
self.assertRaises(SystemExit) as cm,
|
|
154
|
+
patch("sys.stdout", new=io.StringIO()) as fake_out,
|
|
155
|
+
):
|
|
156
|
+
self.planner.read_celestial_names(fake_path)
|
|
157
|
+
self.assertEqual(cm.exception.code, 1)
|
|
158
|
+
self.assertIn("does not exist", fake_out.getvalue())
|
|
159
|
+
|
|
160
|
+
@patch("celestsp.main.SkyCoord.from_name", side_effect=dummy_from_name)
|
|
161
|
+
def test_read_celestial_names_empty(self, mock_from_name):
|
|
162
|
+
# Create an empty temporary file.
|
|
163
|
+
with tempfile.NamedTemporaryFile(
|
|
164
|
+
delete=False, mode="w", encoding="utf-8"
|
|
165
|
+
) as tmp:
|
|
166
|
+
empty_path = tmp.name
|
|
167
|
+
try:
|
|
168
|
+
with (
|
|
169
|
+
self.assertRaises(SystemExit) as cm,
|
|
170
|
+
patch("sys.stdout", new=io.StringIO()) as fake_out,
|
|
171
|
+
):
|
|
172
|
+
self.planner.read_celestial_names(empty_path)
|
|
173
|
+
self.assertEqual(cm.exception.code, 1)
|
|
174
|
+
self.assertIn(
|
|
175
|
+
"empty or contains no valid celestial names", fake_out.getvalue()
|
|
176
|
+
)
|
|
177
|
+
finally:
|
|
178
|
+
os.unlink(empty_path)
|
|
179
|
+
|
|
180
|
+
def test_is_observable(self):
|
|
181
|
+
# Create a dummy altaz object with alt.deg = 5.
|
|
182
|
+
dummy = DummyAltAz(5)
|
|
183
|
+
self.assertTrue(CelestialTSP.is_observable(dummy))
|
|
184
|
+
# Test below horizon.
|
|
185
|
+
dummy2 = DummyAltAz(-1)
|
|
186
|
+
self.assertFalse(CelestialTSP.is_observable(dummy2))
|
|
187
|
+
# Test with min_altitude argument.
|
|
188
|
+
self.assertFalse(CelestialTSP.is_observable(dummy, min_altitude=6))
|
|
189
|
+
|
|
190
|
+
def test_make_graph(self):
|
|
191
|
+
# Prepare simple coordinates and distance matrix.
|
|
192
|
+
coords = np.array([[10, 20], [30, 40], [50, 60]])
|
|
193
|
+
dist_mat = np.array([[0, 1, 2], [1, 0, 3], [2, 3, 0]])
|
|
194
|
+
graph = CelestialTSP.make_graph(coords, dist_mat)
|
|
195
|
+
self.assertIsInstance(graph, nx.Graph)
|
|
196
|
+
# Check nodes and edge weights.
|
|
197
|
+
self.assertEqual(len(graph.nodes), 3)
|
|
198
|
+
self.assertEqual(len(graph.edges), 3) # complete graph of 3 nodes: 3 edges.
|
|
199
|
+
self.assertAlmostEqual(graph[0][1]["weight"], 1)
|
|
200
|
+
|
|
201
|
+
def test_show_results(self):
|
|
202
|
+
# Create a dummy DataFrame with required columns.
|
|
203
|
+
df = pd.DataFrame(
|
|
204
|
+
{
|
|
205
|
+
"Name": ["A", "B"],
|
|
206
|
+
"RA": [10.0, 20.0],
|
|
207
|
+
"Dec": [5.0, 15.0],
|
|
208
|
+
"Altitude": [30.0, 20.0],
|
|
209
|
+
"Azimuth": [40.0, 50.0],
|
|
210
|
+
"TimeToSet": [2.0, 3.0],
|
|
211
|
+
"Observable": [True, False],
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
# Capture printed output.
|
|
215
|
+
out = io.StringIO()
|
|
216
|
+
with redirect_stdout(out):
|
|
217
|
+
CelestialTSP.show_results(df)
|
|
218
|
+
result = out.getvalue()
|
|
219
|
+
self.assertIn("Optimal Order of Celestial Bodies", result)
|
|
220
|
+
self.assertIn("A", result)
|
|
221
|
+
self.assertIn("B", result)
|
|
222
|
+
|
|
223
|
+
def test_save_spherical_image(self):
|
|
224
|
+
# Create a dummy DataFrame with required columns.
|
|
225
|
+
df = pd.DataFrame(
|
|
226
|
+
{
|
|
227
|
+
"Name": ["A", "B", "C"],
|
|
228
|
+
"RA": [10.0, 20.0, 30.0],
|
|
229
|
+
"Dec": [5.0, 15.0, 25.0],
|
|
230
|
+
"Altitude": [80.0, 70.0, 60.0],
|
|
231
|
+
"Azimuth": [40.0, 50.0, 60.0],
|
|
232
|
+
"TimeToSet": [2.0, 3.0, 4.0],
|
|
233
|
+
"Observable": [True, True, False],
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
location_str = "Test Location"
|
|
237
|
+
observation_time = Time("2023-01-01 00:00:00")
|
|
238
|
+
# Patch plt.savefig so no file is written.
|
|
239
|
+
with (
|
|
240
|
+
patch("celestsp.main.plt.savefig") as mock_savefig,
|
|
241
|
+
patch("datetime.datetime") as mock_datetime,
|
|
242
|
+
):
|
|
243
|
+
mock_datetime.now.return_value = datetime.datetime(2023, 1, 1, 0, 0, 0)
|
|
244
|
+
CelestialTSP.save_spherical_image(
|
|
245
|
+
df, location_str, observation_time, "dummy_output"
|
|
246
|
+
)
|
|
247
|
+
# Assert that savefig was called.
|
|
248
|
+
mock_savefig.assert_called()
|
|
249
|
+
|
|
250
|
+
def test_get_location_success(self):
|
|
251
|
+
with patch(
|
|
252
|
+
"celestsp.main.requests.get", side_effect=dummy_requests_get_success
|
|
253
|
+
):
|
|
254
|
+
lat, lon = CelestialTSP.get_location()
|
|
255
|
+
self.assertEqual(lat, 35.0)
|
|
256
|
+
self.assertEqual(lon, -120.0)
|
|
257
|
+
|
|
258
|
+
def test_get_location_fail(self):
|
|
259
|
+
with patch("celestsp.main.requests.get", side_effect=dummy_requests_get_fail):
|
|
260
|
+
lat, lon = CelestialTSP.get_location()
|
|
261
|
+
self.assertIsNone(lat)
|
|
262
|
+
self.assertIsNone(lon)
|
|
263
|
+
|
|
264
|
+
def test_build_arg_parser(self):
|
|
265
|
+
# To make sure build_arg_parser returns the expected Namespace,
|
|
266
|
+
# we simulate a command-line call by patching sys.argv.
|
|
267
|
+
args_list = [
|
|
268
|
+
self.temp_input.name,
|
|
269
|
+
"--lat",
|
|
270
|
+
"12.34",
|
|
271
|
+
"--lon",
|
|
272
|
+
"56.78",
|
|
273
|
+
"--height",
|
|
274
|
+
"100",
|
|
275
|
+
"--date",
|
|
276
|
+
"2023-12-31",
|
|
277
|
+
"--time",
|
|
278
|
+
"23:59:59",
|
|
279
|
+
"--tz",
|
|
280
|
+
"+3",
|
|
281
|
+
"--output",
|
|
282
|
+
"my_results.png",
|
|
283
|
+
"--first_body",
|
|
284
|
+
"TestBody",
|
|
285
|
+
]
|
|
286
|
+
with patch.object(sys, "argv", ["prog"] + args_list):
|
|
287
|
+
with patch(
|
|
288
|
+
"celestsp.main.CelestialTSP.get_location", return_value=(1.0, 2.0)
|
|
289
|
+
):
|
|
290
|
+
parser_args = CelestialTSP.build_arg_parser()
|
|
291
|
+
self.assertEqual(parser_args.input_file_path, self.temp_input.name)
|
|
292
|
+
self.assertEqual(parser_args.lat, 12.34)
|
|
293
|
+
self.assertEqual(parser_args.lon, 56.78)
|
|
294
|
+
self.assertEqual(parser_args.height, 100)
|
|
295
|
+
self.assertEqual(parser_args.date, "2023-12-31")
|
|
296
|
+
self.assertEqual(parser_args.time, "23:59:59")
|
|
297
|
+
self.assertEqual(parser_args.tz, "+3")
|
|
298
|
+
self.assertEqual(parser_args.output, "my_results.png")
|
|
299
|
+
self.assertEqual(parser_args.first_body, "TestBody")
|
|
300
|
+
|
|
301
|
+
@patch("celestsp.main.SkyCoord.from_name", side_effect=dummy_from_name)
|
|
302
|
+
@patch("celestsp.main.SkyCoord.transform_to", new=dummy_transform_to)
|
|
303
|
+
@patch("celestsp.main.nx.approximation.greedy_tsp", return_value=[1, 0, 2])
|
|
304
|
+
def test_run_with_first_body_not_specified(self, mock_tsp, mock_from_name):
|
|
305
|
+
# Test the full run() method when first_body is not provided.
|
|
306
|
+
# First, read celestial names.
|
|
307
|
+
self.planner.df = self.planner.read_celestial_names(self.args.input_file_path)
|
|
308
|
+
# We expect find_first_body to use our dummy transform_to.
|
|
309
|
+
# With our dummy, rows with RA < 1 get altitude 10 and others get altitude 5.
|
|
310
|
+
# In our test file, the first entry is "0", so RA = 0 and altitude=10; the second is "1"
|
|
311
|
+
# (RA = 1 gives altitude=5) and third is "NonNumeric" -> RA =15 so altitude=5.
|
|
312
|
+
# When computing t_set in find_first_body the one with lower dummy altitude (5) will set earlier.
|
|
313
|
+
out = io.StringIO()
|
|
314
|
+
with redirect_stdout(out):
|
|
315
|
+
# Because run() eventually calls sys.exit(1) if first_body provided is invalid.
|
|
316
|
+
# In this run we'll not provide first_body so find_first_body returns an index.
|
|
317
|
+
self.planner.run()
|
|
318
|
+
# In our dummy simulation, first_index should be 1 (the second row).
|
|
319
|
+
printed = out.getvalue()
|
|
320
|
+
self.assertIn("Location:", printed)
|
|
321
|
+
self.assertIn("Observation Date/Time:", printed)
|
|
322
|
+
# Because all plotting and file saving functions are exercised, check that the output contains the plot saved message.
|
|
323
|
+
self.assertIn("Plot saved as", printed)
|
|
324
|
+
|
|
325
|
+
@patch("celestsp.main.SkyCoord.from_name", side_effect=dummy_from_name)
|
|
326
|
+
def test_run_with_invalid_first_body(self, mock_from_name):
|
|
327
|
+
# Set a first_body that is not in the file.
|
|
328
|
+
self.args.first_body = "NonExistent"
|
|
329
|
+
self.planner = CelestialTSP(self.args)
|
|
330
|
+
# read the file (will read three lines and get dataframe)
|
|
331
|
+
self.planner.df = self.planner.read_celestial_names(self.args.input_file_path)
|
|
332
|
+
out = io.StringIO()
|
|
333
|
+
with redirect_stdout(out), self.assertRaises(SystemExit):
|
|
334
|
+
self.planner.run()
|
|
335
|
+
printed = out.getvalue()
|
|
336
|
+
self.assertIn("is not in the input file.", printed)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Test the module level main() function.
|
|
340
|
+
class TestMainFunction(unittest.TestCase):
|
|
341
|
+
@patch("celestsp.main.CelestialTSP.build_arg_parser")
|
|
342
|
+
@patch("celestsp.main.CelestialTSP.run")
|
|
343
|
+
def test_main(self, mock_run, mock_build_arg_parser):
|
|
344
|
+
# Create a dummy Namespace to be returned by build_arg_parser.
|
|
345
|
+
dummy_args = argparse.Namespace(
|
|
346
|
+
input_file_path="dummy.txt",
|
|
347
|
+
lat=0.0,
|
|
348
|
+
lon=0.0,
|
|
349
|
+
height=0.0,
|
|
350
|
+
date="2023-01-01",
|
|
351
|
+
time="00:00:00",
|
|
352
|
+
tz="+0",
|
|
353
|
+
output="dummy_output",
|
|
354
|
+
first_body="",
|
|
355
|
+
default_datetime=True,
|
|
356
|
+
)
|
|
357
|
+
mock_build_arg_parser.return_value = dummy_args
|
|
358
|
+
# Run main() and make sure run() is called.
|
|
359
|
+
with patch("sys.stdout", new=io.StringIO()):
|
|
360
|
+
main()
|
|
361
|
+
mock_run.assert_called()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
unittest.main()
|