Week 4 — 任務規劃/軌道選擇

資料夾:week04_orbit/

基礎知識導論

Week 4 基礎知識導論 — 軌道力學與軌道選擇

軌道是任務設計裡最早凍結、最難更改的決定。它一旦定了,
覆蓋、功率、熱、通訊、壽命全部跟著定下來。這一週是全課的分水嶺。

1. 這一週在解什麼問題

給定「監測環太平洋火山、GSD 30 m、重訪 < 24 hr」,要回答:
高度多少?傾角多少?升交點時角(LTAN)多少?

方法不是查表,是掃描:讓 STK 在 (a, i) 平面上跑一遍,用 access 統計當目標函數,
看出最佳區域,再理解「為什麼是那裡」。

2. 核心觀念

2.1 六個軌道根數

根數 符號 決定什麼
半長軸 a 軌道大小 → 週期、高度、速度
離心率 e 形狀(0 = 圓)
傾角 i 軌道面對赤道的傾斜 → 能飛到多高緯度
升交點赤經 RAAN (Ω) 軌道面在慣性空間的方位 → 決定地方時
近地點幅角 ω 近地點在軌道面內的位置
真近點角 ν 衛星此刻在軌道上的位置

週期只由 a 決定:

T = 2π * sqrt(a³ / μ)        μ_地球 = 398600.4418 km³/s²

本課 FIREYE 選定的任務軌道:a = 6928 km(高度約 550 km)、i = 56°。
560 km 圓軌道的週期約 95.6 分鐘,一天約 15 圈。

2.2 傾角決定緯度覆蓋 —— 沒有例外

一顆傾角 i 的衛星,星下點緯度永遠不會超過 ±i。所以:

FIREYE 選 56° 是一個折衷:涵蓋環太平洋主要火山帶,又不必付極軌的發射代價。

2.3 J2 攝動:地球不是球

地球的赤道隆起(J2 = 1.0826e-3)造成兩個一階效應:

昇交點漂移   dΩ/dt = -(3/2) * n * J2 * (Re/p)² * cos(i)
近地點漂移   dω/dt =  (3/4) * n * J2 * (Re/p)² * (5cos²(i) - 1)

其中 n 是平均角速度、p = a(1-e²)、Re = 6378.137 km。

兩個重要推論:

  1. 太陽同步軌道 (SSO):當 dΩ/dt = +0.9856 °/day(地球繞日的速率)時,
    軌道面相對太陽的方位固定 → 每次過同一緯度都是同一個地方時。
    因為 cos(i) 必須為負,SSO 一定是逆行軌道(i > 90°,約 97–98°)。
    對地球觀測任務,這代表光照條件一致、太陽能板受照穩定。
  2. 5cos²(i) = 1 時 dω/dt = 0 → i = 63.4°(臨界傾角),
    近地點不漂移。Molniya 軌道就是靠這個。

2.4 地面軌跡與重複軌道

一圈之內地球轉了 T x 15.04 °/hr,所以下一圈的地面軌跡會西移。
若 軌道圈數 / 天數 是有理數,地面軌跡會重複 —— 這叫回歸軌道 (repeat ground track)。

回歸軌道的意義:過境時間可預測、影像可比對(同一視角)。
代價是可選的高度變成離散的幾個值,不再連續可調。

2.5 Access 的幾何

地面站或目標能看到衛星,需要仰角 el 大於遮罩角(本課設 5°)。
圓軌道下,最大地心半角:

sin(ρ) = Re / (Re + h)                     地球視半角
cos(λ_max) = (Re / (Re+h)) * cos(el_min) ... 幾何解出地心角 λ_max

實務上直接讓 STK 算。要記住的是趨勢:

這就是 Week 2 那個「coverage 換 volume」的量化版本。

2.6 傳播器的精度階梯

傳播器 忽略了什麼 7 天後的量級誤差
TwoBody J2、阻力、三體、SRP 數百 km
J2Perturbation 阻力、三體、SRP 數十 km
HPOP 依設定,可含 21x21 重力場 + 大氣 + 三體 + SRP km 以下

本教材實測:同一組初始條件,HPOP 的設定若沒有真正生效,7 天後位置差約 107 km。

HPOP 的一個致命陷阱(本課實測):HPOP ... Force / Drag / Integrator 這類指令是
延後提交的 —— 要等下一次傳播才寫進去。STK 12 的 ExportDataFile 不會觸發傳播,
所以必須自己補一行 Propagate;STK 13 會自動觸發。忘了補,你拿到的是舊力學模型的軌道,
而且沒有任何錯誤訊息。

2.7 掃描 (parameter sweep) 的方法論

  1. 先固定其他變數,一次只掃 1–2 個維度。
  2. 掃描範圍要含已知不合理的區域,才看得出趨勢與邊界。
  3. 記錄的不只是最佳值,還有平原有多平 —— 平原愈平,設計愈耐受製造誤差。
  4. 掃描起點與任務軌道是兩回事。本教材的 Week 4 起點是 250 km / i = 0,
    那是掃描的一端,不是選定的軌道。曾經有一週(Week 11)誤用了這個起點,
    結果鏈路完全沒有 access。

3. 名詞與符號

符號 意義 值 / 單位
μ 地球重力參數 398600.4418 km³/s²
Re 地球赤道半徑 6378.137 km
J2 地球扁率二階項 1.0826e-3
a, e, i, Ω, ω, ν 六個經典根數 —
n 平均角速度 rad/s
h 軌道高度 km
el_min 仰角遮罩 本課 5°
SSO 太陽同步軌道 i ≈ 97–98°
LTAN 昇交點地方時 hh:mm
GSD 地面解析度 m

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
(a, i) 掃描 Lab1OrbitDesign_Connect.m、OrbitDesignLab_Connect.txt
完整任務場景 FIREYE_Volcano_FullScenario_Connect.txt
HPOP 與延後提交 STKSat_HPOP_Connect.txt,結尾必須有 Propagate
物件模型自動化 fireye_volcano_python.py(Week 13 會再深入)
ACK ≠ 生效 week04_verify.py,回讀傳播器型別與力學模型設定

5. 常見誤解

  1. 「高度愈高覆蓋愈好,所以選高一點。」 — 覆蓋變好,但 GSD 變差、
    鏈路損耗增加、輻射劑量上升、去軌時間變長(可能違反去軌規範)。
  2. 「i = 56° 就是只能看到 56° 以下的目標。」 — 正確說法是星下點不超過 ±56°,
    但斜視(感測器有錐角)可以看到更高緯度,只是視角變差。
  3. 「SSO 只是為了光照。」 — SSO 同時穩定了熱環境與發電幾何,對 CubeSat 尤其重要。
  4. 「TLE 可以當初始根數。」 — TLE 是 SGP4 專用的平均根數,直接餵給 HPOP 會有系統性偏差。
  5. 「指令下了 HPOP 就是用 HPOP。」 — 見 2.6。這是本教材抓到的真實 bug。

6. 延伸閱讀

軌道力學

傳播器與精度

其他工具(交叉驗證用)

7. 課前自我檢核

  1. 週期 95 分鐘的圓軌道,高度大約多少?自己用 T = 2π√(a³/μ) 算一次。
  2. 為什麼太陽同步軌道一定是逆行軌道?
  3. 把仰角遮罩從 5° 改成 10°,你預期 access 總時長變化多少?先猜,再用 STK 驗證。
  4. 掃描結果的最佳點若落在掃描範圍邊界上,代表什麼?該怎麼辦?
  5. 你怎麼證明HPOP 的力學模型設定真的生效了,而不只是收到 ACK?

