獨立章節 1 — 地面雙基地雷達:干擾、雜波與航跡穩定度

資料夾:radar_bistatic/

RadarBistatic_Lab_Handout.md

實驗單 — 地面雙基地雷達:干擾、雜波與航跡穩定度

獨立章節,不屬於 16 週立方衛星課程。
先讀 RadarBistatic_Primer.md,再做本實驗。

參考:AGI 訓練單元 Radar Tracking and Jamming(單基地版)


0. 情境

台灣海峽的雙基地防空雷達:

物件 位置 說明
TxSite 24.90°N, 120.85°E 發射站,100 kW 峰值、4 m 拋物面、S 頻段 3 GHz
RxSite 24.30°N, 120.45°E 接收站,靜默,4 m 拋物面。基線 78 km
Track01 北→南沿海峽 真值航跡,FL300 (9144 m)、250 m/s、RCS 10 m²
JamAir 24.9→23.7°N, 118.9°E 空中支援干擾機,10 km 高,20 W,對準接收站

分析窗 30 分鐘,取樣 5 秒(等於雷達更新率)。

為什麼用真值航跡:模擬相對真實世界最大的優勢,就是真值已知。
所有「追得準不準」的問題才有絕對的基準可比。


1. 執行步驟

1. 開啟 STK 12 桌面版(本章沒有繪圖指令,無介面 Engine 也可以)
2. python bistatic_build.py       建場景
3. python bistatic_tracking.py    四情境分析 + 圖
4. python bistatic_sweep.py       干擾功率與雜波係數掃描 + 圖
5. python bistatic_verify.py      驗證段(離開碼 0 = 全部通過)

bistatic_build.py 會先送 bistatic_radar_Connect.txt(72 條指令),
再用物件模型補上 Connect 做不到的三件事。


2. Part A — 雙基地幾何(佔 15%)

要交的東西:距離和、雙基地角、距離乘積隨時間的變化。

要回答的問題:

  1. 為什麼雙基地的等訊號強度輪廓是 Cassini 卵形線而不是圓?
  2. 距離乘積 Rt·Rr 在本場景變化多少 dB?這對「哪一段航路最容易掉」有什麼預測?
  3. 把接收站往西移 20 km(改 SetPosition */Place/RxSite),
    基線變長還是變短?訊號變好還是變差?先預測,再跑。

基準值:Rt 111–216 km、Rr 59–197 km、距離和 186–360 km、
雙基地角 3.3–42.8°、距離乘積差 12.3 dB。


3. Part B — 偵測與四情境比較(佔 25%)

STK 只提供三個 PDet 元素:乾淨、w/Jamming、w/J+C。
沒有「只有雜波」。所以腳本會把干擾與雜波逐一開關,重算四次。

要回答的問題:

  1. 為什麼不能只跑「全開」那一組,再把退化都算到雜波頭上?
  2. S/T Integrated J/S 有 267/280 個有效樣本,其餘 13 個是 −9999.99。
    那 13 拍代表什麼?(提示:干擾機的航路與可見性)
  3. 把 PulseIntegrationType 改回 0(Goal SNR)重跑,PDet 會怎麼變?
    為什麼那個結果是假的?

基準值(20 W 干擾、雜波係數 0.05):

情境 PDet 平均
乾淨 1.0000
只有雜波 0.9980
只有干擾 0.8587
干擾+雜波 0.8540

4. Part C — 航跡連續性(佔 20%)

判準:PDet ≥ 0.8 視為這一拍有偵測;連續中斷超過 30 秒(外推上限)視為航跡丟失。

基準值:

情境 偵測率 最長中斷 中斷次數 航跡
乾淨 100.0% 0 s 0 維持
只有雜波 100.0% 0 s 0 維持
只有干擾 83.2% 55 s 7 丟失
干擾+雜波 82.9% 55 s 7 丟失

要回答的問題:

  1. 偵測率 83% 聽起來還不錯,但航跡卻丟了。為什麼「平均值」在這裡是誤導的?
  2. 把外推上限從 30 秒放寬到 60 秒,航跡就保得住嗎?代價是什麼?
    (提示:外推期間你相信的是模型,不是量測 —— 目標若在此時轉彎會怎樣?)
  3. 中斷發生在航路的哪一段?對照 Part A 的幾何,說明為什麼是那裡。

5. Part D — 追蹤誤差 vs 真值(佔 25%,本章重點)

用 STK 的 SNR 產生量測雜訊,跑 alpha-beta 濾波器,與真值逐點比對。

基準值:

情境 命中率 航跡存活 重起 RMS 誤差 P95 誤差
乾淨 100.0% 100.0% 0 189 m 375 m
只有雜波 99.6% 100.0% 0 196 m 398 m
只有干擾 86.1% 95.7% 3 918 m 986 m
干擾+雜波 85.7% 95.7% 3 920 m 996 m

要回答的問題:

  1. 偵測率只掉 17 個百分點,RMS 誤差卻變成 4.9 倍。用 2.8 節的公式解釋。
  2. 本場景的定位誤差主要來自距離量測還是角度量測?把過程寫出來。
    (提示:4 m 天線在 3 GHz 的波束寬是 1.75°,150 km 處 1° 是多少公尺?)
  3. 把 alpha 從 0.5 改成 0.2(更信模型),誤差怎麼變?
    在乾淨與有干擾兩種情境下,變化方向一樣嗎?為什麼?
  4. 如果把接收天線換成 8 m,定位精度會改善幾倍?先用公式預測,再改參數驗證。

6. Part E — 掃描與設計決策(佔 15%)

bistatic_sweep.py 產生兩條曲線。

干擾機功率掃描(雜波關閉):

功率 0.1 W 1 W 5 W 10 W 50 W 200 W 2000 W
偵測率 100% 98.2% 93.9% 89.6% 61.4% 30.0% 10.4%
最長中斷 0 s 10 s 30 s 40 s 160 s 205 s 225 s

雜波反射係數掃描(干擾關閉):

係數 0.01 0.05 0.5 5 50 5000
偵測率 100% 100% 86.1% 70.0% 42.5% 6.8%

要回答的問題:

  1. 打穿門檻落在 5–10 W 之間。一部 100 kW 的雷達被 10 W 打穿,
    這個 4 個數量級的落差從哪裡來?
  2. 雜波在預設係數 0.05 下完全不影響偵測。
    這是實驗失敗還是有意義的結果? 你要怎麼把它寫進報告?
  3. 兩條曲線哪一段平坦、哪一段陡降?平原與懸崖分別代表什麼設計意義?
  4. 你要替這部雷達提一個抗干擾改良案。列出三個選項
    (例如:旁瓣抑制、跳頻、增加接收站),各自能買到多少 dB?各自的代價?

7. 驗證段 — 三層靜默失敗

bistatic_verify.py,62 項全部 PASS。本章的雷達模組有三層靜默失敗:

層 症狀 怎麼抓
1 指令被拒絕 NACK 計數
2 ACK 了卻沒生效 Radar_RM ... GetValue 回讀比對
3 ACK、回讀正確、輸出還是空的 檢查資料提供者有沒有真的吐出數字

第 3 層是本章新發現的:雜波幾何設成 Smooth_Oblate_Earth,
Connect ACK、回讀一字不差,但雙基地接收端的 S/T Clutter Power
全部是 −9999.99。只有 Single_Point 算得出來。

驗證段把這個「已知會壞的設定」也寫成一個檢查項 ——
如果哪天 STK 修好了,那一項會變成 FAIL,我們才會知道。

要回答的問題:

  1. 第 2 層與第 3 層的差別是什麼?各舉一個本章的例子。
  2. SetPosition ... 10.0 與 SetHeightAboveGround 10.0,
    哪一個真的把物件抬起來?哪一個的回讀值是對的?
    這件事對「只靠回讀做驗證」有什麼啟示?
  3. 自己加一個檢查項:確認干擾機的航路涵蓋整個分析窗
    (提示:本章一開始的原型就因為干擾機航路只有 100 秒,
    導致 6 個取樣點裡只有 1 個算得出干擾)。

8. 評分

項目 比重
Part A 幾何理解 15%
Part B 四情境設計與解讀 25%
Part C 航跡連續性 20%
Part D 追蹤誤差分析 25%
Part E 掃描與設計決策 15%

加分:把 alpha-beta 換成等速模型的 Kalman 濾波器,
用 STK 給的 SNR 逐拍計算量測共變異數,比較兩者在有干擾時的表現差異。


9. 檔案

檔案 內容
RadarBistatic_Primer.md 基礎知識導論
bistatic_radar_Connect.txt Connect 腳本(72 條)
bistatic_build.py 建場景 + Connect 做不到的三件事
bistatic_tracking.py 四情境分析(Part A–D)
bistatic_sweep.py 干擾功率與雜波係數掃描
bistatic_verify.py 驗證段(62 項)
bistatic_tracking.png 四宮格結果圖
bistatic_sweep.png 兩條掃描曲線

RadarBistatic_Primer.md

基礎知識導論 — 地面雙基地雷達、干擾與雜波

本章是獨立章節,不屬於 16 週立方衛星課程。
它借用同一套方法論(STK 主導、Python 補位、ACK 不等於生效),
但主題換成地面防空雷達。

1. 這一章在解什麼問題

一部雷達「偵測得到目標」與「穩定追蹤得住目標」是兩件事。

給定一條已知真值的航跡(這是模擬相對於真實世界的最大優勢:真值是已知的),
問三個層次的問題:

  1. 能量夠嗎 — 訊號雜訊比高不高?
  2. 看得連續嗎 — 偵測有沒有中斷?中斷多久?濾波器外推得過去嗎?
  3. 看到的位置準嗎 — 濾波器輸出與真值差多少?

多數雷達分析停在第 1 層。使用者真正在意的是第 3 層。

參考範例是 AGI 官方訓練單元
Radar Tracking and Jamming,
但那份是單基地。本章改成雙基地,並加上雜波。

2. 核心觀念

