Week 14 — 整合測試與運作

資料夾:week14_operations/

基礎知識導論

Week 14 基礎知識導論 — 運作:TLE、通聯窗與排程

設計階段問「這顆衛星能不能做到」;運作階段問「今天晚上幾點幾分要做什麼」。
這一週把軌道分析變成一張可以交給值班人員的時刻表。

1. 這一週在解什麼問題

給定一組 TLE 與一個地面站,算出未來 7 天的所有通聯窗:
AOS(訊號獲得)、LOS(訊號失去)、最大仰角、持續時間。
然後把 Week 12 的資料下傳需求排進這些窗裡。

並且用 STK 交叉驗證 Python 的結果 —— 兩個獨立方法互相檢查。

2. 核心觀念

2.1 TLE:格式、來源與限制

Two-Line Element set 是一組 69 字元 x 2 行的固定格式:

1 25544U 98067A   24001.50000000  .00016717  00000-0  10270-3 0  9005
2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.49814542    05

包含:曆元、傾角、RAAN、離心率、近地點幅角、平近點角、平均運動、阻力項 (B*)。

三個必須記住的限制:

  1. TLE 是 SGP4 專用的平均根數,不是瞬時的物理根數。
    直接餵給 HPOP 或兩體傳播器會有系統性偏差(Week 1、4 都提過)。
  2. 精度隨時間衰減:LEO 的 TLE 在曆元附近誤差約 1 km,
    一週後可能到 10 km 以上,主要來自大氣阻力的不確定性。
    要定期更新 TLE,不能用一組算一整年。
  3. TLE 用 TEME 座標系(True Equator Mean Equinox),不是 J2000 也不是 ECEF。
    轉換弄錯會造成數十公里的位置誤差。

2.2 SGP4

SGP4 是搭配 TLE 的解析式傳播器,內建了它自己的簡化攝動模型
(J2–J4、簡化大氣、共振項)。它的設計目標是快,不是準。

關鍵原則:TLE 與 SGP4 是一組,不能拆開。
TLE 的數值是為了讓 SGP4 重現觀測而擬合出來的,換一個傳播器就沒有意義了。

2.3 通聯窗的幾何

地面站看到衛星的條件:

仰角 el >= el_min          本課取 5°

仰角由地面站到衛星的向量與當地水平面計算:

1. 算衛星在 ECEF 的位置
2. 轉到地面站的 ENU(東-北-天)座標
3. el = arcsin( u / |r| )         u 是天頂分量

一次通聯的關鍵量:

量 意義 LEO 典型值(550 km, 5° 遮罩)
AOS 仰角上升穿越 el_min 的時刻 —
LOS 仰角下降穿越 el_min 的時刻 —
持續時間 LOS − AOS 5–12 分鐘
最大仰角 過境的最高點 5°–90° 都可能
每日次數 — 中緯度地面站約 4–6 次

2.4 不是每個窗都能用

最大仰角低的過境幾乎沒有價值:

實務上會設一個可用仰角門檻(例如最大仰角 > 20°)來篩選。
本週的排程要把這個篩選做出來,並看它砍掉多少窗。

2.5 排程是一個最佳化問題

給定一堆通聯窗與一堆下傳需求,要決定「哪些資料在哪個窗傳」。約束:

單一地面站的情形可以用貪婪法(先到先服務、或按優先權排序)得到夠好的解。
多站多星的情形是 NP-hard,實務上用啟發式或整數規劃。

2.6 交叉驗證:Python vs STK

這是本週的方法論重點。兩個獨立實作應該給出一致的 AOS/LOS(差幾秒是正常的)。

差異來源可能是:

來源 影響量級
傳播器不同(SGP4 vs HPOP) 秒到分鐘
地球模型不同(球 vs WGS84 橢球) 秒
是否考慮大氣折射 低仰角時秒級
時間系統(UTC / UT1 / TT) 亞秒到秒
仰角遮罩定義(幾何 vs 視在) 低仰角時明顯