Week4_7_11_12_STK_Lab_Handouts.md

STK 主場週次實驗單:Week 4 / 7 / 11 / 12

四週共用同一條 FIREYE 火山觀測任務主線;每週往同一場景疊一層。
所有 Connect 腳本先確認 STK Connect Server 已啟用(Edit→Preferences→Connect,勾 Allow Connect,port 5001),
報告輸出資料夾要先存在(路徑見 tools/stk_paths.py,預設 C:\STK_Reports)。
建起來:python -c "import sys;sys.path.insert(0,'tools');import stk_paths;print(stk_paths.ensure_reports_dir())"


Week 4 — 任務規劃與軌道選擇 ◎ STK 核心週

目標:為火山觀測任務選出使觀測 access 最大化的軌道。

現成檔案:Lab1OrbitDesign_Connect.m(MATLAB 掃描)、FIREYE_Volcano_FullScenario_Connect.txt(完整場景 + 軌道選項)、fireye_volcano_python.py(Python 物件模型版)。

步驟:
1. 載入 FIREYE_Volcano_FullScenario_Connect.txt,建好衛星 + 20 火山 + 成像 chain。
2. 用 Lab1OrbitDesign_Connect.m 掃描半長軸 a(6600–6928 km)與傾角 i(0–90°),記錄每組的總 access 時數。
3. 找出最大 access 的 (a, i);對照 Lab1 解(圓軌道 a=6928, i≈56;橢圓 a=6778, e=0.0221)。
4. 切換到三種發射載具軌道(腳本內註解),比較各自 access。

交付:access vs (a, i) 的掃描表/曲面圖 + 選定軌道的理由。
評分:掃描完整度 30% / 最佳解正確 30% / 發射載具比較 20% / CONOPS 討論 20%。


Week 7 — 功率系統 ◎

目標:用 STK 原生 Solar Panel Tool 算發電,建立功率收支。

現成檔案:FIREYE_SolarPanel_Connect.txt(發電)、storage_vs_time.py(儲存/電量積分)。

前提:FIREYE 需用「已註冊太陽能板的 3D 模型」(模型檔含 SolarPanelGroup;見腳本檔頭)。

步驟:
1. 執行 VO ... SolarPanel Compute ... Power,得軌道發電曲線。
2. 比較三種太陽能板方案(SmallSat Part 2:效率 0.28/0.21/0.168)的軌道平均發電。
3. 對照負載預算(待機 0.75 W / 成像 4 W / 下傳 6.75 W),算發電 − 負載收支。
4. 重現 FIREYE vs FIREYEPosX 構型比較(+Z 是否裝板)。

交付:發電曲線 + 功率收支表 + 板/電池選型建議。
評分:發電計算 30% / 收支分析 30% / 選型權衡 25% / 構型比較 15%。


Week 11 — 通訊系統 ◎

目標:建上/下行 RF 鏈路,驗證鏈路餘裕 ≥ 3 dB。

現成檔案:STKSat_Comm_Connect.txt(UHF 435 下行 / VHF 145 上行雙向鏈路)。

步驟:
1. 載入腳本,建地面站 + 衛星收發機 + 天線。
2. 出 ReportCreate ... "Link Budget - Detailed",讀 Eb/No、C/N、鏈路餘裕。
3. 檢查兩條鏈路餘裕是否 ≥ 3 dB;不足則調發射功率/天線增益。
4. 開 RF 環境(雨衰 ITU-R P618、大氣吸收 P676),看損耗變化。
5. 觀察 UHF vs VHF 都卜勒頻移差異(對照 SmallSat Part 3 結論)。

交付:上/下行鏈路預算表 + 餘裕 pass/fail + 環境損耗分析。
評分:鏈路預算 30% / 餘裕驗證 25% / 環境模型 25% / 都卜勒討論 20%。


Week 12 — 酬載與資料處理 ◎

目標:建立資料預算——成像產生 vs 下傳能力,判斷儲存是否會滿。

現成檔案:FIREYE_Volcano_FullScenario_Connect.txt(成像/下傳 chain)、storage_vs_time.py(儲存填充曲線)。

步驟:
1. 出成像 access(Imager→火山)與下傳 access(SBand→地面站)報告。
2. 用 storage_vs_time.py 讀這兩份報告,畫「儲存填充 vs 時間」。
3. 調感測器解析度/資料率,看資料產生量變化;調下傳窗,看能否清空。
4. 找出「儲存不溢出」的成像速率上限(對照 eSatellite 教學的錄影機填滿問題)。

交付:資料預算 + 儲存填充曲線 + 成像速率上限建議。
評分:access 計算 25% / 資料預算 30% / 儲存曲線 25% / 速率優化 20%。


Week 11 附錄 — 驗證段(ACK 不等於生效)

檔案:week11_comm/week11_verify.py(共用工具在 tools/stk_verify.py)

為什麼通訊週要有這一段

鏈路預算是一串乘法。任何一個參數靜默沒生效——功率、頻率、天線設計頻率、G/T——
Link Budget 報告照樣會印出一個「看起來很合理」的餘裕值,而且是錯的。
Connect 的 ACK 完全不會告訴你這件事。

本週腳本藏著三個這種問題,三個都只有回讀看得出來:

問題 症狀 修法
Model.DesignFrequency 屬性不存在 ACK,值沒寫進去 改寫到 Model.AntennaControl.Antenna.DesignFrequency
設定天線型別會換掉天線物件 ACK,先前寫的 DesignFrequency 被沖回預設 14.5 GHz 先設 Antenna Isotropic,再設它的 DesignFrequency
STKSat 沿用 Lab1 的掃描起點軌道(250 km, i=0) 鏈路報告印 "Data Unavailable",全部指令仍 ACK 本週開頭改用選定的任務軌道(550 km, i=56)

第三項最值得注意:赤道 250 km 軌道對北緯 40 度的測站完全沒有 access,
所以「餘裕 ≥ 3 dB」這個要求在修正前根本無從驗證,而腳本從頭到尾 0 個 NACK。

執行

python week11_comm/week11_verify.py      # 離開碼 0 = 全部通過

會做三類檢查,共 37 項:

  1. 自動回讀:腳本設過的每一個 SetValue,用 Transmitter_RM / Receiver_RM / RFEnvironment_RM ... GetValue 讀回來比對(容許單位換算,如 20.6 degC vs 293.75 K)。
  2. 結構檢查:測站位置、仰角約束、感測器與收發機物件是否存在。
  3. 鏈路判準:產生 Link Budget 報告,取最差 Eb/No 減去 BPSK@BER 1e-5 的
    門檻 9.6 dB,確認餘裕 ≥ 3 dB。這是本週真正的 pass/fail,原本從未被檢查。

基準結果:37 項全 PASS,最差 Eb/No 47.12 dB → 餘裕 37.52 dB。

作業

  1. 跑一次驗證段,貼上 37 項的輸出。
  2. 故意弄壞它:把 Model.Power 改成 0、或把 Model.GOverT 改回 -10、
    或把本週開頭的 SetState 那行註解掉。各跑一次,回答:
    - 腳本回報幾個 NACK?驗證段抓到幾項 FAIL?
    - 哪一種錯誤是「ACK 但無效」,哪一種會被 Connect 直接拒絕?
    - 如果沒有驗證段,你要多久才會發現?
  3. 為 Model.Modulator 與 Model.DataRate 各加一項自訂檢查,
    說明為什麼這兩個值錯了會讓餘裕算出來偏高。

程式碼