2.1 雙基地雷達:發射與接收分開站

        目標 T
       /      \
   Rt /        \ Rr
     /          \
  發射站 -------- 接收站
        基線 L

2.2 雙基地雷達方程式

Pr = Pt * Gt * Gr * λ² * σ_b / ( (4π)³ * Rt² * Rr² * L_sys )

跟單基地最大的差別:

單基地 雙基地
距離項 R⁴ (Rt · Rr)²
等訊號輪廓 圓 Cassini 卵形線 Rt·Rr = 常數
RCS 單基地 RCS σ 雙基地 RCS σ_b(beta),與角度有關

Cassini 卵形線是本章的核心幾何直覺。
當 Rt·Rr 常數夠小,卵形線會分裂成兩個各自繞著發射站與接收站的封閉區域;
常數變大時兩葉合併成一個花生形,再變大變成一個大卵形。
也就是說:雙基地雷達的覆蓋不是一個圓,而且在基線附近有一段特別敏感的區域。

本章實測:目標飛過時距離乘積 Rt·Rr 從 7.5e3 變到 3.1e4 km²,
對訊號的影響達 12.3 dB —— 一條直線航路上,訊號強度就會差 16 倍。

2.3 為什麼要做雙基地

動機 說明
反匿蹤 匿蹤外形是把能量朝特定方向反射掉。接收站不在發射站旁邊,就可能收到那些能量
接收站被動 接收站不輻射,不會被反輻射飛彈鎖定
抗干擾 干擾機通常對著它「看得到」的輻射源,而接收站是靜默的
利用他人的照射源 被動雷達(用電視台、手機基地台當照射源)

代價:時間與相位同步困難、幾何複雜、資源排程困難。

2.4 偵測:從 SNR 到 PDet

單一脈衝的訊號雜訊比 SNR1 通常不夠,要脈衝積分:

SNR_int  ≈  SNR1 + 10*log10(N)        (理想同調積分)

非同調積分的增益低一些(約 N^0.7–N^0.8)。
偵測門檻由虛警機率 Pfa 與積分脈衝數共同決定:N 愈多,積分增益愈大,
但門檻也愈高。兩者是拉鋸,不是單向。

STK 的陷阱:PulseIntegration 預設策略是 Goal SNR ——
它會一直積到達成設定的目標 SNR 為止。結果是 S/T Integrated SNR
永遠等於那個目標值,而偵測門檻隨著脈衝數(可能積到 512 個)一路升高,
PDet 反而被壓死在 1e-3 以下。
要看真實的偵測表現,必須改成 Fixed Pulse Number。
而這個切換 Connect 做不到,只能用物件模型的
Waveform.PulseIntegrationType。

2.5 干擾 (Jamming)

干擾的度量是 J/S(干擾對訊號比):

J/S = [ Pj * Gj * Gr(θj) * Rt² * Rr² * 4π ]  /  [ Pt * Gt * Gr(0) * σ * Rr² ]

關鍵不對稱:

所以干擾機的功率優勢是巨大的。本章實測:一部 20 W 的空中干擾機,
足以讓一部 100 kW 的雙基地雷達偵測率從 100% 掉到 83%,
並產生 55 秒的追蹤中斷 —— 功率差 5000 倍,結果是干擾機贏。

再往上掃:

干擾機功率 偵測率 最長中斷
0.1 W 100.0% 0 s
1 W 98.2% 10 s
5 W 93.9% 30 s
10 W 89.6% 40 s ← 打穿門檻
50 W 61.4% 160 s
2000 W 10.4% 225 s

燒穿距離 (burn-through range) 是這件事的另一面:靠得夠近,
訊號的 1/R⁴ 終究會贏過干擾的 1/R²。設計上要問的不是「會不會被干擾」,
而是「在什麼距離之內我方還贏得了」。

干擾進入接收機的路徑是天線的旁瓣(除非干擾機剛好在主瓣裡)。
所以旁瓣抑制 (sidelobe cancellation) 是抗干擾最重要的硬體手段。

2.6 雜波 (Clutter)

雜波是不想要的真實回波:地面、海面、雨、鳥、風機。與雜訊的差別:

雜訊 雜波
來源 接收機本身 環境的真實散射
隨發射功率 不變 等比例增加
對策 加功率、加積分 都卜勒濾波、MTI、CFAR

加功率對雜波完全無效,因為訊號與雜波一起變大。這是雜波和雜訊最重要的差別。

雜波強度由雜波反射係數 σ⁰(每單位面積的 RCS)與被照亮的雜波面積決定。
面積又與距離解析度、波束寬、掠射角有關 —— 所以低掠射角、寬波束、
長脈衝的雷達雜波最嚴重。

本章的目標在 9 km 高空、60–200 km 距離,天線仰角只有 1–2 度,
主瓣確實會掃到地面。但實測的 C/N 最高只有 −1.6 dB,
雜波幾乎不影響偵測(偵測率仍 100%)。掃描顯示要到反射係數 0.5 以上才開始咬人:

雜波反射係數 偵測率 最長中斷
0.01 / 0.05 100.0% 0 s
0.5 86.1% 110 s
5 70.0% 135 s
5000 6.8% 390 s

這是誠實的結果,不是失敗的實驗:對這個幾何(高空目標、中距離),
干擾是主要威脅,雜波不是。雜波成為主角要等到低空目標與岸際地形 ——
那是 TIREM 章節的主題。

2.7 追蹤:偵測不等於航跡

一條航跡要活著,需要:

  1. 關聯 (association) — 這一拍的量測屬於哪一條航跡
  2. 濾波 (filtering) — 用量測修正狀態估計
  3. 外推 (coasting) — 沒有量測時靠模型撐住
  4. 起始與終結 — 何時開一條新航跡、何時放棄

本章用最簡單的 alpha-beta 濾波器(等速模型的常增益近似):

預測   x⁻ = x + v·dt
更新   x  = x⁻ + α·(z − x⁻)
       v  = v  + (β/dt)·(z − x⁻)

α、β 決定「信量測」還是「信模型」。
連續外推超過門檻(本章取 30 秒)就宣告航跡丟失,下一個量測要重新起始。

2.8 量測精度隨 SNR 惡化

這是最容易被忽略的一點。干擾與雜波不只讓你看不到,
還讓看得到的那些拍變不準:

距離誤差  σ_R  = c / ( 2·B·sqrt(2·SNR) )         B = 1/脈寬
角度誤差  σ_θ  = θ_3dB / ( 1.6·sqrt(2·SNR) )

橫向位置誤差 = σ_θ × 距離,在遠距離時遠大於距離誤差。
本章的 4 m 天線在 3 GHz 波束寬 1.75°,在 150 km 處 1° 就是 2.6 km ——
所以雙基地雷達的定位精度幾乎完全由角度誤差決定。

本章實測(alpha-beta 濾波 vs STK 真值):

情境 偵測率 航跡存活 RMS 位置誤差
乾淨 100.0% 100.0% 189 m
只有雜波 100.0% 100.0% 196 m
只有干擾(20 W) 83.2% 95.7% 918 m
干擾+雜波 82.9% 95.7% 920 m

誤差變成 4.9 倍,而偵測率只掉了 17 個百分點。
只看偵測率會嚴重低估干擾的實際危害。

3. 名詞與符號

符號 意義 單位 本章值
Rt / Rr 發射站/接收站到目標的距離 km 111–216 / 59–197
R 雙基地距離和 Rt+Rr km 186–360
L 基線長度 km 78
beta 雙基地角 ° 3.3–42.8
σ 雷達截面積 m² / dBsm 10 m² (10 dBsm)
σ⁰ 雜波反射係數 — 0.05
SNR1 單脈衝訊雜比 dB —
Pfa 虛警機率 — 1e-6
PDet 偵測機率 — 判準 ≥ 0.8
J/S 干擾訊號比 dB −66 至 +21
C/N 雜波雜訊比 dB −91 至 −1.6
θ_3dB 天線半功率波束寬 ° 1.75
PRF 脈衝重複頻率 Hz 2000

4. 觀念 → 本章實驗對照

觀念 出現在哪裡
雙基地拓撲、波形、天線 bistatic_radar_Connect.txt
Connect 做不到的三件事 bistatic_build.py
Cassini 幾何、距離乘積 bistatic_tracking.py Part A
PDet 四情境 同上 Part B
航跡連續性 同上 Part C
alpha-beta 濾波 vs 真值 同上 Part D
干擾功率與雜波係數掃描 bistatic_sweep.py
三層靜默失敗的檢查 bistatic_verify.py(62 項全 PASS)

5. 本章踩到的坑(全部是實測,不是教科書上的)

雷達模組的靜默失敗分三層,一層比一層難抓:

第 1 層:指令被拒絕(有 NACK,最好抓)

第 2 層:ACK 了卻沒生效(回讀抓得到)

第 3 層:ACK、回讀正確、輸出仍然是空的(最難抓)

要抓到這一層,驗證必須一路做到「資料提供者有沒有真的吐出數字」。
回讀設定值是不夠的。

其他

6. 延伸閱讀

官方範例與說明

雷達原理

追蹤與資料融合

工具

7. 課前自我檢核

  1. 雙基地雷達的等訊號強度輪廓為什麼不是圓?畫出 Rt·Rr = 常數 大致的樣子。
  2. 為什麼一部 20 W 的干擾機打得贏一部 100 kW 的雷達?寫出兩者的距離依賴關係。
  3. 加大發射功率對「雜訊」與「雜波」的效果分別是什麼?為什麼不同?
  4. 偵測率從 100% 掉到 83%,定位誤差卻變成 4.9 倍。這兩件事為什麼不成比例?
  5. 一個設定「Connect ACK 了、Radar_RM 回讀也正確」。它可能還是沒生效嗎?
    本章的哪一個例子證明了這件事?你要再檢查什麼才算數?

圖


radar_bistatic/bistatic_anim.gif


radar_bistatic/bistatic_sweep.png


