citrascope 0.7.0__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 (44) 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 +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
  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 +46 -37
  24. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  25. citrascope/settings/citrascope_settings.py +6 -0
  26. citrascope/tasks/runner.py +2 -0
  27. citrascope/tasks/scope/static_telescope_task.py +17 -12
  28. citrascope/tasks/task.py +3 -0
  29. citrascope/time/__init__.py +13 -0
  30. citrascope/time/time_health.py +96 -0
  31. citrascope/time/time_monitor.py +164 -0
  32. citrascope/time/time_sources.py +62 -0
  33. citrascope/web/app.py +229 -51
  34. citrascope/web/static/app.js +296 -36
  35. citrascope/web/static/config.js +216 -81
  36. citrascope/web/static/filters.js +55 -0
  37. citrascope/web/static/style.css +39 -0
  38. citrascope/web/templates/dashboard.html +114 -9
  39. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  40. citrascope-0.8.0.dist-info/RECORD +62 -0
  41. citrascope-0.7.0.dist-info/RECORD +0 -41
  42. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  43. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  44. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,94 @@
1
+ """Filter synchronization utilities for syncing hardware filters to backend API."""
2
+
3
+
4
+ def extract_enabled_filter_names(filter_config: dict) -> list[str]:
5
+ """Extract names of enabled filters from hardware configuration.
6
+
7
+ Args:
8
+ filter_config: Dict mapping filter IDs to config dicts with 'name' and 'enabled' keys
9
+
10
+ Returns:
11
+ List of filter names where enabled=True
12
+ """
13
+ enabled_names = []
14
+ for filter_id, config in filter_config.items():
15
+ if config.get("enabled", False):
16
+ enabled_names.append(config["name"])
17
+ return enabled_names
18
+
19
+
20
+ def build_spectral_config_from_expanded(expanded_filters: list[dict]) -> tuple[dict, list[str]]:
21
+ """Build discrete spectral_config from API expanded filter response.
22
+
23
+ Args:
24
+ expanded_filters: List of filter dicts from /filters/expand API response,
25
+ each with 'name', 'central_wavelength_nm', 'bandwidth_nm', 'is_known'
26
+
27
+ Returns:
28
+ Tuple of (spectral_config dict, list of unknown filter names)
29
+ """
30
+ filter_specs = []
31
+ unknown_filters = []
32
+
33
+ for f in expanded_filters:
34
+ filter_specs.append(
35
+ {"name": f["name"], "central_wavelength_nm": f["central_wavelength_nm"], "bandwidth_nm": f["bandwidth_nm"]}
36
+ )
37
+ if not f.get("is_known", True):
38
+ unknown_filters.append(f["name"])
39
+
40
+ spectral_config = {"type": "discrete", "filters": filter_specs}
41
+
42
+ return spectral_config, unknown_filters
43
+
44
+
45
+ def sync_filters_to_backend(api_client, telescope_id: str, filter_config: dict, logger) -> bool:
46
+ """Sync enabled filters from hardware to backend API.
47
+
48
+ Extracts enabled filter names, expands them via filter library API,
49
+ builds spectral_config, and updates telescope record.
50
+
51
+ Args:
52
+ api_client: CitraApiClient instance
53
+ telescope_id: UUID string of telescope to update
54
+ filter_config: Hardware filter configuration dict
55
+ logger: Logger instance for output
56
+
57
+ Returns:
58
+ True if sync succeeded, False otherwise
59
+ """
60
+ if not filter_config:
61
+ logger.debug("No filter configuration to sync")
62
+ return False
63
+
64
+ # Extract enabled filter names
65
+ enabled_filter_names = extract_enabled_filter_names(filter_config)
66
+
67
+ if not enabled_filter_names:
68
+ logger.debug("No enabled filters to sync")
69
+ return False
70
+
71
+ logger.info(f"Syncing {len(enabled_filter_names)} enabled filters to backend: {enabled_filter_names}")
72
+
73
+ # Expand filter names to full spectral specs via API
74
+ expand_response = api_client.expand_filters(enabled_filter_names)
75
+ if not expand_response or "filters" not in expand_response:
76
+ logger.warning("Failed to expand filter names - API returned no data")
77
+ return False
78
+
79
+ # Build spectral_config from expanded filters
80
+ expanded_filters = expand_response["filters"]
81
+ spectral_config, unknown_filters = build_spectral_config_from_expanded(expanded_filters)
82
+
83
+ if unknown_filters:
84
+ logger.warning(f"Unknown filters (using defaults): {unknown_filters}")
85
+
86
+ # Update telescope spectral_config via PATCH
87
+ update_response = api_client.update_telescope_spectral_config(telescope_id, spectral_config)
88
+
89
+ if update_response:
90
+ logger.info(f"Successfully synced {len(spectral_config['filters'])} filters to backend")
91
+ return True
92
+ else:
93
+ logger.warning("Failed to update telescope spectral_config on backend")
94
+ return False
@@ -47,7 +47,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
47
47
  # TetraSolver.high_memory()
