獨立章節 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°

要回答的問題:

  1. 自己算的根數與 STK 差到機器精度。這證明了什麼?沒有證明什麼?
  2. 把燒盡速度改成 7.55 / 7.75 / 7.85 km/s,近地點各是多少?先預測再跑。
    為什麼入軌對速度這麼敏感?
  3. 近地點 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.1 秒處理 16,052 個物體。前置濾波器做了什麼?
    把 PreFilters 全部關掉再跑一次,時間變多少?事件數變多少?
  2. 最近的一筆分量是徑向 1.75 km、越軌 0.52 km、沿軌只有 0.16 km。
    這個形狀說明了什麼?(提示:兩條軌道的關係)
  3. 為什麼本章要設 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。

要回答的問題:

  1. 相對速度 14.35 km/s、粗掃步長 30 s。一步走多遠?
    要用均勻網格找到 1.8 km 的接近,步長最多多大?總共要幾個取樣點?
  2. 細掃的結果與 AdvCAT 差 1.3%。這個差來自哪裡?
  3. 這一步驗證了什麼、沒有驗證什麼?
    (提示:兩邊都用 STK 的 SGP4)
  4. 若要真正獨立地驗證傳播器,你要換掉什麼?會遇到什麼座標系問題?
    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

要回答的問題:

  1. 500 km 的事件數是 800 km 的 59 倍。為什麼是 500 km?查一下那個高度有什麼。
  2. 700 km 的事件數只有 21 筆,最小距離卻是全部裡最近的(0.349 km)。
    「事件少」與「安全」是同一件事嗎? 你會用哪一個當設計判準?
  3. 再加一個維度:固定高度 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 一致;且均勻粗掃找不到(反面教材)

要回答的問題:

  1. 為什麼「TLE 曆元的新鮮度」要當成一個檢查項?不查會怎樣?
  2. 驗證段有一項是「均勻粗掃找不到這個事件」。
    為什麼要把一個失敗寫成通過條件?
  3. 自己加一項:確認 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。兩個問題:

  1. 入軌成功了嗎? —— 燒盡時的狀態向量隱含了一條軌道,近地點夠不夠高?
  2. 這條軌道會不會撞到別人? —— 拿當前的太空物體目錄篩一遍。

第二個問題現在是發射前的法定程序,不是可選項。
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 交會分析:一個「找針」的問題

交會分析要在三天、一萬六千個物體裡,找出幾公里的接近。難點不是物理,是搜尋:

所以流程一定是兩階段:

前置濾波 (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 目錄的兩個致命問題

交會分析的結論不會比它的目錄更可信。目錄有兩個致命問題:

  1. 過期。TLE 的精度隨曆元距離衰減;LEO 幾天就會明顯偏離。
    本章實測踩過:場景設在 2024 年、目錄是 2026 年的,
    AdvCAT 把每個物體標成 Vehicle Out of Date,
    然後回報 0 筆事件 —— 而且 Compute 只花 0.2 秒就「完成」。
    看起來就像是真的很安全。
  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. 本章踩到的坑

6. 延伸閱讀

交會與太空交通管理

軌道力學

工具

7. 課前自我檢核

  1. 燒盡速度差 1.3%,近地點差多少?為什麼入軌對速度這麼敏感?
  2. 相對速度 14 km/s、最小距離 1.8 km。要用固定網格找到這個事件,
    取樣間隔最多能多大?為什麼實務上不這樣做?
  3. AdvCAT 回報「0 筆交會事件」。列出三個你要先排除的可能原因。
  4. 為什麼 TLE 算不出碰撞機率?缺的是什麼?那個東西從哪裡來?
  5. 500 km 的交會事件是 800 km 的 59 倍,但 700 km 的最小距離最近。
    這兩件事矛盾嗎?你會用哪一個當設計判準?

圖


launch_conjunction/lc_anim.gif


launch_conjunction/lc_conjunction.png

程式碼

launch_conjunction/launch_conjunction_Connect.txt
# ==============================================================================
# 雙節運載火箭入軌 + 交會迴避分析 — 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"
launch_conjunction/lc_animate.py
# -*- 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)
launch_conjunction/lc_build.py
# -*- 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)
launch_conjunction/lc_conjunction.py
# -*- 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)
launch_conjunction/lc_deck.py
# -*- 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", "實驗單"),
    ],
)
launch_conjunction/lc_verify.py
# -*- 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)