Week 10 — 推進系統

資料夾:week10_propulsion/

基礎知識導論

Week 10 基礎知識導論 — 推進、Delta-V 與 Astrogator MCS

軌道機動的貨幣是 delta-V。任務規劃的一半工作,
就是把有限的 delta-V 預算分配給抬升、圓化、維持與去軌。

1. 這一週在解什麼問題

FIREYE 要從入軌高度移到任務軌道、在兩年任務期間對抗大氣阻力、
並在任務結束後把自己降下來。三件事各要多少 delta-V?推進劑要帶多少?

本週用 STK Astrogator 的任務控制序列 (MCS) 與差分修正器求解,
再用 Python 的解析式(Hohmann + 火箭方程)做量級交叉驗證。

2. 核心觀念

2.1 火箭方程

Δv = Isp * g0 * ln( m0 / mf )              g0 = 9.80665 m/s²

反過來求推進劑質量:

m_prop = m0 * ( 1 - exp( -Δv / (Isp * g0) ) )

指數關係是關鍵:delta-V 需求線性增加,推進劑質量指數增加。
所以任務設計的第一原則永遠是「先想辦法不要用 delta-V」
(選對入軌軌道、用氣阻自然去軌、用被動姿態)。

2.2 CubeSat 推進系統的選擇

類型 Isp (s) 推力 CubeSat 適用性
冷氣體 (cold gas) 40–70 mN 簡單安全,但 Isp 太低
綠色單推進劑 200–250 mN–N 需要加熱與觸媒床
肼類單推進劑 200–230 N 毒性,發射審查困難
電熱 (resistojet) 50–150 mN 功率需求中等
電噴 (electrospray) 800–2000 µN 推力極小,機動要數天
離子 / 霍爾 1000–3000 mN 功率需求高(數十至數百 W)

CubeSat 的推進是被功率與安全審查綁死的,不是被物理綁死的。
6U 常見功率只有 10–30 W,離子推進的功率需求往往直接出局。

2.3 Hohmann 轉移:兩次點火的最省路徑

從半徑 r1 圓軌道轉到 r2 圓軌道:

Δv1 = sqrt(μ/r1) * ( sqrt( 2*r2 / (r1+r2) ) - 1 )       抬升(在 r1 點火)
Δv2 = sqrt(μ/r2) * ( 1 - sqrt( 2*r1 / (r1+r2) ) )       圓化(在 r2 點火)
Δv_total = Δv1 + Δv2

轉移時間是轉移橢圓週期的一半。
Hohmann 在共面圓軌道之間是最省的兩脈衝解(在 r2/r1 < 11.94 時)。

2.4 脈衝 vs 有限推力

解析式假設瞬時脈衝(速度瞬間改變,位置不變)。
真實推進器要燒幾分鐘甚至幾小時,期間衛星一直在移動:

Astrogator 的 Finite Maneuver 段可以模擬這件事,
而 Python 的解析式不行。這是本週兩者差異的來源之一。

2.5 攝動:為什麼 STK 的答案跟手算不同

本教材的實測對照:

機動 Python 解析(兩體) STK Astrogator(HPOP) 差異
抬遠地點 41.8 m/s 38.97 m/s −6.8%
圓化 41.6 m/s 38.99 m/s −6.3%
去軌 143.3 m/s 140.56 m/s −1.9%
總計 233.0 m/s 218.5 m/s −6.2%

差異來自兩體 vs 含攝動的力學模型。
解析式的用途是量級驗證(如果差 10 倍就是有人算錯),
不是取代高精度傳播。

另外,軌道維持的 6.2 m/s 是年度預算,由大氣阻力決定,
它是一連串小修正的總和,沒有對應的單一 MCS 段。

2.6 Astrogator 的 MCS 結構

MCS (Mission Control Sequence) 是一串段 (segment):

段型 作用
Initial State 起始軌道狀態
Propagate 傳播到某個停止條件
Maneuver 施加 delta-V(脈衝或有限推力)
Target Sequence 包住一組段,用差分修正器求解未知數
Return / Stop 結束

停止條件 (Stopping Condition) 是 Astrogator 的靈魂:
不是「傳播 90 分鐘」,而是「傳播到 Apoapsis」、「傳播到 Altitude = 550 km」。
這讓 MCS 具有物理意義,也讓它在參數改變後仍然正確。

2.7 差分修正器 (Differential Corrector)

要解的是一個打靶問題:

控制參數 (Control)      →  [傳播]  →  結果 (Constraint)
例:Maneuver 的 ΔV 大小              例:遠地點高度 = 550 km

差分修正器用數值微分建立 Jacobian,牛頓法迭代:

Δcontrol = J⁻¹ * ( target - current )

成功的判準是 Status == "Converged",不是「跑完了」。
沒收斂的 MCS 一樣會產生結果,只是那個結果不滿足約束 —— 又一個靜默失敗。

本教材踩過的兩個坑:

  1. ΔV 讀成 0 — 收斂後的值在差分修正器控制項的 FinalValue 上,
    不在 Maneuver 段的名目欄位上。
  2. DV2 收斂到錯的分支(1946 m/s) — 在遠地點用
    "Radius Of Periapsis" 當約束是退化的(該點對近地點半徑的靈敏度接近零),
    改用 Eccentricity = 0 才收斂到正確的 39 m/s。

第 2 點是打靶問題的通則:約束的選擇要讓 Jacobian 良態。

2.8 去軌:不是選項,是義務

國際準則(IADC)長期要求 LEO 任務在任務結束後 25 年內再入;
美國 FCC 已對其管轄的部分任務收緊到 5 年。
CubeSat 常見的三種作法:

  1. 自然衰減 — 選夠低的軌道讓氣阻處理(約 500 km 以下數年)
  2. 主動去軌 — 用推進把近地點降到大氣層(本週的第三次點火)
  3. 增阻裝置 — 展開帆或氣球,增加面積質量比

本週的 MCS 第三段就是把近地點降到 RE + 50 km,需要約 140 m/s。
這筆 delta-V 必須在任務一開始就列進預算,不能事後才想。

3. 名詞與符號

