Week 13 — 軟體與自動化

資料夾:week13_automation/

基礎知識導論

Week 13 基礎知識導論 — 自動化:Connect 字串 vs 物件模型

手動點選做出來的分析,沒有人能重現,包括三個月後的你自己。
這一週把整條流程變成程式碼:可版本控制、可重跑、可驗證。

1. 這一週在解什麼問題

前十二週累積了大量 STK 操作。現在要問:

2. 核心觀念

2.1 STK 的兩套 API

Connect 物件模型 (Object Model)
形式 送字串指令 呼叫物件的方法與屬性
回傳 ACK / NACK,或文字結果 型別化的物件
探索性 差(要查指令手冊) 好(IDE 有自動完成)
錯誤訊息 通常只有「失敗」 有例外型別與訊息
適合 一次性的批次設定、腳本移植 需要讀值、迴圈、條件判斷的流程

實務上是混用:用 Connect 快速建構,用物件模型讀值與判斷。
本課的驗證段就是這個模式 —— Connect 設定,物件模型或 *_RM ... GetValue 回讀。

2.2 單位陷阱

這是本課反覆出現的坑:

介面 距離預設
Connect 指令 公尺
物件模型多數屬性 公里

而且 Connect 的 SetUnits 只管距離,而且已經是過時指令。
要改報表單位得用:

Units_Set * Report Power W

防禦作法:在腳本開頭明確設定單位,並在驗證段用已知答案檢查一次
(例如設 550 km 高度,讀回來確認是 550 而不是 550000)。

2.3 Desktop vs Engine

STK Desktop STK Engine(無介面)
圖形 有 2D/3D 視窗 無
適合 教學、除錯、視覺驗證 批次運算、伺服器、CI
連線 STKDesktop.AttachToApplication() STKEngine.StartApplication()

本教材實測:有 18 條指令在 Engine 下會被拒絕(STK 12 與 13 表現一致):
Animate、Graphics ... SetColor、VO ... SolarPanel、OrbitWizard ... Color、
ReportCreate ... Type Display 等。全部都是圖形相關的指令。

所以這 18 個 NACK 是 engine-vs-desktop 的差異,不是版本差異。
本課需要 STK 的 notebook 都必須用桌面版。

2.4 兩個真實的 COM 陷阱

陷阱一:app 被回收

root = STKDesktop.AttachToApplication().Root      # 錯

這樣寫,app 物件立刻被垃圾回收,Root 的 COM 介面變成空指標。
之後每一條指令都會拋:

'NoneType' object has no attribute '_getVtblEntry'

正確寫法是保留 app 的參考:

app  = STKDesktop.AttachToApplication()
root = app.Root

陷阱二:工作目錄被改掉

啟動 STK Engine 會改變 process 的工作目錄。
之後任何 os.getcwd() 算出來的相對路徑都會錯。
本教材的 notebook 因此在最前面加了一個 bootstrap cell,
把週次資料夾釘在 WEEK_DIR,不再事後依賴 getcwd()。

2.5 資料提供者 (Data Provider)

報表與圖表背後的統一取數介面。相較於「產生報表檔再 parse」,
Data Provider 直接把數值陣列給你:

物件 → DataProviders["某個提供者"] → Exec(起, 迄, 步長) → DataSets → GetValues()

好處:不經過檔案系統、沒有格式解析問題、不會出現「空檔案也算成功」的靜默失敗。

什麼時候還是要用報表檔:需要跟別人交換、需要留存證據、
或該資料只有報表樣式提供(如 "Link Budget - Detailed")。

2.6 自動化的三個層次

層次 1  可重跑     腳本化,同樣輸入得到同樣輸出
層次 2  可驗證     程式自己檢查結果對不對(驗證段)
層次 3  可組合     一週的輸出直接餵給下一週,不經人手

本課的 16 週正是層次 3 的示範:
Week 4 的 access 報告 → Week 12 的資料預算;
Week 4 的日照時間線 → Week 6 的熱模型;
Week 8 的姿態 .a 檔 → STK 3D。

