Week 6 — 熱管理與環境

資料夾:week06_thermal/

基礎知識導論

Week 6 基礎知識導論 — 太空熱環境與節點熱網路

太空裡沒有空氣,所以沒有對流。熱只能靠傳導走、靠輻射離開。
這一句話決定了整個熱控設計的形狀。

1. 這一週在解什麼問題

FIREYE 繞地球一圈約 95 分鐘,其中約三分之一在地影。
在日照與地影之間反覆切換,衛星每一面的溫度會怎麼擺動?
電池會不會低於 0 °C?酬載會不會超過容許上限?

本週用一個節點熱網路 (lumped-node thermal network) 模型回答,
並且把 STK 算出來的真實日照 / 地影時間線接進來當熱輸入的開關。

2. 核心觀念

2.1 太空中的三個外部熱輸入

熱源 量級 特性
直射太陽 太陽常數 S = 1361 W/m²(地球附近) 只在日照段;隨面的朝向有 cos 因子
地球反照 (albedo) S x a,反照率 a 約 0.30(地表條件下 0.2–0.4) 只在日照的地球上方
地球紅外 (Earth IR) 約 230–240 W/m² 全時段都有,包含地影

再加上內部電子元件發熱 Q_int。
地影裡只剩地球紅外與內熱,所以溫度會掉。

2.2 唯一的散熱路徑:輻射

Stefan–Boltzmann:

Q_rad = ε * σ * A * ( T⁴ - T_space⁴ )        σ = 5.670e-8 W/(m²·K⁴)
T_space 約 2.7 K,T_space⁴ 通常可忽略

注意 T⁴:溫度稍微上升,散熱能力就大幅增加。
這讓太空熱系統天然穩定,但也表示低溫端很難靠輻射自救 —— 冷了只能靠加熱器。

2.3 α/ε 比:表面處理就是熱設計

平衡溫度大致由 α/ε 決定:

ε σ A_rad T⁴  =  α A_sun S + ...        =>        T ∝ (α/ε)^(1/4)
表面 α ε α/ε 用途
拋光鋁 約 0.15 約 0.05 3.0 保溫(會很熱)
白漆 約 0.20 約 0.90 0.22 散熱面
黑漆 約 0.95 約 0.90 1.06 均溫
OSR / 二次表面鏡 約 0.08 約 0.80 0.10 高效散熱器
MLI(多層隔熱毯) 等效發射率很低 — — 隔絕

CubeSat 的熱控多半是被動的:選塗層、選散熱面朝向、加隔熱、加熱器。
沒有空間放主動迴路。

2.4 節點熱網路

把衛星切成 N 個等溫節點(六個面 + 內部),每個節點一條 ODE:

C_i * dT_i/dt  =  Q_solar,i + Q_albedo,i + Q_IR,i + Q_int,i
                  - ε_i * σ * A_i * T_i⁴
                  + Σ_j  k_ij * ( T_j - T_i )        節點間傳導

這是一組耦合的非線性 ODE(因為 T⁴),用 scipy.integrate.solve_ivp 積分。

物理直覺:熱容大 → 擺動小、跟不上光照變化;熱導大 → 各節點溫度接近。
熱控設計的兩個旋鈕,基本上就是這兩個。

2.5 為什麼一定要接 STK 的日照時間線

日照 / 地影的切換時刻取決於軌道幾何,而且不是每圈都一樣:
軌道面相對太陽的夾角(beta angle)會隨 RAAN 漂移而變化,
在某些季節甚至會出現全日照(no eclipse)期間。

用「假設地影佔 35%」這種平均值做出來的熱分析,會錯過:

熱設計是由最壞工況 (worst hot / worst cold case) 決定的,不是平均工況。
所以必須拿 STK 的真實時間線。

2.6 溫度需求(典型值,實際依元件規格)

元件 工作溫度 存活溫度
鋰電池 0 至 +40 °C(充電下限尤其嚴) −20 至 +60 °C
電子板 (COTS) −20 至 +60 °C −40 至 +85 °C
太陽能電池 −100 至 +100 °C 更寬
光學酬載 依設計,常需 ±2 °C 穩定度 —

電池幾乎永遠是熱設計的驅動元件。
本週的基準案例就是電池 FAIL(掉到 0 °C 以下)。

3. 名詞與符號

