Skip to main content
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.
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: 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. 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. 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: 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: 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:
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 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: 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 for the rule, which is shared by all three arms.

Reinstatement layers (CalculatorReinstatementLayer)

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

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