week04_orbit/FIREYE_Volcano_FullScenario_Connect.txt
# ================================================================
# FIREYE Volcano-Observation - COMPLETE SCENARIO (STK Connect)
# One batch build: satellite + 20 volcano points + access +
# solar-panel power report + storage-fill inputs.  No SOLIS needed.
#
# RUN (batch):
#   Preferably create the report folder first:  C:\STK_Reports\
#   Then in STK:  # ConFile / "C:\stk\FIREYE_Volcano_FullScenario_Connect.txt"
#   Or push the file with AgIPCExp -f / -t over the socket.
#
# PREREQUISITE for the solar-panel section ONLY:
#   FIREYE must use a 3D model registered for solar-panel analysis
#   (model file contains SolarPanelGroup lines, e.g.
#    "SolarPanelGroup PosX 30.0"). Connect cannot create panel
#    faces - see FIREYE_SolarPanel_Connect.txt header. The rest of
#    this scenario runs without any model prep.
#
# OUTPUT FILES (written to C:\STK_Reports\):
#   FIREYE_power.txt      solar power generated vs time (native)
#   imaging_access.txt    Imager -> Volcanoes access (feeds storage)
#   downlink_access.txt   SBand  -> GroundStations access (feeds storage)
#   -> run storage_vs_time.py on the two access files for the
#      "storage fill vs time" curve (SOLIS recorder replacement).
# ================================================================

# ---------------- 場景 / Scenario + time + units ----------------
New / Scenario FIREYE_Volcano
SetAnalysisTimePeriod * "1 Jan 2024 00:00:00.000" "8 Jan 2024 00:00:00.000"
SetTimePeriod        * "1 Jan 2024 00:00:00.000" "8 Jan 2024 00:00:00.000"
SetEpoch             * "1 Jan 2024 00:00:00.000"
Animate * Reset
SetUnits / km
# scenario power unit -> W (for the SolarPanel report)
Units_Set * Report Power W

# ================================================================
# 衛星軌道選擇 / Satellite orbit selection
# SELECTED: Lab1 optimum circular orbit for max volcano access
#   a = 6928 km (550 km alt), e = 0, i = 56 deg  (Lab1 Deliverable 2)
# Order in SetState Classical: SMA Ecc Inc AoP RAAN TA
# ================================================================
New / */Satellite FIREYE
SetState */Satellite/FIREYE Classical J2Perturbation "UseAnalysisStartTime" "UseAnalysisStopTime" 60 J2000 "UseAnalysisStartTime" 6928.0 0.0 56.0 0.0 0.0 0.0
Graphics */Satellite/FIREYE SetColor yellow

# ---- Alternative orbits (uncomment ONE to switch selection) ----
# Elliptical optimum (Lab1 Deliverable 3): a=6778 e=0.0221 i=56 AoP=249
# SetState */Satellite/FIREYE Classical J2Perturbation "UseAnalysisStartTime" "UseAnalysisStopTime" 60 J2000 "UseAnalysisStartTime" 6778.0 0.0221 56.0 249.0 0.0 0.0
# Launch provider - ULA polar:   OrbitWizard */Satellite/FIREYE Circular Inclination 90   Altitude 422
# Launch provider - ATK to ISS:  OrbitWizard */Satellite/FIREYE Circular Inclination 51.6 Altitude 402
# Launch provider - SpaceX CCAFS:OrbitWizard */Satellite/FIREYE Circular Inclination 28.5 Altitude 550
# Sun-synchronous 700 km:        OrbitWizard */Satellite/FIREYE SunSynchronous Altitude 700

# ---- Nadir attitude: +Z faces nadir (imager & SBand boresight [0 0 1]) ----
# If the token is rejected in your version, set in GUI:
#   FIREYE > Basic > Attitude > "Nadir alignment with ECF velocity constraint"
SetAttitude */Satellite/FIREYE Profile NadirECFVel Offset 0.0

# ---- Imager payload (nadir cone, boresight = body +Z) ----
New / */Satellite/FIREYE/Sensor Imager
# 40 deg half-angle (Lab1)
Define */Satellite/FIREYE/Sensor/Imager SimpleCone 40
Graphics */Satellite/FIREYE/Sensor/Imager SetColor red

# ---- SBand downlink (FOV sensor + transmitter, 10 Mbit/s) ----
New / */Satellite/FIREYE/Sensor SBandFOV
# 120 deg full -> 60 half-angle
Define */Satellite/FIREYE/Sensor/SBandFOV SimpleCone 60
New / */Satellite/FIREYE/Sensor/SBandFOV/Transmitter SBand
Transmitter */Satellite/FIREYE/Sensor/SBandFOV/Transmitter/SBand SetValue Model Complex_Transmitter_Model
Transmitter */Satellite/FIREYE/Sensor/SBandFOV/Transmitter/SBand SetValue Model.Frequency 2250000000.0
Transmitter */Satellite/FIREYE/Sensor/SBandFOV/Transmitter/SBand SetValue Model.AntennaControl.Antenna.DesignFrequency 2250000000.0
Transmitter */Satellite/FIREYE/Sensor/SBandFOV/Transmitter/SBand SetValue Model.DataRate 10000000.0
Transmitter */Satellite/FIREYE/Sensor/SBandFOV/Transmitter/SBand SetValue Model.AntennaControl.Antenna Isotropic
Transmitter */Satellite/FIREYE/Sensor/SBandFOV/Transmitter/SBand SetValue Model.Modulator BPSK

# ================================================================
# 20 個火山觀測點 / 20 US volcano observation Places (real coords)
# lat lon alt(km)
# ================================================================
New / */Place Volcano_Mt_St_Helens
SetPosition */Place/Volcano_Mt_St_Helens Geodetic 46.20 -122.20 2.55
New / */Place Volcano_Shishaldin
SetPosition */Place/Volcano_Shishaldin Geodetic 54.76 -163.97 2.86
New / */Place Volcano_Mt_Cleveland
SetPosition */Place/Volcano_Mt_Cleveland Geodetic 52.82 -169.94 1.73
New / */Place Volcano_Fourpeaked
SetPosition */Place/Volcano_Fourpeaked Geodetic 58.77 -153.67 2.10
New / */Place Volcano_Kasatochi_Island
SetPosition */Place/Volcano_Kasatochi_Island Geodetic 52.18 -175.50 0.31
New / */Place Volcano_South_Sister
SetPosition */Place/Volcano_South_Sister Geodetic 44.10 -121.77 3.16
New / */Place Volcano_Lassen
SetPosition */Place/Volcano_Lassen Geodetic 40.49 -121.51 3.19
New / */Place Volcano_Mt_Hood
SetPosition */Place/Volcano_Mt_Hood Geodetic 45.37 -121.69 3.43
New / */Place Volcano_Mt_Baker
SetPosition */Place/Volcano_Mt_Baker Geodetic 48.78 -121.81 3.29
New / */Place Volcano_Mt_Rainier
SetPosition */Place/Volcano_Mt_Rainier Geodetic 46.85 -121.76 4.39
New / */Place Volcano_Mt_Adams
SetPosition */Place/Volcano_Mt_Adams Geodetic 46.21 -121.49 3.74
New / */Place Volcano_Glacier_Peak
SetPosition */Place/Volcano_Glacier_Peak Geodetic 48.11 -121.11 3.21
New / */Place Volcano_Mt_Shasta
SetPosition */Place/Volcano_Mt_Shasta Geodetic 41.41 -122.19 4.32
New / */Place Volcano_Newberry
SetPosition */Place/Volcano_Newberry Geodetic 43.72 -121.23 2.43
New / */Place Volcano_Crater_Lake
SetPosition */Place/Volcano_Crater_Lake Geodetic 42.93 -122.12 2.49
New / */Place Volcano_Redoubt
SetPosition */Place/Volcano_Redoubt Geodetic 60.49 -152.74 3.11
New / */Place Volcano_Augustine
SetPosition */Place/Volcano_Augustine Geodetic 59.36 -153.43 1.25
New / */Place Volcano_Katmai
SetPosition */Place/Volcano_Katmai Geodetic 58.28 -154.96 2.05
New / */Place Volcano_Okmok
SetPosition */Place/Volcano_Okmok Geodetic 53.43 -168.13 1.07
New / */Place Volcano_Mauna_Loa
SetPosition */Place/Volcano_Mauna_Loa Geodetic 19.47 -155.60 4.17

