From c3ad5cc2f8b2581b799b289f5ac231f766dfb8aa Mon Sep 17 00:00:00 2001 From: JimmyBinoculars Date: Sun, 15 Mar 2026 15:22:04 +0000 Subject: [PATCH] Lots of stuff --- src/App.jsx | 8 +- src/components/trajectory/TimelineBar.jsx | 141 --- src/components/trajectory/TrajectoryPlot.jsx | 203 --- src/engine/satelliteCalcs.js | 304 +++++ src/engine/trajectoryCalcs.js | 346 ------ src/hooks/useEngineDesign.js | 55 +- src/hooks/useRocketDesign.js | 62 +- src/hooks/useSatelliteDesign.js | 184 +++ src/hooks/useSolver.js | 32 +- src/hooks/useTrajectory.js | 128 -- src/pages/DocsPage.jsx | 227 ---- src/pages/SatellitePage.jsx | 1169 ++++++++++++++++++ src/pages/TrajectoryPage.jsx | 375 ------ 13 files changed, 1765 insertions(+), 1469 deletions(-) delete mode 100644 src/components/trajectory/TimelineBar.jsx delete mode 100644 src/components/trajectory/TrajectoryPlot.jsx create mode 100644 src/engine/satelliteCalcs.js delete mode 100644 src/engine/trajectoryCalcs.js create mode 100644 src/hooks/useSatelliteDesign.js delete mode 100644 src/hooks/useTrajectory.js create mode 100644 src/pages/SatellitePage.jsx delete mode 100644 src/pages/TrajectoryPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 05b8991..b451ed0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,11 +3,11 @@ import Home from './pages/Home.jsx' import Solver from './pages/Solver.jsx' import EnginePage from './pages/EnginePage.jsx' import RocketPage from './pages/RocketPage.jsx' -import TrajectoryPage from './pages/TrajectoryPage.jsx' import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx' import KnowledgebaseEquationsPage from './pages/KnowledgebaseEquationsPage.jsx' import KnowledgebaseAblativesPage from './pages/KnowledgebaseAblativesPage.jsx' import DocsPage from './pages/DocsPage.jsx' +import SatellitePage from './pages/SatellitePage.jsx' export default function App() { return ( @@ -73,7 +73,7 @@ export default function App() { Rocket `block px-4 py-2 text-sm transition-colors ${ isActive @@ -82,7 +82,7 @@ export default function App() { }` } > - Trajectory + Satellite @@ -151,7 +151,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/trajectory/TimelineBar.jsx b/src/components/trajectory/TimelineBar.jsx deleted file mode 100644 index c92fa8d..0000000 --- a/src/components/trajectory/TimelineBar.jsx +++ /dev/null @@ -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 ( -
-

Run simulation to see timeline

