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

# Pit / Structures calculator

> Domain model for the pit / structures quantity calculator

The pit calculator is the built-in quantity engine attached to a work item, a work item component or an activity when `calculator.type` is `"pit"`. It measures a rectangular excavation, spoil per stratum, and a reinstatement build-up above whatever sits in the hole. The separate bedding layer and in-situ concrete pad are deprecated: the modal no longer offers them, but the engine still prices a stored one.

The engine lives in `stralos-domain/packages/calculators/src/pit.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.

> [!NOTE]
>
> This page describes the calculators 1.7 and calculator-ui 0.4 contract, with the structure fit display from calculator-ui 0.10.2. The Estimate and Activities remotes and APIs all pin calculators 1.7.0 or later, and both remotes pin calculator-ui 0.4.0 or later, so structure-position editing, the clearing of stored bedding and concrete-base fields, and position-aware displacement apply on both surfaces and on the server.

## Calculator shape

`PitCalculatorArm` carries:

| Field | Type | Notes |
| - | - | - |
| `type` | `"pit"` | Discriminant. |
| `count` | `number` | Multiplies the complete pit before rounding. New calculators use `1`. |
| `lengthM` | `number` | Manually entered plan length in metres. |
| `widthM` | `number` | Manually entered plan width in metres. |
| `depth` | `number` | Excavation depth in metres. |
| `structures` | `PitStructure[]` | Geometry-only structures sharing the pit. |
| `bedding` | `PitCalculatorLayer \| null` | Full-plan bedding. `null` means none. Deprecated since calculators 1.7.0 — calculator-ui 0.4.0 writes `null` and drops any stored value on open; the engine still prices a stored one. Use a reinstatement layer and `PitStructure.position` instead. |
| `concreteBase` | `PitConcreteBase \| null` | Optional pad above the bedding. `null` means none. Deprecated with `bedding` since calculators 1.7.0. |
| `layers` | `PitReinstatementLayer[]` | Reinstatement build-up, ordered top → bottom. New configs use this field; after normalisation `[]` means no reinstatement. See [Reinstatement layers](#reinstatement-layers). |
| `backfill` | object (deprecated, optional input) | Legacy single backfill entry accepted on pre-1.6.0 stored configs. Normalisation migrates it into `layers` as `pit-backfill`; new configs use `layers[]`. |
| `batter` | `CalculatorBatter` (optional) | Base excavation batter. |
| `overcut` | `CalculatorOvercut` (optional) | Base width and height allowances. |
| `existingLayers` | `CalculatorExcavationStratum[]` (optional) | Excavated strata, ordered top to bottom; the last depth is derived. |

`bedding` and `concreteBase` are required and nullable. `layers` is required and may be empty. Normalisation maps older absent fields to `null` (bedding, concreteBase) or `[]` (layers), leaving one representation of each absent state. There are no clearance fields or automatic plan sizing.

## Structures (`PitStructure`)

Structures are recorded for geometry purposes — their volume is subtracted from the excavation when calculating backfill, and their footprints drive the per-structure pad and the combined-footprint warning. **Structure records generate no row of any kind**, and their supply is priced outside the calculator.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Keys the list and the diagram slot. Never appears in a `calcKey`. |
| `name` | `string` (optional) | Display label. |
| `position` | `PitStructurePosition` (optional) | Where the structure sits vertically. Absent means on the floor. See [Vertical position](#vertical-position). |
| `shape` | `"box" \| "cylinder"` | Discriminates the dimension fields. |
| `orientation` | `"vertical" \| "horizontal"` | Cylinders only. |
| `lengthM` | `number` | Box, and a horizontal cylinder (its length along the pit's L axis). |
| `widthM` | `number` | Box only. |
| `heightM` | `number` | Box and a vertical cylinder. |
| `diameterM` | `number` | Cylinders only. |

The union is self-narrowing: no shape carries both a height and a length, so no code has to decide which to read. A horizontal cylinder occupies its diameter of both the W axis and the depth.

### Vertical position

`PitStructurePosition` places a structure along the pit's vertical axis. It carries a single distance measured from one of two reference ends:

| Field | Type | Notes |
| - | - | - |
| `from` | `"floor" \| "surface"` | The reference end. `"floor"` measures from the stack base up; `"surface"` measures from the ground surface down. |
| `distanceM` | `number` | Distance in metres. Never negative. |

With `from: "floor"`, the structure's underside sits `distanceM` above the stack base (the top of the concrete base, or the bedding, or the design floor). `distanceM: 0` places the underside on the stack base, which is identical to an absent `position`.

With `from: "surface"`, the structure's top sits `distanceM` below the ground surface. `distanceM: 0` places the top flush with the surface — the position for a manhole or chamber lid at finished ground level.

The engine resolves each structure's seat once and records every placement in `structurePlacements` on the engine output. The displacement, each layer's net volume, fit warnings, and the diagram all read from `structurePlacements`, so the drawing and the quantities cannot place a structure differently.

Normalisation strips `{ from: "floor", distanceM: 0 }` and any `position` whose `from` is not one of the two valid ends. "On the floor" has one representation: absent `position`. A surface-referenced or lifted floor-referenced position is preserved as-is.

A structure that reaches past the pit is not clamped. Its volume is clipped to the hole, and a `structure-depth` warning is emitted. The proud part of a floor-referenced structure — the volume above the surface — is still deducted from the lowest reinstatement layer, matching the behaviour for unpositioned structures.

The server-side schema validates `structures` with a **plain union**, not a discriminated union on `shape`. Because `orientation` splits `cylinder` into two arms, `shape` is not unique across the three members. A discriminated union on `shape` would throw a duplicate-discriminator error at parse time, producing an unhandled 500 on every pit save that included a structure. The plain union tries each arm in order and reports the first match.

| Shape | Displaced volume | Plan footprint | Depth it needs |
| - | - | - | - |
| Box | `lengthM × widthM × heightM` | `lengthM × widthM` | `heightM` |
| Vertical cylinder | `π (diameterM/2)² × heightM` | `π (diameterM/2)²` | `heightM` |
| Horizontal cylinder | `π (diameterM/2)² × lengthM` | `lengthM × diameterM` | `diameterM` |

A lying cylinder's plan projection **is** `lengthM × diameterM` exactly, not a bound on it. One `pitStructureFootprint` definition serves both the per-structure pad and the fit warning, so a pad can never be computed against a footprint the warning does not know about.

## Excavation strata (`CalculatorExcavationStratum`)

Pit, volume, and trench calculators share the same live excavation fields.

| 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

`PitCalculatorArm.overcut` is the calculator-level allowance for the bucket taking more ground than the design line. It carries two independent members:

| Member | Type | Effect |
| - | - | - |
| `widthPerSideMm` | `number` (optional) | Widens every stratum's top and bottom on all four sides. Each plan axis grows by `2 × widthPerSideMm / 1000`. Growth is quadratic in the offset: `2o × ∫(L + W)dz + 4o²d`. |
| `heightMm` | `number` | Adds a band below the design floor. The band continues the base remainder's wall downward at its own average slope. |

Both members are optional and there is no default for either. An absent `overcut` key is different from `overcut: {}` — an untouched pit mints no key at all and computes byte-identically with any pit stored before the field existed.

### Per-row width override

`overcut.widthPerSideMm` is the **base** for the whole excavation. Each stratum can override it with `CalculatorExcavationStratum.overdigPerSideMm`:

* A present value **replaces** the base for that stratum. It does not stack with it.
* Absent means inherit the calculator's base.
* A stored `0` means neat — the stratum is dug to the design line regardless of the calculator's base.

This is the same model as per-row batter: one base for the excavation, overridable per row.

### Height band geometry

The height band hangs below the design floor. The bedding is measured **up from the cut floor** (reading A), so the bedding fills the band first:

* `b >= h`: bedding fills the whole band and a prism of `(b − h)` sits above the design floor.
* `b < h`: bedding fills the bottom `b` of the band; the lowest reinstatement layer absorbs the remainder (`h − b`).
* `b = 0` (no bedding): the lowest reinstatement layer absorbs the whole band. A pit with no layers leaves the band as plain extra excavation with nothing to fill it back.

The design-floor profile does not move — the band hangs under it. Everything referenced to depth (the pad, fit checks, the D29 warnings) remains measured from the design floor.

`PitOvercutBand` is published on the engine output:

| Field | Type | Notes |
| - | - | - |
| `z1` | `number` | Design floor depth, m. Equal to `calculator.depth`. |
| `z2` | `number` | Cut floor depth, m. Equal to `cutDepth`. |
| `topWidth` | `number` | W-axis cut width at `z1` — the widest end of the band. |
| `bottomWidth` | `number` | W-axis cut width at `z2`. Clamped at 0. |
| `topArea` | `number` | Plan area at `z1`, m². |
| `bottomArea` | `number` | Plan area at `z2`, m². |
| `volume` | `number` | In-situ volume of the band alone, per unit. |
| `absorbedByBeddingM` | `number` | Metres of band height the bedding fills: `min(b, h)`. |
| `absorbedByBackfillM` | `number` | Remaining metres absorbed by the lowest reinstatement layer: `h − absorbedByBeddingM`. (Field name preserved from pre-1.6.0.) |
| `layerId` | `EntityId` (optional) | The base remainder's id. `undefined` when the pit has no strata. |

The engine also publishes `beddingExtent` and `padExtent` as `{ z1, z2 }` depth ranges for diagram rendering, and `overcutWidthPerSideM` (the resolved base width in metres) and `overcutHeightM` (the resolved height in metres) alongside the existing `cutDepth`.

`structurePlacements` is published on the engine output alongside `structuresVolume`. It is an array in `structures` order, each entry carrying:

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | The structure's id. |
| `z1` | `number` | Depth of the structure's top below the surface, m. Negative when it stands proud of the surface. |
| `z2` | `number` | Depth of the structure's underside below the surface, m. |
| `volume` | `number` | What the structure displaces, per unit. Clipped to the hole; any proud part is included. |

### Overcut-needs-bedding warning

When a pit has a concrete base, a height overcut, and no bedding (or a bedding shallower than the overcut height), the engine emits a new warning:

```
{ kind: "overcut-needs-bedding";
  bandVolumeM3: number;
  concreteBaseScope: "pit" | "structures";
  beddingDepthM: number | null;
  requiredDepthM: number }
