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. UnderareaSource: "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
0means 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
overcutkey is present): rows are overrides; value-less rows inherit the base. - Legacy config (the
overcutkey is absent): base = maxoverdigPerSideMmacross excavation strata; value-less rows pinned at0. No stored quantity moves.
overdigPerSideMm is untouched by this migration.
Disposal substance outputs
WhenCalculatorExcavationStratum.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
Inlwd 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
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:
resolveStrataDepths:
- 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. - Non-base strata are clamped to the excavation. Raw effective depths drive the UI and the “exceeds” warning; clamped depths drive volume and cut.
- 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:
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: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:
countresolves to a positive number, defaulting to1. The field is legacy; see Quantities, units and density.- The overcut base is resolved before strata are processed. Legacy row widths migrate without changing quantity; fill-layer widths remain independent.
- An absent
existingLayerslist 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. - Fill-layer ids and physical material snapshots are normalised. Reordering does not re-mint stable ids, and dormant batter values survive mode switches.
needsReviewis stored only when true.- The output is rebuilt as either
lwdorareaDepth;areaSourceQuantityis 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 carriesallocations), 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 stampedneedsReview 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:
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.
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.
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.
Generated rows
The shared engine emits physical output rows with stablevol- 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
Thevol-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’sdecided_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 embeddedcalculator 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.
