postcn commited on
Commit
d196012
·
verified ·
1 Parent(s): 3fea429

Upload 14 files

Browse files
README.md CHANGED
@@ -1,3 +1,400 @@
1
  ---
2
  license: apache-2.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
+ base_model: Datadog/Toto-2.0-2.5B
4
+ pipeline_tag: time-series-forecasting
5
+ library_name: pytorch
6
+ tags:
7
+ - time-series
8
+ - time-series-forecasting
9
+ - foundation-model
10
+ - fine-tuned
11
+ - satellite
12
+ - orbit-prediction
13
+ - orbit-propagation
14
+ - orbital-mechanics
15
+ - TLE
16
+ - SGP4
17
+ - space-weather
18
+ - LEO
19
+ - aerospace
20
+ - toto
21
+ language:
22
+ - en
23
+ - ko
24
+ metrics:
25
+ - rmse
26
+ model-index:
27
+ - name: OrbitFM
28
+ results:
29
+ - task:
30
+ type: time-series-forecasting
31
+ name: Satellite orbit prediction (TLE element forecasting)
32
+ dataset:
33
+ type: tle-archive
34
+ name: Historical TLE archive (2005-2024, LEO) + CelesTrak SW-All space weather
35
+ metrics:
36
+ - type: rmse
37
+ name: Median position RMSE @ 30-day horizon (km)
38
+ value: 140.46
39
+ - type: rmse
40
+ name: Mean position RMSE @ 30-day horizon (km)
41
+ value: 798.75
42
  ---
