This commit is contained in:
2026-03-03 16:43:30 +00:00
commit 03452517b5
58 changed files with 13181 additions and 0 deletions

687
src/pages/EnginePage.jsx Normal file
View File

@@ -0,0 +1,687 @@
import { useRef, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useEngineDesign } from '../hooks/useEngineDesign.js'
import {
exportEngineJSON,
exportEngineOdt,
parseEngineImport,
downloadBlob,
} from '../engine/engineExportImport.js'
import DesignSection from '../components/engine/DesignSection.jsx'
import EngineModel3D from '../components/engine/EngineModel3D.jsx'
import { formatValue } from '../engine/format.js'
import { getUnitsForFamily } from '../engine/units.js'
import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.js'
/* ── Info popup ───────────────────────────────────────────────────── */
function InfoPopup({ infoKey, anchorRef, onClose }) {
const popupRef = useRef(null)
const info = ENGINE_FIELD_INFO[infoKey]
const [pos, setPos] = useState({ top: 0, left: 0 })
useEffect(() => {
if (anchorRef.current) {
const r = anchorRef.current.getBoundingClientRect()
setPos({
top: r.bottom + 6,
left: Math.min(r.left, window.innerWidth - 230),
})
}
}, [anchorRef])
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
function onDown(e) {
if (!popupRef.current?.contains(e.target) && !anchorRef.current?.contains(e.target)) {
onClose()
}
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onDown)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDown)
}
}, [onClose, anchorRef])
if (!info) return null
return createPortal(
<div
ref={popupRef}
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999 }}
className="bg-slate-800 border border-slate-600 rounded-lg p-3 shadow-2xl w-56 text-xs"
>
<p className="font-semibold text-slate-100 mb-1">{info.name}</p>
<p className="text-slate-300 mb-2 leading-relaxed">{info.description}</p>
{info.higher && <p className="text-emerald-400 mb-0.5"> {info.higher}</p>}
{info.lower && <p className="text-amber-400"> {info.lower}</p>}
</div>,
document.body,
)
}
/* ── Small reusable input components ──────────────────────────────── */
function NumInput({ label, value, onChange, units, step, placeholder, unitFamily, defaultUnitId, infoKey }) {
const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null
const [selectedUnitId, setSelectedUnitId] = useState(
() => defaultUnitId ?? unitList?.[0]?.id ?? null,
)
const [popupOpen, setPopupOpen] = useState(false)
const infoRef = useRef(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 typed = parseFloat(str)
if (isNaN(typed)) return
onChange(selectedUnit ? selectedUnit.toSI(typed) : typed)
}
return (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
<input
type="number"
value={displayValue}
step={step}
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"
/>
{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}
{infoKey && (
<>
<button
ref={infoRef}
onClick={() => setPopupOpen(v => !v)}
className="text-slate-500 hover:text-slate-300 text-xs shrink-0 leading-none"
title="Info"
></button>
{popupOpen && (
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
)}
</>
)}
</div>
)
}
function SelectInput({ label, value, onChange, options, infoKey }) {
const [popupOpen, setPopupOpen] = useState(false)
const infoRef = useRef(null)
return (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
<select
value={value}
onChange={e => onChange(e.target.value)}
className="px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
focus:border-blue-500 focus:outline-none"
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{infoKey && (
<>
<button
ref={infoRef}
onClick={() => setPopupOpen(v => !v)}
className="text-slate-500 hover:text-slate-300 text-xs shrink-0 leading-none"
title="Info"
></button>
{popupOpen && (
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
)}
</>
)}
</div>
)
}
/* ── Result display helpers ───────────────────────────────────────── */
function ResultRow({ label, value, unit, unitFamily, defaultUnitId, infoKey }) {
const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null
const [selectedUnitId, setSelectedUnitId] = useState(
() => defaultUnitId ?? unitList?.[0]?.id ?? null,
)
const [popupOpen, setPopupOpen] = useState(false)
const infoRef = useRef(null)
const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null
const rawDisplay = value !== null && value !== undefined && isFinite(value)
? (selectedUnit ? selectedUnit.fromSI(value) : value)
: null
const display = rawDisplay !== null ? formatValue(rawDisplay) : '—'
const unitLabel = selectedUnit?.label ?? unit
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-400 w-48 shrink-0">{label}</span>
<span className={`font-mono ${display === '—' ? 'text-slate-600' : 'text-green-400'}`}>
{display}
</span>
{unitList && unitList.length > 1 ? (
<select
value={selectedUnit?.id ?? ''}
onChange={e => setSelectedUnitId(e.target.value)}
className="px-1 py-0 bg-slate-700 border border-slate-600 rounded text-xs text-slate-400
focus:outline-none cursor-pointer"
>
{unitList.map(u => <option key={u.id} value={u.id}>{u.label}</option>)}
</select>
) : (
unitLabel && <span className="text-slate-500 text-xs">{unitLabel}</span>
)}
{infoKey && (
<>
<button
ref={infoRef}
onClick={() => setPopupOpen(v => !v)}
className="text-slate-500 hover:text-slate-300 text-xs leading-none shrink-0"
title="Info"
></button>
{popupOpen && (
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
)}
</>
)}
</div>
)
}
function ResultSection({ title, children }) {
return (
<div className="mb-5">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 pb-1 border-b border-slate-800">
{title}
</h3>
<div className="space-y-1">{children}</div>
</div>
)
}
/* ── Main page ────────────────────────────────────────────────────── */
export default function EnginePage() {
const importRef = useRef(null)
const {
thermoInputs, setThermoInput,
chamber, setChamber,
nozzle, setNozzle,
injector, setInjector,
cooling, setCooling,
feedSystem, setFeedSystem,
burnTime, setBurnTime,
allThermo,
chamberGeometry: cg,
nozzleGeometry: ng,
injectorGeometry: ig,
coolingResults: cr,
feedResults: fr,
loadDesign,
} = useEngineDesign()
function handleExportJSON() {
const blob = exportEngineJSON({
thermoInputs, chamber, nozzle, injector, cooling, feedSystem, burnTime,
allThermo, chamberGeometry: cg, nozzleGeometry: ng,
injectorGeometry: ig, coolingResults: cr, feedResults: fr,
})
downloadBlob(blob, 'engine-design.json')
}
async function handleExportODT() {
const blob = await exportEngineOdt({
allThermo, chamberGeometry: cg, nozzleGeometry: ng,
injectorGeometry: ig, coolingResults: cr, feedResults: fr,
})
downloadBlob(blob, 'engine-design.odt')
}
function handleImport(e) {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
try {
const inputs = parseEngineImport(ev.target.result)
loadDesign(inputs)
} catch (err) {
alert('Import failed: ' + err.message)
}
}
reader.readAsText(file)
e.target.value = ''
}
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Sub-header */}
<div className="flex items-center justify-between px-5 py-2 bg-slate-900 border-b border-slate-700 shrink-0">
<h1 className="text-sm font-semibold text-slate-200">Engine Design</h1>
<div className="flex gap-2">
<button
onClick={handleExportJSON}
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
>
JSON
</button>
<button
onClick={handleExportODT}
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
>
ODT
</button>
<button
onClick={() => importRef.current?.click()}
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
>
Import
</button>
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
</div>
</div>
{/* Three-column layout */}
<div className="flex flex-1 overflow-hidden">
{/* ── Left: Inputs ── */}
<div className="w-[420px] shrink-0 overflow-y-auto p-4 border-r border-slate-700">
<DesignSection title="Thermodynamic Inputs">
<NumInput
label="Chamber Pressure (p₀)"
value={thermoInputs.p0}
onChange={v => setThermoInput('p0', v)}
unitFamily="pressure"
defaultUnitId="MPa"
infoKey="p0"
placeholder="3"
/>
<NumInput
label="Chamber Temp (T₀)"
value={thermoInputs.T0}
onChange={v => setThermoInput('T0', v)}
unitFamily="temperature"
defaultUnitId="K"
infoKey="T0"
placeholder="3500"
/>
<NumInput
label="Ratio of Specific Heats (γ)"
value={thermoInputs.gamma}
onChange={v => setThermoInput('gamma', v)}
infoKey="gamma"
placeholder="1.2"
step="0.01"
/>
<NumInput
label="Specific Gas Constant (R)"
value={thermoInputs.R}
onChange={v => setThermoInput('R', v)}
units="J/(kg·K)"
infoKey="R_gas"
placeholder="360"
/>
<NumInput
label="Mass Flow Rate (ṁ)"
value={thermoInputs.mdot}
onChange={v => setThermoInput('mdot', v)}
unitFamily="massflow"
defaultUnitId="kg/s"
infoKey="mdot"
placeholder="5"
/>
<NumInput
label="Thrust (F)"
value={thermoInputs.F}
onChange={v => setThermoInput('F', v)}
unitFamily="force"
defaultUnitId="kN"
infoKey="F_input"
placeholder="or enter ṁ"
/>
<NumInput
label="O/F Ratio"
value={thermoInputs.OF}
onChange={v => setThermoInput('OF', v)}
infoKey="OF"
placeholder="2.2"
step="0.1"
/>
<NumInput
label="Throat Area (Aₜ)"
value={thermoInputs.At}
onChange={v => setThermoInput('At', v)}
unitFamily="area"
defaultUnitId="cm²"
infoKey="At_input"
placeholder="auto-solved"
/>
<NumInput
label="Expansion Ratio (ε)"
value={thermoInputs.eps}
onChange={v => setThermoInput('eps', v)}
infoKey="eps_input"
placeholder="auto-solved"
step="0.5"
/>
<NumInput
label="Ambient Pressure (pₐ)"
value={thermoInputs.pa}
onChange={v => setThermoInput('pa', v)}
unitFamily="pressure"
defaultUnitId="kPa"
infoKey="pa"
placeholder="101.325"
/>
</DesignSection>
<DesignSection title="Combustion Chamber">
<NumInput
label="Characteristic Length (L*)"
value={chamber.Lstar}
onChange={v => setChamber(c => ({ ...c, Lstar: v }))}
unitFamily="length"
defaultUnitId="m"
infoKey="Lstar"
step="0.05"
/>
<NumInput
label="Contraction Ratio"
value={chamber.contractionRatio}
onChange={v => setChamber(c => ({ ...c, contractionRatio: v }))}
infoKey="contractionRatio"
step="0.5"
/>
<NumInput
label="Convergent Half-Angle"
value={chamber.convAngleDeg}
onChange={v => setChamber(c => ({ ...c, convAngleDeg: v }))}
units="°"
infoKey="convAngleDeg"
step="1"
/>
</DesignSection>
<DesignSection title="Nozzle">
<SelectInput
label="Nozzle Type"
value={nozzle.type}
onChange={v => setNozzle(n => ({ ...n, type: v }))}
options={[
{ value: 'conical', label: 'Conical' },
{ value: 'bell', label: 'Bell (80% length)' },
]}
infoKey="nozzleType"
/>
{nozzle.type === 'conical' && (
<NumInput
label="Divergence Half-Angle"
value={nozzle.divAngleDeg}
onChange={v => setNozzle(n => ({ ...n, divAngleDeg: v }))}
units="°"
infoKey="divAngleDeg"
step="1"
/>
)}
</DesignSection>
<DesignSection title="Injector">
<SelectInput
label="Injector Type"
value={injector.type}
onChange={v => setInjector(i => ({ ...i, type: v }))}
options={[
{ value: 'doublet', label: 'Impinging Doublet' },
{ value: 'triplet', label: 'Impinging Triplet' },
{ value: 'coaxial', label: 'Coaxial' },
{ value: 'pintle', label: 'Pintle' },
]}
infoKey="injectorType"
/>
<NumInput
label="Number of Elements (N)"
value={injector.N}
onChange={v => setInjector(i => ({ ...i, N: Math.max(1, Math.round(v)) }))}
infoKey="injectorN"
step="1"
/>
<NumInput
label="ΔP/p₀ Fraction"
value={injector.dpFraction}
onChange={v => setInjector(i => ({ ...i, dpFraction: v }))}
infoKey="dpFraction"
step="0.01"
/>
<NumInput
label="Discharge Coefficient (Cd)"
value={injector.Cd}
onChange={v => setInjector(i => ({ ...i, Cd: v }))}
infoKey="Cd"
step="0.01"
/>
<NumInput
label="Fuel Density (ρ_f)"
value={injector.rhoFuel}
onChange={v => setInjector(i => ({ ...i, rhoFuel: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoFuel_inj"
/>
<NumInput
label="Oxidiser Density (ρ_ox)"
value={injector.rhoOx}
onChange={v => setInjector(i => ({ ...i, rhoOx: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoOx_inj"
/>
</DesignSection>
<DesignSection title="Cooling">
<SelectInput
label="Cooling Method"
value={cooling.method}
onChange={v => setCooling(c => ({ ...c, method: v }))}
options={[
{ value: 'regenerative', label: 'Regenerative' },
{ value: 'film', label: 'Film Cooling' },
{ value: 'ablative', label: 'Ablative' },
{ value: 'uncooled', label: 'Uncooled' },
]}
infoKey="coolingMethod"
/>
{cooling.method === 'regenerative' && (
<NumInput
label="Channel Count"
value={cooling.channelCount}
onChange={v => setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))}
infoKey="channelCount"
step="1"
/>
)}
{cooling.method === 'film' && (
<NumInput
label="Film Mass Fraction"
value={cooling.filmFraction}
onChange={v => setCooling(c => ({ ...c, filmFraction: v }))}
infoKey="filmFraction"
step="0.01"
/>
)}
</DesignSection>
<DesignSection title="Feed System">
<SelectInput
label="Feed System"
value={feedSystem.type}
onChange={v => setFeedSystem(f => ({ ...f, type: v }))}
options={[
{ value: 'pressure_fed', label: 'Pressure-Fed' },
{ value: 'pump_fed', label: 'Pump-Fed' },
]}
infoKey="feedType"
/>
{feedSystem.type === 'pressure_fed' && (
<NumInput
label="Feed Pressure Factor"
value={feedSystem.feedFactor}
onChange={v => setFeedSystem(f => ({ ...f, feedFactor: v }))}
infoKey="feedFactor"
step="0.05"
/>
)}
<NumInput
label="Fuel Density (ρ_f)"
value={feedSystem.rhoFuel}
onChange={v => setFeedSystem(f => ({ ...f, rhoFuel: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoFuel_feed"
/>
<NumInput
label="Oxidiser Density (ρ_ox)"
value={feedSystem.rhoOx}
onChange={v => setFeedSystem(f => ({ ...f, rhoOx: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoOx_feed"
/>
<NumInput
label="Burn Time"
value={burnTime}
onChange={v => setBurnTime(v ?? 30)}
unitFamily="time"
defaultUnitId="s"
infoKey="burnTime"
step="1"
/>
</DesignSection>
</div>
{/* ── Centre: 3D Model ── */}
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
</div>
{/* ── Right: Results ── */}
<div className="w-[380px] shrink-0 overflow-y-auto p-4">
<ResultSection title="Thermodynamic Performance">
<ResultRow label="Throat Area (Aₜ)" value={allThermo.At} unitFamily="area" defaultUnitId="cm²" infoKey="At_result" />
<ResultRow label="Exit Area (Aₑ)" value={allThermo.Ae} unitFamily="area" defaultUnitId="cm²" infoKey="Ae_result" />
<ResultRow label="Expansion Ratio (ε)" value={allThermo.eps} unit="—" infoKey="eps_result" />
<ResultRow label="Exit Mach (Mₑ)" value={allThermo.Me} unit="—" infoKey="Me_result" />
<ResultRow label="Exit Temp (Tₑ)" value={allThermo.Te} unitFamily="temperature" defaultUnitId="K" infoKey="Te_result" />
<ResultRow label="Exit Pressure (pₑ)" value={allThermo.pe} unitFamily="pressure" defaultUnitId="kPa" infoKey="pe_result" />
<ResultRow label="Exhaust Velocity (Vₑ)" value={allThermo.Ve} unitFamily="velocity" defaultUnitId="m/s" infoKey="Ve_result" />
<ResultRow label="Thrust (F)" value={allThermo.F} unitFamily="force" defaultUnitId="kN" infoKey="F_result" />
<ResultRow label="Specific Impulse (Isp)" value={allThermo.Isp} unit="s" infoKey="Isp_result" />
<ResultRow label="c*" value={allThermo.cstar} unitFamily="velocity" defaultUnitId="m/s" infoKey="cstar_result" />
<ResultRow label="Thrust Coefficient (CF)" value={allThermo.CF} unit="—" infoKey="CF_result" />
<ResultRow label="Fuel Flow (ṁ_f)" value={allThermo.mdot_f} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot_f_result" />
<ResultRow label="Ox Flow (ṁ_ox)" value={allThermo.mdot_ox} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot_ox_result" />
</ResultSection>
<ResultSection title="Chamber Geometry">
<ResultRow label="Chamber Diameter (Dc)" value={cg?.Dc} unitFamily="length" defaultUnitId="mm" infoKey="Dc_result" />
<ResultRow label="Throat Diameter (Dt)" value={cg?.Dt} unitFamily="length" defaultUnitId="mm" infoKey="Dt_result" />
<ResultRow label="Contraction Ratio" value={cg?.contractionRatio} unit="—" infoKey="contractionRatio_result" />
<ResultRow label="Total Length (Lc)" value={cg?.Lc} unitFamily="length" defaultUnitId="mm" infoKey="Lc_result" />
<ResultRow label="Cylindrical Length" value={cg?.L_cyl} unitFamily="length" defaultUnitId="mm" infoKey="L_cyl_result" />
<ResultRow label="Convergent Length" value={cg?.L_conv} unitFamily="length" defaultUnitId="mm" infoKey="L_conv_result" />
<ResultRow label="Chamber Volume" value={cg?.Vc} unitFamily="volume" defaultUnitId="cm³" infoKey="Vc_result" />
</ResultSection>
<ResultSection title="Nozzle Geometry">
<ResultRow label="Throat Diameter (Dt)" value={ng?.Dt} unitFamily="length" defaultUnitId="mm" infoKey="Dt_result" />
<ResultRow label="Exit Diameter (De)" value={ng?.De} unitFamily="length" defaultUnitId="mm" infoKey="De_result" />
<ResultRow label="Nozzle Length (Ln)" value={ng?.Ln} unitFamily="length" defaultUnitId="mm" infoKey="Ln_result" />
</ResultSection>
<ResultSection title="Injector">
<ResultRow label="Pressure Drop (ΔP)" value={ig?.deltaP} unitFamily="pressure" defaultUnitId="kPa" infoKey="deltaP_result" />
<ResultRow label="Fuel Jet Velocity" value={ig?.v_f} unitFamily="velocity" defaultUnitId="m/s" infoKey="v_f_result" />
<ResultRow label="Oxidiser Jet Velocity" value={ig?.v_ox} unitFamily="velocity" defaultUnitId="m/s" infoKey="v_ox_result" />
<ResultRow label="Fuel Orifice Diameter" value={ig?.d_f} unitFamily="length" defaultUnitId="mm" infoKey="d_f_result" />
<ResultRow label="Oxidiser Orifice Diameter" value={ig?.d_ox} unitFamily="length" defaultUnitId="mm" infoKey="d_ox_result" />
</ResultSection>
<ResultSection title="Cooling">
{cr ? (
<>
{cr.method === 'regenerative' && (
<>
<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" />
</>
)}
{cr.method === 'film' && (
<>
<ResultRow label="Film Mass Flow" value={cr.mdot_film} unitFamily="massflow" defaultUnitId="g/s" infoKey="mdot_film_result" />
<ResultRow label="Est. Isp Penalty" value={cr.ispPenalty} unit="%" infoKey="ispPenalty_result" />
</>
)}
{cr.note && (
<p className="text-xs text-slate-400 italic mt-1">{cr.note}</p>
)}
</>
) : (
<p className="text-xs text-slate-600"></p>
)}
</ResultSection>
<ResultSection title="Feed System">
{fr ? (
<>
<ResultRow label="Tank Pressure" value={fr.p_tank} unitFamily="pressure" defaultUnitId="MPa" infoKey="p_tank_result" />
<ResultRow label="Fuel Volume" value={fr.V_fuel} unitFamily="volume" defaultUnitId="L" infoKey="V_fuel_result" />
<ResultRow label="Oxidiser Volume" value={fr.V_ox} unitFamily="volume" defaultUnitId="L" infoKey="V_ox_result" />
<ResultRow label="Total Propellant Volume" value={fr.V_prop} unitFamily="volume" defaultUnitId="L" infoKey="V_prop_result" />
{fr.m_press != null && <ResultRow label="Pressurant Mass" value={fr.m_press} unitFamily="mass" defaultUnitId="kg" infoKey="m_press_result" />}
{fr.dP_pump != null && <ResultRow label="Pump ΔP" value={fr.dP_pump} unitFamily="pressure" defaultUnitId="MPa" infoKey="dP_pump_result" />}
{fr.P_turbine != null && <ResultRow label="Est. Turbine Power" value={fr.P_turbine / 1000} unit="kW" infoKey="P_turbine_result" />}
</>
) : (
<p className="text-xs text-slate-600"></p>
)}
</ResultSection>
</div>
</div>
</div>
)
}