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

Types

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

Narrowing a stored config

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

Calculator shape

TrenchCalculator carries:

CalculatorOvercut

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

Deprecated calculator fields

Existing layers (TrenchCalculatorExistingLayer)

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

Deprecated stratum fields

Reinstatement layers (TrenchCalculatorLayer)

FillMaterialSnapshot

FillMaterialSnapshot is the physics frozen onto a bought fill layer when a material is selected or a settings refresh is accepted. It is the fill-side mirror of DisposalSubstanceSnapshot. densityMin and densityMax replace the single density field removed in D73b. When a bought-fill item has multiple suppliers with different conversion factors, no single density is correct: the lightest under-orders, the heaviest over-orders, and the mean is a figure no supplier will honour. The calculator therefore states the tonnage as a range and leaves the final choice to the work item line, where the rate option’s own conversion factor applies. A fill layer states tonnesMin and tonnesMax in its outputs. tonnes is present only when densityMin === densityMax — that is, when the bounds agree and a single figure is unambiguous. Register links on tonnes for a ranged layer are refused by the existing calculator_link_unknown_output rule. tonnesMin and tonnesMax are display-only outputs. They are not valid register link targets: a work item takes the m³ quantity, and the rate option’s conversion factor handles the t/m³ arithmetic on the line.

NonBulkMaterialSnapshot

NonBulkMaterialSnapshot records the rate item chosen as a non-bulk material for a reinstatement layer. It is a snapshot — the item’s name and unit are frozen at selection time and are not re-derived from the rates catalogue. unit decides which layer output the engine prices by, through pricedOutputKeyForUnit: The four whole-unit stock units (length, roll_length, roll_area, sheet) are for items bought by the piece that each cover a fixed length or area. The row is measured in the output’s unit (metres or m²) like any other non-bulk layer. The stock unit is only the priced unit: orderedQuantityForPricedUnit (in @stralos-co-nz/domain) converts the measured quantity into whole pieces, rounding up per run. For example, 120 m² of geotextile at 50 m² per roll orders ⌈120 / 50⌉ = 3 rolls. The rate option’s conversion factor records how much of the covered unit one piece provides. Only the catalogue codes are recognised: a bare “roll” is ambiguous between the two roll codes and stays count. Before calculators 1.13.1, these four units fell through to count and priced one piece regardless of the layer’s size. unit is not validated against the catalogue on save. It is a snapshot of RateSummary.modeUnit, which the server cannot re-derive. An unrecognised or empty unit defaults to count and prices 1. pricedOutputKeyForUnit first matches exact catalogue codes and common space-separated aliases after case-folding and whitespace normalisation. A second pass collapses runs of spaces, underscores, or hyphens before looking up the token again. This resolves forms such as sq_m, sq-m, cu_m, cu-m, lineal_metre, lineal-metre, linear_metre, square-metre, and cubic-metre to their intended output rather than falling through to count. Whole-unit stock codes (length, roll_length, roll_area, sheet) are matched as exact catalogue codes in the first pass. Exact catalogue codes are matched first. A token that remains unrecognised after both passes falls back to count rather than raising an error. A non-bulk layer is mutually exclusive with the three other material statements. When nonBulk is present:
  • fill is stripped by normalisation.
  • backfillFrom is stripped by normalisation.
  • isBackfill is forced to false by normalisation.
The engine applies the same precedence rule even when reading a stored config that has not been normalised: nonBulk wins, and the layer is treated as bought-in reinstatement. It still occupies depth and displaces volume exactly as any other fill layer; only the priced output differs. A non-bulk layer emits exactly one output: the priced output for its unit. It never states tonnes, looseM3, tonnesMin, or tonnesMax because a non-bulk item has no density. storedNonBulkMaterial(layer) (exported from @stralos-co-nz/calculators) normalises the snapshot to exactly { id, name, unit } before storage. The Estimate API accepts optional categoryId and category fields beside the engine’s { id, name, unit } snapshot. On calculator save it reads GET /api/rates/rate-items/:id, requires the item to remain active, and refreshes its name and category. It does not re-check the rate group because the picker is limited to the Materials and Subcontractors groups at load time. The unit remains a client-supplied snapshot because that rate-item endpoint does not return it; rateGroup and status are not stored in the calculator config. Changing the non-bulk item can change the layer’s sole output key. For a server-derived register, the Estimate API re-points a stale link to the new sole output. A client-supplied link that names an output the layer does not expose remains a calculator_link_unknown_output error. This repair is limited to non-bulk transitions so the service does not choose among multiple outputs on unrelated layers.

