citrascope 0.1.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/__init__.py +0 -0
- citrascope/__main__.py +23 -0
- citrascope/api/abstract_api_client.py +23 -0
- citrascope/api/citra_api_client.py +101 -0
- citrascope/citra_scope_daemon.py +90 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +110 -0
- citrascope/hardware/indi_adapter.py +290 -0
- citrascope/logging/__init__.py +3 -0
- citrascope/logging/_citrascope_logger.py +35 -0
- citrascope/settings/__init__.py +0 -0
- citrascope/settings/_citrascope_settings.py +42 -0
- citrascope/tasks/runner.py +156 -0
- citrascope/tasks/scope/base_telescope_task.py +205 -0
- citrascope/tasks/scope/static_telescope_task.py +16 -0
- citrascope/tasks/scope/tracking_telescope_task.py +28 -0
- citrascope/tasks/task.py +43 -0
- citrascope-0.1.0.dist-info/METADATA +158 -0
- citrascope-0.1.0.dist-info/RECORD +21 -0
- citrascope-0.1.0.dist-info/WHEEL +4 -0
- citrascope-0.1.0.dist-info/entry_points.txt +2 -0
- docs/index.md +47 -0
citrascope/__init__.py
ADDED
|
File without changes
|
citrascope/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from citrascope.citra_scope_daemon import CitraScopeDaemon
|
|
4
|
+
from citrascope.settings._citrascope_settings import CitraScopeSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
@click.option("--dev", is_flag=True, default=False, help="Use the development API (dev.app.citra.space)")
|
|
9
|
+
@click.option("--log-level", default="INFO", help="Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def cli(ctx, dev, log_level):
|
|
12
|
+
ctx.obj = {"settings": CitraScopeSettings(dev=dev, log_level=log_level)}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cli.command("start")
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def start(ctx):
|
|
18
|
+
daemon = CitraScopeDaemon(ctx.obj["settings"])
|
|
19
|
+
daemon.run()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
cli()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AbstractCitraApiClient(ABC):
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def does_api_server_accept_key(self):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def get_telescope(self, telescope_id):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def get_satellite(self, satellite_id):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def get_telescope_tasks(self, telescope_id):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def get_ground_station(self, ground_station_id):
|
|
23
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .abstract_api_client import AbstractCitraApiClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CitraApiClient(AbstractCitraApiClient):
|
|
9
|
+
def __init__(self, host: str, token: str, use_ssl: bool = True, logger=None):
|
|
10
|
+
self.base_url = ("https" if use_ssl else "http") + "://" + host
|
|
11
|
+
self.token = token
|
|
12
|
+
self.logger = logger
|
|
13
|
+
self.client = httpx.Client(base_url=self.base_url, headers={"Authorization": f"Bearer {self.token}"})
|
|
14
|
+
|
|
15
|
+
def __enter__(self):
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
19
|
+
self.client.close()
|
|
20
|
+
|
|
21
|
+
def _request(self, method: str, endpoint: str, **kwargs):
|
|
22
|
+
try:
|
|
23
|
+
resp = self.client.request(method, endpoint, **kwargs)
|
|
24
|
+
if self.logger:
|
|
25
|
+
self.logger.debug(f"{method} {endpoint}: {resp.status_code} {resp.text}")
|
|
26
|
+
resp.raise_for_status()
|
|
27
|
+
return resp.json()
|
|
28
|
+
except httpx.HTTPStatusError as e:
|
|
29
|
+
if self.logger:
|
|
30
|
+
self.logger.error(f"HTTP error: {e.response.status_code} {e.response.text}")
|
|
31
|
+
return None
|
|
32
|
+
except Exception as e:
|
|
33
|
+
if self.logger:
|
|
34
|
+
self.logger.error(f"Request error: {e}")
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def does_api_server_accept_key(self):
|
|
38
|
+
"""Check if the API key is valid."""
|
|
39
|
+
response = self._request("GET", "/auth/personal-access-tokens")
|
|
40
|
+
return response is not None
|
|
41
|
+
|
|
42
|
+
def get_telescope(self, telescope_id):
|
|
43
|
+
"""Check if the telescope ID is valid."""
|
|
44
|
+
return self._request("GET", f"/telescopes/{telescope_id}")
|
|
45
|
+
|
|
46
|
+
def get_satellite(self, satellite_id):
|
|
47
|
+
"""Fetch satellite details from /satellites/{satellite_id}"""
|
|
48
|
+
return self._request("GET", f"/satellites/{satellite_id}")
|
|
49
|
+
|
|
50
|
+
def get_telescope_tasks(self, telescope_id):
|
|
51
|
+
"""Fetch tasks for a given telescope."""
|
|
52
|
+
return self._request("GET", f"/telescopes/{telescope_id}/tasks")
|
|
53
|
+
|
|
54
|
+
def get_ground_station(self, ground_station_id):
|
|
55
|
+
"""Fetch ground station details from /ground-stations/{ground_station_id}"""
|
|
56
|
+
return self._request("GET", f"/ground-stations/{ground_station_id}")
|
|
57
|
+
|
|
58
|
+
def upload_image(self, task_id, telescope_id, filepath):
|
|
59
|
+
"""Upload an image file for a given task."""
|
|
60
|
+
signed_url_response = self._request(
|
|
61
|
+
"POST", f"/my/images?filename=citra_task_{task_id}_image.fits&telescope_id={telescope_id}&task_id={task_id}"
|
|
62
|
+
)
|
|
63
|
+
if not signed_url_response or "uploadUrl" not in signed_url_response:
|
|
64
|
+
if self.logger:
|
|
65
|
+
self.logger.error("Failed to get signed URL for image upload.")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
upload_url = signed_url_response["uploadUrl"]
|
|
69
|
+
fields = signed_url_response["fields"]
|
|
70
|
+
|
|
71
|
+
# Prepare the multipart form data
|
|
72
|
+
files = {"file": (os.path.basename(filepath), open(filepath, "rb"), "application/fits")}
|
|
73
|
+
data = fields # Fields provided in the signed URL response
|
|
74
|
+
|
|
75
|
+
# Perform the POST request to upload the file
|
|
76
|
+
try:
|
|
77
|
+
response = httpx.post(upload_url, data=data, files=files)
|
|
78
|
+
if self.logger:
|
|
79
|
+
self.logger.debug(f"Image upload response: {response.status_code} {response.text}")
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
return signed_url_response.get("resultsUrl") # Return the results URL if needed
|
|
82
|
+
except httpx.RequestError as e:
|
|
83
|
+
if self.logger:
|
|
84
|
+
self.logger.error(f"Failed to upload image: {e}")
|
|
85
|
+
return None
|
|
86
|
+
finally:
|
|
87
|
+
# Ensure the file is closed after the upload
|
|
88
|
+
files["file"][1].close()
|
|
89
|
+
|
|
90
|
+
def mark_task_complete(self, task_id):
|
|
91
|
+
"""Mark a task as complete using the API."""
|
|
92
|
+
try:
|
|
93
|
+
body = {"status": "Succeeded"}
|
|
94
|
+
response = self._request("PUT", f"/tasks/{task_id}", json=body)
|
|
95
|
+
if self.logger:
|
|
96
|
+
self.logger.debug(f"Marked task {task_id} as complete: {response}")
|
|
97
|
+
return response
|
|
98
|
+
except Exception as e:
|
|
99
|
+
if self.logger:
|
|
100
|
+
self.logger.error(f"Failed to mark task {task_id} as complete: {e}")
|
|
101
|
+
return None
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from citrascope.api.citra_api_client import AbstractCitraApiClient, CitraApiClient
|
|
5
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
|
|
6
|
+
from citrascope.hardware.indi_adapter import IndiAdapter
|
|
7
|
+
from citrascope.logging import CITRASCOPE_LOGGER
|
|
8
|
+
from citrascope.settings._citrascope_settings import CitraScopeSettings
|
|
9
|
+
from citrascope.tasks.runner import TaskManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CitraScopeDaemon:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
settings: CitraScopeSettings,
|
|
16
|
+
api_client: Optional[AbstractCitraApiClient] = None,
|
|
17
|
+
hardware_adapter: Optional[AbstractAstroHardwareAdapter] = None,
|
|
18
|
+
):
|
|
19
|
+
self.settings = settings
|
|
20
|
+
CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
|
|
21
|
+
self.api_client = api_client or CitraApiClient(
|
|
22
|
+
self.settings.host,
|
|
23
|
+
self.settings.personal_access_token,
|
|
24
|
+
self.settings.use_ssl,
|
|
25
|
+
CITRASCOPE_LOGGER,
|
|
26
|
+
)
|
|
27
|
+
self.hardware_adapter = hardware_adapter or IndiAdapter(
|
|
28
|
+
CITRASCOPE_LOGGER, self.settings.indi_server_url, int(self.settings.indi_server_port)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def run(self):
|
|
32
|
+
CITRASCOPE_LOGGER.info(f"CitraAPISettings host is {self.settings.host}")
|
|
33
|
+
CITRASCOPE_LOGGER.info(f"CitraAPISettings telescope_id is {self.settings.telescope_id}")
|
|
34
|
+
|
|
35
|
+
# check api for valid key, telescope and ground station
|
|
36
|
+
if not self.api_client.does_api_server_accept_key():
|
|
37
|
+
CITRASCOPE_LOGGER.error("Aborting: could not authenticate with Citra API.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
citra_telescope_record = self.api_client.get_telescope(self.settings.telescope_id)
|
|
41
|
+
if not citra_telescope_record:
|
|
42
|
+
CITRASCOPE_LOGGER.error("Aborting: telescope_id is not valid on the server.")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
ground_station = self.api_client.get_ground_station(citra_telescope_record["groundStationId"])
|
|
46
|
+
if not ground_station:
|
|
47
|
+
CITRASCOPE_LOGGER.error("Aborting: could not get ground station info from the server.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# connect to hardware server
|
|
51
|
+
CITRASCOPE_LOGGER.info(f"Connecting to hardware server with {type(self.hardware_adapter).__name__}...")
|
|
52
|
+
self.hardware_adapter.connect()
|
|
53
|
+
time.sleep(1)
|
|
54
|
+
CITRASCOPE_LOGGER.info("List of hardware devices")
|
|
55
|
+
device_list = self.hardware_adapter.list_devices() or []
|
|
56
|
+
|
|
57
|
+
# check for telescope
|
|
58
|
+
if not self.settings.indi_telescope_name in device_list:
|
|
59
|
+
CITRASCOPE_LOGGER.error(
|
|
60
|
+
f"Aborting: could not find configured telescope ({self.settings.indi_telescope_name}) on hardware server."
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
self.hardware_adapter.select_telescope(self.settings.indi_telescope_name)
|
|
64
|
+
self.hardware_adapter.scope_slew_rate_degrees_per_second = citra_telescope_record["maxSlewRate"]
|
|
65
|
+
CITRASCOPE_LOGGER.info(
|
|
66
|
+
f"Found configured Telescope ({self.settings.indi_telescope_name}) on hardware server"
|
|
67
|
+
+ f" (slew rate: {self.hardware_adapter.scope_slew_rate_degrees_per_second} deg/sec)"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# check for camera
|
|
71
|
+
if not self.settings.indi_camera_name in device_list:
|
|
72
|
+
CITRASCOPE_LOGGER.error(
|
|
73
|
+
f"Aborting: could not find configured camera ({self.settings.indi_camera_name}) on hardware server."
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
self.hardware_adapter.select_camera(self.settings.indi_camera_name)
|
|
77
|
+
CITRASCOPE_LOGGER.info(f"Found configured Camera ({self.settings.indi_camera_name}) on hardware server!")
|
|
78
|
+
|
|
79
|
+
task_manager = TaskManager(
|
|
80
|
+
self.api_client, citra_telescope_record, ground_station, CITRASCOPE_LOGGER, self.hardware_adapter
|
|
81
|
+
)
|
|
82
|
+
task_manager.start()
|
|
83
|
+
|
|
84
|
+
CITRASCOPE_LOGGER.info("Starting telescope task daemon... (press Ctrl+C to exit)")
|
|
85
|
+
try:
|
|
86
|
+
while True:
|
|
87
|
+
time.sleep(1)
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
CITRASCOPE_LOGGER.info("Shutting down daemon.")
|
|
90
|
+
task_manager.stop()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AbstractAstroHardwareAdapter(ABC):
|
|
6
|
+
|
|
7
|
+
logger = None # Optional logger, can be set by subclasses
|
|
8
|
+
|
|
9
|
+
_slew_min_distance_deg: float = 2.0
|
|
10
|
+
scope_slew_rate_degrees_per_second: float = 0.0
|
|
11
|
+
|
|
12
|
+
def point_telescope(self, ra: float, dec: float):
|
|
13
|
+
"""Point the telescope to the specified RA/Dec coordinates."""
|
|
14
|
+
# separated out to allow pre/post processing if needed
|
|
15
|
+
self._do_point_telescope(ra, dec)
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def _do_point_telescope(self, ra: float, dec: float):
|
|
19
|
+
"""Hardware-specific implementation to point the telescope."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def angular_distance(
|
|
23
|
+
self, ra1_degrees: float, dec1_degrees: float, ra2_degrees: float, dec2_degrees: float
|
|
24
|
+
) -> float: # TODO: move this out of the hardware adapter... this isn't hardware stuff
|
|
25
|
+
"""Compute angular distance between two (RA hours, Dec deg) points in degrees."""
|
|
26
|
+
|
|
27
|
+
# Convert to radians
|
|
28
|
+
ra1_rad = math.radians(ra1_degrees)
|
|
29
|
+
ra2_rad = math.radians(ra2_degrees)
|
|
30
|
+
dec1_rad = math.radians(dec1_degrees)
|
|
31
|
+
dec2_rad = math.radians(dec2_degrees)
|
|
32
|
+
# Spherical law of cosines
|
|
33
|
+
cos_angle = math.sin(dec1_rad) * math.sin(dec2_rad) + math.cos(dec1_rad) * math.cos(dec2_rad) * math.cos(
|
|
34
|
+
ra1_rad - ra2_rad
|
|
35
|
+
)
|
|
36
|
+
# Clamp for safety
|
|
37
|
+
cos_angle = min(1.0, max(-1.0, cos_angle))
|
|
38
|
+
angle_rad = math.acos(cos_angle)
|
|
39
|
+
return math.degrees(angle_rad)
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
Abstract base class for controlling astrophotography hardware.
|
|
43
|
+
|
|
44
|
+
This adapter provides a common interface for interacting with telescopes, cameras,
|
|
45
|
+
filter wheels, focus dials, and other astrophotography devices.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def connect(self) -> bool:
|
|
50
|
+
"""Connect to the hardware server."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def disconnect(self):
|
|
55
|
+
"""Disconnect from the hardware server."""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def list_devices(self) -> list[str]:
|
|
60
|
+
"""List all connected devices."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def select_telescope(self, device_name: str) -> bool:
|
|
65
|
+
"""Select a specific camera by name."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def get_telescope_direction(self) -> tuple[float, float]:
|
|
70
|
+
"""Read the current telescope direction (RA degrees, DEC degrees)."""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def telescope_is_moving(self) -> bool:
|
|
75
|
+
"""Check if the telescope is currently moving."""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def select_camera(self, device_name: str) -> bool:
|
|
80
|
+
"""Select a specific camera by name."""
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def take_image(self, task_id: str, exposure_duration_seconds=1.0) -> str:
|
|
85
|
+
"""Capture an image with the currently selected camera. Returns the file path of the saved image."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
|
|
90
|
+
"""Set the tracking rate for the telescope in RA and Dec (arcseconds per second)."""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def get_tracking_rate(self) -> tuple[float, float]:
|
|
95
|
+
"""Get the current tracking rate for the telescope in RA and Dec (arcseconds per second)."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Perform plate-solving-based alignment to adjust the telescope's position.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
target_ra (float): The target Right Ascension (RA) in degrees.
|
|
105
|
+
target_dec (float): The target Declination (Dec) in degrees.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
bool: True if alignment was successful, False otherwise.
|
|
109
|
+
"""
|
|
110
|
+
pass
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import PyIndi
|
|
6
|
+
import tetra3
|
|
7
|
+
from pixelemon import Telescope, TelescopeImage, TetraSolver
|
|
8
|
+
from pixelemon.optics import WilliamsMiniCat51
|
|
9
|
+
from pixelemon.optics._base_optical_assembly import BaseOpticalAssembly
|
|
10
|
+
from pixelemon.sensors import IMX174
|
|
11
|
+
from pixelemon.sensors._base_sensor import BaseSensor
|
|
12
|
+
|
|
13
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# The IndiClient class which inherits from the module PyIndi.BaseClient class
|
|
17
|
+
# Note that all INDI constants are accessible from the module as PyIndi.CONSTANTNAME
|
|
18
|
+
class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
19
|
+
# Minimum angular distance (degrees) to consider a move significant for slew rate measurement
|
|
20
|
+
_slew_min_distance_deg: float = 2.0
|
|
21
|
+
|
|
22
|
+
our_scope: PyIndi.BaseDevice
|
|
23
|
+
our_camera: PyIndi.BaseDevice
|
|
24
|
+
|
|
25
|
+
_current_task_id: str = ""
|
|
26
|
+
_last_saved_filename: str = ""
|
|
27
|
+
|
|
28
|
+
_alignment_offset_ra: float = 0.0
|
|
29
|
+
_alignment_offset_dec: float = 0.0
|
|
30
|
+
|
|
31
|
+
def __init__(self, CITRA_LOGGER, host: str, port: int):
|
|
32
|
+
super(IndiAdapter, self).__init__()
|
|
33
|
+
self.logger = CITRA_LOGGER
|
|
34
|
+
self.logger.debug("creating an instance of IndiClient")
|
|
35
|
+
self.host = host
|
|
36
|
+
self.port = port
|
|
37
|
+
|
|
38
|
+
TetraSolver.high_memory()
|
|
39
|
+
|
|
40
|
+
def newDevice(self, d):
|
|
41
|
+
"""Emmited when a new device is created from INDI server."""
|
|
42
|
+
self.logger.info(f"new device {d.getDeviceName()}")
|
|
43
|
+
# TODO: if it's the scope we want, set our_scope
|
|
44
|
+
|
|
45
|
+
def removeDevice(self, d):
|
|
46
|
+
"""Emmited when a device is deleted from INDI server."""
|
|
47
|
+
self.logger.info(f"remove device {d.getDeviceName()}")
|
|
48
|
+
# TODO: if it's our_scope, set our_scope to None, and react accordingly
|
|
49
|
+
|
|
50
|
+
def newProperty(self, p):
|
|
51
|
+
"""Emmited when a new property is created for an INDI driver."""
|
|
52
|
+
self.logger.debug(f"new property {p.getName()} as {p.getTypeAsString()} for device {p.getDeviceName()}")
|
|
53
|
+
|
|
54
|
+
def updateProperty(self, p):
|
|
55
|
+
"""Emmited when a new property value arrives from INDI server."""
|
|
56
|
+
self.logger.debug(f"update property {p.getName()} as {p.getTypeAsString()} for device {p.getDeviceName()}")
|
|
57
|
+
try:
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
hasattr(self, "our_scope")
|
|
61
|
+
and self.our_scope is not None
|
|
62
|
+
and p.getDeviceName() == self.our_scope.getDeviceName()
|
|
63
|
+
):
|
|
64
|
+
value = None
|
|
65
|
+
changed_type = p.getTypeAsString()
|
|
66
|
+
if changed_type == "INDI_TEXT":
|
|
67
|
+
value = self.our_scope.getText(p.getName())[0].value
|
|
68
|
+
if changed_type == "INDI_NUMBER":
|
|
69
|
+
value = self.our_scope.getNumber(p.getName())[0].value
|
|
70
|
+
self.logger.debug(
|
|
71
|
+
f"Scope '{self.our_scope.getDeviceName()}' property {p.getName()} updated value: {value}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if p.getType() == PyIndi.INDI_BLOB:
|
|
75
|
+
blobProperty = self.our_camera.getBLOB(p.getName())
|
|
76
|
+
format = blobProperty[0].getFormat()
|
|
77
|
+
bloblen = blobProperty[0].getBlobLen()
|
|
78
|
+
size = blobProperty[0].getSize()
|
|
79
|
+
self.logger.debug(f"Received BLOB of format {format}, size {size}, length {bloblen}")
|
|
80
|
+
|
|
81
|
+
# if there's a task underway, save the image to a file
|
|
82
|
+
if self._current_task_id != "":
|
|
83
|
+
os.makedirs("images", exist_ok=True)
|
|
84
|
+
self._last_saved_filename = f"images/citra_task_{self._current_task_id}_image.fits"
|
|
85
|
+
for b in blobProperty:
|
|
86
|
+
with open(self._last_saved_filename, "wb") as f:
|
|
87
|
+
f.write(b.getblobdata())
|
|
88
|
+
self.logger.info(f"Saved {self._last_saved_filename}")
|
|
89
|
+
self._current_task_id = ""
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self.logger.error(f"Error processing updated property {p.getName()}: {e}")
|
|
92
|
+
|
|
93
|
+
def removeProperty(self, p):
|
|
94
|
+
"""Emmited when a property is deleted for an INDI driver."""
|
|
95
|
+
self.logger.debug(f"remove property {p.getName()} as {p.getTypeAsString()} for device {p.getDeviceName()}")
|
|
96
|
+
|
|
97
|
+
def newMessage(self, d, m):
|
|
98
|
+
"""Emmited when a new message arrives from INDI server."""
|
|
99
|
+
msg = d.messageQueue(m)
|
|
100
|
+
if "error" in msg.lower():
|
|
101
|
+
self.logger.error(f"new Message {msg}")
|
|
102
|
+
else:
|
|
103
|
+
self.logger.debug(f"new Message {msg}")
|
|
104
|
+
|
|
105
|
+
def serverConnected(self):
|
|
106
|
+
"""Emmited when the server is connected."""
|
|
107
|
+
self.logger.info(f"INDI Server connected ({self.getHost()}:{self.getPort()})")
|
|
108
|
+
|
|
109
|
+
def serverDisconnected(self, code):
|
|
110
|
+
"""Emmited when the server gets disconnected."""
|
|
111
|
+
self.logger.info(f"INDI Server disconnected (exit code = {code},{self.getHost()}:{self.getPort()})")
|
|
112
|
+
|
|
113
|
+
def newBLOB(self, bp):
|
|
114
|
+
for b in bp:
|
|
115
|
+
with open("image.fits", "wb") as f:
|
|
116
|
+
f.write(b.getblob())
|
|
117
|
+
print("Saved image.fits")
|
|
118
|
+
|
|
119
|
+
# ========================= AstroHardwareAdapter Methods =========================
|
|
120
|
+
|
|
121
|
+
def connect(self) -> bool:
|
|
122
|
+
self.setServer(self.host, self.port)
|
|
123
|
+
return self.connectServer()
|
|
124
|
+
|
|
125
|
+
def list_devices(self):
|
|
126
|
+
names = []
|
|
127
|
+
for device in self.getDevices():
|
|
128
|
+
names.append(device.getDeviceName())
|
|
129
|
+
return names
|
|
130
|
+
|
|
131
|
+
def select_telescope(self, device_name: str) -> bool:
|
|
132
|
+
devices = self.getDevices()
|
|
133
|
+
for device in devices:
|
|
134
|
+
if device.getDeviceName() == device_name:
|
|
135
|
+
self.our_scope = device
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def disconnect(self):
|
|
140
|
+
self.disconnectServer()
|
|
141
|
+
|
|
142
|
+
def _do_point_telescope(self, ra: float, dec: float):
|
|
143
|
+
"""Hardware-specific implementation to point the telescope to the specified RA/Dec coordinates."""
|
|
144
|
+
telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
|
|
145
|
+
new_ra = float(ra)
|
|
146
|
+
new_dec = float(dec)
|
|
147
|
+
telescope_radec[0].setValue(new_ra) # RA in hours
|
|
148
|
+
telescope_radec[1].setValue(new_dec) # DEC in degrees
|
|
149
|
+
try:
|
|
150
|
+
self.sendNewNumber(telescope_radec)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
self.logger.error(f"Error sending new RA/DEC to telescope: {e}")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def get_telescope_direction(self) -> tuple[float, float]:
|
|
156
|
+
"""Read the current telescope direction (RA degrees, DEC degrees)."""
|
|
157
|
+
telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
|
|
158
|
+
self.logger.debug(
|
|
159
|
+
f"Telescope currently pointed to RA: {telescope_radec[0].value * 15.0} degrees, DEC: {telescope_radec[1].value} degrees"
|
|
160
|
+
)
|
|
161
|
+
return telescope_radec[0].value * 15.0, telescope_radec[1].value
|
|
162
|
+
|
|
163
|
+
def telescope_is_moving(self) -> bool:
|
|
164
|
+
"""Check if the telescope is currently moving."""
|
|
165
|
+
telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
|
|
166
|
+
return telescope_radec.getState() == PyIndi.IPS_BUSY
|
|
167
|
+
|
|
168
|
+
def select_camera(self, device_name: str) -> bool:
|
|
169
|
+
"""Select a specific camera by name."""
|
|
170
|
+
devices = self.getDevices()
|
|
171
|
+
for device in devices:
|
|
172
|
+
if device.getDeviceName() == device_name:
|
|
173
|
+
self.our_camera = device
|
|
174
|
+
self.setBLOBMode(PyIndi.B_ALSO, device_name, None)
|
|
175
|
+
return True
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
def take_image(self, task_id: str, exposure_duration_seconds=1.0):
|
|
179
|
+
"""Capture an image with the currently selected camera."""
|
|
180
|
+
|
|
181
|
+
self.logger.info(f"Taking {exposure_duration_seconds} second exposure...")
|
|
182
|
+
self._current_task_id = task_id
|
|
183
|
+
ccd_exposure = self.our_camera.getNumber("CCD_EXPOSURE")
|
|
184
|
+
ccd_exposure[0].setValue(exposure_duration_seconds)
|
|
185
|
+
self.sendNewNumber(ccd_exposure)
|
|
186
|
+
|
|
187
|
+
while self.is_camera_busy() and self._current_task_id != "":
|
|
188
|
+
self.logger.debug("Waiting for camera to finish exposure...")
|
|
189
|
+
time.sleep(0.2)
|
|
190
|
+
|
|
191
|
+
filename = self._last_saved_filename
|
|
192
|
+
self._last_saved_filename = ""
|
|
193
|
+
return filename
|
|
194
|
+
|
|
195
|
+
def is_camera_busy(self) -> bool:
|
|
196
|
+
"""Check if the camera is currently busy taking an image."""
|
|
197
|
+
ccd_exposure = self.our_camera.getNumber("CCD_EXPOSURE")
|
|
198
|
+
return ccd_exposure.getState() == PyIndi.IPS_BUSY
|
|
199
|
+
|
|
200
|
+
def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
|
|
201
|
+
"""Set the tracking rate for the telescope in RA and Dec (arcseconds per second)."""
|
|
202
|
+
self.logger.info(f"Setting tracking rate: RA {ra_rate} arcseconds/s, Dec {dec_rate} arcseconds/s")
|
|
203
|
+
try:
|
|
204
|
+
|
|
205
|
+
track_state_prop = self.our_scope.getSwitch("TELESCOPE_TRACK_STATE")
|
|
206
|
+
track_state_prop[0].setState(PyIndi.ISS_OFF)
|
|
207
|
+
self.sendNewSwitch(track_state_prop)
|
|
208
|
+
|
|
209
|
+
track_mode_prop = self.our_scope.getSwitch("TELESCOPE_TRACK_MODE")
|
|
210
|
+
track_mode_prop[0].setState(PyIndi.ISS_OFF) # TRACK_SIDEREAL
|
|
211
|
+
track_mode_prop[1].setState(PyIndi.ISS_OFF) # TRACK_SOLAR
|
|
212
|
+
track_mode_prop[2].setState(PyIndi.ISS_OFF) # TRACK_LUNAR
|
|
213
|
+
track_mode_prop[3].setState(PyIndi.ISS_ON) # TRACK_CUSTOM
|
|
214
|
+
self.sendNewSwitch(track_mode_prop)
|
|
215
|
+
|
|
216
|
+
indi_tracking_rate = self.our_scope.getNumber("TELESCOPE_TRACK_RATE")
|
|
217
|
+
self.logger.info(
|
|
218
|
+
f"Current INDI tracking rates: 0: {indi_tracking_rate[0].value} 1: {indi_tracking_rate[1].value}"
|
|
219
|
+
)
|
|
220
|
+
indi_tracking_rate[0].setValue(ra_rate)
|
|
221
|
+
indi_tracking_rate[1].setValue(dec_rate)
|
|
222
|
+
self.sendNewNumber(indi_tracking_rate)
|
|
223
|
+
|
|
224
|
+
track_state_prop[0].setState(PyIndi.ISS_ON) # Turn tracking ON
|
|
225
|
+
self.sendNewSwitch(track_state_prop)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self.logger.error(f"Error setting tracking rates: {e}")
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def get_tracking_rate(self) -> tuple[float, float]:
|
|
233
|
+
"""Get the current tracking rate for the telescope in RA and Dec (arcseconds per second)."""
|
|
234
|
+
ra_rate = self.our_scope.getNumber("TELESCOPE_TRACK_RATE_RA")[0].value
|
|
235
|
+
dec_rate = self.our_scope.getNumber("TELESCOPE_TRACK_RATE_DEC")[0].value
|
|
236
|
+
return ra_rate, dec_rate
|
|
237
|
+
|
|
238
|
+
def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
|
|
239
|
+
"""
|
|
240
|
+
Perform plate-solving-based alignment to adjust the telescope's position.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
target_ra (float): The target Right Ascension (RA) in degrees.
|
|
244
|
+
target_dec (float): The target Declination (Dec) in degrees.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
bool: True if alignment was successful, False otherwise.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
|
|
251
|
+
# take alignment exposure
|
|
252
|
+
alignment_filename = self.take_image("alignment", 5.0)
|
|
253
|
+
|
|
254
|
+
# this needs to be made configurable
|
|
255
|
+
sim_ccd = BaseSensor(
|
|
256
|
+
x_pixel_count=1280,
|
|
257
|
+
y_pixel_count=1024,
|
|
258
|
+
pixel_width=5.86,
|
|
259
|
+
pixel_height=5.86,
|
|
260
|
+
)
|
|
261
|
+
sim_scope = BaseOpticalAssembly(image_circle_diameter=9.61, focal_length=300, focal_ratio=6)
|
|
262
|
+
telescope = Telescope(sensor=sim_ccd, optics=sim_scope)
|
|
263
|
+
image = TelescopeImage.from_fits_file(Path(alignment_filename), telescope)
|
|
264
|
+
|
|
265
|
+
# this line can be used to read a manually sideloded FITS file for testing
|
|
266
|
+
# image = TelescopeImage.from_fits_file(Path("images/cosmos-2564_10s.fits"), Telescope(sensor=IMX174(), optics=WilliamsMiniCat51()))
|
|
267
|
+
|
|
268
|
+
solve = image.plate_solve
|
|
269
|
+
|
|
270
|
+
self.logger.debug(f"Plate solving result: {solve}")
|
|
271
|
+
|
|
272
|
+
if solve is None:
|
|
273
|
+
self.logger.error("Plate solving failed.")
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
self.logger.info(
|
|
277
|
+
f"From {solve.number_of_stars} stars, solved RA: {solve.right_ascension:.4f}deg, Solved Dec: {solve.declination:.4f}deg in {solve.solve_time:.2f}ms, "
|
|
278
|
+
+ f"false prob: {solve.false_positive_probability}, est fov: {solve.estimated_horizontal_fov:.3f}"
|
|
279
|
+
)
|
|
280
|
+
self._alignment_offset_dec = solve.declination - target_dec
|
|
281
|
+
self._alignment_offset_ra = solve.right_ascension - target_ra
|
|
282
|
+
|
|
283
|
+
self.logger.info(
|
|
284
|
+
f"Alignment offsets set to RA: {self._alignment_offset_ra} degrees, Dec: {self._alignment_offset_dec} degrees"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return True
|
|
288
|
+
except Exception as e:
|
|
289
|
+
self.logger.error(f"Error during alignment: {e}")
|
|
290
|
+
return False
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExcludeHttpRequestFilter(logging.Filter):
|
|
5
|
+
def filter(self, record):
|
|
6
|
+
return "HTTP Request:" not in record.getMessage()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ColoredFormatter(logging.Formatter):
|
|
10
|
+
COLORS = {
|
|
11
|
+
"DEBUG": "\033[94m", # Blue
|
|
12
|
+
"INFO": "\033[92m", # Green
|
|
13
|
+
"WARNING": "\033[93m", # Yellow
|
|
14
|
+
"ERROR": "\033[91m", # Red
|
|
15
|
+
"CRITICAL": "\033[95m", # Magenta
|
|
16
|
+
}
|
|
17
|
+
RESET = "\033[0m"
|
|
18
|
+
|
|
19
|
+
def format(self, record):
|
|
20
|
+
color = self.COLORS.get(record.levelname, self.RESET)
|
|
21
|
+
record.levelname = f"{color}{record.levelname}{self.RESET}"
|
|
22
|
+
return super().format(record)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CITRASCOPE_LOGGER = logging.getLogger()
|
|
26
|
+
CITRASCOPE_LOGGER.setLevel(logging.INFO)
|
|
27
|
+
|
|
28
|
+
handler = logging.StreamHandler()
|
|
29
|
+
handler.addFilter(ExcludeHttpRequestFilter())
|
|
30
|
+
log_format = "%(asctime)s %(levelname)s %(message)s"
|
|
31
|
+
date_format = "%Y-%m-%d %H:%M:%S"
|
|
32
|
+
formatter = ColoredFormatter(fmt=log_format, datefmt=date_format)
|
|
33
|
+
handler.setFormatter(formatter)
|
|
34
|
+
CITRASCOPE_LOGGER.handlers.clear()
|
|
35
|
+
CITRASCOPE_LOGGER.addHandler(handler)
|
|
File without changes
|