48
48
 
49
49
  @classmethod
50
- def get_settings_schema(cls) -> list[SettingSchemaEntry]:
50
+ def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
51
51
  """
52
52
  Return a schema describing configurable settings for the INDI adapter.
53
53
  """
@@ -60,6 +60,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
60
60
  "description": "INDI server hostname or IP address",
61
61
  "required": True,
62
62
  "placeholder": "localhost or 192.168.1.100",
63
+ "group": "Connection",
63
64
  },
64
65
  {
65
66
  "name": "port",
@@ -71,6 +72,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
71
72
  "placeholder": "7624",
72
73
  "min": 1,
73
74
  "max": 65535,
75
+ "group": "Connection",
74
76
  },
75
77
  {
76
78
  "name": "telescope_name",
@@ -80,6 +82,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
80
82
  "description": "Name of the telescope device (leave empty to auto-detect)",
81
83
  "required": False,
82
84
  "placeholder": "Telescope Simulator",
85
+ "group": "Devices",
83
86
  },
84
87
  {
85
88
  "name": "camera_name",
@@ -89,6 +92,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
89
92
  "description": "Name of the camera device (leave empty to auto-detect)",
90
93
  "required": False,
91
94
  "placeholder": "CCD Simulator",
95
+ "group": "Devices",
92
96
  },
93
97
  ]
94
98
 
@@ -726,5 +730,5 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
726
730
  def get_observation_strategy(self) -> ObservationStrategy:
727
731
  return ObservationStrategy.MANUAL
728
732
 
729
- def perform_observation_sequence(self, task_id, satellite_data) -> str:
733
+ def perform_observation_sequence(self, task, satellite_data) -> str:
730
734
  raise NotImplementedError
@@ -84,7 +84,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
84
84
  self.scheduler: dbus.Interface | None = None
85
85
 
86
86
  @classmethod
87
- def get_settings_schema(cls) -> list[SettingSchemaEntry]:
87
+ def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
88
88
  """
89
89
  Return a schema describing configurable settings for the KStars DBus adapter.
90
90
  """
@@ -97,6 +97,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
97
97
  "description": "D-Bus service name for KStars (default: org.kde.kstars)",
98
98
  "required": False,
99
99
  "placeholder": "org.kde.kstars",
100
+ "group": "Connection",
100
101
  },
101
102
  {
102
103
  "name": "ccd_name",
@@ -106,6 +107,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
106
107
  "description": "Name of the camera device in your Ekos profile (check Ekos logs on connect for available devices)",
107
108
  "required": False,
108
109
  "placeholder": "CCD Simulator",
110
+ "group": "Devices",
109
111
  },
110
112
  {
111
113
  "name": "filter_wheel_name",
@@ -115,6 +117,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
115
117
  "description": "Name of the filter wheel device (leave empty if no filter wheel)",
116
118
  "required": False,
117
119
  "placeholder": "Filter Simulator",
120
+ "group": "Devices",
118
121
  },
119
122
  {
120
123
  "name": "optical_train_name",
@@ -124,6 +127,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
124
127
  "description": "Name of the optical train in your Ekos profile (check Ekos logs on connect for available trains)",
125
128
  "required": False,
126
129
  "placeholder": "Primary",
130
+ "group": "Devices",
127
131
  },
128
132
  {
129
133
  "name": "exposure_time",
@@ -135,6 +139,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
135
139
  "placeholder": "1.0",
136
140
  "min": 0.001,
137
141
  "max": 300.0,
142
+ "group": "Imaging",
138
143
  },
