Week 15 — 發射與部署

資料夾:week15_launch/

基礎知識導論

Week 15 基礎知識導論 — 發射、上升軌跡與部署

一顆 CubeSat 的軌道,九成不是自己選的,是發射服務給的。
這一週要理解:載具能把你送到哪裡、代價是什麼、以及你能談判的空間有多大。

1. 這一週在解什麼問題

用 STK Aviator 建一枚兩節火箭,掃描燃料量 x 發射方位角 x 飛行路徑角速率,
看哪些組合能把 FIREYE 送進有效軌道(判準:近地點 > 100 km),
並讀出入軌的遠地點/近地點/傾角。

再用 Python 的火箭方程做量級驗證,最後接回 Week 14 算部署後的首次通聯。

2. 核心觀念

2.1 進入軌道需要多少 delta-V

理想的圓軌道速度(550 km):

v_circ = sqrt( μ / r ) = sqrt( 398600 / 6928 ) ≈ 7.58 km/s

但實際的發射 delta-V 需求約 9.3–9.5 km/s,多出來的約 1.8 km/s 是損失:

損失 量級 來源
重力損失 (gravity loss) 1.0–1.5 km/s 上升期間推力要抵抗重力
氣動阻力損失 0.1–0.3 km/s 稠密大氣層內的阻力
轉向損失 0.1–0.3 km/s 推力方向不在速度方向
地球自轉助益 −0.4 km/s(赤道向東) 這一項是賺的

地球自轉的助益隨緯度下降(正比於 cos(緯度)),
這是赤道附近發射場(Kourou、Alcântara)的主要優勢。

2.2 為什麼要分節

火箭方程的指數關係(Week 10 的 2.1)決定了單節火箭幾乎不可能入軌:
結構質量比無法做到那麼低。分節的邏輯是把已經燒空的結構丟掉。

Δv_total = Σ_i  Isp_i * g0 * ln( m0_i / mf_i )

每一節各自套用火箭方程,總 delta-V 是各節相加。

2.3 重力轉向 (gravity turn)

火箭不會急轉彎(氣動負荷會拆掉它)。標準的上升軌跡是:

  1. 垂直起飛,快速離開稠密大氣
  2. 俯仰程序 (pitch kick):輕微傾斜,讓重力開始把速度向量往水平轉
  3. 重力轉向:讓重力自然完成轉向,箭體保持零攻角(氣動負荷最小)
  4. 入軌燃燒:接近水平,把速度推到軌道速度

Aviator 裡的 FPA rate(飛行路徑角速率) 就是控制這個轉向快慢的參數。

存在一個最佳值,而且這個最佳值跟燃料量耦合。 這就是本週要掃描的原因。

2.4 發射方位角決定傾角

cos(i) = cos(φ) * sin(β)

兩個重要推論:

  1. 傾角不可能小於發射場緯度(除非在太空中改變傾角,那非常貴)。
    從緯度 28.5° 的卡納維爾角,最低只能直接入軌到 i = 28.5°。
  2. 向東發射 (β = 90°) 得到最低傾角,也拿到最多的地球自轉助益。
    向南或向北(極軌)拿不到自轉助益,運力下降。

改變傾角的 delta-V 極貴:

Δv = 2 * v * sin( Δi / 2 )

在 7.6 km/s 下改 1° 就要約 133 m/s —— 比 FIREYE 整個任務的 delta-V 預算還多。
所以傾角必須在發射時就拿對。

2.5 Rideshare:CubeSat 的現實

絕大多數 CubeSat 是搭便車上去的:

模式 特性
專屬發射 (dedicated) 軌道你決定,但每公斤成本極高
Rideshare(共乘) 便宜,但軌道由主酬載決定,發射時間常延期
ISS 部署 從國際太空站彈出;軌道固定(約 400 km, i = 51.6°),壽命短(1–2 年)

這對設計的影響是根本性的:
你的 Week 4 軌道選擇,可能會被「今年只有這幾班車」壓縮成兩三個選項。
所以任務設計要留軌道彈性,並且知道每個候選軌道下的效能損失。

2.6 部署 (Deployment) 與分離

2.7 為什麼要做 trade study 而不是單點分析

單點分析回答「這個設計行不行」;trade study 回答「設計空間長什麼樣」。
後者才能告訴你:

本週的 3 x 3 x 3 = 27 次傳播就是在畫這張圖。

3. 名詞與符號

符號 意義 單位 典型值
v_circ 圓軌道速度 km/s 550 km 時 7.58
Δv_launch 入軌總需求 km/s 9.3–9.5
β 發射方位角 ° 90 = 正東
φ 發射場緯度 ° —
FPA 飛行路徑角 ° 上升時由 90° 轉向 0°
Isp 比衝 s 固體 250–290,液體 300–450
近地點判準 有效入軌 km 本課 > 100
分離角速度 部署翻滾 °/s 5–20

4. 觀念 → 本週實驗對照

觀念 出現在本週的哪裡
Aviator 兩節火箭 week15_launch_aviator.py
3 x 3 x 3 trade study 同上,掃燃料 x 方位 x FPA rate
AGI 官方原版範例 LaunchVehicleTradeStudiesAviator.py
火箭方程量級驗證 week15_launch_deploy.py
部署後首次通聯 同上,接 Week 14
圖表 week15_launch.png
驗證段 week15_verify.py

LaunchVehicleTradeStudiesAviator.py 是 AGI 官方原版,逐字保留
(只修掉原檔的一處編碼損毀,並加上課程註記標頭)。
它會啟動 STK Engine 跑 27 次傳播,數十分鐘起跳,
所以不收進 notebook,請在終端機單獨執行。
保留原版的用意是:讓你看到業界工程師實際怎麼寫這種腳本,
而不只是看老師改寫過的教學版。

5. 常見誤解

  1. 「入軌需要 7.8 km/s。」 — 那是軌道速度。實際發射需求約 9.3–9.5 km/s,
    差額是重力、阻力與轉向損失。
  2. 「傾角可以之後再改。」 — 改 1° 要約 133 m/s,遠超過 CubeSat 的推進能力。
    傾角在發射時就決定了。
  3. 「向哪個方向發射都一樣。」 — 向東可拿到最多 0.4 km/s 的地球自轉助益,
    極軌完全拿不到。
  4. 「FPA rate 愈小愈好(爬得高)。」 — 爬太久重力損失大。存在最佳值,而且與燃料量耦合。
  5. 「軌道是我們選的。」 — Rideshare 的軌道由主酬載決定。設計要留彈性。

6. 延伸閱讀

上升軌跡與發射力學

發射服務(真實的介面文件)

工具

7. 課前自我檢核

  1. 550 km 圓軌道速度是 7.58 km/s,為什麼發射需要 9.4 km/s?差額去了哪裡?
  2. 從緯度 25° 的發射場,能不能直接入軌到 i = 20°?為什麼?
  3. 把傾角改 1° 要多少 delta-V?跟 FIREYE 的總 delta-V 預算(約 220 m/s)比較。
  4. FPA rate 太大與太小各會發生什麼事?為什麼最佳值跟燃料量有關?
  5. 你的任務被安排搭 ISS 部署(400 km, i = 51.6°)。Week 4 的哪些結論要重算?

圖


week15_launch/week15_launch.png

程式碼

week15_launch/LaunchVehicleTradeStudiesAviator.py
# -*- coding: utf-8 -*-
# ================================================================
# 課程備註(非原始碼的一部分)
#
# 這是 AGI 官方的 Aviator 發射載具 trade study 參考範例,手冊「檔案清單 /
# 現成參考範例」列出的就是本檔。與 week15_launch_aviator.py 的分工:
#   本檔                        = AGI 原版,agi.stk12 舊版 API,完整可跑的兩節發射
#   week15_launch_aviator.py    = 課程版,改用 ansys.stk.core 新版 API,結構精簡
#   week15_launch_deploy.py     = Python 火箭方程,量級驗證用
#
# 執行前注意:
#   1. 需要 STK Enterprise(Aviator + Integration)。以 STKEngine 啟動,
#      預設 noGraphics=False(要顯示);要純批次改成 True。
#   2. pathToGlb 寫死 STK 12 安裝路徑,換機器要改。
#   3. 需要 pandas 與 openpyxl(輸出 xlsx)。
#   4. trade study 是 3×3×3 = 27 次傳播,每次都會匯出星曆並存檔,
#      實際跑起來要數十分鐘;課堂示範建議先把 fuelRuns/azRuns/fpaRuns 調小。
#   5. 會在工作目錄建立 Ephemeris/ 與 LaunchVehicleAnalysis/ 兩個資料夾。
#
# 以下為原始檔內容(僅修正原檔的編碼破損字元,其餘未更動)。
# ================================================================

# This script will build out a baseline Aviator Two Stage Launch. Once the baseline
# trajectory is built, it will run a trade study varying inputs like True Course Hint,
# control FPA dot, and weight to determine which configuration/trajectory inputs bring
# a given amount of payload to orbit. This serves as an example of how you can script a
# launch vehicle trajectory generation and run trade studies. To take this script further,
# you could refine inputs to match your needs and optimize set inputs to attempt and find
# right configuration for your mission.
#
# As used throughout this tutorial, the term "missile" refers to a generic high-speed
# ballistic object and does not refer to a weapon or defense article
#
# Date Created: 5/6/2025
# STK Version used: STK 12.10

# Import main library
# from agi.stk12.stkdesktop import STKDesktop
from agi.stk12.stkengine import STKEngine
from agi.stk12.stkobjects import *
from agi.stk12.stkobjects.aviator import *
from agi.stk12.vgt import *
import csv
import pandas as pd # Will need to install if you do not have
import os
import openpyxl # Will need to install if you do not have

# Create directory for ephemeris files
current_directory = os.getcwd()
folder_path = current_directory + '\\Ephemeris'
try:
    os.makedirs(folder_path)
    print(f"Folder '{folder_path}' created successfully.")
except FileExistsError:
    print(f"Folder '{folder_path}' already exists.")
except Exception as e:
    print(f"An error occurred: {e}")

scenario_folder = current_directory + '\\LaunchVehicleAnalysis'
try:
    os.makedirs(scenario_folder)
    print(f"Folder '{scenario_folder}' created successfully.")
except FileExistsError:
    print(f"Folder '{scenario_folder}' already exists.")
except Exception as e:
    print(f"An error occurred: {e}")

# init results and input arrays
perigeeResults = []
apogeeResults = []
payloadResults = []
inclinationResults = []
fuelStateInputs = []
launchAzimuthInputs = []
fpaDotInputs = []
payloadInputs = []
orbitValid = []

stk = STKEngine.StartApplication(noGraphics=False) # optionally, noGraphics = True

