feat: page interactive, démos Play/Step et simulateur Carnot

Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,254 @@
import type { SimulatorPlugin } from './types'
/**
* Ideal Carnot machine between two reservoirs (2nd law).
*
* Physics (absolute temperatures only):
* - Refrigerator: COP_R = Tc/(ThTc), W_min = Qc/COP_R, Qh = Qc+W
* - Heat pump: COP_HP = Th/(ThTc) = COP_R+1, W_min = Qh/COP_HP
* - Engine: η = 1Tc/Th, W_out = η·Qh, Qc = QhW
*
* `q_cold` is the primary load magnitude (kJ or W — same number; unit is UI-only).
* For fridge it is Qc; the view remaps for PAC / engine. Temperatures always Kelvin.
*
* Refs: COP_R Carnot = Tc/(ThTc); 1st law Qh=Qc+W; energy↔power interchangeable
* if all rates use the same time basis (see standard thermo textbooks / Carnot fridge calculators).
*/
export const carnotCycleSimulator: SimulatorPlugin = {
family: 'sim',
id: 'carnot-cycle',
title: {
fr: 'Machine de Carnot (frigo / PAC / moteur)',
en: 'Carnot machine (fridge / heat pump / engine)',
},
summary: {
fr: 'Limites de Carnot entre deux sources : COP frigo Tc/(ThTc), COP pompe à chaleur Th/(ThTc), rendement moteur η=1Tc/Th, travail ou puissance minimal(e). 1er et 2e principes.',
en: 'Carnot limits between two reservoirs: fridge COP Tc/(ThTc), heat-pump COP Th/(ThTc), engine η=1Tc/Th, minimum work or power. 1st and 2nd laws.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cop',
'réfrigérateur',
'frigo',
'refrigerator',
'pompe à chaleur',
'heat pump',
'moteur',
'engine',
'rendement',
'efficiency',
'watt',
'puissance',
'power',
'deuxième principe',
'second law',
],
params: [
{
id: 't_cold',
symbol: 'T_c',
label: { fr: 'Source froide', en: 'Cold reservoir' },
min: 200,
max: 320,
step: 1,
defaultValue: 260,
unit: 'K',
intent: 'cache',
},
{
id: 't_hot',
symbol: 'T_h',
label: { fr: 'Source chaude', en: 'Hot reservoir' },
min: 273,
max: 400,
step: 1,
defaultValue: 300,
unit: 'K',
intent: 'warning',
},
{
id: 'q_cold',
symbol: 'Q_c',
label: { fr: 'Charge (Qc frigo)', en: 'Load (fridge Qc)' },
min: 10,
max: 500,
step: 5,
defaultValue: 100,
unit: 'kJ',
intent: 'flow',
},
],
outputs: [
{
id: 'cop_fridge',
symbol: '\\mathrm{COP}_{R}',
label: { fr: 'COP réfrigérateur', en: 'Fridge COP' },
intent: 'output',
digits: 2,
},
{
id: 'cop_hp',
symbol: '\\mathrm{COP}_{HP}',
label: { fr: 'COP pompe à chaleur', en: 'Heat-pump COP' },
intent: 'output',
digits: 2,
},
{
id: 'eta',
symbol: '\\eta',
label: { fr: 'Rendement moteur', en: 'Engine efficiency' },
unit: '%',
intent: 'highlight',
digits: 1,
},
{
id: 'w_min',
symbol: 'W',
label: { fr: 'Travail (énergie)', en: 'Work (energy)' },
unit: 'kJ',
intent: 'compute',
digits: 1,
},
{
id: 'q_hot',
symbol: 'Q_h',
label: { fr: 'Chaleur côté chaud', en: 'Hot-side heat' },
unit: 'kJ',
intent: 'flow',
digits: 1,
},
],
compute(env) {
const tc = env.t_cold
const th = env.t_hot
const qc = env.q_cold
if (!(th > tc) || !(qc > 0)) {
return {
cop_fridge: NaN,
cop_hp: NaN,
eta: NaN,
w_min: NaN,
q_hot: NaN,
}
}
const copFridge = tc / (th - tc)
const copHp = th / (th - tc)
const eta = 1 - tc / th
const wMin = qc / copFridge
return {
cop_fridge: copFridge,
cop_hp: copHp,
eta: eta * 100,
w_min: wMin,
q_hot: qc + wMin,
}
},
}
/** Operating mode for the interactive view (UI-only; physics shared). */
export type CarnotMode = 'fridge' | 'heat_pump' | 'engine'
/** Energy (kJ) vs power (W) — same ratios; only unit labels change. */
export type CarnotQuantity = 'energy' | 'power'
export type CarnotPhysics = {
ok: boolean
tc: number
th: number
copR: number
copHP: number
eta: number
/** Heat exchanged with cold reservoir (magnitude > 0). */
qc: number
/** Heat exchanged with hot reservoir (magnitude > 0). */
qh: number
/** Work magnitude > 0 (input for fridge/PAC, output for engine). */
w: number
/** Reversible check: Qc/Tc ≈ Qh/Th */
entropyOk: boolean
}
/**
* Resolve magnitudes for the selected mode.
* `load` is the primary useful quantity:
* - fridge: Qc extracted from cold
* - heat_pump: Qh delivered to hot
* - engine: Qh absorbed from hot
*/
export function resolveCarnotPhysics(
tc: number,
th: number,
load: number,
mode: CarnotMode
): CarnotPhysics {
if (!(th > tc) || !(load > 0) || !Number.isFinite(tc) || !Number.isFinite(th)) {
return {
ok: false,
tc,
th,
copR: NaN,
copHP: NaN,
eta: NaN,
qc: NaN,
qh: NaN,
w: NaN,
entropyOk: false,
}
}
const copR = tc / (th - tc)
const copHP = th / (th - tc)
const eta = 1 - tc / th
let qc: number
let qh: number
let w: number
if (mode === 'fridge') {
qc = load
w = qc / copR
qh = qc + w
} else if (mode === 'heat_pump') {
qh = load
w = qh / copHP
qc = qh - w
} else {
qh = load
w = eta * qh
qc = qh - w
}
const ratioC = qc / tc
const ratioH = qh / th
const entropyOk =
Number.isFinite(ratioC) &&
Number.isFinite(ratioH) &&
Math.abs(ratioC - ratioH) / Math.max(ratioC, ratioH, 1e-9) < 1e-6
return { ok: true, tc, th, copR, copHP, eta, qc, qh, w, entropyOk }
}
/** Convert fridge-stored Qc load ↔ display load for other modes (fixture-compatible). */
export function fridgeLoadFromModeLoad(
tc: number,
th: number,
modeLoad: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(modeLoad > 0)) return modeLoad
if (mode === 'fridge') return modeLoad
if (mode === 'heat_pump') return modeLoad * (tc / th) // Qc = Qh · Tc/Th
return modeLoad * (tc / th) // engine: Qc = Qh · (1η) = Qh · Tc/Th
}
export function modeLoadFromFridgeLoad(
tc: number,
th: number,
fridgeQc: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(fridgeQc > 0)) return fridgeQc
if (mode === 'fridge') return fridgeQc
if (mode === 'heat_pump') return fridgeQc * (th / tc) // Qh = Qc · Th/Tc
return fridgeQc * (th / tc) // engine Qh = Qc / (1η) = Qc · Th/Tc
}