epanet-jsAPI version 1llms.txt

epanet-js scripting API

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.

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.
Types

A point on the map.

type Position = [lng: number, lat: number];

A bounding box.

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).

PropertyTypeDescription
network.namestringThe project's name — use it for report titles and file names.
network.projectProjectInfoThe open project: name, file, source, units and headloss formula.
network.statsNetworkStatsCounts per asset type and the network's extent.
network.unitsUnitsQuantity → unit for every value the API hands out.
network.simulationSimulationSettingsThe simulation settings the next run would use.
Types

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.

1.2 Lists and lookup

PropertyTypeDescription
network.junctionsJunction[]Every junction.
network.reservoirsReservoir[]Every reservoir.
network.tanksTank[]Every tank.
network.pipesPipe[]Every pipe.
network.pumpsPump[]Every pump.
network.valvesValve[]Every valve.
network.nodesNodeAsset[]Every junction, reservoir and tank.
network.assetsAsset[]Every node and link.
network.customerPointsCustomerPoint[]Every customer point.

network.getNode(label)

The junction, reservoir or tank with that label, or null.

getNode(label: string): NodeAsset | null

The pipe, pump or valve with that label, or null.

getLink(label: string): LinkAsset | null

network.getCustomerPoint(label)

The customer point with that label, or null.

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

As asset. What every asset carries.

PropertyTypeDescription
asset.uidnumberAn internal number. Never persist it; address assets by label.
asset.labelstringThe name shown in the app and written to INP files.
asset.typeAssetTypeThe asset's type.
asset.isNodebooleanWhether it is a junction, reservoir or tank.
asset.isActivebooleanWhether it takes part in the simulation, the INP and the review checks.
asset.isSelectedbooleanWhether it is in the user's selection, or the script's after selection.set.
asset.geometryGeoJSON.Point | GeoJSON.LineStringThe asset's geometry.

NodeAsset

As node. A junction, reservoir or tank. Has every Asset member.

PropertyTypeDescription
node.coordinatesPositionWhere the node is.
node.elevationnumber | nullThe node's elevation.
node.initialQualitynumber | nullThe initial water quality.
node.chemicalSourceType"concen" | "mass" | "flowpaced" | "setpoint" | nullThe kind of quality source at the node.
node.chemicalSourceStrengthnumber | nullThe quality source's strength.
node.chemicalSourcePatternstring | nullThe label of the quality source's pattern.

Junction

As junction. A demand node. Has every NodeAsset member.

PropertyTypeDescription
junction.emitterCoefficientnumber | nullThe emitter coefficient.
junction.demandsDemand[]The demand categories.
junction.baseDemandnumberThe sum of the categories' base. Demand from customer points is not included.
junction.customerPointsCustomerPoint[]The customer points allocated to the junction.
junction.resultsJunctionResults | nullThis 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

As reservoir. A fixed-head source. Has every NodeAsset member.

PropertyTypeDescription
reservoir.headnumber | nullThe reservoir's head.
reservoir.headPatternstring | nullThe label of the head pattern.
reservoir.resultsReservoirResults | nullThis 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

As tank. A storage node. Has every NodeAsset member.

PropertyTypeDescription
tank.initialLevelnumber | nullThe initial level.
tank.minLevelnumber | nullThe minimum level.
tank.maxLevelnumber | nullThe maximum level.
tank.diameternumber | nullThe tank's diameter, in units.tankDiameter.
tank.minVolumenumber | nullThe minimum volume.
tank.overflowbooleanWhether the tank can overflow.
tank.mixingModel"mixed" | "2comp" | "fifo" | "lifo"The mixing model.
tank.mixingFractionnumber | nullThe mixing fraction.
tank.bulkReactionCoeffnumber | nullThe bulk reaction coefficient.
tank.volumeCurvestring | nullThe label of the volume curve.
tank.resultsTankResults | nullThis 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.

As link. A pipe, pump or valve. Has every Asset member.

PropertyTypeDescription
link.startNodeNodeAssetThe start node. Positive flow runs start → end.
link.endNodeNodeAssetThe end node.
link.lengthnumberThe stored length, in units.length.
link.geometryLengthnumberThe length measured along the drawn line, in units.length.
link.verticesPosition[]The intermediate points of the line.
link.pointAt(distance)

The point that far along the line, in the project length unit.

pointAt(distance: number): Position

Pipe