如果差到分鐘以上,一定有一邊錯了。 逐項排除是本週的實作練習。

2.7 從 LEOP 到常規運作

階段 內容 對應週次
LEOP(發射與早期軌道) 首次捕獲、消旋、展開、健康檢查 Week 8、15
調試 (Commissioning) 各子系統逐一開機驗證、酬載校正 全部
常規運作 排程、下傳、軌道維持 Week 12、14、10
除役 去軌、鈍化(放電、洩壓) Week 10

第一次通聯是整個任務最緊張的時刻:你不知道衛星在哪、姿態如何、有沒有活著。
所以要事先算好部署後首次通聯的時間與都卜勒曲線(Week 15 會接上這一段)。

3. 名詞與符號

名詞 意義 備註
TLE Two-Line Element 只搭配 SGP4
SGP4 簡化攝動模型 解析式,快但不準
TEME True Equator Mean Equinox TLE 的座標系
AOS / LOS 訊號獲得 / 失去 仰角穿越門檻的時刻
el_min 仰角遮罩 本課 5°
ENU 東-北-天 地面站的本地座標
B* TLE 的阻力項 不是物理的彈道係數
LEOP 發射與早期軌道階段 —

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
SGP4 傳播 week14_pass_scheduler.py,使用 sgp4 套件
AOS/LOS/最大仰角 同上,仰角穿越偵測
通聯窗篩選 同上,可用仰角門檻
排程時刻表 同上,接 Week 12 的下傳需求
STK 交叉驗證 week14_verify.py
圖表 week14_passes.png(通聯窗與仰角曲線)
資料來源 passes.csv

5. 常見誤解

  1. 「TLE 可以當初始根數丟給任何傳播器。」 — 不行。TLE 與 SGP4 是一組。
  2. 「一組 TLE 可以用一整年。」 — LEO 的 TLE 幾天就會明顯偏離。要定期更新。
  3. 「每天 6 次通聯就是 6 次可用機會。」 — 低仰角的過境鏈路餘裕不足,實際可用的少很多。
  4. 「Python 跟 STK 差 30 秒,一定有 bug。」 — 幾秒到十幾秒可能來自模型差異(見 2.6)。
    差到分鐘以上才是 bug。要先量化每個來源的量級。
  5. 「排程就是把窗填滿。」 — 還要考慮優先權、延遲需求、電池狀態與地面站衝突。

6. 延伸閱讀

TLE 與 SGP4(這一節的每個連結都值得點)

Python 工具

地面站與運作

7. 課前自我檢核

  1. TLE 為什麼不能餵給 HPOP?會產生什麼量級的誤差?
  2. 550 km 的衛星對中緯度地面站,每天大約幾次通聯?每次多久?
  3. 最大仰角 8° 的過境跟 80° 的過境,鏈路餘裕差多少 dB?哪一個實際可用?
  4. 你的 Python 與 STK 算出的 AOS 差 12 秒。列出三個可能來源,並排出量級順序。
  5. 排程時只考慮「窗有多長」而忽略電池狀態,可能出什麼事?

圖


week14_operations/week14_passes.png

程式碼

week14_operations/week14_pass_scheduler.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 14 Lab - 地面站通聯排程(pass scheduling)
#
# 運作階段:從 TLE 算衛星對地面站的通聯窗(rise/set/最大仰角)。
# 純 sgp4 + numpy(不需下載星曆),結果可與 STK access 互相驗證。
#
# 分工:這一份完全獨立於 STK,是「開源交叉檢核」——用同一顆衛星、
#       同一個地面站,比對 Python 算的 pass 與 STK access,驗證兩者一致。
#
# 輸出:week14_passes.png(仰角曲線 + pass 甘特)+ 主控台時刻表 + passes.csv
# ================================================================

import numpy as np
import pandas as pd
from datetime import datetime, timedelta, timezone
from sgp4.api import Satrec, jday
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import os

