(() => { 'use strict'; const data = window.DENIM_DEMO; if (!data) throw new Error('DENIM demo data not loaded'); const $ = id => document.getElementById(id); const state = { trajectory: 6, step: 120, playing: false, pairToggle: 0, visible: { truth: true, denim: true, gru: false } }; const colors = { truth: '#f2f0ea', denim: '#d38a51', gru: '#6d91e8', cyan: '#72d6d1' }; let timer = null; function trajectory() { return data.trajectories[state.trajectory]; } function extent(values) { let min = Math.min(...values), max = Math.max(...values); if (Math.abs(max - min) < 1e-9) { min -= 1; max += 1; } const pad = (max - min) * .1; return [min - pad, max + pad]; } const map = (value, a, b, c, d) => c + (value - a) * (d - c) / (b - a); const pathString = points => points.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' '); const svgElement = (name, attrs = {}) => { const element = document.createElementNS('http://www.w3.org/2000/svg', name); for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, value); return element; }; function append(parent, name, attrs = {}, text = '') { const element = svgElement(name, attrs); if (text) element.textContent = text; parent.append(element); return element; } function buildPathList() { const list = $('pathList'); list.innerHTML = ''; data.trajectories.forEach((item, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = `path-button${index === state.trajectory ? ' active' : ''}`; button.innerHTML = `${String(index + 1).padStart(2, '0')}${item.label}${item.scope}${item.split === 'validation' ? 'VAL' : item.split.toUpperCase()}`; button.onclick = () => { // Keep the user's transport state; the existing timer reads the current trajectory. if (state.trajectory === index) return; state.trajectory = index; state.step = 0; state.pairToggle = 0; $('timeline').value = 0; buildPathList(); render(); }; list.append(button); }); } function chartFrame(svg, xValues, yValues, labels) { const width = Math.max(280, svg.clientWidth || 700); const height = Math.max(160, svg.clientHeight || 430); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.innerHTML = ''; const margin = { left: 58, right: 20, top: 18, bottom: 43 }; const [xmin, xmax] = extent(xValues), [ymin, ymax] = extent(yValues); const sx = value => map(value, xmin, xmax, margin.left, width - margin.right); const sy = value => map(value, ymin, ymax, height - margin.bottom, margin.top); for (let i = 0; i <= 5; i++) { const x = map(i, 0, 5, margin.left, width - margin.right); const y = map(i, 0, 5, margin.top, height - margin.bottom); append(svg, 'line', { x1: x, x2: x, y1: margin.top, y2: height - margin.bottom, class: 'grid' }); append(svg, 'line', { x1: margin.left, x2: width - margin.right, y1: y, y2: y, class: 'grid' }); append(svg, 'text', { x, y: height - 21, 'text-anchor': 'middle' }, (xmin + i * (xmax - xmin) / 5).toFixed(2)); append(svg, 'text', { x: margin.left - 9, y: y + 3, 'text-anchor': 'end' }, (ymax - i * (ymax - ymin) / 5).toFixed(Math.max(Math.abs(ymin), Math.abs(ymax)) < 2 ? 2 : 0)); } append(svg, 'line', { x1: margin.left, x2: width - margin.right, y1: height - margin.bottom, y2: height - margin.bottom, class: 'axis' }); append(svg, 'line', { x1: margin.left, x2: margin.left, y1: margin.top, y2: height - margin.bottom, class: 'axis' }); append(svg, 'text', { x: (margin.left + width - margin.right) / 2, y: height - 4, 'text-anchor': 'middle' }, labels.x); append(svg, 'text', { x: 13, y: (margin.top + height - margin.bottom) / 2, transform: `rotate(-90 13 ${(margin.top + height - margin.bottom) / 2})`, 'text-anchor': 'middle' }, labels.y); return { sx, sy, width, height, margin }; } function drawHysteresis() { const item = trajectory(), svg = $('hysteresisChart'); const allStress = [...item.truth_mpa, ...item.denim_mpa, ...item.gru_mpa]; const frame = chartFrame(svg, item.strain_pct, allStress, { x: `应变 ${item.primary} / %`, y: `应力 ${item.primary} / MPa` }); const definitions = [ ['truth', item.truth_mpa], ['denim', item.denim_mpa], ['gru', item.gru_mpa] ]; for (const [name, values] of definitions) { if (!state.visible[name]) continue; const points = item.strain_pct.map((x, i) => [frame.sx(x), frame.sy(values[i])]); append(svg, 'path', { d: pathString(points), class: `series ${name}-path future` }); append(svg, 'path', { d: pathString(points.slice(0, state.step + 1)), class: `series ${name}-path` }); append(svg, 'circle', { cx: points[state.step][0], cy: points[state.step][1], r: name === 'denim' ? 4.5 : 3.5, class: 'marker', stroke: colors[name] }); } const pair = item.memory_pair; pair.forEach((index, n) => { const x = frame.sx(item.strain_pct[index]); const y = frame.sy(item.truth_mpa[index]); append(svg, 'circle', { cx: x, cy: y, r: 7, fill: 'none', stroke: n ? colors.denim : colors.cyan, 'stroke-width': 1.2, opacity: .7 }); }); } function drawPathPlane() { const item = trajectory(), svg = $('pathChart'); const frame = chartFrame(svg, item.path_x_pct, item.path_y_pct, { x: `${item.primary} / %`, y: `${item.secondary} / %` }); const points = item.path_x_pct.map((x, i) => [frame.sx(x), frame.sy(item.path_y_pct[i])]); append(svg, 'path', { d: pathString(points), class: 'series path-plane future' }); append(svg, 'path', { d: pathString(points.slice(0, state.step + 1)), class: 'series path-plane' }); append(svg, 'circle', { cx: points[state.step][0], cy: points[state.step][1], r: 4.5, class: 'marker', stroke: colors.cyan }); } function updateState() { const item = trajectory(), i = state.step; $('stepLabel').textContent = `${i + 1} / ${item.strain_pct.length}`; $('timeline').max = item.strain_pct.length - 1; $('timeline').value = i; $('pathName').textContent = item.label; $('pathScope').textContent = item.scope; $('component').textContent = `${item.primary.toUpperCase()} / ${item.secondary.toUpperCase()}`; $('currentError').textContent = `${item.step_error_mpa[i].toFixed(3)} MPa`; $('yieldResidual').textContent = `${item.yield_residual_pa[i].toFixed(1)} Pa`; $('stressNow').textContent = item.denim_mpa[i].toFixed(1); $('memoryOne').textContent = `${item.memory_1_mpa[i].toFixed(1)} MPa`; $('memoryTwo').textContent = `${item.memory_2_mpa[i].toFixed(1)} MPa`; $('isotropicNow').textContent = `${item.isotropic_mpa[i].toFixed(1)} MPa`; $('peeqNow').textContent = item.peeq[i].toFixed(5); $('pathAxes').textContent = `${item.primary.toUpperCase()}—${item.secondary.toUpperCase()} 应变平面`; const plastic = i > 0 && item.peeq[i] - item.peeq[i - 1] > 1e-10; $('stateMode').textContent = plastic ? '塑性演化' : '弹性响应'; document.querySelector('.state-dot i').style.background = plastic ? 'var(--copper)' : 'var(--green)'; const maxA = Math.max(...item.memory_1_mpa, 1), maxB = Math.max(...item.memory_2_mpa, 1); $('memoryA').style.transform = `rotate(${360 * item.memory_1_mpa[i] / maxA - 25}deg)`; $('memoryB').style.transform = `rotate(${-320 * item.memory_2_mpa[i] / maxB + 45}deg)`; const pair = item.memory_pair; const closeToPair = Math.abs(i - pair[0]) < 2 || Math.abs(i - pair[1]) < 2; $('historyBadge').textContent = closeToPair ? '同应变 · 异历史' : (plastic ? '塑性状态演化' : '历史状态保持'); } function render() { updateState(); drawHysteresis(); drawPathPlane(); } function stopPlayback() { state.playing = false; clearInterval(timer); $('play').textContent = '播放路径'; $('play').setAttribute('aria-pressed', 'false'); } function togglePlayback() { state.playing = !state.playing; $('play').textContent = state.playing ? '暂停播放' : '播放路径'; $('play').setAttribute('aria-pressed', String(state.playing)); clearInterval(timer); if (state.playing) timer = setInterval(() => { const count = trajectory().strain_pct.length; state.step = (state.step + 2) % count; render(); }, 55); } function buildLadder() { const full = data.summary.full_physics_path_ood; const closure = data.summary.test; const rows = [ ['Pointwise MLP', 'full', full.pointwise_mlp.rmse_mpa, 'blackbox'], ['GRU', 'full', full.gru.rmse_mpa, 'blackbox'], ['LSTM', 'full', full.lstm.rmse_mpa, 'blackbox'], ['Causal TCN', 'full', full.causal_tcn.rmse_mpa, 'blackbox'], ['Physics-state GRU', 'full', full.physics_state_gru.rmse_mpa, 'blackbox'], ['Physics-integrator NN', 'full', full.physics_integrator_nn.rmse_mpa, 'physics'], ['Incomplete J2', 'closure', closure.incomplete_j2.rmse_mpa, 'closure'], ['GRU', 'closure', closure.gru.rmse_mpa, 'closure'], ['DENIM', 'closure', closure.denrm.rmse_mpa, 'denim'], ].sort((a, b) => (a[1] === b[1] ? a[2]-b[2] : a[1]==='closure' ? -1 : 1)); const ladder = $('ladder'); ladder.innerHTML = ''; const maxValue = Math.max(...rows.map(row => row[2])) * 1.04; const denimValue = closure.denrm.rmse_mpa; let groupIndex = 0; rows.forEach((row, index) => { if (index === 0 || rows[index - 1][1] !== row[1]) groupIndex = 0; groupIndex++; if (index === 0 || rows[index - 1][1] !== row[1]) { const heading = document.createElement('div'); heading.className = 'protocol-heading'; heading.innerHTML = row[1] === 'closure' ? 'B · 未知硬化闭合隐藏三通道 Chaboche 硬化;DENIM、Incomplete J2 与 GRU 按同一协议比较。' : 'A · 已知方程基准完整 J2 / Chaboche 方程与参数已知;比较模型对未见加载路径的预测。'; ladder.append(heading); } const [name, protocol, value, kind] = row; const line = document.createElement('div'); line.className = `ladder-group${name === 'DENIM' ? ' featured' : ''}`; const width = 100 * value / maxValue; let protocolName = protocol === 'full' ? '协议 A · 已知方程 · 新路径' : '协议 B · 未知硬化 · 新路径'; if (name === 'Physics-integrator NN') protocolName = '协议 A · 完整方程积分'; const ratio = value / denimValue; const comparison = protocol === 'full' ? '仅在协议 A 内比较' : name === 'DENIM' ? '协议 B 基准' : (ratio >= 1 ? `误差比 DENIM 高 ${ratio.toFixed(ratio >= 10 ? 0 : 1)}×` : `误差比 DENIM 低 ${(1 / ratio).toFixed(0)}×`); line.innerHTML = `
${String(groupIndex).padStart(2, '0')}
${name}
${protocolName}
${value < .1 ? value.toFixed(4) : value.toFixed(3)} MPa${comparison}
`; ladder.append(line); }); } const modelAtlas = [ { name: 'Pointwise MLP', short: '单点映射', family: '数据驱动 · 无记忆', badge: '当前量 → 当前量', tagline: '只看当前应变,不读取加载历史。', nodes: [['输入', '当前应变 εₜ', '单个时刻'], ['神经映射', 'MLP', '黑箱回归', 'learned'], ['输出', '当前应力 σₜ', '无内部状态']], summary: '结构最简单、推理很快,但同一应变在不同加载历史下可能对应不同应力,单点映射无法区分。', traits: ['无', '无', '预测全部应力', '弱'] }, { name: 'GRU', short: '时序黑箱', family: '数据驱动 · 隐式记忆', badge: '历史序列 → 应力', tagline: '从应变序列中压缩出隐藏历史状态。', nodes: [['输入', '应变历史 ε₀:ₜ', '完整序列'], ['序列编码', 'GRU', '隐状态记忆', 'learned'], ['输出', '应力序列 σ₀:ₜ', '端到端预测']], summary: '能够识别滞回和反向加载,但记忆完全藏在网络隐状态中;路径分布变化时,外推稳定性受训练数据覆盖范围影响。', traits: ['隐状态', '无', '学习全部演化', '中—弱'] }, { name: 'Physics-state GRU', short: '弱物理时序', family: '物理增强 · 隐状态', badge: '物理特征 + 时序网络', tagline: '把物理状态作为特征交给循环网络。', nodes: [['输入', '应变 + 物理特征', '状态提示', 'physics'], ['状态更新', 'Physics-state GRU', '学习时序演化', 'learned'], ['输出', '应力 + 隐状态', '弱物理约束']], summary: '物理特征改善了网络的输入表达,但积分更新仍主要由黑箱时序网络承担,守恒与一致性通常需要额外约束。', traits: ['隐状态', '物理特征', '学习状态更新', '中'] }, { name: 'Physics-integrator NN', short: '神经辅助积分器', family: '已知方程 · 神经辅助积分', badge: '完整本构方程已知', tagline: '网络修正塑性增量初值,完整方程完成一致性校正。', nodes: [['输入', 'Δε + zₙ', '增量与状态'], ['力学初值', 'Δλ seed', '解析近似', 'physics'], ['神经修正', 'NN correction', '改善初值', 'learned'], ['方程校正', '返回映射', '屈服一致性', 'physics'], ['输出', 'σₙ₊₁ + zₙ₊₁', '高精度']], summary: '网络不学习新的材料规律;J2 / Chaboche 方程、参数和内部变量均已知。它只帮助求解塑性增量,并由完整方程完成最终校正。', traits: ['显式内部变量', '完整方程', '修正塑性增量初值', '强'] }, { name: 'Incomplete J2', short: '不完备基线', family: '不完备物理 · 无神经闭合', badge: '骨架正确 · 演化缺失', tagline: '保留基本塑性框架,但硬化演化表达不足。', nodes: [['输入', 'Δε + zₙ', '增量与状态'], ['力学骨架', '弹性 + J2 屈服', '基本约束', 'physics'], ['缺失环节', '不完备硬化', '系统偏差'], ['输出', 'σₙ₊₁', '误差累积']], summary: '物理骨架提供基本合理性,但缺少真实材料的复杂记忆与硬化通道,循环和非比例路径下会产生持续误差。', traits: ['有限', '基础骨架', '无', '中—弱'] }, { name: 'DENIM', short: '离散能量神经内部变量模型', family: '不完备物理 · 神经闭合', badge: '力学骨架 + 可学习演化', tagline: '保留可验证力学积分,以神经内部变量补全未知演化。', nodes: [['输入', 'Δε + zₙ', '增量与历史状态'], ['力学骨架', '弹性 · J2 · 流动法则', '显式约束', 'physics'], ['神经闭合', '快—慢记忆通道', '未知硬化演化', 'learned memory'], ['隐式积分', '返回映射', '一致性求解', 'physics'], ['输出', 'σₙ₊₁ + zₙ₊₁', '可追踪状态']], summary: '网络不替代整套本构方程,只学习物理骨架未描述的演化项;因此同时保留路径记忆、求解约束和内部状态可解释性。', traits: ['显式神经内部变量', '核心骨架', '闭合未知演化', '强'] }, { name: 'Causal TCN', short: '因果时序卷积', family: '数据驱动 · 有限历史窗口', badge: '只读当前与过去', tagline: '通过多层因果卷积,提取不同时间尺度的加载历史。', summary: '膨胀卷积扩大历史感受野,不读取未来输入;记忆由有限历史窗口提供,而非循环隐状态。', traits: ['历史感受野', '无', '预测全部应力', '依赖训练覆盖'] } ]; function buildModelSelector() { const selector = $('modelSelector'); selector.innerHTML = ''; [0, 1, 6, 2, 3, 4, 5].forEach(index => { const model = modelAtlas[index]; const button = document.createElement('button'); button.type = 'button'; button.dataset.modelIndex = index; button.className = `model-select${index === 5 ? ' active' : ''}`; button.setAttribute('aria-pressed', String(index === 5)); button.innerHTML = `${model.name}${model.short}`; button.onclick = () => renderArchitecture(index); selector.append(button); }); } function renderArchitecture(index) { const model = modelAtlas[index]; document.querySelectorAll('.model-select').forEach((button, buttonIndex) => { const active = Number(button.dataset.modelIndex) === index; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); }); $('architectureFamily').textContent = model.family; $('architectureName').textContent = model.name; $('architectureTagline').textContent = model.tagline; $('architectureBadge').textContent = model.badge; $('architectureSummary').textContent = model.summary; ['traitMemory', 'traitPhysics', 'traitLearning', 'traitOod'].forEach((id, traitIndex) => { $(id).textContent = model.traits[traitIndex]; }); window.DenimArchitecture.render(index); } document.querySelectorAll('[data-series]').forEach(input => { input.addEventListener('change', () => { state.visible[input.dataset.series] = input.checked; drawHysteresis(); }); }); $('timeline').addEventListener('input', event => { state.step = Number(event.target.value); render(); }); $('play').onclick = togglePlayback; $('memoryPair').onclick = () => { const pair = trajectory().memory_pair; state.step = pair[state.pairToggle++ % 2]; render(); }; $('reset').onclick = () => { stopPlayback(); state.step = 0; state.pairToggle = 0; render(); }; document.addEventListener('keydown', event => { if ($('labView').hidden) return; if (event.target.matches('input, button, a')) return; if (event.code === 'Space') { event.preventDefault(); togglePlayback(); } if (event.key === 'ArrowRight') { stopPlayback(); state.step = Math.min(state.step + 1, trajectory().strain_pct.length - 1); render(); } if (event.key === 'ArrowLeft') { stopPlayback(); state.step = Math.max(state.step - 1, 0); render(); } if (event.key.toLowerCase() === 'm') { $('memoryPair').click(); } }); document.querySelectorAll('[data-view]').forEach(button => { button.onclick = () => { const target = button.dataset.view; const evidence = target === 'evidence'; const models = target === 'models'; $('labView').hidden = evidence || models; $('evidenceView').hidden = !evidence; $('modelsView').hidden = !models; document.querySelectorAll('[data-view]').forEach(value => { value.classList.toggle('active', value === button); value.setAttribute('aria-selected', String(value === button)); }); if (target !== 'lab') stopPlayback(); if (target === 'lab') requestAnimationFrame(render); }; }); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(render, 90); }); buildPathList(); buildLadder(); buildModelSelector(); renderArchitecture(5); render(); })();