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.
@@ -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)
File without changes