-
- ) - } - - 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 ( -
- {/* Playback controls */} -
- - - - -
- Speed: - -
- - - t = {currentTime.toFixed(2)} s / {maxTime.toFixed(1)} s - -
- - {/* Timeline */} -
-
- {/* Event markers */} - {events.map(event => { - const percent = (event.t / maxTime) * 100 - return ( -
- {/* Label above */} -
- {event.label} -
-
- ) - })} - - {/* Playhead */} -
-
- - {/* Event list below */} -
- {events.slice(0, 5).map(event => ( -
-
- {event.t.toFixed(1)}s - {event.label} -
- ))} - {events.length > 5 && ( -
+{events.length - 5} more events
- )} -
-
-
- ) -} diff --git a/src/components/trajectory/TrajectoryPlot.jsx b/src/components/trajectory/TrajectoryPlot.jsx deleted file mode 100644 index 8f28c15..0000000 --- a/src/components/trajectory/TrajectoryPlot.jsx +++ /dev/null @@ -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 ( -
- -
- ) -} diff --git a/src/engine/satelliteCalcs.js b/src/engine/satelliteCalcs.js new file mode 100644 index 0000000..7a23bcf --- /dev/null +++ b/src/engine/satelliteCalcs.js @@ -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} 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} 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} 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, + } +} diff --git a/src/engine/trajectoryCalcs.js b/src/engine/trajectoryCalcs.js deleted file mode 100644 index 6c20a07..0000000 --- a/src/engine/trajectoryCalcs.js +++ /dev/null @@ -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), - }, - } -} diff --git a/src/hooks/useEngineDesign.js b/src/hooks/useEngineDesign.js index 8581447..c208cb9 100644 --- a/src/hooks/useEngineDesign.js +++ b/src/hooks/useEngineDesign.js @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { solve } from '../engine/solver.js' import { calcChamber, @@ -9,24 +9,49 @@ import { calcStructure, } 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() { + const saved = useMemo(() => loadFromStorage(), []) + // Thermodynamic inputs — SI values, null means not provided - const [thermoInputs, setThermoInputs] = useState({ - p0: null, T0: null, gamma: null, R: null, - mdot: null, F: null, OF: null, At: null, eps: null, pa: null, - }) + const [thermoInputs, setThermoInputs] = useState(saved?.thermoInputs ?? DEFAULTS.thermoInputs) // Engineering design choices - const [chamber, setChamber] = useState({ Lstar: 1.0, contractionRatio: 8, convAngleDeg: 30 }) - const [nozzle, setNozzle] = useState({ type: 'conical', divAngleDeg: 15 }) - const [injector, setInjector] = useState({ type: 'doublet', N: 20, dpFraction: 0.2, Cd: 0.7, rhoFuel: 800, rhoOx: 1140 }) - 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 [feedSystem, setFeedSystem] = useState({ - type: 'pressure_fed', feedFactor: 1.3, - rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300, - }) - const [burnTime, setBurnTime] = useState(30) - const [structure, setStructure] = useState({ materialId: 'al_6061_t6', safetyFactor: 2.0 }) + const [chamber, setChamber] = useState(saved?.chamber ?? DEFAULTS.chamber) + const [nozzle, setNozzle] = useState(saved?.nozzle ?? DEFAULTS.nozzle) + const [injector, setInjector] = useState(saved?.injector ?? DEFAULTS.injector) + const [cooling, setCooling] = useState(saved?.cooling ?? DEFAULTS.cooling) + const [feedSystem, setFeedSystem] = useState(saved?.feedSystem ?? DEFAULTS.feedSystem) + const [burnTime, setBurnTime] = useState(saved?.burnTime ?? DEFAULTS.burnTime) + const [structure, setStructure] = useState(saved?.structure ?? DEFAULTS.structure) + + // Persist to localStorage whenever state changes + 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 const thermoResults = useMemo(() => { diff --git a/src/hooks/useRocketDesign.js b/src/hooks/useRocketDesign.js index 2fd678f..644184c 100644 --- a/src/hooks/useRocketDesign.js +++ b/src/hooks/useRocketDesign.js @@ -1,43 +1,57 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' 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() { + const saved = useMemo(() => loadFromStorage(), []) + // Engine imported from JSON export - const [engineData, setEngineData] = useState(null) + const [engineData, setEngineData] = useState(saved?.engineData ?? DEFAULTS.engineData) // Outer body diameter (m) - const [outerRadius, setOuterRadius] = useState(null) + const [outerRadius, setOuterRadius] = useState(saved?.outerRadius ?? DEFAULTS.outerRadius) // Tank configuration - const [tankConfig, setTankConfig] = useState({ - arrangement: 'tandem', // 'coaxial' | 'tandem' - innerPropellant: 'fuel', // coaxial only - }) + const [tankConfig, setTankConfig] = useState(saved?.tankConfig ?? DEFAULTS.tankConfig) // Propellant densities (kg/m³) — pre-filled from engine injector if available - const [propDensities, setPropDensities] = useState({ - rhoFuel: 800, - rhoOx: 1140, - }) + const [propDensities, setPropDensities] = useState(saved?.propDensities ?? DEFAULTS.propDensities) // Payload - const [payload, setPayload] = useState({ - mass: 0, - bayLength: 0, - }) + const [payload, setPayload] = useState(saved?.payload ?? DEFAULTS.payload) // Structure - const [structure, setStructure] = useState({ - 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 - }) + const [structure, setStructure] = useState(saved?.structure ?? DEFAULTS.structure) // 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) const effectiveBurnTime = useMemo(() => { diff --git a/src/hooks/useSatelliteDesign.js b/src/hooks/useSatelliteDesign.js new file mode 100644 index 0000000..cbdc1c7 --- /dev/null +++ b/src/hooks/useSatelliteDesign.js @@ -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, + } +} diff --git a/src/hooks/useSolver.js b/src/hooks/useSolver.js index ae6f69f..6051ee4 100644 --- a/src/hooks/useSolver.js +++ b/src/hooks/useSolver.js @@ -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 { EQUATIONS, EQUATION_PRESETS } from '../engine/equations.js' import { VARIABLES } from '../engine/variables.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() { + const saved = useMemo(() => loadFromStorage(), []) + // Variable ids currently on the workspace - const [workspaceVarIds, setWorkspaceVarIds] = useState([]) + const [workspaceVarIds, setWorkspaceVarIds] = useState(saved?.workspaceVarIds ?? []) // User-entered values stored in SI: { varId: number } - const [userValues, setUserValues] = useState({}) + const [userValues, setUserValues] = useState(saved?.userValues ?? {}) // Selected unit per variable: { varId: unitId } - const [unitSelections, setUnitSelections] = useState({}) - const [sciNotation, setSciNotation] = useState(false) + const [unitSelections, setUnitSelections] = useState(saved?.unitSelections ?? {}) + const [sciNotation, setSciNotation] = useState(saved?.sciNotation ?? false) // 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 const unitSelectionsRef = useRef(unitSelections) diff --git a/src/hooks/useTrajectory.js b/src/hooks/useTrajectory.js deleted file mode 100644 index e73abb4..0000000 --- a/src/hooks/useTrajectory.js +++ /dev/null @@ -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, - } -} diff --git a/src/pages/DocsPage.jsx b/src/pages/DocsPage.jsx index 6968c62..7e4c851 100644 --- a/src/pages/DocsPage.jsx +++ b/src/pages/DocsPage.jsx @@ -7,7 +7,6 @@ const SECTIONS = [ 'Solver', 'Engine Designer', 'Rocket Designer', - 'Trajectory Simulator', 'Knowledgebase', 'Variables Reference', ] @@ -235,232 +234,6 @@ export default function DocsPage() {
- {/* Trajectory Simulator */} -
-

Trajectory Simulator

-

- Simulate realistic rocket flight trajectories using numerical integration with atmospheric and aerodynamic physics. -

- -
-
-

Overview

-

- The Trajectory Simulator integrates engine design and rocket parameters to predict flight paths. It: -

-
    -
  • Solves the 3-DOF equations of motion in 2D (altitude × downrange)
  • -
  • Includes thrust, atmospheric drag, and gravity (with altitude correction)
  • -
  • Uses real atmosphere model (US Standard Atmosphere)
  • -
  • Tracks vehicle mass during propellant burn
  • -
  • Detects key flight events (liftoff, MECO, apogee, landing)
  • -
  • Enables interactive playback with timeline scrubbing
  • -
-
- -
-

Physics Model

-

The simulator uses 4th-order Runge-Kutta (RK4) integration with the following state vector:

-
- state = [x, y, vx, vy, m] -
-

where:

-
    -
  • x = downrange distance (m)
  • -
  • y = altitude above sea level (m)
  • -
  • vx, vy = velocity components (m/s)
  • -
  • m = vehicle mass (kg)
  • -
-
- -
-

Forces

-

Three forces act on the vehicle:

- -
-

1. Thrust

-
- Fthrust = F × (cos θ, sin θ) -
-

- where F is engine thrust and θ is pitch angle (0° horizontal, 90° vertical). -

-
- -
-

2. Drag

-
- Fdrag = -0.5 × ρ(y) × v² × Cd × Aref × (v̂) -
-

- Atmospheric density ρ varies with altitude; drag opposes the velocity vector. -

-
- -
-

3. Gravity (Altitude-Corrected)

-
- g(y) = g₀ × (RE / (RE + y))² -
-

- where g₀ = 9.80665 m/s² and RE = 6,371 km (Earth radius). -

-
-
- -
-

Atmosphere Model

-

- Density vs. altitude follows a piecewise US Standard Atmosphere: -

-
    -
  • Troposphere (0–11 km): Scale height H = 8,500 m → ρ = ρ₀ × exp(−h/H)
  • -
  • Stratosphere (11–20 km): H = 6,500 m
  • -
  • Higher altitude: H = 4,500 m (simplified)
  • -
-

- Sea-level density ρ₀ = 1.225 kg/m³. This provides realistic dynamic pressure (Q) changes during ascent. -

-
- -
-

Pitch Program (Guidance)

-

- By default, the vehicle follows a gravity turn: -

-
    -
  • Initial phase: Vertical hold (90°) until reaching "Pitch Start Altitude"
  • -
  • Gravity turn: Pitch angle equals the velocity vector angle (no additional steering)
  • -
  • User events: Override pitch at specific times via guidance events (advanced)
  • -
-
- -
-

Mass Depletion

-

- During engine burn: -

-
- dm/dt = −ṁ -
-

- where ṁ is mass flow rate (mdot). The engine shuts off when m ≤ m_dry (dry mass) or burn duration elapsed. -

-
- -
-

Integration Method: RK4

-

- 4th-order Runge-Kutta (RK4) provides excellent accuracy with a fixed timestep: -

-
- state(t+dt) = state(t) + (dt/6) × (k₁ + 2k₂ + 2k₃ + k₄) -
-

- Default timestep: 0.05 s (produces ~12,000 steps for a typical 600 s flight). -

-
- -
-

Input Parameters

-

Configure the simulation with:

-
-
-

Engine Parameters

-
    -
  • Thrust F (N)
  • -
  • Specific Impulse Isp (s) — reference only in this tool
  • -
  • Mass Flow Rate (kg/s)
  • -
  • Wet Mass m_wet, Dry Mass m_dry (kg)
  • -
  • Burn Time (s)
  • -
-
-
-

Aerodynamics

-
    -
  • Drag Coefficient Cd (default 0.3)
  • -
  • Reference Area A_ref (m²) — cross-sectional area
  • -
-
-
-

Flight Profile

-
    -
  • Pitch Start Altitude (m) — when to begin gravity turn
  • -
  • Launch Angle (°) — typically 90° for vertical launch
  • -
  • Timestep dt (s) — RK4 step size
  • -
  • Max Simulation Time (s) — cutoff to prevent infinite loops
  • -
-
-
-
- -
-

Auto-Detected Events

-

- The simulator automatically identifies key mission milestones: -

-
    -
  • Liftoff: t ≈ 0.1 s
  • -
  • MECO: Main Engine Cutoff, when m ≤ m_dry
  • -
  • Max Q: Peak dynamic pressure (0.5 × ρ × v²)
  • -
  • Apogee: Maximum altitude (where vy crosses zero)
  • -
  • Landing: Final impact at y = 0
  • -
-
- -
-

Results & Visualization

-

- After simulation runs: -

-
    -
  • Altitude vs. Downrange Plot: 2D trajectory curve with auto-scaled axes
  • -
  • Event Markers: Color-coded points (green=engine, red=MECO, amber=Max Q, blue=apogee, purple=landing)
  • -
  • Timeline Scrubber: Interactive playback controls; click plot to seek
  • -
  • Live Readout: Current altitude, velocity, mass, phase during playback
  • -
-
- -
-

Workflow: Engine to Trajectory

-
    -
  1. Design an engine in /design/engine
  2. -
  3. Export engine JSON
  4. -
  5. Go to /design/trajectory
  6. -
  7. Click "Import Engine JSON" and select the exported file
  8. -
  9. Parameters auto-populate (thrust, Isp, mass flow, burn time, nozzle area)
  10. -
  11. Adjust aerodynamics (Cd, A_ref) and flight profile as needed
  12. -
  13. Click "Run Simulation"
  14. -
  15. Use Play/Pause, speed controls, and timeline scrubbing to explore the flight
  16. -
-
- -
-

Tips & Limitations

-
-
-

✓ Strengths

-
    -
  • Fast RK4 integration (typical flight solves in <100 ms)
  • -
  • Realistic atmosphere and gravity models
  • -
  • Good accuracy for point-mass trajectory (±5% apogee typical)
  • -
-
-
-

⚠ Limitations

-
    -
  • Point-mass model: Ignores rotational dynamics, wind, spinning
  • -
  • 2D only: Assumes flat Earth in vertical plane (no coriolis, curvature)
  • -
  • Constant Cd: Drag coefficient doesn't vary with Mach or angle of attack
  • -
  • Simple pitch program: Gravity turn only; no attitude control or TVC
  • -
  • Sea-level launch: Assumes pad at elevation 0
  • -
-
-
-
-
-
- {/* Knowledgebase */}

Knowledgebase

diff --git a/src/pages/SatellitePage.jsx b/src/pages/SatellitePage.jsx new file mode 100644 index 0000000..a91c182 --- /dev/null +++ b/src/pages/SatellitePage.jsx @@ -0,0 +1,1169 @@ +import { useState, useMemo } from 'react' +import { useSatelliteDesign } from '../hooks/useSatelliteDesign.js' +import DesignSection from '../components/engine/DesignSection.jsx' +import { formatValue } from '../engine/format.js' +import { STRUCTURAL_MATERIALS } from '../engine/knowledgebaseData.js' +import { CUBESAT_FORM_FACTORS, MODULATION_EB_N0 } from '../engine/satelliteCalcs.js' + +// ── Local primitives ──────────────────────────────────────────────────────── + +function NumInput({ label, value, onChange, units, step, min, max, placeholder }) { + function handleChange(str) { + if (str === '') { onChange(null); return } + const v = parseFloat(str) + if (!isNaN(v)) onChange(v) + } + return ( +
+ + handleChange(e.target.value)} + className="flex-1 min-w-0 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm + text-slate-100 focus:border-blue-500 focus:outline-none placeholder-slate-600" + /> + {units && {units}} +
+ ) +} + +function SelectInput({ label, value, onChange, options }) { + return ( +
+ + +
+ ) +} + +function TextInput({ label, value, onChange, placeholder }) { + return ( +
+ + onChange(e.target.value)} + className="flex-1 min-w-0 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm + text-slate-100 focus:border-blue-500 focus:outline-none placeholder-slate-600" + /> +
+ ) +} + +function CheckboxRow({ label, value, onChange }) { + return ( +
+ + onChange(e.target.checked)} + className="w-4 h-4 accent-blue-500 cursor-pointer" + /> +
+ ) +} + +function ResultSection({ title, children }) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function ResultRow({ label, value, units, colorClass }) { + const color = colorClass ?? 'text-green-400' + return ( +
+ {label} + + {value == null ? '—' : value} + {value != null && units ? {units} : null} + +
+ ) +} + +function StatusBadge({ status, labels }) { + const cfg = { + ok: { bg: 'bg-green-900/40 border-green-700', text: 'text-green-300' }, + deficit: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' }, + failed: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' }, + marginal: { bg: 'bg-amber-900/40 border-amber-700', text: 'text-amber-300' }, + overrun: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' }, + critical: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' }, + unknown: { bg: 'bg-slate-800 border-slate-600', text: 'text-slate-400' }, + } + const s = cfg[status] ?? cfg.unknown + return ( +
+ {labels[status] ?? status} +
+ ) +} + +// ── CubeSat SVG diagram ───────────────────────────────────────────────────── + +function CubeSatDiagram({ formFactor, deployablePanelArea }) { + const ff = CUBESAT_FORM_FACTORS[formFactor] ?? CUBESAT_FORM_FACTORS['3U'] + const [W_mm,, L_mm] = ff.dims // width, (height is depth), length = long axis + + // Scale body to fit in a ~120 × 200 px drawing area within 400×300 viewBox + const maxDrawW = 110, maxDrawH = 190 + const scale = Math.min(maxDrawW / W_mm, maxDrawH / L_mm) + const drawW = W_mm * scale + const drawH = L_mm * scale + const cx = 200, cy = 148 + + const x0 = cx - drawW / 2 + const y0 = cy - drawH / 2 + + const hasDeploy = deployablePanelArea > 0 + const panelW = hasDeploy ? Math.min(78, Math.max(20, deployablePanelArea * 12000)) : 0 + const panelH = drawH * 0.6 + const panelY = cy - panelH / 2 + + return ( + + + + + + + + + + + + + + + + + + {/* Background grid */} + + + {/* Deployable solar panels */} + {hasDeploy && ( + <> + + + {/* Hinge connectors */} + + + + + + )} + + {/* Main body */} + + + {/* Body-mounted solar cell strips */} + + + + {/* Stack divider lines (for multi-U) */} + {L_mm > 150 && ( + + )} + {L_mm > 300 && ( + + )} + + {/* Antenna stub (top) */} + + + + {/* Sun direction arrow */} + + Sun + + {/* Dimension lines */} + + {/* Width arrow */} + + + + {/* Length arrow */} + + + + + {W_mm}mm + {L_mm}mm + + {/* Form factor label */} + {formFactor} + + max {(ff.maxMass / 1000).toFixed(1)} kg + + + ) +} + +// ── Power orbit chart ─────────────────────────────────────────────────────── + +function PowerOrbitChart({ orbitData, powerBudget, powerInputs }) { + const chartData = useMemo(() => { + if (!orbitData || !powerBudget || !powerBudget.C_batt) return null + const { t_sun, t_eclipse, T } = orbitData + const { chargeRateW, dischargeRateW, C_batt, DOD = 0.3 } = powerBudget + const DOD_val = powerInputs?.DOD ?? DOD ?? 0.3 + const C_batt_Ws = C_batt * 3600 // Wh → Ws + + const N = 120 + const pts = [] + let soc = 1.0 // start at full charge + + // Steady-state: find starting SoC at eclipse entry + // SoC_min = 1 - DOD_val (minimum allowed) + // Actually let's simulate a full cycle starting from "end of sunlight" + for (let i = 0; i <= N; i++) { + const t = (i / N) * T + let s + if (t <= t_sun) { + // Sunlight: recharging from SoC after eclipse + // We start from SoC_after_eclipse and charge up + s = (1 - DOD_val) + (chargeRateW / C_batt_Ws) * t + } else { + // Eclipse: discharging + s = 1.0 - (dischargeRateW / C_batt_Ws) * (t - t_sun) + } + pts.push({ t, soc: Math.min(1.0, Math.max(0, s)) }) + } + return { pts, t_sun, t_eclipse, T, DOD_val, soc_min: 1 - DOD_val } + }, [orbitData, powerBudget, powerInputs]) + + if (!chartData) { + return ( +
+

