獨立章節 5 — 台美太空港次軌道飛行(Aviator + 自製滑翔解)
資料夾:
spaceport_suborbital/程式碼
spaceport_suborbital/make_connect.py
# -*- coding: utf-8 -*-
"""產生本章場景的 STK Connect 腳本。
**數字全部由 spaceport_build.mission_geometry() 算出**,不是寫死的 ——
設計常數一改,重跑這支就同步。
哪些指令做得到、哪些做不到,是 2026-09-24 對執行中的 STK 13
**逐條實測**的結果(送出後看 ACK/NACK),不是查手冊推測的。
實測腳本與原始輸出見檔尾附錄 B。
python make_connect.py -> spaceport_suborbital_Connect.txt
"""
import io
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import spaceport_analysis as A
import spaceport_build as B
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "spaceport_suborbital_Connect.txt")
# 實測結果:(指令, 是否 ACK, 備註)
# 2026-09-25 新增:分節分離與尾焰那一批(STK 12 實測)。
# 與上面 2026-09-24 那批分開標,因為是不同天、不同版本量的。
PROBE_STAGES = [
("VO */Aircraft/<name> VaporTrail Show On", True,
"Aircraft 與 Missile 都 ACK —— 尾焰的開關"),
("VO */Missile/<name> VaporTrail Show On", True, ""),
("VO */Aircraft/<name> Trail Show On", False,
"Command has failed —— 沒有 Trail 這個關鍵字"),
("Articulate */Aircraft/<name> <t> <artic> <trans> <v>", False,
"Command has failed,連活動件名稱正確也一樣"),
("VO */Aircraft/<name> Articulate <artic> <trans> <v>", False,
"Command has failed"),
("VO */Aircraft/<name> ArticulationFile \"<f.sama>\"", False,
"Command has failed —— 時變活動件只能走物件模型"),
("VO */Aircraft/<name> Model Detail", False, "Command has failed"),
]
PROBE = [
("New / */Place <name>", True, ""),
("SetPosition */Place/<name> Geodetic <lat> <lon> <alt>", True, ""),
("New / */Facility <name>", True, ""),
("SetPosition */Facility/<name> Geodetic <lat> <lon> <alt>", True, ""),
("VO */Facility/<name> Model File \"<path>\"", True, ""),
("New / */Missile <name>", True, "物件建得出來"),
("SetPropagator */Missile/<name> Ballistic", False, "Command has failed"),
("SetLaunchPosition */Missile/<name> Geodetic ...", False, "Command has failed"),
("SetImpactPosition */Missile/<name> Geodetic ...", False, "Command has failed"),
("SetState */Missile/<name> Ballistic ...", False, "Command has failed"),
("New / */Aircraft <name>", True, ""),
("SetPropagator */Aircraft/<name> StkExternal", False,
"但下一行不需要它也能成立"),
("SetState */Aircraft/<name> FromFile \"<file.e>\"", True,
"**可用**,回讀 RouteType = 6(StkExternal)"),
("SetState */Satellite/<name> FromFile \"<file.e>\"", True, ""),
("SetPropagator */Aircraft/<name> Aviator", True, "傳播器設得了"),
("Aviator */Aircraft/<name> Mission ...", False, "Command has failed"),
("AviatorPropagator */Aircraft/<name> Mission New", False, "Command has failed"),
("SetAviatorMission */Aircraft/<name> New", False, "Command has failed"),
("VO */Aircraft/<name> Model File \"<file.glb>\"", True, ""),
("VO * ViewFromTo Normal From */Place/<name>", True, ""),
("Animate * Reset", True, ""),
]
def build_text():
ho = B.mission_geometry(verbose=False)
ho_sf = B.mission_geometry(ld=B.SEAM_FREE_LD, verbose=False)
eph = os.path.join(HERE, B.EPH_FILENAME).replace("\\", "/")
eph_sf = os.path.join(HERE, "twsp_glide_seamfree.e").replace("\\", "/")
glb = os.path.join(HERE, B.MODEL_GLB).replace("\\", "/")
rwy = A.KEFD_RUNWAY
L = []
w = L.append
w("# " + "=" * 76)
w("# 台美太空港次軌道任務 —— STK Connect 腳本")
w("#")
w(f"# 起飛 旭海發射場(屏東縣牡丹鄉) "
f"{A.LAUNCH_LAT:.4f} N {A.LAUNCH_LON:.4f} E")
w(f"# 降落 休士頓太空港 Ellington Field "
f"{A.LANDING_LAT:.4f} N {abs(A.LANDING_LON):.4f} W (KEFD)")
w(f"# 航線 大圓 {ho['total_m']/1000:,.0f} km,"
f"初始方位 {ho['azimuth_deg']:.1f} 度")
w("#")
w("# 本檔由 make_connect.py 產生,數字來自 mission_geometry(),")
w("# 設計常數一改重跑即可同步。不要手動編輯。")
w("# " + "-" * 76)
w("# 任務分成兩段,兩段相加等於全程(閉合殘差 "
f"{ho['closure_residual_m']:.3f} m):")
w("#")
w(f"# 1. 助推真空弧 {ho['arc_m']/1000:>9,.0f} km "
f"{ho['arc_m']/ho['total_m']*100:4.1f}% Missile + 彈道傳播器")
w(f"# 2. 外部星曆段 {ho['eph_m']/1000:>9,.0f} km "
f"{ho['eph_m']/ho['total_m']*100:4.1f}% Aircraft + StkExternal")
w("# (末段約 31 km 交給 Aviator)")
w("#")
w(f"# 熄火 {ho['v_bo']:,.0f} m/s @ {ho['burnout_alt_m']/1000:.0f} km,"
f"γ {ho['gamma_bo_deg']:.1f} 度,遠地點 {ho['apogee_m']/1000:,.0f} km")
w(f"# 交接 {ho['eph_start_alt_m']/1000:.1f} km,"
f"{ho['eph_start_tas_ms']:,.0f} m/s(Mach {ho['eph_start_mach']:.2f}),"
f"({ho['eph_start_lat']:.3f}, {ho['eph_start_lon']:.3f})")
w("#")
w("# 舊版這裡寫著「3,765 km 無模型涵蓋」。那是記帳錯誤,不是工具限制 ——")
w("# 真空弧的射程被重複計入滑翔段。已修正,航程沒有缺口。")
w("# " + "-" * 76)
w("# **哪些做得到,是實測出來的**")
w("#")
w("# 2026-09-24 對執行中的 STK 13 逐條送出候選指令,記錄 ACK / NACK。")
w("# 結論:")
w("#")
w("# 做得到 場景、場址、物件建立、**外部星曆載入**、3D 模型、視角")
w("# 做不到 Missile 的彈道傳播器設定(發射點/落點/控制方式)")
w("# Aviator 的任務、階段與程序")
w("#")
w("# 其中最值得注意的是:**整個高超音速滑翔段可以只用 Connect 完成**,")
w("# 因為 SetState ... FromFile 吃得下 STK 的 .e 星曆檔。")
w("# 逐條清單見檔尾附錄 B。")
w("#")
w("# 本檔本身也整份跑過:20 條指令 **20/20 ACK**,")
w("# 之後回讀兩段星曆,航跡/直線都是 1.000 倍,")
w("# 高度由 71.1 km 降到 3.0 km(對照機 67.1 -> 3.0)。")
w("# **ACK 不等於做對了** —— 所以每次都要回讀,這是本章反覆強調的習慣。")
w("# " + "-" * 76)
w("# 執行")
w("# 在 STK 命令列:")
w(f'# ConFile / "{os.path.join(HERE, "spaceport_suborbital_Connect.txt").replace(chr(92), "/")}"')
w("# 注意 ConFile 是非同步的:立刻 ACK,檔案在背景才跑完。")
w("#")
w("# 完整場景(含 Connect 做不到的部分):")
w("# python spaceport_build.py --force")
w("# python spaceport_verify.py # 39 項回歸檢查")
w("# " + "=" * 76)
w("")
w("# ---- 1. 場景 " + "-" * 64)
w(f"New / Scenario {B.SCENARIO}")
w(f'SetAnalysisTimePeriod * "{B.EPOCH}" "{B.STOP}"')
w("SetUnits / km")
w("Animate * Reset")
w("")
w("# 不用地形:本章的高度尺度是 3–106 km,地形起伏(最多 4 km)對彈道與")
w("# 滑翔沒有影響,只會讓每次計算變慢。")
w("# **而且 Facility 的 UseTerrain 預設是開的,會靜默覆寫你設的高度** ——")
w("# 設 0 / 100 / 9.8 全部回讀成 -19.26 m。這是本章踩過的坑。")
w("Terrain * TerrainServer UseTerrainForAnalysis No")
w("")
w("# ---- 2. 場址 " + "-" * 64)
w("# 旭海:TASA 的探空火箭發射場。用 Place 而不是 Facility ——")
w("# 它是地表的一個地點,不是通訊站。")
w(f"New / */Place {A.LAUNCH_NAME}")
w(f"SetPosition */Place/{A.LAUNCH_NAME} Geodetic "
f"{A.LAUNCH_LAT:.4f} {A.LAUNCH_LON:.4f} {A.LAUNCH_ALT_M/1000:.3f}")
w("")
w("# Ellington Field (KEFD),2015 年取得 FAA 太空港執照。")
w(f"# 跑道 17R/35L:{rwy['length_m']:,.0f} x 46 m,"
f"場高 {rwy['alt_m']:.1f} m。由北向南落 RWY 17R。")
w(f"New / */Facility {A.LANDING_NAME}")
w(f"SetPosition */Facility/{A.LANDING_NAME} Geodetic "
f"{A.LANDING_LAT:.4f} {A.LANDING_LON:.4f} {A.LANDING_ALT_M/1000:.4f}")
w("")
w("# ---- 3. 助推真空弧(Missile)" + "-" * 49)
w("# **Connect 只建得出物件,建不了彈道。**")
w("# SetPropagator / SetLaunchPosition / SetImpactPosition / SetState")
w("# 四種形式全部 NACK(實測)。彈道傳播器的發射點、落點與")
w("# 「固定遠地點」控制方式只能用物件模型設定 —— 見附錄 A 第 1 條。")
w(f"New / */Missile {B.MISSILE_NAME}")
w("")
w("# 供對照:物件模型要設成的值")
w(f"# 發射 {A.LAUNCH_LAT:.4f} N {A.LAUNCH_LON:.4f} E "
f"{A.LAUNCH_ALT_M:.1f} m")
w(f"# 落點 {ho['eph_start_lat']:.4f} {ho['eph_start_lon']:.4f} "
f"{ho['arc_end_alt_m']:,.0f} m (= 星曆起點)")
w(f"# 控制 eLaunchControlFixedApogeeAlt,遠地點 "
f"{ho['apogee_m']:,.0f} m")
w("")
w("# ---- 4. 高超音速滑翔(外部星曆)" + "-" * 45)
w("# **這一段可以完全用 Connect 完成。**")
w("# SetState ... FromFile 吃得下 STK 的 .e 星曆檔;實測回讀 RouteType = 6")
w("# (ePropagatorStkExternal),航跡/直線 = 1.000 倍。")
w("#")
w("# 為什麼用星曆而不是 Aviator:同一條航線交給 Aviator 飛,地面軌跡是")
w("# 直線距離的 **5.32 倍** —— 載具在繞圈,而且當時 32 項驗證全部通過。")
w("# 根因是 Aviator 的航段求解器碰到高度變化就多繞一圈,調 AltitudeRate 無效。")
w("#")
w("# 星曆檔本身由 spaceport_build.write_ephemeris() 產生(Fixed 座標,")
w("# 位置 + 速度),**內容是解析解算出來的,不是 STK 算的**。")
w(f"New / */Aircraft {B.GLIDE_OBJ_NAME}")
w(f'SetState */Aircraft/{B.GLIDE_OBJ_NAME} FromFile "{eph}"')
w("")
w(f'VO */Aircraft/{B.GLIDE_OBJ_NAME} Model File "{glb}"')
w("")
name_main = f"{B.SPACEPLANE_NAME}_LD{ho['ld']:g}".replace(".", "p")
w("# ---- 5. 終端進場與降落(Aviator)" + "-" * 44)
w("# **Connect 設得了傳播器,設不了任務內容。**")
w("# SetPropagator ... Aviator 是 ACK 的,但 Aviator 的 Mission / Phase /")
w("# Procedure 沒有對應的 Connect 指令(三種形式實測皆 NACK)。")
w("# 見附錄 A 第 2 條。")
w(f"New / */Aircraft {name_main}")
w(f"SetPropagator */Aircraft/{name_main} Aviator")
w(f'VO */Aircraft/{name_main} Model File "{glb}"')
w("")
w("# 供對照:Aviator 任務要設成的內容(只有兩個程序)")
w(f"# 1. 進場介面 {B.APPROACH_IF_ALT_M:,.0f} m、"
f"{B.APPROACH_IF_V_MS:.0f} m/s,接在星曆終點")
w(f"# 2. 降落 KEFD RWY 17R,下滑道 {B.GLIDESLOPE_DEG:.0f} 度,"
f"真方位 {rwy['low_end_hdg']:.0f} 度")
w("# 中間**不放航點** —— 多放一個就會踩到「高度變化所需段長」而繞圈。")
w("")
name_sf = f"{B.SPACEPLANE_NAME}_LD{B.SEAM_FREE_LD:g}".replace(".", "p")
w("# ---- 6. 對照機:升阻比夠高時 Aviator 可獨力飛完 " + "-" * 28)
w(f"# L/D {B.SEAM_FREE_LD} 時弧終點速度降到 "
f"{ho_sf['eph_start_tas_ms']:,.0f} m/s,")
w(f"# 恰好等於 Aviator 的求解上限 {B.HANDOVER_TAS_MS:,.0f} m/s"
f"(還差 {ho_sf['aviator_seam_ms']:,.0f} m/s)。")
w("# 兩條航跡放在同一個視窗裡,就能看出工具邊界是怎麼變成設計指標的。")
gl_sf = f"TWSP_Glide_LD{B.SEAM_FREE_LD:g}".replace(".", "p")
w(f"New / */Aircraft {gl_sf}")
w(f'SetState */Aircraft/{gl_sf} FromFile "{eph_sf}"')
w("")
w("# ---- 7. 視角與動畫 " + "-" * 58)
w(f"VO * ViewFromTo Normal From */Place/{A.LAUNCH_NAME}")
w("Animate * Reset")
w("")
# ── 附錄 A ────────────────────────────────────────────────────────
w("")
w("# " + "=" * 76)
w("# 附錄 A:Connect 做不到的三件事")
w("# " + "=" * 76)
w("#")
w("# 1. Missile 的彈道傳播器")
w("# " + "-" * 40)
w("# 實測 NACK:SetPropagator / SetLaunchPosition / SetImpactPosition")
w("# / SetState ... Ballistic")
w("# 要設的是「發射點 → 落點 + 遠地點高度」這組三選二的約束,")
w("# Connect 沒有對應語法。物件模型的做法:")
w("#")
w("# m.SetPropagatorType(AgEVePropagatorType.ePropagatorBallistic)")
w("# traj = m.Propagator.Trajectory")
w("# traj.Launch / traj.ImpactLocation.Impact 設經緯高")
w("# traj.ImpactLocation.SetLaunchControlType(")
w("# AgEVeLaunchControl.eLaunchControlFixedApogeeAlt)")
w("# traj.ImpactLocation.LaunchControl.ApogeeAlt = <遠地點>")
w("#")
w("# **另外有一個一定要設的東西:LaunchTrueBearing。**")
w("# 它預設是 0(正北),不設會讓載具繞圈。要先算大圓初始方位再寫進去。")
w("# 對應函式:spaceport_build.build_missile()")
w("#")
w("# 2. Aviator 的任務、階段與程序")
w("# " + "-" * 40)
w("# 實測:SetPropagator ... Aviator 是 ACK 的,")
w("# 但 Aviator Mission / AviatorPropagator / SetAviatorMission")
w("# 三種形式全部 NACK。")
w("# Aviator 的整個結構(AvtrMission -> Phases -> Procedures)")
w("# 只在物件模型裡存在。要設的包括:")
w("# * 起降場與跑道(KEFD RWY 17R)")
w("# * 降落程序的下滑道與對齊")
w("# * 效能模型:必須改用 **Built-In** 並設 CeilingAltitude")
w("# (生成的 Advanced Fixed Wing 模型高度上限固定在 13,106 m,")
w("# 而且不可設定 —— 這是本章實測出來的邊界)")
w("# 對應函式:spaceport_build.build_spaceplane()")
w("# spaceport_vehicle.configure_builtin_models()")
w("#")
w("# 3. 3D 模型的『持久化』")
w("# " + "-" * 40)
w("# VO ... Model File 是 ACK 的,但**只對本次 session 有效**:")
w("# 在 GUI 裡重算航路會把它還原成機型自己的模型。")
w("# 要永久生效必須改機型 XML 裡的 Model3D 欄位,")
w("# 那是檔案操作,不是 Connect 或物件模型的事。")
w("# 對應函式:spaceport_build.set_vehicle_model3d()")
w("#")
w("# 順帶一提:STK 13 的機型目錄在 Documents\\STK_ODTK 13\\,")
w("# **不是** STK 13\\ —— 寫死路徑會改到不存在的檔案而以為成功。")
w("#")
w("# 4. 分節分離的動畫與尾焰")
w("# " + "-" * 40)
w("# **尾焰的開關是 Connect 做得到的**:")
w("# VO */Aircraft/<name> VaporTrail Show On <- ACK")
w("# 但只有開關。時間窗、貼圖、半徑、掛點都在物件模型裡:")
w("#")
w("# vt = ac.VO.VaporTrail")
w("# vt.ImageFile = r\"STKData\VO\Textures\flametex-orange.tga\"")
w("# vt.UseAttachPoint = True; vt.AttachPointName = \"Flame_Booster1\"")
w("# vt.DisplayInterval.SetExplicitInterval(0.0, 182.0)")
w("#")
w("# **時變活動件(火焰大小、分節掉落)Connect 完全做不到。**")
w("# Articulate / VO ... Articulate / VO ... ArticulationFile")
w("# 三種寫法都 NACK,即使活動件名稱是從")
w("# GetAvailableArticulations() 讀出來的正確名稱。")
w("# 唯一的機制是 .sama articulation 檔:")
w("#")
w("# art = ac.VO.Model.Articulation")
w("# art.UseArticulationFile = True")
w("# art.VOArticulationFile.Filename = <path> # 它是物件,不是字串")
w("# art.VOArticulationFile.Reload()")
w("#")
w("# 對應函式:spaceport_stages.build_stages()")
w("# spaceport_stages.write_sama()")
w("#")
# ── 附錄 B ────────────────────────────────────────────────────────
w("")
w("# " + "=" * 76)
w("# 附錄 B:實測清單(STK 13 於 2026-09-24;分節那批 STK 12 於 2026-09-25)")
w("# " + "=" * 76)
w("#")
allp = PROBE + PROBE_STAGES
ack = sum(1 for _c, ok, _n in allp if ok)
w(f"# {ack} / {len(allp)} 條 ACK")
w("#")
for cmd, ok, note in allp:
mark = "ACK " if ok else "NACK"
line = f"# [{mark}] {cmd}"
if note:
line += f"\n# {note}"
w(line)
w("#")
w("# 實測方法:在現有場景裡建 CT_ 前綴的暫時物件逐條送指令,測完全部卸載,")
w("# 不影響既有場景。ACK 之後還**回讀確認**星曆真的載進去了 ——")
w("# ACK 只代表指令被接受,不代表它做了你以為的事。")
return "\n".join(L) + "\n"
def main():
txt = build_text()
with io.open(OUT, "w", encoding="utf-8") as fh:
fh.write(txt)
print(f"已寫出 {OUT}")
print(f" {len(txt.splitlines())} 行,{len(txt):,} 字元")
cmds = [x for x in txt.splitlines()
if x and not x.startswith("#")]
print(f" 可執行指令 {len(cmds)} 條")
return 0
if __name__ == "__main__":
raise SystemExit(main())
spaceport_suborbital/make_recommendation.py
# -*- coding: utf-8 -*-
"""產出 STK 能力邊界技術評估:純文字檔 + PPTX。
內容集中在一個 SECTIONS 結構裡,兩種格式由同一份內容生成 ——
避免兩份文件講的話不一致(改了一邊忘了另一邊)。
執行:
python make_recommendation.py
產出:
STK技術建議_技術版.txt
STK技術建議_技術版.pptx
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TXT = os.path.join(HERE, "STK技術建議_技術版.txt")
PPT = os.path.join(HERE, "STK技術建議_技術版.pptx")
# 收件者。**版本庫中刻意不記錄個人姓名** ——
# 交付特定對象時在此填入,例如 "國立臺北科技大學 ○○○ 老師"。
RECIPIENT = "國立臺北科技大學 合作教師"
TITLE = "台美太空港次軌道任務"
SUBTITLE = "STK 實作技術建議"
META = [
f"收件:{RECIPIENT}",
"任務:雙節運載火箭自屏東旭海太空港起飛,第二節為有翼太空飛機,",
" 飛至次軌道高度後再入滑翔,降落美國休士頓太空港 KEFD",
"日期:2026-09-24",
"依據:STK 12 實機量測(agi.stk12 Python API)+ AGI 原廠文件能力邊界",
]
# 每個 section:(標題, [段落或表格])
# 表格用 ("table", 表頭, 各列) 表示,其餘是字串段落。
SECTIONS = [
("一、結論摘要", [
"整條任務在 STK 裡「做得出來」,但不存在單一物件能涵蓋全程。"
"必須按物理階段拆給不同物件,並且有一段是所有物件都不涵蓋的。",
("table", ["任務階段", "STK 物件", "狀態"], [
["動力上升、分節分離", "Satellite + Astrogator", "建議路線,介面已確認,尚未實作"],
["次軌道彈道弧", "Missile + Ballistic", "已實作並驗證"],
["高速滑翔 6.3→5.4 km/s", "(無物件涵蓋)", "需另闢蹊徑,或提高 L/D"],
["再入滑翔 4,009 km", "Aircraft + Aviator", "已實作並驗證"],
["跑道進場與降落", "Aircraft + Aviator", "已實作並驗證"],
]),
"合作教師找到的範例都停在升空階段,原因在物件設計本身:Launch Vehicle 物件"
"只有 SimpleAscent / StkExternal / RealTime 三種傳播器,"
"設計目的就是「發射點到入軌點」,原廠文件裡沒有再入與降落。"
"換句話說,那不是範例做得不完整,是那個物件不做那件事。",
"目前已完成的場景通過 26 項驗證(26/26),落點偏差 0.76 km,"
"觸地航向與跑道 17R 相差 0.0 度。",
]),
("二、採用的架構與關鍵數字", [
"旭海(22.0620N, 120.9020E)到 KEFD(29.6073N, 95.1588W)大圓距離"
" 13,109 km,發射方位 35.34 度。",
"這個距離的物理意義必須先講清楚:純彈道飛 13,109 km 需要熄火速度"
" 7,593 m/s,是圓軌道速度的 97.5%。也就是說這是一個「幾乎入軌」的任務,"
"不是一般印象中的次軌道試飛。",
("table", ["項目", "數值", "說明"], [
["架構", "淺角度助推滑翔", "熄火於卡門線,γ = 2°"],
["設計升阻比", "L/D 4.0", "實用乘波體範圍"],
["熄火速度", "6,335 m/s", "由全程滑翔射程反解"],
["遠地點", "107 km", "STK 實算,與解析解差 0%"],
["助推真空弧", "1,327 km (10%)", "Missile 承擔"],
["高速滑翔", "3,765 km (29%)", "**無模型涵蓋**"],
["Aviator 段", "8,017 km (61%)", "平衡滑翔 + 降落"],
["交接高度 / 速度", "67 km / 5,400 m/s", "高度由平衡滑翔解算出"],
["第一節熄火/分離", "t = 179 s", "區間 164-217 s"],
["第二節熄火", "t = 433 s", ""],
["無接縫所需 L/D", "6.54", "升阻比到此值,全程可端對端模擬"],
]),
"機翼的效益有一個反直覺的結論:在這個射程下,L/D 低於約 1.6 時機翼是"
"淨負擔。L/D 1.0 比純彈道還多要 248 m/s,L/D 1.5 多要 53 m/s;"
"要到 L/D 2.0 才省 211 m/s,L/D 3.0 省 765 m/s。"
"原因是滑翔延伸射程的效益與升阻比成正比,而再入速度的需求下降得慢。"
"所以「加機翼」這個設計決策要用 L/D 目標來論證,不能只說有翼就有利。",
]),
("三、分離時刻:可以驗證到什麼程度", [
"分離時刻有一個很有用的性質:與載具絕對大小無關。",
" t_burn = Isp x (1 - 1/R) / (T/W)",
"推導:ṁ = F/(Isp g0),而 F = (T/W) m0 g0,故 ṁ = (T/W) m0 / Isp;"
"推進劑 mp = m0 (1 - 1/R)。相除後 m0 消掉。",
"這表示概念設計階段不需要先定總質量,只要定 Isp、Δv 分配、推重比,"
"分離時刻就定了。",
"本設計(Isp1 285 s、Δv 分配 0.62、T/W 1.30)得到分離 t = 185 s。"
"三層驗證的結果:",
("table", ["驗證層次", "結果", "強度"], [
["質量流與火箭方程自洽", "Δv 誤差 < 0.5 m/s", "必要但不充分"],
["與真實載具對照", "可比機種 162-253 s,本設計 185 s 落在區間內", "最強的現有證據"],
["敏感度(T/W 1.15-1.40、分配 0.55-0.70)", "164-217 s", "說明不確定度"],
]),
"做不到的部分要講明:熄火要同時滿足速度 7,031 m/s、飛行路徑角 24.6 度、"
"高度約 100 km 三個條件,而簡化上升段模型只有「俯仰踢角」一個自由度,"
"收不了三個條件。本次的積分器算出熄火高度 649 km(遠高於 100 km),"
"而且損失模型套在 200 km 入軌基準上只算出 655 m/s"
"(公認 1,500-2,000 m/s),沒通過基準。",
"結論:185 s 是一個有工程依據的設計值,但不是一條收斂軌跡的解。"
"要得到後者必須做上升段軌跡最佳化,正確工具是 Astrogator 的 targeter。",
]),
("四、如何在軌道資訊上呈現分離", [
"目前場景有一個容易誤導的性質必須先修正:Ballistic 傳播器把熄火速度"
"當成脈衝施加在發射點上,所以 Missile 物件的起始時刻在物理上等於"
"第二節熄火,不是起飛。",
"也就是說,真正的起飛在 Missile 起始時刻的 453 s 之前,"
"分離在 268 s 之前。要在時間軸上呈現分離,場景起點必須往前挪 453 s。",
"建議的呈現方式,由簡到繁:",
("table", ["方式", "做法", "得到什麼"], [
["時間軸標記", "Analysis Workbench 建 Time Instant(分離、二節熄火)",
"Timeline View 上看得到事件,可用於報表與約束"],
["分節物件", "第一節殘骸另建物件走各自彈道",
"3D 視窗看得到分離後兩體分離"],
["動力上升段", "Astrogator MCS:Launch + 有限推力機動 + 高度停止條件",
"分離時刻由 STK 自己算,這才是真正的驗證"],
["外部星曆", "自算上升段寫成 .e,以 StkExternal 載入 Launch Vehicle",
"顯示正確但數字仍是外部算的,不構成驗證"],
]),
"建議採第三項。Astrogator 是 Satellite 物件的傳播器"
"(以 IsPropagatorTypeSupported 確認該環境是否可用),"
"MCS 段落型別包含 Launch、"
"Maneuver(有限推力)、Propagate(可設高度停止條件)、TargetSequence,"
"正是兩節動力上升與次軌道拋物段需要的組合。",
]),
("五、原廠文件與實測的落差 — 請特別注意", [
"文件說 Advanced Fixed Wing 支援 Mach 20 以上、35 萬呎(106.7 km)以下。"
"實測結果與這句話需要更精確的解讀。",
("table", ["效能模型", "有效解的最高真空速", "高度上限"], [
["AdvFixedWing 生成模型", "4,391 m/s(Mach 15 @ 13.2 km)", "13.2 km"],
["Built-In + CeilingAltitude", "5,023-5,398 m/s", "100 km 以上"],
]),
"兩條路都無法同時給到高度與速度。Mach 20 以上雖然「算得出數字」"
"(Mach 21.95、真空速 6,478 m/s),但那些都是 IsValid = False 的"
"截斷航路,不是解出來的軌跡。",
"合理的解讀:文件講的是氣動模型的定義域,不是 BasicPointToPoint "
"能收斂的範圍。這兩件事在 STK 裡是分開的 —— 氣動策略"
"(Sub/Super/Hypersonic)定義升阻特性,而航段求解另有自己的收斂限制。",
"對老師的實務影響:Aviator 適合再入滑翔的後段與跑道進場,"
"但不要期待它承擔再入初期 6-7 km/s 的高能段。",
]),
("六、必須另闢蹊徑的一段:再入減速帶", [
"彈道段把載具送到交接點時速度約 7,031 m/s,而 Aviator 只接得住 "
"5,400 m/s。這 1,631 m/s 的差值要由氣動減速提供,發生在大約 "
"120 km 到 80 km 之間。",
"問題是兩個物件都不模擬它:Missile 的 Ballistic 是真空橢圓,"
"不含氣動減速;Aviator 上不到那個速度。",
"可行的處理方式:",
" (1) 教學上明確標示為模型接縫,用解析式(Eggers 平衡滑翔、"
"Sutton-Graves 熱通量比例式)定量描述。本專案目前採此法。",
" (2) 用 Astrogator 在再入段加入阻力模型,讓 STK 積分這一段。"
"Astrogator 支援大氣阻力,是技術上最完整的做法。",
" (3) 外部程式(如自行撰寫或 OpenRocket/RPA 類工具)算出再入減速段,"
"以 StkExternal 星曆接回 STK。",
"建議:教學版用 (1) 並把接縫當成教學重點 —— 讓學生看到工程模擬裡"
"「模型邊界」的真實存在,比給一條看似完美的軌跡更有價值。"
"研究版用 (2)。",
]),
("七、Missile Tool Kit 不可用的替代方案", [
"MTK 提供含 boost phase 的端對端彈道,是最直接的工具,"
"但台灣不在輸出核可白名單內。",
"替代組合:",
" 上升段 Astrogator MCS(有限推力 + targeter)",
" 彈道弧 Astrogator Propagate,或 Missile + Ballistic",
" 再入減速 Astrogator 含阻力積分",
" 滑翔與降落 Aviator",
"四者都在標準 STK 授權範圍內,不涉及出口管制項目。",
"代價是要自行銜接各段的狀態(位置、速度、時刻),"
"而 MTK 是一體完成的。銜接處就是前一節談的接縫,要明確處理。",
]),
("八、會浪費時間的陷阱(實測)", [
"以下每一項都是實際踩過、而且都不會報錯的。",
("table", ["現象", "原因", "處理"], [
["要求飛 60 km,實際一律 13,106 m",
"CreateAllPerfModels 生成的模型有內建上限,不暴露也改不動",
"改用 Built-In 模型,設 CeilingAltitude"],
["CeilingAlt 找不到這個屬性",
"XML 叫 CeilingAlt,物件模型叫 CeilingAltitude",
"用 API 的名字;查不到參數時去讀出廠 XML"],
["高速段航路解不出來",
"高速時航段太短(5,400 m/s 下 501 km 失敗、1,002 km 成功)",
"等時間步長佈點,Δs = v x Δt"],
["速度欄位全設成同一個大值後全部無效",
"破壞了 Min < Endurance < Range < Perf < Max 的排序",
"維持排序關係"],
["Facility 高度設什麼都回讀同一個值",
"UseTerrain 預設 True,高度被地形覆寫",
"先設 UseTerrain = False 再 Assign"],
["3D 模型掛上後又變回 f16",
"每次 Propagate() 都用機型的模型覆蓋 VO 設定",
"同時改機型 XML 的 Model3D,重啟 STK 後永久生效"],
["跑道長度 2442 變成 744 km",
"Site.Length 吃 AviatorRunwayLength(千呎),"
"ApproachFixRange 吃 AviatorDistanceUnit(海里)",
"同一物件上的不同單位維度要分別釘住"],
["改了單位沒還原,STK 100% CPU 空轉 44 分鐘",
"單位是全域狀態",
"用 context manager 設定並還原"],
]),
"一條通則:在 STK 裡「寫進去、讀回來、相符」完全不保證生效。"
"驗證要從 data provider 取實算結果,不要回讀屬性。",
]),
("九、建議實作順序", [
"第 1 階段(已完成)彈道段 + 滑翔段 + 降落,含驗證腳本。"
"可直接用於課堂演示完整的太空港到太空港航跡。",
"第 2 階段 場景時間軸往前挪 453 s,加入 Analysis Workbench 時間標記"
"(分離、二節熄火),並建第一節殘骸物件。工作量小,"
"讓時間軸與分離事件在畫面上正確。",
"第 3 階段 用 Astrogator 建動力上升段,以 targeter 收斂熄火條件"
"(v、γ、h 三者)。這一步之後分離時刻才是 STK 算出來的,"
"而不是外部帶入的設計值。",
"第 4 階段(研究用)Astrogator 再入段加阻力模型,消掉 1,631 m/s "
"的接縫。",
"第 2、3 階段合計預估數日工作量;第 4 階段需要氣動係數資料,"
"取決於太空飛機外型定案的程度。",
]),
("十、未解問題", [
"1. 上升段軌跡最佳化尚未進行,185 s 的分離時刻是設計值而非收斂解。",
"2. 再入減速帶 1,631 m/s 目前以解析式描述,未在 STK 中積分。",
"3. 太空飛機的氣動係數目前用 Aviator 內建的簡化模型;"
"若要談熱防護與結構,需要 CFD 或風洞資料。",
"4. Aviator 真空速上限約 5.4 km/s 的成因未確認 —— "
"已排除 MaxAirspeed 設定值(8,000/20,000/50,000 kt 三組結果相同),"
"但未定位到實際的限制來源。",
"5. 第二節作為有翼太空飛機,其上升段氣動負荷與控制未納入分析。",
]),
("附錄:檔案與驗證", [
("table", ["檔案", "用途"], [
["spaceport_analysis.py", "彈道與滑翔力學,不依賴 STK"],
["spaceport_ascent.py", "上升段時程與分離時刻,含三層驗證"],
["spaceport_vehicle.py", "太空飛機定義與 Aviator 效能模型設定"],
["spaceport_build.py", "建構 STK 場景(執行 --force 重建)"],
["spaceport_verify.py", "26 項驗證,從 data provider 取實算結果"],
["model/twsp2_spaceplane.glb", "太空飛機 3D 模型"],
]),
"驗證結果:26/26 通過。含落點偏差 0.76 km、觸地航向差 0.0 度、"
"高度未被模型上限夾住(最高 80,000 m)、真空速在可解範圍內、"
"剖面單調下降、出廠機型未被污染。",
]),
]
def write_txt(path, title=None, subtitle=None, meta=None, sections=None):
"""把內容寫成純文字。標題與內容可傳入,讓不同版本共用同一套排版。"""
title = TITLE if title is None else title
subtitle = SUBTITLE if subtitle is None else subtitle
meta = META if meta is None else meta
sections = SECTIONS if sections is None else sections
W = 76
L = []
L.append("=" * W)
L.append(title)
L.append(subtitle)
L.append("=" * W)
L.append("")
L.extend(meta)
L.append("")
for head, body in sections:
L.append("")
L.append("=" * W)
L.append(head)
L.append("=" * W)
for item in body:
if isinstance(item, tuple) and item and item[0] == "table":
_t, hdr, rows = item
L.append("")
# 欄寬按顯示寬度分配,總寬不超過版面
raw = [max([_dw(hdr[c])] + [_dw(r[c]) for r in rows])
for c in range(len(hdr))]
budget = W - 2 - 2 * (len(hdr) - 1)
if sum(raw) <= budget:
widths = raw
else:
widths = [max(10, int(budget * x / sum(raw))) for x in raw]
L.append(" " + " ".join(_pad(h, widths[i])
for i, h in enumerate(hdr)))
L.append(" " + " ".join("-" * widths[i]
for i in range(len(hdr))))
for r in rows:
cells = [_wrap(r[c], widths[c]) for c in range(len(hdr))]
n = max(len(c) for c in cells)
for k in range(n):
L.append(" " + " ".join(_pad(
cells[c][k] if k < len(cells[c]) else "",
widths[c]) for c in range(len(hdr))).rstrip())
L.append("")
else:
L.append("")
for ln in _wrap(str(item), W - 2):
L.append(" " + ln)
L.append("")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(L) + "\n")
return path
def _pad(text, width):
"""按顯示寬度補空格。str.ljust 會把中文算一格,欄位因此對不齊。"""
return str(text) + " " * max(0, width - _dw(text))
def _dw(text):
"""顯示寬度:中日韓字元佔兩格。用 len() 量中文會讓欄寬只有一半。"""
return sum(2 if ord(c) > 0x2E80 else 1 for c in str(text))
def _tokens(text):
"""切成不可再分的片段:CJK 逐字可斷,ASCII 詞不可從中間斷開。
不這樣做會出現 "Si / mpleAscent"、"7.0→5. / 4 km/s" 這種切法。
"""
out, buf = [], ""
for ch in str(text):
if ord(ch) > 0x2E80 or ch == " ":
if buf:
out.append(buf)
buf = ""
out.append(ch)
else:
buf += ch
if buf:
out.append(buf)
return out
def _wrap(text, width):
"""按顯示寬度折行,且不切斷 ASCII 詞。"""
out, cur, w = [], "", 0
for tok in _tokens(text):
tw = _dw(tok)
if w + tw > width and cur:
out.append(cur.rstrip())
cur, w = "", 0
if tok == " ":
continue
cur += tok
w += tw
if cur.strip() or not out:
out.append(cur.rstrip())
return out
def write_pptx(path):
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
FONT = "Microsoft JhengHei" # Windows 內建正黑體,PowerPoint 開得開
INK = RGBColor(0x1A, 0x1A, 0x1E)
MUTED = RGBColor(0x5A, 0x5E, 0x6B)
ACCENT = RGBColor(0x0B, 0x4F, 0x6C)
WARN = RGBColor(0xA8, 0x3A, 0x1E)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
def txbox(sl, x, y, w, h):
tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = tb.text_frame
tf.word_wrap = True
return tf
def setpara(p, text, size, bold=False, color=INK, space_after=6):
p.text = text
p.space_after = Pt(space_after)
for r in p.runs:
r.font.name = FONT
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
return p
# ── 封面 ──
sl = prs.slides.add_slide(blank)
tf = txbox(sl, 0.9, 2.0, 11.5, 2.4)
setpara(tf.paragraphs[0], TITLE, 40, True, ACCENT, 4)
setpara(tf.add_paragraph(), SUBTITLE, 26, False, INK, 18)
for m in META:
setpara(tf.add_paragraph(), m, 13, False, MUTED, 2)
# ── 內容頁 ──
for head, body in SECTIONS:
chunks = _split_body(body)
for ci, chunk in enumerate(chunks):
sl = prs.slides.add_slide(blank)
ht = txbox(sl, 0.7, 0.42, 12.0, 0.8)
setpara(ht.paragraphs[0],
head + ("" if len(chunks) == 1 else f"({ci+1}/{len(chunks)})"),
24, True, ACCENT, 4)
y = 1.35
for item in chunk:
if isinstance(item, tuple) and item and item[0] == "table":
_t, hdr, rows = item
y = _add_table(sl, hdr, rows, y, FONT, ACCENT, INK)
else:
text = str(item)
est = 0.30 + 0.245 * max(1, len(_wrap(text, 108)))
tf = txbox(sl, 0.75, y, 11.9, est)
col = WARN if text.startswith(("問題是", "做不到",
"兩條路都無法")) else INK
setpara(tf.paragraphs[0], text, 13.5, False, col, 4)
y += est + 0.06
prs.save(path)
return path, len(prs.slides._sldIdLst)
# 投影片可用高度:版面 7.5 in 減去標題區 1.35 與下緣留白。
SLIDE_AVAIL_IN = 5.85
def _item_height_in(item):
"""用**與繪製端相同的公式**估高度。
原本用「段落算 1 單位、表格算 2.2 單位」的粗估,結果 7 列的表格
和 3 列的算同樣重,表格較多的一節因此溢出版面 0.19 in。
分頁要準,就不能和繪製用不同的算法。
"""
if isinstance(item, tuple) and item and item[0] == "table":
rows = len(item[2])
return min(0.34 * (rows + 1) + 0.1, 5.4) + 0.18
lines = max(1, len(_wrap(str(item), 108)))
return 0.30 + 0.245 * lines + 0.06
def _split_body(body, avail_in=SLIDE_AVAIL_IN):
"""把一節切成幾張投影片,依實際估高而非單位數。"""
chunks, cur, h = [], [], 0.0
for item in body:
ih = _item_height_in(item)
if h + ih > avail_in and cur:
chunks.append(cur)
cur, h = [], 0.0
cur.append(item)
h += ih
if cur:
chunks.append(cur)
# 避免孤兒:尾段很小且併回去不會溢出時就併回前一張。
# 半空的投影片在正式文件裡看起來像沒做完,但溢出更糟 ——
# 所以條件用實際高度判斷,不用單位數。
if len(chunks) >= 2:
tail = sum(_item_height_in(x) for x in chunks[-1])
prev = sum(_item_height_in(x) for x in chunks[-2])
if tail <= 0.95 and prev + tail <= avail_in:
chunks[-2].extend(chunks.pop())
return chunks
def _add_table(sl, hdr, rows, y, font, accent, ink):
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
nr, nc = len(rows) + 1, len(hdr)
h = min(0.34 * nr + 0.1, 5.4)
shp = sl.shapes.add_table(nr, nc, Inches(0.75), Inches(y),
Inches(11.9), Inches(h))
tbl = shp.table
# 欄寬:最後一欄通常是說明,給寬一點
if nc == 3:
for i, frac in enumerate((0.30, 0.26, 0.44)):
tbl.columns[i].width = Inches(11.9 * frac)
elif nc == 2:
for i, frac in enumerate((0.34, 0.66)):
tbl.columns[i].width = Inches(11.9 * frac)
for c, t in enumerate(hdr):
cell = tbl.cell(0, c)
cell.text = t
for p in cell.text_frame.paragraphs:
for r in p.runs:
r.font.name = font
r.font.size = Pt(11.5)
r.font.bold = True
r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
cell.fill.solid()
cell.fill.fore_color.rgb = accent
for ri, row in enumerate(rows, 1):
for c, t in enumerate(row):
cell = tbl.cell(ri, c)
cell.text = str(t)
for p in cell.text_frame.paragraphs:
for r in p.runs:
r.font.name = font
r.font.size = Pt(10.5)
r.font.color.rgb = ink
return y + h + 0.18
if __name__ == "__main__":
p1 = write_txt(TXT)
print("文字檔 ->", p1, f"({os.path.getsize(p1):,} bytes)")
try:
p2, n = write_pptx(PPT)
print("PPTX ->", p2, f"({os.path.getsize(p2):,} bytes, {n} 張)")
except Exception as e:
print("PPTX 產生失敗:", e)
sys.exit(1)
spaceport_suborbital/spaceport_analysis.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 台美太空港計畫 —— 次軌道點對點任務的能量與軌跡分析
#
# 起飛 旭海發射場(屏東縣牡丹鄉) 22.0620 N 120.9020 E
# 降落 休士頓太空港 Ellington Field 29.6073 N 95.1588 W (KEFD)
#
# 載具:兩節式。第二節是有機翼的太空飛機,燃料有限,用於
# (1) 上升段末期的軌跡提升 (2) 再入滑翔段的軌跡控制。
#
# ------------------------------------------------------------------------------
# 這一章要回答的問題,以及為什麼它不是「畫出一條漂亮的弧線」
#
# 「次軌道」這個詞很容易誤導。New Shepard 打到 100 km 再落回原地,
# 需要的速度只有入軌速度的 13%;而屏東飛到休士頓是 13,109 km,
# **純彈道要 7.59 km/s,是 200 km 圓軌道速度的 97.5%**。
# 換句話說:這不是一次跳躍,是一枚入軌級運載火箭少掉最後一點速度。
#
# 機翼是這個任務的關鍵,但**效益沒有直覺上那麼大**,而且極度敏感:
# L/D 1.5 -> 只省 0.06 km/s
# L/D 2.0 -> 省 0.34 km/s
# L/D 3.0 -> 省 0.87 km/s
# 而高超音速 L/D 正是這類載具最難確定的參數。所以本章把 L/D 當成
# **掃描參數**,不是給一個數字了事。
#
# 一個容易犯的錯(本章開發時犯過):把「彈道射程」和「滑翔射程」相加。
# Eggers 平衡滑翔公式給的是**從燃燒結束算起的總射程**,不是增量;
# 相加會把 L/D=2 的效益從 0.34 km/s 誇大成 1.1 km/s。
# 正確的模型是三段:上升 -> 大氣外滑行 -> 再入滑翔。
# ==============================================================================
import json
import math
import os
MU = 3.986004418e14 # m^3/s^2
RE = 6378137.0 # m,WGS84 赤道半徑
G0 = 9.80665
HERE = os.path.dirname(os.path.abspath(__file__))
# ── 場址 ─────────────────────────────────────────────────────────────────────
LAUNCH_NAME = "Xuhai"
LAUNCH_LAT, LAUNCH_LON = 22.0620, 120.9020 # 旭海發射場(TASA 探空火箭場)
LAUNCH_ALT_M = 20.0
LANDING_NAME = "HoustonSpaceport"
LANDING_LAT, LANDING_LON = 29.6073, -95.1588 # Ellington Field KEFD 機場基準點
LANDING_ALT_M = 9.8 # 32 ft MSL
# KEFD 跑道 17R/35L —— 從北方進場落 17R
# 資料來源 SkyVector:9001 x 150 ft = 2744 x 46 m
# 17R 跑道頭 N29°37.10' W95°9.86' 真方位 179°
# 35L 跑道頭 N29°35.62' W95°9.83' 真方位 359°
KEFD_RUNWAY = {
"name": "KEFD_17R",
"lat": 29.60600, # 兩端跑道頭的中點
"lon": -95.16408,
"alt_m": 9.8,
"length_m": 2744.0,
"low_end_hdg": 179.0, # RWY 17R,向南落地
"high_end_hdg": 359.0, # RWY 35L
}
# ── 載具設計參數 ─────────────────────────────────────────────────────────────
BURNOUT_ALT_M = 100_000.0 # 第二節熄火高度
ENTRY_ALT_M = 120_000.0 # 再入介面(習慣取 120 km)
LD_SWEEP = (1.0, 1.5, 2.0, 2.5, 3.0)
LD_DESIGN = 1.74 # 設計點。見下方:2.0 -> 4.0 -> 3.2 -> 2.3 -> 2.2 -> 1.74
KARMAN_M = 100_000.0 # 卡門線。助推段的目標熄火高度
GAMMA_BO_DEG = 2.0 # 熄火飛行路徑角。淺角度是助推滑翔的關鍵
CL_GLIDE = 0.8 # 滑翔段的升力係數(高超音速下的典型配平值)
WING_LOADING_KGM2 = 250.0 # 翼負荷,用於推算平衡滑翔高度
AVIATOR_TAS_LIMIT_MS = 5_400.0 # Aviator 航段求解上限(STK 實測)
# ──────────────────────────────────────────────────────────────────────
# **設計點從 L/D 2.0 改為 4.0,以及為什麼 —— 這是一次實質更正**
#
# 舊架構用 boost_coast_glide():把射程拆成「大氣層外滑行」+「平衡滑翔」,
# 兩段各自用最小能量彈道與 Eggers 算所需速度,取較大者,再掃描分配比例
# 取最小。它算出 L/D 2.0、48% 滑行時 v_bo = 6,406 m/s,看起來比純滑翔
# (7,382 m/s)便宜近一千公尺每秒。
#
# **那個便宜是假的。** 最小能量彈道的熄火角是 γ = 45° − θ/4,射程愈短
# 角度愈陡 —— 6,292 km 的滑行段對應 γ = 30.9°,把載具拋到 **1,130 km**,
# 是卡門線的 11 倍。那條軌跡:
# * 對無翼彈頭確實最省(最小能量的定義)
# * 對有翼載具是白爬升,而且再入角太陡、熱負荷集中在很短的時間內
# * 沒有任何乘波/滑翔飛行器會這樣飛
#
# 真實的助推滑翔(Sänger 反蹠點轟炸機以降的概念)是:淺角度熄火在卡門線
# 附近,靠升力滑翔。實測淺角度熄火的遠地點:
# γ = 0° -> 100 km γ = 5° -> 269 km
# γ = 2° -> 130 km γ = 12° -> 808 km
# 相對 1,130 km 降為 1/9。
#
# 代價是同一個 L/D 下更貴(L/D 2.0 要 7,382 m/s),但要把 Δv 賺回來靠的是
# **提高升阻比**,不是拋高彈道:
# L/D 2.0 -> 7,091 m/s
# L/D 4.0 -> 6,118 m/s
# L/D 6.5 -> 5,220 m/s
# 所以設計點曾改為 L/D 4.0:它是實用乘波體的範圍,而且把假軌跡宣稱的
# 省油真的賺了回來。**但 4.0 是查文獻來的,不是算出來的** —— 見下方
# 第三次更正。
#
# 附帶發現:**L/D = 6.13 時弧終點速度恰好等於 Aviator 的 5,400 m/s 上限**,
# 也就是升阻比到那個值,整條任務可在 Aviator 內端對端模擬。
# 代價是細長低容積外型,運輸效率差。
#
# 這個錯誤是由外部審查者從**軌跡形狀**看出來的(頂點 1,100 km 不合理),
# 不是由數值檢查抓到的 —— 每個數字都「對」,組合起來卻是一條沒有載具
# 會飛的軌跡。boost_coast_glide() 保留在下方並標示已被取代。
# ──────────────────────────────────────────────────────────────────────
# **第二次更正(2026-09):真空弧的射程被重複計算**
#
# 上面那一版把 v_bo 定義成「從熄火起一路平衡滑翔 13,109 km 所需的速度」。
# 但熄火後有一段約 1,200 km 的弧是在大氣之上飛的,幾乎沒有阻力 ——
# 把那一段也算進 Eggers 滑翔射程,等於同一段路算了兩次。
# 多出來的差額當時被記成一個 3,765 km 的「無模型涵蓋」接縫,
# 並被當成工具限制寫進對外文件。**那不是工具限制,是記帳錯誤。**
#
# 現在 boost_glide() 解的是自洽的閉合條件:
# 弧射程(v_bo) + Eggers 滑翔射程(弧終點速度) = 全程
# 結果:v_bo 由 6,335 降到 6,118 m/s(真空弧確實比滑翔便宜),
# 弧終點速度恰好等於該點所需的滑翔進入速度,航程接縫歸零,
# 接縫消失升阻比由 6.54 修正為 6.13。
#
# 這一次同樣不是數值檢查抓到的 —— 舊版每一段的數字都自洽,
# 是**把三段加起來對照全程**才看出少了一塊。
# ──────────────────────────────────────────────────────────────────────
# **設計點由 L/D 4.0 改為 3.2(2026-09-25)**
#
# 4.0 是「實用乘波體的範圍」這種文獻式的說法,不是這個外型算出來的。
# 把它算出來需要兩步,而兩步都做完了:
#
# 1. cfd/su2_run.py 無黏 Euler:裸翼 L/D 5.55 @ α 10°
# 2. cfd/friction.py 加上平板摩擦(濕面積由實際網格算出):
# 裸翼 4.27
# 整機濕面積 3.21 <- 取這個
#
# 濕面積比:裸翼 2.01、整機 4.90 —— 機身與尾翼讓摩擦阻力翻 2.4 倍。
#
# **基底選錯的後果**:用牛頓流當基底加摩擦會得到 3.7-3.9,
# 但 CFD 那一節已經量過牛頓流在這個後掠翼上低估 L/D 約 15%。
# 基底要用 SU2 的無黏解。
#
# 改成 3.2 的後果(每一列都重解三段閉合):
# v_bo 5,936 -> 6,298 m/s (+361)
# Δv 7,287 -> 7,648 m/s (+361)
# 質量比 11.6 -> 13.0 (+13%)
# 酬載分數 4.99% -> 4.13% (-17%)
# 遠地點 105 -> 107 km、機腹壁溫 1,210 -> 1,219 °C(幾乎不動)
#
# **全章的招牌結論全部存活**:13,109 km 仍需 97% 的軌道速度、
# 機翼在低升阻比仍是淨負擔(門檻見 wing_break_even_ld())、工具邊界仍在 5.62。
# 質量比 13.0 也仍遠低於「20:1 難以實現」那條線。
#
# 3.2 本身仍是**估算**:整機那一欄把裸翼的無黏係數配上整機的濕面積,
# 機身產生的升力沒有計入。
# ──────────────────────────────────────────────────────────────────────
# **設計點再由 3.2 改為 2.3(2026-09-25,同日稍後)**
#
# 3.2 的問題正是上面那句話:它把**裸翼**的無黏升阻比套在整機上,
# 等於假設機身貢獻升力與阻力的比例和機翼一樣。機身主要是阻力,
# 所以那是偏樂觀的。
#
# 把牛頓流直接算在**整機網格**上(含遮蔽測試),再套 SU2 在裸翼量到的
# 修正,加上整機摩擦:
#
# 整機牛頓流(含遮蔽) 2.90 @ α 14°
# × SU2 修正 3.23
# + 整機摩擦 **2.33**
#
# 取 **2.3**。
#
# **為什麼相信 2.33 多過 3.2**:兩者用同一套摩擦,差別在無黏那一段
# 描述的是誰。2.33 描述的是**這架載具**,3.2 描述的是一片不是這架載具
# 的翼。外插修正因子帶來的不確定約 ±10%,遠小於 0.87 的差距。
#
# 改成 2.3 的後果:
# v_bo 6,298 -> 6,764 m/s (+466)
# Δv 7,648 -> 8,115 m/s (+467)
# 質量比 13.0 -> 15.3 (+18%)
# 酬載分數 4.13% -> 3.19% (-23%)
# 遠地點 107 -> 111 km、機腹壁溫 1,219 -> 1,213 °C(幾乎不動)
#
# **任務仍然可行**:質量比 15.3 仍低於「20:1 難以實現」那條線,
# 酬載分數 3.19% 仍為正。全章的招牌結論不變。
#
# 2.3 仍是估算,三個環節只有第一個是直接算的。要釘死需要**整機的
# SU2 解**,那是開放項目 11.6。
# ──────────────────────────────────────────────────────────────────────
# **再由 2.3 收到 2.2:設計點必須是不動點(2026-09-25,同日)**
#
# 2.3 接上去之後就**不自洽**了。摩擦係數是 Re* 的函數,
# Re* 由**弧終點的速度與高度**決定,而那個條件又由升阻比決定:
#
# 升阻比降低 -> 熄火速度升高 -> 弧終點速度升高
# -> Re* 降低 -> C_f 升高 -> 升阻比又更低
#
# 這就是第 06 節那條設計迴圈,在這裡變成一個真的數值問題。
# 先前 `full_vehicle_ld()` 的參考速度寫死 6,338 m/s(L/D 3.2 時的弧終點),
# 接到 2.3 自己的弧終點 6,798 m/s 之後,整機只給 **2.21**。
#
# `cfd/friction.py` 的 `design_ld_fixed_point()` 解這個不動點
# (阻尼迭代,增益約 0.17,是壓縮映射,5 次收斂):
#
# 假設 2.300 -> 2.213 假設 2.210 -> 2.200
# 假設 2.248 -> 2.206 假設 2.204 -> 2.199
# 假設 2.223 -> 2.202 **不動點 2.20 @ α 16°**
#
# 取 **2.2**。最大升阻比的攻角也從 14° 移到 16°。
#
# 2.3 與 2.2 只差 4%,遠在外插修正的 ±10% 不確定之內 ——
# 但**「設計點必須是不動點」不是 4% 的問題**,它是方法上的錯:
# 把摩擦釘在固定速度上算出來的升阻比屬於**別的**設計點。
# ──────────────────────────────────────────────────────────────────────
# **再由 2.2 降到 1.74:牛頓流的遮蔽演算法本身是錯的(2026-09-25,同日)**
#
# 設計點鏈上**唯一直接算的**那一環是整機的修正牛頓流。那一環有兩個錯,
# 方向相反、互相掩護,所以整體看起來很穩定:
#
# 1. **零面積三角形參與了遮蔽測試。** 它沒有面積、擋不住任何東西,
# 但投影足跡可能很長(兩個頂點重合、第三點很遠)。
# 本模型的 .obj 有 58 個這種三角形(機鼻塌成一點的扇形)。
# 2. **光柵化用的是投影的「包圍盒」而不是三角形本身**,
# 細長的斜三角形會霸佔整個盒子 —— 系統性地高估遮蔽、低估阻力。
#
# 第二個錯(過度宣稱)剛好補了「佔用集合會漏」的洞,所以先前的
# 解析度掃描看起來穩定。把包圍盒換成真正的三角形光柵化之後,
# L/D 在 240/320/440 格之間在 1.52 與 1.75 之間來回 —— **根本沒收斂**。
#
# 真正的原因是**二元分類**:面只能「全遮」或「全不遮」,
# 卡在門檻上時解析度一變就整片翻面。改成
# **深度緩衝(z-buffer)+ 可見比例加權**之後才收斂:
#
# 80 格 1.727 240 格 1.713 600 格 1.736
# 120 格 1.808 320 格 1.737 800 格 1.726 -> 穩在 1.73
#
# 新的遮蔽碼有三項驗證:解析度收斂、**凸體加不加遮蔽結果相同**、
# 以及球體的牛頓流阻力對上解析解 cp_max/2(0.9177 vs 0.9187,差 0.11%)。
#
# 修正後的鏈:
#
# 整機牛頓流(含遮蔽) 1.78 @ α 20° (原本算成 2.90 @ α 16°)
# × SU2 修正 1.88
# + 整機摩擦 **1.74**
#
# 解不動點得到 **1.74 @ α 20°**(7 次收斂)。
#
# 先跑敏感度再改(這一章第三次用同一個流程):掃 L/D 1.4-2.2,
# **一路到 1.4 都還可行** —— 質量比最高 18.0(仍低於 20:1 那條線)、
# 酬載分數仍為正(2.00%)。1.74 時質量比 17.0、酬載 2.26%。
#
# **全章的招牌結論仍然存活**:13,109 km 仍需 97% 的軌道速度、
# 機翼在低升阻比仍是淨負擔、接縫消失仍在 5.62。
#
# > **2026-09-25 更正**:這裡原本寫「設計點 1.74 距離損益平衡點 1.61
# > 只剩 8%,是迫近的設計約束」。**那個 1.61 是錯的** ——
# > 它是 L/D 4.0 時代、閉合條件還沒改之前的數字,而且 Python 裡
# > 從來沒有函式在算它。`wing_break_even_ld()` 把三種記帳都算出來
# > (0.81 / 0.60 / 1.10),**設計點的餘裕是 +59% 到 +192%**,
# > 不是 8%。見開放項目 11.10。
# ──────────────────────────────────────────────────────────────────────
# Δv 損失:重力損失 + 阻力 + 轉向。入軌任務典型 1.3-2.0 km/s
LOSS_SWEEP_MS = (1300.0, 1600.0, 2000.0)
LOSS_DESIGN_MS = 1600.0
# 兩節的比衝(真空,秒)
ISP1_S = 285.0 # 第一節,煤油/液氧級
ISP2_S = 340.0 # 第二節,較高膨脹比
# ══════════════════════════════════════════════════════════════════════════════
# 幾何
# ══════════════════════════════════════════════════════════════════════════════
def great_circle(lat1, lon1, lat2, lon2):
"""回傳 (中心角 rad, 距離 m, 初始方位 deg)。"""
p1, p2 = math.radians(lat1), math.radians(lat2)
dl = math.radians(lon2 - lon1)
c = math.sin(p1) * math.sin(p2) + math.cos(p1) * math.cos(p2) * math.cos(dl)
ang = math.acos(max(-1.0, min(1.0, c)))
x = math.sin(dl) * math.cos(p2)
y = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
az = (math.degrees(math.atan2(x, y)) + 360.0) % 360.0
return ang, ang * RE, az
def point_along(lat1, lon1, az_deg, dist_m):
"""從 (lat1, lon1) 沿大圓以初始方位 az 前進 dist_m。"""
d = dist_m / RE
p1 = math.radians(lat1)
a = math.radians(az_deg)
p2 = math.asin(math.sin(p1) * math.cos(d)
+ math.cos(p1) * math.sin(d) * math.cos(a))
dl = math.atan2(math.sin(a) * math.sin(d) * math.cos(p1),
math.cos(d) - math.sin(p1) * math.sin(p2))
return math.degrees(p2), lon1 + math.degrees(dl)
# ══════════════════════════════════════════════════════════════════════════════
# 彈道段
# ══════════════════════════════════════════════════════════════════════════════
def v_circular(alt_m=200_000.0):
return math.sqrt(MU / (RE + alt_m))
def ballistic_min_energy(theta, r0=RE):
"""最小能量彈道。回傳 dict。
球形地球、無阻力、起點與落點同半徑 r0。經典解:
v²/(g·r0) = 2·sin(θ/2) / (1 + sin(θ/2))
γ = 45° − θ/4
"""
s = math.sin(theta / 2.0)
g = MU / r0 ** 2
v2 = (2.0 * s / (1.0 + s)) * g * r0
v = math.sqrt(v2)
gam = math.radians(45.0 - math.degrees(theta) / 4.0)
eps = v2 / 2.0 - MU / r0
a = -MU / (2.0 * eps)
h = r0 * v * math.cos(gam)
p = h * h / MU
e = math.sqrt(max(0.0, 1.0 - p / a))
apo = a * (1.0 + e) - RE
cosnu = (p / r0 - 1.0) / e if e > 1e-9 else 1.0
nu1 = math.acos(max(-1.0, min(1.0, cosnu)))
E1 = 2.0 * math.atan(math.sqrt((1 - e) / (1 + e)) * math.tan(nu1 / 2.0))
M1 = E1 - e * math.sin(E1)
n = math.sqrt(MU / a ** 3)
tof = 2.0 * (math.pi - M1) / n
return {"v": v, "gamma_deg": math.degrees(gam), "apogee_m": apo,
"tof_s": tof, "a": a, "e": e, "theta": theta}
def ballistic_v_for_range(range_m, r0=RE):
"""反解:要飛這個射程,最小能量彈道需要多少速度。"""
th = range_m / RE
return ballistic_min_energy(th, r0)
def ballistic_range_for_v(v, r0=RE):
"""正解的反函數:給定速度,最小能量彈道能飛多遠(m)。"""
lo, hi = 1e-5, math.pi - 1e-5
for _ in range(100):
mid = 0.5 * (lo + hi)
if ballistic_min_energy(mid, r0)["v"] < v:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi) * RE
# ══════════════════════════════════════════════════════════════════════════════
# 滑翔段
# ══════════════════════════════════════════════════════════════════════════════
def eggers_glide_range(v_entry, ld, v_c=None):
"""Eggers 平衡滑翔射程(m)。
R = (L/D) · (R_E/2) · ln[ 1 / (1 − (V_E/V_c)²) ]
**這是從再入點算起的總滑翔射程,不是可以疊加在彈道射程上的增量。**
把兩者相加會嚴重誇大機翼的效益(L/D=2 從 0.34 km/s 誇大到 1.1 km/s)。
假設:等升阻比、升力與重力/離心力平衡、指數大氣。
對第一階段設計估算夠用;真實軌跡要用有阻力與升力的六自由度積分。
"""
vc = v_c if v_c else v_circular(0.0)
x = (v_entry / vc) ** 2
if x >= 1.0:
return float("inf")
return ld * (RE / 2.0) * math.log(1.0 / (1.0 - x))
def glide_v_for_range(range_m, ld, v_c=None):
"""反解:要滑翔這個射程,再入速度要多少。"""
vc = v_c if v_c else v_circular(0.0)
arg = range_m / (ld * RE / 2.0)
x = 1.0 - math.exp(-arg)
return vc * math.sqrt(x)
# ══════════════════════════════════════════════════════════════════════════════
# 三段式 boost–coast–glide
# ══════════════════════════════════════════════════════════════════════════════
def orbit_from_burnout(r_m, v_ms, gamma_deg):
"""由熄火狀態求軌道元素與遠地點。
熄火不在地表,所以不能用 ballistic_min_energy()(它假設起落點同半徑)。
"""
g = math.radians(gamma_deg)
eps = v_ms * v_ms / 2.0 - MU / r_m
a = -MU / (2.0 * eps)
h = r_m * v_ms * math.cos(g)
p = h * h / MU
e = math.sqrt(max(0.0, 1.0 - p / a))
return {"a": a, "e": e, "eps": eps,
"apogee_m": a * (1.0 + e) - RE,
"perigee_m": a * (1.0 - e) - RE}
def us76_density(alt_m):
"""US Standard Atmosphere 1976 密度。86 km 以上以標高 6 km 指數外插。
外插段標示出來:那不是標準大氣的一部分,只是為了讓平衡滑翔高度的
求解在上界不會發散。
"""
layers = ((0.0, 288.15, -0.0065, 101325.0), (11000.0, 216.65, 0.0, 22632.1),
(20000.0, 216.65, 0.001, 5474.89), (32000.0, 228.65, 0.0028, 868.019),
(47000.0, 270.65, 0.0, 110.906), (51000.0, 270.65, -0.0028, 66.9389),
(71000.0, 214.65, -0.002, 3.95642), (84852.0, 186.946, 0.0, 0.373384))
R = 287.053
# **US76 的分層界線是位勢高度,不是幾何高度。**
# 少了這個轉換,47 km 以下看不出來,但往上迅速惡化:
# 50 km -4.8% 70 km -10.4% 80 km -14.9%
# 而本任務的滑翔段就在 70 km 附近。轉換之後誤差回到 0.00%。
# 這是 2026-09-25 由純 Python 測試抓到的 —— STK 那一層驗不到它。
R_E76 = 6356766.0 # US76 用的有效地球半徑
z = float(alt_m)
h = R_E76 * z / (R_E76 + z) # 幾何 -> 位勢
if h > 84852.0: # 幾何約 86 km,US76 的上界
rho0 = 0.373384 / (R * 186.946)
return rho0 * math.exp(-(h - 84852.0) / 6000.0)
hb, tb, lr, pb = layers[0]
for lh, lt, ll, lp in layers:
if h >= lh:
hb, tb, lr, pb = lh, lt, ll, lp
else:
break
if lr == 0.0:
t = tb
p = pb * math.exp(-G0 * (h - hb) / (R * tb))
else:
t = tb + lr * (h - hb)
p = pb * (t / tb) ** (-G0 / (lr * R))
return p / (R * t)
def equilibrium_glide_alt(v_ms, wing_loading=WING_LOADING_KGM2, cl=CL_GLIDE):
"""平衡滑翔的高度:升力等於**離心解除後**的有效重量。
½·ρ·v²·C_L = (W/S)·g·(1 − v²/v_c²)
離心解除那一項是關鍵。v = 7,400 m/s 時有效重量只剩 12%,
所以滑翔高度比直覺高很多(約 81 km);速度衰減後才逐步下降。
這說明了一件常被誤解的事:**卡門線是「進入」高度,不是滑翔高度。**
載具在 100 km 熄火後會下沉到 70–85 km 才建立平衡滑翔。
"""
eff = max(0.0, 1.0 - (v_ms / v_circular(0.0)) ** 2)
lift_needed = wing_loading * G0 * eff
if lift_needed <= 0.0 or v_ms <= 0.0:
return float("inf")
rho_needed = 2.0 * lift_needed / (cl * v_ms * v_ms)
lo, hi = 0.0, 130_000.0
for _ in range(80):
mid = 0.5 * (lo + hi)
if us76_density(mid) > rho_needed:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
def _arc_and_entry(v_bo, burnout_alt_m, gamma_bo_deg):
"""給定熄火狀態,求真空弧的射程與弧終點狀態。
弧從熄火點沿圓錐曲線下降,終點定義為**大氣開始撐得住載具**之處,
也就是該速度的平衡滑翔高度。高度與速度互為因變(高度定速度、
速度又定高度),所以用固定點迭代收斂 —— 相依很弱,五次就夠。
回傳 (弧射程 m, 弧終點高度 m, 弧終點速度 m/s, 軌道元素)。
"""
orb = orbit_from_burnout(RE + burnout_alt_m, v_bo, gamma_bo_deg)
a, e = orb["a"], orb["e"]
if e <= 1e-9 or not math.isfinite(a):
return 0.0, burnout_alt_m, v_bo, orb
p = a * (1.0 - e * e)
def nu_of(r):
c = (p / r - 1.0) / e
return math.acos(max(-1.0, min(1.0, c)))
# 夾在物理定義域內。平衡滑翔高度在 v → v_c 時發散(離心力把重量
# 全部抵銷),固定點迭代會因此跑掉,所以兩端都要夾:
# - 速度不得達到圓軌速度(那已經不是滑翔)
# - 弧終點不可能高於熄火點(弧是在下降支)
vc_local = v_circular(0.0)
def glide_alt_clamped(v):
alt_ = equilibrium_glide_alt(min(v, 0.98 * vc_local))
return min(alt_, burnout_alt_m) if math.isfinite(alt_) else burnout_alt_m
alt = glide_alt_clamped(v_bo)
v_end = v_bo
for _ in range(8):
r_gl = RE + alt
v_end = math.sqrt(max(0.0, MU * (2.0 / r_gl - 1.0 / a)))
alt = glide_alt_clamped(v_end)
r_gl = RE + alt
arc_rad = max(0.0, (2.0 * math.pi - nu_of(r_gl)) - nu_of(RE + burnout_alt_m))
return arc_rad * RE, alt, v_end, orb
def boost_glide(target_range_m, ld=None, burnout_alt_m=KARMAN_M,
gamma_bo_deg=GAMMA_BO_DEG, ascent_fn=None):
"""淺角度助推 + 平衡滑翔。**這是本專案採用的架構。**
與 boost_coast_glide() 的差別:不再把射程拆成兩段各自求解。
熄火點放在卡門線附近、角度壓低,之後全程靠升力飛完。
**2026-09 修正:真空弧的射程要計入閉合條件。**
先前的寫法是 v_bo = glide_v_for_range(全程),等於假設「從熄火起
就一路平衡滑翔」。但熄火後有一段 1,100–1,300 km 的弧是在大氣之上
飛的,那一段幾乎沒有阻力 —— 把它也算進滑翔射程,就把同一段路
**重複計算**了一次,多出來的差額過去被推給一個 3,765 km 的
「無模型涵蓋」接縫。
現在解的是自洽的閉合條件:
弧射程(v_bo) + Eggers 滑翔射程(弧終點速度) = 全程
v_bo 因此降了約 218 m/s(真空弧比滑翔便宜),而且
**弧終點速度恰好等於該點所需的滑翔進入速度,接縫歸零。**
"""
ld = LD_DESIGN if ld is None else ld
vc = v_circular(0.0)
# ascent_fn(v_bo) -> 上升段沿程(m)。用**注入**而不是 import,
# 因為上升段模組本身依賴這裡,直接 import 會成環。
# 不給就退回舊行為(把上升段當成一個點)—— 那正是 2026-09-25
# 發現的第三個記帳錯誤:整條任務會飛過落點 1,259 km。
def asc(v):
return 0.0 if ascent_fn is None else float(ascent_fn(v))
def closure(v):
arc_m, alt, v_end, orb = _arc_and_entry(v, burnout_alt_m, gamma_bo_deg)
arc_m = min(target_range_m * 0.5, arc_m)
return (asc(v) + arc_m + eggers_glide_range(v_end, ld)
- target_range_m)
lo, hi = 200.0, vc * 0.9999
feasible = closure(hi) >= 0.0
if feasible:
for _ in range(48):
mid = 0.5 * (lo + hi)
if closure(mid) > 0.0:
hi = mid
else:
lo = mid
v_bo = 0.5 * (lo + hi)
else:
v_bo = hi # 射程超出這個升阻比能做到的範圍
arc_m, arc_alt, v_entry, orb = _arc_and_entry(v_bo, burnout_alt_m,
gamma_bo_deg)
arc_m = min(target_range_m * 0.5, arc_m)
ascent_m = asc(v_bo)
glide_m = max(0.0, target_range_m - arc_m - ascent_m)
return {
"ld": ld, "range_m": target_range_m,
"burnout_alt_m": burnout_alt_m, "gamma_bo_deg": gamma_bo_deg,
"v_bo": v_bo, "v_entry": v_entry,
"apogee_m": orb["apogee_m"], "perigee_m": orb["perigee_m"],
"e": orb["e"], "a": orb["a"],
"ascent_m": ascent_m, "arc_m": arc_m, "glide_m": glide_m,
"glide_alt_at_bo_m": arc_alt,
"pct_of_circular": v_bo / vc * 100.0,
# 閉合殘差:自洽解應為 0。留著是為了讓上層能驗證,而不是相信。
"closure_residual_m": (ascent_m + arc_m
+ eggers_glide_range(v_entry, ld)
- target_range_m),
# 舊欄位。接縫現在是 Aviator 求解器的邊界,不是航程的缺口 ——
# 那一段由外部星曆飛,這個值只用來回答「Aviator 能不能獨力飛完」。
"seam_ms": max(0.0, v_entry - AVIATOR_TAS_LIMIT_MS),
"feasible": feasible and math.isfinite(v_bo),
}
def arc_duration_s(v_bo, burnout_alt_m=None, gamma_bo_deg=None,
arc_end_alt_m=None):
r"""真空弧的**解析**飛行時間(s)。
## 為什麼需要它
真空弧在 STK 裡是 `Missile` + 彈道傳播器,而那個物件的**時間不對**:
傳播器一定要從地面發射,所以它的軌跡包含一段從地面爬到熄火高度的
**贗品**(本設計 159 s,佔它 406 s 可用區間的 39%),
而且它不帶動力上升段的能量(終端速度 5,254 vs 解析 6,747 m/s)。
三個數字都不一樣:
Missile 可用區間 406 s 含 159 s 的贗品爬升
Missile 的弧段部分 247 s 扣掉贗品之後
**解析弧時長** **346 s** ← 這個才是任務的
星曆是由解析解生出來的,所以時間軸也要用解析解。
## 算法
由熄火狀態求出圓錐曲線,再用偏近點角求兩個半徑之間的飛行時間:
cos E = (1 − r/a) / e M = E − e·sin E t = ΔM / n
分成「熄火 → 遠地點」與「遠地點 → 弧終點」兩段相加 ——
因為兩段在不同的分支上,直接用一次 ΔM 會拿到錯的那一支。
"""
burnout_alt_m = KARMAN_M if burnout_alt_m is None else burnout_alt_m
gamma_bo_deg = GAMMA_BO_DEG if gamma_bo_deg is None else gamma_bo_deg
orb = orbit_from_burnout(RE + burnout_alt_m, v_bo, gamma_bo_deg)
a, e = orb["a"], orb["e"]
if not math.isfinite(a) or e <= 1e-9:
return 0.0
if arc_end_alt_m is None:
arc_end_alt_m = equilibrium_glide_alt(v_bo)
n = math.sqrt(MU / a ** 3)
def _M(r):
c = max(-1.0, min(1.0, (1.0 - r / a) / e))
ecc = math.acos(c)
return ecc - e * math.sin(ecc)
r_bo = RE + burnout_alt_m
r_ap = a * (1.0 + e)
r_end = RE + arc_end_alt_m
return (abs(_M(r_ap) - _M(r_bo)) + abs(_M(r_ap) - _M(r_end))) / n
def wing_break_even_ld(target_range_m=None, ascent_fn=None, iters=34,
lo=0.3, hi=8.0):
r"""機翼由「淨負擔」轉為「有利」的升阻比。回傳 dict。
## 這個門檻在講什麼
同樣飛 13,109 km,比較兩架載具:
* **無翼**:純彈道(最小能量解),必須把自己拋到 1,189 km
* **有翼**:助推滑翔,熄火角壓到 2°,遠地點 117 km
升阻比低到某個程度時,機翼帶來的升力還不夠付它自己的重量與阻力,
所需的熄火速度反而**高於**純彈道 —— 那時機翼是淨負擔。
## 記帳方式會改變答案,而且改很多
助推滑翔那一側的閉合**含動力上升段的沿程**(約 1,570 km),
純彈道基準 `ballistic_v_for_range()` 卻是「發射點到落點」的純彈道解,
**不含上升段**。兩邊記帳不同,比出來的門檻就不是同一件事。
所以這裡把三種都算出來:
| | 滑翔側 | 彈道側 | 本任務實測 | 評價 |
|---|---|---|---|---|
| **`no_ascent`** | 不含 | 不含 | **0.81** | **well-posed,用這個** |
| `mixed` | **含** | 不含 | 0.60 | 不一致 |
| `with_ascent` | 含 | 含 | 1.10 | **問題不成立** |
## 為什麼 `no_ascent` 才是對的
`ballistic_min_energy()` 的假設寫在它自己的 docstring 裡:
**「球形地球、無阻力、起點與落點同半徑 r0」**。也就是那個基準
**根本沒有熄火高度** —— 發射點就是熄火點,動力上升段被當成
發射點的一個**點事件**。
`boost_glide()` 不傳 `ascent_fn` 時是同一個層級的理想化:
熄火點直接落在發射場上空,弧 + 滑翔涵蓋全程。
**兩邊的理想化層級一致,所以可以比。**
## `mixed` 為什麼會偏向機翼
它只在滑翔側加上真實的上升段沿程(約 1,570 km),等於讓有翼載具
少飛 1,570 km 的滑翔,而無翼載具沒有同樣的折扣。
門檻因此被壓低到 0.60。**網頁畫的就是這一條**(見下)。
## `with_ascent` 為什麼問題不成立
給純彈道基準「加一段上升段」是重複計入 —— 那個基準的發射點
**已經是**熄火點了。而且就算硬要算,目標狀態本身不一致:
熄火時 γ = 15.6°、v = 7,400 m/s 表示垂直速度 2 km/s,
要在那一刻只有 100 km,幾十秒前得在十幾公里高以數 km/s 飛行。
實測上升段積分在 γ = 15.6° 時差 398 km,而且**把踢角上限從 6°
拉到 80° 完全沒用** —— γ 一旦夾住,剩餘燃燒時間就一路以那個角度爬。
這一欄保留是為了讓「為什麼不能這樣比」有地方講,**不要拿它當答案**。
## 結論
**門檻是 0.81,設計點 1.74 的餘裕 +116%。**
文件裡曾經寫的 **1.61 三種算法都不是** —— 那是 L/D 4.0 時代、
閉合條件還沒改之前的數字,而且 Python 裡從來沒有函式在算它。
"""
target_range_m = (great_circle(LAUNCH_LAT, LAUNCH_LON,
KEFD_RUNWAY["lat"],
KEFD_RUNWAY["lon"])[1]
if target_range_m is None else target_range_m)
bal_plain = ballistic_v_for_range(target_range_m)["v"]
def cross(afn, target):
a, b = lo, hi
for _ in range(iters):
m = 0.5 * (a + b)
if boost_glide(target_range_m, m, ascent_fn=afn)["v_bo"] > target:
a = m
else:
b = m
return 0.5 * (a + b)
out = {"ballistic_v_ms": bal_plain,
"no_ascent": cross(None, bal_plain)}
if ascent_fn is not None:
# 彈道側也含上升段:解 ascent(v) + ballistic_range(v) = 全程
a, b = 5_000.0, 9_500.0
for _ in range(40):
m = 0.5 * (a + b)
if ascent_fn(m) + ballistic_range_for_v(m) < target_range_m:
a = m
else:
b = m
bal_asc = 0.5 * (a + b)
out["ballistic_v_with_ascent_ms"] = bal_asc
out["mixed"] = cross(ascent_fn, bal_plain)
out["with_ascent"] = cross(ascent_fn, bal_asc)
for k in ("no_ascent", "mixed", "with_ascent"):
if k in out:
out[k + "_margin_pct"] = (LD_DESIGN / out[k] - 1.0) * 100.0
return out
def ld_for_velocity(target_range_m, v_target_ms, lo=0.5, hi=25.0, on="entry",
ascent_fn=None):
"""反解:要把速度壓到 v_target,需要多少升阻比。
用來回答「升阻比要多高,整條任務才進得了 Aviator 的可解範圍」。
`on` 決定壓的是哪個速度:
"entry" 真空弧終點速度 —— **預設,也是該用的那個**。
Aviator 要接手的是這個速度,不是熄火速度。
"burnout" 熄火速度。舊行為,保留供對照。
改用 "entry" 之後,本任務的接縫消失升阻比由 6.54 修正為 6.13 ——
差別來自真空弧:弧終點比熄火點低,速度反而略高。
**`ascent_fn` 不能省。** 它一路往下傳給 `boost_glide()`;
不傳的話解出來的是「沒有動力上升段」那條閉合,升阻比會偏高。
本任務實測 **6.13(不含上升段)vs 5.62(含)**。
這個參數先前不存在,於是同一個程式裡對同一個問題有兩個答案:
`spaceport_build.SEAM_FREE_LD` 是 5.6235(含上升段算的),
而 `describe_plan()` 印出來的是這支函式給的 6.13。
網頁的 `ldForVelocity()` 走 `boostGlide()`,一直都含上升段,
所以網頁是對的、Python 是錯的 —— 交叉核對沒有蓋到這個量。
這是「上升段沿程沒被計入」那個錯誤活下來的最後一處。
"""
for _ in range(48):
mid = 0.5 * (lo + hi)
bg = boost_glide(target_range_m, mid, ascent_fn=ascent_fn)
v = bg["v_bo"] if on == "burnout" else bg["v_entry"]
if v > v_target_ms:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
# ══════════════════════════════════════════════════════════════════════════════
# 已被取代:分段代理模型
#
# 保留的理由是它記錄了一個實際犯過的錯 —— 每個數字都對,組合起來卻是
# 一條 1,130 km 遠地點的軌跡,沒有有翼載具會那樣飛。
# **不要用它做設計。** 用 boost_glide()。
# ══════════════════════════════════════════════════════════════════════════════
def boost_coast_glide(target_range_m, ld, coast_frac,
burnout_alt_m=BURNOUT_ALT_M):
"""**【已被取代,勿用於設計】** 分段代理模型。
把射程拆成「大氣外滑行」+「平衡滑翔」,解出需要的燃燒結束速度。
coast_frac 是大氣外滑行段佔總射程的比例(0 = 一熄火就滑翔,
1 = 純彈道)。這個比例是**設計變數**,取決於熄火時的軌跡角:
拉高(lofted)則滑行段長、再入較陡;壓低(depressed)則相反。
回傳 dict;解不出來時 feasible=False。
"""
coast_m = target_range_m * coast_frac
glide_m = target_range_m - coast_m
r0 = RE + burnout_alt_m
# 滑翔段需要的再入速度
v_entry = glide_v_for_range(glide_m, ld) if glide_m > 1.0 else 0.0
# 大氣外滑行段需要的熄火速度(以最小能量彈道近似該段)
if coast_m > 1.0:
v_coast = ballistic_min_energy(coast_m / RE, r0)["v"]
else:
v_coast = 0.0
# 熄火速度要同時滿足兩者 —— 取較大者。
#
# **先前把這稱為「保守的一階近似」,那個說法已收回。**
# v_coast 與 v_entry 在物理上不是可以各自獨立指定的量:熄火的單一
# 狀態同時決定了滑行弧與再入條件。而且這裡的 v_coast 用的是
# 「起落點同半徑的最小能量彈道」,不是真實滑行段的需求。
# 所以 max() 既不是上界也不是下界。
#
# 更根本的問題見檔頭:它算出的軌跡遠地點 1,130 km,不可飛。
v_bo = max(v_entry, v_coast)
return {
"ld": ld, "coast_frac": coast_frac,
"coast_m": coast_m, "glide_m": glide_m,
"v_entry": v_entry, "v_coast": v_coast, "v_bo": v_bo,
"feasible": math.isfinite(v_bo) and v_bo < v_circular(0.0) * 1.02,
}
# ══════════════════════════════════════════════════════════════════════════════
# Δv 與質量
# ══════════════════════════════════════════════════════════════════════════════
def dv_budget(v_bo, loss_ms=LOSS_DESIGN_MS, lat_deg=LAUNCH_LAT, az_deg=None):
"""總 Δv,含地球自轉助益。"""
v_rot = 465.1 * math.cos(math.radians(lat_deg))
assist = 0.0
if az_deg is not None:
assist = v_rot * math.sin(math.radians(az_deg))
return {"v_bo": v_bo, "loss": loss_ms, "rot_assist": assist,
"dv_total": v_bo + loss_ms - assist}
def mass_ratio(dv, isp_s):
return math.exp(dv / (isp_s * G0))
def two_stage_split(dv_total, isp1=ISP1_S, isp2=ISP2_S, split=0.62):
"""把總 Δv 分給兩節,回傳各節質量比。
split 是第一節承擔的比例。0.6-0.65 是兩節式常見的分配。
"""
dv1 = dv_total * split
dv2 = dv_total - dv1
return {"dv1": dv1, "dv2": dv2,
"mr1": mass_ratio(dv1, isp1), "mr2": mass_ratio(dv2, isp2),
"mr_total": mass_ratio(dv1, isp1) * mass_ratio(dv2, isp2),
"split": split}
# ══════════════════════════════════════════════════════════════════════════════
# 再入條件
# ══════════════════════════════════════════════════════════════════════════════
def entry_conditions(v_entry, ld):
"""再入的量級估算:停滯點熱通量比例、平衡滑翔的高度與過載。
熱通量用 Sutton–Graves 的比例形式 q ∝ sqrt(ρ)·v³ —— 這裡只給
**相對於軌道再入的比值**,絕對值需要鼻錐半徑與大氣模型。
"""
v_orb = v_circular(0.0)
q_ratio = (v_entry / v_orb) ** 3
# 平衡滑翔:L = W − mv²/r -> 取 n_z = cos 項,約 1/(L/D) 量級的縱向減速
decel_g = 1.0 / ld if ld > 0 else float("nan")
return {"v_entry": v_entry, "q_rel_to_orbital": q_ratio,
"axial_decel_g_order": decel_g}
# ══════════════════════════════════════════════════════════════════════════════
def analyse(verbose=True):
out = {}
ang, dist, az = great_circle(LAUNCH_LAT, LAUNCH_LON,
LANDING_LAT, LANDING_LON)
out["range_m"] = dist
out["central_angle_deg"] = math.degrees(ang)
out["azimuth_deg"] = az
vc = v_circular(200_000.0)
out["v_circ_200km"] = vc
if verbose:
print("=" * 78)
print("一、航線")
print("=" * 78)
print(f" {LAUNCH_NAME} ({LAUNCH_LAT:.4f} N, {LAUNCH_LON:.4f} E)"
f" -> {LANDING_NAME} ({LANDING_LAT:.4f} N, {LANDING_LON:.4f} E)")
print(f" 大圓距離 {dist/1000:,.0f} km 中心角 {math.degrees(ang):.1f}°"
f" 初始方位 {az:.1f}°")
# ---- 基準:純彈道(沒有機翼)----
b = ballistic_min_energy(ang)
out["ballistic"] = {
"v_bo": b["v"], "gamma_deg": b["gamma_deg"],
"apogee_m": b["apogee_m"], "tof_s": b["tof_s"],
"pct_of_orbital": 100.0 * b["v"] / vc,
}
if verbose:
print()
print("=" * 78)
print("二、基準:純彈道(無機翼)")
print("=" * 78)
print(f" 燃燒結束速度 {b['v']:,.0f} m/s = {b['v']/1000:.2f} km/s")
print(f" 軌跡角 {b['gamma_deg']:.1f}°")
print(f" 遠地點 {b['apogee_m']/1000:,.0f} km")
print(f" 飛行時間 {b['tof_s']/60:.1f} 分鐘")
print(f" ** 相當於 200 km 圓軌道速度的 "
f"{100*b['v']/vc:.1f}% ** —— 這不是一次跳躍,是入軌級任務")
# ---- 機翼的效益:純平衡滑翔 ----
if verbose:
print()
print("=" * 78)
print("三、機翼的效益(純平衡滑翔,從熄火算起)")
print("=" * 78)
print(f" {'L/D':>5} {'需要 v_bo':>12} {'相對純彈道':>12} {'省下':>10}")
out["glide_only"] = {}
for ld in LD_SWEEP:
v_need = glide_v_for_range(dist, ld)
save = b["v"] - v_need
out["glide_only"][ld] = {"v_bo": v_need, "save_ms": save}
if verbose:
print(f" {ld:5.1f} {v_need/1000:9.2f} km/s "
f"{100*v_need/b['v']:11.1f}% {save:9.0f} m/s")
if verbose:
print()
print(" 注意:Eggers 公式給的是**從再入點算起的總射程**,")
print(" 不是可以加在彈道射程上的增量。相加會把 L/D=2 的效益")
print(" 從 0.34 km/s 誇大成 1.1 km/s —— 本章開發時犯過這個錯。")
# ---- 三段式:滑行比例是設計變數 ----
if verbose:
print()
print("=" * 78)
print(f"四、淺角度助推滑翔(熄火於卡門線,γ = {GAMMA_BO_DEG}°)")
print("=" * 78)
print(f" {'L/D':>5} {'v_bo m/s':>10} {'弧終 m/s':>10} "
f"{'真空弧 km':>11} {'遠地點 km':>11} "
f"{'滑翔高度 km':>12} {'閉合殘差 m':>11}")
out["boost_glide"] = {}
for ld in (1.5, 2.0, 3.0, LD_DESIGN, 5.0, 6.54, 8.0):
r = boost_glide(dist, ld)
out["boost_glide"][ld] = r
if verbose:
mark = " <- 設計點" if abs(ld - LD_DESIGN) < 1e-9 else ""
print(f" {ld:5.2f} {r['v_bo']:10,.0f} {r['v_entry']:10,.0f} "
f"{r['arc_m']/1000:11,.0f} {r['apogee_m']/1000:11,.0f} "
f"{r['glide_alt_at_bo_m']/1000:12.1f} "
f"{r['closure_residual_m']:11.3f}{mark}")
des = boost_glide(dist, LD_DESIGN)
out["design_v_bo"] = des["v_bo"]
out["design_apogee_m"] = des["apogee_m"]
ld_sf = ld_for_velocity(dist, AVIATOR_TAS_LIMIT_MS)
out["ld_seam_free"] = ld_sf
if verbose:
print()
print(f" 設計點 L/D {LD_DESIGN}:v_bo = {des['v_bo']/1000:.2f} km/s,"
f"遠地點 {des['apogee_m']/1000:,.0f} km")
print(f" 真空弧 {des['arc_m']/1000:,.0f} km + 平衡滑翔 "
f"{des['glide_m']/1000:,.0f} km = {dist/1000:,.0f} km"
f"(閉合殘差 {des['closure_residual_m']:.3f} m)")
print(" **舊版的分段代理模型算出遠地點 1,130 km —— 不可飛,已移除。**")
print(" **2026-09 再修正:真空弧的射程先前被重複計入滑翔段,"
"多出來的差額")
print(" 被推給一個 3,765 km 的『無模型涵蓋』接縫。改成自洽閉合後")
print(f" v_bo 由 6,335 降到 {des['v_bo']:,.0f} m/s,該接縫歸零。**")
print(f" 升阻比提高到 {ld_sf:.2f} 時**弧終點速度**降到 Aviator 求解上限"
f"({AVIATOR_TAS_LIMIT_MS:,.0f} m/s)以下,")
print(" 整條任務可在 Aviator 內端對端模擬。")
# ---- Δv 與質量 ----
v_des = out["design_v_bo"]
if verbose:
print()
print("=" * 78)
print("五、Δv 預算與質量比")
print("=" * 78)
print(f" 設計燃燒結束速度 {v_des/1000:.2f} km/s")
print(f" 緯度 {LAUNCH_LAT:.2f}° 的自轉速度 "
f"{465.1*math.cos(math.radians(LAUNCH_LAT)):.0f} m/s,"
f"方位 {az:.0f}° 可用分量 "
f"{465.1*math.cos(math.radians(LAUNCH_LAT))*math.sin(math.radians(az)):.0f} m/s")
print()
print(f" {'損失':>8} {'總 Δv':>10} {'總質量比':>10} "
f"{'一節 Δv':>9} {'二節 Δv':>9}")
out["dv"] = {}
for loss in LOSS_SWEEP_MS:
d = dv_budget(v_des, loss, LAUNCH_LAT, az)
sp = two_stage_split(d["dv_total"])
out["dv"][loss] = {**d, **sp}
if verbose:
print(f" {loss/1000:6.1f} km/s {d['dv_total']/1000:8.2f} km/s "
f"{sp['mr_total']:9.2f} {sp['dv1']/1000:7.2f} "
f"{sp['dv2']/1000:8.2f} km/s")
# ---- 再入 ----
if verbose:
print()
print("=" * 78)
print("六、再入條件")
print("=" * 78)
out["entry"] = {}
for ld in LD_SWEEP:
v_e = glide_v_for_range(dist, ld)
ec = entry_conditions(v_e, ld)
out["entry"][ld] = ec
if verbose:
print(f" L/D {ld:.1f}: 再入速度 {v_e/1000:.2f} km/s,"
f"停滯點熱通量約為軌道再入的 {ec['q_rel_to_orbital']:.2f} 倍,"
f"縱向減速量級 {ec['axial_decel_g_order']:.2f} g")
if verbose:
print()
print(" 熱通量用 Sutton-Graves 的比例式 q ∝ √ρ·v³ 只取比值 ——")
print(" 絕對值要鼻錐半徑與大氣模型,本章不聲稱算得出來。")
# ---- 落點跑道 ----
if verbose:
print()
print("=" * 78)
print("七、降落場:休士頓太空港 Ellington Field (KEFD)")
print("=" * 78)
print(f" 跑道 17R/35L {KEFD_RUNWAY['length_m']:,.0f} m,"
f"場高 {KEFD_RUNWAY['alt_m']:.1f} m")
print(f" 從北方進場 -> 落 RWY 17R(真方位 "
f"{KEFD_RUNWAY['low_end_hdg']:.0f}°)")
print(f" 航線末端方位 {az:.0f}° 出發,抵達德州時由北向南,"
f"與 17R 一致")
out["runway"] = dict(KEFD_RUNWAY)
return out
def save_stats(out, path=None):
path = path or os.path.join(HERE, "spaceport_analysis_stats.json")
ser = json.loads(json.dumps(out, default=float))
with open(path, "w", encoding="utf-8") as fh:
json.dump(ser, fh, ensure_ascii=False, indent=2)
return path
# ══════════════════════════════════════════════════════════════════════════════
# 飛行路徑角、攻角與俯仰角
# ══════════════════════════════════════════════════════════════════════════════
SCALE_HEIGHT_M = 6_000.0 # 指數大氣標高,與 us76_density 的 86 km 外插一致
TAEM_V_MS = 750.0 # 終端能量管理介面速度(約 Mach 2.5、33 km)
def flight_path_angle_deg(v_ms, ld=None, scale_h=SCALE_HEIGHT_M):
r"""平衡滑翔的飛行路徑角(度,下降為負)。
由 Eggers 射程式與指數大氣消去速度可得解析形式:
γ ≈ − 2H / ( (L/D) · R_E · u² ) u = v / v_c
推導:R = (L/D)(R_E/2)·ln[1/(1−u²)] 給出 dR/du = (L/D)·R_E·u/(1−u²);
平衡條件給出 ρ ∝ (1−u²)/u²,配合 h = −H·ln(ρ/ρ₀) 得
dh/du = H[2u/(1−u²) + 2/u];兩者相除即得上式。
**這個式子的量級很反直覺**:L/D 4、u = 0.68(5,400 m/s)時
γ = −0.06 度 —— 高超音速滑翔幾乎是平飛的。
要到跨音速才陡起來。
與數值微分的對照(實測):高速段差 < 0.1 度,低速段差到 1.5 度 ——
後者是**平衡滑翔假設本身失效**(離心解除消失,升力要撐全部重量),
不是公式誤差。低於 TAEM_V_MS 不要採信。
"""
ld = LD_DESIGN if ld is None else ld
u = v_ms / v_circular(0.0)
if u <= 1e-6:
return -90.0
return math.degrees(math.atan(-2.0 * scale_h / (ld * RE * u * u)))
def angle_of_attack_deg(v_ms, ld=None):
"""攻角(度)。高超音速段用牛頓流的 L/D = cot α,低速段過渡到常規值。
**這是一個模型,不是氣動資料。** 牛頓流對鈍體高攻角較準,
對細長乘波體會低估阻力因而高估 L/D。對照:太空梭再入攻角 40 度、
L/D 約 1.5–2(本專案設計點 L/D 4 對應 α 只有 14 度,
那是樂觀的細長外型假設)。
真實值要氣動係數資料庫,本檔不聲稱算得出來。
"""
ld = LD_DESIGN if ld is None else ld
hyper = math.degrees(math.atan(1.0 / ld))
if v_ms > 800.0:
return hyper
f = min(1.0, (800.0 - v_ms) / 650.0)
return hyper + (10.0 - hyper) * f # 低速過渡到常規進場攻角
def pitch_angle_deg(v_ms, ld=None):
"""俯仰角 θ = γ + α(度)。
高超音速段的結果值得注意:γ ≈ 0 但 α = 14 度,所以
**載具是機頭朝上十幾度在下降的**。那是高攻角滑翔的特徵 ——
升力來自機體迎風面,不是來自「往下飛」。
俯仰角要到跨音速之後才轉負。
"""
return flight_path_angle_deg(v_ms, ld) + angle_of_attack_deg(v_ms, ld)
def attitude_table(ld=None, speeds=None):
"""各速度的高度、γ、α、θ 與剩餘射程。回傳 list of dict。"""
ld = LD_DESIGN if ld is None else ld
speeds = speeds or (5400, 4000, 3000, 2000, 1500, 1000,
TAEM_V_MS, 400, 250, 150)
out = []
for v in speeds:
h = equilibrium_glide_alt(v)
out.append({"v_ms": float(v), "alt_m": h,
"gamma_deg": flight_path_angle_deg(v, ld),
"alpha_deg": angle_of_attack_deg(v, ld),
"pitch_deg": pitch_angle_deg(v, ld),
"range_to_go_m": eggers_glide_range(v, ld),
"valid": v >= TAEM_V_MS})
return out
# ══════════════════════════════════════════════════════════════════════════════
# 氣動加熱與壁溫
# ══════════════════════════════════════════════════════════════════════════════
SIGMA_SB = 5.670374419e-8 # Stefan-Boltzmann
K_SUTTON_GRAVES = 1.7415e-4 # Sutton-Graves 常數(SI,地球空氣)
NOSE_RADIUS_M = 0.30 # 鼻錐/前緣半徑。細長乘波體的假設值
EMISSIVITY = 0.85 # 隔熱系統表面輻射率(矽基瓦典型值)
# 機腹相對駐點的熱通量比。**用太空梭實測錨定,不是算出來的:**
# 鼻錐帽(RCC)峰值約 1,650 °C = 1,923 K
# 機腹中線峰值約 1,260 °C = 1,533 K
# -> 熱通量比 = (1533/1923)^4 = 0.404
BELLY_FLUX_RATIO = 0.40
def stagnation_heat_flux(rho, v_ms, nose_r_m=NOSE_RADIUS_M):
"""Sutton-Graves 駐點對流熱通量(W/m²)。
q = k · √(ρ / R_n) · v³ k = 1.7415e-4(SI)
**注意 v 的三次方。** 再入速度降 10%,熱通量降 27% ——
這是升阻比在熱防護上的真正價值,比延伸射程更重要。
限制:冷壁、平衡空氣、球形駐點。不含真實氣體效應、表面催化、
輻射加熱(超過約 10 km/s 才顯著)。鼻錐半徑是假設值。
"""
if rho <= 0 or v_ms <= 0:
return 0.0
return K_SUTTON_GRAVES * math.sqrt(rho / nose_r_m) * v_ms ** 3
def radiative_equilibrium_temp(q_wm2, emissivity=EMISSIVITY):
"""輻射平衡壁溫(K):對流進來等於輻射出去。
q = ε·σ·T⁴ -> T = (q / (ε·σ))^(1/4)
**這是穩態假設。** 忽略熱傳進結構,所以:
* 加熱段早期會**高估**(結構還在吸熱)
* 峰值之後會**低估**(結構回吐熱量)
對量級判斷夠用;真實壁溫要做暫態熱傳分析。
"""
if q_wm2 <= 0:
return 0.0
return (q_wm2 / (emissivity * SIGMA_SB)) ** 0.25
def belly_temperature(v_ms, alt_m=None, ld=None, wing_loading=None):
"""機腹(迎風面中線)的輻射平衡壁溫。回傳 dict。
高度沒給時用平衡滑翔解出的高度。
"""
ld = LD_DESIGN if ld is None else ld
if alt_m is None:
alt_m = equilibrium_glide_alt(
v_ms, wing_loading or WING_LOADING_KGM2)
rho = us76_density(alt_m)
q_s = stagnation_heat_flux(rho, v_ms)
q_b = q_s * BELLY_FLUX_RATIO
return {"v_ms": v_ms, "alt_m": alt_m, "rho": rho,
"q_stag_wm2": q_s, "q_belly_wm2": q_b,
"T_stag_k": radiative_equilibrium_temp(q_s),
"T_belly_k": radiative_equilibrium_temp(q_b),
"T_belly_c": radiative_equilibrium_temp(q_b) - 273.15}
def heating_table(ld=None, speeds=None):
"""各速度的熱通量與壁溫。回傳 list of dict。
**峰值出現在起點是模型的假象。** 本模型從「平衡滑翔已建立」的狀態
起算,跳過真實再入初期的高空低密度段;真實再入的熱峰在中段。
"""
speeds = speeds or (5400, 4500, 4000, 3500, 3000, 2500,
2000, 1500, 1000, TAEM_V_MS, 400)
return [belly_temperature(v, ld=ld) for v in speeds]
# ══════════════════════════════════════════════════════════════════════════════
# 電漿遮蔽(通訊中斷)
# ══════════════════════════════════════════════════════════════════════════════
M_E = 9.1093837e-31
Q_E = 1.602176634e-19
EPS_0 = 8.8541878e-12
K_B = 1.380649e-23
H_PLANCK = 6.62607015e-34
I_NO_EV = 9.25 # NO 的游離能 —— 空氣中最低,所以最先游離
SHOCK_COMPRESSION = 8.0 # 強激震的密度壓縮比(γ=1.4 極限為 6,真實氣體更高)
X_NO = 0.01 # 激震層中 NO 的莫耳分率(量級)
COMM_BANDS = (("UHF", 400e6), ("S", 2.2e9), ("X", 8.4e9), ("Ka", 26e9))
def plasma_frequency(n_e):
"""電漿頻率(Hz)。f_p = (1/2π)·√(n_e·e²/(ε₀·m_e)) ≈ 8.98·√n_e"""
if n_e <= 0:
return 0.0
return math.sqrt(n_e * Q_E * Q_E / (EPS_0 * M_E)) / (2.0 * math.pi)
def critical_electron_density(freq_hz):
"""某頻率被遮蔽所需的電子密度(m⁻³)。**這一步是精確的。**
n_e,crit = 4π²·ε₀·m_e·f² / e²
電漿頻率高於訊號頻率時電波被反射/衰減 —— 這就是再入通訊中斷的機制。
頻率愈高需要愈高的電子密度才會被擋,所以 Ka 頻比 UHF 早恢復。
"""
return (freq_hz ** 2) * 4.0 * math.pi ** 2 * EPS_0 * M_E / (Q_E ** 2)
def shock_electron_density(v_ms, alt_m, x_no=X_NO):
"""激震層電子密度的**量級估算**(m⁻³)。
鏈條:
1. 激震層溫度用工程經驗式 **T[K] ≈ v[m/s]**(地球再入 4–11 km/s 適用)。
不能用 T = v²/2c_p —— 那會給出百萬 K,因為它忽略了解離與游離
吸收掉的巨大能量。
2. 密度用強激震壓縮比 8 倍。
3. 以 Saha 方程解 NO ⇌ NO⁺ + e⁻(NO 游離能 9.25 eV,空氣中最低)。
**這是量級估算,不是 CFD。** 真實值需要非平衡化學、
邊界層與尾流的空間分布。本式只用來回答「哪個頻段、大約多久」。
"""
if v_ms <= 0 or alt_m < 0:
return 0.0
T = float(v_ms)
if T < 1500.0:
return 0.0
rho = us76_density(alt_m)
n_tot = rho / (28.96 * 1.66053907e-27) * SHOCK_COMPRESSION
n_no = n_tot * x_no
rhs = 2.0 * (2.0 * math.pi * M_E * K_B * T / H_PLANCK ** 2) ** 1.5 \
* math.exp(-I_NO_EV * Q_E / (K_B * T))
disc = rhs * rhs + 4.0 * rhs * n_no
if disc <= 0:
return 0.0
return (-rhs + math.sqrt(disc)) / 2.0
def blackout_status(v_ms, alt_m=None, ld=None):
"""各頻段是否被遮蔽。回傳 dict。"""
if alt_m is None:
alt_m = equilibrium_glide_alt(v_ms)
n_e = shock_electron_density(v_ms, alt_m)
f_p = plasma_frequency(n_e)
return {"v_ms": v_ms, "alt_m": alt_m, "n_e": n_e, "f_p_hz": f_p,
"blocked": {nm: f_p > f for nm, f in COMM_BANDS}}
def blackout_speed_limits(ld=None):
"""各頻段恢復通訊的速度門檻(m/s)。低於該速度即不再遮蔽。"""
out = {}
for nm, f in COMM_BANDS:
lo, hi = 300.0, 6000.0
for _ in range(70):
m = 0.5 * (lo + hi)
st = blackout_status(m, ld=ld)
if st["blocked"][nm]:
hi = m
else:
lo = m
out[nm] = 0.5 * (lo + hi)
return out
# ══════════════════════════════════════════════════════════════════════════════
# 命令列入口
#
# **入口放在檔尾**,不是放在中間。先前它在第 607 行,後面還有 249 行定義,
# 直接執行時那個區塊會先跑、之後才定義姿態/加熱/電漿那幾組函式 ——
# 目前能動只是因為 analyse() 剛好沒用到它們,是脆弱的隱性相依。
# ══════════════════════════════════════════════════════════════════════════════
def main():
res = analyse(verbose=True)
p = save_stats(res)
print()
print("=" * 78)
print(f" 統計輸出 -> {p}")
print("=" * 78)
return 0
if __name__ == "__main__":
raise SystemExit(main())
spaceport_suborbital/spaceport_ascent.py
# -*- coding: utf-8 -*-
r"""上升段時程與分節分離時刻。
## 為什麼需要這個檔案
目前場景裡的彈道段用 `ePropagatorBallistic`,它把熄火速度**當成脈衝**
施加在發射點上 —— 不模擬推力、不模擬分節。所以**現在的 STK 場景裡
讀不出任何分離時間**,那不是還沒去讀,而是模型裡不存在這個事件。
要談分離時刻,就得把上升段補起來。本檔算時程,
`spaceport_ascent_stk.py` 把它放進 STK 並由 STK 獨立算一次來對照。
## 分離時刻與火箭大小無關
ṁ = F/(Isp·g₀) 質量流率
F = (T/W)·m₀·g₀ 推力由起飛推重比定義
⇒ ṁ = (T/W)·m₀/Isp
mp = m₀(1 − 1/R) 推進劑質量,R 是質量比
⇒ t_burn = mp/ṁ = Isp·(1 − 1/R)/(T/W)
**m₀ 消掉了。** 分離時刻只取決於 Isp、Δv 分配、推重比,
不需要知道載具的絕對尺寸 —— 這對概念設計階段很重要,
因為那三個量都是設計選擇,而總質量還不知道。
## 本檔用的 v_bo 是 7,031 m/s,不是 6,650
6,650 m/s 是「全程 13,109 km、40% 滑行」那個架構的數字。
本專案採用的架構是彈道段 9,101 km + Aviator 滑翔 4,009 km
(因為 Aviator 的真空速上限實測約 5.4 km/s),
彈道段的最小能量解需要 **7,031 m/s**。混用兩個數字會得到錯的時程。
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import spaceport_analysis as A
import spaceport_build as B
G0 = 9.80665
# 起飛推重比。1.2–1.4 是兩節式運載火箭的常見範圍:太低爬不動、
# 重力損失爆增;太高結構與氣動負荷過大。取 1.3。
TW1 = 1.3
# 第二節點火推重比。上面級通常明顯小於 1 —— 它已經在真空、有初速,
# 不需要克服全部重力。0.8 是常見值。
TW2 = 0.8
# 分離到二節點火之間的滑行。實務上 3–10 s(分離、姿態穩定、點火)。
COAST_S = 6.0
STAGE1_SPLIT = 0.62 # 第一節承擔的 Δv 比例,與 two_stage_split 一致
def timeline(v_bo=None, tw1=TW1, tw2=TW2, coast_s=COAST_S,
split=STAGE1_SPLIT, loss_ms=A.LOSS_DESIGN_MS,
azimuth_deg=None):
"""回傳上升段時程。
時間原點是**起飛(liftoff)**,t=0。
`v_bo` 與 `azimuth_deg` 都給了就**不會**去呼叫 mission_geometry ——
這是刻意的:任務幾何現在要把上升段的沿程算進閉合條件,
如果這裡再回頭呼叫它就會無限遞迴。
"""
ho = None
if v_bo is None or azimuth_deg is None:
ho = B.mission_geometry(verbose=False)
if v_bo is None:
v_bo = ho["v_bo"]
if azimuth_deg is None:
azimuth_deg = ho["azimuth_deg"]
bud = A.dv_budget(v_bo, loss_ms=loss_ms, lat_deg=A.LAUNCH_LAT,
az_deg=azimuth_deg)
sp = A.two_stage_split(bud["dv_total"], split=split)
t1 = A.ISP1_S * (1.0 - 1.0 / sp["mr1"]) / tw1
t2 = A.ISP2_S * (1.0 - 1.0 / sp["mr2"]) / tw2
return {
"v_bo": v_bo, "dv_total": bud["dv_total"], "loss_ms": bud["loss"],
"rot_assist": bud["rot_assist"],
"dv1": sp["dv1"], "dv2": sp["dv2"],
"mr1": sp["mr1"], "mr2": sp["mr2"], "mr_total": sp["mr_total"],
"tw1": tw1, "tw2": tw2,
"t_stage1_burn": t1,
"t_separation": t1, # 分離 = 第一節熄火
"t_stage2_ignition": t1 + coast_s,
"t_stage2_burn": t2,
"t_burnout": t1 + coast_s + t2,
"coast_s": coast_s,
"azimuth_deg": azimuth_deg,
"handover": ho,
}
# **不要在這裡另外定義地球常數。** 先前這裡寫的是平均半徑 6,371 km,
# 而 spaceport_analysis 用的是 WGS84 赤道半徑 6,378.137 km ——
# 同一個量兩份真相,積分出來的沿程與高度都會偏。
# 這是本章第三次踩到「常數各寫一份」(前兩次:網頁的 RE、TAEM_V_MS)。
RE = A.RE
MU = A.MU
# ── 彈道係數:加阻力唯一需要引入的新變數 ──────────────────────────
#
# 本章的上升段模型刻意與質量無關(只用 T/W 與 Isp)。
# **阻力打破這個前提** —— 阻力減速度是 q·Cd·A/m,要質量也要面積。
#
# 把兩者合成一個參數:起飛彈道係數 β₀ = m₀/(Cd·A),單位 kg/m²。
# 飛行中質量遞減,瞬時值是 β = β₀·(m/m₀)。
#
# 量級參考(Cd 取跨音速的 0.5):
# Falcon 9 549 t / (0.5 × 10.8 m²) ≈ 102,000 kg/m²
# 小型運載 50 t / (0.5 × 2.5 m²) ≈ 40,000 kg/m²
#
# **這是新引入的假設,不是從本章其他地方推導出來的。**
# 所以用掃描而不是取一個值 —— 與本章處理升阻比的方式一致。
BETA0_DESIGN = 70_000.0 # kg/m²,兩節式中型運載的量級
def ascent_integrate(tl, kick_deg=1.2, t_kick_s=12.0, dt=0.05,
gamma_target_deg=None, record_dt=None,
beta0=None, gamma_clamp_deg=None, steering=False):
r"""積分二維上升段:重力轉彎 + 導引段。回傳熄火狀態與**算出來的**損失。
## 兩種行不通的作法
**用假設曲線反推損失**(12 s 垂直後線性轉到 γ_bo)會得到
3,558 m/s 對 1,600 m/s。那檢查的是那條假設曲線,不是設計 ——
線性轉向意味著 t=100 s 還在 77 度,而真實載具 60–80 s 就接近 45 度。
**積分純重力轉彎但漏掉離心解除項**,結果 γ 會轉到
−46 度、高度積到 −198 km。球面地球上正確的式子是
dγ/dt = −cos γ · (g/v − v/r)
`v/r` 在 v → √(μ/r) ≈ 7.9 km/s 時抵消重力;少了它,
純重力轉彎必然一路轉過水平線。
## 現在的模型
dv/dt = a − g(h)·sin γ
dγ/dt = −cos γ · (g(h)/v − v/r) 重力轉彎段
dγ/dt = 0 導引段(γ 到達目標後夾住)
dh/dt = v·sin γ
ds/dt = v·cos γ · RE/r 投影到地表的沿程
重力轉彎之後夾住 γ 是標準簡化:真實載具在那之後走閉迴路導引,
不會讓 γ 繼續自由掉。夾住 γ 需要推力向量提供法向分量,
因此有轉向損失。`steering=True` 會把它算進來:
sin α = cos γ·(g − v²/r) / a 沿程分量 = a·cos α
轉向損失 = ∫ a(1 − cos α) dt
**預設是關的**,因為打開會改變熄火解、進而改變整個任務幾何。
量到的值見 `steering_loss_report()`。
## 帳目怎麼對(2026-09-25 釐清)
`solve_ascent()` 會印出「假設損失」與「積分損失」,兩者差約 250 m/s。
先前的註解把它說成「阻力 + cos α 轉向損失」。**那是錯的。**
加了阻力模型之後阻力只有 48 m/s,而那個差額紋風不動 ——
因為它是一個**記帳恆等式**:
dv_budget 尺寸化時 dv_total = v_bo + loss − 自轉助益
積分沒有地球自轉 loss_computed = dv_ideal − v_bo
= loss − 自轉助益
=> 差額 ≡ 自轉助益
方位 35.34° 的沿程分量是 249 m/s,實測差額 254 m/s,
相差 4.5 m/s 是求解器容差。**所以沒有未解釋的殘差。**
## 不含氣動阻力
阻力損失典型 100–150 m/s,需要外型與大氣模型。所以積分出的是
**重力損失 + 轉向損失**,與假設的 1,600 m/s(含阻力)比較時要記得。
"""
if gamma_target_deg is None:
gamma_target_deg = tl["handover"]["gamma_bo_deg"]
gam_t = math.radians(gamma_target_deg)
# ── 導引段的起點是一個自由度 ────────────────────────────────────
# 預設(gamma_clamp_deg = None)是舊行為:重力轉彎一路轉到 γ_target
# 才夾住,之後 γ 固定。那是**標準簡化,不是最佳解** ——
# 真實載具在轉彎結束後走閉迴路導引,γ 會繼續平滑下降。
#
# 給了 gamma_clamp_deg 就改成:γ 降到這個值時接手,
# 之後讓 γ 在**剩餘燃燒時間內線性**收到 γ_target:
#
# dγ/dt = (γ_target − γ) / (t_burnout − t)
#
# γ_clamp = γ_target 時退化成舊行為。
gam_c = gam_t if gamma_clamp_deg is None else math.radians(
gamma_clamp_deg)
t_end = tl["t_burnout"]
stages = ((tl["tw1"], A.ISP1_S, tl["t_stage1_burn"]),
(tl["tw2"], A.ISP2_S, tl["t_stage2_burn"]))
v, gam, h, sdown, t = 1.0, math.radians(90.0), 0.0, 0.0, 0.0
dv_ideal, grav_loss, drag_loss = 0.0, 0.0, 0.0
steer_loss = 0.0
steer_alpha_max = 0.0
steer_infeasible = False
max_q = 0.0
max_q_alt = 0.0
mfrac = 1.0 # m/m0,由各節燒掉的比例更新
kicked, guided = False, False
marks = {}
# record_dt 給了才記軌跡。預設不記 —— 損失驗證只需要端點狀態,
# 記下來只是浪費;但要寫星曆就非有不可。
track = [] if record_dt else None
next_rec = 0.0
def nonlocal_rec():
nonlocal next_rec
if t >= next_rec - 1e-9:
track.append((t, h, sdown, v, math.degrees(gam)))
next_rec += record_dt
def step(acc, dt):
nonlocal v, gam, h, sdown, t, dv_ideal, grav_loss, guided
nonlocal drag_loss, max_q, max_q_alt
nonlocal steer_loss, steer_alpha_max, steer_infeasible
r = RE + max(0.0, h)
g = MU / (r * r)
dv_ideal += acc * dt
grav_loss += g * math.sin(gam) * dt
# 阻力:a_D = q/(β₀·(m/m₀))。beta0=None 就完全不算,
# 保留舊行為以便對照「有沒有阻力差多少」。
a_d = 0.0
if beta0:
q = 0.5 * A.us76_density(max(0.0, h)) * v * v
if q > max_q:
max_q, max_q_alt = q, h
a_d = q / max(1.0, beta0 * max(0.02, mfrac))
drag_loss += a_d * dt
# ── cos α 轉向損失 ────────────────────────────────────────
# 導引段要把 γ 夾住,推力必須提供法向分量抵消
# cos γ·(g − v²/r)。推力偏離速度方向 α 之後,沿程分量只剩
# a·cos α,差額就是轉向損失。重力轉彎段 α = 0,沒有損失。
#
# `sin α > 1` 表示**推力撐不住那個 γ** —— 不是數值問題,
# 是這個推重比做不到這條軌跡。
acc_eff = acc
if steering and guided and acc > 1e-9:
a_n = math.cos(gam) * (g - v * v / r)
sa = a_n / acc
if abs(sa) >= 1.0:
steer_infeasible = True
sa = math.copysign(0.999999, sa)
ca = math.sqrt(max(0.0, 1.0 - sa * sa))
steer_alpha_max = max(steer_alpha_max,
abs(math.degrees(math.asin(sa))))
steer_loss += acc * (1.0 - ca) * dt
acc_eff = acc * ca
v += (acc_eff - g * math.sin(gam) - a_d) * dt
v = max(1.0, v)
if not guided:
gam += -math.cos(gam) * (g / v - v / r) * dt
if gam <= gam_c:
gam, guided = gam_c, True
elif gamma_clamp_deg is not None:
# 線性收到目標。剩餘時間見底時直接夾住,避免除以零。
left = t_end - t
if left > dt:
gam += (gam_t - gam) / left * dt
else:
gam = gam_t
h += v * math.sin(gam) * dt
sdown += v * math.cos(gam) * dt * RE / r
t += dt
if track is not None:
nonlocal_rec()
for si, (tw, isp, tb) in enumerate(stages, 1):
if si == 2:
for _ in range(max(1, int(tl["coast_s"] / dt))):
step(0.0, dt) # 分離後滑行,無推力
marks["ignition2"] = (t, v, math.degrees(gam), h)
n = max(1, int(tb / dt))
for i in range(n):
frac = (tw / isp) * (i * dt)
acc = tw * G0 / max(0.05, 1.0 - frac)
# 目前質量占起飛質量的比例。兩節的總燒蝕用一階近似連續處理,
# 估阻力足夠(阻力在第一節就付掉九成以上)。
mfrac = max(0.02, 1.0 - frac * (0.62 if si == 1 else 0.38))
step(acc, dt)
if not kicked and t >= t_kick_s:
gam -= math.radians(kick_deg)
kicked = True
marks[f"burnout{si}"] = (t, v, math.degrees(gam), h)
if si == 1:
marks["separation"] = marks["burnout1"]
return {"v_burnout": v, "gamma_burnout_deg": math.degrees(gam),
"alt_burnout_m": h, "downrange_m": sdown, "t_burnout": t,
"dv_ideal": dv_ideal, "loss_computed_ms": dv_ideal - v,
"grav_loss_integral_ms": grav_loss, "guided": guided,
"gamma_target_deg": gamma_target_deg,
"gamma_clamp_deg": gamma_clamp_deg,
"kick_deg": kick_deg, "marks": marks,
"track": track,
"beta0": beta0,
"drag_loss_ms": drag_loss,
"steering": steering,
"steer_loss_ms": steer_loss,
"steer_alpha_max_deg": steer_alpha_max,
"steer_infeasible": steer_infeasible,
"max_q_pa": max_q, "max_q_alt_m": max_q_alt}
# `ascent_downrange` 的快取。見那一支的說明。
_DOWNRANGE_CACHE = {}
def ascent_downrange(v_bo, alt_bo=None, gamma_bo_deg=None,
azimuth_deg=None, dt=0.05, beta0=BETA0_DESIGN):
"""給定熄火速度,解出上升段要飛多遠才到得了熄火點(m)。
**不呼叫 mission_geometry** —— 它是被任務幾何的閉合條件呼叫的,
回頭呼叫會無限遞迴。
內部同時解兩個旋鈕(踢角、假設損失),把熄火狀態打在
(alt_bo, v_bo) 上。單次約 0.7 秒。
## 有快取
這一支被**任務幾何的閉合條件**呼叫,而閉合條件本身又被放在
別人的二分法裡(`ld_for_velocity`、損益平衡點的反解…)。
不快取的話 `ld_for_velocity` 含上升段要跑十分鐘,
損益平衡點的掃描更久。
鍵取到 0.1 m/s —— 比任何有意義的解析度細,
而輸入全是決定性的,所以快取不會改變答案。
"""
alt_bo = A.KARMAN_M if alt_bo is None else alt_bo
gamma_bo_deg = A.GAMMA_BO_DEG if gamma_bo_deg is None else gamma_bo_deg
azimuth_deg = 35.34 if azimuth_deg is None else azimuth_deg
key = (round(float(v_bo), 1), round(float(alt_bo), 3),
round(float(gamma_bo_deg), 6), round(float(azimuth_deg), 6),
dt, beta0)
hit = _DOWNRANGE_CACHE.get(key)
if hit is not None:
return hit
kick, loss = 1.3, A.LOSS_DESIGN_MS
r = None
for _ in range(60):
tl = timeline(v_bo=v_bo, loss_ms=loss, azimuth_deg=azimuth_deg)
r = ascent_integrate(tl, kick_deg=kick, t_kick_s=16.0, dt=dt,
gamma_target_deg=gamma_bo_deg, beta0=beta0)
d_alt = r["alt_burnout_m"] - alt_bo
d_v = r["v_burnout"] - v_bo
if abs(d_alt) < 200.0 and abs(d_v) < 2.0:
break
kick = min(6.0, max(0.05, kick + d_alt / 1.0e5))
loss = min(4000.0, max(200.0, loss - d_v * 0.9))
out = {"downrange_m": r["downrange_m"], "kick_deg": kick,
"loss_assumed_ms": loss, "t_burnout": r["t_burnout"],
"drag_loss_ms": r["drag_loss_ms"],
"grav_loss_ms": r["grav_loss_integral_ms"],
"loss_computed_ms": r["loss_computed_ms"],
"max_q_pa": r["max_q_pa"], "max_q_alt_m": r["max_q_alt_m"],
"beta0": beta0,
"alt_burnout_m": r["alt_burnout_m"], "v_burnout": r["v_burnout"],
"gamma_burnout_deg": r["gamma_burnout_deg"],
"alt_err_m": r["alt_burnout_m"] - alt_bo,
"v_err_ms": r["v_burnout"] - v_bo}
_DOWNRANGE_CACHE[key] = out
return out
def _solve_two_knobs(target_alt_m, target_v, t_kick_s=16.0,
gamma_clamp_deg=None, tw1=TW1,
kick0=1.2, kick_hi=6.0, gain_alt=1.0e5,
gain_v=0.9, iters=60, tol_alt=200.0, tol_v=2.0):
"""解「踢角 + 假設損失」兩個旋鈕,打中指定的熄火狀態。
回傳 (kick, loss, result, timeline)。
抽出來是為了讓 `solve_ascent()` 與 `optimise_ascent()` 共用同一個
求解器 —— 這一章已經因為同一個量有兩份實作而出過三次錯
(RE 的重複定義、ld_for_velocity 漏掉上升段、SEAM_FREE_LD)。
預設的增益與上下界就是 `solve_ascent()` 原本寫死的那組,
所以它的結果不變。掃描時會放寬 `kick_hi` 與迭代次數。
"""
kick, loss = kick0, A.LOSS_DESIGN_MS
r = tl = None
for _ in range(iters):
tl = timeline(v_bo=target_v, loss_ms=loss, tw1=tw1)
r = ascent_integrate(tl, kick_deg=kick, t_kick_s=t_kick_s,
beta0=BETA0_DESIGN,
gamma_clamp_deg=gamma_clamp_deg)
d_alt = r["alt_burnout_m"] - target_alt_m
d_v = r["v_burnout"] - target_v
if abs(d_alt) < tol_alt and abs(d_v) < tol_v:
break
# 高度偏高 -> 踢大一點(轉得更早更兇);速度偏高 -> 少給一點 Δv
kick = min(kick_hi, max(0.02, kick + d_alt / gain_alt))
loss = min(4000.0, max(200.0, loss - d_v * gain_v))
tl = timeline(v_bo=target_v, loss_ms=loss, tw1=tw1)
r = ascent_integrate(tl, kick_deg=kick, t_kick_s=t_kick_s,
beta0=BETA0_DESIGN,
gamma_clamp_deg=gamma_clamp_deg)
r["converged"] = (abs(r["alt_burnout_m"] - target_alt_m) < tol_alt * 1.5
and abs(r["v_burnout"] - target_v) < tol_v * 1.5)
return kick, loss, r, tl
def solve_ascent(target_alt_m=None, target_v=None, verbose=False):
"""解出能**打中設計熄火狀態**的上升段。
先前 `ascent_integrate()` 只驗證損失模型,從來沒有要求它落在設計點上。
直接跑預設參數得到的是 120 km / 6,252 m/s,而設計要的是
100 km / 6,121 m/s —— 差了 20 km 與 131 m/s,那條軌跡接不上真空弧。
兩個旋鈕、兩個目標:
踢角 kick_deg -> 主要影響**熄火高度**(踢得淺就飛得高)
假設損失 loss -> 決定分節燃燒時間,主要影響**熄火速度**
「假設損失」要與積分出來的實際損失自洽 —— 用多少損失去尺寸化火箭,
就該真的付出多少損失。這是一個不動點,不是一個可以隨手填的常數。
"""
ho = B.mission_geometry(verbose=False)
target_alt_m = ho["burnout_alt_m"] if target_alt_m is None else target_alt_m
target_v = ho["v_bo"] if target_v is None else target_v
kick, loss, r, tl = _solve_two_knobs(target_alt_m, target_v)
r["kick_solved_deg"] = kick
r["loss_assumed_ms"] = loss
r["timeline"] = tl
r["target_alt_m"] = target_alt_m
r["target_v"] = target_v
r["alt_err_m"] = r["alt_burnout_m"] - target_alt_m
r["v_err_ms"] = r["v_burnout"] - target_v
if verbose:
print(f" 解出:踢角 {kick:.3f}°、假設損失 {loss:,.0f} m/s")
print(f" 熄火 {r['alt_burnout_m']/1000:.2f} km "
f"(目標 {target_alt_m/1000:.0f},差 {r['alt_err_m']:,.0f} m)")
print(f" 速度 {r['v_burnout']:,.1f} m/s "
f"(目標 {target_v:,.1f},差 {r['v_err_ms']:+.1f})")
print(f" γ {r['gamma_burnout_deg']:.2f}°、"
f"沿程 {r['downrange_m']/1000:,.0f} km、"
f"T+{r['t_burnout']:.0f} s")
print(f" 積分損失 {r['loss_computed_ms']:,.0f} m/s "
f"vs 假設 {loss:,.0f} m/s")
print(f" 其中重力 {r['grav_loss_integral_ms']:,.0f}、"
f"阻力 {r['drag_loss_ms']:,.0f}"
f"(β₀ {BETA0_DESIGN:,.0f} kg/m²)")
print(f" Max-Q {r['max_q_pa']/1000:.1f} kPa @ "
f"{r['max_q_alt_m']/1000:.1f} km")
assist = A.dv_budget(target_v, loss_ms=loss,
lat_deg=A.LAUNCH_LAT,
az_deg=tl["azimuth_deg"])["rot_assist"]
resid = loss - r["loss_computed_ms"]
print(f" 自轉助益 {assist:,.0f}(尺寸化時扣掉,積分看不到)")
print(f" 帳目:假設 {loss:,.0f} = 積分 "
f"{r['loss_computed_ms']:,.0f} + 自轉助益 {assist:,.0f}"
f" (差 {abs(resid-assist):.1f} m/s,求解器容差)")
print(" **所以沒有未解釋的殘差。** 先前把這個差額說成")
print(" 「阻力 + cos α 轉向損失」是錯的 —— 加了阻力模型之後")
print(f" 阻力只有 {r['drag_loss_ms']:,.0f} m/s,而差額紋風不動,")
print(" 因為它從頭到尾就是自轉助益的記帳恆等式。")
print()
print(" **真正沒有被算進去的是 cos α 轉向損失** ——")
print(" 夾住 γ 需要推力向量提供法向分量,那個代價")
print(" 在假設端與積分端都不存在,所以本模型**低估所需 Δv**。")
print(" 實測 **409 m/s**(不是引用的典型值 100-200)——")
print(" 而且要 59° 的推力向量角。見 steering_loss_report()。")
return r
# ══════════════════════════════════════════════════════════════════════
# 上升段軌跡最佳化(開放項目 11.7)
# ══════════════════════════════════════════════════════════════════════
ADOPT_THRESHOLD_MS = 50.0 # 省超過這個才值得改設計
def optimise_ascent(verbose=True):
r"""同樣打中設計熄火狀態的前提下,俯仰程序還能省多少 Δv?
## 目標函式
**不是**積分出來的損失,而是**假設損失** —— 也就是尺寸化火箭時用的
那個數。它才是決定質量比的量。兩者差一個自轉助益的記帳恆等式
(見 `ascent_integrate` 的說明),所以比哪一個都可以,但要一致。
約束是三個熄火條件全中:高度 100 km、速度 = 設計 v_bo、γ = 2°。
每一組參數都重新解「踢角 + 假設損失」兩個旋鈕去滿足它。
## 掃三個東西
1. **踢的時刻** `t_kick_s`
2. **導引段起點** `gamma_clamp_deg`(γ 降到這個值就接手,
之後在剩餘燃燒時間內線性收到 γ_target)
3. **第一節推重比** `tw1` —— 嚴格說這不是軌跡參數,是載具選擇,
但它是重力損失的主要槓桿,不放進來看不出「錢在哪裡」
## 結論(2026-09-25 實測)
| 掃什麼 | 最好能省 | 判讀 |
|---|---|---|
| 踢的時刻 14-28 s | **0** | 完全是平的 —— 踢角自己補償掉 |
| 導引段起點 | **16 m/s** @ γ_clamp 3.25° | 低於 50 的門檻,**不採用** |
| 第一節推重比 | 174 @ T/W 1.5、347 @ T/W 2.0 | **錢在這裡**,但要付 Max-Q |
所以開放項目 11.7 要**改寫**:固定的俯仰程序不是 Δv 貴的原因,
在這個模型裡它已經接近這一族解的最佳。真正在花錢的假設是
**T/W₁ = 1.30**,而動它受 Max-Q 限制:1.30 -> 1.50 讓 Max-Q
從 34 kPa 升到 51 kPa;T/W 2.0 省 347 m/s 但 Max-Q 到 115 kPa,
而且求解器要用 28° 的「踢角」才打得中 —— 那已經不是一個踢,
是完全不同的軌跡形狀。**那是結構的決定,不是軌跡的。**
"""
ho = B.mission_geometry(verbose=False)
ta, tv = ho["burnout_alt_m"], ho["v_bo"]
def run(**kw):
kick, loss, r, tl = _solve_two_knobs(
ta, tv, kick_hi=30.0, gain_alt=1.5e5, gain_v=0.6, iters=140,
**kw)
return {"kick": kick, "loss": loss, "r": r, "tl": tl,
"ok": bool(r["converged"])}
base = run()
out = {"base": base, "kick_time": [], "clamp": [], "tw1": []}
for tk in (10.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 34.0):
o = run(t_kick_s=tk)
out["kick_time"].append((tk, o))
for gc in (2.0, 2.5, 3.0, 3.25, 3.5, 4.0, 5.0, 7.0, 10.0):
o = run(gamma_clamp_deg=gc)
out["clamp"].append((gc, o))
for tw in (1.20, 1.30, 1.40, 1.50, 1.70, 2.00):
o = run(tw1=tw)
out["tw1"].append((tw, o))
ok_clamp = [(g, o) for g, o in out["clamp"] if o["ok"]]
best = min(ok_clamp, key=lambda x: x[1]["loss"])
out["best_clamp_deg"] = best[0]
out["best_clamp_loss"] = best[1]["loss"]
out["saving_ms"] = base["loss"] - best[1]["loss"]
out["adopt"] = out["saving_ms"] > ADOPT_THRESHOLD_MS
if verbose:
_report_opt(out, base)
return out
def _report_opt(out, base):
def row(tag, o, ref):
return (f" {tag:>9} {o['kick']:>8.3f} {o['loss']:>10,.1f} "
f"{ref - o['loss']:>+8,.0f} {o['tl']['mr_total']:>8.2f} "
f"{o['r']['max_q_pa']/1000:>10.1f} "
f"{'v' if o['ok'] else 'X':>5}")
ref = base["loss"]
print("=" * 78)
print(" 上升段軌跡最佳化 —— 同樣打中設計熄火狀態,還能省多少")
print("=" * 78)
print(f" 基準:踢角 {base['kick']:.3f}°、假設損失 {ref:,.1f} m/s、"
f"質量比 {base['tl']['mr_total']:.2f}")
print(" 目標函式是**假設損失**(尺寸化火箭用的那個數),不是積分損失。")
print()
hdr = (f" {'':>9} {'踢角°':>8} {'假設損失':>10} {'省':>8} "
f"{'質量比':>8} {'Max-Q kPa':>10} {'收斂':>5}")
print(" 一、踢的時刻 t_kick")
print(hdr)
for tk, o in out["kick_time"]:
print(row(f"{tk:.0f} s", o, ref))
print(" -> **完全是平的**。踢角自己補償掉,所以踢的時刻不是自由度。")
print()
print(" 二、導引段起點 γ_clamp(之後線性收到 γ_target)")
print(hdr)
for gc, o in out["clamp"]:
print(row(f"{gc:.2f}°", o, ref))
best_o = dict(out["clamp"])[out["best_clamp_deg"]]
print(f" -> 最佳 γ_clamp {out['best_clamp_deg']:.2f}°,"
f"省 **{out['saving_ms']:,.1f} m/s**")
print(f" 採用門檻 {ADOPT_THRESHOLD_MS:.0f} m/s —— "
f"{'採用' if out['adopt'] else '**不採用**'}")
print(f" 而且**不是免費的**:Max-Q 由 {base['r']['max_q_pa']/1000:.1f}"
f" 升到 {best_o['r']['max_q_pa']/1000:.1f} kPa(+"
f"{(best_o['r']['max_q_pa']/base['r']['max_q_pa']-1)*100:.0f}%)。")
print(" 4° 時 Max-Q 已是 57 kPa、5° 時 178 kPa ——")
print(" 導引段提早接手會把軌跡壓平,載具在稠密大氣裡待更久。")
print(" 5° 以上求解器打不中(Δalt 爆到數十公里),那些列不算數。")
print()
print(" 三、第一節推重比 T/W₁(**不是軌跡參數,是載具選擇**)")
print(hdr)
for tw, o in out["tw1"]:
print(row(f"{tw:.2f}", o, ref))
print(" -> **錢在這裡**:+0.2 買到約 174 m/s,+0.7 買到約 347。")
print(" 但要付 Max-Q:34 -> 51 -> 115 kPa。")
print(" 而且 T/W 2.0 時求解器要用 28° 的『踢角』才打得中 ——")
print(" 那已經不是一個踢,是完全不同的軌跡形狀。")
print()
print(" **結論**:固定的俯仰程序不是 Δv 貴的原因,")
print(" 它在這一族解裡已經接近最佳(差 16 m/s,1.1%)。")
print(" 真正在花錢的假設是 T/W₁ = 1.30,而動它受 Max-Q 限制 ——")
print(" **那是結構的決定,不是軌跡的。**")
print()
print(" 這個掃描**沒有**涵蓋的:cos α 轉向損失(模型裡不存在)、")
print(" 真正的最佳控制(線性正切導引)、兩節之間的 Δv 分配、")
print(" 以及把 γ_target 當自由度(它是任務設計點,不是這裡能動的)。")
def steering_loss_report(verbose=True):
r"""把 cos α 轉向損失**算出來**,並掃它對第二節推重比的敏感度。
## 先前這裡是一個引用來的數字
`ascent_integrate` 的說明原本寫「推力方向偏離造成的 cos α 損失沒有
計入 …… 本檔因此會略微低估損失(**典型 100-200 m/s**)」。
那個 100-200 是**引用來的**,不是為這架載具算的。
## 算出來是 409 m/s,而且推力向量角要 59°
導引段要把 γ 夾住,推力必須提供法向分量抵消 `cos γ·(g − v²/r)`:
sin α = cos γ·(g − v²/r) / a 轉向損失 = ∫ a(1 − cos α) dt
問題集中在**二節點火那一刻**:那時 v ≈ 3,900 m/s,
`g − v²/r ≈ 7.2 m/s²`,而 T/W₂ = 0.8 給的推力加速度只有 7.85 ——
`sin α = 0.92`,幾乎撐不住。
| T/W₂ | 轉向損失 | α max | 假設損失 |
|---|---|---|---|
| **0.8(目前)** | **409 m/s** | **59.1°** | 1,820 |
| 1.0 | 178 | 38.2° | 1,606 |
| 1.2 | 88 | 27.3° | 1,530 |
| 1.5 | 33 | 17.3° | 1,485 |
**引用的 100-200 m/s 對應的是 T/W₂ ≈ 1.0-1.2**,也就是真實上面級的值。
這架載具的 0.8 偏低,所以它的轉向損失是典型的兩到四倍。
## 所以結論不是「模型少算了 409」
**59° 的推力向量角不是一個真實的控制輸入。** 這個數字真正說的是:
「T/W₂ = 0.8 + 一到達目標就把 γ 夾死」這個組合**飛不出來**。
要嘛推重比提高,要嘛導引段讓 γ 再多降一點
(實測 `gamma_clamp_deg` 由 2.0 降到 0.3 可以把轉向損失壓到 212 m/s、
α 降到 43° —— 還是太大)。
所以**沒有把 `steering=True` 設成預設**:那會把一個不合理的
導引律的代價當成設計值。它是一個**開放項目**,不是一個修正。
python spaceport_ascent.py --steering
"""
ho = B.mission_geometry(verbose=False)
ta, tv = ho["burnout_alt_m"], ho["v_bo"]
def solve(tw2=TW2, gam_c=None):
kick, loss = 1.3, A.LOSS_DESIGN_MS
r = None
for _ in range(160):
tl = timeline(v_bo=tv, loss_ms=loss, tw2=tw2)
r = ascent_integrate(tl, kick_deg=kick, t_kick_s=16.0,
beta0=BETA0_DESIGN,
gamma_clamp_deg=gam_c, steering=True)
dh = r["alt_burnout_m"] - ta
dv = r["v_burnout"] - tv
if abs(dh) < 200.0 and abs(dv) < 2.0:
break
kick = min(30.0, max(0.02, kick + dh / 1.5e5))
loss = min(5000.0, max(200.0, loss - dv * 0.6))
return loss, r, abs(dh) < 400.0
base_off = solve_ascent()
out = {"loss_no_steering_ms": base_off["loss_assumed_ms"],
"tw2": [], "clamp": []}
for tw2 in (0.8, 1.0, 1.2, 1.5):
loss, r, ok = solve(tw2=tw2)
out["tw2"].append((tw2, loss, r["steer_loss_ms"],
r["steer_alpha_max_deg"], ok))
for gc in (2.0, 1.0, 0.6, 0.3):
loss, r, ok = solve(gam_c=gc)
out["clamp"].append((gc, loss, r["steer_loss_ms"],
r["steer_alpha_max_deg"], ok))
if verbose:
print("=" * 78)
print(" cos α 轉向損失 —— 算出來的,不是引用的")
print("=" * 78)
print(f" 不含轉向的假設損失 {out['loss_no_steering_ms']:,.0f} m/s")
print()
print(f" 一、第二節推重比(γ 一到 2° 就夾死)")
print(f" {'T/W2':>6} {'假設損失':>9} {'其中轉向':>9} {'α max':>7}"
f" {'收斂':>5}")
for tw2, loss, sl, am, ok in out["tw2"]:
print(f" {tw2:>6.1f} {loss:>9,.0f} {sl:>9,.0f} {am:>7.1f}"
f" {'v' if ok else 'X':>5}")
print(" -> 引用的 100-200 m/s 對應 T/W₂ ≈ 1.0-1.2(真實上面級的值)。")
print(f" 本設計的 0.8 給出 {out['tw2'][0][2]:,.0f} m/s,"
"是典型的兩到四倍。")
print()
print(" 二、讓重力轉彎多跑一點(T/W₂ 維持 0.8)")
print(f" {'γ_clamp':>8} {'假設損失':>9} {'其中轉向':>9} {'α max':>7}"
f" {'收斂':>5}")
for gc, loss, sl, am, ok in out["clamp"]:
print(f" {gc:>8.1f} {loss:>9,.0f} {sl:>9,.0f} {am:>7.1f}"
f" {'v' if ok else 'X':>5}")
print(" -> 壓得下來但壓不夠:α 仍有 43°。")
print()
print(" **結論不是「模型少算了 409 m/s」。**")
print(" 59° 的推力向量角不是一個真實的控制輸入 —— 這個數字說的是")
print(" 「T/W₂ = 0.8 + 一到達目標就把 γ 夾死」這個組合**飛不出來**。")
print(" 所以沒有把 steering=True 設成預設:那會把一個不合理的")
print(" 導引律的代價當成設計值。這是**開放項目**,不是修正。")
return out
def validate_loss_model(verbose=True):
"""**先驗證損失模型本身,再拿它去檢查設計。**
入軌任務的損失(重力+轉向+阻力)公認在 1.5–2.0 km/s。
把同一個積分器套在「200 km 圓軌道」的設計點上,
若算出的損失落在那個區間(扣掉阻力後約 1.4–1.9),
這個積分器才有資格用來檢查次軌道的設計。
這一步是為了避免上一輪的錯誤:拿一個沒驗證過的模型得到
「不自洽」,然後去懷疑設計 —— 其實該懷疑的是模型。
"""
v_orb = math.sqrt(MU / (RE + 200_000.0))
tl = timeline(v_bo=v_orb, loss_ms=1_600.0)
# 入軌是近水平入軌,γ_bo 約 0
r = ascent_integrate(tl, gamma_target_deg=0.5)
loss = r["loss_computed_ms"]
ok = 1_200.0 <= loss <= 2_100.0
if verbose:
print(f" 基準案例:200 km 圓軌道,v_orb = {v_orb:,.0f} m/s")
print(f" 第一節燃燒 {tl['t_stage1_burn']:.0f} s"
f" 第二節 {tl['t_stage2_burn']:.0f} s"
f" 總 {r['t_burnout']:.0f} s")
print(f" 算出的損失(重力+轉向,不含阻力) {loss:>8,.0f} m/s")
print(f" 公認區間 1,500–2,000(含阻力 100–150)")
print(f" 熄火速度 {r['v_burnout']:,.0f} m/s"
f"(需要 {v_orb:,.0f},差 {r['v_burnout']-v_orb:+,.0f})")
print(f" {'** 損失模型通過基準 **' if ok else '** 損失模型不可信,下面的檢查不要採信 **'}")
return {"ok": ok, "loss_ms": loss, "v_orb": v_orb, "result": r}
# 真實兩節式運載火箭的第一節燃燒時間(公開資料,約值)。
# 這是**外部基準** —— 用來判斷算出的分離時刻是否落在工程上合理的範圍,
# 而不是只跟自己的公式自圓其說。
#
# 注意可比性:固體第一節(Minotaur)推重比高得多,燃燒時間自然短;
# 帶助推器的構型(Soyuz、Ariane 5)第一節定義不同,不直接可比。
# 與本設計最接近的是「液體、T/W 約 1.2–1.3、兩節入軌」那一類。
REAL_STAGE1_BURN_S = (
("Falcon 9 Block 5", 162, "液體 kerolox,T/W 約 1.3"),
("Atlas V 401", 253, "液體 kerolox,T/W 約 1.2"),
("Delta IV Medium", 245, "液體 hydrolox,T/W 約 1.2"),
("Antares 230", 205, "液體 kerolox"),
("Electron", 150, "液體 kerolox,小型"),
("Minotaur IV", 56, "固體,T/W 高,不同類"),
)
def mass_flow_check(tl, dt=0.01):
"""質量流自洽:逐步積分 ṁ,確認燒掉的推進劑真的等於 1 − 1/R。
這是**必要但不充分**的檢查 —— 它只證明公式與數值積分一致,
不證明這個時程飛得到目標。不要把它當成驗證了分離時刻。
"""
out = {}
for si, (tw, isp, tb, mr, dv) in enumerate(
((tl["tw1"], A.ISP1_S, tl["t_stage1_burn"], tl["mr1"], tl["dv1"]),
(tl["tw2"], A.ISP2_S, tl["t_stage2_burn"], tl["mr2"], tl["dv2"])), 1):
mdot_rel = tw / isp # ṁ/m₀,單位 1/s
n = int(tb / dt)
burned = 0.0
for _ in range(n):
burned += mdot_rel * dt
want = 1.0 - 1.0 / mr
dv_back = isp * G0 * math.log(1.0 / (1.0 - burned))
out[si] = {"burned_frac": burned, "want_frac": want,
"err_frac": burned - want,
"dv_back": dv_back, "dv_want": dv,
"err_dv": dv_back - dv}
return out
def sensitivity(tl):
"""分離時刻對三個設計假設的敏感度。
t_sep = Isp·(1 − 1/R₁)/(T/W)₁,而 R₁ 由 Δv 分配決定。
報出區間,而不是只報一個點值 —— 概念設計階段那三個量都還沒定。
"""
rows = []
for tw1 in (1.15, 1.20, 1.30, 1.40):
for split in (0.55, 0.62, 0.70):
t = timeline(tw1=tw1, split=split)
rows.append((tw1, split, t["dv1"], t["mr1"],
t["t_separation"], t["t_burnout"]))
return rows
def describe(tl=None):
tl = tl or timeline()
ho = tl["handover"]
print("=" * 78)
print("上升段時程與分節分離")
print("=" * 78)
print()
print("一、設計點")
print(f" 總射程 {ho['total_m']/1000:>10,.0f} km")
print(f" 架構 淺角度助推滑翔,L/D {ho['ld']:.1f}")
print(f" 需要的熄火速度 v_bo {tl['v_bo']:>10,.0f} m/s")
print(f" 假設損失(重力+氣動+轉向) {tl['loss_ms']:>10,.0f} m/s")
print(f" 自轉助益(方位 {ho['azimuth_deg']:.1f} deg) "
f"{tl['rot_assist']:>10,.0f} m/s")
print(f" 總 Δv {tl['dv_total']:>10,.0f} m/s")
print()
print("二、分節")
print(f" {'':<10}{'Δv m/s':>10}{'Isp s':>8}{'質量比':>9}"
f"{'推重比':>8}{'燃燒 s':>10}")
print(" " + "-" * 53)
print(f" {'第一節':<10}{tl['dv1']:>10,.0f}{A.ISP1_S:>8.0f}"
f"{tl['mr1']:>9.3f}{tl['tw1']:>8.2f}{tl['t_stage1_burn']:>10.1f}")
print(f" {'第二節':<10}{tl['dv2']:>10,.0f}{A.ISP2_S:>8.0f}"
f"{tl['mr2']:>9.3f}{tl['tw2']:>8.2f}{tl['t_stage2_burn']:>10.1f}")
print(f" 總質量比 {tl['mr_total']:.2f}")
print()
print("三、時程(t=0 是起飛)")
print(f" {'t = 0.0 s':>14} 起飛")
print(f" {'t = %.1f s' % tl['t_separation']:>14} "
f"**第一節熄火 / 分離**")
print(f" {'t = %.1f s' % tl['t_stage2_ignition']:>14} "
f"第二節點火(滑行 {tl['coast_s']:.0f} s)")
print(f" {'t = %.1f s' % tl['t_burnout']:>14} "
f"第二節熄火,v = {tl['v_bo']:,.0f} m/s")
print()
print(" 分離時刻 t_burn = Isp·(1 − 1/R)/(T/W) —— **與載具絕對質量無關**,")
print(" 只取決於 Isp、Δv 分配、推重比。概念設計階段這三個都是設計選擇。")
print()
print("四、驗證(一):質量流與火箭方程自洽")
mf = mass_flow_check(tl)
for si in (1, 2):
r = mf[si]
print(f" 第{'一' if si == 1 else '二'}節 燒掉推進劑比例 "
f"{r['burned_frac']:.5f}(公式 {r['want_frac']:.5f},"
f"差 {r['err_frac']:+.2e})")
print(f" 反推 Δv {r['dv_back']:>8,.1f} m/s"
f"(需要 {r['dv_want']:,.1f},差 {r['err_dv']:+.1f})")
ok_mf = all(abs(mf[si]["err_dv"]) < 5.0 for si in (1, 2))
print(f" {'通過' if ok_mf else '**未通過**'} —— 但這只證明公式與數值一致,")
print(f" **不證明這個時程飛得到目標**。不要把它當成驗證了分離時刻。")
print()
print("五、驗證(二):與真實運載火箭的第一節燃燒時間對照")
print(f" {'載具':<20}{'第一節燃燒 s':>14} 說明")
print(" " + "-" * 62)
for nm, sec, note in REAL_STAGE1_BURN_S:
print(f" {nm:<20}{sec:>14} {note}")
print(f" {'本設計':<20}{tl['t_separation']:>14.0f} "
f"液體,T/W {tl['tw1']:.2f},Δv 分配 {STAGE1_SPLIT:.2f}")
comparable = [sec for nm, sec, note in REAL_STAGE1_BURN_S
if "不同類" not in note and "小型" not in note]
lo, hi = min(comparable), max(comparable)
ok_bm = lo <= tl["t_separation"] <= hi
print(f" 可比機種區間 {lo}–{hi} s;本設計 {tl['t_separation']:.0f} s"
f" -> {'落在區間內' if ok_bm else '**落在區間外**'}")
print()
print("六、敏感度:分離時刻取決於三個還沒定的設計選擇")
print(f" {'T/W₁':>6}{'Δv 分配':>9}{'Δv₁ m/s':>11}{'質量比':>9}"
f"{'分離 s':>9}{'熄火 s':>9}")
print(" " + "-" * 53)
for tw1, split, dv1, mr1, tsep, tbo in sensitivity(tl):
mark = " <- 本設計" if (abs(tw1 - tl["tw1"]) < 1e-6
and abs(split - STAGE1_SPLIT) < 1e-6) else ""
print(f" {tw1:>6.2f}{split:>9.2f}{dv1:>11,.0f}{mr1:>9.2f}"
f"{tsep:>9.1f}{tbo:>9.1f}{mark}")
ts = [r[4] for r in sensitivity(tl)]
print(f" 分離時刻範圍 {min(ts):.0f}–{max(ts):.0f} s"
f"(本設計 {tl['t_separation']:.0f} s)")
print(f" **所以 185 s 不是一個精確值,是一個設計選擇的結果。**")
print()
print("七、做不到的部分 —— 這一段不要當成已驗證")
val = validate_loss_model(verbose=False)
r = ascent_integrate(tl)
print(f" 熄火要同時滿足三個條件:v = {tl['v_bo']:,.0f} m/s、"
f"γ = {r['gamma_target_deg']:.1f} deg、h ≈ "
f"{A.KARMAN_M/1000:.0f} km。")
print(f" 本檔的上升段積分只有「俯仰踢角」一個自由度,所以收不了三個條件:")
print(f" 積分出的熄火 v {r['v_burnout']:,.0f} m/s"
f" γ {r['gamma_burnout_deg']:.1f} deg"
f" h {r['alt_burnout_m']/1000:,.0f} km")
print(f" (h 遠高於 {A.KARMAN_M/1000:.0f} km —— γ 夾在 "
f"{r['gamma_target_deg']:.1f} deg 之後一路爬升,軌跡過度拋高)")
print(f" 而且損失模型**沒通過基準**:套在 200 km 入軌案例上算出 "
f"{val['loss_ms']:,.0f} m/s,")
print(f" 公認是 1,500–2,000。所以積分器不能用來驗證時程,")
print(f" 它只證明了一件事:**這個設計點需要做上升段軌跡最佳化**。")
print(f" STK 的 Astrogator 有有限推力機動與 targeter,")
print(f" 是做這件事的正確工具;本檔不假裝已經做了。")
print()
print("八、對現有場景的影響(這一項很重要)")
print(f" 彈道傳播器把熄火速度當**脈衝**施加在發射點,所以")
print(f" Missile 物件的起始時刻在物理上等於**第二節熄火**,不是起飛。")
print(f" 也就是說,真正的起飛在 Missile 起始時刻的 "
f"{tl['t_burnout']:.0f} s 之前,")
print(f" 分離在 Missile 起始時刻的 "
f"{tl['t_burnout'] - tl['t_separation']:.0f} s 之前。")
print(f" 要在時間軸上呈現分離,場景起點必須往前挪 "
f"{tl['t_burnout']:.0f} s。")
return tl
if __name__ == "__main__":
import sys as _sys
if "--optimise" in _sys.argv or "--optimize" in _sys.argv:
optimise_ascent()
elif "--steering" in _sys.argv:
steering_loss_report()
else:
describe()
# ══════════════════════════════════════════════════════════════════════════════
# 寫進 STK:上升段的外部星曆
#
# 為什麼不用 Astrogator:Astrogator 做得到動力上升,但本章的上升段是
# 自己積分的(重力轉彎 + 導引段),要在 Astrogator 裡重現同一條軌跡
# 得把同一組假設再輸入一次 —— 那會變成兩份真相。
# 用外部星曆播放自己的解,與高超音速滑翔段的做法一致。
#
# **代價要講明:星曆是播放,不是求解。** STK 不會驗證這條軌跡的
# 動力學,它只是照著座標走。真正的動力學驗證在 validate_loss_model()。
# ══════════════════════════════════════════════════════════════════════════════
ASCENT_OBJ = "TWSP_Ascent"
ASCENT_EPH = "twsp_ascent.e"
def ascent_track(sol=None, record_dt=2.0):
"""上升段的地固軌跡 [(t, lat, lon, alt, v, phase)]。
沿發射方位的大圓前進 downrange,高度由積分給出。
自轉的影響在這個時間尺度(7 分鐘)只有 0.03 度經度,略過。
## 這裡曾經漏掉阻力
先前這一段重新積分時**沒有傳 `beta0`**,所以它是**無阻力**的軌跡 ——
而踢角與時程卻是 `solve_ascent()` **含阻力**解出來的。
兩者不相容,結果是星曆止於 **120.8 km** 而不是設計的 100 km
(+20.8 km、−13 m/s、沿程 −9.5 km)。
建構的輸出一直印著「止於 121.7 km」,但沒有任何檢查在看它 ——
驗證段比的是解析解與 Missile,沒有比 `TWSP_Ascent` 的星曆終點。
抓到它的是**分離動畫**:第一節的星曆含阻力、太空飛機的不含,
所以分離那一刻兩個物件相距 **17 km**、高度差 11 km,
在 3D 視窗裡根本不會「分離」,而是兩條各走各的軌跡。
"""
if sol is None or sol.get("track") is None:
base = solve_ascent()
tl = base["timeline"]
sol = ascent_integrate(tl, kick_deg=base["kick_solved_deg"],
t_kick_s=16.0, record_dt=record_dt,
beta0=BETA0_DESIGN)
sol["kick_solved_deg"] = base["kick_solved_deg"]
ho = B.mission_geometry(verbose=False)
az = ho["azimuth_deg"]
out = []
for t, h, sdown, v, gam in sol["track"]:
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, sdown)
out.append((t, lat, B.wrap_lon(lon), max(0.0, h), v, "ascent"))
return out, sol
def build_ascent(force=False, verbose=True):
"""把上升段建成 STK 物件。需要 STK 已啟動、且場景已開。"""
from stk_binding import (AgESTKObjectType, AgEVePropagatorType,
STKDesktop)
import os
pts, sol = ascent_track()
here = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(here, ASCENT_EPH)
B.write_ephemeris(path, pts, B.EPOCH)
stk = STKDesktop.AttachToApplication()
root = stk.Root
sc = root.CurrentScenario
if sc is None:
raise SystemExit("沒有開著的場景。先跑 spaceport_build.py")
for i in range(sc.Children.Count - 1, -1, -1):
if sc.Children.Item(i).InstanceName == ASCENT_OBJ:
sc.Children.Item(i).Unload()
ac = sc.Children.New(AgESTKObjectType.eAircraft, ASCENT_OBJ)
ac.SetRouteType(AgEVePropagatorType.ePropagatorStkExternal)
ac.Route.Filename = path
ac.Route.Propagate()
info = {"object": ASCENT_OBJ, "ephemeris": path, "points": len(pts),
"t_burnout_s": pts[-1][0], "alt_burnout_m": pts[-1][3],
"v_burnout": pts[-1][4], "downrange_m": sol["downrange_m"],
"kick_deg": sol.get("kick_solved_deg")}
if verbose:
print(f" {ASCENT_OBJ} {len(pts)} 點,T+{pts[-1][0]:.0f} s,"
f"止於 {pts[-1][3]/1000:.1f} km / {pts[-1][4]:,.0f} m/s ✔")
print(f" 沿程 {sol['downrange_m']/1000:,.0f} km")
return ac, info
spaceport_suborbital/spaceport_build.py
# -*- coding: utf-8 -*-
"""台美太空港次軌道任務 —— STK 場景建構。
屏東旭海太空港 -> 次軌道彈道弧 -> 再入滑翔 -> 休士頓太空港 KEFD
## 架構已更正:淺角度助推滑翔,不是拋高彈道
**舊版用最小能量彈道當彈道段,那條軌跡遠地點 1,130 km,沒有有翼載具會飛。**
最小能量解的熄火角是 γ = 45° − θ/4,射程愈短角度愈陡(6,292 km 對應 30.9°),
載具被拋到卡門線的 11 倍高。對無翼彈頭那最省,對乘波體是白爬升。
現在改成真正的助推滑翔:**淺角度(γ = 2°)熄火在卡門線附近,全程靠升力**。
遠地點降到 106 km,是原本的 1/11。
這個錯誤是從**軌跡形狀**看出來的,不是數值檢查抓到的 ——
每個數字都「對」,組合起來卻不可飛。
## 第二次更正:真空弧的射程曾被重複計算
舊版把熄火速度定義成「從熄火起一路平衡滑翔 13,109 km 所需的速度」,
但熄火後有約 1,200 km 的弧是在大氣之上飛的,幾乎沒有阻力。
把那一段也算進 Eggers 滑翔射程,等於同一段路算了兩次,多出來的差額
被記成一段 **3,765 km 的「無模型涵蓋」接縫**,還被當成工具限制對外說明。
**那不是工具限制,是記帳錯誤。** boost_glide() 現在解自洽閉合條件:
弧射程(v_bo) + Eggers 滑翔射程(弧終點速度) = 全程
結果 v_bo 由 6,335 降到 **6,118 m/s**,而且弧終點速度恰好等於該點所需的
滑翔進入速度 —— **航程接縫歸零**,閉合殘差 0.000 m。
## 三個物件,分工由不同的理由決定
Missile TWSP_Ballistic 真空弧 1,171 km。無升力,彈道傳播器夠用。
Aircraft TWSP_Glide 外部星曆 11,938 km。高超音速滑翔 + TAEM。
Aircraft TWSP_Spaceplane_LD4 Aviator 終端進場降落,約 31 km。
中段用**外部星曆**而不是 Aviator,理由是實測:同一條航線讓 Aviator 飛,
地面軌跡是直線距離的 **5.32 倍**(載具在繞圈),而星曆是 1.00 倍。
根因是 Aviator 的航段求解器碰到高度變化就多繞一圈,調 AltitudeRate 無效。
## 還剩下的是工具邊界,不是航程缺口
弧終點 6,163 m/s 仍高於 Aviator 的求解上限 5,400 m/s(實測,見
spaceport_vehicle.py 檔尾)。這個差距**只影響「能不能用 Aviator 飛」**,
不影響航程閉合。升阻比提高到 `SEAM_FREE_LD`(目前 5.62)時弧終點速度
降到該上限以下,
整條任務可在 Aviator 內端對端模擬 —— 工具邊界因此可以寫成設計指標。
"""
import io
import math
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import spaceport_analysis as A
import spaceport_vehicle as V
SCENARIO = "TWSP_Suborbital"
EPOCH = "1 Oct 2026 02:00:00.000"
STOP = "1 Oct 2026 08:00:00.000"
MISSILE_NAME = "TWSP_Ballistic"
SPACEPLANE_NAME = "TWSP_Spaceplane"
# 交接條件。真空速取實測上限,不是估計值;
# 交接高度**由平衡滑翔解算出**,不再是手選的 80 km ——
# 實測 W/S 250 kg/m²、C_L 0.8 時,5,400 m/s 對應約 67 km。
HANDOVER_TAS_MS = V.AVIATOR_TAS_LIMIT_MS # 5,400 m/s
DESIGN_LD = A.LD_DESIGN # 4.0,見分析模組的更正說明
# 此值以上 Aviator 可獨力飛完(弧終點速度降到求解上限以下)。
# 兩次修正:
# 6.54 -> 6.13 判準改看**弧終點速度**而非熄火速度
# 6.13 -> 5.62 把**上升段的沿程**計入閉合條件之後,滑翔段變短,
# 所需的弧終點速度跟著降,需要的升阻比也就更低
SEAM_FREE_LD = 5.6235
# 滑翔段的航點數。太少 -> 剖面失真;太多 -> 每點都是一次求解,很慢。
GLIDE_WAYPOINTS = 8
# 交接進場的高度與速度。低於此改由降落程序接手。
APPROACH_ALT_M = 6_000.0
APPROACH_MACH = 0.55
# 太空飛機的 3D 模型。**用加了掛點的那一版。**
# 原檔 `twsp2_spaceplane.glb` 沒有 attach point,所以尾焰只能從物件原點
# 噴出來,看起來像從機身中央冒煙。`model/add_attach_point.py` 產生
# `_ap` 版,在機身尾端中心線加一個 `MainEngine` 掛點
# (glTF 擴充 `AGI_articulations` 的 `isAttachPoint`,逆推自 STK 自己的
# aircraft.glb / launchvehicle.glb —— **不是靠名字認的**)。
# 實測:原檔的 AvailableAttachPoints 是 []、`_ap` 版是 ['MainEngine']。
# **原檔沒有被改動**,把這一行換回去就用回原本的。
MODEL_GLB = "model/twsp2_spaceplane_ap.glb"
MODEL_GLB_PLAIN = "model/twsp2_spaceplane.glb" # 原檔,留著對照
SPACEPLANE_ATTACH = "MainEngine" # 尾焰掛在這裡
# ══════════════════════════════════════════════════════════════════════════════
# US Standard Atmosphere 1976 的音速 —— 只到 86 km
#
# 為什麼需要:Aviator 的速度欄位吃 **Mach**,而實測的限制在**真空速**上。
# 要把物理上的速度指令換成 Mach,就得知道該高度的音速。
#
# 86 km 是 US Std 1976 的上界。實測 STK 在 100 km 給出的最高 Mach 與 80 km
# 完全相同(18.96 / 18.95),與「86 km 以上不再變化」一致 —— 所以本函式
# 在 86 km 以上回傳 86 km 的值,並且**明確標示這是外插**,不假裝算得準。
# ══════════════════════════════════════════════════════════════════════════════
_LAYERS = ( # (基準高度 m, 基準溫度 K, 溫度梯度 K/m)
(0.0, 288.15, -0.0065),
(11_000.0, 216.65, 0.0),
(20_000.0, 216.65, 0.001),
(32_000.0, 228.65, 0.0028),
(47_000.0, 270.65, 0.0),
(51_000.0, 270.65, -0.0028),
(71_000.0, 214.65, -0.002),
(84_852.0, 186.946, 0.0),
)
_GAMMA, _R_AIR = 1.4, 287.053
def temperature(alt_m):
"""US Std 1976 靜溫。回傳 (溫度 K, 是否為外插)。"""
h = float(alt_m)
extrap = h > 84_852.0
if extrap:
h = 84_852.0
base_h, base_t, lapse = _LAYERS[0]
for lh, lt, ll in _LAYERS:
if h >= lh:
base_h, base_t, lapse = lh, lt, ll
else:
break
return base_t + lapse * (h - base_h), extrap
def sound_speed(alt_m):
"""該高度的音速 m/s。回傳 (音速, 是否為外插)。"""
t, extrap = temperature(alt_m)
return math.sqrt(_GAMMA * _R_AIR * t), extrap
def wrap_lon(lon_deg):
"""經度收到 -180..180。point_along 會回傳 0..360,直接印出來會看到
「227 度」這種讀者無法對照地圖的數字,寫進 STK 也不直觀。"""
x = float(lon_deg) % 360.0
return x - 360.0 if x > 180.0 else x
# 任務幾何現在要解上升段(每次約 5 秒),而它被驗證段、測試、
# 文件產生器反覆呼叫。快取以升阻比為鍵 —— 其餘輸入都是模組常數。
_GEOM_CACHE = {}
def _ascent_downrange_fn(azimuth_deg):
"""回傳 ascent_fn(v_bo) -> 上升段沿程。
**延遲 import**:spaceport_ascent 在模組層級 import 本模組,
在這裡於函式內 import 才不會成環。
"""
def fn(v_bo):
import spaceport_ascent as S
return S.ascent_downrange(v_bo, azimuth_deg=azimuth_deg)["downrange_m"]
return fn
# ══════════════════════════════════════════════════════════════════════════════
def mission_geometry(ld=None, verbose=True):
"""把任務分成兩段,並算出各段的幾何。
**2026-09 改版:接縫歸零,分段由兩段構成。**
1. 助推真空弧 熄火(卡門線、γ=2°)到載具降至平衡滑翔高度為止。
無升力參與,Missile 的彈道傳播器可以表示。
2. 外部星曆段 弧終點到跑道進場介面。高超音速滑翔 + TAEM,
由 ePropagatorStkExternal 播放,**不受 Aviator 限制**。
之後 Aviator 只飛最後約 31 km 的終端進場(見 terminal_rows())。
先前這裡有第三段「高速滑翔 3,765 km,**無模型涵蓋**」。
那一段不是物理或工具限制,是記帳錯誤:交接點被釘在 Aviator 的
5,400 m/s 求解上限,而真空弧的射程又被重複計入滑翔段。
boost_glide() 改成自洽閉合之後,**弧終點速度恰好等於該點所需的
滑翔進入速度**,航程接縫歸零。
`aviator_seam_ms` 仍保留:它現在只回答一個**工具**問題 ——
「如果不用外部星曆,Aviator 能否獨力接手?」與航程無關。
"""
ld = DESIGN_LD if ld is None else ld
key = round(float(ld), 6)
if key in _GEOM_CACHE:
out = _GEOM_CACHE[key]
if verbose:
_print_geometry(out)
return out
ang, total_m, az = A.great_circle(A.LAUNCH_LAT, A.LAUNCH_LON,
A.LANDING_LAT, A.LANDING_LON)
bg = A.boost_glide(total_m, ld, ascent_fn=_ascent_downrange_fn(az))
# 兩段的長度與交接狀態全部來自 boost_glide 的自洽解 ——
# 這裡不再自己算一次圓錐曲線,避免兩份真相來源。
ascent_m = bg.get("ascent_m", 0.0)
arc_m = bg["arc_m"]
eph_m = bg["glide_m"]
v_eph = bg["v_entry"] # 弧終點速度 = 星曆起點速度
# 星曆起點的沿程距離 = 上升段 + 真空弧。
# **上升段的沿程先前完全沒被計入**,整條任務因此會飛過落點 1,259 km。
eph_s = ascent_m + arc_m
eph_lat, eph_lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, eph_s)
eph_alt = bg["glide_alt_at_bo_m"]
a_eph, extrap = sound_speed(eph_alt)
out = {"ld": ld, "total_m": total_m, "azimuth_deg": az,
"v_bo": bg["v_bo"], "apogee_m": bg["apogee_m"],
"burnout_alt_m": bg["burnout_alt_m"],
"gamma_bo_deg": bg["gamma_bo_deg"],
"ascent_m": ascent_m, "arc_m": arc_m, "eph_m": eph_m,
"arc_end_alt_m": eph_alt,
"eph_start_s_m": eph_s, "eph_start_lat": eph_lat,
"eph_start_lon": wrap_lon(eph_lon),
"eph_start_alt_m": eph_alt, "eph_start_tas_ms": v_eph,
"eph_start_mach": v_eph / a_eph, "sound_speed_ms": a_eph,
"sound_speed_extrapolated": extrap,
# 閉合殘差:兩段相加應等於全程。留著讓上層驗證,而不是相信。
"closure_residual_m": ascent_m + arc_m + eph_m - total_m,
# 工具邊界,不是航程缺口
"aviator_seam_ms": max(0.0, v_eph - HANDOVER_TAS_MS)}
_GEOM_CACHE[key] = out
if verbose:
_print_geometry(out)
return out
def _print_geometry(out):
total_m, az, ld = out["total_m"], out["azimuth_deg"], out["ld"]
ascent_m, arc_m, eph_m = out["ascent_m"], out["arc_m"], out["eph_m"]
bg = out
if True:
print(f" 大圓全程 {total_m/1000:>9,.0f} km 方位 {az:.2f} deg"
f" L/D {ld:.2f}")
print(f" 熄火 {bg['v_bo']:>9,.0f} m/s @ "
f"{bg['burnout_alt_m']/1000:.0f} km, γ {bg['gamma_bo_deg']:.1f} deg"
f" 遠地點 {bg['apogee_m']/1000:,.0f} km")
print(f" 動力上升段 {ascent_m/1000:>9,.0f} km "
f"{ascent_m/total_m*100:4.1f}% (外部星曆 TWSP_Ascent)")
print(f" 助推真空弧 {arc_m/1000:>9,.0f} km {arc_m/total_m*100:4.1f}%"
f" (Missile)")
print(f" 外部星曆段 {eph_m/1000:>9,.0f} km {eph_m/total_m*100:4.1f}%"
f" (StkExternal,末段交給 Aviator)")
print(f" 交接 {out['eph_start_alt_m']/1000:>9.1f} km "
f"{out['eph_start_tas_ms']:,.0f} m/s"
f" -> Mach {out['eph_start_mach']:.2f}"
f" ({out['eph_start_lat']:.3f}, {out['eph_start_lon']:.3f})")
print(f" 閉合殘差 {out['closure_residual_m']:>9.3f} m"
f" (三段相加 vs 全程)")
print(f" Aviator 獨力接手還差 {out['aviator_seam_ms']:,.0f} m/s"
f" —— 工具邊界,不是航程缺口")
# 舊名保留為別名,讓既有呼叫端不致於靜默取到錯的東西 ——
# 它現在回傳的是新的幾何 dict,鍵名已改,用舊鍵會立刻 KeyError 而不是算錯。
def handover_geometry(ld=None, tas_ms=None, verbose=True):
"""**已更名為 mission_geometry()。** 舊名保留以便追蹤呼叫端。"""
return mission_geometry(ld=ld, verbose=verbose)
# ── 佈點的兩個約束,都是實測出來的 ──────────────────────────────────
#
# **一、每段的航向變化不能超過約 13 度。**
#
# 控制實驗(Mach 17、1,000 km 兩段、只改中間的轉向角):
# 轉向 0° / 5° / 10° / 13° -> 軌跡/直線 = 1.00–1.01 直線
# 轉向 15° / 20° / 30° -> 軌跡/直線 = 3.87–3.97 **繞圈**
# 門檻在 13–15 度之間,而且與速度變化無關(Mach −1/−3/−5 都是 1.01)。
#
# 超過門檻時 Aviator 不會報錯、IsValid 仍是 True ——
# 載具改以最大轉彎能力持續盤旋(實測半徑約 1,050 km、側向 2.4g),
# 一圈一圈慢慢漂向航點。
#
# **這個錯誤讓 32 項驗證全部通過。** 端點、高度、速度、落點、觸地航向
# 全部正確,只有地面軌跡多繞了 5.32 倍(42,681 km 對 8,016 km),
# 飛行時間因此從解析解的 65 分鐘變成 236 分鐘。
# 驗證段已補上地面軌跡長度檢查。
#
# **二、段長不能太短。** 同一組實驗:Mach 17 下 200 km 的段繞圈 29 倍,
# 500 km 以上都是 1.00。取 v × 100 s 作為下限
# (5,180 m/s 時約 518 km,與實測邊界相符)。
#
# 兩個約束方向相反 —— 段短則轉向小但可能短於下限。
# 佈點時先取時間步長,再依航向變化收縮,最後檢查是否撞到下限並回報。
GLIDE_TIME_STEP_S = 120.0
GLIDE_MIN_LEG_S = 100.0 # 最短段長 = v × 這個秒數
GLIDE_MIN_LEG_M = 120_000.0 # 低速時的絕對下限
GLIDE_MAX_TURN_DEG = 10.0 # 實測門檻 13–15 度,留邊際
def glide_profile_timed(ho, dt_s=GLIDE_TIME_STEP_S, min_leg_m=GLIDE_MIN_LEG_M):
"""Aviator 段的航點剖面:等時間步長佈點,高度由平衡滑翔解算出。
兩處與舊版不同:
**高度不再是內插出來的。** 舊版用 `frac = (v/v0)²` 在交接高度與進場高度
之間內插,那只是一條形狀合理的曲線。現在每一點的高度直接由
`equilibrium_glide_alt(v)` 解出 —— 升力等於離心解除後的有效重量。
高度因此有物理依據,而且會自然地隨速度衰減而下降。
**這個剖面目前不被飛。** 滑翔段改走外部星曆之後,實際軌跡由
`glide_track()` 產生;這裡算出來的航點只被印出來,以及回報
「若改回 Aviator 航點航路,哪一段會撞到求解邊界」。
保留它是因為那個邊界隨升阻比移動 —— 設計點由 3.2 降到 2.3 時,
沿程 7,129 km 處就第一次撞到下限(轉 10.3°、段長 590 km)。
**等時間步長的理由不變。** 實測交接真空速下,501 km 的航段 Aviator
解不出來、1,002 km 可以;低速時 264 km 仍可解。所需航段長度隨速度
增加,所以用 Δs = v·Δt 佈點:快的時候段長、慢的時候段短。
"""
az = ho["azimuth_deg"]
total_glide = ho["eph_m"]
ld = ho["ld"]
s0 = ho["eph_start_s_m"]
s_cur = s0
rows = []
warns = []
prev_course = None
guard = 0
while guard < 200:
guard += 1
travelled = s_cur - s0
remaining = max(0.0, total_glide - travelled)
v = A.glide_v_for_range(remaining, ld) if remaining > 1000.0 else 0.0
if remaining <= 1000.0:
alt = APPROACH_ALT_M
else:
alt = max(APPROACH_ALT_M, A.equilibrium_glide_alt(v))
a, extrap = sound_speed(alt)
mach = max(APPROACH_MACH, v / a) if a > 0 else APPROACH_MACH
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, s_cur)
lon = wrap_lon(lon)
if rows:
prev_course = A.great_circle(rows[-1][1], rows[-1][2],
lat, lon)[2]
rows.append((s_cur, lat, lon, alt, mach, v, extrap))
if remaining <= 1000.0:
break
# 先按時間步長取一個候選段長,再依「航向變化不超過上限」收縮。
floor_m = max(GLIDE_MIN_LEG_M, v * GLIDE_MIN_LEG_S)
step = max(floor_m, v * dt_s)
for _ in range(24):
if travelled + step >= total_glide:
break
la2, lo2 = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az,
s_cur + step)
c_in = A.great_circle(lat, lon, la2, wrap_lon(lo2))[2]
if prev_course is None:
break
d = abs((c_in - prev_course + 180.0) % 360.0 - 180.0)
if d <= GLIDE_MAX_TURN_DEG or step <= floor_m * 1.001:
break
step = max(floor_m, step * 0.75)
# 撞到下限還轉不過來就要講出來,不要靜默接受
if prev_course is not None and step <= floor_m * 1.001:
la2, lo2 = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az,
min(s_cur + step, s0 + total_glide))
c_in = A.great_circle(lat, lon, la2, wrap_lon(lo2))[2]
d = abs((c_in - prev_course + 180.0) % 360.0 - 180.0)
if d > GLIDE_MAX_TURN_DEG:
warns.append((s_cur / 1000.0, d, step / 1000.0))
if travelled + step >= total_glide:
s_cur = s0 + total_glide
else:
s_cur += step
if warns:
print(f" [WARN] {len(warns)} 段的航向變化超過 "
f"{GLIDE_MAX_TURN_DEG:.0f} 度且已撞到最短段長:")
for sk, d, lk in warns[:5]:
print(f" 沿程 {sk:,.0f} km 處轉 {d:.1f} 度,段長 {lk:,.0f} km")
print(" **這是反事實**:目前滑翔段走外部星曆"
"(由 glide_track() 產生),不是飛這組航點,")
print(" 所以不會真的繞圈。這個警告量的是"
"「若改回 Aviator 航點航路,哪一段會解不出來」。")
return rows
def describe_plan(ld=None, verbose=True):
"""不連 STK 也能跑,先把數字攤開檢查。"""
print("=" * 78)
print("台美太空港次軌道任務 —— 任務剖面(淺角度助推滑翔)")
print("=" * 78)
ho = mission_geometry(ld=ld, verbose=verbose)
rows = glide_profile_timed(ho)
print()
print(f" 外部星曆段剖面({len(rows)} 點,高度由平衡滑翔解算出)")
print(f" {'沿程 km':>10} {'緯度':>9} {'經度':>10} {'高度 km':>9}"
f" {'真空速 m/s':>12} {'指令 Mach':>11}")
print(" " + "-" * 66)
for s_, lat, lon, alt, mach, v, ex in rows:
print(f" {s_/1000:>10,.0f} {lat:>9.3f} {lon:>10.3f} {alt/1000:>9.1f}"
f" {v:>12,.0f} {mach:>11.2f}{' *' if ex else ''}")
print(" (* 音速取 86 km 的值,US Std 1976 的上界以上是外插)")
print()
print(" ── 與舊架構的對照 ──────────────────────────────────────")
old = A.ballistic_v_for_range(A.RE * 0.9873) # 舊版的 6,292 km 彈道段
print(f" 舊:分段代理模型,彈道段遠地點 1,130 km —— 有翼載具不會那樣飛")
print(f" 新:淺角度熄火 γ {ho['gamma_bo_deg']:.0f}°,遠地點 "
f"{ho['apogee_m']/1000:,.0f} km(1/{1130/(ho['apogee_m']/1000):.0f})")
print()
print(" ── 航程閉合 ────────────────────────────────────────────")
print(f" 真空弧 {ho['arc_m']/1000:,.0f} km + 外部星曆 "
f"{ho['eph_m']/1000:,.0f} km = {ho['total_m']/1000:,.0f} km")
print(f" 閉合殘差 {ho['closure_residual_m']:.3f} m —— **沒有航程缺口**。")
print(" 先前這裡有一段 3,765 km『無模型涵蓋』,那是記帳錯誤:")
print(" 交接點被釘在 Aviator 的求解上限,而真空弧的射程又被重複")
print(" 計入滑翔段。改成自洽閉合後,弧終點速度恰好等於該點所需的")
print(" 滑翔進入速度。")
print()
print(" ── 工具邊界(與航程無關)────────────────────────────────")
if ho["aviator_seam_ms"] > 1.0:
print(f" 弧終點 {ho['eph_start_tas_ms']:,.0f} m/s 高於 Aviator 的"
f"求解上限 {HANDOVER_TAS_MS:,.0f} m/s,差 "
f"**{ho['aviator_seam_ms']:,.0f} m/s**。")
print(" 所以高超音速段用外部星曆播放,Aviator 只做終端進場。")
# **用常數,不要在這裡重解。** 含上升段的反解要 48 次二分,
# 每次 boost_glide 內部還要解上升段的兩個旋鈕(單次約 0.12 s)——
# 實測約十分鐘,放在每次建場景的路徑上不合理。
# SEAM_FREE_LD 就是那個解;`tests/test_physics.py` 用**一次**
# boost_glide 驗證它(檢查它是解,而不是重新解一次)。
need = SEAM_FREE_LD
print(f" 升阻比提高到 **{need:.2f}** 時弧終點速度降到該上限以下,")
print(" 整條任務可在 Aviator 內端對端模擬。")
else:
print(f" 弧終點 {ho['eph_start_tas_ms']:,.0f} m/s 已在 Aviator "
f"可解範圍內,全程可端對端模擬。")
return ho
# ══════════════════════════════════════════════════════════════════════════════
# STK 建構
# ══════════════════════════════════════════════════════════════════════════════
from contextlib import contextmanager
# 無動力太空飛機的下滑道比客機陡得多。太空梭外段約 20 度、
# 約 600 m 拉平到 1.5 度。這裡取一個折衷值供 Aviator 攔截。
GLIDESLOPE_DEG = 8.0
# 切入下滑道的距離,自跑道端算。
# **必須小於星曆終點到跑道的剩餘距離**(約 31 km)——
# 設 40 km 時載具要先飛出去再折回,實測軌跡/直線 2.10 倍。
APPROACH_FIX_M = 12_000.0
MISSILE_STEP_S = 10.0
@contextmanager
def scoped_unit(root, dimension, unit):
"""暫時切換單位維度,離開時還原。
**為什麼一定要還原。** 上一章有一次改了 AviatorDistanceUnit 沒還原,
下一架飛機的 MinAirspeed 被當成 50 nm/hr 寫成 92,600 ——
STK 接著以 100% CPU 空轉 44 分鐘,沒有任何錯誤訊息。
單位是全域狀態,而「寫進去讀回來相符」對單位錯誤完全沒有偵測力。
"""
up = root.UnitPreferences
try:
old = up.GetCurrentUnitAbbrv(dimension)
except Exception:
old = None
try:
up.SetCurrentUnit(dimension, unit)
except Exception:
pass
try:
cur = up.GetCurrentUnitAbbrv(dimension)
except Exception:
cur = None
try:
yield cur
finally:
if old is not None:
try:
up.SetCurrentUnit(dimension, old)
except Exception:
pass
def attach():
"""接上正在執行的 STK。
STK 12 與 STK 13 是兩套獨立的 COM 綁定,互相接不到對方的實例 ——
這裡固定用 12,與本專案其他章節一致。
"""
from stk_binding import STKDesktop
stk = STKDesktop.AttachToApplication()
return stk, stk.Root
def open_scenario(root, name=SCENARIO, force=False):
"""開啟或建立場景。
**不用 --force 就不動既有場景。** 上一章有一次測試腳本共用了生產場景
名稱又帶了 force,把使用者辛苦建好的四機場景整個換成一架測試機。
規則:測試腳本用自己的場景名稱,force 只在使用者明確要求時給。
"""
sc = root.CurrentScenario
if sc is not None and sc.InstanceName == name:
if not force:
print(f" 沿用既有場景「{name}」(要重建請給 --force)")
return sc, False
root.CloseScenario()
sc = None
elif sc is not None:
if not force:
print(f" [STOP] 目前開著「{sc.InstanceName}」,不是「{name}」。")
print(f" 要關掉它並建立「{name}」請給 --force。")
return None, False
root.CloseScenario()
sc = None
root.NewScenario(name)
sc = root.CurrentScenario
sc.SetTimePeriod(EPOCH, STOP)
try:
root.Rewind()
except Exception:
pass
print(f" 建立場景「{name}」 {EPOCH} -> {STOP}")
return sc, True
def set_units(root):
"""把本腳本依賴的單位維度全部釘死,並回報實際值。
不回報就等於沒釘 —— SetCurrentUnit 失敗是靜默的。
"""
want = (("DateFormat", "UTCG"), ("Distance", "m"), ("Angle", "deg"),
("AviatorAltitudeUnit", "m"), ("Rate", "m/sec"))
got = {}
for dim, unit in want:
try:
root.UnitPreferences.SetCurrentUnit(dim, unit)
except Exception:
pass
try:
got[dim] = root.UnitPreferences.GetCurrentUnitAbbrv(dim)
except Exception:
got[dim] = "<無此維度>"
return got
def build_sites(root, sc):
"""發射場(Place)與降落場(Facility)。"""
from stk_binding import AgESTKObjectType
out = {}
for kind, name, lat, lon, alt in (
(AgESTKObjectType.ePlace, A.LAUNCH_NAME,
A.LAUNCH_LAT, A.LAUNCH_LON, A.LAUNCH_ALT_M),
(AgESTKObjectType.eFacility, A.LANDING_NAME,
A.LANDING_LAT, A.LANDING_LON, A.LANDING_ALT_M)):
for ch in list(sc.Children):
if ch.InstanceName == name:
ch.Unload()
o = sc.Children.New(kind, name)
# **UseTerrain 預設是 True,高度會被地形覆寫。**
# 實測:設 0 / 100 / 9.8 三次,回讀都是 -19.26 m —— 寫入的值
# 完全不生效,而 AssignGeodetic 不報任何錯。
# (AltRef=2 代表以地形為基準;HeightAboveGround=0 代表貼地。)
# 這裡明確關掉,讓設定的 MSL 高度生效。
try:
o.UseTerrain = False
except Exception as e:
print(f" [WARN] {name} 關閉 UseTerrain 失敗:{str(e)[:50]}")
o.Position.AssignGeodetic(lat, lon, alt)
gl = o.Position.QueryPlanetodetic()
out[name] = (gl[0], gl[1], gl[2])
d = gl[2] - alt
mark = "OK" if abs(d) < 1.0 else f"** 與設定值差 {d:+.1f} m **"
print(f" {name:<18} {gl[0]:>9.4f}, {gl[1]:>10.4f} {gl[2]:>7.1f} m"
f" (設 {alt:.1f}) {mark}")
return out
def build_missile(root, sc, ho, verbose=True, t0_s=0.0):
"""助推真空弧:旭海 -> 載具降到平衡滑翔高度為止。
**不再用最小能量解。** 最小能量彈道對應的熄火角是 γ = 45° − θ/4,
把載具拋到 1,130 km —— 那是無翼彈頭的最省解,不是有翼載具的軌跡。
改用 `eLaunchControlFixedApogeeAlt`,把遠地點釘在卡門線附近
(本設計 107 km),得到壓低(depressed)的彈道。
**這一段只能到載具降至平衡滑翔高度為止。** 之後靠升力維持的滑翔,
彈道傳播器表示不了 —— 它是真空橢圓,沒有升力就會掉下來。
那正是需要機翼的原因,也是本章模型接縫的由來。
"""
from stk_binding import (AgESTKObjectType, AgEVePropagatorType,
AgEVeImpactLocation, AgEVeImpact,
AgEVeLaunchControl)
for ch in list(sc.Children):
if ch.InstanceName == MISSILE_NAME:
ch.Unload()
m = sc.Children.New(AgESTKObjectType.eMissile, MISSILE_NAME)
m.SetTrajectoryType(AgEVePropagatorType.ePropagatorBallistic)
tr = m.Trajectory
tr.Step = MISSILE_STEP_S
try:
tr.EphemerisInterval.SetExplicitInterval(EPOCH, STOP)
except Exception as e:
print(f" [WARN] 彈道時間區間設定失敗:{str(e)[:60]}")
# **發射點移到動力上升段的終點(的地面投影)。**
# Missile 的彈道傳播器只能從地面發射,所以這是一個模型上的權宜:
# 它代表的是「熄火之後的真空弧」,而那一段的起點在旭海下游
# 約 1,200 km。先前從旭海起飛,於是這條弧與星曆段的起點
# 差了整整一個上升段的長度(驗證段抓到 1,204.7 km)。
bo_lat, bo_lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON,
ho["azimuth_deg"], ho["ascent_m"])
tr.Launch.Lat = bo_lat
tr.Launch.Lon = wrap_lon(bo_lon)
tr.Launch.Alt = A.LAUNCH_ALT_M
tr.SetImpactLocationType(AgEVeImpactLocation.eImpactLocationPoint)
il = tr.ImpactLocation
il.SetImpactType(AgEVeImpact.eImpactLLA)
# 落點 = 真空弧末端(載具降到平衡滑翔高度的那一點),不是交接點。
arc_lat, arc_lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON,
ho["azimuth_deg"],
ho["ascent_m"] + ho["arc_m"])
arc_lon = wrap_lon(arc_lon)
il.Impact.Lat = arc_lat
il.Impact.Lon = arc_lon
alt_set = False
try:
il.Impact.Alt = ho["arc_end_alt_m"]
alt_set = abs(il.Impact.Alt - ho["arc_end_alt_m"]) < 1.0
except Exception:
alt_set = False
# 壓低彈道:把遠地點釘在卡門線附近,而不是讓它解最小能量
apo_set = False
try:
il.SetLaunchControlType(AgEVeLaunchControl.eLaunchControlFixedApogeeAlt)
lc = il.LaunchControl
lc.ApogeeAlt = ho["apogee_m"]
apo_set = abs(lc.ApogeeAlt - ho["apogee_m"]) < 100.0
except Exception as e:
print(f" [WARN] 固定遠地點設定失敗,退回最小能量解:{str(e)[:60]}")
try:
il.SetLaunchControlType(
AgEVeLaunchControl.eLaunchControlFixedDeltaVMinEcc)
except Exception:
pass
# ── 真空弧要從**熄火時刻**開始,不是從場景起點 ──────────────────
# 彈道傳播器把熄火速度當脈衝施加在發射點,所以這個物件在物理上
# 代表的是**熄火之後**的那一段。先前它的可用區間由 t = 0 起算,
# 於是在 3D 視窗裡它與上升段**同時**飛 —— 看起來像兩架載具。
#
# `Trajectory.EphemerisInterval` 是可設的(實測 SetExplicitInterval
# 把它挪到 448 → 854,飛行時間由傳播器自己算)。
#
# **時間窗用解析弧時長,不要用傳播器自己的飛行時間。**
# 傳播器的 406 s 裡有 159 s 是「從地面爬到熄火高度」的贗品
# (它一定要從地面發射),而且它不帶動力上升段的能量,
# 所以它的弧段部分只有 247 s。解析解是 346 s —— 星曆是由解析解
# 生出來的,時間軸就要用解析解。見 `A.arc_duration_s()`。
arc_dt = A.arc_duration_s(ho["v_bo"],
arc_end_alt_m=ho["eph_start_alt_m"])
if t0_s > 0.0:
with scoped_unit(root, "DateFormat", "EpSec"):
try:
tr.EphemerisInterval.SetExplicitInterval(
t0_s, t0_s + arc_dt)
except Exception as exc: # noqa: BLE001
print(f" [WARN] 真空弧的起始時刻設不了:{str(exc)[:70]}")
tr.Propagate()
info = {"impact_alt_set": alt_set, "apogee_set": apo_set,
"arc_lat": arc_lat, "arc_lon": arc_lon, "t0_s": t0_s}
with scoped_unit(root, "DateFormat", "EpSec"):
try:
info["t0_s"] = float(m.Vgt.Events.Item(
"AvailabilityStartTime").FindOccurrence().Epoch)
info["t1_s"] = float(m.Vgt.Events.Item(
"AvailabilityStopTime").FindOccurrence().Epoch)
except Exception: # noqa: BLE001
info["t1_s"] = t0_s
info["arc_duration_analytic_s"] = arc_dt
# 下一段要接在**解析**弧終點,不是傳播器的可用區間終點
if t0_s > 0.0:
info["t1_s"] = t0_s + arc_dt
try:
dp = m.DataProviders.Item("LLA State").Group.Item("Fixed")
r = dp.ExecElements(sc.StartTime, sc.StopTime, 30.0,
["Time", "Lat", "Lon", "Alt"])
alts = [v for v in r.DataSets.GetDataSetByName("Alt").GetValues()
if isinstance(v, (int, float))]
if alts:
info["apogee_m"] = max(alts)
info["final_alt_m"] = alts[-1]
info["n_points"] = len(alts)
except Exception as e:
info["err"] = str(e)[:60]
if verbose:
print(f" {MISSILE_NAME:<18} 壓低彈道(遠地點釘在 "
f"{ho['apogee_m']/1000:,.0f} km){' ✔' if apo_set else ' ✘ 未生效'}")
print(f" {'':<18} 真空弧 {ho['arc_m']/1000:,.0f} km -> "
f"({arc_lat:.3f}, {arc_lon:.3f}) @ "
f"{ho['arc_end_alt_m']/1000:.1f} km")
if not alt_set:
print(f" {'':<18} 落點高度設不進去 —— 彈道會一路到地面")
ap = info.get("apogee_m")
if ap:
print(f" {'':<18} 實際遠地點 {ap/1000:,.0f} km"
f" ({info.get('n_points')} 筆星曆)")
elif "err" in info:
print(f" {'':<18} [WARN] 取不到軌跡:{info['err']}")
return m, info
def build_spaceplane(root, sc, ho, rows, verbose=True, t0_s=0.0):
"""滑翔段 + 降落:交接點 -> KEFD RWY 17R。
**關鍵:不要呼叫 create_perf_models()。** 一旦生成 AdvFixedWing 模型,
高度會被鎖在 13,106 m —— 而 Propagate() 仍回報成功、IsValid 仍是 True。
見 spaceport_vehicle.py 檔尾的量測記錄。
"""
from stk_binding import (AgESTKObjectType, AgEVePropagatorType,
AgEAvtrSiteType, AgEAvtrProcedureType, AgEAvtrAltitudeRef,
AgEAvtrAirspeedType, AgEAvtrAGLMSL, AgEAvtrCruiseSpeed,
AgEAvtrApproachMode, AgEAvtrRunwayHighLowEnd,
AgEAvtrLandingApproachFixRangeMode)
for ch in list(sc.Children):
if ch.InstanceName == SPACEPLANE_NAME:
ch.Unload()
ac = sc.Children.New(AgESTKObjectType.eAircraft, SPACEPLANE_NAME)
ac.SetRouteType(AgEVePropagatorType.ePropagatorAviator)
avtr = ac.Route.AvtrPropagator
mis = avtr.AvtrMission
# **每架飛機要有自己的機型副本。** 共用一個名稱時,第二架會踩到
# 「Item is currently in use.」而退回 Duplicate() 的預設名
# (Basic Fighter Copy),那會在使用者的機型目錄留下污染。
veh_name = f"{SPACEPLANE_NAME}_Veh"
veh, nm = V.make_private_vehicle_from_prop(root, avtr, mis,
name=veh_name,
verbose=verbose)
if veh is None:
print(" [FATAL] 建立私有機型失敗,中止")
return None, {}
ph = mis.Phases.Item(0) if mis.Phases.Count > 0 else mis.Phases.Add()
_w, ok_models = V.configure_builtin_models(ph, verbose=False)
if not ok_models:
print(" [FATAL] Built-In 模型設定失敗 —— 高度會被靜默夾掉")
V.configure_builtin_models(ph, verbose=True)
return None, {}
if verbose:
print(f" Built-In 模型:CeilingAltitude"
f" {V.BUILTIN_CEILING_M/1000:,.0f} km OK")
procs = ph.Procedures
while procs.Count > 0:
procs.RemoveAtIndex(0)
# 起點 = 交接點。這不是一次真的起飛 —— 是接手彈道段的狀態,
# 所以用 LaunchWaypoint 而不是 Takeoff(空中沒有跑道可以起飛)。
s0, lat0, lon0, alt0, mach0, v0, _e0 = rows[0]
lw = procs.Add(AgEAvtrSiteType.eSiteWaypoint,
AgEAvtrProcedureType.eProcLaunchWaypoint)
lw.Site.Latitude, lw.Site.Longitude = lat0, lon0
lw.AltitudeRef = AgEAvtrAltitudeRef.eAltitudeRefMSL
lw.LaunchAltitude = alt0
lw.SetAirspeed(AgEAvtrAirspeedType.eMach, mach0)
# **終端進場要接在星曆終點的時刻**,不是從場景起點開始。
# 第一個程序的 `LaunchTime` 就是 Aviator 任務的起始時刻
# (`AvtrMission` 本身沒有 StartTime/Epoch 屬性 —— 要從這裡設)。
if t0_s > 0.0:
with scoped_unit(root, "DateFormat", "EpSec"):
try:
lw.LaunchTime = float(t0_s)
except Exception as exc: # noqa: BLE001
print(f" [WARN] 終端段的起始時刻設不了:{str(exc)[:60]}")
# **LaunchTrueBearing 預設是 0(正北),一定要設。**
#
# 沒設的後果不是報錯,是**飛機繞圈**:起始航向朝北、第一個航點在
# 方位 64 度,而 Mach 17 的轉彎半徑接近 3,000 km ——
# 載具因此進入永久盤旋,一圈約 22 分鐘,一圈一圈慢慢漂向航點。
#
# 實測的代價:地面軌跡 42,785 km(起迄直線只有 8,016 km,**5.34 倍**),
# 飛行時間 236 分鐘(解析解 65 分鐘)。而且
# **驗證 32 項全過** —— 端點、高度、速度、落點、航向全部正確,
# 只是路徑多繞了五倍。又一個「數值對、形狀錯」的例子。
if len(rows) > 1:
_, la1, lo1 = rows[1][1], rows[1][1], rows[1][2]
course = A.great_circle(lat0, lon0, rows[1][1], rows[1][2])[2]
try:
lw.LaunchTrueBearing = course
got = lw.LaunchTrueBearing
if abs((got - course + 180) % 360 - 180) > 0.5:
print(f" [FATAL] LaunchTrueBearing 寫 {course:.1f} 讀 {got:.1f}"
f" —— 載具會繞圈,不要當成成功")
elif verbose:
print(f" 發射真方位 {course:.1f} deg(到第一個航點的大圓初始方位)")
except Exception as e:
print(f" [FATAL] LaunchTrueBearing 設定失敗:{str(e)[:60]}")
for i, (s, lat, lon, alt, mach, v, _ex) in enumerate(rows[1:], 1):
p = procs.Add(AgEAvtrSiteType.eSiteWaypoint,
AgEAvtrProcedureType.eProcBasicPointToPoint)
# 給每個航點一個名字。全部叫 "Basic Point to Point" 的話,
# GetFirstInvalidProcedure() 回報的名字無法指出是哪一段 ——
# 診斷訊息分不出個體,等於沒有診斷訊息。
try:
p.Name = f"G{i:02d}_{alt/1000:.0f}km_M{mach:.1f}"
except Exception:
pass
p.Site.Latitude, p.Site.Longitude = lat, lon
ao = p.AltitudeOptions
ao.UseDefaultCruiseAltitude = False
ao.AltitudeReference = AgEAvtrAGLMSL.eAltMSL
ao.Altitude = alt
so = p.EnrouteCruiseAirspeedOptions
so.CruiseSpeedType = AgEAvtrCruiseSpeed.eOtherAirspeed
so.SetOtherAirspeed(AgEAvtrAirspeedType.eMach, mach)
# 降落。跑道長度吃 AviatorRunwayLength(千呎),進場距離吃
# AviatorDistanceUnit(海里)—— 同一個物件上的兩個不同單位維度。
rw = A.KEFD_RUNWAY
ok_land = False
with scoped_unit(root, "AviatorDistanceUnit", "m") as u_d, \
scoped_unit(root, "AviatorRunwayLength", "m") as u_r:
if u_d not in ("m", "meter", "meters"):
print(f" [WARN] AviatorDistanceUnit 是「{u_d}」,不是公尺")
if u_r not in ("m", "meter", "meters"):
print(f" [WARN] AviatorRunwayLength 是「{u_r}」,不是公尺")
try:
land = procs.Add(AgEAvtrSiteType.eSiteRunway,
AgEAvtrProcedureType.eProcLanding)
site = land.Site
site.IsMagnetic = False # 要先設,否則已寫入的方位會被換算掉
site.Latitude = rw["lat"]
site.Longitude = rw["lon"]
site.Altitude = rw["alt_m"]
site.Length = rw["length_m"]
site.LowEndHeading = rw["low_end_hdg"]
site.HighEndHeading = rw["high_end_hdg"]
try:
site.Name = str(rw["name"])
except Exception:
pass
land.RunwayHeadingOptions.RunwayMode = AgEAvtrRunwayHighLowEnd.eLowEnd
land.ApproachMode = AgEAvtrApproachMode.eInterceptGlideslope
gs = land.ModeAsInterceptGlideslope
gs.Glideslope = GLIDESLOPE_DEG
gs.ApproachFixRangeMode = \
AgEAvtrLandingApproachFixRangeMode.eRelToRunwayEnd
gs.ApproachFixRange = APPROACH_FIX_M
gs.TouchAndGo = False
ok_land = True
except Exception as e:
print(f" [WARN] 降落程序設定失敗:{str(e)[:70]}")
try:
avtr.Propagate()
except Exception as e:
print(f" [WARN] Propagate 例外:{str(e)[:70]}")
info = {"procedures": procs.Count, "landing": ok_land, "vehicle": nm}
try:
info["valid"] = bool(mis.IsValid)
except Exception:
info["valid"] = None
if not info["valid"]:
try:
b = mis.GetFirstInvalidProcedure()
info["first_invalid"] = b.Name if b else "?"
except Exception:
pass
if verbose:
extra = (" 第一個無效:" + str(info.get("first_invalid"))
if not info["valid"] else "")
print(f" {SPACEPLANE_NAME:<18} {procs.Count} 個程序"
f"(滑翔 {len(rows)} 點 + 降落 {'OK' if ok_land else 'FAIL'})"
f" IsValid={info['valid']}{extra}")
return ac, info
# **不要硬寫版本號。** 各版的 Aviator 機型目錄名稱不一致:
# STK 12 -> %USERPROFILE%\Documents\STK 12\Config\Catalog\Aviator\Aircraft
# STK 13 -> %USERPROFILE%\Documents\STK_ODTK 13\... (不是 "STK 13")
#
# 實測踩過:路徑寫死成 STK 12,在 STK 13 底下跑時去改了 STK 12 的舊檔,
# 讀回來發現「已經是我們的模型」於是**回報成功** ——
# 而 STK 13 的那份自始至終指著 Air\fighter_f16.glb。
# 典型的靜默失敗:每一步都沒報錯,只是動到了錯的檔案。
#
# 所以改成「找出實際含有該機型的目錄」,讓這件事自我驗證。
def vehicle_catalog_dir(vehicle_name=None):
"""回傳目前這個 STK 版本實際使用的 Aviator 機型目錄。
傳入 vehicle_name 時,只接受**確實含有該機型 XML** 的目錄 ——
光是目錄存在不算數,那正是先前出錯的地方。找不到回傳 None。
"""
import glob
import stk_binding as SB
docs = os.path.expandvars(r"%USERPROFILE%\Documents")
prefer = {"12": ["STK 12"], "13": ["STK_ODTK 13", "STK 13"]}
pats = prefer.get(SB.STK_VERSION, []) + ["STK*"]
seen = []
for pat in pats:
for d in sorted(glob.glob(os.path.join(docs, pat))):
p = os.path.join(d, "Config", "Catalog", "Aviator", "Aircraft")
if not os.path.isdir(p) or p in seen:
continue
seen.append(p)
if vehicle_name is None:
return p
if os.path.isfile(os.path.join(p, vehicle_name + ".xml")):
return p
return None
# 向後相容:沒指定機型時的預設目錄(可能為 None)
VEHICLE_CATALOG_DIR = vehicle_catalog_dir()
def set_vehicle_model3d(vehicle_name, glb_path, verbose=True):
r"""把私有機型 XML 裡的 Model3D 改成我們的 .glb。
**為什麼需要這一步。** Aviator 的機型自帶 3D 模型
(複製 Basic Fighter 得到的是 Air\fighter_f16.glb),而
**每次 avtr.Propagate() 都會用機型的模型覆蓋 ac.VO.Model**。
實測:
設定前 STKData\VO\Models\Air\fighter_f16.glb
設定後 F:\...\model\twsp2_spaceplane.glb <- 寫入成功
重算後 STKData\VO\Models\Air\fighter_f16.glb <- 被還原
而機型物件在物件模型上**沒有** 3D 模型屬性(列舉過全部成員:
Acceleration / AdvFixedWingTool / Climb / Cruise / ... / Save,
沒有任何 Model3D 之類的東西),AvtrCatalog 也沒有重載方法。
所以唯一能讓它持久的地方就是機型自己的 XML。
**限制要講清楚:改 XML 不會立刻生效。** 本專案已確認 STK 把機型
快取在記憶體裡,改磁碟上的 XML 要等下次 STK 啟動才會讀到。
所以這裡兩條路都走:VO 讓當次看得到,XML 讓重啟後永久正確。
"""
cat = vehicle_catalog_dir(vehicle_name)
if cat is None:
if verbose:
print(f" [FATAL] 找不到含「{vehicle_name}」的機型目錄 —— "
f"3D 模型無法持久。**不要當成成功。**")
return False
xml = os.path.join(cat, vehicle_name + ".xml")
try:
raw = io.open(xml, encoding="utf-8-sig").read()
except Exception as e:
if verbose:
print(f" [WARN] 讀不到機型 XML:{str(e)[:50]}")
return False
pat = re.compile(r'(<Model3D Value = ")([^"]*)(")')
mt = pat.search(raw)
if mt is None:
if verbose:
print(f" [WARN] 機型 XML 裡沒有 Model3D 欄位")
return False
if os.path.normcase(mt.group(2)) == os.path.normcase(glb_path):
if verbose:
print(f" 機型 Model3D 已經是我們的模型")
return True
out = pat.sub(lambda m: m.group(1) + glb_path.replace("&", "&")
+ m.group(3), raw, count=1)
try:
io.open(xml, "w", encoding="utf-8").write(out)
except Exception as e:
if verbose:
print(f" [WARN] 寫入機型 XML 失敗:{str(e)[:50]}")
return False
# 回讀確認真的寫進去了 —— 本專案有過「修補沒套用卻讀了舊輸出」的前例
back = pat.search(io.open(xml, encoding="utf-8-sig").read())
ok = back is not None and os.path.normcase(back.group(2)) == \
os.path.normcase(glb_path)
if verbose:
print(f" 機型 Model3D -> {os.path.basename(glb_path)}"
f" {'OK(下次啟動 STK 後永久生效)' if ok else 'FAIL'}")
return ok
def attach_3d_model(ac, path=MODEL_GLB, vehicle_name=None, verbose=True):
"""掛上太空飛機的 3D 模型,兩條路都走。
回傳 (當次有效, 永久有效)。找不到檔案就明說,不靜默跳過。
"""
full = os.path.join(os.path.dirname(os.path.abspath(__file__)), path)
full = os.path.normpath(full)
if not os.path.isfile(full):
if verbose:
print(f" [WARN] 找不到 3D 模型 {path},沿用 STK 預設外觀")
return False, False
now = False
try:
ac.VO.Model.ModelData.Filename = full
got = ac.VO.Model.ModelData.Filename
now = os.path.normcase(got) == os.path.normcase(full)
if verbose:
mark = "OK" if now else ("回讀不符:" + str(got))
print(f" 3D 模型(本次){os.path.basename(full)} {mark}")
if now:
print(f" {'':<16} 注意:在 GUI 裡重算航路會把它還原成機型的"
f"模型,直到 STK 重啟後才會讀到下面改好的 XML")
except Exception as e:
if verbose:
print(f" [WARN] 3D 模型掛載失敗:{str(e)[:60]}")
perm = False
if vehicle_name:
perm = set_vehicle_model3d(vehicle_name, full, verbose=verbose)
return now, perm
# ══════════════════════════════════════════════════════════════════════════════
# 方案(乙):高超音速滑翔段用外部星曆
#
# **為什麼不讓 Aviator 飛這一段 —— 這是實測逼出來的。**
#
# 控制實驗(每項都只改一個變因):
# Mach 0.8–17、固定高度、1,000 km 直線 -> 軌跡/直線 = 1.00
# 航向變化 0–13 度 -> 1.00–1.01
# 航向變化 15 度以上 -> 3.87–3.97 繞圈
# 速度變化 Mach −1 / −3 / −5 -> 1.01,無影響
# **高度變化(連 200 m)** -> 6.68 繞圈
# AltitudeRate 100 -> 200,000 -> 全部 6.70,無影響
#
# 任何高度變化都會讓 Aviator 多飛**一整圈**,圈的周長等於該速度的最小
# 轉彎圓周。要避開,段長必須 ≳ 0.6 × 2πR(R ≈ v²/2.4g);
# 但航向變化又要求段長 ≤ 約 900 km(這條大圓航線轉得快)。
# 兩個約束在 v ≈ 2,300 m/s 以上互相矛盾,而滑翔段起點是 5,400 m/s。
#
# 代價要講清楚:**這一段的軌跡不是 STK 算的。**
# 外部星曆解決的是「軌跡正確」,不是「用 STK 模擬了那一段」。
#
# ── 為什麼 Aviator 段只能這麼短 ──────────────────────────────────
# 低速時 Eggers 射程 R ≈ (L/D)·R_E·v²/(2v_c²),而高度變化所需段長
# L_req ≈ 0.6·2π·v²/(n·g) —— **兩者都正比於 v²**,比值恆為
# R / L_req = (L/D)·R_E·n·g / (2·v_c²·0.6·2π) ≈ 1.27
# 與交接點放在哪裡無關。所以 Aviator 終端段最多放得下**一個**下降航段。
# 因此星曆一路帶到進場介面,Aviator 只做最後進場與降落。
# ══════════════════════════════════════════════════════════════════════════════
# 單一真相來源在 spaceport_analysis.py —— 這裡只是引用。
# 先前兩邊各寫一次 750.0,值相同但是兩份真相,改一邊就會失聯。
# `A.TAEM_V_MS`(750 m/s)是**模型的有效性下限** ——
# 定值 C_L 的平衡滑翔在更低的速度會失效。它不是交棒速度。
# 真正的交棒速度由 `taem_handover_v()` 解出來,見那支函式。
TAEM_V_FLOOR_MS = A.TAEM_V_MS # 平衡滑翔模型的有效性下限(約 Mach 2.5)
TAEM_GLIDESLOPE_DEG = 20.0 # 太空梭外段下滑道約 20 度
APPROACH_IF_ALT_M = 3_000.0 # 交給 Aviator 的進場介面高度
# 留給 Aviator 的進場走廊長度。從 3,000 m 用 8 度下滑道要 21 km,
# 再加上對齊跑道的餘裕 —— 取 30 km。
# 這個值不能讓它「剩多少算多少」,否則設計點一改就繞圈。
APPROACH_CORRIDOR_M = 30_000.0
APPROACH_IF_V_MS = 130.0 # 該點的速度
EPH_STEP_M = 20_000.0
GLIDE_OBJ_NAME = "TWSP_Glide"
EPH_FILENAME = "twsp_glide.e"
WGS_A = 6378137.0
WGS_F = 1.0 / 298.257223563
WGS_E2 = 2.0 * WGS_F - WGS_F * WGS_F
def lla_to_ecef(lat_deg, lon_deg, alt_m):
"""大地座標轉地固直角座標(WGS84)。"""
p = math.radians(lat_deg)
l = math.radians(lon_deg)
n = WGS_A / math.sqrt(1.0 - WGS_E2 * math.sin(p) ** 2)
return ((n + alt_m) * math.cos(p) * math.cos(l),
(n + alt_m) * math.cos(p) * math.sin(l),
(n * (1.0 - WGS_E2) + alt_m) * math.sin(p))
def taem_handover_v(ld, wing_loading=None, target_slope_deg=None):
r"""交棒給 TAEM 的速度。**由設計點解出來,不是常數。**
## 為什麼不能是常數(這是同一個錯誤的第三次)
交棒時的平衡滑翔高度要在剩下的射程裡降完。兩個量:
平衡滑翔高度 由**翼負荷與 C_L**決定(經 equilibrium_glide_alt)
Eggers 剩餘射程 由**升阻比**決定
所以「用設計下滑道降得完」的那個速度是 **L/D 的函數**:
L/D 1.5 -> 1,316 m/s L/D 3.2 -> 848
L/D 2.0 -> 1,115 L/D 4.0 -> 743
L/D 2.2 -> 1,055 L/D 5.62 -> 607
**L/D 4.0 那一列是 743 —— 而常數寫的是 750。**
也就是 `TAEM_V_MS = 750` 是在舊設計點 4.0 下「剛好對」的,
設計點一路降到 2.2 之後它就不再對,下滑道因此變成 42.3°。
這和**進場走廊**(從下滑道幾何裡扣 30 km,只在 L/D 3.2 附近對)、
**摩擦的參考速度**(寫死 6,338,那是 L/D 3.2 的弧終點)
是同一個錯誤的第三次:**把某個設計點下算出來的值寫成常數。**
## 定義
交棒速度 = max(模型有效性下限, 幾何交會速度)
取 max 是因為兩個約束方向相反:低於 `TAEM_V_FLOOR_MS` 平衡滑翔模型
本身失效(150 m/s 時給出 γ = −53°),而低於幾何交會速度則是
載具太高、剩下的射程降不完。升阻比高的時候幾何交會速度會掉到
下限以下(L/D 8 時是 491),那時用下限,下滑道自然比設計值淺 —— 那沒問題。
## 這不是把問題搬走
改交棒速度**不是**讓數字好看:它是一個真的軌跡決定 ——
**更早開始終端下降**,而不是繼續沿平衡滑翔保持高度。
物理上的陳述是:*平衡滑翔解只在約 1,055 m/s 以上與「直線進場 20°
降到跑道」相容;低於此載具比直線進場能吸收的還高。*
真實載具還有第三條路:**用 S 彎把多餘的能量耗掉**(太空梭就是這樣),
也就是在同樣的地面射程裡飛更長的路徑。本模型**沒有側向運動**,
所以走不了那條路 —— 這是模型的邊界,要講明。
"""
wl = A.WING_LOADING_KGM2 if wing_loading is None else wing_loading
tgt = TAEM_GLIDESLOPE_DEG if target_slope_deg is None else target_slope_deg
def slope_at(v):
alt = A.equilibrium_glide_alt(v, wing_loading=wl)
rtg = A.eggers_glide_range(v, ld)
run = max(1_000.0, rtg - APPROACH_CORRIDOR_M)
return math.degrees(math.atan(
(alt - APPROACH_IF_ALT_M) / run))
lo, hi = 300.0, 4_000.0
for _ in range(60):
mid = 0.5 * (lo + hi)
if slope_at(mid) > tgt:
lo = mid
else:
hi = mid
return max(TAEM_V_FLOOR_MS, 0.5 * (lo + hi))
def taem_feasible_region(lds=None, wls=None, vs=None):
"""下滑道角度的二維掃描,給 11.8 用。回傳 dict。
`wls` × `vs` 的角度表(固定升阻比),以及 `lds` 對交棒速度的影響。
做成函式而不是一次性腳本,是因為設計點還會動。
"""
lds = lds or (1.5, 2.0, 2.2, 2.6, 3.2, 4.0, 5.6235, 8.0)
wls = wls or (150, 200, 250, 300, 400)
vs = vs or (750, 850, 950, 1050, 1100, 1200, 1400)
ld0 = A.LD_DESIGN
grid = []
for wl in wls:
row = []
for v in vs:
alt = A.equilibrium_glide_alt(v, wing_loading=wl)
rtg = A.eggers_glide_range(v, ld0)
run = max(1_000.0, rtg - APPROACH_CORRIDOR_M)
row.append(math.degrees(math.atan(
(alt - APPROACH_IF_ALT_M) / run)))
grid.append((wl, row))
return {"lds": [(ld, taem_handover_v(ld)) for ld in lds],
"wls": wls, "vs": vs, "grid": grid,
"wl_at_design": [(wl, taem_handover_v(ld0, wing_loading=wl))
for wl in wls]}
def taem_geometry(ld):
"""TAEM 段的幾何。回傳 dict。**下滑道是結果,不是輸入。**
兩個互相獨立的量在這裡相遇:
* `rtg_m` —— 750 m/s 時 Eggers 說還剩多少射程。由**升阻比**決定。
* `geom_m` —— 從平衡滑翔高度用 `TAEM_GLIDESLOPE_DEG` 降到進場介面
需要多少水平距離。由**翼負荷與 CL**決定(經平衡滑翔高度),
與升阻比無關。
模型不保證這兩個量相容。留下走廊之後,實際能用的水平距離是
`rtg_m - APPROACH_CORRIDOR_M`;若它小於 `geom_m`,載具在交棒點
**太高**,只能用比設計值更陡的角度下來。
L/D 4.0 剩 115.3 km 扣走廊 85.3 km -> 19.6° ≈ 設計值
L/D 3.2 剩 92.3 km 扣走廊 62.3 km -> 26.0°
L/D 2.3 剩 66.3 km 扣走廊 36.3 km -> **40.0°**
40° 不是進場,是俯衝。所以這是設計點 2.3 帶出來的**真實發現**,
要往下推的是翼負荷或更早進入 TAEM,不是把角度吸收掉。
"""
v_ho = taem_handover_v(ld)
rtg = A.eggers_glide_range(v_ho, ld)
alt = A.equilibrium_glide_alt(v_ho)
drop = alt - APPROACH_IF_ALT_M
geom = drop / math.tan(math.radians(TAEM_GLIDESLOPE_DEG))
run = max(1_000.0, rtg - APPROACH_CORRIDOR_M)
slope = math.degrees(math.atan(drop / run))
return {"rtg_m": rtg, "alt_m": alt, "geom_m": geom, "run_m": run,
"slope_deg": slope, "design_slope_deg": TAEM_GLIDESLOPE_DEG,
"v_handover_ms": v_ho, "v_floor_ms": TAEM_V_FLOOR_MS,
"floor_binding": v_ho <= TAEM_V_FLOOR_MS + 1e-6,
"too_high_m": max(0.0, geom - run),
"ok": slope <= TAEM_GLIDESLOPE_DEG * 1.10}
def glide_track(ho, step_m=EPH_STEP_M):
"""算出高超音速滑翔段的軌跡點 [(t, lat, lon, alt, v, phase)]。
兩段接起來:
1. **平衡滑翔** 交接點 -> TAEM 介面(750 m/s、33 km)。
高度由「升力 = 離心解除後的有效重量」解出,
時間由 dt = ds/v 逐段積分(速度沿途衰減,不能用平均速度)。
2. **TAEM** 20 度外段下滑道降到進場介面(3 km)。
平衡滑翔在 v << v_c 時失效(本模型 150 m/s 給出 γ = −53 度,
那是模型失效不是軌跡),所以低速段改用太空梭的實際做法。
"""
az = ho["azimuth_deg"]
ld = ho["ld"]
s0 = ho["eph_start_s_m"]
total = ho["eph_m"]
tg = taem_geometry(ld)
taem_rtg = tg["rtg_m"]
taem_alt = tg["alt_m"]
v_ho = tg["v_handover_ms"] # 交棒速度:解出來的,不是常數
pts = []
t = 0.0
s = 0.0
eq_len = max(0.0, total - taem_rtg) # 平衡滑翔段長度
while s < eq_len - 1.0:
rem = total - s
v = A.glide_v_for_range(rem, ld)
alt = A.equilibrium_glide_alt(v)
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, s0 + s)
pts.append((t, lat, wrap_lon(lon), alt, v, "equilibrium"))
step = min(step_m, eq_len - s)
v_mid = A.glide_v_for_range(max(1.0, rem - step / 2.0), ld)
t += step / max(120.0, v_mid)
s += step
# TAEM:下降到進場介面,速度線性收斂。
#
# **走廊是「留給跑道的距離」,不是「從下滑道長度扣掉的量」。**
# 這一行先前寫成 `taem_run = taem_run - APPROACH_CORRIDOR_M`,
# 也就是從**下滑道幾何需要的水平距離**裡扣。那有兩個錯:
#
# 1. 真正剩給跑道的是 `taem_rtg - taem_run`,而 `taem_rtg`
# 隨升阻比縮小、`taem_run` 不隨。所以那 30 km 只在 L/D 3.2
# 附近剛好對:4.0 時剩 61.7 km、3.2 時 38.6 km、
# 2.3 時只剩 **12.7 km**,終端段又開始繞圈(1.62 倍)。
# 2. 從下滑道長度裡扣 30 km,等於把下滑道從設計的 20° **偷偷改陡**
# (3.2 時變成 29.6°)。舊註解還寫著「8 度」,連參考值都是錯的
# —— `TAEM_GLIDESLOPE_DEG` 一直是 20。
#
# 改成直接由剩餘射程扣:這樣任何升阻比下留給跑道的都恰好是走廊長度。
# 下滑道因此變成**結果**,不是輸入 —— 由 taem_geometry() 算出並回報。
s_taem0 = eq_len
taem_run = tg["run_m"]
n_t = max(20, int(taem_run / 4_000.0)) # TAEM 段用 4 km 取樣
for k in range(n_t + 1):
f = k / n_t
s_k = s_taem0 + taem_run * f
alt = taem_alt + (APPROACH_IF_ALT_M - taem_alt) * f
v = v_ho + (APPROACH_IF_V_MS - v_ho) * f
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, s0 + s_k)
if k > 0:
ds = taem_run / n_t
v_prev = pts[-1][4]
t += ds / max(100.0, 0.5 * (v + v_prev))
pts.append((t, lat, wrap_lon(lon), alt, v, "taem"))
return pts
def write_ephemeris(path, pts, epoch, t_offset_s=0.0):
r"""寫 STK .e 星曆檔(Fixed 座標,位置 + 速度)。
**速度不要省略。** 只給位置時 STK 自行內插,高超音速下那個誤差不小。
端點用單側差分,其餘用中央差分。
## `t_offset_s`:讓各段在時間軸上接續
先前每一段都從場景起點算起,所以**七個物件全部從 t = 0 開始** ——
3D 視窗裡是四架載具同時從旭海起飛、各飛各的,那不是這個任務,
是四條平行航班。
偏移加在**時間欄**上,`ScenarioEpoch` 不動。這樣所有段共用同一個
時間原點,相鄰段的接縫可以直接相減來檢查。
"""
ec = [lla_to_ecef(la, lo, al) for _, la, lo, al, _, _ in pts]
n = len(pts)
vel = []
for i in range(n):
if i == 0:
dt = max(1e-6, pts[1][0] - pts[0][0])
v = tuple((ec[1][k] - ec[0][k]) / dt for k in range(3))
elif i == n - 1:
dt = max(1e-6, pts[-1][0] - pts[-2][0])
v = tuple((ec[-1][k] - ec[-2][k]) / dt for k in range(3))
else:
dt = max(1e-6, pts[i+1][0] - pts[i-1][0])
v = tuple((ec[i+1][k] - ec[i-1][k]) / dt for k in range(3))
vel.append(v)
with open(path, "w", encoding="ascii", newline="\n") as f:
f.write("stk.v.11.0\n\nBEGIN Ephemeris\n\n")
f.write(f"NumberOfEphemerisPoints {n}\n")
f.write(f"ScenarioEpoch {epoch}\n")
f.write("InterpolationMethod Lagrange\n")
f.write("InterpolationSamplesM1 5\n")
f.write("CentralBody Earth\n")
f.write("CoordinateSystem Fixed\n\n")
f.write("EphemerisTimePosVel\n\n")
for (t, _, _, _, _, _), p, v in zip(pts, ec, vel):
t = t + t_offset_s
f.write(f"{t:.6f} {p[0]:.3f} {p[1]:.3f} {p[2]:.3f} "
f"{v[0]:.4f} {v[1]:.4f} {v[2]:.4f}\n")
f.write("\nEND Ephemeris\n")
return path
def build_glide_ephemeris(root, sc, ho, verbose=True, t0_s=0.0):
"""建立高超音速滑翔段的外部星曆物件。回傳 (物件, 資訊)。
`t0_s` 是這一段在場景時間軸上的起點 —— 滑翔接在真空弧之後,
不是跟上升段一起從 0 開始。見 `write_ephemeris` 的說明。
"""
from stk_binding import AgESTKObjectType, AgEVePropagatorType
pts = glide_track(ho)
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
EPH_FILENAME)
with scoped_unit(root, "DateFormat", "UTCG"):
epoch = str(sc.StartTime)
write_ephemeris(path, pts, epoch, t_offset_s=t0_s)
for ch in list(sc.Children):
if ch.InstanceName == GLIDE_OBJ_NAME:
ch.Unload()
ac = sc.Children.New(AgESTKObjectType.eAircraft, GLIDE_OBJ_NAME)
ok = False
try:
ac.SetRouteType(AgEVePropagatorType.ePropagatorStkExternal)
ac.Route.Filename = path
ac.Route.Propagate()
ok = True
except Exception as e:
print(f" [FATAL] 星曆載入失敗:{str(e)[:80]}")
eq = [p for p in pts if p[5] == "equilibrium"]
tm = [p for p in pts if p[5] == "taem"]
tg = taem_geometry(ho["ld"])
info = {"points": len(pts), "loaded": ok, "path": path,
"duration_s": pts[-1][0],
"eq_points": len(eq), "taem_points": len(tm),
"end_lat": pts[-1][1], "end_lon": pts[-1][2],
"end_alt_m": pts[-1][3], "end_v_ms": pts[-1][4],
"t0_s": t0_s, "t1_s": t0_s + pts[-1][0],
"taem": tg}
if verbose:
print(f" {GLIDE_OBJ_NAME:<18} 外部星曆 {len(pts)} 點"
f"({len(eq)} 平衡滑翔 + {len(tm)} TAEM)"
f" {'✔' if ok else '✘'}")
print(f" {'':<18} 歷時 {pts[-1][0]/60:.1f} min,"
f"止於 {pts[-1][3]/1000:.1f} km / {pts[-1][4]:,.0f} m/s")
print(f" {'':<18} **這一段由解析解算出,不是 STK 算的**")
print(f" {'':<18} TAEM 下滑道 {tg['slope_deg']:.1f}deg"
f"(設計 {tg['design_slope_deg']:.0f}deg)"
f" {'OK' if tg['ok'] else '[WARN] 太陡'}")
if not tg["ok"]:
print(f" {'':<18} 750 m/s 時只剩 {tg['rtg_m']/1000:.0f} km,"
f"扣 {APPROACH_CORRIDOR_M/1000:.0f} km 走廊後剩"
f" {tg['run_m']/1000:.0f} km,")
print(f" {'':<18} 但從 {tg['alt_m']/1000:.0f} km 用設計下滑道"
f"要 {tg['geom_m']/1000:.0f} km —— **交棒點高了"
f" {tg['too_high_m']/1000:.0f} km 的水平距離**。")
print(f" {'':<18} 這是升阻比降低帶出來的真實限制,"
f"不是取樣問題。往下要動的是翼負荷或 TAEM 進入速度。")
return ac, info
def build(force=False, ld=None, also_seam_free=True):
"""建整個場景。回傳 (成功, 資訊)。
預設同時建兩架太空飛機做對照:
* 設計點 `A.LD_DESIGN` —— 弧終點速度高於 Aviator 的求解上限,
所以高超音速段走外部星曆。
* `SEAM_FREE_LD` —— 弧終點速度恰等於該上限,**Aviator 可獨力飛完**。
兩者的**航程都是閉合的**(接縫已歸零),差別在於能不能用 Aviator 飛 ——
這正是「工具邊界是設計條件」要表達的:同一條任務,升阻比決定你
需要幾個模型才拼得出來。
"""
print("=" * 78)
print("台美太空港次軌道任務 —— 建構 STK 場景(淺角度助推滑翔)")
print("=" * 78)
ho = mission_geometry(ld=ld, verbose=True)
rows = glide_profile_timed(ho)
stk, root = attach()
import stk_binding as SB
units = set_units(root)
print()
print(" " + SB.describe())
print(" 單位:" + " ".join(f"{k}={v}" for k, v in units.items()))
sc, fresh = open_scenario(root, force=force)
if sc is None:
return False, {}
print()
build_sites(root, sc)
print()
# ── 時間軸:各段要接續,不是全部從 t = 0 開始 ────────────────
# 先前七個物件的可用區間都由場景起點算起,所以 3D 視窗裡是
# 四架載具同時從旭海起飛、各飛各的 —— 那不是這個任務。
# 上升段 0 → 熄火,真空弧接在熄火,滑翔接在弧終點。
import spaceport_ascent as _SA0
_asc = _SA0.ascent_downrange(ho["v_bo"], azimuth_deg=ho["azimuth_deg"])
t_burnout_s = float(_asc["t_burnout"])
m, minfo = build_missile(root, sc, ho, t0_s=t_burnout_s)
print()
gl, ginfo = build_glide_ephemeris(
root, sc, ho, t0_s=minfo.get("t1_s", t_burnout_s))
print()
global SPACEPLANE_NAME
base_name = SPACEPLANE_NAME
SPACEPLANE_NAME = f"{base_name}_LD{ho['ld']:g}".replace(".", "p")
# Aviator 只接星曆終點之後那一段(約 32 km、3 km 高、130 m/s)。
# 之所以這麼短,見 build_glide_ephemeris 的說明:剩餘射程與
# 高度變化所需段長都正比於 v²,比值恆為 1.27,放不下第二段。
term = terminal_rows(ho, ginfo)
ac, ainfo = build_spaceplane(root, sc, ho, term,
t0_s=ginfo.get("t1_s", 0.0))
if ac is not None:
m_now, m_perm = attach_3d_model(ac,
vehicle_name=ainfo.get("vehicle"))
ainfo["model_now"] = m_now
ainfo["model_permanent"] = m_perm
ainfo["name"] = SPACEPLANE_NAME
sf_info = None
if also_seam_free:
print()
print(f" ── 對照機:L/D {SEAM_FREE_LD}(接縫消失)──────────────")
ho2 = mission_geometry(ld=SEAM_FREE_LD, verbose=True)
SPACEPLANE_NAME = f"{base_name}_LD{SEAM_FREE_LD:g}".replace(".", "p")
# 對照機同樣用星曆 + 終端,只是它沒有模型接縫
global GLIDE_OBJ_NAME, EPH_FILENAME
_gn, _fn = GLIDE_OBJ_NAME, EPH_FILENAME
GLIDE_OBJ_NAME = f"TWSP_Glide_LD{SEAM_FREE_LD:g}".replace(".", "p")
EPH_FILENAME = "twsp_glide_seamfree.e"
# 對照機也要接在弧終點,不能從 t = 0 開始 —— 不然它會在動畫裡
# 與本設計**同時**從旭海起飛,看起來像兩架載具而不是一個對照。
# 它的上升段與真空弧時間與本設計略有不同,這裡沿用同一個起點:
# 這張對照要看的是**滑翔段的長度差**,不是時程差。
gl2, ginfo2 = build_glide_ephemeris(
root, sc, ho2, t0_s=minfo.get("t1_s", 0.0))
GLIDE_OBJ_NAME, EPH_FILENAME = _gn, _fn
ac2, sf_info = build_spaceplane(root, sc, ho2,
terminal_rows(ho2, ginfo2),
t0_s=ginfo2.get("t1_s", 0.0))
if ac2 is not None:
attach_3d_model(ac2, vehicle_name=sf_info.get("vehicle"),
verbose=False)
sf_info["name"] = SPACEPLANE_NAME
sf_info["handover"] = ho2
SPACEPLANE_NAME = base_name
# ── 動力上升段 ─────────────────────────────────────────────────
# 延遲 import:spaceport_ascent 在模組層級 import 本模組。
try:
import spaceport_ascent as _S
_ac, asc_info = _S.build_ascent(verbose=verbose if False else True)
except Exception as exc:
asc_info = {"error": str(exc)}
print(f" 上升段建構失敗:{exc}")
# ── 分節分離與尾焰 ─────────────────────────────────────────────
# 第一節是獨立物件(TWSP_Stage1),要在上升段之後建 ——
# 它的時序來自同一份上升段解,而太空飛機的尾焰要設在 TWSP_Ascent 上。
# 放在 build() 裡是因為 `--force` 重建會把場景清掉,
# 不接進來的話每次重建都會弄丟分離動畫。
try:
import spaceport_stages as _ST
_ok_st, st_info = _ST.build_stages(verbose=True)
except Exception as exc: # noqa: BLE001
st_info = {"error": str(exc)}
print(f" 分節分離建構失敗:{exc}")
# ── 掃掉本流程自己留下的舊機型 ──────────────────────────────────
# 設計常數改了(例如 SEAM_FREE_LD 由 6.54 改成 6.13),上一次跑
# 留下的 TWSP_Spaceplane_LD6p54_Veh 就變成孤兒。
# **產生污染的程式要自己負責清掉**,而且只清自己命名空間下的東西 ——
# 使用者自己的機型一律不動。
keep = {x for x in (ainfo.get("vehicle"),
(sf_info or {}).get("vehicle")) if x}
sweep_stale_vehicles(root, keep, verbose=True)
ok = (ac is not None and ainfo.get("valid") is True
and (sf_info is None or sf_info.get("valid") is True))
return ok, {"handover": ho, "glide_rows": rows, "stages": st_info,
"missile": minfo, "spaceplane": ainfo,
"ascent": asc_info, "seam_free": sf_info}
def sweep_stale_vehicles(root, keep, verbose=True):
"""移除目錄裡本流程命名空間(TWSP_*)但這次沒用到的機型。
回傳實際移除掉的名稱。移不掉的(仍被某架飛機占用)會一併回報,
不靜默吞掉 —— 驗證段會再抓一次。
"""
from stk_binding import AgESTKObjectType, AgEVePropagatorType
removed, stuck = [], []
failed = None
ac = None
try:
# 機型目錄要透過某一架飛機的 **AvtrPropagator** 才拿得到,
# 所以開一架暫時的飛機當把手,用完立刻卸載。
# 新飛機預設是 GreatArc,不先切成 Aviator 會拿不到 AvtrCatalog。
ac = root.CurrentScenario.Children.New(
AgESTKObjectType.eAircraft, "TWSP_SweepTmp")
ac.SetRouteType(AgEVePropagatorType.ePropagatorAviator)
# 目錄掛在 Route.AvtrPropagator 上,不是 Route 本身。
avtr = ac.Route.AvtrPropagator
names = list(V.catalog_models(avtr).ChildNames or [])
for nm in names:
if not nm.startswith("TWSP_") or nm in keep:
continue
if V.remove_catalog_model(avtr, nm, verbose=False):
removed.append(nm)
else:
stuck.append(nm)
except Exception as exc:
failed = exc
finally:
try:
if ac is not None:
ac.Unload()
except Exception:
pass
if verbose:
if failed is not None:
# **掃不掉不能印成「沒有殘留」。** 那會把一個沒做成的動作
# 說成做完了,而不一致只會在驗證段才浮現。
print(f" 舊機型掃除 **失敗**,目錄未檢查:{failed}")
else:
if removed:
print(f" 掃除舊機型 {removed} ✔")
if stuck:
print(f" 舊機型移不掉(仍被占用){stuck} ✘")
if not removed and not stuck:
print(" 沒有殘留的舊機型")
return removed
def terminal_rows(ho, ginfo):
"""星曆終點之後交給 Aviator 的航點。
只有兩點:星曆終點(進場介面)與跑道。中間不放航點 ——
多放一個就會踩到「高度變化所需段長」的限制而繞圈。
降落程序本身會處理下滑道攔截與跑道對齊。
"""
a_, ex = sound_speed(ginfo["end_alt_m"])
mach = max(APPROACH_MACH, ginfo["end_v_ms"] / a_)
d_rem = A.great_circle(ginfo["end_lat"], ginfo["end_lon"],
A.KEFD_RUNWAY["lat"], A.KEFD_RUNWAY["lon"])[1]
return [(ho["total_m"] - d_rem, ginfo["end_lat"], ginfo["end_lon"],
ginfo["end_alt_m"], mach, ginfo["end_v_ms"], ex)]
if __name__ == "__main__":
force = "--force" in sys.argv
if "--plan" in sys.argv or "--no-stk" in sys.argv:
describe_plan()
else:
describe_plan()
print()
ok, info = build(force=force)
print()
print("BUILD-OK" if ok else "BUILD-INCOMPLETE")
spaceport_suborbital/spaceport_return.py
# -*- coding: utf-8 -*-
"""場景二:繞地球一圈,回到原發射點 —— 向東飛 vs 向西飛。
## 題目
場景一是點對點(旭海 → 休士頓)。場景二問的是:
**如果太空飛機要降落在起飛的那一點呢?** 向東繞一圈,或向西繞一圈。
地球是圓的,所以兩個方向的**地面航程都是一圈**(大圓周長)。
看起來應該對稱。**它不對稱**,而且是兩個方向相反的效應在打架。
## 先搞清楚一件事:繞一圈**回不到原點**
讓落點收斂回旭海是個很自然、但做不到的目標。看幾何就明白:
**正東發射的大圓,旭海正好落在它的最高緯度點。** 所以只要弧長不是
整整一圈,落點的緯度就不對;而整整一圈的時間裡(約 120 分鐘),
地球已經向東轉了 **30.2°**。
載具繞一圈回到的是**同一個慣性點**,但那個點在地面上已經西移了:
落點 = 旭海以西 3,115 km —— **向東飛、向西飛都一樣**
要真的降落在旭海,必須靠**側向機動**把這 3,000 公里補回來。
太空梭(高超音速 L/D 約 1.1)的側向航程約 2,000 km,而本章沒有
側向運動模型,所以這裡只說「需要那個量級」,不給數字。
## 那東西向差在哪裡
差在**發射時的自轉助益**,而且只差在這裡。
旭海在北緯 22.06°,地表向東 465·cos(22.06°) = 431 m/s。
向東發射白拿 431 m/s;向西要先抵銷掉它,等於倒貼 431 m/s。
**東西相差 862 m/s。**
兩個方向的慣性航程、飛行時間、落點偏移**完全相同** ——
唯一的差別是那 862 m/s,以及它在指數上放大成的質量比差距。
不過**地面航跡的長度不一樣**(STK 回讀):
向東 37,021 km 地球跟著你轉,對地距離變短
向西 43,224 km 地球迎面轉來,對地距離變長
差 6,203 km = 2 × 431 m/s × 飛行時間
這是載具真正穿過大氣走的距離。
## 這個場景真正的結論
繞一圈所需的熄火速度是 **7,598 m/s,約為圓軌速度的 96%**。
要滑翔回原點所需的能量,與直接入軌幾乎相同。
到了這裡「次軌道」這個詞已經沒有意義。合理的工程答案不是
「滑翔繞一圈」,而是「入軌,繞幾圈等地球轉到位,再下來」——
後者不必全程在大氣裡付阻力,也不需要 3,000 km 的側向機動。
**所以這個場景的價值是它證明了一個界線在哪裡**,不是提出一個可行的任務。
## 模型與界線
* 地面航程取大圓周長。由旭海正東/正西出發的大圓,傾角等於當地緯度
22.06°,最高緯度就在發射點,兩個方向幾何上完全對稱。
* 自轉的沿程分量沿著航跡是變化的(赤道附近最大)。這裡取**發射場的
值當常數**,是一階近似。
* **Eggers 射程用的是慣性速度,阻力卻取決於對空速度。**
上面那 6,203 km 的地面航跡差,意味著向西飛實際上要穿過更多空氣、
付更多阻力,而本模型沒有把這一項算進去 ——
所以向西的代價**被低估了**,真實差距比 862 m/s 更大。
要修得用會轉的大氣做滑翔積分,不在本章範圍。
* 其餘(淺角度助推、平衡滑翔、Eggers 射程)與場景一同一套式子。
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import spaceport_analysis as A
# 地球自轉在赤道的地表速度。乘 cos(緯度) 得到當地值。
V_EQ_MS = 465.1
CIRCUM_M = 2.0 * math.pi * A.RE # 大圓周長 = 慣性系要飛的一圈
OMEGA_E = 7.2921159e-5 # rad/s,地球自轉角速度
def glide_time(total_glide_m, ld=None, step=20_000.0):
"""滑翔完 total_glide_m 所需的時間(秒)。
**演算法刻意與 spaceport_build.glide_track() 相同**(沿距離步進,
速度由剩餘射程反解),否則兩處會給出不同的飛行時間。
用「進入速度與終端速度的幾何平均」去估的話,場景一的 44 分鐘
會算成 338 分鐘 —— Eggers 滑翔的大部分路程是在高速段走完的。
校準:場景一的 11,946 km 這裡給 55.4 分鐘,`glide_track()` 給 55.0 分鐘
(差 0.7%)。注意不要拿舊架構的 43.7 分鐘來對 —— 那是閉合修正前
7,985 km 那一段的值,航段長度已經不一樣了。
"""
ld = A.LD_DESIGN if ld is None else ld
# 交棒速度由設計點解出來,不是常數 —— 見 build.taem_handover_v()。
# 延遲 import:本模組在模組層級被 build 之外的東西用,
# 與檔案其他地方的做法一致。
import spaceport_build as _B
v_ho = _B.taem_handover_v(ld)
taem_rtg = A.eggers_glide_range(v_ho, ld)
eq_len = max(0.0, total_glide_m - taem_rtg)
t = 0.0
s_cur = 0.0
while s_cur < eq_len - 1.0:
rem = total_glide_m - s_cur
stp = min(step, eq_len - s_cur)
v_mid = A.glide_v_for_range(max(1.0, rem - stp / 2.0), ld)
t += stp / max(120.0, v_mid)
s_cur += stp
# TAEM 段:速度由 TAEM 介面線性收到進場速度
t += taem_rtg / max(100.0, 0.5 * (v_ho + 130.0))
return t
def site_rotation_speed(lat_deg=None):
lat = A.LAUNCH_LAT if lat_deg is None else lat_deg
return V_EQ_MS * math.cos(math.radians(lat))
def solve_direction(eastward, ld=None, loss_ms=None, verbose=False):
"""解一個方向。回傳 dict。
**兩個方向的軌跡是一樣的**(同樣的慣性航程、同樣的飛行時間、
同樣的落點偏移),差別只在發射時的自轉助益。
先前的版本試圖用「調整慣性航程」讓落點收斂回旭海,那在幾何上
做不到 —— 見檔頭說明。
"""
ld = A.LD_DESIGN if ld is None else ld
loss = A.LOSS_DESIGN_MS if loss_ms is None else loss_ms
v_rot = site_rotation_speed()
sign = 1.0 if eastward else -1.0
# **上升段的沿程也要計入**(場景一 2026-09-25 的第三次架構更正)。
#
# 注意:算出來兩個方向的上升段沿程**完全相同**(1,737 km)。
# 那不是物理結果,是**模型的界線** —— 上升段的積分不含地球自轉,
# 它只被要求打中 (100 km, v_bo, γ=2°),而 v_bo 由射程閉合決定、
# 與方向無關。自轉助益只改變「要燒多少推進劑」,不改變這條軌跡。
#
# 真實情況:向東發射起步就帶著 431 m/s 的地速,對空的速度剖面
# 與向西不同,阻力與沿程都會有差。要算那個得做會轉的地球上的
# 三維上升段積分,不在本章範圍。
import spaceport_build as _B
bg = A.boost_glide(CIRCUM_M, ld,
ascent_fn=_B._ascent_downrange_fn(90.0 if eastward
else 270.0))
# **從起飛算起**,不是從進入點。地球在上升段那 400 秒也一直在轉,
# 漏掉它會讓落點偏移少算 200 公里以上。
import spaceport_ascent as _S
_asc = _S.ascent_downrange(bg["v_bo"], azimuth_deg=90.0 if eastward
else 270.0)
# **真空弧要用解析飛行時間,不是「射程 / 速度」。**
# 粗估在本場景差 113.7 s(2,143 vs 2,257)—— 而 t_flight 直接決定
# 地球轉過多少度,113.7 s 就是 0.475°、約 50 km 的落點偏移。
# 先前 build 與 verify 用的是**同一個**粗估,所以兩邊自洽、
# 驗證抓不到。見 `A.arc_duration_s()`。
t_flight = (_asc["t_burnout"]
+ A.arc_duration_s(bg["v_bo"],
arc_end_alt_m=bg["glide_alt_at_bo_m"])
+ glide_time(bg["glide_m"], ld))
drift_deg = math.degrees(OMEGA_E * t_flight)
drift_m = (A.RE * math.radians(drift_deg)
* math.cos(math.radians(A.LAUNCH_LAT)))
dv_total = bg["v_bo"] + loss - sign * v_rot
mr = A.two_stage_split(dv_total, A.ISP1_S, A.ISP2_S)
return {
"direction": "east" if eastward else "west",
"azimuth_deg": 90.0 if eastward else 270.0,
"ld": ld, "v_rot": v_rot,
"ground_range_m": CIRCUM_M,
"inertial_range_m": CIRCUM_M,
"ascent_m": bg.get("ascent_m", 0.0),
"closure_residual_m": bg["closure_residual_m"],
"t_flight_s": t_flight,
"earth_drift_deg": drift_deg,
"landing_offset_m": drift_m, # 落點在旭海以西
"v_bo": bg["v_bo"], "v_entry": bg["v_entry"],
"apogee_m": bg["apogee_m"], "arc_m": bg["arc_m"],
"glide_m": bg["glide_m"],
"glide_alt_m": bg["glide_alt_at_bo_m"],
"pct_of_circular": bg["pct_of_circular"],
"rot_assist_ms": sign * v_rot,
"loss_ms": loss,
"dv_total_ms": dv_total,
"mass_ratio": mr["mr_total"],
"feasible": bg["feasible"],
"raw_mr": mr,
}
def compare(ld=None, verbose=True):
e = solve_direction(True, ld)
w = solve_direction(False, ld)
v_orb = A.v_circular(200_000.0)
if verbose:
print("=" * 78)
print("場景二:從旭海繞地球一圈 —— 向東 vs 向西")
print("=" * 78)
print(f" 慣性航程(大圓周長) {CIRCUM_M/1000:>10,.0f} km")
print(f" 升阻比 {e['ld']:>10.2f}")
print(f" 熄火速度 {e['v_bo']:>10,.0f} m/s"
f" ({e['pct_of_circular']:.1f}% 的地表圓軌速度)")
print(f" 飛行時間 {e['t_flight_s']/60:>10,.0f} min")
print()
print(" ── 分段(三段閉合)────────────────────────────────────")
print(f" {'':<20}{'向東':>13}{'向西':>13}")
for lab, k in (("動力上升段 km", "ascent_m"),
("助推真空弧 km", "arc_m"),
("外部星曆滑翔 km", "glide_m")):
print(f" {lab:<20}{e[k]/1000:>13,.0f}{w[k]/1000:>13,.0f}")
print(f" {'合計 km':<20}"
f"{(e['ascent_m']+e['arc_m']+e['glide_m'])/1000:>13,.0f}"
f"{(w['ascent_m']+w['arc_m']+w['glide_m'])/1000:>13,.0f}")
print(f" {'閉合殘差 m':<20}{e['closure_residual_m']:>13.3f}"
f"{w['closure_residual_m']:>13.3f}")
print()
print(" ── 兩個方向相同的部分 ────────────────────────────────")
print(f" 期間地球向東轉過 {e['earth_drift_deg']:>10.1f}°")
print(f" **落點在旭海以西** {e['landing_offset_m']/1000:>10,.0f} km"
f" (解析估計)")
print(" 真正的值以 spaceport_return_build.describe() 逐點積分出來的")
print(" 軌跡為準(3,240 km);這裡的差 0.7% 來自真空弧時間的估算方式。")
print(" 繞一圈回到的是同一個**慣性點**,而那個點在地面上已經西移。")
print(" 向東飛、向西飛都一樣 —— 這不是方向的問題,是自轉的問題。")
print()
print(" ── 東西唯一的差別:發射時的自轉助益 ──────────────────")
hdr = f" {'':<20}{'向東':>13}{'向西':>13}{'差':>13}"
print(hdr)
print(" " + "-" * (len(hdr) - 2))
print(f" {'自轉助益 m/s':<20}{e['rot_assist_ms']:>+13,.0f}"
f"{w['rot_assist_ms']:>+13,.0f}"
f"{e['rot_assist_ms']-w['rot_assist_ms']:>+13,.0f}")
print(f" {'總 Δv m/s':<20}{e['dv_total_ms']:>13,.0f}"
f"{w['dv_total_ms']:>13,.0f}"
f"{e['dv_total_ms']-w['dv_total_ms']:>13,.0f}")
print(f" {'質量比':<20}{e['mass_ratio']:>13,.1f}"
f"{w['mass_ratio']:>13,.1f}"
f"{e['mass_ratio']-w['mass_ratio']:>13,.1f}")
print()
print(" ── 結論 ──────────────────────────────────────────────")
print(f" 一、**繞一圈回不到原點**,落在西邊 "
f"{e['landing_offset_m']/1000:,.0f} km。要補回來需要那個量級的")
print(" 側向機動 —— 太空梭(L/D 約 1.1)的側向航程約 2,000 km。")
print(" 本章沒有側向運動模型,所以只說量級,不給數字。")
print(f" 二、向東比向西省 {w['dv_total_ms']-e['dv_total_ms']:,.0f} m/s"
f"(= 2 × {e['v_rot']:,.0f}),")
print(f" 質量比從 {w['mass_ratio']:,.1f} 降到 {e['mass_ratio']:,.1f}。")
print(f" 三、熄火速度是 200 km 圓軌速度({v_orb:,.0f} m/s)的 "
f"{e['v_bo']/v_orb*100:.1f}% ——")
print(" 到這裡「次軌道」不再是有意義的描述。合理的做法是入軌、")
print(" 等地球轉到位再下來,那不必全程付阻力,也不需要側向機動。")
return {"east": e, "west": w, "v_circular_200km": v_orb}
if __name__ == "__main__":
compare()
spaceport_suborbital/spaceport_return_build.py
# -*- coding: utf-8 -*-
"""場景二的 STK 建構:繞一圈回到旭海,向東與向西各一條。
## 為什麼不能直接畫一個大圓
在**地固系**裡畫一個通過旭海的大圓、讓載具沿著它跑一圈,
兩個方向會是同一條線,看不出差別 —— 而且那是錯的。
載具在**慣性系**裡走大圓,地球在底下自轉,所以地面軌跡是會西移的
螺旋(和衛星的 ground track 一樣)。正確做法:
1. 在慣性系沿大圓佈點(方位 90° 向東 / 270° 向西)
2. 每一點的經度減掉 ω_E·t,轉到地固系
3. 落點**不會**回到旭海 —— 一圈的時間裡地球轉了 30 度,
所以兩條軌跡都落在旭海以西約 3,100 km
這樣兩條地面軌跡的形狀就**真的不一樣**,而且看得出那個西移。
## 產出
TWSP_Return 場景
TWSP_Ret_East Aircraft + 外部星曆,向東
TWSP_Ret_West Aircraft + 外部星曆,向西
Xuhai Place(起點也是終點)
"""
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import spaceport_analysis as A
import spaceport_build as B
import spaceport_return as R
SCENARIO = "TWSP_Return"
EPOCH = "1 Oct 2026 02:00:00.000"
STOP = "1 Oct 2026 08:00:00.000"
OMEGA_E = 7.2921159e-5 # rad/s,地球自轉角速度
STEP_M = 40_000.0 # 沿程取樣間隔
def return_track(sol, step_m=STEP_M):
"""產生地固系的軌跡點 [(t, lat, lon, alt, v)]。
結構與場景一相同:**星曆從進入點起算**(上升段 + 真空弧之後),
時間則從**起飛**算起,因為地球在上升段也一直在轉。
先前這裡把整整一圈都當成滑翔,與三段閉合解不一致 ——
落點差了 116 km。
"""
az = sol["azimuth_deg"]
ld = sol["ld"]
s0 = sol["ascent_m"] + sol["arc_m"] # 進入點的沿程距離
total_g = sol["glide_m"] # 滑翔段長度
ld_taem = A.eggers_glide_range(B.taem_handover_v(ld), ld)
eq_len = max(0.0, total_g - ld_taem)
# 起飛到進入點所花的時間:上升段 + 真空弧
import spaceport_ascent as S
asc = S.ascent_downrange(sol["v_bo"], azimuth_deg=az)
# 真空弧用解析飛行時間 —— 粗估差 113.7 s,而 t 直接決定
# 地面軌跡扣多少自轉(0.475°、約 50 km)。見 spaceport_return.py。
t = asc["t_burnout"] + A.arc_duration_s(
sol["v_bo"], arc_end_alt_m=sol["glide_alt_m"])
pts = []
s = 0.0
while s < eq_len - 1.0:
rem = total_g - s
v = A.glide_v_for_range(rem, ld)
alt = A.equilibrium_glide_alt(v)
lat, lon_i = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, s0 + s)
lon = B.wrap_lon(lon_i - math.degrees(OMEGA_E * t))
pts.append((t, lat, lon, alt, v))
stp = min(step_m, eq_len - s)
v_mid = A.glide_v_for_range(max(1.0, rem - stp / 2.0), ld)
t += stp / max(120.0, v_mid)
s += stp
# ── 到 TAEM 介面為止 ───────────────────────────────────────────
#
# **場景二的星曆止於 TAEM 介面(750 m/s、約 33 km),不飛到跑道。**
#
# 理由是模型的界線,不是偷懶:這裡整條軌跡是在**慣性系**用平衡滑翔
# 解出來的,再轉到地固系。當載具的速度掉到與地表自轉(該緯度
# 431 m/s)同一個量級時,那個轉換就失去意義了 ——
#
# 向西飛、對空 130 m/s 的載具,慣性速度是 130 − 431 = **−301 m/s**,
# 也就是它在慣性系裡其實在往東走。
#
# 把 130 m/s 當成慣性速度的話,地面軌跡會在最後 28 km **倒退**
# (驗證段的航向突變檢查擋得下來)。加上自轉分量之後倒退消失,
# 而東西兩條的落點差 166 km —— 那個差完全來自這 92 km 的
# TAEM 段被自轉主導。
#
# 場景二要回答的是「繞一圈能不能回到原點」與「東西差多少 Δv」,
# 兩者都不取決於最後 92 km。所以這裡**停在模型還成立的地方**,
# 並把沒模擬的部分講明,而不是硬湊一個看起來完整的軌跡。
#
# **但要停在正確的地方。** 上面的迴圈條件是 `s < eq_len - 1`,
# 所以最後一個**被記下來的**點在 `eq_len` 前一個步長(40 km),
# 速度與高度都還偏高。設計點還高的時候那個偏差小、驗證的絕對容差
# 蓋得住;降到 L/D 1.74 之後高度變高,同樣的相對誤差就露出來了
# (STK 讀回 41.7 km,解析的 TAEM 介面是 39.8)。
# 所以補上 `s = eq_len` 這個端點。
if pts:
v_end = A.glide_v_for_range(max(1.0, total_g - eq_len), ld)
lat, lon_i = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az,
s0 + eq_len)
pts.append((t, lat, B.wrap_lon(lon_i - math.degrees(OMEGA_E * t)),
A.equilibrium_glide_alt(v_end), v_end))
return pts
def landing_error_km(pts):
"""落點離旭海多遠(km)。
**這不是誤差,是結果。** 繞一圈回到的是同一個慣性點,而地球在
這段時間已經向東轉了 30 度,所以落點必然在旭海以西約 3,100 km。
要降回旭海得靠側向機動,不是把航程調一調就行 —— 詳見
spaceport_return 的檔頭說明。"""
return A.great_circle(pts[-1][1], pts[-1][2],
A.LAUNCH_LAT, A.LAUNCH_LON)[1] / 1000.0
def describe(ld=None, verbose=True):
"""不連 STK 也能跑,先看軌跡合不合理。"""
cmp_ = R.compare(ld=ld, verbose=False)
out = {}
if verbose:
print("=" * 78)
print("場景二軌跡檢查(不需要 STK)")
print("=" * 78)
for key in ("east", "west"):
sol = cmp_[key]
pts = return_track(sol)
err = landing_error_km(pts)
lons = [p[2] for p in pts]
out[key] = {"sol": sol, "pts": pts, "landing_err_km": err}
if verbose:
name = "向東" if key == "east" else "向西"
print(f" {name} {len(pts)} 點,歷時 {pts[-1][0]/60:,.0f} min")
print(f" 起 ({pts[0][1]:.3f}, {pts[0][2]:.3f}) "
f"{pts[0][3]/1000:.1f} km / {pts[0][4]:,.0f} m/s")
print(f" 止 ({pts[-1][1]:.3f}, {pts[-1][2]:.3f}) "
f"{pts[-1][3]/1000:.1f} km / {pts[-1][4]:,.0f} m/s")
print(f" 落點離旭海 {err:,.1f} km")
print(f" 緯度範圍 {min(p[1] for p in pts):.1f}° "
f"to {max(p[1] for p in pts):.1f}°")
print()
return out
def build(force=False, ld=None, verbose=True):
"""建場景二。需要 STK 已啟動。"""
from stk_binding import (AgESTKObjectType, AgEVePropagatorType,
STKDesktop)
import stk_binding as SB
data = describe(ld=ld, verbose=verbose)
stk = STKDesktop.AttachToApplication()
root = stk.Root
if verbose:
print(f" {SB.describe()}")
root.UnitPreferences.SetCurrentUnit("DateFormat", "UTCG")
root.UnitPreferences.SetCurrentUnit("Distance", "m")
sc = root.CurrentScenario
if sc is not None and sc.InstanceName != SCENARIO:
if not force:
raise SystemExit(
f"目前開著的是「{sc.InstanceName}」。"
f"加 --force 才會關掉它並建立 {SCENARIO}。")
root.CloseScenario()
sc = None
if sc is None:
root.NewScenario(SCENARIO)
sc = root.CurrentScenario
sc.SetTimePeriod(EPOCH, STOP)
root.Rewind()
# 發射場
names = [sc.Children.Item(i).InstanceName for i in range(sc.Children.Count)]
if A.LAUNCH_NAME not in names:
pl = sc.Children.New(AgESTKObjectType.ePlace, A.LAUNCH_NAME)
pl.UseTerrain = False
pl.Position.AssignGeodetic(A.LAUNCH_LAT, A.LAUNCH_LON,
A.LAUNCH_ALT_M)
info = {}
for key, obj_name, eph_name in (
("east", "TWSP_Ret_East", "twsp_return_east.e"),
("west", "TWSP_Ret_West", "twsp_return_west.e")):
pts = data[key]["pts"]
path = os.path.join(HERE, eph_name)
# write_ephemeris 要的是 (t, lat, lon, alt, v, phase) 六元組
B.write_ephemeris(
path, [(p[0], p[1], p[2], p[3], p[4], "glide") for p in pts],
EPOCH)
for i in range(sc.Children.Count - 1, -1, -1):
if sc.Children.Item(i).InstanceName == obj_name:
sc.Children.Item(i).Unload()
ac = sc.Children.New(AgESTKObjectType.eAircraft, obj_name)
ac.SetRouteType(AgEVePropagatorType.ePropagatorStkExternal)
ac.Route.Filename = path
ac.Route.Propagate()
info[key] = {"object": obj_name, "ephemeris": path,
"points": len(pts),
"duration_min": pts[-1][0] / 60.0,
"landing_err_km": data[key]["landing_err_km"],
"sol": data[key]["sol"]}
if verbose:
print(f" {obj_name} {len(pts)} 點,"
f"{pts[-1][0]/60:,.0f} min,落點離旭海 "
f"{data[key]['landing_err_km']:,.1f} km ✔")
return True, info
if __name__ == "__main__":
if "--plan" in sys.argv or "--no-stk" in sys.argv:
R.compare()
print()
describe()
else:
R.compare()
print()
ok, info = build(force="--force" in sys.argv)
print()
print("RETURN-BUILD-OK" if ok else "RETURN-BUILD-INCOMPLETE")
spaceport_suborbital/spaceport_return_verify.py
# -*- coding: utf-8 -*-
"""場景二 —— 驗證。
場景一有 39 項回歸檢查,**場景二先前一項都沒有**。
它建得出來、圖看起來合理,但沒有任何東西確認那兩條軌跡
真的符合解析解 —— 而這一章已經有三次「圖看起來對、帳是錯的」。
與 `spaceport_verify.py` 一樣的規矩:
* **從 STK 回讀**,不看設定值
* 失敗要看得出「量到什麼」,不只是「不通過」
* 形狀檢查與數值檢查分開列 —— 數值檢查抓不到形狀錯誤
python spaceport_return_build.py --force # 先建
python spaceport_return_verify.py # 再驗
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import spaceport_analysis as A
import spaceport_build as B
import spaceport_return as R
import spaceport_return_build as RB
TOL_POS_KM = 60.0 # 落點容差。解析估計與逐點積分本來就差 0.7%
TOL_PATH = 1.03 # 航跡/直線的上限 —— 繞圈就會爆掉
class Check:
"""與 spaceport_verify.Check 同一套,分類計數。"""
LABELS = {
"scene": "場景建構 物件存在、星曆載得進去",
"geom": "幾何與連續性 起訖點、航跡長度、不繞圈",
"physics": "物理自洽性 與解析解一致",
}
def __init__(self):
self.rows = []
self.kind = "scene"
def section(self, title, kind):
assert kind in self.LABELS, kind
self.kind = kind
print()
print(title)
def add(self, ok, name, detail=""):
self.rows.append((bool(ok), name, detail, self.kind))
print(f" [{'OK ' if ok else 'FAIL'}] {name}"
+ (f" {detail}" if detail else ""))
return bool(ok)
def skip(self, name, why):
return self.add(False, name, f"量不到:{why}")
def note(self, name, detail=""):
print(f" [--- ] {name}" + (f" {detail}" if detail else ""))
def report(self):
print()
print("=" * 78)
print(" 場景二驗證結果(依種類分列)")
print("=" * 78)
for kind, label in self.LABELS.items():
rs = [r for r in self.rows if r[3] == kind]
if not rs:
continue
print(f" {sum(1 for r in rs if r[0]):>2}/{len(rs):<2} {label}")
ok = sum(1 for r in self.rows if r[0])
print("-" * 78)
print(f" {ok}/{len(self.rows)} 項通過")
bad = [r[1] for r in self.rows if not r[0]]
if bad:
print(f" 未通過:{bad}")
print()
print(" **這是回歸檢查,不是工程可行性驗證。**")
print(" 場景二的結論本來就是「這個任務做不到」——")
print(" 驗證只確認那個結論是從正確的計算得出的。")
print("=" * 78)
return ok == len(self.rows)
def lla_track(obj, sc, step=120.0):
try:
dp = obj.DataProviders.Item("LLA State").Group.Item("Fixed")
r = dp.ExecElements(sc.StartTime, sc.StopTime, step,
["Lat", "Lon", "Alt"])
g = lambda k: r.DataSets.GetDataSetByName(k).GetValues() # noqa: E731
out = [(a, b, c) for a, b, c in zip(g("Lat"), g("Lon"), g("Alt"))
if isinstance(a, (int, float)) and isinstance(b, (int, float))
and isinstance(c, (int, float))]
return out or None
except Exception:
return None
def ground_path_m(pts):
return sum(A.great_circle(pts[i - 1][0], pts[i - 1][1],
pts[i][0], pts[i][1])[1]
for i in range(1, len(pts)))
def rotation_along_track_m(pts, path_m, glide_m, ld):
"""地球自轉在這條地面航跡上累積出來的**沿航跡**位移。
## 為什麼不能用 `2 × V_rot × 飛行時間`
先前這裡就是那個估計式,門檻 30%。它在設計點 L/D 3.2 時通過,
降到 2.2 時偏差 36% 而失敗 —— 但**失敗的是估計式,不是模型**。
兩個修正,都是同一個方向:
1. **自轉只有垂直於航跡的東西向分量會改變航跡長度。**
這條軌跡橫跨 ±22° 緯度,航向從 90° 一路轉到 34°;
航向愈偏南北,自轉對航跡長度的貢獻愈小。
要乘 `cos(緯度) · |sin(航向)|`。
2. **時間權重不能按弧長平均分配。** 這是主要的那一項。
載具沿途從 7,785 m/s 減速到 761 m/s,**慢的那一段**
每公里花掉的時間多得多 —— 而那正好是航向已經轉到 34°、
東西向分量最小的那一段。按弧長分配時間等於把那一段的
權重壓掉,於是高估了自轉的效果。
兩項加起來把預測從 5,626 km 修到 4,197 km,
與實測的 3,577 km 差 15%(先前是 36%)。
速度沿途由剩餘射程反解(與 `glide_track()` 同一套 Eggers 關係)。
"""
acc = 0.0
s_cum = 0.0
for i in range(1, len(pts)):
seg, az = A.great_circle(pts[i - 1][0], pts[i - 1][1],
pts[i][0], pts[i][1])[1:3]
s_cum += seg
rem = max(1.0, glide_m * (1.0 - s_cum / path_m))
v = max(120.0, A.glide_v_for_range(rem, ld))
lat = math.radians((pts[i - 1][0] + pts[i][0]) / 2.0)
acc += (R.V_EQ_MS * math.cos(lat)
* abs(math.sin(math.radians(az))) * seg / v)
return acc
def verify():
ck = Check()
cmp_ = R.compare(verbose=False)
import stk_binding as SB
stk = SB.STKDesktop.AttachToApplication()
root = stk.Root
B.set_units(root)
sc = root.CurrentScenario
print("=" * 78)
print("場景二:繞地球一圈回到旭海 —— 驗證")
print("=" * 78)
print(f" {SB.describe()}")
print(f" 設計升阻比 L/D {A.LD_DESIGN}")
ck.section("一、場景", "scene")
if sc is None:
ck.add(False, "場景已開啟", "CurrentScenario 是 None")
return ck.report()
ck.add(sc.InstanceName == RB.SCENARIO, "場景名稱",
f"{sc.InstanceName}(預期 {RB.SCENARIO})")
names = [sc.Children.Item(i).InstanceName for i in range(sc.Children.Count)]
ck.add(A.LAUNCH_NAME in names, "發射場存在", A.LAUNCH_NAME)
tracks = {}
for key, obj_name in (("east", "TWSP_Ret_East"),
("west", "TWSP_Ret_West")):
sol = cmp_[key]
label = "向東" if key == "east" else "向西"
ck.section(f"二、{label}({obj_name})", "geom")
obj = next((sc.Children.Item(i) for i in range(sc.Children.Count)
if sc.Children.Item(i).InstanceName == obj_name), None)
if obj is None:
ck.add(False, f"{obj_name} 存在", "找不到物件")
continue
ck.add(True, f"{obj_name} 存在", "")
tk = lla_track(obj, sc)
if tk is None:
ck.skip(f"{label}軌跡可取得", "LLA State 取不到")
continue
tracks[key] = tk
# 起點 = 進入點(上升段 + 真空弧之後),不是旭海
s0 = sol["ascent_m"] + sol["arc_m"]
elat, elon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON,
sol["azimuth_deg"], s0)
# **要扣掉地球在上升段 + 真空弧期間轉過的角度。**
# 軌跡是地固系的,而 point_along 給的是慣性系的位置。
# 漏掉這一步會量出 733 km 的假偏差。
import spaceport_ascent as _S
_asc = _S.ascent_downrange(sol["v_bo"],
azimuth_deg=sol["azimuth_deg"])
# 與 build 用同一個解析值 —— 先前兩邊都用粗估,自洽但都錯。
t_entry = _asc["t_burnout"] + A.arc_duration_s(
sol["v_bo"], arc_end_alt_m=sol["glide_alt_m"])
elon_fixed = B.wrap_lon(
elon - math.degrees(RB.OMEGA_E * t_entry))
d0 = A.great_circle(tk[0][0], tk[0][1], elat, elon_fixed)[1]
ck.add(d0 / 1000.0 < TOL_POS_KM, f"{label}起於進入點",
f"偏差 {d0/1000:.1f} km(進入點在旭海下游 "
f"{s0/1000:,.0f} km,期間地球轉過 "
f"{math.degrees(RB.OMEGA_E*t_entry):.1f}°)")
# **要用弧終點速度,不是熄火速度。** 軌跡起點在真空弧的終點,
# 那裡比熄火點低但更快,平衡滑翔高度因此更高。
# 先前這裡比的是 `sol["glide_alt_m"]`(= 熄火速度的平衡高度),
# 差約 4.6%;設計點還高的時候絕對值小,剛好落在 2 km 的容差內,
# 降到 L/D 1.74 之後高度變高,同樣的相對誤差就撐破容差了。
alt_ref = A.equilibrium_glide_alt(sol["v_entry"])
ck.add(abs(tk[0][2] - alt_ref) < 2_000.0,
f"{label}起始高度符合平衡滑翔",
f"{tk[0][2]/1000:.1f} km(解析 {alt_ref/1000:.1f},"
f"用弧終點速度 {sol['v_entry']:,.0f} m/s)")
# 場景二止於 **TAEM 介面**,不飛到跑道 ——
# 慣性系的滑翔模型在速度掉到與自轉同量級時就不成立了。
taem_alt = A.equilibrium_glide_alt(B.taem_handover_v(A.LD_DESIGN))
ck.add(abs(tk[-1][2] - taem_alt) < 1_500.0,
f"{label}止於 TAEM 介面",
f"{tk[-1][2]/1000:.1f} km(解析 {taem_alt/1000:.1f});"
f"最後 92 km 不模擬,見 return_build 的說明")
# 物件的**可用區間**要由進入點起算,不是場景起點 ——
# 場景一先前七個物件全部由 t = 0 起算,在 3D 視窗裡變成
# 四架載具同時起飛。場景二沒有那個問題(星曆本來就帶偏移),
# 但沒有東西在守,所以補一項。
#
# 注意 `lla_track()` 只回 (Lat, Lon, Alt),**沒有時間** ——
# 拿 `tk[0][0]` 當時間去比的話,那其實是緯度(−21°)。
with B.scoped_unit(root, "DateFormat", "EpSec"):
try:
av0 = float(obj.Vgt.Events.Item("AvailabilityStartTime")
.FindOccurrence().Epoch)
except Exception: # noqa: BLE001
av0 = None
if av0 is None:
ck.skip(f"{label}由進入點起算", "取不到可用區間")
else:
ck.add(abs(av0 - t_entry) < 120.0,
f"{label}的可用區間由進入點起算",
f"{av0:,.0f} s vs 進入點 {t_entry:,.0f} s"
f"(差 {av0 - t_entry:+,.0f})")
# **形狀檢查**:不繞圈
path = ground_path_m(tk)
direct = A.great_circle(tk[0][0], tk[0][1], tk[-1][0], tk[-1][1])[1]
# 繞地球一圈時「直線距離」沒有意義(起訖點很近),
# 改用「相鄰點的航向變化」判斷有沒有折返。
turns = 0
for i in range(2, len(tk)):
a1 = A.great_circle(tk[i-2][0], tk[i-2][1], tk[i-1][0], tk[i-1][1])[2]
a2 = A.great_circle(tk[i-1][0], tk[i-1][1], tk[i][0], tk[i][1])[2]
d = abs((a2 - a1 + 180.0) % 360.0 - 180.0)
if d > 45.0:
turns += 1
ck.add(turns == 0, f"{label}沒有折返或繞圈",
f"航向突變 {turns} 次(航跡 {path/1000:,.0f} km)")
ck.section("三、物理自洽性", "physics")
e, w = cmp_["east"], cmp_["west"]
ck.add(abs(e["closure_residual_m"]) < 1.0, "向東的三段閉合",
f"殘差 {e['closure_residual_m']:.3f} m")
ck.add(abs(w["closure_residual_m"]) < 1.0, "向西的三段閉合",
f"殘差 {w['closure_residual_m']:.3f} m")
# 兩個方向的慣性航程、熄火速度必須相同 —— 差別只在自轉助益
ck.add(abs(e["v_bo"] - w["v_bo"]) < 1.0,
"兩個方向的熄火速度相同",
f"{e['v_bo']:,.1f} vs {w['v_bo']:,.1f} m/s")
dv_gap = w["dv_total_ms"] - e["dv_total_ms"]
ck.add(abs(dv_gap - 2.0 * e["v_rot"]) < 5.0,
"東西的 Δv 差 = 2 倍自轉助益",
f"{dv_gap:,.0f} vs 2×{e['v_rot']:,.0f} = "
f"{2*e['v_rot']:,.0f} m/s")
# 地面航跡:向西必須比向東長,差約 2×V_rot×飛行時間
if "east" in tracks and "west" in tracks:
pe = ground_path_m(tracks["east"])
pw = ground_path_m(tracks["west"])
gap = pw - pe
want = (rotation_along_track_m(tracks["east"], pe,
e["glide_m"], A.LD_DESIGN)
+ rotation_along_track_m(tracks["west"], pw,
w["glide_m"], A.LD_DESIGN))
naive = 2.0 * e["v_rot"] * e["t_flight_s"]
ck.add(pw > pe, "向西的地面航跡比向東長",
f"{pw/1000:,.0f} vs {pe/1000:,.0f} km")
ck.add(abs(gap - want) / want < 0.30, "地面航跡差符合自轉量級",
f"實測 {gap/1000:,.0f} km,沿航跡時間權重的自轉位移 "
f"{want/1000:,.0f} km(差 {(gap-want)/want*100:+.0f}%);"
f"粗估的 2×V_rot×T 會給 {naive/1000:,.0f} km,"
f"高估是因為它沒有時間權重")
# 落點:兩條都在旭海以西,而且兩者一致
if "east" in tracks and "west" in tracks:
de = A.great_circle(tracks["east"][-1][0], tracks["east"][-1][1],
A.LAUNCH_LAT, A.LAUNCH_LON)[1]
dw = A.great_circle(tracks["west"][-1][0], tracks["west"][-1][1],
A.LAUNCH_LAT, A.LAUNCH_LON)[1]
# 兩條**不會**落在同一點,而且那是幾何上必然的:
# 總航程停在「整圈差 92 km」處,向東少走 0.83°、向西多走 0.83°。
# 真正該驗的是**兩者對稱於自轉預測的經度** ——
# 那直接驗到了「落點西移 = 地球在飛行期間轉過的角度」。
lon_e = B.wrap_lon(tracks["east"][-1][1])
lon_w = B.wrap_lon(tracks["west"][-1][1])
# **要用軌跡自己的結束時間。**
# sol["t_flight_s"] 算的是飛到跑道,而軌跡停在 TAEM 介面 ——
# 兩者差 3.8 分鐘,換算成經度差 0.95°,這一項就會假失敗。
t_track = RB.return_track(e)[-1][0]
drift_deg = math.degrees(RB.OMEGA_E * t_track)
want_lon = B.wrap_lon(A.LAUNCH_LON - drift_deg)
mid = 0.5 * (lon_e + lon_w)
ck.add(abs(mid - want_lon) < 0.5,
"東西落點對稱於自轉預測的經度",
f"平均 {mid:.2f}° vs 預測 {want_lon:.2f}°"
f"(旭海 {A.LAUNCH_LON:.2f}° − 自轉 {drift_deg:.2f}°,"
f"星曆歷時 {t_track/60:.1f} min)")
ck.add(abs(tracks["east"][-1][0] - tracks["west"][-1][0]) < 0.5,
"兩個方向的終點緯度相同",
f"{tracks['east'][-1][0]:.2f}° vs "
f"{tracks['west'][-1][0]:.2f}°")
ck.add(de / 1000.0 > 1_000.0, "**落點離旭海很遠**(繞一圈回不到原點)",
f"{de/1000:,.0f} km —— 這是結果,不是誤差")
lon_e = B.wrap_lon(tracks["east"][-1][1])
ck.add(((A.LAUNCH_LON - lon_e) % 360.0) < 180.0,
"落點在旭海**以西**",
f"落點 {lon_e:.2f}°、旭海 {A.LAUNCH_LON:.2f}°")
ck.note("熄火速度佔地表圓軌速度",
f"{e['pct_of_circular']:.1f}% —— 到這裡「次軌道」已無意義")
return ck.report()
if __name__ == "__main__":
ok = verify()
print("RETURN-VERIFY-OK" if ok else "RETURN-VERIFY-INCOMPLETE")
sys.exit(0 if ok else 1)
spaceport_suborbital/spaceport_stages.py
# -*- coding: utf-8 -*-
r"""分節分離的動畫與尾焰。需要 STK 已啟動、場景已開。
## 這一支在回答什麼
發射載具是**雙節火箭**,第二節就是太空飛機。先前場景裡只有一個
`TWSP_Ascent`(Aircraft + 外部星曆)代表整支上升段 —— **沒有第一節這個
物件**,所以在 3D 視窗裡看不到分離,也沒有尾焰。
## 先量工具能力,再設計(實測,不是查文件)
| 機制 | 結果 | 備註 |
|---|---|---|
| `VO <obj> VaporTrail Show On`(Connect) | **ACK** | Aircraft 與 Missile 都支援 |
| `VO <obj> Trail ...`(Connect) | 失敗 | 沒有這個關鍵字 |
| `VO.VaporTrail.DisplayInterval` | **可設可回讀** | 實測設 183→440 s 後回讀一致 |
| `VaporTrail.UseAttachPoint` / `AttachPointName` | **可用,但看模型** | 見下 |
| `VaporTrail.ImageFile` | **可換** | STK 內建 `flametex-orange/blue/white.tga` |
| Connect `Articulate` | 失敗 | 活動件名稱不對就失敗;改走物件模型 |
| `Model.Articulation.GetAvailableArticulations(lod)` | **可用** | 需要 LOD 參數 |
| **`.sama` articulation 檔** | **可用** | 這才是**時變**活動件的機制 |
各模型的掛點與活動件(實測):
| 模型 | 掛點 | 關鍵活動件 |
|---|---|---|
| `Space\launchvehicle.glb` | `Flame_Booster1/2`、`SRB_Flame1..6`、`Centaur_Flame1/2` | `Booster`、`UpperStage`、**`BoosterFlames:Size`**、**`UpperStageFlames:Size`**、`SRBs:{Drop,Separate}`、`Fairing:{Open,Drop,Separate}` |
| `Missiles\missile_stages.glb` | `FirstStage_Smoke` 等 | `ScaleFirstStage:{Size,Drop}`、`FirstStageFlame:Size`、二三節同理 |
| 內建 `Air\aircraft.glb` | `StarboardEngine`、`PortEngine` | 只有 `Aircraft:{Size,Roll,Pitch,Yaw,Move*}` —— **沒有火焰件** |
| 本專案的 `model\twsp2_spaceplane.glb`(原檔)| **無** | **無** |
| `model\twsp2_spaceplane_ap.glb` | **`MainEngine`** | 無 —— 由 `model/add_attach_point.py` 加上 |
## 由實測導出的設計
* **第一節** `TWSP_Stage1`:用 `launchvehicle.glb`(它有分節與火焰活動件)。
星曆 = 上升段的 [0, 分離] + **分離後的拋離軌跡**(用完的節是彈道墜落,
不是消失)。尾焰用 `.sama` 檔驅動 `BoosterFlames:Size`,
再加一層掛在 `Flame_Booster1` 的 VaporTrail 當煙柱。
分離時 `BoosterFlames:Size → 0`,`Booster` 縮小表示空殼。
* **第二節** `TWSP_Ascent`:模型維持**太空飛機**(使用者要的就是這個)。
它沒有活動件,所以火焰不能用活動件縮放,只能用 VaporTrail,
`DisplayInterval` 設成 [二節點火, 熄火]。
掛點則是**後來補上的** —— 原檔沒有,所以尾焰一度從物件原點噴出
(看起來像從機身中央冒煙);`model/add_attach_point.py` 在機身尾端
加了 `MainEngine`,現在噴口在對的位置。
* 兩段的時間窗由 `spaceport_ascent.timeline()` 給,不是手填。
## 這個做法**不是**什麼
VaporTrail 是 STK 的**煙團(puff)系統**,把貼圖換成火焰只是讓它*看起來*
像噴焰 —— 它**不是**噴流物理模型,沒有膨脹比、沒有背壓效應、
沒有真空中的羽流擴張。要那個得用 CFD 或 plume 模組,不在本章範圍。
活動件的火焰是**模型自帶的幾何**,同理。
python spaceport_stages.py # 建物件 + 設尾焰
python spaceport_stages.py --plan # 只印時序,不動 STK
"""
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import spaceport_analysis as A # noqa: E402
import spaceport_ascent as S # noqa: E402
import spaceport_build as B # noqa: E402
STAGE1_OBJ = "TWSP_Stage1"
STAGE1_EPH = "twsp_stage1.e"
STAGE1_SAMA = "twsp_stage1.sama"
STAGE1_MODEL = r"STKData\VO\Models\Space\launchvehicle.glb"
# 用完的第一節:空殼、翻滾、沒有姿態控制,所以彈道係數遠低於起飛時的
# beta0 = 70,000 kg/m^2。翻滾空殼典型 1,000-3,000;取 2,000。
# **這個值沒有量測依據**,只是量級 —— 它只影響拋離段落點,
# 不影響任何任務結論,所以不做成驗證項目。
BETA_SPENT_KGM2 = 2_000.0
JETTISON_DT_S = 1.0
JETTISON_MAX_S = 900.0
JETTISON_RECORD_S = 5.0
FLAME_TEX = r"STKData\VO\Textures\flametex-orange.tga"
# 燃燒段的 articulation 要從場景起點**之前**開始,否則 t = 0 那一格
# 讀回來的火焰大小是 0(實測)。使用者要的是剛發射就有尾焰。
IGNITION_LEAD_S = 10.0
# 推進器模型相對物件原點的視覺偏移(模型單位,範圍 ±1000)。
# 分離前兩個物件在**同一個位置** —— 它們本來就是連在一起的 ——
# 所以太空飛機會落在推進器模型裡面。把推進器往後挪讓兩個都看得見。
# **純視覺,沒有物理意義**,覺得不好看就改這一個數字。
STAGE1_OFFSET_X = -12.0
# ══════════════════════════════════════════════════════════════════════
# 一、時序
# ══════════════════════════════════════════════════════════════════════
def stage_timing():
"""分節時序。全部由 `spaceport_ascent` 解出,這裡不填任何數字。"""
base = S.solve_ascent()
tl = base["timeline"]
sol = S.ascent_integrate(tl, kick_deg=base["kick_solved_deg"],
t_kick_s=16.0, record_dt=2.0,
beta0=S.BETA0_DESIGN)
m = sol["marks"]
return {
"t_sep": tl["t_separation"],
"t_ign2": tl["t_stage2_ignition"],
"t_burnout": tl["t_burnout"],
"sep_state": m["separation"], # (t, v, gamma_deg, h)
"azimuth_deg": tl["azimuth_deg"],
"track": sol["track"],
"solution": sol,
}
# ══════════════════════════════════════════════════════════════════════
# 二、拋離軌跡
# ══════════════════════════════════════════════════════════════════════
def jettison_track(h0_m, s0_m, v0_ms, gamma0_deg, t0_s,
beta=BETA_SPENT_KGM2, dt=JETTISON_DT_S):
r"""用完的第一節分離之後的彈道墜落。回傳 [(t, h, s, v, gamma_deg)]。
與 `ascent_integrate` 同一套式子,但**沒有推力**:
dv/dt = -g(h)*sin(gamma) - a_D
dgamma/dt = -cos(gamma) * (g/v - v/r)
dh/dt = v*sin(gamma)
ds/dt = v*cos(gamma) * RE/r
離心解除項 `v/r` 不能省(上升段那次就是漏了它才把 gamma 轉過水平線)。
積到落地(h <= 0)或時間上限為止。
"""
RE, MU = A.RE, A.MU
v = max(1.0, float(v0_ms))
gam = math.radians(gamma0_deg)
h, s, t = float(h0_m), float(s0_m), float(t0_s)
out = [(t, h, s, v, math.degrees(gam))]
next_rec = t + JETTISON_RECORD_S
for _ in range(int(JETTISON_MAX_S / dt)):
r = RE + max(0.0, h)
g = MU / (r * r)
q = 0.5 * A.us76_density(max(0.0, h)) * v * v
a_d = q / beta
v = max(1.0, v + (-g * math.sin(gam) - a_d) * dt)
gam += -math.cos(gam) * (g / v - v / r) * dt
h += v * math.sin(gam) * dt
s += v * math.cos(gam) * dt * RE / r
t += dt
if h <= 0.0:
out.append((t, 0.0, s, v, math.degrees(gam)))
break
if t >= next_rec - 1e-9:
out.append((t, h, s, v, math.degrees(gam)))
next_rec += JETTISON_RECORD_S
return out
def stage1_track():
"""第一節的完整地固軌跡 [(t, lat, lon, alt, v, phase)]。
兩段接起來:上升段的 [0, 分離] + 拋離墜落。
分離點的狀態由上升段積分給出,不是另外假設的。
"""
tm = stage_timing()
az = tm["azimuth_deg"]
t_sep = tm["t_sep"]
pts = []
last = None
for t, h, sdown, v, gam in tm["track"]:
if t > t_sep + 1e-9:
break
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, sdown)
pts.append((t, lat, B.wrap_lon(lon), max(0.0, h), v, "boost"))
last = (t, h, sdown, v, gam)
# 分離點的狀態用 marks 的精確值。track 的取樣間隔是 2 s,所以
# 上面那個迴圈的最後一點可能落在分離前 2 s(實測速度差 140 m/s)——
# 把精確的分離點補成助推段的最後一點,星曆在交界處才是準的。
#
# **沿程要內插,不能沿用上一個取樣點。** 取 `last[2]`
# (最後一個已記錄取樣的沿程)的話,補出來的點會有 t=183.95 的高度與
# 速度、卻有 t=182 的位置 —— 位置差 **7.7 km**。
# 那讓第一節與太空飛機在分離那一刻看起來相距 7.7 km,
# 在 3D 視窗裡不會像「分離」。
ts, vs, gs, hs = tm["sep_state"]
s_sep = last[2] if last else 0.0
prev = nxt = None
for row in tm["track"]:
if row[0] <= ts:
prev = row
elif nxt is None:
nxt = row
break
if prev is not None and nxt is not None and nxt[0] > prev[0]:
f = (ts - prev[0]) / (nxt[0] - prev[0])
s_sep = prev[2] + (nxt[2] - prev[2]) * f
if not pts or abs(pts[-1][0] - ts) > 1e-6:
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, s_sep)
pts.append((ts, lat, B.wrap_lon(lon), max(0.0, hs), vs, "boost"))
for t, h, sdown, v, _gam in jettison_track(hs, s_sep, vs, gs, ts)[1:]:
lat, lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON, az, sdown)
pts.append((t, lat, B.wrap_lon(lon), max(0.0, h), v, "jettison"))
return pts, tm
# ══════════════════════════════════════════════════════════════════════
# 三、articulation 檔(時變活動件)
# ══════════════════════════════════════════════════════════════════════
def write_sama(path, events):
r"""寫 STK 的 `.sama` articulation 檔。
格式是**實測既有樣本**得到的(STK 安裝目錄下的 `ImageSat.sama`):
每個區塊 `NEW_ARTICULATION` 帶 STARTTIME / DURATION /
ARTICULATION / TRANSFORMATION / STARTVALUE / ENDVALUE,
時間是**場景秒**。
`events` = [(start_s, duration_s, articulation, transformation,
start_value, end_value), ...]
"""
L = ["#VO_v90"]
for t0, dur, art, trans, v0, v1 in events:
L += ["NEW_ARTICULATION ",
" STARTTIME %.6f" % t0,
" DURATION %.6f" % max(0.001, dur),
" DEADBANDDURATION 0.000000",
" ACCELDURATION 0.000000",
" DECELDURATION 0.000000",
" DUTYCYCLEDELTA 0.000000",
" PERIOD 0.0",
" ARTICULATION %s" % art,
" TRANSFORMATION %s" % trans,
" STARTVALUE %g" % v0,
" ENDVALUE %g" % v1]
with open(path, "w", encoding="ascii", newline="\n") as f:
f.write("\n".join(L) + "\n")
return len(events)
def stage1_articulation_events(tm):
"""第一節的活動件時序。
* 燃燒段的 STARTTIME 取 **負值**(`IGNITION_LEAD_S`),不是 0。
實測:STARTTIME 正好是 0 時,在動畫時刻 t = 0 讀回來的
`BoosterFlames.Size` 是 **0.0** —— STK 把該時刻當成該段的起點、
還在從前一個值過渡。使用者要的是「**剛發射時**就有尾焰」,
所以讓燃燒段在場景起點之前就開始。
* 分離時 1 秒內火焰收到 0,並把 `Booster` 縮小,表示它已是空殼。
* `UpperStage`、`UpperStageFlames`、`Fairing` 在這個物件上一路是 0 ——
第二節由**太空飛機那個物件**代表,不要在這裡畫第二次。
## 回讀實測
t=0 flame 0.000 Booster.Size 0.600 <- 見下面的邊界
t=0.5 1.000 1.000
t=60 1.000 1.000
t=182 0.984 0.997(分離漸變中)
t=183 0.000 0.797
t=185 以後 0.000 0.600
## 一個量到的工具邊界:**場景起點那一格不套用 articulation**
在**恰好** t = 0(場景起點)時,活動件的值不是檔案給的,
而是「沒有事件作用」的殘留值。從 t = 0.5 s 起就正確。
三種寫法都試過,結果一樣:
1. 燃燒段的 STARTTIME 取 0
2. 燃燒段的 STARTTIME 取 −10(場景起點之前開始)
3. 燃燒段拉成 `STARTTIME -99999、DURATION 99999+t_sep`
—— 也就是 t = 0 時**確實有事件正在作用**
中間一度以為「把燃燒段排到檔案最後」修好了(t=0 讀到 1.000),
但那是 `Reload()` 之後的**暫態** —— 先把時間移到別處再移回 0 就破功。
所以那不是修好,是誤判,這裡如實記著。
**實務上看不到**:一按播放就進入 t > 0,尾焰就在。
只有「打開場景、停在起點、還沒播放」那一格沒有火。
要真的消掉它得把場景起點挪到發射之前幾秒 ——
那會動到全專案的時間軸對應(Missile 的起始時刻是二節熄火),
為一格畫面改那個不值得,所以不改。
"""
t_sep = tm["t_sep"]
lead = IGNITION_LEAD_S
return [
# 燃燒段:從場景起點之前就開始,所以 t > 0 的每一格都有火
(-lead, t_sep + lead, "BoosterFlames", "Size", 1.0, 1.0),
(-lead, t_sep + lead, "Booster", "Size", 1.0, 1.0),
# 分離:火焰 1 秒收掉,本體 2 秒縮成空殼
(t_sep, 1.0, "BoosterFlames", "Size", 1.0, 0.0),
(t_sep + 1.0, 0.001, "BoosterFlames", "Size", 0.0, 0.0),
(t_sep, 2.0, "Booster", "Size", 1.0, 0.6),
# 第二節與整流罩不在這個物件上呈現 —— 太空飛機是另一個物件
(-99999.0, 0.001, "UpperStageFlames", "Size", 0.0, 0.0),
(-99999.0, 0.001, "UpperStage", "Size", 0.0, 0.0),
(-99999.0, 0.001, "Fairing", "Size", 0.0, 0.0),
# 視覺偏移:兩個物件在分離前是**同一個位置**(它們是連在一起的),
# 所以太空飛機會落在推進器模型裡面。把推進器往後挪一點,
# 兩個都看得見。**純視覺,沒有物理意義。**
(-99999.0, 0.001, "Booster", "MoveX",
STAGE1_OFFSET_X, STAGE1_OFFSET_X),
]
# ══════════════════════════════════════════════════════════════════════
# 四、建物件
# ══════════════════════════════════════════════════════════════════════
def _set_vapor_trail(obj, t0_s, t1_s, image, radius, density,
attach=None, puffs=6000):
"""設定一個物件的 VaporTrail(含時間窗)。回傳**回讀**的值。"""
vt = obj.VO.VaporTrail
vt.Visible = True
vt.ImageFile = image
vt.Radius = float(radius)
vt.Density = float(density)
vt.MaxNumOfPuffs = int(puffs)
used = ""
if attach:
try:
avail = list(vt.AvailableAttachPoints)
except Exception:
avail = []
if attach in avail:
vt.UseAttachPoint = True
vt.AttachPointName = attach
used = attach
else:
vt.UseAttachPoint = False
else:
vt.UseAttachPoint = False
vt.DisplayInterval.SetExplicitInterval(float(t0_s), float(t1_s))
di = vt.DisplayInterval
return {"visible": vt.Visible, "image": vt.ImageFile,
"radius": vt.Radius, "density": vt.Density,
"attach": used,
"t0": float(di.FindStartTime()),
"t1": float(di.FindStopTime())}
def build_stages(verbose=True):
"""建第一節物件、寫 articulation 檔、設兩段尾焰。回傳 (ok, info)。"""
from stk_binding import (AgESTKObjectType, AgEVePropagatorType,
STKDesktop)
pts, tm = stage1_track()
eph = os.path.join(HERE, STAGE1_EPH)
sama = os.path.join(HERE, STAGE1_SAMA)
stk = STKDesktop.AttachToApplication()
root = stk.Root
sc = root.CurrentScenario
if sc is None:
raise SystemExit("沒有開著的場景。先跑 spaceport_build.py")
root.UnitPreferences.SetCurrentUnit("DateFormat", "UTCG")
epoch = str(sc.StartTime)
B.write_ephemeris(eph, pts, epoch)
n_ev = write_sama(sama, stage1_articulation_events(tm))
# 時間單位切成場景秒,DisplayInterval 才吃得到數字
root.UnitPreferences.SetCurrentUnit("DateFormat", "EpSec")
for i in range(sc.Children.Count - 1, -1, -1):
if sc.Children.Item(i).InstanceName == STAGE1_OBJ:
sc.Children.Item(i).Unload()
s1 = sc.Children.New(AgESTKObjectType.eAircraft, STAGE1_OBJ)
s1.SetRouteType(AgEVePropagatorType.ePropagatorStkExternal)
s1.Route.Filename = eph
s1.Route.Propagate()
info = {"object": STAGE1_OBJ, "points": len(pts),
"ephemeris": eph, "sama": sama, "sama_events": n_ev,
"t_sep": tm["t_sep"], "t_ign2": tm["t_ign2"],
"t_burnout": tm["t_burnout"],
"impact_t_s": pts[-1][0], "impact_lat": pts[-1][1],
"impact_lon": pts[-1][2]}
md = s1.VO.Model
md.ModelData.Filename = STAGE1_MODEL
art = md.Articulation
# `VOArticulationFile` 是**物件**,不是字串 —— 直接指派會得到
# 「property has no setter」。它只有 `Filename` 與 `Reload()`。
# Connect 這一路完全走不通:`Articulate`、`VO ... Articulate`、
# `VO ... ArticulationFile` 三種寫法都回 Command has failed
# (即使活動件名稱是從 GetAvailableArticulations 讀出來的正確名稱)。
try:
art.UseArticulationFile = True
art.VOArticulationFile.Filename = sama
art.VOArticulationFile.Reload()
info["artic_file_ok"] = True
info["artic_file"] = art.VOArticulationFile.Filename
except Exception as e: # noqa: BLE001
info["artic_file_ok"] = False
info["artic_err"] = str(e)[:140]
info["trail_stage1"] = _set_vapor_trail(
s1, 0.0, tm["t_sep"], FLAME_TEX, radius=12.0, density=80.0,
attach="Flame_Booster1")
# 第二節(太空飛機):自製模型沒有掛點,所以不掛
asc = next((sc.Children.Item(i) for i in range(sc.Children.Count)
if sc.Children.Item(i).InstanceName == S.ASCENT_OBJ), None)
if asc is None:
info["trail_stage2"] = None
else:
# **第二節要載太空飛機的模型。**
# `TWSP_Ascent` 先前用的是 STK 內建的 `aircraft.glb`(一架客機)
# —— 那不是這個任務的第二節,而且它的掛點是
# StarboardEngine / PortEngine,不是我們加的 MainEngine。
mg = os.path.normpath(os.path.join(HERE, B.MODEL_GLB))
try:
asc.VO.Model.ModelData.Filename = mg
info["stage2_model"] = os.path.basename(mg)
except Exception as exc: # noqa: BLE001
info["stage2_model_err"] = str(exc)[:90]
# 掛在機身尾端的 MainEngine 上。那個掛點是
# `model/add_attach_point.py` 加進 .glb 的 —— 原檔沒有掛點,
# 尾焰只能從物件原點噴,看起來像從機身中央冒煙。
info["trail_stage2"] = _set_vapor_trail(
asc, tm["t_ign2"], tm["t_burnout"], FLAME_TEX,
radius=6.0, density=60.0, attach=B.SPACEPLANE_ATTACH)
if verbose:
_report(info, pts)
return True, info
def _report(info, pts):
print("=" * 78)
print(" 分節分離與尾焰")
print("=" * 78)
print(" 分離 T+%.0f s" % info["t_sep"])
print(" 二節點火 T+%.0f s(滑行 %.0f s)"
% (info["t_ign2"], info["t_ign2"] - info["t_sep"]))
print(" 熄火 T+%.0f s" % info["t_burnout"])
print()
boost = [p for p in pts if p[5] == "boost"]
jet = [p for p in pts if p[5] == "jettison"]
print(" %-14s 外部星曆 %d 點(助推 %d + 拋離 %d)"
% (STAGE1_OBJ, info["points"], len(boost), len(jet)))
print(" %-14s 落點 T+%.0f s (%.3f, %.3f)"
% ("", info["impact_t_s"], info["impact_lat"],
info["impact_lon"]))
print(" %-14s 模型 %s" % ("", os.path.basename(STAGE1_MODEL)))
print(" %-14s articulation 檔 %d 個事件 %s"
% ("", info["sama_events"],
"OK" if info.get("artic_file_ok") else
"失敗:" + info.get("artic_err", "")))
t1 = info["trail_stage1"]
print(" 尾焰 第一節 %.0f -> %.0f s 掛點 %s 半徑 %g"
% (t1["t0"], t1["t1"], t1["attach"] or "(無)", t1["radius"]))
t2 = info.get("trail_stage2")
if t2:
print(" 尾焰 太空飛機 %.0f -> %.0f s 掛點 %s 半徑 %g"
% (t2["t0"], t2["t1"],
t2["attach"] or "(無 —— 自製模型沒有掛點)", t2["radius"]))
else:
print(" 尾焰 太空飛機 找不到 %s —— 先跑 spaceport_build.py"
% S.ASCENT_OBJ)
print()
print(" **VaporTrail 是煙團系統,換成火焰貼圖只是讓它看起來像噴焰。**")
print(" 它不是噴流物理模型:沒有膨脹比、沒有背壓、沒有真空羽流擴張。")
def describe():
pts, tm = stage1_track()
boost = [p for p in pts if p[5] == "boost"]
jet = [p for p in pts if p[5] == "jettison"]
_ts, vs, gs, hs = tm["sep_state"]
print("=" * 78)
print(" 分節時序與拋離軌跡(不動 STK)")
print("=" * 78)
print(" 分離 T+%.0f s v %s m/s gamma %.2f deg h %.1f km"
% (tm["t_sep"], format(vs, ",.0f"), gs, hs / 1000.0))
print(" 二節點火 T+%.0f s、熄火 T+%.0f s"
% (tm["t_ign2"], tm["t_burnout"]))
print(" 第一節星曆 %d 點(助推 %d + 拋離 %d)"
% (len(pts), len(boost), len(jet)))
print(" 拋離落地 T+%.0f s,(%.3f, %.3f)"
% (pts[-1][0], pts[-1][1], pts[-1][2]))
d = A.great_circle(A.LAUNCH_LAT, A.LAUNCH_LON,
pts[-1][1], pts[-1][2])[1]
print(" 落點離旭海 %s km" % format(d / 1000.0, ",.0f"))
print(" 空殼彈道係數假設 beta %s kg/m^2(量級,非量測 —— 只影響落點)"
% format(BETA_SPENT_KGM2, ",.0f"))
return pts, tm
if __name__ == "__main__":
if "--plan" in sys.argv or "--no-stk" in sys.argv:
describe()
else:
describe()
print()
ok, _info = build_stages()
print()
print("STAGES-OK" if ok else "STAGES-INCOMPLETE")
spaceport_suborbital/spaceport_suborbital_Connect.txt
# ============================================================================
# 台美太空港次軌道任務 —— STK Connect 腳本
#
# 起飛 旭海發射場(屏東縣牡丹鄉) 22.0620 N 120.9020 E
# 降落 休士頓太空港 Ellington Field 29.6073 N 95.1588 W (KEFD)
# 航線 大圓 13,109 km,初始方位 35.3 度
#
# 本檔由 make_connect.py 產生,數字來自 mission_geometry(),
# 設計常數一改重跑即可同步。不要手動編輯。
# ----------------------------------------------------------------------------
# 任務分成兩段,兩段相加等於全程(閉合殘差 0.000 m):
#
# 1. 助推真空弧 2,400 km 18.3% Missile + 彈道傳播器
# 2. 外部星曆段 9,139 km 69.7% Aircraft + StkExternal
# (末段約 31 km 交給 Aviator)
#
# 熄火 7,074 m/s @ 100 km,γ 2.0 度,遠地點 117 km
# 交接 78.5 km,7,103 m/s(Mach 25.08),(48.245, 151.327)
#
# 舊版這裡寫著「3,765 km 無模型涵蓋」。那是記帳錯誤,不是工具限制 ——
# 真空弧的射程被重複計入滑翔段。已修正,航程沒有缺口。
# ----------------------------------------------------------------------------
# **哪些做得到,是實測出來的**
#
# 2026-09-24 對執行中的 STK 13 逐條送出候選指令,記錄 ACK / NACK。
# 結論:
#
# 做得到 場景、場址、物件建立、**外部星曆載入**、3D 模型、視角
# 做不到 Missile 的彈道傳播器設定(發射點/落點/控制方式)
# Aviator 的任務、階段與程序
#
# 其中最值得注意的是:**整個高超音速滑翔段可以只用 Connect 完成**,
# 因為 SetState ... FromFile 吃得下 STK 的 .e 星曆檔。
# 逐條清單見檔尾附錄 B。
#
# 本檔本身也整份跑過:20 條指令 **20/20 ACK**,
# 之後回讀兩段星曆,航跡/直線都是 1.000 倍,
# 高度由 71.1 km 降到 3.0 km(對照機 67.1 -> 3.0)。
# **ACK 不等於做對了** —— 所以每次都要回讀,這是本章反覆強調的習慣。
# ----------------------------------------------------------------------------
# 執行
# 在 STK 命令列:
# ConFile / "F:/GitHub/Smallsat/spaceport_suborbital/spaceport_suborbital_Connect.txt"
# 注意 ConFile 是非同步的:立刻 ACK,檔案在背景才跑完。
#
# 完整場景(含 Connect 做不到的部分):
# python spaceport_build.py --force
# python spaceport_verify.py # 39 項回歸檢查
# ============================================================================
# ---- 1. 場景 ----------------------------------------------------------------
New / Scenario TWSP_Suborbital
SetAnalysisTimePeriod * "1 Oct 2026 02:00:00.000" "1 Oct 2026 08:00:00.000"
SetUnits / km
Animate * Reset
# 不用地形:本章的高度尺度是 3–106 km,地形起伏(最多 4 km)對彈道與
# 滑翔沒有影響,只會讓每次計算變慢。
# **而且 Facility 的 UseTerrain 預設是開的,會靜默覆寫你設的高度** ——
# 設 0 / 100 / 9.8 全部回讀成 -19.26 m。這是本章踩過的坑。
Terrain * TerrainServer UseTerrainForAnalysis No
# ---- 2. 場址 ----------------------------------------------------------------
# 旭海:TASA 的探空火箭發射場。用 Place 而不是 Facility ——
# 它是地表的一個地點,不是通訊站。
New / */Place Xuhai
SetPosition */Place/Xuhai Geodetic 22.0620 120.9020 0.020
# Ellington Field (KEFD),2015 年取得 FAA 太空港執照。
# 跑道 17R/35L:2,744 x 46 m,場高 9.8 m。由北向南落 RWY 17R。
New / */Facility HoustonSpaceport
SetPosition */Facility/HoustonSpaceport Geodetic 29.6073 -95.1588 0.0098
# ---- 3. 助推真空弧(Missile)-------------------------------------------------
# **Connect 只建得出物件,建不了彈道。**
# SetPropagator / SetLaunchPosition / SetImpactPosition / SetState
# 四種形式全部 NACK(實測)。彈道傳播器的發射點、落點與
# 「固定遠地點」控制方式只能用物件模型設定 —— 見附錄 A 第 1 條。
New / */Missile TWSP_Ballistic
# 供對照:物件模型要設成的值
# 發射 22.0620 N 120.9020 E 20.0 m
# 落點 48.2448 151.3272 78,486 m (= 星曆起點)
# 控制 eLaunchControlFixedApogeeAlt,遠地點 117,048 m
# ---- 4. 高超音速滑翔(外部星曆)---------------------------------------------
# **這一段可以完全用 Connect 完成。**
# SetState ... FromFile 吃得下 STK 的 .e 星曆檔;實測回讀 RouteType = 6
# (ePropagatorStkExternal),航跡/直線 = 1.000 倍。
#
# 為什麼用星曆而不是 Aviator:同一條航線交給 Aviator 飛,地面軌跡是
# 直線距離的 **5.32 倍** —— 載具在繞圈,而且當時 32 項驗證全部通過。
# 根因是 Aviator 的航段求解器碰到高度變化就多繞一圈,調 AltitudeRate 無效。
#
# 星曆檔本身由 spaceport_build.write_ephemeris() 產生(Fixed 座標,
# 位置 + 速度),**內容是解析解算出來的,不是 STK 算的**。
New / */Aircraft TWSP_Glide
SetState */Aircraft/TWSP_Glide FromFile "F:/GitHub/Smallsat/spaceport_suborbital/twsp_glide.e"
VO */Aircraft/TWSP_Glide Model File "F:/GitHub/Smallsat/spaceport_suborbital/model/twsp2_spaceplane_ap.glb"
# ---- 5. 終端進場與降落(Aviator)--------------------------------------------
# **Connect 設得了傳播器,設不了任務內容。**
# SetPropagator ... Aviator 是 ACK 的,但 Aviator 的 Mission / Phase /
# Procedure 沒有對應的 Connect 指令(三種形式實測皆 NACK)。
# 見附錄 A 第 2 條。
New / */Aircraft TWSP_Spaceplane_LD1p74
SetPropagator */Aircraft/TWSP_Spaceplane_LD1p74 Aviator
VO */Aircraft/TWSP_Spaceplane_LD1p74 Model File "F:/GitHub/Smallsat/spaceport_suborbital/model/twsp2_spaceplane_ap.glb"
# 供對照:Aviator 任務要設成的內容(只有兩個程序)
# 1. 進場介面 3,000 m、130 m/s,接在星曆終點
# 2. 降落 KEFD RWY 17R,下滑道 8 度,真方位 179 度
# 中間**不放航點** —— 多放一個就會踩到「高度變化所需段長」而繞圈。
# ---- 6. 對照機:升阻比夠高時 Aviator 可獨力飛完 ----------------------------
# L/D 5.6235 時弧終點速度降到 5,400 m/s,
# 恰好等於 Aviator 的求解上限 5,400 m/s(還差 0 m/s)。
# 兩條航跡放在同一個視窗裡,就能看出工具邊界是怎麼變成設計指標的。
New / */Aircraft TWSP_Glide_LD5p6235
SetState */Aircraft/TWSP_Glide_LD5p6235 FromFile "F:/GitHub/Smallsat/spaceport_suborbital/twsp_glide_seamfree.e"
# ---- 7. 視角與動畫 ----------------------------------------------------------
VO * ViewFromTo Normal From */Place/Xuhai
Animate * Reset
# ============================================================================
# 附錄 A:Connect 做不到的三件事
# ============================================================================
#
# 1. Missile 的彈道傳播器
# ----------------------------------------
# 實測 NACK:SetPropagator / SetLaunchPosition / SetImpactPosition
# / SetState ... Ballistic
# 要設的是「發射點 → 落點 + 遠地點高度」這組三選二的約束,
# Connect 沒有對應語法。物件模型的做法:
#
# m.SetPropagatorType(AgEVePropagatorType.ePropagatorBallistic)
# traj = m.Propagator.Trajectory
# traj.Launch / traj.ImpactLocation.Impact 設經緯高
# traj.ImpactLocation.SetLaunchControlType(
# AgEVeLaunchControl.eLaunchControlFixedApogeeAlt)
# traj.ImpactLocation.LaunchControl.ApogeeAlt = <遠地點>
#
# **另外有一個一定要設的東西:LaunchTrueBearing。**
# 它預設是 0(正北),不設會讓載具繞圈。要先算大圓初始方位再寫進去。
# 對應函式:spaceport_build.build_missile()
#
# 2. Aviator 的任務、階段與程序
# ----------------------------------------
# 實測:SetPropagator ... Aviator 是 ACK 的,
# 但 Aviator Mission / AviatorPropagator / SetAviatorMission
# 三種形式全部 NACK。
# Aviator 的整個結構(AvtrMission -> Phases -> Procedures)
# 只在物件模型裡存在。要設的包括:
# * 起降場與跑道(KEFD RWY 17R)
# * 降落程序的下滑道與對齊
# * 效能模型:必須改用 **Built-In** 並設 CeilingAltitude
# (生成的 Advanced Fixed Wing 模型高度上限固定在 13,106 m,
# 而且不可設定 —— 這是本章實測出來的邊界)
# 對應函式:spaceport_build.build_spaceplane()
# spaceport_vehicle.configure_builtin_models()
#
# 3. 3D 模型的『持久化』
# ----------------------------------------
# VO ... Model File 是 ACK 的,但**只對本次 session 有效**:
# 在 GUI 裡重算航路會把它還原成機型自己的模型。
# 要永久生效必須改機型 XML 裡的 Model3D 欄位,
# 那是檔案操作,不是 Connect 或物件模型的事。
# 對應函式:spaceport_build.set_vehicle_model3d()
#
# 順帶一提:STK 13 的機型目錄在 Documents\STK_ODTK 13\,
# **不是** STK 13\ —— 寫死路徑會改到不存在的檔案而以為成功。
#
# 4. 分節分離的動畫與尾焰
# ----------------------------------------
# **尾焰的開關是 Connect 做得到的**:
# VO */Aircraft/<name> VaporTrail Show On <- ACK
# 但只有開關。時間窗、貼圖、半徑、掛點都在物件模型裡:
#
# vt = ac.VO.VaporTrail
# vt.ImageFile = r"STKData\VO\Textureslametex-orange.tga"
# vt.UseAttachPoint = True; vt.AttachPointName = "Flame_Booster1"
# vt.DisplayInterval.SetExplicitInterval(0.0, 182.0)
#
# **時變活動件(火焰大小、分節掉落)Connect 完全做不到。**
# Articulate / VO ... Articulate / VO ... ArticulationFile
# 三種寫法都 NACK,即使活動件名稱是從
# GetAvailableArticulations() 讀出來的正確名稱。
# 唯一的機制是 .sama articulation 檔:
#
# art = ac.VO.Model.Articulation
# art.UseArticulationFile = True
# art.VOArticulationFile.Filename = <path> # 它是物件,不是字串
# art.VOArticulationFile.Reload()
#
# 對應函式:spaceport_stages.build_stages()
# spaceport_stages.write_sama()
#
# ============================================================================
# 附錄 B:實測清單(STK 13 於 2026-09-24;分節那批 STK 12 於 2026-09-25)
# ============================================================================
#
# 15 / 28 條 ACK
#
# [ACK ] New / */Place <name>
# [ACK ] SetPosition */Place/<name> Geodetic <lat> <lon> <alt>
# [ACK ] New / */Facility <name>
# [ACK ] SetPosition */Facility/<name> Geodetic <lat> <lon> <alt>
# [ACK ] VO */Facility/<name> Model File "<path>"
# [ACK ] New / */Missile <name>
# 物件建得出來
# [NACK] SetPropagator */Missile/<name> Ballistic
# Command has failed
# [NACK] SetLaunchPosition */Missile/<name> Geodetic ...
# Command has failed
# [NACK] SetImpactPosition */Missile/<name> Geodetic ...
# Command has failed
# [NACK] SetState */Missile/<name> Ballistic ...
# Command has failed
# [ACK ] New / */Aircraft <name>
# [NACK] SetPropagator */Aircraft/<name> StkExternal
# 但下一行不需要它也能成立
# [ACK ] SetState */Aircraft/<name> FromFile "<file.e>"
# **可用**,回讀 RouteType = 6(StkExternal)
# [ACK ] SetState */Satellite/<name> FromFile "<file.e>"
# [ACK ] SetPropagator */Aircraft/<name> Aviator
# 傳播器設得了
# [NACK] Aviator */Aircraft/<name> Mission ...
# Command has failed
# [NACK] AviatorPropagator */Aircraft/<name> Mission New
# Command has failed
# [NACK] SetAviatorMission */Aircraft/<name> New
# Command has failed
# [ACK ] VO */Aircraft/<name> Model File "<file.glb>"
# [ACK ] VO * ViewFromTo Normal From */Place/<name>
# [ACK ] Animate * Reset
# [ACK ] VO */Aircraft/<name> VaporTrail Show On
# Aircraft 與 Missile 都 ACK —— 尾焰的開關
# [ACK ] VO */Missile/<name> VaporTrail Show On
# [NACK] VO */Aircraft/<name> Trail Show On
# Command has failed —— 沒有 Trail 這個關鍵字
# [NACK] Articulate */Aircraft/<name> <t> <artic> <trans> <v>
# Command has failed,連活動件名稱正確也一樣
# [NACK] VO */Aircraft/<name> Articulate <artic> <trans> <v>
# Command has failed
# [NACK] VO */Aircraft/<name> ArticulationFile "<f.sama>"
# Command has failed —— 時變活動件只能走物件模型
# [NACK] VO */Aircraft/<name> Model Detail
# Command has failed
#
# 實測方法:在現有場景裡建 CT_ 前綴的暫時物件逐條送指令,測完全部卸載,
# 不影響既有場景。ACK 之後還**回讀確認**星曆真的載進去了 ——
# ACK 只代表指令被接受,不代表它做了你以為的事。
spaceport_suborbital/spaceport_vehicle.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 台美太空港計畫 —— 第二節太空飛機的氣動定義(Aviator Advanced Fixed Wing)
#
# 為什麼不用 Basic Airliner
# Aviator 內建的機型目錄裡最接近的是 Basic Airliner,但它的尺寸是客機:
# 機翼 260 m² 襟翼 52 m² 減速板 10.4 m²
# MaxMach 0.95 MaxAltitude 15.2 km MaxAOA 20°
# 用這組參數飛太空飛機,結果不會只是「不精確」——
# 本章開發時已經驗證過一次:借用不適合的效能模型會讓降落程序
# **完全解不出來**,而且 Propagate() 照樣回報成功。
#
# Advanced Fixed Wing 工具可以從幾何生成一整套效能模型:
# AdvFixedWingTool.CreateAllPerfModels(name, overwrite, makeDefault)
# 這同時解掉另一個問題:不再依賴 phase.SetDefaultPerfModels() 去挑
# 「這台電腦的使用者目錄裡剛好有什麼」——模型由本程式的幾何產生。
#
# ------------------------------------------------------------------------------
# 高超音速氣動模式的參數(實測 STK 12 / 13 都有,API 完全對等)
# AeroStrategy = eSubSuperHyperAero
# MaxAOA 最大攻角
# SubsonicAspectRatio 展弦比
# SuperHyperMachTransition 超音速->高超音速的建模轉換點
# TransonicMinMach/MaxMach 穿音速區間
# TransonicMachDragFactor 穿音速阻力因子
# WaveDragFactor 波阻因子
# LeadingEdgeFrontalAreaRatio 前緣正面積比 <- 鈍頭載具的關鍵
# 高超音速模式**沒有** WingSweep —— 它用正面積比與波阻,
# 不是升力線理論那一套。後掠角只在次/超音速模式裡有意義。
#
# ------------------------------------------------------------------------------
# **最重要的一條:mis.Vehicle 是共用的機型目錄條目,不是這架飛機的私有副本**
#
# 直接對 mis.Vehicle 寫參數,改到的是整個機型(例如 "Basic Airliner"),
# 而且**會寫回磁碟**,在使用者設定目錄留下覆蓋檔:
# C:\Users\<user>\Documents\STK 12\Config\Catalog\Aviator\Aircraft\
# Basic Airliner.xml <- 蓋掉出廠版本
# Basic Airliner\<階段>\*.xml <- 生成的效能模型
#
# 後果:影響所有用該機型的飛機、影響日後所有場景,而且沒有任何警告。
# 本專案實測踩過:把 Basic Airliner 改成了太空飛機(機翼 85 m²、Mach 25),
# 之後所有「用客機當對照組」的量測全部無效,自己卻沒察覺。
#
# 這也解釋了另一個謎:上一章遇到一個「不是本程式建立、來源不明」的
# 自訂效能模型 UAV_Perf,它讓降落完全解不出來。那極可能就是某次
# 類似操作留下的殘留 —— **污染會跨場景、跨專案地留下來**。
#
# 正確流程(AGI 的教材也是這樣教):
# base = catalog.GetAircraft("Basic Fighter")
# dup = base.Duplicate() # 複製
# dup.Name = "<自己的名字>" # 另存
# mission.Vehicle = dup # 指派
# ... 改參數 ... # 只改自己的副本
# CreateAllPerfModels(...) # 依新參數重建效能模型
#
# 並且:**產生的東西要自己負責清掉**(見 remove_catalog_model)。
# 否則下一次執行會踩到上一次的殘留。
#
# **警告:接受這個值 ≠ 飛得出來。**
# 實測 MaxMach 可以寫到 30、MaxAltitude 可以寫到 200 km,回讀都正確。
# 但那只代表屬性接受了數字,不代表 Aviator 解得出 Mach 22 的滑翔。
# 要判定「飛得出來」只有一個辦法:傳播,然後問 AvtrMission.IsValid。
# ==============================================================================
import math
# ── 設計點 ───────────────────────────────────────────────────────────────────
# 尺寸取在 Dream Chaser(長 9 m、翼展 7 m)與太空梭軌道器(37 m / 23.8 m)之間,
# 對應一架載人/載貨的點對點次軌道第二節。
SPACEPLANE = {
"name": "TWSP2_Spaceplane",
"length_m": 18.0,
"span_m": 11.0,
# 工具層
"wing_area_m2": 85.0,
"flaps_frac": 0.15, # 襟翼佔機翼面積 —— 本章的掃描參數
"speedbrakes_frac": 0.05,
"max_mach": 25.0,
"max_altitude_m": 130_000.0,
"max_load_factor": 3.0, # 載人
"min_load_factor": -1.0,
# 高超音速氣動
"max_aoa_deg": 40.0, # 太空梭再入時的攻角就是 40°
"wave_drag_factor": 1.0,
"le_frontal_ratio": 0.03, # 鈍前緣;客機預設 0.01 是尖前緣
"super_hyper_mach": 4.0,
# 平衡滑翔的巡航高度,以及爬升/下降的高度積分步長。
# 步長預設 304.8 m,用在 60 km 的跨距上會切成兩百段 —— 慢且易不收斂。
"glide_alt_m": 60_000.0,
"alt_step_m": 3_000.0,
}
# 對照組:Aviator 內建客機的實測預設值(用來說明差距)
AIRLINER_DEFAULTS = {
"wing_area_m2": 260.13,
"flaps_area_m2": 52.03,
"speedbrakes_m2": 10.41,
"max_mach": 0.95,
"max_altitude_m": 15240.0,
"max_aoa_deg": 20.0,
"le_frontal_ratio": 0.01,
"aspect_ratio": 4.0,
}
def derived(cfg=None):
"""由設計點推出衍生量。"""
c = dict(cfg or SPACEPLANE)
c["aspect_ratio"] = c["span_m"] ** 2 / c["wing_area_m2"]
c["flaps_area_m2"] = c["wing_area_m2"] * c["flaps_frac"]
c["speedbrakes_area_m2"] = c["wing_area_m2"] * c["speedbrakes_frac"]
return c
def describe(cfg=None):
"""列出設計點,並和客機預設值對照。"""
c = derived(cfg)
a = AIRLINER_DEFAULTS
rows = [
("機翼面積 m²", c["wing_area_m2"], a["wing_area_m2"]),
("襟翼面積 m²", c["flaps_area_m2"], a["flaps_area_m2"]),
("減速板面積 m²", c["speedbrakes_area_m2"], a["speedbrakes_m2"]),
("展弦比", c["aspect_ratio"], a["aspect_ratio"]),
("最大 Mach", c["max_mach"], a["max_mach"]),
("最大高度 km", c["max_altitude_m"]/1000, a["max_altitude_m"]/1000),
("最大攻角 °", c["max_aoa_deg"], a["max_aoa_deg"]),
("前緣正面積比", c["le_frontal_ratio"], a["le_frontal_ratio"]),
]
print(f" {'項目':<16}{'太空飛機':>12}{'客機預設':>12}{'倍率':>10}")
for label, sp, al in rows:
r = sp / al if al else float("nan")
print(f" {label:<16}{sp:12.2f}{al:12.2f}{r:9.2f}x")
print(f"\n 全長 {c['length_m']:.1f} m,翼展 {c['span_m']:.1f} m,"
f"襟翼佔機翼 {c['flaps_frac']:.0%}")
return c
# 以哪一個出廠機型為基礎。實測 Basic Fighter 與 Basic Airliner 的工具層
# 出廠值相同(MaxMach 0.95 / MaxAltitude 15,240 m)—— 戰機**不是**天生高升限,
# 那些是設定值不是物理特性。選 Fighter 是因為它的氣動策略是超音速
# (AeroStrategy=2 vs 客機也是 2),且低展弦比、小機翼更接近太空飛機。
BASE_AIRCRAFT = "Basic Fighter"
def catalog_models(avtr_prop):
"""取得機型目錄的 AircraftModels 集合。"""
return avtr_prop.AvtrCatalog.AircraftCategory.AircraftModels
def remove_catalog_model(avtr_prop, name=None, verbose=True):
r"""把自建的機型從目錄移除。
**產生污染的程式要自己負責清掉。** 不清的話會在使用者的
Config\Catalog 留下覆蓋檔,跨場景、跨專案地影響後續執行。
注意:機型正在被某架飛機使用時會拒絕移除
("Item is currently in use."),要先把那架飛機的 Vehicle 換掉或卸載。
"""
nm = name or SPACEPLANE["name"]
am = catalog_models(avtr_prop)
if nm not in (am.ChildNames or []):
if verbose:
print(f" 目錄裡沒有「{nm}」,不需移除")
return True
try:
am.RemoveChild(nm)
gone = nm not in (am.ChildNames or [])
if verbose:
print(f" 移除「{nm}」{'✔' if gone else '—— 但仍在目錄裡 ✘'}")
return gone
except Exception as e:
if verbose:
print(f" 移除「{nm}」失敗:{str(e)[:70]}")
print(f" (機型被使用中時無法移除,要先卸載引用它的飛機)")
return False
def make_private_vehicle_from_prop(root, avtr_prop, mission,
base=BASE_AIRCRAFT, name=None,
verbose=True):
"""複製出廠機型 -> 改名 -> 指派給任務。回傳 (機體, 名稱) 或 (None, None)。"""
nm = name or SPACEPLANE["name"]
am = catalog_models(avtr_prop)
# 已存在的同名機型先清掉,確保每次都從出廠狀態複製 ——
# 否則會疊在上一次的修改上,而那正是污染累積的方式。
if nm in (am.ChildNames or []):
remove_catalog_model(avtr_prop, nm, verbose=verbose)
try:
src = am.GetAircraft(base)
except Exception as e:
print(f" [FATAL] 取不到基礎機型「{base}」:{str(e)[:60]}")
return None, None
before = set(am.ChildNames or [])
try:
dup = src.Duplicate()
except Exception as e:
print(f" [FATAL] Duplicate 失敗:{str(e)[:70]}")
return None, None
added = sorted(set(am.ChildNames or []) - before)
if verbose:
print(f" Duplicate('{base}') -> {added or [dup.Name]}")
try:
dup.Name = nm
except Exception as e:
nm = dup.Name
if verbose:
print(f" 改名失敗,沿用「{nm}」:{str(e)[:50]}")
try:
mission.Vehicle = am.GetAircraft(nm)
except Exception as e:
print(f" [FATAL] 指派 mission.Vehicle 失敗:{str(e)[:70]}")
return None, None
veh = mission.Vehicle
if veh.Name != nm:
print(f" [FATAL] 指派後回讀是「{veh.Name}」,不是「{nm}」")
return None, None
if verbose:
print(f" mission.Vehicle = {veh.Name} ✔(私有副本,不影響出廠機型)")
return veh, nm
# ══════════════════════════════════════════════════════════════════════════════
def apply_to_vehicle(root, veh, cfg=None, verbose=True):
"""把設計點寫進 AdvFixedWingTool,並**逐項回讀確認**。
**veh 必須是 make_private_vehicle_from_prop() 產生的私有副本。**
直接傳 mission.Vehicle(出廠機型)會污染使用者的 STK 安裝 —— 見檔頭。
回讀不是形式 —— 今天(本專案)已經抓到三個「寫得進去、回讀正確、
但意義不同或不生效」的例子。這裡的回讀只能證明「值進去了」,
不能證明「飛得出來」;後者要靠 IsValid。
"""
from stk_binding import AgEAvtrAdvFixedWingAeroStrategy
c = derived(cfg)
# 單位先釘死,否則寫進去的數字意義不明(本專案踩過三次)
for dim, unit in (("Distance", "m"), ("Angle", "deg")):
try:
root.UnitPreferences.SetCurrentUnit(dim, unit)
except Exception:
pass
t = veh.AdvFixedWingTool
t.AeroStrategy = AgEAvtrAdvFixedWingAeroStrategy.eSubSuperHyperAero
wrote = {}
for attr, val in (("WingArea", c["wing_area_m2"]),
("FlapsArea", c["flaps_area_m2"]),
("SpeedbrakesArea", c["speedbrakes_area_m2"]),
("MaxMach", c["max_mach"]),
("MaxAltitude", c["max_altitude_m"]),
("MaxLoadFactor", c["max_load_factor"]),
("MinLoadFactor", c["min_load_factor"])):
try:
setattr(t, attr, float(val))
wrote[attr] = (val, getattr(t, attr))
except Exception as e:
wrote[attr] = (val, f"ERR {str(e)[:40]}")
aero = t.AeroModeAsSubSuperHypersonic
for attr, val in (("MaxAOA", c["max_aoa_deg"]),
("SubsonicAspectRatio", c["aspect_ratio"]),
("WaveDragFactor", c["wave_drag_factor"]),
("LeadingEdgeFrontalAreaRatio", c["le_frontal_ratio"]),
("SuperHyperMachTransition", c["super_hyper_mach"])):
try:
setattr(aero, attr, float(val))
wrote[attr] = (val, getattr(aero, attr))
except Exception as e:
wrote[attr] = (val, f"ERR {str(e)[:40]}")
bad = []
for k, (want, got) in wrote.items():
ok = isinstance(got, (int, float)) and abs(got - want) <= max(
1e-6, abs(want) * 1e-6)
if not ok:
bad.append((k, want, got))
if verbose:
mark = "✔" if ok else "✘"
print(f" {mark} {k:<28} 寫 {want:<12.4g} 讀 {got}")
if bad:
print(f" [FATAL] {len(bad)} 項寫入後回讀不符:"
f"{[b[0] for b in bad]}")
return c, wrote, (not bad)
def create_perf_models(veh, name=None, overwrite=True, make_default=True,
verbose=True):
"""由幾何生成整套效能模型。
這一步取代 phase.SetDefaultPerfModels() —— 後者挑的是使用者目錄下
剛好存在的自訂模型,換一台電腦結果就不同,而且挑到不適合的模型會讓
降落與高度設定**完全失效且不報錯**(本專案實測)。
"""
nm = name or SPACEPLANE["name"]
before = set(veh.ChildNames or [])
veh.AdvFixedWingTool.CreateAllPerfModels(nm, bool(overwrite),
bool(make_default))
after = set(veh.ChildNames or [])
if verbose:
print(f" CreateAllPerfModels('{nm}') ✔")
print(f" 機體下的模型類別:{sorted(after)}")
return nm
def set_model_limits(phase, cfg=None, verbose=True):
"""把生成模型自己的高度上限改掉。
**這是本章最貴的一個坑,實測診斷如下。**
工具層設 MaxAltitude = 130,000 m,回讀正確,生成模型之後也還是
130,000 m。但 CreateAllPerfModels 生出來的各類模型**帶著自己的**
高度上限,而且沒有從 MaxAltitude 繼承:
Climb AltitudeLimit = 3,048 m (10,000 ft)
Cruise AltitudeLimit = 3,048 m DefaultCruiseAltitude = 10,058 m
Descent AltitudeLimit = 3,048 m
後果是:航點高度全部寫得進去(60/55/45/35 km 逐項回讀相符 ✔),
但飛機被壓在 **11.9 km**,而要求的 Mach 22 仍然照算 ——
動壓因此變成 **6.66 MPa**(Mach 22 在 60 km 應約 5–15 kPa,差四百倍)。
那是一個物理上不成立的狀態,所以 BasicPointToPoint 解不出來。
**無效的原因是高度被模型上限擋住,不是 Aviator 飛不了高超音速。**
高超音速氣動本身是好的:實測飛到 Mach 21.96(要求 22)。
這一類「看起來該連動、其實各自獨立」的參數在本專案出現過多次
(例如同一個 Landing 物件上 Site.Length 與 ApproachFixRange 吃的是
不同的單位維度)。共同的處理原則:**逐項明確設定並回讀,
不要假設上層的設定會往下傳。**
"""
c = derived(cfg)
lim = c["max_altitude_m"]
cruise_alt = c.get("glide_alt_m", 60_000.0)
# 爬升/下降的高度積分步長。預設 304.8 m 用在 60 km 的跨距上會切成
# 兩百段,慢且容易不收斂;放大到 3 km。
delta = c.get("alt_step_m", 3_000.0)
wrote, bad = {}, []
for kind in PHASE_PERF_TYPES:
try:
pm = phase.GetPerformanceModelByType(kind)
props = pm.Properties
except Exception as e:
wrote[kind] = f"取不到 ({str(e)[:35]})"
continue
for attr, val in (("AltitudeLimit", lim),
("DefaultCruiseAltitude", cruise_alt),
("ComputeDeltaAltitude", delta)):
if not hasattr(props, attr):
continue
try:
setattr(props, attr, float(val))
got = getattr(props, attr)
ok = abs(got - val) <= max(1e-6, abs(val) * 1e-6)
wrote[f"{kind}.{attr}"] = (val, got, ok)
if not ok:
bad.append(f"{kind}.{attr}")
except Exception as e:
wrote[f"{kind}.{attr}"] = (val, f"ERR {str(e)[:35]}", False)
bad.append(f"{kind}.{attr}")
if verbose:
for k, v in sorted(wrote.items()):
if isinstance(v, tuple):
want, got, ok = v
mark = "✔" if ok else "✘"
gs = f"{got:,.1f}" if isinstance(got, (int, float)) else got
print(f" {mark} {k:<38} 寫 {want:>10,.0f} 讀 {gs}")
else:
print(f" - {k:<38} {v}")
if bad:
print(f" [FATAL] {len(bad)} 項高度上限沒設成功:{bad}")
print(f" 飛機會被壓在預設上限,高超音速航段解不出來。")
return wrote, (not bad)
# 需要掛到 phase 上的類別。TerrainFollow 不含 —— 太空飛機不做貼地飛行,
# 而且實測 phase 上本來就沒有這一類(「There is no performance model
# for this category」)。
PHASE_PERF_TYPES = ("Takeoff", "Climb", "Cruise", "Descent",
"Landing", "Acceleration")
def link_phase_to_models(phase, name=None, verbose=True):
"""把 phase 的各類效能模型明確指向生成的模型,並回讀確認。
**為什麼不能只靠 CreateAllPerfModels 的 makeDefault=True。**
觀察到的事實(兩者都實測過):
* 全新的 STK session、catalog 裡沒有這個模型時,
CreateAllPerfModels(..., makeDefault=True) 之後 phase 仍然顯示
`Built-In Model` —— 模型建出來了,但沒有掛上去。
* 同一個 STK session 的後續執行(catalog 已在記憶體裡),
phase 顯示的就是我們的模型。
**機制未確定 —— 這是標示出來的無知,不是結論。**
無法分辨是「makeDefault 不回溯綁定已存在的 phase」還是
「要等 catalog 重讀才生效」。想確認需要冷啟動 STK;而
**刪掉磁碟上的 catalog XML 不算冷啟動** —— STK 把模型快取在記憶體,
刪檔之後 phase 照樣顯示該模型(本專案試過,那次測試因此無效)。
不論機制是哪一個,明確 LinkToCatalog + 回讀都能得到確定的結果,
而且不依賴執行歷史。所以本函式無條件執行,不做條件判斷。
這和本專案另一個坑是同一型:phase.SetDefaultPerfModels() 會挑使用者
目錄裡剛好存在的自訂模型。兩者的結論相同 ——
**效能模型一定要明確指定並回讀,不要相信任何「預設」。**
"""
nm = name or SPACEPLANE["name"]
out, bad = {}, []
for kind in PHASE_PERF_TYPES:
try:
pm = phase.GetPerformanceModelByType(kind)
except Exception as e:
out[kind] = f"取不到 ({str(e)[:35]})"
bad.append(kind)
continue
try:
pm.LinkToCatalog(nm)
except Exception as e:
out[kind] = f"連結失敗 ({str(e)[:35]})"
bad.append(kind)
continue
got = None
try:
got = pm.Name
except Exception:
pass
out[kind] = got
if got != nm:
bad.append(kind)
if verbose:
for kind in PHASE_PERF_TYPES:
mark = "✔" if out.get(kind) == nm else "✘"
print(f" {mark} {kind:<14} -> {out.get(kind)}")
if bad:
print(f" [FATAL] {len(bad)} 類沒掛上「{nm}」:{bad}")
print(f" 太空飛機的氣動定義不會生效。")
return out, (not bad)
# ══════════════════════════════════════════════════════════════════════════════
# 實際採用的做法:**Built-In 模型 + 拉高 CeilingAltitude**
#
# 上面 create_perf_models() / set_model_limits() 那條路是量測過的死路,
# 保留下來是因為它記錄了為什麼不能走。以下是證據,不是判斷。
#
# ── 高度上限的真正來源 ────────────────────────────────────────────────────
# 出廠機型的 XML(C:\Program Files\AGI\STK 12\STKData\Aviator\Aircraft\)
# 裡有一個參數 CeilingAlt,它**不對應**任何一個 API 屬性:
#
# 機型 XML CeilingAlt 實測飛到
# Basic Airliner 12,801.6 m 12,802 m
# Basic Fighter 15,240.0 m 15,240 m
#
# 兩次都是**精確**相符。物件模型上的名字是 CeilingAltitude
# (XML 叫 CeilingAlt —— 名字不同,這是先前 hasattr 檢查一直跳過它的原因)。
#
# ── 為什麼不用 CreateAllPerfModels 生成的模型 ─────────────────────────────
# 生成的模型**沒有 CeilingAltitude 屬性**,卻被硬壓在 13,106 m。試過:
# DefaultCruiseAltitude 設 20/30/60/100 km(回讀全部正確)-> 一律 13,106 m
# AltitudeLimit 寫入靜默失敗(回讀恆為 3,048 m)
# LaunchAltitude 直接設 60 km -> 仍被夾到 13,106
# 翼面積 85 -> 680 -> 2000 m2 -> 13,106 / 10,668 / 10,668
# 也就是說那個上限既不暴露也改不動。**該換的是模型種類,不是參數值。**
#
# ── 換成 Built-In 之後的實測 ──────────────────────────────────────────────
# 要求 20 / 30 / 60 / 100 km -> 實際 20,000 / 30,000 / 60,000 / 100,000 m
# 全部 IsValid=True(Mach 8)
# 60 km -> 3 km 的完整再入下滑剖面,跨 13,109 km 大圓,IsValid=True
#
# ── 代價:真空速上限約 5.4 km/s ────────────────────────────────────────────
# 這是 Built-In 路線唯一的硬限制,而且**與 Mach 和高度都無關**:
#
# 60 km / Mach 18 -> IsValid True 真空速 10,494 kt = 5,398 m/s
# 60 km / Mach 19 -> IsValid False 真空速 11,528 kt = 5,930 m/s
# 80 km / Mach 20 -> IsValid True 真空速 10,422 kt = 5,361 m/s
# 100 km / Mach 20 -> IsValid True 真空速 10,408 kt = 5,354 m/s
#
# 三個成立的案例緊貼 5.36–5.40 km/s,失敗的那個是 5.93 km/s。
# (真空速的單位是節,由內部一致性確認:10,494 / 17.13 = 612.6,
# 612.6 x 0.5144 = 315 m/s,正是 60 km 的音速。)
#
# 後果:彈道分析要的 v_bo = 6.65 km/s、再入 6.8–7.8 km/s **超出 Aviator**。
# 所以本章是混合架構,而這是被量測逼出來的,不是選擇:
# Missile 物件 助推 + 彈道弧 + 大氣層外滑行(6.65–7.4 km/s)
# Aviator 真空速降到 5.4 km/s 之後接手 -> 滑翔 -> 降落
# 7.4 -> 5.4 km/s 的減速帶是兩個模型的**接縫**,兩者都不模擬它
# (Missile 是真空彈道,Aviator 上不去),由 spaceport_analysis.py 定量補上。
# 這個接縫要在教材裡明講,不能藏。
# ══════════════════════════════════════════════════════════════════════════════
# CeilingAltitude 要設多高。設計上限 130 km,實測 100 km 可用。
BUILTIN_CEILING_M = 130_000.0
# Built-In 的速度是查表的,不是解氣動,所以上限要一起放寬。
# **排序必須維持 Min < Endurance < Range < Perf < Max。**
# 實測:把它們全設成同一個值,四組測試全部 IsValid=False ——
# 不是速度不夠,是把模型的內部排序關係弄壞了,而且不報錯。
#
# 單位是節(AviatorSpeedUnit 實測為 nm/hr)。20,000 kt = 10.3 km/s,
# 遠高於 5.4 km/s 的實際天花板 —— 放寬這裡不會提高天花板
# (8,000 / 20,000 / 50,000 kt 三組得到完全相同的結果),
# 目的只是確保不是**這裡**在夾。
BUILTIN_MAX_AIRSPEED_KT = 20_000.0
AIRSPEED_ORDER = (("MinAirspeed", 0.01), ("MaxEnduranceAirspeed", 0.30),
("MaxRangeAirspeed", 0.60), ("Airspeed", 0.60),
("MaxPerfAirspeed", 0.85), ("MaxAirspeed", 1.00))
# 爬升/下降率,單位同上(節)。出廠 Fighter 是 101.6 m/s = 20,000 ft/min;
# 60 km 的跨距用那個值要十分鐘以上,放大到 12,000 kt 量級。
BUILTIN_ALT_RATE_KT = 12_000.0
# Aviator 真空速上限的實測值。超過這個值 BasicPointToPoint 解不出來。
AVIATOR_TAS_LIMIT_MS = 5_400.0
def configure_builtin_models(phase, ceiling_m=None, max_airspeed_kt=None,
verbose=True):
"""把 phase 的 Built-In 效能模型放寬到太空飛機用得上的範圍。
**前提:不要呼叫 create_perf_models()。** 一旦生成 AdvFixedWing 模型,
phase 就掛到沒有 CeilingAltitude 的模型上,高度會被鎖在 13,106 m
(見上方的量測記錄)。
回傳 (逐項寫入回讀表, 全部成功)。
**CeilingAltitude 沒寫成功就是致命的** —— 高度會靜默地被夾掉,
而 Propagate() 仍然回報成功、IsValid 仍然是 True,只是飛錯高度。
"""
ceil = float(ceiling_m if ceiling_m is not None else BUILTIN_CEILING_M)
mx = float(max_airspeed_kt if max_airspeed_kt is not None
else BUILTIN_MAX_AIRSPEED_KT)
wrote, fatal = {}, []
for kind in PHASE_PERF_TYPES:
try:
pm = phase.GetPerformanceModelByType(kind)
props = pm.Properties
except Exception as e:
wrote[kind] = f"取不到 ({str(e)[:35]})"
continue
if pm.Name != "Built-In Model":
wrote[f"{kind}.<模型>"] = (f"應為 Built-In Model,實際「{pm.Name}」")
fatal.append(f"{kind}.<模型>")
# CeilingAltitude 只存在於 Climb / Cruise / Descent。
# 不存在的類別不算失敗 —— 但存在卻寫不進去就是失敗。
if hasattr(props, "CeilingAltitude"):
try:
props.CeilingAltitude = ceil
got = props.CeilingAltitude
ok = abs(got - ceil) <= 1.0
wrote[f"{kind}.CeilingAltitude"] = (ceil, got, ok)
if not ok:
fatal.append(f"{kind}.CeilingAltitude")
except Exception as e:
wrote[f"{kind}.CeilingAltitude"] = (ceil, f"ERR {str(e)[:30]}",
False)
fatal.append(f"{kind}.CeilingAltitude")
for attr, frac in AIRSPEED_ORDER:
if not hasattr(props, attr):
continue
want = mx * frac
try:
setattr(props, attr, want)
got = getattr(props, attr)
wrote[f"{kind}.{attr}"] = (want, got,
abs(got - want) <= max(1.0, want * 1e-6))
except Exception as e:
wrote[f"{kind}.{attr}"] = (want, f"ERR {str(e)[:30]}", False)
if hasattr(props, "AltitudeRate"):
want = BUILTIN_ALT_RATE_KT if kind == "Climb" else -BUILTIN_ALT_RATE_KT
try:
props.AltitudeRate = want
wrote[f"{kind}.AltitudeRate"] = (want, props.AltitudeRate, True)
except Exception as e:
wrote[f"{kind}.AltitudeRate"] = (want, f"ERR {str(e)[:30]}", False)
if verbose:
for k in sorted(wrote):
v = wrote[k]
if isinstance(v, tuple) and len(v) == 3:
want, got, ok = v
gs = f"{got:,.1f}" if isinstance(got, (int, float)) else str(got)
print(f" {'OK ' if ok else 'BAD'} {k:<34} 寫 {want:>12,.1f} 讀 {gs}")
else:
print(f" - {k:<34} {v}")
if fatal:
print(f" [FATAL] {len(fatal)} 項關鍵設定失敗:{fatal}")
print(f" 高度會被靜默夾掉,而 IsValid 仍會是 True。")
return wrote, (not fatal)
def mach_for_tas(tas_ms, speed_of_sound_ms):
"""真空速換 Mach。Aviator 的速度欄位吃 Mach,而限制在真空速上,
所以指令值要自己換算,不能直接給 Mach 了事。"""
return float(tas_ms) / float(speed_of_sound_ms)
def tas_within_limit(tas_ms):
"""這個真空速 Aviator 解得出來嗎。實測上限 5.4 km/s,見檔尾說明。"""
return float(tas_ms) <= AVIATOR_TAS_LIMIT_MS
spaceport_suborbital/spaceport_verify.py
# -*- coding: utf-8 -*-
"""台美太空港次軌道任務 —— 場景驗證。
## 這個檔案在驗什麼
**不驗設定值,驗 STK 實算出來的結果。** 本專案反覆踩到的一類錯誤是
「寫進去、讀回來、相符,但根本沒生效」:
* AltitudeAGL 寫入一直失敗,而 STK 預設剛好等於想要的值,完美遮住
* Site.Length 設 2442 被當成 2442 千呎 = 744 km 的跑道,回讀還是 2442
* Facility 的 UseTerrain 預設 True,設 0 / 100 / 9.8 回讀都是 -19.26 m
* CreateAllPerfModels 生成的模型把高度鎖在 13,106 m,而 IsValid 仍是 True
* 最小能量彈道的每個數字都對,組合起來卻是遠地點 1,130 km 的不可飛軌跡
最後一項是本章最貴的一課:**數值檢查不會抓到「形狀不對」。**
所以第三節加了一項遠地點上界檢查 —— 助推滑翔的遠地點應該在卡門線附近,
超過 200 km 就表示又退回拋高彈道了。
所以每一項都盡量從 data provider 取實際軌跡,而不是回讀屬性。
取不到就記為失敗,**不用設定值代替** —— 那樣做只是把檢查變成同義反覆。
## 也驗有沒有污染使用者的 STK
本專案曾經誤改共用機型 Basic Airliner,在使用者的 Config\\Catalog 留下
覆蓋檔,讓之後所有用該機型的量測失效。所以最後一節檢查出廠機型
Basic Fighter 是否仍是出廠值。
"""
import io
import math
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import spaceport_analysis as A
import spaceport_build as B
import spaceport_vehicle as V
# 出廠 Basic Fighter 的值,用來偵測污染。
# 來源:C:\\Program Files\\AGI\\STK 12\\STKData\\Aviator\\Aircraft\\Basic Fighter.xml
FIGHTER_FACTORY = {"CeilingAlt_m": 15_240.0, "DefaultCruiseAltitude_m": 9_144.0}
TOL_ALT_M = 50.0 # 高度容差:滑翔剖面是逐點指令,不要求逐點精確
TOL_SITE_M = 1.0
TOL_POS_KM = 30.0 # 交接點/落點的位置容差
# 檢查的**種類**。分開列的理由:「35/35 通過」很容易被讀成
# 「這個任務已經驗證可行」,但這裡每一項都只是回歸檢查。
# 場景建得出來、幾何連得上,跟載具做不做得出來是不同層級的問題。
KIND_LABEL = {
"scene": "場景建構 物件存在、參數寫得進去、回讀一致",
"geom": "幾何與連續性 軌跡形狀合理、段與段接得上",
"model": "模型適用性 數值落在模型成立的範圍內",
"tool": "工具邊界 STK 物件的能力限制",
"hygiene": "環境衛生 沒有污染使用者的 STK 安裝",
}
class Check:
"""逐項檢查與計數。失敗要能看出「量到什麼」而不只是「不通過」。
每一項都帶一個**種類**,由 section() 設定。報告依種類分開列,
避免把回歸檢查的通過數讀成工程可行性的結論。
"""
def __init__(self):
self.rows = []
self.kind = "scene"
def section(self, title, kind):
"""開一節。之後 add() 的項目都歸到這一類。"""
assert kind in KIND_LABEL, kind
self.kind = kind
print()
print(title)
def add(self, ok, name, detail=""):
self.rows.append((bool(ok), name, detail, self.kind))
print(f" [{'OK ' if ok else 'FAIL'}] {name}"
+ (f" {detail}" if detail else ""))
return bool(ok)
def note(self, name, detail=""):
"""只記錄、不計分。用於**已知且已說明的模型層級落差** ——
把它算成失敗會讓人想去放寬門檻;完全不印又會讓它被遺忘。"""
print(f" [--- ] {name}" + (f" {detail}" if detail else ""))
def skip(self, name, why):
"""量不到就算失敗 —— 不用設定值代替,那樣是同義反覆。"""
return self.add(False, name, f"量不到:{why}")
@property
def passed(self):
return sum(1 for ok, _n, _d, _k in self.rows if ok)
@property
def total(self):
return len(self.rows)
def report(self):
print()
print("=" * 78)
print(" 驗證結果(依種類分列)")
print("=" * 78)
for kind, label in KIND_LABEL.items():
rs = [r for r in self.rows if r[3] == kind]
if not rs:
continue
ok_n = sum(1 for r in rs if r[0])
print(f" {ok_n:>2}/{len(rs):<2} {label}")
print("-" * 78)
print(f" {self.passed}/{self.total} 項通過")
bad = [n for ok, n, _d, _k in self.rows if not ok]
if bad:
print(f" 未通過:{bad}")
print()
print(" **這是回歸檢查,不是工程可行性驗證。** 全部通過只代表:")
print(" 場景物件建得出來、參數回讀一致、軌跡形狀與段間連續性合理。")
print(" 它**不**代表載具做得出來、上升段已最佳化、熱防護與控制可行,")
print(" 也不代表外部星曆段等同於真實氣動飛行 —— 星曆是**播放**,")
print(" 不是動力學求解。")
print("=" * 78)
return self.passed == self.total
def get_child(sc, name):
for ch in list(sc.Children):
if ch.InstanceName == name:
return ch
return None
def lla_track(obj, sc, step=30.0):
"""取物件的 LLA 軌跡。回傳 [(t, lat, lon, alt)] 或 None。"""
try:
dp = obj.DataProviders.Item("LLA State").Group.Item("Fixed")
r = dp.ExecElements(sc.StartTime, sc.StopTime, step,
["Time", "Lat", "Lon", "Alt"])
t = r.DataSets.GetDataSetByName("Time").GetValues()
la = r.DataSets.GetDataSetByName("Lat").GetValues()
lo = r.DataSets.GetDataSetByName("Lon").GetValues()
al = r.DataSets.GetDataSetByName("Alt").GetValues()
out = [(tt, a, b, c) for tt, a, b, c in zip(t, la, lo, al)
if isinstance(a, (int, float)) and isinstance(b, (int, float))
and isinstance(c, (int, float))]
return out or None
except Exception:
return None
def air_track(obj, sc, step=60.0):
"""取飛機的高度/Mach/真空速。回傳 [(alt, mach, tas_kt)] 或 None。"""
try:
dp = obj.DataProviders.Item("Flight Airspeed and Atmosphere")
if hasattr(dp, "Group"):
dp = dp.Group.Item(0)
r = dp.ExecElements(sc.StartTime, sc.StopTime, step,
["MSL Altitude", "Mach #", "True Air Speed"])
al = r.DataSets.GetDataSetByName("MSL Altitude").GetValues()
mm = r.DataSets.GetDataSetByName("Mach #").GetValues()
ta = r.DataSets.GetDataSetByName("True Air Speed").GetValues()
out = [(a, m, t) for a, m, t in zip(al, mm, ta)
if isinstance(a, (int, float)) and isinstance(m, (int, float))
and isinstance(t, (int, float))]
return out or None
except Exception:
return None
def track_speed(tk, where, step_s):
"""由相鄰兩點的有限差分估端點附近的速度(m/s)。
`where` 取 "start" 或 "end"。時間間隔用**取樣步長**而不是時間欄位 ——
LLA State 的 Time 回傳的是日期字串,不是秒數,拿去相減會得到 None,
那正是這一項第一次跑出來就失敗的原因。
端點的最後一個間隔可能不是完整步長,所以取**內縮一格**的完整間隔;
真空弧與平衡滑翔在 30 秒內的速度變化只有個位數 m/s,不影響判讀。
"""
if tk is None or len(tk) < 3 or step_s <= 0:
return None
if where == "start":
p, q = tk[1], tk[2]
else:
p, q = tk[-3], tk[-2]
dh = A.great_circle(p[1], p[2], q[1], q[2])[1]
dv = p[3] - q[3]
return math.sqrt(dh * dh + dv * dv) / step_s
def verify():
ck = Check()
ho = B.mission_geometry(verbose=False)
ho_sf = B.mission_geometry(ld=B.SEAM_FREE_LD, verbose=False)
stk, root = B.attach()
B.set_units(root)
sc = root.CurrentScenario
import stk_binding as SB
print("=" * 78)
print("台美太空港次軌道任務 —— 驗證")
print("=" * 78)
print(f" {SB.describe()}")
ck.section("一、場景", "scene")
if sc is None:
ck.add(False, "場景已開啟", "CurrentScenario 是 None")
return ck.report()
ck.add(sc.InstanceName == B.SCENARIO, "場景名稱",
f"{sc.InstanceName}(預期 {B.SCENARIO})")
ck.add(str(sc.StartTime).startswith("1 Oct 2026"), "起始時刻",
str(sc.StartTime))
ck.section("二、場站位置(要驗高度 —— UseTerrain 會靜默覆寫它)", "scene")
for name, lat, lon, alt in ((A.LAUNCH_NAME, A.LAUNCH_LAT, A.LAUNCH_LON,
A.LAUNCH_ALT_M),
(A.LANDING_NAME, A.LANDING_LAT, A.LANDING_LON,
A.LANDING_ALT_M)):
o = get_child(sc, name)
if o is None:
ck.add(False, f"{name} 存在", "找不到物件")
continue
gl = o.Position.QueryPlanetodetic()
d_pos = A.great_circle(lat, lon, gl[0], gl[1])[1]
ck.add(d_pos < 100.0, f"{name} 經緯度",
f"偏差 {d_pos:.1f} m")
ck.add(abs(gl[2] - alt) < TOL_SITE_M, f"{name} 高度",
f"實際 {gl[2]:.2f} m,設定 {alt:.2f} m")
ck.section("三、彈道段 Missile", "geom")
m = get_child(sc, B.MISSILE_NAME)
if m is None:
ck.add(False, f"{B.MISSILE_NAME} 存在", "找不到物件")
else:
tk = lla_track(m, sc)
if tk is None:
ck.skip("彈道軌跡可取得", "LLA State 取不到")
else:
lat0, lon0, alt0 = tk[0][1], tk[0][2], tk[0][3]
# **不是旭海。** Missile 代表的是熄火之後的真空弧,
# 而那一段的起點在旭海下游一個上升段(約 1,200 km)處。
bo_lat, bo_lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON,
ho["azimuth_deg"], ho["ascent_m"])
d0 = A.great_circle(bo_lat, B.wrap_lon(bo_lon), lat0, lon0)[1]
ck.add(d0 < 5_000.0, "彈道起點在熄火點的地面投影",
f"偏差 {d0/1000:.2f} km"
f"(上升段 {ho['ascent_m']/1000:,.0f} km 之後)")
apo = max(x[3] for x in tk)
# **形狀檢查:遠地點必須在卡門線附近。**
# 舊架構用最小能量彈道,遠地點 1,309 km —— 每個數字都對,
# 但那不是有翼載具的軌跡。這一項就是為了擋住那種錯誤。
ck.add(apo < 200_000.0, "遠地點在卡門線附近(非拋高彈道)",
f"{apo/1000:,.0f} km(上界 200;舊架構是 1,309)")
rel = abs(apo - ho["apogee_m"]) / max(1.0, ho["apogee_m"])
ck.add(rel < 0.20, "遠地點與助推滑翔解一致",
f"STK {apo/1000:,.0f} km vs 解析 "
f"{ho['apogee_m']/1000:,.0f} km(差 {rel*100:.0f}%)")
latN, lonN, altN = tk[-1][1], tk[-1][2], tk[-1][3]
arc_lat, arc_lon = A.point_along(A.LAUNCH_LAT, A.LAUNCH_LON,
ho["azimuth_deg"],
ho["ascent_m"] + ho["arc_m"])
dN = A.great_circle(arc_lat, B.wrap_lon(arc_lon), latN, lonN)[1]
ck.add(dN / 1000.0 < TOL_POS_KM, "真空弧終點在預期位置",
f"偏差 {dN/1000:.1f} km(容差 {TOL_POS_KM:.0f})")
ck.section("三之二、高超音速滑翔段(外部星曆)", "geom")
gl = get_child(sc, B.GLIDE_OBJ_NAME)
if gl is None:
ck.add(False, f"{B.GLIDE_OBJ_NAME} 存在", "找不到物件")
else:
gt = lla_track(gl, sc, step=30.0)
if gt is None:
ck.skip("星曆軌跡可取得", "LLA State 取不到")
else:
gpath = sum(A.great_circle(gt[i-1][1], gt[i-1][2],
gt[i][1], gt[i][2])[1]
for i in range(1, len(gt)))
gdir = A.great_circle(gt[0][1], gt[0][2], gt[-1][1], gt[-1][2])[1]
gr = gpath / gdir if gdir > 1000 else float("inf")
# **這一項是方案(乙)存在的理由。**
# 同一條航線讓 Aviator 飛是 5.32 倍(繞圈),外部星曆是 1.00。
ck.add(gr < 1.02, "星曆段沒有繞圈",
f"{gpath/1000:,.0f} / {gdir/1000:,.0f} = {gr:.2f} 倍"
f"(Aviator 飛同一段是 5.32 倍)")
ck.add(abs(gt[0][3] - ho["eph_start_alt_m"]) < 1_000.0,
"星曆起於交接高度",
f"{gt[0][3]/1000:.1f} km"
f"(交接 {ho['eph_start_alt_m']/1000:.1f})")
ck.add(abs(gt[-1][3] - B.APPROACH_IF_ALT_M) < 500.0,
"星曆止於進場介面",
f"{gt[-1][3]/1000:.1f} km(設定 "
f"{B.APPROACH_IF_ALT_M/1000:.1f})")
dur_g = gt[-1][0] - gt[0][0] if isinstance(gt[0][0], (int, float)) else None
# ══ 段間連續性 ══════════════════════════════════════════════════
# **這一節是新的,而且它本來就該在。**
# 舊架構在 Missile 終點與星曆起點之間有 3,765 km 的缺口,
# 而 35 項檢查沒有任何一項會因此失敗 —— 因為沒有人檢查兩段接不接得上。
# 現在缺口已經由自洽閉合消掉,這一節就是防止它再長回來。
ck.section("三之三、段間連續性(Missile 終點 → 星曆起點)", "geom")
if m is None or gl is None:
ck.skip("段間連續性可檢查", "缺少 Missile 或星曆物件")
else:
tkm = lla_track(m, sc)
gtm = lla_track(gl, sc, step=30.0)
if tkm is None or gtm is None:
ck.skip("段間連續性可檢查", "軌跡取不到")
else:
a_end, b_start = tkm[-1], gtm[0]
dxy = A.great_circle(a_end[1], a_end[2],
b_start[1], b_start[2])[1]
ck.add(dxy / 1000.0 < TOL_POS_KM, "兩段的水平位置接得上",
f"相距 {dxy/1000:.1f} km(容差 {TOL_POS_KM:.0f})")
dalt = abs(a_end[3] - b_start[3])
ck.add(dalt < 2_000.0, "兩段的高度接得上",
f"差 {dalt:,.0f} m(容差 2,000)")
v_a = track_speed(tkm, "end", 30.0)
v_b = track_speed(gtm, "start", 30.0)
# 星曆的起點速度要對得上解析解 —— 這一項是真的檢查,
# 因為星曆是我們自己寫出去的,寫錯就該被抓到。
if v_b is None:
ck.skip("星曆起點速度符合解析解", "算不出速度")
else:
rel = abs(v_b - ho["eph_start_tas_ms"]) / ho["eph_start_tas_ms"]
ck.add(rel < 0.05, "星曆起點速度符合解析解",
f"回讀 {v_b:,.0f} vs 解析 "
f"{ho['eph_start_tas_ms']:,.0f} m/s(差 {rel*100:.1f}%)")
# ── Missile 的終端速度:已知的模型層級落差,不是缺陷 ──────
# Missile 物件用的是「地面發射 -> 固定遠地點 -> 落點」的彈道解,
# **它不含動力上升段**。所以它的終端速度是那條純彈道解的結果,
# 不等於「在 100 km 以 6,118 m/s 熄火」的載具到達同一點的速度。
#
# 位置與高度是對的(上面兩項),能量狀態由解析解與星曆承載。
# 把這一項做成 pass/fail 只會誘使人去放寬門檻,所以記成 note,
# 並把數字寫出來 —— 它是「Missile 物件能代表什麼」的界線。
if v_a is not None and v_b is not None:
ck.note("Missile 終端速度與星曆起點的落差(已知,非缺陷)",
f"{v_a:,.0f} -> {v_b:,.0f} m/s"
f"(差 {abs(v_a-v_b):,.0f})—— Missile 不含動力上升段")
# 航程閉合:解析解自己要先對,否則 STK 裡怎麼量都是錯的
ck.add(abs(ho["closure_residual_m"]) < 1.0,
"解析解的兩段相加等於全程",
f"殘差 {ho['closure_residual_m']:.3f} m"
f"(舊架構缺 3,765,000 m)")
# ── TAEM 下滑道:設計點降到 2.3 之後浮出來的物理限制 ────────────
# 不做成 pass/fail:它不是「建錯了」,是這個外型在這條航線上
# **真的太高**。做成失敗只會誘使人去放寬 TAEM_GLIDESLOPE_DEG,
# 那是把發現吸收掉。
# 交棒速度改成由設計點解出來之後,這一項從 note 升級成 pass/fail。
# 先前它是「已知的物理限制」(下滑道 42.3° vs 設計 20°),
# 但那不是物理限制,是 `TAEM_V_MS = 750` 這個常數只在舊設計點
# L/D 4.0 下剛好對 —— 和進場走廊、摩擦參考速度是同一個錯誤。
tg = B.taem_geometry(ho["ld"])
ck.add(tg["ok"], "TAEM 下滑道在設計值之內",
f"{tg['slope_deg']:.1f}deg vs 設計 "
f"{tg['design_slope_deg']:.0f}deg"
f"(交棒 {tg['v_handover_ms']:,.0f} m/s @ "
f"{tg['alt_m']/1000:.1f} km"
+ ("、有效性下限綁住)" if tg["floor_binding"] else "、幾何解出)"))
ck.add(tg["v_handover_ms"] >= tg["v_floor_ms"] - 1e-6,
"交棒速度不低於平衡滑翔模型的有效性下限",
f"{tg['v_handover_ms']:,.0f} >= {tg['v_floor_ms']:,.0f} m/s")
ck.section("四、終端進場與降落 Aviator(設計點 L/D "
f"{ho['ld']:g})", "geom")
name_main = f"{B.SPACEPLANE_NAME}_LD{ho['ld']:g}".replace(".", "p")
ac = get_child(sc, name_main)
if ac is None:
ck.add(False, f"{name_main} 存在", "找不到物件")
return ck.report()
mis = ac.Route.AvtrPropagator.AvtrMission
try:
valid = bool(mis.IsValid)
except Exception:
valid = None
bad_name = ""
if not valid:
try:
bp = mis.GetFirstInvalidProcedure()
bad_name = bp.Name if bp else "?"
except Exception:
pass
ck.add(valid is True, "任務有效(IsValid)",
f"第一個無效:{bad_name}" if not valid else "")
ph = mis.Phases.Item(0)
# 效能模型必須是 Built-In —— 生成模型會把高度鎖在 13,106 m
names = {}
for kind in V.PHASE_PERF_TYPES:
try:
names[kind] = ph.GetPerformanceModelByType(kind).Name
except Exception:
names[kind] = "<取不到>"
all_builtin = all(v == "Built-In Model" for v in names.values())
ck.add(all_builtin, "各階段用 Built-In 模型",
"" if all_builtin else str(names))
try:
ceil = ph.GetPerformanceModelByType("Cruise").Properties.CeilingAltitude
ck.add(abs(ceil - V.BUILTIN_CEILING_M) < 1.0, "Cruise CeilingAltitude",
f"{ceil:,.0f} m(預期 {V.BUILTIN_CEILING_M:,.0f})")
except Exception as e:
ck.skip("Cruise CeilingAltitude", str(e)[:40])
at = air_track(ac, sc)
if at is None:
ck.skip("飛行剖面可取得", "Flight Airspeed and Atmosphere 取不到")
else:
alts = [x[0] for x in at]
machs = [x[1] for x in at]
tas_kt = [x[2] for x in at]
top = max(alts)
ck.add(abs(top - B.APPROACH_IF_ALT_M) < 500.0, "起於進場介面高度",
f"{top:,.0f} m(星曆交棒點 {B.APPROACH_IF_ALT_M:,.0f})")
low = min(alts)
ck.add(low < A.KEFD_RUNWAY["alt_m"] + 300.0, "下降到跑道高度",
f"最低 {low:,.1f} m(跑道 {A.KEFD_RUNWAY['alt_m']:.1f})")
tas_ms = max(tas_kt) * 1852.0 / 3600.0
ck.add(tas_ms <= V.AVIATOR_TAS_LIMIT_MS * 1.05,
"真空速在 Aviator 可解範圍內",
f"最高 {tas_ms:,.0f} m/s(實測上限約 "
f"{V.AVIATOR_TAS_LIMIT_MS:,.0f})")
# 單調下降:滑翔不該中途爬升。容許小幅數值抖動。
rises = [alts[i] - alts[i - 1] for i in range(1, len(alts))
if alts[i] - alts[i - 1] > 200.0]
ck.add(not rises, "剖面單調下降",
"OK" if not rises else f"{len(rises)} 處上升,最大 {max(rises):,.0f} m")
tk = lla_track(ac, sc, step=60.0)
if tk is None:
ck.skip("落點位置可取得", "LLA State 取不到")
else:
# **形狀檢查:地面軌跡不能比直線長太多。**
# 這一項是踩過才加的。航向變化超過約 13 度時 Aviator 不報錯、
# IsValid 仍是 True,但載具會持續盤旋 —— 實測軌跡 42,681 km
# 對直線 8,016 km(5.32 倍),飛行時間從 65 分鐘變 236 分鐘。
# 當時 32 項驗證**全部通過**,因為沒有一項在看路徑長度。
path = sum(A.great_circle(tk[i-1][1], tk[i-1][2],
tk[i][1], tk[i][2])[1]
for i in range(1, len(tk)))
straight = A.great_circle(tk[0][1], tk[0][2], tk[-1][1], tk[-1][2])[1]
ratio = path / straight if straight > 1000 else float("inf")
ck.add(ratio < 1.30, "終端軌跡沒有繞圈" # 降落攔截本來就不是直線,
f"軌跡 {path/1000:,.0f} km / 直線 {straight/1000:,.0f} km"
f" = {ratio:.2f} 倍(上限 1.15)")
dur = None
try:
dur = (len(tk) - 1) * 60.0
except Exception:
pass
if dur:
ck.add(dur < 3.0 * 3600.0, "飛行時間合理",
f"{dur/60:,.0f} min(解析解約 65 min,上限 180)")
latE, lonE = tk[-1][1], tk[-1][2]
dE = A.great_circle(A.KEFD_RUNWAY["lat"], A.KEFD_RUNWAY["lon"],
latE, lonE)[1]
ck.add(dE < 5_000.0, "落點在 KEFD 跑道",
f"偏差 {dE/1000:.2f} km")
# 觸地方向要對齊跑道。取最後兩點的航向。
if len(tk) >= 2:
_t, la1, lo1, _a1 = tk[-2]
hdg = A.great_circle(la1, lo1, latE, lonE)[2]
want = A.KEFD_RUNWAY["low_end_hdg"]
diff = abs((hdg - want + 180.0) % 360.0 - 180.0)
ck.add(diff < 20.0, "觸地航向對齊 RWY 17R",
f"實際 {hdg:.1f} deg,跑道 {want:.1f} deg(差 {diff:.1f})")
# **銜接連續性:終端段要從星曆的終點接上。**
# 混合架構最容易出錯的就是接縫 —— 兩段各自正確、接不起來。
gl2 = get_child(sc, B.GLIDE_OBJ_NAME)
gt2 = lla_track(gl2, sc, step=30.0) if gl2 is not None else None
if gt2 is None:
ck.skip("終端段接上星曆終點", "取不到星曆軌跡")
else:
d0 = A.great_circle(gt2[-1][1], gt2[-1][2],
tk[0][1], tk[0][2])[1]
dh = abs(gt2[-1][3] - tk[0][3])
ck.add(d0 / 1000.0 < 5.0 and dh < 500.0,
"終端段接上星曆終點",
f"水平偏差 {d0/1000:.2f} km、高度差 {dh:.0f} m")
ck.section(f"四之二、Aviator 可獨力飛完的對照機(L/D {B.SEAM_FREE_LD})",
"tool")
name_sf = f"{B.SPACEPLANE_NAME}_LD{B.SEAM_FREE_LD:g}".replace(".", "p")
ac2 = get_child(sc, name_sf)
if ac2 is None:
ck.add(False, f"{name_sf} 存在", "找不到物件")
else:
try:
v2 = bool(ac2.Route.AvtrPropagator.AvtrMission.IsValid)
except Exception:
v2 = None
ck.add(v2 is True, "對照機任務有效", "")
ck.add(ho_sf["aviator_seam_ms"] < 1.0,
"對照機的弧終點速度在 Aviator 可解範圍內",
f"還差 {ho_sf['aviator_seam_ms']:,.0f} m/s"
f"(上限 {V.AVIATOR_TAS_LIMIT_MS:,.0f})")
# 分母要扣掉動力上升段 —— 那一段本來就不是 Aviator 的工作。
# 先前用全程當分母,把上升段計入之後這一項必然失敗(86%)。
flyable = ho_sf["total_m"] - ho_sf["ascent_m"]
ck.add(ho_sf["eph_m"] / flyable > 0.90,
"對照機由單一模型涵蓋九成以上的**滑翔**航程",
f"{ho_sf['eph_m']/flyable*100:.0f}%"
f"(分母已扣掉上升段 {ho_sf['ascent_m']/1000:,.0f} km)")
at2 = air_track(ac2, sc)
if at2 is None:
ck.skip("對照機飛行剖面可取得", "取不到")
else:
tas2 = max(x[2] for x in at2) * 1852.0 / 3600.0
ck.add(tas2 <= V.AVIATOR_TAS_LIMIT_MS * 1.05,
"對照機全程在 Aviator 可解範圍內",
f"最高真空速 {tas2:,.0f} m/s")
ck.section("五、3D 模型", "scene")
# **判定依據是機型 XML,不是 ac.VO.Model。**
# ac.VO.Model.ModelData.Filename 每次 Propagate() 都會被機型的模型
# 覆蓋(實測:設定後正確,重算後變回 fighter_f16.glb),而上面第四節
# 查 data provider 就會觸發重算 —— 所以到這裡讀 VO 必然是 f16。
# 拿 VO 當判定會報出一個假的失敗。
want_glb = os.path.normcase(os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), B.MODEL_GLB)))
ck.add(os.path.isfile(want_glb), "太空飛機 .glb 檔存在",
os.path.basename(want_glb))
try:
# 不能用硬寫的目錄:STK 12 與 13 的機型目錄名稱不同,
# 而「改到錯的檔案」正是這一項要擋的失敗模式。
cat = B.vehicle_catalog_dir(f"{name_main}_Veh")
assert cat, "找不到含該機型的目錄"
xml = os.path.join(cat, f"{name_main}_Veh.xml")
raw = io.open(xml, encoding="utf-8-sig").read()
mt = re.search(r'<Model3D Value = "([^"]*)"', raw)
got = mt.group(1) if mt else ""
ck.add(os.path.normcase(os.path.normpath(got)) == want_glb,
"機型 Model3D 指向太空飛機",
f"{got}(重啟 STK 後生效)")
except Exception as e:
ck.skip("機型 Model3D 指向太空飛機", str(e)[:50])
try:
fn = ac.VO.Model.ModelData.Filename
print(f" [--- ] 本次 VO 模型(僅供參考,重算會還原)"
f" {os.path.basename(fn)}")
except Exception:
pass
# ══════════════════════════════════════════════════════════════
# 分節分離與尾焰(spaceport_stages.py 建的)
# ══════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
# 時間軸:各段要接續,不是全部從 t = 0 開始
# ══════════════════════════════════════════════════════════════
ck.section("三之四、動畫時間軸(段與段的接縫)", "geom")
with B.scoped_unit(root, "DateFormat", "EpSec"):
def span(name):
o = get_child(sc, name)
if o is None:
return None
try:
return (float(o.Vgt.Events.Item("AvailabilityStartTime")
.FindOccurrence().Epoch),
float(o.Vgt.Events.Item("AvailabilityStopTime")
.FindOccurrence().Epoch))
except Exception: # noqa: BLE001
return None
import spaceport_stages as _ST0
# **真空弧用解析時長,不用 Missile 物件的可用區間。**
# 傳播器一定要從地面發射,所以它的 406 s 裡有 159 s 是
# 「爬到熄火高度」的贗品;而且它不帶動力上升段的能量,
# 弧段部分只有 247 s。解析解是 346 s —— 星曆由解析解生出來,
# 時間軸就用解析解。`SetExplicitInterval` 設得了起點,
# 設不了飛行時間(那由幾何決定),所以物件本身會超出約 60 s。
arc_dt = A.arc_duration_s(ho["v_bo"],
arc_end_alt_m=ho["eph_start_alt_m"])
ms = span(B.MISSILE_NAME)
arc_span = (ms[0], ms[0] + arc_dt) if ms else None
segs = [("上升段", span(_ST0.S.ASCENT_OBJ)),
("真空弧(解析)", arc_span),
("滑翔段", span(B.GLIDE_OBJ_NAME)),
("終端進場", span(name_main))]
if ms:
ck.note("Missile 物件的飛行時間比解析弧長(已知,非缺陷)",
f"物件 {ms[1]-ms[0]:,.0f} s vs 解析 {arc_dt:,.0f} s"
f"(其中約 159 s 是傳播器從地面爬到熄火高度的贗品)")
# 先前**七個物件的可用區間全部由 t = 0 起算** —— 3D 視窗裡是
# 四架載具同時從旭海起飛、各飛各的,那不是這個任務。
# 這一段守的就是「動畫演的是一個任務,不是四條平行航班」。
for i in range(len(segs) - 1):
(na, sa), (nb, sb) = segs[i], segs[i + 1]
if sa is None or sb is None:
ck.skip(f"{na} -> {nb} 的時間接縫", "取不到可用區間")
continue
gap = sb[0] - sa[1]
ck.add(abs(gap) < 5.0, f"{na} -> {nb} 的時間接得上",
f"{sa[1]:,.1f} -> {sb[0]:,.1f} s(差 {gap:+.1f})")
s1 = span(_ST0.STAGE1_OBJ)
a0 = segs[0][1]
if s1 and a0:
ck.add(abs(s1[0] - a0[0]) < 5.0,
"第一節與上升段同時起飛",
f"{s1[0]:,.1f} vs {a0[0]:,.1f} s")
ck.section("五之二、分節分離與尾焰", "scene")
import spaceport_stages as ST
s1 = get_child(sc, ST.STAGE1_OBJ)
ck.add(s1 is not None, f"{ST.STAGE1_OBJ} 存在",
"" if s1 is not None else "先跑 spaceport_stages.py")
if s1 is not None:
tm = ST.stage_timing()
t_sep, t_ign2, t_bo = tm["t_sep"], tm["t_ign2"], tm["t_burnout"]
# 一、星曆要跨過分離、而且要一路積到落地
with B.scoped_unit(root, "DateFormat", "EpSec"):
try:
t0 = float(s1.Vgt.Events.Item(
"AvailabilityStartTime").FindOccurrence().Epoch)
t1 = float(s1.Vgt.Events.Item(
"AvailabilityStopTime").FindOccurrence().Epoch)
except Exception as exc: # noqa: BLE001
t0 = t1 = None
ck.skip("第一節的可用區間", str(exc)[:60])
if t1 is not None:
ck.add(t1 > t_sep + 60.0, "第一節星曆跨過分離並續飛拋離段",
f"{t0:.0f} -> {t1:.0f} s(分離 {t_sep:.0f} s)")
# 二、拋離段要落回地面 —— 用完的節是墜落,不是停在空中
pts, _tm = ST.stage1_track()
ck.add(pts[-1][3] < 1.0, "拋離段積到落地",
f"終點高度 {pts[-1][3]:.1f} m @ T+{pts[-1][0]:.0f} s")
jet = [p for p in pts if p[5] == "jettison"]
ck.add(len(jet) >= 10, "拋離段有足夠取樣點", f"{len(jet)} 點")
# 三、分離點的狀態要與上升段積分一致(不是另外假設的)
boost = [p for p in pts if p[5] == "boost"]
_ts, vs, _gs, hs = tm["sep_state"]
ck.add(abs(boost[-1][4] - vs) / vs < 0.02,
"分離點速度來自上升段積分",
f"星曆 {boost[-1][4]:,.0f} vs 積分 {vs:,.0f} m/s")
# 三之二、**兩個物件在分離那一刻要在同一個位置**
#
# 這兩項是後來補的,因為**兩個真錯都沒有東西在守**:
# 1. ascent_track() 重新積分時漏傳 beta0,所以太空飛機的星曆
# 是無阻力的,止於 120.8 km 而不是設計的 100 km。
# 建構輸出一直印著「止於 121.7 km」,沒有檢查在看它。
# 2. 補「精確分離點」時沿程沿用了上一個取樣點,
# 位置差 7.7 km —— 高度與速度都對,只有位置錯。
# 兩個加起來讓分離在 3D 視窗裡根本不像分離。
import spaceport_ascent as _SA
asc_pts, asc_sol = _SA.ascent_track()
ck.add(abs(asc_pts[-1][3] - ho["burnout_alt_m"]) < 1_000.0,
"太空飛機星曆止於設計熄火高度",
f"{asc_pts[-1][3]/1000:.2f} km"
f"(設計 {ho['burnout_alt_m']/1000:.0f})")
ck.add(abs(asc_pts[-1][4] - ho["v_bo"]) / ho["v_bo"] < 0.01,
"太空飛機星曆止於設計熄火速度",
f"{asc_pts[-1][4]:,.0f} vs {ho['v_bo']:,.0f} m/s")
def _interp(tr, tt):
for i in range(1, len(tr)):
if tr[i][0] >= tt:
p, q = tr[i - 1], tr[i]
f = (tt - p[0]) / max(1e-9, q[0] - p[0])
return (p[1] + (q[1] - p[1]) * f,
p[2] + (q[2] - p[2]) * f,
p[3] + (q[3] - p[3]) * f)
return tr[-1][1:4]
t_exact = tm["sep_state"][0]
a = _interp(pts, t_exact)
b = _interp(asc_pts, t_exact)
gap = math.hypot(A.great_circle(a[0], a[1], b[0], b[1])[1],
abs(a[2] - b[2]))
ck.add(gap < 200.0, "分離那一刻兩個物件在同一個位置",
f"相距 {gap:,.0f} m")
# 四、活動件:時變的火焰大小要真的隨時間變
art = s1.VO.Model.Articulation
ck.add(bool(art.UseArticulationFile), "已啟用 articulation 檔")
try:
fn = art.VOArticulationFile.Filename
except Exception:
fn = ""
ck.add(fn.lower().endswith(".sama"), "articulation 檔已載入",
os.path.basename(fn) if fn else "(空)")
def _artic(name, trans):
for tr in art.GetAvailableTransformations(0, name):
if tr.Name == trans:
return float(tr.Value)
return None
def _artic_at(t):
"""把動畫時間移到 t 再讀活動件(只適用於平穩段)。"""
root.CurrentTime = float(t)
return (_artic("BoosterFlames", "Size"),
_artic("Booster", "Size"))
def _artic_walk(targets, start, step=1.0):
"""**逐步走過去**再讀,不是直接跳。
實測:活動件是**隨動畫步進**推進的,不是時間的絕對函數。
從 t = 181 直接跳到 185 讀到火焰 1.00(應為 0.00),
因為中間那個 1 秒的漸變事件被跳過了;逐秒走過去就正確。
這一項很容易誤報,而且容易誤診成回讀延遲 ——
設兩次時間沒有用,原因是跳過了事件。
"""
out = {}
t = float(start)
hi = max(targets)
while t <= hi + 1e-9:
root.CurrentTime = t
for g in targets:
if abs(t - g) < step * 0.51:
out[g] = (_artic("BoosterFlames", "Size"),
_artic("Booster", "Size"))
t += step
return out
with B.scoped_unit(root, "DateFormat", "EpSec"):
keep = root.CurrentTime
try:
# 燃燒中是平穩段,直接跳沒問題
samples = {t: _artic_at(t)
for t in (0.5, t_sep * 0.5, t_sep - 1.0)}
# 跨過分離要逐步走
samples.update(_artic_walk(
[t_sep + 3.0, t_sep + 20.0], t_sep - 2.0))
finally:
root.CurrentTime = keep
burn = [v for t, (v, _b) in samples.items() if t < t_sep]
after = [v for t, (v, _b) in samples.items() if t > t_sep]
ck.add(all(v is not None and v > 0.9 for v in burn),
"第一節燃燒中火焰全開",
"、".join(f"{v:.2f}" for v in burn))
ck.add(all(v is not None and v < 0.05 for v in after),
"分離之後火焰關閉",
"、".join(f"{v:.2f}" for v in after))
shell = [b for t, (_v, b) in samples.items() if t > t_sep]
ck.add(all(b is not None and b < 0.95 for b in shell),
"分離之後第一節縮成空殼",
"、".join(f"{b:.2f}" for b in shell))
# 場景起點那一格不套用 articulation —— 量到的工具邊界,不是缺陷
with B.scoped_unit(root, "DateFormat", "EpSec"):
keep = root.CurrentTime
try:
root.CurrentTime = 300.0
root.CurrentTime = 0.0
root.CurrentTime = 0.0
v0 = _artic("BoosterFlames", "Size")
finally:
root.CurrentTime = keep
if v0 is not None and v0 < 0.5:
ck.note("場景起點那一格不套用 articulation(已知的工具邊界)",
f"t=0 讀到 {v0:.2f},t=0.5 起正常 —— "
"三種事件寫法實測都一樣,按下播放就看不到")
# 五、尾焰的時間窗:第一節 [0, 分離]、太空飛機 [二節點火, 熄火]
def _trail(obj, label, want0, want1, expect_attach):
vt = obj.VO.VaporTrail
ck.add(bool(vt.Visible), f"{label}尾焰已開啟")
di = vt.DisplayInterval
with B.scoped_unit(root, "DateFormat", "EpSec"):
try:
g0 = float(di.FindStartTime())
g1 = float(di.FindStopTime())
except Exception as exc: # noqa: BLE001
ck.skip(f"{label}尾焰時間窗", str(exc)[:60])
return
ck.add(abs(g0 - want0) < 2.0 and abs(g1 - want1) < 2.0,
f"{label}尾焰時間窗正確",
f"{g0:.0f} -> {g1:.0f} s(應為 {want0:.0f} -> "
f"{want1:.0f})")
used = vt.AttachPointName if vt.UseAttachPoint else ""
ck.add(used == expect_attach, f"{label}尾焰掛在噴嘴上",
used or "(沒掛)")
_trail(s1, "第一節", 0.0, t_sep, "Flame_Booster1")
asc = get_child(sc, ST.S.ASCENT_OBJ)
if asc is None:
ck.add(False, "太空飛機尾焰", f"找不到 {ST.S.ASCENT_OBJ}")
else:
# 掛點原本不存在(自製 glb 沒有 attach point),尾焰因此從
# 物件原點噴出。`model/add_attach_point.py` 補上 MainEngine
# 之後,這一項從 note 升級成 pass/fail。
_trail(asc, "太空飛機", t_ign2, t_bo, B.SPACEPLANE_ATTACH)
mdl = ""
try:
mdl = os.path.basename(asc.VO.Model.ModelData.Filename)
except Exception: # noqa: BLE001
pass
ck.add(mdl == os.path.basename(B.MODEL_GLB),
"第二節載的是太空飛機模型",
mdl or "(讀不到)")
ck.section("六、沒有污染使用者的 STK(出廠機型是否仍是出廠值)", "hygiene")
try:
am = ac.Route.AvtrPropagator.AvtrCatalog.AircraftCategory.AircraftModels
names_cat = list(am.ChildNames or [])
want_veh = [f"{name_main}_Veh", f"{name_sf}_Veh"]
ck.add(all(w in names_cat for w in want_veh), "兩架各有私有機型",
", ".join(want_veh))
# **只檢查本流程可能產生的名稱。**
# 原本寫成「任何含 Copy 的名稱」,在 STK 13 上誤報了使用者自己
# 2026-05 建立的「Basic VSTOL Copy」—— 那是別人的資料,不是污染。
# 檢查要精確到「本流程可能弄髒的東西」,否則它只是在製造假警報。
own_prefix = V.BASE_AIRCRAFT + " Copy" # Duplicate() 的預設名
strays = [x for x in names_cat
if x.startswith(own_prefix) or x == "TWSP_Spaceplane_Veh"
or (x.startswith("TWSP_") and x not in want_veh)]
ck.add(not strays, "本流程沒有留下殘留機型",
"無" if not strays else str(strays))
others = [x for x in names_cat
if not x.startswith("Basic ") and not x.startswith("TWSP_")]
if others:
print(f" [--- ] 目錄中的其他機型(使用者既有,未動) {others}")
ftr = am.GetAircraft("Basic Fighter")
# 出廠值從 Built-In Cruise 模型讀,那是 CeilingAlt 實際存放的地方
f_ph = None
try:
f_ph = ftr.AdvFixedWingTool
except Exception:
pass
ck.add(f_ph is not None, "可讀取 Basic Fighter", "")
user_dir = os.path.expandvars(
r"%USERPROFILE%\Documents\STK 12\Config\Catalog\Aviator\Aircraft")
override = os.path.join(user_dir, "Basic Fighter.xml")
ck.add(not os.path.isfile(override),
"Basic Fighter 沒有被覆寫",
"使用者目錄下沒有 Basic Fighter.xml"
if not os.path.isfile(override) else f"發現覆蓋檔 {override}")
except Exception as e:
ck.skip("機型目錄檢查", str(e)[:50])
return ck.report()
if __name__ == "__main__":
ok = verify()
print("VERIFY-OK" if ok else "VERIFY-INCOMPLETE")
spaceport_suborbital/stk_binding.py
# -*- coding: utf-8 -*-
r"""選擇正確的 STK Python 綁定,並把名稱重新匯出。
## 為什麼需要這一層
`agi.stk12` 與 `agi.stk13` 是**兩套獨立的 COM 綁定,互相接不到對方的實例**。
用 `agi.stk13` 對著一個跑著的 STK 12 只會丟 "Failed to attach",
**不會自動退回去用 12** —— 而那個錯誤訊息長得像「STK 沒開」,
很容易往錯的方向查半天。
所以先看現在跑的是哪一版,再決定匯入哪一組。
想指定的話設環境變數 `STK_API=12` 或 `STK_API=13`。
## 用法
呼叫端把
from agi.stk12.stkobjects import AgESTKObjectType, AgEVePropagatorType
換成
from stk_binding import AgESTKObjectType, AgEVePropagatorType
`stkobjects` 與 `stkobjects.aviator` 的公開名稱都已重新匯出到本模組,
`STKDesktop` 也是。`STK_PKG` 會告訴你實際選到哪一個。
## 已驗證
本章的建構與驗證流程在 **STK 12.10** 與 **STK 13(STK_ODTK 13)** 兩邊
都跑過,API 名稱一致 —— 包含 Aviator 的 134 個 `AgEAvtr*` 列舉、
`AgEVeLaunchControl`、`AgEVeImpactLocation` 等本章用到的全部型別。
"""
import importlib
import importlib.util
import os
import subprocess as _sp
def running_stk_versions():
"""目前跑著的 STK 版本('12' / '13'),依執行檔路徑判斷。
兩版的執行檔都叫 AgUiApplication.exe,只有路徑分得出來:
C:\\Program Files\\AGI\\STK 12\\bin\\...
C:\\Program Files\\AGI\\STK_ODTK 13\\bin\\...
"""
ps = ("Get-Process AgUiApplication -ErrorAction SilentlyContinue | "
"ForEach-Object { $_.Path }")
try:
out = _sp.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=30).stdout
except Exception:
return []
vers = []
for line in out.splitlines():
p = line.strip().lower()
if not p:
continue
if "stk 12" in p:
vers.append("12")
elif "13" in p:
vers.append("13")
return vers
def pick_stk_package():
"""決定要用哪一個綁定。優先順序:環境變數 > 正在跑的 > 已安裝的。"""
env = os.getenv("STK_API", "").strip().lower().replace("stk", "")
if env in ("12", "13"):
return "agi.stk" + env
for v in running_stk_versions():
pkg = "agi.stk" + v
if importlib.util.find_spec(pkg) is not None:
return pkg
for pkg in ("agi.stk13", "agi.stk12"):
if importlib.util.find_spec(pkg) is not None:
return pkg
raise ImportError("找不到 agi.stk12 或 agi.stk13")
STK_PKG = pick_stk_package()
STK_VERSION = STK_PKG.replace("agi.stk", "")
STKDesktop = importlib.import_module(STK_PKG + ".stkdesktop").STKDesktop
# 把 stkobjects 與 aviator 的公開名稱攤平到本模組,
# 讓呼叫端只要換 import 來源、不必改用法。
for _sub in ("stkobjects", "stkobjects.aviator"):
_mod = importlib.import_module(STK_PKG + "." + _sub)
globals().update({_k: _v for _k, _v in vars(_mod).items()
if not _k.startswith("_")})
del _sub, _mod
def describe():
"""回傳一行說明,讓每支腳本的輸出都記錄實際用的綁定。"""
run = running_stk_versions()
return (f"STK 綁定 {STK_PKG}"
+ (f"(執行中:STK {', '.join(run)})" if run else "(未偵測到執行中的 STK)"))
spaceport_suborbital/cfd/friction.py
# -*- coding: utf-8 -*-
"""滑翔段的摩擦阻力估算 —— 不用 CFD 就能回答「L/D 4.0 扣掉摩擦剩多少」。
## 為什麼要先做這個
第 08 節的 SU2 是**無黏** Euler,算不出摩擦阻力,所以那裡的 L/D 是上限。
而本章整個設計點押在 **L/D = 4.0** 上 —— 如果摩擦把它吃掉一大塊,
動搖的不是一張圖,是全章的結論。
跑黏性 RANS 在 Mach 20 要解邊界層,網格需求比無黏高一個量級。
**先用工程估算給出量級,再決定值不值得投入。**
## 方法
C_D,f = C_f × S_wet / S_ref
`S_wet` **從實際的網格算**(model/twsp2_spaceplane.obj,10,644 個三角面),
不是估的。`S_ref` 用機翼參考面積 85 m²。
`C_f` 要分層流/紊流,並做可壓縮修正:
* 雷諾數 Re = ρ·v·L/μ,μ 用 Sutherland
* 層流 C_f = 1.328/√Re (Blasius,平板)
* 紊流 C_f = 0.455/(log10 Re)^2.58 (Schlichting)
* 可壓縮修正用 **參考溫度法**(Eckert):以參考溫度重算 ρ 與 μ
T* = T_e + 0.5(T_w − T_e) + 0.22(T_aw − T_e)
## 這個估算的界線
* 平板關係式套在有後掠、有攻角的機體上,是**量級估算**。
* 沒有激波–邊界層干擾、沒有分離、沒有底部阻力。
* 高空低雷諾數時邊界層很厚,平板關係式會低估。
* **不含底阻**(鈍底的壓差阻力),那在高超音速可能與摩擦同量級。
"""
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
sys.path.insert(0, HERE)
import spaceport_analysis as A # noqa: E402
OBJ = os.path.join(ROOT, "model", "twsp2_spaceplane.obj")
S_REF_M2 = 85.0 # 機翼參考面積,與 spaceport_vehicle 一致
LENGTH_M = 18.0 # 機身長度,當特徵長度
T_WALL_K = 1200.0 + 273.15 # 壁溫取輻射平衡的量級(見 analysis 的加熱表)
# 絕熱壁溫的上限。解離會把它壓在這個量級,完全氣體的公式不會。
T_AW_CAP_K = 6500.0
GAMMA = 1.4
R_AIR = 287.053
# Sutherland
MU_REF, T_REF, S_SUTH = 1.716e-5, 273.15, 110.4
def wetted_area(path=OBJ):
"""從 .obj 直接算濕面積(m²)。**不是估的。**"""
verts, area = [], 0.0
with open(path, encoding="utf-8", errors="replace") as fh:
for ln in fh:
if ln.startswith("v "):
p = ln.split()
verts.append((float(p[1]), float(p[2]), float(p[3])))
elif ln.startswith("f "):
idx = [int(t.split("/")[0]) - 1 for t in ln.split()[1:]]
for k in range(1, len(idx) - 1):
a, b, c = verts[idx[0]], verts[idx[k]], verts[idx[k + 1]]
u = (b[0]-a[0], b[1]-a[1], b[2]-a[2])
w = (c[0]-a[0], c[1]-a[1], c[2]-a[2])
n = (u[1]*w[2]-u[2]*w[1], u[2]*w[0]-u[0]*w[2],
u[0]*w[1]-u[1]*w[0])
area += 0.5 * math.sqrt(n[0]**2 + n[1]**2 + n[2]**2)
return area, len(verts)
def sutherland(t_k):
return MU_REF * (t_k / T_REF) ** 1.5 * (T_REF + S_SUTH) / (t_k + S_SUTH)
def us76_temp(alt_m):
"""與 spaceport_build.temperature() 同一組係數(位勢高度)。"""
layers = ((0.0, 288.15, -0.0065), (11000.0, 216.65, 0.0),
(20000.0, 216.65, 0.001), (32000.0, 228.65, 0.0028),
(47000.0, 270.65, 0.0), (51000.0, 270.65, -0.0028),
(71000.0, 214.65, -0.002), (84852.0, 186.946, 0.0))
r_e = 6356766.0
h = min(r_e * alt_m / (r_e + alt_m), 84852.0)
hb, tb, lr = layers[0]
for lh, lt, ll in layers:
if h >= lh:
hb, tb, lr = lh, lt, ll
else:
break
return tb + lr * (h - hb)
def friction(v_ms, alt_m, length_m=LENGTH_M, t_wall_k=T_WALL_K):
"""回傳 dict:雷諾數、C_f(層流與紊流)、以及參考溫度法的修正值。"""
t_e = us76_temp(alt_m)
rho_e = A.us76_density(alt_m)
a_e = math.sqrt(GAMMA * R_AIR * t_e)
mach = v_ms / a_e
# 絕熱壁溫(恢復係數 0.89,紊流)。
#
# **要壓上限。** γ = 1.4 的完全氣體在 Mach 21.7 給出 T_aw = 18,005 K,
# 那是不合理的:真實空氣在 2,500 K 以上就開始解離,吸走的能量
# 讓平衡溫度停在 6,000-7,000 K。不壓的話參考溫度被拉到 4,757 K、
# ρ* 只剩自由流的 4.5%、Re* 掉到 2.8e3 —— 平板關係式被拿到
# 完全不適用的範圍去算。
#
# 壓到 6,500 K 之後 Re* 回到 9.1e3,而 C_f **變大**
# (0.00112 -> 0.00133)—— 也就是原本的估算是偏樂觀的。
t_aw = t_e * (1.0 + 0.89 * (GAMMA - 1.0) / 2.0 * mach * mach)
t_aw = min(t_aw, T_AW_CAP_K)
# Eckert 參考溫度
t_star = t_e + 0.5 * (t_wall_k - t_e) + 0.22 * (t_aw - t_e)
rho_star = rho_e * t_e / t_star # 等壓
mu_star = sutherland(t_star)
re_star = rho_star * v_ms * length_m / mu_star
re_e = rho_e * v_ms * length_m / sutherland(t_e)
cf_lam = 1.328 / math.sqrt(max(1.0, re_star))
cf_turb = 0.455 / (math.log10(max(10.0, re_star)) ** 2.58)
# 平均摩擦係數以壁溫/參考溫度的密度比換回自由流
scale = rho_star / rho_e
return {"mach": mach, "T_e": t_e, "T_aw": t_aw, "T_star": t_star,
"rho_e": rho_e, "Re_e": re_e, "Re_star": re_star,
"cf_lam": cf_lam * scale, "cf_turb": cf_turb * scale,
"scale": scale}
def glide_friction_table(ld=None, speeds=None):
ld = A.LD_DESIGN if ld is None else ld
speeds = speeds or (6000, 5400, 4500, 3500, 2500, 1500, 900)
s_wet, nv = wetted_area()
rows = []
for v in speeds:
alt = A.equilibrium_glide_alt(float(v))
f = friction(float(v), alt)
cd_lam = f["cf_lam"] * s_wet / S_REF_M2
cd_turb = f["cf_turb"] * s_wet / S_REF_M2
rows.append({"v": v, "alt_m": alt, **f,
"cd_f_lam": cd_lam, "cd_f_turb": cd_turb})
return rows, s_wet, nv
def main():
rows, s_wet, nv = glide_friction_table()
print("=" * 78)
print(" 滑翔段摩擦阻力估算(平板關係式 + Eckert 參考溫度)")
print("=" * 78)
print(f" 濕面積 {s_wet:,.1f} m²(由 {nv:,} 個頂點的網格算出),"
f"參考面積 {S_REF_M2:.0f} m²")
print(f" S_wet/S_ref = {s_wet/S_REF_M2:.2f}、特徵長度 {LENGTH_M:.0f} m、"
f"壁溫 {T_WALL_K-273.15:.0f} °C")
print()
print(f" {'v m/s':>7} {'高度 km':>8} {'Mach':>7} {'Re':>10} "
f"{'C_D,f 層流':>11} {'C_D,f 紊流':>11}")
print(" " + "-" * 62)
for r in rows:
print(f" {r['v']:>7,} {r['alt_m']/1000:>8.1f} {r['mach']:>7.1f} "
f"{r['Re_star']:>10.2e} {r['cd_f_lam']:>11.5f} "
f"{r['cd_f_turb']:>11.5f}")
print()
print(" **雷諾數落在 10^5 到 10^6**,平板轉捩通常在 5×10^5 附近 ——")
print(" 所以高空段偏層流、低空段偏紊流,兩欄都要看。")
return 0
if __name__ == "__main__":
sys.exit(main())
# ══════════════════════════════════════════════════════════════════════
# 把摩擦加到 SU2 的無黏解上 —— 這才是「L/D 4.0 站不站得住」的答案
# ══════════════════════════════════════════════════════════════════════
def design_ld_fixed_point(start=None, tol=0.005, damp=0.6, max_iter=20,
verbose=True):
"""設計點的**不動點**。回傳 dict。
## 為什麼設計點必須是不動點
這一章的設計迴圈(第 06 節)在這裡變成一個真的數值問題:
升阻比降低 -> 熄火速度升高 -> 弧終點速度升高
-> Re* 降低 -> C_f 升高 -> 升阻比又更低
所以「整機升阻比」不是一個可以單獨算出來的數 —— 它取決於載具**自己**
的進入條件,而進入條件又取決於升阻比。把摩擦估算釘在某個固定速度上
等於跳過這個迴圈。
實際發生過:設計點由 3.2 改成 2.3 之後,`full_vehicle_ld()` 的參考
速度還寫死在 6,338 m/s(3.2 時的弧終點)。接到 2.3 自己的弧終點
6,798 m/s 之後,整機只給 **2.21** —— 2.3 不自洽。
迭代收斂得很快(增益約 0.17,是壓縮映射):
假設 2.300 -> 2.213 假設 2.210 -> 2.200
假設 2.248 -> 2.206 假設 2.204 -> 2.199
假設 2.223 -> 2.202 **不動點 2.20 @ α 16°**
用阻尼迭代而不是直接代入,是因為直接代入在起點離不動點較遠時會震盪。
"""
import contextlib
import io as _io
import spaceport_build as _B
x = A.LD_DESIGN if start is None else float(start)
hist = []
for _k in range(max_iter):
_B._GEOM_CACHE.clear()
ho = _B.mission_geometry(ld=x, verbose=False)
v = ho["eph_start_tas_ms"]
buf = _io.StringIO()
with contextlib.redirect_stdout(buf):
r = full_vehicle_ld(v_ms=v, verbose=True)
got = r["best"]["ld_total"]
hist.append({"assumed": x, "v_ms": v,
"alt_m": ho["eph_start_alt_m"],
"Re_star": r["Re_star"], "got": got,
"aoa": r["best"]["aoa"]})
if abs(got - x) < tol:
break
x = x + damp * (got - x)
last = hist[-1]
if verbose:
print("=" * 78)
print(" 設計點的不動點(升阻比 <-> 進入條件互相決定)")
print("=" * 78)
print(f" {'假設 L/D':>9} {'弧終點 m/s':>11} {'高度 km':>8} "
f"{'Re*':>10} {'實際 L/D':>9} {'α':>4} {'差':>7}")
for h in hist:
print(f" {h['assumed']:>9.3f} {h['v_ms']:>11,.0f} "
f"{h['alt_m']/1000:>8.1f} {h['Re_star']:>10.2e} "
f"{h['got']:>9.3f} {h['aoa']:>3}° "
f"{h['got']-h['assumed']:>+7.3f}")
print()
conv = abs(last["got"] - last["assumed"]) < tol
print(f" **不動點 L/D = {last['got']:.2f} @ α {last['aoa']}°**"
f" ({'收斂' if conv else '未收斂'},{len(hist)} 次迭代)")
print(f" 目前設計點 {A.LD_DESIGN}")
print()
print(" 把摩擦估算釘在固定速度上會跳過這個迴圈 ——")
print(" 那樣算出來的升阻比屬於**別的**設計點。")
return {"ld": last["got"], "aoa": last["aoa"], "history": hist,
"converged": abs(last["got"] - last["assumed"]) < tol}
def _ref_v_ms():
"""摩擦估算的參考速度 = **目前設計點**的弧終點速度。
先前這兩支函式各自寫死一個值(5,983 是 L/D 4.0 時代的、
6,338 是 3.2 時代的)。摩擦係數是 Re* 的函數,Re* 又由速度與高度
決定 —— 寫死等於拿**別的設計點**的摩擦來評估現在的設計點。
設計點改成 2.3 之後兩個值都過期了,所以改成問一次。
"""
import spaceport_build as _B
return _B.mission_geometry(verbose=False)["eph_start_tas_ms"]
def ld_with_friction(v_ms=None, alt_m=None, verbose=True):
"""在 SU2 的無黏 CL/CD 上加摩擦,算出可達到的升阻比。
**不要用牛頓流當基底。** 第 08 節已經量過:牛頓流在這個後掠三角翼上
低估 L/D 約 15%。用它當基底會得到「L/D 上限 3.7-3.9,設計點 4.0
做不到」的結論,而那個結論有一部分是牛頓流自己的誤差造成的。
"""
import json
import newtonian as N
v_ms = _ref_v_ms() if v_ms is None else v_ms
alt_m = A.equilibrium_glide_alt(v_ms) if alt_m is None else alt_m
mesh = os.path.join(HERE, "mesh_delta3d.su2")
res = os.path.join(HERE, "cfd_results.json")
if not (os.path.isfile(mesh) and os.path.isfile(res)):
print(" 需要先跑 cfd/su2_run.py 產生網格與結果")
return None
doc = json.load(open(res, encoding="utf-8"))
s_ref = doc["delta"]["mesh"]["ref_area_half"]
chord = doc["delta"]["mesh"]["root_chord"]
pts, tris = N.read_su2_surface(mesh, "wing")
s_wet = 0.0
for i, j, k in tris:
p, q, r = pts[i], pts[j], pts[k]
u = (q[0]-p[0], q[1]-p[1], q[2]-p[2])
w = (r[0]-p[0], r[1]-p[1], r[2]-p[2])
nx = (u[1]*w[2]-u[2]*w[1], u[2]*w[0]-u[0]*w[2], u[0]*w[1]-u[1]*w[0])
s_wet += 0.5*math.sqrt(nx[0]**2+nx[1]**2+nx[2]**2)
f = friction(v_ms, alt_m, length_m=chord)
cf = max(f["cf_lam"], f["cf_turb"])
cd_f_wing = cf * s_wet / s_ref
s_wet_full, _ = wetted_area()
ratio_full = (s_wet_full / S_REF_M2) / (s_wet / s_ref)
cd_f_full = cd_f_wing * ratio_full
rows = []
for r in doc["delta"]["rows"]:
if not r.get("converged") or abs(r["cl_cfd"]) < 0.005:
continue
rows.append({
"aoa": r["aoa"], "cl": r["cl_cfd"], "cd_inv": r["cd_cfd"],
"ld_inv": r["ld_cfd"],
"ld_wing": r["cl_cfd"] / (r["cd_cfd"] + cd_f_wing),
"ld_full": r["cl_cfd"] / (r["cd_cfd"] + cd_f_full)})
best_w = max(rows, key=lambda x: x["ld_wing"])
best_f = max(rows, key=lambda x: x["ld_full"])
if verbose:
print("=" * 78)
print(" 加上摩擦之後,升阻比還剩多少")
print("=" * 78)
print(f" 自由流 {v_ms:,.0f} m/s @ {alt_m/1000:.1f} km,"
f"Re {f['Re_star']:.2e},C_f {cf:.5f}")
print(f" 裸翼 S_wet/S_ref {s_wet/s_ref:.2f} -> C_D,f {cd_f_wing:.5f}")
print(f" 整機 S_wet/S_ref {s_wet_full/S_REF_M2:.2f}"
f" -> C_D,f {cd_f_full:.5f}(是裸翼的 {ratio_full:.1f} 倍)")
print()
print(f" {'AoA':>5} {'C_L':>9} {'L/D 無黏':>9} "
f"{'L/D 裸翼+摩擦':>13} {'L/D 整機+摩擦':>13}")
print(" " + "-" * 56)
for r in rows:
print(f" {r['aoa']:>4.0f}° {r['cl']:>9.5f} {r['ld_inv']:>9.2f} "
f"{r['ld_wing']:>13.2f} {r['ld_full']:>13.2f}")
print()
print(f" 裸翼可達上限 {best_w['ld_wing']:.2f} @ α {best_w['aoa']:.0f}°")
print(f" 整機可達上限 {best_f['ld_full']:.2f} @ α {best_f['aoa']:.0f}°")
print(f" **本章設計點 L/D = {A.LD_DESIGN:.1f}**")
print()
if best_f["ld_full"] < A.LD_DESIGN:
print(" ** 整機的可達上限低於設計點。**")
print(" 裸翼還有餘裕,但機身與尾翼把濕面積翻了 2.4 倍,")
print(" 摩擦阻力跟著翻倍,L/D 掉到設計點之下。")
print()
print(" 界線:平板關係式套在有後掠、有攻角的機體上是量級估算;")
print(" 整機那一欄把**裸翼的無黏係數**配上**整機的濕面積**,")
print(" 不是嚴謹的整機解 —— 機身也會產生升力,這裡沒有計入。")
print(" 要給出可信的整機 L/D,需要整機的黏性解(RANS)。")
return {"rows": rows, "cd_f_wing": cd_f_wing, "cd_f_full": cd_f_full,
"best_wing": best_w, "best_full": best_f,
"s_wet_wing": s_wet, "s_wet_full": s_wet_full,
"cf": cf, "Re": f["Re_star"]}
# ══════════════════════════════════════════════════════════════════════
# 整機升阻比:把機身的升力也算進去
#
# 設計點 3.2 是「**裸翼**的無黏係數 + **整機**的濕面積」湊出來的,
# 機身產生的升力從頭到尾沒有計入 —— 那是那個數字最大的弱點。
#
# 這裡改成:整機牛頓流(含遮蔽)-> 套 SU2 在裸翼上量到的修正 -> 加摩擦。
# ══════════════════════════════════════════════════════════════════════
def full_vehicle_ld(v_ms=None, alt_m=None, cells=240, verbose=True):
"""整機可達到的升阻比。回傳 dict。
三個環節,可信度**遞減**:
1. 整機牛頓流(含遮蔽)—— 幾何是實際網格,遮蔽經解析度掃描
確認 L/D 對格子數不敏感(100-320 格之間 2.87-2.90)
2. 乘上 SU2 在**裸翼**上量到的 SU2/牛頓 比值 ——
**這是外插**,整機(鈍頭、有尾翼)的誤差不見得和裸翼一樣
3. 加平板摩擦 —— 絕熱壁溫已壓上限,但 Re* 仍在 1e4 量級,
平板關係式在那裡本來就勉強
所以結果要當成**區間**,不是一個值。
"""
import json
import newtonian as N
v_ms = _ref_v_ms() if v_ms is None else v_ms
alt_m = A.equilibrium_glide_alt(v_ms) if alt_m is None else alt_m
res = os.path.join(HERE, "cfd_results.json")
if not os.path.isfile(res):
print(" 需要先跑 cfd/su2_run.py")
return None
doc = json.load(open(res, encoding="utf-8"))
ratio = {r["aoa"]: r["ld_cfd"] / r["ld_new"]
for r in doc["delta"]["rows"]
if r.get("converged") and r.get("ld_new")
and abs(r["cl_cfd"]) > 0.005}
def r_at(aoa):
ks = sorted(ratio)
if aoa <= ks[0]:
return ratio[ks[0]]
if aoa >= ks[-1]:
return ratio[ks[-1]]
for i in range(1, len(ks)):
if aoa <= ks[i]:
x0, x1 = ks[i - 1], ks[i]
return (ratio[x0] + (ratio[x1] - ratio[x0])
* (aoa - x0) / (x1 - x0))
f = friction(v_ms, alt_m, length_m=LENGTH_M)
cf = max(f["cf_lam"], f["cf_turb"])
s_wet, _ = wetted_area()
cd_f = cf * s_wet / S_REF_M2
rows = []
for aoa in (5, 8, 10, 12, 14, 16, 20, 25):
r = N.forces_full_shadowed(20.0, float(aoa), ref_area=S_REF_M2,
cells=cells)
k = r_at(aoa)
ld_inv = r["CEff"] * k
cd_inv = r["CL"] / ld_inv
rows.append({"aoa": aoa, "cl": r["CL"], "cd_new": r["CD"],
"ld_new": r["CEff"], "k": k, "ld_inv": ld_inv,
"ld_total": r["CL"] / (cd_inv + cd_f)})
best = max(rows, key=lambda x: x["ld_total"])
best_inv = max(rows, key=lambda x: x["ld_inv"])
if verbose:
print("=" * 78)
print(" 整機升阻比(牛頓流 + 遮蔽 -> SU2 修正 -> 摩擦)")
print("=" * 78)
print(f" {v_ms:,.0f} m/s @ {alt_m/1000:.1f} km、Re* {f['Re_star']:.2e}")
print(f" 整機 C_D,f {cd_f:.5f}(S_wet/S_ref {s_wet/S_REF_M2:.2f})")
print()
print(f" {'AoA':>5} {'C_L':>9} {'L/D 牛頓':>9} {'×SU2':>7} "
f"{'L/D 無黏':>9} {'L/D 含摩擦':>11}")
print(" " + "-" * 56)
for r in rows:
print(f" {r['aoa']:>4}° {r['cl']:>9.5f} {r['ld_new']:>9.3f} "
f"{r['k']:>7.3f} {r['ld_inv']:>9.3f} {r['ld_total']:>11.3f}")
print()
print(f" 整機無黏上限 {best_inv['ld_inv']:.2f} @ α "
f"{best_inv['aoa']}°")
print(f" **整機含摩擦 {best['ld_total']:.2f} @ α {best['aoa']}°**")
print(f" 目前設計點 {A.LD_DESIGN}")
print()
print(" 對照:裸翼(SU2 無黏 + 整機濕面積的摩擦)給 3.21 ——")
print(" 那個數字沒有計入機身的升力與阻力。加進來之後掉到 "
f"{best['ld_total']:.1f}。")
print()
print(" **這個數字已經被採納成設計點(2026-09-25)。**")
print(" 採納前先跑了敏感度掃描(L/D 2.0-3.4):一路到 2.0 都還可行,")
print(" 質量比最高 16.1(低於 20:1 那條線)、酬載分數仍為正。")
print()
print(" 為什麼相信它多過 3.2:兩者用**同一套摩擦**,差別只在")
print(" 無黏那一段描述的是誰。這裡描述的是**這架載具**,")
print(" 3.2 描述的是一片不是這架載具的翼。")
print()
print(" 但它仍是估算:三個環節裡只有第一個是直接算的,")
print(" 第二個是從裸翼外插、第三個的 Re* 在 1e4 量級。")
print(" 要把它釘死,需要**整機的 SU2 解**(尚未做)。")
return {"rows": rows, "best": best, "best_inviscid": best_inv,
"cd_f": cd_f, "ratio": ratio, "Re_star": f["Re_star"]}
spaceport_suborbital/cfd/mesh_repair.py
# -*- coding: utf-8 -*-
r"""把 `model/twsp2_spaceplane.obj` 修成可以產體網格的水密曲面。
## 為什麼需要這一支
設計點 2.2 的三個環節裡,第二環是**從裸翼外插到整機**的修正因子
(牛頓流對 SU2 的比值)。要收窄它只有一條路:**整機的 SU2 解**。
而整機的 SU2 解要先有體網格,體網格要先有**水密且流形**的曲面。
拿到的 `.obj` 兩者都不是。實測的缺陷:
| 症狀 | 數量 | 是什麼 |
|---|---|---|
| 零面積三角形 | 58 | 退化 |
| 重複頂點 | 29 | 同一點寫了兩次 |
| 自環邊 `(1094,1094)` 被 112 個面共用 | 1 | **機鼻頂點 (0,0,9) 塌成一點**的扇形 |
| 被 4 個面共用的邊 | 30 | 非流形 |
| 被 3 個面共用的邊 | 28 | **z = 6.80 平面上的內部隔板**(3.858 m²) |
| 只被 1 個面共用的邊 | 128 | 三個洞,全在機尾 |
直接丟給 gmsh 產體網格會失敗,而且**失敗訊息不會告訴你是哪一種**。
## 修的順序(順序有意義)
1. **合併重複頂點**(座標取到 1e-6)—— 不先做,後面的邊統計全是錯的
2. **丟掉索引重複的三角形** —— 這一步才會消掉機鼻那個 112 面的自環邊
3. **丟掉零面積三角形**
4. **丟掉重複的面**(同一組頂點出現兩次)
5. **移掉內部隔板** —— 它的每條邊都是「隔板 + 兩個外殼面」,
也就是外殼本來就是封的,移掉之後那些邊自然變成 2
6. **補洞** —— 三個洞**實測都是平面的**(對質心做 SVD,第三奇異值 / 第一
奇異值 = 0.0000),所以用質心扇形補是正確的,不是湊合
## 結果(實測)
原始 點 5,369 面 10,644 邊分布 {1:128, 2:15786, 4:30, 112:1}
修完 點 5,341 面 10,656 邊分布 {2:15984} <- 水密且流形
有號體積 109.84 -> 109.909 m³(+0.06%)
體積幾乎不變,說明補的洞確實只是薄薄的開口,不是把一大塊填實了。
python cfd/mesh_repair.py # 修並回報
python cfd/mesh_repair.py --stl # 另外寫出 STL 給 gmsh
"""
import collections
import os
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import newtonian as N # noqa: E402
OBJ = os.path.join(os.path.dirname(HERE), "model", "twsp2_spaceplane.obj")
STL_OUT = os.path.join(HERE, "twsp2_watertight.stl")
MERGE_TOL = 1e-6 # 頂點合併的座標容差(m)
AREA_TOL = 1e-9 # 小於這個面積視為退化(m²)
PLANAR_TOL = 1e-3 # 洞的平面性容差(第三奇異值 / 第一奇異值)
def edge_counts(faces):
"""每條無向邊被幾個面共用。"""
e = collections.Counter()
for a, b, c in faces:
for x in ((a, b), (b, c), (c, a)):
e[(a, b) if a < b else (b, a)] += 1 if x else 1
# 上面那行只是為了排版;實際要的是對每條邊各數一次
e = collections.Counter()
for a, b, c in faces:
for u, v in ((a, b), (b, c), (c, a)):
e[(u, v) if u < v else (v, u)] += 1
return e
def loops_from_open_edges(open_edges):
"""把開放邊接成連通分量。回傳 [[頂點索引, ...], ...]。"""
adj = collections.defaultdict(list)
for a, b in open_edges:
adj[a].append(b)
adj[b].append(a)
seen, out = set(), []
for s in adj:
if s in seen:
continue
comp, stack = [], [s]
while stack:
u = stack.pop()
if u in seen:
continue
seen.add(u)
comp.append(u)
stack += [w for w in adj[u] if w not in seen]
out.append(comp)
return out
def repair(obj_path=OBJ, verbose=True):
"""回傳 (points, faces, report)。points/faces 是 numpy 陣列。"""
V, F = N.read_obj(obj_path)
V = np.asarray(V, dtype=float)
F = np.asarray(F, dtype=np.int64)
rep = {"in_points": len(V), "in_faces": len(F)}
# 1. 合併重複頂點
_, inv = np.unique(np.round(V, int(-np.log10(MERGE_TOL))), axis=0,
return_inverse=True)
P = np.zeros((int(inv.max()) + 1, 3))
P[inv] = V
F = inv[F]
rep["merged_points"] = len(V) - len(P)
# 2. 索引重複的三角形(機鼻那個塌成一點的扇形就在這裡消掉)
keep = ((F[:, 0] != F[:, 1]) & (F[:, 1] != F[:, 2])
& (F[:, 2] != F[:, 0]))
rep["dropped_repeated_index"] = int((~keep).sum())
F = F[keep]
# 3. 零面積
area = 0.5 * np.linalg.norm(
np.cross(P[F[:, 1]] - P[F[:, 0]], P[F[:, 2]] - P[F[:, 0]]), axis=1)
rep["dropped_degenerate"] = int((area <= AREA_TOL).sum())
F = F[area > AREA_TOL]
# 4. 重複的面
_, idx = np.unique(np.sort(F, axis=1), axis=0, return_index=True)
rep["dropped_duplicate_face"] = len(F) - len(idx)
F = F[np.sort(idx)]
# 5. 內部隔板:整片落在同一個平面、而且它的邊都是三面邊
e = edge_counts(F)
nm = [k for k, v in e.items() if v > 2]
rep["nonmanifold_edges_before"] = len(nm)
if nm:
# 隔板的**邊界環**才是三面邊,內部的三角形不是 ——
# 所以不能用「三個頂點都在三面邊上」來找 —— 那樣一個都選不到。
# 改成:先從三面邊定出那個平面的 z,
# 再把**整片落在該平面上**的面都選起來。
touched = set()
for u, v in nm:
touched.update((u, v))
z_plane = float(np.median(P[sorted(touched)][:, 2]))
cand = [fi for fi, tri in enumerate(F)
if (P[tri][:, 2].max() - P[tri][:, 2].min() < MERGE_TOL
and abs(P[tri][:, 2].mean() - z_plane) < MERGE_TOL)]
if cand:
a = 0.5 * np.linalg.norm(
np.cross(P[F[cand][:, 1]] - P[F[cand][:, 0]],
P[F[cand][:, 2]] - P[F[cand][:, 0]]), axis=1)
rep["bulkhead_faces"] = len(cand)
rep["bulkhead_area_m2"] = float(a.sum())
rep["bulkhead_z"] = float(P[F[cand[0]]][:, 2].mean())
mask = np.ones(len(F), dtype=bool)
mask[cand] = False
F = F[mask]
# 6. 補洞
e = edge_counts(F)
op = [k for k, v in e.items() if v == 1]
rep["open_edges"] = len(op)
loops = loops_from_open_edges(op)
rep["holes"] = []
Pl = list(P)
new = []
for L in loops:
q = P[L]
c = q.mean(0)
s = np.linalg.svd(q - c, compute_uv=False)
flat = float(s[2] / max(s[0], 1e-12))
rep["holes"].append({"points": len(L), "centroid": c.tolist(),
"planarity": flat,
"planar": flat < PLANAR_TOL})
ci = len(Pl)
Pl.append(c)
ring = set(L)
for a, b in op:
if a in ring and b in ring:
new.append((a, b, ci))
P = np.array(Pl)
if new:
F = np.vstack([F, np.array(new, dtype=np.int64)])
rep["patch_faces"] = len(new)
e = edge_counts(F)
counts = collections.Counter(e.values())
rep["edge_counts"] = dict(sorted(counts.items()))
rep["watertight"] = (set(counts) == {2})
rep["out_points"] = len(P)
rep["out_faces"] = len(F)
rep["volume_m3"] = float(N.signed_volume(P, F))
if verbose:
_report(rep)
return P, F, rep
def _report(r):
print("=" * 78)
print(" 整機曲面修復(給 SU2 體網格用)")
print("=" * 78)
print(f" 輸入 點 {r['in_points']:,} 面 {r['in_faces']:,}")
print(f" 合併重複頂點 {r['merged_points']}")
print(f" 丟掉索引重複的三角形 {r['dropped_repeated_index']}"
f"(機鼻塌成一點的扇形)")
print(f" 丟掉零面積三角形 {r['dropped_degenerate']}")
print(f" 丟掉重複的面 {r['dropped_duplicate_face']}")
if "bulkhead_faces" in r:
print(f" 移掉內部隔板 {r['bulkhead_faces']} 面、"
f"{r['bulkhead_area_m2']:.3f} m² @ z = {r['bulkhead_z']:.2f}")
print(f" 開放邊 {r['open_edges']} 條,{len(r['holes'])} 個洞:")
for h in r["holes"]:
c = h["centroid"]
print(f" {h['points']:>3} 點 質心 ({c[0]:6.2f},{c[1]:6.2f},"
f"{c[2]:6.2f}) 平面性 {h['planarity']:.4f}"
f" {'平面' if h['planar'] else '**非平面,質心扇形不適用**'}")
print(f" 補了 {r['patch_faces']} 個三角形")
print()
print(f" 輸出 點 {r['out_points']:,} 面 {r['out_faces']:,}")
print(f" 邊分布 {r['edge_counts']}")
print(f" **{'水密且流形' if r['watertight'] else '仍未水密 —— 不能產體網格'}**")
print(f" 有號體積 {r['volume_m3']:,.3f} m³")
def write_stl(path, P, F, name="twsp2"):
"""寫 ASCII STL 給 gmsh 讀。"""
n = np.cross(P[F[:, 1]] - P[F[:, 0]], P[F[:, 2]] - P[F[:, 0]])
ln = np.linalg.norm(n, axis=1, keepdims=True)
n = n / np.maximum(ln, 1e-30)
with open(path, "w", encoding="ascii", newline="\n") as f:
f.write(f"solid {name}\n")
for tri, nv in zip(F, n):
f.write(f" facet normal {nv[0]:.6e} {nv[1]:.6e} {nv[2]:.6e}\n")
f.write(" outer loop\n")
for i in tri:
p = P[i]
f.write(f" vertex {p[0]:.6e} {p[1]:.6e} {p[2]:.6e}\n")
f.write(" endloop\n endfacet\n")
f.write(f"endsolid {name}\n")
return len(F)
def main():
P, F, rep = repair()
if "--stl" in sys.argv:
n = write_stl(STL_OUT, P, F)
print(f"\n 已寫出 {os.path.basename(STL_OUT)}({n:,} 個三角形)")
return 0 if rep["watertight"] else 1
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/cfd/newtonian.py
# -*- coding: utf-8 -*-
"""在**同一份 SU2 網格**上算修正牛頓流,才能和 CFD 逐面對等比較。
工作單第 07 節用的就是這個方法(套在整架太空飛機的表面網格上)。
這裡把它套在三角翼的 CFD 網格上,好處是兩邊用的是同一組面元、
同一個參考面積,差異就只來自物理模型本身。
模型:
Cp = Cp_max · sin²δ δ 是面元與自由流的夾角(迎風面)
Cp = 0 背風面(sin δ <= 0)—— 牛頓流不給背風壓力
Cp_max 由正震波後的駐點壓力給出,Mach -> ∞ 時趨近 1.839
(馬赫數獨立原理,這也是它在高超音速比在低超音速**更**站得住的原因)
它不知道的事:震波在哪裡、鄰近面元怎麼互相影響、膨脹波、
背風面的真實壓力(不是零,是低於自由流)。
"""
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
def read_su2_surface(path, marker):
"""讀 .su2 的某個 MARKER,回傳 (節點座標 list, 三角形索引 list)。"""
pts, tris = [], []
with open(path, encoding="utf-8", errors="replace") as fh:
lines = fh.read().splitlines()
i = 0
npoin = 0
while i < len(lines):
ln = lines[i].strip()
if ln.startswith("NPOIN="):
npoin = int(ln.split("=")[1].split()[0])
for k in range(1, npoin + 1):
p = lines[i + k].split()
pts.append((float(p[0]), float(p[1]), float(p[2])))
i += npoin
elif ln.startswith("MARKER_TAG="):
tag = ln.split("=")[1].strip()
nl = lines[i + 1].strip()
nel = int(nl.split("=")[1].split()[0])
if tag == marker:
for k in range(nel):
p = lines[i + 2 + k].split()
if int(p[0]) == 5: # VTK 三角形
tris.append((int(p[1]), int(p[2]), int(p[3])))
elif int(p[0]) == 9: # 四邊形 -> 拆兩個三角形
a, b, c, d = (int(x) for x in p[1:5])
tris.append((a, b, c))
tris.append((a, c, d))
i += nel + 1
i += 1
return pts, tris
def cp_max(mach, gamma=1.4):
m2 = mach * mach
p0p = (((gamma + 1.0) ** 2 * m2
/ (4.0 * gamma * m2 - 2.0 * (gamma - 1.0))) ** (gamma / (gamma - 1.0))
* ((1.0 - gamma + 2.0 * gamma * m2) / (gamma + 1.0)))
return 2.0 / (gamma * m2) * (p0p - 1.0)
def forces(mesh_path, marker, mach, aoa_deg, ref_area, gamma=1.4):
"""回傳 dict:CL、CD、CEff,以及迎風/背風面積。
自由流方向在 x-y 平面內轉 AoA(與 SU2 的 AOA 定義一致),
展向是 z。半邊模型,所以 ref_area 要用半邊參考面積。
"""
pts, tris = read_su2_surface(mesh_path, marker)
if not tris:
return None
a = math.radians(aoa_deg)
# 與 SU2 三維慣例一致:x 順流、y 展向、z 升力方向,AoA 在 x-z 平面
v = (math.cos(a), 0.0, math.sin(a))
cpm = cp_max(mach, gamma)
fx = fz = 0.0
a_wind = a_lee = 0.0
for i, j, k in tris:
p, q, r = pts[i], pts[j], pts[k]
ux = (q[0] - p[0], q[1] - p[1], q[2] - p[2])
wx = (r[0] - p[0], r[1] - p[1], r[2] - p[2])
nx = (ux[1] * wx[2] - ux[2] * wx[1],
ux[2] * wx[0] - ux[0] * wx[2],
ux[0] * wx[1] - ux[1] * wx[0])
area2 = math.sqrt(nx[0] ** 2 + nx[1] ** 2 + nx[2] ** 2)
if area2 < 1e-14:
continue
area = 0.5 * area2
n = (nx[0] / area2, nx[1] / area2, nx[2] / area2)
# 外法向的方向由三角形繞向決定,不保證一致 ——
# 用「從面心指向外」的判斷太脆弱,這裡改用:對閉合殼體,
# 讓法向遠離形心。三角翼是凸的,這個判準成立。
cen = ((p[0] + q[0] + r[0]) / 3.0, (p[1] + q[1] + r[1]) / 3.0,
(p[2] + q[2] + r[2]) / 3.0)
out = (cen[0] - CENTROID[0], cen[1] - CENTROID[1],
cen[2] - CENTROID[2])
if n[0] * out[0] + n[1] * out[1] + n[2] * out[2] < 0.0:
n = (-n[0], -n[1], -n[2])
sin_d = -(v[0] * n[0] + v[1] * n[1] + v[2] * n[2])
if sin_d <= 0.0:
a_lee += area
continue # 背風面:牛頓流給 0
a_wind += area
cp = cpm * sin_d * sin_d
# 壓力沿 -n 作用
fx += -cp * area * n[0]
fz += -cp * area * n[2]
cx, cz = fx / ref_area, fz / ref_area
cd = cx * math.cos(a) + cz * math.sin(a)
cl = -cx * math.sin(a) + cz * math.cos(a)
return {"CL": cl, "CD": cd, "CEff": cl / cd if abs(cd) > 1e-12 else None,
"cp_max": cpm, "n_tri": len(tris),
"area_windward": a_wind, "area_leeward": a_lee}
CENTROID = (0.0, 0.0, 0.0)
def set_centroid(mesh_path, marker):
"""形心 —— 用來把面法向統一朝外。"""
global CENTROID
pts, tris = read_su2_surface(mesh_path, marker)
used = sorted({i for t in tris for i in t})
if not used:
return
CENTROID = tuple(sum(pts[i][d] for i in used) / len(used) for d in range(3))
if __name__ == "__main__":
mesh = os.path.join(HERE, "mesh_delta3d.su2")
import su2_mesh as M
_p, info = None, None
set_centroid(mesh, "wing")
print(f" 形心 {tuple(round(c, 3) for c in CENTROID)}")
print(f" Cp_max @ Mach 20 = {cp_max(20.0):.4f}")
print()
print(f" {'AoA':>5} {'CL':>10} {'CD':>10} {'L/D':>8} "
f"{'迎風面積':>10} {'背風面積':>10}")
for aoa in (0, 5, 10, 15, 20):
r = forces(mesh, "wing", 20.0, aoa, ref_area=37.675)
print(f" {aoa:5.0f} {r['CL']:10.5f} {r['CD']:10.5f} "
f"{(r['CEff'] if r['CEff'] else float('nan')):8.3f} "
f"{r['area_windward']:10.2f} {r['area_leeward']:10.2f}")
# ══════════════════════════════════════════════════════════════════════
# 整機:在 model/twsp2_spaceplane.obj 上做同一套牛頓流
#
# 為什麼需要:設計點 3.2 是「**裸翼**的無黏係數 + **整機**的濕面積」
# 湊出來的,機身產生的升力從頭到尾沒有計入 ——
# 那是那個數字目前最大的弱點。
#
# 這裡把牛頓流直接套在整機網格上,拿到含機身的 CL/CD。
#
# **法向朝外的判斷不能用「遠離形心」** —— 整機不是凸形(有機翼、
# 垂尾),那個判準在翼根附近會判反。改用面繞向,並先用散度定理
# 驗證整體封閉且朝外(實測有號體積 +109.84 m³)。
# ══════════════════════════════════════════════════════════════════════
OBJ_FULL = os.path.join(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))), "model", "twsp2_spaceplane.obj")
def read_obj(path=None):
"""回傳 (頂點, 三角面)。面繞向沿用檔案裡的。"""
path = path or OBJ_FULL
verts, tris = [], []
with open(path, encoding="utf-8", errors="replace") as fh:
for ln in fh:
if ln.startswith("v "):
p = ln.split()
verts.append((float(p[1]), float(p[2]), float(p[3])))
elif ln.startswith("f "):
idx = [int(t.split("/")[0]) - 1 for t in ln.split()[1:]]
for k in range(1, len(idx) - 1):
tris.append((idx[0], idx[k], idx[k + 1]))
return verts, tris
def signed_volume(verts, tris):
"""散度定理。正值代表繞向朝外、而且網格是封閉的。"""
v6 = 0.0
for i, j, k in tris:
a, b, c = verts[i], verts[j], verts[k]
v6 += (a[0] * (b[1] * c[2] - b[2] * c[1])
- a[1] * (b[0] * c[2] - b[2] * c[0])
+ a[2] * (b[0] * c[1] - b[1] * c[0]))
return v6 / 6.0
def forces_full(mach, aoa_deg, ref_area=85.0, path=None, gamma=1.4):
"""整機的牛頓流 CL/CD。
座標(由 model/spaceplane_trimesh.py 決定):
x 展向、y 垂直(上為正)、z 縱向(機頭在 +z)
攻角為正 = 機頭朝上,相對風由下方來:
自由流方向 V = (0, +sin α, −cos α)
升力方向 L = (0, +cos α, +sin α)
"""
verts, tris = read_obj(path)
vol = signed_volume(verts, tris)
flip = -1.0 if vol < 0 else 1.0 # 檔案若是朝內就整體翻面
a = math.radians(aoa_deg)
v = (0.0, math.sin(a), -math.cos(a))
lhat = (0.0, math.cos(a), math.sin(a))
cpm = cp_max(mach, gamma)
cf = [0.0, 0.0, 0.0]
a_wind = a_lee = 0.0
for i, j, k in tris:
p, q, r = verts[i], verts[j], verts[k]
u = (q[0]-p[0], q[1]-p[1], q[2]-p[2])
w = (r[0]-p[0], r[1]-p[1], r[2]-p[2])
nx = (u[1]*w[2]-u[2]*w[1], u[2]*w[0]-u[0]*w[2], u[0]*w[1]-u[1]*w[0])
mag = math.sqrt(nx[0]**2 + nx[1]**2 + nx[2]**2)
if mag < 1e-14:
continue
area = 0.5 * mag
n = (flip*nx[0]/mag, flip*nx[1]/mag, flip*nx[2]/mag)
sin_d = -(v[0]*n[0] + v[1]*n[1] + v[2]*n[2])
if sin_d <= 0.0:
a_lee += area
continue
a_wind += area
cp = cpm * sin_d * sin_d
for d in range(3):
cf[d] += -cp * area * n[d]
cf = [c / ref_area for c in cf]
cd = cf[0]*v[0] + cf[1]*v[1] + cf[2]*v[2]
cl = cf[0]*lhat[0] + cf[1]*lhat[1] + cf[2]*lhat[2]
return {"CL": cl, "CD": cd, "CEff": cl/cd if abs(cd) > 1e-12 else None,
"cp_max": cpm, "n_tri": len(tris), "volume_m3": vol,
"area_windward": a_wind, "area_leeward": a_lee}
if __name__ == "__main__" and "--full" in sys.argv:
verts, tris = read_obj()
print(f" 整機網格 {len(tris):,} 面,有號體積 "
f"{signed_volume(verts, tris):,.2f} m³")
print(f" {'AoA':>5} {'CL':>10} {'CD':>10} {'L/D':>8} "
f"{'cot α':>8} {'迎風/背風 m²':>16}")
for aoa in (5, 8, 10, 12, 14, 16, 20):
r = forces_full(20.0, aoa)
cot = 1.0/math.tan(math.radians(aoa))
print(f" {aoa:>4}° {r['CL']:>10.5f} {r['CD']:>10.5f} "
f"{(r['CEff'] or float('nan')):>8.3f} {cot:>8.3f} "
f"{r['area_windward']:>7.1f} /{r['area_leeward']:>7.1f}")
# ══════════════════════════════════════════════════════════════════════
# 遮蔽測試:牛頓流只能作用在**直接被自由流打到**的面
#
# 牛頓流只看面元自己的傾角,不知道自己是不是躲在別人後面。
# 裸翼是凸的薄體,所以沒差;**整機不是**:
#
# 船尾式收縮的後體,外法向朝前 -> 牛頓流判定它迎風並給正壓力
#
# 實測在整機上這個假阻力有多大:機尾 z −9..−5 貢獻 CD 0.22,
# 而整機總 CD 只有 0.33 —— **三分之二是假的**。
#
# 做法:沿自由流方向做畫家演算法。把所有面元投影到垂直自由流的平面,
# 依深度由近到遠掃過去;某個格子已經被更前面的面佔住,後面的就算被遮蔽。
#
# **界線**:格子有解析度,所以邊緣會有鋸齒;細長的縫隙可能判錯。
# 這是工程近似,不是精確的光線追蹤。格子數由 `cells` 控制,
# 下面會掃描它確認結果對解析度不敏感。
# ══════════════════════════════════════════════════════════════════════
def _basis(v):
"""給自由流方向,回傳垂直它的兩個單位向量。"""
up = (0.0, 1.0, 0.0)
if abs(v[1]) > 0.9:
up = (1.0, 0.0, 0.0)
e1 = (v[1]*up[2]-v[2]*up[1], v[2]*up[0]-v[0]*up[2],
v[0]*up[1]-v[1]*up[0])
m = math.sqrt(sum(c*c for c in e1))
e1 = tuple(c/m for c in e1)
e2 = (v[1]*e1[2]-v[2]*e1[1], v[2]*e1[0]-v[0]*e1[2],
v[0]*e1[1]-v[1]*e1[0])
return e1, e2
def shadow_fraction(verts, tris, v, cells=180, cover=0.55):
r"""回傳 {三角形索引: 可見比例 0..1}。**用深度緩衝(z-buffer)。**
## 遮蔽測試有兩種常見作法,兩種都不收斂
**形心點取樣。** 完全由網格密度決定 —— 格子一細,
每個三角形各佔一格就什麼都遮不到(遮蔽面積 57 -> 10 m²)。
**投影足跡的『包圍盒』+ 佔用集合。**
兩個錯疊在一起,方向相反所以看起來穩定:
* 包圍盒會**過度宣稱** —— 細長的斜三角形霸佔整個盒子,
等於宣稱遮住了它沒蓋到的地方
* 佔用集合會**漏** —— 格子越細,三角形之間的空隙越多,
後面的面就從縫裡「看得到」
實測解析度掃描(α 16°):80 格 L/D 1.97、240 格 2.08、320 格 1.53。
**那不是收斂,是兩個誤差在打架。**
**本模組用深度緩衝。** 兩趟:
1. 把每個三角形光柵化(**格心落在三角形內**才算,不是包圍盒),
每格記下最近的深度
2. 再掃一次,看每個三角形有多少比例的格子「自己就是最近的」。
可見比例低於 `1 − cover` 就算被遮蔽
解析度提高時這會收斂到真正的可見性,因為它問的是
「這一格前面有沒有別的東西」,而不是「這一格有沒有被誰宣稱過」。
## 零面積三角形不參與
它沒有面積,擋不住任何東西,但**投影足跡可能很長**
(兩個頂點重合、第三點很遠)。本模型的 `.obj` 有 58 個這種三角形
(機鼻塌成一點的扇形),讓整機 L/D 被高估。
"""
e1, e2 = _basis(v)
def proj(p):
return (p[0]*e1[0] + p[1]*e1[1] + p[2]*e1[2],
p[0]*e2[0] + p[1]*e2[1] + p[2]*e2[2])
items = []
lo1 = lo2 = 1e18
hi1 = hi2 = -1e18
for t, (i, j, k) in enumerate(tris):
p, q, r = verts[i], verts[j], verts[k]
ux = (q[0]-p[0], q[1]-p[1], q[2]-p[2])
wx = (r[0]-p[0], r[1]-p[1], r[2]-p[2])
cr = (ux[1]*wx[2]-ux[2]*wx[1], ux[2]*wx[0]-ux[0]*wx[2],
ux[0]*wx[1]-ux[1]*wx[0])
if (cr[0]*cr[0] + cr[1]*cr[1] + cr[2]*cr[2]) < 1e-24:
continue # 零面積:擋不住東西
a = proj(p); b = proj(q); c = proj(r)
dp = p[0]*v[0] + p[1]*v[1] + p[2]*v[2]
dq = q[0]*v[0] + q[1]*v[1] + q[2]*v[2]
dr = r[0]*v[0] + r[1]*v[1] + r[2]*v[2]
items.append((t, a, b, c, dp, dq, dr))
lo1 = min(lo1, a[0], b[0], c[0]); hi1 = max(hi1, a[0], b[0], c[0])
lo2 = min(lo2, a[1], b[1], c[1]); hi2 = max(hi2, a[1], b[1], c[1])
s1 = (hi1 - lo1) / cells or 1.0
s2 = (hi2 - lo2) / cells or 1.0
# ── 第一趟:深度緩衝 ────────────────────────────────────────────
zbuf = {}
foot = {}
for t, a, b, c, dp, dq, dr in items:
cov = _raster_tri_depth(a, b, c, dp, dq, dr, lo1, lo2, s1, s2)
foot[t] = cov
for cell, d in cov:
z = zbuf.get(cell)
if z is None or d < z:
zbuf[cell] = d
# ── 第二趟:每個面**看得見多少比例** ────────────────────────────
# 回傳比例而不是「遮/不遮」。二元分類收斂不了:面只要卡在門檻上,
# 解析度一變就整片翻面,CD 跟著跳。實測 z-buffer + 門檻在
# 240/320/440/600 格之間讓 L/D 在 1.52 與 1.75 之間來回。
# 用比例加權,解析度提高時它會平滑收斂到真正的可見面積。
tol = 1e-6 * max(1.0, abs(hi1 - lo1))
frac = {}
for t, cov in foot.items():
if not cov:
continue
vis = sum(1 for cell, d in cov if d <= zbuf[cell] + tol)
frac[t] = vis / len(cov)
return frac
def _raster_tri_depth(A, B, C, dA, dB, dC, lo1, lo2, s1, s2):
"""三角形覆蓋到的 [(格子, 深度)]。格心落在三角形內才算。
深度用重心座標內插 —— 用形心深度代替整個三角形,
在傾斜的大三角形上會差很多。
"""
x0 = min(A[0], B[0], C[0]); x1 = max(A[0], B[0], C[0])
y0 = min(A[1], B[1], C[1]); y1 = max(A[1], B[1], C[1])
i0 = int((x0 - lo1) / s1); i1 = int((x1 - lo1) / s1)
j0 = int((y0 - lo2) / s2); j1 = int((y1 - lo2) / s2)
det = ((B[1]-C[1])*(A[0]-C[0]) + (C[0]-B[0])*(A[1]-C[1]))
out = []
if abs(det) > 1e-18:
for ii in range(i0, i1 + 1):
px = lo1 + (ii + 0.5) * s1
for jj in range(j0, j1 + 1):
py = lo2 + (jj + 0.5) * s2
l1 = ((B[1]-C[1])*(px-C[0]) + (C[0]-B[0])*(py-C[1])) / det
l2 = ((C[1]-A[1])*(px-C[0]) + (A[0]-C[0])*(py-C[1])) / det
if l1 < -1e-9 or l2 < -1e-9 or (l1 + l2) > 1 + 1e-9:
continue
l3 = 1.0 - l1 - l2
out.append(((ii, jj), l1*dA + l2*dB + l3*dC))
if not out:
# 比一個格子還小、或投影退化成線:記形心那一格
cx = (A[0] + B[0] + C[0]) / 3.0
cy = (A[1] + B[1] + C[1]) / 3.0
out = [((int((cx - lo1) / s1), int((cy - lo2) / s2)),
(dA + dB + dC) / 3.0)]
return out
def forces_full_shadowed(mach, aoa_deg, ref_area=85.0, path=None,
cells=220, gamma=1.4):
"""整機牛頓流,**含遮蔽測試**。"""
verts, tris = read_obj(path)
flip = -1.0 if signed_volume(verts, tris) < 0 else 1.0
a = math.radians(aoa_deg)
v = (0.0, math.sin(a), -math.cos(a))
lhat = (0.0, math.cos(a), math.sin(a))
cpm = cp_max(mach, gamma)
vis = shadow_fraction(verts, tris, v, cells)
cf = [0.0, 0.0, 0.0]
a_wind = a_shadow = 0.0
for t, (i, j, k) in enumerate(tris):
p, q, r = verts[i], verts[j], verts[k]
u = (q[0]-p[0], q[1]-p[1], q[2]-p[2])
w = (r[0]-p[0], r[1]-p[1], r[2]-p[2])
nx = (u[1]*w[2]-u[2]*w[1], u[2]*w[0]-u[0]*w[2], u[0]*w[1]-u[1]*w[0])
mag = math.sqrt(nx[0]**2 + nx[1]**2 + nx[2]**2)
if mag < 1e-14:
continue
area = 0.5 * mag
n = (flip*nx[0]/mag, flip*nx[1]/mag, flip*nx[2]/mag)
sin_d = -(v[0]*n[0] + v[1]*n[1] + v[2]*n[2])
if sin_d <= 0.0:
continue
# **用可見比例加權**,不是二選一 —— 見 shadow_fraction()
f = vis.get(t, 1.0)
a_shadow += area * (1.0 - f)
if f <= 0.0:
continue
a_wind += area * f
cp = cpm * sin_d * sin_d
for d in range(3):
cf[d] += -cp * area * f * n[d]
cf = [c / ref_area for c in cf]
cd = cf[0]*v[0] + cf[1]*v[1] + cf[2]*v[2]
cl = cf[0]*lhat[0] + cf[1]*lhat[1] + cf[2]*lhat[2]
return {"CL": cl, "CD": cd, "CEff": cl/cd if abs(cd) > 1e-12 else None,
"area_windward": a_wind, "area_shadowed": a_shadow,
"cells": cells}
spaceport_suborbital/cfd/su2_fullbody.py
# -*- coding: utf-8 -*-
r"""整機的 SU2 網格與求解(把設計點 2.2 的第二環釘死)。
## 這一支在回答什麼
設計點 2.2 由三環組成,**只有第一環是直接算的**:
```
整機牛頓流(含遮蔽) 2.71 @ α 16° <- 直接算
× SU2 修正 2.96 <- **從裸翼外插**,±10%
+ 整機摩擦 **2.20** <- Re* 1e4,平板關係式勉強
```
第二環是拿 SU2 在**裸翼**上量到的「SU2/牛頓」比值套到整機。
整機有機身、尾翼、底部,壓力分布和一片薄翼不一樣,所以那是外插。
要收窄它只有一條路:**整機的 SU2 解**。
## 流程
1. `mesh_repair.repair()` 把 `.obj` 修成水密流形曲面(見那一支)
2. 旋轉到 **SU2 的軸向慣例**:x 順流、y 展向、z 升力
3. gmsh:STL -> classifySurfaces -> createGeometry -> 曲面環
-> 遠場盒 -> 體積 = 盒 減 機體
4. 寫 `.su2`,標記 `BODY`(Euler 壁)與 `FARFIELD`
5. 跑 SU2 Euler,掃攻角
## 軸向慣例:這是第二次踩
`.obj` 的軸向是 **z 順流**(機鼻在 z = +9)、x 展向、y 垂直。
SU2 要的是 x 順流、y 展向、z 升力,而且**攻角在 x–z 平面內轉**。
S4 就是因為把展向建在 z 而不是 y,做出來的「攻角」其實是偏航 ——
而且那個錯**只有在收斂之後才看得出來**(RMS −9 但 CD 2.3e-7、CL 為負)。
所以這裡把旋轉寫成明確的矩陣並檢查行列式為 +1(真旋轉,不是鏡射):
x_su2 = −z_obj y_su2 = −x_obj z_su2 = +y_obj
鏡射會把法向與升力的正負號翻過來,而那同樣要收斂之後才看得出來。
python cfd/su2_fullbody.py --mesh # 只產網格
python cfd/su2_fullbody.py --mesh --fine # 細網格
python cfd/su2_fullbody.py --run # 產網格 + 跑 SU2
"""
import json
import os
import subprocess
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import mesh_repair as MR # noqa: E402
SU2_BIN = os.environ.get(
"SU2_BIN", r"F:\Tools\SU2-v8.5.0\bin\SU2_CFD.exe")
MESH_SU2 = os.path.join(HERE, "mesh_full3d.su2")
STL_ROT = os.path.join(HERE, "twsp2_su2axes.stl")
RESULTS = os.path.join(HERE, "fullbody_results.json")
# 遠場盒,以機身長度 L 為單位。Mach 20 的震波貼得很近,
# 所以上游不必留太多 —— 留太多只是把格子花在沒有梯度的地方。
FAR_UP, FAR_DOWN, FAR_SIDE = 1.2, 2.0, 1.5
# 參考面積:與 `cfd/friction.py`、`cfd/newtonian.py` 用同一個值,
# 否則係數不能互相比較。
S_REF_M2 = 85.0
MACH = 20.0
AOAS = (5.0, 10.0, 14.0, 16.0, 20.0)
# ══════════════════════════════════════════════════════════════════════
# 一、旋轉到 SU2 的軸向慣例
# ══════════════════════════════════════════════════════════════════════
def rotation_to_su2():
"""回傳把 .obj 軸向轉成 SU2 軸向的矩陣。**行列式必須是 +1。**"""
R = np.array([[0.0, 0.0, -1.0], # x_su2 = -z_obj (順流)
[-1.0, 0.0, 0.0], # y_su2 = -x_obj (展向)
[0.0, 1.0, 0.0]]) # z_su2 = +y_obj (升力)
det = float(np.linalg.det(R))
if abs(det - 1.0) > 1e-9:
raise SystemExit(
f"軸向轉換的行列式是 {det:+.3f},不是 +1 —— 那是鏡射不是旋轉,"
"法向與升力的正負號會被翻過來(而且要收斂之後才看得出來)")
return R
def repaired_in_su2_axes(verbose=True):
"""修好的曲面,已轉到 SU2 軸向。回傳 (points, faces, report)。"""
P, F, rep = MR.repair(verbose=verbose)
R = rotation_to_su2()
P = P @ R.T
rep["bbox_su2"] = {"x": [float(P[:, 0].min()), float(P[:, 0].max())],
"y": [float(P[:, 1].min()), float(P[:, 1].max())],
"z": [float(P[:, 2].min()), float(P[:, 2].max())]}
return P, F, rep
# ══════════════════════════════════════════════════════════════════════
# 二、體網格
# ══════════════════════════════════════════════════════════════════════
def build_mesh(fine=False, verbose=True):
"""產整機的四面體網格,寫成 `.su2`。回傳 info dict。"""
import gmsh
P, F, rep = repaired_in_su2_axes(verbose=verbose)
MR.write_stl(STL_ROT, P, F, name="twsp2_su2")
L = float(P[:, 0].max() - P[:, 0].min())
h_body = L / (260.0 if fine else 130.0)
h_far = L / (6.0 if fine else 4.0)
gmsh.initialize()
try:
gmsh.option.setNumber("General.Terminal", 1 if verbose else 0)
gmsh.merge(STL_ROT)
# 把離散曲面分類成可以組成體積的幾何。角度大一點,
# 不然這種外殼會被切成上百片,後面的 surface loop 很脆弱。
gmsh.model.mesh.classifySurfaces(80.0 * np.pi / 180.0, True, False)
gmsh.model.mesh.createGeometry()
surfs = [e[1] for e in gmsh.model.getEntities(2)]
sl_body = gmsh.model.geo.addSurfaceLoop(surfs)
x0, x1 = P[:, 0].min(), P[:, 0].max()
yc, zc = 0.5 * (P[:, 1].min() + P[:, 1].max()), \
0.5 * (P[:, 2].min() + P[:, 2].max())
half = FAR_SIDE * L
bx0, bx1 = x0 - FAR_UP * L, x1 + FAR_DOWN * L
by0, by1 = yc - half, yc + half
bz0, bz1 = zc - half, zc + half
def pt(x, y, z):
return gmsh.model.geo.addPoint(x, y, z, h_far)
c = [pt(bx0, by0, bz0), pt(bx1, by0, bz0), pt(bx1, by1, bz0),
pt(bx0, by1, bz0), pt(bx0, by0, bz1), pt(bx1, by0, bz1),
pt(bx1, by1, bz1), pt(bx0, by1, bz1)]
ln = gmsh.model.geo.addLine
e = [ln(c[0], c[1]), ln(c[1], c[2]), ln(c[2], c[3]), ln(c[3], c[0]),
ln(c[4], c[5]), ln(c[5], c[6]), ln(c[6], c[7]), ln(c[7], c[4]),
ln(c[0], c[4]), ln(c[1], c[5]), ln(c[2], c[6]), ln(c[3], c[7])]
quads = [[e[0], e[1], e[2], e[3]],
[e[4], e[5], e[6], e[7]],
[e[0], e[9], -e[4], -e[8]],
[e[1], e[10], -e[5], -e[9]],
[e[2], e[11], -e[6], -e[10]],
[e[3], e[8], -e[7], -e[11]]]
box_s = []
for q in quads:
cl = gmsh.model.geo.addCurveLoop(q)
box_s.append(gmsh.model.geo.addPlaneSurface([cl]))
sl_box = gmsh.model.geo.addSurfaceLoop(box_s)
vol = gmsh.model.geo.addVolume([sl_box, sl_body])
gmsh.model.geo.synchronize()
gmsh.model.addPhysicalGroup(2, surfs, name="BODY")
gmsh.model.addPhysicalGroup(2, box_s, name="FARFIELD")
gmsh.model.addPhysicalGroup(3, [vol], name="FLUID")
gmsh.option.setNumber("Mesh.MeshSizeMin", h_body)
gmsh.option.setNumber("Mesh.MeshSizeMax", h_far)
gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0)
gmsh.option.setNumber("Mesh.Algorithm3D", 10) # HXT,快
gmsh.model.mesh.generate(3)
n_nodes = len(gmsh.model.mesh.getNodes()[0])
types, tags, _ = gmsh.model.mesh.getElements(3)
n_cells = sum(len(t) for t in tags)
gmsh.write(MESH_SU2)
finally:
gmsh.finalize()
info = {"nodes": int(n_nodes), "cells": int(n_cells),
"h_body": h_body, "h_far": h_far, "length_m": L,
"mesh": MESH_SU2, "repair": rep,
"box": [float(bx0), float(bx1), float(by0), float(by1),
float(bz0), float(bz1)]}
if verbose:
print()
print("=" * 78)
print(" 整機體網格")
print("=" * 78)
print(f" 機身長 {L:.2f} m(SU2 軸向:x 順流)")
print(f" 遠場盒 x [{bx0:.1f}, {bx1:.1f}] y [{by0:.1f}, {by1:.1f}]"
f" z [{bz0:.1f}, {bz1:.1f}]")
print(f" 格距 機體 {h_body:.3f} m、遠場 {h_far:.2f} m")
print(f" 節點 {n_nodes:,} 四面體 {n_cells:,}")
print(f" 已寫出 {os.path.basename(MESH_SU2)}")
return info
# ══════════════════════════════════════════════════════════════════════
# 三、SU2
# ══════════════════════════════════════════════════════════════════════
CFG = """\
SOLVER= EULER
MATH_PROBLEM= DIRECT
RESTART_SOL= NO
MACH_NUMBER= {mach}
AOA= {aoa}
SIDESLIP_ANGLE= 0.0
FREESTREAM_PRESSURE= {p}
FREESTREAM_TEMPERATURE= {t}
REF_ORIGIN_MOMENT_X= 0.0
REF_ORIGIN_MOMENT_Y= 0.0
REF_ORIGIN_MOMENT_Z= 0.0
REF_LENGTH= 1.0
REF_AREA= {sref}
MARKER_EULER= ( BODY )
MARKER_FAR= ( FARFIELD )
MARKER_PLOTTING= ( BODY )
MARKER_MONITORING= ( BODY )
NUM_METHOD_GRAD= GREEN_GAUSS
CFL_NUMBER= {cfl}
CFL_ADAPT= YES
CFL_ADAPT_PARAM= ( 0.5, 1.05, 0.5, 20.0 )
ITER= {iters}
LINEAR_SOLVER= FGMRES
LINEAR_SOLVER_PREC= ILU
LINEAR_SOLVER_ERROR= 1E-4
LINEAR_SOLVER_ITER= 10
CONV_NUM_METHOD_FLOW= AUSM
MUSCL_FLOW= YES
SLOPE_LIMITER_FLOW= VENKATAKRISHNAN
VENKAT_LIMITER_COEFF= 0.05
TIME_DISCRE_FLOW= EULER_IMPLICIT
CONV_RESIDUAL_MINVAL= -8
CONV_STARTITER= 20
MESH_FILENAME= {mesh}
MESH_FORMAT= SU2
SCREEN_OUTPUT= ( INNER_ITER, RMS_DENSITY, LIFT, DRAG )
HISTORY_OUTPUT= ( ITER, RMS_RES, AERO_COEFF )
CONV_FILENAME= {tag}_history
VOLUME_FILENAME= {tag}_flow
SURFACE_FILENAME= {tag}_surf
OUTPUT_FILES= ( RESTART )
OUTPUT_WRT_FREQ= 100000
"""
def run_case(aoa, mach=MACH, iters=400, cfl=2.0, p=3.0, t=230.0,
verbose=True):
"""跑一個攻角。回傳 dict(含是否收斂)。"""
if not os.path.isfile(SU2_BIN):
raise SystemExit(f"找不到 SU2_CFD:{SU2_BIN}")
tag = f"full_a{aoa:g}".replace(".", "p")
cfg = os.path.join(HERE, f"cfg_{tag}.cfg")
with open(cfg, "w", encoding="ascii", newline="\n") as f:
f.write(CFG.format(mach=mach, aoa=aoa, p=p, t=t, sref=S_REF_M2,
cfl=cfl, iters=iters,
mesh=os.path.basename(MESH_SU2), tag=tag))
env = dict(os.environ)
r = subprocess.run([SU2_BIN, os.path.basename(cfg)], cwd=HERE, env=env,
capture_output=True, text=True, errors="replace",
timeout=7200)
out = (r.stdout or "") + (r.stderr or "")
with open(os.path.join(HERE, f"{tag}.log"), "w",
encoding="utf-8") as f:
f.write(out)
res = _parse(out)
res.update({"aoa": aoa, "returncode": r.returncode, "tag": tag})
if verbose:
print(f" α {aoa:>5.1f}° rc {r.returncode} "
f"RMS {res.get('rms', float('nan')):>7.2f} "
f"CL {res.get('cl', float('nan')):>9.5f} "
f"CD {res.get('cd', float('nan')):>9.5f} "
f"L/D {res.get('ld', float('nan')):>7.3f} "
f"{'收斂' if res.get('converged') else '**未收斂**'}")
return res
def _parse(out):
"""從 SU2 的螢幕輸出抓最後一列的殘差與係數。"""
rms = cl = cd = None
for line in out.splitlines():
s = line.strip()
if not s.startswith("|"):
continue
parts = [x.strip() for x in s.strip("|").split("|")]
if len(parts) < 4:
continue
try:
vals = [float(x) for x in parts]
except ValueError:
continue
rms, cl, cd = vals[1], vals[2], vals[3]
d = {"rms": rms, "cl": cl, "cd": cd}
if cl is not None and cd not in (None, 0.0):
d["ld"] = cl / cd
d["converged"] = (rms is not None and rms < -4.0
and cd is not None and cd > 1e-4)
return d
def main():
fine = "--fine" in sys.argv
if "--mesh" in sys.argv or "--run" in sys.argv:
info = build_mesh(fine=fine)
else:
print(__doc__.strip().splitlines()[0])
print(" 用 --mesh 產網格,--run 產網格並跑 SU2")
return 0
if "--run" not in sys.argv:
return 0
print()
print("=" * 78)
print(f" 整機 SU2 Euler,Mach {MACH:g}")
print("=" * 78)
rows = [run_case(a) for a in AOAS]
ok = [r for r in rows if r.get("converged")]
doc = {"mesh": info, "mach": MACH, "rows": rows,
"converged": len(ok), "total": len(rows)}
with open(RESULTS, "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
print()
print(f" {len(ok)}/{len(rows)} 個攻角收斂,已寫出 "
f"{os.path.basename(RESULTS)}")
if not ok:
print(" **一個都沒收斂 —— 不要把這些數字當資料用。**")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/cfd/su2_mesh.py
# -*- coding: utf-8 -*-
"""用 gmsh 產生 SU2 要的網格。
SU2 吃的是**體積網格**(2D 是面網格),不是我們既有的表面三角網格 ——
`model/twsp2_spaceplane.obj` 只有外殼,沒有外面那團空氣,不能直接拿來算。
這裡產生兩種:
wedge2d() 二維楔形。**先驗證用的**,因為它有精確的斜震波解:
tan θ = 2·cot β·(M²sin²β − 1)/(M²(γ + cos2β) + 2)
先確認 SU2 的設定算得出正確的 β 與震波後壓力比,
再去信任三維的結果。
delta3d() 三維簡化三角翼(菱形斷面、銳前緣)。
後掠角與展弦比取自本章太空飛機的實際機翼,
但把翼型簡化成上下各一個楔面 —— 這樣每一面都是平的,
修正牛頓流有**解析解**可以逐面比對。
"""
import math
import os
import gmsh
HERE = os.path.dirname(os.path.abspath(__file__))
# ── 本章太空飛機的機翼(見 model/spaceplane_trimesh.py)────────────────
SPAN_M = 11.0
ROOT_CHORD_M = 12.5 # 翼根弦長(Z_LE_ROOT 到 Z_TE)
TIP_CHORD_M = 1.20
WING_AREA_M2 = 85.0
TC_RATIO = 0.06 # 簡化三角翼的厚度比(菱形斷面)
def _init(name, verbose):
gmsh.initialize()
gmsh.option.setNumber("General.Terminal", 1 if verbose else 0)
gmsh.model.add(name)
def wedge2d(theta_deg=15.0, chord=1.0, upstream=0.5, height=1.2,
n_wall=140, n_far=90, out=None, verbose=False):
"""二維楔形。回傳 (檔案路徑, 幾何資訊)。
邊界標記:
wall 楔面(含上游平板段),Euler 壁
farfield 進口、上方、出口 —— 超音速下特徵線全部外傳,用遠場即可
"""
out = out or os.path.join(HERE, "mesh_wedge2d.su2")
th = math.radians(theta_deg)
y_te = chord * math.tan(th)
_init("wedge2d", verbose)
g = gmsh.model.geo
# 壁面由三點構成:上游平板 -> 楔頂點 -> 楔後緣
p1 = g.addPoint(-upstream, 0.0, 0.0)
p2 = g.addPoint(0.0, 0.0, 0.0)
p3 = g.addPoint(chord, y_te, 0.0)
p4 = g.addPoint(chord, height, 0.0)
p5 = g.addPoint(-upstream, height, 0.0)
l_w1 = g.addLine(p1, p2) # 上游平板
l_w2 = g.addLine(p2, p3) # 楔面
l_out = g.addLine(p3, p4) # 出口
l_top = g.addLine(p4, p5) # 上方
l_in = g.addLine(p5, p1) # 進口
cl = g.addCurveLoop([l_w1, l_w2, l_out, l_top, l_in])
surf = g.addPlaneSurface([cl])
g.synchronize()
# 壁面附近要密一些,震波才解析得出來
gmsh.model.mesh.setTransfiniteCurve(l_w1, max(8, n_wall // 4))
gmsh.model.mesh.setTransfiniteCurve(l_w2, n_wall)
gmsh.model.mesh.setTransfiniteCurve(l_top, n_wall // 2)
gmsh.model.mesh.setTransfiniteCurve(l_in, n_far, "Progression", 1.02)
gmsh.model.mesh.setTransfiniteCurve(l_out, n_far, "Progression", 1.02)
gmsh.model.addPhysicalGroup(1, [l_w1, l_w2], name="wall")
gmsh.model.addPhysicalGroup(1, [l_in, l_top, l_out], name="farfield")
gmsh.model.addPhysicalGroup(2, [surf], name="fluid")
gmsh.option.setNumber("Mesh.MeshSizeMax", chord / 55.0)
gmsh.option.setNumber("Mesh.MeshSizeMin", chord / 400.0)
gmsh.model.mesh.generate(2)
gmsh.write(out)
n_nodes = len(gmsh.model.mesh.getNodes()[0])
gmsh.finalize()
return out, {"theta_deg": theta_deg, "chord": chord, "y_te": y_te,
"nodes": n_nodes}
def delta3d(sweep_deg=None, root_chord=None, span=None, tc=TC_RATIO,
far=None, n_le=46, out=None, verbose=False):
"""三維簡化三角翼(菱形斷面),只建半邊 —— 對稱面用對稱邊界。
斷面:最大厚度在 50% 弦長,上下各一個平面 ——
每一面都是平的,所以修正牛頓流 Cp = Cp_max·sin²δ 有逐面解析解。
"""
span = span or SPAN_M
root_chord = root_chord or ROOT_CHORD_M
half = span / 2.0
if sweep_deg is None:
# 由翼根/翼尖弦長與半展長推出前緣後掠角
sweep_deg = math.degrees(math.atan2(root_chord - TIP_CHORD_M, half))
far = far or root_chord * 6.0
tip_le_x = half * math.tan(math.radians(sweep_deg))
tip_chord = root_chord - tip_le_x
t_root = tc * root_chord / 2.0 # 半厚
_init("delta3d", verbose)
occ = gmsh.model.occ
# 翼剖面是菱形:前緣 -> 上脊(50% 弦) -> 後緣 -> 下脊。
# 用**放樣**(ThruSections)把翼根與翼尖兩個剖面接起來 ——
# 先前試著逐面 addPlaneSurface 會失敗,因為四個角點並不共面。
# **座標系要配合 SU2 的三維慣例**:x 順流、**y 展向**、z 是升力方向,
# AOA 在 x-z 平面內旋轉。先前把展向放在 z、厚度放在 y,
# 結果下「攻角 10 度」實際上是在偏航這片翼 —— 解收斂到 RMS −9
# 卻給出 CD ≈ 2e-7、CL 為負,正是「流場裡沒有東西擋」的特徵。
def section(x_le, chord, t, y):
p = [occ.addPoint(x_le, y, 0.0),
occ.addPoint(x_le + chord * 0.5, y, t),
occ.addPoint(x_le + chord, y, 0.0),
occ.addPoint(x_le + chord * 0.5, y, -t)]
ls = [occ.addLine(p[i], p[(i + 1) % 4]) for i in range(4)]
return occ.addWire(ls)
# 翼尖厚度縮到很小(銳翼尖),但不能是零 —— 零厚度會產生退化面
t_tip = max(t_root * 0.08, root_chord * 0.002)
w_root = section(0.0, root_chord, t_root, 0.0)
w_tip = section(tip_le_x, tip_chord, t_tip, half)
loft = occ.addThruSections([w_root, w_tip], makeSolid=True, makeRuled=True)
wing = loft[0][1]
occ.synchronize()
# 遠場半盒(y >= 0,對稱面在 y = 0),再把機翼挖掉
box = occ.addBox(-far * 0.45, 0.0, -far * 0.5, far, far * 0.5, far)
occ.synchronize()
cut, _ = occ.cut([(3, box)], [(3, wing)])
occ.synchronize()
vol = cut[0][1]
surfs = gmsh.model.getBoundary([(3, vol)], oriented=False)
wall, sym, farf = [], [], []
# 判定要用「這個面在某個座標上是**平的**,而且那個值貼在盒子邊界」。
# 先前只檢查 bounding box 有沒有碰到邊界 —— z=0 的對稱面橫跨整個 x
# 範圍,它的 bbox 也碰得到 x 邊界,於是被誤判成遠場,對稱面標記整個消失。
x0, x1 = -far * 0.45, far * 0.55
y0, y1 = 0.0, far * 0.5
z0, z1 = -far * 0.5, far * 0.5
tol = 1e-6 * max(1.0, far)
def flat_at(bb, i, val):
return abs(bb[i] - bb[i + 3]) < tol and abs(bb[i] - val) < tol
for _d, sid in surfs:
bb = gmsh.model.getBoundingBox(2, sid)
if flat_at(bb, 1, y0):
sym.append(sid) # 對稱面 y = 0
elif (flat_at(bb, 0, x0) or flat_at(bb, 0, x1)
or flat_at(bb, 1, y1)
or flat_at(bb, 2, z0) or flat_at(bb, 2, z1)):
farf.append(sid) # 遠場的五個面
else:
wall.append(sid) # 剩下的就是機翼
gmsh.model.addPhysicalGroup(2, wall, name="wing")
gmsh.model.addPhysicalGroup(2, sym, name="symmetry")
gmsh.model.addPhysicalGroup(2, farf, name="farfield")
gmsh.model.addPhysicalGroup(3, [vol], name="fluid")
# 機翼表面要細、遠場可以粗
fld = gmsh.model.mesh.field
fld.add("Distance", 1)
fld.setNumbers(1, "SurfacesList", wall)
fld.add("Threshold", 2)
fld.setNumber(2, "InField", 1)
fld.setNumber(2, "SizeMin", root_chord / n_le)
fld.setNumber(2, "SizeMax", root_chord / 2.2)
fld.setNumber(2, "DistMin", root_chord * 0.05)
fld.setNumber(2, "DistMax", root_chord * 1.6)
fld.setAsBackgroundMesh(2)
gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 0)
gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 0)
gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0)
out = out or os.path.join(HERE, "mesh_delta3d.su2")
gmsh.model.mesh.generate(3)
gmsh.write(out)
n_nodes = len(gmsh.model.mesh.getNodes()[0])
gmsh.finalize()
# 參考面積:半邊 planform(SU2 的力係數要用它正規化)
s_half = 0.5 * (root_chord + tip_chord) * half
return out, {"sweep_deg": sweep_deg, "root_chord": root_chord,
"tip_chord": tip_chord, "span": span, "half_span": half,
"tc": tc, "ref_area_half": s_half, "nodes": n_nodes,
"far": far}
# ── 精確解:斜震波 ────────────────────────────────────────────────────
def oblique_shock(mach, theta_deg, gamma=1.4):
"""θ-β-M 關係的弱解。回傳 dict,失敗回傳 None(震波脫體)。"""
th = math.radians(theta_deg)
mu = math.asin(1.0 / mach) # 馬赫角
def f(b):
return (2.0 / math.tan(b) * (mach ** 2 * math.sin(b) ** 2 - 1.0)
/ (mach ** 2 * (gamma + math.cos(2 * b)) + 2.0)
- math.tan(th))
# **θ-β-M 有兩個根**(弱解與強解),而 f 在兩個端點都是負的 ——
# 直接用端點測號會永遠判定無解。先掃出極大值,再在弱解那一側二分。
n = 4000
b_peak, f_peak = mu, -1e9
for i in range(1, n):
b = mu + (math.pi / 2 - mu) * i / n
v = f(b)
if v > f_peak:
f_peak, b_peak = v, b
if f_peak < 0.0:
return None # 超過最大偏折角,震波脫體
lo, hi = mu + 1e-12, b_peak
for _ in range(200):
mid = 0.5 * (lo + hi)
if f(lo) * f(mid) <= 0:
hi = mid
else:
lo = mid
beta = 0.5 * (lo + hi)
mn1 = mach * math.sin(beta)
p2p1 = 1.0 + 2.0 * gamma / (gamma + 1.0) * (mn1 ** 2 - 1.0)
r2r1 = ((gamma + 1.0) * mn1 ** 2) / ((gamma - 1.0) * mn1 ** 2 + 2.0)
t2t1 = p2p1 / r2r1
mn2 = math.sqrt((1.0 + (gamma - 1.0) / 2.0 * mn1 ** 2)
/ (gamma * mn1 ** 2 - (gamma - 1.0) / 2.0))
m2 = mn2 / math.sin(beta - th)
# 壁面壓力係數
cp = 2.0 / (gamma * mach ** 2) * (p2p1 - 1.0)
return {"beta_deg": math.degrees(beta), "p2_p1": p2p1, "rho2_rho1": r2r1,
"T2_T1": t2t1, "M2": m2, "cp": cp}
def newtonian_cp(mach, delta_deg, gamma=1.4):
"""修正牛頓流:Cp = Cp_max·sin²δ,Cp_max 由正震波駐點壓力給出。"""
m2 = mach * mach
p0p = (((gamma + 1.0) ** 2 * m2 / (4.0 * gamma * m2 - 2.0 * (gamma - 1.0)))
** (gamma / (gamma - 1.0))
* ((1.0 - gamma + 2.0 * gamma * m2) / (gamma + 1.0)))
cp_max = 2.0 / (gamma * m2) * (p0p - 1.0)
return cp_max * math.sin(math.radians(delta_deg)) ** 2, cp_max
if __name__ == "__main__":
import sys
which = sys.argv[1] if len(sys.argv) > 1 else "wedge"
if which.startswith("w"):
p, info = wedge2d(verbose="-v" in sys.argv)
print(f" {p}")
print(f" {info}")
ex = oblique_shock(20.0, info["theta_deg"])
print(f" Mach 20、θ {info['theta_deg']}° 的精確解:"
f"β {ex['beta_deg']:.3f}°、p2/p1 {ex['p2_p1']:.2f}、"
f"Cp {ex['cp']:.5f}")
else:
p, info = delta3d(verbose="-v" in sys.argv)
print(f" {p}")
for k, v in info.items():
print(f" {k:<16} {v}")
spaceport_suborbital/cfd/su2_run.py
# -*- coding: utf-8 -*-
"""驅動 SU2 並把結果讀回來。
## 這一章為什麼要用 CFD
工作單裡的 Mach 20 流場用的是**修正牛頓流撞擊理論** ——
每個面元 Cp = Cp_max·sin²δ,只看面的傾角,不知道鄰居的存在。
它在高馬赫、鈍體上站得住,但是:
* 它算不出震波的位置與形狀
* 它給不出背風面(sin²δ 在背風面直接取 0)
* 對二維楔形,它**系統性低估** Cp 二成到三成(見 su2_mesh 的對照)
SU2 的無黏 Euler 解可以回答前兩項,並量化第三項。
## 驗證順序:先楔形,再三角翼
**不先驗證工具就不要信任它的三維結果。** 二維楔形有精確的斜震波解,
先確認 SU2 的 β 與 p2/p1 對得上,再去算沒有解析解的三維翼。
這和本章對 STK 的態度一致:量測,不是推測。
## 不做什麼
* **不是黏性解。** Euler 無黏,算不出摩擦阻力與邊界層。
高超音速的摩擦阻力占比不小,所以這裡的 L/D 是**上限**。
* **不是真實氣體。** γ = 1.4 定值。Mach 20 的震波後溫度上看數千 K,
真實空氣會解離、游離,γ 會下降、震波會更貼近物體。
要算那個得用 SU2-NEMO(非平衡模組),不在本章範圍。
"""
import csv
import io
import math
import os
import shutil
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import su2_mesh as M # noqa: E402
SU2_BIN = os.environ.get(
"SU2_BIN", r"F:\Tools\SU2-v8.5.0\bin\SU2_CFD.exe")
# 交接點附近的大氣條件(US76 70 km)—— 用任務真的會經過的狀態,
# 不是隨便挑一個「標準」條件。
P_INF = 5.22 # Pa
T_INF = 219.6 # K
BASE = """% 由 su2_run.py 產生,不要手改
SOLVER= EULER
MATH_PROBLEM= DIRECT
RESTART_SOL= NO
MACH_NUMBER= {mach}
AOA= {aoa}
SIDESLIP_ANGLE= 0.0
FREESTREAM_PRESSURE= {p}
FREESTREAM_TEMPERATURE= {t}
REF_DIMENSIONALIZATION= DIMENSIONAL
REF_AREA= {ref_area}
REF_LENGTH= {ref_len}
{markers}
NUM_METHOD_GRAD= GREEN_GAUSS
CFL_NUMBER= {cfl}
CFL_ADAPT= YES
CFL_ADAPT_PARAM= ( 0.1, 1.15, 0.5, {cfl_max} )
% AUSM 家族在強震波下比 Roe 穩;限制器係數壓小以免震波附近震盪
CONV_NUM_METHOD_FLOW= AUSMPLUSUP2
MUSCL_FLOW= YES
SLOPE_LIMITER_FLOW= VENKATAKRISHNAN
VENKAT_LIMITER_COEFF= 0.02
TIME_DISCRE_FLOW= EULER_IMPLICIT
LINEAR_SOLVER= FGMRES
LINEAR_SOLVER_PREC= ILU
LINEAR_SOLVER_ERROR= 1E-6
LINEAR_SOLVER_ITER= 15
ITER= {iters}
CONV_FIELD= RMS_DENSITY
CONV_RESIDUAL_MINVAL= -9
CONV_STARTITER= 50
MESH_FILENAME= {mesh}
MESH_FORMAT= SU2
SCREEN_OUTPUT= (INNER_ITER, RMS_DENSITY, LIFT, DRAG)
SCREEN_WRT_FREQ_INNER= 200
HISTORY_OUTPUT= (ITER, RMS_RES, AERO_COEFF)
% 沒有 PRIMITIVE 的話表面檔只有守恆變數,拿不到 Cp
VOLUME_OUTPUT= (COORDINATES, SOLUTION, PRIMITIVE)
OUTPUT_FILES= (RESTART, SURFACE_CSV)
CONV_FILENAME= history_{tag}
SURFACE_FILENAME= surface_{tag}
RESTART_FILENAME= restart_{tag}.dat
OUTPUT_WRT_FREQ= 100000
"""
def write_cfg(tag, mesh, mach, aoa, markers, ref_area, ref_len,
iters=2500, cfl=0.4, cfl_max=8.0):
cfg = BASE.format(mach=mach, aoa=aoa, p=P_INF, t=T_INF, mesh=mesh,
markers=markers, ref_area=ref_area, ref_len=ref_len,
iters=iters, tag=tag, cfl=cfl, cfl_max=cfl_max)
path = os.path.join(HERE, f"cfg_{tag}.cfg")
io.open(path, "w", encoding="utf-8").write(cfg)
return path
def run(cfg, threads=None, verbose=False):
"""跑 SU2_CFD,回傳 (是否收斂, 最後殘差, 輸出尾段)。"""
if not os.path.isfile(SU2_BIN):
raise SystemExit(f"找不到 SU2_CFD:{SU2_BIN}\n"
"設定環境變數 SU2_BIN 指到它。")
env = dict(os.environ)
env["OMP_NUM_THREADS"] = str(threads or max(1, (os.cpu_count() or 4) // 2))
p = subprocess.run([SU2_BIN, os.path.basename(cfg)], cwd=HERE, env=env,
capture_output=True, text=True, errors="replace")
out = (p.stdout or "") + (p.stderr or "")
if verbose:
print(out[-3000:])
rms = None
for line in out.splitlines():
parts = line.split("|")
if len(parts) >= 3:
try:
rms = float(parts[2])
except ValueError:
pass
ok = p.returncode == 0 and "Error" not in out[-2000:]
return ok, rms, out
def read_history(tag):
"""回傳最後一列的 (CL, CD)。SU2 的欄名有引號與空白,要清掉。"""
for ext in (".csv", ".dat"):
f = os.path.join(HERE, f"history_{tag}{ext}")
if os.path.isfile(f):
rows = list(csv.reader(io.open(f, encoding="utf-8")))
hdr = [h.strip().strip('"') for h in rows[0]]
last = rows[-1]
d = {h: last[i].strip() for i, h in enumerate(hdr)
if i < len(last)}
def g(*names):
for n in names:
if n in d:
try:
return float(d[n])
except ValueError:
return None
return None
return g("CL", "LIFT"), g("CD", "DRAG"), d
return None, None, {}
def read_surface(tag):
"""表面 CSV -> [(x, y, z, Cp)]。"""
for ext in (".csv", ".dat"):
f = os.path.join(HERE, f"surface_{tag}{ext}")
if os.path.isfile(f):
rows = list(csv.reader(io.open(f, encoding="utf-8")))
hdr = [h.strip().strip('"') for h in rows[0]]
def col(*names):
for n in names:
if n in hdr:
return hdr.index(n)
return None
ix, iy, iz = col("x"), col("y"), col("z")
icp = col("Pressure_Coefficient", "C_p", "Cp")
out = []
for r in rows[1:]:
try:
out.append((float(r[ix]), float(r[iy]),
float(r[iz]) if iz is not None else 0.0,
float(r[icp])))
except (ValueError, IndexError, TypeError):
continue
return out
return []
# ══════════════════════════════════════════════════════════════════════
# 案例一:二維楔形 —— 對照精確斜震波解
# ══════════════════════════════════════════════════════════════════════
def wedge_exact_coeffs(mach, theta_deg, chord=1.0):
"""楔形的精確 CL/CD(每單位展長,以弦長為參考面積)。
楔面是**平的**,所以斜震波解給的均勻 Cp 可以直接積分:
面長 L = c/cos θ,外法向 n = (−sin θ, cos θ)
壓力沿 −n 作用 -> (Cx, Cy) = Cp·L/c·(sin θ, −cos θ)
上游平板與自由流平行,Cp = 0,不貢獻。
"""
ex = M.oblique_shock(mach, theta_deg)
if ex is None:
return None
th = math.radians(theta_deg)
ll = 1.0 / math.cos(th)
cx = ex["cp"] * ll * math.sin(th)
cy = -ex["cp"] * ll * math.cos(th)
return {"CD": cx, "CL": cy, "cp": ex["cp"], "beta_deg": ex["beta_deg"]}
def case_wedge(mach=20.0, theta=15.0, iters=6000, verbose=False):
mesh, info = M.wedge2d(theta_deg=theta)
tag = f"wedge_m{mach:g}_t{theta:g}"
markers = ("MARKER_EULER= ( wall )\n"
"MARKER_FAR= ( farfield )\n"
"MARKER_PLOTTING= ( wall )\n"
"MARKER_MONITORING= ( wall )")
cfg = write_cfg(tag, os.path.basename(mesh), mach, 0.0, markers,
ref_area=info["chord"], ref_len=info["chord"],
iters=iters, cfl_max=20.0)
ok, rms, out = run(cfg, verbose=verbose)
cl, cd, hist = read_history(tag)
surf = read_surface(tag)
ex = wedge_exact_coeffs(mach, theta, info["chord"])
# 楔面中段的 Cp(避開頂點奇異與後緣膨脹)
on = [s for s in surf if 0.25 * info["chord"] < s[0] < 0.75 * info["chord"]]
cp_cfd = sum(s[3] for s in on) / len(on) if on else None
cp_new, _ = M.newtonian_cp(mach, theta)
# 牛頓流的同一組積分(它只知道面的傾角,所以同樣是均勻 Cp)
th = math.radians(theta)
ll = 1.0 / math.cos(th)
return {"tag": tag, "ok": ok, "rms": rms, "nodes": info["nodes"],
"theta": theta, "mach": mach,
"cd_cfd": cd, "cl_cfd": cl,
"cd_exact": ex["CD"], "cl_exact": ex["CL"],
"cd_newton": cp_new * ll * math.sin(th),
"cl_newton": -cp_new * ll * math.cos(th),
"cp_cfd": cp_cfd, "cp_exact": ex["cp"],
"cp_newton": cp_new, "beta_exact": ex["beta_deg"],
"n_surf": len(on), "out": out}
# ══════════════════════════════════════════════════════════════════════
# 案例二:三維簡化三角翼
# ══════════════════════════════════════════════════════════════════════
def case_delta(mach=20.0, aoa=10.0, iters=12000, mesh=None, info=None,
first_order=True, verbose=False):
"""半邊三角翼,對稱面用 MARKER_SYM。回傳 CFD 與牛頓流的對照。
**這組設定是試出來的,不要隨便改:**
* 網格 n_le=90(約 32,500 節點)。8,600 節點的粗網格收斂不了。
* 一階格式(MUSCL 關掉)+ AUSM。二階在 Mach 20 的強震波上
會一直震盪,殘差卡在 −1 附近。
* CFL 0.5 爬到 50,12,000 疊代 —— 殘差可以掉到 −9。
踩過的坑:**座標軸要配合 SU2 的三維慣例**(x 順流、y 展向、
z 升力方向)。一開始把展向放在 z,於是 AOA 實際上在偏航這片翼,
解收斂到 RMS −9 卻給出 CD ≈ 2e-7、CL 為負 ——
**是那個收斂的解才讓錯誤露出來的**,沒收斂的時候只看得到一團亂數。
"""
import newtonian as N
if mesh is None:
mesh, info = M.delta3d()
tag = f"delta_m{mach:g}_a{aoa:g}".replace(".", "p")
markers = ("MARKER_EULER= ( wing )\n"
"MARKER_SYM= ( symmetry )\n"
"MARKER_FAR= ( farfield )\n"
"MARKER_PLOTTING= ( wing )\n"
"MARKER_MONITORING= ( wing )")
cfg = write_cfg(tag, os.path.basename(mesh), mach, aoa, markers,
ref_area=info["ref_area_half"],
ref_len=info["root_chord"], iters=iters, cfl=0.5,
cfl_max=50.0)
if first_order:
t = io.open(cfg, encoding="utf-8").read()
t = t.replace("MUSCL_FLOW= YES", "MUSCL_FLOW= NO")
t = t.replace("CONV_NUM_METHOD_FLOW= AUSMPLUSUP2",
"CONV_NUM_METHOD_FLOW= AUSM")
io.open(cfg, "w", encoding="utf-8").write(t)
ok, rms, out = run(cfg, verbose=verbose)
cl, cd, _h = read_history(tag)
N.set_centroid(mesh, "wing")
nw = N.forces(mesh, "wing", mach, aoa, info["ref_area_half"])
return {"tag": tag, "aoa": aoa, "mach": mach, "ok": ok, "rms": rms,
"cl_cfd": cl, "cd_cfd": cd,
"ld_cfd": (cl / cd) if (cl and cd and abs(cd) > 1e-12) else None,
"cl_new": nw["CL"], "cd_new": nw["CD"], "ld_new": nw["CEff"],
"nodes": info["nodes"], "ref_area": info["ref_area_half"],
"out": out}
def delta_sweep(machs=(20.0,), aoas=(0.0, 5.0, 10.0, 15.0, 20.0),
iters=12000, n_le=90, verbose=False):
mesh, info = M.delta3d(n_le=n_le)
print(f" 網格 {info['nodes']:,} 節點,後掠 {info['sweep_deg']:.1f}°、"
f"半邊參考面積 {info['ref_area_half']:.2f} m²")
rows = []
for mach in machs:
for aoa in aoas:
r = case_delta(mach=mach, aoa=aoa, iters=iters, mesh=mesh,
info=info, verbose=verbose)
rows.append(r)
ld = r["ld_cfd"]
print(f" Mach {mach:>4.0f} AoA {aoa:>4.0f}° RMS {r['rms']}"
f" CFD CL {r['cl_cfd']:.5f} CD {r['cd_cfd']:.5f}"
f" L/D {(ld if ld else float('nan')):6.3f}"
f" | 牛頓 L/D {r['ld_new']:6.3f}"
if r["cl_cfd"] is not None else
f" Mach {mach} AoA {aoa}: 讀不到係數")
return rows, info
RESULTS = os.path.join(HERE, "cfd_results.json")
# 收斂門檻。殘差沒掉到這個量級就**不算數** ——
# 未收斂的解會給出負阻力那種物理上不可能的值,
# 把它當結果端出去比沒有結果更糟。
CONV_RMS = -4.0
def save_results(wedge_rows, delta_rows, delta_info):
"""把結果寫成 JSON,供網頁與文件取用。
**只存結論,不存幾十 MB 的流場** —— 流場檔案在 .gitignore 裡。
"""
import datetime
import json
doc = {
"generated": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"su2": os.path.basename(os.path.dirname(os.path.dirname(SU2_BIN))),
"freestream": {"mach": 20.0, "p_pa": P_INF, "t_k": T_INF,
"alt_km": 70.0,
"note": "US76 70 km,交接點附近的實際狀態"},
"wedge": [{k: r[k] for k in
("theta", "mach", "rms", "nodes", "beta_exact",
"cd_cfd", "cd_exact", "cd_newton",
"cl_cfd", "cl_exact", "cl_newton",
"cp_cfd", "cp_exact", "cp_newton")}
for r in wedge_rows],
"delta_converged": all(
(r.get("rms") is not None and r["rms"] < CONV_RMS
and r.get("cd_cfd") is not None and r["cd_cfd"] > 0)
for r in delta_rows) if delta_rows else False,
"delta": {
"mesh": {k: delta_info[k] for k in
("sweep_deg", "root_chord", "tip_chord", "span",
"half_span", "tc", "ref_area_half", "nodes")},
"rows": [dict({k: r[k] for k in
("aoa", "mach", "rms", "cl_cfd", "cd_cfd",
"ld_cfd", "cl_new", "cd_new", "ld_new")},
converged=bool(
r.get("rms") is not None and r["rms"] < CONV_RMS
and r.get("cd_cfd") is not None
and r["cd_cfd"] > 0))
for r in delta_rows],
},
}
io.open(RESULTS, "w", encoding="utf-8").write(
json.dumps(doc, ensure_ascii=False, indent=2))
return RESULTS
if __name__ == "__main__":
v = "-v" in sys.argv
print("=" * 74)
print(" 案例一:二維楔形 —— 先驗證工具,再信任三維結果")
print("=" * 74)
print(f" SU2:{SU2_BIN}")
print(f" 自由流:Mach 20、{P_INF} Pa、{T_INF} K(US76 70 km)")
print()
rows = []
hdr = (f" {'θ':>5} {'β 精確':>8} | {'CD SU2':>9} {'CD 精確':>9} {'差':>8}"
f" | {'Cp SU2':>9} {'Cp 精確':>9} {'Cp 牛頓':>9} {'牛頓誤差':>9}")
print(hdr)
print(" " + "-" * (len(hdr) - 2))
for th in (10.0, 15.0, 20.0, 30.0):
r = case_wedge(theta=th, verbose=v)
rows.append(r)
dcd = (r["cd_cfd"] / r["cd_exact"] - 1) * 100 if r["cd_cfd"] else float("nan")
cp = r["cp_cfd"]
dnew = (r["cp_newton"] / r["cp_exact"] - 1) * 100
print(f" {th:5.0f} {r['beta_exact']:8.2f} | "
f"{r['cd_cfd']:9.5f} {r['cd_exact']:9.5f} {dcd:+7.2f}% | "
f"{(cp if cp is not None else float('nan')):9.5f} "
f"{r['cp_exact']:9.5f} {r['cp_newton']:9.5f} {dnew:+8.1f}%")
print()
print(f" 網格 {rows[0]['nodes']:,} 節點,殘差 RMS[Rho] 約 "
f"{rows[0]['rms']:.1f}")
print(" 牛頓流對二維楔形系統性低估 —— 它只看面的傾角,")
print(" 不知道震波把氣流壓縮到什麼程度。")
spaceport_suborbital/model/add_attach_point.py
# -*- coding: utf-8 -*-
r"""給自製的 `.glb` 加上 STK 的**尾焰掛點**。
## 為什麼需要這一支
分節分離的尾焰做好之後,第一節是對的(掛在推進器的噴嘴上),
但**太空飛機的尾焰是從機身原點噴出來的** —— 因為
`model/twsp2_spaceplane.glb` 沒有 attach point,
`VaporTrail.AvailableAttachPoints` 回傳空清單。
內建的 `launchvehicle.glb` 有 `Flame_Booster1` 之類的掛點,
所以第一節掛得上去。差別不在 STK,在模型。
## STK 怎麼認出掛點(逆推自它自己的模型)
不是靠名字 —— `aircraft.glb` 的掛點叫 `StarboardEngine` / `PortEngine`,
名字裡沒有 flame 字樣。真正的標記是 glTF 的**擴充**:
```json
"extensionsUsed": ["AGI_articulations"],
"nodes": [
{"name": "MainEngine",
"translation": [0, -0.16, -9.0],
"extensions": {"AGI_articulations": {"isAttachPoint": true}}}
]
```
`aircraft.glb` 的掛點就是這樣的**空節點** —— 只有 `name`、`translation`
與那個旗標,沒有 mesh。`launchvehicle.glb` 的掛點多帶一個
`articulationName`(把掛點綁到某個活動件),我們的模型沒有活動件,
所以不需要。
## 座標
這個 `.glb` 的節點都掛在 `world` 之下、沒有額外的變換,
所以網格座標就是模型座標,與 `.obj` 一致:
z 順流(機鼻 +9.0、機尾 −9.0) x 展向(±5.5) y 垂直
機身(`fuselage`)的包圍盒是 x ±1.6、y −1.512..1.188、z −9.0..6.8,
所以尾端中心線大約在 **(0, −0.16, −9.0)**。
## 不動原檔
輸出到新檔 `twsp2_spaceplane_ap.glb`。原檔留著 ——
若 STK 不認、或掛點位置不對,換回去就好。
python model/add_attach_point.py # 產生新檔
python model/add_attach_point.py --inspect # 只看現有 glb 的結構
"""
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "twsp2_spaceplane.glb")
DST = os.path.join(HERE, "twsp2_spaceplane_ap.glb")
# 掛點:機身尾端、中心線。名字取得直白一點,
# 因為它會出現在 `AvailableAttachPoints` 裡給人看。
ATTACH_POINTS = [
("MainEngine", (0.0, -0.16, -9.05)),
]
EXT = "AGI_articulations"
def read_glb(path):
"""回傳 (json_dict, bin_chunk_bytes)。"""
with open(path, "rb") as f:
magic, ver, total = struct.unpack("<III", f.read(12))
if magic != 0x46546C67:
raise SystemExit(f"{path} 不是 glb")
j = None
binc = b""
while f.tell() < total:
ln, typ = struct.unpack("<II", f.read(8))
data = f.read(ln)
if typ == 0x4E4F534A: # 'JSON'
j = json.loads(data.decode("utf-8"))
elif typ == 0x004E4942: # 'BIN\0'
binc = data
if j is None:
raise SystemExit("glb 裡沒有 JSON chunk")
return j, binc
def write_glb(path, j, binc):
"""寫回 glb。**每個 chunk 都要補到 4 的倍數**,不然 STK 讀不進去。"""
js = json.dumps(j, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4) # JSON 用空白補
bn = binc + b"\0" * ((4 - len(binc) % 4) % 4) # BIN 用 0 補
total = 12 + 8 + len(js) + (8 + len(bn) if bn else 0)
with open(path, "wb") as f:
f.write(struct.pack("<III", 0x46546C67, 2, total))
f.write(struct.pack("<II", len(js), 0x4E4F534A))
f.write(js)
if bn:
f.write(struct.pack("<II", len(bn), 0x004E4942))
f.write(bn)
return total
def add_attach_points(j, points=ATTACH_POINTS):
"""在 glTF 的節點樹裡加上掛點。回傳加了幾個。"""
used = j.setdefault("extensionsUsed", [])
if EXT not in used:
used.append(EXT)
nodes = j.setdefault("nodes", [])
existing = {n.get("name") for n in nodes}
# 掛在場景的根節點之下,這樣它和網格在同一個座標系
scene = j["scenes"][j.get("scene", 0)]
root_idx = scene["nodes"][0]
added = 0
for name, xyz in points:
if name in existing:
continue
nodes.append({
"name": name,
"translation": [float(v) for v in xyz],
"extensions": {EXT: {"isAttachPoint": True}},
})
nodes[root_idx].setdefault("children", []).append(len(nodes) - 1)
added += 1
return added
def inspect(path):
j, binc = read_glb(path)
print(f" {os.path.basename(path)} glTF 2、BIN {len(binc):,} bytes")
print(f" extensionsUsed: {j.get('extensionsUsed')}")
print(f" 節點 {len(j.get('nodes', []))} 個:")
for i, n in enumerate(j.get("nodes", [])):
ap = n.get("extensions", {}).get(EXT, {}).get("isAttachPoint")
mark = " <- 掛點" if ap else ""
print(f" {i}: {n.get('name','(無名)'):<22}"
f"{'mesh ' + str(n['mesh']) if 'mesh' in n else '':<10}"
f"{n.get('translation', '')}{mark}")
return j
def main():
if "--inspect" in sys.argv:
inspect(SRC)
if os.path.isfile(DST):
print()
inspect(DST)
return 0
j, binc = read_glb(SRC)
n = add_attach_points(j)
total = write_glb(DST, j, binc)
print("=" * 74)
print(" 給太空飛機的 .glb 加上尾焰掛點")
print("=" * 74)
print(f" 來源 {os.path.basename(SRC)}"
f"({os.path.getsize(SRC):,} bytes,**不動它**)")
print(f" 輸出 {os.path.basename(DST)}({total:,} bytes)")
print(f" 加了 {n} 個掛點:")
for name, xyz in ATTACH_POINTS:
print(f" {name:<14} {xyz}(機身尾端、中心線)")
print()
print(" 標記方式是 glTF 擴充 `AGI_articulations` 的 `isAttachPoint`,")
print(" 逆推自 STK 自己的 aircraft.glb / launchvehicle.glb —— ")
print(" **不是靠名字認的**(內建的掛點叫 StarboardEngine)。")
print()
print(" 下一步:讓 STK 載入這個檔,回讀 AvailableAttachPoints 確認。")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/model/spaceplane_trimesh.py
# -*- coding: utf-8 -*-
# ==============================================================================
# TWSP2 太空飛機雛型 —— 幾何產生器(純 Python / trimesh)
#
# 為什麼不是 Blender
# 本機實測:Blender 完全沒有安裝。掃過 C:\Program Files\Blender Foundation、
# Program Files (x86)、PATH、winget Links、LocalAppData\Programs、Steam,
# 以及 /c /f /e 三顆磁碟 depth-4 的 blender.exe;登錄檔 Uninstall 樹下
# 找不到任何 Blender 項目;六個 conda 環境與 C:\Python313 都沒有 bpy。
# 依專案指示「沒有就不要裝」,改用已存在的 trimesh 4.12.2 + numpy + shapely。
#
# 座標系(實測自 STK 內建模型,不是猜的)
# 讀 C:\Program Files\AGI\STK 12\STKData\VO\Models\Air\airliner.glb 把節點
# 矩陣套完之後做 Z 切片:z=-27.8..-23.6 這一段 |X|max 只有 6.6 m 但 Y 衝到
# +7.67 m(垂尾),z=-15..-3 這一段 |X|max=20.27 m(主翼)。
# => glTF 空間裡 **+Z 是機頭方向、+Y 是上、X 是橫向、單位公尺**。
# AGI 的匯出器在根節點放一個把機體軸 (X前/Y右/Z下) 轉到上述 glTF 軸的矩陣;
# 我們直接用 glTF 軸建模,所以不需要那個根節點矩陣。
# 另外實測 trimesh 的 glb 匯出器寫出來的節點是 identity(沒有偷偷轉軸),
# 所以這裡建的座標會原封不動進到檔案裡。
#
# 面積怎麼對上 85 m²
# 85 m² 是 Aviator 的「參考機翼面積」,和客機的 WingArea 一樣是理論面積 ——
# 三角翼延伸到中心線、含埋在機身裡的那一塊(太空梭 249.9 m² 也是這樣算的)。
# 展弦比 1.42 很低,所以這塊理論планform 幾乎佔滿俯視圖:
# 平均弦長 = 85/11 = 7.73 m,對 18 m 的機身來說根弦會到 ~14 m。
# 這不是畫錯,是 AR=1.42 逼出來的形狀(升力體而不是客機翼)。
# 襟翼/升降副翼是疊在機翼後緣上的獨立實體,投影面積被 union 吸收,
# 所以 machine翼 planform 仍然剛好 85 m²,而襟翼自己的面積可以單獨量。
# ==============================================================================
import sys
import json
import struct
from pathlib import Path
import numpy as np
import trimesh
from shapely.geometry import Polygon
from shapely.ops import unary_union
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
from spaceport_vehicle import SPACEPLANE, derived # noqa: E402
CFG = derived(SPACEPLANE)
OUT_GLB = HERE / "twsp2_spaceplane.glb"
OUT_OBJ = HERE / "twsp2_spaceplane.obj"
OUT_MTL = HERE / "twsp2_spaceplane.mtl"
LENGTH = float(CFG["length_m"]) # 18.0
SPAN = float(CFG["span_m"]) # 11.0
WING_AREA = float(CFG["wing_area_m2"]) # 85.0
FLAP_AREA = float(CFG["flaps_area_m2"]) # 12.75
SB_AREA = float(CFG["speedbrakes_area_m2"]) # 4.25
Z_NOSE = +LENGTH / 2.0 # +9.0
Z_TAIL = -LENGTH / 2.0 # -9.0
HALF_SPAN = SPAN / 2.0 # 5.5
# 機身
FUS_W = 3.20 # 最大寬
FUS_H = 2.70 # 最大高
NOSE_R = 0.65 # 鈍頭球半徑(le_frontal_ratio 0.03 -> 鈍)
NOSE_LEN = 5.00
BASE_FRAC = 0.88 # 尾部收到最大斷面的 88%(鈍底)
# 機翼 planform
Z_TE = -8.00 # 後緣(留 1 m 給體襟翼)
TIP_CHORD = 1.20
STRAKE_X = 1.50 # 內側 strake 折點
STRAKE_DZ = 4.40 # strake 段的後掠量
WING_Y = -0.30 # 翼面掛在機身中下方
RGBA = {
"fuselage": (232, 232, 236, 255), # TPS 白
"nose": ( 28, 28, 32, 255), # RCC 黑鈍頭
"wing": (222, 222, 228, 255),
"wing_le": ( 28, 28, 32, 255),
"elevon": (150, 152, 160, 255),
"bodyflap": ( 40, 40, 46, 255),
"fin": (228, 228, 234, 255),
"rudder_sb": (190, 120, 60, 255), # 減速板 = 開裂方向舵
}
# ── 小工具 ────────────────────────────────────────────────────────────────────
def _quad_grid(P):
"""P[i, j] 是 (ni, nj, 3) 的點陣,回傳三角形索引(不封邊)。"""
ni, nj = P.shape[0], P.shape[1]
idx = np.arange(ni * nj).reshape(ni, nj)
a, b = idx[:-1, :-1].ravel(), idx[1:, :-1].ravel()
c, d = idx[1:, 1:].ravel(), idx[:-1, 1:].ravel()
f = np.vstack([np.column_stack([a, b, c]),
np.column_stack([a, c, d])])
return P.reshape(-1, 3), f
def _mesh(v, f, key):
m = trimesh.Trimesh(vertices=np.asarray(v, float),
faces=np.asarray(f, int), process=True)
r, g, b, a = RGBA[key]
m.visual = trimesh.visual.TextureVisuals(
material=trimesh.visual.material.PBRMaterial(
name=key,
baseColorFactor=[r / 255, g / 255, b / 255, a / 255],
metallicFactor=0.15, roughnessFactor=0.55))
return m
def _slab(x0, x1, z0, z1, y0, y1, key):
"""軸對齊方塊(給襟翼/體襟翼/減速板用),回傳 mesh。"""
box = trimesh.creation.box(extents=(x1 - x0, y1 - y0, z1 - z0))
box.apply_translation(((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2))
return _mesh(box.vertices, box.faces, key)
# ── 機翼 planform:解出根弦讓理論面積剛好 = 85 m² ──────────────────────────────
def half_planform(z_le_root):
"""半邊 planform 的 (x, z) 多邊形,逆時針。"""
return [(0.0, z_le_root),
(STRAKE_X, z_le_root - STRAKE_DZ),
(HALF_SPAN, Z_TE + TIP_CHORD),
(HALF_SPAN, Z_TE),
(0.0, Z_TE)]
def solve_root_le():
"""planform 面積對 z_le_root 是線性的 -> 兩點求解,再驗算。"""
def area(r):
return 2.0 * Polygon(half_planform(r)).area
r0, r1 = 0.0, 10.0
a0, a1 = area(r0), area(r1)
r = r0 + (WING_AREA - a0) * (r1 - r0) / (a1 - a0)
assert abs(area(r) - WING_AREA) < 1e-9, (r, area(r))
return r
Z_LE_ROOT = solve_root_le()
def wing_edges(x):
"""給 |x|,回傳 (z_le, z_te)。"""
x = np.abs(x)
if x <= STRAKE_X:
z_le = Z_LE_ROOT - (STRAKE_DZ / STRAKE_X) * x
else:
z1, z2 = Z_LE_ROOT - STRAKE_DZ, Z_TE + TIP_CHORD
z_le = z1 + (z2 - z1) * (x - STRAKE_X) / (HALF_SPAN - STRAKE_X)
return z_le, Z_TE
def naca_thickness(s, chord, tc=0.085):
"""NACA 4 位數厚度分布。sqrt 項天生給出鈍前緣,符合 le_frontal_ratio=0.03。"""
s = np.clip(s, 0.0, 1.0)
return 5.0 * tc * chord * (0.2969 * np.sqrt(s) - 0.1260 * s
- 0.3516 * s ** 2 + 0.2843 * s ** 3
- 0.1015 * s ** 4)
def build_wing(n_span=30, n_chord=34):
"""左右對稱的實心機翼,LE/TE 精確落在 planform 上 -> 投影面積可驗證。"""
xs = np.concatenate([-np.linspace(HALF_SPAN, 0.0, n_span)[:-1],
np.linspace(0.0, HALF_SPAN, n_span)])
ss = (1 - np.cos(np.linspace(0, np.pi, n_chord))) / 2 # LE/TE 加密
up = np.zeros((len(xs), n_chord, 3))
lo = np.zeros((len(xs), n_chord, 3))
for i, x in enumerate(xs):
z_le, z_te = wing_edges(x)
chord = z_le - z_te
t = naca_thickness(ss, chord)
z = z_le - ss * chord
up[i, :, 0] = lo[i, :, 0] = x
up[i, :, 2] = lo[i, :, 2] = z
up[i, :, 1] = WING_Y + 0.85 * t # 上表面
lo[i, :, 1] = WING_Y - 0.15 * t # 下表面近乎平底(再入載具特徵)
vu, fu = _quad_grid(up)
vl, fl = _quad_grid(lo)
n = len(vu)
verts = np.vstack([vu, vl])
faces = np.vstack([fu, fl[:, ::-1] + n])
# 封翼尖(i=0 與 i=-1)與前後緣縫合
ni, nj = up.shape[0], up.shape[1]
iu = np.arange(ni * nj).reshape(ni, nj)
il = iu + n
tips = []
for row, flip in ((0, False), (ni - 1, True)):
for j in range(nj - 1):
a, b = iu[row, j], iu[row, j + 1]
c, d = il[row, j + 1], il[row, j]
q = [(a, b, c), (a, c, d)]
tips += [t[::-1] for t in q] if flip else q
faces = np.vstack([faces, np.array(tips)])
return _mesh(verts, faces, "wing")
# ── 機身 ─────────────────────────────────────────────────────────────────────
def fus_halfsize(d):
"""距機頭 d 公尺處的半寬、半高。球形鈍頭 -> 冪律過渡 -> 等剖面 -> 鈍底。"""
if d <= NOSE_R:
r = np.sqrt(max(NOSE_R ** 2 - (NOSE_R - d) ** 2, 0.0))
return r, r
if d <= NOSE_LEN:
f = ((d - NOSE_R) / (NOSE_LEN - NOSE_R)) ** 0.70
return (NOSE_R + (FUS_W / 2 - NOSE_R) * f,
NOSE_R + (FUS_H / 2 - NOSE_R) * f)
d_taper = LENGTH * 0.80
if d <= d_taper:
return FUS_W / 2, FUS_H / 2
f = 1.0 - (1.0 - BASE_FRAC) * (d - d_taper) / (LENGTH - d_taper)
return FUS_W / 2 * f, FUS_H / 2 * f
def _closed_tube(P, key):
"""P (ni, nj, 3) 已環向閉合;兩端各加一個扇形封口 -> 封閉實體。"""
v, f = _quad_grid(P)
ni, nj = P.shape[0], P.shape[1]
idx = np.arange(ni * nj).reshape(ni, nj)
extra = []
for row, flip in ((0, True), (ni - 1, False)):
cen = len(v)
v = np.vstack([v, P[row].mean(axis=0)])
for j in range(nj - 1):
t = (cen, idx[row, j], idx[row, j + 1])
extra.append(t[::-1] if flip else t)
return _mesh(v, np.vstack([f, np.array(extra)]), key)
def build_fuselage(n_len=46, n_ring=28, nose_black_len=2.20):
"""超橢圓斷面升力體:上緣圓潤、下緣偏平。
黑色鈍頭錐是把機身**切成兩段**,不是外包一層放大的殼。
先前用 `vc * 1.005` 外包的寫法會把整個殼對原點等比放大,
機頭在 z=+9 被推到 +9.045,量到的全長就變成 18.045 m(超規 0.25%)。
改成共用同一組斷面、在 nose_black_len 處切開,兩段表面完全貼合,
全長才會精確等於 18.000 m。
"""
ds = np.concatenate([np.linspace(0, NOSE_LEN, 18)[:-1],
np.linspace(NOSE_LEN, LENGTH, n_len - 17)])
# 確保切點正好是一個斷面,兩段才不會有縫
ds = np.unique(np.concatenate([ds, [nose_black_len]]))
th = np.linspace(0, 2 * np.pi, n_ring, endpoint=False)
P = np.zeros((len(ds), n_ring, 3))
for i, d in enumerate(ds):
a, b = fus_halfsize(d)
ct, st = np.cos(th), np.sin(th)
# 上半 n=2.6(圓潤)、下半 n=3.6(平底)
n_exp = np.where(st >= 0, 2.6, 3.6)
x = a * np.sign(ct) * np.abs(ct) ** (2.0 / n_exp)
y = b * np.sign(st) * np.abs(st) ** (2.0 / n_exp)
y = y - 0.12 * b # 讓腹部更平、背部拱起
P[i, :, 0], P[i, :, 1], P[i, :, 2] = x, y, Z_NOSE - d
P = np.concatenate([P, P[:, :1, :]], axis=1) # 環向閉合
k = int(np.argmin(np.abs(ds - nose_black_len)))
nose = _closed_tube(P[:k + 1], "nose") # RCC 黑鈍頭
body = _closed_tube(P[k:], "fuselage") # TPS 白機身
return body, nose
def build_fin():
"""中線垂尾 + 上段開裂方向舵當減速板。"""
z_root_le, z_root_te = -4.30, -8.40
z_tip_le, z_tip_te = -6.60, -8.40
y0, y1 = 1.05, 4.10
half_t = 0.16
prof = []
for (zle, zte, y) in ((z_root_le, z_root_te, y0), (z_tip_le, z_tip_te, y1)):
ss = (1 - np.cos(np.linspace(0, np.pi, 16))) / 2
c = zle - zte
t = naca_thickness(ss, c, tc=0.10) * 0.5 + 1e-4
prof.append((zle - ss * c, y, t))
ni, nj = 2, 16
Pp = np.zeros((ni, nj, 3)); Pn = np.zeros((ni, nj, 3))
for i, (z, y, t) in enumerate(prof):
Pp[i, :, 0], Pp[i, :, 1], Pp[i, :, 2] = np.minimum(t, half_t), y, z
Pn[i, :, 0], Pn[i, :, 1], Pn[i, :, 2] = -np.minimum(t, half_t), y, z
vp, fp = _quad_grid(Pp)
vn, fn = _quad_grid(Pn)
n = len(vp)
v = np.vstack([vp, vn]); f = np.vstack([fp, fn[:, ::-1] + n])
ip = np.arange(ni * nj).reshape(ni, nj); iq = ip + n
edge = []
for row, flip in ((0, False), (ni - 1, True)):
for j in range(nj - 1):
q = [(ip[row, j], ip[row, j + 1], iq[row, j + 1]),
(ip[row, j], iq[row, j + 1], iq[row, j])]
edge += [t[::-1] for t in q] if flip else q
f = np.vstack([f, np.array(edge)])
fin = _mesh(v, f, "fin")
# 減速板 = 方向舵上段;面積由設計點反算弦長
sb_span = 2.40
sb_chord = SB_AREA / sb_span
sb_y0 = y1 - sb_span
rud = _slab(-0.22, 0.22, z_root_te, z_root_te + sb_chord,
sb_y0, y1, "rudder_sb")
return fin, rud, dict(span=sb_span, chord=sb_chord, y=(sb_y0, y1))
def build_control_surfaces():
"""升降副翼(機翼後緣)+ 體襟翼(機身尾下)= 襟翼總面積 12.75 m²。"""
el_x0, el_x1 = 1.80, 5.30
el_span = el_x1 - el_x0
bf_w, bf_chord = 2.75, 1.20
bf_area = bf_w * bf_chord
el_area_each = (FLAP_AREA - bf_area) / 2.0
el_chord = el_area_each / el_span
parts = {}
for side, sgn in (("port", +1), ("stbd", -1)):
x0, x1 = sorted((sgn * el_x0, sgn * el_x1))
zc = Z_TE + el_chord
# 厚度取該處翼厚,略微外包好讓它在 STK 裡看得出是獨立面
t = naca_thickness(np.array([1.0 - el_chord / 6.0]), 6.0)[0]
parts[f"elevon_{side}"] = _slab(x0, x1, Z_TE, zc,
WING_Y - 0.10, WING_Y + 0.10 + t,
"elevon")
parts["body_flap"] = _slab(-bf_w / 2, bf_w / 2,
Z_TAIL, Z_TAIL + bf_chord,
-1.05, -0.70, "bodyflap")
info = dict(elevon_span_each=el_span, elevon_chord=el_chord,
elevon_area_each=el_area_each,
bodyflap=(bf_w, bf_chord, bf_area))
return parts, info
# ── 量測(和建構邏輯無關,只看最終三角形)────────────────────────────────────
def projected_area(meshes):
"""把三角形投影到 XZ 平面取聯集面積 —— 重疊不會重複計算。"""
polys = []
for m in meshes:
tri = m.vertices[m.faces][:, :, [0, 2]]
for t in tri:
p = Polygon(t)
if p.is_valid and p.area > 1e-12:
polys.append(p)
return unary_union(polys).area
def main():
print("=" * 74)
print("TWSP2 太空飛機雛型 —— trimesh 產生器(Blender 不存在於本機)")
print("=" * 74)
print(f" trimesh {trimesh.__version__} / numpy {np.__version__}")
print(f" 解出的根弦前緣 z_le_root = {Z_LE_ROOT:+.4f} m"
f" -> 根弦 {Z_LE_ROOT - Z_TE:.3f} m"
f"(佔全長 {(Z_LE_ROOT - Z_TE) / LENGTH:.0%})")
fus, nose = build_fuselage()
wing = build_wing()
fin, rudder_sb, sb_info = build_fin()
ctrl, ctrl_info = build_control_surfaces()
geoms = {"fuselage": fus, "nose_cap": nose, "wing": wing,
"fin": fin, "rudder_speedbrake": rudder_sb}
geoms.update(ctrl)
scene = trimesh.Scene(geoms)
# ---- 匯出 ----
# include_normals=True 是必要的:trimesh 預設不寫 NORMAL,
# 而 glTF 規格規定沒有 NORMAL 的 primitive 必須用 flat shading,
# 在 STK 裡機身會變成一格一格的多面體而不是平滑曲面。(實測過)
glb = scene.export(file_type="glb", include_normals=True)
OUT_GLB.write_bytes(glb)
# OBJ 備援。必須用 return_texture 把 .mtl 一起拿出來寫檔 ——
# 只寫 .obj 的話裡面的 `mtllib material.mtl` 會指向不存在的檔案,
# 顏色全部掉光。(第一次就是這樣,實測抓到的)
from trimesh.exchange.obj import export_obj
obj_txt, tex = export_obj(scene, include_normals=True,
return_texture=True, mtl_name=OUT_MTL.name)
OUT_OBJ.write_text(obj_txt, encoding="utf-8")
for fn, blob in tex.items():
(OUT_OBJ.parent / fn).write_bytes(blob)
# ---- 回讀驗證 ----
back = trimesh.load(OUT_GLB, force="scene")
bl, bu = back.bounds
meas_len = bu[2] - bl[2]
meas_span = bu[0] - bl[0]
meas_h = bu[1] - bl[1]
wing_grp = [back.geometry[k] for k in back.geometry
if k.startswith(("wing", "elevon"))]
meas_wing = projected_area(wing_grp)
meas_elev = projected_area([back.geometry[k] for k in back.geometry
if k.startswith("elevon")])
meas_bf = projected_area([back.geometry[k] for k in back.geometry
if k.startswith("body_flap")])
meas_sb = projected_area([back.geometry[k] for k in back.geometry
if "rudder" in k]) # 俯視投影,僅供對照
sb_side = sb_info["span"] * sb_info["chord"]
print(f"\n 幾何:{len(back.geometry)} 個 mesh,"
f"{sum(len(g.faces) for g in back.geometry.values())} 個三角形")
print(f" 邊界盒 X[{bl[0]:+.3f},{bu[0]:+.3f}] "
f"Y[{bl[1]:+.3f},{bu[1]:+.3f}] Z[{bl[2]:+.3f},{bu[2]:+.3f}]")
print("\n ── 量到的尺寸 vs 設計點 ──────────────────────────────────")
print(f" {'項目':<22}{'設計點':>10}{'量測':>10}{'差':>10}{'相對':>9}")
rows = [("全長 m (Z 向)", LENGTH, meas_len),
("翼展 m (X 向)", SPAN, meas_span),
("機翼參考面積 m²", WING_AREA, meas_wing),
("襟翼面積 m² (升降副翼)", ctrl_info["elevon_area_each"] * 2,
meas_elev),
("減速板面積 m² (側視)", SB_AREA, sb_side)]
worst = 0.0
for label, want, got in rows:
d = got - want
rel = d / want if want else 0.0
worst = max(worst, abs(rel))
mark = "OK " if abs(rel) < 0.01 else "!! "
print(f" {mark}{label:<20}{want:10.3f}{got:10.3f}{d:+10.4f}"
f"{rel:+8.2%}")
print(f" (無設計點) 全高 m(含垂尾){'-':>10}{meas_h:10.3f}")
print(f" (無設計點) 體襟翼 m² {'-':>10}{meas_bf:10.3f}"
f" 目標 {ctrl_info['bodyflap'][2]:.3f}")
print(f" (參考) 襟翼+體襟翼 m² "
f"{meas_elev + ctrl_info['bodyflap'][2]:10.3f}"
f" 設計點襟翼總額 {FLAP_AREA:.3f}")
print(f" 展弦比 = {meas_span ** 2 / meas_wing:.4f}"
f" 設計點 {CFG['aspect_ratio']:.4f}")
print(f"\n 最大相對誤差 {worst:.3%}")
# ---- glTF 結構檢查 ----
hdr = struct.unpack("<III", glb[:12])
clen, _ = struct.unpack("<II", glb[12:20])
j = json.loads(glb[20:20 + clen].decode("utf-8"))
print(f"\n ── 檔案 ──────────────────────────────────────────────────")
print(f" {OUT_GLB}")
print(f" magic={hdr[0]:#x} version={hdr[1]} size={hdr[2]} bytes"
f" (實際 {OUT_GLB.stat().st_size})")
print(f" glTF {j['asset']['version']} / generator "
f"{j['asset'].get('generator')}")
print(f" nodes={len(j['nodes'])} meshes={len(j['meshes'])} "
f"materials={len(j.get('materials', []))}")
root_mats = [m.get("name") for m in j.get("materials", [])]
print(f" materials: {root_mats}")
nonid = [n.get("name") for n in j["nodes"]
if any(k in n for k in ("matrix", "rotation", "scale"))]
print(f" 帶轉換的節點(應為空,代表座標原封不動):{nonid}")
print(f" {OUT_OBJ} {OUT_OBJ.stat().st_size} bytes")
for fn in sorted(tex):
f2 = OUT_OBJ.parent / fn
print(f" {f2} {f2.stat().st_size} bytes")
ok = worst < 0.01
print("\n " + ("全部項目在 1% 內。" if ok else "*** 有項目超出 1%。***"))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/tests/test_deploy_doc.py
# -*- coding: utf-8 -*-
"""部署庫那份對外文件的過期宣稱檢查。**不需要 STK。**
## 為什麼需要這一支
`F:\GitHub\suborbital-workbench\web\設計與計算原理.md` 是**對外**文件,
但它不在主庫裡,所以 `tests/test_docs.py` 看不到它。結果是:設計點改了
四次(2.0 → 4.0 → 3.2 → 2.3 → 2.2),那份文件整整停在 **L/D 4.0**,
而且還在主張「3,765 km 無模型涵蓋」—— 那早就被證實是記帳錯誤。
這正是 `test_docs.py` 當初要解決的同一類問題,只是換了一個庫。
## 檢查方式
**不是**去比對每一個數字(那要解析散文,不可靠),而是維護一張
「已經過期的字串」表。文件裡每出現一次,就必須滿足其中一個:
* 已經改成現值,或
* 所在的章節被標記為**歷史紀錄**(標題下一行放 `<!-- 歷史紀錄 -->`)
歷史紀錄的章節是刻意保留的 —— 它們記錄「當時」的推理,
把數字改掉反而會讓那段推理讀不通。但它們必須**明講自己是歷史**。
任何一處既沒改、又沒標記,就是失敗。
python tests/test_deploy_doc.py
python tests/test_deploy_doc.py --list # 列出所有命中,給修文件時用
"""
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
# 部署庫的位置。不在就**回報**,不要靜默跳過。
DEPLOY = os.environ.get(
"SUBORBITAL_DEPLOY_DIR", r"F:\GitHub\suborbital-workbench")
DOC = os.path.join(DEPLOY, "web", "設計與計算原理.md")
HISTORY_MARK = "<!-- 歷史紀錄 -->" # 放在標題下一行:整節都是沿革
LINE_MARK = "<!-- 歷史 -->" # 放在行末:只有那一行是沿革
# 行級標記用在「一節裡混著現值與沿革」的情況 —— 例如檔首既要講現在的
# 定位,又要保留每一次更正的前後對照。整節標成歷史會讓現值宣稱漏過檢查。
# ── 已經過期的宣稱 ────────────────────────────────────────────────────
# (正規式, 它當初是什麼, 現在是什麼)
#
# **用正規式而不是純字串**,因為裸數字會撞到別的量:
# 「6.13」既是舊的接縫消失升阻比,也是目前的第一節質量比;
# 「1,189 km」既是舊架構的遠地點,也是**現在仍然正確**的純彈道基準遠地點。
# 用純字串比對的話,這兩個都會誤報。
STALE = [
(r"L/D 4\.0", "舊設計點", "L/D 2.2"),
(r"升阻比 4\.0", "舊設計點", "升阻比 2.2"),
(r"6,335 m/s", "L/D 4.0 的熄火速度", "6,819 m/s"),
(r"3,765 km", "「無模型涵蓋」的缺口", "記帳錯誤,航程接縫已歸零"),
(r"1,327 km", "L/D 4.0 的上升段", "1,486 km"),
(r"8,017 km", "L/D 4.0 的滑翔段", "9,764 km"),
(r"13\.2\s*:\s*1", "L/D 4.0 的質量比", "15.5 : 1"),
# 67.1 km 是 5,400 m/s 的平衡滑翔高度,那個值**現在仍然正確** ——
# 只有被當成**交接高度**時才是過期的(交接速度已不是 5,400)。
(r"交接高度[^|]*67\.1 km", "L/D 4.0 的交接高度", "76.4 km"),
(r"67\.1 km[^|]*交接", "L/D 4.0 的交接高度", "76.4 km"),
# 遠地點 107 km 是 L/D 3.2 之前的值
(r"遠地點[^|]*107 km", "L/D 4.0 的遠地點", "112 km"),
(r"\*\*107 km\*\*", "L/D 4.0 的遠地點", "112 km"),
# 接縫消失升阻比:只在**被當成那個量**時才算過期。
# 裸「6.13」會撞到第一節質量比,所以一定要帶上下文。
(r"(?:接縫|無接縫|求解上限)[^|]*6\.(?:54|13)",
"舊的接縫消失升阻比", "5.62"),
(r"6\.(?:54|13)\s*\|\s*\*?\*?5,400",
"舊的接縫消失升阻比", "5.62"),
(r"\*\*6\.(?:54|13)\*\*", "舊的接縫消失升阻比", "5.62"),
# 遠地點:只在被當成**本任務**的遠地點時才算過期。
(r"遠地點[^|]*1,(?:189|130) km", "舊架構的遠地點", "112 km"),
(r"1,(?:189|130) km[^|]*遠地點", "舊架構的遠地點", "112 km"),
(r"\*\*1,(?:189|130) km\*\*", "舊架構的遠地點", "112 km"),
]
# 行內豁免:出現這些字樣時不算命中。每一條都要有理由。
SAFE = (
"審查意見 3.2", # 審查報告的章節編號,不是升阻比
"純彈道", # 1,189 km 當純彈道基準值是**現在仍然正確**的
"判準看", # 接縫消失升阻比的修正沿革表
"判準改看",
)
def _read():
return io.open(DOC, encoding="utf-8").read()
def sections(text):
"""切成 [(標題, 是否歷史紀錄, 起始行, 內容行)]。"""
lines = text.splitlines()
out = []
cur = {"title": "(檔首)", "hist": False, "start": 1, "body": []}
for i, ln in enumerate(lines, 1):
if re.match(r"^#{2,4} ", ln):
out.append(cur)
cur = {"title": ln.strip(), "hist": False, "start": i,
"body": []}
continue
# 標記只要出現在**第一行非空內容之前**就算 ——
# 要求緊接標題太嚴,標題與標記之間放空行是常見的排版。
if (ln.strip() == HISTORY_MARK
and not any(b.strip() for _i, b in cur["body"])):
cur["hist"] = True
continue
cur["body"].append((i, ln))
out.append(cur)
return out
def scan():
"""回傳 [(行號, 章節標題, 是否歷史, 命中的字串, 說明, 現值)]。"""
hits = []
for sec in sections(_read()):
for lineno, ln in sec["body"]:
if LINE_MARK in ln or any(x in ln for x in SAFE):
continue
for pat, was, now in STALE:
m = re.search(pat, ln)
if m:
hits.append((lineno, sec["title"], sec["hist"],
m.group(0)[:22], was, now))
return hits
# ══════════════════════════════════════════════════════════════════════
def test_deploy_doc_exists():
"""部署庫的文件要找得到 —— 找不到就是失敗,不是跳過。"""
assert os.path.isfile(DOC), (
f"找不到 {DOC}。若部署庫在別處,設 SUBORBITAL_DEPLOY_DIR")
def test_no_unmarked_stale_claims():
"""過期的數值宣稱,必須改掉或所在章節標為歷史紀錄。"""
bad = [h for h in scan() if not h[2]]
if bad:
msg = "\n".join(
f" 第 {ln} 行「{tok}」({was} → 現在 {now})"
f"\n 在:{title[:48]}"
for ln, title, _h, tok, was, now in bad[:12])
extra = f"\n …另有 {len(bad)-12} 處" if len(bad) > 12 else ""
raise AssertionError(
f"{len(bad)} 處未標記的過期宣稱:\n{msg}{extra}")
def test_line_marks_say_so_in_prose():
"""行級歷史標記所在的那一行,散文裡也要看得出來是沿革。
讀者看不到 HTML 註解。標記只是讓檢查器閉嘴,不是讓讀者誤會。
"""
words = ("當時", "舊架構", "舊模型", "先前", "更正", "已收回",
"第一版", "第二版", "停在")
bad = []
for i, ln in enumerate(_read().splitlines(), 1):
if LINE_MARK not in ln:
continue
if not any(w in ln for w in words):
bad.append(f"第 {i} 行")
assert not bad, (
"標了行級歷史但那一行沒講明白:" + "、".join(bad))
def test_history_sections_say_so_in_prose():
"""標成歷史紀錄的章節,散文裡也要看得出來 —— 標記是給機器看的。
讀者不會看到 HTML 註解。所以那一節的散文必須自己講明白
它記的是「當時」的數字,否則讀者仍會把它當現值。
"""
words = ("當時", "歷史", "舊架構", "舊模型", "修訂記錄", "已收回",
"第一版", "先前", "更正前", "停在")
bad = []
for sec in sections(_read()):
if not sec["hist"]:
continue
# 標題也算散文 —— 讀者第一眼看到的就是它
body = sec["title"] + "\n" + "\n".join(
ln for _i, ln in sec["body"])
if not any(w in body for w in words):
bad.append(sec["title"][:56])
assert not bad, (
"標了歷史紀錄但散文沒講明白的章節:" + "、".join(bad))
def test_current_values_block_matches_code():
"""檔首那張「目前」表要與程式一致。"""
import spaceport_analysis as A
import spaceport_build as B
doc = _read()
i = doc.find("## ⚠ 本文件落後於頁面")
assert i >= 0, "找不到檔首的現值區塊"
block = doc[i:i + 3000]
ho = B.mission_geometry(verbose=False)
want = (("設計點 `LD_DESIGN`", f"{A.LD_DESIGN:g}"),
("熄火速度", f"{ho['v_bo']:,.0f} m/s"),
("遠地點", f"{ho['apogee_m']/1000:,.0f} km"),
("動力上升段", f"{ho['ascent_m']/1000:,.0f} km"),
("助推真空弧", f"{ho['arc_m']/1000:,.0f} km"))
miss = [f"{lbl}({val})" for lbl, val in want if val not in block]
assert not miss, f"現值表與程式不符:{'、'.join(miss)}"
def test_generated_blocks_are_present():
"""由 `web/embed_principles.py` 產生的區塊要在,而且不是空的。
## 為什麼補這一項
8.5 的「四段長度與時程」**過期過兩次**:一次停在 L/D 4.0、
一次停在 2.2。原因和交叉核對表一模一樣 ——
量測自動化了,**表格還是手打的**。
現在那張表夾在標記之間由程式產生。這一項守的是「標記還在、
而且中間有東西」:若有人手改把標記刪掉,重產就會靜默失效。
表格內容本身不在這裡比對 —— 它要 STK 才算得出時程,
而這一套測試刻意不依賴 STK。`embed_principles.py` 自己會在
STK 讀不到時**跳過**那一塊,不寫半套進去。
"""
doc = _read()
want = (("<!-- BEGIN 設計點數值", "<!-- END 設計點數值 -->", "設計點數值"),
("<!-- BEGIN 四段時程", "<!-- END 四段時程 -->", "四段時程"))
for b, e, name in want:
i = doc.find(b)
assert i >= 0, f"找不到「{name}」的 BEGIN 標記"
j = doc.find(e, i)
assert j > i, f"找不到「{name}」的 END 標記"
inner = doc[i:j].split("\n", 1)[1].strip()
assert len(inner) > 40, (
f"「{name}」的產生區塊是空的({len(inner)} 字)——"
" embed_principles.py 可能跳過了")
# ══════════════════════════════════════════════════════════════════════
def main():
if "--list" in sys.argv:
hits = scan()
print(f" 共 {len(hits)} 處命中"
f"({sum(1 for h in hits if h[2])} 處在歷史紀錄章節)")
cur = None
for ln, title, hist, tok, was, now in hits:
if title != cur:
cur = title
print(f"\n {'[歷史] ' if hist else ''}{title[:66]}")
print(f" {ln:>5} {tok:<12} {was} → {now}")
return 0
tests = [(n, f) for n, f in sorted(globals().items())
if n.startswith("test_") and callable(f)]
print("=" * 74)
print(f" 部署庫對外文件的過期宣稱檢查 —— {len(tests)} 項")
print("=" * 74)
bad = []
for name, fn in tests:
try:
fn()
print(f" [OK ] {name}")
except AssertionError as exc:
bad.append(name)
print(f" [FAIL] {name}\n {exc}")
except Exception as exc: # noqa: BLE001
bad.append(name)
print(f" [ERR ] {name}\n {type(exc).__name__}: {exc}")
print()
print("=" * 74)
print(f" {len(tests) - len(bad)}/{len(tests)} 項通過")
if bad:
print(f" 未通過:{bad}")
print(" 用 --list 看每一處命中在哪裡")
print("=" * 74)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/tests/test_docs.py
# -*- coding: utf-8 -*-
"""文件與程式的一致性測試。**不需要 STK。**
## 為什麼需要這一支
這一章的文件記了大量數字:設計點、三段長度、熄火速度、驗證項數、
網頁章節數。**每一次改設計都會讓其中一批過期**,而過期的文件
沒有任何東西會抓到。實際發生過的:
* `程式架構.md` 的架構表停在 L/D 4.0 的分段,而程式已經是 3.2
* 開放項目 11.5 標示為「未解決」,但設計點早就改了 ——
原因是那個 patch 腳本因為別處 assert 失敗而**整個中止**,沒人發現
* `README.md` 的飛行階段表被 patch 腳本寫成**只剩表頭**,並推送出去
最後一個尤其糟:一個 patch 腳本部分套用就寫檔,把表格內容刪光,
三道閘門(測試、STK 驗證、冒煙測試)沒有一道會看 README。
這支測試把文件當成**會過期的產物**來檢查,而不是當成散文。
python tests/test_docs.py # 自帶檢查器
pytest tests/test_docs.py -q # 也可以用 pytest
"""
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
REPO = os.path.dirname(ROOT)
sys.path.insert(0, ROOT)
import spaceport_analysis as A # noqa: E402
import spaceport_build as B # noqa: E402
B_ = B
ARCH = os.path.join(ROOT, "程式架構.md")
README = os.path.join(REPO, "README.md")
PAGE = os.path.join(ROOT, "web", "suborbital_workbench.html")
def _read(path):
return io.open(path, encoding="utf-8").read()
def _num(txt):
"""把 '1,318' 這種帶千分位的字串轉成數字。"""
return float(txt.replace(",", ""))
# ══════════════════════════════════════════════════════════════════════
# 一、文件宣稱的數字 vs 程式算出來的
# ══════════════════════════════════════════════════════════════════════
def test_arch_current_design_point_matches_code():
"""`程式架構.md` 的「目前的設計點」表必須與程式一致。
這張表是讀者看現值的地方。歷史區塊可以保留當時的數字
(它們有標註「當時」),但這張表不行。
"""
doc = _read(ARCH)
ho = B.mission_geometry(verbose=False)
block = doc[doc.index("### 目前的設計點"):]
block = block[:block.index("\n## ")] if "\n## " in block else block
m = re.search(r"`LD_DESIGN`\s*\|\s*\*\*([\d.]+)\*\*", block)
assert m, "找不到 LD_DESIGN 那一列"
assert abs(float(m.group(1)) - A.LD_DESIGN) < 1e-9, (
f"文件寫 LD_DESIGN {m.group(1)},程式是 {A.LD_DESIGN}")
m = re.search(r"`v_bo`\s*\|\s*([\d,]+)\s*m/s", block)
assert m, "找不到 v_bo 那一列"
assert abs(_num(m.group(1)) - ho["v_bo"]) < 5.0, (
f"文件寫 v_bo {m.group(1)},程式是 {ho['v_bo']:,.0f}")
m = re.search(r"接縫消失升阻比\s*\|\s*([\d.]+)", block)
assert m, "找不到接縫消失升阻比"
assert abs(float(m.group(1)) - B.SEAM_FREE_LD) < 0.02, (
f"文件寫 {m.group(1)},程式是 {B.SEAM_FREE_LD}")
def test_arch_segment_table_matches_code():
"""第一節的三段長度表必須與程式一致。"""
doc = _read(ARCH)
ho = B.mission_geometry(verbose=False)
head = doc[:doc.index("### 目前的設計點")]
want = (("動力上升", ho["ascent_m"]), ("助推真空弧", ho["arc_m"]),
("高超音速滑翔", ho["eph_m"]))
for label, val in want:
m = re.search(re.escape(label) + r".*?\|\s*([\d,]+) km", head, re.S)
assert m, f"架構表找不到「{label}」的長度"
got = _num(m.group(1)) * 1000.0
assert abs(got - val) / val < 0.01, (
f"{label}:文件 {m.group(1)} km,程式 {val/1000:,.0f} km")
def test_readme_stage_table_matches_code():
"""README 的飛行階段表:長度要對,而且**不能是空表**。
這一項就是為了 README 被 patch 腳本寫成只剩表頭那次。
"""
doc = _read(README)
ho = B.mission_geometry(verbose=False)
i = doc.index("| 飛行階段 |")
block = doc[i:i + 900]
rows = [ln for ln in block.splitlines()
if ln.startswith("|") and not ln.startswith("|---")
and "飛行階段" not in ln]
assert len(rows) >= 4, f"階段表只有 {len(rows)} 列資料 —— 疑似被寫壞"
for label, val in (("動力上升", ho["ascent_m"]),
("助推真空弧", ho["arc_m"]),
("高超音速滑翔", ho["eph_m"])):
m = re.search(re.escape(label) + r"[^|]*?([\d,]+) km", block)
assert m, f"README 階段表找不到「{label}」"
got = _num(m.group(1)) * 1000.0
assert abs(got - val) / val < 0.01, (
f"README {label}:{m.group(1)} km vs 程式 {val/1000:,.0f} km")
def test_no_empty_markdown_table_rows():
"""任何 markdown 表格的表頭與分隔線之後,必須至少有一列資料。
README 那次就是「表頭 + 分隔線 + 空行」,肉眼掃過去很容易漏掉。
"""
for path in (ARCH, README):
lines = _read(path).splitlines()
for i, ln in enumerate(lines):
if not re.match(r"^\|\s*-{2,}", ln.replace(" ", "")):
continue
if i == 0 or not lines[i - 1].lstrip().startswith("|"):
continue
nxt = lines[i + 1] if i + 1 < len(lines) else ""
assert nxt.lstrip().startswith("|"), (
f"{os.path.basename(path)}:{i+2} 表格分隔線之後沒有資料列")
# ══════════════════════════════════════════════════════════════════════
# 二、文件提到的東西要真的存在
# ══════════════════════════════════════════════════════════════════════
def test_referenced_files_exist():
"""文件裡用反引號提到的 .py / .md / .txt 檔案都要存在。"""
doc = _read(ARCH) + "\n" + _read(README)
names = set(re.findall(r"`([\w/\\.一-鿿-]+\.(?:py|md|txt|e|json))`",
doc))
# 樣板名(weekNN_*、<name>.py、萬用字元)不是真的檔案,要排除
names = {x for x in names
if "NN" not in x and "<" not in x and "*" not in x}
missing = []
for nm in sorted(names):
nm2 = nm.replace("\\", "/")
if any(os.path.isfile(os.path.join(base, nm2))
for base in (ROOT, REPO)):
continue
# 允許只寫檔名、實際在別的子目錄裡(例如 tools/stk_verify.py)
base = os.path.basename(nm2)
hit = False
for walk_root in (ROOT, REPO):
for d, subs, files in os.walk(walk_root):
subs[:] = [x for x in subs
if x not in (".git", "__pycache__", "node_modules")]
if base in files:
hit = True
break
if hit:
break
if not hit:
missing.append(nm)
assert not missing, f"文件提到但不存在的檔案:{missing}"
def test_web_section_count_matches_doc():
"""文件裡的網頁章節清單要與實際頁面的章節數一致。"""
page = _read(PAGE)
nums = re.findall(r'sec-num">(\d+)</span>', page)
doc = _read(ARCH)
m = re.search(r"^0?1 設計變數與推導鏈.*?```", doc, re.S | re.M)
assert m, "文件裡找不到網頁章節清單"
# 清單是雙欄排版,一行有兩個項目 —— 不能只抓行首。
# 但也不能亂抓:「Mach 20 的流場」裡的 20 不是章節編號。
# 章節編號只出現在行首,或是被兩個以上的空格隔開的第二欄。
listed = re.findall(r"(?:^|\s{2,})(\d{2}) ", m.group(0), re.M)
assert len(listed) == len(nums), (
f"文件列了 {len(listed)} 節,頁面實際有 {len(nums)} 節")
def test_verify_check_count_claim():
"""文件宣稱的驗證項數,要與 verify 原始碼裡的 add() 數量相容。
不能直接比對(有分支),但文件宣稱的數字不該超過原始碼的上限。
"""
src = _read(os.path.join(ROOT, "spaceport_verify.py"))
upper = src.count(".add(")
doc = _read(ARCH)
claims = {int(x) for x in re.findall(r"(\d+)/\1 通過", doc)}
claims |= {int(x) for x in re.findall(r"實測:(\d+)/\d+", doc)}
for c in claims:
assert c <= upper, (
f"文件宣稱 {c} 項,但 verify 原始碼只有 {upper} 處 add()")
def test_claimed_test_counts_match_reality():
"""文件宣稱的「不需 STK 的測試」項數,要與實際的測試函式數量一致。
## 為什麼補這一項
`test_verify_check_count_claim()` 只守 STK 驗證那個數(而且只守上界)。
不需 STK 的那三套測試的項數**沒有任何東西在守** ——
S6 加了一項物理測試之後,架構文件還寫著 24/24,
是最終巡檢用 grep 抓到的,不是測試抓到的。
這正是 `test_docs.py` 存在的理由:文件是會過期的產物,
每一個會隨程式變動的數字都該有東西盯著。
"""
doc = _read(ARCH)
block = doc[doc.index("### 目前的設計點"):]
end = block.find("\n## ")
if end > 0:
block = block[:end]
m = re.search(r"不需 STK 的測試\s*\|\s*(\d+)/\d+[^|]*?"
r"(\d+)/\d+[^|]*?(\d+)/\d+", block)
assert m, "找不到「不需 STK 的測試」那一列"
claimed = [int(m.group(i)) for i in (1, 2, 3)]
def count_tests(path):
return len(re.findall(r"^def (test_\w+)", _read(path), re.M))
actual = [count_tests(os.path.join(ROOT, "tests", nm))
for nm in ("test_physics.py", "test_docs.py",
"test_deploy_doc.py")]
names = ("物理", "文件一致性", "對外文件")
bad = [f"{n}:文件 {c},實際 {a}"
for n, c, a in zip(names, claimed, actual) if c != a]
assert not bad, "測試項數與文件不符 —— " + "、".join(bad)
def test_cross_check_table_matches_code():
"""`程式架構.md` 的 JS/Python 交叉核對表,要與程式現在算出來的一致。
## 為什麼補這一項
這張表**過期過兩次**。第一次停在 L/D 4.0(那時它是手動跑 node 抽
JS 再對的);後來把量測自動化成 `web/cross_check.py`,
但**表格本身還是手打的**,所以設計點由 2.2 改成 1.74 之後它又停在
2.2 —— 兩次都是最終巡檢用 grep 抓到的,不是測試抓到的。
這一項只核對**數值**,不核對排版:把表裡的 v_bo 與弧終點速度抓出來,
與 `mission_geometry()` 對。那兩個量足以判斷整張表是不是舊的 ——
設計點一動它們必動。
需要 node 的那一半(網頁那一欄)不在這裡重跑:
`web/cross_check.py` 是獨立的閘門,它會自己失敗。
"""
doc = _read(ARCH)
i = doc.find("<!-- BEGIN 交叉核對")
assert i >= 0, "找不到交叉核對表的標記"
j = doc.index("<!-- END 交叉核對 -->", i)
block = doc[i:j]
ho = B_.mission_geometry(verbose=False)
m = re.search(r"`v_bo`\s*\|\s*([\d,.]+) m/s", block)
assert m, "交叉核對表裡找不到 v_bo"
assert abs(_num(m.group(1)) - ho["v_bo"]) < 5.0, (
f"交叉核對表寫 v_bo {m.group(1)},程式是 {ho['v_bo']:,.1f}")
m = re.search(r"弧終點速度\s*\|\s*([\d,.]+) m/s", block)
assert m, "交叉核對表裡找不到弧終點速度"
assert abs(_num(m.group(1)) - ho["eph_start_tas_ms"]) < 5.0, (
f"交叉核對表寫弧終點 {m.group(1)},"
f"程式是 {ho['eph_start_tas_ms']:,.1f}")
def test_no_stray_control_characters():
"""文件裡不該有控制字元(換行除外)。
## 為什麼補這一項
在 heredoc 裡寫 Python 時,字串中的 `\t`、`\a`、`\n` 會被吃掉,
變成真正的 TAB / BEL / 換行。實際發生過:
`model\twsp2_spaceplane.glb` -> `model<TAB>wsp2_spaceplane.glb`
`Air\aircraft.glb` -> `Air<BEL>ircraft.glb`
檔名因此是壞的,而且**肉眼幾乎看不出來** —— markdown 把 TAB 算成
空白,BEL 根本不顯示。這一類已經踩過五次,所以讓機器盯著。
順帶也擋掉 TAB 縮排(markdown 表格裡的 TAB 會把排版弄亂)。
"""
bad = []
for path in (ARCH, README):
for i, line in enumerate(_read(path).splitlines(), 1):
for j, ch in enumerate(line):
if ord(ch) < 32:
bad.append(f"{os.path.basename(path)}:{i} "
f"第 {j+1} 字 {ch!r}")
assert not bad, "文件裡有控制字元:" + "、".join(bad[:6])
# ══════════════════════════════════════════════════════════════════════
# 自帶檢查器
# ══════════════════════════════════════════════════════════════════════
def main():
tests = [(n, f) for n, f in sorted(globals().items())
if n.startswith("test_") and callable(f)]
print("=" * 74)
print(f" 文件與程式的一致性測試 —— {len(tests)} 項")
print("=" * 74)
bad = []
for name, fn in tests:
try:
fn()
print(f" [OK ] {name}")
except AssertionError as exc:
bad.append((name, str(exc)))
print(f" [FAIL] {name}\n {exc}")
except Exception as exc: # noqa: BLE001
bad.append((name, f"{type(exc).__name__}: {exc}"))
print(f" [ERR ] {name}\n {type(exc).__name__}: {exc}")
print()
print("=" * 74)
print(f" {len(tests) - len(bad)}/{len(tests)} 項通過")
if bad:
print(f" 未通過:{[n for n, _ in bad]}")
print("=" * 74)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/tests/test_physics.py
# -*- coding: utf-8 -*-
"""不需要 STK 的物理與一致性測試。
## 為什麼需要這一支
在這之前,這一章的所有驗證都要 STK 在跑。後果是:
**改了 `spaceport_analysis.py` 的物理,沒有 STK 就無法確認有沒有改壞。**
這裡測的是不需要 STK 的那一層:封閉解的自洽性、各段相加、
單一真相來源、以及網頁與 Python 的常數一致。全部是純 Python,
幾秒鐘跑完,可以在每次改動後立刻跑。
**它不取代 `spaceport_verify.py`。** 那一支驗的是 STK 場景裡實際
長出來的東西(回讀),這一支驗的是算式本身。兩層都要。
python tests/test_physics.py # 自帶檢查器,不需要 pytest
pytest tests/test_physics.py -q # 也可以用 pytest 跑
"""
import io
import json
import math
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
import spaceport_analysis as A # noqa: E402
import spaceport_build as B # noqa: E402
import spaceport_vehicle as V # noqa: E402
PAGE = os.path.join(ROOT, "web", "suborbital_workbench.html")
TOTAL_M = A.great_circle(A.LAUNCH_LAT, A.LAUNCH_LON,
A.LANDING_LAT, A.LANDING_LON)[1]
# ══════════════════════════════════════════════════════════════════════
# 一、助推滑翔解的自洽性
# ══════════════════════════════════════════════════════════════════════
def test_closure_residual_is_zero():
"""兩段相加必須等於全程。
**這一項是第二次架構更正的核心。** 舊版把真空弧的射程重複計入
滑翔段,差額被推給一個 3,765 km 的「無模型涵蓋」接縫。
自洽閉合之後殘差應為 0 —— 而且要在整個參數範圍內都成立,
不是只在設計點。
"""
for ld in (0.5, 1.0, 2.0, 4.0, 6.13, 8.0, 12.0):
for rng_km in (2000, 5000, 10000, 13109.3, 16000, 20000):
bg = A.boost_glide(rng_km * 1000.0, ld)
assert abs(bg["closure_residual_m"]) < 1.0, (
f"L/D {ld}、射程 {rng_km} km 的閉合殘差 "
f"{bg['closure_residual_m']:.3f} m")
assert abs(bg["arc_m"] + bg["glide_m"] - rng_km * 1000.0) < 1.0
def test_arc_end_is_faster_than_burnout():
"""真空弧是下降支,位能換動能 —— 弧終點速度必須**高於**熄火速度。
先前的版本把交接速度釘在 Aviator 的 5,400 m/s 上限,
比熄火速度還低,那在物理上說不通。
"""
for ld in (2.0, 4.0, 6.13, 8.0):
bg = A.boost_glide(TOTAL_M, ld)
assert bg["v_entry"] > bg["v_bo"], (
f"L/D {ld}:弧終點 {bg['v_entry']:.0f} 不應低於熄火 "
f"{bg['v_bo']:.0f}")
assert bg["v_entry"] - bg["v_bo"] < 200.0, "差值不該大到離譜"
def test_apogee_stays_near_karman():
"""**形狀檢查。** 遠地點必須在卡門線附近,不是拋高彈道。
最小能量彈道的遠地點是 1,130 km —— 每個數字都對,
但沒有有翼載具會那樣飛。這一項就是擋住那種錯誤。
"""
for ld in (1.0, 2.0, 4.0, 8.0):
bg = A.boost_glide(TOTAL_M, ld)
assert bg["apogee_m"] < 200_000.0, (
f"L/D {ld} 的遠地點 {bg['apogee_m']/1000:.0f} km 太高"
f"(舊架構是 1,130 km)")
assert bg["apogee_m"] > A.KARMAN_M * 0.95
def test_higher_ld_needs_less_speed():
"""升阻比愈高,所需熄火速度愈低 —— 單調性。"""
prev = None
for ld in (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0):
v = A.boost_glide(TOTAL_M, ld)["v_bo"]
if prev is not None:
assert v < prev, f"L/D {ld} 的 v_bo {v:.0f} 沒有比前一個低"
prev = v
def test_seam_free_ld_matches_constant():
"""`SEAM_FREE_LD` 要真的讓弧終點速度等於 Aviator 的求解上限。
這個值由 6.54 改成 6.13(判準改看弧終點速度),常數與算式
必須對得上,否則對照機就不是「恰好可解」。
"""
# 要走 mission_geometry,因為只有它會注入上升段的沿程函式;
# 直接呼叫 boost_glide 會退回「把上升段當成一個點」的舊閉合。
ho = B.mission_geometry(ld=B.SEAM_FREE_LD, verbose=False)
bg = {"v_entry": ho["eph_start_tas_ms"]}
err = abs(bg["v_entry"] - V.AVIATOR_TAS_LIMIT_MS)
assert err < 15.0, (
f"SEAM_FREE_LD {B.SEAM_FREE_LD} 給出弧終點 "
f"{bg['v_entry']:.0f} m/s,與上限 {V.AVIATOR_TAS_LIMIT_MS:.0f} "
f"差 {err:.0f}")
# ══════════════════════════════════════════════════════════════════════
# 二、任務幾何與星曆
# ══════════════════════════════════════════════════════════════════════
def test_mission_geometry_keys_finite():
ho = B.mission_geometry(verbose=False)
for k in ("total_m", "ascent_m", "arc_m", "eph_m", "v_bo", "apogee_m",
"eph_start_s_m", "eph_start_alt_m", "eph_start_tas_ms",
"eph_start_mach", "closure_residual_m"):
assert k in ho, f"缺少鍵 {k}"
assert math.isfinite(ho[k]), f"{k} 不是有限值:{ho[k]}"
assert abs(ho["closure_residual_m"]) < 1.0
# 星曆接在**上升段 + 真空弧**之後。先前這裡寫的是「接在真空弧終點」,
# 因為那時上升段的沿程根本沒被計入 —— 整條任務會飛過落點 1,259 km。
assert abs(ho["eph_start_s_m"] - (ho["ascent_m"] + ho["arc_m"])) < 1.0
assert abs(ho["ascent_m"] + ho["arc_m"] + ho["eph_m"]
- ho["total_m"]) < 1.0, "三段相加要等於全程"
assert ho["ascent_m"] > 500_000.0, "上升段沿程不該是零"
def test_old_keys_raise_not_silently_wrong():
"""舊鍵名必須 KeyError,不能靜默回傳別的東西。
`glide_m` / `handover_s_m` / `seam_m` 都已更名。取舊鍵應該立刻炸,
而不是拿到一個看起來像數字的錯值。
"""
ho = B.mission_geometry(verbose=False)
for old in ("handover_s_m", "handover_alt_m", "handover_tas_ms",
"seam_m", "seam_dv_ms"):
assert old not in ho, f"舊鍵 {old} 還在,會讓呼叫端靜默取到錯的東西"
def test_glide_track_is_monotone():
"""星曆軌跡:時間遞增、高度遞減、起止點符合設計。"""
ho = B.mission_geometry(verbose=False)
pts = B.glide_track(ho)
assert len(pts) > 50, f"取樣點只有 {len(pts)} 個"
for i in range(1, len(pts)):
assert pts[i][0] > pts[i - 1][0], f"第 {i} 點時間沒有遞增"
assert pts[i][3] <= pts[i - 1][3] + 1.0, f"第 {i} 點高度上升了"
assert abs(pts[0][3] - ho["eph_start_alt_m"]) < 1.0, "起點高度不符"
assert abs(pts[-1][3] - B.APPROACH_IF_ALT_M) < 1.0, "終點不是進場介面"
assert pts[-1][4] > 0.0, "終點速度應為正"
def test_glide_track_does_not_circle():
"""**形狀檢查。** 星曆的地面軌跡不能繞圈。
Aviator 飛同一條航線是 5.32 倍 —— 那個錯誤讓 32 項驗證全部通過,
因為沒有人檢查軌跡長度。
"""
ho = B.mission_geometry(verbose=False)
pts = B.glide_track(ho)
path = sum(A.great_circle(pts[i - 1][1], pts[i - 1][2],
pts[i][1], pts[i][2])[1]
for i in range(1, len(pts)))
direct = A.great_circle(pts[0][1], pts[0][2], pts[-1][1], pts[-1][2])[1]
ratio = path / direct
assert ratio < 1.02, f"航跡/直線 = {ratio:.2f},疑似繞圈"
# ══════════════════════════════════════════════════════════════════════
# 三、大氣、滑翔、姿態、加熱
# ══════════════════════════════════════════════════════════════════════
def test_us76_density_known_points():
"""US76 密度對標準表。**基準值一律用幾何高度索引**。
這一項抓到過一個真的錯誤:`us76_density()` 少做「幾何高度 → 位勢高度」
的轉換,50 km 差 -4.8%、70 km 差 -10.4%、80 km 差 -14.9% ——
而本任務的滑翔段就在 70 km 附近。STK 那一層驗不到這種事。
這裡有個很容易犯的錯:低空用位勢高度索引的值、高空用幾何高度
索引的值,兩者混在一起,於是修好模型之後低空反而「變差」。
**基準值的定義要先統一,才有資格說誰對誰錯。**
"""
for alt, rho in ((0.0, 1.22500), (10_000.0, 0.413510),
(30_000.0, 0.0184101), (50_000.0, 1.02690e-3),
(70_000.0, 8.28290e-5), (86_000.0, 6.9580e-6)):
got = A.us76_density(alt)
assert abs(got / rho - 1.0) < 0.01, (
f"{alt/1000:.0f} km(幾何):{got:.5e} vs {rho:.5e} "
f"({(got/rho-1)*100:+.2f}%)")
def test_equilibrium_glide_alt_monotone():
"""速度愈高,平衡滑翔高度愈高(離心解除讓有效重量變小)。"""
prev = None
for v in (800, 1500, 2500, 3500, 4500, 5400, 6163, 7000):
alt = A.equilibrium_glide_alt(float(v))
assert math.isfinite(alt)
if prev is not None:
assert alt > prev, f"v {v} 的滑翔高度沒有比前一個高"
prev = alt
def test_attitude_nose_up_while_descending():
"""**這是本章的招牌發現,要測起來。**
高超音速滑翔段:飛行路徑角幾乎為零(下降),攻角十幾度 ——
載具是**機頭朝上在下降**。
"""
v = 6163.0
g = A.flight_path_angle_deg(v, 4.0)
a = A.angle_of_attack_deg(v, 4.0)
th = A.pitch_angle_deg(v, 4.0)
assert -1.0 < g < 0.0, f"飛行路徑角 {g:.3f}° 應該是很小的負值"
assert 10.0 < a < 20.0, f"攻角 {a:.1f}° 不在預期範圍"
assert th > 5.0, f"俯仰角 {th:.1f}° 應為正(機頭朝上)"
assert abs(th - (g + a)) < 1e-9, "θ = γ + α 必須成立"
def test_newtonian_ld_is_cot_alpha():
"""高超音速牛頓流:L/D = cot α。攻角函式必須與它自洽。"""
for ld in (2.0, 4.0, 6.0):
a = A.angle_of_attack_deg(7000.0, ld)
assert abs(1.0 / math.tan(math.radians(a)) - ld) < 0.05, (
f"L/D {ld} 對應攻角 {a:.2f}°,cot α = "
f"{1.0/math.tan(math.radians(a)):.3f}")
def test_heating_and_blackout_finite():
"""加熱與電漿的整個速度範圍都要給出有限值,不能有 NaN 漏出去。"""
for v in (500, 1500, 3000, 4500, 6163):
d = A.belly_temperature(float(v), A.equilibrium_glide_alt(float(v)),
4.0)
assert math.isfinite(d["T_belly_c"]), f"v {v} 的壁溫不是有限值"
assert -100.0 < d["T_belly_c"] < 3000.0, f"v {v} 壁溫 {d['T_belly_c']}"
st = A.blackout_status(float(v), A.equilibrium_glide_alt(float(v)),
4.0)
assert isinstance(st, dict) and st
def test_cp_max_approaches_mach_independence():
"""Cp_max 在高馬赫要趨近 1.839(馬赫數獨立原理)。"""
sys.path.insert(0, os.path.join(ROOT, "cfd"))
import su2_mesh as Msh
_cp, cpm = Msh.newtonian_cp(20.0, 90.0)
assert abs(cpm - 1.839) < 0.005, f"Cp_max = {cpm:.4f}"
_c2, cpm2 = Msh.newtonian_cp(50.0, 90.0)
assert cpm2 > cpm, "馬赫數愈高應該愈接近極限"
def test_oblique_shock_known_values():
"""斜震波對教科書值:M = 2、θ = 10° 時 β ≈ 39.31°。
**這一項曾經整個失效** —— θ-β-M 有弱解與強解兩個根,而函式在兩個
端點都是負的,用端點測號會永遠判定「震波脫體」。
"""
sys.path.insert(0, os.path.join(ROOT, "cfd"))
import su2_mesh as Msh
r = Msh.oblique_shock(2.0, 10.0)
assert r is not None, "M=2、θ=10° 的震波是貼體的,不該回傳 None"
assert abs(r["beta_deg"] - 39.31) < 0.15, f"β = {r['beta_deg']:.2f}°"
assert Msh.oblique_shock(20.0, 55.0) is None, "θ 過大時應判定脫體"
def test_ascent_loss_bookkeeping_identity():
"""上升段「假設損失」與「積分損失」的差 **必須等於自轉助益**。
這是一個記帳恆等式,不是未解釋的物理:
尺寸化時 dv_total = v_bo + loss − 自轉助益
積分沒有地球自轉,所以 loss_computed = dv_ideal − v_bo
= loss − 自轉助益
先前註解把這個差額說成「阻力 + cos α 轉向損失」。**那是錯的** ——
加了阻力模型之後阻力只有約 50 m/s,而差額紋風不動。
這一項就是防止那個誤解再長回來。
"""
import spaceport_ascent as S
r = S.solve_ascent()
assist = A.dv_budget(r["target_v"], loss_ms=r["loss_assumed_ms"],
lat_deg=A.LAUNCH_LAT,
az_deg=r["timeline"]["azimuth_deg"])["rot_assist"]
resid = r["loss_assumed_ms"] - r["loss_computed_ms"]
assert abs(resid - assist) < 15.0, (
f"殘差 {resid:.1f} 與自轉助益 {assist:.1f} 差 "
f"{abs(resid-assist):.1f} m/s —— 記帳恆等式不成立了")
def test_ascent_drag_is_small_and_maxq_sane():
"""阻力損失與 Max-Q 要落在運載火箭的量級。
β₀ = 70,000 kg/m² 對應兩節式中型運載。阻力損失典型 50-150 m/s、
Max-Q 典型 30-40 kPa 在 10-13 km。
"""
import spaceport_ascent as S
tl = S.timeline()
r = S.ascent_integrate(tl, kick_deg=1.2, t_kick_s=16.0,
beta0=S.BETA0_DESIGN)
assert 20.0 < r["drag_loss_ms"] < 200.0, (
f"阻力損失 {r['drag_loss_ms']:.0f} m/s 不在合理範圍")
assert 20e3 < r["max_q_pa"] < 60e3, f"Max-Q {r['max_q_pa']/1000:.1f} kPa"
assert 8_000 < r["max_q_alt_m"] < 16_000, (
f"Max-Q 高度 {r['max_q_alt_m']/1000:.1f} km")
# 不給 beta0 就完全不算阻力 —— 舊行為要保留以便對照
r0 = S.ascent_integrate(tl, kick_deg=1.2, t_kick_s=16.0)
assert r0["drag_loss_ms"] == 0.0
# ══════════════════════════════════════════════════════════════════════
# 四、單一真相來源
# ══════════════════════════════════════════════════════════════════════
def test_constants_are_single_source():
"""同一個量不能有兩份定義。
`TAEM_V_MS` 曾經在 analysis 與 build 各寫一次 —— 值相同,
(build 那一份現在叫 `TAEM_V_FLOOR_MS`,因為它的意思是**模型有效性
下限**,不是交棒速度 —— 交棒速度由 `taem_handover_v()` 解出來)
但那是兩份真相,改一邊就會失聯。
"""
assert (B.TAEM_V_FLOOR_MS is A.TAEM_V_MS
or B.TAEM_V_FLOOR_MS == A.TAEM_V_MS)
assert B.HANDOVER_TAS_MS == V.AVIATOR_TAS_LIMIT_MS
assert B.DESIGN_LD == A.LD_DESIGN
# 地球常數:先前 spaceport_ascent 自己寫了平均半徑 6,371 km,
# 而 analysis 用 WGS84 赤道半徑 6,378.137 km —— 兩份真相。
import spaceport_ascent as S
assert S.RE == A.RE, f"ascent.RE {S.RE} != analysis.RE {A.RE}"
assert S.MU == A.MU
# 各模組不得自行定義 RE / MU 的數值
for fn in ("spaceport_ascent.py", "spaceport_build.py",
"spaceport_return.py"):
src = io.open(os.path.join(ROOT, fn), encoding="utf-8").read()
for m in re.finditer("^(RE|MU)[ ]*=[ ]*[0-9]", src, re.M):
raise AssertionError(f"{fn} 自行定義了 {m.group(1)} 的數值")
def test_no_module_imports_agi_directly():
"""只有 `stk_binding.py` 可以碰 `agi.*`。
STK 12 與 13 的綁定互不相認,散在各處 import 會讓跨版本失效。
"""
for fn in os.listdir(ROOT):
if not fn.endswith(".py") or fn == "stk_binding.py":
continue
src = io.open(os.path.join(ROOT, fn), encoding="utf-8").read()
for m in re.finditer(r"^\s*(?:from|import)\s+agi\b", src, re.M):
line = src[:m.start()].count("\n") + 1
raise AssertionError(f"{fn}:{line} 直接 import agi,"
f"應該走 stk_binding")
def test_web_constants_match_python():
"""網頁的物理常數必須與 Python 一致。
踩過一次:網頁用地球平均半徑 6,371 km、Python 用赤道半徑
6,378.137 km,兩邊算出不同答案。**兩邊都錯比兩邊不一致好查。**
"""
js = io.open(PAGE, encoding="utf-8").read()
def const(name):
m = re.search(r"\bconst\s+(?:[A-Za-z_0-9]+\s*=\s*[^,;]+,\s*)*"
+ name + r"\s*=\s*([0-9.eE+-]+)", js)
if m:
return float(m.group(1))
m = re.search(r"\b" + name + r"\s*=\s*([0-9.eE+-]+)\s*[;,]", js)
return float(m.group(1)) if m else None
pairs = [("RE", A.RE), ("MU", A.MU), ("G0", A.G0),
("KARMAN", A.KARMAN_M), ("GAMMA_BO", A.GAMMA_BO_DEG),
("CL_GLIDE", A.CL_GLIDE),
("TAS_LIMIT", V.AVIATOR_TAS_LIMIT_MS),
("NOSE_R", A.NOSE_RADIUS_M), ("EMIS", A.EMISSIVITY),
("BELLY_F", A.BELLY_FLUX_RATIO),
("SCALE_H", A.SCALE_HEIGHT_M)]
for name, want in pairs:
got = const(name)
assert got is not None, f"網頁裡找不到常數 {name}"
assert abs(got - want) < 1e-6 * max(1.0, abs(want)), (
f"{name}:網頁 {got} vs Python {want}")
def test_web_ascent_table_matches_python():
"""網頁的上升段查表必須與 Python 一致。
**這一項的存在理由是「忘記重產」。**
上升段的沿程是 v_bo 的函數,而 v_bo 由射程閉合決定 ——
改 L/D、卡門線高度、翼負荷、比衝都會讓它變。
網頁走的是查表(瀏覽器裡積分太慢),所以每次改設計常數都要
重跑 `web/embed_ascent_table.py`。
忘記的後果不是報錯,是**網頁照樣畫得出圖,只是數字是舊的** ——
和本章踩過的其他幾次無聲分歧一樣。
"""
import spaceport_ascent as S
js = io.open(PAGE, encoding="utf-8").read()
m = re.search(r"const ASCENT_TBL = (\[.*?\]);", js, re.S)
assert m, "網頁裡找不到 ASCENT_TBL"
tbl = json.loads(m.group(1))
assert len(tbl) >= 20, f"查表只有 {len(tbl)} 點"
# 在取樣點上逐點比對(這些點是直接算出來的,不該有插值誤差)
worst, worst_v = 0.0, 0
for v, d in tbl[::4]:
exact = S.ascent_downrange(float(v))["downrange_m"]
e = abs(d - exact)
if e > worst:
worst, worst_v = e, v
assert worst < 50.0, (
f"網頁查表與 Python 在 v_bo {worst_v:,} m/s 差 {worst:,.0f} m "
f"—— 忘記跑 web/embed_ascent_table.py 了?")
def test_web_section_numbers_are_sequential():
"""章節編號要連續且不重號 —— 曾經有兩節都編成 04。"""
js = io.open(PAGE, encoding="utf-8").read()
nums = re.findall(r'sec-num">(\d+)</span>', js)
assert nums, "抓不到章節編號"
want = [f"{i:02d}" for i in range(1, len(nums) + 1)]
assert nums == want, f"編號不連續:{' '.join(nums)}"
# ══════════════════════════════════════════════════════════════════════
# 自帶檢查器 —— 不需要 pytest 也能跑
# ══════════════════════════════════════════════════════════════════════
def test_ld_for_velocity_takes_the_ascent_into_account():
"""`ld_for_velocity()` 必須能把上升段的沿程計進去,而 `describe_plan()`
報的接縫消失升阻比必須是 `SEAM_FREE_LD`,不是不含上升段的那個答案。
## 這一項抓的是什麼
`ld_for_velocity()` 先前**沒有** `ascent_fn` 參數,所以它解的是
「沒有動力上升段」那條閉合,答案 **6.13**;而 `SEAM_FREE_LD` 是含上升段
算出來的 **5.6235**。同一個程式裡對同一個問題有兩個答案,
而 `describe_plan()` 印出去的是錯的那一個。
網頁的 `ldForVelocity()` 走 `boostGlide()`,一直都含上升段 ——
所以**網頁是對的、Python 是錯的**,而 `web/cross_check.py` 沒有蓋到
這個量。這是「上升段沿程沒被計入」那個錯誤活下來的最後一處。
## 為什麼不直接重解一次
含上升段的反解要 48 次二分,每次 `boost_glide()` 內部還要解上升段的
兩個旋鈕 —— 實測約十分鐘。所以這裡只做三件便宜的事:
參數還在、陷阱還在、`describe_plan()` 報的是常數。
"""
import inspect
sig = inspect.signature(A.ld_for_velocity)
assert "ascent_fn" in sig.parameters, (
"ld_for_velocity() 少了 ascent_fn —— 不傳上升段會解出偏高的升阻比")
ho = B.mission_geometry(verbose=False)
# 陷阱要還在:同一個升阻比下,不含上升段的滑翔要走更遠,
# 所以進入速度**更高**,於是反解會要求更高的升阻比。
v_no = A.boost_glide(ho["total_m"], B.SEAM_FREE_LD)["v_entry"]
assert v_no > V.AVIATOR_TAS_LIMIT_MS + 100.0, (
f"不含上升段時弧終點應明顯高於上限(實測約 5,560 m/s);"
f"實得 {v_no:,.0f} —— 兩條路徑可能已經被混在一起")
# describe_plan() 報的必須是常數
import contextlib
import io as _io
buf = _io.StringIO()
with contextlib.redirect_stdout(buf):
B.describe_plan(verbose=True)
out = buf.getvalue()
m = re.search(r"升阻比提高到 \*\*([\d.]+)\*\*", out)
assert m, "describe_plan() 沒有報出接縫消失升阻比"
got = float(m.group(1))
assert abs(got - B.SEAM_FREE_LD) < 0.02, (
f"describe_plan() 報 {got},SEAM_FREE_LD 是 {B.SEAM_FREE_LD}"
f"(6.13 是不含上升段的錯答案)")
def test_ascent_pitch_programme_is_near_optimal():
"""目前的俯仰程序要**接近**這一族解的最佳,差距在採用門檻之內。
## 這一項在守什麼
開放項目 11.7 原本寫「上升段沒有做軌跡最佳化」,聽起來像是有一大塊
Δv 沒撿。實測掃完之後結論相反:**固定的俯仰程序已經接近最佳**,
導引段起點最多省 16 m/s(1.1%),而且那 16 m/s 要付 7% 的 Max-Q。
把這個結論釘住有意義:如果哪天有人改了積分模型、或改了設計點,
使得忽然有幾百 m/s 可以撿,那就不再是「已知接近最佳」,
應該重新評估 —— 這一項會失敗並講出省多少。
反過來也守住:如果有人把 `gamma_clamp_deg` 的預設改掉,
基準就不是原本那條軌跡了,差距會跑掉。
## 為什麼不直接跑完整掃描
`optimise_ascent()` 掃三組參數要二十幾秒。這裡只掃導引段起點
那一組的幾個點 —— 那是唯一真的有自由度的一維,而且最佳值就在裡面。
"""
import spaceport_ascent as SA
ho = B.mission_geometry(verbose=False)
ta, tv = ho["burnout_alt_m"], ho["v_bo"]
def run(gc=None):
kick, loss, r, _tl = SA._solve_two_knobs(
ta, tv, kick_hi=30.0, gain_alt=1.5e5, gain_v=0.6, iters=140,
gamma_clamp_deg=gc)
return loss, r
base_loss, _r = run()
best_loss, best_gc = base_loss, None
for gc in (2.5, 3.0, 3.25, 3.5, 4.0):
loss, r = run(gc)
if r["converged"] and loss < best_loss:
best_loss, best_gc = loss, gc
saving = base_loss - best_loss
assert saving < SA.ADOPT_THRESHOLD_MS, (
f"導引段起點 {best_gc}° 可省 {saving:,.0f} m/s,"
f"超過採用門檻 {SA.ADOPT_THRESHOLD_MS:.0f} —— "
"『目前程序已接近最佳』這個結論不再成立,要重新評估")
# 也不該是**負的**:基準若比掃到的每一點都差,代表基準跑掉了
assert saving >= -1.0, (
f"基準比掃描到的任何一點都差 {-saving:,.0f} m/s —— "
"預設的俯仰程序可能被改過")
def test_wing_break_even_is_well_below_design_point():
"""「機翼是淨負擔」的門檻要明顯低於設計點,**三種記帳都要**。
## 這一項在守什麼
文件曾經寫「損益平衡點在 L/D 1.61」,並據此說設計點 1.74 只高出 8%、
「無法排除機翼是淨負擔」。**那個 1.61 是 L/D 4.0 時代、閉合條件還沒
改之前的數字**,三種記帳方式算出來都不是它(0.81 / 0.60 / 1.10)。
所以這裡不釘死一個門檻值 —— 記帳方式會改變它。釘的是**結論**:
不管用哪一種,設計點都要有明顯餘裕(這裡取 20%)。
哪天設計點再降、或閉合條件再改而讓餘裕消失,這一項會失敗。
只算 `no_ascent` 那一種(不需要上升段解,快)。
含上升段的兩種在 `wing_break_even_ld()` 裡,要傳 `ascent_fn`。
"""
tot = B.mission_geometry(verbose=False)["total_m"]
r = A.wing_break_even_ld(tot)
be = r["no_ascent"]
assert be < A.LD_DESIGN / 1.20, (
f"損益平衡點 {be:.3f},設計點 {A.LD_DESIGN} 只高出 "
f"{(A.LD_DESIGN/be-1)*100:.0f}% —— 餘裕不足 20%,"
"「機翼是淨負擔」不再是遙遠的門檻,要重新評估")
# 門檻本身也該是合理的正數,不是求解器跑到邊界
assert 0.3 < be < 8.0, f"損益平衡點 {be} 落在求解區間的邊界"
def test_arc_duration_matches_range_over_speed():
"""解析弧時長要與「弧射程 / 平均速度」的粗估同量級。
`arc_duration_s()` 用偏近點角算兩個半徑之間的飛行時間,
而且**分成上升與下降兩段相加** —— 直接用一次 ΔM 會拿到錯的那一支。
這一項就是在守那個分段:若有人「簡化」成一次算完,
答案會差很多,而粗估抓得到。
粗估用弧射程除以熄火速度(弧上速度變化不大,±3%),
所以容差取 15%。
"""
ho = B.mission_geometry(verbose=False)
t = A.arc_duration_s(ho["v_bo"], arc_end_alt_m=ho["eph_start_alt_m"])
rough = ho["arc_m"] / ho["v_bo"]
assert abs(t - rough) / rough < 0.15, (
f"解析弧時長 {t:,.1f} s vs 粗估 {rough:,.1f} s"
f"(差 {(t/rough-1)*100:+.0f}%)")
assert 60.0 < t < 1_200.0, f"弧時長 {t:,.1f} s 不合理"
def main():
tests = [(n, f) for n, f in sorted(globals().items())
if n.startswith("test_") and callable(f)]
print("=" * 74)
print(f" 不需要 STK 的物理與一致性測試 —— {len(tests)} 項")
print("=" * 74)
bad = []
for name, fn in tests:
try:
fn()
print(f" [OK ] {name}")
except AssertionError as exc:
bad.append((name, str(exc)))
print(f" [FAIL] {name}\n {exc}")
except Exception as exc: # noqa: BLE001
bad.append((name, f"{type(exc).__name__}: {exc}"))
print(f" [ERR ] {name}\n {type(exc).__name__}: {exc}")
print()
print("=" * 74)
print(f" {len(tests) - len(bad)}/{len(tests)} 項通過")
if bad:
print(f" 未通過:{[n for n, _ in bad]}")
print("=" * 74)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/cross_check.py
# -*- coding: utf-8 -*-
"""網頁 JS 與 Python 的閉合解交叉核對。**需要 node,不需要 STK。**
## 為什麼要有這一支
`boostGlide()`(JS)與 `boost_glide()`(Python)是**同一套自洽閉合解的
兩份實作**。網頁要能拖滑桿即時重算,所以不能呼叫 Python;Python 要餵
STK,所以不能靠瀏覽器。兩份實作就有兩份真相的風險。
架構文件先前把這件事寫成「手動跑 node 抽出 JS 再對」,並註明
**每次改設計點都要重量一次** —— 那正是會被忘記的那種步驟。
設計點由 3.2 改成 2.3 時就該重量,所以把它做成腳本。
## 怎麼做
把頁面裡所有非 JSON 的 `<script>` 串起來,前面接一層最小 DOM 假件,
用 node 跑,呼叫 `boostGlide(DEFAULTS.range, DEFAULTS.ld)`,
再與 `spaceport_build.mission_geometry()` 對。
假件只要讓**模型碼**跑得完就夠 —— 繪圖與事件繫結都可以是空殼。
`application/json` 的資料區塊要餵真內容,否則 `JSON.parse('')` 會炸。
python web/cross_check.py # 印出對照表
python web/cross_check.py --quiet # 只回傳狀態碼,給測試用
"""
import io
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
PAGE = os.path.join(HERE, "suborbital_workbench.html")
TOL = 0.005 # 0.5% —— 兩邊都是數值解,不要求逐位相同
# ── 最小 DOM 假件 ─────────────────────────────────────────────────────
SHIM = r"""
const _JSON_BY_ID = __PAYLOAD__;
const _el = new Proxy(function(){}, {
get: (t, k) => (k === 'value' || k === 'innerHTML' || k === 'textContent')
? '' : (k === Symbol.toPrimitive ? () => '' : _el),
set: () => true, apply: () => _el, has: () => true });
const _mk = id => (id in _JSON_BY_ID)
? { textContent: _JSON_BY_ID[id], innerHTML: _JSON_BY_ID[id],
style: {}, dataset: {},
classList: { add(){}, remove(){}, toggle(){}, contains: () => false },
addEventListener(){}, getContext: () => _el, appendChild(){} }
: _el;
globalThis.document = {
getElementById: _mk, querySelector: () => _el, querySelectorAll: () => [],
createElement: () => _el, addEventListener(){}, removeEventListener(){},
documentElement: _el, body: _el, head: _el, readyState: 'complete' };
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
globalThis.removeEventListener = () => {};
globalThis.requestAnimationFrame = () => 0;
globalThis.cancelAnimationFrame = () => {};
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '#000' });
globalThis.localStorage = { getItem: () => null, setItem(){}, removeItem(){} };
globalThis.matchMedia = () => ({ matches: false, addEventListener(){},
removeEventListener(){} });
globalThis.ResizeObserver = class { observe(){} disconnect(){} };
globalThis.MutationObserver = class { observe(){} disconnect(){}
takeRecords(){ return []; } };
globalThis.IntersectionObserver = class { observe(){} disconnect(){} };
globalThis.Image = class { set src(_v){} addEventListener(){} };
globalThis.fetch = () => Promise.reject(new Error('no network in shim'));
globalThis.devicePixelRatio = 1;
"""
DRIVER = r"""
const _S = boostGlide(DEFAULTS.range * 1000, DEFAULTS.ld, DEFAULTS.wl);
const _C = compute();
console.log("RESULT " + JSON.stringify({
ld: DEFAULTS.ld, v_bo: _S.v_bo, v_entry: _S.v_entry,
ascent_m: _S.ascent, arc_m: _S.arc, eph_m: _S.glide,
residual_m: _S.residual,
t_sep_s: _C.tSep, t_burnout_s: _C.tBO }));
"""
# 網頁欄位 -> mission_geometry 的鍵
PAIRS = (("v_bo", "v_bo", "m/s"),
("v_entry", "eph_start_tas_ms", "m/s"),
("ascent_m", "ascent_m", "m"),
("arc_m", "arc_m", "m"),
("eph_m", "eph_m", "m"),
("residual_m", "closure_residual_m", "m"),
# 分節時序。比的是 `spaceport_ascent.timeline()`,不是
# `solve_ascent()` —— 兩者用同一個假設損失(1,600 m/s)。
# STK 場景用的是 solve_ascent() 的兩旋鈕解,分離時刻因此
# 會再差約 1.4 s(0.8%),那不是分歧,是兩個不同的問題:
# timeline() 問「假設損失 1,600 時時程多長」,
# solve_ascent() 問「要讓熄火落在設計點,損失得是多少」。
("t_sep_s", "t_separation", "s"),
("t_burnout_s", "t_burnout", "s"))
def run_js(scratch=None):
"""在 node 裡跑網頁的模型碼,回傳它算出來的閉合解。"""
html = io.open(PAGE, encoding="utf-8").read()
payload = {m.group(1): m.group(2) for m in re.finditer(
r'<script type="application/json" id="([^"]+)">(.*?)</script>',
html, re.S)}
blocks = re.findall(
r"<script(?![^>]*application/json)[^>]*>(.*?)</script>", html, re.S)
src = (SHIM.replace("__PAYLOAD__", json.dumps(payload))
+ "\n".join(blocks) + DRIVER)
scratch = scratch or os.environ.get("TEMP") or HERE
path = os.path.join(scratch, "_cross_check_driver.js")
io.open(path, "w", encoding="utf-8").write(src)
try:
r = subprocess.run(["node", path], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=180)
except FileNotFoundError:
raise RuntimeError("找不到 node —— 交叉核對需要 Node.js")
hits = [ln for ln in (r.stdout or "").splitlines()
if ln.startswith("RESULT ")]
if not hits:
raise RuntimeError("網頁的模型碼沒跑完:\n"
+ (r.stderr or "(無 stderr)")[:800])
return json.loads(hits[0][7:])
def compare():
"""回傳 (js, py, rows, worst)。"""
import spaceport_ascent as SA
import spaceport_build as B
js = run_js()
ho = dict(B.mission_geometry(ld=js["ld"], verbose=False))
# 分節時序不在 mission_geometry 裡,從 timeline() 補進來
ho.update(SA.timeline(v_bo=ho["v_bo"]))
rows, worst = [], 0.0
for key, pkey, unit in PAIRS:
a, b = float(js[key]), float(ho[pkey])
# 殘差兩邊都該是 0,比相對差沒有意義 —— 改看絕對值
rel = (abs(a - b) / max(1.0, abs(b)) if key != "residual_m"
else abs(a - b) / 1_000.0)
worst = max(worst, rel)
rows.append((key, a, b, rel, unit))
return js, ho, rows, worst
def main():
quiet = "--quiet" in sys.argv
js, _ho, rows, worst = compare()
if not quiet:
print("=" * 74)
print(f" 網頁 JS vs Python —— 同一套閉合解(設計點 L/D {js['ld']:g})")
print("=" * 74)
print(f" {'量':<10}{'網頁 JS':>15}{'Python':>15}{'差':>13}{'相對':>10}")
for key, a, b, rel, unit in rows:
print(f" {key:<10}{a:>15,.1f}{b:>15,.1f}{a-b:>+13,.1f}"
f"{rel*100:>9.3f}% {unit}")
print("-" * 74)
print(f" 最大相對差 {worst*100:.3f}%(門檻 {TOL*100:.1f}%)")
print(" 兩邊都是數值解(JS 二分法、Python brentq),"
"不要求逐位相同;")
print(" 要抓的是**一邊改了另一邊沒改**,那種差會是百分之幾以上。")
print("=" * 74)
ok = worst < TOL
print("XCHECK-OK" if ok else f"XCHECK-FAIL 最大相對差 {worst*100:.3f}%")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/embed_ascent_table.py
# -*- coding: utf-8 -*-
"""把動力上升段的沿程查表同步進工作單。
## 為什麼是查表而不是在瀏覽器裡算
上升段要解「踢角 + 假設損失」兩個旋鈕才能把熄火打在設計點上,
單次約 0.12 秒。拖動滑桿時 `boostGlide()` 的二分法要呼叫它幾十次 ——
在瀏覽器裡做不到。
所以由 `spaceport_ascent.ascent_downrange()` 預先算好取樣點,
網頁線性插值。**插值誤差每次都會重新量並寫進網頁的註解**,
不是寫死一個數字。
## 為什麼要有這支腳本
這張表若靠手動重產就是一個缺口:
設計常數一改(L/D、卡門線高度、翼負荷、比衝…)沿程就會變,
忘記重產的話網頁與 Python 會**無聲分歧** ——
網頁照樣畫得出圖,只是數字是舊的。
`tests/test_physics.py` 有一項 `test_web_ascent_table_matches_python()`
會在兩邊不一致時失敗,所以忘記重產會被閘門擋下來。
**冪等**:內容沒變就不寫檔。
python web/embed_ascent_table.py
python tests/test_physics.py # 確認一致
python web/smoke_test.py # SMOKE-OK 才 push
"""
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
import spaceport_ascent as S # noqa: E402
PAGE = os.path.join(HERE, "suborbital_workbench.html")
# 取樣範圍要蓋住網頁滑桿走得到的 v_bo。
# 射程 2,000-16,000 km、L/D 0.5-8 的組合下,v_bo 落在約 2,300-7,600。
V_LO, V_HI, V_STEP = 2000, 7800, 200
def build_table():
return [[v, round(S.ascent_downrange(float(v))["downrange_m"], 1)]
for v in range(V_LO, V_HI + 1, V_STEP)]
def interp(tbl, v):
"""與網頁 ascentDownrange() 同一套線性插值。"""
if v <= tbl[0][0]:
return tbl[0][1] * v / tbl[0][0]
if v >= tbl[-1][0]:
return tbl[-1][1]
for i in range(1, len(tbl)):
if v <= tbl[i][0]:
x0, y0 = tbl[i - 1]
x1, y1 = tbl[i]
return y0 + (y1 - y0) * (v - x0) / (x1 - x0)
return tbl[-1][1]
def measure_error(tbl, n=40):
"""在取樣點**之間**抽驗,量出插值的最大誤差。"""
worst, worst_v = 0.0, 0
lo, hi = tbl[0][0], tbl[-1][0]
for k in range(1, n):
v = lo + (hi - lo) * k / n
e = abs(interp(tbl, v) - S.ascent_downrange(v)["downrange_m"])
if e > worst:
worst, worst_v = e, v
return worst, worst_v
def main():
tbl = build_table()
worst, worst_v = measure_error(tbl)
assert len(tbl) >= 20, f"取樣點只有 {len(tbl)} 個"
assert worst < 5_000.0, (
f"插值誤差 {worst:,.0f} m 太大 —— 取樣間隔要縮小")
s = io.open(PAGE, encoding="utf-8").read()
if "const ASCENT_TBL" not in s:
print("找不到 const ASCENT_TBL —— 網頁還沒有這個查表")
return 1
js = "[" + ",".join(f"[{v},{d:.0f}]" for v, d in tbl) + "]"
new = re.sub(r"const ASCENT_TBL = \[.*?\];",
"const ASCENT_TBL = " + js + ";", s, count=1, flags=re.S)
new = re.sub(r"\*\*插值誤差已量測:最大 [\d,]+ m\*\*",
f"**插值誤差已量測:最大 {worst:,.0f} m**", new, count=1)
changed = new != s
if changed:
io.open(PAGE, "w", encoding="utf-8").write(new)
print(f" 取樣 {len(tbl)} 點({V_LO:,}-{V_HI:,} m/s,間隔 {V_STEP})")
print(f" 沿程 {tbl[0][1]/1000:,.0f} → {tbl[-1][1]/1000:,.0f} km")
print(f" 線性插值最大誤差 {worst:,.0f} m @ v_bo {worst_v:,.0f} m/s"
f"({worst/tbl[-1][1]*100:.2f}% of 沿程)")
if changed:
print(f" 已更新 {os.path.basename(PAGE)}")
print(" 記得同步到部署庫,並跑 tests/test_physics.py 與 smoke_test.py")
else:
print(" 網頁已是最新,未改動")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/embed_cfd.py
# -*- coding: utf-8 -*-
"""把 CFD 結果同步進工作單的第 08 節。
單一來源是 `cfd/cfd_results.json`(由 cfd/su2_run.py 產生)。
網頁只負責呈現。**冪等**:只替換 `<script id="cfdData">` 的內容。
python cfd/su2_run.py # 跑 SU2,產生 cfd_results.json
python web/embed_cfd.py # 寫進網頁
python web/smoke_test.py # SMOKE-OK 才 push
流場與網格檔很大,而且可以重新產生,所以在 cfd/.gitignore 裡排除;
**只有結論(cfd_results.json)進版控**。
"""
import io
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, "cfd", "cfd_results.json")
PAGE = os.path.join(HERE, "suborbital_workbench.html")
TAG = "cfdData"
def main():
if not os.path.isfile(SRC):
print(f"找不到 {SRC} —— 先跑 python cfd/su2_run.py")
return 1
doc = json.load(io.open(SRC, encoding="utf-8"))
# 抽取壞掉或跑一半時要**立刻停**,不要把半截資料寫進網頁
rows = doc.get("delta", {}).get("rows", [])
wedge = doc.get("wedge", [])
assert len(wedge) >= 3, f"楔形驗證只有 {len(wedge)} 筆"
assert len(rows) >= 3, f"三角翼只有 {len(rows)} 個攻角"
bad = [r for r in rows if r.get("cl_cfd") is None]
if bad:
print(f" 警告:{len(bad)} 個攻角沒有 CFD 結果,仍寫入(網頁會顯示 —)")
payload = json.dumps(doc, ensure_ascii=False)
s = io.open(PAGE, encoding="utf-8").read()
pat = re.compile(
r'(<script type="application/json" id="%s">)(.*?)(</script>)' % TAG,
re.S)
if not pat.search(s):
print(f'找不到 <script id="{TAG}"> —— 第 08 節還沒建立。')
return 1
new = pat.sub(lambda m: m.group(1) + payload + m.group(3), s, count=1)
changed = new != s
if changed:
io.open(PAGE, "w", encoding="utf-8").write(new)
w_err = sum(abs(r["cd_cfd"] / r["cd_exact"] - 1) for r in wedge) / len(wedge)
n_err = sum(r["cp_newton"] / r["cp_exact"] - 1 for r in wedge) / len(wedge)
print(f" 楔形 {len(wedge)} 筆,SU2 平均誤差 {w_err*100:.1f} %,"
f"牛頓流平均偏差 {n_err*100:.1f} %")
print(f" 三角翼 {len(rows)} 個攻角,網格 "
f"{doc['delta']['mesh']['nodes']:,} 節點")
if changed:
print(f" 已更新 {os.path.basename(PAGE)}")
print(" 記得同步到部署庫,並跑 python web/smoke_test.py")
else:
print(" 網頁已是最新,未改動")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/embed_connect.py
# -*- coding: utf-8 -*-
"""把 Connect 腳本與實測結果同步進工作單的第 11 節。
單一來源是 `spaceport_suborbital_Connect.txt`(由 make_connect.py 產生,
數字來自 mission_geometry())。網頁只負責呈現,不重新編寫內容 ——
兩邊分歧的成本比重複的成本高。
**這支是冪等的**:它只替換網頁裡 `<script id="connectData">` 的內容,
不會重複插入章節。設計常數改過之後的同步順序:
python make_connect.py # 重產 Connect 腳本
python web/embed_connect.py # 寫進網頁
python web/smoke_test.py # 確認 SMOKE-OK 才 push
"""
import io
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, "spaceport_suborbital_Connect.txt")
PAGE = os.path.join(HERE, "suborbital_workbench.html")
TAG = 'connectData'
def extract():
raw = io.open(SRC, encoding="utf-8").read().splitlines()
# 腳本本體:從「---- 1.」到附錄 A 之前。
# 結束標記要用**行首**比對 —— 本文裡有「見附錄 A 第 1 條」這種
# 交叉引用,用 `in` 會提早切斷,只抽到 27 行。
i0 = next(i for i, x in enumerate(raw) if x.startswith("# ---- 1."))
i1 = next(i for i, x in enumerate(raw) if x.startswith("# 附錄 A"))
body = raw[i0:i1]
while body and not body[-1].strip():
body.pop()
body = [re.sub(r"-{6,}$", "-" * 6, x) for x in body]
# 附錄 B 的逐條實測
probe, cur = [], None
for x in raw[i1:]:
m = re.match(r"^#\s+\[(ACK |NACK)\]\s+(.*)$", x)
if m:
cur = {"ok": m.group(1).strip() == "ACK",
"cmd": m.group(2).strip(), "note": ""}
probe.append(cur)
continue
if cur is not None:
m2 = re.match(r"^#\s{10,}(\S.*)$", x)
if m2:
cur["note"] = m2.group(1).strip()
continue
cur = None
runnable = [x for x in body if x.strip() and not x.startswith("#")]
# 抽取壞掉時要**立刻停**,不要把半截內容寫進網頁
assert len(body) > 40, f"腳本本體只有 {len(body)} 行"
assert len(runnable) >= 15, f"可執行指令只有 {len(runnable)} 條"
assert len(probe) >= 15, f"實測清單只有 {len(probe)} 條"
return body, probe, runnable
def main():
body, probe, runnable = extract()
ack = sum(1 for p in probe if p["ok"])
payload = json.dumps({"body": body, "probe": probe, "ack": ack},
ensure_ascii=False)
s = io.open(PAGE, encoding="utf-8").read()
pat = re.compile(r'(<script type="application/json" id="%s">)(.*?)(</script>)'
% TAG, re.S)
if not pat.search(s):
print(f"找不到 <script id=\"{TAG}\"> —— 第 11 節還沒建立。")
return 1
# 用函式當替換值,re 才不會去解釋 payload 裡的反斜線跳脫
new = pat.sub(lambda m: m.group(1) + payload + m.group(3), s, count=1)
changed = new != s
if changed:
io.open(PAGE, "w", encoding="utf-8").write(new)
print(f" 腳本本體 {len(body)} 行,可執行 {len(runnable)} 條")
print(f" 實測清單 {len(probe)} 條,{ack} 條 ACK")
if changed:
print(f" 已更新 {os.path.basename(PAGE)}")
print(" 記得同步到部署庫,並跑 python web/smoke_test.py")
else:
print(" 網頁已是最新,未改動")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/embed_principles.py
# -*- coding: utf-8 -*-
"""重產部署庫那份對外文件裡的「預設設計點的數值」。**不需要 STK。**
## 為什麼要有這一支
`web/設計與計算原理.md` 的第三部分是一整張現值表(約 25 列)。
設計點改過四次(2.0 → 4.0 → 3.2 → 2.3 → 2.2),那張表整整停在 4.0 ——
因為它是手打的,而且它不在主庫裡,`tests/test_docs.py` 看不到它。
所以改成**產生**它:表格夾在兩個標記之間,每次由程式重寫。
`tests/test_deploy_doc.py` 會在它與程式不一致時失敗。
這和 `web/embed_ascent_table.py`、`web/embed_scenario2.py` 是同一個模式。
**冪等**:內容沒變就不寫檔。
python web/embed_principles.py
python tests/test_deploy_doc.py
"""
import io
import math
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
import spaceport_analysis as A # noqa: E402
import spaceport_build as B # noqa: E402
DEPLOY = os.environ.get(
"SUBORBITAL_DEPLOY_DIR", r"F:\GitHub\suborbital-workbench")
DOC = os.path.join(DEPLOY, "web", "設計與計算原理.md")
BEGIN = "<!-- BEGIN 設計點數值(由 web/embed_principles.py 產生) -->"
END = "<!-- END 設計點數值 -->"
BEGIN_T = "<!-- BEGIN 四段時程(由 web/embed_principles.py 產生) -->"
END_T = "<!-- END 四段時程 -->"
def numbers():
"""所有要寫進表格的量,全部由程式算出。"""
ho = B.mission_geometry(verbose=False)
tot = ho["total_m"]
theta_deg = math.degrees(
A.great_circle(A.LAUNCH_LAT, A.LAUNCH_LON,
A.KEFD_RUNWAY["lat"], A.KEFD_RUNWAY["lon"])[0])
bal = A.ballistic_v_for_range(tot)
dv = A.dv_budget(ho["v_bo"], az_deg=ho["azimuth_deg"])
sp = A.two_stage_split(dv["dv_total"])
belly = A.belly_temperature(ho["eph_start_tas_ms"],
ho["eph_start_alt_m"])
tg = B.taem_geometry(ho["ld"])
# 四段時程。真空弧用解析值(Missile 物件含贗品爬升);
# 其餘從已建好的 STK 物件讀 —— **讀不到就回 None**,
# 那一塊就不重產,不要寫半套進文件。
arc_s = A.arc_duration_s(ho["v_bo"],
arc_end_alt_m=ho["eph_start_alt_m"])
spans = _stk_spans()
return {
"ho": ho, "tot": tot, "theta_deg": theta_deg, "bal": bal,
"dv": dv, "sp": sp, "belly": belly, "tg": tg,
"arc_s": arc_s, "spans": spans,
"eq_alt_bo": A.equilibrium_glide_alt(ho["v_bo"]),
"v_circ_200": A.v_circular(200_000.0),
}
def _stk_spans():
"""從 STK 讀四段的可用區間長度(s)。讀不到回 None。"""
try:
import stk_binding as SB
# **中間物件要留著。** 串成 `AttachToApplication().Root` 會讓
# 應用程式物件被回收,下一次呼叫就會炸在
# `'NoneType' object has no attribute '_getVtblEntry'`。
# 這是本章記過的 COM 陷阱,寫這支時又踩了一次。
stk = SB.STKDesktop.AttachToApplication()
root = stk.Root
sc = root.CurrentScenario
if sc is None:
return None
import spaceport_ascent as SA
names = {"ascent": SA.ASCENT_OBJ, "glide": B.GLIDE_OBJ_NAME,
"terminal": f"{B.SPACEPLANE_NAME}_LD"
f"{A.LD_DESIGN:g}".replace(".", "p")}
out = {}
with B.scoped_unit(root, "DateFormat", "EpSec"):
for key, nm in names.items():
ch = next((sc.Children.Item(i)
for i in range(sc.Children.Count)
if sc.Children.Item(i).InstanceName == nm), None)
if ch is None:
return None
t0 = float(ch.Vgt.Events.Item("AvailabilityStartTime")
.FindOccurrence().Epoch)
t1 = float(ch.Vgt.Events.Item("AvailabilityStopTime")
.FindOccurrence().Epoch)
out[key] = t1 - t0
if key == "ascent":
out["_t_start"] = t0
if key == "terminal":
out["_t_end"] = t1
out["total"] = out["_t_end"] - out["_t_start"]
return out
except Exception: # noqa: BLE001
return None
def block(n):
ho, bal, dv, sp = n["ho"], n["bal"], n["dv"], n["sp"]
tot, tg = n["tot"], n["tg"]
pct = lambda x: f"{x / tot * 100:.1f}%" # noqa: E731
L = []
add = L.append
add(BEGIN)
add("")
add(f"射程 {tot/1000:,.1f} km、**L/D {A.LD_DESIGN:g}**、"
f"翼負荷 {A.WING_LOADING_KGM2:g} kg/m²、Isp 285/340 s、"
f"Δv 分配 {sp['split']:.2f}、T/W 1.30、損失 "
f"{dv['loss']:,.0f} m/s。")
add("")
add("| 量 | 值 |")
add("|---|---|")
add(f"| 中心角 θ | {n['theta_deg']:.2f}° |")
add(f"| 純彈道基準 v_bo(不用機翼) | {bal['v']:,.0f} m/s |")
add(f"| 純彈道基準遠地點 | {bal['apogee_m']/1000:,.0f} km |")
add(f"| 熄火高度(卡門線) | {A.KARMAN_M/1000:.0f} km |")
add(f"| 熄火角 γ | {A.GAMMA_BO_DEG:.1f}° |")
add(f"| **所需 v_bo** | **{ho['v_bo']:,.0f} m/s** |")
add(f"| **遠地點** | **{ho['apogee_m']/1000:,.0f} km** |")
add(f"| 相對純彈道 | 省 {bal['v'] - ho['v_bo']:,.0f} m/s |")
add(f"| 相當於 200 km 圓軌速度 | "
f"{ho['v_bo']/n['v_circ_200']*100:.1f} %"
f"(速度尺度比較,非入軌性能比值) |")
add(f"| 平衡滑翔高度(熄火速度時) | "
f"{n['eq_alt_bo']/1000:.1f} km |")
add(f"| **動力上升段**(外部星曆) | "
f"**{ho['ascent_m']/1000:,.0f} km({pct(ho['ascent_m'])})** |")
add(f"| **助推真空弧**(Missile) | "
f"**{ho['arc_m']/1000:,.0f} km({pct(ho['arc_m'])})** |")
add(f"| **高超音速滑翔 + TAEM**(外部星曆) | "
f"**{ho['eph_m']/1000:,.0f} km({pct(ho['eph_m'])})** |")
add(f"| 三段閉合殘差 | {ho['closure_residual_m']:.3f} m |")
add(f"| 交接高度 / 速度 | {ho['eph_start_alt_m']/1000:.1f} km / "
f"{ho['eph_start_tas_ms']:,.0f} m/s |")
add(f"| 自轉助益 | {dv['rot_assist']:,.0f} m/s |")
add(f"| 總 Δv | {dv['dv_total']:,.0f} m/s |")
add(f"| 質量比 R₁ / R₂ | {sp['mr1']:.2f} / {sp['mr2']:.2f} |")
add(f"| **總質量比** | **{sp['mr_total']:.1f} : 1** |")
add(f"| 機腹進入壁溫 | {n['belly']['T_belly_c']:,.0f} °C |")
add(f"| TAEM 下滑道(**結果,非輸入**) | "
f"{tg['slope_deg']:.1f}°(設計 {tg['design_slope_deg']:.0f}°)|")
add(f"| Aviator 獨力接手還差 | "
f"{ho['aviator_seam_ms']:,.0f} m/s(工具邊界,不是航程缺口) |")
add(f"| **接縫消失所需 L/D** | **{B.SEAM_FREE_LD:.2f}** |")
add("")
add("> **這張表由 `web/embed_principles.py` 產生**,不要手改 ——")
add("> 手改會在下一次重產時被蓋掉,而且 `tests/test_deploy_doc.py`")
add("> 會在它與程式不一致時失敗。")
add("")
add(END)
return "\n".join(L)
def timing_block(n):
r"""8.5 的「四段長度與時程」。
這張表**過期過兩次**:一次停在 L/D 4.0,一次停在 2.2 ——
因為它是手打的。交叉核對表也犯過一模一樣的錯
(自動化了量測卻沒自動化表格),所以這裡直接產生它。
時程取自**已建好的 STK 物件**的可用區間,只有真空弧例外:
那一段用 `A.arc_duration_s()` 的解析值,因為 Missile 物件的
飛行時間含一段「從地面爬到熄火高度」的贗品(見 11.13 / 架構文件)。
"""
import spaceport_stages as ST
ho = n["ho"]
tot = n["tot"]
spans = n["spans"]
arc_s = n["arc_s"]
L = [BEGIN_T, ""]
add = L.append
add(f"目前設計點(L/D {A.LD_DESIGN:g})的四段,"
"長度與時程都是從建好的 STK 物件量的:")
add("")
add("```")
add(f"外部星曆(StkExternal) 動力上升 "
f"{ho['ascent_m']/1000:7,.0f} km {spans['ascent']/60:5.1f} min")
add(f"Missile(彈道傳播器) 助推真空弧 "
f"{ho['arc_m']/1000:7,.0f} km {arc_s/60:5.1f} min <- 解析值")
add(f"外部星曆(StkExternal) 高超音速滑翔 "
f"{ho['eph_m']/1000:7,.0f} km {spans['glide']/60:5.1f} min")
add(f"Aviator 終端進場與降落"
f" {B.APPROACH_CORRIDOR_M/1000:5.0f} km "
f"{spans['terminal']/60:5.1f} min")
add(" ─────────────────────")
add(f" 總任務 "
f"{spans['total']/60:5.1f} min")
add("```")
add("")
add(f"三段長度相加等於全程 {tot/1000:,.0f} km,**閉合殘差 "
f"{ho['closure_residual_m']:.3f} m**。")
add("")
add("> **這張表由 `web/embed_principles.py` 產生**,不要手改。")
add("> 真空弧那一列用的是**解析**飛行時間:Missile 物件的可用區間")
add("> 含一段「從地面爬到熄火高度」的贗品(彈道傳播器一定要從地面")
add("> 發射),實測那段佔它的 39%。")
add("")
add(END_T)
return "\n".join(L)
def main():
if not os.path.isfile(DOC):
print(f" 找不到 {DOC}")
print(" 若部署庫在別處,設 SUBORBITAL_DEPLOY_DIR")
return 1
n = numbers()
new_block = block(n)
s = io.open(DOC, encoding="utf-8").read()
orig = s # **比對要用原文**,不是被這支自己改過的那一份
if BEGIN_T in s and END_T in s and n.get("spans"):
tb = timing_block(n)
s = re.sub(re.escape(BEGIN_T) + r".*?" + re.escape(END_T),
tb.replace("\\", "\\\\"), s, count=1, flags=re.S)
if BEGIN in s and END in s:
out = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END),
new_block.replace("\\", "\\\\"), s, count=1, flags=re.S)
else:
print(f" 文件裡還沒有標記 —— 請先把第三部分的表格換成:")
print(f" {BEGIN}")
print(f" {END}")
print(" 然後再跑一次。")
return 1
changed = out != orig
if changed:
io.open(DOC, "w", encoding="utf-8").write(out)
ho = n["ho"]
print(f" 設計點 L/D {A.LD_DESIGN:g}、v_bo {ho['v_bo']:,.0f} m/s、"
f"遠地點 {ho['apogee_m']/1000:,.0f} km")
print(f" 三段 {ho['ascent_m']/1000:,.0f} / {ho['arc_m']/1000:,.0f}"
f" / {ho['eph_m']/1000:,.0f} km,殘差 "
f"{ho['closure_residual_m']:.3f} m")
print(f" 總質量比 {n['sp']['mr_total']:.1f} : 1、"
f"接縫消失 L/D {B.SEAM_FREE_LD:.2f}")
print(" 已更新" if changed else " 已是最新,未改動")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/embed_scenario2.py
# -*- coding: utf-8 -*-
"""把場景二(繞一圈回到旭海)的資料同步進工作單。
單一來源是 `spaceport_return` 與 `spaceport_return_build` ——
網頁只負責呈現。**冪等**:只替換 `<script id="scen2Data">` 的內容。
python web/embed_scenario2.py
python web/smoke_test.py # SMOKE-OK 才 push
軌跡點會抽稀到每 6 個取一個(約 140 點/條),因為網頁只要畫得出
地面軌跡的形狀,不需要完整解析度 —— 完整的在 .e 星曆檔裡。
"""
import io
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
import spaceport_analysis as A # noqa: E402
import spaceport_return as R # noqa: E402
import spaceport_return_build as RB # noqa: E402
PAGE = os.path.join(HERE, "suborbital_workbench.html")
TAG = "scen2Data"
THIN = 6
def build_payload():
cmp_ = R.compare(verbose=False)
out = {"launch": {"lat": A.LAUNCH_LAT, "lon": A.LAUNCH_LON,
"name": "旭海"},
"v_circular_200km": cmp_["v_circular_200km"],
"dirs": {}}
for key in ("east", "west"):
sol = cmp_[key]
pts = RB.return_track(sol)
thin = pts[::THIN]
if thin[-1] is not pts[-1]:
thin.append(pts[-1])
path = sum(A.great_circle(pts[i - 1][1], pts[i - 1][2],
pts[i][1], pts[i][2])[1]
for i in range(1, len(pts)))
out["dirs"][key] = {
"azimuth_deg": sol["azimuth_deg"],
"ascent_m": sol["ascent_m"], "arc_m": sol["arc_m"],
"glide_m": sol["glide_m"],
"closure_residual_m": sol["closure_residual_m"],
"v_bo": sol["v_bo"], "v_entry": sol["v_entry"],
"pct_of_circular": sol["pct_of_circular"],
"rot_assist_ms": sol["rot_assist_ms"],
"dv_total_ms": sol["dv_total_ms"],
"mass_ratio": sol["mass_ratio"],
"t_flight_s": sol["t_flight_s"],
"earth_drift_deg": sol["earth_drift_deg"],
"ground_path_m": path,
"landing_offset_m": A.great_circle(
pts[-1][1], pts[-1][2], A.LAUNCH_LAT, A.LAUNCH_LON)[1],
"track": [[round(p[1], 3), round(p[2], 3),
round(p[3] / 1000.0, 1)] for p in thin],
}
return out
def main():
doc = build_payload()
for k, d in doc["dirs"].items():
assert len(d["track"]) > 50, f"{k} 只有 {len(d['track'])} 點"
assert abs(d["closure_residual_m"]) < 1.0, f"{k} 閉合殘差過大"
payload = json.dumps(doc, ensure_ascii=False)
s = io.open(PAGE, encoding="utf-8").read()
pat = re.compile(
r'(<script type="application/json" id="%s">)(.*?)(</script>)' % TAG,
re.S)
if not pat.search(s):
print(f'找不到 <script id="{TAG}"> —— 場景二那一節還沒建立。')
return 1
new = pat.sub(lambda m: m.group(1) + payload + m.group(3), s, count=1)
changed = new != s
if changed:
io.open(PAGE, "w", encoding="utf-8").write(new)
e, w = doc["dirs"]["east"], doc["dirs"]["west"]
print(f" 向東 {len(e['track'])} 點,地面航跡 "
f"{e['ground_path_m']/1000:,.0f} km")
print(f" 向西 {len(w['track'])} 點,地面航跡 "
f"{w['ground_path_m']/1000:,.0f} km")
print(f" 落點離旭海 {e['landing_offset_m']/1000:,.0f} km,"
f"Δv 差 {w['dv_total_ms']-e['dv_total_ms']:,.0f} m/s")
print(" 已更新" if changed else " 網頁已是最新,未改動")
return 0
if __name__ == "__main__":
sys.exit(main())
spaceport_suborbital/web/smoke_test.py
# -*- coding: utf-8 -*-
"""工作單的瀏覽器冒煙測試。
## 為什麼需要這一支
`node --check` 只驗**語法**。它抓不到:
* 暫時死區(const 在使用之後才宣告)—— 載入時就丟 ReferenceError
* 取不到的 DOM 節點、拼錯的 id
* canvas 畫成空白
* 執行期才出現的 NaN / Infinity
這個專案真的踩過:`const WR` 宣告在使用之後,整個 <script> 在載入時中斷,
**11 張 canvas 全部空白**,而 `node --check` 通過、檔案大小正常、
章節結構完整 —— 從靜態檢查完全看不出來,只有真的用瀏覽器
打開才看得見。
所以規則是:**改完工作單一定要跑這支,通過才 push。**
python web/smoke_test.py # 測本機檔案
python web/smoke_test.py --url <網址> # 測已部署的 app
需要 playwright:
pip install playwright && python -m playwright install chromium
"""
import argparse
import io
import json
import os
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
PAGE = os.path.join(HERE, "suborbital_workbench.html")
# 章節編號必須從 01 連續到最後一節,而且沒有重號 ——
# 先前有過兩節都編成 04 的情況。
MIN_SECTIONS = 15
SKELETON = """<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head><body>
{body}
</body></html>"""
def analyse(page_url, browser, want_local):
console, errors = [], []
ctx = browser.new_context(viewport={"width": 1500, "height": 1100},
locale="zh-TW")
pg = ctx.new_page()
pg.on("console", lambda m: console.append((m.type, m.text[:300])))
pg.on("pageerror", lambda e: errors.append(str(e)[:300]))
pg.goto(page_url, wait_until="domcontentloaded", timeout=90_000)
pg.wait_for_timeout(3000 if want_local else 9000)
frame = None
for f in pg.frames:
try:
if f.evaluate("() => !!document.getElementById('mission')"):
frame = f
break
except Exception:
continue
if frame is None:
return None, console, errors, pg
data = frame.evaluate(r"""() => {
const secs = [...document.querySelectorAll('.sec-head')].map(h => ({
num: h.querySelector('.sec-num')?.textContent?.trim(),
title: h.querySelector('h2')?.textContent?.trim()}));
const dl = id => {
const el = document.getElementById(id);
return el ? [...el.querySelectorAll('div')].map(d => [
d.querySelector('dt')?.textContent?.trim(),
d.querySelector('dd')?.textContent?.trim()]) : null;
};
const canvases = [...document.querySelectorAll('canvas')].map(c => {
let painted = -1;
try {
const g = c.getContext('2d');
if (g) {
const d = g.getImageData(0, 0, c.width, c.height).data;
let nz = 0;
for (let i = 3; i < d.length; i += 4*97) if (d[i] > 0) nz++;
painted = nz;
}
} catch (e) { painted = -2; }
return {id: c.id, painted};
});
// 有沒有「無解」或 NaN 漏到畫面上
const body = document.body.innerText;
const NL = String.fromCharCode(10);
const cx = document.getElementById('cxCode');
const cxCmds = cx
? cx.textContent.split(NL).filter(l => l.trim() && l[0] !== '#').length
: 0;
return {secs, canvases,
mission: dl('mission'), envRead: dl('envRead'),
wrRead: dl('wrRead'), cxRead: dl('cxRead'),
glossRows: document.querySelectorAll('#glossTbl tr').length,
loopRows: document.querySelectorAll('#designLoop .lp').length,
cxRows: document.querySelectorAll('#cxTbl tr').length,
cxCmds,
nan: (body.match(/NaN|Infinity|undefined/g) || []).length,
noSol: (body.match(/無解/g) || []).length};
}""")
return data, console, errors, pg
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", help="改測已部署的網址")
ap.add_argument("--shots", default=tempfile.gettempdir())
args = ap.parse_args()
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("需要 playwright:pip install playwright"
" && python -m playwright install chromium")
return 3
tmp = None
if args.url:
url, local = args.url, False
else:
body = io.open(PAGE, encoding="utf-8").read()
tmp = os.path.join(tempfile.gettempdir(), "_workbench_smoke.html")
io.open(tmp, "w", encoding="utf-8").write(
SKELETON.format(body=body))
url, local = "file:///" + tmp.replace("\\", "/"), True
print("=" * 78)
print(f" 工作單冒煙測試 —— {url[:66]}")
print("=" * 78)
with sync_playwright() as p:
br = p.chromium.launch()
data, console, errors, pg = analyse(url, br, local)
if data is None:
print(" ✘ 找不到工作單內容(#mission 不存在)")
for t, m in console[:10]:
print(f" [{t}] {m}")
for e in errors[:10]:
print(f" [pageerror] {e}")
br.close()
return 1
fails = []
# 1. 執行期錯誤
cerr = [m for m in console if m[0] == "error"
and "404" not in m[1] and "favicon" not in m[1].lower()]
print(f"\n 執行期錯誤:pageerror {len(errors)}、console error {len(cerr)}")
for e in errors:
print(f" [pageerror] {e}")
fails.append("pageerror: " + e[:60])
for t, m in cerr[:8]:
print(f" [{t}] {m}")
fails.append("console: " + m[:60])
# 2. 章節連續且不重號
nums = [s["num"] for s in data["secs"]]
print(f"\n 章節 {len(nums)} 節:{' '.join(nums)}")
want = [f"{i:02d}" for i in range(1, len(nums) + 1)]
if nums != want:
print(f" ✘ 編號不連續或重號,預期 {' '.join(want)}")
fails.append("章節編號")
if len(nums) < MIN_SECTIONS:
print(f" ✘ 只有 {len(nums)} 節,預期至少 {MIN_SECTIONS}")
fails.append("章節數")
# 3. canvas 不能空白
blank = [c["id"] for c in data["canvases"] if c["painted"] == 0]
print(f"\n canvas {len(data['canvases'])} 張,"
f"空白 {len(blank)} 張")
if blank:
print(f" ✘ 空白:{blank}")
fails.append("空白 canvas " + ",".join(blank))
# 4. 讀數要有內容
for key, label in (("mission", "任務讀數"), ("envRead", "包絡線讀數"),
("wrRead", "水火箭讀數"), ("cxRead", "Connect 讀數")):
rows = data.get(key) or []
print(f" {label} {len(rows)} 列")
if not rows:
print(f" ✘ {label}是空的")
fails.append(label)
print(f" 名詞對照 {data['glossRows']} 列、設計迴圈 {data['loopRows']} 格")
if data["glossRows"] < 5 or data["loopRows"] < 5:
fails.append("名詞對照/設計迴圈未渲染")
print(f" Connect 實測表 {data['cxRows']} 列、腳本 {data['cxCmds']} 條指令")
if data["cxRows"] < 15 or data["cxCmds"] < 15:
fails.append("Connect 區塊未渲染")
# 5. 畫面上不該出現 NaN / undefined
print(f"\n 畫面上的 NaN/Infinity/undefined:{data['nan']}"
f" 「無解」:{data['noSol']}")
if data["nan"]:
fails.append(f"畫面出現 NaN/undefined ×{data['nan']}")
shot = os.path.join(args.shots, "workbench_smoke.png")
try:
pg.screenshot(path=shot, full_page=False)
print(f" 截圖 {shot}")
except Exception:
pass
br.close()
print()
print("=" * 78)
if fails:
print(f" SMOKE-FAIL {len(fails)} 項")
for f in fails:
print(f" - {f}")
print("=" * 78)
return 2
print(" SMOKE-OK")
print("=" * 78)
return 0
if __name__ == "__main__":
sys.exit(main())