Off-Nadir Delta — Python SDK
Official Python client for the Off-Nadir Delta geospatial event-intelligence API and MCP server. Query geolocated, source-linked event signals, search satellite imagery (STAC), and run AI assessments and ask the analyst agent — all from Python. The AI tools are available on every plan, including Free — gated only by your token balance.
- Sync (
Client) and async (AsyncClient) — one dependency (httpx). - Fully typed responses (Pydantic v2), forward-compatible with additive API fields.
- Automatic cursor pagination, retry with backoff, and a uniform error model.
- A thin MCP client (
McpClient/AsyncMcpClient) for the JSON-RPC MCP endpoint.
Requires Python 3.9+. Licensed under Apache-2.0.
Install
pip install offnadir-delta
Authentication
Create an API key from your account's Developer API page. Keys start with ond_ and are
shown once. Pass it explicitly or via the OFFNADIR_DELTA_API_KEY environment variable.
from offnadir_delta import Client
client = Client(api_key="ond_...") # or: Client() -> reads OFFNADIR_DELTA_API_KEY
The API meters usage in tokens against the same wallet as the web app. Every metered
response reports what it cost under meta.tokens. Check your balance for free first:
usage = client.usage()
print(usage.tokens.remaining, "tokens left, LLM access:", usage.plan.api_llm_access)
Quickstart
from offnadir_delta import Client
with Client() as client:
# One page of Severity 6+ signals placed to a town or finer in an AOI over a week
# (bbox = [min_lon, min_lat, max_lon, max_lat]).
page = client.signals.list(
bbox=[22, 44, 40, 53],
start_date="2026-09-21",
end_date="2026-09-27",
min_severity_band=6,
stages=["localized", "pinpointed"],
limit=100,
)
print(page.meta.count, "signals,", page.meta.tokens.charged, "tokens charged")
for s in page.signals:
print(s.occurred_at, s.country_code, s.title, s.severity_score, s.stage)
Auto-pagination
signals.iterate() follows the cursor for you (each page is a separately-metered request):
for signal in client.signals.iterate(bbox=[22, 44, 40, 53], recency="24h", min_severity_band=6):
print(signal.title)
The filters are the app's, with the same choices — the API accepts nothing the app does not offer, and a retired parameter is refused (400) with its replacement named:
| Where in the app | Parameter | Choices |
|---|---|---|
| Watchfloor date range | start_date, end_date |
YYYY-MM-DD, both or neither |
| Watchfloor time window | recency |
"15m", "1h", "24h" (default without a date range) |
| Watchfloor search | q |
every word must match; "two words"; -word excludes |
| Watchfloor category / market | categories, markets |
lists |
| Watchfloor stage | stages |
reported, localized, pinpointed |
| Watchfloor severity | min_severity_band |
4, 6, 9 |
| Watchfloor publishers | min_publishers |
2, 3, 5 |
| Watchfloor location | placement |
"all", "map" (drawable as a point), "list" |
| Watchfloor sort | sort |
"latest" (default), "oldest", "geoint" |
The app's Map uses these same filters (since 2.0.0). The Map's own filters — observable, sensors,
collection_levels, escalation_trends — were removed from the app and from the API; read
collection.observable, collection.sensor, collection.level and escalation_trend on each signal.
updated_since (only signals refolded at or after an ISO 8601 timestamp) is for incremental
sync. On Free, the Watchfloor filters beyond category (q, stages, min_severity_band,
min_publishers, markets, placement) and the 15m / 1h windows are not applied, and
page.meta.filter_clamp lists what was dropped — the same rule as the Watchfloor.
Fetch one signal by id
signal = client.signals.get("3f0c…") # the `id` (a UUID) from a list() result
print(signal.title, signal.severity_score)
Satellite imagery (STAC search)
Metadata only — no image bytes, no signed URLs. bbox is required.
scenes = client.imagery.search(
bbox=[22, 44, 40, 53],
collection="sentinel-2-l2a",
cloud_cover_max=20,
start_date="2026-09-14",
end_date="2026-09-27", # omit both for the last 7 days
)
for scene in scenes:
print(scene.id, scene.datetime, scene.cloud_cover)
Optical-observability weather
Cloud cover is what gates a Sentinel-2 optical pass. Get a per-day outlook (cloud
at the ~10:30 local overpass, best clear day) to decide optical vs SAR collection.
Requires a deployment with a commercial Open-Meteo licence. Weather data by
Open-Meteo.com (CC BY 4.0) — surface result.weather.attribution.
result = client.weather.observability(lat=48.85, lon=2.35, end_date="2026-07-22")
print("best optical day:", result.weather.best_optical_day)
for day in result:
print(day.date, day.optical_verdict, day.overpass_cloud_cover_pct)
Collection planning & tasking
The collection manager's half of the product: what is worth imaging, which sensor class answers it, and when a satellite can next see it.
# The deterministic plan for ONE event — each collection searched exactly once against
# the event footprint, so the all-weather (SAR) look cannot be missed.
plan = client.collection.plan(event_id=signal.id, analysis_goal="damage_assessment")
for step in plan.plan.steps:
print(step.collection, step.usable_count, step.sar_pair_status)
# Where observation is most worthwhile. Counts are reported separately from the returned
# slice, so "2 returned" is never mistaken for "2 exist".
ranked = client.collection.priority(bbox=[22, 44, 40, 53], top_n=5)
print(ranked.priority.returned, "of", ranked.priority.total_available)
for target in ranked.targets:
print(target.headline, target.rs_level, target.collection_ready, target.readiness_blockers)
# Exhaustive per-sensor survey — the misses are reported too, not just the hits.
survey = client.collection.observability(sensor="sentinel-1", top_n=10).survey
print(survey.observable_count, "observable /", survey.not_observable_count, "not")
# Next viewing opportunities. `collection_mode` is the field that matters: a systematic
# satellite acquires on a fixed plan, an agile one only images if somebody tasks it.
for p in client.collection.passes(lat=26.6, lon=56.5, max_passes=5).passes:
print(p.satellite, p.collection_mode, p.peak_elevation_deg, p.sunlit)
Standing orders
Put an area under continuous watch — the app's "Watch this area": the area is checked weekly,
and you are notified only when something crossed the reporting bar. It takes the area, a
name and notify_email. Creating and listing are free — only a check that
actually fires runs the Analyst and is metered. The order joins the Watchlist, and you pause
or remove it there, as in the app.
order = client.standing_orders.create(bbox=[55, 25, 57, 27], name="hormuz")
print(order.summary) # states the monthly token ceiling up front
client.watches.pause(order.watch_id) # pause, keep the order
client.watches.delete(order.watch_id) # removes everything bound to that watch
Notifications (free)
Every change a watch reports — an event development, a newly related event, an anomaly, a new
event at a site, a new scene, a fired standing order — is one notification with a read state. It
is the same list as the bell in the app, so marking one read here clears it there too. A value
your plan does not open is locked, with lock_plan naming the plan that opens it.
page = client.notifications.list(unread_only=True)
print(page.unread_count)
for n in page:
print(n.at, n.watch["name"], n.line)
client.notifications.mark_read(ids=[n.id for n in page]) # or mark_read(all=True)
Measured events (metered per page)
Six kinds of event are made from a measurement or an official record rather than from reporting.
They appear in signals.list(categories=[...]) like any other event, and each has a reader that
returns the values behind it. A record states what was recorded and never a cause.
| Reader | What a record is |
|---|---|
client.thermal_anomalies |
a heat source far above its own recent level (satellite) |
client.transit_anomalies |
ship transits at a strait or canal against their usual level (an index, never ship counts) |
client.nightlight_anomalies |
a city's night lights below half of its own usual level (satellite) |
client.so2_anomalies |
sulfur dioxide above the local background around a volcano (satellite) |
client.quake_exposures |
a USGS earthquake with registered facilities within a set distance — distances, not shaking or damage |
client.cyclone_exposures |
registered places inside a tropical cyclone's forecast wind radii — a forecast (Atlantic, eastern Pacific and central Pacific only), not an observation |
for q in client.quake_exposures.iterate(start_date="2026-10-01", end_date="2026-10-07"):
if q.detail_withheld: # the plan returns when and where, not the values
continue
eq = q.earthquake
print(eq["magnitude"], eq["depth_km"], q.radius_km, [f["name"] for f in q.facilities])
page = client.cyclone_exposures.list()
print(page.meta["notes"]) # what the record does and does not establish
print(page.meta["attribution"]) # the source credit — show it with the data
for storm in page.cyclone_exposures:
print(storm.storm, storm.places_inside_now_count, storm.places_inside_forecast_count)
thermal_anomalies and the four below it take bbox, start_date / end_date, limit and
cursor; transit_anomalies.list() takes no arguments. On a plan without the full event detail
every record is still returned with detail_withheld=True — None in a field then means "not
shown", not "unknown".
Version probe (no API key)
Checking which build is live must not require a credential, so this one works without a client. The three hashes fingerprint the tool contract — compare them to detect a stale cached roster.
from offnadir_delta.resources.version import probe
v = probe()
print(v.version, v.tool_count, v.schema_hash)
AI assessment & analyst
Available on every plan, including Free — gated only by your token balance (an
insufficient balance raises InsufficientTokensError). Check client.usage() first.
signal = client.signals.list(recency="24h").signals[0]
assessment = client.intelligence.assess(event_id=signal.id, kind="quick") # 5 quick / 15 deep tokens
print(assessment.content)
answer = client.intelligence.analyst(
"What is escalating in the Black Sea this week?",
bbox=[22, 44, 40, 53],
) # metered 5-123 tokens
print(answer.brief)
assess is cached per (account, event, kind), so re-assessing the same event is free.
A signal with no satellite-imageable footprint raises NotObservableError (422) before
any token is charged — pre-filter on signal.collection.observable from signals.list.
Long analyst runs (async jobs)
The analyst is hybrid-async: a run that finishes within ~95s returns the AnalystBrief
directly (as above); a longer run returns an AnalystJobPending and completes in the
background — poll it for free, and pay only when it completes. Pass an idempotency_key
so a re-send resolves to the same run instead of being charged again (without one,
analyst is not idempotent and is never retried automatically):
from offnadir_delta import AnalystBrief
result = client.intelligence.analyst(
"Compare naval activity across the Black Sea and Baltic this month",
idempotency_key="black-sea-baltic-2026-07-17",
)
if not isinstance(result, AnalystBrief): # AnalystJobPending (HTTP 202)
job = client.intelligence.get_job(result.job_id) # free; status: running|done|error
print(job.status)
Or let the SDK poll to completion for you (sync and async):
answer = client.intelligence.analyst_and_wait(
"Compare naval activity across the Black Sea and Baltic this month",
idempotency_key="black-sea-baltic-2026-07-17",
poll_interval=5.0,
timeout=300.0,
)
print(answer.brief)
On timeout this raises APITimeoutError, but the run keeps going server-side —
re-fetch the brief later via get_job(job_id) (or the same idempotency_key) at no
extra charge.
Daily World Brief (free)
brief = client.brief.get() # latest; or client.brief.get("2026-07-11")
Terrain elevation (free)
Heights come from the Copernicus DEM GLO-30. Free and unmetered — a deterministic read of a public dataset.
point = client.elevation.point(44.9695, 24.3636)
print(point.point.elevation_m, "m") # 260.0
area = client.elevation.area([24.24, 44.93, 24.48, 45.01])
print(area.stats.relief_m, "m of relief") # max - min
ring = [[24.3, 44.9], [24.4, 44.9], [24.4, 45.0], [24.3, 44.9]]
inside = client.elevation.polygon(ring) # statistics over the ring interior
print(inside.stats.measured_over) # "polygon_interior"
Three things the answer carries that you should not flatten away:
- it is a surface model (
surface_type == "dsm"): buildings and tree canopy are included, so it is not bare ground; downsampled=Truemeans a large box was read below native posting —min_m/max_mare smoothed inward, so the true relief is at leastrelief_m;covered=False(point) ortiles_missing > 0(area) means open water, where the model has no value.elevation_misNonethere — that is absence, not 0 m.
Terrain-derived analysis (free)
Two computations from the DEM itself, beyond reading heights out of it.
# How much of an area radar cannot use, for a given geometry.
g = client.terrain.sar_geometry(
[138.70, 35.34, 138.76, 35.38], incidence_deg=35, look_azimuth_deg=270
).sar_geometry
print(g.layover_fraction, g.shadow_fraction, g.mean_local_incidence_deg)
# Can this position see that one?
los = client.terrain.profile(35.3604, 138.7271, 35.5171, 138.7519).profile.line_of_sight
print(los.clear, los.blocked_at_m, los.blocking_elevation_m)
incidence_deg and look_azimuth_deg have no defaults on purpose: the answer changes with both.
Measured on one volcanic flank, layover was 2.6% looking west and 0.7% looking east at the same 35°,
so a default would answer about a geometry you did not ask for. look_azimuth_deg is the compass
bearing the sensor looks along the ground range — a right-looking descending pass looks roughly west
(270).
Both results carry sample_spacing_m, and it belongs in any conclusion drawn from them: a coarser
grid reads flatter, and therefore more observable, than the ground is.
How an index changed over the archive (metered per scene)
The retrospective half of monitoring: same measurement, run backwards over Sentinel-2 rather than forwards over new acquisitions. Estimate first — it is free, and it is the only way to know the cost before you spend it.
plan = client.index_series.estimate(
"ndvi", "2013-01-01", "2026-08-01", bbox=[67.64, 33.31, 67.74, 33.84]
).estimate
print(plan.scenes_found, plan.scenes_measurable, plan.estimated_tokens)
for note in plan.notes:
print(note) # the archive floor, and whether this will be a sample
series = client.index_series.measure(
"ndvi", "2020-01-01", "2026-08-01", polygon=[[67.6, 33.3], [67.7, 33.3], [67.7, 33.4]]
).series
for s in series:
print(s.date, s.mean, s.cloudCover)
print(series.trend.change, series.trend.basis)
Three things the response states rather than hides, and which belong in anything built on it:
- the Sentinel-2 archive begins 2015-06-27. An earlier
startis moved forward andclamped_to_archivesays so — those years are genuinely unavailable, not empty; - at most 24 scenes are measured per call, so a longer period comes back as a sample, with
every unmeasured scene listed in
skippedwith its reason; trendcompares the first and last measured scene only (basissays exactly that). It is not a fitted rate, so do not attach a slope or a confidence to it.
Only a scene actually read is charged; a failed read appears in skipped and costs nothing.
Counting vessels in a SAR scene (metered)
Radar sees through cloud and at night, so a count works where optical returns nothing. Search the catalog, pick a scene, then detect on it.
scene = client.imagery.search(bbox=[37.79, 44.69, 37.83, 44.72], collection="sentinel-1-grd")[0]
result = client.ships.detect("sentinel-1-grd", scene.id, bbox=[37.79, 44.69, 37.83, 44.72])
print(result.count)
for c in result.caveats:
print(c)
caveats is part of the answer, not a footnote. It states when the land mask was unavailable
(shoreline structures may be counted as vessels), how far from the coast detections were excluded
(the default excludes vessels alongside a quay), and when the scene covered only part of the area —
a partial-coverage count must never be compared with a full one as though the difference were
vessels. A scene the worker refuses is not charged.
This measures one scene at one time; to track a berth over time, create a monitored area with
metric ships instead.
Continuous measurement (Delta Monitor)
Free to create and to read; metered only per scene actually measured.
for area in client.monitoring.list():
print(area.name, area.metric, area.latest_value, area.anomaly)
client.monitoring.create([139.7, 35.6, 139.8, 35.7], "ndwi", name="Berth 4")
series = client.monitoring.get(area_id) # every point traceable to its STAC item
Data freshness / pipeline status (free)
Pre-flight whether the underlying data is fresh enough before spending on a metered query.
status = client.status.get() # or client.status.current()
print(status.pipeline_status, "— data current through", status.data_current_through)
Async
Every call has an await-able equivalent on AsyncClient:
import asyncio
from offnadir_delta import AsyncClient
async def main():
async with AsyncClient() as client:
async for signal in client.signals.iterate(bbox=[22, 44, 40, 53], recency="24h"):
print(signal.title)
asyncio.run(main())
Error handling
All failures raise a subclass of OffnadirError carrying .status_code, .code, and
.request_id (quote it in support requests):
from offnadir_delta.errors import (
AuthenticationError, PermissionDeniedError, InvalidRequestError,
NotObservableError, InsufficientTokensError, RateLimitError, APIError,
)
try:
client.signals.list(bbox=[22, 44, 40, 53])
except InsufficientTokensError as e:
print("Need", e.required, "have", e.available)
except RateLimitError as e:
print("Retry after", e.retry_after, "seconds")
Transient failures (429, 5xx, connection errors) are retried automatically with backoff
(honoring Retry-After); configure with Client(max_retries=...). The last response's
rate-limit headers are available on client.last_rate_limit.
MCP
The API also exposes a stateless MCP server at
/api/v1/mcp (JSON-RPC 2.0). MCP hosts such as Claude connect to it directly over
OAuth 2.1 — see the docs. For programmatic use with a
static API key, this SDK ships a thin client:
from offnadir_delta import McpClient
with McpClient() as mcp:
mcp.initialize()
print([t["name"] for t in mcp.list_tools()])
result = mcp.call_tool("query_signals", {"bbox": [22, 44, 40, 53], "recency": "24h"})
brief = mcp.read_resource("brief://latest")
Tools: query_signals, search_imagery,
assess_weather_observability (optical outlook; commercial weather licence only),
get_world_brief, get_usage, assess_signal (metered), ask_analyst (metered) — all
available on every plan, gated only by token balance.
Development
pip install -e ".[dev]"
ruff check src tests
mypy src
pytest
Tests run fully offline (HTTP mocked with respx) — they never call the live API or spend
tokens.
License
Metadata
Release files for offnadir-delta 3.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| offnadir_delta-3.1.0.tar.gz | 111.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| offnadir_delta-3.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 201.2 kB
Release files / offnadir_delta-3.1.0.tar.gz
| Download URL | offnadir_delta-3.1.0.tar.gz |
|---|---|
| Size | 111.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9c8cf3df0e98271c01962d9d65848094f9806e5dcf88db5eeb501f9082a9286b
|
|
BLAKE2b-256 checksum How to use checksums |
5525b416304ea258eba810257f2d6e9d7b6de8609d0d22d1ec4f97fe65c89282
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.13
|
Release files / offnadir_delta-3.1.0-py3-none-any.whl
| Download URL | offnadir_delta-3.1.0-py3-none-any.whl |
|---|---|
| Size | 89.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
57f1a2dd0464a9bcb59e799b4dae8fb6e938dc80ad590d211b71d6696a915c24
|
|
BLAKE2b-256 checksum How to use checksums |
1cf386989dc68d95aacd7fe23882f8b1ec58664d20f0003ac9dbb83134c1be43
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.13
|