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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
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.
@@ -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
+ ![Python](https://img.shields.io/badge/Python-3.10%2B-blue)
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
+ ![results_20250216_223009.png](https://github.com/rioriost/homebrew-celestsp/blob/main/images/results_20250216_223009.png)
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)
@@ -0,0 +1,155 @@
1
+ # Celestial TSP
2
+
3
+ ![Python](https://img.shields.io/badge/Python-3.10%2B-blue)
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
+ ![results_20250216_223009.png](https://github.com/rioriost/homebrew-celestsp/blob/main/images/results_20250216_223009.png)
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)
@@ -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,5 @@
1
+ M2
2
+ M73
3
+ M72
4
+ M55
5
+ M30
@@ -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()