獨立章節 6 — 地月轉移與繞月進入(相位迴圈)

資料夾:moon_transfer/

MoonTransfer_Lab_Handout.md

實驗單 — 地月轉移與繞月進入

先讀:MoonTransfer_Primer.md

資料夾:moon_transfer/

需求:Python(numpy / scipy / matplotlib);Part E 另需 STK 12 桌面版
(Astrogator)。沒有 STK 也能完成 Part A–D 與驗證段的大部分。


0. 情境

一艘探測船已經在 200 km 圓軌道(傾角 28.5°)。任務要把它送進
100 km 繞月圓軌道。

你要回答的是:


1. 執行步驟

cd moon_transfer

python moon_transfer_analysis.py      # Part A/B:解析
python moon_transfer_propagate.py     # Part C/D:數值積分 + 自寫目標器
python moon_transfer_plot.py          # 四張圖
python moon_transfer_build.py         # Part E:需要 STK,會關掉當下場景
python moon_transfer_verify.py        # 驗證段(必做)

moon_transfer_build.py 會關掉 STK 裡當下載入的場景。
跑之前確認沒有別人的東西在裡面。


2. Part A — Δv 帳(佔 15%)

跑 moon_transfer_analysis.py,回答:

  1. 直接 TLI 要多少 Δv?飛行時間多久?
  2. LOI 要多少?總共多少?
  3. 月球影響球半徑是多少?和公認的 66,100 km 差多少?
  4. 查一個公開資料(任何探月任務都可以),把它的 TLI 與 LOI 抄下來,
    和你算的比。差多少?為什麼會差?

提示:大多數任務不走最小能量轉移。飛得快要用 Δv 買。


3. Part B — 相位迴圈:先猜再算(佔 25%,本章重點)

動手之前先寫下你的猜測(這一題的分數給在「有沒有誠實記錄猜測」):

分成 4 次燒,總 Δv 會比一次燒完(3.13 km/s)多、少、還是一樣?
為什麼?

寫完再跑。然後回答:

  1. 實際差多少?(答案在輸出的「差」那一行)
  2. 為什麼是這個答案? 用一句話解釋,不要抄。
  3. 把 N_LOOPS 改成 2、6、10 再跑。恆等式還成立嗎?
  4. 既然 Δv 一樣,為什麼真實任務還要分次燒?列出三個理由。
  5. 相位迴圈的代價是什麼?從輸出裡找出兩個數字支持你的答案。

4. Part C — 數值積分(佔 20%)

跑 moon_transfer_propagate.py。

  1. 積分器自檢那一行的「比能量相對漂移」是多少?這個數字代表什麼?
    如果它是 1e-3,你會怎麼判斷?
  2. 數值目標器解出的每一圈 Δv,和解析差多少?
  3. 在 accel() 裡把第三體的間接項 - rm / rmn ** 3 註解掉再跑一次。
    相位迴圈的 Δv 變了嗎?為什麼?(提示:那一項是在修正什麼?)
    記得改回來。

5. Part D — 目標器與敏感度(佔 25%,本章第二重點)

看圖 (c),然後動手:

  1. TLI 目標器解出來的 Δv,和純兩體的 3.1313 km/s 差幾 m/s?
  2. 從圖 (c) 讀出:TLI 的 Δv 差 1 m/s,最接近月球的距離大約差多少 km?
  3. 圖 (c) 的曲線和目標線交在兩個地方。兩個都是「100 km 近月點」,
    差別在哪裡?(提示:想像探測船從月球的哪一側經過)
  4. 把 target_lunar_periapsis() 的 span_kms 改成 0.03 再跑。
    解出來的 Δv 一樣嗎?如果不一樣,哪一個是對的?
  5. 承上:這件事對任務設計的意涵是什麼?
    為什麼相位迴圈的「每圈一次重試機會」有價值?

6. Part E — STK/Astrogator(佔 15%,需要 STK)

跑 moon_transfer_build.py。

  1. Connect 送出多少條?幾條 NACK?
  2. Astrogator 的差分修正器解出來的總 Δv,和解析差多少?
  3. 打開 STK,在 MCS 裡找到 Loop1 的 Target Sequence。
    控制變數是什麼?約束是什麼? 和腳本裡寫的對得上嗎?
  4. 把 Loop1 的 MaxStep 從腳本裡拿掉再跑一次。修正器還收斂嗎?
    收斂到哪裡?(這是 Week 10 記錄過的坑)
  5. 用一句話說明:Astrogator 在這一章提供的價值是什麼?
    (提示:不是傳播)

7. 驗證段(必做)

python moon_transfer_verify.py

應該是 28 項全 PASS(沒有 STK 時 STK 那幾項會是 SKIP)。

驗證段分成幾類,看懂分類比看懂數字重要:

類 例子 它證明什麼
數學恆等式 v_esc / v_circ = sqrt(2) 程式自洽
文獻的外部對照 影響球 66,100、繞月圓軌道 1.633 這才是驗證
核心結論 相位迴圈恆等於直接 TLI 推導沒寫錯
三條路互相對照 解析 = 數值 = Astrogator 三個獨立實作一致

問題:如果把「外部對照」那一類全部拿掉,剩下的還算驗證嗎?
為什麼?(這一題佔驗證段分數的一半)


8. 加分題(各 5%,最多 10%)


9. 繳交

  1. 四張圖(moon_transfer.png)
  2. 驗證段的完整輸出
  3. Part A–E 的書面回答,含 Part B 動手前寫下的猜測
  4. 一頁總結:如果你是任務規劃者,你會選直接 TLI 還是相位迴圈?
    用這一章算出來的數字支持你的選擇,不要用「比較安全」這種說法。

MoonTransfer_Primer.md

基礎知識導論 — 地月轉移與繞月進入

去月球貴在哪裡?為什麼要繞好幾圈才走?
這一章的答案全部是算出來的,三條互相獨立的路都給同一個數字。


1. 這一章在解什麼問題

把一艘探測船從低地球軌道送進繞月軌道,要回答三個問題:

  1. 離開地球要多少 Δv?(TLI,Trans-Lunar Injection,地月轉移入射)
  2. 一次燒完,還是用相位迴圈(phasing loops)分好幾次燒?
  3. 到了月球要多少 Δv 才停得下來?(LOI,Lunar Orbit Insertion,繞月進入)

背景:國家太空中心 2020 年委託的「繞月任務之地月路徑及月球軌道模擬評估」
研究案,採用相位迴圈法設計地月轉移軌道與繞月進入軌跡;
淡江大學航太系 2025 年重啟探月相關計畫,延續先前的模擬成果,
進一步評估探測船前往月球的執行可行性。

本章把那條路徑的量級算出來,並且用三條路互相驗證。


2. 核心觀念

2.1 Δv 帳:去月球為什麼貴

段 Δv (km/s) 說明
發射到 200 km 停泊軌道 ~9.4 見 Week 15,本章不含
TLI 地月轉移入射 3.13 把遠地點從 200 km 抬到 384,400 km
LOI 繞月進入 0.82 在月球把雙曲線減速成圓軌道
本章合計 3.95

注意 TLI 的 3.13 km/s 幾乎等於「再發射一次小型火箭」。
這就是為什麼探月任務的質量比極端嚴苛。

2.2 直接 TLI:一次燒完

從 200 km 圓軌道(速度 7.784 km/s),把遠地點抬到月球距離:

轉移橢圓  r_p = 6578 km、r_a = 384,400 km -> a = 195,489 km
近地點速度 v_p = sqrt( mu (2/r_p - 1/a) ) = 10.916 km/s
Δv = 10.916 - 7.784 = 3.131 km/s
飛行時間 = 半個橢圓 = 4.98 天

Apollo 只花約 3 天,因為它走的不是這條最小能量轉移,
而是更快、更耗 Δv 的軌道。速度可以買,用 Δv 買。

2.3 相位迴圈:為什麼要分好幾次燒

不在近地點一次燒完 3.13 km/s,而是分成好幾次,每次都在同一個近地點,
把遠地點一圈一圈往外推:

圈 遠地點高度 (km) 週期 (天) 本次 Δv (km/s)
1 11,809 0.16 1.6497
2 43,907 0.55 0.9183
3 132,653 2.26 0.4048
4 378,022 9.96 0.1585
合計 8.0 天 合計 3.1313

遠地點按幾何級數排 —— 真實任務用圈的週期去對月球的相位,
幾何級數是最常見的起手式。

2.4 這一章最重要的一句話

理想脈衝下,相位迴圈的總 Δv 與直接 TLI 完全相同。

不是「差不多」,是恆等。理由只有一句:

所有點火都在同一個近地點半徑 r_p,
把近地點速度從 v0 推到 vN,不論分幾次,總和都是 vN - v0。
速度在同一點線性相加。

本章實測差 0.000e+00 km/s,而且圈數換成 1 / 2 / 5 / 8 圈都成立
(只有預設圈數對得上,那是巧合不是恆等)。

所以相位迴圈換到的不是 Δv,是時間。 4.98 天變成 8.0 天,
再加上有限推力損失(燒的次數多,每次都有重力損失與姿態損失)。

買到的是三件事:

  1. 發射窗口放寬 —— 用迴圈的圈數與週期去對月球的相位,
    不必等某一天的某一刻
  2. 每一圈近地點都是一次重試機會 —— 單次點火失敗不會直接丟掉任務,
    下一圈再來
  3. 小推力也做得到 —— 最大的單次點火是 1.65 km/s,不是 3.13。
    小推力的探測船本來就燒不出那一下

教學重點:貴的不是 Δv,是時間與風險的交換。

2.5 影響球與拼接圓錐

月球的影響球(Sphere of Influence)半徑:

r_SOI = a_moon * (m_moon / m_earth)^(2/5) = 66,183 km

(公認值約 66,100 km。)球內只算月球引力、球外只算地球引力 ——
這叫拼接圓錐(patched conic)。它是個近似,而本章量得出它的誤差:
拼接圓錐給的 LOI 是 0.8217 km/s,數值積分給 0.8132,差 1.0%。

拼接圓錐給得出量級,給不出幾何(從月球哪一側進、近月點多高、
什麼時候到)。要那些就得數值積分。

2.6 繞月進入(LOI)

到了月球,探測船相對月球是雙曲線軌道(不減速就飛走):

v_p = sqrt( v_inf^2 + 2 mu_moon / r_p )     能量守恆,不是近似
100 km 繞月圓軌道速度 = sqrt( mu_moon / 1837.4 ) = 1.6335 km/s
Δv_LOI = v_p - v_circ = 0.82 km/s

2.7 傳播容易,目標才是工作量

這一點值得單獨講。數值積分地球 + 月球兩個點質量很簡單,
難的是反問題:

「近地點要燒多少,才會在幾天後從月球前緣 100 km 通過?」