符號 意義 單位 值
S 太陽常數 W/m² 1361
a 地球反照率 — 約 0.30
q_IR 地球紅外通量 W/m² 約 230–240
σ Stefan–Boltzmann 常數 W/(m²·K⁴) 5.670e-8
α 太陽吸收率 — 見 2.3
ε 紅外發射率 — 見 2.3
C 熱容 J/K m * c_p
k 節點間熱導 W/K —
β beta angle,軌道面與太陽夾角 ° 決定地影長度
MLI 多層隔熱毯 — —

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
六面 + 內部節點 ODE week6_thermal_network.py
日照開關 Part C 讀入 STK 的日照時間線
基準 FAIL 電池低於 0 °C
熱控迭代 Part B 改塗層 / 熱容 / 熱導
結果圖 week6_thermal.png
把手冊數字變成斷言 week06_verify.py

5. 常見誤解

  1. 「太空很冷,所以衛星會凍住。」 — 真空沒有溫度。衛星溫度由輻射平衡決定,
    曬到太陽的面可能超過 100 °C。「冷」與「熱」在同一顆衛星上同時存在。
  2. 「加散熱片就能散熱。」 — 真空中散熱片只有能輻射出去時才有用,
    而且要看得到冷空(不能對著地球、也不能對著自己)。傳導路徑不通,散熱片就是裝飾。
  3. 「用平均地影比例就好。」 — 見 2.5,熱設計看的是極端工況。
  4. 「黑色比較會散熱。」 — 黑色 α 高 ε 也高,α/ε 約 1,是「均溫」不是「散熱」。
    真正的散熱面要 α 低而 ε 高(白漆、OSR)。
  5. 「熱模型是熱工程師的事。」 — 熱與功率(Week 7)、軌道(Week 4)、
    CONOPS(Week 2)全部耦合。改一個 duty cycle 就是改熱模型的輸入。

6. 延伸閱讀

太空熱控

太空環境

數值方法與工具

7. 課前自我檢核

  1. 為什麼太空中沒有對流?這對散熱設計造成什麼限制?
  2. α/ε = 0.2 與 α/ε = 3.0 的兩個表面,哪一個比較熱?溫度差幾倍?(用四次方根估)
  3. 熱容加倍,溫度擺動幅度大約變成幾倍?相位會怎麼變?
  4. 什麼情況下衛星會進入「全日照」?那是最熱工況還是最冷工況?
  5. 你的熱模型若用「地影佔 35%」的平均值,會漏掉哪兩個關鍵工況?

圖


week06_thermal/week6_thermal.png

程式碼