```

`beddingDepthM` is `null` when there is no bedding at all. `requiredDepthM` is the resolved overcut height. The warning fires on `<`, so equal depth is silent. It warns and does not re-price — the engine still hands the remainder of the band to the lowest reinstatement layer. The width overcut does not trigger this warning.

### Normalisation and migration

`normalizePitCalculator` resolves the overcut before processing strata:

* **Override-era config** (the `overcut` key is present, even as `{}` or height-only): row values are per-row overrides. Rows that state nothing inherit the base.
* **Legacy config** (the `overcut` key is absent): the base is set to the maximum `overdigPerSideMm` across all strata. Every row that stated a width keeps its own value. Every row that stated nothing is pinned at `0` (dug neat). Migrating a legacy pit moves no quantity — the pinned rows are dug exactly as they were under the per-row model.

The overcut key is absent on the output when neither the base nor the resolved value requires one, so the output is never churned with a `{}`.

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

`calculatePitCalculator` 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, which preserves pre-existing behaviour. |
| `retainedInSituM3` | In-situ volume left on site. Only strata with `disposition: "onsite"` contribute. |

All four are **per unit** — multiply by `count` to get the totals the UI shows.

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 is intentional: a named destination describes all of the material, while an unnamed stratum keeps the legacy implicit-only summary, which is what makes an untouched config byte-identical.

## Reinstatement layers

Calculators 1.6.0 replaced the single `backfill` entry with an ordered list, `layers[]`. Each entry is a `PitReinstatementLayer` with the same shape as `PitCalculatorLayer`: it can be bought fill, a from-the-dig selection, or a non-bulk rate item.

Layers are ordered **top to bottom**, above the concrete base. The engine fills the available reinstatement volume from the first layer down.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Stable identity within the arm. Used to derive the generated row key (`pit-layer-<id>`). |
| `depth` | `number` | Layer depth in metres. |
| `isBackfill` | `boolean` (optional) | Uses excavated material when true. |
| `backfillFrom` | `BackfillStratumSnapshot` (optional) | Selected source stratum for from-the-dig fill. See [backfillFrom on reinstatement layers](#backfillfrom-on-reinstatement-layers). |
| Other `PitCalculatorLayer` fields | — | `name`, `fill`, `nonBulk` apply as on bedding and concrete base. |

### Legacy `backfill` compatibility

A config stored before the list existed still carries a top-level `backfill` entry. The engine migrates it at compute time into the single layer `pit-backfill`, using the same layer id to keep row keys stable. Configs are never rewritten at rest, so old-shape pits continue to round-trip as stored.

The Activities service accepts `backfillFrom` on the legacy `backfill` entry for the same reason: refusing it would return `422` on a calculator's next save when the engine still honours the key there.

### `backfillFrom` on reinstatement layers

`backfillFrom` is accepted on any entry in `pit.layers[]`, in addition to `bedding` and `concreteBase`. The field identifies which excavated stratum a from-the-dig layer draws from.

The Activities service validates `backfillFrom` on write. Two failure codes apply:

| Code | Meaning |
| - | - |
| `calculator_config_backfill_from_malformed` | The value is not `{ stratumId, name }` with both fields non-blank strings, or carries extra members. |
| `calculator_config_backfill_from_misplaced` | The key appears on a shape that carries no `isBackfill` (for example, a stratum, a pipe, or the calculator itself). |

Both return `422 Unprocessable Entity` and 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 Estimate API enforces an additional eligibility rule on write:

| Code | HTTP status | Meaning |
| - | - | - |
| `calculator_config_backfill_from_ineligible` | `400` | A from-the-dig layer (`isBackfill: true`) names a stratum whose frozen substance snapshot carries `canUseForReinstatement: false`. |

The rule 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 regardless of compaction. The UI advises on those strata; the save gate does not block them.

Every pit create or update that carries a config also runs the shared save gate, `assertCalculatorSaveReady`. It refuses an incomplete config with `422 calculator_incomplete` (for example, a stratum with material left and no substance or disposal destination, a reinstatement layer with depth and no material, a from-the-dig layer whose stratum is explicitly ineligible even on update, or a non-bulk unit a pit cannot measure), and a config it cannot read with `422 calculator_config_unreadable`. See [Save validation](https://help.stralos.app/internal/trench-calculator.md#save-validation) for the full rules.

### Picker and generated-row labels

For a from-the-dig reinstatement layer, the material picker presents one choice per eligible excavation stratum as **Excavation — \<stratum name>** (each stratum is checked with `isStratumReinstatementEligible`; ineligible strata are omitted). When the pit has exactly one excavation stratum, normalisation resolves that source and the generic **From the dig** choice is not shown.

Generated rows use **Excavation — \<stratum name> (\<calculation>)** when the source is known. With two or more strata and no `backfillFrom` selection, the row uses **Excavation (\<calculation>)** and the engine emits `fill-layer-stratum-unspecified`. Saving a resolved label updates the existing linked row in place, preserving its row id, generated key, quantity, and estimator-entered rate.

A reinstatement stack shorter than the pit leaves a gap at the surface; the engine does not fill it implicitly. A stack taller than the pit produces an `overfill` warning.

## Bedding and concrete base (`PitCalculatorLayer`, `PitConcreteBase`)

> [!NOTE]
>
> `bedding` and `concreteBase` are deprecated since calculators 1.7.0. calculator-ui 0.4.0 writes `null` to both and drops any stored value on open. Use a reinstatement layer for the material that sits below the structure, and set `PitStructure.position` to lift the structure to the right height. The engine still prices a stored bedding or concrete base for backwards compatibility; other consumers may still read them.

Both layers are optional in the product, represented by a layer object or `null`. Turning a layer off keeps its draft values only for the current modal session.

| Field | Type | Notes |
| - | - | - |
| `name` | `string` (optional) | Display label resolved through `fillLayerLabel`. |
| `depth` | `number` | Layer depth in metres. |
| `fill` | `FillMaterialSnapshot` (optional) | Frozen bought-material density range and compaction. Stripped when `nonBulk` is present. |
| `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. Valid on `bedding`, `concreteBase`, and `layers[*]`; also accepted on the legacy `backfill` entry. |
| `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). |
| `scope` | `"pit" \| "structures"` (optional) | Concrete-base only. The default covers the whole pit; `structures` pours beneath each structure footprint. |

Bedding always covers the full plan. When a stored concrete base and an overcut height are both active, the engine expects bedding at least as deep as the overcut band and otherwise raises `overcut-needs-bedding`. The modal never shows this state, because it drops a stored bedding and concrete base on open.

## Geometry

### Plan area with batter

The pit plan at the surface is entered:

```
L0 = calculator.lengthM
W0 = calculator.widthM
```

Both axes widen by the same per-side offset, so the plan area at depth `z` is

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

The stratum profile helpers are the trench's, shared unchanged. They expect an `overcutWidth` that is a total effective width, so each stratum is converted **once, at the call boundary**, by `batterOvercutWidth`:

```
overcutWidth = W0 + 2 × (batterPerSideMm / 1000)
```

The per-side offset at depth `z` is recovered as `off(z) = (w(z) − W0) / 2`, which returns exactly `batterPerSideMm / 1000` at an angled stratum's top. Converting anywhere else as well is how the same value ends up meaning two things.

`A(z)` is **quadratic and convex** in `z` inside an angled segment, so a trapezoid over it lies above the curve and over-counts — in the customer's disfavour. `pitVolumeBetween` therefore integrates each segment with Simpson's rule, which is exact for a quadratic:

```
V(segment, a, b) = ((b − a) / 6) × ( A(a) + 4·A((a+b)/2) + A(b) )
```

A square-cut stratum has a constant offset and Simpson returns its prism exactly; an unbattered stratum returns `L0 × W0 × depth`.

> [!NOTE]
>
> The classical frustum formula `h/3·(A₁ + A₂ + √(A₁A₂))` does **not** apply. It assumes similar faces, and 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` — a square fixture would pass a wrong implementation.

