satnogs-predict 0.4__tar.gz → 0.6__tar.gz

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.
Files changed (56) hide show
  1. {satnogs_predict-0.4 → satnogs_predict-0.6}/PKG-INFO +1 -1
  2. {satnogs_predict-0.4 → satnogs_predict-0.6}/docs/docs.md +37 -12
  3. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/__init__.py +2 -0
  4. satnogs_predict-0.6/src/satnogs_predict/core/__init__.py +15 -0
  5. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/core/engine.py +17 -0
  6. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/__init__.py +2 -1
  7. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/observer.py +5 -2
  8. satnogs_predict-0.6/src/satnogs_predict/domain/orbit.py +89 -0
  9. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/propagation/propagator.py +6 -2
  10. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/tle.py +3 -2
  11. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict.egg-info/PKG-INFO +1 -1
  12. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict.egg-info/SOURCES.txt +1 -0
  13. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/core/test_engine.py +21 -0
  14. satnogs_predict-0.6/tests/domain/test_observer.py +44 -0
  15. satnogs_predict-0.6/tests/domain/test_orbit.py +146 -0
  16. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/domain/test_required_fields.py +6 -22
  17. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/propagation/test_propagator.py +35 -1
  18. satnogs_predict-0.4/src/satnogs_predict/core/__init__.py +0 -3
  19. satnogs_predict-0.4/src/satnogs_predict/domain/orbit.py +0 -53
  20. satnogs_predict-0.4/tests/domain/test_orbit.py +0 -37
  21. {satnogs_predict-0.4 → satnogs_predict-0.6}/.gitignore +0 -0
  22. {satnogs_predict-0.4 → satnogs_predict-0.6}/.gitlab-ci.yml +0 -0
  23. {satnogs_predict-0.4 → satnogs_predict-0.6}/.pre-commit-config.yaml +0 -0
  24. {satnogs_predict-0.4 → satnogs_predict-0.6}/.python-version +0 -0
  25. {satnogs_predict-0.4 → satnogs_predict-0.6}/CONTRIBUTING.md +0 -0
  26. {satnogs_predict-0.4 → satnogs_predict-0.6}/LICENSE +0 -0
  27. {satnogs_predict-0.4 → satnogs_predict-0.6}/README.md +0 -0
  28. {satnogs_predict-0.4 → satnogs_predict-0.6}/pyproject.toml +0 -0
  29. {satnogs_predict-0.4 → satnogs_predict-0.6}/setup.cfg +0 -0
  30. {satnogs_predict-0.4 → satnogs_predict-0.6}/setup.py +0 -0
  31. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/constraints/__init__.py +0 -0
  32. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/constraints/constraints.py +0 -0
  33. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/constraints.py +0 -0
  34. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/geometry.py +0 -0
  35. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/planner.py +0 -0
  36. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/time.py +0 -0
  37. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/validation.py +0 -0
  38. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/domain/window.py +0 -0
  39. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/planning/__init__.py +0 -0
  40. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/planning/planner.py +0 -0
  41. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/propagation/__init__.py +0 -0
  42. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict/py.typed +0 -0
  43. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict.egg-info/dependency_links.txt +0 -0
  44. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict.egg-info/requires.txt +0 -0
  45. {satnogs_predict-0.4 → satnogs_predict-0.6}/src/satnogs_predict.egg-info/top_level.txt +0 -0
  46. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/__init__.py +0 -0
  47. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/conftest.py +0 -0
  48. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/constraints/test_constraints.py +0 -0
  49. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/domain/test_planner_domain.py +0 -0
  50. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/domain/test_time.py +0 -0
  51. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/domain/test_window.py +0 -0
  52. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/helpers.py +0 -0
  53. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/integration/test_fake_tle_integration.py +0 -0
  54. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/planning/test_planner.py +0 -0
  55. {satnogs_predict-0.4 → satnogs_predict-0.6}/tests/test_tle.py +0 -0
  56. {satnogs_predict-0.4 → satnogs_predict-0.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: satnogs-predict
3
- Version: 0.4
3
+ Version: 0.6
4
4
  Summary: A package for calculating passes and observation windows for satellites.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -9,8 +9,9 @@
9
9
  - [3.3 - Time Modeling](#33---time-modeling)
10
10
  - [4. Observation Windows \& samples](#4-observation-windows--samples)
11
11
  - [4.1 - Samples](#41---samples)
12
- - [4.2 - Observation Windows](#42---observation-windows)
13
- - [4.3 - Constraints](#43---constraints)
12
+ - [4.2 - Doppler correction](#42---doppler-correction)
13
+ - [4.3 - Observation Windows](#43---observation-windows)
14
+ - [4.4 - Constraints](#44---constraints)
14
15
  - [5. Planning](#5-planning)
15
16
  - [5.1 - Planning is implemented by providing a config class instance:](#51---planning-is-implemented-by-providing-a-config-class-instance)
16
17
  - [5.2 - Overlap Handling](#52---overlap-handling)
@@ -84,16 +85,25 @@ The top-level exports define the supported API contract.
84
85
 
85
86
  ## 3.1 - Satellite
86
87
 
87
- Represents orbital information, currently derived from TLE data.
88
- (In the future, more ways may be added.)
88
+ Represents orbital information derived from TLE or OMM data.
89
89
 
90
90
  ```python
91
91
  satellite = Satellite.from_tle(
92
- line0=tle['tle0'], line1=tle['tle1'], line2=tle['tle2'], identifier="XSKZ-5603-1870-9019-3066"
92
+ line0=tle['tle0'], line1=tle['tle1'], line2=tle['tle2']
93
93
  )
94
94
  ```
95
95
 
96
- The `identifier` is required. It is used both as part of the public model and as the key for the internal propagation cache.
96
+ You can also create a satellite from an OMM element dictionary. The field names and
97
+ values are passed through to Skyfield's `EarthSatellite.from_omm` support:
98
+
99
+ ```python
100
+ satellite = Satellite.from_omm(
101
+ fields=omm_fields,
102
+ )
103
+ ```
104
+
105
+ The `identifier` is optional and defaults to a random UUID. Provide an explicit
106
+ identifier when you need a stable public value or cache key.
97
107
 
98
108
  For testing or development, you can also generate a synthetic TLE:
99
109
 
@@ -119,12 +129,12 @@ station = Observer(
119
129
  lat_deg=37.983810,
120
130
  lon_deg=23.727539,
121
131
  elevation_meters=100,
122
- horizon_deg=10.0,
123
- identifier="360"
132
+ horizon_deg=10.0
124
133
  )
125
134
  ```
126
135
 
127
- The `identifier` is required here as well, and is also used by the internal propagation cache.
136
+ The `identifier` is optional here as well and defaults to a random UUID. Provide one
137
+ when you need a stable public value or cache key.
128
138
 
129
139
 
130
140
  ## 3.3 - Time Modeling
@@ -211,9 +221,24 @@ class RangeSample:
211
221
  range_rate: float
212
222
  ```
213
223
 
214
- `range_m` is the observer-to-satellite distance in meters, and `range_rate` is the radial velocity in meters per second.
224
+ `range_m` is the observer-to-satellite distance in meters, and `range_rate` is the
225
+ rate of change of that distance in meters per second.
226
+
227
+ ## 4.2 - Doppler correction
228
+
229
+ Use `get_doppler_adjusted_frequency` to apply the Doppler correction to a
230
+ base frequency:
231
+
232
+ ```python
233
+ from satnogs_predict import get_doppler_adjusted_frequency
234
+
235
+ adjusted_frequency_hz = get_doppler_adjusted_frequency(
236
+ range_rate_m_per_s=range_sample.range_rate,
237
+ frequency_hz=145_000_000.0,
238
+ )
239
+ ```
215
240
 
216
- ## 4.2 - Observation Windows
241
+ ## 4.3 - Observation Windows
217
242
 
218
243
  An observation window represents a pass or a subset of a pass.
219
244
 
@@ -257,7 +282,7 @@ If the window has an overlap with the provided already scheduled ranges, `overla
257
282
 
258
283
  Note: if a later constraint modifies the window length, `overlap_ratio` is currently not recalculated. It remains the value computed earlier during planning.
259
284
 
260
- ## 4.3 - Constraints
285
+ ## 4.4 - Constraints
261
286
 
262
287
  When searching for observation windows (more on this below), certain constraints can be applied. Currently supported constraints are:
263
288
 
@@ -4,6 +4,7 @@ from .core.engine import (
4
4
  find_observation_windows,
5
5
  get_altaz,
6
6
  get_and_validate_pass_from_range,
7
+ get_doppler_adjusted_frequency,
7
8
  get_range,
8
9
  )
9
10
  from .domain import * # noqa: F401,F403
@@ -19,6 +20,7 @@ __all__ += [
19
20
  "find_observation_windows",
20
21
  "get_altaz",
21
22
  "get_and_validate_pass_from_range",
23
+ "get_doppler_adjusted_frequency",
22
24
  "get_range",
23
25
  ]
24
26
  __all__ += ["generate_fake_tle"]
@@ -0,0 +1,15 @@
1
+ from .engine import (
2
+ find_observation_windows,
3
+ get_altaz,
4
+ get_and_validate_pass_from_range,
5
+ get_doppler_adjusted_frequency,
6
+ get_range,
7
+ )
8
+
9
+ __all__ = [
10
+ "find_observation_windows",
11
+ "get_altaz",
12
+ "get_and_validate_pass_from_range",
13
+ "get_doppler_adjusted_frequency",
14
+ "get_range",
15
+ ]
@@ -1,5 +1,7 @@
1
1
  from typing import Sequence
2
2
 
3
+ from skyfield.constants import C # type: ignore[import-untyped]
4
+
3
5
  from satnogs_predict.constraints.constraints import Constraint
4
6
  from satnogs_predict.domain.constraints import ConstraintAppliedResult, ConstraintContext
5
7
  from satnogs_predict.domain.geometry import GeometrySample, RangeSample
@@ -101,6 +103,21 @@ def get_range(satellite: Satellite, observer: Observer, instant: Instant) -> Ran
101
103
  return _propagation_get_range(satellite, observer, instant)
102
104
 
103
105
 
106
+ def get_doppler_adjusted_frequency(range_rate_m_per_s: float, frequency_hz: float) -> float:
107
+ """
108
+ Return the Doppler-adjusted frequency in Hz.
109
+
110
+ Args:
111
+ range_rate_m_per_s: Range rate in meters per second. Positive values mean the
112
+ satellite is moving away from the observer.
113
+ frequency_hz: Nominal transmit or receive frequency in Hz.
114
+
115
+ Returns:
116
+ Doppler-corrected frequency
117
+ """
118
+ return frequency_hz * (1 - (range_rate_m_per_s / C))
119
+
120
+
104
121
  def get_and_validate_pass_from_range(
105
122
  satellite: Satellite,
106
123
  observer: Observer,
@@ -1,7 +1,7 @@
1
1
  from .constraints import ConstraintAppliedResult, ConstraintCheckFailedError, ConstraintContext
2
2
  from .geometry import GeometrySample, RangeSample
3
3
  from .observer import Observer
4
- from .orbit import TLE, Orbit, OrbitRepresentation, Satellite
4
+ from .orbit import OMM, TLE, Orbit, OrbitRepresentation, Satellite
5
5
  from .planner import OverlapHandling, PlanningConfig
6
6
  from .time import (
7
7
  Duration,
@@ -17,6 +17,7 @@ __all__ = [
17
17
  "GeometrySample",
18
18
  "RangeSample",
19
19
  "Observer",
20
+ "OMM",
20
21
  "TLE",
21
22
  "OrbitRepresentation",
22
23
  "Orbit",
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
+ from uuid import uuid4
4
5
 
5
6
  from .validation import ensure_required_fields_not_none
6
7
 
@@ -13,7 +14,9 @@ class Observer:
13
14
  lon_deg: float
14
15
  elevation_meters: int
15
16
  horizon_deg: float
16
- identifier: str
17
+ identifier: str = field(default_factory=lambda: str(uuid4()))
17
18
 
18
19
  def __post_init__(self) -> None:
20
+ if self.identifier is None:
21
+ object.__setattr__(self, "identifier", str(uuid4()))
19
22
  ensure_required_fields_not_none(self)
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from enum import IntEnum
6
+ from uuid import uuid4
7
+
8
+ from .validation import ensure_required_fields_not_none
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class TLE:
13
+ line0: str
14
+ line1: str
15
+ line2: str
16
+
17
+ def __post_init__(self) -> None:
18
+ ensure_required_fields_not_none(self)
19
+
20
+ def to_list(self) -> list[str]:
21
+ return [self.line0, self.line1, self.line2]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class OMM:
26
+ fields: dict[str, str]
27
+
28
+ def __post_init__(self) -> None:
29
+ ensure_required_fields_not_none(self)
30
+
31
+ def to_dict(self) -> dict[str, str]:
32
+ return dict(self.fields)
33
+
34
+
35
+ class OrbitRepresentation(IntEnum):
36
+ TLE = 0
37
+ OMM = 1
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class Orbit:
42
+ representation: OrbitRepresentation
43
+ data: TLE | OMM
44
+
45
+ def __post_init__(self) -> None:
46
+ ensure_required_fields_not_none(self)
47
+
48
+ @classmethod
49
+ def from_omm(
50
+ cls,
51
+ fields: Mapping[str, str],
52
+ ):
53
+ return cls(representation=OrbitRepresentation.OMM, data=OMM(fields=dict(fields)))
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Satellite:
58
+ orbit: Orbit
59
+ identifier: str = field(default_factory=lambda: str(uuid4()))
60
+
61
+ def __post_init__(self) -> None:
62
+ if self.identifier is None:
63
+ object.__setattr__(self, "identifier", str(uuid4()))
64
+ ensure_required_fields_not_none(self)
65
+
66
+ @classmethod
67
+ def from_tle(
68
+ cls,
69
+ line0: str,
70
+ line1: str,
71
+ line2: str,
72
+ identifier: str | None = None,
73
+ ):
74
+ tle = TLE(line0=line0, line1=line1, line2=line2)
75
+ orbit = Orbit(representation=OrbitRepresentation.TLE, data=tle)
76
+ if identifier is None:
77
+ return cls(orbit=orbit)
78
+ return cls(orbit=orbit, identifier=identifier)
79
+
80
+ @classmethod
81
+ def from_omm(
82
+ cls,
83
+ fields: Mapping[str, str],
84
+ identifier: str | None = None,
85
+ ):
86
+ orbit = Orbit.from_omm(fields)
87
+ if identifier is None:
88
+ return cls(orbit=orbit)
89
+ return cls(orbit=orbit, identifier=identifier)
@@ -1,4 +1,5 @@
1
1
  from dataclasses import dataclass
2
+ from typing import cast
2
3
 
3
4
  from skyfield.api import Angle, EarthSatellite, Time, Topos, load # type: ignore[import-untyped]
4
5
  from skyfield.searchlib import find_maxima # type: ignore[import-untyped]
@@ -6,7 +7,7 @@ from skyfield.units import Distance, Velocity # type: ignore[import-untyped]
6
7
 
7
8
  from satnogs_predict.domain.geometry import GeometrySample, RangeSample
8
9
  from satnogs_predict.domain.observer import Observer
9
- from satnogs_predict.domain.orbit import TLE, OrbitRepresentation, Satellite
10
+ from satnogs_predict.domain.orbit import OMM, TLE, OrbitRepresentation, Satellite
10
11
  from satnogs_predict.domain.time import (
11
12
  Instant,
12
13
  TimeRange,
@@ -82,13 +83,16 @@ def _skyfield_sat_from_satellite(satellite: Satellite) -> EarthSatellite:
82
83
 
83
84
  match satellite.orbit.representation:
84
85
  case OrbitRepresentation.TLE:
85
- tle: TLE = satellite.orbit.data
86
+ tle = cast(TLE, satellite.orbit.data)
86
87
  skyfield_sat = EarthSatellite(
87
88
  tle.line1,
88
89
  tle.line2,
89
90
  satellite.identifier,
90
91
  ts,
91
92
  )
93
+ case OrbitRepresentation.OMM:
94
+ omm = cast(OMM, satellite.orbit.data)
95
+ skyfield_sat = EarthSatellite.from_omm(ts, omm.to_dict())
92
96
  case _:
93
97
  raise ValueError("Unknown orbit representation")
94
98
 
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from datetime import datetime, timezone
5
5
 
6
- from skyfield.api import load # type: ignore[import-untyped]
6
+ from skyfield.api import load, wgs84 # type: ignore[import-untyped]
7
7
 
8
8
  from satnogs_predict.domain.orbit import TLE
9
9
 
@@ -67,7 +67,8 @@ def generate_fake_tle(
67
67
  skyfield_time = _ts.from_datetime(epoch)
68
68
 
69
69
  # Derived values
70
- right_ascension = (skyfield_time.gmst * 15.0 + longitude) % 360.0
70
+ observer_location = wgs84.latlon(latitude, longitude)
71
+ right_ascension = (observer_location.lst_hours_at(skyfield_time) * 15.0) % 360.0
71
72
  mean_anomaly = latitude % 360.0
72
73
 
73
74
  epoch_year = epoch.year
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: satnogs-predict
3
- Version: 0.4
3
+ Version: 0.6
4
4
  Summary: A package for calculating passes and observation windows for satellites.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -40,6 +40,7 @@ tests/helpers.py
40
40
  tests/test_tle.py
41
41
  tests/constraints/test_constraints.py
42
42
  tests/core/test_engine.py
43
+ tests/domain/test_observer.py
43
44
  tests/domain/test_orbit.py
44
45
  tests/domain/test_planner_domain.py
45
46
  tests/domain/test_required_fields.py
@@ -272,6 +272,27 @@ class TestGetRange:
272
272
  }
273
273
 
274
274
 
275
+ class TestGetDopplerAdjustedFrequency:
276
+ def test_given_zero_range_rate_when_getting_then_returns_nominal_frequency(self) -> None:
277
+ assert engine.get_doppler_adjusted_frequency(
278
+ range_rate_m_per_s=0.0, frequency_hz=145_000_000.0
279
+ ) == pytest.approx(145_000_000.0)
280
+
281
+ def test_given_receding_range_rate_when_getting_then_returns_lower_frequency(self) -> None:
282
+ assert engine.get_doppler_adjusted_frequency(
283
+ range_rate_m_per_s=2_997.92458,
284
+ frequency_hz=145_000_000.0,
285
+ ) == pytest.approx(144_998_550.0)
286
+
287
+ def test_given_approaching_range_rate_when_getting_then_returns_higher_frequency(
288
+ self,
289
+ ) -> None:
290
+ assert engine.get_doppler_adjusted_frequency(
291
+ range_rate_m_per_s=-2_997.92458,
292
+ frequency_hz=145_000_000.0,
293
+ ) == pytest.approx(145_001_450.0)
294
+
295
+
275
296
  class TestEnsureWindowRespectConstraints:
276
297
  def test_given_valid_and_rejected_windows_then_returns_expected(self, monkeypatch) -> None:
277
298
  thepass = window_seconds(0, 10)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from uuid import UUID
4
+
5
+ from satnogs_predict.domain.observer import Observer
6
+
7
+
8
+ class TestObserver:
9
+ def test_given_no_identifier_when_created_then_generates_identifier(self) -> None:
10
+ observer = Observer(
11
+ lat_deg=0.0,
12
+ lon_deg=0.0,
13
+ elevation_meters=0,
14
+ horizon_deg=0.0,
15
+ )
16
+
17
+ assert UUID(observer.identifier)
18
+
19
+ def test_given_none_identifier_when_created_then_generates_identifier(self) -> None:
20
+ observer = Observer(
21
+ lat_deg=0.0,
22
+ lon_deg=0.0,
23
+ elevation_meters=0,
24
+ horizon_deg=0.0,
25
+ identifier=None, # type: ignore[arg-type]
26
+ )
27
+
28
+ assert UUID(observer.identifier)
29
+
30
+ def test_given_no_identifier_when_created_twice_then_generates_unique_identifiers(self) -> None:
31
+ first = Observer(
32
+ lat_deg=0.0,
33
+ lon_deg=0.0,
34
+ elevation_meters=0,
35
+ horizon_deg=0.0,
36
+ )
37
+ second = Observer(
38
+ lat_deg=0.0,
39
+ lon_deg=0.0,
40
+ elevation_meters=0,
41
+ horizon_deg=0.0,
42
+ )
43
+
44
+ assert first.identifier != second.identifier
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from uuid import UUID
4
+
5
+ from satnogs_predict.domain.orbit import OMM, TLE, Orbit, OrbitRepresentation, Satellite
6
+ from tests.helpers import next_satellite_identifier
7
+
8
+ OMM_FIELDS = {
9
+ "OBJECT_NAME": "ISS (ZARYA)",
10
+ "NORAD_CAT_ID": "25544",
11
+ "OBJECT_ID": "1998-067A",
12
+ "CLASSIFICATION_TYPE": "U",
13
+ "EPHEMERIS_TYPE": "0",
14
+ "ELEMENT_SET_NO": "999",
15
+ "REV_AT_EPOCH": "54773",
16
+ "EPOCH": "2026-01-13T05:00:55.753",
17
+ "MEAN_MOTION": "15.49269250",
18
+ "ECCENTRICITY": "0.0007741",
19
+ "INCLINATION": "51.6330",
20
+ "RA_OF_ASC_NODE": "346.6801",
21
+ "ARG_OF_PERICENTER": "12.6584",
22
+ "MEAN_ANOMALY": "347.4598",
23
+ "MEAN_MOTION_DOT": ".00008852",
24
+ "MEAN_MOTION_DDOT": "0",
25
+ "BSTAR": ".00016699",
26
+ }
27
+
28
+
29
+ class TestTle:
30
+ def test_given_tle_when_to_list_then_returns_three_lines_in_order(self) -> None:
31
+ tle = TLE(line0="LINE0", line1="LINE1", line2="LINE2")
32
+
33
+ assert tle.to_list() == ["LINE0", "LINE1", "LINE2"]
34
+
35
+
36
+ class TestOmm:
37
+ def test_given_omm_when_to_dict_then_returns_copy_of_fields(self) -> None:
38
+ omm = OMM(fields=OMM_FIELDS)
39
+
40
+ result = omm.to_dict()
41
+ result["OBJECT_NAME"] = "CHANGED"
42
+
43
+ assert result != omm.fields
44
+ assert omm.fields == OMM_FIELDS
45
+
46
+
47
+ class TestOrbitFromOmm:
48
+ def test_given_fields_when_created_then_builds_omm_orbit(self) -> None:
49
+ orbit = Orbit.from_omm(OMM_FIELDS)
50
+
51
+ assert orbit.representation == OrbitRepresentation.OMM
52
+ assert orbit.data == OMM(fields=OMM_FIELDS)
53
+
54
+ def test_given_fields_when_created_then_copies_fields(self) -> None:
55
+ fields = dict(OMM_FIELDS)
56
+
57
+ orbit = Orbit.from_omm(fields)
58
+ fields["OBJECT_NAME"] = "CHANGED"
59
+
60
+ assert orbit.data == OMM(fields=OMM_FIELDS)
61
+
62
+
63
+ class TestSatelliteFromTle:
64
+ def test_given_identifier_when_created_then_builds_satellite(self) -> None:
65
+ line0 = "ISS (ZARYA)"
66
+ line1 = "1 25544U 98067A 26013.20897862 .00008852 00000-0 16699-3 0 9991"
67
+ line2 = "2 25544 51.6330 346.6801 0007741 12.6584 347.4598 15.49269250547732"
68
+ identifier = next_satellite_identifier()
69
+
70
+ sat = Satellite.from_tle(line0=line0, line1=line1, line2=line2, identifier=identifier)
71
+
72
+ assert sat.identifier == identifier
73
+ assert sat.orbit.representation == OrbitRepresentation.TLE
74
+ assert sat.orbit.data == TLE(line0=line0, line1=line1, line2=line2)
75
+
76
+ def test_given_identifier_when_created_then_uses_custom_value(self) -> None:
77
+ line0 = "ISS (ZARYA)"
78
+ line1 = "1 25544U 98067A 26013.20897862 .00008852 00000-0 16699-3 0 9991"
79
+ line2 = "2 25544 51.6330 346.6801 0007741 12.6584 347.4598 15.49269250547732"
80
+ identifier = "ISS"
81
+
82
+ sat = Satellite.from_tle(line0=line0, line1=line1, line2=line2, identifier=identifier)
83
+
84
+ assert sat.identifier == identifier
85
+ assert sat.orbit.representation == OrbitRepresentation.TLE
86
+ assert sat.orbit.data == TLE(line0=line0, line1=line1, line2=line2)
87
+
88
+ def test_given_no_identifier_when_created_then_generates_identifier(self) -> None:
89
+ line0 = "ISS (ZARYA)"
90
+ line1 = "1 25544U 98067A 26013.20897862 .00008852 00000-0 16699-3 0 9991"
91
+ line2 = "2 25544 51.6330 346.6801 0007741 12.6584 347.4598 15.49269250547732"
92
+
93
+ sat = Satellite.from_tle(line0=line0, line1=line1, line2=line2)
94
+
95
+ assert UUID(sat.identifier)
96
+
97
+ def test_given_none_identifier_when_created_then_generates_identifier(self) -> None:
98
+ line0 = "ISS (ZARYA)"
99
+ line1 = "1 25544U 98067A 26013.20897862 .00008852 00000-0 16699-3 0 9991"
100
+ line2 = "2 25544 51.6330 346.6801 0007741 12.6584 347.4598 15.49269250547732"
101
+
102
+ sat = Satellite.from_tle(line0=line0, line1=line1, line2=line2, identifier=None)
103
+
104
+ assert UUID(sat.identifier)
105
+
106
+
107
+ class TestSatelliteFromOmm:
108
+ def test_given_identifier_when_created_then_builds_satellite(self) -> None:
109
+ identifier = next_satellite_identifier()
110
+
111
+ sat = Satellite.from_omm(fields=OMM_FIELDS, identifier=identifier)
112
+
113
+ assert sat.identifier == identifier
114
+ assert sat.orbit.representation == OrbitRepresentation.OMM
115
+ assert sat.orbit.data == OMM(fields=OMM_FIELDS)
116
+
117
+ def test_given_fields_when_created_then_copies_fields(self) -> None:
118
+ fields = dict(OMM_FIELDS)
119
+
120
+ sat = Satellite.from_omm(fields=fields, identifier=next_satellite_identifier())
121
+ fields["OBJECT_NAME"] = "CHANGED"
122
+
123
+ assert sat.orbit.data == OMM(fields=OMM_FIELDS)
124
+
125
+ def test_given_no_identifier_when_created_then_generates_identifier(self) -> None:
126
+ sat = Satellite.from_omm(fields=OMM_FIELDS)
127
+
128
+ assert UUID(sat.identifier)
129
+
130
+ def test_given_none_identifier_when_created_then_generates_identifier(self) -> None:
131
+ sat = Satellite.from_omm(fields=OMM_FIELDS, identifier=None)
132
+
133
+ assert UUID(sat.identifier)
134
+
135
+
136
+ class TestSatellite:
137
+ def test_given_no_identifier_when_created_then_generates_identifier(self) -> None:
138
+ sat = Satellite(orbit=Orbit.from_omm(OMM_FIELDS))
139
+
140
+ assert UUID(sat.identifier)
141
+
142
+ def test_given_no_identifier_when_created_twice_then_generates_unique_identifiers(self) -> None:
143
+ first = Satellite(orbit=Orbit.from_omm(OMM_FIELDS))
144
+ second = Satellite(orbit=Orbit.from_omm(OMM_FIELDS))
145
+
146
+ assert first.identifier != second.identifier
@@ -5,8 +5,7 @@ from collections.abc import Callable
5
5
  import pytest
6
6
 
7
7
  from satnogs_predict.domain.geometry import GeometrySample
8
- from satnogs_predict.domain.observer import Observer
9
- from satnogs_predict.domain.orbit import TLE, Orbit, Satellite
8
+ from satnogs_predict.domain.orbit import OMM, TLE, Orbit, Satellite
10
9
  from satnogs_predict.domain.planner import PlanningConfig
11
10
  from satnogs_predict.domain.time import Duration, Instant, TimeRange
12
11
  from satnogs_predict.domain.window import ObservationWindow
@@ -20,16 +19,6 @@ class TestRequiredFields:
20
19
  @pytest.mark.parametrize(
21
20
  ("factory", "expected_message"),
22
21
  [
23
- (
24
- lambda: Observer(
25
- lat_deg=0.0,
26
- lon_deg=0.0,
27
- elevation_meters=0,
28
- horizon_deg=0.0,
29
- identifier=None, # type: ignore[arg-type]
30
- ),
31
- r"Observer\.identifier cannot be None",
32
- ),
33
22
  (
34
23
  lambda: GeometrySample(
35
24
  instant=None, # type: ignore[arg-type]
@@ -54,21 +43,16 @@ class TestRequiredFields:
54
43
  r"Orbit\.representation cannot be None",
55
44
  ),
56
45
  (
57
- lambda: Satellite(
58
- orbit=None, # type: ignore[arg-type]
59
- identifier="SAT",
46
+ lambda: OMM(
47
+ fields=None, # type: ignore[arg-type]
60
48
  ),
61
- r"Satellite\.orbit cannot be None",
49
+ r"OMM\.fields cannot be None",
62
50
  ),
63
51
  (
64
52
  lambda: Satellite(
65
- orbit=Orbit(
66
- representation=0,
67
- data=TLE("LINE0", "LINE1", "LINE2"),
68
- ),
69
- identifier=None, # type: ignore[arg-type]
53
+ orbit=None, # type: ignore[arg-type]
70
54
  ),
71
- r"Satellite\.identifier cannot be None",
55
+ r"Satellite\.orbit cannot be None",
72
56
  ),
73
57
  (
74
58
  lambda: Duration(
@@ -1120,8 +1120,42 @@ class TestSkyfieldSatFromSatellite:
1120
1120
  assert first is not second
1121
1121
  assert len(created) == 2
1122
1122
 
1123
+ def test_given_omm_satellite_when_called_then_uses_skyfield_from_omm(self, monkeypatch) -> None:
1124
+ omm_fields = {
1125
+ "OBJECT_NAME": "ISS (ZARYA)",
1126
+ "NORAD_CAT_ID": "25544",
1127
+ }
1128
+ satellite = Satellite.from_omm(
1129
+ fields=omm_fields,
1130
+ identifier=next_satellite_identifier(),
1131
+ )
1132
+ skyfield_satellite = object()
1133
+ captured: dict[str, object] = {}
1134
+
1135
+ class EarthSatelliteSpy:
1136
+ @classmethod
1137
+ def from_omm(cls, timescale, element_dict):
1138
+ captured["timescale"] = timescale
1139
+ captured["element_dict"] = element_dict
1140
+ return skyfield_satellite
1141
+
1142
+ monkeypatch.setattr(propagator, "EarthSatellite", EarthSatelliteSpy)
1143
+ propagator.clear_satellite_cache()
1144
+
1145
+ result = propagator._skyfield_sat_from_satellite(satellite)
1146
+ omm_fields["OBJECT_NAME"] = "CHANGED"
1147
+
1148
+ assert result is skyfield_satellite
1149
+ assert captured == {
1150
+ "timescale": propagator.ts,
1151
+ "element_dict": {
1152
+ "OBJECT_NAME": "ISS (ZARYA)",
1153
+ "NORAD_CAT_ID": "25544",
1154
+ },
1155
+ }
1156
+
1123
1157
  def test_given_unknown_representation_when_called_then_raises(self) -> None:
1124
- orbit = Orbit(representation=1, data=TLE("LINE0", "LINE1", "LINE2"))
1158
+ orbit = Orbit(representation=999, data=TLE("LINE0", "LINE1", "LINE2"))
1125
1159
  sat = Satellite(orbit=orbit, identifier=next_satellite_identifier())
1126
1160
  propagator.clear_satellite_cache()
1127
1161
 
@@ -1,3 +0,0 @@
1
- from .engine import find_observation_windows, get_altaz, get_and_validate_pass_from_range, get_range
2
-
3
- __all__ = ["find_observation_windows", "get_altaz", "get_and_validate_pass_from_range", "get_range"]
@@ -1,53 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from enum import IntEnum
5
-
6
- from .validation import ensure_required_fields_not_none
7
-
8
-
9
- @dataclass(frozen=True, slots=True)
10
- class TLE:
11
- line0: str
12
- line1: str
13
- line2: str
14
-
15
- def __post_init__(self) -> None:
16
- ensure_required_fields_not_none(self)
17
-
18
- def to_list(self) -> list[str]:
19
- return [self.line0, self.line1, self.line2]
20
-
21
-
22
- class OrbitRepresentation(IntEnum):
23
- TLE = 0
24
-
25
-
26
- @dataclass(frozen=True, slots=True)
27
- class Orbit:
28
- representation: OrbitRepresentation
29
- data: TLE
30
-
31
- def __post_init__(self) -> None:
32
- ensure_required_fields_not_none(self)
33
-
34
-
35
- @dataclass(frozen=True, slots=True)
36
- class Satellite:
37
- orbit: Orbit
38
- identifier: str
39
-
40
- def __post_init__(self) -> None:
41
- ensure_required_fields_not_none(self)
42
-
43
- @classmethod
44
- def from_tle(
45
- cls,
46
- line0: str,
47
- line1: str,
48
- line2: str,
49
- identifier: str,
50
- ):
51
- tle = TLE(line0=line0, line1=line1, line2=line2)
52
- orbit = Orbit(representation=OrbitRepresentation.TLE, data=tle)
53
- return cls(orbit=orbit, identifier=identifier)
@@ -1,37 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from satnogs_predict.domain.orbit import TLE, OrbitRepresentation, Satellite
4
- from tests.helpers import next_satellite_identifier
5
-
6
-
7
- class TestTle:
8
- def test_given_tle_when_to_list_then_returns_three_lines_in_order(self) -> None:
9
- tle = TLE(line0="LINE0", line1="LINE1", line2="LINE2")
10
-
11
- assert tle.to_list() == ["LINE0", "LINE1", "LINE2"]
12
-
13
-
14
- class TestSatelliteFromTle:
15
- def test_given_identifier_when_created_then_builds_satellite(self) -> None:
16
- line0 = "ISS (ZARYA)"
17
- line1 = "1 25544U 98067A 26013.20897862 .00008852 00000-0 16699-3 0 9991"
18
- line2 = "2 25544 51.6330 346.6801 0007741 12.6584 347.4598 15.49269250547732"
19
- identifier = next_satellite_identifier()
20
-
21
- sat = Satellite.from_tle(line0=line0, line1=line1, line2=line2, identifier=identifier)
22
-
23
- assert sat.identifier == identifier
24
- assert sat.orbit.representation == OrbitRepresentation.TLE
25
- assert sat.orbit.data == TLE(line0=line0, line1=line1, line2=line2)
26
-
27
- def test_given_identifier_when_created_then_uses_custom_value(self) -> None:
28
- line0 = "ISS (ZARYA)"
29
- line1 = "1 25544U 98067A 26013.20897862 .00008852 00000-0 16699-3 0 9991"
30
- line2 = "2 25544 51.6330 346.6801 0007741 12.6584 347.4598 15.49269250547732"
31
- identifier = "ISS"
32
-
33
- sat = Satellite.from_tle(line0=line0, line1=line1, line2=line2, identifier=identifier)
34
-
35
- assert sat.identifier == identifier
36
- assert sat.orbit.representation == OrbitRepresentation.TLE
37
- assert sat.orbit.data == TLE(line0=line0, line1=line1, line2=line2)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes