2 * One running simulation: grid, medium, compiled .m, timestep.
3 *
4 * Everything that is not rendering. The app and the tests both go through
5 * this, so there is one place that decides how a pair of .m files becomes
6 * something running on the GPU — and nothing about it is browser-specific
7 * beyond needing a GPUDevice.
8 */
9import { makeGrid, stableDt, type Grid } from '../grid.ts';
10import { StencilPlan } from './stencil.ts';
11import { GpuModel, type ModelParams } from './model.ts';
12import { Scene } from '../scene/scene.ts';
13import { Recorder } from '../audio/recorder.ts';
14import type { MModel, Params } from './registry.ts';
15import type { MScene } from '../scene/registry.ts';
17export interface ModelSessionOptions {
18 device: GPUDevice;
19 model: MModel;
20 params: Params;
21 /** Override the model source — the editor's working copy. */
22 source?: string;
23 scene: MScene;
24 sceneParams: Params;
25 /** Override the scene source — the editor's working copy. */
26 sceneSource?: string;
27 /** Grid points per side. */
28 n: number;
29 /** Side length of the square domain, in metres. Defaults to a small toy
30 * domain (2 m); the app passes its real DOMAIN constant explicitly. */
31 L?: number;
32 /** Fraction of the stability limit to take as the timestep. */
33 cfl?: number;
34 /** Grid fields one kernel may read, overriding what the device allows.
35 * Only for tests, which use it to exercise the planner's kernel splitting
36 * on a device that would never need it. */
37 operandBudget?: number;
38}
40export class ModelSession {
41 readonly device: GPUDevice;
42 readonly model: MModel;
43 readonly grid: Grid;
44 readonly gpu: GpuModel;
45 /** The microphone: the pressure at one point, every timestep. */
46 readonly recorder: Recorder;
48 /** Model time and step count since the last reset. */
49 t = 0;
50 steps = 0;
52 #scene: Scene;
53 #sceneModel: MScene;
54 #params: Params;
55 #cfl: number;
57 private constructor(init: {
58 device: GPUDevice;
59 model: MModel;
60 grid: Grid;
61 gpu: GpuModel;
62 recorder: Recorder;
63 scene: Scene;
64 sceneModel: MScene;
65 params: Params;
66 cfl: number;
67 }) {
68 this.device = init.device;
69 this.model = init.model;
70 this.grid = init.grid;
71 this.gpu = init.gpu;
72 this.recorder = init.recorder;
73 this.#scene = init.scene;
74 this.#sceneModel = init.sceneModel;
75 this.#params = init.params;
76 this.#cfl = init.cfl;
77 }
79 static async create(opts: ModelSessionOptions): Promise<ModelSession> {
80 const { device, model, params, scene, sceneParams } = opts;
81 const grid = makeGrid(opts.n, opts.L ?? 2);
82 const cfl = opts.cfl ?? 0.5;
84 // The scene first: the model's timestep depends on the fastest speed in
85 // it, and the medium is an argument the compiled step reads.
86 const built = Scene.create({
87 grid,
88 source: opts.sceneSource ?? scene.source,
89 paramNames: scene.params.map((p) => p.key),
90 params: sceneParams,
91 });
93 const stencil = StencilPlan.create(device, { nx: grid.nx, ny: grid.ny, h: grid.h });
94 const gpu = await GpuModel.create({
95 device,
96 stencil,
97 grid,
98 medium: built,
99 source: opts.source ?? model.source,
100 paramNames: model.params.map((p) => p.key),
101 state: model.state,
102 operandBudget: opts.operandBudget,
103 });
105 // The microphone listens to the host-owned pressure buffer, which is
106 // where both `init` and `step` leave their result.
107 const recorder = await Recorder.create({
108 device,
109 field: gpu.stateBuffer(model.state[0])!,
110 nx: grid.nx,
111 ny: grid.ny,
112 });
114 const session = new ModelSession({
115 device, model, grid, gpu, recorder, scene: built, sceneModel: scene, params, cfl,
116 });
117 session.#applyDt();
118 gpu.setParams(params);
119 return session;
120 }
122 get scene(): Scene {
123 return this.#scene;
124 }
126 get sceneModel(): MScene {
127 return this.#sceneModel;
128 }
130 /** The pressure field's name — the first state field the model advances. */
131 get pressureName(): string {
132 return this.model.state[0];
133 }
135 get dt(): number {
136 return this.gpu.dt;
137 }
139 /** Fraction of the stability limit the timestep is taken at. */
140 get cfl(): number {
141 return this.#cfl;
142 }
144 /**
145 * Change the timestep, as a fraction of what the scheme is stable at.
146 *
147 * A fraction rather than a number of seconds, because the stable step
148 * follows from the grid spacing and the fastest speed in the scene: refine
149 * the grid or drop in a faster scatterer and a dt that was fine becomes a
150 * dt that diverges. This way the meaning of the setting survives both.
151 *
152 * Applied to the running simulation as it stands. Leapfrog carries two
153 * fields a timestep apart, so changing dt between steps is inconsistent by
154 * the size of the change; a small drag is a small transient, and a large
155 * jump is worth a Restart.
156 */
157 setCfl(cfl: number): void {
158 this.#cfl = cfl;
159 this.#applyDt();
160 this.gpu.setParams(this.#params);
161 // The trace is one sample per timestep, so a recording made at one dt
162 // cannot be spliced onto one made at another: it would be two different
163 // sample rates in the same buffer.
164 this.recorder.clear();
165 }
167 /** The largest timestep this scheme is stable at on this grid and medium. */
168 get dtLimit(): number {
169 return stableDt(this.grid.h, this.#scene.cmax, this.model.order, 1);
170 }
172 #applyDt(): void {
173 this.gpu.setDt(stableDt(this.grid.h, this.#scene.cmax, this.model.order, this.#cfl));
174 }
176 /**
177 * Swap the medium under a running model. It is data, not code, so this
178 * needs no recompile — but the timestep follows from it, and changing dt
179 * mid-run would leave the leapfrog's two histories half a step apart, so
180 * the caller is expected to reset afterwards.
181 */
182 setScene(sceneModel: MScene, params: Params, source?: string): void {
183 const built = Scene.create({
184 grid: this.grid,
185 source: source ?? sceneModel.source,
186 paramNames: sceneModel.params.map((p) => p.key),
187 params,
188 });
189 this.#scene = built;
190 this.#sceneModel = sceneModel;
191 this.gpu.uploadMedium(built);
192 this.#applyDt();
193 this.gpu.setParams(this.#params);
194 this.recorder.clear();
195 }
197 setParams(params: ModelParams): void {
198 this.#params = params;
199 this.gpu.setParams(params);
200 }
202 /** Run `init`: a silent grid at t = 0. */
203 reset(): void {
204 this.gpu.init();
205 this.recorder.clear();
206 this.t = 0;
207 this.steps = 0;
208 }
210 /** Put the microphone at the grid point nearest the given coordinates. */
211 setMic(x: number, y: number): void {
212 const { L, h } = this.grid;
213 this.recorder.setProbe((x + L / 2) / h - 0.5, (y + L / 2) / h - 0.5);
214 }
216 /** Advance `n` steps. Synchronous: records and submits, nothing read back.
217 * The microphone samples inside the same submission, once per step. */
218 step(n = 1): void {
219 this.gpu.step(n, (enc) => this.recorder.encode(enc));
220 this.t += n * this.dt;
221 this.steps += n;
222 }
224 /** Wait for the submitted steps to finish, without reading anything back. */
225 sync(): Promise<undefined> {
226 return this.device.queue.onSubmittedWorkDone();
227 }
229 /** Read a named field back to the CPU. */
230 read(name: string): Promise<Float32Array> {
231 return this.gpu.read(name);
232 }
234 describe(): { init: string[]; step: string[] } {
235 return this.gpu.describe();
236 }
238 destroy(): void {
239 this.recorder.destroy();
240 this.gpu.destroy();
241 }
242}