@swmmrs/swmmrs 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,406 +1,406 @@
1
- import { WorkerClient } from "./client.js";
2
- import { LifecycleError, WorkerError } from "./exceptions.js";
3
- import type { ModelTime, SimulationState } from "./enums.js";
4
- import { ObjectCollection, NodeCollection, LinkCollection, SubcatchmentCollection, RainGageCollection } from "./objects/collections.js";
5
- import { SimulationOptionsView, type SchedulePatch } from "./objects/options.js";
6
- import { Definition, Aquifer, SnowmeltParameterSet } from "./objects/definitions.js";
7
- import { AmmModel, type AmmAssignment } from "./objects/amm.js";
8
- import { UnitHydrograph, type RdiiAssignment } from "./objects/rtk.js";
9
- import { LidControl } from "./objects/lids.js";
10
- import type { CheckpointBundle } from "./scenarios.js";
11
- import { bytes, createWorker, runtimeInfo } from "./runtime.js";
12
- import type { NodeResults, LinkResults, SimulationStatistics } from "./snapshots.js";
13
- import type { FileContents, SimulationOptions, RunOptions, StepOptions, SimulationInfo, SimulationStatus, RunResults } from "./types.js";
14
-
15
-
16
- /**
17
- * One isolated project, worker pool, and in-memory file system. Open with
18
- * {@link open}, not the private constructor, and always await {@link close}.
19
- * Handles and collections share this owner's lifecycle. Only one iterator or
20
- * manual advancement may own the solver; result reads and allowed controls can
21
- * run between observations. Records already returned remain usable after close.
22
- */
23
- export class Simulation implements AsyncIterable<ModelTime>, AsyncDisposable {
24
- /** Configured-order node handles and aligned snapshot reads. */
25
- readonly nodes: NodeCollection;
26
- /** Configured-order link handles and aligned snapshot reads. */
27
- readonly links: LinkCollection;
28
- /** Configured-order subcatchment handles and aligned snapshot reads. */
29
- readonly subcatchments: SubcatchmentCollection;
30
- /** Configured-order rain-gage handles; no collection snapshot API. */
31
- readonly rainGages: RainGageCollection;
32
- /** Stable model-option view, distinct from worker creation options. */
33
- readonly options: SimulationOptionsView;
34
- /** Pollutant identities; definition editing is not exposed. */
35
- readonly pollutants: ObjectCollection<Definition>;
36
- /** Land-use identities. */
37
- readonly landUses: ObjectCollection<Definition>;
38
- /** Time-pattern identities. */
39
- readonly timePatterns: ObjectCollection<Definition>;
40
- /** Curve identities. */
41
- readonly curves: ObjectCollection<Definition>;
42
- /** Time-series identities; data editing is not exposed. */
43
- readonly timeSeries: ObjectCollection<Definition>;
44
- /** Control-rule identities. */
45
- readonly controls: ObjectCollection<Definition>;
46
- /** Transect identities. */
47
- readonly transects: ObjectCollection<Definition>;
48
- /** Editable aquifer definitions. */
49
- readonly aquifers: ObjectCollection<Aquifer>;
50
- /** Editable snowmelt parameter sets. */
51
- readonly snowmeltSets: ObjectCollection<SnowmeltParameterSet>;
52
- /** Custom cross-section shape identities. */
53
- readonly shapes: ObjectCollection<Definition>;
54
- /** Street identities. */
55
- readonly streets: ObjectCollection<Definition>;
56
- /** Inlet-design identities. */
57
- readonly inletDesigns: ObjectCollection<Definition>;
58
- /** Editable AMM definitions; node assignments are separate. */
59
- readonly ammModels: ObjectCollection<AmmModel>;
60
- /** Editable RTK definitions; RDII assignments are separate. */
61
- readonly unitHydrographs: ObjectCollection<UnitHydrograph>;
62
- /** Editable LID-control layers; unit placements belong to subcatchments. */
63
- readonly lidControls: ObjectCollection<LidControl>;
64
- readonly #client: WorkerClient;
65
- #iterating = false;
66
- #advancing = false;
67
- #closed = false;
68
- #terminateRequested = false;
69
-
70
- private constructor(client: WorkerClient, info: SimulationInfo) {
71
- this.#client = client;
72
- const assertOpen = () => client.assertOpen();
73
- this.nodes = NodeCollection.create(info.nodeIds, client.call, assertOpen);
74
- this.links = LinkCollection.create(info.linkIds, client.call, assertOpen);
75
- this.subcatchments = SubcatchmentCollection.create(info.subcatchmentIds, client.call, assertOpen);
76
- this.rainGages = RainGageCollection.create(info.rainGageIds, client.call, assertOpen);
77
- this.options = SimulationOptionsView.create(client.call);
78
- this.pollutants = new ObjectCollection(info.pollutantIds, id => Definition.create(id, client.call), assertOpen);
79
- this.landUses = new ObjectCollection(info.landUseIds, id => Definition.create(id, client.call), assertOpen);
80
- this.timePatterns = new ObjectCollection(info.timePatternIds, id => Definition.create(id, client.call), assertOpen);
81
- this.curves = new ObjectCollection(info.curveIds, id => Definition.create(id, client.call), assertOpen);
82
- this.timeSeries = new ObjectCollection(info.timeSeriesIds, id => Definition.create(id, client.call), assertOpen);
83
- this.controls = new ObjectCollection(info.controlIds, id => Definition.create(id, client.call), assertOpen);
84
- this.transects = new ObjectCollection(info.transectIds, id => Definition.create(id, client.call), assertOpen);
85
- this.aquifers = new ObjectCollection(info.aquiferIds, id => Aquifer.create(id, client.call), assertOpen);
86
- this.snowmeltSets = new ObjectCollection(info.snowmeltIds, id => SnowmeltParameterSet.create(id, client.call), assertOpen);
87
- this.shapes = new ObjectCollection(info.shapeIds, id => Definition.create(id, client.call), assertOpen);
88
- this.streets = new ObjectCollection(info.streetIds, id => Definition.create(id, client.call), assertOpen);
89
- this.inletDesigns = new ObjectCollection(info.inletDesignIds, id => Definition.create(id, client.call), assertOpen);
90
- this.ammModels = new ObjectCollection(info.ammModelIds, id => AmmModel.create(id, client.call), assertOpen);
91
- this.unitHydrographs = new ObjectCollection(info.unitHydrographIds, id => UnitHydrograph.create(id, client.call), assertOpen);
92
- this.lidControls = new ObjectCollection(info.lidControlIds, id => LidControl.create(id, client.call), assertOpen);
93
- }
94
-
95
- /** Parse an INP in a new independent worker.
96
- * @param input - INP contents, never a host path.
97
- * @param files - Supporting-file contents keyed by INP-relative paths; defaults to an empty record.
98
- * @param options - Worker URL and thread capacity; defaults to an empty record.
99
- * @returns An owner in `open`; close it when finished.
100
- * @throws Rejects malformed input, invalid thread capacity, unavailable runtime prerequisites, or solver parse failures.
101
- */
102
- static async open(input: FileContents, files: Readonly<Record<string, FileContents>> = {}, options: SimulationOptions = {}): Promise<Simulation> {
103
- const { maxThreads, defaultThreads, parallel } = await runtimeInfo();
104
- const threads = options.threads ?? defaultThreads;
105
- validateThreads(threads, maxThreads, parallel);
106
- const stagedFiles = Object.fromEntries(await Promise.all(
107
- Object.entries(files).map(async ([name, value]) => [name, await bytes(value)] as const),
108
- ));
109
- const inputBytes = await bytes(input);
110
- const client = new WorkerClient(await createWorker(options));
111
- try {
112
- const info = await client.call("open", inputBytes, stagedFiles, threads);
113
- return new Simulation(client, info);
114
- } catch (error) {
115
- client.stop(error instanceof Error ? error : new Error(String(error)));
116
- throw error;
117
- }
118
- }
119
-
120
- /** Restore a checkpoint in an independent worker without rerunning the model.
121
- * @param checkpoint - Complete bundle, including manifest sidecars and dependencies.
122
- * @param options - Worker creation options (the second argument); defaults to an empty record.
123
- * @returns An independent owner; no worker or mutable state is shared with the source.
124
- * @throws Rejects unsupported, corrupt, mismatched, or incomplete checkpoint bundles.
125
- */
126
- static async resume(checkpoint: CheckpointBundle, options: SimulationOptions = {}): Promise<Simulation> {
127
- const { maxThreads, defaultThreads, parallel } = await runtimeInfo();
128
- const threads = options.threads ?? defaultThreads;
129
- validateThreads(threads, maxThreads, parallel);
130
- const client = new WorkerClient(await createWorker(options));
131
- try {
132
- return new Simulation(client, await client.call("resumeCheckpoint", checkpoint, threads));
133
- } catch (error) {
134
- client.stop(error instanceof Error ? error : new Error(String(error)));
135
- throw error;
136
- }
137
- }
138
-
139
- /** Read native lifecycle state, including after cleanup.
140
- * @returns The current state, or `closed` after close completes.
141
- */
142
- getState(): Promise<SimulationState> { return this.#closed ? Promise.resolve("closed") : this.#client.call("state"); }
143
- /** Read model identities, units, solver build, effective threads, and schedule while healthy.
144
- * @returns Detached model information.
145
- */
146
- info(): Promise<SimulationInfo> { return this.#client.call("info"); }
147
- /** Read lifecycle and progress before close.
148
- * @returns Detached status; unavailable after cleanup.
149
- */
150
- status(): Promise<SimulationStatus> { return this.#client.call("status"); }
151
- /** Update calendar boundaries in `open` or `ended`; accepted edits return `ended` to `open`.
152
- * @param patch - Sparse timezone-free schedule. Omitted times retain their values.
153
- * @returns Resolves after the schedule update is accepted.
154
- */
155
- updateSchedule(patch: SchedulePatch): Promise<void> { return this.#advance("updateSchedule", () => this.#client.call("updateSchedule", patch)); }
156
- /** Read the complete AMM node-assignment list.
157
- * @returns Canonical node/model IDs and areas in project land-area units.
158
- */
159
- ammAssignments(): Promise<readonly AmmAssignment[]> { return this.#client.call("ammAssignments"); }
160
- /** Atomically replace all AMM assignments in `open` or `ended`; accepted edits return the owner to `open`.
161
- * @param assignments - Complete replacement list; an empty list clears all assignments.
162
- * @returns Resolves after all assignments are accepted.
163
- */
164
- replaceAmmAssignments(assignments: readonly AmmAssignment[]): Promise<void> { return this.#client.call("replaceAmmAssignments", assignments); }
165
- /** Read the complete RTK RDII assignment list.
166
- * @returns Canonical node/unit-hydrograph IDs and project land areas.
167
- */
168
- rdiiAssignments(): Promise<readonly RdiiAssignment[]> { return this.#client.call("rdiiAssignments"); }
169
- /** Atomically replace all RTK assignments in `open` or `ended`; accepted edits return the owner to `open`.
170
- * @param assignments - Complete replacement list; an empty list clears all assignments.
171
- * @returns Resolves after all assignments are accepted.
172
- */
173
- replaceRdiiAssignments(assignments: readonly RdiiAssignment[]): Promise<void> { return this.#client.call("replaceRdiiAssignments", assignments); }
174
-
175
- /** Configure hotstart input in `open` or `ended`; accepted edits return the owner to `open`.
176
- * @param input - Hotstart file contents, or null to clear the configured input.
177
- * @returns Resolves after the configuration write.
178
- */
179
- useHotstart(input: FileContents | null): Promise<void> {
180
- return this.#advance("useHotstart", async () => this.#client.call("useHotstart", input === null ? null : await bytes(input)));
181
- }
182
- /** Save current hydraulic state in `running` or `complete`.
183
- * @returns Caller-owned EPA hotstart bytes.
184
- */
185
- saveHotstart(): Promise<Uint8Array> { return this.#client.call("saveHotstart"); }
186
- /** Capture a quiescent `running` or `complete` owner, including validated dependencies.
187
- * @returns Complete portable checkpoint bundle with manifest sidecars.
188
- */
189
- saveCheckpoint(): Promise<CheckpointBundle> { return this.#client.call("exportCheckpoint"); }
190
- /** Stage physical and numerical checkpoint state in an `open` owner.
191
- * Declarations, files, and persistent forcings remain those of the receiver.
192
- * @param checkpoint - Compatible bundle with all required dependencies.
193
- * @returns Resolves with the owner still `open`; state is applied by its next start.
194
- * @throws Rejects incompatible identity, unsupported dependencies, corrupt manifests, or missing sidecars.
195
- */
196
- loadCheckpointState(checkpoint: CheckpointBundle): Promise<void> {
197
- return this.#advance("loadCheckpointState", () => this.#client.call("loadCheckpointState", checkpoint));
198
- }
199
- /** Save a checkpoint and resume it in a new independent owner.
200
- * @param options - Worker options for the child; defaults to an empty record.
201
- * @returns Independent simulation with copied run state and forcings.
202
- */
203
- async fork(options: SimulationOptions = {}): Promise<Simulation> {
204
- return Simulation.resume(await this.saveCheckpoint(), options);
205
- }
206
-
207
- /** Start an `open` or `ended` model.
208
- * @param options - Run options; `saveResults` defaults to true.
209
- * @returns Resolves after solver initialization succeeds.
210
- */
211
- start({ saveResults = true }: RunOptions = {}): Promise<void> {
212
- validateSaveResults(saveResults);
213
- return this.#advance("start", () => this.#client.call("start", saveResults));
214
- }
215
- /** Advance one routing step in a started run, without an active iterator.
216
- * @returns New model time, or null at natural completion.
217
- */
218
- step(): Promise<ModelTime | null> { return this.#advance("step", () => this.#client.call("step")); }
219
- /** Advance an observation interval in a started run.
220
- * @param seconds - Positive 32-bit integer interval in seconds.
221
- * @param strict - Defaults to true: land exactly on the boundary. False allows whole-step overshoot.
222
- * @returns New model time, or null at natural completion.
223
- */
224
- async stride(seconds: number, strict = true): Promise<ModelTime | null> {
225
- validateSeconds(seconds);
226
- if (typeof strict !== "boolean") throw new TypeError("strict must be a boolean");
227
- return this.#advance("stride", () => this.#client.call("stride", seconds, strict));
228
- }
229
-
230
- /** Automatic start and routing-step advancement; exhaustion retains final statistics.
231
- * @returns The same async generator as {@link steps} with default options.
232
- */
233
- [Symbol.asyncIterator](): AsyncGenerator<ModelTime, void, unknown> { return this.steps(); }
234
-
235
- /** Iterate observations, lazily starting `open` or `ended` owners on the first `next()`.
236
- * Early exit releases advancement ownership, leaving the run available to finish or resume.
237
- * @param options - Observation/run options; routing-step cadence, strict boundaries, and retained results by default.
238
- * @returns Generator of observation times. Natural exhaustion leaves final statistics available.
239
- * @throws Rejects invalid intervals/flags or competing advancement/finalization.
240
- */
241
- async *steps({ seconds, strict = true, saveResults = true }: StepOptions = {}): AsyncGenerator<ModelTime, void, unknown> {
242
- if (seconds !== undefined) validateSeconds(seconds);
243
- validateSaveResults(saveResults);
244
- if (typeof strict !== "boolean") throw new TypeError("strict must be a boolean");
245
- this.#assertManual("iterate");
246
- this.#iterating = true;
247
- try {
248
- const state = await this.#client.call("state");
249
- if (state === "open" || state === "ended") await this.#client.call("start", saveResults);
250
- if (state === "complete") return;
251
- while (true) {
252
- if (this.#terminateRequested) {
253
- await this.#client.call("end");
254
- return;
255
- }
256
- const time = seconds === undefined
257
- ? await this.#client.call("step")
258
- : await this.#client.call("stride", seconds, strict);
259
- if (time === null) return;
260
- yield time;
261
- }
262
- } finally {
263
- this.#iterating = false;
264
- this.#terminateRequested = false;
265
- }
266
- }
267
-
268
- /** End active iteration at its next observation boundary, without closing the owner.
269
- * @returns Immediately; the iterator performs the end operation at its next boundary.
270
- * @throws Synchronously throws LifecycleError if no iterator owns advancement.
271
- */
272
- terminate(): void {
273
- if (!this.#iterating) throw new LifecycleError({ message: "No active iterator owns advancement", operation: "terminate" });
274
- this.#terminateRequested = true;
275
- }
276
-
277
- #assertManual(operation: string): void {
278
- this.#client.assertOpen();
279
- if (this.#iterating) throw new LifecycleError({ message: "An active iterator owns simulation advancement", operation });
280
- if (this.#advancing) throw new LifecycleError({ message: "A simulation advancement is already pending", operation });
281
- }
282
-
283
- async #advance<T>(operation: string, execute: () => Promise<T>): Promise<T> {
284
- this.#assertManual(operation);
285
- this.#advancing = true;
286
- try { return await execute(); }
287
- finally { this.#advancing = false; }
288
- }
289
-
290
- /** Direct node result read retained for prototype callers; requires results to be available.
291
- * @param id - Node ID resolved in the worker, not by a local collection lookup.
292
- * @returns Detached node hydraulics; unknown IDs reject the promise.
293
- */
294
- node(id: string): Promise<NodeResults> { return this.#client.call("node", id); }
295
- /** Direct link result read retained for prototype callers.
296
- * @param id - Link ID resolved in the worker.
297
- * @returns Detached link hydraulics; unknown IDs reject the promise.
298
- */
299
- link(id: string): Promise<LinkResults> { return this.#client.call("link", id); }
300
- /** Direct equivalent of `nodes.get(id).setExternalInflow(flow)`.
301
- * @param id - Node ID resolved in the worker.
302
- * @param flow - Persistent additive inflow in project flow units.
303
- * @returns Resolves after updating the forcing in `open`, `running`, or `ended`.
304
- */
305
- setNodeExternalInflow(id: string, flow: number): Promise<void> { return this.#client.call("setNodeExternalInflow", id, flow); }
306
- /** Direct equivalent of `links.get(id).setTargetSetting(setting)`; requires `running`.
307
- * @param id - Link ID resolved in the worker.
308
- * @param setting - Dimensionless opening or pump speed factor.
309
- * @returns Resolves after updating the target; unknown IDs reject the promise.
310
- */
311
- setLinkTargetSetting(id: string, setting: number): Promise<void> { return this.#client.call("setLinkTargetSetting", id, setting); }
312
-
313
- /** Read system statistics in `running` or `complete`, before ending the run.
314
- * @returns Detached cumulative totals, continuity balances, and routing diagnostics.
315
- */
316
- statistics(): Promise<SimulationStatistics> { return this.#client.call("statistics"); }
317
- /** End the run, write requested reports, and finalize binary output; safe to repeat in `ended`.
318
- * Supports partial runs and retains the owner for result reads and reruns.
319
- * @returns Finalized report and caller-owned output bytes; output is empty with `saveResults: false`.
320
- */
321
- finish(): Promise<RunResults> { return this.#advance("finish", () => this.#client.call("finish")); }
322
- /** Start, run to completion, and finalize with one worker request.
323
- * @param options - Run options; `saveResults` defaults to true.
324
- * @returns Finalized files, retaining the owner in `ended` until closed or reused.
325
- */
326
- run({ saveResults = true }: RunOptions = {}): Promise<RunResults> {
327
- validateSaveResults(saveResults);
328
- return this.#advance("run", () => this.#client.call("run", saveResults));
329
- }
330
- /** End a `running` or `complete` run without detailed report tables.
331
- * @returns Resolves after flushing output/summary statistics and entering `ended`.
332
- */
333
- end(): Promise<void> { return this.#advance("end", () => this.#client.call("end")); }
334
- /** Append and flush only the runtime footer in `ended`, once, even without saved results.
335
- * JavaScript exposes this before close removes its in-memory files; Python report paths survive close.
336
- * @returns Resolves after the footer is flushed, without detailed report tables.
337
- */
338
- finalizeReport(): Promise<void> { return this.#advance("finalizeReport", () => this.#client.call("finalizeReport")); }
339
- /** Generate requested detailed report tables and footer in `ended`, once.
340
- * @returns Resolves after flushing the report.
341
- * @throws Rejects when the run did not retain binary results.
342
- */
343
- report(): Promise<void> { return this.#advance("report", () => this.#client.call("report")); }
344
- /** Discard run/results/output state in `open` or `ended` without reparsing the INP.
345
- * Retains declarations and persistent forcings; clears staged checkpoint state.
346
- * @returns Resolves with the owner in `open`.
347
- */
348
- resetSolver(): Promise<void> { return this.#advance("resetSolver", () => this.#client.call("resetSolver")); }
349
- /** Put active Dynamic Wave workers to sleep; requires `running`.
350
- * @returns Resolves after the power-state operation; lifecycle state is unchanged.
351
- */
352
- sleepWorkers(): Promise<void> { return this.#client.call("sleepWorkers"); }
353
- /** Copy a project or generated file before close; does not flush or finalize a run.
354
- * @param name - Path in the worker's project file system, not a host path.
355
- * @returns Caller-owned bytes. Call {@link finish} first for finalized report/output files.
356
- */
357
- readFile(name: string): Promise<Uint8Array> { return this.#client.call("readFile", name); }
358
-
359
- /** Release the project, workers, and in-memory files. Repeated calls share cleanup.
360
- * @returns Resolves when cleanup finishes; all owner-bound handles then become unusable.
361
- */
362
- async close(): Promise<void> {
363
- try { await this.#client.close(); }
364
- finally { this.#closed = true; }
365
- }
366
- /** Await resource cleanup for `await using`.
367
- * @returns The same cleanup promise as {@link close}.
368
- */
369
- [Symbol.asyncDispose](): Promise<void> { return this.close(); }
370
- }
371
-
372
- function validateSeconds(seconds: number): void {
373
- if (!Number.isInteger(seconds) || seconds < 1 || seconds > 2_147_483_647) throw new RangeError("seconds must be a positive 32-bit integer");
374
- }
375
-
376
- function validateSaveResults(value: boolean): void {
377
- if (typeof value !== "boolean") throw new TypeError("saveResults must be a boolean");
378
- }
379
-
380
- function validateThreads(threads: number, maximum: number, parallel: boolean): void {
381
- if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be an integer between 1 and ${maximum}`);
382
- if (threads > 1 && !parallel) throw new WorkerError({ message: "Multiple threads require cross-origin isolation with COOP/COEP headers; use threads: 1 for serial execution", operation: "open" });
383
- if (threads > maximum) throw new RangeError(`threads must be an integer between 1 and ${maximum}`);
384
- }
385
-
386
- /**
387
- * Open, run, finalize, and close one INP model. Also exported as the package default.
388
- * @param input - INP contents, never a host path.
389
- * @param files - Supporting-file contents keyed by INP-relative paths; defaults to an empty record.
390
- * @param options - Worker and run options; retained results by default.
391
- * @returns Finalized report and caller-owned binary output after cleanup.
392
- * @throws Preserves the run error if cleanup also fails, attaching the secondary failure as `cleanupError`.
393
- */
394
- export async function runSwmm(input: FileContents, files: Readonly<Record<string, FileContents>> = {}, options: SimulationOptions & RunOptions = {}): Promise<RunResults> {
395
- const simulation = await Simulation.open(input, files, options);
396
- let failure: unknown;
397
- try { return await simulation.run(options); }
398
- catch (error) { failure = error; throw error; }
399
- finally {
400
- try { await simulation.close(); }
401
- catch (cleanupError) {
402
- if (failure === undefined) throw cleanupError;
403
- if (failure instanceof Error) Object.assign(failure, { cleanupError });
404
- }
405
- }
406
- }
1
+ import { WorkerClient } from "./client.js";
2
+ import { LifecycleError, WorkerError } from "./exceptions.js";
3
+ import type { ModelTime, SimulationState } from "./enums.js";
4
+ import { ObjectCollection, NodeCollection, LinkCollection, SubcatchmentCollection, RainGageCollection } from "./objects/collections.js";
5
+ import { SimulationOptionsView, type SchedulePatch } from "./objects/options.js";
6
+ import { Definition, Aquifer, SnowmeltParameterSet } from "./objects/definitions.js";
7
+ import { AmmModel, type AmmAssignment } from "./objects/amm.js";
8
+ import { UnitHydrograph, type RdiiAssignment } from "./objects/rtk.js";
9
+ import { LidControl } from "./objects/lids.js";
10
+ import type { CheckpointBundle } from "./scenarios.js";
11
+ import { bytes, createWorker, runtimeInfo } from "./runtime.js";
12
+ import type { NodeResults, LinkResults, SimulationStatistics } from "./snapshots.js";
13
+ import type { FileContents, SimulationOptions, RunOptions, StepOptions, SimulationInfo, SimulationStatus, RunResults } from "./types.js";
14
+
15
+
16
+ /**
17
+ * One isolated project, worker pool, and in-memory file system. Open with
18
+ * {@link open}, not the private constructor, and always await {@link close}.
19
+ * Handles and collections share this owner's lifecycle. Only one iterator or
20
+ * manual advancement may own the solver; result reads and allowed controls can
21
+ * run between observations. Records already returned remain usable after close.
22
+ */
23
+ export class Simulation implements AsyncIterable<ModelTime>, AsyncDisposable {
24
+ /** Configured-order node handles and aligned snapshot reads. */
25
+ readonly nodes: NodeCollection;
26
+ /** Configured-order link handles and aligned snapshot reads. */
27
+ readonly links: LinkCollection;
28
+ /** Configured-order subcatchment handles and aligned snapshot reads. */
29
+ readonly subcatchments: SubcatchmentCollection;
30
+ /** Configured-order rain-gage handles; no collection snapshot API. */
31
+ readonly rainGages: RainGageCollection;
32
+ /** Stable model-option view, distinct from worker creation options. */
33
+ readonly options: SimulationOptionsView;
34
+ /** Pollutant identities; definition editing is not exposed. */
35
+ readonly pollutants: ObjectCollection<Definition>;
36
+ /** Land-use identities. */
37
+ readonly landUses: ObjectCollection<Definition>;
38
+ /** Time-pattern identities. */
39
+ readonly timePatterns: ObjectCollection<Definition>;
40
+ /** Curve identities. */
41
+ readonly curves: ObjectCollection<Definition>;
42
+ /** Time-series identities; data editing is not exposed. */
43
+ readonly timeSeries: ObjectCollection<Definition>;
44
+ /** Control-rule identities. */
45
+ readonly controls: ObjectCollection<Definition>;
46
+ /** Transect identities. */
47
+ readonly transects: ObjectCollection<Definition>;
48
+ /** Editable aquifer definitions. */
49
+ readonly aquifers: ObjectCollection<Aquifer>;
50
+ /** Editable snowmelt parameter sets. */
51
+ readonly snowmeltSets: ObjectCollection<SnowmeltParameterSet>;
52
+ /** Custom cross-section shape identities. */
53
+ readonly shapes: ObjectCollection<Definition>;
54
+ /** Street identities. */
55
+ readonly streets: ObjectCollection<Definition>;
56
+ /** Inlet-design identities. */
57
+ readonly inletDesigns: ObjectCollection<Definition>;
58
+ /** Editable AMM definitions; node assignments are separate. */
59
+ readonly ammModels: ObjectCollection<AmmModel>;
60
+ /** Editable RTK definitions; RDII assignments are separate. */
61
+ readonly unitHydrographs: ObjectCollection<UnitHydrograph>;
62
+ /** Editable LID-control layers; unit placements belong to subcatchments. */
63
+ readonly lidControls: ObjectCollection<LidControl>;
64
+ readonly #client: WorkerClient;
65
+ #iterating = false;
66
+ #advancing = false;
67
+ #closed = false;
68
+ #terminateRequested = false;
69
+
70
+ private constructor(client: WorkerClient, info: SimulationInfo) {
71
+ this.#client = client;
72
+ const assertOpen = () => client.assertOpen();
73
+ this.nodes = NodeCollection.create(info.nodeIds, client.call, assertOpen);
74
+ this.links = LinkCollection.create(info.linkIds, client.call, assertOpen);
75
+ this.subcatchments = SubcatchmentCollection.create(info.subcatchmentIds, client.call, assertOpen);
76
+ this.rainGages = RainGageCollection.create(info.rainGageIds, client.call, assertOpen);
77
+ this.options = SimulationOptionsView.create(client.call);
78
+ this.pollutants = new ObjectCollection(info.pollutantIds, id => Definition.create(id, client.call), assertOpen);
79
+ this.landUses = new ObjectCollection(info.landUseIds, id => Definition.create(id, client.call), assertOpen);
80
+ this.timePatterns = new ObjectCollection(info.timePatternIds, id => Definition.create(id, client.call), assertOpen);
81
+ this.curves = new ObjectCollection(info.curveIds, id => Definition.create(id, client.call), assertOpen);
82
+ this.timeSeries = new ObjectCollection(info.timeSeriesIds, id => Definition.create(id, client.call), assertOpen);
83
+ this.controls = new ObjectCollection(info.controlIds, id => Definition.create(id, client.call), assertOpen);
84
+ this.transects = new ObjectCollection(info.transectIds, id => Definition.create(id, client.call), assertOpen);
85
+ this.aquifers = new ObjectCollection(info.aquiferIds, id => Aquifer.create(id, client.call), assertOpen);
86
+ this.snowmeltSets = new ObjectCollection(info.snowmeltIds, id => SnowmeltParameterSet.create(id, client.call), assertOpen);
87
+ this.shapes = new ObjectCollection(info.shapeIds, id => Definition.create(id, client.call), assertOpen);
88
+ this.streets = new ObjectCollection(info.streetIds, id => Definition.create(id, client.call), assertOpen);
89
+ this.inletDesigns = new ObjectCollection(info.inletDesignIds, id => Definition.create(id, client.call), assertOpen);
90
+ this.ammModels = new ObjectCollection(info.ammModelIds, id => AmmModel.create(id, client.call), assertOpen);
91
+ this.unitHydrographs = new ObjectCollection(info.unitHydrographIds, id => UnitHydrograph.create(id, client.call), assertOpen);
92
+ this.lidControls = new ObjectCollection(info.lidControlIds, id => LidControl.create(id, client.call), assertOpen);
93
+ }
94
+
95
+ /** Parse an INP in a new independent worker.
96
+ * @param input - INP contents, never a host path.
97
+ * @param files - Supporting-file contents keyed by INP-relative paths; defaults to an empty record.
98
+ * @param options - Worker URL and thread capacity; defaults to an empty record.
99
+ * @returns An owner in `open`; close it when finished.
100
+ * @throws Rejects malformed input, invalid thread capacity, unavailable runtime prerequisites, or solver parse failures.
101
+ */
102
+ static async open(input: FileContents, files: Readonly<Record<string, FileContents>> = {}, options: SimulationOptions = {}): Promise<Simulation> {
103
+ const { maxThreads, defaultThreads, parallel } = await runtimeInfo();
104
+ const threads = options.threads ?? defaultThreads;
105
+ validateThreads(threads, maxThreads, parallel);
106
+ const stagedFiles = Object.fromEntries(await Promise.all(
107
+ Object.entries(files).map(async ([name, value]) => [name, await bytes(value)] as const),
108
+ ));
109
+ const inputBytes = await bytes(input);
110
+ const client = new WorkerClient(await createWorker(options));
111
+ try {
112
+ const info = await client.call("open", inputBytes, stagedFiles, threads);
113
+ return new Simulation(client, info);
114
+ } catch (error) {
115
+ client.stop(error instanceof Error ? error : new Error(String(error)));
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ /** Restore a checkpoint in an independent worker without rerunning the model.
121
+ * @param checkpoint - Complete bundle, including manifest sidecars and dependencies.
122
+ * @param options - Worker creation options (the second argument); defaults to an empty record.
123
+ * @returns An independent owner; no worker or mutable state is shared with the source.
124
+ * @throws Rejects unsupported, corrupt, mismatched, or incomplete checkpoint bundles.
125
+ */
126
+ static async resume(checkpoint: CheckpointBundle, options: SimulationOptions = {}): Promise<Simulation> {
127
+ const { maxThreads, defaultThreads, parallel } = await runtimeInfo();
128
+ const threads = options.threads ?? defaultThreads;
129
+ validateThreads(threads, maxThreads, parallel);
130
+ const client = new WorkerClient(await createWorker(options));
131
+ try {
132
+ return new Simulation(client, await client.call("resumeCheckpoint", checkpoint, threads));
133
+ } catch (error) {
134
+ client.stop(error instanceof Error ? error : new Error(String(error)));
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ /** Read native lifecycle state, including after cleanup.
140
+ * @returns The current state, or `closed` after close completes.
141
+ */
142
+ getState(): Promise<SimulationState> { return this.#closed ? Promise.resolve("closed") : this.#client.call("state"); }
143
+ /** Read model identities, units, solver build, effective threads, and schedule while healthy.
144
+ * @returns Detached model information.
145
+ */
146
+ info(): Promise<SimulationInfo> { return this.#client.call("info"); }
147
+ /** Read lifecycle and progress before close.
148
+ * @returns Detached status; unavailable after cleanup.
149
+ */
150
+ status(): Promise<SimulationStatus> { return this.#client.call("status"); }
151
+ /** Update calendar boundaries in `open` or `ended`; accepted edits return `ended` to `open`.
152
+ * @param patch - Sparse timezone-free schedule. Omitted times retain their values.
153
+ * @returns Resolves after the schedule update is accepted.
154
+ */
155
+ updateSchedule(patch: SchedulePatch): Promise<void> { return this.#advance("updateSchedule", () => this.#client.call("updateSchedule", patch)); }
156
+ /** Read the complete AMM node-assignment list.
157
+ * @returns Canonical node/model IDs and areas in project land-area units.
158
+ */
159
+ ammAssignments(): Promise<readonly AmmAssignment[]> { return this.#client.call("ammAssignments"); }
160
+ /** Atomically replace all AMM assignments in `open` or `ended`; accepted edits return the owner to `open`.
161
+ * @param assignments - Complete replacement list; an empty list clears all assignments.
162
+ * @returns Resolves after all assignments are accepted.
163
+ */
164
+ replaceAmmAssignments(assignments: readonly AmmAssignment[]): Promise<void> { return this.#client.call("replaceAmmAssignments", assignments); }
165
+ /** Read the complete RTK RDII assignment list.
166
+ * @returns Canonical node/unit-hydrograph IDs and project land areas.
167
+ */
168
+ rdiiAssignments(): Promise<readonly RdiiAssignment[]> { return this.#client.call("rdiiAssignments"); }
169
+ /** Atomically replace all RTK assignments in `open` or `ended`; accepted edits return the owner to `open`.
170
+ * @param assignments - Complete replacement list; an empty list clears all assignments.
171
+ * @returns Resolves after all assignments are accepted.
172
+ */
173
+ replaceRdiiAssignments(assignments: readonly RdiiAssignment[]): Promise<void> { return this.#client.call("replaceRdiiAssignments", assignments); }
174
+
175
+ /** Configure hotstart input in `open` or `ended`; accepted edits return the owner to `open`.
176
+ * @param input - Hotstart file contents, or null to clear the configured input.
177
+ * @returns Resolves after the configuration write.
178
+ */
179
+ useHotstart(input: FileContents | null): Promise<void> {
180
+ return this.#advance("useHotstart", async () => this.#client.call("useHotstart", input === null ? null : await bytes(input)));
181
+ }
182
+ /** Save current hydraulic state in `running` or `complete`.
183
+ * @returns Caller-owned EPA hotstart bytes.
184
+ */
185
+ saveHotstart(): Promise<Uint8Array> { return this.#client.call("saveHotstart"); }
186
+ /** Capture a quiescent `running` or `complete` owner, including validated dependencies.
187
+ * @returns Complete portable checkpoint bundle with manifest sidecars.
188
+ */
189
+ saveCheckpoint(): Promise<CheckpointBundle> { return this.#client.call("exportCheckpoint"); }
190
+ /** Stage physical and numerical checkpoint state in an `open` owner.
191
+ * Declarations, files, and persistent forcings remain those of the receiver.
192
+ * @param checkpoint - Compatible bundle with all required dependencies.
193
+ * @returns Resolves with the owner still `open`; state is applied by its next start.
194
+ * @throws Rejects incompatible identity, unsupported dependencies, corrupt manifests, or missing sidecars.
195
+ */
196
+ loadCheckpointState(checkpoint: CheckpointBundle): Promise<void> {
197
+ return this.#advance("loadCheckpointState", () => this.#client.call("loadCheckpointState", checkpoint));
198
+ }
199
+ /** Save a checkpoint and resume it in a new independent owner.
200
+ * @param options - Worker options for the child; defaults to an empty record.
201
+ * @returns Independent simulation with copied run state and forcings.
202
+ */
203
+ async fork(options: SimulationOptions = {}): Promise<Simulation> {
204
+ return Simulation.resume(await this.saveCheckpoint(), options);
205
+ }
206
+
207
+ /** Start an `open` or `ended` model.
208
+ * @param options - Run options; `saveResults` defaults to true.
209
+ * @returns Resolves after solver initialization succeeds.
210
+ */
211
+ start({ saveResults = true }: RunOptions = {}): Promise<void> {
212
+ validateSaveResults(saveResults);
213
+ return this.#advance("start", () => this.#client.call("start", saveResults));
214
+ }
215
+ /** Advance one routing step in a started run, without an active iterator.
216
+ * @returns New model time, or null at natural completion.
217
+ */
218
+ step(): Promise<ModelTime | null> { return this.#advance("step", () => this.#client.call("step")); }
219
+ /** Advance an observation interval in a started run.
220
+ * @param seconds - Positive 32-bit integer interval in seconds.
221
+ * @param strict - Defaults to true: land exactly on the boundary. False allows whole-step overshoot.
222
+ * @returns New model time, or null at natural completion.
223
+ */
224
+ async stride(seconds: number, strict = true): Promise<ModelTime | null> {
225
+ validateSeconds(seconds);
226
+ if (typeof strict !== "boolean") throw new TypeError("strict must be a boolean");
227
+ return this.#advance("stride", () => this.#client.call("stride", seconds, strict));
228
+ }
229
+
230
+ /** Automatic start and routing-step advancement; exhaustion retains final statistics.
231
+ * @returns The same async generator as {@link steps} with default options.
232
+ */
233
+ [Symbol.asyncIterator](): AsyncGenerator<ModelTime, void, unknown> { return this.steps(); }
234
+
235
+ /** Iterate observations, lazily starting `open` or `ended` owners on the first `next()`.
236
+ * Early exit releases advancement ownership, leaving the run available to finish or resume.
237
+ * @param options - Observation/run options; routing-step cadence, strict boundaries, and retained results by default.
238
+ * @returns Generator of observation times. Natural exhaustion leaves final statistics available.
239
+ * @throws Rejects invalid intervals/flags or competing advancement/finalization.
240
+ */
241
+ async *steps({ seconds, strict = true, saveResults = true }: StepOptions = {}): AsyncGenerator<ModelTime, void, unknown> {
242
+ if (seconds !== undefined) validateSeconds(seconds);
243
+ validateSaveResults(saveResults);
244
+ if (typeof strict !== "boolean") throw new TypeError("strict must be a boolean");
245
+ this.#assertManual("iterate");
246
+ this.#iterating = true;
247
+ try {
248
+ const state = await this.#client.call("state");
249
+ if (state === "open" || state === "ended") await this.#client.call("start", saveResults);
250
+ if (state === "complete") return;
251
+ while (true) {
252
+ if (this.#terminateRequested) {
253
+ await this.#client.call("end");
254
+ return;
255
+ }
256
+ const time = seconds === undefined
257
+ ? await this.#client.call("step")
258
+ : await this.#client.call("stride", seconds, strict);
259
+ if (time === null) return;
260
+ yield time;
261
+ }
262
+ } finally {
263
+ this.#iterating = false;
264
+ this.#terminateRequested = false;
265
+ }
266
+ }
267
+
268
+ /** End active iteration at its next observation boundary, without closing the owner.
269
+ * @returns Immediately; the iterator performs the end operation at its next boundary.
270
+ * @throws Synchronously throws LifecycleError if no iterator owns advancement.
271
+ */
272
+ terminate(): void {
273
+ if (!this.#iterating) throw new LifecycleError({ message: "No active iterator owns advancement", operation: "terminate" });
274
+ this.#terminateRequested = true;
275
+ }
276
+
277
+ #assertManual(operation: string): void {
278
+ this.#client.assertOpen();
279
+ if (this.#iterating) throw new LifecycleError({ message: "An active iterator owns simulation advancement", operation });
280
+ if (this.#advancing) throw new LifecycleError({ message: "A simulation advancement is already pending", operation });
281
+ }
282
+
283
+ async #advance<T>(operation: string, execute: () => Promise<T>): Promise<T> {
284
+ this.#assertManual(operation);
285
+ this.#advancing = true;
286
+ try { return await execute(); }
287
+ finally { this.#advancing = false; }
288
+ }
289
+
290
+ /** Direct node result read retained for prototype callers; requires results to be available.
291
+ * @param id - Node ID resolved in the worker, not by a local collection lookup.
292
+ * @returns Detached node hydraulics; unknown IDs reject the promise.
293
+ */
294
+ node(id: string): Promise<NodeResults> { return this.#client.call("node", id); }
295
+ /** Direct link result read retained for prototype callers.
296
+ * @param id - Link ID resolved in the worker.
297
+ * @returns Detached link hydraulics; unknown IDs reject the promise.
298
+ */
299
+ link(id: string): Promise<LinkResults> { return this.#client.call("link", id); }
300
+ /** Direct equivalent of `nodes.get(id).setExternalInflow(flow)`.
301
+ * @param id - Node ID resolved in the worker.
302
+ * @param flow - Persistent additive inflow in project flow units.
303
+ * @returns Resolves after updating the forcing in `open`, `running`, or `ended`.
304
+ */
305
+ setNodeExternalInflow(id: string, flow: number): Promise<void> { return this.#client.call("setNodeExternalInflow", id, flow); }
306
+ /** Direct equivalent of `links.get(id).setTargetSetting(setting)`; requires `running`.
307
+ * @param id - Link ID resolved in the worker.
308
+ * @param setting - Dimensionless opening or pump speed factor.
309
+ * @returns Resolves after updating the target; unknown IDs reject the promise.
310
+ */
311
+ setLinkTargetSetting(id: string, setting: number): Promise<void> { return this.#client.call("setLinkTargetSetting", id, setting); }
312
+
313
+ /** Read system statistics in `running` or `complete`, before ending the run.
314
+ * @returns Detached cumulative totals, continuity balances, and routing diagnostics.
315
+ */
316
+ statistics(): Promise<SimulationStatistics> { return this.#client.call("statistics"); }
317
+ /** End the run, write requested reports, and finalize binary output; safe to repeat in `ended`.
318
+ * Supports partial runs and retains the owner for result reads and reruns.
319
+ * @returns Finalized report and caller-owned output bytes; output is empty with `saveResults: false`.
320
+ */
321
+ finish(): Promise<RunResults> { return this.#advance("finish", () => this.#client.call("finish")); }
322
+ /** Start, run to completion, and finalize with one worker request.
323
+ * @param options - Run options; `saveResults` defaults to true.
324
+ * @returns Finalized files, retaining the owner in `ended` until closed or reused.
325
+ */
326
+ run({ saveResults = true }: RunOptions = {}): Promise<RunResults> {
327
+ validateSaveResults(saveResults);
328
+ return this.#advance("run", () => this.#client.call("run", saveResults));
329
+ }
330
+ /** End a `running` or `complete` run without detailed report tables.
331
+ * @returns Resolves after flushing output/summary statistics and entering `ended`.
332
+ */
333
+ end(): Promise<void> { return this.#advance("end", () => this.#client.call("end")); }
334
+ /** Append and flush only the runtime footer in `ended`, once, even without saved results.
335
+ * JavaScript exposes this before close removes its in-memory files; Python report paths survive close.
336
+ * @returns Resolves after the footer is flushed, without detailed report tables.
337
+ */
338
+ finalizeReport(): Promise<void> { return this.#advance("finalizeReport", () => this.#client.call("finalizeReport")); }
339
+ /** Generate requested detailed report tables and footer in `ended`, once.
340
+ * @returns Resolves after flushing the report.
341
+ * @throws Rejects when the run did not retain binary results.
342
+ */
343
+ report(): Promise<void> { return this.#advance("report", () => this.#client.call("report")); }
344
+ /** Discard run/results/output state in `open` or `ended` without reparsing the INP.
345
+ * Retains declarations and persistent forcings; clears staged checkpoint state.
346
+ * @returns Resolves with the owner in `open`.
347
+ */
348
+ resetSolver(): Promise<void> { return this.#advance("resetSolver", () => this.#client.call("resetSolver")); }
349
+ /** Put active Dynamic Wave workers to sleep; requires `running`.
350
+ * @returns Resolves after the power-state operation; lifecycle state is unchanged.
351
+ */
352
+ sleepWorkers(): Promise<void> { return this.#client.call("sleepWorkers"); }
353
+ /** Copy a project or generated file before close; does not flush or finalize a run.
354
+ * @param name - Path in the worker's project file system, not a host path.
355
+ * @returns Caller-owned bytes. Call {@link finish} first for finalized report/output files.
356
+ */
357
+ readFile(name: string): Promise<Uint8Array> { return this.#client.call("readFile", name); }
358
+
359
+ /** Release the project, workers, and in-memory files. Repeated calls share cleanup.
360
+ * @returns Resolves when cleanup finishes; all owner-bound handles then become unusable.
361
+ */
362
+ async close(): Promise<void> {
363
+ try { await this.#client.close(); }
364
+ finally { this.#closed = true; }
365
+ }
366
+ /** Await resource cleanup for `await using`.
367
+ * @returns The same cleanup promise as {@link close}.
368
+ */
369
+ [Symbol.asyncDispose](): Promise<void> { return this.close(); }
370
+ }
371
+
372
+ function validateSeconds(seconds: number): void {
373
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 2_147_483_647) throw new RangeError("seconds must be a positive 32-bit integer");
374
+ }
375
+
376
+ function validateSaveResults(value: boolean): void {
377
+ if (typeof value !== "boolean") throw new TypeError("saveResults must be a boolean");
378
+ }
379
+
380
+ function validateThreads(threads: number, maximum: number, parallel: boolean): void {
381
+ if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be an integer between 1 and ${maximum}`);
382
+ if (threads > 1 && !parallel) throw new WorkerError({ message: "Multiple threads require cross-origin isolation with COOP/COEP headers; use threads: 1 for serial execution", operation: "open" });
383
+ if (threads > maximum) throw new RangeError(`threads must be an integer between 1 and ${maximum}`);
384
+ }
385
+
386
+ /**
387
+ * Open, run, finalize, and close one INP model. Also exported as the package default.
388
+ * @param input - INP contents, never a host path.
389
+ * @param files - Supporting-file contents keyed by INP-relative paths; defaults to an empty record.
390
+ * @param options - Worker and run options; retained results by default.
391
+ * @returns Finalized report and caller-owned binary output after cleanup.
392
+ * @throws Preserves the run error if cleanup also fails, attaching the secondary failure as `cleanupError`.
393
+ */
394
+ export async function runSwmm(input: FileContents, files: Readonly<Record<string, FileContents>> = {}, options: SimulationOptions & RunOptions = {}): Promise<RunResults> {
395
+ const simulation = await Simulation.open(input, files, options);
396
+ let failure: unknown;
397
+ try { return await simulation.run(options); }
398
+ catch (error) { failure = error; throw error; }
399
+ finally {
400
+ try { await simulation.close(); }
401
+ catch (cleanupError) {
402
+ if (failure === undefined) throw cleanupError;
403
+ if (failure instanceof Error) Object.assign(failure, { cleanupError });
404
+ }
405
+ }
406
+ }