radar_bistatic/bistatic_tracking.png

程式碼

radar_bistatic/bistatic_animate.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 雙基地雷達 — 展示動畫
#
# 設計目標:**投影出來三公尺外看得懂**,不是資訊完整。
# 所以只講一件事:同一條航跡、同一部雷達,打開 20 W 干擾機之後
# 偵測開始中斷、alpha-beta 濾波的估計誤差擴大。
#
# 兩格:
#   上  平面航跡 —— 真值航跡、目前位置、兩種情境的濾波估計
#   下  偵測機率對時間 —— 乾淨 vs 干擾,加偵測門檻與游標
#
# 資料只抓一次(兩個情境各一趟),之後純離線畫圖。
#
# 執行:python bistatic_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

import bistatic_build as B                                         # noqa: E402
import bistatic_tracking as T                                      # noqa: E402

OUT = os.path.join(HERE, "bistatic_anim.gif")
FPS = 4
# 簡報動畫的長度目標是 10-15 秒。全解析度是 280 格 = 70 秒,太長。
# 只抽稀「游標的落點」,曲線與航跡仍然用全解析度畫。
MAX_FRAMES = 56


def _pid():
    if "--pid" in sys.argv:
        i = sys.argv.index("--pid")
        if i + 1 < len(sys.argv):
            return int(sys.argv[i + 1])
    return None


def gather(root):
    """兩個情境:乾淨、只有干擾。各抓一次。"""
    out = {}
    for label, jam, clut in (("clean", False, False),
                             ("jammed", True, False)):
        T.configure(root, jam, clut)
        st = T.fetch(root, "Radar SearchTrack", T.ST_ELEMENTS)
        key = ("S/T Integrated PDet w/Jamming" if jam
               else "S/T Integrated PDet")
        pdet = T.clean(st.get(key, st["S/T Integrated PDet"]))
        pdet = np.where(np.isfinite(pdet), pdet,
                        T.clean(st["S/T Integrated PDet"]))
        out[label] = dict(t=st["Time"], pdet=pdet,
                          snr=T.clean(st["S/T Integrated SNR"]))
    t_tr, xyz = T.truth_track(root)
    out["truth"] = dict(t=t_tr, xyz=xyz)
    return out


def render(data, out=OUT):
    tr = data["truth"]
    xyz = tr["xyz"]
    # 用地固座標的水平投影當「平面圖」—— 展示用,不需要投影法的嚴謹
    x = (xyz[:, 0] - xyz[0, 0]) / 1000.0
    y = (xyz[:, 1] - xyz[0, 1]) / 1000.0

    tc, pc = data["clean"]["t"], data["clean"]["pdet"]
    tj, pj = data["jammed"]["t"], data["jammed"]["pdet"]
    n = min(len(x), len(tc), len(tj))
    x, y = x[:n], y[:n]
    tc, pc, pj = tc[:n], pc[:n], pj[:n]

    fig = plt.figure(figsize=(11, 7.2))
    gs = fig.add_gridspec(2, 1, height_ratios=[1.25, 1.0], hspace=0.32,
                          left=0.10, right=0.97, top=0.86, bottom=0.10)
    ax_m, ax_p = fig.add_subplot(gs[0]), fig.add_subplot(gs[1])

    ax_m.plot(x, y, lw=1.8, color="0.75", label="truth track")
    seen, = ax_m.plot([], [], lw=3.0, color="#1E6F6B")
    lost, = ax_m.plot([], [], "x", ms=11, mew=2.6, color="#AC3427",
                      linestyle="none")
    now, = ax_m.plot([], [], "o", ms=14, color="#1B5E9E", zorder=5)
    # 平面航跡圖:x 與 y 都是公里,不等比例會讓航向角失真
    ax_m.set_aspect("equal", adjustable="datalim")
    ax_m.set_xlabel("east (km)", fontsize=11)
    ax_m.set_ylabel("north (km)", fontsize=11)
    ax_m.grid(alpha=.25)
    ax_m.tick_params(labelsize=10)
    ax_m.legend(fontsize=10, loc="upper left")

    ax_p.plot(tc / 60.0, pc, lw=2.4, color="#1E6F6B", label="clean")
    ax_p.plot(tc / 60.0, pj, lw=2.4, color="#AC3427",
              label="with 20 W jammer")
    ax_p.axhline(T.PDET_MIN, ls="--", lw=1.3, color="0.4",
                 label="detection threshold %.1f" % T.PDET_MIN)
    cur, = ax_p.plot([], [], "o", ms=11, color="#AC3427", zorder=5)
    vline = ax_p.axvline(tc[0] / 60.0, color="0.5", lw=1.2, ls=":")
    ax_p.set_xlim(tc[0] / 60.0, tc[-1] / 60.0)
    ax_p.set_ylim(-0.05, 1.08)
    ax_p.set_xlabel("time (min)", fontsize=11)
    ax_p.set_ylabel("integrated PDet", fontsize=11)
    # 右下角:左下正好是兩次偵測中斷的位置,圖例會蓋住整章的重點
    ax_p.legend(fontsize=10, loc="lower right")
    ax_p.grid(alpha=.25)
    ax_p.tick_params(labelsize=10)

    title = fig.text(0.5, 0.955, "", ha="center", fontsize=17, weight="bold")
    read = fig.text(0.5, 0.905, "", ha="center", fontsize=13, color="#333333")

    def frame(i):
        seen.set_data(x[:i + 1], y[:i + 1])
        bad = [j for j in range(i + 1)
               if np.isfinite(pj[j]) and pj[j] < T.PDET_MIN]
        lost.set_data(x[bad], y[bad])
        now.set_data([x[i]], [y[i]])
        cur.set_data([tc[i] / 60.0], [pj[i] if np.isfinite(pj[i]) else 0.0])
        vline.set_xdata([tc[i] / 60.0, tc[i] / 60.0])
        title.set_text("雙基地雷達:20 W 干擾機如何打斷航跡")
        state = ("偵測中斷" if (np.isfinite(pj[i]) and pj[i] < T.PDET_MIN)
                 else "追蹤中")
        read.set_text("t = %5.1f min      乾淨 PDet %.2f      干擾 PDet %.2f      %s"
                      % (tc[i] / 60.0,
                         pc[i] if np.isfinite(pc[i]) else float("nan"),
                         pj[i] if np.isfinite(pj[i]) else float("nan"),
                         state))
        return seen, lost, now, cur, vline, title, read

    # 抽稀成 MAX_FRAMES 格。用 linspace 取索引而不是固定步長,
    # 這樣頭尾一定會被取到 —— 固定步長常常把最後一格漏掉。
    idx = np.unique(np.linspace(0, n - 1, min(MAX_FRAMES, n)).astype(int))
    anim = FuncAnimation(fig, lambda k: frame(idx[k]), frames=len(idx),
                         blit=False)
    anim.save(out, writer=PillowWriter(fps=FPS))
    plt.close(fig)
    return out, len(idx)


def main():
    app, root = B.build(jammer_power_w=20.0)[:2]
    print("\n--- 抓資料(乾淨 / 干擾 各一趟)---")
    data = gather(root)
    print("    真值航跡 %d 點" % len(data["truth"]["xyz"]))
    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)
radar_bistatic/bistatic_build.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 地面雙基地雷達 — 場景建構
#
# 分工:
#   bistatic_radar_Connect.txt   Connect 做得到的一切(建物件、波形、天線、雜波)
#   本檔                          Connect 做不到的三件事,用物件模型補上
#
# Connect 做不到的三件事(STK 12.10 實測):
#   1. Model.BistaticTransmitterSelectionStrategy.BistaticTransmitters.ObjectList
#      —— 讀得到(GetValue OK),寫不進去。雙基地系統的核心連結只能用物件模型。
#   2. Model.Jamming.Jammers.ObjectList
#      —— 同上。Enabled 可以用 Connect 設,清單內容不行。
#   3. Model.Mode.Waveform.PulseIntegration 的策略切換
#      —— 各種名稱都 NACK;要用 Waveform.PulseIntegrationType 這個列舉。
#
# 這正是本章的第一個教學點:**Connect 與物件模型不是二選一,是分工。**
# 只用 Connect 建不出一套會動的雙基地雷達。
#
# 執行:
#   1. 開啟 STK 12 桌面版(無介面的 Engine 也可以,本章沒有用到繪圖指令)
#   2. python bistatic_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, "bistatic_radar_Connect.txt")

TXR = "*/Place/TxSite/Sensor/TxServo/Radar/TxRadar"
RXR = "*/Place/RxSite/Sensor/RxServo/Radar/RxRadar"
JMR = "*/Aircraft/JamAir/Sensor/JamServo/Radar/Jammer"
TGT = "*/Aircraft/Track01"

# 脈衝積分策略的列舉值(AgERadarPulseIntegrationType)
PULSE_INTEGRATION_GOAL_SNR = 0
PULSE_INTEGRATION_FIXED_PULSE_NUMBER = 1

PULSES = 20                 # 每次偵測積分的脈衝數


def connect(root, path, echo_fail=True):
    """逐行送出 Connect 腳本,回報 ACK / NACK。"""
    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
        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


