citrascope 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- citrascope/__main__.py +8 -5
- citrascope/api/abstract_api_client.py +7 -0
- citrascope/api/citra_api_client.py +30 -1
- citrascope/citra_scope_daemon.py +214 -61
- citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
- citrascope/hardware/adapter_registry.py +94 -0
- citrascope/hardware/indi_adapter.py +456 -16
- citrascope/hardware/kstars_dbus_adapter.py +179 -0
- citrascope/hardware/nina_adv_http_adapter.py +593 -0
- citrascope/hardware/nina_adv_http_survey_template.json +328 -0
- citrascope/logging/__init__.py +2 -1
- citrascope/logging/_citrascope_logger.py +80 -1
- citrascope/logging/web_log_handler.py +74 -0
- citrascope/settings/citrascope_settings.py +145 -0
- citrascope/settings/settings_file_manager.py +126 -0
- citrascope/tasks/runner.py +124 -28
- citrascope/tasks/scope/base_telescope_task.py +25 -10
- citrascope/tasks/scope/static_telescope_task.py +11 -3
- citrascope/web/__init__.py +1 -0
- citrascope/web/app.py +470 -0
- citrascope/web/server.py +123 -0
- citrascope/web/static/api.js +82 -0
- citrascope/web/static/app.js +500 -0
- citrascope/web/static/config.js +362 -0
- citrascope/web/static/img/citra.png +0 -0
- citrascope/web/static/img/favicon.png +0 -0
- citrascope/web/static/style.css +120 -0
- citrascope/web/static/websocket.js +127 -0
- citrascope/web/templates/dashboard.html +354 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
- citrascope-0.3.0.dist-info/RECORD +38 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
- citrascope/settings/_citrascope_settings.py +0 -42
- citrascope-0.1.0.dist-info/RECORD +0 -21
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import platformdirs
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import (
|
|
13
|
+
AbstractAstroHardwareAdapter,
|
|
14
|
+
ObservationStrategy,
|
|
15
|
+
SettingSchemaEntry,
|
|
16
|
+
)
|
|
17
|
+
from citrascope.settings.citrascope_settings import APP_AUTHOR, APP_NAME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
21
|
+
"""HTTP adapter for controlling astronomical equipment through N.I.N.A. (Nighttime Imaging 'N' Astronomy) Advanced API.
|
|
22
|
+
https://bump.sh/christian-photo/doc/advanced-api/"""
|
|
23
|
+
|
|
24
|
+
DEFAULT_FOCUS_POSITION = 9000
|
|
25
|
+
CAM_URL = "/equipment/camera/"
|
|
26
|
+
FILTERWHEEL_URL = "/equipment/filterwheel/"
|
|
27
|
+
FOCUSER_URL = "/equipment/focuser/"
|
|
28
|
+
MOUNT_URL = "/equipment/mount/"
|
|
29
|
+
SAFETYMON_URL = "/equipment/safetymonitor/"
|
|
30
|
+
SEQUENCE_URL = "/sequence/"
|
|
31
|
+
|
|
32
|
+
def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
|
|
33
|
+
super().__init__(images_dir=images_dir)
|
|
34
|
+
self.logger: logging.Logger = logger
|
|
35
|
+
self._data_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR))
|
|
36
|
+
self._focus_positions_file = self._data_dir / "nina_focus_positions.json"
|
|
37
|
+
self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
|
|
38
|
+
self.bypass_autofocus = kwargs.get("bypass_autofocus", False)
|
|
39
|
+
|
|
40
|
+
self.filter_map = {}
|
|
41
|
+
self._load_focus_positions()
|
|
42
|
+
|
|
43
|
+
def _load_focus_positions(self):
|
|
44
|
+
"""Load focus positions from file if available."""
|
|
45
|
+
try:
|
|
46
|
+
if self._focus_positions_file.exists():
|
|
47
|
+
with open(self._focus_positions_file, "r") as f:
|
|
48
|
+
focus_data = json.load(f)
|
|
49
|
+
if self.logger:
|
|
50
|
+
self.logger.info(f"Loaded focus positions from {self._focus_positions_file}")
|
|
51
|
+
self._focus_positions_cache = focus_data
|
|
52
|
+
else:
|
|
53
|
+
self._focus_positions_cache = {}
|
|
54
|
+
except Exception as e:
|
|
55
|
+
if self.logger:
|
|
56
|
+
self.logger.warning(f"Could not load focus positions file: {e}")
|
|
57
|
+
self._focus_positions_cache = {}
|
|
58
|
+
|
|
59
|
+
def _save_focus_positions(self):
|
|
60
|
+
"""Save current filter_map focus positions to file."""
|
|
61
|
+
try:
|
|
62
|
+
# Ensure data directory exists
|
|
63
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
focus_data = {
|
|
66
|
+
str(fid): {"name": fdata["name"], "focus_position": fdata["focus_position"]}
|
|
67
|
+
for fid, fdata in self.filter_map.items()
|
|
68
|
+
}
|
|
69
|
+
with open(self._focus_positions_file, "w") as f:
|
|
70
|
+
json.dump(focus_data, f, indent=2)
|
|
71
|
+
if self.logger:
|
|
72
|
+
self.logger.info(f"Saved focus positions to {self._focus_positions_file}")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
if self.logger:
|
|
75
|
+
self.logger.warning(f"Could not save focus positions file: {e}")
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
79
|
+
"""
|
|
80
|
+
Return a schema describing configurable settings for the NINA Advanced HTTP adapter.
|
|
81
|
+
"""
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
"name": "nina_api_path",
|
|
85
|
+
"friendly_name": "N.I.N.A. API URL",
|
|
86
|
+
"type": "str",
|
|
87
|
+
"default": "http://nina:1888/v2/api",
|
|
88
|
+
"description": "Base URL for the NINA Advanced HTTP API",
|
|
89
|
+
"required": True,
|
|
90
|
+
"placeholder": "http://localhost:1888/v2/api",
|
|
91
|
+
"pattern": r"^https?://.*",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "bypass_autofocus",
|
|
95
|
+
"friendly_name": "Bypass Autofocus",
|
|
96
|
+
"type": "bool",
|
|
97
|
+
"default": False,
|
|
98
|
+
"description": "Skip autofocus routine when initializing, will use cached focus positions if available",
|
|
99
|
+
"required": False,
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def do_autofocus(self):
|
|
104
|
+
self.logger.info("Performing autofocus routine ...")
|
|
105
|
+
# move telescope to bright star and start autofocus
|
|
106
|
+
# Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
|
|
107
|
+
ra = (1 + 9 / 60.0 + 47.45 / 3600.0) * 15
|
|
108
|
+
dec = 35 + 37 / 60.0 + 11.1 / 3600.0
|
|
109
|
+
|
|
110
|
+
self.logger.info("Slewing to Mirach ...")
|
|
111
|
+
mount_status = requests.get(
|
|
112
|
+
self.nina_api_path + self.MOUNT_URL + "slew?ra=" + str(ra) + "&dec=" + str(dec)
|
|
113
|
+
).json()
|
|
114
|
+
self.logger.info(f"Mount {mount_status['Response']}")
|
|
115
|
+
|
|
116
|
+
# wait for slew to complete
|
|
117
|
+
while self.telescope_is_moving():
|
|
118
|
+
self.logger.info("Waiting for mount to finish slewing...")
|
|
119
|
+
time.sleep(5)
|
|
120
|
+
|
|
121
|
+
for id, filter in self.filter_map.items():
|
|
122
|
+
self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
|
|
123
|
+
focus_value = self._auto_focus_one_filter(id, filter["name"])
|
|
124
|
+
self.filter_map[id]["focus_position"] = focus_value
|
|
125
|
+
|
|
126
|
+
# Save focus positions after autofocus
|
|
127
|
+
self._save_focus_positions()
|
|
128
|
+
|
|
129
|
+
# autofocus routine
|
|
130
|
+
def _auto_focus_one_filter(self, filter_id, filter_name) -> int:
|
|
131
|
+
|
|
132
|
+
# change to the requested filter
|
|
133
|
+
correct_filter_in_place = False
|
|
134
|
+
while not correct_filter_in_place:
|
|
135
|
+
requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "change-filter?filterId=" + str(filter_id))
|
|
136
|
+
filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
|
|
137
|
+
current_filter_id = filterwheel_status["Response"]["SelectedFilter"]["Id"]
|
|
138
|
+
if current_filter_id == filter_id:
|
|
139
|
+
correct_filter_in_place = True
|
|
140
|
+
else:
|
|
141
|
+
self.logger.info(f"Waiting for filterwheel to change to filter ID {filter_id} ...")
|
|
142
|
+
time.sleep(5)
|
|
143
|
+
|
|
144
|
+
# move to starting focus position
|
|
145
|
+
self.logger.info("Moving focus to autofocus starting position ...")
|
|
146
|
+
starting_focus_position = self.DEFAULT_FOCUS_POSITION
|
|
147
|
+
is_in_starting_position = False
|
|
148
|
+
while not is_in_starting_position:
|
|
149
|
+
focuser_status = requests.get(
|
|
150
|
+
self.nina_api_path + self.FOCUSER_URL + "move?position=" + str(starting_focus_position)
|
|
151
|
+
).json()
|
|
152
|
+
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "info").json()
|
|
153
|
+
if int(focuser_status["Response"]["Position"]) == starting_focus_position:
|
|
154
|
+
is_in_starting_position = True
|
|
155
|
+
else:
|
|
156
|
+
self.logger.info("Waiting for focuser to reach starting position ...")
|
|
157
|
+
time.sleep(5)
|
|
158
|
+
|
|
159
|
+
# start autofocus
|
|
160
|
+
self.logger.info("Starting autofocus ...")
|
|
161
|
+
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "auto-focus").json()
|
|
162
|
+
self.logger.info(f"Focuser {focuser_status['Response']}")
|
|
163
|
+
|
|
164
|
+
last_completed_autofocus = requests.get(self.nina_api_path + self.FOCUSER_URL + "last-af").json()
|
|
165
|
+
|
|
166
|
+
if not last_completed_autofocus.get("Success"):
|
|
167
|
+
self.logger.error(f"Failed to start autofocus: {last_completed_autofocus.get('Error')}")
|
|
168
|
+
self.logger.warning("Using default focus position")
|
|
169
|
+
return starting_focus_position
|
|
170
|
+
|
|
171
|
+
while (
|
|
172
|
+
last_completed_autofocus["Response"]["Filter"] != filter_name
|
|
173
|
+
or last_completed_autofocus["Response"]["InitialFocusPoint"]["Position"] != starting_focus_position
|
|
174
|
+
):
|
|
175
|
+
self.logger.info("Waiting autofocus")
|
|
176
|
+
last_completed_autofocus = requests.get(self.nina_api_path + self.FOCUSER_URL + "last-af").json()
|
|
177
|
+
time.sleep(15)
|
|
178
|
+
|
|
179
|
+
autofocused_position = last_completed_autofocus["Response"]["CalculatedFocusPoint"]["Position"]
|
|
180
|
+
autofocused_value = last_completed_autofocus["Response"]["CalculatedFocusPoint"]["Value"]
|
|
181
|
+
|
|
182
|
+
self.logger.info(
|
|
183
|
+
f"Autofocus complete for filter {filter_name}: Position {autofocused_position}, HFR {autofocused_value}"
|
|
184
|
+
)
|
|
185
|
+
return autofocused_position
|
|
186
|
+
|
|
187
|
+
def _do_point_telescope(self, ra: float, dec: float):
|
|
188
|
+
self.logger.info(f"Slewing to RA: {ra}, Dec: {dec}")
|
|
189
|
+
slew_response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}").json()
|
|
190
|
+
|
|
191
|
+
if slew_response.get("Success"):
|
|
192
|
+
self.logger.info(f"Mount slew initiated: {slew_response['Response']}")
|
|
193
|
+
return True
|
|
194
|
+
else:
|
|
195
|
+
self.logger.error(f"Failed to slew mount: {slew_response.get('Error')}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def connect(self) -> bool:
|
|
199
|
+
try:
|
|
200
|
+
# start connection to all equipments
|
|
201
|
+
self.logger.info("Connecting camera ...")
|
|
202
|
+
cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect").json()
|
|
203
|
+
if not cam_status["Success"]:
|
|
204
|
+
self.logger.error(f"Failed to connect camera: {cam_status.get('Error')}")
|
|
205
|
+
return False
|
|
206
|
+
self.logger.info(f"Camera Connected!")
|
|
207
|
+
|
|
208
|
+
self.logger.info("Starting camera cooling ...")
|
|
209
|
+
cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool").json()
|
|
210
|
+
if not cool_status["Success"]:
|
|
211
|
+
self.logger.warning(f"Failed to start camera cooling: {cool_status.get('Error')}")
|
|
212
|
+
else:
|
|
213
|
+
self.logger.info("Cooler started!")
|
|
214
|
+
|
|
215
|
+
self.logger.info("Connecting filterwheel ...")
|
|
216
|
+
filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect").json()
|
|
217
|
+
if not filterwheel_status["Success"]:
|
|
218
|
+
self.logger.warning(f"Failed to connect filterwheel: {filterwheel_status.get('Error')}")
|
|
219
|
+
else:
|
|
220
|
+
self.logger.info(f"Filterwheel Connected!")
|
|
221
|
+
|
|
222
|
+
self.logger.info("Connecting focuser ...")
|
|
223
|
+
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect").json()
|
|
224
|
+
if not focuser_status["Success"]:
|
|
225
|
+
self.logger.warning(f"Failed to connect focuser: {focuser_status.get('Error')}")
|
|
226
|
+
else:
|
|
227
|
+
self.logger.info(f"Focuser Connected!")
|
|
228
|
+
|
|
229
|
+
self.logger.info("Connecting mount ...")
|
|
230
|
+
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect").json()
|
|
231
|
+
if not mount_status["Success"]:
|
|
232
|
+
self.logger.error(f"Failed to connect mount: {mount_status.get('Error')}")
|
|
233
|
+
return False
|
|
234
|
+
self.logger.info(f"Mount Connected!")
|
|
235
|
+
|
|
236
|
+
self.logger.info("Unparking mount ...")
|
|
237
|
+
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark").json()
|
|
238
|
+
if not mount_status["Success"]:
|
|
239
|
+
self.logger.error(f"Failed to unpark mount: {mount_status.get('Error')}")
|
|
240
|
+
return False
|
|
241
|
+
self.logger.info(f"Mount Unparked!")
|
|
242
|
+
|
|
243
|
+
# make a map of available filters and their focus positions
|
|
244
|
+
self.discover_filters()
|
|
245
|
+
if not self.bypass_autofocus:
|
|
246
|
+
self.do_autofocus()
|
|
247
|
+
else:
|
|
248
|
+
self.logger.info("Bypassing autofocus routine as requested")
|
|
249
|
+
|
|
250
|
+
return True
|
|
251
|
+
except Exception as e:
|
|
252
|
+
self.logger.error(f"Failed to connect to NINA Advanced API: {e}")
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
def discover_filters(self):
|
|
256
|
+
self.logger.info("Discovering filters ...")
|
|
257
|
+
filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
|
|
258
|
+
if not filterwheel_info.get("Success"):
|
|
259
|
+
self.logger.error(f"Failed to get filterwheel info: {filterwheel_info.get('Error')}")
|
|
260
|
+
raise RuntimeError("Failed to get filterwheel info")
|
|
261
|
+
|
|
262
|
+
filters = filterwheel_info["Response"]["AvailableFilters"]
|
|
263
|
+
for filter in filters:
|
|
264
|
+
filter_id = filter["Id"]
|
|
265
|
+
filter_name = filter["Name"]
|
|
266
|
+
# Try to load focus position from cache, fallback to default
|
|
267
|
+
focus_position = self._focus_positions_cache.get(str(filter_id), {}).get(
|
|
268
|
+
"focus_position", self.DEFAULT_FOCUS_POSITION
|
|
269
|
+
)
|
|
270
|
+
self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
|
|
271
|
+
self.logger.info(f"Discovered filter: {filter_name} with ID: {filter_id}, focus position: {focus_position}")
|
|
272
|
+
|
|
273
|
+
def disconnect(self):
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
def is_telescope_connected(self) -> bool:
|
|
277
|
+
"""Check if telescope is connected and responsive."""
|
|
278
|
+
try:
|
|
279
|
+
mount_info = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}info", timeout=2).json()
|
|
280
|
+
return mount_info.get("Success", False) and mount_info.get("Response", {}).get("Connected", False)
|
|
281
|
+
except Exception:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def is_camera_connected(self) -> bool:
|
|
285
|
+
"""Check if camera is connected and responsive."""
|
|
286
|
+
try:
|
|
287
|
+
cam_info = requests.get(f"{self.nina_api_path}{self.CAM_URL}info", timeout=2).json()
|
|
288
|
+
return cam_info.get("Success", False) and cam_info.get("Response", {}).get("Connected", False)
|
|
289
|
+
except Exception:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
def list_devices(self) -> list[str]:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
def select_telescope(self, device_name: str) -> bool:
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
def get_telescope_direction(self) -> tuple[float, float]:
|
|
299
|
+
mount_info = requests.get(self.nina_api_path + self.MOUNT_URL + "info").json()
|
|
300
|
+
if mount_info.get("Success"):
|
|
301
|
+
ra_degrees = mount_info["Response"]["Coordinates"]["RADegrees"]
|
|
302
|
+
dec_degrees = mount_info["Response"]["Coordinates"]["Dec"]
|
|
303
|
+
return (ra_degrees, dec_degrees)
|
|
304
|
+
else:
|
|
305
|
+
self.logger.error(f"Failed to get telescope direction: {mount_info.get('Error')}")
|
|
306
|
+
raise RuntimeError(f"Failed to get mount info: {mount_info.get('Error')}")
|
|
307
|
+
|
|
308
|
+
def telescope_is_moving(self) -> bool:
|
|
309
|
+
mount_info = requests.get(self.nina_api_path + self.MOUNT_URL + "info").json()
|
|
310
|
+
if mount_info.get("Success"):
|
|
311
|
+
return mount_info["Response"]["Slewing"]
|
|
312
|
+
else:
|
|
313
|
+
self.logger.error(f"Failed to get telescope status: {mount_info.get('Error')}")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def select_camera(self, device_name: str) -> bool:
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def take_image(self, task_id: str, exposure_duration_seconds=1) -> str:
|
|
320
|
+
raise NotImplementedError
|
|
321
|
+
|
|
322
|
+
def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
|
|
323
|
+
pass # TODO: make real
|
|
324
|
+
|
|
325
|
+
def get_tracking_rate(self) -> tuple[float, float]:
|
|
326
|
+
return (0, 0) # TODO: make real
|
|
327
|
+
|
|
328
|
+
def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
|
|
329
|
+
return True # TODO: make real
|
|
330
|
+
|
|
331
|
+
def _get_sequence_template(self) -> str:
|
|
332
|
+
"""Load the sequence template as a string for placeholder replacement."""
|
|
333
|
+
template_path = os.path.join(os.path.dirname(__file__), "nina_adv_http_survey_template.json")
|
|
334
|
+
with open(template_path, "r") as f:
|
|
335
|
+
return f.read()
|
|
336
|
+
|
|
337
|
+
def get_observation_strategy(self) -> ObservationStrategy:
|
|
338
|
+
return ObservationStrategy.SEQUENCE_TO_CONTROLLER
|
|
339
|
+
|
|
340
|
+
def _find_by_id(self, data, target_id):
|
|
341
|
+
"""Recursively search for an item with a specific $id in the JSON structure.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
data: The JSON data structure to search (dict, list, or primitive)
|
|
345
|
+
target_id: The $id value to search for (as string)
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
The item with the matching $id, or None if not found
|
|
349
|
+
"""
|
|
350
|
+
if isinstance(data, dict):
|
|
351
|
+
if data.get("$id") == target_id:
|
|
352
|
+
return data
|
|
353
|
+
for value in data.values():
|
|
354
|
+
result = self._find_by_id(value, target_id)
|
|
355
|
+
if result is not None:
|
|
356
|
+
return result
|
|
357
|
+
elif isinstance(data, list):
|
|
358
|
+
for item in data:
|
|
359
|
+
result = self._find_by_id(item, target_id)
|
|
360
|
+
if result is not None:
|
|
361
|
+
return result
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def _get_max_id(self, data):
|
|
365
|
+
"""Recursively find the maximum $id value in the JSON structure.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
data: The JSON data structure to search (dict, list, or primitive)
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
The maximum numeric $id value found, or 0 if none found
|
|
372
|
+
"""
|
|
373
|
+
max_id = 0
|
|
374
|
+
if isinstance(data, dict):
|
|
375
|
+
if "$id" in data:
|
|
376
|
+
try:
|
|
377
|
+
max_id = max(max_id, int(data["$id"]))
|
|
378
|
+
except (ValueError, TypeError):
|
|
379
|
+
pass
|
|
380
|
+
for value in data.values():
|
|
381
|
+
max_id = max(max_id, self._get_max_id(value))
|
|
382
|
+
elif isinstance(data, list):
|
|
383
|
+
for item in data:
|
|
384
|
+
max_id = max(max_id, self._get_max_id(item))
|
|
385
|
+
return max_id
|
|
386
|
+
|
|
387
|
+
def _update_all_ids(self, data, id_counter):
|
|
388
|
+
"""Recursively update all $id values in a data structure.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
data: The JSON data structure to update (dict, list, or primitive)
|
|
392
|
+
id_counter: A list with a single integer element [current_id] that gets incremented
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
None (modifies data in place)
|
|
396
|
+
"""
|
|
397
|
+
if isinstance(data, dict):
|
|
398
|
+
if "$id" in data:
|
|
399
|
+
data["$id"] = str(id_counter[0])
|
|
400
|
+
id_counter[0] += 1
|
|
401
|
+
for value in data.values():
|
|
402
|
+
self._update_all_ids(value, id_counter)
|
|
403
|
+
elif isinstance(data, list):
|
|
404
|
+
for item in data:
|
|
405
|
+
self._update_all_ids(item, id_counter)
|
|
406
|
+
|
|
407
|
+
def perform_observation_sequence(self, task_id, satellite_data) -> str | list[str]:
|
|
408
|
+
"""Create and execute a NINA sequence for the given satellite.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
task_id: Unique identifier for this observation task
|
|
412
|
+
satellite_data: Satellite data including TLE information
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
str: Path to the captured image
|
|
416
|
+
"""
|
|
417
|
+
elset = satellite_data["most_recent_elset"]
|
|
418
|
+
|
|
419
|
+
# Load template as JSON
|
|
420
|
+
template_str = self._get_sequence_template()
|
|
421
|
+
sequence_json = json.loads(template_str)
|
|
422
|
+
|
|
423
|
+
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task_id}"
|
|
424
|
+
|
|
425
|
+
# Replace basic placeholders
|
|
426
|
+
tle_data = f"{elset['tle'][0]}\n{elset['tle'][1]}"
|
|
427
|
+
sequence_json["Name"] = nina_sequence_name
|
|
428
|
+
|
|
429
|
+
# Navigate to the TLE container (ID 20 in the template)
|
|
430
|
+
target_container = self._find_by_id(sequence_json, "20")
|
|
431
|
+
if not target_container:
|
|
432
|
+
raise RuntimeError("Could not find TLE container (ID 20) in sequence template")
|
|
433
|
+
|
|
434
|
+
target_container["TLEData"] = tle_data
|
|
435
|
+
target_container["Name"] = satellite_data["name"]
|
|
436
|
+
target_container["Target"]["TargetName"] = satellite_data["name"]
|
|
437
|
+
|
|
438
|
+
# Find the TLE control item and update it
|
|
439
|
+
tle_items = target_container["Items"]["$values"]
|
|
440
|
+
for item in tle_items:
|
|
441
|
+
if item.get("$type") == "DaleGhent.NINA.PlaneWaveTools.TLE.TLEControl, PlaneWave Tools":
|
|
442
|
+
item["Line1"] = elset["tle"][0]
|
|
443
|
+
item["Line2"] = elset["tle"][1]
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
# Find the template triplet (filter/focus/expose) - should be items 1, 2, 3
|
|
447
|
+
# (item 0 is TLE control)
|
|
448
|
+
template_triplet = tle_items[1:4] # SwitchFilter, MoveFocuserAbsolute, TakeExposure
|
|
449
|
+
|
|
450
|
+
# Clear the items list and rebuild with TLE control + triplets for each filter
|
|
451
|
+
new_items = [tle_items[0]] # Keep TLE control as first item
|
|
452
|
+
|
|
453
|
+
# Generate triplet for each discovered filter
|
|
454
|
+
# Find the maximum ID in use and start after it to avoid collisions
|
|
455
|
+
base_id = self._get_max_id(sequence_json) + 1
|
|
456
|
+
self.logger.debug(f"Starting dynamic ID generation at {base_id}")
|
|
457
|
+
|
|
458
|
+
id_counter = [base_id] # Use list so it can be modified in nested function
|
|
459
|
+
|
|
460
|
+
for filter_id, filter_info in self.filter_map.items():
|
|
461
|
+
filter_name = filter_info["name"]
|
|
462
|
+
focus_position = filter_info["focus_position"]
|
|
463
|
+
|
|
464
|
+
# Create a deep copy of the triplet and update ALL nested IDs
|
|
465
|
+
filter_triplet = json.loads(json.dumps(template_triplet))
|
|
466
|
+
self._update_all_ids(filter_triplet, id_counter)
|
|
467
|
+
|
|
468
|
+
# Update filter switch (first item in triplet)
|
|
469
|
+
filter_triplet[0]["Filter"]["_name"] = filter_name
|
|
470
|
+
filter_triplet[0]["Filter"]["_position"] = filter_id
|
|
471
|
+
|
|
472
|
+
# Update focus position (second item in triplet)
|
|
473
|
+
filter_triplet[1]["Position"] = focus_position
|
|
474
|
+
|
|
475
|
+
# Exposure settings (third item) are already set from template
|
|
476
|
+
|
|
477
|
+
# Add this triplet to the sequence
|
|
478
|
+
new_items.extend(filter_triplet)
|
|
479
|
+
|
|
480
|
+
self.logger.info(f"Added filter {filter_name} (ID: {filter_id}) with focus position {focus_position}")
|
|
481
|
+
|
|
482
|
+
# Update the items list
|
|
483
|
+
tle_items.clear()
|
|
484
|
+
tle_items.extend(new_items)
|
|
485
|
+
|
|
486
|
+
# Convert back to JSON string
|
|
487
|
+
template_str = json.dumps(sequence_json, indent=2)
|
|
488
|
+
|
|
489
|
+
# POST the sequence
|
|
490
|
+
|
|
491
|
+
self.logger.info(f"Posting NINA sequence")
|
|
492
|
+
post_response = requests.post(f"{self.nina_api_path}{self.SEQUENCE_URL}load", json=sequence_json).json()
|
|
493
|
+
if not post_response.get("Success"):
|
|
494
|
+
self.logger.error(f"Failed to post sequence: {post_response.get('Error')}")
|
|
495
|
+
raise RuntimeError("Failed to post NINA sequence")
|
|
496
|
+
|
|
497
|
+
self.logger.info(f"Loaded sequence to NINA, starting sequence...")
|
|
498
|
+
|
|
499
|
+
start_response = requests.get(
|
|
500
|
+
f"{self.nina_api_path}{self.SEQUENCE_URL}start?skipValidation=true"
|
|
501
|
+
).json() # TODO: try and fix validation issues
|
|
502
|
+
if not start_response.get("Success"):
|
|
503
|
+
self.logger.error(f"Failed to start sequence: {start_response.get('Error')}")
|
|
504
|
+
raise RuntimeError("Failed to start NINA sequence")
|
|
505
|
+
|
|
506
|
+
timeout_minutes = 60
|
|
507
|
+
poll_interval_seconds = 10
|
|
508
|
+
elapsed_time = 0
|
|
509
|
+
status_response = None
|
|
510
|
+
while elapsed_time < timeout_minutes * 60:
|
|
511
|
+
status_response = requests.get(f"{self.nina_api_path}{self.SEQUENCE_URL}json").json()
|
|
512
|
+
|
|
513
|
+
start_status = status_response["Response"][1][
|
|
514
|
+
"Status"
|
|
515
|
+
] # these are also based on the hardcoded template sections for now...
|
|
516
|
+
targets_status = status_response["Response"][2]["Status"]
|
|
517
|
+
end_status = status_response["Response"][3]["Status"]
|
|
518
|
+
self.logger.debug(f"Sequence status - Start: {start_status}, Targets: {targets_status}, End: {end_status}")
|
|
519
|
+
|
|
520
|
+
if start_status == "FINISHED" and targets_status == "FINISHED" and end_status == "FINISHED":
|
|
521
|
+
self.logger.info(f"NINA sequence completed")
|
|
522
|
+
break
|
|
523
|
+
|
|
524
|
+
self.logger.info(f"NINA sequence still running, waiting {poll_interval_seconds} seconds...")
|
|
525
|
+
time.sleep(poll_interval_seconds)
|
|
526
|
+
elapsed_time += poll_interval_seconds
|
|
527
|
+
else:
|
|
528
|
+
self.logger.error(f"NINA sequence did not complete within timeout of {timeout_minutes} minutes")
|
|
529
|
+
raise RuntimeError("NINA sequence timeout")
|
|
530
|
+
|
|
531
|
+
# get a list of images taken in the sequence
|
|
532
|
+
self.logger.info(f"Retrieving list of images taken in sequence...")
|
|
533
|
+
images_response = requests.get(f"{self.nina_api_path}/image-history?all=true").json()
|
|
534
|
+
if not images_response.get("Success"):
|
|
535
|
+
self.logger.error(f"Failed to get images list: {images_response.get('Error')}")
|
|
536
|
+
raise RuntimeError("Failed to get images list from NINA")
|
|
537
|
+
|
|
538
|
+
images_to_download = []
|
|
539
|
+
expected_image_count = len(self.filter_map) # One image per filter
|
|
540
|
+
images_found = len(images_response["Response"])
|
|
541
|
+
self.logger.info(
|
|
542
|
+
f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
|
|
543
|
+
)
|
|
544
|
+
start_index = max(0, images_found - expected_image_count)
|
|
545
|
+
end_index = images_found
|
|
546
|
+
if images_found < expected_image_count:
|
|
547
|
+
self.logger.warning(f"Fewer images found ({images_found}) than expected ({expected_image_count})")
|
|
548
|
+
for i in range(start_index, end_index):
|
|
549
|
+
possible_image = images_response["Response"][i]
|
|
550
|
+
if "Filename" not in possible_image:
|
|
551
|
+
self.logger.warning(f"Image {i} has no filename in response, skipping")
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
if task_id in possible_image["Filename"]:
|
|
555
|
+
self.logger.info(f"Image {i} {possible_image['Filename']} matches task id")
|
|
556
|
+
images_to_download.append(i)
|
|
557
|
+
else:
|
|
558
|
+
self.logger.warning(
|
|
559
|
+
f"Image {i} {possible_image['Filename']} does not match task id, skipping. Please make sure NINA is configured to include Sequence Title in image filenames under Options > Imaging > Image File Pattern."
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# Get the most recent image from NINA (index 0) in raw FITS format
|
|
563
|
+
filepaths = []
|
|
564
|
+
for image_index in images_to_download:
|
|
565
|
+
self.logger.debug(f"Retrieving image from NINA...")
|
|
566
|
+
image_response = requests.get(
|
|
567
|
+
f"{self.nina_api_path}/image/{image_index}",
|
|
568
|
+
params={"raw_fits": "true"},
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
if image_response.status_code != 200:
|
|
572
|
+
self.logger.error(f"Failed to retrieve image: HTTP {image_response.status_code}")
|
|
573
|
+
raise RuntimeError("Failed to retrieve image from NINA")
|
|
574
|
+
|
|
575
|
+
image_data = image_response.json()
|
|
576
|
+
if not image_data.get("Success"):
|
|
577
|
+
self.logger.error(f"Failed to get image: {image_data.get('Error')}")
|
|
578
|
+
raise RuntimeError(f"Failed to get image from NINA: {image_data.get('Error')}")
|
|
579
|
+
|
|
580
|
+
# Decode base64 FITS data and save to file
|
|
581
|
+
fits_base64 = image_data["Response"]
|
|
582
|
+
fits_bytes = base64.b64decode(fits_base64)
|
|
583
|
+
|
|
584
|
+
# Save the FITS file
|
|
585
|
+
filepath = str(self.images_dir / f"citra_task_{task_id}_image_{image_index}.fits")
|
|
586
|
+
filepaths.append(filepath)
|
|
587
|
+
|
|
588
|
+
with open(filepath, "wb") as f:
|
|
589
|
+
f.write(fits_bytes)
|
|
590
|
+
|
|
591
|
+
self.logger.info(f"Saved FITS image to {filepath}")
|
|
592
|
+
|
|
593
|
+
return filepaths
|