每經過一次人手複製貼上,就多一個無法追溯的錯誤來源。

2.7 版本控制與可重現性

STK 場景檔(.sc、.sa 等)是二進位或半結構化的,diff 不好看。
正確作法是版控「產生場景的腳本」,而不是場景本身。

這正是本 repo 的組織方式:每一週都是
.txt(Connect 腳本)+ .py(Python Lab)+ _verify.py(驗證段),
全部是純文字,可 diff、可 review、可回溯。

3. 名詞與符號

名詞 意義
Connect STK 的字串指令介面
Object Model STK 的型別化 API
agi.stk12 / ansys.stk.core Python 綁定套件(STK 12 / 13)
Data Provider 統一的取數介面
ACK / NACK 指令被接受 / 被拒絕
*_RM ... GetValue Connect 的回讀指令族
Desktop / Engine 有介面 / 無介面
WEEK_DIR 本教材 notebook 釘住路徑用的變數

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
物件模型建場景 fireye_volcano_python.py
Connect 與物件模型混用 同上
單位陷阱 同上的註解與斷言
驗證段 week13_verify.py(6 項)

week13_verify.py 不收進 notebook。
它會把實驗腳本再跑一次;notebook 的 kernel 裡已經有一個 STK 連線,
同一個 process 再連一次(Engine + Desktop 混用)會讓 COM 斷線。
請在終端機單獨執行:python week13_automation/week13_verify.py。
這件事本身就是一個教學點:自動化要考慮執行環境的狀態,不只是程式邏輯。

5. 常見誤解

  1. 「寫成一行比較漂亮。」 — STKDesktop.AttachToApplication().Root 就是這樣壞掉的。
    COM 的生命週期不是 Python 的表達式作用域。
  2. 「物件模型比 Connect 好,應該全部改寫。」 — 兩者各有適用場合。
    大量一次性設定用 Connect 更短,需要讀值判斷用物件模型更安全。
  3. 「Engine 跟 Desktop 只差在有沒有視窗。」 — 有 18 條指令行為不同。
    在 Engine 上開發、在 Desktop 上交付,會遇到驚喜。
  4. 「腳本跑完沒報錯就是對的。」 — 全課主軸。ACK 不等於生效。
  5. 「把 .sc 檔存進 git 就是版本控制了。」 — 二進位檔無法 review。
    要版控產生它的腳本。

6. 延伸閱讀

STK 程式介面

可重現性與軟體工程

替代與交叉驗證工具

7. 課前自我檢核

  1. STKDesktop.AttachToApplication().Root 為什麼會壞?錯誤訊息長什麼樣?
  2. 同一個高度值,Connect 與物件模型分別要填什麼數字?怎麼自動檢查你沒搞錯?
  3. 哪些指令在 STK Engine 下會失敗?它們有什麼共同點?
  4. Data Provider 相較於「產生報表檔再解析」,避免了哪一類的靜默失敗?
  5. 你要把整個 FIREYE 分析交給另一個人重現。你會交出哪些檔案?為什麼不交場景檔?

程式碼