Legacy normalisation: StoredFillMaterialSnapshot

StoredFillMaterialSnapshot is the type accepted by normalizeFillMaterialSnapshot. It extends the canonical shape with the legacy read-only field: The distinction between FillMaterialSnapshot and StoredFillMaterialSnapshot is deliberate. The canonical type is what the engine reads and what a client writes. The stored type is the normalisation entry point only, so the legacy field cannot be read outside normalizeFillMaterialSnapshot.

Layer labelling (fillLayerLabel)

Because name is optional, every site that needs to display a reinstatement layer’s name must call fillLayerLabel(layer, fallback) rather than reading layer.name directly. The function applies D74’s fallback order:
  1. The typed name (trimmed) — if the estimator entered one.
  2. "From the dig" — if isBackfill is true. This step comes before the material step: when isBackfill is set the engine ignores fill, so naming the layer after a dormant snapshot would describe material the layer is not made of.
  3. The non-bulk item’s name (nonBulk.name, trimmed) — if a non-bulk snapshot is present and named.
  4. The fill material’s name (fill.name, trimmed) — if a fill material snapshot is present and named.
  5. The caller’s last resort — a number renders as Reinstatement layer N (1-based); a string is used as a fixed slot label.
storedFillLayerName(name) is the normalisation half: it trims and returns undefined for an empty or whitespace-only string, so there is one representation of “unnamed” in storage. The normaliser never seeds a name onto a fill layer. An empty Name (optional) field opens with the fallback as its placeholder, which is the honest rendering of “this layer has no name yet”.

Fill material source

A reinstatement layer has three mutually exclusive material choices: filled from the dig (isBackfill), a bulk material from the fill-material picker (fill), or a non-bulk rate item (nonBulk). Exactly one applies at a time; the normaliser enforces this by stripping whichever of the other two is present when the active choice is saved. A reinstatement layer that is not backfilled from the dig uses the Material picker. Besides named excavation strata, the picker can list:
  • Bulk materials — rate items whose category has parentGroup = quarry_materials, shown under Bulk Materials in Settings → Resource categories. A bulk item is offered when api-rates can derive at least one positive density from its priced options; items without a readable conversion factor are omitted.
  • Non-bulk materials: rate items from the Materials (other_materials) and Subcontractors (subcontractors) rate groups. These items have no density physics, so the layer is priced by the rate item’s own unit. This group appears only when the host supplies nonBulkMaterials; the estimate host and the activities host both supply it to trench, pit, and volume calculators. The pit and volume pickers list only items whose unit the arm can measure (see Non-bulk units by arm). Items from all other rate groups, including plant, equipment, and unclassified, are excluded.
Neither list has a separate settings catalogue. Items are displayed in the library’s own order: sorted by category, then name, then id. For bulk materials, the API returns the lightest and heaviest readable density values as densityTPerM3Min and densityTPerM3Max; a single value is represented by equal bounds. Bulk state is loaded through useFillMaterials and passed as fillMaterials; non-bulk state is loaded separately through useNonBulkMaterials and passed as nonBulkMaterials. The host maps bulk API bounds to the modal’s densityMin and densityMax fields. A load failure produces an error state without changing stored snapshots.

Reinstatement eligibility

DisposalSubstanceSnapshot.canUseForReinstatement controls whether a stratum’s material may be used for reinstatement. It is an optional boolean because stored snapshots predate the field. isSubstanceReinstatementEligible(substance) and isStratumReinstatementEligible(stratum) (exported from @stralos-co-nz/calculators) determine eligibility by these three rules:
  • No substance — a stratum nobody has classified is eligible. It is the pre-substance calculator state, not an unchecked item.
  • Explicit flag — when canUseForReinstatement is a boolean, that value wins regardless of compactionPct.
  • Legacy snapshot (no flag) — eligible when compactionPct is a number (including 0). A snapshot missing the compactionPct key entirely reads as ineligible, matching the disposal physics treatment of a missing key as N/A.
