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

# Volume calculator

> Domain model for the bulk volume quantity calculator

The volume calculator is the built-in quantity engine attached to a work item, a work item component or an activity when `calculator.type` is `"volume"`. It measures a bulk volume such as a pad, dig, cut-to-level, or reinstatement build-up, including spoil per stratum and material placed back into the cut.

The engine lives in `stralos-domain/packages/calculators/src/volume.ts`. The modal imports the engine functions for its live preview, but nothing the preview computes is persisted.

> [!NOTE]
>
> The embedded `calculator` column on work items and components is retired in the Estimate API (migration 0058). All embedded configs have been converted to project-level calculator entities. The Activities API still stores the column verbatim. New estimate and activity calculator work uses calculator entities and their link registers.

## Calculator shape

`VolumeCalculatorArm` carries:

| Field | Type | Notes |
| - | - | - |
| `type` | `"volume"` | Discriminant. |
| `count` | `number` | Legacy. The UI does not expose this control. The engine still applies a stored value so that no existing calculator re-prices, but the quantity belongs on the work item instead. New calculators store `1`. |
| `depth` | `number` | Excavation depth in metres. |
| `existingLayers` | `CalculatorExcavationStratum[]` (optional) | Excavated strata; the last depth is derived. |
| `layers` | `CalculatorReinstatementLayer[]` | Bottom-anchored reinstatement layers. |
| `batter` | `CalculatorBatter` (optional) | Base excavation batter; ignored but preserved in area-depth mode. |
| `overcut` | `CalculatorOvercut` (optional) | Base width and height allowance. Width is dormant in area-depth mode; height remains applicable. |
| `needsReview` | `boolean` (optional) | Server-written flag for an SOQ-sourced area that has changed. |

The mode adds the plan dimensions described below.

## Modes

The calculator takes **m² inputs only**, in two modes, and there is no direct-m³ mode: a direct m³ has no depth, and without a depth neither the strata stack nor the fill stack has anything to divide.

| Mode | Excavation | Batters |
| - | - | - |
| **L × W × D** | The pit's excavation machinery with the structures removed: stratum profiles, cut segments and Simpson integration over the plan area. | **Applied.** |
| **Area × D** | `areaM2 × depth`; the strata simply split a prism by depth. | **Ignored** — an m² takeoff area already contains whatever the batter did. |

A stored `batterStyle` or `batterPerSideMm` in `areaDepth` mode is **ignored, never cleared**, and the batter controls are hidden rather than removed. A saved config that switched modes must not throw, and `lwd → areaDepth → lwd` round-trips to an identical config. Stripping on a mode switch would destroy data the estimator typed at a moment they asked for nothing to be destroyed.

### The area source

A work item whose unit is m² opens the calculator on **Area × D pre-filled with its own SOQ quantity** — a **default, not a lock**. There is no cross-item area picking: the area comes from the host SOQ item or is typed.

Under `areaSource: "soq"` the area field is **read-only**. The only route to a manual area is switching the **Area source** select to Manual, which seeds `areaM2` from the value on screen, sets `areaSource: "manual"` and drops `areaSourceQuantity`. Typing never silently flips the source, because there is no typing into the field — so a config can never claim an SOQ source while holding a hand-typed area. Switching back to SOQ re-reads the host quantity and discards the manual value, behind a confirm.

A work item whose unit is **m³ cannot use this calculator at all**: the submenu entry is disabled, because an m³ quantity already *is* a volume and calculating one from it is circular.

Activities have no work item and no SOQ, so a volume calculator on an activity is **manual-area only** and `needsReview` can never be set there.

## Excavation strata (`CalculatorExcavationStratum`)

Volume, pit, and trench calculators share the same live excavation fields. Batter and horizontal overcut are ignored but preserved in `areaDepth` mode because that mode has no plan edges.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Stable identity within the calculator. |
| `name` | `string` | Display label. |
| `depth` | `number` | Depth in metres. The last stratum is the derived base remainder. |
| `disposition` | `"onsite" \| "removed"` (optional) | Destination of the excavated remainder. Absent preserves the legacy removed behaviour. |
| `disposalMaterial` | `{ id: EntityId; name: string }` (optional) | Disposal rate-item identity; pricing remains on the assigned destination line. |
| `substance` | `DisposalSubstanceSnapshot` (optional) | Frozen bulking and density physics. |
| `allocations` | `ExcavationAllocation[]` (optional) | Explicit shares of in-situ volume sent to 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. |

