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.
- orbx/Configs.py +7 -0
- orbx/Models.py +27 -0
- orbx/__init__.py +5 -0
- orbx/clustering/Cluster.py +48 -0
- orbx/clustering/Core.py +44 -0
- orbx/clustering/Schema.py +41 -0
- orbx/clustering/__init__.py +3 -0
- orbx/clustering/algorithm_wrappers/DBSCANWrapper.py +216 -0
- orbx/clustering/algorithm_wrappers/HDBSCANWrapper.py +37 -0
- orbx/clustering/algorithm_wrappers/OPTICSWrapper.py +96 -0
- orbx/clustering/algorithm_wrappers/cluster_wrapper.py +12 -0
- orbx/clustering/data_handling/DataHandler.py +144 -0
- orbx/density/Density.py +74 -0
- orbx/density/__init__.py +3 -0
- orbx/density/tools/DMT.py +49 -0
- orbx/density/tools/distance_matrix.py +147 -0
- orbx/synthetic_orbits/DMT.py +49 -0
- orbx/synthetic_orbits/__init__.py +3 -0
- orbx/synthetic_orbits/data_handling/build_czml.py +176 -0
- orbx/synthetic_orbits/data_handling/ionop_czml.py +95 -0
- orbx/synthetic_orbits/orbit_finder/DMT.py +57 -0
- orbx/synthetic_orbits/orbit_finder/frechet_orbit_finder.py +215 -0
- orbx/synthetic_orbits/orbit_finder/get_optimum_orbit.py +524 -0
- orbx/synthetic_orbits/orbit_finder/max_separation_orbit_finder.py +173 -0
- orbx/synthetic_orbits/orbit_finder/optimum_orbit_tle.py +161 -0
- orbx/synthetic_orbits/orbit_finder/process_input.py +89 -0
- orbx/synthetic_orbits/orbit_finder/query_spacetrack.py +45 -0
- orbx/synthetic_orbits/process_input.py +53 -0
- orbx/synthetic_orbits/synthetic_orbit.py +108 -0
- orbx/tools/DMT.py +49 -0
- orbx/tools/distance_matrix.py +147 -0
- orbx-1.0.0.dist-info/METADATA +233 -0
- orbx-1.0.0.dist-info/RECORD +36 -0
- orbx-1.0.0.dist-info/WHEEL +5 -0
- orbx-1.0.0.dist-info/licenses/LICENSE +201 -0
- orbx-1.0.0.dist-info/top_level.txt +1 -0
orbx/Configs.py
ADDED
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,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
|
orbx/clustering/Core.py
ADDED
|
@@ -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,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
|
+
|