Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
398 changes: 301 additions & 97 deletions docs/integration.md

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions frontend/src/components/studio/CompressorAnalysis.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* CompressorAnalysis.jsx
* ─────────────────────────────────────────────────────────────────────────────
* Carrier-agnostic compressor analysis (H₂ or CO₂): specific compression work
* vs outlet pressure, from the Unit's target pressure and isentropic efficiency
* (mirrors the steady-state/Modelica physics `0.5·ln(p)/η`).
*/

import React from 'react';
import ReactECharts from 'echarts-for-react';

export default function CompressorAnalysis({ model }) {
const pOut = Number(model?.target_pressure_bar ?? 350);
const eta = Number(model?.isentropic_efficiency ?? 0.78);
const stages = model?.number_stages != null ? Number(model.number_stages) : null;

const pressures = [];
for (let p = 10; p <= Math.max(pOut * 1.15, 20); p += Math.max(5, Math.round(pOut / 12))) pressures.push(p);
const spec = (p) => +(0.5 * Math.log(Math.max(p, 2)) / Math.max(eta, 0.1)).toFixed(3); // kWh/kg
const specAtTarget = spec(pOut);

const option = {
animation: false,
color: ['#4d4b9e'],
grid: { top: 26, bottom: 42, left: 56, right: 20 },
tooltip: { trigger: 'axis', valueFormatter: (v) => `${v} kWh/kg` },
xAxis: { type: 'category', data: pressures.map((p) => `${p}`), name: 'Outlet pressure (bar)', nameLocation: 'middle', nameGap: 28, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: 'kWh/kg', axisLabel: { fontSize: 10 }, nameTextStyle: { fontSize: 10 } },
series: [{
name: 'Specific work', type: 'line', smooth: true, symbol: 'none',
data: pressures.map(spec), areaStyle: { opacity: 0.08 },
markLine: { silent: true, symbol: 'none', data: [{ xAxis: `${pressures.reduce((a, b) => (Math.abs(b - pOut) < Math.abs(a - pOut) ? b : a))}` }], lineStyle: { color: '#943700', type: 'dashed' }, label: { formatter: `target ${pOut} bar`, fontSize: 10 } },
}],
};

return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Stat label="Target pressure" value={`${pOut} bar`} />
<Stat label="Isentropic η" value={`${(eta * 100).toFixed(0)} %`} />
<Stat label="Specific work" value={`${specAtTarget} kWh/kg`} />
<Stat label="Stages" value={stages != null ? String(stages) : '—'} />
</div>
<div className="rounded-2xl border border-outline-variant/20 bg-surface-container-lowest p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-primary text-[18px]">compress</span>
<h3 className="font-headline text-sm font-bold text-on-surface">Compression Work vs Pressure</h3>
</div>
<ReactECharts option={option} style={{ height: 300 }} notMerge lazyUpdate />
<p className="text-[11px] text-on-surface-variant/70 mt-2">
Work grows with the log of the pressure ratio; higher isentropic efficiency lowers the parasitic draw.
</p>
</div>
</div>
);
}

function Stat({ label, value }) {
return (
<div className="rounded-xl border border-outline-variant/30 bg-surface-container px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-on-surface-variant">{label}</p>
<p className="font-headline text-base font-bold text-on-surface">{value}</p>
</div>
);
}
75 changes: 75 additions & 0 deletions frontend/src/components/studio/FuelCellAnalysis.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* FuelCellAnalysis.jsx
* ─────────────────────────────────────────────────────────────────────────────
* Compact analysis for a fuel-cell Unit (no dedicated panel existed in the old
* simulator). Shows the efficiency-vs-load characteristic (fuel cells are most
* efficient at part load) and power output vs load, from the composed
* technology's nominal efficiency and capacity.
*/

import React from 'react';
import ReactECharts from 'echarts-for-react';

const HHV = 39.4; // kWh/kg H2

