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.
- satnogs_orbital_data/__init__.py +1 -0
- satnogs_orbital_data/domain/__init__.py +26 -0
- satnogs_orbital_data/domain/fetching.py +60 -0
- satnogs_orbital_data/domain/orbital_data.py +96 -0
- satnogs_orbital_data/fetching/__init__.py +25 -0
- satnogs_orbital_data/fetching/errors.py +13 -0
- satnogs_orbital_data/fetching/fetcher.py +262 -0
- satnogs_orbital_data/fetching/handlers.py +165 -0
- satnogs_orbital_data/fetching/sources.py +55 -0
- satnogs_orbital_data/formats/__init__.py +35 -0
- satnogs_orbital_data/formats/common.py +89 -0
- satnogs_orbital_data/formats/omm_csv.py +63 -0
- satnogs_orbital_data/formats/omm_json.py +48 -0
- satnogs_orbital_data/formats/tle.py +151 -0
- satnogs_orbital_data/py.typed +0 -0
- satnogs_orbital_data/settings.py +2 -0
- satnogs_orbital_data-0.1.dist-info/METADATA +46 -0
- satnogs_orbital_data-0.1.dist-info/RECORD +21 -0
- satnogs_orbital_data-0.1.dist-info/WHEEL +5 -0
- satnogs_orbital_data-0.1.dist-info/licenses/LICENSE +661 -0
- satnogs_orbital_data-0.1.dist-info/top_level.txt +1 -0
|
@@ -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,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)
|