獨立章節 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 繞月圓軌道。
你要回答的是:
- 這趟要多少 Δv?分幾次燒?
- 分次燒會不會比較貴?(先猜,再算)
- 到得了月球嗎?近月點多高?差一點會怎樣?
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,回答:
- 直接 TLI 要多少 Δv?飛行時間多久?
- LOI 要多少?總共多少?
- 月球影響球半徑是多少?和公認的 66,100 km 差多少?
- 查一個公開資料(任何探月任務都可以),把它的 TLI 與 LOI 抄下來,
和你算的比。差多少?為什麼會差?
提示:大多數任務不走最小能量轉移。飛得快要用 Δv 買。
3. Part B — 相位迴圈:先猜再算(佔 25%,本章重點)
動手之前先寫下你的猜測(這一題的分數給在「有沒有誠實記錄猜測」):
分成 4 次燒,總 Δv 會比一次燒完(3.13 km/s)多、少、還是一樣?
為什麼?
寫完再跑。然後回答:
- 實際差多少?(答案在輸出的「差」那一行)
- 為什麼是這個答案? 用一句話解釋,不要抄。
- 把
N_LOOPS改成 2、6、10 再跑。恆等式還成立嗎? - 既然 Δv 一樣,為什麼真實任務還要分次燒?列出三個理由。
- 相位迴圈的代價是什麼?從輸出裡找出兩個數字支持你的答案。
4. Part C — 數值積分(佔 20%)
跑 moon_transfer_propagate.py。
- 積分器自檢那一行的「比能量相對漂移」是多少?這個數字代表什麼?
如果它是 1e-3,你會怎麼判斷? - 數值目標器解出的每一圈 Δv,和解析差多少?
- 在
accel()裡把第三體的間接項- rm / rmn ** 3註解掉再跑一次。
相位迴圈的 Δv 變了嗎?為什麼?(提示:那一項是在修正什麼?)
記得改回來。
5. Part D — 目標器與敏感度(佔 25%,本章第二重點)
看圖 (c),然後動手:
- TLI 目標器解出來的 Δv,和純兩體的 3.1313 km/s 差幾 m/s?
- 從圖 (c) 讀出:TLI 的 Δv 差 1 m/s,最接近月球的距離大約差多少 km?
- 圖 (c) 的曲線和目標線交在兩個地方。兩個都是「100 km 近月點」,
差別在哪裡?(提示:想像探測船從月球的哪一側經過) - 把
target_lunar_periapsis()的span_kms改成 0.03 再跑。
解出來的 Δv 一樣嗎?如果不一樣,哪一個是對的? - 承上:這件事對任務設計的意涵是什麼?
為什麼相位迴圈的「每圈一次重試機會」有價值?
6. Part E — STK/Astrogator(佔 15%,需要 STK)
跑 moon_transfer_build.py。
- Connect 送出多少條?幾條 NACK?
- Astrogator 的差分修正器解出來的總 Δv,和解析差多少?
- 打開 STK,在 MCS 裡找到
Loop1的 Target Sequence。
控制變數是什麼?約束是什麼? 和腳本裡寫的對得上嗎? - 把
Loop1的MaxStep從腳本裡拿掉再跑一次。修正器還收斂嗎?
收斂到哪裡?(這是 Week 10 記錄過的坑) - 用一句話說明: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%)
- A. 換發射日:改
moon_phase_for_rendezvous()給的相位 ±5°,
再跑目標器。還解得出來嗎?解不出來代表什麼? - B. 真實星曆:這台機器有
skyfield。把圓軌道的月球換成 JPL 星曆,
近月點差多少? - C. 自由返回:加一個約束 —— 萬一 LOI 沒點著,軌道要能把探測船帶回
地球大氣層。這個約束讓 TLI 的 Δv 增加多少?
9. 繳交
- 四張圖(
moon_transfer.png) - 驗證段的完整輸出
- Part A–E 的書面回答,含 Part B 動手前寫下的猜測
- 一頁總結:如果你是任務規劃者,你會選直接 TLI 還是相位迴圈?
用這一章算出來的數字支持你的選擇,不要用「比較安全」這種說法。
MoonTransfer_Primer.md
基礎知識導論 — 地月轉移與繞月進入
去月球貴在哪裡?為什麼要繞好幾圈才走?
這一章的答案全部是算出來的,三條互相獨立的路都給同一個數字。
1. 這一章在解什麼問題
把一艘探測船從低地球軌道送進繞月軌道,要回答三個問題:
- 離開地球要多少 Δv?(TLI,Trans-Lunar Injection,地月轉移入射)
- 一次燒完,還是用相位迴圈(phasing loops)分好幾次燒?
- 到了月球要多少 Δ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.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 那一側
- 建場景是
New / Scenario <名>,不加*/;建物件才是
New / */Satellite <名>(而且不是New / */Satellite/<名>)。
兩個任一寫錯,整批指令(本章是 90 條)會全部 NACK。 - 差分修正器的
FinalValue單位是 m/s,與場景的SetUnits / km無關。
直接拿來比的話,四圈的差會剛好都是 1000 倍 —— 那是單位,不是誤差。 SetProp會先建一組預設 MCS,不先刪掉,後面的改名會改到預設段。- 插了 Target_Sequence 之後插入點會跑進它裡面,所以一律用完整路徑。
- 控制變數少了
MaxStep、約束少了Tolerance,修正器會收斂到荒謬的
遠支解,而且一樣 ACK。
數值那一側
- 積分時間一定要設上限。
brentq的搜尋上界逼近脫離速度時
半長軸 → 無限大,1.2 * period(a)會讓單次呼叫去積好幾年的模擬時間。
四圈的目標器因此跑了 252 秒,而單次呼叫其實只要 0.003 秒;
加上限之後整支從 6 分 07 秒變成 8.2 秒,結果完全相同。 - 搜尋上界不能寫死。寫 3.5 km/s 會讓
period()對雙曲線軌道開根號。
改成由物理定出:再燒下去就脫離了。 - 第三體的間接項不能省。少了
-mu_M * r_M / |r_M|^3,
地心這個參考系被月球拉動的那一份就沒算進去。
工具那一側
- 失敗注入可能假通過。 Python 用「秒」的精度比對
.pyc記的來源
時間戳;注入如果在同一秒內改完又還原,原始碼看起來乾淨、md5 也對,
但載進來的還是注入版。本章實測:原始碼22:14:59.111、
.pyc22:14:59.102,驗證段因此一直用著被改壞的月球重力參數在跑。
tools/stk_verify.load_lab已改成直接讀原始碼 compile,不碰位元碼。
6. 延伸閱讀
這一章沒做、但做得到的
- 真實星曆:本章的月球走圓軌道且在赤道面上。真實月球軌道
e = 0.0549、對黃道傾斜 5.145°,黃道又對赤道傾斜 23.4°。
要真正的到達幾何就得換 JPL 星曆(skyfield/jplephem)。 - 自由返回軌道(free-return):萬一 LOI 沒點著,軌道本身能把
探測船帶回地球。那是載人任務的硬需求,是一個額外的約束。 - 弱穩定邊界轉移(WSB / ballistic capture):繞到日地 L1/L2 附近再回來,
LOI 可以省下約 20%,代價是 3-4 個月的飛行時間。又一次時間換 Δv。 - 有限推力:本章的點火是脈衝的。真實的長時間點火有重力損失,
相位迴圈燒的次數多,這一項會比直接 TLI 大。
相關週次
- Week 10 推進:Astrogator MCS 與差分修正器的入門
- Week 15 發射與部署:怎麼上到這裡假設的 200 km 停泊軌道
- 獨立章節 3 運載火箭入軌與交會迴避:同樣用 Astrogator
圖

moon_transfer/moon_transfer.png
程式碼
# 由 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"
# -*- 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)
# -*- 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)
# -*- 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)
# -*- 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)
# -*- 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)