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,9 @@
1
+ from . import constraints, domain
2
+ from .constraints import * # noqa: F401,F403
3
+ from .core.engine import find_observation_windows, get_and_validate_pass_from_range
4
+ from .domain import * # noqa: F401,F403
5
+ from .planning.planner import check_observation_density_limit_respected, has_range_list_overlap
6
+
7
+ __all__ = list(domain.__all__) + list(constraints.__all__)
8
+ __all__ += ["check_observation_density_limit_respected", "has_range_list_overlap"]
9
+ __all__ += ["find_observation_windows", "get_and_validate_pass_from_range"]
@@ -0,0 +1,3 @@
1
+ from .constraints import MaxDurationConstraint, MinCulminationConstraint, MinDurationConstraint
2
+
3
+ __all__ = ["MinCulminationConstraint", "MinDurationConstraint", "MaxDurationConstraint"]
@@ -0,0 +1,111 @@
1
+ from typing import Protocol
2
+
3
+ from satnogs_predict.domain.constraints import ConstraintAppliedResult, ConstraintCheckFailedError
4
+ from satnogs_predict.domain.time import Duration
5
+ from satnogs_predict.domain.window import ObservationWindow
6
+
7
+
8
+ class Constraint(Protocol):
9
+ """
10
+ Abstract Constraint parent class.
11
+ """
12
+
13
+ name: str
14
+
15
+ def apply(self, candidate: ObservationWindow) -> ObservationWindow:
16
+ """
17
+ Applies the constraint to a candidate observation window.
18
+ Returns the candidate with the constraint_applied_result field set.
19
+
20
+ The candidate may or may not be altered by the constraint.
21
+ """
22
+ ... # pragma: no cover
23
+
24
+ def check(self, candidate: ObservationWindow):
25
+ """
26
+ Checks if the candidate respects the constraint.
27
+ Does not alter the candidate.
28
+ Raises a ConstraintCheckFailedError error if the check fails.
29
+ """
30
+ ... # pragma: no cover
31
+
32
+ def _get_check_failed_message(self, reason: str):
33
+ return f"{self.name} - constraint check failed: {reason}"
34
+
35
+
36
+ class MinCulminationConstraint(Constraint):
37
+ """
38
+ Ensures the observation window contains a minumum altitude for the pass.
39
+ """
40
+
41
+ name: str = "min-culmination"
42
+
43
+ def __init__(self, min_culmination_deg: float):
44
+ self.min_culmination_deg = min_culmination_deg
45
+
46
+ def check(self, candidate: ObservationWindow):
47
+ if candidate.max_altitude_sample.altitude_deg < self.min_culmination_deg:
48
+ raise ConstraintCheckFailedError(
49
+ self._get_check_failed_message("Max altitude less than min culmination")
50
+ )
51
+
52
+ def apply(self, candidate: ObservationWindow) -> ObservationWindow:
53
+ if candidate.max_altitude_sample.altitude_deg < self.min_culmination_deg:
54
+ candidate.constraint_applied_result = ConstraintAppliedResult.REJECTED
55
+ candidate.rejection_reason = "Max altitude less than min culmination"
56
+ else:
57
+ candidate.constraint_applied_result = ConstraintAppliedResult.UNCHANGED
58
+
59
+ return candidate
60
+
61
+
62
+ class MinDurationConstraint(Constraint):
63
+ """
64
+ Ensures the observation window contains a minumum altitude for the pass.
65
+ """
66
+
67
+ name: str = "min-duration"
68
+
69
+ def __init__(self, min_duration: Duration):
70
+ self.min_duration = min_duration
71
+
72
+ def check(self, candidate: ObservationWindow):
73
+ if candidate.time_range.duration < self.min_duration:
74
+ raise ConstraintCheckFailedError(
75
+ self._get_check_failed_message("Duration less than min duration")
76
+ )
77
+
78
+ def apply(self, candidate: ObservationWindow) -> ObservationWindow:
79
+ if candidate.time_range.duration < self.min_duration:
80
+ candidate.rejection_reason = "Duration less than min duration"
81
+ candidate.constraint_applied_result = ConstraintAppliedResult.REJECTED
82
+ else:
83
+ candidate.constraint_applied_result = ConstraintAppliedResult.UNCHANGED
84
+
85
+ return candidate
86
+
87
+
88
+ class MaxDurationConstraint(Constraint):
89
+ """
90
+ Ensures the observation window does not exceed a maximum duration.
91
+ """
92
+
93
+ name: str = "max-duration"
94
+
95
+ def __init__(self, max_duration: Duration):
96
+ self.max_duration = max_duration
97
+
98
+ def check(self, candidate: ObservationWindow):
99
+ if candidate.time_range.duration > self.max_duration:
100
+ raise ConstraintCheckFailedError(
101
+ self._get_check_failed_message("Duration more than max duration")
102
+ )
103
+
104
+ def apply(self, candidate: ObservationWindow) -> ObservationWindow:
105
+ if candidate.time_range.duration > self.max_duration:
106
+ candidate.rejection_reason = "Duration more than max duration"
107
+ candidate.constraint_applied_result = ConstraintAppliedResult.REJECTED
108
+ else:
109
+ candidate.constraint_applied_result = ConstraintAppliedResult.UNCHANGED
110
+
111
+ return candidate
File without changes
@@ -0,0 +1,105 @@
1
+ from typing import Sequence
2
+
3
+ from satnogs_predict.constraints.constraints import Constraint
4
+ from satnogs_predict.domain.constraints import ConstraintAppliedResult
5
+ from satnogs_predict.domain.observer import Observer
6
+ from satnogs_predict.domain.orbit import Satellite
7
+ from satnogs_predict.domain.planner import PlanningConfig
8
+ from satnogs_predict.domain.time import TimeRange
9
+ from satnogs_predict.domain.window import ObservationWindow, RangeToPassError
10
+ from satnogs_predict.planning.planner import plan_observation_windows
11
+ from satnogs_predict.propagation.propagator import find_passes
12
+
13
+
14
+ def _validate_windows(
15
+ candidate_windows: Sequence[ObservationWindow], constraints: Sequence[Constraint]
16
+ ) -> tuple[list[ObservationWindow], list[ObservationWindow]]:
17
+ """
18
+ Applies the constraints and returns the valid and rejected windows.
19
+ Valid windows may or may not be modified.
20
+ """
21
+ valid: list[ObservationWindow] = []
22
+ rejected: list[ObservationWindow] = []
23
+ for window in candidate_windows:
24
+ constrained_pass = window
25
+ for constr in constraints:
26
+ constrained_pass = constr.apply(constrained_pass)
27
+ if constrained_pass.constraint_applied_result == ConstraintAppliedResult.REJECTED:
28
+ rejected.append(constrained_pass)
29
+ break
30
+ else:
31
+ valid.append(constrained_pass)
32
+
33
+ return valid, rejected
34
+
35
+
36
+ def _ensure_window_respects_constraints(
37
+ window: ObservationWindow, constraints: Sequence[Constraint]
38
+ ) -> None:
39
+ """
40
+ Makes sure that the provided windows already respect the constraints
41
+ """
42
+
43
+ for constr in constraints:
44
+ constr.check(window)
45
+
46
+
47
+ def find_observation_windows(
48
+ satellite: Satellite,
49
+ observer: Observer,
50
+ time_range: TimeRange,
51
+ planning_config: PlanningConfig,
52
+ pre_plan_constraints: Sequence[Constraint] = [],
53
+ post_plan_constraints: Sequence[Constraint] = [],
54
+ scheduled_intervals: Sequence[TimeRange] = [],
55
+ ) -> tuple[list[ObservationWindow], list[ObservationWindow]]:
56
+ # Find passes
57
+ candidate_passes = find_passes(satellite, observer, time_range)
58
+ # Validate passes (prunning)
59
+ valid_passes, rejected_passes = _validate_windows(candidate_passes, pre_plan_constraints)
60
+
61
+ valid_windows: list[ObservationWindow] = []
62
+ rejected_windows: list[ObservationWindow] = rejected_passes
63
+ for thepass in valid_passes:
64
+ # Find available windows given already scheduled time ranges of the observer
65
+ ordered_scheduled_intervals = list(scheduled_intervals)
66
+ ordered_scheduled_intervals.sort(key=lambda o: o.start)
67
+ candidate_windows = plan_observation_windows(
68
+ satellite, observer, thepass, planning_config, ordered_scheduled_intervals
69
+ )
70
+ # Validate those windows
71
+ current_valid_windows, current_rejected_windows = _validate_windows(
72
+ candidate_windows, post_plan_constraints
73
+ )
74
+ valid_windows.extend(current_valid_windows)
75
+ rejected_windows.extend(current_rejected_windows)
76
+
77
+ return valid_windows, rejected_windows
78
+
79
+
80
+ def get_and_validate_pass_from_range(
81
+ satellite: Satellite,
82
+ observer: Observer,
83
+ time_range: TimeRange,
84
+ constraints: Sequence[Constraint] = [],
85
+ ) -> ObservationWindow:
86
+ """
87
+ Return the single pass corresponding to `time_range` after validating constraints.
88
+
89
+ The function first finds passes in `time_range` and requires exactly one result.
90
+ It then validates that pass with each constraint via `constraint.check`.
91
+
92
+ Raises:
93
+ RangeToPassError: if no pass or multiple passes are found in `time_range`.
94
+ ConstraintCheckFailedError: if any provided constraint check fails.
95
+ """
96
+
97
+ thepass = find_passes(satellite, observer, time_range)
98
+ if not len(thepass):
99
+ raise RangeToPassError("No pass found in the provided time range")
100
+ if len(thepass) > 1:
101
+ raise RangeToPassError("Multiple passes found in the provided time range")
102
+
103
+ _ensure_window_respects_constraints(thepass[0], constraints)
104
+
105
+ return thepass[0]
@@ -0,0 +1,29 @@
1
+ from .constraints import ConstraintAppliedResult, ConstraintCheckFailedError
2
+ from .geometry import GeometrySample
3
+ from .observer import Observer
4
+ from .orbit import TLE, Orbit, OrbitRepresentation, Satellite
5
+ from .planner import OverlapHandling, PlanningConfig
6
+ from .time import (
7
+ Duration,
8
+ Instant,
9
+ TimeRange,
10
+ )
11
+ from .window import ObservationWindow, RangeToPassError
12
+
13
+ __all__ = [
14
+ "ConstraintAppliedResult",
15
+ "ConstraintCheckFailedError",
16
+ "GeometrySample",
17
+ "Observer",
18
+ "TLE",
19
+ "OrbitRepresentation",
20
+ "Orbit",
21
+ "Satellite",
22
+ "Instant",
23
+ "Duration",
24
+ "TimeRange",
25
+ "ObservationWindow",
26
+ "RangeToPassError",
27
+ "PlanningConfig",
28
+ "OverlapHandling",
29
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class ConstraintAppliedResult(IntEnum):
7
+ REJECTED = 0
8
+ UNCHANGED = 1
9
+ MODIFIED = 2
10
+
11
+
12
+ class ConstraintCheckFailedError(Exception):
13
+ pass
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .time import Instant
6
+ from .validation import ensure_required_fields_not_none
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class GeometrySample:
11
+ """Geometry at an instant, relative to an observer."""
12
+
13
+ instant: Instant
14
+ azimuth_deg: float
15
+ altitude_deg: float
16
+
17
+ def __post_init__(self) -> None:
18
+ ensure_required_fields_not_none(self)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .validation import ensure_required_fields_not_none
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Observer:
10
+ """Observer to calculate passes over e.g. a ground station"""
11
+
12
+ lat_deg: float
13
+ lon_deg: float
14
+ elevation_meters: int
15
+ horizon_deg: float
16
+ identifier: str
17
+
18
+ def __post_init__(self) -> None:
19
+ ensure_required_fields_not_none(self)
@@ -0,0 +1,47 @@
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
+
19
+ class OrbitRepresentation(IntEnum):
20
+ TLE = 0
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Orbit:
25
+ representation: OrbitRepresentation
26
+ data: TLE
27
+
28
+ def __post_init__(self) -> None:
29
+ ensure_required_fields_not_none(self)
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Satellite:
34
+ orbit: Orbit
35
+ identifier: str
36
+
37
+ def __post_init__(self) -> None:
38
+ ensure_required_fields_not_none(self)
39
+
40
+ @classmethod
41
+ def from_tle(cls, line0: str, line1: str, line2: str, identifier: str = ""):
42
+ tle = TLE(line0, line1, line2)
43
+ orbit = Orbit(representation=OrbitRepresentation.TLE, data=tle)
44
+ if not identifier:
45
+ identifier = line0
46
+ sat = cls(orbit=orbit, identifier=identifier)
47
+ return sat
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass
2
+ from enum import IntEnum
3
+
4
+ from .time import Duration
5
+ from .validation import ensure_required_fields_not_none
6
+
7
+
8
+ class OverlapHandling(IntEnum):
9
+ """
10
+ 0 -> reject overlaps entirely
11
+ 1 -> return truncated/split windows where possible
12
+ 2 -> return full pass even if overlapped (and annotate overlap ratio)
13
+ """
14
+
15
+ DROP = 0
16
+ TRUNCATE = 1
17
+ FULL = 2
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class PlanningConfig:
22
+ """
23
+ The planner's configuration for splitting and handling window overlaps
24
+ """
25
+
26
+ overlap_handling: OverlapHandling
27
+ split_long_windows: bool
28
+
29
+ # When truncating around overlaps, apply padding.
30
+ overlap_padding: Duration | None = None
31
+
32
+ # If a usable window is longer than max_window_duration, split into chunks separated by break_duration.
33
+ max_window_duration: Duration | None = None
34
+
35
+ # Time to wait between splits
36
+ break_duration: Duration | None = None
37
+
38
+ # If after splitting, what remains is less than remainder_cutoff_duration, drop it.
39
+ remainder_cutoff_duration: Duration | None = None
40
+
41
+ def __post_init__(self) -> None:
42
+ ensure_required_fields_not_none(self)
43
+
44
+ if self.overlap_handling == OverlapHandling.TRUNCATE and not self.overlap_padding:
45
+ raise ValueError(
46
+ "PlanningConfig error: overlap_padding is required when overlap_handling=OverlapHandling.TRUNCATE"
47
+ )
48
+
49
+ if self.split_long_windows and not all(
50
+ (self.max_window_duration, self.break_duration, self.remainder_cutoff_duration)
51
+ ):
52
+ raise ValueError(
53
+ "PlanningConfig error: max_window_duration, break_duration, remainder_cutoff_duration are required when split_long_windows=True"
54
+ )
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from .validation import ensure_required_fields_not_none
7
+
8
+ # Instant = NewType("Instant", int) # microseconds since Unix epoch (UTC)
9
+
10
+
11
+ class Instant(int):
12
+ """Microseconds since Unix epoch (UTC)."""
13
+
14
+ def to_iso_utc(self) -> str:
15
+ dt = datetime.fromtimestamp(int(self) / 1_000_000, tz=timezone.utc)
16
+ # Keep microseconds and use Z for UTC
17
+ return dt.isoformat(timespec="microseconds").replace("+00:00", "Z")
18
+
19
+ @classmethod
20
+ def from_datetime(cls, dt: datetime) -> "Instant":
21
+ if dt.tzinfo is None:
22
+ raise ValueError("dt must be timezone-aware")
23
+ dt = dt.astimezone(timezone.utc)
24
+ epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
25
+ delta = dt - epoch
26
+ micros = delta.days * 86_400_000_000 + delta.seconds * 1_000_000 + delta.microseconds
27
+ return cls(micros)
28
+
29
+ def to_datetime(self) -> "datetime":
30
+ seconds, micros = divmod(int(self), 1_000_000)
31
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(microsecond=micros)
32
+
33
+ def __add__(self, other: object) -> "Instant":
34
+ if not isinstance(other, Duration):
35
+ return NotImplemented
36
+ return Instant(int(self) + other.micros)
37
+
38
+ def __radd__(self, other: object) -> "Instant":
39
+ if not isinstance(other, Duration):
40
+ return NotImplemented
41
+ return self + other
42
+
43
+ def __sub__(self, other: object) -> "Instant | int":
44
+ if isinstance(other, Duration):
45
+ return Instant(int(self) - other.micros)
46
+ if isinstance(other, int):
47
+ return int(self) - other
48
+ return NotImplemented
49
+
50
+ def __str__(self) -> str:
51
+ return self.to_datetime().isoformat(timespec="seconds").replace("+00:00", "Z")
52
+
53
+ def __repr__(self) -> str:
54
+ return self.__str__()
55
+
56
+
57
+ @dataclass(frozen=True, slots=True, order=True)
58
+ class Duration:
59
+ """A non-negative duration."""
60
+
61
+ micros: int
62
+
63
+ def __post_init__(self) -> None:
64
+ ensure_required_fields_not_none(self)
65
+ if self.micros < 0:
66
+ raise ValueError("Duration must be non-negative")
67
+
68
+ def __mul__(self, n: int) -> Duration:
69
+ return Duration(self.micros * n)
70
+
71
+ def __add__(self, other: "Duration") -> "Duration":
72
+ if not isinstance(other, Duration):
73
+ return NotImplemented
74
+ return Duration(self.micros + other.micros)
75
+
76
+ def __sub__(self, other: "Duration") -> int:
77
+ if not isinstance(other, Duration):
78
+ return NotImplemented
79
+ return self.micros - other.micros
80
+
81
+ def __int__(self) -> int:
82
+ return self.micros
83
+
84
+ def __radd__(self, other: object) -> "Duration":
85
+ if other == 0:
86
+ return self
87
+ return NotImplemented
88
+
89
+ @classmethod
90
+ def from_timedelta(cls, td: timedelta) -> "Duration":
91
+ micros = int(td.total_seconds() * 1_000_000)
92
+ return cls(micros=micros)
93
+
94
+ @classmethod
95
+ def from_seconds(cls, seconds: int) -> "Duration":
96
+ return cls(micros=seconds * 1_000_000)
97
+
98
+ def to_timedelta(self) -> timedelta:
99
+ return timedelta(microseconds=self.micros)
100
+
101
+ def to_seconds(self) -> float:
102
+ return self.micros / 1_000_000
103
+
104
+ def __str__(self) -> str:
105
+ total_seconds = self.micros // 1_000_000
106
+ hours, remainder = divmod(total_seconds, 3600)
107
+ minutes, seconds = divmod(remainder, 60)
108
+
109
+ parts: list[str] = []
110
+ if hours:
111
+ parts.append(f"{hours} hr" + ("" if hours == 1 else "s"))
112
+ if minutes:
113
+ parts.append(f"{minutes} min" + ("" if minutes == 1 else "s"))
114
+ if seconds:
115
+ parts.append(f"{seconds} sec" + ("" if seconds == 1 else "s"))
116
+
117
+ if not parts:
118
+ return "0 secs"
119
+
120
+ return " ".join(parts)
121
+
122
+ def __repr__(self) -> str:
123
+ return str(self)
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class TimeRange:
128
+ """Half-open interval [start, end)."""
129
+
130
+ start: Instant
131
+ end: Instant
132
+
133
+ def __post_init__(self) -> None:
134
+ ensure_required_fields_not_none(self)
135
+ if int(self.start) >= int(self.end):
136
+ raise ValueError("TimeRange requires start < end")
137
+
138
+ @classmethod
139
+ def from_datetimes(cls, start: datetime, end: datetime) -> "TimeRange":
140
+ return cls(
141
+ start=Instant.from_datetime(start),
142
+ end=Instant.from_datetime(end),
143
+ )
144
+
145
+ @property
146
+ def duration(self) -> Duration:
147
+ return Duration(micros=int(self.end) - int(self.start))
148
+
149
+ def contains(self, instant: Instant) -> bool:
150
+ return int(self.start) <= int(instant) < int(self.end)
151
+
152
+ def intersects(self, other: "TimeRange") -> bool:
153
+ return int(self.start) < int(other.end) and int(other.start) < int(self.end)
154
+
155
+ def intersection(self, other: "TimeRange") -> "TimeRange | None":
156
+ s = max(int(self.start), int(other.start))
157
+ e = min(int(self.end), int(other.end))
158
+ if s >= e:
159
+ return None
160
+ return TimeRange(start=Instant(s), end=Instant(e))
161
+
162
+ def subtract(self, other: TimeRange) -> tuple[TimeRange, ...]:
163
+ inter = self.intersection(other)
164
+ if inter is None:
165
+ return (self,)
166
+
167
+ pieces: list[TimeRange] = []
168
+
169
+ if int(self.start) < int(inter.start):
170
+ pieces.append(TimeRange(self.start, inter.start))
171
+
172
+ if int(inter.end) < int(self.end):
173
+ pieces.append(TimeRange(inter.end, self.end))
174
+
175
+ return tuple(pieces)
176
+
177
+ def overlap_ratio(self, other: TimeRange) -> float:
178
+ overlap = self.intersection(other)
179
+ if overlap is None:
180
+ return 0.0
181
+ return int(overlap.duration) / int(self.duration)
182
+
183
+ def shift(self, delta: Duration) -> "TimeRange":
184
+ return TimeRange(
185
+ start=self.start + delta,
186
+ end=self.end + delta,
187
+ )
188
+
189
+ def pad_secs(self, left: float = 0.0, right: float = 0.0) -> "TimeRange":
190
+ left_micros = int(left * 1_000_000)
191
+ right_micros = int(right * 1_000_000)
192
+ return TimeRange(
193
+ start=Instant(int(self.start) - left_micros),
194
+ end=Instant(int(self.end) + right_micros),
195
+ )
196
+
197
+ def pad_duration(self, left: Duration, right: Duration) -> "TimeRange":
198
+ left_micros = left.micros if left else 0
199
+ right_micros = right.micros if right else 0
200
+ return TimeRange(
201
+ start=Instant(int(self.start) - left_micros),
202
+ end=Instant(int(self.end) + right_micros),
203
+ )
204
+
205
+ def __str__(self) -> str:
206
+ return f"TimeRange: {str(self.start)} -> {self.end}"
207
+
208
+ def __repr__(self) -> str:
209
+ return str(self)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import MISSING, fields
4
+ from typing import Any
5
+
6
+
7
+ def ensure_required_fields_not_none(instance: Any) -> None:
8
+ """Fail fast when required dataclass fields are passed as None."""
9
+ for field in fields(instance):
10
+ has_default = field.default is not MISSING or field.default_factory is not MISSING
11
+ if not has_default and getattr(instance, field.name) is None:
12
+ raise ValueError(f"{type(instance).__name__}.{field.name} cannot be None")