# Get the IAgStkObjectRoot interface
root = stk.NewObjectRoot()

# Create new scenario
root.NewScenario("LaunchVehicleAnalysis")

# IAgStkObjectRoot root: STK Object Model root
scenario = root.CurrentScenario

# Set unit pref to EpSec
root.UnitPreferences.Item('DateFormat').SetCurrentUnit('EpSec')

# Create baseline objects
lc39 = scenario.Children.New(AgESTKObjectType.ePlace, 'LC-39A')
stage1 = scenario.Children.New(AgESTKObjectType.eAircraft, 'Stage1')
stage2 = scenario.Children.New(AgESTKObjectType.eAircraft, 'Stage2')
sat = scenario.Children.New(AgESTKObjectType.eSatellite, 'Orbit')

# Assign to Kennedy Space Center
lc39.Position.AssignGeodetic(28.6084,-80.6043,0)

# Set Stage 1 to an Avtr AC
stage1.SetRouteType(AgEVePropagatorType.ePropagatorAviator)

# Connect to Aviator
route1 = stage1.Route
avtr1 = route1.AvtrPropagator
mission1 = avtr1.AvtrMission
config1 = mission1.Configuration
phases1 = mission1.Phases
phase1 = phases1[0]
procedures1 = phase1.Procedures
catalog1 = avtr1.AvtrCatalog

perfName1 = 'Stage1LV'

# Looks to see if there is already a performance model with that name, if it exists, it removes it
try:
    testifexists = catalog1.AircraftCategory.MissileModels.GetMissile(perfName1)
    testifexists.Remove()
    print('Model removed\n')
except:
    print('No model found\n')

# Creates the Stage One Performance Model
stage1PerfModel = catalog1.AircraftCategory.MissileModels.AddMissile(perfName1)
stage1PerfModel.AttitudeTransitions.RollRate = 10
stage1PerfModel.Aerodynamics.ModeAsSimple.SRef = 10
stage1PerfModel.Propulsion.ModeAsSimple.MaxThrust = 1710000
stage1PerfModel.Propulsion.ModeAsSimple.FuelFlow = 22406000
stage1PerfModel.DefaultConfiguration.MaxLandingWeight = 100000000
stage1PerfModel.DefaultConfiguration.EmptyWeight = 315000
stations1 = stage1PerfModel.DefaultConfiguration.GetStations()
fuelTank1 = stations1.GetInternalFuelTankByName('Internal Fuel Tank')
fuelTank1.Capacity = 905900
fuelTank1.InitialFuelState = 905900

# Sets the performance Model
model1 = catalog1.AircraftCategory.MissileModels.GetMissile(perfName1)
mission1.Vehicle = model1

# Launch Procedure
launch1 : IAgAvtrProcedureLaunch
launch1 = procedures1.Add(AgEAvtrSiteType.eSiteSTKStaticObject, AgEAvtrProcedureType.eProcLaunch)
launchsite1 = launch1.Site
launchsite1.ObjectName = 'Place/LC-39A'
launch1.DirectionVecName = 'LC-39A Zenith(Detic)'
launch1.AttitudeMode = AgEAvtrLaunchAttitudeMode.eLaunchAlignDirectionVector
launch1.SpecifyLaunchAirspeed = 1
launch1.AccelG = 2
launch1.TrueCourseHint = 45
launch1.SetAirspeed(AgEAvtrAirspeedType.eCAS, 250)

# Pitch 63 FPA 10 km DR
pitch3D1 : IAgAvtrBasicManeuverStrategyPitch3D
pitch1 = procedures1.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
pitch1.NavigationStrategyType = 'Pitch3D'
pitch3D1 = pitch1.Navigation
pitch3D1.CommandFPA = 63
pitch3D1.StopWhenFPAAchieved = 0
pitch3D1.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelAeroProp
pitch3D1.AirspeedOptions.Throttle = 90
pitch3D1.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
pitch3D1.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
pitch1.MaxDownrange = 5.39957  # 10 km
pitch1.UseMaxTimeOfFlight = 0
pitch1.FuelFlowType = AgEAvtrBasicManeuverFuelFlowType.eBasicManeuverFuelFlowAeroProp

# Pitch 55 FPA 40 km DR
pitch3D2 : IAgAvtrBasicManeuverStrategyPitch3D
pitch2 = procedures1.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
pitch2.NavigationStrategyType = 'Pitch3D'
pitch3D2 = pitch2.Navigation
pitch3D2.CommandFPA = 55
pitch3D2.ControlFPADot = 0.15
pitch3D2.StopWhenFPAAchieved = 0
pitch3D2.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelAeroProp
pitch3D2.AirspeedOptions.Throttle = 100
pitch3D2.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
pitch3D2.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
pitch2.MaxDownrange = 21.5983 #nm = 40 km
pitch2.UseMaxTimeOfFlight = 0
pitch2.FuelFlowType = AgEAvtrBasicManeuverFuelFlowType.eBasicManeuverFuelFlowAeroProp

# Continue Zero G until MECO
ballistic3D1 : IAgAvtrBasicManeuverStrategyBallistic3D
zeroGMECO = procedures1.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
zeroGMECO.NavigationStrategyType = 'Ballistic3D'
ballistic3D1 = zeroGMECO.Navigation
ballistic3D1.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelAeroProp
ballistic3D1.AirspeedOptions.Throttle = 100
ballistic3D1.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
ballistic3D1.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
zeroGMECO.UseMaxDownrange = 0
zeroGMECO.UseMaxTimeOfFlight = 0
zeroGMECO.FuelFlowType = AgEAvtrBasicManeuverFuelFlowType.eBasicManeuverFuelFlowAeroProp

# Coast 3 seconds
coastMan : IAgAvtrBasicManeuverStrategyBallistic3D
coast1 = procedures1.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
coast1.NavigationStrategyType = 'Ballistic3D'
coastMan = coast1.Navigation
coastMan.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelUnderGravity
coastMan.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
coastMan.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
coast1.UseMaxDownrange = 0
coast1.MaxTimeOfFlight = '00:00:03.000'
coast1.UseStopFuelState = 0

# Sets 3D model to preinstalled LV glb
pathToGlb = r'C:\Program Files\AGI\STK 12\STKData\VO\Models\Space\launchvehicle.glb'
glbCmd1 = r'MissionModeler */Aircraft/Stage1 Aircraft SetValue Model3D "' + pathToGlb + '"'
root.ExecuteCommand(glbCmd1)

avtr1.Propagate()

## STAGE 2 ##
stage2.SetRouteType(AgEVePropagatorType.ePropagatorAviator)

# Connect to Aviator
route2 = stage2.Route
avtr2 = route2.AvtrPropagator
mission2 = avtr2.AvtrMission
config2 = mission2.Configuration
phases2 = mission2.Phases
phase2 = phases2[0]
procedures2 = phase2.Procedures
catalog2 = avtr2.AvtrCatalog

perfName2 = 'Stage2LV'

# Looks to see if there is already a performance model with that name, if it exists, it removes it
try:
    testifexists = catalog2.AircraftCategory.MissileModels.GetMissile(perfName2)
    testifexists.Remove()
    print('Model removed\n')
except:
    print('No model found\n')

# Creates the Stage Two Performance Model
stage2PerfModel = catalog2.AircraftCategory.MissileModels.AddMissile(perfName2)
stage2PerfModel.AttitudeTransitions.RollRate = 10
stage2PerfModel.Aerodynamics.ModeAsSimple.SRef = 10
stage2PerfModel.Propulsion.ModeAsSimple.MaxThrust = 285000
stage2PerfModel.Propulsion.ModeAsSimple.FuelFlow = 3260130
stage2PerfModel.DefaultConfiguration.MaxLandingWeight = 100000000
stage2PerfModel.DefaultConfiguration.EmptyWeight = 39100
stations2 = stage2PerfModel.DefaultConfiguration.GetStations()
fuelTank2 = stations2.GetInternalFuelTankByName('Internal Fuel Tank')
fuelTank2.Capacity = 237000
fuelTank2.InitialFuelState = 237000

# Sets the performance model
model2 = catalog2.AircraftCategory.MissileModels.GetMissile(perfName2)
mission2.Vehicle = model2

# Launch Procedure
launch2 : IAgAvtrProcedureLaunch
launch2 = procedures2.Add(AgEAvtrSiteType.eSiteSTKVehicle, AgEAvtrProcedureType.eProcLaunch)
launchsite2 = launch2.Site
launchsite2.ObjectName = 'Aircraft/Stage1'
launch2.LaunchTime = stage1.Vgt.Events.GetItemByName('EphemerisStopTime').FindOccurrence().Epoch
launch2.DirectionVecName = '1stStage FlightPath.X'
launch2.AttitudeMode = AgEAvtrLaunchAttitudeMode.eLaunchAlignDirectionVector
launch2.AttitudeMode = AgEAvtrLaunchAttitudeMode.eLaunchHoldParentAttitude
launch2.SpecifyLaunchAirspeed = 0

# Coast 3 seconds
coastMan2 : IAgAvtrBasicManeuverStrategyBallistic3D
coast2 = procedures2.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
coast2.NavigationStrategyType = 'Ballistic3D'
coastMan2 = coast2.Navigation
coastMan2.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelUnderGravity
coastMan2.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
coastMan2.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
coast2.UseMaxDownrange = 0
coast2.MaxTimeOfFlight = '00:00:03.000'
coast2.UseStopFuelState = 0

# Slow Pitch Over
slow3dPitch : IAgAvtrBasicManeuverStrategyPitch3D
slowPitch = procedures2.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
slowPitch.NavigationStrategyType = 'Pitch3D'
slow3dPitch = slowPitch.Navigation
slow3dPitch.CommandFPA = 0
slow3dPitch.ControlFPADot = 0.3
slow3dPitch.StopWhenFPAAchieved = 0
slow3dPitch.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelAeroProp
slow3dPitch.AirspeedOptions.Throttle = 100
slow3dPitch.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
slow3dPitch.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
slowPitch.UseMaxDownrange = 0
slowPitch.MaxTimeOfFlight = '00:04:00.000'
slowPitch.FuelFlowType = AgEAvtrBasicManeuverFuelFlowType.eBasicManeuverFuelFlowAeroProp