The legacy `batterStyle` and `batterPerSideMm` fields are converted to the current batter or overcut shape during normalisation and then stripped.

## Overcut

`VolumeCalculatorArm.overcut` is the same `CalculatorOvercut` shape used by the pit and trench calculators:

| Member | Type | Applies in `lwd` | Applies in `areaDepth` |
| - | - | - | - |
| `widthPerSideMm` | `number` (optional) | Yes — widens every stratum's top and bottom on all four sides. | **Dormant** — preserved, not applied. An m² takeoff area has no edges to widen. `overcutWidthPerSideM` on the output is published as `0`. |
| `heightMm` | `number` (optional) | Yes | Yes — adds a prism of `areaM2 × heightMm / 1000` below the design floor. |

`widthPerSideMm` is dormant, not absent, in `areaDepth` mode — the value is stored across a mode switch so that switching back to `lwd` restores it unchanged.

### Per-row width override

`overcut.widthPerSideMm` is the base for the excavation. Each excavation stratum can override it with `CalculatorExcavationStratum.overdigPerSideMm`, using the same replace-not-stack rule as the pit:

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

`CalculatorReinstatementLayer.overdigPerSideMm` is a separate, independent field for the fill layer's own plan widening. It does not override the excavation's base and the excavation's base does not reach it (R4).

### Height band geometry

The height band hangs below the design floor. The lowest reinstatement layer absorbs the band's volume. Its designed prism is not resized; it receives the band's volume as an addition, and its drawn extent grows by the band's depth. With no layers the band is plain extra excavation — nothing puts it back.

`VolumeOvercutBand` is published on the engine output:

| Field | Type | Notes |
| - | - | - |
| `z1` | `number` | Design floor depth, m. |
| `z2` | `number` | Cut floor depth, m. Equal to `cutDepth`. |
| `topArea` | `number` | Plan area at `z1`, m². In `areaDepth` mode this is the typed area. |
| `bottomArea` | `number` | Plan area at `z2`, m². Equal to `topArea` wherever there is no wall to continue. |
| `volume` | `number` | In-situ volume of the band alone, per unit. |
| `layerId` | `EntityId` (optional) | The base remainder's id. `undefined` when no strata are configured. |
| `topWidth` | `number` (optional) | W-axis cut width at `z1`. `lwd` mode only. |
| `bottomWidth` | `number` (optional) | W-axis cut width at `z2`, clamped at 0. `lwd` mode only. |

The engine also publishes `overcutWidthPerSideM` (as applied — `0` in `areaDepth` mode), `overcutHeightM`, and `cutDepth`.

### Normalisation and migration

`normalizeVolumeCalculator` resolves the overcut before processing strata using the same rule as the pit:

* **Override-era config** (the `overcut` key is present): rows are overrides; value-less rows inherit the base.
* **Legacy config** (the `overcut` key is absent): base = max `overdigPerSideMm` across excavation strata; value-less rows pinned at `0`. No stored quantity moves.

The overcut does not reach fill layers — a reinstatement layer's `overdigPerSideMm` is untouched by this migration.

### Disposal substance outputs

When `CalculatorExcavationStratum.substance` is present, the engine derives physical disposal outputs from the snapshot's `bulkingPct` and `looseDensity`:

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

`DisposalSubstanceSnapshot` freezes the organisation substance identity and physics at the moment it is selected or refreshed. It contains `id`, `name`, `looseDensity`, `bulkingPct`, nullable `compactionPct`, optional `canUseForReinstatement`, and `resolvedAt`. Pricing and the charge unit belong to the assigned destination line. A stratum without a substance can still emit its in-situ output, but it has no derived loose-volume or tonnage output.