Enter orbit and power inputs to see chart

+
+ ) + } + + const { pts, t_sun, T, DOD_val } = chartData + const mg = { top: 24, right: 18, bottom: 36, left: 44 } + const W = 480, H = 170 + const plotW = W - mg.left - mg.right + const plotH = H - mg.top - mg.bottom + + const getX = t => mg.left + (t / T) * plotW + const getY = soc => mg.top + (1 - soc) * plotH + + const linePts = pts.map(p => `${getX(p.t).toFixed(1)},${getY(p.soc).toFixed(1)}`).join(' ') + + // Color segments: red below DOD line + const minY = getY(0) + const dodY = getY(1 - DOD_val) + const sunX = getX(t_sun) + const T_min = (T / 60).toFixed(1) + const tS_min = (t_sun / 60).toFixed(1) + + return ( + + + + + + + + {/* Eclipse shading */} + + ECLIPSE + SUNLIGHT + + {/* DOD minimum line */} + + DOD limit + + {/* 100% charge line */} + + + {/* SoC trace */} + + + {/* Eclipse start tick */} + + + {/* Axes */} + + + + {/* Y ticks */} + {[0, 0.25, 0.5, 0.75, 1.0].map(s => ( + + + + {(s * 100).toFixed(0)}% + + + ))} + + {/* X ticks: 0, 25%, 50%, 75%, 100% of orbital period */} + {[0, 0.25, 0.5, 0.75, 1.0].map(f => ( + + + + {((f * T) / 60).toFixed(0)}m + + + ))} + + {/* Axis labels */} + State of Charge + + Time in orbit (min) — period {T_min} min + + + ) +} + +// ── Link waterfall chart ──────────────────────────────────────────────────── + +function LinkWaterfallChart({ linkBudget }) { + if (!linkBudget?.cascade) { + return ( +
+