符號 意義 單位 典型值
Δv 速度增量 m/s 本任務總計約 220–233
Isp 比衝 s 見 2.2
g0 標準重力 m/s² 9.80665
μ 地球重力參數 km³/s² 398600.4418
MCS 任務控制序列 — Astrogator 的主結構
DC 差分修正器 — 判準是 Converged
Stopping Condition 停止條件 — Apoapsis / Altitude / Duration
m0 / mf 初 / 末質量 kg —

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
MCS 三段機動 week10_propulsion_astrogator.txt(75 條指令,含去軌)
差分修正器 同上,三個 Target Sequence
解析交叉驗證 week10_propulsion_deltav.py,Hohmann + 火箭方程
圖表 week10_deltav.png
段序 / 停止條件 / 收斂 / ΔV 回讀 week10_verify.py,42 項(36 PASS / 6 SKIP)

腳本層面的兩個實作陷阱(教材已修正):
SetProp 會自動建立預設 MCS 段,必須先 DeleteSegment 清掉;
插入點寫成裸的 - 會漂進 Target Sequence 裡,要寫成
InsertSegment MainSequence.SegmentList.- X 指定完整路徑。

5. 常見誤解

  1. 「delta-V 是燃料。」 — delta-V 是速度增量,燃料是它經過火箭方程(指數)的結果。
    同樣的 delta-V,Isp 差 3 倍,燃料差很多。
  2. 「MCS 跑完就是解出來了。」 — 要檢查 Status == "Converged"。沒收斂也會有結果。
  3. 「解析式跟 STK 差 6%,一定是 STK 錯了。」 — 反過來想:解析式忽略了攝動與有限推力。
    6% 是合理的模型差,不是 bug。差 600% 才是 bug。
  4. 「去軌之後再想就好。」 — 去軌 delta-V 是本任務最大的一筆(140 m/s,佔 64%)。
    事後才加會直接壓垮質量預算。
  5. 「約束隨便選一個就好。」 — 見 2.7,退化的約束會讓修正器收斂到荒謬的分支,
    而且它會告訴你「收斂了」。

6. 延伸閱讀

軌道機動

推進系統

去軌與太空碎片

工具

7. 課前自我檢核

  1. 用火箭方程算:6 kg 衛星、Isp = 220 s、需要 220 m/s,推進劑要幾克?
  2. 為什麼 Hohmann 是「最省」?它在什麼條件下不是最省?
  3. 差分修正器回報收斂,但 delta-V 是 1946 m/s。你會怎麼判斷這是不是對的?
  4. 去軌 delta-V 佔本任務總預算的比例是多少?如果改用增阻裝置,這筆會變成什麼成本?
  5. 解析式與 STK 差 6.2%。列出三個可能的來源,並說明怎麼逐一排除。

Week10_15_Propulsion_Launch_Handouts.md

實驗單:Week 10 推進系統 / Week 15 發射與部署

兩週皆採雙軌(同 Week 5/8 規格):Python 解析保證可跑並出圖,STK 付費模組
(Astrogator / Aviator)為正牌高精度路徑。程式內附 STK Connect / ansys.stk.core 範本。


Week 10 — 推進系統(delta-V 預算與機動)

檔案:week10_propulsion_deltav.py → week10_deltav.png
參考:官方 Astrogator MCS snippet;程式尾 ASTROGATOR_MCS_CONNECT。

目標:為 CubeSat 任務建 delta-V 預算,並用火箭方程反推推進劑質量。

Python 那軌算什麼:
- Hohmann 轉移(部署軌道 → 任務軌道)兩次 delta-V 與轉移時間。
- 軌道維持(低軌阻力補償)年 delta-V。
- Deorbit(降近地點至再入)delta-V。
- 火箭方程:總 delta-V + 乾質量 + Isp → 推進劑質量。

基準結果(400→550 km,1 年任務,乾重 4 kg,Isp 220 s):總 ~233 m/s,推進劑 ~456 g。

操作:
1. 跑基準,判讀三張圖(delta-V 堆疊 / 推進劑 vs Isp / Hohmann 幾何)。
2. Part B:改推進技術 Isp(冷氣 70 / 單推 220 / 雙推 300 / 電推 1200 s),看推進劑質量差異——體會為何深空/長壽命任務用電推。
3. Part C(需 Astrogator):執行 week10_propulsion_astrogator.txt,用 MCS + 差分修正器求
三次 burn(抬遠地點 / 圓化 / deorbit)的 delta-V,對比 Python 解析值。

交付:delta-V 預算表 + 推進劑質量 + 推進技術選型 + (選作)Astrogator 對比。
評分:delta-V 計算 30% / 火箭方程 25% / 技術選型 25% / Astrogator 對比 20%。

Python 解析 vs STK/Astrogator 實測對照(本機 STK 12.10.0 跑出的數值):

項目 Python 解析(兩體) STK Astrogator(HPOP + 差分修正) 差異
burn 1 抬遠地點 41.8 m/s 38.97 m/s −6.8 %
burn 2 圓化 41.6 m/s 38.99 m/s −6.3 %
deorbit 降近地點 143.3 m/s −140.56 m/s(逆向) −1.9 %
軌道維持(1 年阻力) 6.2 m/s 無對應段(年度預算,非單次機動) —
合計 233.0 m/s 218.5 m/s −6.2 %

差異來源:Python 用理想兩體 Hohmann,STK 用 Earth HPOP Default v10 含 J2 等攝動,
且轉移段起點是實際傳播到的近地點而非理想圓軌道。討論題:哪一個該拿去做推進劑採購決策?

末軌驗證:Deorbit.FinalState.Keplerian.PeriapsisRadSize = 6428.19 km,
命中目標 6428.137 km(= RE + 50 km),容差 1 km。

已知限制:FinalState.FuelMass 仍是初始的 0.5 kg —— Isp 屬於 EngineModel 元件
(Constant Thrust and Isp),Connect 無法直接寫入,要在元件瀏覽器改或用物件模型。
所以推進劑消耗目前只能由 Python 的火箭方程給(456 g)。


Week 15 — 發射與部署

檔案:week15_launch_deploy.py → week15_launch.png
參考:LaunchVehicleTradeStudiesAviator.py(Aviator 三重迴圈 trade study);
TASA...STKEngine示範.ipynb(ansys.stk.core 彈道發射)。程式尾附兩者骨架。