# Group the 20 volcanoes
New / */Constellation Volcanoes
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_St_Helens
Chains */Constellation/Volcanoes Add */Place/Volcano_Shishaldin
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Cleveland
Chains */Constellation/Volcanoes Add */Place/Volcano_Fourpeaked
Chains */Constellation/Volcanoes Add */Place/Volcano_Kasatochi_Island
Chains */Constellation/Volcanoes Add */Place/Volcano_South_Sister
Chains */Constellation/Volcanoes Add */Place/Volcano_Lassen
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Hood
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Baker
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Rainier
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Adams
Chains */Constellation/Volcanoes Add */Place/Volcano_Glacier_Peak
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Shasta
Chains */Constellation/Volcanoes Add */Place/Volcano_Newberry
Chains */Constellation/Volcanoes Add */Place/Volcano_Crater_Lake
Chains */Constellation/Volcanoes Add */Place/Volcano_Redoubt
Chains */Constellation/Volcanoes Add */Place/Volcano_Augustine
Chains */Constellation/Volcanoes Add */Place/Volcano_Katmai
Chains */Constellation/Volcanoes Add */Place/Volcano_Okmok
Chains */Constellation/Volcanoes Add */Place/Volcano_Mauna_Loa

# ================================================================
# 地面站 / Downlink ground stations (AGI HQ + AK for polar passes)
# ================================================================
New / */Facility AGI_HQ
SetPosition */Facility/AGI_HQ Geodetic 40.0416 -75.5983 0.10
SetConstraint */Facility/AGI_HQ ElevationAngle Min 5.0
New / */Facility Fairbanks_ASF
SetPosition */Facility/Fairbanks_ASF Geodetic 64.8600 -147.8500 0.30
SetConstraint */Facility/Fairbanks_ASF ElevationAngle Min 5.0

New / */Constellation GroundStations
Chains */Constellation/GroundStations Add */Facility/AGI_HQ
Chains */Constellation/GroundStations Add */Facility/Fairbanks_ASF

# ================================================================
# Access + 報告 / Access & report generation
# ================================================================
# 成像: Imager -> 20 volcanoes  (feeds storage fill; total access = mission value)
New / */Chain Imaging
Chains */Chain/Imaging Add */Satellite/FIREYE/Sensor/Imager
Chains */Chain/Imaging Add */Constellation/Volcanoes
Chains */Chain/Imaging Compute
ReportCreate */Chain/Imaging Type Save Style "Complete Chain Access" File "C:\STK_Reports\imaging_access.txt"

# 下傳: SBand -> ground stations  (feeds storage drain)
New / */Chain Downlink
Chains */Chain/Downlink Add */Satellite/FIREYE/Sensor/SBandFOV
Chains */Chain/Downlink Add */Constellation/GroundStations
Chains */Chain/Downlink Compute
ReportCreate */Chain/Downlink Type Save Style "Complete Chain Access" File "C:\STK_Reports\downlink_access.txt"

# ================================================================
# 太陽能發電報告 / FIREYE solar-panel power report (native, no SOLIS)
# Requires the solar-panel-registered model (see PREREQUISITE above).
# ================================================================
VO */Satellite/FIREYE SolarPanel Object On CentralBody/Earth
VO */Satellite/FIREYE SolarPanel Compute "UseAnalysisStartTime" "UseAnalysisStopTime" 60 Power "C:\STK_Reports\FIREYE_power.txt"
# DeleteData 會清掉太陽能板的計算狀態,之後即使重新 Object On,
# 下一次 Compute 也只會產出空報告(而且照樣花兩分鐘、照樣 ACK)。
# Week 7 接在本檔之後,正是被這一行害的 —— 報告已經寫出來了,
# 這行沒有實際用途,註解掉。
#VO */Satellite/FIREYE SolarPanel DeleteData

# ================================================================
# NEXT (outside Connect): storage-fill curve
#   python storage_vs_time.py         # reads imaging_access.txt + downlink_access.txt
#   -> storage_vs_time.png  (SOLIS data-recorder replacement)
# Optional battery SOC: feed FIREYE_power.txt into the same script.
# ================================================================
week04_orbit/Lab1OrbitDesign_Connect.m
%% OrbitDesignLab - STK Connect Command Version
% Converted from Lab1OrbitDesign.m: all STK object-model calls replaced
% with Connect commands sent through root.ExecuteCommand().
% The same Connect strings work over the raw TCP socket (port 5001),
% so this logic ports directly to Python/C#/shell clients.
%
% NOTES vs. the original COM version:
%  - "SetUnits / km" sets the Connect session distance unit to km,
%    so SMA is passed in km exactly as in the original script.
%  - The satellite state is re-issued each iteration with SetState
%    (Connect has no persistent "keplerian" handle to mutate).
%  - Access durations are parsed from the chain "Complete Access" report
%    returned by Report_RM.
clc
clear
%% Launch STK and attach Connect root
app  = actxserver('STK11.application');   % use 'STK12.application' for STK 12
root = app.Personality2;
root.ExecuteCommand('SetUnits / km');     % Connect session distance unit: km
%% Initialize Scenario
Start = '1 Jan 2024 00:00:00.000';
Stop  = '15 Jan 2024 00:00:00.000';
root.ExecuteCommand('New / Scenario OrbitDesignLab');
root.ExecuteCommand(['SetAnalysisTimePeriod * "' Start '" "' Stop '"']);
root.ExecuteCommand('Animate * Reset');
%% Create Volcanoes (Place objects)
volc = { ...
 'Volcano_Mt_St_Helens'    46.20 -122.20; ...
 'Volcano_Shishaldin'      54.76 -163.97; ...
 'Volcano_Mt_Cleveland'    52.82 -169.94; ...
 'Volcano_Fourpeaked'      58.77 -153.67; ...
 'Volcano_Kasatochi_Island' 52.18 -175.50; ...
 'Volcano_South_Sister'    44.10 -121.77; ...
 'Volcano_Lassen'          40.49 -121.51; ...
 'Volcano_Mt_Hood'         45.37 -121.69; ...
 'Volcano_Mt_Baker'        48.78 -121.81; ...
 'Volcano_Mt_Rainer'       46.85 -121.76};

for k = 1:size(volc,1)
    root.ExecuteCommand(['New / */Place ' volc{k,1}]);
    root.ExecuteCommand(sprintf( ...
        'SetPosition */Place/%s Geodetic %.2f %.2f 0.0', ...
        volc{k,1}, volc{k,2}, volc{k,3}));
end
%% Group Volcanoes into a Constellation
root.ExecuteCommand('New / */Constellation Volcanoes');
for k = 1:size(volc,1)
    root.ExecuteCommand(['Chains */Constellation/Volcanoes Add */Place/' volc{k,1}]);