### Two shoulder rules

The profile is discontinuous where a square cut sits over a narrower stratum, so `A(z)` is two-valued exactly at the boundary. The two rules answer different questions and must not be merged:

1. **Point queries** — `pitAreaAt(z)`, used by the pad, the bedding and the fit checks. The **preceding (upper) segment owns the boundary**, matching the shipped lookup, so a caller asking how wide the pit is at 1.2 m gets one answer, deterministically.
2. **Integration** — `pitVolumeBetween` is **strictly segment-local**. Every segment integrates using its own width at its own endpoints, including at a shared boundary, where the upper segment uses its bottom width and the lower segment uses its top width — different values, both correct.

Feeding a point-query answer into an integration endpoint integrates the upper segment's width across the lower segment's top, silently transferring volume between strata, and so between disposal rows, which are priced separately. The two rules cannot conflict, because a discontinuity has zero width and contributes nothing to any integral.

### Three depth rules

Inherited from the shipped trench, in `resolveStrataDepths`:

1. **The last stratum is the derived base remainder** — its depth is `pitDepth − Σ(other strata depths)`, floored at 0. Its batter and overcut are applied like any other stratum.
2. **Non-base strata are clamped to the pit.** A stratum, or the part of one, sitting below the floor is never excavated and never disposed of. **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 an unbattered plan widened by the calculator-level overcut. This is a separate code path from the base remainder, and it is what makes preserving an explicit empty strata list safe — a user who deletes every stratum still gets the full excavation, with no stratum-specific disposal rows.

