Skip to content

Produce AGS4

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 — the columns are your AGS headings — and constructs a file from exactly the groups you supplied.

AGS4 also mandates the metadata catalogs (TRAN, UNIT, TYPE, plus ABBR for any PA pick-list codes), which your frames don't carry. Those are reported, not invented — hence the three findings above, Rules 14/15/17. mode="autofix" repairs what your input contains; it does not mint groups you never wrote.

To get them, ask:

res = laterite.build_ags4(
    {"PROJ": proj, "LOCA": loca},
    synthesise_metadata=True,
    tran=laterite.TranStamp(
        issue="1",
        date="2026-07-30",
        producer="Your Firm",
        recipient="The Client",
        status="Final",
    ),
)

UNIT and TYPE are derived from your columns. TRAN is not derivable — only you know who sent what to whom — so you state it. Omit the stamp and no TRAN is written and Rule 14 reports the gap, rather than a placeholder being invented that would satisfy the rule while asserting a transmission that never happened. All five are required together — they are REQUIRED headings, so TranStamp demands them rather than letting a half-stamp reach the file. TRAN_AGS, TRAN_DLIM and TRAN_RCON are absent from it on purpose: they describe the file the emitter is writing, so it fills them.

Synthesis is opt-in on every surface (synthesise_metadata= in Python, { synthesiseMetadata } in Node, synthesise_metadata in the browser wasm build) so nothing appears in your file that you didn't ask for.

From a typed PROJ graph

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

The other door takes a typed graph: a PROJ with LOCA children attached via .locas.append(...) or the locas=[...] constructor kwarg. build_ags4 walks it depth-first and — like the frames door — emits only the headings you set, and only the groups you built. That's why a sparse graph builds clean: nothing is invented, in your data columns or around them. synthesise_metadata=True works here too, and reports the same Rules 14/15/17 without it. The managed child collection is append-only, so reassigning p.locas raises AttributeError rather than silently dropping the rows you built up.

Tip

Both doors return the same BuildResult. 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 to disk.

→ See the whole fluent API assembled in the Chaining Showcase.