def build(jammer_power_w=None, pulses=PULSES):
    """建好整個場景。回傳 (app, root) —— app 一定要留住,否則 COM 介面會失效。"""
    app = STKDesktop.AttachToApplication()
    root = app.Root
    if root.CurrentScenario is not None:
        root.CloseScenario()

    ack, nack = connect(root, SCRIPT)

    rx = root.GetObjectFromPath(RXR)
    tx = root.GetObjectFromPath(TXR)
    jm = root.GetObjectFromPath(JMR)

    # (1) 雙基地連結:告訴接收機「照亮目標的是哪一具發射機」。
    #     沒有這一步,接收端的 Search/Track 完全算不出訊號。
    rx.Model.BistaticTransmitters.RemoveAll()
    rx.Model.BistaticTransmitters.AddObject(tx)

    # (2) 干擾清單
    rx.Model.Jamming.Enabled = True
    rx.Model.Jamming.Jammers.RemoveAll()
    rx.Model.Jamming.Jammers.AddObject(jm)

    # (3) 脈衝積分:預設的 Goal SNR 會「積到達成目標 SNR 為止」,
    #     於是 Integrated SNR 永遠等於那個目標值,PDet 反而被偵測門檻壓死。
    #     要看真實的偵測表現,必須改成固定脈衝數。
    wf = rx.Model.Mode.Waveform
    wf.PulseIntegrationType = PULSE_INTEGRATION_FIXED_PULSE_NUMBER
    wf.PulseIntegration.PulseNumber = pulses

    if jammer_power_w is not None:
        root.ExecuteCommand("Radar %s SetValue Model.Transmitter.Power %g W"
                            % (JMR, jammer_power_w))

    # 設定改過之後一定要清掉舊的 access 快取,否則後面取到的是舊結果。
    root.ExecuteCommand("ClearAllAccess /")
    return app, root, ack, nack


def main():
    app, root, ack, nack = build()
    rx = root.GetObjectFromPath(RXR)
    print("\n雙基地連結  BistaticTransmitters =", rx.Model.BistaticTransmitters.Count)
    print("干擾清單    Jammers               =", rx.Model.Jamming.Jammers.Count)
    print("脈衝積分    type=%s / %d 個脈衝"
          % (rx.Model.Mode.Waveform.PulseIntegrationType,
             rx.Model.Mode.Waveform.PulseIntegration.PulseNumber))
    acc = rx.GetAccessToObject(root.GetObjectFromPath(TGT))
    acc.ComputeAccess()
    n = acc.ComputedAccessIntervalTimes.Count
    print("接收雷達對目標的 access 區間數 =", n)
    return 0


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
radar_bistatic/bistatic_deck.py
# -*- coding: utf-8 -*-
"""獨立章節 1 的簡報內容。純資料,不 import STK。"""

DECK = dict(
    eyebrow="獨立章節 1 · STK Radar(雙基地)",
    title="二十瓦打斷十萬瓦",
    subtitle="地面雙基地雷達:干擾、雜波與航跡穩定度",
    thesis="一部 20 W 的空中干擾機,足以讓 100 kW 的雙基地雷達失去航跡 —— "
           "因為干擾走單程,雷達走雙程。",

    scenario=[
        ("發射站 / 接收站", "分離 78 km 基線,雙基地構型"),
        ("雷達", "100 kW 發射,單基地 RCS 10 m²(10 dBsm)"),
        ("目標", "9 km 高空飛行器,已知真值航跡"),
        ("干擾機", "空中平台,0.1 W – 2000 W 掃描"),
        ("雜波", "Single Point 幾何,反射係數 0.01 – 5000 掃描"),
        ("追蹤", "alpha-beta 濾波,量測 σ 由 SNR 推導(Skolnik 一階)"),
    ],

    focus=[
        "雙基地的等訊號輪廓是 Cassini 卵形線,不是圓 —— 距離項是 (Rt·Rr)²。",
        "干擾走單程、雷達走雙程,所以干擾機的功率優勢是巨大的。",
        "雜波與雜訊完全不同:加發射功率對雜波無效,訊號與雜波等比例增加。",
        "航跡穩定度才是工程判準 —— 偵測率高不代表航跡活得下來。",
    ],

    animation="bistatic_anim.gif",
    animation_title="動畫:干擾如何吃掉航跡",
    animation_caption="目標沿真值航跡飛行;干擾開啟後偵測中斷、"
                      "alpha-beta 濾波的估計誤差擴大",

    figures=[
        ("bistatic_tracking.png",
         "航跡穩定度 —— 四種構型(乾淨 / 只有雜波 / 只有干擾 / 干擾+雜波)"),
        ("bistatic_sweep.png",
         "掃描 —— 干擾功率與雜波反射係數各自的門檻"),
    ],

    results_caption="全部為 STK 12.10.0 實測",
    results=[
        ("情境", "偵測率", "航跡存活", "RMS 位置誤差"),
        ("乾淨", "100.0%", "100.0%", "189 m"),
        ("只有雜波", "100.0%", "100.0%", "196 m"),
        ("只有干擾(20 W)", "83.2%", "95.7%", "918 m"),
        ("干擾 + 雜波", "82.9%", "95.7%", "920 m"),
        ("", "", "", ""),
        ("干擾功率掃描", "偵測率", "最長中斷", ""),
        ("1 W", "98.2%", "10 s", ""),
        ("10 W", "89.6%", "40 s", "← 打穿門檻"),
        ("50 W", "61.4%", "160 s", ""),
        ("2000 W", "10.4%", "225 s", ""),
    ],

    findings=[
        "20 W 干擾機讓 RMS 定位誤差從 189 m 變成 918 m —— **4.9 倍**,"
        "而偵測率只從 100% 掉到 83%。偵測率是誤導性的指標。",
        "雜波在這個構型下幾乎無害:主瓣確實掃到地面,但 C/N 最高只有 −1.6 dB。"
        "目標在 9 km 高空,掠射角大,雜波面積小。",
        "雙基地的雜波幾何只有 Single_Point 算得出來 —— Smooth_Oblate_Earth "
        "在雙基地構型下靜默地什麼都不產出,已寫成一項反向驗證檢查。",
        "Bistatic Transmitter 的 Connect 指令要寫成 Bistatic_Transmitter(底線),"
        "而干擾機與雙基地發射機清單只能用物件模型 AddObject。",
        "Goal SNR 的脈衝積分會壓垮 PDet —— 要用 Fixed Pulse Number。",
    ],

    verify=(62, 0, 0),
    verify_note=[
        "62 項涵蓋:Connect 設過的每個值回讀比對、雙基地與干擾機清單的結構、"
        "Cassini 幾何的物理判準,以及一項反向檢查 ——",
        "「Smooth_Oblate_Earth 在雙基地下算不出雜波」寫成預期成立的斷言;"
        "哪天 STK 修好了,那一項會 FAIL,提醒我們重寫結論。",
    ],

    run=[
        "python bistatic_build.py     --pid <PID>   建場景(72 條 Connect,0 NACK)",
        "python bistatic_tracking.py  --pid <PID>   Part A–D + 航跡圖",
        "python bistatic_animate.py   --pid <PID>   展示動畫",
        "python bistatic_sweep.py     --pid <PID>   干擾與雜波掃描(數分鐘)",
        "python bistatic_verify.py    --pid <PID>   驗證段(62 項)",
    ],
    files=[
        ("bistatic_radar_Connect.txt", "Connect 腳本(72 條,0 NACK)"),
        ("bistatic_build.py", "雙基地與干擾機清單(物件模型)"),
        ("bistatic_tracking.py", "Part A–D:幾何、干擾、雜波、航跡穩定度"),
        ("bistatic_animate.py", "展示動畫"),
        ("bistatic_sweep.py", "干擾功率與雜波係數掃描"),
        ("bistatic_verify.py", "驗證段(62 項全 PASS)"),
        ("RadarBistatic_Primer.md", "基礎知識導論"),
        ("RadarBistatic_Lab_Handout.md", "實驗單"),
    ],
)
radar_bistatic/bistatic_radar_Connect.txt
# ==============================================================================
# 地面雙基地雷達 — 場景建構 Connect 腳本
#
# 這支腳本負責「Connect 做得到」的部分:建物件、擺位置、設波形、設天線、
# 開雜波。三件 Connect 做不到的事留給 bistatic_build.py 用物件模型補:
#
#   1. 把發射雷達加進接收雷達的 BistaticTransmitters 清單
#   2. 把干擾機加進接收雷達的 Jammers 清單
#   3. 把脈衝積分策略從 Goal SNR 改成 Fixed Pulse Number
#
# 這三件事在 STK 12.10 都沒有可用的 Connect 屬性路徑(實測 NACK),
# 詳見 RadarBistatic_Primer.md 第 5 節。
#
# 執行:python bistatic_build.py(它會先送這支腳本,再補上述三件事)
# ==============================================================================

# ---- 場景 --------------------------------------------------------------------
New / Scenario BistaticTrack
SetAnalysisTimePeriod * "1 Jun 2024 02:00:00.000" "1 Jun 2024 02:30:00.000"
SetUnits / km

# ---- 真值航跡 ----------------------------------------------------------------
# 大圓航路 + 明確航點 = 完全確定的真值。之後所有「追得準不準」都是拿這條當基準。
New / */Aircraft Track01
SetPropagator */Aircraft/Track01 GreatArc
Waypoints */Aircraft/Track01 Clear
AddWaypoint */Aircraft/Track01 DetTimeAccFromVel 26.00 119.90 9.144 0.25
AddWaypoint */Aircraft/Track01 DetTimeAccFromVel 24.60 119.70 9.144 0.25
AddWaypoint */Aircraft/Track01 DetTimeAccFromVel 23.00 120.40 9.144 0.25
Propagate */Aircraft/Track01

# 目標 RCS:10 m^2 (10 dBsm),中型機的量級。
# 注意 Inherit 要先關掉,否則物件會沿用場景層級的值,你設的數字不會生效。
RCS */Aircraft/Track01 SetValue Inherit false
RCS */Aircraft/Track01 SetValue Model.FrequencyBandList[0].ComputeTypeStrategy.ConstantValue 10.0 sqm

# ---- 干擾機:空中支援干擾(stand-off jammer)---------------------------------
# 為什麼用 Aircraft 而不用 Place:
#   Place 想抬高只能用 SetHeightAboveGround。用 SetPosition ... 10.0 設高度會
#   ACK、回讀也顯示 10 km,但幾何上仍然貼在地面,對地面站完全沒有 access。
#   這是本章最典型的「ACK 不等於生效,而且回讀還會騙你」案例。
New / */Aircraft JamAir
SetPropagator */Aircraft/JamAir GreatArc
Waypoints */Aircraft/JamAir Clear
AddWaypoint */Aircraft/JamAir DetTimeAccFromVel 24.90 118.90 10.0 0.20
AddWaypoint */Aircraft/JamAir DetTimeAccFromVel 23.70 118.90 10.0 0.20
AddWaypoint */Aircraft/JamAir DetTimeAccFromVel 24.90 118.90 10.0 0.20
Propagate */Aircraft/JamAir