The Material picker checks each stratum with isStratumReinstatementEligible and lists only the eligible ones, in their stored order, under From excavation for a from-the-dig reinstatement layer. The APIs’ save gate is narrower than the eligibility rule. Save validation in api-estimate and api-activities refuses only an explicit canUseForReinstatement === false in the frozen snapshot. A legacy snapshot without the flag is accepted on save even when the eligibility rule calls it ineligible, so an existing estimate whose stratum has compactionPct: null is not broken on an unchanged re-save. When a stored layer names a stratum that has become ineligible, fillLayerStratumIneligible(layer, strata) returns true and the UI shows the advisory message FILL_LAYER_STRATUM_INELIGIBLE_MESSAGE: “This excavated material can no longer be used for reinstatement. Choose another material for this layer.” The advisory moves no quantity. Saving is blocked only when the named stratum’s snapshot carries canUseForReinstatement: false; see Save validation.

BackfillStratumSnapshot

BackfillStratumSnapshot records which excavated stratum a from-the-dig reinstatement layer is filled from: backfillFrom is stripped by the normaliser whenever isBackfill is not true. A bought-in layer never carries a stratum reference, and a dormant reference beside a live fill snapshot is an ambiguous second representation of the layer’s material. This stripping rule is applied by storedBackfillFrom, exported from the package barrel, and called by all four normalisers that handle a layer carrying these fields. fillLayerLabel reads backfillFrom.name when isBackfill is true and produces the label “From the dig — <name>”. The function is called with the typed name first, so a layer the estimator has named explicitly is not relabelled. fillLayerFromTheDigLabel(stratumName?) — also exported from the package barrel — produces this label independently, and is intended for the UI’s per-stratum dropdown rows so each row reads identically to the label the layer would carry after the choice is made. When no stratum declares a disposition, from-the-dig volume is drawn pro rata from the excavation, preserving legacy results. Once a stratum declares a destination, backfillFrom selects the source stratum and changes the destination-aware partition quantities.

Estimate API write-side validation

The Estimate API validates backfillFrom on every calculator save. Three failure codes apply: All three name the offending path. A well-formed reference beside isBackfill: false is stored as sent; the engine’s normaliser strips it at compute time. The eligibility check (calculator_config_backfill_from_ineligible) applies only to a fresh pick. On update (PUT /calculators/:id), a stratum the stored config already draws from is exempt from this 400, but the shared save gate below still refuses a layer that names a stratum whose snapshot carries canUseForReinstatement: false. A stratum with no substance, or whose snapshot does not carry the canUseForReinstatement flag, is accepted whatever its compaction. Every trench, pit, and volume create or update that carries a config also runs assertCalculatorSaveReady, which applies calculatorSaveIssues:

Supply quantity and compaction

A bought fill layer stores its physical material snapshot with densityMin, densityMax, and compactionPct. The engine converts its compacted in-place volume to loose volume by dividing by (1 - compactionPct / 100), then derives the low and high tonnage bounds from the two densities. Absent or zero compaction leaves the loose volume equal to the in-place volume. Pricing and supplier conversion remain on the consuming destination line.

Deprecated fields

Pipes (TrenchCalculatorPipe)

Pipes are geometry inputs. Their combined cross-sectional area is deducted from the base reinstatement layer only, clamped at zero. Other reinstatement layers keep their full prism volume. Each pipe is also reported as its own physical-output row.
The field stores the outside diameter in millimetres, not the nominal (DN) size. Nominal sizes differ from actual outside diameters by pipe material and standard: concrete DN300 is 367 mm, PVC-U DN300 is 315.4 mm, and PE DN315 is 315 mm.

Measurements

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

Overall measurements offered by the trench arm

The m:o:pipes3, m:o:pipes5_8, and m:o:pipes6 stock-length counts are no longer emitted. The stock length belongs to the pipe’s rate option (priced in length, with metres per length), and pricing rounds up per run. See Pipe runs and whole lengths. The Depth reading (m:o:depthM) states the design floor depth, not cutDepth. The design floor is what the estimator entered and what bedding, pad, and fit checks are measured against. Over-break below it is already priced through the excavation volume. The trench has no count field — its length is geometry. Every measurement is therefore already at the figure a work item should price by; no further scaling is applied.

Per-layer measurements

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

Overcut

TrenchCalculator.overcut is a CalculatorOvercut — the same type used by the pit and volume calculators. TrenchOvercut is a kept alias for backward compatibility. Both members are optional with no defaults. An absent overcut key means the config is in the legacy state — see migration below.

Per-row width override