# Orbit Injection
orbitInjMan : IAgAvtrBasicManeuverStrategyBallistic3D
orbitInj = procedures2.Add(AgEAvtrSiteType.eSiteEndOfPrevProcedure, AgEAvtrProcedureType.eProcBasicManeuver)
orbitInj.NavigationStrategyType = 'Ballistic3D'
orbitInjMan = orbitInj.Navigation
orbitInjMan.AirspeedOptions.AirspeedMode = AgEAvtrBasicManeuverAirspeedMode.eAccelDecelAeroProp
orbitInjMan.AirspeedOptions.MinSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
orbitInjMan.AirspeedOptions.MaxSpeedLimits = AgEAvtrBasicManeuverStrategyAirspeedPerfLimits.eIgnoreIfViolated
orbitInjMan.AirspeedOptions.Throttle = 100
orbitInj.UseMaxDownrange = 0
orbitInj.UseMaxTimeOfFlight = 0

# Sets second stage 3D model
glbCmd2 = r'MissionModeler */Aircraft/Stage2 Aircraft SetValue Model3D "' + pathToGlb + '"'
root.ExecuteCommand(glbCmd2)
avtr2.Propagate()

# Gets MISSION specific fuel tank
stationsMission2 = config2.GetStations()
fuelTankMission2 = stationsMission2.GetInternalFuelTankByName('Internal Fuel Tank')

## BEGIN TRADE STUDY ##

# Initial weights, step size, and number of runs
initEmptyWeight2 = 39100
initFuelState2 = 237000
fuelStep = 1000
fuelRuns = 3

# True course hint initial, step size, and runs
azInit = 45
azStep = 5
azRuns = 3

# Control FPA Dot intial, step size, and runs
fpaInit = 0.3
fpaStep = 0.2
fpaRuns = 3

# counter for all of the runs
count = 1

# For loop to loop through and trade fuel for empty weight
for i in range (fuelRuns):

    emptyWeight2 = initEmptyWeight2 + fuelStep * i
    fuelState2 = initFuelState2 - fuelStep * i
    config2.EmptyWeight = emptyWeight2
    fuelTankMission2.InitialFuelState = fuelState2
    fuelTankMission2.Capacity = fuelState2
    payload = emptyWeight2 - 9100

    # For loop to loop through different true course hints
    for j in range(azRuns):
        phase1 = mission1.Phases.Item(0)
        launch1 = phase1.Procedures.Item(0)
        launchAzimuth = azInit + azStep*j
        launch1.TrueCourseHint = launchAzimuth

        # For loop to loop through different control FPA dots
        for k in range(fpaRuns):
            phase2 = mission2.Phases.Item(0)
            pitchOver = phase2.Procedures.Item(2)
            FPADot = fpaInit + fpaStep*k
            pitchOver.Navigation.ControlFPADot = FPADot

            avtr1.Propagate()

            # Sets the seconds launch time to be the ephemeris end of the first stage
            launch2.LaunchTime = stage1.Vgt.Events.GetItemByName('EphemerisStopTime').FindOccurrence().Epoch
            avtr2.Propagate()

            countstring = str(count)

            # Exports the second stage as an ephemeris file and sets the orbit's intial state as the ephemeris last point
            stage2.ExportTools.GetEphemerisStkExportTool().Export(folder_path + '//2ndStage' + countstring + '.e')
            initStateCmd = r'InitialState */Satellite/Orbit Import STKFile "'+ folder_path + r'\2ndStage' + countstring + '.e" EpochSelect LastPoint'
            root.ExecuteCommand(initStateCmd)
            sat.Propagator.Propagate()
            sat.ExportTools.GetEphemerisStkExportTool().Export(folder_path + '//Orbit' + countstring + '.e')

            # Grabs outputs for the excel table
            dp = sat.DataProviders.Item('Classical Elements').Group.Item('ICRF').Exec(scenario.StartTime, scenario.StopTime, 60)
            Time = dp.DataSets.GetDataSetByName('Time').GetValues()
            apogeeAlt = dp.DataSets.GetDataSetByName('Apogee Altitude').GetValues()
            perigeeAlt = dp.DataSets.GetDataSetByName('Perigee Altitude').GetValues()
            inclination = dp.DataSets.GetDataSetByName('Inclination').GetValues()

            # Adds results to array
            perigeeResults.append(perigeeAlt[0])
            apogeeResults.append(apogeeAlt[0])
            inclinationResults.append(inclination[0])
            fuelStateInputs.append(fuelState2)
            launchAzimuthInputs.append(launchAzimuth)
            fpaDotInputs.append(FPADot)
            payloadInputs.append(payload)

            # Prints results of each run
            print('run ' + countstring + ' resulted in ' + str(perigeeAlt[0]) +
                  ' km perigee, ' + str(apogeeAlt[0]) + ' km apogee, and '+ str(inclination[0]) +
                  ' inclination with a fuel state of ' + str(fuelState2) + ' lbs ' + 'using a Launch Azimuth of ' +
                  str(launchAzimuth) + ' and an FPADot of ' + str(FPADot) + ' carrying ' + str(payload) + ' lbs of payload\n')
            # Checks if a valid orbit Valid = not going below 100 km
            if perigeeAlt[0] < 100:
                orbitValid.append(False)
            else:
                orbitValid.append(True)
            count += 1

combinedPath = folder_path + '\\test.csv'

### write csv for satellite collection
f = open(combinedPath, "w")
f.write("Version\t\t 1.0\n\n")
f.write("ContentType EphemerisFile\n\n")
f.write("Begin Columns\n")
f.write("Name, Filename\n")
for y in range (fuelRuns * azRuns * fpaRuns):
    z = y+1
    f.write("Sat" + str(z) + ", Orbit" + str(z) + ".e\n")

f.write("End Columns\n")
f.close()

satcol = scenario.Children.New(AgESTKObjectType.eSatelliteCollection, 'Results')
satcolCmd = 'Collection */SatelliteCollection/Results Define Custom Propagator "J2Perturbation" File ' + combinedPath + ' EphemerisDirectory "' + folder_path + '"'
root.ExecuteCommand(satcolCmd)
satcolGraphicsCmd = 'Graphics */SatelliteCollection/Results SubsetGfx Subset AllSatellites ShowOrbits On'
root.ExecuteCommand(satcolGraphicsCmd)

# Create a dictionary where keys are column names and values are the lists
data = {'Payload': payloadInputs, 'Fuel Carried Stage 2': fuelStateInputs, 'FPA Dot': fpaDotInputs, 'Launch Azimuth': launchAzimuthInputs, 'Perigee': perigeeResults, 'Agogee': apogeeResults, 'Inclination': inclinationResults, 'Orbit Valid?': orbitValid}

# Create a Pandas DataFrame from the dictionary
df = pd.DataFrame(data)

# Write the DataFrame to an Excel file
df.to_excel(folder_path +'\\LVAnalysis.xlsx', index=False) # index=False prevents writing the index

print("Excel file created successfully.")

# Save Scenario
saveCmd = r'SaveAs / * "' + scenario_folder + r'\LaunchVehicleAnalysis"'
root.ExecuteCommand(saveCmd)
print('Scenario saved\nEnd of Script')
week15_launch/week15_launch_aviator.py
# -*- coding: utf-8 -*-
r"""Week 15 主軌 — STK/Aviator 兩節發射載具 trade study(可執行)。

對照 AGI 官方範例 `LaunchVehicleTradeStudiesAviator.py`(本資料夾內),
改寫成新版 `ansys.stk.core` API。**這一支是真的會跑的**:啟動 STK Engine、
建兩節 Aviator 載具、掃描、把第二節熄火狀態餵給衛星讀軌道根數。

    python week15_launch_aviator.py            # 1x1x1,約一分半
    python week15_launch_aviator.py --full     # 3x3x3 = 27 次,數十分鐘

## 先前這支跑不起來的兩個原因(都已修正)

1. **`VehiclePropagatorType` 這個名字不存在**。新版是 `PropagatorType`,
   成員 `AVIATOR` / `BALLISTIC` / `SIMPLE_ASCENT` / `STK_EXTERNAL` /
   `ASTROGATOR`。舊寫法連 import 都過不了,擋在載入期 ——
   比下面那件事更早,所以下面那件事一直沒機會被發現。
2. 三行 `execute_command(f"MissionModeler ... {fuel}")` **字面上就有 `...`**。
   那不是示意的省略號,Connect 收到會直接失敗。已換成官方範例用的
   **object model** 寫法(對照 `Aviator_ObjectModel_FuelConsumptionStudy.py`,
   隨 STK 安裝在 `CodeSamples/Automation/Python/`)。

## 新舊 API 名稱對照(踩過才知道的那幾個)

| 舊 `agi.stk12` | 新 `ansys.stk.core` |
|---|---|
| `AgEVePropagatorType.ePropagatorAviator` | `PropagatorType.AVIATOR` |
| `stage.Route.AvtrPropagator` | `stage.route.aviator_propagator` |
| `.AvtrMission` / `.AvtrCatalog` | `.aviator_mission` / `.aviator_catalog` |
| `.Aerodynamics.ModeAsSimple.SRef` | `.aerodynamics.mode_as_simple.s_reference` |
| `.Propulsion.ModeAsSimple.MaxThrust` | `.propulsion.mode_as_simple.max_thrust` |
| `Pitch3D.CommandFPA` | `.command_flight_path_angle` |
| `Pitch3D.ControlFPADot` | `.control_flight_path_angle_dot` |
| **`stage.Vgt.Events`** | **`.analysis_workbench_components.time_instants`** |
| `DataProvider....Exec(...)` | `....execute(...)` |

最後兩列不是單純的大小寫轉換,用猜的猜不到。

## 單位

Aviator 的效能模型用**磅**與**浬**,與場景的單位偏好無關 ——
下面那些 905900、237000 是 lb,`max_downrange` 的 5.39957 是 nm(= 10 km)。
效能數值沿用官方範例,因為那組數值是已知能入軌的;換成自己編的數字,
多半會卡在「近地點掉進大氣層」,而且分不出是模型錯還是設計錯。
"""
import gc
import os
import sys

# ---- 新版 API 的名字。`VehiclePropagatorType` 不存在,別再寫回去。----
from ansys.stk.core.stkengine import STKEngine
from ansys.stk.core.stkobjects import STKObjectType, PropagatorType
from ansys.stk.core.stkobjects.aviator import (
    SiteType, ProcedureType, LaunchAttitudeMode, AirspeedType,
    BasicManeuverAirspeedMode as ASMode,
    BasicManeuverStrategyAirspeedPerformanceLimits as ASLimit,
    BasicManeuverFuelFlowType as FFType)

HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))

IGNORE = ASLimit.IGNORE_IF_VIOLATED
AEROPROP = ASMode.ACCELERATION_DECELERATION_AERODYNAMIC_PROPULSION
GRAVITY = ASMode.ACCELERATION_DECELERATION_UNDER_GRAVITY
FF_AEROPROP = FFType.BASIC_MANEUVER_FUEL_FLOW_AERODYNAMIC_PROPULSION