week06_thermal/week06_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 6 驗證段 — 節點熱網路的基準溫度與電池 FAIL
#
# 本週同樣是「基準 FAIL → 熱控迭代」。電池低於 0 degC 這個結論是 Part B 的起點,
# 而它取決於 beta angle、面積、發射率等一串假設 —— 任何一個被改動都會讓結論翻轉。
#
# 執行:python week06_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 scipy.integrate import solve_ivp
    m = load_lab(os.path.join(HERE, "week6_thermal_network.py"))
    v = Verifier(None)

    v.expect("軌道週期", m.PERIOD / 60.0, 96.7, tol=0.5, unit=" min")

    # 完全照實驗腳本 main() 的積分設定:T0 = 10 degC、跑三圈、取全段範圍。
    # 自己另訂初始條件或積分區間,算出來的數字就不會是實驗單上的那組。
    sol = solve_ivp(m.thermal_rhs, [0, 3 * m.PERIOD], m.T0,
                    t_eval=np.linspace(0, 3 * m.PERIOD, 1500),
                    method="LSODA", rtol=1e-6, atol=1e-6)
    T = sol.y - 273.15
    batt_min, batt_max = float(T[6].min()), float(T[6].max())
    v.expect("電池最低溫", batt_min, -24.5, tol=2.0, unit=" degC")
    v.expect("電池最高溫", batt_max, 19.8, tol=2.0, unit=" degC")
    v.expect_true("基準應為 FAIL(電池低於 0 degC)", batt_min < 0.0,
                  f"工作區間 0..40 degC,實測 {batt_min:.1f}..{batt_max:.1f}")
    v.expect_true("六個面溫度都在合理範圍(-60..+80 degC)",
                  bool(T[:6].min() > -60 and T[:6].max() < 80),
                  f"面板 {T[:6].min():.1f}..{T[:6].max():.1f} degC")

    # ---- 兩項獨立對照 ----
    # 上面那幾項是拿模型的輸出釘模型自己(-24.5、19.8 都是它算出來的)。
    # 下面兩項不一樣。

    def fluxes(tt, Tcol):
        """把 thermal_rhs 的熱流重新算一次:(吸收總功率, 輻射總功率)。

        傳導是內部項,對整顆衛星相消,所以不必算。
        """
        sun = m.is_sunlit(tt)
        s = m.sun_dir_body(tt)
        qi, qo = m.Q_INT, 0.0
        for i in range(6):
            cs = max(0.0, float(np.dot(m.NORMALS[i], s)))
            if sun:
                qi += m.ALPHA * m.SOLAR * m.FACE_A * cs
                qi += m.ALPHA * m.ALBEDO * m.SOLAR * m.FACE_A * m.q_earth_view * 0.5
            qi += m.EPS * m.EARTH_IR * m.FACE_A * m.q_earth_view
            qo += m.EPS * m.SIGMA * m.FACE_A * (Tcol[i] ** 4 - m.T_SPACE ** 4)
        return qi, qo

    last = sol.t >= 2 * m.PERIOD          # 最後一整圈
    tl, Yl = sol.t[last], sol.y[:, last]
    qin = np.array([fluxes(tt, Yl[:, k])[0] for k, tt in enumerate(tl)])
    qout = np.array([fluxes(tt, Yl[:, k])[1] for k, tt in enumerate(tl)])
    trapz = getattr(np, "trapezoid", None) or np.trapz
    e_in, e_out = float(trapz(qin, tl)), float(trapz(qout, tl))
    # 內能的變化必須等於淨熱流。這是**積分器必須滿足的守恆律**,
    # 與溫度值本身無關 —— 節點熱容寫錯、傳導項符號反了、積分沒收斂,
    # 都會讓它對不起來。實測殘差 0.11%(限制因素是梯形積分的取樣密度,
    # 1500 點 0.11% / 3000 點 0.02%,不是積分容差)。
    d_u = float(sum(m.C[i] * (Yl[i, -1] - Yl[i, 0]) for i in range(7)))
    resid = abs(e_in - e_out - d_u) / e_in * 100.0
    v.expect_true("整圈能量守恆:淨熱流 = 內能變化(殘差 < 0.5%)",
                  resid < 0.5,
                  f"吸收 {e_in:.0f} J、輻射 {e_out:.0f} J、"
                  f"內能變化 {d_u:+.0f} J -> 殘差 {resid:.3f}%")

    # 第二項才是真正獨立於積分的:**解析的輻射平衡溫度**。
    # 整圈平均吸收 Qbar 必須由六個面在某個溫度輻射掉,
    #     Qbar = 6 eps sigma A (T_eq^4 - T_space^4)
    # 這是閉合解、不經積分。要比的是六面的 **T^4 平均開四次方**
    # (不是溫度平均 —— 輻射走四次方,平均不能先開再算)。
    q_bar = float(trapz(qin, tl) / (tl[-1] - tl[0]))
    t_eq = (q_bar / (6 * m.EPS * m.SIGMA * m.FACE_A) + m.T_SPACE ** 4) ** 0.25
    t4_root = float((Yl[:6] ** 4).mean()) ** 0.25
    v.expect("六面 (T^4 平均)^(1/4) 對上解析輻射平衡溫度",
             t4_root - 273.15, t_eq - 273.15, tol=0.5, unit=" degC")

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


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)
week06_thermal/week6_thermal_network.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 6 Lab - CubeSat 節點熱網路(吃 STK 日照/地影時間線)
#
# 分工:STK 給「軌道熱幾何」(何時日照、何時地影),
#       Python 解「熱響應」(節點溫度隨軌道的變化)。
#
# 模型:6 面 + 1 內部節點(電池/電子)的集總參數熱網路。
#   每個節點能量平衡:
#     C_i dT_i/dt = Q_solar_i + Q_albedo_i + Q_earthIR_i + Q_internal_i
#                   - Q_rad_i(對太空)  + sum_j K_ij (T_j - T_i)  (節點間傳導)
#   日照期 Q_solar 開,地影期關 —— 這個開關就來自 STK 的 Lighting 時間線。
#
# 輸入:STK 匯出的 Lighting/Access 報告 CSV(Sunlight 區間),或無檔時
#       用內建 beta-angle 幾何自動產生日照率(Lab 仍可完整執行)。
#
# 輸出:week6_thermal.png + 主控台摘要(各節點溫度範圍、是否超工作區間)
# ================================================================

import os
import re
import numpy as np
from scipy.integrate import solve_ivp
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