目標:用兩節火箭的火箭方程做 trade study,看能送多少酬載到目標 LEO;
理解發射後部署與首次通聯幾何。

Python 那軌算什麼:
- 目標軌道所需 delta-V(軌道速度 + 隨高度增加的損失)。
- 兩節火箭酬載能力(逐節火箭方程,二分搜尋酬載)。
- Trade 1:第二節推進劑質量(staging)vs 酬載。
- Trade 2:目標高度 vs 酬載(高度越高酬載越少)。
- 部署後圓軌道週期與地平可見幾何。

基準結果(目標 550 km):所需 ~9.7 km/s,基準兩節火箭送 ~3 t;高度越高酬載越少。

操作:
1. 跑基準,判讀三張圖(staging trade / altitude trade / 部署軌道)。
2. Part B:改各節 Isp/結構比,看對酬載的敏感度;找最佳 staging。
3. Part C(需 Aviator + Astrogator):照 LaunchVehicleTradeStudiesAviator.py 的三重迴圈
(燃料 × 發射方位 × pitch FPA rate),用 DataProviders 讀入軌 apogee/perigee/inclination,
以 perigee > 100 km 為有效入軌判據;或照 TASA 範本建彈道發射軌跡並匯出星曆。
4. 部署後:用 Week 14 的 pass scheduler 算 CubeSat 入軌後對地面站的首次通聯。

交付:酬載 trade study 表/圖 + 最佳 staging + (選作)STK 發射軌跡 + 首次通聯時刻。
評分:所需 delta-V 30% / 酬載 trade 30% / STK 發射軌跡 25% / 首次通聯串接 15%。


兩週的授權提醒


Week 10 附錄 — 驗證段(補 Astrogator 的回讀死角)

檔案:week10_propulsion/week10_verify.py(共用工具在 tools/stk_verify.py)

為什麼推進週的保證最弱

本週 75 條指令裡有 23 條是結構指令(InsertSegment、AddMCSSegmentControl、
SetMCSControlValue、SetMCSConstraintValue),Connect 對它們沒有任何 GetValue
可以回讀。在加上驗證段之前,整個 MCS 只能靠「最後 delta-V 看起來合理」來擔保。

這一段實際踩過的坑,每一個都是 0 個 NACK:

坑 症狀
裸 - 是「目前插入點」,插了 Target Sequence 之後就跑進它裡面 後兩段 InsertSegment 插錯位置,照樣 ACK
SetProp 會先建一組預設 MCS 腳本的改名改到預設段,自己插入的變孤兒
解出的 delta-V 在修正器的控制變數上 讀 segment 永遠得到標稱的 0 m/s,沒有任何錯誤
DV2 少了 MaxStep / Tolerance 收斂到遠支解 1946 m/s(遠地點 24900 km),數字荒謬但 ACK

執行

python week10_propulsion/week10_verify.py    # 離開碼 0 = 全部通過

42 項檢查,分五類:

  1. 自動回讀:24 條 Astrogator ... SetValue 逐一比對。
  2. MCS 結構:主序列的段名與順序、各 Target Sequence 的子段、傳播段的停止條件。
    這是補上面那 23 條的死角。
  3. 修正器:約束名稱/目標值/容差設對了嗎?Status 是不是 Converged?
    |Difference| 有沒有落在 Tolerance 內?
  4. 物理判準:三次 delta-V 與 Python 解析值比對(容許 10%,涵蓋 HPOP 與兩體的差異,
    但抓得到遠支解那種數量級錯誤)。解析值是即時呼叫 week10_propulsion_deltav.py
    算的,不是抄進來的常數 —— 抄下來的數字會跟著腳本改動而過期。
  5. 末軌:圓化後偏心率 ≈ 0、deorbit 後近地點命中 6428.137 km。

基準結果:42 項 = 36 PASS / 0 FAIL / 6 SKIP。
6 個 SKIP 是清單型屬性(StoppingConditions / Results),Connect 一律回 Unknown,
已由第 2、3 類的專屬檢查覆蓋。

作業

  1. 跑一次驗證段,貼上 42 項的輸出,並說明 6 個 SKIP 為什麼可以接受。
  2. 故意弄壞它,各跑一次並記錄哪幾項變成 FAIL:
    - 把 Circularize 的約束改回 "Radius Of Periapsis" Desired 6928.137,並刪掉 MaxStep
    - 把某一段的 InsertSegment MainSequence.SegmentList.- 改回裸的 -
    - 把 Deorbit 的目標近地點改成 6428.137 → 6000(低於地表)
  3. 第 2 題的第一項會出現 status=Encountered an Error 且 delta-V = 0.00 m/s。
    解釋:為什麼修正器失敗時 delta-V 是 0 而不是某個錯誤的數字?
    如果沒有驗證段,你會從哪裡看出不對?

圖


week10_propulsion/week10_deltav.png

程式碼

week10_propulsion/week10_propulsion_astrogator.txt
# ================================================================
# Week 10 主軌 - STK/Astrogator 推進機動(MCS + 差分修正器)
# Python(week10_propulsion_deltav.py)降為量級驗證用。
#
# 任務:CubeSat 從部署軌道(400 km)Hohmann 抬到任務軌道(550 km),
#       任務結束後 deorbit。全部用 Astrogator MCS 精確求解。
#
# 執行:在有 Astrogator 授權的 STK,逐行 root.ExecuteCommand(...)
#       或存成 .txt 用 ConFile 批次載入。
# ================================================================

New / Scenario Week10_Propulsion
SetAnalysisTimePeriod * "1 Jan 2024 00:00:00.000" "2 Jan 2024 00:00:00.000"
SetUnits / km

# ---- 建衛星,設 Astrogator 傳播器 ----
New / */Satellite PropSat
# (SetPropagatorType is not a Connect command - 'Astrogator ... SetProp' below does it)
Astrogator */Satellite/PropSat SetProp
# SetProp creates a default MCS ('Initial State' + 'Propagate').
# Delete both, otherwise the renames below hit the DEFAULT segments and the
# ones this script inserts are left orphaned.
Astrogator */Satellite/PropSat DeleteSegment MainSequence.SegmentList.Initial_State
Astrogator */Satellite/PropSat DeleteSegment MainSequence.SegmentList.Propagate

