/** * One running simulation: the two grids, the body, the compiled .m, the * timestep, the microphone. * * Everything that is not rendering. The app and the tests both go through * this, so there is one place that decides how a pair of .m files becomes * something running on the GPU — and nothing about it is browser-specific * beyond needing a GPUDevice. * * The order of construction matters and is worth spelling out. The air grid * fixes h; the scene fixes the fastest speed; together they fix dt (the CFL * condition). Only then can the string grid be sized, because the string has * no say in the timestep — the air dictates dt, and the string chooses the * finest spacing that is stable at that dt for anything the sliders can ask * (see makeStringGrid). Then the ops and the model compile against both * grids. */ import { makeAirGrid, makeStringGrid, stableDt, type AirGrid, type StringGrid, } from '../grid.ts'; import { OpPlan } from './ops.ts'; import { GpuModel, type ModelParams } from './model.ts'; import { Scene } from '../scene/scene.ts'; import { Recorder } from '../audio/recorder.ts'; import { worstCase, type MModel, type Params } from './registry.ts'; import type { MScene } from '../scene/registry.ts'; import { C_AIR, CFL, DOMAIN_X } from '../units.ts'; export interface ModelSessionOptions { device: GPUDevice; model: MModel; params: Params; /** Override the model source — the editor's working copy. */ source?: string; scene: MScene; sceneParams: Params; /** Override the scene source — the editor's working copy. */ sceneSource?: string; /** Air grid points along x; y and z get half each. */ nx: number; /** Domain length along x, metres. */ Lx?: number; /** String length, metres. */ Ls: number; /** Grid fields one kernel may read, overriding what the device allows. * Only for tests, which use it to exercise the planner's kernel splitting * on a device that would never need it. */ operandBudget?: number; } export class ModelSession { readonly device: GPUDevice; readonly model: MModel; readonly air: AirGrid; readonly string: StringGrid; readonly gpu: GpuModel; /** The microphone: the pressure at one point, every timestep. */ readonly recorder: Recorder; /** Model time and step count since the last pluck. */ t = 0; steps = 0; #scene: Scene; #sceneModel: MScene; #params: Params; #dt: number; private constructor(init: { device: GPUDevice; model: MModel; air: AirGrid; string: StringGrid; gpu: GpuModel; recorder: Recorder; scene: Scene; sceneModel: MScene; params: Params; dt: number; }) { this.device = init.device; this.model = init.model; this.air = init.air; this.string = init.string; this.gpu = init.gpu; this.recorder = init.recorder; this.#scene = init.scene; this.#sceneModel = init.sceneModel; this.#params = init.params; this.#dt = init.dt; } static async create(opts: ModelSessionOptions): Promise { const { device, model, params, scene, sceneParams } = opts; const air = makeAirGrid(opts.nx, opts.Lx ?? DOMAIN_X); // The scene first: the timestep depends on the fastest speed in it. const built = Scene.create({ air, Ls: opts.Ls, source: opts.sceneSource ?? scene.source, paramNames: scene.params.map((p) => p.key), params: sceneParams, }); const dt = stableDt(air.h, Math.max(built.cmax, C_AIR), CFL); const string = makeStringGrid(opts.Ls, dt, worstCase(model)); const ops = new OpPlan(device, { nx: air.nx, ny: air.ny, nz: air.nz, h: air.h, ns: string.ns, hs: string.hs, xs0: -string.Ls / 2, Lx: air.Lx, }); const gpu = await GpuModel.create({ device, ops, air, string, medium: built, source: opts.source ?? model.source, paramNames: model.params.map((p) => p.key), state: model.state, operandBudget: opts.operandBudget, }); gpu.setDt(dt); // The microphone listens to the host-owned pressure buffer, which is // where both `init` and `step` leave their result. const recorder = await Recorder.create({ device, field: gpu.stateBuffer(model.pressure)!, nx: air.nx, ny: air.ny, nz: air.nz, }); const session = new ModelSession({ device, model, air, string, gpu, recorder, scene: built, sceneModel: scene, params, dt, }); gpu.setParams(params); return session; } get scene(): Scene { return this.#scene; } get sceneModel(): MScene { return this.#sceneModel; } /** The air pressure field's name — what gets drawn and recorded. */ get pressureName(): string { return this.model.pressure; } /** The string displacement field's name — what the string plot shows. */ get displacementName(): string { return this.model.displacement; } get dt(): number { return this.#dt; } /** * Swap the body under a running model. It is data, not code, so this needs * no recompile. The timestep is deliberately left alone: it was set from * max(cmax, c_air) at build time, and a scene edit that *raises* the * fastest speed above that needs a rebuild anyway (the caller compares * `dtWanted` and rebuilds when they disagree). */ setScene(sceneModel: MScene, params: Params, source?: string): void { const built = Scene.create({ air: this.air, Ls: this.string.Ls, source: source ?? sceneModel.source, paramNames: sceneModel.params.map((p) => p.key), params, }); this.#scene = built; this.#sceneModel = sceneModel; this.gpu.uploadMedium(built); } /** The timestep the current scene would ask for. Differs from `dt` only * when a scene edit changed the fastest speed, which calls for a rebuild. */ get dtWanted(): number { return stableDt(this.air.h, Math.max(this.#scene.cmax, C_AIR), CFL); } setParams(params: ModelParams): void { this.#params = params; this.gpu.setParams(params); } /** Run `init`: the string drawn into its pluck, silent air, t = 0. */ pluck(): void { this.gpu.init(); this.recorder.clear(); this.t = 0; this.steps = 0; } /** Put the microphone at the grid point nearest (x, y, z), metres. */ setMic(x: number, y: number, z: number): void { const { Lx, Ly, Lz, h } = this.air; this.recorder.setProbe( (x + Lx / 2) / h - 0.5, (y + Ly / 2) / h - 0.5, (z + Lz / 2) / h - 0.5, ); } /** Advance `n` steps. Synchronous: records and submits, nothing read back. * The microphone samples inside the same submission, once per step. */ step(n = 1): void { this.gpu.step(n, (enc) => this.recorder.encode(enc)); this.t += n * this.#dt; this.steps += n; } /** Wait for the submitted steps to finish, without reading anything back. */ sync(): Promise { return this.device.queue.onSubmittedWorkDone(); } /** Read a named field back to the CPU. */ read(name: string): Promise { return this.gpu.read(name); } describe(): { init: string[]; step: string[] } { return this.gpu.describe(); } destroy(): void { this.recorder.destroy(); this.gpu.destroy(); } }