citrascope 0.7.0__py3-none-any.whl → 0.9.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 (51) 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 +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -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 +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -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 +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -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