### Floor order and reinstatement

The floor order is **bedding → concrete base → structures**. The reinstatement layers sit above the stack base (the top of the pad, or of the bedding, or the design floor) and fill upward. Each layer's volume is the band its typed depth occupies, clamped at the surface. The lowest layer also absorbs any height-overcut band below the design floor.

```text theme={null}
bedding volume          = pit area at bedding depth × bedding depth
concrete volume         = selected pad area × concrete depth
structure volume        = sum of structure solids
reinstatement volume    = sum of layer band volumes (each layer's typed depth, clamped at surface)
```

A whole-pit concrete base uses the pit footprint at its elevation. A structure-scoped base sums the individual structure footprints, so its total may be larger than the pit footprint; the fit warning reports that condition.

A stack shorter than the pit leaves the top open — no warning, just an unfilled gap. A stack taller than the pit produces an `overfill` warning; the excess is not priced.

Each layer's volume is a physical result. A bought-fill snapshot supplies a density range and compaction; a from-the-dig selection nets against the named excavation stratum when destination-aware partitioning is active.

The pre-1.6.0 backfill residual (`excavation − displacement`, clamped at zero) is replaced by the sum of all layer band volumes. A migrated pit's one layer fills exactly the same space the residual did, so no quantity moves on migration.

## Fit warnings (`PitWarning`)

