Week 3 — 系統工程

資料夾:week03_budgets/

基礎知識導論

Week 3 基礎知識導論 — 任務預算表與餘裕管理

預算表 (budget) 是系統工程的控制器,不是報表。
它的用途是:任何一個設計變更發生時,在幾秒內知道有沒有東西被撞破。

1. 這一週在解什麼問題

四張表:質量、功率、鏈路、資料。每一張的結構都一樣:

需求(上限或下限)  <-->  目前設計值  <-->  餘裕 (margin)

本週的技能不是「會加總」,而是:
- 知道每一項的數字從哪裡來(哪一週的 STK 結果?哪一份規格書?還是猜的?)
- 知道改動一個數字會沿著耦合鏈傳到哪裡
- 知道餘裕該留多少、什麼時候可以收掉

2. 核心觀念

2.1 餘裕的定義與慣例

margin = (能力 - 需求) / 需求 x 100%

設計成熟度愈低,餘裕要愈大。業界常見的階段性慣例(量級參考,實際依專案規範):

階段 質量餘裕 功率餘裕
概念 / Pre-Phase A 25–30% 30% 以上
PDR 20% 20–25%
CDR 10–15% 15%
交付 0–5%(改用實測值) 5–10%

餘裕不是保守,是把「你還不知道的事」寫成數字。 餘裕被吃光時,
代表未知已經變成已知 —— 這時候要嘛設計凍結,要嘛承認風險。

2.2 質量預算

2.3 功率預算(最容易做錯的一張)

功率預算必須沿著 CONOPS 的時間軸做,不能只做一個「平均功耗」:

   一圈發電量                        一圈耗電量
∫(一圈) P_gen dt      >=      Σ_i ( P_i x D_i ) x T_orbit   +   充放電損失

其中 D_i 是第 i 個負載的 duty cycle。三個常見陷阱:

  1. 忘了地影:LEO 一圈約 90–100 分鐘,地影可佔 30–40%,發電只在日照段發生。
  2. 忘了 cosine loss:太陽能板不會永遠正對太陽,P = η x A x S x cos(θ)。
  3. 忘了電池 DOD 限制:鋰電池若要撐 2 年(約 1 萬圈),每圈 DOD 通常壓在 10–20%。
    這會讓所需電池容量遠大於「一圈用電量」。

2.4 鏈路預算(Week 11 的預告)

以 dB 表示的一串加減:

Eb/N0 [dB] = EIRP - L_FS - L_atm - L_point + G/T - k - 10*log10(Rb)

2.5 資料預算

每日產生量  <=  每日可下傳量

2.6 預算表之間是連動的

成像次數↑ → 資料量↑ → 需要更高下傳速率 → 發射功率↑ → 功率預算↑
                                      → 電池↑ → 質量↑ → 可能超出 6U 上限
                    → 或改成更高增益天線 → 指向精度需求↑ → ADCS 規格↑ → 功率↑

這就是為什麼四張表要放在同一支程式裡。 分開做的預算表一定會不一致。

3. 名詞與符號

符號 意義 單位 本課典型值
S 太陽常數 W/m² 1361
η 太陽能電池效率 — 0.28–0.32(三接面)
D duty cycle — 0–1
DOD 放電深度 % 每圈 10–20%
EIRP 等效全向輻射功率 dBW —
G/T 接收品質因數 dB/K 欄位可能吃線性比值,見誤解 4
Eb/N0 每位元能量對雜訊功率密度比 dB 門檻約 9.6(未編碼 BPSK)
Rb 位元率 bit/s —
GSD 地面解析度 m 30
margin 餘裕 % 見 2.1

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
四表結構 week3_mission_budgets.py,四個資料表
餘裕與 pass/fail 每張表末尾的判定;改數字看判定翻轉
表與表連動 改成像速率 → 資料表 → 鏈路表 → 功率表
數字來源可追溯 Week 7 / 11 / 12 的 STK 結果會回填進這裡
視覺化 week3_budgets.png 四宮格

5. 常見誤解

  1. 「先算平均功耗就好。」 — 平均過得去,但地影段電池撐不住,一樣是失敗。
    功率預算必須是時間函數。
  2. 「餘裕留著好看。」 — 餘裕是資源,遲早要花掉。重點是有意識地花,
    並在每次設計評審記錄「這次花掉多少、換到什麼」。
  3. 「壓縮可以解決資料量問題。」 — 有損壓縮直接影響科學產出。
    壓縮比必須由酬載需求決定,不能由下傳能力反推。
  4. 「dB 可以直接相乘。」 — dB 是對數,加減對應線性的乘除。
    本教材的 Week 11 就踩過 Model.GOverT -10 的坑 —— 那個欄位吃的是線性比值,
    −10 dB 要寫成 0.1。