overcut.widthPerSideMm is the base for the whole leg. Each stratum can override it with TrenchCalculatorExistingLayer.overdigPerSideMm:
  • A present value replaces the base for that stratum.
  • Absent means inherit.
  • A stored 0 means neat.

Legacy migration

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

Normalisation

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

Fill material option (FillMaterialOption)

The host supplies bought fill materials as FillMaterialsState. Each ready item carries id, name, densityMin, densityMax, compactionPct, ordering, and status. Both density bounds are required for bulk items; differing suppliers produce a range rather than an unusable item. Non-bulk items (rate items outside the quarry_materials group) carry no density. Selecting a non-bulk material records the rate item identity on the layer; the layer is priced by the rate item’s own unit rather than by looseM3. The engine does not derive tonnage or density for non-bulk layers. Selecting a bulk material freezes densityMin, densityMax, and compaction into the layer’s FillMaterialSnapshot. A legacy single density value normalises to equal minimum and maximum bounds. Loading or error states leave the calculator operable and preserve any stored snapshot. A bulk material without a readable positive density is not offered.

Fill material tonnage

The modal’s Material t tile shows the total bought fill tonnage. The value comes directly from the engine’s layerMaterialTonnesPerMetreMin and layerMaterialTonnesPerMetreMax outputs, scaled by the trench length. The UI does not multiply any volume by a density. A bought fill material’s density is a range, not a single figure. One Bulk Materials item can be priced by several suppliers with different conversion factors, so collapsing to a single density is always wrong: the lowest under-orders, the highest over-orders, and no supplier will honour a mean. The engine carries both ends, and the tile shows the tonnage as a range — for example 27.2–27.7 t. When all of a material’s options agree (or it has a single supplier), both ends are equal and the tile shows one figure. The supplier is chosen on the work-item line, which converts the m³ quantity through its own rate option. The calculator works in m³ throughout; the tonnage range is informational only. formatCalculatorRange (calculatorModalShared.tsx) formats the two ends. It compares the formatted strings, not the raw numbers: two ends that differ only beyond display precision are shown as one figure, because showing 27.4–27.4 t would read as a defect rather than as a degenerate range. The separator is an en dash, not a hyphen.

Disposal substance physics

An excavated stratum’s DisposalSubstanceSnapshot freezes id, name, looseDensity, bulkingPct, nullable compactionPct, optional canUseForReinstatement, and resolvedAt. The host passes the catalogue’s eligibility flag to calculator-ui; when the flag is absent, the UI derives eligibility from compaction and freezes the result on selection. Eligibility checks use this stored snapshot, not an unaccepted live catalogue change. The engine derives:
Without a substance snapshot, the layer still states its in-situ volume but omits loose volume and tonnage because those values cannot be derived honestly. The disposal material identifies the rate item used by the destination line; it is not the physics source.

Ineligible reinstatement refusal

A config write (POST /calculators or PUT /calculators/:id) returns 400 calculator_config_backfill_from_ineligible when a reinstatement layer’s backfillFrom stratum references an excavated substance whose canUseForReinstatement flag is false. A stratum the stored config already drew from is exempt on a PUT (only a fresh pick is refused). The calculator editor intercepts this specific code in describeConfigSaveError (src/calculatorsApi.ts) and rewrites the error message shown to the estimator:
A reinstatement layer is filled from excavated material that can no longer be used for reinstatement — choose another material for it.
The server’s own sentence (which begins with a config path such as layers.1.backfillFrom: …) is suppressed entirely because a raw config path is not meaningful to an estimator. Every other 400 refusal and all 409 conflict prompts pass through unchanged. The same 400 calculator_config_backfill_from_ineligible code can appear when an activity’s attached calculation is copied during an insert (see Activity-insert copy failure in the estimate frontend docs). A copy is a POST /calculators with no existing config to exempt, so the refusal is permanent on every retry. The copy failure message identifies this case and tells the estimator to choose another material rather than offering a retry.

Host data

Calculator hosts load four independent catalogues:
  • disposal substances from the Global API, including inactive entries needed to render frozen snapshots;
  • fill materials from GET /api/rates/rate-summaries?rateGroup=quarry_materials, retaining both density bounds;
  • active disposal items from GET /api/rates/rate-summaries?rateGroup=disposal;
  • non-bulk materials from every page of GET /api/rates/rate-summaries, kept to the other_materials and subcontractors rate groups by nonBulkMaterialsFrom.
