The canvas below simulates a small box of particles. Without a thermostat the temperature drifts freely. Choose Berendsen or Nosé–Hoover, set your target temperature, and watch the measured temperature lock onto T0.
<!-- Controls: thermostat mode selector, target temperature slider, reset button -->
<div class="controls">
<label>Thermostat:
<select id="mode">
<option value="none">None (free drift)</option>
<option value="berendsen">Berendsen</option>
<option value="nh">Nosé–Hoover</option>
</select>
</label>
<label>Target T₀: <b id="t0disp">1.0</b>
<input type="range" id="t0slider" min="0.3" max="2.5" step="0.05" value="1.0">
</label>
<button id="resetBtn" type="button">Reset</button>
</div>
<canvas id="cv" width="480" height="220" aria-label="Particle simulation canvas"></canvas>
<div class="chart-wrap">
<canvas id="chart" width="480" height="140" aria-label="Temperature history chart"></canvas>
</div>
<div id="status" class="status"></div>
/* Layout and styling for the MD thermostat demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: #222; background: #fff; }
.controls { display: flex; flex-wrap: wrap; gap: .6rem .9rem; padding: .5rem 0 .6rem; align-items: center; }
label { display: flex; align-items: center; gap: .3rem; font-size: .85rem; }
select, input[type=range] { cursor: pointer; }
select { padding: .2rem .4rem; border: 1px solid #adb1b8; border-radius: 6px; background: #f5f6f8; }
input[type=range] { width: 90px; }
button { font: 600 13px system-ui; padding: .3rem .7rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
canvas { display: block; border: 1px solid #dde2e8; border-radius: 6px; background: #f8f9fb; }
.chart-wrap { margin-top: .45rem; }
.status { margin-top: .4rem; font-size: .9rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.drift { color: #c92f3c; }
.status.settling { color: #e07e00; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* MD thermostat demo — Berendsen velocity rescaling vs Nosé–Hoover heat bath */
/* Simulation parameters */
const N_PART = 40;
const BOX_W = 480, BOX_H = 220;
const RADIUS = 5;
const DT = 0.018;
const TAU_BEREN = 0.5;
const TAU_NH = 0.4;
const HISTORY_LEN = 200;
/* Simulation state variables */
let pos, vel, xi, T0, mode, tHistory, frameId;
function randGauss() {
/* Box–Muller transform for Gaussian random numbers */
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function initSystem() {
/* Initialize particle positions and velocities */
pos = [];
vel = [];
xi = 0;
T0 = parseFloat(document.getElementById('t0slider').value);
mode = document.getElementById('mode').value;
tHistory = [];
/* Arrange particles on a regular grid with small random offsets */
const cols = Math.ceil(Math.sqrt(N_PART));
const rows = Math.ceil(N_PART / cols);
const dx = (BOX_W - 2 * RADIUS * 2) / cols;
const dy = (BOX_H - 2 * RADIUS * 2) / rows;
let idx = 0;
for (let r = 0; r < rows && idx < N_PART; r++) {
for (let c = 0; c < cols && idx < N_PART; c++) {
pos.push([
RADIUS * 2 + (c + 0.5) * dx + (Math.random() - 0.5) * dx * 0.3,
RADIUS * 2 + (r + 0.5) * dy + (Math.random() - 0.5) * dy * 0.3
]);
/* Maxwell–Boltzmann initial velocities */
vel.push([randGauss() * Math.sqrt(T0 * 0.35), randGauss() * Math.sqrt(T0 * 0.35)]);
idx++;
}
}
/* Remove center-of-mass drift */
let vxMean = 0, vyMean = 0;
vel.forEach(v => { vxMean += v[0]; vyMean += v[1]; });
vxMean /= N_PART; vyMean /= N_PART;
vel.forEach(v => { v[0] -= vxMean; v[1] -= vyMean; });
}
function kineticTemp() {
/* Instantaneous kinetic temperature: T = <v^2> / 2 (2D, kB=m=1) */
let ek = 0;
vel.forEach(v => { ek += v[0] * v[0] + v[1] * v[1]; });
return ek / (2 * N_PART);
}
function step() {
/* Euler integration: move particles */
for (let i = 0; i < N_PART; i++) {
pos[i][0] += vel[i][0] * DT;
pos[i][1] += vel[i][1] * DT;
/* Reflect off walls (elastic bounce) */
if (pos[i][0] < RADIUS) { pos[i][0] = RADIUS; vel[i][0] = Math.abs(vel[i][0]); }
if (pos[i][0] > BOX_W - RADIUS) { pos[i][0] = BOX_W - RADIUS; vel[i][0] = -Math.abs(vel[i][0]); }
if (pos[i][1] < RADIUS) { pos[i][1] = RADIUS; vel[i][1] = Math.abs(vel[i][1]); }
if (pos[i][1] > BOX_H - RADIUS) { pos[i][1] = BOX_H - RADIUS; vel[i][1] = -Math.abs(vel[i][1]); }
}
const T = kineticTemp();
if (mode === 'berendsen') {
/* Berendsen velocity rescaling: lambda couples T toward T0 */
const lam = Math.sqrt(1 + (DT / TAU_BEREN) * (T0 / Math.max(T, 0.001) - 1));
vel.forEach(v => { v[0] *= lam; v[1] *= lam; });
} else if (mode === 'nh') {
/* Nosé–Hoover friction: xi drives kinetic energy toward N*kB*T0 */
const Q = 3 * N_PART * T0 * TAU_NH * TAU_NH;
xi += DT * (2 * N_PART * T - 2 * N_PART * T0) / Q;
vel.forEach(v => { v[0] -= xi * v[0] * DT; v[1] -= xi * v[1] * DT; });
}
/* Record temperature history for the chart */
tHistory.push(kineticTemp());
if (tHistory.length > HISTORY_LEN) tHistory.shift();
}
function drawParticles() {
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, BOX_W, BOX_H);
const T = kineticTemp();
/* Color particles by speed: blue = slow, orange = fast */
pos.forEach((p, i) => {
const speed = Math.sqrt(vel[i][0] * vel[i][0] + vel[i][1] * vel[i][1]);
const hot = Math.min(speed / (3 * Math.sqrt(T0 + 0.1)), 1);
ctx.beginPath();
ctx.arc(p[0], p[1], RADIUS, 0, 2 * Math.PI);
ctx.fillStyle = `hsl(${220 - hot * 200},70%,${55 - hot * 15}%)`;
ctx.fill();
});
}
function drawChart() {
const cv = document.getElementById('chart');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
/* Draw temperature chart with axes and gridlines */
const pad = { l: 38, r: 8, t: 8, b: 22 };
const iW = W - pad.l - pad.r;
const iH = H - pad.t - pad.b;
const maxT = Math.max(T0 * 2.2, ...tHistory, 0.1);
ctx.strokeStyle = '#ccd2da';
ctx.lineWidth = 1;
/* Horizontal gridlines at fractions of T0 */
[0.25, 0.5, 0.75, 1.0].forEach(f => {
const y = pad.t + iH * (1 - f * T0 * 2 / maxT);
if (y < pad.t || y > pad.t + iH) return;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iW, y); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui';
ctx.fillText((f * T0 * 2).toFixed(2), 2, y + 3);
});
/* Red dashed line at target temperature T0 */
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]);
const ty = pad.t + iH * (1 - T0 / maxT);
ctx.beginPath(); ctx.moveTo(pad.l, ty); ctx.lineTo(pad.l + iW, ty); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946'; ctx.font = '10px system-ui';
ctx.fillText('T₀', pad.l + iW - 14, ty - 3);
/* Blue line: measured temperature over time */
if (tHistory.length > 1) {
ctx.strokeStyle = '#1d6aa5';
ctx.lineWidth = 1.6;
ctx.beginPath();
tHistory.forEach((t, i) => {
const x = pad.l + (i / (HISTORY_LEN - 1)) * iW;
const y = pad.t + iH * (1 - t / maxT);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}
/* Axis labels */
ctx.fillStyle = '#444'; ctx.font = '10px system-ui';
ctx.fillText('T', 4, pad.t + 4);
ctx.fillText('time →', pad.l + iW / 2 - 12, H - 4);
}
function updateStatus() {
const el = document.getElementById('status');
const T = kineticTemp();
const diff = Math.abs(T - T0) / T0;
if (mode === 'none') {
el.textContent = i18nFmt('No thermostat — T = %T% (drifting)', { T: T.toFixed(3) });
el.className = 'status drift';
} else if (diff < 0.05) {
el.textContent = i18nFmt('Stable at T = %T% (target %T0%)', { T: T.toFixed(3), T0: T0.toFixed(2) });
el.className = 'status ok';
} else {
el.textContent = i18nFmt('Settling… T = %T% → %T0%', { T: T.toFixed(3), T0: T0.toFixed(2) });
el.className = 'status settling';
}
}
function loop() {
for (let i = 0; i < 3; i++) step();
drawParticles();
drawChart();
updateStatus();
frameId = requestAnimationFrame(loop);
}
function reset() {
if (frameId) cancelAnimationFrame(frameId);
initSystem();
loop();
}
document.getElementById('resetBtn').addEventListener('click', reset);
document.getElementById('mode').addEventListener('change', () => {
mode = document.getElementById('mode').value;
xi = 0;
});
document.getElementById('t0slider').addEventListener('input', () => {
T0 = parseFloat(document.getElementById('t0slider').value);
document.getElementById('t0disp').textContent = T0.toFixed(2);
});
reset();
<!-- Controls: thermostat mode selector, target temperature slider, reset button -->
<div class="controls">
<label>Thermostat:
<select id="mode">
<option value="none">None (free drift)</option>
<option value="berendsen">Berendsen</option>
<option value="nh">Nosé–Hoover</option>
</select>
</label>
<label>Target T₀: <b id="t0disp">1.0</b>
<input type="range" id="t0slider" min="0.3" max="2.5" step="0.05" value="1.0">
</label>
<button id="resetBtn" type="button">Reset</button>
</div>
<canvas id="cv" width="480" height="220" aria-label="Particle simulation canvas"></canvas>
<div class="chart-wrap">
<canvas id="chart" width="480" height="140" aria-label="Temperature history chart"></canvas>
</div>
<div id="status" class="status"></div>
/* Layout and styling for the MD thermostat demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: #222; background: #fff; }
.controls { display: flex; flex-wrap: wrap; gap: .6rem .9rem; padding: .5rem 0 .6rem; align-items: center; }
label { display: flex; align-items: center; gap: .3rem; font-size: .85rem; }
select, input[type=range] { cursor: pointer; }
select { padding: .2rem .4rem; border: 1px solid #adb1b8; border-radius: 6px; background: #f5f6f8; }
input[type=range] { width: 90px; }
button { font: 600 13px system-ui; padding: .3rem .7rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
canvas { display: block; border: 1px solid #dde2e8; border-radius: 6px; background: #f8f9fb; }
.chart-wrap { margin-top: .45rem; }
.status { margin-top: .4rem; font-size: .9rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.drift { color: #c92f3c; }
.status.settling { color: #e07e00; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* MD thermostat demo — Berendsen velocity rescaling vs Nosé–Hoover heat bath */
/* Simulation parameters */
const N_PART = 40;
const BOX_W = 480, BOX_H = 220;
const RADIUS = 5;
const DT = 0.018;
const TAU_BEREN = 0.5;
const TAU_NH = 0.4;
const HISTORY_LEN = 200;
/* Simulation state variables */
let pos, vel, xi, T0, mode, tHistory, frameId;
function randGauss() {
/* Box–Muller transform for Gaussian random numbers */
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function initSystem() {
/* Initialize particle positions and velocities */
pos = [];
vel = [];
xi = 0;
T0 = parseFloat(document.getElementById('t0slider').value);
mode = document.getElementById('mode').value;
tHistory = [];
/* Arrange particles on a regular grid with small random offsets */
const cols = Math.ceil(Math.sqrt(N_PART));
const rows = Math.ceil(N_PART / cols);
const dx = (BOX_W - 2 * RADIUS * 2) / cols;
const dy = (BOX_H - 2 * RADIUS * 2) / rows;
let idx = 0;
for (let r = 0; r < rows && idx < N_PART; r++) {
for (let c = 0; c < cols && idx < N_PART; c++) {
pos.push([
RADIUS * 2 + (c + 0.5) * dx + (Math.random() - 0.5) * dx * 0.3,
RADIUS * 2 + (r + 0.5) * dy + (Math.random() - 0.5) * dy * 0.3
]);
/* Maxwell–Boltzmann initial velocities */
vel.push([randGauss() * Math.sqrt(T0 * 0.35), randGauss() * Math.sqrt(T0 * 0.35)]);
idx++;
}
}
/* Remove center-of-mass drift */
let vxMean = 0, vyMean = 0;
vel.forEach(v => { vxMean += v[0]; vyMean += v[1]; });
vxMean /= N_PART; vyMean /= N_PART;
vel.forEach(v => { v[0] -= vxMean; v[1] -= vyMean; });
}
function kineticTemp() {
/* Instantaneous kinetic temperature: T = <v^2> / 2 (2D, kB=m=1) */
let ek = 0;
vel.forEach(v => { ek += v[0] * v[0] + v[1] * v[1]; });
return ek / (2 * N_PART);
}
function step() {
/* Euler integration: move particles */
for (let i = 0; i < N_PART; i++) {
pos[i][0] += vel[i][0] * DT;
pos[i][1] += vel[i][1] * DT;
/* Reflect off walls (elastic bounce) */
if (pos[i][0] < RADIUS) { pos[i][0] = RADIUS; vel[i][0] = Math.abs(vel[i][0]); }
if (pos[i][0] > BOX_W - RADIUS) { pos[i][0] = BOX_W - RADIUS; vel[i][0] = -Math.abs(vel[i][0]); }
if (pos[i][1] < RADIUS) { pos[i][1] = RADIUS; vel[i][1] = Math.abs(vel[i][1]); }
if (pos[i][1] > BOX_H - RADIUS) { pos[i][1] = BOX_H - RADIUS; vel[i][1] = -Math.abs(vel[i][1]); }
}
const T = kineticTemp();
if (mode === 'berendsen') {
/* Berendsen velocity rescaling: lambda couples T toward T0 */
const lam = Math.sqrt(1 + (DT / TAU_BEREN) * (T0 / Math.max(T, 0.001) - 1));
vel.forEach(v => { v[0] *= lam; v[1] *= lam; });
} else if (mode === 'nh') {
/* Nosé–Hoover friction: xi drives kinetic energy toward N*kB*T0 */
const Q = 3 * N_PART * T0 * TAU_NH * TAU_NH;
xi += DT * (2 * N_PART * T - 2 * N_PART * T0) / Q;
vel.forEach(v => { v[0] -= xi * v[0] * DT; v[1] -= xi * v[1] * DT; });
}
/* Record temperature history for the chart */
tHistory.push(kineticTemp());
if (tHistory.length > HISTORY_LEN) tHistory.shift();
}
function drawParticles() {
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, BOX_W, BOX_H);
const T = kineticTemp();
/* Color particles by speed: blue = slow, orange = fast */
pos.forEach((p, i) => {
const speed = Math.sqrt(vel[i][0] * vel[i][0] + vel[i][1] * vel[i][1]);
const hot = Math.min(speed / (3 * Math.sqrt(T0 + 0.1)), 1);
ctx.beginPath();
ctx.arc(p[0], p[1], RADIUS, 0, 2 * Math.PI);
ctx.fillStyle = `hsl(${220 - hot * 200},70%,${55 - hot * 15}%)`;
ctx.fill();
});
}
function drawChart() {
const cv = document.getElementById('chart');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
/* Draw temperature chart with axes and gridlines */
const pad = { l: 38, r: 8, t: 8, b: 22 };
const iW = W - pad.l - pad.r;
const iH = H - pad.t - pad.b;
const maxT = Math.max(T0 * 2.2, ...tHistory, 0.1);
ctx.strokeStyle = '#ccd2da';
ctx.lineWidth = 1;
/* Horizontal gridlines at fractions of T0 */
[0.25, 0.5, 0.75, 1.0].forEach(f => {
const y = pad.t + iH * (1 - f * T0 * 2 / maxT);
if (y < pad.t || y > pad.t + iH) return;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + iW, y); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui';
ctx.fillText((f * T0 * 2).toFixed(2), 2, y + 3);
});
/* Red dashed line at target temperature T0 */
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]);
const ty = pad.t + iH * (1 - T0 / maxT);
ctx.beginPath(); ctx.moveTo(pad.l, ty); ctx.lineTo(pad.l + iW, ty); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946'; ctx.font = '10px system-ui';
ctx.fillText('T₀', pad.l + iW - 14, ty - 3);
/* Blue line: measured temperature over time */
if (tHistory.length > 1) {
ctx.strokeStyle = '#1d6aa5';
ctx.lineWidth = 1.6;
ctx.beginPath();
tHistory.forEach((t, i) => {
const x = pad.l + (i / (HISTORY_LEN - 1)) * iW;
const y = pad.t + iH * (1 - t / maxT);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}
/* Axis labels */
ctx.fillStyle = '#444'; ctx.font = '10px system-ui';
ctx.fillText('T', 4, pad.t + 4);
ctx.fillText('time →', pad.l + iW / 2 - 12, H - 4);
}
function updateStatus() {
const el = document.getElementById('status');
const T = kineticTemp();
const diff = Math.abs(T - T0) / T0;
if (mode === 'none') {
el.textContent = i18nFmt('No thermostat — T = %T% (drifting)', { T: T.toFixed(3) });
el.className = 'status drift';
} else if (diff < 0.05) {
el.textContent = i18nFmt('Stable at T = %T% (target %T0%)', { T: T.toFixed(3), T0: T0.toFixed(2) });
el.className = 'status ok';
} else {
el.textContent = i18nFmt('Settling… T = %T% → %T0%', { T: T.toFixed(3), T0: T0.toFixed(2) });
el.className = 'status settling';
}
}
function loop() {
for (let i = 0; i < 3; i++) step();
drawParticles();
drawChart();
updateStatus();
frameId = requestAnimationFrame(loop);
}
function reset() {
if (frameId) cancelAnimationFrame(frameId);
initSystem();
loop();
}
document.getElementById('resetBtn').addEventListener('click', reset);
document.getElementById('mode').addEventListener('change', () => {
mode = document.getElementById('mode').value;
xi = 0;
});
document.getElementById('t0slider').addEventListener('input', () => {
T0 = parseFloat(document.getElementById('t0slider').value);
document.getElementById('t0disp').textContent = T0.toFixed(2);
});
reset();
Comments
Loading comments...