6. 延伸閱讀

系統工程與餘裕管理

把「猜的數字」換成「查到的數字」

Python 工具

7. 課前自我檢核

  1. 你的功率預算裡,地影時間是從哪裡來的?是估的還是 STK 算的?
  2. 電池容量若只依「一圈用電量」決定,兩年後會發生什麼事?
  3. 成像速率提高 2 倍,四張表分別會怎麼動?哪一張會先破?
  4. 「餘裕 15%」在 PDR 階段代表健康還是危險?判斷依據是什麼?
  5. 為什麼 G/T 欄位填 −10 會是錯的?正確值是多少?

Week3_6_14_OpenSource_Lab_Handouts.md

開源補位週次實驗單:Week 3 / 6 / 14

STK 做不到的三週,用 Python/開源補上。每份都是 numpy/scipy/matplotlib 即可跑,
Week 6 與 Week 14 另有吃 STK 輸出 / 與 STK 交叉驗證的接口。


Week 3 — 系統工程:任務預算表 (Python/pandas)

檔案:week3_mission_budgets.py → week3_budgets.png + budgets_mass.csv

概念:系統工程的核心是「預算 + margin 管理」,不是物理模擬。物理量由各週工具算
(STK 給 link/power/data、FEA 給結構),這裡彙整成系統級四大預算並管 margin。

四大預算:
- 質量:各子系統質量 × (1+margin),對照 1U 限制。
- 功率:各模式負載 × 占空比 → 平均負載,對照 STK 發電。
- 鏈路:各項 dB 累加 → Rx power,對照靈敏度,餘裕 ≥ 3 dB。
- 資料:每日產生 vs 下傳(來自 STK access)。

操作:改各表數字 → 看 pass/fail 如何翻轉;把 Week 7/11/12 的 STK 結果填進對應格。
交付:四大預算表 + margin 分析 + 哪個預算最緊。
評分:四表正確 40% / margin 管理 30% / 與 STK 結果串接 30%。


Week 6 — 節點熱網路 (Python + STK 日照時間線)

檔案:week6_thermal_network.py → week6_thermal.png

分工:STK 給「何時日照/地影」(Lighting 時間線),Python 解「節點溫度」。

模型:6 面 + 1 內部節點集總參數熱網路,每節點能量平衡(太陽/反照/地球紅外/內部
發熱/輻射散熱/節點傳導),日照期太陽輸入開、地影期關。

操作:
1. 直接跑(內建 beta-angle 幾何產生日照率)——基準案例電池會低於 0°C(FAIL)。
2. Part B:改塗層 α/ε、內部發熱、beta angle,讓內部節點守住工作區間。
3. Part C:在 STK 對衛星出 Sunlight 時間線 CSV,load_stk_lighting() 載入,用真實
軌道日照重算,對比內建幾何。

交付:溫度曲線 + 電池工作區間 pass/fail + 熱控建議(加熱器/隔熱/塗層)。
評分:基準判讀 25% / 熱控迭代 30% / STK 日照接入 30% / 討論 15%。


Week 14 — 地面站通聯排程 (sgp4,與 STK access 交叉驗證)

檔案:week14_pass_scheduler.py → week14_passes.png + passes.csv

概念:運作階段從 TLE 算對地面站的通聯窗(rise/set/最大仰角),純 sgp4 不需下載星曆。
可與 STK access 互相驗證——同衛星同地面站,兩者算的 pass 應一致。

操作:
1. 改 TLE 為你的 CubeSat(Space-Track/CelesTrak 抓)、改地面站經緯度。
2. 跑出未來 N 天的 pass 時刻表(AOS/LOS/時長/最大仰角/最近距離)。
3. Part B(交叉驗證):在 STK 對同一顆衛星、同一地面站算 access,比對 Python 結果。
4. 排一天的下傳排程,對照 Week 12 的資料預算(通聯總時間夠不夠清空儲存)。

交付:pass 時刻表 + 仰角曲線圖 + 與 STK access 的比對誤差。
評分:pass 計算 30% / 時刻表 25% / STK 交叉驗證 30% / 與資料預算串接 15%。

圖


week03_budgets/week3_budgets.png

程式碼

