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

# Trench calculator

> Domain model for the trench quantity calculator (v3)

The trench calculator is the built-in quantity engine attached to a work item, work item component, or activity when `calculator.type` is `"trench"`. It derives excavation, reinstatement, disposal, and pipe quantities from a set of cross-section inputs without the estimator having to calculate volumes manually.

## Types

`WorkItemComponentCalculator` is the union type for all calculator variants. The trench arm is also available as the named alias `TrenchCalculator`:

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

`TrenchCalculator` is defined as `Extract<WorkItemComponentCalculator, { type: "trench" }>`. The current union also contains pit and volume arms, so the alias is a real narrowing. Use `TrenchCalculator` in signatures that accept only trench configs.

### Narrowing a stored config

`@stralos-co-nz/calculators` exports no runtime guard for the trench arm (`isTrenchCalculator` was removed in calculators 2.0.0). The API accepts the config as an open record, refuses pricing keys anywhere in it, and validates the shape and placement of `backfillFrom`; the calculator engine then decides whether the geometry is readable. A blob with `type: "trench"` but invalid fields fails engine validation with `422 calculator_config_unreadable`.

## Calculator shape

`TrenchCalculator` carries:

| Field | Type | Notes |
| - | - | - |
| `type` | `"trench"` | Discriminant. |
| `width` | `number` | Design width in metres. |
| `depth` | `number` | Design floor depth in metres. |
| `length` | `number` (optional) | Repeated length in metres. Normalisation defaults an absent value to `1`. |
| `batter` | `CalculatorBatter` (optional) | Base batter for the excavation. A stratum can override it. |
| `overcut` | `CalculatorOvercut` (optional) | Base width and height allowance in millimetres. A stratum can override the width. |
| `baseLayerId` | `EntityId` (optional) | Identity of the base reinstatement layer. Pipe displacement applies to this layer only. |
| `layers` | `TrenchCalculatorLayer[]` | Reinstatement layers, ordered top to bottom. |
| `existingLayers` | `TrenchCalculatorExistingLayer[]` (optional) | Excavated strata, ordered top to bottom; the last depth is derived. |
| `pipes` | `TrenchCalculatorPipe[]` | Pipe runs. Each emits a row and displaces the base reinstatement layer. |

### `CalculatorOvercut`

| Field | Type | Notes |
| - | - | - |
| `widthPerSideMm` | `number` (optional) | Extra excavation width on each side. |
| `heightMm` | `number` (optional) | Extra depth below the design floor. |

An untouched trench carries no `overcut` key. Clearing both members removes the object.

### Deprecated calculator fields

| Field | Status | Migration |
| - | - | - |
| `overcutWidth` | Legacy read only. | Normalisation converts the old effective width into the current overcut model and strips the field. |

## Existing layers (`TrenchCalculatorExistingLayer`)

Existing layers describe the ground excavated by the trench. Their shared fields match pit and volume excavation strata.