# ---- 兩個地面站 --------------------------------------------------------------
New / */Place TxSite
SetPosition */Place/TxSite Geodetic 24.90 120.85 0.0
New / */Place RxSite
SetPosition */Place/RxSite Geodetic 24.30 120.45 0.0

# ---- 伺服機構:用 Sensor 當天線的轉台 ----------------------------------------
# Radar 物件的天線指向父物件的 Z 體軸。把 Radar 掛在一個「Targeted」的
# Sensor 底下,天線就會跟著轉台鎖定目標。
# 錐角只影響可見性判定,不影響指向;這裡取 3 度(略大於天線波束)。
New / */Place/TxSite/Sensor TxServo
Define */Place/TxSite/Sensor/TxServo SimpleCone 3.0
Point */Place/TxSite/Sensor/TxServo Targeted Add Aircraft/Track01
Point */Place/TxSite/Sensor/TxServo Targeted Tracking Aircraft/Track01
Location */Place/TxSite/Sensor/TxServo Fixed Cartesian 0.0 0.0 -15.0

New / */Place/RxSite/Sensor RxServo
Define */Place/RxSite/Sensor/RxServo SimpleCone 3.0
Point */Place/RxSite/Sensor/RxServo Targeted Add Aircraft/Track01
Point */Place/RxSite/Sensor/RxServo Targeted Tracking Aircraft/Track01
Location */Place/RxSite/Sensor/RxServo Fixed Cartesian 0.0 0.0 -15.0

New / */Aircraft/JamAir/Sensor JamServo
Define */Aircraft/JamAir/Sensor/JamServo SimpleCone 10.0
Point */Aircraft/JamAir/Sensor/JamServo Targeted Add Place/RxSite
Point */Aircraft/JamAir/Sensor/JamServo Targeted Tracking Place/RxSite

# ---- 三具雷達 ----------------------------------------------------------------
# Connect 的型別名稱是底線版:Bistatic_Transmitter / Bistatic_Receiver。
# GUI 與物件模型用的是有空格的 "Bistatic Transmitter",Connect 送空格版會 NACK。
New / */Place/TxSite/Sensor/TxServo/Radar TxRadar
New / */Place/RxSite/Sensor/RxServo/Radar RxRadar
New / */Aircraft/JamAir/Sensor/JamServo/Radar Jammer

Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model Bistatic_Transmitter
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model Bistatic_Receiver
Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model Monostatic

# ---- 天線 --------------------------------------------------------------------
# 順序很重要:先設天線型別,再設設計頻率。
# 反過來寫,設定頻率會被換型別的動作沖回 14.5 GHz 預設值,而且不會有任何錯誤訊息。
# (這個坑在本教材的 Week 2 與 Week 11 都踩過,雷達這裡一模一樣。)
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.AntennaControl.Antenna Parabolic
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.AntennaControl.Antenna.InputType "Use Diameter"
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.AntennaControl.Antenna.Diameter 4.0 m
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.AntennaControl.Antenna.DesignFrequency 3.0 GHz

Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.AntennaControl.Antenna Parabolic
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.AntennaControl.Antenna.InputType "Use Diameter"
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.AntennaControl.Antenna.Diameter 4.0 m
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.AntennaControl.Antenna.DesignFrequency 3.0 GHz

Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model.AntennaControl.Antenna Parabolic
Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model.AntennaControl.Antenna.InputType "Use Diameter"
Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model.AntennaControl.Antenna.Diameter 1.00 m
Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model.AntennaControl.Antenna.DesignFrequency 3.0 GHz

# ---- 發射端:功率與波形 ------------------------------------------------------
# 雙基地系統的脈衝定義(PRF / 脈寬 / 脈衝數)在發射端,
# 積分與偵測門檻(PulseIntegration / Pfa)在接收端。兩邊不要設錯地方。
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.Transmitter.Power 100000 W
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.Transmitter.Frequency 3.0 GHz
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.Mode.Waveform.PulseDefinition.PRF 2000 Hz
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.Mode.Waveform.PulseDefinition.PulseWidth 1e-6 sec
Radar */Place/TxSite/Sensor/TxServo/Radar/TxRadar SetValue Model.Mode.Waveform.PulseDefinition.NumberOfPulses 20

# ---- 接收端 ------------------------------------------------------------------
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Receiver.Frequency 3.0 GHz
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Receiver.ConstantSystemNoiseTemp 290 K
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Mode.Waveform.ProbabilityOfDetection.ProbabilityFalseAlarm 1e-6

# ---- 干擾機發射參數 ----------------------------------------------------------
Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model.Transmitter.Power 20 W
Radar */Aircraft/JamAir/Sensor/JamServo/Radar/Jammer SetValue Model.Transmitter.Frequency 3.0 GHz

# ---- 干擾與雜波的開關(清單內容要用物件模型加)------------------------------
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Jamming.Enabled true
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.ClutterEnabled true
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Clutter.PointProviderList[0].PointProvider Single_Point
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Clutter.PointProviderList[0].PointProvider.ScatteringPointModel Constant_Coefficient
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Clutter.PointProviderList[0].PointProvider.ScatteringPointModel.ClutterCoefficient 0.05
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Mode.ClutterFilters.EnableMainLobeClutter true
Radar */Place/RxSite/Sensor/RxServo/Radar/RxRadar SetValue Model.Mode.ClutterFilters.EnableSideLobeClutter true
radar_bistatic/bistatic_sweep.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 地面雙基地雷達 — 兩個掃描:干擾機功率、雜波反射係數
#
# bistatic_tracking.py 回答「這一組參數下追不追得住」。
# 本檔回答設計者真正要問的兩個問題:
#
#   1. 干擾機要多大功率才能打斷航跡?(反過來說:雷達要多少餘裕才撐得住)
#   2. 雜波反射係數要多大才會開始咬人?
#
# 這兩條曲線的形狀比單點結果重要:平原代表設計耐受,懸崖代表脆弱。
#
# 執行(需先跑過 bistatic_build.py):
#   python bistatic_sweep.py
# ==============================================================================

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

from agi.stk12.stkdesktop import STKDesktop                       # noqa: E402
from bistatic_tracking import (                                   # noqa: E402
    RXR, STEP, PDET_MIN, ST_ELEMENTS, clean, configure, continuity, fetch)

JMR = "*/Aircraft/JamAir/Sensor/JamServo/Radar/Jammer"
CLUT = ("Radar %s SetValue Model.Clutter.PointProviderList[0]"
        ".PointProvider.ScatteringPointModel.ClutterCoefficient %g")

JAM_POWERS = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 200.0, 2000.0]   # W
CLUT_COEFFS = [0.01, 0.05, 0.5, 5.0, 50.0, 500.0, 5000.0]         # 線性反射係數


def measure(root, pdet_element):
    st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
    p = clean(st[pdet_element])
    rate, longest, ngap = continuity(p)
    return rate, longest, ngap, np.nanmean(p)


def sweep_jammer(root):
    print("=" * 74)
    print("掃描 1  干擾機功率(雜波關閉,單獨看干擾)")
    print("=" * 74)
    print("  %10s %10s %13s %10s %s"
          % ("功率 (W)", "偵測率", "最長中斷(s)", "中斷次數", "航跡"))
    rows = []
    for p in JAM_POWERS:
        root.ExecuteCommand("Radar %s SetValue Model.Transmitter.Power %g W" % (JMR, p))
        configure(root, True, False)
        rate, longest, ngap, mean = measure(root, "S/T Integrated PDet w/Jamming")
        rows.append((p, rate, longest, ngap))
        print("  %10g %9.1f%% %13.0f %10d %s"
              % (p, rate * 100, longest, ngap, "維持" if longest <= 30 else "丟失"))
    return rows


def sweep_clutter(root):
    print()
    print("=" * 74)
    print("掃描 2  雜波反射係數(干擾關閉,單獨看雜波)")
    print("=" * 74)
    print("  %10s %10s %13s %10s %s"
          % ("反射係數", "偵測率", "最長中斷(s)", "中斷次數", "航跡"))
    rows = []
    for c in CLUT_COEFFS:
        # 順序不能顛倒:ClutterEnabled 是 false 的時候,整棵雜波屬性樹都是
        # (Read-only),SetValue 會 NACK。要先開啟才寫得進去。
        configure(root, False, True)
        root.ExecuteCommand(CLUT % (RXR, c))
        root.ExecuteCommand("ClearAllAccess /")
        rate, longest, ngap, mean = measure(root, "S/T Integrated PDet w/J+C")
        rows.append((c, rate, longest, ngap))
        print("  %10g %9.1f%% %13.0f %10d %s"
              % (c, rate * 100, longest, ngap, "維持" if longest <= 30 else "丟失"))
    return rows


def plot(jam, clut, path):
    fig, ax = plt.subplots(1, 2, figsize=(12, 4.4))
    for a, rows, xlabel, title in [
            (ax[0], jam, "jammer power (W)", "jammer power sweep"),
            (ax[1], clut, "clutter reflection coefficient", "clutter sweep")]:
        x = [r[0] for r in rows]
        a.semilogx(x, [r[1] * 100 for r in rows], "o-", lw=1.8, color="C0")
        a.set_xlabel(xlabel)
        a.set_ylabel("detection rate (%)", color="C0")
        a.set_ylim(-5, 105)
        a.axhline(90, ls="--", c="k", lw=1)
        a2 = a.twinx()
        a2.semilogx(x, [max(r[2], 1) for r in rows], "s--", lw=1.4, color="C3")
        a2.set_yscale("log")
        a2.set_ylabel("longest gap (s)", color="C3")
        a.set_title(title)
        a.grid(alpha=.3, which="both")
    fig.tight_layout()
    fig.savefig(path, dpi=130)
    print("\n圖已存到", path)


