orbx 1.0.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.
Files changed (36) hide show
  1. orbx/Configs.py +7 -0
  2. orbx/Models.py +27 -0
  3. orbx/__init__.py +5 -0
  4. orbx/clustering/Cluster.py +48 -0
  5. orbx/clustering/Core.py +44 -0
  6. orbx/clustering/Schema.py +41 -0
  7. orbx/clustering/__init__.py +3 -0
  8. orbx/clustering/algorithm_wrappers/DBSCANWrapper.py +216 -0
  9. orbx/clustering/algorithm_wrappers/HDBSCANWrapper.py +37 -0
  10. orbx/clustering/algorithm_wrappers/OPTICSWrapper.py +96 -0
  11. orbx/clustering/algorithm_wrappers/cluster_wrapper.py +12 -0
  12. orbx/clustering/data_handling/DataHandler.py +144 -0
  13. orbx/density/Density.py +74 -0
  14. orbx/density/__init__.py +3 -0
  15. orbx/density/tools/DMT.py +49 -0
  16. orbx/density/tools/distance_matrix.py +147 -0
  17. orbx/synthetic_orbits/DMT.py +49 -0
  18. orbx/synthetic_orbits/__init__.py +3 -0
  19. orbx/synthetic_orbits/data_handling/build_czml.py +176 -0
  20. orbx/synthetic_orbits/data_handling/ionop_czml.py +95 -0
  21. orbx/synthetic_orbits/orbit_finder/DMT.py +57 -0
  22. orbx/synthetic_orbits/orbit_finder/frechet_orbit_finder.py +215 -0
  23. orbx/synthetic_orbits/orbit_finder/get_optimum_orbit.py +524 -0
  24. orbx/synthetic_orbits/orbit_finder/max_separation_orbit_finder.py +173 -0
  25. orbx/synthetic_orbits/orbit_finder/optimum_orbit_tle.py +161 -0
  26. orbx/synthetic_orbits/orbit_finder/process_input.py +89 -0
  27. orbx/synthetic_orbits/orbit_finder/query_spacetrack.py +45 -0
  28. orbx/synthetic_orbits/process_input.py +53 -0
  29. orbx/synthetic_orbits/synthetic_orbit.py +108 -0
  30. orbx/tools/DMT.py +49 -0
  31. orbx/tools/distance_matrix.py +147 -0
  32. orbx-1.0.0.dist-info/METADATA +233 -0
  33. orbx-1.0.0.dist-info/RECORD +36 -0
  34. orbx-1.0.0.dist-info/WHEEL +5 -0
  35. orbx-1.0.0.dist-info/licenses/LICENSE +201 -0
  36. orbx-1.0.0.dist-info/top_level.txt +1 -0