end
%% Create STKSat (state assigned later via SetState / J2Perturbation)
root.ExecuteCommand('New / */Satellite STKSat');
%% Create Sensor (Imager): 40 deg simple cone
root.ExecuteCommand('New / */Satellite/STKSat/Sensor Imager');
root.ExecuteCommand('Define */Satellite/STKSat/Sensor/Imager SimpleCone 40');
%% Chain: Imager -> Volcanoes
root.ExecuteCommand('New / */Chain Observe');
root.ExecuteCommand('Chains */Chain/Observe Add */Satellite/STKSat/Sensor/Imager');
root.ExecuteCommand('Chains */Chain/Observe Add */Constellation/Volcanoes');
%% INPUT SECTION: Vary 1 or 2 Orbital Elements at Once to Find Max Access
%**************************************************************************
%**************************************************************************
% INPUT Orbital Elements (baseline; varied elements override these)
SMA  = 6628;  % SemiMajorAxis (km): >6378.14
Ecc  = 0;     % Eccentricity: 0 to 0.99999
Inc  = 0;     % Inclination (deg): 0 to 180
AoP  = 0;     % Argument of Perigee (deg): 0 to 360
RAAN = 0;     % RAAN (deg): 0 to 360
TA   = 0;     % True Anomaly (deg): 0 to 360

% INPUT Number of Orbital Elements to Vary
NumberToVary = 0;  % 0, 1, or 2

% INPUT Which Orbital Element(s) to Vary and the Range(s)
Element1    = 'a';  % 'a','e','i','AoP','RAAN', or 'TA'
Element1Min = 0;
Element1Max = 0;
StepSize1   = 100;

% ONLY USED if varying 2 elements
Element2    = 'i';
Element2Min = 0;
Element2Max = 0;
StepSize2   = 10;
%**************************************************************************
%**************************************************************************
elems = struct('a',SMA,'e',Ecc,'i',Inc,'AoP',AoP,'RAAN',RAAN,'TA',TA);
Element1Range = Element1Min:StepSize1:Element1Max;
NumberOfIterations1 = length(Element1Range);

if NumberToVary == 0
    setKeplerian(root, Start, elems);
    TotalDuration = getTotalAccessHours(root);
    disp(['Max Total Access: ' num2str(TotalDuration) ' hr'])

elseif NumberToVary == 1
    TotalNumberOfIterations = NumberOfIterations1;
    DurationList = zeros(NumberOfIterations1,1);
    for i1 = 1:NumberOfIterations1
        elems.(Element1) = Element1Range(i1);
        setKeplerian(root, Start, elems);
        DurationList(i1) = getTotalAccessHours(root);
        disp(['Iterations: ' num2str(TotalNumberOfIterations)]);
        disp(['Complete: ' num2str(round(i1*100/TotalNumberOfIterations)) '%']);
    end
    [MaxDuration, IndexOfMax] = max(DurationList);
    disp(['Max Orbital Element 1: ' num2str(Element1Range(IndexOfMax))]);
    disp(['Max Total Access: ' num2str(MaxDuration) ' hr'])
    plot(Element1Range, DurationList, '--*')
    ylabel('Total Access (hr)'); grid on
    xlabel('Orbital Element 1')