那是一個邊界值問題。STK Astrogator 的價值不在它會傳播,
在它的差分修正器幫你解這個反問題。
純 Python 也解得出來 —— 單變數用 scipy.optimize.brentq,
多變數用 least_squares —— 只是要自己寫。

而繞月捕獲對初始條件極度敏感。本章實測:

TLI 的 Δv 改 ±5 m/s,最接近月球的距離就從 17,500 km 掃到撞上月球。

同一條曲線還有多個根(從月球不同側接近,都能給 100 km 近月點),
掃描範圍一換就落到另一個解。兩個都對 ——
這正說明為什麼實務上要留重新瞄準的機會。

2.8 三條互相獨立的路

路 檔案 給什麼
解析(拼接圓錐) moon_transfer_analysis.py 閉合解,Δv 帳
數值 moon_transfer_propagate.py solve_ivp 積分 + brentq 自寫目標器
STK moon_transfer_build.py Astrogator 的差分修正器

三條路各自獨立。它們給同一個答案才算數 ——
拿模型對自己不算驗證。


3. 名詞與符號

符號 / 名詞 意義 本章的值
TLI 地月轉移入射 3.1313 km/s
LOI 繞月進入 0.82 km/s
v_inf 相對月球的雙曲線剩餘速度 0.81 km/s
r_SOI 月球影響球半徑 66,183 km
mu_E / mu_M 地球 / 月球重力參數 398,600.44 / 4,902.80 km³/s²
a_moon 地月平均距離 384,400 km
恆星月 月球相對恆星繞一圈 27.32 天(不是朔望月 29.53)
相位迴圈 在同一近地點分次抬高遠地點 本章 4 圈、8.0 天
拼接圓錐 影響球內外分別用兩體解 本章量到誤差 1.0%

4. 觀念 → 本章實驗對照

觀念 在哪裡看到
Δv 帳與恆等式 moon_transfer_analysis.py 的 phasing_total_equals_direct()
軌跡長什麼樣 圖 (a) moon_transfer.png
每圈要燒多少 圖 (b),三條路的差註記在右上角
到達對 TLI 有多敏感 圖 (c) —— ±5 m/s 掃過整個範圍
遠地點與週期怎麼長大 圖 (d)
差分修正器在做什麼 moon_transfer_build.py 的 Target Sequence
自己寫目標器 moon_transfer_propagate.py 的 solve_loop_dv()
拼接圓錐的誤差 驗證段「數值 LOI 與解析相差 < 5%」那一項

5. 本章踩到的坑(全部是實測)

Astrogator 那一側

  1. 建場景是 New / Scenario <名>,不加 */;建物件才是
    New / */Satellite <名>(而且不是 New / */Satellite/<名>)。
    兩個任一寫錯,整批指令(本章是 90 條)會全部 NACK。
  2. 差分修正器的 FinalValue 單位是 m/s,與場景的 SetUnits / km 無關。
    直接拿來比的話,四圈的差會剛好都是 1000 倍 —— 那是單位,不是誤差。
  3. SetProp 會先建一組預設 MCS,不先刪掉,後面的改名會改到預設段。
  4. 插了 Target_Sequence 之後插入點會跑進它裡面,所以一律用完整路徑。
  5. 控制變數少了 MaxStep、約束少了 Tolerance,修正器會收斂到荒謬的
    遠支解,而且一樣 ACK。

數值那一側

  1. 積分時間一定要設上限。 brentq 的搜尋上界逼近脫離速度時
    半長軸 → 無限大,1.2 * period(a) 會讓單次呼叫去積好幾年的模擬時間。
    四圈的目標器因此跑了 252 秒,而單次呼叫其實只要 0.003 秒;
    加上限之後整支從 6 分 07 秒變成 8.2 秒,結果完全相同。
  2. 搜尋上界不能寫死。寫 3.5 km/s 會讓 period() 對雙曲線軌道開根號。
    改成由物理定出:再燒下去就脫離了。
  3. 第三體的間接項不能省。少了 -mu_M * r_M / |r_M|^3,
    地心這個參考系被月球拉動的那一份就沒算進去。

工具那一側

  1. 失敗注入可能假通過。 Python 用「秒」的精度比對 .pyc 記的來源
    時間戳;注入如果在同一秒內改完又還原,原始碼看起來乾淨、md5 也對,
    但載進來的還是注入版。本章實測:原始碼 22:14:59.111、
    .pyc 22:14:59.102,驗證段因此一直用著被改壞的月球重力參數在跑。
    tools/stk_verify.load_lab 已改成直接讀原始碼 compile,不碰位元碼。

6. 延伸閱讀

這一章沒做、但做得到的

相關週次

圖


moon_transfer/moon_transfer.png

程式碼

moon_transfer/moon_transfer_Connect.txt
# 由 moon_transfer_build.py 產生,請勿手改 —— 目標半徑來自
# moon_transfer_analysis.phasing_loops(),手抄會過期。
New / Scenario Moon_Transfer
SetAnalysisTimePeriod * "1 Jan 2026 00:00:00.000" "+30 days"
SetUnits / km
New / */Satellite Probe
Astrogator */Satellite/Probe SetProp
Astrogator */Satellite/Probe DeleteSegment MainSequence.SegmentList.Initial_State
Astrogator */Satellite/Probe DeleteSegment MainSequence.SegmentList.Propagate
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Initial_State
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Initial_State.ComponentName Park
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Park.InitialState.Keplerian.PeriapsisRadSize 6578.137000 km
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Park.InitialState.Keplerian.ApoapsisRadSize 6578.137000 km
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Park.InitialState.Keplerian.inc 28.5000 deg
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Park.InitialState.Keplerian.RAAN 0
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Park.InitialState.Keplerian.TA 0
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Propagate.ComponentName ToPerigee1
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee1.Propagator "Earth Point Mass"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee1.StoppingConditions Periapsis
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Target_Sequence.ComponentName Loop1
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.Loop1.SegmentList.- Maneuver
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop1.SegmentList.Maneuver.ComponentName DV1
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop1.SegmentList.DV1.MnvrType Impulsive
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop1.SegmentList.DV1.AttitudeControl "Thrust Vector"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop1.SegmentList.DV1.ThrustAxes "Satellite VNC(Earth)"
Astrogator */Satellite/Probe AddMCSSegmentControl MainSequence.SegmentList.Loop1.SegmentList.DV1 ImpulsiveMnvr.Cartesian.X
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop1.SegmentList.DV1.Results "Radius Of Apoapsis"
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.Loop1.Profiles.Differential_Corrector DV1 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.Loop1.Profiles.Differential_Corrector DV1 ImpulsiveMnvr.Cartesian.X MaxStep 0.2062 km/sec
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop1.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Active true
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop1.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Desired 18187.5010 km
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop1.Profiles.Differential_Corrector DV1 "Radius Of Apoapsis" Tolerance 3.6375 km
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop1.Action "Run active profiles"
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Propagate.ComponentName ToPerigee2
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee2.Propagator "Earth Point Mass"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee2.StoppingConditions Periapsis
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Target_Sequence.ComponentName Loop2
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.Loop2.SegmentList.- Maneuver
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop2.SegmentList.Maneuver.ComponentName DV2
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop2.SegmentList.DV2.MnvrType Impulsive
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop2.SegmentList.DV2.AttitudeControl "Thrust Vector"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop2.SegmentList.DV2.ThrustAxes "Satellite VNC(Earth)"
Astrogator */Satellite/Probe AddMCSSegmentControl MainSequence.SegmentList.Loop2.SegmentList.DV2 ImpulsiveMnvr.Cartesian.X
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop2.SegmentList.DV2.Results "Radius Of Apoapsis"
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.Loop2.Profiles.Differential_Corrector DV2 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.Loop2.Profiles.Differential_Corrector DV2 ImpulsiveMnvr.Cartesian.X MaxStep 0.1148 km/sec
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop2.Profiles.Differential_Corrector DV2 "Radius Of Apoapsis" Active true
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop2.Profiles.Differential_Corrector DV2 "Radius Of Apoapsis" Desired 50285.5433 km
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop2.Profiles.Differential_Corrector DV2 "Radius Of Apoapsis" Tolerance 10.0571 km
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop2.Action "Run active profiles"
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Propagate.ComponentName ToPerigee3
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee3.Propagator "Earth Point Mass"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee3.StoppingConditions Periapsis
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Target_Sequence.ComponentName Loop3
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.Loop3.SegmentList.- Maneuver
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop3.SegmentList.Maneuver.ComponentName DV3
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop3.SegmentList.DV3.MnvrType Impulsive
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop3.SegmentList.DV3.AttitudeControl "Thrust Vector"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop3.SegmentList.DV3.ThrustAxes "Satellite VNC(Earth)"
Astrogator */Satellite/Probe AddMCSSegmentControl MainSequence.SegmentList.Loop3.SegmentList.DV3 ImpulsiveMnvr.Cartesian.X
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop3.SegmentList.DV3.Results "Radius Of Apoapsis"
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.Loop3.Profiles.Differential_Corrector DV3 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.Loop3.Profiles.Differential_Corrector DV3 ImpulsiveMnvr.Cartesian.X MaxStep 0.0506 km/sec
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop3.Profiles.Differential_Corrector DV3 "Radius Of Apoapsis" Active true
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop3.Profiles.Differential_Corrector DV3 "Radius Of Apoapsis" Desired 139031.5174 km
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.Loop3.Profiles.Differential_Corrector DV3 "Radius Of Apoapsis" Tolerance 27.8063 km
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Loop3.Action "Run active profiles"
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Propagate
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Propagate.ComponentName ToPerigee4
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee4.Propagator "Earth Point Mass"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.ToPerigee4.StoppingConditions Periapsis
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.- Target_Sequence
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.Target_Sequence.ComponentName TLI
Astrogator */Satellite/Probe InsertSegment MainSequence.SegmentList.TLI.SegmentList.- Maneuver
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.TLI.SegmentList.Maneuver.ComponentName DV4
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.TLI.SegmentList.DV4.MnvrType Impulsive
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.TLI.SegmentList.DV4.AttitudeControl "Thrust Vector"
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.TLI.SegmentList.DV4.ThrustAxes "Satellite VNC(Earth)"
Astrogator */Satellite/Probe AddMCSSegmentControl MainSequence.SegmentList.TLI.SegmentList.DV4 ImpulsiveMnvr.Cartesian.X
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.TLI.SegmentList.DV4.Results "Radius Of Apoapsis"
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.TLI.Profiles.Differential_Corrector DV4 ImpulsiveMnvr.Cartesian.X Active true
Astrogator */Satellite/Probe SetMCSControlValue MainSequence.SegmentList.TLI.Profiles.Differential_Corrector DV4 ImpulsiveMnvr.Cartesian.X MaxStep 0.0500 km/sec
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.TLI.Profiles.Differential_Corrector DV4 "Radius Of Apoapsis" Active true
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.TLI.Profiles.Differential_Corrector DV4 "Radius Of Apoapsis" Desired 384400.0000 km
Astrogator */Satellite/Probe SetMCSConstraintValue MainSequence.SegmentList.TLI.Profiles.Differential_Corrector DV4 "Radius Of Apoapsis" Tolerance 76.8800 km
Astrogator */Satellite/Probe SetValue MainSequence.SegmentList.TLI.Action "Run active profiles"
moon_transfer/moon_transfer_analysis.py
# -*- coding: utf-8 -*-
r"""地月轉移與繞月進入 —— 解析核心。

## 這一章在解什麼

把一艘探測船從低地球軌道(LEO)送進**繞月軌道**,要回答三個問題:

1. 離開地球要多少 Δv?(TLI,Trans-Lunar Injection)
2. 一次燒完,還是用**相位迴圈**(phasing loops)分好幾次燒?
3. 到了月球要多少 Δv 才停得下來?(LOI,Lunar Orbit Insertion)

背景:國家太空中心 2020 年委託的「繞月任務之地月路徑及月球軌道模擬評估」
研究案採用相位迴圈法設計地月轉移軌道與繞月進入軌跡;淡江大學航太系
2025 年重啟探月相關計畫,延續先前的模擬成果評估執行可行性。
本章把那條路徑的**量級**用解析法算出來,再用 STK Astrogator 對照。

## 本章最重要的一個結論(算出來的,不是背下來的)

**理想脈衝下,相位迴圈的總 Δv 與直接 TLI 完全相同。**

理由是速度在同一點線性相加:每一次點火都在同一個近地點半徑 r_p,
所以把近地點速度從 v0 推到 vN,不論分成幾次,總和都是 vN − v0。
`phasing_total_equals_direct()` 把這件事算出來驗證。

所以相位迴圈換到的不是 Δv,而是:

- **時間**(多繞好幾圈,整個轉移從 3 天變成數週)
- **有限推力損失**(燒的次數多,每次都有重力損失與姿態損失)

換來的是:

- 發射窗口放寬(用迴圈的圈數與週期去對月球的相位)
- 每一圈近地點都是一次**重試機會**,單次點火失敗不會直接丟掉任務
- 小推力的探測船本來就燒不出 3.1 km/s 的單次脈衝

這一段是這一章的教學重點:**貴的不是 Δv,是時間與風險的交換。**

執行:python moon_transfer_analysis.py
"""
import math
import os
import sys

