kelvinzero 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.
- kelvinzero/__init__.py +64 -0
- kelvinzero/__main__.py +102 -0
- kelvinzero/constants.py +25 -0
- kelvinzero/figures.py +393 -0
- kelvinzero/frames.py +62 -0
- kelvinzero/orbit.py +247 -0
- kelvinzero/reference.py +69 -0
- kelvinzero/regions.py +89 -0
- kelvinzero/sun.py +136 -0
- kelvinzero/viewer_dist/assets/index-Co4P4QfB.js +4443 -0
- kelvinzero/viewer_dist/assets/index-DXHzM_N1.css +1 -0
- kelvinzero/viewer_dist/index.html +14 -0
- kelvinzero/viewer_dist/textures/earth-blue-marble.webp +0 -0
- kelvinzero/viewer_dist/textures/earth-topology.webp +0 -0
- kelvinzero/viewer_dist/vite.svg +1 -0
- kelvinzero/viz.py +62 -0
- kelvinzero-0.1.0.dist-info/METADATA +131 -0
- kelvinzero-0.1.0.dist-info/RECORD +22 -0
- kelvinzero-0.1.0.dist-info/WHEEL +5 -0
- kelvinzero-0.1.0.dist-info/entry_points.txt +2 -0
- kelvinzero-0.1.0.dist-info/licenses/LICENSE +21 -0
- kelvinzero-0.1.0.dist-info/top_level.txt +1 -0
kelvinzero/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Kelvin Zero — eclipse-minimising sun-synchronous orbits in a mean-Sun rotating frame.
|
|
2
|
+
|
|
3
|
+
The model behind the paper and the visualizer at https://k0webapp.web.app. Everything is pure
|
|
4
|
+
NumPy; figures need matplotlib, the local 3-D viewer needs plotly (``pip install kelvinzero[viz]``).
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from kelvinzero import Orbit, yearly_eclipse_stats
|
|
9
|
+
print(yearly_eclipse_stats(Orbit(altitude_km=800, raan_offset_deg=270)))
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .constants import DEG, J2, MU, R_EARTH, TROPICAL_YEAR_DAYS
|
|
13
|
+
from .frames import eci_to_display, frame_angle, gmst, lat_lng_to_vec, rotate_z, vec_to_lat_lng
|
|
14
|
+
from .orbit import (
|
|
15
|
+
Orbit,
|
|
16
|
+
YearlyStats,
|
|
17
|
+
alpha_deg_for_altitude,
|
|
18
|
+
altitude_for_sso_inclination_deg,
|
|
19
|
+
argument_of_latitude,
|
|
20
|
+
best_node_offset,
|
|
21
|
+
beta_angle,
|
|
22
|
+
eclipse_fraction_per_orbit,
|
|
23
|
+
is_eclipsed,
|
|
24
|
+
ltan_hours,
|
|
25
|
+
no_eclipse_beta_deg,
|
|
26
|
+
orbit_normal_eci,
|
|
27
|
+
orbit_ring_eci,
|
|
28
|
+
orbital_period,
|
|
29
|
+
plane_normal,
|
|
30
|
+
plane_point,
|
|
31
|
+
raan_eci,
|
|
32
|
+
raan_mssi,
|
|
33
|
+
raan_precession_rate,
|
|
34
|
+
satellite_position_eci,
|
|
35
|
+
sso_inclination_deg,
|
|
36
|
+
yearly_eclipse_fraction_sampled,
|
|
37
|
+
yearly_eclipse_stats,
|
|
38
|
+
)
|
|
39
|
+
from .regions import (
|
|
40
|
+
REGION_ALWAYS_ECLIPSE,
|
|
41
|
+
REGION_NO_ECLIPSE,
|
|
42
|
+
REGION_SOME_ECLIPSE,
|
|
43
|
+
SunDotExtrema,
|
|
44
|
+
classify_regions,
|
|
45
|
+
compute_sun_dot_extrema,
|
|
46
|
+
region_area_fractions,
|
|
47
|
+
region_contours,
|
|
48
|
+
sun_dot_extrema_at,
|
|
49
|
+
)
|
|
50
|
+
from .sun import (
|
|
51
|
+
SunState,
|
|
52
|
+
analemma_mssi,
|
|
53
|
+
earth_heliocentric_ecliptic,
|
|
54
|
+
ecliptic_eci,
|
|
55
|
+
mean_sun_longitude,
|
|
56
|
+
sun_direction_eci,
|
|
57
|
+
sun_direction_mssi,
|
|
58
|
+
sun_state,
|
|
59
|
+
year_markers,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__version__ = "0.1.0"
|
|
63
|
+
|
|
64
|
+
__all__ = [name for name in dir() if not name.startswith("_")]
|
kelvinzero/__main__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
python -m kelvinzero stats --alt 800 --raan 270
|
|
4
|
+
python -m kelvinzero sweep --alt 800
|
|
5
|
+
python -m kelvinzero figures out/
|
|
6
|
+
python -m kelvinzero viz --alt 800 --raan 270 --view eclipse
|
|
7
|
+
python -m kelvinzero reference reference.json
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .orbit import Orbit, alpha_deg_for_altitude, best_node_offset, ltan_hours, no_eclipse_beta_deg, orbital_period, yearly_eclipse_stats
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_stats(a):
|
|
21
|
+
o = Orbit(a.alt, a.raan)
|
|
22
|
+
st = yearly_eclipse_stats(o, 366)
|
|
23
|
+
print(f"altitude {o.altitude_km:8.1f} km")
|
|
24
|
+
print(f"SSO inclination {o.inclination_deg:8.3f} deg")
|
|
25
|
+
print(f"period {orbital_period(o.altitude_km) / 60:8.2f} min")
|
|
26
|
+
print(f"node offset Ω {o.raan_offset_deg:8.2f} deg (LTAN {ltan_hours(o.raan_offset_deg):05.2f} h)")
|
|
27
|
+
print(f"eclipse-free |β| ≥ {float(no_eclipse_beta_deg(o.altitude_km)):8.2f} deg")
|
|
28
|
+
print(f"β over the year {st.beta_min_deg:8.2f} … {st.beta_max_deg:.2f} deg")
|
|
29
|
+
print(f"yearly eclipse {100 * st.eclipse_fraction:8.3f} % of time (sun exposure {100 * st.sun_exposure:.3f} %)")
|
|
30
|
+
print(f"days with eclipse {st.eclipse_days:8.1f} / 365")
|
|
31
|
+
print(f"region cone α {float(alpha_deg_for_altitude(o.altitude_km)):8.2f} deg")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_sweep(a):
|
|
35
|
+
print(f"altitude {a.alt:g} km: yearly eclipse vs node offset")
|
|
36
|
+
for O in np.arange(0, 360, a.step):
|
|
37
|
+
st = yearly_eclipse_stats(Orbit(a.alt, float(O)), 183)
|
|
38
|
+
bar = "#" * int(round(st.eclipse_fraction * 100))
|
|
39
|
+
print(f" Ω = {O:5.1f}° {100 * st.eclipse_fraction:6.2f} % {bar}")
|
|
40
|
+
O, st = best_node_offset(a.alt, a.step, 183)
|
|
41
|
+
print(f"best: Ω = {O:g}° ({100 * st.eclipse_fraction:.3f} % of the year, {st.eclipse_days:.0f} days with eclipse)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_figures(a):
|
|
45
|
+
from .figures import make_all
|
|
46
|
+
|
|
47
|
+
make_all(a.out, a.only)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_viz(a):
|
|
51
|
+
from .viz import serve
|
|
52
|
+
|
|
53
|
+
serve(port=a.port, open_browser=not a.no_browser, alt=a.alt, raan=a.raan, view=a.view, day=a.day, cam=a.cam, frame=a.frame)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_reference(a):
|
|
57
|
+
from .reference import write_reference
|
|
58
|
+
|
|
59
|
+
print("wrote", write_reference(a.out))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv=None):
|
|
63
|
+
# Windows consoles often default to a legacy code page; make Greek letters and ≥ printable everywhere.
|
|
64
|
+
for stream in (sys.stdout, sys.stderr):
|
|
65
|
+
if hasattr(stream, "reconfigure"):
|
|
66
|
+
try:
|
|
67
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
68
|
+
except (ValueError, OSError):
|
|
69
|
+
pass
|
|
70
|
+
p = argparse.ArgumentParser(prog="kelvinzero", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
71
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
72
|
+
s = sub.add_parser("stats", help="yearly eclipse statistics for one orbit")
|
|
73
|
+
s.add_argument("--alt", type=float, default=800.0, help="altitude (km)")
|
|
74
|
+
s.add_argument("--raan", type=float, default=270.0, help="node offset from the mean Sun (deg)")
|
|
75
|
+
s.set_defaults(fn=cmd_stats)
|
|
76
|
+
s = sub.add_parser("sweep", help="yearly eclipse versus node offset at one altitude")
|
|
77
|
+
s.add_argument("--alt", type=float, default=800.0)
|
|
78
|
+
s.add_argument("--step", type=float, default=10.0)
|
|
79
|
+
s.set_defaults(fn=cmd_sweep)
|
|
80
|
+
s = sub.add_parser("figures", help="regenerate the paper's figures (matplotlib)")
|
|
81
|
+
s.add_argument("out", nargs="?", default="figures")
|
|
82
|
+
s.add_argument("--only", nargs="*")
|
|
83
|
+
s.set_defaults(fn=cmd_figures)
|
|
84
|
+
s = sub.add_parser("viz", help="run the Kelvin Zero visualizer locally (the same one as k0webapp.web.app)")
|
|
85
|
+
s.add_argument("--alt", type=float, help="altitude (km)")
|
|
86
|
+
s.add_argument("--raan", type=float, help="node offset from the mean Sun (deg)")
|
|
87
|
+
s.add_argument("--view", choices=["overview", "eclipse", "frames", "helio", "sun"], help="preset view to open")
|
|
88
|
+
s.add_argument("--day", type=float, help="timeline day (0-365.24)")
|
|
89
|
+
s.add_argument("--cam", help="camera preset: sun, antisun, pole, side, follow, above")
|
|
90
|
+
s.add_argument("--frame", choices=["EMI", "MSSI"])
|
|
91
|
+
s.add_argument("--port", type=int, default=0, help="local port (default: a free one)")
|
|
92
|
+
s.add_argument("--no-browser", action="store_true", help="just serve; do not open a browser")
|
|
93
|
+
s.set_defaults(fn=cmd_viz)
|
|
94
|
+
s = sub.add_parser("reference", help="write reference values (JSON) for cross-checking other implementations")
|
|
95
|
+
s.add_argument("out", nargs="?", default="reference.json")
|
|
96
|
+
s.set_defaults(fn=cmd_reference)
|
|
97
|
+
a = p.parse_args(argv)
|
|
98
|
+
a.fn(a)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|
kelvinzero/constants.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Physical constants. These match the values used by the Kelvin Zero visualizer so the two
|
|
2
|
+
implementations can be compared number for number."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
DEG = math.pi / 180.0
|
|
7
|
+
TWO_PI = 2.0 * math.pi
|
|
8
|
+
|
|
9
|
+
MU = 398600.4418 # km^3/s^2, Earth gravitational parameter
|
|
10
|
+
R_EARTH = 6378.137 # km, WGS84 equatorial radius
|
|
11
|
+
J2 = 1.08262668e-3
|
|
12
|
+
SECONDS_PER_DAY = 86400.0
|
|
13
|
+
TROPICAL_YEAR_DAYS = 365.2422
|
|
14
|
+
|
|
15
|
+
#: Mean-sun rate: the uniform eastward motion of the fictitious mean Sun along the equator
|
|
16
|
+
#: (USNO low-precision ephemeris), degrees per day. An exactly sun-synchronous orbit's node
|
|
17
|
+
#: precesses at this rate.
|
|
18
|
+
MEAN_SUN_RATE_DEG_DAY = 0.9856474
|
|
19
|
+
MEAN_SUN_RATE_RAD_S = MEAN_SUN_RATE_DEG_DAY * DEG / SECONDS_PER_DAY
|
|
20
|
+
|
|
21
|
+
#: Rate used when solving the sun-synchronous inclination (rad/s).
|
|
22
|
+
OMEGA_SSO_REQ = TWO_PI / (TROPICAL_YEAR_DAYS * SECONDS_PER_DAY)
|
|
23
|
+
|
|
24
|
+
ALT_MIN_KM = 100.0
|
|
25
|
+
ALT_MAX_KM = 2000.0
|
kelvinzero/figures.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""The figures of the paper, regenerated from this package (matplotlib).
|
|
2
|
+
|
|
3
|
+
python -m kelvinzero figures out/
|
|
4
|
+
|
|
5
|
+
Every plot is drawn from the model in this package only; nothing is cached or hard-coded."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .constants import TROPICAL_YEAR_DAYS
|
|
14
|
+
from .orbit import (
|
|
15
|
+
Orbit,
|
|
16
|
+
alpha_deg_for_altitude,
|
|
17
|
+
best_node_offset,
|
|
18
|
+
beta_angle,
|
|
19
|
+
eclipse_fraction_per_orbit,
|
|
20
|
+
no_eclipse_beta_deg,
|
|
21
|
+
orbital_period,
|
|
22
|
+
sso_inclination_deg,
|
|
23
|
+
yearly_eclipse_stats,
|
|
24
|
+
)
|
|
25
|
+
from .regions import classify_regions, compute_sun_dot_extrema, region_contours
|
|
26
|
+
from .sun import analemma_mssi, sun_state
|
|
27
|
+
|
|
28
|
+
C_SKY, C_EMERALD, C_AMBER, C_VIOLET, C_ROSE, C_INK = "#0369a1", "#047857", "#b45309", "#6d28d9", "#be123c", "#0f172a"
|
|
29
|
+
MONTHS = "JFMAMJJASOND"
|
|
30
|
+
MONTH_D = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _style():
|
|
34
|
+
import matplotlib
|
|
35
|
+
|
|
36
|
+
matplotlib.use("Agg")
|
|
37
|
+
import logging
|
|
38
|
+
|
|
39
|
+
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR) # missing-font fallbacks are fine
|
|
40
|
+
import matplotlib.pyplot as plt
|
|
41
|
+
|
|
42
|
+
plt.rcParams.update(
|
|
43
|
+
{
|
|
44
|
+
"font.family": ["Helvetica Neue", "Helvetica", "Arial", "DejaVu Sans"],
|
|
45
|
+
"font.size": 9,
|
|
46
|
+
"axes.titlesize": 10,
|
|
47
|
+
"axes.labelsize": 9,
|
|
48
|
+
"axes.edgecolor": "#334155",
|
|
49
|
+
"axes.linewidth": 0.8,
|
|
50
|
+
"axes.spines.top": False,
|
|
51
|
+
"axes.spines.right": False,
|
|
52
|
+
"xtick.color": "#334155",
|
|
53
|
+
"ytick.color": "#334155",
|
|
54
|
+
"grid.color": "#cbd5e1",
|
|
55
|
+
"grid.linewidth": 0.5,
|
|
56
|
+
"legend.frameon": False,
|
|
57
|
+
"legend.fontsize": 8,
|
|
58
|
+
"figure.dpi": 150,
|
|
59
|
+
"savefig.dpi": 300,
|
|
60
|
+
"savefig.bbox": "tight",
|
|
61
|
+
"savefig.pad_inches": 0.05,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return plt
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fig_analemma(out):
|
|
68
|
+
plt = _style()
|
|
69
|
+
d, lng, lat, _ = analemma_mssi(730)
|
|
70
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7.0, 3.1), gridspec_kw={"width_ratios": [1, 1.5]})
|
|
71
|
+
ax1.plot(lng, lat, color=C_SKY, lw=1.6)
|
|
72
|
+
for k, md in enumerate(MONTH_D):
|
|
73
|
+
i = int(np.argmin(np.abs(d - md)))
|
|
74
|
+
ax1.plot(lng[i], lat[i], "o", ms=3, color=C_AMBER, zorder=3)
|
|
75
|
+
ax1.annotate(MONTHS[k], (lng[i], lat[i]), textcoords="offset points", xytext=(5, 3), fontsize=7, color="#475569")
|
|
76
|
+
ax1.axhline(0, color="#94a3b8", lw=0.6)
|
|
77
|
+
ax1.axvline(0, color="#94a3b8", lw=0.6)
|
|
78
|
+
ax1.set_xlabel("longitude in MSSI, α − L = −EoT (°) → east")
|
|
79
|
+
ax1.set_ylabel("declination δ (°)")
|
|
80
|
+
ax1.set_xlim(-5.5, 5.5)
|
|
81
|
+
ax1.set_ylim(-26, 26)
|
|
82
|
+
ax1.set_aspect(0.2)
|
|
83
|
+
ax1.grid(True)
|
|
84
|
+
ax1.set_title("(a) Analemma, mean-Sun frame")
|
|
85
|
+
dd = np.arange(0, 367)
|
|
86
|
+
s = sun_state(dd)
|
|
87
|
+
ax2.plot(dd, np.degrees(s.eot) * 4, color=C_SKY, lw=1.4, label="equation of time (min)")
|
|
88
|
+
ax2b = ax2.twinx()
|
|
89
|
+
ax2b.plot(dd, np.degrees(s.dec), color=C_AMBER, lw=1.4, ls="--", label="declination (°)")
|
|
90
|
+
ax2b.spines["right"].set_visible(True)
|
|
91
|
+
ax2.set_xlim(0, 365)
|
|
92
|
+
ax2.set_xticks(MONTH_D)
|
|
93
|
+
ax2.set_xticklabels(list(MONTHS))
|
|
94
|
+
ax2.set_ylabel("EoT = L − α (min)", color=C_SKY)
|
|
95
|
+
ax2b.set_ylabel("δ (°)", color=C_AMBER)
|
|
96
|
+
ax2.axhline(0, color="#94a3b8", lw=0.6)
|
|
97
|
+
ax2.grid(True)
|
|
98
|
+
ax2.set_title("(b) Equation of time and declination")
|
|
99
|
+
h1, l1 = ax2.get_legend_handles_labels()
|
|
100
|
+
h2, l2 = ax2b.get_legend_handles_labels()
|
|
101
|
+
ax2.legend(h1 + h2, l1 + l2, loc="lower left")
|
|
102
|
+
fig.savefig(os.path.join(out, "fig-analemma.png"))
|
|
103
|
+
plt.close(fig)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fig_sso(out):
|
|
107
|
+
plt = _style()
|
|
108
|
+
h = np.arange(100, 2001, 10)
|
|
109
|
+
fig, axs = plt.subplots(1, 3, figsize=(7.4, 2.4))
|
|
110
|
+
fig.subplots_adjust(wspace=0.5)
|
|
111
|
+
axs[0].plot(h, sso_inclination_deg(h), color=C_SKY, lw=1.6)
|
|
112
|
+
axs[0].set_ylabel("i_SSO (°)")
|
|
113
|
+
axs[0].set_title("(a) i_SSO(h)")
|
|
114
|
+
axs[1].plot(h, no_eclipse_beta_deg(h), color=C_ROSE, lw=1.6)
|
|
115
|
+
axs[1].set_ylabel("|β| threshold (°)")
|
|
116
|
+
axs[1].set_title("(b) eclipse-free |β| = asin R/(R+h)", fontsize=9)
|
|
117
|
+
axs[2].plot(h, orbital_period(h) / 60, color=C_EMERALD, lw=1.6)
|
|
118
|
+
axs[2].set_ylabel("period (min)")
|
|
119
|
+
axs[2].set_title("(c) period")
|
|
120
|
+
for ax in axs:
|
|
121
|
+
ax.set_xlabel("altitude h (km)")
|
|
122
|
+
ax.grid(True)
|
|
123
|
+
ax.set_xlim(100, 2000)
|
|
124
|
+
fig.savefig(os.path.join(out, "fig-sso.png"))
|
|
125
|
+
plt.close(fig)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fig_frac_beta(out):
|
|
129
|
+
plt = _style()
|
|
130
|
+
fig, ax = plt.subplots(figsize=(4.6, 2.8))
|
|
131
|
+
b = np.linspace(0, 90, 181)
|
|
132
|
+
for k, h in enumerate([400, 550, 800, 1000, 1500]):
|
|
133
|
+
ax.plot(b, 100 * eclipse_fraction_per_orbit(np.radians(b), h), color=[C_INK, C_SKY, C_EMERALD, C_AMBER, C_VIOLET][k], lw=1.5, label=f"{h} km")
|
|
134
|
+
ax.set_xlabel("β angle (°)")
|
|
135
|
+
ax.set_ylabel("fraction of orbit in shadow (%)")
|
|
136
|
+
ax.set_xlim(0, 90)
|
|
137
|
+
ax.set_ylim(0, 42)
|
|
138
|
+
ax.grid(True)
|
|
139
|
+
ax.legend(title="altitude")
|
|
140
|
+
ax.set_title("Per-orbit eclipse fraction f_E(β), cylindrical shadow")
|
|
141
|
+
fig.savefig(os.path.join(out, "fig-frac-beta.png"))
|
|
142
|
+
plt.close(fig)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fig_raan(out):
|
|
146
|
+
plt = _style()
|
|
147
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7.0, 3.0))
|
|
148
|
+
cols = [C_INK, C_SKY, C_EMERALD, C_AMBER, C_VIOLET]
|
|
149
|
+
O = np.arange(0, 361, 2)
|
|
150
|
+
for k, h in enumerate([550, 800, 1000, 1200, 1500]):
|
|
151
|
+
f = np.array([yearly_eclipse_stats(Orbit(h, o), 183).eclipse_fraction for o in O])
|
|
152
|
+
ax1.plot(O, 100 * f, color=cols[k], lw=1.5, label=f"{h} km")
|
|
153
|
+
j = int(np.argmin(f))
|
|
154
|
+
ax1.plot(O[j], 100 * f[j], "o", ms=3.5, color=cols[k])
|
|
155
|
+
ax1.set_xlim(0, 360)
|
|
156
|
+
ax1.set_xticks([0, 90, 180, 270, 360])
|
|
157
|
+
ax1.set_xlabel("RAAN offset from mean Sun, Ω (°) [LTAN = 12:00 + Ω/15°]")
|
|
158
|
+
ax1.set_ylabel("mean yearly eclipse (% of time)")
|
|
159
|
+
ax1.grid(True)
|
|
160
|
+
ax1.legend(title="altitude", ncol=5, loc="upper center", bbox_to_anchor=(0.5, -0.32), fontsize=7, title_fontsize=7)
|
|
161
|
+
ax1.set_title("(a) Yearly eclipse vs. plane orientation")
|
|
162
|
+
dd = np.arange(0, 366)
|
|
163
|
+
for k, (name, h, o) in enumerate([("Noon–midnight, 550 km (Ω = 0°)", 550, 0), ("Dawn–dusk, 800 km (Ω = 270°)", 800, 270), ("Dawn–dusk, 1500 km (Ω = 270°)", 1500, 270)]):
|
|
164
|
+
col = [C_INK, C_SKY, C_VIOLET][k]
|
|
165
|
+
ax2.plot(dd, np.degrees(beta_angle(Orbit(h, o), dd)), color=col, lw=1.4, label=name)
|
|
166
|
+
for sgn in (1, -1):
|
|
167
|
+
ax2.axhline(sgn * no_eclipse_beta_deg(h), color=col, lw=0.7, ls=":")
|
|
168
|
+
ax2.set_xlim(0, 365)
|
|
169
|
+
ax2.set_xticks(MONTH_D)
|
|
170
|
+
ax2.set_xticklabels(list(MONTHS))
|
|
171
|
+
ax2.set_ylim(-95, 95)
|
|
172
|
+
ax2.set_ylabel("β (°)")
|
|
173
|
+
ax2.grid(True)
|
|
174
|
+
ax2.legend(loc="center", bbox_to_anchor=(0.5, 0.62), fontsize=7)
|
|
175
|
+
ax2.set_title("(b) β over the year; dotted = eclipse-free threshold")
|
|
176
|
+
fig.savefig(os.path.join(out, "fig-raan.png"))
|
|
177
|
+
plt.close(fig)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fig_optimum(out):
|
|
181
|
+
plt = _style()
|
|
182
|
+
h = np.arange(400, 2001, 50)
|
|
183
|
+
best = [best_node_offset(x, 2.0, 183) for x in h]
|
|
184
|
+
days = np.array([b[1].eclipse_days for b in best])
|
|
185
|
+
f = np.array([b[1].eclipse_fraction for b in best])
|
|
186
|
+
fig, ax = plt.subplots(figsize=(4.6, 2.6))
|
|
187
|
+
ax.plot(h, days, color=C_ROSE, lw=1.6, label="days with any eclipse")
|
|
188
|
+
ax.set_xlabel("altitude h (km)")
|
|
189
|
+
ax.set_ylabel("days per year with eclipse")
|
|
190
|
+
axb = ax.twinx()
|
|
191
|
+
axb.plot(h, 100 * f, color=C_SKY, lw=1.4, ls="--", label="mean yearly eclipse (%)")
|
|
192
|
+
axb.set_ylabel("mean yearly eclipse (%)", color=C_SKY)
|
|
193
|
+
axb.spines["right"].set_visible(True)
|
|
194
|
+
ax.grid(True)
|
|
195
|
+
ax.set_xlim(400, 2000)
|
|
196
|
+
h1, l1 = ax.get_legend_handles_labels()
|
|
197
|
+
h2, l2 = axb.get_legend_handles_labels()
|
|
198
|
+
ax.legend(h1 + h2, l1 + l2, loc="upper right")
|
|
199
|
+
ax.set_title("Best fixed SSO plane vs. altitude")
|
|
200
|
+
fig.savefig(os.path.join(out, "fig-optimum.png"))
|
|
201
|
+
plt.close(fig)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def fig_regions(out, altitudes=(550, 1000)):
|
|
205
|
+
plt = _style()
|
|
206
|
+
from matplotlib.colors import ListedColormap
|
|
207
|
+
from matplotlib.patches import Patch
|
|
208
|
+
|
|
209
|
+
ext = compute_sun_dot_extrema(361, 181, 366)
|
|
210
|
+
_, alng, alat, _ = analemma_mssi(400)
|
|
211
|
+
fig, axs = plt.subplots(len(altitudes), 1, figsize=(7.0, 2.6 * len(altitudes)))
|
|
212
|
+
cmap = ListedColormap(["#c4b5fd", "#fdba74", "#a7f3d0"])
|
|
213
|
+
for ax, h in zip(np.atleast_1d(axs), altitudes):
|
|
214
|
+
alpha = float(alpha_deg_for_altitude(h))
|
|
215
|
+
ax.imshow(classify_regions(ext, alpha), origin="lower", extent=[-180, 180, -90, 90], cmap=cmap, vmin=0, vmax=2, aspect="auto", interpolation="nearest")
|
|
216
|
+
cont = region_contours(ext, alpha)
|
|
217
|
+
for pl in cont["dU"]:
|
|
218
|
+
ax.plot(pl[:, 0], pl[:, 1], color=C_VIOLET, lw=1.2)
|
|
219
|
+
for pl in cont["dI"]:
|
|
220
|
+
ax.plot(pl[:, 0], pl[:, 1], color="#c2410c", lw=1.2)
|
|
221
|
+
ax.plot(alng, alat, color=C_SKY, lw=1.0)
|
|
222
|
+
ax.set_xticks(range(-180, 181, 60))
|
|
223
|
+
ax.set_yticks(range(-90, 91, 30))
|
|
224
|
+
ax.set_xlabel("longitude in MSSI (°) — 0° = mean Sun, ±180° = anti-Sun")
|
|
225
|
+
ax.set_ylabel("latitude (°)")
|
|
226
|
+
ax.set_title(f"h = {h} km, α = {alpha:.1f}°")
|
|
227
|
+
ax.grid(True, alpha=0.5)
|
|
228
|
+
np.atleast_1d(axs)[0].legend(
|
|
229
|
+
handles=[Patch(color="#c4b5fd", label="always eclipsed"), Patch(color="#fdba74", label="sometimes"), Patch(color="#a7f3d0", label="never"), plt.Line2D([], [], color=C_VIOLET, label="∂U"), plt.Line2D([], [], color="#c2410c", label="∂I"), plt.Line2D([], [], color=C_SKY, label="analemma")],
|
|
230
|
+
loc="upper center", bbox_to_anchor=(0.5, -0.32), ncol=6, fontsize=7,
|
|
231
|
+
)
|
|
232
|
+
fig.subplots_adjust(hspace=0.75)
|
|
233
|
+
fig.savefig(os.path.join(out, "fig-regions.png"))
|
|
234
|
+
plt.close(fig)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def fig_heatmap(out):
|
|
238
|
+
plt = _style()
|
|
239
|
+
A = np.arange(400, 2001, 50)
|
|
240
|
+
O = np.arange(0, 361, 4)
|
|
241
|
+
F = np.array([[100 * yearly_eclipse_stats(Orbit(h, o), 92).eclipse_fraction for o in O] for h in A])
|
|
242
|
+
fig, ax = plt.subplots(figsize=(7.0, 3.4))
|
|
243
|
+
pc = ax.pcolormesh(O, A, F, cmap="magma_r", vmin=0, vmax=40, shading="nearest")
|
|
244
|
+
cs = ax.contour(O, A, F, levels=[0.5, 1, 2, 5, 10, 20, 30], colors="white", linewidths=0.6, alpha=0.9)
|
|
245
|
+
ax.clabel(cs, fmt="%g%%", fontsize=7, colors="white")
|
|
246
|
+
# exact eclipse-free boundary: min_d |beta(d)| = asin(R/(R+h)), on a fine grid so the line is smooth
|
|
247
|
+
A2 = np.arange(400, 2001, 10)
|
|
248
|
+
O2 = np.arange(0, 361, 1)
|
|
249
|
+
dd = np.arange(0, 366)
|
|
250
|
+
M = np.array([[np.min(np.abs(np.degrees(beta_angle(Orbit(h, o), dd)))) - float(no_eclipse_beta_deg(h)) for o in O2] for h in A2])
|
|
251
|
+
ax.contour(O2, A2, M, levels=[0.0], colors=[C_EMERALD], linewidths=1.8)
|
|
252
|
+
ax.set_xlabel("RAAN offset from mean Sun, Ω (°) [LTAN = 12:00 + Ω/15°]")
|
|
253
|
+
ax.set_ylabel("altitude h (km)")
|
|
254
|
+
ax.set_xticks([0, 90, 180, 270, 360])
|
|
255
|
+
cb = fig.colorbar(pc, ax=ax, pad=0.02)
|
|
256
|
+
cb.set_label("mean yearly eclipse (% of time)")
|
|
257
|
+
ax.set_title("Yearly eclipse for every fixed sun-synchronous plane; green = eclipse-free")
|
|
258
|
+
fig.savefig(os.path.join(out, "fig-heatmap.png"))
|
|
259
|
+
plt.close(fig)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _lobe_widths(h, centre, thresholds):
|
|
263
|
+
"""Width (degrees of Ω) of the lobe around `centre` in which the yearly eclipse stays below each
|
|
264
|
+
threshold, by golden-section search for the minimum and bisection for the edges (continuous in Ω)."""
|
|
265
|
+
f = lambda O: yearly_eclipse_stats(Orbit(h, O), 92).eclipse_fraction # noqa: E731
|
|
266
|
+
a, b = centre - 40.0, centre + 40.0
|
|
267
|
+
g = (5 ** 0.5 - 1) / 2
|
|
268
|
+
c, d = b - g * (b - a), a + g * (b - a)
|
|
269
|
+
fc, fd = f(c), f(d)
|
|
270
|
+
for _ in range(30):
|
|
271
|
+
if fc < fd:
|
|
272
|
+
b, d, fd = d, c, fc
|
|
273
|
+
c = b - g * (b - a)
|
|
274
|
+
fc = f(c)
|
|
275
|
+
else:
|
|
276
|
+
a, c, fc = c, d, fd
|
|
277
|
+
d = a + g * (b - a)
|
|
278
|
+
fd = f(d)
|
|
279
|
+
o_min = (a + b) / 2
|
|
280
|
+
f_min = f(o_min)
|
|
281
|
+
|
|
282
|
+
def edge(thr, sgn):
|
|
283
|
+
lo, hi = o_min, o_min + sgn * 90.0
|
|
284
|
+
for _ in range(20):
|
|
285
|
+
mid = (lo + hi) / 2
|
|
286
|
+
if f(mid) <= thr:
|
|
287
|
+
lo = mid
|
|
288
|
+
else:
|
|
289
|
+
hi = mid
|
|
290
|
+
return lo
|
|
291
|
+
|
|
292
|
+
widths = [0.0 if f_min > thr else edge(thr, 1) - edge(thr, -1) for thr in thresholds]
|
|
293
|
+
|
|
294
|
+
# the eclipse-free width comes from the exact margin min|β| − asin(R/(R+h)) (daily minimum, no sampling noise)
|
|
295
|
+
days = np.arange(366.0)
|
|
296
|
+
thr_beta = float(no_eclipse_beta_deg(h))
|
|
297
|
+
margin = lambda O: float(np.min(np.abs(np.degrees(beta_angle(Orbit(h, O), days))))) - thr_beta # noqa: E731
|
|
298
|
+
|
|
299
|
+
# f is flat (zero) across the island, so start the free-edge bisection from the margin's peak, not from o_min
|
|
300
|
+
a, b = centre - 40.0, centre + 40.0
|
|
301
|
+
c, d = b - g * (b - a), a + g * (b - a)
|
|
302
|
+
mc, md = margin(c), margin(d)
|
|
303
|
+
for _ in range(25):
|
|
304
|
+
if mc > md:
|
|
305
|
+
b, d, md = d, c, mc
|
|
306
|
+
c = b - g * (b - a)
|
|
307
|
+
mc = margin(c)
|
|
308
|
+
else:
|
|
309
|
+
a, c, mc = c, d, md
|
|
310
|
+
d = a + g * (b - a)
|
|
311
|
+
md = margin(d)
|
|
312
|
+
o_max = (a + b) / 2
|
|
313
|
+
|
|
314
|
+
def free_edge(sgn):
|
|
315
|
+
lo, hi = o_max, o_max + sgn * 90.0
|
|
316
|
+
for _ in range(20):
|
|
317
|
+
mid = (lo + hi) / 2
|
|
318
|
+
if margin(mid) >= 0:
|
|
319
|
+
lo = mid
|
|
320
|
+
else:
|
|
321
|
+
hi = mid
|
|
322
|
+
return lo
|
|
323
|
+
|
|
324
|
+
widths[0] = free_edge(1) - free_edge(-1) if margin(o_max) >= 0 else 0.0
|
|
325
|
+
return f_min, widths
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def fig_window(out):
|
|
329
|
+
plt = _style()
|
|
330
|
+
h = np.arange(400, 2001, 20)
|
|
331
|
+
thr = (1e-9, 0.01, 0.02, 0.05)
|
|
332
|
+
rows = []
|
|
333
|
+
for x in h:
|
|
334
|
+
_, dawn = _lobe_widths(x, 270.0, thr)
|
|
335
|
+
_, dusk = _lobe_widths(x, 90.0, thr)
|
|
336
|
+
rows.append([(a + b) / 2 for a, b in zip(dawn, dusk)]) # per lobe, averaged over 06:00 and 18:00
|
|
337
|
+
rows = np.array(rows)
|
|
338
|
+
fig, ax = plt.subplots(figsize=(4.8, 2.9))
|
|
339
|
+
for k, (lab, col) in enumerate([("no eclipse at all", C_EMERALD), ("≤ 1% of the year", C_SKY), ("≤ 2%", C_AMBER), ("≤ 5%", C_ROSE)]):
|
|
340
|
+
ax.plot(h, rows[:, k], color=col, lw=1.6, label=lab)
|
|
341
|
+
ax.set_xlabel("altitude h (km)")
|
|
342
|
+
ax.set_ylabel("Ω window per dawn/dusk lobe (°)")
|
|
343
|
+
ax.set_xlim(400, 2000)
|
|
344
|
+
ax.set_ylim(0, 60)
|
|
345
|
+
ax.grid(True)
|
|
346
|
+
ax.legend(title="yearly eclipse", fontsize=7, title_fontsize=7)
|
|
347
|
+
ax.set_title("How far the node may sit from dawn–dusk")
|
|
348
|
+
fig.savefig(os.path.join(out, "fig-window.png"))
|
|
349
|
+
plt.close(fig)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def fig_surface(out):
|
|
353
|
+
"""The eclipse map as a relief over altitude and node offset (the paper's interactive figure)."""
|
|
354
|
+
plt = _style()
|
|
355
|
+
h = np.arange(400, 2001, 25)
|
|
356
|
+
O = np.arange(0, 361, 3)
|
|
357
|
+
F = 100 * np.array([[yearly_eclipse_stats(Orbit(x, o), 92).eclipse_fraction for o in O] for x in h])
|
|
358
|
+
OO, HH = np.meshgrid(O, h)
|
|
359
|
+
fig = plt.figure(figsize=(7.0, 4.8))
|
|
360
|
+
ax = fig.add_subplot(111, projection="3d")
|
|
361
|
+
ax.plot_surface(OO, HH, F, cmap="magma_r", vmin=0, vmax=40, rstride=1, cstride=1, linewidth=0, antialiased=True)
|
|
362
|
+
ax.contour(OO, HH, F, levels=[0.5, 1, 2, 5, 10, 20, 30], colors="white", linewidths=0.5)
|
|
363
|
+
ax.set_xlabel("Ω (°)")
|
|
364
|
+
ax.set_ylabel("altitude h (km)")
|
|
365
|
+
ax.set_zlabel("yearly eclipse (%)")
|
|
366
|
+
ax.set_xticks([0, 90, 180, 270, 360])
|
|
367
|
+
ax.set_zlim(0, 40)
|
|
368
|
+
ax.view_init(elev=32, azim=-125)
|
|
369
|
+
ax.set_title("Yearly eclipse as a relief over altitude and node offset")
|
|
370
|
+
fig.savefig(os.path.join(out, "fig-surface.png"))
|
|
371
|
+
plt.close(fig)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
ALL = {
|
|
375
|
+
"analemma": fig_analemma,
|
|
376
|
+
"sso": fig_sso,
|
|
377
|
+
"frac-beta": fig_frac_beta,
|
|
378
|
+
"raan": fig_raan,
|
|
379
|
+
"optimum": fig_optimum,
|
|
380
|
+
"regions": fig_regions,
|
|
381
|
+
"heatmap": fig_heatmap,
|
|
382
|
+
"window": fig_window,
|
|
383
|
+
"surface": fig_surface,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def make_all(out: str, only=None):
|
|
388
|
+
os.makedirs(out, exist_ok=True)
|
|
389
|
+
for name, fn in ALL.items():
|
|
390
|
+
if only and name not in only:
|
|
391
|
+
continue
|
|
392
|
+
fn(out)
|
|
393
|
+
print("wrote", os.path.join(out, f"fig-{name}.png"))
|
kelvinzero/frames.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Reference frames.
|
|
2
|
+
|
|
3
|
+
EMI (Earth Mean Inertial, a.k.a. ECI): x toward the vernal equinox, z along Earth's spin axis.
|
|
4
|
+
Nothing rotates with time.
|
|
5
|
+
|
|
6
|
+
MSSI (Mean-Sun Synchronous Inertial): EMI rotated about z by the mean longitude of the Sun L(d).
|
|
7
|
+
Its x axis always points at the mean Sun, so the frame turns once per tropical year and an
|
|
8
|
+
exactly sun-synchronous orbit plane is frozen in it.
|
|
9
|
+
|
|
10
|
+
All physics is computed in EMI; a display frame is a rotation about z by -frame_angle(frame, d).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .constants import DEG
|
|
18
|
+
from .sun import mean_sun_longitude, wrap_two_pi
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def frame_angle(frame: str, d):
|
|
22
|
+
"""Rotation angle (radians, about +z) of the display frame relative to EMI."""
|
|
23
|
+
if frame == "MSSI":
|
|
24
|
+
return mean_sun_longitude(d)
|
|
25
|
+
if frame == "EMI":
|
|
26
|
+
return np.zeros_like(np.asarray(d, dtype=float))
|
|
27
|
+
raise ValueError(f"unknown frame {frame!r}; use 'EMI' or 'MSSI'")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gmst(d):
|
|
31
|
+
"""Greenwich Mean Sidereal Time (radians): angle from the vernal equinox to the Greenwich
|
|
32
|
+
meridian. At d = 0 (J2000, 12:00 UT) it equals 280.46 deg, i.e. the mean Sun is on the
|
|
33
|
+
Greenwich meridian."""
|
|
34
|
+
return wrap_two_pi((280.46061837 + 360.98564736629 * np.asarray(d, dtype=float)) * DEG)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rotate_z(v, ang):
|
|
38
|
+
"""Rotate vector(s) ``v`` (shape (..., 3)) about +z by ``ang`` radians."""
|
|
39
|
+
v = np.asarray(v, dtype=float)
|
|
40
|
+
ang = np.asarray(ang, dtype=float)
|
|
41
|
+
c, s = np.cos(ang), np.sin(ang)
|
|
42
|
+
x, y, z = v[..., 0], v[..., 1], v[..., 2]
|
|
43
|
+
return np.stack([c * x - s * y, s * x + c * y, z], axis=-1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def eci_to_display(v, frame: str, d):
|
|
47
|
+
"""EMI -> display-frame coordinates."""
|
|
48
|
+
return rotate_z(v, -frame_angle(frame, d))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def vec_to_lat_lng(v):
|
|
52
|
+
"""Unit vector(s) -> (lat_deg, lng_deg)."""
|
|
53
|
+
v = np.asarray(v, dtype=float)
|
|
54
|
+
r = np.linalg.norm(v, axis=-1)
|
|
55
|
+
return np.degrees(np.arcsin(v[..., 2] / r)), np.degrees(np.arctan2(v[..., 1], v[..., 0]))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def lat_lng_to_vec(lat_deg, lng_deg):
|
|
59
|
+
"""(lat_deg, lng_deg) -> unit vector(s)."""
|
|
60
|
+
la, lo = np.radians(lat_deg), np.radians(lng_deg)
|
|
61
|
+
c = np.cos(la)
|
|
62
|
+
return np.stack([c * np.cos(lo), c * np.sin(lo), np.sin(la)], axis=-1)
|