| Field | Type | Notes | |
| - | - | - | - |
| `id` | `EntityId` | Stable identity within the calculator. | |
| `name` | `string` | Display name. | |
| `depth` | `number` | Depth in metres. The last stratum is the derived remainder. | |
| `disposition` | \`"onsite" | "removed"\` (optional) | Destination of the excavated remainder. An absent value preserves the legacy removed behaviour. |
| `disposalMaterial` | `{ id: EntityId; name: string }` (optional) | Disposal rate-item identity. Pricing remains on the assigned work item or activity component. | |
| `substance` | `DisposalSubstanceSnapshot` (optional) | Frozen bulking and density physics. | |
| `allocations` | `ExcavationAllocation[]` (optional) | Shares of in-situ volume sent to explicit destinations. | |
| `batter` | `CalculatorBatter` (optional) | Override for the calculator's base batter. | |
| `overdigPerSideMm` | `number` (optional) | Override for the calculator's base overcut width; `0` means neat. | |

### Deprecated stratum fields

| Field | Status | Migration |
| - | - | - |
| `overcutStyle` | Legacy read only. | Converted to the current batter or overcut representation and stripped. |
| `overcutWidth` | Legacy read only. | Converted with `overcutStyle` and stripped. |
| `overcut` (boolean) | Legacy read only. | Converted through the legacy overcut path and stripped. |

## Reinstatement layers (`TrenchCalculatorLayer`)

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Stable layer identity. |
| `name` | `string` (optional) | User-entered label. Use `fillLayerLabel` for display fallbacks. |
| `depth` | `number` | Layer depth in metres. |
| `isBackfill` | `boolean` (optional) | Uses excavated material when true; the bought-fill snapshot is ignored. Has no effect when `nonBulk` is present — a non-bulk layer is always bought in. |
| `backfillFrom` | `BackfillStratumSnapshot` (optional) | Names the selected excavation stratum. Normalisation strips it when `isBackfill` is not true. Stripped when `nonBulk` is present. |
| `fill` | `FillMaterialSnapshot` (optional) | Frozen bought-material physics. An absent snapshot uses the engine's legacy fallback. Stripped by normalisation when `nonBulk` is present. |
| `nonBulk` | `NonBulkMaterialSnapshot` (optional) | Non-bulk rate item to price this layer by. Mutually exclusive with `fill`, `isBackfill`, and `backfillFrom`. See `NonBulkMaterialSnapshot` below. |

## `FillMaterialSnapshot`

`FillMaterialSnapshot` is the physics frozen onto a bought fill layer when a material is selected or a settings refresh is accepted. It is the fill-side mirror of `DisposalSubstanceSnapshot`.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Material identity. |
| `name` | `string` | Display name. |
| `densityMin` | `number` | Low bound of loose density in t/m³ — the lightest priced supplier's conversion factor. |
| `densityMax` | `number` | High bound of loose density in t/m³. Equal to `densityMin` when the material has one supplier or all suppliers agree. |
| `compactionPct` | `number \| null` (optional) | Whole percent of the loose volume (13 means 13 %). Absent, `null`, and `0` all mean the void is ordered as-is. |

`densityMin` and `densityMax` replace the single `density` field removed in D73b. When a bought-fill item has multiple suppliers with different conversion factors, no single density is correct: the lightest under-orders, the heaviest over-orders, and the mean is a figure no supplier will honour. The calculator therefore states the tonnage as a range and leaves the final choice to the work item line, where the rate option's own conversion factor applies.

A fill layer states `tonnesMin` and `tonnesMax` in its outputs. `tonnes` is present only when `densityMin === densityMax` — that is, when the bounds agree and a single figure is unambiguous. Register links on `tonnes` for a ranged layer are refused by the existing `calculator_link_unknown_output` rule.

`tonnesMin` and `tonnesMax` are display-only outputs. They are not valid register link targets: a work item takes the m³ quantity, and the rate option's conversion factor handles the t/m³ arithmetic on the line.

## `NonBulkMaterialSnapshot`

`NonBulkMaterialSnapshot` records the rate item chosen as a non-bulk material for a reinstatement layer. It is a snapshot — the item's name and unit are frozen at selection time and are not re-derived from the rates catalogue.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | The rate item's UUID. Note: the field is `id`, not `rateItemId`. The calculator API's config-key validation refuses the `rateItemId` spelling with a `422`. |
| `name` | `string` | The item's name as it was when chosen. |
| `unit` | `string` | The `unit_catalogue` code snapshotted from `RateSummary.modeUnit` at selection time. An empty string means the item stated no unit. |

`unit` decides which layer output the engine prices by, through `pricedOutputKeyForUnit`:

| Unit value | Output key |
| - | - |
| `metre` (and aliases: `m`, `lm`, `linear metre`, …) | `lengthM` — the trench's run length |
| `length`, `roll_length` | `lengthM` — whole-unit stock that covers metres (pipe lengths, wire or tape on a roll); see below |
| `square_metre` (and aliases: `m2`, `sqm`, …) | `areaM2` — the layer's own top face × length |
| `roll_area`, `sheet` | `areaM2` — whole-unit stock that covers square metres (geotextile rolls, sheets); see below |
| `cubic_metre` (and aliases: `m3`, `cum`, …) | `inSituM3` — in-place compacted volume |
| Any other value, including empty string | `count` — priced at 1 per layer (e.g. `each`, `hour`, `lot`, `lump_sum`) |

The four whole-unit stock units (`length`, `roll_length`, `roll_area`, `sheet`) are for items bought by the piece that each cover a fixed length or area. The row is measured in the output's unit (metres or m²) like any other non-bulk layer. The stock unit is only the priced unit: `orderedQuantityForPricedUnit` (in `@stralos-co-nz/domain`) converts the measured quantity into whole pieces, rounding up per run. For example, 120 m² of geotextile at 50 m² per roll orders ⌈120 / 50⌉ = 3 rolls. The rate option's conversion factor records how much of the covered unit one piece provides. Only the catalogue codes are recognised: a bare "roll" is ambiguous between the two roll codes and stays `count`.

Before calculators 1.13.1, these four units fell through to `count` and priced one piece regardless of the layer's size.

`unit` is not validated against the catalogue on save. It is a snapshot of `RateSummary.modeUnit`, which the server cannot re-derive. An unrecognised or empty unit defaults to `count` and prices 1.

`pricedOutputKeyForUnit` first matches exact catalogue codes and common space-separated aliases after case-folding and whitespace normalisation. A second pass collapses runs of spaces, underscores, or hyphens before looking up the token again. This resolves forms such as `sq_m`, `sq-m`, `cu_m`, `cu-m`, `lineal_metre`, `lineal-metre`, `linear_metre`, `square-metre`, and `cubic-metre` to their intended output rather than falling through to `count`. Whole-unit stock codes (`length`, `roll_length`, `roll_area`, `sheet`) are matched as exact catalogue codes in the first pass.

Exact catalogue codes are matched first. A token that remains unrecognised after both passes falls back to `count` rather than raising an error.

A non-bulk layer is mutually exclusive with the three other material statements. When `nonBulk` is present:

* `fill` is stripped by normalisation.
* `backfillFrom` is stripped by normalisation.
* `isBackfill` is forced to `false` by normalisation.

The engine applies the same precedence rule even when reading a stored config that has not been normalised: `nonBulk` wins, and the layer is treated as bought-in reinstatement. It still occupies depth and displaces volume exactly as any other fill layer; only the priced output differs.

A non-bulk layer emits exactly one output: the priced output for its unit. It never states `tonnes`, `looseM3`, `tonnesMin`, or `tonnesMax` because a non-bulk item has no density.

`storedNonBulkMaterial(layer)` (exported from `@stralos-co-nz/calculators`) normalises the snapshot to exactly `{ id, name, unit }` before storage.

### Estimate-service enrichment and link repair

The Estimate API accepts optional `categoryId` and `category` fields beside the engine's `{ id, name, unit }` snapshot. On calculator save it reads `GET /api/rates/rate-items/:id`, requires the item to remain active, and refreshes its name and category. It does not re-check the rate group because the picker is limited to the Materials and Subcontractors groups at load time. The unit remains a client-supplied snapshot because that rate-item endpoint does not return it; `rateGroup` and `status` are not stored in the calculator config.

Changing the non-bulk item can change the layer's sole output key. For a server-derived register, the Estimate API re-points a stale link to the new sole output. A client-supplied link that names an output the layer does not expose remains a `calculator_link_unknown_output` error. This repair is limited to non-bulk transitions so the service does not choose among multiple outputs on unrelated layers.

### Legacy normalisation: `StoredFillMaterialSnapshot`

`StoredFillMaterialSnapshot` is the type accepted by `normalizeFillMaterialSnapshot`. It extends the canonical shape with the legacy read-only field:

| Field | Status | Notes |
| - | - | - |
| `density` | Deprecated — read-only | Pre-D73b single loose density. `normalizeFillMaterialSnapshot` lifts it to `densityMin = densityMax = density` and strips it. Nothing writes this field. |

The distinction between `FillMaterialSnapshot` and `StoredFillMaterialSnapshot` is deliberate. The canonical type is what the engine reads and what a client writes. The stored type is the normalisation entry point only, so the legacy field cannot be read outside `normalizeFillMaterialSnapshot`.

### Layer labelling (`fillLayerLabel`)

Because `name` is optional, every site that needs to display a reinstatement layer's name must call `fillLayerLabel(layer, fallback)` rather than reading `layer.name` directly. The function applies D74's fallback order:

1. The typed name (trimmed) — if the estimator entered one.
2. `"From the dig"` — if `isBackfill` is `true`. This step comes before the material step: when `isBackfill` is set the engine ignores `fill`, so naming the layer after a dormant snapshot would describe material the layer is not made of.
3. The non-bulk item's name (`nonBulk.name`, trimmed) — if a non-bulk snapshot is present and named.
4. The fill material's name (`fill.name`, trimmed) — if a fill material snapshot is present and named.
5. The caller's last resort — a `number` renders as `Reinstatement layer N` (1-based); a `string` is used as a fixed slot label.

`storedFillLayerName(name)` is the normalisation half: it trims and returns `undefined` for an empty or whitespace-only string, so there is one representation of "unnamed" in storage.

The normaliser never seeds a name onto a fill layer. An empty **Name (optional)** field opens with the fallback as its placeholder, which is the honest rendering of "this layer has no name yet".

### Fill material source

A reinstatement layer has three mutually exclusive material choices: filled from the dig (`isBackfill`), a bulk material from the fill-material picker (`fill`), or a non-bulk rate item (`nonBulk`). Exactly one applies at a time; the normaliser enforces this by stripping whichever of the other two is present when the active choice is saved.

A reinstatement layer that is not backfilled from the dig uses the **Material** picker. Besides named excavation strata, the picker can list:

* **Bulk materials** — rate items whose category has `parentGroup = quarry_materials`, shown under **Bulk Materials** in **Settings → Resource categories**. A bulk item is offered when api-rates can derive at least one positive density from its priced options; items without a readable conversion factor are omitted.
* **Non-bulk materials**: rate items from the **Materials** (`other_materials`) and **Subcontractors** (`subcontractors`) rate groups. These items have no density physics, so the layer is priced by the rate item's own unit. This group appears only when the host supplies `nonBulkMaterials`; the estimate host and the activities host both supply it to trench, pit, and volume calculators. The pit and volume pickers list only items whose unit the arm can measure (see [Non-bulk units by arm](#non-bulk-units-by-arm)). Items from all other rate groups, including plant, equipment, and unclassified, are excluded.

Neither list has a separate settings catalogue. Items are displayed in the library's own order: sorted by category, then name, then id.

For bulk materials, the API returns the lightest and heaviest readable density values as `densityTPerM3Min` and `densityTPerM3Max`; a single value is represented by equal bounds.

Bulk state is loaded through `useFillMaterials` and passed as `fillMaterials`; non-bulk state is loaded separately through `useNonBulkMaterials` and passed as `nonBulkMaterials`. The host maps bulk API bounds to the modal's `densityMin` and `densityMax` fields. A load failure produces an error state without changing stored snapshots.

### Reinstatement eligibility

`DisposalSubstanceSnapshot.canUseForReinstatement` controls whether a stratum's material may be used for reinstatement. It is an optional boolean because stored snapshots predate the field.

`isSubstanceReinstatementEligible(substance)` and `isStratumReinstatementEligible(stratum)` (exported from `@stralos-co-nz/calculators`) determine eligibility by these three rules:

* **No substance** — a stratum nobody has classified is eligible. It is the pre-substance calculator state, not an unchecked item.
* **Explicit flag** — when `canUseForReinstatement` is a boolean, that value wins regardless of `compactionPct`.
* **Legacy snapshot** (no flag) — eligible when `compactionPct` is a number (including `0`). A snapshot missing the `compactionPct` key entirely reads as ineligible, matching the disposal physics treatment of a missing key as N/A.

The Material picker checks each stratum with `isStratumReinstatementEligible` and lists only the eligible ones, in their stored order, under **From excavation** for a from-the-dig reinstatement layer.

The APIs' save gate is narrower than the eligibility rule. Save validation in `api-estimate` and `api-activities` refuses only an explicit `canUseForReinstatement === false` in the frozen snapshot. A legacy snapshot without the flag is accepted on save even when the eligibility rule calls it ineligible, so an existing estimate whose stratum has `compactionPct: null` is not broken on an unchanged re-save.

When a stored layer names a stratum that has become ineligible, `fillLayerStratumIneligible(layer, strata)` returns `true` and the UI shows the advisory message `FILL_LAYER_STRATUM_INELIGIBLE_MESSAGE`: **"This excavated material can no longer be used for reinstatement. Choose another material for this layer."** The advisory moves no quantity. Saving is blocked only when the named stratum's snapshot carries `canUseForReinstatement: false`; see [Save validation](#save-validation).

### BackfillStratumSnapshot

`BackfillStratumSnapshot` records which excavated stratum a from-the-dig reinstatement layer is filled from:

| Field | Type | Notes |
| - | - | - |
| `stratumId` | `EntityId` | The id of the excavated stratum at the time the estimator chose it. Kept even when the stratum has since been renamed or deleted — the engine never validates the reference against the live strata list. |
| `name` | `string` | The stratum's name as it was when chosen. `fillLayerLabel` reads this to produce "From the dig — \<name>". |

`backfillFrom` is stripped by the normaliser whenever `isBackfill` is not `true`. A bought-in layer never carries a stratum reference, and a dormant reference beside a live fill snapshot is an ambiguous second representation of the layer's material. This stripping rule is applied by `storedBackfillFrom`, exported from the package barrel, and called by all four normalisers that handle a layer carrying these fields.

`fillLayerLabel` reads `backfillFrom.name` when `isBackfill` is `true` and produces the label "From the dig — \<name>". The function is called with the typed name first, so a layer the estimator has named explicitly is not relabelled. `fillLayerFromTheDigLabel(stratumName?)` — also exported from the package barrel — produces this label independently, and is intended for the UI's per-stratum dropdown rows so each row reads identically to the label the layer would carry after the choice is made.

When no stratum declares a disposition, from-the-dig volume is drawn pro rata from the excavation, preserving legacy results. Once a stratum declares a destination, `backfillFrom` selects the source stratum and changes the destination-aware partition quantities.

#### Estimate API write-side validation

The Estimate API validates `backfillFrom` on every calculator save. Three failure codes apply:

| Code | HTTP status | Meaning |
| - | - | - |
| `calculator_config_backfill_from_malformed` | `422` | The value is not `{ stratumId, name }` with both fields non-blank strings, or carries extra members. |
| `calculator_config_backfill_from_misplaced` | `422` | The key appears on a shape that carries no `isBackfill` (for example, a stratum, a pipe, or the calculator itself). |
| `calculator_config_backfill_from_ineligible` | `400` | A from-the-dig layer (`isBackfill: true`) names a stratum whose frozen substance snapshot carries `canUseForReinstatement: false`. |

All three name the offending path. A well-formed reference beside `isBackfill: false` is stored as sent; the engine's normaliser strips it at compute time.

The eligibility check (`calculator_config_backfill_from_ineligible`) applies only to a fresh pick. On update (`PUT /calculators/:id`), a stratum the stored config already draws from is exempt from this `400`, but the shared save gate below still refuses a layer that names a stratum whose snapshot carries `canUseForReinstatement: false`. A stratum with no substance, or whose snapshot does not carry the `canUseForReinstatement` flag, is accepted whatever its compaction.

Every trench, pit, and volume create or update that carries a config also runs `assertCalculatorSaveReady`, which applies [`calculatorSaveIssues`](#save-validation):

| Code | HTTP status | Meaning |
| - | - | - |
| `calculator_incomplete` | `422` | The config fails one or more save requirements. The message joins every issue's message. |
| `calculator_config_unreadable` | `422` | The config cannot be read, for example a trench or volume config without a `layers` array, or a shape the shared code cannot normalise. |

### Supply quantity and compaction

A bought fill layer stores its physical material snapshot with `densityMin`, `densityMax`, and `compactionPct`. The engine converts its compacted in-place volume to loose volume by dividing by `(1 - compactionPct / 100)`, then derives the low and high tonnage bounds from the two densities.

Absent or zero compaction leaves the loose volume equal to the in-place volume. Pricing and supplier conversion remain on the consuming destination line.

### Deprecated fields

| Field | Status | Migration |
| - | - | - |
| `overcut` (boolean) | Deprecated — read-only for legacy normalisation. | Reinstatement layers do not overcut; normalisation strips this field. |

## Pipes (`TrenchCalculatorPipe`)

Pipes are geometry inputs. Their combined cross-sectional area is deducted from the base reinstatement layer only, clamped at zero. Other reinstatement layers keep their full prism volume. Each pipe is also reported as its own physical-output row.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | |
| `name` | `string` (optional) | Display label. |
| `diameter` | `number` | Outside (actual) diameter in millimetres. Not the nominal size — see note below. |
| `quantity` | `number` | Number of pipes at this diameter. |

> [!NOTE]
>
> The field stores the **outside diameter** in millimetres, not the nominal (DN) size. Nominal sizes differ from actual outside diameters by pipe material and standard: concrete DN300 is 367 mm, PVC-U DN300 is 315.4 mm, and PE DN315 is 315 mm.

## Measurements

Measurements are synthetic, read-only rows the engine appends after its physical rows. They restate geometry dimensions as named link targets so a work item can price by the metre, area, or volume of the calculator rather than by a specific layer. Each measurement carries a stable `layer_id` in the form `m:o:<key>` (whole-calculator reading) or `m:l:<layerId>:<key>` (per-layer reading), and an `outputKey` that names the axis (`lengthM`, `areaM2`, `inSituM3`, or `count`).

### Overall measurements offered by the trench arm

| Layer id | Name | Output key | Notes |
| - | - | - | - |
| `m:o:lengthM` | Total length | `lengthM` | The trench's `lengthMetres`. |
| `m:o:sawcut` | Saw cut length (2 × length) | `lengthM` | Two cut lines for a concrete saw. |
| `m:o:circ` | Circumference (excavation top perimeter) | `lengthM` | The perimeter of the excavation opening at the surface: 2 × (length + top width). |
| `m:o:areaM2` | Plan area | `areaM2` | Length × top width. |
| `m:o:depthM` | Depth | `lengthM` | The **design floor depth** — what the estimator typed. Added in v1.4.1 to match the pit and volume arms. |
| `m:o:inSituM3` | Excavation volume | `inSituM3` | Total cut volume. |
| `m:o:pipesM` | Pipe run length | `lengthM` | Sum of all pipe-run lengths. The only pipe reading. |

The `m:o:pipes3`, `m:o:pipes5_8`, and `m:o:pipes6` stock-length counts are no longer emitted. The stock length belongs to the pipe's rate option (priced in `length`, with metres per length), and pricing rounds up per run. See [Pipe runs and whole lengths](https://help.stralos.app/internal/calculator-measurements.md#pipe-runs-and-whole-lengths).

The `Depth` reading (`m:o:depthM`) states the design floor depth, not `cutDepth`. The design floor is what the estimator entered and what bedding, pad, and fit checks are measured against. Over-break below it is already priced through the excavation volume.

The trench has no `count` field — its `length` is geometry. Every measurement is therefore already at the figure a work item should price by; no further scaling is applied.

### Per-layer measurements

For each physical layer row the engine emits, the arm appends layer-scoped measurements for the axes that row carries (`areaM2`, `lengthM`, `inSituM3`). Their `layer_id` is `m:l:<layerId>:<key>`. The `count` output key is never restated as a measurement.

## Overcut

`TrenchCalculator.overcut` is a `CalculatorOvercut` — the same type used by the pit and volume calculators. `TrenchOvercut` is a kept alias for backward compatibility.

| Member | Type | Effect |
| - | - | - |
| `widthPerSideMm` | `number` (optional) | Widens every stratum at both its top and its bottom. The cross-section grows by `2 × widthPerSideMm / 1000`. |
| `heightMm` | `number` (optional) | Adds a band below the design floor. The lowest reinstatement layer absorbs the band's volume. |

Both members are optional with no defaults. An absent `overcut` key means the config is in the legacy state — see migration below.

### Per-row width override

`overcut.widthPerSideMm` is the base for the whole leg. Each stratum can override it with `TrenchCalculatorExistingLayer.overdigPerSideMm`:

* A present value **replaces** the base for that stratum.
* Absent means inherit.
* A stored `0` means neat.

### Legacy migration

A trench config whose `overcut` key is absent is legacy. On normalisation, `normalizeTrenchCalculator` migrates it as D71 specified: the maximum `overdigPerSideMm` across the strata becomes the calculator's base, and every stratum is stripped of its individual value so all rows inherit. A trench stored since D71 (which already carries a base and bare rows) is not migrating — its rows are overrides and value-less rows inherit.

## Normalisation

`normalizeTrenchCalculator` upgrades legacy shapes without changing their intended geometry:

1. It converts the retired calculator-level `overcutWidth` and stratum flags into the calculator-level `overcut` shape.
2. When the `overcut` key is absent, it lifts the maximum legacy stratum `overdigPerSideMm` into the shared base and removes the legacy row values.
3. It removes retired layer and stratum overcut flags.
4. It defaults an absent `length` to `1`.
5. It seeds one full-depth excavation stratum only when `existingLayers` is absent; an explicit empty array remains empty.
6. It removes `backfillFrom` when a reinstatement layer is not filled from the dig and stores blank optional layer names as absent.
7. When a reinstatement layer carries a `nonBulk` snapshot, it strips `fill`, clears `backfillFrom`, and forces `isBackfill` to `false`. The snapshot itself is stored as exactly `{ id, name, unit }`.

## Fill material option (`FillMaterialOption`)

The host supplies bought fill materials as `FillMaterialsState`. Each ready item carries `id`, `name`, `densityMin`, `densityMax`, `compactionPct`, ordering, and status. Both density bounds are required for bulk items; differing suppliers produce a range rather than an unusable item.

Non-bulk items (rate items outside the `quarry_materials` group) carry no density. Selecting a non-bulk material records the rate item identity on the layer; the layer is priced by the rate item's own unit rather than by `looseM3`. The engine does not derive tonnage or density for non-bulk layers.

Selecting a bulk material freezes `densityMin`, `densityMax`, and compaction into the layer's `FillMaterialSnapshot`. A legacy single `density` value normalises to equal minimum and maximum bounds.

Loading or error states leave the calculator operable and preserve any stored snapshot. A bulk material without a readable positive density is not offered.

## Fill material tonnage

The modal's **Material t** tile shows the total bought fill tonnage. The value comes directly from the engine's `layerMaterialTonnesPerMetreMin` and `layerMaterialTonnesPerMetreMax` outputs, scaled by the trench length. The UI does not multiply any volume by a density.

A bought fill material's density is a **range**, not a single figure. One Bulk Materials item can be priced by several suppliers with different conversion factors, so collapsing to a single density is always wrong: the lowest under-orders, the highest over-orders, and no supplier will honour a mean. The engine carries both ends, and the tile shows the tonnage as a range — for example `27.2–27.7 t`. When all of a material's options agree (or it has a single supplier), both ends are equal and the tile shows one figure.

The supplier is chosen on the work-item line, which converts the m³ quantity through its own rate option. The calculator works in m³ throughout; the tonnage range is informational only.

`formatCalculatorRange` (`calculatorModalShared.tsx`) formats the two ends. It compares the formatted strings, not the raw numbers: two ends that differ only beyond display precision are shown as one figure, because showing `27.4–27.4 t` would read as a defect rather than as a degenerate range. The separator is an en dash, not a hyphen.

### Disposal substance physics

An excavated stratum's `DisposalSubstanceSnapshot` freezes `id`, `name`, `looseDensity`, `bulkingPct`, nullable `compactionPct`, optional `canUseForReinstatement`, and `resolvedAt`. The host passes the catalogue's eligibility flag to calculator-ui; when the flag is absent, the UI derives eligibility from compaction and freezes the result on selection. Eligibility checks use this stored snapshot, not an unaccepted live catalogue change.

The engine derives:

```text theme={null}
looseM3 = inSituM3 × (1 + bulkingPct / 100)
tonnes  = looseM3 × looseDensity
```

Without a substance snapshot, the layer still states its in-situ volume but omits loose volume and tonnage because those values cannot be derived honestly. The disposal material identifies the rate item used by the destination line; it is not the physics source.

### Ineligible reinstatement refusal

A config write (`POST /calculators` or `PUT /calculators/:id`) returns `400 calculator_config_backfill_from_ineligible` when a reinstatement layer's `backfillFrom` stratum references an excavated substance whose `canUseForReinstatement` flag is `false`. A stratum the stored config already drew from is exempt on a PUT (only a fresh pick is refused).

The calculator editor intercepts this specific code in `describeConfigSaveError` (`src/calculatorsApi.ts`) and rewrites the error message shown to the estimator:

> A reinstatement layer is filled from excavated material that can no longer be used for reinstatement — choose another material for it.

The server's own sentence (which begins with a config path such as `layers.1.backfillFrom: …`) is suppressed entirely because a raw config path is not meaningful to an estimator. Every other 400 refusal and all 409 conflict prompts pass through unchanged.

The same `400 calculator_config_backfill_from_ineligible` code can appear when an activity's attached calculation is copied during an insert (see [Activity-insert copy failure](https://help.stralos.app/internal/estimate-frontend.md#activity-insert-copy-failure-activitycalculatorcopy-ts) in the estimate frontend docs). A copy is a `POST /calculators` with no existing config to exempt, so the refusal is permanent on every retry. The copy failure message identifies this case and tells the estimator to choose another material rather than offering a retry.

## Host data

Calculator hosts load four independent catalogues:

* disposal substances from the Global API, including inactive entries needed to render frozen snapshots;
* fill materials from `GET /api/rates/rate-summaries?rateGroup=quarry_materials`, retaining both density bounds;
* active disposal items from `GET /api/rates/rate-summaries?rateGroup=disposal`;
* non-bulk materials from every page of `GET /api/rates/rate-summaries`, kept to the `other_materials` and `subcontractors` rate groups by `nonBulkMaterialsFrom`.

Substance usage is an optional ranking input. A failed usage request falls back to alphabetical choices and never blocks saving. A failed catalogue request leaves the modal open and preserves stored snapshots, but prevents selecting a new item from that catalogue.

The estimate host and the activities host each load all four catalogues once per open calculator (`useCalculatorCatalogues` in each host's `calculatorRegistry.tsx`) and supply them to trench, pit, and volume calculators.

## Embedded calculator columns (retired)

Embedded calculator configurations have been replaced by standalone calculators and their assignment registers. Estimate migration `0058` guards against unconverted configurations; migration `0059` physically drops the `calculator` columns on estimate work items and components. Activities migration `0022` drops the embedded `activities_activities.calculator` column.

Neither API stores an embedded `calculator` supplied on a work-item, component, or activity write. Undeclared keys are stripped, including an explicit `null`; they do not create a calculator, clear a standalone calculator, or delete its generated rows. Legacy `wi:` and `wic:` component keys remain compatible stored identities, but no embedded engine writes or regenerates their rows.

Use the Estimate API's [Project calculators](https://help.stralos.app/api/estimate.md#project-calculators) or the Activities API's [Library-level calculators](https://help.stralos.app/internal/activities-service.md#library-level-calculators-migration-0012) for current calculator work.

## Project-level calculator disposal reconciliation

Project-level calculators of all three arms — trench, pit, and volume — run a shared reconciliation path managed by `reconcileProjectCalculator`. This path handles disposal materials and net remainder quantities. The gate `carriesLayerMaterials(type)` admits `"trench"`, `"pit"`, and `"volume"`, so disposal and reinstatement material readers apply identically to all three arms. Configs stored before this was fixed heal on the next save: a component row that carries no `rate_item_id` is read as a material change and is relinked to the chosen rate item.

### Disposal material validation

Before the estimate transaction lock is taken, `resolveTrenchDisposalMaterials` validates each stratum marked `disposition: "removed"`. The validation rules are:

* Every `"removed"` stratum must carry both a `substance` and a `disposalMaterial`. A stratum missing either returns `422` with a clear message.
* The `disposalMaterial.id` is resolved against `GET /api/rates/rate-items/:id`. An id that returns `404` returns `422`; a gateway failure returns `502`.
* The resolved rate item must be active and belong to the `disposal` rate group. Any other item returns `422`.
* Strata marked `disposition: "onsite"` are not validated. Their disposal row is removed and no rate item check is made.
* A stratum with no material left after reinstatement reuse (no excavation row with a positive `inSituM3`) is not validated either. It needs no disposal rate, and its stored choice is kept for later edits.

On success, the config is updated with the name and category from the Rates API catalogue before storage. This keeps the stored label current with whatever the rates team last saved, rather than whatever the estimator typed.

### Net remainder quantities

The engine's `looseM3` and `tonnes` outputs for a disposal row already have the backfill reuse draw netted off pro rata. A trench that reuses some of its own spoil as backfill reduces the cartaway quantity before it reaches the disposal row. The reconcile writes those net engine outputs directly to the row without any further adjustment.

A disposal row's `areaM2` is its share of the stratum's plan area (`sliceAreaShare` in `allocations.ts`). The share is taken over the volume the disposal slices divide, `grossInSituM3 − reuseInSituM3`, not over the gross, because the reuse is not a slice and owns no footprint. So a stratum with one remaining slice after reuse carries the stratum's whole plan area, and the slices of a partitioned stratum scale up together. For example, a 10 × 5 m dig 0.6 m deep that reuses 5 m³ disposes of 25 m³ over the full 50 m². Where nothing is reused the denominator is the gross, so nothing changes. For a stratum that is not partitioned, its `m:l:<id>:areaM2` measurement restates the same figure. The rule lives in the shared allocation code, so it applies to the trench, pit and volume arms alike.

### Disposal row binding rules

| Condition | Outcome |
| - | - |
| `disposition: "removed"`, material present | Row created or updated with the catalogue rate item and the engine's net quantity |
| `disposition: "onsite"` | Row deleted; the link survives with `componentRowId: null` so the assignment is not lost |
| Material changed | Previous pricing overrides and companion rows are pruned; the row is re-seeded with the new rate item, zero unit cost, and no option pin |
| Material unchanged, substance present | The pricing override's `conversionFactor` is refreshed from the substance: `looseDensity × (1 + bulkingPct / 100)` for `inSituM3` links, or `looseDensity` for `looseM3` links |
| Layer has no substance snapshot | Row is still emitted using the engine's quantity; no `conversionFactor` is written |

The estimator's chosen rate option, unit cost, rate group, margin override, and charge basis all survive a re-run unless the material itself changes.

## Measurement rows (calculators 1.4.1)

The engine emits one `CalculatorOutputRow` per **measurement** of the stored calculator — the trench's total length, its saw-cut length, a layer's own area, and similar readings — under a synthetic `layerId` with the prefix `m:`. This lets an estimator price any of those readings via a rate item using the same link-and-reconcile machinery already in place for physical layers, with no new config field and no new output key.

### Synthetic layer ID format

| Pattern | Meaning |
| - | - |
| `m:o:<key>` | A whole-calculator reading (for example, `m:o:sawcut` — the total saw-cut length across all runs). |
| `m:l:<layerId>:<key>` | A per-layer reading (for example, the in-situ area of one excavation stratum). |

`isMeasurementRow(row)` is the authoritative test for any row in hand. Do not test the `m:` prefix directly — that string is for stored links where no row is available.

### Seeding

Measurement links are **never seeded** by `seedLinksForDefault`. Seeding creates a link for every emitted layer the estimator has not yet decided about; applying that rule to measurements would auto-price every reading of every existing calculator onto its default work item on the first save after this engine version, on calculators the estimator never touched. Only the estimator creates a measurement link, through the Calculations modal.

### Zero rule: removed on save

A measurement link whose reading states nothing, or states a figure that is not a positive number, is **removed** on the server-driven save that makes it so. Its link is deleted from the register and its build-up row is removed with it. This is distinct from physical layers, which keep their link even when the engine emits no row, because the assignment there records an intent about the ground that survives geometry edits in progress.

The zero rule only fires on **server-derived registers** (config saves, default changes, copy). It does not fire on the estimator's own `PUT /calculators/:id/links`. A register supplied by the client is the estimator stating what they want; deleting a newly assigned link inside the `200` that acknowledges it would be silent and unexplained. The link is removed on the next server-derived save.

When a measurement link is removed this way, the estimator re-adds it from the Calculations modal. Nothing re-creates a measurement link automatically — `seedLinksForDefault` skips measurements entirely.

Setting a pipe run's `quantity` to `0` stops the engine emitting that run's layer row, which also stops it emitting that run's per-layer measurement rows (`m:l:<id>:*`). Those links are pruned by the layer-deleted rule rather than the zero rule; the outcome is the same.

### `count` is not scaled

Measurement rows are not scaled by the calculator's `count` field. A pit with `count: 4` and a measurement reading of 6 reports 6, not 24. This is the engine 1.4.1 fix; engine 1.4.0 scaled measurement rows by `count`, producing incorrect quantities.

### Unit

A `count` measurement row carries the unit `"each"` (the `OUTPUT_UNITS.count` fallback) unless the non-bulk snapshot supplies a charge unit for the layer. The other measurement keys (`lengthM`, `areaM2`, `inSituM3`, and so on) use the fixed units from `OUTPUT_UNITS`.

### D66 exemption for server-derived registers

The D66 membership check refuses a link whose output the engine no longer states. Measurement rows are emitted even when the arm cannot state a figure (`outputs: {}`), so a measurement link on a reading that has gone empty would be refused by D66 and block the save. The exemption lets such a link past **only** when all three of these conditions hold:

* The register is server-derived (client-supplied registers are still refused).
* The row is a measurement (`isMeasurementRow(row)` is true — no physical layer is exempt).
* The link names the measurement's own axis (read from the synthetic ID, not from the current value).

This lets the reconcile apply the zero rule and remove the link. A new client-supplied link on a reading that states nothing is still refused (`calculator_link_unknown_output`).

## Server write pipeline

Current calculator writes store geometry and physical inputs on standalone calculator entities: `estimate_calculators` for an estimate, or `activities_calculators` for the activities library. Pricing belongs to the consuming work-item or activity-component line; calculator configuration must not contain pricing fields.

The hosts validate configuration and assignment links, run the shared engine, and reconcile physical output rows against the calculator's assignment register. Generated rows use the `calc:<calculatorId>:<layerId>[:<ordinal>]` namespace. Reconciliation updates the calculator's own rows while preserving the consuming line's pricing choices unless its material changes.

Estimate mutations reconcile and recompute totals under the estimate lock. Activities mutations reconcile the library calculator's rows across its destination activities. See [Project-level calculator disposal reconciliation](#project-level-calculator-disposal-reconciliation) above and [Library calculator reconciliation](https://help.stralos.app/internal/activities-service.md#library-calculator-reconciliation) for the host-specific rules.

## Save validation

`calculatorSaveIssues(calculator)` (from calculators 1.17.0, `packages/calculators/src/calculatorSave.ts` in `stralos-domain`) holds the save requirements shared by all three arms. The calculator modals run it on **Save** and show its messages as the form error; the Estimate API runs it through `assertCalculatorSaveReady` and refuses with `422 calculator_incomplete`. An empty array means the calculator can be saved. Recalculating an existing calculator does not run it, so historical incomplete configurations still compute.

Each `CalculatorSaveIssue` carries a `message` and, for a layer-specific issue, the layer's `layerId`.

### Dimensions

Dimensions are checked first. If any dimension fails, only the dimension issues are returned. Each must be a finite number greater than 0, and each failure reads `"<Name> must be greater than 0."`.

| Arm | Dimensions checked |
| - | - |
| Trench | Length (an absent length counts as 1), Width, Depth |
| Pit, and volume in `lwd` mode | Length, Width, Depth |
| Volume in `areaDepth` mode | Area, Depth |

### Excavation strata

* A calculator with no excavation strata returns `"Add an excavation layer with a disposal substance and destination."`
* A stratum with material left to dispose of (gross in-situ volume less reuse greater than 1 × 10⁻⁹ m³) needs a disposal substance (`"Choose a disposal substance for <name>."`) and, unless it is **Leave Onsite**, a disposal material (`"Choose a disposal material or Leave Onsite for <name>."`).
* A stratum fully consumed by reinstatement reuse has nothing left to dispose of, so it needs neither.

### Reinstatement layers

* A non-bulk layer whose unit the arm cannot measure returns `"<name>: this calculator cannot measure the selected rate item's unit."` This check applies whatever the layer's depth, so a mode or depth change cannot leave an unmeasurable unit behind.
* A layer with a depth greater than 0 and no material (no non-bulk item, no bought fill, and not from the dig) returns `"Choose a reinstatement material for <name>."`
* A from-the-dig layer with a depth greater than 0 returns `"Choose an eligible excavation stratum for <name>."` when there are no strata, when its `backfillFrom` names a stratum that no longer exists, when it names no stratum and there are two or more strata, or when its source stratum's snapshot carries `canUseForReinstatement: false`. The source is the named stratum, or the first stratum when none is named. A stratum with no substance, or a legacy snapshot without the flag, is accepted (calculators 1.17.1).

### Non-bulk units by arm

`reinstatementOutputKeys(type)` returns the outputs a non-bulk reinstatement layer can be priced by on each arm. `nonBulkUnitIsMeasurable(unit, available)` maps the unit through `pricedOutputKeyForUnit` and checks the result against that list.

| Arm | Measurable output keys |
| - | - |
| Trench | `inSituM3`, `areaM2`, `lengthM`, `count` |
| Pit | `inSituM3`, `count` |
| Volume | `inSituM3`, `areaM2`, `count` |

`count` is for units that are not geometric (for example `each` or `lot`). It is never a substitute for a geometric unit the arm cannot measure: a per-metre item on a pit or volume, or a per-m² item on a pit, is not offered in the modal's picker and is refused on save.

## In the modal

`TrenchCalculatorModal` edits geometry and physical outputs. Pricing is applied on the consuming work-item or activity-component line.

### View mode toggles

The modal opens in simple mode. Two toggle switches appear below the title:

* **Advanced Mode** — when off (the default), the calculator-level **Overcut side** and **Overcut depth** strip fields, the **Excavation Batter** controls, and the per-layer result-line output columns are hidden. When on, all controls are visible.
* **Assign to Work items** — visible only when the host supplies `renderLayerAssignment`. When on, each excavation and reinstatement row shows its **Used by** assignment control. This toggle is independent of **Advanced Mode**: a user can show assignments without turning on advanced fields, and vice versa.

Neither toggle changes the stored calculator or its computed totals. Hiding a control preserves the value it holds.

### Geometry and output rows

The main fields appear in **Length**, **Width**, **Depth** order. Existing strata and reinstatement layers show the engine's outputs as a result sentence below each row. Missing physics renders as absent rather than zero.

Each excavation stratum's result sentence reads: `<inSituM3> m³ in situ · × <bulking factor> bulking · <looseM3> m³ loose spoil · <density> t/m³ · <tonnes> t · <area> m²`. Fill rows read: `<inSituM3> m³ compacted · × <compaction factor> compaction · <looseM3> m³ loose to buy · <density range> t/m³ · <tonnes range> t`. A from-the-dig fill row reads: `<inSituM3> m³ · loose, counted with its stratum`. These state labels are display-only — the engine keys `inSituM3` and `looseM3` are unchanged.

The base remainder's derived depth is displayed as a read-only figure with a **remainder** tag. It cannot be edited directly.

The **Overcut side** and **Overcut depth** fields at the calculator level — which set the base overcut width per side and the height below the design floor — are visible only in advanced mode. The solid diagram line shows the cut profile and the dashed line shows the neat profile. A new excavation stratum is added at the top while numbering continues from the base remainder. The excavation name column is headed **Name (optional)**. A new stratum's name field opens empty; the generated label (for example, **Existing layer 2**) appears as a placeholder and is written to storage on save if the estimator does not type a name.

### Per-row overcut column (compound model)

Each excavation stratum row has an **Overcut** column (in mm) that is visible in both simple and advanced mode. The value shown is the **additional** overcut width per side that this stratum adds on top of the stratum below it — not an absolute width. This is the compound overcut model.

For example: if the base width is 600 mm and a lower stratum adds 100 mm (cutting at 700 mm total), an upper stratum with an overcut increment of 50 mm cuts at 750 mm total. The engine stores absolute `overdigPerSideMm` values on each stratum; the modal translates between increments and absolute widths through `excavationOvercutSteps` and `withExcavationOvercutSteps`. Changing the base overcut, the depth, or the stratum order preserves each row's increment through `preserveExcavationOvercutSteps`.

The calculator title bar shows a summary of the form `<L> m long · <W> m × <D> m section · N layers · N pipes · V m³/m`. Length comes from the engine's `lengthMetres` output.

A **Check before saving** advisory panel appears above **Save** when any advisory exists, for example when an excavation stratum has no substance. The panel uses `role="note"` and `data-part="calculator-advisories"`. Advisories do not gate **Save**; [`calculatorSaveIssues`](#save-validation) does, and its messages show as the form error when **Save** is pressed.

### Reinstatement material

Each reinstatement layer has one **Material** picker. It lists eligible excavation strata under **From the dig** (each stratum is checked with `isStratumReinstatementEligible`; ineligible strata are omitted), bulk bought materials under **Fill material**, and rate items under **Non-bulk material**. Choosing a stratum stores `backfillFrom`; choosing bulk fill stores its physics snapshot; choosing a non-bulk item stores a `NonBulkMaterialSnapshot` and strips the other two choices. Legacy generic backfill remains selectable.

When a currently stored layer names a stratum that is now ineligible, the stratum remains visible in the layer row and the advisory **"This excavated material can no longer be used for reinstatement. Choose another material for this layer."** appears. Saving is blocked only when the stratum's snapshot carries `canUseForReinstatement: false`.

A non-bulk layer shows no density, loose-volume, or tonnes tile. Its priced output comes from the item's unit, and the estimator does not choose an axis.

Pipes use their outside diameter in millimetres. They reduce the base reinstatement layer only and are not assignment destinations.

### Disposal material

Each excavation stratum has one disposal picker. **Leave Onsite** stores `disposition: "onsite"` and clears the material. A named item from the **Disposal** rate group stores `disposition: "removed"` and its catalogue identity.

An absent disposition is a valid legacy removed state and displays **Carted offsite** without writing a new value. Any stratum that is not **Leave Onsite** and still has material left after reuse needs a disposal material before the calculator saves.

### Used by

When the host supplies `renderLayerAssignment`, the **Assign to Work items** toggle is shown. Turning it on reveals a **Used by** cell on each excavation and reinstatement row. The callback receives the engine layer id, kind, and display name. No returned control renders **Unassigned**. Pipes are excluded.

Measurement rows (`layerKind: "measurement"`) are never offered to the assignment slot. Starting from calculators 1.4.0, the engine appends one measurement row per offerable read-out — overall figures such as total length, saw cut, perimeter, surface area, depth, excavation volume, and total pipe length, plus per-layer area, length, and volume. Each measurement row carries a synthetic `m:o:<key>` (overall) or `m:l:<layerId>:<key>` (per-layer) id. A measurement is a quantity an estimator prices on a work item, not a layer of the hole: it has no material, no depth, and no editable row in the modal. Use `nonMeasurementRows` and `isMeasurementRow` from `@stralos-co-nz/calculators` to filter them — test on `layerKind` rather than the `m:` id prefix, which is a fallback for stored links with no row beside them.

A newly added unsaved layer holds its assignment until the calculator is saved. A saved layer with no output explains that there is nothing to assign. Split excavation assignments open the partition editor rather than collapsing to one destination.

### Rename and save

When `onRename` is present, the pencil beside the title edits only the trimmed calculator name. Enter or blur saves; Escape cancels. A rename does not save geometry or run the calculator reconcile.

Saving runs [`calculatorSaveIssues`](#save-validation), validates explicit disposal choices, runs the engine, and reconciles the calculator's assignment destinations. Existing rate choices survive unless the selected material identity changes.

### Advisory panel

An advisory panel appears below the modal body when any of the following conditions hold. None blocks saving on its own; the [save validation](#save-validation) rules are separate.

* The trench is narrower than the pipe needs (D53).
* An excavation stratum has no substance snapshot, so loose spoil volume and weight cannot be stated for it.
* Reinstatement layers exceed the cut depth.
* Existing layers exceed the trench depth.

### Discard confirmation

Closing the modal via Escape, the **×** button, **Cancel**, or a backdrop click raises a **Discard changes?** confirmation whenever there is anything unsaved. The guard covers two sources of unsaved state:

* **Geometry changes** — any dimension, layer, or stratum edit since the modal opened.
* **Host-held edits** — a pending rename or a pending **Used by** choice. Both are held by the host until the modal's **Save** is pressed, so the modal's own geometry snapshot cannot see them. The host passes `hostDirty={true}` while either is in flight; the guard ORs this into the dirty check on every close route, including the backdrop.

**Save** and **Remove calculator** are unaffected by the guard.


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