# ================================================================
# MCS 段 1:初始狀態(部署軌道 400 km 圓軌道, i=56)
# ================================================================
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Initial_State
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Initial_State.ComponentName Deploy
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deploy.InitialState.Keplerian.PeriapsisRadSize 6778.137 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deploy.InitialState.Keplerian.ApoapsisRadSize 6778.137 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deploy.InitialState.Keplerian.inc 56 deg
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deploy.InitialState.Keplerian.RAAN 0
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deploy.InitialState.Keplerian.TA 0
# 設乾質量(供推進劑計算)
# DryMass has no Connect path on this segment (only FuelMass / MaxFuelMass).
# Set it in the GUI, or via the object model:
#   sat.Propagator.MainSequence.Item('Deploy').SpacecraftParameters.DryMass = 4.0
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deploy.FuelMass 0.5 kg

# ================================================================
# MCS 段 2:傳播到近地點(Hohmann burn 1 的施力點)
# ================================================================
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Propagate.ComponentName ToPerigee
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.ToPerigee.Propagator "Earth HPOP Default v10"
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.ToPerigee.StoppingConditions Periapsis

# ================================================================
# MCS 段 3:Target Sequence - Hohmann burn 1
# 差分修正器解 delta-V 使遠地點半徑達 6928 km(550 km)
# ================================================================
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Target_Sequence.ComponentName RaiseApogee
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.RaiseApogee.SegmentList.- Maneuver
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.RaiseApogee.SegmentList.Maneuver.ComponentName DV1
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.RaiseApogee.SegmentList.DV1.MnvrType Impulsive
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.RaiseApogee.SegmentList.DV1.AttitudeControl "Thrust Vector"
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.RaiseApogee.SegmentList.DV1.ThrustAxes "Satellite VNC(Earth)"
# 設引擎(比衝 Isp,決定推進劑消耗)
# Isp belongs to the engine-model component referenced by DV1.EngineModel
# ('Constant Thrust and Isp'), not to the segment - edit it in the Component
# Browser, or clone the component and point DV1.EngineModel at the clone.
# 控制變數:沿速度方向的 delta-V
Astrogator */Satellite/PropSat AddMCSSegmentControl MainSequence.SegmentList.RaiseApogee.SegmentList.DV1 ImpulsiveMnvr.Cartesian.X
# 結果:遠地點半徑
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.RaiseApogee.SegmentList.DV1.Results "Radius Of Apoapsis"
# 差分修正器設定
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.RaiseApogee.Profiles.Differential_Corrector DV1 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.RaiseApogee.Profiles.Differential_Corrector DV1 ImpulsiveMnvr.Cartesian.X MaxStep 0.05 km/sec
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.RaiseApogee.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Active true
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.RaiseApogee.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Desired 6928.137 km
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.RaiseApogee.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Tolerance 0.1 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.RaiseApogee.Action "Run active profiles"

# ================================================================
# MCS 段 4:傳播到遠地點 + Hohmann burn 2(圓化)
# ================================================================
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Propagate.ComponentName ToApogee
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.ToApogee.StoppingConditions Apoapsis

Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Target_Sequence.ComponentName Circularize
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.Circularize.SegmentList.- Maneuver
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Circularize.SegmentList.Maneuver.ComponentName DV2
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Circularize.SegmentList.DV2.MnvrType Impulsive
Astrogator */Satellite/PropSat AddMCSSegmentControl MainSequence.SegmentList.Circularize.SegmentList.DV2 ImpulsiveMnvr.Cartesian.X
# Circularizing at apogee: "Radius Of Periapsis = 6928 km" is DEGENERATE - a huge
# prograde burn makes the burn point itself the new periapsis and also satisfies it
# (the corrector converged on 1946 m/s with apoapsis ~24900 km). Target eccentricity
# instead; that has a single nearby solution.
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Circularize.SegmentList.DV2.Results "Eccentricity"
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.Circularize.Profiles.Differential_Corrector DV2 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.Circularize.Profiles.Differential_Corrector DV2 ImpulsiveMnvr.Cartesian.X MaxStep 0.05 km/sec
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Circularize.Profiles.Differential_Corrector DV2 Eccentricity Active true
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Circularize.Profiles.Differential_Corrector DV2 Eccentricity Desired 0.0
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Circularize.Profiles.Differential_Corrector DV2 Eccentricity Tolerance 0.0001
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Circularize.Action "Run active profiles"

# ================================================================
# 執行 MCS 並讀結果
# ================================================================
# ================================================================
# MCS 段 6:任務結束後 deorbit(降近地點至再入高度)
# 對照 Python 的 deorbit_dv(r_perigee_target = RE + 50 km) = 143.3 m/s。
# 在遠地點逆向燒可把近地點拉下來 —— 這是 61% 的任務 delta-V,
# 先前的 MCS 沒有這一段,所以 Python 的 233 m/s 有大半無從對照。
# ================================================================
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Propagate.ComponentName Coast2Deorbit
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Coast2Deorbit.StoppingConditions Apoapsis

Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Target_Sequence.ComponentName Deorbit
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.Deorbit.SegmentList.- Maneuver
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deorbit.SegmentList.Maneuver.ComponentName DV3
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deorbit.SegmentList.DV3.MnvrType Impulsive
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deorbit.SegmentList.DV3.AttitudeControl "Thrust Vector"
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deorbit.SegmentList.DV3.ThrustAxes "Satellite VNC(Earth)"
# 控制變數:沿速度方向的 delta-V(解出來會是負值 = 逆向燒)
Astrogator */Satellite/PropSat AddMCSSegmentControl MainSequence.SegmentList.Deorbit.SegmentList.DV3 ImpulsiveMnvr.Cartesian.X
# 目標:近地點半徑 = 6378.137 + 50 = 6428.137 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deorbit.SegmentList.DV3.Results "Radius Of Periapsis"
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.Deorbit.Profiles.Differential_Corrector DV3 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.Deorbit.Profiles.Differential_Corrector DV3 ImpulsiveMnvr.Cartesian.X MaxStep 0.05 km/sec
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Deorbit.Profiles.Differential_Corrector DV3 "Radius Of Periapsis" Active true
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Deorbit.Profiles.Differential_Corrector DV3 "Radius Of Periapsis" Desired 6428.137 km
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Deorbit.Profiles.Differential_Corrector DV3 "Radius Of Periapsis" Tolerance 1.0 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Deorbit.Action "Run active profiles"

