Metadata-Version: 2.4
Name: open-sar-triad
Version: 1.1.0
Summary: Discover and download open SAR scenes from ICEYE, Umbra and Capella through one API
Author: Pavan Muguda Sanjeevamurthy
License: MIT
Project-URL: Homepage, https://www.pmuguda.com/open-sar-triad/
Project-URL: Source, https://github.com/pmuguda/open-sar-triad
Project-URL: Issues, https://github.com/pmuguda/open-sar-triad/issues
Project-URL: STAC catalog, https://www.pmuguda.com/open-sar-triad/api/v1/catalog.json
Keywords: sar,synthetic aperture radar,stac,earth observation,remote sensing,iceye,umbra,capella,open data
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
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: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: plot
Requires-Dist: matplotlib>=3.7; extra == "plot"
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == "pandas"
Provides-Extra: geo
Requires-Dist: geopandas>=0.14; extra == "geo"
Requires-Dist: shapely>=2.0; extra == "geo"
Provides-Extra: stac
Requires-Dist: pystac>=1.10; extra == "stac"
Provides-Extra: notebooks
Requires-Dist: pandas>=2.0; extra == "notebooks"
Requires-Dist: matplotlib>=3.7; extra == "notebooks"
Requires-Dist: jupyter>=1.0; extra == "notebooks"
Provides-Extra: all
Requires-Dist: matplotlib>=3.7; extra == "all"
Requires-Dist: pandas>=2.0; extra == "all"
Requires-Dist: geopandas>=0.14; extra == "all"
Requires-Dist: shapely>=2.0; extra == "all"
Requires-Dist: pystac>=1.10; extra == "all"

# open-sar-triad (Python client)

Discover and download open Synthetic Aperture Radar scenes from **ICEYE**, **Umbra** and **Capella** through one interface.

```python
from opensartriad import Catalog

cat = Catalog()
scenes = cat.search(bbox=(5.9, 47.2, 10.5, 55.1), start="2025-01-01", family="complex")
scenes.download("data/", family="complex", dry_run=True)
```

- **No API key, no account, no rate limits.** The backend is a static catalog on a CDN.
- **No required dependencies.** The client is standard library only.
- **Imagery is never proxied.** Downloads go straight to each provider's own storage.

---

## Contents

