{"id":"67bc93d4-4aa5-4ad9-8736-9ee0232ade4d","slug":"maz2580-geospark","kind":"mcp","name":"geospark","summary":"Verifiable spatial reasoning for LLMs: geodesic distance, topology and CRS computed by a geometry engine, with sources and licences on every answer. MCP server, REST API, Python library.","owner":"Maz2580","repoUrl":"https://github.com/Maz2580/geospark","license":"Apache-2.0","licenseTier":"permissive","trust":"community","stars":19,"match":null,"fewStars":false,"format":"mcp","createdAt":"2026-03-03T13:46:30.000Z","addedAt":"2026-10-23T08:58:44.331Z","logoUrl":"https://avatars.githubusercontent.com/u/128564697?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["cloud-infra"],"tags":["benchmark","geoai","geospatial","gis","llm","openstreetmap","python","spatial-reasoning"],"runtime":"remote","local":false,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T01:10:06.171Z","sample":false,"url":"https://conduits.dev/tools/maz2580-geospark","language":"Python","transports":["http"],"install":{"remoteUrl":"https://geospark.terrascout.app/mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"<!-- mcp-name: io.github.Maz2580/geospark -->\n<p align=\"center\">\n  <h1 align=\"center\">GeoSpark</h1>\n  <p align=\"center\"><strong>Verifiable spatial reasoning for large language models.</strong></p>\n  <p align=\"center\"><em>Distances, topology, coordinate reference systems and heights computed by a geometry engine and official geodetic models, not estimated by a language model. Open source, self-hostable, and usable by any model that can call a tool.</em></p>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/Maz2580/geospark/actions/workflows/ci.yml\"><img src=\"https://img.shields.io/github/actions/workflow/status/Maz2580/geospark/ci.yml?branch=main&label=tests\" alt=\"Tests\"></a>\n  <a href=\"https://pypi.org/project/geospark-ai/\"><img src=\"https://img.shields.io/pypi/v/geospark-ai?color=blue\" alt=\"PyPI\"></a>\n  <a href=\"https://pypi.org/project/geospark-ai/\"><img src=\"https://img.shields.io/pypi/pyversions/geospark-ai\" alt=\"Python\"></a>\n  <a href=\"https://github.com/Maz2580/geospark/pkgs/container/geospark\"><img src=\"https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white\" alt=\"Docker\"></a>\n  <a href=\"https://github.com/Maz2580/geospark/blob/main/LICENSE\"><img src=\"https://img.shields.io/badge/license-Apache%202.0-green\" alt=\"License\"></a>\n</p>\n\n---\n\nGeoSpark provides language models with tools that **compute** spatial answers rather than estimate\nthem: geodesic distances, exact topology, official datum and height conversions, geocoding,\nelevation, routing, open map data and live environmental data. Each result carries its sources,\nlicences, units and caveats, and a result that cannot be computed reliably is reported as such\nrather than approximated. GeoSpark also checks users' own datasets (vector files, LiDAR point\nclouds and elevation models) for the silent coordinate and height errors that most often corrupt\nspatial analyses.\n\n**Contents:** [Motivation and evaluation history](#motivation-and-evaluation-history) ·\n[Capabilities](#capabilities) · [Access and installation](#access-and-installation) ·\n[Usage](#usage) · [Command-line interface](#command-line-interface) · [Evaluation](#evaluation) ·\n[Validation against authoritative sources](#validation-against-authoritative-sources) ·\n[Limitations](#limitations) · [Self-hosting](#self-hosting) · [Release history](#release-history) ·\n[Data attribution](#data-attribution) · [Development](#development) · [Citation](#citation)\n\n## Motivation and evaluation history\n\nGeoSpark is evaluated against the models in current use, and its scope follows the gaps that those\nevaluations reveal.\n\n- **GeoSpark Bench v2.** Across nine models of the time, small open-weight models (3.8B–9B\n  parameters) answered numeric distance questions incorrectly about 95% of the time, and frontier\n  APIs ranged from 25% to 95% correct. With GeoSpark's tools, every model that issued valid tool\n  calls answered 76–96% correctly; a 7B model running on a CPU improved from 5% to 76%.\n- **Re-evaluation with current models (October 2026).** Claude Opus 5.5 and GPT-6.1 now answer the\n  same distance and topology questions correctly without tools, and with GeoSpark's tools a 4B open\n  model on a workstation matches them. Distances between given points are no longer the limiting\n  problem.\n- **Identification of the next gap.** Both frontier models apply datum parameters, plate motion and\n  map projections to the millimetre, but fail on conversions that require a national grid file:\n  ellipsoidal (GNSS) heights to AHD, ODN, NZVD2016 or NAVD88, and British National Grid through\n  OSTN15. Answering directly, including within the providers' own code sandboxes (which have no\n  internet access), GPT-6.1 declined and Opus 5.5 estimated, with errors of up to 9.9 m. GeoSpark\n  now performs these conversions with the official grids. When asked instead to write a script\n  executed with internet access, both frontier models produced correct results; smaller local\n  models did not.\n- **Independent verification.** Agreement with PROJ demonstrates only that a model used the tool\n  correctly. GeoSpark's own results were therefore compared with the published calculators and test\n  data of Geoscience Australia, Ordnance Survey, LINZ and the US National Geodetic Survey, agreeing\n  within millimetres with one disclosed exception. The same audit corrected statements in the\n  project's documentation and fixed the edge cases it exposed.\n- **Small-model safety.** Four open models on a local workstation were run through GeoSpark's\n  question-answering loop to measure how often an incorrect answer is still reported as `ok`. Each\n  failure mode found (unsupported numbers, empty answers, contradictory comparisons, an ambiguous\n  place name resolved to the wrong country) is now detected; after the changes, 120 answers over\n  three runs contained one silent error, which has since been fixed.\n\nAll questions, answer-generation scripts, harnesses and scored results are included in the\nrepository and can be re-run ([evaluation](#evaluation),\n[October 2026 materials](https://github.com/Maz2580/geospark/tree/main/docs/benchmarks/2026-10)).\n\n**Current state of the project**\n\n- **A public website, REST API and hosted MCP server** at\n  [geospark.terrascout.app](https://geospark.terrascout.app), offering question answering,\n  reachable-area (isochrone) mapping, open map data exploration, and datum and height conversion.\n- **Verifiable results:** sources and licences, units, caveats (an ambiguous place name, a DEM's\n  accuracy, a local height datum) and a status of `ok`, `partial` or `cannot_answer`. An answer\n  that relies on coordinates typed by the model, converts to a different system than the question\n  specifies, or states quantities that no tool returned is marked `partial`.\n- **Dataset checks** for vector files, LiDAR point clouds and elevation models: declared and actual\n  coordinate reference systems, datum mix-ups, wrong zones, units, and height references.\n- **Self-hostable on a single server** with a local model at no API cost: routing (Valhalla), an\n  OpenStreetMap replica (Overpass) and the official geodetic grids, all under one data directory.\n- **Open:** Apache-2.0 licence, about 1,250 offline tests, and re-runnable benchmarks.\n\n## Capabilities\n\n| | |\n|---|---|\n| **Spatial engine** | Geodesic distance, area and length (pyproj on WGS 84); topology predicates (contains, within, intersects, touches, crosses, overlaps, disjoint) via Shapely; shape-preserving buffers; union, intersection, difference, centroid and convex hull; CRS transformation of every input geometry |\n| **Places and terrain** | Geocoding and reverse geocoding (OpenStreetMap Nominatim, within its usage policy: at most one request per second, cached); elevation from Copernicus GLO-30 with an Open Elevation fallback and vertical-datum awareness (NAVD88, EGM96, WGS 84); routing (OSRM); isochrones on foot, by bicycle or by car, and travel-time matrices (Valhalla) |\n| **Official datums and heights** | Coordinate conversions with the published transformations and grids as implemented in PROJ (GDA94 to GDA2020, MGA zones, British National Grid via OSTN15, NAD27 to NAD83 via NADCON, ITRF at an epoch) and heights between the ellipsoid and AHD, ODN, NZVD2016 or NAVD88 with the official geoid models. Grids are downloaded once; PROJ's silent ballpark fallbacks are refused; every result states the operation used and its accuracy |\n| **Sun and terrain** | Sun position and shadows (NOAA solar position, refraction included), sunrise and sunset, and hours of direct sun allowing for the terrain horizon; terrain horizon profiles and line of sight on the Copernicus DEM with Earth curvature and refraction, including first Fresnel zone clearance for radio links |\n| **Dataset checks** | Vector files (GeoJSON, GeoPackage, Shapefile, GeoParquet, CSV and others), LiDAR (LAS, LAZ, COPC) and elevation models (GeoTIFF, COG): CRS consistency, axis order, zone, datum, units and height reference, and diagnosis of the offset between two datasets or between a surface and survey marks |\n| **Live data** | Overture Maps places, buildings, roads and administrative areas (no key, via DuckDB); weather and forecasts (Open-Meteo, no key); air quality (OpenAQ v3, free key); active fires (NASA FIRMS, free key); satellite scene search (STAC) |\n| **Agents** | `GeoAgent` (multi-step analysis from a plain-language goal), `SpatialReport` (a location dossier: amenities, accessibility, elevation, weather), `SiteSelector` (multi-criteria site ranking), and a coordinator that routes a goal to the appropriate agent |\n| **Interfaces** | Python library, command-line interface (`geospark …`), REST API (59 documented operations, reference at `/docs`), MCP server (`geospark-mcp`), web interface |\n| **Memory and workflows** | Spatial memory with facts, episodes and contradiction detection; a tiered context store; Flows (directed acyclic workflows, constructible from a natural-language goal) |\n| **GeoSpark Bench** | 535 reproducible questions across topology, distance, multi-step reasoning, change and multimodal tasks, with computed ground truth |\n\n## Access and installation\n\n**Website.** [geospark.terrascout.app](https://geospark.terrascout.app) provides question\nanswering, reachable-area mapping and Overture Maps exploration around any location. Every result\nshows its sources, caveats and units, and can be downloaded as GeoJSON.\n\n**Hosted REST API** (open, no key required; [API reference](https://geospark.terrascout.app/docs)):\n\n```bash\ncurl -X POST https://geospark.terrascout.app/api/v1/distance \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"lat_a\": 48.8584, \"lon_a\": 2.2945, \"lat_b\": 48.8606, \"lon_b\": 2.3376}'\n# {\"distance_m\": 3172.01, \"distance_km\": 3.172, \"note\": \"Geodesic distance on WGS84 ellipsoid\"}\n```\n\n**Hosted MCP server.** In Claude or any MCP client, add a custom connector with the following URL\n(in Claude: Settings → Connectors → Add custom connector):\n\n```\nhttps://geospark.terrascout.app/mcp\n```\n\nThe hosted server is open, requires no key, offers 21 tools and applies the same rate limits as the\nhosted API. It does not read files; tools for local files are available only from a locally run\nserver.\n\n**Local MCP server** (Claude Desktop or any MCP client):\n\n```bash\npip install \"geospark-ai[mcp]\"\n```\n\nRegister the server in the MCP client's configuration. For Claude Desktop, the file is\n`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS and\n`%APPDATA%\\Claude\\claude_desktop_config.json` on Windows:\n\n```json\n{ \"mcpServers\": { \"geospark\": { \"command\": \"geospark-mcp\" } } }\n```\n\nThe local server provides 25 read-only tools:\n\n- **Computation:** `calculate_distance` (between place names, geocoded by the server, or\n  coordinates), `check_spatial_relationship`, `spatial_operation` (buffer, area, length, centroid,\n  convex hull), `transform_coordinates` and `convert_height`.\n- **Places and data:** `geocode`, `reverse_geocode`, `get_elevation`, `route`, `get_weather`,\n  `get_air_quality`, `get_active_fires` and `overture_features`.\n- **Sun and terrain:** `sun_position`, `sun_times`, `terrain_horizon` and `line_of_sight`.\n- **Analysis:** `isochrone`, `travel_time_matrix`, `location_report` and `select_site`.\n- **Local files (local server only):** `check_dataset`, `layer_info`, `layer_count_within` and\n  `layer_nearest`.\n\nResults are returned as structured content with output schemas, usually GeoJSON, and carry\nprovenance (source, licence, attribution), units and warnings. When a result cannot be computed\nreliably (invalid geometry, an unknown place, an unavailable data source), the tool returns an\nerror stating the reason, so that the model can report it instead of estimating. The server uses\nMCP SDK 2.x (protocol 2026-07-28) and remains compatible with clients on earlier protocol versions.\n\n**Python library:**\n\n```bash\npip install geospark-ai                  # core\npip install \"geospark-ai[api,mcp]\"       # REST server and MCP server\npip install \"geospark-ai[all]\"           # all optional components\n```\n\n**Docker:**\n\n```bash\ndocker run -p 8000:8000 ghcr.io/maz2580/geospark:latest\n# then open http://localhost:8000\n```\n\nThis image runs in development mode, in which operator routes are open; it is intended for local\nuse. A public deployment requires `-e GEOSPARK_ENV=production -e GEOSPARK_ADMIN_TOKEN=<secret>`\n(see [Self-hosting](#self-hosting)).\n\n## Usage\n\n### Geometry\n\n```python\nfrom geospark.engine.spatial_reasoner import SpatialReasoner\n\npark = {\"type\": \"Polygon\", \"coordinates\": [[[2.29, 48.85], [2.30, 48.85], [2.30, 48.86], [2.29, 48.86], [2.29, 48.85]]]}\npoint = {\"type\": \"Point\", \"coordinates\": [2.295, 48.855]}  # GeoJSON order: [lon, lat]\n\nSpatialReasoner.check_relationship(park, point, \"contains\")  # True\n\nSpatialReasoner.calculate_distance(\n    {\"type\": \"Point\", \"coordinates\": [2.2945, 48.8584]},  # Eiffel Tower\n    {\"type\": \"Point\", \"coordinates\": [2.3376, 48.8606]},  # Louvre\n)  # 3172.01 (metres, geodesic)\n```\n\n### Queries through the engine\n\n```python\nfrom geospark import Engine\nfrom geospark.protocol import SpatialOperation, SpatialQuery\n\nengine = Engine(tools=[\"geocoder\", \"terrain\"])\n\nplace = engine.execute(SpatialQuery(operation=SpatialOperation.GEOCODE,\n                                    metadata={\"query\": \"Federation Square, Melbourne\"}))\nprint(place.features[0].geometry)  # {'type': 'Point', 'coordinates': [144.969..., -37.817...]}\n\n# Inputs in another CRS are transformed to WGS 84 first, including the second geometry\nbuffered = engine.execute(SpatialQuery(operation=SpatialOperation.BUFFER,\n                                       geometry={\"type\": \"Point\", \"coordinates\": [2.2945, 48.8584]},\n                                       radius_m=100))\nprint(buffered.features[0].properties[\"area_sq_m\"])  # ≈ 31,365\n```\n\n### Verifiable results\n\n```python\nresult = engine.execute(SpatialQuery(operation=SpatialOperation.GEOCODE, metadata={\"query\": \"Springfield\"}))\nresult.status        # \"ok\" | \"partial\" | \"cannot_answer\"\nresult.warnings      # [\"Ambiguous place name: 'Springfield, …, Illinois…'; '…Massachusetts…' …\"]\nresult.provenance    # source, licence (ODbL-1.0), attribution, retrieved_at, method\nresult.spatial_context.units  # {\"distance_m\": \"metre\", ...}\n\nimport json\njson.dump(result.to_geojson(), open(\"springfield.geojson\", \"w\"))  # RFC 7946; opens directly in QGIS\n```\n\nInvalid input is refused rather than approximated: a self-intersecting polygon, out-of-range\ncoordinates or a probable [latitude, longitude] swap returns `cannot_answer` with the reason. From\nthe API, GeoJSON output is requested with `?format=geojson` (or `Accept: application/geo+json`);\nfrom the CLI, with `--geojson out.geojson` on `geocode`, `elevation`, `query` or `report`. A\nlocation report exported this way becomes a map layer containing the place and every nearby\namenity, with distances.\n\n### Open map data: Overture Maps\n\n```python\nfrom geospark.tools.overture.client import query   # pip install \"geospark-ai[overture]\"\n\nquery(\"divisions\", 48.8606, 2.3376)   # France › Île-de-France › Paris › Paris 1er Arrondissement\nquery(\"places\", -37.8180, 144.9691, radius_m=200, category=\"cafe\")  # nearest first, geodesic metres\nquery(\"buildings\", -37.8180, 144.9691, radius_m=150)  # footprints, height, geodesic footprint area\nquery(\"transportation\", -37.8180, 144.9691, radius_m=150)  # road and path segments, geodesic length\n```\n\n[Overture Maps](https://overturemaps.org) GeoParquet is queried directly on S3 with DuckDB, using\nthe release's STAC index to read only the files that cover the requested area; no download or key\nis required. Results report the Overture release and the licence of the returned records. The same\ndata is available from the CLI (`geospark overture places -37.818 144.969 --category cafe`), the\nREST API (`POST /api/v1/overture/{theme}`) and the MCP tool `overture_features`.\n\n### Datum and height conversions\n\n```python\nfrom geospark import geodesy\n\n# A drone's GNSS height at Federation Square, in the Australian Height Datum (AUSGeoid2020)\ngeodesy.convert_height(-37.8177, 144.9691, 40.0, \"ellipsoidal\", \"AHD\")\n# {'height_m': 35.2574, 'separation_m': 4.7426, 'geoid_model': 'AUSGeoid2020', 'accuracy_m': 0.15, ...}\n\n# GDA94 / MGA zone 55 to GDA2020 / MGA zone 55 (a 1.55 m shift), with the EPSG operation used\ngeodesy.transform(\"EPSG:28355\", \"EPSG:7855\", [[321242.045, 5812466.767]]).as_dict()\n```\n\nPROJ falls back silently when a grid is missing: AHD becomes the ellipsoidal height (4.7 m in\nerror in Melbourne) and British National Grid coordinates move by about 124 m. GeoSpark downloads\nthe required grid or refuses the conversion. The conversions are also available as\n`POST /api/v1/transform`, `POST /api/v1/height` and the MCP tools `transform_coordinates` and\n`convert_height`. Grids are cached in `$GEOSPARK_DATA_DIR/proj`; `GEOSPARK_PROJ_NETWORK=false`\ndisables downloads. Agreement with the national agencies' own results is reported under\n[Validation against authoritative sources](#validation-against-authoritative-sources).\n\n### Sun, shadows and terrain visibility\n\n```python\nfrom geospark import sun, visibility\n\nsun.shadow(-37.818, 144.969, \"2026-06-21T12:00:00\", object_height_m=10, tz=\"Australia/Melbourne\")\n# {'elevation_deg': 28.5587, 'azimuth_deg': 5.7072, 'shadow_length_m': 18.373, 'shadow_direction_deg': 185.7072, ...}\n\nsun.sunlight_hours(-36.7297, 146.9608, \"2026-06-21\", tz=\"Australia/Melbourne\")   # Bright, Victoria, midwinter\n# {'flat_horizon_hours': 9.63, 'terrain_hours': 8.83, 'first_direct_sun': '2026-06-21T08:02+10:00', ...}\n\nvisibility.horizon(-36.7297, 146.9608, step_deg=5)            # skyline angle and obstruction distance per azimuth\nvisibility.line_of_sight(-37.8333, 145.35, -37.70, 145.10, height_a_m=100, height_b_m=10, frequency_mhz=900)\n```\n\nThe Sun's position follows NOAA's solar calculator (after Meeus) and agrees with NREL's Solar\nPosition Algorithm to 0.014° in elevation (Sun above 5°); sunrise and sunset agree with NREL-based crossings to\nwithin 5 s. Horizons are traced across the Copernicus GLO-30 DEM with Earth curvature and standard\nrefraction (k = 0.13) and agree with the European Commission's PVGIS horizon profiles to a mean of\n0.2–0.9° at six of eight test sites (details under\n[Validation against authoritative sources](#validation-against-authoritative-sources)). A line of\nsight reports whether two points are intervisible, the minimum clearance and its location, and,\nwith `frequency_mhz`, the first Fresnel zone clearance. The same functions are available as\n`POST /api/v1/sun`, `/api/v1/sun/times`, `/api/v1/sun/hours`, `/api/v1/horizon` and\n`/api/v1/line-of-sight`, the CLI commands `geospark sun`, `geospark horizon` and `geospark los`, and\nthe MCP tools listed above. The approach to terrain rays follows\n[geo-sleuth](https://github.com/Oldcircle/geo-sleuth) (MIT); the implementation is GeoSpark's own.\n\n### Users' own data\n\n```bash\npip install \"geospark-ai[io]\"\ngeospark layer info suburbs.gpkg                            # features, fields, CRS, geodesic area\ngeospark layer count-within crashes.csv suburbs.gpkg --geojson counts.geojson\ngeospark layer nearest schools.shp hospitals.geojson\ngeospark layer intersect parcels.gpkg flood_zones.geojson\n```\n\nSupported formats are GeoJSON, GeoPackage, Shapefile (or a .zip), GeoParquet, CSV with\nlongitude/latitude columns, FlatGeobuf, KML, GML and File Geodatabase. **The CRS is taken from the\nfile, never assumed silently:**\n\n- a projected file (e.g. MGA zone 55) is reprojected, and its original CRS is reported;\n- a Shapefile without a `.prj` file is refused until a CRS is given (`--crs EPSG:28355`);\n- a CSV cannot declare a CRS: longitude/latitude columns are read as WGS 84 and the result states\n  this (`--crs` overrides it for GDA94 or another datum); x/y columns are refused until a CRS is\n  given;\n- invalid geometries are refused unless `--repair` is passed.\n\nResults include each file's SHA-256 digest, so that an analysis can be reproduced against exactly\nthe same data. In Python, `geospark.io.layers` provides `load_layer`, `count_within`, `nearest` and\n`intersect`; the local MCP server provides the same operations as `layer_*` tools.\n\n### Dataset checks\n\n```bash\ngeospark layer check pipes.csv --crs EPSG:7855                          # CRS, datum and height caveats\ngeospark layer check pipes.csv --crs EPSG:7855 --against parcels.csv --against-crs EPSG:7855\n# problem: pipes.csv sits 1.55 m from parcels.csv, matching the official GDA94 to GDA2020 shift here.\n#          pipes.csv holds GDA94 coordinates but is treated as the same datum as parcels.csv.\n```\n\nA single file is checked for errors that produce no failure, only wrong numbers: no declared CRS,\nprojected values labelled as degrees (or the reverse), swapped latitude and longitude, points\noutside the CRS's area of use, Australian data labelled WGS 84, and heights that do not state\nwhether they are ellipsoidal or AHD. With `--against`, the systematic offset to a reference layer\nis measured and a cause is named only when the measured numbers match it: GDA94 data labelled\nGDA2020 (compared with the official shift at that location), the wrong MGA zone, feet instead of\nmetres, swapped axes, a plate-motion epoch, or ellipsoidal against AHD heights. Any other offset is\nreported as unexplained.\n\nOn 2,000 published Victorian survey marks, the check identified GDA94 coordinates labelled GDA2020\n(1.55 m, scatter 3 cm), a wrong zone, and ellipsoidal against AHD heights, and reported no problem\nwhen the same marks were labelled correctly (residual 6 mm). The command exits with code 2 when it\nfinds a problem, for use in data pipelines. The Python interface is\n`geospark.io.check.check_layer` and `compare_layers`; the MCP tool is `check_dataset`.\n\n**LiDAR point clouds and elevation models** (LAS, LAZ, COPC, GeoTIFF and COG, local or by URL) are\nchecked in the same way with the `pointcloud` extra, which reads them with\n[terrio](https://github.com/opengeos/terrio) (pure Rust, without GDAL or PDAL):\n\n```bash\npip install \"geospark-ai[pointcloud]\"\ngeospark layer check tile.laz                                   # CRS, vertical CRS, height units, ground class\ngeospark layer check tile.laz --against marks.csv --against-crs EPSG:7855\n# problem: Heights differ by +4.74 m, the AUSGeoid2020 separation here (4.74 m): tile.laz has\n#          ellipsoidal (GNSS) heights and the other AHD heights.\n```\n\nAgainst survey marks with heights, the surface height at each mark (the median of ground-classified\npoints within 1 m, or the DEM cell) is compared with the mark's height. Ellipsoidal heights labelled\nas AHD, ODN, NZVD2016 or NAVD88, heights in feet, and an undeclared vertical CRS are identified.\nterrio is used only for reading: datum and height conversions remain in GeoSpark's own geodesy\nmodule, because terrio, like PROJ, keeps a height unchanged when a geoid grid is missing.\n\n### Reproducible analyses\n\n```bash\ngeospark record report \"Federation Square, Melbourne\" --out fedsq.json\ngeospark replay fedsq.json\n# REPRODUCED: Same result as recorded.\n# ... or DATA CHANGED: openstreetmap-overpass returned different data for the same request (step 6 ...)\n# ... or CODE CHANGED: versions changed: {'shapely': ('2.0.0', '2.2.0')}\n```\n\nA run record contains:\n\n- the environment: GeoSpark, Python, pyproj/PROJ and Shapely/GEOS versions;\n- every data request (Nominatim, Overpass, Overture, Valhalla, weather, elevation) and every engine\n  computation, each with a fingerprint of its input and output;\n- a fingerprint of the final result.\n\n`geospark replay` re-runs the analysis and reports whether it reproduced; if not, it identifies the\nsource that returned different data, or the code or library version that changed. The Python\ninterface is `geospark.runs.record_run(...)` and `replay(...)`.\n\n### Natural-language questions\n\n```python\nresult = engine.ask(\"How far is the Eiffel Tower from Big Ben?\")\nprint(result.spatial_context.summary)\nprint(result.sources)  # the model, then each tool it called\n```\n\n`ask` uses a local [Ollama](https://ollama.com) model by default and falls back to free OpenRouter\nmodels when `OPENROUTER_API_KEY` is set. Each answer is checked against the tool results: every\nstated quantity must be traceable to a tool result (allowing unit changes, rounding, and sums or\ndifferences of results), an empty answer is `cannot_answer`, and comparative claims (\"X is closer\")\nare checked against the measured distances.\n\n### Agents\n\n```python\nfrom geospark.agents import AgentCoordinator, GeoAgent, SiteSelector, SpatialReport\n\nGeoAgent().run(\"Find all hospitals within 2km of the Eiffel Tower\").findings[\"nearby\"]\n\nreport = SpatialReport().analyze(\"Federation Square, Melbourne\", radius_m=1000)\nprint(report.weather, report.accessibility, report.data_gaps)\n\nbest = SiteSelector().find(within=\"Zurich\", near=[\"hospital\", \"school\"], facility_type=\"pharmacy\").best\n\nAgentCoordinator().run(\"Find the best location for a cafe in Melbourne CBD near schools\")\n```\n\nAgents use a language model to interpret the goal. Without one, a rule-based parser handles\nrequests such as those above, including multi-word place names, radii such as \"2km\", and amenity\ntypes.\n\n### Memory, context and flows\n\n```python\nfrom geospark.memory import SpatialIntelligence\n\nintel = SpatialIntelligence()\nintel.remember_fact(\"Eiffel Tower is at 48.8584 N, 2.2945 E\", source=\"user\")\nintel.remember_episode(\"PM2.5 was 120 in Delhi\", importance=0.8, source=\"tool:air_quality\")\nintel.recall(\"Where is the Eiffel Tower?\", limit=5)  # semantic with Ollama embeddings, keyword match without\nintel.find_contradictions()\n```\n\n```python\nfrom geospark.flows import ChatFlowSession, FlowRunner, make_ollama_chat_fn\n\nsession = ChatFlowSession(llm_fn=make_ollama_chat_fn(\"qwen2.5:7b\"))\nresult = session.run(\"Geocode Valencia, Spain then check its elevation\")\nif result.flow:\n    FlowRunner().run(result.flow)  # a validated DAG, built one tool call at a time\n```\n\n## Command-line interface\n\n```bash\ngeospark distance -37.8136 144.9631 -33.8688 151.2093   # Melbourne to Sydney: 713.86 km\ngeospark check contains '<geojson A>' '<geojson B>'\ngeospark geocode \"Tokyo Tower, Japan\"\ngeospark elevation -37.8177 144.9691\ngeospark isochrone -37.8180 144.9691 --minutes 5,10,15 --mode walk\ngeospark ask \"Is Tokyo closer to Seoul or Beijing?\"\n\ngeospark agent \"Find all parks within 2km of Big Ben\"\ngeospark report \"Federation Square, Melbourne\"\ngeospark site-select --within \"Paris\" --near \"school,hospital\" --facility pharmacy\ngeospark multi-agent \"Analyze Federation Square\" --stream\n\ngeospark data weather \"Melbourne, Australia\"\ngeospark data air-quality \"Delhi\"        # requires OPENAQ_API_KEY\ngeospark data fires \"Amazon Rainforest\"  # requires FIRMS_MAP_KEY\n\ngeospark layer check tile.laz --against marks.csv --against-crs EPSG:7855\ngeospark flow list\ngeospark flow build \"Geocode Valencia then check its elevation\" --run\ngeospark info                            # versions and optional components\n```\n\n## Evaluation\n\n### GeoSpark Bench v2\n\nGeoSpark Bench v2 evaluated nine models under three protocols: the bare question, step-by-step\nreasoning (chain of thought), and the question with GeoSpark's MCP tools attached. The models were\nfive open-weight models of at most 9B parameters (Qwen 2.5 7B, Llama 3.1 8B, Gemma 2 9B, Mistral\n7B, Phi-3.5 3.8B), gpt-oss 20B, and three frontier APIs (GPT-5.4, Claude Sonnet 4.5, Gemini 2.5\nPro). Intervals are Wilson 95% intervals, and the per-call cost ledger (about US$41) is included in\nthe repository. The full analysis is in a manuscript under peer review.\n\n**Numeric distance** (`distance_absolute`, n = 80 per model; correct = within 10% of the\n`pyproj.Geod.inv()` geodesic):\n\n| Model | Tier | Bare LLM | Step-by-step | With GeoSpark tools |\n|---|---|:---:|:---:|:---:|\n| Qwen 2.5 7B | open ≤9B | 5.0% | 20.0% | **76.2%** |\n| Llama 3.1 8B | open ≤9B | 5.0% | 21.2% | 7.5% ᵃ |\n| Gemma 2 9B | open ≤9B | 37.5% ᵇ | 6.2% | — |\n| Mistral 7B | open ≤9B | 2.5% | 2.5% | 0.0% ᵃ |\n| Phi-3.5 3.8B | open ≤9B | 1.2% | 6.2% | — |\n| gpt-oss 20B | open mid | **93.8%** | 93.8% | 91.2% |\n| GPT-5.4 | frontier | 95.0% | 90.0% | 95.0% |\n| Claude Sonnet 4.5 | frontier | 73.8% | 92.5% | **96.2%** |\n| Gemini 2.5 Pro | frontier | 25.0% | 55.0% | 85.0% |\n\nᵃ The tools were available but not used correctly: Llama confused kilometres and metres in the\nreturned coordinates, and Mistral answered in plain text. ᵇ Gemma's correct answers are\nwell-known long-distance pairs, indicating recall of training data rather than computation.\n— denotes not evaluated with tools.\n\n**Findings**\n\n- **Tools act as a leveller.** Bare-model accuracy ranges from 1% to 95%; with tools, every model\n  that issues valid tool calls reaches 76–96%. Qwen gained 71 percentage points; GPT-5.4 gained\n  none, having already reached 95%.\n- **Aggregate scores conceal bias.** Qwen's 56% on topology results from a tendency to answer\n  \"yes\": it scores 93% on `intersects` (where the data skews true) and 3% on `disjoint`. GPT-5.4\n  scores 0% on `touches`. With GeoSpark, every predicate is answered correctly, because the engine\n  computes it.\n- **Step-by-step prompting is not a general remedy.** It improves some models and degrades others.\n- **Knowledge questions do not require tools.** Questions such as \"Did the Amazon lose forest\n  between 2015 and 2023?\" are answered well at every model scale without them.\n\nThe benchmark is re-run with `python -m geospark.bench run`, and the datasets regenerate\nidentically with `python -m geospark.bench.generate_datasets`. Provenance is documented in the\n[datasets README](https://github.com/Maz2580/geospark/blob/main/geospark/bench/datasets/README.md)\nand every table in the\n[benchmark report](https://github.com/Maz2580/geospark/blob/main/docs/BENCHMARK_REPORT.md).\n\n### Re-evaluation with current models (October 2026)\n\nA sample of GeoSpark Bench was re-run on current frontier models and on open models served from a\nlocal workstation, together with a new test of datum and height conversions. The questions, the\nscripts that compute the reference answers, the harness and the scored results are in\n[docs/benchmarks/2026-10](https://github.com/Maz2580/geospark/tree/main/docs/benchmarks/2026-10).\n\n**GeoSpark Bench sample** (20 numeric-distance questions and 18 topology questions, three per\npredicate, coordinates given; distance correct within 10% of the `pyproj` geodesic, topology exact):\n\n| Model | Deployment | Without tools: distance / topology | With GeoSpark tools |\n|---|---|:---:|:---:|\n| Claude Opus 5.5 | API | 100% / 100% | 100% / 100% |\n| GPT-6.1 | API | 95% ¹ / 100% | 100% / 100% |\n| gpt-oss 20B | local workstation | 95% / 100% | 100% / 100% |\n| Qwen 3.5 4B | local workstation | 90% / 100% | 100% / 100% |\n| Qwen 3.5 9B | local workstation | — ² | 100% / 94% |\n| Phi-4-mini 3.8B | local workstation | 15% / 50% | 20% / 50% ³ |\n\n¹ One reply exhausted its length limit while reasoning and gave no number. ² Most replies timed\nout on a heavily loaded workstation, so the run does not constitute a fair score. ³ The model never\ncalled the tools. Gemma 4 12B timed out in the same way and is omitted.\n\nCurrent frontier models answer these questions correctly without tools, and with tools a 4B model\non a workstation matches them.\n\n**Datum and height conversions** (32 questions in Australia, the United Kingdom, New Zealand and\nthe United States; reference answers computed with PROJ's most accurate EPSG operations and the\nnational grids, and checked against the agencies' own services where available: GDA94 to GDA2020,\nMGA zones, ITRF2014 to GDA2020 at an epoch, ellipsoidal heights to AHD, ODN, NZVD2016 and NAVD88,\nOSTN15, NAD27 to NAD83). Survey-grade denotes agreement within 5 cm, or within the geoid model's\nstated accuracy for heights:\n\n| Model | Without tools: survey-grade | within 0.5 m | wrong | declined | With GeoSpark's geodesy tools |\n|---|:---:|:---:|:---:|:---:|:---:|\n| Claude Opus 5.5 | 15 | 7 | 8 (up to 5.3 m off) | 2 ⁴ | **31 survey-grade, 1 within 6 cm** |\n| GPT-6.1 | 13 | 2 | 1 (1.65 m off) | 16 | **32 survey-grade** |\n\n⁴ Two British National Grid answers exceeded an 8,000-token reply limit while computing by hand.\n\nBecause GeoSpark's tools also use PROJ, the with-tools column shows that the models used the tools\ncorrectly; the correctness of PROJ's answers is established separately (see\n[Validation against authoritative sources](#validation-against-authoritative-sources)). For the two\nNAD27 questions, the reference is itself accurate only to about 0.27 m (NGS's stated uncertainty),\nso \"survey-grade\" there denotes agreement with PROJ's NADCON rather than centimetre-level truth.\n\nBoth models apply datum parameters, plate motion and map projections to the millimetre. Answering\ndirectly, neither can read the national grids on which heights and some datum conversions depend:\nGPT-6.1 declines (\"I cannot access the AUSGeoid2020 grid\") and Opus 5.5 estimates from memory.\nGeoSpark's [`transform_coordinates` and `convert_height`](#datum-and-height-conversions) close this\ngap.\n\n**Fairness checks** ([details](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/fairness.md)).\nThe gap persists with the providers' own code execution at high effort (their sandboxes include\nPROJ but no internet access; GPT-6.1 declined all 17 grid questions, and Opus 5.5 estimated with\nerrors of up to 9.9 m) and across three repeated runs. It does **not** persist when these frontier\nmodels write a script that is executed with internet access: Opus 5.5 completed all eight everyday\nGIS scripting tasks correctly, AHD included, and GPT-6.1 completed seven and failed safely on the\neighth. The silently incorrect scripts came from the smaller local models: gpt-oss 20B reported the\nellipsoidal height unchanged as \"AHD\", and Qwen 3.5 4B completed one of eight tasks. Web browsing\nwas not tested. The API cost of the October 2026 re-evaluation was US$8.46.\n\n**Small local models with GeoSpark's tools**\n([details](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/small_models.md)).\nQwen 3.5 4B and 9B, gpt-oss 20B and Gemma 4 12B answered ten questions each through GeoSpark's\nfull tool set and answer checks. The measure of interest is the number of incorrect answers still\nreported as `ok`. Before the changes, 4 of 30 answers returned no text and were reported as `ok`;\nother failures included quantities absent from every tool result, a comparison contradicting the\nmeasured distances, and \"Gold Coast\" resolved to Ghana. After the changes, 120 answers over three\nruns contained one silent error (the Ghana case, since fixed); the remaining non-clean results were\ncorrectly reported as `partial` or `cannot_answer`.\n\n## Validation against authoritative sources\n\nGeoSpark's geodetic results were compared with the agencies' published calculators and test data,\nnot only with PROJ\n([details](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/agency_checks.md)):\n\n| Reference | Comparison | Agreement |\n|---|---|---|\n| Geoscience Australia calculators | GDA94 to GDA2020 and ATRF2014 to GDA2020, eight points | within 0.1 mm |\n| Geoscience Australia AUSGeoid2020 | ellipsoidal to AHD heights, three cities | within 2 mm |\n| Ordnance Survey OSTN15/OSGM15 test points | 40 points | grid within 8 mm, heights within 2 mm |\n| LINZ NZVD2016 examples | published example points | within their 1 cm rounding |\n| NGS GEOID18 service | ellipsoidal to NAVD88 | 1 mm |\n| Victorian surveyed marks | GDA94 to GDA2020, 416 marks | within 7 mm |\n| Victorian spirit-levelled marks | AUSGeoid2020 against levelled AHD, 99 marks | 6 mm median |\n| NREL Solar Position Algorithm (pvlib) | apparent Sun elevation, 903 random cases above 5° | 0.014° maximum |\n| NREL SPA, crossings sampled each second | sunrise and sunset, 899 events | 4.7 s maximum, 1.1 s median |\n| PVGIS 5.3 horizon profiles | terrain horizon at 8 mountain and valley sites | 0.2–0.9° mean at 6 sites; 1.5° (urban) and 3.1° (sheer valley) at 2 |\n\nThe accuracy reported with each geodetic result is the accuracy EPSG states for the operation, not\na measurement. The sun and horizon comparisons are reported in full in\n[docs/benchmarks/2026-10/sun_horizon.md](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/sun_horizon.md).\n\n## Limitations\n\n- **NAD27 to NAD83** uses the original NADCON grids, about 0.27 m from NGS's current NADCON 5 in\n  San Francisco (within NADCON 5's stated uncertainty).\n- **AHD** is refused outside mainland Australia and Tasmania. OSGM15 heights on the Isles of\n  Scilly, the Isle of Man, the Outer Hebrides, Orkney and Shetland are labelled with their local\n  datum, not ODN.\n- **LiDAR and DEM checks** have been tested on synthetic tiles and elevation models containing each\n  error, and on reading the header of a public 10.6-million-point COPC file by URL (horizontal and\n  vertical CRS in about 2 s). They have not yet been validated on a real delivery with known errors.\n- **Terrain horizons and lines of sight** use a ~30 m surface model, which includes canopy and large\n  buildings coarsely and nothing narrower than a cell; in sheer-walled valleys the horizon can differ\n  from other DEMs by several degrees. Line of sight has been tested on synthetic terrain only.\n- **Planned, not yet implemented:** slope, full viewshed maps, raster band mathematics and change\n  detection.\n  These operations are defined in the protocol but currently return an explicit \"not implemented\"\n  error rather than a result.\n- **Evaluation scope:** the October 2026 re-evaluation used samples (38 GeoSpark Bench questions,\n  32 datum and height questions, eight scripting tasks, ten small-model questions); results on\n  other question types and prompts may differ.\n\n## Self-hosting\n\n```bash\ngit clone https://github.com/Maz2580/geospark && cd geospark\ncp .env.example .env     # set GEOSPARK_ENV=production and GEOSPARK_ADMIN_TOKEN\ndocker compose -f docker-compose.prod.yml up -d\n```\n\nThe hosted API is designed to remain **open** without a key, with the following safeguards:\n\n- An admin token protects operator routes (audit, usage, flow storage). The LLM gateway requires\n  it or a revocable partner key.\n- Public callers can select only the local model or free models, so paid API credit cannot be\n  consumed by third parties.\n- Each visitor receives a private, size-capped, automatically expiring sandbox for the memory and\n  context demonstrations.\n- Rate limits use the real client IP address, and language-model work has a concurrency limit.\n- Inputs are bounded, and the audit log stores hashed IP addresses.\n\nAll state is stored under `GEOSPARK_DATA_DIR`. To lift Nominatim's public limits,\n`GEOSPARK_NOMINATIM_URL` can point to a self-hosted server. Details are in\n[deploy/DEPLOY.md](https://github.com/Maz2580/geospark/blob/main/deploy/DEPLOY.md#security) and\n[`.env.example`](https://github.com/Maz2580/geospark/blob/main/.env.example).\n\n## Release history\n\n- **Unreleased:** sun position, sunrise, shadows and terrain-aware hours of sun; terrain horizons\n  and lines of sight with Fresnel clearance; LiDAR and DEM dataset checks (`pointcloud` extra);\n  compound-CRS area checks; national geoid selection for height comparisons.\n- **0.10.0:** official datum and height conversions with national grids; `geospark layer check`\n  for vector datasets; answer checks for small models; validation against national agencies.\n- **0.9.0:** testing of every agent, tool and flow template on the live site; place-name inputs\n  for question-answering tools; a self-hosted OpenStreetMap server; the hosted MCP server, listed\n  in the official MCP Registry.\n- **0.8.0:** Overture Maps buildings, places and roads through DuckDB; isochrones and travel-time\n  matrices; users' own GeoJSON, GeoPackage and GeoParquet; reproducible run records; a redesigned\n  website with a Proof panel on every result.\n- **0.7.0:** verifiable answers: source, licence, timestamp, CRS and units on every result;\n  `cannot_answer` instead of an estimate; data-quality flags; QGIS-ready GeoJSON; the MCP server\n  updated to the 2026 specification.\n- **Planned:** external benchmarks (GPSBench, GeoBenchX), a QGIS bridge, validation of the LiDAR\n  checks on real deliveries, and further MCP tools.\n\nFull release notes are in [CHANGELOG.md](https://github.com/Maz2580/geospark/blob/main/CHANGELOG.md).\n\n## Data attribution\n\nGeoSpark computes geometry locally but retrieves data from public services. Results derived from\nthem remain subject to the providers' licences and should credit them when published:\n\n- **OpenStreetMap** (geocoding, amenities, map tiles): © OpenStreetMap contributors, [ODbL](https://www.openstreetmap.org/copyright)\n- **Copernicus DEM GLO-30** (elevation): © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018, provided under COPERNICUS by the European Union and ESA\n- **Open-Meteo** (weather): [CC BY 4.0](https://open-meteo.com/)\n- **OpenAQ** (air quality) and **NASA FIRMS** (active fires): subject to each provider's terms;\n  air-quality results include each station's licence.\n\nElevation results include an `attribution` field for direct citation. The full list is in\n[NOTICE](https://github.com/Maz2580/geospark/blob/main/NOTICE).\n\n## Development\n\n```bash\ngit clone https://github.com/Maz2580/geospark && cd geospark\npython -m venv .venv && source .venv/bin/activate\npip install -e \".[dev,api,mcp]\"\npytest                       # about 1,250 tests; offline, never writing to ~/.geospark\nruff check geospark tests\n```\n\nContributions are welcome; see\n[CONTRIBUTING.md](https://github.com/Maz2580/geospark/blob/main/CONTRIBUTING.md). Security issues\nshould be reported privately as described in\n[SECURITY.md](https://github.com/Maz2580/geospark/blob/main/SECURITY.md). The architecture is\ndescribed in [docs/ARCHITECTURE.md](https://github.com/Maz2580/geospark/blob/main/docs/ARCHITECTURE.md).\n\n## Citation\n\nWork that uses GeoSpark or GeoSpark Bench should cite it. GitHub's **\"Cite this repository\"**\nbutton provides the reference from\n[CITATION.cff](https://github.com/Maz2580/geospark/blob/main/CITATION.cff).\n\n## Author\n\n**Mazdak Ghasemi Tootkaboni** ([University of Melbourne](https://www.unimelb.edu.au/))\n· ORCID [0000-0001-8084-5270](https://orcid.org/0000-0001-8084-5270) · GitHub [@Maz2580](https://github.com/Maz2580)\n\n## License\n\n[Apache 2.0](https://github.com/Maz2580/geospark/blob/main/LICENSE). Copyright 2024-2026 Mazdak Ghasemi Tootkaboni.\n"}