Skip to content

Build AGS4 from a typed graph

Available in: Python · Node · Browser

Construct a PROJ typed-class tree, attach its children, and hand the whole graph to the emitter — use this when your data is already a graph in memory (objects, not tables) rather than {code: frame} mappings.

import laterite
from laterite import build_ags4
from laterite.groups import LOCA, PROJ

# A typed PROJ graph — children attach via .append or the constructor kwarg.
p = PROJ(proj_id="LAT-DEMO", proj_name="Built from a typed graph")
p.locas.append(LOCA(loca_id="BH01", loca_gl=12.50))  # attach via .append …
PROJ(proj_id="P2", locas=[LOCA(loca_id="BH02", loca_gl=13.75)])  # … or the ctor kwarg

# #214: the graph is walked depth-first, and the door emits only the headings
# you set (like the frames door) — so a sparse graph stays sparse.
res = build_ags4(p)
print("groups:", laterite.read(data=res.bytes).groups)
print("findings:", len(res.findings))

# The root-metadata groups (TRAN/UNIT/TYPE/ABBR/DICT) have no parent, so they are
# not part of a PROJ-rooted graph and cannot be reached by the walk. They are
# reported, not invented:
assert set(laterite.read(data=res.bytes).groups) == {"PROJ", "LOCA"}
assert res.findings

# `synthesise_metadata=True` derives the ones that CAN be derived — UNIT and TYPE
# from your data, ABBR when PA codes are used. PROJ, DICT and TRAN are never
# invented: a project identity, a schema extension and a record of transmission
# are yours to state. A guessed DICT parent would quietly mislead the relational
# checks, and a placeholder TRAN would satisfy the rule while asserting a
# transmission that never happened.
full = build_ags4(
    p,
    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  # a valid file, in one call, because you asked

# The managed child collection is append-only — reassigning it raises:
try:
    p.locas = [LOCA(loca_id="BH99")]
    raise AssertionError("expected AttributeError")
except AttributeError:
    pass
groups: ['PROJ', 'LOCA']
findings: 3

build_ags4(PROJ(...)) walks the graph depth-first (#214) and emits exactly the groups you built. You handed it a PROJ with one LOCA child; you got those two back, plus three findings naming the mandatory metadata catalogs your graph doesn't carry — Rules 14 (TRAN), 15 (UNIT) and 17 (TYPE). Pass synthesise_metadata=True to derive UNIT and TYPE (ABBR too, when PA pick-list codes are used). TRAN is not derivable — state it with the five tran_* arguments — so a sparse graph builds clean in one call once you supply the transmission it represents.

Children attach two ways, shown above: .locas.append(LOCA(...)) after the fact, or the locas=[...] constructor kwarg up front. Either way the typed-graph door emits only the headings you set — nothing is invented in your data columns — which is why a graph carrying just loca_id and loca_gl is enough.

The managed child collection is append-only: reassigning p.locas = [...] raises AttributeError rather than silently dropping the rows you built up. Mutate it through .append (and the list's own methods), never by rebinding.

// what this shows: build a typed PROJ graph (objects, not tables) and emit valid AGS4 from it.
import { buildAgs4, read, PROJ, LOCA } from "laterite";
import assert from "node:assert/strict";

// A typed PROJ graph — children attach via the `locas` array.
const p = new PROJ({
  PROJ_ID: "LAT-DEMO",
  PROJ_NAME: "Built from a typed graph",
});
p.locas.push(new LOCA({ LOCA_ID: "BH01", LOCA_GL: 12.5 })); // attach via push …
new PROJ({
  PROJ_ID: "P2",
  locas: [new LOCA({ LOCA_ID: "BH02", LOCA_GL: 13.75 })],
}); // … or the ctor field

// buildAgs4 walks the graph depth-first (#214) and emits only the headings you
// set — so a sparse graph stays sparse.
const res = buildAgs4(p);
const groups = read(res.bytes).groups;
console.log("groups:", groups);
console.log("findings:", res.findings.length);

// The root-metadata groups (TRAN/UNIT/TYPE/ABBR/DICT) have no parent, so they
// are not part of a PROJ-rooted graph. They are reported, not invented:
assert.deepEqual(groups, ["PROJ", "LOCA"]);
assert.ok(res.findings.length > 0);

// `synthesiseMetadata` derives the ones that CAN be derived. PROJ, DICT and
// TRAN are never invented: a project identity, a schema extension and a record
// of transmission are yours to state. A guessed DICT parent would quietly
// mislead the relational checks, and a placeholder TRAN would satisfy the rule
// while asserting a transmission that never happened.
const full = buildAgs4(p, {
  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); // valid in one call, because you asked
groups: [ 'PROJ', 'LOCA' ]
findings: 3

The same typed classes are named exports — import { PROJ, LOCA } from "laterite" — with uppercase heading fields (PROJ_ID, LOCA_GL) and a child array per parent (p.locas). Build the tree with the constructor's locas: [...] field or push onto it, then hand the root to buildAgs4; it walks the graph depth-first and returns the same BuildResult the frames door does, with { synthesiseMetadata: true } deriving the catalogs the same way. (Node's locas is a plain array — the append-only guard is a Python-only nicety.)

Open the web app's Export pane: whether you assemble data as a graph or as per-group tables, the same WebAssembly emitter builds the file client-side — nothing uploaded. Direct wasm callers take synthesise_metadata on build_ags4 / build_ags4_ipc to derive the UNIT/TYPE catalogs, plus the five tran_* arguments to stamp a TRAN.

Tip

build_ags4 / buildAgs4 returns a BuildResult whichever door you use. Inspect res.text / res.bytes in memory, check res.findings for any caveats autofix couldn't resolve, or res.save("out.ags") to persist a byte-faithful AGS4 file.

See also: Build from frames · Produce AGS4.