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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
from citrascope.logging import CITRASCOPE_LOGGER
|
|
4
|
+
|
|
5
|
+
UNDEFINED_STRING = "undefined"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CitraScopeSettings(BaseSettings):
|
|
9
|
+
model_config = SettingsConfigDict(
|
|
10
|
+
env_prefix="CITRASCOPE_",
|
|
11
|
+
env_nested_delimiter="__",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Default to production API
|
|
15
|
+
host: str = "api.citra.space"
|
|
16
|
+
port: int = 443
|
|
17
|
+
|
|
18
|
+
personal_access_token: str = UNDEFINED_STRING
|
|
19
|
+
use_ssl: bool = True
|
|
20
|
+
telescope_id: str = UNDEFINED_STRING
|
|
21
|
+
|
|
22
|
+
indi_server_url: str = "localhost"
|
|
23
|
+
indi_server_port: int = 7624
|
|
24
|
+
indi_telescope_name: str = UNDEFINED_STRING
|
|
25
|
+
indi_camera_name: str = UNDEFINED_STRING
|
|
26
|
+
|
|
27
|
+
log_level: str = "INFO"
|
|
28
|
+
|
|
29
|
+
def __init__(self, dev: bool = False, log_level: str = "INFO", **kwargs):
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
self.log_level = log_level
|
|
32
|
+
if dev:
|
|
33
|
+
self.host = "dev.api.citra.space"
|
|
34
|
+
CITRASCOPE_LOGGER.info("Using development API endpoint.")
|
|
35
|
+
|
|
36
|
+
def model_post_init(self, __context) -> None:
|
|
37
|
+
if self.personal_access_token == UNDEFINED_STRING:
|
|
38
|
+
CITRASCOPE_LOGGER.warning(f"{self.__class__.__name__} personal_access_token has not been set")
|
|
39
|
+
exit(1)
|
|
40
|
+
if self.telescope_id == UNDEFINED_STRING:
|
|
41
|
+
CITRASCOPE_LOGGER.warning(f"{self.__class__.__name__} telescope_id has not been set")
|
|
42
|
+
exit(1)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import heapq
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from dateutil import parser as dtparser
|
|
8
|
+
|
|
9
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
|
|
10
|
+
from citrascope.tasks.scope.static_telescope_task import StaticTelescopeTask
|
|
11
|
+
from citrascope.tasks.scope.tracking_telescope_task import TrackingTelescopeTask
|
|
12
|
+
from citrascope.tasks.task import Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskManager:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_client,
|
|
19
|
+
telescope_record,
|
|
20
|
+
ground_station_record,
|
|
21
|
+
logger,
|
|
22
|
+
hardware_adapter: AbstractAstroHardwareAdapter,
|
|
23
|
+
):
|
|
24
|
+
self.api_client = api_client
|
|
25
|
+
self.telescope_record = telescope_record
|
|
26
|
+
self.ground_station_record = ground_station_record
|
|
27
|
+
self.logger = logger
|
|
28
|
+
self.task_heap = [] # min-heap by start time
|
|
29
|
+
self.task_ids = set()
|
|
30
|
+
self.hardware_adapter = hardware_adapter
|
|
31
|
+
self.heap_lock = threading.RLock()
|
|
32
|
+
self._stop_event = threading.Event()
|
|
33
|
+
self.current_task_id = None # Track currently executing task
|
|
34
|
+
|
|
35
|
+
def poll_tasks(self):
|
|
36
|
+
while not self._stop_event.is_set():
|
|
37
|
+
try:
|
|
38
|
+
tasks = self.api_client.get_telescope_tasks(self.telescope_record["id"])
|
|
39
|
+
added = 0
|
|
40
|
+
now = int(time.time())
|
|
41
|
+
with self.heap_lock:
|
|
42
|
+
for task_dict in tasks:
|
|
43
|
+
try:
|
|
44
|
+
task = Task.from_dict(task_dict)
|
|
45
|
+
tid = task.id
|
|
46
|
+
task_start = task.taskStart
|
|
47
|
+
task_stop = task.taskStop
|
|
48
|
+
# Skip if task is in heap or is currently being executed
|
|
49
|
+
if tid and task_start and tid not in self.task_ids and tid != self.current_task_id:
|
|
50
|
+
try:
|
|
51
|
+
start_epoch = int(dtparser.isoparse(task_start).timestamp())
|
|
52
|
+
stop_epoch = int(dtparser.isoparse(task_stop).timestamp()) if task_stop else 0
|
|
53
|
+
except Exception:
|
|
54
|
+
self.logger.error(f"Could not parse taskStart/taskStop for task {tid}")
|
|
55
|
+
continue
|
|
56
|
+
if stop_epoch and stop_epoch < now:
|
|
57
|
+
self.logger.debug(f"Skipping past task {tid} that ended at {task_stop}")
|
|
58
|
+
continue # Skip tasks whose end date has passed
|
|
59
|
+
if task.status not in ["Pending", "Scheduled"]:
|
|
60
|
+
self.logger.debug(f"Skipping task {tid} with status {task.status}")
|
|
61
|
+
continue # Only schedule pending/scheduled tasks
|
|
62
|
+
heapq.heappush(self.task_heap, (start_epoch, stop_epoch, tid, task))
|
|
63
|
+
self.task_ids.add(tid)
|
|
64
|
+
added += 1
|
|
65
|
+
except Exception as e:
|
|
66
|
+
self.logger.error(f"Error adding task {tid} to heap: {e}", exc_info=True)
|
|
67
|
+
if added > 0:
|
|
68
|
+
self.logger.info(self._heap_summary("Added tasks"))
|
|
69
|
+
self.logger.info(self._heap_summary("Polled tasks"))
|
|
70
|
+
except Exception as e:
|
|
71
|
+
self.logger.error(f"Exception in poll_tasks loop: {e}", exc_info=True)
|
|
72
|
+
time.sleep(5) # avoid tight error loop
|
|
73
|
+
self._stop_event.wait(15)
|
|
74
|
+
|
|
75
|
+
def task_runner(self):
|
|
76
|
+
while not self._stop_event.is_set():
|
|
77
|
+
try:
|
|
78
|
+
now = int(time.time())
|
|
79
|
+
completed = 0
|
|
80
|
+
while True:
|
|
81
|
+
with self.heap_lock:
|
|
82
|
+
if not (self.task_heap and self.task_heap[0][0] <= now):
|
|
83
|
+
break
|
|
84
|
+
_, _, tid, task = self.task_heap[0]
|
|
85
|
+
self.logger.info(f"Starting task {tid} at {datetime.now().isoformat()}: {task}")
|
|
86
|
+
self.current_task_id = tid # Mark as in-flight
|
|
87
|
+
|
|
88
|
+
# Observation is now outside the lock!
|
|
89
|
+
try:
|
|
90
|
+
observation_succeeded = self._observe_satellite(task)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
self.logger.error(f"Exception during observation for task {tid}: {e}", exc_info=True)
|
|
93
|
+
observation_succeeded = False
|
|
94
|
+
|
|
95
|
+
with self.heap_lock:
|
|
96
|
+
self.current_task_id = None # Clear after done (success or fail)
|
|
97
|
+
if observation_succeeded:
|
|
98
|
+
self.logger.info(f"Completed observation task {tid} successfully.")
|
|
99
|
+
heapq.heappop(self.task_heap)
|
|
100
|
+
self.task_ids.discard(tid)
|
|
101
|
+
completed += 1
|
|
102
|
+
else:
|
|
103
|
+
self.logger.error(f"Observation task {tid} failed.")
|
|
104
|
+
|
|
105
|
+
if completed > 0:
|
|
106
|
+
self.logger.info(self._heap_summary("Completed tasks"))
|
|
107
|
+
except Exception as e:
|
|
108
|
+
self.logger.error(f"Exception in task_runner loop: {e}", exc_info=True)
|
|
109
|
+
time.sleep(5) # avoid tight error loop
|
|
110
|
+
self._stop_event.wait(1)
|
|
111
|
+
|
|
112
|
+
def _observe_satellite(self, task: Task):
|
|
113
|
+
|
|
114
|
+
# stake a still
|
|
115
|
+
static_task = StaticTelescopeTask(
|
|
116
|
+
self.api_client, self.hardware_adapter, self.logger, self.telescope_record, self.ground_station_record, task
|
|
117
|
+
)
|
|
118
|
+
return static_task.execute()
|
|
119
|
+
|
|
120
|
+
# track the sat for a while with longer exposure
|
|
121
|
+
# tracking_task = TrackingTelescopeTask(
|
|
122
|
+
# self.api_client, self.hardware_adapter, self.logger, self.telescope_record, self.ground_station_record, task
|
|
123
|
+
# )
|
|
124
|
+
# return tracking_task.execute()
|
|
125
|
+
|
|
126
|
+
def _heap_summary(self, event):
|
|
127
|
+
with self.heap_lock:
|
|
128
|
+
summary = f"{event}: {len(self.task_heap)} tasks in queue. "
|
|
129
|
+
next_tasks = []
|
|
130
|
+
if self.task_heap:
|
|
131
|
+
next_tasks = [
|
|
132
|
+
f"{tid} at {datetime.fromtimestamp(start).isoformat()}"
|
|
133
|
+
for start, stop, tid, _ in self.task_heap[:3]
|
|
134
|
+
]
|
|
135
|
+
if len(self.task_heap) > 3:
|
|
136
|
+
next_tasks.append(f"... ({len(self.task_heap)-3} more)")
|
|
137
|
+
if self.current_task_id is not None:
|
|
138
|
+
# Show the current in-flight task at the front
|
|
139
|
+
summary += f"Current: {self.current_task_id}. "
|
|
140
|
+
if next_tasks and len(next_tasks) > 1 and self.current_task_id != next_tasks[0].split()[0]:
|
|
141
|
+
summary += "Next: " + ", ".join(next_tasks)
|
|
142
|
+
else:
|
|
143
|
+
summary += "No tasks scheduled."
|
|
144
|
+
return summary
|
|
145
|
+
|
|
146
|
+
def start(self):
|
|
147
|
+
self._stop_event.clear()
|
|
148
|
+
self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
|
|
149
|
+
self.runner_thread = threading.Thread(target=self.task_runner, daemon=True)
|
|
150
|
+
self.poll_thread.start()
|
|
151
|
+
self.runner_thread.start()
|
|
152
|
+
|
|
153
|
+
def stop(self):
|
|
154
|
+
self._stop_event.set()
|
|
155
|
+
self.poll_thread.join()
|
|
156
|
+
self.runner_thread.join()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from dateutil import parser as dtparser
|
|
6
|
+
from skyfield.api import Angle, EarthSatellite, load, wgs84
|
|
7
|
+
|
|
8
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AbstractBaseTelescopeTask(ABC):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
api_client,
|
|
15
|
+
hardware_adapter: AbstractAstroHardwareAdapter,
|
|
16
|
+
logger,
|
|
17
|
+
telescope_record,
|
|
18
|
+
ground_station_record,
|
|
19
|
+
task,
|
|
20
|
+
):
|
|
21
|
+
self.api_client = api_client
|
|
22
|
+
self.hardware_adapter: AbstractAstroHardwareAdapter = hardware_adapter
|
|
23
|
+
self.logger = logger
|
|
24
|
+
self.telescope_record = telescope_record
|
|
25
|
+
self.ground_station_record = ground_station_record
|
|
26
|
+
self.task = task
|
|
27
|
+
|
|
28
|
+
def fetch_satellite(self) -> dict | None:
|
|
29
|
+
satellite_data = self.api_client.get_satellite(self.task.satelliteId)
|
|
30
|
+
if not satellite_data:
|
|
31
|
+
self.logger.error(f"Could not fetch satellite data for {self.task.satelliteId}")
|
|
32
|
+
return None
|
|
33
|
+
elsets = satellite_data.get("elsets", [])
|
|
34
|
+
if not elsets:
|
|
35
|
+
self.logger.error(f"No elsets found for satellite {self.task.satelliteId}")
|
|
36
|
+
return None
|
|
37
|
+
satellite_data["most_recent_elset"] = self._get_most_recent_elset(satellite_data)
|
|
38
|
+
return satellite_data
|
|
39
|
+
|
|
40
|
+
def _get_most_recent_elset(self, satellite_data) -> dict | None:
|
|
41
|
+
if "most_recent_elset" in satellite_data:
|
|
42
|
+
return satellite_data["most_recent_elset"]
|
|
43
|
+
|
|
44
|
+
elsets = satellite_data.get("elsets", [])
|
|
45
|
+
if not elsets:
|
|
46
|
+
self.logger.error(f"No elsets found for satellite {self.task.satelliteId}")
|
|
47
|
+
return None
|
|
48
|
+
most_recent_elset = max(
|
|
49
|
+
elsets,
|
|
50
|
+
key=lambda e: (
|
|
51
|
+
dtparser.isoparse(e["creationEpoch"])
|
|
52
|
+
if e.get("creationEpoch")
|
|
53
|
+
else dtparser.isoparse("1970-01-01T00:00:00Z")
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
return most_recent_elset
|
|
57
|
+
|
|
58
|
+
def upload_image_and_mark_complete(self, filepath):
|
|
59
|
+
upload_result = self.api_client.upload_image(self.task.id, self.telescope_record["id"], filepath)
|
|
60
|
+
if upload_result:
|
|
61
|
+
self.logger.info(f"Successfully uploaded image for task {self.task.id}")
|
|
62
|
+
else:
|
|
63
|
+
self.logger.error(f"Failed to upload image for task {self.task.id}")
|
|
64
|
+
try:
|
|
65
|
+
os.remove(filepath)
|
|
66
|
+
self.logger.info(f"Deleted local image file {filepath} after upload.")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
self.logger.error(f"Failed to delete local image file {filepath}: {e}")
|
|
69
|
+
marked_complete = self.api_client.mark_task_complete(self.task.id)
|
|
70
|
+
if not marked_complete:
|
|
71
|
+
task_check = self.api_client.get_telescope_tasks(self.telescope_record["id"])
|
|
72
|
+
for t in task_check:
|
|
73
|
+
if t["id"] == self.task.id and t.get("status") == "Succeeded":
|
|
74
|
+
self.logger.info(f"Task {self.task.id} is already marked complete.")
|
|
75
|
+
return True
|
|
76
|
+
self.logger.error(f"Failed to mark task {self.task.id} as complete.")
|
|
77
|
+
return False
|
|
78
|
+
self.logger.info(f"Marked task {self.task.id} as complete.")
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def execute(self):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def _get_skyfield_ground_station_and_satellite(self, satellite_data):
|
|
86
|
+
"""
|
|
87
|
+
Returns (ground_station, satellite, ts) Skyfield objects for the given satellite and elset.
|
|
88
|
+
"""
|
|
89
|
+
ts = load.timescale()
|
|
90
|
+
most_recent_elset = self._get_most_recent_elset(satellite_data)
|
|
91
|
+
if most_recent_elset is None:
|
|
92
|
+
raise ValueError("No valid elset available for satellite.")
|
|
93
|
+
|
|
94
|
+
ground_station = wgs84.latlon(
|
|
95
|
+
self.ground_station_record["latitude"],
|
|
96
|
+
self.ground_station_record["longitude"],
|
|
97
|
+
elevation_m=self.ground_station_record["altitude"],
|
|
98
|
+
)
|
|
99
|
+
satellite = EarthSatellite(most_recent_elset["tle"][0], most_recent_elset["tle"][1], satellite_data["name"], ts)
|
|
100
|
+
return ground_station, satellite, ts
|
|
101
|
+
|
|
102
|
+
def get_target_radec_and_rates(self, satellite_data, seconds_from_now: float = 0.0):
|
|
103
|
+
ground_station, satellite, ts = self._get_skyfield_ground_station_and_satellite(satellite_data)
|
|
104
|
+
difference = satellite - ground_station
|
|
105
|
+
days_to_add = seconds_from_now / (24 * 60 * 60) # Skyfield uses days
|
|
106
|
+
topocentric = difference.at(ts.now() + days_to_add)
|
|
107
|
+
target_ra, target_dec, _ = topocentric.radec()
|
|
108
|
+
|
|
109
|
+
# determine ra/dec travel rates
|
|
110
|
+
rates = topocentric.frame_latlon_and_rates(
|
|
111
|
+
ground_station
|
|
112
|
+
) # TODO can this be collapsed with .radec() call above?
|
|
113
|
+
target_dec_rate = rates[4]
|
|
114
|
+
target_ra_rate = rates[3]
|
|
115
|
+
|
|
116
|
+
return target_ra, target_dec, target_ra_rate, target_dec_rate
|
|
117
|
+
|
|
118
|
+
def predict_slew_time_seconds(self, satellite_data, seconds_from_now: float = 0.0) -> float:
|
|
119
|
+
current_scope_ra, current_scope_dec = self.hardware_adapter.get_telescope_direction()
|
|
120
|
+
current_target_ra, current_target_dec, _, _ = self.get_target_radec_and_rates(satellite_data, seconds_from_now)
|
|
121
|
+
|
|
122
|
+
ra_diff_deg = abs((current_target_ra.degrees - current_scope_ra)) # type: ignore
|
|
123
|
+
dec_diff_deg = abs(current_target_dec.degrees - current_scope_dec) # type: ignore
|
|
124
|
+
|
|
125
|
+
if ra_diff_deg > dec_diff_deg:
|
|
126
|
+
return ra_diff_deg / self.hardware_adapter.scope_slew_rate_degrees_per_second
|
|
127
|
+
else:
|
|
128
|
+
return dec_diff_deg / self.hardware_adapter.scope_slew_rate_degrees_per_second
|
|
129
|
+
|
|
130
|
+
def point_to_lead_position(self, satellite_data):
|
|
131
|
+
|
|
132
|
+
self.logger.debug(f"Using TLE {satellite_data['most_recent_elset']['tle']}")
|
|
133
|
+
|
|
134
|
+
max_angular_distance_deg = 0.3
|
|
135
|
+
attempts = 0
|
|
136
|
+
max_attempts = 10
|
|
137
|
+
while attempts < max_attempts:
|
|
138
|
+
attempts += 1
|
|
139
|
+
# Estimate lead position and slew time
|
|
140
|
+
lead_ra, lead_dec, est_slew_time = self.estimate_lead_position(satellite_data)
|
|
141
|
+
self.logger.info(
|
|
142
|
+
f"Pointing ahead to RA: {lead_ra.hours:.4f}h, DEC: {lead_dec.degrees:.4f}°, estimated slew time: {est_slew_time:.1f}s"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Move the scope
|
|
146
|
+
slew_start_time = time.time()
|
|
147
|
+
self.hardware_adapter.point_telescope(lead_ra.hours, lead_dec.degrees) # type: ignore
|
|
148
|
+
while self.hardware_adapter.telescope_is_moving():
|
|
149
|
+
self.logger.debug(f"Slewing to lead position for {satellite_data['name']}...")
|
|
150
|
+
time.sleep(0.1)
|
|
151
|
+
|
|
152
|
+
slew_duration = time.time() - slew_start_time
|
|
153
|
+
self.logger.info(
|
|
154
|
+
f"Telescope slew done, took {slew_duration:.1f} sec, off by {abs(slew_duration - est_slew_time):.1f} sec."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# check our alignment against the starfield
|
|
158
|
+
# is_aligned = self.hardware_adapter.perform_alignment(lead_ra.degrees, lead_dec.degrees) # type: ignore
|
|
159
|
+
# if not is_aligned:
|
|
160
|
+
# continue # try again with the new alignment offsets
|
|
161
|
+
|
|
162
|
+
# Check angular distance to satellite's current position
|
|
163
|
+
current_scope_ra, current_scope_dec = self.hardware_adapter.get_telescope_direction()
|
|
164
|
+
current_satellite_position = self.get_target_radec_and_rates(satellite_data)
|
|
165
|
+
current_angular_distance_deg = self.hardware_adapter.angular_distance(
|
|
166
|
+
current_scope_ra,
|
|
167
|
+
current_scope_dec,
|
|
168
|
+
current_satellite_position[0].degrees, # type: ignore
|
|
169
|
+
current_satellite_position[1].degrees, # type: ignore
|
|
170
|
+
)
|
|
171
|
+
self.logger.info(f"Current angular distance to satellite is {current_angular_distance_deg:.3f} degrees.")
|
|
172
|
+
if current_angular_distance_deg <= max_angular_distance_deg:
|
|
173
|
+
self.logger.info("Telescope is within acceptable range of target.")
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
def estimate_lead_position(
|
|
177
|
+
self,
|
|
178
|
+
satellite_data: dict,
|
|
179
|
+
max_iterations: int = 5,
|
|
180
|
+
tolerance: float = 0.1,
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
Iteratively estimate the future RA/Dec where the satellite will be when the telescope finishes slewing.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
satellite_data: Satellite data dict.
|
|
187
|
+
max_iterations: Maximum number of iterations.
|
|
188
|
+
tolerance: Convergence threshold in seconds.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Tuple of (target_ra, target_dec, estimated_slew_time)
|
|
192
|
+
"""
|
|
193
|
+
# Get initial estimate
|
|
194
|
+
est_slew_time = self.predict_slew_time_seconds(satellite_data)
|
|
195
|
+
for _ in range(max_iterations):
|
|
196
|
+
future_radec = self.get_target_radec_and_rates(satellite_data, est_slew_time)
|
|
197
|
+
try:
|
|
198
|
+
new_slew_time = self.predict_slew_time_seconds(satellite_data, est_slew_time)
|
|
199
|
+
except TypeError:
|
|
200
|
+
# Fallback for legacy predict_slew_time_seconds signature
|
|
201
|
+
new_slew_time = self.predict_slew_time_seconds(satellite_data)
|
|
202
|
+
if abs(new_slew_time - est_slew_time) < tolerance:
|
|
203
|
+
break
|
|
204
|
+
est_slew_time = new_slew_time
|
|
205
|
+
return future_radec[0], future_radec[1], est_slew_time
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from citrascope.tasks.scope.base_telescope_task import AbstractBaseTelescopeTask
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StaticTelescopeTask(AbstractBaseTelescopeTask):
|
|
7
|
+
def execute(self):
|
|
8
|
+
|
|
9
|
+
satellite_data = self.fetch_satellite()
|
|
10
|
+
if not satellite_data or satellite_data.get("most_recent_elset") is None:
|
|
11
|
+
raise ValueError("Could not fetch valid satellite data or TLE.")
|
|
12
|
+
self.point_to_lead_position(satellite_data)
|
|
13
|
+
|
|
14
|
+
# Take the image
|
|
15
|
+
filepath = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
|
|
16
|
+
return self.upload_image_and_mark_complete(filepath)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from citrascope.tasks.scope.base_telescope_task import AbstractBaseTelescopeTask
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TrackingTelescopeTask(AbstractBaseTelescopeTask):
|
|
7
|
+
def execute(self):
|
|
8
|
+
|
|
9
|
+
satellite_data = self.fetch_satellite()
|
|
10
|
+
if not satellite_data or satellite_data.get("most_recent_elset") is None:
|
|
11
|
+
raise ValueError("Could not fetch valid satellite data or TLE.")
|
|
12
|
+
|
|
13
|
+
self.point_to_lead_position(satellite_data)
|
|
14
|
+
|
|
15
|
+
# determine appropriate tracking rates based on satellite motion
|
|
16
|
+
_, _, target_ra_rate, target_dec_rate = self.get_target_radec_and_rates(satellite_data)
|
|
17
|
+
|
|
18
|
+
# Set the telescope tracking rates
|
|
19
|
+
tracking_set = self.hardware_adapter.set_custom_tracking_rate(
|
|
20
|
+
target_ra_rate.arcseconds.per_second, target_dec_rate.arcseconds.per_second
|
|
21
|
+
)
|
|
22
|
+
if not tracking_set:
|
|
23
|
+
self.logger.error("Failed to set tracking rates on telescope.")
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
# Take the image
|
|
27
|
+
filepath = self.hardware_adapter.take_image(self.task.id, 20.0) # 20 second exposure
|
|
28
|
+
return self.upload_image_and_mark_complete(filepath)
|
citrascope/tasks/task.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Task:
|
|
6
|
+
id: str
|
|
7
|
+
type: str
|
|
8
|
+
status: str
|
|
9
|
+
creationEpoch: str
|
|
10
|
+
updateEpoch: str
|
|
11
|
+
taskStart: str
|
|
12
|
+
taskStop: str
|
|
13
|
+
userId: str
|
|
14
|
+
username: str
|
|
15
|
+
satelliteId: str
|
|
16
|
+
satelliteName: str
|
|
17
|
+
telescopeId: str
|
|
18
|
+
telescopeName: str
|
|
19
|
+
groundStationId: str
|
|
20
|
+
groundStationName: str
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, data: dict) -> "Task":
|
|
24
|
+
return cls(
|
|
25
|
+
id=data.get("id"),
|
|
26
|
+
type=data.get("type", ""),
|
|
27
|
+
status=data.get("status"),
|
|
28
|
+
creationEpoch=data.get("creationEpoch", ""),
|
|
29
|
+
updateEpoch=data.get("updateEpoch", ""),
|
|
30
|
+
taskStart=data.get("taskStart", ""),
|
|
31
|
+
taskStop=data.get("taskStop", ""),
|
|
32
|
+
userId=data.get("userId", ""),
|
|
33
|
+
username=data.get("username", ""),
|
|
34
|
+
satelliteId=data.get("satelliteId", ""),
|
|
35
|
+
satelliteName=data.get("satelliteName", ""),
|
|
36
|
+
telescopeId=data.get("telescopeId", ""),
|
|
37
|
+
telescopeName=data.get("telescopeName", ""),
|
|
38
|
+
groundStationId=data.get("groundStationId", ""),
|
|
39
|
+
groundStationName=data.get("groundStationName", ""),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
return f"<Task {self.id} {self.type} {self.status}>"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: citrascope
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author-email: Christopher Stevens <chris@citra.space>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: httpx
|
|
10
|
+
Requires-Dist: pixelemon
|
|
11
|
+
Requires-Dist: pydantic-settings
|
|
12
|
+
Requires-Dist: pyindi-client
|
|
13
|
+
Requires-Dist: pytest-cov
|
|
14
|
+
Requires-Dist: python-dateutil
|
|
15
|
+
Requires-Dist: python-json-logger
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: skyfield
|
|
18
|
+
Requires-Dist: types-python-dateutil
|
|
19
|
+
Provides-Extra: build
|
|
20
|
+
Requires-Dist: build; extra == 'build'
|
|
21
|
+
Provides-Extra: deploy
|
|
22
|
+
Requires-Dist: twine; extra == 'deploy'
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black; extra == 'dev'
|
|
25
|
+
Requires-Dist: flake8; extra == 'dev'
|
|
26
|
+
Requires-Dist: flake8-pytest-style; extra == 'dev'
|
|
27
|
+
Requires-Dist: isort; extra == 'dev'
|
|
28
|
+
Requires-Dist: mockito; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
30
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
33
|
+
Provides-Extra: docs
|
|
34
|
+
Requires-Dist: sphinx; extra == 'docs'
|
|
35
|
+
Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
|
|
36
|
+
Requires-Dist: sphinx-markdown-builder; extra == 'docs'
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: mockito; extra == 'test'
|
|
39
|
+
Requires-Dist: pytest; extra == 'test'
|
|
40
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# CitraScope
|
|
44
|
+
[](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml) [](https://github.com/citra-space/citrascope/actions/workflows/docker-publish.yml)
|
|
45
|
+
|
|
46
|
+
Remotely control a telescope while it polls for tasks, collects observations, and delivers data for further processing.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- Connects to Citra.space's API and identifies itself as an online telescope
|
|
51
|
+
- Connects to configured INDI telescope and camera hardware
|
|
52
|
+
- Acts as a task daemon carrying out and remitting photography tasks
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
Install CitraScope from PyPI:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pip install citrascope
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This provides the `citrascope` command-line tool. To see available commands:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
citrascope --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
Run the CLI tool:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
citrascope start
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To connect to the Citra Dev server:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
citrascope start --dev
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Settings are managed via environment variables with the prefix `CITRASCOPE_`. You must configure your personal access token and telescope ID, as well as INDI server details. You can set these variables in your shell or in a `.env` file at the project root.
|
|
85
|
+
|
|
86
|
+
Example `.env` file:
|
|
87
|
+
|
|
88
|
+
```env
|
|
89
|
+
CITRASCOPE_PERSONAL_ACCESS_TOKEN=citra_pat_xxx
|
|
90
|
+
CITRASCOPE_TELESCOPE_ID=xxx
|
|
91
|
+
# CITRASCOPE_INDI_SERVER_URL=127.0.0.1
|
|
92
|
+
CITRASCOPE_INDI_SERVER_URL=host.docker.internal # use with devcontainer for accessing a localhost indi server
|
|
93
|
+
CITRASCOPE_INDI_SERVER_PORT=7624
|
|
94
|
+
CITRASCOPE_INDI_TELESCOPE_NAME=Telescope Simulator
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Variable descriptions:**
|
|
98
|
+
|
|
99
|
+
- `CITRASCOPE_PERSONAL_ACCESS_TOKEN`: Your CitraScope personal access token (required)
|
|
100
|
+
- `CITRASCOPE_TELESCOPE_ID`: Your telescope ID (required)
|
|
101
|
+
- `CITRASCOPE_INDI_SERVER_URL`: Hostname or IP address of the INDI server (default: `host.docker.internal` for devcontainers, or `127.0.0.1` for local)
|
|
102
|
+
- `CITRASCOPE_INDI_SERVER_PORT`: Port for the INDI server (default: `7624`)
|
|
103
|
+
- `CITRASCOPE_INDI_TELESCOPE_NAME`: Name of the INDI telescope device (default: `Telescope Simulator`)
|
|
104
|
+
|
|
105
|
+
You can copy `.env.example` to `.env` and tweak your values.
|
|
106
|
+
|
|
107
|
+
## Developer Setup
|
|
108
|
+
|
|
109
|
+
If you are developing on macOS or Windows, use the provided [VS Code Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) setup. The devcontainer provides a full Linux environment, which is required for the `pyindi-client` dependency to work. This is necessary because `pyindi-client` only works on Linux, and will not function natively on Mac or Windows.
|
|
110
|
+
|
|
111
|
+
By opening this project in VS Code and choosing "Reopen in Container" (or using the Dev Containers extension), you can develop and run the project seamlessly, regardless of your host OS.
|
|
112
|
+
|
|
113
|
+
The devcontainer also ensures all required system dependencies (like `cmake`) are installed automatically.
|
|
114
|
+
|
|
115
|
+
### Installing Development Dependencies
|
|
116
|
+
|
|
117
|
+
To install development dependencies (for code style, linting, and pre-commit hooks):
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
pip install '.[dev]'
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Setting up Pre-commit Hooks
|
|
124
|
+
|
|
125
|
+
This project uses [pre-commit](https://pre-commit.com/) to run code quality checks (like Flake8, Black, isort, etc.) automatically before each commit.
|
|
126
|
+
|
|
127
|
+
After installing the dev dependencies, enable the hooks with:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
pre-commit install
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
You can manually run all pre-commit checks on all files with:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
pre-commit run --all-files
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This ensures code style and quality checks are enforced for all contributors.
|
|
140
|
+
|
|
141
|
+
### Running and Debugging with VS Code
|
|
142
|
+
|
|
143
|
+
If you are using Visual Studio Code, you can run or debug the project directly using the pre-configured launch options in `.vscode/launch.json`:
|
|
144
|
+
|
|
145
|
+
- **Python: citrascope dev start** — Runs the main entry point with development options.
|
|
146
|
+
- **Python: citrascope dev start DEBUG logging** — Runs with development options and sets log level to DEBUG for more detailed output.
|
|
147
|
+
|
|
148
|
+
To use these, open the Run and Debug panel in VS Code, select the desired configuration, and click the Run or Debug button. This is a convenient way to start or debug the app without manually entering commands.
|
|
149
|
+
|
|
150
|
+
## Running Tests
|
|
151
|
+
|
|
152
|
+
This project uses [pytest](https://pytest.org/) for unit testing. All tests are located in the `tests/` directory.
|
|
153
|
+
|
|
154
|
+
To run tests manually:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
citrascope/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
citrascope/__main__.py,sha256=MlIzMVRL_tnvd8vJcZXENIglepirST3KDPfVpN7V9bE,673
|
|
3
|
+
citrascope/citra_scope_daemon.py,sha256=rQ4U7r2q5n9FSxM7E7xTdhpJjYCT7Mv_3PhUPkWwMTc,4073
|
|
4
|
+
citrascope/api/abstract_api_client.py,sha256=FG386RwHZtsCNaGGx0bhwxajyj_0B6SxU3p2w0R4tYU,472
|
|
5
|
+
citrascope/api/citra_api_client.py,sha256=7T7woGN-cjTYF8CNkGSjWXCKO-q0ufnBLGrdT2pdY3w,4173
|
|
6
|
+
citrascope/hardware/abstract_astro_hardware_adapter.py,sha256=JOw0PDoFrgicB5qZrurlLrVcDsjQNjHcxyFJ6674KJ8,3748
|
|
7
|
+
citrascope/hardware/indi_adapter.py,sha256=57AvH-AzthqXyefUgMq21GfcFfNh9WcGoara03NiJkg,12564
|
|
8
|
+
citrascope/logging/__init__.py,sha256=bXX2PX6MZelEX3fi_-lmT51uZCfN9WnLPZfx5_RswAA,101
|
|
9
|
+
citrascope/logging/_citrascope_logger.py,sha256=-KZ3ufQc3VOSlP1I3KC1YFDU7o880-O0iu3URlzRGlM,1056
|
|
10
|
+
citrascope/settings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
citrascope/settings/_citrascope_settings.py,sha256=JpuVFroZERPjbx58ZCw9BM9xr41qW6MNxm3sYGjYS14,1386
|
|
12
|
+
citrascope/tasks/runner.py,sha256=pUyQlrQkSNbM_SKl6AUFY8QSV2NzZfGvybGlzV7Q-BU,7320
|
|
13
|
+
citrascope/tasks/task.py,sha256=0u0oN56E6KaNz19ba_7WuY43Sk4CTXc8UPT7sdUpRXo,1287
|
|
14
|
+
citrascope/tasks/scope/base_telescope_task.py,sha256=He8W9OSHDL2tp56sY5dPdtYDswzp5HDMMeWpSNYNcgY,9198
|
|
15
|
+
citrascope/tasks/scope/static_telescope_task.py,sha256=DTrKZiOJ3ZPSDPvmMbyWkW50kS4I6Li6qC6SyrpWbaI,612
|
|
16
|
+
citrascope/tasks/scope/tracking_telescope_task.py,sha256=k5LEmEi_xnFHNjqPNYb8_tqDdCFD3YGe25Wh_brJXHk,1130
|
|
17
|
+
docs/index.md,sha256=YQDeVrN9AcbRzo88Jc4iRCO70gAh_4GSgImrJMwcSCo,1402
|
|
18
|
+
citrascope-0.1.0.dist-info/METADATA,sha256=u7Uc7lN6PZz78TUNFqmYe3IWBgxPdfudIpg31kuuVX0,5817
|
|
19
|
+
citrascope-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
20
|
+
citrascope-0.1.0.dist-info/entry_points.txt,sha256=fP22Lt8bNZ_whBowDnOWSADf_FUrgAWnIhqqPf5Xo2g,55
|
|
21
|
+
citrascope-0.1.0.dist-info/RECORD,,
|