As pipe. A pipe. Has every LinkAsset member.

PropertyTypeDescription
pipe.diameternumber | nullThe pipe's diameter, in units.diameter.
pipe.roughnessnumber | nullThe pipe's own roughness, often null. Not what the engine gets: read appliedRoughness.
pipe.appliedRoughnessnumber | nullWhat 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" | nullWhere appliedRoughness came from.
pipe.minorLossnumber | nullThe minor loss coefficient.
pipe.status"open" | "closed" | "cv"The initial status.
pipe.materialstring | nullThe pipe's material.
pipe.yearnumber | nullThe year the pipe was installed.
pipe.bulkReactionCoeffnumber | nullThe bulk reaction coefficient.
pipe.wallReactionCoeffnumber | nullThe wall reaction coefficient.
pipe.customerPointsCustomerPoint[]The customer points connected to the pipe.
pipe.resultsPipeResults | nullThis 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

As pump. A pump. Has every LinkAsset member.

PropertyTypeDescription
pump.status"on" | "off"The initial status.
pump.definitionType"power" | "designPointCurve" | "standardCurve" | "curveId"How the pump is defined.
pump.powernumber | nullThe pump's power, for a power pump.
pump.curvePoints{ flow: number; head: number }[] | nullThe pump curve's points, however the pump is defined.
pump.designPoint{ flow: number; head: number } | nullThe single point of a one-point curve, else null.
pump.curvestring | nullThe library curve's label when definitionType === "curveId", else null.
pump.speednumber | nullThe relative speed.
pump.speedPatternstring | nullThe label of the speed pattern.
pump.efficiencyCurvestring | nullThe label of the efficiency curve.
pump.energyPricenumber | nullThe energy price.
pump.energyPricePatternstring | nullThe label of the energy price pattern.
pump.controlPumpControl | nullThe 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.resultsPumpResults | nullThis 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

As valve. A valve; its setting unit follows its kind. Has every LinkAsset member.

PropertyTypeDescription
valve.kind"prv" | "psv" | "fcv" | "pbv" | "tcv" | "gpv" | "pcv"The valve's kind.
valve.settingnumber | nullThe setting, in the unit its kind uses.
valve.diameternumber | nullThe valve's diameter, in units.diameter.
valve.minorLossnumber | nullThe minor loss coefficient.
valve.status"active" | "open" | "closed"The initial status.
valve.curvestring | nullThe label of the valve's curve.
valve.targetNodeNodeAsset | nullA PRV's remote set-point node, or null.
valve.resultsValveResults | nullThis 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

As customerPoint. A customer: a point carrying demand, allocated to a pipe and through it to a junction.

PropertyTypeDescription
customerPoint.uidnumberAn internal number. Never persist it.
customerPoint.labelstringThe customer point's label.
customerPoint.type"customerPoint"Always "customerPoint".
customerPoint.isSelectedbooleanWhether it is in the selection.
customerPoint.coordinatesPositionWhere the customer is.
customerPoint.geometryGeoJSON.PointThe point's geometry.
customerPoint.demandsDemand[]The demand categories.
customerPoint.demandnumberThe sum of the categories' base, in units.customerDemand.
customerPoint.isConnectedbooleanWhether it is allocated to a pipe. A disconnected point contributes no demand.
customerPoint.pipePipe | nullThe pipe it is allocated to.
customerPoint.junctionJunction | nullThe junction its demand goes to.
customerPoint.snapPointPosition | nullThe point on the pipe it connects at.
Types

The 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;
}

1.4 Libraries, controls and attributes

PropertyTypeDescription
network.patternsPattern[]Every pattern.
network.curvesCurve[]Every curve.
network.pipeMaterialsPipeMaterial[]The pipe library.
network.rawControlsstring[]The [CONTROLS] lines as INP text, e.g. "LINK PU1 CLOSED IF NODE T1 ABOVE 5". Filter them as strings.
network.rawRulesstring[]The [RULES] blocks as INP text.
network.defaultAllocationRulesAllocationRule[]The default customer point allocation rules for the project's units.
Types

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.

1.5 Spatial queries and geometry

Network

As network. The model the script reads.

PropertyTypeDescription
network.geoNetworkGeoGeodesic helpers in the project length unit.
network.findWithin(area, options?)

The assets inside an area.

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.

NetworkGeo

As network.geo. Geodesic helpers in the project length unit.

Types

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.

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.