Enter link budget inputs to see chart

+
+ ) + } + + const cascade = linkBudget.cascade + const W = 480, H = 230 + const labelW = 106, valueW = 56, barAreaW = W - labelW - valueW - 12 + const rowH = 20, firstY = 18 + + // Scale bars by magnitude; FSPL dominates + const maxMag = Math.max(...cascade.map(r => Math.abs(r.delta)), 1) + const barScale = barAreaW * 0.9 / maxMag + + const typeColor = { base: '#3b82f6', gain: '#10b981', loss: '#ef4444', noise: '#8b5cf6', req: '#f97316', result: '#60a5fa' } + + const statusColor = linkBudget.status === 'ok' ? '#10b981' : linkBudget.status === 'marginal' ? '#f59e0b' : '#ef4444' + + return ( + + + Link Budget Cascade — bars proportional to contribution magnitude + + + {cascade.map((row, i) => { + const y = firstY + i * rowH + const mag = Math.abs(row.delta) + const barW = mag * barScale + const isGain = row.delta > 0 + const color = typeColor[row.type] ?? '#94a3b8' + const isLast = i === cascade.length - 1 + + return ( + + {/* Row background (alternating) */} + {i % 2 === 0 && ( + + )} + {/* Label */} + + {row.label} + + {/* Bar */} + {isLast ? ( + // Link margin bar: centered, colored by status + + ) : ( + + )} + {/* Center reference line */} + {!isLast && ( + + )} + {/* Value */} + + {row.delta >= 0 ? '+' : ''}{row.delta.toFixed(1)} dB + + + ) + })} + + {/* Center axis */} + + + ) +} + +// ── Thermal source chart ──────────────────────────────────────────────────── + +function ThermalSourceChart({ thermalBalance }) { + if (!thermalBalance) { + return ( +
+