# ---------------------------------------------------------------- 常數
MU_EARTH = 398600.4418        # km^3/s^2
MU_MOON = 4902.800066         # km^3/s^2
RE = 6378.137                 # km
R_MOON = 1737.4               # km
A_MOON = 384400.0             # km,地月平均距離
M_RATIO = MU_MOON / MU_EARTH  # 質量比(用重力參數比,數值相同)

# 本章的基準設計點
PARK_ALT = 200.0              # km,LEO 停泊軌道高度
LLO_ALT = 100.0               # km,目標繞月圓軌道高度
N_LOOPS = 3                   # 相位迴圈圈數

HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))


# ---------------------------------------------------------------- 基本量
def v_circ(r, mu=MU_EARTH):
    """圓軌道速度(km/s)。"""
    return math.sqrt(mu / r)


def v_esc(r, mu=MU_EARTH):
    """脫離速度(km/s)。恆等於 sqrt(2) 倍的圓軌道速度。"""
    return math.sqrt(2.0 * mu / r)


def vis_viva(r, a, mu=MU_EARTH):
    """活力公式:半長軸 a 的軌道在半徑 r 處的速度(km/s)。"""
    return math.sqrt(mu * (2.0 / r - 1.0 / a))


def period(a, mu=MU_EARTH):
    """軌道週期(s)。"""
    return 2.0 * math.pi * math.sqrt(a ** 3 / mu)


def soi_radius(a=A_MOON, mass_ratio=M_RATIO):
    """月球的影響球半徑(km)。r_SOI = a (m/M)^(2/5)。

    這是**兩體拼接**(patched conic)的分界:球內只算月球引力,
    球外只算地球引力。公認值約 66,100 km。
    """
    return a * mass_ratio ** 0.4


# ---------------------------------------------------------------- TLI
def tli_direct(park_alt=PARK_ALT, r_apo=A_MOON):
    """直接 TLI:一次燒完,把停泊軌道的遠地點抬到月球距離。

    回傳 dict:停泊速度、近地點速度、Δv、轉移軌道半長軸與飛行時間。
    """
    rp = RE + park_alt
    a = 0.5 * (rp + r_apo)
    v_park = v_circ(rp)
    v_p = vis_viva(rp, a)
    return {"r_p": rp, "r_a": r_apo, "a": a,
            "v_park": v_park, "v_perigee": v_p, "dv": v_p - v_park,
            # 半個橢圓 = 從近地點到遠地點
            "tof_days": period(a) / 2.0 / 86400.0}


def phasing_loops(park_alt=PARK_ALT, n=N_LOOPS, r_final=A_MOON):
    """相位迴圈:在同一個近地點分 n+1 次把遠地點逐步抬高。

    遠地點按**幾何級數**排,讓每一圈的週期成比例成長 ——
    真實任務會用圈的週期去對月球的相位,幾何級數是最常見的起手式。

    回傳 dict,含每一圈的遠地點/週期/Δv,以及總 Δv。
    """
    rp = RE + park_alt
    v0 = v_circ(rp)
    # 遠地點序列:從停泊軌道的 rp 幾何級數長到 r_final
    apos = [rp * (r_final / rp) ** ((i + 1) / float(n + 1)) for i in range(n + 1)]
    legs, v_prev = [], v0
    for i, ra in enumerate(apos):
        a = 0.5 * (rp + ra)
        v_here = vis_viva(rp, a)
        legs.append({"loop": i + 1, "r_apo": ra, "alt_apo": ra - RE,
                     "a": a, "period_days": period(a) / 86400.0,
                     "v_perigee": v_here, "dv": v_here - v_prev})
        v_prev = v_here
    return {"r_p": rp, "v_park": v0, "legs": legs,
            "dv_total": sum(l["dv"] for l in legs),
            # 迴圈段的總時間:每一圈各繞一整圈,最後一段只走半圈到月球
            "elapsed_days": sum(l["period_days"] for l in legs[:-1])
                            + legs[-1]["period_days"] / 2.0}


def phasing_total_equals_direct(park_alt=PARK_ALT, n=N_LOOPS, r_final=A_MOON):
    """**這一章的核心結論**:理想脈衝下兩者的總 Δv 相同。

    速度在同一點線性相加 —— 所有點火都在同一個 r_p,
    把近地點速度從 v0 推到 vN,不論分幾次,總和都是 vN − v0。
    回傳 (直接, 迴圈, 差值)。差值應該是浮點誤差等級。
    """
    d = tli_direct(park_alt, r_final)["dv"]
    p = phasing_loops(park_alt, n, r_final)["dv_total"]
    return d, p, p - d


# ---------------------------------------------------------------- 到月球
def arrival_v_inf(park_alt=PARK_ALT, r_apo=A_MOON):
    """抵達月球影響球時相對月球的速度 v_inf(km/s)。

    兩體拼接的簡化:轉移軌道在 r = a_moon 處的地心速度,
    與月球的公轉速度相減。這裡取**共線近似**(只看大小差),
    量級對、方向不對 —— 真實的 v_inf 要看進入影響球的幾何。
    真正的幾何交給 STK Astrogator 的 Lunar Transfer 目標器。
    """
    rp = RE + park_alt
    a = 0.5 * (rp + r_apo)
    v_sc = vis_viva(r_apo, a)          # 探測船在月球距離的地心速度
    v_moon = v_circ(A_MOON)            # 月球公轉速度
    return abs(v_moon - v_sc), v_sc, v_moon


def loi_dv(v_inf, llo_alt=LLO_ALT):
    """繞月進入:雙曲線近月點減速成圓軌道(km/s)。

    v_p_hyp = sqrt(v_inf^2 + 2 mu / r_p) —— 能量守恆,不是近似。
    """
    rp = R_MOON + llo_alt
    v_hyp = math.sqrt(v_inf ** 2 + 2.0 * MU_MOON / rp)
    v_llo = v_circ(rp, MU_MOON)
    return {"r_p": rp, "v_hyp": v_hyp, "v_llo": v_llo,
            "dv": v_hyp - v_llo, "v_inf": v_inf}


def mission_budget(park_alt=PARK_ALT, llo_alt=LLO_ALT, n=N_LOOPS):
    """整段任務的 Δv 帳(km/s)。"""
    ph = phasing_loops(park_alt, n)
    vinf, v_sc, v_moon = arrival_v_inf(park_alt)
    loi = loi_dv(vinf, llo_alt)
    return {"tli": ph["dv_total"], "loi": loi["dv"],
            "total": ph["dv_total"] + loi["dv"],
            "v_inf": vinf, "elapsed_days": ph["elapsed_days"],
            "phasing": ph, "loi_detail": loi}


# ---------------------------------------------------------------- 輸出
def main():
    print("=" * 76)
    print("  地月轉移與繞月進入 —— 解析核心")
    print("=" * 76)
    print("  常數:mu_E %.4f、mu_M %.6f、RE %.3f、R_M %.1f、a_moon %.0f km"
          % (MU_EARTH, MU_MOON, RE, R_MOON, A_MOON))
    print("  月球影響球半徑 %.0f km(公認約 66,100)" % soi_radius())
    print()

    d = tli_direct()
    print("  直接 TLI(%.0f km 停泊圓軌道 -> 遠地點 %.0f km)" % (PARK_ALT, A_MOON))
    print("    停泊速度 %.4f、近地點速度 %.4f -> **Δv %.4f km/s**"
          % (d["v_park"], d["v_perigee"], d["dv"]))
    print("    轉移半長軸 %.0f km、飛行時間 %.2f 天" % (d["a"], d["tof_days"]))
    print()

    ph = phasing_loops()
    print("  相位迴圈(%d 圈,同一個近地點)" % (N_LOOPS + 1))
    print("    圈  遠地點高度(km)     週期(天)   本次 Δv(km/s)")
    for l in ph["legs"]:
        print("    %2d   %12.0f   %9.3f   %11.4f"
              % (l["loop"], l["alt_apo"], l["period_days"], l["dv"]))
    print("    總 Δv %.4f km/s、迴圈段歷時 %.1f 天"
          % (ph["dv_total"], ph["elapsed_days"]))
    print()

    a, b, diff = phasing_total_equals_direct()
    print("  **理想脈衝下兩者的總 Δv 相同**")
    print("    直接 %.6f  相位迴圈 %.6f  差 %.3e km/s" % (a, b, diff))
    print("    理由:所有點火都在同一個近地點,速度在同一點線性相加。")
    print("    相位迴圈換到的是時間與有限推力損失,買到的是發射窗口、")
    print("    每圈一次的重試機會,以及小推力探測船也做得到的分次點火。")
    print()

    vinf, v_sc, v_moon = arrival_v_inf()
    loi = loi_dv(vinf)
    print("  抵達月球(兩體拼接的共線近似)")
    print("    探測船在月球距離的地心速度 %.4f、月球公轉 %.4f -> v_inf %.4f km/s"
          % (v_sc, v_moon, vinf))
    print("  繞月進入(近月點 %.0f km)" % LLO_ALT)
    print("    雙曲線近月點 %.4f、繞月圓軌道 %.4f -> **Δv %.4f km/s**"
          % (loi["v_hyp"], loi["v_llo"], loi["dv"]))
    print()

    m = mission_budget()
    print("  任務 Δv 帳")
    print("    TLI(相位迴圈)  %.4f km/s" % m["tli"])
    print("    LOI(繞月進入)  %.4f km/s" % m["loi"])
    print("    合計            %.4f km/s" % m["total"])
    print("    地月段歷時      %.1f 天" % m["elapsed_days"])
    return 0


