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*)。
三個必須記住的限制:
- TLE 是 SGP4 專用的平均根數,不是瞬時的物理根數。
直接餵給 HPOP 或兩體傳播器會有系統性偏差(Week 1、4 都提過)。 - 精度隨時間衰減:LEO 的 TLE 在曆元附近誤差約 1 km,
一週後可能到 10 km 以上,主要來自大氣阻力的不確定性。
要定期更新 TLE,不能用一組算一整年。 - 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 不是每個窗都能用
最大仰角低的過境幾乎沒有價值:
- 斜距長 → 自由空間損耗大(Week 11 的 2.2,可差 11 dB)
- 持續時間短
- 低仰角穿過更多大氣層 → 額外衰減
- 可能被地形或建物遮擋
實務上會設一個可用仰角門檻(例如最大仰角 > 20°)來篩選。
本週的排程要把這個篩選做出來,並看它砍掉多少窗。
2.5 排程是一個最佳化問題
給定一堆通聯窗與一堆下傳需求,要決定「哪些資料在哪個窗傳」。約束:
- 容量:每個窗能傳的量有限(Week 12 的 2.2)
- 優先權:緊急的火山活動資料要先傳
- 延遲需求:12 小時內必須送達
- 地面站衝突:同一個地面站不能同時服務兩顆衛星
- 能源:發射機耗電,要跟電池狀態(Week 7)協調
單一地面站的情形可以用貪婪法(先到先服務、或按優先權排序)得到夠好的解。
多站多星的情形是 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. 常見誤解
- 「TLE 可以當初始根數丟給任何傳播器。」 — 不行。TLE 與 SGP4 是一組。
- 「一組 TLE 可以用一整年。」 — LEO 的 TLE 幾天就會明顯偏離。要定期更新。
- 「每天 6 次通聯就是 6 次可用機會。」 — 低仰角的過境鏈路餘裕不足,實際可用的少很多。
- 「Python 跟 STK 差 30 秒,一定有 bug。」 — 幾秒到十幾秒可能來自模型差異(見 2.6)。
差到分鐘以上才是 bug。要先量化每個來源的量級。 - 「排程就是把窗填滿。」 — 還要考慮優先權、延遲需求、電池狀態與地面站衝突。
6. 延伸閱讀
TLE 與 SGP4(這一節的每個連結都值得點)
- CelesTrak — TLE 的主要公開來源,由 T.S. Kelso 維護。
- CelesTrak 技術專欄
— 講 TLE 該怎麼用、不該怎麼用,是本主題最好的中文以外資源。 - CelesTrak NORAD 文件 — TLE 格式的權威說明。
- Vallado 等, "Revisiting Spacetrack Report #3" (AIAA 2006-6753)
— SGP4 的權威論文與參考實作原始碼。強烈建議至少讀前 10 頁。 - Space-Track.org — 美國官方的軌道資料來源(需註冊)。
Python 工具
- sgp4 (PyPI) — 本週使用的套件,Vallado 參考實作的移植。
- Skyfield
— 更高階的介面,自動處理座標轉換與時間系統,適合交叉驗證。 - Astropy — 時間系統(UTC/UT1/TT)與座標框架的標準工具。
地面站與運作
- SatNOGS 開源地面站網路
— 看真實的通聯排程與接收結果,可以直接跟你算的窗對照。 - Gpredict — 開源追蹤軟體,可拿來對 AOS/LOS。
- N2YO 即時追蹤 — 快速查任一顆衛星的下次過境。
- AMSAT — 業餘衛星的運作社群與工具。
7. 課前自我檢核
- TLE 為什麼不能餵給 HPOP?會產生什麼量級的誤差?
- 550 km 的衛星對中緯度地面站,每天大約幾次通聯?每次多久?
- 最大仰角 8° 的過境跟 80° 的過境,鏈路餘裕差多少 dB?哪一個實際可用?
- 你的 Python 與 STK 算出的 AOS 差 12 秒。列出三個可能來源,並排出量級順序。
- 排程時只考慮「窗有多長」而忽略電池狀態,可能出什麼事?
圖

week14_operations/week14_passes.png
程式碼
# -*- 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()
# -*- 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)