Metadata-Version: 2.5
Name: geospark-ai
Version: 0.10.0
Summary: Verifiable spatial reasoning for LLMs: geodesic distance, topology, isochrones and Overture Maps data computed by real tools, with sources and licences on every answer. MCP server, REST API and Python library.
Project-URL: Homepage, https://github.com/Maz2580/geospark
Project-URL: Documentation, https://github.com/Maz2580/geospark#readme
Project-URL: Repository, https://github.com/Maz2580/geospark
Project-URL: Issues, https://github.com/Maz2580/geospark/issues
Author-email: Mazdak Ghasemi Tootkaboni <M.GhasemiTootkaboni@unimelb.edu.au>
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: ai,geoai,geojson,geospatial,gis,llm,mcp,mcp-server,openstreetmap,protocol,remote-sensing,spatial-reasoning
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.10
Requires-Dist: click>=8.0
Requires-Dist: httpx>=0.25
Requires-Dist: numpy>=1.24
Requires-Dist: pydantic>=2.0
Requires-Dist: pyproj>=3.6
Requires-Dist: python-dotenv>=1.0
Requires-Dist: rich>=13.0
Requires-Dist: shapely>=2.0
Provides-Extra: all
Requires-Dist: anthropic>=0.18; extra == 'all'
Requires-Dist: duckdb>=0.10; extra == 'all'
Requires-Dist: duckdb>=1.1; extra == 'all'
Requires-Dist: faiss-cpu>=1.7; extra == 'all'
Requires-Dist: fastapi>=0.110; extra == 'all'
Requires-Dist: fiona>=1.9; extra == 'all'
Requires-Dist: folium>=0.16; extra == 'all'
Requires-Dist: geoalchemy2>=0.14; extra == 'all'
Requires-Dist: geopandas>=0.14; extra == 'all'
Requires-Dist: h3>=4.0; extra == 'all'
Requires-Dist: matplotlib>=3.8; extra == 'all'
Requires-Dist: mcp<3,>=2.3; extra == 'all'
Requires-Dist: openai>=1.10; extra == 'all'
Requires-Dist: psycopg2-binary>=2.9; extra == 'all'
Requires-Dist: pyarrow>=14; extra == 'all'
Requires-Dist: pydeck>=0.9; extra == 'all'
Requires-Dist: pyogrio>=0.7; extra == 'all'
Requires-Dist: pystac-client>=0.7; extra == 'all'
Requires-Dist: rasterio>=1.3; extra == 'all'
Requires-Dist: rio-tiler>=6.0; extra == 'all'
Requires-Dist: sqlalchemy>=2.0; extra == 'all'
Requires-Dist: stackstac>=0.5; extra == 'all'
Requires-Dist: supabase>=2.0; extra == 'all'
Requires-Dist: uvicorn[standard]>=0.27; extra == 'all'
Provides-Extra: api
Requires-Dist: fastapi>=0.110; extra == 'api'
Requires-Dist: uvicorn[standard]>=0.27; extra == 'api'
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pre-commit>=3.6; extra == 'dev'
Requires-Dist: pyarrow>=14; extra == 'dev'
Requires-Dist: pyogrio>=0.7; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: geo
Requires-Dist: duckdb>=0.10; extra == 'geo'
Requires-Dist: fiona>=1.9; extra == 'geo'
Requires-Dist: geopandas>=0.14; extra == 'geo'
Requires-Dist: h3>=4.0; extra == 'geo'
Requires-Dist: rasterio>=1.3; extra == 'geo'
Provides-Extra: intelligence
Requires-Dist: faiss-cpu>=1.7; extra == 'intelligence'
Provides-Extra: io
Requires-Dist: pyarrow>=14; extra == 'io'
Requires-Dist: pyogrio>=0.7; extra == 'io'
Provides-Extra: llm
Requires-Dist: anthropic>=0.18; extra == 'llm'
Requires-Dist: openai>=1.10; extra == 'llm'
Provides-Extra: mcp
Requires-Dist: mcp<3,>=2.3; extra == 'mcp'
Provides-Extra: overture
Requires-Dist: duckdb>=1.1; extra == 'overture'
Provides-Extra: postgis
Requires-Dist: geoalchemy2>=0.14; extra == 'postgis'
Requires-Dist: psycopg2-binary>=2.9; extra == 'postgis'
Requires-Dist: sqlalchemy>=2.0; extra == 'postgis'
Provides-Extra: satellite
Requires-Dist: pystac-client>=0.7; extra == 'satellite'
Requires-Dist: rio-tiler>=6.0; extra == 'satellite'
Requires-Dist: stackstac>=0.5; extra == 'satellite'
Provides-Extra: supabase
Requires-Dist: supabase>=2.0; extra == 'supabase'
Provides-Extra: terrain
Requires-Dist: rasterio>=1.3; extra == 'terrain'
Provides-Extra: viz
Requires-Dist: folium>=0.16; extra == 'viz'
Requires-Dist: matplotlib>=3.8; extra == 'viz'
Requires-Dist: pydeck>=0.9; extra == 'viz'
Description-Content-Type: text/markdown

