toastsim/src/sim/heatsource.ts
type-two b76820b88e Heat sources M-H1/M-H2: the knob is not the heat
New bone src/sim/heatsource.ts: a HeatSource sits between the knob and
the browning integrator — the dial sets target, output chases it on a
fuel-specific lag, every station reads output. Five fuels (gas/electric/
induction/charcoal/radiant) + appliance SKUs (oven_gas, oven_fan,
cheap_electric). roasting.ts retrofit is golden-preserving: optional
src?, reads src.output ?? power, byte-identical when absent.

Verified in-browser:
- GOLDEN: no-src oven = mean 0.698 / blister 1.0 / collapse 0 (== HEAD).
- M-H1 chase 3->9: gas 0.433s, electric 6.0s, induction 0.133s (distinct).
- residual after 9->0: gas 0@1s, electric 6.6@6s, charcoal 9@30s.
- M-H2: gas oven stdev 0.1207 vs fan 0.0016 at equal mean 0.6001 (75x).
- day 14 bruschetta now roasts on oven_gas, still grades S (9.62); oven
  HUD reads 'GAS OVEN 8 · blue flame', tomatoes visibly hot-at-centre.

Doctrine: OPUS-BUILD-BRIEF-HEAT.md (the cross-cutting heat & environment
doctrine — 'you never cook the knob, you cook the lag'). Harness:
t.heatChase/residual/ovenStdev/ovenGolden/ovenDemo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:16:54 +10:00

211 lines
9.0 KiB
TypeScript