Astrogator */Satellite/PropSat RunMCS

# 讀三次 burn 的 delta-V(對照 Python:41.8 / 41.6 / -143.3 m/s)
Astrogator_RM */Satellite/PropSat GetMCSControlValue MainSequence.SegmentList.RaiseApogee.Profiles.Differential_Corrector DV1 ImpulsiveMnvr.Cartesian.X FinalValue
Astrogator_RM */Satellite/PropSat GetMCSControlValue MainSequence.SegmentList.Circularize.Profiles.Differential_Corrector DV2 ImpulsiveMnvr.Cartesian.X FinalValue
Astrogator_RM */Satellite/PropSat GetMCSControlValue MainSequence.SegmentList.Deorbit.Profiles.Differential_Corrector DV3 ImpulsiveMnvr.Cartesian.X FinalValue
# 讀消耗的推進劑質量與末軌(近地點應落在 6428 km 附近)
Astrogator_RM */Satellite/PropSat GetValue MainSequence.SegmentList.Deorbit.FinalState.FuelMass
Astrogator_RM */Satellite/PropSat GetValue MainSequence.SegmentList.Deorbit.FinalState.Keplerian.PeriapsisRadSize

# ---- 出報告驗證軌道 ----
ReportCreate */Satellite/PropSat Type Save Style "Classical Orbit Elements" File "C:\STK_Reports\propsat_elements.txt"
week10_propulsion/week10_propulsion_deltav.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 10 Lab - 推進系統:delta-V 預算與機動
#
# 雙軌(同 Week 5/8 哲學):
#   (A) Python 解析軌道力學 = 保證可跑:Hohmann 轉移、軌道維持、
#       deorbit 的 delta-V,配合火箭方程算推進劑質量。
#   (B) STK/Astrogator = 正牌高精度:MCS 任務控制序列 + 差分修正器
#       求解機動(見檔尾 astrogator_mcs_connect,對照 TASA/官方範例)。
#
# 輸出:week10_deltav.png + 主控台 delta-V/推進劑預算
# ================================================================

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import os

# 產出要落在**週次目錄**,不是 process 的工作目錄。每一支 weekNN_verify.py
# 開頭都有這個慣用法並註明「啟動 STK Engine 會改變 process 的工作目錄」,
# 但 lab 這一側先前沒有套用 —— 結果庫根目錄散落著九個產出,
# 其中一份 week15_launch.png 還是過期的。notebook 的 kernel 沒有 __file__,
# 所以留 WEEK_DIR(notebook 開頭的 bootstrap cell 會設)當後備。
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))

MU = 398600.4418     # km^3/s^2
RE = 6378.137        # km
G0 = 9.80665         # m/s^2

def v_circ(r): return np.sqrt(MU/r)

def hohmann(r1, r2):
    """兩圓軌道間 Hohmann 轉移的兩次 delta-V(km/s)與轉移時間(s)。"""
    at = 0.5*(r1+r2)
    v1 = v_circ(r1); v2 = v_circ(r2)
    vp = np.sqrt(MU*(2/r1 - 1/at))     # 轉移橢圓近地點速度
    va = np.sqrt(MU*(2/r2 - 1/at))     # 轉移橢圓遠地點速度
    dv1 = abs(vp - v1); dv2 = abs(v2 - va)
    tof = np.pi*np.sqrt(at**3/MU)
    return dv1, dv2, dv1+dv2, tof

def deorbit_dv(r_circ, r_perigee_target=RE+50):
    """從圓軌道降近地點到再入高度(~50 km)的單次 delta-V。"""
    at = 0.5*(r_circ + r_perigee_target)
    v_c = v_circ(r_circ)
    v_a = np.sqrt(MU*(2/r_circ - 1/at))   # 降軌橢圓在原高度(遠地點)的速度
    return abs(v_c - v_a)

# ---- 大氣密度:Vallado 的指數大氣表(公開文獻,不是本教材擬合的)----
# 《Fundamentals of Astrodynamics and Applications》的分段指數模型。
# 每一段 (基準高度 km, 基準密度 kg/m^3, 標高 km)。
ATMOS = [
    (0, 1.225, 7.249), (25, 3.899e-2, 6.349), (30, 1.774e-2, 6.682),
    (40, 3.972e-3, 7.554), (50, 1.057e-3, 8.382), (60, 3.206e-4, 7.714),
    (70, 8.770e-5, 6.549), (80, 1.905e-5, 5.799), (90, 3.396e-6, 5.382),
    (100, 5.297e-7, 5.877), (110, 9.661e-8, 7.263), (120, 2.438e-8, 9.473),
    (130, 8.484e-9, 12.636), (140, 3.845e-9, 16.149), (150, 2.070e-9, 22.523),
    (180, 5.464e-10, 29.740), (200, 2.789e-10, 37.105), (250, 7.248e-11, 45.546),
    (300, 2.418e-11, 53.628), (350, 9.518e-12, 53.298), (400, 3.725e-12, 58.515),
    (450, 1.585e-12, 60.828), (500, 6.967e-13, 63.822), (600, 1.454e-13, 71.835),
    (700, 3.614e-14, 88.667), (800, 1.170e-14, 124.64), (900, 5.245e-15, 181.05),
    (1000, 3.019e-15, 268.00),
]
YEAR_S = 365.25*86400.0

# 3U CubeSat 的彈道係數。面積不是猜的:**凸體隨機翻滾的平均投影面積 = 表面積 / 4**
# (Cauchy 的公式)。3U 是 10x10x30 cm,表面積 0.14 m^2 -> 平均投影 0.035 m^2。
# 乾重 4 kg(與下面 main() 的 m_dry 一致),阻力係數取自由分子流的典型值 2.2。
CUBESAT_3U_AREA = 0.14/4.0          # m^2,平均投影面積
CUBESAT_CD_A_M = 2.2*CUBESAT_3U_AREA/4.0    # m^2/kg