week13_automation/fireye_volcano_python.py
# -*- coding: utf-8 -*-
# ============================================================
# fireye_volcano_python.py
# 學習 Python 控制 STK —— 用你已熟的 FIREYE 火山任務,
# 從 Connect 指令(字串)過渡到 STK Python 物件模型(agi.stk12)。
#
# 對照你先前的 Connect 腳本 FIREYE_Volcano_FullScenario_Connect.txt:
#   同一個場景(FIREYE 衛星 + 20 火山 + access + coverage),
#   這裡改用物件模型 API 寫,並在每段標注「學了什麼」。
#
# 三種連線方式(三個上傳範例各示範一種),下面用旗標切換:
#   1. STKEngine  (北科大/TASA/Aviator 範例) - 無介面、跨平台、批次
#   2. STKDesktop.StartApplication      - 開新的 STK 桌面 GUI
#   3. STKDesktop.AttachToApplication   - 接已開好的 STK(中興高中 notebook 用這個)
#
# 需求: STK 12.1+,  pip install "C:\Program Files\AGI\STK 12\bin\
#        AgPythonAPI\agi.stk12-<ver>-py3-none-any.whl"
# ------------------------------------------------------------
# 這個檔在 week04_orbit/ 與 week13_automation/ **各有一份,必須逐位元相同**。
# 改一邊要記得改另一邊 —— tools/check_duplicates.py 會盯著,分岔就會叫。
#
# 為什麼不收進 tools/(storage_vs_time.py 就是那樣合併的)?
# 因為這一支沒有 __main__ 保護,**import 就會接上 STK 並建出整個場景**,
# week13_verify.py 正是靠這個(load_lab() 之後直接用 m.root)。
# 它是週次的實驗腳本,不是 helper。Week 13 是擁有者;Week 4 的實驗單
# 把它列為該週現成檔案,所以兩邊都留。
# ============================================================

import os
import time

# --- 物件模型的列舉型別都在 stkobjects;工具型別在 stkutil ---
from agi.stk12.stkobjects import (
    AgESTKObjectType,          # eSatellite / eFacility / ePlace / eConstellation ...
    AgEVePropagatorType,       # ePropagatorTwoBody / eJ2Perturbation / eHPOP ...
    AgEClassicalSizeShape,     # eSizeShapeSemimajorAxis ...
    AgEClassicalLocation,      # eLocationTrueAnomaly ...
    AgEOrientationAscNode,     # eAscNodeRAAN ...
    AgECvBounds,               # coverage grid bounds
    AgECvResolution,
    AgEFmDefinitionType,       # eFmAccessDuration ...
    AgEFmCompute,              # eAverage / eMaximum / eMinimum
)
from agi.stk12.stkutil import AgEOrbitStateType

# ------------------------------------------------------------
# 連線方式(改這裡切換 1/2/3)
# ------------------------------------------------------------
MODE = os.environ.get("STK_MODE", "engine")   # "engine" | "desktop" | "attach"
# 用環境變數覆寫,方便驗證段與 CI 指定連線方式:
#   set STK_MODE=attach   -> 接已開好的 STK 桌面版(不佔 Engine 授權)

t0 = time.time()

def connect_stk(mode):
    """回傳 (stk, root)。這是三個上傳範例最核心的差異所在。"""
    if mode == "engine":
        # 北科大 / TASA / Aviator 範例:無介面、可在 Linux/伺服器跑
        from agi.stk12.stkengine import STKEngine
        stk = STKEngine.StartApplication(noGraphics=True)
        root = stk.NewObjectRoot()          # Engine 用 NewObjectRoot()
    elif mode == "desktop":
        # 開一個新的 STK 桌面視窗
        from agi.stk12.stkdesktop import STKDesktop
        stk = STKDesktop.StartApplication(visible=True, userControl=True)
        root = stk.Root                     # Desktop 用 .Root
    elif mode == "attach":
        # 中興高中 notebook 用法:接「已經開好」的 STK(用環境變數 STK_PID)
        from agi.stk12.stkdesktop import STKDesktop
        # STK 啟動時會設 STK_PID;沒有就接目前唯一的那個實例
        pid = os.getenv("STK_PID")
        stk = (STKDesktop.AttachToApplication(pid=int(pid)) if pid
               else STKDesktop.AttachToApplication())
        root = stk.Root
    else:
        raise ValueError(mode)
    return stk, root

stk, root = connect_stk(MODE)

# 設定顯示單位(UnitPreferences 是全域偏好,影響 DataProvider 回傳的單位)
root.UnitPreferences.SetCurrentUnit("DateFormat", "UTCG")

# ============================================================
# Section 1 — 場景 / Scenario
# 學了什麼:Children.New(型別, 名稱) 是「建立任何 STK 物件」的通用工法。
# ============================================================
root.NewScenario("FIREYE_Volcano_Py")
scenario = root.CurrentScenario
scenario.SetTimePeriod("1 Jan 2024 00:00:00.000", "8 Jan 2024 00:00:00.000")
if MODE != "engine":
    root.Rewind()   # GUI 才需要;Engine noGraphics 沒有動畫可復位

