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 ) 節點間傳導
C_i = m_i * c_p,i熱容 (J/K) —— 決定溫度擺動的幅度與相位延遲k_ij節點間熱導 (W/K) —— 決定各節點溫度的均勻程度
這是一組耦合的非線性 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. 常見誤解
- 「太空很冷,所以衛星會凍住。」 — 真空沒有溫度。衛星溫度由輻射平衡決定,
曬到太陽的面可能超過 100 °C。「冷」與「熱」在同一顆衛星上同時存在。 - 「加散熱片就能散熱。」 — 真空中散熱片只有能輻射出去時才有用,
而且要看得到冷空(不能對著地球、也不能對著自己)。傳導路徑不通,散熱片就是裝飾。 - 「用平均地影比例就好。」 — 見 2.5,熱設計看的是極端工況。
- 「黑色比較會散熱。」 — 黑色
α高ε也高,α/ε約 1,是「均溫」不是「散熱」。
真正的散熱面要α低而ε高(白漆、OSR)。 - 「熱模型是熱工程師的事。」 — 熱與功率(Week 7)、軌道(Week 4)、
CONOPS(Week 2)全部耦合。改一個 duty cycle 就是改熱模型的輸入。
6. 延伸閱讀
太空熱控
- NASA Technical Reports Server (NTRS)
— 搜尋 "Spacecraft Thermal Control Handbook" 或 "CubeSat thermal design",大量免費技術報告。 - ECSS 標準總表 — ECSS-E-ST-31 系列(熱控)。
- NASA State of the Art of Small Spacecraft Technology
— Thermal Control 一章列出 CubeSat 尺度可用的熱控元件與塗層。 - 教科書:Gilmore, Spacecraft Thermal Control Handbook(業界標準參考書)。
太空環境
- SPENVIS(ESA 太空環境資訊系統)
— 線上計算軌道上的輻射、原子氧、太陽通量等環境。 - NASA CCMC 社群協調模擬中心 — 太空天氣與環境模型。
數值方法與工具
- SciPy 說明文件
—solve_ivp的求解器選擇。本週是剛性 (stiff) 問題的邊緣案例,Radau/BDF值得一試。 - Ansys STK 線上說明 — Lighting / Eclipse 資料提供者。
7. 課前自我檢核
- 為什麼太空中沒有對流?這對散熱設計造成什麼限制?
α/ε = 0.2與α/ε = 3.0的兩個表面,哪一個比較熱?溫度差幾倍?(用四次方根估)- 熱容加倍,溫度擺動幅度大約變成幾倍?相位會怎麼變?
- 什麼情況下衛星會進入「全日照」?那是最熱工況還是最冷工況?
- 你的熱模型若用「地影佔 35%」的平均值,會漏掉哪兩個關鍵工況?
圖

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