Fit checking replaces auto-sizing. **None of these blocks a save** — a hole too small for its contents is the estimator's judgement to override, not the calculator's to refuse. Warnings are collected into the advisory panel below the modal body.

| `kind` | Payload | Raised when |
| - | - | - |
| `structure-depth` | `structureId`, `requiredM`, `availableM` | A structure needs more depth than the available stack. `requiredM` is the structure's height plus its `position.distanceM` (zero when absent); `availableM` is `pitDepth − beddingDepth − padDepth`. For a floor-referenced structure, `requiredM − availableM` is how far it stands above the surface. See [Advisory panel](#advisory-panel) for how the modal shows it. |
| `combined-footprint` | `footprintAreaM2`, `availableAreaM2` | `Σ footprint(structure) > pitAreaAt(available)`. |
| `backfill-clamped` | `shortfallM3` | `displacement >= excavation` and `displacement > 0` — the structures and layers already fill or exceed the excavation, leaving no room for any reinstatement layer. |
| `overfill` | `overfillM` | The reinstatement layers' typed depths rise above the surface. `overfillM` is how much above the surface, in metres. Nothing above the surface is priced. |
| `strata-exceed-depth` | `strataDepthM`, `pitDepthM` | The raw stratum depths run deeper than the pit. Volumes use the clamped depths, so nothing below the floor was excavated or disposed of. |
| `overcut-needs-bedding` | `bandVolumeM3`, `concreteBaseScope`, `beddingDepthM`, `requiredDepthM` | A concrete base is present, a height overcut is set, and the bedding is absent or shallower than the overcut height. See [Overcut](#overcut) for the full payload shape. |
| `fill-layer-stratum-unspecified` | `layerId` | A fill entry uses `isBackfill: true` with no `backfillFrom`, in a calculator that has two or more excavation strata. The advisory message is exported as `FILL_LAYER_STRATUM_UNSPECIFIED_MESSAGE`. Moves no quantity. |

The combined check is a **sum of areas against the pad area, not a packing test**. Three structures whose areas sum to less than the pad can still be impossible to lay out, and this passes them. It catches the gross case and nothing subtler, which is why it warns rather than blocks — a false sense of a fit guarantee would be worse than no check.

`backfill-clamped` uses `>=`, not `>`: structures and layers that exactly fill the pit leave no room for reinstatement, which is the same condition to tell the estimator as overflowing. The `displacement > 0` guard keeps a degenerate empty pit from warning about a `0 = 0` it has no opinion on.

## Normalisation

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

1. `count` resolves to a positive number, defaulting to `1`.
2. Structure ids are minted only where absent or duplicated. Reordering the list changes nothing. Each structure's `position` is normalised: `{ from: "floor", distanceM: 0 }` is stripped (absent is the canonical spelling for "on the floor"), as is any `position` whose `from` is not `"floor"` or `"surface"`.
3. Empty bedding or concrete-base objects become `null`; an absent concrete-base scope becomes `"pit"`.
4. The strata are normalised before the fill entries, because the R3 re-point (step 5) reads them.
5. **Reinstatement migration (calculators 1.6.0):** A config with no `layers` key has its `backfill` entry moved into `layers` as one `PitReinstatementLayer` with id `pit-backfill` and a typed depth equal to the stack base, so it fills exactly the space the pre-1.6.0 residual filled and prices to the same double. A config with neither becomes `layers: []`. The `backfill` key is deleted either way.
6. **R3 re-point:** When a reinstatement layer (including the migrated one) uses `isBackfill: true` with no `backfillFrom`, and the calculator has exactly one excavation stratum, normalisation sets `backfillFrom` to that stratum. With zero strata or two or more, the layer is left as-is and the `fill-layer-stratum-unspecified` advisory fires at row-build time.
7. Layer ids are made unique; an absent or duplicate id is re-minted, except `pit-backfill` which is always preserved so existing links survive.
8. The overcut base is resolved before the strata. Legacy row widths migrate without changing quantity; absent means inherit and `0` means dug neat.
9. 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.

This normalisation is an in-memory compute step. The Estimate API stores an old-shape config as sent, so the migration runs again on each read until the calculator is explicitly saved in the new shape.

## Defaults

Exported from `stralos-domain/packages/calculators/src/defaults.ts` so the shape of a valid config stays the domain's business:

| Constant | Value |
| - | - |
| `CALCULATOR_DEFAULT_STRATUM_DEPTH_M` | `0.2` |
| `CALCULATOR_DEFAULT_FILL_LAYER_DEPTH_M` | `0.1` |
| `PIT_DEFAULT_STRUCTURE_DIMENSIONS` | `{ lengthM: 0.675, widthM: 0.675, diameterM: 1.05, heightM: 0.9 }` |

`createDefaultPitCalculator` opens on a 2.5 × 2 × 2 m pit holding one vertical Ø1.05 m chamber 1.5 m tall, with `bedding` and `concreteBase` set to `null`, and one reinstatement layer with no material whose depth equals the pit depth. That layer fills the hole exactly as the old derived backfill did; choosing a material or splitting into multiple layers is up to the estimator. `createDefaultPitStructure` mints a box, the shape whose dimensions are all directly entered.

`createDefaultReinstatementLayer(strata)` — used by all three calculator arms when an **Add layer** button is pressed — names a sole stratum on the new layer's `backfillFrom` only when that stratum is eligible for reinstatement. When the calculator's only stratum has `canUseForReinstatement === false` (or derives ineligible through the legacy rule), the fresh layer opens with no material set instead. Two or more strata leave the choice to the estimator regardless of eligibility.

`PIT_BACKFILL_LABEL` (`"Backfill"`) is deprecated since calculators 1.6.0. The engine no longer reads it; unnamed reinstatement layers are now labelled by their position in the stack (for example, **Reinstatement layer 1**), matching the trench and volume calculators.

## In the modal

`PitCalculatorModal` uses a two-pane layout: editable geometry and layers on the left, with the section preview and material-balance ledger on the right. At narrow widths the panes stack in reading order.

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

Neither toggle changes the stored calculator or its computed totals.

### Overcut and per-row overcut column

The calculator-level **Overcut side** and **Overcut depth** fields, which set the base overcut width per side and the height below the design floor, are visible only in advanced mode. A stratum can override the width; an explicit zero means neat excavation.

Each excavation stratum row has an **Overcut** column (in mm) that is visible in both modes. The value is the **additional** overcut width per side that this stratum adds on top of the stratum below it — the compound overcut model. Changing the base overcut, depth, or stratum order preserves each row's increment.

Each excavation stratum has a substance picker and, when the host supplies disposal rates, a **Disposal material** picker. **Leave Onsite** retains material. A named disposal item records an explicit removed destination. Both the estimate host and the activities host supply the Disposal rate group to pit calculators.

Each reinstatement layer uses a single material picker for bulk bought fill, a named excavation stratum, or a non-bulk rate item. Both hosts supply the non-bulk catalogue to the pit editor. A pit layer states neither a length nor a top-face area, so the picker offers only non-bulk items priced by volume (`inSituM3`) or by a unit that is not geometric (`count`, for example `each` or `lot`). Only strata eligible for reinstatement appear in the **From excavation** list; strata whose substance has `canUseForReinstatement === false` are omitted. When two or more excavation strata exist, a from-the-dig layer with no stratum selected shows the advisory **Choose which excavation layer this reinstatement layer comes from.** 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 calculator shows physical output chains; 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, strata and the entries from `layers[]` show **Used by** controls. The modal has no bedding or concrete-base rows, so it offers no `pit-bedding` or `pit-concrete-base` control. The engine still emits those rows for a legacy stored bedding or pad that has not been reopened and saved. For configs that still carry a legacy `backfill` entry, the migrated `pit-backfill` row is shown. The bulk excavation row (`pit-excavation`) is not offered in the assignment UI: it is excluded from the layer catalogue and from the measurement catalogue's per-layer readings.

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.

A new stratum is added at the top while the base remainder stays numbered 1. The excavation name column is headed **Name (optional)**. The Name box opens empty; the generated name (for example, **Existing layer 2**) appears as its placeholder and is written to the config on save if the estimator leaves the box blank. The quantity control is hidden for new calculators; a legacy count above one remains effective and is shown as a read-only note.

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.

* A fit warning fires (structure depth, combined footprints, backfill clamped, or strata exceed depth). The engine's `overcut-needs-bedding` warning needs a stored concrete base, which the modal drops on open.
* An excavation stratum has no substance snapshot, so loose spoil volume and weight cannot be stated for it.

Each message names the affected section (for example, "Structures — a structure needs 1.50 m; 0.90 m is available") so the estimator knows where to look.

A `structure-depth` warning for a floor-referenced structure means it stands above the ground (`requiredM − availableM` is the height above). From calculator-ui 0.10.2 this is a warning, not information: the structure's row in **Structures** shows it in the same orange as a structure that goes through the floor ("… stands 0.30 m above ground — check this is intended. Its full volume is counted."), and the advisory reads "Structures — a structure stands 0.30 m above ground — check this is intended (counted in full)". Earlier versions showed it as a grey note. Nothing blocks saving, the full volume is still counted, and no quantity changes. A surface-hung structure that reaches past the floor keeps the "reaches … where it is placed" wording.

### 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 type-prefixed keys, layer ids, layer kinds, display names, and the outputs that exist for each row. Pit outputs include the bulk excavation total, per-stratum disposal quantities, and one row per reinstatement layer, plus legacy bedding and concrete-base rows when a stored config still carries them. Structures affect geometry and warnings but do not emit rows.

Reinstatement layer rows use the key `pit-layer-<layerId>` and carry the layer's own id as `layerId`. A pit migrated from the pre-1.6.0 shape keeps the id `pit-backfill` on its first layer, so its row appears under `pit-layer-pit-backfill` and every existing link remains valid. Unnamed layers are labelled by position — for example, **Reinstatement layer 1**.

From-the-dig reinstatement rows are named **Excavation — \<stratum>** when the stratum is unambiguous, and **Excavation** when no stratum is specified with two or more strata. Estimator-entered names on priced rows remain unchanged by a save.

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

### Bulk dig exclusion

The `pit-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:pit-excavation:*`) are also excluded. Overall calculator readings (`m:o:*`) remain available for assignment.

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