LAUNCH_LAT, LAUNCH_LON = 22.111607, 120.887016      # 台南示範(對照 TASA)

# 第二節:**總重守住,酬載與燃料對換** —— 這才是 trade。
# 官方範例的同一件事寫成 emptyWeight2 += step / fuelState2 -= step。
STAGE2_DRY = 9100.0          # 乾結構 (lb)
STAGE2_GROSS = 276100.0      # 第二節總重 (lb),掃描中不變
PAYLOAD_INIT = 30000.0       # 酬載起點 (lb)
# 每多帶一步的酬載,就少帶同樣多的燃料。步長取 10,000 是**量出來的**:
# 實測交越點落在 30,000 與 40,000 lb 之間,所以預設三格剛好跨過邊界
# (VALID / invalid / invalid)。官方範例用 1,000,對這組參數太細 ——
# 三格全是 VALID,掃了等於沒掃,看不到邊界在哪裡。
PAYLOAD_STEP = 10000.0
TANK = "Internal Fuel Tank"


def _perf(catalog, name, thrust, fuel_flow, empty, fuel):
    """建一個飛彈類效能模型(發射載具在 Aviator 裡走 MissileModels)。"""
    models = catalog.aircraft_category.missile_models
    if models.contains(name):
        models.remove_child(name)
    pm = models.add_missile(name)
    pm.attitude_transitions.roll_rate = 10
    pm.aerodynamics.mode_as_simple.s_reference = 10
    pm.propulsion.mode_as_simple.max_thrust = thrust
    pm.propulsion.mode_as_simple.fuel_flow = fuel_flow
    pm.default_configuration.max_landing_weight = 100000000
    pm.default_configuration.empty_weight = empty
    tank = pm.default_configuration.get_stations().get_internal_fuel_tank_by_name(TANK)
    tank.capacity = fuel
    tank.initial_fuel_state = fuel
    return models.get_missile(name)


def _pitch(procs, fpa, fpa_dot, throttle, max_downrange=None, max_time=None):
    """Pitch3D 基本機動:重力轉向的每一段。"""
    p = procs.add(SiteType.SITE_END_OF_PREV_PROCEDURE,
                  ProcedureType.PROCEDURE_BASIC_MANEUVER)
    p.navigation_strategy_type = "Pitch3D"
    nav = p.navigation
    nav.command_flight_path_angle = fpa
    if fpa_dot is not None:
        nav.control_flight_path_angle_dot = fpa_dot
    nav.stop_when_flight_path_angle_achieved = False
    nav.airspeed_options.airspeed_mode = AEROPROP
    nav.airspeed_options.throttle = throttle
    nav.airspeed_options.min_speed_limits = IGNORE
    nav.airspeed_options.max_speed_limits = IGNORE
    if max_downrange is not None:
        p.max_downrange = max_downrange
    else:
        p.use_max_downrange = False
    if max_time is not None:
        p.max_time_of_flight = max_time
    else:
        p.use_max_time_of_flight = False
    p.fuel_flow_type = FF_AEROPROP
    return p


def _ballistic(procs, mode, throttle=None, max_time=None):
    """Ballistic3D 基本機動:續燒到熄火、或純滑行。"""
    p = procs.add(SiteType.SITE_END_OF_PREV_PROCEDURE,
                  ProcedureType.PROCEDURE_BASIC_MANEUVER)
    p.navigation_strategy_type = "Ballistic3D"
    nav = p.navigation
    nav.airspeed_options.airspeed_mode = mode
    if throttle is not None:
        nav.airspeed_options.throttle = throttle
    nav.airspeed_options.min_speed_limits = IGNORE
    nav.airspeed_options.max_speed_limits = IGNORE
    p.use_max_downrange = False
    if max_time is not None:
        p.max_time_of_flight = max_time
    else:
        p.use_max_time_of_flight = False
    if throttle is not None:
        p.fuel_flow_type = FF_AEROPROP
    return p


def build_launch_vehicle(no_graphics=True, scenario_name="W15_LaunchTrade"):
    """建兩節 Aviator 發射載具 + 一顆接手的衛星。回傳一個 dict。"""
    stk = STKEngine.start_application(no_graphics=no_graphics)
    root = stk.new_object_root()
    root.new_scenario(scenario_name)
    sc = root.current_scenario
    root.units_preferences.item("DateFormat").set_current_unit("EpSec")
    try:
        site = sc.children.new(STKObjectType.PLACE, "LaunchSite")
        site.position.assign_geodetic(LAUNCH_LAT, LAUNCH_LON, 0)
        stage1 = sc.children.new(STKObjectType.AIRCRAFT, "Stage1")
        stage2 = sc.children.new(STKObjectType.AIRCRAFT, "Stage2")
        sat = sc.children.new(STKObjectType.SATELLITE, "Orbit")

        # ---- 第一節 ----
        stage1.set_route_type(PropagatorType.AVIATOR)
        av1 = stage1.route.aviator_propagator
        m1 = av1.aviator_mission
        m1.vehicle = _perf(av1.aviator_catalog, "Stage1LV",
                           1710000, 22406000, 315000, 905900)
        pr1 = m1.phases.item(0).procedures
        launch = pr1.add(SiteType.SITE_STK_STATIC_OBJECT,
                         ProcedureType.PROCEDURE_LAUNCH)
        launch.site.object_name = "Place/LaunchSite"
        launch.direction_vec_name = "LaunchSite Zenith(Detic)"
        launch.attitude_mode = LaunchAttitudeMode.LAUNCH_ALIGN_DIRECTION_VECTOR
        launch.specify_launch_airspeed = True
        launch.acceleration_g = 2
        launch.true_course_hint = 45
        launch.set_airspeed(AirspeedType.CAS, 250)
        _pitch(pr1, 63, None, 90, max_downrange=5.39957)                # 到 10 km DR
        pitch_over = _pitch(pr1, 55, 0.15, 100, max_downrange=21.5983)  # 掃這一支
        _ballistic(pr1, AEROPROP, throttle=100)                         # 續燒到 MECO
        _ballistic(pr1, GRAVITY, max_time="00:00:03.000")               # 分離前滑行

        # ---- 第二節 ----
        stage2.set_route_type(PropagatorType.AVIATOR)
        av2 = stage2.route.aviator_propagator
        m2 = av2.aviator_mission
        m2.vehicle = _perf(av2.aviator_catalog, "Stage2LV", 285000, 3260130,
                           STAGE2_DRY + PAYLOAD_INIT,
                           STAGE2_GROSS - STAGE2_DRY - PAYLOAD_INIT)
        pr2 = m2.phases.item(0).procedures
        launch2 = pr2.add(SiteType.SITE_STK_VEHICLE, ProcedureType.PROCEDURE_LAUNCH)
        launch2.site.object_name = "Aircraft/Stage1"
        launch2.attitude_mode = LaunchAttitudeMode.LAUNCH_HOLD_PARENT_ATTITUDE
        launch2.specify_launch_airspeed = False
        _ballistic(pr2, GRAVITY, max_time="00:00:03.000")
        _pitch(pr2, 0, 0.3, 100, max_time="00:04:00.000")
        _ballistic(pr2, AEROPROP, throttle=100)

        return dict(stk=stk, root=root, scenario=sc, stage1=stage1, stage2=stage2,
                    sat=sat, av1=av1, av2=av2, mission2=m2,
                    launch=launch, launch2=launch2, pitch_over=pitch_over)
    except Exception:
        stk.shutdown()
        raise


def run_once(ctx, payload, azimuth, fpa_dot, eph_path):
    """設三個旋鈕、傳播兩節、把熄火狀態餵給衛星,回軌道根數。"""
    empty = STAGE2_DRY + payload
    fuel = STAGE2_GROSS - empty          # 對換:第二節總重守住
    cfg = ctx["mission2"].configuration
    cfg.empty_weight = empty
    tank = cfg.get_stations().get_internal_fuel_tank_by_name(TANK)
    # **順序有意義**:capacity 不能設成比現有的 initial_fuel_state 小,
    # 直接寫 capacity 會在「酬載變重、燃料變少」那一側被 STK 拒絕
    # (One or more arguments are invalid)。先歸零再設,兩個方向都安全。
    tank.initial_fuel_state = 0
    tank.capacity = fuel
    tank.initial_fuel_state = fuel

    ctx["launch"].true_course_hint = azimuth
    ctx["pitch_over"].navigation.control_flight_path_angle_dot = fpa_dot

    out = dict(payload=payload, fuel=fuel, empty=empty, azimuth=azimuth,
               fpa_dot=fpa_dot, apogee=None, perigee=None, inclination=None,
               valid=False, error=None)
    # 掃描一定會掃到飛不出來的組態 —— 那是 trade study 的**重點**,不是故障。
    # 不接住的話,整個掃描會在第一個不可行點中斷,後面的格子全部拿不到。
    try:
        ctx["av1"].propagate()
        ctx["launch2"].launch_time = (ctx["stage1"].analysis_workbench_components
                                      .time_instants.item("EphemerisStopTime")
                                      .find_occurrence().epoch)
        ctx["av2"].propagate()

        # 第二節星曆的最後一點就是入軌狀態
        ctx["stage2"].export_tools.get_ephemeris_stk_export_tool().export(eph_path)
        ctx["root"].execute_command(
            'InitialState */Satellite/Orbit Import STKFile "' + eph_path + '" '
            'EpochSelect LastPoint')
        ctx["sat"].propagator.propagate()
        sc = ctx["scenario"]
        dp = (ctx["sat"].data_providers.item("Classical Elements")
              .group.item("ICRF").execute(sc.start_time, sc.stop_time, 60))

        def get(name):
            return dp.data_sets.get_data_set_by_name(name).get_values()[0]

        out["apogee"] = get("Apogee Altitude")
        out["perigee"] = get("Perigee Altitude")
        out["inclination"] = get("Inclination")
        # 有效入軌判據(同官方範例):近地點沒掉進大氣層
        out["valid"] = out["perigee"] > 100
    except Exception as exc:                      # noqa: BLE001
        out["error"] = "%s: %s" % (type(exc).__name__, exc)
    return out