139
144
  {
140
145
  "name": "frame_count",
@@ -146,6 +151,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
146
151
  "placeholder": "1",
147
152
  "min": 1,
148
153
  "max": 100,
154
+ "group": "Imaging",
149
155
  },
150
156
  {
151
157
  "name": "binning_x",
@@ -157,6 +163,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
157
163
  "placeholder": "1",
158
164
  "min": 1,
159
165
  "max": 4,
166
+ "group": "Imaging",
160
167
  },
161
168
  {
162
169
  "name": "binning_y",
@@ -168,6 +175,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
168
175
  "placeholder": "1",
169
176
  "min": 1,
170
177
  "max": 4,
178
+ "group": "Imaging",
171
179
  },
172
180
  {
173
181
  "name": "image_format",
@@ -178,6 +186,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
178
186
  "required": False,
179
187
  "placeholder": "Mono",
180
188
  "options": ["Mono", "RGGB", "RGB"],
189
+ "group": "Imaging",
181
190
  },
182
191
  ]
183
192
 
@@ -223,7 +232,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
223
232
  raise FileNotFoundError(f"Template not found: {template_path}")
224
233
  return template_path.read_text()
225
234
 
226
- def _create_sequence_file(self, task_id: str, satellite_data: dict, output_dir: Path) -> Path:
235
+ def _create_sequence_file(self, task_id: str, satellite_data: dict, output_dir: Path, task=None) -> Path:
227
236
  """
228
237
  Create an ESQ sequence file from template.
229
238
 
@@ -231,6 +240,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
231
240
  task_id: Unique task identifier
232
241
  satellite_data: Dictionary containing target information
233
242
  output_dir: Base output directory for captures
243
+ task: Optional task object containing filter assignment
234
244
 
235
245
  Returns:
236
246
  Path to the created sequence file
@@ -241,7 +251,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
241
251
  target_name = satellite_data.get("name", "Unknown").replace(" ", "_")
242
252
 
243
253
  # Generate job blocks based on filter configuration
244
- jobs_xml = self._generate_job_blocks(output_dir)
254
+ jobs_xml = self._generate_job_blocks(output_dir, task)
245
255
 
246
256
  # Replace placeholders
247
257
  sequence_content = template.replace("{{JOBS}}", jobs_xml)
@@ -261,13 +271,14 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
261
271
  self.logger.info(f"Created sequence file: {sequence_file}")
262
272
  return sequence_file
263
273
 
264
- def _generate_job_blocks(self, output_dir: Path) -> str:
274
+ def _generate_job_blocks(self, output_dir: Path, task=None) -> str:
265
275
  """
266
276
  Generate XML job blocks for each filter in filter_map.
267
277
  If no filters discovered, generates single job with no filter.
268
278
 
269
279
  Args:
270
280
  output_dir: Base output directory for captures
281
+ task: Optional task object containing filter assignment
271
282
 
272
283
  Returns:
273
284
  XML string containing one or more <Job> blocks
@@ -311,33 +322,31 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
311
322
 
312
323
  jobs = []
313
324
 
314
- # Filter to only enabled filters
315
- enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
325
+ # Select filters to use for this task (allow_no_filter for KStars '--' fallback)
326
+ filters_to_use = self.select_filters_for_task(task, allow_no_filter=True)
316
327
 
317
- if enabled_filters:
318
- # Multi-filter mode: create one job per enabled filter
319
- self.logger.info(
320
- f"Generating {len(enabled_filters)} jobs for enabled filters: "
321
- f"{[f['name'] for f in enabled_filters.values()]}"
322
- )
323
- for filter_idx in sorted(enabled_filters.keys()):
324
- filter_info = enabled_filters[filter_idx]
325
- filter_name = filter_info["name"]
326
-
327
- job_xml = job_template.format(
328
- exposure=self.exposure_time,
329
- format=self.image_format,
330
- binning_x=self.binning_x,
331
- binning_y=self.binning_y,
332
- filter_name=filter_name,
333
- count=self.frame_count,
334
- output_dir=str(output_dir),
335
- )
336
- jobs.append(job_xml)
337
- else:
338
- # Single-filter mode: use '--' for no filter
328
+ if filters_to_use is None:
329
+ # No filters available - use '--' for no filter wheel
339
330
  filter_name = "--" if not self.filter_wheel_name else "Luminance"
