Distributed storage and openEO processing for Earth observation data. No vendor lock-in. Community-driven.
Data fetched from upstream sources (Element84, CDSE, etc.)
Every chunk identified by SHA-256 hash. Tamper-proof by design โ corrupted or fake data is automatically rejected.
New data automatically propagates to all registered nodes. No manual intervention needed.
Runs at lowest CPU and I/O priority (nice 19, ionice idle). Never competes with your other workloads.
Built-in STAC catalog. Every item is discoverable, searchable, and interoperable with the EO ecosystem.
NDVI, NDWI, EVI, cloud masking, band math โ process data where it lives, no data movement needed.
Beacons coordinate, data flows peer-to-peer. No single point of failure. Works across firewalls via HTTPS.
# Fetch latest Sentinel-2 for an area (distributed across the grid)
earthgrid fetch --bbox 12.4,55.6,12.6,55.7
# Date range, multiple scenes
earthgrid fetch --bbox 12.4,55.6,12.6,55.7 --start 2025-06-01 --end 2025-06-30 --limit 10
# Specific bands only
earthgrid fetch --bbox 12.4,55.6,12.6,55.7 --bands B04,B08,SCL
# Verify data integrity
earthgrid verify
earthgrid verify --heal # auto-fix corrupted chunks
# Check node status
earthgrid status
import requests, matplotlib.pyplot as plt
from collections import defaultdict
from datetime import datetime
BASE = "http://localhost:8400"
LON, LAT = 12.57, 55.68 # Frederiksberg Gardens
# Search all scenes at this point
items = requests.get(f"{BASE}/stac/search", params={
"bbox": f"{LON-0.01},{LAT-0.01},{LON+0.01},{LAT+0.01}",
"collections": "sentinel-2-l2a", "limit": 500,
}).json()["features"]
# Group by date
dates = defaultdict(dict)
for item in items:
band = item["id"].rsplit("_", 1)[-1]
dt = item["properties"]["datetime"][:10]
dates[dt][band] = item
# Extract NDVI time series
ndvi_ts = []
for dt in sorted(dates):
if "B04" not in dates[dt] or "B08" not in dates[dt]:
continue
try:
red = requests.get(f"{BASE}/point/{dates[dt]['B04']['collection']}/{dates[dt]['B04']['id']}",
params={"lon": LON, "lat": LAT}).json()["value"]
nir = requests.get(f"{BASE}/point/{dates[dt]['B08']['collection']}/{dates[dt]['B08']['id']}",
params={"lon": LON, "lat": LAT}).json()["value"]
if nir + red > 0:
ndvi_ts.append((datetime.strptime(dt, "%Y-%m-%d"), (nir - red) / (nir + red)))
except: continue
# Plot
d, v = zip(*ndvi_ts)
plt.figure(figsize=(14, 5))
plt.fill_between(d, v, alpha=0.3, color="#3fb950")
plt.plot(d, v, "o-", color="#3fb950", markersize=4)
plt.title(f"NDVI Time Series โ Copenhagen ({LAT}ยฐN, {LON}ยฐE)")
plt.ylabel("NDVI"); plt.grid(alpha=0.2)
plt.savefig("ndvi_timeseries.png", dpi=150)
import openeo
conn = openeo.connect("http://localhost:8400")
cube = conn.load_collection("sentinel-2-l2a",
spatial_extent={"west": 12.4, "south": 55.6, "east": 12.7, "north": 55.75},
temporal_extent=["2025-06-01", "2025-06-30"],
bands=["B04", "B08"])
cube.ndvi(red="B04", nir="B08").save_result("GTiff").download("ndvi.tif")
library(httr); library(terra); library(jsonlite)
base <- "http://localhost:8400"
# Search
items <- fromJSON(content(GET(paste0(base, "/stac/search"),
query = list(bbox = "12.4,55.6,12.7,55.75",
collections = "sentinel-2-l2a", limit = 100)
), "text"))$features
# Download helper
dl <- function(band) {
item <- items[grepl(band, items$id), ][1, ]
url <- paste0(base, "/download/", item$collection, "/", item$id)
tmp <- tempfile(fileext = ".tif")
writeBin(content(GET(url), "raw"), tmp)
rast(tmp)
}
red <- dl("B04"); nir <- dl("B08")
ndvi <- (nir - red) / (nir + red)
plot(ndvi, main = "NDVI โ Copenhagen",
col = colorRampPalette(c("brown", "yellow", "darkgreen"))(100),
range = c(-0.2, 0.8))
library(openeo)
con <- connect("http://localhost:8400")
p <- processes()
cube <- p$load_collection("sentinel-2-l2a",
spatial_extent = list(west=12.4, south=55.6, east=12.7, north=55.75),
temporal_extent = c("2025-06-01", "2025-06-30"),
bands = c("B04", "B08"))
result <- p$save_result(p$ndvi(cube, red="B04", nir="B08"), format="GTiff")
compute_result(result, "ndvi.tif")
library(terra)
plot(rast("ndvi.tif"),
col = colorRampPalette(c("brown","yellow","darkgreen"))(100))
const fs = require("fs");
const { execSync } = require("child_process");
const BASE = "http://localhost:8400";
// Search
const resp = await fetch(
`${BASE}/stac/search?bbox=12.4,55.6,12.7,55.75&collections=sentinel-2-l2a&limit=100`
);
const items = (await resp.json()).features;
// Download B04 and B08
async function download(band) {
const item = items.find(i => i.id.includes(band));
const r = await fetch(`${BASE}/download/${item.collection}/${item.id}`);
const path = `/tmp/${band}.tif`;
fs.writeFileSync(path, Buffer.from(await r.arrayBuffer()));
return path;
}
const b04 = await download("B04");
const b08 = await download("B08");
// Compute NDVI with GDAL
execSync(`gdal_calc.py -A ${b08} -B ${b04} --outfile=ndvi.tif \
--calc="where((A+B)>0, (A-B)/(A+B), 0)" --type=Float32`);
console.log("Saved: ndvi.tif");
use reqwest::blocking::Client;
use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = "http://localhost:8400";
let client = Client::new();
// Search
let items: serde_json::Value = client
.get(format!("{base}/stac/search"))
.query(&[("bbox", "12.4,55.6,12.7,55.75"),
("collections", "sentinel-2-l2a"),
("limit", "100")])
.send()?.json()?;
let features = items["features"].as_array().unwrap();
// Download bands
for band in &["B04", "B08"] {
let item = features.iter()
.find(|i| i["id"].as_str().unwrap().contains(band))
.unwrap();
let col = item["collection"].as_str().unwrap();
let id = item["id"].as_str().unwrap();
let bytes = client
.get(format!("{base}/download/{col}/{id}"))
.send()?.bytes()?;
fs::write(format!("{band}.tif"), &bytes)?;
println!("Downloaded {band}: {:.1} MB",
bytes.len() as f64 / 1e6);
}
println!("Compute NDVI with gdal_calc.py or the gdal crate");
Ok(())
}
using HTTP, JSON3, ArchGDAL, Plots
base = "http://localhost:8400"
# Search
resp = HTTP.get("$base/stac/search", query=Dict(
"bbox" => "12.4,55.6,12.7,55.75",
"collections" => "sentinel-2-l2a", "limit" => 100))
items = JSON3.read(resp.body).features
# Download helper
function dl(band)
item = first(filter(i -> contains(String(i.id), band), items))
r = HTTP.get("$base/download/$(item.collection)/$(item.id)")
path = tempname() * ".tif"
write(path, r.body)
ArchGDAL.read(path) do ds
Float64.(ArchGDAL.read(ds, 1))
end
end
red, nir = dl("B04"), dl("B08")
ndvi = @. ifelse((nir + red) > 0, (nir - red) / (nir + red), 0.0)
heatmap(ndvi[end:-1:1, :], c=:RdYlGn, clims=(-0.2, 0.8),
title="NDVI โ Copenhagen", size=(800, 800))
savefig("ndvi.png")
# Search the STAC catalog
curl -s "http://localhost:8400/stac/search?bbox=12.4,55.6,12.7,55.75" | jq '.features[].id'
# Download bands
curl -o B04.tif "http://localhost:8400/download/sentinel-2-l2a/S2C_33UUB_20250614_0_L2A_B04"
curl -o B08.tif "http://localhost:8400/download/sentinel-2-l2a/S2C_33UUB_20250614_0_L2A_B08"
# Compute NDVI with GDAL
gdal_calc.py -A B08.tif -B B04.tif --outfile=ndvi.tif \
--calc="where((A+B)>0, (A.astype(float)-B)/(A+B), 0)" --type=Float32
# List collections
curl "http://localhost:8400/collections"
# Network status
curl "http://localhost:8400/nodes"
Give AI coding tools direct access to the grid via Model Context Protocol.
# Claude Code / Codex / Cursor โ add to ~/.mcp.json:
{
"mcpServers": {
"earthgrid": {
"command": "earthgrid-mcp",
"args": ["--api-key", "your-key", "--api-url", ""]
}
}
}
# Then in your AI tool:
# "Search the grid for Sentinel-2 data over Sรผdtirol"
# "Enqueue a fetch for tile 32TPS, 2024-2026"
# "Show me the current coverage map"
Want to run your own node? Check the installation guide and join the network.
๐ Installation Guide on GitHub