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.
Files changed (45) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  5. citrascope/hardware/adapter_registry.py +10 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {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.filter_map = {}
38
- # Load filter configuration from settings if available
39
- saved_filters = kwargs.get("filters", {})
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 network requests fail
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
- self.logger.info("Performing autofocus routine ...")
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 self.filter_map.items():
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, otherwise use default
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, task_id, satellite_data) -> str | list[str]:
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
- task_id: Unique identifier for this observation task
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: {task_id}"
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
- for filter_id, filter_info in self.filter_map.items():
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.info(f"Added filter {filter_name} (ID: {filter_id}) with focus position {focus_position}")
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(self.filter_map) # One image per filter
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": 1,
178
- "Y": 1
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": 4,
209
- "Y": 4
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:
@@ -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
- if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.MANUAL:
16
- self.point_to_lead_position(satellite_data)
17
- filepaths = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
18
-
19
- if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
20
- # Calculate current satellite position and add to satellite_data
21
- target_ra, target_dec, _, _ = self.get_target_radec_and_rates(satellite_data)
22
- satellite_data["ra"] = target_ra.degrees
23
- satellite_data["dec"] = target_dec.degrees
24
-
25
- # Sequence-based adapters handle pointing and tracking themselves
26
- filepaths = self.hardware_adapter.perform_observation_sequence(self.task.id, satellite_data)
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
+ }