獨立章節 4 — 岸際雷達:TIREM 地形傳播與四種靜默失敗
tirem_coastal/TIREMCoastal_Lab_Handout.md
實驗單 — 岸際雷達:TIREM 地形傳播與海面雜波
獨立章節,不屬於 16 週立方衛星課程。
先讀 TIREMCoastal_Primer.md,再做本實驗。
工具:STK Radar + TIREM + 本機地形檔(NED_Maui.pdtt)+ Python
參考:AGI 訓練單元 Introduction to TIREM
本章有一個貫穿全篇的反例。「TIREM 算不出結果」是一個很容易得到、
而且會通過四十幾項驗證的錯誤結論,導論第 5 節專門處理它。
做這個實驗的時候,請一邊做一邊問:我憑什麼相信這個結果是真的?
0. 情境
茂宜島南岸(Kihei)的 X 頻段海岸監視雷達,面對一架貼海面進來、
最後繞到島東北方的低空目標 —— 那時候 Haleakala(3055 m)整個擋在電波路徑中間。
| 物件 | 設定 |
|---|---|
| 地形 | NED_Maui.pdtt(本機檔,關閉地形伺服器) |
CoastRadar |
20.765°N, −156.445°E(Kihei 岸際),天線高 30 m |
| 雷達 | 單基地,9.4 GHz,25 kW,2 m 拋物面,PRF 1500 Hz,脈寬 0.5 µs,16 脈衝 |
LowFlyer |
六點大圓航路,地形之上 150 m、150 m/s,RCS 5 m² |
| 大氣吸收 | TIREM 5.50 / TIREM 3.31 / ITU-R P676-13 / 關閉 |
| 雜波 | Single Point 幾何 + 常數反射係數 0.02 |
| 分析窗 | 12 分鐘,取樣 15 秒 |
航路刻意設計成兩段:
- 海面段 —— 目標在雷達正南方的開闊海面,視線暢通
- 遮蔽段 —— 目標繞到島東北方,Haleakala 擋在中間
注意地形檔的覆蓋範圍:NED_Maui.pdtt 只涵蓋 20.5–21.0 N / −156.5 – −156.0 E
(東茂宜 / Haleakala),不含西茂宜山脈,也不含 Lanai。
整個場景必須落在這個框內 —— 出框的代價是 STK 停住數十分鐘,而不是報錯。
tirem_build.py 會在連線之前先擋下來。
目標全程在水面上。擋住它的不是它腳下的地形,而是雷達與目標之間的山。
這一點很容易設錯:若讓目標跨過山脈,它會直接從山裡穿過去
(航路下方地形最高 926 m,目標最高只有 516 m,而 STK 沒有任何警告)。
1. 執行步驟
1. 開啟 STK 12 桌面版(**只開一個**;開兩個請往下看)
2. python tirem_build.py [--pid <PID>] 建場景 + 載地形 + 啟用 TIREM
3. python tirem_analysis.py [--pid <PID>] Part A-F + 圖
4. python tirem_verify.py [--pid <PID>] 驗證段(離開碼 0 = 全通過)
為什麼需要 --pid:AttachToApplication() 不指定 pid 時,
是從 Running Object Table 拿第一個註冊的實例 —— 不是最後開的、
也不是有場景的那一個。而這些腳本第一件事就是關掉現有場景。
所以 attach() 在偵測到多於一個 STK 實例時會中止並要求你指名。
不要跑的東西:tirem_terrain_bounds_demo.py 會讓 STK 停住數十分鐘。
它是附錄示範,要加 --yes-i-will-hang-stk 才會動。課堂上不要跑。
2. Part A — 幾何與地形剖面(佔 10%)
要回答的問題:
.pdtt在開闊海面上回的不是 0,是十幾公尺。那是什麼?
如果你用terrain_alt == 0當海陸判準,會得到什麼結論?- GreatArc 把高度參考設成 Terrain 之後,是不是整條航路都保持在地形之上?
自己用Terrain Alt與Alt相減檢查一次。為什麼光看場景畫面看不出來? - 雷達看目標的仰角很低。這對雜波的照射面積有什麼影響?
寫出面雜波面積與掠射角的關係。
3. Part B — TIREM 的五個前提(佔 30%,本章重點)
STK 說明列出五個前提:
- 場景要有本機地形檔(不吃地形伺服器)
- 物件必須以地形為高度參考(Place 要打開 Use Terrain Data;
載具航點的 Altitude Reference 要是 Terrain) - 物件不能離開地形檔的覆蓋範圍
- 收發兩端都要低於 30 km
- 頻率在 1 MHz – 40 GHz
Part B 把能安全執行的項目逐一關掉,看哪一個真的會讓 TIREM 失效。
要回答的問題:
- 第 2 項(Use Terrain Data、航點高度參考)關掉之後,TIREM 還算不算得出來?
數值差多少?這個結果和 STK 說明一致嗎? - 如果一個「硬性前提」關掉之後結果幾乎不變,那它到底是什麼?
你要怎麼修正自己對因果的理解? - 第 3 項為什麼不執行?讀
tirem_terrain_bounds_demo.py的檔頭。
STK 說明承諾在這個情況下會做什麼?實際上做了什麼? Radar Active Prop Models這個資料提供者告訴你什麼,是回讀設定值
告訴不了你的?用一句話說清楚兩者的差別。
加分題:導論第 5 節那個錯誤結論,是在 41 項驗證全部 PASS 的情況下得出的。
那 41 項驗的是什麼?要加上什麼樣的檢查,才可能抓到它?
4. Part C — 三個大氣模型對照(佔 10%)
關閉 / ITU-R P676-13 / TIREM 三者的衰減曲線。
要回答的問題:
- ITU-R P676 沿整條航路的變化很小,TIREM 變化很大。差別從哪裡來?
- 如果有人用 ITU-R P676 的結果宣稱「地形對這條鏈路沒有影響」,
他錯在哪裡? - 兩個 TIREM 版本(5.50 與 3.31)的差別有多大?這代表什麼?
5. Part D — 越過地平線(佔 25%)
打開 / 關閉幾何視線約束的對照,以及與單刀鋒繞射理論的獨立比對。
要回答的問題:
- 打開視線約束之後,遮蔽段發生了什麼?是「訊號變弱」還是「沒有資料」?
這兩者對後續分析的意義有什麼不同? - 為什麼 AGI 建議用 TIREM 時要關掉視線、地形遮罩、方位仰角遮罩?
- 注意:
LineOfSight約束只看橢球體,不含地形。
那要怎麼讓地形進到 access 判定?為什麼本章沒有走那條路? - 刀鋒繞射理論值與 TIREM 差多少?兩者應該一致嗎?
這個比對的用途是什麼? - 自己算一次:把山脊高度、d1、d2 代進
v的公式,
確認v = 0時得到 6.02 dB。
6. Part E — 地表電性與極化(佔 10%)
海水 (εr=80, σ=5 S/m) vs 陸地 (εr=15, σ=0.005 S/m) × 垂直 vs 水平極化。
要回答的問題:
- STK 說明警告:在海平面上用
.pdtt,TIREM 不會啟用海水傳播特性。
本章實測把地表電性手動改成海水值,結果差多少? - 如果差別很小,那句警告是錯的嗎?還是你讀漏了適用條件?
地表電性影響的是哪一種傳播機制?那個機制在 9.4 GHz 還重要嗎? - 同一句警告在 VHF 通訊鏈路上會不會是致命的?為什麼?
7. Part F — 海面雜波與 MTI(佔 15%)
要回答的問題:
- C/N 隨雜波係數怎麼變?每十倍加多少 dB?這代表模型自洽嗎?
- 把發射功率加大十倍,C/N 會變嗎?為什麼?
- MTI 脈衝對消器買回多少 dB?對消 3 個之後為什麼就飽和了?
- 面對這種量級的海面雜波,還有哪三條路可走?各自的代價是什麼?
8. 驗證段(必做)
python tirem_verify.py --pid <PID>
驗證段盯三種不同的靜默失敗:
- A. ACK 了但沒生效 ——
SetHeightAboveGround有 ACK,
但place.UseTerrain仍是False - B. 文件說的前提不是真正的前提 —— 關掉 Use Terrain Data,
TIREM 照樣算 - C. 失效時不是回哨兵值,是整個停住 —— 離開地形檔覆蓋範圍
(只確認附錄腳本存在,且正常流程不會走到)
要回答的問題:
- 「TIREM 算不出結果(已知限制)」曾經被寫成一項預期失敗的檢查,
而它是錯的。一個寫成「預期失敗」的檢查項,有什麼風險? - 型態 B 的檢查(關掉之後結果不變)為什麼算是一種驗證?
它驗的是什麼? - 選一個你自己在 Part A–F 裡量到的數字,寫出一項可執行的檢查,
讓它在那個數字變了的時候會 FAIL。
9. 繳交
| 項目 | 內容 |
|---|---|
| 圖 | tirem_coastal.png(六格) |
| 表 | Part B 的前提消去表、Part E 的地表電性表、Part F 的係數掃描表 |
| 驗證段輸出 | tirem_verify.py 的完整輸出,含 PASS/FAIL 統計 |
| 短文(600 字) | 三種靜默失敗各舉一個你自己遇到的例子,並說明你是怎麼發現的 |
短文的評分重點不是你有沒有遇到坑,而是你怎麼知道自己遇到了坑。
TIREMCoastal_Primer.md
基礎知識導論 — 岸際雷達:TIREM 地形傳播與海面雜波
本章是獨立章節,不屬於 16 週立方衛星課程。
它與雙基地雷達那一章互補:那一章的目標在 9 km 高空,雜波幾乎不影響;
這一章的目標貼海面飛,地形傳播與雜波才是主角。這一章有一個反例貫穿全篇。
「TIREM 在這台機器上算不出結果」是一個很容易得到、而且會通過
四十幾項驗證的結論 —— 它是錯的。第 5 節專門處理它錯在哪裡,
以及為什麼那麼多檢查都沒攔下來。
1. 這一章在解什麼問題
一部架在茂宜島南岸(Kihei)的 X 頻段監視雷達,面對一架貼海面(150 m)
沿南方外海由西向東飛的低空目標。西端是純海面路徑;往東走,
Haleakalā(3055 m)逐漸擋進雷達與目標之間。三個問題:
- 山擋住的時候,還剩多少訊號? 幾何上看不見,不代表電磁上收不到。
- 海面雜波吃掉多少? 低掠射角下的海面回波有多強?
- 怎麼知道模型有沒有在算? 這才是本章真正的主題。
第三個問題聽起來像雜項,實際上是本章最花時間的部分。
STK 對 TIREM 的失效方式有三種,沒有一種會給你錯誤訊息。
2. 核心觀念
2.1 地形對電波的兩種影響,性質完全不同
| 機制 | 性質 | 能不能用功率補 | STK 怎麼處理 |
|---|---|---|---|
| 遮蔽 (masking) | 幾何:視線被擋住 | 不能 | 幾何約束(視線 / Az-El 遮罩) |
| 繞射 / 表面波 | 能量:訊號繞過障礙但衰減 | 可以,代價很大 | 傳播模型(TIREM) |
這兩者在 STK 裡是互斥的兩條路:
- 打開幾何約束,山後那一段直接沒有資料 —— TIREM 連開口的機會都沒有。
- 關掉幾何約束,TIREM 才會告訴你「看不見的地方還剩多少訊號」。
這就是 AGI 建議用 TIREM 時關掉視線、地形遮罩、方位仰角遮罩三個約束的理由。
關掉約束不是為了偷懶,是因為 TIREM 唯一的賣點就是那一段。
2.2 TIREM 是什麼
TIREM(Terrain Integrated Rough Earth Model)計算的傳播損耗包含:
自由空間擴散、反射、繞射、表面波、對流層散射、大氣吸收。
適用範圍:天線高度 30 km 以下、頻率 1 MHz – 40 GHz。
在 STK 裡它掛在大氣吸收模型(Atmospheric Absorption Model)這個位置,
與 ITU-R P676 並列。但兩者的性質差很多:
| ITU-R P676-13 | TIREM | |
|---|---|---|
| 算什麼 | 氣體吸收 | 地形繞射 + 表面波 + 對流層散射 + 氣體吸收 |
| 吃不吃地形 | 不吃 | 吃,而且只吃本機地形檔 |
| 沿航路的變化 | 只隨路徑長度緩慢變 | 隨地形劇烈變化 |
| 本章實測範圍 | 0.32 – 0.61 dB | 0 – 73.3 dB |
「有沒有地形效應」不能靠氣體吸收模型來回答。 兩者差三個數量級。
2.3 雜波:與雜訊完全不同的東西
| 雜訊 | 雜波 | |
|---|---|---|
| 來源 | 接收機本身 | 環境的真實散射 |
| 隨發射功率 | 不變 | 等比例增加 |
| 對策 | 加功率、加積分 | 都卜勒濾波、MTI、CFAR、改幾何 |
加功率對雜波完全無效。 訊號與雜波一起變大,訊雜比不動。
這是雜波與雜訊最根本的差別。
面雜波的強度:
sigma_clutter = sigma0 x A_clutter
A_clutter ~= (c*tau/2) x (R*theta_az) / cos(grazing) 低掠射角時
sigma0 隨掠射角、海況、極化、頻率變化極大,低掠射角時尤其難預測。
2.4 獨立驗證路徑:單刀鋒繞射
TIREM 是黑盒子。要判斷它給的幾十 dB 合不合理,需要一條不經過 STK 的路。
單刀鋒繞射(ITU-R P.526-15 式 31):
v = h * sqrt( 2*(d1+d2) / (lambda*d1*d2) )
J(v) = 6.9 + 20*log10( sqrt((v-0.1)^2 + 1) + v - 0.1 ) v > -0.78
h 是障礙物頂端高於收發直線的高度,要含 4/3 地球等效半徑修正:
h = (h_obs + d1*d2/(2*k*Re)) - [h_tx + (h_rx-h_tx)*d1/(d1+d2)] k = 4/3
自我檢核:v = 0(剛好擦過刀鋒)應得 6.02 dB,本教材的實作給 6.03 dB。
本章實測(衰減最大的取樣點,目標距雷達 42.0 km):
| 量 | 值 |
|---|---|
| 雷達→目標之間的地形最高點 | 2079 m(Haleakalā 側翼),距雷達 16.8 km |
相對視線的遮擋高度 h |
2019 m |
繞射參數 v |
159.3 |
| 刀鋒理論值 | 57.0 dB |
| TIREM | 73.3 dB |
| 比值 | 1.29 |
這條路只算一個理想化的單一障礙物,TIREM 走的是整條地形剖面,
還加上表面波與對流層散射,兩者不會一致。它的用途是量級檢核:
如果 TIREM 給出 2 dB 或 5000 dB,就該回頭查設定。
取剖面要取對地方。 障礙物在雷達與目標之間,不在目標航路下方 ——
本章的目標全程在水面上,航路下方的地形一律是 16 m。
改版過程中一度拿航路地形當代用品,結果刀鋒理論值算出 0 dB,
底下還配著一句「兩者都是幾十 dB 的量級」。一條驗證路徑取錯輸入,
不會報錯,只會給你一個假的確認。
3. 名詞與符號
| 符號 | 意義 | 單位 | 本章值 |
|---|---|---|---|
sigma0 |
雜波反射係數 | — | 0.02(掃描 0.002–20) |
| C/N | 雜波雜訊比 | dB | 見 Part F |
v |
繞射參數 | — | 見 Part D |
k |
地球等效半徑係數 | — | 4/3 |
| PDet | 積分偵測機率 | — | 判準 ≥ 0.8 |
| MTI | 移動目標指示 | — | 脈衝對消器 2–5 |
| −9999.99 | STK 的「沒有計算」哨兵值 | — | 一律濾掉 |
4. 觀念 → 本章實驗對照
| 觀念 | 出現在哪裡 |
|---|---|
| 場景、地形、雷達、低空目標 | tirem_coastal_Connect.txt(37 條,0 NACK) |
| Connect 做不到的三件事 | tirem_build.py |
| 幾何與地形剖面 | tirem_analysis.py Part A |
| TIREM 前提的消去實驗 | 同上 Part B |
| 三個大氣模型對照 | 同上 Part C |
| 越過地平線 + 刀鋒繞射比對 | 同上 Part D |
| 地表電性與極化 | 同上 Part E |
| 海面雜波與 MTI | 同上 Part F |
| 會讓 STK 停住的那一項 | tirem_terrain_bounds_demo.py(需明確確認才跑) |
| 全部檢查 | tirem_verify.py |
5. 本章踩到的坑
這一節比其他章都長,因為這一章的主角是一個會通過驗證的錯誤結論。
5.0 一個很有說服力的錯誤結論
把雷達架在茂宜島西岸、航路往西延伸出去,你會觀察到:
TIREM 出現在可用模型清單裡、設得進去、回讀正確,但輸出全部是 −9999.99。
於是很自然地寫下:「選單裡看得到、設得進去、回讀正確,都不代表它會算。」
那句方法論是對的,但套在 TIREM 上的結論是錯的。
把場景搬進地形檔的覆蓋範圍之後,TIREM 5.50 與 3.31 都算得出來(0 – 73.3 dB),
Radar Active Prop Models 也確認它掛在收發兩條鏈路上。
−9999.99 不是「TIREM 不能用」,是目標在地形圖磚外。
同一組前提還會帶出第二個錯誤:「載入地形之後視線約束就會考慮地形遮蔽,
實測地形擋掉 33% 的航路」。LineOfSight 約束只看橢球體,不含地形。
那 33% 幾乎確定是地球曲率:目標最遠 113 km,
而 30 m 天線對 150 m 目標的雷達地平線大約只有 72 km。
(這一點是推論,本章沒有回頭重跑那組場景去證實。)
重點不是「粗心」,而是:一個正確的方法論套上一組沒查證的前提,
會產生一個聽起來很有說服力的錯誤結論。 而且它會通過 41 項驗證 ——
因為那 41 項驗的是「腳本有沒有照設計做」,不是「設計本身對不對」。
5.1 三種靜默失敗,要分清楚
本章盯的是三種性質不同的失敗,混在一起就查不出來:
| 型態 | 現象 | 本章實例 |
|---|---|---|
| A. ACK 了但沒生效 | 指令回 ACK,回讀值沒變 | SetHeightAboveGround 有 ACK,但 place.UseTerrain 仍是 False |
| B. 生效了,但文件說的前提不是真正的前提 | 設定正確、回讀正確,而且關掉也沒差 | Use Terrain Data 與航點高度參考 |
| C. 失效時不是回哨兵值,是整個停住 | 沒有錯誤、沒有對話框、沒有逾時 | 物件離開地形檔覆蓋範圍 |
| D. 寫得進去、通過範圍檢查、回讀正確,對輸出毫無影響 | 你拿到一個合理、但與設定無關的數字 | TIREM 的地表電性參數 |
型態 B 特別陰險:你照文件做了、回讀也對,於是相信「因為設好了所以它才會動」。
實測把它關掉,結果一模一樣 —— 代表你對因果的理解是錯的,
只是剛好沒有造成傷害。下一次場景不同,它就會咬你。
型態 D 比 B 更難察覺,因為 A/B/C 至少會讓你在某個環節看到異狀:
拿不到數字、或關掉也一樣。D 什麼異狀都沒有 —— 你改了設定、回讀是對的、
結果也很合理,只是那個結果從頭到尾跟你的設定無關。
唯一抓得到的方法,是刻意挑一個「該有明顯差異」的條件去改,然後看輸出動不動。
本章的做法見 §5.6。
5.2 地形檔涵蓋哪裡?STK 不告訴你,而問錯了會停住半小時
這是本章最實用的一個發現,也是把前面所有失敗串起來的那條線。
NED_Maui.pdtt 只涵蓋 20.5–21.0 °N / −156.5 – −156.0 °E ——
一塊 0.5 度見方的 USGS NED 圖磚,內容是 Haleakalā(東茂宜)。
檔名叫 "Maui",但它不含西茂宜山脈,也不含 Lanai。
這件事之所以難發現,是因為三個條件湊在一起:
- STK 沒有任何 API 或 Connect 指令告訴你一個地形檔涵蓋哪裡。
Terrain Alt在覆蓋範圍外照樣回值 —— 回的是大地基準面高度
(本章實測 14–16 m)。所以從分析端分不出「這裡是海」與
「這裡沒有資料」。你會看到一個合理的小數字,然後繼續往下做。- 越界的後果不是 −9999.99,是整個 STK 停住數十分鐘。
沒有回饋、後果嚴重。唯一可靠的辦法是繞過 STK,自己讀檔頭:
# .pdtt 檔頭(小端序),四個 double,單位是弧度
# 位移 24: lat_min 32: lat_max 40: lon_min 48: lon_max
lat0, lat1, lon0, lon1 = struct.unpack_from("<4d", header, 24)
tirem_build.py 的 terrain_bounds() 與 assert_inside_terrain() 做的就是這件事,
而且排在 build() 的最前面 —— 連線之前。座標出界會直接中止並印出範圍,
不會有機會把 STK 弄停。
同一個原因會有兩種完全不同的表現,這是它難追的地方:
| 雷達位置 | 航路 | 結果 |
|---|---|---|
| 西茂宜 −156.51 | −157.6 → −155.9 | 大半在圖磚外 → TIREM 全部 −9999.99 |
| 西岸 −156.675 | −156.95 → −156.35 | 全部在圖磚外 → STK 停住 |
| 南岸 Kihei −156.445 | 全在圖磚內 | 正常 |
出界一部分會拿到哨兵值,出界全部則是整個停住。
兩者看起來像兩個不同的問題,實際上是同一個。
5.3 型態 C 的細節:STK 承諾了錯誤訊息,給的是停住
STK 說明(Help/Content/comm/AtmosModelTIREM.htm)白紙黑字寫著:
STK searches for terrain data for each object location. If the object
moves out of the available terrain data, STK issues an error message
that provides the object's time, latitude, and longitude when it
crossed over the terrain's boundary.
本機實測拿到的是:
ComputeAccess再也不回來 —— 不是慢,是停住- STK 行程 CPU 掉到 0.000/s,它沒有在算
- 沒有錯誤訊息、沒有對話框、沒有逾時
如果這時候把 Python 用戶端強制中止,那個 STK 實例會進入
長時間無回應(數十分鐘量級,推測是 Windows RPC 逾時在跑):
Responding 變成 False、AttachToApplication() 掛住。
它會自己恢復,不是永久壞掉 —— 本教材一開始誤判成必須重開 STK,
後來兩個實例都在無人介入下自己活了回來。
所以這一項不在課堂腳本裡。把會讓學生機器停住半小時的指令
放進教材是不負責任的。要自己驗證的人用
tirem_terrain_bounds_demo.py --yes-i-will-hang-stk。
同一類的還有一個:用 SetAzElMask(eTerrainData, ...) 從地形
算地面站的方位-仰角遮罩,本機同樣會停住(CPU 0.000/s)。
所以本章不用地形遮罩來標定遮蔽段,改用 TIREM 自己的衰減剖面。
5.6 型態 D:TIREM 的地表電性參數是惰性的
STK 說明警告:在海平面上用 .pdtt,TIREM 不會啟用海水傳播特性。
直覺的驗證方式是把地表電性手動改成海水值,看結果差多少。本章這樣做了:
| 頻率 | 組合 | 衰減中位數 | 四組極差 |
|---|---|---|---|
| 9.4 GHz | 陸地/海水 × 垂直/水平 | 56.324646 dB | 0.000000000 dB |
| 100 MHz | 陸地/海水 × 垂直/水平 | 35.232872 dB | 0.000000000 dB |
第一列很容易被解釋成物理:「X 頻段本來就不受地表電性影響,因為表面波
早就衰減殆盡」。這個解釋聽起來完全合理 —— 而且是錯的。
第二列是判別實驗。表面波在 100 MHz 是主要傳播機制之一,
地表電性如果有進到計算裡,VHF 的數值必須不一樣。它一個位元都沒動。
而這些參數並不是被忽略的垃圾輸入 —— STK 會範圍檢查它們:
RelativePermittivity = 1e6 -> Value range is 0.00001 to 100
寫得進去、通過範圍驗證、回讀正確,然後對輸出沒有任何影響。
方法論上的重點:一個設定有沒有效,只有「改動它並觀察輸出」才知道,
而且要挑一個該有明顯差異的條件去改。在 X 頻段測地表電性,
就算它真的失效你也看不出來 —— 因為你會用物理把它解釋掉。
5.7 開了 TIREM 就拿不到雜波
| 大氣吸收模型 | SNR 有效 | 雜波功率 有效 | C/N 有效 |
|---|---|---|---|
| TIREM 5.50 | 29/30 | 0/30 | 0/30 |
| ITU-R P676-13 | 30/30 | 30/30 | 30/30 |
| 關閉 | 30/30 | 30/30 | 30/30 |
TIREM 一開,S/T Clutter Power 與 S/T C/N 全部變成 −9999.99,
而 SNR 與 PDet 照常計算 —— 報表看起來是完整的,只有雜波那幾欄是哨兵值。
沒有任何警告,STK 文件也沒有提到這個限制。
所以在 STK 12.10 裡,TIREM 的地形傳播與雷達雜波拿不到同一份。
要兩者都要,只能跑兩趟再自己合成。本章因此把 Part C/D(地形傳播)
與 Part F(雜波)分開,各用各的大氣模型,並把這個限制本身當成一項結果。
5.8 超出頻率範圍時,回的是乾淨的 0 dB
TIREM 的適用頻段是 1 MHz – 40 GHz。把雷達頻率設到 45 GHz:
(5) 頻率 45.0 GHz(上限 40 GHz) 有效 5/5 0.000 – 0.000 dB
不是哨兵值,是五個乾淨的 0。看起來完全像「這條路徑沒有地形損耗」。
如果你只檢查「有沒有無效值」,這一項會通過。
5.4 Connect 做不到、只能用物件模型的事
| 要做的事 | Connect | 物件模型 |
|---|---|---|
| Place 的 Use Terrain Data | SetHeightAboveGround ACK 但沒打開 |
place.UseTerrain = True |
| GreatArc 航點高度參考 | 沒有對應指令 | route.SetAltitudeRefType(1) |
| 挑大氣吸收模型元件 | — | pc.SetAtmosAbsorptionModel(name) |
| TIREM 的地表電性與極化 | — | pc.AtmosAbsorptionModel.* |
| 地形 Az-El 遮罩 | AzElMask ... Terrain 一律 NACK |
place.SetAzElMask(1, h) |
MTI EnablePulseCanceller = true |
NACK(設 false 反而 ACK) |
waveform.EnablePulseCanceller |
5.5 其他
-
大氣吸收模型有兩層。 場景層在
scenario.RFEnvironment.PropagationChannel,
雷達用的卻是物件層那一份(radar.RFEnvironment.PropagationChannel)。
設場景層不會影響雷達。而且它掛在PropagationChannel底下,
不是掛在RFEnvironment上 —— 走錯一層會拿到AttributeError。 -
.pdtt在開闊海面上回的不是 0,是十幾公尺(本章實測 14.4 – 16.1 m)。
那是大地基準面相對於橢球體的偏移,不是真的有地。
用terrain_alt == 0當海陸判準會把整片海判成陸地。
STK 說明也正是因此建議海平面模擬不要用.pdtt:
TIREM 看到十幾公尺的「地」,就不會啟用海水傳播特性。 -
GreatArc 的 Terrain 高度參考只釘住航點。 航點之間的高度是直線內插的。
若讓目標跨過西茂宜山脈,航路下方地形最高 926 m、
目標最高只有 516 m —— 它從山裡穿過去了,而 STK 一聲不吭。
驗證段有一項專門盯這個(最大穿山深度必須 < 0)。 -
MaxRangeWhenComputingAzElMask在遮罩型別還是 None 的時候是唯讀的。
要先SetAzElMask,才寫得進去。跟雙基地那一章的雜波子樹是同一個模式:
先打開,才寫得進去。 -
兩個 STK 實例是危險的。
AttachToApplication()不指定pid時,
是從 Running Object Table 拿第一個註冊的 —— 不是最後開的、
也不是有場景的那一個。而本教材大部分腳本第一件事就是CloseScenario()。
兩件事湊在一起,就是「腳本安靜地把另一個視窗裡的工作關掉」。
本章的attach()因此改成:多於一個實例就必須指名pid,
而且場景名不符就中止,不是關掉它。 -
SetState ... Model.Receiver.Frequency在單基地雷達上會 NACK。
接收頻率由發射端推導,是唯讀屬性。 -
AER Data是群組型資料提供者,要先進Group.Item("Default")
才有ExecElements。 -
Radar Active Prop Models是本章最有用的工具。
它直接列出 STK 這一次計算實際掛上去的模型,
比回讀設定值可信得多。回讀證明「你寫進去了」,它證明「STK 用了」。
6. 延伸閱讀
TIREM 與地形傳播
- AGI 訓練單元:Introduction to TIREM
— 本章的參考範例(通訊鏈路版)。 - AGI 訓練單元:Radar Tracking and Jamming
- Ansys STK 線上說明。本機說明檔:
C:\Program Files\AGI\STK 12\Help\Content\comm\AtmosModelTIREM.htm
(TIREM 的前提全部寫在這裡),RadarClutter.htm是雜波幾何的權威說明。 - 本機地形檔:
C:\Program Files\AGI\STK 12\Data\Resources\stktraining\imagery\
(本章使用NED_Maui.pdtt)。 - Wikipedia: Terrain masking
繞射
- ITU-R P.526 — Propagation by diffraction
— 單刀鋒繞射公式的出處,本章的獨立驗證路徑。 - ITU-R P.527 — Electrical characteristics of the surface of the Earth
— 海水與陸地的介電常數與導電度。 - ITU-R P.676 — Attenuation by atmospheric gases
- Wikipedia: Knife-edge effect
- Wikipedia: Geoid — 為什麼海面上的地形讀值不是 0。
雜波與都卜勒處理
- Wikipedia: Clutter (radar)
- Wikipedia: Moving target indication
- Wikipedia: Constant false alarm rate
- MIT Lincoln Laboratory:Introduction to Radar Systems
— 雜波、MTI 與都卜勒處理的完整講義,免費。 - 教科書:Skolnik, Introduction to Radar Systems(雜波一章);
Richards, Fundamentals of Radar Signal Processing(MTI 與都卜勒處理);
Nathanson, Radar Design Principles(海面雜波的實測資料)。
工具
(以上外部連結全部經過連線驗證。)
7. 課前自我檢核
- 幾何遮蔽與繞射損耗有什麼根本差別?為什麼在 STK 裡打開視線約束,
反而會讓 TIREM 沒有東西可算? - 為什麼加大發射功率對雜波完全無效?寫出訊號與雜波各自的功率關係。
.pdtt在開闊海面上回 15 公尺。那是什麼?
如果你用terrain_alt == 0當海陸判準,會得到什麼結論?- 你把一個模型設好了、回讀也正確,而且結果看起來合理。
要再做什麼,才敢說「這個模型真的在影響結果」? - 本章列出三種靜默失敗(ACK 沒生效 / 文件前提不是真前提 / 停住而非報錯)。
對每一種,寫出一個可執行的檢查方式。
圖