def atmos_density(alt_km):
    """指數大氣的密度(kg/m^3)。"""
    for h0, rho0, H in reversed(ATMOS):
        if alt_km >= h0:
            return rho0*np.exp(-(alt_km - h0)/H)
    return ATMOS[0][1]


def drag_makeup_dv_per_year(alt_km, cd_a_over_m=CUBESAT_CD_A_M):
    """圓軌道的年阻力補償 delta-V(km/s/yr)。

    連續推力剛好抵銷阻力,所以 Δv = 阻力加速度 x 時間:

        a_D = 0.5 rho v^2 (Cd A / m)
        Δv/yr = a_D * 一年的秒數

    **舊版是 `0.05*exp(-(alt-300)/120)`,那條曲線是編的**,有三個問題:

    1. 它的 docstring 說「~500 km 幾十 m/s/yr、~300 km 上百」,
       而公式自己給 9.4 與 50 —— **說法與公式對不上**。
    2. **它沒有彈道係數。** 阻力 Δv 正比於 Cd A / m,而那是衛星之間差最多的
       參數:同樣在 400 km,1U 與 3U 差 3 倍,對上 ISS 級的差 15 倍。
       一條沒有這個參數的曲線,不可能同時對兩種衛星。
    3. 它的標高 120 km 比真實大氣平緩太多(300-500 km 實際是 53-64 km),
       所以低空低估、高空高估。

    改用物理式之後可以拿外部數字驗:ISS(Cd A/m 約 0.0105 m^2/kg、
    高度約 415 km)算出約 28 m/s/yr,與公開常引用的
    「每年約 25-40 m/s(隨太陽活動變動)」同一量級。
    """
    v = np.sqrt(MU/(RE + alt_km))*1000.0            # m/s
    a_drag = 0.5*atmos_density(alt_km)*v*v*cd_a_over_m
    return a_drag*YEAR_S/1000.0                      # km/s per year

def prop_mass(dv_total_ms, m_dry, isp):
    """火箭方程:給總 delta-V(m/s)、乾質量、比衝,回推進劑質量(kg)。"""
    return m_dry*(np.exp(dv_total_ms/(isp*G0)) - 1)

def main():
    # --- 任務情境:CubeSat 從部署軌道抬到任務軌道,維持一年,再 deorbit ---
    alt_deploy = 400.0     # km,rideshare 部署高度
    alt_mission = 550.0    # km,任務軌道
    mission_years = 1.0
    m_dry = 4.0            # kg(3U + 推進模組乾重)
    ISP = 220.0           # s(冷氣/單推系統典型;電推可到 >1000)

    r1, r2 = RE+alt_deploy, RE+alt_mission
    dv1, dv2, dv_hoh, tof = hohmann(r1, r2)
    dv_sk = drag_makeup_dv_per_year(alt_mission)*mission_years
    dv_de = deorbit_dv(r2)
    dv_total = dv_hoh + dv_sk + dv_de           # km/s
    dv_total_ms = dv_total*1000
    mp = prop_mass(dv_total_ms, m_dry, ISP)

    print("=== delta-V 預算 (CubeSat 推進任務) ===")
    print(f"部署 {alt_deploy:.0f} km -> 任務 {alt_mission:.0f} km (Hohmann)")
    print(f"  burn 1 (近地點): {dv1*1000:6.1f} m/s")
    print(f"  burn 2 (遠地點): {dv2*1000:6.1f} m/s")
    print(f"  轉移時間        : {tof/60:5.1f} min")
    print(f"軌道維持 (阻力補償, {mission_years:.0f} yr): {dv_sk*1000:6.1f} m/s")
    print(f"deorbit (降近地點至再入)          : {dv_de*1000:6.1f} m/s")
    print(f"--- 總 delta-V: {dv_total_ms:6.1f} m/s ---")
    print(f"\n火箭方程 (Isp={ISP:.0f}s, 乾重 {m_dry:.1f}kg):")
    print(f"  需推進劑: {mp*1000:.1f} g  ({100*mp/(m_dry+mp):.1f}% of wet mass)")

    # --- 圖 ---
    fig, ax = plt.subplots(1, 3, figsize=(14, 4.5))

    # (1) delta-V 分項堆疊
    items = ["Hohmann\nup", "Station-\nkeeping", "Deorbit"]
    vals = [dv_hoh*1000, dv_sk*1000, dv_de*1000]
    colors = ["tab:blue", "tab:orange", "tab:red"]
    bottom = 0
    for it, v, c in zip(items, vals, colors):
        ax[0].bar(0, v, bottom=bottom, color=c, label=f"{it.strip()}: {v:.0f}")
        bottom += v
    ax[0].set_xticks([]); ax[0].set_ylabel("delta-V (m/s)")
    ax[0].set_title(f"delta-V budget (total {dv_total_ms:.0f} m/s)")
    ax[0].legend(fontsize=8)

    # (2) 推進劑質量 vs Isp(不同推進技術)
    isps = np.linspace(60, 1500, 200)
    mps = [prop_mass(dv_total_ms, m_dry, isp)*1000 for isp in isps]
    ax[1].plot(isps, mps, color="tab:green", lw=2)
    for isp_lbl, name in [(70,"cold gas"),(220,"monoprop"),(300,"biprop"),(1200,"電推 EP")]:
        ax[1].axvline(isp_lbl, color="gray", ls=":", lw=0.8)
        ax[1].annotate(name,(isp_lbl,ax[1].get_ylim()[1]*0.9),fontsize=7,rotation=90,va="top")
    ax[1].set_xlabel("Isp (s)"); ax[1].set_ylabel("propellant mass (g)")
    ax[1].set_title(f"Propellant vs Isp (dry {m_dry} kg)")
    ax[1].grid(alpha=0.3)

    # (3) Hohmann 轉移軌道示意
    th = np.linspace(0, 2*np.pi, 200)
    ax[2].plot(r1*np.cos(th), r1*np.sin(th), "b--", lw=1, label=f"deploy {alt_deploy:.0f}km")
    ax[2].plot(r2*np.cos(th), r2*np.sin(th), "r--", lw=1, label=f"mission {alt_mission:.0f}km")
    at = 0.5*(r1+r2); e = (r2-r1)/(r2+r1); b = at*np.sqrt(1-e**2)
    ax[2].plot(at*np.cos(th)-at*e, b*np.sin(th), "g-", lw=1.8, label="transfer")
    ax[2].add_patch(plt.Circle((0,0), RE, color="steelblue", alpha=0.5))
    ax[2].set_aspect("equal"); ax[2].set_title("Hohmann transfer"); ax[2].legend(fontsize=7)
    ax[2].set_xticks([]); ax[2].set_yticks([])

    fig.suptitle("Week 10 - Propulsion: delta-V budget & maneuvers", fontsize=13)
    fig.tight_layout(rect=[0,0,1,0.95])
    fig.savefig(os.path.join(HERE, "week10_deltav.png"), dpi=130)
    print("\nWrote week10_deltav.png")