# 產出要落在**週次目錄**,不是 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()))

SIGMA = 5.670374419e-8   # Stefan-Boltzmann
T_SPACE = 2.7            # K, 深太空
SOLAR   = 1361.0        # W/m^2 太陽常數
ALBEDO  = 0.30          # 地球反照率
EARTH_IR = 237.0        # W/m^2 地球紅外
q_earth_view = 0.3      # 地球視角因子(簡化)

# ---- 軌道(SSO ~600 km)----
MU=3.986004418e14; RE=6378.137e3
ALT=600e3; SMA=RE+ALT
N_ORB=np.sqrt(MU/SMA**3); PERIOD=2*np.pi/N_ORB
BETA = np.radians(20.0)   # beta angle(太陽與軌道面夾角),決定日照率

# ---- 1U 幾何/熱物性(6 面 + 內部)----
FACE_A = 0.01            # m^2 每面面積(0.1x0.1)
ALPHA  = 0.65            # 吸收率(表面塗層)
EPS    = 0.85            # 發射率
# 各節點熱容 C = m*cp(J/K):6 面板 + 1 內部塊
C_FACE = 0.05*900        # 50 g 鋁面 * cp
C_INT  = 0.30*800        # 300 g 內部(電池+PCB)
C = np.array([C_FACE]*6 + [C_INT])
# 內部發熱(W):平均電子耗散,集中在內部節點
Q_INT = 2.0
# 節點間傳導 K(W/K):每面板 <-> 內部(結構+線束的等效傳導)
K_FACE_INT = 0.2
# 面板法向(±X,±Y,±Z),用於算對太陽/地球的投影
NORMALS = np.array([[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1.0]])

T0 = np.full(7, 273.15+10)   # 初始 10 degC

# ---------------- 日照率:STK 時間線 或 內建幾何 ----------------
_SUN_INTERVALS = None   # [(start_sec, stop_sec), ...] 相對模擬起點

def load_stk_lighting(csv_path):
    """讀 STK 匯出的 Sunlight 區間(Start/Stop 時間欄)。
    在 STK:對衛星出 'Lighting Times' 報告(Sunlight),存成 CSV/txt。
    這裡用寬鬆解析:每列抓兩個 STK 日期,轉成相對秒。"""
    global _SUN_INTERVALS
    date_re = re.compile(r"(\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)")
    from datetime import datetime
    def p(s):
        for f in ("%d %b %Y %H:%M:%S.%f","%d %b %Y %H:%M:%S"):
            try: return datetime.strptime(s,f)
            except: pass
        raise ValueError(s)
    rows=[]
    with open(csv_path,encoding="utf-8",errors="ignore") as f:
        for line in f:
            d=date_re.findall(line)
            if len(d)>=2: rows.append((p(d[0]),p(d[1])))
    if not rows: return False
    t0=rows[0][0]
    _SUN_INTERVALS=[((a-t0).total_seconds(),(b-t0).total_seconds()) for a,b in rows]
    return True

def sunlit_fraction_geom():
    """用 beta angle 幾何算日照佔比(無 STK 檔時的保底)。"""
    rho=np.arcsin(RE/SMA)  # 地球角半徑
    if np.abs(BETA) >= (np.pi/2 - rho):
        return 1.0          # 全日照(高 beta)
    # 地影半角
    frac_eclipse=(1/np.pi)*np.arccos(np.sqrt(np.cos(BETA)**2 - np.cos(np.pi/2-rho)**2)/np.cos(BETA))
    return 1.0-frac_eclipse

def is_sunlit(t):
    if _SUN_INTERVALS is not None:
        tt=t % (_SUN_INTERVALS[-1][1] if _SUN_INTERVALS else PERIOD)
        for a,b in _SUN_INTERVALS:
            if a<=t<=b: return True
        return False
    # 幾何:每軌前 f 比例日照(簡化為相位窗)
    f=sunlit_fraction_geom()
    ph=(t % PERIOD)/PERIOD
    return ph < f

# ---------------- 太陽/地球方向(簡化:太陽固定 +X_eci,衛星繞行)----------------
def sun_dir_body(t):
    """回傳太陽單位向量(body 座標)。簡化:衛星 nadir 指向、繞軌旋轉,
    太陽在慣性 +X。這裡用軌道相位近似 body<-inertial。"""
    th=N_ORB*t
    # 慣性太陽方向(含 beta):
    s_in=np.array([np.cos(BETA),0,np.sin(BETA)])
    # 簡化 body 旋轉(繞 z 以軌道角速度)——教學用近似
    c,sn=np.cos(th),np.sin(th)
    Rz=np.array([[c,sn,0],[-sn,c,0],[0,0,1.0]])
    return Rz@s_in