# 產出要落在**週次目錄**,不是 process 的工作目錄。每一支 weekNN_verify.py
# 開頭都有這個慣用法並註明「啟動 STK Engine 會改變 process 的工作目錄」,
# 但 lab 這一側先前沒有套用 —— 結果庫根目錄散落著九個產出,
# 其中一份 week15_launch.png 還是過期的。notebook 的 kernel 沒有 __file__,
# 所以留 WEEK_DIR(notebook 開頭的 bootstrap cell 會設)當後備。
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))

# ---------------- 輸入:TLE + 地面站 ----------------
# 範例 TLE:ISS(可換成你的 CubeSat)。真實任務用 Space-Track/CelesTrak 抓。
TLE_NAME = "ISS (ZARYA)"
TLE1 = "1 25544U 98067A   24001.50000000  .00016717  00000-0  10270-3 0  9008"
TLE2 = "2 25544  51.6416  21.0000 0007000  90.0000 270.0000 15.50000000    05"

# 地面站(清大/新竹附近示範)
GS_NAME = "Hsinchu_GS"
GS_LAT  = 24.7961      # deg
GS_LON  = 120.9967     # deg
GS_ALT  = 0.05         # km
MIN_ELEV = 5.0         # deg,最小可通聯仰角

START = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
DAYS  = 1
STEP_S = 30            # 取樣間隔(秒)

# ---------------- 座標工具 ----------------
RE=6378.137; F=1/298.257223563  # WGS84

def gmst(jd, fr):
    """格林威治平恆星時(rad),供 ECI->ECEF。"""
    T=(jd+fr-2451545.0)/36525.0
    g=280.46061837+360.98564736629*(jd+fr-2451545.0)+0.000387933*T*T-T*T*T/38710000.0
    return np.radians(g % 360.0)

def gs_ecef(lat, lon, alt):
    la,lo=np.radians(lat),np.radians(lon)
    N=RE/np.sqrt(1-F*(2-F)*np.sin(la)**2)
    x=(N+alt)*np.cos(la)*np.cos(lo)
    y=(N+alt)*np.cos(la)*np.sin(lo)
    z=(N*(1-F)**2+alt)*np.sin(la)
    return np.array([x,y,z])

def eci_to_ecef(r_eci, theta):
    c,s=np.cos(theta),np.sin(theta)
    R=np.array([[c,s,0],[-s,c,0],[0,0,1.0]])
    return R@r_eci

def elevation(r_sat_ecef, gs):
    """地面站看衛星的仰角(deg)。"""
    la,lo=np.radians(GS_LAT),np.radians(GS_LON)
    rho=r_sat_ecef-gs
    # ECEF -> 地面站 ENU(topocentric)
    up=np.array([np.cos(la)*np.cos(lo),np.cos(la)*np.sin(lo),np.sin(la)])
    rng=np.linalg.norm(rho)
    return np.degrees(np.arcsin(np.dot(rho,up)/rng)), rng

