獨立章節 3 — 雙節運載火箭入軌與交會迴避(AdvCAT)
launch_conjunction/LaunchConjunction_Lab_Handout.md
實驗單 — 雙節運載火箭入軌與交會迴避
獨立章節,不屬於 16 週立方衛星課程。
先讀 LaunchConjunction_Primer.md,再做本實驗。
工具:STK LaunchVehicle + AdvCAT + Python
目錄:CelesTrak active TLE(建場景時自動下載,16,052 個物體)
0. 情境
從東部海岸發射一枚雙節運載火箭,把酬載送進 LEO,然後篩三天的交會。
| 物件 | 設定 |
|---|---|
LaunchSite |
24.00°N, 121.00°E |
Ascent |
Simple Ascent,燒盡速度 7.65 km/s、燒盡點 34.00°N 116.00°E / 700 km,上升 600 s |
Payload |
分離即入軌:初始狀態 = 燒盡時的狀態向量 |
ConjScreen |
AdvCAT,門檻 25 km,PntToPnt On,AllowPartialEphem On |
| 分析窗 | 2026-08-28 → 2026-08-31(3 天) |
場景時間必須與 TLE 曆元同期。 這不是美觀問題:差兩年的話,
AdvCAT 會把每個物體標成 Vehicle Out of Date,然後回報 0 筆事件。
1. 執行步驟
1. 開啟 STK 12 桌面版(或 Engine,本章沒有繪圖指令)
2. python lc_build.py 建場景 + 下載目錄 + 燒盡狀態餵給酬載
3. python lc_conjunction.py Part A-D + 圖
4. python lc_verify.py 驗證段(離開碼 0 = 26 項全通過)
lc_build.py 會先送 launch_conjunction_Connect.txt(20 條指令),
再做兩件 Connect 做不到的事:把燒盡狀態向量寫進酬載、取得 TLE 目錄。
2. Part A — 入軌過程(佔 20%)
基準值:
| 量 | 值 |
|---|---|
| 上升段 | 600 s,0 → 700.0 km |
| 燒盡狀態 | |r| = 7071.49 km,|v| = 7.4795 km/s |
| 解出的軌道 | a = 7018.616 km,e = 0.007533,i = 108.243°,RAAN = 107.067° |
| 近地點 / 遠地點高度 | 587.6 km / 693.3 km |
| 自算 vs STK | a 差 6e-6 km,e 差 8e-10,i 與 RAAN 差 2e-9° |
要回答的問題:
- 自己算的根數與 STK 差到機器精度。這證明了什麼?沒有證明什麼?
- 把燒盡速度改成 7.55 / 7.75 / 7.85 km/s,近地點各是多少?先預測再跑。
為什麼入軌對速度這麼敏感? - 近地點 587.6 km 判為「有效入軌」。這個門檻(200 km)合理嗎?
一顆沒有推進的 6U 在 587 km 能撐多久?
3. Part B — AdvCAT 交會篩選(佔 25%)
基準值(3 天、門檻 25 km):
| 量 | 值 |
|---|---|
| 交會事件數 | 99 |
| 最接近的一筆 | SSC 43005,最小距離 1.830 km |
| 該筆的相對速度 | 14.35 km/s |
| 該筆的分量 | 徑向 1.748 / 沿軌 −0.161 / 越軌 0.516 km |
| Compute 時間 | 1.1 秒(16,052 個物體) |
要回答的問題:
- 1.1 秒處理 16,052 個物體。前置濾波器做了什麼?
把PreFilters全部關掉再跑一次,時間變多少?事件數變多少? - 最近的一筆分量是徑向 1.75 km、越軌 0.52 km、沿軌只有 0.16 km。
這個形狀說明了什麼?(提示:兩條軌道的關係) - 為什麼本章要設
PntToPnt On?不設會拿到什麼?那個數字為什麼沒有意義?
4. Part C — 獨立驗證(佔 30%,本章重點)
先示範「均勻粗掃」為什麼無效,再在事件附近細掃。
基準值:
| 方法 | 取樣 | 結果 |
|---|---|---|
| 均勻粗掃 | 30 s 步長,8621 點 | 最小距離 19.4 km @ t = 113400 s |
| 事件附近細掃 | ±30 s,0.05 s 步長,1202 點 | 最小距離 1.854 km @ t = 101694.900 s |
| AdvCAT | — | 最小距離 1.830 km @ t = 101694.879 s |
差 0.024 km(1.30%),時刻差 0.021 s。
要回答的問題:
- 相對速度 14.35 km/s、粗掃步長 30 s。一步走多遠?
要用均勻網格找到 1.8 km 的接近,步長最多多大?總共要幾個取樣點? - 細掃的結果與 AdvCAT 差 1.3%。這個差來自哪裡?
- 這一步驗證了什麼、沒有驗證什麼?
(提示:兩邊都用 STK 的 SGP4) - 若要真正獨立地驗證傳播器,你要換掉什麼?會遇到什麼座標系問題?
26 年的歲差在 7000 km 半徑上是多少公里?跟 1.8 km 比呢?
5. Part D — 入軌高度掃描(佔 15%)
基準值(3 天、門檻 25 km、i 與 RAAN 固定):
| 入軌高度 | 交會事件數 | 最小距離 | 5 km 內的事件 |
|---|---|---|---|
| 500 km | 644 | 1.744 km | 8 |
| 600 km | 124 | 1.527 km | 2 |
| 700 km | 21 | 0.349 km | 2 |
| 800 km | 11 | 5.117 km | 0 |
| 900 km | 11 | 7.347 km | 0 |
要回答的問題:
- 500 km 的事件數是 800 km 的 59 倍。為什麼是 500 km?查一下那個高度有什麼。
- 700 km 的事件數只有 21 筆,最小距離卻是全部裡最近的(0.349 km)。
「事件少」與「安全」是同一件事嗎? 你會用哪一個當設計判準? - 再加一個維度:固定高度 700 km,掃 RAAN(0° / 90° / 180° / 270°)。
事件數變化多少?這告訴你什麼?
6. 驗證段(佔 10%)
lc_verify.py,26 項全部 PASS。本章特有的是第 4 組:輸入資料的可信度。
| 檢查組 | 內容 |
|---|---|
| 1 結構 | 物件存在、發射場位置 |
| 2 入軌 | 兩條獨立路徑算出的根數要一致 |
| 3 入軌判定 | 近地點 / 遠地點、有效入軌 |
| 4 輸入資料 | 目錄存在、物體數 > 10000、中位曆元距今 < 30 天、場景時間與曆元同期 |
| 5 AdvCAT 設定 | 門檻、PntToPnt、AllowPartialEphem |
| 6 事件數與最近距離 | 對照基準 |
| 7 交叉驗證 | 自算與 AdvCAT 一致;且均勻粗掃找不到(反面教材) |
要回答的問題:
- 為什麼「TLE 曆元的新鮮度」要當成一個檢查項?不查會怎樣?
- 驗證段有一項是「均勻粗掃找不到這個事件」。
為什麼要把一個失敗寫成通過條件? - 自己加一項:確認
Errors資料提供者裡沒有Vehicle Out of Date。
7. 評分
| 項目 | 比重 |
|---|---|
| Part A 入軌與根數驗證 | 20% |
| Part B AdvCAT 篩選與解讀 | 25% |
| Part C 獨立驗證與取樣策略 | 30% |
| Part D 高度掃描與架構決策 | 15% |
| 驗證段 | 10% |
加分:用 Python 的 sgp4 套件獨立傳播 SSC 43005,自行處理 TEME → ICRF
的轉換,再算一次最小距離。說明你的轉換誤差有多大,以及它是否小到足以
驗證 1.8 km 的接近。
8. 檔案
| 檔案 | 內容 |
|---|---|
LaunchConjunction_Primer.md |
基礎知識導論 |
launch_conjunction_Connect.txt |
Connect 腳本(20 條) |
lc_build.py |
建場景 + 燒盡狀態 + TLE 目錄 |
lc_conjunction.py |
Part A–D |
lc_verify.py |
驗證段(26 項) |
lc_conjunction.png |
上升剖面、獨立掃描、高度擁擠度 |
celestrak_active.tle |
下載的目錄(不進版控) |
LaunchConjunction_Primer.md
基礎知識導論 — 運載火箭入軌與交會迴避
本章是獨立章節,不屬於 16 週立方衛星課程。
主題:把酬載送上去,然後確認它不會撞到別人。
1. 這一章在解什麼問題
一枚雙節運載火箭把酬載送進 LEO。兩個問題:
- 入軌成功了嗎? —— 燒盡時的狀態向量隱含了一條軌道,近地點夠不夠高?
- 這條軌道會不會撞到別人? —— 拿當前的太空物體目錄篩一遍。
第二個問題現在是發射前的法定程序,不是可選項。
LEO 已經有一萬六千多個在編目物體,而且大部分集中在幾個高度殼層。
參考:STK Help 的 AdvCAT 範例與 ACAT Connect 指令族。
2. 核心觀念
2.1 入軌:燒盡狀態向量就是一切
上升段的內彈道(推力、比衝、分節)決定了燒盡時刻的狀態向量
(r, v)。從那一刻起,酬載就只是一個受重力支配的物體 ——
入軌成不成功、之後會不會撞到別人,全部由那六個數決定。
從狀態向量解出經典根數是標準的教科書運算:
比角動量 h = r x v
比能量 E = |v|²/2 - mu/|r|
半長軸 a = -mu / (2E)
離心率向量 e = ( (|v|² - mu/|r|) r - (r . v) v ) / mu
傾角 i = acos( h_z / |h| )
本章實測:自己算出來的根數與 STK 的差
a 差 6e-6 km、e 差 8e-10、i 與 RAAN 差 2e-9 度 ——
兩條路徑吻合到機器精度。這是最乾淨的一種驗證:
如果對不上,幾乎一定是單位或座標系,不是物理。
2.2 入軌判定:近地點高度
近地點高度 hp = a(1-e) - Re
hp 太低,大氣阻力會在幾天到幾週內把它拉下來。
本章取 200 km 當「有效入軌」的門檻。
燒盡速度對軌道形狀的影響非常敏感 —— 本章實測(燒盡點固定在 700 km):
| 燒盡速度 | a | e | 近地點 | 遠地點 |
|---|---|---|---|---|
| 7.55 km/s | 6839.9 km | 0.0339 | 230 km | 693 km |
| 7.65 km/s | 7018.6 km | 0.0075 | 588 km | 693 km |
| 7.75 km/s | 7209.5 km | 0.0192 | 693 km | 969 km |
| 7.85 km/s | 7413.9 km | 0.0462 | 693 km | 1378 km |
差 0.1 km/s(1.3%),近地點差 358 km。
入軌是一個對速度極度敏感的問題,這就是為什麼運載火箭要做閉迴路導引。
2.3 交會分析:一個「找針」的問題
交會分析要在三天、一萬六千個物體裡,找出幾公里的接近。難點不是物理,是搜尋:
- 典型的相對速度是 10–15 km/s
- 一次「接近」的持續時間只有幾秒
- 用固定網格取樣是找不到的:30 秒的間隔等於 430 公里的位移
所以流程一定是兩階段:
前置濾波 (prefilter) 用粗略判據刷掉不可能交會的物體
遠近地點區間沒重疊、軌道路徑距離太遠、時間上不可能、曆元太舊
↓
細化搜尋 (refinement) 對剩下的候選,用自適應步長找真正的最小距離
本章實測:AdvCAT 用這個流程在 1.1 秒內處理完 16,052 個物體,
找出 99 筆門檻內的接近事件。
而一個 30 秒間隔的均勻粗掃(8621 個取樣點)只找到 19.4 km ——
完全錯過那筆 1.8 km 的接近。
2.4 距離 vs 誤差橢球
AdvCAT 有兩種判據:
| 判據 | 意義 | 需要什麼 |
|---|---|---|
| 距離 (PntToPnt) | 兩點之間的幾何距離 | 只要星曆 |
| 誤差橢球 | 考慮定軌不確定度的橢球間距 | 共變異數 |
TLE 沒有共變異數。用預設橢球尺寸算出來的「橢球距離」,
那個尺寸跟你的實際定軌精度毫無關係 —— 是一個沒有物理意義的數字。
所以本章明確設 PntToPnt On,只用距離。
這也決定了本章結論的天花板:能講「哪裡比較擠」,不能講「碰撞機率是多少」。
2.5 碰撞機率需要什麼
真正的迴避決策看的是碰撞機率 Pc,而 Pc 需要:
- 兩個物體在最接近時刻的位置共變異數
- 兩者的硬體半徑
- 相對速度與接近幾何
實務上這些來自 CDM(Conjunction Data Message),由具備定軌能力的機構發布,
不是從 TLE 算得出來的。AdvCAT 的報表裡確實有
Collision Probability (Analytic) 等欄位,但沒有共變異數時那些欄位沒有意義。
這是本章最重要的界線:換演算法解決不了資料的問題。
2.6 目錄的兩個致命問題
交會分析的結論不會比它的目錄更可信。目錄有兩個致命問題:
- 過期。TLE 的精度隨曆元距離衰減;LEO 幾天就會明顯偏離。
本章實測踩過:場景設在 2024 年、目錄是 2026 年的,
AdvCAT 把每個物體標成Vehicle Out of Date,
然後回報 0 筆事件 —— 而且 Compute 只花 0.2 秒就「完成」。
看起來就像是真的很安全。 - 不完整。公開目錄不含所有物體(小碎片、未編目物體、部分軍用物體)。
所以本章的驗證段有一組別章沒有的檢查:輸入資料本身的可信度。
2.7 擁擠度隨高度的分布
本章實測(3 天、門檻 25 km、i = 108.2°):
| 入軌高度 | 交會事件數 | 最小距離 | 5 km 內 |
|---|---|---|---|
| 500 km | 644 | 1.744 km | 8 |
| 600 km | 124 | 1.527 km | 2 |
| 700 km | 21 | 0.349 km | 2 |
| 800 km | 11 | 5.117 km | 0 |
| 900 km | 11 | 7.347 km | 0 |
500 km 的事件數是 800 km 的 59 倍。原因不神秘:
那是巨型星座的殼層。軌道高度的選擇現在是一個交通問題,不只是任務問題。
但要注意:700 km 的事件數少,最小距離卻是全部裡最近的(0.349 km)。
「事件少」不等於「安全」 —— 這兩個指標回答的是不同問題。
3. 名詞與符號
| 符號 | 意義 | 單位 | 本章值 |
|---|---|---|---|
hp / ha |
近地點 / 遠地點高度 | km | 587.6 / 693.3 |
a, e, i |
半長軸、離心率、傾角 | km, —, ° | 7018.6, 0.0075, 108.24 |
| TCA | 最接近時刻 | — | Time of Closest Approach |
| TMS | 最小距離時刻 | — | Time at Min Separation |
| Min Range | 幾何最小距離 | km | 最近 1.830 |
| Min Separation | 橢球最小距離 | km | PntToPnt 時等於 Min Range |
| 相對速度 | 最接近時的相對速度 | km/s | 14.35 |
| SSC | 太空目錄編號 | — | 最近的一筆是 43005 |
Pc |
碰撞機率 | — | 本章算不出來(無共變異數) |
4. 觀念 → 本章實驗對照
| 觀念 | 出現在哪裡 |
|---|---|
| 上升段、酬載、AdvCAT 物件 | launch_conjunction_Connect.txt |
| 燒盡狀態 → 酬載初始狀態 | lc_build.py(Connect 做不到) |
| 狀態向量 → 根數(獨立驗證) | lc_build.elements_from_state() |
| TLE 目錄的取得與新鮮度 | lc_build.get_catalogue() |
| 交會篩選與排序 | lc_conjunction.py Part B |
| 均勻網格為何無效 + 細掃驗證 | 同上 Part C |
| 高度掃描 | 同上 Part D |
| 輸入資料可信度的檢查 | lc_verify.py(26 項全 PASS) |
5. 本章踩到的坑
-
場景時間與 TLE 曆元差兩年 → 0 筆事件,而且 Compute 只花 0.2 秒。
AdvCAT 不會抱怨,它的Errors資料提供者才會說
Vehicle Out of Date. Epoch is …。要主動去讀那個報表。 -
酬載的星曆從分離時刻(t = 600 s)才開始,不涵蓋整個分析區間。
不開AllowPartialEphem On,AdvCAT 會把主要物件標成錯誤,一筆都不算。
症狀跟上一條一樣:0 筆事件,計算「成功」。 -
ACAT … PreFilters OutOfDate 30.0會 NACK。
文件寫「1 至 3650 天」,但值是用 Connect 時間單位(秒)輸入的 ——
要寫2592000。文件的單位敘述與實際輸入單位不一致。 -
Missile/LaunchVehicle的燒盡狀態沒有辦法用 Connect 餵給衛星。
Connect 只能設常數,不能把 A 的計算結果餵給 B。要回讀資料提供者再寫回去。 -
AdvCAT 的資料提供者要用場景時間格式。 這個場景的時間格式是 EpSec,
傳日期字串進去會拿到Invalid StartTime value。 -
沒有
ACAT_R。 設定值要從物件模型讀(acat.Threshold、
acat.UseRangeMeasure、acat.Advanced.AllowPartialEphemeris),
或從General Info資料提供者讀。
6. 延伸閱讀
交會與太空交通管理
- CelesTrak — TLE 目錄的主要公開來源,以及大量技術專欄。
- CelesTrak 技術專欄 — TLE 該怎麼用、不該怎麼用。
- Space-Track.org — 美國官方目錄與 CDM(需註冊)。
- Space-Track API 文件
- NASA 軌道碎片計畫辦公室 — 碎片環境模型與季刊。
- ESA Space Debris — 年度太空環境報告。
- IADC — 機構間太空碎片協調委員會。
軌道力學
- Vallado, "Revisiting Spacetrack Report #3" (AIAA 2006-6753)
— SGP4 的權威說明與參考實作。 - Braeunig, Rocket & Space Technology
— 上升軌跡與狀態向量到根數的完整推導。 - Wikipedia: Gravity turn
- 教科書:Vallado, Fundamentals of Astrodynamics and Applications
(第 11 章講交會與碰撞機率);Curtis, Orbital Mechanics for Engineering Students。
工具
- Ansys STK 線上說明 — AdvCAT。
Connect 指令參考在本機:
C:\Program Files\AGI\STK 12\Help\Programming\Subsystems\connectCmds\Content\
(cmd_ACAT.htm、cmd_ACatProbability.htm、cmd_SetStateTLESatellites.htm、
cmd_SetStateSimpleAscentLaunchVehicle.htm)。 - NASA GMAT — 可用來對答案。
- sgp4 (PyPI) — 若要做真正獨立的傳播器驗證。
7. 課前自我檢核
- 燒盡速度差 1.3%,近地點差多少?為什麼入軌對速度這麼敏感?
- 相對速度 14 km/s、最小距離 1.8 km。要用固定網格找到這個事件,
取樣間隔最多能多大?為什麼實務上不這樣做? - AdvCAT 回報「0 筆交會事件」。列出三個你要先排除的可能原因。
- 為什麼 TLE 算不出碰撞機率?缺的是什麼?那個東西從哪裡來?
- 500 km 的交會事件是 800 km 的 59 倍,但 700 km 的最小距離最近。
這兩件事矛盾嗎?你會用哪一個當設計判準?
圖

