stk-files 0.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.
stk_files/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ from stk_files._gaps import detect_availability, write_availability
2
+ from stk_files._parser import STKParseError
3
+ from stk_files._types import (
4
+ AttitudeFormat,
5
+ AzElSequence,
6
+ CentralBody,
7
+ CoordinateAxes,
8
+ EphemerisFormat,
9
+ EulerSequence,
10
+ InterpolationMethod,
11
+ MessageLevel,
12
+ RotationSequence,
13
+ SensorFormat,
14
+ TimeFormat,
15
+ YPRSequence,
16
+ )
17
+ from stk_files._version import __version__
18
+ from stk_files.attitude import (
19
+ AttitudeChunkWriter,
20
+ AttitudeConfig,
21
+ attitude_writer,
22
+ read_attitude,
23
+ write_attitude,
24
+ )
25
+ from stk_files.ephemeris import (
26
+ EphemerisChunkWriter,
27
+ EphemerisConfig,
28
+ ephemeris_writer,
29
+ read_ephemeris,
30
+ write_ephemeris,
31
+ )
32
+ from stk_files.interval import Interval, IntervalConfig, read_interval, write_interval
33
+ from stk_files.sensor import (
34
+ SensorChunkWriter,
35
+ SensorConfig,
36
+ read_sensor,
37
+ sensor_writer,
38
+ write_sensor,
39
+ )
40
+
41
+ __all__ = [
42
+ "AttitudeChunkWriter",
43
+ "AttitudeConfig",
44
+ "AttitudeFormat",
45
+ "AzElSequence",
46
+ "CentralBody",
47
+ "CoordinateAxes",
48
+ "EphemerisChunkWriter",
49
+ "EphemerisConfig",
50
+ "EphemerisFormat",
51
+ "EulerSequence",
52
+ "InterpolationMethod",
53
+ "Interval",
54
+ "IntervalConfig",
55
+ "MessageLevel",
56
+ "RotationSequence",
57
+ "STKParseError",
58
+ "SensorChunkWriter",
59
+ "SensorConfig",
60
+ "SensorFormat",
61
+ "TimeFormat",
62
+ "YPRSequence",
63
+ "__version__",
64
+ "attitude_writer",
65
+ "detect_availability",
66
+ "ephemeris_writer",
67
+ "read_attitude",
68
+ "read_ephemeris",
69
+ "read_interval",
70
+ "read_sensor",
71
+ "sensor_writer",
72
+ "write_attitude",
73
+ "write_availability",
74
+ "write_ephemeris",
75
+ "write_interval",
76
+ "write_sensor",
77
+ ]
stk_files/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from stk_files.cli import main
4
+
5
+ main()
stk_files/_coerce.py ADDED
@@ -0,0 +1,106 @@
1
+ """Runtime coercion of pandas/polars types to numpy arrays.
2
+
3
+ Both pandas and polars are optional dependencies. This module uses
4
+ ``isinstance`` checks with cached lazy imports so that users who only
5
+ work with numpy never pay for the import cost.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+
16
+ @functools.lru_cache(maxsize=1)
17
+ def _pandas_types() -> tuple[Any, Any]:
18
+ try:
19
+ import pandas as pd
20
+
21
+ return pd.Series, pd.DataFrame
22
+ except ImportError: # pragma: no cover
23
+ return None, None
24
+
25
+
26
+ @functools.lru_cache(maxsize=1)
27
+ def _polars_types() -> tuple[Any, Any]:
28
+ try:
29
+ import polars as pl
30
+
31
+ return pl.Series, pl.DataFrame
32
+ except ImportError: # pragma: no cover
33
+ return None, None
34
+
35
+
36
+ def _is_pandas_series(obj: Any) -> bool:
37
+ cls, _ = _pandas_types()
38
+ return cls is not None and isinstance(obj, cls)
39
+
40
+
41
+ def _is_pandas_dataframe(obj: Any) -> bool:
42
+ _, cls = _pandas_types()
43
+ return cls is not None and isinstance(obj, cls)
44
+
45
+
46
+ def _is_polars_series(obj: Any) -> bool:
47
+ cls, _ = _polars_types()
48
+ return cls is not None and isinstance(obj, cls)
49
+
50
+
51
+ def _is_polars_dataframe(obj: Any) -> bool:
52
+ _, cls = _polars_types()
53
+ return cls is not None and isinstance(obj, cls)
54
+
55
+
56
+ def coerce_times(times: Any) -> Any:
57
+ """Convert *times* to a numpy datetime64 array.
58
+
59
+ Accepted inputs:
60
+ - ``numpy.ndarray`` of ``datetime64`` — returned as-is
61
+ - ``pandas.Series`` of datetime — converted via ``.to_numpy(dtype="datetime64[ms]")``
62
+ - ``polars.Series`` of Datetime/Date — converted via ``.to_numpy()`` then cast
63
+ """
64
+ if isinstance(times, np.ndarray):
65
+ return times
66
+
67
+ if _is_pandas_series(times):
68
+ return times.to_numpy(dtype="datetime64[ms]")
69
+
70
+ if _is_polars_series(times):
71
+ arr = times.to_numpy()
72
+ if not np.issubdtype(arr.dtype, np.datetime64):
73
+ arr = arr.astype("datetime64[ms]")
74
+ return arr
75
+
76
+ # Fall through — let numpy try to coerce it
77
+ return np.asarray(times, dtype="datetime64[ms]")
78
+
79
+
80
+ def coerce_data(data: Any) -> Any:
81
+ """Convert *data* to a 2-D numpy floating-point array.
82
+
83
+ Accepted inputs:
84
+ - ``numpy.ndarray`` — returned as-is
85
+ - ``pandas.DataFrame`` — converted via ``.to_numpy(dtype=float)``
86
+ - ``pandas.Series`` — converted via ``.to_numpy(dtype=float)`` then reshaped to column
87
+ - ``polars.DataFrame`` — converted via ``.to_numpy()`` then cast
88
+ - ``polars.Series`` — converted via ``.to_numpy()`` then reshaped to column
89
+ """
90
+ if isinstance(data, np.ndarray):
91
+ return data
92
+
93
+ if _is_pandas_dataframe(data):
94
+ return data.to_numpy(dtype=float)
95
+
96
+ if _is_pandas_series(data):
97
+ return data.to_numpy(dtype=float).reshape(-1, 1)
98
+
99
+ if _is_polars_dataframe(data):
100
+ return data.to_numpy().astype(float)
101
+
102
+ if _is_polars_series(data):
103
+ return data.to_numpy().astype(float).reshape(-1, 1)
104
+
105
+ # Fall through — let numpy try
106
+ return np.asarray(data, dtype=float)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+
7
+ from stk_files._types import QUATERNION_FORMATS
8
+
9
+ if TYPE_CHECKING:
10
+ from numpy.typing import NDArray
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Scalar formatters (kept for single-value use in headers, etc.)
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ def format_iso_ymd(time: np.datetime64) -> str:
19
+ """Format as 'YYYY-MM-DDTHH:MM:SS.sss' (23 chars)."""
20
+ return str(time.astype("datetime64[ms]"))[:23]
21
+
22
+
23
+ def format_ep_sec(time: np.datetime64, epoch: np.datetime64) -> str:
24
+ """Format as epoch seconds with 15.3f width."""
25
+ dt_ms = (time - epoch) / np.timedelta64(1, "ms") # type: ignore[operator]
26
+ return f"{float(dt_ms) / 1e3:15.3f}"
27
+
28
+
29
+ def format_quaternion_row(row: np.ndarray) -> str:
30
+ """Format 4 quaternion components with {:+12.9f}."""
31
+ return " ".join(f"{float(v):+12.9f}" for v in row)
32
+
33
+
34
+ def format_generic_row(row: np.ndarray) -> str:
35
+ """Format numeric values with {:12f}."""
36
+ return " ".join(f"{float(v):12f}" for v in row)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Vectorized bulk formatters
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def format_iso_ymd_array(times: NDArray[np.datetime64]) -> list[str]:
45
+ """Vectorized ISO-YMD formatting for an entire array of times."""
46
+ ms_times = times.astype("datetime64[ms]")
47
+ return [s[:23] for s in np.datetime_as_string(ms_times, unit="ms").tolist()]
48
+
49
+
50
+ def format_ep_sec_array(
51
+ times: NDArray[np.datetime64],
52
+ epoch: np.datetime64,
53
+ ) -> list[str]:
54
+ """Vectorized epoch-seconds formatting for an entire array of times."""
55
+ ms = (times - epoch) / np.timedelta64(1, "ms")
56
+ return [f"{float(v) / 1e3:15.3f}" for v in ms]
57
+
58
+
59
+ def format_quaternion_block(data: NDArray[np.floating]) -> list[str]:
60
+ """Vectorized quaternion formatting for an entire data array."""
61
+ cols = [np.char.mod("%+12.9f", data[:, i]) for i in range(data.shape[1])]
62
+ result = cols[0]
63
+ for c in cols[1:]:
64
+ result = np.char.add(np.char.add(result, " "), c)
65
+ return result.tolist() # type: ignore[no-any-return]
66
+
67
+
68
+ def format_generic_block(data: NDArray[np.floating]) -> list[str]:
69
+ """Vectorized generic formatting for an entire data array."""
70
+ cols = [np.char.mod("%12f", data[:, i]) for i in range(data.shape[1])]
71
+ result = cols[0]
72
+ for c in cols[1:]:
73
+ result = np.char.add(np.char.add(result, " "), c)
74
+ return result.tolist() # type: ignore[no-any-return]
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Shared high-level formatters (used by all writer modules)
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def format_times(
83
+ time_format: str,
84
+ times: NDArray[np.datetime64],
85
+ scenario_epoch: np.datetime64 | None = None,
86
+ ) -> list[str]:
87
+ """Format a time array based on the time format setting."""
88
+ if time_format == "EpSec":
89
+ if scenario_epoch is None:
90
+ raise ValueError("EpSec time format requires scenario_epoch")
91
+ return format_ep_sec_array(times, scenario_epoch)
92
+ return format_iso_ymd_array(times)
93
+
94
+
95
+ def format_data_block(fmt: str, data: NDArray[np.floating]) -> list[str]:
96
+ """Format a data array using quaternion or generic formatting."""
97
+ if fmt in QUATERNION_FORMATS:
98
+ return format_quaternion_block(data)
99
+ return format_generic_block(data)
stk_files/_gaps.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, TextIO
4
+
5
+ import numpy as np
6
+
7
+ from stk_files.interval import Interval, write_interval
8
+
9
+ if TYPE_CHECKING:
10
+ from numpy.typing import NDArray
11
+
12
+
13
+ def detect_availability(
14
+ times: NDArray[np.datetime64],
15
+ max_gap: np.timedelta64,
16
+ min_points: int = 2,
17
+ ) -> list[tuple[np.datetime64, np.datetime64]]:
18
+ """Identify contiguous data spans from a sorted time array.
19
+
20
+ Returns a list of (start, end) tuples. A new span begins whenever
21
+ the gap between consecutive timestamps exceeds *max_gap*. Spans
22
+ with fewer than *min_points* points are excluded (default 2).
23
+ """
24
+ if times.size == 0:
25
+ return []
26
+
27
+ if times.size == 1:
28
+ if min_points <= 1:
29
+ return [(times[0], times[0])]
30
+ return []
31
+
32
+ gaps = np.diff(times) > max_gap
33
+ break_indices = np.where(gaps)[0]
34
+
35
+ spans: list[tuple[np.datetime64, np.datetime64]] = []
36
+ start_idx = 0
37
+ for brk in break_indices:
38
+ if brk - start_idx + 1 >= min_points:
39
+ spans.append((times[start_idx], times[brk]))
40
+ start_idx = brk + 1
41
+ if len(times) - start_idx >= min_points:
42
+ spans.append((times[start_idx], times[-1]))
43
+ return spans
44
+
45
+
46
+ def write_availability(
47
+ stream: TextIO,
48
+ times: NDArray[np.datetime64],
49
+ max_gap: np.timedelta64,
50
+ min_points: int = 2,
51
+ ) -> None:
52
+ """Write an interval file expressing when data is available.
53
+
54
+ Should be called on post-filter times so intervals reflect actual
55
+ valid data coverage. Spans with fewer than *min_points* points
56
+ are excluded.
57
+ """
58
+ spans = detect_availability(times, max_gap, min_points=min_points)
59
+ intervals: list[Interval] = [Interval(s, e) for s, e in spans]
60
+ write_interval(stream, intervals)
stk_files/_parser.py ADDED
@@ -0,0 +1,162 @@
1
+ """Low-level parsing utilities for reading STK external data files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+
10
+ if TYPE_CHECKING:
11
+ from numpy.typing import NDArray
12
+
13
+
14
+ class STKParseError(ValueError):
15
+ """Raised when an STK file cannot be parsed."""
16
+
17
+
18
+ def parse_datetime(value: str) -> np.datetime64:
19
+ """Parse an ISO-YMD datetime string to ``np.datetime64[ms]``."""
20
+ return np.datetime64(value.strip(), "ms")
21
+
22
+
23
+ def parse_header(
24
+ lines: list[str],
25
+ sentinel_prefix: str,
26
+ ) -> tuple[dict[str, str], str, int]:
27
+ """Parse an STK file header.
28
+
29
+ Returns ``(header_dict, format_string, first_data_line_index)``.
30
+ The *sentinel_prefix* is ``"AttitudeTime"`` for attitude/sensor files
31
+ or ``"Ephemeris"`` for ephemeris files.
32
+ """
33
+ if not lines:
34
+ raise STKParseError("empty file")
35
+
36
+ if not lines[0].strip().startswith("stk.v."):
37
+ raise STKParseError(
38
+ f"line 1: expected 'stk.v.' version header, got {lines[0]!r}"
39
+ )
40
+
41
+ header: dict[str, str] = {}
42
+ for idx in range(1, len(lines)):
43
+ stripped = lines[idx].strip()
44
+ if not stripped:
45
+ continue
46
+ if stripped.startswith("BEGIN "):
47
+ continue
48
+ if stripped.startswith(sentinel_prefix):
49
+ fmt = stripped[len(sentinel_prefix) :]
50
+ return header, fmt, idx + 1
51
+ parts = stripped.split(None, 1)
52
+ if len(parts) == 2:
53
+ header[parts[0]] = parts[1].strip()
54
+ elif len(parts) == 1:
55
+ header[parts[0]] = ""
56
+
57
+ raise STKParseError(f"no '{sentinel_prefix}*' data sentinel found in header")
58
+
59
+
60
+ def parse_data_section(
61
+ lines: list[str],
62
+ start: int,
63
+ time_format: str,
64
+ expected_cols: int,
65
+ scenario_epoch: np.datetime64 | None = None,
66
+ ) -> tuple[NDArray[np.datetime64], NDArray[np.floating]]:
67
+ """Parse the data rows of an STK file into numpy arrays.
68
+
69
+ Reads from ``lines[start:]`` until a line starting with ``"END"``
70
+ is encountered.
71
+ """
72
+ time_list: list[np.datetime64] = []
73
+ data_list: list[list[float]] = []
74
+
75
+ for idx in range(start, len(lines)):
76
+ stripped = lines[idx].strip()
77
+ if not stripped:
78
+ continue
79
+ if stripped.startswith("END"):
80
+ break
81
+
82
+ parts = stripped.split()
83
+ if len(parts) < 1 + expected_cols:
84
+ raise STKParseError(
85
+ f"line {idx + 1}: expected {1 + expected_cols} columns, got {len(parts)}"
86
+ )
87
+
88
+ time_str = parts[0]
89
+ if time_format == "EpSec":
90
+ if scenario_epoch is None:
91
+ raise STKParseError("EpSec data requires a ScenarioEpoch")
92
+ ms = round(float(time_str) * 1000)
93
+ time_list.append(scenario_epoch + np.timedelta64(ms, "ms"))
94
+ else:
95
+ time_list.append(np.datetime64(time_str, "ms"))
96
+
97
+ try:
98
+ data_list.append([float(x) for x in parts[1 : 1 + expected_cols]])
99
+ except ValueError as exc:
100
+ raise STKParseError(f"line {idx + 1}: invalid numeric value: {exc}") from None
101
+
102
+ if not time_list:
103
+ return (
104
+ np.array([], dtype="datetime64[ms]"),
105
+ np.empty((0, expected_cols), dtype=np.float64),
106
+ )
107
+
108
+ return (
109
+ np.array(time_list, dtype="datetime64[ms]"),
110
+ np.array(data_list, dtype=np.float64),
111
+ )
112
+
113
+
114
+ _QUOTED_RE = re.compile(r'"([^"]*)"')
115
+
116
+
117
+ def parse_interval_file(
118
+ lines: list[str],
119
+ ) -> list[tuple[np.datetime64, np.datetime64, str]]:
120
+ """Parse an STK interval list file.
121
+
122
+ Returns a list of ``(start, end, data_string)`` tuples.
123
+ """
124
+ if not lines:
125
+ raise STKParseError("empty file")
126
+
127
+ if not lines[0].strip().startswith("stk.v."):
128
+ raise STKParseError(
129
+ f"line 1: expected 'stk.v.' version header, got {lines[0]!r}"
130
+ )
131
+
132
+ # Find BEGIN Intervals
133
+ data_start = -1
134
+ for idx in range(1, len(lines)):
135
+ if lines[idx].strip() == "BEGIN Intervals":
136
+ data_start = idx + 1
137
+ break
138
+
139
+ if data_start < 0:
140
+ raise STKParseError("no 'BEGIN Intervals' found")
141
+
142
+ result: list[tuple[np.datetime64, np.datetime64, str]] = []
143
+ for idx in range(data_start, len(lines)):
144
+ stripped = lines[idx].strip()
145
+ if not stripped:
146
+ continue
147
+ if stripped.startswith("END"):
148
+ break
149
+
150
+ match_iter = _QUOTED_RE.finditer(stripped)
151
+ first = next(match_iter, None)
152
+ second = next(match_iter, None)
153
+ if first is None or second is None:
154
+ raise STKParseError(
155
+ f"line {idx + 1}: expected two quoted timestamps"
156
+ )
157
+ start = np.datetime64(first.group(1), "ms")
158
+ end = np.datetime64(second.group(1), "ms")
159
+ after = stripped[second.end() :].strip()
160
+ result.append((start, end, after))
161
+
162
+ return result
stk_files/_types.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Union
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+ DateTimeArray = NDArray[np.datetime64]
9
+
10
+ MessageLevel = Literal["Errors", "Warnings", "Verbose"]
11
+ TimeFormat = Literal["ISO-YMD", "EpSec"]
12
+ CentralBody = Literal["Earth", "Moon"]
13
+
14
+ CoordinateAxes = Literal[
15
+ "Fixed",
16
+ "J2000",
17
+ "ICRF",
18
+ "Inertial",
19
+ "TrueOfDate",
20
+ "MeanOfDate",
21
+ "TEMEOfDate",
22
+ "MeanOfEpoch",
23
+ "TrueOfEpoch",
24
+ "TEMEOfEpoch",
25
+ "AlignmentAtEpoch",
26
+ ]
27
+
28
+ InterpolationMethod = Literal["Lagrange", "Hermite"]
29
+
30
+ AttitudeFormat = Literal[
31
+ "Quaternions",
32
+ "QuatScalarFirst",
33
+ "EulerAngles",
34
+ "YPRAngles",
35
+ "DCM",
36
+ "ECFVector",
37
+ "ECIVector",
38
+ ]
39
+
40
+ SensorFormat = Literal[
41
+ "Quaternions",
42
+ "QuatScalarFirst",
43
+ "EulerAngles",
44
+ "YPRAngles",
45
+ "AzElAngles",
46
+ "DCM",
47
+ "ECFVector",
48
+ "ECIVector",
49
+ ]
50
+
51
+ EphemerisFormat = Literal[
52
+ "TimePos",
53
+ "TimePosVel",
54
+ "TimePosVelAcc",
55
+ "LLATimePos",
56
+ "LLATimePosVel",
57
+ ]
58
+
59
+ EulerSequence = Literal[121, 123, 131, 132, 212, 213, 231, 232, 312, 313, 321, 323]
60
+ YPRSequence = Literal[123, 132, 213, 231, 312, 321]
61
+ AzElSequence = Literal[323, 213]
62
+ RotationSequence = Union[EulerSequence, YPRSequence]
63
+
64
+ EPOCH_DEPENDENT_AXES: frozenset[str] = frozenset(
65
+ {
66
+ "MeanOfEpoch",
67
+ "TrueOfEpoch",
68
+ "TEMEOfEpoch",
69
+ "AlignmentAtEpoch",
70
+ }
71
+ )
72
+
73
+ QUATERNION_FORMATS: frozenset[str] = frozenset({"Quaternions", "QuatScalarFirst"})
74
+ ANGLE_FORMATS: frozenset[str] = frozenset({"EulerAngles", "YPRAngles", "AzElAngles"})
75
+
76
+ ATTITUDE_COLUMNS: dict[str, int] = {
77
+ "Quaternions": 4,
78
+ "QuatScalarFirst": 4,
79
+ "EulerAngles": 3,
80
+ "YPRAngles": 3,
81
+ "DCM": 9,
82
+ "ECFVector": 3,
83
+ "ECIVector": 3,
84
+ }
85
+
86
+ SENSOR_COLUMNS: dict[str, int] = {
87
+ **ATTITUDE_COLUMNS,
88
+ "AzElAngles": 2,
89
+ }
90
+
91
+ EPHEMERIS_COLUMNS: dict[str, int] = {
92
+ "TimePos": 3,
93
+ "TimePosVel": 6,
94
+ "TimePosVelAcc": 9,
95
+ "LLATimePos": 3,
96
+ "LLATimePosVel": 6,
97
+ }
98
+
99
+ EULER_SEQUENCES = (121, 123, 131, 132, 212, 213, 231, 232, 312, 313, 321, 323)
100
+ YPR_SEQUENCES = (123, 132, 213, 231, 312, 321)
101
+ AZEL_SEQUENCES = (323, 213)