def threshold(rows, floor=0.9):
    """找出偵測率跌破 floor 的第一個值,回傳 (前一個安全值, 第一個失敗值)。"""
    prev = None
    for x, rate, _, _ in rows:
        if rate < floor:
            return prev, x
        prev = x
    return prev, None


def main():
    app = STKDesktop.AttachToApplication()
    root = app.Root
    if root.CurrentScenario is None:
        print("沒有開啟的場景。請先執行 python bistatic_build.py")
        return 1

    jam = sweep_jammer(root)
    root.ExecuteCommand("Radar %s SetValue Model.Transmitter.Power 2000 W" % JMR)
    clut = sweep_clutter(root)
    configure(root, True, True)                # 還原成預設設定
    root.ExecuteCommand(CLUT % (RXR, 0.05))
    root.ExecuteCommand("ClearAllAccess /")

    plot(jam, clut, os.path.join(HERE, "bistatic_sweep.png"))

    print()
    print("=" * 74)
    print("結論")
    print("=" * 74)
    ok, bad = threshold(jam)
    print("  干擾機:%s W 還撐得住,%s W 就打穿了(偵測率 < 90%%)。"
          % (ok, bad if bad is not None else "掃描範圍內都沒有"))
    ok, bad = threshold(clut)
    print("  雜波  :反射係數 %s 還撐得住,%s 就開始咬人。"
          % (ok, bad if bad is not None else "掃描範圍內都沒有"))
    print()
    print("  兩條曲線的形狀比單點數字重要:")
    print("  平坦的一段代表設計對該參數不敏感,陡降的一段代表那裡是懸崖。")
    return 0


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
radar_bistatic/bistatic_tracking.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 地面雙基地雷達 — 能不能穩定追蹤已知真值航跡?
#
# 「看得到」跟「追得住」是兩件事。本檔把它拆成四段:
#
#   Part A  幾何:雙基地距離和、雙基地角、距離乘積(Cassini 卵形線)
#   Part B  偵測:四種情境的 PDet
#   Part C  航跡連續性:偵測率、最長中斷、中斷次數
#   Part D  追蹤濾波:用 STK 的 SNR 產生量測雜訊,跑 alpha-beta 濾波器,
#           與 STK 給的真值逐點比對,算出真正的定位誤差
#
# Part D 才是本章的重點。前三段回答「訊號夠不夠」,
# Part D 回答使用者真正在意的事:**這條航跡的位置資訊可不可信。**
#
# 執行:
#   1. 開啟 STK 12 桌面版
#   2. python bistatic_build.py       (先建場景)
#   3. python bistatic_tracking.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

from agi.stk12.stkdesktop import STKDesktop                       # noqa: E402

RXR = "*/Place/RxSite/Sensor/RxServo/Radar/RxRadar"
TGT = "*/Aircraft/Track01"

STEP = 5.0          # 取樣間隔 (s) —— 也就是雷達更新率
PDET_MIN = 0.8      # 判定「這一拍有偵測」的門檻
COAST_MAX = 30.0    # 濾波器最多允許外推多久 (s),超過視為航跡丟失
C_LIGHT = 299792458.0
SEED = 20240601     # 固定亂數種子,量測雜訊可重現

# STK 用 -9999.99 / 3000 這類哨兵值表示「這個量沒有計算」。
# 不濾掉的話,平均值會被汙染成無意義的數字 —— 而且不會有任何錯誤訊息。
SENTINEL_LOW = -9000.0
SENTINEL_HIGH = 2999.0

# 四個情境。STK 只提供「乾淨 / 加干擾 / 加干擾+雜波」三個 PDet 元素,
# 沒有「只有雜波」。要把雜波的貢獻單獨拆出來,只能真的把干擾關掉再算一次 ——
# 這就是為什麼下面要逐一切換設定重新計算,而不是一次取數就好。
CASES = [
    # 顯示名稱     畫圖用      干擾   雜波   PDet 元素                        SNR 元素
    ("乾淨",       "clean",    False, False, "S/T Integrated PDet",
     "S/T Integrated SNR"),
    ("只有雜波",   "clutter",  False, True,  "S/T Integrated PDet w/J+C",
     "S/T Integrated S/(N+J+C)"),
    ("只有干擾",   "jamming",  True,  False, "S/T Integrated PDet w/Jamming",
     "S/T Integrated S/(N+J)"),
    ("干擾+雜波",  "jam+clut", True,  True,  "S/T Integrated PDet w/J+C",
     "S/T Integrated S/(N+J+C)"),
]

GEO_ELEMENTS = ["Time", "Range Sum", "Bistatic Angle", "Rcvr Range", "Xmtr Range"]
ST_ELEMENTS = ["Time", "S/T Integrated SNR", "S/T Integ. Det. Thresh.",
               "S/T Integrated J/S", "S/T C/N",
               "S/T Integrated S/(N+J)", "S/T Integrated S/(N+J+C)",
               "S/T Integrated PDet", "S/T Integrated PDet w/Jamming",
               "S/T Integrated PDet w/J+C"]


# ------------------------------------------------------------------------------
# 取數
# ------------------------------------------------------------------------------
def clean(a):
    """把哨兵值換成 NaN。"""
    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):
    """從 Access 的資料提供者取一組元素,回傳 {元素名: numpy 陣列}。"""
    rx = root.GetObjectFromPath(RXR)
    acc = rx.GetAccessToObject(root.GetObjectFromPath(TGT))
    acc.ComputeAccess()
    sc = root.CurrentScenario
    res = acc.DataProviders.Item(dp_name).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 configure(root, jamming, clutter):
    """切換干擾與雜波的開關,並清掉舊的 access 快取。

    不清快取的話,拿到的會是上一個設定的結果 —— 而且數字看起來完全正常。
    """
    rx = root.GetObjectFromPath(RXR)
    rx.Model.Jamming.Enabled = bool(jamming)
    root.ExecuteCommand("Radar %s SetValue Model.ClutterEnabled %s"
                        % (RXR, "true" if clutter else "false"))
    root.ExecuteCommand("ClearAllAccess /")


def truth_track(root, step=STEP):
    """從 STK 取目標的真值航跡(地固座標,公尺)。"""
    tgt = root.GetObjectFromPath(TGT)
    sc = root.CurrentScenario
    dp = tgt.DataProviders.Item("Cartesian Position").Group.Item("Fixed")
    res = dp.ExecElements(sc.StartTime, sc.StopTime, step, ["Time", "x", "y", "z"])
    g = {}
    for i in range(res.DataSets.Count):
        d = res.DataSets.Item(i)
        g[d.ElementName] = np.array(
            [v if isinstance(v, (int, float)) else np.nan for v in d.GetValues()],
            dtype=float)
    # Connect 單位設成 km,Cartesian Position 也回公里 -> 換成公尺
    return g["Time"], np.vstack([g["x"], g["y"], g["z"]]).T * 1000.0


def collect(root):
    """逐一切換四個情境並取數。回傳 (t, geo, per_case)。"""
    geo = None
    per = {}
    for name, _, jam, clut, pdet_el, snr_el in CASES:
        configure(root, jam, clut)
        st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
        if geo is None:
            geo = fetch(root, "Radar Geometry", GEO_ELEMENTS)
        snr = clean(st.get(snr_el, st["S/T Integrated SNR"]))
        # 情境關掉的那一項,對應的 SNR 元素會是空的 -> 退回乾淨 SNR
        snr = np.where(np.isfinite(snr), snr, clean(st["S/T Integrated SNR"]))
        per[name] = dict(pdet=clean(st[pdet_el]), snr=snr,
                         thr=clean(st["S/T Integ. Det. Thresh."]),
                         js=clean(st["S/T Integrated J/S"]),
                         cn=clean(st["S/T C/N"]))
    return st["Time"], geo, per


# ------------------------------------------------------------------------------
# Part A 幾何
# ------------------------------------------------------------------------------
def part_a(geo):
    print("=" * 74)
    print("Part A  雙基地幾何")
    print("=" * 74)
    rs, br = clean(geo["Range Sum"]), clean(geo["Bistatic Angle"])
    rr, rt = clean(geo["Rcvr Range"]), clean(geo["Xmtr Range"])
    print("  發射站到目標 Rt : %7.1f - %7.1f km" % (np.nanmin(rt), np.nanmax(rt)))
    print("  目標到接收站 Rr : %7.1f - %7.1f km" % (np.nanmin(rr), np.nanmax(rr)))
    print("  雙基地距離和 R  : %7.1f - %7.1f km" % (np.nanmin(rs), np.nanmax(rs)))
    print("  雙基地角 beta   : %7.2f - %7.2f deg" % (np.nanmin(br), np.nanmax(br)))
    print()
    print("  單基地雷達的作用距離由 R^4 決定,雙基地由乘積 (Rt*Rr)^2 決定。")
    print("  等訊號強度的軌跡是 Cassini 卵形線 Rt*Rr = 常數,不是圓。")
    prod = rt * rr
    print("  距離乘積 Rt*Rr  : %.3g - %.3g km^2,對訊號的影響達 %.1f dB"
          % (np.nanmin(prod), np.nanmax(prod),
             20 * math.log10(np.nanmax(prod) / np.nanmin(prod))))
    return rs, br, rt, rr


