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)
火箭不會急轉彎(氣動負荷會拆掉它)。標準的上升軌跡是:
- 垂直起飛,快速離開稠密大氣
- 俯仰程序 (pitch kick):輕微傾斜,讓重力開始把速度向量往水平轉
- 重力轉向:讓重力自然完成轉向,箭體保持零攻角(氣動負荷最小)
- 入軌燃燒:接近水平,把速度推到軌道速度
Aviator 裡的 FPA rate(飛行路徑角速率) 就是控制這個轉向快慢的參數。
- 轉太快 → 過早水平 → 高度不夠,近地點掉進大氣層
- 轉太慢 → 垂直爬太久 → 重力損失大,燃料不夠
存在一個最佳值,而且這個最佳值跟燃料量耦合。 這就是本週要掃描的原因。
2.4 發射方位角決定傾角
cos(i) = cos(φ) * sin(β)
φ發射場緯度、β發射方位角(從正北順時針)
兩個重要推論:
- 傾角不可能小於發射場緯度(除非在太空中改變傾角,那非常貴)。
從緯度 28.5° 的卡納維爾角,最低只能直接入軌到 i = 28.5°。 - 向東發射 (β = 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) 與分離
- 分離的初始角速度:dispenser 的彈簧與釋放不對稱會給衛星一個翻滾
(典型 5–20 °/s)—— 這就是 Week 8 消旋的初始條件。 - 分離後的禁令:CDS 規定部署後一段時間內(通常 30 分鐘)不得供電或展開,
避免干擾主酬載與其他共乘者。 - 首次通聯:部署時刻與地面站幾何決定了第一次能聯絡上的時間。
這是 LEOP 最關鍵的數字,要事先算好(接回 Week 14)。
2.7 為什麼要做 trade study 而不是單點分析
單點分析回答「這個設計行不行」;trade study 回答「設計空間長什麼樣」。
後者才能告訴你:
- 邊界在哪裡(燃料再少 5% 會不會失敗)
- 哪個參數最敏感(值得投資改善)
- 有沒有平原(製造誤差能不能容忍)
本週的 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. 常見誤解
- 「入軌需要 7.8 km/s。」 — 那是軌道速度。實際發射需求約 9.3–9.5 km/s,
差額是重力、阻力與轉向損失。 - 「傾角可以之後再改。」 — 改 1° 要約 133 m/s,遠超過 CubeSat 的推進能力。
傾角在發射時就決定了。 - 「向哪個方向發射都一樣。」 — 向東可拿到最多 0.4 km/s 的地球自轉助益,
極軌完全拿不到。 - 「FPA rate 愈小愈好(爬得高)。」 — 爬太久重力損失大。存在最佳值,而且與燃料量耦合。
- 「軌道是我們選的。」 — Rideshare 的軌道由主酬載決定。設計要留彈性。
6. 延伸閱讀
上升軌跡與發射力學
- Braeunig, Rocket & Space Technology
— 上升軌跡、重力損失、分節最佳化的完整推導,免費。 - Wikipedia: Tsiolkovsky rocket equation
- NASA Beginner's Guide(航太基礎)
— 推力、阻力與火箭原理的入門互動教材。 - 教科書:Curtis, Orbital Mechanics for Engineering Students 第 11 章(火箭載具動力學)。
發射服務(真實的介面文件)
- SpaceX Rideshare Payload User's Guide (PDF)
— 可用軌道、環境、介面、時程,實際的共乘條件。 - Rocket Lab Electron — 專屬小型發射服務。
- NASA CubeSat Launch Initiative (CSLI)
— NASA 提供給教育單位的免費發射機會,申請流程與條件。 - CubeSat Design Specification — 部署後的供電與展開禁令。
工具
- Ansys STK 線上說明 — Aviator 飛行任務建模。
本機:C:\Program Files\AGI\STK 12\Help\Content\aviator.htm。 - NASA GMAT — 也能做發射與轉移分析。
7. 課前自我檢核
- 550 km 圓軌道速度是 7.58 km/s,為什麼發射需要 9.4 km/s?差額去了哪裡?
- 從緯度 25° 的發射場,能不能直接入軌到 i = 20°?為什麼?
- 把傾角改 1° 要多少 delta-V?跟 FIREYE 的總 delta-V 預算(約 220 m/s)比較。
- FPA rate 太大與太小各會發生什麼事?為什麼最佳值跟燃料量有關?
- 你的任務被安排搭 ISS 部署(400 km, i = 51.6°)。Week 4 的哪些結論要重算?
圖

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