import { Field } from '../core/field';
import { Rng, valueNoise2D } from '../core/rng';
/**
* THE HEAT SOURCE — the bone between the knob and the browning integrator.
*
* Every heat surface in the game used to map its dial STRAIGHT into the cooker:
* roastStep read s.power, toastStep read power, and the number the knob asked for
* was the number the food got. No real cook believes that. A HeatSource sits in
* between: the knob sets a `target`, an `output` chases it on a fuel-specific lag,
* and every station reads `output` where it read `power`.
*
* gas — output glued to the knob (fast rise AND fall). No memory.
* electric — slow rise, slower fall: the coil remembers, you cook its future.
* induction — instant both ways, flattest heat, DEAD to a non-ferrous pan.
* charcoal — no knob; a heat you bank and it decays over a service (see zone).
* radiant — the element: slow, top-biased — what the toaster & fan oven ARE.
*
* You never cook the knob — you cook the lag.
*
* Pure: Field + Rng only, so the harness measures the same numbers the judge does.
* Retrofit is golden-preserving: a station passing no source reads `power` exactly
* as before (see roasting.ts / the M-H2 golden test).
*/
export type Fuel = 'gas' | 'electric' | 'induction' | 'charcoal' | 'radiant';
export interface HeatSource {
fuel: Fuel;
/** 0..10 — what the knob commands (charcoal: the banked bed level). */
target: number;
/** 0..10 — what actually reaches the vessel. EVERY station reads this. */
output: number;
/** units/sec climbing toward target. */
riseRate: number;
/** units/sec falling toward target. LOW = residual heat that won't let go. */
fallRate: number;
/** powerMax clamp — cheap hobs top out below 10. */
maxOut: number;
/** blotch amplitude blended into the station's heat-map (hot-spots). */
evenNoise: number;
/** radial concentration of the heat map — a burner ring, a coal pile, a centre-hot oven. */
hotspot: number;
/** has a flame → wind steals from output, fat can flare it. */
flame: boolean;
/** induction rejects a non-ferrous vessel → output pinned at ambient. */
needsFerrous: boolean;
/** how hard rendered fat spikes output (gas high, induction zero). */
flareGain: number;
/** charcoal only: a 2D heat-zone gradient you arrange. */
zone?: Field;
}
export interface Environment {
place: 'indoor' | 'outdoor';
/** the temp cooling pulls toward (a cold night lowers it). */
ambient: number;
wind: number; // 0..1 base
gust: number; // 0..1 live, walks toward wind
/** lid / windbreak / lee side → gust forced to 0 (the dodgeable floor). */
sheltered: boolean;
/** does the mounted pan take induction. */
vesselFerrous: boolean;
/** indoor only: burnt food accumulates it → fires the ZAP once. */
smoke: number;
rng: Rng;
}
type Profile = Omit<HeatSource, 'fuel' | 'target' | 'output' | 'zone'>;
/**
* Base fuel profiles. Tuned to the M-H1 exit bars: chase 3→9 is gas <1s,
* electric 5-9s, induction <0.15s; residual after 9→0 is gas <1 within 1s,
* electric still >6 at 6s, charcoal still >5 at 30s.
*/
export const FUELS: Record<Fuel, Profile> = {
gas: { riseRate: 14, fallRate: 14, maxOut: 10, evenNoise: 0.1, hotspot: 0.35, flame: true, needsFerrous: false, flareGain: 3.5 },
electric: { riseRate: 1.0, fallRate: 0.4, maxOut: 9, evenNoise: 0.28, hotspot: 0.2, flame: false, needsFerrous: false, flareGain: 0 },
induction: { riseRate: 45, fallRate: 45, maxOut: 10, evenNoise: 0.03, hotspot: 0.1, flame: false, needsFerrous: true, flareGain: 0 },
charcoal: { riseRate: 6, fallRate: 0.02, maxOut: 10, evenNoise: 0.35, hotspot: 0.6, flame: true, needsFerrous: false, flareGain: 4.0 },
radiant: { riseRate: 0.9, fallRate: 0.5, maxOut: 10, evenNoise: 0.14, hotspot: 0.05, flame: false, needsFerrous: false, flareGain: 0.5 },
};
/**
* Appliance SKUs — a base fuel with overrides. This is both the two oven
* variants (gas hot-at-top vs electric fan even) and the P12 buy mechanism
* (a cheap hob is noise + a slow chase, never a flat score tax).
*/
export const HEATSOURCE_SKUS: Record<string, Partial<Profile> & { fuel: Fuel }> = {
// Gas oven: preheats fast-ish, top/centre-hot, blotchy → uneven roast (high stdev).
oven_gas: { fuel: 'gas', riseRate: 2.2, fallRate: 1.2, evenNoise: 0.24, hotspot: 0.62, maxOut: 10 },
// Electric fan oven: slow to preheat, laboratory-flat → even roast (low stdev).
oven_fan: { fuel: 'radiant', riseRate: 0.8, fallRate: 0.5, evenNoise: 0.03, hotspot: 0.0, maxOut: 10 },
// P12: a cheap thin electric hob — hot-spot noise + a slow chase, tops out low.
cheap_electric: { fuel: 'electric', maxOut: 8.2, evenNoise: 0.42, hotspot: 0.5, riseRate: 0.9 },
};
export function newHeatSource(fuelOrSku: Fuel | string): HeatSource {
const sku = HEATSOURCE_SKUS[fuelOrSku];
const fuel: Fuel = sku ? sku.fuel : (fuelOrSku as Fuel);
const p: Profile = { ...FUELS[fuel], ...(sku ?? {}) };
return {
fuel,
target: 0,
output: 0,
riseRate: p.riseRate,
fallRate: p.fallRate,
maxOut: p.maxOut,
evenNoise: p.evenNoise,
hotspot: p.hotspot,
flame: p.flame,
needsFerrous: p.needsFerrous,
flareGain: p.flareGain,
};
}
export function newIndoorEnv(seed = 7): Environment {
return { place: 'indoor', ambient: 0, wind: 0, gust: 0, sheltered: true, vesselFerrous: true, smoke: 0, rng: new Rng(seed) };
}
const INDOOR = newIndoorEnv();
/** Set the knob. Charcoal ignores this (target is the bed level; use stoke). */
export function setKnob(hs: HeatSource, k: number): void {
if (hs.fuel === 'charcoal') return;
hs.target = Math.max(0, Math.min(hs.maxOut, k));
}
/** Charcoal: fan the coals — bump the bed ceiling. */
export function stoke(hs: HeatSource, amt = 1.5): void {
hs.target = Math.min(hs.maxOut, hs.target + amt);
}
/**
* Advance the source one tick. output chases target on the fuel's lag; a flame
* loses ground to wind and gains it from fat; a dead (non-ferrous) induction pan
* sits at ambient. This is TempClock's linear drift and coolStep's decay, married.
*/
export function heatStep(hs: HeatSource, dt: number, env: Environment = INDOOR, fatLoad = 0): void {
if (hs.needsFerrous && !env.vesselFerrous) {
hs.output = env.ambient;
return;
}
const t = hs.flame ? hs.target * (1 - 0.35 * env.gust) : hs.target;
if (hs.output < t) hs.output = Math.min(t, hs.output + hs.riseRate * dt);
else hs.output = Math.max(t, hs.output - hs.fallRate * dt);
if (fatLoad > 0 && hs.flareGain > 0) {
hs.output = Math.min(hs.maxOut, hs.output + hs.flareGain * fatLoad);
}
}
/** Walk the live gust toward the base wind (0 indoors or when sheltered). */
export function envStep(env: Environment, dt: number): void {
const target = env.place === 'outdoor' && !env.sheltered ? env.wind : 0;
// A lazy first-order walk with a little value-noise jitter so gusts feel alive.
const jitter = (env.rng.next() - 0.5) * 0.4 * env.wind;
env.gust += (target + jitter - env.gust) * Math.min(1, dt * 1.5);
env.gust = Math.max(0, Math.min(1, env.gust));
}
/**
* Reshape a station's heat map for this fuel — the only spatial hook. A flat fan
* (hotspot 0, evenNoise ~0) leaves a near-uniform disc; a gas/coal source (high
* hotspot + blotch) concentrates heat toward the centre and speckles it, so an
* even cook becomes a real skill. Normalized over `mask` (or the whole grid) so
* two fuels compared at equal cook time have equal MEAN heat — the difference is
* pure evenness (the M-H2 bar).
*/
export function applyToHeatMap(heat: Float32Array, n: number, hs: HeatSource, rng: Rng, mask?: Field): void {
const bias = valueNoise2D(rng, n, n, 3);
let sum = 0;
let count = 0;
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const i = y * n + x;
const u = x / (n - 1);
const v = y / (n - 1);
const dc = Math.hypot(u - 0.5, v - 0.5);
const centre = 1 - hs.hotspot * Math.min(1, dc / 0.6);
const blot = 1 + hs.evenNoise * (bias[i] * 2 - 1);
const val = Math.max(0.05, centre * blot);
heat[i] = val;
if (!mask || mask.data[i] >= 0.5) {
sum += val;
count++;
}
}
}
// Normalize so the masked average matches the legacy heat map's ~0.9.
const avg = count > 0 ? sum / count : 1;
const k = avg > 0 ? 0.9 / avg : 1;
for (let i = 0; i < heat.length; i++) heat[i] *= k;
}
/** A human tell for the current source state — for a diegetic HUD, never a bar. */
export function fuelWord(hs: HeatSource): string {
const o = hs.output;
switch (hs.fuel) {
case 'gas':
return o < 0.5 ? 'flame out' : o > 8 ? 'roaring blue' : 'blue flame';
case 'electric':
return o < 0.5 ? 'cold coil' : o > hs.target + 0.5 ? 'still glowing (residual)' : o > 6 ? 'glowing orange' : 'warming';
case 'induction':
return o < 0.3 ? 'dead — wrong pan?' : 'silent, exact';
case 'charcoal':
return o > 6 ? 'coals screaming' : o > 3 ? 'good bed' : 'coals fading';
default:
return o > 6 ? 'element hot' : o > 2 ? 'warming' : 'cold';
}
}