export default function FuelCellAnalysis({ model }) {
const effNom = Number(model?.efficiency_pct ?? model?.nominal_efficiency_pct ?? 58);
const ratedKw = Number(model?.capacity_kw ?? 1000);
const minLoad = 10;

// Fuel cells peak at low/mid load and decline toward full load.
const loads = Array.from({ length: 10 }, (_, i) => (i + 1) * 10);
const eff = (x) => {
if (x < minLoad) return null;
const f = 1.14 - 0.26 * (x / 100); // ~+14% at low load → ~−12% at full load
return Math.max(20, Math.min(99, effNom * f));
};
const effSeries = loads.map(eff);
const powerSeries = loads.map((x) => Math.round(ratedKw * (x / 100)));
const peakEff = Math.max(...effSeries.filter((v) => v != null));
const dailyH2 = +((ratedKw / (HHV * effNom / 100)) * 24 / 1000).toFixed(2); // t/day at nominal

const option = {
animation: false,
color: ['#4d4b9e', '#6f7dd6'],
grid: { top: 30, bottom: 42, left: 52, right: 56 },
tooltip: { trigger: 'axis' },
legend: { data: ['Efficiency (%)', 'Power (kW)'], bottom: 0, textStyle: { fontSize: 11 } },
xAxis: { type: 'category', data: loads.map((x) => `${x}%`), name: 'Load', axisLabel: { fontSize: 10 } },
yAxis: [
{ type: 'value', name: 'η (%)', min: 0, max: 100, axisLabel: { fontSize: 10 }, nameTextStyle: { fontSize: 10 } },
{ type: 'value', name: 'kW', axisLabel: { fontSize: 10 }, nameTextStyle: { fontSize: 10 }, splitLine: { show: false } },
],
series: [
{ name: 'Efficiency (%)', type: 'line', smooth: true, symbol: 'circle', data: effSeries, yAxisIndex: 0, areaStyle: { opacity: 0.08 } },
{ name: 'Power (kW)', type: 'line', smooth: true, symbol: 'none', lineStyle: { type: 'dashed' }, data: powerSeries, yAxisIndex: 1 },
],
};

return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Stat label="Rated Power" value={ratedKw >= 1000 ? `${(ratedKw / 1000).toFixed(1)} MW` : `${ratedKw} kW`} />
<Stat label="Nominal η" value={`${effNom.toFixed(0)} % (HHV)`} />
<Stat label="Peak η" value={`${peakEff.toFixed(0)} %`} />
<Stat label="H₂ demand" value={`${dailyH2} t/day`} />
</div>
<div className="rounded-2xl border border-outline-variant/20 bg-surface-container-lowest p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-primary text-[18px]">bolt</span>
<h3 className="font-headline text-sm font-bold text-on-surface">Part-Load Efficiency &amp; Power</h3>
</div>
<ReactECharts option={option} style={{ height: 300 }} notMerge lazyUpdate />
</div>
</div>
);
}

function Stat({ label, value }) {
return (
<div className="rounded-xl border border-outline-variant/30 bg-surface-container px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-on-surface-variant">{label}</p>
<p className="font-headline text-base font-bold text-on-surface">{value}</p>
</div>
);
}
2 changes: 1 addition & 1 deletion frontend/src/components/studio/ProcessCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ const ProcessCanvas = forwardRef(function ProcessCanvas({ initialProcess, onDirt
}), [nodes, edges, setNodes, markDirty]);