Substance usage is an optional ranking input. A failed usage request falls back to alphabetical choices and never blocks saving. A failed catalogue request leaves the modal open and preserves stored snapshots, but prevents selecting a new item from that catalogue. The estimate host and the activities host each load all four catalogues once per open calculator (useCalculatorCatalogues in each host’s calculatorRegistry.tsx) and supply them to trench, pit, and volume calculators.

Embedded calculator columns (retired)

Embedded calculator configurations have been replaced by standalone calculators and their assignment registers. Estimate migration 0058 guards against unconverted configurations; migration 0059 physically drops the calculator columns on estimate work items and components. Activities migration 0022 drops the embedded activities_activities.calculator column. Neither API stores an embedded calculator supplied on a work-item, component, or activity write. Undeclared keys are stripped, including an explicit null; they do not create a calculator, clear a standalone calculator, or delete its generated rows. Legacy wi: and wic: component keys remain compatible stored identities, but no embedded engine writes or regenerates their rows. Use the Estimate API’s Project calculators or the Activities API’s Library-level calculators for current calculator work.

Project-level calculator disposal reconciliation

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

Disposal material validation

Before the estimate transaction lock is taken, resolveTrenchDisposalMaterials validates each stratum marked disposition: "removed". The validation rules are:
  • Every "removed" stratum must carry both a substance and a disposalMaterial. A stratum missing either returns 422 with a clear message.
  • The disposalMaterial.id is resolved against GET /api/rates/rate-items/:id. An id that returns 404 returns 422; a gateway failure returns 502.
  • The resolved rate item must be active and belong to the disposal rate group. Any other item returns 422.
  • Strata marked disposition: "onsite" are not validated. Their disposal row is removed and no rate item check is made.
  • A stratum with no material left after reinstatement reuse (no excavation row with a positive inSituM3) is not validated either. It needs no disposal rate, and its stored choice is kept for later edits.
On success, the config is updated with the name and category from the Rates API catalogue before storage. This keeps the stored label current with whatever the rates team last saved, rather than whatever the estimator typed.

Net remainder quantities

The engine’s looseM3 and tonnes outputs for a disposal row already have the backfill reuse draw netted off pro rata. A trench that reuses some of its own spoil as backfill reduces the cartaway quantity before it reaches the disposal row. The reconcile writes those net engine outputs directly to the row without any further adjustment. A disposal row’s areaM2 is its share of the stratum’s plan area (sliceAreaShare in allocations.ts). The share is taken over the volume the disposal slices divide, grossInSituM3 − reuseInSituM3, not over the gross, because the reuse is not a slice and owns no footprint. So a stratum with one remaining slice after reuse carries the stratum’s whole plan area, and the slices of a partitioned stratum scale up together. For example, a 10 × 5 m dig 0.6 m deep that reuses 5 m³ disposes of 25 m³ over the full 50 m². Where nothing is reused the denominator is the gross, so nothing changes. For a stratum that is not partitioned, its m:l:<id>:areaM2 measurement restates the same figure. The rule lives in the shared allocation code, so it applies to the trench, pit and volume arms alike.

Disposal row binding rules

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

Measurement rows (calculators 1.4.1)

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

Synthetic layer ID format

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

Seeding

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

Zero rule: removed on save

A measurement link whose reading states nothing, or states a figure that is not a positive number, is removed on the server-driven save that makes it so. Its link is deleted from the register and its build-up row is removed with it. This is distinct from physical layers, which keep their link even when the engine emits no row, because the assignment there records an intent about the ground that survives geometry edits in progress. The zero rule only fires on server-derived registers (config saves, default changes, copy). It does not fire on the estimator’s own PUT /calculators/:id/links. A register supplied by the client is the estimator stating what they want; deleting a newly assigned link inside the 200 that acknowledges it would be silent and unexplained. The link is removed on the next server-derived save. When a measurement link is removed this way, the estimator re-adds it from the Calculations modal. Nothing re-creates a measurement link automatically — seedLinksForDefault skips measurements entirely. Setting a pipe run’s quantity to 0 stops the engine emitting that run’s layer row, which also stops it emitting that run’s per-layer measurement rows (m:l:<id>:*). Those links are pruned by the layer-deleted rule rather than the zero rule; the outcome is the same.

count is not scaled

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

Unit

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

D66 exemption for server-derived registers

