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
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
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.