if __name__ == "__main__":
    _rc = main()
    # 在 notebook 裡不要用 SystemExit 打斷 kernel;在終端機才回傳離開碼。
    # 這是庫裡既有的慣例(各週的驗證段都這樣寫)—— 少了它,
    # 這一格在 notebook 執行時會以 SystemExit 失敗。
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
moon_transfer/moon_transfer_build.py
# -*- coding: utf-8 -*-
r"""地月轉移章 — STK/Astrogator 場景建構。

## 這一支在做什麼

把 `moon_transfer_analysis.py` 算出來的相位迴圈,用 Astrogator 的
**差分修正器**實際解一次,再把解出來的 Δv 拿回來跟解析值對。

解析說「理想脈衝下相位迴圈的總 Δv 恆等於直接 TLI」。Astrogator 這一側
是真的在傳播、真的在解 —— 它會不會也給同一個數字,是這一章的主驗證。

## 目標半徑不手抄

每一圈的遠地點半徑由 `moon_transfer_analysis.phasing_loops()` 產生,
再組成 Connect 指令。**手抄下來的常數會跟著設計點改動而過期** ——
這個教材庫在 spaceport 那一章為此吃過兩次虧(交叉核對表過期兩次)。
本檔同時把送出去的指令寫成 `moon_transfer_Connect.txt` 存查。

## 四個 Astrogator 的坑(week10 踩過,這裡沿用它的作法)

1. `SetProp` 會先建一組預設 MCS(Initial_State + Propagate)。
   不先刪掉,後面的改名會改到**預設段**,自己插的段變成孤兒。
2. 裸 `-` 是「目前插入點」。插了 Target_Sequence 之後插入點會跑進它裡面,
   所以每一段都用**完整路徑**,不用裸 `-`。
3. 解出來的 Δv 在**差分修正器的控制變數**上,segment 上永遠是標稱的 0。
   讀錯地方會得到 0 m/s 卻毫無錯誤訊息。
4. 控制變數少了 MaxStep、約束少了 Tolerance,修正器會收斂到荒謬的遠支解,
   而且一樣 ACK。**每一個控制變數都要給 MaxStep,每一個約束都要給 Tolerance。**

## 傳播器故意用 Earth Point Mass

不是偷懶。這一章要驗的是「同一個近地點分幾次燒,總 Δv 不變」這個**恆等式**,
加上月球第三體與 J2 之後近地點會漂、恆等式就不再精確成立。
先用點質量把恆等式驗起來,再談攝動會破壞它多少 —— 那是下一層的事。

    python moon_transfer_build.py
"""
import io
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, HERE)

import moon_transfer_analysis as A                                # noqa: E402

SCENARIO = "Moon_Transfer"
SAT = "*/Satellite/Probe"
CONNECT_OUT = os.path.join(HERE, "moon_transfer_Connect.txt")

INC_DEG = 28.5                 # 停泊軌道傾角(低緯度發射場的典型值)
EPOCH = "1 Jan 2026 00:00:00.000"


def mcs_commands():
    """由解析模組產生整串 Connect 指令。回傳 [指令, ...]。"""
    ph = A.phasing_loops()
    rp = ph["r_p"]
    c = []
    a = c.append

    # 建場景是 `New / Scenario <名>`,**不加 `*/`**;`*/` 是場景內物件的前綴。
    # 建物件是 `New / */Satellite <名>`,不是 `New / */Satellite/<名>`。
    # 兩個任一寫錯,整批 90 條指令會全部 NACK。
    a("New / Scenario " + SCENARIO)
    a('SetAnalysisTimePeriod * "%s" "+30 days"' % EPOCH)
    a("SetUnits / km")
    a("New / */Satellite " + SAT.rsplit("/", 1)[1])
    a("Astrogator %s SetProp" % SAT)
    # 坑 1:SetProp 會建預設 MCS,先刪掉
    a("Astrogator %s DeleteSegment MainSequence.SegmentList.Initial_State" % SAT)
    a("Astrogator %s DeleteSegment MainSequence.SegmentList.Propagate" % SAT)

    # ---- 初始狀態:停泊圓軌道 ----
    a("Astrogator %s InsertSegment MainSequence.SegmentList.- Initial_State" % SAT)
    a("Astrogator %s SetValue MainSequence.SegmentList.Initial_State.ComponentName Park" % SAT)
    for k, val in (("PeriapsisRadSize", "%.6f km" % rp),
                   ("ApoapsisRadSize", "%.6f km" % rp),
                   ("inc", "%.4f deg" % INC_DEG),
                   ("RAAN", "0"), ("TA", "0")):
        a("Astrogator %s SetValue MainSequence.SegmentList.Park.InitialState.Keplerian.%s %s"
          % (SAT, k, val))

    # ---- 每一圈:傳播到近地點 -> Target Sequence 解 Δv ----
    for leg in ph["legs"]:
        i = leg["loop"]
        prop = "ToPerigee%d" % i
        ts = "Loop%d" % i if i < len(ph["legs"]) else "TLI"
        dv = "DV%d" % i
        a("Astrogator %s InsertSegment MainSequence.SegmentList.- Propagate" % SAT)
        a("Astrogator %s SetValue MainSequence.SegmentList.Propagate.ComponentName %s"
          % (SAT, prop))
        # 傳播器故意用點質量:見檔頭
        a('Astrogator %s SetValue MainSequence.SegmentList.%s.Propagator "Earth Point Mass"'
          % (SAT, prop))
        a("Astrogator %s SetValue MainSequence.SegmentList.%s.StoppingConditions Periapsis"
          % (SAT, prop))

        a("Astrogator %s InsertSegment MainSequence.SegmentList.- Target_Sequence" % SAT)
        a("Astrogator %s SetValue MainSequence.SegmentList.Target_Sequence.ComponentName %s"
          % (SAT, ts))
        # 坑 2:插完 Target_Sequence 之後插入點在它裡面,一律用完整路徑
        a("Astrogator %s InsertSegment MainSequence.SegmentList.%s.SegmentList.- Maneuver"
          % (SAT, ts))
        a("Astrogator %s SetValue MainSequence.SegmentList.%s.SegmentList.Maneuver.ComponentName %s"
          % (SAT, ts, dv))
        base = "MainSequence.SegmentList.%s.SegmentList.%s" % (ts, dv)
        a("Astrogator %s SetValue %s.MnvrType Impulsive" % (SAT, base))
        a('Astrogator %s SetValue %s.AttitudeControl "Thrust Vector"' % (SAT, base))
        a('Astrogator %s SetValue %s.ThrustAxes "Satellite VNC(Earth)"' % (SAT, base))
        a("Astrogator %s AddMCSSegmentControl %s ImpulsiveMnvr.Cartesian.X" % (SAT, base))
        a('Astrogator %s SetValue %s.Results "Radius Of Apoapsis"' % (SAT, base))

        dc = "MainSequence.SegmentList.%s.Profiles.Differential_Corrector" % ts
        # 坑 4:MaxStep 與 Tolerance 一定要給
        step = max(0.05, leg["dv"] / 8.0)
        tol = max(1.0, leg["r_apo"] * 2e-4)
        a("Astrogator %s SetMCSControlValue %s %s ImpulsiveMnvr.Cartesian.X Active true"
          % (SAT, dc, dv))
        a("Astrogator %s SetMCSControlValue %s %s ImpulsiveMnvr.Cartesian.X MaxStep %.4f km/sec"
          % (SAT, dc, dv, step))
        a('Astrogator %s SetMCSConstraintValue %s %s "Radius Of Apoapsis" Active true'
          % (SAT, dc, dv))
        a('Astrogator %s SetMCSConstraintValue %s %s "Radius Of Apoapsis" Desired %.4f km'
          % (SAT, dc, dv, leg["r_apo"]))
        a('Astrogator %s SetMCSConstraintValue %s %s "Radius Of Apoapsis" Tolerance %.4f km'
          % (SAT, dc, dv, tol))
        a('Astrogator %s SetValue MainSequence.SegmentList.%s.Action "Run active profiles"'
          % (SAT, ts))
    return c, ph


def run(root, cmds, echo_fail=True):
    ack = nack = 0
    for cmd in cmds:
        try:
            root.ExecuteCommand(cmd)
            ack += 1
        except Exception as e:                        # noqa: BLE001
            nack += 1
            if echo_fail:
                print("  NACK: %s" % cmd[:110])
                print("        %s" % str(e)[:150])
    return ack, nack


DV_PLAUSIBLE_KMS = (0.01, 5.0)      # 這一章的單次點火合理範圍


def solved_dv(root, ts_name, dv_name):
    """讀差分修正器解出來的 Δv,回傳 **km/s**。

    坑 3:解在**控制變數**上,segment 的 ImpulsiveMnvr 永遠是標稱的 0。

    坑 5(這一章新踩到的):`FinalValue` 的單位是 **m/s**,
    與場景的 `SetUnits / km` 無關 —— Astrogator 的控制變數走自己的內部單位。
    直接拿來比的話,四圈的差會剛好都是 1000 倍 —— 那是單位,不是誤差。
    所以這裡換算之後還要落在合理範圍,不然就是單位又變了。
    """
    sat = root.GetObjectFromPath(SAT)
    ms = sat.Propagator.MainSequence
    ts = ms.GetItemByName(ts_name)
    prof = ts.Profiles.Item(0)
    cp = prof.ControlParameters.GetControlByPaths(dv_name,
                                                  "ImpulsiveMnvr.Cartesian.X")
    kms = float(cp.FinalValue) / 1000.0
    lo, hi = DV_PLAUSIBLE_KMS
    if not (lo <= abs(kms) <= hi):
        raise ValueError(
            "%s 的 Δv 換算成 %.6f km/s,落在合理範圍 %.2f-%.2f 之外 —— "
            "FinalValue 的單位可能已經不是 m/s" % (dv_name, kms, lo, hi))
    return kms


