> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Calculator measurements

> Read-only synthetic layers a work item can price from a stored calculator (v1.4.0)

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

```ts theme={null}
import { calculatorMeasurements } from "@stralos-co-nz/calculators";
import type { WorkItemComponentCalculator } from "@stralos-co-nz/base";

const measurements = calculatorMeasurements(calculator);
```

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

| Field | Type | Notes |
| - | - | - |
| `layerId` | `string` | Synthetic layer id. The link's `layer_id`. See [Layer ids](#layer-ids). |
| `scope` | `CalculatorMeasurementScope` | `"overall"` or `{ layerId: EntityId }`. |
| `name` | `string` | Display label, matching the emitted row verbatim. |
| `outputKey` | `CalculatorOutputKey` | The link key (one of the six the register accepts). |
| `value` | `number \| undefined` | The figure as the row states it. `undefined` when the arm cannot state it. |
| `reason` | `string` (optional) | Present only when `value` is absent. The sentence the modal shows beside a disabled entry. |

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

```ts theme={null}
type CalculatorMeasurementScope = "overall" | { layerId: EntityId };
```

`"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:

| Format | Scope |
| - | - |
| `m:o:<key>` | Overall reading |
| `m:l:<layerId>:<key>` | Layer-scoped reading |

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.

```ts theme={null}
import {
  MEASUREMENT_LAYER_PREFIX,
  measurementLayerId,
  parseMeasurementLayerId,
} from "@stralos-co-nz/calculators";

const id = measurementLayerId("overall", "lengthM");       // "m:o:lengthM"
const parsed = parseMeasurementLayerId("m:o:lengthM");     // { scope: "overall", key: "lengthM" }
const isLayerId = parseMeasurementLayerId(someId) !== undefined;
```

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

| Name | Key | Notes |
| - | - | - |
| Total length | `lengthM` | The trench's `lengthMetres`. |
| Saw cut length (2 × length) | `lengthM` | Two cut lines down the trench. |
| Circumference (excavation top perimeter) | `lengthM` | Perimeter of the excavation opening: 2 × (length + top width). |
| Surface area (top width × length) | `areaM2` | Plan area at the surface. |
| Depth | `lengthM` | The design floor depth entered by the estimator. |
| Excavation volume | `inSituM3` | |
| Pipe run length | `lengthM` | Sum of pipe run lengths. |
| Circumference (excavation top perimeter) | `lengthM` | `2 × (length + top width)`. Top width is the battered, over-dug cut width at the surface. |

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:

| Name | Key | Notes |
| - | - | - |
| Total length | — | Not stated. Disabled with reason. |
| Plan area (length × width) | `areaM2` | Neat rectangle (R4). Layer id `m:o:areaM2`. |
| Surface area | `areaM2` | The cut opening at ground level, expanding the neat rectangle by batter and overdig. Layer id `m:o:surfaceM2` (calculators 1.17.0). Equals the plan area when the pit neither batters nor over-digs. |
| Perimeter (2 × (length + width)) | `lengthM` | Neat rectangle (R4). |
| Depth | `lengthM` | The design floor depth. |
| Excavation volume | `inSituM3` | |
| Circumference (excavation top perimeter) | `lengthM` | The cut perimeter at the surface: `2 × (cut length + cut width)`, expanding the neat dimensions by batter and overdig. |

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:

| Name | Key | Notes |
| - | - | - |
| Total length | — | Not stated. Disabled with reason. |
| Plan area | `areaM2` | `lengthM × widthM` in `lwd` mode; the typed area in `areaDepth` mode. Layer id `m:o:areaM2`. |
| Surface area | `areaM2` | The cut opening at ground level. In `lwd` mode it expands the neat plan by batter and overdig; in `areaDepth` mode it is the typed area. Layer id `m:o:surfaceM2` (calculators 1.17.0). |
| Perimeter (2 × (length + width)) | `lengthM` | `lwd` mode only: the neat plan outline, as on the pit. Not stated in `areaDepth` mode. |
| Depth | `lengthM` | |
| Excavation volume | `inSituM3` | |
| Circumference (excavation top perimeter) | `lengthM` | `lwd` mode only: the cut perimeter at the surface, accounting for batter and overdig. Not stated in `areaDepth` mode. |

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.

```ts theme={null}
import { layerMeasurements } from "@stralos-co-nz/calculators";

const perLayer = layerMeasurements(rows);
```

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`

```ts theme={null}
import {
  isMeasurementRow,
  nonMeasurementRows,
  measurementRows,
} from "@stralos-co-nz/calculators";
```

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

```ts theme={null}
import { measurementFromRow } from "@stralos-co-nz/calculators";

const measurement = measurementFromRow(row, "trench"); // CalculatorMeasurement | undefined
```

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`

```ts theme={null}
import { usableMeasurements } from "@stralos-co-nz/calculators";

const offerable = usableMeasurements(measurements, pricedRows);
```

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.