week03_budgets/week03_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 3 驗證段 — 四大預算的基準結果與 PASS/FAIL 判定
#
# 實驗單寫死了四張預算表的基準數字與 pass/fail。教材裡最容易悄悄過期的就是這種數字:
# 改了假設卻忘了改實驗單,學生跑出來對不上就會以為是自己弄錯。
# 這裡直接 import 實驗腳本、斷言它算出來的值,而不是解析主控台輸出。
#
# 執行:python week03_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():
    m = load_lab(os.path.join(HERE, "week3_mission_budgets.py"))
    v = Verifier(None)          # 純數值檢查,不需要 STK

    # (1) 質量預算
    total_mass = float(m.mass["Mass_kg"].sum())
    with_margin = float((m.mass["Mass_kg"] * (1 + m.mass["Margin_pct"] / 100)).sum())
    v.expect_true(f"含 margin 質量 <= 1U 限制 {m.MASS_LIMIT} kg", with_margin <= m.MASS_LIMIT,
                  f"小計 {total_mass:.3f} kg,含 margin {with_margin:.3f} kg")

    # (2) 功率預算:軌道平均負載必須低於 STK Solar Panel 給的發電量
    v.expect("平均負載", m.AVG_LOAD, 1.93, tol=0.05, unit=" W")
    v.expect("軌道平均發電", m.GEN_ORBIT_AVG, 3.20, tol=0.01, unit=" W")
    v.expect_true("功率收支 PASS", m.AVG_LOAD < m.GEN_ORBIT_AVG,
                  f"餘裕 {m.GEN_ORBIT_AVG - m.AVG_LOAD:+.2f} W")

    # (3) 鏈路預算:餘裕須 >= 需求
    rx = float(m.link["dB"].sum())
    margin = rx - m.RX_SENS
    v.expect("接收功率", rx, -101.5, tol=0.2, unit=" dBm")
    v.expect("鏈路餘裕", margin, 8.5, tol=0.2, unit=" dB")
    v.expect_true(f"餘裕 >= 需求 {m.REQ_MARGIN} dB", margin >= m.REQ_MARGIN)

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


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
week03_budgets/week3_mission_budgets.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 3 Lab - 系統工程:任務預算表(mass / power / link / data)
#
# 系統工程的核心不是物理模擬,是「預算 + margin 管理」。
# 各子系統的物理量由別週的工具算出(STK 算 link/power/data、
# FEA 算結構),這裡把它們彙整成帶 margin 的系統級預算表。
#
# 輸出:week3_budgets.png(四張預算圖)+ 主控台表格 + budgets.csv
# ================================================================

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
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()))

# ---------------- 1. 質量預算(Mass budget)----------------
# 每列:子系統, 質量(kg), margin(%)
mass = pd.DataFrame([
    ("Structure",     0.30, 5),
    ("EPS (battery+panels)", 0.25, 10),
    ("ADCS",          0.20, 10),
    ("OBC",           0.08, 10),
    ("COMM",          0.12, 10),
    ("Payload (imager)", 0.30, 15),
    ("Harness/misc",  0.10, 20),
], columns=["Subsystem","Mass_kg","Margin_pct"])
mass["Mass_with_margin"] = mass["Mass_kg"]*(1+mass["Margin_pct"]/100)
MASS_LIMIT = 2.00   # 1U 限制(含裕度)

# ---------------- 2. 功率預算(Power budget)----------------
# 每列:模式負載(W);再比較平均消耗 vs 發電(來自 STK Solar Panel Tool)
power_loads = pd.DataFrame([
    ("Idle/holding",      0.75, 0.40),   # (W, 每軌占空比)
    ("Imaging",           4.00, 0.15),
    ("Downlink (Tx)",     6.75, 0.10),
    ("ADCS active",       1.00, 0.35),
], columns=["Mode","Load_W","DutyCycle"])
power_loads["AvgContribution_W"] = power_loads["Load_W"]*power_loads["DutyCycle"]
AVG_LOAD = power_loads["AvgContribution_W"].sum()
GEN_ORBIT_AVG = 3.2   # W,來自 STK Solar Panel Tool 的軌道平均發電(FIREYE_SolarPanel)

# ---------------- 3. 鏈路預算(Link budget)- 下行 UHF ----------------
# 各項 dB(來自 STK Comm / 手算),最後看 margin >= 3 dB
link = pd.DataFrame([
    ("Tx power (2 W)",            33.0),   # dBm
    ("Tx line loss",             -1.0),
    ("Tx antenna gain",           2.0),
    ("Free-space path loss",   -145.0),
    ("Atmospheric loss",         -1.5),
    ("Polarization loss",        -3.0),
    ("Rx antenna gain",          15.0),
    ("Rx line loss",             -1.0),
], columns=["Item","dB"])
RX_SENS = -110.0   # dBm 接收靈敏度(含所需 Eb/No)
REQ_MARGIN = 3.0

