ARCNM

Recipes

Recipe — quote a part end-to-end

From a STEP file on disk to a customer-ready quote PDF, with audit lineage.

Turn a STEP file into a defensible, customer-ready quote in one pass. This recipe walks the canonical ARCNM flow: a file lands on your server (RFQ inbox, customer portal upload, ERP attachment), and you return a unit price to your sales team within a minute.

By the end you'll have:

  1. Uploaded the CAD and an optional drawing.
  2. Run the ARCNM engine against your production environment.
  3. Pulled the unit cost, costs, and audit blob.
  4. Generated a customer-ready PDF stamped with the calculation id.

Prerequisites

  • An ARCNM API key with scopes parts:write (to run calculations) and parts:read (to fetch the result).
  • One costing environment (a UUID). The dashboard onboarding flow creates a default one; this recipe assumes you have its id.
  • A STEP file (./bracket.step) and optionally a drawing (./bracket.pdf).
export ARCNM_API_KEY="ak_live_…"
export ENV_ID="…"        # your costing environment UUID

Step 1 — Quote in one call

POST /api/v1/calculations/upload-and-quote creates the Part, Revision, and Dataset from the uploaded file and enqueues the calculation in a single multipart/form-data request. Returns 202 Accepted.

costing_environment_id, part_number, and cad_file are required.

CALC=$(curl -s -X POST https://api.arcnm.io/api/v1/calculations/upload-and-quote \
  -H "X-API-Key: $ARCNM_API_KEY" \
  -F "costing_environment_id=$ENV_ID" \
  -F "part_number=BRACKET-001" \
  -F "cad_file=@./bracket.step;type=application/step" \
  -F "drawing_file=@./bracket.pdf;type=application/pdf" \
  -F "lot_size=50" \
  -F "annual_volume=500" \
  -F "material_ref=1.4301")

echo "$CALC"
CALC_ID=$(echo "$CALC" | jq -r .id)
import os, requests

resp = requests.post(
    "https://api.arcnm.io/api/v1/calculations/upload-and-quote",
    headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
    data={
        "costing_environment_id": os.environ["ENV_ID"],
        "part_number": "BRACKET-001",
        "lot_size": 50,
        "annual_volume": 500,
        "material_ref": "1.4301",
    },
    files={
        "cad_file": ("bracket.step", open("./bracket.step", "rb"), "application/step"),
        "drawing_file": ("bracket.pdf", open("./bracket.pdf", "rb"), "application/pdf"),
    },
)
resp.raise_for_status()
calc = resp.json()
print(f"queued: {calc['id']}")
import { readFile } from "node:fs/promises"

const form = new FormData()
form.set("costing_environment_id", process.env.ENV_ID!)
form.set("part_number", "BRACKET-001")
form.set("lot_size", "50")
form.set("annual_volume", "500")
form.set("material_ref", "1.4301")
form.set("cad_file", new Blob([await readFile("./bracket.step")]), "bracket.step")
form.set("drawing_file", new Blob([await readFile("./bracket.pdf")]), "bracket.pdf")