Enter thermal inputs to see chart

+
+ ) + } + + const { Q_sun, Q_earth_ir, Q_albedo, Q_int, Q_total_hot, Q_total_cold, T_hot, T_cold } = thermalBalance + + const W = 480, H = 200 + const leftW = 90, rightW = 110, barAreaW = W - leftW - rightW - 16 + const rowH = 32 + + const maxQ = Math.max(Q_total_hot, 0.1) + const scaleBar = barAreaW / maxQ + + const hotSegments = [ + { label: 'Solar', val: Q_sun, color: '#fbbf24' }, + { label: 'Earth IR', val: Q_earth_ir, color: '#ef4444' }, + { label: 'Albedo', val: Q_albedo, color: '#f97316' }, + { label: 'Internal', val: Q_int, color: '#8b5cf6' }, + ] + const coldSegments = [ + { label: 'Earth IR ×0.5', val: Q_earth_ir * 0.5, color: '#fca5a5' }, + { label: 'Internal ×0.3', val: Q_int * 0.3, color: '#c4b5fd' }, + ] + + function StackedBar({ segments, total, y }) { + let x = leftW + const bars = segments.map((s, i) => { + const w = s.val * scaleBar + const bar = + x += w + return bar + }) + return ( + <> + {bars} + {/* Total width indicator */} + + + ) + } + + const T_hot_C = (T_hot - 273.15).toFixed(1) + const T_cold_C = (T_cold - 273.15).toFixed(1) + const hotColor = thermalBalance.status === 'ok' ? '#10b981' : thermalBalance.status === 'marginal' ? '#f59e0b' : '#ef4444' + + return ( + + {/* Hot case */} + Hot + + + {T_hot.toFixed(0)} K ({T_hot_C}°C) + + + {/* Cold case */} + Cold + + + {T_cold.toFixed(0)} K ({T_cold_C}°C) + + + {/* Legend */} + {[...hotSegments, { label: 'Earth IR (cold)', color: '#fca5a5' }, { label: 'Internal (cold)', color: '#c4b5fd' }].map((s, i) => ( + + + {s.label} + + ))} + + {/* Axis */} + + {[0, 0.25, 0.5, 0.75, 1.0].map(f => { + const x = leftW + f * barAreaW + return ( + + + + {(f * maxQ).toFixed(1)}W + + + ) + })} + + ) +} + +// ── Mass breakdown chart ──────────────────────────────────────────────────── + +function MassBreakdownChart({ massBudget }) { + if (!massBudget) { + return ( +
+