# ============================================================
# Section 2 — FIREYE 衛星(物件模型設定軌道)
# 學了什麼:設軌道有兩條路
#   (A) 物件模型:ConvertTo(eOrbitStateClassical) 再逐欄設 -> 型別安全、可讀
#   (B) 混合法:root.ExecuteCommand('SetState ...') 送 Connect 字串 -> 快
# 這裡示範 (A),對應 Lab1 最佳圓軌道 a=6928, e=0, i=56。
# ============================================================
sat = scenario.Children.New(AgESTKObjectType.eSatellite, "FIREYE")
sat.SetPropagatorType(AgEVePropagatorType.ePropagatorJ2Perturbation)  # Lab1 用 J2

prop = sat.Propagator
orbit = prop.InitialState.Representation
kep = orbit.ConvertTo(AgEOrbitStateType.eOrbitStateClassical)  # 轉成古典六根數介面

kep.SizeShapeType = AgEClassicalSizeShape.eSizeShapeSemimajorAxis
kep.SizeShape.SemiMajorAxis = 6928.0   # km  (物件模型預設 km,和 Connect 的公尺不同!)
kep.SizeShape.Eccentricity  = 0.0
kep.Orientation.Inclination  = 56.0    # deg
kep.Orientation.ArgOfPerigee = 0.0
kep.Orientation.AscNodeType  = AgEOrientationAscNode.eAscNodeRAAN
kep.Orientation.AscNode.Value = 0.0
kep.LocationType = AgEClassicalLocation.eLocationTrueAnomaly
kep.Location.Value = 0.0

orbit.Assign(kep)     # 把設好的六根數指回衛星
prop.Propagate()      # 產生星曆(忘了這步就沒有軌道)

# nadir 姿態 + imager 感測器:這類幾何設定用 Connect 字串最省事(混合法)
# 正確語法是 Profile + NadirECFVel,且 Offset 參數必填
# ("Basic Nadir ECFVelocity" 會被 STK 拒絕)
root.ExecuteCommand("SetAttitude */Satellite/FIREYE Profile NadirECFVel Offset 0.0")
root.ExecuteCommand("New / */Satellite/FIREYE/Sensor Imager")
root.ExecuteCommand("Define */Satellite/FIREYE/Sensor/Imager SimpleCone 40")

# ============================================================
# Section 3 — 20 個火山觀測點(用迴圈建物件,Python 的強項)
# 學了什麼:資料驅動建模 —— 把座標放進 list,用迴圈建,遠比 Connect
# 逐行 New/SetPosition 精簡。AssignGeodetic(lat, lon, alt_km)。
# ============================================================
VOLCANOES = [
    ("Mt_St_Helens", 46.20, -122.20, 2.55), ("Shishaldin", 54.76, -163.97, 2.86),
    ("Mt_Cleveland", 52.82, -169.94, 1.73), ("Fourpeaked", 58.77, -153.67, 2.10),
    ("Kasatochi",    52.18, -175.50, 0.31), ("South_Sister", 44.10, -121.77, 3.16),
    ("Lassen",       40.49, -121.51, 3.19), ("Mt_Hood",     45.37, -121.69, 3.43),
    ("Mt_Baker",     48.78, -121.81, 3.29), ("Mt_Rainier",  46.85, -121.76, 4.39),
    ("Mt_Adams",     46.21, -121.49, 3.74), ("Glacier_Peak",48.11, -121.11, 3.21),
    ("Mt_Shasta",    41.41, -122.19, 4.32), ("Newberry",    43.72, -121.23, 2.43),
    ("Crater_Lake",  42.93, -122.12, 2.49), ("Redoubt",     60.49, -152.74, 3.11),
    ("Augustine",    59.36, -153.43, 1.25), ("Katmai",      58.28, -154.96, 2.05),
    ("Okmok",        53.43, -168.13, 1.07), ("Mauna_Loa",   19.47, -155.60, 4.17),
]