340
- self.logger.info(f"Generating single job with filter: {filter_name}")
331
+ task_id_str = task.id if task else "unknown"
332
+ self.logger.info(f"Using fallback filter '{filter_name}' for task {task_id_str}")
333
+
334
+ job_xml = job_template.format(
335
+ exposure=self.exposure_time,
336
+ format=self.image_format,
337
+ binning_x=self.binning_x,
338
+ binning_y=self.binning_y,
339
+ filter_name=filter_name,
340
+ count=self.frame_count,
341
+ output_dir=str(output_dir),
342
+ )
343
+ jobs.append(job_xml)
344
+ return "\n".join(jobs)
345
+
346
+ # Generate jobs for selected filters
347
+ for filter_idx in sorted(filters_to_use.keys()):
348
+ filter_info = filters_to_use[filter_idx]
349
+ filter_name = filter_info["name"]
341
350
 
342
351
  job_xml = job_template.format(
343
352
  exposure=self.exposure_time,
@@ -521,12 +530,12 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
521
530
 
522
531
  return matching_files
523
532
 
524
- def perform_observation_sequence(self, task_id: str, satellite_data: dict) -> list[str]:
533
+ def perform_observation_sequence(self, task, satellite_data: dict) -> list[str]:
525
534
  """
526
535
  Execute a complete observation sequence using Ekos Scheduler.
527
536
 
528
537
  Args:
529
- task_id: Unique task identifier
538
+ task: Task object containing id and filter assignment
530
539
  satellite_data: Dictionary with keys: 'name', and either 'ra'/'dec' or TLE data
531
540
 
532
541
  Returns:
@@ -550,7 +559,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
550
559
  output_dir.mkdir(exist_ok=True, parents=True)
551
560
 
552
561
  # Clear task-specific directory to prevent Ekos from thinking job is already done
553
- task_output_dir = output_dir / task_id
562
+ task_output_dir = output_dir / task.id
554
563
  if task_output_dir.exists():
555
564
  shutil.rmtree(task_output_dir)
556
565
  self.logger.info(f"Cleared existing output directory: {task_output_dir}")
@@ -560,20 +569,20 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
560
569
  self.logger.info(f"Output directory: {task_output_dir}")
561
570
 
562
571
  # Create sequence and scheduler job files (use task-specific directory)
563
- sequence_file = self._create_sequence_file(task_id, satellite_data, task_output_dir)
564
- job_file = self._create_scheduler_job(task_id, satellite_data, sequence_file)
572
+ sequence_file = self._create_sequence_file(task.id, satellite_data, task_output_dir, task)
573
+ job_file = self._create_scheduler_job(task.id, satellite_data, sequence_file)
565
574
 
566
575
  # Ensure temp files are cleaned up even on failure
567
576
  try:
568
- self._execute_observation(task_id, output_dir, sequence_file, job_file)
577
+ self._execute_observation(task.id, output_dir, sequence_file, job_file)
569
578
  finally:
570
579
  # Cleanup temp files
571
580
  self._cleanup_temp_files(sequence_file, job_file)
572
581
 
573
582
  # Retrieve and return captured images
574
- image_paths = self._retrieve_captured_images(task_id, output_dir)
583
+ image_paths = self._retrieve_captured_images(task.id, output_dir)
575
584
  if not image_paths:
576
- raise RuntimeError(f"No images captured for task {task_id}")
585
+ raise RuntimeError(f"No images captured for task {task.id}")
577
586
 
578
587
  self.logger.info(f"Observation sequence complete: {len(image_paths)} images captured")
579
588
  return image_paths
@@ -39,7 +39,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
39
39
  self.autofocus_binning = kwargs.get("autofocus_binning", 1)
40
40
 
41
41
  @classmethod
42
- def get_settings_schema(cls) -> list[SettingSchemaEntry]:
42
+ def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
43
43
  """
44
44
  Return a schema describing configurable settings for the NINA Advanced HTTP adapter.
45
45
  """
@@ -53,6 +53,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
53
53
  "required": True,
54
54
  "placeholder": "http://localhost:1888/v2/api",
55
55
  "pattern": r"^https?://.*",
56
+ "group": "Connection",
56
57
  },
57
58
  {
58
59
  "name": "autofocus_binning",
@@ -64,6 +65,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
64
65
  "placeholder": "1",
65
66
  "min": 1,
66
67
  "max": 4,
68
+ "group": "Imaging",
67
69
  },
68
70
  {
69
71
  "name": "binning_x",
@@ -75,6 +77,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
75
77
  "placeholder": "1",
76
78
  "min": 1,
77
79
  "max": 4,
80
+ "group": "Imaging",
78
81
  },
79
82
  {
80
83
  "name": "binning_y",
@@ -86,6 +89,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
86
89
  "placeholder": "1",
87
90
  "min": 1,
88
91
  "max": 4,
92
+ "group": "Imaging",
89
93
  },
90
94
  ]
91
95
 
@@ -440,11 +444,11 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
440
444
  for item in data:
441
445
  self._update_all_ids(item, id_counter)
442
446
 
443
- def perform_observation_sequence(self, task_id, satellite_data) -> str | list[str]:
447
+ def perform_observation_sequence(self, task, satellite_data) -> str | list[str]:
444
448
  """Create and execute a NINA sequence for the given satellite.
445
449
 
446
450
  Args:
447
- task_id: Unique identifier for this observation task
451
+ task: Task object containing id and filter assignment
448
452
  satellite_data: Satellite data including TLE information
449
453
 
450
454
  Returns:
@@ -459,7 +463,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
459
463
  template_str = template_str.replace("{autofocus_binning}", str(self.autofocus_binning))
460
464
  sequence_json = json.loads(template_str)
461
465
 
462
- 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}"
463
467
 