- [Install](#install)
- [Quick start](#quick-start)
- [Searching](#searching)
- [Working with a scene](#working-with-a-scene)
- [Product families](#product-families)
- [Downloading](#downloading)
- [Exporting](#exporting)
- [Plotting](#plotting)
- [STAC interoperability](#stac-interoperability)
- [How it works](#how-it-works)
- [API reference](#api-reference)
- [Licence and attribution](#licence-and-attribution)

---

## Install

```bash
pip install open-sar-triad
```

The client has **no required dependencies**: it is standard library only, so this
pulls in nothing else.

Optional extras:

```bash
pip install "open-sar-triad[pandas]"     # to_dataframe()
pip install "open-sar-triad[geo]"        # geopandas / shapely workflows
pip install "open-sar-triad[stac]"       # pystac interop
pip install "open-sar-triad[notebooks]"  # everything the example notebooks use
```

From source:

```bash
git clone https://github.com/pmuguda/open-sar-triad.git
pip install -e open-sar-triad/python
```

---

## Quick start

```python
from opensartriad import Catalog

cat = Catalog()

# What is in the catalog?
stats = cat.stats()
print(stats["total"])            # 14798
print(stats["by_provider"])      # {'umbra': 11892, 'capella': 2469, 'iceye': 437}
print(stats["temporal_extent"])  # ['2020-12-31', '2026-08-19']

# Find scenes over an area, in a date window
scenes = cat.search(
    bbox=(5.9, 47.2, 10.5, 55.1),   # west, south, east, north (EPSG:4326)
    start="2025-01-01",
    end="2026-01-01",
)
print(scenes)
# SceneCollection(129 scenes; umbra 128, iceye 1; 2025-01-30 to 2026-07-09)

# Look at one
s = scenes[0]
print(s.id, s.provider, s.date, s.mode, s.formats)
```

The first `search()` downloads a ~0.6 MB index and caches it on the `Catalog`
instance. Later searches are local and instant, so reuse one `Catalog`.

---

## Searching

All filters combine with **AND**. Omitted filters do nothing.

```python
scenes = cat.search(
    bbox=(72.8, 18.9, 73.1, 19.3),  # (west, south, east, north)
    start="2025-06-01",             # inclusive; str, date or datetime
    end="2025-12-31",               # inclusive
    providers=["umbra", "capella"], # or a single string
    mode="spotlight",               # sensor mode, case-insensitive
    orbit="ascending",              # or "descending"
    look="right",                   # or "left"
    family="complex",               # only scenes that can satisfy this family
    formats=["SLC", "SICD"],        # or exact formats
    limit=100,                      # stop after N matches
)
```

`SceneCollection` behaves like a list:

```python
len(scenes)
scenes[0]
scenes[:10]
for s in scenes:
    ...
[s for s in scenes if s.year == 2025]
```

**bbox and the antimeridian.** Matching is a bounding-box intersection test, so
it is inclusive at the edges: a scene whose footprint box touches your box is
returned even if the polygon itself does not overlap. A box crossing ±180°
should be passed as two searches.

---

## Working with a scene

Cheap fields come from the search index and cost nothing:

```python
s.id          # provider scene id
s.provider    # 'iceye' | 'umbra' | 'capella'
s.date        # 'YYYY-MM-DD'
s.year        # int
s.mode        # sensor mode, lowercase
s.orbit       # 'ascending' | 'descending' | None
s.look        # 'left' | 'right' | None
s.formats     # ['GEC', 'CSI', 'SICD', 'SIDD', 'CPHD']
s.bbox        # [west, south, east, north]
```

These trigger a lazy fetch of that provider's full records the first time (then cached):

```python
s.url("SICD")           # download URL for an exact format
s.metadata_url("SICD")  # provider metadata sidecar, or None if not published
s.properties            # polarization, resolution, incidence angle, ...
s.geometry              # GeoJSON footprint
s.assets                # full STAC asset map
```

---

## Product families

Each provider names the same kind of product differently. Asking for `SLC` by
name returns **nothing** from Umbra, even though all of its scenes carry complex
data, because Umbra labels it `SICD`.

Families solve this. Ask for what the product **is**, and each scene resolves to
its own provider's equivalent:

| Family | Resolves in order |
|---|---|
| `detected` | `GEO` → `GRD` → `GEC` → `SIDD` |
| `complex` | `SLC` → `SICD` |
| `phase` | `CPHD` |
| `visual` | `CSI` → `VID` |

```python
s.resolve("complex")    # 'SICD' for Umbra, 'SLC' for ICEYE/Capella
s.resolve("detected")   # 'GEC' for Umbra, 'GRD' for ICEYE, 'GEO' for Capella
s.resolve("phase")      # 'CPHD', or None if that scene has none
```

Each scene contributes **one file per family**, so you get the best available
product rather than several near-duplicates.

---

## Downloading

> **SAR products are large.** A single GRD can exceed 1 GB. Always `dry_run` first.

```python
# See what would happen; downloads nothing
scenes.download("data/", family="complex", dry_run=True)
# 6 file(s): 3 data + 3 metadata, from 3 scene(s)
#   [data    ] umbra/2025-07-15-06-10-12_UMBRA-08_SICD.nitf
#   [metadata] umbra/2025-07-15-06-10-12_UMBRA-08.stac.v2.json

# Then commit
paths = scenes.download("data/", family="complex")
```

Files land in `data/<provider>/`, with each provider's **metadata sidecar saved
next to every data file**, so your archive arrives documented rather than as a
pile of anonymous rasters.

```python
scenes.download(
    dest="data/",
    family="complex",       # or formats=["SLC", "SICD"]
    metadata=True,          # fetch sidecars (default)
    skip_existing=True,     # resume-friendly (default)
    dry_run=False,
    quiet=False,
)
```

Inspect the plan without downloading:

```python
jobs = scenes.download_urls(family="detected")
len(jobs)
jobs[0]  # {'scene': ..., 'provider': 'umbra', 'format': 'GEC', 'url': ..., 'kind': 'data'}
```

Downloads are resumable: existing non-empty files are skipped, partial files are
written to `.part` and only renamed on success, and one failed asset does not
abort the batch.

---

## Exporting

```python
scenes.to_geojson()             # GeoJSON FeatureCollection (dict)
scenes.save_geojson("aoi.geojson")
scenes.to_stac()                # STAC ItemCollection, for pystac / stackstac
scenes.to_dataframe()           # pandas DataFrame (needs pandas)
```

```python
df = scenes.to_dataframe()
df.groupby("provider").size()
df.groupby(df["date"].str[:7]).size()   # scenes per month
```

---

## Plotting

```bash
pip install "open-sar-triad[plot]"
```

Four plots, all drawn against a **country basemap**:

```python
scenes.plot_coverage()     # footprints on a world map, coloured by provider
scenes.plot_timeline()     # acquisitions over time, stacked by provider
scenes.plot_providers()    # scene counts per provider
scene.plot_footprint()     # one scene, with surrounding geography
```

Every one returns a matplotlib `Axes` and accepts `ax=`, so they compose into your own figures:

```python
import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 1, figsize=(13, 10))
scenes.plot_coverage(ax=axes[0])
scenes.plot_timeline(ax=axes[1], freq="year")
fig.savefig("report.png", dpi=150, bbox_inches="tight")
```

### Coverage options

```python
scenes.plot_coverage(
    footprints=True,            # true acquisition polygons instead of bounding boxes
    bbox=(-10, 35, 30, 60),     # zoom to an area
    basemap=False,              # skip the country outlines
    alpha=0.5,
    title="Scenes over Europe",
)
```

`plot_coverage` draws **bounding boxes by default**, because those come free with the search index. `footprints=True` is more accurate but downloads the full per-provider records first.

### About the basemap

Country outlines come from the same `world-atlas` data the web map uses, decoded in about forty lines rather than pulling in `cartopy` (which needs PROJ and GEOS compiled) or `contextily` (which needs rasterio). It is fetched once and cached under `~/.cache/opensartriad/`.

Several mirrors are tried in turn, and **if all of them are blocked the plot still renders**, without the basemap and with a warning. That matters on networks where public CDNs are unreachable.

```python
from opensartriad.plotting import load_world, draw_basemap

load_world()                      # rings, cached after the first call
draw_basemap(ax, required=True)   # raise instead of warning if unavailable
```

---

## STAC interoperability

The backend **is** a STAC catalog, so standard tools work without this client:

```python
import pystac

cat = pystac.Catalog.from_file(
    "https://www.pmuguda.com/open-sar-triad/api/v1/catalog.json"
)
for child in cat.get_children():
    print(child.id, child.title)
```

Or take search results straight into a STAC workflow:

```python
items = scenes.to_stac()["features"]
```

Raw endpoints, if you want to skip the client entirely:

| Endpoint | What |
|---|---|
| `/api/v1/catalog.json` | STAC root catalog |
| `/api/v1/collections/{provider}.json` | STAC Collection |
| `/api/v1/items/{provider}.json` | STAC ItemCollection |
| `/api/v1/index.json` | compact search index |
| `/api/v1/stats.json` | counts and extents |
| `/api/v1/scenes/{provider}.geojson` | raw FeatureCollection |

Base URL: `https://www.pmuguda.com/open-sar-triad/api/v1`

---

## How it works

There is no server. Every endpoint is a file, rebuilt weekly by CI and served
from a CDN. That has real consequences worth knowing:

- **Search is local.** `search()` filters a cached index in memory, so it is
  instant and unmetered. There is nothing to rate limit.
- **Detail is lazy.** Download URLs live in the larger per-provider files, which
  are fetched only when you first ask for a URL, then cached.
- **Reuse one `Catalog`.** A new instance means a new cold cache.
- **Data refreshes weekly.** `stats()["generated"]` tells you when.

```python
cat = Catalog()                              # default hosted API
cat = Catalog("http://localhost:8000/api/v1")  # a local build, for testing
```

---

## API reference

### `Catalog(base_url=DEFAULT_BASE_URL, timeout=60)`

| Method | Returns | Notes |
|---|---|---|
| `search(...)` | `SceneCollection` | see [Searching](#searching) |
| `get(scene_id)` | `Scene \| None` | exact id lookup |
| `all()` | `SceneCollection` | every scene |
| `stats()` | `dict` | counts, extents, families |
| `stac_catalog()` | `dict` | STAC root |
| `stac_collection(provider)` | `dict` | STAC Collection |
| `stac_items(provider)` | `dict` | full ItemCollection (large) |
| `license()` | `dict` | attribution notice |

### `Scene`

Attributes `id`, `provider`, `date`, `year`, `mode`, `orbit`, `look`, `formats`, `bbox`
(free), and `assets`, `properties`, `geometry` (lazy).
Methods `url(fmt)`, `metadata_url(fmt)`, `resolve(family)`.

### `SceneCollection`

A `Sequence` of `Scene`. Methods `download(...)`, `download_urls(...)`,
`to_geojson()`, `save_geojson(path)`, `to_stac()`, `to_dataframe()`.

### Exceptions

`OpenSarTriadError` for any network or API failure.

---

## Licence and attribution

The **client code** is MIT.

**Scene metadata is CC-BY 4.0**, published by ICEYE, Umbra and Capella under
their open data programs. **When you publish derived work, credit the
originating provider.**

```python
print(Catalog().license()["attribution"])
```

Imagery is **not** redistributed by this project. Every download URL points at
the provider's own storage and remains subject to that provider's terms.

This is an independent third-party catalog, not endorsed by or affiliated with
ICEYE, Umbra or Capella. Scene records are normalized from the STAC catalogs
maintained by [Jack-Hayes/commerical-sar-stac](https://github.com/Jack-Hayes/commerical-sar-stac) (MIT).
