# Wyvern for AI Agents

This page is the condensed, factual entry point for **AI agents, LLMs, and automated
tools** working with Wyvern hyperspectral data. Everything here is designed to be
ingested programmatically. A plain-markdown mirror of this page is served at
[`/AGENTS.md`](https://knowledge.wyvern.space/AGENTS.md).

## Machine-readable endpoints

| Resource | URL |
| --- | --- |
| Curated site map for LLMs | https://knowledge.wyvern.space/llms.txt |
| Full docs text (single file) | https://knowledge.wyvern.space/llms-full.txt |
| This page as plain markdown | https://knowledge.wyvern.space/AGENTS.md |
| Index library (JSON) | https://raw.githubusercontent.com/Nrevyw/wyvern-public-resources/refs/heads/main/index-library/wyvern_index_library.json |
| STAC catalog root (Open Data) | https://wyvern-odp.com/catalog.json |
| Open Data browser (human UI) | https://opendata.wyvern.space/ |
| Code, notebooks & agent skill | https://github.com/Nrevyw/wyvern-public-resources |
| Sensor spectral response curves | https://github.com/Nrevyw/wyvern-public-resources/tree/main/relative-spectral-responses |

## Agent skill (recommended)

A portable, tested skill teaching coding agents the full workflow — discovery,
loading, band resolution, index computation, verification — lives at
[`agent-skills/working-with-wyvern-data`](https://github.com/Nrevyw/wyvern-public-resources/tree/main/agent-skills)
in the public resources repo. Claude Code users can copy it into `.claude/skills/`;
its `SKILL.md` also works as plain context for any other framework, and its bundled
STAC helper script is standard-library Python.

## Data format facts

Both L2A and L1B are purchasable. Open Data publishes L2A; an
[L1B collection](https://wyvern-odp.com/l1b/collection.json) exists but returned zero
items as of 2026-08 — re-check rather than assuming it is still empty.

**L2A surface reflectance (Open Data today), license CC-BY-4.0:**

- Cloud-Optimized GeoTIFF, `uint16`, **multiply by `scale` = 0.0001 to get reflectance
  (0–1)**; NoData = **65535** — mask *before* scaling
- Projected to a **per-scene UTM zone** (`proj:epsg`, e.g. 32639); GSD ≈ 5.2 m
- 23 bands (Standard VNIR, Dragonette-1, ~503–799 nm) or 31 bands (Extended VNIR,
  Dragonette-2/3/4, ~445–870 nm)

**L1B top-of-atmosphere radiance:**

- `float32`, W·m⁻²·sr⁻¹·µm⁻¹, no scale factor; NoData = −9999
- EPSG:4326 with non-square pixels sized to 5 m at scene-centre latitude
- Convert to ToA reflectance with
  [top-of-atmosphere-processing](https://github.com/Nrevyw/wyvern-public-resources/tree/main/top-of-atmosphere-processing),
  or [atmospherically correct](https://knowledge.wyvern.space/docs/documentation/getting_started/atmospheric_correction) to surface
  reflectance for quantitative work. Prefer L2A for absorption-feature analysis — L1B
  radiance carries the solar spectrum and atmospheric features (e.g. the O₂ band near
  760 nm) that swamp subtle targets.

**Where band metadata lives (two authoritative per-scene sources):**

```python
# 1. The GeoTIFF's own tags — already in nm, works offline
with rasterio.open(path) as src:
    cwl = [float(src.tags(b)["wavelength"]) for b in range(1, src.count + 1)]
    fwhm = [float(src.tags(b)["FWHM"]) for b in range(1, src.count + 1)]
    nodata = src.nodata            # 65535 — read it, don't hardcode
```

2. The STAC item's COG asset `eo:bands` (`center_wavelength` /
   `full_width_half_max` in **µm**) and `raster:bands` (dtype / nodata / scale).

⚠️ `src.scales` is **1.0** — the GeoTIFF does *not* carry the 0.0001 reflectance scale.
Take it from STAC `raster:bands`; trusting `src.scales` silently leaves data unscaled.

## STAC catalog structure

Root `https://wyvern-odp.com/catalog.json` (STAC 1.0.0) has child catalogs grouping
the same scenes three ways: `year/`, `application/` (agriculture, mining, coastal,
forestry, …), and `product-type/{standard,extended}` — the collection files' `rel:
item` links are the scenes.

Item assets (keys contain spaces — quote them):

| Asset key | Content |
| --- | --- |
| `Cloud optimized GeoTiff` | The hyperspectral imagery (with `eo:bands`, `raster:bands`) |
| `Data Mask` | Valid-data mask COG |
| `Pixel Quality Mask` | Quality/cloud mask COG |
| `Overview image`, `Thumbnail image` | PNG previews |
| `stac_metadata` | This item JSON |
| `zip_file` | Everything bundled |

Filterable item properties: `eo:cloud_cover`, `datetime`, `platform`,
`view:sun_elevation`, `proj:epsg`, plus the item `bbox`. Note `product_type` is the
literal string `"hyperspectral"` on every item and does **not** identify the band
configuration — use the band count (23 = Standard, 31 = Extended) or the
`product-type/{standard,extended}` catalog path.

## Code quickstart

```python
import requests, rasterio
import numpy as np

# 1. Discover: walk product-type collections; each rel=item link is a scene.
#    (Send a User-Agent header: the CDN 403s Python-urllib's default.)
col = requests.get("https://wyvern-odp.com/product-type/extended/collection.json").json()
item_url = next(l["href"] for l in col["links"] if l["rel"] == "item")
item = requests.get(item_url).json()

# 2. Resolve wavelengths -> 1-based band numbers from THIS scene's metadata
cog = item["assets"]["Cloud optimized GeoTiff"]
bands = cog["eo:bands"]  # wavelengths in µm
def band_for(nm):
    return min(range(len(bands)),
               key=lambda i: abs(bands[i]["center_wavelength"] * 1000 - nm)) + 1

# 3. Load (use a windowed read for an AOI), mask, THEN scale
with rasterio.open(cog["href"]) as src:
    red = src.read(band_for(660)).astype("float64")
    nir = src.read(band_for(800)).astype("float64")
    nodata = src.nodata                  # 65535, from the file
scale = 0.0001                           # from cog["raster:bands"]; src.scales is 1.0
red = np.where(red == nodata, np.nan, red) * scale
nir = np.where(nir == nodata, np.nan, nir) * scale

# 4. Compute + verify: reflectance in [0, ~1], NDVI in [-1, 1]
ndvi = (nir - red) / (nir + red)
```

Index definitions with per-product-type band mappings (JSON keyed by
`"Standard VNIR"` / `"Extended VNIR"`, 1-based `band_index`):
[wyvern_index_library.json](https://raw.githubusercontent.com/Nrevyw/wyvern-public-resources/refs/heads/main/index-library/wyvern_index_library.json)
— this is the machine-readable source of truth. The browsable
[Hyperspectral Index Library](https://knowledge.wyvern.space/hyperspectral_library) is generated from it.

## Recommended Python packages

```bash
pip install rasterio numpy pystac requests pyproj shapely spectral matplotlib
```

| Package | Use |
| --- | --- |
| `rasterio` | Read/write COGs, windowed reads, reprojection — the default loader |
| `numpy` | Band math, index calculation, masking |
| `pystac` | Parse STAC items (`pystac.Item.from_file(url)` works against the catalog) |
| `requests` | HTTP fetches (its default User-Agent avoids the CDN 403) |
| `pyproj`, `shapely` | AOI geometry and CRS transforms (each scene has its own UTM zone) |
| `spectral` (SPy) | Target detection (ACE, matched filter), anomaly detection (RX), SAM, MNF, unmixing |
| `scikit-learn` | Classification and clustering |
| `xarray` + `rioxarray`, `dask` | Labeled dimensions, time series, out-of-core processing |
| `matplotlib` | Spectral plots and index maps |

`pystac-client` is only useful against a STAC *API*; the Open Data catalog is static
JSON, so walk `rel: item` links instead. Avoid `pysptools` — it fails to import on
current Python versions; `spectral` covers the same algorithms and is maintained.

## Spectral libraries

[OpenSpecLib](https://github.com/null-jones/openspeclib) (third-party, not
Wyvern-maintained) amalgamates USGS Spectral Library 7, ECOSTRESS, and EcoSIS into one
schema-validated structure. **Pin a release** — counts, sizes and grid layout shift
between them. v0.0.6, which the REE notebook also pins, holds 32,940 spectra: 26,780
vegetation, 2,885 mineral, 1,410 water, 470 rock, 440 man-made. Download release assets
directly — no install needed:

```bash
BASE=https://github.com/null-jones/openspeclib/releases/download/v0.0.6
curl -sLO $BASE/usgs_splib07.parquet     #  38 MB — minerals/rocks
curl -sLO $BASE/wavelengths.parquet      # 0.3 MB — REQUIRED for wavelengths
curl -sLO $BASE/ecosis.parquet           # 307 MB — optional; vegetation, large
```

Two schema details that trip up first attempts: spectra store
`spectral_data.values` but **not** their wavelengths — those live in
`wavelengths.parquet`, joined on `spectral_data.wavelength_grid_id`; and wavelengths
are **not uniformly µm** — usgs_splib07 and ecostress grids are µm but all 43 ecosis
grids (which hold the 26,780 vegetation spectra) are nm, so read `wavelength_unit`
rather than hardcoding a conversion. Bad-band fill values are large negatives
(e.g. `-1.23e34`) and must be masked. There is also a no-install
[browser viewer](https://null-jones.github.io/openspeclib/) that can search, plot,
simulate Wyvern-band downsampling, and export CSV/ENVI `.sli`.

## Spectral analysis approaches

Run these on masked, scaled reflectance shaped `(rows, cols, bands)` using `spectral`:

| Goal | Method |
| --- | --- |
| "Is material X here?" (have a reference spectrum) | **ACE** (`sp.ace`) — scale-invariant, responds to spectral shape rather than brightness |
| "What's unusual here?" (no reference spectrum) | **RX** (`sp.rx`) |
| Denoise / reduce bands | **MNF** (`sp.mnf`) — preferred over PCA, orders by SNR rather than variance |
| Fractional abundance | endmembers (`sp.smacc`/`sp.ppi`) → `sp.unmix` |
| Sharpen narrow absorptions | `sp.remove_continuum` |

⚠️ **SPy cannot consume a masked cube directly.** Wyvern swaths are rotated, so a NoData
fringe is always present. `ace`, `rx`, `mnf` and `calc_stats` raise `NaNValueError`, and
`spectral_angles` and `smacc` are worse — they return corrupted or all-NaN output
without raising. Pass only the valid pixels as a degenerate `(N, 1, bands)` array and
scatter the results back; the agent skill ships tested `valid_pixels` / `scatter_scores`
helpers for exactly this.

SPy has no `mtmf()` — don't report plain `matched_filter` as MTMF. For working code,
thresholding, and the failure modes of each method, use the skill's
[spectral-analysis reference](https://github.com/Nrevyw/wyvern-public-resources/blob/main/agent-skills/working-with-wyvern-data/references/spectral-analysis.md)
rather than reimplementing from this summary.

The core pipeline is **resample reference spectrum → ACE → verify hits resemble the
reference**, worked end to end in the
[rare earth elements notebook](https://github.com/Nrevyw/wyvern-public-resources/tree/main/tutorial-notebooks/detecting-rare-earth-elements)
(neodymium at Mountain Pass) — which runs ACE on plain scaled reflectance and uses
continuum removal only to inspect features in plots, not as a detection step. Read it
before building a new detection workflow.

**Resample reference spectra before comparing.** Library spectra are measured at
1–10 nm; Wyvern bands are 16–32 nm wide, so a reference must be convolved onto each
band's response (FWHM-weighted). Naive interpolation over-weights narrow features and
produces wrong scores. The agent skill ships `scripts/resample_spectra.py` for this.

**Wyvern is VNIR-only, and the range depends on the product type** — Standard VNIR is
503–799 nm, Extended VNIR 445–869 nm. Check the band count before applying this table;
a target at 460 nm or 860 nm is undetectable on a Standard scene:

| Detectable in VNIR | Needs SWIR (not detectable) |
| --- | --- |
| Rare earth elements — Nd³⁺ features at 585, 745, 810, 870 nm; Standard reaches only the first two | Clays / phyllosilicates |
| Ferric iron (Fe³⁺ band ~630–715 nm, both product types) | Carbonates |
| Vegetation pigments, chlorophyll, stress | Hydrocarbons, most alteration minerals |
| Water constituents, chlorophyll-a, turbidity | Evaporites, sulfates |

**Detecting ferric iron is not the same as naming the oxide.** Hematite's discriminating
minimum is ~860 nm (just inside Extended, outside Standard) and goethite's is
~900–920 nm — beyond both. Say "ferric material is present"; only Extended supports
"hematite-like", and goethite cannot be located at all.

Don't over-generalize to "minerals need SWIR" — REE detection in VNIR is a proven
Wyvern workflow. But when a target's diagnostic features fall outside the scene's
range, say so rather than presenting a weak score as a detection.

**Interpreting detection results:** scores are relative to the scene, not absolute.
Threshold statistically (e.g. 99.9th percentile) and state the threshold; confirm hits
aren't clouds, shadows, glint, or scene edges; check that the detected pixels' mean
spectrum actually resembles the target; and corroborate with a second method. "Not
detected" is a valid finding.

## Common wavelength → band numbers

| Use | nm | Standard VNIR | Extended VNIR |
| --- | ---: | ---: | ---: |
| Blue (coastal) | 445–490 | — | 1–4 |
| Green | 550 | 5 | 9 |
| Red | 660 | 12 | 16 |
| Red edge | 712 | 17 | 21 |
| Red edge | 750 | 20 | 24 |
| NIR | 800 | 23 | 27 |
| NIR (upper) | 870 | — | 31 |

## Pitfalls checklist

1. **Mask NoData (65535) before applying the 0.0001 scale** — scaling first turns
   NoData into 6.5535, which silently poisons statistics.
2. **Never hardcode band numbers across scenes** — Standard and Extended VNIR differ
   (band 20 is ~750 nm on Standard but ~700 nm on Extended); resolve per scene.
3. **`eo:bands` wavelengths are µm**, not nm — but the GeoTIFF's `wavelength` tags are
   already nm. Also: `src.scales` is 1.0, so the 0.0001 scale must come from STAC.
4. **Asset keys contain spaces** (`item["assets"]["Cloud optimized GeoTiff"]`).
5. **Each scene has its own UTM zone** — reproject to a common CRS before mosaicking
   or cross-scene comparison; transform lon/lat AOIs into the scene CRS before
   windowed reads.
6. **Python-urllib's default User-Agent gets HTTP 403** from the catalog CDN — set
   any custom User-Agent (the `requests` default works).
7. **The imagery host rate-limits.** Metadata/previews come from `wyvern-odp.com`, but
   COGs are on `wyvern-data.com`, which returns **HTTP 429** under repeated access.
   GDAL often reports this as `Range downloading not supported by this server!` rather
   than a 429 — treat it as "back off and retry" (`GDAL_HTTP_MAX_RETRY`,
   `GDAL_HTTP_RETRY_DELAY`), and fall back to downloading the asset if streaming keeps
   failing.
8. **A wavelength farther than one FWHM from the nearest band center isn't covered**
   by the sensor — report that rather than substituting silently.
9. **Diagnostic features outside the scene's range are undetectable** — Standard is
   503–799 nm, Extended 445–869 nm. Clay, carbonate, sulfate and hydrocarbon
   absorptions are in SWIR; say so instead of reporting a weak match. REE and ferric
   iron *are* VNIR-detectable, so don't refuse those either.

---

Canonical HTML version: https://knowledge.wyvern.space/docs/documentation/agents
Site map for LLMs: https://knowledge.wyvern.space/llms.txt · Full docs text: https://knowledge.wyvern.space/llms-full.txt