# ---------------- 計算 ----------------
def main():
    sat=Satrec.twoline2rv(TLE1, TLE2)
    gs=gs_ecef(GS_LAT, GS_LON, GS_ALT)
    n=int(DAYS*86400/STEP_S)
    times=[START+timedelta(seconds=i*STEP_S) for i in range(n)]
    elev=np.full(n, -90.0); rng=np.zeros(n)
    for i,t in enumerate(times):
        jd,fr=jday(t.year,t.month,t.day,t.hour,t.minute,t.second)
        e,r,v=sat.sgp4(jd,fr)
        if e!=0: continue
        theta=gmst(jd,fr)
        r_ecef=eci_to_ecef(np.array(r), theta)
        elev[i],rng[i]=elevation(r_ecef, gs)

    # 抓 pass(仰角 > MIN_ELEV 的連續區間)
    above=elev>=MIN_ELEV
    passes=[]
    i=0
    while i<n:
        if above[i]:
            j=i
            while j<n and above[j]: j+=1
            seg=slice(i,j)
            kmax=i+int(np.argmax(elev[seg]))
            passes.append({
                "AOS": times[i], "LOS": times[j-1],
                "Duration_min": (j-1-i)*STEP_S/60.0,
                "MaxElev_deg": elev[kmax],
                "MaxElev_time": times[kmax],
                "MinRange_km": rng[seg][elev[seg]>=MIN_ELEV].min(),
            })
            i=j
        else:
            i+=1

    df=pd.DataFrame(passes)
    print(f"衛星: {TLE_NAME}  地面站: {GS_NAME} ({GS_LAT},{GS_LON})  仰角門檻 {MIN_ELEV} deg")
    print(f"時段: {START:%Y-%m-%d %H:%M} UTC + {DAYS} day  ->  {len(passes)} passes\n")
    if len(df):
        show=df.copy()
        show["AOS"]=show["AOS"].dt.strftime("%H:%M:%S")
        show["LOS"]=show["LOS"].dt.strftime("%H:%M:%S")
        show["MaxElev_time"]=show["MaxElev_time"].dt.strftime("%H:%M:%S")
        print(show[["AOS","LOS","Duration_min","MaxElev_deg","MinRange_km"]].round(1).to_string(index=False))
        df.to_csv(os.path.join(HERE, "passes.csv"), index=False)
        total=df["Duration_min"].sum()
        print(f"\n每日總通聯時間: {total:.1f} min  |  最高仰角 pass: {df['MaxElev_deg'].max():.1f} deg")

    # ---- 圖 ----
    fig,ax=plt.subplots(2,1,figsize=(11,7),
                        gridspec_kw={"height_ratios":[3,1]})
    tt=[t for t in times]
    ev=np.where(elev<-5,np.nan,elev)
    ax[0].plot(tt,ev,color="tab:blue",lw=0.8)
    ax[0].axhline(MIN_ELEV,color="red",ls="--",lw=1,label=f"min elev {MIN_ELEV} deg")
    for p in passes:
        ax[0].axvspan(p["AOS"],p["LOS"],color="gold",alpha=0.25)
        ax[0].annotate(f"{p['MaxElev_deg']:.0f}",
                       (p["MaxElev_time"],p["MaxElev_deg"]),
                       fontsize=7,ha="center",va="bottom")
    ax[0].set_ylim(0,90); ax[0].set_ylabel("elevation (deg)")
    ax[0].set_title(f"{TLE_NAME} passes over {GS_NAME}")
    ax[0].legend(fontsize=8); ax[0].grid(alpha=0.3)

    # pass 時間線(passes 只有數分鐘,用可見標記 + 標註)
    ax[1].set_xlim(times[0], times[-1]); ax[1].set_ylim(0, 1)
    for p in passes:
        mid = p["AOS"] + (p["LOS"]-p["AOS"])/2
        ax[1].axvspan(p["AOS"], p["LOS"], color="tab:green", alpha=0.5)
        ax[1].plot(mid, 0.5, marker="v", color="tab:green", ms=9)
        ax[1].annotate(f"{p['AOS']:%H:%M}\n{p['Duration_min']:.0f}min\n{p['MaxElev_deg']:.0f}°",
                       (mid, 0.5), fontsize=7, ha="center", va="center",
                       xytext=(0, 14), textcoords="offset points")
    ax[1].set_yticks([]); ax[1].set_xlabel("time (UTC)")
    ax[1].set_title("pass schedule (AOS / duration / max elevation)")
    for a in ax:
        a.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
        a.xaxis.set_major_locator(mdates.HourLocator(interval=2))
    fig.tight_layout(); fig.savefig(os.path.join(HERE, "week14_passes.png"),dpi=130)
    print("Wrote week14_passes.png, passes.csv")

if __name__=="__main__":
    main()
