citrascope 0.6.1__py3-none-any.whl → 0.8.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/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +97 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
- citrascope/hardware/adapter_registry.py +10 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +102 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +402 -0
- citrascope/hardware/devices/camera/ximea_camera.py +744 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +787 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +67 -96
- citrascope/hardware/nina_adv_http_adapter.py +81 -64
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -0
- citrascope/tasks/runner.py +105 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +13 -0
- citrascope/time/time_health.py +96 -0
- citrascope/time/time_monitor.py +164 -0
- citrascope/time/time_sources.py +62 -0
- citrascope/web/app.py +274 -51
- citrascope/web/static/app.js +379 -36
- citrascope/web/static/config.js +448 -108
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +176 -36
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.6.1.dist-info/RECORD +0 -41
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -30,22 +30,16 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
30
30
|
SEQUENCE_URL = "/sequence/"
|
|
31
31
|
|
|
32
32
|
def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
|
|
33
|
-
super().__init__(images_dir=images_dir)
|
|
33
|
+
super().__init__(images_dir=images_dir, **kwargs)
|
|
34
34
|
self.logger: logging.Logger = logger
|
|
35
35
|
self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
|
|
36
36
|
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for filter_id, filter_data in saved_filters.items():
|
|
41
|
-
# Convert string keys back to int for internal use
|
|
42
|
-
try:
|
|
43
|
-
self.filter_map[int(filter_id)] = filter_data
|
|
44
|
-
except (ValueError, TypeError) as e:
|
|
45
|
-
self.logger.warning(f"Invalid filter ID '{filter_id}' in settings, skipping: {e}")
|
|
37
|
+
self.binning_x = kwargs.get("binning_x", 1)
|
|
38
|
+
self.binning_y = kwargs.get("binning_y", 1)
|
|
39
|
+
self.autofocus_binning = kwargs.get("autofocus_binning", 1)
|
|
46
40
|
|
|
47
41
|
@classmethod
|
|
48
|
-
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
42
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
49
43
|
"""
|
|
50
44
|
Return a schema describing configurable settings for the NINA Advanced HTTP adapter.
|
|
51
45
|
"""
|
|
@@ -59,22 +53,64 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
59
53
|
"required": True,
|
|
60
54
|
"placeholder": "http://localhost:1888/v2/api",
|
|
61
55
|
"pattern": r"^https?://.*",
|
|
56
|
+
"group": "Connection",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "autofocus_binning",
|
|
60
|
+
"friendly_name": "Autofocus Binning",
|
|
61
|
+
"type": "int",
|
|
62
|
+
"default": 1,
|
|
63
|
+
"description": "Pixel binning for autofocus (1=no binning, 2=2x2, etc.)",
|
|
64
|
+
"required": False,
|
|
65
|
+
"placeholder": "1",
|
|
66
|
+
"min": 1,
|
|
67
|
+
"max": 4,
|
|
68
|
+
"group": "Imaging",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "binning_x",
|
|
72
|
+
"friendly_name": "Binning X",
|
|
73
|
+
"type": "int",
|
|
74
|
+
"default": 1,
|
|
75
|
+
"description": "Horizontal pixel binning for observations (1=no binning, 2=2x2, etc.)",
|
|
76
|
+
"required": False,
|
|
77
|
+
"placeholder": "1",
|
|
78
|
+
"min": 1,
|
|
79
|
+
"max": 4,
|
|
80
|
+
"group": "Imaging",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "binning_y",
|
|
84
|
+
"friendly_name": "Binning Y",
|
|
85
|
+
"type": "int",
|
|
86
|
+
"default": 1,
|
|
87
|
+
"description": "Vertical pixel binning for observations (1=no binning, 2=2x2, etc.)",
|
|
88
|
+
"required": False,
|
|
89
|
+
"placeholder": "1",
|
|
90
|
+
"min": 1,
|
|
91
|
+
"max": 4,
|
|
92
|
+
"group": "Imaging",
|
|
62
93
|
},
|
|
63
94
|
]
|
|
64
95
|
|
|
65
96
|
def do_autofocus(self):
|
|
66
|
-
"""Perform autofocus routine for all filters.
|
|
97
|
+
"""Perform autofocus routine for all enabled filters.
|
|
67
98
|
|
|
68
99
|
Slews telescope to Mirach (bright reference star) and runs autofocus
|
|
69
|
-
for each filter in the filter map, updating focus positions.
|
|
100
|
+
for each enabled filter in the filter map, updating focus positions.
|
|
70
101
|
|
|
71
102
|
Raises:
|
|
72
|
-
RuntimeError: If no filters discovered or
|
|
103
|
+
RuntimeError: If no filters discovered or no enabled filters
|
|
73
104
|
"""
|
|
74
105
|
if not self.filter_map:
|
|
75
106
|
raise RuntimeError("No filters discovered. Cannot perform autofocus.")
|
|
76
107
|
|
|
77
|
-
|
|
108
|
+
# Filter to only enabled filters
|
|
109
|
+
enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
|
|
110
|
+
if not enabled_filters:
|
|
111
|
+
raise RuntimeError("No enabled filters. Cannot perform autofocus.")
|
|
112
|
+
|
|
113
|
+
self.logger.info(f"Performing autofocus routine on {len(enabled_filters)} enabled filter(s) ...")
|
|
78
114
|
# move telescope to bright star and start autofocus
|
|
79
115
|
# Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
|
|
80
116
|
ra = (1 + 9 / 60.0 + 47.45 / 3600.0) * 15
|
|
@@ -96,7 +132,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
96
132
|
self.logger.info("Waiting for mount to finish slewing...")
|
|
97
133
|
time.sleep(5)
|
|
98
134
|
|
|
99
|
-
for id, filter in
|
|
135
|
+
for id, filter in enabled_filters.items():
|
|
100
136
|
self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
|
|
101
137
|
# Pass existing focus position to preserve it if autofocus fails
|
|
102
138
|
existing_focus = filter.get("focus_position", self.DEFAULT_FOCUS_POSITION)
|
|
@@ -190,42 +226,42 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
190
226
|
try:
|
|
191
227
|
# start connection to all equipments
|
|
192
228
|
self.logger.info("Connecting camera ...")
|
|
193
|
-
cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect").json()
|
|
229
|
+
cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect", timeout=5).json()
|
|
194
230
|
if not cam_status["Success"]:
|
|
195
231
|
self.logger.error(f"Failed to connect camera: {cam_status.get('Error')}")
|
|
196
232
|
return False
|
|
197
233
|
self.logger.info(f"Camera Connected!")
|
|
198
234
|
|
|
199
235
|
self.logger.info("Starting camera cooling ...")
|
|
200
|
-
cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool").json()
|
|
236
|
+
cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool", timeout=5).json()
|
|
201
237
|
if not cool_status["Success"]:
|
|
202
238
|
self.logger.warning(f"Failed to start camera cooling: {cool_status.get('Error')}")
|
|
203
239
|
else:
|
|
204
240
|
self.logger.info("Cooler started!")
|
|
205
241
|
|
|
206
242
|
self.logger.info("Connecting filterwheel ...")
|
|
207
|
-
filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect").json()
|
|
243
|
+
filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect", timeout=5).json()
|
|
208
244
|
if not filterwheel_status["Success"]:
|
|
209
245
|
self.logger.warning(f"Failed to connect filterwheel: {filterwheel_status.get('Error')}")
|
|
210
246
|
else:
|
|
211
247
|
self.logger.info(f"Filterwheel Connected!")
|
|
212
248
|
|
|
213
249
|
self.logger.info("Connecting focuser ...")
|
|
214
|
-
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect").json()
|
|
250
|
+
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect", timeout=5).json()
|
|
215
251
|
if not focuser_status["Success"]:
|
|
216
252
|
self.logger.warning(f"Failed to connect focuser: {focuser_status.get('Error')}")
|
|
217
253
|
else:
|
|
218
254
|
self.logger.info(f"Focuser Connected!")
|
|
219
255
|
|
|
220
256
|
self.logger.info("Connecting mount ...")
|
|
221
|
-
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect").json()
|
|
257
|
+
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect", timeout=5).json()
|
|
222
258
|
if not mount_status["Success"]:
|
|
223
259
|
self.logger.error(f"Failed to connect mount: {mount_status.get('Error')}")
|
|
224
260
|
return False
|
|
225
261
|
self.logger.info(f"Mount Connected!")
|
|
226
262
|
|
|
227
263
|
self.logger.info("Unparking mount ...")
|
|
228
|
-
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark").json()
|
|
264
|
+
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark", timeout=5).json()
|
|
229
265
|
if not mount_status["Success"]:
|
|
230
266
|
self.logger.error(f"Failed to unpark mount: {mount_status.get('Error')}")
|
|
231
267
|
return False
|
|
@@ -241,7 +277,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
241
277
|
|
|
242
278
|
def discover_filters(self):
|
|
243
279
|
self.logger.info("Discovering filters ...")
|
|
244
|
-
filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
|
|
280
|
+
filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info", timeout=5).json()
|
|
245
281
|
if not filterwheel_info.get("Success"):
|
|
246
282
|
self.logger.error(f"Failed to get filterwheel info: {filterwheel_info.get('Error')}")
|
|
247
283
|
raise RuntimeError("Failed to get filterwheel info")
|
|
@@ -250,58 +286,33 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
250
286
|
for filter in filters:
|
|
251
287
|
filter_id = filter["Id"]
|
|
252
288
|
filter_name = filter["Name"]
|
|
253
|
-
# Use existing focus position if filter already in map
|
|
289
|
+
# Use existing focus position and enabled state if filter already in map
|
|
254
290
|
if filter_id in self.filter_map:
|
|
255
291
|
focus_position = self.filter_map[filter_id].get("focus_position", self.DEFAULT_FOCUS_POSITION)
|
|
292
|
+
enabled = self.filter_map[filter_id].get("enabled", True)
|
|
256
293
|
self.logger.info(
|
|
257
|
-
f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}"
|
|
294
|
+
f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}, enabled: {enabled}"
|
|
258
295
|
)
|
|
259
296
|
else:
|
|
260
297
|
focus_position = self.DEFAULT_FOCUS_POSITION
|
|
298
|
+
enabled = True # Default new filters to enabled
|
|
261
299
|
self.logger.info(
|
|
262
300
|
f"Discovered new filter: {filter_name} with ID: {filter_id}, using default focus position: {focus_position}"
|
|
263
301
|
)
|
|
264
302
|
|
|
265
|
-
self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
|
|
303
|
+
self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position, "enabled": enabled}
|
|
266
304
|
|
|
267
305
|
def disconnect(self):
|
|
268
306
|
pass
|
|
269
307
|
|
|
308
|
+
def supports_autofocus(self) -> bool:
|
|
309
|
+
"""Indicates that NINA adapter supports autofocus."""
|
|
310
|
+
return True
|
|
311
|
+
|
|
270
312
|
def supports_filter_management(self) -> bool:
|
|
271
313
|
"""Indicates that NINA adapter supports filter/focus management."""
|
|
272
314
|
return True
|
|
273
315
|
|
|
274
|
-
def get_filter_config(self) -> dict[str, FilterConfig]:
|
|
275
|
-
"""Get current filter configuration with focus positions.
|
|
276
|
-
|
|
277
|
-
Returns:
|
|
278
|
-
dict: Filter configuration mapping filter ID strings to FilterConfig
|
|
279
|
-
"""
|
|
280
|
-
return {
|
|
281
|
-
str(filter_id): {"name": filter_data["name"], "focus_position": filter_data["focus_position"]}
|
|
282
|
-
for filter_id, filter_data in self.filter_map.items()
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
|
|
286
|
-
"""Update the focus position for a specific filter.
|
|
287
|
-
|
|
288
|
-
Args:
|
|
289
|
-
filter_id: Filter ID as string
|
|
290
|
-
focus_position: New focus position in steps
|
|
291
|
-
|
|
292
|
-
Returns:
|
|
293
|
-
bool: True if update was successful, False otherwise
|
|
294
|
-
"""
|
|
295
|
-
try:
|
|
296
|
-
filter_id_int = int(filter_id)
|
|
297
|
-
if filter_id_int in self.filter_map:
|
|
298
|
-
self.filter_map[filter_id_int]["focus_position"] = focus_position
|
|
299
|
-
self.logger.info(f"Updated filter {filter_id} focus position to {focus_position}")
|
|
300
|
-
return True
|
|
301
|
-
return False
|
|
302
|
-
except (ValueError, KeyError):
|
|
303
|
-
return False
|
|
304
|
-
|
|
305
316
|
def is_telescope_connected(self) -> bool:
|
|
306
317
|
"""Check if telescope is connected and responsive."""
|
|
307
318
|
try:
|
|
@@ -433,11 +444,11 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
433
444
|
for item in data:
|
|
434
445
|
self._update_all_ids(item, id_counter)
|
|
435
446
|
|
|
436
|
-
def perform_observation_sequence(self,
|
|
447
|
+
def perform_observation_sequence(self, task, satellite_data) -> str | list[str]:
|
|
437
448
|
"""Create and execute a NINA sequence for the given satellite.
|
|
438
449
|
|
|
439
450
|
Args:
|
|
440
|
-
|
|
451
|
+
task: Task object containing id and filter assignment
|
|
441
452
|
satellite_data: Satellite data including TLE information
|
|
442
453
|
|
|
443
454
|
Returns:
|
|
@@ -445,11 +456,14 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
445
456
|
"""
|
|
446
457
|
elset = satellite_data["most_recent_elset"]
|
|
447
458
|
|
|
448
|
-
# Load template as JSON
|
|
459
|
+
# Load template as JSON and replace binning placeholders
|
|
449
460
|
template_str = self._get_sequence_template()
|
|
461
|
+
template_str = template_str.replace("{binning_x}", str(self.binning_x))
|
|
462
|
+
template_str = template_str.replace("{binning_y}", str(self.binning_y))
|
|
463
|
+
template_str = template_str.replace("{autofocus_binning}", str(self.autofocus_binning))
|
|
450
464
|
sequence_json = json.loads(template_str)
|
|
451
465
|
|
|
452
|
-
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {
|
|
466
|
+
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task.id}"
|
|
453
467
|
|
|
454
468
|
# Replace basic placeholders (use \r\n for Windows NINA compatibility)
|
|
455
469
|
tle_data = f"{elset['tle'][0]}\r\n{elset['tle'][1]}"
|
|
@@ -486,7 +500,10 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
486
500
|
|
|
487
501
|
id_counter = [base_id] # Use list so it can be modified in nested function
|
|
488
502
|
|
|
489
|
-
|
|
503
|
+
# Select filters to use for this task
|
|
504
|
+
filters_to_use = self.select_filters_for_task(task, allow_no_filter=False)
|
|
505
|
+
|
|
506
|
+
for filter_id, filter_info in filters_to_use.items():
|
|
490
507
|
filter_name = filter_info["name"]
|
|
491
508
|
focus_position = filter_info["focus_position"]
|
|
492
509
|
|
|
@@ -506,7 +523,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
506
523
|
# Add this triplet to the sequence
|
|
507
524
|
new_items.extend(filter_triplet)
|
|
508
525
|
|
|
509
|
-
self.logger.
|
|
526
|
+
self.logger.debug(f"Added filter {filter_name} (ID: {filter_id}) with focus position {focus_position}")
|
|
510
527
|
|
|
511
528
|
# Update the items list
|
|
512
529
|
tle_items.clear()
|
|
@@ -565,7 +582,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
565
582
|
raise RuntimeError("Failed to get images list from NINA")
|
|
566
583
|
|
|
567
584
|
images_to_download = []
|
|
568
|
-
expected_image_count = len(
|
|
585
|
+
expected_image_count = len(filters_to_use) # One image per filter in sequence
|
|
569
586
|
images_found = len(images_response["Response"])
|
|
570
587
|
self.logger.info(
|
|
571
588
|
f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
|
|
@@ -174,8 +174,8 @@
|
|
|
174
174
|
"_autoFocusBinning": {
|
|
175
175
|
"$id": "31",
|
|
176
176
|
"$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
|
|
177
|
-
"X":
|
|
178
|
-
"Y":
|
|
177
|
+
"X": {autofocus_binning},
|
|
178
|
+
"Y": {autofocus_binning}
|
|
179
179
|
},
|
|
180
180
|
"_autoFocusGain": -1,
|
|
181
181
|
"_autoFocusOffset": -1
|
|
@@ -205,8 +205,8 @@
|
|
|
205
205
|
"Binning": {
|
|
206
206
|
"$id": "34",
|
|
207
207
|
"$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
|
|
208
|
-
"X":
|
|
209
|
-
"Y":
|
|
208
|
+
"X": {binning_x},
|
|
209
|
+
"Y": {binning_y}
|
|
210
210
|
},
|
|
211
211
|
"ImageType": "LIGHT",
|
|
212
212
|
"ExposureCount": 130,
|
|
@@ -65,6 +65,26 @@ class CitraScopeSettings:
|
|
|
65
65
|
self.file_logging_enabled: bool = config.get("file_logging_enabled", True)
|
|
66
66
|
self.log_retention_days: int = config.get("log_retention_days", 30)
|
|
67
67
|
|
|
68
|
+
# Autofocus configuration (top-level/global settings)
|
|
69
|
+
self.scheduled_autofocus_enabled: bool = config.get("scheduled_autofocus_enabled", False)
|
|
70
|
+
self.autofocus_interval_minutes: int = config.get("autofocus_interval_minutes", 60)
|
|
71
|
+
self.last_autofocus_timestamp: Optional[int] = config.get("last_autofocus_timestamp")
|
|
72
|
+
|
|
73
|
+
# Validate autofocus interval
|
|
74
|
+
if (
|
|
75
|
+
not isinstance(self.autofocus_interval_minutes, int)
|
|
76
|
+
or self.autofocus_interval_minutes < 1
|
|
77
|
+
or self.autofocus_interval_minutes > 1439
|
|
78
|
+
):
|
|
79
|
+
CITRASCOPE_LOGGER.warning(
|
|
80
|
+
f"Invalid autofocus_interval_minutes ({self.autofocus_interval_minutes}). Setting to default 60 minutes."
|
|
81
|
+
)
|
|
82
|
+
self.autofocus_interval_minutes = 60
|
|
83
|
+
|
|
84
|
+
# Time synchronization monitoring configuration (always enabled)
|
|
85
|
+
self.time_check_interval_minutes: int = config.get("time_check_interval_minutes", 5)
|
|
86
|
+
self.time_offset_pause_ms: float = config.get("time_offset_pause_ms", 500.0)
|
|
87
|
+
|
|
68
88
|
def get_images_dir(self) -> Path:
|
|
69
89
|
"""Get the path to the images directory.
|
|
70
90
|
|
|
@@ -107,6 +127,11 @@ class CitraScopeSettings:
|
|
|
107
127
|
"max_retry_delay_seconds": self.max_retry_delay_seconds,
|
|
108
128
|
"file_logging_enabled": self.file_logging_enabled,
|
|
109
129
|
"log_retention_days": self.log_retention_days,
|
|
130
|
+
"scheduled_autofocus_enabled": self.scheduled_autofocus_enabled,
|
|
131
|
+
"autofocus_interval_minutes": self.autofocus_interval_minutes,
|
|
132
|
+
"last_autofocus_timestamp": self.last_autofocus_timestamp,
|
|
133
|
+
"time_check_interval_minutes": self.time_check_interval_minutes,
|
|
134
|
+
"time_offset_pause_ms": self.time_offset_pause_ms,
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
def save(self) -> None:
|
citrascope/tasks/runner.py
CHANGED
|
@@ -43,6 +43,11 @@ class TaskManager:
|
|
|
43
43
|
# Task processing control (always starts active)
|
|
44
44
|
self._processing_active = True
|
|
45
45
|
self._processing_lock = threading.Lock()
|
|
46
|
+
# Autofocus request flag (set by manual or scheduled triggers)
|
|
47
|
+
self._autofocus_requested = False
|
|
48
|
+
self._autofocus_lock = threading.Lock()
|
|
49
|
+
# Automated scheduling state (initialized from server on startup)
|
|
50
|
+
self._automated_scheduling = telescope_record.get("automatedScheduling", False) if telescope_record else False
|
|
46
51
|
|
|
47
52
|
def poll_tasks(self):
|
|
48
53
|
while not self._stop_event.is_set():
|
|
@@ -219,6 +224,20 @@ class TaskManager:
|
|
|
219
224
|
except Exception as e:
|
|
220
225
|
self.logger.error(f"Exception in task_runner loop: {e}", exc_info=True)
|
|
221
226
|
time.sleep(5) # avoid tight error loop
|
|
227
|
+
|
|
228
|
+
# Check for autofocus requests between tasks
|
|
229
|
+
with self._autofocus_lock:
|
|
230
|
+
should_autofocus = self._autofocus_requested
|
|
231
|
+
if should_autofocus:
|
|
232
|
+
self._autofocus_requested = False # Clear flag before execution
|
|
233
|
+
# Also check if scheduled autofocus should run (inside lock to prevent race condition)
|
|
234
|
+
elif self._should_run_scheduled_autofocus():
|
|
235
|
+
should_autofocus = True
|
|
236
|
+
self._autofocus_requested = False # Ensure flag is clear
|
|
237
|
+
|
|
238
|
+
if should_autofocus:
|
|
239
|
+
self._execute_autofocus()
|
|
240
|
+
|
|
222
241
|
self._stop_event.wait(1)
|
|
223
242
|
|
|
224
243
|
def _observe_satellite(self, task: Task):
|
|
@@ -278,6 +297,92 @@ class TaskManager:
|
|
|
278
297
|
with self._processing_lock:
|
|
279
298
|
return self._processing_active
|
|
280
299
|
|
|
300
|
+
def request_autofocus(self) -> bool:
|
|
301
|
+
"""Request autofocus to run at next safe point between tasks.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
bool: True indicating request was queued.
|
|
305
|
+
"""
|
|
306
|
+
with self._autofocus_lock:
|
|
307
|
+
self._autofocus_requested = True
|
|
308
|
+
self.logger.info("Autofocus requested - will run between tasks")
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
def cancel_autofocus(self) -> bool:
|
|
312
|
+
"""Cancel pending autofocus request if still queued.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
bool: True if autofocus was cancelled, False if nothing to cancel.
|
|
316
|
+
"""
|
|
317
|
+
with self._autofocus_lock:
|
|
318
|
+
was_requested = self._autofocus_requested
|
|
319
|
+
self._autofocus_requested = False
|
|
320
|
+
if was_requested:
|
|
321
|
+
self.logger.info("Autofocus request cancelled")
|
|
322
|
+
return was_requested
|
|
323
|
+
|
|
324
|
+
def is_autofocus_requested(self) -> bool:
|
|
325
|
+
"""Check if autofocus is currently requested/queued.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
bool: True if autofocus is queued, False otherwise.
|
|
329
|
+
"""
|
|
330
|
+
with self._autofocus_lock:
|
|
331
|
+
return self._autofocus_requested
|
|
332
|
+
|
|
333
|
+
def _should_run_scheduled_autofocus(self) -> bool:
|
|
334
|
+
"""Check if scheduled autofocus should run based on settings.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
bool: True if autofocus is enabled and interval has elapsed.
|
|
338
|
+
"""
|
|
339
|
+
if not self.settings:
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
# Check if scheduled autofocus is enabled (top-level setting)
|
|
343
|
+
if not self.settings.scheduled_autofocus_enabled:
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
# Check if adapter supports autofocus
|
|
347
|
+
if not self.hardware_adapter.supports_autofocus():
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
interval_minutes = self.settings.autofocus_interval_minutes
|
|
351
|
+
last_timestamp = self.settings.last_autofocus_timestamp
|
|
352
|
+
|
|
353
|
+
# If never run (None), treat as overdue and run immediately
|
|
354
|
+
if last_timestamp is None:
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
# Check if interval has elapsed
|
|
358
|
+
elapsed_minutes = (int(time.time()) - last_timestamp) / 60
|
|
359
|
+
return elapsed_minutes >= interval_minutes
|
|
360
|
+
|
|
361
|
+
def _execute_autofocus(self) -> None:
|
|
362
|
+
"""Execute autofocus routine and update timestamp on both success and failure."""
|
|
363
|
+
try:
|
|
364
|
+
self.logger.info("Starting autofocus routine...")
|
|
365
|
+
self.hardware_adapter.do_autofocus()
|
|
366
|
+
|
|
367
|
+
# Save updated filter configuration after autofocus
|
|
368
|
+
if self.hardware_adapter.supports_filter_management():
|
|
369
|
+
try:
|
|
370
|
+
filter_config = self.hardware_adapter.get_filter_config()
|
|
371
|
+
if filter_config and self.settings:
|
|
372
|
+
self.settings.adapter_settings["filters"] = filter_config
|
|
373
|
+
self.logger.info(f"Saved filter configuration with {len(filter_config)} filters")
|
|
374
|
+
except Exception as e:
|
|
375
|
+
self.logger.warning(f"Failed to save filter configuration after autofocus: {e}")
|
|
376
|
+
|
|
377
|
+
self.logger.info("Autofocus routine completed successfully")
|
|
378
|
+
except Exception as e:
|
|
379
|
+
self.logger.error(f"Autofocus failed: {str(e)}", exc_info=True)
|
|
380
|
+
finally:
|
|
381
|
+
# Always update timestamp to prevent retry spam
|
|
382
|
+
if self.settings:
|
|
383
|
+
self.settings.last_autofocus_timestamp = int(time.time())
|
|
384
|
+
self.settings.save()
|
|
385
|
+
|
|
281
386
|
def start(self):
|
|
282
387
|
self._stop_event.clear()
|
|
283
388
|
self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
|
|
@@ -12,18 +12,23 @@ class StaticTelescopeTask(AbstractBaseTelescopeTask):
|
|
|
12
12
|
raise ValueError("Could not fetch valid satellite data or TLE.")
|
|
13
13
|
|
|
14
14
|
filepath = None
|
|
15
|
-
|
|
16
|
-
self.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
try:
|
|
16
|
+
if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.MANUAL:
|
|
17
|
+
self.point_to_lead_position(satellite_data)
|
|
18
|
+
filepaths = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
|
|
19
|
+
|
|
20
|
+
if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
|
|
21
|
+
# Calculate current satellite position and add to satellite_data
|
|
22
|
+
target_ra, target_dec, _, _ = self.get_target_radec_and_rates(satellite_data)
|
|
23
|
+
satellite_data["ra"] = target_ra.degrees
|
|
24
|
+
satellite_data["dec"] = target_dec.degrees
|
|
25
|
+
|
|
26
|
+
# Sequence-based adapters handle pointing and tracking themselves
|
|
27
|
+
filepaths = self.hardware_adapter.perform_observation_sequence(self.task, satellite_data)
|
|
28
|
+
except RuntimeError as e:
|
|
29
|
+
# Filter errors and other hardware errors
|
|
30
|
+
self.logger.error(f"Observation failed for task {self.task.id}: {e}")
|
|
31
|
+
raise
|
|
27
32
|
|
|
28
33
|
# Take the image
|
|
29
34
|
return self.upload_image_and_mark_complete(filepaths)
|
citrascope/tasks/task.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
@dataclass
|
|
@@ -18,6 +19,7 @@ class Task:
|
|
|
18
19
|
telescopeName: str
|
|
19
20
|
groundStationId: str
|
|
20
21
|
groundStationName: str
|
|
22
|
+
assigned_filter_name: Optional[str] = None
|
|
21
23
|
|
|
22
24
|
@classmethod
|
|
23
25
|
def from_dict(cls, data: dict) -> "Task":
|
|
@@ -37,6 +39,7 @@ class Task:
|
|
|
37
39
|
telescopeName=data.get("telescopeName", ""),
|
|
38
40
|
groundStationId=data.get("groundStationId", ""),
|
|
39
41
|
groundStationName=data.get("groundStationName", ""),
|
|
42
|
+
assigned_filter_name=data.get("assigned_filter_name"),
|
|
40
43
|
)
|
|
41
44
|
|
|
42
45
|
def __repr__(self):
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Time synchronization monitoring for CitraScope."""
|
|
2
|
+
|
|
3
|
+
from citrascope.time.time_health import TimeHealth, TimeStatus
|
|
4
|
+
from citrascope.time.time_monitor import TimeMonitor
|
|
5
|
+
from citrascope.time.time_sources import AbstractTimeSource, NTPTimeSource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"TimeHealth",
|
|
9
|
+
"TimeStatus",
|
|
10
|
+
"TimeMonitor",
|
|
11
|
+
"AbstractTimeSource",
|
|
12
|
+
"NTPTimeSource",
|
|
13
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Time health status calculation and monitoring."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TimeStatus(str, Enum):
|
|
9
|
+
"""Time synchronization status levels."""
|
|
10
|
+
|
|
11
|
+
OK = "ok"
|
|
12
|
+
CRITICAL = "critical"
|
|
13
|
+
UNKNOWN = "unknown"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TimeHealth:
|
|
18
|
+
"""Time synchronization health status."""
|
|
19
|
+
|
|
20
|
+
offset_ms: Optional[float]
|
|
21
|
+
"""Clock offset in milliseconds (positive = system clock ahead)."""
|
|
22
|
+
|
|
23
|
+
status: TimeStatus
|
|
24
|
+
"""Current time sync status level."""
|
|
25
|
+
|
|
26
|
+
source: str
|
|
27
|
+
"""Time source used (ntp, unknown)."""
|
|
28
|
+
|
|
29
|
+
message: Optional[str] = None
|
|
30
|
+
"""Optional status message or error description."""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def calculate_status(
|
|
34
|
+
offset_ms: Optional[float],
|
|
35
|
+
pause_threshold: float,
|
|
36
|
+
) -> TimeStatus:
|
|
37
|
+
"""
|
|
38
|
+
Calculate time status based on offset and pause threshold.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
offset_ms: Clock offset in milliseconds (None if check failed)
|
|
42
|
+
pause_threshold: Threshold in milliseconds that triggers task pause
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
TimeStatus level (OK, CRITICAL, or UNKNOWN)
|
|
46
|
+
"""
|
|
47
|
+
if offset_ms is None:
|
|
48
|
+
return TimeStatus.UNKNOWN
|
|
49
|
+
|
|
50
|
+
abs_offset = abs(offset_ms)
|
|
51
|
+
|
|
52
|
+
if abs_offset < pause_threshold:
|
|
53
|
+
return TimeStatus.OK
|
|
54
|
+
else:
|
|
55
|
+
return TimeStatus.CRITICAL
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_offset(
|
|
59
|
+
cls,
|
|
60
|
+
offset_ms: Optional[float],
|
|
61
|
+
source: str,
|
|
62
|
+
pause_threshold: float,
|
|
63
|
+
message: Optional[str] = None,
|
|
64
|
+
) -> "TimeHealth":
|
|
65
|
+
"""
|
|
66
|
+
Create TimeHealth from offset and pause threshold.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
offset_ms: Clock offset in milliseconds
|
|
70
|
+
source: Time source identifier
|
|
71
|
+
pause_threshold: Threshold that triggers task pause
|
|
72
|
+
message: Optional status message
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
TimeHealth instance
|
|
76
|
+
"""
|
|
77
|
+
status = cls.calculate_status(offset_ms, pause_threshold)
|
|
78
|
+
return cls(
|
|
79
|
+
offset_ms=offset_ms,
|
|
80
|
+
status=status,
|
|
81
|
+
source=source,
|
|
82
|
+
message=message,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def should_pause_observations(self) -> bool:
|
|
86
|
+
"""Check if observations should be paused due to time sync issues."""
|
|
87
|
+
return self.status == TimeStatus.CRITICAL
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
"""Convert to dictionary for JSON serialization."""
|
|
91
|
+
return {
|
|
92
|
+
"offset_ms": self.offset_ms,
|
|
93
|
+
"status": self.status.value,
|
|
94
|
+
"source": self.source,
|
|
95
|
+
"message": self.message,
|
|
96
|
+
}
|