spacecoords 0.1.0__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.
Potentially problematic release.
This version of spacecoords might be problematic. Click here for more details.
- spacecoords/__init__.py +44 -0
- spacecoords/celestial.py +226 -0
- spacecoords/cli.py +22 -0
- spacecoords/constants.py +51 -0
- spacecoords/download.py +59 -0
- spacecoords/frames.py +174 -0
- spacecoords/linalg.py +280 -0
- spacecoords/py.typed +0 -0
- spacecoords/spherical.py +185 -0
- spacecoords/spice.py +8 -0
- spacecoords/spk_basic.py +71 -0
- spacecoords/types.py +35 -0
- spacecoords/version.py +3 -0
- spacecoords-0.1.0.dist-info/METADATA +71 -0
- spacecoords-0.1.0.dist-info/RECORD +19 -0
- spacecoords-0.1.0.dist-info/WHEEL +5 -0
- spacecoords-0.1.0.dist-info/entry_points.txt +2 -0
- spacecoords-0.1.0.dist-info/licenses/LICENSE +21 -0
- spacecoords-0.1.0.dist-info/top_level.txt +1 -0
spacecoords/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""General purpose convenience functions for different coordinate systems and linear algebra
|
|
2
|
+
functions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
import importlib.util
|
|
7
|
+
from .version import __version__
|
|
8
|
+
|
|
9
|
+
from . import linalg
|
|
10
|
+
from . import spherical
|
|
11
|
+
from . import constants
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_missing_module(name: str, dep: str):
|
|
15
|
+
class _MissingModule(ModuleType):
|
|
16
|
+
def __getattr__(self, key):
|
|
17
|
+
raise ImportError(
|
|
18
|
+
f"The optional dependency `{dep}` for is missing.\n"
|
|
19
|
+
f"Install it with `pip install spacecoords[all]` or `pip install {dep}`."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return _MissingModule(name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Optional modules
|
|
26
|
+
if importlib.util.find_spec("astropy") is not None:
|
|
27
|
+
from . import celestial
|
|
28
|
+
else:
|
|
29
|
+
astropy = _make_missing_module("celestial", "astropy")
|
|
30
|
+
|
|
31
|
+
if importlib.util.find_spec("jplephem") is not None:
|
|
32
|
+
from . import spk_basic
|
|
33
|
+
else:
|
|
34
|
+
naif_ephemeris = _make_missing_module("spk_basic", "jplephem")
|
|
35
|
+
|
|
36
|
+
if importlib.util.find_spec("spice") is not None:
|
|
37
|
+
from . import spice
|
|
38
|
+
else:
|
|
39
|
+
naif_spice = _make_missing_module("spice", "spiceypy")
|
|
40
|
+
|
|
41
|
+
if importlib.util.find_spec("requests") is not None:
|
|
42
|
+
from . import download
|
|
43
|
+
else:
|
|
44
|
+
download = _make_missing_module("download", "requests")
|
spacecoords/celestial.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""Coordinate frame transformations and related functions.
|
|
4
|
+
Main usage is the `convert` function that wraps Astropy frame transformations.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Type, Any
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import numpy as np
|
|
9
|
+
from astropy.time import Time
|
|
10
|
+
import astropy.coordinates as coord
|
|
11
|
+
import astropy.units as units
|
|
12
|
+
import astropy.config as config
|
|
13
|
+
|
|
14
|
+
from .types import (
|
|
15
|
+
NDArray_N,
|
|
16
|
+
NDArray_3,
|
|
17
|
+
NDArray_6,
|
|
18
|
+
NDArray_3xN,
|
|
19
|
+
NDArray_6xN,
|
|
20
|
+
T,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
"""List of astropy frames
|
|
24
|
+
"""
|
|
25
|
+
ASTROPY_FRAMES = {
|
|
26
|
+
"TEME": "TEME",
|
|
27
|
+
"ITRS": "ITRS",
|
|
28
|
+
"ITRF": "ITRS",
|
|
29
|
+
"ICRS": "ICRS",
|
|
30
|
+
"ICRF": "ICRS",
|
|
31
|
+
"GCRS": "GCRS",
|
|
32
|
+
"GCRF": "GCRS",
|
|
33
|
+
"HCRS": "HCRS",
|
|
34
|
+
"HCRF": "HCRS",
|
|
35
|
+
"HeliocentricMeanEcliptic".upper(): "HeliocentricMeanEcliptic",
|
|
36
|
+
"GeocentricMeanEcliptic".upper(): "GeocentricMeanEcliptic",
|
|
37
|
+
"HeliocentricTrueEcliptic".upper(): "HeliocentricTrueEcliptic",
|
|
38
|
+
"GeocentricTrueEcliptic".upper(): "GeocentricTrueEcliptic",
|
|
39
|
+
"BarycentricMeanEcliptic".upper(): "BarycentricMeanEcliptic",
|
|
40
|
+
"BarycentricTrueEcliptic".upper(): "BarycentricTrueEcliptic",
|
|
41
|
+
"SPICEJ2000": "ICRS",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
"""List of frames that are not time-dependant
|
|
45
|
+
"""
|
|
46
|
+
ASTROPY_NOT_OBSTIME = [
|
|
47
|
+
"ICRS",
|
|
48
|
+
"BarycentricMeanEcliptic",
|
|
49
|
+
"BarycentricTrueEcliptic",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_solarsystem_body_state(
|
|
54
|
+
body: str,
|
|
55
|
+
time: Time,
|
|
56
|
+
kernel_dir: Path,
|
|
57
|
+
ephemeris: str = "jpl",
|
|
58
|
+
) -> NDArray_6xN | NDArray_6:
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
This is to not have to remember how to do this astropy config stuff
|
|
62
|
+
# https://docs.astropy.org/en/stable/api/astropy.coordinates.solar_system_ephemeris.html
|
|
63
|
+
"""
|
|
64
|
+
with config.set_temp_cache(path=str(kernel_dir), delete=False):
|
|
65
|
+
pos, vel = coord.get_body_barycentric_posvel(body, time, ephemeris=ephemeris)
|
|
66
|
+
|
|
67
|
+
size = len(time)
|
|
68
|
+
shape: tuple[int, ...] = (6, size) if size > 0 else (6,)
|
|
69
|
+
state = np.empty(shape, dtype=np.float64)
|
|
70
|
+
state[:3, ...] = pos.xyz.to(units.m).value
|
|
71
|
+
state[3:, ...] = vel.d_xyz.to(units.m / units.s).value
|
|
72
|
+
return state
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def not_geocentric(frame: str) -> bool:
|
|
76
|
+
"""Check if the given frame name is one of the non-geocentric frames."""
|
|
77
|
+
frame = frame.upper()
|
|
78
|
+
return frame in ["ICRS", "ICRF", "HCRS", "HCRF"] or frame.startswith("Heliocentric".upper())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_geocentric(frame: str) -> bool:
|
|
82
|
+
"""Check if the frame name is a supported geocentric frame"""
|
|
83
|
+
return not not_geocentric(frame)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def convert(
|
|
87
|
+
t: NDArray_N,
|
|
88
|
+
states: NDArray_6xN,
|
|
89
|
+
in_frame: str,
|
|
90
|
+
out_frame: str,
|
|
91
|
+
frame_kwargs: dict[str, Any],
|
|
92
|
+
) -> NDArray_6xN:
|
|
93
|
+
"""Perform predefined coordinate transformations using Astropy.
|
|
94
|
+
Always returns a copy of the array.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
t
|
|
99
|
+
Absolute time corresponding to the input states.
|
|
100
|
+
states
|
|
101
|
+
Size `(6,n)` matrix of states in SI units where rows 1-3
|
|
102
|
+
are position and 4-6 are velocity.
|
|
103
|
+
in_frame
|
|
104
|
+
Name of the frame the input states are currently in.
|
|
105
|
+
out_frame
|
|
106
|
+
Name of the state to transform to.
|
|
107
|
+
frame_kwargs
|
|
108
|
+
Any arguments needed for the specific transform detailed by `astropy`
|
|
109
|
+
in their documentation
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
Size `(6,n)` matrix of states in SI units where rows
|
|
114
|
+
1-3 are position and 4-6 are velocity.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
in_frame = in_frame.upper()
|
|
119
|
+
out_frame = out_frame.upper()
|
|
120
|
+
|
|
121
|
+
if in_frame == out_frame:
|
|
122
|
+
return states.copy()
|
|
123
|
+
|
|
124
|
+
if in_frame in ASTROPY_FRAMES:
|
|
125
|
+
in_frame_ = ASTROPY_FRAMES[in_frame]
|
|
126
|
+
in_frame_cls = getattr(coord, in_frame_)
|
|
127
|
+
else:
|
|
128
|
+
err_str = [
|
|
129
|
+
f"In frame '{in_frame}' not recognized, ",
|
|
130
|
+
"please check spelling or perform manual transformation",
|
|
131
|
+
]
|
|
132
|
+
raise ValueError("".join(err_str))
|
|
133
|
+
|
|
134
|
+
kw = {}
|
|
135
|
+
kw.update(frame_kwargs)
|
|
136
|
+
if in_frame_ not in ASTROPY_NOT_OBSTIME:
|
|
137
|
+
kw["obstime"] = t
|
|
138
|
+
|
|
139
|
+
astropy_states = _convert_to_astropy(states, in_frame_cls, kw)
|
|
140
|
+
|
|
141
|
+
if out_frame in ASTROPY_FRAMES:
|
|
142
|
+
out_frame_ = ASTROPY_FRAMES[out_frame]
|
|
143
|
+
out_frame_cls = getattr(coord, out_frame_)
|
|
144
|
+
else:
|
|
145
|
+
err_str = [
|
|
146
|
+
f"Out frame '{out_frame}' not recognized, ",
|
|
147
|
+
"please check spelling or perform manual transformation",
|
|
148
|
+
]
|
|
149
|
+
raise ValueError("".join(err_str))
|
|
150
|
+
|
|
151
|
+
kw = {}
|
|
152
|
+
kw.update(frame_kwargs)
|
|
153
|
+
if out_frame_ not in ASTROPY_NOT_OBSTIME:
|
|
154
|
+
kw["obstime"] = t
|
|
155
|
+
|
|
156
|
+
out_states = astropy_states.transform_to(out_frame_cls(**kw))
|
|
157
|
+
|
|
158
|
+
rets = states.copy()
|
|
159
|
+
rets[:3, ...] = out_states.cartesian.xyz.to(units.m).value
|
|
160
|
+
rets[3:, ...] = out_states.velocity.d_xyz.to(units.m / units.s).value
|
|
161
|
+
|
|
162
|
+
return rets
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _convert_to_astropy(
|
|
166
|
+
states: NDArray_6xN | NDArray_6,
|
|
167
|
+
frame: Type[T],
|
|
168
|
+
frame_kwargs: dict[str, Any],
|
|
169
|
+
) -> T:
|
|
170
|
+
state_p = coord.CartesianRepresentation(states[:3, ...] * units.m)
|
|
171
|
+
state_v = coord.CartesianDifferential(states[3:, ...] * units.m / units.s)
|
|
172
|
+
astropy_states = frame(state_p.with_differentials(state_v), **frame_kwargs) # type: ignore
|
|
173
|
+
return astropy_states
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def geodetic_to_ITRS(
|
|
177
|
+
lat: NDArray_N | float,
|
|
178
|
+
lon: NDArray_N | float,
|
|
179
|
+
alt: NDArray_N | float,
|
|
180
|
+
degrees: bool = True,
|
|
181
|
+
) -> NDArray_3xN | NDArray_3:
|
|
182
|
+
"""Use `astropy.coordinates.WGS84GeodeticRepresentation` to transform from WGS84 to ITRS."""
|
|
183
|
+
ang_unit = units.deg if degrees else units.rad
|
|
184
|
+
|
|
185
|
+
wgs_cord = coord.WGS84GeodeticRepresentation(
|
|
186
|
+
lon=lon * ang_unit,
|
|
187
|
+
lat=lat * ang_unit,
|
|
188
|
+
height=alt * units.m,
|
|
189
|
+
)
|
|
190
|
+
itrs_cord = coord.ITRS(wgs_cord)
|
|
191
|
+
|
|
192
|
+
if isinstance(lat, np.ndarray):
|
|
193
|
+
size = lat.size
|
|
194
|
+
else:
|
|
195
|
+
size = 0
|
|
196
|
+
|
|
197
|
+
shape: tuple[int, ...] = (6, size) if size > 0 else (6,)
|
|
198
|
+
state = np.empty(shape, dtype=np.float64)
|
|
199
|
+
state[:3, ...] = itrs_cord.cartesian.xyz.to(units.m).value
|
|
200
|
+
state[3:, ...] = itrs_cord.velocity.d_xyz.to(units.m / units.s).value
|
|
201
|
+
|
|
202
|
+
return state
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def ITRS_to_geodetic(
|
|
206
|
+
state: NDArray_3xN | NDArray_N,
|
|
207
|
+
degrees: bool = True,
|
|
208
|
+
):
|
|
209
|
+
"""Use `astropy.coordinates.WGS84GeodeticRepresentation` to transform from ITRS to WGS84."""
|
|
210
|
+
raise NotImplementedError()
|
|
211
|
+
# ang_unit = units.deg if degrees else units.rad
|
|
212
|
+
# astropy_states = _convert_to_astropy(state, coord.ITRS)
|
|
213
|
+
# wgs_cord = coord.WGS84GeodeticRepresentation(astropy_states)
|
|
214
|
+
#
|
|
215
|
+
# if isinstance(lat, np.ndarray):
|
|
216
|
+
# size = lat.size
|
|
217
|
+
# else:
|
|
218
|
+
# size = 0
|
|
219
|
+
#
|
|
220
|
+
# shape: tuple[int, ...] = (6, size) if size > 0 else (6,)
|
|
221
|
+
# state = np.empty(shape, dtype=np.float64)
|
|
222
|
+
# state[:3, ...] = itrs_cord.cartesian.xyz.to(units.m).value
|
|
223
|
+
# state[3:, ...] = itrs_cord.velocity.d_xyz.to(units.m / units.s).value
|
|
224
|
+
#
|
|
225
|
+
# return state
|
|
226
|
+
pass
|
spacecoords/cli.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
import spacecoords as sc
|
|
6
|
+
|
|
7
|
+
parser = argparse.ArgumentParser(description="Download files")
|
|
8
|
+
subparsers = parser.add_subparsers(help="Available download interfaces", dest="command")
|
|
9
|
+
|
|
10
|
+
naif_parser = subparsers.add_parser("naif_kernel", help="Download NAIF Kernels")
|
|
11
|
+
naif_parser.add_argument(
|
|
12
|
+
"kernel_type",
|
|
13
|
+
choices=list(sc.download.KERNEL_PATHS.keys()),
|
|
14
|
+
help="Type of kernel (determines location on server)",
|
|
15
|
+
)
|
|
16
|
+
naif_parser.add_argument("kernel_name", help="Kernel filename")
|
|
17
|
+
naif_parser.add_argument("output_file", help="Path to output file")
|
|
18
|
+
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
if args.command == "naif_kernel":
|
|
22
|
+
sc.download.naif_kernel_main(args)
|
spacecoords/constants.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""Constants useful for different coordinate systems"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
G: float = 6.67430e-11
|
|
7
|
+
"""Newtonian constant of gravitation (m^3 kg^-1 s^-2), CODATA Recommended Values of the Fundamental
|
|
8
|
+
Physical Constants 2022.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WGS84:
|
|
13
|
+
"""World Geodetic System 1984 constants."""
|
|
14
|
+
|
|
15
|
+
a: float = 6378.137 * 1e3
|
|
16
|
+
"""float: semi-major axis parameter in meters of the World Geodetic System 1984 (WGS84)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
b: float = 6356.7523142 * 1e3
|
|
20
|
+
"""float: semi-minor axis parameter in meters of the World Geodetic System 1984 (WGS84)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
esq: float = 6.69437999014 * 0.001
|
|
24
|
+
"""float: `esq` parameter in meters of the World Geodetic System 1984 (WGS84)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
e1sq: float = 6.73949674228 * 0.001
|
|
28
|
+
"""float: `e1sq` parameter in meters of the World Geodetic System 1984 (WGS84)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
f: float = 1 / 298.257223563
|
|
32
|
+
"""float: `f` parameter of the World Geodetic System 1984 (WGS84)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
R_earth: float = 6371.0088e3
|
|
37
|
+
"""float: Radius of the Earth using the International
|
|
38
|
+
Union of Geodesy and Geophysics (IUGG) definition
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WGS72:
|
|
43
|
+
"""World Geodetic System 1972 constants."""
|
|
44
|
+
|
|
45
|
+
MU_earth: float = 398600.8 * 1e9
|
|
46
|
+
"""float: Standard gravitational parameter of the Earth using the WGS72 convention.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
M_earth: float = MU_earth / G
|
|
50
|
+
"""float: Mass of the Earth using the WGS72 convention.
|
|
51
|
+
"""
|
spacecoords/download.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
NAIF_URL = "https://naif.jpl.nasa.gov/pub/naif/"
|
|
7
|
+
|
|
8
|
+
KERNEL_PATHS = {
|
|
9
|
+
"planetary": "generic_kernels/spk/planets/",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def naif_kernel(
|
|
14
|
+
kernel_path: str,
|
|
15
|
+
output_file: Path,
|
|
16
|
+
progress: bool = True,
|
|
17
|
+
chunk_size: int = 8192,
|
|
18
|
+
):
|
|
19
|
+
url = NAIF_URL + kernel_path
|
|
20
|
+
with requests.get(url, stream=True) as r:
|
|
21
|
+
r.raise_for_status()
|
|
22
|
+
total_length = r.headers.get("content-length")
|
|
23
|
+
downloaded = 0
|
|
24
|
+
if total_length is None:
|
|
25
|
+
with open(output_file, "wb") as fh:
|
|
26
|
+
for chunk in r.iter_content(chunk_size=chunk_size):
|
|
27
|
+
fh.write(chunk)
|
|
28
|
+
if progress:
|
|
29
|
+
downloaded += len(chunk)
|
|
30
|
+
sys.stdout.write(f"{downloaded / 1024:.1f} KB")
|
|
31
|
+
sys.stdout.flush()
|
|
32
|
+
if progress:
|
|
33
|
+
print()
|
|
34
|
+
else:
|
|
35
|
+
total_length = int(total_length)
|
|
36
|
+
prog_width = min(os.get_terminal_size()[0] - 10, 100)
|
|
37
|
+
with open(output_file, "wb") as fh:
|
|
38
|
+
for chunk in r.iter_content(chunk_size=chunk_size):
|
|
39
|
+
if not chunk:
|
|
40
|
+
continue
|
|
41
|
+
fh.write(chunk)
|
|
42
|
+
if progress:
|
|
43
|
+
downloaded += len(chunk)
|
|
44
|
+
done = int(prog_width * downloaded / total_length)
|
|
45
|
+
sys.stdout.write(
|
|
46
|
+
f"\r[{'=' * done}{' ' * (prog_width - done)}] "
|
|
47
|
+
f"{downloaded / 1024:.1f} KB / {total_length / 1024:.1f} KB"
|
|
48
|
+
)
|
|
49
|
+
sys.stdout.flush()
|
|
50
|
+
if progress:
|
|
51
|
+
print()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def naif_kernel_main(args):
|
|
55
|
+
naif_kernel(
|
|
56
|
+
kernel_path=KERNEL_PATHS[args.kernel_type] + args.kernel_name,
|
|
57
|
+
output_file=args.output_file,
|
|
58
|
+
progress=True,
|
|
59
|
+
)
|
spacecoords/frames.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""Transforms between common coordinate frames without any additional dependencies"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from .spherical import sph_to_cart
|
|
7
|
+
from .types import (
|
|
8
|
+
NDArray_N,
|
|
9
|
+
NDArray_3,
|
|
10
|
+
NDArray_3xN,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ned_to_ecef(
|
|
15
|
+
lat: float,
|
|
16
|
+
lon: float,
|
|
17
|
+
ned: NDArray_3xN | NDArray_3,
|
|
18
|
+
degrees: bool = False,
|
|
19
|
+
) -> NDArray_3xN | NDArray_3:
|
|
20
|
+
"""NED (north/east/down) using geocentric zenith to ECEF coordinate system
|
|
21
|
+
conversion, not including translation.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
lat
|
|
26
|
+
Latitude of the origin in geocentric spherical coordinates
|
|
27
|
+
lon
|
|
28
|
+
Longitude of the origin in geocentric spherical coordinates
|
|
29
|
+
ned
|
|
30
|
+
(3,n) input matrix of positions in the NED-convention.
|
|
31
|
+
degrees
|
|
32
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
(3,) or (3,n) array x,y and z coordinates in ECEF.
|
|
37
|
+
"""
|
|
38
|
+
enu = np.empty(ned.size, dtype=ned.dtype)
|
|
39
|
+
enu[0, ...] = ned[1, ...]
|
|
40
|
+
enu[1, ...] = ned[0, ...]
|
|
41
|
+
enu[2, ...] = -ned[2, ...]
|
|
42
|
+
return enu_to_ecef(lat, lon, enu, degrees=degrees)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def azel_to_ecef(
|
|
46
|
+
lat: float,
|
|
47
|
+
lon: float,
|
|
48
|
+
az: NDArray_N | float,
|
|
49
|
+
el: NDArray_N | float,
|
|
50
|
+
degrees: bool = False,
|
|
51
|
+
) -> NDArray_3xN | NDArray_3:
|
|
52
|
+
"""Radar pointing (az,el) using geocentric zenith to unit vector in
|
|
53
|
+
ECEF, not including translation.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
lat
|
|
58
|
+
Latitude of the origin in geocentric spherical coordinates
|
|
59
|
+
lon
|
|
60
|
+
Longitude of the origin in geocentric spherical coordinates
|
|
61
|
+
az
|
|
62
|
+
Azimuth of the pointing direction
|
|
63
|
+
el
|
|
64
|
+
Elevation of the pointing direction
|
|
65
|
+
degrees
|
|
66
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
(3,) or (3,n) array x,y and z coordinates in ECEF.
|
|
71
|
+
"""
|
|
72
|
+
shape: tuple[int, ...] = (3,)
|
|
73
|
+
|
|
74
|
+
if isinstance(az, np.ndarray):
|
|
75
|
+
if len(az.shape) == 0:
|
|
76
|
+
az = float(az)
|
|
77
|
+
elif len(az) > 1:
|
|
78
|
+
shape = (3, len(az))
|
|
79
|
+
az = az.flatten()
|
|
80
|
+
else:
|
|
81
|
+
az = az[0]
|
|
82
|
+
|
|
83
|
+
if isinstance(el, np.ndarray):
|
|
84
|
+
if len(el.shape) == 0:
|
|
85
|
+
el = float(el)
|
|
86
|
+
elif len(el) > 1:
|
|
87
|
+
shape = (3, len(el))
|
|
88
|
+
el = el.flatten()
|
|
89
|
+
else:
|
|
90
|
+
el = el[0]
|
|
91
|
+
|
|
92
|
+
sph = np.empty(shape, dtype=np.float64)
|
|
93
|
+
sph[0, ...] = az
|
|
94
|
+
sph[1, ...] = el
|
|
95
|
+
sph[2, ...] = 1.0
|
|
96
|
+
enu = sph_to_cart(sph, degrees=degrees)
|
|
97
|
+
return enu_to_ecef(lat, lon, enu, degrees=degrees)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def enu_to_ecef(
|
|
101
|
+
lat: float,
|
|
102
|
+
lon: float,
|
|
103
|
+
enu: NDArray_3 | NDArray_3xN,
|
|
104
|
+
degrees: bool = False,
|
|
105
|
+
) -> NDArray_3xN | NDArray_3:
|
|
106
|
+
"""Rotate ENU (east/north/up) using geocentric zenith to ECEF coordinate system,
|
|
107
|
+
not including translation.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
lat
|
|
112
|
+
Latitude of the origin in geocentric spherical coordinates
|
|
113
|
+
lon
|
|
114
|
+
Longitude of the origin in geocentric spherical coordinates
|
|
115
|
+
enu
|
|
116
|
+
(3,n) input matrix of positions in the ENU-convention.
|
|
117
|
+
degrees
|
|
118
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
(3,) or (3,n) array x,y and z coordinates in ECEF.
|
|
123
|
+
"""
|
|
124
|
+
if degrees:
|
|
125
|
+
lat, lon = np.radians(lat), np.radians(lon)
|
|
126
|
+
|
|
127
|
+
mx = np.array(
|
|
128
|
+
[
|
|
129
|
+
[-np.sin(lon), -np.sin(lat) * np.cos(lon), np.cos(lat) * np.cos(lon)],
|
|
130
|
+
[np.cos(lon), -np.sin(lat) * np.sin(lon), np.cos(lat) * np.sin(lon)],
|
|
131
|
+
[0, np.cos(lat), np.sin(lat)],
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
ecef = np.dot(mx, enu)
|
|
136
|
+
return ecef
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ecef_to_enu(
|
|
140
|
+
lat: float,
|
|
141
|
+
lon: float,
|
|
142
|
+
ecef: NDArray_3 | NDArray_3xN,
|
|
143
|
+
degrees: bool = False,
|
|
144
|
+
) -> NDArray_3xN | NDArray_3:
|
|
145
|
+
"""Rotate ECEF coordinate system to local ENU (east,north,up) using geocentric
|
|
146
|
+
zenith, not including translation.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
lat
|
|
151
|
+
Latitude of the origin in geocentric spherical coordinates
|
|
152
|
+
lon
|
|
153
|
+
Longitude of the origin in geocentric spherical coordinates
|
|
154
|
+
ecef
|
|
155
|
+
(3,) or (3,n) array x,y and z coordinates in ECEF.
|
|
156
|
+
degrees
|
|
157
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
(3,) or (3,n) array x, y and z coordinates in ENU.
|
|
162
|
+
"""
|
|
163
|
+
if degrees:
|
|
164
|
+
lat, lon = np.radians(lat), np.radians(lon)
|
|
165
|
+
|
|
166
|
+
mx = np.array(
|
|
167
|
+
[
|
|
168
|
+
[-np.sin(lon), -np.sin(lat) * np.cos(lon), np.cos(lat) * np.cos(lon)],
|
|
169
|
+
[np.cos(lon), -np.sin(lat) * np.sin(lon), np.cos(lat) * np.sin(lon)],
|
|
170
|
+
[0, np.cos(lat), np.sin(lat)],
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
enu = np.dot(np.linalg.inv(mx), ecef)
|
|
174
|
+
return enu
|
spacecoords/linalg.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""Useful utility functions related to linear algebra"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
from .types import (
|
|
8
|
+
NDArray_3,
|
|
9
|
+
NDArray_3xN,
|
|
10
|
+
NDArray_N,
|
|
11
|
+
NDArray_3x3,
|
|
12
|
+
NDArray_3x3xN,
|
|
13
|
+
NDArray_2x2,
|
|
14
|
+
NDArray_NxN,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def vector_angle(
|
|
19
|
+
a: NDArray_3 | NDArray_3xN, b: NDArray_3 | NDArray_3xN, degrees: bool = False
|
|
20
|
+
) -> NDArray_N | float:
|
|
21
|
+
"""Angle between two vectors.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
a
|
|
26
|
+
(3, N) or (3,) vector of Cartesian coordinates.
|
|
27
|
+
This argument is vectorized in the second array dimension.
|
|
28
|
+
b
|
|
29
|
+
(3, N) or (3,) vector of Cartesian coordinates.
|
|
30
|
+
This argument is vectorized in the second array dimension.
|
|
31
|
+
degrees
|
|
32
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
(N, ) or float vector of angles between input vectors.
|
|
37
|
+
|
|
38
|
+
Notes
|
|
39
|
+
-----
|
|
40
|
+
Definition
|
|
41
|
+
$$
|
|
42
|
+
\\theta = \\cos^{-1}\\frac{
|
|
43
|
+
\\langle\\mathbf{a},\\mathbf{b}\\rangle
|
|
44
|
+
}{
|
|
45
|
+
|\\mathbf{a}||\\mathbf{b}|
|
|
46
|
+
}
|
|
47
|
+
$$
|
|
48
|
+
where $\\langle\\mathbf{a},\\mathbf{b}\\rangle$ is the dot
|
|
49
|
+
product and $|\\mathbf{a}|$ denotes the norm.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
a_norm = np.linalg.norm(a, axis=0)
|
|
53
|
+
b_norm = np.linalg.norm(b, axis=0)
|
|
54
|
+
|
|
55
|
+
if len(a.shape) == 1:
|
|
56
|
+
proj = np.dot(a, b) / (a_norm * b_norm)
|
|
57
|
+
elif len(b.shape) == 1:
|
|
58
|
+
proj = np.dot(b, a) / (a_norm * b_norm)
|
|
59
|
+
else:
|
|
60
|
+
assert a.shape == b.shape, "Input shapes do not match"
|
|
61
|
+
proj = np.sum(a * b, axis=0) / (a_norm * b_norm)
|
|
62
|
+
|
|
63
|
+
if len(a.shape) == 1 and len(b.shape) == 1:
|
|
64
|
+
if proj > 1.0:
|
|
65
|
+
proj = 1.0
|
|
66
|
+
elif proj < -1.0:
|
|
67
|
+
proj = -1.0
|
|
68
|
+
else:
|
|
69
|
+
proj[proj > 1.0] = 1.0
|
|
70
|
+
proj[proj < -1.0] = -1.0
|
|
71
|
+
|
|
72
|
+
theta = np.arccos(proj)
|
|
73
|
+
if degrees:
|
|
74
|
+
theta = np.degrees(theta)
|
|
75
|
+
|
|
76
|
+
return theta
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def rot_mat_x(
|
|
80
|
+
theta: NDArray_N | float, dtype: npt.DTypeLike = np.float64, degrees: bool = False
|
|
81
|
+
) -> NDArray_3x3xN | NDArray_3x3:
|
|
82
|
+
"""Compute matrix for rotation of R3 vector through angle theta
|
|
83
|
+
around the X-axis. For frame rotation, use the transpose.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
theta
|
|
88
|
+
Angle to rotate.
|
|
89
|
+
dtype
|
|
90
|
+
Numpy datatype of the rotation matrix.
|
|
91
|
+
degrees
|
|
92
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
(3, 3) Rotation matrix, or (3, 3, n) tensor if theta is vector input.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
if degrees:
|
|
100
|
+
theta = np.radians(theta)
|
|
101
|
+
size: tuple[int, ...]
|
|
102
|
+
if isinstance(theta, np.ndarray) and theta.ndim > 0:
|
|
103
|
+
size = (3, 3, len(theta))
|
|
104
|
+
else:
|
|
105
|
+
size = (3, 3)
|
|
106
|
+
|
|
107
|
+
ca, sa = np.cos(theta), np.sin(theta)
|
|
108
|
+
rot = np.zeros(size, dtype=dtype)
|
|
109
|
+
rot[0, 0, ...] = 1
|
|
110
|
+
rot[1, 1, ...] = ca
|
|
111
|
+
rot[1, 2, ...] = -sa
|
|
112
|
+
rot[2, 1, ...] = sa
|
|
113
|
+
rot[2, 2, ...] = ca
|
|
114
|
+
return rot
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rot_mat_y(
|
|
118
|
+
theta: NDArray_N | float, dtype: npt.DTypeLike = np.float64, degrees: bool = False
|
|
119
|
+
) -> NDArray_3x3xN | NDArray_3x3:
|
|
120
|
+
"""Compute matrix for rotation of R3 vector through angle theta
|
|
121
|
+
around the Y-axis. For frame rotation, use the transpose.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
theta
|
|
126
|
+
Angle to rotate.
|
|
127
|
+
dtype
|
|
128
|
+
Numpy datatype of the rotation matrix.
|
|
129
|
+
degrees
|
|
130
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
(3, 3) Rotation matrix, or (3, 3, n) tensor if theta is vector input.
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
if degrees:
|
|
138
|
+
theta = np.radians(theta)
|
|
139
|
+
size: tuple[int, ...]
|
|
140
|
+
if isinstance(theta, np.ndarray) and theta.ndim > 0:
|
|
141
|
+
size = (3, 3, len(theta))
|
|
142
|
+
else:
|
|
143
|
+
size = (3, 3)
|
|
144
|
+
|
|
145
|
+
ca, sa = np.cos(theta), np.sin(theta)
|
|
146
|
+
rot = np.zeros(size, dtype=dtype)
|
|
147
|
+
rot[0, 0, ...] = ca
|
|
148
|
+
rot[0, 2, ...] = sa
|
|
149
|
+
rot[1, 1, ...] = 1
|
|
150
|
+
rot[2, 0, ...] = -sa
|
|
151
|
+
rot[2, 2, ...] = ca
|
|
152
|
+
return rot
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def rot_mat_z(
|
|
156
|
+
theta: NDArray_N | float, dtype: npt.DTypeLike = np.float64, degrees: bool = False
|
|
157
|
+
) -> NDArray_3x3xN | NDArray_3x3:
|
|
158
|
+
"""Compute matrix for rotation of R3 vector through angle theta
|
|
159
|
+
around the Z-axis. For frame rotation, use the transpose.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
theta
|
|
164
|
+
Angle to rotate.
|
|
165
|
+
dtype
|
|
166
|
+
Numpy datatype of the rotation matrix.
|
|
167
|
+
degrees
|
|
168
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
(3, 3) Rotation matrix, or (3, 3, n) tensor if theta is vector input.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
if degrees:
|
|
176
|
+
theta = np.radians(theta)
|
|
177
|
+
size: tuple[int, ...]
|
|
178
|
+
if isinstance(theta, np.ndarray) and theta.ndim > 0:
|
|
179
|
+
size = (3, 3, len(theta))
|
|
180
|
+
else:
|
|
181
|
+
size = (3, 3)
|
|
182
|
+
|
|
183
|
+
ca, sa = np.cos(theta), np.sin(theta)
|
|
184
|
+
rot = np.zeros(size, dtype=dtype)
|
|
185
|
+
rot[0, 0, ...] = ca
|
|
186
|
+
rot[0, 1, ...] = -sa
|
|
187
|
+
rot[1, 0, ...] = sa
|
|
188
|
+
rot[1, 1, ...] = ca
|
|
189
|
+
rot[2, 2, ...] = 1
|
|
190
|
+
return rot
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def rot_mat_2d(
|
|
194
|
+
theta: float,
|
|
195
|
+
dtype: npt.DTypeLike = np.float64,
|
|
196
|
+
degrees: bool = False,
|
|
197
|
+
) -> NDArray_2x2:
|
|
198
|
+
"""Matrix for rotation of R2 vector in the plane through angle theta
|
|
199
|
+
For frame rotation, use the transpose.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
theta : float
|
|
204
|
+
Angle to rotate.
|
|
205
|
+
dtype : numpy.dtype
|
|
206
|
+
Numpy datatype of the rotation matrix.
|
|
207
|
+
degrees : bool
|
|
208
|
+
If :code:`True`, use degrees. Else all angles are given in radians.
|
|
209
|
+
|
|
210
|
+
Returns
|
|
211
|
+
-------
|
|
212
|
+
numpy.ndarray
|
|
213
|
+
(2, 2) Rotation matrix.
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
if degrees:
|
|
217
|
+
theta = np.radians(theta)
|
|
218
|
+
|
|
219
|
+
ca, sa = np.cos(theta), np.sin(theta)
|
|
220
|
+
return np.array([[ca, -sa], [sa, ca]], dtype=dtype)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def scale_mat_2d(
|
|
224
|
+
x: float,
|
|
225
|
+
y: float,
|
|
226
|
+
dtype: npt.DTypeLike = np.float64,
|
|
227
|
+
) -> NDArray_2x2:
|
|
228
|
+
"""Matrix for 2d scaling.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
x
|
|
233
|
+
Scaling coefficient for first coordinate axis.
|
|
234
|
+
y
|
|
235
|
+
Scaling coefficient for second coordinate axis.
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
(2, 2) Scaling matrix.
|
|
240
|
+
"""
|
|
241
|
+
M_scale = np.zeros((2, 2), dtype=dtype)
|
|
242
|
+
M_scale[0, 0] = x
|
|
243
|
+
M_scale[1, 1] = y
|
|
244
|
+
return M_scale
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def vec_to_vec(vec_in: NDArray_N, vec_out: NDArray_N) -> NDArray_NxN:
|
|
248
|
+
"""Get the rotation matrix that rotates `vec_in` to `vec_out` along the
|
|
249
|
+
plane containing both. Uses quaternion calculations.
|
|
250
|
+
"""
|
|
251
|
+
N = len(vec_in)
|
|
252
|
+
if N != len(vec_out):
|
|
253
|
+
raise ValueError("Input and output vectors must be same dimensionality.")
|
|
254
|
+
assert N == 3, "Only implemented for 3d vectors"
|
|
255
|
+
|
|
256
|
+
a = vec_in / np.linalg.norm(vec_in)
|
|
257
|
+
b = vec_out / np.linalg.norm(vec_out)
|
|
258
|
+
|
|
259
|
+
adotb = np.dot(a, b)
|
|
260
|
+
axb = np.cross(a, b)
|
|
261
|
+
axb_norm = np.linalg.norm(axb)
|
|
262
|
+
|
|
263
|
+
# rotation in the plane frame of `vec_in` and `vec_out`
|
|
264
|
+
G = np.zeros((N, N), dtype=vec_in.dtype)
|
|
265
|
+
G[0, 0] = adotb
|
|
266
|
+
G[0, 1] = -axb_norm
|
|
267
|
+
G[1, 0] = axb_norm
|
|
268
|
+
G[1, 1] = adotb
|
|
269
|
+
G[2, 2] = 1
|
|
270
|
+
|
|
271
|
+
# inverse of change of basis from standard orthonormal to `vec_in` and `vec_out` plane
|
|
272
|
+
F = np.zeros((N, N), dtype=vec_in.dtype)
|
|
273
|
+
F[:, 0] = a
|
|
274
|
+
F[:, 1] = (b - adotb * a) / np.linalg.norm(b - adotb * a)
|
|
275
|
+
F[:, 2] = axb
|
|
276
|
+
|
|
277
|
+
# go to frame, rotation in plane, leave frame
|
|
278
|
+
R = F @ G @ np.linalg.inv(F)
|
|
279
|
+
|
|
280
|
+
return R
|
spacecoords/py.typed
ADDED
|
File without changes
|
spacecoords/spherical.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""Functions related to spherical coordinate systems"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
from .types import NDArray_3, NDArray_3xN, NDArray_N
|
|
8
|
+
|
|
9
|
+
from . import linalg
|
|
10
|
+
|
|
11
|
+
CLOSE_TO_POLE_LIMIT = 1e-9**2
|
|
12
|
+
CLOSE_TO_POLE_LIMIT_rad = np.arctan(1 / np.sqrt(CLOSE_TO_POLE_LIMIT))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def arctime_to_degrees(minutes: NDArray | float, seconds: NDArray | float) -> NDArray | float:
|
|
16
|
+
return (minutes + seconds / 60.0) / 60.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cart_to_sph(vec: NDArray_3 | NDArray_3xN, degrees: bool = False) -> NDArray_3 | NDArray_3xN:
|
|
20
|
+
"""Convert from Cartesian coordinates (east, north, up) to Spherical
|
|
21
|
+
coordinates (azimuth, elevation, range) in a angle east of north and
|
|
22
|
+
elevation fashion. Returns azimuth between [-pi, pi] and elevation between
|
|
23
|
+
[-pi/2, pi/2].
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
vec
|
|
28
|
+
(3, N) or (3,) vector of Cartesian coordinates (east, north, up).
|
|
29
|
+
This argument is vectorized in the second array dimension.
|
|
30
|
+
degrees
|
|
31
|
+
If `True`, use degrees. Else all angles are given in radians.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
(3, N) or (3, ) vector of Spherical coordinates
|
|
36
|
+
(azimuth, elevation, range).
|
|
37
|
+
|
|
38
|
+
Notes
|
|
39
|
+
-----
|
|
40
|
+
Azimuth close to pole convention
|
|
41
|
+
Uses a :code:`CLOSE_TO_POLE_LIMIT` constant when transforming determine
|
|
42
|
+
if the point is close to the pole and sets the azimuth by definition
|
|
43
|
+
to 0 "at" the poles for consistency.
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
r2_ = vec[0, ...] ** 2 + vec[1, ...] ** 2
|
|
48
|
+
|
|
49
|
+
sph = np.empty(vec.shape, dtype=vec.dtype)
|
|
50
|
+
|
|
51
|
+
if len(vec.shape) == 1:
|
|
52
|
+
if r2_ < CLOSE_TO_POLE_LIMIT:
|
|
53
|
+
sph[0] = 0.0
|
|
54
|
+
sph[1] = np.sign(vec[2]) * np.pi * 0.5
|
|
55
|
+
else:
|
|
56
|
+
sph[0] = np.arctan2(vec[0], vec[1])
|
|
57
|
+
sph[1] = np.arctan(vec[2] / np.sqrt(r2_))
|
|
58
|
+
else:
|
|
59
|
+
inds_ = r2_ < CLOSE_TO_POLE_LIMIT
|
|
60
|
+
not_inds_ = np.logical_not(inds_)
|
|
61
|
+
|
|
62
|
+
sph[0, inds_] = 0.0
|
|
63
|
+
sph[1, inds_] = np.sign(vec[2, inds_]) * np.pi * 0.5
|
|
64
|
+
sph[0, not_inds_] = np.arctan2(vec[0, not_inds_], vec[1, not_inds_])
|
|
65
|
+
sph[1, not_inds_] = np.arctan(vec[2, not_inds_] / np.sqrt(r2_[not_inds_]))
|
|
66
|
+
|
|
67
|
+
sph[2, ...] = np.sqrt(r2_ + vec[2, ...] ** 2)
|
|
68
|
+
if degrees:
|
|
69
|
+
sph[:2, ...] = np.degrees(sph[:2, ...])
|
|
70
|
+
|
|
71
|
+
return sph
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sph_to_cart(vec: NDArray_3 | NDArray_3xN, degrees: bool = False) -> NDArray_3 | NDArray_3xN:
|
|
75
|
+
"""Convert from spherical coordinates (azimuth, elevation, range) to
|
|
76
|
+
Cartesian (east, north, up) in a angle east of north and elevation fashion.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
vec
|
|
82
|
+
(3, N) or (3,) vector of Cartesian Spherical
|
|
83
|
+
(azimuth, elevation, range).
|
|
84
|
+
This argument is vectorized in the second array dimension.
|
|
85
|
+
degrees
|
|
86
|
+
If :code:`True`, use degrees. Else all angles are given in radians.
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
(3, N) or (3, ) vector of Cartesian coordinates (east, north, up).
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
_az = vec[0, ...]
|
|
95
|
+
_el = vec[1, ...]
|
|
96
|
+
if degrees:
|
|
97
|
+
_az, _el = np.radians(_az), np.radians(_el)
|
|
98
|
+
cart = np.empty(vec.shape, dtype=vec.dtype)
|
|
99
|
+
|
|
100
|
+
cart[0, ...] = vec[2, ...] * np.sin(_az) * np.cos(_el)
|
|
101
|
+
cart[1, ...] = vec[2, ...] * np.cos(_az) * np.cos(_el)
|
|
102
|
+
cart[2, ...] = vec[2, ...] * np.sin(_el)
|
|
103
|
+
|
|
104
|
+
return cart
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def az_el_to_sph(
|
|
108
|
+
azimuth: NDArray_N | float,
|
|
109
|
+
elevation: NDArray_N | float,
|
|
110
|
+
) -> NDArray_3xN | NDArray_3:
|
|
111
|
+
"""Convert input azimuth and elevation to spherical coordinates states,
|
|
112
|
+
i.e a `shape=(3,n)` numpy array.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
az_len = azimuth.size if isinstance(azimuth, np.ndarray) else None
|
|
116
|
+
el_len = elevation.size if isinstance(elevation, np.ndarray) else None
|
|
117
|
+
|
|
118
|
+
if el_len is not None and az_len is not None:
|
|
119
|
+
assert el_len == az_len, f"azimuth {az_len} and elevation {el_len} sizes must agree"
|
|
120
|
+
|
|
121
|
+
shape: tuple[int] | tuple[int, int]
|
|
122
|
+
if az_len is not None:
|
|
123
|
+
shape = (3, az_len)
|
|
124
|
+
elif el_len is not None:
|
|
125
|
+
shape = (3, el_len)
|
|
126
|
+
else:
|
|
127
|
+
shape = (3,)
|
|
128
|
+
|
|
129
|
+
sph = np.empty(shape, dtype=np.float64)
|
|
130
|
+
sph[0, ...] = azimuth
|
|
131
|
+
sph[1, ...] = elevation
|
|
132
|
+
sph[2, ...] = 1.0
|
|
133
|
+
|
|
134
|
+
return sph
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def az_el_point(
|
|
138
|
+
self, azimuth: NDArray_N | float, elevation: NDArray_N | float, degrees: bool = False
|
|
139
|
+
) -> NDArray_3xN | NDArray_3:
|
|
140
|
+
"""Point beam towards azimuth and elevation coordinate.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
azimuth : float
|
|
145
|
+
Azimuth east of north of pointing direction.
|
|
146
|
+
elevation : float
|
|
147
|
+
Elevation from horizon of pointing direction.
|
|
148
|
+
degrees : bool
|
|
149
|
+
If :code:`True` all input/output angles are in degrees,
|
|
150
|
+
else they are in radians. Defaults to instance
|
|
151
|
+
settings :code:`self.radians`.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
sph = az_el_to_sph(azimuth, elevation)
|
|
155
|
+
return sph_to_cart(sph, degrees=degrees)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def az_el_vs_cart_angle(
|
|
159
|
+
self,
|
|
160
|
+
azimuth: NDArray_N | float,
|
|
161
|
+
elevation: NDArray_N | float,
|
|
162
|
+
cart: NDArray_3xN | NDArray_3,
|
|
163
|
+
degrees: bool = False,
|
|
164
|
+
) -> NDArray_N | float:
|
|
165
|
+
"""Get angle between azimuth and elevation and pointing direction.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
azimuth : float or NDArray
|
|
170
|
+
Azimuth east of north of pointing direction.
|
|
171
|
+
elevation : float or NDArray
|
|
172
|
+
Elevation from horizon of pointing direction.
|
|
173
|
+
degrees : bool
|
|
174
|
+
If :code:`True` all input/output angles are in degrees,
|
|
175
|
+
else they are in radians.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
float or NDArray
|
|
180
|
+
Angle between pointing and given direction.
|
|
181
|
+
|
|
182
|
+
"""
|
|
183
|
+
sph = az_el_to_sph(azimuth, elevation)
|
|
184
|
+
k = sph_to_cart(sph, degrees=degrees)
|
|
185
|
+
return linalg.vector_angle(cart, k, degrees=degrees)
|
spacecoords/spice.py
ADDED
spacecoords/spk_basic.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
import numpy as np
|
|
3
|
+
from jplephem.spk import SPK
|
|
4
|
+
|
|
5
|
+
"""Mapping from body name to integer id's used by the kernels.
|
|
6
|
+
|
|
7
|
+
#todo: these can be expanded to get any body probably? and do more than astropy implementation does
|
|
8
|
+
"""
|
|
9
|
+
BODY_NAME_TO_KERNEL_SPEC = OrderedDict(
|
|
10
|
+
[
|
|
11
|
+
("sun", [(0, 10)]),
|
|
12
|
+
("mercury", [(0, 1), (1, 199)]),
|
|
13
|
+
("venus", [(0, 2), (2, 299)]),
|
|
14
|
+
("earth-moon-barycenter", [(0, 3)]),
|
|
15
|
+
("earth", [(0, 3), (3, 399)]),
|
|
16
|
+
("moon", [(0, 3), (3, 301)]),
|
|
17
|
+
("mars", [(0, 4)]),
|
|
18
|
+
("jupiter", [(0, 5)]),
|
|
19
|
+
("saturn", [(0, 6)]),
|
|
20
|
+
("uranus", [(0, 7)]),
|
|
21
|
+
("neptune", [(0, 8)]),
|
|
22
|
+
("pluto", [(0, 9)]),
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_solarsystem_body_states(bodies, epoch, kernel, units=None):
|
|
28
|
+
"""Open a kernel file and get the statates of the given bodies at epoch in ICRS.
|
|
29
|
+
|
|
30
|
+
Note: All outputs from kernel computations are in the Barycentric (ICRS) "eternal" frame.
|
|
31
|
+
"""
|
|
32
|
+
assert SPK is not None, "jplephem package needed to directly interact with kernels"
|
|
33
|
+
states = {}
|
|
34
|
+
|
|
35
|
+
kernel = SPK.open(kernel)
|
|
36
|
+
|
|
37
|
+
epoch_ = epoch.tdb # jplephem uses Barycentric Dynamical Time (TDB)
|
|
38
|
+
jd1, jd2 = epoch_.jd1, epoch_.jd2
|
|
39
|
+
|
|
40
|
+
for body in bodies:
|
|
41
|
+
body_ = body.lower().strip()
|
|
42
|
+
|
|
43
|
+
if body_ not in BODY_NAME_TO_KERNEL_SPEC:
|
|
44
|
+
raise ValueError(f'Body name "{body}" not recognized')
|
|
45
|
+
|
|
46
|
+
states[body] = np.zeros((6,), dtype=np.float64)
|
|
47
|
+
|
|
48
|
+
# if there are multiple steps to go from states to
|
|
49
|
+
# ICRS barycentric, iterate trough and combine
|
|
50
|
+
for pair in BODY_NAME_TO_KERNEL_SPEC[body_]:
|
|
51
|
+
spk = kernel[pair]
|
|
52
|
+
if spk.data_type == 3:
|
|
53
|
+
# Type 3 kernels contain both position and velocity.
|
|
54
|
+
posvel = spk.compute(jd1, jd2).flatten()
|
|
55
|
+
else:
|
|
56
|
+
pos_, vel_ = spk.compute_and_differentiate(jd1, jd2)
|
|
57
|
+
posvel = np.zeros((6,), dtype=np.float64)
|
|
58
|
+
posvel[:3] = pos_
|
|
59
|
+
posvel[3:] = vel_
|
|
60
|
+
|
|
61
|
+
states[body] += posvel
|
|
62
|
+
|
|
63
|
+
# units from kernels are usually in km and km/day
|
|
64
|
+
if units is None:
|
|
65
|
+
states[body] *= 1e3
|
|
66
|
+
states[body][3:] /= 86400.0
|
|
67
|
+
else:
|
|
68
|
+
states[body] *= units[0]
|
|
69
|
+
states[body][3:] /= units[1]
|
|
70
|
+
|
|
71
|
+
return states
|
spacecoords/types.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains convenient type information so that typing can be precise
|
|
3
|
+
but not too verbose in the code itself.
|
|
4
|
+
"""
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
NDArray_N = npt.NDArray
|
|
11
|
+
"(n,) shaped ndarray"
|
|
12
|
+
|
|
13
|
+
NDArray_3 = npt.NDArray
|
|
14
|
+
"(3,) shaped ndarray"
|
|
15
|
+
|
|
16
|
+
NDArray_6 = npt.NDArray
|
|
17
|
+
"(6,) shaped ndarray"
|
|
18
|
+
|
|
19
|
+
NDArray_3xN = npt.NDArray
|
|
20
|
+
"(3,n) shaped ndarray"
|
|
21
|
+
|
|
22
|
+
NDArray_6xN = npt.NDArray
|
|
23
|
+
"(6,n) shaped ndarray"
|
|
24
|
+
|
|
25
|
+
NDArray_NxN = npt.NDArray
|
|
26
|
+
"(n,n) shaped ndarray"
|
|
27
|
+
|
|
28
|
+
NDArray_3x3 = npt.NDArray
|
|
29
|
+
"(3,3) shaped ndarray"
|
|
30
|
+
|
|
31
|
+
NDArray_2x2 = npt.NDArray
|
|
32
|
+
"(2,2) shaped ndarray"
|
|
33
|
+
|
|
34
|
+
NDArray_3x3xN = npt.NDArray
|
|
35
|
+
"(3,3,n) shaped ndarray"
|
spacecoords/version.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spacecoords
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A collection of coordinate transforms for convenient use
|
|
5
|
+
Author-email: Daniel Kastinen <daniel.kastinen@irf.se>
|
|
6
|
+
Maintainer-email: Daniel Kastinen <daniel.kastinen@irf.se>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) [2025] [Daniel Kastinen]
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Project-URL: Documentation, https://danielk.developer.irf.se/spacecoords/
|
|
30
|
+
Project-URL: Repository, https://github.com/danielk333/spacecoords
|
|
31
|
+
Classifier: Intended Audience :: Science/Research
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Requires-Python: >=3.10
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
License-File: LICENSE
|
|
39
|
+
Requires-Dist: numpy>=2
|
|
40
|
+
Provides-Extra: all
|
|
41
|
+
Requires-Dist: astropy>=6; extra == "all"
|
|
42
|
+
Requires-Dist: spiceypy>=8.0.0; extra == "all"
|
|
43
|
+
Requires-Dist: jplephem>=2.23; extra == "all"
|
|
44
|
+
Requires-Dist: requests>=2.20; extra == "all"
|
|
45
|
+
Provides-Extra: develop
|
|
46
|
+
Requires-Dist: pytest; extra == "develop"
|
|
47
|
+
Requires-Dist: pytest-cov; extra == "develop"
|
|
48
|
+
Requires-Dist: flake8; extra == "develop"
|
|
49
|
+
Requires-Dist: wheel; extra == "develop"
|
|
50
|
+
Requires-Dist: build; extra == "develop"
|
|
51
|
+
Requires-Dist: twine; extra == "develop"
|
|
52
|
+
Requires-Dist: auditwheel; extra == "develop"
|
|
53
|
+
Requires-Dist: numpydoc; extra == "develop"
|
|
54
|
+
Requires-Dist: black; extra == "develop"
|
|
55
|
+
Requires-Dist: matplotlib; extra == "develop"
|
|
56
|
+
Requires-Dist: ipykernel; extra == "develop"
|
|
57
|
+
Requires-Dist: mkdocs-material; extra == "develop"
|
|
58
|
+
Requires-Dist: mkdocstrings[python]; extra == "develop"
|
|
59
|
+
Requires-Dist: mkdocs-jupyter; extra == "develop"
|
|
60
|
+
Requires-Dist: mkdocs-gen-files; extra == "develop"
|
|
61
|
+
Requires-Dist: mkdocs-literate-nav; extra == "develop"
|
|
62
|
+
Requires-Dist: mkdocs-section-index; extra == "develop"
|
|
63
|
+
Provides-Extra: tests
|
|
64
|
+
Requires-Dist: pytest; extra == "tests"
|
|
65
|
+
Requires-Dist: pytest-cov; extra == "tests"
|
|
66
|
+
Dynamic: license-file
|
|
67
|
+
|
|
68
|
+
# spacecoords
|
|
69
|
+
|
|
70
|
+
A collection of coordinate transforms for convenient use across our applications to avoid
|
|
71
|
+
duplication
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
spacecoords/__init__.py,sha256=Pnio_jaYCpNxzWRde23PFCnk-t_fvq5zavoWT-ObRN0,1230
|
|
2
|
+
spacecoords/celestial.py,sha256=_WEQXv4YGRpa6_9kyWVNMJjcx-pJQG5rvYF6IA25V-E,6657
|
|
3
|
+
spacecoords/cli.py,sha256=Esbbi3-u-xfvXSG5P8QqpT0odElwUreza_J1wmdS5yU,737
|
|
4
|
+
spacecoords/constants.py,sha256=3BTHyhUFMXNzFrzuE-2FaRkEVTFW9khQQdG2IOoyFC0,1375
|
|
5
|
+
spacecoords/download.py,sha256=h1e46ekYJC-TZeP4PpKoEyPSVPNWItWE1z-5PXWyauw,1931
|
|
6
|
+
spacecoords/frames.py,sha256=u5og846ZXVGXyyWPMrRSX3Y82-09dVPd3Jh9uVH1w7Q,4646
|
|
7
|
+
spacecoords/linalg.py,sha256=YT4F0y76_OLk-I0az89i6J3fL4PjIE4Q2I4wByd0Sb0,7189
|
|
8
|
+
spacecoords/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
spacecoords/spherical.py,sha256=x_6pFAyEI-XySui70B9RWJSdKhYponCGX-jFpgh_njk,5589
|
|
10
|
+
spacecoords/spice.py,sha256=CibzR8EopwAwLvtd0BVGkRPjrScILENlLQUU_YcpWMU,164
|
|
11
|
+
spacecoords/spk_basic.py,sha256=CA4cZe6Y8Mw3UfL8X2ZaVFmvxWPnrcWAF6dgn2BeCQk,2335
|
|
12
|
+
spacecoords/types.py,sha256=bDO9YJWNUb1M6IAIgNaNlEvJwJbV3T9Pc0QO_DWTOhQ,644
|
|
13
|
+
spacecoords/version.py,sha256=56qNmktYm-a9EiinIbbIJG-6XTFmNReaWyZTcLJ15SI,83
|
|
14
|
+
spacecoords-0.1.0.dist-info/licenses/LICENSE,sha256=HwPwsjPm8Dw0iFn-o4B6E-X4irUfOlYFHAI3jB2bTWM,1076
|
|
15
|
+
spacecoords-0.1.0.dist-info/METADATA,sha256=RvssUD5fWfrTwtE1pL0OEpohTY0pgp22b1fnDvggKaE,3257
|
|
16
|
+
spacecoords-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
spacecoords-0.1.0.dist-info/entry_points.txt,sha256=lDDxb87uHwAnvKkEVGvBxBbGOfTjYmWXp6BHBYpBjq4,53
|
|
18
|
+
spacecoords-0.1.0.dist-info/top_level.txt,sha256=jknXkDXmkZUa9yRuVhcgbl5Da8LVxQsLofpQiLO0n70,12
|
|
19
|
+
spacecoords-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2025] [Daniel Kastinen]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spacecoords
|