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 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())
@@ -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)