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.
- citrascope/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +75 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
- 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 +46 -37
- citrascope/hardware/nina_adv_http_adapter.py +13 -11
- citrascope/settings/citrascope_settings.py +6 -0
- citrascope/tasks/runner.py +2 -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 +229 -51
- citrascope/web/static/app.js +296 -36
- citrascope/web/static/config.js +216 -81
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +114 -9
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.7.0.dist-info/RECORD +0 -41
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {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,
|
|
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
|
-
#
|
|
315
|
-
|
|
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
|
|
318
|
-
#
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 /
|
|
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(
|
|
564
|
-
job_file = self._create_scheduler_job(
|
|
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(
|
|
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(
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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: {
|
|
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
|
-
#
|
|
500
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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:
|
citrascope/tasks/runner.py
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|