def run_launch_trade_study(payload_runs=1, az_runs=1, fpa_runs=1,
                           out_dir=None, no_graphics=True, verbose=True):
    """三重迴圈:酬載(與燃料對換)x 發射方位 x pitch 程式的 FPA 速率。"""
    out_dir = out_dir or os.path.join(HERE, "LVEphemeris")
    os.makedirs(out_dir, exist_ok=True)
    payloads = [PAYLOAD_INIT + PAYLOAD_STEP*i for i in range(payload_runs)]
    azimuths = [45 + 5*j for j in range(az_runs)]          # 同官方範例
    fpa_dots = [0.3 + 0.2*k for k in range(fpa_runs)]      # 同官方範例

    ctx = build_launch_vehicle(no_graphics=no_graphics)
    results = []
    try:
        n = 0
        for payload in payloads:
            for az in azimuths:
                for fdot in fpa_dots:
                    n += 1
                    r = run_once(ctx, payload, az, fdot,
                                 os.path.join(out_dir, "stage2_%02d.e" % n))
                    results.append(r)
                    if verbose:
                        head = ("  run %2d  酬載 %6.0f lb(燃料 %7.0f)  az %2.0f"
                                "  fpadot %.1f  ->  "
                                % (n, payload, r["fuel"], az, fdot))
                        if r["error"]:
                            print(head + "飛不出來(" + r["error"][:52] + ")")
                        else:
                            print(head + "近地 %8.2f / 遠地 %8.2f km,傾角 %.2f,%s"
                                  % (r["perigee"], r["apogee"], r["inclination"],
                                     "VALID" if r["valid"] else "invalid"))
    finally:
        # **順序有意義**:ctx 裡那些 STK 物件的 COM 介面必須在引擎還在的時候
        # 釋放掉。先 shutdown 再讓它們被回收,直譯器結束時會 SIGSEGV ——
        # 畫面上的輸出全都印完了,只有離開碼是 139,很容易看不到。
        stk = ctx.pop("stk")
        ctx.clear()
        gc.collect()
        stk.shutdown()

    valid = [r for r in results if r["valid"]]
    if verbose:
        print("")
        print("  %d/%d 組態成功入軌" % (len(valid), len(results)))
        if valid:
            best = max(valid, key=lambda r: r["payload"])
            print("  還能入軌的最大酬載 %.0f lb(az %.0f、fpadot %.1f)"
                  % (best["payload"], best["azimuth"], best["fpa_dot"]))
        else:
            print("  沒有任何組態入軌 —— 酬載起點就太重,把 PAYLOAD_INIT 往下調")
    return results


# ================================================================
# 彈道發射軌跡(對照 TASA notebook)
# ================================================================
def ballistic_launch_demo(root, eph_path=None):
    """用 Missile + 彈道傳播器做一條發射軌跡,匯出星曆接 Week 14。"""
    from ansys.stk.core.stkobjects import VehicleLaunchControl
    eph_path = eph_path or os.path.join(HERE, "LV_ephemeris.e")
    missile = root.current_scenario.children.new(STKObjectType.MISSILE,
                                                 "LaunchVehicle")
    missile.set_trajectory_type(PropagatorType.BALLISTIC)
    traj = missile.trajectory
    traj.launch.latitude = LAUNCH_LAT
    traj.launch.longitude = LAUNCH_LON
    traj.impact_location.impact.latitude = 21
    traj.impact_location.impact.longitude = 124
    traj.impact_location.set_launch_control_type(
        VehicleLaunchControl.FIXED_APOGEE_ALTITUDE)
    traj.impact_location.launch_control.apogee_altitude = 307   # km
    traj.propagate()
    missile.export_tools.get_ephemeris_stk_export_tool().export(eph_path)
    print("彈道發射軌跡已建立,星曆寫到 " + eph_path)
    return missile


def main():
    full = "--full" in sys.argv
    runs = (3, 3, 3) if full else (1, 1, 1)
    print("=" * 74)
    print("  Week 15 Aviator 兩節發射 trade study(%dx%dx%d = %d 次傳播)"
          % (runs[0], runs[1], runs[2], runs[0]*runs[1]*runs[2]))
    print("=" * 74)
    run_launch_trade_study(*runs)
    return 0


if __name__ == "__main__":
    _rc = main()
    # **量到的工具邊界**:STK Engine 的收尾偶爾會在直譯器結束時 SIGSEGV,
    # 離開碼變成 139 —— 而畫面上的結果**早就全部印完而且是對的**。
    # 實測:只啟動引擎 + 建空場景 + 關閉,5 次全部乾淨;
    #       建完 Aviator 載具再關,3 次裡 1 次崩;整支跑完 3 次裡 2 次崩。
    #       先放掉 ctx 的參考再 shutdown、以及先 close_scenario,**都沒有改善**。
    # 崩在 STK 自己的收尾,腳本這一側修不掉。結果已經落地(星曆都寫出去了),
    # 所以這裡跳過直譯器的 finalize,讓離開碼說實話 —— 不然 CI 與學生
    # 會看到一個「跑得好好的卻回報失敗」的腳本。
    sys.stdout.flush()
    sys.stderr.flush()
    os._exit(_rc)
week15_launch/week15_launch_deploy.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 15 Lab - 發射與部署
#
# 雙軌:
#   (A) Python 解析 = 保證可跑:兩節火箭「火箭方程 + 目標軌道速度」
#       trade study —— 三張圖分別掃分節比、目標高度、發射方位;
#       另印部署軌道的通聯幾何(週期、地平半角、最大斜距)。
#       **首次通聯時刻還沒做**(要繞地球自轉推算,那是 Week 14 的工作)——
#       抬頭原本寫「算首次地面站可見」,程式裡從來沒有,已改成這一句。
#   (B) STK/Aviator + Astrogator = 正牌:發射載具軌跡 trade study
#       (對照 LaunchVehicleTradeStudiesAviator.py 的三重迴圈),與
#       彈道發射軌跡(對照 TASA notebook 的 ansys.stk.core 寫法)。
#
# 輸出:week15_launch.png + 主控台 trade study 表
# ================================================================

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

# 輸出要落在週次目錄,不是 process 的工作目錄 ——
# week15_verify.py 開頭就註明 STK Engine 會改 CWD。
import os
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))

MU = 398600.4418; RE = 6378.137; G0 = 9.80665

# ---- 目標軌道所需速度(含重力/阻力損失粗估)----
REF_ALT_KM = 300.0   # 損失預算的參考高度:2.1 引用的 9.3-9.5 km/s 對應的是低 LEO

# 發射場緯度。與 week15_launch_aviator.py 的 LAUNCH_LAT 同一個地方(台南示範),
# 兩條路才對得起來。
LAUNCH_LAT_DEG = 22.111607

# 赤道自轉速度:一個恆星日 86,164.0905 s 轉一圈,不是 86,400。
V_EQUATOR_MS = 2*np.pi*RE*1000/86164.0905          # 465.10 m/s

# 損失**明細**,對應核心觀念 2.1 那張表的三個量級區間。
# 舊版只有一個寫死的 1600,所以 2.1 裡「自轉助益 -0.4 km/s」和 2.4 整節
# 在講的方位角,在程式裡都不存在 —— 教材說是主要的 trade,模型卻表達不了。
LOSS_GRAVITY_MS = 1450.0     # 2.1 表:1.0-1.5 km/s
LOSS_DRAG_MS = 300.0         # 2.1 表:0.1-0.3 km/s
LOSS_STEERING_MS = 280.0     # 2.1 表:0.1-0.3 km/s
# 三項相加 2,030;在參考條件(台南向東)扣掉自轉助益 431 之後淨 1,599 m/s,
# 與舊版寫死的 1,600 幾乎一樣 —— 這個重構**不動既有的值**,
# 新增的是「緯度與方位角現在有後果」。


def inclination_from_azimuth(lat_deg=LAUNCH_LAT_DEG, azimuth_deg=90.0):
    """直接入軌能拿到的傾角(deg)。核心觀念 2.4:`cos i = cos φ · sin β`。

    方位角 180-360 度時 sin 為負,傾角大於 90 度 —— 那就是逆行軌道。
    向東(β = 90)給**最低**的傾角,而且它剛好等於發射場緯度 ——
    這就是 2.4 說的「傾角不可能小於發射場緯度」。
    """
    c = np.cos(np.radians(lat_deg))*np.sin(np.radians(azimuth_deg))
    return np.degrees(np.arccos(np.clip(c, -1.0, 1.0)))


# ---- CDS 的部署後禁令。**這是兩個不同的時鐘,不是一個。** ----
# 核心觀念 2.6 原本寫「部署後一段時間內(通常 30 分鐘)不得供電或展開」,
# 把兩件事併成一件,而且把「不得發射」寫成「不得供電」。CDS Rev 13:
#   展開物(天線、太陽板、伸桿)自**部署開關致動**起至少等 30 分鐘;
#   訊號則是從**整合進 P-POD 那一刻**起,到部署後 45 分鐘內,
#   不得產生或發射任何訊號 —— 管的是發射,不是通電。
# 差別會直接動到 2.6 自己說「LEOP 最關鍵」的那個數字:
# 首次可通聯不是第一次幾何過境,而是 45 分鐘之後的第一次。
CDS_DEPLOYABLES_MIN = 30.0   # 展開物,自部署開關致動起
CDS_RF_SILENCE_MIN = 45.0    # 不得產生/發射訊號,自部署起


def leop_inhibit(period_min):
    """把兩個禁令時鐘換算成圈數。回傳 dict。

    圈數才是有感的單位:45 分鐘在 550 km 是將近半圈,
    表示部署後的第一次幾何過境很可能**根本不能用**。
    """
    return {"deployables_min": CDS_DEPLOYABLES_MIN,
            "deployables_rev": CDS_DEPLOYABLES_MIN/period_min,
            "rf_silence_min": CDS_RF_SILENCE_MIN,
            "rf_silence_rev": CDS_RF_SILENCE_MIN/period_min}


def rotation_credit(lat_deg=LAUNCH_LAT_DEG, azimuth_deg=90.0):
    """地球自轉的助益(m/s)。向東為正,向西為負,極軌為零。

    助益 = 赤道自轉速度 x cos(緯度) x sin(方位角)。
    cos(緯度) 就是核心觀念 2.1 說的「隨緯度下降」,
    sin(方位角) 就是 2.4 說的「向東拿最多、極軌拿不到」。
    """
    return (V_EQUATOR_MS*np.cos(np.radians(lat_deg))
            * np.sin(np.radians(azimuth_deg)))


def hohmann_dv(alt1_km, alt2_km):
    """兩個圓軌道之間的兩脈衝轉移 delta-V(m/s)。alt2 低於 alt1 時為負。"""
    r1, r2 = RE + alt1_km, RE + alt2_km
    a = 0.5*(r1 + r2)
    dv1 = np.sqrt(MU*(2/r1 - 1/a)) - np.sqrt(MU/r1)    # 離開低軌
    dv2 = np.sqrt(MU/r2) - np.sqrt(MU*(2/r2 - 1/a))    # 到了再圓化
    return (dv1 + dv2)*1000


