Exporting LandXML with Correct CRS Metadata
LandXML is the exchange format cadastral work actually uses between survey software packages, and its CRS handling is a convention rather than a constraint: the element exists, most of its attributes are optional, and consumers vary in what they read. This guide, part of geospatial file formats and CRS metadata, covers writing a CoordinateSystem element that a receiving system can act on, carrying the realisation, epoch and operation that the element itself has no field for, and validating the export against what a reader will actually see.
What the Element Carries
LandXML’s CoordinateSystem element has attributes for a horizontal datum name, a vertical datum name, a projection or zone name, a geoid model name, and — in later revisions — an EPSG code. All are optional, all are free text apart from the code, and none of them is a realisation in the strict sense. A file that says horizontalDatum="NAD83" is naming an ensemble, and the receiving system will resolve it however it resolves it.
Figure — which CoordinateSystem attributes have defined meaning, and which are convention.
Two things follow. Write the EPSG code whenever there is one, because it is the only attribute with a defined meaning rather than a conventional one. And carry everything the element cannot express — the coordinate epoch, the operation identifier, the grid checksums — in the document’s own metadata or in a companion record, because a survey deliverable in a time-dependent frame is not interpretable without them, as recording coordinate epochs in cadastral deliverables sets out.
Complete Runnable Implementation
from __future__ import annotations
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_EVEN
from pyproj import CRS
LANDXML_NS = "http://www.landxml.org/schema/LandXML-1.2"
ENSEMBLE_CODES = {("EPSG", "4326"), ("EPSG", "4269"), ("EPSG", "4258")}
def q(value: float, decimals: int = 4) -> str:
"""Fixed-precision, half-to-even formatting for every coordinate written."""
quantum = Decimal(1).scaleb(-decimals)
return str(Decimal(repr(value)).quantize(quantum, rounding=ROUND_HALF_EVEN))
@dataclass(frozen=True)
class LandXmlExport:
"""A LandXML document whose CRS metadata says what the coordinates mean."""
crs: CRS
epoch: float | None
operation_id: str
geoid_model: str | None = None
def coordinate_system_element(self) -> ET.Element:
auth = self.crs.to_authority()
if auth is None:
raise ValueError(
"the CRS has no authority code; LandXML consumers resolve datums by "
"name, so an unmapped CRS must be documented out of band"
)
if tuple(auth) in ENSEMBLE_CODES:
raise ValueError(
f"{auth[0]}:{auth[1]} is a datum ensemble; export the realisation"
)
el = ET.Element("CoordinateSystem", {
"name": self.crs.name,
"epsgCode": str(auth[1]),
"horizontalDatum": self.crs.datum.name if self.crs.datum else "",
"desc": f"operation {self.operation_id}"
+ (f"; epoch {self.epoch:.2f}" if self.epoch else ""),
})
if self.geoid_model:
el.set("geoidName", self.geoid_model)
return el
def document(self, points: list[tuple[str, float, float, float]]) -> ET.ElementTree:
"""A minimal document: units, CRS, and a CgPoints block."""
root = ET.Element("LandXML", {
"xmlns": LANDXML_NS,
"version": "1.2",
})
units = ET.SubElement(root, "Units")
ET.SubElement(units, "Metric", {
"areaUnit": "squareMeter", "linearUnit": "meter",
"volumeUnit": "cubicMeter", "temperatureUnit": "celsius",
"pressureUnit": "milliBars", "angularUnit": "decimal degrees",
"directionUnit": "decimal degrees",
})
root.append(self.coordinate_system_element())
cg = ET.SubElement(root, "CgPoints", {"name": "control"})
for name, north, east, elev in points:
p = ET.SubElement(cg, "CgPoint", {"name": name})
# LandXML orders point text as northing easting elevation.
p.text = f"{q(north)} {q(east)} {q(elev, 3)}"
return ET.ElementTree(root)
Parameter Reference
| Name | Type | Note |
|---|---|---|
crs |
CRS |
Must be a realisation with an authority code |
epoch |
float or None |
Written into desc; also belongs in the sidecar |
operation_id |
str |
The operation that produced the coordinates |
geoid_model |
str or None |
Names the model behind any orthometric heights |
| point text | str |
Northing, easting, elevation — that order |
Worked Example
from pyproj import CRS
export = LandXmlExport(
crs=CRS.from_epsg(6339),
epoch=2020.00,
operation_id="EPSG:8264",
geoid_model="GEOID18",
)
tree = export.document([
("CV-114", 5_037_223.0894, 531_247.3195, 61.230),
("CV-115", 5_037_901.4412, 531_902.7731, 64.115),
])
import io
buf = io.BytesIO()
tree.write(buf, encoding="utf-8", xml_declaration=True)
print(buf.getvalue().decode()[:220])
# <?xml version='1.0' encoding='utf-8'?>
# <LandXML xmlns="http://www.landxml.org/schema/LandXML-1.2" version="1.2">
# <Units><Metric areaUnit="squareMeter" ... /></Units>
# <CoordinateSystem name="NAD83(2011) / UTM zone 10N" epsgCode="6339" ...
Figure — LandXML orders point text northing first; most GIS code does not.
Validation Check
def assert_point_order(tree: ET.ElementTree, expect_north_first: bool = True) -> None:
"""Catch a transposed CgPoint before the file leaves.
A northing and an easting of similar magnitude transpose invisibly; in a UTM
zone they differ by an order of magnitude, which makes the check trivial.
"""
ns = {"lx": LANDXML_NS}
for p in tree.getroot().findall(".//lx:CgPoint", ns):
first, second, *_ = (float(v) for v in (p.text or "").split())
if expect_north_first:
assert first > second, (
f"point {p.get('name')}: first value {first} is not larger than "
f"{second}; northing and easting may be transposed"
)
Common Mistakes
Writing the ensemble datum name and nothing else. horizontalDatum="NAD83" is what most exporters emit and it names a family spanning about two metres. Always write the EPSG code alongside it, and write the realisation in the name.
Assuming the receiving system reads the EPSG code. Many read the datum name first and use the code only as a fallback, or ignore it entirely. That is a reason to write both consistently, not a reason to skip the code — and a reason to confirm with the recipient which attribute they actually consume before a large delivery.
Transposing northing and easting. LandXML orders point text northing first, which is the opposite of the easting-first convention most GIS code uses. In a UTM zone the two differ by an order of magnitude, so the check above catches it instantly; in a local grid where both are small, it will not, and the transposition ships.
Confirming What the Recipient Reads
LandXML’s CRS handling is conventional rather than normative, which means the only reliable way to know how a file will be interpreted is to ask the recipient’s software. That sounds like an excuse to skip the question; in practice it is a ten-minute exercise that prevents a redelivery.
Figure — three outcomes of a test import, each locating a different problem.
Send a small test file — a handful of control points at known coordinates, in the same CRS and with the same attributes as the real deliverable — and ask the recipient to import it and report the coordinates their system shows. Three outcomes are possible. The coordinates match, in which case the convention you used is the one they read. They match but the CRS is displayed as something else, in which case their system inferred the CRS from the datum name and the EPSG code was ignored — worth knowing before a delivery where the two disagree. Or the coordinates are transposed or shifted, which locates the problem precisely: transposition means the point ordering convention differs, and a uniform shift means the datum was resolved to a different realisation.
Recording the outcome against that recipient, with the date and the software version, turns an unrepeatable conversation into a fact the next delivery can rely on. Recipients change their software, so the fact has a shelf life — but a stale answer is still a better starting point than none.
Frequently Asked Questions
Which LandXML version should I target?
Whatever the recipient’s software imports, which in practice is usually 1.2 — later drafts are unevenly supported. This is one of the few areas where matching the consumer matters more than using the most capable version, because an unreadable file helps nobody.
Can I put the grid checksums in the LandXML file?
There is no defined element for them, so anything you add is a private extension that most readers will ignore and some will reject. Put them in the companion metadata record listed in the delivery manifest, and reference the record from the desc attribute so a human reading the file knows it exists.
How should orthometric heights be labelled?
Name the geoid model in the geoidName attribute and state the vertical datum explicitly. A height with no model named is not interpretable to better than the model differences — often a decimetre or more — and the arithmetic behind it is in applying geoid undulation for orthometric heights.
Is LandXML a defensible archival format?
For the geometry and the survey structure, reasonably: it is text, schema-described and widely readable. For the CRS identity, only with the companion record, because its metadata is conventional rather than normative. Archive both together and hash them together.
How should the file record which software produced it?
In the Application element, with the name and version, and — for a transformation pipeline — the library and grid versions in the accompanying record rather than in the LandXML itself. Recipients use the application string to explain differences between files, and a generic or absent value costs them an investigation. It is also the field that most often still says whatever the template said, which is a good reason to populate it from the running code rather than from a constant.
A final habit worth adopting: keep one known-good exported file per recipient as a reference, and diff new exports against it when something changes. Structural differences show up immediately, and a diff is a far faster diagnosis than reading a schema when an import silently fails.