464
468
  # Replace basic placeholders (use \r\n for Windows NINA compatibility)
465
469
  tle_data = f"{elset['tle'][0]}\r\n{elset['tle'][1]}"
@@ -496,12 +500,10 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
496
500
 
497
501
  id_counter = [base_id] # Use list so it can be modified in nested function
498
502
 
499
- # Filter to only enabled filters for observation
500
- enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
501
- if not enabled_filters:
502
- raise RuntimeError("No enabled filters available for observation sequence")
503
+ # Select filters to use for this task
504
+ filters_to_use = self.select_filters_for_task(task, allow_no_filter=False)
503
505
 
504
- for filter_id, filter_info in enabled_filters.items():
506
+ for filter_id, filter_info in filters_to_use.items():
505
507
  filter_name = filter_info["name"]
506
508
  focus_position = filter_info["focus_position"]
507
509
 
@@ -521,7 +523,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
521
523
  # Add this triplet to the sequence
522
524
  new_items.extend(filter_triplet)
523
525
 
524
- 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}")
525
527
 
526
528
  # Update the items list
527
529
  tle_items.clear()
@@ -580,7 +582,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
580
582
  raise RuntimeError("Failed to get images list from NINA")
581
583
 
582
584
  images_to_download = []
583
- expected_image_count = len(enabled_filters) # One image per enabled filter
585
+ expected_image_count = len(filters_to_use) # One image per filter in sequence
584
586
  images_found = len(images_response["Response"])
585
587
  self.logger.info(
586
588
  f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
@@ -81,6 +81,10 @@ class CitraScopeSettings:
81
81
  )
82
82
  self.autofocus_interval_minutes = 60
83
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
+
84
88
  def get_images_dir(self) -> Path:
85
89
  """Get the path to the images directory.
86
90
 
@@ -126,6 +130,8 @@ class CitraScopeSettings:
126
130
  "scheduled_autofocus_enabled": self.scheduled_autofocus_enabled,
127
131
  "autofocus_interval_minutes": self.autofocus_interval_minutes,
128
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,
129
135
  }
130
136
 
131
137
  def save(self) -> None:
@@ -46,6 +46,8 @@ class TaskManager:
46
46
  # Autofocus request flag (set by manual or scheduled triggers)
47
47
  self._autofocus_requested = False
48
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
49
51
 
50
52
  def poll_tasks(self):
51
53
  while not self._stop_event.is_set():
@@ -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
+ }