Bad geometry is the most common reason plot data fails, whether for EUDR submissions, carbon projects, or agricultural registries. Most of it is catchable automatically. Here's the pipeline I'd build, in order.
For why this matters under EUDR specifically, see EUDR Geolocation Requirements.
1. Parse and check structure
First, is it JSON? Paste it into my JSON Formatter if you're debugging by hand. Then check GeoJSON structure with a schema:
import { z } from "zod"; const Position = z.tuple([z.number(), z.number()]); const Ring = z.array(Position).min(4); const PointGeom = z.object({ type: z.literal("Point"), coordinates: Position }); const PolygonGeom = z.object({ type: z.literal("Polygon"), coordinates: z.array(Ring).min(1) }); const PlotFeature = z.object({ type: z.literal("Feature"), properties: z.object({ plotId: z.string() }).passthrough(), geometry: z.union([PointGeom, PolygonGeom]), });
2. Check coordinates
function checkPosition([lon, lat]: [number, number]) { const errors: string[] = []; if (lon < -180 || lon > 180) errors.push("longitude out of range"); if (lat < -90 || lat > 90) errors.push("latitude out of range"); const decimals = (n: number) => (n.toString().split(".")[1] ?? "").length; if (decimals(lon) < 6 || decimals(lat) < 6) errors.push("fewer than 6 decimals"); return errors; }
Also check the swap heuristic: if your plots should be in Brazil but the "longitude" looks like a Brazilian latitude, the coordinates are probably reversed. A point-in-country check catches this.
Note: JavaScript drops trailing zeros (36.820000 becomes 36.82), so check precision on the raw string if your input source preserves it.
3. Check ring closure and size
function checkRing(ring: [number, number][]) { const [first, last] = [ring[0], ring[ring.length - 1]]; const closed = first[0] === last[0] && first[1] === last[1]; return closed ? [] : ["ring not closed"]; }
4. Check validity (self-intersections)
This is where libraries earn their keep. In TypeScript, Turf.js has kinks to find self-intersections:
import kinks from "@turf/kinks"; const found = kinks(polygonFeature); if (found.features.length > 0) { errors.push(`self-intersects at ${found.features.length} point(s)`); }
In PostGIS, it's a single query:
SELECT id, ST_IsValidReason(geom) FROM plots WHERE NOT ST_IsValid(geom);
5. Check area rules
SELECT id, ST_Area(geom::geography) / 10000 AS area_ha FROM plots;
Casting to geography gives area in square metres on the ellipsoid. Then apply your rules, e.g. flag point-only plots that declare an area over the polygon threshold.
6. Check overlaps
SELECT a.id, b.id, ST_Area(ST_Intersection(a.geom, b.geom)::geography) / 10000 AS overlap_ha FROM plots a JOIN plots b ON a.id < b.id AND ST_Intersects(a.geom, b.geom) WHERE a.producer_id <> b.producer_id;
Big overlaps between different producers usually mean duplicated or mis-drawn plots.
Fixing vs flagging
- Auto-fix only safe things: closing rings, removing duplicate consecutive points.
- Flag for review: self-intersections (
ST_MakeValidcan help, but check the result), swapped coordinates, big overlaps. - Keep the original alongside any fixed version.
Show errors humans can act on
"Invalid geometry" helps nobody. "Plot PLT-0042: the boundary crosses itself near the north-east corner (see map)" does. Render the problem on a map.