The D66 membership check refuses a link whose output the engine no longer states. Measurement rows are emitted even when the arm cannot state a figure (outputs: {}), so a measurement link on a reading that has gone empty would be refused by D66 and block the save. The exemption lets such a link past only when all three of these conditions hold:
  • The register is server-derived (client-supplied registers are still refused).
  • The row is a measurement (isMeasurementRow(row) is true — no physical layer is exempt).
  • The link names the measurement’s own axis (read from the synthetic ID, not from the current value).
This lets the reconcile apply the zero rule and remove the link. A new client-supplied link on a reading that states nothing is still refused (calculator_link_unknown_output).

Server write pipeline

Current calculator writes store geometry and physical inputs on standalone calculator entities: estimate_calculators for an estimate, or activities_calculators for the activities library. Pricing belongs to the consuming work-item or activity-component line; calculator configuration must not contain pricing fields. The hosts validate configuration and assignment links, run the shared engine, and reconcile physical output rows against the calculator’s assignment register. Generated rows use the calc:<calculatorId>:<layerId>[:<ordinal>] namespace. Reconciliation updates the calculator’s own rows while preserving the consuming line’s pricing choices unless its material changes. Estimate mutations reconcile and recompute totals under the estimate lock. Activities mutations reconcile the library calculator’s rows across its destination activities. See Project-level calculator disposal reconciliation above and Library calculator reconciliation for the host-specific rules.

Save validation

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

Dimensions

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

Excavation strata

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

Reinstatement layers

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

Non-bulk units by arm

reinstatementOutputKeys(type) returns the outputs a non-bulk reinstatement layer can be priced by on each arm. nonBulkUnitIsMeasurable(unit, available) maps the unit through pricedOutputKeyForUnit and checks the result against that list. count is for units that are not geometric (for example each or lot). It is never a substitute for a geometric unit the arm cannot measure: a per-metre item on a pit or volume, or a per-m² item on a pit, is not offered in the modal’s picker and is refused on save.

In the modal

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

View mode toggles

The modal opens in simple mode. Two toggle switches appear below the title:
  • Advanced Mode — when off (the default), the calculator-level Overcut side and Overcut depth strip fields, the Excavation Batter controls, and the per-layer result-line output columns are hidden. When on, all controls are visible.
  • Assign to Work items — visible only when the host supplies renderLayerAssignment. When on, each excavation and reinstatement row shows its Used by assignment control. This toggle is independent of Advanced Mode: a user can show assignments without turning on advanced fields, and vice versa.
Neither toggle changes the stored calculator or its computed totals. Hiding a control preserves the value it holds.

Geometry and output rows

The main fields appear in Length, Width, Depth order. Existing strata and reinstatement layers show the engine’s outputs as a result sentence below each row. Missing physics renders as absent rather than zero. Each excavation stratum’s result sentence reads: <inSituM3> m³ in situ · × <bulking factor> bulking · <looseM3> m³ loose spoil · <density> t/m³ · <tonnes> t · <area> m². Fill rows read: <inSituM3> m³ compacted · × <compaction factor> compaction · <looseM3> m³ loose to buy · <density range> t/m³ · <tonnes range> t. A from-the-dig fill row reads: <inSituM3> m³ · loose, counted with its stratum. These state labels are display-only — the engine keys inSituM3 and looseM3 are unchanged. The base remainder’s derived depth is displayed as a read-only figure with a remainder tag. It cannot be edited directly. The Overcut side and Overcut depth fields at the calculator level — which set the base overcut width per side and the height below the design floor — are visible only in advanced mode. The solid diagram line shows the cut profile and the dashed line shows the neat profile. A new excavation stratum is added at the top while numbering continues from the base remainder. The excavation name column is headed Name (optional). A new stratum’s name field opens empty; the generated label (for example, Existing layer 2) appears as a placeholder and is written to storage on save if the estimator does not type a name.

Per-row overcut column (compound model)