elseif NumberToVary == 2
    Element2Range = Element2Min:StepSize2:Element2Max;
    NumberOfIterations2 = length(Element2Range);
    TotalNumberOfIterations = NumberOfIterations1*NumberOfIterations2;
    DurationList = zeros(NumberOfIterations1, NumberOfIterations2);
    for i1 = 1:NumberOfIterations1
        elems.(Element1) = Element1Range(i1);
        for i2 = 1:NumberOfIterations2
            elems.(Element2) = Element2Range(i2);
            setKeplerian(root, Start, elems);
            DurationList(i1,i2) = getTotalAccessHours(root);
            done = (i1-1)*NumberOfIterations2 + i2;
            disp(['Iterations: ' num2str(TotalNumberOfIterations)]);
            disp(['Complete: ' num2str(round(done*100/TotalNumberOfIterations)) '%']);
        end
    end
    MaxOfRows = max(DurationList,[],2);
    [~, IndexOfMaxRow] = max(MaxOfRows);
    [MaxDuration, IndexOfMaxCol] = max(DurationList(IndexOfMaxRow,:));
    disp(['Max Orbital Element 1: ' num2str(Element1Range(IndexOfMaxRow))]);
    disp(['Max Orbital Element 2: ' num2str(Element2Range(IndexOfMaxCol))]);
    disp(['Max Total Access: ' num2str(MaxDuration) ' hr']);
    X = repmat(Element1Range, NumberOfIterations2, 1).';
    Y = repmat(Element2Range.', 1, NumberOfIterations1).';
    surf(X, Y, DurationList); colorbar
    xlabel('Orbital Element 1'); ylabel('Orbital Element 2')
    zlabel('Access Time (hr)')
end

%% ------------------------- Local Functions ------------------------------
function setKeplerian(root, epoch, e)
% Push a full Keplerian state via Connect SetState (J2Perturbation).
% Session units set to km via "SetUnits / km" -> SMA passed in km directly.
% Trailing "TrueAnomaly" keyword tells STK the anomaly value is TA.
    cmd = sprintf(['SetState */Satellite/STKSat Classical J2Perturbation ' ...
        'UseScenarioInterval 60 ICRF "%s" %.6f %.6f %.6f %.6f %.6f %.6f TrueAnomaly'], ...
        epoch, e.a, e.e, e.i, e.AoP, e.RAAN, e.TA);
    root.ExecuteCommand(cmd);
end

function hrs = getTotalAccessHours(root)
% Recompute the chain, pull "Complete Access" via Report_RM, and sum the
% Duration (sec) column. Returns 0 when no access exists (mirrors the
% try/catch in the original COM script).
    hrs = 0;
    try
        root.ExecuteCommand('ChainAccess */Chain/Observe Compute');
        result = root.ExecuteCommand( ...
            'Report_RM */Chain/Observe Style "Complete Access"');
        totalSec = 0;
        for n = 1:result.Count
            line = char(result.Item(n));
            % Data rows begin with the access number; the duration (sec)
            % is the trailing numeric token on the row.
            if ~isempty(regexp(line, '^\s*\d+\s', 'once'))
                tok = regexp(line, '([\d\.]+)\s*$', 'tokens', 'once');
                if ~isempty(tok)
                    totalSec = totalSec + str2double(tok{1});
                end
            end
        end
        hrs = totalSec/3600;
    catch
        hrs = 0;   % no access found for this element combination
    end
end
week04_orbit/OrbitDesignLab_Connect.txt
# ============================================================
# OrbitDesignLab - STK Connect Command Script (rev.2)
# Converted from Lab1OrbitDesign.m; style aligned with the
# provided connect_command_sample.txt conventions:
#   - "#" comment lines (ConFile-safe)
#   - SetUnits / km declared up front
#   - SetEpoch alongside SetAnalysisTimePeriod
#   - UseAnalysisStartTime / UseAnalysisStopTime keywords in
#     SetState (no hard-coded epoch strings)
#   - SetDescription on scenario and key objects
#
# To batch-load inside STK, uncomment:
#Unload / *
#ConFile / "C:\stk\OrbitDesignLab_Connect.txt"
# ============================================================

New / Scenario OrbitDesignLab
SetAnalysisTimePeriod * "1 Jan 2024 00:00:00.000" "15 Jan 2024 00:00:00.000"
SetEpoch * "1 Jan 2024 00:00:00.000"
SetDescription * Short "SmallSat Lab1: STKSat orbit design for max volcano access"
Animate * Reset

SetUnits / km

# ---- Volcano targets (Place objects) ----
New / */Place Volcano_Mt_St_Helens
SetPosition */Place/Volcano_Mt_St_Helens Geodetic 46.20 -122.20 0.0

New / */Place Volcano_Shishaldin
SetPosition */Place/Volcano_Shishaldin Geodetic 54.76 -163.97 0.0

New / */Place Volcano_Mt_Cleveland
SetPosition */Place/Volcano_Mt_Cleveland Geodetic 52.82 -169.94 0.0

New / */Place Volcano_Fourpeaked
SetPosition */Place/Volcano_Fourpeaked Geodetic 58.77 -153.67 0.0

New / */Place Volcano_Kasatochi_Island
SetPosition */Place/Volcano_Kasatochi_Island Geodetic 52.18 -175.50 0.0

New / */Place Volcano_South_Sister
SetPosition */Place/Volcano_South_Sister Geodetic 44.10 -121.77 0.0

New / */Place Volcano_Lassen
SetPosition */Place/Volcano_Lassen Geodetic 40.49 -121.51 0.0

New / */Place Volcano_Mt_Hood
SetPosition */Place/Volcano_Mt_Hood Geodetic 45.37 -121.69 0.0

New / */Place Volcano_Mt_Baker
SetPosition */Place/Volcano_Mt_Baker Geodetic 48.78 -121.81 0.0

New / */Place Volcano_Mt_Rainer
SetPosition */Place/Volcano_Mt_Rainer Geodetic 46.85 -121.76 0.0

# ---- Group volcanoes into a Constellation ----
New / */Constellation Volcanoes
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_St_Helens
Chains */Constellation/Volcanoes Add */Place/Volcano_Shishaldin
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Cleveland
Chains */Constellation/Volcanoes Add */Place/Volcano_Fourpeaked
Chains */Constellation/Volcanoes Add */Place/Volcano_Kasatochi_Island
Chains */Constellation/Volcanoes Add */Place/Volcano_South_Sister
Chains */Constellation/Volcanoes Add */Place/Volcano_Lassen
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Hood
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Baker
Chains */Constellation/Volcanoes Add */Place/Volcano_Mt_Rainer

# ---- STKSat: J2Perturbation, Keplerian initial state ----
# SetState uses analysis-interval keywords (per sample) so the state
# tracks the scenario time period automatically.
# a=6628 km, e=0, i=0, AoP=0, RAAN=0, TA=0 (baseline from the .m file)
# Trailing "TrueAnomaly" keyword: last value is TA, not Mean Anomaly.
New / */Satellite STKSat
SetState */Satellite/STKSat Classical J2Perturbation "UseAnalysisStartTime" "UseAnalysisStopTime" 60 ICRF "UseAnalysisStartTime" 6628.0 0.0 0.0 0.0 0.0 0.0 TrueAnomaly
SetDescription */Satellite/STKSat Short "1U CubeSat imaging US volcanoes (SmallSat Lab1)"

# ---- Imager sensor: 40 deg simple cone ----
New / */Satellite/STKSat/Sensor Imager
Define */Satellite/STKSat/Sensor/Imager SimpleCone 40

# ---- Chain: Imager -> Volcanoes ----
New / */Chain Observe
Chains */Chain/Observe Add */Satellite/STKSat/Sensor/Imager
Chains */Chain/Observe Add */Constellation/Volcanoes

# ---- Compute chain access (aggregate over all volcanoes) ----
Chains */Chain/Observe Compute
Report_RM */Chain/Observe Style "Complete Chain Access"

# ---- Optional: per-target pairwise access, sample-file style ----
# Uncomment to compute individual sensor-to-volcano accesses:
# Access */Satellite/STKSat/Sensor/Imager */Place/Volcano_Mt_St_Helens TimePeriod UseScenarioInterval
# Access */Satellite/STKSat/Sensor/Imager */Place/Volcano_Mt_Rainer TimePeriod UseScenarioInterval

# ---- Sweep pattern: re-issue SetState, recompute, re-pull report ----
# Example next iteration (a = 6728 km):
# SetState */Satellite/STKSat Classical J2Perturbation "UseAnalysisStartTime" "UseAnalysisStopTime" 60 ICRF "UseAnalysisStartTime" 6728.0 0.0 0.0 0.0 0.0 0.0 TrueAnomaly
# ChainAccess */Chain/Observe Compute
# Report_RM */Chain/Observe Style "Complete Access"
week04_orbit/STKSat_HPOP_Connect.txt
# ============================================================
# STKSat HPOP High-Fidelity Propagation - Connect Script
# Applies the HPOP orbit-prediction reference (SetState Cartesian/
# Classical HPOP, Force / Drag / Integrator, ExportDataFile) to the
# SmallSat STKSat, using force models appropriate for a 250-550 km
# CubeSat.
#
# WHY HPOP HERE:
#   Lab1 used J2Perturbation for the design SWEEP (fast, and the
#   assignment says to ignore drag/SRP/3rd-body). But a CubeSat at
#   250-550 km is drag-dominated in reality, so the OPERATIONAL
#   ephemeris (ground-station scheduling, decay, SSA hand-off) should
#   come from an HPOP run WITH drag. This script is that realistic run.
#
# CONNECT CAVEATS (from the reference):
#   - Connect returns nothing on success/failure; don't spam commands.
#   - Propagator time span MUST match the scenario or the orbit won't
#     display -> we use UseAnalysisStartTime / UseAnalysisStopTime.
# ============================================================

# ---- STANDALONE (uncomment to run without Lab1 loaded) ----
#New / Scenario STKSat_HPOP
#SetAnalysisTimePeriod * "1 Jan 2024 00:00:00.000" "8 Jan 2024 00:00:00.000"
#SetTimePeriod * "1 Jan 2024 00:00:00.000" "8 Jan 2024 00:00:00.000"
#Animate * Reset

SetUnits / km

# ============================================================
# Method A - fastest: OrbitWizard seed at the Part 1 optimum,
# then switch the propagator to HPOP via SetState.
# (a = 6928 km, i = 56 deg, circular; from Lab1 Deliverable 2)
# ============================================================
New / */Satellite STKSat_HPOP
OrbitWizard */Satellite/STKSat_HPOP Circular Inclination 56 Altitude 550 RAAN 0 Color Red

# ============================================================
# Method B - explicit HPOP state (Classical), time span tied to
# the scenario so it always renders. This OVERRIDES Method A.
# Anomaly value 0 with trailing fields = TrueAnomaly.
# Order: SMA Ecc Inc AoP RAAN TA
# ============================================================
SetState */Satellite/STKSat_HPOP Classical HPOP "UseAnalysisStartTime" "UseAnalysisStopTime" 60 J2000 "UseAnalysisStartTime" 6928.0 0.0 56.0 0.0 0.0 0.0

# ---- Earth gravity: EGM2008-class field, 70x70 (LEO SSA standard) ----
# Adjust the .grv path to your install; WGS84.grv or JGM3.grv also valid.
HPOP */Satellite/STKSat_HPOP Force Gravity "C:\Program Files\AGI\STK 12\STKData\CentralBodies\Earth\EGM2008.grv" 70 70

# ---- Atmospheric drag: dominant force at this altitude ----
# Cd 2.2 (cannonball default), Area/Mass for a 1U ~1.3 kg CubeSat:
#   0.01 m^2 face / 1.3 kg ~ 0.0077 m^2/kg (use ~0.008).
# NRLMSISE 2000 density model, Manual solar flux (F10.7=150, Ap=3).
HPOP */Satellite/STKSat_HPOP Drag On 2.2 0.008 "NRLMSISE 2000" Manual 150.0 150.0 3.0

# ---- Third-body gravity: Sun + Moon ----
HPOP */Satellite/STKSat_HPOP Force ThirdBodyGravity Sun On
HPOP */Satellite/STKSat_HPOP Force ThirdBodyGravity Moon On

# ---- Solar radiation pressure (small at this altitude, keep for realism) ----
# Spherical model: Cr=1.3, Area/Mass 0.008 m^2/kg
HPOP */Satellite/STKSat_HPOP Force SRP On Model Spherical 1.3 0.008

# ---- Tides ----
HPOP */Satellite/STKSat_HPOP Force SolidTides On
HPOP */Satellite/STKSat_HPOP Force OceanTides Off

# ---- Integrator: RKF7(8), tight relative-error step control ----
HPOP */Satellite/STKSat_HPOP Integrator IntegMethod RKF78 StepControl RelativeError 1.0e-13 MinStepSize 0.1 MaxStepSize 30 InterpMethod LaGrange 7

# ============================================================
# Export the high-fidelity ephemeris (feeds scheduling / SSA / OD)
# ============================================================
# The HPOP Force/Drag/Integrator settings above are QUEUED - they are only
# committed on the next propagation. On STK 12 desktop ExportDataFile does NOT
# trigger one, so without this line the exported ephemeris uses STK's DEFAULT
# force model (EGM2008 21x21, Cd 2.2, A/m 0.02, Jacchia-Roberts) - a ~107 km
# position difference after 7 days. (STK 13 commits on export; STK 12 does not.)
Propagate */Satellite/STKSat_HPOP

ExportDataFile */Satellite/STKSat_HPOP Ephemeris "C:\temp\STKSat_HPOP.e" Type STK CoordSys J2000 CentralBody Earth InterpBoundaries Include

# ---- Optional: read back the configured state to verify ----
# Satellite_RM */Satellite/STKSat_HPOP GetValue

# ============================================================
# Compare against the volcano access (reuse Lab1 chain if present)
# ============================================================
# New / */Satellite/STKSat_HPOP/Sensor Imager
# Define */Satellite/STKSat_HPOP/Sensor/Imager SimpleCone 40
# New / */Chain Observe_HPOP
# Chains */Chain/Observe_HPOP Add */Satellite/STKSat_HPOP/Sensor/Imager
# Chains */Chain/Observe_HPOP Add */Constellation/Volcanoes
# ChainAccess */Chain/Observe_HPOP Compute
# Report_RM */Chain/Observe_HPOP Style "Complete Access"
week04_orbit/VolcanoUAV_Aviator_Connect.txt
# ============================================================
# VolcanoUAV - STK Aviator (MissionModeler) Connect Script
# Applies the UAVMission sample conventions to the SmallSat
# volcano-observation scenario: a UAV patrols the Cascade
# volcano cluster as an airborne imaging platform that
# complements STKSat.
#
# USAGE:
#   - Run AFTER OrbitDesignLab_Connect.txt so the volcano
#     Place objects already exist, OR uncomment the
#     "STANDALONE" block below to create them here.
#
# Reference (Aviator Connect library):
# https://help.agi.com/stk/12.9.1/index.htm#../Subsystems/connectCmds/Content/amm/
#
# UNIT NOTE: Aviator values carry explicit units (deg / ft / km),
#   so SetUnits is not required here; altitudes follow aviation
#   convention (ft), turn diameters in km.
# ============================================================

# ---- STANDALONE block (uncomment if not chaining after Lab1) ----
#New / Scenario OrbitDesignLab
#SetAnalysisTimePeriod * "1 Jan 2024 00:00:00.000" "15 Jan 2024 00:00:00.000"
#New / */Place Volcano_Mt_St_Helens
#SetPosition */Place/Volcano_Mt_St_Helens Geodetic 46.20 -122.20 0.0
#New / */Place Volcano_Mt_Rainer
#SetPosition */Place/Volcano_Mt_Rainer Geodetic 46.85 -121.76 0.0
#New / */Place Volcano_Mt_Baker
#SetPosition */Place/Volcano_Mt_Baker Geodetic 48.78 -121.81 0.0
#New / */Place Volcano_Mt_Hood
#SetPosition */Place/Volcano_Mt_Hood Geodetic 45.37 -121.69 0.0
#New / */Place Volcano_South_Sister
#SetPosition */Place/Volcano_South_Sister Geodetic 44.10 -121.77 0.0

# ============================================================
# UAV aircraft + MissionModeler propagator
# ============================================================
New / */Aircraft VolcanoUAV

# Load a visualization model (path may vary by install)
VO */Aircraft/VolcanoUAV Model File "C:\Program Files\AGI\STK 12\STKData\VO\Models\Air\rq-4a_globalhawk.glb"

SetPropagator */Aircraft/VolcanoUAV MissionModeler
MissionModeler */Aircraft/VolcanoUAV Aircraft Choose "Basic UAV"

# ------------------------------------------------------------
# Procedure 1 - Takeoff from Portland Intl (PDX) 45.5898, -122.5951
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add AsFirst SiteType Runway ProcedureType "Takeoff"
# SetTime addresses procedure 1, so it must come AFTER that procedure exists
MissionModeler */Aircraft/VolcanoUAV Procedure SetTime 1 Start "1 Jan 2024 12:00:00.00" UTCG
MissionModeler */Aircraft/VolcanoUAV Site 1 SetValue Latitude 45.5898 deg
MissionModeler */Aircraft/VolcanoUAV Site 1 SetValue Longitude -122.5951 deg
# Heading: last bool True = magnetic, False = true heading
MissionModeler */Aircraft/VolcanoUAV Site 1 SetValue Heading 10.0 deg true
MissionModeler */Aircraft/VolcanoUAV Procedure 1 SetValue RunwayAltitudeOffset 31 ft
MissionModeler */Aircraft/VolcanoUAV SendNtfUpdate

# ------------------------------------------------------------
# Procedure 2 - Enroute to Mt Hood 45.37, -121.69
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add After 1 SiteType Waypoint ProcedureType "Basic Point to Point"
MissionModeler */Aircraft/VolcanoUAV Site 2 SetValue Latitude 45.37 deg
MissionModeler */Aircraft/VolcanoUAV Site 2 SetValue Longitude -121.69 deg
MissionModeler */Aircraft/VolcanoUAV Procedure 2 SetValue Altitude False True 24000 ft

# ------------------------------------------------------------
# Procedure 3 - Enroute to Mt St Helens 46.20, -122.20
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add After 2 SiteType Waypoint ProcedureType "Basic Point to Point"
MissionModeler */Aircraft/VolcanoUAV Site 3 SetValue Latitude 46.20 deg
MissionModeler */Aircraft/VolcanoUAV Site 3 SetValue Longitude -122.20 deg
MissionModeler */Aircraft/VolcanoUAV Procedure 3 SetValue Altitude False True 24000 ft

# ------------------------------------------------------------
# Procedure 4 - Enroute to Mt Rainier 46.85, -121.76
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add After 3 SiteType Waypoint ProcedureType "Basic Point to Point"
MissionModeler */Aircraft/VolcanoUAV Site 4 SetValue Latitude 46.85 deg
MissionModeler */Aircraft/VolcanoUAV Site 4 SetValue Longitude -121.76 deg
MissionModeler */Aircraft/VolcanoUAV Procedure 4 SetValue Altitude False True 24000 ft

# ------------------------------------------------------------
# Procedure 5 - Loiter over Mt Rainier for extended imaging
# Holding-Circular uses "End of previous procedure" (no new Site)
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add AfterLast 0 SiteType "End of previous procedure" ProcedureType "Holding - Circular"
MissionModeler */Aircraft/VolcanoUAV Procedure 5 SetValue Diameter 40 km
MissionModeler */Aircraft/VolcanoUAV Procedure 5 SetValue Turns 3
MissionModeler */Aircraft/VolcanoUAV Procedure 5 SetValue RequestedAltitude false 21450 ft
MissionModeler */Aircraft/VolcanoUAV SendNtfUpdate

# ------------------------------------------------------------
# Procedure 6 - Enroute to Mt Baker 48.78, -121.81 (northern turn)
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add After 5 SiteType Waypoint ProcedureType "Basic Point to Point"
MissionModeler */Aircraft/VolcanoUAV Site 6 SetValue Latitude 48.78 deg
MissionModeler */Aircraft/VolcanoUAV Site 6 SetValue Longitude -121.81 deg
# NOTE: target Procedure 6 (the sample mislabels this as Procedure 3)
MissionModeler */Aircraft/VolcanoUAV Procedure 6 SetValue Altitude False True 24000 ft

# ------------------------------------------------------------
# Procedure 7 - Return leg toward PDX approach 45.75, -122.40
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add After 6 SiteType Waypoint ProcedureType "Basic Point to Point"
MissionModeler */Aircraft/VolcanoUAV Site 7 SetValue Latitude 45.75 deg
MissionModeler */Aircraft/VolcanoUAV Site 7 SetValue Longitude -122.40 deg
MissionModeler */Aircraft/VolcanoUAV Procedure 7 SetValue Altitude False True 21450 ft

# ------------------------------------------------------------
# Procedure 8 - Landing at Portland Intl (PDX)
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV Procedure Add After 7 SiteType Runway ProcedureType "Landing"
MissionModeler */Aircraft/VolcanoUAV Procedure 8 SetValue ApproachAltitude 1280 ft
MissionModeler */Aircraft/VolcanoUAV Procedure 8 SetValue Glideslope 3.0 deg
MissionModeler */Aircraft/VolcanoUAV Site 8 SetValue Latitude 45.5898 deg
MissionModeler */Aircraft/VolcanoUAV Site 8 SetValue Longitude -122.5951 deg
MissionModeler */Aircraft/VolcanoUAV Site 8 SetValue Heading 190.0 deg true

# ------------------------------------------------------------
# Build the mission
# ------------------------------------------------------------
MissionModeler */Aircraft/VolcanoUAV ConfigureAll
MissionModeler */Aircraft/VolcanoUAV CalculateAll
MissionModeler */Aircraft/VolcanoUAV SendNtfUpdate

# ============================================================
# Nadir imaging sensor + access to the Cascade volcanoes
# ============================================================
New / */Aircraft/VolcanoUAV/Sensor Imager
Define */Aircraft/VolcanoUAV/Sensor/Imager SimpleCone 40

# Aggregate access via a chain (mirrors the orbit-lab workflow)
New / */Chain UAV_Observe
Chains */Chain/UAV_Observe Add */Aircraft/VolcanoUAV/Sensor/Imager
Chains */Chain/UAV_Observe Add */Constellation/Volcanoes
Chains */Chain/UAV_Observe Compute
Report_RM */Chain/UAV_Observe Style "Complete Chain Access"

# Or per-target pairwise access (sample-file style):
# Access */Aircraft/VolcanoUAV/Sensor/Imager */Place/Volcano_Mt_Rainer TimePeriod UseScenarioInterval
# Access */Aircraft/VolcanoUAV/Sensor/Imager */Place/Volcano_Mt_St_Helens TimePeriod UseScenarioInterval
week04_orbit/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)
# ============================================================
week04_orbit/week04_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 4 驗證段 — 軌道設計、完整場景與 HPOP 受力模型
#
# 本週最隱蔽的坑是 HPOP:Force / Drag / Integrator 的設定是**延後提交**的,
# STK 12 的 ExportDataFile 不會觸發傳播,少了 Propagate 就等於整組設定沒生效,
# 而且 0 個 NACK。匯出的星曆會用 STK 預設模型,7 天後位置差約 107 km。
#
# 執行:python week04_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
import stk_paths
from agi.stk12.stkdesktop import STKDesktop
from stk_verify import run_script      # noqa: E402