launch_conjunction/lc_anim.gif

launch_conjunction/lc_conjunction.png
程式碼
# ==============================================================================
# 雙節運載火箭入軌 + 交會迴避分析 — Connect 腳本
#
# 這支腳本負責 Connect 做得到的部分:發射載具的上升段、酬載衛星、AdvCAT 物件。
#
# Connect 做不到 / 不該做的部分留給 lc_build.py:
# 1. 從發射載具的燒盡狀態取出位置速度,拿去設定酬載衛星的初始狀態
# (要回讀資料提供者,Connect 沒有「把 A 的狀態餵給 B」的指令)
# 2. TLE 目錄的取得與更新
#
# 執行:python lc_build.py
# ==============================================================================
# ---- 場景 --------------------------------------------------------------------
New / Scenario LaunchConjunction
SetAnalysisTimePeriod * "28 Aug 2026 00:00:00.000" "31 Aug 2026 00:00:00.000"
SetUnits / km
# ---- 發射場 ------------------------------------------------------------------
New / */Facility LaunchSite
SetPosition */Facility/LaunchSite Geodetic 24.00 121.00 0.0
# ---- 雙節運載火箭的上升段 ----------------------------------------------------
# Simple Ascent 用「發射點 -> 燒盡點 + 燒盡速度」描述整個上升軌跡。
# 它不解內彈道,但給出的是**入軌時刻的狀態向量** —— 而入軌成不成功、
# 之後會不會撞到別人,靠的正是那一組狀態向量,不是燃燒室壓力。
#
# 燒盡速度 7.65 km/s、燒盡高度 700 km,燒盡點選在發射場西北方,
# 使軌道傾角落在太陽同步的區間(這是 LEO 最擁擠的地方,交會分析才有東西看)。
New / */LaunchVehicle Ascent
SetState */LaunchVehicle/Ascent SimpleAscent "28 Aug 2026 00:00:00.000" "28 Aug 2026 00:10:00.000" 5.0 Geodetic 24.00 121.00 0.0 7.65 34.00 116.00 700.0
# ---- 酬載衛星 ----------------------------------------------------------------
# 先建一個佔位狀態;真正的初始狀態由 lc_build.py 從燒盡狀態填進來。
New / */Satellite Payload
SetState */Satellite/Payload Classical TwoBody "28 Aug 2026 00:00:00.000" "31 Aug 2026 00:00:00.000" 10 ICRF "28 Aug 2026 00:00:00.000" 7078.14 0.0 98.2 0.0 0.0 0.0
# ---- AdvCAT:進階交會分析 ----------------------------------------------------
# AdvCAT 物件預設不在插入清單裡,但 Connect 可以直接建。
New / */AdvCAT ConjScreen
# 主要清單:我們的酬載。次要清單由 lc_build.py 指定(TLE 目錄的路徑要絕對路徑)。
ACAT */AdvCAT/ConjScreen Primary RemoveAll
ACAT */AdvCAT/ConjScreen Secondary RemoveAll
ACAT */AdvCAT/ConjScreen Primary Add "Satellite/Payload"
# 篩選門檻:25 km。這是一個「值得看一眼」的距離,不是「一定會撞」的距離。
ACAT */AdvCAT/ConjScreen Threshold 25.0
# 前置濾波器:先用粗略判據把不可能交會的物件刷掉,否則 1.5 萬顆要算很久。
# OutOfDate TLE 曆元太舊的不算。文件寫「1 至 3650 天」,但值是用
# Connect 時間單位(秒)輸入的 —— 寫 30 會 NACK,要寫 2592000。
# ApogeePerigee 遠地點/近地點區間沒重疊的不算
# OrbitPath 軌道路徑距離超過門檻的不算
# Time 時間上不可能接近的不算
ACAT */AdvCAT/ConjScreen PreFilters OutOfDate 2592000.0
ACAT */AdvCAT/ConjScreen PreFilters ApogeePerigee 50.0
ACAT */AdvCAT/ConjScreen PreFilters OrbitPath 50.0
ACAT */AdvCAT/ConjScreen PreFilters Time 50.0
# 酬載的星曆從分離時刻(t=600 s)才開始,不涵蓋整個分析區間。
# 不開這一項的話,AdvCAT 會直接把主要物件標成錯誤,一筆事件都不算 ——
# 而且 Compute 只花 0.2 秒就「完成」,看起來像是真的沒有交會。
ACAT */AdvCAT/ConjScreen AllowPartialEphem On
# 用距離而不是誤差橢球當判據。橢球需要共變異數資料,TLE 沒有。
# 這一行很重要:不設的話,AdvCAT 會用預設橢球尺寸,而那個尺寸跟你的
# 實際定軌精度毫無關係 —— 報告出來的「橢球距離」會是一個沒有物理意義的數字。
ACAT */AdvCAT/ConjScreen PntToPnt On
ACAT */AdvCAT/ConjScreen TimePeriod "28 Aug 2026 00:00:00.000" "31 Aug 2026 00:00:00.000"
# -*- coding: utf-8 -*-
# ==============================================================================
# 入軌與交會迴避 — 展示動畫
#
# 設計目標:**投影出來三公尺外看得懂**,不是資訊完整。
# 所以只講一件事:**30 秒的取樣間隔為什麼看不見一次 1.8 公里的接近。**
#
# 兩格:
# 上 距離對時間 —— 細掃(0.05 s)畫出真正的谷底,粗掃(30 s)的取樣點
# 用大紅點疊上去。谷底兩側各一個點,中間什麼都沒有。
# 下 相對距離的放大 —— 同一段時間,y 軸縮到 0–30 km,
# 讓「粗掃看到的最小值」與「真正的最小值」的差距直接可見。
#
# 動畫的推進是**取樣間隔**,不是時間:一格一種間隔,從 0.05 s 一路粗到 30 s,
# 觀眾看著那個谷底怎麼被抹掉。
#
# 執行:python lc_animate.py --pid <PID>
# ==============================================================================
import os
import sys
import matplotlib
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from cjk import use_cjk # noqa: E402
use_cjk()
from matplotlib.animation import FuncAnimation, PillowWriter # noqa: E402
import lc_build as B # noqa: E402
import lc_conjunction as C # noqa: E402
OUT = os.path.join(HERE, "lc_anim.gif")
FPS = 1.2
# 一格一個取樣間隔(秒)。從細到粗 —— 觀眾看著谷底被抹掉。
STEPS = [0.05, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 30.0]
WINDOW = 90.0 # TCA 前後各取這麼多秒
def gather(root, rows):
"""取 TCA 附近的細掃距離曲線,以及每一種取樣間隔會看到的最小值。"""
top = min(rows, key=lambda r: r.get("Min Range", 1e9))
tca = float(top["Time at Min Sep (TMS)"])
truth_min = float(top["Min Range"])
ssc = int(top["SSC"])
name = str(top.get("Object Name", "?"))
# `range_series()` 讀 */Satellite/CheckSat,而那是 part_c() 建的。
# 動畫不跑 part_c,所以這裡自己建 —— 前置條件寫成程式碼,
# 不要依賴「剛好前面有人跑過」。
l1, l2 = C.find_tle(ssc)
if not l1:
raise SystemExit("目錄裡找不到 SSC %d 的 TLE,動畫無從畫起。" % ssc)
try:
root.ExecuteCommand("Unload / */Satellite/CheckSat")
except Exception:
pass
root.ExecuteCommand("New / */Satellite CheckSat")
root.ExecuteCommand('SetState */Satellite/CheckSat TLE "%s" "%s" '
'TimePeriod "%s" "%s" TimeStep 10 Routine SGP4'
% (l1, l2, B.T0, B.T1))
t0, t1 = tca - WINDOW, tca + WINDOW
t_fine, d_fine = C.range_series(root, t0, t1, STEPS[0])
# 每一種間隔:從同一條真實軌跡上「以那個間隔取樣」會看到什麼。
# 用細掃資料重新取樣,而不是再問 STK —— 這樣比較的是同一條軌跡,
# 差別純粹來自取樣,不會混進別的因素。
seen = {}
for s in STEPS:
k = max(1, int(round(s / STEPS[0])))
idx = np.arange(0, len(t_fine), k)
seen[s] = (t_fine[idx], d_fine[idx])
return dict(tca=tca, truth=truth_min, ssc=ssc, name=name,
t=t_fine, d=d_fine, seen=seen)
def render(data, out=OUT):
t, d = data["t"], data["d"]
tca = data["tca"]
tr = (t - tca)
lo = float(np.nanmin(d))
fig = plt.figure(figsize=(11, 7.2))
gs = fig.add_gridspec(2, 1, height_ratios=[1.0, 1.0], hspace=0.34,
left=0.10, right=0.97, top=0.855, bottom=0.09)
ax_a, ax_b = fig.add_subplot(gs[0]), fig.add_subplot(gs[1])
for ax, ymax in ((ax_a, float(np.nanmax(d))), (ax_b, 30.0)):
ax.plot(tr, d, lw=2.2, color="#9A3D2A", zorder=6,
label="真實距離(0.05 s 細掃)")
ax.axhline(lo, ls="--", lw=1.2, color="0.45",
label="真正的最小值 %.3f km" % lo)
ax.set_ylim(0, ymax)
ax.set_xlim(tr[0], tr[-1])
ax.grid(alpha=.25)
ax.tick_params(labelsize=10)
ax.set_ylabel("距離 (km)", fontsize=11)
ax_b.set_xlabel("相對於最接近時刻的時間 (s)", fontsize=11)
ax_a.legend(fontsize=10, loc="upper right")
pts_a, = ax_a.plot([], [], "o", ms=9, color="#1B5E9E", linestyle="none",
zorder=4)
pts_b, = ax_b.plot([], [], "o", ms=11, color="#1B5E9E", linestyle="none",
zorder=4)
hit_a = ax_a.axhline(np.nan, ls=":", lw=1.8, color="#1B5E9E")
hit_b = ax_b.axhline(np.nan, ls=":", lw=1.8, color="#1B5E9E")
title = fig.text(0.5, 0.955, "", ha="center", fontsize=17, weight="bold")
read = fig.text(0.5, 0.902, "", ha="center", fontsize=13.5)
def frame(i):
s = STEPS[i]
ts, ds = data["seen"][s]
m = float(np.nanmin(ds))
# 點多就細而淡,點少就大而實 —— 固定大小會讓 0.05 s 那一格
# 變成一團藍色,把要對照的真值曲線整個蓋掉。
k = len(ts)
ms_a = float(np.clip(300.0 / max(k, 1), 1.5, 9.0))
al = float(np.clip(600.0 / max(k, 1), 0.25, 1.0))
pts_a.set_data(ts - tca, ds)
pts_a.set_markersize(ms_a)
pts_a.set_alpha(al)
pts_b.set_data(ts - tca, ds)
pts_b.set_markersize(min(ms_a * 1.3, 12.0))
pts_b.set_alpha(al)
hit_a.set_ydata([m, m])
hit_b.set_ydata([m, m])
title.set_text("取樣間隔決定你看不看得見那一次接近")
over = m / lo if lo > 0 else float("nan")
read.set_text("取樣間隔 %5.2f s 這樣掃到的最小值 %7.3f km"
" = 真值的 %.2f 倍" % (s, m, over))
read.set_color("#9A3D2A" if over > 2 else "#2C7A4B")
return pts_a, pts_b, hit_a, hit_b, title, read
anim = FuncAnimation(fig, frame, frames=len(STEPS), blit=False)
anim.save(out, writer=PillowWriter(fps=FPS))
plt.close(fig)
return out, len(STEPS)
def main():
app, root, el = B.build()[:3]
print("\n--- AdvCAT 篩選 ---")
rows = C.part_b(root)
if not rows:
print("沒有交會事件,動畫無從畫起。")
return 1
print("\n--- 取 TCA 附近的細掃 ---")
data = gather(root, rows)
print(" 對象 %s(SSC %d),TCA t=%.3f s,真正的最小距離 %.3f km"
% (data["name"], data["ssc"], data["tca"], data["truth"]))
print("\n--- 畫動畫 ---")
out, n = render(data)
print(" %d 格 @ %.1f fps -> %s (%.0f KB)"
% (n, FPS, out, os.path.getsize(out) / 1024.0))
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
# ==============================================================================
# 雙節運載火箭入軌 + 交會迴避 — 場景建構
#
# 分工:
# launch_conjunction_Connect.txt Connect 做得到的一切
# 本檔 兩件 Connect 做不到的事
#
# Connect 做不到的兩件事:
# 1. 「把發射載具燒盡時的狀態向量,拿去當酬載衛星的初始狀態」。
# Connect 沒有這種指令 —— 它只能設常數,不能把一個物件的計算結果
# 餵給另一個物件。要回讀資料提供者再寫回去。
# 2. TLE 目錄的取得。本機隨附的 stkAllTLE.tce 是 2012 年的快照;
# 真正要做交會分析必須用**當前**的目錄,所以本檔會嘗試從 CelesTrak 下載。
#
# 執行:
# 1. 開啟 STK 12 桌面版(或 Engine,本章沒有繪圖指令)
# 2. python lc_build.py
# ==============================================================================
import math
import os
import sys
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
from agi.stk12.stkdesktop import STKDesktop # noqa: E402
SCRIPT = os.path.join(HERE, "launch_conjunction_Connect.txt")
LV = "*/LaunchVehicle/Ascent"
PAYLOAD = "*/Satellite/Payload"
ACAT = "*/AdvCAT/ConjScreen"
T0 = "28 Aug 2026 00:00:00.000"
T1 = "31 Aug 2026 00:00:00.000"
BURNOUT_T = 600.0 # s,與 Connect 腳本的上升段停止時間一致
MU_EARTH = 398600.4418 # km^3/s^2
RE = 6378.137 # km
# 本機隨附的 TLE 目錄(2012 年快照)。CelesTrak 下載失敗時的退路。
FALLBACK_TLE = os.path.join(
os.path.expanduser("~"), "Documents", "STK 12", "Space_Systems_1",
"stkAllTLE.tce")
CELESTRAK = ("https://celestrak.org/NORAD/elements/gp.php"
"?GROUP=active&FORMAT=tle")
LOCAL_TLE = os.path.join(HERE, "celestrak_active.tle")
def connect(root, path, echo_fail=True):
ok = fail = 0
for n, line in enumerate(open(path, encoding="utf-8", errors="replace"), 1):
cmd = line.strip()
if not cmd or cmd.startswith("#"):
continue
try:
root.ExecuteCommand(cmd)
ok += 1
except Exception:
fail += 1
if echo_fail:
print(" NACK L%-4d %s" % (n, cmd[:100]))
print("%s: %d 條 ACK, %d 條 NACK" % (os.path.basename(path), ok, fail))
return ok, fail
# ------------------------------------------------------------------------------
def get_catalogue(force_download=False):
"""取得次要清單用的 TLE 目錄。
優先用 CelesTrak 的當前目錄;拿不到就退回本機隨附的 2012 快照,
並且**明講用了哪一個** —— 交會分析用過期目錄等於沒做。
"""
if os.path.exists(LOCAL_TLE) and not force_download:
n = sum(1 for l in open(LOCAL_TLE, encoding="utf-8", errors="replace")
if l.startswith("1 "))
print(" 使用既有的 %s(%d 個物件)" % (os.path.basename(LOCAL_TLE), n))
return LOCAL_TLE, "celestrak-cached", n
try:
import urllib.request
req = urllib.request.Request(
CELESTRAK, headers={"User-Agent": "STK-course/1.0"})
with urllib.request.urlopen(req, timeout=60) as r:
text = r.read().decode("utf-8", "replace")
if text.count("\n") < 10:
raise RuntimeError("回應太短,可能被限流")
open(LOCAL_TLE, "w", encoding="utf-8", newline="\n").write(text)
n = sum(1 for l in text.splitlines() if l.startswith("1 "))
print(" 從 CelesTrak 下載了 %d 個物件 -> %s"
% (n, os.path.basename(LOCAL_TLE)))
return LOCAL_TLE, "celestrak", n
except Exception as e:
print(" CelesTrak 下載失敗(%s)" % str(e)[:60])
if os.path.exists(FALLBACK_TLE):
n = sum(1 for l in open(FALLBACK_TLE, encoding="utf-8",
errors="replace") if l.startswith("1 "))
print(" 改用本機隨附的 %s(%d 個物件)"
% (os.path.basename(FALLBACK_TLE), n))
print(" 注意:這是 2012 年的快照。真正的交會分析必須用當前目錄。")
return FALLBACK_TLE, "local-2012", n
raise RuntimeError("找不到任何 TLE 目錄")
# ------------------------------------------------------------------------------
def burnout_state(root, t=BURNOUT_T):
"""回讀發射載具在燒盡時刻的慣性系位置與速度 (km, km/s)。"""
lv = root.GetObjectFromPath(LV)
dp = lv.DataProviders.Item("Cartesian Position").Group.Item("ICRF")
dv = lv.DataProviders.Item("Cartesian Velocity").Group.Item("ICRF")
r = dp.ExecElements(t - 1.0, t, 1.0, ["Time", "x", "y", "z"])
v = dv.ExecElements(t - 1.0, t, 1.0, ["Time", "x", "y", "z"])
rr = {r.DataSets.Item(i).ElementName: list(r.DataSets.Item(i).GetValues())[-1]
for i in range(r.DataSets.Count)}
vv = {v.DataSets.Item(i).ElementName: list(v.DataSets.Item(i).GetValues())[-1]
for i in range(v.DataSets.Count)}
return ([rr["x"], rr["y"], rr["z"]], [vv["x"], vv["y"], vv["z"]])
def elements_from_state(r_vec, v_vec, mu=MU_EARTH):
"""從狀態向量解出經典軌道根數。
這是本章的獨立驗證:STK 有自己的一套,我們用教科書公式再算一次,
兩邊對不上就是有人錯了(通常是單位或座標系)。
"""
r = [float(x) for x in r_vec]
v = [float(x) for x in v_vec]
rn = math.sqrt(sum(x * x for x in r))
vn = math.sqrt(sum(x * x for x in v))
h = [r[1] * v[2] - r[2] * v[1],
r[2] * v[0] - r[0] * v[2],
r[0] * v[1] - r[1] * v[0]]
hn = math.sqrt(sum(x * x for x in h))
energy = vn * vn / 2.0 - mu / rn
a = -mu / (2.0 * energy)
e_vec = [((vn * vn - mu / rn) * r[i] - sum(r[j] * v[j] for j in range(3)) * v[i]) / mu
for i in range(3)]
e = math.sqrt(sum(x * x for x in e_vec))
inc = math.degrees(math.acos(max(-1.0, min(1.0, h[2] / hn))))
n_vec = [-h[1], h[0], 0.0]
nn = math.sqrt(n_vec[0] ** 2 + n_vec[1] ** 2)
raan = math.degrees(math.atan2(n_vec[1], n_vec[0])) % 360.0 if nn > 1e-9 else 0.0
if nn > 1e-9 and e > 1e-9:
argp = math.degrees(math.acos(max(-1.0, min(
1.0, sum(n_vec[i] * e_vec[i] for i in range(3)) / (nn * e)))))
if e_vec[2] < 0:
argp = 360.0 - argp
else:
argp = 0.0
if e > 1e-9:
ta = math.degrees(math.acos(max(-1.0, min(
1.0, sum(e_vec[i] * r[i] for i in range(3)) / (e * rn)))))
if sum(r[i] * v[i] for i in range(3)) < 0:
ta = 360.0 - ta
else:
ta = math.degrees(math.atan2(
sum(r[i] * h[i] for i in range(3)) / hn, r[0])) % 360.0
return dict(a=a, e=e, i=inc, raan=raan, argp=argp, ta=ta,
rp=a * (1 - e), ra=a * (1 + e),
hp=a * (1 - e) - RE, ha=a * (1 + e) - RE)
def insert_payload(root, r_vec, v_vec, t=BURNOUT_T):
"""把酬載衛星的初始狀態設成燒盡時的狀態向量(分離即入軌)。"""
import datetime
t0 = datetime.datetime(2026, 8, 28, 0, 0, 0)
sep = (t0 + datetime.timedelta(seconds=t)).strftime("%d %b %Y %H:%M:%S.000")
cmd = ('SetState %s Cartesian TwoBody "%s" "%s" 10 ICRF "%s" '
'%.6f %.6f %.6f %.9f %.9f %.9f'
% (PAYLOAD, sep, T1, sep,
r_vec[0], r_vec[1], r_vec[2], v_vec[0], v_vec[1], v_vec[2]))
root.ExecuteCommand(cmd)
return sep
def build(secondary=None):
app = STKDesktop.AttachToApplication() # 保留 app,否則 COM 介面會失效
root = app.Root
if root.CurrentScenario is not None:
root.CloseScenario()
ack, nack = connect(root, SCRIPT)
print("\n--- 燒盡狀態 -> 酬載入軌 ---")
r_vec, v_vec = burnout_state(root)
el = elements_from_state(r_vec, v_vec)
print(" 燒盡位置 |r| = %.2f km,速度 |v| = %.4f km/s"
% (math.sqrt(sum(x * x for x in r_vec)),
math.sqrt(sum(x * x for x in v_vec))))
print(" 解出的軌道:a = %.2f km, e = %.5f, i = %.3f deg"
% (el["a"], el["e"], el["i"]))
print(" 近地點高度 %.1f km / 遠地點高度 %.1f km" % (el["hp"], el["ha"]))
sep = insert_payload(root, r_vec, v_vec)
print(" 酬載分離時刻:%s" % sep)
print("\n--- TLE 目錄 ---")
if secondary is None:
secondary, source, n = get_catalogue()
else:
source, n = "user", -1
root.ExecuteCommand('ACAT %s Secondary RemoveAll' % ACAT)
root.ExecuteCommand('ACAT %s Secondary Add "%s"'
% (ACAT, secondary.replace("\\", "/")))
print(" 次要清單:%s(%s)" % (os.path.basename(secondary), source))
return app, root, el, dict(path=secondary, source=source, count=n)
def main():
app, root, el, cat = build()
print("\n--- 檢查 ---")
print(" 入軌判定:近地點高度 %.1f km -> %s"
% (el["hp"], "有效入軌" if el["hp"] > 200.0 else "未入軌(會再入)"))
print(" AdvCAT 次要物件數:%d" % cat["count"])
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
# ==============================================================================
# 雙節運載火箭入軌 + 交會迴避 — 分析
#
# Part A 入軌過程:上升軌跡、燒盡狀態、解析解出的軌道(獨立驗證 STK)
# Part B AdvCAT 交會篩選:門檻內的接近事件、依最小距離排序
# Part C 獨立驗證:把最近那個物件的 TLE 單獨載進場景,自己逐點算距離,
# 跟 AdvCAT 的搜尋結果對答案
# Part D 設計掃描:入軌高度 -> 交會事件數,找出最不擁擠的高度
#
# Part C 是本章的方法論核心。交會分析的結論會直接影響是否要做迴避機動,
# 而迴避機動要花推進劑、要中斷任務 —— 一個沒有被獨立驗證過的交會清單,
# 不足以作為那種決策的依據。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python lc_build.py
# 3. python lc_conjunction.py
# ==============================================================================
import math
import os
import sys
import matplotlib
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from agi.stk12.stkdesktop import STKDesktop # noqa: E402
import lc_build as B # noqa: E402
THRESHOLD_KM = 25.0
SCAN_ALTS = [500.0, 600.0, 700.0, 800.0, 900.0] # km,入軌高度掃描
COARSE_STEP = 30.0 # s,獨立驗證的粗掃步長
FINE_STEP = 0.05 # s,事件附近的細掃步長(14 km/s 下才解析得出來)
FINE_HALF = 30.0 # s,細掃視窗的半寬
# AdvCAT 的報表。名稱是實測得到的 —— 這個物件有 19 個資料提供者,
# 「Events by Min Sep」是依最小橢球距離排序的那一份。
DP_EVENTS = "Events by Min Sep"
EVENT_ELS = ["Object Name", "SSC", "Time at Min Sep (TMS)", "Min Separation",
"Min Range", "Relative Velocity", "Time of Close Appr (TCA)",
"Min Range - Radial", "Min Range - InTrack", "Min Range - CrossTrack"]
# ------------------------------------------------------------------------------
def part_a(root, el):
print("=" * 78)
print("Part A 入軌過程")
print("=" * 78)
lv = root.GetObjectFromPath(B.LV)
dp = lv.DataProviders.Item("LLA State").Group.Item("Fixed")
r = dp.ExecElements(0.0, B.BURNOUT_T, 30.0, ["Time", "Lat", "Lon", "Alt"])
d = {r.DataSets.Item(i).ElementName:
np.array([v if isinstance(v, (int, float)) else np.nan
for v in r.DataSets.Item(i).GetValues()], dtype=float)
for i in range(r.DataSets.Count)}
print(" 上升段 %.0f 秒,高度 %.1f -> %.1f km"
% (B.BURNOUT_T, d["Alt"][0], d["Alt"][-1]))
print(" 地面軌跡 (%.2f, %.2f) -> (%.2f, %.2f)"
% (d["Lat"][0], d["Lon"][0], d["Lat"][-1], d["Lon"][-1]))
print()
print(" 從燒盡狀態向量解出的軌道(教科書公式,不靠 STK):")
print(" a = %10.3f km e = %.6f" % (el["a"], el["e"]))
print(" i = %10.3f deg RAAN = %.3f deg" % (el["i"], el["raan"]))
print(" 近地點高度 %8.2f km 遠地點高度 %8.2f km" % (el["hp"], el["ha"]))
print()
sat = root.GetObjectFromPath(B.PAYLOAD)
cl = sat.DataProviders.Item("Classical Elements").Group.Item("ICRF")
rr = cl.ExecElements(B.BURNOUT_T, B.BURNOUT_T + 20.0, 20.0,
["Time", "Semi-major Axis", "Eccentricity",
"Inclination", "RAAN"])
s = {rr.DataSets.Item(i).ElementName: list(rr.DataSets.Item(i).GetValues())[0]
for i in range(rr.DataSets.Count)}
print(" STK 自己算的根數:a = %.3f km, e = %.6f, i = %.3f deg, RAAN = %.3f deg"
% (s["Semi-major Axis"], s["Eccentricity"], s["Inclination"], s["RAAN"]))
print(" 兩者差:a %.4f km, e %.2e, i %.5f deg, RAAN %.5f deg"
% (abs(s["Semi-major Axis"] - el["a"]), abs(s["Eccentricity"] - el["e"]),
abs(s["Inclination"] - el["i"]), abs(s["RAAN"] - el["raan"])))
print()
print(" 入軌判定(近地點高度 > 200 km):%s"
% ("有效入軌" if el["hp"] > 200.0 else "未入軌,會再入大氣"))
return d, s
# ------------------------------------------------------------------------------
def run_advcat(root, compute=True):
"""跑 AdvCAT 並取回接近事件清單(list of dict)。"""
if compute:
root.ExecuteCommand("ACAT %s Compute" % B.ACAT)
acat = root.GetObjectFromPath(B.ACAT)
sc = root.CurrentScenario
res = acat.DataProviders.Item(DP_EVENTS).ExecElements(
sc.StartTime, sc.StopTime, EVENT_ELS)
if res.DataSets.Count == 0:
return []
cols = {res.DataSets.Item(i).ElementName: list(res.DataSets.Item(i).GetValues())
for i in range(res.DataSets.Count)}
n = max(len(v) for v in cols.values())
return [{k: (v[i] if i < len(v) else None) for k, v in cols.items()}
for i in range(n)]
def part_b(root):
print()
print("=" * 78)
print("Part B AdvCAT 交會篩選(門檻 %.0f km)" % THRESHOLD_KM)
print("=" * 78)
rows = run_advcat(root)
print(" 接近事件數:%d" % len(rows))
if not rows:
print(" 沒有事件。先確認:次要清單載進去了嗎?TLE 曆元跟場景時間同期嗎?")
print(" (AdvCAT 的 Errors 資料提供者會直接告訴你原因)")
return rows
srt = sorted(rows, key=lambda r: r.get("Min Range", 1e9))
print()
print(" 最接近的 10 筆:")
print(" %-14s %-8s %-26s %10s %10s %9s"
% ("物件", "SSC", "最接近時刻", "最小距離", "橢球距離", "相對速度"))
for r in srt[:10]:
print(" %-14s %-8s %-26s %9.3f %10.3f %8.3f"
% (str(r["Object Name"])[:14], r["SSC"],
str(r["Time at Min Sep (TMS)"])[:26],
r["Min Range"], r["Min Separation"], r["Relative Velocity"]))
print()
d = srt[0]
print(" 最近的一筆分量(接收站座標系):徑向 %.3f / 沿軌 %.3f / 越軌 %.3f km"
% (d.get("Min Range - Radial", float("nan")),
d.get("Min Range - InTrack", float("nan")),
d.get("Min Range - CrossTrack", float("nan"))))
print(" 相對速度 %.2f km/s —— 這個量級下,幾公里的誤判就是幾秒的差別。"
% d["Relative Velocity"])
return rows
# ------------------------------------------------------------------------------
def find_tle(ssc, path=None):
"""從目錄檔裡撈出指定 SSC 的兩行 TLE。"""
path = path or B.LOCAL_TLE
want = "%05d" % int(ssc)
l1 = None
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.rstrip("\n").rstrip("\r")
if line.startswith("1 ") and line[2:7] == want:
l1 = line
elif l1 and line.startswith("2 ") and line[2:7] == want:
return l1, line
elif line.startswith("1 "):
l1 = None
return None, None
def range_series(root, t0, t1, step):
"""回讀酬載與驗證衛星的 ICRF 位置,自己算距離。"""
out = {}
for name, p in (("pay", B.PAYLOAD), ("chk", "*/Satellite/CheckSat")):
dp = root.GetObjectFromPath(p).DataProviders.Item(
"Cartesian Position").Group.Item("ICRF")
r = dp.ExecElements(t0, t1, step, ["Time", "x", "y", "z"])
g = {r.DataSets.Item(i).ElementName:
np.array([v if isinstance(v, (int, float)) else np.nan
for v in r.DataSets.Item(i).GetValues()], dtype=float)
for i in range(r.DataSets.Count)}
out[name] = (g["Time"], np.vstack([g["x"], g["y"], g["z"]]).T)
t = out["pay"][0]
n = min(len(t), len(out["chk"][0]))
d = np.linalg.norm(out["pay"][1][:n] - out["chk"][1][:n], axis=1)
return t[:n], d
def part_c(root, rows):
"""獨立驗證最近的一筆。
這一段本身就是一堂課:**交會事件用固定網格是掃不到的**。
相對速度 14 km/s,30 秒的取樣間隔等於 430 公里的位移 ——
一次 2 公里的接近,在這樣的網格上連影子都看不到。
所以流程必須是「先用篩選器定位事件,再在事件附近細掃」,
而不是「均勻取樣找最小值」。這正是 AdvCAT 這種工具存在的理由。
"""
print()
print("=" * 78)
print("Part C 獨立驗證")
print("=" * 78)
if not rows:
print(" 沒有 AdvCAT 事件可以驗證。")
return None
top = min(rows, key=lambda r: r.get("Min Range", 1e9))
ssc = int(top["SSC"])
tca = float(top["Time at Min Sep (TMS)"])
print(" 驗證對象:%s(SSC %d)" % (top["Object Name"], ssc))
print(" AdvCAT:最小距離 %.3f km @ t = %.3f s,相對速度 %.2f km/s"
% (top["Min Range"], tca, top["Relative Velocity"]))
l1, l2 = find_tle(ssc)
if not l1:
print(" 目錄裡找不到這個 SSC 的 TLE,跳過。")
return None
print(" TLE 曆元欄位:%s" % l1[18:32])
sc = root.CurrentScenario
try:
root.ExecuteCommand("Unload / */Satellite/CheckSat")
except Exception:
pass
root.ExecuteCommand("New / */Satellite CheckSat")
root.ExecuteCommand('SetState */Satellite/CheckSat TLE "%s" "%s" '
'TimePeriod "%s" "%s" TimeStep 10 Routine SGP4'
% (l1, l2, B.T0, B.T1))
# (1) 先示範「均勻粗掃」為什麼行不通
t_c, d_c = range_series(root, float(B.BURNOUT_T), float(sc.StopTime),
COARSE_STEP)
k = int(np.nanargmin(d_c))
print()
print(" 均勻粗掃(%.0f s 步長,共 %d 點):最小距離 %.1f km @ t = %.0f s"
% (COARSE_STEP, len(t_c), d_c[k], t_c[k]))
print(" 相對速度 %.2f km/s x %.0f s = %.0f km 的位移 —— "
% (top["Relative Velocity"], COARSE_STEP,
top["Relative Velocity"] * COARSE_STEP))
print(" 這個網格根本解析不出 %.1f km 的接近。均勻取樣在交會分析上是無效的。"
% top["Min Range"])
# (2) 在 AdvCAT 給的時刻附近細掃,才是有意義的獨立驗證
lo = max(float(B.BURNOUT_T), tca - FINE_HALF)
hi = min(float(sc.StopTime), tca + FINE_HALF)
tf, df = range_series(root, lo, hi, FINE_STEP)
kf = int(np.nanargmin(df))
mine, mint = float(df[kf]), float(tf[kf])
print()
print(" 在 AdvCAT 給的時刻 +/- %.0f s 內細掃(%.2f s 步長,共 %d 點):"
% (FINE_HALF, FINE_STEP, len(tf)))
print(" 最小距離 %.3f km @ t = %.3f s" % (mine, mint))
dd = abs(mine - top["Min Range"])
dt = abs(mint - tca)
print()
print(" 距離:AdvCAT %.3f km vs 自己算 %.3f km -> 差 %.3f km (%.2f%%)"
% (top["Min Range"], mine, dd, 100.0 * dd / max(mine, 1e-9)))
print(" 時刻:AdvCAT %.3f s vs 自己算 %.3f s -> 差 %.3f s"
% (tca, mint, dt))
print()
print(" 這一步驗證的是 AdvCAT 的**搜尋與細化**是否可信,不是 SGP4 本身 ——")
print(" 兩邊都用 STK 的 SGP4。要驗證傳播器,得換一套獨立實作(例如 Python")
print(" 的 sgp4 套件),而那還得自己處理 TEME 與 ICRF 的座標轉換;")
print(" 26 年的歲差就有約 0.36 度,在 7000 km 半徑上是 44 km ——")
print(" 比要驗證的 %.1f km 大一個量級。座標系弄錯,驗證就是自欺欺人。"
% mine)
return dict(ssc=ssc, advcat=top["Min Range"], mine=mine, t=mint, tca=tca,
tf=tf, df=df, tc=t_c, dc=d_c, name=top["Object Name"],
relv=top["Relative Velocity"])
# ------------------------------------------------------------------------------
def part_d(root, base_el):
print()
print("=" * 78)
print("Part D 入軌高度掃描")
print("=" * 78)
print(" %10s %16s %16s %14s"
% ("高度 (km)", "交會事件數", "最小距離 (km)", "5 km 內的事件"))
out = []
for alt in SCAN_ALTS:
a = B.RE + alt
root.ExecuteCommand(
'SetState %s Classical TwoBody "%s" "%s" 10 ICRF "%s" '
'%.4f 0.0 %.4f %.4f 0.0 0.0'
% (B.PAYLOAD, B.T0, B.T1, B.T0, a, base_el["i"], base_el["raan"]))
rows = run_advcat(root)
mind = min((r["Min Range"] for r in rows
if isinstance(r.get("Min Range"), (int, float))),
default=float("nan"))
close = sum(1 for r in rows
if isinstance(r.get("Min Range"), (int, float))
and r["Min Range"] < 5.0)
out.append((alt, len(rows), mind, close))
print(" %10.0f %16d %16.3f %14d" % (alt, len(rows), mind, close))
return out
def plot(track, verify, scan, path):
fig, ax = plt.subplots(1, 3, figsize=(15, 4.4))
a = ax[0]
a.plot(track["Time"], track["Alt"], lw=2.0, color="C0")
a.set_xlabel("time from launch (s)")
a.set_ylabel("altitude (km)")
a.set_title("A. two-stage ascent profile")
a.grid(alpha=.3)
a = ax[1]
if verify:
a.plot((verify["tf"] - verify["tca"]), verify["df"], lw=1.8, color="C1")
a.axhline(verify["advcat"], ls="--", c="k", lw=1,
label="AdvCAT %.3f km" % verify["advcat"])
a.plot([verify["t"] - verify["tca"]], [verify["mine"]], "o", color="C3",
ms=7, label="own min %.3f km" % verify["mine"])
a.set_xlabel("time relative to AdvCAT TCA (s)")
a.set_ylabel("range to SSC %d (km)" % verify["ssc"])
a.legend(fontsize=8)
a.set_title("C. closest conjunction, independent scan")
a.grid(alpha=.3)
a = ax[2]
if scan:
alts = [s[0] for s in scan]
a.bar([x - 18 for x in alts], [s[1] for s in scan], width=36,
color="C0", label="events < %.0f km" % THRESHOLD_KM)
a.bar([x + 18 for x in alts], [s[3] for s in scan], width=36,
color="C3", label="events < 5 km")
a.set_xlabel("insertion altitude (km)")
a.set_ylabel("conjunction events in 3 days")
a.legend(fontsize=8)
a.set_title("D. congestion vs insertion altitude")
a.grid(alpha=.3, axis="y")
fig.tight_layout()
fig.savefig(path, dpi=130)
print("\n圖已存到", path)
def main():
app = STKDesktop.AttachToApplication()
root = app.Root
if root.CurrentScenario is None:
print("沒有開啟的場景。請先執行 python lc_build.py")
return 1
r_vec, v_vec = B.burnout_state(root)
el = B.elements_from_state(r_vec, v_vec)
track, stk_el = part_a(root, el)
rows = part_b(root)
verify = part_c(root, rows)
scan = part_d(root, el)
plot(track, verify, scan, os.path.join(HERE, "lc_conjunction.png"))
print()
print("=" * 78)
print("結論")
print("=" * 78)
if scan:
best = min(scan, key=lambda s: s[1])
worst = max(scan, key=lambda s: s[1])
print(" 掃描範圍內,%.0f km 最不擁擠(%d 筆),%.0f km 最擁擠(%d 筆)。"
% (best[0], best[1], worst[0], worst[1]))
print(" 但「事件最少」不等於「最安全」:事件數受目錄完整度、篩選門檻、")
print(" 傳播器精度三件事影響,而這三件事都不是物理。")
print()
print(" 真正的迴避決策要看**碰撞機率**,而碰撞機率需要共變異數。")
print(" TLE 沒有共變異數 —— 所以這一章能給的結論只到「哪裡比較擠」,")
print(" 再往下就必須換資料源(CDM / 定軌共變異數),不是換演算法。")
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
"""獨立章節 3 的簡報內容。純資料,不 import STK。"""
DECK = dict(
eyebrow="獨立章節 3 · STK Astrogator + AdvCAT",
title="入軌之後,誰在那裡",
subtitle="雙節運載火箭入軌與交會迴避",
thesis="30 秒間隔的均勻粗掃找到的最近距離是 19.4 km,"
"而真正的最近接近是 1.83 km —— 相對速度 14 km/s 下,30 秒就是 430 公里。",
scenario=[
("運載火箭", "雙節,燒盡點固定在 700 km"),
("燒盡速度", "7.65 km/s(掃描 7.55 – 7.85)"),
("入軌軌道", "a 7018.6 km · e 0.0075 · i 108.24° · 588 × 693 km"),
("目錄", "CelesTrak active,16,052 個物體"),
("篩選窗", "3 天,門檻 25 km"),
("判據", "PntToPnt(距離);TLE 沒有共變異數,算不出碰撞機率"),
],
focus=[
"燒盡速度對軌道形狀極度敏感:7.55 → 7.65 km/s,近地點從 230 km 變成 588 km。",
"狀態向量 → 軌道根數的獨立驗證:自算與 STK 差到機器精度。",
"AdvCAT 的預篩機制:1.1 秒處理 16,052 個物體。",
"均勻粗掃會漏掉高速交會 —— 取樣間隔必須細於你要找的事件。",
"入軌高度決定擁擠程度,但「事件少」不等於「安全」。",
],
animation="lc_anim.gif",
animation_title="動畫:粗掃為什麼看不見那 1.8 公里",
animation_caption="兩顆物體以 14.35 km/s 相對速度接近;"
"30 秒取樣的採樣點全部落在最近點兩側",
figures=[
("lc_conjunction.png",
"入軌軌跡與交會篩選 —— 粗掃 vs 細掃、高度掃描"),
],
results_caption="全部為 STK 12.10.0 + CelesTrak 實測",
results=[
("量", "結果", "說明"),
("AdvCAT 篩選", "16,052 物體 / 1.1 秒 / 99 筆事件", "3 天,門檻 25 km"),
("最近接近", "1.830 km", "相對速度 14.35 km/s,SSC 43005"),
("30 秒均勻粗掃", "只找到 19.4 km", "8621 點,完全錯過"),
("0.05 秒細掃(TCA 附近)", "1.854 km", "與 AdvCAT 差 1.30%,時刻差 0.021 s"),
("", "", ""),
("入軌高度", "交會事件數", "最小距離"),
("500 km", "644", "1.744 km"),
("600 km", "124", "1.527 km"),
("700 km", "21", "0.349 km ← 最近"),
("800 km", "11", "5.117 km"),
],
findings=[
"**粗掃的取樣間隔必須細於你要找的事件。** 14 km/s 下,30 秒就是 430 公里 —— "
"均勻粗掃找到 19.4 km 並宣稱「安全」,而真值是 1.83 km。",
"500 km 有 644 筆事件、800 km 只有 11 筆,差 59 倍 —— 那是巨型星座的殼層。"
"但 700 km 只有 21 筆事件,最小距離卻是全部最近的 0.349 km:"
"**事件少不等於安全。**",
"AdvCAT 回報 0 筆而且 Compute 只花 0.2 秒時,看起來就像真的很安全。"
"實際上是場景設在 2024 年、TLE 曆元是 2026 年 —— 全部被標 Vehicle Out of Date。",
"PreFilters OutOfDate 的文件寫「1 至 3650 天」,實際吃的是秒(2592000)。",
"TLE 沒有共變異數,所以本章**算不出碰撞機率** —— 這是資料的限制,"
"不是工具的限制,要照實寫進報告。",
],
verify=(26, 0, 0),
verify_note=[
"26 項涵蓋:燒盡狀態 → 軌道根數的獨立驗證(差到機器精度)、"
"AdvCAT 的門檻與預篩設定回讀、粗掃與細掃的一致性。",
"本章的驗證重點是**兩條獨立路徑要對得上**:"
"自算的根數與 STK 的根數、細掃的最近距離與 AdvCAT 的最近距離。",
],
run=[
"python lc_build.py --pid <PID> 建場景 + 取 CelesTrak 目錄",
"python lc_conjunction.py --pid <PID> Part A–D + 圖",
"python lc_animate.py --pid <PID> 展示動畫",
"python lc_verify.py --pid <PID> 驗證段(26 項)",
],
files=[
("launch_conjunction_Connect.txt", "Connect 腳本(20 條)"),
("lc_build.py", "燒盡狀態向量、根數獨立驗證、目錄取得"),
("lc_conjunction.py", "Part A–D:入軌、AdvCAT、粗掃細掃、高度掃描"),
("lc_animate.py", "展示動畫"),
("lc_verify.py", "驗證段(26 項全 PASS)"),
("celestrak_active.tle", "CelesTrak 目錄快照"),
("LaunchConjunction_Primer.md", "基礎知識導論"),
("LaunchConjunction_Lab_Handout.md", "實驗單"),
],
)
# -*- coding: utf-8 -*-
# ================================================================
# 運載火箭入軌 + 交會迴避章 — 驗證段
#
# 本章的驗證重點與前兩章不同。前兩章驗證「設定有沒有生效」;
# 本章還要驗證**輸入資料本身可不可信** ——
# 交會分析的結論完全建立在 TLE 目錄上,而目錄可能過期、可能不完整。
# 一份曆元差兩年的目錄跑出「沒有交會」,那個結論毫無意義,
# 而且 AdvCAT 會很快樂地回報「計算完成」。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python lc_verify.py
# 離開碼 0 = 全部通過,1 = 有項目沒生效
# ================================================================
import datetime
import math
import os
import sys
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
from agi.stk12.stkdesktop import STKDesktop # noqa: E402
from stk_verify import Verifier # noqa: E402
import lc_build as B # noqa: E402
import lc_conjunction as C # noqa: E402
# 實測基準(28–31 Aug 2026、CelesTrak active 目錄、門檻 25 km)
BASE = dict(a=7018.616, e=0.007533, i=108.243, hp=587.61, ha=693.35,
events=99, closest=1.830, verify_diff_pct=1.3)
def tle_epoch_days(path):
"""回傳目錄裡每個物件的曆元距今天數。"""
ages = []
now = datetime.datetime.now()
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
if line.startswith("1 ") and len(line) > 32:
try:
yy = int(line[18:20])
doy = float(line[20:32])
except ValueError:
continue
year = 2000 + yy if yy < 57 else 1900 + yy
ep = datetime.datetime(year, 1, 1) + datetime.timedelta(days=doy - 1)
ages.append((now - ep).total_seconds() / 86400.0)
except Exception:
pass
return ages
def main():
app = STKDesktop.AttachToApplication() # 保留 app,否則 COM 介面會失效
root = app.Root
print("--- 建場景 ---")
_, root, el, cat = B.build()
v = Verifier(root)
# ---- (1) 結構 --------------------------------------------------------
for p in ["*/Facility/LaunchSite", B.LV, B.PAYLOAD, B.ACAT]:
v.object_exists(p)
v.position("*/Facility/LaunchSite", 24.00, 121.00)
# ---- (2) 入軌:兩條獨立路徑算出的根數要一致 ---------------------------
# STK 有自己的一套;我們用教科書的「狀態向量 -> 根數」公式再算一次。
# 對不上通常是單位或座標系,不是物理。
sat = root.GetObjectFromPath(B.PAYLOAD)
cl = sat.DataProviders.Item("Classical Elements").Group.Item("ICRF")
rr = cl.ExecElements(B.BURNOUT_T, B.BURNOUT_T + 20.0, 20.0,
["Time", "Semi-major Axis", "Eccentricity",
"Inclination", "RAAN"])
s = {rr.DataSets.Item(i).ElementName: list(rr.DataSets.Item(i).GetValues())[0]
for i in range(rr.DataSets.Count)}
v.expect("半長軸(自算 vs STK)", el["a"], s["Semi-major Axis"], tol=1e-3, unit="km")
v.expect("離心率(自算 vs STK)", el["e"], s["Eccentricity"], tol=1e-7)
v.expect("傾角(自算 vs STK)", el["i"], s["Inclination"], tol=1e-4, unit="deg")
v.expect("RAAN(自算 vs STK)", el["raan"], s["RAAN"], tol=1e-4, unit="deg")
# ---- (3) 入軌判定 -----------------------------------------------------
v.expect("近地點高度", el["hp"], BASE["hp"], tol=2.0, unit="km")
v.expect("遠地點高度", el["ha"], BASE["ha"], tol=2.0, unit="km")
v.expect_true("有效入軌:近地點高度 > 200 km", el["hp"] > 200.0,
"近地點高度 %.1f km" % el["hp"])
v.expect_between("入軌高度落在 LEO 範圍", el["ha"], 300.0, 1200.0, unit="km")
# ---- (4) 輸入資料的可信度(本章特有)---------------------------------
v.expect_true("TLE 目錄檔存在", os.path.exists(cat["path"]), cat["path"])
v.expect_true("TLE 目錄有足夠多的物件(> 10000)", cat["count"] > 10000,
"%d 個物件" % cat["count"])
ages = tle_epoch_days(cat["path"])
if ages:
med = float(np.median(ages))
v.custom("TLE 目錄曆元的新鮮度", med <= 30.0,
"中位曆元距今 %.1f 天(來源 %s)。超過 30 天的目錄,"
"SGP4 誤差會讓交會篩選失去意義。" % (med, cat["source"]))
else:
v.custom("TLE 目錄曆元的新鮮度", None, "解不出曆元(可能是 .tce 二進位格式)")
# 場景時間必須與目錄曆元同期。差兩年的話,AdvCAT 會把每個物件標成
# 「Vehicle Out of Date」,然後回報 0 筆事件 —— 看起來像是真的很安全。
sc = root.CurrentScenario
if ages:
v.expect_true("場景時間與 TLE 曆元同期(中位曆元 30 天內)",
abs(float(np.median(ages))) <= 30.0,
"中位曆元距今 %.1f 天" % float(np.median(ages)))
# ---- (5) AdvCAT 設定回讀 ---------------------------------------------
acat = root.GetObjectFromPath(B.ACAT)
v.expect("AdvCAT 門檻", acat.Threshold, C.THRESHOLD_KM, tol=0.01, unit="km")
v.expect_true("以距離而非誤差橢球為判據(PntToPnt)", bool(acat.UseRangeMeasure),
"UseRangeMeasure = %s" % acat.UseRangeMeasure)
v.expect_true("允許星曆不涵蓋整個區間(酬載從分離才開始)",
bool(acat.Advanced.AllowPartialEphemeris),
"AllowPartialEphemeris = %s" % acat.Advanced.AllowPartialEphemeris)
# ---- (6) AdvCAT 真的算出東西 ------------------------------------------
rows = C.run_advcat(root)
v.custom("AdvCAT 在門檻內找到接近事件", len(rows) > 0,
"%d 筆。0 筆不一定是好消息 —— 先看 Errors 資料提供者。" % len(rows))
v.expect("接近事件數", len(rows), BASE["events"], tol=BASE["events"] * 0.25)
if rows:
closest = min(r["Min Range"] for r in rows
if isinstance(r.get("Min Range"), (int, float)))
v.expect("最近一筆的最小距離", closest, BASE["closest"], tol=0.5, unit="km")
# ---- (7) 交叉驗證:自己算的最小距離要跟 AdvCAT 對得上 -----------------
ver = C.part_c(root, rows)
if ver:
pct = 100.0 * abs(ver["mine"] - ver["advcat"]) / max(ver["mine"], 1e-9)
v.expect_true("自算最小距離與 AdvCAT 一致(差 < 5%)", pct < 5.0,
"AdvCAT %.3f km vs 自算 %.3f km,差 %.2f%%"
% (ver["advcat"], ver["mine"], pct))
v.expect_true("最接近時刻一致(差 < 1 s)",
abs(ver["t"] - ver["tca"]) < 1.0,
"差 %.3f s" % abs(ver["t"] - ver["tca"]))
# 這一項刻意驗證「粗掃找不到」:如果哪天它找得到了,代表取樣策略變了
k = int(np.nanargmin(ver["dc"]))
v.custom("均勻粗掃找不到這個事件(取樣策略的反面教材)",
float(ver["dc"][k]) > 5.0 * ver["mine"],
"30 s 粗掃只找到 %.1f km,真值是 %.3f km"
% (ver["dc"][k], ver["mine"]))
ok = v.report("運載火箭入軌 + 交會迴避 驗證段")
print("\n提醒:第 4 組(輸入資料的可信度)是本章特有的 ——")
print(" 交會分析的結論不會比它的目錄更可信,而工具不會提醒你這件事。")
return 0 if ok else 1
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)