def dv_to_orbit(alt_km, lat_deg=LAUNCH_LAT_DEG, azimuth_deg=90.0,
                losses_ms=None):
    """到圓軌道所需 delta-V(m/s)= 參考高度的入軌需求 + 轉移上去的代價。

    舊版寫成 `v_orb(alt) + (1600 + 2.2*(alt-300))`,**那條線性斜率是編的**。
    拿「300 km 停泊 + 兩脈衝轉移」當獨立對照,舊版在 550 km 多收 269 m/s、
    1200 km 多收 1,034 m/s —— 正確的高度代價是**次線性**的,
    因為轉移 delta-V 隨 r 收斂。舊版還讓 550 km 給出 9,735 m/s,
    落在核心觀念 2.1 自己說的 9.3-9.5 km/s **之外**,
    而驗證段那條 9000~10500 又寬到抓不到。

    損失由 2.1 那張表的三個分項加總,再扣掉**隨緯度與方位角變化**的
    自轉助益。`losses_ms` 若給了就直接當淨損失用(覆寫明細),
    給 None(預設)就照明細算。

    預設是**台南向東**(方位 90°),那是這個發射場拿得到最多助益的方向。
    極軌(方位 0 或 180)拿不到助益,要多付 431 m/s;
    逆行(方位 270)還要倒貼同樣多,共 862 m/s。
    """
    if losses_ms is None:
        losses_ms = (LOSS_GRAVITY_MS + LOSS_DRAG_MS + LOSS_STEERING_MS
                     - rotation_credit(lat_deg, azimuth_deg))
    v_ref = np.sqrt(MU/(RE + REF_ALT_KM))*1000
    return v_ref + losses_ms + hohmann_dv(REF_ALT_KM, alt_km)

# ---- 兩節火箭酬載能力(火箭方程反解)----
def payload_capacity(dv_req, stages):
    """給總需求 delta-V 與各節 (Isp, 結構, 推進劑質量),回可送酬載(kg)。

    stages: list of dict,由下而上。每一節要 isp、mp,結構質量二選一:
        ms  = 乾結構質量(kg)
        eps = 乾結構質量 /(乾結構 + 推進劑),換算 ms = eps/(1-eps)*mp
    (舊版 docstring 寫 eps、程式卻只讀 ms,eps 是死參數;現在兩種都吃。)"""
    def stage_ms(st):
        if "ms" in st:
            return st["ms"]
        e = st["eps"]
        return e/(1.0 - e)*st["mp"]
    # 由上而下分配 delta-V 不易封閉,這裡用「給定各節質量 -> 算總 delta-V -> 反推酬載」
    # 簡化:固定各節推進劑與結構,掃描酬載使總 delta-V 剛好達 dv_req。
    def total_dv(m_payload):
        m_upper = m_payload
        dv = 0.0
        for st in reversed(stages):   # 由上節往下累積
            ms = stage_ms(st)
            m0 = m_upper + st["mp"] + ms           # 該節點火初質量
            mf = m_upper + ms                      # 燒完質量
            dv += st["isp"]*G0*np.log(m0/mf)
            m_upper = m0                            # 下一節要抬起整節
        return dv
    # 二分搜尋酬載。**上界不能寫死** —— 寫死的話酬載一旦超過上界就會
    # 「靜默飽和」,回一個剛好等於上界的值,看起來像答案。先撐到真的不可行。
    lo = 0.0
    if total_dv(lo) < dv_req: return 0.0
    hi = 1000.0
    for _ in range(40):
        if total_dv(hi) < dv_req: break
        hi *= 2.0
    else:
        raise ValueError("酬載上界撐不開;檢查 dv_req 與各節質量")
    for _ in range(80):
        mid = 0.5*(lo+hi)
        if total_dv(mid) >= dv_req: lo = mid
        else: hi = mid
    return lo

# ---- 分節 trade:總推進劑固定,分配給兩節 ----
def staging_trade(dv_req, stages, share=None):
    """把**固定的總推進劑**分配給兩節,回掃描結果。

    守住的量是總推進劑;各節結構比 eps 由 stages 的基準值取出後固定,
    所以結構質量跟著推進劑走(eps1 = eps2 時載具總重也整個固定)。
    這是分節最佳化真正的問法 —— 舊版讓推進劑、結構比、載具總重三件事
    同時動,曲線單調上升,argmax 只會落在掃描上界。

    回傳 dict:share / payload / best_i / at_edge / best_share /
    best_payload / band(平原)/ mp_total / eps / base_share。
    """
    mp_total = stages[0]["mp"] + stages[1]["mp"]
    eps = [st["ms"]/(st["ms"] + st["mp"]) for st in stages]
    if share is None:
        share = np.linspace(0.05, 0.45, 81)
    pl = []
    for f2 in share:
        two = [{"isp": stages[0]["isp"], "eps": eps[0], "mp": mp_total*(1 - f2)},
               {"isp": stages[1]["isp"], "eps": eps[1], "mp": mp_total*f2}]
        pl.append(payload_capacity(dv_req, two))
    pl = np.array(pl)
    bi = int(np.argmax(pl))
    # 平原:距峰值 1% 以內的佔比區間(核心觀念 2.7 要 trade study 回答的三件事之一)
    band = share[pl >= pl[bi]*0.99]
    return {"share": share, "payload": pl, "best_i": bi,
            # argmax 落在掃描端點就不是最佳值,是掃描範圍不夠
            "at_edge": bi in (0, len(share) - 1),
            "best_share": float(share[bi]), "best_payload": float(pl[bi]),
            "band": (float(band[0]), float(band[-1])),
            "mp_total": float(mp_total), "eps": eps,
            "base_share": float(stages[1]["mp"]/mp_total)}


