獨立章節 2 — 地面望遠鏡追蹤探空火箭尾焰(EOIR)
eoir_earlywarning/EOIR_Lab_Handout.md
實驗單 — 地面望遠鏡追蹤探空火箭尾焰
獨立章節,不屬於 16 週立方衛星課程。
先讀 EOIR_Primer.md,再做本實驗。
工具:STK EOIR + Missile + LaunchVehicle + Facility + Python
主題:把上一章(天基預警看飛彈)的場景上下顛倒,看物理結論會怎麼翻轉。
0. 情境
2024 年 10 月 7 日當地時間 22:00(呼應探空十號的發射日),
一枚兩節式固態探空火箭從旭海科研火箭發射場升空,朝東南方的太平洋飛行。
台東與屏東的兩個地面觀測點同時用光學/紅外望遠鏡追蹤它。
| 物件 | 設定 |
|---|---|
XuhaiPad |
旭海科研火箭發射場,22.174839 N, 120.892569 E,海拔 10 m |
SR10 |
Missile 物件,ApogeeAlt 286.0 km,發射方位 125°,落點 21.174 N 122.415 E |
| 尾焰 | 2000 K 灰體(ε = 0.99),0–43 s 燃燒,相對寬 6 / 相對長 4 → 約 30.8 m × 2.5 m |
| 箭體 | 圓柱 7.7 m × 0.42 m,WhiteThermalControl,400 K |
SR10_Boost |
LaunchVehicle / SimpleAscent,燒盡點 t=43 s、48.12 km、2.112 km/s(只當幾何對照,不掛 EOIR) |
NantianOP |
南田觀測點(台東縣達仁鄉),22.26199 N, 120.89034 E,海拔 45 m,基線 9.7 km |
DahanOP |
大漢山觀測點(屏東/台東界山),22.40776 N, 120.75124 E,海拔 1688 m,基線 29.7 km |
| 望遠鏡 | 口徑 0.40 m、焦距 1.60 m(F/4),兩站相同 |
| 相機 | MWIR 3.7–4.1 / CO₂ 4.2–4.4 / LWIR 8–12 / VIS 0.45–0.75 µm(南田四台,大漢山只有 MWIR) |
| 約束 | 仰角 ≥ 5°、EOIR SNR ≥ 6 |
| 大氣 | Simple 模式、maritime 氣溶膠、能見度 23 km、濕度 80%、無雲 |
設計原則:兩站的望遠鏡完全一樣,四台相機的口徑焦距也完全一樣。
唯一的變數是站位與波段 —— 這樣「站在哪裡」與「選哪個波段」
這兩個工程問題才有乾淨的答案。
1. 執行步驟
1. 開啟 STK 12 桌面版(合成影像需要 3D 視窗,無介面的 Engine 不支援)
2. 查出它的 PID: Get-Process AgUiApplication | Select Id, MainWindowTitle
3. python eoir_build.py --pid <PID> 建場景 + EOIR 波段/光學(物件模型)
4. python eoir_detect.py --pid <PID> Part A-G + 圖
5. python eoir_animate.py --pid <PID> 四波段並排動畫
6. python eoir_verify.py --pid <PID> 驗證段
⚠ --pid 不是選填。
機器上同時開著多個 STK 時,不帶 pid 的 AttachToApplication() 從 Running Object
Table 拿到哪一個是不確定的 —— 抓錯之後每一條指令都會安靜地送到別人的場景上,
全程 ACK。attach() 因此會:實例數 > 1 而沒給 pid → 中止;pid 不存在 → 中止;
場景名不是本章的 → 中止(要覆寫得加 --force)。
⚠ 長時間的 STK 工作一律背景執行,而且不要砍 mid-COM 的 Python。
強制中止一個正在 COM 呼叫中途的用戶端,會讓那個 STK 實例進入長時間無回應
(Responding=False、CPU 掉到 0.01 s/s、AttachToApplication() 掛住),
量級是數十分鐘。它會自己恢復,不是死掉。 寧可等,不要砍;
真的等不了就換一個實例。詳見 Primer §5.3。
⚠ attach() 預設「重用同名場景」而不關場景。
本章實測:對一個已經建好 EOIR 感測器的場景呼叫 CloseScenario(),
STK 12.10.0 的行程會安靜地結束,Python 只看到 HRESULT = 0x800706be。
所以重建走的是「把子物件卸掉、場景留著」,CloseScenario() 只在最後手段時才用。
理由見 Primer §5.2 / §5.3 —— 這不是防呆,是防事故。
EOIR 很慢:輻射鏈每個取樣點要算數秒。五具感測器、10 秒間隔、0–320 秒,
eoir_detect.py 約 8–12 分鐘,eoir_animate.py 約 5–8 分鐘。
2. Part A — 幾何與可見性(佔 15%)
你會看到什麼
- 彈道交叉驗證:自算的兩體解 vs STK 的
ApogeeAlt解。
| 自算兩體解 | STK 實測 | 差 | |
|---|---|---|---|
| 最高點 | 286.00 km | 286.000 km | 0 |
| 最高點時刻 | t ≈ 251 s | t = 252 s | +1 s |
| 飛行時間 | 501.9 s | 503.7 s | +0.36% |
| t=43 s 高度 | 90.51 km | 90.23 km | −0.3% |
| - 助推段的模型界線: |
| t (s) | STK 解析彈道 | 動力上升模型 | 高估倍數 |
|---|---|---|---|
| 5 | 11.44 km | 1.44 km | 7.9 |
| 43 | 90.51 km | 48.12 km | 1.9 |
- 視線幾何:
| 南田 | 大漢山 | |
|---|---|---|
| 基線 | 9.7 km | 29.7 km |
| 最大仰角 | 約 76° | 約 70° |
| 角速度(動力模型) | 3.77 °/s @ t≈6 s | 1.60 °/s @ t≈24 s |
| 角速度(STK 彈道) | 13.6 °/s @ t=0 | 4.5 °/s @ t=0 |
| 方位角掃過 | 48° | 22° |
要回答的問題
- 為什麼 STK 算出來的角速度是動力模型的三倍以上,而且尖峰在 t=0?
哪一個才是真的轉台需求?哪一個可以拿來當安全裕度的上界?說明理由。 - 飛行時間 501.9 s 對「射程」幾乎不敏感(自算掃描:射程 38–400 km 只對應
501.3–504.0 s)。為什麼?這件事為什麼讓「不模擬地球自轉的自算模型」
仍然可以用來驗證「有自轉的 STK」? - 實測的探空十號飛了 520 s,純彈道解只有 501.9 s。
這 18 秒的差從哪裡來?自己算一次驗證你的說法。 - 大漢山在 1688 m,看 29.7 km 外的發射場俯角 −3.2°,半途稜線的俯角約 −2.1°
—— 稜線比發射台高,發射台被擋住。但 Part A 的結論是「地形不是限制因素」。
為什麼?兩個約束(地形遮蔽、仰角下限 5°)同時存在時,你要怎麼判斷哪一個在綁?
實測:南田首次可見 t = 4.0 s、大漢山 t = 9.5 s(動力上升模型)。
這 5.5 秒的差是誰造成的? - 如果要用 STK 自己算地形遮蔽而不是用幾何估計,需要什麼?
沒有那個東西時 STK 會怎麼做?(提示:它不會報錯)
3. Part B — 輻射獨立驗證(佔 15%)
不要相信任何一個工具給的數字,除非你能用另一條路算出同樣的答案。
作法:拿 STK 自己給的 Target Solid Angle,乘上自己用 Planck 積分算的
頻帶輻射亮度,再跟 STK 的 In-band target irradiance 比:
E_predict = L_band(2000 K, band) x 0.99 x Omega_target
實測比值(大氣 Mode 1 開著):
| 感測器 | STK 輻照度 (W/m²) | Planck 預測 (W/m²) | 比值 |
|---|---|---|---|
| Nantian-MWIR | 3.083e−05 | 4.645e−05 | 0.664 |
| Nantian-LWIR | 1.675e−05 | 2.353e−05 | 0.712 |
| Nantian-VIS | 1.241e−05 | 1.719e−05 | 0.722 |
| Dahan-MWIR | 5.785e−05 | 7.120e−05 | 0.812 |
| Nantian-CO₂ | 4.425e−11 | 1.749e−05 | 0.0000 |
自算的頻帶輻射亮度(W/m²/sr):
| 波段 | 2000 K | 400 K | 280 K |
|---|---|---|---|
| VIS 0.45–0.75 | 3686 | 2.9e−12 | 1.0e−23 |
| MWIR 3.7–4.1 | 9956 | 14.5 | 0.102 |
| CO₂ 4.2–4.4 | 3747 | 9.56 | 0.105 |
| LWIR 8–12 | 5043 | 205 | 27.2 |
要回答的問題
- 開著大氣時比值一定小於 1,關掉大氣(
SetAtmosphere Mode 0)才會接近 1。
為什麼?先預測關掉大氣後的比值,再跑腳本驗證。 - 如果比值是 0.1 或 10,你會先懷疑什麼?
本章的單位陷阱至少有四個(dB vs 線性、km vs m、弧度 vs 度、還有一個),
把它們全部列出來。 - 把尾焰溫度從 2000 K 改成 1200 K,MWIR 的
L_band變成幾分之一?
先算再跑。(提示:在 3.9 µm、hc/λk = 3691 K,
用1/(exp(3691/T)−1)估,再乘上頻寬。) - 為什麼用「立體角」而不是「面積 / 距離²」做這個驗證比較好?
4. Part C — 大氣:把 STK 的穿透率挖出來(佔 20%,本章重點之一)
作法:同一個場景跑兩次,SetAtmosphere Mode 1 與 Mode 0,
兩次的在頻帶輻照度相除 —— 那就是 STK 用的斜路徑穿透率。
不要去猜 STK 內部用什麼模型,直接量它。
實測:STK 反算的穿透率 vs 自算 Beer–Lambert
(助推段中段;南田仰角 69.9°、斜距 47.0 km,大漢山 48.4°、56.6 km)
| 波段 | STK | 自算 | 比值 |
|---|---|---|---|
| Nantian MWIR | 0.6687 | 0.7701 | 0.87 |
| Nantian LWIR | 0.7166 | 0.7790 | 0.92 |
| Nantian VIS | 0.7121 | 0.7844 | 0.91 |
| Dahan MWIR | 0.8562 | 0.8438 | 1.01 |
| Nantian CO₂ | 2.574e−6 | (校準過,見下) | — |
三個大氣窗波段的參數是先驗給的,對到 8–13% 以內。
CO₂ 那一格的 k₀ 是回頭用 STK 校準的(原本憑直覺給 20,τ 高估了 12 倍),
所以它不算獨立驗證 —— 課本裡把兩者分開標,是因為證據力不同。
自算的 Beer–Lambert 基準(觀測點 45 m,目標 100 km):
| 波段 | 天頂 | el=30° | el=10° | el=5° |
|---|---|---|---|---|
| MWIR | 0.782 | 0.613 | 0.254 | 0.080 |
| CO₂ 4.2–4.4 | ~0 | ~0 | ~0 | ~0 |
| LWIR | 0.791 | 0.626 | 0.270 | 0.089 |
| VIS | 0.796 | 0.634 | 0.280 | 0.095 |
要回答的問題
- 這一題是本章的核心。
天基預警把 4.3 µm 選成黃金波段,理由是「大氣把地表背景擋掉」。
地面望遠鏡用同一個波段卻什麼都看不到。
畫一張圖說明「大氣在光路的哪一段」如何完全翻轉結論。
然後回答:有沒有哪一種地面觀測情境,4.3 µm 反而是對的? - STK 的 Simple 大氣模式固定用 US Standard 1976,而且不算水平變化
(說明檔原文:"It does not calculate the horizontal variations — which
constitute weather — nor clouds")。
那SetAtmosphere Humidity 80到底改了什麼? 跑跑看,把它從 20 改到 95,
MWIR 的穿透率變了多少?如果完全沒變,那代表什麼?
(這一題沒有標準答案,答案取決於你量到什麼。把量到的寫下來。) - 把
SetAtmosphere Mode改成 2(MODTRAN) 再跑一次。
CO₂ 波段的結果變了嗎?MWIR 呢?哪一個模式比較接近我們自寫的 Beer–Lambert? - 仰角從 90° 掉到 5°,MWIR 穿透率掉了近十倍。
那為什麼腳本裡的仰角下限設 5° 而不是 10° 或 20°?
把「多看到多少秒」與「訊號少多少」兩件事一起考慮,提出你的建議值並說明。 - 大漢山比南田高 1643 m,MWIR 天頂穿透率 0.88 vs 0.78。
在 el = 10° 的時候,這個差距是變大還是變小? 先預測,再用腳本算。
5. Part D — 波段選擇(佔 20%,本章重點之二)
實測:
| 感測器 | SNR 最大 | SNR 中位 | 在頻帶輻照度 (W/m²) | 偵測拍數 |
|---|---|---|---|---|
| Nantian-MWIR | 7.434e+05 | 4.430e+04 | 3.083e−05 | 9 |
| Nantian-CO₂ | 3.753e+04 | 3.753e+04 | 4.425e−11 | 1 |
| Nantian-LWIR | 2.951e+06 | 1.090e+01 | 1.675e−05 | 20 |
| Nantian-VIS | 8.361e+04 | 1.598e+04 | 1.241e−05 | 5 |
| Dahan-MWIR | 2.532e+05 | 7.653e+04 | 5.785e−05 | 9 |
CO₂ 的在頻帶輻照度比 MWIR 窗小六個數量級。 這就是本章的主要結論。
要回答的問題
- 為什麼 LWIR 對 2000 K 的尾焰是最差的紅外選擇,
對 400 K 的燒盡箭體卻是最好的? 用上面兩張表的數字回答,不要用直覺。 - VIS 在紙上的對比度是天文數字(10²⁶ 量級)。列出至少三個讓這個數字失效的理由。
(提示:其中一個跟 STK 的尾焰模型有關,不是跟大氣有關。) - 把場景時間從 14:00 UTC(當地 22:00)改成 04:00 UTC(當地 12:00 正午),
重跑 Part D。四個波段各自變了多少? 哪一個變化最大?為什麼? - 如果只能選一個波段裝在望遠鏡上,你選哪一個?
分成三種任務目標分別回答:
(a) 只要知道「有沒有點火」;
(b) 要量測尾焰長度以推估推力;
(c) 要一路追到最高點。 - STK 的尾焰是 99% 發射率的灰體,而且不可覆寫。
真實尾焰的光譜有 CO₂ 4.3 µm 帶與 H₂O 2.7 µm 帶的強烈結構。
這個簡化會讓哪一個波段的結果最不可信? 為什麼?
6. Part E — 望遠鏡設計(佔 15%)
| 波段 | IFOV | 視場 | 繞射 1.22λ/D | 每個繞射斑 |
|---|---|---|---|---|
| MWIR | 9.38 µrad (1.93″) | 0.344°×0.275° | 11.9 µrad | 1.3 px |
| LWIR | 10.6 µrad | 0.390°×0.292° | 30.5 µrad | 2.9 px |
| VIS | 3.44 µrad | 0.403° | 1.83 µrad | 0.5 px |
MWIR 焦平面上的目標大小:
| 斜距 | 尾焰長 | 尾焰寬 | 箭體長 |
|---|---|---|---|
| 10 km | 328 px | 27 px | 82 px |
| 50 km | 66 px | 5 px | 16 px |
| 300 km | 11 px | 0.9 px | 2.7 px |
對照:上一章的天基場景,GEO 感測器看到的目標只有 6.7 px。
實測:繞射解析度四個波段全部吻合到小數第二位
| 波段 | 自算 1.22λ/D | STK Rayleigh resolution |
|---|---|---|
| MWIR | 11.89 µrad | 11.90 µrad |
| CO₂ | 13.12 µrad | 13.12 µrad |
| LWIR | 30.50 µrad | 30.50 µrad |
| VIS | 1.83 µrad | 1.83 µrad |
追蹤速率實測
| 觀測點 | STK 彈道 (deg/s) | 動力模型 (deg/s) | 拖曳 px (2 ms) | 過 FOV 時間 |
|---|---|---|---|---|
| 南田 | 13.25(t=0 假象) | 3.80 | 14.0 | 0.091 s |
| 大漢山 | 4.44(t=0 假象) | 1.60 | 5.9 | 0.215 s |
要回答的問題
- 地面望遠鏡看到的是延伸目標,天基看到的是點源。這個差別為什麼重要?
從「偵測要用哪個量」與「能從影像裡讀出什麼資訊」兩方面回答。 - 把口徑從 0.40 m 加到 0.80 m,延伸目標的 SNR 變成幾倍?
點源呢?先預測,再改eoir_build.py的APERTURE重跑驗證。
兩者為什麼不同?(這一題答錯的人非常多。) - 南田的最大角速度 3.77 °/s、視場 0.344°。
目標穿越整個視場要多久?(答案是 0.091 s。)
這對「開迴路指向」與「閉迴路追蹤」的取捨代表什麼? - MWIR 積分時間 2 ms,無追蹤時的拖曳是 14 個像素。
要把拖曳壓到 1 個像素以內,積分時間要多短?
那樣做的代價是什麼?有沒有別的辦法? - STK 完全沒有替我們檢查轉台追不追得上。
說明檔查證:SetAttitude ... Target Slew RateMagnitude只適用
Satellite / LaunchVehicle / Missile,Facility 沒有 Attitude 物件。
如果你一定要在 STK 裡表達「轉台最快 2 °/s」這個限制,你會怎麼做?
提出一個做法,並說明它的破綻。 - LWIR 一個繞射斑跨 2.9 個像素(欠取樣)。
這是設計錯誤嗎? 如果不是,那要改什麼才能修正?代價是什麼?
7. Part F — 飛行階段與偵測窗(佔 10%)
尾焰在 43 s 熄火。之後只剩 400 K 的箭體,而且距離還在拉遠。
自算的落差(MWIR 3.7–4.1 µm):
輻射亮度比 L(2000K)/L(400K) = 9956 / 14.5 = 686 倍
投影面積比 (30.8 x 2.5) / (7.7 x 0.42) = 23.8 倍
合計 約 1.6e4 倍
實測:
| 感測器 | 拍數(只看 SNR) | 拍數(+仰角) | 窗(只看 SNR) | 窗(+仰角) | 首次偵測 |
|---|---|---|---|---|---|
| Nantian-MWIR | 9 | 8 | 90 s | 80 s | 10 s |
| Nantian-LWIR | 20 | 19 | 200 s | 190 s | 10 s |
| Nantian-CO₂ | 1 | 0 | 10 s | 0 s | — |
| Nantian-VIS | 5 | 4 | 50 s | 40 s | 10 s |
| Dahan-MWIR | 9 | 8 | 90 s | 80 s | 10 s |
每一個波段都剛好少一拍,而且少的都是同一筆:t = 0 s、仰角 −0.01°。
CO₂ 原本那唯一一次「偵測」正是那一筆 —— 修正後歸零。
最後一欄才是對的。 前一欄只看 SNR,把 t = 0 s(仰角 −0.3°、火箭還在發射台上)
也算成偵測。CO₂ 原本那唯一一次「偵測」正是那一筆 —— 修正後歸零。
LWIR 的偵測窗是 MWIR 的兩倍多。 熄火之後 MWIR 掉到 SNR 0.4(看不見),
LWIR 還有 4.7 —— 因為剩下的是 400 K 的箭體。同一副望遠鏡、同一個目標,
兩個波段的優劣在 43 秒那一刻對調。
要回答的問題
- 自算的落差是 4.57e+04 倍,MWIR 實測是 2.7e+05 倍 —— 差了約 6 倍。
差在哪裡? 列出兩個候選並說明怎麼分辨。 - 本章有一個已知的簡化:真實火箭在 6–12 s 之間是節間滑行、沒有尾焰,
但 STK 的單一Stage Plume表達不了這個空檔,所以設成 0–43 s 連續燃燒。
這個簡化讓 6–12 s 的訊號高估了多少? 估一個量級。
要正確表達要怎麼做?(提示:說明檔明講「一個 Missile 物件 = 一節」。) - 把
BodyTemperature從 400 K 改成 600 K 重跑。
熄火後的偵測窗變長多少? 600 K 這個值合理嗎?從氣動加熱的角度論證。 - 把「什麼時候看得到」寫成一張完整的排除表,
把仰角、地形、雲、日照、熄火五個因素都放進去,標出各自的時間區間。
最後回答:真正能用的觀測窗有多長?
8. 驗證段(佔 5%)
eoir_verify.py。目標是 30 項以上全 PASS。
本章的靜默失敗有四個層次,而且多了一個新型態:
| 層次 | 症狀 | 怎麼抓 |
|---|---|---|
| 1 | 指令被拒絕 | NACK,最好抓 |
| 2 | ACK 但值沒進去 | 回讀比對 |
| 3 | 值進去了,意義卻不同 | 回讀 + 換算 + 交叉驗證 |
| 4 | 設定全對,輸出還是空的 | 檢查 DataSets.Count |
第 3 層在本章有兩個惡劣的例子:
ProcessingLevel = 3:你以為是 Sensor Output,其實是 Sensor Off。
程式碼看起來完全正確,回讀值也完全正確,感測器被關掉。HorizontalHalfAngle = 0.17:你以為是 0.17°,STK 當 0.17 弧度 收,
視場變成 9.7°。回讀還是 0.17。
要回答的問題
-
打開
eoir_ascent.gif,看 t = 0 s 那一格。
四個波段的 SNR 分別是 7.4e5 / 3.8e4 / 3.0e6 / 8.4e4,全部遠超過門檻 6,
但標籤是BELOW HORIZON (el −0.3 deg)而不是DETECTED。
為什麼 SNR 這麼高?為什麼還是看不到?
然後打開eoir_detect.py的detected(),說明它為什麼要吃三個參數而不是一個。
最後回答:STK 的 access 已經把仰角與 EOIRSNR 求交集了,
為什麼資料提供者這一側還要再做一次? -
本章有三個「首次可見時間」:STK 彈道 1.0 s、動力上升模型 4.0 s、
動畫第一格DETECTED8.0 s。 三個都對,但回答的不是同一個問題。
(a) 各自在回答什麼?
(b) 為什麼不能用動畫去確認 4.0 s?
(c) 如果把動畫改成每 1 秒一格,它就能驗證 4.0 s 了嗎?先想清楚再回答。
(提示:動畫的仰角是從哪一條彈道算出來的?) -
「資料提供者回了 0 個 DataSet」跟「回了一堆 0」有什麼不同?哪一個比較危險?
- 自己加一個檢查項:確認尾焰真的在 43 秒熄火
(提示:比較 t=40 與 t=60 的Number of Target Pixels)。 eoir_verify.py裡的BASE有兩類數字:「設定回讀」用 1e-9 的容差,
「量測量」用寬鬆的物理上下界。為什麼要分開?
如果全部都用實測值寫死,幾個月後會發生什麼事?-
驗證段裡有一項的 PASS 條件是「CO₂ 波段的訊號比 MWIR 小兩個數量級以上」。
如果哪天 STK 改了大氣模型讓這一項 FAIL,你會怎麼判斷是 STK 對還是我們對? -
這一章發現的第二種靜默失敗是「回讀是對的,錯的是單位」。
把EntrancePDia設成0.60、HorizontalHalfAngle設成math.radians(0.5),
兩個值回讀一字不差,所有回讀檢查都 PASS。你實際上造出了一台什麼望遠鏡?
然後:回讀比對抓不到這種錯,要靠什麼?
打開eoir_verify.py,找出那三項用「錨」而不是用「回讀」在驗單位的檢查,
說明每一項的錨是什麼。(Primer §5.2) -
SetConstraint <sensor> EOIRSNR Min 6.0對一個還不是 EOIR 的感測器也照樣 ACK。
本章的 Connect 腳本正好就是在感測器還不是 EOIR 的時候下這幾行的。
驗證段有一項具名檢查在抓它。跑一次,看它是 PASS 還是 FAIL,
然後根據結果決定這幾行該放在 Connect 腳本裡還是搬到eoir_build.py。
這一題沒有標準答案 —— 答案是你量到什麼。
9. 評分
| 項目 | 比重 |
|---|---|
| Part A 幾何與可見性 | 15% |
| Part B Planck 獨立驗證 | 15% |
| Part C 大氣與穿透率反算 | 20% |
| Part D 波段選擇 | 20% |
| Part E 望遠鏡設計與追蹤需求 | 15% |
| Part F 飛行階段與偵測窗 | 10% |
| 驗證段 | 5% |
加分題(三選一)
- A. 把火箭改成真正的兩節。 依說明檔的做法建兩個 Missile 物件
(SR10_S10–6 s、SR10_S212–43 s),各自有自己的形狀與尾焰,
用起訖時間串接。重跑 Part F,說明節間滑行的空檔在 SNR 曲線上長什麼樣。 - B. 把天氣放進來。 用
EOIR */ CloudData加一層雲
(高度、覆蓋率、溫度、發射率都可以設),掃描雲高與覆蓋率,
畫出「可觀測機率 vs 雲況」。再去查中央氣象署大武站 10 月的雲量統計,
估一次「這個發射窗實際能觀測到的機率」。 - C. 把發射場搬到台東。 本章的發射場在屏東旭海,觀測點在台東南田
(取捨的理由見 Primer §2.1)。把發射場改成南田太空港(22.26199 N, 120.89034 E),
觀測點往北移到大武(22.3567 N, 120.8964 E, 30 m)或多良(22.4331 N, 120.9494 E, 80 m),
重跑 Part A + Part E。基線、最大仰角、角速度各變了多少?
結論有沒有跟著變? 如果沒有,那說明了什麼? - D. 第三個觀測點。 自己在台東/屏東找一個真實的地點(要有座標與海拔來源),
加進場景,用 Part A + Part C + Part E 的指標論證它比南田或大漢山好在哪、差在哪。
不准說「都很好」 —— 要指出它輸在哪一項。
10. 檔案
| 檔案 | 內容 |
|---|---|
EOIR_Primer.md |
基礎知識導論 |
EOIR_Lab_Handout.md |
本檔 |
eoir_sounding_Connect.txt |
Connect 腳本(場景、發射場、觀測點、彈道、尾焰、望遠鏡、大氣) |
eoir_build.py |
建場景 + EOIR 波段/光學(物件模型)+ 場景身分 guard |
eoir_detect.py |
Part A–G,含所有不靠 STK 的物理(Planck、Beer–Lambert、彈道、視線幾何) |
eoir_animate.py |
四波段並排動畫 |
eoir_verify.py |
驗證段 |
eoir_detect.png |
六宮格:高度、仰角、角速度、SNR、Planck 曲線、大氣穿透率 |
eoir_detect_stats.json / eoir_scenes_stats.json / eoir_ascent_stats.json |
每格/每項統計的 sidecar 指紋,供驗證段比對產出內容 |
eoir_verify_stats.json |
驗證段自己吐出的 PASS/FAIL/SKIP 與非 PASS 項目清單;展示頁與 README 的數字從這裡取,不手打 |
eoir_scenes.png |
合成影像對照(四波段 × 四個時刻) |
eoir_ascent.gif |
上升段動畫 |
關於產出的時間戳:
eoir_ascent.gif產出於 20:08,其餘圖與 sidecar 於 21:3x。
內容一致性由eoir_verify.py第 10 組的指紋比對保證,不依賴時間戳。為什麼要特別講這件事:本輪曾出現一批時間戳完全一致的產出,
而其中eoir_detect.png的 panel F 註解仍然是過期的。
時間戳一致不代表內容一致 —— 它只證明「同時寫入」,不證明「同一份邏輯」。
真正能證明的是:用當下的程式碼重算一次,跟產出當時留下的 sidecar 逐項比對。
第 10 組做的就是這件事,而它對 20:08 那份 GIF sidecar 的四項檢查全部通過。這一段本身就是教材:用什麼證據,支持什麼結論。
時間戳能支持的結論很窄,不要讓它去承擔它撐不起的斷言。
EOIR_Primer.md
基礎知識導論 — 地面望遠鏡追蹤探空火箭尾焰
本章是獨立章節,不屬於 16 週立方衛星課程。
主題:用 STK 的 EOIR(電光/紅外線)能力,模擬台東/屏東的地面光學望遠鏡
觀測旭海發射場升空的探空火箭尾焰。
1. 這一章在解什麼問題
一枚固態火箭點火之後,尾焰是一團兩千度的高溫氣體。在紅外線波段,
它是天空中最亮的東西之一 —— 這件事對天基預警衛星成立,對地面望遠鏡也成立。
但「看得到」這件事,從天上往下看和從地上往上看,是兩個完全不同的問題。
本章就是把上一章(天基預警看洲際飛彈)的場景上下顛倒,看會發生什麼事。
要回答五個工程問題:
- 地面望遠鏡在大氣層內看尾焰,與天基感測器的根本差別是什麼?
大氣衰減、背景輻射、仰角、天氣 —— 哪一個才是主角? - 尾焰在不同飛行階段的紅外特徵怎麼變?
海平面 vs 高空、燃燒中 vs 燒盡後的箭體本體。 - 望遠鏡的視場、像素解析度、追蹤速率夠不夠? 角速度最大是多少?
- 什麼時候看得到、什麼時候看不到?
仰角下限、地形遮蔽、白天還是晚上。 - 波段怎麼選? MWIR(3–5 µm)vs LWIR(8–12 µm)vs 可見光。
先講結論,因為它很反直覺:
天基預警的黃金波段是 4.3 µm 的二氧化碳吸收帶。
從地面往上看,同一個波段是完全不透明的,一點用都沒有。
同一個物理(CO₂ 在 4.3 µm 吸收很強),兩個完全相反的工程結論。
差別只在於大氣站在你和目標之間,還是站在目標和背景之間。
這是本章唯一真正要傳達的觀念,其餘都是在把它量化。
2. 核心觀念
2.1 場景:為什麼是旭海、南田、大漢山
發射場:旭海科研火箭發射場
| 項目 | 值 | 來源 |
|---|---|---|
| 座標 | 22°10′29.42″N 120°53′33.25″E = 22.174839, 120.892569 | 維基百科「旭海科研火箭發射場」 |
| 位置 | 屏東縣牡丹鄉旭海村牡丹灣沙灘 | 同上 |
| 面積 | 0.9723 公頃 | 同上 |
| 啟用 | 2022 年 1 月 13 日,國家太空中心(TASA)營運 | 同上 |
| 正式名稱 | 國科會「短期科研探空火箭發射場域」 | TASA 官網 |
為什麼選這裡而不是南田太空港?
台灣東南部有兩個候選:
- 南田太空港(台東縣達仁鄉,22°15′43.16″N 120°53′25.22″E):
2019 年由民間的晉陞太空科技(TiSPACE)興建。唯一一次發射是 2020 年 2 月 13 日的
飛鼠一號,因天候與風切在點火後未能升空。土地用途有爭議,已遭裁罰。 - 旭海科研火箭發射場(屏東縣牡丹鄉):
台灣第一座合法的科研火箭發射場域,由國家太空中心營運,至 2025 年已執行
十次以上發射(HTTP-3A、淡江一型/二型、SHSR Aero-1/2 等)。
選旭海的理由很單純:它是真的在運作、而且數字查得到的那一個。
教材寧可用一個平凡但可查證的場址,也不要用一個聽起來厲害但只有一次失敗紀錄的。
另外,旭海緊鄰中科院九鵬基地(屏東縣滿州鄉/牡丹鄉界,約 4000 公頃,
東臨太平洋)—— 台灣的十枚探空火箭(1998–2014)全部從九鵬發射。
所以「從這一帶的海岸往東南方的太平洋打探空火箭」是有真實歷史的。
一個要明講的取捨:題目說的是「台東的火箭發射場」,而旭海在屏東。
台灣東南角這一帶,發射場與觀測點分屬兩個縣是常態 —— 旭海(屏東牡丹)、
九鵬(屏東滿州/牡丹)、南田(台東達仁)三個點彼此都在 10 km 上下,
縣界就從中間穿過去。本章的配置是發射場在屏東旭海、主觀測點在台東南田,
所以「望遠鏡在台東」成立,「台東的發射場」不成立。之所以這樣選,是因為在「真實可查證」與「行政區符合題目字面」之間,
本教材一律選前者:台東確實有一個發射場(南田太空港),但它只有一次
未能升空的紀錄、而且土地用途遭裁罰;旭海才是國家太空中心實際營運、
十次以上發射紀錄、座標與啟用日期都查得到的那一個。這個取捨對物理完全沒有影響:兩個場址相距 9.7 km、方位差不到 1 度,
把發射場換成南田只是把基線從 9.7 km 變成 0(觀測點與發射場重疊),
那反而會讓「基線」這個工程變數消失。真要把發射場放在台東,
正確的做法是把發射場設在南田、觀測點往北移到大武或多良 ——
幾何量會變,結論不會變。
觀測點 A:南田(近、低)
| 項目 | 值 |
|---|---|
| 座標 | 22.26199 N, 120.89034 E,海拔 45 m |
| 行政區 | 台東縣達仁鄉 |
| 到發射場基線 | 9.7 km,方位角 178.6°(發射場幾乎在正南方) |
理由:
- 這裡就是南田太空港的場址,原本就建有追蹤天線與氣象預測站 ——
把一副光學/紅外望遠鏡放在既有的追蹤場址上,是最不需要辯護的選擇。 - 台 26 線南端的海岸台地,東到南全部是太平洋。紅外背景是海面與夜空,
沒有城鎮熱源、沒有路燈。這對可見光波段尤其重要。 - 代價:基線只有 9.7 km,火箭幾乎從頭頂飛過(最大仰角約 76°),
角速度需求高。
觀測點 B:大漢山(遠、高)
| 項目 | 值 |
|---|---|
| 座標 | 22.40776 N, 120.75124 E,海拔 1688 m |
| 行政區 | 屏東縣春日鄉與台東縣達仁鄉的界山 |
| 到發射場基線 | 29.7 km,方位角 150.7° |
理由:
- 1688 m 已經在大氣邊界層之上。水氣的尺度高度只有 2–2.5 km,
所以爬到 1688 m 就把天頂路徑上大約一半的可凝結水氣留在腳下:
自算的 MWIR 天頂穿透率從南田的 0.78 升到 0.88。
這是所有高山天文台存在的理由,對紅外波段又特別明顯。 - 基線 29.7 km,最大仰角降到約 70°,最大角速度只有南田的四成。
- 代價:比南田晚 5.5 秒才收到目標。實測(動力上升模型、仰角門檻 5°):
南田在 t = 4.0 s 首次可見,大漢山要到 t = 9.5 s;門檻拉到 10° 則是
5.6 s 對 13.7 s。原因是「距離遠 + 觀測點自己站在 1688 m 上」——
火箭要爬得更高,才跨得過同一個仰角門檻。
注意這裡有一個很容易寫錯的因果。 從 1688 m 看 29.7 km 外海平面的發射場,
俯角是 −3.2°;半途(約 15 km)的稜線在 1000–1300 m,俯角約 −2.1° ——
稜線確實比發射台高,發射台確實被擋住。但這件事在本場景裡不是限制因素:
兩個俯角都是負的,而仰角下限是 +5°,仰角約束比地形嚴格得多,
把地形遮蔽整個蓋過去了。決定「什麼時候看得到」的是仰角,不是山。
這裡很容易寫下「大漢山被稜線擋住所以看不到點火」,而 Part A 的結果會
推翻它:首次可見時間是仰角門檻決定的。兩個約束同時存在時,
要先確認哪一個在綁,不要挑那個聽起來比較有畫面的當結論。
獨立章節 4(
tirem_coastal/)踩過一模一樣的坑,而且更深:
那一章把「地球曲率擋掉的樣本」當成地形效應,而且讓它通過了驗證段。
兩章的教訓是同一條:驗證段只能證明「數字是這樣」,不能證明「原因是那樣」。
因果要另外驗 —— 最便宜的作法是把疑似原因拿掉(這裡是把仰角門檻降到 0)
再跑一次,看結論變不變。
- 山頂是空軍雷達站管制區,實務上望遠鏡要架在大漢林道終點附近而不是三角點上。
這不影響本章的幾何。
兩個觀測點的存在本身就是本章的一個工程問題:近而低 vs 遠而高,
沒有標準答案 —— 取決於你要回答的是「有沒有點火」還是「飛得多準」。
火箭:以國家太空中心「探空火箭」系列為藍本
台灣探空火箭計畫在 1997–2014 年間共發射十枚,全部從九鵬基地。
通用諸元(維基百科「台灣探空火箭」):
射高 200 ~ 300 km
全長 7.7 m
質量 1680 kg
節數 兩節,固態推進
各次任務的可查證數字:
| 任務 | 日期 | 最高點 | 飛行時間 | 射程 |
|---|---|---|---|---|
| 探空三號 | 2003-12-24 | 約 265 km | 508 s | 約 180 km |
| 探空七號 | 2010-05-05 | 289 km | 約 450 s | 約 200 km |
| 探空九號 | 2014-03-26 | 286 km | 540 s | — |
| 探空十號 | 2014-10-07 | 286 km | 520 s | 量測範圍 90–286 km |
探空十號的時序(本章的基準):
t = 0 s 第一節點火
t = 6 s 第一節燒盡,分離
t = 12 s 第二節點火
t = 43 s 第二節燒盡 <- 尾焰在這裡熄火
t ~ 270 s 最高點 286 km
t = 520 s 落海
本章的場景時間刻意設在 2024-10-07 22:00(當地)= 14:00 UTC,
呼應探空十號的發射日,並且是夜間 —— 理由見 2.6 節。
2.2 Planck 定律:溫度決定一切
黑體的光譜輻射亮度:
L(λ, T) = 2hc² / λ⁵ / ( exp( hc / (λkT) ) − 1 ) [W/m²/sr/m]
Wien 位移:λ_peak · T = 2898 µm·K。本章三個溫度:
| 物體 | 溫度 | Wien 峰值 |
|---|---|---|
| 尾焰 | 2000 K | 1.45 µm |
| 燒盡後的箭體 | 400 K | 7.24 µm |
| 夜間天空背景 | 280 K | 10.35 µm |
尾焰溫度 2000 K 的依據:AP/HTPB 複合固態推進劑的燃燒室火焰溫度約
3000–3400 K,經噴嘴膨脹後在出口約 1800–2200 K,再加上與空氣的後燃燒。
對一枚小型探空火箭,2000 K 是合理的量級。STK 的 EOIR 只給你一個溫度旋鈕,
所以這個數字要選得有理由,而且要在實驗裡掃描它。
把 Planck 在各頻帶內積分(planck_band_radiance(),不靠 STK):
| 波段 | 2000 K 尾焰 | 400 K 箭體 | 280 K 天空 | 尾焰/天空 |
|---|---|---|---|---|
| VIS 0.45–0.75 µm | 3686 | 2.9e−12 | 1.0e−23 | 天文數字 |
| MWIR 3.7–4.1 µm | 9956 | 14.5 | 0.102 | 9.7e4 |
| CO₂ 4.2–4.4 µm | 3747 | 9.56 | 0.105 | 3.6e4 |
| LWIR 8–12 µm | 5043 | 205 | 27.2 | 186 |
單位都是 W/m²/sr。
注意接下來這一段的順序 —— 它比結論本身重要。
上面那張 Planck 積分表是在跑任何 STK 之前算出來的,而且當時就已經寫下推論:
「LWIR 對 2000 K 尾焰最差(背景太亮),但對 400 K 的燒盡箭體最好」。
那時候這只是紙上的一行字,沒有任何實測支持。
然後 STK 跑出來的偵測窗是:
| 感測器 | 燃燒中 SNR | 燒盡後 SNR | 落差 | 偵測窗 |
|---|---|---|---|---|
| Nantian MWIR | 1.06e5 | 0.387 | 2.7e5 倍 | 80 s |
| Nantian LWIR | 3.86e5 | 4.714 | 8.2e4 倍 | 190 s |
| Nantian CO₂ | 0.047 | 3.2e−9 | — | 0 s |
| Dahan MWIR | 1.58e5 | 0.459 | 3.4e5 倍 | 80 s |
判準只看 SNR 是不夠的。 沒有套仰角門檻的話,
t = 0 s(仰角 −0.3°,火箭還在發射台上)也會被算成偵測到。
套上門檻前後的差(取樣間隔 10 s):MWIR 90 → 80 s、LWIR 200 → 190 s、
VIS 50 → 40 s、Dahan-MWIR 90 → 80 s、而 CO₂ 10 → 0 s。
每一個波段都剛好少一拍,而且少的都是同一筆 —— t = 0 s、仰角 −0.01°。
CO₂ 原本那唯一一次「偵測」,正是那個在地平線下的取樣。結論沒有被推翻,反而更乾淨:LWIR 的窗仍然是 MWIR 的 2.4 倍(190/80),
而 CO₂ 從「幾乎看不到」變成完全是零。
LWIR 的偵測窗是 MWIR 的兩倍多。 尾焰熄火之後 MWIR 的 SNR 掉到 0.4(看不見了),
LWIR 還有 4.7 —— 因為剩下的是 400 K 的箭體,而 400 K 在 8–12 µm 有 133.7,
在 3.7–4.1 µm 只有 5.2。同一副望遠鏡、同一個目標,兩個波段的答案在 43 秒那一刻對調。
這個結果不是事後補的解釋,是先寫下的推論被實測打中。
兩者的差別很大:事後解釋幾乎總是能編得出來(任何數字都可以找到說法),
而先寫推論、後被證實是有風險的 —— 推論錯了就會當場被打臉。
本章刻意保留這個順序,是因為它是這份文件裡少數幾個「真的做了預測」的地方。
讀教材時值得養成的習慣:看到一個解釋,先問「這是預測還是事後補的?」
這張表已經回答了大半個「波段選擇」問題:
- LWIR 的問題不是穿不透,是背景太亮。 280 K 的天空在 8–12 µm 自己就在發光
(那正是它的 Wien 峰值),對比度只有 186 倍。MWIR 窗有近十萬倍。 - LWIR 唯一贏的地方是燒盡後的箭體:400 K 在 8–12 µm 有 205,
在 3.7–4.1 µm 只有 14.5。要追慣性段的冷箭體,LWIR 才是對的。 - VIS 的對比度在紙上是天文數字,但那有兩個前提:夜間,而且尾焰真的像灰體。
兩個前提都要打折,見 2.6 節。
2.3 飛行剖面:兩個模型,一個真相
本章有兩套彈道,而且刻意都保留下來,因為它們的差距本身就是教材。
(a) STK 的 Missile 物件:解析兩體彈道
Missile ... Trajectory 給發射點、落點,再從四個控制選項挑一個:
TOF / ApogeeAlt / DeltaV / DeltaVMinEcc。本章用 ApogeeAlt 286.0,
因為 286 km 是探空十號實際飛出來的可查證數字。
自己用兩體彈道解同一個問題(ballistic_arc()),得到:
最高點 286.00 km,發射路徑角 80°
-> 發射速度 2348.7 m/s、飛行時間 501.9 s、地面射程 193.1 km
這就是本章的第一道交叉驗證:我們把最高點當輸入,飛行時間就變成 STK 的輸出,
而我們可以獨立預測它。用 TOF 當輸入就沒有這個機會了。
實測結果(STK 12.10.0):
| 自算兩體解 | STK ApogeeAlt 解 |
差 | |
|---|---|---|---|
| 最高點 | 286.00 km | 286.000 km | 0 |
| 最高點時刻 | t ≈ 251 s | t = 252 s | +1 s |
| 飛行時間 | 501.9 s | 503.7 s | +1.8 s(0.36%) |
| t=5 s 高度 | 11.44 km | 11.41 km | −0.3% |
| t=43 s 高度 | 90.51 km | 90.23 km | −0.3% |
兩條完全獨立的路徑吻合到 0.4% 以內。
值得注意的是這個吻合為什麼可以在不模擬地球自轉的情況下成立:
STK 在自轉的地球上解彈道,500 秒內地表往東跑了 200 km 以上,
所以慣性系的射程跟地固系的 193 km 差很多。但自算的掃描顯示,
在最高點固定的前提下,飛行時間對射程幾乎不敏感
(射程 38–400 km 只對應 501.3–504.0 s)。所以這道驗證抓的是能量,不是幾何。
寫這段兩體解時最容易錯的地方:這種高橢圓彈道的近拱點在地球內部,
發射點的真近點角接近 +180°,整段彈道是「繞過遠拱點的那一小段短弧」。
符號取成負的話,高度會一路往下,而且程式不會報錯。
(b) 動力上升參考模型:STK 沒有的那一段
STK 的 Missile 物件沒有內彈道。 它在 t = 0 就有全部的 2349 m/s。
真實的火箭要燒 43 秒才到那個速度。所以 powered_ascent() 用探空十號的時序
建了一個獨立的參考剖面:
第一節 0–6 s 推力加速度 117 m/s² (~12 g)
滑行 6–12 s
第二節 12–43 s 推力加速度 57 m/s²
射角 80°(近乎垂直,探空火箭的典型)
結果:
| t (s) | STK 彈道高度 | 動力模型高度 | 高估倍數 |
|---|---|---|---|
| 5 | 11.44 km | 1.44 km | 7.9 |
| 10 | 22.64 km | 4.76 km | 4.8 |
| 20 | 44.32 km | 12.62 km | 3.5 |
| 30 | 65.02 km | 25.02 km | 2.6 |
| 43 | 90.51 km | 48.12 km | 1.9 |
燒盡狀態:t = 43 s、高度 48.1 km、速度 2112 m/s、射程 8.5 km。
接上兩體滑行之後最高點 280.7 km、總飛行 496 s —— 和探空十號的
286 km / 520 s 差 2% 與 5%,對一個只有三個旋鈕(a₁、a₂、射角)的模型來說,
這個吻合度已經足夠支撐所有量級判斷。動力段吃掉的 43 秒,也正好解釋了純彈道解的
501.9 s 與實測 520 s 之間的差距。
所以:
- 燒盡之後(t > 43 s)的幾何,兩個模型幾乎一樣,直接用 STK 的。
- 助推段的高度、速度、角速度,STK 全部高估。要用動力模型的數字。
這不是設定錯誤,是模型的界線。要真實的助推段剖面必須改用 Astrogator,
那已經是另一章的份量了。
2.4 大氣:從地面往上看,大氣站在敵方
天基感測器往下看時,大氣是幫手:4.3 µm 的 CO₂ 吸收帶把地表與低層大氣的
輻射擋掉,讓高空的尾焰浮在一片很暗的背景上(所謂 solar blind / red spike 波段)。
地面望遠鏡往上看時,同一層大氣整條擋在你和尾焰之間。
本章自己寫了一個一階 Beer–Lambert 指數大氣模型(atm_transmittance())。
它不是 MODTRAN,它是一個兩參數的量級模型,唯一的用途是提供一條與 STK
完全獨立的計算路徑:
τ = k₀ · H · ( e^(−h_obs/H) − e^(−h_tgt/H) ) · AM(el)
穿透率 = e^(−τ)
AM(el) 是空氣質量,低仰角用 Kasten–Young(1989):
AM = 1 / ( sin(el) + 0.50572 · (el + 6.07995)^(−1.6364) ) el 單位 deg
| 波段 | k₀ (1/km) | H (km) | 主導機制 |
|---|---|---|---|
| MWIR 3.7–4.1 | 0.10 | 2.5 | 水氣連續吸收 + 海洋型氣溶膠 |
| CO₂ 4.2–4.4 | 20 | 8.0 | CO₂ 基頻吸收;CO₂ 是均勻混合氣體,尺度高度是整層大氣 |
| LWIR 8–12 | 0.12 | 2.0 | 水氣連續吸收 |
| VIS 0.45–0.75 | 0.13 | 1.8 | Rayleigh 散射 + 氣溶膠 |
算出來的斜路徑穿透率(觀測點在 45 m,目標在 100 km):
| 波段 | 天頂 | el = 30° | el = 10° | el = 5° | el = 3° |
|---|---|---|---|---|---|
| MWIR | 0.782 | 0.613 | 0.254 | 0.080 | 0.031 |
| CO₂ | ~0 | ~0 | ~0 | ~0 | ~0 |
| LWIR | 0.791 | 0.626 | 0.270 | 0.089 | 0.036 |
| VIS | 0.796 | 0.634 | 0.280 | 0.095 | 0.039 |
與 STK 的實測對照(助推段中段,南田仰角 69.9°、斜距 47.0 km;大漢山 48.4°、56.6 km):
| 波段 | STK 反算的穿透率 | 自算 Beer–Lambert | 比值 |
|---|---|---|---|
| Nantian MWIR | 0.6687 | 0.7701 | 0.87 |
| Nantian LWIR | 0.7166 | 0.7790 | 0.92 |
| Nantian VIS | 0.7121 | 0.7844 | 0.91 |
| Dahan MWIR | 0.8562 | 0.8438 | 1.01 |
| Nantian CO₂ | 2.574e−6 | (見下) | — |
三個大氣窗波段的參數是先驗給的、沒有回頭調過,跟 STK 對到 8–13% 以內;
大漢山那一格甚至只差 1%。這才是真正的獨立驗證。
⚠ CO₂ 那一格不是。 憑「CO₂ 吸收很強」給
k₀ = 20會得到
τ ≈ 160、穿透率 e^(−160)。STK 說 τ ≈ 12.9、穿透率 2.574e−6。
結論一致(不透明),但光學厚度高估了 12 倍,換算成穿透率差 64 個數量級。現在的
k₀ = 1.53是用 STK 校準回來的,不是先驗值。
把校準過的參數拿去「驗證」校準它的來源,是循環論證 ——
保留它只是為了讓穿透率曲線在圖上有正確的量級,它不證明任何事。
表格裡把先驗與校準分開標,是因為兩者的證據力完全不同。
三個結論:
- CO₂ 帶從地面看是不透明的。 STK 實測的斜路徑穿透率是 2.6e−6,
在頻帶輻照度比 MWIR 窗小 六個數量級(4.4e−11 vs 3.1e−05 W/m²)。
天基預警最愛的波段,在這裡等於零。 - 仰角 5° 是一條硬線。 空氣質量是天頂的 10.3 倍,MWIR 穿透率從
0.78 掉到 0.08 —— 少一個數量級以上。這就是 Connect 腳本裡
SetConstraint ... ElevationAngle Min 5.0的物理來源,不是隨手設的。 - 海拔真的有用。 大漢山在 1688 m,MWIR 天頂穿透率 0.88 vs 南田的 0.78。
看起來只差 13%,但那是每一次觀測都省下的 13%,而且低仰角時差距更大。
至於 STK 那邊怎麼算,本章不去猜 —— 直接量它:
同一個場景跑兩次 SetAtmosphere Mode 1 與 Mode 0,兩次的在頻帶輻照度相除,
得到的就是 STK 用的斜路徑穿透率(eoir_detect.py Part C)。
2.5 望遠鏡:視場、解析度、追蹤速率
本章的望遠鏡:口徑 0.40 m、有效焦距 1.60 m(F/4),上面掛四台相機。
| 波段 | 感測器 | 像素間距 | IFOV | 視場 | 繞射 1.22λ/D | 積分時間 |
|---|---|---|---|---|---|---|
| MWIR 3.7–4.1 | 640×512 InSb | 15 µm | 9.38 µrad(1.93″) | 0.344°×0.275° | 11.9 µrad | 2 ms |
| CO₂ 4.2–4.4 | 同上 | 15 µm | 9.38 µrad | 同上 | 13.1 µrad | 2 ms |
| LWIR 8–12 | 640×480 微測輻射熱計 | 17 µm | 10.6 µrad | 0.390°×0.292° | 30.5 µrad | 8 ms |
| VIS 0.45–0.75 | 2048² CMOS | 5.5 µm | 3.44 µrad | 0.403° | 1.83 µrad | 5 ms |
送進 STK 的時候這些數字全部要換單位(見 §5.2 的單位對照表):
口徑 0.40 m 要寫成 EntrancePDia = 40.0(cm)、焦距 1.60 m 要寫成
EffFocalL = 160.0(cm)、半視場角要寫度數 0.171887(不是弧度)。
eoir_build.py 的 BANDS 因此每個波段同時帶「SI 值」與「STK 值」兩組欄位,
名稱刻意分開(EffFocalL vs EffFocalL_m)—— 本章就是被單位咬過才學到這件事。
驗證的方式是讓 STK 自己把答案算回來。實測結果:
| 波段 | 自算 1.22λ/D | STK Rayleigh resolution |
|---|---|---|
| MWIR | 11.89 µrad | 11.90 µrad |
| CO₂ | 13.12 µrad | 13.12 µrad |
| LWIR | 30.50 µrad | 30.50 µrad |
| VIS | 1.83 µrad | 1.83 µrad |
四個波段全部吻合到小數第二位。這一組數字是整條單位鏈的證明 ——
口徑要用 cm、半視場要用 deg、像素間距回讀是 µm、而這個資料提供者用 deg 報角度,
四件事任何一件搞錯,這張表都不可能對得起來。
MWIR 的 IFOV 幾乎剛好等於繞射斑的 1/1.3 —— 接近繞射極限取樣,設計合理。
LWIR 一個繞射斑跨 2.9 個像素(欠取樣),那是 0.4 m 口徑在 10 µm 的物理極限,
不是設定錯誤。
目標有多大 —— 這是與天基最大的差別
STK 的尾焰依「該節第一個形狀元件」的長寬定尺寸,再乘上你給的相對比例。
本章:圓柱 7.7 m × 0.42 m,RelativeLength 4、RelativeWidth 6
→ 尾焰約 30.8 m 長 × 2.5 m 寬。
MWIR 焦平面上(IFOV 9.38 µrad):
| 斜距 | 尾焰長 | 尾焰寬 | 箭體長 |
|---|---|---|---|
| 10 km | 328 px | 27 px | 82 px |
| 20 km | 164 px | 13 px | 41 px |
| 50 km | 66 px | 5 px | 16 px |
| 100 km | 33 px | 3 px | 8 px |
| 300 km | 11 px | 0.9 px | 2.7 px |
對照:上一章的天基場景,GEO 感測器看到的目標只有 6.7 個像素。
這是一個質的差別,不是量的差別:
- 天基是點源偵測。訊號全部落在一兩個像素上,靠的是「這幾個像素比周圍亮多少」,
用的是點源 SNR 與 NEI(雜訊等效輻照度)。 - 地面是延伸目標成像。尾焰被解析開,你看得到它的形狀、長度、亮度分布。
用的是延伸目標的對比度與 NER(雜訊等效輻射亮度)。 - 所以地面望遠鏡能做天基做不到的事:從尾焰的長度與形狀推斷推力與高度;
也做不到天基做得到的事:隨時盯著一整個半球。
追蹤速率:STK 不會告訴你追不上
視線角速度用單位向量夾角算,不要分別對方位/仰角微分 ——
接近天頂時方位角速度會發散,那是座標的假象。
用動力上升模型(不是 STK 的脈衝彈道)算出來:
| 觀測點 | 最大角速度 | 發生時刻 | 方位掃過 | 過 MWIR 視場的時間 |
|---|---|---|---|---|
| 南田(9.7 km) | 3.77 °/s | t ≈ 6 s | 48° | 0.09 s |
| 大漢山(29.7 km) | 1.60 °/s | t ≈ 24 s | 22° | 0.21 s |
兩個推論:
- 必須是閉迴路追蹤,不能凝視。 若轉台完全不動,3.77 °/s 在 2 ms 曝光內
就把目標拖過 14 個像素;視場只有 0.34°,目標在十分之一秒內就穿越整個畫面。 - 最大角速度出現在第一節燒盡附近(t ≈ 6 s),不是在最高點。
這很反直覺 —— 那時候火箭才飛了 2 km,但它離觀測點也只有 10 km,
角速度 = 橫向速度 / 距離,分母小的時候分子一點點就很大。
⚠ 注意:eoir_detect.py Part A 印出來的角速度是從 STK 的彈道算的,
會大得多(南田 13.6 °/s、大漢山 4.5 °/s,而且都發生在 t = 0)。
那是 2.3 節那個「t=0 就有全部速度」的假象,不是真的追蹤需求。
設計轉台要用動力模型的 3.77 °/s;而 STK 那個 13.6 °/s 剛好可以拿來當
安全裕度的上界。 兩個數字都印出來,而不是只留一個,是刻意的。
⚠ STK 完全不會替你檢查這件事。 說明檔查證的結果:
轉動速率限制只存在於載具姿態層級(SetAttitude ... Target Slew RateMagnitude),
而且只適用 Satellite / LaunchVehicle / Missile。
Facility / Place / Target 根本沒有 Attitude 物件,
所以地面望遠鏡在 STK 裡永遠是理想轉台:要 100 °/s 它就給你 100 °/s,
access 照樣算得出來,一個警告都沒有。
這個約束只能自己在 Python 這邊算。 這正是本教材「STK 主導、Python 補位」
的典型場景 —— 補的不是計算能力,是 STK 根本沒有的物理。
2.6 背景與天氣:地面觀測真正的敵人
天基感測器的背景是地球,穩定、可預測。地面望遠鏡的背景是天空,而天空會變。
| 因素 | 影響 | 在本章怎麼處理 |
|---|---|---|
| 太陽 | 白天的散射天光把可見光整個淹掉,MWIR 也被太陽反射與熱背景抬高數個數量級 | 場景設在當地 22:00 |
| 雲 | 一片雲就結束了。這不是衰減,是遮蔽 | SetAtmosphere Clouds Off;實驗單有一題要你打開它 |
| 水氣 | MWIR 與 LWIR 的消光主角,尺度高度只有 2–2.5 km | SetAtmosphere Humidity 80(海岸,亞熱帶) |
| 氣溶膠 | 海洋型氣溶膠粒徑大、吸濕,在紅外的散射比大陸型嚴重 | SetAtmosphere Aerosol 2(maritime) |
| 能見度 | 直接進 Beer–Lambert 的 k₀ | SetAtmosphere Visibility 23 |
| 地形 | 稜線擋住發射台,但被仰角門檻蓋過去,不是限制因素 | 幾何估算;STK 需要 .dem/.pdtt 才算得出來 |
「什麼時候看得到」的完整答案不是一條 SNR 曲線,是一張排除表:
白天 -> 可見光不行,MWIR 大打折扣
有雲 -> 全部不行
仰角 < 5 度 -> 大氣吃掉一個數量級
地形擋住 -> 大漢山看不到 t < 5 s
t > 43 s(熄火後) -> 只剩 400 K 箭體,訊號掉三到四個數量級
真正能用的觀測窗,比 SNR 曲線上看起來的窄得多。
2.7 STK EOIR 的模型界線
必須先知道 EOIR 做不到什麼,否則會把模型的假象當成物理結論。
| 限制 | 內容 | 出處 |
|---|---|---|
| 尾焰是 99% 發射率的灰體 | 形狀是「甜筒」(球+錐),溫度/相對寬/相對長是唯三的旋鈕。反射率內部寫死,不可覆寫 | Help\Content\eoir\Missile_Basic_EOIRStage.htm |
| 沒有分子帶結構 | 真實尾焰的光譜有強烈的 CO₂ 4.3 µm 帶、H₂O 2.7 µm 帶。灰體近似抓不到 | 同上 |
| 一個 Missile 物件 = 一節 | 多節火箭要建多個 Missile 物件,用起訖時間串接 | Missile_Basic_EOIRShape.htm |
| Simple 大氣固定用 US Standard 1976 | 只有隨高度的變化,不算水平變化(也就是天氣)、不算雲 | Help\Content\eoir\eoir_atmosphere.htm |
| 不算大氣臨邊 | 唯一被排除的幾何是 space→atmosphere→space | 同上 |
| Facility 不能當「有形狀的目標」 | EOIR 與 EOIR_R 指令只對 Satellite / Aircraft / Missile 有效 |
cmd_EOIR.htm |
| 地面感測器沒有轉動速率限制 | 只有載具姿態層級有,Facility 沒有 Attitude | cmd_SetAttitudeTarget.htm |
| 沒有大氣穿透率的資料提供者 | EOIR 只有四個 DP,都不輸出 transmission / path radiance | Sensor.htm |
本章因此做了一個關鍵的簡化並且明講:尾焰設成 0–43 s 連續燃燒,
沒有表達真實的 6–12 s 節間滑行空檔。要正確表達必須建兩個 Missile 物件。
實驗單有一題就是估這個簡化造成多少誤差。
3. 名詞與符號
| 符號 | 意義 | 單位 | 本章值 |
|---|---|---|---|
L(λ,T) |
光譜輻射亮度 | W/m²/sr/m | — |
L_band |
頻帶內積分輻射亮度 | W/m²/sr | 9956(2000 K, 3.7–4.1 µm) |
ε |
發射率 | — | 0.99(EOIR 尾焰固定值) |
Ω |
目標立體角 | sr | — |
E |
輻照度 | W/m² | — |
τ |
斜路徑光學厚度 | — | MWIR 天頂 0.246 |
AM(el) |
空氣質量 | — | el=90° → 1.00;el=5° → 10.31 |
k₀ |
海平面消光係數 | 1/km | MWIR 0.10;CO₂ 20 |
H |
消光物質的尺度高度 | km | 水氣 2.0–2.5;CO₂ 8.0 |
NEI |
雜訊等效輻照度(點源) | W/m² | STK 輸出 |
NER |
雜訊等效輻射亮度(延伸目標) | W/m²/sr | STK 輸出 |
D |
入瞳直徑 | m | 0.40 |
f |
有效焦距 | m | 1.60(F/4) |
| IFOV | 單像素張角 | µrad | MWIR 9.38 |
t_int |
積分時間 | s | MWIR 0.002 |
ω |
視線角速度 | °/s | 南田最大 3.77 |
T_plume |
尾焰溫度 | K | 2000 |
T_body |
箭體本體溫度 | K | 400 |
| SNR | 訊雜比 | — | 偵測門檻取 6 |
4. 觀念 → 本章實驗對照
| 觀念 | 出現在哪裡 |
|---|---|
| 發射場、觀測點、彈道、尾焰、望遠鏡、大氣 | eoir_sounding_Connect.txt |
| EOIR 波段/光學/處理層級(Connect 做不到的部分) | eoir_build.py |
| 兩體彈道自算 vs STK、助推段模型界線 | eoir_detect.py Part A |
| 地形遮蔽、仰角下限 | 同上 Part A(A4) |
| Planck 獨立驗證 | 同上 Part B |
| 反算 STK 的大氣穿透率,與 Beer–Lambert 比 | 同上 Part C |
| 波段選擇:MWIR / CO₂ / LWIR / VIS | 同上 Part D |
| 視場、解析度、追蹤速率需求 | 同上 Part E |
| 燃燒中 vs 燒盡後、偵測窗 | 同上 Part F |
| 合成影像 | 同上 Part G → eoir_scenes.png |
| 四波段並排動畫 | eoir_animate.py → eoir_ascent.gif |
| 驗證段 | eoir_verify.py |
5. 常見誤解與本章踩到的坑
5.1 觀念上的誤解
-
「地面比較近,所以一定比天基好看。」
距離確實近三個數量級,但大氣站在你這邊還是對面才是主角。
在 CO₂ 帶,10 km 的地面路徑比 36000 km 的太空路徑還糟 —— 糟無限多倍。 -
「紅外線可以穿透雲。」 不行。MWIR 與 LWIR 穿得過薄霾,穿不過雲。
雲不是衰減,是遮蔽。 -
「LWIR 比 MWIR 靈敏,因為它靠近室溫物體的峰值。」
對冷目標成立,對 2000 K 的尾焰完全相反 —— LWIR 的背景太亮,
對比度只有 MWIR 的 1/500。 -
「SNR 夠高就是偵測到了。」
這是本章最有代表性的一個錯。只看 SNR 的話,動畫在 t = 0 s 那一格
會讓四個波段全部標著DETECTED,SNR 從 3.75e4 到 2.95e6。但那一格的仰角是 −0.3°
—— 火箭還在發射台上,在地平線下面,望遠鏡根本指不到。
問題出在:輻射鏈算的是「如果看得到,會有多亮」,它不負責回答「看不看得到」。
資料提供者是無條件計算的,距離只有 9.7 km 當然給你一個很漂亮的 SNR。
一個數字算得出來,不代表那個情境成立。
正確的判準是 SNR 門檻 AND 仰角門檻。STK 的 access 本身有把兩個約束求交集
(ElevationAngle Min 5 與 EOIRSNR Min 6),但資料提供者沒有 ——
在 Python 這一側要自己補這一刀(eoir_detect.detected())。
套上門檻之後動畫的前幾格會自己演出這件事:畫面上標的是 BELOW HORIZON (el −0.3 deg)
而不是 DETECTED,即使 SNR 是七位數。這比任何文字說明都清楚。
這跟 §2.1 地形那一段是同一個教訓的兩個面向:兩個約束同時存在時,
要先確認哪一個在綁。 地形那次是把不綁的當成綁的;這次是漏掉了第二個。
- 「動畫裡看到了,所以驗證了。」——「解析度」決定一個產出能驗證什麼。
本章有三個關於「什麼時候第一次看得到」的數字,它們都對,但回答的不是同一個問題:
| 數字 | 來源 | 時間解析度 |
|---|---|---|
| 1.0 s | STK 的解析彈道(t=0 就有全部速度) | 1 s |
| 4.0 s | 動力上升模型 —— 物理上真實的那一個 | 0.25 s |
| 8.0 s | 動畫第一格 DETECTED |
8 s |
很容易把第三個拿去「確認」第二個。不能。
動畫每 8 秒一格,所以第一個 DETECTED 必然落在 8 s ——
取樣間隔多大,答案就是多少,它跟真實的擷取時刻沒有關係。
真正的首次可見是 4.0 s,只是動畫根本解析不到。
一個產出能不能用來驗證某個量,取決於它的解析度是否細於那個量。
發現這件事之後,第一個念頭是「那就把動畫前 16 秒加密,讓它對得上 4.0 s」。
那是修錯地方。 動畫的用途是看亮度與形狀怎麼變,不是量時刻;
為了讓它「對得上」而加密,反而會弄丟這個教材本身。
正確的作法是維持 8 秒,並且在文件裡寫清楚它不能回答什麼。
這個形狀的錯誤在本教材出現過不只一次 ——
獨立章節 3 用 30 秒步長的粗掃去找一個 1.8 km 的交會,是同一類。
兩者都不是計算錯,是拿一把太粗的尺去量一個太細的東西,然後相信了讀數。
- 敘述沒有跟著資料走 —— 寫死的數字被自己的產出否定。
本輪教材開發過程中,這個病出現了三次,分屬兩個章節,而且三次都不是計算錯。
計算是對的、圖是對的、資料是對的,錯的是旁邊那句話:
| # | 章節 | 寫死的敘述 | 自己的資料說什麼 |
|---|---|---|---|
| 1 | 4(TIREM) | 圖上印「模型自洽」 | 該組 C/N 全部是 NaN,根本沒有東西可以自洽 |
| 2 | 4(TIREM) | 印「被砍掉的 0 個取樣點」 | 對照組與實驗組零差異,那句話描述的情境不存在 |
| 3 | 2(本章) | panel F 註解寫 CO2 ~1e-40 |
sidecar 寫 zenith 5.195e-06、el5 3.345e-55 |
第 3 個特別值得看,因為它連「舊值」都算不上:1e-40 是 k₀ = 20 時代的量級,
而校準成 k₀ = 1.53 之後,天頂是 5.2e−6、5° 是 3.3e−55 ——
1e-40 兩個都不是,它是一個介於中間、不對應任何實際輸出的數字。
曲線畫得完全正確,只有那行字是錯的。
三次的共同結構:
1. 某個數字先被手打進敘述(圖註、print、文件表格)。
2. 底層參數或判準改了,產生數字的程式碼跟著改了。
3. 敘述沒有跟著改,因為它不是算出來的,是打上去的。
4. 它看起來仍然合理,所以沒有人重讀它。
對策不是「記得更新」,是「不要手打」。
敘述裡的每一個量都應該從產生它的同一個函式取值。
本章的 panel F 註解現在寫成
"CO2: %.1e at zenith, %.0e at el=5 deg" % (atm_transmittance(...), atm_transmittance(...))
—— 跟 sidecar 用的是同一個atm_transmittance(),所以它不可能再和資料分岔。
這跟 §5.2 的單位問題是一對:
單位問題是以為自己知道(回讀正確,認知錯誤);
這個問題是曾經知道、後來過期(當初正確,環境變了)。
兩者都不會讓程式停下來,也都不會被回讀比對抓到。
- 「程式跑完了、檔案也產出了,所以成功了。」
這一章在做圖的時候被這件事咬了一次,而且咬得很難堪。
montage() 有一版把索引寫錯:stats 用 t 當 key,卻拿 (name, t) 去查,
於是 12 格全部走進「沒有資料」的分支。結果是:程式跑完、退出碼 0、
圖檔存出來了、每一格都畫了外框、標題也都在 —— 只是裡面一格影像都沒有。
更值得記的是診斷過程也錯了,而且錯了兩次、由兩個人各錯一次。
看到滿版的 n/a,第一個人的第一反應是「一定是影像沒抓到」,
於是開始查 SaveSceneRawData、暫存目錄、處理層級。
第二個人(沒有拿到那次執行的 stdout)獨立推論出另一個同樣合理的原因:
「完整流程跑得出來、--scenes-only 跑不出來,兩者只差前面幾段 ——
一定是某個前置狀態被那幾段順手設好了」,並且把它稱為「最強的線索」。
兩個解釋都很合理。兩個都是錯的。
那次執行的 stdout 白紙黑字印著 12 行成功的畫格,連背景統計表都印出來了
—— 而那張表正是用 frames[(name, t)] 算出來的,所以 frames 不可能是空的。
資料一直都在,只是查表查錯 key。
三個教訓,一個比一個重要:
1. 「產出存在」不等於「產出有內容」。 空產出要主動變成錯誤 ——
montage()現在會在全空時raise,驗證段還會再獨立檢查一次
(見eoir_verify.py第 10 組:讀 sidecar 的每格統計,
而不是看檔案大小 —— GIF 的洗白版 197 KB、修好版 220 KB,大小分不出來)。
2. 看到症狀先去讀 stdout,不要先挑一個聽起來合理的原因。
這跟 §2.1 地形那次是同一個錯:都在還沒看證據之前就先選好了兇手。
3. 「聽起來合理」不是證據,而且它會傳染。
兩個人從同一個症狀各自推出一個合理但錯誤的解釋,比一個人犯錯更能說明
這個陷阱有多容易踩 —— 因為「合理」正是它偽裝成答案的方式。
真正該做的第一件事很無聊:去把那次的輸出調出來看。
這一族的失敗(t=0 的 DETECTED、全 n/a 的圖、回 0 個 DataSet 的資料提供者)
有一個共同點:它們都不會讓程式停下來。 所以它們只能靠「主動檢查」抓,
不能靠「等它報錯」抓。
- 「望遠鏡口徑愈大愈好。」 對點源成立。但本章的尾焰是被解析開的延伸目標,
加大口徑同時放大訊號與背景,延伸目標的 SNR 幾乎不變。
加大口徑真正買到的是角解析度,不是靈敏度。
5.2 第二種靜默失敗:回讀是對的,錯的是單位
本教材一路在講「ACK ≠ 生效」:指令收下了不代表值進去了,所以每個值都要回讀。
本章遇到了一個更難抓的類別:
值進去了。回讀完全正確。但你和 STK 對這個數字的單位認知不同。
回讀在這裡幫不上任何忙 —— 你寫 0.00873 進去,讀出來就是 0.00873,
每一項檢查都 PASS,而你的望遠鏡視場小了 57 倍。
EOIR 的 Band 屬性分成兩類,而且沒有任何文件把這件事講清楚:
| 類別 | 行為 | 屬性 |
|---|---|---|
| (a) 跟隨場景單位偏好 | 只有角度。走 UnitPreferences 的 Angle 維度 |
HorizontalHalfAngle、VerticalHalfAngle |
| (b) 固定內部單位 | 完全不理會任何單位偏好 | 其餘全部 |
實測 (a):把 Angle 偏好改成 rad,同一個半視場角的回讀從 0.171887 變成 0.003000
—— 值沒動,單位換了。SetUnits / km 之後 Angle 是 deg,所以要填度數。
實測 (b):把 Time 改成 min、SmallDistance 改成 cm,IntegrationTime、
EffFocalL、EntrancePDia、HorizontalPP、波段邊界的數值一動也不動。
單位對照表(每一格都註明「錨是什麼」)
沒有錨的東西不要相信。以下每一項都是在本機 STK 12.10.0 上用受控實驗釘出來的:
| 屬性 | 單位 | 錨(怎麼知道的) |
|---|---|---|
HorizontalHalfAngle / VerticalHalfAngle |
deg(跟隨 Angle 偏好) | 把 Angle 偏好切成 rad,回讀從 0.171887 變 0.003000,比值正好 57.2958 |
EffFocalL |
cm | 設 160.0 → EOIR Sensor Optics 用 SI 報 Effective focal length = 1.6 |
EntrancePDia |
cm | 設 40.0 → Rayleigh resolution = 6.815e-4 deg = 1.189e-5 rad,而 1.22 × 3.9 µm / 0.40 m = 1.189e-5 rad,吻合 |
HorizontalPP(唯讀) |
µm | 設半視場 0.171887 deg + 640 像素 → 回讀 15.00,而 15 µm 正是我們要的像素間距 |
HorizontalIFOV(唯讀) |
mrad | 同上 → 回讀 0.009375,而 15 µm / 1.60 m = 9.375 µrad = 0.009375 mrad |
LowBandEdgeWL / HighBandEdgeWL |
µm | 設 3.7 / 4.1 → EOIR Sensor Performance 報 3.7e-06 / 4.1e-06(m) |
QEValue、OpticalTransmission、DetectorFillFactor |
無單位(0–1) | 預設值都是 1.0 |
Rayleigh resolution、Airy disk diameter(DP) |
deg(跟隨 Angle 偏好) | 與 1.22 λ/D 比對,差正好 57.2958 倍 |
Sensor NEI / NER / SEI / SER(DP) |
dB(dBW/m²、dBW/m²/sr) | 值是 −110 這種量級,線性化後才有物理意義 |
IntegrationTime |
假設是 ms —— 沒有找到內部錨 | 見下 |
IntegrationTime 這一格必須誠實地空著。 已經確定的事實:
- 預設值
100.0。100 s與100 µs都不是任何真實相機的預設值,100 ms是。 Sensor NEI嚴格正比於1/√t(實測 0.1 / 1 / 2 / 100 四點)。Sensor peak irradiance responsivity嚴格正比於t。- 它不跟隨
Time單位偏好。 - 沒有任何資料提供者用 SI 報它。
- 想用運動模糊當內部錨也失敗:開
UseMotionBlur、把AlongScanSmearRate
設到 10,Horizontal effective instantaneous FOV的增量嚴格為 0。
所以本章把它當毫秒,並且把這個假設寫成一個可執行的檢查:
eoir_verify.py 一方面驗縮放律(單位錯了這一項照樣過,它只驗模型形狀),
一方面驗 NEI 的絕對量級像不像一台真的 MWIR 相機(單位錯了這一項會失守)。
假設不可怕,沒有標示的假設才可怕。
一個寫著「這裡我們不知道」的表格,比一個每一格都填滿的表格有用得多。
舊章節因此錯了兩個地方
這一章原本是「天基預警看飛彈」,改寫時把舊程式碼讀了一遍,發現兩個真 bug:
sen.Pattern.ProcessingLevel = 3—— 以為是 Sensor Output,實際上是 Sensor Off。b.EntrancePDia = 0.60—— 以為是 0.6 m,實際上是 6 mm 口徑;
b.HorizontalHalfAngle = math.radians(0.5)—— 以為是 0.5°,
實際上是 0.0087°,視場只有想要的 1/57。
兩個 bug 疊在一起,正好解釋舊章節記錄的那句
「Sensor Output 的合成影像整張只有 0–4 個計數,尾焰完全埋在雜訊裡」——
那是一台 6 mm 口徑、視場 0.017°、而且被關掉的相機。
有意思的是舊章節的 Planck 交叉驗證(比值 0.983)仍然成立 ——
因為那條驗證用的是「目標立體角 × 頻帶輻射亮度」,跟口徑和視場無關。
一條正確的驗證路徑,不會因為旁邊有 bug 就失效;但它也不會替你抓到那些 bug。
這是本章關於「驗證段要涵蓋什麼」最重要的一課。
5.3 STK 上踩到的坑
AgESnEOIRProcessingLevels的列舉值不是 GUI 下拉選單的順序。
GUI 顯示 Sensor Off / Geometric Input / Radiometric Input / Sensor Output,
很自然會以為列舉值就是 0,1,2,3。不是。 直接從 COM 列舉物件印出來:
{'eGeometricInput': 0, 'eRadiometricInput': 1, 'eSensorOutput': 2, 'eSensorOff': 3}
照直覺寫ProcessingLevel = 3想要 Sensor Output,實際上是把感測器關掉,
沒有任何錯誤訊息。驗證段有兩項專門抓它(值要等於 2、而且不能等於 3)。
同一份列舉表也順手釘了另外兩個:
OpticalInputMode: {'eFNumberAndFocalLength':0,'eFNumberAndApertureDiameter':1,'eFocalLengthAndApertureDiameter':2}
SpatialInputMode: {'eFOVandNumPix':0,'eFOVandPixelPitch':1,'eNumPixAndPixelPitch':2}
不要查文件猜列舉值,直接把列舉物件印出來。 一行程式碼的事。
-
CloseScenario()會把 STK 整個帶走。
對一個已經建好 EOIR 感測器 + Missile + LaunchVehicle 的場景呼叫它,
STK 12.10.0 桌面版的行程安靜地結束 —— 沒有崩潰對話框,
Windows 的 Application Error 事件記錄裡一條都沒有。Python 這邊看到的只有:
STKRuntimeError: (HRESULT = 0x800706be) # RPC_S_CALL_FAILED
這個錯誤碼看起來像 DCOM 權限問題,實際上是「RPC 對象不存在了」。
這只觀察到一次,原因未確認 —— 不要把它當成必然會發生的規律。
但它的代價夠高(整個實例連同場景一起沒了),所以值得用便宜的方式繞開。
本章因此做了兩件事:
1. 優先重用同名場景(clear_children()把子物件卸掉,場景留著),
CloseScenario()降級成最後手段。
2. 真的要關的時候包在close_scenario()裡:失敗就先確認行程還在不在,
再給出講人話的訊息,而不是吐一個十六進位錯誤碼。 -
⚠ 無回應不等於死掉 —— 不要砍 mid-COM 的用戶端。
強制中止一個正在 COM 呼叫中途的 Python 用戶端,會讓對應的 STK 實例進入
長時間無回應:Responding = False、CPU 掉到 0.01 s/s、
AttachToApplication()直接掛住不返回。推測是 Windows RPC 的逾時在跑。
它會自己恢復,量級是數十分鐘。本教材一度把這個現象記成
「COM 永久壞掉,只能重開 STK」—— 那是觀察不足下的錯誤結論,
兩個實例後來都在無人介入的情況下自己回來了。
正確的作法:
1. 長時間的 STK 工作一律背景執行,腳本自帶長 timeout。
2. 寧可等,不要砍。 砍掉的代價是那個實例要無回應數十分鐘。
3. 真的等不了就改用另一個實例,不要去 kill STK。
這一條比它看起來重要:「無回應」與「死掉」在外觀上幾乎一樣
(都是 COM 呼叫沒有回應),但處置方式完全相反 —— 一個要等,一個要換。
分辨的方法是看行程還在不在,以及 CPU 有沒有在動。
-
SetConstraint <sensor> EOIRSNR Min 6.0對「還不是 EOIR」的感測器也照樣 ACK。
也就是說 NACK 與否完全無法區分「約束裝好了」和「約束被丟掉了」。
驗證段因此有一項具名檢查:去AccessConstraints裡把它找出來、讀Min值。 -
EOIR_R ... Stage Relative Width(說明檔的拼法,有空格)NACK。
可用的是無空格的RelativeWidth。文件是錯的,以實測為準。 -
Define <sensor> EOIR ModifyBand ...全部 NACK。
波段參數只能走物件模型sensor.Pattern.Bands.Item(0)(55 個屬性)。 -
Define ... EOIR只能對「還不是 EOIR」的感測器下一次。
對已經是 EOIR 的感測器再下一次會 ACK,但整個波段定義被靜默重設回
可見光預設值(Band1 / 0.4–0.7 µm)。所以切處理層級要用物件模型。
好消息:實測Define ... EOIR不會清掉既有的Point ... Targeted,
兩種順序(先指向再 Define、先 Define 再指向)都成立。 -
波段邊界先寫高的再寫低的。 先寫
LowBandEdgeWL = 8.0會被拒
(「必須比高邊界小至少 1 奈米」),因為此時高邊界還是可見光的預設值 0.7。 -
距離單位是 Connect 的 km。 火箭全長 7.7 m 要寫成
0.0077;
Facility 海拔 45 m 要寫成0.045;LaunchVehicle 的燒盡速度 2112 m/s 要寫成2.112。
每一個寫錯都會 ACK。注意這跟 5.2 節那組固定內部單位是兩回事 ——
Connect 指令的距離參數跟隨SetUnits,物件模型的 Band 屬性不跟隨。 -
LaunchVehicle的SetState SimpleAscent把燒盡速度放在燒盡經緯度前面。
順序是... <發射Lat> <發射Lon> <發射Alt> <燒盡速度> <燒盡Lat> <燒盡Lon> <燒盡Alt>。
停止時間是選填,帶不帶結果一樣(實測兩種寫法都給出 43 s、48.120 km 的同一條航跡)。
LaunchVehicle 沒有 apogee、也沒有燃燒時間可以設,要控最高點只能用 Missile 的
ApogeeAlt。 -
資料提供者掛在感測器上,不掛在 access 上,而且需要 PreData。
格式是"<目標截短路徑> <EOIR 組態名稱>",例如"Missile/SR10 MWIR"。
給錯不會拋例外,只會回 0 個 DataSet。 -
取時間序列一定要一次抓整段。 分段呼叫
ExecElements(t, t+1, 10)時,
步長比區間還長,每一格都回 0 個 DataSet,整串變 NaN,而且不拋錯。 -
輻照度與輻射亮度是 dB 值。
In-band target irradiance回的是 dBW/m²。
拿去跟 Planck 比會差十個數量級。 -
STK 哨兵值:
-9999.99= 沒算,3000= 無限大比值。 任何統計前先濾掉。 -
⚠ 多個 STK 實例同時開著時,不帶 pid 的
AttachToApplication()抓到哪一個是不確定的。
它走GetActiveObject(CLSID),從 Running Object Table 拿第一個註冊的,
不保證是最後開的、也不保證是有場景的那一個。抓錯之後每一條指令都會安靜地
送到別人的場景上,全程 ACK。
危險的不是「忘了關場景」,是「關掉了不該關的那一個,而且沒人知道」。
本章的 attach() 因此把 pid 設成必填:偵測到多於一個實例而呼叫端沒給 pid,
直接 SystemExit;給了 pid 但那個 pid 不存在,也 SystemExit;
場景名不是本章的,一樣中止,要覆寫必須明確加 --force。
順帶一提:import agi.stk12.stkdesktop 本身不會連線,只是載入 COM wrapper。
會動到 STK 的是 AttachToApplication()(抓現有實例)與
StartApplication()(開一個新的)。
- EOIR 很慢。 輻射鏈每個取樣點要算數秒。本章五具感測器、10 秒間隔、
0–320 秒,合計約 8–12 分鐘。不要把間隔調小。
6. 延伸閱讀
以下連結全部逐一連線確認可達(2026-08)。
場景與火箭(可查證的一手來源)
- 維基百科:旭海科研火箭發射場
— 座標 22°10′29.42″N 120°53′33.25″E、啟用日期、歷次發射紀錄。 - TASA:短期科研探空火箭發射場域
— 國家太空中心官方頁面。 - 維基百科:台灣探空火箭
— 通用諸元(7.7 m / 1680 kg / 射高 200–300 km)與十次任務的高度、飛行時間。 - 科技大觀園:台灣探空火箭的發展
— 1997–2014 十枚火箭的逐次紀錄,含探空十號的 90–286 km。 - 維基百科:南田太空港發射場
— 觀測點 A 的場址背景(既有追蹤天線與氣象站)。 - 維基百科:中科院九鵬院區
— 十枚探空火箭的實際發射地。 - Wikipedia: Sounding rocket
— 探空火箭的定位:填補氣球(40 km)與衛星(120 km)之間的空隙,
飛行時間通常 5–20 分鐘。
紅外物理
- Wikipedia: Planck's law
— 光譜輻射亮度公式與 Wien 位移常數 2898 µm·K。 - Wikipedia: Atmospheric window
- Wikipedia: Infrared window
— 8–14 µm 窗的邊界由水氣(短波側)與 CO₂ 14.7 µm(長波側)決定。 - Wikipedia: Air mass (astronomy)
— Kasten–Young 公式與係數;地平線的空氣質量約 38。 - NASA Technical Reports Server
— 搜尋 "rocket plume radiation"、"infrared signature"、"sounding rocket",
有大量公開的實測報告。 - 教科書:Hudson, Infrared System Engineering;
Holst, Electro-Optical Imaging System Performance。
STK EOIR
- Ansys STK 線上說明
- AGI EOIR 資源集
— 需要 AGI/Ansys 帳號。 - 本機說明檔才是權威(本章所有 API 用法都是從這裡查證的,不是猜的):
C:\Program Files\AGI\STK 12\Help\Content\eoir\
(eoir_atmosphere.htm、eoir_spatial.htm、eoir_config.htm、
Missile_Basic_EOIRShape.htm、Missile_Basic_EOIRStage.htm)C:\Program Files\AGI\STK 12\Help\Programming\Content\DocX\
(STKObjects~IAgSnEOIRBand.html有全部 58 個波段屬性;
STKObjects~Enumerations~AgESnEOIRProcessingLevels_EN.html有那組害人的列舉值)C:\Program Files\AGI\STK 12\Help\Programming\Subsystems\connectCmds\Content\
(cmd_EOIR.htm、cmd_EOIR_R.htm、cmd_EOIRDetails.htm、
cmd_Missile.htm、cmd_SetStateSimpleAscentLaunchVehicle.htm、
cmd_SetAttitudeTarget.htm)C:\Program Files\AGI\STK 12\Help\Subsystems\dataProviders\Content\html\dataProviders\
(EOIR_Sensor_To_Target_Metrics.htm的 18 個元素清單)
工具
7. 課前自我檢核
做實驗之前先自己回答。答不出來就回去讀對應的節。
-
天基預警選 4.3 µm 是因為大氣把地表背景擋掉。
那為什麼地面望遠鏡不能用同一個波段?
把「大氣在光路的哪一段」畫成一張圖來回答。(§2.4) -
2000 K 的尾焰,Wien 峰值在 1.45 µm。
為什麼本章的主力波段卻是 3.7–4.1 µm,不是 1.45 µm 附近?
從背景與大氣兩個角度各給一個理由。(§2.2、§2.4) -
觀測點從 9.7 km 移到 29.7 km,最大角速度從 3.77 °/s 降到 1.60 °/s。
同時斜距變遠了。哪些量變好、哪些變差?
至少列出三項,並說明你會怎麼取捨。(§2.1、§2.5) -
火箭在 43 秒熄火。從那一刻起訊號會掉多少個數量級?
用 Planck 積分(2000 K → 400 K)與投影面積比自己估一次,
再說明為什麼 LWIR 在這一段反而變成比較好的選擇。(§2.2、§2.5) -
你把
EntrancePDia設成 0.60、HorizontalHalfAngle設成math.radians(0.5),
回讀出來一字不差,所有檢查都 PASS。你實際上造出了一台什麼樣的望遠鏡?
然後回答:回讀比對抓不到這種錯,那要靠什麼?
(§5.2 —— 提示:錨。想一個不必相信任何單位就能驗證口徑的方法。) -
一個資料提供者回了 0 個 DataSet,而且沒有拋例外;
一個ProcessingLevel = 3讓感測器安靜地關掉;
一個AttachToApplication()接到了別人的場景然後被CloseScenario()關掉。
這三件事有什麼共同點?你要怎麼在程式裡讓它們變成看得見的錯誤?
(§5.3 —— 這一題是整套教材的核心,不只是本章的。)
圖

eoir_earlywarning/eoir_ascent.gif

eoir_earlywarning/eoir_ascent_deck.gif

eoir_earlywarning/eoir_detect.png

eoir_earlywarning/eoir_scenes.png
程式碼
# -*- coding: utf-8 -*-
# ==============================================================================
# 地面望遠鏡追蹤探空火箭 — 把上升過程做成動畫
#
# eoir_detect.py 的 Part G 只取四個時刻的靜態畫面。本檔沿著整個上升段逐格取樣,
# 四個波段並排,做成一段動畫。
#
# 為什麼值得做成動畫:
# 靜態圖回答「那一刻看得到嗎」,動畫回答「**什麼時候**開始看得到、
# 什麼時候消失、中間亮度怎麼變、四個波段的差別在哪一段最明顯」。
#
# 這一章最重要的一格是 t=43 s 的下一格:尾焰熄火,四個畫面同時暗下來,
# 但暗下來的程度完全不同。那就是「波段選擇」這個問題的視覺答案。
#
# 還有一個從頭到尾都是黑的畫面 —— CO2 4.2-4.4 um。那不是壞掉,
# 那是本章的主要結論:天基預警的黃金波段,從地面完全看不到。
#
# 技術重點:
# 1. 用 Radiometric Input 處理層級(列舉值 = 1)。Sensor Output(= 2)
# 經過量化之後動態範圍常常只剩幾個計數,動畫會是一片雜訊。
# **不要照 GUI 下拉選單的順序猜列舉值**,3 是 Sensor Off。
# 2. **色階必須跨格固定**,而且每個波段各自一組。每一格各自拉伸的話,
# 「愈燒愈亮 / 熄火變暗」會被歸一化掉 —— 那正是這段動畫唯一想表達的東西。
# 但四個波段的絕對輻射亮度差好幾個數量級,所以不能四個共用一組色階。
# 3. 輸出 GIF(Pillow 內建,不需要 ffmpeg)。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python eoir_animate.py (本檔自己重建場景)
# ==============================================================================
import json
import math
import os
import sys
import matplotlib
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import eoir_build as B # noqa: E402
from eoir_detect import (DP, PLUME_OFF, PLUME_ON, SCRATCH, # noqa: E402
set_graphics_level,
SNR_MIN, _abs_time, _crop_contrast,
look_angles, missile_lla)
# 等距 8 秒取樣。
#
# **這個取樣間隔決定了這段動畫能回答什麼、不能回答什麼。**
# 第一格 DETECTED 必然落在 t=8 s,但那不是「量到的首次可見時刻」,
# 只是「第一個仰角過門檻的取樣點」—— 取樣間隔多大,答案就是多少。
# 真正的首次可見是 t=4.0 s(Part A 的動力上升模型,時間解析度 0.25 s)。
#
# 一度想把前 16 秒加密好讓動畫「對得上」Part A。**那是修錯地方。**
# 動畫的用途是看亮度與形狀怎麼變,不是量時刻;把它加密到能量時刻,
# 反而會弄丟一個很好的教材:**一個產出能不能用來驗證某個量,
# 取決於它的解析度是否細於那個量。** 所以維持 8 秒,並在畫面上註明。
T_START, T_STOP, T_STEP = 0.0, 120.0, 8.0
ANIM_PIXELS = (256, 205) # 出動畫時暫時降像素數(視場不變,只是變粗)
# 理由同 eoir_detect.part_g:EOIRDetails 沒有解析度參數,場景光柵大小由
# 感測器像素數決定。640x512 一張是 2048x2560 的文字檔,16 格 x 4 個波段
# 要寫幾十 GB。動畫只是要看亮度怎麼變,256 像素綽綽有餘。
GRAPHICS_LEVEL = "Smoothed" # Coarse / Smoothed / Fine —— **要用字串,不要用數字**
# 精細(2)會去貼材質圖,每格要花數十秒,而且 STK 的記憶體會一路長上去。
# 平滑(1)已經足夠看出尾焰的位置與亮度變化。
FRAME_MS = 260
# --deck:簡報用的精簡版。
#
# 完整版四格並排、每格帶 SNR 與 peak/bg,是給**讀者坐在螢幕前**看的 ——
# 資訊完整,但投影到牆上、三公尺外就只剩一團字。
# 簡報版的判準不是「資訊完整」,是**三秒內看懂**:
# 只留 MWIR 與 CO2 兩格 —— 那正是整章的論點:同一副望遠鏡、同一個目標、
# 兩個相鄰的紅外波段,一個看得到、一個完全看不到。
# 拿掉所有數值標籤,每格只留一個大字的狀態;時間/仰角/距離用大字放頂端。
DECK = "--deck" in sys.argv
# 簡報上不要出現內部代號。CO2BND 是我們在程式裡分辨波段用的 key,
# 投影片上它只會讓觀眾停下來想「BND 是什麼」。
DISPLAY = {"CO2BND": "CO2", "MWIR": "MWIR", "LWIR": "LWIR", "VIS": "VIS"}
OUT_GIF = os.path.join(HERE, "eoir_ascent_deck.gif" if DECK else "eoir_ascent.gif")
# 動畫只用南田站的波段 —— 這一段要回答的是「波段」,不是「站位」。
PANELS = [(n, p, k) for n, p, k, s in B.SENSORS if s == "NEAR"
and (not DECK or k in ("MWIR", "CO2BND"))]
def scene(root, sensor_path, t):
"""取某一時刻的輻射場(浮點數陣列)。"""
root.ExecuteCommand('SetAnimation * StartAndCurrentTime "%s"' % _abs_time(t))
# 中繼檔寫到系統暫存區,不寫進教材資料夾;try/finally 保證刪除。
# 被砍在寫檔中途留下的半成品是幾百 MB —— 本章真的發生過一次。
raw = os.path.join(SCRATCH, "_anim_tmp.txt")
try:
if os.path.exists(raw):
os.remove(raw)
try:
root.ExecuteCommand('EOIRDetails %s SaveSceneRawData "%s"'
% (sensor_path, raw.replace("\\", "/")))
except Exception:
return None
if not (os.path.exists(raw) and os.path.getsize(raw) > 0):
return None
return np.loadtxt(raw)
finally:
if os.path.exists(raw):
os.remove(raw)
def metrics_series(root, sensor_path, band_name, times):
"""一次把整段的 SNR 與目標像素數取回來,回傳 {t: (snr, px)}。
**一定要一次取完整段。** 逐格呼叫 ExecElements(t, t+1, step) 時,
只要 step 比區間長就會回 0 個 DataSet —— 整段標註都是 NaN,而且不拋錯。
一次取完既快,也不會踩到這個邊界條件。
"""
sen = root.GetObjectFromPath(sensor_path)
dp = sen.DataProviders.Item(DP)
dp.PreData = "%s %s" % (B.TGT.lstrip("*/"), band_name)
step = times[1] - times[0] if len(times) > 1 else 10.0
r = dp.ExecElements(min(times), max(times), step,
["Time", "Signal to noise ratio", "Number of Target Pixels"])
if r.DataSets.Count == 0:
raise RuntimeError("EOIR 資料提供者回了 0 個 DataSet —— PreData 或時間區間不對")
cols = {r.DataSets.Item(i).ElementName: list(r.DataSets.Item(i).GetValues())
for i in range(r.DataSets.Count)}
tt = cols["Time"]
snr = cols.get("Signal to noise ratio", [])
px = cols.get("Number of Target Pixels", [])
out = {}
for k, tv in enumerate(tt):
near = min(times, key=lambda x: abs(x - tv))
s = snr[k] if k < len(snr) else float("nan")
p = px[k] if k < len(px) else float("nan")
# STK 哨兵值
s = float("nan") if (s is None or abs(s + 9999.99) < 0.01) else s
p = float("nan") if (p is None or abs(p + 9999.99) < 0.01) else p
out[near] = (s, p)
return out
def collect(root, times):
"""逐格取樣。輻射鏈與影像鏈要在不同處理層級,所以切過去再切回來。"""
frames = {}
for name, path, key in PANELS:
band = B.BANDS[key]["BandName"]
root.GetObjectFromPath(path).Pattern.ProcessingLevel = B.PROC_SENSOR_OUTPUT
try:
met = metrics_series(root, path, band, times)
except Exception as exc:
print(" %-14s 輻射鏈取值失敗:%s" % (name, str(exc)[:60]))
met = {}
# 順序:先降像素數、再切處理層級、最後才設細節等級(見 detect 的說明)。
band_obj = root.GetObjectFromPath(path).Pattern.Bands.Item(0)
keep_px = (band_obj.HorizontalPixels, band_obj.VerticalPixels)
band_obj.HorizontalPixels, band_obj.VerticalPixels = ANIM_PIXELS
root.GetObjectFromPath(path).Pattern.ProcessingLevel = B.PROC_RADIOMETRIC
set_graphics_level(root, path, GRAPHICS_LEVEL)
for t in times:
a = scene(root, path, t)
if a is None:
print(" %-14s t=%5.0f s 沒有產生影像" % (name, t))
continue
snr, px = met.get(t, (float("nan"), float("nan")))
frames[(name, t)] = dict(img=a, snr=snr, px=px)
print(" %-14s t=%5.0f s peak %.3g SNR %9.3g 目標 %8.1f px"
% (name, t, a.max(), snr, px))
root.GetObjectFromPath(path).Pattern.ProcessingLevel = B.PROC_SENSOR_OUTPUT
band_obj.HorizontalPixels, band_obj.VerticalPixels = keep_px
return frames
def color_limits(frames):
"""每個波段各自一組、跨格固定的色階 —— 但量的是**對背景的對比度**。
直接用原始輻射亮度的分位數會得到 t=10 s 整片死白、t=90 s 整片橘色,
尾焰反而看不見。原因是輻射亮度的**絕對值**在不同時刻差好幾個數量級
(仰角變了,視線穿過的大氣路徑就變了,大氣自身輻射就是背景),
跨格固定色階會被背景最亮的那一格拉爆。
除以每一格自己的背景中位數之後,16 格才變成可以並排比較的東西,
而「愈燒愈亮 / 熄火變暗」也才留得下來 —— 那是這段動畫唯一想表達的事。
背景本身沒有被丟掉:它印在每一格的標題上。
"""
lims = {}
for name, _p, _k in PANELS:
vals = [np.log10(_crop_contrast(f["img"])[0]).ravel()
for (n, _t), f in frames.items() if n == name]
if not vals:
continue
allv = np.concatenate(vals)
lims[name] = (0.0, max(float(np.nanpercentile(allv, 99.99)), 0.3))
return lims
def render(frames, times, lims, geo, out_gif):
"""把每一格畫出來,組成 GIF。
**判準是 SNR 門檻 AND 仰角門檻,不是只看 SNR。** 這一點是本章方法論的縮影,
值得單獨講:t=0 時 MWIR 的 SNR 高達 7e5,因為距離只有 9.7 km ——
但那時候火箭還在發射台上,仰角 -0.3 度,在地平線**下面**,望遠鏡指不到。
輻射鏈照樣算得出一個很漂亮的 SNR,因為它算的是「如果看得到,會有多亮」。
一個數字算得出來,不代表那個情境成立。
所以動畫的前幾格會自己演出「看得到訊號、但看不到目標」的差別 ——
那比任何文字說明都清楚。這也正好呼應 Part A 的地形那一段學到的:
**兩個約束同時存在時,要先確認哪一個在綁。**
"""
from PIL import Image
imgs = []
cells = []
tmp = os.path.join(SCRATCH, "_frame.png") # 中繼檔不寫進教材資料夾
try:
for t in times:
# 先把這一格的幾何算出來 —— 判準要用它,不是只有標題要用。
rng_t = float(np.interp(t, geo["t"], geo["rng"]))
el_t = float(np.interp(t, geo["t"], geo["el"]))
om_t = float(np.interp(t, geo["t"], geo["om"]))
visible = el_t >= B.EL_MIN
fig, ax = plt.subplots(1, len(PANELS),
figsize=((6.4 if DECK else 4.4) * len(PANELS),
7.4 if DECK else 5.9))
for k, (name, _p, key) in enumerate(PANELS):
a = ax[k]
f = frames.get((name, t))
bd = B.BANDS[key]
a.set_xticks([])
a.set_yticks([])
if f is None or name not in lims:
a.text(0.5, 0.5, "n/a", ha="center", va="center")
a.set_title("%s %.2f-%.2f um"
% (key, bd["LowBandEdgeWL"], bd["HighBandEdgeWL"]),
fontsize=20 if DECK else 9)
continue
vmin, vmax = lims[name]
crop, bg = _crop_contrast(f["img"])
a.imshow(np.log10(crop), cmap="inferno", vmin=vmin, vmax=vmax)
if float(crop.max()) <= 1.0000001:
# 整片平坦 = 目標根本不在畫面裡(通常還在地平線下)。
# 不要讓它變成一個沒有說明的黑方塊。
a.text(0.5, 0.5, "no target in field", transform=a.transAxes,
ha="center", va="center", color="0.75",
fontsize=16 if DECK else 10)
# **判準是 SNR 門檻 AND 仰角門檻,不是只看 SNR。**
# t=0 時 SNR 高達 7e5,但仰角 -0.3 度,火箭還在發射台上、
# 在地平線下面。輻射鏈算的是「如果看得到,會有多亮」,
# 它不負責回答「看不看得到」。
if el_t < 0.0:
det, col = "BELOW HORIZON (el %.1f deg)" % el_t, "tab:red"
elif not visible:
det, col = "EL %.1f deg < %.0f deg" % (el_t, B.EL_MIN), "tab:orange"
elif np.isfinite(f["snr"]) and f["snr"] >= SNR_MIN:
det, col = "DETECTED", "tab:green"
else:
det, col = "no detection", "0.35"
if DECK:
# 簡報版:大字波段名 + 一個大字的狀態,沒有任何數值。
short = ("BELOW HORIZON" if det.startswith("BELOW")
else "TOO LOW" if det.startswith("EL ")
else "NOT VISIBLE" if det == "no detection"
else "DETECTED")
a.set_title("%s %.1f-%.1f um"
% (DISPLAY.get(key, key), bd["LowBandEdgeWL"],
bd["HighBandEdgeWL"]),
fontsize=20, fontweight="bold", pad=14)
a.text(0.5, -0.045, short, transform=a.transAxes, ha="center",
va="top", fontsize=24, color=col, fontweight="bold")
else:
a.set_title("%s %.2f-%.2f um\npeak/bg %.3g bg %.3g\nSNR %.3g"
% (key, bd["LowBandEdgeWL"], bd["HighBandEdgeWL"],
float(crop.max()), bg, f["snr"]), fontsize=8.5)
a.text(0.5, -0.06, det, transform=a.transAxes, ha="center",
va="top", fontsize=9, color=col, fontweight="bold")
cells.append(dict(band=key, t=float(t), el=el_t,
ratio=float(crop.max()), bg=float(bg),
snr=float(f["snr"]) if np.isfinite(f["snr"]) else None,
state=det))
state = "PLUME ON" if PLUME_ON <= t <= PLUME_OFF else "PLUME OFF"
# STK 的 Missile 沒有內彈道,t=0 就有全部速度 -> 早期角速度是假象。
# 動力上升模型算出來的真實尖峰需求是 3.8 deg/s(南田)。
note = " <- impulsive-launch artefact" if t < 5.0 else ""
if DECK:
# 仰角用 %.1f:t=0 的 -0.25 度用 %.0f 會印成 "-0 deg",
# 在投影片上看起來像 bug,而那一格恰好是最重要的一格。
fig.suptitle("t = %.0f s elevation %.1f deg range %.0f km"
% (t, el_t, rng_t), fontsize=26, fontweight="bold")
fig.text(0.5, 0.905, "same telescope, same target, two bands",
ha="center", fontsize=14, color="0.45")
fig.subplots_adjust(left=0.03, right=0.97, top=0.80, bottom=0.09,
wspace=0.06)
else:
fig.suptitle("Nantian ground telescope (0.40 m) t = %5.1f s [%s]"
" range %.1f km el %.1f deg LOS rate %.2f deg/s%s\n"
"cropped to target, divided by each frame's own background "
"median; real slew requirement peaks at 3.8 deg/s"
% (t, state, rng_t, el_t, om_t, note), fontsize=10.5)
# 不用 tight_layout:它會把 suptitle 跟子圖標題壓在一起,
# 而且把軸外的狀態標籤裁掉。明確給邊界比較可靠。
fig.subplots_adjust(left=0.02, right=0.98, top=0.76, bottom=0.10,
wspace=0.06)
fig.savefig(tmp, dpi=110 if DECK else 96)
plt.close(fig)
imgs.append(Image.open(tmp).convert("P", palette=Image.ADAPTIVE))
finally:
if os.path.exists(tmp):
os.remove(tmp)
if not imgs:
raise RuntimeError("沒有任何畫格,不輸出動畫。"
"(空產出要當成錯誤,不要安靜地存一個空檔)")
# sidecar:GIF 的檔案大小分不出「洗白的版本」與「修好的版本」
# (197 KB vs 220 KB),差距太小。所以驗證段要看內容 ——
# 每一格的 peak/bg 與判準狀態。
side = os.path.splitext(out_gif)[0] + "_stats.json"
with open(side, "w", encoding="utf-8") as fh:
json.dump({"cells": cells}, fh, ensure_ascii=False, indent=1)
print(" 每格統計 ->", os.path.basename(side))
imgs[0].save(out_gif, save_all=True, append_images=imgs[1:],
duration=FRAME_MS, loop=0, optimize=True)
print("\n動畫已存到 %s(%d 格,%.1f MB)"
% (os.path.basename(out_gif), len(imgs), os.path.getsize(out_gif) / 1e6))
return out_gif
def main():
# 本檔自己重建場景。原因:動畫要在輻射鏈與影像鏈兩個處理層級之間來回切換,
# 殘留狀態會讓資料提供者回 0 個 DataSet。與其猜上一支腳本把場景留成什麼樣,
# 不如從乾淨的狀態開始。attach() 的 guard 仍然會擋住「接到別人的場景」。
print("--- 重建場景 ---")
app, root, _ack, _nack = B.build(pid=B.cli_pid(), force="--force" in sys.argv)
times = [T_START + i * T_STEP
for i in range(int((T_STOP - T_START) / T_STEP) + 1)]
print("=" * 78)
print("上升段動畫:%.0f - %.0f 秒,每 %.0f 秒一格,共 %d 格 x %d 個波段"
% (T_START, T_STOP, T_STEP, len(times), len(PANELS)))
print("EOIR 每格要算數秒,合計約 %d 分鐘。"
% max(1, int(len(times) * len(PANELS) * 4 / 60)))
print("=" * 78)
lla = missile_lla(root, t0=T_START, t1=T_STOP + 20.0, step=2.0)
rng, az, el = look_angles(B.NEAR_LLA, lla["Lat"], lla["Lon"], lla["Alt"])
from eoir_detect import angular_rate
geo = dict(t=lla["Time"], rng=rng, az=az, el=el,
om=angular_rate(az, el, lla["Time"]))
frames = collect(root, times)
if not frames:
print("沒有取到任何畫格。SaveSceneRawData 需要桌面版與 3D 視窗。")
return 1
lims = color_limits(frames)
print()
for name, (lo, hi) in lims.items():
print(" %-14s 固定色階(log10):%.2f - %.2f" % (name, lo, hi))
render(frames, times, lims, geo, OUT_GIF)
print()
print("=" * 78)
on = [t for t in times if PLUME_ON <= t <= PLUME_OFF]
off = [t for t in times if t > PLUME_OFF]
print(" %-14s %14s %14s %10s" % ("波段", "燃燒中峰值", "熄火後峰值", "落差倍數"))
for name, _p, _k in PANELS:
pon = [frames[(name, t)]["img"].max() for t in on if (name, t) in frames]
poff = [frames[(name, t)]["img"].max() for t in off if (name, t) in frames]
if not pon or not poff:
continue
a, b_ = float(np.median(pon)), float(np.median(poff))
print(" %-14s %14.4g %14.4g %10.1f" % (name, a, b_, a / max(b_, 1e-30)))
print()
print(" 動畫要看三件事:")
print(" 1. t=%.0f s 那一刻四個畫面同時暗下來,但暗的程度完全不同。" % PLUME_OFF)
print(" 2. CO2 那一格從頭到尾是黑的 —— 那不是壞掉,那是結論。")
print(" 3. 標題列的角速度:峰值出現在第一節燒盡附近,不是在最高點。")
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
# ==============================================================================
# 地面望遠鏡追蹤探空火箭尾焰 — 場景建構
#
# 分工:
# eoir_sounding_Connect.txt Connect 做得到的一切
# 本檔 Connect 做不到的部分
#
# Connect 做不到的部分(STK 12.10 實測 + 說明檔查證):
# 1. EOIR 感測器的波段與光學參數。
# `Define <sensor> EOIR` 會 ACK,但接下來的 `ModifyBand ...` 全部 NACK。
# 可用的路徑是物件模型的 `sensor.Pattern.Bands.Item(0)`,它有 58 個屬性。
# 2. 處理層級 (ProcessingLevel)。不能用第二次 `Define ... EOIR` 去改 ——
# 那會 ACK,但把整個波段定義靜默重設回可見光預設值。
# 3. EOIR 的大氣設定完全沒有物件模型 API(說明檔查證:IAgSnEOIRPattern 與
# IAgSnEOIRBand 沒有任何大氣或背景輻射屬性),只能走 Connect。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python eoir_build.py (用本章預設的 STK_PID = 67408)
# python eoir_build.py --pid 67408 (明確指定實例)
# python eoir_build.py --force (明確表示要覆寫既有場景)
# ==============================================================================
import math
import os
import sys
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
from agi.stk12.stkdesktop import STKDesktop # noqa: E402
SCRIPT = os.path.join(HERE, "eoir_sounding_Connect.txt")
SCENARIO = "EOIRSoundingRocket"
T0 = "7 Oct 2024 14:00:00.000" # UTC;當地(UTC+8)10/7 22:00,夜間
# 本章專用的 STK 桌面版行程 ID。
#
# **為什麼這是必填而不是選填**:這台機器上同時開著多個 STK 實例。
# 不帶 pid 呼叫 AttachToApplication() 時,它走的是
# `oleaut32.GetActiveObject(CLSID_AgUiApplication)` —— 從 Running Object Table
# 拿**第一個註冊**的那一個。那不是最後開的、不是有場景的、更不是你要的那一個。
# 抓錯之後接下來的每一條指令都會安靜地送到別人的場景上,而且全程 ACK。
#
# 帶 pid 時走的是另一條路(attach_to_stk_by_pid),明確綁定行程,不會抓錯。
STK_PID = 67408
TGT = "*/Missile/SR10"
BOOST = "*/LaunchVehicle/SR10_Boost"
PAD = "*/Facility/XuhaiPad"
NEAR = "*/Facility/NantianOP"
FAR = "*/Facility/DahanOP"
# 場景設計參數(Connect 腳本與分析腳本共用同一份真值,避免兩邊漂移)
PAD_LLA = (22.174839, 120.892569, 0.010) # 旭海科研火箭發射場
IMPACT_LLA = (21.17395, 122.41469, 0.0) # 彈道落點(自算兩體解,方位 125 度)
NEAR_LLA = (22.26199, 120.89034, 0.045) # 南田觀測點(台東縣達仁鄉)
FAR_LLA = (22.40776, 120.75124, 1.688) # 大漢山觀測點(屏東/台東界山)
APOGEE_KM = 286.0 # 探空十號實際最高點
BURN_OFF = 43.0 # s,第二節燒盡
PLUME_T = 2000.0 # K
PLUME_EMISS = 0.99 # EOIR 內部固定值,不可改
BODY_T = 400.0 # K,箭體本體
EL_MIN = 5.0 # deg,仰角下限約束
SNR_MIN = 6.0 # 偵測門檻
# 感測器清單:(顯示名, 物件路徑, 波段設定 key, 站別)
SENSORS = [
("Nantian-MWIR", NEAR + "/Sensor/MWIR", "MWIR", "NEAR"),
("Nantian-CO2", NEAR + "/Sensor/CO2BND", "CO2BND", "NEAR"),
("Nantian-LWIR", NEAR + "/Sensor/LWIR", "LWIR", "NEAR"),
("Nantian-VIS", NEAR + "/Sensor/VIS", "VIS", "NEAR"),
("Dahan-MWIR", FAR + "/Sensor/MWIR", "MWIR", "FAR"),
]
# ------------------------------------------------------------------------------
# AgESnEOIRProcessingLevels —— **不要照 GUI 下拉選單的順序猜**
#
# GUI 與 Connect 的字串順序是:Sensor Off / Geometric Input / Radiometric Input
# / Sensor Output。很自然會以為列舉值就是 0,1,2,3。**不是。**
# 說明檔 STKObjects~Enumerations~AgESnEOIRProcessingLevels_EN.html:
# eGeometricInput = 0
# eRadiometricInput = 1
# eSensorOutput = 2
# eSensorOff = 3
# 照直覺寫 ProcessingLevel = 3 想要「Sensor Output」,實際上是把感測器**關掉**,
# 而且不會有任何錯誤訊息。這是本章最貴的一個坑,驗證段有專門一項在抓它。
PROC_GEOMETRIC, PROC_RADIOMETRIC, PROC_SENSOR_OUTPUT, PROC_SENSOR_OFF = 0, 1, 2, 3
# AgESnEOIRBandOpticalInputMode:選「你要輸入哪兩個」,第三個由 STK 算
# 0 = F 數 + 焦距 / 1 = F 數 + 口徑 / 2 = 焦距 + 口徑
OPT_FOCAL_AND_APERTURE = 2
# AgESnEOIRBandSpatialInputMode
# 0 = 視場 + 像素數 / 1 = 視場 + 像素間距 / 2 = 像素數 + 像素間距
# 注意:選 2 時 HalfAngle 變成唯讀,寫進去不會生效(說明檔明講)。
SPAT_FOV_AND_NUMPIX = 0
# ------------------------------------------------------------------------------
# EOIR 波段屬性的單位 —— 全部是本機實測釘出來的,不是猜的
#
# 這一組是本章最貴的發現。EOIR 的 Band 屬性**分成兩類**:
#
# (a) 跟隨場景單位偏好的:只有角度。
# `HorizontalHalfAngle` 走 UnitPreferences 的 Angle 維度。
# 實測:把 Angle 改成 rad,同一個值從 0.171887 變成 0.003000。
# `SetUnits / km` 之後 Angle 是 **deg**,所以這裡要填**度數**。
# -> 舊章節寫 `math.radians(0.5)` = 0.00873,得到的是 0.00873 度的半視場,
# 也就是一個 0.017 度的視場,而不是想要的 1 度。回讀還完全正確。
#
# (b) **完全不跟隨**單位偏好、用固定內部單位的:其餘全部。
# 實測:把 Time 改成 min、SmallDistance 改成 cm,數值一動也不動。
# 單位是用物理錨釘出來的(見下面每一行的註解)。
#
# 釘單位用的兩個物理錨:
# 1. `EOIR Sensor Performance` 資料提供者用 SI 報同一組量。
# 設 EffFocalL = 160.0 -> 它報 "Effective focal length" = 1.6 (m)。**所以是 cm。**
# 設 EntrancePDia = 40.0 -> Rayleigh resolution = 6.815e-4 deg = 1.19e-5 rad,
# 而 1.22*3.9um/0.40m = 1.189e-5 rad。**所以也是 cm。**
# 2. 設半視場角 0.171887 deg + 640 像素 -> 回讀 HorizontalPP = 15.00 (um)、
# HorizontalIFOV = 0.009375,而 15um/1.60m = 9.375 urad = 0.009375 mrad。
# **所以 PP 是 um、IFOV 是 mrad。**
#
# ⚠ 唯一沒釘死的:`IntegrationTime`。見 INTEGRATION_TIME_NOTE。
# ------------------------------------------------------------------------------
CM_PER_M = 100.0
UM_PER_M = 1e6
APERTURE_M = 0.40 # m,望遠鏡入瞳直徑(五具感測器共用同一副望遠鏡)
FOCAL_M = 1.60 # m,有效焦距 -> F/4
APERTURE = APERTURE_M * CM_PER_M # 送進 STK 的值:cm
FOCAL = FOCAL_M * CM_PER_M # 送進 STK 的值:cm
INTEGRATION_TIME_NOTE = """
IntegrationTime 的絕對單位沒有釘死,本章取 **毫秒**。已經確定的事實:
* 預設值是 100.0。100 s 或 100 us 都不是任何真實相機的預設值,100 ms 是。
* NEI 嚴格正比於 1/sqrt(IntegrationTime)(實測 0.1/1/2/100 四點,誤差 < 1e-9)。
* 「Sensor peak irradiance responsivity」嚴格正比於 IntegrationTime。
* 它**不跟隨** UnitPreferences 的 Time 維度(改成 min 數值不變)。
* 沒有任何資料提供者用 SI 報它;`EOIR Sensor Performance` 沒有這個元素。
* 想用運動模糊當內部錨也失敗:UseMotionBlur + AlongScanSmearRate 對
「Horizontal effective instantaneous FOV」完全沒有影響(增量嚴格為 0)。
所以本章把它當毫秒,並且在 eoir_verify.py 用「NEI 隨積分時間的縮放律」
把這個假設變成一個可執行的檢查:單位若真的錯了,縮放律仍然成立,
但 NEI 的絕對量級會離譜 —— 那一項會 FAIL。
"""
def half_angles(npix_h, npix_v, pitch_m, focal_m=FOCAL_M):
"""由像素數與像素間距算半視場角,回傳**度數**(STK 的 Angle 偏好是 deg)。
為什麼要自己算:SpatialInputMode 選 0(視場 + 像素數)時,STK 要的是
半視場角;相機規格書給的卻是像素間距。中間這一步自己算,
順便得到一個可以拿去跟 STK 回讀的 IFOV 交叉比對的獨立答案。
"""
return (math.degrees(npix_h * pitch_m / focal_m / 2.0),
math.degrees(npix_v * pitch_m / focal_m / 2.0))
# 波段定義。五具感測器實體上是同一副 0.40 m 望遠鏡上的四台不同相機。
#
# MWIR 3.7-4.1 um InSb,大氣窗,對 2000 K 尾焰對比度最高
# CO2BND 4.2-4.4 um 天基預警的黃金波段 —— 從地面看卻是不透明的
# LWIR 8.0-12.0 um 非製冷微測輻射熱計,但 280 K 的天空自己就在這裡發光
# VIS 0.45-0.75um 夜間可用,白天完全不行
#
# 每一個 dict 同時帶「SI 值(給我們自己的物理用)」與「STK 值(給 API 用)」。
# 兩者分開命名是刻意的 —— 本章就是被單位咬過才學到這件事。
BANDS = {}
for _k, _lo, _hi, _nh, _nv, _pitch_um, _tint_ms, _qe, _tr in [
# name lo(um) hi(um) Nh Nv pitch(um) t_int(ms) QE opt.trans
("MWIR", 3.7, 4.1, 640, 512, 15.0, 2.0, 0.75, 0.75),
("CO2BND", 4.2, 4.4, 640, 512, 15.0, 2.0, 0.75, 0.75),
("LWIR", 8.0, 12.0, 640, 480, 17.0, 8.0, 0.60, 0.70),
("VIS", 0.45, 0.75, 2048, 2048, 5.5, 5.0, 0.60, 0.85)]:
_pitch_m = _pitch_um / UM_PER_M
_hh_deg, _vh_deg = half_angles(_nh, _nv, _pitch_m)
BANDS[_k] = dict(
BandName=_k,
# --- 送進 STK 的值(注意單位) ---
LowBandEdgeWL=_lo, # um(固定內部單位)
HighBandEdgeWL=_hi, # um
EntrancePDia=APERTURE, # cm(固定內部單位)
EffFocalL=FOCAL, # cm
HorizontalHalfAngle=_hh_deg, # deg(跟隨 Angle 單位偏好)
VerticalHalfAngle=_vh_deg, # deg
HorizontalPixels=_nh,
VerticalPixels=_nv,
IntegrationTime=_tint_ms, # ms(見 INTEGRATION_TIME_NOTE)
QEValue=_qe, # 0-1 無單位
OpticalTransmission=_tr, # 0-1 無單位
# --- 我們自己算物理用的 SI 值 ---
PixelPitch_m=_pitch_m,
EntrancePDia_m=APERTURE_M,
EffFocalL_m=FOCAL_M,
IFOV_rad=_pitch_m / FOCAL_M,
HalfAngle_rad=math.radians(_hh_deg),
IntegrationTime_s=_tint_ms * 1e-3,
)
# ------------------------------------------------------------------------------
def list_stk_pids():
"""列出本機所有 STK 12 桌面版行程的 PID。"""
import subprocess
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-Process -Name AgUiApplication -ErrorAction SilentlyContinue "
"| ForEach-Object { $_.Id }"],
capture_output=True, text=True, timeout=30).stdout
return sorted(int(x) for x in out.split() if x.strip().isdigit())
except Exception:
return []
# COM/RPC 的「對方不見了」錯誤碼。看到這幾個就不要再送任何指令 ——
# 行程已經沒了,後面每一條都會失敗,而且失敗訊息長得像是腳本語法寫錯。
RPC_DEAD = (0x800706BE, # RPC_S_CALL_FAILED —— 呼叫途中對方消失
0x800706BA, # RPC_S_SERVER_UNAVAILABLE
0x80010108, # RPC_E_DISCONNECTED
0x800706B5) # RPC_S_UNKNOWN_IF
def is_rpc_dead(exc):
t = str(exc).lower()
return any(("%x" % c) in t for c in RPC_DEAD)
def process_alive(pid):
return pid in list_stk_pids()
def close_scenario(root, pid):
"""關閉場景,並且把「STK 在這一步死掉」當成可預期的結果來處理。
**本章實測(STK 12.10.0 桌面版)**:對一個已經建好 EOIR 感測器 +
Missile + LaunchVehicle 的場景呼叫 `CloseScenario()`,STK 整個行程會
安靜地結束 —— 不是崩潰對話框,Windows 的 Application Error 事件記錄裡
連一條都沒有。Python 這邊看到的只有:
STKRuntimeError: (HRESULT = 0x800706be) # RPC_S_CALL_FAILED
這個錯誤碼**看起來像 DCOM 設定問題**,實際上是「RPC 端點不見了,
因為對方行程已經沒了」。不特別處理的話,人會花很久去查 DCOM 權限,
而真正該做的只是把 STK 重開。
所以這裡:失敗 -> 立刻確認行程還在不在 -> 給出對應的、講人話的訊息。
"""
try:
root.CloseScenario()
return True
except Exception as exc:
if not process_alive(pid):
raise SystemExit(
"STK(pid %s)在 CloseScenario() 的過程中結束了。\n"
" 錯誤碼 %s 是 RPC_S_CALL_FAILED —— 不是你的腳本寫錯,\n"
" 是 RPC 對象已經不存在了。\n"
" 本章觀察到一次:含 EOIR 感測器的場景在關閉時,STK 行程消失了。\n"
" 原因未確認,只發生過一次。\n"
" 開一個新的 STK 12 桌面版,用 --pid <新的 PID> 再跑一次。"
% (pid, str(exc)[:40]))
raise SystemExit(
"CloseScenario() 失敗,但 STK(pid %s)還活著:%s\n"
" 請在 GUI 裡手動關閉場景後再試。" % (pid, str(exc)[:80]))
def clear_children(root):
"""清掉場景裡的所有物件,但**不關場景**。
為什麼要有這個:`CloseScenario()` 在本章實測會把 STK 整個帶走
(見 close_scenario() 的說明)。而我們一輪一輪地重建場景,
每多關一次場景就多一次把 STK 弄死的機會。
重用同名場景只需要:把子物件清掉,再重跑 Connect 腳本(略過
`New / Scenario` 那一行)。場景本身、時間區間、單位都留著。
回傳被清掉的物件路徑清單 —— 這個清單本身就是一個檢查項:
如果清完還有殘留,下一輪的 `New / */Missile SR10` 會撞名。
"""
sc = root.CurrentScenario
removed = []
for _ in range(500): # 上限只是防呆,正常幾圈就空了
ch = sc.Children
if ch.Count == 0:
break
o = ch.Item(0)
path = o.Path
o.Unload()
removed.append(path)
if sc.Children.Count:
raise RuntimeError("清不乾淨,還剩 %d 個物件" % sc.Children.Count)
return removed
def attach(pid=STK_PID, force=False, expected=SCENARIO):
"""接上**指定的** STK 實例,並確認裡面的場景是本章的。
兩道 guard,防的是兩件不同的事故:
guard 1 — 接錯實例。
機器上同時開著多個 STK 桌面版時,不帶 pid 的 `AttachToApplication()`
從 Running Object Table 拿到哪一個是**不確定的** —— 不是最後開的,
也不是有場景的那個。所以 pid 在本章是**必填**:偵測到多於一個實例
而呼叫端沒給 pid,就直接中止並要求 --pid。
guard 2 — 關錯場景。
接上之後先讀 CurrentScenario.InstanceName:
沒有場景 -> 放心建
場景名就是我們的 -> 重建,正常
場景名是別人的 -> **中止**,除非明確傳 force=True
危險的不是「忘了關場景」,是「關掉了不該關的那一個,而且沒人知道」。
"""
pids = list_stk_pids()
if pid is None:
if len(pids) > 1:
raise SystemExit(
"中止:偵測到 %d 個 STK 實例 %s,但沒有指定 pid。\n"
" 不帶 pid 的 AttachToApplication() 抓到哪一個是不確定的。\n"
" 請用 --pid <PID> 指定。" % (len(pids), pids))
app = STKDesktop.AttachToApplication()
else:
if pids and pid not in pids:
raise SystemExit(
"中止:pid %d 不在現存的 STK 實例清單 %s 裡。" % (pid, pids))
app = STKDesktop.AttachToApplication(pid=pid)
root = app.Root # 保留 app,否則 COM 介面會失效
print("已接上 STK pid=%s(本機共 %d 個實例:%s)" % (pid, len(pids), pids))
try:
cur = root.CurrentScenario.InstanceName if root.CurrentScenario else None
except Exception:
cur = None
reuse = False
if cur is None:
print("目前沒有開啟的場景 —— 直接建立 %s" % expected)
elif cur == expected:
# **優先重用,不關場景。** CloseScenario() 是最後手段,不是第一手段。
try:
removed = clear_children(root)
reuse = True
print("重用既有場景 %s,清掉 %d 個物件:%s"
% (cur, len(removed), ", ".join(r.split("/")[-1] for r in removed) or "(本來就空)"))
except Exception as exc:
if is_rpc_dead(exc):
raise SystemExit("STK(pid %s)在清場景時失去連線:%s" % (pid, str(exc)[:60]))
print("清物件失敗(%s),退回關閉場景。" % str(exc)[:60])
close_scenario(root, pid)
elif force:
print("警告:目前場景是 %r(不是 %r),因為 force=True 關閉它。"
% (cur, expected))
close_scenario(root, pid)
else:
raise SystemExit(
"中止:pid %s 裡面是場景 %r,不是本章的 %r。\n"
" 先確認 pid 給對了沒有;真的要覆寫再加 --force。" % (pid, cur, expected))
return app, root, reuse
def connect(root, path, echo_fail=True, skip_new_scenario=False):
"""逐行送出 Connect 腳本。回傳 (ack, nack, 失敗清單)。
ack 只代表 STK 收下指令,不代表設定生效 —— 這正是 eoir_verify.py 存在的理由。
"""
ok = fail = 0
fails = []
for n, line in enumerate(open(path, encoding="utf-8", errors="replace"), 1):
cmd = line.strip()
if not cmd or cmd.startswith("#"):
continue
if skip_new_scenario and cmd.lower().startswith("new / scenario"):
# 重用既有場景時要略過這一行,否則會 NACK(場景已存在)。
# 略過**只有這一行**:時間區間、單位、TerrainServer 都要重下,
# 因為上一輪可能改過它們(例如 Part C 把大氣關掉又忘了開回來)。
continue
try:
root.ExecuteCommand(cmd)
ok += 1
except Exception as exc:
# RPC 斷線要立刻停。繼續送下去會得到幾百條 NACK,而那些訊息
# 長得跟「指令語法錯」一模一樣 —— 會把人帶到完全錯的方向。
if is_rpc_dead(exc):
raise SystemExit(
"STK 在送到第 %d 行時失去連線(%s)。\n"
" 目前還在的 STK 行程:%s\n"
" 這不是指令語法問題。若行程還在,它可能只是無回應(見下),\n"
" 等它自己恢復;若行程真的不見了,改用另一個實例。"
% (n, str(exc)[:40], list_stk_pids() or "一個都沒有"))
fail += 1
fails.append((n, cmd, str(exc)[:80]))
if echo_fail:
print(" NACK L%-4d %s" % (n, cmd[:100]))
print("%s: %d 條 ACK, %d 條 NACK" % (os.path.basename(path), ok, fail))
return ok, fail, fails
def configure_band(root, sensor_path, band):
"""用物件模型設定一具 EOIR 感測器的波段與光學。
Connect 的 `ModifyBand` 在 STK 12.10 一律 NACK,不管參數怎麼排。
"""
# `Define ... EOIR` 只能對「還不是 EOIR」的感測器下一次。對已經是 EOIR 的
# 感測器再下一次,它會 ACK,但**整個波段定義會被重設回可見光預設值**
# (Band1 / 0.4-0.7 um),而且沒有任何錯誤訊息。之後所有以波段名稱為
# PreData 的資料提供者就全部回 0 個 DataSet。
root.ExecuteCommand("Define %s EOIR" % sensor_path)
sen = root.GetObjectFromPath(sensor_path)
# 輻射鏈的資料提供者要在 Sensor Output 這一層算。
# 再說一次:eSensorOutput = 2,不是 3(3 是 eSensorOff)。
sen.Pattern.ProcessingLevel = PROC_SENSOR_OUTPUT
b = sen.Pattern.Bands.Item(0)
b.BandName = band["BandName"]
# 光學:輸入「焦距 + 口徑」,F 數由 STK 算
b.OpticalInputMode = OPT_FOCAL_AND_APERTURE
b.EffFocalL = band["EffFocalL"]
b.EntrancePDia = band["EntrancePDia"]
# 空間:輸入「視場 + 像素數」,像素間距由 STK 算
b.SpatialInputMode = SPAT_FOV_AND_NUMPIX
# 半視場角**跟隨場景的 Angle 單位偏好**。`SetUnits / km` 之後那是 deg,
# 所以這裡填度數。實測:把 Angle 改成 rad,同一個值的回讀從 0.171887
# 變成 0.003000 —— 值沒動,單位換了。
# 說明檔 cmd_DefineSensor.htm 寫的 "default units are radians" 只適用
# **Connect 指令**的參數,不適用物件模型屬性。這兩件事不一樣。
b.HorizontalHalfAngle = band["HorizontalHalfAngle"]
b.VerticalHalfAngle = band["VerticalHalfAngle"]
b.HorizontalPixels = band["HorizontalPixels"]
b.VerticalPixels = band["VerticalPixels"]
# 波段邊界。順序不能顛倒:先寫低邊界會撞到「必須比高邊界小至少 1 nm」的
# 檢查,因為此時高邊界還是預設值(可見光 0.7 um)。
# LWIR 這一組最明顯 —— 先寫 8.0 會直接被拒。
b.HighBandEdgeWL = band["HighBandEdgeWL"]
b.LowBandEdgeWL = band["LowBandEdgeWL"]
b.IntegrationTime = band["IntegrationTime"]
b.QEValue = band["QEValue"]
b.OpticalTransmission = band["OpticalTransmission"]
return b
def build(pid=STK_PID, force=False):
app, root, reuse = attach(pid=pid, force=force)
ack, nack, fails = connect(root, SCRIPT, skip_new_scenario=reuse)
print("\n--- EOIR 波段與光學(物件模型)---")
print(" %-14s %-8s %12s %6s %11s %10s %10s"
% ("感測器", "波段", "範圍(um)", "F數", "IFOV(urad)", "FOV(deg)", "t_int(ms)"))
for name, path, key, _site in SENSORS:
band = BANDS[key]
b = configure_band(root, path, band)
ifov = band["IFOV_rad"] * 1e6 # urad
fov = 2 * band["HorizontalHalfAngle"] # 已經是度數
print(" %-14s %-8s %5.2f-%5.2f %6.2f %11.2f %10.3f %10.1f"
% (name, b.BandName, b.LowBandEdgeWL, b.HighBandEdgeWL,
band["EffFocalL_m"] / band["EntrancePDia_m"], ifov, fov,
band["IntegrationTime"]))
root.ExecuteCommand("ClearAllAccess /")
return app, root, ack, nack
def cli_pid(argv=None):
"""從命令列取 --pid,沒給就用本章預設的 STK_PID。"""
argv = list(sys.argv if argv is None else argv)
if "--pid" in argv:
return int(argv[argv.index("--pid") + 1])
return STK_PID
def main():
app, root, ack, nack = build(pid=cli_pid(), force="--force" in sys.argv)
print("\n--- 檢查:望遠鏡看得到火箭嗎 ---")
tgt = root.GetObjectFromPath(TGT)
for name, path, _key, _site in SENSORS:
sen = root.GetObjectFromPath(path)
acc = sen.GetAccessToObject(tgt)
acc.ComputeAccess()
print(" %-14s access 區間數 = %d" % (name, acc.ComputedAccessIntervalTimes.Count))
print("\n 註:access 已含仰角 >= %.0f 度與 EOIRSNR >= %.0f 兩個約束,"
% (EL_MIN, SNR_MIN))
print(" 所以區間長度就是偵測窗。CO2 那一具如果是 0,那正是本章要的結果。")
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
# ==============================================================================
# 獨立章節 2 — 簡報內容字典
#
# 這個檔案**只有一個 DECK 常數,沒有副作用**:不連 STK、不讀檔、不畫圖。
# 共用的 make_pptx.py 會 import 它。
#
# 數字的來源原則(本章第 5 節的核心教訓):
# 凡是「跑出來的量」,都標註它出自哪一支腳本的哪一段輸出。
# verify 的三個數字直接對應 eoir_verify_stats.json,不是手打的印象值。
# 若之後重跑導致數字變動,這裡要跟著改 —— 所以每一項都寫了出處,
# 讓「該不該改」這件事查得到,而不是靠記憶。
# ==============================================================================
DECK = dict(
title="地面望遠鏡追蹤探空火箭尾焰",
subtitle="大氣站在哪一邊",
thesis="天基預警的黃金波段 4.3 µm,從地面看天頂穿透率 5.2e-6",
# ---- 場景設定(座標均為可查證來源,見 EOIR_Primer.md 第 6 節)----
scenario=[
("發射場", "旭海科研火箭發射場 22.1748 N 120.8926 E,海拔 10 m"),
("觀測點 A", "南田(台東縣達仁鄉) 22.2620 N 120.8903 E,45 m,基線 9.7 km"),
("觀測點 B", "大漢山(屏東/台東界山) 22.4078 N 120.7512 E,1688 m,基線 29.7 km"),
("火箭", "兩節式固態探空火箭,全長 7.7 m、1680 kg,以探空十號為藍本"),
("彈道", "最高點 286 km、飛行 503.7 s、第二節 43 s 燒盡"),
("尾焰", "2000 K 灰體(ε=0.99),0–43 s 燃燒"),
("望遠鏡", "口徑 0.40 m、焦距 1.60 m(F/4),四台相機共用"),
("波段", "MWIR 3.7–4.1 / CO₂ 4.2–4.4 / LWIR 8–12 / VIS 0.45–0.75 µm"),
("時間", "2024-10-07 22:00 當地(夜間),仰角下限 5°、SNR 門檻 6"),
],
# ---- 模擬重點:每條一行,是「這一章在問什麼」----
focus=[
"同一個 4.3 µm CO₂ 吸收帶,天基是黃金波段、地面完全不透明 —— 差別只在大氣站在光路的哪一段",
"把最高點當輸入(ApogeeAlt)、飛行時間當輸出,才能用自算的兩體彈道獨立驗證 STK",
"地面看到的是被解析開的延伸目標(數百像素),天基看到的是點源(6.7 像素)",
"STK 的 Missile 沒有內彈道:助推段高度高估 1.9–7.9 倍,角速度尖峰是假象",
"地面望遠鏡在 STK 裡永遠是理想轉台 —— Facility 沒有 Attitude,追不追得上只能自己算",
],
animation="eoir_ascent_deck.gif",
figures=[
("eoir_detect.png",
"六宮格:A 高度(解析彈道 vs 動力上升)· B 視線仰角 · C 追蹤角速度需求 · "
"D 五具感測器的 SNR · E Planck 曲線與四個波段 · F 自算大氣穿透率"),
("eoir_scenes.png",
"合成影像:列為波段、行為時刻。每格裁切到目標並除以該格自己的背景中位數。"
"CO₂ 那一列 peak/bg = 1.000,整格就是天空背景"),
],
# ---- 關鍵數字(首列為表頭)。來源:eoir_detect_stats.json ----
results=[
("感測器", "偵測拍數", "偵測窗"),
("Nantian-MWIR", "8", "80 s"),
("Nantian-LWIR", "19", "190 s"),
("Nantian-CO₂", "0", "0 s"),
("Nantian-VIS", "4", "40 s"),
("Dahan-MWIR", "8", "80 s"),
],
# ---- 招牌發現:只留物理結論。方法論那三條搬到 verify_note,
# 九頁版面上七條 17pt 會擠,而且會沖淡前四條的份量。----
findings=[
"CO₂ 4.2–4.4 µm 從地面看:在頻帶輻照度比 MWIR 窗小六個數量級、"
"偵測窗 0 秒、合成影像 peak/bg 恰好等於 1.000 —— 三條獨立路徑同一個結論",
"LWIR 的偵測窗是 MWIR 的 2.4 倍(190 s vs 80 s):對 2000 K 尾焰最差、"
"對 400 K 燒盡箭體最好,兩者在 43 秒熄火那一刻對調。"
"這是跑 STK **之前**先寫下的推論,後來被實測打中",
"關掉大氣後 STK 輻照度 / 自算 Planck = 0.983 / 0.993 / 0.994 / 0.949,"
"四個波段一起重現舊章節「差 1.5%」的招牌數字",
"自算 1.22λ/D 與 STK 的 Rayleigh resolution 四個波段吻合到小數第二位 —— "
"這是 cm / deg / µm / mrad 整條單位鏈的證明",
],
# ---- 驗證段。來源:eoir_verify_stats.json(PASS, FAIL, SKIP)----
verify=(202, 0, 3),
verify_note=(
"3 項 SKIP 全是「查不到就標查不到」,不是失敗:"
"EOIR 目標清單沒有 EOIR_R 回讀路徑(1 項)、"
"IntegrationTime 單位沒有找到內部錨(2 項,兩具 MWIR 感測器各一)。"
"零 FAIL,且沒有任何刻意保留的預期失敗項。" \
"本章另外三個方法論發現(簡報上不佔行,講的時候再帶):"
"① 第二種靜默失敗 —— 回讀是對的、錯的是單位(EntrancePDia 是 cm 不是 m,"
"半視場角跟隨 Angle 偏好是 deg 不是弧度,回讀完全正確但望遠鏡是 6 mm 口徑);"
"② AgESnEOIRProcessingLevels 的列舉值不是 GUI 下拉選單的順序 —— "
"eSensorOutput=2、eSensorOff=3,寫 3 以為是輸出其實是把感測器關掉;"
"③ SNR 算得出來不代表看得到 —— t=0 時 SNR 7.4e5 但仰角 −0.3°,"
"判準必須是 SNR 門檻 AND 仰角門檻。"),
files=[
("eoir_sounding_Connect.txt", "Connect 腳本(44 條,0 NACK)"),
("eoir_build.py", "建場景 + EOIR 波段/光學 + pid 與場景身分 guard"),
("eoir_detect.py", "Part A–G,含所有不靠 STK 的物理(Planck、Beer–Lambert、彈道)"),
("eoir_animate.py", "上升段動畫(--deck 產生簡報精簡版)"),
("eoir_verify.py", "驗證段 205 項"),
("EOIR_Primer.md", "基礎知識導論:選址、大氣、望遠鏡、單位錨表"),
("EOIR_Lab_Handout.md", "實驗單:Part A–F、評分比重、加分題"),
("*_stats.json", "sidecar 指紋,供驗證段比對產出內容"),
],
run=[
"開啟 STK 12 桌面版,查出 PID",
"python eoir_earlywarning/eoir_build.py --pid <PID>",
"python eoir_earlywarning/eoir_detect.py --pid <PID>",
"python eoir_earlywarning/eoir_animate.py --pid <PID> [--deck]",
"python eoir_earlywarning/eoir_verify.py --pid <PID>",
],
)
# -*- coding: utf-8 -*-
# ==============================================================================
# 地面望遠鏡追蹤探空火箭尾焰 — 分析
#
# Part A 幾何與可見性:彈道交叉驗證、距離/仰角/角速度、地形遮蔽、仰角下限
# Part B 輻射獨立驗證:自己算的 Planck 頻帶輻射亮度 vs STK 的在頻帶輻照度
# Part C 大氣:把 STK 心目中的穿透率反算出來,跟自寫的 Beer-Lambert 比
# Part D 波段選擇:MWIR / CO2 / LWIR / VIS 的訊號、背景與對比度
# Part E 望遠鏡設計:視場、像素解析度、追蹤角速度需求
# Part F 飛行階段:燃燒中 vs 燒盡後,偵測窗有多長
# Part G 合成影像:望遠鏡實際看到的畫面
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python eoir_build.py
# 3. python eoir_detect.py
#
# EOIR 很慢:輻射鏈每個取樣點要算數秒。本章五具感測器、取樣間隔 10 秒、
# 範圍 0-320 秒,合計約 8-12 分鐘。不要把間隔調小。
# ==============================================================================
import json
import math
import os
import sys
import tempfile
import matplotlib
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
# 場景原始資料的中繼檔**不要寫進教材資料夾**。
# EOIR 的 SaveSceneRawData 吐的是文字檔,一張 2048x2560 就上百 MB,
# 8192x8192(可見光)接近 800 MB。正常流程用完就刪,但只要程式被砍在
# 寫檔中途,那個半成品就會留在 repo 裡 —— 本章真的發生過一次(797 MB)。
# 所以:寫到系統暫存區,而且用 try/finally 保證刪除。
SCRATCH = os.environ.get("EOIR_SCRATCH") or os.path.join(
tempfile.gettempdir(), "eoir_scenes")
os.makedirs(SCRATCH, exist_ok=True)
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from agi.stk12.stkdesktop import STKDesktop # noqa: E402
import eoir_build as B # noqa: E402
# ---- 取樣 --------------------------------------------------------------------
STEP = 10.0 # s
T_START, T_STOP = 0.0, 320.0 # 助推段 + 前半段慣性飛行
PLUME_ON, PLUME_OFF = 0.0, B.BURN_OFF
PLUME_T, PLUME_EMISS = B.PLUME_T, B.PLUME_EMISS
SNR_MIN = B.SNR_MIN
DP = "EOIR Sensor To Target Metrics"
DP_OPT = "EOIR Sensor Optics"
ELEMENTS = ["Time", "Range to Target", "In-band target irradiance",
"Total noise equivalent irradiance", "Signal to noise ratio",
"Number of Target Pixels", "Target Solid Angle",
"In-band target radiance", "Effective background radiance",
"Scene photon NEI",
"Effective instantaneous field of view footprint"]
# ---- 物理常數 ----------------------------------------------------------------
H_PLANCK = 6.62607015e-34
C_LIGHT = 299792458.0
K_BOLTZ = 1.380649e-23
MU_EARTH = 398600.4418 # km^3/s^2
R_EARTH = 6378.137 # km
FLAT = 1.0 / 298.257223563
G0 = 9.80665
# ---- 火箭幾何(與 Connect 腳本一致)-----------------------------------------
BODY_LEN, BODY_DIA = 7.7, 0.42 # m
PLUME_RELW, PLUME_RELL = 6.0, 4.0 # Connect 的 RelativeWidth / RelativeLength
# STK 的尾焰依「該節第一個形狀元件」的長寬定尺寸,再乘上相對比例。
# 第一個元件是 7.7 m x 0.42 m 的圓柱,所以:
PLUME_LEN = BODY_LEN * PLUME_RELL # m
PLUME_WID = BODY_DIA * PLUME_RELW # m
# ==============================================================================
# 不靠 STK 的一段物理 —— 本章所有交叉驗證的基準
# ==============================================================================
def planck_band_radiance(temp_k, lo_um, hi_um, n=4000):
"""灰體在 [lo, hi] um 內的積分輻射亮度 [W/m^2/sr]。
L(lam,T) = 2hc^2 / lam^5 / ( exp(hc/(lam kT)) - 1 )
"""
lam = np.linspace(lo_um, hi_um, n) * 1e-6
x = H_PLANCK * C_LIGHT / (lam * K_BOLTZ * temp_k)
return float(np.trapezoid((2.0 * H_PLANCK * C_LIGHT ** 2 / lam ** 5) / np.expm1(x), lam))
# 一階 Beer-Lambert 指數大氣模型的頻帶參數:(海平面消光係數 1/km, 尺度高度 km)
#
# **這不是 MODTRAN。** 它是一個兩參數的量級模型,用途只有一個:提供一條與 STK
# 完全獨立的計算路徑,好判斷 STK 的答案是不是同一個數量級。
# 參數的來源與意義:
# MWIR 3.7-4.1 大氣窗。消光由水氣連續吸收 + 海洋型氣溶膠主導,尺度高度取
# 水氣的 2.5 km。天頂穿透率 0.78。
# CO2 4.2-4.4 二氧化碳的基頻吸收帶。CO2 是均勻混合氣體,尺度高度是整層
# 大氣的 8 km,而且吸收極強 -> 從地面看**完全不透明**。
# LWIR 8-12 大氣窗。水氣連續吸收主導,尺度高度 2.0 km。
# VIS 0.45-0.75 Rayleigh 散射 + 氣溶膠。550 nm 的天頂光學厚度約 0.23,
# 對應穿透率 0.79,與這組參數一致。
#
# ⚠ CO2BND 的 k0 是**唯一一個回頭用 STK 校準過的參數**,其餘三個都是先驗設定。
# 憑「CO2 吸收很強」給 k0 = 20 會得到光學厚度 τ ≈ 160。
# STK 實測的斜路徑穿透率是 2.574e-6,也就是 τ ≈ 12.9 —— **τ 高估了 12 倍**,
# 換算成穿透率是 e^-160 vs e^-12.9,差了 64 個數量級。
# 兩邊的**結論**完全一致(從地面看 CO2 帶不透明),但**量級**是猜的。
#
# 這件事要講清楚:三個大氣窗波段的參數是先驗給的,跟 STK 對到 8-13% 以內,
# 那是真正的獨立驗證;CO2 這一格不是,它是被 STK 校準出來的。
# **把校準過的參數拿去「驗證」校準它的來源,是循環論證。**
# 保留它是因為它讓那條穿透率曲線在圖上有正確的量級,不是因為它證明了什麼。
ATM_BAND = {"MWIR": (0.10, 2.5), # 先驗
"CO2BND": (1.53, 8.0), # 用 STK 校準(見上)
"LWIR": (0.12, 2.0), # 先驗
"VIS": (0.13, 1.8)} # 先驗
def airmass(el_deg):
"""空氣質量。低仰角用 Kasten-Young(1989),平面平行近似在 10 度以下會爆掉。"""
el = np.maximum(np.asarray(el_deg, dtype=float), 0.05)
return 1.0 / (np.sin(np.radians(el)) +
0.50572 * (el + 6.07995) ** -1.6364)
def atm_transmittance(band_key, h_obs_km, h_tgt_km, el_deg):
"""一階 Beer-Lambert 斜路徑穿透率。h 單位 km,el 單位 deg。"""
k0, H = ATM_BAND[band_key]
tau = k0 * H * (np.exp(-np.asarray(h_obs_km, dtype=float) / H) -
np.exp(-np.asarray(h_tgt_km, dtype=float) / H)) * airmass(el_deg)
return np.exp(-np.maximum(tau, 0.0))
# ------------------------------------------------------------------------------
def ballistic_arc(apogee_km, gamma_deg, r0_km=R_EARTH):
"""兩體彈道弧:給最高點高度與發射路徑角,解出速度、飛行時間、射程。
這是 STK Missile 的 `ApogeeAlt` 選項在做的同一件事,用來獨立預測它的答案。
幾何要點(第一次寫最容易錯的地方):
這種高橢圓彈道的近拱點在地球內部,發射點的真近點角接近 +180 度。
整段彈道是**繞過遠拱點的那一小段短弧**(從 nu0 經 180 度到 -nu0),
不是繞過近拱點的長弧。符號取錯的話高度會一路往下,而且不會報錯。
"""
from scipy.optimize import brentq
def arc(v0):
eps = v0 ** 2 / 2 - MU_EARTH / r0_km
a = -MU_EARTH / (2 * eps)
h = r0_km * v0 * math.cos(math.radians(gamma_deg))
e = math.sqrt(max(1 + 2 * eps * h * h / MU_EARTH ** 2, 0.0))
p = h * h / MU_EARTH
nu0 = math.acos(max(-1.0, min(1.0, (p / r0_km - 1) / e))) # 取正根
E0 = 2 * math.atan2(math.sqrt(1 - e) * math.sin(nu0 / 2),
math.sqrt(1 + e) * math.cos(nu0 / 2))
M0 = E0 - e * math.sin(E0)
n = math.sqrt(MU_EARTH / a ** 3)
return dict(apo=a * (1 + e) - R_EARTH, tof=2 * (math.pi - M0) / n,
rng=r0_km * 2 * (math.pi - nu0), a=a, e=e, nu0=nu0, n=n, v0=v0)
v = brentq(lambda vv: arc(vv)["apo"] - apogee_km, 1.0, 3.2, xtol=1e-13)
return arc(v)
def powered_ascent(t_end=B.BURN_OFF, a1=117.0, a2=57.0, t1=6.0, t2=12.0,
gamma_deg=80.0, dt=0.25):
"""探空火箭動力上升的參考剖面(離線,不靠 STK)。
STK 的 Missile 沒有內彈道 —— 它在 t=0 就有全部速度。這一段就是拿來
量化「彈道解在助推段錯多少」的獨立模型。時序照探空十號:
第一節 0-6 s 燒盡分離、第二節 12 s 點火、43 s 燒盡。
推力加速度 a1 / a2 是反推出來的:讓 43 s 的燒盡狀態接得上 286 km 的最高點。
"""
ts = np.arange(0.0, t_end + 1e-9, dt)
g_along = G0 * math.sin(math.radians(gamma_deg))
v = np.zeros_like(ts)
for i, t in enumerate(ts):
if t <= t1:
v[i] = a1 * t
elif t <= t2:
v[i] = a1 * t1 - g_along * (t - t1)
else:
v[i] = a1 * t1 - g_along * (t2 - t1) + (a2 - g_along) * (t - t2)
s = np.concatenate([[0.0], np.cumsum(0.5 * (v[1:] + v[:-1]) * np.diff(ts))]) / 1000.0
return dict(t=ts, v=v, alt=s * math.sin(math.radians(gamma_deg)),
downrange=s * math.cos(math.radians(gamma_deg)),
gamma_deg=gamma_deg)
def geodetic_to_ecef(lat, lon, alt_km):
la, lo = np.radians(lat), np.radians(lon)
e2 = 2 * FLAT - FLAT * FLAT
N = R_EARTH / np.sqrt(1 - e2 * np.sin(la) ** 2)
return np.stack([(N + alt_km) * np.cos(la) * np.cos(lo),
(N + alt_km) * np.cos(la) * np.sin(lo),
(N * (1 - e2) + alt_km) * np.sin(la)], axis=-1)
def look_angles(site_lla, lat, lon, alt_km):
"""站心地平座標:回傳 (斜距 km, 方位 deg, 仰角 deg)。"""
s = geodetic_to_ecef(np.array([site_lla[0]]), np.array([site_lla[1]]),
np.array([site_lla[2]]))[0]
la, lo = math.radians(site_lla[0]), math.radians(site_lla[1])
up = np.array([math.cos(la) * math.cos(lo), math.cos(la) * math.sin(lo), math.sin(la)])
east = np.array([-math.sin(lo), math.cos(lo), 0.0])
north = np.cross(up, east)
d = geodetic_to_ecef(np.asarray(lat), np.asarray(lon), np.asarray(alt_km)) - s
rng = np.linalg.norm(d, axis=-1)
return rng, (np.degrees(np.arctan2(d @ east, d @ north)) + 360) % 360, \
np.degrees(np.arcsin((d @ up) / rng))
def angular_rate(az_deg, el_deg, t):
"""視線角速度 [deg/s]。用單位向量的夾角,不要分別對 az/el 微分 ——
接近天頂時方位角速度會發散,那是座標的假象,不是真的轉得快。"""
az, el = np.radians(az_deg), np.radians(el_deg)
u = np.stack([np.cos(el) * np.sin(az), np.cos(el) * np.cos(az), np.sin(el)], axis=1)
dot = np.clip(np.sum(u[1:] * u[:-1], axis=1), -1, 1)
om = np.degrees(np.arccos(dot)) / np.diff(t)
return np.concatenate([[om[0]], om])
# ==============================================================================
# STK 端
# ==============================================================================
def fetch(root, sensor_path, band_name, step=STEP, t0=T_START, t1=T_STOP):
"""取 EOIR 的「感測器對目標度量」。
這個資料提供者掛在**感測器**上,不掛在 access 上,而且需要 PreData:
PreData = "<目標的截短路徑> <EOIR 組態名稱>" 例:"Missile/SR10 MWIR"
說明檔寫的是 "SensorConfiguration Name";單一波段時它就是波段名稱。
**PreData 給錯不會拋例外,只會回 0 個 DataSet** —— 靜默失敗,所以下面明確檢查。
另一個踩過的坑:一定要**一次抓整段**。分段呼叫 ExecElements(t, t+1, 10)
會因為步長比區間還長而回 0 個 DataSet,整串變成 NaN,而且不會拋錯。
"""
sen = root.GetObjectFromPath(sensor_path)
dp = sen.DataProviders.Item(DP)
dp.PreData = "%s %s" % (B.TGT.lstrip("*/"), band_name)
res = dp.ExecElements(t0, t1, step, ELEMENTS)
if res.DataSets.Count == 0:
raise RuntimeError("EOIR 資料提供者回了 0 個 DataSet —— PreData 格式或波段名稱不對")
out = {}
for i in range(res.DataSets.Count):
d = res.DataSets.Item(i)
out[d.ElementName] = np.array(
[v if isinstance(v, (int, float)) else np.nan for v in d.GetValues()],
dtype=float)
# 輻照度與輻射亮度是 dB 值(dBW/m^2 等),換成線性才能跟 Planck 比。
# 直接拿 dB 值去比會差十個數量級 —— 而且量級差太多時很容易誤判成「模型錯」。
for k in ("In-band target irradiance", "Total noise equivalent irradiance",
"In-band target radiance", "Effective background radiance",
"Scene photon NEI"):
if k in out:
out[k + " (linear)"] = np.power(10.0, out[k] / 10.0)
# STK 的哨兵值:-9999.99 = 沒算,3000 = 無限大比值。全部濾掉。
for k, v in list(out.items()):
if k == "Time":
continue
out[k] = np.where(np.isclose(v, -9999.99, atol=0.01) |
np.isclose(v, 3000.0, atol=1e-6), np.nan, v)
return out
def fetch_optics(root, sensor_path):
"""EOIR Sensor Optics:F 數、Airy 斑直徑、Rayleigh / Sparrow 解析度。
這個提供者**不需要 PreData**(說明檔查證),而且是固定資料,不隨時間變。
它給的 Rayleigh resolution 正好可以拿去跟自己算的 1.22*lambda/D 比。
"""
sen = root.GetObjectFromPath(sensor_path)
dp = sen.DataProviders.Item(DP_OPT)
res = dp.Exec()
out = {}
for i in range(res.DataSets.Count):
d = res.DataSets.Item(i)
vals = list(d.GetValues())
out[d.ElementName] = vals[0] if vals else None
return out
def missile_lla(root, t0=T_START, t1=520.0, step=1.0, path=None):
"""回讀火箭的地固經緯高。一次抓整段。"""
o = root.GetObjectFromPath(path or B.TGT)
res = o.DataProviders.Item("LLA State").Group.Item("Fixed").ExecElements(
t0, t1, step, ["Time", "Lat", "Lon", "Alt"])
if res.DataSets.Count == 0:
raise RuntimeError("LLA State 回了 0 個 DataSet")
d = {res.DataSets.Item(i).ElementName:
np.array(list(res.DataSets.Item(i).GetValues()), dtype=float)
for i in range(res.DataSets.Count)}
return d
# ==============================================================================
def _downrange_to_lla(downrange_km, pad=None, az_deg=125.0):
"""把「沿地面走了幾公里」換成經緯度。動力上升模型只給射程,不給經緯。"""
pad = pad or B.PAD_LLA
la0, lo0, az = math.radians(pad[0]), math.radians(pad[1]), math.radians(az_deg)
ar = np.asarray(downrange_km, dtype=float) / R_EARTH
la2 = np.arcsin(np.sin(la0) * np.cos(ar) + np.cos(la0) * np.sin(ar) * np.cos(az))
lo2 = lo0 + np.arctan2(np.sin(az) * np.sin(ar) * np.cos(la0),
np.cos(ar) - np.sin(la0) * np.sin(la2))
return np.degrees(la2), (np.degrees(lo2) + 540) % 360 - 180
def part_a(root):
"""幾何與可見性。"""
print("=" * 78)
print("Part A 幾何與可見性")
print("=" * 78)
# --- A1 彈道交叉驗證 ---
arc = ballistic_arc(B.APOGEE_KM, 80.0)
print(" A1 彈道(自己算 vs STK)")
print(" 自算:最高點 %.1f km -> 發射速度 %.0f m/s、飛行 %.1f s、射程 %.1f km"
% (arc["apo"], arc["v0"] * 1000, arc["tof"], arc["rng"]))
lla = missile_lla(root)
ok = np.isfinite(lla["Alt"])
apo_stk = np.nanmax(lla["Alt"])
tof_stk = lla["Time"][ok][-1]
print(" STK :最高點 %.1f km @ t=%.0f s,彈道結束於 t=%.1f s"
% (apo_stk, lla["Time"][np.nanargmax(lla["Alt"])], tof_stk))
print(" 差:最高點 %+.2f km(%+.2f%%)、飛行時間 %+.1f s"
% (apo_stk - arc["apo"], 100 * (apo_stk - arc["apo"]) / arc["apo"],
tof_stk - arc["tof"]))
print(" 註:STK 的彈道在自轉的地球上解,自轉在 500 秒內把落點往東帶 200 km 以上。")
print(" 但**在最高點固定的前提下,飛行時間對射程幾乎不敏感**")
print(" (自算掃描:射程 38-400 km 對應飛行時間 501.3-504.0 s),")
print(" 所以這個比對即使不模擬自轉也成立。")
# --- A2 助推段:彈道解錯多少 ---
pw = powered_ascent()
print("\n A2 助推段:Missile 的解析彈道 vs 真實動力上升")
print(" %6s %14s %14s %10s" % ("t (s)", "STK 高度 (km)", "動力模型 (km)", "高估倍數"))
for t in (5, 10, 20, 30, B.BURN_OFF):
h_stk = float(np.interp(t, lla["Time"], lla["Alt"]))
h_pw = float(np.interp(t, pw["t"], pw["alt"]))
print(" %6.0f %14.2f %14.2f %10.1f" % (t, h_stk, h_pw, h_stk / max(h_pw, 1e-6)))
print(" 燒盡點(動力模型):t=%.0f s、高度 %.1f km、速度 %.0f m/s"
% (B.BURN_OFF, pw["alt"][-1], pw["v"][-1]))
print(" -> 助推段的高度、速度與角速度,STK 全部高估。這是 Missile 物件的")
print(" 模型界線,不是設定錯誤。要真實的助推段剖面必須換 Astrogator。")
# --- A3 視線幾何 ---
print("\n A3 視線幾何(用 STK 的彈道)")
geo = {}
for site, lla_site in [("NantianOP", B.NEAR_LLA), ("DahanOP", B.FAR_LLA)]:
rng, az, el = look_angles(lla_site, lla["Lat"], lla["Lon"], lla["Alt"])
om = angular_rate(az, el, lla["Time"])
geo[site] = dict(t=lla["Time"], rng=rng, az=az, el=el, om=om)
vis = el >= B.EL_MIN
print(" %-10s 斜距 %.1f -> %.0f km(燒盡時 %.1f km)"
% (site, rng[0], np.nanmax(rng), float(np.interp(B.BURN_OFF, lla["Time"], rng))))
print(" 仰角 起始 %.1f、最大 %.1f deg @t=%.0f s"
% (el[0], np.nanmax(el), lla["Time"][np.nanargmax(el)]))
print(" 角速度 最大 %.2f deg/s @t=%.0f s;方位掃過 %.1f deg"
% (np.nanmax(om), lla["Time"][np.nanargmax(om)], np.nanmax(az) - np.nanmin(az)))
print(" 仰角 >= %.0f deg 的總時間 %.0f s"
% (B.EL_MIN, vis.sum() * (lla["Time"][1] - lla["Time"][0])))
# --- A4 地形遮蔽與首次可見 ---
print("\n A4 地形遮蔽與首次可見")
d_pad = math.degrees(math.atan2(B.PAD_LLA[2] - B.FAR_LLA[2], 29.7))
d_ridge = math.degrees(math.atan2(1.15 - B.FAR_LLA[2], 15.0))
print(" 大漢山海拔 %.0f m,發射場在 29.7 km 外的海平面 -> 視線俯角 %.2f deg。"
% (B.FAR_LLA[2] * 1000, d_pad))
print(" 半途(約 15 km)的稜線約 1000-1300 m -> 俯角 %.2f deg,**比發射場高**。"
% d_ridge)
print(" 所以發射台本身確實被稜線擋住。")
print()
print(" **但這件事在本場景裡不是限制因素。** 兩個俯角都是負的,而仰角下限是")
print(" +%.0f deg —— 仰角約束比地形嚴格得多,地形遮蔽整個被它蓋過去。" % B.EL_MIN)
print(" 會決定「什麼時候看得到」的是仰角,不是山。")
print()
print(" 首次可見(仰角 >= %.0f deg):" % B.EL_MIN)
print(" %-10s %14s %16s %12s" % ("觀測點", "STK 彈道", "動力上升模型", "差"))
for site, site_lla in [("NantianOP", B.NEAR_LLA), ("DahanOP", B.FAR_LLA)]:
g = geo[site]
first_stk = g["t"][g["el"] >= B.EL_MIN]
first_stk = first_stk[0] if first_stk.size else float("nan")
# 動力模型:自己算一次同樣的仰角
plat, plon = _downrange_to_lla(pw["downrange"])
_, _, pel = look_angles(site_lla, plat, plon, pw["alt"])
ok = pw["t"][pel >= B.EL_MIN]
first_pw = ok[0] if ok.size else float("nan")
print(" %-10s %12.1f s %14.1f s %10.1f s"
% (site, first_stk, first_pw, first_pw - first_stk))
print()
print(" STK 那一欄是 2.3 節那個「t=0 就有全部速度」的假象 —— 火箭在 1 秒內")
print(" 就被拋到十幾公里高,當然馬上就過仰角門檻。**動力模型那一欄才是真的。**")
print(" 真正的結論是:大漢山比南田晚幾秒才收到目標,原因是「距離遠 + 觀測點自己")
print(" 站在 1688 m 上,火箭要爬得更高才跨得過同一個仰角門檻」,")
print(" **不是**因為稜線擋住。稜線擋的是發射台,而發射台本來就在仰角門檻以下。")
print()
print(" 註:這是純幾何估計。要用 STK 自己算地形遮蔽,需要安裝 .dem/.pdtt")
print(" 並開啟 TerrainMask —— 沒有地形資料時 STK 會安靜地退回橢球體,")
print(" 遮蔽時間變成 0,而且不會有任何警告。")
return geo, lla, arc, pw
def part_b(data):
"""輻射:自己算的 Planck vs STK 的在頻帶輻照度。"""
print()
print("=" * 78)
print("Part B 輻射獨立驗證:Planck vs STK")
print("=" * 78)
print(" 尾焰是 %.0f K、發射率 %.2f 的灰體(EOIR 內部寫死,不可改)。" % (PLUME_T, PLUME_EMISS))
print(" 用 STK 自己給的目標立體角反推輻照度,再跟 STK 的輻照度比:")
print(" E_predict = L_band(T) * emissivity * Omega_target")
print()
print(" %-14s %10s %13s %13s %9s %8s"
% ("感測器", "L_band", "STK 輻照度", "Planck 預測", "比值", "取樣"))
rows = {}
for name, path, key, _site in B.SENSORS:
band = B.BANDS[key]
lb = planck_band_radiance(PLUME_T, band["LowBandEdgeWL"], band["HighBandEdgeWL"])
d = data.get(name)
if d is None:
print(" %-14s %10.4g (沒有資料)" % (name, lb))
continue
t = d["Time"]
boost = (t >= PLUME_ON + 5) & (t <= PLUME_OFF - 5)
e_stk = d["In-band target irradiance (linear)"][boost]
omega = d["Target Solid Angle"][boost]
good = np.isfinite(e_stk) & np.isfinite(omega) & (omega > 0) & (e_stk > 0)
if not good.any():
print(" %-14s %10.4g (助推段沒有有效取樣)" % (name, lb))
rows[name] = dict(lb=lb, ratio=float("nan"))
continue
e_pred = lb * PLUME_EMISS * omega[good]
ratio = float(np.median(e_stk[good] / e_pred))
rows[name] = dict(lb=lb, ratio=ratio, e_stk=float(np.median(e_stk[good])),
e_pred=float(np.median(e_pred)))
print(" %-14s %10.4g %13.4g %13.4g %9.3f %8d"
% (name, lb, np.median(e_stk[good]), np.median(e_pred), ratio, good.sum()))
print()
print(" 怎麼讀這一欄比值:")
print(" 比值 ~ 1 兩條路徑吻合,只差大氣穿透率與頻帶內的光譜加權。")
print(" 比值 << 1 STK 算進了大氣衰減而我們沒有 —— 正是本章要量的東西,")
print(" Part C 會把它反算成穿透率。")
print(" 比值 差 10 倍以上 先懷疑單位:dB vs 線性、km vs m、弧度 vs 度。")
return rows
def part_c(root, data, geo, lla):
"""大氣:反算 STK 的穿透率,跟自寫的 Beer-Lambert 比。"""
print()
print("=" * 78)
print("Part C 大氣:把 STK 心目中的穿透率挖出來")
print("=" * 78)
print(" 作法:同一個場景跑兩次,SetAtmosphere Mode 1 與 Mode 0,")
print(" 兩次的在頻帶輻照度相除,得到的就是 STK 用的斜路徑穿透率。")
print(" (這比去猜 STK 內部用什麼模型可靠 —— 直接量它。)")
root.ExecuteCommand("EOIR */ SetAtmosphere Mode 0")
vac = {}
for name, path, key, _site in B.SENSORS:
try:
vac[name] = fetch(root, path, B.BANDS[key]["BandName"])
except Exception as exc:
print(" %-14s 無大氣那一輪失敗:%s" % (name, str(exc)[:60]))
root.ExecuteCommand("EOIR */ SetAtmosphere Mode 1")
print()
print(" %-14s %8s %11s %13s %13s %9s"
% ("感測器", "仰角", "斜距 km", "STK 穿透率", "自算 Beer-L", "比值"))
rows = {}
for name, path, key, site in B.SENSORS:
d, v = data.get(name), vac.get(name)
if d is None or v is None:
continue
t = d["Time"]
boost = (t >= PLUME_ON + 5) & (t <= PLUME_OFF - 5)
e1 = d["In-band target irradiance (linear)"][boost]
e0 = v["In-band target irradiance (linear)"][boost]
good = np.isfinite(e1) & np.isfinite(e0) & (e0 > 0)
tau_stk = float(np.median(e1[good] / e0[good])) if good.any() else float("nan")
g = geo["NantianOP" if site == "NEAR" else "DahanOP"]
h_obs = (B.NEAR_LLA if site == "NEAR" else B.FAR_LLA)[2]
el = float(np.interp(np.mean(t[boost]), g["t"], g["el"]))
rng = float(np.interp(np.mean(t[boost]), g["t"], g["rng"]))
h_tgt = float(np.interp(np.mean(t[boost]), lla["Time"], lla["Alt"]))
tau_own = float(atm_transmittance(key, h_obs, h_tgt, el))
rows[name] = dict(tau_stk=tau_stk, tau_own=tau_own, el=el, rng=rng)
print(" %-14s %8.1f %11.1f %13.4g %13.4g %9.2f"
% (name, el, rng, tau_stk, tau_own,
tau_stk / tau_own if tau_own > 0 else float("nan")))
print()
print(" 仰角對穿透率的影響(自算,目標高度 100 km):")
print(" %-8s %9s %9s %9s %9s %9s" % ("波段", "90 deg", "30", "10", "5", "3"))
for key in ("MWIR", "CO2BND", "LWIR", "VIS"):
vals = [atm_transmittance(key, B.NEAR_LLA[2], 100.0, e) for e in (90, 30, 10, 5, 3)]
print(" %-8s %9.4f %9.4f %9.4f %9.4f %9.4f" % (key, *vals))
print()
print(" 這張表就是仰角下限 %.0f 度的來源:5 度時空氣質量是天頂的 %.1f 倍,"
% (B.EL_MIN, float(airmass(5.0))))
print(" MWIR 穿透率從 0.78 掉到 0.08 —— 訊號少一個數量級以上。")
return rows
def detected(snr, times, geo_site):
"""偵測判準:**SNR 門檻 AND 仰角門檻**。
只看 SNR 是錯的,而且錯得很有代表性。t=0 時南田的 MWIR SNR 高達 7e5,
因為距離只有 9.7 km —— 但那時候火箭還在發射台上,仰角 -0.3 度,
在地平線**下面**。輻射鏈算的是「如果看得到,會有多亮」,
它不負責回答「看不看得到」。
一個數字算得出來,不代表那個情境成立。
STK 的 access 本身有把兩個約束求交集(仰角 + EOIRSNR),但資料提供者沒有 ——
它是無條件算的。所以在 Python 這一側要自己補上這一刀。
"""
el = np.interp(times, geo_site["t"], geo_site["el"])
return np.isfinite(snr) & (snr >= SNR_MIN) & (el >= B.EL_MIN)
def _site_geo(geo, site):
return geo["NantianOP" if site == "NEAR" else "DahanOP"]
def part_d(data, geo):
"""波段選擇:訊號、背景、對比度。"""
print()
print("=" * 78)
print("Part D 波段選擇:MWIR / CO2 / LWIR / VIS")
print("=" * 78)
print(" 先看純物理(自算,不含大氣):")
print(" %-8s %12s %12s %12s %12s %12s"
% ("波段", "2000K 尾焰", "400K 箭體", "280K 天空", "尾焰/天空", "天頂穿透"))
for key in ("MWIR", "CO2BND", "LWIR", "VIS"):
b = B.BANDS[key]
lo, hi = b["LowBandEdgeWL"], b["HighBandEdgeWL"]
lp, lb_, ls = (planck_band_radiance(T, lo, hi) for T in (PLUME_T, B.BODY_T, 280.0))
print(" %-8s %12.4g %12.4g %12.4g %12.4g %12.4f"
% (key, lp, lb_, ls, lp / ls,
float(atm_transmittance(key, B.NEAR_LLA[2], 100.0, 90.0))))
print()
print(" Wien 峰值:2000 K -> %.2f um、400 K -> %.2f um、280 K -> %.2f um"
% (2897.77 / PLUME_T, 2897.77 / B.BODY_T, 2897.77 / 280))
print()
print(" 三個結論:")
print(" 1. CO2 4.2-4.4 um 是**天基預警**的黃金波段,因為大氣把地表背景擋掉。")
print(" 從地面往上看,同一層大氣擋在你和尾焰之間 —— 天頂穿透率就是 0。")
print(" **同一個物理,兩個相反的工程結論。**")
print(" 2. LWIR 的問題不是穿不透,是背景太亮:280 K 的天空在 8-12 um 自己")
print(" 就在發光,尾焰/背景只有兩百倍。MWIR 窗有十萬倍。")
print(" 3. VIS 的對比度在紙上是天文數字,但那只在**夜間**成立。")
print(" 本章刻意把場景設在當地時間 22:00。白天再跑一次就會看到差別。")
print()
print(" STK 實測:")
print(" (偵測拍數 = SNR >= %.0f **而且** 仰角 >= %.0f 度的取樣數)"
% (SNR_MIN, B.EL_MIN))
print(" %-14s %11s %11s %13s %13s %10s"
% ("感測器", "SNR 最大", "SNR 中位", "訊號輻照度", "背景輻射亮度", "偵測拍數"))
rows = {}
for name, path, key, site in B.SENSORS:
d = data.get(name)
if d is None:
print(" %-14s (沒有資料)" % name)
continue
snr = d["Signal to noise ratio"]
det = detected(snr, d["Time"], _site_geo(geo, site))
boost = (d["Time"] >= PLUME_ON) & (d["Time"] <= PLUME_OFF)
rows[name] = dict(snr_max=np.nanmax(snr), det=det, boost=boost)
print(" %-14s %11.4g %11.4g %13.4g %13.4g %10d"
% (name, np.nanmax(snr),
np.nanmedian(snr[det]) if det.any() else 0.0,
np.nanmedian(d["In-band target irradiance (linear)"][boost]),
np.nanmedian(d["Effective background radiance (linear)"][boost]),
det.sum()))
return rows
def part_e(root, geo):
"""望遠鏡設計:解析度與追蹤需求。"""
print()
print("=" * 78)
print("Part E 望遠鏡設計:視場、解析度、追蹤速率")
print("=" * 78)
print(" 口徑 %.2f m、焦距 %.2f m(F/%.1f)。"
% (B.APERTURE, B.FOCAL, B.FOCAL / B.APERTURE))
print()
print(" %-8s %10s %10s %11s %11s %11s"
% ("波段", "IFOV urad", "FOV deg", "繞射 urad", "斑/像素", "STK Rayleigh"))
for key in ("MWIR", "CO2BND", "LWIR", "VIS"):
b = B.BANDS[key]
ifov = b["IFOV_rad"]
lam = 0.5 * (b["LowBandEdgeWL"] + b["HighBandEdgeWL"]) * 1e-6
dif = 1.22 * lam / b["EntrancePDia_m"]
path = [p for _n, p, k, _s in B.SENSORS if k == key]
stk_ray = float("nan")
if path:
try:
o = fetch_optics(root, path[0])
# ⚠ 這個資料提供者用**場景的角度單位**報解析度,SetUnits / km
# 之後是 deg。直接拿去跟 1.22*lambda/D(弧度)比會差 57.3 倍。
stk_ray = math.radians(float(o.get("Rayleigh resolution", float("nan"))))
except Exception:
pass
print(" %-8s %10.2f %10.3f %11.2f %11.2f %11.2f"
% (key, ifov * 1e6, 2 * b["HorizontalHalfAngle"],
dif * 1e6, dif / ifov, stk_ray * 1e6 if np.isfinite(stk_ray) else float("nan")))
print(" (STK Rayleigh 欄位來自 `EOIR Sensor Optics` 資料提供者,單位已換成 urad。")
print(" 它跟自算的 1.22*lambda/D 應該一致 —— 這是光學鏈的獨立驗證。)")
print()
ifov = B.BANDS["MWIR"]["IFOV_rad"]
print(" 目標在焦平面上有多大(MWIR,IFOV %.2f urad):" % (ifov * 1e6))
print(" %8s %12s %12s %12s" % ("斜距 km", "尾焰長 px", "尾焰寬 px", "箭體長 px"))
for R in (10, 20, 50, 100, 200, 300):
print(" %8d %12.1f %12.1f %12.1f"
% (R, PLUME_LEN / (R * 1000) / ifov, PLUME_WID / (R * 1000) / ifov,
BODY_LEN / (R * 1000) / ifov))
print(" -> 這是地面望遠鏡與天基感測器最大的差別:**尾焰是被解析開的延伸目標**,")
print(" 不是幾個像素的點源。延伸目標的偵測看的是對比度,不是點源 SNR。")
print()
print(" 追蹤需求:")
print(" %-10s %13s %13s %14s %12s"
% ("觀測點", "STK 彈道 deg/s", "動力模型 deg/s", "拖曳 px(2 ms)", "過 FOV 時間"))
fov_deg = 2 * B.BANDS["MWIR"]["HorizontalHalfAngle"] # deg
t_int = B.BANDS["MWIR"]["IntegrationTime_s"] # s
pw = powered_ascent(dt=0.05)
plat, plon = _downrange_to_lla(pw["downrange"])
for site, site_lla in [("NantianOP", B.NEAR_LLA), ("DahanOP", B.FAR_LLA)]:
g = geo[site]
om_stk = np.nanmax(g["om"]) # STK 彈道(含 t=0 假象)
_, paz, pel = look_angles(site_lla, plat, plon, pw["alt"])
om_pw = float(np.nanmax(angular_rate(paz, pel, pw["t"])))
smear = math.radians(om_pw) * t_int / ifov # 用真實的那一個算拖曳
print(" %-10s %13.2f %13.2f %14.1f %12.3f s"
% (site, om_stk, om_pw, smear, fov_deg / om_pw))
print(" (STK 那一欄的尖峰都在 t=0,是「脈衝發射」的假象 —— 見 Part A 的 A2。")
print(" **設計轉台要用動力模型那一欄**;STK 那一欄可以當安全裕度的上界。)")
print(" -> 若轉台完全不動,一次 2 ms 曝光就把目標拖過十幾個像素;")
print(" 視場只有 0.34 度,目標在幾百毫秒內就穿越整個畫面。")
print(" **必須是閉迴路追蹤,不能凝視。**")
print()
print(" ** STK 不會替你檢查這件事。** 說明檔查證:轉動速率限制只存在於")
print(" 載具的 Attitude 層級(SetAttitude ... Target Slew RateMagnitude),")
print(" 而且只適用 Satellite / LaunchVehicle / Missile。")
print(" Facility / Place / Target **沒有 Attitude 物件**,所以地面望遠鏡在")
print(" STK 裡永遠是理想轉台:要 100 deg/s 它就給你 100 deg/s,")
print(" access 照樣算得出來。這個約束只能自己在 Python 這邊算。")
def part_f(data, geo):
"""飛行階段與偵測窗。"""
print()
print("=" * 78)
print("Part F 飛行階段:燃燒中 vs 燒盡後")
print("=" * 78)
print(" %-14s %13s %13s %11s %13s"
% ("感測器", "燃燒中 SNR", "燒盡後 SNR", "落差倍數", "偵測窗 (s)"))
rows = {}
for name, path, key, site in B.SENSORS:
d = data.get(name)
if d is None:
continue
t, snr = d["Time"], d["Signal to noise ratio"]
on = (t >= PLUME_ON + 5) & (t <= PLUME_OFF - 5)
off = t >= PLUME_OFF + 30
s_on = np.nanmedian(snr[on]) if np.isfinite(snr[on]).any() else np.nan
s_off = np.nanmedian(snr[off]) if np.isfinite(snr[off]).any() else np.nan
det = detected(snr, t, _site_geo(geo, site))
rows[name] = dict(on=s_on, off=s_off, window=det.sum() * STEP)
print(" %-14s %13.4g %13.4g %11.1f %13.0f"
% (name, s_on, s_off, s_on / max(s_off, 1e-12), det.sum() * STEP))
print()
lb_on = planck_band_radiance(PLUME_T, 3.7, 4.1)
lb_off = planck_band_radiance(B.BODY_T, 3.7, 4.1)
a_plume = PLUME_LEN * PLUME_WID
a_body = BODY_LEN * BODY_DIA
print(" 自己估一次落差(MWIR 3.7-4.1 um):")
print(" 輻射亮度比 L(%.0fK)/L(%.0fK) = %.4g / %.4g = %.0f 倍"
% (PLUME_T, B.BODY_T, lb_on, lb_off, lb_on / lb_off))
print(" 投影面積比 %.1f m^2 / %.1f m^2 = %.1f 倍" % (a_plume, a_body, a_plume / a_body))
print(" 合計預期落差約 %.3g 倍 —— 跟上表的實測比對。"
% (lb_on / lb_off * a_plume / a_body))
print()
print(" 這就是「什麼時候看得到」的答案:")
print(" 0 - %.0f s 尾焰燃燒,亮度離譜,MWIR 隨便都看得到" % PLUME_OFF)
print(" %.0f s 之後 只剩 %.0f K 的箭體,而且距離還在拉遠 —— 訊號掉好幾個數量級"
% (PLUME_OFF, B.BODY_T))
print(" 仰角 < %.0f deg 大氣把剩下的也吃掉" % B.EL_MIN)
return rows
def _abs_time(dt_sec, t0=B.T0):
import datetime
base = datetime.datetime.strptime(t0, "%d %b %Y %H:%M:%S.%f")
return (base + datetime.timedelta(seconds=float(dt_sec))).strftime(
"%d %b %Y %H:%M:%S.000")
PART_G_BANDS = ("MWIR", "CO2BND", "LWIR")
# 出圖時暫時把感測器的像素數降到這個值(SpatialInputMode 是「視場 + 像素數」,
# 所以**視場不變、只是像素變粗**)。理由:
# EOIRDetails 沒有任何解析度參數(說明檔查證),場景光柵的大小由感測器的
# 像素數決定。640x512 的 MWIR 吐出來是 2048x2560 的文字檔;
# 2048x2048 的可見光吐出來是 8192x8192,接近 800 MB、一張要好幾分鐘。
# 合成影像只是要看「尾焰在哪、有多亮」,256 像素綽綽有餘,
# 而且時間與磁碟差三個數量級。畫完就把像素數改回去。
PART_G_PIXELS = (256, 205) # 維持 640:512 的長寬比
GRAPHICS_LEVEL = "Smoothed" # Coarse / Smoothed / Fine —— **要用字串**
# 實測:`EOIRDetails <path> GraphicsLevel 1`(數字)在感測器處於
# Sensor Output 層級時會 NACK,但同一個時刻 `GraphicsLevel Smoothed`(字串)
# 照樣 ACK。說明檔 cmd_EOIRDetails.htm 列的合法值本來就是
# Coarse / Smoothed / Fine 三個字串,數字是我們自己想當然耳加上去的。
# **文件寫字串就用字串**,不要因為別的指令吃數字就假設這個也吃。
#
# 而且順序有關係:要先把 ProcessingLevel 切到 Radiometric,再下 EOIRDetails。
# 反過來在某些狀態下會 NACK。
def set_graphics_level(root, path, level=GRAPHICS_LEVEL):
"""設定場景細節等級。**失敗不中止。**
這只是 tessellation 的精細度,影響外觀不影響輻射量。寫成直接
ExecuteCommand 的話,一個純粹是外觀的設定 NACK 掉,
整個 Part G 就一張圖都產不出來 —— 那是很糟的失敗模式:
**不重要的東西壞掉,把重要的東西一起帶走。**
"""
try:
root.ExecuteCommand("EOIRDetails %s GraphicsLevel %s" % (path, level))
return True
except Exception as exc:
print(" (GraphicsLevel %s 被拒:%s —— 用預設值繼續,不影響輻射量)"
% (level, str(exc)[:50]))
return False
# VIS 預設不進 Part G:它的場景原始資料是 8192x8192 的**文字檔**,
# 一張要算好幾分鐘。第一次跑就是卡在這裡被 timeout 砍掉的。
# 要看可見光的合成影像,自己把 "VIS" 加回去,並且把 times 減到兩個。
def part_g(root, geo=None, times=(10.0, 30.0, 43.0, 90.0), bands=PART_G_BANDS):
"""合成影像:望遠鏡實際看到的畫面。
EOIR 有兩條**互相獨立**的路徑,不要搞混:
輻射鏈 資料提供者 "EOIR Sensor To Target Metrics"
解析計算,給輻照度、NEI、SNR。Part B-F 用的是這一條。
影像鏈 EOIRDetails SaveSceneImage / SaveSceneRawData
真的把場景算成一張圖。
影像鏈的處理層級決定你拿到什麼:
Sensor Output 成品影像,含量化與雜訊。動態範圍常常只有幾個計數。
Radiometric Input 進感測器之前的輻射場,浮點數,動態範圍完整。要看就用這一層。
再提醒一次列舉值:eRadiometricInput = 1、eSensorOutput = 2、eSensorOff = 3。
"""
print()
print("=" * 78)
print("Part G 合成影像")
print("=" * 78)
frames = {}
show = [(n, p, k) for n, p, k, _s in B.SENSORS if k in bands and _s == "NEAR"]
for name, path, key in show:
# 順序:先降像素數、再切處理層級、最後才設細節等級。
# 切層級一定要走物件模型。再下一次 `Define ... EOIR` 會把波段定義重設。
band_obj = root.GetObjectFromPath(path).Pattern.Bands.Item(0)
keep_px = (band_obj.HorizontalPixels, band_obj.VerticalPixels)
band_obj.HorizontalPixels, band_obj.VerticalPixels = PART_G_PIXELS
root.GetObjectFromPath(path).Pattern.ProcessingLevel = B.PROC_RADIOMETRIC
set_graphics_level(root, path)
for t in times:
root.ExecuteCommand('SetAnimation * StartAndCurrentTime "%s"' % _abs_time(t))
raw = os.path.join(SCRATCH, "_scene_%s_%03.0f.txt" % (key, t))
try:
if os.path.exists(raw):
os.remove(raw)
try:
root.ExecuteCommand('EOIRDetails %s SaveSceneRawData "%s"'
% (path, raw.replace("\\", "/")))
except Exception as exc:
print(" %-14s t=%5.0f s 存檔失敗:%s" % (name, t, str(exc)[:50]))
continue
if not (os.path.exists(raw) and os.path.getsize(raw) > 0):
print(" %-14s t=%5.0f s 沒有產生檔案" % (name, t))
continue
a = np.loadtxt(raw)
finally:
# 無論成功失敗都要刪 —— 被砍在寫檔中途留下的半成品是幾百 MB。
if os.path.exists(raw):
os.remove(raw)
frames[(name, t)] = a
print(" %-14s t=%5.0f s %dx%d 輻射亮度 %.3g - %.3g"
% (name, t, a.shape[0], a.shape[1], a.min(), a.max()))
root.GetObjectFromPath(path).Pattern.ProcessingLevel = B.PROC_SENSOR_OUTPUT
band_obj.HorizontalPixels, band_obj.VerticalPixels = keep_px
if not frames:
print(" 沒有存出任何影像。SaveSceneRawData 需要桌面版與 3D 視窗;")
print(" 無介面的 STK Engine 不支援(說明檔明列 Linux 不支援 SaveSceneImage)。")
return frames
# --- 把「背景隨仰角變化」量出來 ---
# 同一列四格的背景會差好幾個數量級,圖因此看起來像壞掉。
# 那不是畫壞了,是真的物理:仰角低的時候視線穿過的大氣路徑長好幾倍,
# 大氣自身的熱輻射就是背景。**這是地面觀測特有的問題,天基沒有** ——
# 天基往下看的背景是地球,穩定得多。所以這張表本身就是本章的一個結果。
print()
print(" 背景輻射亮度與目標峰值:")
print(" %-8s %8s %9s %12s %12s %12s"
% ("波段", "t (s)", "仰角 deg", "背景 W/m2/sr", "峰值", "峰值/背景"))
for name, _p, key in show:
for t in times:
if (name, t) not in frames:
continue
st = _scene_stats(frames[(name, t)])
el = float(np.interp(t, geo["NantianOP"]["t"], geo["NantianOP"]["el"])) if geo else float("nan")
print(" %-8s %8.0f %9.1f %12.4g %12.4g %12.4g"
% (key, t, el, st["bg"], st["peak"],
st["peak"] / st["bg"] if st["bg"] > 0 else float("nan")))
print()
print(" 怎麼讀這張表 —— 而且要注意它推翻了一個很自然的猜測:")
print(" 1. **背景其實很穩定。** 這四個時刻的仰角是 60.8 - 75.4 度,都很高,")
print(" 空氣質量從 1.15 只變到 1.03,所以背景只變 10-25%,不是好幾個數量級。")
print(" (圖之所以會糊掉,原因**不是**背景擺盪,是目標只佔畫面不到 1%")
print(" 又用絕對值當色階 —— 猜錯原因就會修錯地方。)")
print(" 2. **變好幾個數量級的是目標。** MWIR 的峰值/背景從 2.6e6 掉到 303,")
print(" LWIR 從 1815 掉到 10.0 —— 那是尾焰熄火,不是背景在動。")
print(" 3. **CO2 那一列的峰值/背景是 1.000。** 不是接近 1,是等於 1:")
print(" 整格畫面就是天空背景,目標一點都沒進來。這是本章最乾淨的一個數字。")
print(" 要看背景真的隨仰角變,得取更低的仰角(例如 t=0-5 s,仰角 <5 度),")
print(" 但那時候目標還在地平線下 —— 這正好是 detected() 要擋掉的區間。")
montage(frames, [(n, k) for n, _p, k in show], times,
os.path.join(HERE, "eoir_scenes.png"))
return frames
def _scene_stats(img, half=90):
"""把一張場景原始資料拆成「背景」與「目標」兩個量。
這是本章成像那一段唯一重要的一件事:**整格畫面幾乎都是背景**,
尾焰只佔幾十個像素。所以要分開量:
bg 背景輻射亮度(全畫面中位數)—— 天空 + 大氣自身輻射
peak 目標峰值輻射亮度
crop 以最亮像素為中心裁出來的窗,才看得到目標
這張圖有兩個很容易犯的錯:
1. 直接畫整張 820x1024,尾焰不到 1% 的面積,什麼都看不到。
2. 用整張的分位數當色階,而**背景在 90 秒內擺盪好幾個數量級**,
於是色階被最亮的那一格拉爆,其餘全部糊掉。
背景會擺盪是有物理原因的(見 part_g 印出來的表),不是畫壞了 ——
所以正確的作法不是把它normalize掉當沒看到,而是**把它量出來寫在圖上**。
"""
a = np.asarray(img, dtype=float)
a = np.where(np.isfinite(a), a, np.nan)
bg = float(np.nanmedian(a))
peak = float(np.nanmax(a))
r, c = np.unravel_index(int(np.nanargmax(a)), a.shape)
r0, r1 = max(0, r - half), min(a.shape[0], r + half)
c0, c1 = max(0, c - half), min(a.shape[1], c + half)
return dict(bg=bg, peak=peak, crop=a[r0:r1, c0:c1], full=a)
def _crop_contrast(img, half=90):
"""回傳 (對背景的對比度裁切窗, 背景中位數)。動畫那邊也用這一個。"""
st = _scene_stats(img, half=half)
bg = st["bg"] if (np.isfinite(st["bg"]) and st["bg"] > 0) else (abs(st["peak"]) or 1.0)
return np.clip(st["crop"] / bg, 1e-3, None), bg
def montage(frames, panels, times, out_path):
"""列 = 波段,行 = 時間。
每一格畫的是**裁切到目標附近、除以該格背景中位數**之後的對比度,
色階在同一列(同一個波段)內真的共用 —— `vmin/vmax` 明確傳給 imshow,
不傳的話 matplotlib 會每格各自拉伸,看起來就會是「固定色階卻
每格背景都不一樣」。
每一格的標題同時給出**背景**與**峰值**的絕對值。除以背景是為了讓四個時刻
可以並排比較,但**除掉的東西要寫回畫面上**,否則讀者無從判斷變的是目標
還是背景 —— 實測顯示這四個時刻的背景只變 10-25%,變好幾個數量級的是目標。
右下角的小圖是同一時刻的全視場,**用同一組 vmin/vmax**。
讓 imshow 自己正規化的話,CO2 那種近乎均勻的畫面會被拉滿白,
剛好和它要講的相反。
"""
names = [n for n, _k in panels]
keys = dict(panels)
fig, ax = plt.subplots(len(names), len(times),
figsize=(3.2 * len(times), 3.4 * len(names)), squeeze=False)
blank = 0
cells = []
for r, name in enumerate(names):
stats = {t: _scene_stats(frames[(name, t)])
for t in times if (name, t) in frames}
if stats:
allv = np.concatenate([
np.log10(np.clip(st["crop"] / (st["bg"] if st["bg"] > 0 else 1.0),
1e-3, None)).ravel() for st in stats.values()])
vmin, vmax = 0.0, max(float(np.nanpercentile(allv, 99.99)), 0.3)
else:
vmin, vmax = 0.0, 1.0
for c, t in enumerate(times):
a = ax[r][c]
a.set_xticks([])
a.set_yticks([])
if t not in stats: # stats 的 key 是 t,不是 (name, t)
a.text(0.5, 0.5, "n/a", ha="center", va="center")
blank += 1
continue
st = stats[t]
bg = st["bg"] if st["bg"] > 0 else 1.0
a.imshow(np.log10(np.clip(st["crop"] / bg, 1e-3, None)),
cmap="inferno", vmin=vmin, vmax=vmax)
state = "ON" if t <= PLUME_OFF else "OFF"
# **band 一律用波段 key(CO2BND),不要用顯示名切出來的字串。**
# 寫成 name.split("-")[-1] 會得到 "CO2",而動畫的 sidecar 用的是
# key "CO2BND" —— 兩個 sidecar 的欄位語彙不一致,驗證段用 "CO2BND"
# 去查合成影像的 cells 就查到空清單,那一項直接 FAIL。
# 同一個概念在不同檔案裡要用同一個名字,否則跨檔案的檢查會假失敗。
cells.append(dict(band=keys[name], t=float(t),
peak=float(st["peak"]), bg=float(st["bg"]),
ratio=float(st["peak"] / bg)))
a.set_title("%s t=%.0f s plume %s\n"
"peak %.3g bg %.3g ratio %.3g"
% (keys[name], t, state,
st["peak"], st["bg"], st["peak"] / bg), fontsize=8)
# 右下角插圖:同一時刻的全視場,提供脈絡。
#
# **插圖必須跟主圖用同一個量、同一組色階。**
# 讓 imshow 自己 normalize 全視場的絕對輻射亮度的話,CO2 t=10
# 那一格(整片幾乎均勻、動態範圍只有 1.7 倍)會被拉伸到滿白 ——
# 於是「CO2 什麼都看不到」那一列裡,偏偏有一格的全視場看起來最亮,
# 剛好和這一列要講的相反。**自動正規化在均勻影像上一定會騙人。**
ins = a.inset_axes([0.66, 0.02, 0.32, 0.32])
ins.imshow(np.log10(np.clip(st["full"] / bg, 1e-3, None)),
cmap="gray", vmin=vmin, vmax=vmax)
ins.set_xticks([])
ins.set_yticks([])
if st["peak"] / bg < 1.05:
# 整片均勻:明講,不要讓讀者從一片灰自己猜。
ins.text(0.5, 0.5, "uniform", transform=ins.transAxes,
ha="center", va="center", fontsize=6, color="cyan")
for sp in ins.spines.values():
sp.set_edgecolor("cyan")
sp.set_linewidth(0.8)
fig.suptitle("Ground telescope EOIR scene — cropped to target, divided by that "
"frame's own background median\n"
"(colour scale shared across the row; peak / bg printed in "
"W/m^2/sr; inset = full 0.34 deg field)", fontsize=10.5)
# **一張全是 n/a 的圖不算「成功產出」。**
#
# 這個檢查是被咬出來的:`montage()` 有一次因為索引寫錯(stats 用 t 當 key,
# 卻拿 (name, t) 去查),12 格全部走到 n/a 分支。程式跑完、退出碼 0、
# 圖也存出來了、每一格都畫了框、標題都在 —— **只是裡面沒有東西**。
#
# 失敗方式是「空產出」而不是「報錯」,跟 t=0 那個 DETECTED 是同一族:
# 程式跑完了、產出了、看起來像成功。所以要主動把它變成錯誤。
total = len(names) * len(times)
if blank == total:
raise RuntimeError(
"montage:%d 格全部是 n/a,沒有任何影像被畫進去 —— 不存這張空圖。"
% total)
if blank:
print(" ⚠ 有 %d/%d 格沒有影像" % (blank, total))
# 把每一格的統計寫成 sidecar,讓驗證段檢查**內容**而不是檔案大小。
# 檔案大小只能區分「空圖 vs 有圖」,分不出「有圖 vs 圖是對的」。
side = os.path.splitext(out_path)[0] + "_stats.json"
with open(side, "w", encoding="utf-8") as fh:
json.dump({"cells": cells, "blank": blank, "total": total}, fh,
ensure_ascii=False, indent=1)
print(" 每格統計 ->", os.path.basename(side))
fig.subplots_adjust(left=0.03, right=0.97, top=0.86, bottom=0.03,
wspace=0.08, hspace=0.30)
fig.savefig(out_path, dpi=120)
print(" 合成影像對照圖 -> %s(%d/%d 格有影像)"
% (os.path.basename(out_path), total - blank, total))
def plot(data, geo, lla, arc, pw, path):
fig, ax = plt.subplots(2, 3, figsize=(18.0, 9.0))
col = {"NantianOP": "C0", "DahanOP": "C1"}
a = ax[0][0]
a.plot(lla["Time"], lla["Alt"], lw=1.8, color="C3", label="STK Missile (ballistic)")
a.plot(pw["t"], pw["alt"], lw=1.8, ls="--", color="C2", label="powered ascent model")
a.axvspan(PLUME_ON, PLUME_OFF, color="C3", alpha=.10)
a.set_xlim(0, T_STOP)
a.set_xlabel("time from launch (s)")
a.set_ylabel("altitude (km)")
a.set_title("A. altitude: analytic ballistic vs powered ascent")
a.legend(fontsize=8)
a.grid(alpha=.3)
a = ax[0][1]
for site in ("NantianOP", "DahanOP"):
g = geo[site]
a.plot(g["t"], g["el"], lw=1.6, color=col[site], label=site)
a.axhline(B.EL_MIN, ls="--", c="k", lw=1, label="elevation limit %.0f deg" % B.EL_MIN)
a.axvspan(PLUME_ON, PLUME_OFF, color="C3", alpha=.10)
a.set_xlabel("time from launch (s)")
a.set_ylabel("elevation (deg)")
a.set_title("B. look elevation")
a.legend(fontsize=8)
a.grid(alpha=.3)
a = ax[0][2]
for site in ("NantianOP", "DahanOP"):
g = geo[site]
a.semilogy(g["t"], np.clip(g["om"], 1e-3, None), lw=1.6, color=col[site], label=site)
a.axvspan(PLUME_ON, PLUME_OFF, color="C3", alpha=.10)
a.set_xlabel("time from launch (s)")
a.set_ylabel("line-of-sight rate (deg/s)")
a.set_title("C. tracking rate requirement")
a.legend(fontsize=8)
a.grid(alpha=.3, which="both")
a = ax[1][0]
for name, _p, _k, _s in B.SENSORS:
d = data.get(name)
if d is None:
continue
a.semilogy(d["Time"], np.clip(d["Signal to noise ratio"], 1e-3, None),
lw=1.5, label=name)
a.axhline(SNR_MIN, ls="--", c="k", lw=1)
a.axvspan(PLUME_ON, PLUME_OFF, color="C3", alpha=.10)
a.set_xlabel("time from launch (s)")
a.set_ylabel("SNR")
a.set_title("D. detection SNR by band and site")
a.legend(fontsize=7)
a.grid(alpha=.3, which="both")
a = ax[1][1]
lam = np.linspace(0.4, 14.0, 900)
for T, c in [(PLUME_T, "C3"), (B.BODY_T, "C2"), (280.0, "C0")]:
x = H_PLANCK * C_LIGHT / (lam * 1e-6 * K_BOLTZ * T)
a.semilogy(lam, (2 * H_PLANCK * C_LIGHT ** 2 / (lam * 1e-6) ** 5) / np.expm1(x),
lw=1.5, color=c, label="%.0f K" % T)
for key, c in [("MWIR", "C4"), ("CO2BND", "C5"), ("LWIR", "C6"), ("VIS", "C7")]:
b = B.BANDS[key]
a.axvspan(b["LowBandEdgeWL"], b["HighBandEdgeWL"], color=c, alpha=.18)
a.set_ylim(1e2, 1e14)
a.set_xlabel("wavelength (um)")
a.set_ylabel("spectral radiance (W/m^2/sr/m)")
a.set_title("E. Planck curves and the four bands")
a.legend(fontsize=8)
a.grid(alpha=.3, which="both")
a = ax[1][2]
els = np.linspace(2, 90, 300)
FLOOR = 1e-6
# 三個大氣窗波段的曲線幾乎重合(0.78 / 0.79 / 0.80),用線型區分,
# 否則畫出來只看得到最後畫的那一條。
for key, c, ls in [("MWIR", "C4", "-"), ("LWIR", "C6", "--"),
("VIS", "C7", ":"), ("CO2BND", "C5", "-")]:
tau = atm_transmittance(key, B.NEAR_LLA[2], 100.0, els)
a.semilogy(els, np.clip(tau, FLOOR, None), lw=2.0 if key == "CO2BND" else 1.5,
color=c, ls=ls, label=key)
a.axhline(FLOOR, ls=":", c="0.5", lw=1)
# **註解的數字自己算出來,不要寫死。**
# 把 CO2 的量級寫死成 "~1e-40"(k0=20 時的值)之後,k0 一旦校準成 1.53,
# 天頂穿透率變成 5.2e-6,註解就地過期 —— 而圖上的曲線是對的,只有字是錯的。
# 這是本章一直在講的那件事的又一個變種,所以改成從模型直接取值。
z = float(atm_transmittance("CO2BND", B.NEAR_LLA[2], 100.0, 90.0))
lo5 = float(atm_transmittance("CO2BND", B.NEAR_LLA[2], 100.0, 5.0))
a.annotate("CO2: %.1e at zenith, %.0e at el=5 deg\n(flat line = clipped at the "
"1e-6 floor)" % (z, lo5),
xy=(40, FLOOR), xytext=(14, 6e-5), fontsize=7.5, color="C5",
arrowprops=dict(arrowstyle="->", color="C5", lw=0.9))
a.axvline(B.EL_MIN, ls="--", c="k", lw=1)
a.set_ylim(FLOOR * 0.5, 1.5)
a.set_xlabel("elevation angle (deg)")
a.set_ylabel("slant-path transmittance")
a.set_title("F. own Beer-Lambert model (target at 100 km)")
a.legend(fontsize=8, loc="lower right")
a.grid(alpha=.3, which="both")
fig.tight_layout()
fig.savefig(path, dpi=130)
# ---- sidecar:主圖也要有機器可比對的數字 ----
#
# 為什麼:這一輪踩到「判準改了、圖沒改」的疑慮 —— 而且**光看檔案時間戳
# 無法判斷一張圖是不是過期**,因為「哪些改動會影響這張圖」要讀完 plot()
# 才知道。把圖所依據的關鍵量寫成 sidecar,驗證段就能重算一次再比對:
# 數字對不上 = 這張圖是用舊程式碼畫的,該重生。
#
# 注意這裡存的是「圖所依據的量」,不是「圖上畫了什麼」。
# 偵測拍數並沒有畫在圖上(panel D 只畫 SNR 曲線與門檻線),
# 但它和圖出自同一次執行、同一份資料 —— 所以它可以當那次執行的指紋。
stats = {"sensors": {}, "atm": {}}
for name, _p, key, site in B.SENSORS:
d = data.get(name)
if d is None:
continue
snr = d["Signal to noise ratio"]
det = detected(snr, d["Time"], _site_geo(geo, site))
stats["sensors"][name] = dict(
band=key, det_count=int(det.sum()), window_s=float(det.sum() * STEP),
snr_max_gated=float(np.nanmax(snr[det])) if det.any() else None)
for key in ("MWIR", "CO2BND", "LWIR", "VIS"):
stats["atm"][key] = dict(
zenith=float(atm_transmittance(key, B.NEAR_LLA[2], 100.0, 90.0)),
el5=float(atm_transmittance(key, B.NEAR_LLA[2], 100.0, 5.0)))
stats["apogee_km"] = float(np.nanmax(lla["Alt"]))
stats["tof_s"] = float(lla["Time"][np.isfinite(lla["Alt"])][-1])
stats["el_min"] = float(B.EL_MIN)
stats["snr_min"] = float(SNR_MIN)
stats["step_s"] = float(STEP)
side = os.path.splitext(path)[0] + "_stats.json"
with open(side, "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=1)
print("\n圖已存到 %s;統計指紋 -> %s"
% (os.path.basename(path), os.path.basename(side)))
def main():
# 機器上可能有多個 STK 實例,一律指定 pid。理由見 eoir_build.attach()。
app = STKDesktop.AttachToApplication(pid=B.cli_pid())
root = app.Root
if root.CurrentScenario is None:
print("沒有開啟的場景。請先執行 python eoir_build.py")
return 1
if root.CurrentScenario.InstanceName != B.SCENARIO:
print("目前場景是 %r,不是 %r。請先執行 python eoir_build.py"
% (root.CurrentScenario.InstanceName, B.SCENARIO))
return 1
geo, lla, arc, pw = part_a(root)
# --- 只重畫合成影像。Part A-F 的輻射鏈掃描要跑二十幾分鐘,
# 光是為了調一張圖的配色而整段重跑是浪費。 ---
if "--scenes-only" in sys.argv:
part_g(root, geo)
return 0
data = {}
for name, path, key, _site in B.SENSORS:
try:
data[name] = fetch(root, path, B.BANDS[key]["BandName"])
except Exception as exc:
print(" %-14s 取資料失敗:%s" % (name, str(exc)[:70]))
# --figs-only:只重畫兩張圖。Part C 會把整組 fetch 再做一次(大氣開/關),
# 佔掉一半以上的時間,而它的輸出不進圖裡 —— 純調圖時沒必要重跑。
figs_only = "--figs-only" in sys.argv
if figs_only:
part_g(root, geo)
plot(data, geo, lla, arc, pw, os.path.join(HERE, "eoir_detect.png"))
return 0
part_b(data)
part_c(root, data, geo, lla)
part_d(data, geo)
part_e(root, geo)
part_f(data, geo)
part_g(root, geo)
plot(data, geo, lla, arc, pw, os.path.join(HERE, "eoir_detect.png"))
print()
print("=" * 78)
print("結論")
print("=" * 78)
print(" 1. 地面望遠鏡與天基感測器的根本差別不是距離,是**大氣站在哪一邊**。")
print(" 天基:大氣擋掉背景,幫你提高對比度 -> 選 CO2 吸收帶。")
print(" 地面:大氣擋在你和目標之間 -> CO2 吸收帶完全不能用,要選大氣窗。")
print(" 2. 近站(南田)看得清楚但追得辛苦;遠站(大漢山)追得輕鬆但看不到點火。")
print(" 這組取捨沒有標準答案,取決於你要回答的是「有沒有點火」還是「飛得多準」。")
print(" 3. 尾焰熄火那一刻,訊號掉好幾個數量級。地面光學能給的是")
print(" **助推段的高精度角度量測**,不是全程追蹤。")
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# ==============================================================================
# 地面望遠鏡追蹤探空火箭尾焰(EOIR)— 場景建構 Connect 腳本
#
# 情境:旭海科研火箭發射場發射一枚兩節式固態探空火箭,
# 台東與屏東山區的兩個地面光學/紅外觀測點同時追蹤它的尾焰。
#
# 這支腳本負責 Connect 做得到的部分:
# 場景、發射場與兩個觀測點的 Facility、彈道、EOIR 外形與尾焰、
# 望遠鏡感測器的建立與指向、EOIR 目標設定與大氣模式。
#
# Connect 做不到、留給 eoir_build.py 的部分:
# 1. EOIR 感測器的波段與光學參數。`ModifyBand` 在 STK 12.10 一律 NACK,
# 只能走物件模型的 sensor.Pattern.Bands.Item(0)。
# 2. 處理層級 (ProcessingLevel)。不能用第二次 `Define ... EOIR` 去改 ——
# 那會把整個波段定義靜默重設回可見光預設值。
#
# 執行:python eoir_build.py
#
# ------------------------------------------------------------------------------
# 座標與數字的來源(可查證,詳見 EOIR_Primer.md 第 6 節):
# 發射場 旭海科研火箭發射場(國科會短期科研探空火箭發射場域),
# 屏東縣牡丹鄉旭海村牡丹灣,22°10'29.42"N 120°53'33.25"E,2022-01 啟用。
# 火箭 以國家太空中心「探空火箭」系列為藍本:全長 7.7 m、質量 1680 kg、
# 兩節固態、設計射高 200–300 km。探空十號(2014-10-07,九鵬基地)
# 第一節 6 s 燒盡分離、第二節 12 s 點火 43 s 燒盡、
# 最高點 286 km、飛行 520 s。
# 觀測點 A 南田(台東縣達仁鄉),鄰近南田太空港既有追蹤天線與氣象站場址。
# 觀測點 B 大漢山(屏東縣春日鄉/台東縣達仁鄉界山),空軍雷達站海拔 1688 m。
# ==============================================================================
# ---- 場景 --------------------------------------------------------------------
# 時間刻意選在當地夜間(UTC+8 = 22:00)。地面光學觀測的第一個工程限制不是
# 感測器,是太陽:白天的天空散射背景會把可見光波段整個淹掉,MWIR 也會被
# 太陽反射與熱背景抬高好幾個數量級。這一點在天基場景裡不存在(對地看的背景
# 是地球),所以是本章與舊章節最先分岔的地方。
New / Scenario EOIRSoundingRocket
SetAnalysisTimePeriod * "7 Oct 2024 14:00:00.000" "7 Oct 2024 14:12:00.000"
SetUnits / km
# 不要去抓 AGI 的線上地形伺服器:沒網路時它會安靜地退回橢球體,
# 而「有沒有地形」正好會改變本章第 4 個工程問題(地形遮蔽)的答案。
# 本章的地形遮蔽改用 Python 端的幾何近似計算,不依賴地形資料是否安裝。
TerrainServer / UseAgiServer No
# ---- 發射場 ------------------------------------------------------------------
# 高度單位是 SetUnits 指定的 km。10 m 要寫成 0.010。
New / */Facility XuhaiPad
SetPosition */Facility/XuhaiPad Geodetic 22.174839 120.892569 0.010
# ---- 觀測點 A:南田(近、低)-------------------------------------------------
# 基線 9.7 km,方位 179 度(火箭幾乎在正南方升空)。海拔 45 m。
# 選這裡的理由:台26 線南端的海岸台地,正東到正南是太平洋 ——
# 紅外背景是海面與夜空,沒有城鎮熱源;而且南田太空港場址本來就有追蹤天線。
# 代價:基線短 -> 最大仰角 76 度,角速度需求高。
New / */Facility NantianOP
SetPosition */Facility/NantianOP Geodetic 22.26199 120.89034 0.045
# ---- 觀測點 B:大漢山(遠、高)-----------------------------------------------
# 基線 29.7 km,方位 151 度。海拔 1688 m —— 已經在邊界層之上,
# 天頂方向的可凝結水氣少掉大半,MWIR 天頂穿透率從 0.78 升到 0.88。
# 代價:中央山脈南段擋住低仰角,看不到點火那一刻(見 Part A)。
New / */Facility DahanOP
SetPosition */Facility/DahanOP Geodetic 22.40776 120.75124 1.688
# 仰角下限。5 度以下的斜路徑空氣質量超過 10 個天頂大氣,
# MWIR 穿透率掉到 0.08 —— 訊號被大氣吃掉一個數量級以上。
SetConstraint */Facility/NantianOP ElevationAngle Min 5.0
SetConstraint */Facility/DahanOP ElevationAngle Min 5.0
# ---- 探空火箭:彈道 ----------------------------------------------------------
# STK 的 Missile 物件是**解析彈道**:給發射點、落點,再從四個彈道控制選項
# 挑一個當第三個條件,STK 解出兩體彈道弧。四個選項是
# TOF <秒> 固定飛行時間
# ApogeeAlt <距離> 固定最高點高度 <- 本章用這個
# DeltaV <速度> 固定瞬時推力(給 0 則求最小 DeltaV 解)
# DeltaVMinEcc 最小偏心率解
#
# 選 ApogeeAlt 的理由:286 km 是探空十號**實際飛出來的、可查證的**數字。
# 把它當輸入,飛行時間就變成 STK 的輸出 —— 於是我們可以用自己寫的
# 兩體彈道解獨立預測它(Part A 預測 501.9 s),再跟 STK 比。
# 用 TOF 當輸入就沒有這個交叉驗證的機會了。
#
# **這個模型的界線要先講清楚**:Missile 沒有內彈道,它在 t=0 就有全部的速度。
# 真實的探空火箭要燒 43 秒才到那個速度。所以:
# - 燒盡之後(t > 43 s)的彈道:兩者幾乎一樣,可以直接用。
# - 助推段(t < 43 s)的高度、速度與角速度:STK 會明顯高估。
# 底下的 LaunchVehicle 物件就是拿來量化這個差距的參考航跡。
New / */Missile SR10
Missile */Missile/SR10 Trajectory "7 Oct 2024 14:00:00.000" 1.0 LnLatGeod 22.174839 120.892569 0.010 ApogeeAlt 286.0 ImLatGeod 21.17395 122.41469 0.0
# ---- 助推段參考航跡(不掛 EOIR,只當幾何對照)-------------------------------
# LaunchVehicle 只有 SimpleAscent 一種軌跡模型,而且**沒有 apogee、也沒有
# 燃燒時間可以設**(說明檔實測:兩個關鍵字都不存在)。你能給的只有
# 「發射點 + 燒盡點 + 燒盡速度」,上升段由 STK 自己內插。
#
# 語法陷阱:燒盡速度寫在**燒盡經緯度前面**,而且單位是 Connect 距離單位/秒,
# 也就是 km/s。寫成 2112(m/s 的數字)一樣會 ACK,只是會得到一枚
# 光速三倍的火箭。
#
# 燒盡狀態取自 Python 的動力上升模型(見 EOIR_Primer.md 第 2.3 節):
# t = 43 s、22.13111 N 120.95997 E、高度 48.12 km、速度 2.112 km/s
New / */LaunchVehicle SR10_Boost
SetState */LaunchVehicle/SR10_Boost SimpleAscent "7 Oct 2024 14:00:00.000" "7 Oct 2024 14:00:43.000" 1.0 Geodetic 22.174839 120.892569 0.010 2.112 22.13111 120.95997 48.12
# ---- 火箭本體的 EOIR 外形 ----------------------------------------------------
# 距離單位是 Connect 的 km:全長 7.7 m -> 0.0077,半徑 0.21 m -> 0.00021。
# 寫成 7.7 / 0.21 一樣會 ACK,只是會得到一枚 7.7 公里長的火箭,
# 尾焰亮度直接差六個數量級。這是本章最容易寫錯、也最難察覺的一行。
#
# BodyTemperature 400 K:燒盡後箭體同時被氣動加熱過又在真空中輻射冷卻,
# 400 K 是一個折衷值。它決定「燒盡之後還看不看得到」的答案,
# 所以在 Part D 會拿它做參數掃描。
EOIR */Missile/SR10 Shape Modify Component1 Type Cylinder 0.0077 0.00021 Material WhiteThermalControl BodyTemperature 400
# ---- 尾焰 --------------------------------------------------------------------
# EOIR 把尾焰模型化成 99% 發射率的灰體(說明文件寫死,不可改),
# 用 Planck 函數 + 你給的溫度算輻射。你能調的只有溫度、相對寬、相對長。
#
# 2000 K:AP/HTPB 複合固態推進劑的燃燒室火焰溫度約 3000–3400 K,
# 噴嘴出口膨脹後降到約 1800–2200 K,再加上與空氣的後燃燒。
# 對一枚小型探空火箭取 2000 K 是合理的量級。
#
# OffTimeDelta 43:對應探空十號第二節 43 s 燒盡。
# **已知簡化**:真實火箭在 6–12 s 之間是節間滑行、沒有尾焰,
# STK 的單一 Stage Plume 表達不了這個空檔,所以本章把 0–43 s 當成連續燃燒。
# 這個簡化會讓 6–12 s 的訊號被高估 —— 實驗單有一題就是估這個誤差。
EOIR */Missile/SR10 Stage Plume Temperature 2000 OnTimeDelta 0 OffTimeDelta 43 RelativeWidth 6.0 RelativeLength 4.0
# ---- 望遠鏡感測器 ------------------------------------------------------------
# 五具 EOIR 感測器,實體上是同一副 0.40 m 口徑望遠鏡上的不同相機。
# 波段與光學參數在 eoir_build.py 用物件模型設定(Connect 的 ModifyBand 是壞的)。
#
# 南田的四個波段回答「波段選擇」這個工程問題:
# MWIR 3.7-4.1 um 大氣窗,對 2000 K 尾焰的對比度最高
# CO2BND 4.2-4.4 um 天基預警的黃金波段 —— 但從地面看是**不透明的**
# LWIR 8.0-12.0 um 大氣窗,但 280 K 的天空自己就在這裡發光
# VIS 0.45-0.75um 夜間可用,白天完全不行
# 大漢山只放 MWIR,單獨回答「站位」這個問題(近/低 vs 遠/高)。
New / */Facility/NantianOP/Sensor MWIR
New / */Facility/NantianOP/Sensor CO2BND
New / */Facility/NantianOP/Sensor LWIR
New / */Facility/NantianOP/Sensor VIS
New / */Facility/DahanOP/Sensor MWIR
# 指向:Targeted 追蹤,等同於一副理想的自動追蹤轉台。
# 「理想」是重點 —— 真實轉台的角速度與角加速度上限在 Part C 會用
# 幾何算出來的需求去檢查,STK 這裡不會替你把追不上的情況變成沒有 access。
Point */Facility/NantianOP/Sensor/MWIR Targeted Add Missile/SR10
Point */Facility/NantianOP/Sensor/MWIR Targeted Tracking Missile/SR10
Point */Facility/NantianOP/Sensor/CO2BND Targeted Add Missile/SR10
Point */Facility/NantianOP/Sensor/CO2BND Targeted Tracking Missile/SR10
Point */Facility/NantianOP/Sensor/LWIR Targeted Add Missile/SR10
Point */Facility/NantianOP/Sensor/LWIR Targeted Tracking Missile/SR10
Point */Facility/NantianOP/Sensor/VIS Targeted Add Missile/SR10
Point */Facility/NantianOP/Sensor/VIS Targeted Tracking Missile/SR10
Point */Facility/DahanOP/Sensor/MWIR Targeted Add Missile/SR10
Point */Facility/DahanOP/Sensor/MWIR Targeted Tracking Missile/SR10
# ---- EOIR 場景設定 -----------------------------------------------------------
# 目標要明確加進 EOIR 的目標清單,否則感測器「看得到」但 EOIR 不把它算進場景,
# SNR 會全部是 0 —— 不是 NaN,是 0,而且沒有任何錯誤訊息。
EOIR */ TargetConfig AddTarget Missile/SR10
# 大氣模式:0 關閉、1 簡單、2 MODTRAN。
# 本章用 Mode 1 當基準,再在 Part B 用 Mode 0/1 的比值把 STK 心目中的
# 穿透率反算出來,跟我們自己寫的 Beer-Lambert 模型比。
#
# **這是本章與天基章節最大的物理差別**:天基感測器往下看,大氣是「幫手」——
# 4.3 um 的 CO2 吸收帶把地表背景擋掉,尾焰浮在暗背景上。
# 地面望遠鏡往上看,同一條大氣是「敵人」—— 它整條擋在望遠鏡與尾焰之間。
# 同一個物理,兩個相反的工程結論。這就是本章存在的理由。
EOIR */ SetAtmosphere Mode 1
# 氣溶膠模型:0 rural / 1 urban / 2 maritime / 3 tropospheric。
# 觀測點在海岸台地與面海的山脊上,選 maritime。
# 海洋氣溶膠的粒徑大、吸濕性強,在 MWIR 與 LWIR 的散射比大陸型嚴重得多 ——
# 這正是「為什麼海邊的觀測站不見得比較好」的物理來源。
EOIR */ SetAtmosphere Aerosol 2
EOIR */ SetAtmosphere Visibility 23
EOIR */ SetAtmosphere Humidity 80
EOIR */ SetAtmosphere Clouds Off
# 註:說明檔只給了 `EOIR SetAtmosphere Mode 2` 一個範例,
# Aerosol / Visibility / Humidity / Clouds 只在參數表裡出現過,沒有範例。
# 所以這四行是照參數表拼出來的 —— 如果 build 的輸出出現 NACK,
# 那本身就是一個要記錄的發現,不要把它藏起來。
# ---- 偵測門檻(讓 STK 自己算偵測區間)---------------------------------------
# SetConstraint ... EOIRSNR 會把「SNR 夠不夠」變成 access 的一部分,
# 於是 access 區間就直接是偵測窗。這比事後在 Python 裡篩 SNR 好,
# 因為它會跟仰角約束一起被 STK 求交集。
SetConstraint */Facility/NantianOP/Sensor/MWIR EOIRSNR Min 6.0
SetConstraint */Facility/NantianOP/Sensor/CO2BND EOIRSNR Min 6.0
SetConstraint */Facility/NantianOP/Sensor/LWIR EOIRSNR Min 6.0
SetConstraint */Facility/NantianOP/Sensor/VIS EOIRSNR Min 6.0
SetConstraint */Facility/DahanOP/Sensor/MWIR EOIRSNR Min 6.0
# -*- coding: utf-8 -*-
# ================================================================
# 地面望遠鏡追蹤探空火箭尾焰 — 驗證段
#
# 「ACK 不等於生效」在這一章有四個層次:
#
# 第 1 層 指令被拒絕 有 NACK,最好抓
# 第 2 層 指令 ACK 但值沒進去 回讀就抓得到
# 第 3 層 值進去了,但**意義跟你想的不同** 只有回讀 + 換算才抓得到
# 例:ProcessingLevel = 3 你以為是 Sensor Output,其實是 Sensor Off;
# HalfAngle 給 0.17 你以為是度,其實 STK 當弧度收
# 第 4 層 設定全對,輸出還是空的 要檢查資料提供者有沒有吐出數字
# 例:PreData 給錯 -> 回 0 個 DataSet,不拋例外
#
# 本章多了一種第 3 層的新型態:**列舉值的順序跟 GUI 下拉選單不一樣**。
# 那是最惡劣的一種,因為程式碼看起來完全正確。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python eoir_verify.py
# 離開碼 0 = 全部通過,1 = 有項目沒生效
# ================================================================
import json
import math
import os
import sys
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
from stk_verify import Verifier # noqa: E402
import eoir_build as B # noqa: E402
from eoir_detect import (PLUME_OFF, PLUME_ON, PLUME_T, # noqa: E402
PLUME_LEN, PLUME_WID, BODY_LEN, BODY_DIA,
SNR_MIN, STEP, _site_geo, angular_rate,
atm_transmittance, detected,
ballistic_arc, fetch, fetch_optics,
look_angles, missile_lla, planck_band_radiance,
powered_ascent)
# ---------------------------------------------------------------------------
# 實測基準。
#
# 第一次連線跑完之後,把下面這些「量測量」換成實測值並收緊容差 ——
# 教材裡寫死的基準數字最容易悄悄過期。在那之前這裡刻意用**物理上界/下界**
# 而不是猜出來的點值:寧可讓檢查寬鬆,也不要讓學生對著一個假基準懷疑自己。
#
# 「設定回讀」類的項目不在此列 —— 那些是確定值,容差本來就該是 1e-9。
BASE = dict(
apogee_km=(281.0, 291.0), # STK 解出來的最高點應該就是我們給的 286
tof_s=(490.0, 515.0), # 自算 501.9 s;自轉讓它有幾秒的餘裕
planck_ratio=(0.30, 1.20), # 有大氣衰減,所以 <= 1;掉到 0.3 以下要查
planck_ratio_vacuum=(0.90, 1.10), # 關掉大氣後應該貼近 1
mwir_snr_min=20.0, # 助推段 MWIR 至少要遠遠超過偵測門檻
co2_over_mwir_max=1e-2, # CO2 波段的訊號應該比 MWIR 小兩個數量級以上
burn_drop_min=50.0, # 熄火前後的訊號落差至少 50 倍
)
# ---------------------------------------------------------------------------
def eoir_r(root, opt, path=None):
"""EOIR_R 的單值回讀。說明檔對某些選項的拼法有空格(例如 "Relative Length"),
但實測可用的是無空格版 —— 兩種都試,避免因為文件筆誤誤判成 FAIL。"""
for o in (opt, opt.replace("Relative", "Relative ")):
try:
r = root.ExecuteCommand("EOIR_R %s %s" % (path or B.TGT, o))
if r.Count:
return str(r.Item(0)).strip()
except Exception:
continue
return None
def num(s):
try:
return float(str(s).split()[0])
except (TypeError, ValueError, IndexError):
return float("nan")
# ---------------------------------------------------------------------------
def main():
print("--- 建場景 ---")
app, root, ack, nack = B.build(pid=B.cli_pid(), force="--force" in sys.argv)
v = Verifier(root)
# =====================================================================
# (1) 結構
# =====================================================================
v.expect_true("場景名稱正確",
root.CurrentScenario.InstanceName == B.SCENARIO,
"讀回 %r" % root.CurrentScenario.InstanceName)
for p in [B.PAD, B.NEAR, B.FAR, B.TGT, B.BOOST] + [s[1] for s in B.SENSORS]:
v.object_exists(p)
# 位置。Facility 的高度是本章一個真的會咬人的地方:
# Connect 距離單位是 km,45 m 要寫成 0.045。寫成 45 一樣 ACK。
for path, lla, label in [(B.PAD, B.PAD_LLA, "旭海發射場"),
(B.NEAR, B.NEAR_LLA, "南田觀測點"),
(B.FAR, B.FAR_LLA, "大漢山觀測點")]:
v.position(path, lla[0], lla[1], tol=1e-4)
try:
g = root.GetObjectFromPath(path).Position.QueryPlanetodetic()
v.expect("%s 高度" % label, g[2], lla[2], tol=1e-4, unit=" km")
except Exception as exc:
v.custom("%s 高度" % label, None, str(exc)[:50])
# 仰角約束
for path in (B.NEAR, B.FAR):
v.elevation_constraint(path, B.EL_MIN)
# =====================================================================
# (2) EOIR 波段與光學:把腳本設過的每個值讀回來
# =====================================================================
for name, path, key, _site in B.SENSORS:
want = B.BANDS[key]
try:
pat = root.GetObjectFromPath(path).Pattern
b = pat.Bands.Item(0)
except Exception as exc:
v.custom("%s 的 EOIR Pattern" % name, False, str(exc)[:60])
continue
v.expect_true("%s 波段名稱" % name, str(b.BandName) == want["BandName"],
"讀回 %r" % b.BandName)
v.expect("%s 波段低邊界" % name, b.LowBandEdgeWL, want["LowBandEdgeWL"],
tol=1e-6, unit=" um")
v.expect("%s 波段高邊界" % name, b.HighBandEdgeWL, want["HighBandEdgeWL"],
tol=1e-6, unit=" um")
v.expect("%s 入瞳直徑" % name, b.EntrancePDia, want["EntrancePDia"],
tol=1e-6, unit=" m")
v.expect("%s 有效焦距" % name, b.EffFocalL, want["EffFocalL"],
tol=1e-6, unit=" m")
v.expect("%s 積分時間" % name, b.IntegrationTime, want["IntegrationTime"],
tol=1e-9, unit=" s")
v.expect("%s 量子效率" % name, b.QEValue, want["QEValue"], tol=1e-9)
v.expect("%s 光學穿透" % name, b.OpticalTransmission,
want["OpticalTransmission"], tol=1e-9)
v.expect("%s 水平像素數" % name, b.HorizontalPixels,
want["HorizontalPixels"], tol=0)
# ---- 第 3 層:值進去了,但單位/意義對不對? ----
# 半視場角給純數字時 STK 當**弧度**收。如果 STK 其實把它當度數,
# 讀回來會是同一個數,但 IFOV 會差 57 倍 —— 所以要靠 F 數與像素間距
# 這條獨立的路徑去交叉檢查,不能只比對讀回值。
v.expect("%s 水平半視場角(度,跟隨 Angle 單位偏好)" % name,
b.HorizontalHalfAngle, want["HorizontalHalfAngle"],
tol=1e-6, unit=" deg")
v.expect_true("%s 半視場角是合理的望遠鏡視場(不是弧度誤植)" % name,
0.01 < float(b.HorizontalHalfAngle) < 1.0,
"%.5f deg(若誤把度數當弧度送,這裡會是 %.5f deg)"
% (float(b.HorizontalHalfAngle),
math.degrees(float(b.HorizontalHalfAngle))))
# STK 自己算出來的像素間距與 IFOV,是光學鏈唯一的獨立回讀路徑。
v.expect("%s 像素間距(STK 由視場+像素數反算,um)" % name,
b.HorizontalPP, want["PixelPitch_m"] * 1e6, tol=1e-3, unit=" um")
v.expect("%s 水平 IFOV(STK 回報 mrad)" % name,
b.HorizontalIFOV, want["IFOV_rad"] * 1e3, tol=1e-6, unit=" mrad")
# ---- ProcessingLevel:本章最貴的坑 ----
# eGeometricInput=0 / eRadiometricInput=1 / eSensorOutput=2 / eSensorOff=3
v.expect("%s 處理層級 = Sensor Output(列舉值 2,不是 3)" % name,
int(pat.ProcessingLevel), B.PROC_SENSOR_OUTPUT, tol=0)
v.expect_true("%s 處理層級不是 Sensor Off" % name,
int(pat.ProcessingLevel) != B.PROC_SENSOR_OFF,
"讀回 %d(3 = eSensorOff,感測器被關掉且沒有任何警告)"
% int(pat.ProcessingLevel))
# ---- 輸入模式 ----
v.expect("%s OpticalInputMode = 焦距+口徑" % name,
int(b.OpticalInputMode), B.OPT_FOCAL_AND_APERTURE, tol=0)
v.expect("%s SpatialInputMode = 視場+像素數" % name,
int(b.SpatialInputMode), B.SPAT_FOV_AND_NUMPIX, tol=0)
# =====================================================================
# (2b) SetConstraint EOIRSNR:ACK 了,但真的裝上去了嗎?
# =====================================================================
# 實測發現:`SetConstraint <sensor> EOIRSNR Min 6.0` 對一個**還不是 EOIR**
# 的感測器也照樣 ACK。也就是說「有沒有 NACK」完全無法區分
# 「約束裝好了」與「約束被丟掉了」。這是本章最典型的 ACK≠生效。
#
# 本章的 Connect 腳本把這幾行放在最後,而 `Define ... EOIR` 是 eoir_build.py
# 在 Connect 跑完之後才做的 —— 換句話說,腳本下這些約束的當下,
# 感測器**還不是 EOIR**。所以這一項不是形式檢查,是真的在賭。
for name, path, _key, _site in B.SENSORS:
try:
cons = root.GetObjectFromPath(path).AccessConstraints
names = [cons.Item(i).ConstraintName for i in range(cons.Count)]
except Exception as exc:
v.custom("%s 的 EOIRSNR 約束" % name, None, "讀不到約束清單:%s" % str(exc)[:50])
continue
hit = [n for n in names if "snr" in str(n).lower()]
if not hit:
v.expect_true("%s 裝上了 EOIRSNR 約束" % name, False,
"現有約束 %s —— SetConstraint ACK 了但沒生效" % names)
continue
got = None
for i in range(cons.Count):
if "snr" in str(cons.Item(i).ConstraintName).lower():
try:
got = float(cons.Item(i).Min)
except Exception:
got = None
if got is None:
v.custom("%s 的 EOIRSNR 門檻" % name, None, "約束在(%s),但讀不到 Min" % hit)
else:
v.expect("%s 的 EOIRSNR 門檻" % name, got, SNR_MIN, tol=1e-6)
# 場景重用之後,EOIR 的目標清單會不會累積重複?
# clear_children() 把 Missile 卸掉了,但 EOIR 的 TargetConfig 是**場景層級**的設定,
# 不是 Missile 的屬性 —— 兩者的生命週期不一定綁在一起。
try:
r = root.ExecuteCommand("EOIR_R */ TargetConfig")
v.custom("EOIR 目標清單可回讀", True, " / ".join(str(r.Item(i)) for i in range(min(r.Count, 3))))
except Exception:
v.custom("EOIR 目標清單可回讀", None,
"EOIR_R 沒有 TargetConfig 這個選項,只能靠 SNR 是否合理間接判斷")
# =====================================================================
# (3) 光學鏈的獨立驗證:STK 的 Rayleigh 解析度 vs 自算 1.22*lambda/D
# =====================================================================
for name, path, key, _site in B.SENSORS:
band = B.BANDS[key]
lam = 0.5 * (band["LowBandEdgeWL"] + band["HighBandEdgeWL"]) * 1e-6
own = 1.22 * lam / band["EntrancePDia_m"]
try:
o = fetch_optics(root, path)
fnum = float(o.get("F number", float("nan")))
v.expect("%s F 數(STK 由焦距/口徑自己算)" % name, fnum,
band["EffFocalL_m"] / band["EntrancePDia_m"], tol=0.02)
ray = o.get("Rayleigh resolution", None)
if ray is None or not np.isfinite(float(ray)):
v.custom("%s Rayleigh 解析度" % name, None, "資料提供者沒有這個元素")
else:
# ⚠ 這個 DP 用場景的角度單位(deg),1.22*lambda/D 是弧度。
# 忘了換算的話會差 57.3 倍 —— 而且兩邊都「看起來像個小數字」。
ray_rad = math.radians(float(ray))
v.expect("%s Rayleigh 解析度 = 1.22*lambda/D" % name,
ray_rad, own, tol=own * 0.02, unit=" rad")
except Exception as exc:
v.custom("%s EOIR Sensor Optics" % name, None, str(exc)[:60])
# =====================================================================
# (3b) IntegrationTime 的單位假設 —— 我們**沒有**釘死它
# =====================================================================
# 已確定:NEI 嚴格正比於 1/sqrt(IntegrationTime),responsivity 嚴格正比於它,
# 但沒有任何資料提供者用 SI 報這個量,運動模糊也不受它影響。
# 本章假設它是**毫秒**。把假設變成可執行的檢查:
# 1. 縮放律要成立(單位錯的話這一項照樣過 —— 它只驗模型形狀)
# 2. NEI 的**絕對量級**要像一台真的 MWIR 相機(單位錯的話這一項會 FAIL)
for name, path, key, _site in B.SENSORS:
if key != "MWIR":
continue
try:
sen = root.GetObjectFromPath(path)
band = sen.Pattern.Bands.Item(0)
base = float(band.IntegrationTime)
perf = {}
for mult in (1.0, 4.0):
band.IntegrationTime = base * mult
rr = sen.DataProviders.Item("EOIR Sensor Performance").Exec()
perf[mult] = {rr.DataSets.Item(i).ElementName:
float(rr.DataSets.Item(i).GetValues()[0])
for i in range(rr.DataSets.Count)}
band.IntegrationTime = base
except Exception as exc:
v.custom("%s NEI 縮放律" % name, None, str(exc)[:60])
continue
# NEI 是 dBW/m^2:積分時間 x4 -> NEI 應該降 10*log10(2) = 3.01 dB
drop = perf[1.0]["Sensor NEI"] - perf[4.0]["Sensor NEI"]
v.expect("%s:積分時間 x4,NEI 降 3.01 dB(1/sqrt(t) 縮放律)" % name,
drop, 10 * math.log10(2.0), tol=0.05, unit=" dB")
nei_lin = 10 ** (perf[1.0]["Sensor NEI"] / 10.0)
v.expect_between("%s NEI 的絕對量級像一台真的 MWIR 相機" % name,
nei_lin, 1e-13, 1e-8, unit=" W/m^2")
v.custom("IntegrationTime 單位 = ms(假設,無內部錨)", None,
"設 %.3f -> NEI %.2f dBW/m^2;若單位其實是 s 或 us,"
"上面那項絕對量級會失守" % (base, perf[1.0]["Sensor NEI"]))
# =====================================================================
# (4) 目標:尾焰與外形
# =====================================================================
for opt, want, tol, label in [("Stage Temperature", PLUME_T, 1.0, "溫度"),
("Stage PlumeOnTimeDelta", PLUME_ON, 0.5, "點火"),
("Stage PlumeOffTimeDelta", PLUME_OFF, 0.5, "熄火"),
("Stage RelativeWidth", 6.0, 0.01, "相對寬"),
("Stage RelativeLength", 4.0, 0.01, "相對長")]:
got = eoir_r(root, opt)
v.expect("尾焰 %s" % label, num(got), want, tol=tol)
# 火箭本體尺寸:Connect 距離單位是 km,7.7 m 要寫成 0.0077。
# 寫成 7.7 一樣 ACK,只是會得到一枚 7.7 公里長的火箭,尾焰亮六個數量級。
shape = eoir_r(root, "Component1 Shape")
v.expect_true("火箭本體尺寸的量級正確(km 單位)",
shape is not None and "0.0077" in str(shape).replace(" ", ""),
"讀回 %r" % shape)
v.expect_true("火箭本體是圓柱",
shape is not None and "cylinder" in str(shape).lower(),
"讀回 %r" % shape)
body_t = eoir_r(root, "Component1 Temperature")
v.expect("箭體本體溫度", num(body_t), B.BODY_T, tol=1.0, unit=" K")
# =====================================================================
# (5) 彈道物理:與自寫的兩體解交叉驗證
# =====================================================================
arc = ballistic_arc(B.APOGEE_KM, 80.0)
try:
lla = missile_lla(root)
apo = float(np.nanmax(lla["Alt"]))
tof = float(lla["Time"][np.isfinite(lla["Alt"])][-1])
except Exception as exc:
v.custom("彈道回讀", False, str(exc)[:60])
lla, apo, tof = None, float("nan"), float("nan")
v.expect_between("STK 解出的最高點", apo, *BASE["apogee_km"], unit=" km")
v.expect_between("STK 解出的飛行時間(自算預測 %.1f s)" % arc["tof"],
tof, *BASE["tof_s"], unit=" s")
v.expect_true("自算的兩體解與 STK 的最高點一致",
abs(apo - arc["apo"]) < 5.0,
"自算 %.2f km,STK %.2f km,差 %+.2f km" % (arc["apo"], apo, apo - arc["apo"]))
# 助推段:Missile 沒有內彈道,所以它一定高估。
# 把「模型的界線」本身變成一個檢查項 —— 這樣模型哪天改了我們會知道。
pw = powered_ascent()
if lla is not None:
h_stk = float(np.interp(B.BURN_OFF, lla["Time"], lla["Alt"]))
h_pw = float(pw["alt"][-1])
v.expect_true("Missile 的解析彈道在助推段確實高估高度(模型界線)",
h_stk > 1.5 * h_pw,
"t=%.0f s:STK %.1f km vs 動力模型 %.1f km(高估 %.1f 倍)"
% (B.BURN_OFF, h_stk, h_pw, h_stk / max(h_pw, 1e-6)))
# =====================================================================
# (6) 視線幾何:望遠鏡真的看得到嗎、追得上嗎
# =====================================================================
geo = {}
if lla is not None:
for site, site_lla in [("NantianOP", B.NEAR_LLA), ("DahanOP", B.FAR_LLA)]:
rng, az, el = look_angles(site_lla, lla["Lat"], lla["Lon"], lla["Alt"])
geo[site] = dict(t=lla["Time"], rng=rng, az=az, el=el,
om=angular_rate(az, el, lla["Time"]))
v.expect_true("南田站的最大仰角比大漢山高(基線短 -> 更接近天頂)",
np.nanmax(geo["NantianOP"]["el"]) > np.nanmax(geo["DahanOP"]["el"]),
"南田 %.1f deg,大漢山 %.1f deg"
% (np.nanmax(geo["NantianOP"]["el"]), np.nanmax(geo["DahanOP"]["el"])))
v.expect_true("南田站的最大角速度比大漢山高(近站追得辛苦)",
np.nanmax(geo["NantianOP"]["om"]) > np.nanmax(geo["DahanOP"]["om"]),
"南田 %.2f deg/s,大漢山 %.2f deg/s"
% (np.nanmax(geo["NantianOP"]["om"]), np.nanmax(geo["DahanOP"]["om"])))
v.expect_between("南田站助推段的最大角速度", np.nanmax(geo["NantianOP"]["om"]),
0.2, 30.0, unit=" deg/s")
# 0.34 度的視場,配上這個角速度 -> 目標穿越整個畫面只要不到一秒。
fov_deg = 2 * B.BANDS["MWIR"]["HorizontalHalfAngle"]
cross = fov_deg / np.nanmax(geo["NantianOP"]["om"])
v.expect_true("目標穿越 MWIR 視場的時間 < 5 s(所以必須閉迴路追蹤)",
cross < 5.0, "%.2f s" % cross)
# =====================================================================
# (7) 資料提供者真的吐出數字
# =====================================================================
# PreData 給錯不會拋例外,只會回 0 個 DataSet。fetch() 內部檢查並拋錯,
# 這裡把「拋不拋錯」本身變成一個檢查項。
data = {}
for name, path, key, _site in B.SENSORS:
try:
data[name] = fetch(root, path, B.BANDS[key]["BandName"])
ok, detail = True, "%d 個取樣點" % len(data[name]["Time"])
except Exception as exc:
ok, detail = False, str(exc)[:70]
v.custom("%s 的 EOIR 資料提供者有回資料" % name, ok, detail)
for name in data:
snr = data[name]["Signal to noise ratio"]
nz = np.isfinite(snr) & (snr > 0)
v.expect_true("%s 的 SNR 不是一整排 0" % name, bool(nz.any()),
"非零 %d/%d(全 0 通常是漏了 EOIR TargetConfig AddTarget)"
% (nz.sum(), snr.size))
v.expect_true("%s 沒有殘留 STK 哨兵值 -9999.99" % name,
not np.any(np.isclose(np.nan_to_num(snr, nan=0.0), -9999.99, atol=0.01)),
"")
# =====================================================================
# (8) 輻射:與獨立算出的 Planck 值比對(本章最強的一道)
# =====================================================================
for name, path, key, _site in B.SENSORS:
band = B.BANDS[key]
lb = planck_band_radiance(PLUME_T, band["LowBandEdgeWL"], band["HighBandEdgeWL"])
v.expect_between("%s 的 Planck 頻帶輻射亮度量級" % name, lb, 1e2, 1e5,
unit=" W/m^2/sr")
d = data.get(name)
if d is None:
continue
t = d["Time"]
boost = (t >= PLUME_ON + 5) & (t <= PLUME_OFF - 5)
e = d["In-band target irradiance (linear)"][boost]
om = d["Target Solid Angle"][boost]
good = np.isfinite(e) & np.isfinite(om) & (om > 0) & (e > 0)
if not good.any():
v.custom("%s:STK 輻照度 / Planck 預測" % name, None, "助推段沒有有效取樣")
continue
ratio = float(np.median(e[good] / (lb * B.PLUME_EMISS * om[good])))
if key == "CO2BND":
# CO2 帶從地面看是不透明的,所以這個比值**應該**遠小於 1。
# 它不是 FAIL,它是本章的主要結論。
v.expect_true("Nantian-CO2:STK/Planck 比值遠小於 1(大氣不透明)",
ratio < 0.05, "比值 %.4g" % ratio)
else:
v.expect_between("%s:STK 輻照度 / Planck 預測" % name, ratio,
*BASE["planck_ratio"])
# 關掉大氣再比一次 —— 這一輪才是「純輻射計算」的乾淨比對。
root.ExecuteCommand("EOIR */ SetAtmosphere Mode 0")
try:
for name, path, key, _site in B.SENSORS:
if key == "CO2BND":
continue # 無大氣時 CO2 沒有特殊性,不重複檢查
band = B.BANDS[key]
lb = planck_band_radiance(PLUME_T, band["LowBandEdgeWL"], band["HighBandEdgeWL"])
d = fetch(root, path, band["BandName"])
t = d["Time"]
boost = (t >= PLUME_ON + 5) & (t <= PLUME_OFF - 5)
e = d["In-band target irradiance (linear)"][boost]
om = d["Target Solid Angle"][boost]
good = np.isfinite(e) & np.isfinite(om) & (om > 0) & (e > 0)
if not good.any():
v.custom("%s(無大氣):STK / Planck" % name, None, "沒有有效取樣")
continue
r = float(np.median(e[good] / (lb * B.PLUME_EMISS * om[good])))
v.expect_between("%s(關掉大氣):STK 輻照度 / Planck 預測" % name, r,
*BASE["planck_ratio_vacuum"])
# 大氣開/關的比值 = STK 心目中的穿透率
d1 = data.get(name)
if d1 is not None:
e1 = d1["In-band target irradiance (linear)"][boost]
g2 = good & np.isfinite(e1) & (e1 > 0)
if g2.any():
tau = float(np.median(e1[g2] / e[g2]))
v.expect_between("%s:STK 的斜路徑穿透率落在 0-1" % name, tau, 0.0, 1.0)
finally:
root.ExecuteCommand("EOIR */ SetAtmosphere Mode 1")
# =====================================================================
# (9) 物理判準:本章要回答的工程問題,逐條變成檢查
# =====================================================================
# 9a. MWIR 在助推段一定要看得到
d = data.get("Nantian-MWIR")
if d is not None and geo:
# **要用有套仰角門檻的 SNR。** 只取 np.nanmax 會抓到 t=0 那一筆 ——
# 那時候 SNR 高達 7e5,但仰角 -0.3 度,火箭還在發射台上、在地平線下面。
# 輻射鏈算的是「如果看得到會有多亮」,不負責回答「看不看得到」。
snr = d["Signal to noise ratio"]
ok = detected(snr, d["Time"], geo["NantianOP"])
v.expect_true("南田 MWIR 在助推段的最大 SNR 遠超過偵測門檻(已套仰角門檻)",
ok.any() and np.nanmax(snr[ok]) > BASE["mwir_snr_min"],
"可見樣本 %d/%d,其中最大 SNR %.4g(門檻 %.0f)"
% (ok.sum(), ok.size,
np.nanmax(snr[ok]) if ok.any() else float("nan"), SNR_MIN))
# 把「未套門檻會高估」本身也變成一個檢查項:t=0 那一筆必須被濾掉。
el0 = float(np.interp(d["Time"][0], geo["NantianOP"]["t"], geo["NantianOP"]["el"]))
v.expect_true("t=0 的取樣被仰角門檻濾掉(SNR 很高但在地平線下)",
(not ok[0]) if ok.size else False,
"t=%.0f s 仰角 %.2f deg,SNR %.4g -> %s"
% (d["Time"][0], el0, snr[0],
"已濾掉" if (ok.size and not ok[0]) else "**沒有濾掉**"))
# 9b. 地面看 CO2 帶應該幾乎沒有訊號 —— 本章的主要結論
dc = data.get("Nantian-CO2")
if d is not None and dc is not None:
bm = (d["Time"] >= PLUME_ON + 5) & (d["Time"] <= PLUME_OFF - 5)
em = np.nanmedian(d["In-band target irradiance (linear)"][bm])
ec = np.nanmedian(dc["In-band target irradiance (linear)"][bm])
v.expect_true("CO2 4.2-4.4 um 的訊號比 MWIR 窗小兩個數量級以上"
"(從地面看,天基預警的黃金波段是不透明的)",
np.isfinite(ec) and np.isfinite(em) and ec / em < BASE["co2_over_mwir_max"],
"CO2/MWIR = %.3g" % (ec / em if em else float("nan")))
# 9c. 自寫的大氣模型也必須給出同樣的結論(獨立路徑)
tau_co2 = float(atm_transmittance("CO2BND", B.NEAR_LLA[2], 100.0, 60.0))
tau_mwir = float(atm_transmittance("MWIR", B.NEAR_LLA[2], 100.0, 60.0))
v.expect_true("自寫的 Beer-Lambert 模型也說 CO2 帶不透明",
tau_co2 < 1e-6 < tau_mwir,
"tau(CO2)=%.3g,tau(MWIR)=%.3g" % (tau_co2, tau_mwir))
v.expect_between("自算 MWIR 天頂穿透率", float(atm_transmittance("MWIR", 0.045, 300.0, 90.0)),
0.6, 0.95)
v.expect_true("高海拔站的天頂穿透率比海岸站高(大漢山 1688 m)",
float(atm_transmittance("MWIR", B.FAR_LLA[2], 300.0, 90.0)) >
float(atm_transmittance("MWIR", B.NEAR_LLA[2], 300.0, 90.0)),
"大漢山 %.4f vs 南田 %.4f"
% (float(atm_transmittance("MWIR", B.FAR_LLA[2], 300.0, 90.0)),
float(atm_transmittance("MWIR", B.NEAR_LLA[2], 300.0, 90.0))))
v.expect_true("仰角 5 度時 MWIR 穿透率掉一個數量級以上",
float(atm_transmittance("MWIR", 0.045, 100.0, 5.0)) <
0.15 * float(atm_transmittance("MWIR", 0.045, 100.0, 90.0)),
"el=90 %.4f -> el=5 %.4f"
% (float(atm_transmittance("MWIR", 0.045, 100.0, 90.0)),
float(atm_transmittance("MWIR", 0.045, 100.0, 5.0))))
# 9d. LWIR 的問題是背景,不是穿透
lp = planck_band_radiance(PLUME_T, 8.0, 12.0)
ls = planck_band_radiance(280.0, 8.0, 12.0)
lpm = planck_band_radiance(PLUME_T, 3.7, 4.1)
lsm = planck_band_radiance(280.0, 3.7, 4.1)
v.expect_true("MWIR 對 280 K 天空背景的對比度比 LWIR 高兩個數量級以上",
(lpm / lsm) / (lp / ls) > 100.0,
"MWIR %.4g 倍 vs LWIR %.4g 倍" % (lpm / lsm, lp / ls))
# 9e. 熄火之後訊號要掉下來
if d is not None:
t = d["Time"]
snr = d["Signal to noise ratio"]
on = (t >= PLUME_ON + 5) & (t <= PLUME_OFF - 5)
off = t >= PLUME_OFF + 30
s_on = np.nanmedian(snr[on])
s_off = np.nanmedian(snr[off])
v.expect_true("尾焰熄火之後 MWIR 訊號掉 %.0f 倍以上" % BASE["burn_drop_min"],
np.isfinite(s_on) and (not np.isfinite(s_off) or
s_on / max(s_off, 1e-12) > BASE["burn_drop_min"]),
"燃燒中 %.4g -> 燒盡後 %.4g" % (s_on, s_off))
# 自己也估一次同樣的落差
drop = (planck_band_radiance(PLUME_T, 3.7, 4.1) /
planck_band_radiance(B.BODY_T, 3.7, 4.1)) * \
(PLUME_LEN * PLUME_WID) / (BODY_LEN * BODY_DIA)
v.expect_true("自算的熄火落差也在同一個量級", drop > BASE["burn_drop_min"],
"自算 %.3g 倍(輻射亮度比 x 投影面積比)" % drop)
# 9f. 目標在地面望遠鏡上是**被解析開的延伸目標**,不是點源
if d is not None:
px = d["Number of Target Pixels"]
boost = (d["Time"] >= PLUME_ON + 5) & (d["Time"] <= PLUME_OFF - 5)
v.expect_true("助推段目標佔的像素數 >> 1(延伸目標,不是點源)",
np.nanmedian(px[boost]) > 10.0,
"中位 %.1f px(對照:天基預警章節的 GEO 感測器只有 6.7 px)"
% np.nanmedian(px[boost]))
ifov = B.BANDS["MWIR"]["IFOV_rad"]
rng_km = np.nanmedian(d["Range to Target"][boost])
# 自算的是「外接矩形」的像素面積:尾焰長 x 寬,兩個方向各除以 IFOV。
own_px = (PLUME_LEN / (rng_km * 1000) / ifov) * (PLUME_WID / (rng_km * 1000) / ifov)
ratio = own_px / max(np.nanmedian(px[boost]), 1e-9)
# 實測 ratio ~ 7.5。原本這一項寫成 0.02 < ratio < 50 —— 那是一個**空的檢查**,
# 幾乎不可能失敗,等於把一個 7.5 倍的落差藏起來。改成釘在實測值附近:
# 外接矩形 vs 實際形狀:EOIR 的尾焰是「球接錐」,投影是三角形不是矩形,
# 這一項大約貢獻 2 倍。
# 剩下的約 3.7 倍是 STK 對 RelativeWidth/RelativeLength 的定義與我們的
# 假設不同,或 "Number of Target Pixels" 只算超過某個門檻的像素。
# **沒有查清楚就不要假裝查清楚了** —— 所以這裡釘住比值,並在說明裡寫出
# 兩個候選原因;哪天 STK 改了模型,這一項會 FAIL,那才是我們想要的行為。
v.expect_between("自算外接矩形 / STK 目標像素數(實測約 7.5,原因見註)",
ratio, 4.0, 12.0)
v.expect_true("STK 的目標像素數確實是延伸目標的量級(>> 1)",
np.nanmedian(px[boost]) > 10.0,
"STK %.1f px vs 自算外接矩形 %.1f px @ %.1f km"
% (np.nanmedian(px[boost]), own_px, rng_km))
# =====================================================================
# (10) 產出檢查:圖與動畫「有沒有內容」
# =====================================================================
# 為什麼這一組必須存在:
# `montage()` 有一次因為索引寫錯,12 格全部走進「沒有資料」的分支。
# 程式跑完、退出碼 0、圖檔存出來了、每一格都畫了外框、標題也都在 ——
# **只是裡面一格影像都沒有。** 失敗方式是「空產出」而不是「報錯」。
#
# `montage()` 內部現在會在全空時 raise,但那**只保護產生圖的那一次執行**。
# 驗證段這一組會在每一次跑 verify 時重新確認產出仍然是有內容的 ——
# 包括「上一次跑壞了、圖還留在磁碟上」這種情況。
#
# 為什麼不用檔案大小當判準:
# 空圖 33 KB、有資料 92 KB,這一組分得開;但 GIF 洗白版 197 KB、
# 修好版 220 KB,**差距太小,大小分不出來**。所以改看 sidecar 的內容。
# ---- 主圖:重算一次,跟 sidecar 比對 ----
# 這一組防的是「數字改了、圖沒改」。**檔案時間戳擋不住這個**,
# 因為「哪些改動會影響哪張圖」要讀完 plot() 才知道,而且很容易漏
# (判準改動不影響主圖,但 panel F 的註解改動影響了,兩者時間戳一樣)。
# 所以不看時間戳,直接拿當下的程式碼重算,跟畫圖那次留下的指紋比。
det_png = os.path.join(HERE, "eoir_detect.png")
det_json = os.path.join(HERE, "eoir_detect_stats.json")
v.expect_true("eoir_detect.png 存在", os.path.exists(det_png),
"%.0f KB" % (os.path.getsize(det_png) / 1e3)
if os.path.exists(det_png) else "缺檔")
if not os.path.exists(det_json):
v.custom("主圖統計指紋", None,
"沒有 sidecar —— 先跑 python eoir_detect.py --pid <PID>")
elif not (data and geo):
v.custom("主圖統計指紋", None, "這一輪沒有取到資料,無法比對")
else:
ds = json.load(open(det_json, encoding="utf-8"))
v.expect("主圖的仰角門檻與現在一致", ds.get("el_min"), B.EL_MIN, tol=1e-9)
v.expect("主圖的 SNR 門檻與現在一致", ds.get("snr_min"), SNR_MIN, tol=1e-9)
v.expect("主圖的取樣間隔與現在一致", ds.get("step_s"), STEP, tol=1e-9)
stale = []
for name, _p, key, site in B.SENSORS:
d = data.get(name)
rec = ds.get("sensors", {}).get(name)
if d is None or rec is None:
continue
now = int(detected(d["Signal to noise ratio"], d["Time"],
_site_geo(geo, site)).sum())
if now != rec["det_count"]:
stale.append("%s 圖 %d vs 現在 %d" % (name, rec["det_count"], now))
v.expect_true("主圖的偵測拍數與現在重算的一致(圖不是用舊程式碼畫的)",
not stale, "; ".join(stale) or "五具全部一致")
atm_bad = []
for key in ("MWIR", "CO2BND", "LWIR", "VIS"):
rec = ds.get("atm", {}).get(key)
if not rec:
continue
now = float(atm_transmittance(key, B.NEAR_LLA[2], 100.0, 90.0))
if abs(now - rec["zenith"]) > max(rec["zenith"] * 1e-6, 1e-12):
atm_bad.append("%s 圖 %.4g vs 現在 %.4g" % (key, rec["zenith"], now))
v.expect_true("主圖 panel F 的大氣參數與現在一致(k0 沒有被改過)",
not atm_bad, "; ".join(atm_bad) or "四個波段全部一致")
scenes_png = os.path.join(HERE, "eoir_scenes.png")
scenes_json = os.path.join(HERE, "eoir_scenes_stats.json")
v.expect_true("eoir_scenes.png 存在", os.path.exists(scenes_png),
"%.0f KB" % (os.path.getsize(scenes_png) / 1e3)
if os.path.exists(scenes_png) else "缺檔")
if not os.path.exists(scenes_json):
v.custom("合成影像每格統計", None,
"沒有 sidecar —— 先跑 python eoir_detect.py --pid <PID> --scenes-only")
else:
sc = json.load(open(scenes_json, encoding="utf-8"))
cells = sc.get("cells", [])
v.expect("合成影像沒有空格", sc.get("blank", -1), 0, tol=0)
v.expect_true("合成影像的格數正確", len(cells) == sc.get("total"),
"%d/%d" % (len(cells), sc.get("total", -1)))
best = max((c["ratio"] for c in cells), default=0.0)
v.expect_true("至少有一格真的看得到目標(peak/bg > 100)", best > 100.0,
"最大 peak/bg = %.4g" % best)
co2 = [c["ratio"] for c in cells if c["band"] == "CO2BND"]
v.expect_true("CO2 每一格的 peak/bg 都約等於 1(整格就是天空背景)",
bool(co2) and max(co2) < 1.05,
"CO2 peak/bg = %s" % ["%.4g" % x for x in co2])
mw = {c["t"]: c["ratio"] for c in cells if c["band"] == "MWIR"}
if 30.0 in mw and 90.0 in mw:
v.expect_true("MWIR 熄火後的 peak/bg 大幅下降",
mw[90.0] < mw[30.0] / 100.0,
"t=30 %.4g -> t=90 %.4g" % (mw[30.0], mw[90.0]))
gif = os.path.join(HERE, "eoir_ascent.gif")
gif_json = os.path.join(HERE, "eoir_ascent_stats.json")
v.expect_true("eoir_ascent.gif 存在", os.path.exists(gif),
"%.0f KB" % (os.path.getsize(gif) / 1e3)
if os.path.exists(gif) else "缺檔")
if not os.path.exists(gif_json):
v.custom("動畫每格統計", None,
"沒有 sidecar —— 先跑 python eoir_animate.py --pid <PID>")
else:
cells = json.load(open(gif_json, encoding="utf-8")).get("cells", [])
best = max((c["ratio"] for c in cells), default=0.0)
v.expect_true("動畫至少有一格真的看得到目標(peak/bg > 10)", best > 10.0,
"最大 peak/bg = %.4g(洗白的版本這裡會接近 1)" % best)
bad = [c for c in cells
if c["el"] < B.EL_MIN and c["state"] == "DETECTED"]
v.expect_true("仰角不足的畫格沒有被標成 DETECTED", not bad,
"違反 %d 格(例:%s)"
% (len(bad), bad[0] if bad else ""))
below = [c for c in cells if c["el"] < 0.0]
v.expect_true("地平線下的畫格被標成 BELOW HORIZON",
bool(below) and all("BELOW HORIZON" in c["state"] for c in below),
"%d 格在地平線下" % len(below))
co2det = [c for c in cells if c["band"] == "CO2BND" and c["state"] == "DETECTED"]
v.expect_true("CO2 波段在動畫裡從頭到尾沒有一格被判為偵測到",
not co2det, "被判偵測 %d 格" % len(co2det))
ok = v.report("地面望遠鏡 EOIR 驗證段")
# 把統計寫成 sidecar。展示頁與 README 的「N/M PASS」要從這裡取,
# **不要手打** —— 本章第 5 節那三個「敘述沒跟著資料走」的例子,
# 全部是手打數字造成的。這裡是同一個對策的最後一塊。
counts = dict(
total=len(v.rows),
npass=sum(1 for o, _l, _d in v.rows if o is True),
nfail=sum(1 for o, _l, _d in v.rows if o is False),
nskip=sum(1 for o, _l, _d in v.rows if o is None),
failed=[l for o, l, _d in v.rows if o is False],
skipped=[l for o, l, _d in v.rows if o is None])
with open(os.path.join(HERE, "eoir_verify_stats.json"), "w",
encoding="utf-8") as fh:
json.dump(counts, fh, ensure_ascii=False, indent=1)
print("統計 -> eoir_verify_stats.json(PASS %d / FAIL %d / SKIP %d)"
% (counts["npass"], counts["nfail"], counts["nskip"]))
print()
print("提醒:")
print(" * 第 (8) 組(與 Planck 比對)是本章最有價值的檢查 —— 它用一條完全")
print(" 獨立的計算路徑,同時驗證了 STK 的輻射計算與我們自己的理解。")
print(" * 第 (9b/9c) 組把「地面看不到 CO2 帶」這個結論同時用 STK 與自寫模型")
print(" 驗證了兩次。如果哪天只有其中一個變了,那就是有東西壞了。")
print(" * ProcessingLevel 那兩項在防的是一種特別惡劣的失敗:程式碼看起來完全")
print(" 正確,列舉值卻不是 GUI 下拉選單的順序。")
return 0 if ok else 1
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)