Skip to main content
A measurement is a read-out of a stored calculator — the trench’s total length, its saw cut, its surface area, how many 6 m pipe sticks it needs — that a work item can price by a rate item. Measurements are available for trench, pit, and volume calculators.

Design

A measurement is a synthetic, read-only layer the engine emits as an ordinary CalculatorOutputRow with layerKind: "measurement". It is not a new kind of link. Because estimate_calculator_links.layer_id is varchar(64) NOT NULL and participates in all three of that table’s unique indexes, a nullable whole-calculator scope would silently disable every one of them. Emitting a row with the existing layerKind field instead means the link register, the partition validator, the prune, the reconcile, the key minting, and the provenance link all work unchanged. A measurement row carries its figure under the key that names its dimension: a saw cut is lengthM, a pipe run is lengthM, a surface is areaM2. LAYER_OUTPUT_KEYS does not move and neither API’s output_key CHECK constraint changes.

Entry point

calculatorMeasurements accepts any WorkItemComponentCalculator and returns every CalculatorMeasurement the arm can offer, in the order the arm emits them: overall readings first, then each layer’s own axes. A refused calculator (a D67 partition over 100 %) has no rows and therefore no measurements. An empty list is returned.

CalculatorMeasurement

value: undefined means the arm cannot state this measurement for this geometry, for example a length on a pit or a perimeter on a volume in areaDepth mode. The row is still emitted and the measurement is still listed, disabled, with reason set. Do not read undefined as zero.

CalculatorMeasurementScope

"overall" is a reading of the whole calculator. { layerId } names a layer the arm already emits a row for and restates one of its axes. Layer-scoped measurements exist because the item–destination unique index makes two links from one layer to one work item unrepresentable — a synthetic id per measurement sidesteps that index while keeping the model uniform.

Layer ids

Synthetic ids follow a fixed format: The prefix m: (MEASUREMENT_LAYER_PREFIX) lets a host distinguish a measurement from a real layer when it holds a stored link but no engine row. A real layer id is always a UUID or one of the fixed pit- / vol- keys and never contains a colon, so there is no ambiguity.
parseMeasurementLayerId returns undefined for anything that is not a measurement id, including all real layer ids.

Layer id length limit

m:l:<layerId>:<key> costs 13 characters on top of the layer’s own id. A layer id over 51 characters mints a measurement id that exceeds varchar(64) — the layer’s own link would still be accepted while its measurement would answer 422 calculator_layer_id_too_long. usableMeasurements removes those entries before they reach the modal.

Trench measurements

trenchOverallMeasurements emits these readings for the whole calculator: The trench no longer emits Pipe lengths (3 m / 5.8 m / 6 m) count readings. Different pipes come in different stock lengths, so the stock length belongs to the pipe’s rate option (priced in length, with metres per length), not to the calculator. Pipe run length is the only pipe reading.

Perimeter vs circumference on a trench

The trench offers two related length readings:
  • Footprint perimeter (m:o:perim) — 2 × (length + top width). The plan outline of the opening at surface level.
  • Circumference (m:o:circ) — the same formula, 2 × (length + top width). Intended for concrete cutting around the full excavation perimeter. Use whichever label matches the rate item.
The two values are equal for a trench. Saw cut (m:o:sawcut, 2 × length) is the figure to use when the saw runs only along the two long edges, not the ends.

Pipe runs and whole lengths

Whole-length counting now happens at pricing, not in the calculator. The count is rounded up per run: each run has its own start and end, so the offcut from one cannot start the next. Two 12.5 m pipes at 6 m per length need 3 + 3 = 6 lengths, not ⌈25 / 6⌉ = 5. The pipe row states only the product (quantity × trench length), so calculators states the runs behind it beside the rows: parallelLengthRuns(quantity, lengthMetres) and calculatorLengthRuns. The host passes them to domain’s orderedQuantityForPricedUnit, which applies the rate option’s metres per length. Nothing on a row changes, so the parity goldens do not move. A fractional quantity is a shorter final run, not rounded away. quantity: 1.5 on a 12 m trench is one 12 m run and one 6 m run, each rounded up on its own.

Pit measurements

pitOverallMeasurements emits these per-unit readings: The plan is the neat rectangle (lengthM × widthM), not the cut plan. The overall calculator reading m:o:areaM2 remains that neat plan area. Use Surface area (m:o:surfaceM2) to price the cut opening. The bulk excavation row also states the cut-surface area as m:l:pit-excavation:areaM2, but that reading cannot be linked: layer-scoped bulk-dig readings (m:l:pit-excavation:*) are excluded from all three assignment paths in the Activities and Estimate services. Length is not stated because a pit is a rectangular plan, not a run: its two plan axes are both sides of the same footprint and neither is a length to price by the metre.

