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 +77 -0
- stk_files/__main__.py +5 -0
- stk_files/_coerce.py +106 -0
- stk_files/_formatting.py +99 -0
- stk_files/_gaps.py +60 -0
- stk_files/_parser.py +162 -0
- stk_files/_types.py +101 -0
- stk_files/_validation.py +482 -0
- stk_files/_version.py +34 -0
- stk_files/_writer.py +192 -0
- stk_files/attitude.py +193 -0
- stk_files/cli.py +270 -0
- stk_files/ephemeris.py +192 -0
- stk_files/interval.py +75 -0
- stk_files/py.typed +0 -0
- stk_files/sensor.py +171 -0
- stk_files-0.0.2.dist-info/METADATA +207 -0
- stk_files-0.0.2.dist-info/RECORD +21 -0
- stk_files-0.0.2.dist-info/WHEEL +4 -0
- stk_files-0.0.2.dist-info/entry_points.txt +2 -0
- stk_files-0.0.2.dist-info/licenses/LICENSE +21 -0
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
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)
|
stk_files/_formatting.py
ADDED
|
@@ -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)
|