def main():
    from agi.stk12.stkdesktop import STKDesktop
    app = STKDesktop.AttachToApplication()      # 保留 app,否則 COM 介面會失效
    root = app.Root
    if root.CurrentScenario is not None:
        print("  關閉目前場景:%s" % root.CurrentScenario.InstanceName)
        root.CloseScenario()

    cmds, ph = mcs_commands()
    io.open(CONNECT_OUT, "w", encoding="utf-8", newline="\n").write(
        "# 由 moon_transfer_build.py 產生,請勿手改 —— 目標半徑來自\n"
        "# moon_transfer_analysis.phasing_loops(),手抄會過期。\n"
        + "\n".join(cmds) + "\n")

    print("=" * 76)
    print("  地月轉移 — Astrogator 場景建構")
    print("=" * 76)
    ack, nack = run(root, cmds)
    print("  Connect:%d ACK / %d NACK" % (ack, nack))
    if nack:
        print("  **有 NACK,先修掉再談結果**")
        return 1

    sat = root.GetObjectFromPath(SAT)
    sat.Propagator.RunMCS()
    print("  MCS 已執行")
    print()
    print("  圈  目標遠地點(km)   解析 Δv    Astrogator Δv    差")
    tot_stk = 0.0
    for leg in ph["legs"]:
        i = leg["loop"]
        ts = "Loop%d" % i if i < len(ph["legs"]) else "TLI"
        try:
            d = solved_dv(root, ts, "DV%d" % i)
        except Exception as e:                        # noqa: BLE001
            print("    %2d  讀不到解:%s" % (i, str(e)[:60]))
            continue
        tot_stk += d
        print("    %2d   %13.1f   %8.4f   %12.4f   %+8.5f"
              % (i, leg["r_apo"], leg["dv"], d, d - leg["dv"]))
    print()
    print("  總 Δv:解析 %.4f   Astrogator %.4f   差 %+.5f km/s"
          % (ph["dv_total"], tot_stk, tot_stk - ph["dv_total"]))
    print("  Connect 指令已存到 %s" % os.path.basename(CONNECT_OUT))
    return 0


if __name__ == "__main__":
    _rc = main()
    # 在 notebook 裡不要用 SystemExit 打斷 kernel;在終端機才回傳離開碼。
    # 這是庫裡既有的慣例(各週的驗證段都這樣寫)—— 少了它,
    # 這一格在 notebook 執行時會以 SystemExit 失敗。
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
moon_transfer/moon_transfer_plot.py
# -*- coding: utf-8 -*-
r"""地月轉移章 — 四張圖。

## 每一張圖在回答什麼(先定工作,再挑形式)

    (a) 相位迴圈長什麼樣      幾何 -> 軌跡圖(等比例座標)
    (b) 每一圈要燒多少        量值比較 -> 長條圖,單一數列
    (c) TLI 的 Δv 有多敏感    連續關係 + 門檻 -> 折線 + 參考線
    (d) 遠地點與週期怎麼長大  量值(跨三個數量級)-> 對數長條

## 配色的兩個決定

- 四圈是**有序**的(第 1 圈到第 4 圈),所以用**單色階**由淺到深,
  不是四個不同的顏色。判準是亮度單調,不是類別色的那套檢查。
- 需要區分「不同實體」的地方(月球 vs 探測船)才用類別色,
  而且照固定順序取:blue -> orange -> aqua,不循環。

淺色階對白底的對比不足,所以**每一段都直接標字** —— 不靠顏色認身分。

## 不用雙 y 軸

(d) 要同時表達遠地點高度與週期。雙軸是最常見的錯誤(兩個尺度疊在一起,
讀者無從判斷交叉點有沒有意義),所以週期用**直接標籤**掛在長條上,
座標軸只有一個。

    python moon_transfer_plot.py
"""
import os
import sys

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt                                   # noqa: E402
import numpy as np                                                # noqa: E402

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)

import moon_transfer_analysis as A                                # noqa: E402
import moon_transfer_propagate as P                               # noqa: E402

OUT = os.path.join(HERE, "moon_transfer.png")

# 序數階:四圈由淺到深(亮度單調)
LOOP_RAMP = ["#cde2fb", "#86b6ef", "#3987e5", "#1c5cab"]
# 類別色:固定順序,不循環
C_BLUE, C_ORANGE, C_AQUA = "#2a78d6", "#eb6834", "#1baf7a"
INK, MUTED, GRID = "#14181d", "#5a6876", "#dde4ec"


def loop_tracks():
    """每一圈的軌跡(關掉月球,各積一整圈)。"""
    ph = A.phasing_loops()
    y = P.perigee_state()
    tracks = []
    for leg in ph["legs"]:
        dv = leg["dv"]
        y = y.copy()
        vhat = y[3:] / np.linalg.norm(y[3:])
        y[3:] = y[3:] + dv * vhat
        a = 0.5 * (leg["r_apo"] + np.linalg.norm(y[:3]))
        sol = P.propagate(y, A.period(a), moon_on=False)
        t = np.linspace(0.0, A.period(a), 600)
        tracks.append((leg, sol.sol(t)[:3]))
    return ph, tracks


def panel_track(ax, ph, tracks, res):
    """(a) 相位迴圈 + 地月轉移的軌跡。"""
    th = np.linspace(0, 2 * np.pi, 400)
    ax.plot(A.A_MOON * np.cos(th) / 1e3, A.A_MOON * np.sin(th) / 1e3,
            color=MUTED, lw=0.9, ls=":", zorder=1)
    ax.text(0.0, A.A_MOON / 1e3 + 18, "Moon orbit", color=MUTED, fontsize=7,
            ha="center")
    # 內三圈的遠地點只有 11,809 / 43,907 / 132,653 km,對上 384,400 km
    # 本來就畫不開 —— 這是**尺度問題**,不是標籤放哪裡的問題。
    # 所以主圖只標外面兩圈,內兩圈交給右下角的放大插圖。
    inset = ax.inset_axes([0.60, 0.05, 0.38, 0.38])
    for (leg, xyz), col in zip(tracks, LOOP_RAMP):
        ax.plot(xyz[0] / 1e3, xyz[1] / 1e3, color=col, lw=1.6, zorder=2)
        inset.plot(xyz[0] / 1e3, xyz[1] / 1e3, color=col, lw=1.4)
        i = int(np.argmax(xyz[1]))
        tgt = ax if leg["loop"] >= 3 else inset
        tgt.annotate("loop %d" % leg["loop"],
                     (xyz[0, i] / 1e3, xyz[1, i] / 1e3),
                     textcoords="offset points", xytext=(0, 5), fontsize=7.5,
                     color=INK, ha="center")
    inset.plot([0], [0], "o", color=INK, ms=3)
    inset.set_xlim(-62, 18)
    inset.set_ylim(-26, 26)
    inset.set_aspect("equal")
    inset.set_title("zoom: loops 1-2", fontsize=7.5, color=MUTED, pad=2)
    inset.tick_params(labelsize=6, colors=MUTED)
    for sp in inset.spines.values():
        sp.set_color(GRID)
    if res.get("ok"):
        rm, _ = P.moon_state(res["t_peri_days"] * 86400.0, res["phase0"])
        ax.plot([rm[0] / 1e3], [rm[1] / 1e3], "o", color=C_ORANGE, ms=7, zorder=4)
        ax.annotate("Moon at arrival, day %.2f" % res["t_peri_days"],
                    (rm[0] / 1e3, rm[1] / 1e3), textcoords="offset points",
                    xytext=(10, -42), fontsize=7.5, color=C_ORANGE, ha="left")
    ax.plot([0], [0], "o", color=INK, ms=5, zorder=5)
    ax.annotate("Earth", (0, 0), textcoords="offset points", xytext=(6, 4),
                fontsize=7, color=INK)
    ax.set_aspect("equal")
    ax.set_xlabel("x (1000 km)")
    ax.set_ylabel("y (1000 km)")
    ax.set_title("(a) Phasing loops, Earth-Moon plane", fontsize=10)
    ax.grid(color=GRID, lw=0.6)
    ax.set_axisbelow(True)


def panel_dv(ax, ph, legs_n):
    """(b) 每一圈的 Δv(單一數列)+ 三條路一致的註記。"""
    x = np.arange(1, len(ph["legs"]) + 1)
    vals = [l["dv"] for l in ph["legs"]]
    ax.bar(x, vals, width=0.58, color=C_BLUE, zorder=2)
    for xi, vi in zip(x, vals):
        ax.text(xi, vi + 0.04, "%.4f" % vi, ha="center", fontsize=7.5, color=INK)
    worst = max(abs(l["diff"]) for l in legs_n) * 1000.0
    ax.set_ylim(0, max(vals) * 1.22)
    ax.set_xticks(x)
    ax.set_xticklabels(["loop %d" % i for i in x])
    ax.set_ylabel("delta-v (km/s)")
    ax.set_title("(b) Burn per loop — same total as one direct TLI", fontsize=10)
    ax.text(0.98, 0.94,
            "analytic = numeric = Astrogator\nworst leg difference %.3f m/s" % worst,
            transform=ax.transAxes, ha="right", va="top", fontsize=7.5,
            color=MUTED)
    ax.grid(axis="y", color=GRID, lw=0.6)
    ax.set_axisbelow(True)


def panel_scan(ax, res):
    """(c) TLI 目標器的掃描曲線:Δv 對最近月心距離。"""
    dv0 = A.tli_direct()["dv"]
    g_dv = (np.asarray(res["grid_dv"]) - dv0) * 1000.0
    # 畫**高度**而不是月心距離:月面 1737 km 與目標 1837 km,
    # 在 17,500 km 的軸上根本分不開,兩條參考線會疊成一條。
    g_h = np.asarray(res["grid_d"]) - A.R_MOON
    ax.axhspan(-2000, 0, color=MUTED, alpha=0.12, zorder=1)
    ax.text(g_dv[0], -180, " below this line = impact", color=MUTED,
            fontsize=7.5, va="top")
    ax.plot(g_dv, g_h, color=C_BLUE, lw=1.8, zorder=3)
    ax.axhline(A.LLO_ALT, color=C_ORANGE, lw=1.4, ls="--", zorder=2)
    ax.text(g_dv[-1], A.LLO_ALT + 110, "target %.0f km " % A.LLO_ALT,
            color=C_ORANGE, fontsize=7.5, va="bottom", ha="right")
    if res.get("ok"):
        ax.plot([(res["dv_tli"] - dv0) * 1000.0], [res["alt_peri"]], "o",
                color=C_AQUA, ms=7, zorder=4)
        ax.annotate("solution %+.2f m/s" % ((res["dv_tli"] - dv0) * 1000.0),
                    ((res["dv_tli"] - dv0) * 1000.0, res["alt_peri"]),
                    textcoords="offset points", xytext=(8, 16), fontsize=7.5,
                    color=INK)
    ax.set_ylim(-2000, 6000)
    ax.set_xlabel("TLI delta-v offset from two-body value (m/s)")
    ax.set_ylabel("closest approach altitude above Moon (km)")
    ax.set_title("(c) Lunar arrival is extremely sensitive to TLI", fontsize=10)
    ax.grid(color=GRID, lw=0.6)
    ax.set_axisbelow(True)


