satnogs-predict 0.3__tar.gz → 0.5__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 (60) hide show
  1. {satnogs_predict-0.3 → satnogs_predict-0.5}/PKG-INFO +1 -1
  2. {satnogs_predict-0.3 → satnogs_predict-0.5}/docs/docs.md +160 -13
  3. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/__init__.py +15 -2
  4. satnogs_predict-0.5/src/satnogs_predict/constraints/__init__.py +15 -0
  5. satnogs_predict-0.5/src/satnogs_predict/constraints/constraints.py +433 -0
  6. satnogs_predict-0.5/src/satnogs_predict/core/__init__.py +15 -0
  7. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/core/engine.py +54 -9
  8. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/__init__.py +6 -3
  9. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/constraints.py +10 -0
  10. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/geometry.py +9 -0
  11. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/observer.py +5 -2
  12. satnogs_predict-0.5/src/satnogs_predict/domain/orbit.py +89 -0
  13. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/time.py +16 -12
  14. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/window.py +1 -1
  15. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/planning/planner.py +2 -2
  16. satnogs_predict-0.5/src/satnogs_predict/propagation/__init__.py +19 -0
  17. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/propagation/propagator.py +83 -3
  18. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/tle.py +3 -2
  19. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict.egg-info/PKG-INFO +1 -1
  20. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict.egg-info/SOURCES.txt +2 -0
  21. satnogs_predict-0.5/tests/constraints/test_constraints.py +745 -0
  22. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/core/test_engine.py +150 -6
  23. satnogs_predict-0.5/tests/domain/test_observer.py +44 -0
  24. satnogs_predict-0.5/tests/domain/test_orbit.py +146 -0
  25. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/domain/test_required_fields.py +7 -13
  26. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/domain/test_time.py +3 -4
  27. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/helpers.py +22 -4
  28. satnogs_predict-0.5/tests/integration/test_fake_tle_integration.py +99 -0
  29. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/propagation/test_propagator.py +216 -18
  30. satnogs_predict-0.3/src/satnogs_predict/constraints/__init__.py +0 -3
  31. satnogs_predict-0.3/src/satnogs_predict/constraints/constraints.py +0 -111
  32. satnogs_predict-0.3/src/satnogs_predict/domain/orbit.py +0 -50
  33. satnogs_predict-0.3/src/satnogs_predict/propagation/__init__.py +0 -0
  34. satnogs_predict-0.3/tests/__init__.py +0 -0
  35. satnogs_predict-0.3/tests/constraints/test_constraints.py +0 -102
  36. satnogs_predict-0.3/tests/domain/test_orbit.py +0 -33
  37. {satnogs_predict-0.3 → satnogs_predict-0.5}/.gitignore +0 -0
  38. {satnogs_predict-0.3 → satnogs_predict-0.5}/.gitlab-ci.yml +0 -0
  39. {satnogs_predict-0.3 → satnogs_predict-0.5}/.pre-commit-config.yaml +0 -0
  40. {satnogs_predict-0.3 → satnogs_predict-0.5}/.python-version +0 -0
  41. {satnogs_predict-0.3 → satnogs_predict-0.5}/CONTRIBUTING.md +0 -0
  42. {satnogs_predict-0.3 → satnogs_predict-0.5}/LICENSE +0 -0
  43. {satnogs_predict-0.3 → satnogs_predict-0.5}/README.md +0 -0
  44. {satnogs_predict-0.3 → satnogs_predict-0.5}/pyproject.toml +0 -0
  45. {satnogs_predict-0.3 → satnogs_predict-0.5}/setup.cfg +0 -0
  46. {satnogs_predict-0.3 → satnogs_predict-0.5}/setup.py +0 -0
  47. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/planner.py +0 -0
  48. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/domain/validation.py +0 -0
  49. {satnogs_predict-0.3/src/satnogs_predict/core → satnogs_predict-0.5/src/satnogs_predict/planning}/__init__.py +0 -0
  50. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict/py.typed +0 -0
  51. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict.egg-info/dependency_links.txt +0 -0
  52. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict.egg-info/requires.txt +0 -0
  53. {satnogs_predict-0.3 → satnogs_predict-0.5}/src/satnogs_predict.egg-info/top_level.txt +0 -0
  54. {satnogs_predict-0.3/src/satnogs_predict/planning → satnogs_predict-0.5/tests}/__init__.py +0 -0
  55. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/conftest.py +0 -0
  56. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/domain/test_planner_domain.py +0 -0
  57. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/domain/test_window.py +0 -0
  58. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/planning/test_planner.py +0 -0
  59. {satnogs_predict-0.3 → satnogs_predict-0.5}/tests/test_tle.py +0 -0
  60. {satnogs_predict-0.3 → satnogs_predict-0.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: satnogs-predict
3
- Version: 0.3
3
+ Version: 0.5
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
@@ -21,7 +21,8 @@
21
21
  - [6.2 - Windows returned](#62---windows-returned)
22
22
  - [6.3 - Implementation Details](#63---implementation-details)
23
23
  - [6.4 - Full workflow for finding passes](#64---full-workflow-for-finding-passes)
24
- - [6.5 - Full workflow for validating time ranges that belong to passes](#65---full-workflow-for-validating-time-ranges-that-belong-to-passes)
24
+ - [6.5 - Sampling point-in-time geometry](#65---sampling-point-in-time-geometry)
25
+ - [6.6 - Full workflow for validating time ranges that belong to passes](#66---full-workflow-for-validating-time-ranges-that-belong-to-passes)
25
26
 
26
27
 
27
28
  # 1. Installation
@@ -41,11 +42,15 @@ level:
41
42
  ``` python
42
43
  from satnogs_predict import (
43
44
  find_observation_windows,
45
+ get_altaz,
44
46
  get_and_validate_pass_from_range,
47
+ get_range,
45
48
  check_observation_density_limit_respected,
46
49
  has_range_list_overlap,
47
50
  generate_fake_tle,
48
51
 
52
+ AngularSeparationConstraint,
53
+ AzimuthWindowConstraint,
49
54
  MaxDurationConstraint,
50
55
  MinCulminationConstraint,
51
56
  MinDurationConstraint,
@@ -53,6 +58,7 @@ from satnogs_predict import (
53
58
  ConstraintAppliedResult,
54
59
  ConstraintCheckFailedError,
55
60
  GeometrySample,
61
+ RangeSample,
56
62
  Observer,
57
63
  TLE,
58
64
  OrbitRepresentation,
@@ -78,15 +84,26 @@ The top-level exports define the supported API contract.
78
84
 
79
85
  ## 3.1 - Satellite
80
86
 
81
- Represents orbital information, currently derived from TLE data.
82
- (In the future, more ways may be added.)
87
+ Represents orbital information derived from TLE or OMM data.
83
88
 
84
89
  ```python
85
90
  satellite = Satellite.from_tle(
86
- line0=tle['tle0'], line1=tle['tle1'], line2=tle['tle2'], identifier="XSKZ-5603-1870-9019-3066"
91
+ line0=tle['tle0'], line1=tle['tle1'], line2=tle['tle2']
87
92
  )
88
93
  ```
89
94
 
95
+ You can also create a satellite from an OMM element dictionary. The field names and
96
+ values are passed through to Skyfield's `EarthSatellite.from_omm` support:
97
+
98
+ ```python
99
+ satellite = Satellite.from_omm(
100
+ fields=omm_fields,
101
+ )
102
+ ```
103
+
104
+ The `identifier` is optional and defaults to a random UUID. Provide an explicit
105
+ identifier when you need a stable public value or cache key.
106
+
90
107
  For testing or development, you can also generate a synthetic TLE:
91
108
 
92
109
  ```python
@@ -111,11 +128,13 @@ station = Observer(
111
128
  lat_deg=37.983810,
112
129
  lon_deg=23.727539,
113
130
  elevation_meters=100,
114
- horizon_deg=10.0,
115
- identifier="360"
131
+ horizon_deg=10.0
116
132
  )
117
133
  ```
118
134
 
135
+ The `identifier` is optional here as well and defaults to a random UUID. Provide one
136
+ when you need a stable public value or cache key.
137
+
119
138
 
120
139
  ## 3.3 - Time Modeling
121
140
 
@@ -138,14 +157,47 @@ now_instant = Instant.from_datetime(now)
138
157
  five_mins_dur = Duration.from_timedelta(delta)
139
158
  five_mins_dur_2 = Duration.from_seconds(5 * 60)
140
159
 
160
+ later_instant = now_instant + five_mins_dur
161
+ earlier_instant = later_instant - five_mins_dur
162
+
141
163
  time_range = TimeRange.from_datetimes(now, future)
164
+ range_duration = time_range.duration
165
+ shifted_range = time_range.shift(Duration.from_seconds(30))
166
+ padded_range = time_range.pad_duration(
167
+ left=Duration.from_seconds(10),
168
+ right=Duration.from_seconds(20),
169
+ )
170
+
171
+ assert now_instant < later_instant
172
+ assert time_range.contains(now_instant)
173
+ assert time_range.intersects(shifted_range)
142
174
  ```
143
175
 
176
+ Supported operations include:
177
+
178
+ - `Instant.from_datetime(dt)` and `instant.to_datetime()`
179
+ - `Instant + Duration -> Instant`
180
+ - `Instant - Duration -> Instant`
181
+ - ordering/comparisons between `Instant` values
182
+ - `Duration.from_timedelta(td)`, `Duration.from_seconds(seconds)`, `duration.to_timedelta()`, `duration.to_seconds()`
183
+ - `Duration + Duration -> Duration`
184
+ - `TimeRange.from_datetimes(start, end)` creates a range from two timezone-aware datetimes
185
+ - `TimeRange.duration` returns the range length as a `Duration`
186
+ - `TimeRange.contains(instant)` checks whether an instant lies inside the range
187
+ - `TimeRange.intersects(other)` checks whether two ranges overlap
188
+ - `TimeRange.intersection(other)` returns the overlapping part of two ranges, or `None`
189
+ - `TimeRange.subtract(other)` removes the overlapping part and returns the remaining pieces
190
+ - `TimeRange.shift(duration)` moves the whole range by a duration
191
+ - `TimeRange.pad_secs(left, right)` extends the range by raw second values on each side
192
+ - `TimeRange.pad_duration(left, right)` extends the range by `Duration` values on each side
193
+
144
194
  # 4. Observation Windows & samples
145
195
 
146
196
  ## 4.1 - Samples
147
197
 
148
- A sample represents information of a satellite's orbit relative to the observer at a specific instant:
198
+ A sample represents information of a satellite's orbit relative to the observer at a specific instant.
199
+
200
+ `GeometrySample` stores angular geometry:
149
201
 
150
202
  ```python
151
203
  class GeometrySample:
@@ -157,7 +209,35 @@ class GeometrySample:
157
209
 
158
210
  ```
159
211
 
160
- ## 4.2 - Observation Windows
212
+ `RangeSample` stores distance information:
213
+
214
+ ```python
215
+ class RangeSample:
216
+ """Range and range velocity at an instant, relative to an observer."""
217
+
218
+ instant: Instant
219
+ range_m: float
220
+ range_rate: float
221
+ ```
222
+
223
+ `range_m` is the observer-to-satellite distance in meters, and `range_rate` is the
224
+ rate of change of that distance in meters per second.
225
+
226
+ ## 4.2 - Doppler correction
227
+
228
+ Use `get_doppler_adjusted_frequency` to apply the Doppler correction to a
229
+ base frequency:
230
+
231
+ ```python
232
+ from satnogs_predict.core import get_doppler_adjusted_frequency
233
+
234
+ adjusted_frequency_hz = get_doppler_adjusted_frequency(
235
+ range_rate_m_per_s=range_sample.range_rate,
236
+ frequency_hz=145_000_000.0,
237
+ )
238
+ ```
239
+
240
+ ## 4.3 - Observation Windows
161
241
 
162
242
  An observation window represents a pass or a subset of a pass.
163
243
 
@@ -199,14 +279,48 @@ If due to the constraints, a window is rejected, the reason is set in `rejection
199
279
 
200
280
  If the window has an overlap with the provided already scheduled ranges, `overlapped` will be set to `True` and the ratio will be the percentage (as a decimal) that is overlapped.
201
281
 
202
- ## 4.3 - Constraints
282
+ Note: if a later constraint modifies the window length, `overlap_ratio` is currently not recalculated. It remains the value computed earlier during planning.
283
+
284
+ ## 4.4 - Constraints
203
285
 
204
286
  When searching for observation windows (more on this below), certain constraints can be applied. Currently supported constraints are:
205
287
 
206
- - `MaxDurationConstraint` Rejects windows over a certain duration. Does not change the window.
288
+ - `AngularSeparationConstraint` Trims a window to stay within a target angular separation from a fixed pointing direction, with optional fallback behavior controlled by `min_duration`.
289
+ - `AzimuthWindowConstraint` Trims the start and end of a window so that both edge azimuths lie inside a clockwise azimuth interval.
290
+ - `MaxDurationConstraint` Rejects windows over a certain duration. If `trim=True`, it trims them symmetrically instead.
207
291
  - `MinDurationConstraint` Rejects windows under a certain duration. Does not change the window.
208
292
  - `MinCulminationConstraint` Rejects windows whose max altitude is under a certain threshold. Does not change the window.
209
293
 
294
+ For `AzimuthWindowConstraint`, the allowed region is defined by sweeping clockwise from `az_start` to `az_end`.
295
+
296
+ Examples:
297
+
298
+ - `AzimuthWindowConstraint(20.0, 100.0)` allows azimuths between `20°` and `100°`.
299
+ - `AzimuthWindowConstraint(350.0, 20.0)` wraps around north and allows azimuths between `350°..360°` and `0°..20°`.
300
+
301
+ If a window already satisfies the azimuth interval at both edges, it is left unchanged. Otherwise, the start is swept forward and the end is swept backward until both edge azimuths are inside the interval. If no valid sub-window remains, the window is rejected.
302
+
303
+ `AngularSeparationConstraint` defines a fixed antenna pointing direction with:
304
+
305
+ - `pointing_az_deg`
306
+ - `pointing_el_deg`
307
+ - `max_separation_deg`
308
+
309
+ The pass is sampled over time, and the angular separation between the satellite direction and that fixed pointing direction is checked at each sample.
310
+
311
+ If `min_duration` is `None`, the constraint trims the pass to the sampled interval that lies within `max_separation_deg`.
312
+
313
+ If `min_duration` is provided:
314
+
315
+ - if the within-separation interval is already at least `min_duration`, the pass is trimmed to that interval
316
+ - if trimming to the within-separation interval would make the pass shorter than `min_duration`, a fallback window of exactly `min_duration` is selected around the closest sampled approach, while staying inside the original pass
317
+ - if the original pass itself is shorter than `min_duration`, the constraint rejects it
318
+
319
+ `MaxDurationConstraint` supports two modes:
320
+
321
+ - by default, it rejects any window longer than `max_duration`
322
+ - if `trim=True`, it trims the window symmetrically around its center down to `max_duration`
323
+
210
324
  Constraint behavior is:
211
325
 
212
326
  - `constraint.apply(window) -> ObservationWindow`: annotates the window with `constraint_applied_result` / `rejection_reason`.
@@ -295,6 +409,8 @@ Internally, satellite & observer are converted to Skyfield's types, which is com
295
409
 
296
410
  This way, repeated consecutive calls of `find_observation_windows` with the same satellite, or with the same `observer` reuse the objects under the hood.
297
411
 
412
+ If you need to clear these caches explicitly, the public helpers `clear_satellite_cache()` and `clear_observer_cache()` are available.
413
+
298
414
  ## 6.4 - Full workflow for finding passes
299
415
 
300
416
  High-level process:
@@ -346,19 +462,50 @@ planning_config = PlanningConfig(
346
462
 
347
463
  min_duration_constraint = MinDurationConstraint(Duration.from_seconds(5))
348
464
  min_culmination_constraint = MinCulminationConstraint(55.0)
465
+ azimuth_window_constraint = AzimuthWindowConstraint(220.0, 330.0)
349
466
 
350
467
  valid_windows, rejected_windows = find_observation_windows(
351
468
  sat,
352
469
  observer,
353
470
  time_range,
354
471
  planning_config,
355
- pre_plan_constraints=[min_duration_constraint, min_culmination_constraint],
356
- post_plan_constraints=[min_duration_constraint, min_culmination_constraint],
472
+ pre_plan_constraints=[
473
+ min_duration_constraint,
474
+ min_culmination_constraint,
475
+ azimuth_window_constraint,
476
+ ],
477
+ post_plan_constraints=[
478
+ min_duration_constraint,
479
+ min_culmination_constraint,
480
+ azimuth_window_constraint,
481
+ ],
357
482
  scheduled_intervals=scheduled_ranges,
358
483
  )
359
484
  ```
360
485
 
361
- ## 6.5 - Full workflow for validating time ranges that belong to passes
486
+ ## 6.5 - Sampling point-in-time geometry
487
+
488
+ For point-in-time queries, the public API also provides helpers for sampling a satellite relative to an observer at a single `Instant`.
489
+
490
+ ```python
491
+ altaz_sample = get_altaz(
492
+ satellite,
493
+ observer,
494
+ instant,
495
+ )
496
+
497
+ range_sample = get_range(
498
+ satellite,
499
+ observer,
500
+ instant,
501
+ )
502
+ ```
503
+
504
+ - `get_altaz()` returns a `GeometrySample` with azimuth and altitude.
505
+ - `get_range()` returns a `RangeSample` with range in meters and range rate in meters per second.
506
+
507
+
508
+ ## 6.6 - Full workflow for validating time ranges that belong to passes
362
509
 
363
510
  - Given a time range, we can validate whether it belongs to a single pass and whether it satisfies constraints.
364
511
 
@@ -1,11 +1,24 @@
1
1
  from . import constraints, domain
2
2
  from .constraints import * # noqa: F401,F403
3
- from .core.engine import find_observation_windows, get_and_validate_pass_from_range
3
+ from .core.engine import (
4
+ find_observation_windows,
5
+ get_altaz,
6
+ get_and_validate_pass_from_range,
7
+ get_range,
8
+ )
4
9
  from .domain import * # noqa: F401,F403
5
10
  from .planning.planner import check_observation_density_limit_respected, has_range_list_overlap
11
+ from .propagation import clear_observer_cache, clear_satellite_cache
6
12
  from .tle import generate_fake_tle
7
13
 
8
14
  __all__ = list(domain.__all__) + list(constraints.__all__)
9
15
  __all__ += ["check_observation_density_limit_respected", "has_range_list_overlap"]
10
- __all__ += ["find_observation_windows", "get_and_validate_pass_from_range"]
16
+ __all__ += [
17
+ "clear_observer_cache",
18
+ "clear_satellite_cache",
19
+ "find_observation_windows",
20
+ "get_altaz",
21
+ "get_and_validate_pass_from_range",
22
+ "get_range",
23
+ ]
11
24
  __all__ += ["generate_fake_tle"]
@@ -0,0 +1,15 @@
1
+ from .constraints import (
2
+ AngularSeparationConstraint,
3
+ AzimuthWindowConstraint,
4
+ MaxDurationConstraint,
5
+ MinCulminationConstraint,
6
+ MinDurationConstraint,
7
+ )
8
+
9
+ __all__ = [
10
+ "AngularSeparationConstraint",
11
+ "AzimuthWindowConstraint",
12
+ "MinCulminationConstraint",
13
+ "MinDurationConstraint",
14
+ "MaxDurationConstraint",
15
+ ]