# ---------------- 4. 資料預算(Data budget)- 每日 ----------------
# 產生 vs 下傳(來自 STK access + storage_vs_time.py)
IMG_RATE_MBIT_S = 5.0
IMG_TIME_S_DAY  = 6*60          # 每日成像秒數(來自 STK 成像 access)
DL_RATE_MBIT_S  = 10.0*0.90     # 扣 10% overhead
DL_TIME_S_DAY   = 4*8*60        # 每日下傳秒數(4 passes x 8 min,來自 STK)

# ================================================================
def main():
    # ---- 質量 ----
    m_total = mass["Mass_with_margin"].sum()
    print("=== MASS BUDGET ===")
    print(mass.to_string(index=False))
    print(f"Total (with margin): {m_total:.3f} kg / limit {MASS_LIMIT} kg -> "
          f"{'PASS' if m_total<=MASS_LIMIT else 'FAIL'}\n")

    # ---- 功率 ----
    print("=== POWER BUDGET ===")
    print(power_loads.to_string(index=False))
    margin_pwr = GEN_ORBIT_AVG - AVG_LOAD
    print(f"Avg load {AVG_LOAD:.2f} W vs generation {GEN_ORBIT_AVG:.2f} W -> "
          f"margin {margin_pwr:+.2f} W  {'PASS' if margin_pwr>0 else 'FAIL'}\n")

    # ---- 鏈路 ----
    rx_power = link["dB"].sum()
    link_margin = rx_power - RX_SENS
    print("=== LINK BUDGET (downlink) ===")
    print(link.to_string(index=False))
    print(f"Rx power {rx_power:.1f} dBm, sensitivity {RX_SENS} dBm -> "
          f"margin {link_margin:.1f} dB (req {REQ_MARGIN}) "
          f"{'PASS' if link_margin>=REQ_MARGIN else 'FAIL'}\n")

    # ---- 資料 ----
    gen_day = IMG_RATE_MBIT_S*IMG_TIME_S_DAY/8/1000   # GB/day
    dl_day  = DL_RATE_MBIT_S*DL_TIME_S_DAY/8/1000
    print("=== DATA BUDGET (per day) ===")
    print(f"Generated {gen_day:.2f} GB/day, downlinked {dl_day:.2f} GB/day -> "
          f"balance {dl_day-gen_day:+.2f} GB "
          f"{'PASS' if dl_day>=gen_day else 'FAIL (backlog)'}\n")

    # ---- 存 CSV ----
    mass.to_csv(os.path.join(HERE, "budgets_mass.csv"), index=False)

    # ---- 四張圖 ----
    fig, ax = plt.subplots(2, 2, figsize=(13, 9))

    ax[0,0].barh(mass["Subsystem"], mass["Mass_with_margin"], color="tab:blue")
    ax[0,0].axvline(MASS_LIMIT, color="red", ls="--", label=f"limit {MASS_LIMIT} kg")
    ax[0,0].set_xlabel("mass with margin (kg)")
    ax[0,0].set_title(f"Mass budget (total {m_total:.2f} kg)"); ax[0,0].legend(fontsize=8)

    ax[0,1].bar(power_loads["Mode"], power_loads["AvgContribution_W"], color="tab:orange")
    ax[0,1].axhline(GEN_ORBIT_AVG, color="green", ls="--", label=f"gen {GEN_ORBIT_AVG} W")
    ax[0,1].set_ylabel("avg power (W)"); ax[0,1].set_title(f"Power (avg load {AVG_LOAD:.2f} W)")
    ax[0,1].tick_params(axis="x", rotation=20); ax[0,1].legend(fontsize=8)

    cum = np.cumsum(link["dB"].values)
    ax[1,0].step(range(len(link)+1), [0]+list(cum), where="post", color="tab:purple")
    ax[1,0].axhline(RX_SENS, color="red", ls="--", label=f"sensitivity {RX_SENS} dBm")
    ax[1,0].set_xticks(range(len(link)))
    ax[1,0].set_xticklabels(link["Item"], rotation=40, ha="right", fontsize=7)
    ax[1,0].set_ylabel("cumulative (dBm)")
    ax[1,0].set_title(f"Link budget (margin {link_margin:.1f} dB)"); ax[1,0].legend(fontsize=8)

    ax[1,1].bar(["Generated","Downlinked"], [gen_day, dl_day],
                color=["tab:red","tab:green"])
    ax[1,1].set_ylabel("GB / day")
    ax[1,1].set_title(f"Data budget (balance {dl_day-gen_day:+.2f} GB/day)")

    fig.suptitle("Week 3 - Mission Budgets (mass / power / link / data)", fontsize=13)
    fig.tight_layout(rect=[0,0,1,0.97])
    fig.savefig(os.path.join(HERE, "week3_budgets.png"), dpi=130)
    print("Wrote week3_budgets.png, budgets_mass.csv")

if __name__ == "__main__":
    main()