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.

Attributes of the LandXML CoordinateSystem element Five attributes. The EPSG code is the only one with a defined meaning and should always be written. The horizontal datum name is free text and names an ensemble in practice. The projection or zone name is conventional and varies by producer. The geoid name is conventional and matters for any orthometric height. The description field is free text and is where the epoch and operation identifier can be carried for a human reader, though not for a machine. epsgCode defined always write it horizontalDatum free text names an ensemble in practice projection / zone conventional varies by producer geoidName conventional required for orthometric heights desc free text epoch + operation, for humans

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" ...
The point-ordering trap in a LandXML export A coordinate pair arrives from GIS code in easting-then-northing order, which is the x-then-y convention of nearly everything else. LandXML writes CgPoint text as northing, then easting, then elevation. Exporting without swapping produces a file that is transposed. In a UTM zone the two values differ by an order of magnitude, so a simple magnitude check catches it; in a local grid where both are small, it does not, and the transposition ships. GIS convention easting, northing Swap deliberately once, at the writer LandXML CgPoint northing, easting, elevation No swap transposed file forgotten

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.

Reading the result of a test import Three outcomes. Coordinates match and the CRS is displayed correctly, which confirms the convention used is the one read. Coordinates match but the CRS is displayed as something else, which means the datum name was used and the EPSG code ignored — worth knowing before a delivery where the two disagree. Coordinates are transposed or shifted, which locates the problem precisely: transposition is a point-order convention, a uniform shift is a datum resolved to a different realisation. Means Action Coordinates + CRS match convention confirmed record it Coordinates match, CRS differs code ignored, name used align the name too Coordinates transposed point order differs swap at the writer Coordinates shifted different realisation state the realisation

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.