Ablative config

This commit is contained in:
2026-03-03 20:30:29 +00:00
parent 386f6fe928
commit 585e66ceb4
16 changed files with 713 additions and 71 deletions

View File

@@ -9,9 +9,16 @@ 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; }
# Detect node path and current user
NODE_PATH=$(which node)
CURRENT_USER=$(whoami)
echo "Rocketry Service Installer"
echo "=========================="
echo ""
echo "Node path: $NODE_PATH"
echo "Service user: $CURRENT_USER"
echo ""
# Port prompt
read -p "Port to run on [default: 8080]: " PORT
@@ -165,9 +172,9 @@ After=network.target
[Service]
Type=simple
User=nobody
User=$CURRENT_USER
WorkingDirectory=/opt/rocketry
ExecStart=/usr/bin/node /opt/rocketry/server.js
ExecStart=$NODE_PATH /opt/rocketry/server.js
Environment="PORT=$PORT"
Restart=on-failure
RestartSec=5

View File

@@ -5,6 +5,7 @@ import EnginePage from './pages/EnginePage.jsx'
import RocketPage from './pages/RocketPage.jsx'
import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx'
import KnowledgebaseEquationsPage from './pages/KnowledgebaseEquationsPage.jsx'
import KnowledgebaseAblativesPage from './pages/KnowledgebaseAblativesPage.jsx'
export default function App() {
return (
@@ -90,6 +91,18 @@ export default function App() {
>
Fuels / Oxidisers
</NavLink>
<NavLink
to="/knowledgebase/ablatives"
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'
}`
}
>
Ablative Materials
</NavLink>
<NavLink
to="/knowledgebase/equations"
className={({ isActive }) =>
@@ -113,6 +126,7 @@ export default function App() {
<Route path="/design/rocket" element={<RocketPage />} />
<Route path="/knowledgebase/fuels" element={<KnowledgebaseFuelsPage />} />
<Route path="/knowledgebase/equations" element={<KnowledgebaseEquationsPage />} />
<Route path="/knowledgebase/ablatives" element={<KnowledgebaseAblativesPage />} />
</Routes>
</div>
)

View File

@@ -80,7 +80,7 @@ function PropellantCard({ propellant, onApply }) {
)
}
export function PropellantModal({ onClose, onApply, existingVarIds, description }) {
export function PropellantModal({ onClose, onApply, description }) {
const [search, setSearch] = useState('')
const [activeType, setActiveType] = useState('All')
@@ -166,7 +166,6 @@ export function PropellantModal({ onClose, onApply, existingVarIds, description
key={p.id}
propellant={p}
onApply={handleApply}
existingVarIds={existingVarIds}
/>
))}
</div>

View File

@@ -1,5 +1,7 @@
import { useRef } from 'react'
import { useRef, useState } from 'react'
import { useDraggable } from '@dnd-kit/core'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { VARIABLES } from '../engine/variables.js'
import { getUnitsForFamily } from '../engine/units.js'
import { formatValue } from '../engine/format.js'
@@ -49,8 +51,26 @@ export function WorkspaceCard({
getUnit,
onUnitChange,
sciNotation,
areaRatioBranch,
onToggleBranch,
}) {
const inputRef = useRef(null)
const [inputError, setInputError] = useState(false)
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: varId })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const v = VARIABLES[varId]
if (!v) return null
@@ -60,10 +80,28 @@ export function WorkspaceCard({
const isUserSet = userValue !== undefined && userValue !== ''
const isSolved = !!solvedInfo
// State: user-entered (blue), solved (green), unknown (grey)
const borderColor = isUserSet ? 'border-blue-500' : isSolved ? 'border-green-500' : 'border-slate-600'
const symbolColor = isUserSet ? 'text-blue-300' : isSolved ? 'text-green-300' : 'text-slate-400'
const valueBg = isUserSet ? 'bg-blue-950' : isSolved ? 'bg-green-950' : 'bg-slate-800'
const handleInputChange = (rawDisplayValue) => {
if (rawDisplayValue === '') {
setInputError(false)
onValueChange(rawDisplayValue)
return
}
const parsed = parseFloat(rawDisplayValue)
if (isNaN(parsed)) return
// Validate positive constraint
if (v.positive && parsed < 0) {
setInputError(true)
return
}
setInputError(false)
onValueChange(rawDisplayValue)
}
// State: user-entered (blue), solved (green), unknown (grey), error (red)
const borderColor = inputError ? 'border-red-500' : isUserSet ? 'border-blue-500' : isSolved ? 'border-green-500' : 'border-slate-600'
const symbolColor = inputError ? 'text-red-300' : isUserSet ? 'text-blue-300' : isSolved ? 'text-green-300' : 'text-slate-400'
const valueBg = inputError ? 'bg-red-950' : isUserSet ? 'bg-blue-950' : isSolved ? 'bg-green-950' : 'bg-slate-800'
// Display value converted from stored SI to current display unit
const inputDisplayValue = isUserSet
@@ -74,7 +112,13 @@ export function WorkspaceCard({
const isNonSI = unit.id !== siUnit.id
return (
<div className={`rounded-xl border-2 ${borderColor} bg-slate-800 p-3 flex flex-col gap-1 relative group`}>
<div
ref={setNodeRef}
style={style}
className={`rounded-xl border-2 ${borderColor} bg-slate-800 p-3 flex flex-col gap-1 relative group ${isDragging ? 'opacity-50' : ''}`}
{...attributes}
{...listeners}
>
<button
onClick={onRemove}
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-slate-700 text-slate-400 hover:bg-red-700 hover:text-white opacity-0 group-hover:opacity-100 transition-all text-xs flex items-center justify-center leading-none"
@@ -101,6 +145,15 @@ export function WorkspaceCard({
) : (
<span className="text-slate-400 text-xs">{unit.label}</span>
)}
{isSolved && varId === 'Me' && solvedInfo?.equationName === 'Isentropic Area Ratio' && onToggleBranch && (
<button
onClick={onToggleBranch}
title={`Switch to ${areaRatioBranch === 'supersonic' ? 'subsonic' : 'supersonic'}`}
className="ml-auto text-xs px-1.5 py-0.5 rounded border transition-colors bg-amber-900/40 text-amber-300 border-amber-600 hover:border-amber-400 hover:bg-amber-900/60"
>
{areaRatioBranch === 'supersonic' ? 'SS' : 'Sub'}
</button>
)}
</div>
<div className="text-slate-300 text-xs truncate">{v.name}</div>
<div className={`mt-1 rounded-md ${valueBg} px-2 py-1`}>
@@ -122,11 +175,14 @@ export function WorkspaceCard({
ref={inputRef}
type="number"
value={inputDisplayValue}
onChange={e => onValueChange(e.target.value)}
onChange={e => handleInputChange(e.target.value)}
placeholder="Enter value…"
className="w-full bg-transparent font-mono text-sm text-blue-200 placeholder-slate-600 outline-none"
className={`w-full bg-transparent font-mono text-sm ${inputError ? 'text-red-200' : 'text-blue-200'} placeholder-slate-600 outline-none`}
/>
{isUserSet && isNonSI && (
{inputError && (
<p className="text-xs text-red-400 mt-0.5">Must be positive</p>
)}
{isUserSet && isNonSI && !inputError && (
<div className="text-blue-800 text-[10px] mt-0.5 font-mono">
= {formatValue(userValue, sciNotation)} {siUnit.label}
</div>

View File

@@ -1,4 +1,5 @@
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable'
import { EQUATION_PRESETS } from '../engine/equations.js'
import { WorkspaceCard } from './VariableCard.jsx'
@@ -13,6 +14,8 @@ export function Workspace({
getUnit,
setUnit,
sciNotation,
areaRatioBranch,
onToggleAreaRatioBranch,
}) {
const { setNodeRef, isOver } = useDroppable({ id: 'workspace' })
@@ -57,6 +60,7 @@ export function Workspace({
</div>
</div>
) : (
<SortableContext items={workspaceVarIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 content-start">
{workspaceVarIds.map(varId => (
<WorkspaceCard
@@ -69,6 +73,8 @@ export function Workspace({
getUnit={getUnit}
onUnitChange={unitId => setUnit(varId, unitId)}
sciNotation={sciNotation}
areaRatioBranch={areaRatioBranch}
onToggleBranch={onToggleAreaRatioBranch}
/>
))}
{/* Drop indicator card when dragging over a non-empty workspace */}
@@ -78,6 +84,7 @@ export function Workspace({
</div>
)}
</div>
</SortableContext>
)}
</div>
</main>

View File

@@ -3,18 +3,25 @@ import { useState } from 'react'
/**
* Collapsible section wrapper for engine design input groups.
*/
export default function DesignSection({ title, children, defaultOpen = true }) {
export default function DesignSection({ title, children, defaultOpen = true, headerAction }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="border border-slate-700 rounded-lg overflow-hidden mb-4">
<div className="w-full flex items-center justify-between px-4 py-3 bg-slate-800 hover:bg-slate-700 text-left transition-colors group">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 bg-slate-800 hover:bg-slate-700 text-left transition-colors"
className="flex items-center gap-2 flex-1"
>
<span className="text-sm font-semibold text-slate-200">{title}</span>
<span className="text-slate-400 text-xs select-none">{open ? '▲' : '▼'}</span>
</button>
{headerAction && (
<div className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
{headerAction}
</div>
)}
</div>
{open && (
<div className="p-4 bg-slate-900 space-y-3">
{children}

View File

@@ -1,4 +1,5 @@
// Pure calculation functions for engine design (no React)
import { ABLATIVE_MATERIALS } from './knowledgebaseData.js'
/**
* Combustion chamber geometry from thermodynamic results and design inputs.
@@ -85,9 +86,10 @@ export function calcInjector(thermo, injector) {
* Cooling analysis based on selected method.
* For regenerative cooling: simplified Bartz heat flux estimate.
* For film cooling: propellant fraction and Isp penalty estimate.
* For ablative/uncooled: informational only.
* For ablative: material selection, erosion, and remaining thickness calculation.
* For uncooled: informational only.
*/
export function calcCooling(thermo, cooling, chamberGeom) {
export function calcCooling(thermo, cooling, chamberGeom, burnTime) {
const { p0, T0, cstar, mdot } = thermo
const { method } = cooling
@@ -120,8 +122,55 @@ export function calcCooling(thermo, cooling, chamberGeom) {
return { method, filmFraction, mdot_film, ispPenalty }
}
if (method === 'ablative') {
const { ablativeMaterial = 'carbon_phenolic', ablativeThickness = 10 } = cooling
const material = ABLATIVE_MATERIALS.find(m => m.id === ablativeMaterial)
if (!material || !burnTime) return { method, material }
// Pressure correction (if p0 available)
const pressureFactor = (p0 && isFinite(p0) && material.refPressure)
? Math.pow(p0 / material.refPressure, material.pressureExponent ?? 0.4)
: 1
const effectiveRate = material.erosionRate * pressureFactor
const effectiveRateMin = material.erosionRateRange[0] * pressureFactor
const effectiveRateMax = material.erosionRateRange[1] * pressureFactor
const erosionMm = effectiveRate * burnTime
const erosionMmMin = effectiveRateMin * burnTime
const erosionMmMax = effectiveRateMax * burnTime
const remainingMm = ablativeThickness - erosionMm
const remainingMmBest = ablativeThickness - erosionMmMin // best case
const remainingMmWorst = ablativeThickness - erosionMmMax // worst case
const fraction = remainingMm / ablativeThickness // 01
// Warning thresholds (based on worst case)
const WARNING_MM = 2 // warn below 2 mm
const CRITICAL_MM = 0.5 // critical below 0.5 mm
return {
method,
material,
ablativeThickness,
pressureFactor,
effectiveRate,
erosionMm,
erosionMmMin,
erosionMmMax,
remainingMm,
remainingMmBest,
remainingMmWorst,
fraction,
status: remainingMmWorst < CRITICAL_MM ? 'critical'
: remainingMmWorst < WARNING_MM ? 'warning'
: 'ok',
}
}
const notes = {
ablative: 'Ablative liner — consult manufacturer data for material thickness and char rate.',
ablative: 'Ablative liner — configure material and initial thickness in inputs.',
uncooled: 'Uncooled — confirm combustion gas temperature is within material thermal limits for the burn duration.',
}
return { method, note: notes[method] ?? '' }

View File

@@ -345,14 +345,15 @@ export const EQUATIONS = [
{
id: 'area_ratio_mach',
name: 'Isentropic Area Ratio (supersonic)',
name: 'Isentropic Area Ratio',
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.',
description: 'Relates exit area ratio to exit Mach number for isentropic flow. Choose supersonic or subsonic branch.',
variables: ['eps', 'Me', 'gamma'],
hasBranch: true,
solvers: {
eps: v => areaRatioFromMach(v.Me, v.gamma),
Me: v => machFromAreaRatio(v.eps, v.gamma, true),
Me: (v, opts) => machFromAreaRatio(v.eps, v.gamma, opts?.supersonic ?? true),
},
},
@@ -581,19 +582,6 @@ export const EQUATIONS = [
},
},
// ── 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

@@ -752,3 +752,127 @@ export const PROPELLANTS = [
notes: 'Catalytic decomposition; vacuum Isp',
})),
]
// ── Ablative liner materials ──────────────────────────────────────────────────────
export const ABLATIVE_MATERIALS = [
{
id: 'carbon_phenolic',
name: 'Carbon-Phenolic',
composition: 'Carbon fiber reinforced phenolic resin',
description:
'Carbon-phenolic is a high-performance ablative material widely used in nozzle throats and combustion chamber walls. It combines excellent thermal stability with good mechanical properties and has proven heritage in operational rocket engines. The phenolic matrix char-yields ~55%, providing a protective char layer that insulates the underlying structure.',
erosionRate: 0.15,
erosionRateRange: [0.05, 0.30],
maxTemp: 3500,
density: 1400,
thermalConductivity: 3.5,
charYield: 0.55,
pressureExponent: 0.35,
applications: ['nozzle throat', 'combustion chamber wall'],
notes: 'Proven material for high-pressure engines (6.9 MPa reference). Good ablation resistance; expensive relative to alternatives.',
refPressure: 6.9e6,
},
{
id: 'silica_phenolic',
name: 'Silica-Phenolic',
composition: 'Silica-filled phenolic resin',
description:
'Silica-phenolic offers moderate performance with good thermal properties and lower cost than carbon-phenolic. The silica filler improves thermal conductivity and provides additional structural support. Suitable for moderate-temperature applications and less demanding environments.',
erosionRate: 0.25,
erosionRateRange: [0.15, 0.40],
maxTemp: 3200,
density: 1350,
thermalConductivity: 2.5,
charYield: 0.50,
pressureExponent: 0.38,
applications: ['nozzle throat', 'chamber sections'],
notes: 'Lower performance than carbon-phenolic but more cost-effective. Good for educational and amateur applications.',
refPressure: 6.9e6,
},
{
id: 'graphite_phenolic',
name: 'Graphite-Phenolic',
composition: 'Graphite-filled phenolic resin',
description:
'Graphite-phenolic combines the strength of phenolic matrix with excellent thermal conductivity from graphite fillers. It provides superior performance in high heat flux regions and is ideal for engines with extreme thermal conditions. Excellent char stability and low ablation rates.',
erosionRate: 0.10,
erosionRateRange: [0.05, 0.20],
maxTemp: 3600,
density: 1450,
thermalConductivity: 8.0,
charYield: 0.58,
pressureExponent: 0.33,
applications: ['nozzle throat (high heat flux)', 'combustion chamber'],
notes: 'Premium material for highest-performance applications. Superior thermal properties justify higher cost.',
refPressure: 6.9e6,
},
{
id: 'epdm',
name: 'EPDM Rubber',
composition: 'Ethylene propylene diene monomer polymer',
description:
'EPDM is a flexible elastomeric ablator suitable for low-pressure engines and non-critical applications. It provides good insulation properties and flexibility, reducing structural loads. Inexpensive and easy to manufacture in various geometries.',
erosionRate: 0.50,
erosionRateRange: [0.30, 0.80],
maxTemp: 2200,
density: 920,
thermalConductivity: 0.25,
charYield: 0.40,
pressureExponent: 0.50,
applications: ['low-pressure chamber', 'insulation layer'],
notes: 'Low cost and simple manufacturing. Limited to low-pressure (<1 MPa) applications due to high erosion rates.',
refPressure: 0.5e6,
},
{
id: 'cork_composite',
name: 'Cork Composite',
composition: 'Cork particles in polymer binder',
description:
'Cork-based composites are the lowest-cost ablative option, popular in amateur rocketry. Cork provides excellent insulation and low density. Performance is modest but adequate for low-pressure, short-duration burns.',
erosionRate: 0.90,
erosionRateRange: [0.50, 1.50],
maxTemp: 1800,
density: 700,
thermalConductivity: 0.15,
charYield: 0.35,
pressureExponent: 0.52,
applications: ['amateur engines', 'low-pressure chambers'],
notes: 'Extremely low cost and accessibility. Suitable only for educational and amateur applications with short burn times.',
refPressure: 0.3e6,
},
{
id: 'silicone_rubber',
name: 'Silicone Rubber',
composition: 'Siloxane polymer',
description:
'Silicone rubber provides flexibility and excellent low-pressure performance with good char stability. It remains serviceable over a wide temperature range and is less brittle than phenolics. Ideal for flexible throat liners and low-pressure applications.',
erosionRate: 0.65,
erosionRateRange: [0.40, 1.00],
maxTemp: 2400,
density: 1100,
thermalConductivity: 0.20,
charYield: 0.45,
pressureExponent: 0.48,
applications: ['flexible throat', 'low-pressure chamber'],
notes: 'Good flexibility reduces structural stress. Higher erosion rates limit use to low-moderate pressure engines.',
refPressure: 0.5e6,
},
{
id: 'pica',
name: 'PICA',
composition: 'Phenolic Impregnated Carbon Ablator',
description:
'PICA (Phenolic Impregnated Carbon Ablator) is an ultra-high-performance ablative material with exceptionally low erosion rates. Developed for spacecraft thermal protection and advanced rocket engines, it combines carbon fibre structure with phenolic impregnation. Extremely expensive but unsurpassed in performance.',
erosionRate: 0.06,
erosionRateRange: [0.03, 0.12],
maxTemp: 3800,
density: 1500,
thermalConductivity: 5.5,
charYield: 0.60,
pressureExponent: 0.30,
applications: ['nozzle throat (ultra-high performance)', 'chamber (extreme conditions)'],
notes: 'Premium aerospace-grade material with proven heritage in advanced engines. Cost is very high; used only when performance demands justify expense.',
refPressure: 6.9e6,
},
]

View File

@@ -5,12 +5,13 @@ import { VARIABLES } from './variables.js'
* Run the constraint-propagation solver.
*
* @param {Record<string, number>} knownValues user-entered { varId: value }
* @param {Record<string, any>} opts equation-specific options: { 'area_ratio_mach': { supersonic: true/false } }
* @returns {{
* solved: Record<string, { value: number, equationId: string, equationName: string }>,
* missing: Record<string, string[][]> varId → list of variable-sets that would unlock it
* }}
*/
export function solve(knownValues) {
export function solve(knownValues, opts = {}) {
const known = { ...knownValues }
const solved = {}
@@ -28,7 +29,8 @@ export function solve(knownValues) {
: eq.variables.filter(v => v !== target)
if (others.every(v => v in known)) {
try {
const val = solver(known)
const eqOpts = opts[eq.id] ?? {}
const val = solver(known, eqOpts)
if (isFinite(val) && !isNaN(val)) {
known[target] = val
solved[target] = { value: val, equationId: eq.id, equationName: eq.name }

View File

@@ -14,42 +14,49 @@ export const VARIABLES = {
description: 'Net thrust force produced by the engine',
category: 'Thrust',
unitFamily: 'force',
positive: true,
},
mdot: {
id: 'mdot', symbol: 'ṁ', name: 'Mass Flow Rate', units: 'kg/s',
description: 'Total propellant mass flow rate through the engine',
category: 'Thrust',
unitFamily: 'massflow',
positive: true,
},
Ve: {
id: 'Ve', symbol: 'Vₑ', name: 'Exhaust Velocity', units: 'm/s',
description: 'Actual kinematic exit velocity at the nozzle exit plane',
category: 'Thrust',
unitFamily: 'velocity',
positive: true,
},
ceff: {
id: 'ceff', symbol: 'cₑ', name: 'Effective Exhaust Velocity', units: 'm/s',
description: 'F/ṁ = Isp·g₀; includes both momentum and pressure thrust',
category: 'Thrust',
unitFamily: 'velocity',
positive: true,
},
pe: {
id: 'pe', symbol: 'pₑ', name: 'Exit Pressure', units: 'Pa',
description: 'Static pressure at the nozzle exit plane',
category: 'Thrust',
unitFamily: 'pressure',
positive: true,
},
pa: {
id: 'pa', symbol: 'pₐ', name: 'Ambient Pressure', units: 'Pa',
description: 'Surrounding ambient static pressure',
category: 'Thrust',
unitFamily: 'pressure',
positive: true,
},
Ae: {
id: 'Ae', symbol: 'Aₑ', name: 'Exit Area', units: 'm²',
description: 'Cross-sectional area of the nozzle exit',
category: 'Thrust',
unitFamily: 'area',
positive: true,
},
// ── Specific Impulse ──────────────────────────────────────────────────
@@ -58,12 +65,14 @@ export const VARIABLES = {
description: 'Thrust produced per unit weight flow of propellant',
category: 'Specific Impulse',
unitFamily: 'time',
positive: true,
},
g0: {
id: 'g0', symbol: 'g₀', name: 'Standard Gravity', units: 'm/s²',
description: 'Standard gravitational acceleration (9.80665 m/s²)',
category: 'Specific Impulse',
unitFamily: 'acceleration',
positive: true,
},
// ── Characteristic Velocity / Thrust Coefficient ──────────────────────
@@ -72,24 +81,28 @@ export const VARIABLES = {
description: 'Measure of combustion efficiency; c* = p₀·Aₜ/ṁ',
category: 'Nozzle Performance',
unitFamily: 'velocity',
positive: true,
},
CF: {
id: 'CF', symbol: 'Cꜰ', name: 'Thrust Coefficient', units: '—',
description: 'Dimensionless nozzle performance factor; Cꜰ = F/(p₀·Aₜ)',
category: 'Nozzle Performance',
unitFamily: 'dimensionless',
positive: true,
},
p0: {
id: 'p0', symbol: 'p₀', name: 'Chamber Pressure', units: 'Pa',
description: 'Stagnation (total) pressure in the combustion chamber',
category: 'Nozzle Performance',
unitFamily: 'pressure',
positive: true,
},
At: {
id: 'At', symbol: 'Aₜ', name: 'Throat Area', units: 'm²',
description: 'Cross-sectional area at the nozzle throat',
category: 'Nozzle Performance',
unitFamily: 'area',
positive: true,
},
Lstar: {
@@ -97,6 +110,7 @@ export const VARIABLES = {
description: 'Characteristic length of the combustion chamber: L* = Vᶜ/Aₜ. Higher values indicate larger chambers.',
category: 'Nozzle Performance',
unitFamily: 'length',
positive: true,
},
Vc: {
@@ -104,6 +118,7 @@ export const VARIABLES = {
description: 'Total volume of the combustion chamber',
category: 'Nozzle Performance',
unitFamily: 'volume',
positive: true,
},
// ── Tsiolkovsky Rocket Equation ───────────────────────────────────────
@@ -118,36 +133,42 @@ export const VARIABLES = {
description: 'Total vehicle mass at ignition (wet mass)',
category: 'Rocket Equation',
unitFamily: 'mass',
positive: true,
},
mf: {
id: 'mf', symbol: 'mf', name: 'Final Mass', units: 'kg',
description: 'Vehicle mass after propellant is expended (dry mass)',
category: 'Rocket Equation',
unitFamily: 'mass',
positive: true,
},
mp: {
id: 'mp', symbol: 'mₚ', name: 'Propellant Mass', units: 'kg',
description: 'Mass of propellant consumed: mₚ = m₀ mf',
category: 'Rocket Equation',
unitFamily: 'mass',
positive: true,
},
MR: {
id: 'MR', symbol: 'MR', name: 'Mass Ratio', units: '—',
description: 'Ratio of initial to final mass: MR = m₀/mf',
category: 'Rocket Equation',
unitFamily: 'dimensionless',
positive: true,
},
zeta: {
id: 'zeta', symbol: 'ζ', name: 'Propellant Mass Fraction', units: '—',
description: 'Fraction of initial mass that is propellant: ζ = mₚ/m₀',
category: 'Rocket Equation',
unitFamily: 'dimensionless',
positive: true,
},
tb: {
id: 'tb', symbol: 'tᵦ', name: 'Burn Time', units: 's',
description: 'Duration of engine burn',
category: 'Rocket Equation',
unitFamily: 'time',
positive: true,
},
// ── Isentropic Flow ───────────────────────────────────────────────────
@@ -156,18 +177,21 @@ export const VARIABLES = {
description: 'Local Mach number at a cross-section',
category: 'Isentropic Flow',
unitFamily: 'dimensionless',
positive: true,
},
gamma: {
id: 'gamma', symbol: 'γ', name: 'Ratio of Specific Heats', units: '—',
description: 'Ratio of specific heats cₚ/cᵥ for the gas (≈1.4 for air, ~1.2 for rocket exhaust)',
category: 'Isentropic Flow',
unitFamily: 'dimensionless',
positive: true,
},
R: {
id: 'R', symbol: 'R', name: 'Specific Gas Constant', units: 'J/(kg·K)',
description: 'Gas constant for the propellant gas: R = R̄/M_mol',
category: 'Isentropic Flow',
unitFamily: 'spec_gas',
positive: true,
},
Mm: {
@@ -175,48 +199,56 @@ export const VARIABLES = {
description: 'Molar mass of the exhaust gas mixture. Used to calculate specific gas constant: R = R̄/Mₘ',
category: 'Isentropic Flow',
unitFamily: 'molar_mass',
positive: true,
},
T: {
id: 'T', symbol: 'T', name: 'Static Temperature', units: 'K',
description: 'Local static temperature at a cross-section',
category: 'Isentropic Flow',
unitFamily: 'temperature',
positive: true,
},
T0: {
id: 'T0', symbol: 'T₀', name: 'Stagnation Temperature', units: 'K',
description: 'Total (stagnation) temperature, equal to chamber temperature',
category: 'Isentropic Flow',
unitFamily: 'temperature',
positive: true,
},
p_static: {
id: 'p_static', symbol: 'p', name: 'Static Pressure', units: 'Pa',
description: 'Local static pressure at a cross-section',
category: 'Isentropic Flow',
unitFamily: 'pressure',
positive: true,
},
rho: {
id: 'rho', symbol: 'ρ', name: 'Density', units: 'kg/m³',
description: 'Local gas density at a cross-section',
category: 'Isentropic Flow',
unitFamily: 'density',
positive: true,
},
rho0: {
id: 'rho0', symbol: 'ρ₀', name: 'Stagnation Density', units: 'kg/m³',
description: 'Total (stagnation) density',
category: 'Isentropic Flow',
unitFamily: 'density',
positive: true,
},
a_sound: {
id: 'a_sound', symbol: 'a', name: 'Speed of Sound', units: 'm/s',
description: 'Local speed of sound: a = √(γRT)',
category: 'Isentropic Flow',
unitFamily: 'velocity',
positive: true,
},
v_flow: {
id: 'v_flow', symbol: 'v', name: 'Flow Velocity', units: 'm/s',
description: 'Local bulk flow velocity: v = M·a',
category: 'Isentropic Flow',
unitFamily: 'velocity',
positive: true,
},
// ── Nozzle Geometry ───────────────────────────────────────────────────
@@ -225,18 +257,21 @@ export const VARIABLES = {
description: 'Mach number at the nozzle exit plane',
category: 'Nozzle Geometry',
unitFamily: 'dimensionless',
positive: true,
},
eps: {
id: 'eps', symbol: 'ε', name: 'Expansion Ratio', units: '—',
description: 'Nozzle area expansion ratio: ε = Aₑ/Aₜ',
category: 'Nozzle Geometry',
unitFamily: 'dimensionless',
positive: true,
},
Te: {
id: 'Te', symbol: 'Tₑ', name: 'Exit Temperature', units: 'K',
description: 'Static temperature at the nozzle exit plane',
category: 'Nozzle Geometry',
unitFamily: 'temperature',
positive: true,
},
// ── Performance Metrics ───────────────────────────────────────────────
@@ -245,18 +280,21 @@ export const VARIABLES = {
description: 'Ratio of thrust to vehicle weight at ignition',
category: 'Performance',
unitFamily: 'dimensionless',
positive: true,
},
m_vehicle: {
id: 'm_vehicle', symbol: 'mᵥ', name: 'Vehicle Mass', units: 'kg',
description: 'Vehicle mass used for TWR calculation',
category: 'Performance',
unitFamily: 'mass',
positive: true,
},
J: {
id: 'J', symbol: 'J', name: 'Total Impulse', units: 'N·s',
description: 'Integral of thrust over burn time: J = F·tᵦ (constant thrust)',
category: 'Performance',
unitFamily: 'impulse',
positive: true,
},
// ── Mass & Propellant ─────────────────────────────────────────────────
@@ -265,18 +303,21 @@ export const VARIABLES = {
description: 'Mass ratio of oxidiser to fuel flow rates',
category: 'Propellant',
unitFamily: 'dimensionless',
positive: true,
},
mdot_ox: {
id: 'mdot_ox', symbol: 'ṁₒₓ', name: 'Oxidiser Flow Rate', units: 'kg/s',
description: 'Mass flow rate of oxidiser',
category: 'Propellant',
unitFamily: 'massflow',
positive: true,
},
mdot_f: {
id: 'mdot_f', symbol: 'ṁf', name: 'Fuel Flow Rate', units: 'kg/s',
description: 'Mass flow rate of fuel',
category: 'Propellant',
unitFamily: 'massflow',
positive: true,
},
}

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: 60, mu: 6e-5, cp: 2000, Pr: 0.7, T_wall: 800, 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, ablativeMaterial: 'carbon_phenolic', ablativeThickness: 10 })
const [feedSystem, setFeedSystem] = useState({
type: 'pressure_fed', feedFactor: 1.3,
rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300,
@@ -52,13 +52,41 @@ export function useEngineDesign() {
const chamberGeometry = useMemo(() => calcChamber(allThermo, chamber), [allThermo, chamber])
const nozzleGeometry = useMemo(() => calcNozzle(allThermo, nozzle), [allThermo, nozzle])
const injectorGeometry = useMemo(() => calcInjector(allThermo, injector), [allThermo, injector])
const coolingResults = useMemo(() => calcCooling(allThermo, cooling, chamberGeometry), [allThermo, cooling, chamberGeometry])
const coolingResults = useMemo(() => calcCooling(allThermo, cooling, chamberGeometry, burnTime), [allThermo, cooling, chamberGeometry, burnTime])
const feedResults = useMemo(() => calcFeedSystem(allThermo, feedSystem, burnTime), [allThermo, feedSystem, burnTime])
function setThermoInput(key, value) {
setThermoInputs(prev => ({ ...prev, [key]: value }))
}
/** Apply propellant values to thermoInputs and densities. */
function applyPropellant(propellant) {
// Map propellant values to thermo inputs
const thermoUpdates = {}
if (propellant.values?.T0 !== undefined) thermoUpdates.T0 = propellant.values.T0
if (propellant.values?.gamma !== undefined) thermoUpdates.gamma = propellant.values.gamma
if (propellant.values?.R !== undefined) thermoUpdates.R = propellant.values.R
if (propellant.values?.OF !== undefined) thermoUpdates.OF = propellant.values.OF
setThermoInputs(prev => ({ ...prev, ...thermoUpdates }))
// Also update densities if available
if (propellant.values?.rhoFuel !== undefined || propellant.values?.rhoOx !== undefined) {
setInjector(prev => ({
...prev,
rhoFuel: propellant.values?.rhoFuel ?? prev.rhoFuel,
rhoOx: propellant.values?.rhoOx ?? prev.rhoOx,
}))
}
if (propellant.values?.rhoFuel !== undefined || propellant.values?.rhoOx !== undefined) {
setFeedSystem(prev => ({
...prev,
rhoFuel: propellant.values?.rhoFuel ?? prev.rhoFuel,
rhoOx: propellant.values?.rhoOx ?? prev.rhoOx,
}))
}
}
/** Restore all design state from a parsed import (inputs section). */
function loadDesign(inputs) {
if (inputs.thermodynamics) {
@@ -86,5 +114,6 @@ export function useEngineDesign() {
chamberGeometry, nozzleGeometry, injectorGeometry, coolingResults, feedResults,
// Actions
loadDesign,
applyPropellant,
}
}

View File

@@ -12,6 +12,8 @@ export function useSolver() {
// Selected unit per variable: { varId: unitId }
const [unitSelections, setUnitSelections] = useState({})
const [sciNotation, setSciNotation] = useState(false)
// Area ratio branch: 'supersonic' or 'subsonic'
const [areaRatioBranch, setAreaRatioBranch] = useState('supersonic')
// Ref so getUnit/setValue always see the latest selections without stale closures
const unitSelectionsRef = useRef(unitSelections)
@@ -40,12 +42,17 @@ export function useSolver() {
setSciNotation(prev => !prev)
}, [])
// Run solver whenever userValues or workspace changes
const toggleAreaRatioBranch = useCallback(() => {
setAreaRatioBranch(prev => prev === 'supersonic' ? 'subsonic' : 'supersonic')
}, [])
// Run solver whenever userValues, workspace, or branch changes
const { solved, missingReport } = useMemo(() => {
const { solved, missing: _missing } = solve(userValues)
const opts = { area_ratio_mach: { supersonic: areaRatioBranch === 'supersonic' } }
const { solved, missing: _missing } = solve(userValues, opts)
const report = getMissingReport(workspaceVarIds, userValues, solved)
return { solved, missingReport: report }
}, [userValues, workspaceVarIds])
}, [userValues, workspaceVarIds, areaRatioBranch])
const addVariable = useCallback((varId) => {
setWorkspaceVarIds(prev => prev.includes(varId) ? prev : [...prev, varId])
@@ -70,6 +77,18 @@ export function useSolver() {
})
}, [])
const reorderVariable = useCallback((fromId, toId) => {
setWorkspaceVarIds(prev => {
const fromIdx = prev.indexOf(fromId)
const toIdx = prev.indexOf(toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return prev
const next = [...prev]
next.splice(fromIdx, 1)
next.splice(toIdx, 0, fromId)
return next
})
}, [])
// rawDisplayValue is in the currently-selected display unit; we convert to SI for storage
const setValue = useCallback((varId, rawDisplayValue) => {
setUserValues(prev => {
@@ -143,13 +162,16 @@ export function useSolver() {
allKnown,
unitSelections,
sciNotation,
areaRatioBranch,
getUnitId,
getUnit,
setUnit,
toggleSciNotation,
toggleAreaRatioBranch,
addVariable,
addVariables,
removeVariable,
reorderVariable,
setValue,
addPreset,
applyPropellant,

View File

@@ -1,6 +1,7 @@
import { useRef, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useEngineDesign } from '../hooks/useEngineDesign.js'
import { PropellantModal } from '../components/PropellantModal.jsx'
import {
exportEngineJSON,
exportEngineOdt,
@@ -13,6 +14,7 @@ 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'
import { ABLATIVE_MATERIALS } from '../engine/knowledgebaseData.js'
/* ── Info popup ───────────────────────────────────────────────────── */
@@ -231,6 +233,7 @@ function ResultSection({ title, children }) {
export default function EnginePage() {
const importRef = useRef(null)
const [showPropellants, setShowPropellants] = useState(false)
const {
thermoInputs, setThermoInput,
chamber, setChamber,
@@ -246,6 +249,7 @@ export default function EnginePage() {
coolingResults: cr,
feedResults: fr,
loadDesign,
applyPropellant,
} = useEngineDesign()
function handleExportJSON() {
@@ -320,7 +324,17 @@ export default function EnginePage() {
{/* ── Left: Inputs ── */}
<div className="w-[420px] shrink-0 overflow-y-auto p-4 border-r border-slate-700">
<DesignSection title="Thermodynamic Inputs">
<DesignSection
title="Thermodynamic Inputs"
headerAction={
<button
onClick={() => setShowPropellants(true)}
className="px-2 py-1 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors whitespace-nowrap"
>
Load Propellant
</button>
}
>
<NumInput
label="Chamber Pressure (p₀)"
value={thermoInputs.p0}
@@ -577,6 +591,24 @@ export default function EnginePage() {
step="0.01"
/>
)}
{cooling.method === 'ablative' && (
<>
<SelectInput
label="Liner Material"
value={cooling.ablativeMaterial}
onChange={v => setCooling(c => ({ ...c, ablativeMaterial: v }))}
options={ABLATIVE_MATERIALS.map(m => ({ value: m.id, label: m.name }))}
/>
<NumInput
label="Initial Thickness"
value={cooling.ablativeThickness}
onChange={v => setCooling(c => ({ ...c, ablativeThickness: v }))}
units="mm"
step="1"
placeholder="10"
/>
</>
)}
</DesignSection>
<DesignSection title="Feed System">
@@ -694,6 +726,52 @@ export default function EnginePage() {
<ResultRow label="Est. Isp Penalty" value={cr.ispPenalty} unit="%" infoKey="ispPenalty_result" />
</>
)}
{cr.method === 'ablative' && cr.material && (
<>
<div className="text-sm mb-2">
<span className="text-slate-400">Material: </span>
<span className="text-slate-100 font-semibold">{cr.material.name}</span>
</div>
{cr.pressureFactor && Math.abs(cr.pressureFactor - 1.0) > 0.01 && (
<ResultRow label="Pressure Correction" value={cr.pressureFactor} unit="×" />
)}
<ResultRow label="Erosion Rate" value={cr.effectiveRate} unit="mm/s" />
<ResultRow label="Initial Thickness" value={cr.ablativeThickness} unit="mm" />
<div className="flex items-center gap-2 text-sm py-1.5">
<span className="text-slate-400 w-48 shrink-0">Eroded</span>
<span className="font-mono text-green-400">
{formatValue(cr.erosionMm)} mm
</span>
<span className="text-xs text-slate-500">
({formatValue(cr.erosionMmMin)}{formatValue(cr.erosionMmMax)} mm)
</span>
</div>
<div className="flex items-center gap-2 text-sm py-1.5">
<span className="text-slate-400 w-48 shrink-0">Remaining Thickness</span>
<span className={`font-mono font-semibold ${
cr.status === 'critical' ? 'text-red-400' :
cr.status === 'warning' ? 'text-amber-400' :
'text-green-400'
}`}>
{formatValue(cr.remainingMm)} mm
</span>
<span className="text-xs text-slate-500">
(worst: {formatValue(cr.remainingMmWorst)} mm)
</span>
</div>
{(cr.status === 'warning' || cr.status === 'critical') && (
<div className={`text-xs px-3 py-2 rounded mt-2 ${
cr.status === 'critical'
? 'bg-red-900/30 border border-red-700 text-red-200'
: 'bg-amber-900/30 border border-amber-700 text-amber-200'
}`}>
{cr.status === 'critical'
? '🚨 CRITICAL: Liner thickness below safe minimum!'
: '⚠️ WARNING: Liner thickness approaching minimum!'}
</div>
)}
</>
)}
{cr.note && (
<p className="text-xs text-slate-400 italic mt-1">{cr.note}</p>
)}
@@ -720,6 +798,14 @@ export default function EnginePage() {
</ResultSection>
</div>
</div>
{showPropellants && (
<PropellantModal
onClose={() => setShowPropellants(false)}
onApply={applyPropellant}
description="Select a propellant to pre-fill γ, R, T₀, O/F, and densities."
/>
)}
</div>
)
}

View File

@@ -0,0 +1,197 @@
import { useState, useMemo } from 'react'
import { ABLATIVE_MATERIALS } from '../engine/knowledgebaseData.js'
// ── Helpers ──────────────────────────────────────────────────────────────────
function categoryBadgeClass(app) {
const map = {
'nozzle throat': 'bg-red-900 text-red-200 border border-red-700',
'nozzle throat (high heat flux)': 'bg-red-900 text-red-200 border border-red-700',
'nozzle throat (ultra-high performance)': 'bg-red-900 text-red-200 border border-red-700',
'combustion chamber wall': 'bg-orange-900 text-orange-200 border border-orange-700',
'combustion chamber': 'bg-orange-900 text-orange-200 border border-orange-700',
'chamber sections': 'bg-orange-900 text-orange-200 border border-orange-700',
'chamber (extreme conditions)': 'bg-orange-900 text-orange-200 border border-orange-700',
'flexible throat': 'bg-purple-900 text-purple-200 border border-purple-700',
'low-pressure chamber': 'bg-blue-900 text-blue-200 border border-blue-700',
'insulation layer': 'bg-slate-700 text-slate-200 border border-slate-500',
'amateur engines': 'bg-green-900 text-green-200 border border-green-700',
}
return map[app] ?? 'bg-slate-700 text-slate-200 border border-slate-500'
}
function performanceBadge(material) {
const avgErosion = (material.erosionRateRange[0] + material.erosionRateRange[1]) / 2
if (avgErosion < 0.15) return { label: 'Ultra-High Performance', color: 'bg-red-900 text-red-200 border border-red-700' }
if (avgErosion < 0.35) return { label: 'High Performance', color: 'bg-orange-900 text-orange-200 border border-orange-700' }
if (avgErosion < 0.60) return { label: 'Moderate', color: 'bg-blue-900 text-blue-200 border border-blue-700' }
return { label: 'Low Cost', color: 'bg-green-900 text-green-200 border border-green-700' }
}
function PropRow({ label, value, unit }) {
if (value === null || value === undefined) return null
return (
<div className="grid grid-cols-[160px_1fr] gap-x-3 py-1.5 border-b border-slate-800">
<span className="text-slate-400 text-sm">{label}</span>
<span className="text-slate-100 text-sm">{typeof value === 'number' ? value.toFixed(2) : value}{unit ? ` ${unit}` : ''}</span>
</div>
)
}
// ── Main page ────────────────────────────────────────────────────────────────
export default function KnowledgebaseAblativesPage() {
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState(ABLATIVE_MATERIALS[0].id)
// Filtered list
const filtered = useMemo(() => {
const q = search.toLowerCase()
return ABLATIVE_MATERIALS.filter(m => {
if (q && !m.name.toLowerCase().includes(q) && !m.composition.toLowerCase().includes(q)) return false
return true
})
}, [search])
const selected = ABLATIVE_MATERIALS.find(m => m.id === selectedId) ?? ABLATIVE_MATERIALS[0]
// Ensure selected item is still in filtered list; if not, show first filtered
const effectiveSelected =
filtered.find(m => m.id === selectedId)
? selected
: (filtered[0] ?? selected)
const perf = performanceBadge(effectiveSelected)
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 materials…"
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>
{/* Material list */}
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<p className="p-3 text-xs text-slate-500">No materials match your search.</p>
) : (
filtered.map(material => (
<button
key={material.id}
onClick={() => setSelectedId(material.id)}
className={`block w-full text-left px-4 py-2 text-sm transition-colors border-l-2 ${
selectedId === material.id
? 'bg-slate-700 text-white border-l-blue-500'
: 'text-slate-300 hover:bg-slate-800 border-l-slate-700'
}`}
>
<div className="font-medium">{material.name}</div>
<div className="text-xs text-slate-500">{material.composition}</div>
</button>
))
)}
</div>
</div>
{/* ── Right panel ────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto p-6">
{effectiveSelected && (
<>
{/* Header */}
<div className="mb-6">
<div className="flex items-start justify-between mb-3">
<div>
<h2 className="text-2xl font-bold text-white mb-1">{effectiveSelected.name}</h2>
<p className="text-sm text-slate-400">{effectiveSelected.composition}</p>
</div>
<div className={`px-3 py-1 rounded text-xs font-semibold border ${perf.color}`}>
{perf.label}
</div>
</div>
<p className="text-sm text-slate-300 leading-relaxed mb-4">
{effectiveSelected.description}
</p>
</div>
{/* Specifications table */}
<div className="mb-6 bg-slate-800/50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-slate-200 mb-3 uppercase tracking-wider">Specifications</h3>
<div className="divide-y divide-slate-800">
<PropRow
label="Erosion Rate"
value={effectiveSelected.erosionRate}
unit="mm/s"
/>
<div className="grid grid-cols-[160px_1fr] gap-x-3 py-1.5 border-b border-slate-800">
<span className="text-slate-400 text-sm">Erosion Range</span>
<span className="text-slate-100 text-sm">{effectiveSelected.erosionRateRange[0].toFixed(2)} {effectiveSelected.erosionRateRange[1].toFixed(2)} mm/s</span>
</div>
<PropRow
label="Max Temperature"
value={effectiveSelected.maxTemp}
unit="K"
/>
<PropRow
label="Density"
value={effectiveSelected.density}
unit="kg/m³"
/>
<PropRow
label="Thermal Conductivity"
value={effectiveSelected.thermalConductivity}
unit="W/(m·K)"
/>
<PropRow
label="Char Yield"
value={(effectiveSelected.charYield * 100).toFixed(1)}
unit="%"
/>
<PropRow
label="Reference Pressure"
value={(effectiveSelected.refPressure / 1e6).toFixed(1)}
unit="MPa"
/>
<PropRow
label="Pressure Exponent (n)"
value={effectiveSelected.pressureExponent}
unit="—"
/>
</div>
</div>
{/* Applications */}
<div className="mb-6">
<h3 className="text-sm font-semibold text-slate-200 mb-3 uppercase tracking-wider">Applications</h3>
<div className="flex flex-wrap gap-2">
{effectiveSelected.applications.map((app, idx) => (
<span
key={idx}
className={`px-3 py-1 rounded-full text-xs font-medium border ${categoryBadgeClass(app)}`}
>
{app}
</span>
))}
</div>
</div>
{/* Notes */}
<div className="bg-slate-800/50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-slate-200 mb-2 uppercase tracking-wider">Notes & Guidance</h3>
<p className="text-sm text-slate-300 leading-relaxed">
{effectiveSelected.notes}
</p>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -22,10 +22,13 @@ export default function Solver() {
getUnit,
setUnit,
sciNotation,
areaRatioBranch,
toggleSciNotation,
toggleAreaRatioBranch,
addVariable,
addVariables,
removeVariable,
reorderVariable,
setValue,
addPreset,
applyPropellant,
@@ -48,10 +51,20 @@ export default function Solver() {
function handleDragEnd(event) {
const { over, active } = event
if (over?.id === 'workspace') {
if (!over) {
setActiveVarId(null)
return
}
// Palette → workspace drop
if (over.id === 'workspace') {
const { varId } = active.data.current ?? {}
if (varId) addVariable(varId)
}
// Workspace → workspace reorder
else if (workspaceVarIds.includes(active.id) && workspaceVarIds.includes(over.id)) {
reorderVariable(active.id, over.id)
}
setActiveVarId(null)
}
@@ -128,6 +141,8 @@ export default function Solver() {
getUnit={getUnit}
setUnit={setUnit}
sciNotation={sciNotation}
areaRatioBranch={areaRatioBranch}
onToggleAreaRatioBranch={toggleAreaRatioBranch}
/>
<ResultsPanel
workspaceVarIds={workspaceVarIds}
@@ -147,7 +162,6 @@ export default function Solver() {
<PropellantModal
onClose={() => setShowPropellants(false)}
onApply={applyPropellant}
existingVarIds={workspaceVarIds}
/>
)}