# ------------------------------------------------------------------------------
# Part B 偵測
# ------------------------------------------------------------------------------
def part_b(per):
    print()
    print("=" * 74)
    print("Part B  偵測機率(四個情境各自重新計算)")
    print("=" * 74)
    jc = per["干擾+雜波"]
    print("  乾淨 SNR        : %6.2f - %6.2f dB   (偵測門檻 %.2f dB)"
          % (np.nanmin(per["乾淨"]["snr"]), np.nanmax(per["乾淨"]["snr"]),
             np.nanmedian(per["乾淨"]["thr"])))
    for label, key in [("干擾訊號比 J/S", "js"), ("雜波雜訊比 C/N", "cn")]:
        v = jc[key]
        if np.isfinite(v).any():
            print("  %-15s : %6.2f - %6.2f dB   有效樣本 %d/%d"
                  % (label, np.nanmin(v), np.nanmax(v), np.isfinite(v).sum(), v.size))
        else:
            print("  %-15s : 全部無資料 —— 這一項根本沒有接上,不是「影響很小」" % label)
    print()
    for name, _, _, _, _, _ in CASES:
        p = per[name]["pdet"]
        print("  PDet(%-10s): %.4f - %.4f   平均 %.4f"
              % (name, np.nanmin(p), np.nanmax(p), np.nanmean(p)))


# ------------------------------------------------------------------------------
# Part C 航跡連續性
# ------------------------------------------------------------------------------
def continuity(pdet, 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)
    longest = max(gaps) * STEP if gaps else 0.0
    return (ok.mean() if ok.size else 0.0), longest, len(gaps)


def part_c(per):
    print()
    print("=" * 74)
    print("Part C  航跡連續性(PDet >= %.1f 視為這一拍有偵測)" % PDET_MIN)
    print("=" * 74)
    print("  %-11s %9s %13s %9s   %s"
          % ("情境", "偵測率", "最長中斷(s)", "中斷次數", "航跡是否維持"))
    for name, _, _, _, _, _ in CASES:
        rate, longest, ngap = continuity(per[name]["pdet"])
        held = longest <= COAST_MAX
        per[name].update(rate=rate, longest=longest, ngap=ngap)
        print("  %-11s %8.1f%% %13.0f %9d   %s"
              % (name, rate * 100, longest, ngap,
                 "維持" if held else "丟失(超過 %.0f s 外推上限)" % COAST_MAX))


# ------------------------------------------------------------------------------
# Part D 追蹤濾波器 vs 真值
# ------------------------------------------------------------------------------
def measurement_sigma(snr_db, beamwidth_rad, rng_m, pulse_width=1e-6):
    """由 SNR 推出量測誤差標準差(Skolnik 的一階式)。

        距離  sigma_R  = c / (2 * B * sqrt(2*SNR)),B = 1/脈寬
        角度  sigma_th = theta_3dB / (1.6 * sqrt(2*SNR))

    重點不是係數多精確,而是 **量測精度隨 SNR 惡化**:
    干擾與雜波不只讓你看不到,還會讓看得到的那些拍變不準。
    """
    snr = np.clip(np.power(10.0, np.asarray(snr_db, float) / 10.0), 1e-3, None)
    sig_r = C_LIGHT / (2.0 * (1.0 / pulse_width) * np.sqrt(2.0 * snr))
    sig_cross = (beamwidth_rad / (1.6 * np.sqrt(2.0 * snr))) * rng_m
    return sig_r, sig_cross


def alpha_beta(t, meas, hit, alpha=0.5, beta=0.1, coast_max=COAST_MAX):
    """三維 alpha-beta 濾波器。

    沒有量測時外推 (coast);連續外推超過 coast_max 秒就宣告航跡丟失,
    下一個有效量測重新起始。回傳 (估計, 航跡是否存活, 重起次數)。
    """
    n = len(t)
    est = np.full((n, 3), np.nan)
    alive = np.zeros(n, dtype=bool)
    x = v = None
    coast, restarts, started = 0.0, 0, False
    for k in range(n):
        dt = (t[k] - t[k - 1]) if k else 0.0
        if x is not None:
            x = x + v * dt
        if hit[k] and np.isfinite(meas[k]).all():
            if x is None:
                x, v = meas[k].copy(), np.zeros(3)
                if started:
                    restarts += 1
                started = True
            else:
                resid = meas[k] - x
                x = x + alpha * resid
                if dt > 0:
                    v = v + (beta / dt) * resid
            coast = 0.0
        else:
            coast += dt
            if coast > coast_max:
                x = v = None                    # 航跡丟失
        if x is not None:
            est[k] = x
            alive[k] = True
    return est, alive, restarts


def part_d(root, t, per, geo):
    print()
    print("=" * 74)
    print("Part D  追蹤濾波器 vs 真值航跡")
    print("=" * 74)
    t_truth, truth = truth_track(root)
    n = min(len(t), len(t_truth))
    t, truth = np.asarray(t[:n], float), truth[:n]
    rng = clean(geo["Rcvr Range"])[:n] * 1000.0

    lam = C_LIGHT / 3.0e9
    beamwidth = math.radians(70.0 * lam / 4.0)     # 拋物面 4 m 天線的 3 dB 波束寬
    print("  接收天線 3 dB 波束寬 = %.3f deg,量測誤差由此換算" % math.degrees(beamwidth))
    print()
    print("  %-11s %8s %10s %7s %11s %11s"
          % ("情境", "命中率", "航跡存活", "重起", "RMS 誤差", "P95 誤差"))

    for name, _, _, _, _, _ in CASES:
        d = per[name]
        rs = np.random.RandomState(SEED)           # 每個情境用同一組雜訊種子
        snr = d["snr"][:n]
        sig_r, sig_x = measurement_sigma(snr, beamwidth, rng)
        sigma = np.sqrt((sig_r ** 2 + 2 * sig_x ** 2) / 3.0)   # 等效各軸誤差
        pd = d["pdet"][:n]
        hit = np.isfinite(pd) & (rs.rand(n) < np.nan_to_num(pd))
        meas = truth + np.nan_to_num(rs.randn(n, 3) * sigma[:, None])

        est, alive, restarts = alpha_beta(t, meas, hit)
        err = np.linalg.norm(est - truth, axis=1)
        e = err[np.isfinite(err)]
        d.update(hit=hit.mean(), alive=alive.mean(), restarts=restarts,
                 rms=np.sqrt(np.mean(e ** 2)) if e.size else np.nan,
                 p95=np.percentile(e, 95) if e.size else np.nan,
                 err=err, sigma=sigma)
        print("  %-11s %7.1f%% %9.1f%% %7d %9.0f m %9.0f m"
              % (name, d["hit"] * 100, d["alive"] * 100, restarts, d["rms"], d["p95"]))
    return t, truth


# ------------------------------------------------------------------------------
def plot(t, geo, per, path):
    fig, ax = plt.subplots(2, 2, figsize=(13.5, 8.5))
    n = len(t)
    rs = clean(geo["Range Sum"])[:n]
    br = clean(geo["Bistatic Angle"])[:n]
    ascii_name = {c[0]: c[1] for c in CASES}

    a = ax[0][0]
    a.plot(t / 60.0, rs, lw=1.6, color="C0")
    a.set_xlabel("time (min)")
    a.set_ylabel("bistatic range sum (km)", color="C0")
    a2 = a.twinx()
    a2.plot(t / 60.0, br, lw=1.6, color="C3")
    a2.set_ylabel("bistatic angle (deg)", color="C3")
    a.set_title("A. bistatic geometry")
    a.grid(alpha=.3)

    a = ax[0][1]
    for i, (name, _, _, _, _, _) in enumerate(CASES):
        a.plot(t / 60.0, per[name]["pdet"][:n], lw=1.4,
               label=ascii_name[name], color="C%d" % i)
    a.axhline(PDET_MIN, ls="--", c="k", lw=1)
    a.set_xlabel("time (min)")
    a.set_ylabel("integrated PDet")
    a.set_ylim(-0.05, 1.08)
    a.set_title("B. detection probability (PDet=%.1f dashed)" % PDET_MIN)
    a.legend(fontsize=8, ncol=2)
    a.grid(alpha=.3)

    a = ax[1][0]
    for i, (name, _, _, _, _, _) in enumerate(CASES):
        p = per[name]["pdet"][:n]
        ok = np.isfinite(p) & (p >= PDET_MIN)
        a.fill_between(t / 60.0, i, i + ok * 0.8, step="mid",
                       color="C%d" % i, alpha=.85, linewidth=0)
    a.set_yticks([i + 0.4 for i in range(len(CASES))])
    a.set_yticklabels([ascii_name[c[0]] for c in CASES], fontsize=9)
    a.set_xlabel("time (min)")
    a.set_title("C. track continuity (filled = detected)")
    a.grid(alpha=.3, axis="x")

    a = ax[1][1]
    for i, (name, _, _, _, _, _) in enumerate(CASES):
        a.plot(t / 60.0, per[name]["err"][:n], lw=1.2,
               label="%s  RMS %.0f m" % (ascii_name[name], per[name]["rms"]),
               color="C%d" % i)
    a.set_yscale("log")
    a.set_xlabel("time (min)")
    a.set_ylabel("position error vs truth (m)")
    a.set_title("D. tracking error against known truth")
    a.legend(fontsize=8)
    a.grid(alpha=.3, which="both")

    fig.tight_layout()
    fig.savefig(path, dpi=130)
    print("\n圖已存到", path)