week14_operations/week14_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 14 驗證段 — 通聯窗計算的基準結果
#
# 本週的重點是「Python 的 sgp4 結果要能和 STK access 互相驗證」。
# 先確保 Python 這一側的基準數字穩定,才有資格拿去對照 STK。
#
# 執行:python week14_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():
    import numpy as np
    from datetime import timedelta
    m = load_lab(os.path.join(HERE, "week14_pass_scheduler.py"))
    v = Verifier(None)
    import csv
    rows = list(csv.DictReader(open(os.path.join(HERE, "passes.csv"), encoding="utf-8")))
    v.expect("通聯次數", len(rows), 4, tol=0, unit=" 次")
    dur = [float(r["Duration_min"]) for r in rows]
    elev = [float(r["MaxElev_deg"]) for r in rows]
    v.expect("每日總通聯時間", sum(dur), 22.5, tol=1.0, unit=" min")
    v.expect("最高仰角", max(elev), 73.8, tol=1.0, unit=" deg")
    v.expect_true(f"所有 pass 的最高仰角 >= 門檻 {m.MIN_ELEV} deg",
                  all(e >= m.MIN_ELEV for e in elev), f"最低者 {min(elev):.1f} deg")
    v.expect_true("passes.csv 與腳本設定的地面站一致",
                  abs(m.GS_LAT - 24.7961) < 1e-4 and abs(m.GS_LON - 120.9967) < 1e-4,
                  f"{m.GS_NAME} ({m.GS_LAT}, {m.GS_LON})")

    # ---- 獨立對照:通聯時長的閉合式 ----
    # 上面那幾項(4 次、22.5 min、73.8 deg)都是腳本自己算出來的。
    # 這一項用**完全不同的數學**:腳本是 SGP4 逐點傳播再掃描門檻,
    # 這裡只用 TLE 的平均運動 + 球面幾何的閉合解。
    #
    #   由平均運動反推  a = (mu / n^2)^(1/3)
    #   仰角 E 對應的地心角   gamma(E) = acos(RE cos E / a) - E
    #   通聯時長              T = (P / pi) * acos(cos gamma_max / cos gamma_min)
    #
    # 兩者不會完全相等:閉合式當地球不轉、軌道正圓,而且腳本的取樣間隔
    # STEP_S 本身就把時長量化成 30 s 的倍數。實測最大差 8.5%,
    # 而 6 分鐘那一次光是量化就有 ±8.3% —— 也就是「與取樣精度一樣好」。
    import math
    MU_KM = 398600.4418
    n_rev = float(m.TLE2[52:63])                  # 平均運動 rev/day
    n_rad = n_rev * 2 * math.pi / 86400.0
    sma = (MU_KM / n_rad ** 2) ** (1.0 / 3.0)
    period_s = 2 * math.pi / n_rad

    def geo_angle(elev_deg):
        e = math.radians(elev_deg)
        return math.acos(m.RE * math.cos(e) / sma) - e

    g_max = geo_angle(m.MIN_ELEV)
    worst = (0.0, 0.0, 0.0)                       # (差, 量測, 閉合式)
    for r in rows:
        d_meas = float(r["Duration_min"])
        g_min = geo_angle(float(r["MaxElev_deg"]))
        ratio = max(-1.0, min(1.0, math.cos(g_max) / math.cos(g_min)))
        d_closed = (period_s / math.pi) * math.acos(ratio) / 60.0
        if abs(d_closed - d_meas) > abs(worst[0]):
            worst = (d_closed - d_meas, d_meas, d_closed)
    # 容差 = 取樣量化(STEP_S)+ 10%,對每一次通聯分別判斷
    tol_worst = m.STEP_S / 60.0 + 0.10 * worst[1]
    v.expect_true("每次通聯的時長對得上球面幾何的閉合式",
                  abs(worst[0]) <= tol_worst,
                  f"最差:量測 {worst[1]:.1f} min vs 閉合式 {worst[2]:.1f} min"
                  f"(差 {worst[0]:+.2f},容許 {tol_worst:.2f})")
    v.expect("由 TLE 反推的軌道高度", sma - m.RE, 416.7, tol=5.0, unit=" km")

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


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