const resp = await fetch(
  "https://api.arcnm.io/api/v1/calculations/upload-and-quote",
  { method: "POST", headers: { "X-API-Key": process.env.ARCNM_API_KEY! }, body: form },
)
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`)
const calc = await resp.json()
console.log("queued:", calc.id)

Response (202 Accepted):

{ "id": "9d8f3b62-…", "status": "queued" }

Already have a Part + Revision? Skip the upload and call POST /api/v1/calculations/quote with a JSON body (part_revision_id + costing_environment_id, optional lot_size, annual_volume, material_ref/material_grade_id, currency). It also returns 202 and enqueues in one call. See Calculations.


Step 2 — Poll for the result

Poll GET /api/v1/calculations/{id} until status reaches a terminal value: succeeded, failed, cancelled, or timed_out.

while true; do
  RESP=$(curl -s "https://api.arcnm.io/api/v1/calculations/$CALC_ID" \
    -H "X-API-Key: $ARCNM_API_KEY")
  STATUS=$(echo "$RESP" | jq -r .status)
  echo "status=$STATUS"
  case "$STATUS" in
    succeeded)                  echo "$RESP" > result.json; break ;;
    failed|cancelled|timed_out) echo "$RESP" | jq .error; exit 1 ;;
    *)                          sleep 2 ;;
  esac
done
import time

def wait_for(calc_id: str, timeout_s: int = 120) -> dict:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        r = requests.get(
            f"https://api.arcnm.io/api/v1/calculations/{calc_id}",
            headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
        )
        r.raise_for_status()
        calc = r.json()
        if calc["status"] == "succeeded":
            return calc
        if calc["status"] in ("failed", "cancelled", "timed_out"):
            raise RuntimeError(calc.get("error"))
        time.sleep(2)
    raise TimeoutError(calc_id)

result = wait_for(calc["id"])
print(f"unit cost @ n=50: {result['unit_cost']:.2f} {result['currency']}")
async function waitFor(calcId: string, timeoutMs = 120_000): Promise<any> {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    const r = await fetch(
      `https://api.arcnm.io/api/v1/calculations/${calcId}`,
      { headers: { "X-API-Key": process.env.ARCNM_API_KEY! } },
    )
    if (!r.ok) throw new Error(`${r.status}`)
    const calc = await r.json()
    if (calc.status === "succeeded") return calc
    if (["failed", "cancelled", "timed_out"].includes(calc.status)) throw new Error(JSON.stringify(calc.error))
    await new Promise(res => setTimeout(res, 2_000))
  }
  throw new Error("timeout")
}

const result = await waitFor(calc.id)
console.log(`unit cost @ n=50: ${result.unit_cost.toFixed(2)} ${result.currency}`)

A succeeded result carries the costs, timings, and the audit blob:

{
  "id":            "9d8f3b62-…",
  "status":        "succeeded",
  "part_id":       "…",
  "part_revision_id": "…",
  "costing_environment_id": "…",
  "engine":        "arcanum",
  "lot_size":      50,
  "annual_volume": 500,
  "unit_cost":     12.84,
  "total_cost":    642.00,
  "setup_cost":    52.50,
  "unit_time_s":   91.3,
  "total_time_s":  4617.0,
  "currency":      "EUR",
  "analytics":     { /* full audit blob */ },
  "started_at":    "2026-05-28T14:23:00Z",
  "finished_at":   "2026-05-28T14:23:32Z"
}

Cost fields are unit_cost / total_cost / setup_cost with a sibling currency. The full breakdown lives in the free-form analytics blob — keep that server-side.


Step 3 — Render the customer-facing quote

The shape is stable enough to pass directly to a PDF template. The audit blob stays server-side; only the price and a calculation id go on the customer document.

from weasyprint import HTML
from jinja2 import Template

tpl = Template(open("./quote.html.j2").read())
html = tpl.render(
    part_number="BRACKET-001",
    calculation_id=result["id"],
    unit_cost=result["unit_cost"],
    total_cost=result["total_cost"],
    setup_cost=result["setup_cost"],
    currency=result["currency"],
)
HTML(string=html).write_pdf("./quote.pdf")
import { renderToFile } from "@react-pdf/renderer"
import { QuoteDocument } from "./QuoteDocument"

await renderToFile(<QuoteDocument
  partNumber="BRACKET-001"
  calculationId={result.id}
  unitCost={result.unit_cost}
  totalCost={result.total_cost}
  setupCost={result.setup_cost}
  currency={result.currency}
/>, "./quote.pdf")

The calculation_id printed in the PDF is the receipt: any auditor (internal or external) can fetch the original calc and re-read the inputs and the audit blob.


Step 4 — Persist the calculation id

Drop the calculation id into your ERP / quote management tool so re-quotes are trivially traceable:

INSERT INTO erp_quotes (rfq_id, part_number, arcanum_calc_id, unit_cost, currency, created_at)
VALUES ($1, $2, $3, $4, $5, NOW());

A year later, GET /api/v1/calculations/{id} returns the exact audit row — same inputs, same costs, same analytics provenance.


Common variations

Quote against multiple environments

To compare your fleet vs a subcontractor (or any set of up to 16 environments), pass costing_environment_ids instead of the single id — one call, one upload, one calculation per environment, grouped under a batch_id. Each environment's run counts as one calculation against your plan quota (2 environments below = 2 calculations):

