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 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,3 @@
1
+ from citrascope.logging._citrascope_logger import CITRASCOPE_LOGGER
2
+
3
+ __all__ = ["CITRASCOPE_LOGGER"]
@@ -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