Each excavation stratum row has an Overcut column (in mm) that is visible in both simple and advanced mode. The value shown is the additional overcut width per side that this stratum adds on top of the stratum below it — not an absolute width. This is the compound overcut model. For example: if the base width is 600 mm and a lower stratum adds 100 mm (cutting at 700 mm total), an upper stratum with an overcut increment of 50 mm cuts at 750 mm total. The engine stores absolute overdigPerSideMm values on each stratum; the modal translates between increments and absolute widths through excavationOvercutSteps and withExcavationOvercutSteps. Changing the base overcut, the depth, or the stratum order preserves each row’s increment through preserveExcavationOvercutSteps. The calculator title bar shows a summary of the form <L> m long · <W> m × <D> m section · N layers · N pipes · V m³/m. Length comes from the engine’s lengthMetres output. A Check before saving advisory panel appears above Save when any advisory exists, for example when an excavation stratum has no substance. The panel uses role="note" and data-part="calculator-advisories". Advisories do not gate Save; calculatorSaveIssues does, and its messages show as the form error when Save is pressed.

Reinstatement material

Each reinstatement layer has one Material picker. It lists eligible excavation strata under From the dig (each stratum is checked with isStratumReinstatementEligible; ineligible strata are omitted), bulk bought materials under Fill material, and rate items under Non-bulk material. Choosing a stratum stores backfillFrom; choosing bulk fill stores its physics snapshot; choosing a non-bulk item stores a NonBulkMaterialSnapshot and strips the other two choices. Legacy generic backfill remains selectable. When a currently stored layer names a stratum that is now ineligible, the stratum remains visible in the layer row and the advisory “This excavated material can no longer be used for reinstatement. Choose another material for this layer.” appears. Saving is blocked only when the stratum’s snapshot carries canUseForReinstatement: false. A non-bulk layer shows no density, loose-volume, or tonnes tile. Its priced output comes from the item’s unit, and the estimator does not choose an axis. Pipes use their outside diameter in millimetres. They reduce the base reinstatement layer only and are not assignment destinations.

Disposal material

Each excavation stratum has one disposal picker. Leave Onsite stores disposition: "onsite" and clears the material. A named item from the Disposal rate group stores disposition: "removed" and its catalogue identity. An absent disposition is a valid legacy removed state and displays Carted offsite without writing a new value. Any stratum that is not Leave Onsite and still has material left after reuse needs a disposal material before the calculator saves.

Used by

When the host supplies renderLayerAssignment, the Assign to Work items toggle is shown. Turning it on reveals a Used by cell on each excavation and reinstatement row. The callback receives the engine layer id, kind, and display name. No returned control renders Unassigned. Pipes are excluded. Measurement rows (layerKind: "measurement") are never offered to the assignment slot. Starting from calculators 1.4.0, the engine appends one measurement row per offerable read-out — overall figures such as total length, saw cut, perimeter, surface area, depth, excavation volume, and total pipe length, plus per-layer area, length, and volume. Each measurement row carries a synthetic m:o:<key> (overall) or m:l:<layerId>:<key> (per-layer) id. A measurement is a quantity an estimator prices on a work item, not a layer of the hole: it has no material, no depth, and no editable row in the modal. Use nonMeasurementRows and isMeasurementRow from @stralos-co-nz/calculators to filter them — test on layerKind rather than the m: id prefix, which is a fallback for stored links with no row beside them. A newly added unsaved layer holds its assignment until the calculator is saved. A saved layer with no output explains that there is nothing to assign. Split excavation assignments open the partition editor rather than collapsing to one destination.

Rename and save

When onRename is present, the pencil beside the title edits only the trimmed calculator name. Enter or blur saves; Escape cancels. A rename does not save geometry or run the calculator reconcile. Saving runs calculatorSaveIssues, validates explicit disposal choices, runs the engine, and reconciles the calculator’s assignment destinations. Existing rate choices survive unless the selected material identity changes.

Advisory panel

An advisory panel appears below the modal body when any of the following conditions hold. None blocks saving on its own; the save validation rules are separate.
  • The trench is narrower than the pipe needs (D53).
  • An excavation stratum has no substance snapshot, so loose spoil volume and weight cannot be stated for it.
  • Reinstatement layers exceed the cut depth.
  • Existing layers exceed the trench depth.

Discard confirmation

Closing the modal via Escape, the × button, Cancel, or a backdrop click raises a Discard changes? confirmation whenever there is anything unsaved. The guard covers two sources of unsaved state:
  • Geometry changes — any dimension, layer, or stratum edit since the modal opened.
  • Host-held edits — a pending rename or a pending Used by choice. Both are held by the host until the modal’s Save is pressed, so the modal’s own geometry snapshot cannot see them. The host passes hostDirty={true} while either is in flight; the guard ORs this into the dirty check on every close route, including the backdrop.
Save and Remove calculator are unaffected by the guard.