Many improvments + install script

This commit is contained in:
2026-03-03 18:11:00 +00:00
parent 03452517b5
commit 386f6fe928
12 changed files with 710 additions and 44 deletions

202
install-service.sh Normal file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -e
# Rocketry Service Installer
# Installs the React SPA as a systemd service on the system
# Preflight checks
command -v node >/dev/null || { echo "Error: node not found"; exit 1; }
command -v npm >/dev/null || { echo "Error: npm not found"; exit 1; }
command -v sudo >/dev/null || { echo "Error: sudo not found"; exit 1; }
echo "Rocketry Service Installer"
echo "=========================="
echo ""
# Port prompt
read -p "Port to run on [default: 8080]: " PORT
PORT=${PORT:-8080}
# Validate port
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "Error: Invalid port number. Must be between 1 and 65535."
exit 1
fi
echo ""
echo "Building application..."
npm run build
# Install directory
INSTALL_DIR="/opt/rocketry"
echo "Installing to $INSTALL_DIR..."
sudo mkdir -p "$INSTALL_DIR"
sudo cp -r dist "$INSTALL_DIR/"
# Write server.js to /opt/rocketry/server.js
echo "Writing server.js..."
sudo tee "$INSTALL_DIR/server.js" > /dev/null <<'SERVEREOF'
#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 8080;
const DIST_DIR = path.join(__dirname, 'dist');
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.json': 'application/json',
};
const server = http.createServer((req, res) => {
// Normalize URL
let urlPath = decodeURIComponent(req.url);
if (urlPath === '/') {
urlPath = '/index.html';
}
// Resolve file path
const filePath = path.join(DIST_DIR, urlPath);
// Security check: ensure we're within DIST_DIR
if (!filePath.startsWith(DIST_DIR)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
return;
}
fs.stat(filePath, (err, stats) => {
if (err) {
// File not found; serve index.html for SPA routing (unless it's a file with extension)
const ext = path.extname(urlPath);
if (ext && ext !== '.html') {
// Likely a missing asset file
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
// SPA fallback: serve index.html
const indexPath = path.join(DIST_DIR, 'index.html');
fs.readFile(indexPath, 'utf-8', (readErr, data) => {
if (readErr) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(data);
});
return;
}
if (stats.isDirectory()) {
// Try to serve index.html from the directory
const indexPath = path.join(filePath, 'index.html');
fs.readFile(indexPath, 'utf-8', (readErr, data) => {
if (readErr) {
// Directory exists but no index.html; fall back to SPA index
const mainIndexPath = path.join(DIST_DIR, 'index.html');
fs.readFile(mainIndexPath, 'utf-8', (mainErr, mainData) => {
if (mainErr) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(mainData);
});
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(data);
});
return;
}
// Serve file with appropriate MIME type
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
fs.readFile(filePath, (readErr, data) => {
if (readErr) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
});
server.listen(PORT, () => {
console.log(`Rocketry server listening on http://localhost:${PORT}`);
});
server.on('error', (err) => {
if (err.code === 'EACCES') {
console.error(`Error: Permission denied. Port ${PORT} may require elevated privileges.`);
} else if (err.code === 'EADDRINUSE') {
console.error(`Error: Port ${PORT} is already in use.`);
} else {
console.error('Server error:', err.message);
}
process.exit(1);
});
SERVEREOF
# Make server.js executable
sudo chmod +x "$INSTALL_DIR/server.js"
# Write systemd unit file
echo "Writing systemd service file..."
sudo bash -c "cat > /etc/systemd/system/rocketry.service" <<UNITEOF
[Unit]
Description=Rocketry Equation Solver
After=network.target
[Service]
Type=simple
User=nobody
WorkingDirectory=/opt/rocketry
ExecStart=/usr/bin/node /opt/rocketry/server.js
Environment="PORT=$PORT"
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNITEOF
# Enable and start service
echo "Configuring systemd service..."
sudo systemctl daemon-reload
sudo systemctl enable rocketry
sudo systemctl restart rocketry
echo ""
echo "✓ Rocketry service installed successfully!"
echo ""
echo " Location: $INSTALL_DIR"
echo " Port: $PORT"
echo " URL: http://localhost:$PORT"
echo ""
echo "Service status:"
sudo systemctl status rocketry --no-pager | sed 's/^/ /'
echo ""
echo "Useful commands:"
echo " Check status: sudo systemctl status rocketry"
echo " View logs: sudo journalctl -u rocketry -f"
echo " Restart: sudo systemctl restart rocketry"
echo " Stop: sudo systemctl stop rocketry"
echo " Uninstall: sudo systemctl disable rocketry && sudo rm -rf $INSTALL_DIR /etc/systemd/system/rocketry.service && sudo systemctl daemon-reload"

View File

@@ -4,6 +4,7 @@ import Solver from './pages/Solver.jsx'
import EnginePage from './pages/EnginePage.jsx'
import RocketPage from './pages/RocketPage.jsx'
import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx'
import KnowledgebaseEquationsPage from './pages/KnowledgebaseEquationsPage.jsx'
export default function App() {
return (
@@ -89,6 +90,18 @@ export default function App() {
>
Fuels / Oxidisers
</NavLink>
<NavLink
to="/knowledgebase/equations"
className={({ isActive }) =>
`block px-4 py-2 text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
}`
}
>
Equations
</NavLink>
</div>
</div>
</nav>
@@ -99,6 +112,7 @@ export default function App() {
<Route path="/design/engine" element={<EnginePage />} />
<Route path="/design/rocket" element={<RocketPage />} />
<Route path="/knowledgebase/fuels" element={<KnowledgebaseFuelsPage />} />
<Route path="/knowledgebase/equations" element={<KnowledgebaseEquationsPage />} />
</Routes>
</div>
)

View File

@@ -0,0 +1,20 @@
import { Component } from 'react'
export default class ErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(e) {
return { error: e }
}
render() {
if (this.state.error) {
return (
<div className="flex items-center justify-center h-full text-slate-500 text-sm">
3D view unavailable
</div>
)
}
return this.props.children
}
}

View File

@@ -96,10 +96,7 @@ export function calcCooling(thermo, cooling, chamberGeom) {
const { Dt, Dc, Lc } = chamberGeom
// Simplified Bartz heat flux [W/m²] using typical exhaust gas properties
const mu = 6e-5 // Pa·s — typical rocket exhaust dynamic viscosity
const cp = 2000 // J/(kg·K)
const Pr = 0.7
const T_wall = 800 // K — assumed hot-gas-side wall temperature
const { mu = 6e-5, cp = 2000, Pr = 0.7, T_wall = 800 } = cooling
const q_est = (0.026 / Math.pow(Dt, 0.2)) *
(Math.pow(mu, 0.2) * cp / Math.pow(Pr, 0.6)) *
@@ -110,9 +107,9 @@ export function calcCooling(thermo, cooling, chamberGeom) {
const q_total = q_est * chamberArea
const { channelCount } = cooling
const channelArea = channelCount > 0 ? q_total / channelCount : 0
const q_perChannel = channelCount > 0 ? q_total / channelCount : 0
return { method, q_est, q_total, channelCount, channelArea }
return { method, q_est, q_total, channelCount, q_perChannel }
}
if (method === 'film') {

View File

@@ -16,6 +16,7 @@ export const EQUATIONS = [
name: 'Fundamental Thrust Equation',
formula: 'F = ṁ·Vₑ + (pₑ pₐ)·Aₑ',
category: 'Thrust',
description: 'Calculates total thrust from momentum thrust and pressure thrust. Used when exit conditions and mass flow are known.',
variables: ['F', 'mdot', 'Ve', 'pe', 'pa', 'Ae'],
solvers: {
F: v => v.mdot * v.Ve + (v.pe - v.pa) * v.Ae,
@@ -40,6 +41,7 @@ export const EQUATIONS = [
name: 'Thrust from Isp',
formula: 'F = ṁ·Isp·g₀',
category: 'Thrust',
description: 'Relates thrust, mass flow, and specific impulse. Commonly used when Isp is known instead of exhaust velocity.',
variables: ['F', 'mdot', 'Isp', 'g0'],
solvers: {
F: v => v.mdot * v.Isp * v.g0,
@@ -55,6 +57,7 @@ export const EQUATIONS = [
name: 'Effective Exhaust Velocity',
formula: 'cₑ = F / ṁ',
category: 'Thrust',
description: 'Defines effective exhaust velocity as thrust divided by mass flow. Includes both momentum and pressure thrust effects.',
variables: ['ceff', 'F', 'mdot'],
solvers: {
ceff: v => v.F / v.mdot,
@@ -69,6 +72,7 @@ export const EQUATIONS = [
name: 'Isp from Effective Exhaust Velocity',
formula: 'Isp = cₑ / g₀',
category: 'Specific Impulse',
description: 'Converts effective exhaust velocity to specific impulse. Related by the standard gravitational acceleration.',
variables: ['Isp', 'ceff', 'g0'],
solvers: {
Isp: v => v.ceff / v.g0,
@@ -82,6 +86,7 @@ export const EQUATIONS = [
name: 'Isp from Thrust & Mass Flow',
formula: 'Isp = F / (ṁ·g₀)',
category: 'Specific Impulse',
description: 'Directly calculates specific impulse from thrust and mass flow. Fundamental performance metric for rocket engines.',
variables: ['Isp', 'F', 'mdot', 'g0'],
solvers: {
Isp: v => v.F / (v.mdot * v.g0),
@@ -97,6 +102,7 @@ export const EQUATIONS = [
name: 'Characteristic Velocity',
formula: 'c* = p₀·Aₜ / ṁ',
category: 'Nozzle Performance',
description: 'Defines characteristic velocity as a measure of combustion chamber performance. Independent of nozzle expansion.',
variables: ['cstar', 'p0', 'At', 'mdot'],
solvers: {
cstar: v => (v.p0 * v.At) / v.mdot,
@@ -111,6 +117,7 @@ export const EQUATIONS = [
name: 'c* from Thrust Coefficient & Isp',
formula: 'c* = Isp·g₀ / Cꜰ',
category: 'Nozzle Performance',
description: 'Relates characteristic velocity to thrust coefficient and specific impulse. Useful for nozzle analysis.',
variables: ['cstar', 'Isp', 'g0', 'CF'],
solvers: {
cstar: v => (v.Isp * v.g0) / v.CF,
@@ -126,6 +133,7 @@ export const EQUATIONS = [
name: 'Thrust Coefficient',
formula: 'Cꜰ = F / (p₀·Aₜ)',
category: 'Nozzle Performance',
description: 'Defines thrust coefficient as a dimensionless nozzle performance factor. Typical values are 1.41.9 for ideal nozzles.',
variables: ['CF', 'F', 'p0', 'At'],
solvers: {
CF: v => v.F / (v.p0 * v.At),
@@ -141,6 +149,7 @@ export const EQUATIONS = [
name: 'Tsiolkovsky Rocket Equation (cₑ)',
formula: 'Δv = cₑ·ln(m₀/mf)',
category: 'Rocket Equation',
description: 'The rocket equation in terms of effective exhaust velocity. Calculates delta-v available from propellant mass ratio.',
variables: ['dv', 'ceff', 'm0', 'mf'],
solvers: {
dv: v => v.ceff * Math.log(v.m0 / v.mf),
@@ -155,6 +164,7 @@ export const EQUATIONS = [
name: 'Tsiolkovsky Rocket Equation (Isp)',
formula: 'Δv = Isp·g₀·ln(m₀/mf)',
category: 'Rocket Equation',
description: 'The classic Tsiolkovsky rocket equation using specific impulse. Foundation of trajectory design and mission planning.',
variables: ['dv', 'Isp', 'g0', 'm0', 'mf'],
solvers: {
dv: v => v.Isp * v.g0 * Math.log(v.m0 / v.mf),
@@ -170,6 +180,7 @@ export const EQUATIONS = [
name: 'Mass Ratio',
formula: 'MR = m₀ / mf',
category: 'Rocket Equation',
description: 'Ratio of initial to final mass. Determines the exponential factor in the Tsiolkovsky equation.',
variables: ['MR', 'm0', 'mf'],
solvers: {
MR: v => v.m0 / v.mf,
@@ -183,6 +194,7 @@ export const EQUATIONS = [
name: 'Propellant Mass',
formula: 'mₚ = m₀ mf',
category: 'Rocket Equation',
description: 'Mass of propellant consumed during burn. Difference between initial (wet) and final (dry) mass.',
variables: ['mp', 'm0', 'mf'],
solvers: {
mp: v => v.m0 - v.mf,
@@ -196,6 +208,7 @@ export const EQUATIONS = [
name: 'Propellant Mass Fraction',
formula: 'ζ = mₚ / m₀',
category: 'Rocket Equation',
description: 'Fraction of total mass that is propellant. Higher values indicate more efficient rocket design.',
variables: ['zeta', 'mp', 'm0'],
solvers: {
zeta: v => v.mp / v.m0,
@@ -209,6 +222,7 @@ export const EQUATIONS = [
name: 'Burn Time',
formula: 'tᵦ = mₚ / ṁ',
category: 'Rocket Equation',
description: 'Duration of main engine burn. Calculated from total propellant mass and mass flow rate.',
variables: ['tb', 'mp', 'mdot'],
solvers: {
tb: v => v.mp / v.mdot,
@@ -223,6 +237,7 @@ export const EQUATIONS = [
name: 'Isentropic Temperature Ratio',
formula: 'T/T₀ = (1 + (γ1)/2·M²)⁻¹',
category: 'Isentropic Flow',
description: 'Static to stagnation temperature ratio for isentropic flow. Key relation for compressible flow analysis.',
variables: ['T', 'T0', 'M', 'gamma'],
solvers: {
T: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.M * v.M),
@@ -241,6 +256,7 @@ export const EQUATIONS = [
name: 'Isentropic Pressure Ratio',
formula: 'p/p₀ = (1 + (γ1)/2·M²)^(γ/(γ1))',
category: 'Isentropic Flow',
description: 'Static to stagnation pressure ratio for isentropic flow. Essential for nozzle and intake analysis.',
variables: ['p_static', 'p0', 'M', 'gamma'],
solvers: {
p_static: v => {
@@ -265,6 +281,7 @@ export const EQUATIONS = [
name: 'Isentropic Density Ratio',
formula: 'ρ/ρ₀ = (1 + (γ1)/2·M²)^(1/(γ1))',
category: 'Isentropic Flow',
description: 'Static to stagnation density ratio for isentropic flow. Used in continuity equations and flow analysis.',
variables: ['rho', 'rho0', 'M', 'gamma'],
solvers: {
rho: v => {
@@ -287,6 +304,7 @@ export const EQUATIONS = [
name: 'Speed of Sound',
formula: 'a = √(γ·R·T)',
category: 'Isentropic Flow',
description: 'Local speed of sound as a function of temperature and gas properties. Used to define Mach number.',
variables: ['a_sound', 'gamma', 'R', 'T'],
solvers: {
a_sound: v => Math.sqrt(v.gamma * v.R * v.T),
@@ -301,6 +319,7 @@ export const EQUATIONS = [
name: 'Flow Velocity',
formula: 'v = M·a',
category: 'Isentropic Flow',
description: 'Flow velocity as Mach number times local speed of sound. Relates Mach number to actual velocity.',
variables: ['v_flow', 'M', 'a_sound'],
solvers: {
v_flow: v => v.M * v.a_sound,
@@ -315,6 +334,7 @@ export const EQUATIONS = [
name: 'Nozzle Expansion Ratio',
formula: 'ε = Aₑ / Aₜ',
category: 'Nozzle Geometry',
description: 'Ratio of exit area to throat area. Determines the degree of expansion in the nozzle divergent section.',
variables: ['eps', 'Ae', 'At'],
solvers: {
eps: v => v.Ae / v.At,
@@ -328,6 +348,7 @@ export const EQUATIONS = [
name: 'Isentropic Area Ratio (supersonic)',
formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ1)/2·Mₑ²)]^((γ+1)/(2(γ1)))',
category: 'Nozzle Geometry',
description: 'Relates exit area ratio to exit Mach number for isentropic supersonic flow. Critical for nozzle design.',
variables: ['eps', 'Me', 'gamma'],
solvers: {
eps: v => areaRatioFromMach(v.Me, v.gamma),
@@ -340,6 +361,7 @@ export const EQUATIONS = [
name: 'Choked Throat Mass Flow',
formula: 'ṁ = Aₜ·p₀·√(γ/(R·T₀))·(2/(γ+1))^((γ+1)/(2(γ1)))',
category: 'Nozzle Geometry',
description: 'Mass flow through a choked throat condition. Maximum mass flow for given stagnation conditions.',
variables: ['mdot', 'At', 'p0', 'gamma', 'R', 'T0'],
solvers: {
mdot: v => {
@@ -380,6 +402,7 @@ export const EQUATIONS = [
name: 'Nozzle Exit Pressure',
formula: 'pₑ = p₀·(1 + (γ1)/2·Mₑ²)^(γ/(γ1))',
category: 'Nozzle Geometry',
description: 'Static pressure at the nozzle exit. Calculated from chamber pressure and exit Mach number.',
variables: ['pe', 'p0', 'Me', 'gamma'],
solvers: {
pe: v => {
@@ -402,6 +425,7 @@ export const EQUATIONS = [
name: 'Nozzle Exit Temperature',
formula: 'Tₑ = T₀ / (1 + (γ1)/2·Mₑ²)',
category: 'Nozzle Geometry',
description: 'Static temperature at the nozzle exit. Lower than stagnation temperature due to expansion.',
variables: ['Te', 'T0', 'Me', 'gamma'],
solvers: {
Te: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.Me * v.Me),
@@ -416,6 +440,7 @@ export const EQUATIONS = [
name: 'Nozzle Exit Velocity',
formula: 'Vₑ = Mₑ·√(γ·R·Tₑ)',
category: 'Nozzle Geometry',
description: 'Actual exhaust velocity at nozzle exit. Product of exit Mach number and local speed of sound.',
variables: ['Ve', 'Me', 'gamma', 'R', 'Te'],
solvers: {
Ve: v => v.Me * Math.sqrt(v.gamma * v.R * v.Te),
@@ -432,6 +457,7 @@ export const EQUATIONS = [
name: 'Thrust-to-Weight Ratio',
formula: 'TWR = F / (mᵥ·g₀)',
category: 'Performance',
description: 'Ratio of thrust to vehicle weight. Values > 1 allow acceleration and ascent.',
variables: ['TWR', 'F', 'm_vehicle', 'g0'],
solvers: {
TWR: v => v.F / (v.m_vehicle * v.g0),
@@ -446,6 +472,7 @@ export const EQUATIONS = [
name: 'Total Impulse',
formula: 'J = F·tᵦ',
category: 'Performance',
description: 'Total impulse as integrated thrust over burn time. Used for classification and performance assessment.',
variables: ['J', 'F', 'tb'],
solvers: {
J: v => v.F * v.tb,
@@ -459,6 +486,7 @@ export const EQUATIONS = [
name: 'Isp from Total Impulse',
formula: 'Isp = J / (mₚ·g₀)',
category: 'Performance',
description: 'Calculates specific impulse from total impulse and propellant mass. Alternative method for performance analysis.',
variables: ['Isp', 'J', 'mp', 'g0'],
solvers: {
Isp: v => v.J / (v.mp * v.g0),
@@ -474,6 +502,7 @@ export const EQUATIONS = [
name: 'Oxidiser/Fuel Ratio',
formula: 'O/F = ṁₒₓ / ṁf',
category: 'Propellant',
description: 'Mass flow ratio of oxidiser to fuel. Optimum value depends on propellant pair and design parameters.',
variables: ['OF', 'mdot_ox', 'mdot_f'],
solvers: {
OF: v => v.mdot_ox / v.mdot_f,
@@ -487,6 +516,7 @@ export const EQUATIONS = [
name: 'Total Mass Flow',
formula: 'ṁ = ṁₒₓ + ṁf',
category: 'Propellant',
description: 'Total propellant mass flow is sum of oxidiser and fuel mass flows. Used in bipropellant engines.',
variables: ['mdot', 'mdot_ox', 'mdot_f'],
solvers: {
mdot: v => v.mdot_ox + v.mdot_f,
@@ -500,12 +530,70 @@ export const EQUATIONS = [
name: 'Mass Flow Split from O/F',
formula: 'ṁ_f = ṁ/(1+OF), ṁ_ox = ṁ·OF/(1+OF)',
category: 'Propellant',
description: 'Splits total mass flow into oxidiser and fuel flows given their mass ratio. Used for injection design.',
variables: ['mdot', 'OF', 'mdot_f', 'mdot_ox'],
solvers: {
mdot_f: Object.assign(v => v.mdot / (1 + v.OF), { requires: () => ['mdot', 'OF'] }),
mdot_ox: Object.assign(v => v.mdot * v.OF / (1 + v.OF), { requires: () => ['mdot', 'OF'] }),
},
},
// ── Chamber Characteristic Length ──────────────────────────────────────
{
id: 'chamber_characteristic_length',
name: 'Chamber Characteristic Length',
formula: 'L* = Vᶜ / Aₜ',
category: 'Nozzle Performance',
description: 'Characteristic chamber length relates chamber volume to throat area. Typical range 0.51.5 m for liquid rockets.',
variables: ['Lstar', 'Vc', 'At'],
solvers: {
Lstar: v => v.Vc / v.At,
Vc: v => v.Lstar * v.At,
At: v => v.Vc / v.Lstar,
},
},
// ── Specific Gas Constant from Molar Mass ─────────────────────────────
{
id: 'specific_gas_constant',
name: 'Specific Gas Constant',
formula: 'R = R̄ / Mₘ',
category: 'Isentropic Flow',
description: 'Specific gas constant for a particular gas, derived from the universal gas constant and molar mass. R̄ = 8.314 J/(mol·K).',
variables: ['R', 'Mm'],
solvers: {
R: v => 8.314 / v.Mm,
Mm: v => 8.314 / v.R,
},
},
// ── Vehicle Mass Identity ─────────────────────────────────────────────
{
id: 'vehicle_mass_identity',
name: 'Vehicle Mass (Wet Mass)',
formula: 'mᵥ = m₀',
category: 'Performance',
description: 'Identity relation: vehicle mass equals initial (wet) mass for TWR calculation.',
variables: ['m_vehicle', 'm0'],
solvers: {
m_vehicle: v => v.m0,
m0: v => v.m_vehicle,
},
},
// ── Subsonic Area Ratio ───────────────────────────────────────────────
{
id: 'area_ratio_mach_subsonic',
name: 'Isentropic Area Ratio (subsonic)',
formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ1)/2·Mₑ²)]^((γ+1)/(2(γ1)))',
category: 'Nozzle Geometry',
description: 'Isentropic area ratio as a function of Mach number (subsonic branch). Used for intake and subsonic flow analysis.',
variables: ['eps', 'Me', 'gamma'],
solvers: {
eps: v => areaRatioFromMach(v.Me, v.gamma),
Me: v => machFromAreaRatio(v.eps, v.gamma, false),
},
},
]
// Equation presets: named groups that seed the workspace with a useful set of variables

View File

@@ -115,6 +115,12 @@ export const UNIT_FAMILIES = {
{ id: '—', label: '—', toSI: v => v, fromSI: v => v },
],
},
molar_mass: {
units: [
{ id: 'g/mol', label: 'g/mol', toSI: v => v * 0.001, fromSI: v => v * 1000 },
{ id: 'kg/mol', label: 'kg/mol', toSI: v => v, fromSI: v => v },
],
},
}
export function getUnitsForFamily(familyId) {

View File

@@ -92,6 +92,20 @@ export const VARIABLES = {
unitFamily: 'area',
},
Lstar: {
id: 'Lstar', symbol: 'L*', name: 'Characteristic Chamber Length', units: 'm',
description: 'Characteristic length of the combustion chamber: L* = Vᶜ/Aₜ. Higher values indicate larger chambers.',
category: 'Nozzle Performance',
unitFamily: 'length',
},
Vc: {
id: 'Vc', symbol: 'Vᶜ', name: 'Chamber Volume', units: 'm³',
description: 'Total volume of the combustion chamber',
category: 'Nozzle Performance',
unitFamily: 'volume',
},
// ── Tsiolkovsky Rocket Equation ───────────────────────────────────────
dv: {
id: 'dv', symbol: 'Δv', name: 'Delta-v', units: 'm/s',
@@ -155,6 +169,13 @@ export const VARIABLES = {
category: 'Isentropic Flow',
unitFamily: 'spec_gas',
},
Mm: {
id: 'Mm', symbol: 'Mₘ', name: 'Molar Mass', units: 'kg/mol',
description: 'Molar mass of the exhaust gas mixture. Used to calculate specific gas constant: R = R̄/Mₘ',
category: 'Isentropic Flow',
unitFamily: 'molar_mass',
},
T: {
id: 'T', symbol: 'T', name: 'Static Temperature', units: 'K',
description: 'Local static temperature at a cross-section',

View File

@@ -19,7 +19,7 @@ export function useEngineDesign() {
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: 40, filmFraction: 0.05 })
const [cooling, setCooling] = useState({ method: 'regenerative', channelCount: 60, mu: 6e-5, cp: 2000, Pr: 0.7, T_wall: 800, filmFraction: 0.05 })
const [feedSystem, setFeedSystem] = useState({
type: 'pressure_fed', feedFactor: 1.3,
rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300,

View File

@@ -9,6 +9,7 @@ import {
} from '../engine/engineExportImport.js'
import DesignSection from '../components/engine/DesignSection.jsx'
import EngineModel3D from '../components/engine/EngineModel3D.jsx'
import ErrorBoundary from '../components/ErrorBoundary.jsx'
import { formatValue } from '../engine/format.js'
import { getUnitsForFamily } from '../engine/units.js'
import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.js'
@@ -524,6 +525,7 @@ export default function EnginePage() {
infoKey="coolingMethod"
/>
{cooling.method === 'regenerative' && (
<>
<NumInput
label="Channel Count"
value={cooling.channelCount}
@@ -531,6 +533,40 @@ export default function EnginePage() {
infoKey="channelCount"
step="1"
/>
<NumInput
label="Dynamic Viscosity (μ)"
value={cooling.mu}
onChange={v => setCooling(c => ({ ...c, mu: v }))}
units="Pa·s"
step="1e-5"
placeholder="6e-5"
/>
<NumInput
label="Specific Heat (cₚ)"
value={cooling.cp}
onChange={v => setCooling(c => ({ ...c, cp: v }))}
units="J/(kg·K)"
step="100"
placeholder="2000"
/>
<NumInput
label="Prandtl Number (Pr)"
value={cooling.Pr}
onChange={v => setCooling(c => ({ ...c, Pr: v }))}
units="—"
step="0.05"
placeholder="0.7"
/>
<NumInput
label="Wall Temperature (T_wall)"
value={cooling.T_wall}
onChange={v => setCooling(c => ({ ...c, T_wall: v }))}
unitFamily="temperature"
defaultUnitId="K"
step="50"
placeholder="800"
/>
</>
)}
{cooling.method === 'film' && (
<NumInput
@@ -593,7 +629,9 @@ export default function EnginePage() {
{/* ── Centre: 3D Model ── */}
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
<ErrorBoundary>
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
</ErrorBoundary>
</div>
{/* ── Right: Results ── */}
@@ -647,7 +685,7 @@ export default function EnginePage() {
<ResultRow label="Est. Heat Flux" value={cr.q_est} unit="W/m²" infoKey="q_est_result" />
<ResultRow label="Total Heat Load" value={cr.q_total} unit="W" infoKey="q_total_result" />
<ResultRow label="Channel Count" value={cr.channelCount} unit="—" infoKey="channelCount_result" />
<ResultRow label="Channel Area (each)" value={cr.channelArea} unitFamily="area" defaultUnitId="mm²" infoKey="channelArea_result" />
<ResultRow label="Heat load / channel" value={cr.q_perChannel} unit="W" infoKey="q_perChannel_result" />
</>
)}
{cr.method === 'film' && (

View File

@@ -23,9 +23,12 @@ export default function Home() {
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
Equation Solver
</h2>
<p className="text-sm text-slate-400">
<p className="text-sm text-slate-400 mb-3">
Drag-and-drop rocketry variables. Enter known values and unknowns solve automatically using constraint propagation.
</p>
<p className="text-xs text-slate-500 mb-3">
Includes: thrust, Isp, c*, thrust coefficient, Tsiolkovsky, mass/burn-time, isentropic flow, nozzle geometry, exit conditions, TWR, total impulse, O/F ratio
</p>
<div className="mt-4 text-sm text-blue-400 font-medium">Open solver </div>
</Link>
@@ -43,14 +46,33 @@ export default function Home() {
<div className="mt-4 text-sm text-blue-400 font-medium">Open designer </div>
</Link>
<div className="p-6 rounded-xl border border-slate-800 bg-slate-900/50 opacity-60 cursor-not-allowed">
<div className="text-3xl mb-3">📊</div>
<h2 className="text-xl font-semibold text-slate-500 mb-2">Trajectory Plotter</h2>
<p className="text-sm text-slate-500">
Simulate flight trajectories with drag and gravity losses.
<Link
to="/design/rocket"
className="group block p-6 rounded-xl border border-slate-700 bg-slate-900 hover:border-blue-500 hover:bg-slate-800 transition-colors"
>
<div className="text-3xl mb-3">🛸</div>
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
Rocket Designer
</h2>
<p className="text-sm text-slate-400">
Design and configure multi-stage rockets. Visualize your design in 3D with mass budget and delta-v calculations.
</p>
<div className="mt-4 text-sm text-slate-600 font-medium">Coming soon</div>
</div>
<div className="mt-4 text-sm text-blue-400 font-medium">Open designer </div>
</Link>
<Link
to="/knowledgebase/fuels"
className="group block p-6 rounded-xl border border-slate-700 bg-slate-900 hover:border-blue-500 hover:bg-slate-800 transition-colors"
>
<div className="text-3xl mb-3">📚</div>
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
Fuels & Oxidisers
</h2>
<p className="text-sm text-slate-400">
Browse a comprehensive database of rocket propellants with chemical properties, performance data, and specifications.
</p>
<div className="mt-4 text-sm text-blue-400 font-medium">View database </div>
</Link>
</div>
</div>
</div>

View File

@@ -0,0 +1,225 @@
import { useState, useMemo, useRef } from 'react'
import React from 'react'
import { EQUATIONS } from '../engine/equations.js'
import { VARIABLES } from '../engine/variables.js'
export default function KnowledgebaseEquationsPage() {
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('All')
const [activeVariableFilter, setActiveVariableFilter] = useState([])
const equationRefs = useRef({})
// Get all unique categories
const categories = ['All', ...new Set(EQUATIONS.map(eq => eq.category))]
// Apply all three filters
const filteredEquations = useMemo(() => {
const q = search.toLowerCase().trim()
return EQUATIONS.filter(eq => {
// Category filter
if (categoryFilter !== 'All' && eq.category !== categoryFilter) return false
// Text search
if (q) {
const nameMatch = eq.name.toLowerCase().includes(q)
const formulaMatch = eq.formula.toLowerCase().includes(q)
const descMatch = eq.description.toLowerCase().includes(q)
const varMatch = eq.variables.some(varId => {
const v = VARIABLES[varId]
return v?.name.toLowerCase().includes(q) || v?.symbol?.toLowerCase().includes(q)
})
if (!nameMatch && !formulaMatch && !descMatch && !varMatch) return false
}
// Variable filter — equation must have ALL active variables
if (activeVariableFilter.length > 0) {
const hasAllVars = activeVariableFilter.every(varId => eq.variables.includes(varId))
if (!hasAllVars) return false
}
return true
})
}, [search, categoryFilter, activeVariableFilter])
// Toggle variable in active filter
const toggleVariableFilter = (varId) => {
setActiveVariableFilter(prev =>
prev.includes(varId)
? prev.filter(v => v !== varId)
: [...prev, varId]
)
}
// Remove variable from filter
const removeVariableFilter = (varId) => {
setActiveVariableFilter(prev => prev.filter(v => v !== varId))
}
// Scroll to equation
const scrollToEquation = (eqId) => {
equationRefs.current[eqId]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<div className="flex flex-1 overflow-hidden">
{/* ── Left panel ────────────────────────────────────────────────────── */}
<div className="w-72 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900">
{/* Search */}
<div className="p-3 border-b border-slate-700">
<input
type="text"
placeholder="Search equations…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full px-3 py-1.5 rounded-md bg-slate-800 border border-slate-600 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
/>
</div>
{/* Category filter pills */}
<div className="px-3 pt-2 pb-2 border-b border-slate-700 flex flex-wrap gap-1">
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
categoryFilter === cat
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{cat}
</button>
))}
</div>
{/* Equation list */}
<div className="flex-1 overflow-y-auto">
{filteredEquations.length === 0 && (
<p className="px-4 py-6 text-sm text-slate-500 text-center">No equations match your filters.</p>
)}
{filteredEquations.map(eq => (
<button
key={eq.id}
onClick={() => scrollToEquation(eq.id)}
className="w-full text-left px-3 py-2 border-b border-slate-800 text-sm hover:bg-slate-800 transition-colors"
>
<div className="font-medium text-slate-100 truncate">{eq.name}</div>
<div className="text-xs text-slate-500 mt-0.5 truncate">{eq.category}</div>
</button>
))}
</div>
</div>
{/* ── Right panel ───────────────────────────────────────────────────── */}
<div className="flex-1 flex flex-col overflow-hidden bg-slate-950">
{/* Active variable filter strip */}
{activeVariableFilter.length > 0 && (
<div className="px-4 py-3 border-b border-slate-700 bg-slate-900/50 flex items-center gap-3 flex-wrap">
<span className="text-xs text-slate-400">Active filters:</span>
{activeVariableFilter.map(varId => {
const v = VARIABLES[varId]
return (
<div
key={varId}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-900/30 border border-blue-700/50 text-xs text-blue-200"
>
<span>{v?.symbol} · {v?.name}</span>
<button
onClick={() => removeVariableFilter(varId)}
className="ml-1 text-blue-400 hover:text-blue-200 transition-colors text-sm leading-none"
>
×
</button>
</div>
)
})}
<button
onClick={() => setActiveVariableFilter([])}
className="text-xs text-slate-400 hover:text-slate-200 transition-colors ml-auto px-2 py-1 rounded hover:bg-slate-800"
>
Clear filters
</button>
</div>
)}
{/* Equations grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4 max-w-5xl">
{filteredEquations.length === 0 ? (
<div className="text-center text-slate-500 py-12 text-sm">
No equations match your filters.
</div>
) : (
filteredEquations.map(eq => (
<EquationCard
key={eq.id}
eq={eq}
activeVariableFilter={activeVariableFilter}
onToggleVariable={toggleVariableFilter}
ref={el => equationRefs.current[eq.id] = el}
/>
))
)}
</div>
</div>
</div>
</div>
)
}
const EquationCard = React.forwardRef(function EquationCard({ eq, activeVariableFilter, onToggleVariable }, ref) {
return (
<div ref={ref} className="rounded-xl border border-slate-700 bg-slate-900 overflow-hidden">
{/* Card header */}
<div className="px-4 pt-3 pb-2 flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-100 text-sm">{eq.name}</span>
<span className="text-[10px] font-medium text-slate-400 bg-slate-800 border border-slate-700 px-1.5 py-0.5 rounded">
{eq.category}
</span>
</div>
</div>
</div>
{/* Formula */}
<div className="mx-4 mb-3 rounded-lg bg-slate-950 border border-slate-700 px-4 py-2.5">
<div className="font-mono text-sm text-amber-300 text-center leading-relaxed">
{eq.formula}
</div>
</div>
{/* Description */}
<div className="px-4 mb-3">
<p className="text-slate-300 text-sm">{eq.description}</p>
</div>
{/* Variable chips */}
<div className="px-4 pb-3">
<div className="flex flex-wrap gap-2">
{eq.variables.map(varId => {
const v = VARIABLES[varId]
const isActive = activeVariableFilter.includes(varId)
return (
<button
key={varId}
onClick={() => onToggleVariable(varId)}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border ${
isActive
? 'bg-blue-900/50 border-blue-600 text-blue-100'
: 'bg-slate-800 border-slate-600 text-slate-300 hover:border-blue-500 hover:text-slate-100'
}`}
>
<span className="font-mono font-bold">{v?.symbol}</span>
{' · '}
<span>{v?.name}</span>
{v?.units && <span className="text-slate-500"> ({v.units})</span>}
</button>
)
})}
</div>
</div>
</div>
)
})

View File

@@ -3,18 +3,30 @@ import { PropellantModal } from '../components/PropellantModal.jsx'
import { useRocketDesign } from '../hooks/useRocketDesign.js'
import DesignSection from '../components/engine/DesignSection.jsx'
import RocketModel3D from '../components/rocket/RocketModel3D.jsx'
import ErrorBoundary from '../components/ErrorBoundary.jsx'
import { formatValue } from '../engine/format.js'
import { getUnitsForFamily } from '../engine/units.js'
import { exportRocketJSON, parseRocketImport, downloadBlob } from '../engine/rocketExportImport.js'
/* ── Tiny input / result primitives (self-contained, no unit dropdown) ── */
function NumInput({ label, value, onChange, units, step, placeholder }) {
const display = value == null ? '' : value
function NumInput({ label, value, onChange, units, step, placeholder, unitFamily, defaultUnitId }) {
const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null
const [selectedUnitId, setSelectedUnitId] = useState(
() => defaultUnitId ?? unitList?.[0]?.id ?? null,
)
const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null
const displayValue = value == null
? ''
: selectedUnit ? selectedUnit.fromSI(value) : value
function handleChange(str) {
if (str === '') { onChange(null); return }
const n = parseFloat(str)
if (!isNaN(n)) onChange(n)
const typed = parseFloat(str)
if (isNaN(typed)) return
onChange(selectedUnit ? selectedUnit.toSI(typed) : typed)
}
return (
@@ -22,14 +34,27 @@ function NumInput({ label, value, onChange, units, step, placeholder }) {
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
<input
type="number"
value={display}
value={displayValue}
step={step ?? 'any'}
placeholder={placeholder ?? ''}
onChange={e => handleChange(e.target.value)}
className="flex-1 min-w-0 w-24 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 && <span className="text-xs text-slate-500 shrink-0 whitespace-nowrap">{units}</span>}
{unitList && unitList.length > 1 ? (
<select
value={selectedUnit?.id ?? ''}
onChange={e => setSelectedUnitId(e.target.value)}
className="px-1 py-0.5 bg-slate-700 border border-slate-600 rounded text-xs text-slate-300
focus:outline-none cursor-pointer shrink-0"
>
{unitList.map(u => <option key={u.id} value={u.id}>{u.label}</option>)}
</select>
) : unitList?.[0] ? (
<span className="text-xs text-slate-500 shrink-0">{unitList[0].label}</span>
) : units ? (
<span className="text-xs text-slate-500 shrink-0 whitespace-nowrap">{units}</span>
) : null}
</div>
)
}
@@ -235,10 +260,11 @@ export default function RocketPage() {
<DesignSection title="Vehicle Geometry">
<NumInput
label="Outer Diameter"
value={displayRadius != null ? parseFloat((displayRadius * 2000).toPrecision(6)) : null}
onChange={v => setOuterRadius(v != null ? v / 2000 : null)}
units="mm"
step="10"
value={displayRadius != null ? displayRadius * 2 : null}
onChange={v => setOuterRadius(v != null ? v / 2 : null)}
unitFamily="length"
defaultUnitId="mm"
step="0.01"
placeholder={suggestedRadius ? `${(suggestedRadius * 2000).toFixed(0)}` : 'e.g. 300'}
/>
{suggestedRadius && !outerRadius && (
@@ -292,7 +318,8 @@ export default function RocketPage() {
label="Fuel Density (ρ_f)"
value={propDensities.rhoFuel}
onChange={v => setPropDensities(p => ({ ...p, rhoFuel: v ?? 800 }))}
units="kg/m³"
unitFamily="density"
defaultUnitId="kg/m³"
step="10"
placeholder="800"
/>
@@ -300,7 +327,8 @@ export default function RocketPage() {
label="Oxidizer Density (ρ_ox)"
value={propDensities.rhoOx}
onChange={v => setPropDensities(p => ({ ...p, rhoOx: v ?? 1140 }))}
units="kg/m³"
unitFamily="density"
defaultUnitId="kg/m³"
step="10"
placeholder="1140"
/>
@@ -308,8 +336,9 @@ export default function RocketPage() {
label="Burn Time"
value={structure.burnTime}
onChange={v => setStructure(s => ({ ...s, burnTime: v }))}
units="s"
step="1"
unitFamily="time"
defaultUnitId="s"
step="0.1"
placeholder={engineData?.inputs?.burnTime ?? '30'}
/>
{effectiveBurnTime != null && !structure.burnTime && (
@@ -324,16 +353,18 @@ export default function RocketPage() {
label="Payload Mass"
value={payload.mass}
onChange={v => setPayload(p => ({ ...p, mass: v ?? 0 }))}
units="kg"
unitFamily="mass"
defaultUnitId="kg"
step="1"
placeholder="0"
/>
<NumInput
label="Payload Bay Length"
value={payload.bayLength != null ? parseFloat((payload.bayLength * 1000).toPrecision(6)) : null}
onChange={v => setPayload(p => ({ ...p, bayLength: v != null ? v / 1000 : 0 }))}
units="mm"
step="10"
value={payload.bayLength}
onChange={v => setPayload(p => ({ ...p, bayLength: v ?? 0 }))}
unitFamily="length"
defaultUnitId="mm"
step="0.001"
placeholder="0"
/>
</DesignSection>
@@ -352,7 +383,9 @@ export default function RocketPage() {
{/* ── Centre: 3D Model ── */}
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
<ErrorBoundary>
<RocketModel3D geometry={geometry} />
</ErrorBoundary>
{/* Requirements checklist — shown when model can't render */}
{!geometry && engineData && (