Skip to main content

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, activity hotspots and statistics, 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 the highest-severity signals in an AOI (bbox = [min_lon, min_lat, max_lon, max_lat])
    page = client.signals.list(
        bbox=[22, 44, 40, 53],
        days=7,
        min_severity=5,
        escalating=True,
        sort="severity",
        limit=100,
    )
    print(page.meta.count, "signals,", page.meta.tokens.charged, "tokens charged")
    for s in page.signals:
        print(s.event_date, s.country_code, s.title, s.severity_score)

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], days=30, min_severity=6):
    print(signal.title)

list() / iterate() also support differential-sync and observability filters: updated_since / created_since (only signals (re)enriched at/after an ISO 8601 timestamp — for incremental sync), observability (observable / not-observable), open_data (sufficient / commercial-recommended / not-applicable), and min_information_gain (0-1).

Fetch one signal by id

signal = client.signals.get(4123456789)   # global_event_id from a list() result
print(signal.title, signal.severity_score)

Aggregates & hotspots

stats = client.signals.stats(bbox=[22, 44, 40, 53], days=7)
for row in stats.stats.by_category:
    print(row.category, row.count)

hotspots = client.signals.hotspots(bbox=[22, 44, 40, 53], precision=0.5)
for h in hotspots.hotspots:
    print(h.lat, h.lng, h.count, h.max_severity)

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,
    days=14,
)
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=1315598448, 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)

Claim ledger

Audit what you were told before acting on it. Every assertion carries its evidence class and independent source count; when a later answer restated it, the claim links to it and says which way the evidence moved. Free.

# The ones to read first: where a later answer was LESS sure than an earlier one.
for claim in client.claims.list(downgraded_only=True).claims:
    print(claim.evidence_class, claim.text, "->", claim.restate_reason)

Standing orders

Put an area under continuous watch. Creating, listing, pausing and deleting are free — only a check that actually fires runs the Analyst and is metered.

order = client.standing_orders.create(bbox=[55, 25, 57, 27], name="hormuz", cadence="weekly")
print(order.summary)                      # states the monthly token ceiling up front
client.standing_orders.set_active(order.order.id, active=False)   # pause, keep the order
client.standing_orders.delete(order.order.id)

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.

assessment = client.intelligence.assess(event_id=123, 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 with signals.list(observability="observable").

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=True means a large box was read below native posting — min_m/max_m are smoothed inward, so the true relief is at least relief_m;
  • covered=False (point) or tiles_missing > 0 (area) means open water, where the model has no value. elevation_m is None there — 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 start is moved forward and clamped_to_archive says 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 skipped with its reason;
  • trend compares the first and last measured scene only (basis says 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], days=7):
            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], "days": 7})
    brief = mcp.read_resource("brief://latest")

Tools: query_signals, query_stats, query_hotspots, 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

Apache-2.0. See LICENSE and NOTICE.

Release files for offnadir-delta 0.15.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for offnadir-delta 0.15.0
File Size Uploaded
offnadir_delta-0.15.0.tar.gz 74.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for offnadir-delta 0.15.0
File Interpreter ABI Platform
offnadir_delta-0.15.0-py3-none-any.whl Python 3 none any Details

Total release size: 143.6 kB

Release files / offnadir_delta-0.15.0.tar.gz

Download URL offnadir_delta-0.15.0.tar.gz
Size 74.9 kB
Tags Source
SHA-256 checksum
How to use checksums
9df04d3ed3cc499c83ea6493a79d02878ffe6366fd330017921bd192c244f47d
BLAKE2b-256 checksum
How to use checksums
6356e6b17b13508ec78b39abc39377cf5a2ed8c2005b0a64a4d877edcd49d313
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-0.15.0-py3-none-any.whl

Download URL offnadir_delta-0.15.0-py3-none-any.whl
Size 68.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5169988c79d20c8feb4526dc48cc026f2c5755d7479d15f23b5da55e02fc5639
BLAKE2b-256 checksum
How to use checksums
1da47f63cd61b173cfb449fa703476fdbc98ea271f7b57904dc4f89b65a7fbf0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.9.13

Release history Release notifications | RSS feed

0.21.0

2 release files

0.20.0

2 release files

0.18.0

2 release files

0.17.0

2 release files

This release

0.15.0 This release

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.4.0

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page