def panel_growth(ax, ph):
    """(d) 遠地點高度(對數)+ 週期用直接標籤 —— **不開第二條 y 軸**。"""
    x = np.arange(1, len(ph["legs"]) + 1)
    alt = [l["alt_apo"] for l in ph["legs"]]
    # **不用長條**:長條的長度要從零基線起算才有意義,而對數軸沒有零。
    # 跨三個數量級的量值用點圖 —— 位置本身就是編碼。
    for xi, a_ in zip(x, alt):
        ax.plot([xi, xi], [4e3, a_], color=GRID, lw=1.2, zorder=2)
    ax.plot(x, alt, "o", color=LOOP_RAMP[3], ms=9, zorder=3)
    for xi, l in zip(x, ph["legs"]):
        ax.annotate("%.0f km / %.2f d" % (l["alt_apo"], l["period_days"]),
                    (xi, l["alt_apo"]), textcoords="offset points",
                    xytext=(0, 13), ha="center", fontsize=7.5, color=INK)
    ax.set_yscale("log")
    ax.set_ylim(4e3, max(alt) * 12)
    ax.set_xlim(0.4, len(x) + 0.6)
    ax.set_xticks(x)
    ax.set_xticklabels(["loop %d" % i for i in x])
    ax.set_ylabel("apogee altitude (km, log)")
    ax.set_title("(d) Apogee grows geometrically (label: altitude / period)",
                 fontsize=10)
    ax.grid(axis="y", color=GRID, lw=0.6, which="both")
    ax.set_axisbelow(True)


def main():
    print("  積分相位迴圈的軌跡 ...")
    ph, tracks = loop_tracks()
    print("  解 TLI 目標器 ...")
    res = P.target_lunar_periapsis()
    print("  數值目標器對照 ...")
    legs_n, _ = P.phasing_by_targeting(moon_on=False)

    fig, ax = plt.subplots(2, 2, figsize=(13.5, 10.5))
    panel_track(ax[0][0], ph, tracks, res)
    panel_dv(ax[0][1], ph, legs_n)
    panel_scan(ax[1][0], res)
    panel_growth(ax[1][1], ph)
    fig.suptitle("Earth-Moon transfer by phasing loops "
                 "(analytic / numeric / Astrogator agree)", fontsize=12)
    fig.tight_layout(rect=[0, 0, 1, 0.965])
    fig.savefig(OUT, dpi=130)
    print("  已寫出 %s" % os.path.basename(OUT))
    return 0


if __name__ == "__main__":
    _rc = main()
    # 在 notebook 裡不要用 SystemExit 打斷 kernel;在終端機才回傳離開碼。
    # 這是庫裡既有的慣例(各週的驗證段都這樣寫)—— 少了它,
    # 這一格在 notebook 執行時會以 SystemExit 失敗。
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
moon_transfer/moon_transfer_propagate.py
# -*- coding: utf-8 -*-
r"""地月轉移 —— 純 Python 的數值傳播與目標器(不用 STK)。

## 這一支要回答的問題

「不靠 Astrogator,純 Python 算得出繞月軌道嗎?」

算得出。但要先分清楚**傳播**與**目標**這兩件事:

- **傳播**很簡單:地球 + 月球兩個點質量,`solve_ivp` 積下去就好。
- **目標**才是工作量:「近地點要燒多少,才會在幾天後從月球前緣
  100 km 通過?」那是一個**邊界值問題**。Astrogator 的價值不在傳播,
  在它的差分修正器幫你解這個反問題。這裡用 `scipy.optimize.brentq`
  自己解 —— 同一件事,只是要自己寫。

而繞月捕獲**對初始條件極度敏感**:TLI 的速度差 1 m/s,到了月球附近
近月點可以差上千公里。所以難的不是積分器準不準,是目標器收不收斂、
以及初始猜測夠不夠好。這也正是相位迴圈法的實務價值之一 ——
每一圈近地點都是一次重新瞄準的機會。

## 三條路互相驗證

    解析(moon_transfer_analysis)  閉合解,給 Δv 帳
    本檔                            數值積分 + 自寫目標器
    STK(moon_transfer_build)      Astrogator 的差分修正器

三條路各自獨立。它們給同一個答案才算數 ——
拿模型對自己不算驗證,這個教材庫反覆踩過這個坑。

## 這個模型簡化了什麼(要說清楚)

- 月球走**圓軌道**、且在赤道面上。真實月球軌道有 e=0.0549、
  對黃道傾斜 5.145°,而黃道又對赤道傾斜 23.4°。
  要真的幾何就得換成 JPL 星曆(這台機器有 skyfield / jplephem)。
- 只有地球與月球兩個點質量:沒有 J2、沒有太陽、沒有太陽輻射壓。
- 點火是**脈衝**的:沒有有限推力損失。

所以本檔給的是「**在這個模型裡**的一條真軌跡」,不是可以拿去飛的軌道。
但它足以回答這一章的問題:相位迴圈的 Δv、到得了月球嗎、近月點多高、
捕獲要多少 Δv。

    python moon_transfer_propagate.py
"""
import math
import os
import sys

import numpy as np
from scipy.integrate import solve_ivp
from scipy.optimize import brentq

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)

import moon_transfer_analysis as A                                # noqa: E402

# 恆星月(月球相對恆星繞地球一圈),不是朔望月的 29.53 天
SIDEREAL_MONTH_S = 27.321661 * 86400.0
N_MOON = 2.0 * math.pi / SIDEREAL_MONTH_S          # rad/s

RTOL, ATOL = 1e-11, 1e-9                           # 地月尺度要很緊才守得住能量
MAX_SPAN_S = 40.0 * 86400.0                        # 單次積分的時間上限


# ---------------------------------------------------------------- 模型
def moon_state(t, phase0=0.0):
    """月球在時刻 t 的位置與速度(km, km/s)。圓軌道、赤道面。"""
    th = phase0 + N_MOON * t
    c, s = math.cos(th), math.sin(th)
    r = A.A_MOON * np.array([c, s, 0.0])
    v = A.A_MOON * N_MOON * np.array([-s, c, 0.0])
    return r, v


def accel(t, y, phase0=0.0, moon_on=True):
    """地心慣性系的加速度。含月球的**直接項與間接項**。

    間接項 (−mu_M r_M/|r_M|^3) 不能省 —— 少了它,參考系(地心)本身
    被月球拉動的那一份就沒算進去,近地端的擾動會偏掉。
    """
    r = y[:3]
    rn = math.sqrt(r @ r)
    a = -A.MU_EARTH * r / rn ** 3
    if moon_on:
        rm, _ = moon_state(t, phase0)
        d = rm - r
        dn = math.sqrt(d @ d)
        rmn = math.sqrt(rm @ rm)
        a = a + A.MU_MOON * (d / dn ** 3 - rm / rmn ** 3)
    return np.hstack((y[3:], a))


def _radial_rate(t, y, *_a):
    """r·v = 0 -> 近地點或遠地點。"""
    return float(y[:3] @ y[3:])


def _moon_range_rate(t, y, phase0=0.0, moon_on=True):
    """到月球的距離變化率 = 0 -> 最接近點。"""
    rm, vm = moon_state(t, phase0)
    d = y[:3] - rm
    dv = y[3:] - vm
    return float(d @ dv)


def propagate(y0, t_end, phase0=0.0, moon_on=True, events=None, t0=0.0):
    return solve_ivp(accel, (t0, t_end), y0, method="DOP853",
                     rtol=RTOL, atol=ATOL, args=(phase0, moon_on),
                     events=events, dense_output=True, max_step=3600.0)


# ---------------------------------------------------------------- 相位迴圈
def perigee_state(alt=A.PARK_ALT):
    """停泊圓軌道的近地點狀態(在 +x 上,速度沿 +y)。"""
    rp = A.RE + alt
    return np.array([rp, 0.0, 0.0, 0.0, A.v_circ(rp), 0.0])


def apoapsis_after_burn(dv, y0, phase0=0.0, moon_on=False):
    """在 y0(近地點)沿速度方向加 dv,積到下一個遠地點,回傳該處半徑。"""
    y = y0.copy()
    vhat = y[3:] / np.linalg.norm(y[3:])
    y[3:] = y[3:] + dv * vhat
    # 粗估週期當上限,再放寬。**a <= 0 表示這一下燒到脫離了**,
    # 沒有遠地點可言 —— 不擋的話,brentq 一試到 3.5 km/s 就會讓
    # period(a) 對雙曲線軌道開根號,直接 math domain error。
    a = 1.0 / (2.0 / np.linalg.norm(y[:3]) - (np.linalg.norm(y[3:]) ** 2) / A.MU_EARTH)
    if a <= 0.0:
        return float("inf")
    ev = _radial_rate
    ev.terminal = True
    ev.direction = -1                 # r·v 由正轉負 = 遠地點
    # **積分時間一定要設上限。** brentq 的搜尋上界逼近脫離速度時 a -> 無限大,
    # 1.2 * period(a) 會讓單次呼叫去積好幾年的模擬時間 ——
    # 少了上限,四圈的目標器會跑 252 秒,而單次呼叫其實只要 0.003 秒。
    # 超過上限還沒到遠地點,就當成「遠地點比我們關心的還遠」回 inf,
    # f = 遠地點 - 目標 在高的那一側恆正,brentq 照樣夾得住。
    t_max = min(1.2 * A.period(a), MAX_SPAN_S)
    sol = propagate(y, t_max, phase0, moon_on, events=ev)
    if not sol.t_events[0].size:
        return float("inf")
    ye = sol.y_events[0][0]
    return float(np.linalg.norm(ye[:3]))