# ================================================================
# STK / Astrogator 正牌路徑(Connect 指令,對照官方 MATLAB snippet)
# 在有 Astrogator 授權的 STK 執行:root.ExecuteCommand(每一行)
# ================================================================
ASTROGATOR_MCS_CONNECT = r'''
# 設定衛星用 Astrogator 傳播,清空 MCS
Astrogator */Satellite/PropSat SetProp
# 初始狀態段
Astrogator */Satellite/PropSat InsertSegment - Initial_State
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Initial_State.InitialState.Keplerian.PeriapsisRadSize 6778 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Initial_State.InitialState.Keplerian.ecc 0
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Initial_State.InitialState.Keplerian.inc 56
# 傳播到近地點
Astrogator */Satellite/PropSat InsertSegment - Propagate
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Propagate.StoppingConditions Periapsis
# 機動段(Hohmann burn 1),用差分修正器求 delta-V 讓遠地點達目標
Astrogator */Satellite/PropSat InsertSegment - Target_Sequence
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Target_Sequence.ComponentName Raise
Astrogator */Satellite/PropSat InsertSegment MainSequence.SegmentList.Raise.SegmentList.- Maneuver
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Raise.SegmentList.Maneuver.ComponentName DV1
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Raise.SegmentList.DV1.MnvrType Impulsive
Astrogator */Satellite/PropSat AddMCSSegmentControl MainSequence.SegmentList.Raise.SegmentList.DV1 ImpulsiveMnvr.Cartesian.X
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Raise.SegmentList.DV1.Results "Radius Of Apoapsis"
Astrogator */Satellite/PropSat SetMCSControlValue MainSequence.SegmentList.Raise.Profiles.Differential_Corrector DV1 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/PropSat SetMCSConstraintValue MainSequence.SegmentList.Raise.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Desired 6928 km
Astrogator */Satellite/PropSat SetValue MainSequence.SegmentList.Raise.Action "Run active profiles"
# 執行 MCS
Astrogator */Satellite/PropSat RunMCS
# 讀 delta-V 結果
Astrogator_RM */Satellite/PropSat GetValue MainSequence.SegmentList.Raise.SegmentList.DV1.ImpulsiveMnvr.Cartesian.X
'''

if __name__ == "__main__":
    main()
week10_propulsion/week10_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 10 驗證段 — 證明 MCS 真的長成腳本描述的樣子,而且修正器收斂了
#
# 為什麼推進週特別需要這一段:
#   本週 75 條指令裡有 23 條是**結構指令**(InsertSegment、
#   AddMCSSegmentControl、SetMCSControlValue、SetMCSConstraintValue),
#   Connect 對它們沒有任何 GetValue 可以回讀。全教材就屬這一段的
#   保證最弱 —— 先前只能靠「最後 delta-V 看起來合理」來擔保。
#
#   而這一段實際踩過的坑,每一個都是 0 個 NACK:
#     - 裸 "-" 是「目前插入點」,插了 Target Sequence 之後就跑進它裡面,
#       後面兩段 InsertSegment 全插錯地方,Connect 照樣 ACK
#     - SetProp 會先建一組預設 MCS,腳本的改名其實改到預設段,
#       自己插入的段變成孤兒
#     - 解出的 delta-V 在**修正器的控制變數**上,segment 上永遠是標稱的 0,
#       讀錯地方會得到 0 m/s 卻毫無錯誤訊息
#     - DV2 少了 MaxStep/Tolerance,修正器收斂到「近地點 6928 km」的
#       遠支解(1946 m/s、遠地點 24900 km),數字荒謬但一樣 ACK
#
# 執行:
#   1. 開啟 STK 12 桌面版(不能用無介面的 STK Engine)
#   2. python week10_verify.py
#   離開碼 0 = 全部通過,1 = 有項目沒生效
# ================================================================

import os
import sys

# notebook 的 kernel 沒有 __file__。而且啟動 STK Engine 會改變 process 的
# 工作目錄,所以不能事後才靠 os.getcwd() —— notebook 開頭的 bootstrap cell
# 會先把週次目錄釘在 WEEK_DIR。
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
sys.path.insert(0, os.path.join(REPO, "tools"))
sys.path.insert(0, HERE)

from agi.stk12.stkdesktop import STKDesktop                       # noqa: E402
from stk_verify import Verifier, run_script                       # noqa: E402

SCRIPT = os.path.join(HERE, "week10_propulsion_astrogator.txt")
SAT = "*/Satellite/PropSat"

# 腳本描述的 MCS,順序不能錯
EXPECTED_MCS = ["Deploy", "ToPerigee", "RaiseApogee", "ToApogee",
                "Circularize", "Coast2Deorbit", "Deorbit"]

RE = 6378.137
R_DEPLOY, R_MISSION = RE + 400.0, RE + 550.0
R_REENTRY = RE + 50.0