def main():
    target_alt = 550.0
    dv_req = dv_to_orbit(target_alt)
    print(f"目標軌道 {target_alt:.0f} km 所需 delta-V(含損失): {dv_req:.0f} m/s\n")

    # 基準兩節火箭(小型運載,示範量級)
    base = [
        {"isp": 280, "ms": 3000,  "mp": 90000},   # 第一節
        {"isp": 320, "ms": 500,   "mp": 15000},   # 第二節
    ]
    pl0 = payload_capacity(dv_req, base)
    print(f"基準兩節火箭可送酬載: {pl0:.0f} kg 到 {target_alt:.0f} km")

    # --- Trade study 1:分節 —— **總推進劑固定**,分配給兩節 ---
    # 舊版是「第一節不動、第二節推進劑由 8 t 加到 25 t」,那不是分節最佳化:
    # 推進劑長大而 ms2 釘在 500 kg,eps2 會從 0.059 一路「改善」到 0.020,
    # 載具總重也跟著漲 —— 三件事同時在動,曲線因此單調上升到底,
    # argmax 落在掃描上界 25 t 卻被印成「最佳」。
    # 官方參考檔 LaunchVehicleTradeStudiesAviator.py 的問法是對的:
    # 它把第二節總重守住,推進劑與酬載一比一對換(emptyWeight2 +1000 /
    # fuelState2 -1000),再看近地點撐不撐得住 100 km。
    # **守住一個量、交換另一個**,才是 trade。
    tr = staging_trade(dv_req, base)
    share, pls, best_i = tr["share"], tr["payload"], tr["best_i"]
    at_edge, band = tr["at_edge"], tr["band"]
    mp_total, eps, base_share = tr["mp_total"], tr["eps"], tr["base_share"]
    print(f"分節 trade(總推進劑固定 {mp_total/1000:.0f} t,"
          f"eps1 {eps[0]:.4f} / eps2 {eps[1]:.4f} 固定):")
    if at_edge:
        print(f"  **argmax 落在掃描端點 {share[best_i]*100:.1f}% —— 這不是最佳值,"
              f"是掃描範圍不夠,請放寬 share**")
    else:
        print(f"  最佳二節佔比 {share[best_i]*100:.1f}%"
              f"(推進劑 {mp_total*share[best_i]/1000:.1f} t)"
              f" -> 酬載 {pls[best_i]:.0f} kg")
    print(f"  平原(距峰值 1% 內)佔比 {band[0]*100:.0f}% ~ {band[1]*100:.0f}%"
          f" —— 這是製造誤差的容忍區間")
    print(f"  基準佔比 {base_share*100:.1f}% -> {pl0:.0f} kg,"
          f"改到最佳點 {pls[best_i] - pl0:+.0f} kg")

    # --- Trade study 2:酬載 vs 目標高度 ---
    alts = np.linspace(300, 1200, 40)
    pl_vs_alt = [payload_capacity(dv_to_orbit(a), base) for a in alts]

    # --- 方位角與緯度的後果(核心觀念 2.1 的自轉助益、2.4 的方位角)---
    print("")
    print(f"發射方位與緯度的後果(目標 {target_alt:.0f} km、基準分節):")
    print("   情境                    方位   自轉助益     所需 dv     酬載   vs 向東")
    ref_pl = payload_capacity(dv_to_orbit(target_alt), base)
    for label, lat, az in (("台南 向東(最佳)", LAUNCH_LAT_DEG, 90),
                           ("台南 東北 45°", LAUNCH_LAT_DEG, 45),
                           ("台南 極軌(向北)", LAUNCH_LAT_DEG, 0),
                           ("台南 逆行(向西)", LAUNCH_LAT_DEG, 270),
                           ("赤道 向東", 0.0, 90)):
        d = dv_to_orbit(target_alt, lat, az)
        pl = payload_capacity(d, base)
        print(f"   {label:<22} {az:>4.0f}  {rotation_credit(lat, az):>8.1f}"
              f"  {d:>9.1f}  {pl:>7.0f}  {pl-ref_pl:>+7.0f} kg")
    polar = payload_capacity(dv_to_orbit(target_alt, LAUNCH_LAT_DEG, 0), base)
    print(f"   -> 2.4 說『極軌拿不到自轉助益,運力下降』:"
          f"少 {ref_pl-polar:.0f} kg({(ref_pl-polar)/ref_pl*100:.1f}%)")

    # --- 部署後首次通聯(簡化:圓軌道 + 地面站,幾何可見)---
    r = RE + target_alt
    # 地平線可見的最大地心角
    lam = np.degrees(np.arccos(RE/r))
    horizon_range = np.sqrt(r**2 - RE**2)
    period = 2*np.pi*np.sqrt(r**3/MU)/60
    print(f"\n部署後 {target_alt:.0f} km 圓軌道:週期 {period:.1f} min,"
          f"地平可見地心半角 {lam:.1f} deg,最大通聯斜距 {horizon_range:.0f} km")
    inh = leop_inhibit(period)
    print(f"CDS 部署後禁令(**兩個時鐘,不是一個**):")
    print(f"   展開物      >= {inh['deployables_min']:.0f} min"
          f"({inh['deployables_rev']:.2f} 圈)  自部署開關致動起")
    print(f"   不得發射訊號 >= {inh['rf_silence_min']:.0f} min"
          f"({inh['rf_silence_rev']:.2f} 圈)  自部署起;管的是發射,不是通電")
    print(f"   -> 首次可通聯不是第一次幾何過境,是 {inh['rf_silence_min']:.0f} min "
          f"之後的第一次(過境排程見 Week 14)")

    # --- 圖 ---
    fig, ax = plt.subplots(1, 3, figsize=(14, 4.5))

    ax[0].plot(share*100, pls, color="tab:blue", lw=2)
    ax[0].axvspan(band[0]*100, band[1]*100, color="tab:blue", alpha=0.12,
                  label=f"plateau {band[0]*100:.0f}-{band[1]*100:.0f}% (within 1%)")
    ax[0].axvline(share[best_i]*100, color="red", ls="--",
                  label=f"opt {share[best_i]*100:.1f}%")
    ax[0].axvline(base_share*100, color="gray", ls=":",
                  label=f"baseline {base_share*100:.1f}%")
    ax[0].set_xlabel("stage-2 share of total propellant (%)")
    ax[0].set_ylabel("payload to orbit (kg)")
    ax[0].set_title(f"Trade: staging split ({mp_total/1000:.0f} t propellant fixed)")
    ax[0].legend(fontsize=7); ax[0].grid(alpha=0.3)

    ax[1].plot(alts, pl_vs_alt, color="tab:green", lw=2)
    ax[1].axvline(target_alt, color="gray", ls=":")
    ax[1].set_xlabel("target altitude (km)"); ax[1].set_ylabel("payload (kg)")
    ax[1].set_title("Trade: altitude vs payload"); ax[1].grid(alpha=0.3)

    # 酬載 vs 發射方位。**這張圖把核心觀念 2.4 的兩半放在一起**:
    # 同一個方位角,左軸是它要付的運力、右軸是它換到的傾角。
    # (原本這一格畫的是一個地球加一個地面站三角形,沒有可見錐、
    #  沒有通聯時刻,除了「軌道比地球大一點」之外沒有資訊。)
    az = np.linspace(0, 360, 361)
    pl_az = [payload_capacity(dv_to_orbit(target_alt, LAUNCH_LAT_DEG, a), base)
             for a in az]
    inc_az = inclination_from_azimuth(LAUNCH_LAT_DEG, az)
    ax[2].plot(az, pl_az, color="tab:red", lw=2)
    ax[2].set_xlabel(f"launch azimuth from {LAUNCH_LAT_DEG:.1f}"
                     f"\u00b0N (deg, 0 = north)")
    ax[2].set_ylabel("payload (kg)", color="tab:red")
    ax[2].tick_params(axis="y", labelcolor="tab:red")
    # \u65b9\u4f4d\u89d2\u76f4\u63a5\u6a19\u6210\u7f85\u76e4\u65b9\u5411,\u7701\u6389\u518d\u89e3\u91cb\u4e00\u6b21 E/S/W
    ax[2].set_xticks([0, 90, 180, 270, 360])
    ax[2].set_xticklabels(["0\nN", "90\nE", "180\nS", "270\nW", "360\nN"])
    for a in (90, 180, 270):
        ax[2].axvline(a, color="gray", ls=":", lw=0.8)
    ax[2].grid(alpha=0.3, axis="y")
    ax[2].plot([90], [max(pl_az)], "o", color="tab:red", ms=5)
    ax2b = ax[2].twinx()
    ax2b.plot(az, inc_az, color="tab:blue", lw=1.2, ls="--")
    ax2b.set_ylabel("inclination (deg)", color="tab:blue")
    ax2b.tick_params(axis="y", labelcolor="tab:blue")
    ax2b.axhline(LAUNCH_LAT_DEG, color="tab:blue", ls=":", lw=0.8)
    # \u5169\u689d\u8a3b\u89e3\u5408\u6210\u4e00\u500b\u6587\u5b57\u6846\u3002\u4f4d\u7f6e\u653e\u5728**\u65b9\u4f4d 200-340 \u5ea6\u3001\u7e31\u5411\u4e2d\u6bb5**\u90a3\u584a\u7a7a\u6a94:
    # \u7d05\u7dda\u5728\u90a3\u88e1\u58d3\u4f4e\u3001\u85cd\u7dda\u62ac\u9ad8,\u4e2d\u9593\u8b93\u51fa\u4e00\u5927\u7247\u3002
    # (\u7b2c\u4e00\u7248\u7528\u5169\u500b\u7bad\u982d,\u6587\u5b57\u58d3\u5728\u66f2\u7dda\u4e0a;\u7b2c\u4e8c\u7248\u79fb\u5230\u5de6\u4e0b\u89d2,
    #  \u767d\u5e95\u53c8\u6b63\u597d\u84cb\u4f4f\u85cd\u7dda\u5728\u6b63\u6771\u7684\u6700\u4f4e\u9ede \u2014\u2014 \u90a3\u6b63\u662f\u8981\u770b\u7684\u5730\u65b9\u3002)
    ax[2].text(0.07, 0.36,
               f"east   {max(pl_az):.0f} kg   i {min(inc_az):.1f}\u00b0\n"
               f"polar  {pl_az[0]:.0f} kg   i 90\u00b0",
               transform=ax[2].transAxes, fontsize=7, va="bottom",
               family="monospace",
               bbox=dict(boxstyle="round,pad=0.35", fc="white", ec="0.7", lw=0.6))
    ax[2].set_title("Trade: azimuth vs payload and inclination")

    fig.suptitle("Week 15 - Launch & Deployment trade study", fontsize=13)
    fig.tight_layout(rect=[0,0,1,0.95])
    fig.savefig(os.path.join(HERE, "week15_launch.png"), dpi=130)
    print("\nWrote week15_launch.png")

# ================================================================
# STK 正牌路徑
# ================================================================
# (B1) Aviator 兩節發射 trade study 骨架(對照 LaunchVehicleTradeStudiesAviator.py)
#   for fuelState in ...:            # 掃第二節燃料
#     for launchAz in ...:           # 掃發射方位
#       for FPADot in ...:           # 掃 pitch 程式 FPA rate
#         root.ExecuteCommand(f'SetState ...')  # 或設 Aviator 參數後 Propagate
#         dp = sat.DataProviders.Item('Classical Elements').Group.Item('ICRF')\
#                 .Exec(scenario.StartTime, scenario.StopTime, 60)
#         apogee   = dp.DataSets.GetDataSetByName('Apogee Altitude').GetValues()[0]
#         perigee  = dp.DataSets.GetDataSetByName('Perigee Altitude').GetValues()[0]
#         inc      = dp.DataSets.GetDataSetByName('Inclination').GetValues()[0]
#         valid    = perigee > 100     # 有效入軌判據
#
# (B2) 彈道發射軌跡(對照 TASA notebook,ansys.stk.core 新版 API):
TASA_LAUNCH_SNIPPET = '''
from ansys.stk.core.stkobjects import STKObjectType, PropagatorType, VehicleLaunchControl
missile = root.current_scenario.children.new(STKObjectType.MISSILE, "LaunchVehicle")
missile.set_trajectory_type(PropagatorType.BALLISTIC)
traj = missile.trajectory
traj.launch.latitude  = 22.111607     # 台南發射場示範
traj.launch.longitude = 120.887016
traj.impact_location.impact.latitude  = 21
traj.impact_location.impact.longitude = 124
traj.impact_location.set_launch_control_type(VehicleLaunchControl.FIXED_APOGEE_ALTITUDE)
traj.impact_location.launch_control.apogee_altitude = 307   # km
traj.propagate()
# 匯出星曆/姿態供後續分析
missile.export_tools.get_ephemeris_stk_export_tool().export("C:/temp/LV_ephemeris.e")
'''

if __name__ == "__main__":
    main()
week15_launch/week15_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# Week 15 驗證段 — 火箭方程與入軌判定的基準結果
#
# Python 這一側是 Aviator trade study 的量級驗證。它自己的數字先站得住,
# 才能拿去判斷 Aviator 掃描出來的酬載量是否合理。
#
# 執行:python week15_verify.py
#   離開碼 0 = 全部通過,1 = 有項目對不上
# ================================================================

import os
import sys

# notebook 的 kernel 沒有 __file__。而且啟動 STK Engine 會改變 process 的
# 工作目錄,所以不能事後才靠 os.getcwd() —— notebook 開頭的 bootstrap cell
# 會先把週次目錄釘在 WEEK_DIR。
HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
        else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
sys.path.insert(0, os.path.join(REPO, "tools"))

from stk_verify import Verifier, load_lab      # noqa: E402