Enter subsystem masses to see chart

+
+ ) + } + + const { breakdown, totalMass, limit } = massBudget + const W = 480, H = 220 + const labelW = 78, valueW = 52 + const barAreaW = W - labelW - valueW - 10 + const rowH = 22 + const firstY = 16 + + const maxBar = limit ?? Math.max(totalMass, 1) + const barScale = barAreaW / maxBar + + const colors = { + structure: '#64748b', eps: '#f59e0b', adcs: '#10b981', + comms: '#3b82f6', cdh: '#8b5cf6', propulsion: '#ef4444', payload: '#06b6d4', + } + const limitX = labelW + limit * barScale + + return ( + + {/* Subsystem rows */} + {breakdown.map((row, i) => { + const y = firstY + i * rowH + const barW = Math.max(0, row.mass * barScale) + const col = colors[row.key] ?? '#94a3b8' + return ( + + {i % 2 === 0 && } + + {row.name} + + + + {row.mass.toFixed(0)} g + + + ) + })} + + {/* Total row */} + {(() => { + const y = firstY + breakdown.length * rowH + 4 + const barW = Math.min(totalMass * barScale, barAreaW) + const over = limit != null && totalMass > limit + const col = over ? '#ef4444' : '#60a5fa' + return ( + + + + Total + + + {over && ( + + )} + + {totalMass.toFixed(0)} g + + + ) + })()} + + {/* Limit line */} + {limit != null && ( + <> + + + limit {limit}g + + + )} + + ) +} + +// ── Main page ─────────────────────────────────────────────────────────────── + +const MATERIAL_OPTIONS = STRUCTURAL_MATERIALS.map(m => ({ value: m.id, label: m.name })) + +const FORM_FACTOR_OPTIONS = Object.keys(CUBESAT_FORM_FACTORS).map(k => ({ value: k, label: k })) + +export default function SatellitePage() { + const { + orbitInputs, setOrbitInput, + powerInputs, setPowerInput, + linkInputs, setLinkInput, + thermalInputs, setThermalInput, + subsystems, setSubsystem, + orbitData, + powerBudget, + linkBudget, + thermalBalance, + massBudget, + } = useSatelliteDesign() + + const [activeTab, setActiveTab] = useState('power') + + const tabs = [ + { id: 'power', label: 'Power' }, + { id: 'link', label: 'Link' }, + { id: 'thermal', label: 'Thermal' }, + { id: 'subsystems', label: 'Subsystems' }, + ] + + // ── Helpers ── + function fmtTime(s) { return s == null ? null : `${(s / 60).toFixed(1)}` } + function fmtPct(f) { return f == null ? null : `${(f * 100).toFixed(1)} %` } + function fmtK(k) { return k == null ? null : `${k.toFixed(0)} K (${(k - 273.15).toFixed(1)} °C)` } + function fmtW(w) { return w == null ? null : formatValue(w) } + function fmtdB(v) { return v == null ? null : `${v.toFixed(2)}` } + function fmtM2(v) { return v == null ? null : v.toFixed(5) } + + function marginColor(v) { + if (v == null) return undefined + return v >= 0 ? 'text-green-400' : 'text-red-400' + } + function thermalColor(T) { + if (T == null) return undefined + return T < 323 ? 'text-green-400' : T < 373 ? 'text-amber-400' : 'text-red-400' + } + function linkMarginColor(m) { + if (m == null) return undefined + return m >= 3 ? 'text-green-400' : m >= 0 ? 'text-amber-400' : 'text-red-400' + } + + // ── Left panel content per tab ── + function renderLeftInputs() { + if (activeTab === 'power') return ( + <> + +
+ setPowerInput('solarCellType', v)} + options={['Si', 'GaAs', 'InGaP']} /> + setPowerInput('etaCell', v)} step={0.01} units="—" /> + setPowerInput('etaPacking', v)} step={0.01} units="—" /> + setPowerInput('deployablePanelArea', v ?? 0)} + step={0.001} min={0} units="m²" placeholder="0" /> +
+
+ + +
+ setPowerInput('DOD', v)} step={0.05} min={0} max={1} units="—" /> + setPowerInput('etaCharge', v)} step={0.01} units="—" /> + setPowerInput('etaDischarge', v)} step={0.01} units="—" /> + setPowerInput('designLife', v)} step={0.5} min={0} units="yr" /> + setPowerInput('degradationPerYear', v)} + step={0.005} units="—" placeholder="-0.030" /> +
+
+ + +
+ setPowerInput('P_sunlight_load', v)} step={0.1} min={0} units="W" /> + setPowerInput('P_eclipse_load', v)} step={0.1} min={0} units="W" /> +
+
+ + ) + + if (activeTab === 'link') return ( + <> + +
+ setLinkInput('P_tx_W', v)} step={0.1} min={0} units="W" /> + setLinkInput('G_tx_dBi', v)} step={0.5} units="dBi" /> + setLinkInput('L_feed_dB', v)} step={0.1} min={0} units="dB" /> + setLinkInput('frequencyMHz', v)} step={1} min={1} units="MHz" /> + { + setLinkInput('modulation', v) + setLinkInput('Eb_N0_req_dB', MODULATION_EB_N0[v] ?? linkInputs.Eb_N0_req_dB) + }} + options={Object.keys(MODULATION_EB_N0)} /> +
+
+ + +
+ setLinkInput('elevationAngleDeg', v)} step={1} min={1} max={90} units="°" /> + setLinkInput('L_atm_dB', v)} step={0.1} min={0} units="dB" /> + setLinkInput('L_point_dB', v)} step={0.1} min={0} units="dB" /> +
+
+ + +
+ setLinkInput('G_rx_dBi', v)} step={0.5} units="dBi" /> + setLinkInput('T_sys_K', v)} step={10} min={1} units="K" /> + setLinkInput('dataRateBps', v)} step={100} min={1} units="bps" /> + setLinkInput('Eb_N0_req_dB', v)} step={0.1} units="dB" /> +
+
+ + ) + + if (activeTab === 'thermal') return ( + <> + +
+ setThermalInput('alpha', v)} step={0.01} min={0} max={1} units="—" /> + setThermalInput('epsilon', v)} step={0.01} min={0} max={1} units="—" /> +
+
+ + +
+ setThermalInput('A_sun', v)} step={0.001} min={0} units="m²" /> + setThermalInput('A_earth', v)} step={0.001} min={0} units="m²" /> + setThermalInput('A_rad', v)} step={0.001} min={0} units="m²" /> +
+
+ + +
+ setThermalInput('Q_int', v)} step={0.1} min={0} units="W" /> +