r = requests.post(
    "https://api.arcnm.io/api/v1/calculations/quote",
    headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
    json={
        "part_revision_id": calc["part_revision_id"],
        "costing_environment_ids": [OUR_ENV_ID, SUB_ENV_ID],
        "lot_size": 50,
    },
)
r.raise_for_status()
batch_id = r.json()["batch_id"]  # runs: r.json()["environment_runs"]

Don't diff unit costs client-side — the comparison endpoint computes the whole matrix server-side (best environment per part, deltas vs the cheapest and vs your baseline, staleness and mixed-currency flags), so every consumer sees the same numbers:

cmp = requests.get(
    f"https://api.arcnm.io/api/v1/calculations/batches/{batch_id}/comparison",
    headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
).json()
# Poll while cmp["complete"] is false — cells fill in as runs finish.
for part in cmp["parts"]:
    print(part["part_number"], "→ best:", part["best_environment_id"])

The same costing_environment_ids field works on POST /calculations/batch (N revisions × M environments in one request) and on upload-and-quote (the file is uploaded once for the whole grid). To compare runs that were not submitted together, POST their ids to /calculations/comparison instead.

Price from 3D geometry alone

There is no extraction-mode flag. To price from the geometry alone, simply don't attach a drawing — the engine analyses what you send it:

requests.post(
    "https://api.arcnm.io/api/v1/calculations/upload-and-quote",
    headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
    data={
        "costing_environment_id": os.environ["ENV_ID"],
        "part_number": "BRACKET-001",
    },
    files={
        # No drawing_file → no drawing analysis. Nothing else to configure.
        "cad_file": ("bracket.step", open("./bracket.step", "rb"), "application/step"),
    },
)

Attach drawing_file and the drawing is read automatically (title block, dimensions, tolerances, notes). The response reports which path ran as engine_path (vlm / no_vlm) for diagnostics.

Currency conversion

The JSON /quote endpoint accepts a currency (ISO-4217) to convert the response:

requests.post(
    "https://api.arcnm.io/api/v1/calculations/quote",
    headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
    json={
        "part_revision_id": part_revision_id,
        "costing_environment_id": os.environ["ENV_ID"],
        "currency": "USD",
    },
)

Attach an extra input after upload

To add a 2D drawing or RFQ text to a calculation created without one, POST /api/v1/calculations/{id}/inputs with a role form field and a single file (one attachment per call):

curl -X POST https://api.arcnm.io/api/v1/calculations/$CALC_ID/inputs \
  -H "X-API-Key: $ARCNM_API_KEY" \
  -F "role=drawing_2d" \
  -F "file=@./bracket.pdf;type=application/pdf"

What to do when it fails

Scenario Action
status: failed, error.details.retryable: false The input is unrecoverable — ask for a fresh upload or fall back to manual quoting.
status: failed, error.details.retryable: true Re-enqueue with POST /api/v1/calculations/{id}/run. The pipeline reuses the existing upload.
422 material_unresolved The material_ref didn't match the catalogue (or the material_grade_id is not a known grade). details.candidates carries ranked near matches — surface them to your sales engineer, or resend with a material_grade_id. Every calculation entry point answers the same way, single-environment and multi-environment alike.
402 quota_exceeded Included calculations exhausted for this period; a grid the remaining quota can't cover never runs in part — details.cells_requested / details.cells_available carry the exact numbers. On /quote and upload-and-quote the cells are saved parked blocked and details.batch_id / details.parked_cells name the batch: top up, then resume it (POST /calculations/bulk-retry, or /run per cell) instead of re-submitting the grid. /calculations/batch rejects the request whole with nothing persisted — shrink the grid or top up, then resubmit. See Pricing & billing.
429 / 5xx Exponential backoff with jitter (the API doesn't emit Retry-After). Pair small JSON retries with an Idempotency-Key.

Idempotency on uploads. A CAD upload is a large multipart request, so the Idempotency-Key header runs in lock-only mode: a fast retry while the original is in flight returns 409 idempotency_in_flight rather than a cached body. The natural key for upload-and-quote is part_number — re-posting the same number attaches a new revision instead of duplicating the Part. See Idempotency.


See also