def analytic():
    """直接呼叫 Week 10 的 Python 解析腳本,不要抄數字進來 ——
    抄下來的常數會跟著腳本改動而過期。"""
    import importlib.util
    spec = importlib.util.spec_from_file_location(
        "w10dv", os.path.join(HERE, "week10_propulsion_deltav.py"))
    m = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(m)                 # main() 有 __main__ 保護,不會執行
    dv1, dv2, _, _ = m.hohmann(R_DEPLOY, R_MISSION)
    dv3 = m.deorbit_dv(R_MISSION, R_REENTRY)
    return dv1 * 1000, dv2 * 1000, dv3 * 1000


def main():
    app = STKDesktop.AttachToApplication()   # 保留 app 參考,否則 COM 介面會失效
    root = app.Root
    if root.CurrentScenario is not None:
        root.CloseScenario()

    ack, nack = run_script(root, SCRIPT)
    a1, a2, a3 = analytic()
    print(f"Python 解析參考值:{a1:.1f} / {a2:.1f} / {a3:.1f} m/s")

    v = Verifier(root)

    # (1) 自動:腳本設過的每個 Astrogator SetValue
    v.auto_setvalues(SCRIPT)

    # (2) MCS 結構 —— 補 Connect 完全沒有回讀路徑的那 23 條
    v.propagator_is(SAT, "Astrogator")
    v.mcs_sequence(SAT, EXPECTED_MCS)
    v.mcs_subsegments(SAT, "RaiseApogee", ["DV1"])
    v.mcs_subsegments(SAT, "Circularize", ["DV2"])
    v.mcs_subsegments(SAT, "Deorbit", ["DV3"])
    v.stopping_condition(SAT, "ToPerigee", "Periapsis")
    v.stopping_condition(SAT, "ToApogee", "Apoapsis")
    v.stopping_condition(SAT, "Coast2Deorbit", "Apoapsis")

    # (3) 修正器:約束設對了嗎?真的收斂了嗎?
    v.dc_constraint(SAT, "RaiseApogee", "Radius Of Apoapsis", 6928.137, 0.1)
    v.dc_constraint(SAT, "Circularize", "Eccentricity", 0.0, 0.0001)
    v.dc_constraint(SAT, "Deorbit", "Radius Of Periapsis", 6428.137, 1.0)
    for ts in ("RaiseApogee", "Circularize", "Deorbit"):
        v.dc_converged(SAT, ts)

    # (4) 物理判準:解出的 delta-V 對得上解析值嗎?
    #     容許 10% —— STK 用 HPOP 含攝動,Python 用理想兩體,本來就會差幾 %。
    #     容許範圍要「夠鬆到不會誤報、夠緊到抓得到遠支解(1946 m/s)」。
    v.delta_v(SAT, "RaiseApogee", a1, rel_tol=0.10)
    v.delta_v(SAT, "Circularize", a2, rel_tol=0.10)
    v.delta_v(SAT, "Deorbit",     a3, rel_tol=0.10)

    # (5) 末軌:圓化到位了嗎?近地點降到再入高度了嗎?
    v.final_element(SAT, "Circularize", "ecc", 0.0, 1e-3)   # 根數名是 ecc,不是 Eccentricity
    v.final_element(SAT, "Deorbit", "PeriapsisRadSize", 6428.137, 1.0)

    # ---- 阻力補償:拿**外部數字**驗,不是拿模型對自己 ----
    # 舊版的 drag_makeup_dv_per_year 是一條編出來的指數
    # (`0.05*exp(-(alt-300)/120)`),而且沒有彈道係數 ——
    # 阻力 Δv 正比於 Cd A / m,那是衛星之間差最多的參數。
    # 改成物理式之後就驗得動了。
    m = load_lab(os.path.join(HERE, "week10_propulsion_deltav.py"))

    # (1) ISS:公開常引用「每年約 25-40 m/s(隨太陽活動變動)」
    iss_cd_a_m = 2.2*2000.0/420000.0        # Cd 2.2、迎風面積約 2000 m^2、420 t
    v.expect_between("ISS 的年阻力補償(公開約 25-40 m/s/yr)",
                     m.drag_makeup_dv_per_year(415.0, iss_cd_a_m)*1000.0,
                     25.0, 40.0, unit=" m/s/yr")

    # (2) 大氣密度要對得上 Vallado 表的基準點(那是文獻值,不是本檔算的)
    for h, rho_ref in ((200, 2.789e-10), (400, 3.725e-12), (800, 1.170e-14)):
        got = m.atmos_density(float(h))
        v.expect_true("大氣密度 @ %d km 對上文獻基準點" % h,
                      abs(got - rho_ref)/rho_ref < 1e-9,
                      "%.4e vs %.4e kg/m^3" % (got, rho_ref))

    # (3) 正比於彈道係數 —— 這是舊公式**表達不了**的東西
    base = m.drag_makeup_dv_per_year(400.0, 0.01)
    v.expect("Δv 正比於 Cd·A/m(加倍就要加倍)",
             m.drag_makeup_dv_per_year(400.0, 0.02), 2.0*base, tol=1e-12,
             unit=" km/s/yr")

    # (4) 隨高度單調下降,而且比舊的編出來的曲線陡
    alts = [300.0, 400.0, 500.0, 600.0, 700.0, 800.0]
    dvs = [m.drag_makeup_dv_per_year(a) for a in alts]
    v.expect_true("年阻力 Δv 隨高度單調下降",
                  all(dvs[i] > dvs[i+1] for i in range(len(dvs)-1)),
                  "%.1f -> %.2f m/s/yr" % (dvs[0]*1000, dvs[-1]*1000))
    # 300 -> 800 km 真實大氣要掉三個數量級以上;舊公式只掉 60 倍
    v.expect_true("300 -> 800 km 至少掉三個數量級(舊的編出來的只掉 60 倍)",
                  dvs[0]/dvs[-1] > 1e3,
                  "掉 %.0f 倍" % (dvs[0]/dvs[-1]))

    ok = v.report("Week 10 驗證段")
    print(f"\n對照:腳本 {ack} 條 ACK / {nack} 條 NACK —— "
          f"其中 23 條結構指令沒有任何 GetValue 可回讀,")
    print("      上面的 MCS 結構與修正器收斂檢查就是補這個死角。")
    return 0 if ok else 1


if __name__ == "__main__":
    _rc = main()
    # 在 notebook 裡不要用 SystemExit 打斷 kernel;在終端機才回傳離開碼
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)