Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Methanol APC — 3D Digital Twin</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: #0a0e17; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; color: #e0e0e0; } | |
| #canvas3d { display: block; } | |
| /* ── HUD overlay ─────────────────────────────────────────── */ | |
| #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } | |
| #title-bar { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 12px 24px; | |
| background: linear-gradient(90deg, rgba(10,14,23,0.95), rgba(10,14,23,0.6)); | |
| border-bottom: 1px solid rgba(0,212,170,0.3); | |
| } | |
| #title-bar h1 { font-size: 18px; font-weight: 600; letter-spacing: 0.5px; } | |
| #title-bar h1 span { color: #00d4aa; } | |
| #status-badge { | |
| padding: 4px 14px; border-radius: 12px; font-size: 12px; font-weight: 600; | |
| background: rgba(255,60,60,0.2); color: #ff6b6b; border: 1px solid rgba(255,60,60,0.3); | |
| } | |
| #status-badge.connected { background: rgba(0,212,170,0.15); color: #00d4aa; border-color: rgba(0,212,170,0.3); } | |
| .data-panel { | |
| position: absolute; background: rgba(10,14,23,0.92); border: 1px solid rgba(0,212,170,0.25); | |
| border-radius: 8px; padding: 14px 18px; min-width: 200px; pointer-events: auto; | |
| backdrop-filter: blur(8px); box-shadow: 0 4px 20px rgba(0,0,0,0.4); | |
| } | |
| .data-panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: #00d4aa; margin-bottom: 10px; } | |
| .data-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 13px; } | |
| .data-row .label { color: #8892a4; } | |
| .data-row .value { font-weight: 600; font-variant-numeric: tabular-nums; } | |
| .data-row .value.warn { color: #ffc107; } | |
| .data-row .value.danger{ color: #ff4444; } | |
| .data-row .value.good { color: #00d4aa; } | |
| #panel-reactor { top: 70px; left: 260px; transition: left 0.3s; } | |
| #panel-economics { top: 70px; right: 20px; } | |
| #panel-catalyst { bottom: 80px; left: 260px; transition: left 0.3s; } | |
| #panel-controls { bottom: 80px; right: 20px; } | |
| #panel-beds { top: 280px; left: 260px; min-width: 180px; transition: left 0.3s; } | |
| .bed-bar { display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; padding: 2px 4px; border-radius: 4px; transition: background 0.2s; } | |
| .bed-bar:hover { background: rgba(0,212,170,0.12); } | |
| .bed-bar:hover .bed-label { color: #00d4aa; } | |
| .bed-bar .bar { height: 10px; border-radius: 3px; flex: 1; background: #333; position: relative; } | |
| .bed-bar .bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s, background 0.3s; } | |
| .bed-bar .bed-label { font-size: 11px; color: #8892a4; width: 44px; transition: color 0.2s; } | |
| .bed-bar .bed-val { font-size: 11px; color: #e0e0e0; width: 50px; text-align: right; font-variant-numeric: tabular-nums; } | |
| /* ── Live Control Panel (right side) ────────────────────── */ | |
| #ctrl-panel { | |
| position: absolute; top: 70px; left: 20px; width: 220px; | |
| background: rgba(10,14,23,0.94); border: 1px solid rgba(255,160,0,0.35); | |
| border-radius: 10px; padding: 14px 16px; pointer-events: auto; | |
| backdrop-filter: blur(8px); box-shadow: 0 4px 24px rgba(0,0,0,0.5); | |
| z-index: 60; transition: transform 0.3s; | |
| } | |
| #ctrl-panel.collapsed { transform: translateX(-240px); } | |
| #ctrl-panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: #ffa000; margin-bottom: 8px; } | |
| #ctrl-toggle { | |
| position: absolute; top: 8px; right: -32px; width: 28px; height: 28px; | |
| background: rgba(10,14,23,0.92); border: 1px solid rgba(255,160,0,0.4); | |
| border-radius: 0 6px 6px 0; cursor: pointer; display: flex; align-items: center; | |
| justify-content: center; font-size: 14px; color: #ffa000; transition: all 0.2s; | |
| } | |
| #ctrl-toggle:hover { background: rgba(255,160,0,0.15); } | |
| .ctrl-row { margin: 8px 0; } | |
| .ctrl-row label { display: flex; justify-content: space-between; font-size: 11px; color: #8892a4; margin-bottom: 3px; } | |
| .ctrl-row label .ctrl-val { color: #e0e0e0; font-weight: 600; font-variant-numeric: tabular-nums; } | |
| .ctrl-row input[type=range] { | |
| width: 100%; height: 6px; -webkit-appearance: none; appearance: none; | |
| background: #222; border-radius: 3px; outline: none; | |
| } | |
| .ctrl-row input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; | |
| background: #ffa000; cursor: pointer; border: 2px solid #0a0e17; | |
| } | |
| .ctrl-row input[type=range]::-moz-range-thumb { | |
| width: 14px; height: 14px; border-radius: 50%; | |
| background: #ffa000; cursor: pointer; border: 2px solid #0a0e17; | |
| } | |
| .ctrl-mode-bar { display: flex; gap: 6px; margin: 10px 0 6px; } | |
| .ctrl-mode-bar button { | |
| flex: 1; padding: 5px 0; border-radius: 5px; font-size: 11px; font-weight: 600; | |
| cursor: pointer; border: 1px solid rgba(255,160,0,0.3); background: transparent; color: #8892a4; transition: all 0.2s; | |
| } | |
| .ctrl-mode-bar button:hover { border-color: #ffa000; color: #ffa000; } | |
| .ctrl-mode-bar button.active { background: rgba(255,160,0,0.2); color: #ffa000; border-color: #ffa000; } | |
| #btn-step { background: rgba(255,160,0,0.15); color: #ffa000; border: 1px solid rgba(255,160,0,0.4); padding: 6px; border-radius: 5px; font-size: 11px; font-weight: 600; cursor: pointer; width: 100%; margin-top: 4px; transition: all 0.2s; } | |
| #btn-step:hover { background: rgba(255,160,0,0.3); } | |
| #btn-step:disabled { opacity: 0.3; cursor: default; } | |
| .ctrl-divider { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 8px 0; } | |
| #panel-chemistry { top: 280px; right: 20px; min-width: 260px; } | |
| .rxn-row { font-size: 12px; margin: 4px 0; padding: 4px 6px; border-radius: 4px; background: rgba(255,255,255,0.04); } | |
| .rxn-eq { color: #b0bec5; font-family: 'Courier New', monospace; font-size: 11px; } | |
| .rxn-info { display: flex; justify-content: space-between; margin-top: 2px; } | |
| .rxn-dh { font-size: 10px; } | |
| .exo { color: #ff8a65; } | |
| .endo { color: #64b5f6; } | |
| #panel-agents { bottom: 80px; left: 50%; transform: translateX(-50%); min-width: 420px; } | |
| .agent-row { display: flex; align-items: center; gap: 8px; margin: 3px 0; font-size: 12px; } | |
| .agent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } | |
| .agent-name { width: 90px; font-weight: 600; } | |
| .agent-controls { color: #8892a4; font-size: 11px; } | |
| #legend { | |
| position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); | |
| display: flex; gap: 20px; background: rgba(10,14,23,0.9); padding: 10px 24px; | |
| border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); pointer-events: auto; font-size: 12px; | |
| } | |
| .legend-item { display: flex; align-items: center; gap: 6px; } | |
| .legend-dot { width: 10px; height: 10px; border-radius: 50%; } | |
| /* ── Connect dialog ──────────────────────────────────────── */ | |
| #connect-dialog { | |
| position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); | |
| background: rgba(10,14,23,0.96); border: 1px solid rgba(0,212,170,0.3); | |
| border-radius: 12px; padding: 30px; text-align: center; pointer-events: auto; | |
| box-shadow: 0 8px 40px rgba(0,0,0,0.6); z-index: 200; | |
| } | |
| #connect-dialog.hidden { display: none; } | |
| #connect-dialog input { | |
| background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); | |
| color: #fff; padding: 10px 16px; border-radius: 6px; width: 320px; font-size: 14px; margin: 12px 0; | |
| } | |
| #connect-dialog button { | |
| background: #00d4aa; color: #0a0e17; border: none; padding: 10px 28px; | |
| border-radius: 6px; font-weight: 600; font-size: 14px; cursor: pointer; margin: 4px; | |
| } | |
| #connect-dialog button:hover { background: #00f0c0; } | |
| #connect-dialog button.secondary { background: transparent; color: #00d4aa; border: 1px solid #00d4aa; } | |
| #connect-dialog button.secondary:hover { background: rgba(0,212,170,0.1); } | |
| #connect-dialog p.hint { font-size: 12px; color: #666; margin-top: 8px; } | |
| #tooltip { | |
| position: absolute; display: none; background: rgba(10,14,23,0.95); border: 1px solid #00d4aa; | |
| padding: 8px 14px; border-radius: 6px; font-size: 12px; pointer-events: none; z-index: 100; | |
| white-space: pre-line; max-width: 320px; line-height: 1.5; | |
| } | |
| /* Camera preset buttons */ | |
| #cam-presets { | |
| position: absolute; top: 55px; left: 50%; transform: translateX(-50%); | |
| display: flex; gap: 6px; pointer-events: auto; z-index: 50; | |
| } | |
| #cam-presets button { | |
| background: rgba(10,14,23,0.85); border: 1px solid rgba(0,212,170,0.3); | |
| color: #8892a4; padding: 5px 12px; border-radius: 5px; font-size: 11px; | |
| cursor: pointer; transition: all 0.2s; | |
| } | |
| #cam-presets button:hover { background: rgba(0,212,170,0.15); color: #00d4aa; border-color: #00d4aa; } | |
| #cam-presets button.active { background: rgba(0,212,170,0.2); color: #00d4aa; border-color: #00d4aa; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="canvas3d"></canvas> | |
| <!-- ── HUD panels ────────────────────────────────────────────── --> | |
| <div id="hud"> | |
| <div id="title-bar"> | |
| <h1><span>◆</span> Methanol APC — 3D Digital Twin</h1> | |
| <div style="display:flex;align-items:center;gap:8px;pointer-events:auto;"> | |
| <select id="mode-select" onchange="onModeChange()" style="background:#1a2030;color:#ffa000;border:1px solid rgba(255,160,0,0.5);border-radius:6px;padding:4px 8px;font-size:11px;font-family:inherit;cursor:pointer;font-weight:600;"> | |
| <option value="training">Training</option> | |
| <option value="testing">Testing</option> | |
| </select> | |
| <select id="task-select" style="background:#1a2030;color:#e0e0e0;border:1px solid rgba(0,212,170,0.4);border-radius:6px;padding:4px 8px;font-size:11px;font-family:inherit;cursor:pointer;"> | |
| <option value="optimization">Optimization (Medium)</option> | |
| <option value="startup">Cold Start (Easy)</option> | |
| <option value="cost_minimization">Cost Min (Medium)</option> | |
| <option value="maximum_yield">Max Yield (Medium)</option> | |
| <option value="disturbance_rejection">Disturbance (Hard)</option> | |
| <option value="emergency_recovery">Emergency (Medium)</option> | |
| <option value="aged_catalyst">Aged Catalyst (Hard)</option> | |
| <option value="pressure_loss">Pressure Loss (Hard)</option> | |
| <option value="feed_composition_upset">Feed Upset (Medium)</option> | |
| <option value="day_night_cycle">Day/Night (Hard)</option> | |
| <option value="long_horizon_production">Long Horizon (Expert)</option> | |
| <option value="multi_disturbance">Multi-Failure (Expert)</option> | |
| </select> | |
| <select id="model-select" style="display:none;background:#1a2030;color:#9c27b0;border:1px solid rgba(156,39,176,0.5);border-radius:6px;padding:4px 8px;font-size:11px;font-family:inherit;cursor:pointer;font-weight:600;"> | |
| <option value="unsloth">Unsloth (Qwen2.5-3B)</option> | |
| <option value="trl">TRL GRPO (Qwen2.5-3B)</option> | |
| </select> | |
| <button id="btn-api-reset" onclick="apiReset()" style="background:rgba(0,212,170,0.2);color:#00d4aa;border:1px solid rgba(0,212,170,0.4);border-radius:6px;padding:4px 12px;font-size:11px;font-weight:600;cursor:pointer;font-family:inherit;">Reset</button> | |
| <button id="btn-api-step" onclick="currentStep()" style="background:rgba(255,160,0,0.2);color:#ffa000;border:1px solid rgba(255,160,0,0.4);border-radius:6px;padding:4px 12px;font-size:11px;font-weight:600;cursor:pointer;font-family:inherit;" disabled>Step</button> | |
| <button id="btn-auto-run" onclick="toggleAutoRun()" style="display:none;background:rgba(156,39,176,0.2);color:#9c27b0;border:1px solid rgba(156,39,176,0.4);border-radius:6px;padding:4px 12px;font-size:11px;font-weight:600;cursor:pointer;font-family:inherit;" disabled>Auto Run</button> | |
| <div id="reward-display" style="background:rgba(10,14,23,0.9);border:1px solid rgba(100,180,255,0.3);border-radius:6px;padding:4px 10px;font-size:11px;min-width:220px;text-align:center;"> | |
| <span style="color:#8892a4;">Reward:</span> <span id="v-reward" style="color:#64b5f6;font-weight:600;">--</span> | |
| <span style="color:#555;margin:0 4px;">|</span> | |
| <span style="color:#8892a4;">Step:</span> <span id="v-step-num" style="color:#e0e0e0;font-weight:600;">--</span> | |
| <span id="model-label" style="display:none;color:#555;margin:0 4px;">|</span> | |
| <span id="model-name-display" style="display:none;color:#9c27b0;font-weight:600;font-size:10px;"></span> | |
| </div> | |
| <div id="status-badge">OFFLINE</div> | |
| </div> | |
| </div> | |
| <div id="cam-presets"> | |
| <button onclick="showTourStep(1)" style="background:rgba(0,212,170,0.2);color:#00d4aa;border-color:#00d4aa;font-weight:600;">▶ Guided Tour</button> | |
| <span style="color:#333;pointer-events:none;">|</span> | |
| <button onclick="setCam('overview')">🌎 Overview</button> | |
| <button onclick="setCam('desulf')">🔥 Desulfurizer</button> | |
| <button onclick="setCam('reformer')">🔥 Reformer</button> | |
| <button onclick="setCam('reactor')">⚙ Reactor</button> | |
| <button onclick="setCam('separator')">🔨 Separator</button> | |
| <button onclick="setCam('distillation')">🏭 Distillation</button> | |
| <button onclick="setCam('storage')">📦 Storage</button> | |
| <button onclick="setCam('control')">💻 Control Room</button> | |
| </div> | |
| <!-- ── Live Control Panel ──────────────────────────────────── --> | |
| <div id="ctrl-panel"> | |
| <div id="ctrl-toggle" onclick="toggleCtrlPanel()">▶</div> | |
| <h3>⚙ Live Controls</h3> | |
| <div class="ctrl-mode-bar"> | |
| <button id="btn-auto" class="active" onclick="setCtrlMode('auto')">Auto</button> | |
| <button id="btn-manual" onclick="setCtrlMode('manual')">Manual</button> | |
| </div> | |
| <button id="btn-step" onclick="manualStep()" disabled>▶ Step Forward</button> | |
| <hr class="ctrl-divider"> | |
| <div class="ctrl-row"> | |
| <label>H₂ Feed <span class="ctrl-val" id="cv-h2">4.0 mol/s</span></label> | |
| <input type="range" id="cr-h2" min="0" max="10" step="0.1" value="4" oninput="onCtrl()"> | |
| </div> | |
| <div class="ctrl-row"> | |
| <label>CO Feed <span class="ctrl-val" id="cv-co">2.0 mol/s</span></label> | |
| <input type="range" id="cr-co" min="0" max="5" step="0.1" value="2" oninput="onCtrl()"> | |
| </div> | |
| <div class="ctrl-row"> | |
| <label>Cooling <span class="ctrl-val" id="cv-cool">50 L/min</span></label> | |
| <input type="range" id="cr-cool" min="0" max="100" step="1" value="50" oninput="onCtrl()"> | |
| </div> | |
| <div class="ctrl-row"> | |
| <label>Compressor <span class="ctrl-val" id="cv-comp">40 kW</span></label> | |
| <input type="range" id="cr-comp" min="0" max="100" step="1" value="40" oninput="onCtrl()"> | |
| </div> | |
| <div class="ctrl-row"> | |
| <label>Purge Valve <span class="ctrl-val" id="cv-purge">2%</span></label> | |
| <input type="range" id="cr-purge" min="0" max="100" step="1" value="2" oninput="onCtrl()"> | |
| </div> | |
| <div class="ctrl-row"> | |
| <label>Recycle <span class="ctrl-val" id="cv-recycle">3.5</span></label> | |
| <input type="range" id="cr-recycle" min="0" max="8" step="0.1" value="3.5" oninput="onCtrl()"> | |
| </div> | |
| <hr class="ctrl-divider"> | |
| <div style="font-size:10px;color:#555;text-align:center;"> | |
| <span id="ctrl-status-text">Auto mode — demo drives controls</span> | |
| </div> | |
| </div> | |
| <div class="data-panel" id="panel-reactor"> | |
| <h3>◆ Reactor State</h3> | |
| <div class="data-row"><span class="label">Temperature</span><span class="value" id="v-temp">-- °C</span></div> | |
| <div class="data-row"><span class="label">Pressure</span><span class="value" id="v-pressure">-- bar</span></div> | |
| <div class="data-row"><span class="label">Reaction Rate</span><span class="value" id="v-rate">-- mol/s</span></div> | |
| <div class="data-row"><span class="label">H2/CO Ratio</span><span class="value" id="v-ratio">--</span></div> | |
| <div class="data-row"><span class="label">Feed H2</span><span class="value" id="v-h2">-- mol/s</span></div> | |
| <div class="data-row"><span class="label">Feed CO</span><span class="value" id="v-co">-- mol/s</span></div> | |
| </div> | |
| <div class="data-panel" id="panel-economics"> | |
| <h3>◆ Economics</h3> | |
| <div class="data-row"><span class="label">Step Profit</span><span class="value" id="v-profit">$--</span></div> | |
| <div class="data-row"><span class="label">Cumulative</span><span class="value" id="v-cum-profit">$--</span></div> | |
| <div class="data-row"><span class="label">MeOH Produced</span><span class="value" id="v-meoh">-- kg</span></div> | |
| <div class="data-row"><span class="label">CO2 Emissions</span><span class="value" id="v-co2">-- kg</span></div> | |
| <div class="data-row"><span class="label">Step / Max</span><span class="value" id="v-step">-- / --</span></div> | |
| </div> | |
| <div class="data-panel" id="panel-catalyst"> | |
| <h3>◆ Catalyst & Safety</h3> | |
| <div class="data-row"><span class="label">Catalyst Health</span><span class="value" id="v-cat">--%</span></div> | |
| <div class="data-row"><span class="label">Selectivity</span><span class="value" id="v-sel">--%</span></div> | |
| <div class="data-row"><span class="label">Purity</span><span class="value" id="v-purity">--%</span></div> | |
| <div class="data-row"><span class="label">Safety</span><span class="value good" id="v-safety">OK</span></div> | |
| <div class="data-row"><span class="label">Task</span><span class="value" id="v-task">--</span></div> | |
| </div> | |
| <div class="data-panel" id="panel-controls"> | |
| <h3>◆ Synthesis Loop</h3> | |
| <div class="data-row"><span class="label">Cooling Flow</span><span class="value" id="v-cool">-- L/min</span></div> | |
| <div class="data-row"><span class="label">Compressor</span><span class="value" id="v-comp">-- kW</span></div> | |
| <div class="data-row"><span class="label">Recycle Ratio</span><span class="value" id="v-recycle">--</span></div> | |
| <div class="data-row"><span class="label">Purge Rate</span><span class="value" id="v-purge">-- mol/s</span></div> | |
| <div class="data-row"><span class="label">Reformer T</span><span class="value" id="v-reform-t">-- °C</span></div> | |
| </div> | |
| <!-- Bed temperature bars --> | |
| <div class="data-panel" id="panel-beds"> | |
| <h3>◆ Reactor Beds (4-Bed ICI Quench)</h3> | |
| <div class="bed-bar" onclick="clickBedFromPanel(0)" title="Click to zoom into Bed 1"><span class="bed-label">Bed 1</span><div class="bar"><div class="bar-fill" id="bed1-fill" style="width:50%;background:#4CAF50"></div></div><span class="bed-val" id="bed1-val">250°C</span></div> | |
| <div class="bed-bar" onclick="clickBedFromPanel(1)" title="Click to zoom into Bed 2"><span class="bed-label">Bed 2</span><div class="bar"><div class="bar-fill" id="bed2-fill" style="width:50%;background:#4CAF50"></div></div><span class="bed-val" id="bed2-val">252°C</span></div> | |
| <div class="bed-bar" onclick="clickBedFromPanel(2)" title="Click to zoom into Bed 3"><span class="bed-label">Bed 3</span><div class="bar"><div class="bar-fill" id="bed3-fill" style="width:50%;background:#4CAF50"></div></div><span class="bed-val" id="bed3-val">254°C</span></div> | |
| <div class="bed-bar" onclick="clickBedFromPanel(3)" title="Click to zoom into Bed 4"><span class="bed-label">Bed 4</span><div class="bar"><div class="bar-fill" id="bed4-fill" style="width:50%;background:#FFC107"></div></div><span class="bed-val" id="bed4-val">256°C</span></div> | |
| <div style="margin-top:6px;font-size:10px;color:#666;">Quench gas injected between beds</div> | |
| </div> | |
| <!-- Chemistry panel --> | |
| <div class="data-panel" id="panel-chemistry"> | |
| <h3>◆ Active Reactions (LHHW Kinetics)</h3> | |
| <div class="rxn-row"> | |
| <div class="rxn-eq">R1: CO + 2H2 → CH3OH</div> | |
| <div class="rxn-info"><span class="exo">ΔH = -90.5 kJ/mol</span><span class="value" id="v-r1">-- mol/s</span></div> | |
| </div> | |
| <div class="rxn-row"> | |
| <div class="rxn-eq">R2: CO2 + 3H2 → CH3OH + H2O</div> | |
| <div class="rxn-info"><span class="exo">ΔH = -49.5 kJ/mol</span><span class="value" id="v-r2">-- mol/s</span></div> | |
| </div> | |
| <div class="rxn-row"> | |
| <div class="rxn-eq">R3: CO2 + H2 → CO + H2O</div> | |
| <div class="rxn-info"><span class="endo">ΔH = +41.2 kJ/mol (endo)</span><span class="value" id="v-r3">-- mol/s</span></div> | |
| </div> | |
| <div style="margin-top:6px;font-size:10px;color:#666;">Catalyst: Cu/ZnO/Al2O3 | SRK fugacity-corrected</div> | |
| </div> | |
| <!-- Multi-agent panel --> | |
| <div class="data-panel" id="panel-agents"> | |
| <h3>◆ Multi-Agent Control (4 Agents)</h3> | |
| <div class="agent-row"><div class="agent-dot" style="background:#FF9800"></div><span class="agent-name">Reformer</span><span class="agent-controls">fuel_gas, steam_flow</span></div> | |
| <div class="agent-row"><div class="agent-dot" style="background:#2196F3"></div><span class="agent-name">Synthesis</span><span class="agent-controls">H2, CO, cooling, compressor, purge, recycle</span></div> | |
| <div class="agent-row"><div class="agent-dot" style="background:#9C27B0"></div><span class="agent-name">Purification</span><span class="agent-controls">reflux, reboiler_duty</span></div> | |
| <div class="agent-row"><div class="agent-dot" style="background:#00BCD4"></div><span class="agent-name">Supervisory</span><span class="agent-controls">orchestrates all — sees full state</span></div> | |
| </div> | |
| <div id="legend"> | |
| <div class="legend-item"><div class="legend-dot" style="background:#2196F3"></div> Cool (<220°C)</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#4CAF50"></div> Optimal (240-260°C)</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#FFC107"></div> Hot (260-280°C)</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#F44336"></div> Critical (>280°C)</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#9C27B0"></div> Shutdown (>300°C)</div> | |
| </div> | |
| </div> | |
| <div id="tour-panel" class="data-panel" style="display:none;position:absolute;bottom:100px;left:50%;transform:translateX(-50%);min-width:380px;max-width:440px;z-index:150;pointer-events:auto;"></div> | |
| <div id="bed-popup" style="display:none;position:absolute;top:50%;right:30px;transform:translateY(-50%); | |
| background:rgba(10,14,23,0.96);border:1px solid rgba(0,212,170,0.4);border-radius:10px; | |
| padding:20px 24px;min-width:340px;max-width:400px;z-index:160;pointer-events:auto; | |
| box-shadow:0 8px 40px rgba(0,0,0,0.6);backdrop-filter:blur(8px);"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> | |
| <h3 id="bed-popup-title" style="color:#00d4aa;font-size:13px;text-transform:uppercase;letter-spacing:1.2px;margin:0;">Bed 1</h3> | |
| <button onclick="closeBedPopup()" style="background:none;border:none;color:#666;font-size:18px;cursor:pointer;padding:0 4px;">×</button> | |
| </div> | |
| <div id="bed-popup-content"></div> | |
| </div> | |
| <div id="tooltip"></div> | |
| <!-- Orchestrator detail popup (shown on tour step 10) --> | |
| <div id="orch-popup" style="display:none;position:absolute;top:50%;right:30px;transform:translateY(-50%); | |
| background:rgba(10,14,23,0.96);border:1px solid rgba(0,188,212,0.4);border-radius:10px; | |
| padding:20px 24px;width:360px;z-index:160;pointer-events:auto; | |
| box-shadow:0 8px 40px rgba(0,0,0,0.6);backdrop-filter:blur(8px);"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;"> | |
| <h3 style="color:#00BCD4;font-size:13px;text-transform:uppercase;letter-spacing:1.2px;margin:0;">◆ Supervisory Agent — Orchestrator</h3> | |
| <button onclick="closeOrchPopup()" style="background:none;border:none;color:#666;font-size:18px;cursor:pointer;padding:0 4px;">×</button> | |
| </div> | |
| <div style="font-size:12px;color:#8892a4;margin-bottom:12px;">Sees ALL 30+ observations. Controls ALL 13 action variables.</div> | |
| <div style="margin-bottom:12px;"> | |
| <div style="font-size:11px;color:#00BCD4;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;font-weight:600;">Coordinates 3 Sub-Agents</div> | |
| <div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(255,152,0,0.08);border-radius:5px;margin:4px 0;"> | |
| <span style="width:8px;height:8px;border-radius:50%;background:#FF9800;flex-shrink:0;"></span> | |
| <span style="font-size:12px;font-weight:600;color:#FF9800;width:100px;">ReformerAgent</span> | |
| <span style="font-size:11px;color:#8892a4;">fuel_gas, steam_flow</span> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(33,150,243,0.08);border-radius:5px;margin:4px 0;"> | |
| <span style="width:8px;height:8px;border-radius:50%;background:#2196F3;flex-shrink:0;"></span> | |
| <span style="font-size:12px;font-weight:600;color:#2196F3;width:100px;">SynthesisAgent</span> | |
| <span style="font-size:11px;color:#8892a4;">H2, CO, cooling, compressor, purge, recycle</span> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(156,39,176,0.08);border-radius:5px;margin:4px 0;"> | |
| <span style="width:8px;height:8px;border-radius:50%;background:#9C27B0;flex-shrink:0;"></span> | |
| <span style="font-size:12px;font-weight:600;color:#9C27B0;width:100px;">PurificationAgent</span> | |
| <span style="font-size:11px;color:#8892a4;">reflux, reboiler_duty</span> | |
| </div> | |
| </div> | |
| <div style="margin-bottom:12px;"> | |
| <div style="font-size:11px;color:#00BCD4;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;font-weight:600;">Decision Framework</div> | |
| <div style="font-size:12px;color:#b0bec5;line-height:1.7;padding-left:8px;"> | |
| ▶ Profit maximization vs Safety constraints<br> | |
| ▶ Catalyst life preservation (Cu/ZnO/Al₂O₃)<br> | |
| ▶ Game theory: day/night shift cooperation | |
| </div> | |
| </div> | |
| <div> | |
| <div style="font-size:11px;color:#00BCD4;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;font-weight:600;">4 MCP Tools</div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;"> | |
| <div style="background:rgba(255,255,255,0.03);padding:6px 8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;">get_energy_pricing()</div> | |
| <div style="font-size:11px;color:#e0e0e0;">Gas & electricity rates</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.03);padding:6px 8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;">get_catalyst_status()</div> | |
| <div style="font-size:11px;color:#e0e0e0;">Remaining hours</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.03);padding:6px 8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;">get_maintenance_schedule()</div> | |
| <div style="font-size:11px;color:#e0e0e0;">Turnaround plan</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.03);padding:6px 8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;">calculate_carbon_footprint()</div> | |
| <div style="font-size:11px;color:#e0e0e0;">EU ETS cost</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="connect-dialog"> | |
| <h2 style="margin-bottom:6px;">Connect to Methanol APC</h2> | |
| <p style="color:#8892a4;margin-bottom:16px;">Enter your HuggingFace Space URL or run locally</p> | |
| <input id="ws-url" type="text" value="wss://glitchfilter-methanol-apc-env.hf.space/ws" placeholder="wss://..."><br> | |
| <button onclick="connectWS()">Connect Live</button> | |
| <button onclick="connectADT()">Azure DT Live</button> | |
| <button class="secondary" onclick="startDemo()">Demo Mode</button> | |
| <p class="hint">Azure DT mode reads live twin data from Azure Digital Twins cloud</p> | |
| </div> | |
| <!-- ── Three.js CDN ──────────────────────────────────────────── --> | |
| <script type="importmap"> | |
| { "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/" | |
| }} | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // Polyfill for roundRect (Safari <15.4) | |
| if (!CanvasRenderingContext2D.prototype.roundRect) { | |
| CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { | |
| if (typeof r === 'number') r = [r, r, r, r]; | |
| this.beginPath(); | |
| this.moveTo(x + r[0], y); | |
| this.lineTo(x + w - r[1], y); | |
| this.quadraticCurveTo(x + w, y, x + w, y + r[1]); | |
| this.lineTo(x + w, y + h - r[2]); | |
| this.quadraticCurveTo(x + w, y + h, x + w - r[2], y + h); | |
| this.lineTo(x + r[3], y + h); | |
| this.quadraticCurveTo(x, y + h, x, y + h - r[3]); | |
| this.lineTo(x, y + r[0]); | |
| this.quadraticCurveTo(x, y, x + r[0], y); | |
| this.closePath(); | |
| }; | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| GLOBAL STATE | |
| ═══════════════════════════════════════════════════════════════ */ | |
| let S = { | |
| temperature: 250, pressure: 50, feed_rate_h2: 4, feed_rate_co: 2, | |
| h2_co_ratio: 2.0, cooling_water_flow: 50, catalyst_health: 1.0, | |
| methanol_produced: 0, reaction_rate: 0, profit_this_step: 0, | |
| cumulative_profit: 0, step_number: 0, max_steps: 100, task_name: 'startup', | |
| safety_warning: null, selectivity: 0.995, product_purity: 0.995, | |
| purge_rate: 0, recycle_ratio: 3.5, reformer_outlet_temp: 850, | |
| total_co2_emissions: 0, compressor_power: 40, flare_valve: 0, | |
| // New: per-bed temps, individual reaction rates, plant stages | |
| bed_temps: [250, 252, 254, 256], | |
| rate_R1: 0, rate_R2: 0, rate_R3: 0, | |
| desulf_h2s_ppm: 0.5, desulf_bed_life: 0.95, | |
| reformer_steam_carbon: 3.0, reformer_efficiency: 0.85, | |
| distillation_reflux: 3.0, reboiler_duty: 50, | |
| }; | |
| let ws = null, demoMode = false, demoStep = 0; | |
| /* ═══════════════════════════════════════════════════════════════ | |
| RENDERER / SCENE / CAMERA | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const canvas = document.getElementById('canvas3d'); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.1; | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.FogExp2(0x0a0e17, 0.006); | |
| const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 500); | |
| camera.position.set(38, 25, 40); | |
| const ctrl = new OrbitControls(camera, canvas); | |
| ctrl.enableDamping = true; ctrl.dampingFactor = 0.05; | |
| ctrl.target.set(0, 6, 0); | |
| ctrl.minDistance = 8; ctrl.maxDistance = 100; | |
| ctrl.maxPolarAngle = Math.PI / 2.05; | |
| /* ═══════════════════════════════════════════════════════════════ | |
| SKY DOME — gradient background with stars | |
| ═══════════════════════════════════════════════════════════════ */ | |
| (function buildSky() { | |
| const skyCanvas = document.createElement('canvas'); | |
| skyCanvas.width = 512; skyCanvas.height = 512; | |
| const ctx = skyCanvas.getContext('2d'); | |
| const g = ctx.createLinearGradient(0, 0, 0, 512); | |
| g.addColorStop(0.0, '#020510'); | |
| g.addColorStop(0.35, '#0a0e17'); | |
| g.addColorStop(0.65, '#0f1a2e'); | |
| g.addColorStop(1.0, '#162040'); | |
| ctx.fillStyle = g; ctx.fillRect(0, 0, 512, 512); | |
| // Stars | |
| ctx.fillStyle = '#ffffff'; | |
| for (let i = 0; i < 200; i++) { | |
| const x = Math.random() * 512, y = Math.random() * 350; | |
| const r = Math.random() * 1.2 + 0.3; | |
| ctx.globalAlpha = Math.random() * 0.6 + 0.2; | |
| ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| const tex = new THREE.CanvasTexture(skyCanvas); | |
| tex.mapping = THREE.EquirectangularReflectionMapping; | |
| scene.background = tex; | |
| scene.environment = tex; | |
| })(); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| LIGHTS | |
| ═══════════════════════════════════════════════════════════════ */ | |
| scene.add(new THREE.AmbientLight(0x1a2333, 1.8)); | |
| const sun = new THREE.DirectionalLight(0xffeedd, 1.8); | |
| sun.position.set(25, 35, 20); | |
| sun.castShadow = true; | |
| sun.shadow.mapSize.set(2048, 2048); | |
| const sc = sun.shadow.camera; | |
| sc.near = 1; sc.far = 100; sc.left = -50; sc.right = 50; sc.top = 50; sc.bottom = -50; | |
| scene.add(sun); | |
| scene.add(new THREE.DirectionalLight(0x4488ff, 0.3).translateX(-15).translateY(10)); | |
| scene.add(new THREE.HemisphereLight(0x2244aa, 0x111111, 0.4)); | |
| const reactorGlow = new THREE.PointLight(0x00d4aa, 1.5, 25); | |
| reactorGlow.position.set(0, 8, 0); scene.add(reactorGlow); | |
| const reformerGlow = new THREE.PointLight(0xff6633, 1.2, 18); | |
| reformerGlow.position.set(-18, 4, 0); scene.add(reformerGlow); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| MATERIALS | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const M = {}; | |
| M.floor = new THREE.MeshStandardMaterial({ color: 0x111827, roughness: 0.95, metalness: 0.05 }); | |
| M.concrete = new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.9, metalness: 0.05 }); | |
| M.steel = new THREE.MeshStandardMaterial({ color: 0x8899aa, roughness: 0.32, metalness: 0.88 }); | |
| M.steelDark = new THREE.MeshStandardMaterial({ color: 0x556677, roughness: 0.4, metalness: 0.82 }); | |
| M.copper = new THREE.MeshStandardMaterial({ color: 0xdd8844, roughness: 0.28, metalness: 0.92 }); | |
| M.pipe = new THREE.MeshStandardMaterial({ color: 0x667788, roughness: 0.38, metalness: 0.75 }); | |
| M.pipeBlue = new THREE.MeshStandardMaterial({ color: 0x4477aa, roughness: 0.35, metalness: 0.7 }); | |
| M.pipeGreen = new THREE.MeshStandardMaterial({ color: 0x44aa55, roughness: 0.35, metalness: 0.7 }); | |
| M.brick = new THREE.MeshStandardMaterial({ color: 0x994433, roughness: 0.88, metalness: 0.08 }); | |
| M.glass = new THREE.MeshStandardMaterial({ color: 0x88ccff, roughness: 0.05, metalness: 0.0, transparent: true, opacity: 0.25 }); | |
| M.yellow = new THREE.MeshStandardMaterial({ color: 0xddcc22, roughness: 0.5, metalness: 0.3 }); | |
| M.red = new THREE.MeshStandardMaterial({ color: 0xcc2222, roughness: 0.5, metalness: 0.3 }); | |
| M.insulation= new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.95, metalness: 0.05 }); | |
| M.grating = new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.6, metalness: 0.7, wireframe: false }); | |
| M.flameCore = new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.9 }); | |
| M.flameOuter= new THREE.MeshBasicMaterial({ color: 0xff2200, transparent: true, opacity: 0.4 }); | |
| // Dynamic | |
| M.reactorBody = new THREE.MeshStandardMaterial({ color: 0x4488aa, roughness: 0.32, metalness: 0.85 }); | |
| M.catBed = []; | |
| for (let i = 0; i < 4; i++) M.catBed.push(new THREE.MeshStandardMaterial({ color: 0x44aa66, roughness: 0.6, metalness: 0.25, emissive: 0x002211, emissiveIntensity: 0.3 })); | |
| M.reformerTube = new THREE.MeshStandardMaterial({ color: 0xcc6633, roughness: 0.35, metalness: 0.75, emissive: 0x331100, emissiveIntensity: 0.2 }); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| GEOMETRY HELPERS | |
| ═══════════════════════════════════════════════════════════════ */ | |
| // Pipe segment between two 3D points | |
| function mkPipe(a, b, r = 0.15, mat = M.pipe) { | |
| const d = new THREE.Vector3().subVectors(b, a); | |
| const L = d.length(); | |
| const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, L, 12), mat); | |
| m.position.lerpVectors(a, b, 0.5); | |
| m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), d.normalize()); | |
| m.castShadow = true; | |
| return m; | |
| } | |
| // Pipe elbow (torus quarter) at a corner position | |
| function mkElbow(pos, r = 0.15, bendR = 0.5, mat = M.pipe, rotX = 0, rotY = 0, rotZ = 0) { | |
| const g = new THREE.TorusGeometry(bendR, r, 12, 12, Math.PI / 2); | |
| const m = new THREE.Mesh(g, mat); | |
| m.position.copy(pos); | |
| m.rotation.set(rotX, rotY, rotZ); | |
| m.castShadow = true; | |
| return m; | |
| } | |
| // Flange ring | |
| function mkFlange(pos, outerR = 0.35, innerR = 0.15, thickness = 0.08, mat = M.steelDark, rotX = 0, rotY = 0, rotZ = 0) { | |
| const shape = new THREE.Shape(); | |
| shape.absarc(0, 0, outerR, 0, Math.PI * 2, false); | |
| const hole = new THREE.Path(); | |
| hole.absarc(0, 0, innerR, 0, Math.PI * 2, true); | |
| shape.holes.push(hole); | |
| const g = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); | |
| const m = new THREE.Mesh(g, mat); | |
| m.position.copy(pos); | |
| m.rotation.set(rotX, rotY, rotZ); | |
| // Add bolt circles | |
| const bolts = 8; | |
| const boltR = (outerR + innerR) / 2; | |
| for (let i = 0; i < bolts; i++) { | |
| const a = (i / bolts) * Math.PI * 2; | |
| const bGeo = new THREE.CylinderGeometry(0.025, 0.025, thickness + 0.04, 6); | |
| const b = new THREE.Mesh(bGeo, M.steelDark); | |
| b.position.set(Math.cos(a) * boltR, Math.sin(a) * boltR, thickness / 2); | |
| m.add(b); | |
| } | |
| return m; | |
| } | |
| // Nozzle (short pipe stub + flange) | |
| function mkNozzle(parent, pos, dir = 'x', len = 1.2, r = 0.2) { | |
| const g = new THREE.Group(); | |
| const pipeG = new THREE.CylinderGeometry(r, r, len, 12); | |
| const pipe = new THREE.Mesh(pipeG, M.steel); | |
| if (dir === 'x') { pipe.rotation.z = Math.PI / 2; pipe.position.x = len / 2; } | |
| else if (dir === '-x') { pipe.rotation.z = -Math.PI / 2; pipe.position.x = -len / 2; } | |
| else if (dir === 'y') { pipe.position.y = len / 2; } | |
| else if (dir === '-y') { pipe.position.y = -len / 2; } | |
| else if (dir === 'z') { pipe.rotation.x = Math.PI / 2; pipe.position.z = len / 2; } | |
| pipe.castShadow = true; | |
| g.add(pipe); | |
| // Flange at tip | |
| const flangeG = new THREE.TorusGeometry(r + 0.1, 0.04, 8, 16); | |
| const flange = new THREE.Mesh(flangeG, M.steelDark); | |
| if (dir === 'x') { flange.rotation.y = Math.PI / 2; flange.position.x = len; } | |
| else if (dir === '-x') { flange.rotation.y = Math.PI / 2; flange.position.x = -len; } | |
| else if (dir === 'y') { flange.rotation.x = Math.PI / 2; flange.position.y = len; } | |
| else if (dir === '-y') { flange.rotation.x = Math.PI / 2; flange.position.y = -len; } | |
| else if (dir === 'z') { flange.position.z = len; } | |
| g.add(flange); | |
| g.position.copy(pos); | |
| parent.add(g); | |
| return g; | |
| } | |
| // Platform with grating and handrails | |
| function mkPlatform(parent, y, radius, segments = 32) { | |
| const g = new THREE.Group(); | |
| // Grating floor | |
| const floor = new THREE.Mesh(new THREE.RingGeometry(radius - 0.1, radius + 1.2, segments, 1), M.grating); | |
| floor.rotation.x = -Math.PI / 2; floor.position.y = y; | |
| floor.receiveShadow = true; | |
| g.add(floor); | |
| // Railing posts | |
| for (let i = 0; i < 8; i++) { | |
| const a = (i / 8) * Math.PI * 2; | |
| const rOut = radius + 1.1; | |
| const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.1, 6), M.yellow); | |
| post.position.set(Math.cos(a) * rOut, y + 0.55, Math.sin(a) * rOut); | |
| g.add(post); | |
| } | |
| // Top rail ring | |
| const rail = new THREE.Mesh(new THREE.TorusGeometry(radius + 1.1, 0.035, 6, segments), M.yellow); | |
| rail.rotation.x = Math.PI / 2; rail.position.y = y + 1.1; | |
| g.add(rail); | |
| // Mid rail ring | |
| const midRail = new THREE.Mesh(new THREE.TorusGeometry(radius + 1.1, 0.025, 6, segments), M.yellow); | |
| midRail.rotation.x = Math.PI / 2; midRail.position.y = y + 0.55; | |
| g.add(midRail); | |
| parent.add(g); | |
| return g; | |
| } | |
| // Ladder | |
| function mkLadder(parent, x, z, yBottom, yTop) { | |
| const g = new THREE.Group(); | |
| const h = yTop - yBottom; | |
| // Side rails | |
| for (const dx of [-0.15, 0.15]) { | |
| const rail = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, h, 6), M.yellow); | |
| rail.position.set(x + dx, yBottom + h / 2, z); | |
| g.add(rail); | |
| } | |
| // Rungs | |
| const rungCount = Math.floor(h / 0.35); | |
| for (let i = 0; i < rungCount; i++) { | |
| const rung = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.3, 6), M.yellow); | |
| rung.rotation.z = Math.PI / 2; | |
| rung.position.set(x, yBottom + (i + 0.5) * (h / rungCount), z); | |
| g.add(rung); | |
| } | |
| parent.add(g); | |
| return g; | |
| } | |
| // Valve body (gate valve) | |
| function mkValve(parent, pos, pipeR = 0.15, dir = 'x') { | |
| const g = new THREE.Group(); | |
| // Body (box) | |
| const body = new THREE.Mesh(new THREE.BoxGeometry(pipeR * 3, pipeR * 4, pipeR * 3), M.steelDark); | |
| g.add(body); | |
| // Handwheel stem | |
| const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, pipeR * 3, 6), M.steel); | |
| stem.position.y = pipeR * 3.5; | |
| g.add(stem); | |
| // Handwheel | |
| const wheel = new THREE.Mesh(new THREE.TorusGeometry(pipeR * 1.5, 0.03, 6, 12), M.red); | |
| wheel.position.y = pipeR * 5; | |
| g.add(wheel); | |
| g.position.copy(pos); | |
| if (dir === 'z') g.rotation.y = Math.PI / 2; | |
| parent.add(g); | |
| return g; | |
| } | |
| // Instrument tap (small cylinder + small box "transmitter") | |
| function mkInstrument(parent, pos, label = 'TI', dir = 'x') { | |
| const g = new THREE.Group(); | |
| const probe = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.6, 8), M.steel); | |
| if (dir === 'x') { probe.rotation.z = Math.PI / 2; probe.position.x = 0.3; } | |
| else { probe.rotation.x = Math.PI / 2; probe.position.z = 0.3; } | |
| g.add(probe); | |
| const box = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.15), M.steelDark); | |
| if (dir === 'x') box.position.x = 0.7; | |
| else box.position.z = 0.7; | |
| g.add(box); | |
| g.position.copy(pos); | |
| parent.add(g); | |
| return g; | |
| } | |
| // Concrete pad | |
| function mkPad(parent, x, z, w, d, h = 0.3) { | |
| const pad = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), M.concrete); | |
| pad.position.set(x, h / 2, z); | |
| pad.receiveShadow = true; | |
| parent.add(pad); | |
| return pad; | |
| } | |
| // Sprite label | |
| function mkLabel(text, pos, size = 0.7) { | |
| const c = document.createElement('canvas'); | |
| c.width = 512; c.height = 80; | |
| const ctx = c.getContext('2d'); | |
| ctx.fillStyle = 'rgba(0,0,0,0.6)'; | |
| ctx.roundRect(10, 5, 492, 70, 12); ctx.fill(); | |
| ctx.fillStyle = '#00d4aa'; | |
| ctx.font = 'bold 40px Segoe UI, system-ui, sans-serif'; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText(text, 256, 42); | |
| const tex = new THREE.CanvasTexture(c); | |
| const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); | |
| sp.position.copy(pos); | |
| sp.scale.set(size * 5, size * 0.8, 1); | |
| return sp; | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| GROUND & GRID | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const ground = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), M.floor); | |
| ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; | |
| scene.add(ground); | |
| const grid = new THREE.GridHelper(150, 75, 0x1a2640, 0x141c30); | |
| grid.position.y = 0.01; scene.add(grid); | |
| const plant = new THREE.Group(); | |
| scene.add(plant); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 1. ICI 4-BED QUENCH REACTOR (center, 0,0,0) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const reactor = new THREE.Group(); | |
| mkPad(reactor, 0, 0, 7, 7); | |
| // Main shell (tall cylinder) | |
| const rShell = new THREE.Mesh(new THREE.CylinderGeometry(2.3, 2.3, 16, 48), M.reactorBody); | |
| rShell.position.y = 8.3; rShell.castShadow = true; rShell.receiveShadow = true; | |
| rShell.userData = { type: 'reactor', label: 'ICI 4-Bed Quench Reactor (T-101)\n3 simultaneous reactions (LHHW kinetics)\nR1: CO + 2H₂ → CH₃OH (-90.5 kJ/mol)\nR2: CO₂ + 3H₂ → CH₃OH + H₂O (-49.5 kJ/mol)\nR3: CO₂ + H₂ → CO + H₂O (+41.2 kJ/mol)\nCatalyst: Cu/ZnO/Al₂O₃\nAgent: SynthesisAgent (6 controls)' }; | |
| reactor.add(rShell); | |
| // Top & bottom torispherical heads (ellipsoid approximation) | |
| const headGeo = new THREE.SphereGeometry(2.3, 48, 24, 0, Math.PI * 2, 0, Math.PI / 3); | |
| const topHead = new THREE.Mesh(headGeo, M.reactorBody); | |
| topHead.position.y = 16.3; topHead.castShadow = true; reactor.add(topHead); | |
| const botHead = new THREE.Mesh(headGeo, M.reactorBody); | |
| botHead.position.y = 0.3; botHead.rotation.x = Math.PI; botHead.castShadow = true; reactor.add(botHead); | |
| // Insulation band (slightly larger semi-transparent cylinder) | |
| const insul = new THREE.Mesh(new THREE.CylinderGeometry(2.55, 2.55, 16.5, 48, 1, true), M.insulation); | |
| insul.position.y = 8.3; insul.material.transparent = true; insul.material.opacity = 0.12; | |
| reactor.add(insul); | |
| // 4 Catalyst beds with perforated support plates | |
| const bedY = [14.0, 11.0, 8.0, 5.0]; | |
| const bedNames = ['Top (inlet)', 'Upper-mid', 'Lower-mid', 'Bottom (outlet)']; | |
| const bedRoles = [ | |
| 'First contact with fresh syngas.\nLowest temp, highest driving force.\nMost conversion happens here.', | |
| 'Partially reacted gas after 1st quench.\nModerate temp, good selectivity.', | |
| 'Higher temp, equilibrium limits conversion.\nByproduct formation starts here.', | |
| 'Hottest bed — thermal runaway risk!\nIf >300°C → emergency shutdown.\nCatalyst sintering onset >270°C.', | |
| ]; | |
| const catBeds = []; | |
| for (let i = 0; i < 4; i++) { | |
| // Catalyst bed | |
| const bed = new THREE.Mesh(new THREE.CylinderGeometry(1.9, 1.9, 2.0, 32), M.catBed[i]); | |
| bed.position.y = bedY[i]; | |
| bed.userData = { type: 'catalyst_bed', bedIndex: i, | |
| label: `Bed ${i+1} — ${bedNames[i]}\nCu/ZnO/Al₂O₃ catalyst\n${bedRoles[i]}` }; | |
| catBeds.push(bed); | |
| reactor.add(bed); | |
| // Support plate (thin disk below bed) | |
| const plate = new THREE.Mesh(new THREE.CylinderGeometry(2.1, 2.1, 0.08, 32), M.steelDark); | |
| plate.position.y = bedY[i] - 1.04; | |
| reactor.add(plate); | |
| } | |
| // Quench gas distributor rings (between beds) | |
| const quenchRings = []; | |
| for (let i = 0; i < 3; i++) { | |
| const qy = bedY[i] - 1.5; | |
| const ring = new THREE.Mesh(new THREE.TorusGeometry(1.5, 0.08, 8, 24), M.copper); | |
| ring.rotation.x = Math.PI / 2; ring.position.y = qy; | |
| ring.userData = { type: 'quench', quenchIndex: i, | |
| label: `Quench Injector ${i+1} (between Bed ${i+1} & ${i+2})\nCold syngas at feed_preheat_temp (200-300°C)\nCools gas by ~5% approach per injection\nPrevents thermal runaway in downstream bed` }; | |
| quenchRings.push(ring); | |
| reactor.add(ring); | |
| // Quench nozzle from shell | |
| mkNozzle(reactor, new THREE.Vector3(2.3, qy, 0), 'x', 1.0, 0.12); | |
| } | |
| // Inlet nozzle (top) | |
| mkNozzle(reactor, new THREE.Vector3(0, 17.2, 0), 'y', 1.5, 0.35); | |
| // Outlet nozzle (bottom) | |
| mkNozzle(reactor, new THREE.Vector3(0, -0.8, 0), '-y', 1.2, 0.35); | |
| // Instrument taps on reactor shell | |
| mkInstrument(reactor, new THREE.Vector3(2.35, 14, 0.8), 'TE-101', 'x'); | |
| mkInstrument(reactor, new THREE.Vector3(2.35, 11, 0.8), 'TE-102', 'x'); | |
| mkInstrument(reactor, new THREE.Vector3(2.35, 8, -0.8), 'PI-101', 'x'); | |
| mkInstrument(reactor, new THREE.Vector3(2.35, 5, -0.8), 'TE-103', 'x'); | |
| // Pressure relief valve on top dome | |
| const prv = new THREE.Group(); | |
| const prvBody = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 0.8, 8), M.red); | |
| prvBody.position.y = 0.4; prv.add(prvBody); | |
| const prvBonnet = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.15, 0.3, 8), M.red); | |
| prvBonnet.position.y = 0.85; prv.add(prvBonnet); | |
| prv.position.set(1.0, 17.5, 0); | |
| reactor.add(prv); | |
| // Manway (access hatch) | |
| const manway = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.15, 16), M.steelDark); | |
| manway.rotation.z = Math.PI / 2; manway.position.set(2.35, 9.5, 0); | |
| reactor.add(manway); | |
| // Manway hinge | |
| const hinge = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.3, 0.08), M.steel); | |
| hinge.position.set(2.43, 9.85, 0); | |
| reactor.add(hinge); | |
| // Support skirt | |
| const skirt = new THREE.Mesh(new THREE.CylinderGeometry(2.5, 2.8, 1.8, 32, 1, true), M.steelDark); | |
| skirt.position.y = -0.9; reactor.add(skirt); | |
| // Platform at top | |
| mkPlatform(reactor, 16.0, 2.3, 32); | |
| // Platform at mid-height | |
| mkPlatform(reactor, 10.0, 2.3, 32); | |
| // Ladder | |
| mkLadder(reactor, 3.6, 0, 0.3, 16.0); | |
| plant.add(reactor); | |
| plant.add(mkLabel('REACTOR T-101', new THREE.Vector3(0, 20, 0), 0.8)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 2. STEAM METHANE REFORMER (left, -18, 0, 0) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const reformer = new THREE.Group(); | |
| reformer.position.set(-18, 0, 0); | |
| mkPad(reformer, 0, 0, 8, 6); | |
| // Radiant section (firebox) | |
| const firebox = new THREE.Mesh(new THREE.BoxGeometry(6, 10, 4.5), M.brick); | |
| firebox.position.y = 5.3; firebox.castShadow = true; firebox.receiveShadow = true; | |
| firebox.userData = { type: 'reformer', label: 'Steam Methane Reformer (H-101)\nCH4 + H2O → CO + 3H2 (800-950°C)\nEndothermic: ΔH = +206 kJ/mol\nNickel catalyst on alumina tubes\nAgent: ReformerAgent controls fuel & steam' }; | |
| reformer.add(firebox); | |
| // Inner refractory lining hint (slightly smaller glowing box) | |
| const refractory = new THREE.Mesh(new THREE.BoxGeometry(5.6, 9.6, 4.1), | |
| new THREE.MeshBasicMaterial({ color: 0x331100, transparent: true, opacity: 0.3 })); | |
| refractory.position.y = 5.3; reformer.add(refractory); | |
| // 8 catalyst-filled tubes | |
| for (let i = 0; i < 8; i++) { | |
| const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.14, 9.5, 12), M.reformerTube); | |
| tube.position.set(-2.1 + i * 0.6, 5.3, 0); tube.castShadow = true; | |
| reformer.add(tube); | |
| } | |
| // Pigtails at bottom (curved pipe connections) | |
| for (let i = 0; i < 8; i++) { | |
| const pigtail = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.06, 8, 8, Math.PI / 2), M.copper); | |
| pigtail.position.set(-2.1 + i * 0.6, 0.5, 1.5); | |
| reformer.add(pigtail); | |
| } | |
| // Inlet header (horizontal pipe connecting all tubes at top) | |
| const header = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 5.5, 12), M.steel); | |
| header.rotation.z = Math.PI / 2; header.position.y = 10.3; header.castShadow = true; | |
| reformer.add(header); | |
| // Outlet header at bottom | |
| const outHeader = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 5.5, 12), M.steel); | |
| outHeader.rotation.z = Math.PI / 2; outHeader.position.set(0, 0.5, 2); | |
| reformer.add(outHeader); | |
| // Convection section (box on top) | |
| const convection = new THREE.Mesh(new THREE.BoxGeometry(6, 3, 4.5), M.steelDark); | |
| convection.position.y = 11.8; convection.castShadow = true; reformer.add(convection); | |
| // Stack / chimney | |
| const stack = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 1.1, 6, 16), M.steelDark); | |
| stack.position.y = 16.3; stack.castShadow = true; reformer.add(stack); | |
| // Stack cap | |
| const stackCap = new THREE.Mesh(new THREE.ConeGeometry(1.2, 1, 16, 1, true), M.steel); | |
| stackCap.position.y = 19.5; reformer.add(stackCap); | |
| // Burners at bottom (6 circles) | |
| for (let i = 0; i < 6; i++) { | |
| const burner = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.1, 12), M.copper); | |
| burner.position.set(-1.5 + i * 0.6, 0.4, -1.5); | |
| reformer.add(burner); | |
| } | |
| // Peephole / observation port | |
| const peep = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.2, 12), M.glass); | |
| peep.rotation.x = Math.PI / 2; peep.position.set(0, 3, 2.35); | |
| reformer.add(peep); | |
| // Burner lights | |
| for (let i = 0; i < 3; i++) { | |
| const bl = new THREE.PointLight(0xff4400, 0.5, 4); | |
| bl.position.set(-1.5 + i * 1.5, 1.5, 0); | |
| reformer.add(bl); | |
| } | |
| // Platform | |
| mkPlatform(reformer, 10.3, 3.5, 8); | |
| mkLadder(reformer, 4.5, 0, 0.3, 10.3); | |
| // Instruments | |
| mkInstrument(reformer, new THREE.Vector3(3.1, 5, 0), 'TE-201'); | |
| mkInstrument(reformer, new THREE.Vector3(3.1, 8, 0), 'TE-202'); | |
| plant.add(reformer); | |
| plant.add(mkLabel('REFORMER H-101', new THREE.Vector3(-18, 21, 0), 0.7)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 3. FEED / EFFLUENT HEAT EXCHANGER (-9, 0, 0) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const hx = new THREE.Group(); | |
| hx.position.set(-9, 0, 0); | |
| mkPad(hx, 0, 0, 5, 3.5); | |
| // Shell | |
| const hxShell = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.3, 7, 32), M.steel); | |
| hxShell.rotation.z = Math.PI / 8; hxShell.position.y = 4.5; | |
| hxShell.castShadow = true; | |
| hxShell.userData = { type: 'hx', label: 'Feed/Effluent Heat Exchanger (E-101)\nShell & tube, U=250 W/(m²·K)\nA=8 m² heat transfer area\nCools reactor effluent / preheats feed\nBaffles for turbulent shell-side flow' }; | |
| hx.add(hxShell); | |
| // Tube sheets (visible disks at ends) | |
| for (const side of [-1, 1]) { | |
| const ts = new THREE.Mesh(new THREE.CylinderGeometry(1.35, 1.35, 0.1, 32), M.steelDark); | |
| ts.rotation.z = Math.PI / 8; | |
| const offset = 3.5 * side; | |
| ts.position.set(-offset * Math.sin(Math.PI/8), 4.5 + offset * Math.cos(Math.PI/8), 0); | |
| hx.add(ts); | |
| } | |
| // Internal baffles hint | |
| for (let i = 0; i < 4; i++) { | |
| const baffle = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.2, 0.05, 32), M.copper); | |
| baffle.rotation.z = Math.PI / 8; | |
| const t = -1.5 + i * 1.0; | |
| baffle.position.set(-t * Math.sin(Math.PI/8), 4.5 + t * Math.cos(Math.PI/8), 0); | |
| baffle.material = new THREE.MeshStandardMaterial({ color: 0xbb7744, roughness: 0.4, metalness: 0.7, transparent: true, opacity: 0.4 }); | |
| hx.add(baffle); | |
| } | |
| // Tube bundle hint (several small tubes) | |
| for (let i = 0; i < 12; i++) { | |
| const a = (i / 12) * Math.PI * 2; | |
| const r = 0.6; | |
| const t = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 6.5, 6), M.copper); | |
| t.rotation.z = Math.PI / 8; | |
| t.position.set(Math.cos(a)*r - 0, 4.5 + Math.sin(a)*r, 0); | |
| hx.add(t); | |
| } | |
| // Saddle supports | |
| for (const zOff of [-1.5, 1.5]) { | |
| const saddle = new THREE.Mesh(new THREE.TorusGeometry(1.5, 0.12, 8, 12, Math.PI), M.steelDark); | |
| saddle.rotation.y = Math.PI / 2; | |
| saddle.position.set(0, 1.5, zOff); | |
| hx.add(saddle); | |
| } | |
| // Nozzles | |
| mkNozzle(hx, new THREE.Vector3(0, 7.5, 0), 'y', 0.8, 0.2); | |
| mkNozzle(hx, new THREE.Vector3(0, 1.2, 0), '-y', 0.6, 0.2); | |
| mkNozzle(hx, new THREE.Vector3(1.4, 5.5, 0), 'x', 0.6, 0.15); | |
| mkNozzle(hx, new THREE.Vector3(-1.4, 3.5, 0), '-x', 0.6, 0.15); | |
| plant.add(hx); | |
| plant.add(mkLabel('HEAT EXCH E-101', new THREE.Vector3(-9, 9.5, 0), 0.5)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 4. DISTILLATION COLUMN (14, 0, 0) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const distCol = new THREE.Group(); | |
| distCol.position.set(14, 0, 0); | |
| mkPad(distCol, 0, 0, 6, 6); | |
| // Column shell | |
| const colShell = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.6, 20, 36), M.steel); | |
| colShell.position.y = 10.3; colShell.castShadow = true; | |
| colShell.userData = { type: 'distillation', label: 'Methanol Distillation Column (C-101)\nCrude MeOH → Grade AA (>99.85%)\nSieve trays, reflux ratio 1-10\nAgent: PurificationAgent controls reflux & reboiler\nOverhead: methanol + lights → condenser\nBottoms: water + heavies' }; | |
| distCol.add(colShell); | |
| // Top head | |
| const colTopHead = new THREE.Mesh(new THREE.SphereGeometry(1.6, 36, 18, 0, Math.PI*2, 0, Math.PI/3), M.steel); | |
| colTopHead.position.y = 20.3; distCol.add(colTopHead); | |
| // Bottom head | |
| const colBotHead = new THREE.Mesh(new THREE.SphereGeometry(1.6, 36, 18, 0, Math.PI*2, 0, Math.PI/3), M.steel); | |
| colBotHead.position.y = 0.3; colBotHead.rotation.x = Math.PI; distCol.add(colBotHead); | |
| // Sieve trays (10 internal disks, visible through column) | |
| for (let i = 0; i < 10; i++) { | |
| const tray = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 1.5, 0.05, 24), M.copper); | |
| tray.position.y = 2 + i * 1.8; | |
| tray.material = new THREE.MeshStandardMaterial({ color: 0xbb8844, roughness: 0.4, metalness: 0.7, transparent: true, opacity: 0.3 }); | |
| distCol.add(tray); | |
| // Tray ring visible on shell exterior | |
| const ring = new THREE.Mesh(new THREE.TorusGeometry(1.65, 0.04, 6, 36), M.steelDark); | |
| ring.rotation.x = Math.PI / 2; ring.position.y = 2 + i * 1.8; | |
| distCol.add(ring); | |
| } | |
| // Feed nozzle (mid-height) | |
| mkNozzle(distCol, new THREE.Vector3(1.65, 10, 0), 'x', 1.0, 0.2); | |
| // Overhead condenser (horizontal drum + fin fan) | |
| const condenser = new THREE.Group(); | |
| const condDrum = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 0.8, 3, 16), M.steel); | |
| condDrum.rotation.z = Math.PI / 2; condDrum.position.set(4, 19, 0); | |
| condDrum.castShadow = true; | |
| condDrum.userData = { type: 'condenser', label: 'Overhead Condenser (E-102)' }; | |
| condenser.add(condDrum); | |
| // Fin fan (flat box array) | |
| for (let i = 0; i < 3; i++) { | |
| const fin = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.05, 2.0), M.copper); | |
| fin.position.set(3 + i * 0.5, 20, 0); | |
| condenser.add(fin); | |
| } | |
| distCol.add(condenser); | |
| // Reflux drum | |
| const refluxDrum = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.6, 2.5, 16), M.steel); | |
| refluxDrum.rotation.z = Math.PI / 2; refluxDrum.position.set(4, 17, 0); | |
| refluxDrum.castShadow = true; distCol.add(refluxDrum); | |
| // Reboiler (shell & tube, horizontal) | |
| const reboiler = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.0, 3.5, 20), M.copper); | |
| reboiler.rotation.z = Math.PI / 2; reboiler.position.set(4, 1.5, 0); | |
| reboiler.castShadow = true; | |
| reboiler.userData = { type: 'reboiler', label: 'Reboiler (E-103) — Thermosiphon' }; | |
| distCol.add(reboiler); | |
| // Reboiler tube sheets | |
| for (const s of [-1, 1]) { | |
| const ts = new THREE.Mesh(new THREE.CylinderGeometry(1.05, 1.05, 0.08, 20), M.steelDark); | |
| ts.rotation.z = Math.PI / 2; ts.position.set(4 + s * 1.8, 1.5, 0); | |
| distCol.add(ts); | |
| } | |
| // Level gauge glass | |
| const gauge = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 3, 8), M.glass); | |
| gauge.position.set(1.75, 5, 0.8); distCol.add(gauge); | |
| const gaugeTop = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, 0.12), M.steelDark); | |
| gaugeTop.position.set(1.75, 6.5, 0.8); distCol.add(gaugeTop); | |
| const gaugeBot = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, 0.12), M.steelDark); | |
| gaugeBot.position.set(1.75, 3.5, 0.8); distCol.add(gaugeBot); | |
| // Nozzles | |
| mkNozzle(distCol, new THREE.Vector3(0, 20.8, 0), 'y', 0.8, 0.25); // overhead vapor | |
| mkNozzle(distCol, new THREE.Vector3(0, -0.5, 0), '-y', 0.8, 0.25); // bottoms | |
| // Platforms at 3 heights | |
| mkPlatform(distCol, 6.0, 1.6, 24); | |
| mkPlatform(distCol, 12.0, 1.6, 24); | |
| mkPlatform(distCol, 18.0, 1.6, 24); | |
| mkLadder(distCol, 3.2, 0, 0.3, 18.0); | |
| // Column support skirt | |
| const colSkirt = new THREE.Mesh(new THREE.CylinderGeometry(1.8, 2.0, 1.2, 32, 1, true), M.steelDark); | |
| colSkirt.position.y = -0.3; distCol.add(colSkirt); | |
| plant.add(distCol); | |
| plant.add(mkLabel('DISTILLATION C-101', new THREE.Vector3(14, 23, 0), 0.7)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 5. SYNGAS COMPRESSOR (7, 0, -9) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const comp = new THREE.Group(); | |
| comp.position.set(7, 0, -9); | |
| mkPad(comp, 0, 0, 6, 4); | |
| const compBody = new THREE.Mesh(new THREE.BoxGeometry(3.5, 2.8, 2.8), M.steelDark); | |
| compBody.position.y = 1.7; compBody.castShadow = true; | |
| compBody.userData = { type: 'compressor', label: 'Syngas Compressor (K-101)\n3-Stage Centrifugal, 0-100 kW\nPressurizes syngas to 50-100 bar\nRecycles unreacted gas (RR=3.5)\nAgent: SynthesisAgent controls power' }; | |
| comp.add(compBody); | |
| // Intake volute (cylinder on side) | |
| const volute = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.0, 1.5, 16), M.steel); | |
| volute.rotation.z = Math.PI / 2; volute.position.set(-2.8, 1.7, 0); | |
| volute.castShadow = true; comp.add(volute); | |
| // Motor | |
| const motor = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 3, 16), M.steel); | |
| motor.rotation.z = Math.PI / 2; motor.position.set(3.2, 1.7, 0); | |
| comp.add(motor); | |
| // Flywheel | |
| const flywheel = new THREE.Mesh(new THREE.TorusGeometry(1.0, 0.1, 8, 24), M.copper); | |
| flywheel.position.set(1.5, 1.7, 0); flywheel.rotation.y = Math.PI / 2; | |
| comp.add(flywheel); | |
| // Discharge nozzle | |
| mkNozzle(comp, new THREE.Vector3(0, 3.2, 0), 'y', 0.8, 0.2); | |
| // Intercoolers (two small drums beside compressor) | |
| for (let i = 0; i < 2; i++) { | |
| const ic = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 1.8, 12), M.copper); | |
| ic.position.set(-0.5 + i * 1.5, 1.7, -2); | |
| ic.userData = { label: `Intercooler ${i+1}` }; | |
| comp.add(ic); | |
| } | |
| plant.add(comp); | |
| plant.add(mkLabel('COMPRESSOR K-101', new THREE.Vector3(7, 5, -9), 0.5)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 6. COOLING WATER SYSTEM (0, 0, -12) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const cooling = new THREE.Group(); | |
| cooling.position.set(0, 0, -12); | |
| mkPad(cooling, 0, 0, 6, 6); | |
| // Cooling tower (hyperboloid approximation) | |
| const ctGeo = new THREE.LatheGeometry([ | |
| new THREE.Vector2(2.5, 0), new THREE.Vector2(2.0, 1.5), new THREE.Vector2(1.6, 3), | |
| new THREE.Vector2(1.5, 4), new THREE.Vector2(1.55, 5), new THREE.Vector2(1.7, 6), | |
| new THREE.Vector2(1.9, 7), | |
| ], 24); | |
| const ct = new THREE.Mesh(ctGeo, M.concrete); | |
| ct.castShadow = true; ct.userData = { type: 'cooling', label: 'Cooling Tower (CT-101)\nRemoves reaction heat from reactor\n0-100 L/min cooling water flow\nU=250 W/(m²·K), A=8 m² heat transfer\nDay/night cycle affects CW temp' }; | |
| cooling.add(ct); | |
| // Fill media hint (internal slats) | |
| for (let y = 1; y < 4; y++) { | |
| const fill = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.3, 0.05, 16), | |
| new THREE.MeshStandardMaterial({ color: 0x445566, roughness: 0.8, transparent: true, opacity: 0.3 })); | |
| fill.position.y = y; | |
| cooling.add(fill); | |
| } | |
| // Fan on top | |
| const fanHub = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.3, 12), M.steelDark); | |
| fanHub.position.y = 7.3; cooling.add(fanHub); | |
| // Fan blades | |
| for (let i = 0; i < 5; i++) { | |
| const blade = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.05, 0.3), M.steel); | |
| blade.position.y = 7.3; | |
| blade.rotation.y = (i / 5) * Math.PI * 2; | |
| blade.position.x = Math.cos((i / 5) * Math.PI * 2) * 0.6; | |
| blade.position.z = Math.sin((i / 5) * Math.PI * 2) * 0.6; | |
| cooling.add(blade); | |
| } | |
| // CW pump | |
| const cwPump = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.8, 12), M.steelDark); | |
| cwPump.position.set(3, 0.5, 0); cwPump.userData = { label: 'CW Pump (P-101)' }; | |
| cooling.add(cwPump); | |
| const pumpMotor = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 1.2, 12), M.steel); | |
| pumpMotor.rotation.z = Math.PI / 2; pumpMotor.position.set(4, 0.5, 0); | |
| cooling.add(pumpMotor); | |
| plant.add(cooling); | |
| plant.add(mkLabel('COOLING CT-101', new THREE.Vector3(0, 9, -12), 0.5)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 7. PRODUCT STORAGE TANK (24, 0, 6) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const storage = new THREE.Group(); | |
| storage.position.set(24, 0, 6); | |
| mkPad(storage, 0, 0, 8, 8); | |
| const tankShell = new THREE.Mesh(new THREE.CylinderGeometry(3, 3, 6, 32), M.steel); | |
| tankShell.position.y = 3; tankShell.castShadow = true; | |
| tankShell.userData = { type: 'storage', label: 'MeOH Storage Tank (TK-101)\n500 m³ capacity\nProduct: Grade AA Methanol >99.85%\nPrice: $0.74/kg (Methanex APAC Apr 2026)\nByproducts: DME, methyl formate (<0.5%)' }; | |
| storage.add(tankShell); | |
| // Floating roof | |
| const floatRoof = new THREE.Mesh(new THREE.CylinderGeometry(2.9, 2.9, 0.15, 32), M.steelDark); | |
| floatRoof.position.y = 5; storage.add(floatRoof); | |
| // Wind girder (top ring) | |
| const windGirder = new THREE.Mesh(new THREE.TorusGeometry(3.05, 0.06, 6, 32), M.steelDark); | |
| windGirder.rotation.x = Math.PI / 2; windGirder.position.y = 6; | |
| storage.add(windGirder); | |
| // Staircase (spiral) | |
| for (let i = 0; i < 20; i++) { | |
| const a = (i / 20) * Math.PI * 2; | |
| const step = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.05, 0.3), M.grating); | |
| step.position.set(Math.cos(a) * 3.3, 0.3 + i * 0.3, Math.sin(a) * 3.3); | |
| step.rotation.y = -a; | |
| storage.add(step); | |
| } | |
| // Nozzles | |
| mkNozzle(storage, new THREE.Vector3(3, 1, 0), 'x', 0.8, 0.25); | |
| mkNozzle(storage, new THREE.Vector3(3, 4, 0), 'x', 0.8, 0.15); | |
| plant.add(storage); | |
| plant.add(mkLabel('MeOH STORAGE TK-101', new THREE.Vector3(24, 8, 6), 0.6)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 8. FLARE STACK (28, 0, -10) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const flare = new THREE.Group(); | |
| flare.position.set(28, 0, -10); | |
| const flarePole = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.3, 22, 12), M.steelDark); | |
| flarePole.position.y = 11; flarePole.castShadow = true; flare.add(flarePole); | |
| // Guy wires (3 cables) | |
| for (let i = 0; i < 3; i++) { | |
| const a = (i / 3) * Math.PI * 2; | |
| const wire = mkPipe( | |
| new THREE.Vector3(Math.cos(a) * 8, 0.5, Math.sin(a) * 8), | |
| new THREE.Vector3(0, 18, 0), 0.02, M.steelDark | |
| ); | |
| flare.add(wire); | |
| } | |
| // Flare tip | |
| const flareTip = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.8, 12, 1, true), M.steelDark); | |
| flareTip.position.y = 22.5; flare.add(flareTip); | |
| // Flame (visible when flare_valve > 0, animated) | |
| const flameGroup = new THREE.Group(); | |
| flameGroup.position.y = 23; | |
| const flameCore = new THREE.Mesh(new THREE.ConeGeometry(0.3, 2, 8), M.flameCore); | |
| flameCore.position.y = 1; flameGroup.add(flameCore); | |
| const flameOuter = new THREE.Mesh(new THREE.ConeGeometry(0.6, 3, 8), M.flameOuter); | |
| flameOuter.position.y = 1.2; flameGroup.add(flameOuter); | |
| flameGroup.visible = false; | |
| flare.add(flameGroup); | |
| // Pilot burner (small light) | |
| const pilotLight = new THREE.PointLight(0xff6600, 0.3, 5); | |
| pilotLight.position.y = 22.5; | |
| flare.add(pilotLight); | |
| plant.add(flare); | |
| plant.add(mkLabel('FLARE', new THREE.Vector3(28, 25, -10), 0.5)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 9. CONTROL ROOM (-8, 0, -12) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const ctrlRoom = new THREE.Group(); | |
| ctrlRoom.position.set(-8, 0, -12); | |
| const building = new THREE.Mesh(new THREE.BoxGeometry(5, 3, 4), M.concrete); | |
| building.position.y = 1.5; building.castShadow = true; | |
| building.userData = { type: 'control_room', label: 'Control Room — DCS / APC Operator Station' }; | |
| ctrlRoom.add(building); | |
| // Roof | |
| const roof = new THREE.Mesh(new THREE.BoxGeometry(5.4, 0.2, 4.4), M.steelDark); | |
| roof.position.y = 3.1; ctrlRoom.add(roof); | |
| // Window | |
| const window1 = new THREE.Mesh(new THREE.PlaneGeometry(2.5, 1.2), M.glass); | |
| window1.position.set(0, 2, 2.01); ctrlRoom.add(window1); | |
| // Door | |
| const door = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 2.2), | |
| new THREE.MeshStandardMaterial({ color: 0x334455, roughness: 0.6 })); | |
| door.position.set(-1.5, 1.1, 2.01); ctrlRoom.add(door); | |
| // Satellite dish | |
| const dish = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 8, 0, Math.PI*2, 0, Math.PI/2), M.steel); | |
| dish.position.set(1.5, 3.5, 0); dish.rotation.x = -0.3; | |
| ctrlRoom.add(dish); | |
| plant.add(ctrlRoom); | |
| plant.add(mkLabel('CONTROL ROOM', new THREE.Vector3(-8, 5, -12), 0.5)); | |
| // ── Control Room Interior Detail: Monitor screens, Agent boards ── | |
| const screenMat = new THREE.MeshStandardMaterial({ color: 0x112233, emissive: 0x1a3a5a, emissiveIntensity: 0.6, roughness: 0.3 }); | |
| const screenGlow = new THREE.MeshStandardMaterial({ color: 0x00d4aa, emissive: 0x00d4aa, emissiveIntensity: 0.8, transparent: true, opacity: 0.7 }); | |
| // 3 monitor screens on the back wall (inside, visible through window) | |
| for (let i = 0; i < 3; i++) { | |
| const scr = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.8, 0.05), screenMat); | |
| scr.position.set(-9.3 + i * 1.4, 2.2, -12); | |
| scr.userData = { type: 'monitor', label: ['Reformer Monitor\nFuel gas, steam, S/C ratio\nAgent: ReformerAgent', 'Reactor & Synthesis Monitor\nTemp, pressure, 4-bed quench\nAgent: SynthesisAgent', 'Purification Monitor\nDistillation, purity, reflux\nAgent: PurificationAgent'][i] }; | |
| ctrlRoom.add(scr); | |
| // Screen "glow" bar at bottom | |
| const bar = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.06, 0.06), screenGlow); | |
| bar.position.set(-9.3 + i * 1.4, 1.75, -12); | |
| ctrlRoom.add(bar); | |
| } | |
| // Supervisory Agent "brain" hologram above the control room | |
| const holoGeo = new THREE.OctahedronGeometry(0.5, 1); | |
| const holoMat = new THREE.MeshStandardMaterial({ color: 0x00BCD4, emissive: 0x00BCD4, emissiveIntensity: 0.6, transparent: true, opacity: 0.5, wireframe: true }); | |
| const holo = new THREE.Mesh(holoGeo, holoMat); | |
| holo.position.set(-8, 4.5, -12); | |
| holo.userData = { type: 'orchestrator', label: 'Supervisory Agent (Orchestrator)\nSees ALL 30+ observations\nControls ALL 13 action variables\nResolves: profit vs safety vs catalyst\nGame theory: day/night shifts\nMCP tools: pricing, catalyst, maintenance, carbon' }; | |
| plant.add(holo); | |
| // Holo glow ring | |
| const ringGeo = new THREE.TorusGeometry(0.7, 0.03, 8, 32); | |
| const ringMat = new THREE.MeshStandardMaterial({ color: 0x00BCD4, emissive: 0x00BCD4, emissiveIntensity: 0.4, transparent: true, opacity: 0.4 }); | |
| const holoRing = new THREE.Mesh(ringGeo, ringMat); | |
| holoRing.position.set(-8, 4.5, -12); | |
| holoRing.rotation.x = Math.PI / 2; | |
| plant.add(holoRing); | |
| // Agent connection lines: hologram → each plant zone (dashed glow lines) | |
| const agentTargets = [ | |
| { pos: new THREE.Vector3(-18, 5, 0), color: 0xFF9800, name: 'ReformerAgent' }, | |
| { pos: new THREE.Vector3(0, 10, 0), color: 0x2196F3, name: 'SynthesisAgent' }, | |
| { pos: new THREE.Vector3(14, 8, 0), color: 0x9C27B0, name: 'PurificationAgent' }, | |
| ]; | |
| const agentLines = []; | |
| agentTargets.forEach(at => { | |
| const pts = [new THREE.Vector3(-8, 4.5, -12), at.pos]; | |
| const geo = new THREE.BufferGeometry().setFromPoints(pts); | |
| const mat = new THREE.LineDashedMaterial({ color: at.color, dashSize: 0.8, gapSize: 0.4, transparent: true, opacity: 0.5 }); | |
| const line = new THREE.Line(geo, mat); | |
| line.computeLineDistances(); | |
| line.userData = { type: 'agent_link', label: `${at.name}\nCommand link from Supervisory Agent` }; | |
| plant.add(line); | |
| agentLines.push(line); | |
| }); | |
| // Orchestration info board (floating billboard near control room) | |
| // Hidden by default — shown only on tour step 10 | |
| holo.visible = false; holoRing.visible = false; | |
| agentLines.forEach(l => l.visible = false); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 10. PIPE RACK (structural steel frame) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const pipeRack = new THREE.Group(); | |
| const rackY = 5.5, rackW = 2.5; | |
| // Vertical columns every 5 units from reformer to distillation | |
| for (let x = -14; x <= 10; x += 5) { | |
| for (const z of [-rackW/2, rackW/2]) { | |
| const col = new THREE.Mesh(new THREE.BoxGeometry(0.2, rackY, 0.2), M.steelDark); | |
| col.position.set(x, rackY / 2, -4 + z); | |
| col.castShadow = true; | |
| pipeRack.add(col); | |
| } | |
| // Cross beams | |
| const beam = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.15, rackW + 0.4), M.steelDark); | |
| beam.position.set(x, rackY, -4); | |
| pipeRack.add(beam); | |
| } | |
| // Longitudinal stringers (top) | |
| for (const z of [-rackW/2, rackW/2]) { | |
| const stringer = mkPipe( | |
| new THREE.Vector3(-14, rackY, -4 + z), | |
| new THREE.Vector3(10, rackY, -4 + z), 0.07, M.steelDark | |
| ); | |
| pipeRack.add(stringer); | |
| } | |
| plant.add(pipeRack); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 11. INTERCONNECT PIPING with valves | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const pipes = new THREE.Group(); | |
| // --- Reformer outlet → HX inlet --- | |
| pipes.add(mkPipe(new THREE.Vector3(-15, 5.5, 2), new THREE.Vector3(-15, 5.5, -4), 0.18, M.pipeBlue)); | |
| pipes.add(mkPipe(new THREE.Vector3(-15, 5.5, -4), new THREE.Vector3(-10.5, 5.5, -4), 0.18, M.pipeBlue)); | |
| mkValve(pipes, new THREE.Vector3(-12.5, 5.5, -4), 0.18, 'x'); | |
| // --- HX outlet → Reactor inlet --- | |
| pipes.add(mkPipe(new THREE.Vector3(-7.5, 5.5, -4), new THREE.Vector3(-3, 5.5, -4), 0.18, M.pipeBlue)); | |
| pipes.add(mkPipe(new THREE.Vector3(-3, 5.5, -4), new THREE.Vector3(-3, 5.5, 0), 0.18, M.pipeBlue)); | |
| pipes.add(mkPipe(new THREE.Vector3(-3, 5.5, 0), new THREE.Vector3(-3, 18, 0), 0.18, M.pipeBlue)); | |
| pipes.add(mkPipe(new THREE.Vector3(-3, 18, 0), new THREE.Vector3(0, 18, 0), 0.18, M.pipeBlue)); | |
| mkValve(pipes, new THREE.Vector3(-3, 12, 0), 0.18); | |
| // --- Reactor outlet → Distillation feed --- | |
| pipes.add(mkPipe(new THREE.Vector3(0, -2, 0), new THREE.Vector3(0, -2.5, 0), 0.2, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(0, -2.5, 0), new THREE.Vector3(0, -2.5, -4), 0.2, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(0, -2.5, -4), new THREE.Vector3(9, -2.5, -4), 0.2, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(9, -2.5, -4), new THREE.Vector3(9, -2.5, 0), 0.2, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(9, -2.5, 0), new THREE.Vector3(9, 10, 0), 0.2, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(9, 10, 0), new THREE.Vector3(14, 10, 0), 0.2, M.pipeGreen)); | |
| mkValve(pipes, new THREE.Vector3(5, -2.5, -4), 0.2, 'x'); | |
| // --- Distillation product → Storage --- | |
| pipes.add(mkPipe(new THREE.Vector3(14, -0.5, 0), new THREE.Vector3(14, -1.5, 0), 0.15, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(14, -1.5, 0), new THREE.Vector3(14, -1.5, 3), 0.15, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(14, -1.5, 3), new THREE.Vector3(21, -1.5, 3), 0.15, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(21, -1.5, 3), new THREE.Vector3(21, 1, 6), 0.15, M.pipeGreen)); | |
| mkValve(pipes, new THREE.Vector3(17.5, -1.5, 3), 0.15, 'x'); | |
| // --- Recycle: Compressor → Reactor --- | |
| pipes.add(mkPipe(new THREE.Vector3(7, 4, -9), new THREE.Vector3(7, 5.5, -9), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(7, 5.5, -9), new THREE.Vector3(2, 5.5, -9), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(2, 5.5, -9), new THREE.Vector3(2, 5.5, -4), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(2, 5.5, -4), new THREE.Vector3(2, 8, -4), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(2, 8, -4), new THREE.Vector3(2, 8, 0), 0.15, M.pipe)); | |
| // --- Cooling water → Reactor --- | |
| pipes.add(mkPipe(new THREE.Vector3(0, 3, -9.5), new THREE.Vector3(0, 3, -3), 0.12, M.pipeBlue)); | |
| pipes.add(mkPipe(new THREE.Vector3(0, 3, -3), new THREE.Vector3(2.5, 3, -3), 0.12, M.pipeBlue)); | |
| mkValve(pipes, new THREE.Vector3(0, 3, -6), 0.12, 'z'); | |
| // --- Flare header --- | |
| pipes.add(mkPipe(new THREE.Vector3(3, 5.5, -4), new THREE.Vector3(20, 5.5, -4), 0.1, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(20, 5.5, -4), new THREE.Vector3(28, 5.5, -10), 0.1, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(28, 5.5, -10), new THREE.Vector3(28, 11, -10), 0.1, M.pipe)); | |
| plant.add(pipes); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12. SAFETY SHOWER (-3, 0, -6) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const shower = new THREE.Group(); | |
| shower.position.set(-3, 0, -7); | |
| const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.5, 8), M.yellow); | |
| pole.position.y = 1.25; shower.add(pole); | |
| const head = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.15, 0.1, 12), M.yellow); | |
| head.position.y = 2.5; shower.add(head); | |
| const eyeWash = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.8, 0.3), M.yellow); | |
| eyeWash.position.set(0, 0.6, 0.2); shower.add(eyeWash); | |
| plant.add(shower); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12b. DESULFURIZATION GUARD BED (Stage 0) (-25, 0, 0) | |
| ZnO bed removes H2S from natural gas feed before reforming. | |
| ZnO + H2S → ZnS + H2O (breakthrough at ~350°C, 6-month bed life) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const desulf = new THREE.Group(); | |
| desulf.position.set(-28, 0, 0); | |
| mkPad(desulf, 0, 0, 4, 4); | |
| // Vertical vessel (smaller than reactor) | |
| const desulfShell = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.2, 6, 24), M.steel); | |
| desulfShell.position.y = 3.5; desulfShell.castShadow = true; | |
| desulfShell.userData = { type: 'desulfurizer', label: 'Desulfurization Guard Bed (V-100)\nZnO + H2S → ZnS + H2O\nRemoves sulfur to <0.1 ppm\nProtects Cu/ZnO/Al2O3 catalyst' }; | |
| desulf.add(desulfShell); | |
| // Top/bottom heads | |
| const dsHead = new THREE.SphereGeometry(1.2, 24, 12, 0, Math.PI*2, 0, Math.PI/3); | |
| const dsTop = new THREE.Mesh(dsHead, M.steel); | |
| dsTop.position.y = 6.5; desulf.add(dsTop); | |
| const dsBot = new THREE.Mesh(dsHead, M.steel); | |
| dsBot.position.y = 0.5; dsBot.rotation.x = Math.PI; desulf.add(dsBot); | |
| // ZnO bed (orange/white granules) | |
| const znoBedMat = new THREE.MeshStandardMaterial({ color: 0xddaa55, roughness: 0.8, metalness: 0.1 }); | |
| const znoBed = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.0, 4, 20), znoBedMat); | |
| znoBed.position.y = 3.5; desulf.add(znoBed); | |
| // Inlet/outlet nozzles | |
| mkNozzle(desulf, new THREE.Vector3(0, 6.8, 0), 'y', 0.8, 0.2); | |
| mkNozzle(desulf, new THREE.Vector3(0, -0.3, 0), '-y', 0.6, 0.2); | |
| // Instrument tap | |
| mkInstrument(desulf, new THREE.Vector3(1.25, 4.5, 0), 'AT-100', 'x'); | |
| // Support skirt | |
| const dsSkirt = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.5, 1.0, 20, 1, true), M.steelDark); | |
| dsSkirt.position.y = -0.1; desulf.add(dsSkirt); | |
| plant.add(desulf); | |
| plant.add(mkLabel('DESULFURIZER V-100', new THREE.Vector3(-28, 9, 0), 0.5)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12c. SEPARATOR / FLASH DRUM (between reactor & distillation) (5, 0, 5) | |
| Crude methanol + unreacted syngas separates here. | |
| Liquid (MeOH+H2O) → distillation; Vapor → recycle compressor | |
| 96% condensation efficiency, T~40°C at separator | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const separator = new THREE.Group(); | |
| separator.position.set(5, 0, 4); | |
| mkPad(separator, 0, 0, 5, 3); | |
| // Horizontal drum | |
| const sepShell = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 1.5, 5, 24), M.steel); | |
| sepShell.rotation.z = Math.PI / 2; sepShell.position.y = 2.5; sepShell.castShadow = true; | |
| sepShell.userData = { type: 'separator', label: 'Flash Drum / Separator (V-102)\nCrude MeOH liquid settles, vapor recycles\n96% condensation efficiency\nOperates at ~40°C, 50 bar' }; | |
| separator.add(sepShell); | |
| // End caps (hemispheres) | |
| for (const sx of [-2.5, 2.5]) { | |
| const cap = new THREE.Mesh(new THREE.SphereGeometry(1.5, 24, 12), M.steel); | |
| cap.position.set(sx, 2.5, 0); cap.scale.x = 0.3; separator.add(cap); | |
| } | |
| // Liquid level indicator (glass gauge) | |
| const sepGauge = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.0, 8), M.glass); | |
| sepGauge.position.set(0, 2.5, 1.55); separator.add(sepGauge); | |
| const liquidLevel = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.0, 8), | |
| new THREE.MeshStandardMaterial({ color: 0x44aaff, roughness: 0.2, metalness: 0.1 })); | |
| liquidLevel.position.set(0, 2.0, 1.55); separator.add(liquidLevel); | |
| // Inlet nozzle (from reactor — top) | |
| mkNozzle(separator, new THREE.Vector3(0, 4.0, 0), 'y', 0.8, 0.25); | |
| // Liquid outlet (bottom — to distillation) | |
| mkNozzle(separator, new THREE.Vector3(0, 0.8, 0), '-y', 0.6, 0.2); | |
| // Vapor outlet (top side — to recycle compressor) | |
| mkNozzle(separator, new THREE.Vector3(2.8, 3.5, 0), 'x', 0.8, 0.2); | |
| // Demister pad (wire mesh inside — shown as translucent disk) | |
| const demister = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.3, 0.15, 20), | |
| new THREE.MeshStandardMaterial({ color: 0x999999, roughness: 0.5, metalness: 0.5, transparent: true, opacity: 0.4 })); | |
| demister.rotation.z = Math.PI / 2; demister.position.set(1.0, 3.5, 0); separator.add(demister); | |
| // Saddle supports | |
| for (const sx of [-1.2, 1.2]) { | |
| const saddle = new THREE.Mesh(new THREE.TorusGeometry(1.6, 0.12, 8, 12, Math.PI), M.concrete); | |
| saddle.position.set(sx, 1.0, 0); saddle.rotation.y = Math.PI / 2; separator.add(saddle); | |
| } | |
| mkInstrument(separator, new THREE.Vector3(0.5, 4.2, 1.2), 'LI-102', 'z'); | |
| plant.add(separator); | |
| plant.add(mkLabel('SEPARATOR V-102', new THREE.Vector3(5, 6, 4), 0.5)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12d. FEED PREHEATER (economizer) (-12, 0, 5) | |
| Uses reactor effluent heat to preheat incoming syngas (200-300°C) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const preheater = new THREE.Group(); | |
| preheater.position.set(-12, 0, 6); | |
| mkPad(preheater, 0, 0, 3, 2); | |
| const phShell = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 0.8, 3, 16), M.steel); | |
| phShell.rotation.z = Math.PI / 2; phShell.position.y = 1.5; phShell.castShadow = true; | |
| phShell.userData = { type: 'preheater', label: 'Feed Preheater (E-102)\nHeats syngas 200→300°C\nUses reactor effluent as hot stream\nCounterflow shell & tube' }; | |
| preheater.add(phShell); | |
| // Tube sheets | |
| for (const dx of [-1.5, 1.5]) { | |
| const ts = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 0.08, 16), M.steelDark); | |
| ts.rotation.z = Math.PI / 2; ts.position.set(dx, 1.5, 0); preheater.add(ts); | |
| } | |
| mkNozzle(preheater, new THREE.Vector3(-1.8, 2.0, 0), '-x', 0.5, 0.12); | |
| mkNozzle(preheater, new THREE.Vector3(1.8, 2.0, 0), 'x', 0.5, 0.12); | |
| plant.add(preheater); | |
| plant.add(mkLabel('PREHEATER E-102', new THREE.Vector3(-12, 4, 6), 0.4)); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12e. ADDITIONAL PIPING — Desulf→Reformer, Reactor→Sep→Distill | |
| ═══════════════════════════════════════════════════════════════ */ | |
| // Natural gas feed → Desulfurizer | |
| pipes.add(mkPipe(new THREE.Vector3(-35, 2, 0), new THREE.Vector3(-28, 2, 0), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(-28, 2, 0), new THREE.Vector3(-28, 7.5, 0), 0.15, M.pipe)); | |
| mkValve(pipes, new THREE.Vector3(-31, 2, 0), 0.15, 'x'); | |
| // Desulfurizer outlet → Reformer inlet | |
| pipes.add(mkPipe(new THREE.Vector3(-28, -0.5, 0), new THREE.Vector3(-28, -1.5, 0), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(-28, -1.5, 0), new THREE.Vector3(-18, -1.5, 0), 0.15, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(-18, -1.5, 0), new THREE.Vector3(-18, 1, 0), 0.15, M.pipe)); | |
| // Reactor outlet → Separator inlet | |
| pipes.add(mkPipe(new THREE.Vector3(0, -2.0, 0), new THREE.Vector3(0, -2.5, 2), 0.18, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(0, -2.5, 2), new THREE.Vector3(5, -2.5, 4), 0.18, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(5, -2.5, 4), new THREE.Vector3(5, 4.8, 4), 0.18, M.pipeGreen)); | |
| // Separator liquid → Distillation | |
| pipes.add(mkPipe(new THREE.Vector3(5, 0.2, 4), new THREE.Vector3(5, -1, 4), 0.15, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(5, -1, 4), new THREE.Vector3(14, -1, 4), 0.15, M.pipeGreen)); | |
| pipes.add(mkPipe(new THREE.Vector3(14, -1, 4), new THREE.Vector3(14, 10, 0), 0.15, M.pipeGreen)); | |
| // Separator vapor → Recycle compressor | |
| pipes.add(mkPipe(new THREE.Vector3(8.3, 3.5, 4), new THREE.Vector3(10, 3.5, 4), 0.12, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(10, 3.5, 4), new THREE.Vector3(10, 3.5, -9), 0.12, M.pipe)); | |
| pipes.add(mkPipe(new THREE.Vector3(10, 3.5, -9), new THREE.Vector3(7, 3.5, -9), 0.12, M.pipe)); | |
| // Purge valve tee (off recycle line → flare) | |
| const purgeMat = new THREE.MeshStandardMaterial({ color: 0xaa4444, roughness: 0.35, metalness: 0.7 }); | |
| pipes.add(mkPipe(new THREE.Vector3(8.5, 3.5, -4), new THREE.Vector3(8.5, 5.5, -4), 0.08, purgeMat)); | |
| mkValve(pipes, new THREE.Vector3(8.5, 4.5, -4), 0.08); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12f. PROCESS FLOW LABELS (3D sprites on pipes showing what flows) | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const flowLabels = [ | |
| { text: 'Natural Gas\n(CH4 + H2S)', pos: [-33, 3.5, 0], color: '#FFA726' }, | |
| { text: 'Clean Gas\n(H2S < 0.1 ppm)', pos: [-23, -0.5, 0], color: '#66BB6A' }, | |
| { text: 'Syngas\n(H2 + CO + CO2)', pos: [-15, 7, -4], color: '#42A5F5' }, | |
| { text: 'Preheated Feed\n(200-300°C)', pos: [-3, 7, -2], color: '#42A5F5' }, | |
| { text: 'Crude MeOH\n(+ H2O + gas)', pos: [2, -3.5, 3], color: '#66BB6A' }, | |
| { text: 'Recycle Gas\n(RR=3.5)', pos: [9, 4.5, -5], color: '#BDBDBD' }, | |
| { text: 'Purge\n(CH4+N2 inerts)', pos: [10, 6, -4], color: '#EF5350' }, | |
| { text: 'Product MeOH\n(> 99.85%)', pos: [18, 0, 3], color: '#66BB6A' }, | |
| { text: 'Cooling Water', pos: [0, 4, -7], color: '#29B6F6' }, | |
| ]; | |
| for (const fl of flowLabels) { | |
| const c = document.createElement('canvas'); | |
| c.width = 256; c.height = 80; | |
| const ctx = c.getContext('2d'); | |
| ctx.fillStyle = 'rgba(10,14,23,0.75)'; | |
| ctx.roundRect(0, 0, 256, 80, 8); ctx.fill(); | |
| ctx.strokeStyle = fl.color; ctx.lineWidth = 2; | |
| ctx.roundRect(2, 2, 252, 76, 7); ctx.stroke(); | |
| ctx.fillStyle = fl.color; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; | |
| const lines = fl.text.split('\n'); | |
| lines.forEach((l, i) => ctx.fillText(l, 128, 28 + i * 22)); | |
| const tex = new THREE.CanvasTexture(c); | |
| const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); | |
| sp.position.set(fl.pos[0], fl.pos[1], fl.pos[2]); | |
| sp.scale.set(3.5, 1.1, 1); | |
| plant.add(sp); | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| 12g. NUMBERED STEP MARKERS — Interactive Guided Tour | |
| Judges can click numbers or use Next/Prev to walk through the process. | |
| Each step shows equipment, agent, and what happens there. | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const TOUR_STEPS = [ | |
| { num: 1, pos: [-33, 5, 3], cam: 'desulf', | |
| title: 'Natural Gas Feed', | |
| desc: 'Raw natural gas (CH4) enters the plant with H2S impurities.\nFeed rate: 5-20 mol/s depending on demand.', | |
| agent: null }, | |
| { num: 2, pos: [-28, 11, 3], cam: 'desulf', | |
| title: 'Desulfurization (V-100)', | |
| desc: 'ZnO guard bed removes H2S to < 0.1 ppm.\nProtects Cu/ZnO/Al2O3 catalyst downstream.\nBed life ~6 months, then replace.', | |
| agent: null }, | |
| { num: 3, pos: [-18, 15, 3], cam: 'reformer', | |
| title: 'Steam Methane Reformer (H-101)', | |
| desc: 'CH4 + H2O -> CO + 3H2 at 800-950 C\nEndothermic (+206 kJ/mol), needs fuel gas.\nNickel catalyst on alumina tubes.', | |
| agent: 'ReformerAgent: fuel_gas, steam_flow' }, | |
| { num: 4, pos: [-9, 8, -2], cam: 'reformer', | |
| title: 'Heat Exchanger (E-101)', | |
| desc: 'Feed/effluent shell & tube exchanger.\nU=250 W/(m2.K), A=8 m2.\nPreheats syngas using reactor outlet heat.', | |
| agent: null }, | |
| { num: 5, pos: [0, 22, 3], cam: 'reactor', | |
| title: 'ICI 4-Bed Quench Reactor (T-101)', | |
| desc: 'CORE: 3 simultaneous reactions (LHHW kinetics)\nR1: CO+2H2->CH3OH (-90.5 kJ/mol)\nR2: CO2+3H2->CH3OH+H2O (-49.5 kJ/mol)\nR3: CO2+H2->CO+H2O (+41.2 kJ/mol, endo)\nQuench gas between beds prevents runaway.\nEmergency shutdown at 300 C!', | |
| agent: 'SynthesisAgent: H2, CO, cooling, compressor, purge, recycle' }, | |
| { num: 6, pos: [5, 8, 7], cam: 'separator', | |
| title: 'Flash Drum / Separator (V-102)', | |
| desc: 'Only ~5% converts per pass!\nLiquid MeOH settles, vapor recycles.\n96% condensation efficiency at ~40 C.\nRecycle ratio = 3.5 (key control variable).', | |
| agent: null }, | |
| { num: 7, pos: [7, 7, -12], cam: 'overview', | |
| title: 'Recycle Compressor (K-101)', | |
| desc: '3-stage centrifugal, 0-100 kW.\nPressurizes unreacted gas back to 50-100 bar.\nPurge valve bleeds inerts (CH4, N2) to flare.', | |
| agent: 'SynthesisAgent: compressor_power, purge_valve' }, | |
| { num: 8, pos: [14, 22, 3], cam: 'distillation', | |
| title: 'Distillation Column (C-101)', | |
| desc: 'Crude MeOH -> Grade AA (>99.85% pure).\nSieve trays, reflux ratio 1-10.\nHigher reflux = purer but costs more energy.', | |
| agent: 'PurificationAgent: reflux, reboiler_duty' }, | |
| { num: 9, pos: [21, 8, 9], cam: 'storage', | |
| title: 'Product Storage (TK-101)', | |
| desc: 'Grade AA Methanol, 500 m3 tank.\nPrice: $0.74/kg (Methanex APAC Apr 2026).\nByproducts: DME, methyl formate (<0.5%).', | |
| agent: null }, | |
| { num: 10, pos: [-8, 7, -15], cam: 'control', | |
| title: 'Control Room — Supervisory Agent', | |
| desc: 'Sees ALL plant state, orchestrates 3 sub-agents.\nResolves conflicts (profit vs safety vs catalyst).\nGame theory: day/night cooperative shifts.\n4 MCP tools: pricing, catalyst, maintenance, carbon.', | |
| agent: 'SupervisoryAgent: orchestrates all 13 controls' }, | |
| ]; | |
| // Agent colors for step markers | |
| const AGENT_COLORS = { | |
| 'ReformerAgent': '#FF9800', | |
| 'SynthesisAgent': '#2196F3', | |
| 'PurificationAgent': '#9C27B0', | |
| 'SupervisoryAgent': '#00BCD4', | |
| }; | |
| // Create 3D numbered circle sprites | |
| const stepSprites = []; | |
| for (const step of TOUR_STEPS) { | |
| const sz = 128; | |
| const c = document.createElement('canvas'); | |
| c.width = sz; c.height = sz; | |
| const ctx = c.getContext('2d'); | |
| // Determine color from agent | |
| let ringColor = '#00d4aa'; | |
| if (step.agent) { | |
| const agentKey = Object.keys(AGENT_COLORS).find(k => step.agent.startsWith(k)); | |
| if (agentKey) ringColor = AGENT_COLORS[agentKey]; | |
| } | |
| // Circle background | |
| ctx.beginPath(); | |
| ctx.arc(sz/2, sz/2, 52, 0, Math.PI*2); | |
| ctx.fillStyle = 'rgba(10,14,23,0.88)'; | |
| ctx.fill(); | |
| ctx.lineWidth = 4; | |
| ctx.strokeStyle = ringColor; | |
| ctx.stroke(); | |
| // Number text | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 52px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(String(step.num), sz/2, sz/2); | |
| const tex = new THREE.CanvasTexture(c); | |
| const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); | |
| sp.position.set(step.pos[0], step.pos[1], step.pos[2]); | |
| sp.scale.set(2.2, 2.2, 1); | |
| sp.userData = { tourStep: step.num, label: `Step ${step.num}: ${step.title}` }; | |
| plant.add(sp); | |
| stepSprites.push(sp); | |
| } | |
| // Guided tour state | |
| let currentTourStep = 0; // 0 = not started, 1-10 = active step | |
| function showTourStep(n) { | |
| if (n < 1 || n > TOUR_STEPS.length) return; | |
| currentTourStep = n; | |
| const step = TOUR_STEPS[n-1]; | |
| // Show/hide orchestrator elements only on step 10 | |
| const showOrch = (n === 10); | |
| holo.visible = showOrch; holoRing.visible = showOrch; | |
| agentLines.forEach(l => l.visible = showOrch); | |
| document.getElementById('orch-popup').style.display = showOrch ? 'block' : 'none'; | |
| // Move camera | |
| if (window.setCam) window.setCam(step.cam); | |
| // Update tour panel | |
| const panel = document.getElementById('tour-panel'); | |
| const agentBadge = step.agent | |
| ? `<div style="margin-top:6px;padding:4px 8px;border-radius:4px;background:rgba(255,255,255,0.06);font-size:11px;color:#00d4aa;">Agent: ${step.agent}</div>` | |
| : '<div style="margin-top:6px;font-size:11px;color:#666;">No agent — fixed process</div>'; | |
| panel.innerHTML = ` | |
| <h3 style="color:#00d4aa;font-size:11px;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px;"> | |
| Step ${step.num} of ${TOUR_STEPS.length} | |
| </h3> | |
| <div style="font-size:15px;font-weight:600;margin-bottom:6px;">${step.title}</div> | |
| <div style="font-size:12px;color:#b0bec5;line-height:1.6;white-space:pre-line;">${step.desc}</div> | |
| ${agentBadge} | |
| <div style="display:flex;gap:8px;margin-top:10px;"> | |
| <button onclick="showTourStep(${n-1})" style="flex:1;padding:6px;border-radius:4px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.05);color:#ccc;cursor:pointer;font-size:12px;" ${n<=1?'disabled style="opacity:0.3;flex:1;padding:6px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:#555;cursor:default;font-size:12px;"':''}>Prev</button> | |
| <button onclick="${n>=TOUR_STEPS.length ? 'closeTour()' : 'showTourStep('+(n+1)+')'}" style="flex:1;padding:6px;border-radius:4px;border:1px solid #00d4aa;background:rgba(0,212,170,0.15);color:#00d4aa;cursor:pointer;font-size:12px;font-weight:600;">${n<TOUR_STEPS.length?'Next ▶':'✓ Done'}</button> | |
| </div> | |
| `; | |
| panel.style.display = 'block'; | |
| // Pulse the active step marker | |
| stepSprites.forEach((sp, i) => { | |
| sp.scale.set(i === n-1 ? 3.0 : 2.2, i === n-1 ? 3.0 : 2.2, 1); | |
| sp.material.opacity = i === n-1 ? 1.0 : 0.5; | |
| }); | |
| } | |
| window.showTourStep = showTourStep; | |
| function closeTour() { | |
| document.getElementById('tour-panel').style.display = 'none'; | |
| currentTourStep = 0; | |
| stepSprites.forEach(sp => { sp.scale.set(2.2, 2.2, 1); sp.material.opacity = 1.0; }); | |
| // Hide orchestrator elements | |
| holo.visible = false; holoRing.visible = false; | |
| agentLines.forEach(l => l.visible = false); | |
| document.getElementById('orch-popup').style.display = 'none'; | |
| if (window.setCam) window.setCam('overview'); | |
| } | |
| window.closeTour = closeTour; | |
| // Click on numbered markers to jump to that step | |
| canvas.addEventListener('click', (e) => { | |
| const mx = (e.clientX / window.innerWidth) * 2 - 1; | |
| const my = -(e.clientY / window.innerHeight) * 2 + 1; | |
| raycaster.setFromCamera(new THREE.Vector2(mx, my), camera); | |
| // Check tour step sprites first | |
| const tourHits = raycaster.intersectObjects(stepSprites); | |
| if (tourHits.length > 0 && tourHits[0].object.userData.tourStep) { | |
| showTourStep(tourHits[0].object.userData.tourStep); | |
| return; | |
| } | |
| // Check plant equipment (beds, quench rings, etc.) | |
| const plantHits = raycaster.intersectObjects(plant.children, true); | |
| for (const h of plantHits) { | |
| let obj = h.object; | |
| while (obj && !obj.userData?.type) obj = obj.parent; | |
| if (obj?.userData?.type === 'catalyst_bed' || obj?.userData?.type === 'quench') { | |
| showBedPopup(obj.userData); | |
| // Zoom camera to that bed level | |
| const by = obj.userData.type === 'catalyst_bed' ? bedY[obj.userData.bedIndex] : bedY[obj.userData.quenchIndex] - 1.5; | |
| const startPos = camera.position.clone(); | |
| const startTarget = ctrl.target.clone(); | |
| const endPos = new THREE.Vector3(6, by + 1, 5); | |
| const endTarget = new THREE.Vector3(0, by, 0); | |
| const startTime = performance.now(); | |
| function animZoom(now) { | |
| const t = Math.min(1, (now - startTime) / 800); | |
| const ease = t < 0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2; | |
| camera.position.lerpVectors(startPos, endPos, ease); | |
| ctrl.target.lerpVectors(startTarget, endTarget, ease); | |
| ctrl.update(); | |
| if (t < 1) requestAnimationFrame(animZoom); | |
| } | |
| requestAnimationFrame(animZoom); | |
| return; | |
| } | |
| } | |
| }); | |
| // Bed detail popup | |
| function showBedPopup(ud) { | |
| const popup = document.getElementById('bed-popup'); | |
| const title = document.getElementById('bed-popup-title'); | |
| const content = document.getElementById('bed-popup-content'); | |
| const beds = S.bed_temps || [S.temperature, S.temperature, S.temperature, S.temperature]; | |
| const f = (v, d=1) => Number(v).toFixed(d); | |
| if (ud.type === 'catalyst_bed') { | |
| const bi = ud.bedIndex; | |
| const bt = beds[bi] || S.temperature; | |
| const statusColor = bt < 240 ? '#2196F3' : bt < 260 ? '#4CAF50' : bt < 280 ? '#FFC107' : bt < 300 ? '#F44336' : '#9C27B0'; | |
| const statusText = bt < 240 ? 'COOL — Below optimal' : bt < 260 ? 'OPTIMAL — Peak efficiency' : bt < 280 ? 'HOT — Selectivity dropping' : bt < 300 ? 'CRITICAL — Catalyst sintering!' : 'SHUTDOWN — Emergency!'; | |
| const rr = S.reaction_rate || 0; | |
| // Approximate per-bed contribution (top beds do more conversion) | |
| const bedFractions = [0.40, 0.28, 0.20, 0.12]; | |
| const bedRate = rr * bedFractions[bi]; | |
| const bedMeOH = bedRate * 0.032 * 60; // kg/step | |
| title.textContent = `Catalyst Bed ${bi+1} — ${bedNames[bi]}`; | |
| content.innerHTML = ` | |
| <div style="background:rgba(255,255,255,0.04);border-radius:6px;padding:10px 12px;margin-bottom:10px;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;"> | |
| <span style="color:#8892a4;font-size:12px;">Temperature</span> | |
| <span style="font-size:20px;font-weight:700;color:${statusColor};">${f(bt)}°C</span> | |
| </div> | |
| <div style="margin-top:6px;height:8px;background:#1a1a2e;border-radius:4px;overflow:hidden;"> | |
| <div style="height:100%;width:${Math.max(2,Math.min(100,(bt-150)/1.5))}%;background:${statusColor};border-radius:4px;transition:width 0.5s;"></div> | |
| </div> | |
| <div style="font-size:11px;color:${statusColor};margin-top:4px;">${statusText}</div> | |
| </div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px;"> | |
| <div style="background:rgba(255,255,255,0.03);padding:8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;text-transform:uppercase;">Reaction Rate</div> | |
| <div style="font-size:14px;font-weight:600;color:#e0e0e0;">${f(bedRate,3)} mol/s</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.03);padding:8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;text-transform:uppercase;">MeOH Output</div> | |
| <div style="font-size:14px;font-weight:600;color:#e0e0e0;">${f(bedMeOH,2)} kg/step</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.03);padding:8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;text-transform:uppercase;">Catalyst Health</div> | |
| <div style="font-size:14px;font-weight:600;color:${S.catalyst_health > 0.7 ? '#4CAF50' : S.catalyst_health > 0.4 ? '#FFC107' : '#F44336'};">${f(S.catalyst_health*100)}%</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.03);padding:8px;border-radius:5px;"> | |
| <div style="font-size:10px;color:#666;text-transform:uppercase;">Conversion Share</div> | |
| <div style="font-size:14px;font-weight:600;color:#e0e0e0;">${(bedFractions[bi]*100).toFixed(0)}%</div> | |
| </div> | |
| </div> | |
| ${bi < 3 ? `<div style="background:rgba(221,136,68,0.1);border:1px solid rgba(221,136,68,0.3);border-radius:5px;padding:8px 10px;font-size:12px;"> | |
| <span style="color:#dd8844;font-weight:600;">▼ Quench below</span><br> | |
| <span style="color:#aaa;">Cold shot injection cools gas before Bed ${bi+2}</span><br> | |
| <span style="color:#dd8844;">Bed ${bi+1}: ${f(bt)}°C → Bed ${bi+2}: ${f(beds[bi+1]||bt)}°C</span> | |
| </div>` : `<div style="background:rgba(244,67,54,0.1);border:1px solid rgba(244,67,54,0.3);border-radius:5px;padding:8px 10px;font-size:12px;"> | |
| <span style="color:#F44336;font-weight:600;">⚠ Last bed — no quench below</span><br> | |
| <span style="color:#aaa;">Hottest point in reactor. If >300°C = emergency shutdown.</span> | |
| </div>`} | |
| <div style="margin-top:8px;font-size:11px;color:#555;"> | |
| Position: ${bedNames[bi]} | Y=${bedY[bi]}m | Agent: SynthesisAgent | |
| </div> | |
| `; | |
| } else if (ud.type === 'quench') { | |
| const qi = ud.quenchIndex; | |
| const above = beds[qi] || S.temperature; | |
| const below = beds[qi+1] || S.temperature; | |
| const drop = above - below; | |
| title.textContent = `Quench Injector ${qi+1}`; | |
| content.innerHTML = ` | |
| <div style="background:rgba(221,136,68,0.08);border-radius:6px;padding:12px;margin-bottom:10px;text-align:center;"> | |
| <div style="font-size:13px;color:#aaa;">Bed ${qi+1}</div> | |
| <div style="font-size:22px;font-weight:700;color:${above > 260 ? '#FFC107' : '#4CAF50'};">${f(above)}°C</div> | |
| <div style="font-size:20px;color:#dd8844;margin:4px 0;">▼ Cold Shot ▼</div> | |
| <div style="font-size:13px;color:#aaa;">Bed ${qi+2}</div> | |
| <div style="font-size:22px;font-weight:700;color:${below > 260 ? '#FFC107' : '#4CAF50'};">${f(below)}°C</div> | |
| </div> | |
| <div style="background:rgba(255,255,255,0.04);border-radius:5px;padding:8px 10px;font-size:12px;color:#b0bec5;line-height:1.6;"> | |
| Cold syngas at <b>${f(S.bed_temps?.[0]-10 || 200)}°C</b> is injected between beds.<br> | |
| Temperature drop: <b style="color:#dd8844;">${f(Math.abs(drop))}°C</b><br> | |
| Controls: <b>feed_preheat_temp</b> (200-300°C range) | |
| </div> | |
| `; | |
| } | |
| popup.style.display = 'block'; | |
| } | |
| window.showBedPopup = showBedPopup; | |
| // HUD panel bed row click → zoom + popup (same as clicking 3D bed) | |
| function clickBedFromPanel(bi) { | |
| // Build userData matching what the 3D bed would have | |
| const ud = { type: 'catalyst_bed', bedIndex: bi, name: `Catalyst Bed ${bi+1}`, desc: bedNames[bi] }; | |
| showBedPopup(ud); | |
| // Zoom camera to that bed level | |
| const by = bedY[bi]; | |
| const startPos = camera.position.clone(); | |
| const startTarget = ctrl.target.clone(); | |
| const endPos = new THREE.Vector3(6, by + 1, 5); | |
| const endTarget = new THREE.Vector3(0, by, 0); | |
| const startTime = performance.now(); | |
| function animZoom(now) { | |
| const t = Math.min(1, (now - startTime) / 800); | |
| const ease = t < 0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2; | |
| camera.position.lerpVectors(startPos, endPos, ease); | |
| ctrl.target.lerpVectors(startTarget, endTarget, ease); | |
| ctrl.update(); | |
| if (t < 1) requestAnimationFrame(animZoom); | |
| } | |
| requestAnimationFrame(animZoom); | |
| } | |
| window.clickBedFromPanel = clickBedFromPanel; | |
| function closeBedPopup() { | |
| document.getElementById('bed-popup').style.display = 'none'; | |
| // Zoom back to reactor overview | |
| const startPos = camera.position.clone(); | |
| const startTarget = ctrl.target.clone(); | |
| const endPos = new THREE.Vector3(8, 12, 10); | |
| const endTarget = new THREE.Vector3(0, 8, 0); | |
| const startTime = performance.now(); | |
| function animOut(now) { | |
| const t = Math.min(1, (now - startTime) / 600); | |
| const ease = t < 0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2; | |
| camera.position.lerpVectors(startPos, endPos, ease); | |
| ctrl.target.lerpVectors(startTarget, endTarget, ease); | |
| ctrl.update(); | |
| if (t < 1) requestAnimationFrame(animOut); | |
| } | |
| requestAnimationFrame(animOut); | |
| } | |
| window.closeBedPopup = closeBedPopup; | |
| function closeOrchPopup() { | |
| document.getElementById('orch-popup').style.display = 'none'; | |
| } | |
| window.closeOrchPopup = closeOrchPopup; | |
| // Steam particles from reformer stack | |
| const steamCount = 80; | |
| const steamGeo = new THREE.BufferGeometry(); | |
| const steamPos = new Float32Array(steamCount * 3); | |
| const steamVel = new Float32Array(steamCount * 3); | |
| const steamLife = new Float32Array(steamCount); | |
| for (let i = 0; i < steamCount; i++) { | |
| resetSteam(i); | |
| } | |
| steamGeo.setAttribute('position', new THREE.BufferAttribute(steamPos, 3)); | |
| const steamMat = new THREE.PointsMaterial({ color: 0xaabbcc, size: 0.4, transparent: true, opacity: 0.4, sizeAttenuation: true }); | |
| const steamParticles = new THREE.Points(steamGeo, steamMat); | |
| scene.add(steamParticles); | |
| function resetSteam(i) { | |
| steamPos[i*3] = -18 + (Math.random()-0.5)*0.5; | |
| steamPos[i*3+1] = 19.5 + Math.random()*2; | |
| steamPos[i*3+2] = (Math.random()-0.5)*0.5; | |
| steamVel[i*3] = (Math.random()-0.5)*0.3; | |
| steamVel[i*3+1] = 0.5 + Math.random()*1.0; | |
| steamVel[i*3+2] = (Math.random()-0.5)*0.3; | |
| steamLife[i] = Math.random() * 3; | |
| } | |
| // Process flow particles | |
| const flowCount = 250; | |
| const flowGeo = new THREE.BufferGeometry(); | |
| const flowPos = new Float32Array(flowCount * 3); | |
| const flowCol = new Float32Array(flowCount * 3); | |
| const flowT = new Float32Array(flowCount); // parametric position along path | |
| const flowPath= new Uint8Array(flowCount); // which path | |
| const flowSpd = new Float32Array(flowCount); | |
| const PATHS = [ | |
| // 0: Syngas (reformer→reactor) BLUE | |
| [[-15,5.5,2],[-15,5.5,-4],[-10.5,5.5,-4],[-7.5,5.5,-4],[-3,5.5,-4],[-3,5.5,0],[-3,18,0],[0,18,0]], | |
| // 1: Crude methanol (reactor→distillation) GREEN | |
| [[0,-2,0],[0,-2.5,-4],[9,-2.5,-4],[9,-2.5,0],[9,10,0],[14,10,0]], | |
| // 2: Product (distillation→storage) CYAN | |
| [[14,-1.5,0],[14,-1.5,3],[21,-1.5,3],[21,1,6]], | |
| // 3: Recycle (compressor→reactor) GREY | |
| [[7,4,-9],[7,5.5,-9],[2,5.5,-9],[2,5.5,-4],[2,8,-4],[2,8,0]], | |
| ]; | |
| const PATH_COLORS = [[0.2,0.5,1],[0.2,0.9,0.4],[0,0.8,0.8],[0.6,0.6,0.7]]; | |
| for (let i = 0; i < flowCount; i++) { | |
| const p = Math.floor(Math.random()*PATHS.length); | |
| flowPath[i] = p; | |
| flowT[i] = Math.random(); | |
| flowSpd[i] = 0.1 + Math.random()*0.3; | |
| const c = PATH_COLORS[p]; | |
| flowCol[i*3] = c[0]; flowCol[i*3+1] = c[1]; flowCol[i*3+2] = c[2]; | |
| setFlowPos(i); | |
| } | |
| flowGeo.setAttribute('position', new THREE.BufferAttribute(flowPos, 3)); | |
| flowGeo.setAttribute('color', new THREE.BufferAttribute(flowCol, 3)); | |
| const flowMat = new THREE.PointsMaterial({ size: 0.12, vertexColors: true, transparent: true, opacity: 0.7, sizeAttenuation: true }); | |
| const flowParticles = new THREE.Points(flowGeo, flowMat); | |
| scene.add(flowParticles); | |
| function setFlowPos(i) { | |
| const path = PATHS[flowPath[i]]; | |
| const totalSegs = path.length - 1; | |
| const globalT = flowT[i] * totalSegs; | |
| const segIdx = Math.min(Math.floor(globalT), totalSegs - 1); | |
| const localT = globalT - segIdx; | |
| const a = path[segIdx], b = path[segIdx + 1]; | |
| flowPos[i*3] = a[0] + (b[0]-a[0])*localT + (Math.random()-0.5)*0.1; | |
| flowPos[i*3+1] = a[1] + (b[1]-a[1])*localT + (Math.random()-0.5)*0.1; | |
| flowPos[i*3+2] = a[2] + (b[2]-a[2])*localT + (Math.random()-0.5)*0.1; | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| RAYCASTING FOR TOOLTIPS | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| const tooltip = document.getElementById('tooltip'); | |
| canvas.addEventListener('mousemove', (e) => { | |
| mouse.x = (e.clientX/window.innerWidth)*2-1; | |
| mouse.y = -(e.clientY/window.innerHeight)*2+1; | |
| raycaster.setFromCamera(mouse, camera); | |
| const hits = raycaster.intersectObjects(plant.children, true); | |
| let found = false; | |
| for (const h of hits) { | |
| let obj = h.object; | |
| while (obj && !obj.userData?.label) obj = obj.parent; | |
| if (obj?.userData?.label) { | |
| tooltip.style.display = 'block'; | |
| tooltip.style.left = e.clientX+15+'px'; | |
| tooltip.style.top = e.clientY-10+'px'; | |
| let html = obj.userData.label.replace(/\n/g, '<br>'); | |
| // Inject live data for catalyst beds | |
| if (obj.userData.type === 'catalyst_bed' && obj.userData.bedIndex !== undefined) { | |
| const bi = obj.userData.bedIndex; | |
| const beds = S.bed_temps || [S.temperature, S.temperature, S.temperature, S.temperature]; | |
| const bt = beds[bi] || S.temperature; | |
| const statusColor = bt < 240 ? '#2196F3' : bt < 260 ? '#4CAF50' : bt < 280 ? '#FFC107' : bt < 300 ? '#F44336' : '#9C27B0'; | |
| const statusText = bt < 240 ? 'COOL' : bt < 260 ? 'OPTIMAL' : bt < 280 ? 'HOT' : bt < 300 ? 'CRITICAL' : 'SHUTDOWN'; | |
| html += `<br><br><span style="font-size:14px;font-weight:700;color:${statusColor};">` + | |
| `█ ${bt.toFixed(1)}°C — ${statusText}</span>`; | |
| html += `<br><span style="color:#888;font-size:11px;">Cat health: ${(S.catalyst_health*100).toFixed(1)}%</span>`; | |
| // Show quench info for beds 1-3 | |
| if (bi < 3) { | |
| const nextBt = beds[bi+1] || bt; | |
| const quenchDrop = (bt - nextBt).toFixed(1); | |
| html += `<br><span style="color:#dd8844;font-size:11px;">Quench below: ${quenchDrop > 0 ? '-' : '+'}${Math.abs(quenchDrop)}°C cooling</span>`; | |
| } | |
| } | |
| // Inject live data for quench injectors | |
| if (obj.userData.type === 'quench' && obj.userData.quenchIndex !== undefined) { | |
| const qi = obj.userData.quenchIndex; | |
| const beds = S.bed_temps || [S.temperature, S.temperature, S.temperature, S.temperature]; | |
| const above = beds[qi] || S.temperature; | |
| const below = beds[qi+1] || S.temperature; | |
| const drop = (above - below).toFixed(1); | |
| html += `<br><br><span style="color:#dd8844;font-weight:600;">Bed ${qi+1}: ${above.toFixed(1)}°C → Bed ${qi+2}: ${below.toFixed(1)}°C</span>`; | |
| html += `<br><span style="color:#aaa;font-size:11px;">Quench effect: ${drop > 0 ? '-' : '+'}${Math.abs(drop).toFixed(1)}°C</span>`; | |
| } | |
| tooltip.innerHTML = html; | |
| found = true; break; | |
| } | |
| } | |
| if (!found) tooltip.style.display = 'none'; | |
| }); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| RESIZE | |
| ═══════════════════════════════════════════════════════════════ */ | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| STATE → VISUALS MAPPING | |
| ═══════════════════════════════════════════════════════════════ */ | |
| function tempColor(t) { | |
| if (t < 220) return new THREE.Color(0x2196F3); | |
| if (t < 240) return new THREE.Color(0x2196F3).lerp(new THREE.Color(0x4CAF50),(t-220)/20); | |
| if (t < 260) return new THREE.Color(0x4CAF50); | |
| if (t < 280) return new THREE.Color(0x4CAF50).lerp(new THREE.Color(0xFFC107),(t-260)/20); | |
| if (t < 300) return new THREE.Color(0xFFC107).lerp(new THREE.Color(0xF44336),(t-280)/20); | |
| return new THREE.Color(0x9C27B0); | |
| } | |
| function catColor(h) { | |
| if (h > 0.8) return new THREE.Color(0x44aa66); | |
| if (h > 0.5) return new THREE.Color(0xaaaa33); | |
| if (h > 0.2) return new THREE.Color(0xcc6633); | |
| return new THREE.Color(0x884444); | |
| } | |
| function updateVisuals() { | |
| const T = S.temperature, c = tempColor(T), cc = catColor(S.catalyst_health); | |
| M.reactorBody.color.copy(c); M.reactorBody.emissive.copy(c).multiplyScalar(0.12); | |
| // Per-bed coloring based on individual bed temps | |
| const beds = S.bed_temps || [T, T+1, T+2, T+3]; | |
| for (let i = 0; i < 4; i++) { | |
| const bc = tempColor(beds[i]); | |
| M.catBed[i].color.copy(bc); M.catBed[i].emissive.copy(bc).multiplyScalar(0.15); | |
| } | |
| reactorGlow.intensity = 0.5 + Math.max(0, T-200)/80*2; reactorGlow.color.copy(c); | |
| const fuelF = Math.min(1, (S.reformer_outlet_temp||850)/1000); | |
| reformerGlow.intensity = fuelF * 2.5; | |
| M.reformerTube.emissive.setHex(0x331100); M.reformerTube.emissiveIntensity = fuelF * 0.4; | |
| // Flare | |
| const flareOn = (S.flare_valve || 0) > 2; | |
| flameGroup.visible = flareOn; | |
| pilotLight.intensity = flareOn ? 2 : 0.3; | |
| // HUD | |
| const el = id => document.getElementById(id); | |
| const f = (v,d=1) => Number(v).toFixed(d); | |
| const cls = (id, v, lo, hi) => { | |
| const e = el(id); if(!e) return; e.classList.remove('warn','danger','good'); | |
| if (v < lo || v > hi) e.classList.add('danger'); | |
| else if (v > hi*0.9 || v < lo*1.1) e.classList.add('warn'); | |
| else e.classList.add('good'); | |
| }; | |
| el('v-temp').textContent = `${f(T)} °C`; cls('v-temp',T,220,280); | |
| el('v-pressure').textContent = `${f(S.pressure)} bar`; | |
| el('v-rate').textContent = `${f(S.reaction_rate,2)} mol/s`; | |
| el('v-ratio').textContent = f(S.h2_co_ratio,2); cls('v-ratio',S.h2_co_ratio,1.8,2.2); | |
| el('v-h2').textContent = `${f(S.feed_rate_h2)} mol/s`; | |
| el('v-co').textContent = `${f(S.feed_rate_co)} mol/s`; | |
| el('v-profit').textContent = `$${f(S.profit_this_step,2)}`; cls('v-profit',S.profit_this_step,0,999); | |
| el('v-cum-profit').textContent = `$${f(S.cumulative_profit,2)}`; | |
| el('v-meoh').textContent = `${f(S.methanol_produced,1)} kg`; | |
| el('v-co2').textContent = `${f(S.total_co2_emissions||0,1)} kg`; | |
| el('v-step').textContent = `${S.step_number} / ${S.max_steps}`; | |
| el('v-cat').textContent = `${f(S.catalyst_health*100,1)}%`; cls('v-cat',S.catalyst_health,0.5,1.1); | |
| el('v-sel').textContent = `${f((S.selectivity||0.995)*100,2)}%`; | |
| el('v-purity').textContent = `${f((S.product_purity||0.995)*100,2)}%`; | |
| const se = el('v-safety'); | |
| if (S.safety_warning) { se.textContent = S.safety_warning; se.className = 'value '+(T>290?'danger':'warn'); } | |
| else { se.textContent = 'OK'; se.className = 'value good'; } | |
| el('v-task').textContent = S.task_name||'--'; | |
| el('v-cool').textContent = `${f(S.cooling_water_flow)} L/min`; | |
| el('v-comp').textContent = `${f(S.compressor_power||40)} kW`; | |
| el('v-recycle').textContent = f(S.recycle_ratio||3.5,1); | |
| el('v-purge').textContent = `${f(S.purge_rate||0,2)} mol/s`; | |
| el('v-reform-t').textContent = `${f(S.reformer_outlet_temp||850)} °C`; | |
| // Bed temperature bars | |
| for (let i = 0; i < 4; i++) { | |
| const bt = beds[i] || T; | |
| const pct = Math.max(0, Math.min(100, (bt - 150) / 150 * 100)); | |
| const fill = el(`bed${i+1}-fill`); | |
| const val = el(`bed${i+1}-val`); | |
| if (fill) { | |
| fill.style.width = pct + '%'; | |
| if (bt < 240) fill.style.background = '#2196F3'; | |
| else if (bt < 260) fill.style.background = '#4CAF50'; | |
| else if (bt < 280) fill.style.background = '#FFC107'; | |
| else fill.style.background = '#F44336'; | |
| } | |
| if (val) val.textContent = `${f(bt)}°C`; | |
| } | |
| // Chemistry panel — individual reaction rates | |
| const rr = S.reaction_rate || 0; | |
| const r1 = S.rate_R1 || rr * 0.65; | |
| const r2 = S.rate_R2 || rr * 0.25; | |
| const r3 = S.rate_R3 || rr * 0.10; | |
| if (el('v-r1')) el('v-r1').textContent = `${f(r1,3)} mol/s`; | |
| if (el('v-r2')) el('v-r2').textContent = `${f(r2,3)} mol/s`; | |
| if (el('v-r3')) el('v-r3').textContent = `${f(r3,3)} mol/s`; | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| ANIMATION LOOP | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = clock.getDelta(), t = clock.elapsedTime; | |
| ctrl.update(); | |
| // Reactor vibration | |
| const vib = Math.max(0, (S.temperature-240)/200)*0.015; | |
| reactor.position.x = Math.sin(t*4)*vib; | |
| // Flywheel spin | |
| flywheel.rotation.x += dt * 4 * (S.compressor_power||40)/40; | |
| // Steam particles | |
| for (let i = 0; i < steamCount; i++) { | |
| steamLife[i] -= dt; | |
| if (steamLife[i] <= 0) { resetSteam(i); continue; } | |
| steamPos[i*3] += steamVel[i*3]*dt; | |
| steamPos[i*3+1] += steamVel[i*3+1]*dt; | |
| steamPos[i*3+2] += steamVel[i*3+2]*dt; | |
| steamVel[i*3] += (Math.random()-0.5)*dt*0.5; // wind drift | |
| steamVel[i*3+2] += (Math.random()-0.5)*dt*0.5; | |
| } | |
| steamGeo.attributes.position.needsUpdate = true; | |
| // Flow particles | |
| const rate = Math.max(0.3, S.reaction_rate||1); | |
| for (let i = 0; i < flowCount; i++) { | |
| flowT[i] += flowSpd[i] * dt * rate * 0.5; | |
| if (flowT[i] > 1) flowT[i] -= 1; | |
| setFlowPos(i); | |
| } | |
| flowGeo.attributes.position.needsUpdate = true; | |
| // Flare flame animation | |
| if (flameGroup.visible) { | |
| flameCore.scale.set(1+Math.sin(t*12)*0.15, 1+Math.sin(t*8)*0.2, 1+Math.cos(t*10)*0.15); | |
| flameOuter.scale.set(1+Math.sin(t*7)*0.2, 1+Math.cos(t*6)*0.25, 1+Math.sin(t*9)*0.2); | |
| flameGroup.rotation.y = t*2; | |
| } | |
| // Orchestrator hologram rotation + ring pulse (only when visible) | |
| if (holo.visible) { | |
| holo.rotation.y += dt * 0.8; | |
| holo.rotation.x = Math.sin(t * 0.5) * 0.2; | |
| holoRing.scale.setScalar(1 + Math.sin(t * 2) * 0.1); | |
| holoRing.material.opacity = 0.25 + Math.sin(t * 3) * 0.15; | |
| agentLines.forEach((l, i) => { l.material.opacity = 0.3 + Math.sin(t * 2 + i * 2) * 0.2; }); | |
| } | |
| // Demo tick | |
| if (demoMode && t % 1.5 < dt) simulateDemo(); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| /* ═══════════════════════════════════════════════════════════════ | |
| DEMO MODE SIMULATION | |
| ═══════════════════════════════════════════════════════════════ */ | |
| function simulateDemo() { | |
| if (ctrlMode === 'manual') return; // Manual mode — user drives via sliders + Step button | |
| demoStep++; | |
| const s = S, t = demoStep; | |
| if (t < 20) { | |
| // STARTUP: ramp from 150°C to 250°C | |
| s.temperature = 150 + t*5 + (Math.random()-0.5)*2; | |
| s.task_name = 'startup'; s.feed_rate_h2 = Math.min(5,t*0.3); s.feed_rate_co = Math.min(2.5,t*0.15); | |
| s.reaction_rate = Math.max(0,(s.temperature-200)/50*3); s.catalyst_health = 1.0; | |
| s.safety_warning = null; s.flare_valve = 0; | |
| s.cooling_water_flow = 20 + t*1.5; | |
| s.bed_temps = [s.temperature-2, s.temperature, s.temperature+1, s.temperature+2]; | |
| } else if (t < 60) { | |
| // OPTIMIZATION: steady state, maximize profit | |
| s.temperature = 248 + Math.sin(t*0.3)*4 + (Math.random()-0.5)*2; | |
| s.task_name = 'optimization'; s.feed_rate_h2 = 4.5+Math.sin(t*0.2)*0.5; | |
| s.feed_rate_co = 2.2+Math.sin(t*0.15)*0.3; s.reaction_rate = 3.5+Math.sin(t*0.25)*0.5; | |
| s.catalyst_health = Math.max(0.85, 1.0-(t-20)*0.002); | |
| s.safety_warning = null; s.flare_valve = 0; | |
| s.cooling_water_flow = 45 + Math.sin(t*0.1)*5; | |
| // ICI 4-bed quench: each bed ~3°C hotter, quench cools between beds | |
| const baseT = s.temperature; | |
| s.bed_temps = [baseT-3, baseT-1, baseT+1, baseT+3]; | |
| } else if (t < 80) { | |
| // DISTURBANCE: cooling failure at step 25 equivalent — temp rises dangerously | |
| s.temperature = Math.min(295, s.temperature+1.5+Math.random()); | |
| s.task_name = 'disturbance'; s.cooling_water_flow = 15; | |
| s.safety_warning = s.temperature > 270 ? 'HIGH TEMPERATURE' : null; | |
| s.reaction_rate = 4.5-(s.temperature-260)*0.05; | |
| s.catalyst_health = Math.max(0.4, s.catalyst_health-0.008); | |
| s.flare_valve = s.temperature > 285 ? 30 : 0; | |
| // Hot beds diverge — bed 4 hottest (thermal runaway risk) | |
| const baseT = s.temperature; | |
| const spread = (baseT - 250) * 0.3; | |
| s.bed_temps = [baseT-spread, baseT-spread*0.5, baseT+spread*0.5, baseT+spread]; | |
| } else if (t < 100) { | |
| // RECOVERY: agent brings temp back down | |
| s.temperature = Math.max(250, s.temperature-2); | |
| s.task_name = 'recovery'; s.cooling_water_flow = 80; | |
| s.safety_warning = s.temperature > 270 ? 'RECOVERING' : null; | |
| s.catalyst_health = Math.max(0.35, s.catalyst_health-0.001); | |
| s.flare_valve = Math.max(0, s.flare_valve-2); | |
| s.bed_temps = [s.temperature-1, s.temperature, s.temperature+0.5, s.temperature+1]; | |
| } else { | |
| demoStep = 0; s.temperature = 150; s.methanol_produced = 0; | |
| s.cumulative_profit = 0; s.total_co2_emissions = 0; | |
| s.catalyst_health = 1.0; s.safety_warning = null; s.flare_valve = 0; | |
| s.bed_temps = [150, 150, 150, 150]; | |
| } | |
| s.pressure = 50+(s.compressor_power||40)*0.5+(Math.random()-0.5)*2; | |
| s.h2_co_ratio = s.feed_rate_co > 0 ? s.feed_rate_h2/s.feed_rate_co : 2.0; | |
| s.methanol_produced += s.reaction_rate*0.032*60; | |
| s.profit_this_step = s.reaction_rate*0.032*60*0.74 - (s.feed_rate_h2+s.feed_rate_co)*0.002*60; | |
| s.cumulative_profit += s.profit_this_step; | |
| s.total_co2_emissions += s.methanol_produced*0.0006; | |
| s.step_number = t; s.max_steps = 100; | |
| // Reaction rate breakdown (approximate from LHHW kinetics proportions) | |
| const rr = s.reaction_rate; | |
| s.rate_R1 = rr * 0.62 + (Math.random()-0.5)*0.05; | |
| s.rate_R2 = rr * 0.28 + (Math.random()-0.5)*0.03; | |
| s.rate_R3 = rr * 0.10 + (Math.random()-0.5)*0.02; | |
| // Plant stage data | |
| s.desulf_bed_life = Math.max(0.5, 0.95 - t*0.001); | |
| s.reformer_steam_carbon = 3.0 + Math.sin(t*0.1)*0.2; | |
| s.reformer_efficiency = 0.82 + Math.sin(t*0.15)*0.03; | |
| s.product_purity = t < 20 ? 0.98 : 0.995 + Math.sin(t*0.2)*0.002; | |
| s.selectivity = 0.995 - Math.max(0, s.temperature-260)*0.0002; | |
| updateVisuals(); | |
| syncSlidersFromState(); | |
| } | |
| /* ═══════════════════════════════════════════════════════════════ | |
| WEBSOCKET CONNECTION | |
| ═══════════════════════════════════════════════════════════════ */ | |
| window.connectWS = function() { | |
| const url = document.getElementById('ws-url').value.trim(); | |
| document.getElementById('connect-dialog').classList.add('hidden'); | |
| const badge = document.getElementById('status-badge'); | |
| badge.textContent = 'CONNECTING...'; | |
| try { | |
| ws = new WebSocket(url); | |
| ws.onopen = () => { badge.textContent = 'CONNECTED'; badge.classList.add('connected'); | |
| ws.send(JSON.stringify({ type: 'reset', task_name: 'optimization' })); }; | |
| ws.onmessage = (ev) => { try { const d = JSON.parse(ev.data); | |
| if (d.observation) Object.assign(S, d.observation); | |
| else if (d.temperature !== undefined) Object.assign(S, d); | |
| updateVisuals(); } catch(e){} }; | |
| ws.onclose = () => { badge.textContent = 'DISCONNECTED'; badge.classList.remove('connected'); }; | |
| ws.onerror = () => { badge.textContent = 'ERROR'; badge.classList.remove('connected'); }; | |
| } catch(e) { badge.textContent = 'FAILED'; startDemo(); } | |
| }; | |
| window.startDemo = function() { | |
| demoMode = true; | |
| document.getElementById('connect-dialog').classList.add('hidden'); | |
| const badge = document.getElementById('status-badge'); | |
| badge.textContent = 'DEMO MODE'; badge.classList.add('connected'); | |
| updateVisuals(); | |
| }; | |
| /* ═══════════════════════════════════════════════════════════════ | |
| AZURE DIGITAL TWINS LIVE MODE | |
| Polls the ADT proxy endpoint on the FastAPI server every 2s. | |
| The server reads all twins and returns merged plant state. | |
| ═══════════════════════════════════════════════════════════════ */ | |
| let adtInterval = null; | |
| window.connectADT = function() { | |
| document.getElementById('connect-dialog').classList.add('hidden'); | |
| const badge = document.getElementById('status-badge'); | |
| badge.textContent = 'ADT CONNECTING...'; | |
| const baseUrl = window.location.origin || 'http://localhost:7860'; | |
| // First test the endpoint | |
| fetch(baseUrl + '/adt/state').then(r => r.json()).then(data => { | |
| if (data.error) { badge.textContent = 'ADT UNAVAILABLE'; startDemo(); return; } | |
| badge.textContent = 'AZURE DT LIVE'; badge.classList.add('connected'); | |
| badge.style.background = '#0078d4'; // Azure blue | |
| Object.assign(S, data); | |
| updateVisuals(); | |
| // Poll every 2 seconds | |
| if (adtInterval) clearInterval(adtInterval); | |
| adtInterval = setInterval(() => { | |
| fetch(baseUrl + '/adt/state').then(r => r.json()).then(d => { | |
| if (!d.error) { Object.assign(S, d); updateVisuals(); } | |
| }).catch(() => {}); | |
| }, 2000); | |
| }).catch(() => { | |
| badge.textContent = 'ADT FAILED'; startDemo(); | |
| }); | |
| }; | |
| /* ═══════════════════════════════════════════════════════════════ | |
| LIVE CONTROL PANEL LOGIC | |
| ═══════════════════════════════════════════════════════════════ */ | |
| let ctrlMode = 'auto'; // 'auto' = demo drives, 'manual' = user drives | |
| // Toggle collapse | |
| window.toggleCtrlPanel = function() { | |
| const p = document.getElementById('ctrl-panel'); | |
| const t = document.getElementById('ctrl-toggle'); | |
| p.classList.toggle('collapsed'); | |
| const hidden = p.classList.contains('collapsed'); | |
| t.innerHTML = hidden ? '▶' : '◀'; | |
| // Shift left-side panels when control panel hides | |
| const newLeft = hidden ? '20px' : '260px'; | |
| document.getElementById('panel-reactor').style.left = newLeft; | |
| document.getElementById('panel-beds').style.left = newLeft; | |
| document.getElementById('panel-catalyst').style.left = newLeft; | |
| }; | |
| // Mode switch | |
| window.setCtrlMode = function(mode) { | |
| ctrlMode = mode; | |
| document.getElementById('btn-auto').classList.toggle('active', mode === 'auto'); | |
| document.getElementById('btn-manual').classList.toggle('active', mode === 'manual'); | |
| document.getElementById('btn-step').disabled = (mode === 'auto'); | |
| document.getElementById('ctrl-status-text').textContent = | |
| mode === 'auto' ? 'Auto mode \u2014 demo drives controls' : 'Manual mode \u2014 drag sliders, click Step'; | |
| // If switching to auto, start demo if not running | |
| if (mode === 'auto' && !demoMode) { | |
| window.startDemo(); | |
| } | |
| }; | |
| // Read slider values | |
| function getCtrlValues() { | |
| return { | |
| feed_rate_h2: parseFloat(document.getElementById('cr-h2').value), | |
| feed_rate_co: parseFloat(document.getElementById('cr-co').value), | |
| cooling_water_flow: parseFloat(document.getElementById('cr-cool').value), | |
| compressor_power: parseFloat(document.getElementById('cr-comp').value), | |
| purge_valve: parseFloat(document.getElementById('cr-purge').value), | |
| recycle_ratio: parseFloat(document.getElementById('cr-recycle').value), | |
| }; | |
| } | |
| // Update slider value labels on input | |
| window.onCtrl = function() { | |
| const cv = getCtrlValues(); | |
| document.getElementById('cv-h2').textContent = cv.feed_rate_h2.toFixed(1) + ' mol/s'; | |
| document.getElementById('cv-co').textContent = cv.feed_rate_co.toFixed(1) + ' mol/s'; | |
| document.getElementById('cv-cool').textContent = cv.cooling_water_flow.toFixed(0) + ' L/min'; | |
| document.getElementById('cv-comp').textContent = cv.compressor_power.toFixed(0) + ' kW'; | |
| document.getElementById('cv-purge').textContent = cv.purge_valve.toFixed(0) + '%'; | |
| document.getElementById('cv-recycle').textContent = cv.recycle_ratio.toFixed(1); | |
| // In manual mode, immediately apply to reactor state (no step needed for visual feedback) | |
| if (ctrlMode === 'manual') { | |
| S.feed_rate_h2 = cv.feed_rate_h2; | |
| S.feed_rate_co = cv.feed_rate_co; | |
| S.cooling_water_flow = cv.cooling_water_flow; | |
| S.compressor_power = cv.compressor_power; | |
| S.flare_valve = cv.purge_valve; | |
| S.recycle_ratio = cv.recycle_ratio; | |
| updateVisuals(); | |
| } | |
| }; | |
| // Sync sliders from state (when auto-demo updates values) | |
| function syncSlidersFromState() { | |
| if (ctrlMode !== 'auto') return; | |
| const setSlider = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; | |
| setSlider('cr-h2', S.feed_rate_h2); | |
| setSlider('cr-co', S.feed_rate_co); | |
| setSlider('cr-cool', S.cooling_water_flow); | |
| setSlider('cr-comp', S.compressor_power || 40); | |
| setSlider('cr-purge', S.flare_valve || 0); | |
| setSlider('cr-recycle', S.recycle_ratio || 3.5); | |
| // Update labels | |
| document.getElementById('cv-h2').textContent = Number(S.feed_rate_h2).toFixed(1) + ' mol/s'; | |
| document.getElementById('cv-co').textContent = Number(S.feed_rate_co).toFixed(1) + ' mol/s'; | |
| document.getElementById('cv-cool').textContent = Number(S.cooling_water_flow).toFixed(0) + ' L/min'; | |
| document.getElementById('cv-comp').textContent = Number(S.compressor_power||40).toFixed(0) + ' kW'; | |
| document.getElementById('cv-purge').textContent = Number(S.flare_valve||0).toFixed(0) + '%'; | |
| document.getElementById('cv-recycle').textContent = Number(S.recycle_ratio||3.5).toFixed(1); | |
| } | |
| // Manual step — simplified physics simulation | |
| window.manualStep = function() { | |
| if (ctrlMode !== 'manual') return; | |
| if (!demoMode) { demoMode = true; document.getElementById('status-badge').textContent = 'MANUAL'; document.getElementById('status-badge').classList.add('connected'); } | |
| document.getElementById('connect-dialog').classList.add('hidden'); | |
| const cv = getCtrlValues(); | |
| S.step_number++; | |
| S.feed_rate_h2 = cv.feed_rate_h2; | |
| S.feed_rate_co = cv.feed_rate_co; | |
| S.cooling_water_flow = cv.cooling_water_flow; | |
| S.compressor_power = cv.compressor_power; | |
| S.recycle_ratio = cv.recycle_ratio; | |
| S.flare_valve = cv.purge_valve; | |
| // Simplified physics: temperature change | |
| const feedHeat = (cv.feed_rate_h2 + cv.feed_rate_co) * 3.5; // heat from reaction | |
| const coolingEffect = cv.cooling_water_flow / 100 * Math.max(0, S.temperature - 35) * 0.08; | |
| const recycleBoost = Math.max(0, cv.recycle_ratio - 2) * 0.5; // more recycle = more conversion = more heat | |
| const noise = (Math.random() - 0.5) * 1.5; | |
| S.temperature = Math.max(100, Math.min(310, S.temperature + feedHeat * 0.3 - coolingEffect + recycleBoost * 0.3 + noise)); | |
| // Pressure from compressor | |
| S.pressure = 50 + cv.compressor_power * 0.5 + (Math.random() - 0.5) * 2; | |
| // Reaction rate: depends on temp (bell curve around 250) × feed × catalyst | |
| const tempFactor = Math.exp(-0.005 * Math.pow(S.temperature - 255, 2)); // bell curve | |
| const feedFactor = Math.min(cv.feed_rate_h2 / 4, cv.feed_rate_co / 2); // limiting reagent | |
| const pressureFactor = Math.min(1, S.pressure / 70); | |
| S.reaction_rate = Math.max(0, 4.5 * tempFactor * feedFactor * pressureFactor * S.catalyst_health + (Math.random()-0.5)*0.1); | |
| // Catalyst deactivation accelerated above 270°C | |
| if (S.temperature > 270) S.catalyst_health = Math.max(0.1, S.catalyst_health - 0.003 * (S.temperature - 270) / 30); | |
| else S.catalyst_health = Math.max(0.1, S.catalyst_health - 0.0002); | |
| // H2/CO ratio | |
| S.h2_co_ratio = cv.feed_rate_co > 0.01 ? cv.feed_rate_h2 / cv.feed_rate_co : 0; | |
| // Bed temperatures (gradient across 4 beds) | |
| const spread = Math.max(0, (S.temperature - 240)) * 0.25; | |
| S.bed_temps = [S.temperature - spread, S.temperature - spread * 0.3, S.temperature + spread * 0.3, S.temperature + spread]; | |
| // Reaction rate breakdown | |
| S.rate_R1 = S.reaction_rate * 0.62 + (Math.random()-0.5)*0.03; | |
| S.rate_R2 = S.reaction_rate * 0.28 + (Math.random()-0.5)*0.02; | |
| S.rate_R3 = S.reaction_rate * 0.10 + (Math.random()-0.5)*0.01; | |
| // Economics | |
| S.methanol_produced += S.reaction_rate * 0.032 * 60; | |
| S.profit_this_step = S.reaction_rate * 0.032 * 60 * 0.74 - (cv.feed_rate_h2 + cv.feed_rate_co) * 0.002 * 60; | |
| S.cumulative_profit += S.profit_this_step; | |
| S.total_co2_emissions += S.methanol_produced * 0.0006; | |
| // Safety | |
| S.safety_warning = S.temperature > 290 ? 'EMERGENCY!' : S.temperature > 270 ? 'HIGH TEMP' : null; | |
| S.task_name = 'manual_control'; | |
| S.max_steps = 999; | |
| // Plant stages | |
| S.selectivity = 0.995 - Math.max(0, S.temperature - 260) * 0.0003; | |
| S.product_purity = S.reaction_rate > 1 ? 0.995 + (Math.random()-0.5)*0.002 : 0.98; | |
| updateVisuals(); | |
| }; | |
| /* ═══════════════════════════════════════════════════════════════ | |
| CAMERA PRESETS | |
| ═══════════════════════════════════════════════════════════════ */ | |
| const CAM_PRESETS = { | |
| overview: { pos: [30, 25, 35], target: [0, 6, 0] }, | |
| desulf: { pos: [-25, 8, 8], target: [-28, 3, 0] }, | |
| reformer: { pos: [-12, 10, 10], target: [-18, 5, 0] }, | |
| reactor: { pos: [8, 12, 10], target: [0, 8, 0] }, | |
| separator: { pos: [10, 6, 12], target: [5, 2, 4] }, | |
| distillation: { pos: [20, 14, 8], target: [14, 8, 0] }, | |
| storage: { pos: [26, 6, 12], target: [21, 3, 6] }, | |
| control: { pos: [-4, 6, -8], target: [-8, 2, -12] }, | |
| }; | |
| window.setCam = function(preset) { | |
| const p = CAM_PRESETS[preset]; | |
| if (!p) return; | |
| // Smooth animation | |
| const startPos = camera.position.clone(); | |
| const startTarget = ctrl.target.clone(); | |
| const endPos = new THREE.Vector3(...p.pos); | |
| const endTarget = new THREE.Vector3(...p.target); | |
| const duration = 1000; // ms | |
| const startTime = performance.now(); | |
| function animateCam(now) { | |
| const elapsed = now - startTime; | |
| const t = Math.min(1, elapsed / duration); | |
| const ease = t < 0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2; // easeInOutCubic | |
| camera.position.lerpVectors(startPos, endPos, ease); | |
| ctrl.target.lerpVectors(startTarget, endTarget, ease); | |
| ctrl.update(); | |
| if (t < 1) requestAnimationFrame(animateCam); | |
| } | |
| requestAnimationFrame(animateCam); | |
| // Update active button | |
| document.querySelectorAll('#cam-presets button').forEach(b => b.classList.remove('active')); | |
| event?.target?.classList?.add('active'); | |
| }; | |
| updateVisuals(); | |
| // ── API Integration: Reset & Step ────────────────────────── | |
| let apiConnected = false; | |
| let apiBase = window.location.origin; | |
| window.apiReset = async function() { | |
| const task = document.getElementById('task-select').value; | |
| const badge = document.getElementById('status-badge'); | |
| const btnStep = document.getElementById('btn-api-step'); | |
| badge.textContent = 'RESETTING...'; badge.className = ''; | |
| try { | |
| const r = await fetch(apiBase + '/reset', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({task_name: task}) | |
| }); | |
| const data = await r.json(); | |
| const obs = data.observation || data; | |
| apiConnected = true; | |
| btnStep.disabled = false; | |
| badge.textContent = 'API CONNECTED'; badge.classList.add('connected'); | |
| // Store obs for model step | |
| lastObsDict = obs; | |
| // Update state from API response | |
| applyObservation(obs, data.reward || 0); | |
| document.getElementById('v-task').textContent = task; | |
| document.getElementById('ctrl-status-text').textContent = | |
| currentMode === 'testing' ? 'Testing mode: click Auto Run' : 'API mode: select task, step through episodes'; | |
| // Enable auto-run in testing mode | |
| if (currentMode === 'testing') { | |
| document.getElementById('btn-auto-run').disabled = false; | |
| } | |
| } catch(e) { | |
| badge.textContent = 'API ERROR'; badge.className = ''; | |
| console.error('Reset failed:', e); | |
| } | |
| }; | |
| window.apiStep = async function() { | |
| if (!apiConnected) return; | |
| const badge = document.getElementById('status-badge'); | |
| const cv = getCtrlValues(); | |
| const action = { | |
| feed_rate_h2: cv.feed_rate_h2, | |
| feed_rate_co: cv.feed_rate_co, | |
| cooling_water_flow: cv.cooling_water_flow, | |
| compressor_power: cv.compressor_power, | |
| purge_valve_position: cv.purge_valve, | |
| recycle_ratio: cv.recycle_ratio, | |
| feed_preheat_temp: 200, | |
| reformer_fuel_gas: 5, | |
| reformer_steam_flow: 15, | |
| distillation_reflux: 3, | |
| reboiler_duty: 50, | |
| flare_valve: 0, | |
| }; | |
| try { | |
| const r = await fetch(apiBase + '/step', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({action: action}) | |
| }); | |
| const data = await r.json(); | |
| const obs = data.observation || data; | |
| const reward = data.reward != null ? data.reward : (obs.reward || 0); | |
| applyObservation(obs, reward); | |
| if (obs.done) { | |
| badge.textContent = 'EPISODE DONE'; badge.className = ''; | |
| document.getElementById('btn-api-step').disabled = true; | |
| } | |
| } catch(e) { | |
| console.error('Step failed:', e); | |
| } | |
| }; | |
| function applyObservation(obs, reward) { | |
| // Update reactor state object S | |
| if (obs.temperature != null) S.temperature = obs.temperature; | |
| if (obs.pressure != null) S.pressure = obs.pressure; | |
| if (obs.reaction_rate != null) S.reaction_rate = obs.reaction_rate; | |
| if (obs.h2_co_ratio != null) S.h2_co_ratio = obs.h2_co_ratio; | |
| if (obs.feed_rate_h2 != null) S.feed_rate_h2 = obs.feed_rate_h2; | |
| if (obs.feed_rate_co != null) S.feed_rate_co = obs.feed_rate_co; | |
| if (obs.catalyst_health != null) S.catalyst_health = obs.catalyst_health; | |
| if (obs.methanol_produced != null) S.methanol_produced = obs.methanol_produced; | |
| if (obs.cumulative_profit != null) S.cumulative_profit = obs.cumulative_profit; | |
| if (obs.cooling_water_flow != null) S.cooling_water_flow = obs.cooling_water_flow; | |
| if (obs.product_purity != null) S.product_purity = obs.product_purity; | |
| if (obs.recycle_ratio != null) S.recycle_ratio = obs.recycle_ratio; | |
| if (obs.profit_this_step != null) S.profit_this_step = obs.profit_this_step; | |
| if (obs.selectivity != null) S.selectivity = obs.selectivity; | |
| if (obs.step_number != null) S.step_number = obs.step_number; | |
| if (obs.max_steps != null) S.max_steps = obs.max_steps; | |
| if (obs.total_co2_emissions != null) S.total_co2_emissions = obs.total_co2_emissions; | |
| if (obs.safety_warning) S.safety_warning = obs.safety_warning; | |
| // Update reward display in title bar | |
| const rewardEl = document.getElementById('v-reward'); | |
| rewardEl.textContent = reward.toFixed(4); | |
| if (reward > 0.7) { rewardEl.style.color = '#00d4aa'; } | |
| else if (reward > 0.4) { rewardEl.style.color = '#ffc107'; } | |
| else { rewardEl.style.color = '#ff4444'; } | |
| // Update step counter | |
| document.getElementById('v-step-num').textContent = | |
| (obs.step_number != null ? obs.step_number : '--') + ' / ' + (obs.max_steps || '--'); | |
| // Sync sliders | |
| syncSlidersFromState(); | |
| window.onCtrl(); | |
| // Update all visuals | |
| updateVisuals(); | |
| } | |
| // ── Mode switching: Training vs Testing ── | |
| let currentMode = 'training'; | |
| let autoRunInterval = null; | |
| let lastObsDict = {}; | |
| window.onModeChange = function() { | |
| currentMode = document.getElementById('mode-select').value; | |
| const modelSelect = document.getElementById('model-select'); | |
| const autoBtn = document.getElementById('btn-auto-run'); | |
| const modelLabel = document.getElementById('model-label'); | |
| const modelNameDisplay = document.getElementById('model-name-display'); | |
| const resetBtn = document.getElementById('btn-api-reset'); | |
| const stepBtn = document.getElementById('btn-api-step'); | |
| if (currentMode === 'testing') { | |
| modelSelect.style.display = 'inline-block'; | |
| autoBtn.style.display = 'inline-block'; | |
| modelLabel.style.display = 'inline'; | |
| modelNameDisplay.style.display = 'inline'; | |
| resetBtn.style.display = 'none'; | |
| stepBtn.style.display = 'none'; | |
| document.getElementById('ctrl-status-text').textContent = 'Testing mode: select model, click Auto Run'; | |
| } else { | |
| modelSelect.style.display = 'none'; | |
| autoBtn.style.display = 'none'; | |
| modelLabel.style.display = 'none'; | |
| modelNameDisplay.style.display = 'none'; | |
| modelNameDisplay.textContent = ''; | |
| resetBtn.style.display = 'inline-block'; | |
| stepBtn.style.display = 'inline-block'; | |
| if (autoRunInterval) { clearInterval(autoRunInterval); autoRunInterval = null; autoBtn.textContent = 'Auto Run'; } | |
| document.getElementById('ctrl-status-text').textContent = 'Training mode: manual slider control'; | |
| } | |
| }; | |
| window.currentStep = function() { | |
| if (currentMode === 'testing') { | |
| modelStep(); | |
| } else { | |
| apiStep(); | |
| } | |
| }; | |
| window.modelStep = async function() { | |
| if (!apiConnected) return; | |
| const badge = document.getElementById('status-badge'); | |
| const modelKey = document.getElementById('model-select').value; | |
| badge.textContent = 'MODEL THINKING...'; badge.className = ''; | |
| try { | |
| // Ask model for action | |
| const mr = await fetch(apiBase + '/model/step', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ model: modelKey, observation: lastObsDict }) | |
| }); | |
| const mdata = await mr.json(); | |
| if (mdata.error) { | |
| badge.textContent = 'MODEL ERROR'; badge.className = ''; | |
| console.error('Model error:', mdata.error); | |
| return; | |
| } | |
| // Use model's action to step the env | |
| const action = mdata.action; | |
| // Fill in defaults for missing fields | |
| const fullAction = { | |
| feed_rate_h2: action.feed_rate_h2 || 3.0, | |
| feed_rate_co: action.feed_rate_co || 1.5, | |
| cooling_water_flow: action.cooling_water_flow || 50.0, | |
| compressor_power: action.compressor_power || 50.0, | |
| purge_valve_position: action.purge_valve_position || 2.0, | |
| recycle_ratio: action.recycle_ratio || 3.5, | |
| feed_preheat_temp: action.feed_preheat_temp || 200.0, | |
| reformer_fuel_gas: action.reformer_fuel_gas || 5.0, | |
| reformer_steam_flow: action.reformer_steam_flow || 15.0, | |
| distillation_reflux: action.distillation_reflux || 3.0, | |
| reboiler_duty: action.reboiler_duty || 50.0, | |
| flare_valve: action.flare_valve || 0.0, | |
| }; | |
| const r = await fetch(apiBase + '/step', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({action: fullAction}) | |
| }); | |
| const data = await r.json(); | |
| const obs = data.observation || data; | |
| const reward = data.reward != null ? data.reward : (obs.reward || 0); | |
| lastObsDict = obs; | |
| applyObservation(obs, reward); | |
| // Update model name display | |
| document.getElementById('model-name-display').textContent = mdata.model || modelKey; | |
| badge.textContent = 'MODEL CONNECTED'; badge.classList.add('connected'); | |
| if (obs.done) { | |
| badge.textContent = 'EPISODE DONE'; badge.className = ''; | |
| document.getElementById('btn-api-step').disabled = true; | |
| document.getElementById('btn-auto-run').disabled = true; | |
| if (autoRunInterval) { clearInterval(autoRunInterval); autoRunInterval = null; } | |
| } | |
| } catch(e) { | |
| badge.textContent = 'MODEL ERROR'; badge.className = ''; | |
| console.error('Model step failed:', e); | |
| } | |
| }; | |
| window.toggleAutoRun = async function() { | |
| const btn = document.getElementById('btn-auto-run'); | |
| if (autoRunInterval) { | |
| clearInterval(autoRunInterval); | |
| autoRunInterval = null; | |
| btn.textContent = 'Auto Run'; | |
| btn.style.background = 'rgba(156,39,176,0.2)'; | |
| } else { | |
| // Auto-reset if not connected | |
| if (!apiConnected) { | |
| await apiReset(); | |
| } | |
| if (!apiConnected) return; // reset failed | |
| btn.textContent = 'Stop'; | |
| btn.style.background = 'rgba(244,67,54,0.2)'; | |
| // Run first step immediately | |
| await modelStep(); | |
| autoRunInterval = setInterval(async () => { | |
| if (!apiConnected) { clearInterval(autoRunInterval); autoRunInterval = null; btn.textContent = 'Auto Run'; btn.style.background = 'rgba(156,39,176,0.2)'; return; } | |
| await modelStep(); | |
| }, 2500); | |
| } | |
| }; | |
| // Override apiReset to also store obs for model | |
| const _origApiReset = window.apiReset; | |
| window.apiReset = async function() { | |
| await _origApiReset(); | |
| // After reset, enable auto-run button in testing mode | |
| if (currentMode === 'testing') { | |
| document.getElementById('btn-auto-run').disabled = false; | |
| } | |
| }; | |
| </script> | |
| </body> | |
| </html> | |