# epanet-js scripting API API version 1. > Sandboxed JavaScript scripting for epanet-js, a browser-based water network modelling app built on the EPANET engine. A script reads the open network — assets, patterns, curves, controls, the pipe library, settings — queries it spatially and topologically, reads the project's zones, runs the app's own review checks, reads the last simulation's results, exports it as INP, and hands results back as the map selection, a zoom, console output and attached files. This version is read-only: scripts cannot edit the model or run a simulation. This is the complete surface a script sees. Everything below is injected into the sandbox; nothing else exists. ## How a script runs - **Globals:** `network`, `zones`, `selection`, `map`, `console`, `ui`, `collections`. There is no `fetch`, DOM, `require`/`import`, timers (`setTimeout`), `structuredClone` or network access. - **Standard JavaScript works:** `Array`, `Map`, `Set`, `JSON`, `Math`, `Number`, `String`, template strings, destructuring, `async`/`await` (top-level `await` is allowed). - **The sandbox is SES (hardened JavaScript).** `Date.now()`, `new Date()` with no arguments and `Math.random()` throw. `new Date(milliseconds)` and date arithmetic on given values work. Do not reach for the current time or randomness. - **Scripts run top to bottom, once.** Every call is synchronous. The script's output — `selection.set`, `map.zoomTo`, `ui.attachFile`, `collections.saveSelectionSet`, `collections.saveBookmark` — is applied when the script ends successfully. If the script throws, the error is shown with its line number and none of that output is applied (console lines already printed stay visible). - **Read-only.** Every value a script receives is frozen. Assigning to an asset field throws (`Cannot set property diameter … which has only a getter`). Lists a member returns directly (`network.pipes`, `selection.assets`, `network.findWithin(...)`, `network.findSubnetworks()`) are fresh arrays the script may sort or push into. Arrays inside a returned object or an asset (`network.trace(...).nodes`, `finding.assets`, `junction.links`, `pipe.vertices`) are frozen: copy them first (`[...zone.nodes].sort(...)`). - **Units:** every number is in the **project's units**. `network.units` maps each quantity to its unit (`network.units.diameter === "mm"`, `network.units.length === "m"`, `network.units.flow === "l/s"`). A unit of `null` means the quantity is dimensionless or depends on the model (e.g. roughness). - **Labels:** assets are addressed by **label**, the name shown in the app and written to INP files. Labels are unique, case-insensitively, within the node group (junctions, reservoirs, tanks), within the link group (pipes, pumps, valves) and within customer points. A node and a link may share a label. - **Coordinates** are `[lng, lat]` positions; a bounding box is `[minLng, minLat, maxLng, maxLat]`. - **Inactive assets** (`asset.isActive === false`) are in the lists and lookups but are left out of the simulation, the INP and the review checks. **`Position`** — A point on the map. ```ts type Position = [lng: number, lat: number]; ``` **`BBox`** — A bounding box. ```ts type BBox = [minLng: number, minLat: number, maxLng: number, maxLat: number]; ``` ## 1. `network` — the model ### 1.1 Overview Start the first script on an unfamiliar model with `console.log(network.stats)` and `console.log(network.units)`. | Property | Type | Description | | --- | --- | --- | | `network.name` | `string` | The project's name — use it for report titles and file names. | | `network.project` | `ProjectInfo` | The open project: name, file, source, units and headloss formula. | | `network.stats` | `NetworkStats` | Counts per asset type and the network's extent. | | `network.units` | `Units` | Quantity → unit for every value the API hands out. | | `network.simulation` | `SimulationSettings` | The simulation settings the next run would use. | **`ProjectInfo`** — The open project. `source` is where it came from: a saved project, an imported INP, or a new one. ```ts interface ProjectInfo { name: string; fileName: string | null; source: "project" | "inp" | "new"; isDemo: boolean; unitSystem: string; headlossFormula: "H-W" | "D-W" | "C-M"; } ``` **`NetworkStats`** — Counts per type and the network's extent. ```ts interface NetworkStats { junctions: number; reservoirs: number; tanks: number; pipes: number; pumps: number; valves: number; customerPoints: number; bounds: BBox | null; } ``` **`Units`** — Quantity → unit for every value the API hands out. ```ts type Units = Readonly>; ``` The keys: `diameter` (pipes, valves), `tankDiameter` (tanks), `length`, `elevation`, `head`, `pressure`, `flow`, `velocity`, `headloss`, `unitHeadloss`, `baseDemand`, `directDemand`, `actualDemand`, `customerDemand`, `customerDemandPerDay`, `initialLevel`, `minLevel`, `maxLevel`, `level`, `minVolume`, `volume`, `tankArea`, `area` (zones; m² or ft² by the length unit), `power`, `speed`, `efficiency`, `roughness`, `minorLoss`, `emitterCoefficient`, `mixingFraction`, `tcvSetting`, `averageKwPerFlowUnit`, `waterAge`, `waterTrace`, `chemicalConcentration`, `netFlow`, `actualHead`, `status`. Mind the mapping: a tank's `diameter` is in `units.tankDiameter`, a pipe's in `units.diameter`. **`SimulationSettings`** — The simulation settings the next run would use, with unset hydraulic options resolved to what EPANET uses. ```ts interface SimulationSettings { version: string; duration: number; hydraulicTimestep: number; reportTimestep: number; patternTimestep: number; qualityTimestep: number | null; ruleTimestep: number | null; globalDemandMultiplier: number; demandModel: "DDA" | "PDA"; minimumPressure: number; requiredPressure: number; pressureExponent: number; emitterExponent: number; backflowAllowed: boolean; trials: number; accuracy: number; unbalancedMode: "STOP" | "CONTINUE"; unbalancedExtraTrials: number; headError: number; flowChange: number; checkFreq: number; maxCheck: number; dampLimit: number; viscosity: number; specificGravity: number; quality: "none" | "chemical" | "age" | "trace"; qualityChemicalName: string; qualityMassUnit: "mg/L" | "ug/L"; qualityTraceNode: string | null; tolerance: number; diffusivity: number; reactionBulkOrder: number; reactionWallOrder: number; reactionTankOrder: number; reactionGlobalBulk: number; reactionGlobalWall: number; reactionLimitingPotential: number; reactionRoughnessCorrelation: number; reportEnergy: boolean; energyGlobalEfficiency: number; energyGlobalPrice: number; energyGlobalPattern: string | null; energyDemandCharge: number; statusReport: "YES" | "NO" | "FULL"; } ``` Times are in seconds. `qualityTraceNode` is a node label and `energyGlobalPattern` a pattern label. Scripts cannot run a simulation; `network.results` reads the one the user last ran. ### 1.2 Lists and lookup | Property | Type | Description | | --- | --- | --- | | `network.junctions` | `Junction[]` | Every junction. | | `network.reservoirs` | `Reservoir[]` | Every reservoir. | | `network.tanks` | `Tank[]` | Every tank. | | `network.pipes` | `Pipe[]` | Every pipe. | | `network.pumps` | `Pump[]` | Every pump. | | `network.valves` | `Valve[]` | Every valve. | | `network.nodes` | `NodeAsset[]` | Every junction, reservoir and tank. | | `network.links` | `LinkAsset[]` | Every pipe, pump and valve. | | `network.assets` | `Asset[]` | Every node and link. | | `network.customerPoints` | `CustomerPoint[]` | Every customer point. | #### `network.getNode(label)` The junction, reservoir or tank with that label, or `null`. ```ts getNode(label: string): NodeAsset | null ``` #### `network.getLink(label)` The pipe, pump or valve with that label, or `null`. ```ts getLink(label: string): LinkAsset | null ``` #### `network.get(label)` The node or link with that label, or `null`. ```ts get(label: string): Asset | null ``` Prefer `getNode` or `getLink`: a node and a link may share a label. **Throws** - `network.get("X") matches a node and a link; use getNode or getLink` when both exist. #### `network.getCustomerPoint(label)` The customer point with that label, or `null`. ```ts getCustomerPoint(label: string): CustomerPoint | null ``` Customer points are a separate label group: `getNode` and `getLink` never return one. Lookups return `null` rather than throwing. The same asset is always the same object: `network.pipes[0] === network.getLink(network.pipes[0].label)`. ### 1.3 Assets Every asset is a read-only object of getters. A value the model has not set reads as `null`. #### `Asset` (`asset`) What every asset carries. | Property | Type | Description | | --- | --- | --- | | `asset.uid` | `number` | An internal number. Never persist it; address assets by label. | | `asset.label` | `string` | The name shown in the app and written to INP files. | | `asset.type` | `AssetType` | The asset's type. | | `asset.isNode` | `boolean` | Whether it is a junction, reservoir or tank. | | `asset.isLink` | `boolean` | Whether it is a pipe, pump or valve. | | `asset.isActive` | `boolean` | Whether it takes part in the simulation, the INP and the review checks. | | `asset.isSelected` | `boolean` | Whether it is in the user's selection, or the script's after `selection.set`. | | `asset.geometry` | `GeoJSON.Point \| GeoJSON.LineString` | The asset's geometry. | ##### `asset.getCustom(name)` A custom attribute's value by its name, or `null`. ```ts getCustom(name: string): string | number | null ``` `network.customAttributes(type)` lists the attributes defined for each type. #### `NodeAsset` (`node`) A junction, reservoir or tank. Has every `Asset` member. | Property | Type | Description | | --- | --- | --- | | `node.coordinates` | `Position` | Where the node is. | | `node.elevation` | `number \| null` | The node's elevation. | | `node.initialQuality` | `number \| null` | The initial water quality. | | `node.chemicalSourceType` | `"concen" \| "mass" \| "flowpaced" \| "setpoint" \| null` | The kind of quality source at the node. | | `node.chemicalSourceStrength` | `number \| null` | The quality source's strength. | | `node.chemicalSourcePattern` | `string \| null` | The label of the quality source's pattern. | | `node.links` | `LinkAsset[]` | The links connected to the node. | #### `Junction` (`junction`) A demand node. Has every `NodeAsset` member. | Property | Type | Description | | --- | --- | --- | | `junction.emitterCoefficient` | `number \| null` | The emitter coefficient. | | `junction.demands` | `Demand[]` | The demand categories. | | `junction.baseDemand` | `number` | The sum of the categories' `base`. Demand from customer points is not included. | | `junction.customerPoints` | `CustomerPoint[]` | The customer points allocated to the junction. | | `junction.results` | `JunctionResults \| null` | This junction's simulation results at the timestep the user had selected in the app when the script started. `at` reads another timestep and `series` one property across the run. `null` when there is no simulation, the run has no timesteps, or it did not include this junction — inactive, or added since. Check `network.results.isStale` before trusting the numbers. Values are in `network.units[name]`, except `demand`, which is in `units.actualDemand`. `stats` is in `units.pressure` and `units.head`. The engine tracks it at every hydraulic step, including the ones between reported timesteps — when a control fires or a tank fills — so it can be beyond anything `series` shows. | #### `Reservoir` (`reservoir`) A fixed-head source. Has every `NodeAsset` member. | Property | Type | Description | | --- | --- | --- | | `reservoir.head` | `number \| null` | The reservoir's head. | | `reservoir.headPattern` | `string \| null` | The label of the head pattern. | | `reservoir.results` | `ReservoirResults \| null` | This reservoir's simulation results at the timestep the user had selected in the app when the script started. `at` reads another timestep and `series` one property across the run. `null` when there is no simulation, the run has no timesteps, or it did not include this reservoir — inactive, or added since. Check `network.results.isStale` before trusting the numbers. Values are in `network.units[name]`. `stats` is in `units.pressure` and `units.head`. The engine tracks it at every hydraulic step, including the ones between reported timesteps — when a control fires or a tank fills — so it can be beyond anything `series` shows. | #### `Tank` (`tank`) A storage node. Has every `NodeAsset` member. | Property | Type | Description | | --- | --- | --- | | `tank.initialLevel` | `number \| null` | The initial level. | | `tank.minLevel` | `number \| null` | The minimum level. | | `tank.maxLevel` | `number \| null` | The maximum level. | | `tank.diameter` | `number \| null` | The tank's diameter, in `units.tankDiameter`. | | `tank.minVolume` | `number \| null` | The minimum volume. | | `tank.overflow` | `boolean` | Whether the tank can overflow. | | `tank.mixingModel` | `"mixed" \| "2comp" \| "fifo" \| "lifo"` | The mixing model. | | `tank.mixingFraction` | `number \| null` | The mixing fraction. | | `tank.bulkReactionCoeff` | `number \| null` | The bulk reaction coefficient. | | `tank.volumeCurve` | `string \| null` | The label of the volume curve. | | `tank.results` | `TankResults \| null` | This tank's simulation results at the timestep the user had selected in the app when the script started. `at` reads another timestep and `series` one property across the run. `null` when there is no simulation, the run has no timesteps, or it did not include this tank — inactive, or added since. Check `network.results.isStale` before trusting the numbers. Values are in `network.units[name]`. `stats` is in `units.pressure` and `units.head`. The engine tracks it at every hydraulic step, including the ones between reported timesteps — when a control fires or a tank fills — so it can be beyond anything `series` shows. | #### `LinkAsset` (`link`) A pipe, pump or valve. Has every `Asset` member. | Property | Type | Description | | --- | --- | --- | | `link.startNode` | `NodeAsset` | The start node. Positive flow runs start → end. | | `link.endNode` | `NodeAsset` | The end node. | | `link.length` | `number` | The stored length, in `units.length`. | | `link.geometryLength` | `number` | The length measured along the drawn line, in `units.length`. | | `link.vertices` | `Position[]` | The intermediate points of the line. | ##### `link.pointAt(distance)` The point that far along the line, in the project length unit. ```ts pointAt(distance: number): Position ``` #### `Pipe` (`pipe`) A pipe. Has every `LinkAsset` member. | Property | Type | Description | | --- | --- | --- | | `pipe.diameter` | `number \| null` | The pipe's diameter, in `units.diameter`. | | `pipe.roughness` | `number \| null` | The pipe's own roughness, often `null`. Not what the engine gets: read `appliedRoughness`. | | `pipe.appliedRoughness` | `number \| null` | What EPANET receives: the pipe's own value, else the pipe library's for its material and age, else `null`. When both this and `roughness` are `null` the INP carries `MISSING`. | | `pipe.roughnessSource` | `"pipe" \| "library" \| null` | Where `appliedRoughness` came from. | | `pipe.minorLoss` | `number \| null` | The minor loss coefficient. | | `pipe.status` | `"open" \| "closed" \| "cv"` | The initial status. | | `pipe.material` | `string \| null` | The pipe's material. | | `pipe.year` | `number \| null` | The year the pipe was installed. | | `pipe.bulkReactionCoeff` | `number \| null` | The bulk reaction coefficient. | | `pipe.wallReactionCoeff` | `number \| null` | The wall reaction coefficient. | | `pipe.customerPoints` | `CustomerPoint[]` | The customer points connected to the pipe. | | `pipe.results` | `PipeResults \| null` | This pipe's simulation results at the timestep the user had selected in the app when the script started. `at` reads another timestep and `series` one property across the run. `null` when there is no simulation, the run has no timesteps, or it did not include this pipe — inactive, or added since. Check `network.results.isStale` before trusting the numbers. Values are in `network.units[name]`. A positive `flow` runs from the start node to the end node. | #### `Pump` (`pump`) A pump. Has every `LinkAsset` member. | Property | Type | Description | | --- | --- | --- | | `pump.status` | `"on" \| "off"` | The initial status. | | `pump.definitionType` | `"power" \| "designPointCurve" \| "standardCurve" \| "curveId"` | How the pump is defined. | | `pump.power` | `number \| null` | The pump's power, for a power pump. | | `pump.curvePoints` | `{ flow: number; head: number }[] \| null` | The pump curve's points, however the pump is defined. | | `pump.designPoint` | `{ flow: number; head: number } \| null` | The single point of a one-point curve, else `null`. | | `pump.curve` | `string \| null` | The library curve's label when `definitionType === "curveId"`, else `null`. | | `pump.speed` | `number \| null` | The relative speed. | | `pump.speedPattern` | `string \| null` | The label of the speed pattern. | | `pump.efficiencyCurve` | `string \| null` | The label of the efficiency curve. | | `pump.energyPrice` | `number \| null` | The energy price. | | `pump.energyPricePattern` | `string \| null` | The label of the energy price pattern. | | `pump.control` | `PumpControl \| null` | The pump's structured control from the app. Pump controls are here, not in `network.rawControls`. Read both before judging why a pump is off. | | `pump.results` | `PumpResults \| null` | This pump's simulation results at the timestep the user had selected in the app when the script started. `at` reads another timestep and `series` one property across the run. `null` when there is no simulation, the run has no timesteps, or it did not include this pump — inactive, or added since. Check `network.results.isStale` before trusting the numbers. Values are in `network.units[name]`, except `head`, which is in `units.actualHead`. `setting` is the relative speed the engine ran the pump at, like `pump.speed`. A positive `flow` runs from the start node to the end node. `energy` has `utilization` and `averageEfficiency` in `units.efficiency`, `averageKw` and `peakKw` in `units.power`, `averageKwPerFlowUnit` in `units.averageKwPerFlowUnit`; `averageCostPerDay` and `demandCharge` are in the project's currency. `demandCharge` is the network's, the same for every pump. `energy` is `null` when the run reported none. | #### `Valve` (`valve`) A valve; its setting unit follows its kind. Has every `LinkAsset` member. | Property | Type | Description | | --- | --- | --- | | `valve.kind` | `"prv" \| "psv" \| "fcv" \| "pbv" \| "tcv" \| "gpv" \| "pcv"` | The valve's kind. | | `valve.setting` | `number \| null` | The setting, in the unit its kind uses. | | `valve.diameter` | `number \| null` | The valve's diameter, in `units.diameter`. | | `valve.minorLoss` | `number \| null` | The minor loss coefficient. | | `valve.status` | `"active" \| "open" \| "closed"` | The initial status. | | `valve.curve` | `string \| null` | The label of the valve's curve. | | `valve.targetNode` | `NodeAsset \| null` | A PRV's remote set-point node, or `null`. | | `valve.results` | `ValveResults \| null` | This valve's simulation results at the timestep the user had selected in the app when the script started. `at` reads another timestep and `series` one property across the run. `null` when there is no simulation, the run has no timesteps, or it did not include this valve — inactive, or added since. Check `network.results.isStale` before trusting the numbers. Values are in `network.units[name]`. `setting` is the setting the engine applied, in the unit its kind uses, like `valve.setting`. A positive `flow` runs from the start node to the end node. | #### `CustomerPoint` (`customerPoint`) A customer: a point carrying demand, allocated to a pipe and through it to a junction. | Property | Type | Description | | --- | --- | --- | | `customerPoint.uid` | `number` | An internal number. Never persist it. | | `customerPoint.label` | `string` | The customer point's label. | | `customerPoint.type` | `"customerPoint"` | Always `"customerPoint"`. | | `customerPoint.isSelected` | `boolean` | Whether it is in the selection. | | `customerPoint.coordinates` | `Position` | Where the customer is. | | `customerPoint.geometry` | `GeoJSON.Point` | The point's geometry. | | `customerPoint.demands` | `Demand[]` | The demand categories. | | `customerPoint.demand` | `number` | The sum of the categories' `base`, in `units.customerDemand`. | | `customerPoint.isConnected` | `boolean` | Whether it is allocated to a pipe. A disconnected point contributes no demand. | | `customerPoint.pipe` | `Pipe \| null` | The pipe it is allocated to. | | `customerPoint.junction` | `Junction \| null` | The junction its demand goes to. | | `customerPoint.snapPoint` | `Position \| null` | The point on the pipe it connects at. | ##### `customerPoint.getCustom(name)` A custom attribute's value by its name, or `null`. ```ts getCustom(name: string): string | number | null ``` **`AssetType`** — The six asset types. ```ts type AssetType = "junction" | "reservoir" | "tank" | "pipe" | "pump" | "valve"; ``` **`Demand`** — One demand category: a base demand and the label of its pattern, absent when it has none. ```ts interface Demand { base: number; pattern?: string } ``` **`PumpControl`** — A pump's structured control from the app. Times are in seconds. ```ts type PumpControl = | { type: "timed-setting"; steps: { time: number; status: "on" | "off"; setting: number }[]; } | { type: "level-setting"; tank: Tank; on: { level: number; setting: number }; off: { level: number }; } | { type: "variable-speed-pump"; quantity: "pressure" | "level" | "flow"; targetAsset: Asset; target: number; minSpeed: number; maxSpeed: number; laggedPumps: Pump[]; schedule: { time: number; target: number }[]; tankLevels: { tank: Tank; onLevel: number; offLevel: number } | null; }; ``` **`JunctionResults`** — A junction's simulation results at one timestep. ```ts interface JunctionResults { pressure: number; head: number; demand: number; /** when the run had that water quality analysis */ waterAge?: number; waterTrace?: number; chemicalConcentration?: number; /** this junction at another timestep; a negative index counts from the end */ at(index: number): JunctionResults | null; /** one value per entry of network.results.timesteps */ series(property: "pressure" | "head" | "demand" | "waterAge" | "waterTrace" | "chemicalConcentration"): number[] | null; /** lowest and highest over the run; the same at every timestep */ readonly stats: { minPressure: number; maxPressure: number; minHead: number; maxHead: number } | null; } ``` **`ReservoirResults`** — A reservoir's simulation results at one timestep. ```ts interface ReservoirResults { pressure: number; head: number; netFlow: number; /** when the run had that water quality analysis */ waterAge?: number; waterTrace?: number; chemicalConcentration?: number; /** this reservoir at another timestep; a negative index counts from the end */ at(index: number): ReservoirResults | null; /** one value per entry of network.results.timesteps */ series(property: "pressure" | "head" | "netFlow" | "waterAge" | "waterTrace" | "chemicalConcentration"): number[] | null; /** lowest and highest over the run; the same at every timestep */ readonly stats: { minPressure: number; maxPressure: number; minHead: number; maxHead: number } | null; } ``` **`TankResults`** — A tank's simulation results at one timestep. ```ts interface TankResults { pressure: number; head: number; level: number; volume: number; netFlow: number; /** when the run had that water quality analysis */ waterAge?: number; waterTrace?: number; chemicalConcentration?: number; /** this tank at another timestep; a negative index counts from the end */ at(index: number): TankResults | null; /** one value per entry of network.results.timesteps */ series(property: "pressure" | "head" | "level" | "volume" | "netFlow" | "waterAge" | "waterTrace" | "chemicalConcentration"): number[] | null; /** lowest and highest over the run; the same at every timestep */ readonly stats: { minPressure: number; maxPressure: number; minHead: number; maxHead: number } | null; } ``` **`PipeResults`** — A pipe's simulation results at one timestep. ```ts interface PipeResults { flow: number; velocity: number; headloss: number; unitHeadloss: number; status: "open" | "closed"; /** when the run had that water quality analysis */ waterAge?: number; waterTrace?: number; chemicalConcentration?: number; /** this pipe at another timestep; a negative index counts from the end */ at(index: number): PipeResults | null; /** `status` at each entry of network.results.timesteps */ series(property: "status"): ("open" | "closed")[] | null; /** one value per entry of network.results.timesteps */ series(property: "flow" | "velocity" | "headloss" | "unitHeadloss" | "waterAge" | "waterTrace" | "chemicalConcentration"): number[] | null; } ``` **`PumpResults`** — A pump's simulation results at one timestep. ```ts interface PumpResults { flow: number; head: number; setting: number; status: "on" | "off"; statusWarning: "cannot-deliver-flow" | "cannot-deliver-pressure" | "cannot-deliver-head" | null; /** when the run had that water quality analysis */ waterAge?: number; waterTrace?: number; chemicalConcentration?: number; /** this pump at another timestep; a negative index counts from the end */ at(index: number): PumpResults | null; /** `status` at each entry of network.results.timesteps */ series(property: "status"): ("on" | "off")[] | null; /** `statusWarning` at each entry of network.results.timesteps */ series(property: "statusWarning"): ("cannot-deliver-flow" | "cannot-deliver-head" | null)[] | null; /** one value per entry of network.results.timesteps */ series(property: "flow" | "head" | "setting" | "waterAge" | "waterTrace" | "chemicalConcentration"): number[] | null; /** energy use over the run, as EPANET reports it; the same at every timestep */ readonly energy: { utilization: number; averageEfficiency: number; averageKwPerFlowUnit: number; averageKw: number; peakKw: number; averageCostPerDay: number; demandCharge: number } | null; } ``` **`ValveResults`** — A valve's simulation results at one timestep. ```ts interface ValveResults { flow: number; velocity: number; headloss: number; setting: number; status: "active" | "open" | "closed"; statusWarning: "cannot-deliver-flow" | "cannot-deliver-pressure" | "cannot-deliver-head" | null; /** when the run had that water quality analysis */ waterAge?: number; waterTrace?: number; chemicalConcentration?: number; /** this valve at another timestep; a negative index counts from the end */ at(index: number): ValveResults | null; /** `status` at each entry of network.results.timesteps */ series(property: "status"): ("active" | "open" | "closed")[] | null; /** `statusWarning` at each entry of network.results.timesteps */ series(property: "statusWarning"): ("cannot-deliver-flow" | "cannot-deliver-pressure" | null)[] | null; /** one value per entry of network.results.timesteps */ series(property: "flow" | "velocity" | "headloss" | "setting" | "waterAge" | "waterTrace" | "chemicalConcentration"): number[] | null; } ``` ### 1.4 Libraries, controls and attributes | Property | Type | Description | | --- | --- | --- | | `network.patterns` | `Pattern[]` | Every pattern. | | `network.curves` | `Curve[]` | Every curve. | | `network.pipeMaterials` | `PipeMaterial[]` | The pipe library. | | `network.rawControls` | `string[]` | The `[CONTROLS]` lines as INP text, e.g. `"LINK PU1 CLOSED IF NODE T1 ABOVE 5"`. Filter them as strings. | | `network.rawRules` | `string[]` | The `[RULES]` blocks as INP text. | | `network.defaultAllocationRules` | `AllocationRule[]` | The default customer point allocation rules for the project's units. | #### `network.getPattern(label)` One pattern by label, or `null`. ```ts getPattern(label: string): Pattern | null ``` #### `network.getCurve(label)` One curve by label, or `null`. ```ts getCurve(label: string): Curve | null ``` #### `network.customAttributes(type)` The custom attributes defined for a type. ```ts customAttributes(type: AssetType | "customerPoint"): CustomAttribute[] ``` Read a value with `asset.getCustom(name)`. **Throws** - `network.customAttributes expects one of junction, reservoir, tank, pipe, pump, valve, customerPoint` **`Pattern`** — A time pattern. ```ts interface Pattern { label: string; type: "demand" | "reservoirHead" | "pumpSpeed" | "qualitySourceStrength" | "energyPrice"; multipliers: number[]; } ``` **`Curve`** — A shared curve. ```ts interface Curve { label: string; type: "pump" | "efficiency" | "volume" | "valve" | "headloss"; points: { x: number; y: number }[]; } ``` **`PipeMaterial`** — A pipe library material: the roughness it has at each age. ```ts interface PipeMaterial { label: string; entries: { age: number; roughness: number }[] } ``` **`CustomAttribute`** — A custom attribute defined for an asset type. ```ts interface CustomAttribute { name: string; type: "text" | "number" } ``` **`AllocationRule`** — A customer point allocation rule. ```ts interface AllocationRule { maxDistance: number; maxDiameter: number } ``` The pumps' structured controls are on `pump.control`, not in `rawControls`. Read both before judging why a pump is off or a valve is closed. ### 1.5 Spatial queries and geometry #### `Network` (`network`) The model the script reads. | Property | Type | Description | | --- | --- | --- | | `network.geo` | `NetworkGeo` | Geodesic helpers in the project length unit. | ##### `network.findWithin(area, options?)` The assets inside an area. ```ts findWithin(area: Area, options?: { types?: (AssetType | "customerPoint")[] }): (Asset | CustomerPoint)[] ``` **Parameters** - `options.types` — The asset types to return, and `"customerPoint"`. The default is every asset type, without customer points. ##### `network.findNear(position, options)` The assets within `radius` of a position, nearest first. ```ts findNear(position: Position, options: { radius: number; types?: (AssetType | "customerPoint")[] }): (Asset | CustomerPoint)[] ``` **Parameters** - `options.radius` — The distance, in the project length unit. - `options.types` — As for `findWithin`. **Throws** - `network.findNear expects { radius } as a number of at least 0, in the project length unit` #### `NetworkGeo` (`network.geo`) Geodesic helpers in the project length unit. ##### `network.geo.distance(a, b)` The geodesic distance between two positions. ```ts distance(a: Position, b: Position): number ``` ##### `network.geo.length(line)` The length of a line of positions. ```ts length(line: Position[]): number ``` ##### `network.geo.pointAlong(line, distance)` The position that far along a line. ```ts pointAlong(line: Position[], distance: number): Position ``` ##### `network.geo.nearestPoint(line, position)` The closest point on a line to a position. ```ts nearestPoint(line: Position[], position: Position): NearestPoint ``` ##### `network.geo.destination(from, bearing, distance)` The position `distance` away on a bearing in degrees. ```ts destination(from: Position, bearing: number, distance: number): Position ``` **`Area`** — Where to look: a polygon, a multipolygon, a feature of one, a box or a ring of positions. A node is inside when its point is; a link only when the whole line is, so one crossing a hole is left out. ```ts type Area = | GeoJSON.Polygon | GeoJSON.MultiPolygon | GeoJSON.Feature | BBox | Position[]; ``` **`NearestPoint`** — The closest point on a line, how far it is, how far along the line, and the fraction of the line. ```ts interface NearestPoint { point: Position; distance: number; chainage: number; fraction: number } ``` `map.bounds` is the user's viewport at run start, so `network.findWithin(map.bounds)` scopes a script to what the user is looking at. ### 1.6 Topology and tracing #### `Network` (`network`) The model the script reads. ##### `network.trace(start, options?)` The walk over the pipes to reach for. By default it is the app's Trace tool, boundary mode: the pressure zone around `start`. ```ts trace(start: Asset | string | (Asset | string)[], options?: { mode?: "allowed" | "connected" | "upstream" | "downstream"; stopAt?: (StopPreset | ((asset: Asset) => boolean))[]; maxDistance?: number; maxHops?: number }): TraceResult ``` By default it grows through open links in the direction water may flow, never crossing a closed link or a one-way link from its end node; `mode: "connected"` crosses both. It stops at every barrier in `stopAt`. Barrier nodes are not included. `stoppedAt` says where it stopped and why. A start node that is itself a barrier is left out, as in the Trace tool; a start link is always included. **Parameters** - `options.mode` — `"allowed"`, the default: through open links in the direction water may flow. `"connected"`: everything connected on the drawing, through closed links and one-way links either way; `stopAt` still applies. `"downstream"` and `"upstream"`: the app's Trace tool in those modes — where the water from `start` goes, or where the water reaching it comes from, following each link's flow in `network.results` at the timestep the user had selected. A link with no flow (closed, off, or under 0.001) is not crossed. A start link is included, and the trace goes on from its downstream end (its upstream end for `"upstream"`). They read the results even when `network.results.isStale`. - `options.stopAt` — Presets and functions that make an asset a barrier. The default, `["tanks", "reservoirs", "pumps", "controlValves"]`, is the Trace tool's boundary. `[]` follows the water through everything passable, pumps and valves only from their start node. A function gets each node and link the trace reaches and returns `true` to stop there. In `"upstream"` and `"downstream"` mode the default is `[]`, as in the Trace tool; the presets still read how each link is set up, not the run. - `options.maxDistance` — Go no further than this along the pipes from the nearest start, in the project length unit (pipe lengths; pumps and valves count 0). No limit by default. - `options.maxHops` — Cross at most this many links from the nearest start, counted along the path the trace took. No limit by default. **Throws** - `network.trace found no asset labelled "X"` - `network.trace has no option "X"; it takes mode, stopAt, maxDistance and maxHops` - `network.trace expects stopAt functions to return true or false` - `network.trace expects maxDistance as a number of at least 0, in the project length unit` - `network.trace expects maxHops as a whole number of at least 0` - `network.trace mode "downstream" needs simulation results; run a simulation first` when there is no run, or it has no timesteps *Why a node is cut off* ```js const zone = network.trace("J1", { stopAt: [] }); for (const stop of zone.stoppedAt) console.log(stop.asset.label, stop.reason, "from", stop.from.label); ``` *The area fed through small pipes, stopping at mains* ```js const area = network.trace("J1", { stopAt: ["tanks", "reservoirs", "pumps", "controlValves", (asset) => asset.type === "pipe" && asset.diameter >= 300], }); selection.set(area.nodes); ``` *Everything connected on the drawing, stopping only at closed links* ```js const piece = network.trace("J1", { mode: "connected", stopAt: ["closed"] }); console.log(piece.nodes.length, "nodes;", piece.stoppedAt.length, "closed links on the rim"); ``` *Everything within 500 along the pipes* ```js const near = network.trace("J1", { stopAt: [], maxDistance: 500 }); console.table(near.nodes.map((node) => ({ node: node.label, distance: near.distance(node) }))); ``` *Where the water from a source goes* ```js if (!network.results) throw new Error("Run a simulation first"); const fed = network.trace("R1", { mode: "downstream" }); console.log(fed.nodes.length, "nodes fed from R1 at the selected timestep"); selection.set(fed.nodes); ``` ##### `network.boundaryLinks(nodes)` The rim of any set of nodes: every link with exactly one end inside. ```ts boundaryLinks(nodes: (NodeAsset | string)[]): BoundaryLink[] ``` ##### `network.shortestPath(from, to, options?)` The cheapest path between two nodes, or `null` when they are not connected. ```ts shortestPath(from: NodeAsset | string, to: NodeAsset | string, options?: { weightBy?: "length" | ((link: LinkAsset) => number) }): Path | null ``` **Parameters** - `options.weightBy` — `"length"`, the default (pipe lengths; pumps and valves cost 0), or a function returning a cost of at least 0. #### `Valve` (`valve`) A valve; its setting unit follows its kind. Has every `LinkAsset` member. ##### `valve.targetNodeOptions()` For a PRV, the nodes the asset panel offers as its remote set-point target (downstream of the valve); `[]` for any other kind. ```ts targetNodeOptions(): NodeAsset[] ``` **`BoundaryLink`** — A link on the rim of a set of nodes. For a PRV on the rim, `inside` is the zone end. ```ts interface BoundaryLink { link: LinkAsset; inside: NodeAsset; outside: NodeAsset } ``` **`StopPreset`** — Named barriers for `trace`'s `stopAt`. `controlValves` is every valve except an open TCV; `cv` is check-valve pipes; `closed` is closed pipes and valves and pumps that are off, for `mode: "connected"`. ```ts type StopPreset = | "tanks" | "reservoirs" | "pumps" | "valves" | "controlValves" | "cv" | "closed" | "prv" | "psv" | "fcv" | "pbv" | "tcv" | "gpv" | "pcv"; ``` **`TraceStop`** — Where a trace stopped and why: the barrier, and the node it was reached from. ```ts interface TraceStop { asset: Asset; reason: "stopAt" | "closed" | "direction" | "limit"; from: NodeAsset; via: LinkAsset | null; } ``` `stopAt`: the asset matched a preset or function. `closed`: a closed pipe or valve, or a pump that is off. `direction`: a one-way link (a pump, a check-valve pipe, any valve except a TCV) reached from its end node. `closed` and `direction` only come from the default mode. In `"upstream"` and `"downstream"` mode, `closed` is a link with no flow in the run — closed, off, or flowing less than 0.001 — and `direction` is a link whose water flows the other way. `limit`: a node past `maxDistance` or `maxHops`. `via` is the link that led to a node barrier, and `null` when the barrier is the link itself. **`TraceResult`** — The zone a trace reached, the links on its rim, every barrier it stopped at, and how far along the pipes each node is. ```ts interface TraceResult { nodes: NodeAsset[]; links: LinkAsset[]; boundaryLinks: BoundaryLink[]; stoppedAt: TraceStop[]; distance(node: NodeAsset | string): number } ``` `nodes` come nearest first; `links` in the order they were crossed. `distance(node)` is the distance along the pipes from the nearest start, in the project length unit (pipe lengths; pumps and valves count 0), and `Infinity` for a node the trace did not reach. Each barrier is in `stoppedAt` once. A one-way link the trace later crossed from its start node is not in it. **`Path`** — A path between two nodes. ```ts interface Path { nodes: NodeAsset[]; links: LinkAsset[]; totalLength: number } ``` `totalLength` is the length along the pipes, in the project length unit (pumps and valves count 0), whatever `weightBy` the path was found with. Do not hand-roll a breadth-first search — `trace`, `boundaryLinks` and `shortestPath` cover it. To change where a trace stops, give it `stopAt`, `maxDistance` or `maxHops`; to learn why it stopped, read `stoppedAt`; for how far a node is along the pipes, `distance(node)`. ### 1.7 Network review and validation These are the app's own checks — a script gets exactly what the Network Review panel and the Run button get. They run on the active network only (inactive assets are left out). #### `network.validate()` The checks behind the dialog that opens when you press Run. ```ts validate(): ValidationReport ``` The findings are orphan assets first, then one finding per area with no reservoir or tank, then the attribute rules, errors before warnings. Orphan tanks and reservoirs are not findings: EPANET solves with them. `findOrphanAssets()` still lists them. #### `network.findOrphanAssets()` The orphan check: nodes with no links, links missing an end, and pumps or valves with nothing else connected at either end. ```ts findOrphanAssets(): Asset[] ``` In the review panel's order: by type, then label. #### `network.findSubnetworks()` The connectivity check: every connected part of the network, largest first. ```ts findSubnetworks(): Subnetwork[] ``` Parts of a single node are left out (they are orphans). #### `network.findProximityAnomalies(options)` The proximity check: nodes within `distance` of a pipe they are not connected to, nearest first. ```ts findProximityAnomalies(options: { distance: number }): ProximityAnomaly[] ``` **Parameters** - `options.distance` — In the project length unit. The review panel's default is 0.5 m or 1.5 ft. **Throws** - `network.findProximityAnomalies expects { distance } as a number of at least 0, in the project length unit` #### `network.findCrossingPipes()` The crossing check: pipes that cross on the map with no junction where they meet. ```ts findCrossingPipes(): CrossingPipe[] ``` **`ValidationReport`** — What `network.validate()` found. ```ts interface ValidationReport { status: "ok" | "warning" | "error"; checks: ("orphanAssets" | "connectivityTrace" | "modelAttributesValidation")[]; counts: { findings: number; errors: number; warnings: number; assets: number }; findings: Finding[]; } ``` `status` is `"ok"` when there are no findings and Run goes straight through, `"warning"` when there are only warnings, and `"error"` when there is at least one error. Run opens its review dialog for both `"warning"` and `"error"`. **`Finding`** — One rule and everything that failed it. ```ts interface Finding { check: "orphanAssets" | "connectivityTrace" | "modelAttributesValidation"; code: string; severity: "error" | "warning"; entityType: string | null; field: string | null; assets: (Asset | CustomerPoint)[]; details: { nodes: NodeAsset[]; links: LinkAsset[] } | null; } ``` - `code` — the app's stable rule id: `"asset.connected"`, `"subNetwork.supplySource.present"`, or an attribute rule such as `"pipe.diameter.present"`, `"node.elevation.present"`, `"pipe.material.inLibrary"`, `"customerPoint.connected"`. - `entityType` — what the rule is about (`"pipe"`, `"tank"`, `"customerPoint"`, …); `null` for the topology checks and for rules shared across types (`node.elevation.present` covers junctions and tanks — read each asset's `type`). - `field` — the property the rule judges, or `null`. - `details` — the nodes and links of an unsupplied area, else `null`. **`Subnetwork`** — A connected part of the network. One with `supplySourceCount === 0` has no reservoir or tank. ```ts interface Subnetwork { nodes: NodeAsset[]; links: LinkAsset[]; supplySourceCount: number; pipeCount: number; bounds: BBox; } ``` **`ProximityAnomaly`** — A node close to a pipe it is not connected to. ```ts interface ProximityAnomaly { node: NodeAsset; pipe: Pipe; distance: number; nearestPointOnPipe: Position } ``` **`CrossingPipe`** — Two pipes that cross with no junction where they meet. The smaller pipe is `pipe1`. ```ts interface CrossingPipe { pipe1: Pipe; pipe2: Pipe; intersectionPoint: Position } ``` `findCrossingPipes()` and `findProximityAnomalies()` are informational — never a reason a run fails. ### 1.8 Export #### `network.toInp()` The network as INP text, built the way File › Export INP builds it, from the model the script reads. ```ts toInp(): string ``` Coordinates, label ids, customer demands and water quality are included. When the model needs **EPANET-LSX** — it has remote set-point PRVs or variable-speed pump controls — `toInp()` logs a console warning naming those assets. The file then carries a `[SCRIPT]` section that standard EPANET cannot run. ```js ui.attachFile(network.name + ".inp", network.toInp()); ``` ### 1.9 Simulation results Results come from the simulation the user last ran from the toolbar, read as it was when the script started. Scripts cannot run a simulation themselves. Read them from the asset: `junction.results.pressure`, `pipe.results.flow`, documented with each asset type in Assets. That covers most scripts, and looping over every asset reads the timestep once. `results` is the timestep the user had selected in the app; `results.at(i)` and `results.series(property)` reach the asset's other timesteps, and a node's `stats` and a pump's `energy` cover the whole run. Use `network.results` for the run as a whole: whether it succeeded and is still current (`status`, `isStale`), its `timesteps` and the one the app shows (`currentStep`), and the lowest and highest values across the network (`minValues`, `maxValues`), which read the stored results directly instead of looping over assets. #### `Network` (`network`) The model the script reads. | Property | Type | Description | | --- | --- | --- | | `network.results` | `SimulationResult \| null` | The simulation the user last ran from the toolbar, or `null` when there is none. The run as a whole; for one asset at the selected timestep, read `asset.results`. Results are matched to assets by identity, not by label. An asset the run did not include — inactive, or added since — reads as `null`. Check `isStale` before trusting the numbers. | #### `SimulationResult` (`results`) The simulation the user last ran, as it was when the script started. | Property | Type | Description | | --- | --- | --- | | `results.status` | `SimulationStatus` | How the run ended: `success`, `warning`, `failure` or `stopped`. | | `results.report` | `string` | The EPANET report of the run, with its warnings and errors. | | `results.isStale` | `boolean` | Whether the model or the simulation settings changed after the run, so the results describe an earlier model. | | `results.timesteps` | `number[]` | Seconds from the start, one per reported timestep; `[0]` for a steady-state run, empty when the run has no results. | | `results.stepCount` | `number` | How many timesteps the run reported. | | `results.currentStep` | `number \| null` | The index in `timesteps` of the timestep the user had selected in the app when the script started — the one `asset.results` reads. `null` when the run has no timesteps. | ##### `results.minValues(property)` Label → lowest value over the run for every node. The network-wide form of `node.results.stats`, with the same numbers. ```ts minValues(property: "pressure" | "head"): Map ``` Both list every node. Like `node.results.stats`, the engine tracks these at every hydraulic step, not only the reported timesteps. A node the run did not include is left out; the map is empty when the run has no timesteps. **Throws** - a property other than `pressure` or `head` - `The simulation results are no longer available; run the simulation again` when a newer run has replaced the stored results ```js const low = [...network.results.minValues("pressure")] .filter(([, p]) => p < 20) .map(([label]) => label); console.log(low.length, "nodes drop below 20", network.units.pressure); ``` ##### `results.maxValues(property)` Label → highest value over the run for every node. The same rules as `minValues`. ```ts maxValues(property: "pressure" | "head"): Map ``` **Throws** - a property other than `pressure` or `head` - `The simulation results are no longer available; run the simulation again` when a newer run has replaced the stored results ```js const high = [...network.results.maxValues("pressure")] .sort(([, a], [, b]) => b - a) .slice(0, 5); console.table(high.map(([node, pressure]) => ({ node, pressure }))); ``` **`SimulationStatus`** — How the run ended. A `failure` or a `stopped` run has no timesteps; `report` says why. ```ts type SimulationStatus = "success" | "warning" | "failure" | "stopped"; ``` ## 2. `zones` — the project's polygons The polygons imported with **File › Import › Zones** — DMAs, pressure zones, supply areas. They are drawn boundaries, unrelated to connectivity: `network.trace` walks the pipes and knows nothing about them. A node, a customer point or a `[lng, lat]` position is inside a zone when its point is. A link is inside only when the whole line is, so a pipe that crosses the boundary, or crosses one of the zone's holes, is in no zone. `network.findWithin(zone.geometry)` uses the same rule. ### `Zones` (`zones`) The project's polygon zones. | Property | Type | Description | | --- | --- | --- | | `zones.list` | `Zone[]` | Every zone. | | `zones.count` | `number` | How many zones there are. | #### `zones.get(label)` The first zone with that label, case-insensitively, or `null`. ```ts get(label: string): Zone | null ``` #### `zones.at(target)` Every zone that contains the target. Zones may overlap; `[]` means it is in none. ```ts at(target: ZoneTarget): Zone[] ``` **Throws** - `zones.at found no asset labelled "X"` - `zones.at expects a [lng, lat] position, an asset, an asset label or a customer point` ### `Zone` (`zone`) One polygon zone. | Property | Type | Description | | --- | --- | --- | | `zone.label` | `string` | The zone's label. | | `zone.geometry` | `GeoJSON.MultiPolygon` | The zone's polygons, holes included. | | `zone.bbox` | `BBox` | The zone's extent. | | `zone.area` | `number` | The zone's area, in `units.area`. | #### `zone.contains(target)` Whether the zone contains the target. ```ts contains(target: ZoneTarget): boolean ``` **Throws** - `zone.contains expects a [lng, lat] position, an asset, an asset label or a customer point` *Junctions per zone* ```js for (const zone of zones.list) { const junctions = network.junctions.filter((j) => zone.contains(j)); console.log(zone.label, junctions.length); } ``` **`ZoneTarget`** — Something a zone can contain: a position, an asset or its label, or a customer point. ```ts type ZoneTarget = Position | Asset | string | CustomerPoint; ``` `console` and `ui.attachFile` print a zone as its label. Zones are the same in every scenario. ## 3. `selection` — the user's selection in, the script's out | Property | Type | Description | | --- | --- | --- | | `selection.assets` | `Asset[]` | The selected assets. | | `selection.customerPoints` | `CustomerPoint[]` | The selected customer points. | | `selection.nodes` | `NodeAsset[]` | The selected nodes. | | `selection.links` | `LinkAsset[]` | The selected links. | | `selection.junctions` | `Junction[]` | The selected junctions. | | `selection.reservoirs` | `Reservoir[]` | The selected reservoirs. | | `selection.tanks` | `Tank[]` | The selected tanks. | | `selection.pipes` | `Pipe[]` | The selected pipes. | | `selection.pumps` | `Pump[]` | The selected pumps. | | `selection.valves` | `Valve[]` | The selected valves. | | `selection.labels` | `string[]` | The labels of everything selected. | | `selection.count` | `number` | How many things are selected. | | `selection.isEmpty` | `boolean` | Whether nothing is selected. | ### `selection.set(targets)` Replace the selection. ```ts set(targets: Asset | CustomerPoint | (Asset | CustomerPoint)[]): void ``` **Throws** - `selection.set expects assets or customer points from the network` ### `selection.add(targets)` Add to the selection. ```ts add(targets: Asset | CustomerPoint | (Asset | CustomerPoint)[]): void ``` ### `selection.clear()` Select nothing. ```ts clear(): void ``` At run start the selection is the user's. After `set`, `add` or `clear`, the getters (and `asset.isSelected`) read the script's selection, and the map shows it when the script ends. When the user says "the selected pipes", start from `selection.pipes`, and throw a clear error when it is empty: `if (selection.pipes.length === 0) throw new Error("Select some pipes first")`. ## 4. `map` — the viewport | Property | Type | Description | | --- | --- | --- | | `map.bounds` | `BBox \| null` | The viewport at run start. | | `map.center` | `Position \| null` | The centre of the viewport. | | `map.zoom` | `number \| null` | The zoom level. | ### `map.zoomTo(target, options?)` Zoom to assets, customer points or a box when the script ends. The last call wins. ```ts zoomTo(target: Asset | CustomerPoint | (Asset | CustomerPoint)[] | BBox, options?: { maxZoom?: number }): void ``` **Throws** - `map.zoomTo expects assets, customer points or a [minLng, minLat, maxLng, maxLat] box` ## 5. `console` — output ### `console.log(...args)` A line in the run's console, streamed live. Assets and customer points print as their labels; objects print as JSON, with assets inside as labels. ```ts log(...args: unknown[]): void ``` ### `console.warn(...args)` A warning line. ```ts warn(...args: unknown[]): void ``` ### `console.error(...args)` An error line. ```ts error(...args: unknown[]): void ``` ### `console.table(rows, columns?)` A table. `rows` is an array of objects or of plain values; `columns` picks and orders the keys. Asset cells print as labels. ```ts table(rows: unknown[], columns?: string[]): void ``` `JSON.stringify(asset)` gives `{}` — assets are getters, not data. Build plain objects of the fields you need (`{ pipe: p.label, diameter: p.diameter }`), or pass the asset to `console`/`ui.attachFile`, which print labels. ## 6. `ui` — progress and files ### `ui.progress(fraction, message?)` Show progress between 0 and 1 in the run dialog. ```ts progress(fraction: number, message?: string): void ``` Calls closer than 50 ms apart with the same message are dropped, so calling it every iteration is fine. ### `ui.attachFile(name, content)` Offer a file for download when the script ends. ```ts attachFile(name: string, content: string | object): void ``` `content` is a string, or an object or array written as indented JSON (assets inside become labels). The name is 1–128 characters; attaching the same name again replaces the file. Deliver tabular or geographic output as files — CSV, GeoJSON, JSON, INP — and keep `console.table` for at-a-glance summaries. ## 6. `collections` — saved selection sets and bookmarks ### `Collections` (`collections`) The project's selection sets and bookmarks, and new ones saved when the script ends. | Property | Type | Description | | --- | --- | --- | | `collections.selectionSets` | `SelectionSet[]` | The project's selection sets, in the Collections panel's order, as they were when the script started. Sets saved during the run are not in the list. Labels can repeat, so pick a set with `find` or `filter` on the label and handle more than one match. | | `collections.bookmarks` | `Bookmark[]` | The project's bookmarks, in the Collections panel's order, as they were when the script started. Labels can repeat, so pick a bookmark with `find` or `filter` on the label and handle more than one match. | #### `collections.saveSelectionSet(label, targets?, options?)` Hand back a selection set as part of the run's output when the script ends. ```ts saveSelectionSet(label: string, targets?: Asset | CustomerPoint | (Asset | CustomerPoint)[], options?: { commit?: boolean }): void ``` The set is listed with the run's output, where the user can select it on the map and save the ones worth keeping to the project's Collections. It is not saved to the project unless `commit` is `true`. Without `targets` it uses the script's current selection at the time of the call. Labels need not be unique: a label that already exists adds another set, so committing from a script that runs twice saves its sets twice. **Parameters** - `label` — The set's name. Leading and trailing spaces are dropped. - `options.commit` — `true` saves the set straight to the project's Collections. Defaults to `false`. Avoid it in most scripts: a run is something the user tries until the result is right, and committed sets pile up in their project on every run. Leave the choice to the user, and commit only when they ask for sets to be saved without review. **Throws** - `collections.saveSelectionSet expects a label` - `collections.saveSelectionSet expects at least one asset or customer point` - `collections.saveSelectionSet expects assets or customer points from the network` - `collections.saveSelectionSet expects { commit } as true or false` ```js collections.saveSelectionSet("Pumps and valves", [...network.pumps, ...network.valves]); ``` ```js collections.saveSelectionSet("Pumps", network.pumps, { commit: true }); ``` #### `collections.saveBookmark(label, bbox?, options?)` Hand back a bookmark as part of the run's output when the script ends. ```ts saveBookmark(label: string, bbox?: BBox, options?: { commit?: boolean }): void ``` The bookmark is listed with the run's output, where the user can go to it on the map. It is not saved to the project unless `commit` is `true`. Without `bbox` it uses the map view the script started with (`map.bounds`). Labels need not be unique: a label that already exists adds another bookmark, so committing from a script that runs twice saves its bookmarks twice. **Parameters** - `label` — The bookmark's name. Leading and trailing spaces are dropped. - `options.commit` — `true` saves the bookmark straight to the project's Collections. Defaults to `false`. Avoid it in most scripts, for the same reason as `saveSelectionSet`: committed bookmarks pile up in the user's project on every run. Commit only when the user asks for bookmarks to be saved without review. **Throws** - `collections.saveBookmark expects a label` - `collections.saveBookmark expects a [minLng, minLat, maxLng, maxLat] box` - `collections.saveBookmark expects a box when there is no map view` - `collections.saveBookmark expects { commit } as true or false` *A bookmark per zone* ```js for (const zone of zones.list) collections.saveBookmark(zone.label, zone.bbox); ``` ```js collections.saveBookmark("Plant", [-3.71, 40.41, -3.69, 40.43], { commit: true }); ``` ### `SelectionSet` (`selectionSet`) A saved selection set: its label and the members still in the model. | Property | Type | Description | | --- | --- | --- | | `selectionSet.label` | `string` | The set's name. Not unique. | | `selectionSet.assets` | `Asset[]` | The set's assets that are still in the model. | | `selectionSet.customerPoints` | `CustomerPoint[]` | The set's customer points that are still in the model. | | `selectionSet.missing` | `number` | How many members were deleted from the model since the set was saved. | ### `Bookmark` (`bookmark`) A saved map view: its label and box. | Property | Type | Description | | --- | --- | --- | | `bookmark.label` | `string` | The bookmark's name. Not unique. | | `bookmark.bbox` | `BBox` | The map view the user saved. | Apply a saved set with `selection.set([...set.assets, ...set.customerPoints])`, and go to a bookmark with `map.zoomTo(bookmark.bbox)`. Saving selection sets and bookmarks needs a plan that includes Collections. Without it the run still applies its selection, zoom and files, and the run dialog says they were not saved. ## 7. Errors and limits - **An uncaught error stops the script.** The run dialog shows `name: message` and the script line it came from. Selection, zoom, attachments, selection sets and bookmarks are not applied; console lines already printed stay. - **API misuse throws early with a specific message**, quoted under each member's **Throws**. - **Cancellation:** the run dialog's Cancel stops the script immediately. There is no time limit. - **Caps:** a console line keeps its first 10,000 characters; the run dialog keeps the last 1,000 console entries; `console.table` shows the first 1,000 rows and says how many it left out; a progress message keeps 200 characters. ## 8. Notes for AI agents - Orient once: start the first script on an unfamiliar model with `console.log(network.stats)` and `console.log(network.units)`. - Check `network.units` before hard-coding a threshold. A pipe diameter of 100 is 100 mm in one project and 100 in in another. - Prefer `getNode`/`getLink` over `get`; never persist `uid`s; address assets by label. - Everything is read-only. If the user asks to change the model, say so — do not write a script that assigns to asset fields. - No `Date.now()`, `new Date()` or `Math.random()`: they throw in this sandbox. - `JSON.stringify(asset)` is `{}`. Map assets to plain objects of the fields you need before serialising. - Use the app's own checks instead of re-implementing them: `network.validate()`, `findOrphanAssets()`, `findSubnetworks()`, `findProximityAnomalies({ distance })`, `findCrossingPipes()`, `trace`, `boundaryLinks`, `shortestPath`. - Walk the pipes with `network.trace`, never with your own search over `junction.links`: `stopAt`, `maxDistance` and `maxHops` set where it stops, `stoppedAt` says where and why it stopped, `distance(node)` how far along the pipes a node is. - `network.validate()`'s `findings[0]` is the worst problem; `code` is stable; `assets` names what to fix. - Read `pipe.appliedRoughness`, not `pipe.roughness`, for the value EPANET receives. - Junction demand is `junction.demands` (categories with patterns) plus the demand of `junction.customerPoints`; `junction.baseDemand` is the categories only. - Hand results back visually: `selection.set(...)` and `map.zoomTo(...)` for what to look at, `ui.attachFile` for data, `collections.saveSelectionSet` and `collections.saveBookmark` for a set or a view the user will want again, `console.table` for a summary. - Leave `collections.saveSelectionSet` and `collections.saveBookmark` uncommitted: the user picks what is worth keeping from the run's output. Pass `{ commit: true }` only when the user asks for them to be saved to the project. - When the user says "the selected …", start from `selection.*` and fail clearly when it is empty or the wrong type. - When the user names a DMA or a zone, look in `zones` (the drawn polygons); `network.trace` is a different, connectivity-based "zone". ## 9. Worked examples The built-in examples from the Scripts panel. ### Select small pipes ```js const MAX_DIAMETER = 100; // select pipes at or below this (project units) const small = network.pipes.filter((p) => p.diameter <= MAX_DIAMETER); console.log(`${small.length} of ${network.pipes.length} pipes at or below ${MAX_DIAMETER}`); if (small.length === 0) throw new Error("Nothing matched — raise MAX_DIAMETER"); // Selection is how a script hands results back: selected assets render // purple on the map when the run finishes. selection.set(small); map.zoomTo(small); ``` ### Pipe length by diameter ```js // Uses the selected pipes, or every pipe when nothing is selected. const pipes = selection.isEmpty ? network.pipes : selection.assets.filter((a) => a.type === "pipe"); if (pipes.length === 0) throw new Error("No pipes in the selection"); const byDiameter = new Map(); for (const p of pipes) { const row = byDiameter.get(p.diameter) ?? { diameter: p.diameter, pipes: 0, length: 0 }; row.pipes += 1; row.length += p.length; byDiameter.set(p.diameter, row); } console.log(`${pipes.length} pipes · diameter in ${network.units.diameter}, length in ${network.units.length}`); console.table( [...byDiameter.values()] .sort((a, b) => a.diameter - b.diameter) .map((row) => ({ ...row, length: Math.round(row.length) })), ); ``` ### Export pipes to CSV ```js const pipes = network.pipes; const rows = ["label,diameter,length,start,end"]; for (const [i, p] of pipes.entries()) { ui.progress(i / pipes.length); rows.push([p.label, p.diameter, p.length, p.startNode.label, p.endNode.label].join(",")); } ui.progress(1); console.log(`${pipes.length} pipes · diameter in ${network.units.diameter}`); ui.attachFile("pipes.csv", rows.join("\n")); ``` ### Count by custom attribute ```js const ATTRIBUTE = "Class"; // the custom attribute's name, as in the asset panel const TYPE = "pipe"; // the asset type it is defined for const assets = network.assets.filter((a) => a.type === TYPE); const counts = new Map(); const missing = []; for (const asset of assets) { const value = asset.getCustom(ATTRIBUTE); if (value === null) missing.push(asset); const key = value ?? "(empty)"; counts.set(key, (counts.get(key) ?? 0) + 1); } if (missing.length === assets.length) { throw new Error(`No ${TYPE} has a value for "${ATTRIBUTE}" — check the name`); } console.log(`${assets.length} ${TYPE}s · ${missing.length} without "${ATTRIBUTE}"`); console.table([...counts].map(([value, count]) => ({ [ATTRIBUTE]: value, count }))); // Selects the ones still missing a value, so they can be filled in. if (missing.length > 0) selection.set(missing); ``` ### Expand selection by one hop ```js if (selection.isEmpty) throw new Error("Select some assets first"); const start = selection.assets; const expanded = new Set(start); for (const asset of start) { const links = asset.isNode ? asset.links : [asset]; for (const link of links) { expanded.add(link); expanded.add(link.startNode); expanded.add(link.endNode); } } const counts = {}; for (const asset of expanded) { counts[asset.type] = (counts[asset.type] ?? 0) + 1; } console.log(`${start.length} → ${expanded.size} assets`); console.table(Object.entries(counts).map(([type, count]) => ({ type, count }))); selection.set([...expanded]); map.zoomTo([...expanded]); ``` ### Trace the zone around the selection ```js const MAIN_DIAMETER = 300; // stop at pipes at or above this (project units) // The Trace tool's boundary mode stops at tanks, reservoirs, pumps, closed // links and valves other than open TCVs. The function in stopAt adds the // mains, so the zone is the area fed through the smaller pipes around the // selection. A function can stop on any field: material, age, a custom // attribute. if (selection.isEmpty) throw new Error("Select a junction or a pipe to start from"); const zone = network.trace(selection.assets, { stopAt: [ "tanks", "reservoirs", "pumps", "controlValves", (asset) => asset.type === "pipe" && asset.diameter >= MAIN_DIAMETER, ], }); console.log(`${zone.nodes.length} nodes · ${zone.links.length} links · ${zone.boundaryLinks.length} on the edge`); console.table( zone.boundaryLinks.map((b) => ({ link: b.link, type: b.link.type, inside: b.inside, outside: b.outside, })), ); const assets = [...zone.nodes, ...zone.links]; selection.set(assets); map.zoomTo(assets); ``` ### Summarise each zone ```js // Counts the junctions, pipe length and customer demand inside each zone // imported with File > Import > Zones. A pipe counts only when all of it is // inside, so one crossing a boundary is in no zone. if (zones.count === 0) throw new Error("This project has no zones — import them first"); const rows = zones.list.map((zone) => { const inside = network.findWithin(zone.geometry, { types: ["junction", "pipe", "customerPoint"], }); const of = (type) => inside.filter((a) => a.type === type); return { zone, junctions: of("junction").length, pipeLength: Math.round(of("pipe").reduce((sum, p) => sum + p.length, 0)), demand: of("customerPoint").reduce((sum, cp) => sum + cp.demand, 0), }; }); console.log(`${zones.count} zones · length in ${network.units.length}, demand in ${network.units.customerDemand}`); console.table(rows); // A bookmark per zone in Collections, to jump between them from the map. for (const zone of zones.list) collections.saveBookmark(zone.label, zone.bbox); const outside = network.junctions.filter((j) => zones.at(j).length === 0); if (outside.length > 0) { console.warn(`${outside.length} junctions are in no zone`); selection.set(outside); map.zoomTo(outside); } ``` ### Network review report ```js const PROXIMITY = network.units.length === "ft" ? 1.5 : 0.5; // the review panel's default (project units) // The Network Review panel's checks in one pass: the checks behind Run // (orphans, areas with no reservoir or tank, attribute rules), then pipes // that cross without a junction and nodes close to a pipe they are not // connected to. Each finding is saved as a selection set in Collections. const report = network.validate(); const crossings = network.findCrossingPipes(); const anomalies = network.findProximityAnomalies({ distance: PROXIMITY }); const findings = report.findings.map((finding) => ({ severity: finding.severity, check: finding.code, assets: finding.details ? [...finding.details.nodes, ...finding.details.links] : finding.assets, })); if (crossings.length > 0) { findings.push({ severity: "info", check: "Crossing pipes", assets: [...new Set(crossings.flatMap(({ pipe1, pipe2 }) => [pipe1, pipe2]))], }); } if (anomalies.length > 0) { findings.push({ severity: "info", check: "Nodes close to pipes", assets: anomalies.map((anomaly) => anomaly.node), }); } if (findings.length === 0) { console.log("No issues found"); } else { const { errors, warnings } = report.counts; console.warn(`${errors} errors · ${warnings} warnings · ${findings.length} findings`); console.table( findings.map((f, i) => ({ "#": i + 1, severity: f.severity, check: f.check, assets: f.assets.length })), ); const rows = ["finding,severity,check,type,label"]; for (const [i, f] of findings.entries()) { collections.saveSelectionSet(`${i + 1}. ${f.check}`, f.assets); for (const asset of f.assets) { rows.push([i + 1, f.severity, f.check, asset.type, asset.label].join(",")); } } ui.attachFile("network-review.csv", rows.join("\n")); // Findings come worst first. selection.set(findings[0].assets); map.zoomTo(findings[0].assets); } ``` ### Where does this node get its water? ```js // The path from each reservoir and tank to the selected node. weightBy // makes a pipe cost length / diameter⁵, roughly how its headloss grows, so // the path follows the large pipes the water would take rather than the // shortest line. Pumps and valves cost nothing; a pipe with no diameter is // never crossed. const [node] = selection.assets; if (selection.assets.length !== 1 || !node.isNode) throw new Error("Select one node"); const sources = [...network.reservoirs, ...network.tanks]; if (sources.length === 0) throw new Error("This network has no reservoir or tank"); const preferLargePipes = (link) => { if (link.type !== "pipe") return 0; return link.diameter > 0 ? link.length / link.diameter ** 5 : Infinity; }; const rows = []; const paths = []; for (const source of sources) { const path = network.shortestPath(source, node, { weightBy: preferLargePipes }); if (path === null) continue; const pipes = path.links.filter((link) => link.type === "pipe"); const smallest = pipes.reduce((min, p) => (min === null || p.diameter < min.diameter ? p : min), null); rows.push({ source, length: Math.round(path.totalLength), pipes: pipes.length, smallestPipe: smallest ?? "", diameter: smallest?.diameter ?? "", }); paths.push(...path.nodes, ...path.links); } if (rows.length === 0) throw new Error(`No reservoir or tank reaches ${node.label}`); console.log(`${rows.length} sources reach ${node.label} · length in ${network.units.length}, diameter in ${network.units.diameter}`); console.table(rows); const assets = [...new Set(paths)]; selection.set(assets); map.zoomTo(assets); ``` ### Pattern and curve usage ```js // Finds what uses each pattern and curve, and which ones nothing uses. const patternUses = new Map(network.patterns.map((p) => [p.label, []])); const curveUses = new Map(network.curves.map((c) => [c.label, []])); const use = (uses, find, label, by) => { if (label === null) return; const item = find(label); if (!item) throw new Error(`${by} uses "${label}", which is not in the library`); uses.get(item.label).push(by); }; const usePattern = (label, by) => use(patternUses, network.getPattern, label, by); const useCurve = (label, by) => use(curveUses, network.getCurve, label, by); const total = network.assets.length + network.customerPoints.length; let done = 0; const step = () => { if (++done % 1000 === 0) ui.progress(done / total); }; for (const node of network.nodes) { step(); usePattern(node.chemicalSourcePattern, `${node.label} quality source`); if (node.type === "junction") { for (const d of node.demands) usePattern(d.pattern ?? null, `${node.label} demand`); } if (node.type === "reservoir") usePattern(node.headPattern, `${node.label} head`); if (node.type === "tank") useCurve(node.volumeCurve, `${node.label} volume`); } for (const link of network.links) { step(); if (link.type === "pump") { usePattern(link.speedPattern, `${link.label} speed`); usePattern(link.energyPricePattern, `${link.label} energy price`); useCurve(link.curve, `${link.label} curve`); useCurve(link.efficiencyCurve, `${link.label} efficiency`); } if (link.type === "valve") useCurve(link.curve, `${link.label} curve`); } for (const cp of network.customerPoints) { step(); for (const d of cp.demands) usePattern(d.pattern ?? null, `${cp.label} demand`); } ui.progress(1); const round = (n) => Math.round(n * 1000) / 1000; const unused = [ ...[...patternUses].filter(([, uses]) => uses.length === 0).map(([label]) => `pattern ${label}`), ...[...curveUses].filter(([, uses]) => uses.length === 0).map(([label]) => `curve ${label}`), ]; console.log(`${patternUses.size} patterns · ${curveUses.size} curves · ${unused.length} unused`); if (unused.length > 0) console.warn(`Unused: ${unused.join(", ")}`); console.table( network.patterns.map((p) => ({ label: p.label, type: p.type, steps: p.multipliers.length, min: Math.min(...p.multipliers), max: Math.max(...p.multipliers), mean: round(p.multipliers.reduce((sum, m) => sum + m, 0) / p.multipliers.length), uses: patternUses.get(p.label).length, })), ); console.table( network.curves.map((c) => ({ label: c.label, type: c.type, points: c.points.length, uses: curveUses.get(c.label).length, })), ); const rows = ["kind,label,used by"]; for (const [kind, uses] of [["pattern", patternUses], ["curve", curveUses]]) { for (const [label, by] of uses) { if (by.length === 0) rows.push([kind, label, ""].join(",")); for (const user of by) rows.push([kind, label, user].join(",")); } } ui.attachFile("pattern-and-curve-usage.csv", rows.join("\n")); ```