def main():
    app = STKDesktop.AttachToApplication()      # 保留 app,否則 COM 介面會失效
    root = app.Root
    if root.CurrentScenario is None:
        print("沒有開啟的場景。請先執行 python bistatic_build.py")
        return 1

    t, geo, per = collect(root)
    part_a(geo)
    part_b(per)
    part_c(per)
    t2, truth = part_d(root, t, per, geo)
    plot(t2, geo, per, os.path.join(HERE, "bistatic_tracking.png"))

    # 收尾:把場景還原成「干擾與雜波都開」,免得下一支腳本拿到半調子的設定
    configure(root, True, True)

    print()
    print("=" * 74)
    print("結論")
    print("=" * 74)
    a, c, j, jc = [per[k[0]] for k in CASES]
    print("  乾淨       偵測率 %5.1f%%,航跡不斷,定位誤差 RMS %.0f m。"
          % (a["rate"] * 100, a["rms"]))
    print("  只有雜波   偵測率 %5.1f%%,RMS %.0f m。"
          % (c["rate"] * 100, c["rms"]))
    print("  只有干擾   偵測率 %5.1f%%,最長中斷 %.0f s,RMS %.0f m。"
          % (j["rate"] * 100, j["longest"], j["rms"]))
    print("  干擾+雜波  偵測率 %5.1f%%,航跡重起 %d 次,RMS %.0f m。"
          % (jc["rate"] * 100, jc["restarts"], jc["rms"]))
    print()
    print("  把四個情境分開算,才知道退化是誰造成的。只跑「全開」那一組,")
    print("  你會把干擾的效果算到雜波頭上 —— 兩者的對策完全不同。")
    return 0


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
radar_bistatic/bistatic_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# 雙基地雷達章 — 驗證段
#
# 這一章的驗證段特別重要,因為雷達模組有三種不同層次的靜默失敗:
#
#   第 1 層  指令被拒絕            —— 會有 NACK,最好抓
#   第 2 層  指令 ACK 但沒生效     —— 回讀就抓得到
#   第 3 層  指令 ACK、回讀也正確、輸出還是空的
#            —— 只有一路檢查到「資料提供者有沒有真的吐出數字」才抓得到
#
# 第 3 層是本章的新發現:把雜波幾何設成 Smooth Oblate Earth,
# Connect ACK、Radar_RM 回讀顯示得一清二楚,但雙基地接收端的
# S/T Clutter Power 全部是 -9999.99(STK 的「沒有計算」哨兵值)。
# 只有 Single Point 這個模型算得出來。
#
# 執行:
#   1. 開啟 STK 12 桌面版
#   2. python bistatic_verify.py
#   離開碼 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 agi.stk12.stkdesktop import STKDesktop                       # noqa: E402
from stk_verify import Verifier                                   # noqa: E402

import bistatic_build as B                                        # noqa: E402
from bistatic_tracking import (ST_ELEMENTS, clean, configure,      # noqa: E402
                               continuity, fetch)

SCRIPT = os.path.join(HERE, "bistatic_radar_Connect.txt")
TXR, RXR, JMR, TGT = B.TXR, B.RXR, B.JMR, B.TGT

# 實測基準(20 W 干擾機、雜波係數 0.05、100 kW 發射、4 m 天線)
BASE = dict(clean_rate=1.00, jam_rate=0.832, jam_gap=55.0,
            clean_rms=189.0, jam_rms=918.0)


def rm(root, path, prop):
    """Radar_RM GetValue 的單行取值。"""
    try:
        r = root.ExecuteCommand("Radar_RM %s GetValue %s" % (path, prop))
        for i in range(r.Count):
            for line in str(r.Item(i)).split("\n"):
                if "=" in line and line.split("=", 1)[0].strip() == prop:
                    return line.split("=", 1)[1].replace("(Read-only)", "").strip()
    except Exception:
        return None
    return None


def main():
    app = STKDesktop.AttachToApplication()   # 保留 app,否則 COM 介面會失效
    root = app.Root

    print("--- 建場景 ---")
    B.build()
    v = Verifier(root)

    # ---- (1) 自動:把 Connect 腳本設過的每一個值讀回來比對 ----------------
    v.auto_setvalues(SCRIPT)

    # ---- (2) 結構:物件與雙基地拓撲 --------------------------------------
    for p in ["*/Place/TxSite", "*/Place/RxSite", TGT, "*/Aircraft/JamAir",
              TXR, RXR, JMR]:
        v.object_exists(p)
    v.position("*/Place/TxSite", 24.90, 120.85)
    v.position("*/Place/RxSite", 24.30, 120.45)

    v.expect_true("TxRadar 是 Bistatic_Transmitter",
                  rm(root, TXR, "Model") == "Bistatic_Transmitter",
                  str(rm(root, TXR, "Model")))
    v.expect_true("RxRadar 是 Bistatic_Receiver",
                  rm(root, RXR, "Model") == "Bistatic_Receiver",
                  str(rm(root, RXR, "Model")))

    rx = root.GetObjectFromPath(RXR)
    # Connect 寫不進這兩個清單,只能用物件模型加。這裡確認真的加進去了。
    v.expect_true("雙基地連結:接收端掛著 1 具發射機",
                  rx.Model.BistaticTransmitters.Count == 1,
                  "Count = %d" % rx.Model.BistaticTransmitters.Count)
    v.expect_true("干擾清單:接收端掛著 1 具干擾機",
                  rx.Model.Jamming.Jammers.Count == 1,
                  "Count = %d" % rx.Model.Jamming.Jammers.Count)
    v.expect_true("脈衝積分已改成 Fixed Pulse Number",
                  int(rx.Model.Mode.Waveform.PulseIntegrationType) ==
                  B.PULSE_INTEGRATION_FIXED_PULSE_NUMBER,
                  "type = %s" % rx.Model.Mode.Waveform.PulseIntegrationType)
    v.expect("積分脈衝數", rx.Model.Mode.Waveform.PulseIntegration.PulseNumber,
             B.PULSES)

    # ---- (3) 天線設計頻率沒有被沖掉 --------------------------------------
    # 先設天線型別、後設設計頻率。順序反了會被沖回 14.5 GHz 預設值,
    # 而且完全沒有錯誤訊息 —— 本教材在 Week 2 與 Week 11 都踩過同一個坑。
    for name, p in [("TxRadar", TXR), ("RxRadar", RXR), ("Jammer", JMR)]:
        f = rm(root, p, "Model.AntennaControl.Antenna.DesignFrequency")
        hz = float(f.split()[0]) if f else float("nan")
        v.expect("%s 天線設計頻率" % name, hz / 1e9, 3.0, tol=1e-3, unit="GHz")

    # ---- (4) 干擾與雜波真的「算得出數字」,不是只有設定值正確 -------------
    # 這是第 3 層失敗的檢查點。STK 用 -9999.99 表示「這個量沒有計算」,
    # 只看設定值永遠看不出來。
    configure(root, True, True)
    st = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
    js, cn = clean(st["S/T Integrated J/S"]), clean(st["S/T C/N"])
    v.expect_true("干擾真的進到接收機(J/S 有數值)", np.isfinite(js).any(),
                  "有效樣本 %d/%d" % (np.isfinite(js).sum(), js.size))
    v.expect_true("雜波真的進到接收機(C/N 有數值)", np.isfinite(cn).any(),
                  "有效樣本 %d/%d;若全為 -9999 表示雜波幾何模型不被"
                  "雙基地接收端支援" % (np.isfinite(cn).sum(), cn.size))

    # 反向確認:換成 Smooth Oblate Earth 應該就算不出雜波了。
    # 把「已知會壞的設定」也寫成檢查項,下次 STK 改版修好了才會被發現。
    root.ExecuteCommand(
        "Radar %s SetValue Model.Clutter.PointProviderList[0]"
        ".PointProvider Smooth_Oblate_Earth" % RXR)
    root.ExecuteCommand("ClearAllAccess /")
    st2 = fetch(root, "Radar SearchTrack", ST_ELEMENTS)
    cn2 = clean(st2["S/T C/N"])
    v.custom("Smooth Oblate Earth 在雙基地下算不出雜波(已知限制)",
             not np.isfinite(cn2).any(),
             "有效樣本 %d/%d —— 若這一項變成 FAIL,代表 STK 已修正此限制"
             % (np.isfinite(cn2).sum(), cn2.size))
    root.ExecuteCommand(
        "Radar %s SetValue Model.Clutter.PointProviderList[0]"
        ".PointProvider Single_Point" % RXR)
    root.ExecuteCommand("ClearAllAccess /")

    # ---- (5) 實驗單寫死的基準數字,變成可執行的斷言 -----------------------
    configure(root, False, False)
    p_clean = clean(fetch(root, "Radar SearchTrack", ST_ELEMENTS)
                    ["S/T Integrated PDet"])
    r_clean, g_clean, _ = continuity(p_clean)
    v.expect("乾淨情境偵測率", r_clean, BASE["clean_rate"], tol=0.02)
    v.expect_true("乾淨情境航跡不中斷", g_clean == 0.0, "最長中斷 %.0f s" % g_clean)

    configure(root, True, False)
    p_jam = clean(fetch(root, "Radar SearchTrack", ST_ELEMENTS)
                  ["S/T Integrated PDet w/Jamming"])
    r_jam, g_jam, n_jam = continuity(p_jam)
    v.expect("20 W 干擾下偵測率", r_jam, BASE["jam_rate"], tol=0.05)
    v.expect("20 W 干擾下最長中斷", g_jam, BASE["jam_gap"], tol=15.0, unit="s")
    v.expect_true("20 W 干擾足以打斷航跡(最長中斷 > 30 s 外推上限)",
                  g_jam > 30.0, "%.0f s / %d 次中斷" % (g_jam, n_jam))

    configure(root, True, True)          # 還原

    # ---- (6) 幾何:雙基地距離和必定大於基線 --------------------------------
    geo = fetch(root, "Radar Geometry",
                ["Time", "Range Sum", "Bistatic Angle", "Rcvr Range", "Xmtr Range"])
    rs = clean(geo["Range Sum"])
    rr, rt = clean(geo["Rcvr Range"]), clean(geo["Xmtr Range"])
    v.expect_true("距離和 = Rt + Rr(STK 的定義自洽)",
                  np.nanmax(np.abs(rs - (rr + rt))) < 1e-3,
                  "最大差 %.2e km" % np.nanmax(np.abs(rs - (rr + rt))))
    baseline = 78.0                      # 兩站間距約 78 km
    v.expect_true("距離和永遠大於基線長度(橢圓的基本性質)",
                  np.nanmin(rs) > baseline,
                  "最小距離和 %.1f km vs 基線 %.0f km" % (np.nanmin(rs), baseline))

    ok = v.report("雙基地雷達 驗證段")
    print("\n提醒:本章的第 4 組檢查是這一章的重點 —— 設定值正確不代表算得出數字。")
    return 0 if ok else 1


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