constellation = scenario.Children.New(AgESTKObjectType.eConstellation, "Volcanoes")

root.BeginUpdate()   # 批次包起來:大量建物件時關閉逐一重繪,速度快非常多
for name, lat, lon, alt in VOLCANOES:
    place = scenario.Children.New(AgESTKObjectType.ePlace, f"Volcano_{name}")
    place.Position.AssignGeodetic(lat, lon, alt)   # 度, 度, km
    constellation.Objects.AddObject(place)         # 加進星群
root.EndUpdate()

# ============================================================
# Section 4 — Access + DataProvider 取數
# 學了什麼:DataProvider 是「從 STK 取結構化結果」的正規途徑,
# 比解析 Report 文字可靠。IntervalFromPath 用於有起訖的區間資料。
# ============================================================
root.UnitPreferences.SetCurrentUnit("Time", "Sec")   # 讓 Duration 以秒回傳

imager = sat.Children.Item("Imager")          # 取回剛用 Connect 建的感測器
chain = scenario.Children.New(AgESTKObjectType.eChain, "Imaging")
chain.Objects.AddObject(imager)
chain.Objects.AddObject(constellation)
chain.ComputeAccess()

dp = chain.DataProviders.GetDataPrvIntervalFromPath("Complete Access")
res = dp.Exec(scenario.StartTime, scenario.StopTime)
durs = res.DataSets.GetDataSetByName("Duration").GetValues()
total_hr = sum(durs) / 3600.0
print(f"總成像 access(20 火山): {total_hr:.2f} hr,  {len(durs)} 個通聯")

# ============================================================
# Section 5 — Coverage + Figure of Merit(區域覆蓋,勝過逐點 access)
# 學了什麼:CoverageDefinition 對「一塊區域」算覆蓋,FigureOfMerit
# 把結果彙整成 Min/Max/Average。這是把「20 點 access」升級成
# 「美西區域覆蓋率/重訪」的正規做法(對應你 xlsx 筆記的第 8 節)。
# ============================================================
cov = scenario.Children.New(AgESTKObjectType.eCoverageDefinition, "USWest_Cov")
grid = cov.Grid
grid.BoundsType = AgECvBounds.eBoundsLatLonRegion      # 用經緯度框一塊區域
b = grid.Bounds
b.MinLatitude, b.MaxLatitude = 18.0, 62.0              # 夏威夷到阿拉斯加
b.MinLongitude, b.MaxLongitude = -180.0, -120.0
grid.ResolutionType = AgECvResolution.eResolutionDistance
grid.Resolution.Distance = 150                          # km 格點
cov.AssetList.Add("Constellation/Volcanoes")           # 這裡也可改指派 Satellite/FIREYE
# 用衛星當 asset 更合理:對火山覆蓋要看衛星,不是火山自己
cov.AssetList.RemoveAll()
cov.AssetList.Add("Satellite/FIREYE")
cov.ComputeAccesses()

fom = cov.Children.New(AgESTKObjectType.eFigureOfMerit, "AccessDuration")
fom.SetDefinitionType(AgEFmDefinitionType.eFmAccessDuration)
fom.Definition.SetComputeType(AgEFmCompute.eAverage)

fdp = fom.DataProviders.GetDataPrvFixedFromPath("Overall Value")
fres = fdp.Exec()
vmin = fres.DataSets.GetDataSetByName("Minimum").GetValues()[0]
vmax = fres.DataSets.GetDataSetByName("Maximum").GetValues()[0]
vavg = fres.DataSets.GetDataSetByName("Average").GetValues()[0]
print(f"區域覆蓋 access duration  min/avg/max = {vmin:.1f} / {vavg:.1f} / {vmax:.1f} sec")

print(f"\n完成,耗時 {time.time()-t0:.1f} sec")