Perimeter vs circumference on a pit

  • Perimeter (m:o:perim) — 2 × (length + width) using the neat plan dimensions the estimator entered. This is the R4 footprint.
  • Circumference (m:o:circ) — 2 × (cut length + cut width) at the surface, accounting for batter and overdig on all four sides. Use this for concrete cutting or any rate that charges against the actual opening at ground level.

Volume measurements

volumeOverallMeasurements emits these per-unit readings: Length is not stated in either mode. In lwd mode the two plan axes are interchangeable, so neither is a run to price by the metre. In lwd mode the plan area (m:o:areaM2) is the neat lengthM × widthM and the surface area (m:o:surfaceM2) is the cut opening, so the two differ wherever the dig batters or over-digs. Both outline readings are available:
  • Perimeter (m:o:perim): 2 × (length + width) using the neat plan dimensions the estimator entered.
  • Circumference (m:o:circ): 2 × (cut length + cut width) at the surface, accounting for batter and overdig. Use this for concrete cutting or any rate charged against the actual opening at ground level.
In areaDepth mode an area has no length or width, so both are listed but disabled. The perimeter’s reason is "Area × Depth mode has an area but no length or width, so there is no outline to measure a perimeter from. Use Length × Width × Depth mode if the perimeter is what you are pricing." The circumference’s reason is "Circumference needs a length and width. An area alone does not define the excavation outline; use Length × Width × Depth mode."

Layer measurements

Each arm appends one measurement per axis per layer for the layers it already emits rows for. The three axes are area (areaM2), length (lengthM), and volume (inSituM3). A layer’s count axis is not restated — the count is the layer’s own row subject and is directly linkable.
The engine emits each synthetic layer-measurement id only once. When a physical layerId appears once in the row set, its measurement carries that row’s value. A D67-partitioned excavation stratum emits one physical row per destination with the same layer id, so its single synthetic measurement states no value and explains that the slices must be priced separately in Layer destinations. This avoids duplicate m:l:<stratumId>:<key> ids and avoids inventing an aggregate from quantities that belong to different destinations. The name is the row’s own label plus the axis — for example, "Asphalt — area".

Rows emitted even when undefined

A measurement in an arm’s stable vocabulary is emitted whether or not it currently has a value:
  • An arm that cannot state a figure emits a row with outputs: {}.
  • A partitioned stratum emits one row per measurement axis with outputs: {} and the partition reason.
This lets the assignment UI show the unavailable measurement with its reason. On a server-derived calculator save, the Estimate and Activities APIs prune an existing measurement link and component row when the reading is absent or non-positive. A client-supplied link survives the register write that creates it; the next server-derived save applies the prune rule if the reading is still spent.

Utility functions

isMeasurementRow / nonMeasurementRows / measurementRows

isMeasurementRow(row) — returns true when row.layerKind === "measurement". Use this predicate anywhere that must skip measurements:
  • api-estimate and api-activities seedLinksForDefault — auto-links every emitted layer not already linked. Without the skip, every measurement of every existing calculator auto-prices onto the default destination on the next save.
  • The D67 Layer destinations view — lists allocatable layers.
  • Parity gates that compare against corpus dumps taken before v1.4.0.
nonMeasurementRows(rows) — returns the rows that are layers of the ground (the set every engine before 1.4.0 emitted, unchanged). measurementRows(measurements) — converts CalculatorMeasurement[] to CalculatorOutputRow[] with layerKind: "measurement" and calcKey === layerId.

measurementFromRow

Reads an emitted measurement row back as a CalculatorMeasurement. Used by calculatorMeasurements so the list a modal offers and the rows the server stores are the same numbers by construction rather than by two parallel computations that could drift. Returns undefined for a non-measurement row or one whose id does not parse — a row from a future engine is skipped rather than guessed at.

usableMeasurements

Filters out measurements the register is guaranteed to refuse:
  • A collision with a real layer id — EntityId is an unrestricted string, so a config carrying a layer whose id is literally "m:o:lengthM" would give that layer’s row and the total-length measurement the same layerId. The real row keeps the id; the measurement is removed.
  • An id that exceeds varchar(64) — a layer id over 51 characters mints a measurement id the column cannot hold. Offering it would guarantee a 422 calculator_layer_id_too_long the estimator cannot act on.
The collision check is on the layerId AND the calcKey of each priced row. A fill layer’s calcKey is layer-<id>, so guarding calcKey alone would miss the layerId collision.

Release

Measurement rows are appended to the row set and never interleave with existing rows, so all pre-v1.4.0 corpora and golden files are byte-unchanged. nonMeasurementRows extracts the unchanged set for parity comparisons. The measurement surface was introduced in calculators 1.4.0. Version 1.4.1 added the trench depth reading and made measurement values consistently per-unit. The base package was unchanged at 1.3.0 for these releases.