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,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,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,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")
|