<!-- mcp-name: io.github.Maz2580/geospark -->
<p align="center">
  <h1 align="center">GeoSpark</h1>
  <p align="center"><strong>Verifiable spatial reasoning for any LLM.</strong></p>
  <p align="center"><em>Distances, topology and CRS computed by a geometry engine, not guessed by a language model. Open source, runs anywhere, works with any model that can call a tool.</em></p>
</p>

<p align="center">
  <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>
  <a href="https://pypi.org/project/geospark-ai/"><img src="https://img.shields.io/pypi/v/geospark-ai?color=blue" alt="PyPI"></a>
  <a href="https://pypi.org/project/geospark-ai/"><img src="https://img.shields.io/pypi/pyversions/geospark-ai" alt="Python"></a>
  <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>
  <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>
</p>

---

GeoSpark gives AI models tools that **compute** spatial answers instead of estimating them: geodesic
distances, exact topology, official datum and height conversions, geocoding, elevation, routing,
open map data and live environmental data. Every answer carries its sources, units and caveats, and
says when it can't be answered rather than guessing.

### How it has changed, and why

GeoSpark is tested against the models people actually use, and it changes as they do.

1. **The first benchmark (GeoSpark Bench v2).** Across 9 models of the time, small open-weight models
   (3.8B–9B) got numeric distance questions wrong about 95% of the time, and frontier APIs ranged from
   25% to 95% correct. With GeoSpark's tools, every model that made valid tool calls answered 76–96%
   correctly; a 7B model on a CPU went from 5% to 76%.
2. **The models improved.** Re-checked in October 2026, Claude Opus 5.5 and GPT-6.1 answer those
   distance and topology questions well on their own, and with GeoSpark's tools a 4B open model on a
   workstation answers them as well as they do. Distances between two given points are no longer the
   gap.
3. **We looked for the next gap, with practitioners' problems in mind.** Both frontier models apply
   datum shifts, plate motion and map projections to the millimetre, but anything that needs a
   national grid file defeated them: converting a GNSS or drone height to AHD, ODN, NZVD2016 or
   NAVD88, or using Britain's OSTN15. Answering directly (even with their own code sandboxes, which
   have no internet), GPT-6.1 declined and Opus 5.5 estimated, up to 9.9 m off. GeoSpark now does these
   conversions with the official grids. (Writing a script for you to run online, both frontier
   models got them right; smaller local models did not.)
4. **We checked our own answers, not just the models'.** Matching PROJ only proves a model used the
   tool, so GeoSpark's answers were compared with Geoscience Australia, Ordnance Survey, LINZ and the
   US National Geodetic Survey: agreement within millimetres, with one disclosed exception. The same
   audit corrected overclaims on our website and fixed what the edge cases broke.