`canUseForReinstatement` controls whether the stratum may appear in the **From excavation** list of the material picker. See [Reinstatement eligibility](https://help.stralos.app/internal/trench-calculator.md#reinstatement-eligibility) for the derivation rules — they apply identically to the pit, trench, and volume calculators.

This substance parity behaviour is identical across the trench, pit, and bulk volume calculators: the same 12 solid m³ of the same substance produces the same disposal row quantity in all three.

### Disposal output totals

`calculateVolumeCalculator` returns four per-unit disposal totals, mirroring the trench arm:

| Field | Notes |
| - | - |
| `disposalLooseCubicMetres` | Loose volume of strata marked `"removed"` (or with no `disposition`), derived through each stratum's own substance physics. Only substance-bearing strata contribute; legacy strata do not. |
| `disposalTonnes` | Tonnes of the removed remainder, derived through each stratum's own substance physics. Same scope as `disposalLooseCubicMetres`. |
| `removedInSituM3` | In-situ volume carted away. A stratum with no `disposition` counts as removed, preserving pre-existing behaviour. |
| `retainedInSituM3` | In-situ volume left on site. Only strata with `disposition: "onsite"` contribute. |

All four are **per unit**. The engine multiplies by `count` when producing estimate rows, but `count` is legacy — the UI no longer exposes the control.

A stratum WITH a `disposition` reports its whole remainder (gross in-situ less reinstatement reuse). A stratum with NO `disposition` reports only its implicit unallocated slice. This asymmetry preserves byte-for-byte output on untouched configs.

Reuse reduces a disposal row's `inSituM3`, `looseM3` and `tonnes`, but not the plan area it covers: a stratum's disposal slices share its whole plan area between them, so a single remaining slice carries all of it. See [Net remainder quantities](https://help.stralos.app/internal/trench-calculator.md#net-remainder-quantities) for the rule, which is shared by all three arms.

## Reinstatement layers (`CalculatorReinstatementLayer`)

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Stable layer identity. |
| `name` | `string` (optional) | Display label resolved through `fillLayerLabel`. |
| `depth` | `number` | Layer depth in metres. |
| `isBackfill` | `boolean` (optional) | Uses excavated material when true. Has no effect when `nonBulk` is present. |
| `backfillFrom` | `BackfillStratumSnapshot` (optional) | Selected source stratum; stripped when `isBackfill` is not true or when `nonBulk` is present. |
| `fill` | `FillMaterialSnapshot` (optional) | Frozen bought-material density range and compaction. Stripped 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](https://help.stralos.app/internal/trench-calculator.md#nonbulkmaterialsnapshot). On the volume arm, a per-metre item falls back to `count` (priced at 1) in `areaDepth` mode, and in `lwd` mode, because no ruling has been made about which plan axis to use for length. |
| `batter` | `CalculatorBatter` (optional) | The fill layer's own batter. It does not inherit the excavation batter. |
| `overdigPerSideMm` | `number` (optional) | The fill layer's own widening. It does not inherit the excavation overcut. |

The layers are independent profiles. A fill layer can extend outside the excavation outline; the diagram shows that geometry rather than clamping it.

### Estimate API write-side validation for `backfillFrom`

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 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`. A stratum with no substance, or whose snapshot does not carry the `canUseForReinstatement` flag, is accepted whatever its compaction.

Every volume create or update that carries a config also runs the shared save gate, `assertCalculatorSaveReady`, which refuses an incomplete config with `422 calculator_incomplete` (including a from-the-dig layer whose stratum is explicitly ineligible, even on update) and an unreadable one with `422 calculator_config_unreadable`. See [Save validation](https://help.stralos.app/internal/trench-calculator.md#save-validation).

## Geometry

### Excavation

In `lwd` mode the plan is `lengthM × widthM` and the excavation runs on the same machinery as the pit: stratum profiles converted once at the call boundary by `batterOvercutWidth`, cut segments, and Simpson integration of the plan area over each stratum's own span. The plan area at depth `z` is

```
A(z) = (L0 + 2·off(z)) × (W0 + 2·off(z))
```

which is quadratic and convex in `z` inside an angled segment, so a trapezoid over it would over-count. Simpson's rule is exact for a quadratic.

In `areaDepth` mode there is no plan to widen, so there are no profiles and no cut segments: each stratum's volume is `areaM2 × clampedDepth`.

Both modes then add the same top-up term:

```
excavation = Σ stratum volumes + max(0, depth − Σ clamped depths) × planArea
```

The three depth rules are inherited from the shipped trench, in `resolveStrataDepths`:

1. **The last stratum is the derived base remainder** — `depth − Σ(other strata depths)`, floored at 0. In L × W × D mode, its batter and overcut are applied like any other stratum.
2. **Non-base strata are clamped to the excavation.** **Raw** effective depths drive the UI and the "exceeds" warning; **clamped** depths drive volume and cut.
3. **Depth the strata do not cover is still excavated** at the base plan, including the calculator-level overcut in L × W × D mode. This separate code path from the base remainder makes an explicit empty strata list safe.

### The fill stack

The stack is **anchored at the excavation floor** and rises. `layers` is ordered top→bottom for display, so the lowest layer is the last element and the top of the stack sits at `depth − Σ depths`:

```
filled   = Σ layers[].depth
open     = max(0, depth − filled)     // an open void at the SURFACE
overfill = max(0, filled − depth)     // warns; quantities are NOT clamped
```

There is **no derived base remainder on the fill side** — that is the difference from the trench, whose base layer absorbs the slack. Unallocated depth stays open at the surface.

Each layer's `ReinstatementBand` records where it sits — `z1` to its top, `z2` to its underside, with `z1` going negative once the stack rises above the surface. The band is what the diagram and the open/overfill arithmetic read. **It does not determine the layer's volume.**

### Per-layer volume

A fill layer's volume comes from **its own** batter, not from the excavation profile:

```
o = (layer.batterPerSideMm ?? 0) / 1000        // per-side offset, metres
d = layer.depth

batterStyle absent  →  offBottom = 0,  offTop = 0     // prism at the plan
batterStyle square  →  offBottom = o,  offTop = o     // prism at the widened plan
batterStyle angled  →  offBottom = 0,  offTop = o     // frustum, wide at the top

A(off)   = (L0 + 2·off) × (W0 + 2·off)
layerVol = (d / 6) × ( A(offBottom) + 4·A((offBottom + offTop)/2) + A(offTop) )

areaDepth mode:  layerVol = areaM2 × d               // always a prism
```

A layer's volume is therefore **independent of where it sits in the stack** and of what the excavation does around it: two identical layers at different heights price identically.

> [!WARNING]
>
> This is Simpson's rule, not a mean-offset shortcut. `A(off)` is quadratic in the offset, so evaluating it once at the mean offset — the "half the offset" intuition for an angled layer — **under-counts by `o²·d/3`**. Simpson's midpoint sample *is* the half-offset term, so the intuition survives as one of three weights rather than as the whole answer. With both offsets equal the three samples coincide and the formula collapses to `A × d` exactly.
>
> It is also **not** the classical frustum formula `h/3·(A₁ + A₂ + √(A₁A₂))`, which assumes similar faces. Equal per-side offsets grow L and W by the same absolute amount, so their ratio changes with depth. On a square plan the faces are similar, which is why the fixtures insist on `L0 ≠ W0`.

**Overfill is not clamped, and a band can rise above the surface.** When `filled > depth` the top layer extends past `z = 0`; the warning fires and the quantity stays honest about what was entered. Nothing about a layer's volume depends on the excavation anyway.

## Warnings (`VolumeWarning`)

Neither warning blocks a save.

| `kind` | Payload | Raised when |
| - | - | - |
| `overfill` | `overfillM` | `Σ layer depths > depth`. Quantities are not clamped. |
| `strata-exceed-depth` | `strataDepthM`, `excavationDepthM` | The raw stratum depths run deeper than the excavation. Volumes use the clamped depths, so nothing below the floor was excavated or disposed of. |

There is **no warning for a layer battered wider than the hole**. It is expressed visually: the diagram draws the cut outline last, so a band wider than the cut is visibly outside it. There is also no alert for a batter ignored in Area × D — the controls are simply not rendered, and the strata section's description says so.

## Normalisation

`normalizeVolumeCalculator` brings a stored config to the current shape and is idempotent:

1. `count` resolves to a positive number, defaulting to `1`. The field is legacy; see [Quantities, units and density](#quantities-units-and-density).
2. The overcut base is resolved before strata are processed. Legacy row widths migrate without changing quantity; fill-layer widths remain independent.
3. An absent `existingLayers` list is seeded with one full-depth stratum; an explicit empty list stays empty. Stratum ids and current batter/overcut fields are normalised, while retired pricing fields are stripped at the shared strata boundary.
4. Fill-layer ids and physical material snapshots are normalised. Reordering does not re-mint stable ids, and dormant batter values survive mode switches.
5. `needsReview` is stored only when true.
6. The output is rebuilt as either `lwd` or `areaDepth`; `areaSourceQuantity` is kept only for an SOQ area source.

## Defaults

`createDefaultVolumeCalculator` opens on a 0.5 m depth with one full-depth base stratum and one "Excavated material" fill layer. Passing a positive, finite `soqAreaM2` seeds `mode: "areaDepth"` with `areaSource: "soq"` and `areaM2 === areaSourceQuantity`; omitting it gives `mode: "lwd"` at 10 × 5 m, which is what an activity always gets. A non-finite or non-positive host quantity is **not** an SOQ area — seeding one would freeze a meaningless number into the config and hand the staleness check two operands it could never reconcile.

`CALCULATOR_DEFAULT_FILL_LAYER_DEPTH_M` is `0.1`; `CALCULATOR_DEFAULT_STRATUM_DEPTH_M` is `0.2`.

## Named destination partition

When any excavated stratum in the config names a destination (that is, any stratum carries `allocations`), all from-the-dig layers use `partitionExcavationByDestination` instead of the default pro-rata `partitionExcavation`. The gate is the config, not the calculator arm — the trench, pit, and volume arms all apply the same rule.

Under the named-destination rule, a `CalculatorReinstatementLayer` with `isBackfill: true` and a `backfillFrom` snapshot draws its reuse demand from the named stratum before any pro-rata assignment runs. The draw converts placed volume to in-situ volume through that stratum's own substance physics (bulking and compaction). Any unnamed from-the-dig demand is then drawn pro rata over what remains.

A config that names no destination partitions exactly as before — `partitionExcavation` runs and every stored config is byte-identical.

`backfillFrom` is physical when a stratum names a destination: selecting a source stratum changes the disposal quantities.

## Staleness

The SOQ-sourced area is **copied into the config at save**. If the source SOQ quantity later changes, the work item's calculator is stamped `needsReview` **server-side, on the quantity write path** — the stamp is not a recompute, and no rows are regenerated. Reopening the calculator and saving it adopts the new value and clears the stamp.

The evaluator, `needsReviewFor(config, currentQuantity)` in `stralos-api-estimate/src/calculators/staleStamp.ts`, returns true for exactly one shape:

```
type === "volume"  AND  mode === "areaDepth"  AND  areaSource === "soq"
AND  areaSourceQuantity and currentQuantity are BOTH finite
AND  round3(areaSourceQuantity) !== round3(currentQuantity)
```

Everything else is false: a manual source, a pit config, a trench, an unrecognised blob, or a non-finite value on either operand. The finiteness test is `Number.isFinite`, never a bare `typeof` — `typeof NaN` is `"number"`, and `round3(NaN) !== q` is true, so a `typeof` guard would *set* the flag it exists to suppress.

`needsReview` is **derived state, recomputed in both directions** on every write that can move either input, so a quantity edited away and back does not leave the flag stuck on. It is stamped from five routes — `POST /work-items`, `PUT /work-items/:id`, the batch `setQuantity` op, `POST /work-item-components` and `PUT /work-item-components/:id` — each re-reading both operands from the database inside the caller's transaction, after the write. It stamps **both altitudes**, and a component-level config is compared against the **parent work item's** quantity.

`false` is stored as the key's **absence**, so a config that cannot be stale never carries the field. A malformed or unrecognised calculator blob is skipped rather than rejected — the flag is advisory and must never be able to fail a quantity write.

> [!WARNING]
>
> The inbound strip is by **schema omission**: `needsReview` is simply not declared on the volume schema, so zod drops it. That covers a volume config and nothing else. A `legacy` trench or a `passthrough` blob is stored byte-for-byte, so a client-planted `needsReview: true` inside one of those **persists**, and the stamp refuses to touch a config whose type is not `"volume"`. This is deliberate and test-pinned, but "a client can never write the flag" is not true as a blanket statement.
>
> `needsReview` is **not** stripped from responses, and should not be — it is how the client learns to show the review banner.

`needsReview` is deliberately on this arm alone. A pit has no SOQ-sourced input, so giving it the field would create a flag that can be set and never cleared. `stralos-api-activities` has no stamping machinery at all: the schema omission is the whole mechanism there, and an activity calculator can never carry the flag.

There is no submission gate on `needsReview`, and no auto-recompute: a stale area yields an out-of-date quantity, not an unpriced one, and the badge is the whole remedy.

## In the modal

`VolumeCalculatorModal` supports **L × W × D** and **Area × D**. Area mode ignores width batter and width overcut because the area already defines the plan, while height overcut remains active. An SOQ-sourced area is read-only until the estimator switches to manual input.

### 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 (where applicable), 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**.

Neither toggle changes the stored calculator or its computed totals.

The two-pane layout places dimensions and layers on the left and the section preview with material balance on the right. At narrow widths the panes stack. A new excavation stratum and a new reinstatement layer are added at the top; the base remainder stays numbered 1. 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.

Each stratum has a substance picker and, when the host supplies disposal rates, a **Disposal material** picker. Both the estimate host and the activities host supply the Disposal rate group to volume calculators.

Each reinstatement layer uses one material picker for a named **From the dig** source, bought fill, or a non-bulk rate item. Both hosts supply the non-bulk catalogue to the volume editor. The picker offers only non-bulk items the volume arm can measure: priced by area (`areaM2`), by volume (`inSituM3`), or by a unit that is not geometric (`count`). A per-metre item is not offered, because neither mode states a plan length to price by the metre. Only eligible strata appear in the **From the dig** list (each stratum is checked with `isStratumReinstatementEligible`; ineligible strata are omitted). When a stored layer names an ineligible stratum, the stratum stays visible in the row and shows the advisory **"This excavated material can no longer be used for reinstatement. Choose another material for this layer."** Saving is blocked only when the stratum's snapshot carries `canUseForReinstatement: false`. The engine displays physical outputs and density ranges; pricing belongs on the destination line.

Each row's result sentence names the volume state: excavation reads `<inSituM3> m³ in situ · × <bulking> bulking · <looseM3> m³ loose spoil · ...`; a bought fill row reads `<inSituM3> m³ compacted · × <compaction> compaction · <looseM3> m³ loose to buy · ...`. A from-the-dig fill row reads `<inSituM3> m³ · loose, counted with its stratum`. Section totals and balance ledger group headings also name the state. These 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.

A **Check before saving** advisory panel appears above **Save** when any issue exists — for example, when an excavation stratum has no substance. The panel uses `role="note"` and `data-part="calculator-advisories"`. Save is not gated by advisories.

When `renderLayerAssignment` is provided, excavation and reinstatement rows show **Used by** controls. The bulk excavation row (`vol-excavation`) is not offered in the assignment UI — it is excluded from the layer catalogue and from the measurement catalogue's per-layer readings.

Each excavation stratum row has an **Overcut** column (in mm) that is visible in both simple and advanced mode. The value is the **additional** overcut width per side that this stratum adds on top of the stratum below it — the compound overcut model. The calculator-level **Overcut side** field (the base width, advanced mode only) is dormant in area–depth mode; the **Overcut depth** field remains active in both plan modes.

When `renderLayerAssignment` is provided, the **Assign to Work items** toggle is shown. Turning it on reveals the **Used by** controls.

Measurement rows (`layerKind: "measurement"`) are not offered to the modal's assignment slot. The engine appends one synthetic `m:o:<key>` or `m:l:<layerId>:<key>` row per available read-out. Use `nonMeasurementRows` and `isMeasurementRow` from `@stralos-co-nz/calculators` to filter them by kind rather than testing the id prefix.

In `lwd` mode the volume states a **Perimeter** (`2 × (lengthM + widthM)`, the neat plan outline) as well as its **Circumference** (the cut outline at the surface). In `areaDepth` mode both are listed but disabled, because an area has no outline. See [Volume measurements](https://help.stralos.app/internal/calculator-measurements.md#volume-measurements).

The UI does not expose the quantity control. A stored `count` above one remains effective in the engine and appears as a read-only note. A volume based on an SOQ area can carry `needsReview` when that source quantity changes; saving adopts the current area.

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

### Advisory panel

An advisory panel appears below the modal body when any of the following conditions hold. None blocks saving.

* The SOQ quantity has changed since the area was last saved.
* Existing layers exceed the excavation depth.
* Reinstatement layers extend above the excavation.
* An excavation stratum has no substance snapshot, so loose spoil volume and weight cannot be stated for it.

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

## Generated rows

The shared engine emits physical output rows with stable `vol-` keys, layer ids, layer kinds, display names, and the outputs available for each row. Volume outputs cover the bulk excavation total, per-stratum disposal quantities, and reinstatement layers.

Project and library calculator services bind these outputs to work-item or activity-component destinations through their assignment registers. Pricing and rate-option selection remain on those destination rows.

### Bulk dig exclusion

The `vol-excavation` layer id is never written to the estimate register. Default seeding, an explicit `PUT /links`, and a replayed register all drop it silently. The engine still computes the bulk dig total — the stratum rows, fill draw, and totals derive from it — but no work-item component row is created for it. The excavation strata disposal rows are what an estimator prices the dig by. Layer-scoped measurements of the bulk dig (`m:l:vol-excavation:*`) are also excluded; overall calculator readings (`m:o:*`) remain available.

### Deleted row stays deleted

When an estimator deletes a component row that a calculator generated, the Estimate API removes the row's link from the register and records the layer id in the calculator's `decided_layer_ids`. On the next calculator save, the reconcile skips any layer in `decided_layer_ids` and does not re-insert the row. Seeding from the default work item also skips decided layers.

## Write paths

The embedded `calculator` columns on estimate work items and components are retired (migration 0058). Every embedded config was converted into a project-level calculator row before that migration deployed. The Estimate API now ignores the `calculator` key on write; an explicit `null` is accepted without error but does not delete any rows. Rows previously generated from embedded calculators survive as ordinary components.

New estimate calculators use project calculator routes. New activity calculators use `/activity-calculators` and its links register. Both validate the current config with the shared engine, reconcile the complete owned key set transactionally, and reject unreadable configs or pricing fields inside calculator geometry.

Disposal material validation and disposal row binding apply to volume calculators through the shared `reconcileProjectCalculator` path. See [Project-level calculator disposal reconciliation](https://help.stralos.app/internal/trench-calculator.md#project-level-calculator-disposal-reconciliation) for the full rules, including how rows with no `rate_item_id` are relinked on the next save.

## Quantities, units and density

`count` is a legacy field. The UI control was removed on 2026-09-12 — a count is a work-item quantity, and a calculator describes one hole. The engine still applies a stored `count` value so that no existing calculator re-prices. A later ruling is needed to remove it from the engine; that decision has not been made. A new calculator stores `count: 1`.

Bought reinstatement layers carry compacted and loose cubic metres plus a minimum and maximum tonnage derived from their frozen density ranges. A single `tonnes` output is present only when both bounds agree. From-the-dig layers net against excavation, and disposal outputs derive loose volume and tonnes from the stratum's substance snapshot. Pricing conversion is applied later by the assigned destination line.


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