return (
<div className="flex-1 flex min-w-0">
<div className="flex-1 flex min-w-0" data-tour="canvas">
<div className="flex-1 relative min-w-0">
{error && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 px-3 py-1.5 rounded-lg
Expand Down
70 changes: 65 additions & 5 deletions frontend/src/components/studio/ProcessStudio.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,29 @@ import ReactECharts from 'echarts-for-react';
import {
FiEdit3, FiPlus, FiSave, FiArrowLeft, FiCheckCircle, FiBox, FiLayers,
FiPlay, FiLoader, FiX, FiAlertCircle, FiUploadCloud, FiGitBranch, FiCheck,
FiActivity,
FiActivity, FiHelpCircle,
} from 'react-icons/fi';
import {
listProcesses, getProcess, submitProcess, listSubmissions, reviewSubmission,
} from './services/processApi';
import { runProcessSimulation } from './services/processSimApi';
import { PALETTE_GROUPS } from './equipmentLibrary';
import ProcessCanvas from './ProcessCanvas';
import StudioTour from './StudioTour';

const TOUR_SEEN_KEY = 'otdb_studio_tour_v1';
const STUDIO_STEPS = [
{ selector: '[data-tour="palette"]', title: 'Add equipment',
body: 'Build your process by adding units — sources, conversion, storage, sinks. Click an item to drop it on the canvas.' },
{ selector: '[data-tour="canvas"]', title: 'Wire the flow',
body: 'Drag from a unit’s coloured OUT port to another’s IN port — only matching carriers connect. Click a unit to select it.' },
{ selector: '[data-tour="inspector"]', title: 'Edit a unit',
body: 'With a unit selected, choose its Catalogue technology, set operating conditions (temperature, pressure, load…), and open its analysis charts.' },
{ selector: '[data-tour="run"]', title: 'Simulate',
body: 'Run the process — it’s queued and simulated; KPIs and dynamic traces appear below.' },
{ selector: '[data-tour="publish"]', title: 'Save & contribute',
body: 'Save a private draft, or Publish your process for review to add it to the shared catalogue.' },
];

const DRAFTS_KEY = 'otdb_process_drafts';
const slugify = (s) => (s || 'untitled').toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'untitled';
Expand All @@ -46,13 +61,31 @@ export default function ProcessStudio() {
const [dirty, setDirty] = useState(false);
const [toast, setToast] = useState(null);
const [sim, setSim] = useState({ status: 'idle', result: null, error: null }); // idle|running|done|error
const [tourOpen, setTourOpen] = useState(false);
const [showRunHint, setShowRunHint] = useState(false);
const runHintSeen = useRef(false);
const canvasRef = useRef(null);

// Nudge to Run once the user has wired a runnable chain (source + ≥1 stream),
// shown once per session and cleared when a simulation starts.
const SOURCE_TYPES = ['power_source', 'flue_gas_source', 'water_source'];
const handleDirty = useCallback(() => {
setDirty(true);
if (runHintSeen.current || sim.status !== 'idle') return;
const g = canvasRef.current?.getProcess();
if (g && g.units.length >= 2 && g.streams.length >= 1
&& g.units.some((u) => SOURCE_TYPES.includes(u.equipment_type))) {
runHintSeen.current = true;
setShowRunHint(true);
}
}, [sim.status]); // eslint-disable-line react-hooks/exhaustive-deps

const flash = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2500); };

const runSim = useCallback(async () => {
const graph = canvasRef.current?.getProcess();
if (!graph || graph.units.length === 0) { flash('Add at least one unit first'); return; }
setShowRunHint(false);
setSim({ status: 'running', result: null, error: null, phase: 'queued', position: null });
try {
const result = await runProcessSimulation(graph, {
Expand All @@ -76,6 +109,12 @@ export default function ProcessStudio() {
setDirty(false);
setSim({ status: 'idle', result: null, error: null });
setMode('build');
// First time in the builder → show the guided tour (once). A short delay
// lets the Build layout mount so the tour can measure its targets.
if (!localStorage.getItem(TOUR_SEEN_KEY)) {
localStorage.setItem(TOUR_SEEN_KEY, '1');
setTimeout(() => setTourOpen(true), 500);
}
}, []);

const newProcess = useCallback(() => {
Expand Down Expand Up @@ -129,6 +168,10 @@ export default function ProcessStudio() {
) : (
<div className="ml-auto flex items-center gap-2">
{dirty && <span className="text-[11px] text-tertiary font-medium">● unsaved</span>}
<button onClick={() => setTourOpen(true)} title="Guided tour"
className="p-1.5 rounded-lg text-on-surface-variant hover:bg-surface-container transition-colors">
<FiHelpCircle size={16} />
</button>
<button onClick={() => setMode('browse')}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-on-surface-variant hover:bg-surface-container transition-colors">
<FiArrowLeft size={14} /> Browse
Expand All @@ -137,11 +180,11 @@ export default function ProcessStudio() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-on-surface border border-outline-variant/40 hover:bg-surface-container transition-colors">
<FiSave size={14} /> Save Draft
</button>
<button onClick={publish} disabled={publishing}
<button data-tour="publish" onClick={publish} disabled={publishing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-on-surface border border-outline-variant/40 hover:bg-surface-container transition-colors disabled:opacity-60">
{publishing ? <FiLoader size={14} className="animate-spin" /> : <FiUploadCloud size={14} />} Publish
</button>
<button onClick={runSim} disabled={sim.status === 'running'}
<button data-tour="run" onClick={runSim} disabled={sim.status === 'running'}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-bold technical-gradient text-on-primary shadow-sm hover:shadow-md transition-all disabled:opacity-60">
{sim.status === 'running' ? <FiLoader size={14} className="animate-spin" /> : <FiPlay size={14} />}
{sim.status === 'running'
Expand All @@ -163,8 +206,25 @@ export default function ProcessStudio() {
{mode === 'browse'
? <BrowseView onOpen={openProcess} />
: <BuildView meta={meta} setMeta={setMeta} initialProcess={initialProcess}
canvasKey={canvasKey} canvasRef={canvasRef} onDirty={() => setDirty(true)}
canvasKey={canvasKey} canvasRef={canvasRef} onDirty={handleDirty}
sim={sim} onCloseResults={() => setSim({ status: 'idle', result: null, error: null })} />}

{showRunHint && mode === 'build' && sim.status === 'idle' && (
<div className="fixed z-[90] top-[104px] right-6 w-64 rounded-xl technical-gradient text-on-primary shadow-2xl p-3 animate-slideInRight">
<div className="flex items-start gap-2">
<FiPlay className="mt-0.5 shrink-0" size={15} />
<p className="text-xs leading-relaxed flex-1">
Your process is wired — click <b>Run Simulation</b> for KPIs and dynamic traces.
</p>
<button onClick={() => setShowRunHint(false)} className="opacity-80 hover:opacity-100 shrink-0"><FiX size={13} /></button>
</div>
<div className="absolute -top-1.5 right-7 w-3 h-3 technical-gradient rotate-45" />
</div>
)}

{tourOpen && mode === 'build' && (
<StudioTour steps={STUDIO_STEPS} onClose={() => setTourOpen(false)} />
)}
</div>
);
}
Expand Down Expand Up @@ -333,7 +393,7 @@ function BuildView({ meta, setMeta, initialProcess, canvasKey, canvasRef, onDirt
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex min-h-0">
{/* Palette */}
<aside className="w-52 shrink-0 border-r border-outline-variant/20 bg-surface-container-lowest overflow-y-auto p-3">
<aside data-tour="palette" className="w-52 shrink-0 border-r border-outline-variant/20 bg-surface-container-lowest overflow-y-auto p-3">
<div className="mb-4">
<label className="block text-[10px] font-bold uppercase tracking-wider text-on-surface-variant mb-1">Process name</label>
<input
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/components/studio/StorageAnalysis.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* StorageAnalysis.jsx
* ─────────────────────────────────────────────────────────────────────────────
* Storage/buffer analysis (H₂ tank, CO₂ reservoir): an illustrative
* charge → hold → discharge state-of-charge cycle with the round-trip loss,
* from the Unit's max pressure / round-trip efficiency (or injection rate).
*/

import React from 'react';
import ReactECharts from 'echarts-for-react';

export default function StorageAnalysis({ model }) {
const maxP = model?.max_pressure_bar != null ? Number(model.max_pressure_bar) : null;
const rte = Number(model?.round_trip_efficiency_pct ?? 99) / 100;
const injection = model?.injection_rate_mtco2_yr != null ? Number(model.injection_rate_mtco2_yr) : null;
const depth = model?.reservoir_depth_m != null ? Number(model.reservoir_depth_m) : null;

// 24-slot cycle: charge to full (0–8h), hold (8–14h), discharge with RTE (14–24h).
const hours = Array.from({ length: 25 }, (_, h) => h);
const soc = hours.map((h) => {
if (h <= 8) return Math.round((h / 8) * 100);
if (h <= 14) return 100;
const d = (h - 14) / 10; // 0..1 over discharge window
return Math.max(0, Math.round(100 - d * 100 * rte));
});

const option = {
animation: false,
color: ['#4d4b9e'],
grid: { top: 26, bottom: 40, left: 50, right: 20 },
tooltip: { trigger: 'axis', valueFormatter: (v) => `${v} %` },
xAxis: { type: 'category', data: hours.map((h) => `${h}h`), name: 'Time', axisLabel: { fontSize: 10, interval: 3 } },
yAxis: { type: 'value', name: 'State of charge (%)', min: 0, max: 100, axisLabel: { fontSize: 10 }, nameTextStyle: { fontSize: 10 } },
series: [{ name: 'State of charge', type: 'line', smooth: true, symbol: 'none', data: soc, areaStyle: { opacity: 0.1 } }],
};

return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{maxP != null && <Stat label="Max pressure" value={`${maxP} bar`} />}
<Stat label="Round-trip η" value={`${(rte * 100).toFixed(0)} %`} />
{injection != null && <Stat label="Injection rate" value={`${injection} Mt/yr`} />}
{depth != null && <Stat label="Reservoir depth" value={`${depth} m`} />}
</div>
<div className="rounded-2xl border border-outline-variant/20 bg-surface-container-lowest p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-primary text-[18px]">battery_charging_full</span>
<h3 className="font-headline text-sm font-bold text-on-surface">Charge / Discharge Cycle</h3>
</div>
<ReactECharts option={option} style={{ height: 300 }} notMerge lazyUpdate />
<p className="text-[11px] text-on-surface-variant/70 mt-2">
Illustrative daily cycle — the round-trip efficiency sets how much of the stored energy is recovered on discharge.
</p>
</div>
</div>
);
}

function Stat({ label, value }) {
return (
<div className="rounded-xl border border-outline-variant/30 bg-surface-container px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-on-surface-variant">{label}</p>
<p className="font-headline text-base font-bold text-on-surface">{value}</p>
</div>
);
}
Loading
Loading