43
+
44
+ # OrbitFM — TLE-based Satellite Orbit Forecasting Model
45
+ # OrbitFM — TLE 기반 위성 궤도 예측 모델
46
+
47
+ **OrbitFM** is a satellite orbit prediction model that forecasts future orbital elements — and, through SGP4, future satellite **positions** — directly from a satellite's historical **Two-Line Element (TLE)** time series. It is built by continued pretraining (fine-tuning) of the [Datadog/Toto-2.0-2.5B](https://huggingface.co/Datadog/Toto-2.0-2.5B) time-series foundation model on 20 years (2005–2024) of cleaned, daily-resampled TLE records for low-Earth-orbit (LEO) objects, enriched with physics-derived features and **space-weather** (solar / geomagnetic) driver channels.
48
+
49
+ **OrbitFM**은 위성의 과거 **TLE(Two-Line Element)** 시계열로부터 미래 궤도 요소를 — 그리고 SGP4를 통해 미래 위성 **위치**를 — 직접 예측하는 위성 궤도 예측 모델입니다. 시계열 파운데이션 모델 [Datadog/Toto-2.0-2.5B](https://huggingface.co/Datadog/Toto-2.0-2.5B)를, 정제 후 일 단위로 재표집한 20년치(2005–2024) 저궤도(LEO) 위성 TLE 기록에 물리 파생 피처와 **우주기상**(태양·지자기 활동) 채널을 더해 continued pretraining(fine-tuning)하여 만들었습니다.
50
+
51
+ The standard operational approach — propagating the last observed TLE forward with SGP4 ("persistence") — is very strong at short horizons but degrades quickly as atmospheric drag, solar activity, and secular element drift accumulate. OrbitFM learns these drift dynamics from data: at horizons of **7 days and beyond it roughly halves the mean position error** of the SGP4 persistence baseline, and at the 30-day horizon it reduces the **median** per-satellite position RMSE from ≈966 km to ≈140 km, beating the baseline on **81.3%** of evaluated satellites.
52
+
53
+ 기존 운용 방식 — 마지막으로 관측된 TLE를 SGP4로 전파하는 "persistence" 방식 — 은 단기 예측에서는 매우 강력하지만, 대기 항력·태양 활동·궤도 요소의 장기 drift가 누적될수록 오차가 빠르게 커집니다. OrbitFM은 이러한 drift 동역학을 데이터에서 학습합니다. **7일 이상 horizon에서 SGP4 persistence baseline의 평균 위치 오차를 약 절반으로** 줄이며, 30일 horizon에서는 위성별 위치 RMSE **중앙값**을 약 966 km에서 약 140 km로 낮추고 평가 대상 위성의 **81.3%**에서 baseline보다 우수한 결과를 보였습니다.
54
+
55
+ ---
56
+
57
+ ## Model Details / 모델 상세
58
+
59
+ | | |
60
+ |---|---|
61
+ | **Model type** | Decoder-style multivariate time-series foundation model (Toto-2 architecture), continued-pretrained for orbital element forecasting |
62
+ | **Base model** | [Datadog/Toto-2.0-2.5B](https://huggingface.co/Datadog/Toto-2.0-2.5B) (~2.5B parameters) |
63
+ | **Task** | Multivariate time-series forecasting → satellite orbital element / position prediction |
64
+ | **Input** | Per-satellite daily-grid multivariate series, **25 channels** (6 orbital targets + 16 physics features + 3 space-weather features), default context of 8 patches × 32 days = **256 days** (evaluation uses 64-day contexts) |
65
+ | **Output** | Quantile forecasts (pinball/quantile head) of the 6 orbital target channels over the forecast horizon; median quantile used for point forecasts |
66
+ | **Position reconstruction** | Predicted element drifts are anchored at the last observed TLE, integrated into absolute elements, and converted to TEME position via **SGP4** |
67
+ | **Training data** | Public TLE archives, 2005–2024, LEO objects only (median mean motion ≥ 11.25 rev/day), outlier-cleaned; space weather from [CelesTrak SW-All.csv](https://celestrak.org/SpaceData/SW-All.csv) |
68
+ | **Languages** | Not applicable (numeric time series) |
69
+ | **License** | Apache-2.0 |
70
+ | **Developed by** | PCN R&S |
71
+
72
+ The model consumes a per-satellite daily-grid multivariate series of 25 channels and outputs quantile forecasts for the 6 orbital target channels; the median quantile is used as the point forecast, and positions are recovered by anchoring the predicted drifts at the last observed TLE and propagating with SGP4.
73
+
74
+ 모델은 위성별 25채널 daily-grid 다변량 시계열을 입력���로 받아 6개 orbital target 채널에 대한 quantile 예측을 출력합니다. point forecast로는 중앙값 quantile을 사용하며, 예측된 drift를 마지막 관측 TLE에 anchor하여 절대 요소로 복원한 뒤 SGP4로 전파해 위치를 계산합니다.
75
+
76
+ ### Why not just SGP4? / 왜 SGP4만으로는 부족한가?
77
+
78
+ Propagating the most recent TLE with SGP4 implicitly assumes the current drag state and element rates stay fixed. Over multi-day horizons, this ignores:
79
+
80
+ - **Atmospheric drag variation** driven by solar EUV flux (F10.7) and geomagnetic activity (Ap), which modulates the secular decay of mean motion — the dominant source of along-track position error;
81
+ - **Slow drift** of BSTAR and mean motion that a single TLE snapshot cannot capture;
82
+ - **Secular rates** of RAAN and argument of perigee that evolve over time.
83
+
84
+ 가장 최근 TLE를 SGP4로 전파하는 방식은 현재의 drag 상태와 요소 변화율이 그대로 유지된다고 암묵적으로 가정합니다. 며칠 이상의 horizon에서는 다음 요인들이 무시됩니다.
85
+
86
+ - 태양 EUV 플럭스(F10.7)와 지자기 활동(Ap)이 유발하는 **대기 항력 변화** — mean motion의 장기 감쇠를 좌우하며 along-track 위치 오차의 지배적 원인;
87
+ - 단일 TLE 스냅숏으로는 포착할 수 없는 BSTAR와 mean motion의 **느린 drift**;
88
+ - 시간에 따라 변화하는 RAAN과 argument of perigee의 **장기 변화율**.
89
+
90
+ OrbitFM consumes the satellite's recent element history *plus* the space-weather record, and predicts how the elements will drift — exactly the information a single-TLE propagation lacks.
91
+
92
+ OrbitFM은 위성의 최근 궤도 요소 이력에 우주기상 기록을 *더하여* 입력으로 사용하고, 궤도 요소가 앞으로 어떻게 drift할지를 예측합니다 — 단일 TLE 전파 방식에 결여된 바로 그 정보입니다.
93
+
94
+ ---
95
+
96
+ ## Channel / Feature Design / 채널·피처 설계
97
+
98
+ The model input is a fixed 25-channel daily-grid series per satellite. Only the first 6 channels are prediction targets; the rest are **input-only context** (masked out of the loss).
99
+
100
+ 모델 입력은 위성별 고정 25채널 daily-grid 시계열입니다. 처음 6개 채널만 예측 대상이며, 나머지는 loss에서 제외되는 **입력 전용(input-only) 문맥 채널**입니다.
101
+
102
+ ### Predicted orbital channels (in loss, indices 0–5) / 예측 대상 orbital 채널 (loss 포함, 인덱스 0–5)
103
+
104
+ | # | Channel | Meaning (EN) | 의미 (KR) |
105
+ |---|---|---|---|
106
+ | 0 | `d_bstar_slog_per_day` | Daily drift of signed-log1p(BSTAR) — drag coefficient trend | signed log BSTAR의 일별 변화량 (drag 계수 추세) |
107
+ | 1 | `d_mean_motion_per_day` | Daily drift of mean motion — orbital decay rate | mean motion의 일별 변화량 (궤도 감쇠율) |
108
+ | 2 | `eccentricity` | Absolute eccentricity | 이심률 절대값 |
109
+ | 3 | `inclination_deg` | Absolute inclination (deg) | 궤도경사각 절대값 (deg) |
110
+ | 4 | `draan_deg_per_day` | RAAN secular rate (deg/day) | RAAN 일별 변화율 (deg/day) |
111
+ | 5 | `dargp_deg_per_day` | Argument-of-perigee secular rate (deg/day) | argument of perigee 일별 변화율 (deg/day) |
112
+
113
+ **Drift (residual) targets.** BSTAR and mean motion barely change in absolute terms over a forecast window, so predicting absolutes collapses to persistence. OrbitFM instead predicts their **per-day deltas** — zero-mean, small-magnitude drift signals — which amplifies exactly the learning signal that separates the model from persistence. Absolute anchors live in an auxiliary array and the trajectory is reconstructed as *anchor + cumulative predicted delta*.
114
+
115
+ **Drift(residual) target 설계.** BSTAR와 mean motion은 forecast window 안에서 절대값이 거의 변하지 않으므로, 절대값을 직접 예측하면 모델이 persistence에 가까운 예측으로 수렴하게 됩니다. OrbitFM은 대신 두 변수의 **일별 delta** — 평균이 0에 가깝고 크기가 작은 drift 신호 — 를 예측하여, 모델을 persistence와 구분 짓는 학습 신호를 그대로 증폭합니다. 절대값 anchor는 보조(aux) 배열에 저장되며, 궤적은 *anchor + 예측 delta의 누적합*으로 복원됩니다.
116
+
117
+ **Angles are never predicted in absolute form.** Mean anomaly, RAAN, and argument of perigee are 0–360° circular variables; predicting them directly creates 359°-vs-1° discontinuity artifacts. Mean anomaly is unwrapped into a cumulative phase using mean motion (more reliable than the TLE rev counter), and the forecast phase is re-integrated trapezoidally from the predicted mean-motion trajectory. RAAN/argp are forecast as daily rates and cumulatively summed from the anchor.
118
+
119
+ **절대 각도는 직접 예측하지 않습니다.** mean anomaly, RAAN, argument of perigee는 0–360°를 순환하는 각도형 변수이므로, 직접 예측하면 359°와 1°처럼 실제로는 가까운 값이 수치적으로 멀게 표현되는 문제가 생깁니다. mean anomaly는 mean motion을 이용해 누적 위상으로 unwrap하며(TLE의 rev counter 필드보다 신뢰도가 높음), 예측 위상은 예측된 mean motion 궤적으로부터 사다리꼴 적분으로 복원합니다. RAAN/argp는 일별 변화율로 예측한 뒤 anchor에서 누적합산합니다.
120
+
121
+ ### Input-only physics features (indices 6–21) / 입력 전용 물리 파생 피처 (인덱스 6–21)
122
+
123
+ Engineered features inspired by the cm-tle-pred benchmark, computed per grid day from the elements (fully vectorized two-body Kepler solve, no per-day sgp4init):
124
+
125
+ - `sat_rx, sat_ry, sat_rz` — ECI position (km); `sat_vx, sat_vy, sat_vz` — ECI velocity (km/s)
126
+ - `semimajor_axis` (km), `period_min` (min/rev), `apoapsis_alt`, `periapsis_alt` (km)
127
+ - `ma_cos/ma_sin`, `raan_cos/raan_sin`, `argp_cos/argp_sin` — cyclical encodings of the angular elements
128
+
129
+ cm-tle-pred 벤치마크에서 착안한 엔지니어링 피처로, 각 grid day의 궤도 요소로부터 계산됩니다(완전 벡터화된 이체 Kepler 풀이 사용, 일별 sgp4init 없음).
130
+
131
+ - `sat_rx, sat_ry, sat_rz` — ECI 위치 (km); `sat_vx, sat_vy, sat_vz` — ECI 속도 (km/s)
132
+ - `semimajor_axis` 장반경 (km), `period_min` 궤도 주기 (분/rev), `apoapsis_alt` 원지점 고도, `periapsis_alt` 근지점 고도 (km)
133
+ - `ma_cos/ma_sin`, `raan_cos/raan_sin`, `argp_cos/argp_sin` — 각도형 요소의 sin/cos 순환 인코딩
134
+
135
+ ### Input-only space-weather features (indices 22–24) / 입력 전용 우주기상 피처 (인덱스 22–24)
136
+
137
+ - `f107` — daily observed F10.7 cm solar radio flux (solar EUV proxy)
138
+ - `f107_81` — 81-day centered mean of F10.7
139
+ - `ap` — daily average Ap geomagnetic index
140
+
141
+ - `f107` — 일별 관측 F10.7 cm 태양 전파 플럭스 (태양 EUV 대리 지표)
142
+ - `f107_81` — F10.7의 81일 중심 이동평균
143
+ - `ap` — 일평균 Ap 지자기 지수
144
+
145
+ Source: CelesTrak `SW-All.csv` (daily since 1957; columns `F10.7_OBS`, `F10.7_OBS_CENTER81`, `AP_AVG`). These exogenous drag drivers are what allow a learned model to beat "hold the last mean motion constant" at multi-day horizons.
146
+
147
+ 출처: CelesTrak `SW-All.csv` (1957년부터 일 단위; `F10.7_OBS`, `F10.7_OBS_CENTER81`, `AP_AVG` 열 사용). 이 외생적 drag 구동 변수들이 있기에 학습 기반 모델이 며칠 이상의 horizon에서 "마지막 mean motion을 그대로 유지"하는 방식을 이길 수 있습니다.
148
+
149
+ ### Auxiliary anchors (not model input/output) / 보조 anchor 정보 (모델 입출력 아님)
150
+
151
+ `[mean_anomaly, RAAN, argp, mean_motion_abs, bstar_slog_abs]` per grid day — the absolute reference values used to anchor/reconstruct forecasts and to compute truth.
152
+
153
+ grid day별 `[mean_anomaly, RAAN, argp, mean_motion_abs, bstar_slog_abs]` — 예측 복원의 anchor 및 ground truth 계산에 사용되는 절대 기준값입니다.
154
+
155
+ ---
156
+
157
+ ## Data & Preprocessing Pipeline / 데이터·전처리 파이프라인
158
+
159
+ Raw TLE archives are noisy; the cleaning pipeline was one of the largest accuracy levers.
160
+
161
+ 원본 TLE archive에는 노이즈가 많으며, 정제 파이프라인은 가장 큰 정확도 향상 요인 중 하나였습니다.
162
+
163
+ 1. **Group & sort** — parse raw TLE text files, group records by NORAD ID, sort by epoch, drop duplicate/near-duplicate epochs.
164
+ 2. **Physical-integrity filter** — drop records with impossible elements: eccentricity ∉ [0, 1), inclination ∉ (0°, 180°), mean motion ∉ (0.1, 20) rev/day.
165
+ 3. **Early-record drop** — discard the first 5 TLEs of each object (pre-stabilization, least reliable).
166
+ 4. **LEO filter** — keep objects with median mean motion ≥ 11.25 rev/day.
167
+ 5. **Robust outlier removal** — per satellite, flag any record deviating from the time-linear interpolation of its neighbors by more than **6 MAD** robust scales, on each smoothly-evolving quantity (mean motion, inclination, eccentricity, log-BSTAR, cumulative mean-anomaly phase, unwrapped RAAN/argp); union of flags removed, up to 2 passes. This is a cheap, dependency-free equivalent of the DBSCAN-on-differences cleaning that the cm-tle-pred benchmark reports as its single biggest accuracy gain.
168
+ 6. **Daily-grid resampling** — TLE epochs are irregular, so each element series is linearly interpolated onto a 1-day grid; grid days farther than 2 days from any real TLE are masked out of loss and evaluation.
169
+ 7. **Angle handling** — mean anomaly → cumulative phase via mean motion; RAAN/argp unwrapped; all angles additionally exposed as sin/cos input features.
170
+ 8. **Feature assembly** — 6 orbital + 16 physics + 3 solar channels + 5 auxiliary anchors, cached as a compressed `.npz` per configuration.
171
+
172
+ 1. **정렬 및 그룹화** — 원본 TLE 텍스트 파일을 파싱해 NORAD ID 기준으로 그룹화하고, epoch 기준으로 정렬한 뒤 동일·근접 epoch의 중복 record를 제거합니다.
173
+ 2. **물리적 무결성 필터** — 물리적으로 불가능한 요소를 가진 record 제거: eccentricity ∉ [0, 1), inclination ∉ (0°, 180°), mean motion ∉ (0.1, 20) rev/day.
174
+ 3. **초기 record 제거** — 각 물체의 첫 5개 TLE 폐기 (궤도 안정화 이전으로 신뢰도가 가장 낮음).
175
+ 4. **LEO 필터** — mean motion 중앙값 ≥ 11.25 rev/day인 물체만 유지.
176
+ 5. **Robust outlier 제거** — 위성별로, 부드럽게 변화해야 하는 각 물리량(mean motion, inclination, eccentricity, log-BSTAR, mean anomaly 누적 위상, unwrap된 RAAN/argp)에 대해 양쪽 이웃 record의 시간 선형 보간값에서 **6 MAD** robust scale 이상 벗어나는 record를 flag하고, flag의 합집합을 최대 2회 반복 제거합니다. 이는 cm-tle-pred 벤치마크가 단일 최대 정확도 향상 요인으로 보고한 DBSCAN 기반 정제의 저비용·무의존성 등가 기법입니다.
177
+ 6. **Daily-grid 재표집** — TLE epoch 간격은 불규칙하므로 각 요소 시계열을 1일 간격 grid로 선형 보간하고, 실제 TLE에서 2일 이상 떨어진 grid day는 mask 처리하여 loss와 평가에서 제외합니다.
178
+ 7. **각도 처리** — mean anomaly는 mean motion 기반 누적 위상으로 변환; RAAN/argp는 unwrap; 모든 각도는 추가로 sin/cos 입력 피처로도 제공됩니다.
179
+ 8. **피처 조립** — orbital 6 + 물리 16 + 우주기상 3 채널 + 보조 anchor 5개를 구성별 압축 `.npz` 캐시로 저장합니다.
180
+
181
+ Satellites with fewer than 64 observed grid days are excluded at cache-build time.
182
+
183
+ 관측된 grid day가 64일 미만인 위성은 캐시 생성 단계에서 제외됩니다.
184
+
185
+ ---
186
+
187
+ ## Training / 학습
188
+
189
+ Fine-tuning (continued pretraining) from Toto-2.0-2.5B pretrained weights, with the next-patch **quantile (pinball) loss in Toto's asinh-scaled space** applied **only to the 6 orbital channels** — physics and solar channels are context the model reads but is never asked to forecast.
190
+
191
+ Toto-2.0-2.5B 사전학습 가중치로부터 fine-tuning(continued pretraining)하였으며, **Toto의 asinh 스케일 공간에서의 next-patch quantile(pinball) loss**를 **6개 orbital 채널에만** 적용했습니다. 물리 피처와 우주기상 채널은 모델이 읽기만 하고 예측하도록 요구되지 않는 문맥 정보입니다.
192
+
193
+ | Hyperparameter | Value |
194
+ |---|---|
195
+ | Base checkpoint | `Datadog/Toto-2.0-2.5B` (pretrained init) |
196
+ | Objective | Next-patch quantile pinball loss (asinh-scaled), orbital channels only |
197
+ | Drift-channel loss weight | **×4.0** on `d_bstar_slog_per_day` and `d_mean_motion_per_day` |
198
+ | Optimizer | AdamW (β₁ = 0.9, β₂ = 0.95, weight decay 0) |
199
+ | Learning rate | 4e-5, linear warmup 1,000 steps, cosine decay to lr/10 |
200
+ | Max steps | 10,000 |
201
+ | Batch size | 16 |
202
+ | Context window | 8 patches × patch_size 32 = 256 days (stride 4 patches) |
203
+ | Precision | bf16 autocast (CUDA) |
204
+ | Gradient clipping | 1.0 |
205
+ | Validation | Every 2,000 steps; best-validation and final checkpoints saved |
206
+ | Seed | 42 |
207
+
208
+ The two drift channels (`d_bstar_slog_per_day`, `d_mean_motion_per_day`) receive a ×4.0 loss weight: SGP4 along-track position error is governed by mean motion and drag, so upweighting them aligns the training objective with position accuracy.
209
+
210
+ 두 drift 채널(`d_bstar_slog_per_day`, `d_mean_motion_per_day`)에는 4배의 loss 가중치를 부여했습니다. SGP4의 along-track 위치 오차는 mean motion과 drag가 좌우하므로, 이 가중치는 학습 목적 함수를 위치 정확도와 정렬시킵니다.
211
+
212
+ ### Data split (forecast-honest time split) / 데이터 분할 (미래 누출 없는 시간 기준 분할)
213
+
214
+ The default split is **by time**, so evaluation is a true forecasting test with no future leakage:
215
+
216
+ - **Train**: windows ending before 2022-01-01
217
+ - **Validation**: windows ending in [2022-01-01, 2023-01-01)
218
+ - **Test**: windows ending on/after 2023-01-01
219
+
220
+ 기본 분할은 **시간 기준**이므로, 평가는 미래 정보 누출이 없는 실제 예측 시험이 됩니다.
221
+
222
+ - **Train**: 2022-01-01 이전에 끝나는 window
223
+ - **Validation**: [2022-01-01, 2023-01-01) 구간에 끝나는 window
224
+ - **Test**: 2023-01-01 이후에 끝나는 window
225
+
226
+ A cm-tle-pred-style **satellite-level 70/15/15 split** (deterministic by NORAD-ID hash; every record of a satellite in one split) is also supported via `--split-mode satellite`.
227
+
228
+ cm-tle-pred 방식의 **위성 단위 70/15/15 분할**(NORAD ID 해시 기반 결정적 분할; 한 위성의 모든 record가 같은 split에 속함)도 `--split-mode satellite` 옵션으로 지원됩니다.
229
+
230
+ ---
231
+
232
+ ## Evaluation / 평가
233
+
234
+ **Protocol.** Per-satellite evaluation on the **time-split test set** (windows ending 2023+): at each anchor the model receives 64 days of true context (solar channels fed as true context), then forecasts the next horizon. Up to 50 anchors per satellite, up to 1,500 satellites. For horizons ≤ patch size a single forward pass is used (no autoregressive feedback of predicted solar values).
235
+
236
+ **평가 방식.** **시간 기준 test set**(2023년 이후에 끝나는 window)에 대한 위성별(per-satellite) 평가입니다. 각 anchor 시점에서 모델은 64일의 실제 context(solar 채널은 실제 값)를 입력받아 이후 horizon을 예측합니다. 위성당 최대 50개 anchor, 최대 1,500개 위성을 평가했습니다. horizon이 patch size 이하인 경우 단일 forward pass를 사용하므로 예측된 solar 값의 자기회귀적 피드백이 없습니다.
237
+
238
+ **Baseline.** SGP4 persistence — propagate the last observed TLE unchanged to the target time (the standard operational method).
239
+
240
+ **Baseline.** SGP4 persistence — 마지막 관측 TLE를 그대로 목표 시점까지 전파하는 방식(표준 운용 방법)입니다.
241
+
242
+ **Metrics.**
243
+ 1. **Element RMSE** — mean motion, inclination, eccentricity as absolute error; mean anomaly / RAAN / argp as circular error.
244
+ 2. **Position RMSE (km)** — predicted elements → SGP4 → TEME position at t₀+Δ, Euclidean distance to the truth-TLE-derived position. In the reported `sgp4` reconstruction mode, the model's forecast drives SGP4 through an interval-averaged predicted mean motion (BSTAR zeroed to avoid double-counting drag), so the phase stays analytic but at the model's predicted decay rate.
245
+
246
+ **평가 지표.**
247
+ 1. **궤도 요소 RMSE** — mean motion, inclination, eccentricity는 절대 오차, mean anomaly / RAAN / argp는 circular error로 계산합니다.
248
+ 2. **위치 RMSE (km)** — 예측 요소 → SGP4 → t₀+Δ 시점의 TEME 위치를 계산하고, ground truth TLE 기반 위치와의 유클리드 거리를 측정합니다. 보고된 `sgp4` 복원 모드에서는 모델 예측이 구간 평균 mean motion으로 SGP4를 구동하며(drag 이중 반영을 막기 위해 BSTAR는 0으로 설정), 위상 계산은 해석적(SGP4)으로 유지하되 모델이 예측한 감쇠율을 따릅니다.
249
+
250
+ ### Position RMSE vs. SGP4 persistence baseline / 위치 RMSE — SGP4 persistence baseline 대비 (test split, 최대 1,500개 위성)
251
+
252
+ | Horizon | Model mean RMSE (km) | Baseline mean RMSE (km) | Mean improvement / 평균 개선율 | Model median RMSE (km) | Baseline median RMSE (km) | Model wins / 모델 우세 위성 비율 |
253
+ |---|---|---|---|---|---|---|
254
+ | 1 d | 6.10 | 6.01 | −1.4% | 3.76 | 2.86 | 55.6% |
255
+ | 3 d | 16.73 | 24.89 | **+32.8%** | 9.69 | 11.75 | 55.5% |
256
+ | 7 d | 56.55 | 110.50 | **+48.8%** | 22.70 | 49.05 | 74.0% |
257
+ | 14 d | 199.97 | 399.04 | **+49.9%** | 50.54 | 192.29 | 82.9% |
258
+ | 30 d | 798.75 | 1589.82 | **+49.8%** | 140.46 | 966.21 | **81.3%** |
259
+
260
+ At 1 day the persistence baseline is already near-optimal — the last TLE is still fresh — and the model is on par. From 3 days onward the learned drift model pulls ahead, and from 7 days onward it cuts mean position error roughly in half. The median-error gap is even larger (30 d: 140 km vs. 966 km), showing the improvement holds for the *typical* satellite rather than being driven by a few outliers.
261
+
262
+ 1일 horizon에서는 마지막 TLE의 정보가 아직 유효해 persistence baseline이 이미 거의 최적이며, 모델은 그와 대등한 수준입니다. 3일부터 학습된 drift 모델이 앞서기 시작하고, 7일 이상에서는 평균 위치 오차를 약 절반으로 줄입니다. 중앙값 기준 격차는 더 큽니다(30일: 140 km vs 966 km). 이는 개선이 일부 특이 위성에 의한 것이 아니라 *일반적인* 위성 다수에서 안정적으로 나타남을 보여줍니다.
263
+
264
+ ### Per-element median RMSE @ 30-day horizon / 궤도 요소별 30일 중앙값 RMSE
265
+
266
+ | Element | Model | Baseline | Note (EN) | 해석 (KR) |
267
+ |---|---|---|---|---|
268
+ | Mean motion | 0.000154 rev/day | 0.000178 rev/day | Model learns part of the decay drift | 모델이 mean motion drift를 일부 학습 |
269
+ | Mean anomaly | 47.03° | 77.33° | Long-horizon phase advantage | 장기 phase 예측에서 모델 우세 |
270
+ | Inclination | 0.00185° | 0.00227° | Small but consistent gain | 작은 차이지만 모델이 개선 |
271
+ | Eccentricity | 5.48e-5 | 7.84e-5 | Gain at long horizon | 장기 horizon에서 모델이 개선 |
272
+ | RAAN | 0.00560° | 63.11° | Rate forecasting vs. no-rate baseline — largest gap | RAAN 변화율 예측 효과가 매우 큼 |
273
+ | Argument of perigee | 10.60° | 42.04° | Rate forecasting improves over baseline | 각도 변화율 예측으로 baseline 대비 개선 |
274
+
275
+ The RAAN / argp gains are structural: the persistence baseline holds these angles' rates at zero, while OrbitFM forecasts the daily rates and integrates them. The mean-anomaly gain reflects the mean-motion drift being modeled — the driver of long-horizon phase (along-track) error.
276
+
277
+ RAAN / argp의 개선은 구조적입니다. persistence baseline은 이 각도들의 변화율을 0으로 고정하는 반면, OrbitFM은 일별 변화율을 예측해 적분하기 때문입니다. mean anomaly의 개선은 mean motion drift가 모델링된 결과로, 이는 장기 phase(along-track) 오차의 핵심 원인입니다.
278
+
279
+ ---
280
+
281
+ ## How to Use / 사용 방법
282
+
283
+ The repository provides the fine-tuned checkpoint (`ckpt/toto_v2_Toto-2.0-2.5B-001.pt`, ~9.8 GB) and the training / evaluation pipeline (`train/train.py`, `eval/eval.py`, `utils/`). The model is a Toto-2 checkpoint; loading follows the Toto-2 API with the fine-tuned state dict.
284
+
285
+ 이 저장소는 fine-tuning된 체크포인트(`ckpt/toto_v2_Toto-2.0-2.5B-001.pt`, 약 9.8 GB)와 학습/평가 파이프라인(`train/train.py`, `eval/eval.py`, `utils/`)을 제공합니다. 모델은 Toto-2 체크포인트이며, Toto-2 API로 로드한 뒤 fine-tuning된 state dict를 적용합니다.
286
+
287
+ ```python
288
+ import torch
289
+ from toto2 import Toto2Model # Toto-2 modeling code
290
+
291
+ # 1. Load the fine-tuned checkpoint on top of the base architecture
292
+ # 베이스 아키텍처 위에 fine-tuning된 체크포인트를 로드
293
+ model = Toto2Model.from_pretrained("Datadog/Toto-2.0-2.5B")
294
+ ckpt = torch.load("ckpt/toto_v2_Toto-2.0-2.5B-001.pt", map_location="cuda")
295
+ model.load_state_dict(ckpt["model"])
296
+ model = model.to("cuda").eval()
297
+
298
+ # 2. Build the 25-channel daily-grid context for one satellite
299
+ # 위성 1기의 25채널 daily-grid context 구성
300
+ # (see utils/tle_dataset.py: build_daily_series / TLEDatasetV2)
301
+ # target: (B, 25, T) float — 6 orbital + 16 physics + 3 solar channels
302
+ # target_mask: (B, 25, T) bool — orbital channels use the coverage mask
303
+ batch = {
304
+ "target": context.to("cuda"),
305
+ "target_mask": mask.to("cuda"),
306
+ "series_ids": torch.zeros(context.shape[0], 25, dtype=torch.long, device="cuda"),
307
+ }
308
+
309
+ # 3. Forecast 30 days of element drift (quantile output; take the median knot)
310
+ # 30일치 요소 drift 예측 (quantile 출력에서 중앙값 사용)
311
+ q = model.forecast(batch, horizon=30, decode_block_size=None, has_missing_values=True)
312
+ median_idx = model.output_head.knots.index(0.5)
313
+ pred = q[median_idx].float().cpu().numpy() # (B, 25, 30) — use channels 0..5
314
+
315
+ # 4. Reconstruct absolute elements from the last observed TLE anchor,
316
+ # then propagate to a position with SGP4 (see eval/eval.py)
317
+ # 마지막 관측 TLE anchor에서 절대 요소를 복원한 뒤 SGP4로 위치 전파
318
+ from tle_dataset import reconstruct_track
319
+ track = reconstruct_track(anchor_aux, pred[0, :6].T) # per-day absolute elements
320
+ ```
321
+
322
+ End-to-end reproduction (cache build → train → eval) is scripted in `main.sh`:
323
+
324
+ 전체 재현 과정(캐시 생성 → 학습 → 평가)은 `main.sh`에 스크립트로 정리되어 있습니다.
325
+
326
+ ```bash
327
+ # build the 2005-2024 daily-grid cache (downloads SW-All.csv if missing)
328
+ # 2005-2024 daily-grid 캐시 생성 (SW-All.csv가 없으면 자동 다운로드)
329
+ python utils/tle_dataset.py --start-year 2005 --end-year 2024 \
330
+ --sw-csv data/SW-All.csv --cache-dir ./cache --window-patches 3 --min-grid-points 64
331
+
332
+ # fine-tune Toto-2.0-2.5B / Toto-2.0-2.5B fine-tuning
333
+ python train/train.py --cache-file $CACHE --model Datadog/Toto-2.0-2.5B \
334
+ --window-patches 8 --batch-size 16 --lr 4e-5 --max-steps 10000 --warmup 1000 \
335
+ --drift-loss-weight 4.0 --split-mode time \
336
+ --train-until 2022-01-01 --valid-until 2023-01-01
337
+
338
+ # evaluate on the test split (SGP4 reconstruction) / test split 평가 (SGP4 복원)
339
+ python eval/eval.py --cache-file $CACHE --ckpt ./ckpt/toto_v2_Toto-2.0-2.5B-001.pt \
340
+ --model Datadog/Toto-2.0-2.5B --split test --split-mode time --recon sgp4 \
341
+ --context-days 64 --horizon-days 30 --horizons 1 3 7 14 30 \
342
+ --per-sat-samples 50 --max-eval-sats 1500
343
+ ```
344
+
345
+ **Dependencies:** PyTorch, numpy, pandas, sgp4, huggingface_hub, tqdm, plus the Toto-2 modeling code.
346
+
347
+ **의존성:** PyTorch, numpy, pandas, sgp4, huggingface_hub, tqdm, 그리고 Toto-2 모델링 코드.
348
+
349
+ ---
350
+
351
+ ## Intended Use / 사용 목적
352
+
353
+ - **Intended:** research on data-driven orbit prediction; medium-horizon (3–30 day) LEO orbit forecasting studies; conjunction-screening research; TLE data-quality and space-weather-coupling analysis; baseline for satellite time-series foundation-model research.
354
+ - **Out of scope / not intended:** operational collision avoidance or safety-of-flight decisions without independent validation; high-precision ephemeris generation (this is a TLE/SGP4-fidelity model, not a numerical-propagation replacement); non-LEO regimes (MEO/GEO/HEO were filtered out of training); maneuvering-satellite prediction (maneuvers are not modeled).
355
+
356
+ - **권장 용도:** 데이터 기반 궤도 예측 연구; 중기(3–30일) LEO 궤도 예측 연구; conjunction screening 연구; TLE 데이터 품질 및 우주기상 연동 분석; 위성 시계열 파운데이션 모델 연구의 baseline.
357
+ - **비권장 / 범위 외:** 독립 검증 없는 실운용 충돌 회피·비행 안전 의사결정; 고정밀 궤도력(ephemeris) 생성(본 모델은 TLE/SGP4 정확도 수준의 모델이며 수치 전파의 대체재가 아님); 비-LEO 궤도(MEO/GEO/HEO는 학습에서 제외됨); 기동(maneuver) 위성 예측(기동은 모델링되지 않음).
358
+
359
+ ## Limitations / 한계
360
+
361
+ 1. **Short horizons (~1 day):** the SGP4 persistence baseline is already near-optimal; the model provides no meaningful advantage there.
362
+ 2. **Per-satellite variance:** on a minority of satellites the model is worse than the baseline — difficulty varies with orbit regime, TLE quality, drag variability, and maneuver activity.
363
+ 3. **No feature ablation yet:** the reported numbers are for the full model (physics + space-weather features together); the independent contribution of each feature group has not been isolated.
364
+ 4. **True future space weather is unknown at inference time.** Evaluation feeds observed (true) solar context; operational use would require forecast space-weather inputs (or a variant trained without solar channels), which may reduce accuracy.
365
+ 5. **Maneuvers and anomalies** are not modeled; station-keeping or deorbit burns will break the drift assumptions.
366
+ 6. **Truth is TLE-derived:** both training targets and evaluation truth inherit TLE/SGP4 accuracy limits (TLEs themselves carry km-level errors).
367
+
368
+ 1. **초단기 horizon (~1일):** SGP4 persistence baseline이 이미 거의 최적이므로 모델의 이점이 제한적입니다.
369
+ 2. **위성별 편차:** 일부 위성에서는 모델이 baseline보다 성능이 낮습니다 — 궤도 특성, TLE 품질, drag 변동성, 기동 여부에 따라 예측 난이도가 크게 달라집니다.
370
+ 3. **피처 ablation 미수행:** 보고된 수치는 물리 피처와 우주기상 피처를 모두 사용한 최종 모델 기준이며, 각 피처군의 독립적 기여도는 아직 분리 검증되지 않았습니다.
371
+ 4. **추론 시점에 미래 우주기상 값은 알 수 없습니다.** 평가에서는 관측된(실제) solar context를 입력했으나, 실운용에서는 우주기상 예보값(또는 solar 채널 없이 학습한 변형 모델)이 필요하며 이 경우 정확도가 낮아질 수 있습니다.
372
+ 5. **기동과 이상 이벤트**는 모델링되지 않습니다. station-keeping이나 deorbit burn은 drift 가정을 깨뜨립니다.
373
+ 6. **Ground truth가 TLE 기반입니다.** 학습 target과 평가 truth 모두 TLE/SGP4의 정확도 한계(TLE 자체가 km 수준 오차 보유)를 물려받습니다.
374
+
375
+ ## Ethical & Safety Considerations / 윤리·안전 고려사항
376
+
377
+ TLE data is publicly distributed (e.g., CelesTrak / Space-Track) and this model adds no sensing capability beyond it. Nevertheless, orbit predictions should not be used as the sole basis for conjunction-assessment or safety-critical decisions; operational users must validate against authoritative special-perturbation ephemerides.
378
+
379
+ TLE 데이터는 공개적으로 배포되며(CelesTrak / Space-Track 등), 본 모델은 그 이상의 관측 능력을 추가하지 않습니다. 다만 궤도 예측을 conjunction 평가나 안전 관련 의사결정의 단독 근거로 사용해서는 안 되며, 실운용 사용자는 공인된 special-perturbation 궤도력과의 교차 검증을 수행해야 합니다.
380
+
381
+ ---
382
+
383
+ ## Citation / 인용
384
+
385
+ ```bibtex
386
+ @misc{orbitfm2026,
387
+ title = {OrbitFM: TLE-based Satellite Orbit Forecasting by Continued Pretraining of a Time-Series Foundation Model},
388
+ author = {{PCN R\&S}},
389
+ year = {2026},
390
+ note = {Fine-tuned from Datadog Toto-2.0-2.5B on 2005--2024 LEO TLE archives with space-weather channels}
391
+ }
392
+ ```
393
+
394
+ **Base model:** please also cite [Datadog Toto-2](https://huggingface.co/Datadog/Toto-2.0-2.5B).
395
+
396
+ **베이스 모델:** [Datadog Toto-2](https://huggingface.co/Datadog/Toto-2.0-2.5B)도 함께 인용해 주세요.
397
+
398
+ **Acknowledgements:** space-weather data from [CelesTrak](https://celestrak.org/SpaceData/); data-cleaning and feature-engineering design informed by the cm-tle-pred benchmark analysis; TLE parsing/propagation via the [sgp4](https://pypi.org/project/sgp4/) Python package.
399
+
400
+ **감사의 글:** 우주기상 데이터는 [CelesTrak](https://celestrak.org/SpaceData/)에서 제공받았으며, 데이터 정제 및 피처 엔지니어링 설계는 cm-tle-pred 벤치마크 분석을 참고했습니다. TLE 파싱/전파에는 [sgp4](https://pypi.org/project/sgp4/) Python 패키지를 사용했습니다.
ckpt/toto_v2_Toto-2.0-2.5B-001.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:760b4af9910af0f1e03a6e165aa43d0ae6b15c8dfd3aa656a14c5145d03d7728
3
+ size 9817331385
data/SW-All.csv ADDED
The diff for this file is too large to render. See raw diff
 
eval/__pycache__/eval.cpython-312.pyc ADDED
Binary file (27.3 kB). View file
 
eval/eval.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Per-satellite evaluation for the v2 (cleaned + solar) Toto TLE forecaster.
5
+
6
+ Same two metric families as v1, reported PER SATELLITE:
7
+ (1) element RMSE (mean_motion, inclination, eccentricity; mean anomaly / RAAN /
8
+ argp as circular error) vs truth and vs a persistence baseline;
9
+ (2) SGP4 position error (km): predicted elements -> SGP4 -> TEME @ t0+Δ, vs
10
+ truth; baseline = propagate the last observed TLE.
11
+
12
+ Framing: given n=context-days, forecast m=horizon-days. Solar channels are fed
13
+ as true context. For horizons <= patch_size we use a single forward pass
14
+ (decode_block_size=None), so no autoregressive feedback of predicted solar.
15
+
16
+ Run:
17
+ python v2/eval/eval.py --ckpt v2/ckpt/toto_v2_Toto-2.0-4m.pt \
18
+ --model Datadog/Toto-2.0-4m --years 2020 --split all \
19
+ --sw-csv v2/data/SW-All.csv --context-days 64 --horizon-days 30 \
20
+ --horizons 1 3 7 14 30 --max-samples 4000 --out-csv v2/eval_out/per_sat.csv
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import csv
27
+ import math
28
+ import sys
29
+ from collections import defaultdict
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+ import torch
34
+ from tqdm import tqdm
35
+
36
+ UTILS = Path(__file__).resolve().parent.parent / "utils"
37
+ sys.path.insert(0, str(UTILS))
38
+
39
+ from tle_dataset import ( # noqa: E402
40
+ build_daily_series, elements_from_feat_aux, reconstruct_track, sat_split_of,
41
+ N_CHANNELS, N_ORBITAL,
42
+ )
43
+ from tle_future_dataset import parse_date_to_unix # noqa: E402
44
+ from toto2 import Toto2Model # noqa: E402
45
+ from sgp4.api import Satrec, WGS72 # noqa: E402
46
+
47
+ _JD_UNIX_EPOCH = 2440587.5
48
+ _JD_SGP4_EPOCH = 2433281.5
49
+
50
+
51
+ def unix_to_jd(u):
52
+ return u / 86400.0 + _JD_UNIX_EPOCH
53
+
54
+
55
+ def build_satrec(elem, epoch_unix, satnum=99999):
56
+ sat = Satrec()
57
+ sat.sgp4init(
58
+ WGS72, "i", satnum, unix_to_jd(epoch_unix) - _JD_SGP4_EPOCH,
59
+ float(elem["bstar"]), 0.0, 0.0, float(elem["eccentricity"]),
60
+ math.radians(elem["argp_deg"]), math.radians(elem["inclination_deg"]),
61
+ math.radians(elem["mean_anomaly_deg"]),
62
+ elem["mean_motion_rev_per_day"] * 2.0 * math.pi / 1440.0,
63
+ math.radians(elem["raan_deg"]),
64
+ )
65
+ return sat
66
+
67
+
68
+ def propagate(sat, target_unix):
69
+ jd = unix_to_jd(target_unix)
70
+ jd_i = math.floor(jd - 0.5) + 0.5
71
+ e, r, v = sat.sgp4(jd_i, jd - jd_i)
72
+ return None if e != 0 else np.asarray(r, dtype=np.float64)
73
+
74
+
75
+ ELEMS = ["mean_motion_rev_per_day", "inclination_deg", "eccentricity",
76
+ "mean_anomaly_deg", "raan_deg", "argp_deg"]
77
+ CIRC = {"mean_anomaly_deg", "raan_deg", "argp_deg"}
78
+ SHORT = {"mean_motion_rev_per_day": "mm", "inclination_deg": "inc", "eccentricity": "ecc",
79
+ "mean_anomaly_deg": "ma", "raan_deg": "raan", "argp_deg": "argp"}
80
+
81
+
82
+ def elem_err(pred, true, key):
83
+ d = pred[key] - true[key]
84
+ if key in CIRC:
85
+ d = ((d + 180.0) % 360.0) - 180.0
86
+ return abs(d)
87
+
88
+
89
+ def split_of(epoch, tu, vu):
90
+ return "train" if epoch < tu else ("valid" if epoch < vu else "test")
91
+
92
+
93
+ def rmse(xs):
94
+ return float(np.sqrt(np.mean(np.square(xs)))) if len(xs) else math.nan
95
+
96
+
97
+ @torch.no_grad()
98
+ def evaluate(model, series, device, patch_size, n_days, horizons, split, tu, vu,
99
+ stride_days, per_sat_samples, max_eval_sats, batch_sats, recon="integrate",
100
+ dump_k=0, split_mode="time"):
101
+ model.eval()
102
+ m_days = max(horizons)
103
+ L = math.ceil(n_days / patch_size) * patch_size
104
+ median_idx = model.output_head.knots.index(0.5)
105
+ block = None if m_days <= patch_size else patch_size # single forward for short horizons
106
+
107
+ def empty():
108
+ return {"pos_m": [], "pos_b": [], "e_m": defaultdict(list), "e_b": defaultdict(list)}
109
+ per_sat = defaultdict(lambda: {h: empty() for h in horizons})
110
+
111
+ # Group anchors PER satellite so each satellite gets several samples (real
112
+ # per-satellite RMSE), instead of ~1 anchor each after a global subsample.
113
+ by_sat = {}
114
+ for norad, s in series.items():
115
+ T = s.feats.shape[0]
116
+ # satellite-level split: whole satellite belongs to one split (cm-tle-pred)
117
+ if split != "all" and split_mode == "satellite" and sat_split_of(norad) != split:
118
+ continue
119
+ anchors = []
120
+ for a in range(n_days - 1, T - m_days, stride_days):
121
+ if not s.mask[a - n_days + 1: a + 1].all():
122
+ continue
123
+ if not s.mask[a + 1: a + m_days + 1].all():
124
+ continue
125
+ if split != "all" and split_mode == "time" \
126
+ and split_of(float(s.grid_epochs[a]), tu, vu) != split:
127
+ continue
128
+ anchors.append(a)
129
+ if not anchors:
130
+ continue
131
+ if per_sat_samples and len(anchors) > per_sat_samples:
132
+ pick = np.linspace(0, len(anchors) - 1, per_sat_samples).astype(int)
133
+ anchors = [anchors[i] for i in sorted(set(pick))]
134
+ by_sat[norad] = anchors
135
+
136
+ sats = list(by_sat)
137
+ if max_eval_sats and len(sats) > max_eval_sats:
138
+ pick = np.linspace(0, len(sats) - 1, max_eval_sats).astype(int)
139
+ sats = [sats[i] for i in sorted(set(pick))]
140
+ jobs = [(n, a) for n in sats for a in by_sat[n]]
141
+ print(f"[eval] {len(sats)} satellites x up to {per_sat_samples} samples = {len(jobs)} windows")
142
+
143
+ n_fail = 0
144
+ dumps = [] # concrete (truth | model | base) values for sanity-checking magnitudes
145
+ for bstart in tqdm(range(0, len(jobs), batch_sats), desc="eval", unit="batch"):
146
+ batch = jobs[bstart: bstart + batch_sats]
147
+ tgt = torch.zeros(len(batch), N_CHANNELS, L, dtype=torch.float32)
148
+ msk = torch.zeros(len(batch), N_CHANNELS, L, dtype=torch.bool)
149
+ for bi, (norad, a) in enumerate(batch):
150
+ ctx = series[norad].feats[a - n_days + 1: a + 1]
151
+ tgt[bi, :, L - n_days:] = torch.from_numpy(ctx.T.copy())
152
+ msk[bi, :, L - n_days:] = True
153
+ sids = torch.zeros(len(batch), N_CHANNELS, dtype=torch.long)
154
+ q = model.forecast({"target": tgt.to(device), "target_mask": msk.to(device),
155
+ "series_ids": sids.to(device)},
156
+ horizon=m_days, decode_block_size=block, has_missing_values=True)
157
+ pred = q[median_idx].float().cpu().numpy() # (B,C,m_days)
158
+
159
+ for bi, (norad, a) in enumerate(batch):
160
+ s = series[norad]
161
+ anchor_aux, anchor_feat = s.aux[a], s.feats[a]
162
+ t_a = float(s.grid_epochs[a])
163
+ anchor_full = elements_from_feat_aux(anchor_feat, anchor_aux)
164
+ track_m = reconstruct_track(anchor_aux, pred[bi].T[:m_days])
165
+ # persistence baseline: zero drift, ecc/inc held at anchor, no angle drift
166
+ base_orbital = np.array([0.0, 0.0, float(anchor_feat[2]), float(anchor_feat[3]), 0.0, 0.0])
167
+ track_b = reconstruct_track(anchor_aux, np.tile(base_orbital, (m_days, 1)))
168
+ base_sat = build_satrec(anchor_full, t_a)
169
+ for h in horizons:
170
+ j = a + h
171
+ t_j = float(s.grid_epochs[j])
172
+ true_el = elements_from_feat_aux(s.feats[j], s.aux[j])
173
+ pred_el, base_el = track_m[h - 1], track_b[h - 1]
174
+ rec = per_sat[norad][h]
175
+ for k in ELEMS:
176
+ rec["e_m"][k].append(elem_err(pred_el, true_el, k))
177
+ rec["e_b"][k].append(elem_err(base_el, true_el, k))
178
+ r_true = propagate(build_satrec(true_el, t_j), t_j)
179
+ if recon == "sgp4":
180
+ # Drive SGP4 with the model's FORECAST, not just bstar: along-track
181
+ # comes from the model's predicted mean motion, interval-averaged over
182
+ # [t_a, t_j] so it already embodies the drag-driven decay. bstar is set
183
+ # to 0 so SGP4 does not re-apply that decay (double count). Phase stays
184
+ # analytic (SGP4), but now at the model's predicted rate.
185
+ mm_eff = float(np.mean([track_m[k]["mean_motion_rev_per_day"]
186
+ for k in range(h)]))
187
+ model_sat = build_satrec(
188
+ {**anchor_full, "mean_motion_rev_per_day": mm_eff, "bstar": 0.0}, t_a)
189
+ r_model = propagate(model_sat, t_j)
190
+ else: # "integrate": daily trapezoidal phase reconstruction
191
+ r_model = propagate(build_satrec(pred_el, t_j), t_j)
192
+ r_base = propagate(base_sat, t_j)
193
+ if r_true is None or r_model is None or r_base is None:
194
+ n_fail += 1
195
+ continue
196
+ pos_m = float(np.linalg.norm(r_model - r_true))
197
+ pos_b = float(np.linalg.norm(r_base - r_true))
198
+ rec["pos_m"].append(pos_m)
199
+ rec["pos_b"].append(pos_b)
200
+ if len(dumps) < dump_k:
201
+ bstar_model = float(pred_el["bstar"]) # model's forecast bstar
202
+ dumps.append({
203
+ "norad": norad, "h": h, "recon": recon,
204
+ "true": {k: float(true_el[k]) for k in ELEMS},
205
+ "model": {k: float(pred_el[k]) for k in ELEMS},
206
+ "base": {k: float(base_el[k]) for k in ELEMS},
207
+ "bstar_true": float(true_el["bstar"]),
208
+ "bstar_model": float(bstar_model),
209
+ "bstar_base": float(base_el["bstar"]),
210
+ "r_true": r_true.tolist(), "r_model": r_model.tolist(),
211
+ "r_base": r_base.tolist(), "pos_m": pos_m, "pos_b": pos_b,
212
+ })
213
+ model.train()
214
+ return per_sat, len(jobs), n_fail, dumps
215
+
216
+
217
+ def summarize(per_sat, horizons):
218
+ rows = []
219
+ for norad, hd in per_sat.items():
220
+ row = {"norad": norad, "n": 0}
221
+ for h in horizons:
222
+ rec = hd[h]
223
+ row["n"] = max(row["n"], len(rec["pos_m"]))
224
+ row[f"posR_m_{h}"] = rmse(rec["pos_m"]); row[f"posR_b_{h}"] = rmse(rec["pos_b"])
225
+ for k in ELEMS:
226
+ row[f"{SHORT[k]}R_m_{h}"] = rmse(rec["e_m"][k])
227
+ row[f"{SHORT[k]}R_b_{h}"] = rmse(rec["e_b"][k])
228
+ rows.append(row)
229
+ return rows
230
+
231
+
232
+ def main():
233
+ ap = argparse.ArgumentParser()
234
+ ap.add_argument("--ckpt", default=None)
235
+ ap.add_argument("--model", default="Datadog/Toto-2.0-4m")
236
+ ap.add_argument("--input-dir", default="/home/irteam/data-vol1/models/OrbitGPT/data/TLEs")
237
+ ap.add_argument("--cache-dir", default="/home/irteam/data-vol1/models/OrbitGPT/v2/cache")
238
+ ap.add_argument("--cache-file", default=None,
239
+ help="explicit prebuilt cache npz (e.g. the full 2005-2024 superset); "
240
+ "filters by --split")
241
+ ap.add_argument("--sw-csv", default="/home/irteam/data-vol1/models/OrbitGPT/v2/data/SW-All.csv")
242
+ ap.add_argument("--years", type=int, nargs="+", default=[2020])
243
+ ap.add_argument("--split", default="all")
244
+ ap.add_argument("--split-mode", default="time", choices=["time", "satellite"],
245
+ help="time = epoch cutoffs; satellite = cm-tle-pred 70/15/15 by NORAD")
246
+ ap.add_argument("--no-clean", action="store_true")
247
+ ap.add_argument("--no-leo", action="store_true", help="disable LEO-only filter")
248
+ ap.add_argument("--train-until", default="2022-01-01")
249
+ ap.add_argument("--valid-until", default="2023-01-01")
250
+ ap.add_argument("--context-days", type=int, default=64)
251
+ ap.add_argument("--horizon-days", type=int, default=30)
252
+ ap.add_argument("--horizons", type=int, nargs="+", default=[1, 3, 7, 14, 30])
253
+ ap.add_argument("--stride-days", type=int, default=15)
254
+ ap.add_argument("--per-sat-samples", type=int, default=8,
255
+ help="max evaluation anchors per satellite (gives n>1 per-sat RMSE)")
256
+ ap.add_argument("--max-eval-sats", type=int, default=1500,
257
+ help="max number of satellites to evaluate")
258
+ ap.add_argument("--max-satellites", type=int, default=None)
259
+ ap.add_argument("--batch-sats", type=int, default=64)
260
+ ap.add_argument("--recon", default="sgp4", choices=["integrate", "sgp4"],
261
+ help="model position reconstruction: 'integrate' (daily trapezoidal "
262
+ "phase) or 'sgp4' (SGP4 analytic phase + model bstar drag correction)")
263
+ ap.add_argument("--dump-samples", type=int, default=8,
264
+ help="print this many concrete (truth|model|base) rows for sanity-checking")
265
+ ap.add_argument("--device", default="cuda:0")
266
+ ap.add_argument("--out-csv", default="/home/irteam/data-vol1/models/OrbitGPT/v2/eval_out/per_sat.csv")
267
+ ap.add_argument("--show", type=int, default=12)
268
+ args = ap.parse_args()
269
+
270
+ horizons = [h for h in args.horizons if h <= args.horizon_days]
271
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
272
+ model = Toto2Model.from_pretrained(args.model).to(device)
273
+ patch_size = model.config.patch_size
274
+ if args.ckpt:
275
+ sd = torch.load(args.ckpt, map_location=device)
276
+ model.load_state_dict(sd["model"] if "model" in sd else sd)
277
+ print(f"[eval] loaded checkpoint {args.ckpt}")
278
+ else:
279
+ print("[eval] zero-shot pretrained weights")
280
+
281
+ series = build_daily_series(
282
+ args.input_dir, years=args.years, cache_dir=args.cache_dir, cache_file=args.cache_file,
283
+ sw_csv=args.sw_csv, clean=not args.no_clean, leo_only=not args.no_leo,
284
+ min_grid_points=args.context_days + args.horizon_days, verbose=True,
285
+ )
286
+ if args.max_satellites is not None:
287
+ keep = sorted(series.keys())[: args.max_satellites]
288
+ series = {k: series[k] for k in keep}
289
+
290
+ per_sat, n_jobs, n_fail, dumps = evaluate(
291
+ model, series, device, patch_size, args.context_days, horizons, args.split,
292
+ parse_date_to_unix(args.train_until), parse_date_to_unix(args.valid_until),
293
+ args.stride_days, args.per_sat_samples, args.max_eval_sats, args.batch_sats,
294
+ recon=args.recon, dump_k=args.dump_samples, split_mode=args.split_mode,
295
+ )
296
+ print(f"[eval] reconstruction mode: {args.recon}")
297
+ rows = summarize(per_sat, horizons)
298
+ rows.sort(key=lambda r: r["norad"])
299
+ print(f"\n[eval] n={args.context_days}d ctx -> m={args.horizon_days}d | "
300
+ f"samples={n_jobs} satellites={len(rows)} sgp4_fail={n_fail}")
301
+
302
+ hdr = ["norad", "n"]
303
+ for h in horizons:
304
+ hdr += [f"posR_m_{h}", f"posR_b_{h}"]
305
+ for k in ELEMS:
306
+ hdr += [f"{SHORT[k]}R_m_{h}", f"{SHORT[k]}R_b_{h}"]
307
+ Path(args.out_csv).parent.mkdir(parents=True, exist_ok=True)
308
+ with open(args.out_csv, "w", newline="") as f:
309
+ w = csv.DictWriter(f, fieldnames=hdr); w.writeheader()
310
+ for r in rows:
311
+ w.writerow({k: r.get(k, "") for k in hdr})
312
+ print(f"[eval] per-satellite CSV ({len(hdr)} cols) -> {args.out_csv}")
313
+
314
+ print("\nper-satellite position RMSE model/base (km):")
315
+ print(f"{'norad':>7} {'n':>4} " + " ".join(f"{f'{h}d':>15}" for h in horizons))
316
+ for r in rows[: args.show]:
317
+ print(f"{r['norad']:>7} {r['n']:>4} " +
318
+ " ".join(f"{r[f'posR_m_{h}']:>6.0f}/{r[f'posR_b_{h}']:<8.1f}" for h in horizons))
319
+
320
+ hmax = max(horizons)
321
+ print(f"\nper-satellite element RMSE @ {hmax}d (model | baseline):")
322
+ print(f"{'norad':>7} {'n':>4} {'mm(rev/d)':>20} {'ma(deg)':>16} {'inc(deg)':>16} {'ecc':>20}")
323
+ for r in rows[: args.show]:
324
+ print(f"{r['norad']:>7} {r['n']:>4} "
325
+ f"{r[f'mmR_m_{hmax}']:>9.6f}|{r[f'mmR_b_{hmax}']:<10.6f} "
326
+ f"{r[f'maR_m_{hmax}']:>7.2f}|{r[f'maR_b_{hmax}']:<8.2f} "
327
+ f"{r[f'incR_m_{hmax}']:>7.4f}|{r[f'incR_b_{hmax}']:<8.4f} "
328
+ f"{r[f'eccR_m_{hmax}']:>9.6f}|{r[f'eccR_b_{hmax}']:<10.6f}")
329
+
330
+ if dumps:
331
+ print("\nground-truth check — actual values (true | model | base):")
332
+ print(" [recon=sgp4 → position uses ONLY bstar; printed mm/ma/inc/ecc are the "
333
+ "model's element forecast, NOT what drives r_model]")
334
+ for d in dumps:
335
+ t, m, b = d["true"], d["model"], d["base"]
336
+ rt, rm, rb = d["r_true"], d["r_model"], d["r_base"]
337
+ print(f" norad {d['norad']} @{d['h']}d [recon={d['recon']}]")
338
+ print(f" mm(rev/d) true={t['mean_motion_rev_per_day']:.7f} "
339
+ f"model={m['mean_motion_rev_per_day']:.7f} base={b['mean_motion_rev_per_day']:.7f}")
340
+ print(f" ma(deg) true={t['mean_anomaly_deg']:8.3f} "
341
+ f"model={m['mean_anomaly_deg']:8.3f} base={b['mean_anomaly_deg']:8.3f}")
342
+ print(f" inc(deg) true={t['inclination_deg']:8.4f} "
343
+ f"model={m['inclination_deg']:8.4f} base={b['inclination_deg']:8.4f}")
344
+ print(f" ecc true={t['eccentricity']:.7f} "
345
+ f"model={m['eccentricity']:.7f} base={b['eccentricity']:.7f}")
346
+ print(f" bstar true={d['bstar_true']:.6e} "
347
+ f"model={d['bstar_model']:.6e} base={d['bstar_base']:.6e}")
348
+ print(f" |r_true|={math.sqrt(sum(x*x for x in rt)):.1f}km "
349
+ f"pos_err: model={d['pos_m']:.1f}km base={d['pos_b']:.1f}km")
350
+ print(f" r_true =[{rt[0]:9.1f},{rt[1]:9.1f},{rt[2]:9.1f}]")
351
+ print(f" r_model=[{rm[0]:9.1f},{rm[1]:9.1f},{rm[2]:9.1f}]")
352
+ print(f" r_base =[{rb[0]:9.1f},{rb[1]:9.1f},{rb[2]:9.1f}]")
353
+
354
+ def med(key):
355
+ v = np.array([r[key] for r in rows if not math.isnan(r.get(key, math.nan))])
356
+ return np.median(v) if len(v) else math.nan
357
+ def sat_win(mk, bk):
358
+ m = np.array([r[mk] for r in rows]); b = np.array([r[bk] for r in rows])
359
+ ok = ~(np.isnan(m) | np.isnan(b))
360
+ return float(np.mean(m[ok] < b[ok]) * 100.0) if ok.any() else math.nan
361
+
362
+ print("\naggregate over satellites (median per-sat RMSE) | model vs baseline:")
363
+ print(f"{'horizon':>8} {'posKm_m':>9} {'posKm_b':>9} {'satwin%':>7} | "
364
+ f"{'mm_m':>8} {'mm_b':>8} {'ma_m':>8} {'ma_b':>8}")
365
+ for h in horizons:
366
+ print(f"{h:>6}d {med(f'posR_m_{h}'):>9.1f} {med(f'posR_b_{h}'):>9.1f} "
367
+ f"{sat_win(f'posR_m_{h}', f'posR_b_{h}'):>7.1f} | "
368
+ f"{med(f'mmR_m_{h}'):>8.5f} {med(f'mmR_b_{h}'):>8.5f} "
369
+ f"{med(f'maR_m_{h}'):>8.2f} {med(f'maR_b_{h}'):>8.2f}")
370
+
371
+
372
+ if __name__ == "__main__":
373
+ main()
main.sh ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ set -euo pipefail
2
+ cd /home/irteam/data-vol1/models/OrbitFM
3
+
4
+ PY=/home/irteam/.conda/envs/orbit/bin/python
5
+ ROOT=/home/irteam/data-vol1/models/OrbitFM
6
+ SW=$ROOT./data/SW-All.csv
7
+ # CACHE=$ROOT./cache/tle_v4_TLEs_5acc79_2005-2024_20_g1_v5cmtle_feats_c1s1m64L1.npz
8
+
9
+ SPLIT_MODE=time
10
+
11
+ mkdir -p ./cache ./ckpt ./eval_out
12
+
13
+ if [ ! -s "$SW" ]; then
14
+ echo "[0] downloading space weather -> $SW"
15
+ curl -L -o "$SW" https://celestrak.org/SpaceData/SW-All.csv
16
+ fi
17
+
18
+ if [ ! -f "$CACHE" ]; then
19
+ echo "[1] building v4 cache -> $CACHE"
20
+ $PY ./utils/tle_dataset.py \
21
+ --start-year 2005 --end-year 2024 \
22
+ --sw-csv "$SW" --cache-dir ./cache \
23
+ --window-patches 3 --min-grid-points 64
24
+ else
25
+ echo "[1] cache present: $CACHE"
26
+ fi
27
+
28
+
29
+ $PY ./train/train.py --cache-file "$CACHE" --out ./ckpt \
30
+ --model Datadog/Toto-2.0-2.5B --window-patches 8 --batch-size 16 \
31
+ --lr 4e-5 --max-steps 10000 --warmup 1000 --val-every 2000 \
32
+ --drift-loss-weight 4.0 --split-mode "$SPLIT_MODE" \
33
+ --train-until 2022-01-01 --valid-until 2023-01-01
34
+
35
+ $PY ./eval/eval.py --cache-file "$CACHE" \
36
+ --ckpt ./ckpt/toto_v2_Toto-2.0-2.5B.pt --model Datadog/Toto-2.0-2.5B \
37
+ --split test --split-mode "$SPLIT_MODE" --recon sgp4 \
38
+ --context-days 64 --horizon-days 30 --horizons 1 3 7 14 30 60 90 120 \
39
+ --per-sat-samples 50 --max-eval-sats 1500 \
40
+ --out-csv ./eval_out/per_sat_2.5b_test.csv
41
+
42
+ $PY ./eval/eval.py --cache-file "$CACHE" \
43
+ --ckpt ./ckpt/toto_v2_Toto-2.0-2.5B.pt --model Datadog/Toto-2.0-2.5B \
44
+ --split test --split-mode "$SPLIT_MODE" --recon integrate \
45
+ --context-days 64 --horizon-days 30 --horizons 1 3 7 14 30 60 90 120 \
46
+ --per-sat-samples 50 --max-eval-sats 1500 \
47
+ --out-csv ./eval_out/per_sat_2.5b_test_integrate.csv
48
+
49
+ echo "[done] per-satellite CSVs in ./eval_out/"
train/__pycache__/train.cpython-312.pyc ADDED
Binary file (18.3 kB). View file
 
train/train.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Fine-tune Toto-2 on cleaned TLE daily series with space-weather channels (v2).
5
+
6
+ Objective: next-patch quantile (pinball) loss in Toto's asinh-scaled space, the
7
+ same as v1, but the loss is applied ONLY to the orbital channels -- the solar
8
+ channels (F10.7, Ap) are input-only context (LOSS_CHANNEL_MASK), so the model
9
+ uses them to inform drag but is not asked to forecast them.
10
+
11
+ Run (smoke, 4m, one year):
12
+ python v2/train/train.py \
13
+ --years 2020 --model Datadog/Toto-2.0-4m \
14
+ --sw-csv v2/data/SW-All.csv --window-patches 3 \
15
+ --batch-size 64 --max-steps 800
16
+
17
+ Then full all-years:
18
+ python v2/train/train.py --years 2005 ... 2024 --model Datadog/Toto-2.0-2.5B ...
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import dataclasses
25
+ import json
26
+ import math
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+ import torch
32
+ from torch.utils.data import DataLoader
33
+ from tqdm import tqdm
34
+
35
+ UTILS = Path(__file__).resolve().parent.parent / "utils"
36
+ sys.path.insert(0, str(UTILS))
37
+
38
+ from tle_dataset import ( # noqa: E402
39
+ TLEDatasetV2, series_collate_fn, N_CHANNELS, LOSS_CHANNEL_MASK, DRIFT_CHANNELS,
40
+ )
41
+ from toto2 import Toto2Model, Toto2ModelConfig # noqa: E402
42
+
43
+
44
+ def build_model(model_id: str, init: str) -> Toto2Model:
45
+ """Load Toto-2 for (A) continued pretraining from Toto weights, or
46
+ (B) from-scratch pretraining (same architecture, random init)."""
47
+ if init == "pretrained":
48
+ return Toto2Model.from_pretrained(model_id)
49
+ # scratch: fetch only the architecture config, randomly initialize weights
50
+ from huggingface_hub import hf_hub_download
51
+ raw = json.loads(Path(hf_hub_download(model_id, "config.json")).read_text())
52
+ known = {f.name for f in dataclasses.fields(Toto2ModelConfig)}
53
+ cfg = Toto2ModelConfig(**{k: v for k, v in raw.items() if k in known})
54
+ return Toto2Model(cfg)
55
+
56
+
57
+ def quantile_pinball_loss(quantiles, target_scaled, valid, knots):
58
+ pred = quantiles[..., :-1, :] # (Q,B,C,S-1,P) position i predicts i+1
59
+ tgt = target_scaled[..., 1:, :].unsqueeze(0)
60
+ m = valid[..., 1:, :].unsqueeze(0)
61
+ err = tgt - pred
62
+ k = knots.view(-1, 1, 1, 1, 1)
63
+ pin = torch.maximum(k * err, (k - 1.0) * err)
64
+ m = m.expand_as(pin)
65
+ return (pin * m).sum() / m.sum().clamp_min(1.0)
66
+
67
+
68
+ def compute_loss(model, batch, device, patch_size, chan_weight):
69
+ target = batch["target"].to(device) # (B,C,T)
70
+ mask = batch["target_mask"].to(device) # (B,C,T)
71
+ series_ids = batch["series_ids"].to(device)
72
+ cpm_mask = torch.ones_like(mask)
73
+ B, C, T = target.shape
74
+ S = T // patch_size
75
+
76
+ with torch.no_grad():
77
+ scaled, _, _ = model.scaler(target, mask & cpm_mask)
78
+ target_scaled = scaled.asinh().view(B, C, S, patch_size)
79
+ # per-channel loss WEIGHT (float): solar=0, orbital=1, drift channels upweighted.
80
+ # Weighted pinball = sum(w*pin)/sum(w), so the denominator stays a proper mean.
81
+ valid = (mask.float() * chan_weight.to(device).view(1, C, 1)).view(B, C, S, patch_size)
82
+
83
+ out = model.forward(target, mask, cpm_mask, series_ids, num_return_steps=None)
84
+ knots = torch.tensor(model.output_head.knots, device=device, dtype=torch.float32)
85
+ return quantile_pinball_loss(out.quantiles.float(), target_scaled.float(), valid, knots)
86
+
87
+
88
+ def build_loader(args, split, shuffle, verbose):
89
+ ds = TLEDatasetV2(
90
+ input_dir=args.input_dir, cache_dir=args.cache_dir, cache_file=args.cache_file,
91
+ sw_csv=args.sw_csv, years=args.years, patch_size=args.patch_size,
92
+ window_patches=args.window_patches, stride_patches=args.stride_patches,
93
+ split=split, clean=not args.no_clean, leo_only=not args.no_leo,
94
+ split_mode=args.split_mode,
95
+ train_until=args.train_until, valid_until=args.valid_until,
96
+ max_satellites=args.max_satellites, verbose=verbose,
97
+ )
98
+ if len(ds) == 0:
99
+ return None
100
+ return DataLoader(ds, batch_size=args.batch_size, shuffle=shuffle,
101
+ num_workers=args.num_workers, collate_fn=series_collate_fn,
102
+ drop_last=shuffle)
103
+
104
+
105
+ def lr_at(step, base_lr, warmup, max_steps, min_lr, schedule):
106
+ """Linear warmup, then constant or cosine decay to min_lr."""
107
+ if warmup > 0 and step < warmup:
108
+ return base_lr * (step + 1) / warmup
109
+ if schedule == "cosine":
110
+ prog = min(1.0, (step - warmup) / max(1, max_steps - warmup))
111
+ return min_lr + 0.5 * (base_lr - min_lr) * (1.0 + math.cos(math.pi * prog))
112
+ return base_lr
113
+
114
+
115
+ @torch.no_grad()
116
+ def validate(model, loader, device, patch_size, chan_weight, max_batches):
117
+ model.eval()
118
+ tot, n = 0.0, 0
119
+ for i, b in enumerate(loader):
120
+ if i >= max_batches:
121
+ break
122
+ tot += float(compute_loss(model, b, device, patch_size, chan_weight)); n += 1
123
+ model.train()
124
+ return tot / max(1, n)
125
+
126
+
127
+ def main():
128
+ ap = argparse.ArgumentParser()
129
+ ap.add_argument("--input-dir", default="/home/irteam/data-vol1/models/OrbitGPT/data/TLEs")
130
+ ap.add_argument("--cache-dir", default="/home/irteam/data-vol1/models/OrbitGPT/v2/cache")
131
+ ap.add_argument("--cache-file", default=None,
132
+ help="explicit prebuilt cache npz (e.g. the full 2005-2024 superset); "
133
+ "skips parsing, ignores --years/--no-clean/--sw-csv, filters by --split")
134
+ ap.add_argument("--sw-csv", default="/home/irteam/data-vol1/models/OrbitGPT/v2/data/SW-All.csv")
135
+ ap.add_argument("--years", type=int, nargs="+", default=[2020])
136
+ ap.add_argument("--model", default="Datadog/Toto-2.0-4m")
137
+ ap.add_argument("--init", default="pretrained", choices=["pretrained", "scratch"],
138
+ help="pretrained = continue-pretrain from Toto weights (recommended); "
139
+ "scratch = same architecture, random init")
140
+ ap.add_argument("--no-clean", action="store_true")
141
+ ap.add_argument("--no-leo", action="store_true",
142
+ help="disable cm-tle-pred LEO-only filter (affects cache key)")
143
+ ap.add_argument("--split-mode", default="time", choices=["time", "satellite"],
144
+ help="time = epoch cutoffs (forecast-honest); satellite = cm-tle-pred 70/15/15")
145
+ # default context = 8 patches (256 days) for pretraining; pass smaller for quick smokes
146
+ ap.add_argument("--window-patches", type=int, default=8)
147
+ ap.add_argument("--stride-patches", type=int, default=4)
148
+ ap.add_argument("--train-until", default="2022-01-01")
149
+ ap.add_argument("--valid-until", default="2023-01-01")
150
+ ap.add_argument("--max-satellites", type=int, default=None)
151
+ ap.add_argument("--batch-size", type=int, default=64)
152
+ ap.add_argument("--num-workers", type=int, default=4)
153
+ ap.add_argument("--lr", type=float, default=2e-4)
154
+ ap.add_argument("--min-lr", type=float, default=None, help="cosine floor (default lr/10)")
155
+ ap.add_argument("--schedule", default="cosine", choices=["cosine", "constant"])
156
+ ap.add_argument("--weight-decay", type=float, default=0.0)
157
+ ap.add_argument("--drift-loss-weight", type=float, default=4.0,
158
+ help="loss weight multiplier for the position-critical drift channels "
159
+ "(d_bstar, d_mean_motion); 1.0 = uniform (old behavior)")
160
+ ap.add_argument("--warmup", type=int, default=40)
161
+ ap.add_argument("--max-steps", type=int, default=800)
162
+ ap.add_argument("--grad-clip", type=float, default=1.0)
163
+ ap.add_argument("--val-every", type=int, default=250,
164
+ help="run validation every N steps (0 = off). Needs a non-empty "
165
+ "valid split (train_until <= epoch < valid_until).")
166
+ ap.add_argument("--val-batches", type=int, default=20)
167
+ ap.add_argument("--amp", default="bf16", choices=["bf16", "fp32"])
168
+ ap.add_argument("--device", default="cuda:0")
169
+ ap.add_argument("--out", default="/home/irteam/data-vol1/models/OrbitGPT/v2/ckpt")
170
+ args = ap.parse_args()
171
+
172
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
173
+ torch.manual_seed(42)
174
+ if args.min_lr is None:
175
+ args.min_lr = args.lr / 10.0
176
+ print(f"[model] {args.init} init of {args.model}")
177
+ model = build_model(args.model, args.init).to(device)
178
+ patch_size = model.config.patch_size
179
+ args.patch_size = patch_size
180
+ model.train()
181
+ print(f"[model] patch_size={patch_size} params={sum(p.numel() for p in model.parameters())/1e6:.1f}M "
182
+ f"| schedule={args.schedule} lr={args.lr}->{args.min_lr} window={args.window_patches}patch")
183
+
184
+ # per-channel loss weights: orbital=1, solar=0, position-critical drift channels
185
+ # (d_bstar, d_mean_motion) upweighted so the objective tracks SGP4 along-track/drag.
186
+ chan_weight = torch.from_numpy(LOSS_CHANNEL_MASK.astype("float32")).clone()
187
+ for c in DRIFT_CHANNELS:
188
+ chan_weight[c] = chan_weight[c] * args.drift_loss_weight
189
+ print(f"[loss] channel weights = {chan_weight.tolist()} (drift x{args.drift_loss_weight})")
190
+ train_loader = build_loader(args, "train", True, True)
191
+ if train_loader is None:
192
+ print("[data] train split empty -> split=all")
193
+ ds = TLEDatasetV2(input_dir=args.input_dir, cache_dir=args.cache_dir, cache_file=args.cache_file,
194
+ sw_csv=args.sw_csv, years=args.years, patch_size=patch_size,
195
+ window_patches=args.window_patches, stride_patches=args.stride_patches,
196
+ split="all", clean=not args.no_clean, leo_only=not args.no_leo,
197
+ split_mode=args.split_mode,
198
+ max_satellites=args.max_satellites, verbose=True)
199
+ train_loader = DataLoader(ds, batch_size=args.batch_size, shuffle=True,
200
+ num_workers=args.num_workers, collate_fn=series_collate_fn, drop_last=True)
201
+ val_loader = build_loader(args, "valid", False, False) if args.val_every else None
202
+ if args.val_every and val_loader is None:
203
+ print("[valid] no valid-split windows (e.g. single-year smoke) -> validation disabled")
204
+
205
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay, betas=(0.9, 0.95))
206
+ use_amp = args.amp == "bf16" and device.type == "cuda"
207
+
208
+ Path(args.out).mkdir(parents=True, exist_ok=True)
209
+ ckpt = Path(args.out) / f"toto_v2_{Path(args.model).name}.pt"
210
+ best_ckpt = Path(args.out) / f"toto_v2_{Path(args.model).name}_best.pt"
211
+ step, t0 = 0, time.time()
212
+ best_val, last_val = float("inf"), None
213
+ pbar = tqdm(total=args.max_steps, desc="train", unit="step")
214
+ while step < args.max_steps:
215
+ for batch in train_loader:
216
+ if step >= args.max_steps:
217
+ break
218
+ for g in opt.param_groups:
219
+ g["lr"] = lr_at(step, args.lr, args.warmup, args.max_steps, args.min_lr, args.schedule)
220
+ opt.zero_grad(set_to_none=True)
221
+ if use_amp:
222
+ with torch.autocast("cuda", dtype=torch.bfloat16):
223
+ loss = compute_loss(model, batch, device, patch_size, chan_weight)
224
+ else:
225
+ loss = compute_loss(model, batch, device, patch_size, chan_weight)
226
+ loss.backward()
227
+ gn = torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
228
+ opt.step()
229
+ pbar.update(1)
230
+ post = {"loss": f"{float(loss):.4f}", "gnorm": f"{float(gn):.2f}",
231
+ "lr": f"{opt.param_groups[0]['lr']:.1e}"}
232
+ if last_val is not None:
233
+ post["val"] = f"{last_val:.4f}"
234
+ pbar.set_postfix(**post)
235
+
236
+ if val_loader is not None and step > 0 and step % args.val_every == 0:
237
+ last_val = validate(model, val_loader, device, patch_size, chan_weight, args.val_batches)
238
+ improved = last_val < best_val
239
+ if improved:
240
+ best_val = last_val
241
+ torch.save({"model": model.state_dict(), "config": vars(args),
242
+ "step": step, "val_loss": last_val}, best_ckpt)
243
+ pbar.write(f"[valid] step {step:6d} val_loss {last_val:.5f} "
244
+ f"train_loss {float(loss):.5f}{' (best, saved)' if improved else ''}")
245
+ step += 1
246
+ pbar.close()
247
+
248
+ if val_loader is not None:
249
+ last_val = validate(model, val_loader, device, patch_size, chan_weight, args.val_batches)
250
+ print(f"[valid] final val_loss {last_val:.5f} (best {best_val:.5f} -> {best_ckpt.name})")
251
+
252
+ torch.save({"model": model.state_dict(), "config": vars(args)}, ckpt)
253
+ print(f"[done] {step} steps in {time.time()-t0:.1f}s -> {ckpt}")
254
+
255
+
256
+ if __name__ == "__main__":
257
+ main()
utils/__pycache__/space_weather.cpython-312.pyc ADDED
Binary file (5.17 kB). View file
 
utils/__pycache__/tle_clean.cpython-312.pyc ADDED
Binary file (7.2 kB). View file
 
utils/__pycache__/tle_dataset.cpython-312.pyc ADDED
Binary file (35.9 kB). View file
 
utils/space_weather.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Space-weather (solar / geomagnetic activity) features for TLE drag modelling.
5
+
6
+ Atmospheric density -- and therefore drag and the secular decay of mean motion
7
+ -- is driven mostly by solar EUV (tracked by the F10.7 cm radio flux) and
8
+ geomagnetic activity (Ap index). Feeding these as extra input channels gives the
9
+ model the exogenous information it needs to predict how an orbit decays, which is
10
+ exactly where a learned model can beat "hold the last mean motion constant" SGP4
11
+ propagation at multi-day horizons.
12
+
13
+ Data source (download once, no auth):
14
+ https://celestrak.org/SpaceData/SW-All.csv
15
+ Save it to v2/data/SW-All.csv (or pass --sw-csv). The CSV is daily from 1957.
16
+
17
+ Columns used: DATE, F10.7_OBS, F10.7_OBS_CENTER81, AP_AVG.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ SOLAR_FEATURES = ["f107", "f107_81", "ap"]
30
+ N_SOLAR = len(SOLAR_FEATURES)
31
+ SW_URL = "https://celestrak.org/SpaceData/SW-All.csv"
32
+
33
+
34
+ class SpaceWeather:
35
+ """Daily F10.7 / Ap lookup, aligned to arbitrary unix epochs."""
36
+
37
+ def __init__(self, day_unix: np.ndarray, table: np.ndarray):
38
+ self.day_unix = day_unix # (D,) sorted unix seconds at 00:00 UTC
39
+ self.table = table # (D, N_SOLAR) float32
40
+
41
+ @classmethod
42
+ def from_csv(cls, csv_path: str | Path) -> "SpaceWeather":
43
+ df = pd.read_csv(csv_path)
44
+ cols = {c.upper(): c for c in df.columns}
45
+
46
+ def col(*names):
47
+ for nm in names:
48
+ if nm in cols:
49
+ return df[cols[nm]]
50
+ raise KeyError(f"none of {names} in SW csv columns {list(df.columns)}")
51
+
52
+ dates = pd.to_datetime(col("DATE"))
53
+ f107 = pd.to_numeric(col("F10.7_OBS", "F10.7_ADJ"), errors="coerce")
54
+ f107_81 = pd.to_numeric(col("F10.7_OBS_CENTER81", "F10.7_ADJ_CENTER81",
55
+ "F10.7_OBS_LAST81"), errors="coerce")
56
+ ap = pd.to_numeric(col("AP_AVG"), errors="coerce")
57
+
58
+ tab = pd.DataFrame({"f107": f107, "f107_81": f107_81, "ap": ap})
59
+ tab = tab.ffill().bfill() # fill predicted/missing tail+head
60
+ day_unix = np.array(
61
+ [d.replace(tzinfo=timezone.utc).timestamp() for d in dates.dt.to_pydatetime()],
62
+ dtype=np.float64,
63
+ )
64
+ order = np.argsort(day_unix)
65
+ return cls(day_unix[order], tab.to_numpy(dtype=np.float32)[order])
66
+
67
+ def for_epochs(self, epochs_unix: np.ndarray) -> np.ndarray:
68
+ """Return (len(epochs), N_SOLAR) by nearest-preceding-day lookup."""
69
+ idx = np.searchsorted(self.day_unix, epochs_unix, side="right") - 1
70
+ idx = np.clip(idx, 0, len(self.day_unix) - 1)
71
+ return self.table[idx]
72
+
73
+
74
+ def load_space_weather(csv_path: Optional[str | Path]) -> Optional[SpaceWeather]:
75
+ if csv_path is None:
76
+ return None
77
+ p = Path(csv_path)
78
+ if not p.exists():
79
+ print(f"[space_weather] WARNING: {p} not found -> solar channels will be ZERO.\n"
80
+ f" download once: {SW_URL}")
81
+ return None
82
+ sw = SpaceWeather.from_csv(p)
83
+ print(f"[space_weather] loaded {len(sw.day_unix)} daily records from {p}")
84
+ return sw
utils/tle_clean.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Robust outlier removal for per-satellite TLE record sequences.
5
+
6
+ Why: raw TLE archives contain corrupted records (bad element fields, wrong
7
+ epochs, mis-decoded mean anomaly / rev counter). These single-point outliers
8
+ poison linear interpolation onto the daily grid and the angle unwrapping. The
9
+ cm-tle-pred benchmark reports that outlier removal (DBSCAN on 1st/2nd-order
10
+ differences of the elements) gave their single biggest accuracy gain (~2 orders
11
+ of magnitude). We use a cheaper, dependency-free equivalent: flag any record
12
+ whose element deviates from a time-linear interpolation of its neighbors by more
13
+ than ``k_mad`` robust (MAD) scales, on the elements that should evolve smoothly.
14
+
15
+ Cleaning runs on the raw record list BEFORE daily resampling / unwrapping.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import List, Tuple
23
+
24
+ import numpy as np
25
+
26
+ # reuse the parser's record type + helpers from the original code/utils
27
+ _CODE_UTILS = Path(__file__).resolve().parents[2] / "code" / "utils"
28
+ if str(_CODE_UTILS) not in sys.path:
29
+ sys.path.insert(0, str(_CODE_UTILS))
30
+ from tle_future_dataset import TLERecord, signed_log1p # noqa: E402
31
+
32
+ SECONDS_PER_DAY = 86400.0
33
+
34
+
35
+ def cumulative_mean_anomaly(recs: List[TLERecord]) -> np.ndarray:
36
+ """Unwrap mean anomaly into a monotone cumulative phase (deg).
37
+
38
+ Revolution count between epochs is disambiguated with the mean motion
39
+ (rev/day), which is far more reliable than the TLE rev-counter field.
40
+ """
41
+ n = len(recs)
42
+ phi = np.empty(n, dtype=np.float64)
43
+ phi[0] = recs[0].mean_anomaly_deg
44
+ for i in range(1, n):
45
+ dt_days = (recs[i].epoch_unix - recs[i - 1].epoch_unix) / SECONDS_PER_DAY
46
+ n_avg = 0.5 * (recs[i].mean_motion_rev_per_day + recs[i - 1].mean_motion_rev_per_day)
47
+ predicted = phi[i - 1] + n_avg * dt_days * 360.0
48
+ m_i = recs[i].mean_anomaly_deg
49
+ k = round((predicted - m_i) / 360.0)
50
+ phi[i] = 360.0 * k + m_i
51
+ return phi
52
+
53
+
54
+ def _neighbor_interp_resid(t: np.ndarray, x: np.ndarray) -> np.ndarray:
55
+ """Residual of each interior point vs a time-linear interp of its neighbors."""
56
+ resid = np.zeros_like(x, dtype=np.float64)
57
+ if len(x) < 3:
58
+ return resid
59
+ t0, t1, t2 = t[:-2], t[1:-1], t[2:]
60
+ denom = np.where((t2 - t0) == 0, 1.0, (t2 - t0))
61
+ w = (t1 - t0) / denom
62
+ x_hat = x[:-2] + (x[2:] - x[:-2]) * w
63
+ resid[1:-1] = x[1:-1] - x_hat
64
+ return resid
65
+
66
+
67
+ def _mad(v: np.ndarray) -> float:
68
+ v = v[np.isfinite(v)]
69
+ if v.size == 0:
70
+ return 0.0
71
+ med = np.median(v)
72
+ return float(1.4826 * np.median(np.abs(v - med)))
73
+
74
+
75
+ def clean_records(
76
+ recs: List[TLERecord], k_mad: float = 6.0, max_passes: int = 2,
77
+ ) -> Tuple[List[TLERecord], int]:
78
+ """Remove single-point outlier records. Returns (cleaned, n_removed).
79
+
80
+ Flags the union of outliers across the smoothly-evolving quantities:
81
+ mean motion, inclination, eccentricity, bstar(log), and the cumulative /
82
+ unwrapped angles (mean anomaly phase, RAAN, argp).
83
+ """
84
+ recs = sorted(recs, key=lambda r: r.epoch_unix)
85
+ removed = 0
86
+ for _ in range(max_passes):
87
+ n = len(recs)
88
+ if n < 5:
89
+ break
90
+ t = np.array([r.epoch_unix for r in recs], dtype=np.float64)
91
+ series = {
92
+ "mm": np.array([r.mean_motion_rev_per_day for r in recs]),
93
+ "inc": np.array([r.inclination_deg for r in recs]),
94
+ "ecc": np.array([r.eccentricity for r in recs]),
95
+ "bstar": np.array([signed_log1p(r.bstar) for r in recs]),
96
+ "phiM": cumulative_mean_anomaly(recs),
97
+ "raan": np.degrees(np.unwrap(np.radians([r.raan_deg for r in recs]))),
98
+ "argp": np.degrees(np.unwrap(np.radians([r.argp_deg for r in recs]))),
99
+ }
100
+ flag = np.zeros(n, dtype=bool)
101
+ for s in series.values():
102
+ resid = _neighbor_interp_resid(t, s)
103
+ scale = _mad(resid[1:-1])
104
+ if scale > 0:
105
+ flag |= np.abs(resid) > (k_mad * scale)
106
+ flag[0] = flag[-1] = False # keep endpoints (no two-sided neighbors)
107
+ if not flag.any():
108
+ break
109
+ recs = [r for r, bad in zip(recs, flag) if not bad]
110
+ removed += int(flag.sum())
111
+ return recs, removed
utils/tle_dataset.py ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ TLE daily-grid dataset v2: outlier-cleaned + space-weather channels.
5
+
6
+ Builds on the v1 daily-grid cumulative-phase formulation and adds the two
7
+ data-quality / physics levers from the cm-tle-pred benchmark analysis:
8
+
9
+ (1) Robust outlier removal per satellite (tle_clean.clean_records) before
10
+ resampling -- their single biggest accuracy lever.
11
+ (2) Space-weather input channels (F10.7, F10.7-81d, Ap) -- exogenous drivers
12
+ of atmospheric drag, i.e. the secular decay of mean motion.
13
+
14
+ Channels (CHANNEL_NAMES):
15
+ orbital, PREDICTED + in loss (indices 0..5):
16
+ bstar_slog, mean_motion, eccentricity, inclination_deg,
17
+ draan_deg_per_day, dargp_deg_per_day
18
+ solar, INPUT-ONLY context, excluded from loss (indices 6..8):
19
+ f107, f107_81, ap
20
+ Absolute angles [MA, RAAN, argp] are kept as aux (anchor/truth), never predicted.
21
+
22
+ Angles are reconstructed by anchoring at the last observed TLE and integrating
23
+ the predicted rates (reconstruct_track) -- absolute phase is never predicted.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import math
31
+ import sys
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import Any, Dict, Iterable, List, Optional
35
+
36
+ import numpy as np
37
+ import torch
38
+ from torch.utils.data import Dataset
39
+ from tqdm import tqdm
40
+
41
+ _HERE = Path(__file__).resolve().parent
42
+ _CODE_UTILS = _HERE.parents[1] / "code" / "utils"
43
+ for p in (str(_HERE), str(_CODE_UTILS)):
44
+ if p not in sys.path:
45
+ sys.path.insert(0, p)
46
+
47
+ from tle_future_dataset import ( # noqa: E402
48
+ collect_txt_files, iter_tle_pairs_from_txt, parse_tle_pair,
49
+ parse_date_to_unix, signed_log1p, extract_year_from_filename,
50
+ )
51
+ from tle_clean import clean_records, cumulative_mean_anomaly # noqa: E402
52
+ from space_weather import SpaceWeather, load_space_weather, SOLAR_FEATURES, N_SOLAR # noqa: E402
53
+
54
+ # v3: persistence-RESIDUAL (drift) targets.
55
+ #
56
+ # v2 predicted absolute mean_motion / bstar. Because these barely change over the
57
+ # forecast window, the loss-optimal prediction was ≈persistence and the model
58
+ # could not beat SGP4 propagation at short/mid horizons. v3 makes the slowly
59
+ # DRIFTING magnitudes into per-day DELTA channels, so the training target IS the
60
+ # drift (zero-mean, small) — the learning signal we care about is amplified.
61
+ # Absolute mean_motion / bstar at the anchor live in aux, and the trajectory is
62
+ # reconstructed by anchor + cumulative predicted delta (like the angle rates).
63
+ #
64
+ # Eccentricity / inclination drift even less and are well predicted as absolutes,
65
+ # so they stay absolute. RAAN/argp remain secular rate channels (as in v2).
66
+ ORBITAL_NAMES = [
67
+ "d_bstar_slog_per_day", # 0 daily drift of signed_log1p(bstar)
68
+ "d_mean_motion_per_day", # 1 daily drift of mean motion (orbital decay rate)
69
+ "eccentricity", # 2 absolute
70
+ "inclination_deg", # 3 absolute
71
+ "draan_deg_per_day", # 4 RAAN secular rate
72
+ "dargp_deg_per_day", # 5 argp secular rate
73
+ ]
74
+ N_ORBITAL = len(ORBITAL_NAMES)
75
+
76
+ # v4 = cm-tle-pred-style processing. The cm-tle-pred benchmark feeds, on top of the
77
+ # raw elements, a set of engineered physical features. We add the reproducible ones
78
+ # as INPUT-ONLY channels (excluded from the loss), mirroring their feature names:
79
+ # - SAT_RX..VZ : Cartesian ECI state from the day's elements (two-body
80
+ # osculating r,v -- same role as their SGP4 SAT_R*/V*)
81
+ # - SEMIMAJOR_AXIS / PERIOD / APOAPSIS / PERIAPSIS : derived orbit geometry
82
+ # - *_COS / *_SIN : cyclical encodings of the angular elements (MA/RAAN/argp)
83
+ # What is PREDICTED is unchanged (the 6 orbital drift/rate channels) -- this only
84
+ # enriches Toto's context, exactly like the solar channels do.
85
+ PHYS_NAMES = [
86
+ "sat_rx", "sat_ry", "sat_rz", # 6..8 ECI position (km)
87
+ "sat_vx", "sat_vy", "sat_vz", # 9..11 ECI velocity (km/s)
88
+ "semimajor_axis", "period_min", # 12..13 (km), (minutes/rev)
89
+ "apoapsis_alt", "periapsis_alt", # 14..15 altitude (km)
90
+ "ma_cos", "ma_sin", # 16..17 mean anomaly cyclical
91
+ "raan_cos", "raan_sin", # 18..19 RAAN cyclical
92
+ "argp_cos", "argp_sin", # 20..21 argp cyclical
93
+ ]
94
+ N_PHYS = len(PHYS_NAMES)
95
+
96
+ CHANNEL_NAMES = ORBITAL_NAMES + PHYS_NAMES + SOLAR_FEATURES # 6 + 16 + 3 = 25
97
+ N_CHANNELS = len(CHANNEL_NAMES)
98
+ # aux carries the absolute anchors/truth NOT directly predicted:
99
+ # [mean_anomaly, RAAN, argp, mean_motion, bstar_slog]
100
+ N_AUX = 5
101
+ AUX_MA, AUX_RAAN, AUX_ARGP, AUX_MM, AUX_BSTAR = 0, 1, 2, 3, 4
102
+ # orbital channels are prediction targets; PHYS + solar are input-only context.
103
+ LOSS_CHANNEL_MASK = np.array([1] * N_ORBITAL + [0] * (N_PHYS + N_SOLAR), dtype=bool)
104
+ # Position-critical drift channels: d_bstar_slog (0) and d_mean_motion (1). SGP4
105
+ # along-track error is governed by mean motion and drag, so these are upweighted in
106
+ # the training loss (--drift-loss-weight) to align the objective with position.
107
+ DRIFT_CHANNELS = (0, 1)
108
+ SECONDS_PER_DAY = 86400.0
109
+ MU_EARTH = 398600.4418 # km^3/s^2 (WGS-72 gravitational parameter)
110
+ R_EARTH = 6378.137 # km (equatorial radius, for apo/peri altitude)
111
+ LEO_MIN_MEAN_MOTION = 11.25 # rev/day; cm-tle-pred LEO cutoff
112
+ CHANNEL_VERSION = "v5cmtle_feats"
113
+
114
+
115
+ def signed_expm1(x: float) -> float:
116
+ return math.copysign(math.expm1(abs(x)), x)
117
+
118
+
119
+ def _clip_ecc(x):
120
+ return float(min(max(x, 0.0), 0.9999999))
121
+
122
+
123
+ def _clip_inc(x):
124
+ return float(min(max(x, 0.0), 180.0))
125
+
126
+
127
+ def elements_from_feat_aux(feat_row, aux_row) -> Dict[str, float]:
128
+ """Full physical elements for an OBSERVED grid row (truth/anchor).
129
+
130
+ ecc/inc come from the absolute channels; bstar/mean_motion/angles from aux.
131
+ """
132
+ f = np.asarray(feat_row, dtype=np.float64)
133
+ a = np.asarray(aux_row, dtype=np.float64)
134
+ return {
135
+ "bstar": signed_expm1(float(a[AUX_BSTAR])),
136
+ "mean_motion_rev_per_day": float(max(a[AUX_MM], 1e-6)),
137
+ "eccentricity": _clip_ecc(f[2]),
138
+ "inclination_deg": _clip_inc(f[3]),
139
+ "mean_anomaly_deg": float(a[AUX_MA] % 360.0),
140
+ "raan_deg": float(a[AUX_RAAN] % 360.0),
141
+ "argp_deg": float(a[AUX_ARGP] % 360.0),
142
+ }
143
+
144
+
145
+ def reconstruct_track(anchor_aux, pred_seq, grid_step_days=1.0):
146
+ """Absolute elements per forecast day, anchored at the last observed TLE.
147
+
148
+ bstar(log) and mean_motion are integrated from their anchor (aux) by summing
149
+ the predicted per-day DELTAS; mean-anomaly phase is integrated trapezoidally
150
+ from the (reconstructed) mean motion; RAAN/argp from predicted daily rates;
151
+ ecc/inc are read from the absolute channels. Nothing absolute-phase is predicted.
152
+ """
153
+ a = np.asarray(anchor_aux, dtype=np.float64)
154
+ pred = np.asarray(pred_seq, dtype=np.float64)
155
+ ma, raan, argp = a[AUX_MA], a[AUX_RAAN], a[AUX_ARGP]
156
+ mm = float(a[AUX_MM]); bstar_slog = float(a[AUX_BSTAR])
157
+ prev_mm = mm
158
+ out = []
159
+ for h in range(pred.shape[0]):
160
+ bstar_slog += float(pred[h, 0]) * grid_step_days # cumulative d_bstar
161
+ mm += float(pred[h, 1]) * grid_step_days # cumulative d_mean_motion
162
+ mm_h = max(mm, 1e-6)
163
+ ma += 360.0 * 0.5 * (prev_mm + mm_h) * grid_step_days
164
+ prev_mm = mm_h
165
+ raan += float(pred[h, 4]) * grid_step_days
166
+ argp += float(pred[h, 5]) * grid_step_days
167
+ out.append({
168
+ "bstar": signed_expm1(bstar_slog),
169
+ "mean_motion_rev_per_day": mm_h,
170
+ "eccentricity": _clip_ecc(pred[h, 2]),
171
+ "inclination_deg": _clip_inc(pred[h, 3]),
172
+ "mean_anomaly_deg": ma % 360.0,
173
+ "raan_deg": raan % 360.0,
174
+ "argp_deg": argp % 360.0,
175
+ })
176
+ return out
177
+
178
+
179
+ # -----------------------------
180
+ # cm-tle-pred physical features (input-only channels)
181
+ # -----------------------------
182
+
183
+ def _kepler_rv(a, e, inc_r, raan_r, argp_r, M_r):
184
+ """Vectorized two-body osculating ECI state from classical elements.
185
+
186
+ Inputs are numpy arrays (one element per grid day); returns rx,ry,rz (km) and
187
+ vx,vy,vz (km/s). This plays the same role as cm-tle-pred's SGP4 SAT_R*/V*
188
+ Cartesian features but is fully vectorized over the daily grid (no per-day
189
+ sgp4init), which matters for the all-years (~23 GB) build.
190
+ """
191
+ E = np.array(M_r, dtype=np.float64, copy=True)
192
+ for _ in range(12): # Newton on Kepler's equation
193
+ E = E - (E - e * np.sin(E) - M_r) / np.maximum(1.0 - e * np.cos(E), 1e-9)
194
+ cosE, sinE = np.cos(E), np.sin(E)
195
+ r = a * (1.0 - e * cosE)
196
+ nu = np.arctan2(np.sqrt(np.maximum(1.0 - e * e, 0.0)) * sinE, cosE - e)
197
+ cosnu, sinnu = np.cos(nu), np.sin(nu)
198
+ p = np.maximum(a * (1.0 - e * e), 1e-6)
199
+ rp_x, rp_y = r * cosnu, r * sinnu
200
+ vfac = np.sqrt(MU_EARTH / p)
201
+ vp_x, vp_y = -vfac * sinnu, vfac * (e + cosnu)
202
+ co, so = np.cos(raan_r), np.sin(raan_r)
203
+ ci, si = np.cos(inc_r), np.sin(inc_r)
204
+ cw, sw = np.cos(argp_r), np.sin(argp_r)
205
+ # perifocal -> ECI rotation R3(-raan) R1(-inc) R3(-argp)
206
+ R11 = co * cw - so * sw * ci
207
+ R12 = -co * sw - so * cw * ci
208
+ R21 = so * cw + co * sw * ci
209
+ R22 = -so * sw + co * cw * ci
210
+ R31, R32 = sw * si, cw * si
211
+ rx = R11 * rp_x + R12 * rp_y
212
+ ry = R21 * rp_x + R22 * rp_y
213
+ rz = R31 * rp_x + R32 * rp_y
214
+ vx = R11 * vp_x + R12 * vp_y
215
+ vy = R21 * vp_x + R22 * vp_y
216
+ vz = R31 * vp_x + R32 * vp_y
217
+ return rx, ry, rz, vx, vy, vz
218
+
219
+
220
+ def physics_features(g_mm, g_ecc, g_inc_deg, g_raan_deg, g_argp_deg, g_ma_deg):
221
+ """(T, N_PHYS) cm-tle-pred input features from the daily-grid elements."""
222
+ mm = np.maximum(np.asarray(g_mm, dtype=np.float64), 1e-6)
223
+ n_rad_s = mm * 2.0 * math.pi / SECONDS_PER_DAY
224
+ a = (MU_EARTH / (n_rad_s ** 2)) ** (1.0 / 3.0) # semimajor axis (km)
225
+ e = np.clip(np.asarray(g_ecc, dtype=np.float64), 0.0, 0.999999)
226
+ inc_r = np.radians(g_inc_deg)
227
+ raan_r = np.radians(np.asarray(g_raan_deg) % 360.0)
228
+ argp_r = np.radians(np.asarray(g_argp_deg) % 360.0)
229
+ ma_r = np.radians(np.asarray(g_ma_deg) % 360.0)
230
+ rx, ry, rz, vx, vy, vz = _kepler_rv(a, e, inc_r, raan_r, argp_r, ma_r)
231
+ period_min = 1440.0 / mm
232
+ apo = a * (1.0 + e) - R_EARTH # apoapsis altitude (km)
233
+ peri = a * (1.0 - e) - R_EARTH # periapsis altitude (km)
234
+ return np.stack([
235
+ rx, ry, rz, vx, vy, vz,
236
+ a, period_min, apo, peri,
237
+ np.cos(ma_r), np.sin(ma_r),
238
+ np.cos(raan_r), np.sin(raan_r),
239
+ np.cos(argp_r), np.sin(argp_r),
240
+ ], axis=1)
241
+
242
+
243
+ def sat_split_of(norad_id, train_frac=0.70, valid_frac=0.15):
244
+ """cm-tle-pred-style SATELLITE-level split (70/15/15): every record of a given
245
+ satellite lands in the same split, deterministically by NORAD id."""
246
+ h = int(hashlib.md5(str(int(norad_id)).encode()).hexdigest(), 16) % 1000
247
+ if h < int(train_frac * 1000):
248
+ return "train"
249
+ if h < int((train_frac + valid_frac) * 1000):
250
+ return "valid"
251
+ return "test"
252
+
253
+
254
+ # -----------------------------
255
+ # Parse + clean (with progress)
256
+ # -----------------------------
257
+
258
+ def _is_physically_possible(r) -> bool:
259
+ """cm-tle-pred 'data integrity' check: drop physically impossible records."""
260
+ return (0.0 <= r.eccentricity < 1.0
261
+ and 0.0 < r.inclination_deg < 180.0
262
+ and 0.1 < r.mean_motion_rev_per_day < 20.0)
263
+
264
+
265
+ def load_and_clean(
266
+ input_dir, years: Optional[Iterable[int]] = None, clean: bool = True,
267
+ min_records_per_object: int = 2, drop_first_n: int = 5, leo_only: bool = True,
268
+ ) -> Dict[int, List]:
269
+ """Parse raw TLE txt (tqdm over files), group by NORAD, dedup, outlier-clean.
270
+
271
+ v4 adds the cm-tle-pred 'data integrity' steps:
272
+ - drop physically impossible records (impossible ecc/inc/mean_motion),
273
+ - discard the first ``drop_first_n`` TLEs per satellite (least reliable),
274
+ - keep only LEO objects (median mean_motion >= LEO_MIN_MEAN_MOTION) when
275
+ ``leo_only`` is set.
276
+ """
277
+ files = collect_txt_files(input_dir, years=years)
278
+ by_norad: Dict[int, List] = {}
279
+ for path in tqdm(files, desc="parse files", unit="file"):
280
+ year = extract_year_from_filename(path)
281
+ ridx = 0
282
+ for obj, l1, l2, ln in iter_tle_pairs_from_txt(path):
283
+ ridx += 1
284
+ rec = parse_tle_pair(l1, l2, source_file=str(path), source_year=year,
285
+ source_line_number=ln, object_name_raw=obj, record_index=ridx)
286
+ if rec is not None and _is_physically_possible(rec):
287
+ by_norad.setdefault(rec.norad_id, []).append(rec)
288
+
289
+ out: Dict[int, List] = {}
290
+ removed_total = 0
291
+ n_nonleo = 0
292
+ for norad, recs in tqdm(by_norad.items(), desc="dedup+clean", unit="sat"):
293
+ recs = sorted(recs, key=lambda r: r.epoch_unix)
294
+ deduped, seen = [], set()
295
+ for r in recs:
296
+ key = round(r.epoch_unix, 3)
297
+ if key in seen:
298
+ continue
299
+ seen.add(key)
300
+ deduped.append(r)
301
+ if drop_first_n > 0 and len(deduped) > drop_first_n: # least-reliable early TLEs
302
+ deduped = deduped[drop_first_n:]
303
+ if leo_only:
304
+ med_mm = float(np.median([r.mean_motion_rev_per_day for r in deduped])) if deduped else 0.0
305
+ if med_mm < LEO_MIN_MEAN_MOTION:
306
+ n_nonleo += 1
307
+ continue
308
+ if clean and len(deduped) >= 5:
309
+ deduped, nrem = clean_records(deduped)
310
+ removed_total += nrem
311
+ if len(deduped) >= min_records_per_object:
312
+ out[norad] = deduped
313
+ print(f"[load] {len(out)} satellites, removed {removed_total} outlier records, "
314
+ f"dropped {n_nonleo} non-LEO objects (leo_only={leo_only})")
315
+ return out
316
+
317
+
318
+ # -----------------------------
319
+ # Daily-grid series + solar
320
+ # -----------------------------
321
+
322
+ @dataclass
323
+ class DailySeries:
324
+ norad_id: int
325
+ grid_epochs: np.ndarray # (T,)
326
+ feats: np.ndarray # (T, N_CHANNELS)
327
+ mask: np.ndarray # (T,) orbital coverage
328
+ aux: np.ndarray # (T, N_AUX) absolute angles
329
+
330
+
331
+ def _build_one(recs, sw: Optional[SpaceWeather], grid_step_days=1.0) -> Optional[DailySeries]:
332
+ if len(recs) < 2:
333
+ return None
334
+ recs = sorted(recs, key=lambda r: r.epoch_unix)
335
+ ep = np.array([r.epoch_unix for r in recs], dtype=np.float64)
336
+ bstar_log = np.array([signed_log1p(r.bstar) for r in recs])
337
+ mm = np.array([r.mean_motion_rev_per_day for r in recs])
338
+ ecc = np.array([r.eccentricity for r in recs])
339
+ inc = np.array([r.inclination_deg for r in recs])
340
+ raan_uw = np.degrees(np.unwrap(np.radians([r.raan_deg for r in recs])))
341
+ argp_uw = np.degrees(np.unwrap(np.radians([r.argp_deg for r in recs])))
342
+ phiM = cumulative_mean_anomaly(recs)
343
+
344
+ step = grid_step_days * SECONDS_PER_DAY
345
+ t0 = math.floor(ep[0] / step) * step
346
+ t1 = math.ceil(ep[-1] / step) * step
347
+ grid = np.arange(t0, t1 + 0.5 * step, step, dtype=np.float64)
348
+ if grid.size < 2:
349
+ return None
350
+
351
+ def itp(y):
352
+ return np.interp(grid, ep, y)
353
+
354
+ g_raan, g_argp = itp(raan_uw), itp(argp_uw)
355
+ g_mm, g_bstar = itp(mm), itp(bstar_log)
356
+ g_ecc, g_inc, g_ma = itp(ecc), itp(inc), itp(phiM) # g_ma = cumulative MA (deg)
357
+ draan = np.gradient(g_raan) / grid_step_days
358
+ dargp = np.gradient(g_argp) / grid_step_days
359
+ d_bstar = np.gradient(g_bstar) / grid_step_days # daily drift (persistence residual)
360
+ d_mm = np.gradient(g_mm) / grid_step_days
361
+
362
+ orbital = np.stack([d_bstar, d_mm, g_ecc, g_inc, draan, dargp], axis=1)
363
+ # cm-tle-pred input-only physical features (Cartesian r/v, SMA/period/apo/peri,
364
+ # cyclical angle encodings) derived from the day's elements
365
+ phys = physics_features(g_mm, g_ecc, g_inc, g_raan, g_argp, g_ma)
366
+ if sw is not None:
367
+ solar = sw.for_epochs(grid) # (T, N_SOLAR)
368
+ else:
369
+ solar = np.zeros((grid.size, N_SOLAR), dtype=np.float32)
370
+ feats = np.concatenate([orbital, phys, solar], axis=1).astype(np.float32)
371
+
372
+ # aux = [MA, RAAN, argp, mean_motion_abs, bstar_slog_abs]
373
+ aux = np.stack(
374
+ [g_ma % 360.0, g_raan % 360.0, g_argp % 360.0, g_mm, g_bstar], axis=1
375
+ ).astype(np.float32)
376
+
377
+ idx = np.clip(np.searchsorted(ep, grid), 1, len(ep) - 1)
378
+ gap = np.minimum(np.abs(grid - ep[idx]), np.abs(grid - ep[idx - 1]))
379
+ mask = gap <= (2.0 * step)
380
+ return DailySeries(recs[0].norad_id, grid, feats, mask, aux)
381
+
382
+
383
+ def _cache_key(input_dir, years, grid_step_days, clean, with_solar, min_grid_points, leo_only) -> str:
384
+ h = hashlib.md5(str(Path(input_dir).resolve()).encode()).hexdigest()[:6]
385
+ tag = Path(input_dir).resolve().name
386
+ ys = "all" if years is None else f"{min(years)}-{max(years)}_{len(list(years))}"
387
+ flags = f"c{int(clean)}s{int(with_solar)}m{int(min_grid_points)}L{int(leo_only)}"
388
+ return f"{tag}_{h}_{ys}_g{grid_step_days:g}_{CHANNEL_VERSION}_{flags}"
389
+
390
+
391
+ def _load_cache_npz(cache_path, verbose) -> Dict[int, DailySeries]:
392
+ d = np.load(cache_path, allow_pickle=True)
393
+ out = {int(n): DailySeries(int(n), d[f"e_{int(n)}"], d[f"f_{int(n)}"],
394
+ d[f"m_{int(n)}"], d[f"a_{int(n)}"]) for n in d["norads"]}
395
+ if verbose:
396
+ print(f"[cache] {len(out)} satellites loaded from {cache_path}")
397
+ return out
398
+
399
+
400
+ def build_daily_series(
401
+ input_dir, years=None, grid_step_days=1.0, min_grid_points=64,
402
+ clean=True, sw_csv=None, cache_dir=None, cache_file=None, rebuild=False,
403
+ leo_only=True, verbose=True,
404
+ ) -> Dict[int, DailySeries]:
405
+ # Explicit prebuilt cache (e.g. the full 2005-2024 superset): load it directly
406
+ # and let the dataset/eval filter by window + time-split. Skips parsing & solar.
407
+ if cache_file is not None:
408
+ cp = Path(cache_file)
409
+ if not cp.exists():
410
+ raise FileNotFoundError(f"--cache-file not found: {cp}")
411
+ if verbose:
412
+ print(f"[cache] using explicit cache {cp}")
413
+ return _load_cache_npz(cp, verbose)
414
+
415
+ sw = load_space_weather(sw_csv)
416
+ with_solar = sw is not None
417
+ cache_path = None
418
+ if cache_dir is not None:
419
+ cache_dir = Path(cache_dir); cache_dir.mkdir(parents=True, exist_ok=True)
420
+ cache_path = cache_dir / f"tle_v4_{_cache_key(input_dir, years, grid_step_days, clean, with_solar, min_grid_points, leo_only)}.npz"
421
+ if cache_path.exists() and not rebuild:
422
+ if verbose:
423
+ print(f"[cache] loading {cache_path}")
424
+ d = np.load(cache_path, allow_pickle=True)
425
+ out = {int(n): DailySeries(int(n), d[f"e_{int(n)}"], d[f"f_{int(n)}"],
426
+ d[f"m_{int(n)}"], d[f"a_{int(n)}"]) for n in d["norads"]}
427
+ if verbose:
428
+ print(f"[cache] {len(out)} satellites loaded")
429
+ return out
430
+
431
+ by_norad = load_and_clean(input_dir, years=years, clean=clean, leo_only=leo_only)
432
+ out: Dict[int, DailySeries] = {}
433
+ for norad, recs in tqdm(by_norad.items(), desc="daily grid+solar", unit="sat"):
434
+ ser = _build_one(recs, sw, grid_step_days)
435
+ if ser is None or int(ser.mask.sum()) < min_grid_points:
436
+ continue
437
+ out[norad] = ser
438
+
439
+ if cache_path is not None:
440
+ kw: Dict[str, Any] = {"norads": np.asarray(sorted(out.keys()))}
441
+ for n, s in out.items():
442
+ kw[f"e_{n}"] = s.grid_epochs; kw[f"f_{n}"] = s.feats
443
+ kw[f"m_{n}"] = s.mask; kw[f"a_{n}"] = s.aux
444
+ np.savez(cache_path, **kw)
445
+ if verbose:
446
+ print(f"[cache] saved {len(out)} satellites -> {cache_path}")
447
+ return out
448
+
449
+
450
+ # -----------------------------
451
+ # Windowed dataset
452
+ # -----------------------------
453
+
454
+ @dataclass
455
+ class DayWindow:
456
+ norad_id: int
457
+ start: int
458
+ end_epoch: float
459
+
460
+
461
+ class TLEDatasetV2(Dataset):
462
+ def __init__(
463
+ self, input_dir, patch_size, years=None, window_patches=4, stride_patches=2,
464
+ min_valid_steps=None, min_grid_points=None, split="train", train_until="2022-01-01",
465
+ valid_until="2023-01-01", clean=True, sw_csv=None, cache_dir=None,
466
+ cache_file=None, rebuild_cache=False, max_satellites=None,
467
+ grid_step_days=1.0, leo_only=True, split_mode="time", verbose=True,
468
+ ):
469
+ if split not in {"train", "valid", "test", "all"}:
470
+ raise ValueError("split must be train|valid|test|all")
471
+ if split_mode not in {"time", "satellite"}:
472
+ raise ValueError("split_mode must be time|satellite")
473
+ self.patch_size = int(patch_size)
474
+ self.window_length = int(window_patches) * self.patch_size
475
+ self.stride = max(1, int(stride_patches) * self.patch_size)
476
+ self.min_obs = int(min_valid_steps or self.window_length)
477
+ self.split = split
478
+ self.split_mode = split_mode # 'time' (epoch cutoffs) or 'satellite' (cm-tle-pred 70/15/15)
479
+ self.train_until_unix = parse_date_to_unix(train_until)
480
+ self.valid_until_unix = parse_date_to_unix(valid_until)
481
+
482
+ # cache-build satellite filter: keep a satellite if it has at least this many
483
+ # observed grid days. Decoupled from window length so the cache can hold the
484
+ # full population (per-window length is enforced later in _build_index).
485
+ cache_min = int(min_grid_points) if min_grid_points is not None else self.window_length
486
+ self.series = build_daily_series(
487
+ input_dir, years=years, grid_step_days=grid_step_days,
488
+ min_grid_points=cache_min, clean=clean, sw_csv=sw_csv,
489
+ cache_dir=cache_dir, cache_file=cache_file, rebuild=rebuild_cache,
490
+ leo_only=leo_only, verbose=verbose,
491
+ )
492
+ if max_satellites is not None:
493
+ keep = sorted(self.series.keys())[:max_satellites]
494
+ self.series = {k: self.series[k] for k in keep}
495
+ self.index: List[DayWindow] = self._build_index()
496
+ if verbose:
497
+ print(json.dumps({
498
+ "dataset": "TLEDatasetV2", "split": split, "split_mode": split_mode,
499
+ "num_satellites": len(self.series), "num_windows": len(self.index),
500
+ "window_days": self.window_length, "patch_size": self.patch_size,
501
+ "n_channels": N_CHANNELS, "orbital": N_ORBITAL,
502
+ "phys": N_PHYS, "solar": N_SOLAR,
503
+ }, indent=2))
504
+
505
+ def _split_of(self, epoch):
506
+ if self.train_until_unix is None or self.valid_until_unix is None:
507
+ return "all"
508
+ if epoch < self.train_until_unix:
509
+ return "train"
510
+ if epoch < self.valid_until_unix:
511
+ return "valid"
512
+ return "test"
513
+
514
+ def _build_index(self):
515
+ out, W = [], self.window_length
516
+ for norad, s in self.series.items():
517
+ T = s.feats.shape[0]
518
+ if T < W:
519
+ continue
520
+ # satellite-level split: the whole satellite belongs to one split
521
+ if self.split != "all" and self.split_mode == "satellite" \
522
+ and sat_split_of(norad) != self.split:
523
+ continue
524
+ for st in range(0, T - W + 1, self.stride):
525
+ if int(s.mask[st:st + W].sum()) < self.min_obs:
526
+ continue
527
+ end_epoch = float(s.grid_epochs[st + W - 1])
528
+ if self.split != "all" and self.split_mode == "time" \
529
+ and self._split_of(end_epoch) != self.split:
530
+ continue
531
+ out.append(DayWindow(norad, st, end_epoch))
532
+ return out
533
+
534
+ def __len__(self):
535
+ return len(self.index)
536
+
537
+ def __getitem__(self, i):
538
+ w = self.index[i]
539
+ s = self.series[w.norad_id]
540
+ W = self.window_length
541
+ feats = s.feats[w.start:w.start + W]
542
+ mask = s.mask[w.start:w.start + W]
543
+ epochs = s.grid_epochs[w.start:w.start + W]
544
+ aux = s.aux[w.start:w.start + W]
545
+
546
+ # orbital channels use coverage mask; solar channels are always observed
547
+ chan_mask = np.zeros((N_CHANNELS, W), dtype=bool)
548
+ chan_mask[:N_ORBITAL] = mask[None, :]
549
+ chan_mask[N_ORBITAL:] = True
550
+
551
+ return {
552
+ "target": torch.from_numpy(feats.T.copy()), # (C, W)
553
+ "target_mask": torch.from_numpy(chan_mask), # (C, W)
554
+ "series_ids": torch.zeros(N_CHANNELS, dtype=torch.long),
555
+ "meta": {"norad_id": w.norad_id, "length": int(mask.sum()),
556
+ "epochs": epochs.copy(), "feats": feats.copy(), "aux": aux.copy()},
557
+ }
558
+
559
+
560
+ def series_collate_fn(batch):
561
+ return {
562
+ "target": torch.stack([b["target"] for b in batch], 0),
563
+ "target_mask": torch.stack([b["target_mask"] for b in batch], 0),
564
+ "series_ids": torch.stack([b["series_ids"] for b in batch], 0),
565
+ "meta": [b["meta"] for b in batch],
566
+ }
567
+
568
+
569
+ def main():
570
+ import argparse
571
+ from torch.utils.data import DataLoader
572
+ ap = argparse.ArgumentParser()
573
+ ap.add_argument("--input-dir", default="/home/irteam/data-vol1/models/OrbitGPT/data/TLEs")
574
+ ap.add_argument("--cache-dir", default="/home/irteam/data-vol1/models/OrbitGPT/v2/cache")
575
+ ap.add_argument("--sw-csv", default="/home/irteam/data-vol1/models/OrbitGPT/v2/data/SW-All.csv")
576
+ ap.add_argument("--start-year", type=int, default=2020)
577
+ ap.add_argument("--end-year", type=int, default=2020)
578
+ ap.add_argument("--patch-size", type=int, default=32)
579
+ ap.add_argument("--window-patches", type=int, default=3)
580
+ ap.add_argument("--stride-patches", type=int, default=1)
581
+ ap.add_argument("--min-grid-points", type=int, default=64,
582
+ help="cache-build filter: keep satellites with >= this many observed "
583
+ "grid days. Low value (e.g. 64) keeps the full population.")
584
+ ap.add_argument("--no-clean", action="store_true")
585
+ ap.add_argument("--no-leo", action="store_true", help="disable LEO-only filter")
586
+ ap.add_argument("--split", default="all")
587
+ ap.add_argument("--split-mode", default="time", choices=["time", "satellite"])
588
+ args = ap.parse_args()
589
+
590
+ ds = TLEDatasetV2(
591
+ input_dir=args.input_dir, cache_dir=args.cache_dir, sw_csv=args.sw_csv,
592
+ years=range(args.start_year, args.end_year + 1), patch_size=args.patch_size,
593
+ window_patches=args.window_patches, stride_patches=args.stride_patches,
594
+ min_grid_points=args.min_grid_points, clean=not args.no_clean,
595
+ leo_only=not args.no_leo, split=args.split, split_mode=args.split_mode,
596
+ )
597
+ if len(ds) == 0:
598
+ print("No windows."); return
599
+ b = next(iter(DataLoader(ds, batch_size=4, shuffle=True, collate_fn=series_collate_fn)))
600
+ print("target", tuple(b["target"].shape), "obs frac", round(float(b["target_mask"].float().mean()), 3))
601
+ print("channels:", N_CHANNELS, "= orbital", N_ORBITAL, "+ phys", N_PHYS, "+ solar", N_SOLAR)
602
+ m0 = b["meta"][0]
603
+ print("phys[t0] (rx,ry,rz,vx,vy,vz,...):",
604
+ [round(float(x), 3) for x in m0["feats"][0][N_ORBITAL:N_ORBITAL + N_PHYS]])
605
+ print("solar[t0] (f107,f107_81,ap):", m0["feats"][0][N_ORBITAL + N_PHYS:].tolist())
606
+ track = reconstruct_track(m0["aux"][0], m0["feats"][1:6])
607
+ print("reconstructed[+5d]:", json.dumps(track[-1], indent=2))
608
+ print("true[+5d]:", json.dumps(elements_from_feat_aux(m0["feats"][5], m0["aux"][5]), indent=2))
609
+
610
+
611
+ if __name__ == "__main__":
612
+ main()