
프로젝트 개요
openMotor는 로켓 모터 실험가를 위한 오픈소스 내탄도 시뮬레이터이다. 추진제 특성, 그레인 형상, 노즐 사양을 기반으로 로켓 모터의 챔버 압력과 추력을 추정하며, Fast Marching Method(고속 행진법)를 사용해 추진제 그레인이 연소되는 방식을 계산한다. 이 방식 덕분에 임의의 코어 형상도 시뮬레이션할 수 있다.
- GitHub 저장소: reilleya/openMotor
- 현재 최신 버전: v0.6.1 (2025년 9월 11일 릴리즈)
- 라이선스: GNU GPL v3
GitHub - reilleya/openMotor: An open-source internal ballistics simulator for rocket motor experimenters
An open-source internal ballistics simulator for rocket motor experimenters - reilleya/openMotor
github.com
핵심 기술 원리
openMotor의 계산 방식은 크게 세 단계로 이루어진다.
1단계 — Kn(연소면/목 면적비) 계산
매 타임스텝마다 현재 그레인 형상으로부터 연소 표면적(Ab)과 노즐 목 면적(At)의 비율인 Kn을 계산합니다. BATES 그레인의 경우 원통 내벽 면적과 양 끝면 면적의 합으로 Ab를 구합니다.
2단계 — 정상상태 챔버 압력 산출
ODE 적분 방식이 아닌, 아래 해석해를 직접 사용합니다.
$$P_c = \left(\frac{\rho \cdot a \cdot K_n}{\Gamma}\right)^{\frac{1}{1-n}}$$
여기서 ρ는 추진제 밀도, a와 n은 Saint-Robert 연소속도 계수, Γ는 γ에 의존하는 노즐 유량 함수입니다. 이 방식은 챔버 부피를 필요로 하지 않으며, BurnSim 등 상용 소프트웨어와 동일한 접근법입니다.
3단계 — 추력 및 비추력 계산
노즐 출구 압력은 팽창비와 등엔트로피 관계식으로 역산하고, 추력계수(CF)에 발산각 손실·목 형상 손실·표면마찰 손실을 각각 분리 적용하여 최종 추력을 산출합니다.
주요 기능
지원 그레인 형상
BATES, Finocyl(핀형 원통), Star(별형), D-Grain, Moon Burner, X-Core, Rod & Tube, End Burner, 그리고 DXF 파일로 불러오는 커스텀 그레인까지 폭넓게 지원한다. 특히 BATES 이외의 복잡한 형상은 Fast Marching Method로 2D 단면 이미지를 분석해 회귀(regression)를 계산하므로 이론적으로 어떤 코어 형상도 처리할 수 있다.
추진제 편집기
압력 구간별로 연소속도 계수(a, n), 연소 온도, 비열비, 분자량을 별도로 입력할 수 있어 압력에 따라 특성이 달라지는 복잡한 추진제도 정확하게 모델링할 수 있다.
호환성 및 파일 형식
ENG 파일 내보내기(ThrustCurve 등 데이터베이스 업로드용), BurnSim 파일 가져오기/내보내기를 지원하며, 모터 설계 파일은 .ric 확장자의 YAML 형식으로 저장되어 텍스트 편집기로도 열람·수정이 가능하다.
설계 최적화 도구
초기 Kn, 포트/목 면적비, 추진제 체적 충전율 등의 퀵 리뷰 기능과 함께 실시간으로 그레인 회귀 형상을 시각화하여 설계 조정 시 시행착오를 줄여준다.
기술 스택
Python 3.10을 기반으로 개발되었으며, PyQt6(GUI), NumPy/SciPy(수치 계산), scikit-fmm(Fast Marching Method), scikit-image(이미지 처리), Matplotlib(그래프)를 주요 의존성으로 사용합니다. 계산 집약적인 부분은 Cython으로 작성되어 C로 컴파일되므로 성능이 대폭 향상됩니다. 전체 코드의 97.1%가 Python, 2.6%가 Cython으로 구성되어 있다.
설치 및 실행
사전 빌드된 바이너리는 Releases 페이지에서 다운로드할 수 있으며, 소스에서 직접 실행하려면 아래 절차를 따른다.
git clone https://github.com/reilleya/openMotor
cd openMotor
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python setup.py build_ui # Qt UI 파일 컴파일
python setup.py build_ext --inplace # Cython 컴파일
python main.py
Linux(AUR, Debian apt)에서는 패키지 매니저로도 설치할 수 있다.
계산 출처 및 신뢰성
계산 근거는 George Sutton의 Rocket Propulsion Elements와 Richard Nakka의 웹사이트(nakka-rocketry.net)에서 가져왔다. GNU GPL v3 라이선스로 소스코드가 공개되어 있어 누구나 계산 결과를 직접 검증할 수 있다. 실제로 저장소의 test/ 디렉터리에는 단위 테스트가 포함되어 있으며, GitHub Actions를 통해 커밋마다 자동으로 검증된다.
오픈소스 생태계와 기여
현재 GitHub에서 540개의 스타와 114개의 포크를 기록하고 있으며, 14명의 기여자가 참여하고 있다. 버그 리포트나 기능 제안은 Issue 트래커를, 코드 기여는 Pull Request를 통해 할 수 있다.
⚠️ 안전 주의사항: 로켓 모터는 위험합니다. 반드시 실험 전에 계산 결과를 검증하고, 사람과 구조물로부터 충분히 떨어진 장소에서 테스트하십시오. 이 프로그램의 결과는 추정치이며 어떠한 보증도 제공되지 않습니다.
코드를 개발하다 보면 기능을 추가하고 싶은 욕망에 코드는 점점 복잡해지기 마련이다. 기본 기능만 가진 코드를 원본으로부터 추출했다. 그레인도 오직 BATES만 가지고 있다. 이 기본 기능을 바탕으로 새로운 GUI와 기능을 추가해 볼까 한다.
SimpleMotor v1
"""
SimpleMotor : openMotor 0.6.1 단순화
=====================================================
실제 openMotor와 동일한 계산 방식:
- SI 단위계
- BATES: inhibited Ends 지원
- 고정 timestep 반복
"""
import math
import numpy as np
import tkinter as tk
from tkinter import ttk
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# ──────────────────────────────────────────────────────
GAS_CONSTANT = 8314.462618 # J/(kmol·K)
STD_GRAVITY = 9.80665 # m/s²
# ══════════════════════════════════════════════════════════
# geometry
# ══════════════════════════════════════════════════════════
def circle_area(dia):
return math.pi * (dia / 2) ** 2
def circle_perimeter(dia):
return math.pi * dia
def cylinder_volume(dia, length):
return circle_area(dia) * length
# ══════════════════════════════════════════════════════════
# BATES grain
# ══════════════════════════════════════════════════════════
class BatesGrain:
def __init__(self, diameter, core_diameter, length, inhibited_ends='Neither'):
self.diameter = diameter # m
self.core_diameter = core_diameter # m
self.length = length # m
self.inhibited_ends = inhibited_ends # 'Neither'|'Top'|'Bottom'|'Both'
# wallWeb: 최대 연소 가능 두께
self.wall_web = (diameter - core_diameter) / 2
# ── 끝면 위치 (grain.py PerforatedGrain.getEndPositions) ──
def get_end_positions(self, reg):
ie = self.inhibited_ends
if ie == 'Neither':
return (reg, self.length - reg)
if ie == 'Top':
return (0, self.length - reg)
if ie == 'Bottom':
return (reg, self.length)
return (0, self.length) # Both
def get_regressed_length(self, reg):
ep = self.get_end_positions(reg)
return ep[1] - ep[0]
# ── web 잔량 (grain.py PerforatedGrain.getWebLeft) ──
def get_web_left(self, reg):
wall_left = self.wall_web - reg
if self.inhibited_ends == 'Both':
return wall_left
length_left = self.get_regressed_length(reg)
return min(length_left, wall_left)
def is_web_left(self, reg, thres=2.54e-5):
return self.get_web_left(reg) > thres
# ── 면적 (bates.py) ──
def get_core_perimeter(self, reg):
return circle_perimeter(self.core_diameter + 2 * reg)
def get_face_area(self, reg):
outer = circle_area(self.diameter)
inner = circle_area(self.core_diameter + 2 * reg)
return outer - inner
def get_core_surface_area(self, reg):
return self.get_core_perimeter(reg) * self.get_regressed_length(reg)
# ── 연소 표면적 (grain.py PerforatedGrain.getSurfaceAreaAtRegression) ──
def get_surface_area(self, reg):
face_area = self.get_face_area(reg)
core_area = self.get_core_surface_area(reg)
ie = self.inhibited_ends
exposed = 2
if ie in ('Top', 'Bottom'):
exposed = 1
if ie == 'Both':
exposed = 0
return core_area + exposed * face_area
# ── 체적 (grain.py PerforatedGrain.getVolumeAtRegression) ──
def get_volume_at_regression(self, reg):
return self.get_face_area(reg) * self.get_regressed_length(reg)
# ── 빈 공간 체적 (grain.py Grain.getFreeVolume) ──
def get_free_volume(self, reg):
return cylinder_volume(self.diameter, self.length) - self.get_volume_at_regression(reg)
# ── 포트 면적 ──
def get_port_area(self, reg):
return circle_area(self.core_diameter + 2 * reg)
# ── 바운딩 체적 ──
def get_bounding_volume(self):
return cylinder_volume(self.diameter, self.length)
# ══════════════════════════════════════════════════════════
# Nozzle
# ══════════════════════════════════════════════════════════
class Nozzle:
def __init__(self, throat_dia, exit_dia, efficiency, div_angle_deg=15.0, throat_length=0.0):
self.throat_dia = throat_dia # m
self.exit_dia = exit_dia # m
self.efficiency = efficiency
self.div_angle_deg = div_angle_deg # 발산 반각 [deg]
self.throat_length = throat_length # m (목 길이, 손실 계산용)
def get_throat_area(self):
return circle_area(self.throat_dia)
def get_exit_area(self):
return circle_area(self.exit_dia)
def get_expansion_ratio(self):
return (self.exit_dia / self.throat_dia) ** 2
# ── 발산각 손실 ──
def get_divergence_losses(self):
rad = math.radians(self.div_angle_deg)
return (1 + math.cos(rad)) / 2
# ── 노즐목 손실 ──
def get_throat_losses(self):
aspect = self.throat_length / self.throat_dia if self.throat_dia > 0 else 0
if aspect > 0.45:
return 0.95
return 0.99 - 0.0333 * aspect
# ── 표면 마찰 손실 ──
def get_skin_losses(self):
return 0.99
# ── 출구 압력 역산 ──
def get_exit_pressure(self, gamma, chamber_pressure):
from scipy.optimize import fsolve
eps = self.get_expansion_ratio()
def e_ratio_from_p_ratio(p_ratio):
"""nozzle.py eRatioFromPRatio"""
return (((gamma + 1) / 2) ** (1 / (gamma - 1))
* (p_ratio ** (1 / gamma))
* (((gamma + 1) / (gamma - 1)) * (1 - p_ratio ** ((gamma - 1) / gamma))) ** 0.5)
# fsolve 사용
result = fsolve(
lambda pe: (1 / eps) - e_ratio_from_p_ratio(pe / chamber_pressure),
chamber_pressure * 0.01, # 초기값: 챔버압의 1%
full_output=True
)
return float(result[0][0])
# ── 이상 추력계수 ──
def get_ideal_thrust_coeff(self, chamber_pres, amb_pres, gamma, exit_pres=None):
if chamber_pres == 0:
return 0
if exit_pres is None:
exit_pres = self.get_exit_pressure(gamma, chamber_pres)
exit_area = self.get_exit_area()
throat_area = self.get_throat_area()
term1 = (2 * gamma ** 2) / (gamma - 1)
term2 = (2 / (gamma + 1)) ** ((gamma + 1) / (gamma - 1))
term3 = 1 - (exit_pres / chamber_pres) ** ((gamma - 1) / gamma)
momentum_thrust = (term1 * term2 * term3) ** 0.5
pressure_thrust = (exit_pres - amb_pres) * exit_area / (throat_area * chamber_pres)
return momentum_thrust + pressure_thrust
# ── 보정 추력계수 ──
def get_adjusted_thrust_coeff(self, chamber_pres, amb_pres, gamma, exit_pres=None):
cf_ideal = self.get_ideal_thrust_coeff(chamber_pres, amb_pres, gamma, exit_pres)
div_loss = self.get_divergence_losses()
throat_loss= self.get_throat_losses()
skin_loss = self.get_skin_losses()
eff = self.efficiency
return div_loss * throat_loss * eff * (skin_loss * cf_ideal + (1 - skin_loss))
# ── 추력 ──
def get_thrust(self, chamber_pres, amb_pres, gamma, exit_pres=None):
cf = self.get_adjusted_thrust_coeff(chamber_pres, amb_pres, gamma, exit_pres)
return max(0.0, cf * self.get_throat_area() * chamber_pres)
# ══════════════════════════════════════════════════════════
# Propellant
# ══════════════════════════════════════════════════════════
class Propellant:
"""단일 압력 범위 추진제 (단순화)"""
def __init__(self, density, a, n, gamma, temp, molar_mass, c_star_efficiency=1.0):
self.density = density # kg/m³
self.a = a # m/(s·Pa^n) ← SI 단위
self.n = n
self.gamma = gamma
self.temp = temp # K
self.molar_mass = molar_mass # kg/mol (g/mol 입력 → /1000 변환)
self.c_star_efficiency = c_star_efficiency
# ── c* ──
def get_c_star(self):
R = GAS_CONSTANT / (self.molar_mass * 1000) # J/(kg·K) (molar_mass: kg/mol)
gamma = self.gamma
num = (gamma * R * self.temp) ** 0.5
denom = gamma * ((2 / (gamma + 1)) ** ((gamma + 1) / (gamma - 1))) ** 0.5
return (num / denom) * self.c_star_efficiency
# ── 연소 속도 ──
def get_burn_rate(self, pressure):
return self.a * (pressure ** self.n)
# ── Kn → 정상상태 압력 ──
def get_pressure_from_kn(self, kn):
"""
Pc^(1-n) = ρ·a·c* · Kn / Γ
Γ = sqrt(γ·(2/(γ+1))^((γ+1)/(γ-1))) [= gamma_func]
"""
R = GAS_CONSTANT / (self.molar_mass * 1000)
gamma = self.gamma
c_star = self.get_c_star()
denom = ((gamma / (R * self.temp))
* ((2 / (gamma + 1)) ** ((gamma + 1) / (gamma - 1)))) ** 0.5
num = kn * self.density * self.a
exponent = 1 / (1 - self.n)
return (num / denom) ** exponent
# ══════════════════════════════════════════════════════════
# Motor Simulation
# ══════════════════════════════════════════════════════════
def run_simulation(grains, nozzle, propellant, amb_pressure,
timestep=0.005, burnout_web_thres=2.54e-5, burnout_thrust_thres=0.1):
density = propellant.density
# initial regression
per_grain_reg = [0.0] * len(grains)
# 결과 저장용
ch = {k: [] for k in ('time', 'pressure', 'force', 'kn', 'exit_pressure')}
ch['regression'] = []
ch['web'] = []
def calc_kn(regs):
ab = sum(g.get_surface_area(r) * int(g.is_web_left(r, burnout_web_thres))
for g, r in zip(grains, regs))
return ab / nozzle.get_throat_area()
def calc_pressure(regs, kn=None):
if kn is None:
kn = calc_kn(regs)
return propellant.get_pressure_from_kn(kn)
# t = 0 초기 기록
kn0 = calc_kn(per_grain_reg)
p0 = calc_pressure(per_grain_reg, kn0)
ch['time'].append(0.0)
ch['kn'].append(kn0)
ch['pressure'].append(p0)
ch['force'].append(0.0)
ch['exit_pressure'].append(0.0)
ch['regression'].append(per_grain_reg[:])
ch['web'].append([g.get_web_left(r) for g, r in zip(grains, per_grain_reg)])
max_force_seen = 0.0
# ── 메인 루프 ──
while True:
last_pressure = ch['pressure'][-1]
last_force = ch['force'][-1]
max_force_seen = max(max_force_seen, last_force)
# 종료 조건: 모든 그레인 소진 + 추력이 최대의 burnout_thrust_thres% 미만
all_burned = all(not g.is_web_left(r, burnout_web_thres)
for g, r in zip(grains, per_grain_reg))
if all_burned:
if max_force_seen > 0 and last_force < max_force_seen * (burnout_thrust_thres / 100):
break
# ── 각 그레인 regression 적용 ──
for gid, grain in enumerate(grains):
if grain.is_web_left(per_grain_reg[gid], burnout_web_thres):
reg_step = timestep * propellant.get_burn_rate(last_pressure)
per_grain_reg[gid] += reg_step
ch['regression'].append(per_grain_reg[:])
ch['web'].append([g.get_web_left(r) for g, r in zip(grains, per_grain_reg)])
# ── Kn → 압력 ──
kn = calc_kn(per_grain_reg)
pressure = calc_pressure(per_grain_reg, kn)
ch['kn'].append(kn)
ch['pressure'].append(pressure)
# ── 출구 압력 & 추력 ──
gamma = propellant.gamma
exit_pres = nozzle.get_exit_pressure(gamma, pressure) if pressure > 0 else 0.0
force = nozzle.get_thrust(pressure, amb_pressure, gamma, exit_pres)
ch['exit_pressure'].append(exit_pres)
ch['force'].append(force)
ch['time'].append(ch['time'][-1] + timestep)
max_force_seen = max(max_force_seen, force)
# 안전 상한: 300 s
if ch['time'][-1] > 300:
break
return {k: np.array(v) for k, v in ch.items()}
# ══════════════════════════════════════════════════════════
# GUI
# ══════════════════════════════════════════════════════════
class OpenMotorLite:
def __init__(self, root):
self.root = root
self.root.title("SimpleMotor (openMotor 0.6.1 algorithm)")
self.root.geometry("1150x1020")
main = ttk.Frame(root, padding="10")
main.pack(fill=tk.BOTH, expand=True)
left = ttk.Frame(main)
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
self.fields = {}
# 1. 추진제 물성
f1 = ttk.LabelFrame(left, text=" 추진제 물성 (SI 단위) ", padding="10")
f1.pack(fill=tk.X, pady=5)
self.add_field(f1, "밀도 (kg/m³)", "density", "1740")
self.add_field(f1, "연소속도계수 a (m/s/Pa^n)","a_si", "1.1845e-5")
self.add_field(f1, "압력지수 n", "n", "0.4")
self.add_field(f1, "c* 효율 η", "eta_c", "0.92")
# 2. 열화학
f2 = ttk.LabelFrame(left, text=" 열화학 ", padding="10")
f2.pack(fill=tk.X, pady=5)
self.add_field(f2, "비열비 γ", "gamma", "1.17")
self.add_field(f2, "연소 온도 (K)", "temp_k", "3371")
self.add_field(f2, "분자량 (g/mol)", "molar_mass","29.3")
# 3. 노즐
f3 = ttk.LabelFrame(left, text=" 노즐 ", padding="10")
f3.pack(fill=tk.X, pady=5)
self.add_field(f3, "목 직경 (mm)", "throat_mm", "12.7")
self.add_field(f3, "출구 직경 (mm)", "exit_mm", "39.1")
self.add_field(f3, "노즐 효율", "noz_eff", "0.97")
self.add_field(f3, "발산 반각 (deg)", "div_angle", "15.0")
self.add_field(f3, "목 길이 (mm)", "throat_len","0.0")
self.add_field(f3, "대기압 (kPa)", "p_amb_kpa", "101.3")
# 4. BATES 그레인
f4 = ttk.LabelFrame(left, text=" BATES 그레인 ", padding="10")
f4.pack(fill=tk.X, pady=5)
self.add_field(f4, "외경 (mm)", "OD_mm", "88.9")
self.add_field(f4, "내경 (mm)", "ID_mm", "63.5")
self.add_field(f4, "길이 (mm)", "L_mm", "157.0")
self.add_field(f4, "그레인 수", "num", "1")
self.inh_var = tk.StringVar(value='Neither')
row = ttk.Frame(f4); row.pack(fill=tk.X, pady=2)
ttk.Label(row, text="Inhibited ends", width=22).pack(side=tk.LEFT)
ttk.Combobox(row, textvariable=self.inh_var, width=10,
values=['Neither','Top','Bottom','Both'],
state='readonly').pack(side=tk.RIGHT)
# 5. 시뮬레이션 설정
f5 = ttk.LabelFrame(left, text=" 시뮬레이션 설정 ", padding="10")
f5.pack(fill=tk.X, pady=5)
self.add_field(f5, "Timestep (s)", "timestep", "0.005")
ttk.Button(left, text="▶ 시뮬레이션 실행", command=self.calculate).pack(pady=12, fill=tk.X)
# 결과
rf = ttk.LabelFrame(left, text=" 결과 ", padding="10")
rf.pack(fill=tk.X, pady=5)
self.res_weight = self.add_result(rf, "추진제 질량:")
self.res_burn_t = self.add_result(rf, "연소 시간:")
self.res_max_p = self.add_result(rf, "최대 압력:")
self.res_max_f = self.add_result(rf, "최대 추력:")
self.res_impulse = self.add_result(rf, "총 역적:")
self.res_isp = self.add_result(rf, "Isp:")
self.res_max_kn = self.add_result(rf, "최대 Kn:")
# 그래프
right = ttk.Frame(main)
right.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
self.fig, (self.ax1, self.ax2) = plt.subplots(2, 1, figsize=(6, 8), dpi=100)
self.fig.tight_layout(pad=4.0)
self.canvas = FigureCanvasTkAgg(self.fig, master=right)
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
def add_field(self, parent, label, key, default):
row = ttk.Frame(parent); row.pack(fill=tk.X, pady=2)
ttk.Label(row, text=label, width=22).pack(side=tk.LEFT)
ent = ttk.Entry(row, width=12)
ent.insert(0, default)
ent.pack(side=tk.RIGHT, expand=True, fill=tk.X)
self.fields[key] = ent
def add_result(self, parent, text):
row = ttk.Frame(parent); row.pack(fill=tk.X)
ttk.Label(row, text=text, width=15).pack(side=tk.LEFT)
var = tk.StringVar(value="-")
ttk.Label(row, textvariable=var, font=('Arial', 9, 'bold')).pack(side=tk.RIGHT)
return var
def calculate(self):
try:
v = {k: float(f.get()) for k, f in self.fields.items()}
# 단위 변환 (mm → m, kPa → Pa, g/mol → 그대로)
throat_m = v['throat_mm'] / 1000
exit_m = v['exit_mm'] / 1000
OD_m = v['OD_mm'] / 1000
ID_m = v['ID_mm'] / 1000
L_m = v['L_mm'] / 1000
throat_len_m = v['throat_len'] / 1000
amb_pa = v['p_amb_kpa'] * 1000
num = int(v['num'])
molar_kg = v['molar_mass'] / 1000 # g/mol → kg/mol
propellant = Propellant(
density = v['density'],
a = v['a_si'],
n = v['n'],
gamma = v['gamma'],
temp = v['temp_k'],
molar_mass = molar_kg,
c_star_efficiency= v['eta_c'],
)
nozzle = Nozzle(
throat_dia = throat_m,
exit_dia = exit_m,
efficiency = v['noz_eff'],
div_angle_deg = v['div_angle'],
throat_length = throat_len_m,
)
grains = [BatesGrain(OD_m, ID_m, L_m, self.inh_var.get()) for _ in range(num)]
result = run_simulation(
grains, nozzle, propellant, amb_pa,
timestep=v['timestep'],
)
time = result['time']
pressure = result['pressure']
force = result['force']
kn = result['kn']
# 추진제 초기 질량
total_mass = sum(g.get_volume_at_regression(0) * propellant.density for g in grains)
# 연소 종료 시점 (최대 추력 10% 기준)
max_f = np.max(force)
bi = np.where(force > max_f * 0.10)[0]
burn_time = time[bi[-1]] if len(bi) > 0 else 0.0
display_end = min(bi[-1] + int(0.3 / v['timestep']), len(time) - 1) if len(bi) > 0 else len(time) - 1
impulse = np.trapezoid(force, time)
isp = impulse / (total_mass * STD_GRAVITY) if total_mass > 0 else 0
self.res_weight.set(f"{total_mass:.3f} kg")
self.res_burn_t.set(f"{burn_time:.3f} s")
self.res_max_p.set(f"{np.max(pressure)/1e6:.3f} MPa")
self.res_max_f.set(f"{max_f:.1f} N")
self.res_impulse.set(f"{impulse:.1f} N·s")
self.res_isp.set(f"{isp:.1f} s")
self.res_max_kn.set(f"{np.max(kn):.1f}")
t_d = time[:display_end]
self.ax1.clear()
self.ax1.plot(t_d, pressure[:display_end] / 1e6, color='red', lw=2)
self.ax1.set_ylabel("Pressure (MPa)"); self.ax1.set_ylim(bottom=0)
self.ax1.grid(True, alpha=0.3)
self.ax2.clear()
self.ax2.plot(t_d, force[:display_end], color='green', lw=2)
self.ax2.set_ylabel("Thrust (N)"); self.ax2.set_xlabel("Time (s)")
self.ax2.set_ylim(bottom=0); self.ax2.grid(True, alpha=0.3)
self.canvas.draw()
except Exception as e:
import traceback; traceback.print_exc()
tk.messagebox.showerror("오류", str(e))
if __name__ == "__main__":
root = tk.Tk()
style = ttk.Style(); style.theme_use('clam')
app = OpenMotorLite(root)
root.mainloop()'프로젝트 > ROCKET' 카테고리의 다른 글
| G2DHeat - 로켓 모터 열해석용 컴퓨터 코드 실행 방법 (0) | 2026.04.19 |
|---|---|
| OpenMotor와 BurnSim의 계산 결과 비교 (0) | 2026.04.19 |
| Saint-Robert's Law 단위 변환 정리 (0) | 2026.04.18 |
| 로켓 모터의 내탄도 해석용 컴퓨터 코드 개발 (1) | 2026.04.18 |
| G2DHeat - 로켓 모터 열해석용 컴퓨터 코드 개발 (1) | 2026.04.18 |