orbcloud 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.
- orbcloud/__init__.py +8 -0
- orbcloud/ensemble.py +423 -0
- orbcloud/kepler_math.py +140 -0
- orbcloud/sim.py +136 -0
- orbcloud-0.1.0.dist-info/METADATA +122 -0
- orbcloud-0.1.0.dist-info/RECORD +9 -0
- orbcloud-0.1.0.dist-info/WHEEL +5 -0
- orbcloud-0.1.0.dist-info/licenses/LICENSE +21 -0
- orbcloud-0.1.0.dist-info/top_level.txt +1 -0
orbcloud/__init__.py
ADDED
orbcloud/ensemble.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ensemble.py - Classes representing multi-planet exoplanetary system ensembles.
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
from .sim import PlanetConfig, generate_posterior_samples, STELLAR_DATABASE
|
|
7
|
+
from .kepler_math import kepler_to_cartesian
|
|
8
|
+
|
|
9
|
+
def _darken_color(hex_color: str, amount: float = 0.25) -> str:
|
|
10
|
+
"""Darkens a hex color by a specified amount (0.0 to 1.0) for the border.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
hex_color (str): The # + 6 letter/number hexcode of the color of the orbit (e.g #F54927)
|
|
14
|
+
amount (float): The amount from 0.0 to 1.0 that the color will be darkened (default set to 0.25)
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The hexcode (str) of the darkened color in the format of #xxxxxx
|
|
18
|
+
"""
|
|
19
|
+
hex_color = hex_color.lstrip('#')
|
|
20
|
+
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
|
21
|
+
r = int(r * (1.0 - amount))
|
|
22
|
+
g = int(g * (1.0 - amount))
|
|
23
|
+
b = int(b * (1.0 - amount))
|
|
24
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
SPECTRAL_COLORS = {
|
|
28
|
+
'O': '#9bb0ff',
|
|
29
|
+
'B': '#aabfff',
|
|
30
|
+
'A': '#cad7ff',
|
|
31
|
+
'F': '#f8f7ff',
|
|
32
|
+
'G': '#FFCC00',
|
|
33
|
+
'K': '#ffd2a1',
|
|
34
|
+
'M': '#ff9e9e'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
SPECTRAL_SIZES = {
|
|
38
|
+
'O': 150,
|
|
39
|
+
'B': 120,
|
|
40
|
+
'A': 90,
|
|
41
|
+
'F': 80,
|
|
42
|
+
'G': 70,
|
|
43
|
+
'K': 60,
|
|
44
|
+
'M': 45
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SystemEnsemble:
|
|
49
|
+
def __init__(self, star_id: str = 'sun', star_name: str = None, star_mass: float = None, star_type: str = None):
|
|
50
|
+
'''
|
|
51
|
+
Args:
|
|
52
|
+
star_id (str): The id of the star to access the stellar properties in STELLAR_DATABASE (default set to sun)
|
|
53
|
+
star_name (str): Name of the star (default set to None)
|
|
54
|
+
star_mass (float): Mass of the star (default set to None)
|
|
55
|
+
star_type (str): The stellar spectral type of the star (default set to None)
|
|
56
|
+
|
|
57
|
+
'''
|
|
58
|
+
if star_id is not None and not isinstance(star_id, str):
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"star_id must be a string. Got {type(star_id).__name__}: {star_id!r}. "
|
|
61
|
+
f"Suggested: 'sun', 'vega', 'barnard'."
|
|
62
|
+
)
|
|
63
|
+
self.star_id = star_id.lower() if star_id else None
|
|
64
|
+
|
|
65
|
+
# Determine base properties
|
|
66
|
+
if self.star_id in STELLAR_DATABASE:
|
|
67
|
+
base_props = STELLAR_DATABASE[self.star_id]
|
|
68
|
+
else:
|
|
69
|
+
base_props = STELLAR_DATABASE['sun']
|
|
70
|
+
|
|
71
|
+
name = star_name if star_name is not None else (base_props['name'] if self.star_id in STELLAR_DATABASE else "Custom Star")
|
|
72
|
+
|
|
73
|
+
if star_mass is not None:
|
|
74
|
+
if not isinstance(star_mass, (int, float, np.integer, np.floating)):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"star_mass must be a numeric value. Got {type(star_mass).__name__}: {star_mass!r}. "
|
|
77
|
+
f"Suggested: 1.0 (solar mass)."
|
|
78
|
+
)
|
|
79
|
+
if star_mass <= 0:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"star_mass must be positive and greater than 0. Got {star_mass}. "
|
|
82
|
+
f"Suggested typical values: 1.0 (for solar mass), 0.14 (M dwarf), or 2.1 (A star)."
|
|
83
|
+
)
|
|
84
|
+
mass = star_mass if star_mass is not None else base_props['mass']
|
|
85
|
+
|
|
86
|
+
if star_type is not None:
|
|
87
|
+
if not isinstance(star_type, str):
|
|
88
|
+
raise TypeError(
|
|
89
|
+
f"star_type must be a string representing a spectral class. Got {type(star_type).__name__}: {star_type!r}. "
|
|
90
|
+
f"Suggested spectral classes: 'O', 'B', 'A', 'F', 'G', 'K', or 'M'."
|
|
91
|
+
)
|
|
92
|
+
stype = star_type.upper()
|
|
93
|
+
if stype not in SPECTRAL_COLORS:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Invalid star_type {star_type!r}. Must be one of the spectral classes: {list(SPECTRAL_COLORS.keys())}. "
|
|
96
|
+
f"Suggested: 'G' (like the Sun), 'M' (like Barnard's Star), or 'A' (like Vega)."
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
stype = base_props['type']
|
|
100
|
+
|
|
101
|
+
# Determine color and size
|
|
102
|
+
if star_type is not None:
|
|
103
|
+
color = SPECTRAL_COLORS.get(stype, '#fff4e8')
|
|
104
|
+
size = SPECTRAL_SIZES.get(stype, 70)
|
|
105
|
+
else:
|
|
106
|
+
color = base_props['color']
|
|
107
|
+
size = base_props['size']
|
|
108
|
+
|
|
109
|
+
self.star_props = {
|
|
110
|
+
'name': name,
|
|
111
|
+
'type': stype,
|
|
112
|
+
'mass': mass,
|
|
113
|
+
'color': color,
|
|
114
|
+
'size': size
|
|
115
|
+
}
|
|
116
|
+
self.m_star = self.star_props['mass']
|
|
117
|
+
self.planets = {}
|
|
118
|
+
|
|
119
|
+
def add_planet(self, config: PlanetConfig, num_samples: int = 1000, num_points: int = 200):
|
|
120
|
+
"""Simulates parameter posterior distributions and pre-computes 3D paths for a planet. This function stores the computed orbital coordinates.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
config (PlanetConfig object): The dataclass storing the planet's orbital parameters
|
|
124
|
+
num_samples (int): The number of posterior samples to generate (default set to 1000)
|
|
125
|
+
num_points (int): The number of points to generate (default set to 200)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
None
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
# Validate inputs
|
|
132
|
+
if not isinstance(config, PlanetConfig):
|
|
133
|
+
raise TypeError(
|
|
134
|
+
f"config must be an instance of PlanetConfig. Got {type(config).__name__}: {config!r}. "
|
|
135
|
+
f"Please create a PlanetConfig instance first."
|
|
136
|
+
)
|
|
137
|
+
if not isinstance(num_samples, (int, np.integer)):
|
|
138
|
+
raise TypeError(
|
|
139
|
+
f"num_samples must be an integer. Got {type(num_samples).__name__}: {num_samples!r}. "
|
|
140
|
+
f"Suggested: 500 or 1000."
|
|
141
|
+
)
|
|
142
|
+
if num_samples <= 0:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"num_samples must be positive and greater than 0. Got {num_samples}. "
|
|
145
|
+
f"Suggested: 1000."
|
|
146
|
+
)
|
|
147
|
+
if not isinstance(num_points, (int, np.integer)):
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"num_points must be an integer. Got {type(num_points).__name__}: {num_points!r}. "
|
|
150
|
+
f"Suggested: 100 or 200."
|
|
151
|
+
)
|
|
152
|
+
if num_points <= 0:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"num_points must be positive and greater than 0. Got {num_points}. "
|
|
155
|
+
f"Suggested: 200."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# 1. Generate posterior samples
|
|
159
|
+
samples = generate_posterior_samples(config, num_samples)
|
|
160
|
+
|
|
161
|
+
# 2. Setup standard Mean Anomaly grid
|
|
162
|
+
M_grid = np.linspace(0.0, 2 * np.pi, num_points)
|
|
163
|
+
|
|
164
|
+
# 3. Compute (num_samples, num_points, 3) coordinate paths for the cloud
|
|
165
|
+
coords = kepler_to_cartesian(
|
|
166
|
+
P=samples['P'],
|
|
167
|
+
e=samples['e'],
|
|
168
|
+
omega=samples['omega'],
|
|
169
|
+
i=samples['i'],
|
|
170
|
+
Omega=samples['Omega'],
|
|
171
|
+
M_grid=M_grid,
|
|
172
|
+
m_star=self.m_star
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 4. Compute representative nominal orbit using parameter averages
|
|
176
|
+
mean_i = np.mean(samples['i'])
|
|
177
|
+
mean_Omega = np.mean(samples['Omega'])
|
|
178
|
+
mean_e = config.e_mean if config.e_mean is not None else np.mean(samples['e'])
|
|
179
|
+
|
|
180
|
+
nominal_coords = kepler_to_cartesian(
|
|
181
|
+
P=np.array([config.P_mean]),
|
|
182
|
+
e=np.array([mean_e]),
|
|
183
|
+
omega=np.array([np.radians(config.omega_mean_deg)]),
|
|
184
|
+
i=np.array([mean_i]),
|
|
185
|
+
Omega=np.array([mean_Omega]),
|
|
186
|
+
M_grid=M_grid,
|
|
187
|
+
m_star=self.m_star
|
|
188
|
+
)[0]
|
|
189
|
+
|
|
190
|
+
# Store computed coordinates directly
|
|
191
|
+
self.planets[config.name] = {
|
|
192
|
+
'coords': coords,
|
|
193
|
+
'nominal_coords': nominal_coords
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def plot_system(
|
|
197
|
+
self,
|
|
198
|
+
ax=None,
|
|
199
|
+
dimension: str = 'both',
|
|
200
|
+
planets_to_show: list[str] = None,
|
|
201
|
+
alpha: float = None,
|
|
202
|
+
alpha_2d: float = 0.02,
|
|
203
|
+
alpha_3d: float = 0.01,
|
|
204
|
+
colors: list[str] = None,
|
|
205
|
+
elev: float = 20.0,
|
|
206
|
+
azim: float = -60.0,
|
|
207
|
+
limit_padding: float = 1.02,
|
|
208
|
+
show_reference_plane: bool = False
|
|
209
|
+
):
|
|
210
|
+
"""
|
|
211
|
+
Plots the exoplanet system in 2D, 3D, or both side-by-side.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
ax (Axes object): Creates a Matplotlib Axes object for plotting (default set to None)
|
|
215
|
+
dimension (str): Generates a 2D and/or 3D plot of the orbits (default set to 'both')
|
|
216
|
+
planets_to_show (list[str]): Identifies which planets in the system should be shown on the plot (default set to None)
|
|
217
|
+
alpha (float): The opacity of the plotted data (deafult set to None)
|
|
218
|
+
alpha_2d (float): The opacity of the plotted data for the 2D plot (default set to 0.02)
|
|
219
|
+
alpha_3d (float): The opacity of the plotted data for the 3D plot (default set to 0.01)
|
|
220
|
+
colors (list[str]): The colors to be used for the orbits (default set to None)
|
|
221
|
+
elev (float): The elevation of the 3D plot for display (default set to 20.0)
|
|
222
|
+
azim (float): The azimuth of the 3D plot for display (default set to -60.0)
|
|
223
|
+
limit_padding (float): Sets the plot axes limits based on the maximum coordinate values (default set to 1.02)
|
|
224
|
+
show_reference_plane (bool): Shows the reference (0º) plane of the orbits on the 3D plot (default set to false)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
ax (Axes object): The 2D and/or 3D plot of the orbits for the planet(s) in the system.
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
if not isinstance(dimension, str):
|
|
231
|
+
raise TypeError(f"dimension must be a string. Got {type(dimension).__name__}: {dimension!r}")
|
|
232
|
+
dimension = dimension.lower()
|
|
233
|
+
if dimension not in ['2d', '3d', 'both']:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"dimension must be '2d', '3d', or 'both'. Got {dimension!r}. "
|
|
236
|
+
f"Suggested: 'both' for 2D & 3D side-by-side, '2d' for top view, or '3d' for lateral view."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if planets_to_show is not None:
|
|
240
|
+
if not isinstance(planets_to_show, list) or not all(isinstance(p, str) for p in planets_to_show):
|
|
241
|
+
raise TypeError(
|
|
242
|
+
f"planets_to_show must be a list of planet name strings. Got {type(planets_to_show).__name__}: {planets_to_show!r}. "
|
|
243
|
+
f"Suggested: ['Planet b'] or ['Planet b', 'Planet c']."
|
|
244
|
+
)
|
|
245
|
+
for p in planets_to_show:
|
|
246
|
+
if p not in self.planets:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Planet {p!r} is not in the system ensemble. "
|
|
249
|
+
f"Available planets: {list(self.planets.keys())}. "
|
|
250
|
+
f"Please add the planet first using add_planet() or check the name spelling."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if alpha is not None:
|
|
254
|
+
if not isinstance(alpha, (int, float, np.integer, np.floating)):
|
|
255
|
+
raise TypeError(f"alpha must be a number. Got {type(alpha).__name__}: {alpha!r}")
|
|
256
|
+
if not (0.0 <= alpha <= 1.0):
|
|
257
|
+
raise ValueError(f"alpha must be in the range [0.0, 1.0]. Got {alpha}")
|
|
258
|
+
|
|
259
|
+
if alpha_2d is not None:
|
|
260
|
+
if not isinstance(alpha_2d, (int, float, np.integer, np.floating)):
|
|
261
|
+
raise TypeError(f"alpha_2d must be a number. Got {type(alpha_2d).__name__}: {alpha_2d!r}")
|
|
262
|
+
if not (0.0 <= alpha_2d <= 1.0):
|
|
263
|
+
raise ValueError(f"alpha_2d must be in the range [0.0, 1.0]. Got {alpha_2d}")
|
|
264
|
+
|
|
265
|
+
if alpha_3d is not None:
|
|
266
|
+
if not isinstance(alpha_3d, (int, float, np.integer, np.floating)):
|
|
267
|
+
raise TypeError(f"alpha_3d must be a number. Got {type(alpha_3d).__name__}: {alpha_3d!r}")
|
|
268
|
+
if not (0.0 <= alpha_3d <= 1.0):
|
|
269
|
+
raise ValueError(f"alpha_3d must be in the range [0.0, 1.0]. Got {alpha_3d}")
|
|
270
|
+
|
|
271
|
+
if limit_padding is not None:
|
|
272
|
+
if not isinstance(limit_padding, (int, float, np.integer, np.floating)):
|
|
273
|
+
raise TypeError(f"limit_padding must be a number. Got {type(limit_padding).__name__}: {limit_padding!r}")
|
|
274
|
+
if limit_padding <= 0:
|
|
275
|
+
raise ValueError(f"limit_padding must be positive and greater than 0. Got {limit_padding}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# 1. Handle side-by-side double plot
|
|
279
|
+
if dimension == 'both':
|
|
280
|
+
if ax is not None:
|
|
281
|
+
if isinstance(ax, (list, tuple)) and len(ax) == 2:
|
|
282
|
+
ax1, ax2 = ax
|
|
283
|
+
else:
|
|
284
|
+
raise ValueError("For dimension='both', 'ax' must be a list/tuple of two axes [ax2d, ax3d].")
|
|
285
|
+
else:
|
|
286
|
+
fig = plt.figure(figsize=(18, 9), facecolor='white')
|
|
287
|
+
ax1 = fig.add_subplot(121, facecolor='white')
|
|
288
|
+
ax2 = fig.add_subplot(122, projection='3d', facecolor='white')
|
|
289
|
+
|
|
290
|
+
ax1.set_title("2D Top View", fontsize=13, pad=12)
|
|
291
|
+
self.plot_system(ax=ax1, dimension='2d', planets_to_show=planets_to_show, alpha=alpha, alpha_2d=alpha_2d, colors=colors, limit_padding=limit_padding)
|
|
292
|
+
|
|
293
|
+
ax2.set_title("3D Lateral View", fontsize=13, pad=12)
|
|
294
|
+
self.plot_system(ax=ax2, dimension='3d', planets_to_show=planets_to_show, alpha=alpha, alpha_3d=alpha_3d, colors=colors, elev=elev, azim=azim, limit_padding=limit_padding, show_reference_plane=show_reference_plane)
|
|
295
|
+
|
|
296
|
+
return (ax1, ax2)
|
|
297
|
+
|
|
298
|
+
if ax is None:
|
|
299
|
+
fig = plt.figure(figsize=(10, 10), facecolor='white')
|
|
300
|
+
if dimension == '3d':
|
|
301
|
+
ax = fig.add_subplot(111, projection='3d', facecolor='white')
|
|
302
|
+
else:
|
|
303
|
+
ax = fig.add_subplot(111, facecolor='white')
|
|
304
|
+
|
|
305
|
+
# Get star-specific visual properties
|
|
306
|
+
star_color = self.star_props.get('color', '#fff4e8')
|
|
307
|
+
star_edge = _darken_color(star_color, 0.25)
|
|
308
|
+
|
|
309
|
+
if dimension == '3d':
|
|
310
|
+
# Configure light 3D aesthetic
|
|
311
|
+
ax.set_proj_type('ortho')
|
|
312
|
+
ax.grid(True, linestyle=':', alpha=0.6, color='gray')
|
|
313
|
+
|
|
314
|
+
ax.xaxis.pane.fill = True
|
|
315
|
+
ax.yaxis.pane.fill = True
|
|
316
|
+
ax.zaxis.pane.fill = True
|
|
317
|
+
ax.xaxis.pane.set_facecolor('#f7f7f7')
|
|
318
|
+
ax.yaxis.pane.set_facecolor('#f7f7f7')
|
|
319
|
+
ax.zaxis.pane.set_facecolor('#f7f7f7')
|
|
320
|
+
|
|
321
|
+
# Set viewing angle (defaults to standard perspective)
|
|
322
|
+
ax.view_init(elev=elev, azim=azim)
|
|
323
|
+
|
|
324
|
+
# Set axis labels
|
|
325
|
+
ax.set_xlabel('Projected Distance (AU)', labelpad=10)
|
|
326
|
+
ax.set_ylabel('Projected Distance (AU)', labelpad=10)
|
|
327
|
+
ax.set_zlabel('Projected Distance (AU)', labelpad=10)
|
|
328
|
+
|
|
329
|
+
# Plot glowing central star in 3D
|
|
330
|
+
ax.scatter(
|
|
331
|
+
[0], [0], zs=[0],
|
|
332
|
+
color=star_color,
|
|
333
|
+
s=self.star_props['size'] * 3, # Make star size visually prominent
|
|
334
|
+
marker='o',
|
|
335
|
+
edgecolors=star_edge,
|
|
336
|
+
linewidths=2,
|
|
337
|
+
zorder=10,
|
|
338
|
+
label=self.star_props['name']
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
# Configure standard 2D aesthetic
|
|
342
|
+
ax.grid(True, linestyle=':', alpha=0.6, color='gray')
|
|
343
|
+
ax.set_xlabel('Projected Distance (AU)', labelpad=10)
|
|
344
|
+
ax.set_ylabel('Projected Distance (AU)', labelpad=10)
|
|
345
|
+
|
|
346
|
+
# Plot glowing central star in 2D
|
|
347
|
+
ax.scatter(
|
|
348
|
+
[0], [0],
|
|
349
|
+
color=star_color,
|
|
350
|
+
s=self.star_props['size'] * 3,
|
|
351
|
+
marker='o',
|
|
352
|
+
edgecolors=star_edge,
|
|
353
|
+
linewidths=2,
|
|
354
|
+
zorder=10,
|
|
355
|
+
label=self.star_props['name']
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Determine which planets to render
|
|
359
|
+
planets_list = list(self.planets.keys()) if planets_to_show is None else planets_to_show
|
|
360
|
+
|
|
361
|
+
default_colors = ['#008B8B', '#C71585', '#7B68EE', '#FF8C00']
|
|
362
|
+
colors = colors or default_colors
|
|
363
|
+
|
|
364
|
+
# Resolve active alpha based on dimension
|
|
365
|
+
if alpha is not None:
|
|
366
|
+
active_alpha = alpha
|
|
367
|
+
else:
|
|
368
|
+
if dimension == '2d':
|
|
369
|
+
active_alpha = alpha_2d
|
|
370
|
+
elif dimension == '3d':
|
|
371
|
+
active_alpha = alpha_3d
|
|
372
|
+
else:
|
|
373
|
+
active_alpha = 0.02 # Fallback
|
|
374
|
+
|
|
375
|
+
# Render selected planet ensembles
|
|
376
|
+
for idx, name in enumerate(planets_list):
|
|
377
|
+
if name in self.planets:
|
|
378
|
+
planet_color = colors[idx % len(colors)]
|
|
379
|
+
p_data = self.planets[name]
|
|
380
|
+
|
|
381
|
+
# 1. Plot the transparent cloud threads
|
|
382
|
+
if dimension == '2d':
|
|
383
|
+
for orbit in p_data['coords']:
|
|
384
|
+
ax.plot(orbit[:, 0], orbit[:, 1], color=planet_color, alpha=active_alpha, lw=0.8)
|
|
385
|
+
# 2. Plot nominal orbit
|
|
386
|
+
ax.plot(p_data['nominal_coords'][:, 0], p_data['nominal_coords'][:, 1],
|
|
387
|
+
color=planet_color, linestyle='--', linewidth=2.5, label=name)
|
|
388
|
+
else:
|
|
389
|
+
for orbit in p_data['coords']:
|
|
390
|
+
ax.plot(orbit[:, 0], orbit[:, 1], orbit[:, 2], color=planet_color, alpha=active_alpha, lw=0.8)
|
|
391
|
+
# 2. Plot nominal orbit
|
|
392
|
+
ax.plot(p_data['nominal_coords'][:, 0], p_data['nominal_coords'][:, 1], p_data['nominal_coords'][:, 2],
|
|
393
|
+
color=planet_color, linestyle='--', linewidth=2.5, label=name)
|
|
394
|
+
|
|
395
|
+
# Equalize limits to maintain perfect circle projections
|
|
396
|
+
all_coords = []
|
|
397
|
+
for name in planets_list:
|
|
398
|
+
if name in self.planets and self.planets[name]['coords'] is not None:
|
|
399
|
+
all_coords.append(self.planets[name]['coords'])
|
|
400
|
+
|
|
401
|
+
if len(all_coords) > 0:
|
|
402
|
+
stacked = np.concatenate(all_coords, axis=0)
|
|
403
|
+
max_val = np.max(np.abs(stacked))
|
|
404
|
+
limit = max_val * limit_padding
|
|
405
|
+
|
|
406
|
+
# Draw a dim gray plane at Z=0 to show the reference plane of the system
|
|
407
|
+
if dimension == '3d' and show_reference_plane:
|
|
408
|
+
x_plane = np.linspace(-limit, limit, 10)
|
|
409
|
+
y_plane = np.linspace(-limit, limit, 10)
|
|
410
|
+
X_plane, Y_plane = np.meshgrid(x_plane, y_plane)
|
|
411
|
+
Z_plane = np.zeros_like(X_plane)
|
|
412
|
+
ax.plot_surface(X_plane, Y_plane, Z_plane, color='gray', alpha=0.1, shade=False, zorder=0)
|
|
413
|
+
|
|
414
|
+
ax.set_xlim(-limit, limit)
|
|
415
|
+
ax.set_ylim(-limit, limit)
|
|
416
|
+
if dimension == '3d':
|
|
417
|
+
ax.set_zlim(-limit, limit)
|
|
418
|
+
ax.set_box_aspect([1, 1, 1])
|
|
419
|
+
else:
|
|
420
|
+
ax.set_aspect('equal')
|
|
421
|
+
|
|
422
|
+
ax.legend(loc='upper left', frameon=True, facecolor='white', edgecolor='lightgray')
|
|
423
|
+
return ax
|
orbcloud/kepler_math.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kepler_math.py - Vectorized Kepler solvers and coordinate projections using a Mean Anomaly phase grid.
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
def solve_kepler(M: np.ndarray, e: np.ndarray, tol: float = 1e-6, max_iter: int = 100) -> np.ndarray:
|
|
7
|
+
"""
|
|
8
|
+
Vectorized Newton-Raphson solver for Kepler's Equation: M = E - e*sin(E).
|
|
9
|
+
|
|
10
|
+
Parameters:
|
|
11
|
+
M: np.ndarray of shape (N_samples, N_points)
|
|
12
|
+
e: np.ndarray of shape (N_samples, 1) (broadcastable to M)
|
|
13
|
+
"""
|
|
14
|
+
e_arr = np.asarray(e)
|
|
15
|
+
if np.any(e_arr < 0.0) or np.any(e_arr >= 1.0):
|
|
16
|
+
raise ValueError(
|
|
17
|
+
f"Eccentricity values must be in range [0, 1) for elliptic orbits. "
|
|
18
|
+
f"Got range [{np.min(e_arr)}, {np.max(e_arr)}]. "
|
|
19
|
+
f"Suggested typical values: 0.0 (circular), 0.15, or 0.3."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
E = M.copy()
|
|
23
|
+
|
|
24
|
+
for _ in range(max_iter):
|
|
25
|
+
f = E - e_arr * np.sin(E) - M
|
|
26
|
+
f_prime = 1.0 - e_arr * np.cos(E)
|
|
27
|
+
delta = f / f_prime
|
|
28
|
+
E -= delta
|
|
29
|
+
|
|
30
|
+
# Check convergence
|
|
31
|
+
if np.max(np.abs(delta)) < tol:
|
|
32
|
+
break
|
|
33
|
+
|
|
34
|
+
return E
|
|
35
|
+
|
|
36
|
+
def kepler_to_cartesian(
|
|
37
|
+
P: np.ndarray,
|
|
38
|
+
e: np.ndarray,
|
|
39
|
+
omega: np.ndarray,
|
|
40
|
+
i: np.ndarray,
|
|
41
|
+
Omega: np.ndarray,
|
|
42
|
+
M_grid: np.ndarray,
|
|
43
|
+
m_star: float
|
|
44
|
+
) -> np.ndarray:
|
|
45
|
+
"""
|
|
46
|
+
Converts Keplerian elements to 3D Cartesian coordinates (x, y, z)
|
|
47
|
+
|
|
48
|
+
All inputs P, e, omega, i, Omega are 1D arrays of length N_samples.
|
|
49
|
+
M_grid is a 1D array of length N_points.
|
|
50
|
+
|
|
51
|
+
Returns an array of shape (N_samples, N_points, 3).
|
|
52
|
+
"""
|
|
53
|
+
# 1. Type validation for stellar mass
|
|
54
|
+
if not isinstance(m_star, (int, float, np.integer, np.floating)):
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"Stellar mass (m_star) must be a numeric value. Got {type(m_star).__name__}: {m_star!r}. "
|
|
57
|
+
f"Suggested: 1.0 (for solar mass)."
|
|
58
|
+
)
|
|
59
|
+
if m_star <= 0:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Stellar mass (m_star) must be positive and greater than 0. Got {m_star}. "
|
|
62
|
+
f"Suggested values: 1.0 (for solar mass), 0.14 (M dwarf), or 2.1 (A star)."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 2. Convert and validate inputs are array-like
|
|
66
|
+
P = np.asarray(P)
|
|
67
|
+
e = np.asarray(e)
|
|
68
|
+
omega = np.asarray(omega)
|
|
69
|
+
i = np.asarray(i)
|
|
70
|
+
Omega = np.asarray(Omega)
|
|
71
|
+
M_grid = np.asarray(M_grid)
|
|
72
|
+
|
|
73
|
+
if P.ndim != 1 or e.ndim != 1 or omega.ndim != 1 or i.ndim != 1 or Omega.ndim != 1:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Orbital elements (P, e, omega, i, Omega) must be 1D arrays representing samples. "
|
|
76
|
+
f"Got dimensions: P={P.ndim}, e={e.ndim}, omega={omega.ndim}, i={i.ndim}, Omega={Omega.ndim}."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
lengths = {
|
|
80
|
+
'P': len(P),
|
|
81
|
+
'e': len(e),
|
|
82
|
+
'omega': len(omega),
|
|
83
|
+
'i': len(i),
|
|
84
|
+
'Omega': len(Omega)
|
|
85
|
+
}
|
|
86
|
+
if len(set(lengths.values())) > 1:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"All orbital element arrays (P, e, omega, i, Omega) must have the exact same length (number of samples). "
|
|
89
|
+
f"Got lengths: {lengths}. "
|
|
90
|
+
f"Please verify your sample generation setup."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if len(P) == 0:
|
|
94
|
+
raise ValueError("Orbital element arrays cannot be empty.")
|
|
95
|
+
if len(M_grid) == 0:
|
|
96
|
+
raise ValueError("M_grid (phase points grid) cannot be empty.")
|
|
97
|
+
|
|
98
|
+
num_samples = len(P)
|
|
99
|
+
|
|
100
|
+
# Deriving semi-major axis (a) in AU from Period (days) using Kepler's 3rd Law:
|
|
101
|
+
P_years = P / 365.25
|
|
102
|
+
a = (P_years**2 * m_star)**(1/3)
|
|
103
|
+
|
|
104
|
+
# Reshape elements to (N_samples, 1) for broadcasting
|
|
105
|
+
a = a[:, np.newaxis]
|
|
106
|
+
e = e[:, np.newaxis]
|
|
107
|
+
i = i[:, np.newaxis]
|
|
108
|
+
omega = omega[:, np.newaxis]
|
|
109
|
+
Omega = Omega[:, np.newaxis]
|
|
110
|
+
|
|
111
|
+
# Tile the Mean Anomaly grid (shape: N_samples, N_points)
|
|
112
|
+
M = np.tile(M_grid, (num_samples, 1))
|
|
113
|
+
M = M % (2 * np.pi)
|
|
114
|
+
|
|
115
|
+
# Solve Kepler's Equation to get Eccentric Anomaly E
|
|
116
|
+
E = solve_kepler(M, e)
|
|
117
|
+
|
|
118
|
+
# Position in the orbital plane
|
|
119
|
+
x_orb = a * (np.cos(E) - e)
|
|
120
|
+
y_orb = a * np.sqrt(1.0 - e**2) * np.sin(E)
|
|
121
|
+
|
|
122
|
+
# Standard 3D rotations from orbital plane to sky plane (x, y, z)
|
|
123
|
+
cos_omega, sin_omega = np.cos(omega), np.sin(omega)
|
|
124
|
+
cos_Omega, sin_Omega = np.cos(Omega), np.sin(Omega)
|
|
125
|
+
cos_i, sin_i = np.cos(i), np.sin(i)
|
|
126
|
+
|
|
127
|
+
# Rotation transformation
|
|
128
|
+
x = x_orb * (cos_omega * cos_Omega - sin_omega * sin_Omega * cos_i) - \
|
|
129
|
+
y_orb * (sin_omega * cos_Omega + cos_omega * sin_Omega * cos_i)
|
|
130
|
+
|
|
131
|
+
y = x_orb * (cos_omega * sin_Omega + sin_omega * cos_Omega * cos_i) - \
|
|
132
|
+
y_orb * (sin_omega * sin_Omega - cos_omega * cos_Omega * cos_i)
|
|
133
|
+
|
|
134
|
+
z = - x_orb * (sin_omega * sin_i) - \
|
|
135
|
+
y_orb * (cos_omega * sin_i)
|
|
136
|
+
|
|
137
|
+
# Stack x, y, z into a single (N_samples, N_points, 3) matrix
|
|
138
|
+
coords = np.stack([x, y, z], axis=-1)
|
|
139
|
+
|
|
140
|
+
return coords
|
orbcloud/sim.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sim.py - Simulated exoplanet posterior parameter generator without redundant RV amplitude/epoch parameters.
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
# Real-world reference stars mapping spectral type, physical mass (M_sun), and visual properties
|
|
8
|
+
STELLAR_DATABASE = {
|
|
9
|
+
'theta1': {'name': 'Theta1 Orionis C', 'type': 'O', 'mass': 33.0, 'color': '#9bb0ff', 'size': 150},
|
|
10
|
+
'achernar': {'name': 'Achernar', 'type': 'B', 'mass': 6.7, 'color': '#aabfff', 'size': 120},
|
|
11
|
+
'vega': {'name': 'Vega', 'type': 'A', 'mass': 2.1, 'color': '#cad7ff', 'size': 90},
|
|
12
|
+
'upsilon':{'name': 'Upsilon Andromedae','type': 'F', 'mass': 1.3, 'color': '#f8f7ff', 'size': 80},
|
|
13
|
+
'sun': {'name': 'The Sun', 'type': 'G', 'mass': 1.0, 'color': '#FFCC00', 'size': 70},
|
|
14
|
+
'epsilon': {'name': 'Epsilon Eridani', 'type': 'K', 'mass': 0.82, 'color': '#ffd2a1', 'size': 60},
|
|
15
|
+
'barnard': {'name': "Barnard's Star", 'type': 'M', 'mass': 0.144, 'color': '#ff9e9e', 'size': 45}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PlanetConfig:
|
|
20
|
+
name: str
|
|
21
|
+
P_mean: float # Period (days)
|
|
22
|
+
P_std: float # Period uncertainty (days)
|
|
23
|
+
omega_mean_deg: float # Argument of periastron (degrees)
|
|
24
|
+
omega_std_deg: float # Argument of periastron uncertainty (degrees)
|
|
25
|
+
e_mean: float = None # Eccentricity (None defaults to Beta prior)
|
|
26
|
+
e_std: float = None # Eccentricity uncertainty
|
|
27
|
+
i_deg: float = 0.0 # Fixed inclination (degrees) - defaults to 0
|
|
28
|
+
Omega_deg: float = 0.0 # Fixed longitude of ascending node (degrees) - defaults to 0
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
32
|
+
raise TypeError(
|
|
33
|
+
f"Planet name must be a non-empty string. Got {type(self.name).__name__}: {self.name!r}. "
|
|
34
|
+
f"Suggested: 'Planet b' or 'Kepler-186f'."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _check_numeric(val, field_name):
|
|
38
|
+
if not isinstance(val, (int, float, np.integer, np.floating)):
|
|
39
|
+
raise TypeError(
|
|
40
|
+
f"{field_name} must be a number (float or int). Got {type(val).__name__}: {val!r}. "
|
|
41
|
+
f"Please check that you didn't pass a string, list, or dictionary."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_check_numeric(self.P_mean, "P_mean")
|
|
45
|
+
_check_numeric(self.P_std, "P_std")
|
|
46
|
+
_check_numeric(self.omega_mean_deg, "omega_mean_deg")
|
|
47
|
+
_check_numeric(self.omega_std_deg, "omega_std_deg")
|
|
48
|
+
_check_numeric(self.i_deg, "i_deg")
|
|
49
|
+
_check_numeric(self.Omega_deg, "Omega_deg")
|
|
50
|
+
|
|
51
|
+
if self.P_mean <= 0:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"P_mean (period mean) must be positive and greater than 0. Got {self.P_mean}. "
|
|
54
|
+
f"Suggested: 90.0 or 365.25."
|
|
55
|
+
)
|
|
56
|
+
if self.P_std < 0:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"P_std (period uncertainty) must be non-negative. Got {self.P_std}. "
|
|
59
|
+
f"Suggested: 5.0 or 0.0."
|
|
60
|
+
)
|
|
61
|
+
if self.omega_std_deg < 0:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"omega_std_deg (omega uncertainty) must be non-negative. Got {self.omega_std_deg}. "
|
|
64
|
+
f"Suggested: 10.0 or 0.0."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if (self.e_mean is None) != (self.e_std is None):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Both e_mean and e_std must be specified (or both left as None to use the default Beta prior). "
|
|
70
|
+
f"Got e_mean={self.e_mean}, e_std={self.e_std}. "
|
|
71
|
+
f"Please specify both (e.g., e_mean=0.15, e_std=0.05) or omit both."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if self.e_mean is not None:
|
|
75
|
+
_check_numeric(self.e_mean, "e_mean")
|
|
76
|
+
if not (0.0 <= self.e_mean < 1.0):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"e_mean (eccentricity) must be in the range [0, 1) for elliptic orbits. Got {self.e_mean}. "
|
|
79
|
+
f"Suggested typical values: 0.0 (circular), 0.15, or 0.3."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if self.e_std is not None:
|
|
83
|
+
_check_numeric(self.e_std, "e_std")
|
|
84
|
+
if self.e_std < 0:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"e_std (eccentricity uncertainty) must be non-negative. Got {self.e_std}. "
|
|
87
|
+
f"Suggested: 0.05 or 0.0."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def generate_posterior_samples(config: PlanetConfig, num_samples: int = 1000) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Simulates posterior distributions for an exoplanet's geometric parameters.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
config (PlanetConfig object): The dataclass storing the planet's orbital parameters
|
|
98
|
+
num_samples (int): The number of posterior samples to generate to simulate MCMC data (default set to 1000)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
dict: a dictionary of the posteriors (arrays of length = num_samples) to simulate MCMC data
|
|
102
|
+
* 'P' (array): Period (days)
|
|
103
|
+
* 'e' (array): Eccentricity
|
|
104
|
+
* 'omega' (array): Argument of periapsis (radians)
|
|
105
|
+
* 'i' (array): Inclination (radians)
|
|
106
|
+
* 'Omega' (array): Longitude of the ascending node (radians)
|
|
107
|
+
"""
|
|
108
|
+
rng = np.random.default_rng()
|
|
109
|
+
|
|
110
|
+
# 1. Period (Normal distribution)
|
|
111
|
+
P = rng.normal(config.P_mean, config.P_std, size=num_samples)
|
|
112
|
+
P = np.maximum(P, 1e-3) # Period must be positive
|
|
113
|
+
|
|
114
|
+
# 2. Argument of periastron (Normal distribution in radians, wrapped to [0, 2pi])
|
|
115
|
+
omega = np.radians(rng.normal(config.omega_mean_deg, config.omega_std_deg, size=num_samples))
|
|
116
|
+
omega = omega % (2 * np.pi)
|
|
117
|
+
|
|
118
|
+
# 3. Eccentricity (Beta distribution by default, or Normal if configured)
|
|
119
|
+
if config.e_mean is None or config.e_std is None:
|
|
120
|
+
# Standard exoplanet prior: Beta(alpha=0.867, beta=3.03) from Kipping 2013
|
|
121
|
+
e = rng.beta(0.867, 3.03, size=num_samples)
|
|
122
|
+
else:
|
|
123
|
+
e = rng.normal(config.e_mean, config.e_std, size=num_samples)
|
|
124
|
+
e = np.clip(e, 0.0, 0.99) # Bounded for closed orbits
|
|
125
|
+
|
|
126
|
+
# 4. Fixed 3D orientation parameters (i, Omega)
|
|
127
|
+
i = np.full(num_samples, np.radians(config.i_deg))
|
|
128
|
+
Omega = np.full(num_samples, np.radians(config.Omega_deg))
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
'P': P,
|
|
132
|
+
'e': e,
|
|
133
|
+
'omega': omega,
|
|
134
|
+
'i': i,
|
|
135
|
+
'Omega': Omega
|
|
136
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orbcloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 3D orbital probability clouds from simulated exoplanet posteriors
|
|
5
|
+
Author: Oscar Flores Gaitán, Sam Hopper
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Oscar A. Flores Gaitán
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Classifier: Programming Language :: Python :: 3
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Intended Audience :: Science/Research
|
|
32
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
33
|
+
Requires-Python: >=3.8
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
License-File: LICENSE
|
|
36
|
+
Requires-Dist: numpy
|
|
37
|
+
Requires-Dist: matplotlib
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# orbcloud
|
|
41
|
+
|
|
42
|
+
`orbcloud` is a Python package designed to transform simulated exoplanet parameter posteriors (such as MCMC chains) into physical 3D orbital probability density clouds.
|
|
43
|
+
|
|
44
|
+
By plotting thousands of low-opacity orbital paths, the overlapping threads naturally highlight the high-probability regions of 3D orbital space, creating a beautiful and physically accurate visualization.
|
|
45
|
+
|
|
46
|
+
> [!TIP]
|
|
47
|
+
> In addition to visualization, `orbcloud` can be useful to rule out possible dynamical instability in the system. Visually mapping the orbital probability clouds allows researchers to quickly identify overlapping orbital regions. This helps save significant time and computational resources by avoiding expensive N-body simulations if a visual inspection already reveals that the system is most likely going to be unstable.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Vectorized Kepler Solver**: Vectorized Newton-Raphson solver to compute eccentric and true anomalies over custom phase grids.
|
|
54
|
+
- **Physical Star Customization**: Built-in star properties database (e.g. Vega, Barnard's Star) that automatically adjusts the size and glowing spectral color of the central star.
|
|
55
|
+
- **Top (2D) & Lateral (3D) Views**: Easily render orbits in 2D, 3D, or side-by-side.
|
|
56
|
+
- **Transparent Alpha-Clouds**: Line-by-line low opacity (`alpha=0.02`) plots that naturally map the probability clouds.
|
|
57
|
+
- **Robust Parameter Validation**: Informative alert messages and correction suggestions to prevent unphysical values (e.g. eccentricity $\ge 1.0$) or solver failures.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install orbcloud
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Dependencies: `numpy`, `matplotlib`
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Quickstart
|
|
72
|
+
|
|
73
|
+
Here is how to set up and render a multi-planet system:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import matplotlib.pyplot as plt
|
|
77
|
+
from orbcloud import PlanetConfig, SystemEnsemble
|
|
78
|
+
|
|
79
|
+
# 1. Initialize the system around a customized star (e.g. M-type dwarf)
|
|
80
|
+
system = SystemEnsemble(star_name="Custom Star", star_mass=1.6, star_type="M")
|
|
81
|
+
|
|
82
|
+
# 2. Configure planets with mean parameters and uncertainties
|
|
83
|
+
planet_b = PlanetConfig(
|
|
84
|
+
name="Planet b",
|
|
85
|
+
P_mean=90.0, P_std=8.0,
|
|
86
|
+
omega_mean_deg=60.0, omega_std_deg=40.0,
|
|
87
|
+
e_mean=0.15, e_std=0.09
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
planet_c = PlanetConfig(
|
|
91
|
+
name="Planet c",
|
|
92
|
+
P_mean=260.0, P_std=14.0,
|
|
93
|
+
omega_mean_deg=210.0, omega_std_deg=45.0,
|
|
94
|
+
e_mean=0.25, e_std=0.10
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# 3. Add planets to simulate posterior distributions and pre-compute 3D coordinates
|
|
98
|
+
system.add_planet(planet_b, num_samples=1000)
|
|
99
|
+
system.add_planet(planet_c, num_samples=1000)
|
|
100
|
+
|
|
101
|
+
# 4. Plot 2D and 3D clouds side-by-side
|
|
102
|
+
system.plot_system(show_reference_plane=True)
|
|
103
|
+
|
|
104
|
+
# 5. Save the result
|
|
105
|
+
plt.savefig("system_plot.png", dpi=150, facecolor="white", bbox_inches="tight")
|
|
106
|
+
plt.show()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Development & Testing
|
|
112
|
+
|
|
113
|
+
Run unit tests via `pytest`:
|
|
114
|
+
```bash
|
|
115
|
+
python3 -m pytest -v
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
orbcloud/__init__.py,sha256=ucbYm_nV7QVo-JBfMUw0_xUb34WjiI7znG1HWX8TptQ,200
|
|
2
|
+
orbcloud/ensemble.py,sha256=8jnUSGIL0WvhKMB7ozF_HXdb5Jy1q5xI8P4c9Gk9UuM,18687
|
|
3
|
+
orbcloud/kepler_math.py,sha256=cPe0lbyAO7M3FE5xFxV8ujvU96H0VWRzqZgUgef79Os,4763
|
|
4
|
+
orbcloud/sim.py,sha256=9KAgBJiLFLrfAAl3832iKe7nekJC-pYs9jJP-ecVzVE,6236
|
|
5
|
+
orbcloud-0.1.0.dist-info/licenses/LICENSE,sha256=yPJmfHiHFS-FVVzHsRwVVVmHgpAizhI0LFOyHM0Us94,1080
|
|
6
|
+
orbcloud-0.1.0.dist-info/METADATA,sha256=myz-Z-uivnZrZbLtKQyLEeULEjMWInEAjv4OvrL4mQg,4683
|
|
7
|
+
orbcloud-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
orbcloud-0.1.0.dist-info/top_level.txt,sha256=OLwk16hoHVbscO2z94IqEO0YNAVN7ZdJ6EyARpB9thc,9
|
|
9
|
+
orbcloud-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oscar A. Flores Gaitán
|
|
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
|
+
orbcloud
|