tirem_coastal/tirem_coastal.png

tirem_coastal/tirem_coastal_anim.gif
程式碼
# -*- coding: utf-8 -*-
# ==============================================================================
# 岸際雷達的地形傳播與海面雜波 — 分析
#
# Part A 幾何與地形剖面
# Part B TIREM 的五個前提 —— 逐一關掉,看哪一個真的會讓它失效
# Part C 三個大氣模型對照:關閉 / ITU-R P676-13 / TIREM
# Part D 幾何約束開 vs 關,以及與單刀鋒繞射理論的獨立比對
# Part E 地表電性與極化 —— 附一個 VHF 判別實驗
# Part F 海面雜波與 MTI
#
# 本章與雙基地那一章互補:那一章的目標在 9 km 高空,雜波幾乎不影響;
# 這一章的目標貼著海面飛,地形傳播與雜波才是主角。
#
# ------------------------------------------------------------------------------
# 「TIREM 在這台機器上算不出結果」是一個很容易得到的結論,而它是錯的:
# 把場景搬進地形檔的覆蓋範圍之後,TIREM 5.50 與 3.31 都算得出來
# (0 - 116 dB),`Radar Active Prop Models` 也確認它掛在收發兩條鏈路上。
#
# 但真正量到的東西,比「補齊前提就好」複雜得多。本章因此變成
# 一份**靜默失敗的分類學**,四種都在這支腳本裡量得出來:
#
# A. ACK 了但沒生效
# `SetHeightAboveGround` 有 ACK,但 place.UseTerrain 仍是 False。
#
# B. 文件說的前提不是真正的前提
# STK 說明把「物件以地形為高度參考」列為硬性前提,實測關掉照樣算,
# 數值幾乎不變。
#
# C. 失效時不是回哨兵值,是整個停住
# 物件離開地形檔覆蓋範圍時,ComputeAccess 再也不回來:CPU 掉到
# 0.000/s、沒有錯誤、沒有對話框、沒有逾時。而 STK 說明白紙黑字
# 承諾這時候會給一則錯誤訊息。中止用戶端之後那個實例會無回應
# 數十分鐘,然後**自己恢復**(不需要重開)。Part B 不執行這一項,
# 見 tirem_terrain_bounds_demo.py。
#
# D. 設定寫得進去、通過範圍檢查、回讀正確,然後對輸出毫無影響
# TIREM 的地表電性參數(RelativePermittivity / SurfaceConductivity)
# 在 9.4 GHz 與 100 MHz 兩個頻率、四種組合下,輸出差 0.000000000 dB。
# VHF 對照排除了「這是物理」的解釋。這是最難察覺的一種 ——
# 你拿到的是一個合理、但與你的設定無關的數字。
#
# 另外還有一個純粹的能力限制:**開了 TIREM 就拿不到雜波**
# (Clutter Power 與 C/N 全變 -9999.99,而 SNR 與 PDet 照常有值)。
# 所以 Part F 的雜波分析用 ITU-R P676-13,地形傳播的部分看 Part C。
#
# ------------------------------------------------------------------------------
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python tirem_build.py
# 3. python tirem_analysis.py
# ==============================================================================
import math
import os
import sys
import matplotlib
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import tirem_build as B # noqa: E402
STEP = 15.0
PDET_MIN = 0.8
SENTINEL_LOW, SENTINEL_HIGH = -9000.0, 2999.0
# 分辨「海面段」與「陸地段」的地形高度門檻 (m)。
#
# 為什麼不是 0:NED_Maui.pdtt 在開闊海面上回的是十幾公尺,不是 0 ——
# 那是大地基準面(geoid)相對於橢球體的偏移,不是真的有地。
# 這正是 STK 說明警告過的事:「在海平面上用 .pdtt,TIREM 多半不會把它
# 當成海水」。本章 Part E 把這句話量化。
TERRAIN_SEA_M = 60.0
ATMOS_BASELINE = "ITU-R P676-13"
CLUTTER_COEFFS = [0.002, 0.02, 0.2, 2.0, 20.0]
MTI_PULSES = [2, 3, 4, 5] # STK 限 2-5,給 1 會拋 Value range 錯誤
ST_ELEMENTS = ["Time", "S/T Integrated SNR", "S/T Integ. Det. Thresh.",
"S/T Clutter Power", "S/T C/N", "S/T S1/C",
"S/T Integrated PDet", "S/T Integrated PDet w/J+C"]
ENV_ELEMENTS = ["Time", "Rcvr Atmos Atten", "Rcvr Prop Atten",
"Rcvr FreeSpace Atten"]
# ------------------------------------------------------------------------------
def clean(a):
"""濾掉 STK 的哨兵值。-9999.99 = 沒算,3000 = 無限大比值。"""
a = np.asarray(a, dtype=float).copy()
a[(a < SENTINEL_LOW) | (a > SENTINEL_HIGH)] = np.nan
return a
def fetch(root, dp_name, elements, step=STEP):
rad = root.GetObjectFromPath(B.RADAR)
acc = rad.GetAccessToObject(root.GetObjectFromPath(B.TGT))
acc.ComputeAccess()
sc = root.CurrentScenario
dp = acc.DataProviders.Item(dp_name)
if hasattr(dp, "Group"): # AER Data 這類是群組,要再進一層
dp = dp.Group.Item("Default")
res = dp.ExecElements(sc.StartTime, sc.StopTime, step, elements)
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)
return out
def active_models(root):
"""哪些傳播模型**真的**在作用。
這是本章找回 TIREM 的關鍵工具:`Radar Active Prop Models` 直接列出
STK 這次計算實際掛上去的模型,比回讀設定值可信得多。
"""
rad = root.GetObjectFromPath(B.RADAR)
acc = rad.GetAccessToObject(root.GetObjectFromPath(B.TGT))
acc.ComputeAccess()
res = acc.DataProviders.Item("Radar Active Prop Models").Exec()
cols = {}
for i in range(res.DataSets.Count):
d = res.DataSets.Item(i)
cols[d.ElementName] = list(d.GetValues())
if not cols:
return []
n = len(next(iter(cols.values())))
return [{k: cols[k][j] for k in cols} for j in range(n)]
def atmos_series(root, step=STEP):
env = fetch(root, "Radar Environment", ENV_ELEMENTS, step)
return clean(env["Rcvr Atmos Atten"]), env["Time"]
def valid_count(a):
return int(np.isfinite(a).sum()), int(np.asarray(a).size)
def target_track(root, step=STEP):
tgt = root.GetObjectFromPath(B.TGT)
sc = root.CurrentScenario
dp = tgt.DataProviders.Item("LLA State").Group.Item("Fixed")
r = dp.ExecElements(sc.StartTime, sc.StopTime, step,
["Time", "Lat", "Lon", "Alt", "Terrain Alt"])
return {r.DataSets.Item(i).ElementName:
np.array([v if isinstance(v, (int, float)) else np.nan
for v in r.DataSets.Item(i).GetValues()], dtype=float)
for i in range(r.DataSets.Count)}
def set_clutter(root, on, coeff=None):
root.ExecuteCommand("Radar %s SetValue Model.ClutterEnabled %s"
% (B.RADAR, "true" if on else "false"))
if on and coeff is not None:
root.ExecuteCommand(
"Radar %s SetValue Model.Clutter.PointProviderList[0]"
".PointProvider.ScatteringPointModel.ClutterCoefficient %g"
% (B.RADAR, coeff))
root.ExecuteCommand("ClearAllAccess /")
def continuity(pdet, step=STEP, thresh=PDET_MIN):
ok = np.isfinite(pdet) & (pdet >= thresh)
gaps, run = [], 0
for v in ok:
if v:
if run:
gaps.append(run)
run = 0
else:
run += 1
if run:
gaps.append(run)
return ((ok.mean() if ok.size else 0.0),
(max(gaps) * step if gaps else 0.0), len(gaps))
# ---- 獨立路徑:單刀鋒繞射(ITU-R P.526)--------------------------------------
def knife_edge_loss_db(v):
"""ITU-R P.526-15 式 (31) 的近似。v <= -0.78 時視為無遮蔽。"""
if v <= -0.78:
return 0.0
return 6.9 + 20.0 * math.log10(math.sqrt((v - 0.1) ** 2 + 1.0) + v - 0.1)
def fresnel_v(h_m, d1_m, d2_m, freq_hz):
lam = 2.99792458e8 / freq_hz
return h_m * math.sqrt(2.0 * (d1_m + d2_m) / (lam * d1_m * d2_m))
def clearance_h(h_obs, h_tx, h_rx, d1, d2, k=4.0 / 3.0):
"""障礙物頂端相對於收發直線的高度,含 4/3 地球等效半徑修正。"""
re = 6371000.0 * k
bulge = d1 * d2 / (2.0 * re)
los = h_tx + (h_rx - h_tx) * d1 / (d1 + d2)
return (h_obs + bulge) - los
def great_circle_m(lat1, lon1, lat2, lon2):
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = p2 - p1, math.radians(lon2 - lon1)
a = (math.sin(dp / 2) ** 2
+ math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2)
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
def terrain_between(root, lat1, lon1, lat2, lon2, npts=40, dur=600.0):
"""取兩點之間的地形剖面,回傳 (lat[], lon[], terrain_m[], dist_m[])。
STK 沒有「回傳這兩點之間的地形」這種 API,所以這裡用一台暫時的
大圓航行器把那條線飛一次,再讀它的 LLA State / Terrain Alt。
讀完就把它卸載掉,不留在場景裡。
"""
sc = root.CurrentScenario
path = "*/Aircraft/TerrainProbe"
try:
root.GetObjectFromPath(path).Unload()
except Exception:
pass
root.ExecuteCommand("New / */Aircraft TerrainProbe")
root.ExecuteCommand("SetPropagator %s GreatArc" % path)
ac = root.GetObjectFromPath(path)
r = ac.Route
r.SetAltitudeRefType(B.ALT_REF_TERRAIN)
r.Waypoints.RemoveAll()
dist = great_circle_m(lat1, lon1, lat2, lon2)
spd = max(dist / dur, 1.0) / 1000.0 # km/s
for la, lo in ((lat1, lon1), (lat2, lon2)):
w = r.Waypoints.Add()
w.Latitude, w.Longitude = la, lo
w.Altitude, w.Speed = 0.05, spd
r.Propagate()
dp = ac.DataProviders.Item("LLA State").Group.Item("Fixed")
res = dp.ExecElements(sc.StartTime, sc.StopTime, max(1.0, dur / npts),
["Time", "Lat", "Lon", "Terrain Alt"])
col = {}
for i in range(res.DataSets.Count):
d = res.DataSets.Item(i)
col[d.ElementName] = np.array(
[v if isinstance(v, (int, float)) else np.nan for v in d.GetValues()],
dtype=float)
try:
ac.Unload()
except Exception:
pass
lat = col.get("Lat", np.array([]))
lon = col.get("Lon", np.array([]))
ter = col.get("Terrain Alt", np.array([])) * 1000.0
dd = np.array([great_circle_m(lat1, lon1, a, b) for a, b in zip(lat, lon)])
return lat, lon, ter, dd
# ==============================================================================
def part_a(root):
print("=" * 78)
print("Part A 幾何與地形剖面")
print("=" * 78)
B.set_los(root, False)
B.set_atmos(root, ATMOS_BASELINE)
track = target_track(root)
ta_m = track["Terrain Alt"] * 1000.0
alt_m = track["Alt"] * 1000.0
on_water = ta_m < TERRAIN_SEA_M
print(" 航路:(%.3f, %.3f) -> (%.3f, %.3f)"
% (track["Lat"][0], track["Lon"][0], track["Lat"][-1], track["Lon"][-1]))
print(" 航路下方地形:%.1f – %.1f m;目標飛行高度 %.0f – %.0f m"
% (np.nanmin(ta_m), np.nanmax(ta_m), np.nanmin(alt_m), np.nanmax(alt_m)))
print(" 目標在水面上的比例:%.0f%%(門檻 %.0f m)"
% (on_water.mean() * 100, TERRAIN_SEA_M))
dig = float(np.nanmax(ta_m - alt_m))
print(" 最大穿山深度:%.1f m(小於 0 代表全程都在地形之上)" % dig)
print()
print(" 雷達架在茂宜島**南岸**(Kihei),目標沿南方外海由西向東飛。")
print(" 西端是純海面路徑,往東走 Haleakala(3055 m)逐漸擋進電波路徑 ——")
print(" 地形不是擋在目標腳下,而是擋在**雷達與目標之間**。Part C/D 量化它。")
print()
if on_water.mean() > 0.99:
print(" 目標全程在水面上(%.0f%%),而且最大穿山深度 %.1f m < 0 ——"
% (on_water.mean() * 100, dig))
print(" 航路沒有鑽進地形裡。")
else:
print(" **注意:目標有 %.0f%% 的時間不在水面上,最大穿山深度 %.1f m。**"
% ((~on_water).mean() * 100, dig))
if dig > 0:
print(" 穿山深度大於 0 代表航路**鑽進地形裡**了,這是不合物理的,")
print(" 必須改航點。")
print()
print(" 為什麼要盯這件事:GreatArc 把高度參考設成 Terrain 之後,")
print(" 只保證**航點**在地形之上,航點之間的高度是直線內插的。")
print(" 改版過程中量到過 926 m 的地形配上 516 m 的目標 —— 它從山裡")
print(" 穿過去了,而 STK 一聲不吭。驗證段有一項專門盯這個。")
print()
print(" 另外:開闊海面上的地形讀值是 %.1f m 起跳,不是 0 —— 那是大地基準面"
% np.nanmin(ta_m))
print(" 相對於橢球體的偏移。用 `terrain_alt == 0` 當海陸判準會全部判成陸地。")
print(" STK 說明也正是因為這件事,建議海平面模擬不要用 .pdtt:")
print(" TIREM 看到十幾公尺的「地」,就不會啟用海水傳播特性。Part E 量化它。")
geo = fetch(root, "AER Data", ["Time", "Azimuth", "Elevation", "Range"])
el, rng = clean(geo["Elevation"]), clean(geo["Range"])
print()
print(" 雷達看目標:仰角 %.2f – %.2f deg,距離 %.1f – %.1f km"
% (np.nanmin(el), np.nanmax(el), np.nanmin(rng), np.nanmax(rng)))
print(" 仰角接近 0 正是海面雜波最嚴重的條件:掠射角低、照射面積大,")
print(" 而且反射係數在低掠射角時變化最劇烈、最難預測。")
return track, on_water, geo
def part_b(root):
"""本章的核心:TIREM 的五個前提,逐一關掉。
每一個前提被違反時,STK 都**不會報錯**,只會把 -9999.99 填進去。
「TIREM 算不出來」這個錯誤結論就是這樣來的。
"""
print()
print("=" * 78)
print("Part B TIREM 的五個前提(消去實驗)")
print("=" * 78)
name = B.newest_tirem(root)
if not name:
print(" 這台機器沒有安裝 TIREM,Part B 跳過。")
return None, []
def measure():
B.set_atmos(root, name)
at, _ = atmos_series(root, step=120.0)
k, n = valid_count(at)
return k, n, (float(np.nanmin(at)) if k else float("nan")), \
(float(np.nanmax(at)) if k else float("nan"))
rows = []
def run(tag, note=""):
k, n, lo, hi = measure()
rows.append(dict(tag=tag, ok=k, n=n, lo=lo, hi=hi, note=note))
if k:
print(" %-30s 有效 %2d/%-2d %8.3f – %8.3f dB" % (tag, k, n, lo, hi))
else:
print(" %-30s 有效 %2d/%-2d <- 靜默失效,沒有任何錯誤訊息"
% (tag, k, n))
return k
# 基準
B.use_terrain_place(root, True)
B.build_route(root, alt_ref=B.ALT_REF_TERRAIN)
run("基準:五個前提全滿足")
# (2a) Place 沒打開 Use Terrain Data
B.use_terrain_place(root, False)
run("(2a) Place 沒開 Use Terrain Data",
"Connect 的 SetHeightAboveGround 只做到這裡")
B.use_terrain_place(root, True)
# (2b) 航點高度參考不是 Terrain
B.build_route(root, alt_ref=B.ALT_REF_MSL)
run("(2b) 航點高度參考 = MSL", "GreatArc 預設值")
B.build_route(root, alt_ref=B.ALT_REF_TERRAIN)
# (1) 與 (3) **故意不執行** —— 見下方 DANGEROUS 說明。
# (5) 頻率超出 1 MHz - 40 GHz
for f in ("45.0",):
root.ExecuteCommand("Radar %s SetValue "
"Model.AntennaControl.Antenna.DesignFrequency %s GHz"
% (B.RADAR, f))
root.ExecuteCommand("Radar %s SetValue Model.Transmitter.Frequency %s GHz"
% (B.RADAR, f))
run("(5) 頻率 %s GHz(上限 40 GHz)" % f)
root.ExecuteCommand("Radar %s SetValue "
"Model.AntennaControl.Antenna.DesignFrequency 9.4 GHz"
% B.RADAR)
root.ExecuteCommand("Radar %s SetValue Model.Transmitter.Frequency 9.4 GHz"
% B.RADAR)
run("復原:回到基準")
print()
dead = [r["tag"] for r in rows if r["ok"] == 0]
if dead:
print(" 讓 TIREM 靜默失效的前提:")
for d in dead:
print(" - %s" % d)
else:
print(" 以上每一個案都算得出數字 —— **沒有任何一項讓 TIREM 失效**。")
print()
print(" 這個結果本身就值得注意。STK 說明把「所有物件都要以地形為高度參考」")
print(" 寫成硬性前提,但實測顯示:把 Place 的 Use Terrain Data 關掉、")
print(" 把航點高度參考從 Terrain 改成 MSL,TIREM 照樣算得出來,數值也幾乎不變。")
print(" 文件寫的是建議做法,不是 STK 真正的檢查條件 —— 兩者不一樣。")
print()
print(" " + "!" * 66)
print(" 以下兩個前提**故意不執行**,因為它們不是回 -9999.99,是讓 STK 整個卡死:")
print()
print(" (3) 航路移出地形檔覆蓋範圍")
print(" (1) 移除本機地形檔")
print()
print(" 本教材實測 (3):把航點往西移 2 度(離開 NED_Maui.pdtt 的資料範圍)")
print(" 之後,ComputeAccess 就再也不回來 —— 不是慢,是停住:STK 行程的")
print(" CPU 掉到 0.01/s,沒有錯誤訊息、沒有對話框、沒有逾時。")
print()
print(" 如果這時候把 Python 用戶端強制中止,那個 STK 實例會進入")
print(" **長時間無回應**(數十分鐘量級,推測是 Windows RPC 逾時在跑):")
print(" `Responding` 變成 False、`AttachToApplication()` 掛住。")
print(" 但它**會自己恢復**,不是永久壞掉 —— 本教材一開始誤判成必須重開,")
print(" 後來兩個實例都在無人介入下自己活了回來。等,或者換一個實例。")
print()
print(" STK 說明對這個情況的承諾是:")
print(' "If the object moves out of the available terrain data, STK')
print(' issues an error message that provides the object\'s time,')
print(' latitude, and longitude when it crossed over the boundary."')
print(" 文件承諾給你一則錯誤訊息,實際上給你的是停住。")
print()
print(" 所以這一項寫成**文件,而不是可執行的測試** —— 把會讓學生機器停住")
print(" 半小時的指令放進課堂腳本是不負責任的。要自己驗證的人請用")
print(" tirem_terrain_bounds_demo.py,它會先警告並要求明確確認。")
print(" " + "!" * 66)
print()
print(" 能直接看穿模型有沒有掛上去的是 `Radar Active Prop Models` ——")
print(" 它列出 STK 這一次**實際掛上去**的模型:")
for r in active_models(root):
print(" %s" % " / ".join(str(r[k]) for k in
("Link Direction", "Category", "Name")))
return name, rows
def part_c(root, tirem):
"""三個模型對照。"""
print()
print("=" * 78)
print("Part C 大氣 / 傳播模型對照")
print("=" * 78)
B.set_los(root, False)
out = {}
print(" %-16s %10s %-22s %s"
% ("模型", "有效樣本", "衰減 (dB)", "說明"))
models = [(None, "off", "只有自由空間擴散"),
(ATMOS_BASELINE, ATMOS_BASELINE, "只有氣體吸收,與地形無關"),
(tirem, tirem, "含地形繞射、表面波、對流層散射")]
for m, label, note in models:
if m is None:
B.set_atmos(root, None)
else:
B.set_atmos(root, m)
at, t = atmos_series(root)
k, n = valid_count(at)
out[label] = (t, at)
if k:
print(" %-16s %6d/%-3d %8.4f – %-11.4f %s"
% (label, k, n, np.nanmin(at), np.nanmax(at), note))
else:
print(" %-16s %6d/%-3d %-22s %s" % (label, k, n, "全部哨兵值", note))
print()
if tirem in out:
itu = out[ATMOS_BASELINE][1]
tir = out[tirem][1]
print(" ITU-R P676 的衰減只跟大氣路徑長度有關,整條航路只差 %.2f dB;"
% (np.nanmax(itu) - np.nanmin(itu)))
print(" TIREM 差了 %.1f dB —— 差別全部來自地形。"
% (np.nanmax(tir) - np.nanmin(tir)))
print(" 這就是為什麼「有沒有地形效應」不能靠氣體吸收模型來回答。")
B.set_atmos(root, tirem)
return out
def part_d(root, tirem, track):
"""越過地平線:幾何約束開 vs 關,以及與繞射理論的獨立比對。"""
print()
print("=" * 78)
print("Part D 越過地平線")
print("=" * 78)
B.set_atmos(root, tirem)
set_clutter(root, False)
res = {}
print(" %-22s %12s %14s" % ("幾何約束", "有效樣本", "TIREM 衰減最大 (dB)"))
for on in (False, True):
B.set_los(root, on)
at, t = atmos_series(root)
k, n = valid_count(at)
res["on" if on else "off"] = (t, at, k, n)
print(" %-22s %8d/%-3d %14.2f"
% ("開(視線+遮罩)" if on else "關(AGI 建議)", k, n,
np.nanmax(at) if k else float("nan")))
off_k, on_k = res["off"][2], res["on"][2]
print()
if on_k < off_k:
print(" 打開幾何約束之後,有效樣本從 %d 掉到 %d。" % (off_k, on_k))
print(" 被砍掉的那 %d 個取樣點不是「訊號很弱」,是**完全沒有資料** ——"
% (off_k - on_k))
print(" 幾何約束把遮蔽段直接判定為看不見,TIREM 連開口的機會都沒有。")
print(" 這正是 AGI 建議用 TIREM 時關掉三個幾何約束的理由。")
else:
print(" 打開幾何約束之後有效樣本沒有變(%d -> %d)。" % (off_k, on_k))
print(" **這代表本場景的幾何約束根本沒有擋掉任何東西**,不是代表")
print(" 「地形沒有影響」—— TIREM 的衰減最大到 %.1f dB,地形影響非常大。"
% (np.nanmax(res["off"][1]) if off_k else float("nan")))
print(" 原因:STK 的 LineOfSight 約束只看**橢球體**,不含地形。")
print(" 要讓地形進到 access 判定,得另外用 SetAzElMask(eTerrainData)")
print(" 在地面站上算一張遮罩 —— 而那個操作在本機會讓 STK 停住,")
print(" 所以本章不走那條路,改用 TIREM 的衰減剖面來標定遮蔽段。")
print()
print(" 這一格的教訓因此不是「幾何 vs 傳播」的對照,而是:")
print(" **一個對照組沒有差別,可能是因為你以為在對照的東西根本沒開。**")
# ---- 獨立路徑:單刀鋒繞射 ----
#
# 障礙物在**雷達與目標之間**(Haleakala),不在目標航路下方 ——
# 目標全程在水面上。所以這裡不能拿航路的地形當代用品,
# 必須沿雷達->目標的大圓另外取一次剖面。
B.set_los(root, False)
at, t = atmos_series(root)
lat, lon = track["Lat"], track["Lon"]
alt_m = track["Alt"] * 1000.0
n = min(len(at), len(lat))
if n and np.isfinite(at[:n]).any():
idx = int(np.nanargmax(at[:n]))
rlat, rlon = B.RADAR_LLA
plat, plon, pter, pd = terrain_between(root, rlat, rlon,
float(lat[idx]), float(lon[idx]))
if len(pter) >= 3 and np.isfinite(pter).any():
j = int(np.nanargmax(pter))
h_obs = float(pter[j])
d1 = float(pd[j])
d_tot = float(great_circle_m(rlat, rlon, lat[idx], lon[idx]))
d2 = max(d_tot - d1, 1.0)
print()
print(" --- 獨立路徑驗證:單刀鋒繞射(ITU-R P.526)---")
print(" TIREM 衰減最大的取樣點:t=%.0f s,距雷達 %.1f km"
% (t[idx], d_tot / 1000.0))
print(" 雷達->目標之間的地形最高點:%.0f m,距雷達 %.1f km、距目標 %.1f km"
% (h_obs, d1 / 1000.0, d2 / 1000.0))
if d1 > 100.0 and d2 > 100.0:
h = clearance_h(h_obs, 30.0, float(alt_m[idx]), d1, d2)
v = fresnel_v(h, d1, d2, 9.4e9)
theo = knife_edge_loss_db(v)
got = float(at[idx])
print(" 相對視線的遮擋高度 h = %.0f m,繞射參數 v = %.1f" % (h, v))
print(" 刀鋒繞射理論值 %.1f dB vs TIREM %.1f dB 差 %.1f dB"
% (theo, got, got - theo))
print()
if theo > 1.0 and got > 1.0:
ratio = got / theo
print(" 兩者都是繞射主導的量級(比值 %.2f)。不會一致 ——"
% ratio)
print(" 刀鋒模型只算一個理想化的單一障礙物,TIREM 走的是整條")
print(" 地形剖面,還加上表面波與對流層散射。這個比對的用途是")
print(" **量級檢核**:如果 TIREM 給出 2 dB 或 5000 dB,")
print(" 那就該回頭查設定,而不是相信它。")
elif theo <= 1.0:
print(" 刀鋒模型判定這條路徑**沒有被遮擋**(v = %.1f < -0.78),"
% v)
print(" 但 TIREM 給了 %.1f dB。兩者不一致,原因可能是:" % got)
print(" - 真正的損耗來自表面波或對流層散射,不是刀鋒繞射")
print(" - 或者最高點不是有效的繞射邊緣(剖面取樣太粗)")
print(" **這種情況下不要拿刀鋒值去「驗證」TIREM** ——")
print(" 兩個模型算的不是同一件事。")
res["knife"] = dict(idx=idx, h_obs=h_obs, d1=d1, d2=d2,
h=h, v=v, theory=theo, tirem=got)
else:
print(" 最高點太靠近端點,刀鋒公式不適用,跳過比對。")
else:
print()
print(" 取不到雷達->目標之間的地形剖面,跳過刀鋒比對。")
B.set_los(root, False)
return res
def part_e(root, tirem):
"""地表電性與極化 —— 附一個判別實驗。
STK 說明警告:在海平面上用 .pdtt,TIREM 不會啟用海水傳播特性。
直覺的驗證方式是把地表電性手動改成海水值,看結果差多少。
本章這樣做了,結果四種組合**一個位元都沒差**。
這時候有兩個競爭解釋,不能用猜的:
(a) 物理:X 頻段本來就不受地表電性影響(表面波早已衰減殆盡)
(b) 實作:STK 根本沒把這些值傳給 TIREM
判別實驗:把頻率降到 VHF。表面波在 VHF 是主要傳播機制之一,
如果 (a) 成立,VHF 下四組數值**必須**出現差異。
"""
print()
print("=" * 78)
print("Part E 地表電性與極化(含判別實驗)")
print("=" * 78)
B.set_los(root, False)
B.set_atmos(root, tirem)
print(" TIREM 預設值:%s" % B.tirem_settings(root))
print()
def set_freq(ghz):
root.ExecuteCommand("Radar %s SetValue "
"Model.AntennaControl.Antenna.DesignFrequency %g GHz"
% (B.RADAR, ghz))
root.ExecuteCommand("Radar %s SetValue Model.Transmitter.Frequency %g GHz"
% (B.RADAR, ghz))
root.ExecuteCommand("ClearAllAccess /")
def sweep(tag):
rows = []
print(" %-9s %-16s %-6s %11s %16s"
% ("頻率", "地表", "極化", "有效樣本", "衰減中位 (dB)"))
for skey in ("land", "sea"):
for pkey, pval in B.POLARIZATION.items():
B.set_tirem_surface(root, B.SURFACE[skey], pval)
at, _ = atmos_series(root, step=90.0)
k, n = valid_count(at)
med = float(np.nanmedian(at)) if k else float("nan")
rows.append((tag, skey, pkey, k, n, med))
print(" %-9s %-16s %-6s %7d/%-3d %16.6f"
% (tag, B.SURFACE_LABEL[skey],
"垂直" if pkey == "vertical" else "水平", k, n, med))
vals = [r[5] for r in rows if np.isfinite(r[5])]
spread = (max(vals) - min(vals)) if len(vals) >= 2 else float("nan")
print(" -> %s 四組之間的最大差 = %.9f dB" % (tag, spread))
return rows, spread
rows_x, spread_x = sweep("9.4 GHz")
print()
print(" --- 判別實驗:降到 VHF,表面波在那裡是主角 ---")
set_freq(0.1)
rows_v, spread_v = sweep("100 MHz")
set_freq(9.4)
B.set_tirem_surface(root, B.SURFACE["land"], B.POLARIZATION["vertical"])
print()
tiny = 1e-9
if spread_x < tiny and spread_v < tiny:
print(" **兩個頻率、四種組合,輸出完全相同(差 0.000000000 dB)。**")
print()
print(" 這排除了「這是物理」的解釋:表面波在 100 MHz 是主要傳播機制,")
print(" 地表電性如果有進到計算裡,VHF 的數值**必須**不一樣。它沒有。")
print()
print(" 而這些參數並不是被忽略的垃圾輸入 —— STK 會**範圍檢查**它們:")
print(" RelativePermittivity = 1e6 -> Value range is 0.00001 to 100")
print(" 寫得進去、通過範圍驗證、回讀正確,")
print(" **然後對輸出沒有任何影響。**")
print()
print(" 這是本章第四種靜默失敗,而且是最難察覺的一種:")
print(" 前三種至少會讓你拿不到數字,這一種讓你拿到一個看起來很合理、")
print(" 實際上與你的設定無關的數字。如果你只做了")
print(" 「改設定 -> 回讀 -> 結果合理」這三步,你會相信它生效了。")
print()
print(" 對本章結論的影響:Part E 不能拿來談海水 vs 陸地的物理。")
print(" 能談的是方法論 —— 一個設定有沒有效,只有**改動它並觀察輸出**")
print(" 才知道,而且要挑一個「該有明顯差異」的條件去改。")
elif spread_x < tiny <= spread_v:
print(" X 頻段沒有差別,但 VHF 有(%.6f dB)。" % spread_v)
print(" 這支持「物理」的解釋:地表電性影響表面波,而表面波在")
print(" 9.4 GHz 早就衰減殆盡,在 100 MHz 才是主角。")
print(" STK 說明那句警語是對的,但要連適用頻段一起讀。")
else:
print(" X 頻段差 %.6f dB,VHF 差 %.6f dB。" % (spread_x, spread_v))
return rows_x + rows_v, spread_x, spread_v
def part_f(root, tirem, clear):
"""海面雜波與 MTI。
**重要:這一段不能在 TIREM 開著的時候做。**
本章實測發現,把大氣吸收模型設成 TIREM 之後,雷達的雜波輸出
(S/T Clutter Power、S/T C/N)會**全部變成 -9999.99**,
而 SNR 與 PDet 照常計算 —— 報表看起來是完整的,只有雜波那幾欄是哨兵值。
換成 ITU-R P676-13 或關掉大氣吸收,雜波立刻算得出來。
也就是說:在 STK 12.10 裡,**TIREM 的地形傳播與雷達雜波拿不到同一份**。
要兩個都要,只能跑兩趟再自己合成。這個限制沒有寫在文件裡,
也沒有任何警告 —— 你只會看到雜波欄位全是 -9999.99。
"""
print()
print("=" * 78)
print("Part F 海面雜波與 MTI")
print("=" * 78)
B.set_los(root, False)
set_clutter(root, True, 0.02)
print(" --- 大氣模型對雜波輸出的影響 ---")
print(" %-16s %14s %16s %14s"
% ("大氣吸收模型", "SNR 有效", "雜波功率 有效", "C/N 有效"))
avail = {}
for m, label in ((tirem, tirem), (ATMOS_BASELINE, ATMOS_BASELINE),
(None, "關閉")):
B.set_atmos(root, m)
st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
snr = valid_count(clean(st["S/T Integrated SNR"]))
cp = valid_count(clean(st["S/T Clutter Power"]))
cn = valid_count(clean(st["S/T C/N"]))
avail[label] = (snr, cp, cn)
print(" %-16s %10d/%-3d %12d/%-3d %10d/%-3d"
% (label, snr[0], snr[1], cp[0], cp[1], cn[0], cn[1]))
print()
if avail[tirem][2][0] == 0 and avail[ATMOS_BASELINE][2][0] > 0:
print(" **TIREM 開著的時候,雷達完全算不出雜波。**")
print(" SNR 與 PDet 照常有值,只有雜波那幾欄是 -9999.99 ——")
print(" 報表看起來是完整的。沒有任何警告,文件也沒有提到這個限制。")
print(" 所以底下的雜波分析改用 %s;地形傳播的部分見 Part C。"
% ATMOS_BASELINE)
print(" 要兩個都要,只能跑兩趟再自己合成。")
print()
B.set_atmos(root, ATMOS_BASELINE)
set_clutter(root, True, 0.02)
st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
cn, cp, t = clean(st["S/T C/N"]), clean(st["S/T Clutter Power"]), st["Time"]
k, n = valid_count(cn)
if k:
print(" 雜波係數 0.02:C/N %.2f – %.2f dB(有效樣本 %d/%d)"
% (np.nanmin(cn), np.nanmax(cn), k, n))
else:
print(" 雜波係數 0.02:C/N 仍然無效(%d/%d)" % (k, n))
m = min(len(clear), len(cn))
seg = {}
for label, mask in [("低衰減半段", clear[:m]), ("高衰減半段", ~clear[:m])]:
sel = mask & np.isfinite(cn[:m])
if not sel.any():
print(" %-10s 沒有有效樣本" % label)
continue
seg[label] = (float(np.median(cn[:m][sel])),
float(np.median(cp[:m][sel])), int(sel.sum()))
print(" %-10s C/N 中位 %7.2f dB 雜波功率中位 %8.2f dBW 樣本 %d"
% (label, seg[label][0], seg[label][1], seg[label][2]))
print()
print(" %12s %12s %14s %13s"
% ("雜波係數", "偵測率", "最長中斷(s)", "C/N 最大"))
scan = []
for c in CLUTTER_COEFFS:
set_clutter(root, True, c)
s2 = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
pd, c2 = clean(s2["S/T Integrated PDet w/J+C"]), clean(s2["S/T C/N"])
rate, longest, _ = continuity(pd)
mxv = float(np.nanmax(c2)) if np.isfinite(c2).any() else float("nan")
scan.append((c, rate, longest, mxv))
print(" %12g %11.1f%% %14.0f %13.2f" % (c, rate * 100, longest, mxv))
set_clutter(root, True, 0.02)
print()
mxs = [r[3] for r in scan if np.isfinite(r[3])]
if len(mxs) >= 2:
steps = np.diff(mxs)
if np.allclose(steps, 10.0, atol=0.5):
print(" C/N 隨係數精確地每十倍加 10 dB —— 模型自洽。")
else:
print(" C/N 每十倍的增量是 %s dB(預期 10.0)。"
% ", ".join("%.2f" % v for v in steps))
print(" **不是 10 dB 就代表模型或設定有問題,要查清楚再往下走。**")
print(" 加大發射功率對雜波完全無效:訊號與雜波等比例增加,訊雜比不動。")
else:
print(" **雜波仍然算不出 C/N。** 這一段沒有結論可下。")
print()
print(" --- MTI 脈衝對消器 ---")
print(" (EnablePulseCanceller 用 Connect 設 true 會 NACK,設 false 反而 ACK,")
print(" 只能用物件模型;NumberOfPulsesToCancel 的合法範圍是 2-5。)")
wf = root.GetObjectFromPath(B.RADAR).Model.Mode.Waveform
mti = []
print(" %-18s %12s %14s %15s"
% ("設定", "偵測率", "最長中斷(s)", "C/N 最大 (dB)"))
wf.EnablePulseCanceller = False
root.ExecuteCommand("ClearAllAccess /")
st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
pd, c0 = clean(st["S/T Integrated PDet w/J+C"]), clean(st["S/T C/N"])
rate, longest, _ = continuity(pd)
base = float(np.nanmax(c0)) if np.isfinite(c0).any() else float("nan")
mti.append(("無 MTI", rate, longest, base))
print(" %-18s %11.1f%% %14.0f %15.2f"
% ("無 MTI", rate * 100, longest, base))
for k2 in MTI_PULSES:
wf.EnablePulseCanceller = True
wf.NumberOfPulsesToCancel = k2
root.ExecuteCommand("ClearAllAccess /")
st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
pd, c2 = clean(st["S/T Integrated PDet w/J+C"]), clean(st["S/T C/N"])
rate, longest, _ = continuity(pd)
mxv = float(np.nanmax(c2)) if np.isfinite(c2).any() else float("nan")
mti.append(("對消 %d 個脈衝" % k2, rate, longest, mxv))
print(" %-18s %11.1f%% %14.0f %15.2f"
% ("對消 %d 個脈衝" % k2, rate * 100, longest, mxv))
wf.EnablePulseCanceller = False
root.ExecuteCommand("ClearAllAccess /")
best = min((r[3] for r in mti[1:] if np.isfinite(r[3])), default=float("nan"))
if np.isfinite(best) and np.isfinite(base):
print()
print(" 脈衝對消器把 C/N 壓低了 %.1f dB,而雜波比雜訊高 %.0f dB。"
% (base - best, base))
print(" 要壓下這種量級的海面雜波,需要真正的都卜勒濾波器組、STAP,")
print(" 或者換幾何:天線架高、縮短距離、加大掠射角。")
return seg, scan, mti, (t, cn, cp), avail
# ==============================================================================
def plot(track, on_water, models, part_d_res, surf_rows, clutter, path):
fig, ax = plt.subplots(2, 3, figsize=(16.5, 8.8))
ta_m = track["Terrain Alt"] * 1000.0
tmin = track["Time"] / 60.0
a = ax[0][0]
# 把地形檔的覆蓋範圍畫出來 —— 這一章最重要的邊界。
# 範圍外 STK 照樣回地形高度(大地基準面),但 TIREM 會讓整個行程停住。
b = B.terrain_bounds()
a.add_patch(plt.Rectangle(
(b["lon_min"], b["lat_min"]),
b["lon_max"] - b["lon_min"], b["lat_max"] - b["lat_min"],
fill=False, ec="0.45", ls="--", lw=1.3,
label="terrain file coverage"))
a.plot(track["Lon"], track["Lat"], lw=2.2, color="C0", label="target track")
a.plot([B.RADAR_LLA[1]], [B.RADAR_LLA[0]], "r^", ms=11, label="coastal radar")
a.set_xlim(b["lon_min"] - 0.05, b["lon_max"] + 0.05)
a.set_ylim(b["lat_min"] - 0.05, b["lat_max"] + 0.05)
a.set_xlabel("longitude (deg)"); a.set_ylabel("latitude (deg)")
a.set_title("A. geometry and terrain-file footprint")
a.legend(fontsize=7, loc="upper right"); a.grid(alpha=.3)
a = ax[0][1]
a.fill_between(tmin, 0, ta_m, color="0.65", label="terrain")
a.plot(tmin, track["Alt"] * 1000.0, lw=1.8, color="C0",
label="target (150 m AGL)")
a.axhline(TERRAIN_SEA_M, ls=":", c="C3", lw=1.2,
label="geoid offset threshold %.0f m" % TERRAIN_SEA_M)
a.set_xlabel("time (min)"); a.set_ylabel("altitude (m)")
a.set_title("B. terrain profile under the track")
a.legend(fontsize=8); a.grid(alpha=.3)
a = ax[0][2]
for label, (t, at) in models.items():
if np.isfinite(at).any():
a.plot(t / 60.0, at, lw=1.7, label=label)
a.set_xlabel("time (min)"); a.set_ylabel("atmospheric attenuation (dB)")
a.set_title("C. propagation models compared")
a.legend(fontsize=8); a.grid(alpha=.3)
a = ax[1][0]
# 兩條曲線**完全重合**,那正是這一格要講的事。
# 所以「關」畫粗且半透明,「開」畫細實線疊在上面 —— 讀者要看得出
# 底下那條還在,而不是以為它沒畫。
for key, lab, c, lw, al in (("off", "constraints off", "C0", 5.0, 0.35),
("on", "constraints on", "C3", 1.6, 1.0)):
if key in part_d_res:
t, at, _, _ = part_d_res[key]
a.plot(t / 60.0, at, lw=lw, alpha=al, color=c, label=lab)
kn = part_d_res.get("knife")
if kn:
a.axhline(kn["theory"], ls="--", c="0.3", lw=1.2,
label="knife-edge theory %.0f dB" % kn["theory"])
a.set_xlabel("time (min)"); a.set_ylabel("TIREM attenuation (dB)")
a.set_title("D. geometry constraints change nothing\n(curves coincide exactly)")
a.legend(fontsize=8); a.grid(alpha=.3)
a = ax[1][1]
# surf_rows: (freq_tag, surface, polarization, k, n, median_dB)
# 兩個頻率各四組。重點是「同一個頻率內完全沒有差異」,
# 所以用分組長條圖,並把每組的極差標出來。
if surf_rows:
tags = []
for r in surf_rows:
if r[0] not in tags:
tags.append(r[0])
width = 0.2
for j, tag in enumerate(tags):
grp = [r for r in surf_rows if r[0] == tag]
meds = [r[5] for r in grp]
xs = [j + (i - 1.5) * width for i in range(len(grp))]
a.bar(xs, meds, width=width * 0.9,
color=["C0", "C1", "C2", "C3"][:len(grp)])
finite = [v for v in meds if np.isfinite(v)]
if len(finite) >= 2:
a.text(j, max(finite) * 1.02,
"spread %.0e dB" % (max(finite) - min(finite)),
ha="center", fontsize=7)
a.set_xticks(range(len(tags)))
a.set_xticklabels(tags, fontsize=8)
a.set_ylabel("median TIREM attenuation (dB)")
a.set_title("E. surface constants x polarization\n(4 bars per frequency)")
a.grid(alpha=.3, axis="y")
a = ax[1][2]
t, cn, cp = clutter
a.plot(t / 60.0, cn, lw=1.7, color="C3", label="C/N")
a.axhline(0, ls=":", c="0.4", lw=1.0)
a.set_xlabel("time (min)"); a.set_ylabel("C/N (dB)")
a.set_title("F. sea clutter")
a.legend(fontsize=8); a.grid(alpha=.3)
fig.suptitle("Coastal radar: TIREM terrain propagation and sea clutter",
fontsize=13)
fig.tight_layout(rect=(0, 0, 1, 0.96))
fig.savefig(path, dpi=125)
print("\n圖已存成 %s" % path)
def main():
app, root, tirem_ok, tirem = B.build(pid=B.cli_pid())
track, on_water, geo = part_a(root)
tirem, rows_b = part_b(root)
models = part_c(root, tirem)
d_res = part_d(root, tirem, track)
# 「視線暢通 vs 地形遮蔽」的判準用 **TIREM 的衰減**,不是幾何約束。
#
# 原本想用「打開幾何約束之後還留得下來的取樣點」當判準,但 Part D
# 量出來幾何約束一個點都沒擋掉 —— 因為 STK 的 LineOfSight 只看橢球體,
# 不含地形。用它當判準會把整條航路都標成「暢通」。
#
# TIREM 的衰減剖面才真的帶著地形資訊:自由空間加氣體吸收只有零點幾 dB,
# 所以衰減的變化幾乎全部來自繞射。
at_t = models.get(tirem, (None, None))[1]
if at_t is not None and np.isfinite(at_t).any():
# 用 TIREM 衰減的**中位數**把航路切成兩半,而不是用一個固定門檻。
# 理由:固定門檻(例如 10 dB)在這個場景會切出 1 : 29,兩邊樣本數
# 差太多,比較沒有意義。用中位數保證兩邊各半,而且門檻本身
# 會跟著場景走 —— 換一條航路不必重調。
# 這裡的「低衰減半段 / 高衰減半段」是**相對**的說法,不是
# 「看得見 / 看不見」的絕對判定。
thr = float(np.nanmedian(at_t))
clear = np.isfinite(at_t) & (at_t < thr)
print()
print(" 航路分段判準:TIREM 衰減中位數 %.2f dB。" % thr)
print(" 低衰減半段 %d 點,高衰減半段 %d 點。"
% (int(clear.sum()), int((~clear).sum())))
else:
clear = np.ones(len(track["Time"]), dtype=bool)
surf, spread_x, spread_v = part_e(root, tirem)
seg, scan, mti, clutter, avail = part_f(root, tirem, clear)
plot(track, on_water, models, d_res, surf, clutter,
os.path.join(HERE, "tirem_coastal.png"))
print()
print("=" * 78)
print("重點回顧")
print("=" * 78)
print(" 1. TIREM 算得出來 —— 「TIREM 不能用」是個錯誤結論。")
print(" 但真正的教訓不是「補齊前提就好」:STK 說明列為硬性前提的")
print(" 兩項(物件以地形為高度參考)實測**不影響**結果,")
print(" 而真正致命的那一項(離開地形檔覆蓋範圍)不是回 -9999.99,")
print(" 是讓整個 STK 停住。文件說的和實際檢查的,是兩回事。")
print()
print(" 2. 地形檔的覆蓋範圍 STK 不告訴你,而且問錯了不會報錯。")
print(" NED_Maui.pdtt 只有 20.5-21.0 N / -156.5--156.0 E(東茂宜),")
print(" 範圍外 `Terrain Alt` 照樣回大地基準面高度,看起來像海面。")
print(" 唯一可靠的辦法是自己讀 .pdtt 檔頭 —— 見 terrain_bounds()。")
print()
print(" 3. `Radar Active Prop Models` 直接告訴你哪個模型真的掛上去了,")
print(" 比回讀設定值可信。回讀證明「你寫進去了」,它證明「STK 用了」。")
print()
print(" 4. **幾何約束沒有擋掉任何東西。** STK 的 LineOfSight 只看橢球體,")
print(" 不含地形;要讓地形進到 access 判定得另外算 Az-El 遮罩。")
print(" 所以「對照組沒有差別」在這裡不代表「沒有影響」,")
print(" 而代表**你以為在對照的東西根本沒開**。")
print()
print(" 5. **TIREM 的地表電性參數對輸出沒有任何影響。**")
print(" 兩個頻率、四種組合,差 0.000000000 dB —— 而 VHF 對照")
print(" 排除了「這是物理」的解釋。這些值寫得進去、通過 STK 的")
print(" 範圍檢查、回讀正確,然後不影響結果。這是最難察覺的一種")
print(" 靜默失敗:你拿到的是一個合理但與你的設定無關的數字。")
print()
print(" 6. **開了 TIREM 就拿不到雜波。** 雜波功率與 C/N 全變 -9999.99,")
print(" 而 SNR 與 PDet 照常有值 —— 報表看起來是完整的。")
print(" 地形傳播與雜波在 STK 12.10 裡拿不到同一份,只能跑兩趟。")
print()
print(" 7. 海面雜波比雜訊高數十 dB,加功率無效,簡單 MTI 也遠遠不夠。")
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
# ==============================================================================
# 岸際雷達 — 展示動畫
#
# 設計目標:**投影出來三公尺外看得懂**,不是資訊完整。
# 所以只講一件事:目標往東飛,Haleakalā 逐漸擋進電波路徑,
# TIREM 的衰減從 0 爬到 73 dB,而 ITU-R P676 幾乎不動。
#
# 兩格:
# 上 地圖 —— 地形檔覆蓋框、雷達、目標、雷達到目標的連線
# (連線顏色隨當下的 TIREM 衰減變化)
# 下 衰減對時間 —— TIREM 與 ITU-R P676 兩條線,加一個跟著跑的游標
#
# 資料只抓一次(兩個模型各一趟),之後純離線畫圖 ——
# 動畫是拿來看的,不該每一格都回頭問 STK。
#
# 執行:python tirem_animate.py --pid <PID>
# ==============================================================================
import os
import sys
import matplotlib
import numpy as np
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, os.pardir, "tools"))
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from cjk import use_cjk # noqa: E402
use_cjk()
from matplotlib.animation import FuncAnimation, PillowWriter # noqa: E402
from matplotlib.patches import Rectangle # noqa: E402
import tirem_build as B # noqa: E402
import tirem_analysis as A # noqa: E402
OUT = os.path.join(HERE, "tirem_coastal_anim.gif")
FPS = 3
STEP = 15.0
def gather(root):
"""抓一次資料就好:航跡、TIREM 衰減、ITU 衰減。"""
B.set_los(root, False)
tirem = B.newest_tirem(root)
B.set_atmos(root, tirem)
at_t, t = A.atmos_series(root, step=STEP)
B.set_atmos(root, A.ATMOS_BASELINE)
at_i, _ = A.atmos_series(root, step=STEP)
track = A.target_track(root, step=STEP)
n = min(len(t), len(track["Lat"]), len(at_t), len(at_i))
return dict(t=t[:n], lat=track["Lat"][:n], lon=track["Lon"][:n],
tirem=at_t[:n], itu=at_i[:n], name=tirem)
def render(data, out=OUT):
b = B.terrain_bounds()
rlat, rlon = B.RADAR_LLA
t = data["t"]
lat, lon = data["lat"], data["lon"]
tir, itu = data["tirem"], data["itu"]
n = len(t)
hi = float(np.nanmax(tir[np.isfinite(tir)])) if np.isfinite(tir).any() else 1.0
fig = plt.figure(figsize=(13.2, 6.4))
gs = fig.add_gridspec(1, 2, width_ratios=[1.0, 1.28], wspace=0.26,
left=0.06, right=0.975, top=0.80, bottom=0.12)
ax_m, ax_a = fig.add_subplot(gs[0]), fig.add_subplot(gs[1])
# ---- 上:地圖 ----
ax_m.add_patch(Rectangle((b["lon_min"], b["lat_min"]),
b["lon_max"] - b["lon_min"],
b["lat_max"] - b["lat_min"],
fill=False, ec="0.55", ls="--", lw=1.4))
ax_m.text(b["lon_min"] + 0.01, b["lat_max"] - 0.03,
"terrain file coverage", fontsize=9, color="0.45")
ax_m.plot(lon, lat, lw=1.6, color="0.75", zorder=1)
ax_m.plot([rlon], [rlat], "^", ms=15, color="#AC3427", zorder=4)
ax_m.text(rlon + 0.02, rlat + 0.012, "coastal radar", ha="left",
fontsize=11, color="#AC3427", weight="bold")
los, = ax_m.plot([], [], lw=3.0, alpha=.9, zorder=2)
tgt, = ax_m.plot([], [], "o", ms=13, color="#1B5E9E", zorder=5)
ax_m.set_xlim(b["lon_min"] - 0.04, b["lon_max"] + 0.04)
ax_m.set_ylim(b["lat_min"] - 0.04, b["lat_max"] + 0.04)
# 等比例:圖磚是 0.5 度見方,畫成長方形會讓雷達->目標的連線角度失真
ax_m.set_aspect("equal", adjustable="box")
ax_m.set_xlabel("longitude (deg)", fontsize=11)
ax_m.set_ylabel("latitude (deg)", fontsize=11)
ax_m.grid(alpha=.25)
ax_m.tick_params(labelsize=10)
# ---- 下:衰減 ----
ax_a.plot(t / 60.0, itu, lw=2.2, color="#C77C1E",
label="ITU-R P676-13 (gas only)")
ax_a.plot(t / 60.0, tir, lw=2.6, color="#2F6B3A",
label="TIREM (terrain diffraction)")
cur, = ax_a.plot([], [], "o", ms=12, color="#2F6B3A", zorder=5)
vline = ax_a.axvline(t[0] / 60.0, color="0.5", lw=1.2, ls=":")
ax_a.set_xlim(t[0] / 60.0, t[-1] / 60.0)
ax_a.set_ylim(-4, hi * 1.12)
ax_a.set_xlabel("time (min)", fontsize=11)
ax_a.set_ylabel("attenuation (dB)", fontsize=11)
ax_a.legend(fontsize=11, loc="upper left")
ax_a.grid(alpha=.25)
ax_a.tick_params(labelsize=10)
title = fig.text(0.5, 0.945, "", ha="center", fontsize=19, weight="bold")
read = fig.text(0.5, 0.875, "", ha="center", fontsize=15, color="#333333")
cmap = plt.get_cmap("YlOrRd")
def frame(i):
v = tir[i]
ok = np.isfinite(v)
los.set_data([rlon, lon[i]], [rlat, lat[i]])
los.set_color(cmap(min(max((v if ok else 0.0) / max(hi, 1.0), 0.0), 1.0)))
tgt.set_data([lon[i]], [lat[i]])
cur.set_data([t[i] / 60.0], [v if ok else 0.0])
vline.set_xdata([t[i] / 60.0, t[i] / 60.0])
title.set_text("岸際雷達:地形如何吃掉訊號")
read.set_text(
"t = %5.1f min TIREM %6.1f dB ITU-R P676 %4.2f dB"
% (t[i] / 60.0, v if ok else float("nan"), itu[i]))
return los, tgt, cur, vline, title, read
anim = FuncAnimation(fig, frame, frames=n, blit=False)
anim.save(out, writer=PillowWriter(fps=FPS))
plt.close(fig)
return out, n
def main():
app, root, ok, name = B.build(pid=B.cli_pid(),
force="--force" in sys.argv)
print("\n--- 抓資料(兩個模型各一趟)---")
data = gather(root)
print(" %d 個取樣點,TIREM %.2f – %.2f dB,ITU %.2f – %.2f dB"
% (len(data["t"]),
np.nanmin(data["tirem"]), np.nanmax(data["tirem"]),
np.nanmin(data["itu"]), np.nanmax(data["itu"])))
print("\n--- 畫動畫 ---")
out, n = render(data)
print(" %d 格 @ %d fps -> %s (%.0f KB)"
% (n, FPS, out, os.path.getsize(out) / 1024.0))
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# -*- coding: utf-8 -*-
# ==============================================================================
# 岸際雷達的地形傳播與海面雜波 — 場景建構
#
# 分工:
# tirem_coastal_Connect.txt Connect 做得到的一切
# 本檔 Connect **做不到**的三件事:
# (1) Place 的 Use Terrain Data
# (2) 載具航點的高度參考 = Terrain
# (3) 挑 TIREM 元件並設定它的地表電性與極化
#
# 為什麼 (1)(2) 一定要用物件模型:
# STK 說明要求「所有物件都以地形為高度參考」才能用 TIREM。Connect 的
# `SetHeightAboveGround` 會 ACK,但回讀 place.UseTerrain 仍然是 False ——
# 它只設了高度,沒有把「使用地形」打開。GreatArc 的高度參考更是
# 完全沒有對應的 Connect 指令。
#
# **但實測顯示這兩項都不是 TIREM 真正的檢查條件**:把它們關掉,
# TIREM 照樣算得出來,數值差不到 1 dB(見 tirem_analysis.py Part B)。
# 這裡仍然照文件把它們設好 —— 照文件做是對的 —— 但不要以為
# 「設好了所以 TIREM 才會動」。文件說的前提與實際生效的前提是兩回事。
#
# 為什麼 (3) 要用物件模型:
# 大氣吸收模型是一個「元件」(component),要從一份可用清單裡挑,
# 而且它掛在 **PropagationChannel** 底下,不是掛在 RFEnvironment 上。
# 更要命的是它有兩層:場景層與物件層,**雷達用的是物件層那一份**。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python tirem_build.py
# ==============================================================================
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, "tirem_coastal_Connect.txt")
PLACE = "*/Place/CoastRadar"
RADAR = "*/Place/CoastRadar/Sensor/Servo/Radar/Surveillance"
TGT = "*/Aircraft/LowFlyer"
TERRAIN = ("C:/Program Files/AGI/STK 12/Data/Resources/stktraining/imagery/"
"NED_Maui.pdtt")
# 岸際雷達站:茂宜島**南岸**(Kihei 一帶),天線 30 m。
# 為什麼不是西岸:NED_Maui.pdtt 只涵蓋 -156.5 到 -156.0,
# 西茂宜(-156.6 以西)完全不在檔案裡 —— 見 terrain_bounds()。
RADAR_LLA = (20.7650, -156.4450)
RADAR_AGL_KM = 0.030
# 航路:茂宜島南方外海 -> 繞過東岸 -> 東北方外海。
#
# **整條都必須落在 NED_Maui.pdtt 的覆蓋範圍內**(20.5-21.0 N / -156.5--156.0 E)。
# 飛出覆蓋範圍時,TIREM 不是回 -9999.99,而是讓整個 STK 停住
# (CPU 掉到 0.000/s,沒有錯誤也沒有對話框)。build() 會在送出任何指令
# 之前先用 assert_inside_terrain() 擋下來 —— 那是本章唯一有效的防呆。
#
# 航路分兩段:
# 西段 目標在雷達正南方外海(約 25 km),電波路徑全程在水面上,視線暢通
# 東段 目標沿南方外海東行,Haleakala(3055 m)逐漸擋進電波路徑
#
# 全部航點都在**離岸的水面上**(緯度 20.53-20.54,茂宜島最南端在 20.585)。
# 這是必要的:GreatArc 的 Terrain 高度參考只釘住航點,航點之間的高度是
# 直線內插的,只要航路跨過陸地就可能鑽進山裡。改版過程量到過
# 62.1 m 的穿山深度,而 STK 完全沒有警告。
#
# 目標**全程在水面上**,這是刻意的:
# GreatArc 把高度參考設成 Terrain,只保證**航點**在地形之上 ——
# 航點之間的高度是直線內插的。航路若跨過西茂宜山脈,
# 航路下方地形最高 926 m、目標最高只有 516 m:它會從山裡穿過去,
# 而 STK 一聲不吭。
#
# 改成全程在水面上之後,「被地形擋住」這件事就完全發生在**電波路徑**上
# (雷達與目標之間隔著山),而不是發生在目標自己的高度上。這才是
# 岸際雷達真正的問題形狀。
WAYPOINTS = [(20.5400, -156.4500), # 正南方外海,雷達看過去是純海面路徑
(20.5350, -156.3500), # 東行
(20.5300, -156.2500), # 東行
(20.5300, -156.1500), # 東行,Haleakala 開始擋住視線
(20.5350, -156.0600)] # 東南方外海,山體完全擋在中間
TGT_AGL_KM = 0.150 # 地形(海面)之上 150 m
TGT_SPEED_KMS = 0.08
# 地表電性:TIREM 的 Surface Conductivity / Relative Permittivity。
# STK 說明明講:在海平面上用 .pdtt 地形檔,TIREM「多半不會把它當成海水」——
# 因為 .pdtt 在開闊海面上回的不是 0.0 MSL。本章把這句話變成可量化的實驗:
# 手動把地表電性換成海水值,看結果差多少。
# 值取自 ITU-R P.527(海水 vs 一般陸地)的低頻標準值。
SURFACE = {
"land": dict(RelativePermittivity=15.0, SurfaceConductivity=0.005),
"sea": dict(RelativePermittivity=80.0, SurfaceConductivity=5.0),
}
SURFACE_LABEL = {"land": "陸地(TIREM 預設)", "sea": "海水"}
POLARIZATION = {"vertical": 0, "horizontal": 1} # AgETiremPolarizationType
ALT_REF_MSL, ALT_REF_TERRAIN, ALT_REF_WGS84 = 0, 1, 2
SCENARIO = "TIREMCoastal"
# ------------------------------------------------------------------------------
def terrain_bounds(path=None):
"""直接讀 .pdtt 檔頭,拿出這個地形檔真正的覆蓋範圍(度)。
為什麼要自己讀檔:
* STK **沒有**任何 API 或 Connect 指令告訴你一個地形檔涵蓋哪裡。
`Terrain Alt` 在覆蓋範圍外照樣回值(回的是大地基準面高度),
所以從分析端根本分不出「這裡是海」與「這裡沒有資料」。
* 而物件一旦離開覆蓋範圍,TIREM 不是回 -9999.99,是讓整個 STK 停住。
也就是說:這是一個**沒有回饋、後果嚴重**的邊界。唯一可靠的辦法
就是繞過 STK,自己讀檔頭。
.pdtt 檔頭(小端序)在固定位移上放四個 double,單位是**弧度**:
24 lat_min 32 lat_max 40 lon_min 48 lon_max
本教材用 NED_Maui.pdtt 驗證過,解出 20.5 - 21.0 N / -156.5 - -156.0 E,
正好是一塊 0.5 度見方的 USGS NED 圖磚(東茂宜 / Haleakala)。
注意:這個檔名叫 "Maui",但它**不含西茂宜山脈,也不含 Lanai**。
把雷達架在西茂宜(-156.675)的話,整條航路都在覆蓋範圍外。
"""
import math
import struct
path = path or TERRAIN
with open(path, "rb") as f:
head = f.read(64)
lat0, lat1, lon0, lon1 = (math.degrees(v) for v in
struct.unpack_from("<4d", head, 24))
return dict(lat_min=lat0, lat_max=lat1, lon_min=lon0, lon_max=lon1)
def assert_inside_terrain(points, path=None, margin_deg=0.01, label="航點"):
"""確認每一個點都落在地形檔覆蓋範圍內,否則直接中止。
這是本章唯一有效的防呆:與其在 STK 停住之後花半小時等它恢復,
不如在送任何指令之前就先擋下來。
"""
b = terrain_bounds(path)
bad = [(lat, lon) for lat, lon in points
if not (b["lat_min"] + margin_deg <= lat <= b["lat_max"] - margin_deg
and b["lon_min"] + margin_deg <= lon
<= b["lon_max"] - margin_deg)]
if bad:
raise SystemExit(
"\n中止:%d 個%s落在地形檔覆蓋範圍外。\n"
" 地形檔:%s\n"
" 覆蓋範圍:緯度 %.4f – %.4f,經度 %.4f – %.4f(已留 %.2f 度邊界)\n"
" 超出範圍的點:%s\n"
" 這不是小問題:物件離開覆蓋範圍時,TIREM 不會回 -9999.99,\n"
" 而是讓整個 STK 停住(CPU 掉到 0.000/s,沒有錯誤也沒有對話框),\n"
" 之後那個實例要無回應數十分鐘才會自己恢復。\n"
% (len(bad), label, os.path.basename(path or TERRAIN),
b["lat_min"], b["lat_max"], b["lon_min"], b["lon_max"],
margin_deg,
", ".join("(%.4f, %.4f)" % p for p in bad)))
return b
def stk_pids():
"""列出目前開著的 STK 桌面版行程。"""
import subprocess
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq AgUiApplication.exe", "/FO", "CSV",
"/NH"], capture_output=True, text=True, timeout=20).stdout
except Exception:
return []
pids = []
for line in out.splitlines():
parts = [p.strip('" ') for p in line.split('","')]
if len(parts) >= 2 and parts[1].isdigit():
pids.append(int(parts[1]))
return pids
def clear_children(root):
"""把場景裡的物件逐一卸載,但**不關場景**。
為什麼不用 CloseScenario():本章實測它會讓 STK 停住 ——
尤其是當上一輪的場景裡留著一條「離開地形檔覆蓋範圍」的航路、
而且 TIREM 還開著的時候。關場景會去碰那些物件,於是又踩一次同樣的雷。
重用場景(清物件 + 重跑 Connect 腳本)完全避開這件事,
而且比關掉再開快得多。
"""
sc = root.CurrentScenario
n = 0
while sc.Children.Count:
before = sc.Children.Count
try:
sc.Children.Item(0).Unload()
n += 1
except Exception:
break
if sc.Children.Count >= before:
break
return n, sc.Children.Count
def attach(force=False, expected=SCENARIO, pid=None):
"""接上 STK,但**先確認接到的是哪一個實例**再決定要不要關場景。
為什麼要這一段(本章實測踩到的坑):
1. `STKDesktop.AttachToApplication()` 是從 Running Object Table 拿實例。
機器上開著兩個 STK 時,拿到哪一個是**不確定的** —— 不是最後開的、
也不是有場景的那一個。
2. 本教材大部分腳本第一件事就是 `CloseScenario()`。
兩件事湊在一起,就是「腳本安靜地把另一個視窗裡別人正在做的工作
關掉」,而且全程 ACK、沒有任何警告。
3. 更糟的是:如果那個實例的 COM 卡住了(例如上一個用戶端在 RPC 中途
被強制中止),不帶 pid 的 AttachToApplication 會**整個掛住不回來**,
而不是報錯。這時候唯一的出路就是指名另一個 pid。
(那種卡住是暫時的:實測會無回應數十分鐘 —— 推測是 Windows RPC
逾時在跑 —— 然後自己恢復,不需要重開 STK。)
所以這裡的規則是:多於一個實例時**必須指名 pid**,不猜。
"""
if pid is None:
pids = stk_pids()
if len(pids) > 1:
raise SystemExit(
"\n中止:偵測到 %d 個 STK 實例(PID %s)。\n"
" AttachToApplication() 不指定 pid 時,抓到哪一個是不確定的,\n"
" 而且如果抓到的那個 COM 卡住,呼叫會直接掛住不回來。\n"
" 請關掉多餘的 STK 視窗,或指名要用哪一個:\n"
" python tirem_build.py --pid <PID>\n"
% (len(pids), ", ".join(str(p) for p in pids)))
app = (STKDesktop.AttachToApplication(pid=pid) if pid
else STKDesktop.AttachToApplication())
root = app.Root
sc = root.CurrentScenario
name = None
if sc is not None:
try:
name = sc.InstanceName
except Exception:
name = "<讀不到名稱>"
if sc is None:
print("已接上 STK(目前沒有開啟場景)")
return app, root, False
# 只有「場景名字就是本章的」才可以就地重用。
#
# 為什麼要分這麼細:就地重用會跳過 `New / Scenario` **與 `Terrain * Add`**。
# 如果接到的是別章的場景(例如 EOIRSoundingRocket),跳過那兩行的結果是:
# 物件在本章的場景設定下建起來,但**地形來源是別章的**。
# 而 `terrain_loaded()` 這時候仍然會回 True —— 因為它讀得到大地基準面高度。
# 本教材第 5.2 節講的正是這件事:範圍外/沒有地形時,Terrain Alt 照樣回值。
# 這個坑連在寫守衛的時候都很容易再踩一次。
if name == expected:
print("已接上 STK(場景 %s,就地清空重建)" % name)
cleared, left = clear_children(root)
print(" 卸載 %d 個物件,剩 %d 個(不關場景 —— CloseScenario 會讓 STK 停住)"
% (cleared, left))
if left == 0:
return app, root, True # True = 場景已存在,要跳過 New / Scenario
print(" 清不乾淨,退回關場景")
elif force:
print("已接上 STK(場景 %s 不是本章的,--force:關掉重建)" % name)
# 為什麼要包 try:本教材觀察過一次 CloseScenario() 之後 STK 行程
# 直接消失,呼叫端拿到 HRESULT 0x800706BE (RPC_S_CALL_FAILED) ——
# 那個代碼的意思是「RPC 端點不見了」,也就是**行程已經沒了**,
# 不是「這個呼叫失敗了」。
# 注意:那一次的因果**沒有確認**(同一時間還有別的用戶端被中止),
# 所以不要把「CloseScenario 會殺掉 STK」當成已知事實。
# 但不論原因是什麼,把它包起來、給人看得懂的訊息都是值得的。
try:
root.CloseScenario()
except Exception as e:
raise SystemExit(
"\n中止:CloseScenario() 失敗 —— %s\n"
" 0x800706BE (RPC_S_CALL_FAILED) 通常代表這個 STK 行程已經\n"
" 不在了。請重開 STK,再用 --pid 指定新的實例。\n"
% str(e)[:120])
return app, root, False
raise SystemExit(
"\n中止:接到的 STK 實例目前開著的場景是「%s」,不是本章的「%s」。\n"
" 機器上可能開了不只一個 STK,AttachToApplication 抓到的不一定是\n"
" 你想要的那一個。繼續下去會把這個場景關掉。\n"
" 處理方式:在 STK 裡關掉不要的場景 / 視窗,或確認無誤後改用\n"
" build(force=True)(或 `python tirem_build.py --force`)。\n"
% (name, expected))
def connect(root, path, echo_fail=True, skip_new_scenario=False):
ok = fail = 0
for n, line in enumerate(open(path, encoding="utf-8", errors="replace"), 1):
cmd = line.strip()
if not cmd or cmd.startswith("#"):
continue
if skip_new_scenario and (
cmd.startswith("New / Scenario")
or cmd.startswith("Terrain * Add")
or cmd.startswith("TerrainServer /")):
# 重用場景時,場景層的設定(地形來源)還在,不必也不該重下。
# 重複 `Terrain * Add` 同一個檔案會疊加來源,而每一次疊加都
# 讓後續的地形查詢更慢 —— 這是本章第四次 STK 停住的可疑成因之一。
continue
try:
root.ExecuteCommand(cmd)
ok += 1
except Exception:
fail += 1
if echo_fail:
print(" NACK L%-4d %s" % (n, cmd[:100]))
print("%s: %d 條 ACK, %d 條 NACK" % (os.path.basename(path), ok, fail))
return ok, fail
# ---- TIREM 前提 (2):兩個 Connect 做不到的高度參考 ---------------------------
def use_terrain_place(root, on=True, agl_km=RADAR_AGL_KM):
"""把 Place 的 Use Terrain Data 打開,並設定天線離地高度。
回傳 (設定前的 UseTerrain, 設定後的 UseTerrain)。
Connect 的 SetHeightAboveGround 跑完之後前者仍然是 False ——
這就是「ACK 了但沒生效」最乾淨的一個實例,驗證段有一項專門盯它。
"""
pl = root.GetObjectFromPath(PLACE)
before = bool(pl.UseTerrain)
pl.UseTerrain = bool(on)
pl.HeightAboveGround = agl_km
return before, bool(pl.UseTerrain)
def build_route(root, waypoints=None, alt_ref=ALT_REF_TERRAIN,
agl_km=TGT_AGL_KM):
"""建航點,並把高度參考設成 Terrain。
GreatArc 的高度參考沒有對應的 Connect 指令,只能走 SetAltitudeRefType。
設完參考型別之後航點高度的意義就變了(地形之上,而不是海平面之上),
所以航點必須排在設定參考型別**之後**才建。
"""
ac = root.GetObjectFromPath(TGT)
r = ac.Route
r.SetAltitudeRefType(alt_ref)
r.Waypoints.RemoveAll()
for lat, lon in (waypoints or WAYPOINTS):
w = r.Waypoints.Add()
w.Latitude, w.Longitude = lat, lon
w.Altitude, w.Speed = agl_km, TGT_SPEED_KMS
r.Propagate()
return int(r.AltitudeRefType)
# ---- TIREM 前提 (1)(3):地形載入與覆蓋範圍 -----------------------------------
def terrain_profile(root, step=30.0):
"""航路下方的地形剖面。
`Terrain * Add ... UseTerrain On` 就算檔案路徑打錯也會 ACK。
真正的判準是:目標航路下方取不取得到地形高度。
"""
tgt = root.GetObjectFromPath(TGT)
sc = root.CurrentScenario
dp = tgt.DataProviders.Item("LLA State").Group.Item("Fixed")
r = dp.ExecElements(sc.StartTime, sc.StopTime, step,
["Time", "Lat", "Lon", "Alt", "Terrain Alt"])
out = {}
for i in range(r.DataSets.Count):
d = r.DataSets.Item(i)
out[d.ElementName] = [v if isinstance(v, (int, float)) else None
for v in d.GetValues()]
return out
def terrain_loaded(root, step=60.0):
p = terrain_profile(root, step)
vals = [v * 1000.0 for v in p.get("Terrain Alt", []) if v is not None]
if not vals:
return False, 0.0, 0.0
return True, min(vals), max(vals)
# ---- TIREM 元件 --------------------------------------------------------------
def propagation_channel(root, path=RADAR):
"""雷達**物件層**的傳播通道。設場景層那一份不會影響雷達。"""
return root.GetObjectFromPath(path).RFEnvironment.PropagationChannel
def atmosphere_models(root):
try:
return list(propagation_channel(root).SupportedAtmosAbsorptionModels)
except Exception:
return []
def newest_tirem(root):
t = [m for m in atmosphere_models(root) if "TIREM" in m.upper()]
return sorted(t)[-1] if t else None
def set_atmos(root, model):
"""設定雷達的大氣吸收模型。model=None 代表關閉。回傳模型元件物件。"""
pc = propagation_channel(root)
if model is None:
pc.EnableAtmosAbsorption = False
root.ExecuteCommand("ClearAllAccess /")
return None
pc.EnableAtmosAbsorption = True
pc.SetAtmosAbsorptionModel(model)
root.ExecuteCommand("ClearAllAccess /")
return pc.AtmosAbsorptionModel
def tirem_settings(root):
"""回讀 TIREM 元件的全部參數。換成 ITU-R P676 之後這些屬性就不存在。"""
m = propagation_channel(root).AtmosAbsorptionModel
return dict(RelativePermittivity=float(m.RelativePermittivity),
SurfaceConductivity=float(m.SurfaceConductivity),
PolarizationType=int(m.PolarizationType),
SurfaceTemperature=float(m.SurfaceTemperature),
SurfaceHumidity=float(m.SurfaceHumidity),
SurfaceRefractivity=float(m.SurfaceRefractivity))
def set_tirem_surface(root, surface=None, polarization=None):
"""設定 TIREM 的地表電性與極化,回傳實際回讀值。"""
m = propagation_channel(root).AtmosAbsorptionModel
if surface:
for k, v in surface.items():
setattr(m, k, v)
if polarization is not None:
m.PolarizationType = polarization
root.ExecuteCommand("ClearAllAccess /")
return tirem_settings(root)
AZEL_MASK_FILE, AZEL_TERRAIN, AZEL_NONE = 0, 1, 2
AZEL_MAX_RANGE_KM = 40.0
def build_terrain_mask(root, on=True, max_range_km=AZEL_MAX_RANGE_KM):
"""從地形算出地面站的方位-仰角遮罩。
*** 警告:本機實測這個函式會讓 STK 停住(CPU 掉到 0.000/s)。 ***
所以它**沒有被本章的正常流程呼叫**,只留在這裡當文件與加分題。
要試的人請有心理準備:那個 STK 實例會無回應數十分鐘才自己恢復。
這是本章第三個「會停住而不是報錯」的操作,前兩個在
tirem_terrain_bounds_demo.py 裡。
**載入地形不會讓 access 自動考慮地形。** 這是本章第三個容易誤判的地方:
以為「載了地形之後,視線約束本身就會考慮地形遮蔽」,看到有效樣本
從 64 掉到 43,就宣稱地形擋掉 33% 的航路。
實際上那 33% 是**地球曲率**(目標最遠 113 km,遠超過 30 m 天線對
150 m 目標的雷達地平線),跟地形一點關係也沒有。
要讓地形進到 access 判定,必須做兩件事:
1. 在地面物件上用地形算出一張 Az-El 遮罩(這個函式)
2. 打開該物件的 AzElMask 約束(set_los 負責)
兩件事都沒有 Connect 指令 —— `AzElMask <path> Terrain ...` 一律 NACK。
"""
pl = root.GetObjectFromPath(PLACE)
if on:
# 順序不能顛倒:遮罩型別還是 None 的時候,MaxRangeWhenComputingAzElMask
# 是**唯讀**的(丟 "Cannot modify a read only property")。
# 跟雙基地那一章的雜波子樹是同一個模式:先打開,才寫得進去。
pl.SetAzElMask(AZEL_TERRAIN, RADAR_AGL_KM)
try:
pl.MaxRangeWhenComputingAzElMask = max_range_km
except Exception as e:
print(" (MaxRangeWhenComputingAzElMask 寫不進去:%s;沿用預設)"
% str(e)[:60])
else:
pl.SetAzElMask(AZEL_NONE, 0.0)
root.ExecuteCommand("ClearAllAccess /")
return int(pl.GetAzElMask())
def set_los(root, on):
"""幾何視線約束開關。
AGI 對 TIREM 的最佳實務是**關掉**它,讓 TIREM 自己算越過地平線的傳播。
這裡切換的是 LineOfSight —— 而 LineOfSight 是**橢球體**的視線,
不含地形。要讓地形進到 access 判定,必須另外用 build_terrain_mask()
在地面站上算一張 Az-El 遮罩,但那個操作在本機會讓 STK 停住,
所以本章不走那條路,改用 TIREM 自己的衰減剖面來標定遮蔽段(Part D)。
這一點很容易搞錯:以為「載了地形之後,視線約束本身就會考慮地形
遮蔽」,看到有效樣本從 64 掉到 43 就宣稱地形擋掉 33% 的航路。
實際上 LineOfSight 只看橢球體,那 33% 是**地球曲率** ——
目標最遠 113 km,而 30 m 天線對 150 m 目標的雷達地平線
大約只有 72 km。
"""
for p in [PLACE, "*/Place/CoastRadar/Sensor/Servo", RADAR, TGT]:
try:
root.ExecuteCommand("SetConstraint %s LineOfSight %s"
% (p, "On" if on else "Off"))
except Exception:
pass
root.ExecuteCommand("ClearAllAccess /")
# ------------------------------------------------------------------------------
def build(use_tirem=True, verbose=True, force=False, pid=None):
# 先擋再說:在送出任何 STK 指令之前,確認雷達站與整條航路都落在
# 地形檔覆蓋範圍內。飛出去的代價是 STK 停住數十分鐘,而且沒有錯誤訊息,
# 所以這個檢查必須排在最前面 —— 連線之前。
b = assert_inside_terrain([RADAR_LLA] + list(WAYPOINTS), label="場景座標")
if verbose:
print("地形檔覆蓋:緯度 %.4f – %.4f,經度 %.4f – %.4f(座標全部在範圍內)"
% (b["lat_min"], b["lat_max"], b["lon_min"], b["lon_max"]))
app, root, reuse = attach(force=force, pid=pid) # 保留 app,否則 COM 失效
connect(root, SCRIPT, skip_new_scenario=reuse)
if verbose:
print("\n--- TIREM 前提 (2):Connect 做不到的兩個高度參考 ---")
before, after = use_terrain_place(root)
if verbose:
print(" Place.UseTerrain:Connect 跑完是 %s,物件模型設定後 %s"
% (before, after))
print(" (SetHeightAboveGround 有 ACK,但沒把 Use Terrain Data 打開)")
ref = build_route(root)
if verbose:
print(" 航點高度參考 = %d (1 = Terrain)" % ref)
if verbose:
print("\n--- TIREM 前提 (1)(3):地形 ---")
ok_terr, lo, hi = terrain_loaded(root)
if verbose:
if ok_terr:
print(" 地形已載入分析:航路下方 %.1f – %.1f m" % (lo, hi))
else:
print(" 地形沒有載入分析 —— TIREM 一定算不出來。")
name = None
if use_tirem:
if verbose:
print("\n--- 大氣吸收模型 ---")
print(" 可用:%s" % ", ".join(atmosphere_models(root)))
name = newest_tirem(root)
if name:
set_atmos(root, name)
if verbose:
print(" 已啟用:%s" % name)
elif verbose:
print(" 這台機器沒有安裝 TIREM。")
else:
set_atmos(root, None)
root.ExecuteCommand("ClearAllAccess /")
return app, root, bool(name), name
def cli_pid(argv=None):
argv = argv or sys.argv
if "--pid" in argv:
i = argv.index("--pid")
if i + 1 < len(argv):
return int(argv[i + 1])
return None
def main():
app, root, ok, name = build(force="--force" in sys.argv, pid=cli_pid())
print("\n--- 檢查 ---")
rad = root.GetObjectFromPath(RADAR)
acc = rad.GetAccessToObject(root.GetObjectFromPath(TGT))
acc.ComputeAccess()
print(" 雷達對低空目標的 access 區間數 = %d"
% acc.ComputedAccessIntervalTimes.Count)
print(" TIREM:%s" % (name if ok else "未啟用"))
if ok:
s = tirem_settings(root)
print(" TIREM 參數:eps_r=%.1f sigma=%.4f S/m 極化=%s"
% (s["RelativePermittivity"], s["SurfaceConductivity"],
"垂直" if s["PolarizationType"] == 0 else "水平"))
return 0
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)
# ==============================================================================
# 岸際雷達的地形傳播與海面雜波 — TIREM
#
# 本章要回答的問題:一部架在海岸上的 X 頻段監視雷達,面對貼海面進來、
# 最後躲到山後的低空目標,地形與海面雜波各自吃掉多少偵測能力?
#
# ------------------------------------------------------------------------------
# TIREM 的五個硬性前提(STK 說明 Help/Content/comm/AtmosModelTIREM.htm)
#
# ① 場景要有**本機**地形檔(不能用 terrain server)
# ② 物件必須以**地形**為高度參考
# Facility/Place:Use Terrain Data 打勾 + Height Above Ground >= 0.5 m
# 載具: Altitude Reference = Terrain,航點高度 >= 0.5 m
# ③ 物件不能飛出地形檔的覆蓋範圍
# ④ 收發兩端都要低於 30 km
# ⑤ 頻率在 1 MHz - 40 GHz
#
# 少了任何一個,TIREM 都**不會報錯**,只會把 -9999.99 填進去。
# 本章 Part B 把這五項逐一關掉,做成消去表 —— 那才是這一章真正的主題。
#
# 注意:② 兩項 Connect **都做不到**。
# `SetHeightAboveGround` 會 ACK,但不會把 Place 的 Use Terrain Data 打開
# (回讀 place.UseTerrain 仍是 False);GreatArc 的高度參考也沒有 Connect 指令。
# 兩者都只能用物件模型,見 tirem_build.py。
# 這正是本教材「Connect ACK 不等於生效」最乾淨的一個例子。
#
# 參考:AGI 訓練單元 Communications Part 4 — Introduction to TIREM
# https://help.agi.com/stk/12.8.0/Content/training/tirem.htm
#
# 執行:python tirem_build.py
# ==============================================================================
# ---- 場景 --------------------------------------------------------------------
New / Scenario TIREMCoastal
SetAnalysisTimePeriod * "1 Jun 2024 00:00:00.000" "1 Jun 2024 00:12:00.000"
SetUnits / km
# ---- 地形(前提 ①)-----------------------------------------------------------
# TIREM 只吃本機地形檔。這是硬性限制,不是偏好 —— 關掉地形伺服器,
# 免得「看起來有地形」但 TIREM 拿不到。
TerrainServer / UseAgiServer No
Terrain * Add Type PDTT File "C:/Program Files/AGI/STK 12/Data/Resources/stktraining/imagery/NED_Maui.pdtt" UseTerrain On
# ---- 岸際雷達站 --------------------------------------------------------------
# 茂宜島西岸 Lahaina 一帶的岸際觀測點,天線高度 30 m。
# UseTerrain 要在 tirem_build.py 用物件模型補打開 —— Connect 做不到。
New / */Place CoastRadar
SetPosition */Place/CoastRadar Geodetic 20.7650 -156.4450 0.0
SetHeightAboveGround */Place/CoastRadar 0.030
# ---- 低空目標 ----------------------------------------------------------------
# 航路刻意設計成三段,而且**整條都落在 NED_Maui.pdtt 的覆蓋範圍內**(前提 ③):
# 1. 海面段 'Au'au 海峽上空,純海水路徑,視線暢通
# 2. 越脊段 爬過西茂宜山脈(Puu Kukui 1764 m)
# 3. 遮蔽段 落到山脊背後的中央地峽 —— 幾何上完全看不見,
# 只有繞射與表面波還到得了。這一段是 TIREM 唯一能給、
# 純幾何算不出來的東西。
# 航點的高度參考要在 tirem_build.py 改成 Terrain(前提 ②),Connect 做不到,
# 所以航點本身也一併在那裡建。
New / */Aircraft LowFlyer
SetPropagator */Aircraft/LowFlyer GreatArc
RCS */Aircraft/LowFlyer SetValue Inherit false
RCS */Aircraft/LowFlyer SetValue Model.FrequencyBandList[0].ComputeTypeStrategy.ConstantValue 5.0 sqm
# ---- 感測器 ------------------------------------------------------------------
# 目標一定要先存在才指得到,所以這一段必須排在 New / */Aircraft 之後。
New / */Place/CoastRadar/Sensor Servo
Define */Place/CoastRadar/Sensor/Servo SimpleCone 85.0
Point */Place/CoastRadar/Sensor/Servo Targeted Add Aircraft/LowFlyer
Point */Place/CoastRadar/Sensor/Servo Targeted Tracking Aircraft/LowFlyer
# ---- 雷達 --------------------------------------------------------------------
New / */Place/CoastRadar/Sensor/Servo/Radar Surveillance
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model Monostatic
# 天線:先型別、後設計頻率(順序反了會被沖回 14.5 GHz 預設值)
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.AntennaControl.Antenna Parabolic
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.AntennaControl.Antenna.InputType "Use Diameter"
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.AntennaControl.Antenna.Diameter 2.0 m
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.AntennaControl.Antenna.DesignFrequency 9.4 GHz
# X 頻段海事監視雷達的量級。9.4 GHz 在 TIREM 的 1 MHz - 40 GHz 之內(前提 ⑤)。
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Transmitter.Power 25000 W
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Transmitter.Frequency 9.4 GHz
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Mode.Waveform.PulseDefinition.PRF 1500 Hz
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Mode.Waveform.PulseDefinition.PulseWidth 0.5e-6 sec
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Mode.Waveform.PulseDefinition.NumberOfPulses 16
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Mode.Waveform.ProbabilityOfDetection.ProbabilityFalseAlarm 1e-6
# ---- 雜波 --------------------------------------------------------------------
# 雙基地那一章實測過:只有 Single_Point 的雜波幾何在各種構型下都算得出來。
# 這裡沿用同一個設定,兩章才對照得起來。
# 注意:ClutterEnabled 沒先打開之前,底下那一整棵子樹是唯讀的。
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.ClutterEnabled true
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Clutter.PointProviderList[0].PointProvider Single_Point
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Clutter.PointProviderList[0].PointProvider.ScatteringPointModel Constant_Coefficient
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Clutter.PointProviderList[0].PointProvider.ScatteringPointModel.ClutterCoefficient 0.02
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Mode.ClutterFilters.EnableMainLobeClutter true
Radar */Place/CoastRadar/Sensor/Servo/Radar/Surveillance SetValue Model.Mode.ClutterFilters.EnableSideLobeClutter true
# ---- 約束 --------------------------------------------------------------------
# AGI 對 TIREM 的最佳實務:**關掉**視線、地形遮罩、方位仰角遮罩三個約束。
# 理由是 TIREM 自己就會算越過地平線的繞射與表面波;留著幾何約束,
# 等於在 TIREM 開口之前先把它唯一的賣點砍掉 —— 山後那一段會直接沒有資料,
# 而不是「有資料但損耗很大」。
#
# Part D 會把這個約束開回來做對照,量化兩者的差別。
SetConstraint */Place/CoastRadar/Sensor/Servo LineOfSight Off
SetConstraint */Place/CoastRadar/Sensor/Servo/Radar/Surveillance LineOfSight Off
SetConstraint */Aircraft/LowFlyer LineOfSight Off
# -*- coding: utf-8 -*-
"""獨立章節 4 的簡報內容。
純資料,沒有副作用,不 import STK —— `tools/make_pptx.py` 會 import 它。
數字全部來自 tirem_analysis.py 與 tirem_verify.py 的實際輸出。
"""
DECK = dict(
eyebrow="獨立章節 4 · STK Radar + TIREM + 本機地形",
title="四種沉默的失敗",
subtitle="岸際雷達:TIREM 地形傳播與海面雜波",
thesis="這一章要用 TIREM 分析海面雜波,而過程中最有價值的產物是一個"
"**會通過驗證的錯誤結論**:「TIREM 算不出結果」—— 那是錯的,"
"而它通過了 41 項全綠的驗證。",
scenario=[
("地形檔", "NED_Maui.pdtt(本機檔,關閉地形伺服器)"),
("地形檔覆蓋範圍", "20.5–21.0 °N / −156.5 – −156.0 °E(東茂宜,0.5° 見方)"),
("CoastRadar", "20.765 °N, −156.445 °E(Kihei 岸際),天線高 30 m"),
("雷達", "單基地 X 頻段 9.4 GHz · 25 kW · 2 m 拋物面 · PRF 1500 Hz"),
("LowFlyer", "五點大圓航路,地形之上 150 m · 80 m/s · RCS 5 m²,全程在水面上"),
("雜波", "Single Point 幾何 + 常數反射係數 0.02"),
("分析窗", "12 分鐘,取樣 15 秒(35 個取樣點)"),
],
focus=[
"TIREM 的五個前提,哪一個是真的?把能安全執行的逐一關掉。",
"地形檔涵蓋哪裡?STK 不告訴你,而問錯了它會停住而不是報錯。",
"幾何遮蔽與繞射損耗是兩條互斥的路 —— 打開視線約束,TIREM 就沒有東西可算。",
"地表電性換成海水值,結果差多少?用 VHF 當判別實驗排除「這是物理」。",
"海面雜波比雜訊高幾十 dB。加功率無效,簡單 MTI 能買回多少?",
],
animation="tirem_coastal_anim.gif",
animation_title="動畫:地形如何吃掉訊號",
animation_caption="目標沿南方外海東行,Haleakalā 逐漸擋進電波路徑;"
"下方是 TIREM 衰減與 ITU-R P676 的對照",
figures=[
("tirem_coastal.png",
"六格結果 —— 地形檔覆蓋、地形剖面、三個傳播模型、幾何約束、地表電性、海面雜波"),
],
results_caption="全部為 STK 12.10.0 實測,非估計值",
results=[
("量", "結果", "說明"),
("TIREM 衰減範圍", "0 – 73.3 dB", "差別全部來自地形"),
("ITU-R P676-13 同一條航路", "0.32 – 0.61 dB", "整條只變 0.29 dB"),
("刀鋒繞射獨立驗證", "理論 57.0 vs TIREM 73.3 dB", "比值 1.29,量級相符"),
("地表電性四組極差(9.4 GHz)", "0.000000000 dB", "陸地/海水 × 垂直/水平"),
("地表電性四組極差(100 MHz)", "0.000000000 dB", "判別實驗:排除物理解釋"),
("TIREM 開啟時的雜波", "C/N 有效 0/35", "SNR 與 PDet 照常有值"),
("ITU 時的雜波", "C/N 38.4 – 45.1 dB", "係數 ×10 → 精確 +10 dB"),
("MTI 對消 5 個脈衝", "C/N −10.5 dB,偵測率 0% → 20%", "面對 45 dB 雜波仍不足"),
],
findings=[
"A|ACK 了但沒生效:SetHeightAboveGround 有 ACK,"
"但 place.UseTerrain 回讀仍是 False。",
"B|文件說的前提不是真前提:STK 說明列為硬性前提的「物件以地形為高度參考」,"
"關掉之後 TIREM 照樣算,數值差 0.000 dB。",
"C|失效時不是回哨兵值,是整個停住:物件離開地形檔覆蓋範圍時,"
"ComputeAccess 再也不回來,CPU 掉到 0.000/s —— 而文件明文承諾會給錯誤訊息。",
"D|通過範圍檢查、回讀正確,對輸出毫無影響:TIREM 的地表電性參數。"
"STK 會擋 RelativePermittivity=1e6(範圍 0.00001–100),但兩個頻率四種組合"
"輸出差 0.000000000 dB。",
"地形檔的覆蓋範圍只能自己讀 .pdtt 檔頭(四個 double,單位是弧度,位移 24/32/40/48)"
"—— STK 沒有任何 API,而 Terrain Alt 在範圍外照樣回大地基準面高度。",
],
verify=(52, 0, 0),
verify_note=[
"41 項全部 PASS、結論卻是錯的 —— 那 41 項驗的全是"
"「腳本有沒有照設計做」,沒有一項驗「設計本身對不對」。",
"其中一項甚至是假的 PASS:「打開幾何約束後樣本減少,代表地形擋住視線」——"
"減少的其實是地球曲率擋掉的,因為 LineOfSight 只看橢球體、不含地形。",
"本章把它改寫成正確的斷言,並新增「TIREM 開著時算不出雜波」等具名檢查。",
],
run=[
"python tirem_build.py --pid <PID> 建場景 + 載地形 + 啟用 TIREM",
"python tirem_analysis.py --pid <PID> Part A–F + 圖",
"python tirem_animate.py --pid <PID> 展示動畫",
"python tirem_verify.py --pid <PID> 驗證段(52 項)",
],
files=[
("tirem_coastal_Connect.txt", "Connect 腳本(34 條,0 NACK)"),
("tirem_build.py", "地形邊界守衛 + STK 實例守衛 + 物件模型設定"),
("tirem_analysis.py", "Part A–F"),
("tirem_animate.py", "展示動畫"),
("tirem_verify.py", "驗證段(52 項全 PASS)"),
("tirem_terrain_bounds_demo.py", "附錄:會讓 STK 停住的示範,需明確確認才跑"),
("TIREMCoastal_Primer.md", "基礎知識導論"),
("TIREMCoastal_Lab_Handout.md", "實驗單"),
],
)
# -*- coding: utf-8 -*-
# ==============================================================================
# 岸際雷達章 — 附錄示範:離開地形檔覆蓋範圍時,TIREM 會讓 STK 停住
#
# *** 這支腳本會讓你的 STK 停住數十分鐘。這是它的目的。 ***
#
# 為什麼要有這一支:
#
# 本章 Part B 把 TIREM 的前提逐一關掉,做成消去表。但其中一項
# ——「物件離開本機地形檔的覆蓋範圍」—— **不能**放進課堂腳本裡,
# 因為它的失效方式不是回一個哨兵值,而是:
#
# * ComputeAccess 呼叫再也不回來(不是慢,是停住)
# * STK 行程的 CPU 掉到 0.01/s —— 它沒有在算
# * 沒有錯誤訊息、沒有對話框、沒有逾時
# * 如果這時候把 Python 用戶端強制中止,那個 STK 實例會進入**長時間
# 無回應**(數十分鐘量級,推測是 Windows RPC 逾時在跑):Responding
# 變成 False、AttachToApplication() 掛住。它**會自己恢復**,不是永久
# 壞掉 —— 本教材一開始誤判成必須重開,後來兩個實例都在無人介入下
# 自己活了回來。等它,或者換一個實例。
#
# 本教材在改寫這一章時,用這個方式停掉兩個 STK 實例才確認它可以重現。
#
# 這件事值得學生知道,但不值得讓學生的機器在課堂上停住半小時。所以它被拆出來,
# 變成一支要**明確確認**才會跑的附錄腳本。
#
# STK 說明是這樣寫的(Help/Content/comm/AtmosModelTIREM.htm):
# "STK searches for terrain data for each object location. If the object
# moves out of the available terrain data, STK issues an error message
# that provides the object's time, latitude, and longitude when it
# crossed over the terrain's boundary."
#
# 也就是說,**文件承諾會給你一則錯誤訊息**。本機實測拿到的是停住。
# 這正是本章一再強調的那件事的最強版本:
# 文件說的行為、實際的行為、以及失效時的表現,是三件不同的事。
#
# 執行前請先存檔、關掉其他重要的 STK 場景,並預期這個實例會有數十分鐘
# 不能用。手邊如果只有一個 STK,就不要在趕時間的時候跑這一支。
# python tirem_terrain_bounds_demo.py --pid <PID> --yes-i-will-hang-stk
# ==============================================================================
import os
import sys
import time
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)
import tirem_build as B # noqa: E402
import tirem_analysis as A # noqa: E402
CONFIRM = "--yes-i-will-hang-stk"
OFFSET_DEG = 2.0 # 把整條航路往西移這麼多度,離開 NED_Maui.pdtt
def main():
if CONFIRM not in sys.argv:
print(__doc__ or "")
print("=" * 74)
print("這支腳本預期會讓 STK 停住,那個實例會有數十分鐘不能用。")
print("確定要跑的話,加上參數:")
print(" python tirem_terrain_bounds_demo.py --pid <PID> %s" % CONFIRM)
print("=" * 74)
return 2
app, root, ok, name = B.build(pid=B.cli_pid())
if not ok:
print("這台機器沒有安裝 TIREM,示範無法進行。")
return 1
print()
print("--- 對照組:航路在地形檔覆蓋範圍**內** ---")
B.build_route(root)
B.set_los(root, False)
t0 = time.time()
at, _ = A.atmos_series(root, step=120.0)
k, n = A.valid_count(at)
print(" 有效樣本 %d/%d,%.3f – %.3f dB,耗時 %.1f s"
% (k, n, np.nanmin(at), np.nanmax(at), time.time() - t0))
print()
print("--- 實驗組:整條航路往西移 %.1f 度,離開地形檔 ---" % OFFSET_DEG)
far = [(lat, lon - OFFSET_DEG) for lat, lon in B.WAYPOINTS]
print(" 新航路:%s" % ", ".join("(%.3f, %.3f)" % p for p in far))
B.build_route(root, waypoints=far)
print()
print(" 接下來這一次 ComputeAccess 預期**不會回來**。")
print(" 請另開一個視窗觀察 STK 行程的 CPU:如果它掉到接近 0,")
print(" 就代表 STK 沒有在算,只是停住了。")
print(" 按 Ctrl-C 中止;中止之後這個實例會無回應數十分鐘才自己恢復。")
print()
sys.stdout.flush()
t0 = time.time()
at, _ = A.atmos_series(root, step=120.0) # <- 預期停在這裡
k, n = A.valid_count(at)
print(" 竟然回來了:有效樣本 %d/%d,耗時 %.1f s" % (k, n, time.time() - t0))
print()
print(" 如果你看到這一行,代表這台機器上的行為和本教材實測不同 ——")
print(" 請把 STK 版本、地形檔、以及這裡的輸出記下來,那是新的資料點。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
# -*- coding: utf-8 -*-
# ================================================================
# 岸際雷達章 — 驗證段
#
# 本章的驗證段盯的是四種**不同**的靜默失敗,分清楚它們是本章的重點:
#
# A. ACK 了但沒生效
# `SetHeightAboveGround` 有 ACK,但 Place 的 Use Terrain Data
# 仍然是 False。
#
# B. 設定生效了,但**文件說的前提不是真正的前提**
# STK 說明把「物件要以地形為高度參考」寫成硬性前提。實測把它關掉,
# TIREM 照樣算得出來,數值幾乎不變。所以驗證段不能只驗「設了沒」,
# 還要驗「這個設定到底影不影響結果」。
#
# C. 失效時**不是回哨兵值,是整個停住**
# 物件離開地形檔覆蓋範圍時,STK 不是回 -9999.99,而是停住。
# 這一項**故意不執行**(見 tirem_terrain_bounds_demo.py),
# 驗證段只確認那支附錄腳本存在、而且不會被正常流程呼叫到。
#
# D. 寫得進去、通過範圍檢查、回讀正確,**對輸出毫無影響**
# TIREM 的地表電性參數。驗證段這裡只驗回讀(那本來就會過);
# 真正的判別實驗在 tirem_analysis.py Part E —— 用 VHF 當對照,
# 排除「這是物理」的解釋。
# 這一型提醒我們:**回讀相符不是生效的證據。**
#
# 「TIREM 算不出結果(已知限制)」很容易被寫成一項「預期失敗」。
# 那是錯的 —— 本段反過來要求:**TIREM 必須算得出來**。
# 寫成「預期失敗」的檢查項會把錯誤結論固定下來,而且永遠是綠的。
#
# 執行:
# 1. 開啟 STK 12 桌面版
# 2. python tirem_verify.py --pid <PID>
# 離開碼 0 = 全部通過,1 = 有項目沒生效
# ================================================================
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 tirem_build as B # noqa: E402
import tirem_analysis as A # noqa: E402
SCRIPT = os.path.join(HERE, "tirem_coastal_Connect.txt")
DEMO = os.path.join(HERE, "tirem_terrain_bounds_demo.py")
# 實測基準(由 tirem_analysis.py 的輸出填入)。
# 這裡刻意只留少數幾個絕對值 —— 大部分檢查改用**相對**判準
# (係數 x10 -> C/N +10 dB、開關某個設定前後的差、樣本數是否改變),
# 因為絕對值會隨場景幾何而變,相對關係才是物理。
BASE = dict(terr_sea_lo=5.0, terr_sea_hi=50.0,
el_max=10.0, rng_lo=10.0, rng_hi=60.0)
def main():
print("--- 建場景 ---")
app, root, tirem_ok, tirem = B.build(pid=B.cli_pid())
v = Verifier(root)
# ---- (1) 自動:Connect 腳本設過的每一個值讀回來比對 -------------------
v.auto_setvalues(SCRIPT)
# ---- (2) 結構 --------------------------------------------------------
for p in ["*/Place/CoastRadar", "*/Place/CoastRadar/Sensor/Servo",
B.RADAR, B.TGT]:
v.object_exists(p)
v.position("*/Place/CoastRadar", *B.RADAR_LLA)
# ---- (3) 失敗型態 A:ACK 了但沒生效 ----------------------------------
# Connect 的 SetHeightAboveGround 會 ACK,但不會打開 Use Terrain Data。
# build() 回報的 before/after 就是證據。
pl = root.GetObjectFromPath(B.PLACE)
v.expect_true("Place 的 Use Terrain Data 已打開(物件模型補的)",
bool(pl.UseTerrain),
"Connect 的 SetHeightAboveGround 只設高度,不會打開這個旗標")
v.expect("Place 離地高度", float(pl.HeightAboveGround) * 1000.0,
B.RADAR_AGL_KM * 1000.0, tol=0.5, unit="m")
ref = int(root.GetObjectFromPath(B.TGT).Route.AltitudeRefType)
v.expect("載具航點高度參考型別", ref, B.ALT_REF_TERRAIN)
# ---- (4) 航路不能穿進地形裡 ------------------------------------------
# GreatArc 的 Terrain 高度參考只保證**航點**在地形之上,航點之間是
# 直線內插。目標很容易因此從山裡穿過去,而 STK 沒有任何警告。
tr = A.target_track(root)
ta = tr["Terrain Alt"] * 1000.0
alt = tr["Alt"] * 1000.0
dig = float(np.nanmax(ta - alt))
v.expect_true("目標全程在地形之上(沒有穿山)", dig < 0.0,
"最大穿山深度 %.1f m —— 大於 0 就代表航路鑽進地形裡了" % dig)
# ---- (5) 地形真的載進「分析用」--------------------------------------
ok_terr, lo, hi = B.terrain_loaded(root)
v.expect_true("地形已載入分析(取得到地形高度)", ok_terr,
"航路下方 %.1f – %.1f m" % (lo, hi))
# .pdtt 在開闊海面上回的不是 0,而是十幾公尺的大地基準面偏移。
v.expect_true("海面上的地形讀值不是 0(大地基準面偏移,已知限制)",
5.0 < lo < 50.0,
"開闊海面讀值 %.1f m —— 用 0 當海陸判準會全部判成陸地" % lo)
# ---- (6) 幾何 --------------------------------------------------------
B.set_los(root, False)
B.set_atmos(root, A.ATMOS_BASELINE)
geo = A.fetch(root, "AER Data", ["Time", "Azimuth", "Elevation", "Range"])
el, rng = A.clean(geo["Elevation"]), A.clean(geo["Range"])
v.expect_true("掠射角極低(最高仰角 < 10 deg)", float(np.nanmax(el)) < 10.0,
"仰角 %.2f – %.2f deg —— 海面雜波最嚴重的條件"
% (float(np.nanmin(el)), float(np.nanmax(el))))
v.expect_between("最遠距離", float(np.nanmax(rng)), 10.0, 60.0, unit="km")
# ---- (7) TIREM 必須算得出來 -----------------------------------------
models = B.atmosphere_models(root)
v.expect_true("TIREM 出現在可用模型清單裡",
any("TIREM" in m for m in models),
", ".join(m for m in models if "TIREM" in m))
B.set_atmos(root, A.ATMOS_BASELINE)
itu, _ = A.atmos_series(root, step=60.0)
k_itu, n_itu = A.valid_count(itu)
v.expect_true("ITU-R P676-13 算得出大氣衰減", k_itu > 0,
"有效樣本 %d/%d,%.4f – %.4f dB"
% (k_itu, n_itu, np.nanmin(itu), np.nanmax(itu)))
tirem_rng = {}
for m in [x for x in models if "TIREM" in x]:
B.set_atmos(root, m)
at, _ = A.atmos_series(root, step=60.0)
k, n = A.valid_count(at)
tirem_rng[m] = (k, n, float(np.nanmax(at)) if k else float("nan"))
# 這一項若 FAIL,代表場景踩到了某個沉默的前提,結論要重查 ——
# 不要反過來把它寫成「預期算不出來」。
v.expect_true("%s 算得出結果" % m, k > 0,
"有效樣本 %d/%d,最大 %.2f dB" % (k, n, tirem_rng[m][2]))
# ---- (8) 模型是不是**真的**掛上去了 ----------------------------------
# 回讀設定值只證明「你寫進去了」。Radar Active Prop Models 才證明
# 「STK 這一次計算真的用了它」。
newest = B.newest_tirem(root)
B.set_atmos(root, newest)
rows = A.active_models(root)
names = {str(r.get("Name", "")) for r in rows}
v.expect_true("Radar Active Prop Models 確認 TIREM 真的在作用",
any("TIREM" in s for s in names),
"實際掛上的模型:%s" % (", ".join(sorted(names)) or "(空)"))
# ---- (9) 失敗型態 B:文件說的前提,不是真正的前提 ---------------------
# 把 Place 的 Use Terrain Data 關掉 —— STK 說明說這是硬性前提。
# 實測 TIREM 照樣算。這一項驗的是「這個差異確實很小」,
# 也就是把「文件與實作不一致」這件事變成可執行的斷言。
at_ref, _ = A.atmos_series(root, step=120.0)
pl.UseTerrain = False
root.ExecuteCommand("ClearAllAccess /")
at_off, _ = A.atmos_series(root, step=120.0)
pl.UseTerrain = True
root.ExecuteCommand("ClearAllAccess /")
k_off, _ = A.valid_count(at_off)
v.custom("關掉 Use Terrain Data 之後 TIREM 仍然算得出來(文件說這是硬性前提)",
k_off > 0,
"有效樣本 %d;STK 說明列為必要條件,實測不影響可算性。"
"文件寫的是建議做法,不是 STK 真正的檢查條件。" % k_off)
if k_off > 0 and np.isfinite(at_ref).any():
d = abs(float(np.nanmax(at_off)) - float(np.nanmax(at_ref)))
v.expect_true("而且數值幾乎不變", d < 2.0,
"最大衰減差 %.3f dB" % d)
# ---- (10) 幾何約束**不含地形** ---------------------------------------
# 這一項很容易寫成「打開幾何約束後樣本應該減少(地形擋住視線)」,
# 而且它在某些場景下真的會 PASS —— 但那是假的 PASS:
# 減少的樣本是**地球曲率**擋掉的,不是地形。
# STK 的 LineOfSight 約束只看橢球體。改寫成正確的斷言:
# 在這個構型下,幾何約束一個樣本都不該擋掉。
B.set_atmos(root, newest)
ns = {}
for on in (False, True):
B.set_los(root, on)
at, _ = A.atmos_series(root, step=30.0)
ns["on" if on else "off"] = A.valid_count(at)[0]
v.custom("幾何視線約束不含地形(開關之後樣本數不變)",
ns["on"] == ns["off"],
"%d -> %d。STK 的 LineOfSight 只看橢球體;要讓地形進到 access "
"判定得另外用 SetAzElMask(eTerrainData) 算一張遮罩。"
"很容易誤把地球曲率擋掉的樣本當成地形效應。"
% (ns["off"], ns["on"]))
B.set_los(root, False)
# ---- (10b) 開了 TIREM 就拿不到雜波 -----------------------------------
# 這是 STK 12.10 的能力限制,文件沒有寫,也沒有任何警告:
# 雜波功率與 C/N 全變 -9999.99,而 SNR 與 PDet 照常有值。
A.set_clutter(root, True, 0.02)
B.set_atmos(root, newest)
st = A.fetch(root, "Radar SearchTrack", A.ST_ELEMENTS)
snr_t = A.valid_count(A.clean(st["S/T Integrated SNR"]))
cn_t = A.valid_count(A.clean(st["S/T C/N"]))
v.custom("TIREM 開著時雷達算不出雜波(已知限制)",
cn_t[0] == 0 and snr_t[0] > 0,
"SNR 有效 %d/%d,C/N 有效 %d/%d。"
"若這一項變成 FAIL,代表 STK 已經可以同時給地形傳播與雜波,"
"Part F 的兩趟做法就可以合併。" % (snr_t + cn_t))
# ---- (11) TIREM 的地表電性回讀 ---------------------------------------
got = B.set_tirem_surface(root, B.SURFACE["sea"],
B.POLARIZATION["horizontal"])
v.expect("海水相對介電常數", got["RelativePermittivity"], 80.0, tol=1e-6)
v.expect("海水導電度", got["SurfaceConductivity"], 5.0, tol=1e-6,
unit="S/m")
v.expect("極化型別(1 = 水平)", got["PolarizationType"], 1)
got = B.set_tirem_surface(root, B.SURFACE["land"],
B.POLARIZATION["vertical"])
v.expect("陸地相對介電常數", got["RelativePermittivity"], 15.0, tol=1e-6)
v.expect("極化型別(0 = 垂直)", got["PolarizationType"], 0)
# ---- (12) 雜波(必須在 TIREM **關掉**的情況下量,見 10b)--------------
B.set_atmos(root, A.ATMOS_BASELINE)
cn = {}
for c in (0.02, 0.2):
A.set_clutter(root, True, c)
st = A.fetch(root, "Radar SearchTrack", A.ST_ELEMENTS)
x = A.clean(st["S/T C/N"])
cn[c] = float(np.nanmax(x)) if np.isfinite(x).any() else np.nan
v.expect_true("雜波算得出 C/N", np.isfinite(cn[0.02]),
"係數 0.02 時 C/N 最大 %.2f dB" % cn[0.02])
v.expect("雜波係數 x10 -> C/N +10 dB", cn[0.2] - cn[0.02], 10.0,
tol=0.5, unit="dB")
A.set_clutter(root, True, 0.02)
# ---- (13) MTI 的合法範圍 ---------------------------------------------
wf = root.GetObjectFromPath(B.RADAR).Model.Mode.Waveform
wf.EnablePulseCanceller = True
wf.NumberOfPulsesToCancel = 3
v.expect("脈衝對消器對消脈衝數", int(wf.NumberOfPulsesToCancel), 3)
bad = False
try:
wf.NumberOfPulsesToCancel = 1 # 合法範圍是 2-5
except Exception:
bad = True
v.expect_true("對消脈衝數 1 會被拒絕(合法範圍 2-5)", bad,
"STK 會拋 Value range is 2 to 5")
wf.NumberOfPulsesToCancel = 3
wf.EnablePulseCanceller = False
root.ExecuteCommand("ClearAllAccess /")
# ---- (14) 失敗型態 C:會讓 STK 卡死的那一項,必須留在正常流程之外 -----
v.expect_true("附錄腳本 tirem_terrain_bounds_demo.py 存在",
os.path.exists(DEMO))
src = open(os.path.join(HERE, "tirem_analysis.py"),
encoding="utf-8").read()
v.expect_true("正常分析流程不會把物件移出地形檔範圍",
"OFFSET_DEG" not in src and "lon - 2.0" not in src,
"那個案例會讓 STK 卡死(不是回哨兵值),"
"只保留在需要明確確認的附錄腳本裡")
ok = v.report("岸際雷達 TIREM 驗證段")
print()
print("提醒:本章盯的是四種不同的靜默失敗 ——")
print(" A. ACK 了但沒生效(SetHeightAboveGround)")
print(" B. 文件說的前提不是真正的前提(Use Terrain Data)")
print(" C. 失效時不是回哨兵值,是整個停住(離開地形檔範圍)")
print(" D. 寫得進去、通過範圍檢查、回讀正確,對輸出毫無影響")
print(" (TIREM 的地表電性參數;判別實驗見 tirem_analysis.py Part E)")
return 0 if ok else 1
if __name__ == "__main__":
_rc = main()
if "ipykernel" not in sys.modules:
raise SystemExit(_rc)