satnogs-orbital-data 0.1__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.
@@ -0,0 +1 @@
1
+ """SATNOGS orbital data package."""
@@ -0,0 +1,26 @@
1
+ """Domain models for SATNOGS orbital data."""
2
+
3
+ from satnogs_orbital_data.domain.fetching import (
4
+ Credentials,
5
+ FetchAllResult,
6
+ FetchLatestResult,
7
+ OrbitalDataFormat,
8
+ SourceCollectionResult,
9
+ SourceConfig,
10
+ SourcedOrbitalData,
11
+ SourceFailure,
12
+ )
13
+ from satnogs_orbital_data.domain.orbital_data import OrbitalData, TleLines
14
+
15
+ __all__ = [
16
+ "Credentials",
17
+ "FetchAllResult",
18
+ "FetchLatestResult",
19
+ "OrbitalData",
20
+ "OrbitalDataFormat",
21
+ "SourceConfig",
22
+ "SourceCollectionResult",
23
+ "SourceFailure",
24
+ "SourcedOrbitalData",
25
+ "TleLines",
26
+ ]
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+
6
+ from satnogs_orbital_data.domain.orbital_data import OrbitalData
7
+
8
+
9
+ class OrbitalDataFormat(StrEnum):
10
+ OMM_CSV = "omm_csv"
11
+ OMM_JSON = "omm_json"
12
+ OMM_XML = "omm_xml"
13
+ TLE = "tle"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Credentials:
18
+ username: str | None = None
19
+ password: str | None = None
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class SourceConfig:
24
+ name: str
25
+ uri: str
26
+ data_format: OrbitalDataFormat
27
+ credentials: Credentials | None = None
28
+ min_request_interval_seconds: int | None = None
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class SourcedOrbitalData:
33
+ source_name: str
34
+ orbital_data: OrbitalData
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class SourceFailure:
39
+ source_name: str
40
+ uri: str
41
+ message: str
42
+ exception_type: str
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class FetchAllResult:
47
+ records: dict[int, list[SourcedOrbitalData]] = field(default_factory=dict)
48
+ failures: list[SourceFailure] = field(default_factory=list)
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class FetchLatestResult:
53
+ records: dict[int, SourcedOrbitalData] = field(default_factory=dict)
54
+ failures: list[SourceFailure] = field(default_factory=list)
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class SourceCollectionResult:
59
+ records: dict[int, list[SourcedOrbitalData]] = field(default_factory=dict)
60
+ failures: list[SourceFailure] = field(default_factory=list)
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+
7
+ from satnogs_orbital_data.settings import PLACEHOLDER_NORAD_ID
8
+
9
+ Primitive = str | int | float | bool
10
+ OmmJson = dict[str, Primitive | datetime]
11
+ ExtraFields = dict[str, Primitive]
12
+ TleLines = tuple[str, str, str]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class OrbitalData:
17
+ object_name: str
18
+ norad_cat_id: int
19
+ classification_type: str
20
+ object_id: str
21
+ epoch: datetime
22
+ mean_motion_dot: float
23
+ mean_motion_ddot: float
24
+ bstar: float
25
+ ephemeris_type: int
26
+ element_set_no: int
27
+ inclination: float
28
+ ra_of_asc_node: float
29
+ eccentricity: float
30
+ arg_of_pericenter: float
31
+ mean_anomaly: float
32
+ mean_motion: float
33
+ rev_at_epoch: int
34
+ center_name: str
35
+ ref_frame: str
36
+ time_system: str
37
+ mean_element_theory: str
38
+ gm: float | None = None
39
+ extra_fields: ExtraFields = field(default_factory=dict)
40
+
41
+ @staticmethod
42
+ def required_omm_field_names() -> list[str]:
43
+ return [
44
+ "OBJECT_NAME",
45
+ "NORAD_CAT_ID",
46
+ "CLASSIFICATION_TYPE",
47
+ "OBJECT_ID",
48
+ "EPOCH",
49
+ "MEAN_MOTION_DOT",
50
+ "MEAN_MOTION_DDOT",
51
+ "BSTAR",
52
+ "EPHEMERIS_TYPE",
53
+ "ELEMENT_SET_NO",
54
+ "INCLINATION",
55
+ "RA_OF_ASC_NODE",
56
+ "ECCENTRICITY",
57
+ "ARG_OF_PERICENTER",
58
+ "MEAN_ANOMALY",
59
+ "MEAN_MOTION",
60
+ "REV_AT_EPOCH",
61
+ "CENTER_NAME",
62
+ "REF_FRAME",
63
+ "TIME_SYSTEM",
64
+ "MEAN_ELEMENT_THEORY",
65
+ ]
66
+
67
+ @staticmethod
68
+ def optional_omm_field_names() -> list[str]:
69
+ return ["GM"]
70
+
71
+ @classmethod
72
+ def from_omm_json(cls, data: Mapping[str, object]) -> OrbitalData:
73
+ from satnogs_orbital_data.formats.omm_json import orbital_data_from_omm_json
74
+
75
+ return orbital_data_from_omm_json(dict(data))
76
+
77
+ @classmethod
78
+ def from_tle(cls, tle: TleLines) -> OrbitalData:
79
+ from satnogs_orbital_data.formats.tle import orbital_data_from_tle
80
+
81
+ return orbital_data_from_tle(tle)
82
+
83
+ def to_tle(self) -> TleLines:
84
+ from satnogs_orbital_data.formats.tle import orbital_data_to_tle
85
+
86
+ return orbital_data_to_tle(self)
87
+
88
+ def to_dummy_tle(self, placeholder_norad_id: int = PLACEHOLDER_NORAD_ID) -> TleLines:
89
+ from satnogs_orbital_data.formats.tle import orbital_data_to_dummy_tle
90
+
91
+ return orbital_data_to_dummy_tle(self, placeholder_norad_id)
92
+
93
+ def to_omm_json(self) -> OmmJson:
94
+ from satnogs_orbital_data.formats.omm_json import orbital_data_to_omm_json
95
+
96
+ return orbital_data_to_omm_json(self)
@@ -0,0 +1,25 @@
1
+ """Public fetching API."""
2
+
3
+ from satnogs_orbital_data.fetching.errors import (
4
+ SourceError,
5
+ SourceFetchError,
6
+ SourceParseError,
7
+ )
8
+ from satnogs_orbital_data.fetching.fetcher import (
9
+ fetch_all,
10
+ fetch_latest,
11
+ )
12
+ from satnogs_orbital_data.fetching.sources import (
13
+ get_default_sources,
14
+ get_failsafe_source,
15
+ )
16
+
17
+ __all__ = [
18
+ "SourceError",
19
+ "SourceFetchError",
20
+ "SourceParseError",
21
+ "fetch_all",
22
+ "fetch_latest",
23
+ "get_default_sources",
24
+ "get_failsafe_source",
25
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SourceError(Exception):
5
+ pass
6
+
7
+
8
+ class SourceFetchError(SourceError):
9
+ pass
10
+
11
+
12
+ class SourceParseError(SourceError):
13
+ pass
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Iterable, Sequence
5
+ from typing import Protocol
6
+
7
+ from satnogs_orbital_data.domain import (
8
+ FetchAllResult,
9
+ FetchLatestResult,
10
+ OrbitalDataFormat,
11
+ SourceCollectionResult,
12
+ SourceConfig,
13
+ SourcedOrbitalData,
14
+ SourceFailure,
15
+ )
16
+ from satnogs_orbital_data.domain.orbital_data import OrbitalData
17
+ from satnogs_orbital_data.fetching.errors import SourceError, SourceFetchError, SourceParseError
18
+ from satnogs_orbital_data.fetching.handlers import (
19
+ FileSourceHandler,
20
+ HttpSourceHandler,
21
+ SourceHandler,
22
+ SpaceTrackSourceHandler,
23
+ )
24
+ from satnogs_orbital_data.fetching.sources import (
25
+ DEFAULT_FAILSAFE_SOURCE,
26
+ get_default_sources,
27
+ get_failsafe_source,
28
+ )
29
+ from satnogs_orbital_data.formats.omm_csv import OmmCsvParser
30
+ from satnogs_orbital_data.formats.omm_json import OmmJsonParser
31
+ from satnogs_orbital_data.formats.tle import TleParser
32
+ from satnogs_orbital_data.settings import DEFAULT_TIMEOUT_SECONDS
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class OrbitalDataParser(Protocol):
38
+ def parse(self, raw: bytes) -> Iterable[OrbitalData]: ...
39
+
40
+
41
+ _PARSERS: dict[OrbitalDataFormat, OrbitalDataParser] = {
42
+ OrbitalDataFormat.OMM_CSV: OmmCsvParser(),
43
+ OrbitalDataFormat.OMM_JSON: OmmJsonParser(),
44
+ OrbitalDataFormat.TLE: TleParser(),
45
+ }
46
+ _HANDLERS: tuple[SourceHandler, ...] = (
47
+ SpaceTrackSourceHandler(),
48
+ FileSourceHandler(),
49
+ HttpSourceHandler(),
50
+ )
51
+
52
+
53
+ def _collect_all(
54
+ norad_ids: Iterable[int],
55
+ *,
56
+ sources: Sequence[SourceConfig] | None = None,
57
+ include_default_sources: bool = True,
58
+ include_failsafe: bool = True,
59
+ failsafe_source: SourceConfig = DEFAULT_FAILSAFE_SOURCE,
60
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
61
+ tls_verify: bool = True,
62
+ ) -> SourceCollectionResult:
63
+ requested_ids = set(norad_ids)
64
+ result = SourceCollectionResult()
65
+ if not requested_ids:
66
+ logger.debug("No NORAD IDs requested; skipping fetch")
67
+ return result
68
+
69
+ effective_sources = []
70
+ if include_default_sources:
71
+ effective_sources.extend(get_default_sources())
72
+ if sources is not None:
73
+ effective_sources.extend(sources)
74
+
75
+ logger.info(
76
+ "Fetching orbital data for %d NORAD IDs from %d sources",
77
+ len(requested_ids),
78
+ len(effective_sources),
79
+ )
80
+
81
+ for source in effective_sources:
82
+ try:
83
+ _collect_source(
84
+ source,
85
+ requested_ids,
86
+ timeout_seconds,
87
+ tls_verify,
88
+ result,
89
+ )
90
+ except SourceError as exc:
91
+ logger.warning(
92
+ "Failed to collect orbital data from %s: %s",
93
+ source.name,
94
+ exc,
95
+ )
96
+ result.failures.append(
97
+ SourceFailure(
98
+ source_name=source.name,
99
+ uri=source.uri,
100
+ message=str(exc),
101
+ exception_type=type(exc).__name__,
102
+ )
103
+ )
104
+
105
+ if include_failsafe:
106
+ missing_norad_ids = requested_ids - set(result.records.keys())
107
+ if missing_norad_ids:
108
+ logger.info(
109
+ "Trying failsafe source for %d missing NORAD IDs",
110
+ len(missing_norad_ids),
111
+ )
112
+ if "{norad_id}" not in failsafe_source.uri:
113
+ message = "Failsafe source URI must contain {norad_id}"
114
+ logger.warning(
115
+ "Failsafe source %s is not usable: %s",
116
+ failsafe_source.name,
117
+ message,
118
+ )
119
+ result.failures.append(
120
+ SourceFailure(
121
+ source_name=failsafe_source.name,
122
+ uri=failsafe_source.uri,
123
+ message=message,
124
+ exception_type=SourceFetchError.__name__,
125
+ )
126
+ )
127
+ else:
128
+ for norad_id in missing_norad_ids:
129
+ source = get_failsafe_source(norad_id, failsafe_source)
130
+ try:
131
+ _collect_source(
132
+ source,
133
+ {norad_id},
134
+ timeout_seconds,
135
+ tls_verify,
136
+ result,
137
+ )
138
+ except SourceError as exc:
139
+ logger.warning(
140
+ "Failsafe source failed for NORAD %s: %s",
141
+ norad_id,
142
+ exc,
143
+ )
144
+ result.failures.append(
145
+ SourceFailure(
146
+ source_name=source.name,
147
+ uri=source.uri,
148
+ message=str(exc),
149
+ exception_type=type(exc).__name__,
150
+ )
151
+ )
152
+
153
+ record_count = sum(len(records) for records in result.records.values())
154
+ logger.info(
155
+ "Fetched %d matching orbital data records for %d NORAD IDs with %d source failures",
156
+ record_count,
157
+ len(result.records),
158
+ len(result.failures),
159
+ )
160
+ return result
161
+
162
+
163
+ def fetch_all(
164
+ norad_ids: Iterable[int],
165
+ *,
166
+ sources: Sequence[SourceConfig] | None = None,
167
+ include_default_sources: bool = True,
168
+ include_failsafe: bool = True,
169
+ failsafe_source: SourceConfig = DEFAULT_FAILSAFE_SOURCE,
170
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
171
+ tls_verify: bool = True,
172
+ ) -> FetchAllResult:
173
+ result = _collect_all(
174
+ norad_ids,
175
+ sources=sources,
176
+ include_default_sources=include_default_sources,
177
+ include_failsafe=include_failsafe,
178
+ failsafe_source=failsafe_source,
179
+ timeout_seconds=timeout_seconds,
180
+ tls_verify=tls_verify,
181
+ )
182
+ return FetchAllResult(records=result.records, failures=result.failures)
183
+
184
+
185
+ def fetch_latest(
186
+ norad_ids: Iterable[int],
187
+ *,
188
+ sources: Sequence[SourceConfig] | None = None,
189
+ include_default_sources: bool = True,
190
+ include_failsafe: bool = True,
191
+ failsafe_source: SourceConfig = DEFAULT_FAILSAFE_SOURCE,
192
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
193
+ tls_verify: bool = True,
194
+ ) -> FetchLatestResult:
195
+ result = _collect_all(
196
+ norad_ids,
197
+ sources=sources,
198
+ include_default_sources=include_default_sources,
199
+ include_failsafe=include_failsafe,
200
+ failsafe_source=failsafe_source,
201
+ timeout_seconds=timeout_seconds,
202
+ tls_verify=tls_verify,
203
+ )
204
+ return FetchLatestResult(
205
+ records={
206
+ norad_id: max(candidates, key=lambda candidate: candidate.orbital_data.epoch)
207
+ for norad_id, candidates in result.records.items()
208
+ if candidates
209
+ },
210
+ failures=result.failures,
211
+ )
212
+
213
+
214
+ def _collect_source(
215
+ source: SourceConfig,
216
+ requested_ids: set[int],
217
+ timeout_seconds: float,
218
+ tls_verify: bool,
219
+ result: SourceCollectionResult,
220
+ ) -> None:
221
+ handler = _handler_for(source)
222
+ parser = _parser_for(source.data_format)
223
+ logger.info("Fetching orbital data from %s", source.name)
224
+ raw = handler.fetch(source, timeout_seconds, tls_verify)
225
+
226
+ try:
227
+ orbital_data_records = parser.parse(raw)
228
+ except (KeyError, TypeError, ValueError) as exc:
229
+ logger.debug("Failed to parse orbital data from %s", source.name, exc_info=True)
230
+ raise SourceParseError(str(exc)) from exc
231
+
232
+ matched_records = 0
233
+ for orbital_data in orbital_data_records:
234
+ if orbital_data.norad_cat_id not in requested_ids:
235
+ continue
236
+
237
+ matched_records += 1
238
+ result.records.setdefault(orbital_data.norad_cat_id, []).append(
239
+ SourcedOrbitalData(
240
+ source_name=source.name,
241
+ orbital_data=orbital_data,
242
+ )
243
+ )
244
+ logger.debug(
245
+ "Collected %d matching orbital data records from %s",
246
+ matched_records,
247
+ source.name,
248
+ )
249
+
250
+
251
+ def _handler_for(source: SourceConfig) -> SourceHandler:
252
+ for handler in _HANDLERS:
253
+ if handler.can_handle(source):
254
+ return handler
255
+ raise SourceFetchError(f"No source handler configured for source: {source.name}")
256
+
257
+
258
+ def _parser_for(data_format: OrbitalDataFormat) -> OrbitalDataParser:
259
+ try:
260
+ return _PARSERS[data_format]
261
+ except KeyError as exc:
262
+ raise SourceParseError(f"No parser configured for data format: {data_format}") from exc
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+ from urllib.parse import unquote, urlparse
9
+ from zipfile import BadZipFile, ZipFile, is_zipfile
10
+
11
+ import requests # type: ignore[import-untyped]
12
+
13
+ from satnogs_orbital_data.domain import OrbitalDataFormat, SourceConfig
14
+ from satnogs_orbital_data.fetching.errors import SourceFetchError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _SPACE_TRACK_GP_FORMATS = {
19
+ OrbitalDataFormat.TLE: "3le",
20
+ OrbitalDataFormat.OMM_CSV: "csv",
21
+ OrbitalDataFormat.OMM_JSON: "json",
22
+ }
23
+
24
+
25
+ class SourceHandler(Protocol):
26
+ def can_handle(self, source: SourceConfig) -> bool: ...
27
+
28
+ def fetch(self, source: SourceConfig, timeout_seconds: float, tls_verify: bool) -> bytes: ...
29
+
30
+
31
+ class FileSourceHandler:
32
+ def can_handle(self, source: SourceConfig) -> bool:
33
+ scheme = urlparse(source.uri).scheme
34
+ return scheme in {"", "file"}
35
+
36
+ def fetch(self, source: SourceConfig, timeout_seconds: float, tls_verify: bool) -> bytes:
37
+ try:
38
+ path = _file_path_from_uri(source.uri)
39
+ logger.debug("Reading orbital data file source %s", path)
40
+ return _extract_zip_if_needed(path.read_bytes())
41
+ except (OSError, BadZipFile, ValueError) as exc:
42
+ raise SourceFetchError(str(exc)) from exc
43
+
44
+
45
+ class HttpSourceHandler:
46
+ def can_handle(self, source: SourceConfig) -> bool:
47
+ scheme = urlparse(source.uri).scheme
48
+ return scheme in {"http", "https"}
49
+
50
+ def fetch(self, source: SourceConfig, timeout_seconds: float, tls_verify: bool) -> bytes:
51
+ try:
52
+ logger.debug("Requesting orbital data source %s", source.uri)
53
+ response = requests.get(
54
+ source.uri,
55
+ timeout=timeout_seconds,
56
+ verify=tls_verify,
57
+ )
58
+ response.raise_for_status()
59
+ logger.debug(
60
+ "Fetched %d bytes from %s",
61
+ len(response.content),
62
+ source.name,
63
+ )
64
+ return _extract_zip_if_needed(response.content)
65
+ except (requests.RequestException, BadZipFile, ValueError) as exc:
66
+ raise SourceFetchError(str(exc)) from exc
67
+
68
+
69
+ class SpaceTrackSourceHandler:
70
+ def can_handle(self, source: SourceConfig) -> bool:
71
+ parsed = urlparse(source.uri)
72
+ return parsed.scheme in {"http", "https"} and "space-track.org" in parsed.netloc
73
+
74
+ def fetch(self, source: SourceConfig, timeout_seconds: float, tls_verify: bool) -> bytes:
75
+ credentials = source.credentials
76
+ if not credentials or not credentials.username or not credentials.password:
77
+ raise SourceFetchError("Space-Track source requires username and password")
78
+
79
+ try:
80
+ logger.debug(
81
+ "Requesting Space-Track GP data from %s as %s",
82
+ _space_track_base_url(source.uri),
83
+ source.data_format,
84
+ )
85
+ payload = _fetch_space_track_gp(
86
+ credentials.username,
87
+ credentials.password,
88
+ _space_track_base_url(source.uri),
89
+ _space_track_gp_format(source.data_format),
90
+ )
91
+ except SourceFetchError:
92
+ raise
93
+ except Exception as exc:
94
+ raise SourceFetchError(str(exc)) from exc
95
+
96
+ raw = _payload_to_bytes(payload)
97
+ logger.debug("Fetched %d bytes from Space-Track source %s", len(raw), source.name)
98
+ return raw
99
+
100
+
101
+ def _file_path_from_uri(uri: str) -> Path:
102
+ parsed = urlparse(uri)
103
+ if parsed.scheme == "":
104
+ return Path(uri)
105
+ if parsed.scheme != "file":
106
+ raise ValueError(f"Unsupported file source URI scheme: {parsed.scheme}")
107
+ if parsed.netloc not in {"", "localhost"}:
108
+ raise ValueError(f"Unsupported file source host: {parsed.netloc}")
109
+ return Path(unquote(parsed.path))
110
+
111
+
112
+ def _fetch_space_track_gp(
113
+ username: str,
114
+ password: str,
115
+ base_url: str,
116
+ space_track_format: str,
117
+ ) -> object:
118
+ try:
119
+ from spacetrack import SpaceTrackClient # type: ignore[import-not-found, import-untyped]
120
+ except ModuleNotFoundError as exc:
121
+ raise SourceFetchError("spacetrack package is required for Space-Track sources") from exc
122
+
123
+ with SpaceTrackClient(username, password, base_url=base_url) as client:
124
+ return client.gp(epoch=">now-30", orderby=["norad_cat_id"], format=space_track_format)
125
+
126
+
127
+ def _space_track_base_url(uri: str) -> str:
128
+ parsed = urlparse(uri)
129
+ return f"{parsed.scheme}://{parsed.netloc}"
130
+
131
+
132
+ def _space_track_gp_format(data_format: OrbitalDataFormat) -> str:
133
+ try:
134
+ return _SPACE_TRACK_GP_FORMATS[data_format]
135
+ except KeyError as exc:
136
+ raise SourceFetchError(f"Space-Track does not support data format: {data_format}") from exc
137
+
138
+
139
+ def _payload_to_bytes(payload: object) -> bytes:
140
+ if isinstance(payload, bytes):
141
+ return payload
142
+ if isinstance(payload, str):
143
+ return payload.encode("utf-8")
144
+ return json.dumps(payload, separators=(",", ":")).encode("utf-8")
145
+
146
+
147
+ def _extract_zip_if_needed(raw: bytes) -> bytes:
148
+ with BytesIO(raw) as buffer:
149
+ if not is_zipfile(buffer):
150
+ return raw
151
+ buffer.seek(0)
152
+ logger.debug("Extracting zip orbital data source")
153
+ return _read_zip_files(buffer)
154
+
155
+
156
+ def _read_zip_files(buffer: BytesIO) -> bytes:
157
+ parts: list[bytes] = []
158
+ with ZipFile(buffer) as archive:
159
+ for member in archive.infolist():
160
+ if not member.is_dir():
161
+ logger.debug("Reading zip member %s", member.filename)
162
+ parts.append(archive.read(member).replace(b"\r\n", b"\n"))
163
+ if not parts:
164
+ raise ValueError("Zip source contains no files")
165
+ return b"\n".join(parts)