A point on the map.
type Position = [lng: number, lat: number];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.
To have an LLM write a script, give it /llms.txt: this reference as one plain-text file.
This is the complete surface a script sees. Everything below is injected into the sandbox; nothing else exists.
network, zones, selection, map, console, ui, collections. There is no fetch, DOM, require/import, timers (setTimeout), structuredClone or network access.Array, Map, Set, JSON, Math, Number, String, template strings, destructuring, async/await (top-level await is allowed).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.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).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(...)).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).[lng, lat] positions; a bounding box is [minLng, minLat, maxLng, maxLat].asset.isActive === false) are in the lists and lookups but are left out of the simulation, the INP and the review checks.network — the modelStart 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. |
The open project. source is where it came from: a saved project, an imported INP, or a new one.
interface ProjectInfo {
name: string;
fileName: string | null;
source: "project" | "inp" | "new";
isDemo: boolean;
unitSystem: string;
headlossFormula: "H-W" | "D-W" | "C-M";
}Counts per type and the network's extent.
interface NetworkStats {
junctions: number;
reservoirs: number;
tanks: number;
pipes: number;
pumps: number;
valves: number;
customerPoints: number;
bounds: BBox | null;
}Quantity → unit for every value the API hands out.
type Units = Readonly<Record<string, string | null>>;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.
The simulation settings the next run would use, with unset hydraulic options resolved to what EPANET uses.
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.
| 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. |
The junction, reservoir or tank with that label, or null.
getNode(label: string): NodeAsset | nullThe pipe, pump or valve with that label, or null.
getLink(label: string): LinkAsset | nullThe node or link with that label, or null.
get(label: string): Asset | nullPrefer getNode or getLink: a node and a link may share a label.
network.get("X") matches a node and a link; use getNode or getLink when both exist.The customer point with that label, or null.
getCustomerPoint(label: string): CustomerPoint | nullCustomer 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).
Every asset is a read-only object of getters. A value the model has not set reads as null.
As 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. |
A custom attribute's value by its name, or null.
getCustom(name: string): string | number | nullnetwork.customAttributes(type) lists the attributes defined for each type.
As 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. |
As 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.
|
As 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.
|
As 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.
|
As 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. |
The point that far along the line, in the project length unit.
pointAt(distance: number): PositionAs 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 |
| 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.
|
As 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 |
| pump.results | PumpResults | null | This pump's simulation results at the timestep the user had selected in the app when the script started.
|
As 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.
|
As 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. |
A custom attribute's value by its name, or null.
getCustom(name: string): string | number | nullThe six asset types.
type AssetType = "junction" | "reservoir" | "tank" | "pipe" | "pump" | "valve";One demand category: a base demand and the label of its pattern, absent when it has none.
interface Demand { base: number; pattern?: string }A pump's structured control from the app. Times are in seconds.
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;
};A junction's simulation results at one timestep.
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;
}A reservoir's simulation results at one timestep.
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;
}A tank's simulation results at one timestep.
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;
}A pipe's simulation results at one timestep.
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;
}A pump's simulation results at one timestep.
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;
}A valve's simulation results at one timestep.
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;
}| 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. |
One pattern by label, or null.
getPattern(label: string): Pattern | nullOne curve by label, or null.
getCurve(label: string): Curve | nullThe custom attributes defined for a type.
customAttributes(type: AssetType | "customerPoint"): CustomAttribute[]Read a value with asset.getCustom(name).
A time pattern.
interface Pattern {
label: string;
type: "demand" | "reservoirHead" | "pumpSpeed" | "qualitySourceStrength" | "energyPrice";
multipliers: number[];
}A shared curve.
interface Curve {
label: string;
type: "pump" | "efficiency" | "volume" | "valve" | "headloss";
points: { x: number; y: number }[];
}A pipe library material: the roughness it has at each age.
interface PipeMaterial { label: string; entries: { age: number; roughness: number }[] }A custom attribute defined for an asset type.
interface CustomAttribute { name: string; type: "text" | "number" }A customer point allocation rule.
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.
As network. The model the script reads.
| Property | Type | Description |
|---|---|---|
| network.geo | NetworkGeo | Geodesic helpers in the project length unit. |
The assets inside an area.
findWithin(area: Area, options?: { types?: (AssetType | "customerPoint")[] }): (Asset | CustomerPoint)[]options.types — The asset types to return, and "customerPoint". The default is every asset type, without customer points.The assets within radius of a position, nearest first.
findNear(position: Position, options: { radius: number; types?: (AssetType | "customerPoint")[] }): (Asset | CustomerPoint)[]options.radius — The distance, in the project length unit.options.types — As for findWithin.As network.geo. Geodesic helpers in the project length unit.
The geodesic distance between two positions.
distance(a: Position, b: Position): numberThe length of a line of positions.
length(line: Position[]): numberThe position that far along a line.
pointAlong(line: Position[], distance: number): PositionThe closest point on a line to a position.
nearestPoint(line: Position[], position: Position): NearestPointThe position distance away on a bearing in degrees.
destination(from: Position, bearing: number, distance: number): PositionWhere 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.
type Area =
| GeoJSON.Polygon
| GeoJSON.MultiPolygon
| GeoJSON.Feature<GeoJSON.Polygon | GeoJSON.MultiPolygon>
| BBox
| Position[];The closest point on a line, how far it is, how far along the line, and the fraction of the line.
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.
As network. The model the script reads.
The walk over the pipes to reach for. By default it is the app's Trace tool, boundary mode: the pressure zone around start.
trace(start: Asset | string | (Asset | string)[], options?: { mode?: "allowed" | "connected" | "upstream" | "downstream"; stopAt?: (StopPreset | ((asset: Asset) => boolean))[]; maxDistance?: number; maxHops?: number }): TraceResultBy 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.
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.network.trace found no asset labelled "X"network.trace has no option "X"; it takes mode, stopAt, maxDistance and maxHopsnetwork.trace expects stopAt functions to return true or falsenetwork.trace expects maxDistance as a number of at least 0, in the project length unitnetwork.trace expects maxHops as a whole number of at least 0network.trace mode "downstream" needs simulation results; run a simulation first when there is no run, or it has no timestepsconst zone = network.trace("J1", { stopAt: [] });
for (const stop of zone.stoppedAt) console.log(stop.asset.label, stop.reason, "from", stop.from.label);const area = network.trace("J1", {
stopAt: ["tanks", "reservoirs", "pumps", "controlValves", (asset) => asset.type === "pipe" && asset.diameter >= 300],
});
selection.set(area.nodes);const piece = network.trace("J1", { mode: "connected", stopAt: ["closed"] });
console.log(piece.nodes.length, "nodes;", piece.stoppedAt.length, "closed links on the rim");const near = network.trace("J1", { stopAt: [], maxDistance: 500 });
console.table(near.nodes.map((node) => ({ node: node.label, distance: near.distance(node) })));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);The rim of any set of nodes: every link with exactly one end inside.
boundaryLinks(nodes: (NodeAsset | string)[]): BoundaryLink[]The cheapest path between two nodes, or null when they are not connected.
shortestPath(from: NodeAsset | string, to: NodeAsset | string, options?: { weightBy?: "length" | ((link: LinkAsset) => number) }): Path | nulloptions.weightBy — "length", the default (pipe lengths; pumps and valves cost 0), or a function returning a cost of at least 0.As valve. A valve; its setting unit follows its kind. Has every LinkAsset member.
For a PRV, the nodes the asset panel offers as its remote set-point target (downstream of the valve); [] for any other kind.
targetNodeOptions(): NodeAsset[]A link on the rim of a set of nodes. For a PRV on the rim, inside is the zone end.
interface BoundaryLink { link: LinkAsset; inside: NodeAsset; outside: NodeAsset }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".
type StopPreset =
| "tanks" | "reservoirs" | "pumps"
| "valves" | "controlValves" | "cv" | "closed"
| "prv" | "psv" | "fcv" | "pbv" | "tcv" | "gpv" | "pcv";Where a trace stopped and why: the barrier, and the node it was reached from.
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.
The zone a trace reached, the links on its rim, every barrier it stopped at, and how far along the pipes each node is.
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.
A path between two nodes.
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).
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).
The checks behind the dialog that opens when you press Run.
validate(): ValidationReportThe 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.
The orphan check: nodes with no links, links missing an end, and pumps or valves with nothing else connected at either end.
findOrphanAssets(): Asset[]In the review panel's order: by type, then label.
The connectivity check: every connected part of the network, largest first.
findSubnetworks(): Subnetwork[]Parts of a single node are left out (they are orphans).
The proximity check: nodes within distance of a pipe they are not connected to, nearest first.
findProximityAnomalies(options: { distance: number }): ProximityAnomaly[]options.distance — In the project length unit. The review panel's default is 0.5 m or 1.5 ft.The crossing check: pipes that cross on the map with no junction where they meet.
findCrossingPipes(): CrossingPipe[]What network.validate() found.
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".
One rule and everything that failed it.
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.A connected part of the network. One with supplySourceCount === 0 has no reservoir or tank.
interface Subnetwork {
nodes: NodeAsset[];
links: LinkAsset[];
supplySourceCount: number;
pipeCount: number;
bounds: BBox;
}A node close to a pipe it is not connected to.
interface ProximityAnomaly { node: NodeAsset; pipe: Pipe; distance: number; nearestPointOnPipe: Position }Two pipes that cross with no junction where they meet. The smaller pipe is pipe1.
interface CrossingPipe { pipe1: Pipe; pipe2: Pipe; intersectionPoint: Position }findCrossingPipes() and findProximityAnomalies() are informational — never a reason a run fails.
The network as INP text, built the way File › Export INP builds it, from the model the script reads.
toInp(): stringCoordinates, 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.
ui.attachFile(network.name + ".inp", network.toInp());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.
As 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 |
As 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. |
Label → lowest value over the run for every node. The network-wide form of node.results.stats, with the same numbers.
minValues(property: "pressure" | "head"): Map<string, number>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.
pressure or headThe simulation results are no longer available; run the simulation again when a newer run has replaced the stored resultsconst low = [...network.results.minValues("pressure")]
.filter(([, p]) => p < 20)
.map(([label]) => label);
console.log(low.length, "nodes drop below 20", network.units.pressure);Label → highest value over the run for every node. The same rules as minValues.
maxValues(property: "pressure" | "head"): Map<string, number>pressure or headThe simulation results are no longer available; run the simulation again when a newer run has replaced the stored resultsconst high = [...network.results.maxValues("pressure")]
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
console.table(high.map(([node, pressure]) => ({ node, pressure })));How the run ended. A failure or a stopped run has no timesteps; report says why.
type SimulationStatus = "success" | "warning" | "failure" | "stopped";zones — the project's polygonsThe 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.
As zones. The project's polygon zones.
| Property | Type | Description |
|---|---|---|
| zones.list | Zone[] | Every zone. |
| zones.count | number | How many zones there are. |
The first zone with that label, case-insensitively, or null.
get(label: string): Zone | nullEvery zone that contains the target. Zones may overlap; [] means it is in none.
at(target: ZoneTarget): Zone[]As 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. |
Whether the zone contains the target.
contains(target: ZoneTarget): booleanfor (const zone of zones.list) {
const junctions = network.junctions.filter((j) => zone.contains(j));
console.log(zone.label, junctions.length);
}Something a zone can contain: a position, an asset or its label, or a customer point.
type ZoneTarget = Position | Asset | string | CustomerPoint;console and ui.attachFile print a zone as its label. Zones are the same in every scenario.
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. |
Replace the selection.
set(targets: Asset | CustomerPoint | (Asset | CustomerPoint)[]): voidAdd to the selection.
add(targets: Asset | CustomerPoint | (Asset | CustomerPoint)[]): voidSelect nothing.
clear(): voidAt 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").
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. |
Zoom to assets, customer points or a box when the script ends. The last call wins.
zoomTo(target: Asset | CustomerPoint | (Asset | CustomerPoint)[] | BBox, options?: { maxZoom?: number }): voidconsole — outputA 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.
log(...args: unknown[]): voidA warning line.
warn(...args: unknown[]): voidAn error line.
error(...args: unknown[]): voidA table. rows is an array of objects or of plain values; columns picks and orders the keys. Asset cells print as labels.
table(rows: unknown[], columns?: string[]): voidJSON.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.
ui — progress and filesShow progress between 0 and 1 in the run dialog.
progress(fraction: number, message?: string): voidCalls closer than 50 ms apart with the same message are dropped, so calling it every iteration is fine.
Offer a file for download when the script ends.
attachFile(name: string, content: string | object): voidcontent 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.
collections — saved selection sets and bookmarksAs 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 |
| 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 |
Hand back a selection set as part of the run's output when the script ends.
saveSelectionSet(label: string, targets?: Asset | CustomerPoint | (Asset | CustomerPoint)[], options?: { commit?: boolean }): voidThe 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.
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.collections.saveSelectionSet expects a labelcollections.saveSelectionSet expects at least one asset or customer pointcollections.saveSelectionSet expects assets or customer points from the networkcollections.saveSelectionSet expects { commit } as true or falsecollections.saveSelectionSet("Pumps and valves", [...network.pumps, ...network.valves]);collections.saveSelectionSet("Pumps", network.pumps, { commit: true });Hand back a bookmark as part of the run's output when the script ends.
saveBookmark(label: string, bbox?: BBox, options?: { commit?: boolean }): voidThe 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.
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.collections.saveBookmark expects a labelcollections.saveBookmark expects a [minLng, minLat, maxLng, maxLat] boxcollections.saveBookmark expects a box when there is no map viewcollections.saveBookmark expects { commit } as true or falsefor (const zone of zones.list) collections.saveBookmark(zone.label, zone.bbox);collections.saveBookmark("Plant", [-3.71, 40.41, -3.69, 40.43], { commit: true });As 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. |
As 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.
name: message and the script line it came from. Selection, zoom, attachments, selection sets and bookmarks are not applied; console lines already printed stay.console.table shows the first 1,000 rows and says how many it left out; a progress message keeps 200 characters.console.log(network.stats) and console.log(network.units).network.units before hard-coding a threshold. A pipe diameter of 100 is 100 mm in one project and 100 in in another.getNode/getLink over get; never persist uids; address assets by label.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.network.validate(), findOrphanAssets(), findSubnetworks(), findProximityAnomalies({ distance }), findCrossingPipes(), trace, boundaryLinks, shortestPath.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.pipe.appliedRoughness, not pipe.roughness, for the value EPANET receives.junction.demands (categories with patterns) plus the demand of junction.customerPoints; junction.baseDemand is the categories only.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.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.selection.* and fail clearly when it is empty or the wrong type.zones (the drawn polygons); network.trace is a different, connectivity-based "zone".The built-in examples from the Scripts panel.
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);// 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) })),
);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"));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);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]);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);// 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);
}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);
}// 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);// 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"));