# ---------------- 熱網路 ODE ----------------
def thermal_rhs(t,T):
    dT=np.zeros(7)
    sunlit=is_sunlit(t)
    s_body=sun_dir_body(t)
    for i in range(6):
        n=NORMALS[i]
        # 太陽輸入(僅日照且面朝太陽)
        cos_s=max(0.0,np.dot(n,s_body))
        Qs=ALPHA*SOLAR*FACE_A*cos_s if sunlit else 0.0
        # 反照(僅日照,朝地面)——簡化用固定視角
        Qa=ALPHA*ALBEDO*SOLAR*FACE_A*q_earth_view*(0.5) if sunlit else 0.0
        # 地球紅外(恆有)
        Qir=EPS*EARTH_IR*FACE_A*q_earth_view
        # 輻射散熱(對太空)
        Qrad=EPS*SIGMA*FACE_A*(T[i]**4 - T_SPACE**4)
        # 與內部節點傳導
        Qcond=K_FACE_INT*(T[6]-T[i])
        dT[i]=(Qs+Qa+Qir-Qrad+Qcond)/C[i]
    # 內部節點:內部發熱 + 與 6 面傳導
    Qcond_int=sum(K_FACE_INT*(T[i]-T[6]) for i in range(6))
    dT[6]=(Q_INT+Qcond_int)/C[6]
    return dT

# ---------------- 執行 ----------------
def main():
    src = "STK lighting timeline" if _SUN_INTERVALS is not None else \
          f"built-in geometry (beta={np.degrees(BETA):.0f} deg, sunlit {sunlit_fraction_geom()*100:.0f}%)"
    print(f"Sunlight source: {src}")
    print(f"Orbit period   : {PERIOD/60:.1f} min")

    t_end=3*PERIOD
    sol=solve_ivp(thermal_rhs,[0,t_end],T0,t_eval=np.linspace(0,t_end,1500),
                  method="LSODA",rtol=1e-6,atol=1e-6)
    Tc=sol.y-273.15   # degC
    names=["+X","-X","+Y","-Y","+Z","-Z","Internal"]

    print("\n節點溫度範圍 (degC):")
    for i,nm in enumerate(names):
        print(f"  {nm:8s}: {Tc[i].min():6.1f} .. {Tc[i].max():6.1f}")
    # 電池工作區間檢查(典型 0..40 degC)
    bat=Tc[6]
    ok = (bat.min()>=0) and (bat.max()<=40)
    print(f"\n電池(內部)工作區間 0..40 degC: {'PASS' if ok else 'FAIL'} "
          f"(min {bat.min():.1f}, max {bat.max():.1f})")

    # ---- 圖 ----
    fig,ax=plt.subplots(2,1,figsize=(10,8),sharex=True)
    tm=sol.t/60
    for i,nm in enumerate(names):
        lw=2.0 if nm=="Internal" else 1.0
        ax[0].plot(tm,Tc[i],label=nm,lw=lw)
    ax[0].axhspan(0,40,color="green",alpha=0.08,label="battery op 0-40C")
    ax[0].set_ylabel("Temperature (degC)")
    ax[0].set_title(f"CubeSat node temperatures  |  {src}")
    ax[0].legend(fontsize=8,ncol=4); ax[0].grid(alpha=0.3)
    # 日照/地影帶
    sun=np.array([1.0 if is_sunlit(t) else 0.0 for t in sol.t])
    ax[1].fill_between(tm,0,sun,step="mid",color="gold",alpha=0.5,label="sunlit")
    ax[1].fill_between(tm,0,1-sun,step="mid",color="navy",alpha=0.3,label="eclipse")
    ax[1].set_ylim(0,1); ax[1].set_yticks([0,1])
    ax[1].set_ylabel("lighting"); ax[1].set_xlabel("time (min)")
    ax[1].legend(fontsize=8,loc="upper right"); ax[1].grid(alpha=0.3)
    fig.tight_layout(); fig.savefig(os.path.join(HERE, "week6_thermal.png"),dpi=130)
    print("\nWrote week6_thermal.png")

if __name__=="__main__":
    # 要用 STK 日照時間線:取消下一行,指向你的 Sunlight 報告 CSV
    # load_stk_lighting("stk_sunlight.csv")
    main()