Every step's questions, scripts and results are in the repository, so anyone can re-run them
([benchmark results](#benchmark-results), [October 2026 re-check](#re-check-with-current-models-october-2026),
[agency checks](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/agency_checks.md)).

### What GeoSpark is now

- **A live site, API and hosted MCP server** at [geospark.terrascout.app](https://geospark.terrascout.app):
  ask questions, draw reachable areas, explore open map data, convert datums and heights.
- **Verifiable answers:** sources and licences, units, caveats (an ambiguous place name, a DEM's
  accuracy, a local height datum), and status `ok`, `partial` or `cannot_answer`. Coordinates a model
  types from memory, or a conversion to a different system than the question asks for, mark the
  answer partial.
- **Self-hostable on one server** with a local model at no API cost: routing (Valhalla), an
  OpenStreetMap copy (Overpass) and the official grids, all under one data folder.
- **Open:** Apache-2.0, 1,190+ offline tests, and benchmarks you can re-run.

## Try it

**No install: the website.** [geospark.terrascout.app](https://geospark.terrascout.app) lets you ask
questions, draw reachable areas and explore Overture Maps data around any place. Every result shows
its sources, caveats and units, and downloads as GeoJSON.

**No install: the hosted API** (open, no key needed; [API reference](https://geospark.terrascout.app/docs))

```bash
curl -X POST https://geospark.terrascout.app/api/v1/distance \
  -H "Content-Type: application/json" \
  -d '{"lat_a": 48.8584, "lon_a": 2.2945, "lat_b": 48.8606, "lon_b": 2.3376}'
# {"distance_m": 3172.01, "distance_km": 3.172, "note": "Geodesic distance on WGS84 ellipsoid"}
```

**In Claude or any MCP client, with no install: the hosted MCP server**

Add a custom connector with this URL (in Claude: Settings → Connectors → Add custom connector):

```
https://geospark.terrascout.app/mcp
```

It is open, with no key, and has the same seventeen tools as the local server below (which adds three for your own files), with the same
rate limits as the hosted API. It never reads files; for your own files, run the server locally.

**In Claude Desktop or any MCP client, running on your computer**

```bash
pip install "geospark-ai[mcp]"
```

Add GeoSpark to your MCP client's config. For Claude Desktop that's
`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, or
`%APPDATA%\Claude\claude_desktop_config.json` on Windows:

```json
{ "mcpServers": { "geospark": { "command": "geospark-mcp" } } }
```

That gives the model fifteen read-only tools. **Computation:** `calculate_distance` (between
place names, which the server geocodes, or coordinates),
`check_spatial_relationship` and `spatial_operation` (buffer, area, length, centroid, convex hull).
**Places and data:** `geocode`, `reverse_geocode`, `get_elevation`, `route`, `get_weather`,
`get_air_quality`, `get_active_fires` and `overture_features`. **Analysis:** `isochrone`, `travel_time_matrix`, `location_report` and `select_site`.

Results come back as structured content with output schemas, usually GeoJSON. They carry
provenance (source, licence, attribution), units and warnings. When GeoSpark can't answer reliably
(invalid geometry, unknown place, a data source down), the tool returns an error that says why, so
the model can tell the user instead of estimating. The server uses MCP SDK 2.x (protocol
2026-07-28) and still works with clients on older protocol versions.

**As a Python library**

```bash
pip install geospark-ai              # core
pip install "geospark-ai[api,mcp]"   # + REST server and MCP server
pip install "geospark-ai[all]"       # everything
```

**Run your own server with Docker**

```bash
docker run -p 8000:8000 ghcr.io/maz2580/geospark:latest
# then open http://localhost:8000
```

That runs in development mode, where operator routes are open: fine on your own machine. For a
public server, add `-e GEOSPARK_ENV=production -e GEOSPARK_ADMIN_TOKEN=<secret>` (see
[Self-hosting](#self-hosting)).

## What it does

| | |
|---|---|
| **Spatial engine** | Geodesic distance, area and length (pyproj on WGS84); topology checks (contains, within, intersects, touches, crosses, overlaps, disjoint) via Shapely; shape-preserving buffers; union, intersection, difference, centroid, convex hull; CRS transformation of every input geometry |
| **Places and terrain** | Geocoding and reverse geocoding (OpenStreetMap Nominatim, following its usage policy: ≤ 1 request/s and cached); elevation from Copernicus GLO-30 with Open Elevation fallback and vertical-datum awareness (NAVD88 / EGM96 / WGS84); routing (OSRM); **isochrones** (where can I reach in N minutes on foot, by bike or by car) and travel-time matrices (Valhalla) |
| **Official datums and heights** | Coordinate conversions with the national agencies' published transformations and grids, as PROJ implements them (GDA94 → GDA2020, MGA zones, British National Grid via OSTN15, NAD27 → 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 and its accuracy |
| **Live data** | Overture Maps places, buildings, roads and admin 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) |
| **Agents** | `GeoAgent` (multi-step analysis from a plain-English goal), `SpatialReport` (a location dossier: amenities, accessibility, elevation, weather), `SiteSelector` (multi-criteria site ranking), and a coordinator that routes a goal to the right one |
| **Interfaces** | Python library, CLI (`geospark …`), REST API (54 documented operations, reference at `/docs`), MCP server (`geospark-mcp`), web playground |
| **Memory and workflows** | Spatial memory with facts, episodes and contradiction detection; a tiered context store; Flows (DAG workflows, buildable from a natural-language goal) |
| **GeoSpark Bench** | 535 reproducible questions across topology, distance, multi-step reasoning, change and multimodal tasks, with computed ground truth |

**Planned, not built yet:** slope, viewshed, raster band math and change detection. These
operations exist in the protocol, but today they return a clear "not implemented yet" error rather
than an answer. See the [roadmap](#roadmap).

## Quick start

### Ground-truth geometry

```python
from geospark.engine.spatial_reasoner import SpatialReasoner

park = {"type": "Polygon", "coordinates": [[[2.29, 48.85], [2.30, 48.85], [2.30, 48.86], [2.29, 48.86], [2.29, 48.85]]]}
point = {"type": "Point", "coordinates": [2.295, 48.855]}  # GeoJSON order: [lon, lat]

SpatialReasoner.check_relationship(park, point, "contains")  # True

SpatialReasoner.calculate_distance(
    {"type": "Point", "coordinates": [2.2945, 48.8584]},  # Eiffel Tower
    {"type": "Point", "coordinates": [2.3376, 48.8606]},  # Louvre
)  # 3172.01 (metres, geodesic)
```

### Queries through the engine

```python
from geospark import Engine
from geospark.protocol import SpatialOperation, SpatialQuery

engine = Engine(tools=["geocoder", "terrain"])

place = engine.execute(SpatialQuery(operation=SpatialOperation.GEOCODE,
                                    metadata={"query": "Federation Square, Melbourne"}))
print(place.features[0].geometry)  # {'type': 'Point', 'coordinates': [144.969..., -37.817...]}

# Inputs in another CRS are transformed to WGS84 first, including the second geometry
buffered = engine.execute(SpatialQuery(operation=SpatialOperation.BUFFER,
                                       geometry={"type": "Point", "coordinates": [2.2945, 48.8584]},
                                       radius_m=100))
print(buffered.features[0].properties["area_sq_m"])  # ≈ 31,365
```

### Every answer is checkable, and opens in QGIS

```python
result = engine.execute(SpatialQuery(operation=SpatialOperation.GEOCODE, metadata={"query": "Springfield"}))
result.status        # "ok" | "partial" | "cannot_answer"
result.warnings      # ["Ambiguous place name: 'Springfield, …, Illinois…'; '…Massachusetts…' …"]
result.provenance    # source, licence (ODbL-1.0), attribution, retrieved_at, method
result.spatial_context.units  # {"distance_m": "metre", ...}

import json
json.dump(result.to_geojson(), open("springfield.geojson", "w"))  # RFC 7946, drag into QGIS
```

GeoSpark refuses rather than guesses. A self-intersecting polygon, out-of-range coordinates or a
likely [lat, lon] swap gets `cannot_answer` with the reason, not a confident wrong number. From
the API, add `?format=geojson` (or `Accept: application/geo+json`). From the CLI, add
`--geojson out.geojson` to `geocode`, `elevation`, `query` or `report`. A location report becomes a
map layer: the place plus every nearby amenity, with distances.

### Open map data with no API key: Overture Maps

```python
from geospark.tools.overture.client import query   # pip install "geospark-ai[overture]"

query("divisions", 48.8606, 2.3376)   # the Louvre is in: France › Île-de-France › Paris › Paris 1er Arrondissement
query("places", -37.8180, 144.9691, radius_m=200, category="cafe")  # nearest first, geodesic metres
query("buildings", -37.8180, 144.9691, radius_m=150)  # footprints, height, geodesic footprint area
query("transportation", -37.8180, 144.9691, radius_m=150)  # road/path segments, geodesic length
```

GeoSpark queries [Overture Maps](https://overturemaps.org) GeoParquet directly on S3 with DuckDB. It
uses the release's STAC index to read only the files covering your area, so there's no download and
no key. Results report the Overture release and the licence of the records returned. The same data is
available from the CLI (`geospark overture places -37.818 144.969 --category cafe`), the REST API
(`POST /api/v1/overture/{theme}`) and the MCP tool `overture_features`.

### Official datum and height conversions

```python
from geospark import geodesy

# A drone's GNSS height at Federation Square, in the Australian Height Datum (AUSGeoid2020)
geodesy.convert_height(-37.8177, 144.9691, 40.0, "ellipsoidal", "AHD")
# {'height_m': 35.2574, 'separation_m': 4.7426, 'geoid_model': 'AUSGeoid2020', 'accuracy_m': 0.15, ...}

# GDA94 / MGA zone 55 -> GDA2020 / MGA zone 55 (a 1.55 m shift), with the EPSG operation used
geodesy.transform("EPSG:28355", "EPSG:7855", [[321242.045, 5812466.767]]).as_dict()
```

In our October 2026 test of two frontier models, both applied datum parameters and plate motion
correctly but could not use the national grids: GPT-6.1 declined every ellipsoid-to-AHD height ("I
cannot access the AUSGeoid2020 grid") and Claude Opus 5.5 estimated them 0.2-5.3 m off. PROJ itself
falls back silently when a grid is missing (AHD becomes the ellipsoidal height, 4.7 m off in
Melbourne; British National Grid moves about 124 m); GeoSpark refuses instead.

**Checked against the agencies themselves**, not only against PROJ
([details](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/agency_checks.md)):
Geoscience Australia's calculators (GDA94 to GDA2020 and ATRF2014 to GDA2020 within 0.1 mm at eight
points; AUSGeoid2020 heights within 2 mm in three cities), Ordnance Survey's 40 OSTN15/OSGM15 test
points (grid coordinates within 8 mm, heights within 2 mm), LINZ's NZVD2016 example points (within
their 1 cm rounding) and NGS's GEOID18 service (1 mm); at scale, GDA94 to GDA2020 agrees with
Victoria's surveyed marks within 7 mm (416 marks) and AUSGeoid2020 with spirit-levelled AHD to 6 mm
median (99 marks). Limits to know: AHD is refused outside mainland
Australia and Tasmania; OSGM15 heights on Scilly, the Isle of Man, the Outer Hebrides, Orkney and
Shetland are labelled with their local datum, not ODN; and NAD27 to NAD83 uses the original NADCON,
about 0.27 m from NGS's current NADCON 5 in San Francisco (within NADCON 5's own stated uncertainty).
The accuracy given with each result is
the one EPSG states for the operation, not a measurement.

Also as `POST /api/v1/transform`, `POST /api/v1/height`
and the MCP tools `transform_coordinates` and `convert_height`. Grids are cached in
`$GEOSPARK_DATA_DIR/proj`; set `GEOSPARK_PROJ_NETWORK=false` to never download.

### Your own data

```bash
pip install "geospark-ai[io]"
geospark layer info suburbs.gpkg                            # features, fields, CRS, geodesic area
geospark layer count-within crashes.csv suburbs.gpkg --geojson counts.geojson
geospark layer nearest schools.shp hospitals.geojson
geospark layer intersect parcels.gpkg flood_zones.geojson
```

Formats: GeoJSON, GeoPackage, Shapefile (or a .zip), GeoParquet, CSV with longitude/latitude
columns, FlatGeobuf, KML, GML, File Geodatabase. **The CRS comes from the file, not a guess**:
- a projected file (e.g. MGA zone 55) is reprojected, and its original CRS is reported;
- a Shapefile without `.prj` is refused until you pass `--crs EPSG:28355`;
- a CSV has no CRS: longitude/latitude columns are read as WGS 84 and the result says so (pass
  `--crs` for GDA94 or another datum); x/y columns are refused until you name the CRS;
- invalid shapes are refused until you pass `--repair`.

**Check a file before you use it**, or find out why two layers disagree:

```bash
geospark layer check pipes.csv --crs EPSG:7855                          # CRS, datum and height caveats
geospark layer check pipes.csv --crs EPSG:7855 --against parcels.csv --against-crs EPSG:7855
# problem: pipes.csv sits 1.55 m from parcels.csv, matching the official GDA94 to GDA2020 shift here.
#          pipes.csv holds GDA94 coordinates but is treated as the same datum as parcels.csv.
```

`check` looks for the silent errors: no CRS declared, projected numbers labelled as degrees (or the
reverse), swapped latitude/longitude, points outside the CRS's zone, Australian data labelled WGS 84,
and heights that don't say whether they are ellipsoidal or AHD. With `--against` it measures the
offset to a reference layer and names the cause only when the numbers match one: GDA94 data labelled
GDA2020 (checked against the official shift at that place), the wrong MGA zone, feet instead of
metres, swapped axes, a plate-motion epoch, or ellipsoidal against AHD heights. Anything else is
reported as an unexplained offset. Tried on 2,000 of Victoria's published survey marks, it named
real GDA94 numbers labelled GDA2020 (1.55 m, scatter 3 cm), a wrong zone and ellipsoidal against AHD
heights, and found nothing wrong when they were labelled correctly (6 mm). The exit code is 2 when it finds a problem, so it fits in a
pipeline; in Python, `geospark.io.check.check_layer` and `compare_layers`; for Claude, the local MCP
tool `check_dataset`.

Results carry each file's SHA-256, so an analysis can be reproduced against exactly that data. In
Python, use `geospark.io.layers` (`load_layer`, `count_within`, `nearest`, `intersect`). The local
MCP server (`geospark-mcp`) offers the same as `layer_*` tools; the hosted `/mcp` endpoint never reads
files.

### Reproducible analyses

```bash
geospark record report "Federation Square, Melbourne" --out fedsq.json
geospark replay fedsq.json
# REPRODUCED: Same result as recorded.
# ... or DATA CHANGED: openstreetmap-overpass returned different data for the same request (step 6 ...)
# ... or CODE CHANGED: versions changed: {'shapely': ('2.0.0', '2.2.0')}
```

A run record lists:
- the environment: GeoSpark, Python, pyproj/PROJ and Shapely/GEOS versions
- every data request (Nominatim, Overpass, Overture, Valhalla, weather, elevation) and every engine
  computation, each with a fingerprint of its input and output
- a fingerprint of the final result

`geospark replay` reruns the analysis and says whether it reproduced. If not, it says which source
returned different data, or that the code or a library version changed. In Python, use
`geospark.runs.record_run(...)` and `replay(...)`.

### Natural-language questions

```python
result = engine.ask("How far is the Eiffel Tower from Big Ben?")
print(result.spatial_context.summary)
print(result.sources)  # the model, then each tool it called
```

`ask` uses a local [Ollama](https://ollama.com) model first and falls back to free OpenRouter models
if `OPENROUTER_API_KEY` is set.

### Agents

```python
from geospark.agents import AgentCoordinator, GeoAgent, SiteSelector, SpatialReport

GeoAgent().run("Find all hospitals within 2km of the Eiffel Tower").findings["nearby"]

report = SpatialReport().analyze("Federation Square, Melbourne", radius_m=1000)
print(report.weather, report.accessibility, report.data_gaps)

best = SiteSelector().find(within="Zurich", near=["hospital", "school"], facility_type="pharmacy").best

AgentCoordinator().run("Find the best location for a cafe in Melbourne CBD near schools")
```

Agents use an LLM to read the goal. Without one, a rule-based parser still handles requests like
the one above: multi-word place names, radii such as "2km", and amenity types.

### Memory, context and flows

```python
from geospark.memory import SpatialIntelligence

intel = SpatialIntelligence()
intel.remember_fact("Eiffel Tower is at 48.8584 N, 2.2945 E", source="user")
intel.remember_episode("PM2.5 was 120 in Delhi", importance=0.8, source="tool:air_quality")
intel.recall("Where is the Eiffel Tower?", limit=5)  # semantic with Ollama embeddings, keyword match without
intel.find_contradictions()
```

```python
from geospark.flows import ChatFlowSession, FlowRunner, make_ollama_chat_fn

session = ChatFlowSession(llm_fn=make_ollama_chat_fn("qwen2.5:7b"))
result = session.run("Geocode Valencia, Spain then check its elevation")
if result.flow:
    FlowRunner().run(result.flow)  # a validated DAG, built one tool call at a time
```

## Command line

```bash
geospark distance -37.8136 144.9631 -33.8688 151.2093   # Melbourne → Sydney: 713.86 km
geospark check contains '<geojson A>' '<geojson B>'
geospark geocode "Tokyo Tower, Japan"
geospark elevation -37.8177 144.9691
geospark isochrone -37.8180 144.9691 --minutes 5,10,15 --mode walk
geospark ask "Is Tokyo closer to Seoul or Beijing?"

geospark agent "Find all parks within 2km of Big Ben"
geospark report "Federation Square, Melbourne"
geospark site-select --within "Paris" --near "school,hospital" --facility pharmacy
geospark multi-agent "Analyze Federation Square" --stream

geospark data weather "Melbourne, Australia"
geospark data air-quality "Delhi"        # needs OPENAQ_API_KEY
geospark data fires "Amazon Rainforest"  # needs FIRMS_MAP_KEY

geospark flow list
geospark flow build "Geocode Valencia then check its elevation" --run
geospark info                            # versions and optional extras
```

## Benchmark results

GeoSpark Bench v2 ran 9 models under three protocols: the bare question, step-by-step reasoning
(CoT), and the question with GeoSpark's MCP tools attached. The models were five open-weight ≤9B
(Qwen 2.5 7B, Llama 3.1 8B, Gemma 2 9B, Mistral 7B, Phi-3.5 3.8B), gpt-oss 20B, and three
frontier APIs (GPT-5.4, Claude Sonnet 4.5, Gemini 2.5 Pro). Intervals are Wilson 95%, and the
per-call cost ledger (~$41) is checked in. The full analysis is in a manuscript currently under peer
review.

**Numeric distance** (`distance_absolute`, n = 80 per model; correct = within 10% of the
`pyproj.Geod.inv()` geodesic):

| Model | Tier | Bare LLM | Step-by-step | + GeoSpark tools |
|---|---|:---:|:---:|:---:|
| Qwen 2.5 7B | open ≤9B | 5.0% | 20.0% | **76.2%** |
| Llama 3.1 8B | open ≤9B | 5.0% | 21.2% | 7.5% ᵃ |
| Gemma 2 9B | open ≤9B | 37.5% ᵇ | 6.2% | — |
| Mistral 7B | open ≤9B | 2.5% | 2.5% | 0.0% ᵃ |
| Phi-3.5 3.8B | open ≤9B | 1.2% | 6.2% | — |
| gpt-oss 20B | open mid | **93.8%** | 93.8% | 91.2% |
| GPT-5.4 | frontier | 95.0% | 90.0% | 95.0% |
| Claude Sonnet 4.5 | frontier | 73.8% | 92.5% | **96.2%** |
| Gemini 2.5 Pro | frontier | 25.0% | 55.0% | 85.0% |

ᵃ The tools were available but not used properly: Llama mixed up km and m in the coordinates it got
back, and Mistral answered in plain text. ᵇ Gemma's correct answers are well-known long-distance
pairs, which is recall of training data, not computation. — means not evaluated with tools.

**What we learned**

- **Tools are a leveller.** The bare-LLM range spans 1–95%. With tools, every model that makes valid
  tool calls lands between 76% and 96%. Qwen gained 71 points; GPT-5.4 gained nothing, because it
  was already at 95%.
- **Aggregate scores hide bias.** Qwen's 56% on topology comes from saying "yes": it scores 93% on
  `intersects` (where the data skews true) and 3% on `disjoint`. GPT-5.4 scores 0% on `touches`.
  With GeoSpark, every predicate is 100%, because the engine computes it.
- **Step-by-step prompting is not a universal fix.** It helps some models and hurts others.
- **Knowledge questions don't need tools.** "Did the Amazon lose forest between 2015 and 2023?"
  is answered well at every scale without them.

Re-run it yourself with `python -m geospark.bench run`. The datasets regenerate identically from
`python -m geospark.bench.generate_datasets`; see the
[datasets README](https://github.com/Maz2580/geospark/blob/main/geospark/bench/datasets/README.md)
for provenance, and the
[benchmark report](https://github.com/Maz2580/geospark/blob/main/docs/BENCHMARK_REPORT.md) for
every table.

### Re-check with current models (October 2026)

We keep testing as models improve. In October 2026 we re-ran a sample of GeoSpark Bench on today's
frontier models and on open models served from a local workstation, and added a new test of datum
and height conversions, where we found the next gap. Everything needed to re-run it (questions,
the scripts that compute the official answers, the harness and a scored summary) is in
[docs/benchmarks/2026-10](https://github.com/Maz2580/geospark/tree/main/docs/benchmarks/2026-10).

**GeoSpark Bench sample** (20 numeric-distance questions and 18 topology questions, 3 per predicate,
coordinates given; distance correct within 10% of the `pyproj` geodesic, topology exact):

| Model | Where it ran | Without tools: distance / topology | With GeoSpark tools |
|---|---|:---:|:---:|
| Claude Opus 5.5 | API | 100% / 100% | 100% / 100% |
| GPT-6.1 | API | 95% ¹ / 100% | 100% / 100% |
| gpt-oss 20B | local workstation | 95% / 100% | 100% / 100% |
| Qwen 3.5 4B | local workstation | 90% / 100% | 100% / 100% |
| Qwen 3.5 9B | local workstation | — ² | 100% / 94% |
| Phi-4-mini 3.8B | local workstation | 15% / 50% | 20% / 50% ³ |

¹ One reply ran out of length while reasoning and gave no number. ² Most replies timed out on a busy
workstation, so the run is not a fair score. ³ It never called the tools. Gemma 4 12B timed out the
same way and is left out.

The finding has moved on since the first run: the newest frontier models now handle these questions
on their own, and with tools a 4B model on a workstation answers them as well as they do.

**Datum and height conversions** (new; 32 questions in Australia, the UK, New Zealand and the US,
with answers computed by PROJ's most accurate EPSG operations and the national grids (checked
against Geoscience Australia's and NGS's own services where they offer one; see above): GDA94 to
GDA2020, MGA zones, ITRF2014 to GDA2020 at an epoch, ellipsoidal heights to AHD, ODN, NZVD2016 and
NAVD88, OSTN15, NAD27 to NAD83). Survey-grade means within 5 cm, or within the geoid model's stated
accuracy for heights:

| Model | Without tools: survey-grade | within 0.5 m | wrong | declined | With GeoSpark's geodesy tools |
|---|:---:|:---:|:---:|:---:|:---:|
| Claude Opus 5.5 | 15 | 7 | 8 (up to 5.3 m off) | 2 ⁴ | **31 survey-grade, 1 within 6 cm** |
| GPT-6.1 | 13 | 2 | 1 (1.65 m off) | 16 | **32 survey-grade** |

⁴ Two British National Grid answers ran past an 8,000-token reply limit while working by hand.

Because GeoSpark's tools also use PROJ, the with-tools column shows that the models used the tools
correctly; whether PROJ's answers are right is the separate agency check above. For the two NAD27
questions the reference itself is only good to about 0.27 m (NGS's stated uncertainty), so "survey-grade"
there means agreement with PROJ's NADCON, not centimetre truth.

Both models apply datum parameters, plate motion and map projections to the millimetre. What they
can't do, answering directly, is read the national grids that heights and some datum conversions
depend on: GPT-6.1 declines ("I cannot access the AUSGeoid2020 grid"), and Opus 5.5 estimates from
memory. That is the gap GeoSpark's [`transform_coordinates` and `convert_height`](#official-datum-and-height-conversions)
close.

**Fairness checks** ([details](https://github.com/Maz2580/geospark/blob/main/docs/benchmarks/2026-10/fairness.md)):
the gap holds with the providers' own code execution at high effort (their sandboxes have PROJ but no
internet; GPT-6.1 declined all 17 grid questions, Opus 5.5 estimated with errors up to 9.9 m) and across
three repeated runs. It does **not** hold when these frontier models write a script that you run with
internet access: Opus 5.5 got all 8 everyday GIS scripting tasks right, AHD included, and GPT-6.1 got 7
and failed safely on the eighth. The silently wrong scripts came from the smaller local models (gpt-oss
20B printed the ellipsoidal height unchanged as "AHD"; Qwen 3.5 4B got 1 of 8). Not tested: web
browsing. The whole October 2026 re-check cost US$8.46 in API calls.

## Self-hosting

```bash
git clone https://github.com/Maz2580/geospark && cd geospark
cp .env.example .env     # set GEOSPARK_ENV=production and GEOSPARK_ADMIN_TOKEN
docker compose -f docker-compose.prod.yml up -d
```

The hosted API is designed to stay **open** without a key, with guard rails:

- An admin token protects operator routes (audit, usage, flow storage). The LLM gateway needs it or
  a revocable partner key.
- Public callers can only choose the local model or free models, so nobody can spend your paid
  API credit.
- Each visitor gets a private, size-capped, auto-expiring sandbox for the memory and context demos.
- Rate limits use the real client IP, and LLM work has a concurrency cap.
- Inputs are bounded, and the audit log stores hashed IPs.

All state lives under `GEOSPARK_DATA_DIR`. To lift Nominatim's public limits, point
`GEOSPARK_NOMINATIM_URL` at your own server. Details are in
[deploy/DEPLOY.md](https://github.com/Maz2580/geospark/blob/main/deploy/DEPLOY.md#security) and
[`.env.example`](https://github.com/Maz2580/geospark/blob/main/.env.example).

## Roadmap

- **v0.7 (released): verifiable answers.**
  - Every result carries its source, licence, timestamp, CRS and units.
  - A "cannot answer" response instead of a guess.
  - Data-quality flags, such as geocoder confidence and DEM accuracy.
  - GeoJSON FeatureCollections that load straight into QGIS.
  - The MCP server updated to the 2026 spec, with structured GeoJSON output and a hosted HTTP
    endpoint.
- **v0.8 (released): more data, more analysis.**
  - Overture Maps buildings, places and roads through DuckDB, with no key.
  - Real isochrones and travel-time matrices.
  - Bring your own GeoJSON, GeoPackage or GeoParquet.
  - Reproducible run manifests.
  - A redesigned website with a Proof panel (sources, caveats, units, GeoJSON download) on every
    result, and Ask answers that never use coordinates the model typed from memory.
- **Ongoing:** external benchmarks (GPSBench, GeoBenchX), a QGIS bridge, and more MCP tools.

## Data attribution

GeoSpark computes geometry locally, but it fetches data from public services. Results built on them
keep the providers' licences, so please credit them when you publish:

- **OpenStreetMap** (geocoding, amenities, map tiles): © OpenStreetMap contributors, [ODbL](https://www.openstreetmap.org/copyright)
- **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
- **Open-Meteo** (weather): [CC BY 4.0](https://open-meteo.com/)
- **OpenAQ** (air quality) and **NASA FIRMS** (active fires): see each provider's terms. Air-quality
  results include each station's licence.

Elevation results include an `attribution` field you can copy directly. The full list is in
[NOTICE](https://github.com/Maz2580/geospark/blob/main/NOTICE).

## Development

```bash
git clone https://github.com/Maz2580/geospark && cd geospark
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,api,mcp]"
pytest                       # 1003 tests; offline, never touch ~/.geospark
ruff check geospark tests
```

Contributions are welcome. See
[CONTRIBUTING.md](https://github.com/Maz2580/geospark/blob/main/CONTRIBUTING.md), and report
security issues privately as described in
[SECURITY.md](https://github.com/Maz2580/geospark/blob/main/SECURITY.md). The architecture overview
is in [docs/ARCHITECTURE.md](https://github.com/Maz2580/geospark/blob/main/docs/ARCHITECTURE.md),
and release notes are in [CHANGELOG.md](https://github.com/Maz2580/geospark/blob/main/CHANGELOG.md).

## Citing

If GeoSpark or GeoSpark Bench helps your research, please cite it. GitHub's **"Cite this
repository"** button uses [CITATION.cff](https://github.com/Maz2580/geospark/blob/main/CITATION.cff).

## Author

Created by **Mazdak Ghasemi Tootkaboni** ([University of Melbourne](https://www.unimelb.edu.au/))
· ORCID [0000-0001-8084-5270](https://orcid.org/0000-0001-8084-5270) · GitHub [@Maz2580](https://github.com/Maz2580)

## License

[Apache 2.0](https://github.com/Maz2580/geospark/blob/main/LICENSE). Copyright 2024-2026 Mazdak Ghasemi Tootkaboni.
