Metadata-Version: 2.4
Name: offnadir-delta
Version: 0.8.0
Summary: Official Python SDK for the Off-Nadir Delta geospatial event-intelligence API and MCP server.
Project-URL: Homepage, https://offnadir-delta.com
Project-URL: Documentation, https://offnadir-delta.com/docs/api
Project-URL: Source, https://github.com/Off-Nadir-Lab/offnadir-delta-sdk
Project-URL: Issues, https://github.com/Off-Nadir-Lab/offnadir-delta-sdk/issues
Author: Off-Nadir Lab
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: event-intelligence,geoint,geospatial,mcp,osint,satellite,stac
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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 :: GIS
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.24
Requires-Dist: pydantic<3,>=2
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: respx>=0.20; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# Off-Nadir Delta — Python SDK

Official Python client for the [Off-Nadir Delta](https://offnadir-delta.com) 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

```bash
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.

```python
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:

```python
usage = client.usage()
print(usage.tokens.remaining, "tokens left, LLM access:", usage.plan.api_llm_access)
```

## Quickstart

```python
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):

```python
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

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

### Aggregates & hotspots

```python
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.

```python
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`.

```python
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.

```python
# 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.

```python
# 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.

```python
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.

```python
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.

```python
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):

```python
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):

```python
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)

```python
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.

```python
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.

```python
# 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.**

```python
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.

```python
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.

```python
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.

```python
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`:

```python
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):

```python
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](https://modelcontextprotocol.io) 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](https://offnadir-delta.com/docs/api). For programmatic use with a
static API key, this SDK ships a thin client:

```python
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

```bash
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](LICENSE) and [NOTICE](NOTICE).