From calculators 1.17.0, an estimate pit's `lengthM` can be linked to takeoff through `inputLinks` as the `pit.lengthM` field, a measurement total or tag subtotals in metres. The resolved value is written into `config.lengthM`. See [Calculator input links](https://help.stralos.app/internal/calculator-input-links.md).

Disposal material validation and disposal row binding apply to pit 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` multiplies each physical output before the single two-decimal rounding step.

Bought fill carries compacted and loose cubic metres plus a minimum and maximum tonnage derived from its frozen density range. A single `tonnes` output is present only when both density bounds agree. Disposal outputs derive loose volume and tonnes from the stratum's substance snapshot. Pricing conversion is applied later by the assigned work-item or activity-component line.

### Measurement values are per-unit figures

Measurement rows (layer ids beginning `m:o:` or `m:l:`) always state the **per-unit figure**, rounded once from the unscaled quantity. Physical rows are scaled by `count` as before. On a calculator with `count > 1`, a layer measurement and that layer's own physical row carry different values. For example, with `count: 5`, `pit-excavation` states five pits' total excavation, while `m:l:pit-excavation:inSituM3` states one pit's excavation.

Dividing a rounded scaled figure back down to recover the per-unit value is a rounding error: on a non-terminating per-unit volume the divide inflates the figure by the rounding residual multiplied by `count`. Measurement rows are built from the unscaled outputs and rounded once.

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

A pit has no SOQ-sourced input, so `PitCalculatorArm` carries **no `needsReview` field**. Giving it one would create a flag that can be set and never cleared. Staleness applies to the [Volume calculator](https://help.stralos.app/internal/volume-calculator.md) alone.


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