def main():
    app = STKDesktop.AttachToApplication()
    root = app.Root
    v = Verifier(root)

    # ---- (A) Lab1 軌道設計:掃描的「起點」軌道 ----
    if root.CurrentScenario is not None:
        root.CloseScenario()
    run_script(root, os.path.join(HERE, "OrbitDesignLab_Connect.txt"), echo_fail=False)
    v.object_exists("*/Satellite/STKSat")
    v.cone_angle("*/Satellite/STKSat/Sensor/Imager", 40.0)
    sc = root.CurrentScenario
    nplace = sum(1 for i in range(sc.Children.Count)
                 if sc.Children.Item(i).ClassName == "Place")
    v.expect("Lab1 火山觀測點數", nplace, 10, tol=0, unit=" 個")   # FullScenario 才是 20 個
    v.expect_true("Lab1 基準是掃描起點(i=0 赤道軌道),不是結論", True,
                  "Week 11 若直接沿用它會完全沒有 access,見 week11_verify.py")

    # ---- (B) HPOP:受力模型必須真的提交 ----
    run_script(root, os.path.join(HERE, "STKSat_HPOP_Connect.txt"), echo_fail=False)
    fm = root.GetObjectFromPath("*/Satellite/STKSat_HPOP").Propagator.ForceModel
    v.expect("重力階數", fm.CentralBodyGravity.MaxDegree, 70, tol=0, unit=" 階")
    v.expect("重力次數", fm.CentralBodyGravity.MaxOrder, 70, tol=0, unit=" 次")
    v.expect("阻力係數 Cd", fm.Drag.DragModel.Cd, 2.2, tol=0.01)
    v.expect("面積質量比", fm.Drag.DragModel.AreaMassRatio, 0.008, tol=1e-6, unit=" m^2/kg")
    v.expect_true("重力檔為 EGM2008",
                  "EGM2008" in fm.CentralBodyGravity.File, fm.CentralBodyGravity.File[-24:])
    v.expect_true("腳本裡有 Propagate(HPOP 設定才會提交)",
                  any(l.strip().startswith("Propagate ")
                      for l in open(os.path.join(HERE, "STKSat_HPOP_Connect.txt"),
                                    encoding="utf-8")),
                  "STK 12 的 ExportDataFile 不會觸發傳播")

    # ---- (C) 完整 FIREYE 場景:chain access 與報告 ----
    if root.CurrentScenario is not None:
        root.CloseScenario()
    ack, nack = run_script(root, os.path.join(HERE,
                           "FIREYE_Volcano_FullScenario_Connect.txt"), echo_fail=False)
    v.expect_true("完整場景沒有 NACK", nack == 0, f"{ack} ACK / {nack} NACK")
    sat = root.GetObjectFromPath("*/Satellite/FIREYE")
    v.expect_true("FIREYE 姿態為 NadirECFVel", int(sat.Attitude.Basic.ProfileType) == 8,
                  f"ProfileType = {int(sat.Attitude.Basic.ProfileType)}")
    # 路徑由 tools/stk_paths.py 統一 —— 原本三個 Python 檔各寫死一份
    for f, label, lo in ((stk_paths.report("imaging_access.txt"), "成像報告", 50),
                         (stk_paths.report("downlink_access.txt"), "下傳報告", 30)):
        n = sum(1 for l in open(f, encoding="utf-8", errors="ignore")
                if l.strip()[:1].isdigit() and len(l.split()) > 6) if os.path.exists(f) else 0
        v.expect_true(f"{label}有足夠通聯({lo} 次以上)", n >= lo, f"{n} 次")

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


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