citrascope 0.8.0__tar.gz → 0.9.1__tar.gz
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-0.8.0 → citrascope-0.9.1}/.gitignore +2 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/PKG-INFO +3 -2
- {citrascope-0.8.0 → citrascope-0.9.1}/README.md +1 -1
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/abstract_astro_hardware_adapter.py +17 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/adapter_registry.py +5 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/abstract_camera.py +12 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/usb_camera.py +20 -15
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/ximea_camera.py +22 -10
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/direct_hardware_adapter.py +21 -3
- citrascope-0.9.1/citrascope/hardware/dummy_adapter.py +202 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/time/__init__.py +2 -1
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/time/time_health.py +8 -1
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/time/time_monitor.py +27 -5
- citrascope-0.9.1/citrascope/time/time_sources.py +261 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/app.py +31 -9
- citrascope-0.9.1/citrascope/web/static/app.js +208 -0
- citrascope-0.9.1/citrascope/web/static/components.js +136 -0
- citrascope-0.9.1/citrascope/web/static/config.js +645 -0
- citrascope-0.9.1/citrascope/web/static/formatters.js +129 -0
- citrascope-0.9.1/citrascope/web/static/store-init.js +216 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/style.css +5 -0
- citrascope-0.9.1/citrascope/web/templates/_config.html +175 -0
- citrascope-0.9.1/citrascope/web/templates/_config_hardware.html +208 -0
- citrascope-0.9.1/citrascope/web/templates/_monitoring.html +242 -0
- citrascope-0.9.1/citrascope/web/templates/dashboard.html +258 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/pyproject.toml +3 -2
- citrascope-0.8.0/citrascope/time/time_sources.py +0 -62
- citrascope-0.8.0/citrascope/web/static/app.js +0 -1078
- citrascope-0.8.0/citrascope/web/static/config.js +0 -941
- citrascope-0.8.0/citrascope/web/templates/dashboard.html +0 -630
- {citrascope-0.8.0 → citrascope-0.9.1}/.devcontainer/devcontainer.json +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.flake8 +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.github/copilot-instructions.md +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.github/dependabot.yml +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.github/workflows/pypi-publish.yml +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.github/workflows/pytest.yml +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.pre-commit-config.yaml +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.python-version +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/.vscode/launch.json +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/LICENSE +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/__main__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/api/abstract_api_client.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/api/citra_api_client.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/citra_scope_daemon.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/constants.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/abstract_hardware_device.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/rpi_hq_camera.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/device_registry.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/filter_wheel/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/focuser/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/focuser/abstract_focuser.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/mount/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/mount/abstract_mount.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/filter_sync.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/indi_adapter.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/kstars_dbus_adapter.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/kstars_scheduler_template.esl +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/kstars_sequence_template.esq +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/nina_adv_http_adapter.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/nina_adv_http_survey_template.json +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/logging/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/logging/_citrascope_logger.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/logging/web_log_handler.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/settings/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/settings/citrascope_settings.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/settings/settings_file_manager.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/runner.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/scope/base_telescope_task.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/scope/static_telescope_task.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/task.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/__init__.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/server.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/api.js +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/filters.js +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/img/citra.png +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/img/favicon.png +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/websocket.js +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_api_client.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_device_dependencies.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_hardware_adapter.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_task_manager.py +0 -0
- {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: citrascope
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Remotely control a telescope while it polls for tasks, collects and edge processes data, and delivers results and data for further processing.
|
|
5
5
|
Project-URL: Homepage, https://citra.space
|
|
6
6
|
Project-URL: Documentation, https://docs.citra.space/citrascope/
|
|
@@ -25,6 +25,7 @@ Requires-Python: <3.13,>=3.10
|
|
|
25
25
|
Requires-Dist: click
|
|
26
26
|
Requires-Dist: fastapi>=0.104.0
|
|
27
27
|
Requires-Dist: httpx
|
|
28
|
+
Requires-Dist: jinja2>=3.1.0
|
|
28
29
|
Requires-Dist: ntplib>=0.4.0
|
|
29
30
|
Requires-Dist: platformdirs>=4.0.0
|
|
30
31
|
Requires-Dist: python-dateutil
|
|
@@ -202,7 +203,7 @@ This ensures code style and quality checks are enforced for all contributors.
|
|
|
202
203
|
|
|
203
204
|
### Releasing a New Version
|
|
204
205
|
|
|
205
|
-
To bump the version and create a release:
|
|
206
|
+
To bump the version and create a release from in the venv:
|
|
206
207
|
|
|
207
208
|
```sh
|
|
208
209
|
bump-my-version bump patch # 0.1.3 → 0.1.4
|
|
@@ -120,7 +120,7 @@ This ensures code style and quality checks are enforced for all contributors.
|
|
|
120
120
|
|
|
121
121
|
### Releasing a New Version
|
|
122
122
|
|
|
123
|
-
To bump the version and create a release:
|
|
123
|
+
To bump the version and create a release from in the venv:
|
|
124
124
|
|
|
125
125
|
```sh
|
|
126
126
|
bump-my-version bump patch # 0.1.3 → 0.1.4
|
{citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/abstract_astro_hardware_adapter.py
RENAMED
|
@@ -234,6 +234,23 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
234
234
|
"""
|
|
235
235
|
return False
|
|
236
236
|
|
|
237
|
+
def supports_direct_camera_control(self) -> bool:
|
|
238
|
+
"""Indicates whether this adapter supports direct camera control.
|
|
239
|
+
|
|
240
|
+
Direct camera control allows manual test captures and camera operations
|
|
241
|
+
from the UI. Remote scheduler adapters (NINA, KStars) typically do not
|
|
242
|
+
support this, as camera control is managed by the external software.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
bool: True if the adapter supports expose_camera() and manual captures
|
|
246
|
+
"""
|
|
247
|
+
# Remote schedulers never support direct control
|
|
248
|
+
if self.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
# MANUAL adapters might - check for the method
|
|
252
|
+
return hasattr(self, "expose_camera") and callable(getattr(self, "expose_camera"))
|
|
253
|
+
|
|
237
254
|
def is_hyperspectral(self) -> bool:
|
|
238
255
|
"""Indicates whether this adapter uses a hyperspectral camera.
|
|
239
256
|
|
|
@@ -38,6 +38,11 @@ REGISTERED_ADAPTERS: Dict[str, Dict[str, str]] = {
|
|
|
38
38
|
"class_name": "DirectHardwareAdapter",
|
|
39
39
|
"description": "Direct Hardware Control - Composable device adapters for cameras, mounts, etc.",
|
|
40
40
|
},
|
|
41
|
+
"dummy": {
|
|
42
|
+
"module": "citrascope.hardware.dummy_adapter",
|
|
43
|
+
"class_name": "DummyAdapter",
|
|
44
|
+
"description": "Dummy Adapter - Fake hardware for testing without real devices",
|
|
45
|
+
},
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
|
|
@@ -100,3 +100,15 @@ class AbstractCamera(AbstractHardwareDevice):
|
|
|
100
100
|
bool: True if hyperspectral camera, False otherwise (default)
|
|
101
101
|
"""
|
|
102
102
|
return False
|
|
103
|
+
|
|
104
|
+
def get_preferred_file_extension(self) -> str:
|
|
105
|
+
"""Get the preferred file extension for saved images.
|
|
106
|
+
|
|
107
|
+
This method allows each camera to define what file format it wants
|
|
108
|
+
to use, without the hardware adapter needing to know camera internals.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
File extension string without the dot (e.g., 'fits', 'png', 'jpg')
|
|
112
|
+
"""
|
|
113
|
+
# Default implementation: use output_format if available, otherwise FITS
|
|
114
|
+
return getattr(self, "output_format", "fits")
|
|
@@ -118,20 +118,21 @@ class UsbCamera(AbstractCamera):
|
|
|
118
118
|
try:
|
|
119
119
|
from cv2_enumerate_cameras import enumerate_cameras
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
backend = camera_info.backend or ""
|
|
121
|
+
# Get fancy camera names from enumerate_cameras
|
|
122
|
+
# Assumption: enumerate_cameras() returns cameras in the same order as OpenCV detection
|
|
123
|
+
camera_infos = list(enumerate_cameras())
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
cap = cv2.VideoCapture(index)
|
|
128
|
-
if cap.isOpened():
|
|
129
|
-
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
130
|
-
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
131
|
-
cap.release()
|
|
125
|
+
logging.debug(f"cv2_enumerate_cameras found {len(camera_infos)} cameras")
|
|
132
126
|
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
# Use the actual device index from camera_info for OpenCV compatibility
|
|
128
|
+
# Don't actually open cameras here - too wasteful
|
|
129
|
+
for camera_info in enumerate_cameras():
|
|
130
|
+
name = camera_info.name or f"Camera {camera_info.index}"
|
|
131
|
+
|
|
132
|
+
# Note: We're NOT opening cameras to verify or get resolution
|
|
133
|
+
# Use camera_info.index (the actual OpenCV device index) not enumerate position
|
|
134
|
+
cameras.append({"value": camera_info.index, "label": name})
|
|
135
|
+
logging.debug(f"Found camera with device_id {camera_info.index}: {name}")
|
|
135
136
|
|
|
136
137
|
except ImportError:
|
|
137
138
|
# cv2-enumerate-cameras not installed, use basic detection
|
|
@@ -297,12 +298,16 @@ class UsbCamera(AbstractCamera):
|
|
|
297
298
|
new_size = (width // binning, height // binning)
|
|
298
299
|
frame = self._cv2_module.resize(frame, new_size, interpolation=self._cv2_module.INTER_AREA)
|
|
299
300
|
|
|
301
|
+
# Determine format from file extension (if save_path provided) or configured output_format
|
|
302
|
+
file_extension = save_path.suffix.lower().lstrip(".")
|
|
303
|
+
format_to_use = file_extension if file_extension in ["fits", "png", "jpg", "jpeg"] else self.output_format
|
|
304
|
+
|
|
300
305
|
# Save based on format
|
|
301
|
-
if
|
|
306
|
+
if format_to_use == "fits":
|
|
302
307
|
self._save_as_fits(frame, save_path)
|
|
303
|
-
elif
|
|
308
|
+
elif format_to_use == "png":
|
|
304
309
|
self._cv2_module.imwrite(str(save_path), frame)
|
|
305
|
-
elif
|
|
310
|
+
elif format_to_use in ["jpg", "jpeg"]:
|
|
306
311
|
self._cv2_module.imwrite(str(save_path), frame, [self._cv2_module.IMWRITE_JPEG_QUALITY, 95])
|
|
307
312
|
|
|
308
313
|
self.logger.info(f"Image saved to {save_path}")
|
|
@@ -20,9 +20,11 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
20
20
|
default_gain (float): Default gain in dB
|
|
21
21
|
default_exposure_ms (float): Default exposure time in milliseconds
|
|
22
22
|
spectral_bands (int): Number of spectral bands (e.g., 16 for SM4X4, 25 for SM5X5)
|
|
23
|
-
|
|
23
|
+
data_mode (str): Data structure - 'raw' (2D mosaic) or 'datacube' (3D separated bands)
|
|
24
24
|
vertical_flip (bool): Flip image vertically
|
|
25
25
|
horizontal_flip (bool): Flip image horizontally
|
|
26
|
+
|
|
27
|
+
Note: Always outputs FITS format files regardless of data_mode.
|
|
26
28
|
"""
|
|
27
29
|
|
|
28
30
|
@classmethod
|
|
@@ -98,11 +100,11 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
98
100
|
"group": "Camera",
|
|
99
101
|
},
|
|
100
102
|
{
|
|
101
|
-
"name": "
|
|
102
|
-
"friendly_name": "
|
|
103
|
+
"name": "data_mode",
|
|
104
|
+
"friendly_name": "Data Mode",
|
|
103
105
|
"type": "str",
|
|
104
106
|
"default": "raw",
|
|
105
|
-
"description": "
|
|
107
|
+
"description": "Data structure: raw (2D mosaic) or datacube (3D separated bands)",
|
|
106
108
|
"required": False,
|
|
107
109
|
"options": ["raw", "datacube"],
|
|
108
110
|
"group": "Camera",
|
|
@@ -151,7 +153,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
151
153
|
self.default_gain: float = kwargs.get("default_gain", 0.0)
|
|
152
154
|
self.default_exposure_ms: float = kwargs.get("default_exposure_ms", 100.0)
|
|
153
155
|
self.spectral_bands: int = kwargs.get("spectral_bands", 25)
|
|
154
|
-
self.
|
|
156
|
+
self.data_mode: str = kwargs.get("data_mode", "raw") # raw mosaic or datacube
|
|
155
157
|
self.vertical_flip: bool = kwargs.get("vertical_flip", False)
|
|
156
158
|
self.horizontal_flip: bool = kwargs.get("horizontal_flip", False)
|
|
157
159
|
|
|
@@ -375,7 +377,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
375
377
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
376
378
|
save_path = Path(f"ximea_hyperspectral_{timestamp}.tiff")
|
|
377
379
|
|
|
378
|
-
# Save image (
|
|
380
|
+
# Save image (data structure depends on data_mode setting)
|
|
379
381
|
self.logger.debug(f"Saving image to: {save_path}")
|
|
380
382
|
self._save_hyperspectral_image(img, save_path)
|
|
381
383
|
|
|
@@ -458,6 +460,16 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
458
460
|
"""
|
|
459
461
|
return True
|
|
460
462
|
|
|
463
|
+
def get_preferred_file_extension(self) -> str:
|
|
464
|
+
"""Get the preferred file extension for saved images.
|
|
465
|
+
|
|
466
|
+
Ximea hyperspectral cameras always output FITS format.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
'fits' - FITS format with hyperspectral metadata
|
|
470
|
+
"""
|
|
471
|
+
return "fits"
|
|
472
|
+
|
|
461
473
|
def get_camera_info(self) -> dict:
|
|
462
474
|
"""Get camera capabilities and information.
|
|
463
475
|
|
|
@@ -552,8 +564,8 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
552
564
|
# Get image data as numpy array
|
|
553
565
|
data = img.get_image_data_numpy()
|
|
554
566
|
|
|
555
|
-
# Process based on
|
|
556
|
-
if self.
|
|
567
|
+
# Process based on data_mode setting
|
|
568
|
+
if self.data_mode == "datacube":
|
|
557
569
|
# Create 3D datacube by demosaicing the spectral mosaic
|
|
558
570
|
datacube = self._demosaic_to_datacube(data)
|
|
559
571
|
|
|
@@ -568,7 +580,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
568
580
|
# Hyperspectral metadata
|
|
569
581
|
primary_hdu.header["HIERARCH SPECTRAL_TYPE"] = "hyperspectral"
|
|
570
582
|
primary_hdu.header["HIERARCH SPECTRAL_BANDS"] = self.spectral_bands
|
|
571
|
-
primary_hdu.header["HIERARCH
|
|
583
|
+
primary_hdu.header["HIERARCH DATA_MODE"] = "datacube"
|
|
572
584
|
primary_hdu.header["HIERARCH SENSOR_TYPE"] = "snapshot_mosaic"
|
|
573
585
|
|
|
574
586
|
# Capture metadata
|
|
@@ -636,7 +648,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
|
|
|
636
648
|
# Hyperspectral metadata
|
|
637
649
|
hdu.header["HIERARCH SPECTRAL_TYPE"] = "hyperspectral"
|
|
638
650
|
hdu.header["HIERARCH SPECTRAL_BANDS"] = self.spectral_bands
|
|
639
|
-
hdu.header["HIERARCH
|
|
651
|
+
hdu.header["HIERARCH DATA_MODE"] = "raw"
|
|
640
652
|
hdu.header["HIERARCH SENSOR_TYPE"] = "snapshot_mosaic"
|
|
641
653
|
|
|
642
654
|
# Capture metadata
|
|
@@ -444,6 +444,21 @@ class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
|
|
|
444
444
|
return (0.0, 0.0)
|
|
445
445
|
return self.mount.get_radec()
|
|
446
446
|
|
|
447
|
+
def _get_camera_file_extension(self) -> str:
|
|
448
|
+
"""Get the preferred file extension from the camera.
|
|
449
|
+
|
|
450
|
+
Delegates to the camera's get_preferred_file_extension() method,
|
|
451
|
+
which allows each camera type to define its own file format logic.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
File extension string (e.g., 'fits', 'png', 'jpg')
|
|
455
|
+
"""
|
|
456
|
+
if not self.camera:
|
|
457
|
+
return "fits"
|
|
458
|
+
|
|
459
|
+
# Let the camera decide its preferred file extension
|
|
460
|
+
return self.camera.get_preferred_file_extension()
|
|
461
|
+
|
|
447
462
|
def expose_camera(
|
|
448
463
|
self,
|
|
449
464
|
exposure_time: float,
|
|
@@ -473,9 +488,10 @@ class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
|
|
|
473
488
|
if count > 1:
|
|
474
489
|
self.logger.info(f"Exposure {i+1}/{count}")
|
|
475
490
|
|
|
476
|
-
# Generate save path
|
|
491
|
+
# Generate save path with camera's preferred file extension
|
|
477
492
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
478
|
-
|
|
493
|
+
output_ext = self._get_camera_file_extension()
|
|
494
|
+
save_path = self.images_dir / f"direct_capture_{timestamp}_{i:03d}.{output_ext}"
|
|
479
495
|
|
|
480
496
|
# Take exposure
|
|
481
497
|
image_path = self.camera.take_exposure(
|
|
@@ -720,7 +736,9 @@ class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
|
|
|
720
736
|
|
|
721
737
|
# Generate save path with task ID
|
|
722
738
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
723
|
-
|
|
739
|
+
# Use camera's preferred file extension
|
|
740
|
+
output_ext = self._get_camera_file_extension()
|
|
741
|
+
save_path = self.images_dir / f"task_{task_id}_{timestamp}.{output_ext}"
|
|
724
742
|
|
|
725
743
|
return str(
|
|
726
744
|
self.camera.take_exposure(
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Dummy hardware adapter for testing without real hardware."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import (
|
|
8
|
+
AbstractAstroHardwareAdapter,
|
|
9
|
+
ObservationStrategy,
|
|
10
|
+
SettingSchemaEntry,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DummyAdapter(AbstractAstroHardwareAdapter):
|
|
15
|
+
"""
|
|
16
|
+
Dummy hardware adapter that simulates hardware without requiring real devices.
|
|
17
|
+
|
|
18
|
+
Perfect for testing, development, and demonstrations. All operations are logged
|
|
19
|
+
and return realistic fake data.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
|
|
23
|
+
"""Initialize dummy adapter.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
logger: Logger instance
|
|
27
|
+
images_dir: Path to images directory
|
|
28
|
+
**kwargs: Additional settings including 'simulate_slow_operations'
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(images_dir, **kwargs)
|
|
31
|
+
self.logger = logger
|
|
32
|
+
self.simulate_slow = kwargs.get("simulate_slow_operations", False)
|
|
33
|
+
self.slow_delay = kwargs.get("slow_delay_seconds", 2.0)
|
|
34
|
+
|
|
35
|
+
# Fake hardware state
|
|
36
|
+
self._connected = False
|
|
37
|
+
self._telescope_connected = False
|
|
38
|
+
self._camera_connected = False
|
|
39
|
+
self._current_ra = 0.0 # degrees
|
|
40
|
+
self._current_dec = 0.0 # degrees
|
|
41
|
+
self._is_moving = False
|
|
42
|
+
self._tracking_rate = (15.041, 0.0) # arcsec/sec (sidereal rate)
|
|
43
|
+
|
|
44
|
+
self.logger.info("DummyAdapter initialized")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
48
|
+
"""Return configuration schema for dummy adapter."""
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
"name": "simulate_slow_operations",
|
|
52
|
+
"friendly_name": "Simulate Slow Operations",
|
|
53
|
+
"type": "bool",
|
|
54
|
+
"default": False,
|
|
55
|
+
"description": "Add artificial delays to simulate slow hardware responses",
|
|
56
|
+
"required": False,
|
|
57
|
+
"group": "Testing",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "slow_delay_seconds",
|
|
61
|
+
"friendly_name": "Delay Duration (seconds)",
|
|
62
|
+
"type": "float",
|
|
63
|
+
"default": 2.0,
|
|
64
|
+
"min": 0.1,
|
|
65
|
+
"max": 10.0,
|
|
66
|
+
"description": "Duration of artificial delays when slow simulation is enabled",
|
|
67
|
+
"required": False,
|
|
68
|
+
"group": "Testing",
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def get_observation_strategy(self) -> ObservationStrategy:
|
|
73
|
+
"""Dummy adapter uses manual strategy."""
|
|
74
|
+
return ObservationStrategy.MANUAL
|
|
75
|
+
|
|
76
|
+
def perform_observation_sequence(self, task, satellite_data) -> str:
|
|
77
|
+
"""Not used for manual strategy."""
|
|
78
|
+
raise NotImplementedError("DummyAdapter uses MANUAL strategy")
|
|
79
|
+
|
|
80
|
+
def connect(self) -> bool:
|
|
81
|
+
"""Simulate connection."""
|
|
82
|
+
self.logger.info("DummyAdapter: Connecting...")
|
|
83
|
+
self._simulate_delay()
|
|
84
|
+
self._connected = True
|
|
85
|
+
self._telescope_connected = True
|
|
86
|
+
self._camera_connected = True
|
|
87
|
+
self.logger.info("DummyAdapter: Connected successfully")
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def disconnect(self):
|
|
91
|
+
"""Simulate disconnection."""
|
|
92
|
+
self.logger.info("DummyAdapter: Disconnecting...")
|
|
93
|
+
self._connected = False
|
|
94
|
+
self._telescope_connected = False
|
|
95
|
+
self._camera_connected = False
|
|
96
|
+
self.logger.info("DummyAdapter: Disconnected")
|
|
97
|
+
|
|
98
|
+
def is_telescope_connected(self) -> bool:
|
|
99
|
+
"""Check fake telescope connection."""
|
|
100
|
+
return self._telescope_connected
|
|
101
|
+
|
|
102
|
+
def is_camera_connected(self) -> bool:
|
|
103
|
+
"""Check fake camera connection."""
|
|
104
|
+
return self._camera_connected
|
|
105
|
+
|
|
106
|
+
def list_devices(self) -> list[str]:
|
|
107
|
+
"""Return list of fake devices."""
|
|
108
|
+
return ["Dummy Telescope", "Dummy Camera", "Dummy Filter Wheel", "Dummy Focuser"]
|
|
109
|
+
|
|
110
|
+
def select_telescope(self, device_name: str) -> bool:
|
|
111
|
+
"""Simulate telescope selection."""
|
|
112
|
+
self.logger.info(f"DummyAdapter: Selected telescope '{device_name}'")
|
|
113
|
+
self._telescope_connected = True
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def _do_point_telescope(self, ra: float, dec: float):
|
|
117
|
+
"""Simulate telescope slew."""
|
|
118
|
+
self.logger.info(f"DummyAdapter: Slewing to RA={ra:.4f}°, Dec={dec:.4f}°")
|
|
119
|
+
self._is_moving = True
|
|
120
|
+
self._simulate_delay()
|
|
121
|
+
self._current_ra = ra
|
|
122
|
+
self._current_dec = dec
|
|
123
|
+
self._is_moving = False
|
|
124
|
+
self.logger.info("DummyAdapter: Slew complete")
|
|
125
|
+
|
|
126
|
+
def get_telescope_direction(self) -> tuple[float, float]:
|
|
127
|
+
"""Return current fake telescope position."""
|
|
128
|
+
return (self._current_ra, self._current_dec)
|
|
129
|
+
|
|
130
|
+
def telescope_is_moving(self) -> bool:
|
|
131
|
+
"""Check if fake telescope is moving."""
|
|
132
|
+
return self._is_moving
|
|
133
|
+
|
|
134
|
+
def select_camera(self, device_name: str) -> bool:
|
|
135
|
+
"""Simulate camera selection."""
|
|
136
|
+
self.logger.info(f"DummyAdapter: Selected camera '{device_name}'")
|
|
137
|
+
self._camera_connected = True
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def take_image(self, task_id: str, exposure_duration_seconds=1.0) -> str:
|
|
141
|
+
"""Simulate image capture."""
|
|
142
|
+
self.logger.info(f"DummyAdapter: Starting {exposure_duration_seconds}s exposure for task {task_id}")
|
|
143
|
+
self._simulate_delay(exposure_duration_seconds)
|
|
144
|
+
|
|
145
|
+
# Create dummy image file
|
|
146
|
+
timestamp = int(time.time())
|
|
147
|
+
filename = f"dummy_{task_id}_{timestamp}.fits"
|
|
148
|
+
filepath = self.images_dir / filename
|
|
149
|
+
|
|
150
|
+
# Create empty file to simulate image
|
|
151
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
filepath.write_text(f"DUMMY FITS IMAGE\nTask: {task_id}\nExposure: {exposure_duration_seconds}s\n")
|
|
153
|
+
|
|
154
|
+
self.logger.info(f"DummyAdapter: Image saved to {filepath}")
|
|
155
|
+
return str(filepath)
|
|
156
|
+
|
|
157
|
+
def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
|
|
158
|
+
"""Simulate setting tracking rate."""
|
|
159
|
+
self.logger.info(f"DummyAdapter: Setting tracking rate RA={ra_rate} arcsec/s, Dec={dec_rate} arcsec/s")
|
|
160
|
+
self._tracking_rate = (ra_rate, dec_rate)
|
|
161
|
+
|
|
162
|
+
def get_tracking_rate(self) -> tuple[float, float]:
|
|
163
|
+
"""Return current fake tracking rate."""
|
|
164
|
+
return self._tracking_rate
|
|
165
|
+
|
|
166
|
+
def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
|
|
167
|
+
"""Simulate plate solving alignment."""
|
|
168
|
+
self.logger.info(f"DummyAdapter: Performing alignment to RA={target_ra}°, Dec={target_dec}°")
|
|
169
|
+
self._simulate_delay()
|
|
170
|
+
# Simulate small correction
|
|
171
|
+
self._current_ra = target_ra + 0.001
|
|
172
|
+
self._current_dec = target_dec + 0.001
|
|
173
|
+
self.logger.info("DummyAdapter: Alignment successful")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def supports_autofocus(self) -> bool:
|
|
177
|
+
"""Dummy adapter supports autofocus."""
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
def do_autofocus(self) -> None:
|
|
181
|
+
"""Simulate autofocus routine."""
|
|
182
|
+
self.logger.info("DummyAdapter: Starting autofocus...")
|
|
183
|
+
self._simulate_delay(3.0)
|
|
184
|
+
self.logger.info("DummyAdapter: Autofocus complete")
|
|
185
|
+
|
|
186
|
+
def supports_filter_management(self) -> bool:
|
|
187
|
+
"""Dummy adapter supports filter management."""
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def supports_direct_camera_control(self) -> bool:
|
|
191
|
+
"""Dummy adapter supports direct camera control."""
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def expose_camera(self, exposure_seconds: float = 1.0) -> str:
|
|
195
|
+
"""Simulate manual camera exposure."""
|
|
196
|
+
return self.take_image("manual_test", exposure_seconds)
|
|
197
|
+
|
|
198
|
+
def _simulate_delay(self, override_delay: float = None):
|
|
199
|
+
"""Add artificial delay if slow simulation is enabled."""
|
|
200
|
+
if self.simulate_slow:
|
|
201
|
+
delay = override_delay if override_delay is not None else self.slow_delay
|
|
202
|
+
time.sleep(delay)
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from citrascope.time.time_health import TimeHealth, TimeStatus
|
|
4
4
|
from citrascope.time.time_monitor import TimeMonitor
|
|
5
|
-
from citrascope.time.time_sources import AbstractTimeSource, NTPTimeSource
|
|
5
|
+
from citrascope.time.time_sources import AbstractTimeSource, ChronyTimeSource, NTPTimeSource
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"TimeHealth",
|
|
9
9
|
"TimeStatus",
|
|
10
10
|
"TimeMonitor",
|
|
11
11
|
"AbstractTimeSource",
|
|
12
|
+
"ChronyTimeSource",
|
|
12
13
|
"NTPTimeSource",
|
|
13
14
|
]
|
|
@@ -24,11 +24,14 @@ class TimeHealth:
|
|
|
24
24
|
"""Current time sync status level."""
|
|
25
25
|
|
|
26
26
|
source: str
|
|
27
|
-
"""Time source used (ntp, unknown)."""
|
|
27
|
+
"""Time source used (ntp, gps, chrony, unknown)."""
|
|
28
28
|
|
|
29
29
|
message: Optional[str] = None
|
|
30
30
|
"""Optional status message or error description."""
|
|
31
31
|
|
|
32
|
+
metadata: Optional[dict] = None
|
|
33
|
+
"""Optional metadata (e.g., GPS satellite count, fix mode)."""
|
|
34
|
+
|
|
32
35
|
@staticmethod
|
|
33
36
|
def calculate_status(
|
|
34
37
|
offset_ms: Optional[float],
|
|
@@ -61,6 +64,7 @@ class TimeHealth:
|
|
|
61
64
|
source: str,
|
|
62
65
|
pause_threshold: float,
|
|
63
66
|
message: Optional[str] = None,
|
|
67
|
+
metadata: Optional[dict] = None,
|
|
64
68
|
) -> "TimeHealth":
|
|
65
69
|
"""
|
|
66
70
|
Create TimeHealth from offset and pause threshold.
|
|
@@ -70,6 +74,7 @@ class TimeHealth:
|
|
|
70
74
|
source: Time source identifier
|
|
71
75
|
pause_threshold: Threshold that triggers task pause
|
|
72
76
|
message: Optional status message
|
|
77
|
+
metadata: Optional metadata dict
|
|
73
78
|
|
|
74
79
|
Returns:
|
|
75
80
|
TimeHealth instance
|
|
@@ -80,6 +85,7 @@ class TimeHealth:
|
|
|
80
85
|
status=status,
|
|
81
86
|
source=source,
|
|
82
87
|
message=message,
|
|
88
|
+
metadata=metadata,
|
|
83
89
|
)
|
|
84
90
|
|
|
85
91
|
def should_pause_observations(self) -> bool:
|
|
@@ -93,4 +99,5 @@ class TimeHealth:
|
|
|
93
99
|
"status": self.status.value,
|
|
94
100
|
"source": self.source,
|
|
95
101
|
"message": self.message,
|
|
102
|
+
"metadata": self.metadata,
|
|
96
103
|
}
|
|
@@ -6,7 +6,7 @@ from typing import Callable, Optional
|
|
|
6
6
|
|
|
7
7
|
from citrascope.logging import CITRASCOPE_LOGGER
|
|
8
8
|
from citrascope.time.time_health import TimeHealth, TimeStatus
|
|
9
|
-
from citrascope.time.time_sources import AbstractTimeSource, NTPTimeSource
|
|
9
|
+
from citrascope.time.time_sources import AbstractTimeSource, ChronyTimeSource, NTPTimeSource
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class TimeMonitor:
|
|
@@ -36,9 +36,8 @@ class TimeMonitor:
|
|
|
36
36
|
self.pause_threshold_ms = pause_threshold_ms
|
|
37
37
|
self.pause_callback = pause_callback
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
self.time_source: AbstractTimeSource =
|
|
41
|
-
CITRASCOPE_LOGGER.info("Time monitor initialized with NTP source")
|
|
39
|
+
# Detect and initialize best available time source
|
|
40
|
+
self.time_source: AbstractTimeSource = self._detect_best_source()
|
|
42
41
|
|
|
43
42
|
# Thread control
|
|
44
43
|
self._stop_event = threading.Event()
|
|
@@ -76,6 +75,25 @@ class TimeMonitor:
|
|
|
76
75
|
with self._lock:
|
|
77
76
|
return self._current_health
|
|
78
77
|
|
|
78
|
+
def _detect_best_source(self) -> AbstractTimeSource:
|
|
79
|
+
"""
|
|
80
|
+
Detect and return the best available time source.
|
|
81
|
+
|
|
82
|
+
Priority order: Chrony > NTP
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The best available time source instance.
|
|
86
|
+
"""
|
|
87
|
+
# Try ChronyTimeSource
|
|
88
|
+
chrony_source = ChronyTimeSource()
|
|
89
|
+
if chrony_source.is_available():
|
|
90
|
+
CITRASCOPE_LOGGER.info("Time monitor initialized with Chrony source")
|
|
91
|
+
return chrony_source
|
|
92
|
+
|
|
93
|
+
# Fall back to NTP
|
|
94
|
+
CITRASCOPE_LOGGER.info("Time monitor initialized with NTP source")
|
|
95
|
+
return NTPTimeSource()
|
|
96
|
+
|
|
79
97
|
def _monitor_loop(self) -> None:
|
|
80
98
|
"""Main monitoring loop (runs in background thread)."""
|
|
81
99
|
# Perform initial check immediately
|
|
@@ -94,14 +112,18 @@ class TimeMonitor:
|
|
|
94
112
|
def _check_time_sync(self) -> None:
|
|
95
113
|
"""Perform a single time synchronization check."""
|
|
96
114
|
try:
|
|
97
|
-
# Query
|
|
115
|
+
# Query time source for offset
|
|
98
116
|
offset_ms = self.time_source.get_offset_ms()
|
|
99
117
|
|
|
118
|
+
# Get metadata if available (e.g., GPS satellite info)
|
|
119
|
+
metadata = self.time_source.get_metadata()
|
|
120
|
+
|
|
100
121
|
# Calculate health status
|
|
101
122
|
health = TimeHealth.from_offset(
|
|
102
123
|
offset_ms=offset_ms,
|
|
103
124
|
source=self.time_source.get_source_name(),
|
|
104
125
|
pause_threshold=self.pause_threshold_ms,
|
|
126
|
+
metadata=metadata,
|
|
105
127
|
)
|
|
106
128
|
|
|
107
129
|
# Store current health (thread-safe)
|