def solve_loop_dv(target_ra, y0=None, moon_on=False):
    """**目標器**:解出讓遠地點達 target_ra 的近地點 Δv(km/s)。

    這就是 Astrogator 差分修正器在做的事,只是換成 `brentq`。
    單變數、單約束,所以一維求根就夠;多變數才需要 `least_squares`。
    """
    y0 = perigee_state() if y0 is None else y0
    f = lambda dv: apoapsis_after_burn(dv, y0, moon_on=moon_on) - target_ra
    # 上界用**物理**定出來:再燒下去就脫離了,那一側沒有遠地點。
    rn = float(np.linalg.norm(y0[:3]))
    vn = float(np.linalg.norm(y0[3:]))
    hi = 0.999 * (A.v_esc(rn) - vn)
    if hi <= 1e-6:
        raise ValueError("這個狀態已經接近脫離,沒有遠地點可解")
    return brentq(f, 1e-6, hi, xtol=1e-10, rtol=1e-12)


def phasing_by_targeting(moon_on=False):
    """把解析給的每一圈遠地點,交給數值目標器自己解 Δv。"""
    ph = A.phasing_loops()
    y = perigee_state()
    out = []
    for leg in ph["legs"]:
        dv = solve_loop_dv(leg["r_apo"], y, moon_on=moon_on)
        out.append({"loop": leg["loop"], "r_apo": leg["r_apo"],
                    "dv_analytic": leg["dv"], "dv_numeric": dv,
                    "diff": dv - leg["dv"]})
        # 下一圈仍從同一個近地點出發,但速度已經被前幾次點火抬高
        y = y.copy()
        vhat = y[3:] / np.linalg.norm(y[3:])
        y[3:] = y[3:] + dv * vhat
    return out, ph


# ---------------------------------------------------------------- 到月球
def lunar_closest(dv_tli, phase0, y0=None, days=12.0):
    """給 TLI 的 Δv 與月球初始相位,積出去找**最接近月球**的那一點。

    回傳 (最近距離 km, 該時刻 s, 該時刻的狀態)。找不到就回 (inf, nan, None)。
    """
    y0 = perigee_state() if y0 is None else y0
    y = y0.copy()
    vhat = y[3:] / np.linalg.norm(y[3:])
    y[3:] = y[3:] + dv_tli * vhat

    ev = _moon_range_rate
    ev.terminal = False
    ev.direction = 1                  # 由靠近轉為遠離 = 最接近點
    sol = propagate(y, days * 86400.0, phase0, True, events=ev)
    best, bt, by = float("inf"), float("nan"), None
    for t, ye in zip(sol.t_events[0], sol.y_events[0]):
        rm, _ = moon_state(t, phase0)
        d = float(np.linalg.norm(ye[:3] - rm))
        if d < best:
            best, bt, by = d, float(t), ye
    return best, bt, by


def moon_phase_for_rendezvous(dv_tli, y0=None):
    """讓月球在探測船抵達遠地點時**剛好在那裡**的初始相位。

    先用無月球的兩體解算飛行時間,再往回推月球該從哪裡出發。
    這只是**初始猜測** —— 真正的相位要靠下面的目標器微調。
    """
    y0 = perigee_state() if y0 is None else y0
    rn = float(np.linalg.norm(y0[:3]))
    vn = float(np.linalg.norm(y0[3:])) + dv_tli
    a = 1.0 / (2.0 / rn - vn ** 2 / A.MU_EARTH)
    tof = A.period(a) / 2.0
    # 探測船的遠地點在 −x 方向(從 +x 近地點出發、逆行半圈)
    return math.pi - N_MOON * tof


def target_lunar_periapsis(target_alt=A.LLO_ALT, span_kms=0.005, n_grid=25,
                           days=8.0):
    """**第二層目標器**:微調 TLI 的 Δv,打到指定的近月點高度。

    月球相位固定(= 固定發射日),只調 Δv。這是一維求根,
    但**函數不單調也不連續** —— 差太多就根本不接近月球。
    所以先掃一遍找出有解的區間,再在區間內求根。
    """
    dv0 = A.tli_direct()["dv"]
    phase0 = moon_phase_for_rendezvous(dv0)
    target_r = A.R_MOON + target_alt

    # 掃描範圍收窄到 ±5 m/s、25 點:實測解只比兩體值高 1.6 m/s,
    # 用 ±30 m/s x 61 點要跑 3 分 45 秒,大部分時間花在離解很遠的地方。
    grid = np.linspace(dv0 - span_kms, dv0 + span_kms, n_grid)
    vals = []
    for dv in grid:
        d, t, y = lunar_closest(dv, phase0, days=days)
        vals.append(d)
    vals = np.array(vals)
    f = vals - target_r
    # 找變號的相鄰格
    idx = np.where(np.sign(f[:-1]) * np.sign(f[1:]) < 0)[0]
    if not idx.size:
        return {"ok": False, "phase0": phase0, "grid_dv": grid,
                "grid_d": vals, "best": float(np.nanmin(vals))}
    i = idx[int(np.argmin(np.abs(f[idx])))]
    g = lambda dv: lunar_closest(dv, phase0, days=days)[0] - target_r
    dv = brentq(g, grid[i], grid[i + 1], xtol=1e-9, rtol=1e-12)
    d, t, y = lunar_closest(dv, phase0, days=days)
    return {"ok": True, "dv_tli": dv, "phase0": phase0, "r_peri": d,
            "alt_peri": d - A.R_MOON, "t_peri_days": t / 86400.0,
            "state": y, "grid_dv": grid, "grid_d": vals}


def loi_from_state(y, t, phase0):
    """在最接近點,把月心相對速度減到繞月圓軌道速度。"""
    rm, vm = moon_state(t, phase0)
    r_rel = y[:3] - rm
    v_rel = y[3:] - vm
    rn = float(np.linalg.norm(r_rel))
    vn = float(np.linalg.norm(v_rel))
    v_circ_moon = A.v_circ(rn, A.MU_MOON)
    # 月心的雙曲線能量 -> v_inf
    energy = vn ** 2 / 2.0 - A.MU_MOON / rn
    v_inf = math.sqrt(2.0 * energy) if energy > 0 else 0.0
    return {"r_peri": rn, "alt": rn - A.R_MOON, "v_rel": vn,
            "v_circ": v_circ_moon, "dv": vn - v_circ_moon,
            "v_inf": v_inf, "hyperbolic": energy > 0}


# ---------------------------------------------------------------- 輸出
def main():
    print("=" * 78)
    print("  地月轉移 —— 純 Python 數值傳播與目標器(不用 STK)")
    print("=" * 78)

    # ---- 1. 能量守恆:先證明積分器本身站得住 ----
    y0 = perigee_state()
    y = y0.copy()
    y[3:] = y[3:] + A.tli_direct()["dv"] * np.array([0.0, 1.0, 0.0])
    sol = propagate(y, 4.0 * 86400.0, moon_on=False)
    e0 = float(np.linalg.norm(y[3:]) ** 2 / 2 - A.MU_EARTH / np.linalg.norm(y[:3]))
    yf = sol.y[:, -1]
    ef = float(np.linalg.norm(yf[3:]) ** 2 / 2 - A.MU_EARTH / np.linalg.norm(yf[:3]))
    print("  積分器自檢(關掉月球,兩體):比能量漂移 %.3e(相對 %.2e)"
          % (ef - e0, abs((ef - e0) / e0)))
    print()

    # ---- 2. 相位迴圈:數值目標器 vs 解析 ----
    legs, ph = phasing_by_targeting(moon_on=False)
    print("  相位迴圈 —— brentq 目標器自己解出每一圈的 Δv")
    print("    圈   目標遠地點(km)    解析 Δv    數值 Δv     差(m/s)")
    for l in legs:
        print("    %2d   %14.1f   %8.5f   %8.5f   %+9.4f"
              % (l["loop"], l["r_apo"], l["dv_analytic"], l["dv_numeric"],
                 l["diff"] * 1000.0))
    tot = sum(l["dv_numeric"] for l in legs)
    print("    總 Δv:解析 %.5f  數值 %.5f  差 %+.4f m/s"
          % (ph["dv_total"], tot, (tot - ph["dv_total"]) * 1000.0))
    print()

    # ---- 3. 打到月球 ----
    print("  TLI 目標器 —— 微調 Δv 打到 %.0f km 近月點" % A.LLO_ALT)
    res = target_lunar_periapsis()
    if not res["ok"]:
        print("    這個發射相位下掃不到解,最接近 %.0f km(月心)" % res["best"])
        print("    -> 要換發射日(調 phase0)或放寬 span。這正是「發射窗口」的意思。")
        return 1
    print("    TLI Δv %.6f km/s(解析 %.6f,差 %+.2f m/s)"
          % (res["dv_tli"], A.tli_direct()["dv"],
             (res["dv_tli"] - A.tli_direct()["dv"]) * 1000.0))
    print("    近月點高度 %.1f km、抵達於第 %.2f 天"
          % (res["alt_peri"], res["t_peri_days"]))
    print()

    loi = loi_from_state(res["state"], res["t_peri_days"] * 86400.0, res["phase0"])
    print("  繞月進入(在最接近點把月心相對速度減到圓軌道速度)")
    print("    月心相對速度 %.4f、繞月圓軌道 %.4f -> **Δv %.4f km/s**"
          % (loi["v_rel"], loi["v_circ"], loi["dv"]))
    print("    v_inf %.4f km/s、雙曲線軌道:%s"
          % (loi["v_inf"], "是" if loi["hyperbolic"] else "否(已被弱捕獲)"))
    print("    解析的拼接圓錐給 %.4f km/s" % A.loi_dv(A.arrival_v_inf()[0])["dv"])
    print()
    print("  三條路的總帳(TLI + LOI,km/s)")
    print("    解析  %.4f" % (ph["dv_total"] + A.loi_dv(A.arrival_v_inf()[0])["dv"]))
    print("    數值  %.4f" % (tot + loi["dv"]))
    return 0


if __name__ == "__main__":
    _rc = main()
    # 在 notebook 裡不要用 SystemExit 打斷 kernel;在終端機才回傳離開碼。
    # 這是庫裡既有的慣例(各週的驗證段都這樣寫)—— 少了它,
    # 這一格在 notebook 執行時會以 SystemExit 失敗。
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
moon_transfer/moon_transfer_verify.py
# -*- coding: utf-8 -*-
r"""地月轉移章 — 解析段的驗證。

這一段**不需要 STK**,查的是解析核心本身站不站得住。分三類:

1. **數學恆等式** —— 對就是對,沒有容差可言
   脫離速度 / 圓軌道速度 = sqrt(2);克卜勒第三定律;活力公式與軌道能量一致
2. **獨立的外部對照** —— 公開文獻上查得到的數字,不是本模組算出來的
   月球影響球半徑 66,100 km、100 km 繞月圓軌道 1.633 km/s、
   LEO 出發的 TLI 約 3.1 km/s、LOI 約 0.8-0.9 km/s
3. **本章的核心結論** —— 相位迴圈的總 Δv 等於直接 TLI
   這一項不是「差不多」,是**恆等**:所有點火都在同一個近地點,
   速度在同一點線性相加。所以容差取到浮點等級。

第 2 類才是真正的驗證 —— 第 1 類只證明程式自洽,第 3 類只證明推導沒寫錯。
拿模型自己的輸出釘模型自己不算驗證,這是這個教材庫反覆踩過的坑。

執行:python moon_transfer_verify.py
  離開碼 0 = 全部通過,1 = 有項目對不上
"""
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, HERE)
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))

