Upload 14 files
Browse files- README.md +397 -0
- ckpt/toto_v2_Toto-2.0-2.5B-001.pt +3 -0
- data/SW-All.csv +0 -0
- eval/__pycache__/eval.cpython-312.pyc +0 -0
- eval/eval.py +373 -0
- main.sh +49 -0
- train/__pycache__/train.cpython-312.pyc +0 -0
- train/train.py +257 -0
- utils/__pycache__/space_weather.cpython-312.pyc +0 -0
- utils/__pycache__/tle_clean.cpython-312.pyc +0 -0
- utils/__pycache__/tle_dataset.cpython-312.pyc +0 -0
- utils/space_weather.py +84 -0
- utils/tle_clean.py +111 -0
- utils/tle_dataset.py +612 -0
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()
|