WEMo API
Run WEMo from your own scripts. Send a list of sites, and get back wave height, representative wave energy (RWE), orbital velocity and bed shear stress for each one, as JSON or as CSV, GeoJSON and GeoTIFF files. It is the same model, data and limits as the web app, so you can automate repeat runs, feed results straight into R, Python or a GIS, or build WEMo into your own tools.
1. Get an API key
- Sign up for a free WEMo account, or log in.
- On your projects page, find API keys, give the key a name (for example "Laptop notebook" or "Lab server") and choose Create key.
- Copy the key straight away. It starts with
wemo_and is shown only once; WEMo keeps only a scrambled (hashed) copy, so nobody, including us, can show it to you again.
Send the key with every request in an Authorization header:
Authorization: Bearer wemo_your-key-hereKeep keys private, like a password. Anyone with your key can run WEMo as you and read your results. Don't put keys in shared notebooks, emails or public code; read them from an environment variable instead. If a key leaks, revoke it on your projects page; it stops working at once. You can have up to five active keys, one for each computer or script.
2. Your first run
Every run follows the same four steps: send your sites and settings, wait while WEMo fetches depths and wind and runs the model, then read the results or download them as a file. A few sites take under a minute; thousands take a few minutes; a new area also downloads its wind the first time.
Needs the requests package (pip install requests). Set your key first: export WEMO_API_KEY=wemo_... (macOS, Linux) or setx WEMO_API_KEY wemo_... (Windows, then open a new terminal).
import os, time, requests
BASE = "https://wemo.geohorizons.co/api/v1"
H = {"Authorization": "Bearer " + os.environ["WEMO_API_KEY"]}
def call(method, path, **kw):
r = requests.request(method, BASE + path, headers=H, timeout=120, **kw)
if not r.ok:
raise SystemExit(f"{r.status_code}: {r.text}")
return r
# 1. Your sites: any whole-number id you like, with longitude and latitude
sites = [{"id": 1, "lon": -76.10, "lat": 37.60},
{"id": 2, "lon": -76.05, "lat": 37.62},
{"id": 3, "lon": -76.00, "lat": 37.58}]
# 2. Start a run (these are the web app's default settings)
job = call("POST", "/jobs", json={
"name": "Script run",
"sites": {"points": sites},
"model_params": {"n_directions": 56, "max_fetch_m": 100000,
"sample_dist_m": 100, "sub_rays": 3},
"wind_source": {"type": "open-meteo",
"start_date": "2014-01-01", "end_date": "2024-12-31"},
}).json()["job_id"]
# 3. Wait for it to finish
while True:
status = call("GET", f"/jobs/{job}").json()
if status["status"] in ("completed", "failed"):
break
time.sleep(5)
if status["status"] == "failed":
raise SystemExit(status["error"])
# 4. Results for each site, and the same as a CSV file
for site in call("GET", f"/jobs/{job}/results").json()["results"]:
print(site["site_id"], round(site["RWE"]), site["avg_wave_height_m"])
csv = call("GET", f"/jobs/{job}/download", params={"format": "csv"})
open("wemo_results.csv", "wb").write(csv.content)Needs the httr and jsonlite packages (install.packages(c("httr", "jsonlite"))). Put your key in ~/.Renviron as WEMO_API_KEY=wemo_... and restart R.
library(httr)
library(jsonlite)
base <- "https://wemo.geohorizons.co/api/v1"
auth <- add_headers(Authorization = paste("Bearer", Sys.getenv("WEMO_API_KEY")))
wemo <- function(verb, path, ...) {
r <- VERB(verb, paste0(base, path), auth, ...)
if (http_error(r)) stop(status_code(r), ": ", content(r, "text", encoding = "UTF-8"))
r
}
# 1. Your sites: any whole-number id you like, with longitude and latitude
sites <- data.frame(id = 1:3, lon = c(-76.10, -76.05, -76.00), lat = c(37.60, 37.62, 37.58))
# 2. Start a run (these are the web app's default settings)
body <- list(
name = "Script run",
sites = list(points = sites),
model_params = list(n_directions = 56, max_fetch_m = 100000, sample_dist_m = 100, sub_rays = 3),
wind_source = list(type = "open-meteo", start_date = "2014-01-01", end_date = "2024-12-31")
)
job <- content(wemo("POST", "/jobs", body = toJSON(body, auto_unbox = TRUE),
content_type_json()))$job_id
# 3. Wait for it to finish
repeat {
status <- content(wemo("GET", paste0("/jobs/", job)))
if (status$status %in% c("completed", "failed")) break
Sys.sleep(5)
}
if (status$status == "failed") stop(status$error$message)
# 4. Results as a data frame, and the same as a CSV file
res <- fromJSON(content(wemo("GET", paste0("/jobs/", job, "/results")), "text", encoding = "UTF-8"))
print(res$results[, c("site_id", "RWE", "avg_wave_height_m")])
invisible(wemo("GET", paste0("/jobs/", job, "/download"), query = list(format = "csv"),
write_disk("wemo_results.csv", overwrite = TRUE)))The same steps from a terminal (macOS or Linux; on Windows use Git Bash or WSL). Set KEY to your key first: KEY=wemo_...
# 1-2. Start a run; the reply has a job_id
curl -s https://wemo.geohorizons.co/api/v1/jobs \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "curl run",
"sites": {"points": [{"id": 1, "lon": -76.10, "lat": 37.60},
{"id": 2, "lon": -76.05, "lat": 37.62}]},
"model_params": {"n_directions": 56, "max_fetch_m": 100000,
"sample_dist_m": 100, "sub_rays": 3},
"wind_source": {"type": "open-meteo",
"start_date": "2014-01-01", "end_date": "2024-12-31"}}'
# 3. Check on it until "status" is "completed"
JOB=paste-the-job_id-here
curl -s https://wemo.geohorizons.co/api/v1/jobs/$JOB -H "Authorization: Bearer $KEY"
# 4. Results as JSON, or as files
curl -s "https://wemo.geohorizons.co/api/v1/jobs/$JOB/results" -H "Authorization: Bearer $KEY"
curl -s -o wemo.csv "https://wemo.geohorizons.co/api/v1/jobs/$JOB/download?format=csv" -H "Authorization: Bearer $KEY"
curl -s -o wemo.geojson "https://wemo.geohorizons.co/api/v1/jobs/$JOB/download?format=geojson" -H "Authorization: Bearer $KEY"
curl -s -o wemo.tif "https://wemo.geohorizons.co/api/v1/jobs/$JOB/download?format=geotiff" -H "Authorization: Bearer $KEY"Sites over a whole area
Instead of listing sites, you can send an outline of the area and a spacing, as the web app does when you draw on the map. WEMo lays a regular grid over it, keeps the points in water, and returns them with their depths and a grid_id to run:
# Python, continuing from above: a grid at 500 m spacing inside an outline
grid = call("POST", "/grid/generate", json={
"polygon": [[-76.20, 37.50], [-75.95, 37.50], [-75.95, 37.70],
[-76.20, 37.70], [-76.20, 37.50]], # [lon, lat], closed
"resolution_m": 500,
}).json()
print(grid["counts"]) # points made, inside, in water
job = call("POST", "/jobs", json={
"name": "Area run",
"sites": {"grid_id": grid["grid_id"]},
"model_params": {"n_directions": 56, "max_fetch_m": 100000,
"sample_dist_m": 100, "sub_rays": 3},
"wind_source": {"type": "open-meteo",
"start_date": "2014-01-01", "end_date": "2024-12-31"},
}).json()["job_id"] # then wait and read results as abovePOST /grid/estimate takes the same outline and spacing and tells you how many sites it would make, and a spacing that fits your limit, without making the grid.
Hourly time series
Add "run_type": "timeseries" to run WEMo hour by hour through the wind record at up to 25 sites, for comparing with buoys and other models. Each site gets its wave height, period, direction, orbital velocity and bed shear stress for every hour, and WEMo can compare a site with a NOAA buoy's measured waves for you. How time series work
# Python, continuing from above: three months at a site next to NOAA buoy 44064
job = call("POST", "/jobs", json={
"name": "Hourly, Jan-Mar 2023",
"run_type": "timeseries",
"sites": {"points": [{"id": 1, "lon": -76.087, "lat": 37.008}]},
"model_params": {"n_directions": 56, "max_fetch_m": 100000,
"sample_dist_m": 100, "sub_rays": 3},
"wind_source": {"type": "open-meteo",
"start_date": "2023-01-01", "end_date": "2023-03-31"},
"timeseries": {"duration_limited": True, # waves need time to grow
"compare": {"station": "ndbc:44064"}}, # optional
}).json()["job_id"]
# ... wait for it as above, then:
ts = call("GET", f"/jobs/{job}/timeseries", params={"site_id": 1}).json()
print(ts["start"], ts["n"], "hours") # 2023-01-01T00:00Z 2160 hours
print(ts["comparison"]["stats"]) # bias_m, rmse_m, scatter_index, r ...
csv = call("GET", f"/jobs/{job}/download", params={"format": "csv", "table": "details"})
open("wemo_hourly.csv", "wb").write(csv.content) # one row per site and hourGET /jobs/{job_id}/timeseries?site_id=1gives one site's hours as arrays (the first hour instart, then one value per hour,nullwhere the wind or the measurement is missing), withobserved_wave_height_mand the statistics for the compared site./resultsgives each site's statistics over the period (mean, 50th, 90th and 99th percentile and highest wave height, mean period, the share of hours still growing, and the comparison);/results/detailsand thetable=detailsCSV give every hour.- To compare with a site of your choice, add
"site_id"tocompare; otherwise the site nearest the buoy is used. To compare with other measurements or another model, upload a CSV with atimestampand awave_heightcolumn toPOST /uploads/wavesand send{"file_id": ..., "site_id": ...}instead ofstation. - Limits: 25 sites and 250 site-years a run. Swell, structures, refraction, top winds and month filters aren't part of time series runs yet.
Endpoints
All paths start with https://wemo.geohorizons.co/api/v1. Requests and replies are JSON unless a file is downloaded.
| Request | What it does |
|---|---|
POST/jobs | Start a run. Body: name, sites (either points or a grid_id) and optional settings (below). Replies 202 with a job_id. |
GET/jobs/{job_id} | Status: queued, fetching_data, running, completed or failed, with progress (sites done of total) and, if it failed, why. |
GET/jobs/{job_id}/results | One row per site (fields below). Paged: ?page=1&page_size=1000, up to 10,000 rows a page; total_sites says how many there are. |
GET/jobs/{job_id}/results/details | One row per site and wind direction: fetch, wind speed and share of time, wave height, period, energy, orbital velocity and bed shear stress. Paged the same way; ?site_id= gives one site's rows. |
GET/jobs/{job_id}/download | A file: ?format=csv, geojson or geotiff. Add &table=details (CSV) for the per-direction rows, or &table=structures for a run with coastal structures. |
GET/jobs/{job_id}/timeseries | A time series run's hours at one site (?site_id=) as arrays, with the measured waves and statistics if that site was compared. |
POST/grid/estimate | How many sites an outline (polygon) at a spacing (resolution_m) would make, and a spacing that fits your limit. |
POST/grid/generate | Make that grid: points in water with their depths, and a grid_id to pass to /jobs. Optional holes (islands to leave out) and water_level_m. |
GET/wind/stations | Buoys and weather stations near a place (?lat=..&lon=..), whose measured wind a run can use instead of reanalysis wind. |
POST/uploads/bathymetry, /uploads/wind, /uploads/waves | Upload your own depth survey (GeoTIFF), wind record (CSV) or measured waves to compare a time series with (CSV), and get a file_id to use in a run. |
Settings
Everything except name and sites is optional. Leave a setting out and WEMo uses its default. Two defaults differ from the web app's (sample_dist_m and sub_rays), so send the model_params shown in the examples to get the same results as the app.
| Setting | What it means |
|---|---|
run_type | climate (default: exposure over the whole wind record) or timeseries (hour by hour, see above). |
timeseries.duration_limited | Time series only: limit each hour's waves by how long the wind has blown (default true). |
timeseries.compare | Time series only: {"station": "ndbc:44064"} or {"file_id": ...}, optionally with site_id. |
model_params.n_directions | Wind directions traced from each site (default 56). |
model_params.max_fetch_m | Longest fetch traced, in metres (default 100,000). |
model_params.sample_dist_m | Spacing of depth samples along each ray, in metres. The web app uses 100. |
model_params.sub_rays | Rays per direction: 1, 3 or 5. The web app uses 3, which smooths streaks behind headlands. |
model_params.water_level_m | Water level above the depth datum, for a tide or storm surge (default 0). |
model_params.sediment_d50_mm | Seabed grain size for bed shear stress (default 0.2 mm, fine sand). |
wind_source.type | open-meteo (hourly wind back to 1940, anywhere) or upload (your own record, with its file_id). |
wind_source.start_date, end_date | The wind period, as YYYY-MM-DD. Ten years or more gives a steady wind climate. |
wind_source.months | Only these months of each year, for a season: [6, 7, 8] for June to August. |
wind_source.top_wind_pct | Only the windiest hours: 90 keeps the windiest 10% (default 0, all hours). |
wind_source.wind_points | Take a wind cell's wind from a buoy or station instead, such as {"cell": {"lon": ..., "lat": ...}, "station": "ndbc:44014"}. |
bathymetry_source | {"type": "auto"} (default: NOAA's Coastal Relief Model on the US coasts where the server has it, GEBCO elsewhere), {"type": "gebco"} for GEBCO only, or {"type": "upload", "file_id": ...} for your survey. GET /bathymetry/coverage lists where the CRM is installed, and a finished job's bathymetry_info says what it used. |
swell_source | Add ocean swell from offshore, such as {"type": "open-meteo", "start_date": ..., "end_date": ...}. |
vegetation | Seagrass, marsh or other beds that slow waves: GeoJSON outlines with canopy height, stem width, density and drag. |
structures | Seawalls, revetments, breakwaters and sills, drawn as lines of [lon, lat], each with a type and optional crest_elevation_m (on a seawall or revetment: waves overtop it when model_params.water_level_m comes near the crest), crest_width_m, slope, reflection, transmission and permeable (false for concrete or sheet pile). Results also show the same sites without them. |
The full reference lists every setting with its allowed values; How it works explains what each one does to the waves.
Results
Each site comes back with the id you sent it with (as site_id), its longitude, latitude and depth, and:
| Field | What it is |
|---|---|
RWE | Representative wave energy (J/m): the wave energy in one wavelength per metre of crest, summed over directions and weighted by how often the wind blows from each. A relative measure for comparing sites in the same area and wind climate; total_RWE adds swell when the run has it. |
avg_wave_height_m, max_wave_height_m | Wave height at the site under the typical wind from each direction, averaged across directions, and the largest single direction. |
direction_of_max_wave_deg | The direction the largest waves come from. |
mean_wave_direction_deg | The energy-weighted mean direction waves come from over the wind record (the map's arrows); total_mean_wave_direction_deg includes swell. |
direction_concentration | How one-sided the exposure is, 0 to 1: 1 when every wave comes from one direction, near 0 when opposite directions balance. |
avg_fetch_m, max_fetch_m | Distance over open water to the shore, averaged over directions and the longest. |
avg_effective_fetch_m, max_effective_fetch_m | The direction-blended fetch used for wave growth: each direction blended with its neighbours, because wind passes energy to the water over a spread of angles. |
avg_orbital_velocity_ms, max_... | Water speed at the bed under the waves (m/s). |
avg_bed_shear_stress_pa, max_... | The waves' pull on the seabed (Pa), for sediment and plant stability. |
wind_cell_lat, wind_cell_lon | Which wind record the site used. |
data_quality.any_ray_off_raster | true if a ray left the depth grid before reaching shore, so its fetch may be short. |
Runs with swell, vegetation or structures add fields for them (for example swell_height_mean_m, vegetation_at_site, RWE_change_pct). The GeoJSON file has every site as a point with all its fields; the GeoTIFF has one band per result, on a grid in the local UTM zone, for GIS. The web app's five exposure classes are simply fifths of the run's sites by RWE, so you can make them yourself (for example with pandas.qcut or dplyr's ntile).
Limits
- Sites per run: your account's limit, 50,000 during early access. A bigger request gets
409 job_size_exceeded; split it into several runs or use a wider grid spacing. - One run at a time per account, from the web app and scripts together. Starting another while one is running gets
429 run_in_progress; wait for the first to finish. - 120 requests a minute per key. Checking a run's status every 5 to 10 seconds is plenty.
- Five active keys per account.
- Keep copies of results you need by downloading them, as with the web app.
Need bigger runs, more at once, or WEMo on your own servers? Email us.
Errors
Errors come back as JSON with a short code to check in a script and a message to read: {"detail": {"code": "...", "message": "..."}}.
| Status | Meaning |
|---|---|
400, 422 | Something in the request is missing or out of range, such as a site without a lon, two sites with the same id, or a wind period with no data. The message says which. |
401 | No key, a mistyped key, or a revoked key (invalid_api_key). |
403 | The request uses something that isn't turned on for your account (feature_off; details.feature says which), such as API access or a model option. Email us to have it turned on. |
404 | No such run or grid on your account. |
409 | The run has more sites than your limit (job_size_exceeded), or results were asked for before the run finished. |
429 | Another run is still going (run_in_progress), or more than 120 requests in a minute (rate_limited; the Retry-After header says how many seconds to wait). |
500, 502, 503 | A problem on our side, or a data source is briefly down. Wait a minute and try again; if it keeps happening, let us know. |
Full reference
The interactive reference lists every endpoint, setting and result field, with allowed values, and lets you try requests in the browser: choose Authorize and paste your key. The same description is available as an OpenAPI file for generating a client in other languages.
Use of the API is covered by the Terms of use; how keys are stored is in the Privacy notice. The API is new; we'll keep /api/v1 working as it is and add to it rather than change it, and give notice by email before any change that could break a script. Questions or ideas: [email protected].