from stk_verify import Verifier                                   # noqa: E402

import moon_transfer_analysis as A                                # noqa: E402


def main():
    v = Verifier(None)

    # ---------------- 1. 數學恆等式 ----------------
    r = A.RE + A.PARK_ALT
    v.expect("脫離速度 / 圓軌道速度 = sqrt(2)",
             A.v_esc(r) / A.v_circ(r), math.sqrt(2.0), tol=1e-12)
    # 克卜勒第三定律:週期比的平方 = 半長軸比的立方
    a1, a2 = 7000.0, 42164.0
    v.expect("克卜勒第三定律 (T2/T1)^2 = (a2/a1)^3",
             (A.period(a2) / A.period(a1)) ** 2, (a2 / a1) ** 3, tol=1e-9)
    # 活力公式要與軌道能量 -mu/(2a) 一致
    a3, r3 = 20000.0, 9000.0
    eps = A.vis_viva(r3, a3) ** 2 / 2.0 - A.MU_EARTH / r3
    v.expect("活力公式與軌道能量 -mu/(2a) 一致", eps, -A.MU_EARTH / (2 * a3),
             tol=1e-9, unit=" km^2/s^2")

    # ---------------- 2. 獨立的外部對照 ----------------
    v.expect("月球影響球半徑(公認約 66,100 km)", A.soi_radius(), 66100.0,
             tol=200.0, unit=" km")
    v.expect("100 km 繞月圓軌道速度(公認 1.633 km/s)",
             A.v_circ(A.R_MOON + 100.0, A.MU_MOON), 1.633, tol=0.005,
             unit=" km/s")
    d = A.tli_direct()
    v.expect_between("LEO 出發的 TLI(文獻約 3.1 km/s)", d["dv"], 3.05, 3.20,
                     unit=" km/s")
    vinf, _v_sc, _v_moon = A.arrival_v_inf()
    loi = A.loi_dv(vinf)
    v.expect_between("繞月進入 LOI(文獻約 0.8-0.9 km/s)", loi["dv"], 0.75, 0.95,
                     unit=" km/s")
    # 月球公轉速度:由 mu/a 算,對照公認的 1.022 km/s
    v.expect("月球公轉速度(公認 1.022 km/s)", A.v_circ(A.A_MOON), 1.022,
             tol=0.01, unit=" km/s")

    # ---------------- 3. 本章的核心結論 ----------------
    direct, phased, diff = A.phasing_total_equals_direct()
    v.expect("相位迴圈總 Δv **恆等於** 直接 TLI", phased, direct, tol=1e-9,
             unit=" km/s")
    v.expect_true("這是恆等不是近似(差值在浮點等級)", abs(diff) < 1e-9,
                  "差 %.3e km/s" % diff)
    # 換個圈數也必須成立 —— 若只有預設圈數對得上,那是巧合不是恆等
    for n in (1, 2, 5, 8):
        _dd, _pp, dif = A.phasing_total_equals_direct(n=n)
        if abs(dif) >= 1e-9:
            v.expect_true("圈數 %d 時仍恆等" % n, False, "差 %.3e" % dif)
            break
    else:
        v.expect_true("圈數換成 1/2/5/8 仍然恆等(不是巧合)", True,
                      "四種圈數的差都 < 1e-9 km/s")

    # ---------------- 4. 相位迴圈的代價:時間 ----------------
    ph = A.phasing_loops()
    v.expect_true("相位迴圈換到的是時間:歷時遠長於直接轉移",
                  ph["elapsed_days"] > 1.5 * d["tof_days"],
                  "迴圈 %.1f 天 vs 直接 %.2f 天"
                  % (ph["elapsed_days"], d["tof_days"]))
    v.expect_true("每一次點火都比單次 TLI 小(小推力也做得到)",
                  max(l["dv"] for l in ph["legs"]) < d["dv"],
                  "最大單次 %.4f < 直接 %.4f km/s"
                  % (max(l["dv"] for l in ph["legs"]), d["dv"]))
    v.expect_true("遠地點逐圈抬高、週期逐圈變長",
                  all(ph["legs"][i]["r_apo"] < ph["legs"][i + 1]["r_apo"]
                      and ph["legs"][i]["period_days"]
                      < ph["legs"][i + 1]["period_days"]
                      for i in range(len(ph["legs"]) - 1)),
                  "%d 圈遞增" % len(ph["legs"]))

    # ---------------- 5. 純 Python 數值傳播 + 自寫目標器 ----------------
    # 第三條路:不用 STK,自己積分、自己解反問題(brentq 取代差分修正器)。
    import numpy as np                                            # noqa: E402
    import moon_transfer_propagate as P                           # noqa: E402

    # 積分器自檢:關掉月球就是兩體,比能量必須守住
    y = P.perigee_state()
    y[3:] = y[3:] + A.tli_direct()["dv"] * np.array([0.0, 1.0, 0.0])
    e0 = float(np.dot(y[3:], y[3:]) / 2 - A.MU_EARTH / np.linalg.norm(y[:3]))
    sol = P.propagate(y, 4.0 * 86400.0, moon_on=False)
    yf = sol.y[:, -1]
    ef = float(np.dot(yf[3:], yf[3:]) / 2 - A.MU_EARTH / np.linalg.norm(yf[:3]))
    v.expect_true("積分器守得住比能量(兩體、4 天,相對漂移 < 1e-8)",
                  abs((ef - e0) / e0) < 1e-8,
                  "相對漂移 %.2e" % abs((ef - e0) / e0))

    legs_n, ph_n = P.phasing_by_targeting(moon_on=False)
    worst_n = max(abs(l["diff"]) for l in legs_n)
    v.expect_true("數值目標器解出的每一圈 Δv 都對上解析值(差 < 1 m/s)",
                  worst_n < 1e-3, "最差 %.4f m/s" % (worst_n * 1000.0))
    v.expect("數值目標器的總 Δv 對上解析值",
             sum(l["dv_numeric"] for l in legs_n), ph_n["dv_total"],
             tol=1e-5, unit=" km/s")

    # 真的打到月球:這是解析做不到的事(拼接圓錐只給量級,不給幾何)
    res = P.target_lunar_periapsis()
    v.expect_true("TLI 目標器解得出來(這個發射相位有解)", res["ok"],
                  "" if res["ok"] else "最接近 %.0f km" % res.get("best", -1))
    if res["ok"]:
        v.expect("目標器打到的近月點高度", res["alt_peri"], A.LLO_ALT, tol=1.0,
                 unit=" km")
        v.expect_between("到達月球的飛行時間(天)", res["t_peri_days"], 3.5, 7.0,
                         unit=" 天")
        # 月球引力對離開地球那一下的影響很小 —— 值得學生看到的量級
        v.expect_true("含月球引力的 TLI 與純兩體相差不到 10 m/s",
                      abs(res["dv_tli"] - A.tli_direct()["dv"]) < 0.01,
                      "%+.2f m/s"
                      % ((res["dv_tli"] - A.tli_direct()["dv"]) * 1000.0))
        loi_n = P.loi_from_state(res["state"], res["t_peri_days"] * 86400.0,
                                 res["phase0"])
        v.expect_true("抵達月球時仍是雙曲線(不會自己被捕獲)",
                      loi_n["hyperbolic"], "v_inf %.4f km/s" % loi_n["v_inf"])
        # **這一項是在量拼接圓錐的誤差**,不是在量模型準不準
        loi_a = A.loi_dv(A.arrival_v_inf()[0])["dv"]
        rel = abs(loi_n["dv"] - loi_a) / loi_a
        v.expect_true("數值 LOI 與拼接圓錐的解析值相差 < 5%(量的是近似的誤差)",
                      rel < 0.05,
                      "數值 %.4f vs 解析 %.4f,差 %.1f%%"
                      % (loi_n["dv"], loi_a, rel * 100.0))

    # ---------------- 6. STK/Astrogator 的獨立重現 ----------------
    # 解析說「相位迴圈的總 Δv 恆等於直接 TLI」。這一段讓 Astrogator 的
    # **差分修正器**自己去解每一圈要燒多少,再回來對 —— 那是完全不同的
    # 路徑(真的在傳播、真的在迭代),不是拿模型對自己。
    # STK 沒開就整段 SKIP,不讓它變成假通過。
    try:
        from agi.stk12.stkdesktop import STKDesktop
        import moon_transfer_build as B
        app = STKDesktop.AttachToApplication()
        root = app.Root
        sc = root.CurrentScenario
        have_stk = sc is not None and sc.InstanceName == B.SCENARIO
    except Exception as exc:                          # noqa: BLE001
        have_stk = False
        v.expect_true("STK 場景 %s 已載入" % "Moon_Transfer", None,
                      "%s —— 先跑 moon_transfer_build.py" % type(exc).__name__)

    if have_stk:
        ph = A.phasing_loops()
        v.expect_true("STK 場景已載入", True, sc.InstanceName)
        ms = root.GetObjectFromPath(B.SAT).Propagator.MainSequence
        # 濾掉 "-" —— 那是插入點標記,不是段
        names = [ms.Item(i).Name for i in range(ms.Count)
                 if ms.Item(i).Name != "-"]
        want = ["Park"]
        for l in ph["legs"]:
            i = l["loop"]
            want += ["ToPerigee%d" % i,
                     "Loop%d" % i if i < len(ph["legs"]) else "TLI"]
        v.expect_true("MCS 主序列與腳本描述的一致", names == want,
                      "讀回 %s" % names)

        tot = 0.0
        worst = 0.0
        for l in ph["legs"]:
            i = l["loop"]
            ts = "Loop%d" % i if i < len(ph["legs"]) else "TLI"
            d = B.solved_dv(root, ts, "DV%d" % i)
            tot += d
            worst = max(worst, abs(d - l["dv"]))
        v.expect_true("每一圈解出來的 Δv 都對得上解析值(差 < 1 m/s)",
                      worst < 1e-3, "最差 %.5f km/s" % worst)
        # **這一章的主驗證**:兩條完全不同的路徑給同一個總 Δv
        v.expect("Astrogator 解出的總 Δv 對上解析值", tot, ph["dv_total"],
                 tol=2e-3, unit=" km/s")
        v.expect("而解析的總 Δv 又恆等於直接 TLI", ph["dv_total"], direct,
                 tol=1e-9, unit=" km/s")

    ok = v.report("地月轉移章")
    return 0 if ok else 1


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)