獨立章節 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("&", "&amp;")
                  + 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\Textureslametex-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())