Skip to content

Build AGS4 from frames

Available in: Python · Node · Browser

What / when: you have your data as per-group tables (one per group, columns named for the AGS headings) and want an AGS4 file back — optionally with the UNIT / TYPE boilerplate derived for you and TRAN stamped from your own transmission details.

import laterite
import polars as pl

# Build valid AGS4 from your own per-group frames — columns are the AGS headings.
proj = pl.DataFrame({"PROJ_ID": ["LAT-DEMO"], "PROJ_NAME": ["Demo site"]})
loca = pl.DataFrame({"LOCA_ID": ["BH01", "BH02"], "LOCA_GL": [12.50, 13.75]})

res = laterite.build_ags4({"PROJ": proj, "LOCA": loca})  # default mode="autofix"
groups = laterite.read(data=res.bytes).groups
print("groups:", groups)
print("findings:", len(res.findings))

# You get back exactly the groups you supplied. AGS4 also mandates the metadata
# catalogs (TRAN/UNIT/TYPE), which your frames don't carry — so those are
# REPORTED rather than invented:
assert set(groups) == {"PROJ", "LOCA"}
assert {f["rule"] for f in res.findings} >= {
    "AGS Format Rule 14",  # TRAN
    "AGS Format Rule 15",  # UNIT
    "AGS Format Rule 17",  # TYPE
}

# Ask for them and UNIT and TYPE are derived from your columns. TRAN is not
# derivable — only you know who sent what to whom — so you state it, and a build
# that doesn't reports the gap instead of inventing a placeholder that would
# satisfy the rule while asserting a transmission that never happened. Opt-in
# either way, so nothing appears in your file that you didn't ask for.
full = laterite.build_ags4(
    {"PROJ": proj, "LOCA": loca},
    synthesise_metadata=True,
    tran=laterite.TranStamp(
        issue="1",
        date="2026-07-30",
        producer="Demo Producer",
        recipient="Demo Recipient",
        status="Final",
    ),
)
assert {"PROJ", "LOCA", "TRAN", "UNIT", "TYPE"}.issubset(
    laterite.read(data=full.bytes).groups
)
assert not full.findings
groups: ['PROJ', 'LOCA']
findings: 3

build_ags4 takes a {code: frame} mapping — each frame's columns are the AGS headings for that group — and returns a BuildResult containing exactly the groups you supplied. AGS4 also mandates the metadata catalogs, which your frames don't carry, so they are reported, not invented: the three findings are Rules 14 (TRAN), 15 (UNIT) and 17 (TYPE). The result carries the file three ways:

  • res.text — the AGS4 as a str (in memory)
  • res.bytes — the byte-faithful encoding (what read(data=…) consumes above)
  • res.save("out.ags") — persist to disk

To get the catalogs: pass synthesise_metadata=True. UNIT and TYPE are derived from your columns and ABBR from the standard table when PA codes are used. PROJ, DICT and TRAN are never synthesised: a project identity, a schema extension and a record of transmission are authorial facts. Inventing a DICT parent would turn a loud Rule 18 error into a silent false statement Rule 10's relational checks then trust; inventing a TRAN would satisfy Rule 14 while asserting a transmission that never happened.

To get a TRAN: state it — tran=TranStamp(issue=…, date=…, producer=…, recipient=…, status=…). All five are required together because all five are REQUIRED headings; the dataclass enforces that at your call site rather than letting a half-stamp become a Rule 10b finding. Pass nothing and Rule 14 reports the gap instead.

Gotcha: synthesis is independent of mode, and only mode="autofix" (the default) honours it. mode="report" emits unmodified and hands you the findings; mode="strict" is a hard gate that rejects the build outright if the output violates any error-severity rule — so a data-only build under strict raises rather than emitting. The emitter also writes only the headings you supply, never inventing data columns, so a sparse frame builds clean rather than padding out the full dictionary.

// what this shows: buildAgs4(entries) — valid AGS4 from your own per-group row data (headings as keys).
import { buildAgs4, read } from "laterite";
import assert from "node:assert/strict";

// Each group's rows are plain objects whose KEYS are the AGS headings.
const proj = [{ PROJ_ID: "LAT-DEMO", PROJ_NAME: "Demo site" }];
const loca = [
  { LOCA_ID: "BH01", LOCA_GL: 12.5 },
  { LOCA_ID: "BH02", LOCA_GL: 13.75 },
];

// A Map (or Array of [code, rows]) — group order is preserved, so put PROJ first.
const res = buildAgs4(
  new Map([
    ["PROJ", proj],
    ["LOCA", loca],
  ]),
); // default mode "autofix"
const groups = read(res.bytes).groups;
console.log("groups:", groups);
console.log("findings:", res.findings.length);

// You get back exactly the groups you supplied. AGS4 also mandates the metadata
// catalogs (TRAN/UNIT/TYPE), which your rows don't carry — so those are REPORTED
// rather than invented:
assert.deepEqual(groups, ["PROJ", "LOCA"]);
const rules = new Set(res.findings.map((f) => f.rule));
for (const r of [
  "AGS Format Rule 14",
  "AGS Format Rule 15",
  "AGS Format Rule 17",
]) {
  assert.ok(rules.has(r), `expected ${r}`);
}

// Ask for them and UNIT and TYPE are derived from your columns. TRAN is not
// derivable — only you know who sent what to whom — so you state it, and a
// build that doesn't reports the gap instead of inventing a placeholder that
// would satisfy the rule while asserting a transmission that never happened.
// Opt-in either way, so nothing appears in your file that you didn't ask for.
const full = buildAgs4(
  new Map([
    ["PROJ", proj],
    ["LOCA", loca],
  ]),
  {
    synthesiseMetadata: true,
    tran: {
      issue: "1",
      date: "2026-07-30",
      producer: "Demo Producer",
      recipient: "Demo Recipient",
      status: "Final",
    },
  },
);
const fullGroups = read(full.bytes).groups;
assert.ok(
  ["PROJ", "LOCA", "TRAN", "UNIT", "TYPE"].every((c) => fullGroups.includes(c)),
);
assert.equal(full.findings.length, 0);
groups: [ 'PROJ', 'LOCA' ]
findings: 3

buildAgs4 takes a Map (or array) of [code, rows] entries — each row a plain object whose keys are the AGS headings — or an arrow-js Table per group. Group order is preserved, so put PROJ first. It returns the same BuildResult as Python: res.bytes / res.text carry the document and res.findings is the residual the mode couldn't clear. Pass { synthesiseMetadata: true } to derive UNIT/TYPE (and ABBR for PA codes) and tran: { issue, date, producer, recipient, status } to stamp a TRAN; without them those gaps are reported as Rules 14/15/17. No DuckDB peer needed — emit is pure.

Open the web app's Export pane: assemble or paste your per-group data and export an AGS4 file. The same emitter runs compiled to WebAssembly, and nothing is uploaded to build it. Direct wasm callers take synthesise_metadata on build_ags4 / build_ags4_ipc — the browser twin of the Python and Node flags.

Every door runs the same emitter over the same dictionary, and metadata synthesis is opt-in on all of them — synthesise_metadata= in Python, { synthesiseMetadata } in Node, synthesise_metadata in the browser build. Without it you get exactly the groups you supplied, plus findings naming the catalogs you didn't.

See also: Build from a typed graph · Produce AGS4