# ============================================================
# 收尾:Engine 要 ShutDown;Desktop 若是你開的可留著看
# ============================================================
if MODE == "engine":
    root.CloseScenario()
    stk.ShutDown()
    print("STK Engine 已關閉。")

# ============================================================
# 對照速查:Connect 字串  <->  Python 物件模型
# ------------------------------------------------------------
#  New / */Satellite FIREYE
#     -> scenario.Children.New(AgESTKObjectType.eSatellite, "FIREYE")
#  SetPosition ... Geodetic lat lon alt
#     -> place.Position.AssignGeodetic(lat, lon, alt)
#  Chains ... Compute  +  Report_RM Style "Complete Chain Access"
#     -> chain.ComputeAccess() + DataProviders.GetDataPrvIntervalFromPath(...)
#  單位:Connect 距離預設「公尺」需 SetUnits/km;
#        物件模型多數屬性預設「公里」。單位系統不同,別混淆!
#  何時用哪個:結構/計算/取數 -> 物件模型;
#              一次性幾何設定或物件模型沒對應的 -> root.ExecuteCommand(Connect)
# ============================================================
week13_automation/week13_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 13 驗證段 — 物件模型自動化建出來的場景是否完整
#
# 本週教「Connect 字串 vs 物件模型」的分工。物件模型的錯誤會直接拋例外,
# 比 Connect 好抓;但腳本裡混用的那幾行 Connect 字串仍然是靜默的 ——
# 本教材原本就有一行 SetAttitude 語法錯誤藏在這裡,ACK 卻沒生效。
#
# 執行:python week13_verify.py
#   離開碼 0 = 全部通過,1 = 有項目對不上
# ================================================================

import os
import sys

# notebook 的 kernel 沒有 __file__。而且啟動 STK Engine 會改變 process 的
# 工作目錄,所以不能事後才靠 os.getcwd() —— notebook 開頭的 bootstrap cell
# 會先把週次目錄釘在 WEEK_DIR。
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
sys.path.insert(0, os.path.join(REPO, "tools"))

from stk_verify import Verifier, load_lab      # noqa: E402


def main():
    # 用 attach 接已開好的 STK 桌面版:不佔 Engine 授權,也不會和別的驗證段搶
    os.environ.setdefault("STK_MODE", "attach")

    # 驗證段會把實驗腳本再跑一次。若 notebook 前面的 cell 已經建過同名場景,
    # 這裡不先關掉就會撞名(STK 不允許同名場景並存)。
    from agi.stk12.stkdesktop import STKDesktop
    _app = STKDesktop.AttachToApplication()
    if _app.Root.CurrentScenario is not None:
        _app.Root.CloseScenario()

    m = load_lab(os.path.join(HERE, "fireye_volcano_python.py"))
    v = Verifier(m.root)

    sc = m.root.CurrentScenario
    v.expect_true("場景已建立", sc is not None,
                  sc.InstanceName if sc is not None else "無")
    kinds = {}
    for i in range(sc.Children.Count):
        k = sc.Children.Item(i).ClassName
        kinds[k] = kinds.get(k, 0) + 1
    v.expect("衛星數", kinds.get("Satellite", 0), 1, tol=0, unit=" 顆")
    v.expect("火山觀測點數", kinds.get("Place", 0), 20, tol=0, unit=" 個")

    v.object_exists("*/Satellite/FIREYE/Sensor/Imager")
    v.cone_angle("*/Satellite/FIREYE/Sensor/Imager", 40.0)

    # 這一行原本是 "SetAttitude ... Basic Nadir ECFVelocity",語法錯、ACK、沒生效。
    # 混用 Connect 字串時,物件模型的例外保護就失效了 —— 只能靠回讀。
    sat = m.root.GetObjectFromPath("*/Satellite/FIREYE")
    v.expect_true("姿態為 NadirECFVel(混用的 Connect 字串有生效)",
                  int(sat.Attitude.Basic.ProfileType) == 8,
                  f"ProfileType = {int(sat.Attitude.Basic.ProfileType)}")

    ok = v.report("Week 13 驗證段")
    return 0 if ok else 1


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