orbx/Configs.py ADDED
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class OrbitalConstants:
5
+ EARTH_RADIUS_M = 6371000 # meters
6
+ GM_EARTH = 3.986004418e14 # m^3/s^2
7
+ SECONDS_IN_DAY = 86400
orbx/Models.py ADDED
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ @dataclass
6
+ class Satellite:
7
+ """Represents a satellite with its orbital parameters"""
8
+ sat_no: str
9
+ line1: str
10
+ line2: str
11
+ inclination: float
12
+ apogee: float
13
+ raan: float
14
+ argument_of_perigee: float
15
+ eccentricity: float
16
+ mean_motion: float
17
+
18
+ def __post_init__(self):
19
+ # Normalize satellite number
20
+ self.sat_no = str(self.sat_no).replace('.0', '').zfill(5)
21
+
22
+
23
+ # @dataclass
24
+ # class ClusterResult:
25
+ # labels: np.ndarray
26
+ # density_df: pd.DataFrame
27
+
orbx/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from orbx.clustering import cluster
2
+ from orbx.density import density
3
+ from orbx.synthetic_orbits import synthetic_orbit
4
+
5
+ __all__ = ["cluster", "synthetic_orbit", "density"]
@@ -0,0 +1,48 @@
1
+ import pandas as pd
2
+ import io
3
+ import warnings
4
+ from contextlib import redirect_stdout
5
+ import numpy as np
6
+ from .Core import Core
7
+ from .Schema import Schema
8
+
9
+
10
+ def cluster(
11
+ df: pd.DataFrame,
12
+ min_samples: int = 3,
13
+ min_cluster_size: int = 2,
14
+ verbose: bool = False,
15
+ ) -> np.ndarray:
16
+
17
+ """
18
+ Arguments
19
+ ----------
20
+ df: pandas.DataFrame
21
+ This should contain the TLEs you want to cluster (at a minimum). Your df should contain
22
+ a "line1" and "line2" in each row, corresponding to a satellite.
23
+ min_samples: int, default 3
24
+ HDBSCAN minimum samples parameter. Refer to HDBSCAN documentation for more details.
25
+ min_cluster_size: int, default 2
26
+ HDBSCAN minimum cluster size parameter. Refer to HDBSCAN documentation for more details.
27
+ verbose: bool, default False
28
+ If ``True``, print internal clustering progress and allow warnings.
29
+ If ``False`` (default), suppress stdout and ``FutureWarning`` noise.
30
+ """
31
+
32
+
33
+
34
+ # Handle input validation
35
+ """
36
+ Core().cluster() is expecting a dataframe with the columns "line1" and "line2"
37
+ """
38
+ Schema().validate(df)
39
+
40
+ if verbose:
41
+ labels = Core().cluster(df, min_samples, min_cluster_size)
42
+ else:
43
+ with warnings.catch_warnings():
44
+ warnings.simplefilter("ignore", category=FutureWarning)
45
+ with redirect_stdout(io.StringIO()):
46
+ labels = Core().cluster(df, min_samples, min_cluster_size)
47
+
48
+ return labels
@@ -0,0 +1,44 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from orbx.clustering.Schema import Schema
4
+ from orbx.tools.distance_matrix import get_distance_matrix
5
+ from orbx.clustering.algorithm_wrappers.cluster_wrapper import ClusterWrapper
6
+ from orbx.clustering.data_handling.DataHandler import DataHandler
7
+
8
+ class Core:
9
+ def __init__(self):
10
+
11
+ self.cluster_wrapper = ClusterWrapper()
12
+ self.data_handler = DataHandler()
13
+
14
+ """
15
+ The cluster function will take in a dataframe of TLEs and return a ClusterResult.
16
+ """
17
+ def cluster(self, df: pd.DataFrame, min_samples: int = 3, min_cluster_size: int = 2) -> np.ndarray:
18
+
19
+ """
20
+ Needs to take the input df
21
+ which supposedly has only the line1 line2 cols, and then
22
+ extract the keplerian elements. These must then be concatenated to the
23
+ original df
24
+ """
25
+
26
+ # Validate the input dataframe
27
+ # Schema().validate(df)
28
+
29
+ df = self.data_handler.tle_to_keplerian(df)
30
+
31
+ distance_matrix, key = get_distance_matrix(df)
32
+ df = self._reorder_dataframe(df, key)
33
+ X = self.data_handler.get_points(df)
34
+
35
+ labels = self.cluster_wrapper.run_hdbscan(distance_matrix, X, min_samples, min_cluster_size)
36
+
37
+ return labels
38
+
39
+ def _reorder_dataframe(self, df: pd.DataFrame, key: dict) -> pd.DataFrame:
40
+ """Reorder dataframe to match key order (this is just overly cautious)"""
41
+ idx_satNo = key["idx_satNo_dict"]
42
+
43
+ satNos_in_order = [idx_satNo[i] for i in range(len(idx_satNo))]
44
+ return df.set_index("satNo").loc[satNos_in_order].reset_index()
@@ -0,0 +1,41 @@
1
+ import pandas as pd
2
+ from sgp4.io import twoline2rv
3
+ import sgp4.earth_gravity as earth_gravity
4
+
5
+ REQUIRED_COLUMNS = {"line1", "line2"}
6
+
7
+ class Schema:
8
+
9
+ def validate(self, df: pd.DataFrame) -> None:
10
+ """
11
+ Validates input DataFrame and computes all derived orbital elements.
12
+
13
+ Required columns:
14
+ line1 (str): TLE line 1
15
+ line2 (str): TLE line 2
16
+
17
+ Optional columns:
18
+ name (str): Satellite name. Defaults to sat_id if not provided.
19
+
20
+ Returns a DataFrame with all orbital elements computed and ready for clustering.
21
+ """
22
+ missing = REQUIRED_COLUMNS - set(df.columns)
23
+ if missing:
24
+ raise ValueError(
25
+ f"Input DataFrame is missing required columns: {missing}\n"
26
+ f"DataFrame must contain at minimum 'line1' and 'line2'."
27
+ )
28
+ if df.empty:
29
+ raise ValueError("Input DataFrame is empty")
30
+ if df[["line1", "line2"]].isnull().any().any():
31
+ raise ValueError("Input DataFrame contains null values in 'line1' or 'line2' columns")
32
+
33
+ # Next we want to validate the two line elements.
34
+
35
+ for _, row in df.iterrows():
36
+ try:
37
+ _ = twoline2rv(row['line1'], row['line2'], earth_gravity.wgs72)
38
+ except ValueError as e:
39
+ raise ValueError(f"Invalid TLE format for TLE: {row['line1']}, {row['line2']}: {e}")
40
+
41
+ return None
@@ -0,0 +1,3 @@
1
+ from .Cluster import cluster
2
+
3
+ __all__ = ["cluster"]
@@ -0,0 +1,216 @@
1
+ import numpy as np
2
+ from sklearn.cluster import DBSCAN
3
+ from orbx.Models import ClusterResult
4
+ from tqdm import tqdm
5
+ import pandas as pd
6
+
7
+
8
+ class DBSCANWrapper:
9
+
10
+ def __init__(self):
11
+ # Broad sweep, but selection will prefer small values
12
+ self.min_samples_range = [2]
13
+ self.min_samples = 2
14
+ self.target_cluster_range = (200, 500)
15
+ self.target_cluster_center = 350
16
+ self.max_noise_fraction = 0.6
17
+
18
+ def run(self, distance_matrix: np.ndarray, X: np.ndarray):
19
+ return self.fit(distance_matrix, X)
20
+
21
+ def _evaluate(self, X, distance_matrix, eps, min_samples):
22
+ model = DBSCAN(
23
+ eps=eps,
24
+ min_samples=min_samples,
25
+ metric="precomputed",
26
+ n_jobs=-1
27
+ )
28
+
29
+ labels = model.fit_predict(distance_matrix)
30
+ n_clusters = len(set(labels) - {-1})
31
+
32
+ if n_clusters < 2:
33
+ return -1.0, labels
34
+
35
+ try:
36
+ score = self.quality_metrics.dbcv_score_wrapper(X, labels)
37
+ return score, labels
38
+ except Exception:
39
+ return -1.0, labels
40
+
41
+ def _feasible(self, n_clusters, noise_count, n_total):
42
+ if not (self.target_cluster_range[0] <= n_clusters <= self.target_cluster_range[1]):
43
+ return False
44
+ if noise_count / n_total > self.max_noise_fraction:
45
+ return False
46
+ return True
47
+
48
+ def fit(self, distance_matrix: np.ndarray, X: np.ndarray) -> ClusterResult:
49
+ best_score = -np.inf
50
+ best_labels = None
51
+ best_params = None
52
+
53
+ n_total = distance_matrix.shape[0]
54
+
55
+ # =========================
56
+ # Stage 1: coarse eps sweep
57
+ # =========================
58
+ dist_vals = distance_matrix[distance_matrix > 0]
59
+
60
+ low, high = np.percentile(dist_vals, [0.1, 3.0])
61
+ low = max(low, 0.15)
62
+ high = min(high, 4.0)
63
+
64
+ coarse_eps = np.geomspace(low, high, 80)
65
+
66
+ coarse_results = []
67
+
68
+ for eps in tqdm(coarse_eps, desc="DBSCAN coarse eps sweep"):
69
+ score, labels = self._evaluate(X, distance_matrix, eps, self.min_samples)
70
+ n_clusters = len(set(labels) - {-1})
71
+ noise_count = (labels == -1).sum()
72
+
73
+ coarse_results.append(
74
+ (eps, score, n_clusters, noise_count)
75
+ )
76
+
77
+ coarse_df = pd.DataFrame(
78
+ coarse_results,
79
+ columns=["eps", "dbcv", "clusters", "noise"]
80
+ )
81
+
82
+ # =========================
83
+ # Find eps focus region
84
+ # =========================
85
+ focus = coarse_df[
86
+ coarse_df.clusters.between(
87
+ self.target_cluster_range[0],
88
+ self.target_cluster_range[1]
89
+ )
90
+ ]
91
+
92
+ if focus.empty:
93
+ raise RuntimeError("No eps values produced feasible cluster counts")
94
+
95
+ eps_center = focus.eps.median()
96
+
97
+ # =========================
98
+ # Stage 2: focused eps sweep
99
+ # =========================
100
+ eps_low = eps_center / 1.5
101
+ eps_high = eps_center * 1.5
102
+
103
+ eps_values = np.geomspace(eps_low, eps_high, 120)
104
+
105
+ sweep_results = []
106
+
107
+ for eps in tqdm(eps_values, desc="DBSCAN focused eps sweep"):
108
+ score, labels = self._evaluate(X, distance_matrix, eps, self.min_samples)
109
+ n_clusters = len(set(labels) - {-1})
110
+ noise_count = (labels == -1).sum()
111
+
112
+ sweep_results.append(
113
+ (2, eps, score, n_clusters, noise_count)
114
+ )
115
+
116
+ if score < 0:
117
+ continue
118
+
119
+ penalty = abs(n_clusters - self.target_cluster_center) / self.target_cluster_center
120
+ adjusted_score = score - 0.5 * penalty
121
+
122
+ if self._feasible(n_clusters, noise_count, n_total):
123
+ if adjusted_score > best_score:
124
+ best_score = adjusted_score
125
+ best_labels = labels
126
+ best_params = (eps, 2)
127
+
128
+ if best_labels is None:
129
+ raise RuntimeError("DBSCAN failed to find a domain-feasible clustering")
130
+
131
+ # =========================
132
+ # Diagnostics plot (focused)
133
+ # =========================
134
+ # output_dir = Path("data")
135
+ # output_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ # df = pd.DataFrame(
138
+ # sweep_results,
139
+ # columns=["min_samples", "eps", "dbcv", "clusters", "noise"]
140
+ # )
141
+
142
+ # fig, ax1 = plt.subplots(figsize=(9, 5))
143
+
144
+ # # ---- Left axis: number of clusters ----
145
+ # ax1.plot(
146
+ # df.eps,
147
+ # df.clusters,
148
+ # alpha=0.7,
149
+ # label="Clusters",
150
+ # color="tab:blue"
151
+ # )
152
+ # ax1.set_xscale("log")
153
+ # ax1.set_xlabel("eps (log scale)")
154
+ # ax1.set_ylabel("Number of clusters", color="tab:blue")
155
+ # ax1.tick_params(axis="y", labelcolor="tab:blue")
156
+
157
+ # # Vertical reference line
158
+ # ax1.axvline(
159
+ # eps_center,
160
+ # color="gray",
161
+ # linestyle="--",
162
+ # alpha=0.6,
163
+ # label="eps center"
164
+ # )
165
+
166
+ # # ---- Right axis: DBCV score ----
167
+ # ax2 = ax1.twinx()
168
+ # ax2.plot(
169
+ # df.eps,
170
+ # df.dbcv,
171
+ # alpha=0.7,
172
+ # label="DBCV",
173
+ # color="tab:orange"
174
+ # )
175
+ # ax2.set_ylabel("DBCV score", color="tab:orange")
176
+ # ax2.tick_params(axis="y", labelcolor="tab:orange")
177
+
178
+ # # ---- Combined legend ----
179
+ # lines_1, labels_1 = ax1.get_legend_handles_labels()
180
+ # lines_2, labels_2 = ax2.get_legend_handles_labels()
181
+ # ax1.legend(
182
+ # lines_1 + lines_2,
183
+ # labels_1 + labels_2,
184
+ # loc="best",
185
+ # fontsize=9
186
+ # )
187
+
188
+ # ax1.set_title("DBSCAN (focused): clusters & DBCV vs eps")
189
+
190
+ # output_path = output_dir / "dbscan_eps_clusters_dbcv_focused.png"
191
+ # plt.tight_layout()
192
+ # plt.savefig(output_path, dpi=200)
193
+ # plt.close(fig)
194
+
195
+
196
+ # =========================
197
+ # Final reporting
198
+ # =========================
199
+ # print(f"Saved plot to {output_path}")
200
+ print(
201
+ f"Best DBSCAN params → eps={best_params[0]:.6f}, min_samples=2"
202
+ )
203
+ print(
204
+ f"DBSCAN found {len(set(best_labels) - {-1})} clusters "
205
+ f"(noise points: {(best_labels == -1).sum()})"
206
+ )
207
+ print(f"Adjusted selection score: {best_score:.4f}")
208
+
209
+ return ClusterResult(
210
+ best_labels,
211
+ len(set(best_labels) - {-1}),
212
+ (best_labels == -1).sum(),
213
+ best_score,
214
+ self.cluster_wrapper.quality_metrics.s_dbw_score_wrapper(X, best_labels)
215
+ )
216
+
@@ -0,0 +1,37 @@
1
+ import numpy as np
2
+ from sklearn.cluster import HDBSCAN
3
+
4
+ class HDBSCANWrapper:
5
+
6
+ def __init__(self):
7
+ pass
8
+
9
+ def run(self, distance_matrix: np.ndarray, X: np.ndarray, min_samples: int = 3, min_cluster_size: int = 2) -> np.ndarray:
10
+ # return labels, best_score
11
+ return self.fit(distance_matrix, X, min_samples, min_cluster_size)
12
+
13
+ def fit(self, distance_matrix: np.ndarray, X: np.ndarray, min_samples: int = 3, min_cluster_size: int = 2) -> np.ndarray:
14
+
15
+ print(f"Running HDBSCAN: ms={min_samples}", flush=True)
16
+
17
+ clusterer = HDBSCAN(
18
+ min_cluster_size=min_cluster_size,
19
+ min_samples=min_samples,
20
+ metric="precomputed",
21
+ cluster_selection_method="eom",
22
+ n_jobs=-1,
23
+ )
24
+
25
+ labels = clusterer.fit_predict(distance_matrix)
26
+
27
+ unique_clusters = set(labels) - {-1}
28
+ n_clusters = len(unique_clusters)
29
+ if n_clusters < 2:
30
+ n_orbits = len(labels)
31
+ n_noise = int(np.sum(labels == -1))
32
+ raise ValueError(
33
+ f"HDBSCAN produced {n_clusters} cluster(s) from {n_orbits} orbit(s) "
34
+ f"({n_noise} marked as noise). Adjust your dataset or tune the HDBSCAN parameters."
35
+ )
36
+
37
+ return labels
@@ -0,0 +1,96 @@
1
+ from sklearn.cluster import OPTICS
2
+ import numpy as np
3
+ from orbx.Models import ClusterResult
4
+ import pickle
5
+
6
+ class OPTICSWrapper:
7
+ def __init__(self):
8
+ self.min_samples_range = [3]
9
+
10
+ self.xi_values = np.geomspace(0.005, 0.2, 10)
11
+
12
+ self.max_eps = np.inf
13
+
14
+ """
15
+ Grid search over (min_samples, xi)
16
+ """
17
+ def run(self, distance_matrix, X) -> ClusterResult:
18
+ best_score = -np.inf
19
+ best_params = None
20
+ best_labels = None
21
+
22
+ n_total = distance_matrix.shape[0]
23
+
24
+ for min_samples in self.min_samples_range:
25
+ for xi in self.xi_values:
26
+ try:
27
+ model = OPTICS(
28
+ min_samples=min_samples,
29
+ max_eps=self.max_eps,
30
+ metric="precomputed",
31
+ cluster_method="xi",
32
+ xi=xi,
33
+ n_jobs=-1,
34
+ )
35
+
36
+ labels = model.fit_predict(distance_matrix)
37
+
38
+ n_clusters = len(set(labels) - {-1})
39
+ noise_count = (labels == -1).sum()
40
+
41
+ # acceptance = QualityMetrics.is_clustering_acceptable(labels.copy())
42
+ # if not acceptance["acceptable"]:
43
+ # print(
44
+ # f"Rejected: min_samples={min_samples}, xi={xi:.4f} "
45
+ # f"({acceptance['fail_reasons']})"
46
+ # )
47
+ # continue
48
+
49
+ score = self.quality_metrics.dbcv_score_wrapper(X, labels)
50
+
51
+ print(
52
+ f"min_samples={min_samples:2d}, "
53
+ f"xi={xi:.4f}, "
54
+ f"clusters={n_clusters:4d}, "
55
+ f"noise={noise_count:4d}, "
56
+ f"DBCV={score:.4f}"
57
+ )
58
+
59
+ if score > best_score:
60
+ best_score = score
61
+ best_params = (min_samples, xi)
62
+ best_labels = labels
63
+
64
+ except Exception as e:
65
+ print(
66
+ f"Error: min_samples={min_samples}, xi={xi:.4f} → {e}"
67
+ )
68
+ continue
69
+
70
+ if best_labels is None:
71
+ raise RuntimeError("OPTICS failed to find a valid clustering")
72
+
73
+ best_min_samples, best_xi = best_params
74
+
75
+ print(
76
+ f"\nBest OPTICS params → min_samples={best_min_samples}, "
77
+ f"xi={best_xi:.4f}, "
78
+ f"DBCV={best_score:.4f}"
79
+ )
80
+
81
+ print(
82
+ f"OPTICS found {len(set(best_labels) - {-1})} clusters "
83
+ f"(noise points: {(best_labels == -1).sum()})"
84
+ )
85
+
86
+ # Save the labels as pkl
87
+ with open("data/cluster_results/optics_labels.pkl", "wb") as f:
88
+ pickle.dump(best_labels, f)
89
+
90
+ return ClusterResult(
91
+ best_labels,
92
+ len(set(best_labels) - {-1}),
93
+ (best_labels == -1).sum(),
94
+ best_score,
95
+ self.cluster_wrapper.quality_metrics.s_dbw_score_wrapper(X, best_labels)
96
+ )
@@ -0,0 +1,12 @@
1
+ import numpy as np
2
+ from .HDBSCANWrapper import HDBSCANWrapper
3
+
4
+ class ClusterWrapper:
5
+ def __init__(self):
6
+ self.hdbscan = HDBSCANWrapper()
7
+
8
+ def run_hdbscan(self, distance_matrix: np.ndarray, X: np.ndarray, min_samples: int = 3, min_cluster_size: int = 2) -> np.ndarray:
9
+ labels = self.hdbscan.run(distance_matrix.copy(), X.copy(), min_samples, min_cluster_size)
10
+ print(f"HDBSCAN found {len(set(labels) - {-1})} clusters")
11
+
12
+ return labels
@@ -0,0 +1,144 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from orbx.Configs import OrbitalConstants
4
+ from orbx.Models import Satellite
5
+ from math import pi
6
+
7
+ class DataHandler:
8
+
9
+ def __init__(self):
10
+ self.orbital_constants = OrbitalConstants()
11
+
12
+ def get_points(self, df: pd.DataFrame):
13
+ """Takes in a dataframe of Satellite objects. Converts each to a point in the 5D manifold embedded in 6D.
14
+ This is so the raw data can be passed into clustering algs, quality metrics, etc.
15
+
16
+ Args:
17
+ df (pd.DataFrame): _description_
18
+
19
+ Returns:
20
+ _type_: _description_
21
+ """
22
+
23
+ # Convert degrees -> radians
24
+ i = np.deg2rad(df["inclination"].values)
25
+ Omega = np.deg2rad(df["raan"].values)
26
+ omega = np.deg2rad(df["argument_of_perigee"].values)
27
+ e = df["eccentricity"].values
28
+ n = df["mean_motion"].values # rev/day
29
+
30
+ # Constants
31
+ MU = self.orbital_constants.GM_EARTH # m^3/s^2
32
+
33
+ # Semi-major axis from mean motion
34
+ n_rad = 2 * np.pi * n / 86400.0
35
+ a = (MU / n_rad**2) ** (1 / 3)
36
+
37
+ # Semi-latus rectum
38
+ p = a * (1 - e**2)
39
+ sqrt_p = np.sqrt(p)
40
+
41
+ # Angular momentum vector u
42
+ u = np.column_stack(
43
+ [
44
+ sqrt_p * np.sin(i) * np.sin(Omega),
45
+ -sqrt_p * np.sin(i) * np.cos(Omega),
46
+ sqrt_p * np.cos(i),
47
+ ]
48
+ )
49
+
50
+ # LRL vector v
51
+ v = np.column_stack(
52
+ [
53
+ e
54
+ * sqrt_p
55
+ * (
56
+ np.cos(omega) * np.cos(Omega)
57
+ - np.cos(i) * np.sin(omega) * np.sin(Omega)
58
+ ),
59
+ e
60
+ * sqrt_p
61
+ * (
62
+ np.cos(omega) * np.sin(Omega)
63
+ + np.cos(i) * np.sin(omega) * np.cos(Omega)
64
+ ),
65
+ e * sqrt_p * (np.sin(i) * np.sin(omega)),
66
+ ]
67
+ )
68
+
69
+ X = np.hstack([u, v])
70
+
71
+ return X
72
+
73
+ def tle_to_keplerian(self, input_df) -> pd.DataFrame:
74
+
75
+ # Pre-allocate lists for keplerian elements
76
+ sat_nos = []
77
+ inclinations = []
78
+ apogees = []
79
+ raans = []
80
+ arguments_of_perigee = []
81
+ eccentricities = []
82
+ mean_motions = []
83
+
84
+ # for each row, compute the keplerian elements and add to df
85
+ for index, row in input_df.iterrows():
86
+ try:
87
+ sat_obj = self._parse_tle_group(
88
+ row['line1'],
89
+ row['line2']
90
+ )
91
+ except ValueError as e:
92
+ raise ValueError(f"Error parsing TLE at row: {index}, TLE: {row['line1']}, {row['line2']}, Error: {e}")
93
+
94
+ sat_nos.append(sat_obj.sat_no)
95
+ inclinations.append(sat_obj.inclination)
96
+ apogees.append(sat_obj.apogee)
97
+ raans.append(sat_obj.raan)
98
+ arguments_of_perigee.append(sat_obj.argument_of_perigee)
99
+ eccentricities.append(sat_obj.eccentricity)
100
+ mean_motions.append(sat_obj.mean_motion)
101
+
102
+ # Add as new columns
103
+ input_df['satNo'] = sat_nos
104
+ input_df['inclination'] = inclinations
105
+ input_df['apogee'] = apogees
106
+ input_df['raan'] = raans
107
+ input_df['argument_of_perigee'] = arguments_of_perigee
108
+ input_df['eccentricity'] = eccentricities
109
+ input_df['mean_motion'] = mean_motions
110
+
111
+ return input_df
112
+
113
+ def _parse_tle_group(self, line1: str, line2: str) -> Satellite:
114
+
115
+ sat_no = line1[2:7].strip()
116
+
117
+ # should use sgp4 to extract keplerians
118
+ inclination = float(line2[8:16].strip())
119
+ mean_motion = float(line2[52:63].strip())
120
+ eccentricity = float("0." + line2[26:33].strip())
121
+ raan = float(line2[17:25].strip())
122
+ argument_of_perigee = float(line2[34:42].strip())
123
+ apogee = self._calculate_apogee(mean_motion, eccentricity)
124
+
125
+ return Satellite(
126
+ sat_no=sat_no,
127
+ line1=line1,
128
+ line2=line2,
129
+ inclination=inclination,
130
+ apogee=apogee,
131
+ raan=raan,
132
+ argument_of_perigee=argument_of_perigee,
133
+ eccentricity=eccentricity,
134
+ mean_motion=mean_motion
135
+ )
136
+
137
+ def _calculate_apogee(self, mean_motion: float, eccentricity: float) -> float:
138
+ """Calculate apogee in kilometers from mean motion and eccentricity"""
139
+ n = mean_motion * 2 * pi / self.orbital_constants.SECONDS_IN_DAY
140
+ a = (self.orbital_constants.GM_EARTH / (n ** 2)) ** (1/3)
141
+ apogee_m = a * (1 + eccentricity) - self.orbital_constants.EARTH_RADIUS_M
142
+ return apogee_m / 1000 # Convert to km
143
+
144
+