Lots of stuff
This commit is contained in:
@@ -3,11 +3,11 @@ import Home from './pages/Home.jsx'
|
|||||||
import Solver from './pages/Solver.jsx'
|
import Solver from './pages/Solver.jsx'
|
||||||
import EnginePage from './pages/EnginePage.jsx'
|
import EnginePage from './pages/EnginePage.jsx'
|
||||||
import RocketPage from './pages/RocketPage.jsx'
|
import RocketPage from './pages/RocketPage.jsx'
|
||||||
import TrajectoryPage from './pages/TrajectoryPage.jsx'
|
|
||||||
import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx'
|
import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx'
|
||||||
import KnowledgebaseEquationsPage from './pages/KnowledgebaseEquationsPage.jsx'
|
import KnowledgebaseEquationsPage from './pages/KnowledgebaseEquationsPage.jsx'
|
||||||
import KnowledgebaseAblativesPage from './pages/KnowledgebaseAblativesPage.jsx'
|
import KnowledgebaseAblativesPage from './pages/KnowledgebaseAblativesPage.jsx'
|
||||||
import DocsPage from './pages/DocsPage.jsx'
|
import DocsPage from './pages/DocsPage.jsx'
|
||||||
|
import SatellitePage from './pages/SatellitePage.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +73,7 @@ export default function App() {
|
|||||||
Rocket
|
Rocket
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/design/trajectory"
|
to="/design/satellite"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block px-4 py-2 text-sm transition-colors ${
|
`block px-4 py-2 text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
@@ -82,7 +82,7 @@ export default function App() {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Trajectory
|
Satellite
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +151,7 @@ export default function App() {
|
|||||||
<Route path="/solver" element={<Solver />} />
|
<Route path="/solver" element={<Solver />} />
|
||||||
<Route path="/design/engine" element={<EnginePage />} />
|
<Route path="/design/engine" element={<EnginePage />} />
|
||||||
<Route path="/design/rocket" element={<RocketPage />} />
|
<Route path="/design/rocket" element={<RocketPage />} />
|
||||||
<Route path="/design/trajectory" element={<TrajectoryPage />} />
|
<Route path="/design/satellite" element={<SatellitePage />} />
|
||||||
<Route path="/knowledgebase/fuels" element={<KnowledgebaseFuelsPage />} />
|
<Route path="/knowledgebase/fuels" element={<KnowledgebaseFuelsPage />} />
|
||||||
<Route path="/knowledgebase/equations" element={<KnowledgebaseEquationsPage />} />
|
<Route path="/knowledgebase/equations" element={<KnowledgebaseEquationsPage />} />
|
||||||
<Route path="/knowledgebase/ablatives" element={<KnowledgebaseAblativesPage />} />
|
<Route path="/knowledgebase/ablatives" element={<KnowledgebaseAblativesPage />} />
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
|
|
||||||
export function TimelineBar({
|
|
||||||
simulationResults,
|
|
||||||
currentTime,
|
|
||||||
onTimeChange,
|
|
||||||
isPlaying,
|
|
||||||
onPlayPause,
|
|
||||||
playbackSpeed,
|
|
||||||
onPlaybackSpeedChange,
|
|
||||||
}) {
|
|
||||||
const [showEventForm, setShowEventForm] = useState(false)
|
|
||||||
const [newEventTime, setNewEventTime] = useState('')
|
|
||||||
const [newEventLabel, setNewEventLabel] = useState('')
|
|
||||||
const [newEventType, setNewEventType] = useState('marker')
|
|
||||||
const timelineRef = useRef(null)
|
|
||||||
|
|
||||||
if (!simulationResults) {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-800 border-t border-slate-700 p-4 h-32">
|
|
||||||
<p className="text-slate-400 text-sm">Run simulation to see timeline</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { states, events, summary } = simulationResults
|
|
||||||
const maxTime = summary.t_max
|
|
||||||
|
|
||||||
// Handle timeline scrub
|
|
||||||
const handleTimelineClick = (e) => {
|
|
||||||
if (!timelineRef.current) return
|
|
||||||
const rect = timelineRef.current.getBoundingClientRect()
|
|
||||||
const percent = (e.clientX - rect.left) / rect.width
|
|
||||||
const t = Math.max(0, Math.min(maxTime, percent * maxTime))
|
|
||||||
onTimeChange(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event colors
|
|
||||||
const eventColors = {
|
|
||||||
engine: '#ef4444',
|
|
||||||
guidance: '#f59e0b',
|
|
||||||
marker: '#8b5cf6',
|
|
||||||
jettison: '#06b6d4',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-800 border-t border-slate-700">
|
|
||||||
{/* Playback controls */}
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700">
|
|
||||||
<button
|
|
||||||
onClick={() => onTimeChange(0)}
|
|
||||||
className="px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs text-slate-300"
|
|
||||||
title="Rewind"
|
|
||||||
>
|
|
||||||
◀◀
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onPlayPause}
|
|
||||||
className={`px-3 py-1 rounded text-xs font-medium ${
|
|
||||||
isPlaying
|
|
||||||
? 'bg-red-600 hover:bg-red-500 text-white'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-500 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPlaying ? '⏸ Pause' : '▶ Play'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-slate-400">Speed:</span>
|
|
||||||
<select
|
|
||||||
value={playbackSpeed}
|
|
||||||
onChange={e => onPlaybackSpeedChange(parseFloat(e.target.value))}
|
|
||||||
className="px-2 py-0.5 bg-slate-700 border border-slate-600 rounded text-xs text-slate-300 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value={1}>1×</option>
|
|
||||||
<option value={5}>5×</option>
|
|
||||||
<option value={10}>10×</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-sm text-slate-300 font-mono ml-auto">
|
|
||||||
t = {currentTime.toFixed(2)} s / {maxTime.toFixed(1)} s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div className="relative px-4 py-4">
|
|
||||||
<div
|
|
||||||
ref={timelineRef}
|
|
||||||
onClick={handleTimelineClick}
|
|
||||||
className="relative h-12 bg-slate-900 border border-slate-700 rounded cursor-pointer hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
{/* Event markers */}
|
|
||||||
{events.map(event => {
|
|
||||||
const percent = (event.t / maxTime) * 100
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
style={{
|
|
||||||
left: `${percent}%`,
|
|
||||||
backgroundColor: event.color || eventColors[event.type] || '#64748b',
|
|
||||||
}}
|
|
||||||
className="absolute top-0 bottom-0 w-1 rounded-t hover:brightness-150"
|
|
||||||
title={`${event.label} @ t=${event.t.toFixed(2)}s`}
|
|
||||||
>
|
|
||||||
{/* Label above */}
|
|
||||||
<div className="absolute bottom-full mb-1 left-1/2 transform -translate-x-1/2 bg-slate-700 border border-slate-600 rounded px-2 py-1 text-xs text-white whitespace-nowrap opacity-0 hover:opacity-100 transition-opacity z-10">
|
|
||||||
{event.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Playhead */}
|
|
||||||
<div
|
|
||||||
style={{ left: `${(currentTime / maxTime) * 100}%` }}
|
|
||||||
className="absolute top-0 bottom-0 w-0.5 bg-emerald-400 pointer-events-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Event list below */}
|
|
||||||
<div className="mt-3 text-xs space-y-1">
|
|
||||||
{events.slice(0, 5).map(event => (
|
|
||||||
<div key={event.id} className="flex items-center gap-2 text-slate-400">
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: event.color || eventColors[event.type] || '#64748b' }}
|
|
||||||
/>
|
|
||||||
<span className="w-20 text-slate-500">{event.t.toFixed(1)}s</span>
|
|
||||||
<span>{event.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{events.length > 5 && (
|
|
||||||
<div className="text-slate-500">+{events.length - 5} more events</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
export function TrajectoryPlot({ simulationResults, currentState, onTimeClick }) {
|
|
||||||
const canvasRef = useRef(null)
|
|
||||||
const containerRef = useRef(null)
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 500 })
|
|
||||||
|
|
||||||
const plotData = useMemo(() => {
|
|
||||||
if (!simulationResults || !simulationResults.states.length) return null
|
|
||||||
|
|
||||||
const states = simulationResults.states
|
|
||||||
const xs = states.map(s => s.x)
|
|
||||||
const ys = states.map(s => s.y)
|
|
||||||
|
|
||||||
const minX = Math.min(...xs)
|
|
||||||
const maxX = Math.max(...xs)
|
|
||||||
const minY = 0
|
|
||||||
const maxY = Math.max(...ys)
|
|
||||||
|
|
||||||
// 10% padding
|
|
||||||
const padX = (maxX - minX) * 0.1 || 1000
|
|
||||||
const padY = maxY * 0.1 || 1000
|
|
||||||
|
|
||||||
return {
|
|
||||||
states,
|
|
||||||
bounds: {
|
|
||||||
x: [minX - padX, maxX + padX],
|
|
||||||
y: [minY, maxY + padY],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}, [simulationResults])
|
|
||||||
|
|
||||||
// Handle canvas resize
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect()
|
|
||||||
setCanvasSize({ width: rect.width || 800, height: rect.height || 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResize()
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Handle canvas click for scrubbing
|
|
||||||
const handleCanvasClick = (e) => {
|
|
||||||
if (!canvasRef.current || !plotData) return
|
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
|
||||||
const clickX = e.clientX - rect.left
|
|
||||||
const clickY = e.clientY - rect.top
|
|
||||||
|
|
||||||
// Convert pixel to data coordinates
|
|
||||||
const { x: xBounds, y: yBounds } = plotData.bounds
|
|
||||||
const xRange = xBounds[1] - xBounds[0]
|
|
||||||
const yRange = yBounds[1] - yBounds[0]
|
|
||||||
|
|
||||||
const dataX = xBounds[0] + (clickX / canvas.width) * xRange
|
|
||||||
const dataY = yBounds[1] - (clickY / canvas.height) * yRange
|
|
||||||
|
|
||||||
// Find closest state by x position
|
|
||||||
let closestState = plotData.states[0]
|
|
||||||
let minDist = Infinity
|
|
||||||
for (const state of plotData.states) {
|
|
||||||
const dist = Math.abs(state.x - dataX)
|
|
||||||
if (dist < minDist) {
|
|
||||||
minDist = dist
|
|
||||||
closestState = state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onTimeClick?.(closestState.t)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current || !plotData) return
|
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
|
|
||||||
// Clear
|
|
||||||
ctx.fillStyle = '#0f172a'
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
|
|
||||||
const { x: xBounds, y: yBounds } = plotData.bounds
|
|
||||||
const xRange = xBounds[1] - xBounds[0]
|
|
||||||
const yRange = yBounds[1] - yBounds[0]
|
|
||||||
|
|
||||||
// Utility to convert data to canvas coords
|
|
||||||
const toCanvasX = (x) => ((x - xBounds[0]) / xRange) * canvas.width
|
|
||||||
const toCanvasY = (y) => canvas.height - ((y - yBounds[0]) / yRange) * canvas.height
|
|
||||||
|
|
||||||
// Draw grid
|
|
||||||
ctx.strokeStyle = '#1e293b'
|
|
||||||
ctx.lineWidth = 0.5
|
|
||||||
ctx.font = '12px monospace'
|
|
||||||
ctx.fillStyle = '#64748b'
|
|
||||||
|
|
||||||
// Vertical grid lines (downrange)
|
|
||||||
const xStep = (xRange / 1000) * 100 // ~10 lines
|
|
||||||
for (let x = Math.ceil(xBounds[0] / xStep) * xStep; x < xBounds[1]; x += xStep) {
|
|
||||||
const cx = toCanvasX(x)
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(cx, 0)
|
|
||||||
ctx.lineTo(cx, canvas.height)
|
|
||||||
ctx.stroke()
|
|
||||||
if (x % (xStep * 2) === 0) {
|
|
||||||
ctx.fillText(`${(x / 1000).toFixed(0)} km`, cx + 5, canvas.height - 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal grid lines (altitude)
|
|
||||||
const yStep = (yRange / 1000) * 100 // ~10 lines
|
|
||||||
for (let y = Math.ceil(yBounds[0] / yStep) * yStep; y < yBounds[1]; y += yStep) {
|
|
||||||
const cy = toCanvasY(y)
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(0, cy)
|
|
||||||
ctx.lineTo(canvas.width, cy)
|
|
||||||
ctx.stroke()
|
|
||||||
if (y % (yStep * 2) === 0) {
|
|
||||||
ctx.fillText(`${(y / 1000).toFixed(0)} km`, 10, cy - 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw trajectory path
|
|
||||||
ctx.strokeStyle = '#3b82f6'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.beginPath()
|
|
||||||
|
|
||||||
for (let i = 0; i < plotData.states.length; i++) {
|
|
||||||
const s = plotData.states[i]
|
|
||||||
const cx = toCanvasX(s.x)
|
|
||||||
const cy = toCanvasY(s.y)
|
|
||||||
|
|
||||||
if (i === 0) {
|
|
||||||
ctx.moveTo(cx, cy)
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(cx, cy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// Draw current position marker (if available)
|
|
||||||
if (currentState) {
|
|
||||||
const cx = toCanvasX(currentState.x)
|
|
||||||
const cy = toCanvasY(currentState.y)
|
|
||||||
|
|
||||||
// Outer ring
|
|
||||||
ctx.strokeStyle = '#10b981'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy, 8, 0, Math.PI * 2)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// Inner circle
|
|
||||||
ctx.fillStyle = '#10b981'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy, 4, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw axes
|
|
||||||
ctx.strokeStyle = '#475569'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
const origin_x = toCanvasX(0)
|
|
||||||
const origin_y = toCanvasY(0)
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(origin_x, canvas.height)
|
|
||||||
ctx.lineTo(origin_x, 0)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(0, origin_y)
|
|
||||||
ctx.lineTo(canvas.width, origin_y)
|
|
||||||
ctx.stroke()
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
ctx.fillStyle = '#cbd5e1'
|
|
||||||
ctx.font = 'bold 14px sans-serif'
|
|
||||||
ctx.fillText('Downrange (m)', canvas.width - 200, canvas.height - 20)
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(20, 100)
|
|
||||||
ctx.rotate(-Math.PI / 2)
|
|
||||||
ctx.fillText('Altitude (m)', 0, 0)
|
|
||||||
ctx.restore()
|
|
||||||
}, [plotData, currentState])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="w-full h-full">
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
width={canvasSize.width}
|
|
||||||
height={canvasSize.height}
|
|
||||||
onClick={handleCanvasClick}
|
|
||||||
className="w-full h-full border border-slate-700 bg-slate-950 cursor-crosshair rounded block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
304
src/engine/satelliteCalcs.js
Normal file
304
src/engine/satelliteCalcs.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
// Satellite bus engineering calculations — pure functions, no React imports
|
||||||
|
|
||||||
|
// ─── Physical constants ────────────────────────────────────────────────────────
|
||||||
|
export const R_EARTH = 6371e3 // m, mean Earth radius
|
||||||
|
export const MU_EARTH = 3.986004418e14 // m³/s², Earth gravitational parameter
|
||||||
|
export const S0 = 1361 // W/m², solar constant (AM0)
|
||||||
|
export const SIGMA = 5.67e-8 // W/(m²·K⁴), Stefan–Boltzmann
|
||||||
|
export const T_EARTH = 255 // K, Earth effective blackbody temperature
|
||||||
|
export const A_ALBEDO = 0.30 // Earth mean albedo
|
||||||
|
export const C_LIGHT = 3e8 // m/s
|
||||||
|
export const K_BOLTZMANN_dB = -228.6 // dBW/(Hz·K), 10·log10(1.38064852e-23)
|
||||||
|
|
||||||
|
// ─── CubeSat form-factor catalogue ────────────────────────────────────────────
|
||||||
|
// dims: [width_mm, height_mm, length_mm] (length = long axis along stack)
|
||||||
|
// maxMass: g bodyArea: m² (one wide face of the stack, approximate usable panel area)
|
||||||
|
export const CUBESAT_FORM_FACTORS = {
|
||||||
|
'1U': { dims: [100, 100, 113.5], maxMass: 1330, bodyArea: 0.006 },
|
||||||
|
'2U': { dims: [100, 100, 227], maxMass: 2660, bodyArea: 0.010 },
|
||||||
|
'3U': { dims: [100, 100, 340.5], maxMass: 4000, bodyArea: 0.014 },
|
||||||
|
'6U': { dims: [100, 226, 366], maxMass: 12000, bodyArea: 0.042 },
|
||||||
|
'12U': { dims: [226, 226, 366], maxMass: 24000, bodyArea: 0.083 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modulation Eb/N0 reference values at BER = 1×10⁻⁵ ───────────────────────
|
||||||
|
export const MODULATION_EB_N0 = {
|
||||||
|
'BPSK': 9.6, // dB
|
||||||
|
'QPSK': 9.6, // dB (same as BPSK per bit)
|
||||||
|
'8PSK': 13.0, // dB
|
||||||
|
'GMSK': 10.5, // dB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── calcOrbit ─────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Computes circular-orbit parameters and eclipse geometry.
|
||||||
|
* @param {{ altitudeKm: number, betaAngleDeg: number }} inputs
|
||||||
|
* @returns {{ altitude, a, T, rho_eclipse, f_eclipse, t_eclipse, t_sun }} | null
|
||||||
|
*/
|
||||||
|
export function calcOrbit({ altitudeKm, betaAngleDeg }) {
|
||||||
|
if (altitudeKm == null || betaAngleDeg == null) return null
|
||||||
|
const altitude = altitudeKm * 1e3
|
||||||
|
if (altitude < 0) return null
|
||||||
|
|
||||||
|
const a = R_EARTH + altitude
|
||||||
|
|
||||||
|
// Orbital period (circular orbit, Kepler 3rd law)
|
||||||
|
const T = 2 * Math.PI * Math.sqrt(a ** 3 / MU_EARTH)
|
||||||
|
|
||||||
|
// Eclipse half-angle: satellite enters shadow when Earth subtends this angle
|
||||||
|
const rho_eclipse = Math.asin(R_EARTH / a)
|
||||||
|
|
||||||
|
const betaRad = betaAngleDeg * (Math.PI / 180)
|
||||||
|
const cosBeta = Math.cos(betaRad)
|
||||||
|
|
||||||
|
let f_eclipse
|
||||||
|
if (Math.abs(betaRad) >= rho_eclipse || Math.abs(cosBeta) < 1e-8) {
|
||||||
|
// Beta angle at or beyond the eclipse zone — no shadow crossing
|
||||||
|
f_eclipse = 0
|
||||||
|
} else {
|
||||||
|
const numerator = Math.sqrt(Math.max(0, a ** 2 * cosBeta ** 2 - R_EARTH ** 2))
|
||||||
|
const denominator = a * Math.abs(cosBeta)
|
||||||
|
const arg = Math.min(1, Math.max(-1, numerator / denominator))
|
||||||
|
f_eclipse = Math.acos(arg) / Math.PI
|
||||||
|
}
|
||||||
|
|
||||||
|
const t_eclipse = f_eclipse * T
|
||||||
|
const t_sun = T - t_eclipse
|
||||||
|
|
||||||
|
return { altitude, a, T, rho_eclipse, f_eclipse, t_eclipse, t_sun }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── calcPowerBudget ───────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Solar panel sizing, battery capacity, and power margin.
|
||||||
|
* @param {ReturnType<calcOrbit>} orbitData
|
||||||
|
* @param {{
|
||||||
|
* formFactor, etaCell, etaPacking, deployablePanelArea,
|
||||||
|
* etaCharge, etaDischarge, DOD,
|
||||||
|
* P_sunlight_load, P_eclipse_load, degradationPerYear, designLife
|
||||||
|
* }} inputs
|
||||||
|
* @returns {object} | null
|
||||||
|
*/
|
||||||
|
export function calcPowerBudget(orbitData, inputs) {
|
||||||
|
if (!orbitData) return null
|
||||||
|
const {
|
||||||
|
formFactor, etaCell, etaPacking, deployablePanelArea = 0,
|
||||||
|
etaCharge, etaDischarge, DOD,
|
||||||
|
P_sunlight_load, P_eclipse_load, degradationPerYear, designLife,
|
||||||
|
} = inputs
|
||||||
|
if ([etaCell, etaPacking, etaCharge, etaDischarge, DOD,
|
||||||
|
P_sunlight_load, P_eclipse_load, degradationPerYear, designLife
|
||||||
|
].some(v => v == null)) return null
|
||||||
|
|
||||||
|
const ff = CUBESAT_FORM_FACTORS[formFactor]
|
||||||
|
if (!ff) return null
|
||||||
|
|
||||||
|
const bodyArea = ff.bodyArea
|
||||||
|
const totalPanelArea = bodyArea + (deployablePanelArea ?? 0)
|
||||||
|
|
||||||
|
const P_bol = S0 * etaCell * etaPacking * totalPanelArea
|
||||||
|
const P_eol = P_bol * Math.pow(1 + degradationPerYear, designLife)
|
||||||
|
|
||||||
|
const { t_eclipse, t_sun, T } = orbitData
|
||||||
|
|
||||||
|
// Minimum battery capacity to survive one eclipse without exceeding DOD
|
||||||
|
const C_batt = (DOD * etaDischarge > 0)
|
||||||
|
? P_eclipse_load * (t_eclipse / 3600) / (DOD * etaDischarge)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Net power rates during each phase
|
||||||
|
const chargeRateW = Math.max(0, P_eol - P_sunlight_load) * etaCharge // W into battery
|
||||||
|
const dischargeRateW = etaDischarge > 0 ? P_eclipse_load / etaDischarge : 0 // W out of battery
|
||||||
|
|
||||||
|
// Energy balance per orbit [Wh]
|
||||||
|
const E_charged = chargeRateW * (t_sun / 3600)
|
||||||
|
const E_discharged = dischargeRateW * (t_eclipse / 3600)
|
||||||
|
const E_margin = E_charged - E_discharged
|
||||||
|
|
||||||
|
// Average power margin
|
||||||
|
const powerMargin = T > 0 ? E_margin / (T / 3600) : 0
|
||||||
|
|
||||||
|
// Minimum panel area needed to close the energy budget
|
||||||
|
const denomArea = S0 * etaCell * etaPacking
|
||||||
|
let requiredArea = null
|
||||||
|
let areaMargin = null
|
||||||
|
if (denomArea > 1e-10 && t_sun > 0) {
|
||||||
|
const requiredPower = P_sunlight_load
|
||||||
|
+ P_eclipse_load * (t_eclipse / t_sun) / (etaCharge * etaDischarge)
|
||||||
|
requiredArea = requiredPower / denomArea
|
||||||
|
areaMargin = totalPanelArea - requiredArea
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = powerMargin >= 0 ? 'ok' : 'deficit'
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyArea, totalPanelArea,
|
||||||
|
P_bol, P_eol,
|
||||||
|
C_batt,
|
||||||
|
chargeRateW, dischargeRateW,
|
||||||
|
E_margin, powerMargin,
|
||||||
|
requiredArea, areaMargin,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── calcLinkBudget ────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Full RF link budget in dB domain.
|
||||||
|
* @param {ReturnType<calcOrbit>} orbitData
|
||||||
|
* @param {{
|
||||||
|
* P_tx_W, G_tx_dBi, L_feed_dB, frequencyMHz,
|
||||||
|
* elevationAngleDeg, L_atm_dB, L_point_dB,
|
||||||
|
* G_rx_dBi, T_sys_K, dataRateBps, Eb_N0_req_dB
|
||||||
|
* }} inputs
|
||||||
|
* @returns {object} | null
|
||||||
|
*/
|
||||||
|
export function calcLinkBudget(orbitData, inputs) {
|
||||||
|
if (!orbitData) return null
|
||||||
|
const {
|
||||||
|
P_tx_W, G_tx_dBi, L_feed_dB,
|
||||||
|
frequencyMHz, elevationAngleDeg,
|
||||||
|
L_atm_dB, L_point_dB,
|
||||||
|
G_rx_dBi, T_sys_K,
|
||||||
|
dataRateBps, Eb_N0_req_dB,
|
||||||
|
} = inputs
|
||||||
|
if ([P_tx_W, G_tx_dBi, L_feed_dB, frequencyMHz,
|
||||||
|
elevationAngleDeg, L_atm_dB, L_point_dB,
|
||||||
|
G_rx_dBi, T_sys_K, dataRateBps, Eb_N0_req_dB
|
||||||
|
].some(v => v == null)) return null
|
||||||
|
if (P_tx_W <= 0 || T_sys_K <= 0 || dataRateBps <= 0) return null
|
||||||
|
|
||||||
|
const elevRad = elevationAngleDeg * (Math.PI / 180)
|
||||||
|
if (Math.sin(elevRad) < 1e-6) return null
|
||||||
|
|
||||||
|
const P_tx_dBW = 10 * Math.log10(P_tx_W)
|
||||||
|
const EIRP = P_tx_dBW + G_tx_dBi - L_feed_dB
|
||||||
|
|
||||||
|
const freqHz = frequencyMHz * 1e6
|
||||||
|
const slantRange = orbitData.altitude / Math.sin(elevRad)
|
||||||
|
const FSPL = 20 * Math.log10(4 * Math.PI * slantRange * freqHz / C_LIGHT)
|
||||||
|
|
||||||
|
const P_rx = EIRP - FSPL - L_atm_dB - L_point_dB + G_rx_dBi
|
||||||
|
const G_T_dB = G_rx_dBi - 10 * Math.log10(T_sys_K)
|
||||||
|
const N0 = K_BOLTZMANN_dB + 10 * Math.log10(T_sys_K)
|
||||||
|
const C_N0 = P_rx - N0
|
||||||
|
const Eb_N0 = C_N0 - 10 * Math.log10(dataRateBps)
|
||||||
|
const linkMargin = Eb_N0 - Eb_N0_req_dB
|
||||||
|
|
||||||
|
const status = linkMargin >= 3 ? 'ok' : linkMargin >= 0 ? 'marginal' : 'failed'
|
||||||
|
|
||||||
|
// Build cascade: each row has { label, delta, running, type }
|
||||||
|
// running tracks the cumulative dB sum at each step, ending at Eb/N0
|
||||||
|
const cascade = buildLinkCascade(P_tx_dBW, G_tx_dBi, L_feed_dB, FSPL, L_atm_dB, L_point_dB, G_rx_dBi, N0, dataRateBps, Eb_N0_req_dB, linkMargin)
|
||||||
|
|
||||||
|
return {
|
||||||
|
P_tx_dBW, EIRP, slantRange, FSPL,
|
||||||
|
P_rx, G_T_dB, N0, C_N0, Eb_N0, linkMargin,
|
||||||
|
status,
|
||||||
|
cascade,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkCascade(P_tx_dBW, G_tx_dBi, L_feed_dB, FSPL, L_atm_dB, L_point_dB, G_rx_dBi, N0, dataRateBps, Eb_N0_req_dB, linkMargin) {
|
||||||
|
let r = 0
|
||||||
|
const rows = []
|
||||||
|
const add = (label, delta, type) => { r += delta; rows.push({ label, delta, running: r, type }) }
|
||||||
|
add('TX Power', P_tx_dBW, 'base')
|
||||||
|
add('TX Gain', G_tx_dBi, 'gain')
|
||||||
|
add('Feed Loss', -L_feed_dB, 'loss')
|
||||||
|
add('Free-Space PL', -FSPL, 'loss')
|
||||||
|
add('Atm Loss', -L_atm_dB, 'loss')
|
||||||
|
add('Point Loss', -L_point_dB, 'loss')
|
||||||
|
add('RX Gain', G_rx_dBi, 'gain')
|
||||||
|
add('Noise + Rate', -N0 - 10 * Math.log10(dataRateBps), 'noise')
|
||||||
|
// r is now Eb/N0; show required level then margin
|
||||||
|
add('Req Eb/N0', -Eb_N0_req_dB, 'req')
|
||||||
|
rows.push({ label: 'Link Margin', delta: linkMargin, running: linkMargin, type: 'result' })
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── calcThermalBalance ───────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Orbital-average thermal equilibrium temperatures.
|
||||||
|
* @param {ReturnType<calcOrbit>} orbitData
|
||||||
|
* @param {{ alpha, epsilon, A_sun, A_earth, A_rad, Q_int }} inputs
|
||||||
|
* @returns {object} | null
|
||||||
|
*/
|
||||||
|
export function calcThermalBalance(orbitData, inputs) {
|
||||||
|
if (!orbitData) return null
|
||||||
|
const { alpha, epsilon, A_sun, A_earth, A_rad, Q_int } = inputs
|
||||||
|
if ([alpha, epsilon, A_sun, A_earth, A_rad, Q_int].some(v => v == null)) return null
|
||||||
|
|
||||||
|
const denom = epsilon * SIGMA * A_rad
|
||||||
|
if (denom < 1e-15) return null
|
||||||
|
|
||||||
|
const { f_eclipse, a } = orbitData
|
||||||
|
|
||||||
|
// Earth view factor for a sphere: F = 0.5 * (1 - sqrt(1 - (R_EARTH/a)²))
|
||||||
|
const sinRho = R_EARTH / a
|
||||||
|
const F_earth = 0.5 * (1 - Math.sqrt(Math.max(0, 1 - sinRho ** 2)))
|
||||||
|
|
||||||
|
// Heat inputs (orbit-averaged)
|
||||||
|
const Q_sun = alpha * S0 * A_sun * (1 - f_eclipse)
|
||||||
|
const Q_earth_ir = epsilon * SIGMA * T_EARTH ** 4 * F_earth * A_earth
|
||||||
|
const Q_albedo = alpha * A_ALBEDO * S0 * F_earth * A_earth * (1 - f_eclipse)
|
||||||
|
|
||||||
|
// Hot case: full orbit-averaged inputs + internal dissipation
|
||||||
|
const Q_total_hot = Q_sun + Q_earth_ir + Q_albedo + Q_int
|
||||||
|
|
||||||
|
// Cold case: eclipse — no solar/albedo, half Earth IR, 30% internal dissipation
|
||||||
|
const Q_total_cold = Q_earth_ir * 0.5 + Q_int * 0.3
|
||||||
|
|
||||||
|
const T_hot = Math.pow(Math.max(0, Q_total_hot) / denom, 0.25)
|
||||||
|
const T_cold = Math.pow(Math.max(0, Q_total_cold) / denom, 0.25)
|
||||||
|
|
||||||
|
const status = T_hot < 350 ? 'ok' : T_hot < 400 ? 'marginal' : 'critical'
|
||||||
|
|
||||||
|
return {
|
||||||
|
F_earth,
|
||||||
|
Q_sun, Q_earth_ir, Q_albedo, Q_int,
|
||||||
|
Q_total_hot, Q_total_cold,
|
||||||
|
T_hot, T_cold,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── calcMassBudget ───────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Per-subsystem mass breakdown and margin against form-factor limit.
|
||||||
|
* @param {object} subsystems
|
||||||
|
* @param {string} formFactor
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function calcMassBudget(subsystems, formFactor) {
|
||||||
|
const ff = CUBESAT_FORM_FACTORS[formFactor]
|
||||||
|
const limit = ff ? ff.maxMass : null
|
||||||
|
|
||||||
|
const keys = ['structure', 'eps', 'adcs', 'comms', 'cdh', 'propulsion', 'payload']
|
||||||
|
const names = {
|
||||||
|
structure: 'Structure', eps: 'EPS', adcs: 'ADCS',
|
||||||
|
comms: 'Comms', cdh: 'CDH', propulsion: 'Propulsion', payload: 'Payload',
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown = keys.map(key => ({
|
||||||
|
key,
|
||||||
|
name: names[key],
|
||||||
|
mass: Number(subsystems[key]?.mass ?? 0) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalMass = breakdown.reduce((s, r) => s + r.mass, 0)
|
||||||
|
const marginMass = limit != null ? limit - totalMass : null
|
||||||
|
const marginFraction = limit != null && limit > 0 ? marginMass / limit : null
|
||||||
|
const status = marginMass == null ? 'unknown'
|
||||||
|
: marginMass >= 0 ? 'ok' : 'overrun'
|
||||||
|
|
||||||
|
// Power totals
|
||||||
|
const subsystemPowerKeys = ['adcs', 'comms', 'cdh', 'propulsion', 'payload']
|
||||||
|
const totalAvgPower = subsystemPowerKeys.reduce((s, k) => s + (Number(subsystems[k]?.avgPower) || 0), 0)
|
||||||
|
const totalPeakPower = subsystemPowerKeys.reduce((s, k) => s + (Number(subsystems[k]?.peakPower) || 0), 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
breakdown, totalMass, limit, marginMass, marginFraction, status,
|
||||||
|
totalAvgPower, totalPeakPower,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
/**
|
|
||||||
* Trajectory simulation using 4th-order Runge-Kutta integration.
|
|
||||||
* Physics: 3-DOF point-mass with thrust, drag, gravity.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// US Standard Atmosphere (simplified piecewise linear in log-rho)
|
|
||||||
function atmosphereDensity(h) {
|
|
||||||
// h in meters
|
|
||||||
if (h < 0) h = 0
|
|
||||||
|
|
||||||
// Simplified US Standard Atmosphere
|
|
||||||
if (h <= 11000) {
|
|
||||||
// Troposphere: ρ = ρ0 * (1 - L*h/T0)^(-g0*M/(R*L))
|
|
||||||
// Approximate with exponential: ρ = ρ0 * exp(-h / H) where H ≈ 8500 m
|
|
||||||
const rho0 = 1.225 // kg/m³ at sea level
|
|
||||||
const H = 8500 // scale height in meters
|
|
||||||
return rho0 * Math.exp(-h / H)
|
|
||||||
} else if (h <= 20000) {
|
|
||||||
// Stratosphere (simplified)
|
|
||||||
const rho11k = 1.225 * Math.exp(-11000 / 8500)
|
|
||||||
return rho11k * Math.exp(-(h - 11000) / 6500)
|
|
||||||
} else {
|
|
||||||
// Higher altitude (even rarer)
|
|
||||||
const rho20k = 1.225 * Math.exp(-20000 / 8500) * Math.exp(-(9000) / 6500)
|
|
||||||
return rho20k * Math.exp(-(h - 20000) / 4500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gravity with altitude correction
|
|
||||||
function gravity(y) {
|
|
||||||
const g0 = 9.80665
|
|
||||||
const R_earth = 6.371e6 // meters
|
|
||||||
return g0 * Math.pow(R_earth / (R_earth + y), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute pitch angle (gravity turn or from pitch program)
|
|
||||||
function computePitch(state, config, events, t) {
|
|
||||||
// Check if there's a pitch program event at or before time t
|
|
||||||
const pitchEvents = events.filter(e => e.type === 'guidance' && e.t <= t)
|
|
||||||
if (pitchEvents.length > 0) {
|
|
||||||
const latest = pitchEvents.reduce((a, b) => a.t > b.t ? a : b)
|
|
||||||
return latest.pitch_angle ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gravity turn: pitch follows velocity vector angle
|
|
||||||
const { y, vx, vy } = state
|
|
||||||
|
|
||||||
// Start vertical, then follow velocity vector
|
|
||||||
if (y < config.pitchStartAlt) {
|
|
||||||
return 90 // vertical
|
|
||||||
}
|
|
||||||
|
|
||||||
// Velocity angle from horizontal
|
|
||||||
const vMag = Math.hypot(vx, vy)
|
|
||||||
if (vMag < 0.1) return 90 // very slow, stay vertical
|
|
||||||
|
|
||||||
// pitch = 90 - elevation_angle, so pitch = arctan(vy/vx) in degrees
|
|
||||||
const elevAngle = Math.atan2(vy, vx) * 180 / Math.PI
|
|
||||||
return Math.max(0, elevAngle)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply jettison events (mass drop)
|
|
||||||
function applyJettison(state, events, t) {
|
|
||||||
let m = state.m
|
|
||||||
const jettisonEvents = events.filter(e => e.type === 'jettison' && e.t <= t)
|
|
||||||
if (jettisonEvents.length > 0) {
|
|
||||||
const latest = jettisonEvents.reduce((a, b) => a.t > b.t ? a : b)
|
|
||||||
if (latest.mass_drop) {
|
|
||||||
m -= latest.mass_drop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derivatives for RK4
|
|
||||||
function derivatives(state, config, events, t) {
|
|
||||||
let { x, y, vx, vy, m } = state
|
|
||||||
|
|
||||||
// Apply jettison if applicable
|
|
||||||
m = applyJettison(state, events, t)
|
|
||||||
|
|
||||||
// Atmosphere
|
|
||||||
const rho = atmosphereDensity(y)
|
|
||||||
|
|
||||||
// Velocity magnitude and direction
|
|
||||||
const vMag = Math.hypot(vx, vy)
|
|
||||||
|
|
||||||
// Gravity
|
|
||||||
const g = gravity(y)
|
|
||||||
|
|
||||||
// Thrust and burn
|
|
||||||
let F = 0
|
|
||||||
let mdot = 0
|
|
||||||
|
|
||||||
if (m > config.m_dry && config.t_burn_remaining > 0) {
|
|
||||||
F = config.F
|
|
||||||
mdot = config.mdot
|
|
||||||
config.t_burn_remaining -= config.dt // decrement locally (will be overridden in main loop)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pitch angle
|
|
||||||
const pitch = computePitch(state, config, events, t)
|
|
||||||
const pitchRad = pitch * Math.PI / 180
|
|
||||||
|
|
||||||
// Thrust direction (along pitch angle from horizontal)
|
|
||||||
const Fx = F * Math.cos(pitchRad)
|
|
||||||
const Fy = F * Math.sin(pitchRad)
|
|
||||||
|
|
||||||
// Drag force
|
|
||||||
const Cd = config.Cd
|
|
||||||
const A_ref = config.A_ref
|
|
||||||
const F_drag = 0.5 * rho * vMag * vMag * Cd * A_ref
|
|
||||||
|
|
||||||
let Fdrag_x = 0
|
|
||||||
let Fdrag_y = 0
|
|
||||||
if (vMag > 0.01) {
|
|
||||||
Fdrag_x = -(F_drag * vx / vMag)
|
|
||||||
Fdrag_y = -(F_drag * vy / vMag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acceleration
|
|
||||||
const ax = (Fx + Fdrag_x) / m
|
|
||||||
const ay = (Fy + Fdrag_y) / m - g
|
|
||||||
|
|
||||||
return {
|
|
||||||
dx: vx,
|
|
||||||
dy: vy,
|
|
||||||
dvx: ax,
|
|
||||||
dvy: ay,
|
|
||||||
dm: -mdot,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RK4 step
|
|
||||||
function rk4Step(state, config, events, t, dt) {
|
|
||||||
const k1 = derivatives(state, config, events, t)
|
|
||||||
|
|
||||||
const state2 = {
|
|
||||||
x: state.x + k1.dx * dt / 2,
|
|
||||||
y: state.y + k1.dy * dt / 2,
|
|
||||||
vx: state.vx + k1.dvx * dt / 2,
|
|
||||||
vy: state.vy + k1.dvy * dt / 2,
|
|
||||||
m: state.m + k1.dm * dt / 2,
|
|
||||||
}
|
|
||||||
const k2 = derivatives(state2, config, events, t + dt / 2)
|
|
||||||
|
|
||||||
const state3 = {
|
|
||||||
x: state.x + k2.dx * dt / 2,
|
|
||||||
y: state.y + k2.dy * dt / 2,
|
|
||||||
vx: state.vx + k2.dvx * dt / 2,
|
|
||||||
vy: state.vy + k2.dvy * dt / 2,
|
|
||||||
m: state.m + k2.dm * dt / 2,
|
|
||||||
}
|
|
||||||
const k3 = derivatives(state3, config, events, t + dt / 2)
|
|
||||||
|
|
||||||
const state4 = {
|
|
||||||
x: state.x + k3.dx * dt,
|
|
||||||
y: state.y + k3.dy * dt,
|
|
||||||
vx: state.vx + k3.dvx * dt,
|
|
||||||
vy: state.vy + k3.dvy * dt,
|
|
||||||
m: state.m + k3.dm * dt,
|
|
||||||
}
|
|
||||||
const k4 = derivatives(state4, config, events, t + dt)
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: state.x + (k1.dx + 2*k2.dx + 2*k3.dx + k4.dx) * dt / 6,
|
|
||||||
y: state.y + (k1.dy + 2*k2.dy + 2*k3.dy + k4.dy) * dt / 6,
|
|
||||||
vx: state.vx + (k1.dvx + 2*k2.dvx + 2*k3.dvx + k4.dvx) * dt / 6,
|
|
||||||
vy: state.vy + (k1.dvy + 2*k2.dvy + 2*k3.dvy + k4.dvy) * dt / 6,
|
|
||||||
m: Math.max(state.m + (k1.dm + 2*k2.dm + 2*k3.dm + k4.dm) * dt / 6, config.m_dry),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run trajectory simulation.
|
|
||||||
* Returns { states, events, summary }
|
|
||||||
*/
|
|
||||||
export function runSimulation(config, userEvents = []) {
|
|
||||||
const {
|
|
||||||
F, Isp, mdot, m_wet, m_dry, burnTime,
|
|
||||||
Cd, A_ref,
|
|
||||||
pitchStartAlt, launchAngle,
|
|
||||||
dt, t_max,
|
|
||||||
} = config
|
|
||||||
|
|
||||||
const g0 = 9.80665
|
|
||||||
|
|
||||||
// Initial state
|
|
||||||
let state = {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
vx: 0,
|
|
||||||
vy: 0,
|
|
||||||
m: m_wet,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch angle (degrees) → velocity components
|
|
||||||
const launchRad = (launchAngle || 90) * Math.PI / 180
|
|
||||||
|
|
||||||
// Configuration for derivatives
|
|
||||||
const simConfig = {
|
|
||||||
F, mdot, m_dry, Cd, A_ref,
|
|
||||||
pitchStartAlt: pitchStartAlt || 100,
|
|
||||||
dt: dt || 0.05,
|
|
||||||
t_burn_remaining: burnTime || 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Results
|
|
||||||
const states = []
|
|
||||||
let t = 0
|
|
||||||
let meco_t = null
|
|
||||||
let apogee_t = null
|
|
||||||
let apogee_y = 0
|
|
||||||
let max_q = 0
|
|
||||||
let max_q_t = 0
|
|
||||||
let vy_last = 0
|
|
||||||
|
|
||||||
// Add initial state
|
|
||||||
states.push({
|
|
||||||
t: 0,
|
|
||||||
x: state.x,
|
|
||||||
y: state.y,
|
|
||||||
vx: state.vx,
|
|
||||||
vy: state.vy,
|
|
||||||
m: state.m,
|
|
||||||
phase: 'standby',
|
|
||||||
q: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Main RK4 loop
|
|
||||||
while (t < t_max && state.y >= 0) {
|
|
||||||
// Determine phase
|
|
||||||
let phase = 'coast'
|
|
||||||
if (state.m > m_dry + 0.1 && simConfig.t_burn_remaining > 0) {
|
|
||||||
phase = 'burn'
|
|
||||||
simConfig.t_burn_remaining -= dt
|
|
||||||
if (simConfig.t_burn_remaining <= 0) {
|
|
||||||
meco_t = t
|
|
||||||
simConfig.t_burn_remaining = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RK4 integration step
|
|
||||||
state = rk4Step(state, simConfig, userEvents, t, dt)
|
|
||||||
t += dt
|
|
||||||
|
|
||||||
// Dynamic pressure
|
|
||||||
const rho = atmosphereDensity(state.y)
|
|
||||||
const v_mag = Math.hypot(state.vx, state.vy)
|
|
||||||
const q = 0.5 * rho * v_mag * v_mag
|
|
||||||
|
|
||||||
if (q > max_q) {
|
|
||||||
max_q = q
|
|
||||||
max_q_t = t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for apogee (vy crosses zero)
|
|
||||||
if (vy_last > 0 && state.vy <= 0 && apogee_t === null) {
|
|
||||||
apogee_t = t
|
|
||||||
apogee_y = state.y
|
|
||||||
}
|
|
||||||
vy_last = state.vy
|
|
||||||
|
|
||||||
// Record state
|
|
||||||
states.push({
|
|
||||||
t: Math.round(t * 1000) / 1000, // round to 3 decimals
|
|
||||||
x: state.x,
|
|
||||||
y: Math.max(0, state.y),
|
|
||||||
vx: state.vx,
|
|
||||||
vy: state.vy,
|
|
||||||
m: state.m,
|
|
||||||
phase,
|
|
||||||
q,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Landing
|
|
||||||
const landing_t = t
|
|
||||||
const landing_y = 0
|
|
||||||
|
|
||||||
// Auto-detect events
|
|
||||||
const autoEvents = []
|
|
||||||
autoEvents.push({
|
|
||||||
id: `event_liftoff`,
|
|
||||||
t: 0.1,
|
|
||||||
label: 'Liftoff',
|
|
||||||
type: 'engine',
|
|
||||||
color: '#10b981',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (meco_t !== null) {
|
|
||||||
autoEvents.push({
|
|
||||||
id: `event_meco`,
|
|
||||||
t: meco_t,
|
|
||||||
label: 'MECO',
|
|
||||||
type: 'engine',
|
|
||||||
color: '#ef4444',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (max_q_t > 0) {
|
|
||||||
autoEvents.push({
|
|
||||||
id: `event_maxq`,
|
|
||||||
t: max_q_t,
|
|
||||||
label: 'Max Q',
|
|
||||||
type: 'guidance',
|
|
||||||
color: '#f59e0b',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apogee_t !== null) {
|
|
||||||
autoEvents.push({
|
|
||||||
id: `event_apogee`,
|
|
||||||
t: apogee_t,
|
|
||||||
label: 'Apogee',
|
|
||||||
type: 'marker',
|
|
||||||
color: '#3b82f6',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
autoEvents.push({
|
|
||||||
id: `event_landing`,
|
|
||||||
t: landing_t,
|
|
||||||
label: 'Landing',
|
|
||||||
type: 'marker',
|
|
||||||
color: '#8b5cf6',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Merge user and auto events
|
|
||||||
const allEvents = [...userEvents, ...autoEvents].sort((a, b) => a.t - b.t)
|
|
||||||
|
|
||||||
return {
|
|
||||||
states,
|
|
||||||
events: allEvents,
|
|
||||||
summary: {
|
|
||||||
t_max: landing_t,
|
|
||||||
apogee: apogee_y,
|
|
||||||
apogee_t,
|
|
||||||
meco_t,
|
|
||||||
max_q,
|
|
||||||
max_q_t,
|
|
||||||
downrange: state.x,
|
|
||||||
final_velocity: Math.hypot(state.vx, state.vy),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { solve } from '../engine/solver.js'
|
import { solve } from '../engine/solver.js'
|
||||||
import {
|
import {
|
||||||
calcChamber,
|
calcChamber,
|
||||||
@@ -9,24 +9,49 @@ import {
|
|||||||
calcStructure,
|
calcStructure,
|
||||||
} from '../engine/engineDesignCalcs.js'
|
} from '../engine/engineDesignCalcs.js'
|
||||||
|
|
||||||
|
const LS_KEY = 'rockettools_engine'
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
thermoInputs: { p0: null, T0: null, gamma: null, R: null, mdot: null, F: null, OF: null, At: null, eps: null, pa: null },
|
||||||
|
chamber: { Lstar: 1.0, contractionRatio: 8, convAngleDeg: 30 },
|
||||||
|
nozzle: { type: 'conical', divAngleDeg: 15 },
|
||||||
|
injector: { type: 'doublet', N: 20, dpFraction: 0.2, Cd: 0.7, rhoFuel: 800, rhoOx: 1140 },
|
||||||
|
cooling: { method: 'regenerative', channelCount: 60, mu: 6e-5, cp: 2000, Pr: 0.7, T_wall: 800, filmFraction: 0.05, ablativeMaterial: 'carbon_phenolic', ablativeThickness: 10 },
|
||||||
|
feedSystem: { type: 'pressure_fed', feedFactor: 1.3, rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300 },
|
||||||
|
burnTime: 30,
|
||||||
|
structure: { materialId: 'al_6061_t6', safetyFactor: 2.0 },
|
||||||
|
}
|
||||||
|
|
||||||
export function useEngineDesign() {
|
export function useEngineDesign() {
|
||||||
|
const saved = useMemo(() => loadFromStorage(), [])
|
||||||
|
|
||||||
// Thermodynamic inputs — SI values, null means not provided
|
// Thermodynamic inputs — SI values, null means not provided
|
||||||
const [thermoInputs, setThermoInputs] = useState({
|
const [thermoInputs, setThermoInputs] = useState(saved?.thermoInputs ?? DEFAULTS.thermoInputs)
|
||||||
p0: null, T0: null, gamma: null, R: null,
|
|
||||||
mdot: null, F: null, OF: null, At: null, eps: null, pa: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Engineering design choices
|
// Engineering design choices
|
||||||
const [chamber, setChamber] = useState({ Lstar: 1.0, contractionRatio: 8, convAngleDeg: 30 })
|
const [chamber, setChamber] = useState(saved?.chamber ?? DEFAULTS.chamber)
|
||||||
const [nozzle, setNozzle] = useState({ type: 'conical', divAngleDeg: 15 })
|
const [nozzle, setNozzle] = useState(saved?.nozzle ?? DEFAULTS.nozzle)
|
||||||
const [injector, setInjector] = useState({ type: 'doublet', N: 20, dpFraction: 0.2, Cd: 0.7, rhoFuel: 800, rhoOx: 1140 })
|
const [injector, setInjector] = useState(saved?.injector ?? DEFAULTS.injector)
|
||||||
const [cooling, setCooling] = useState({ method: 'regenerative', channelCount: 60, mu: 6e-5, cp: 2000, Pr: 0.7, T_wall: 800, filmFraction: 0.05, ablativeMaterial: 'carbon_phenolic', ablativeThickness: 10 })
|
const [cooling, setCooling] = useState(saved?.cooling ?? DEFAULTS.cooling)
|
||||||
const [feedSystem, setFeedSystem] = useState({
|
const [feedSystem, setFeedSystem] = useState(saved?.feedSystem ?? DEFAULTS.feedSystem)
|
||||||
type: 'pressure_fed', feedFactor: 1.3,
|
const [burnTime, setBurnTime] = useState(saved?.burnTime ?? DEFAULTS.burnTime)
|
||||||
rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300,
|
const [structure, setStructure] = useState(saved?.structure ?? DEFAULTS.structure)
|
||||||
})
|
|
||||||
const [burnTime, setBurnTime] = useState(30)
|
// Persist to localStorage whenever state changes
|
||||||
const [structure, setStructure] = useState({ materialId: 'al_6061_t6', safetyFactor: 2.0 })
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({ thermoInputs, chamber, nozzle, injector, cooling, feedSystem, burnTime, structure }))
|
||||||
|
} catch {}
|
||||||
|
}, [thermoInputs, chamber, nozzle, injector, cooling, feedSystem, burnTime, structure])
|
||||||
|
|
||||||
// Run the existing constraint-propagation solver on the thermodynamic inputs
|
// Run the existing constraint-propagation solver on the thermodynamic inputs
|
||||||
const thermoResults = useMemo(() => {
|
const thermoResults = useMemo(() => {
|
||||||
|
|||||||
@@ -1,43 +1,57 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { calcRocketGeometry, diagnoseRocketInputs } from '../engine/rocketDesignCalcs.js'
|
import { calcRocketGeometry, diagnoseRocketInputs } from '../engine/rocketDesignCalcs.js'
|
||||||
|
|
||||||
|
const LS_KEY = 'rockettools_rocket'
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
engineData: null,
|
||||||
|
outerRadius: null,
|
||||||
|
tankConfig: { arrangement: 'tandem', innerPropellant: 'fuel' },
|
||||||
|
propDensities: { rhoFuel: 800, rhoOx: 1140 },
|
||||||
|
payload: { mass: 0, bayLength: 0 },
|
||||||
|
structure: { tankMaterialId: 'al_6061_t6', tankSafetyFactor: 2.0, feedSystem: 'pressure_fed', ullagePercent: 5, otherStructFraction: 0.05, burnTime: null },
|
||||||
|
noseConeShape: 'conical',
|
||||||
|
}
|
||||||
|
|
||||||
export function useRocketDesign() {
|
export function useRocketDesign() {
|
||||||
|
const saved = useMemo(() => loadFromStorage(), [])
|
||||||
|
|
||||||
// Engine imported from JSON export
|
// Engine imported from JSON export
|
||||||
const [engineData, setEngineData] = useState(null)
|
const [engineData, setEngineData] = useState(saved?.engineData ?? DEFAULTS.engineData)
|
||||||
|
|
||||||
// Outer body diameter (m)
|
// Outer body diameter (m)
|
||||||
const [outerRadius, setOuterRadius] = useState(null)
|
const [outerRadius, setOuterRadius] = useState(saved?.outerRadius ?? DEFAULTS.outerRadius)
|
||||||
|
|
||||||
// Tank configuration
|
// Tank configuration
|
||||||
const [tankConfig, setTankConfig] = useState({
|
const [tankConfig, setTankConfig] = useState(saved?.tankConfig ?? DEFAULTS.tankConfig)
|
||||||
arrangement: 'tandem', // 'coaxial' | 'tandem'
|
|
||||||
innerPropellant: 'fuel', // coaxial only
|
|
||||||
})
|
|
||||||
|
|
||||||
// Propellant densities (kg/m³) — pre-filled from engine injector if available
|
// Propellant densities (kg/m³) — pre-filled from engine injector if available
|
||||||
const [propDensities, setPropDensities] = useState({
|
const [propDensities, setPropDensities] = useState(saved?.propDensities ?? DEFAULTS.propDensities)
|
||||||
rhoFuel: 800,
|
|
||||||
rhoOx: 1140,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Payload
|
// Payload
|
||||||
const [payload, setPayload] = useState({
|
const [payload, setPayload] = useState(saved?.payload ?? DEFAULTS.payload)
|
||||||
mass: 0,
|
|
||||||
bayLength: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Structure
|
// Structure
|
||||||
const [structure, setStructure] = useState({
|
const [structure, setStructure] = useState(saved?.structure ?? DEFAULTS.structure)
|
||||||
tankMaterialId: 'al_6061_t6',
|
|
||||||
tankSafetyFactor: 2.0,
|
|
||||||
feedSystem: 'pressure_fed', // 'pressure_fed' | 'pump_fed'
|
|
||||||
ullagePercent: 5,
|
|
||||||
otherStructFraction: 0.05,
|
|
||||||
burnTime: null, // null → use engine feedSystem.burnTime if available
|
|
||||||
})
|
|
||||||
|
|
||||||
// Nose cone shape
|
// Nose cone shape
|
||||||
const [noseConeShape, setNoseConeShape] = useState('conical')
|
const [noseConeShape, setNoseConeShape] = useState(saved?.noseConeShape ?? DEFAULTS.noseConeShape)
|
||||||
|
|
||||||
|
// Persist to localStorage whenever state changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({ engineData, outerRadius, tankConfig, propDensities, payload, structure, noseConeShape }))
|
||||||
|
} catch {}
|
||||||
|
}, [engineData, outerRadius, tankConfig, propDensities, payload, structure, noseConeShape])
|
||||||
|
|
||||||
// Effective burn time (prefer explicit override, fallback to engine data)
|
// Effective burn time (prefer explicit override, fallback to engine data)
|
||||||
const effectiveBurnTime = useMemo(() => {
|
const effectiveBurnTime = useMemo(() => {
|
||||||
|
|||||||
184
src/hooks/useSatelliteDesign.js
Normal file
184
src/hooks/useSatelliteDesign.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
calcOrbit,
|
||||||
|
calcPowerBudget,
|
||||||
|
calcLinkBudget,
|
||||||
|
calcThermalBalance,
|
||||||
|
calcMassBudget,
|
||||||
|
} from '../engine/satelliteCalcs.js'
|
||||||
|
|
||||||
|
const LS_KEY = 'rockettools_satellite'
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
orbitInputs: {
|
||||||
|
altitudeKm: 500,
|
||||||
|
betaAngleDeg: 0,
|
||||||
|
},
|
||||||
|
powerInputs: {
|
||||||
|
formFactor: '3U',
|
||||||
|
solarCellType: 'GaAs',
|
||||||
|
etaCell: 0.28,
|
||||||
|
etaPacking: 0.85,
|
||||||
|
deployablePanelArea: 0,
|
||||||
|
etaCharge: 0.95,
|
||||||
|
etaDischarge: 0.97,
|
||||||
|
DOD: 0.30,
|
||||||
|
P_sunlight_load: 3.0,
|
||||||
|
P_eclipse_load: 2.0,
|
||||||
|
degradationPerYear: -0.03,
|
||||||
|
designLife: 2,
|
||||||
|
},
|
||||||
|
linkInputs: {
|
||||||
|
P_tx_W: 1.0,
|
||||||
|
G_tx_dBi: 6.0,
|
||||||
|
L_feed_dB: 1.0,
|
||||||
|
frequencyMHz: 437,
|
||||||
|
modulation: 'BPSK',
|
||||||
|
elevationAngleDeg: 5,
|
||||||
|
L_atm_dB: 0.5,
|
||||||
|
L_point_dB: 1.0,
|
||||||
|
G_rx_dBi: 12.0,
|
||||||
|
T_sys_K: 290,
|
||||||
|
dataRateBps: 9600,
|
||||||
|
Eb_N0_req_dB: 9.6,
|
||||||
|
},
|
||||||
|
thermalInputs: {
|
||||||
|
alpha: 0.90,
|
||||||
|
epsilon: 0.85,
|
||||||
|
A_sun: 0.01,
|
||||||
|
A_earth: 0.01,
|
||||||
|
A_rad: 0.02,
|
||||||
|
Q_int: 3.0,
|
||||||
|
},
|
||||||
|
subsystems: {
|
||||||
|
adcs: {
|
||||||
|
controlMode: 'sun-pointing',
|
||||||
|
pointingAccuracyDeg: 5,
|
||||||
|
reactionWheels: false,
|
||||||
|
magnetorquers: true,
|
||||||
|
mass: 200,
|
||||||
|
avgPower: 0.5,
|
||||||
|
peakPower: 1.0,
|
||||||
|
},
|
||||||
|
comms: {
|
||||||
|
frequencyMHz: 437,
|
||||||
|
P_tx_W: 1.0,
|
||||||
|
antennaType: 'dipole',
|
||||||
|
modulation: 'BPSK',
|
||||||
|
mass: 100,
|
||||||
|
avgPower: 1.0,
|
||||||
|
peakPower: 2.0,
|
||||||
|
},
|
||||||
|
cdh: {
|
||||||
|
processor: 'ARM Cortex-M4',
|
||||||
|
storageGB: 2,
|
||||||
|
housekeepingRateHz: 1,
|
||||||
|
mass: 120,
|
||||||
|
avgPower: 0.8,
|
||||||
|
peakPower: 1.2,
|
||||||
|
},
|
||||||
|
eps: {
|
||||||
|
batteryChemistry: 'Li-ion',
|
||||||
|
solarCellType: 'GaAs',
|
||||||
|
mass: 300,
|
||||||
|
},
|
||||||
|
structure: {
|
||||||
|
materialId: 'al_6061_t6',
|
||||||
|
configuration: 'standard',
|
||||||
|
mass: 400,
|
||||||
|
},
|
||||||
|
propulsion: {
|
||||||
|
type: 'none',
|
||||||
|
propellant: '',
|
||||||
|
mass: 0,
|
||||||
|
avgPower: 0,
|
||||||
|
peakPower: 0,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
mass: 0,
|
||||||
|
avgPower: 0,
|
||||||
|
peakPower: 0,
|
||||||
|
interface: 'UART',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSatelliteDesign() {
|
||||||
|
const saved = useMemo(() => loadFromStorage(), [])
|
||||||
|
|
||||||
|
const [orbitInputs, setOrbitInputs] = useState(saved?.orbitInputs ?? DEFAULTS.orbitInputs)
|
||||||
|
const [powerInputs, setPowerInputs] = useState(saved?.powerInputs ?? DEFAULTS.powerInputs)
|
||||||
|
const [linkInputs, setLinkInputs] = useState(saved?.linkInputs ?? DEFAULTS.linkInputs)
|
||||||
|
const [thermalInputs, setThermalInputs] = useState(saved?.thermalInputs ?? DEFAULTS.thermalInputs)
|
||||||
|
const [subsystems, setSubsystems] = useState(saved?.subsystems ?? DEFAULTS.subsystems)
|
||||||
|
|
||||||
|
// Persist all state slices on any change
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||||
|
orbitInputs, powerInputs, linkInputs, thermalInputs, subsystems,
|
||||||
|
}))
|
||||||
|
} catch {}
|
||||||
|
}, [orbitInputs, powerInputs, linkInputs, thermalInputs, subsystems])
|
||||||
|
|
||||||
|
// ── Calculation chain (pure useMemo, no side effects) ──
|
||||||
|
const orbitData = useMemo(() => calcOrbit(orbitInputs), [orbitInputs])
|
||||||
|
const powerBudget = useMemo(() => calcPowerBudget(orbitData, powerInputs), [orbitData, powerInputs])
|
||||||
|
const linkBudget = useMemo(() => calcLinkBudget(orbitData, linkInputs), [orbitData, linkInputs])
|
||||||
|
const thermalBalance = useMemo(() => calcThermalBalance(orbitData, thermalInputs), [orbitData, thermalInputs])
|
||||||
|
const massBudget = useMemo(() => calcMassBudget(subsystems, powerInputs.formFactor), [subsystems, powerInputs.formFactor])
|
||||||
|
|
||||||
|
// ── Setters ──
|
||||||
|
|
||||||
|
/** Patch a single key in orbitInputs */
|
||||||
|
function setOrbitInput(key, value) {
|
||||||
|
setOrbitInputs(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a single key in powerInputs */
|
||||||
|
function setPowerInput(key, value) {
|
||||||
|
setPowerInputs(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a single key in linkInputs */
|
||||||
|
function setLinkInput(key, value) {
|
||||||
|
setLinkInputs(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch a single key in thermalInputs */
|
||||||
|
function setThermalInput(key, value) {
|
||||||
|
setThermalInputs(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch one or more fields of a specific subsystem */
|
||||||
|
function setSubsystem(key, patch) {
|
||||||
|
setSubsystems(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State slices
|
||||||
|
orbitInputs, setOrbitInput,
|
||||||
|
powerInputs, setPowerInput,
|
||||||
|
linkInputs, setLinkInput,
|
||||||
|
thermalInputs, setThermalInput,
|
||||||
|
subsystems, setSubsystem,
|
||||||
|
|
||||||
|
// Computed results
|
||||||
|
orbitData,
|
||||||
|
powerBudget,
|
||||||
|
linkBudget,
|
||||||
|
thermalBalance,
|
||||||
|
massBudget,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,39 @@
|
|||||||
import { useState, useMemo, useCallback, useRef } from 'react'
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||||
import { solve, getMissingReport } from '../engine/solver.js'
|
import { solve, getMissingReport } from '../engine/solver.js'
|
||||||
import { EQUATIONS, EQUATION_PRESETS } from '../engine/equations.js'
|
import { EQUATIONS, EQUATION_PRESETS } from '../engine/equations.js'
|
||||||
import { VARIABLES } from '../engine/variables.js'
|
import { VARIABLES } from '../engine/variables.js'
|
||||||
import { UNIT_FAMILIES, getUnitsForFamily } from '../engine/units.js'
|
import { UNIT_FAMILIES, getUnitsForFamily } from '../engine/units.js'
|
||||||
|
|
||||||
|
const LS_KEY = 'rockettools_solver'
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useSolver() {
|
export function useSolver() {
|
||||||
|
const saved = useMemo(() => loadFromStorage(), [])
|
||||||
|
|
||||||
// Variable ids currently on the workspace
|
// Variable ids currently on the workspace
|
||||||
const [workspaceVarIds, setWorkspaceVarIds] = useState([])
|
const [workspaceVarIds, setWorkspaceVarIds] = useState(saved?.workspaceVarIds ?? [])
|
||||||
// User-entered values stored in SI: { varId: number }
|
// User-entered values stored in SI: { varId: number }
|
||||||
const [userValues, setUserValues] = useState({})
|
const [userValues, setUserValues] = useState(saved?.userValues ?? {})
|
||||||
// Selected unit per variable: { varId: unitId }
|
// Selected unit per variable: { varId: unitId }
|
||||||
const [unitSelections, setUnitSelections] = useState({})
|
const [unitSelections, setUnitSelections] = useState(saved?.unitSelections ?? {})
|
||||||
const [sciNotation, setSciNotation] = useState(false)
|
const [sciNotation, setSciNotation] = useState(saved?.sciNotation ?? false)
|
||||||
// Area ratio branch: 'supersonic' or 'subsonic'
|
// Area ratio branch: 'supersonic' or 'subsonic'
|
||||||
const [areaRatioBranch, setAreaRatioBranch] = useState('supersonic')
|
const [areaRatioBranch, setAreaRatioBranch] = useState(saved?.areaRatioBranch ?? 'supersonic')
|
||||||
|
|
||||||
|
// Persist to localStorage whenever state changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({ workspaceVarIds, userValues, unitSelections, sciNotation, areaRatioBranch }))
|
||||||
|
} catch {}
|
||||||
|
}, [workspaceVarIds, userValues, unitSelections, sciNotation, areaRatioBranch])
|
||||||
|
|
||||||
// Ref so getUnit/setValue always see the latest selections without stale closures
|
// Ref so getUnit/setValue always see the latest selections without stale closures
|
||||||
const unitSelectionsRef = useRef(unitSelections)
|
const unitSelectionsRef = useRef(unitSelections)
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
||||||
import { runSimulation } from '../engine/trajectoryCalcs.js'
|
|
||||||
|
|
||||||
export function useTrajectory() {
|
|
||||||
// Simulation parameters
|
|
||||||
const [config, setConfig] = useState({
|
|
||||||
F: 5000, // Newtons
|
|
||||||
Isp: 250, // seconds
|
|
||||||
mdot: 2, // kg/s
|
|
||||||
m_wet: 100, // kg
|
|
||||||
m_dry: 20, // kg
|
|
||||||
burnTime: 40, // seconds
|
|
||||||
Cd: 0.3,
|
|
||||||
A_ref: 0.05, // m²
|
|
||||||
pitchStartAlt: 100, // meters
|
|
||||||
launchAngle: 90, // degrees
|
|
||||||
dt: 0.05, // timestep (s)
|
|
||||||
t_max: 600, // max simulation time (s)
|
|
||||||
})
|
|
||||||
|
|
||||||
// User-defined events
|
|
||||||
const [userEvents, setUserEvents] = useState([])
|
|
||||||
|
|
||||||
// Simulation results
|
|
||||||
const [simulationResults, setSimulationResults] = useState(null)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
// Playback state
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1) // 1x, 5x, 10x
|
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
|
||||||
|
|
||||||
// Update a config value
|
|
||||||
const updateConfig = useCallback((key, value) => {
|
|
||||||
setConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value,
|
|
||||||
}))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Run simulation
|
|
||||||
const runSim = useCallback(() => {
|
|
||||||
try {
|
|
||||||
setError(null)
|
|
||||||
const results = runSimulation(config, userEvents)
|
|
||||||
setSimulationResults(results)
|
|
||||||
setCurrentTime(0)
|
|
||||||
setCurrentIndex(0)
|
|
||||||
setIsPlaying(false)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
}, [config, userEvents])
|
|
||||||
|
|
||||||
// Get current state (for playhead position)
|
|
||||||
const currentState = useMemo(() => {
|
|
||||||
if (!simulationResults || simulationResults.states.length === 0) return null
|
|
||||||
return simulationResults.states[currentIndex] || simulationResults.states[0]
|
|
||||||
}, [simulationResults, currentIndex])
|
|
||||||
|
|
||||||
// Playback loop (update time on interval)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying || !simulationResults) return
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setCurrentTime(prev => {
|
|
||||||
const maxTime = simulationResults.summary.t_max
|
|
||||||
const increment = 0.016 * playbackSpeed // ~60 fps * playback speed
|
|
||||||
let next = prev + increment
|
|
||||||
if (next >= maxTime) {
|
|
||||||
setIsPlaying(false)
|
|
||||||
return maxTime
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 16) // ~60 fps
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [isPlaying, simulationResults, playbackSpeed])
|
|
||||||
|
|
||||||
// Update currentIndex based on currentTime
|
|
||||||
useEffect(() => {
|
|
||||||
if (!simulationResults) return
|
|
||||||
const states = simulationResults.states
|
|
||||||
let idx = 0
|
|
||||||
for (let i = 0; i < states.length; i++) {
|
|
||||||
if (states[i].t <= currentTime) {
|
|
||||||
idx = i
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCurrentIndex(idx)
|
|
||||||
}, [currentTime, simulationResults])
|
|
||||||
|
|
||||||
// Add/remove user events
|
|
||||||
const addEvent = useCallback((event) => {
|
|
||||||
setUserEvents(prev => [...prev, event])
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeEvent = useCallback((eventId) => {
|
|
||||||
setUserEvents(prev => prev.filter(e => e.id !== eventId))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateEvent = useCallback((eventId, updates) => {
|
|
||||||
setUserEvents(prev => prev.map(e => e.id === eventId ? { ...e, ...updates } : e))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
config,
|
|
||||||
updateConfig,
|
|
||||||
userEvents,
|
|
||||||
addEvent,
|
|
||||||
removeEvent,
|
|
||||||
updateEvent,
|
|
||||||
simulationResults,
|
|
||||||
error,
|
|
||||||
runSim,
|
|
||||||
isPlaying,
|
|
||||||
setIsPlaying,
|
|
||||||
playbackSpeed,
|
|
||||||
setPlaybackSpeed,
|
|
||||||
currentTime,
|
|
||||||
setCurrentTime,
|
|
||||||
currentState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ const SECTIONS = [
|
|||||||
'Solver',
|
'Solver',
|
||||||
'Engine Designer',
|
'Engine Designer',
|
||||||
'Rocket Designer',
|
'Rocket Designer',
|
||||||
'Trajectory Simulator',
|
|
||||||
'Knowledgebase',
|
'Knowledgebase',
|
||||||
'Variables Reference',
|
'Variables Reference',
|
||||||
]
|
]
|
||||||
@@ -235,232 +234,6 @@ export default function DocsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Trajectory Simulator */}
|
|
||||||
<section id="Trajectory Simulator" className="space-y-4">
|
|
||||||
<h2 className="text-3xl font-bold text-white">Trajectory Simulator</h2>
|
|
||||||
<p className="text-slate-300">
|
|
||||||
Simulate realistic rocket flight trajectories using numerical integration with atmospheric and aerodynamic physics.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Overview</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
The Trajectory Simulator integrates engine design and rocket parameters to predict flight paths. It:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-slate-300 list-disc list-inside">
|
|
||||||
<li>Solves the 3-DOF equations of motion in 2D (altitude × downrange)</li>
|
|
||||||
<li>Includes thrust, atmospheric drag, and gravity (with altitude correction)</li>
|
|
||||||
<li>Uses real atmosphere model (US Standard Atmosphere)</li>
|
|
||||||
<li>Tracks vehicle mass during propellant burn</li>
|
|
||||||
<li>Detects key flight events (liftoff, MECO, apogee, landing)</li>
|
|
||||||
<li>Enables interactive playback with timeline scrubbing</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Physics Model</h3>
|
|
||||||
<p className="text-slate-300 mb-4">The simulator uses 4th-order Runge-Kutta (RK4) integration with the following state vector:</p>
|
|
||||||
<div className="bg-slate-950 rounded p-4 font-mono text-amber-300 text-sm mb-4">
|
|
||||||
state = [x, y, vx, vy, m]
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 mb-3">where:</p>
|
|
||||||
<ul className="space-y-1 text-slate-300 text-sm">
|
|
||||||
<li><span className="font-mono text-amber-300">x</span> = downrange distance (m)</li>
|
|
||||||
<li><span className="font-mono text-amber-300">y</span> = altitude above sea level (m)</li>
|
|
||||||
<li><span className="font-mono text-amber-300">vx, vy</span> = velocity components (m/s)</li>
|
|
||||||
<li><span className="font-mono text-amber-300">m</span> = vehicle mass (kg)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Forces</h3>
|
|
||||||
<p className="text-slate-300 mb-3">Three forces act on the vehicle:</p>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-slate-300 font-semibold mb-2">1. Thrust</p>
|
|
||||||
<div className="bg-slate-950 rounded p-3 font-mono text-amber-300 text-sm mb-2">
|
|
||||||
F<sub>thrust</sub> = F × (cos θ, sin θ)
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 text-sm">
|
|
||||||
where <span className="font-mono text-amber-300">F</span> is engine thrust and <span className="font-mono text-amber-300">θ</span> is pitch angle (0° horizontal, 90° vertical).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-slate-300 font-semibold mb-2">2. Drag</p>
|
|
||||||
<div className="bg-slate-950 rounded p-3 font-mono text-amber-300 text-sm mb-2">
|
|
||||||
F<sub>drag</sub> = -0.5 × ρ(y) × v² × C<sub>d</sub> × A<sub>ref</sub> × (v̂)
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 text-sm">
|
|
||||||
Atmospheric density ρ varies with altitude; drag opposes the velocity vector.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-300 font-semibold mb-2">3. Gravity (Altitude-Corrected)</p>
|
|
||||||
<div className="bg-slate-950 rounded p-3 font-mono text-amber-300 text-sm mb-2">
|
|
||||||
g(y) = g₀ × (R<sub>E</sub> / (R<sub>E</sub> + y))²
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 text-sm">
|
|
||||||
where g₀ = 9.80665 m/s² and R<sub>E</sub> = 6,371 km (Earth radius).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Atmosphere Model</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
Density vs. altitude follows a piecewise US Standard Atmosphere:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-slate-300 text-sm">
|
|
||||||
<li><strong>Troposphere (0–11 km):</strong> Scale height H = 8,500 m → ρ = ρ₀ × exp(−h/H)</li>
|
|
||||||
<li><strong>Stratosphere (11–20 km):</strong> H = 6,500 m</li>
|
|
||||||
<li><strong>Higher altitude:</strong> H = 4,500 m (simplified)</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-slate-300 text-sm mt-3">
|
|
||||||
Sea-level density ρ₀ = 1.225 kg/m³. This provides realistic dynamic pressure (Q) changes during ascent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Pitch Program (Guidance)</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
By default, the vehicle follows a gravity turn:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-slate-300 text-sm">
|
|
||||||
<li><strong>Initial phase:</strong> Vertical hold (90°) until reaching "Pitch Start Altitude"</li>
|
|
||||||
<li><strong>Gravity turn:</strong> Pitch angle equals the velocity vector angle (no additional steering)</li>
|
|
||||||
<li><strong>User events:</strong> Override pitch at specific times via guidance events (advanced)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Mass Depletion</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
During engine burn:
|
|
||||||
</p>
|
|
||||||
<div className="bg-slate-950 rounded p-3 font-mono text-amber-300 text-sm mb-3">
|
|
||||||
dm/dt = −ṁ
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 text-sm">
|
|
||||||
where ṁ is mass flow rate (mdot). The engine shuts off when m ≤ m_dry (dry mass) or burn duration elapsed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Integration Method: RK4</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
4th-order Runge-Kutta (RK4) provides excellent accuracy with a fixed timestep:
|
|
||||||
</p>
|
|
||||||
<div className="bg-slate-950 rounded p-3 font-mono text-amber-300 text-sm text-xs mb-3 overflow-x-auto">
|
|
||||||
state(t+dt) = state(t) + (dt/6) × (k₁ + 2k₂ + 2k₃ + k₄)
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-300 text-sm">
|
|
||||||
Default timestep: 0.05 s (produces ~12,000 steps for a typical 600 s flight).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Input Parameters</h3>
|
|
||||||
<p className="text-slate-300 mb-3">Configure the simulation with:</p>
|
|
||||||
<div className="space-y-3 text-slate-300 text-sm">
|
|
||||||
<div className="bg-slate-950 rounded p-3">
|
|
||||||
<p className="font-semibold text-white">Engine Parameters</p>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
<li>Thrust <span className="font-mono text-amber-300">F</span> (N)</li>
|
|
||||||
<li>Specific Impulse <span className="font-mono text-amber-300">Isp</span> (s) — reference only in this tool</li>
|
|
||||||
<li>Mass Flow Rate <span className="font-mono text-amber-300">ṁ</span> (kg/s)</li>
|
|
||||||
<li>Wet Mass <span className="font-mono text-amber-300">m_wet</span>, Dry Mass <span className="font-mono text-amber-300">m_dry</span> (kg)</li>
|
|
||||||
<li>Burn Time (s)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-950 rounded p-3">
|
|
||||||
<p className="font-semibold text-white">Aerodynamics</p>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
<li>Drag Coefficient <span className="font-mono text-amber-300">C<sub>d</sub></span> (default 0.3)</li>
|
|
||||||
<li>Reference Area <span className="font-mono text-amber-300">A_ref</span> (m²) — cross-sectional area</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-950 rounded p-3">
|
|
||||||
<p className="font-semibold text-white">Flight Profile</p>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
<li>Pitch Start Altitude (m) — when to begin gravity turn</li>
|
|
||||||
<li>Launch Angle (°) — typically 90° for vertical launch</li>
|
|
||||||
<li>Timestep <span className="font-mono text-amber-300">dt</span> (s) — RK4 step size</li>
|
|
||||||
<li>Max Simulation Time (s) — cutoff to prevent infinite loops</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Auto-Detected Events</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
The simulator automatically identifies key mission milestones:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-slate-300 text-sm">
|
|
||||||
<li><strong>Liftoff:</strong> t ≈ 0.1 s</li>
|
|
||||||
<li><strong>MECO:</strong> Main Engine Cutoff, when m ≤ m_dry</li>
|
|
||||||
<li><strong>Max Q:</strong> Peak dynamic pressure (0.5 × ρ × v²)</li>
|
|
||||||
<li><strong>Apogee:</strong> Maximum altitude (where vy crosses zero)</li>
|
|
||||||
<li><strong>Landing:</strong> Final impact at y = 0</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Results & Visualization</h3>
|
|
||||||
<p className="text-slate-300 mb-3">
|
|
||||||
After simulation runs:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-slate-300 text-sm list-disc list-inside">
|
|
||||||
<li><strong>Altitude vs. Downrange Plot:</strong> 2D trajectory curve with auto-scaled axes</li>
|
|
||||||
<li><strong>Event Markers:</strong> Color-coded points (green=engine, red=MECO, amber=Max Q, blue=apogee, purple=landing)</li>
|
|
||||||
<li><strong>Timeline Scrubber:</strong> Interactive playback controls; click plot to seek</li>
|
|
||||||
<li><strong>Live Readout:</strong> Current altitude, velocity, mass, phase during playback</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Workflow: Engine to Trajectory</h3>
|
|
||||||
<ol className="space-y-2 text-slate-300 text-sm list-decimal list-inside">
|
|
||||||
<li>Design an engine in <strong>/design/engine</strong></li>
|
|
||||||
<li>Export engine JSON</li>
|
|
||||||
<li>Go to <strong>/design/trajectory</strong></li>
|
|
||||||
<li>Click "Import Engine JSON" and select the exported file</li>
|
|
||||||
<li>Parameters auto-populate (thrust, Isp, mass flow, burn time, nozzle area)</li>
|
|
||||||
<li>Adjust aerodynamics (Cd, A_ref) and flight profile as needed</li>
|
|
||||||
<li>Click "Run Simulation"</li>
|
|
||||||
<li>Use Play/Pause, speed controls, and timeline scrubbing to explore the flight</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl border border-slate-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-3">Tips & Limitations</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-300 font-semibold mb-1">✓ Strengths</p>
|
|
||||||
<ul className="space-y-1 text-slate-300 text-sm list-disc list-inside">
|
|
||||||
<li>Fast RK4 integration (typical flight solves in <100 ms)</li>
|
|
||||||
<li>Realistic atmosphere and gravity models</li>
|
|
||||||
<li>Good accuracy for point-mass trajectory (±5% apogee typical)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-300 font-semibold mb-1">⚠ Limitations</p>
|
|
||||||
<ul className="space-y-1 text-slate-300 text-sm list-disc list-inside">
|
|
||||||
<li><strong>Point-mass model:</strong> Ignores rotational dynamics, wind, spinning</li>
|
|
||||||
<li><strong>2D only:</strong> Assumes flat Earth in vertical plane (no coriolis, curvature)</li>
|
|
||||||
<li><strong>Constant Cd:</strong> Drag coefficient doesn't vary with Mach or angle of attack</li>
|
|
||||||
<li><strong>Simple pitch program:</strong> Gravity turn only; no attitude control or TVC</li>
|
|
||||||
<li><strong>Sea-level launch:</strong> Assumes pad at elevation 0</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Knowledgebase */}
|
{/* Knowledgebase */}
|
||||||
<section id="Knowledgebase" className="space-y-4">
|
<section id="Knowledgebase" className="space-y-4">
|
||||||
<h2 className="text-3xl font-bold text-white">Knowledgebase</h2>
|
<h2 className="text-3xl font-bold text-white">Knowledgebase</h2>
|
||||||
|
|||||||
1169
src/pages/SatellitePage.jsx
Normal file
1169
src/pages/SatellitePage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,375 +0,0 @@
|
|||||||
import { useRef, useState } from 'react'
|
|
||||||
import { useTrajectory } from '../hooks/useTrajectory.js'
|
|
||||||
import { parseEngineImport, downloadBlob } from '../engine/engineExportImport.js'
|
|
||||||
import { TrajectoryPlot } from '../components/trajectory/TrajectoryPlot.jsx'
|
|
||||||
import { TimelineBar } from '../components/trajectory/TimelineBar.jsx'
|
|
||||||
import ErrorBoundary from '../components/ErrorBoundary.jsx'
|
|
||||||
import { formatValue } from '../engine/format.js'
|
|
||||||
|
|
||||||
function ResultRow({ label, value, unit }) {
|
|
||||||
const display = value !== null && value !== undefined && isFinite(value)
|
|
||||||
? formatValue(value)
|
|
||||||
: '—'
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between items-baseline gap-2 text-sm">
|
|
||||||
<span className="text-slate-400">{label}</span>
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className={`font-mono ${display === '—' ? 'text-slate-600' : 'text-green-400'}`}>
|
|
||||||
{display}
|
|
||||||
</span>
|
|
||||||
{unit && <span className="text-slate-500 text-xs shrink-0">{unit}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumInput({ label, value, onChange, units, step = 1 }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1 mb-2">
|
|
||||||
<label className="text-xs text-slate-400">{label}</label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value ?? ''}
|
|
||||||
step={step}
|
|
||||||
onChange={e => onChange(parseFloat(e.target.value) || 0)}
|
|
||||||
className="flex-1 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
|
|
||||||
focus:border-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
{units && <span className="text-xs text-slate-500 shrink-0">{units}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectInput({ label, value, onChange, options }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1 mb-2">
|
|
||||||
<label className="text-xs text-slate-400">{label}</label>
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
className="w-full px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
|
|
||||||
focus:border-blue-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
{options.map(o => (
|
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResultSection({ title, children }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 pb-1 border-b border-slate-800">
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1 text-sm">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrajectoryPage() {
|
|
||||||
const trajectory = useTrajectory()
|
|
||||||
const fileInputRef = useRef(null)
|
|
||||||
const [importError, setImportError] = useState(null)
|
|
||||||
|
|
||||||
// Import engine data from JSON
|
|
||||||
const handleImport = async (e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setImportError(null)
|
|
||||||
const text = await file.text()
|
|
||||||
const data = JSON.parse(text)
|
|
||||||
|
|
||||||
// Handle engine design export
|
|
||||||
if (data.type === 'engine_design') {
|
|
||||||
const engineData = parseEngineImport(data)
|
|
||||||
|
|
||||||
if (engineData.thermoResults) {
|
|
||||||
const { F, Isp, mdot } = engineData.thermoResults
|
|
||||||
const burnTime = data.burnTime || 30
|
|
||||||
|
|
||||||
// Calculate m_dry and m_wet from propellant mass and burn time
|
|
||||||
const m_prop = mdot * burnTime
|
|
||||||
const m_dry = data.m_dry || 20
|
|
||||||
const m_wet = m_dry + m_prop
|
|
||||||
|
|
||||||
trajectory.updateConfig('F', F || 5000)
|
|
||||||
trajectory.updateConfig('Isp', Isp || 250)
|
|
||||||
trajectory.updateConfig('mdot', mdot || 2)
|
|
||||||
trajectory.updateConfig('m_wet', m_wet)
|
|
||||||
trajectory.updateConfig('m_dry', m_dry)
|
|
||||||
trajectory.updateConfig('burnTime', burnTime)
|
|
||||||
|
|
||||||
// Estimate A_ref from nozzle exit area if available
|
|
||||||
if (data.nozzleGeometry?.A_e) {
|
|
||||||
trajectory.updateConfig('A_ref', data.nozzleGeometry.A_e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setImportError(err.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { config, simulationResults, error, currentState } = trajectory
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* Main content area: left panel + center plot */}
|
|
||||||
<div className="flex-1 flex overflow-hidden gap-0">
|
|
||||||
{/* Left panel */}
|
|
||||||
<div className="w-80 bg-slate-900 border-r border-slate-700 overflow-y-auto p-4">
|
|
||||||
<h2 className="text-lg font-bold text-white mb-4">Trajectory Simulation</h2>
|
|
||||||
|
|
||||||
{/* Import */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors"
|
|
||||||
>
|
|
||||||
Import Engine JSON
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
onChange={handleImport}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
{importError && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">{importError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Simulation parameters */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 pb-2 border-b border-slate-700">
|
|
||||||
Engine Parameters
|
|
||||||
</h3>
|
|
||||||
<NumInput
|
|
||||||
label="Thrust (N)"
|
|
||||||
value={config.F}
|
|
||||||
onChange={v => trajectory.updateConfig('F', v)}
|
|
||||||
units="N"
|
|
||||||
step={100}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Isp (s)"
|
|
||||||
value={config.Isp}
|
|
||||||
onChange={v => trajectory.updateConfig('Isp', v)}
|
|
||||||
units="s"
|
|
||||||
step={5}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Mass Flow (kg/s)"
|
|
||||||
value={config.mdot}
|
|
||||||
onChange={v => trajectory.updateConfig('mdot', v)}
|
|
||||||
units="kg/s"
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Wet Mass (kg)"
|
|
||||||
value={config.m_wet}
|
|
||||||
onChange={v => trajectory.updateConfig('m_wet', v)}
|
|
||||||
units="kg"
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Dry Mass (kg)"
|
|
||||||
value={config.m_dry}
|
|
||||||
onChange={v => trajectory.updateConfig('m_dry', v)}
|
|
||||||
units="kg"
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Burn Time (s)"
|
|
||||||
value={config.burnTime}
|
|
||||||
onChange={v => trajectory.updateConfig('burnTime', v)}
|
|
||||||
units="s"
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 pb-2 border-b border-slate-700">
|
|
||||||
Aerodynamics
|
|
||||||
</h3>
|
|
||||||
<NumInput
|
|
||||||
label="Drag Coefficient"
|
|
||||||
value={config.Cd}
|
|
||||||
onChange={v => trajectory.updateConfig('Cd', v)}
|
|
||||||
units=""
|
|
||||||
step={0.01}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Ref Area (m²)"
|
|
||||||
value={config.A_ref}
|
|
||||||
onChange={v => trajectory.updateConfig('A_ref', v)}
|
|
||||||
units="m²"
|
|
||||||
step={0.001}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 pb-2 border-b border-slate-700">
|
|
||||||
Flight Profile
|
|
||||||
</h3>
|
|
||||||
<NumInput
|
|
||||||
label="Pitch Start Alt (m)"
|
|
||||||
value={config.pitchStartAlt}
|
|
||||||
onChange={v => trajectory.updateConfig('pitchStartAlt', v)}
|
|
||||||
units="m"
|
|
||||||
step={10}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Launch Angle (°)"
|
|
||||||
value={config.launchAngle}
|
|
||||||
onChange={v => trajectory.updateConfig('launchAngle', v)}
|
|
||||||
units="°"
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Timestep (s)"
|
|
||||||
value={config.dt}
|
|
||||||
onChange={v => trajectory.updateConfig('dt', v)}
|
|
||||||
units="s"
|
|
||||||
step={0.01}
|
|
||||||
/>
|
|
||||||
<NumInput
|
|
||||||
label="Max Time (s)"
|
|
||||||
value={config.t_max}
|
|
||||||
onChange={v => trajectory.updateConfig('t_max', v)}
|
|
||||||
units="s"
|
|
||||||
step={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Run button */}
|
|
||||||
<button
|
|
||||||
onClick={trajectory.runSim}
|
|
||||||
className="w-full mt-6 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg text-sm font-semibold text-white transition-colors"
|
|
||||||
>
|
|
||||||
Run Simulation
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{simulationResults && (
|
|
||||||
<div className="mt-6 pt-6 border-t border-slate-700">
|
|
||||||
<ResultSection title="Mission Summary">
|
|
||||||
<ResultRow
|
|
||||||
label="Apogee"
|
|
||||||
value={simulationResults.summary.apogee}
|
|
||||||
unit="m"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Apogee Time"
|
|
||||||
value={simulationResults.summary.apogee_t}
|
|
||||||
unit="s"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Max Q"
|
|
||||||
value={simulationResults.summary.max_q}
|
|
||||||
unit="Pa"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Flight Time"
|
|
||||||
value={simulationResults.summary.t_max}
|
|
||||||
unit="s"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Downrange"
|
|
||||||
value={simulationResults.summary.downrange}
|
|
||||||
unit="m"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Final Velocity"
|
|
||||||
value={simulationResults.summary.final_velocity}
|
|
||||||
unit="m/s"
|
|
||||||
/>
|
|
||||||
</ResultSection>
|
|
||||||
|
|
||||||
{currentState && (
|
|
||||||
<ResultSection title="Current State">
|
|
||||||
<ResultRow
|
|
||||||
label="Time"
|
|
||||||
value={currentState.t}
|
|
||||||
unit="s"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Altitude"
|
|
||||||
value={currentState.y}
|
|
||||||
unit="m"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Downrange"
|
|
||||||
value={currentState.x}
|
|
||||||
unit="m"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Velocity"
|
|
||||||
value={Math.hypot(currentState.vx, currentState.vy)}
|
|
||||||
unit="m/s"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Mass"
|
|
||||||
value={currentState.m}
|
|
||||||
unit="kg"
|
|
||||||
/>
|
|
||||||
<ResultRow
|
|
||||||
label="Phase"
|
|
||||||
value={currentState.phase}
|
|
||||||
unit=""
|
|
||||||
/>
|
|
||||||
</ResultSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: plot */}
|
|
||||||
{simulationResults ? (
|
|
||||||
<div className="flex-1 bg-slate-950 p-4 overflow-hidden">
|
|
||||||
<TrajectoryPlot
|
|
||||||
simulationResults={simulationResults}
|
|
||||||
currentState={currentState}
|
|
||||||
onTimeClick={(t) => trajectory.setCurrentTime(t)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex items-center justify-center bg-slate-950">
|
|
||||||
<p className="text-slate-500 text-center">
|
|
||||||
Import an engine and click "Run Simulation" to see trajectory
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline at bottom */}
|
|
||||||
{simulationResults && (
|
|
||||||
<TimelineBar
|
|
||||||
simulationResults={simulationResults}
|
|
||||||
currentTime={trajectory.currentTime}
|
|
||||||
onTimeChange={trajectory.setCurrentTime}
|
|
||||||
isPlaying={trajectory.isPlaying}
|
|
||||||
onPlayPause={() => trajectory.setIsPlaying(!trajectory.isPlaying)}
|
|
||||||
playbackSpeed={trajectory.playbackSpeed}
|
|
||||||
onPlaybackSpeedChange={trajectory.setPlaybackSpeed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user