orbcloud 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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,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,83 @@
1
+ # orbcloud
2
+
3
+ `orbcloud` is a Python package designed to transform simulated exoplanet parameter posteriors (such as MCMC chains) into physical 3D orbital probability density clouds.
4
+
5
+ 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.
6
+
7
+ > [!TIP]
8
+ > 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.
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ - **Vectorized Kepler Solver**: Vectorized Newton-Raphson solver to compute eccentric and true anomalies over custom phase grids.
15
+ - **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.
16
+ - **Top (2D) & Lateral (3D) Views**: Easily render orbits in 2D, 3D, or side-by-side.
17
+ - **Transparent Alpha-Clouds**: Line-by-line low opacity (`alpha=0.02`) plots that naturally map the probability clouds.
18
+ - **Robust Parameter Validation**: Informative alert messages and correction suggestions to prevent unphysical values (e.g. eccentricity $\ge 1.0$) or solver failures.
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install orbcloud
26
+ ```
27
+
28
+ Dependencies: `numpy`, `matplotlib`
29
+
30
+ ---
31
+
32
+ ## Quickstart
33
+
34
+ Here is how to set up and render a multi-planet system:
35
+
36
+ ```python
37
+ import matplotlib.pyplot as plt
38
+ from orbcloud import PlanetConfig, SystemEnsemble
39
+
40
+ # 1. Initialize the system around a customized star (e.g. M-type dwarf)
41
+ system = SystemEnsemble(star_name="Custom Star", star_mass=1.6, star_type="M")
42
+
43
+ # 2. Configure planets with mean parameters and uncertainties
44
+ planet_b = PlanetConfig(
45
+ name="Planet b",
46
+ P_mean=90.0, P_std=8.0,
47
+ omega_mean_deg=60.0, omega_std_deg=40.0,
48
+ e_mean=0.15, e_std=0.09
49
+ )
50
+
51
+ planet_c = PlanetConfig(
52
+ name="Planet c",
53
+ P_mean=260.0, P_std=14.0,
54
+ omega_mean_deg=210.0, omega_std_deg=45.0,
55
+ e_mean=0.25, e_std=0.10
56
+ )
57
+
58
+ # 3. Add planets to simulate posterior distributions and pre-compute 3D coordinates
59
+ system.add_planet(planet_b, num_samples=1000)
60
+ system.add_planet(planet_c, num_samples=1000)
61
+
62
+ # 4. Plot 2D and 3D clouds side-by-side
63
+ system.plot_system(show_reference_plane=True)
64
+
65
+ # 5. Save the result
66
+ plt.savefig("system_plot.png", dpi=150, facecolor="white", bbox_inches="tight")
67
+ plt.show()
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Development & Testing
73
+
74
+ Run unit tests via `pytest`:
75
+ ```bash
76
+ python3 -m pytest -v
77
+ ```
78
+
79
+ ---
80
+
81
+ ## License
82
+
83
+ This project is licensed under the MIT License.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "orbcloud"
7
+ version = "0.1.0"
8
+ description = "3D orbital probability clouds from simulated exoplanet posteriors"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {file = "LICENSE"}
12
+ authors = [
13
+ {name = "Oscar Flores Gaitán"},
14
+ {name = "Sam Hopper"}
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Intended Audience :: Science/Research",
21
+ "Topic :: Scientific/Engineering :: Astronomy",
22
+ ]
23
+ dependencies = [
24
+ "numpy",
25
+ "matplotlib"
26
+ ]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """
2
+ orbcloud - 3D orbital probability clouds from simulated exoplanet posteriors.
3
+ """
4
+
5
+ from .sim import PlanetConfig
6
+ from .ensemble import SystemEnsemble
7
+
8
+ __all__ = ["PlanetConfig", "SystemEnsemble"]
@@ -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
@@ -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
@@ -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,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/orbcloud/__init__.py
5
+ src/orbcloud/ensemble.py
6
+ src/orbcloud/kepler_math.py
7
+ src/orbcloud/sim.py
8
+ src/orbcloud.egg-info/PKG-INFO
9
+ src/orbcloud.egg-info/SOURCES.txt
10
+ src/orbcloud.egg-info/dependency_links.txt
11
+ src/orbcloud.egg-info/requires.txt
12
+ src/orbcloud.egg-info/top_level.txt
13
+ tests/test_ensemble.py
14
+ tests/test_kepler_math.py
@@ -0,0 +1,2 @@
1
+ numpy
2
+ matplotlib
@@ -0,0 +1 @@
1
+ orbcloud
@@ -0,0 +1,117 @@
1
+ import pytest
2
+ import numpy as np
3
+ from orbcloud.sim import PlanetConfig
4
+ from orbcloud.ensemble import SystemEnsemble
5
+
6
+ def test_planet_config_validation():
7
+ # 1. Valid config should construct without errors
8
+ config = PlanetConfig(
9
+ name="Planet b",
10
+ P_mean=10.0, P_std=1.0,
11
+ omega_mean_deg=90.0, omega_std_deg=10.0,
12
+ e_mean=0.2, e_std=0.05,
13
+ i_deg=10.0, Omega_deg=5.0
14
+ )
15
+ assert config.name == "Planet b"
16
+
17
+ # 2. Invalid name (empty or not string)
18
+ with pytest.raises(TypeError) as excinfo:
19
+ PlanetConfig(name=123, P_mean=10.0, P_std=1.0, omega_mean_deg=90.0, omega_std_deg=10.0)
20
+ assert "Planet name must be a non-empty string" in str(excinfo.value)
21
+
22
+ # 3. Invalid P_mean
23
+ with pytest.raises(ValueError) as excinfo:
24
+ PlanetConfig(name="b", P_mean=0.0, P_std=1.0, omega_mean_deg=90.0, omega_std_deg=10.0)
25
+ assert "P_mean (period mean) must be positive" in str(excinfo.value)
26
+ assert "Suggested" in str(excinfo.value)
27
+
28
+ # 4. Invalid P_std
29
+ with pytest.raises(ValueError) as excinfo:
30
+ PlanetConfig(name="b", P_mean=10.0, P_std=-1.0, omega_mean_deg=90.0, omega_std_deg=10.0)
31
+ assert "P_std (period uncertainty) must be non-negative" in str(excinfo.value)
32
+
33
+ # 5. Mismatched e_mean/e_std (one set, other None)
34
+ with pytest.raises(ValueError) as excinfo:
35
+ PlanetConfig(name="b", P_mean=10.0, P_std=1.0, omega_mean_deg=90.0, omega_std_deg=10.0, e_mean=0.1)
36
+ assert "Both e_mean and e_std must be specified" in str(excinfo.value)
37
+
38
+ # 6. Invalid e_mean boundary (>= 1.0)
39
+ with pytest.raises(ValueError) as excinfo:
40
+ PlanetConfig(name="b", P_mean=10.0, P_std=1.0, omega_mean_deg=90.0, omega_std_deg=10.0, e_mean=1.2, e_std=0.1)
41
+ assert "e_mean (eccentricity) must be in the range [0, 1)" in str(excinfo.value)
42
+
43
+ # 7. Invalid non-numeric type
44
+ with pytest.raises(TypeError) as excinfo:
45
+ PlanetConfig(name="b", P_mean="ten", P_std=1.0, omega_mean_deg=90.0, omega_std_deg=10.0)
46
+ assert "P_mean must be a number" in str(excinfo.value)
47
+
48
+
49
+ def test_system_ensemble_init_validation():
50
+ # 1. Valid initialization from DB
51
+ sys_env = SystemEnsemble(star_id="vega")
52
+ assert sys_env.star_props["name"] == "Vega"
53
+ assert sys_env.star_props["type"] == "A"
54
+ assert sys_env.m_star == 2.1
55
+
56
+ # 2. Invalid star type
57
+ with pytest.raises(ValueError) as excinfo:
58
+ SystemEnsemble(star_type="Z")
59
+ assert "Invalid star_type" in str(excinfo.value)
60
+
61
+ # 3. Invalid star mass
62
+ with pytest.raises(ValueError) as excinfo:
63
+ SystemEnsemble(star_mass=-0.5)
64
+ assert "star_mass must be positive" in str(excinfo.value)
65
+
66
+ with pytest.raises(TypeError) as excinfo:
67
+ SystemEnsemble(star_mass="heavy")
68
+ assert "star_mass must be a numeric value" in str(excinfo.value)
69
+
70
+
71
+ def test_add_planet_validation():
72
+ sys_env = SystemEnsemble(star_id="sun")
73
+
74
+ # 1. Invalid config type
75
+ with pytest.raises(TypeError):
76
+ sys_env.add_planet("not_a_config_object")
77
+
78
+ # 2. Invalid num_samples/num_points
79
+ config = PlanetConfig(name="Planet b", P_mean=10.0, P_std=1.0, omega_mean_deg=90.0, omega_std_deg=10.0)
80
+
81
+ with pytest.raises(ValueError) as excinfo:
82
+ sys_env.add_planet(config, num_samples=0)
83
+ assert "num_samples must be positive" in str(excinfo.value)
84
+
85
+ with pytest.raises(TypeError) as excinfo:
86
+ sys_env.add_planet(config, num_points="many")
87
+ assert "num_points must be an integer" in str(excinfo.value)
88
+
89
+
90
+ def test_plot_system_validation_and_filtering():
91
+ sys_env = SystemEnsemble(star_id="sun")
92
+ config_b = PlanetConfig(name="Planet b", P_mean=50.0, P_std=2.0, omega_mean_deg=45.0, omega_std_deg=10.0)
93
+ config_c = PlanetConfig(name="Planet c", P_mean=150.0, P_std=5.0, omega_mean_deg=120.0, omega_std_deg=15.0)
94
+
95
+ sys_env.add_planet(config_b, num_samples=100, num_points=50)
96
+ sys_env.add_planet(config_c, num_samples=100, num_points=50)
97
+
98
+ # 1. Invalid plot dimension
99
+ with pytest.raises(ValueError) as excinfo:
100
+ sys_env.plot_system(dimension="4d")
101
+ assert "dimension must be '2d', '3d', or 'both'" in str(excinfo.value)
102
+
103
+ # 2. Non-existent planet filter
104
+ with pytest.raises(ValueError) as excinfo:
105
+ sys_env.plot_system(planets_to_show=["Planet d"])
106
+ assert "Planet 'Planet d' is not in the system ensemble" in str(excinfo.value)
107
+ assert "Available planets: ['Planet b', 'Planet c']" in str(excinfo.value)
108
+
109
+ # 3. Invalid alpha ranges
110
+ with pytest.raises(ValueError) as excinfo:
111
+ sys_env.plot_system(alpha=1.5)
112
+ assert "alpha must be in the range [0.0, 1.0]" in str(excinfo.value)
113
+
114
+ # 4. Invalid limit padding
115
+ with pytest.raises(ValueError) as excinfo:
116
+ sys_env.plot_system(limit_padding=-0.1)
117
+ assert "limit_padding must be positive" in str(excinfo.value)
@@ -0,0 +1,99 @@
1
+ import numpy as np
2
+ import pytest
3
+ from orbcloud.kepler_math import solve_kepler, kepler_to_cartesian
4
+
5
+ def test_solve_kepler_convergence():
6
+ # Test convergence for low, medium, and high eccentricities
7
+ M_grid = np.linspace(0.0, 2 * np.pi, 100)
8
+ # Shape (N_samples, N_points)
9
+ M = np.tile(M_grid, (3, 1))
10
+
11
+ # 3 samples: e = 0.0, 0.5, 0.99
12
+ e = np.array([[0.0], [0.5], [0.99]])
13
+
14
+ E = solve_kepler(M, e, tol=1e-6)
15
+
16
+ # Check Kepler's Equation: M = E - e*sin(E)
17
+ kepler_err = np.abs(E - e * np.sin(E) - M)
18
+ assert np.max(kepler_err) < 1e-6
19
+
20
+ def test_solve_kepler_invalid_eccentricity():
21
+ M = np.array([[0.0, 1.0]])
22
+
23
+ # Eccentricity >= 1.0
24
+ with pytest.raises(ValueError) as excinfo:
25
+ solve_kepler(M, np.array([[1.0]]))
26
+ assert "Eccentricity values must be in range [0, 1)" in str(excinfo.value)
27
+ assert "Suggested" in str(excinfo.value)
28
+
29
+ # Eccentricity < 0
30
+ with pytest.raises(ValueError) as excinfo:
31
+ solve_kepler(M, np.array([[-0.1]]))
32
+ assert "Eccentricity values must be in range [0, 1)" in str(excinfo.value)
33
+
34
+ def test_kepler_to_cartesian_circular_orbit():
35
+ # Test that e = 0, i = 0 yields a perfect circle in X-Y plane
36
+ P = np.array([365.25]) # 1 year -> a = 1.0 AU around 1 solar mass star
37
+ e = np.array([0.0])
38
+ omega = np.array([0.0])
39
+ i = np.array([0.0])
40
+ Omega = np.array([0.0])
41
+ M_grid = np.linspace(0.0, 2 * np.pi, 100)
42
+ m_star = 1.0
43
+
44
+ coords = kepler_to_cartesian(P, e, omega, i, Omega, M_grid, m_star)
45
+ # Expected shape: (1, 100, 3)
46
+ assert coords.shape == (1, 100, 3)
47
+
48
+ x = coords[0, :, 0]
49
+ y = coords[0, :, 1]
50
+ z = coords[0, :, 2]
51
+
52
+ # Z should be exactly 0
53
+ np.testing.assert_allclose(z, 0.0, atol=1e-12)
54
+
55
+ # Radius should be exactly 1.0 (since a = 1.0 AU)
56
+ radii = np.sqrt(x**2 + y**2)
57
+ np.testing.assert_allclose(radii, 1.0, rtol=1e-5)
58
+
59
+ def test_kepler_to_cartesian_coplanar():
60
+ # Test that i = 0 yields z = 0 for any eccentricity and parameters
61
+ P = np.array([100.0, 200.0])
62
+ e = np.array([0.1, 0.5])
63
+ omega = np.array([0.5, 1.2])
64
+ i = np.array([0.0, 0.0])
65
+ Omega = np.array([1.5, 2.0])
66
+ M_grid = np.linspace(0.0, 2 * np.pi, 50)
67
+ m_star = 1.2
68
+
69
+ coords = kepler_to_cartesian(P, e, omega, i, Omega, M_grid, m_star)
70
+ z = coords[:, :, 2]
71
+ np.testing.assert_allclose(z, 0.0, atol=1e-12)
72
+
73
+ def test_kepler_to_cartesian_invalid_inputs():
74
+ P = np.array([100.0])
75
+ e = np.array([0.1])
76
+ omega = np.array([0.5])
77
+ i = np.array([0.0])
78
+ Omega = np.array([1.5])
79
+ M_grid = np.linspace(0.0, 2 * np.pi, 50)
80
+
81
+ # 1. Invalid m_star type
82
+ with pytest.raises(TypeError) as excinfo:
83
+ kepler_to_cartesian(P, e, omega, i, Omega, M_grid, "invalid_mass")
84
+ assert "Stellar mass (m_star) must be a numeric value" in str(excinfo.value)
85
+
86
+ # 2. Invalid m_star value
87
+ with pytest.raises(ValueError) as excinfo:
88
+ kepler_to_cartesian(P, e, omega, i, Omega, M_grid, -1.0)
89
+ assert "Stellar mass (m_star) must be positive" in str(excinfo.value)
90
+
91
+ # 3. Mismatched array lengths
92
+ with pytest.raises(ValueError) as excinfo:
93
+ kepler_to_cartesian(np.array([100.0, 200.0]), e, omega, i, Omega, M_grid, 1.0)
94
+ assert "must have the exact same length" in str(excinfo.value)
95
+
96
+ # 4. Empty arrays
97
+ with pytest.raises(ValueError) as excinfo:
98
+ kepler_to_cartesian(np.array([]), np.array([]), np.array([]), np.array([]), np.array([]), M_grid, 1.0)
99
+ assert "arrays cannot be empty" in str(excinfo.value)