satnogs-predict 0.2__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_predict/__init__.py +9 -0
- satnogs_predict/constraints/__init__.py +3 -0
- satnogs_predict/constraints/constraints.py +111 -0
- satnogs_predict/core/__init__.py +0 -0
- satnogs_predict/core/engine.py +105 -0
- satnogs_predict/domain/__init__.py +29 -0
- satnogs_predict/domain/constraints.py +13 -0
- satnogs_predict/domain/geometry.py +18 -0
- satnogs_predict/domain/observer.py +19 -0
- satnogs_predict/domain/orbit.py +47 -0
- satnogs_predict/domain/planner.py +54 -0
- satnogs_predict/domain/time.py +209 -0
- satnogs_predict/domain/validation.py +12 -0
- satnogs_predict/domain/window.py +67 -0
- satnogs_predict/planning/__init__.py +0 -0
- satnogs_predict/planning/planner.py +211 -0
- satnogs_predict/propagation/__init__.py +0 -0
- satnogs_predict/propagation/propagator.py +375 -0
- satnogs_predict/py.typed +0 -0
- satnogs_predict-0.2.dist-info/METADATA +51 -0
- satnogs_predict-0.2.dist-info/RECORD +24 -0
- satnogs_predict-0.2.dist-info/WHEEL +5 -0
- satnogs_predict-0.2.dist-info/licenses/LICENSE +661 -0
- satnogs_predict-0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from satnogs_predict.domain.constraints import ConstraintAppliedResult
|
|
6
|
+
|
|
7
|
+
from .geometry import GeometrySample
|
|
8
|
+
from .time import TimeRange
|
|
9
|
+
from .validation import ensure_required_fields_not_none
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RangeToPassError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class ObservationWindow:
|
|
18
|
+
"""
|
|
19
|
+
A schedulable observation window.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
sat_identifier: str
|
|
23
|
+
observer_identifier: str
|
|
24
|
+
|
|
25
|
+
start_sample: GeometrySample
|
|
26
|
+
end_sample: GeometrySample
|
|
27
|
+
max_altitude_sample: GeometrySample
|
|
28
|
+
|
|
29
|
+
# The altitude does has multiple peaks during the window (None means unknown)
|
|
30
|
+
has_multiple_peaks: bool | None = None
|
|
31
|
+
|
|
32
|
+
constraint_applied_result: ConstraintAppliedResult | None = None
|
|
33
|
+
rejection_reason: str | None = None
|
|
34
|
+
|
|
35
|
+
overlapped: bool = False
|
|
36
|
+
overlap_ratio: float = 0.0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def time_range(self):
|
|
40
|
+
return TimeRange(start=self.start_sample.instant, end=self.end_sample.instant)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
ensure_required_fields_not_none(self)
|
|
44
|
+
|
|
45
|
+
if int(self.start_sample.instant) >= int(self.end_sample.instant):
|
|
46
|
+
raise ValueError("ObservationWindow must be non-empty")
|
|
47
|
+
|
|
48
|
+
def __str__(self):
|
|
49
|
+
t_start = self.start_sample.instant
|
|
50
|
+
t_end = self.end_sample.instant
|
|
51
|
+
rise_azimuth = self.start_sample.azimuth_deg
|
|
52
|
+
max_altitude = self.max_altitude_sample.altitude_deg if self.max_altitude_sample else None
|
|
53
|
+
max_alt_str = f"{max_altitude:.1f}°" if max_altitude is not None else "Unknown"
|
|
54
|
+
set_azimuth = self.end_sample.azimuth_deg
|
|
55
|
+
|
|
56
|
+
representation = (
|
|
57
|
+
f"Window {{{t_start} -> {t_end}}} ({self.time_range.duration}) - "
|
|
58
|
+
f"{{Rise az: {rise_azimuth:.1f}°, Max alt: {max_alt_str}°, Set az: {set_azimuth:.1f}°}}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if self.constraint_applied_result == ConstraintAppliedResult.REJECTED:
|
|
62
|
+
representation += f" | Rejected: {self.rejection_reason}"
|
|
63
|
+
|
|
64
|
+
return representation
|
|
65
|
+
|
|
66
|
+
def __repr__(self):
|
|
67
|
+
return str(self)
|
|
File without changes
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from typing import Sequence
|
|
2
|
+
|
|
3
|
+
from satnogs_predict.domain.observer import Observer
|
|
4
|
+
from satnogs_predict.domain.orbit import Satellite
|
|
5
|
+
from satnogs_predict.domain.planner import OverlapHandling, PlanningConfig
|
|
6
|
+
from satnogs_predict.domain.time import Duration, Instant, TimeRange
|
|
7
|
+
from satnogs_predict.domain.window import ObservationWindow
|
|
8
|
+
from satnogs_predict.propagation.propagator import obs_window_from_range
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def plan_observation_windows(
|
|
12
|
+
satellite: Satellite,
|
|
13
|
+
observer: Observer,
|
|
14
|
+
thepass: ObservationWindow,
|
|
15
|
+
config: PlanningConfig,
|
|
16
|
+
ordered_scheduled: list[TimeRange],
|
|
17
|
+
) -> list[ObservationWindow]:
|
|
18
|
+
"""
|
|
19
|
+
Handles pass overlapping with scheduled ranges,
|
|
20
|
+
as well as splitting long windows
|
|
21
|
+
"""
|
|
22
|
+
overlap_idx = _idx_of_overlap(thepass, ordered_scheduled, config)
|
|
23
|
+
|
|
24
|
+
if overlap_idx is None:
|
|
25
|
+
obs_ranges = [thepass.time_range]
|
|
26
|
+
else:
|
|
27
|
+
thepass.overlapped = True
|
|
28
|
+
thepass.overlap_ratio = _overlap_ratio(thepass, ordered_scheduled)
|
|
29
|
+
match config.overlap_handling:
|
|
30
|
+
case OverlapHandling.DROP:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
case OverlapHandling.FULL:
|
|
34
|
+
obs_ranges = [thepass.time_range]
|
|
35
|
+
|
|
36
|
+
case OverlapHandling.TRUNCATE:
|
|
37
|
+
obs_ranges = _resolve_overlaps(
|
|
38
|
+
thepass.time_range, ordered_scheduled[overlap_idx:], config
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
case _:
|
|
42
|
+
raise ValueError("Unhandled overlap handling mode")
|
|
43
|
+
|
|
44
|
+
if config.split_long_windows:
|
|
45
|
+
split_ranges: list[TimeRange] = []
|
|
46
|
+
for rg in obs_ranges:
|
|
47
|
+
split_ranges.extend(_split_long_range(rg, config))
|
|
48
|
+
obs_ranges = split_ranges
|
|
49
|
+
|
|
50
|
+
obs_windows = [
|
|
51
|
+
obs_window_from_range(satellite, observer, therange, thepass) for therange in obs_ranges
|
|
52
|
+
]
|
|
53
|
+
return obs_windows
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_range_list_overlap(
|
|
57
|
+
ordered_ranges: Sequence[TimeRange], padding: Duration = Duration(0)
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Return `True` if any adjacent ranges overlap after applying symmetric padding.
|
|
61
|
+
|
|
62
|
+
`ordered_ranges` is assumed to be sorted by `start` in ascending order.
|
|
63
|
+
Two touching ranges are treated as overlapping (`next_start <= current_end`).
|
|
64
|
+
"""
|
|
65
|
+
for idx in range(len(ordered_ranges) - 1):
|
|
66
|
+
current = ordered_ranges[idx]
|
|
67
|
+
next_range = ordered_ranges[idx + 1]
|
|
68
|
+
|
|
69
|
+
current_end = current.end + padding
|
|
70
|
+
next_start = next_range.start - padding
|
|
71
|
+
|
|
72
|
+
if next_start <= current_end:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _idx_of_overlap(
|
|
79
|
+
thepass: ObservationWindow, ordered_scheduled: Sequence[TimeRange], config: PlanningConfig
|
|
80
|
+
) -> int | None:
|
|
81
|
+
"""
|
|
82
|
+
Returns the index of the first range in ordered_scheduled that overlaps
|
|
83
|
+
with the time range of thepass, after applying padding.
|
|
84
|
+
|
|
85
|
+
ordered_scheduled must be a sequence ordered by time_range.start
|
|
86
|
+
|
|
87
|
+
returns the index if there is an overlap, None otherwise.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
if config.overlap_padding is None:
|
|
91
|
+
disallowed_ranges = ordered_scheduled
|
|
92
|
+
else:
|
|
93
|
+
disallowed_ranges = [
|
|
94
|
+
obs.pad_duration(left=config.overlap_padding, right=config.overlap_padding)
|
|
95
|
+
for obs in ordered_scheduled
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
for idx, obs in enumerate(disallowed_ranges):
|
|
99
|
+
if thepass.time_range.intersects(obs):
|
|
100
|
+
return idx
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _overlap_ratio(thepass: ObservationWindow, ordered_scheduled: Sequence[TimeRange]) -> float:
|
|
105
|
+
"""
|
|
106
|
+
Returns the percentage (in decimal e.g. 0.1 = 10%) that unpadded scheduled ranges overlap with the pass.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
return sum(
|
|
110
|
+
thepass.time_range.overlap_ratio(scheduled_range) for scheduled_range in ordered_scheduled
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _split_long_range(window: TimeRange, config: PlanningConfig) -> list[TimeRange]:
|
|
115
|
+
"""
|
|
116
|
+
Split a long TimeRange into multiple windows of `config.max_window_duration`,
|
|
117
|
+
separated by `config.break_duration`.
|
|
118
|
+
|
|
119
|
+
If the remainder is < `config.remainder_cutoff_duration`, it is dropped.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
windows: list[TimeRange] = []
|
|
123
|
+
|
|
124
|
+
if config.max_window_duration is None:
|
|
125
|
+
raise ValueError("PlanningConfig missing max_window_duration value")
|
|
126
|
+
|
|
127
|
+
if config.break_duration is None:
|
|
128
|
+
raise ValueError("PlanningConfig missing break_duration value")
|
|
129
|
+
|
|
130
|
+
if config.remainder_cutoff_duration is None:
|
|
131
|
+
raise ValueError("PlanningConfig missing remainder_cutoff_duration value")
|
|
132
|
+
|
|
133
|
+
cycle = config.max_window_duration + config.break_duration
|
|
134
|
+
total_duration = window.duration
|
|
135
|
+
|
|
136
|
+
split_count = (total_duration.micros // cycle.micros) + 1
|
|
137
|
+
remainder = Duration(total_duration.micros % cycle.micros)
|
|
138
|
+
|
|
139
|
+
if remainder < config.remainder_cutoff_duration:
|
|
140
|
+
split_count -= 1
|
|
141
|
+
|
|
142
|
+
for i in range(split_count):
|
|
143
|
+
start_offset = cycle * i
|
|
144
|
+
window_start = Instant(int(window.start) + start_offset.micros)
|
|
145
|
+
|
|
146
|
+
# If the last split will have window.end as end value
|
|
147
|
+
window_end = min(window.end, Instant(int(window_start) + config.max_window_duration.micros))
|
|
148
|
+
|
|
149
|
+
windows.append(TimeRange(window_start, window_end))
|
|
150
|
+
|
|
151
|
+
return windows
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_overlaps(
|
|
155
|
+
candidate: TimeRange, sorted_scheduled: Sequence[TimeRange], config: PlanningConfig
|
|
156
|
+
) -> list[TimeRange]:
|
|
157
|
+
"""
|
|
158
|
+
Return parts of the candiate that don't overlap with the `sorted_scheduled` ranges,
|
|
159
|
+
after padding has been aplied to each of them.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
if config.overlap_padding is None:
|
|
163
|
+
raise ValueError("PlanningConfig missing overlap_padding value")
|
|
164
|
+
|
|
165
|
+
disallowed_ranges = [
|
|
166
|
+
obs.pad_duration(left=config.overlap_padding, right=config.overlap_padding)
|
|
167
|
+
for obs in sorted_scheduled
|
|
168
|
+
]
|
|
169
|
+
window_ranges = [candidate]
|
|
170
|
+
|
|
171
|
+
for obs in disallowed_ranges:
|
|
172
|
+
new_windows: list[TimeRange] = []
|
|
173
|
+
for w in window_ranges:
|
|
174
|
+
new_windows.extend(w.subtract(obs))
|
|
175
|
+
window_ranges = new_windows
|
|
176
|
+
|
|
177
|
+
return window_ranges
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_observation_density_limit_respected(
|
|
181
|
+
ordered_observation_starts: Sequence[Instant],
|
|
182
|
+
obs_density_limit: int,
|
|
183
|
+
evaluation_duration: Duration,
|
|
184
|
+
) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Check that any `evaluation_duration` window doesn't contain more than
|
|
187
|
+
`obs_density_limit` observation starts.
|
|
188
|
+
|
|
189
|
+
This enforces a specific density of observations.
|
|
190
|
+
|
|
191
|
+
`ordered_observation_starts` is assumed to be sorted ascending.
|
|
192
|
+
Starts exactly at `inst + evaluation_duration` are included in the same
|
|
193
|
+
evaluation window.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
if len(ordered_observation_starts) <= obs_density_limit:
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
for idx, inst in enumerate(ordered_observation_starts):
|
|
200
|
+
eval_window_end = inst + evaluation_duration
|
|
201
|
+
number_of_obs_in_eval_window = 0
|
|
202
|
+
for start_instant in ordered_observation_starts[idx:]:
|
|
203
|
+
if start_instant > eval_window_end:
|
|
204
|
+
break
|
|
205
|
+
number_of_obs_in_eval_window += 1
|
|
206
|
+
if number_of_obs_in_eval_window > obs_density_limit:
|
|
207
|
+
return False
|
|
208
|
+
if eval_window_end > ordered_observation_starts[-1]:
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from skyfield.api import Angle, EarthSatellite, Time, Topos, load # type: ignore[import-untyped]
|
|
4
|
+
from skyfield.searchlib import find_maxima # type: ignore[import-untyped]
|
|
5
|
+
|
|
6
|
+
from satnogs_predict.domain.geometry import GeometrySample
|
|
7
|
+
from satnogs_predict.domain.observer import Observer
|
|
8
|
+
from satnogs_predict.domain.orbit import TLE, OrbitRepresentation, Satellite
|
|
9
|
+
from satnogs_predict.domain.time import (
|
|
10
|
+
Instant,
|
|
11
|
+
TimeRange,
|
|
12
|
+
)
|
|
13
|
+
from satnogs_predict.domain.window import ObservationWindow
|
|
14
|
+
|
|
15
|
+
ts = load.timescale()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SkyfieldSample:
|
|
20
|
+
t: Time
|
|
21
|
+
alt: Angle
|
|
22
|
+
az: Angle
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class SkyfieldPass:
|
|
27
|
+
start_sample: SkyfieldSample
|
|
28
|
+
peak_sample: SkyfieldSample
|
|
29
|
+
end_sample: SkyfieldSample
|
|
30
|
+
has_multiple_peaks: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_sample_at_time(sat: EarthSatellite, observer: Topos, t: Time) -> SkyfieldSample:
|
|
34
|
+
alt, az, _ = (sat - observer).at(t).altaz()
|
|
35
|
+
return SkyfieldSample(t=t, alt=alt, az=az)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _skyfield_time_from_instant(instant: Instant) -> Time:
|
|
39
|
+
return ts.from_datetime(instant.to_datetime())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _skyfield_times_from_range(time_range: TimeRange) -> tuple[Time, Time]:
|
|
43
|
+
return (
|
|
44
|
+
_skyfield_time_from_instant(time_range.start),
|
|
45
|
+
_skyfield_time_from_instant(time_range.end),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _instant_from_skyfield_time(t: Time):
|
|
50
|
+
return Instant.from_datetime(t.utc_datetime())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_last_sat: EarthSatellite | None = None
|
|
54
|
+
_last_sat_identifier: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _skyfield_sat_from_satellite(satellite: Satellite) -> EarthSatellite:
|
|
58
|
+
global _last_sat, _last_sat_identifier
|
|
59
|
+
|
|
60
|
+
if _last_sat_identifier == satellite.identifier and _last_sat is not None:
|
|
61
|
+
return _last_sat
|
|
62
|
+
|
|
63
|
+
match satellite.orbit.representation:
|
|
64
|
+
case OrbitRepresentation.TLE:
|
|
65
|
+
tle: TLE = satellite.orbit.data
|
|
66
|
+
skyfield_sat = EarthSatellite(
|
|
67
|
+
tle.line1,
|
|
68
|
+
tle.line2,
|
|
69
|
+
satellite.identifier,
|
|
70
|
+
ts,
|
|
71
|
+
)
|
|
72
|
+
case _:
|
|
73
|
+
raise ValueError("Unknown orbit representation")
|
|
74
|
+
|
|
75
|
+
_last_sat_identifier = satellite.identifier
|
|
76
|
+
_last_sat = skyfield_sat
|
|
77
|
+
return skyfield_sat
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_last_topos: Topos | None = None
|
|
81
|
+
_last_identifier: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _skyfield_topos_from_observer(observer: Observer) -> Topos:
|
|
85
|
+
global _last_topos, _last_identifier
|
|
86
|
+
|
|
87
|
+
if _last_identifier == observer.identifier and _last_topos is not None:
|
|
88
|
+
return _last_topos
|
|
89
|
+
|
|
90
|
+
_last_topos = Topos(
|
|
91
|
+
latitude_degrees=observer.lat_deg,
|
|
92
|
+
longitude_degrees=observer.lon_deg,
|
|
93
|
+
elevation_m=observer.elevation_meters,
|
|
94
|
+
)
|
|
95
|
+
_last_identifier = observer.identifier
|
|
96
|
+
return _last_topos
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_sample_from_skyfield_sample(skyfield_sample: SkyfieldSample) -> GeometrySample:
|
|
100
|
+
instant = _instant_from_skyfield_time(skyfield_sample.t)
|
|
101
|
+
azimuth_deg = float(skyfield_sample.az.degrees)
|
|
102
|
+
altitude_deg = float(skyfield_sample.alt.degrees)
|
|
103
|
+
return GeometrySample(instant=instant, azimuth_deg=azimuth_deg, altitude_deg=altitude_deg)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _skyfield_pass_to_obs_window(
|
|
107
|
+
thepass: SkyfieldPass, sat_identifier: str, obs_identifier: str
|
|
108
|
+
) -> ObservationWindow:
|
|
109
|
+
start_sample = _get_sample_from_skyfield_sample(thepass.start_sample)
|
|
110
|
+
max_altitude_sample = _get_sample_from_skyfield_sample(thepass.peak_sample)
|
|
111
|
+
end_sample = _get_sample_from_skyfield_sample(thepass.end_sample)
|
|
112
|
+
return ObservationWindow(
|
|
113
|
+
start_sample=start_sample,
|
|
114
|
+
max_altitude_sample=max_altitude_sample,
|
|
115
|
+
end_sample=end_sample,
|
|
116
|
+
sat_identifier=sat_identifier,
|
|
117
|
+
observer_identifier=obs_identifier,
|
|
118
|
+
has_multiple_peaks=thepass.has_multiple_peaks,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _find_visibility_intervals(
|
|
123
|
+
satellite: EarthSatellite,
|
|
124
|
+
station: Topos,
|
|
125
|
+
t_start: Time,
|
|
126
|
+
t_end: Time,
|
|
127
|
+
horizon_deg: float,
|
|
128
|
+
) -> list[SkyfieldPass]:
|
|
129
|
+
"""
|
|
130
|
+
Find exact visibility intervals where the satellite is above
|
|
131
|
+
a constant horizon.
|
|
132
|
+
|
|
133
|
+
Handles:
|
|
134
|
+
- passes already above the horizon at t_start
|
|
135
|
+
- geosynchronous / geostatic satellites
|
|
136
|
+
|
|
137
|
+
Returns: list of (t_start, t_peak, peak_altevation_deg, t_end)
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
start_sample = _get_sample_at_time(satellite, station, t_start)
|
|
141
|
+
visible_at_start = start_sample.alt.degrees > horizon_deg # Satellite is overhead
|
|
142
|
+
|
|
143
|
+
times, events = satellite.find_events(
|
|
144
|
+
station,
|
|
145
|
+
t_start,
|
|
146
|
+
t_end,
|
|
147
|
+
altitude_degrees=horizon_deg,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
passes: list[SkyfieldPass] = []
|
|
151
|
+
|
|
152
|
+
# If the sat is currently overhead, start the pass at t_start
|
|
153
|
+
current_start_sample: SkyfieldSample | None = start_sample if visible_at_start else None
|
|
154
|
+
current_peak_sample: SkyfieldSample | None = None
|
|
155
|
+
|
|
156
|
+
has_multiple_peaks = False
|
|
157
|
+
last_event: int | None = 0 if visible_at_start else None
|
|
158
|
+
|
|
159
|
+
for t, event in zip(times, events):
|
|
160
|
+
if event == 0:
|
|
161
|
+
# Rise
|
|
162
|
+
current_start_sample = _get_sample_at_time(satellite, station, t)
|
|
163
|
+
current_peak_sample = None
|
|
164
|
+
last_event = 0
|
|
165
|
+
elif event == 1:
|
|
166
|
+
# Culmination
|
|
167
|
+
|
|
168
|
+
if not current_start_sample:
|
|
169
|
+
# This case can happen if the satellite culminates while under the horizon
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# In some cases (old TLE or non-leo orbit) there can be multiple culmination events in a row.
|
|
173
|
+
# The satellite descends after one culmination, but not below horizon. Then rises again.
|
|
174
|
+
# In these cases, we keep the overall highest altitude.
|
|
175
|
+
candidate_peak_sample = _get_sample_at_time(satellite, station, t)
|
|
176
|
+
if (
|
|
177
|
+
last_event != 1
|
|
178
|
+
or not current_peak_sample
|
|
179
|
+
or candidate_peak_sample.alt.degrees > current_peak_sample.alt.degrees
|
|
180
|
+
):
|
|
181
|
+
current_peak_sample = candidate_peak_sample
|
|
182
|
+
if last_event == 1:
|
|
183
|
+
has_multiple_peaks = True
|
|
184
|
+
last_event = 1
|
|
185
|
+
elif event == 2:
|
|
186
|
+
# Set
|
|
187
|
+
assert current_start_sample is not None, "Set event without active pass"
|
|
188
|
+
|
|
189
|
+
if not current_peak_sample:
|
|
190
|
+
# If there is no peak, the altitude function is monotonic in the time range
|
|
191
|
+
# If we have set event without culmination -> start is after culmination
|
|
192
|
+
# Therefore the pass has max altitude at start
|
|
193
|
+
current_peak_sample = current_start_sample
|
|
194
|
+
passes.append(
|
|
195
|
+
SkyfieldPass(
|
|
196
|
+
start_sample=current_start_sample,
|
|
197
|
+
peak_sample=current_peak_sample,
|
|
198
|
+
end_sample=_get_sample_at_time(satellite, station, t),
|
|
199
|
+
has_multiple_peaks=has_multiple_peaks,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
current_start_sample = None
|
|
203
|
+
current_peak_sample = None
|
|
204
|
+
last_event = 2
|
|
205
|
+
has_multiple_peaks = False
|
|
206
|
+
|
|
207
|
+
# If we have an open-ended pass at t_end, make the pass end then
|
|
208
|
+
if current_start_sample is not None:
|
|
209
|
+
current_end_sample = _get_sample_at_time(satellite, station, t_end)
|
|
210
|
+
if not current_peak_sample:
|
|
211
|
+
# If there is no peak, the altitude function is monotonic in the time range.
|
|
212
|
+
# Either start is after culmination or end is before culmination because no culmination event
|
|
213
|
+
if current_start_sample.alt.degrees > current_end_sample.alt.degrees:
|
|
214
|
+
# start is after culmination because alt is decreasing
|
|
215
|
+
current_peak_sample = current_start_sample
|
|
216
|
+
else:
|
|
217
|
+
# end is before culmination because alt is increasing
|
|
218
|
+
current_peak_sample = current_end_sample
|
|
219
|
+
|
|
220
|
+
passes.append(
|
|
221
|
+
SkyfieldPass(
|
|
222
|
+
start_sample=current_start_sample,
|
|
223
|
+
peak_sample=current_peak_sample,
|
|
224
|
+
end_sample=current_end_sample,
|
|
225
|
+
has_multiple_peaks=has_multiple_peaks,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return passes
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def find_passes(
|
|
233
|
+
satellite: Satellite, observer: Observer, time_range: TimeRange
|
|
234
|
+
) -> list[ObservationWindow]:
|
|
235
|
+
"""
|
|
236
|
+
Finds a list of AOS -> LOS for a satellite over an observer.
|
|
237
|
+
"""
|
|
238
|
+
skyfield_sat = _skyfield_sat_from_satellite(satellite)
|
|
239
|
+
skyfield_observer = _skyfield_topos_from_observer(observer)
|
|
240
|
+
t_start, t_end = _skyfield_times_from_range(time_range)
|
|
241
|
+
|
|
242
|
+
passes = [
|
|
243
|
+
_skyfield_pass_to_obs_window(skyfield_pass, satellite.identifier, observer.identifier)
|
|
244
|
+
for skyfield_pass in _find_visibility_intervals(
|
|
245
|
+
skyfield_sat, skyfield_observer, t_start, t_end, observer.horizon_deg
|
|
246
|
+
)
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
return passes
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def obs_window_from_range(
|
|
253
|
+
satellite: Satellite,
|
|
254
|
+
observer: Observer,
|
|
255
|
+
time_range: TimeRange,
|
|
256
|
+
origin_pass: ObservationWindow | None,
|
|
257
|
+
) -> ObservationWindow:
|
|
258
|
+
"""
|
|
259
|
+
Calculates the start and end sample for a time range, converting it into an ObservationWindow.
|
|
260
|
+
Does not compute the max_altitude_sample.
|
|
261
|
+
"""
|
|
262
|
+
t_start, t_end = _skyfield_times_from_range(time_range)
|
|
263
|
+
skyfield_satellite = _skyfield_sat_from_satellite(satellite)
|
|
264
|
+
skyfield_observer = _skyfield_topos_from_observer(observer)
|
|
265
|
+
|
|
266
|
+
skyfield_start_sample = _get_sample_at_time(skyfield_satellite, skyfield_observer, t_start)
|
|
267
|
+
start_sample = _get_sample_from_skyfield_sample(skyfield_start_sample)
|
|
268
|
+
|
|
269
|
+
skyfield_end_sample = _get_sample_at_time(skyfield_satellite, skyfield_observer, t_end)
|
|
270
|
+
end_sample = _get_sample_from_skyfield_sample(skyfield_end_sample)
|
|
271
|
+
|
|
272
|
+
if origin_pass and not origin_pass.has_multiple_peaks:
|
|
273
|
+
t_closest_approach = origin_pass.max_altitude_sample.instant
|
|
274
|
+
if t_closest_approach > time_range.end:
|
|
275
|
+
max_altitude_sample = end_sample
|
|
276
|
+
elif t_closest_approach < time_range.start:
|
|
277
|
+
max_altitude_sample = start_sample
|
|
278
|
+
else:
|
|
279
|
+
max_altitude_sample = origin_pass.max_altitude_sample
|
|
280
|
+
has_multiple_peaks = False
|
|
281
|
+
|
|
282
|
+
else:
|
|
283
|
+
max_altitude__skyfield_sample = _calculate_max_alt_skyfield_sample(
|
|
284
|
+
skyfield_start_sample, skyfield_end_sample, skyfield_satellite, skyfield_observer
|
|
285
|
+
)
|
|
286
|
+
max_altitude_sample = _get_sample_from_skyfield_sample(max_altitude__skyfield_sample)
|
|
287
|
+
has_multiple_peaks = None # Unknown
|
|
288
|
+
|
|
289
|
+
return ObservationWindow(
|
|
290
|
+
start_sample=start_sample,
|
|
291
|
+
end_sample=end_sample,
|
|
292
|
+
max_altitude_sample=max_altitude_sample,
|
|
293
|
+
sat_identifier=satellite.identifier,
|
|
294
|
+
observer_identifier=observer.identifier,
|
|
295
|
+
has_multiple_peaks=has_multiple_peaks,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _calculate_max_alt_skyfield_sample(
|
|
300
|
+
start_sample: SkyfieldSample,
|
|
301
|
+
end_sample: SkyfieldSample,
|
|
302
|
+
skyfield_sat: EarthSatellite,
|
|
303
|
+
topos: Topos,
|
|
304
|
+
step_seconds: float = 5.0,
|
|
305
|
+
) -> SkyfieldSample:
|
|
306
|
+
if end_sample.t.tt <= start_sample.t.tt:
|
|
307
|
+
raise ValueError("Invalid window: end <= start")
|
|
308
|
+
|
|
309
|
+
def altitude_degrees(t: Time): # pragma: no cover
|
|
310
|
+
alt, _, _ = (skyfield_sat - topos).at(t).altaz()
|
|
311
|
+
return alt.degrees
|
|
312
|
+
|
|
313
|
+
# Control coarse search resolution
|
|
314
|
+
altitude_degrees.step_days = step_seconds / 86400.0 # type: ignore[attr-defined]
|
|
315
|
+
|
|
316
|
+
t_peaks, peak_values = find_maxima(start_sample.t, end_sample.t, altitude_degrees)
|
|
317
|
+
|
|
318
|
+
# Include endpoints
|
|
319
|
+
alt_start = start_sample.alt.degrees
|
|
320
|
+
alt_end = end_sample.alt.degrees
|
|
321
|
+
|
|
322
|
+
max_alt = alt_start
|
|
323
|
+
max_time = start_sample.t
|
|
324
|
+
|
|
325
|
+
if alt_end > max_alt:
|
|
326
|
+
max_alt = alt_end
|
|
327
|
+
max_time = end_sample.t
|
|
328
|
+
|
|
329
|
+
# Compare interior maxima
|
|
330
|
+
for t_peak, alt_peak in zip(t_peaks, peak_values):
|
|
331
|
+
if alt_peak > max_alt:
|
|
332
|
+
max_alt = alt_peak
|
|
333
|
+
max_time = t_peak
|
|
334
|
+
|
|
335
|
+
return _get_sample_at_time(skyfield_sat, topos, max_time)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _calculate_min_alt_skyfield_sample(
|
|
339
|
+
start_sample: SkyfieldSample,
|
|
340
|
+
end_sample: SkyfieldSample,
|
|
341
|
+
skyfield_sat: EarthSatellite,
|
|
342
|
+
topos: Topos,
|
|
343
|
+
step_seconds: float = 5.0,
|
|
344
|
+
) -> SkyfieldSample:
|
|
345
|
+
if end_sample.t.tt <= start_sample.t.tt:
|
|
346
|
+
raise ValueError("Invalid window: end <= start")
|
|
347
|
+
|
|
348
|
+
def neg_altitude_degrees(t: Time) -> float: # pragma: no cover
|
|
349
|
+
alt, _, _ = (skyfield_sat - topos).at(t).altaz()
|
|
350
|
+
return -float(alt.degrees)
|
|
351
|
+
|
|
352
|
+
# Control coarse search resolution
|
|
353
|
+
neg_altitude_degrees.step_days = step_seconds / 86400.0 # type: ignore[attr-defined]
|
|
354
|
+
|
|
355
|
+
t_mins, neg_values = find_maxima(start_sample.t, end_sample.t, neg_altitude_degrees)
|
|
356
|
+
|
|
357
|
+
# Include endpoints
|
|
358
|
+
alt_start = float(start_sample.alt.degrees)
|
|
359
|
+
alt_end = float(end_sample.alt.degrees)
|
|
360
|
+
|
|
361
|
+
min_alt = alt_start
|
|
362
|
+
min_time = start_sample.t
|
|
363
|
+
|
|
364
|
+
if alt_end < min_alt:
|
|
365
|
+
min_alt = alt_end
|
|
366
|
+
min_time = end_sample.t
|
|
367
|
+
|
|
368
|
+
# Compare interior minima: neg_values are maxima of (-alt) => minima of alt
|
|
369
|
+
for t_min, neg_val in zip(t_mins, neg_values):
|
|
370
|
+
alt_val = -float(neg_val)
|
|
371
|
+
if alt_val < min_alt:
|
|
372
|
+
min_alt = alt_val
|
|
373
|
+
min_time = t_min
|
|
374
|
+
|
|
375
|
+
return _get_sample_at_time(skyfield_sat, topos, min_time)
|
satnogs_predict/py.typed
ADDED
|
File without changes
|