+ Orbit-averaged solar flux and Earth IR are derived from the orbit parameters above. +

+
+
+ + ) + + if (activeTab === 'subsystems') return ( + <> + +
+ setSubsystem('adcs', { controlMode: v })} + options={['sun-pointing', 'nadir', '3-axis stabilised']} /> + setSubsystem('adcs', { pointingAccuracyDeg: v })} step={0.5} units="°" /> + setSubsystem('adcs', { reactionWheels: v })} /> + setSubsystem('adcs', { magnetorquers: v })} /> + setSubsystem('adcs', { mass: v ?? 0 })} step={10} min={0} units="g" /> + setSubsystem('adcs', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" /> + setSubsystem('adcs', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" /> +
+
+ + +
+ setSubsystem('comms', { frequencyMHz: v })} step={1} units="MHz" /> + setSubsystem('comms', { P_tx_W: v })} step={0.1} min={0} units="W" /> + setSubsystem('comms', { antennaType: v })} + options={['dipole', 'patch', 'helical', 'yagi']} /> + setSubsystem('comms', { modulation: v })} + options={Object.keys(MODULATION_EB_N0)} /> + setSubsystem('comms', { mass: v ?? 0 })} step={10} min={0} units="g" /> + setSubsystem('comms', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" /> + setSubsystem('comms', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" /> +

Detailed link budget analysis in the Link tab.

+
+
+ + +
+ setSubsystem('cdh', { processor: v })} + options={['ARM Cortex-M4', 'ARM Cortex-A9', 'LEON3', 'Raspberry Pi CM']} /> + setSubsystem('cdh', { storageGB: v })} step={1} min={0} units="GB" /> + setSubsystem('cdh', { housekeepingRateHz: v })} step={0.5} min={0} units="Hz" /> + setSubsystem('cdh', { mass: v ?? 0 })} step={10} min={0} units="g" /> + setSubsystem('cdh', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" /> + setSubsystem('cdh', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" /> +
+
+ + +
+ setSubsystem('eps', { batteryChemistry: v })} + options={['Li-ion', 'LiPo', 'NiH2']} /> + setSubsystem('eps', { solarCellType: v })} + options={['Si', 'GaAs', 'InGaP']} /> + setSubsystem('eps', { mass: v ?? 0 })} step={10} min={0} units="g" /> +

Solar array and battery sizing configured in Power tab.

