citrascope 0.6.1__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  5. citrascope/hardware/adapter_registry.py +10 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,102 @@
1
+ """Abstract camera device interface."""
2
+
3
+ from abc import abstractmethod
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from citrascope.hardware.devices.abstract_hardware_device import AbstractHardwareDevice
8
+
9
+
10
+ class AbstractCamera(AbstractHardwareDevice):
11
+ """Abstract base class for camera devices.
12
+
13
+ Provides a common interface for controlling imaging cameras including
14
+ CCDs, CMOS sensors, and hyperspectral cameras.
15
+ """
16
+
17
+ @abstractmethod
18
+ def take_exposure(
19
+ self,
20
+ duration: float,
21
+ gain: Optional[int] = None,
22
+ offset: Optional[int] = None,
23
+ binning: int = 1,
24
+ save_path: Optional[Path] = None,
25
+ ) -> Path:
26
+ """Capture an image exposure.
27
+
28
+ Args:
29
+ duration: Exposure duration in seconds
30
+ gain: Camera gain setting (device-specific units)
31
+ offset: Camera offset/black level setting
32
+ binning: Pixel binning factor (1=no binning, 2=2x2, etc.)
33
+ save_path: Optional path to save the image (if None, use default)
34
+
35
+ Returns:
36
+ Path to the saved image file
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ def abort_exposure(self):
42
+ """Abort the current exposure if one is in progress."""
43
+ pass
44
+
45
+ @abstractmethod
46
+ def get_temperature(self) -> Optional[float]:
47
+ """Get the current camera sensor temperature.
48
+
49
+ Returns:
50
+ Temperature in degrees Celsius, or None if not available
51
+ """
52
+ pass
53
+
54
+ @abstractmethod
55
+ def set_temperature(self, temperature: float) -> bool:
56
+ """Set the target camera sensor temperature.
57
+
58
+ Args:
59
+ temperature: Target temperature in degrees Celsius
60
+
61
+ Returns:
62
+ True if temperature setpoint accepted, False otherwise
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def start_cooling(self) -> bool:
68
+ """Enable camera cooling system.
69
+
70
+ Returns:
71
+ True if cooling started successfully, False otherwise
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def stop_cooling(self) -> bool:
77
+ """Disable camera cooling system.
78
+
79
+ Returns:
80
+ True if cooling stopped successfully, False otherwise
81
+ """
82
+ pass
83
+
84
+ @abstractmethod
85
+ def get_camera_info(self) -> dict:
86
+ """Get camera capabilities and information.
87
+
88
+ Returns:
89
+ Dictionary containing camera specs (resolution, pixel size, bit depth, etc.)
90
+ """
91
+ pass
92
+
93
+ def is_hyperspectral(self) -> bool:
94
+ """Indicates whether this camera captures hyperspectral data.
95
+
96
+ Hyperspectral cameras capture multiple spectral bands simultaneously
97
+ (e.g., snapshot mosaic sensors like Ximea MQ series).
98
+
99
+ Returns:
100
+ bool: True if hyperspectral camera, False otherwise (default)
101
+ """
102
+ return False
@@ -0,0 +1,353 @@
1
+ """Raspberry Pi High Quality Camera adapter."""
2
+
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional, cast
7
+
8
+ from citrascope.hardware.abstract_astro_hardware_adapter import SettingSchemaEntry
9
+ from citrascope.hardware.devices.camera import AbstractCamera
10
+
11
+
12
+ class RaspberryPiHQCamera(AbstractCamera):
13
+ """Adapter for Raspberry Pi High Quality Camera (12.3MP IMX477 sensor).
14
+
15
+ Uses the picamera2 library for camera control. Supports long exposures
16
+ suitable for astrophotography.
17
+
18
+ Configuration:
19
+ default_gain (float): Default analog gain (default: 1.0)
20
+ default_exposure_ms (float): Default exposure time in milliseconds
21
+ output_format (str): Output format - 'fits', 'png', 'jpg', 'raw'
22
+ """
23
+
24
+ @classmethod
25
+ def get_friendly_name(cls) -> str:
26
+ """Return human-readable name for this camera device.
27
+
28
+ Returns:
29
+ Friendly display name
30
+ """
31
+ return "Raspberry Pi HQ Camera"
32
+
33
+ @classmethod
34
+ def get_dependencies(cls) -> dict[str, str | list[str]]:
35
+ """Return required Python packages.
36
+
37
+ Returns:
38
+ Dict with packages and install extra
39
+ """
40
+ return {
41
+ "packages": ["picamera2"],
42
+ "install_extra": "rpi",
43
+ }
44
+
45
+ @classmethod
46
+ def get_settings_schema(cls) -> list[SettingSchemaEntry]:
47
+ """Return schema for Raspberry Pi HQ Camera settings.
48
+
49
+ Returns:
50
+ List of setting schema entries
51
+ """
52
+ schema = [
53
+ {
54
+ "name": "default_gain",
55
+ "friendly_name": "Default Gain",
56
+ "type": "float",
57
+ "default": 1.0,
58
+ "description": "Default analog gain (1.0-16.0)",
59
+ "required": False,
60
+ "min": 1.0,
61
+ "max": 16.0,
62
+ "group": "Camera",
63
+ },
64
+ {
65
+ "name": "default_exposure_ms",
66
+ "friendly_name": "Default Exposure (ms)",
67
+ "type": "float",
68
+ "default": 1000.0,
69
+ "description": "Default exposure time in milliseconds",
70
+ "required": False,
71
+ "min": 0.1,
72
+ "max": 600000.0,
73
+ "group": "Camera",
74
+ },
75
+ {
76
+ "name": "output_format",
77
+ "friendly_name": "Output Format",
78
+ "type": "str",
79
+ "default": "fits",
80
+ "description": "Image output format",
81
+ "required": False,
82
+ "options": ["fits", "png", "jpg", "raw"],
83
+ "group": "Camera",
84
+ },
85
+ ]
86
+ return cast(list[SettingSchemaEntry], schema)
87
+
88
+ def __init__(self, logger: logging.Logger, **kwargs):
89
+ """Initialize the Raspberry Pi HQ Camera.
90
+
91
+ Args:
92
+ logger: Logger instance
93
+ **kwargs: Configuration parameters matching the schema
94
+ """
95
+ super().__init__(logger, **kwargs)
96
+
97
+ # Hardware specs (fixed by IMX477 sensor)
98
+ self.sensor_width = 4056
99
+ self.sensor_height = 3040
100
+
101
+ # Camera settings
102
+ self.default_gain = kwargs.get("default_gain", 1.0)
103
+ self.default_exposure_ms = kwargs.get("default_exposure_ms", 1000.0)
104
+ self.output_format = kwargs.get("output_format", "fits")
105
+
106
+ # Camera instance (lazy loaded)
107
+ self._camera = None
108
+ self._connected = False
109
+
110
+ # Lazy import picamera2 to avoid hard dependency
111
+ self._picamera2_module = None
112
+
113
+ def connect(self) -> bool:
114
+ """Connect to the Raspberry Pi HQ Camera.
115
+
116
+ Returns:
117
+ True if connection successful, False otherwise
118
+ """
119
+ try:
120
+ # Lazy import
121
+ if self._picamera2_module is None:
122
+ import picamera2 # type: ignore
123
+
124
+ self._picamera2_module = picamera2
125
+
126
+ self.logger.info("Connecting to Raspberry Pi HQ Camera...")
127
+
128
+ # Initialize camera
129
+ self._camera = self._picamera2_module.Picamera2()
130
+
131
+ # Configure camera for full sensor resolution
132
+ config = self._camera.create_still_configuration(
133
+ main={
134
+ "size": (self.sensor_width, self.sensor_height),
135
+ "format": "RGB888", # We'll convert to desired format later
136
+ },
137
+ buffer_count=2,
138
+ )
139
+ self._camera.configure(config)
140
+
141
+ self._camera.start()
142
+ self._connected = True
143
+
144
+ self.logger.info(f"Raspberry Pi HQ Camera connected: {self.sensor_width}x{self.sensor_height}")
145
+ return True
146
+
147
+ except ImportError:
148
+ self.logger.error("picamera2 library not found. Install with: pip install picamera2")
149
+ return False
150
+ except Exception as e:
151
+ self.logger.error(f"Failed to connect to Raspberry Pi HQ Camera: {e}")
152
+ return False
153
+
154
+ def disconnect(self):
155
+ """Disconnect from the Raspberry Pi HQ Camera."""
156
+ if self._camera:
157
+ try:
158
+ self._camera.stop()
159
+ self._camera.close()
160
+ self.logger.info("Raspberry Pi HQ Camera disconnected")
161
+ except Exception as e:
162
+ self.logger.error(f"Error disconnecting camera: {e}")
163
+
164
+ self._camera = None
165
+ self._connected = False
166
+
167
+ def is_connected(self) -> bool:
168
+ """Check if camera is connected and responsive.
169
+
170
+ Returns:
171
+ True if connected, False otherwise
172
+ """
173
+ return self._connected and self._camera is not None
174
+
175
+ def take_exposure(
176
+ self,
177
+ duration: float,
178
+ gain: Optional[int] = None,
179
+ offset: Optional[int] = None,
180
+ binning: int = 1,
181
+ save_path: Optional[Path] = None,
182
+ ) -> Path:
183
+ """Capture an exposure.
184
+
185
+ Args:
186
+ duration: Exposure time in seconds
187
+ gain: Camera gain (1.0-16.0), uses default if None
188
+ offset: Not used for RPi HQ camera
189
+ binning: Binning factor (1, 2, or 4) - applied during image processing
190
+ save_path: Path to save the image
191
+
192
+ Returns:
193
+ Path to the saved image
194
+
195
+ Raises:
196
+ RuntimeError: If camera not connected or capture fails
197
+ """
198
+ if not self.is_connected():
199
+ raise RuntimeError("Camera not connected")
200
+
201
+ if self._camera is None:
202
+ raise RuntimeError("Camera instance is None")
203
+
204
+ if save_path is None:
205
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
206
+ save_path = Path(f"/tmp/rpi_hq_{timestamp}.{self.output_format}")
207
+
208
+ try:
209
+ # Set camera controls
210
+ actual_gain = gain if gain is not None else self.default_gain
211
+ exposure_us = int(duration * 1_000_000) # Convert to microseconds
212
+
213
+ self.logger.info(f"Taking {duration}s exposure, gain={actual_gain}")
214
+
215
+ # Configure exposure settings
216
+ self._camera.set_controls(
217
+ {
218
+ "ExposureTime": exposure_us,
219
+ "AnalogueGain": float(actual_gain),
220
+ }
221
+ )
222
+
223
+ # Capture image
224
+ request = self._camera.capture_request()
225
+
226
+ # Get the image data
227
+ image_data = request.make_array("main")
228
+ request.release()
229
+
230
+ # Apply binning if requested
231
+ if binning > 1:
232
+ image_data = self._apply_binning(image_data, binning)
233
+
234
+ # Save based on format
235
+ if self.output_format == "fits":
236
+ self._save_as_fits(image_data, save_path)
237
+ elif self.output_format == "png":
238
+ self._save_as_png(image_data, save_path)
239
+ elif self.output_format == "jpg":
240
+ self._save_as_jpg(image_data, save_path)
241
+ elif self.output_format == "raw":
242
+ self._save_as_raw(image_data, save_path)
243
+
244
+ self.logger.info(f"Image saved to {save_path}")
245
+ return save_path
246
+
247
+ except Exception as e:
248
+ self.logger.error(f"Failed to capture image: {e}")
249
+ raise RuntimeError(f"Image capture failed: {e}")
250
+
251
+ def abort_exposure(self):
252
+ """Abort current exposure.
253
+
254
+ Note: Picamera2 doesn't support aborting exposures, this is a no-op.
255
+ """
256
+ self.logger.warning("Raspberry Pi camera does not support aborting exposures")
257
+
258
+ def get_temperature(self) -> Optional[float]:
259
+ """Get camera sensor temperature.
260
+
261
+ Returns:
262
+ Temperature in Celsius, or None if unavailable
263
+
264
+ Note: RPi HQ camera does not expose temperature readings via picamera2
265
+ """
266
+ return None
267
+
268
+ def _apply_binning(self, image_data, binning: int):
269
+ """Apply pixel binning to reduce resolution and increase sensitivity.
270
+
271
+ Args:
272
+ image_data: Image array (H, W, C) or (H, W)
273
+ binning: Binning factor (2 or 4)
274
+
275
+ Returns:
276
+ Binned image array
277
+ """
278
+ import numpy as np
279
+
280
+ if binning == 1:
281
+ return image_data
282
+
283
+ # Crop to be evenly divisible by binning factor
284
+ h, w = image_data.shape[:2]
285
+ h_crop = (h // binning) * binning
286
+ w_crop = (w // binning) * binning
287
+ cropped = image_data[:h_crop, :w_crop]
288
+
289
+ # Reshape and average
290
+ if len(cropped.shape) == 3: # RGB
291
+ h, w, c = cropped.shape
292
+ binned = cropped.reshape(h // binning, binning, w // binning, binning, c).mean(axis=(1, 3))
293
+ else: # Grayscale
294
+ h, w = cropped.shape
295
+ binned = cropped.reshape(h // binning, binning, w // binning, binning).mean(axis=(1, 3))
296
+
297
+ return binned.astype(image_data.dtype)
298
+
299
+ def _save_as_fits(self, image_data, save_path: Path):
300
+ """Save image as FITS format."""
301
+ try:
302
+ import numpy as np
303
+ from astropy.io import fits
304
+
305
+ # Convert RGB to grayscale for astronomy (luminance)
306
+ if len(image_data.shape) == 3:
307
+ gray = np.mean(image_data, axis=2).astype(np.uint16)
308
+ else:
309
+ gray = image_data.astype(np.uint16)
310
+
311
+ hdu = fits.PrimaryHDU(gray)
312
+ hdu.header["INSTRUME"] = "Raspberry Pi HQ Camera"
313
+ hdu.header["CAMERA"] = "IMX477"
314
+ hdu.writeto(save_path, overwrite=True)
315
+
316
+ except ImportError:
317
+ self.logger.error("astropy not installed. Install with: pip install astropy")
318
+ raise
319
+
320
+ def _save_as_png(self, image_data, save_path: Path):
321
+ """Save image as PNG format."""
322
+ try:
323
+ from PIL import Image
324
+
325
+ img = Image.fromarray(image_data)
326
+ img.save(save_path)
327
+
328
+ except ImportError:
329
+ self.logger.error("Pillow not installed. Install with: pip install Pillow")
330
+ raise
331
+
332
+ def _save_as_jpg(self, image_data, save_path: Path):
333
+ """Save image as JPEG format."""
334
+ try:
335
+ from PIL import Image
336
+
337
+ img = Image.fromarray(image_data)
338
+ img.save(save_path, quality=95)
339
+
340
+ except ImportError:
341
+ self.logger.error("Pillow not installed. Install with: pip install Pillow")
342
+ raise
343
+
344
+ def _save_as_raw(self, image_data, save_path: Path):
345
+ """Save raw image data as numpy array."""
346
+ try:
347
+ import numpy as np
348
+
349
+ np.save(save_path.with_suffix(".npy"), image_data)
350
+
351
+ except ImportError:
352
+ self.logger.error("numpy not installed. Install with: pip install numpy")
353
+ raise