1.6 Topology and tracing

Network

As 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.

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
Why a node is cut off
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
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
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
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
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[]
network.shortestPath(from, to, options?)

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 | 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

As 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.

targetNodeOptions(): NodeAsset[]
Types

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).

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.

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.

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.

findSubnetworks(): Subnetwork[]

Parts of a single node are left out (they are orphans).

network.findCrossingPipes()

The crossing check: pipes that cross on the map with no junction where they meet.

findCrossingPipes(): CrossingPipe[]
Types

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.

1.8 Export

network.toInp()

The network as INP text, built the way File › Export INP builds it, from the model the script reads.

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.

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

As network. The model the script reads.

PropertyTypeDescription
network.resultsSimulationResult | nullThe 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

As results. The simulation the user last ran, as it was when the script started.

PropertyTypeDescription
results.statusSimulationStatusHow the run ended: success, warning, failure or stopped.
results.reportstringThe EPANET report of the run, with its warnings and errors.
results.isStalebooleanWhether the model or the simulation settings changed after the run, so the results describe an earlier model.
results.timestepsnumber[]Seconds from the start, one per reported timestep; [0] for a steady-state run, empty when the run has no results.
results.stepCountnumberHow many timesteps the run reported.
results.currentStepnumber | nullThe 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.

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.

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
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.

maxValues(property: "pressure" | "head"): Map<string, number>
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
const high = [...network.results.maxValues("pressure")]
  .sort(([, a], [, b]) => b - a)
  .slice(0, 5);
console.table(high.map(([node, pressure]) => ({ node, pressure })));
Types

How the run ended. A failure or a stopped run has no timesteps; report says why.

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

As zones. The project's polygon zones.

PropertyTypeDescription
zones.listZone[]Every zone.
zones.countnumberHow many zones there are.

zones.get(label)

The first zone with that label, case-insensitively, or null.

get(label: string): Zone | null

Zone

As zone. One polygon zone.

PropertyTypeDescription
zone.labelstringThe zone's label.
zone.geometryGeoJSON.MultiPolygonThe zone's polygons, holes included.
zone.bboxBBoxThe zone's extent.
zone.areanumberThe zone's area, in units.area.
Types

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.

3. selection — the user's selection in, the script's out

PropertyTypeDescription
selection.assetsAsset[]The selected assets.
selection.customerPointsCustomerPoint[]The selected customer points.
selection.nodesNodeAsset[]The selected nodes.
selection.junctionsJunction[]The selected junctions.
selection.reservoirsReservoir[]The selected reservoirs.
selection.tanksTank[]The selected tanks.
selection.pipesPipe[]The selected pipes.
selection.pumpsPump[]The selected pumps.
selection.valvesValve[]The selected valves.
selection.labelsstring[]The labels of everything selected.
selection.countnumberHow many things are selected.
selection.isEmptybooleanWhether nothing is selected.

selection.add(targets)

Add to the selection.

add(targets: Asset | CustomerPoint | (Asset | CustomerPoint)[]): 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

PropertyTypeDescription
map.boundsBBox | nullThe viewport at run start.
map.centerPosition | nullThe centre of the viewport.
map.zoomnumber | nullThe zoom level.

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.

log(...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.

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.

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.

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

As collections. The project's selection sets and bookmarks, and new ones saved when the script ends.

PropertyTypeDescription
collections.selectionSetsSelectionSet[]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.bookmarksBookmark[]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.

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("Pumps and valves", [...network.pumps, ...network.valves]);
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.

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
A bookmark per zone
for (const zone of zones.list) collections.saveBookmark(zone.label, zone.bbox);
collections.saveBookmark("Plant", [-3.71, 40.41, -3.69, 40.43], { commit: true });

SelectionSet

As selectionSet. A saved selection set: its label and the members still in the model.

PropertyTypeDescription
selectionSet.labelstringThe set's name. Not unique.
selectionSet.assetsAsset[]The set's assets that are still in the model.
selectionSet.customerPointsCustomerPoint[]The set's customer points that are still in the model.
selectionSet.missingnumberHow many members were deleted from the model since the set was saved.

Bookmark

As bookmark. A saved map view: its label and box.

PropertyTypeDescription
bookmark.labelstringThe bookmark's name. Not unique.
bookmark.bboxBBoxThe 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 uids; 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
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
// 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
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
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
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
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
// 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
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?
// 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
// 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"));