+
+
+ + +
+ setSubsystem('structure', { materialId: v })} + options={MATERIAL_OPTIONS} /> + setSubsystem('structure', { configuration: v })} + options={['standard', 'custom']} /> + setSubsystem('structure', { mass: v ?? 0 })} step={10} min={0} units="g" /> +
+
+ + +
+ setSubsystem('propulsion', { type: v })} + options={[ + { value: 'none', label: 'None' }, + { value: 'cold_gas', label: 'Cold Gas' }, + { value: 'monoprop', label: 'Monopropellant' }, + { value: 'electrospray', label: 'Electrospray' }, + ]} /> + {subsystems.propulsion.type !== 'none' && ( + <> + setSubsystem('propulsion', { propellant: v })} + placeholder="e.g. N₂, Hydrazine" /> + setSubsystem('propulsion', { mass: v ?? 0 })} step={10} min={0} units="g" /> + setSubsystem('propulsion', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" /> + setSubsystem('propulsion', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" /> + + )} +
+
+ + +
+ setSubsystem('payload', { name: v })} + placeholder="e.g. Multispectral Imager" /> + setSubsystem('payload', { type: v })} + options={['Earth Obs', 'Science', 'Comms', 'Tech Demo', 'Other']} /> + setSubsystem('payload', { mass: v ?? 0 })} step={10} min={0} units="g" /> + setSubsystem('payload', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" /> + setSubsystem('payload', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" /> + setSubsystem('payload', { interface: v })} + options={['UART', 'SPI', 'I2C', 'Ethernet', 'USB', 'Custom']} /> +
+
+ + ) + return null + } + + // ── Right panel content per tab ── + function renderRightResults() { + if (activeTab === 'power') return ( + <> + + + + + + + + + + + + + + + + + + + + + + + {powerBudget && ( + + )} + + ) + + if (activeTab === 'link') return ( + <> + + + + + + + + + + + + + + + + + + + + + {linkBudget && ( + + )} + + ) + + if (activeTab === 'thermal') return ( + <> + + + + + + + + + +
+ +
+
+ + + + + + + + + + + {thermalBalance && ( + + )} + + ) + + if (activeTab === 'subsystems') return ( + <> + + {massBudget?.breakdown.map(row => ( + + ))} + {massBudget && ( + <> +
+ + + + +
+ + + )} +
+ + + + + + + + ) + return null + } + + return ( +
+ {/* Sub-header */} +
+
+

Satellite Bus Design

+
+ {tabs.map(tab => ( + + ))} +
+
+
+ + {/* Three-column body */} +
+ {/* ── Left: inputs ── */} +
+ {/* Orbit & Mission always visible */} + +
+ setPowerInput('formFactor', v)} + options={FORM_FACTOR_OPTIONS} /> + setOrbitInput('altitudeKm', v)} step={10} min={200} units="km" /> + setOrbitInput('betaAngleDeg', v)} step={5} min={0} max={90} units="°" + placeholder="0" /> +
+
+ + {renderLeftInputs()} +
+ + {/* ── Centre: CubeSat diagram + chart ── */} +
+ {/* CubeSat diagram — fixed 240px height */} +
+ +
+ + {/* Tab chart — fills remaining space */} +
+ {activeTab === 'power' && } + {activeTab === 'link' && } + {activeTab === 'thermal' && } + {activeTab === 'subsystems' && } +
+
+ + {/* ── Right: results ── */} +
+ {renderRightResults()} +
+
+
+ ) +} diff --git a/src/pages/TrajectoryPage.jsx b/src/pages/TrajectoryPage.jsx deleted file mode 100644 index aa9d078..0000000 --- a/src/pages/TrajectoryPage.jsx +++ /dev/null @@ -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 ( -
- {label} -
- - {display} - - {unit && {unit}} -
-
- ) -} - -function NumInput({ label, value, onChange, units, step = 1 }) { - return ( -
- -
- 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 && {units}} -
-
- ) -} - -function SelectInput({ label, value, onChange, options }) { - return ( -
- - -
- ) -} - -function ResultSection({ title, children }) { - return ( -
-

- {title} -

-
{children}
-
- ) -} - -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 ( - -
- {/* Main content area: left panel + center plot */} -
- {/* Left panel */} -
-

Trajectory Simulation

- - {/* Import */} -
- - - {importError && ( -

{importError}

- )} -
- - {/* Simulation parameters */} -
-

- Engine Parameters -

- trajectory.updateConfig('F', v)} - units="N" - step={100} - /> - trajectory.updateConfig('Isp', v)} - units="s" - step={5} - /> - trajectory.updateConfig('mdot', v)} - units="kg/s" - step={0.1} - /> - trajectory.updateConfig('m_wet', v)} - units="kg" - /> - trajectory.updateConfig('m_dry', v)} - units="kg" - /> - trajectory.updateConfig('burnTime', v)} - units="s" - step={1} - /> -
- -
-

- Aerodynamics -

- trajectory.updateConfig('Cd', v)} - units="" - step={0.01} - /> - trajectory.updateConfig('A_ref', v)} - units="m²" - step={0.001} - /> -
- -
-

- Flight Profile -

- trajectory.updateConfig('pitchStartAlt', v)} - units="m" - step={10} - /> - trajectory.updateConfig('launchAngle', v)} - units="°" - step={1} - /> - trajectory.updateConfig('dt', v)} - units="s" - step={0.01} - /> - trajectory.updateConfig('t_max', v)} - units="s" - step={10} - /> -
- - {/* Run button */} - - - {error && ( -

{error}

- )} - - {/* Results */} - {simulationResults && ( -
- - - - - - - - - - {currentState && ( - - - - - - - - - )} -
- )} -
- - {/* Center: plot */} - {simulationResults ? ( -
- trajectory.setCurrentTime(t)} - /> -
- ) : ( -
-

- Import an engine and click "Run Simulation" to see trajectory -

-
- )} -
- - {/* Timeline at bottom */} - {simulationResults && ( - trajectory.setIsPlaying(!trajectory.isPlaying)} - playbackSpeed={trajectory.playbackSpeed} - onPlaybackSpeedChange={trajectory.setPlaybackSpeed} - /> - )} -
-
- ) -}