def main():
    m = load_lab(os.path.join(HERE, "week15_launch_deploy.py"))
    v = Verifier(None)

    dv = m.dv_to_orbit(550.0)
    v.expect("550 km 所需 delta-V(含損失)", dv, 9466, tol=50, unit=" m/s")
    # ---- 自轉助益:核心觀念 2.1 記 -0.4 km/s、2.4 整節在講方位角,
    # 但舊模型的簽章裡**既沒有緯度也沒有方位角**,表達不了它自己說的主要 trade。
    v.expect("赤道自轉速度(恆星日,不是 86400 s)", m.V_EQUATOR_MS, 465.1,
             tol=0.1, unit=" m/s")
    v.expect("台南向東的自轉助益", m.rotation_credit(m.LAUNCH_LAT_DEG, 90),
             430.9, tol=1.0, unit=" m/s")
    v.expect_true("極軌拿不到自轉助益(sin 0 = 0)",
                  abs(m.rotation_credit(m.LAUNCH_LAT_DEG, 0)) < 1e-9,
                  f"{m.rotation_credit(m.LAUNCH_LAT_DEG, 0):.2e} m/s")
    v.expect_true("逆行要倒貼,且幅度等於向東的助益",
                  abs(m.rotation_credit(m.LAUNCH_LAT_DEG, 270)
                      + m.rotation_credit(m.LAUNCH_LAT_DEG, 90)) < 1e-9,
                  f"{m.rotation_credit(m.LAUNCH_LAT_DEG, 270):.1f} m/s")
    v.expect_true("助益隨緯度下降(2.1:正比於 cos 緯度)",
                  m.rotation_credit(0.0, 90) > m.rotation_credit(28.5, 90) > 0,
                  f"赤道 {m.rotation_credit(0.0, 90):.1f} > "
                  f"卡納維爾角 {m.rotation_credit(28.5, 90):.1f} m/s")
    # 損失明細要落在 2.1 那張表自己給的量級區間裡
    v.expect_between("重力損失在 2.1 表的 1.0-1.5 km/s 內",
                     m.LOSS_GRAVITY_MS, 1000, 1500, unit=" m/s")
    v.expect_between("阻力損失在 2.1 表的 0.1-0.3 km/s 內",
                     m.LOSS_DRAG_MS, 100, 300, unit=" m/s")
    v.expect_between("轉向損失在 2.1 表的 0.1-0.3 km/s 內",
                     m.LOSS_STEERING_MS, 100, 300, unit=" m/s")
    # 極軌的代價:2.4 說「運力下降」,現在要算得出來
    _base = [{"isp": 280, "ms": 3000, "mp": 90000},
             {"isp": 320, "ms": 500,  "mp": 15000}]
    _east = m.payload_capacity(m.dv_to_orbit(550.0, m.LAUNCH_LAT_DEG, 90), _base)
    _polar = m.payload_capacity(m.dv_to_orbit(550.0, m.LAUNCH_LAT_DEG, 0), _base)
    v.expect("極軌相對向東的酬載損失", _east - _polar, 532, tol=15, unit=" kg")
    # ---- 第三張圖畫的是傾角與運力的取捨,公式要守住(核心觀念 2.4)----
    v.expect("向東入軌的傾角 = 發射場緯度(2.4:傾角不可能更低)",
             m.inclination_from_azimuth(m.LAUNCH_LAT_DEG, 90),
             m.LAUNCH_LAT_DEG, tol=1e-9, unit=" deg")
    v.expect_true("向東是傾角的最小值(掃 0-360 度)",
                  min(m.inclination_from_azimuth(m.LAUNCH_LAT_DEG, a)
                      for a in range(0, 361))
                  >= m.LAUNCH_LAT_DEG - 1e-9,
                  f"最小 {min(m.inclination_from_azimuth(m.LAUNCH_LAT_DEG, a) for a in range(0, 361)):.4f} deg")
    v.expect("正北發射是極軌", m.inclination_from_azimuth(m.LAUNCH_LAT_DEG, 0),
             90.0, tol=1e-9, unit=" deg")
    v.expect_true("向西是逆行(傾角 > 90 度)",
                  m.inclination_from_azimuth(m.LAUNCH_LAT_DEG, 270) > 90,
                  f"{m.inclination_from_azimuth(m.LAUNCH_LAT_DEG, 270):.2f} deg")
    # 運力的峰值要落在正東 —— 這是第三張圖上那個註解
    _az = range(0, 361)
    _pl = [m.payload_capacity(m.dv_to_orbit(550.0, m.LAUNCH_LAT_DEG, a), _base)
           for a in _az]
    v.expect("酬載峰值的方位角", float(max(_az, key=lambda a: _pl[a])), 90.0,
             tol=0.5, unit=" deg")
    # **這一項才是會抓到矛盾的那一項。** 舊版只檢查 9000~10500,寬到
    # 容得下 9,735 —— 而核心觀念 2.1 自己寫的是 9.3-9.5 km/s。
    v.expect_between("delta-V 落在核心觀念 2.1 自己說的 9.3-9.5 km/s",
                     dv, 9300, 9500, unit=" m/s")
    # 高度代價是次線性的(轉移 delta-V 隨 r 收斂)。舊版用線性斜率,
    # 等高度間距的增量會一樣大,到 1200 km 多收 1,034 m/s。
    d_lo = m.dv_to_orbit(800) - m.dv_to_orbit(400)
    d_hi = m.dv_to_orbit(1200) - m.dv_to_orbit(800)
    v.expect_true("高度代價次線性:等高度間距的增量要遞減", d_hi < d_lo,
                  f"400->800 {d_lo:.0f} m/s,800->1200 {d_hi:.0f} m/s")

    # ---- 分節 trade:守住總推進劑,才問得出最佳分配 ----
    # 舊版 trade study 讓推進劑、結構比、載具總重三件事同時動,曲線單調
    # 上升到底,argmax 落在掃描上界 25 t 卻被印成「最佳」,圖上還畫了紅線。
    # 下面每一項都是為了讓那種錯下次會被抓到。
    base = [{"isp": 280, "ms": 3000, "mp": 90000},
            {"isp": 320, "ms": 500,  "mp": 15000}]
    tr = m.staging_trade(dv, base)
    v.expect_true("分節最佳點是內點,不是掃描端點", not tr["at_edge"],
                  f"最佳佔比 {tr['best_share']*100:.1f}%")
    v.expect("最佳二節推進劑佔比", tr["best_share"]*100, 24.0, tol=1.0, unit=" %")
    v.expect("最佳點酬載", tr["best_payload"], 3463, tol=20, unit=" kg")
    v.expect("掃描守住的量:總推進劑", tr["mp_total"], 105000, tol=1, unit=" kg")
    v.expect_true("平原有寬度(核心觀念 2.7:trade study 要答得出容忍度)",
                  tr["band"][1] - tr["band"][0] > 0.05,
                  f"{tr['band'][0]*100:.0f}% ~ {tr['band'][1]*100:.0f}%")
    v.expect_true("最佳分配優於基準分配",
                  tr["best_payload"] > m.payload_capacity(dv, base),
                  f"{tr['best_payload'] - m.payload_capacity(dv, base):+.0f} kg")

    # ---- 酬載二分搜尋:上界寫死會靜默飽和,回一個剛好等於上界的假答案 ----
    big = m.payload_capacity(dv, [{"isp": 280, "ms": 3000, "mp": 300000},
                                  {"isp": 320, "ms": 500,  "mp": 40000}])
    v.expect_true("酬載二分上界會自己撐開(舊版寫死 5000)", big > 7500,
                  f"{big:.0f} kg")

    # ---- docstring 說吃 eps、程式卻只讀 ms,是原本的死參數 ----
    v.expect("eps 記法與 ms 記法給同一個答案",
             m.payload_capacity(dv, [{"isp": 280, "eps": 3000/93000, "mp": 90000},
                                     {"isp": 320, "eps": 500/15500,  "mp": 15000}]),
             m.payload_capacity(dv, base), tol=1e-6, unit=" kg")

    # ---- 主軌檔:這一組守門抓的是「連 import 都過不了」那一類 ----
    # 舊版寫 `from ansys.stk.core.stkobjects import VehiclePropagatorType`,
    # 那個名字不存在,擋在載入期。沒有任何檢查在看這件事,所以它擺了很久。
    # 這一組不需要啟動 STK Engine,只是把名字解析一次。
    import importlib.util as _ilu
    av_path = os.path.join(HERE, "week15_launch_aviator.py")
    if _ilu.find_spec("ansys.stk.core") is None:
        v.expect_true("主軌檔 import 得起來", None, "本機沒有 ansys.stk.core")
    else:
        try:
            load_lab(av_path)
            imported, why = True, "ansys.stk.core 的名字全部解析得到"
        except Exception as exc:                       # noqa: BLE001
            imported, why = False, f"{type(exc).__name__}: {exc}"
        v.expect_true("主軌檔 import 得起來(API 名稱全部存在)", imported, why)

    # 舊版三行 Connect 字串字面上就有 `...`,Connect 收到會直接失敗。
    # 用 AST 只看真正的呼叫。改用 grep 的話會抓到說明文件裡
    # **描述那個錯誤**的句子,變成自己誤報自己。
    import ast as _ast
    src = open(av_path, encoding="utf-8").read()
    bad = []
    for node in _ast.walk(_ast.parse(src)):
        if not isinstance(node, _ast.Call):
            continue
        fn = node.func
        if getattr(fn, "attr", None) != "execute_command":
            continue
        seg = _ast.get_source_segment(src, node) or ""
        if "..." in seg:
            bad.append(" ".join(seg.split())[:60])
    v.expect_true("Connect 呼叫裡沒有字面上的 ...", not bad,
                  bad[0] if bad else "0 處")

    # 分節 trade 的守恆:乾結構 + 酬載 + 燃料 = 第二節總重,掃描中不變
    av = load_lab(av_path)
    for i in range(3):
        pl = av.PAYLOAD_INIT + av.PAYLOAD_STEP*i
        v.expect(f"第二節總重守恆(酬載 {pl:.0f} lb)",
                 av.STAGE2_DRY + pl + (av.STAGE2_GROSS - av.STAGE2_DRY - pl),
                 av.STAGE2_GROSS, tol=1e-6, unit=" lb")
    # 實測交越點在 30,000-40,000 lb 之間,預設三格要跨得過去 ——
    # 掃不到邊界的 trade study 等於沒掃(核心觀念 2.7)
    v.expect_true("預設酬載掃描跨得過實測交越點(30,000-40,000 lb)",
                  av.PAYLOAD_INIT <= 30000
                  and av.PAYLOAD_INIT + 2*av.PAYLOAD_STEP >= 40000,
                  f"{av.PAYLOAD_INIT:.0f} -> "
                  f"{av.PAYLOAD_INIT + 2*av.PAYLOAD_STEP:.0f} lb")

    # ---- CDS 的兩個時鐘:守門就是不准它們再被併成一個 ----
    v.expect("CDS 展開物禁令", m.CDS_DEPLOYABLES_MIN, 30.0, tol=1e-9, unit=" min")
    v.expect("CDS 訊號靜默", m.CDS_RF_SILENCE_MIN, 45.0, tol=1e-9, unit=" min")
    v.expect_true("兩個時鐘不相等(2.6 原本把它們併成一個)",
                  m.CDS_DEPLOYABLES_MIN != m.CDS_RF_SILENCE_MIN,
                  f"{m.CDS_DEPLOYABLES_MIN:.0f} vs {m.CDS_RF_SILENCE_MIN:.0f} min")
    v.expect_true("訊號靜默比展開物久(順序不能顛倒)",
                  m.CDS_RF_SILENCE_MIN > m.CDS_DEPLOYABLES_MIN,
                  f"{m.CDS_RF_SILENCE_MIN - m.CDS_DEPLOYABLES_MIN:.0f} min 之差")
    _T = 2*3.141592653589793*((6378.137 + 550.0)**3/398600.4418)**0.5/60
    _inh = m.leop_inhibit(_T)
    v.expect("550 km 的訊號靜默換算圈數", _inh["rf_silence_rev"], 0.47,
             tol=0.02, unit=" 圈")
    v.expect_true("靜默將近半圈,所以第一次幾何過境很可能不能用",
                  0.3 < _inh["rf_silence_rev"] < 0.7,
                  f"{_inh['rf_silence_rev']:.2f} 圈 @ 週期 {_T:.1f} min")

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


if __name__ == "__main__":
    _rc = main()
    if "ipykernel" not in sys.modules:
        raise SystemExit(_rc)