Skip to content

AI features

Opt-in, model-backed surfaces that keep the library deterministic — see the determinism boundary. A model is only called at the edge; every result is re-validated or provenance-stamped.

LLM context

llm_context

llm_context()

Return the domain knowledge an agent needs to drive umbra-py.

A single JSON-serialisable dict: the product-type table (with one-line explanations), the search-parameter semantics, the polarization change-detection caveat, and the mandatory CC-BY license/attribution rules. Pull it into a model's context at the start of a session so it can pick the right product and build a valid search without a round trip.

This is the user agent guide ("how to drive this library"), the counterpart to the repo's AGENTS.md contributor guide.

llms_txt

Generate the llms.txt context bundle for umbra-py.

Context is a product surface. The llms.txt convention <https://llmstxt.org/>_ gives a project one well-known, LLM-ready description of itself: a concise /llms.txt index and an expanded /llms-full.txt that an agent can pull into context in a single fetch. Where :func:umbra_py.llm_context is the machine-readable domain document (a JSON dict for programmatic use), this module renders the prose guide — "how to drive this library" — for a model reading Markdown.

Two documents are produced, both from facts already in the package:

  • :func:llms_txt — the concise index (title, one-paragraph orientation, and link sections), the convention's /llms.txt.
  • :func:llms_full_txt — the self-contained bundle: the determinism boundary, the domain knowledge from :func:umbra_py.llm_context, the full CLI command reference (introspected from the live umbra command tree), the AI-native interfaces, and each core module's explanatory docstring. This is /llms-full.txt.

Design notes, in keeping with the library's determinism boundary (AGENTS.md): this module is deterministic and stdlib-only — it describes the library, it never calls a model, and it imports no heavy extra. Module docstrings are read from source with :mod:ast rather than by importing the modules, so the generator runs in the bare requests + click core install without pulling in fastapi, mcp, matplotlib or the rest. The committed llms.txt / llms-full.txt at the repo root are the rendered output of these functions; a golden test keeps them from drifting. Regenerate with::

umbra llms-txt > llms.txt
umbra llms-txt --full > llms-full.txt

llms_txt

llms_txt()

Return the concise llms.txt index as a Markdown string.

The llms.txt convention <https://llmstxt.org/>_ document: an H1 title, a one-line blockquote summary, a short orientation, and link sections pointing an agent at the fuller resources (chief among them llms-full.txt). It is the map; :func:llms_full_txt is the territory.

llms_full_txt

llms_full_txt()

Return the expanded llms-full.txt bundle as a Markdown string.

Everything an agent needs to drive umbra-py, in one document and in reading order: the determinism boundary, the domain knowledge (product types, product order, search-parameter semantics, the polarization caveat, the license), the full CLI command reference introspected from the live command tree, the two AI-native interfaces, and each core module's explanatory docstring. Assembled entirely from facts already in the package, so it never drifts from the code it describes.

llms_full_txt

llms_full_txt()

Return the expanded llms-full.txt bundle as a Markdown string.

Everything an agent needs to drive umbra-py, in one document and in reading order: the determinism boundary, the domain knowledge (product types, product order, search-parameter semantics, the polarization caveat, the license), the full CLI command reference introspected from the live command tree, the two AI-native interfaces, and each core module's explanatory docstring. Assembled entirely from facts already in the package, so it never drifts from the code it describes.

AI_PROVENANCE module-attribute

AI_PROVENANCE = "AI-generated interpretation of SAR imagery. Descriptions are a model's reading of the scene, not verified measurements, and may be wrong; verify against the source data before relying on them."

Natural-language search (ask)

ask

ask(question, *, planner=None, model=None, today=None, aois=())

Turn a natural-language question into a validated :class:SearchPlan.

Builds the prompt (:func:build_messages), calls the planner (default: :func:default_planner, chosen from environment keys) to get the model's raw reply, then validates it deterministically (:func:parse_plan). The returned plan is safe to execute: every filter has passed the determinism boundary. The model is only consulted to produce the raw plan; inject a planner in tests to avoid any network call.

aois offers the model the caller's own areas of interest to choose between (umbra ask --aoi coast.geojson). The same sequence goes into the prompt and into the validation, so a plan can only ever select one of them.

parse_plan

parse_plan(raw, question, *, today=None, aois=())

Validate a model's raw plan dict into a :class:SearchPlan.

This is the determinism boundary. Every field the model produced is re-checked here before it can become a filter: dates are resolved by :func:umbra_py.parse_date_bound (so a season or a bad date is caught), product types must be canonical :data:umbra_py.PRODUCT_ASSETS, the bbox is range-checked, and place/bbox/aoi are enforced mutually exclusive. Unknown keys are ignored. Raises :class:AskError with a self-describing message on any invalid field.

aois are the caller's areas of interest; a plan's aoi must name one of them (see :func:_coerce_aoi), so the polygon a search runs against is always the user's own geometry.

today anchors relative dates for deterministic tests, mirroring :func:umbra_py.parse_date_bound.

SearchPlan dataclass

SearchPlan(question, area=None, fuzzy=False, place=None, bbox=None, aoi=None, start=None, end=None, product_types=list(), polarizations=list(), min_incidence=None, max_incidence=None, max_resolution=None, limit=None, max_per_task=None, rationale=None)

A validated, deterministic search the model's plan maps to.

Every field has already passed through :func:parse_plan -- dates are ISO YYYY-MM-DD strings, product_types are canonical :data:umbra_py.PRODUCT_ASSETS names, bbox is a 4-tuple of floats. place (a free-text name geocoded at execution time), bbox and aoi are mutually exclusive -- one spatial filter, however it was spelled. rationale is the model's one-line explanation, kept only to show the user; it never becomes a filter.

to_search_kwargs

to_search_kwargs()

The keyword arguments for :meth:umbra_py.UmbraCatalog.search.

Omits place/bbox -- the caller resolves those into a single bbox (geocoding place) in the deterministic execution layer, exactly as the umbra search command does. A selected aoi needs no such resolution (its rings were parsed before the model ever saw its name), so it passes straight through as intersects. The SAR acquisition-property filters (polarizations / min_incidence / max_incidence / max_resolution) push through to the same :meth:~umbra_py.models.UmbraItem.matches_filters predicate every other surface shares.

to_command

to_command()

The plan as a copy-pasteable umbra search ... command string.

to_dict

to_dict()

A plain JSON-serialisable view of the plan (for --json).

Published as docs/schemas/search-plan.schema.json -- the document a caller audits before running the plan, which is why command is in it.

AreaOfInterest dataclass

AreaOfInterest(name, geometry, source=None)

A named polygon the caller supplied, which a plan may select by name.

The unit of the "chosen, never authored" rule described in the module docstring: geometry is already-parsed exterior rings (whatever :func:umbra_py._geometry.parse_geometry accepted), so the coordinates come from the user's own file and the model contributes only the name.

source is how the user spelled it on the command line (a path, or inline GeoJSON), kept so the audited umbra search --intersects … line is the command they would have typed rather than an inlined ring dump.

bbox property

bbox

The area's bounding box -- what the prompt shows the model, and what makes an --json plan auditable without the full ring list.

to_dict

to_dict()

A JSON view for --json: the name, the spelling, and the bounds.

The rings themselves are deliberately omitted -- they are the user's input, unchanged, and can be arbitrarily large; source says where to find them.

SemanticTaskIndex

SemanticTaskIndex(path=None)

An embedding index over Umbra task names for semantic area matching.

Open (creating the database and schema if needed) with a path, or no path to use :func:default_semantic_path. Usable as a context manager, which commits and closes on exit::

from umbra_py.semantic import SemanticTaskIndex, default_embedder

embed = default_embedder()                 # needs an embedding API key
with SemanticTaskIndex() as sem:
    sem.build(index_path="catalog.db", embedder=embed)     # embed once
    for match in sem.matching_tasks("grain storage north dakota", embed):
        print(match.task, round(match.score, 3))           # Beet Piler - ND ...

The model is consulted only to embed text (the injected embedder); storage, ranking and thresholding are deterministic and offline.

stored_model

stored_model()

The embedding model the stored vectors were produced with, or None for an empty index. Mixing models in one index is disallowed (see :meth:build), so this is single-valued.

build

build(*, embedder, task_names=None, index_path=None, model='default', batch_size=128, progress=None)

Embed task names and persist their vectors. Returns the number newly embedded (an already-stored name is skipped, so a rebuild is cheap).

Provide the names explicitly via task_names or let them be read from a catalog index (index_path, default :func:~umbra_py.index.default_index_path). model is a label recorded with each vector so a query can refuse to compare across embedding models; all vectors in one index must share it (rebuild in a fresh file to switch models). embedder is called in batches of batch_size; progress (if given) receives (done, total) after each batch.

matching_tasks

matching_tasks(query, embedder, *, top_k=5, min_score=0.0)

Rank the stored task names by semantic closeness to query.

Embeds query once with embedder, scores every stored vector by :func:cosine_similarity, and returns the top_k matches with score >= min_score, highest first. Returns an empty list if nothing clears the threshold. Raises :class:SemanticError if the index is empty (build it first) or the query embedding's length disagrees with the stored vectors (a model mismatch).

stats

stats()

Summary for umbra semantic info: how many task vectors are stored, the embedding model they came from, and their dimensionality.

SemanticMatch dataclass

SemanticMatch(task, score)

One ranked candidate: an Umbra task name and its cosine score against the query (1.0 identical, 0.0 unrelated, higher is closer).

umbra semantic search --json emits these as docs/schemas/task-matches.schema.json.

parse_date_bound

parse_date_bound(value, *, is_end=False, today=None)

Resolve a date expression to a concrete :class:date.

value may be None (returns None), a :class:date / :class:datetime (returned as a date, unchanged), or a string in any of the accepted forms:

  • a full ISO date YYYY-MM-DD;
  • a bare year 2024 or year-month 2024-03 (see is_end);
  • today / now / yesterday / tomorrow;
  • a point offset "<n> <unit> ago" (unit: day/week/month/year, also "a week ago"); or
  • a period this/last week/month/year (see is_end).

is_end disambiguates spans: a bare year, year-month, or period keyword resolves to the first day of that span by default and its last day when is_end is true, so a start bound and an end bound each snap to the natural edge. Full ISO dates and point offsets are single days and ignore is_end.

today overrides the anchor for relative expressions (defaults to :meth:date.today), which keeps the resolver deterministic under test.

Raises :class:ValueError for an unrecognized string, with a message that lists the accepted forms.

matching_tasks

matching_tasks(query, task_names, *, fuzzy=True)

Filter task_names to those matching query (see :func:task_matches), preserving input order.

Scene description

describe

umbra describe: a vision-language reading of a SAR scene, grounded in the imagery the library already renders and the domain facts it already carries.

This is the first of the Tier C "VLM-in-the-loop" capabilities (C2; see docs/STRATEGY.md). Where umbra ask (:mod:umbra_py.planner) lets a model plan a search, umbra describe lets a model read a scene: it renders an item's quicklook, sends that picture plus the item's :meth:~umbra_py.UmbraItem.to_llm_context card to a vision model, and returns a structured description -- {summary, observed_features[], confidence, caveats[]}. The idea is the one the whole AI direction rests on: the library's outputs are images with precise metadata, the native input of a VLM, so nothing new has to be invented -- only connected.

How it stays honest

The library's determinism boundary (docs/STRATEGY.md §7) still holds:

  1. The picture and the metadata are produced deterministically. The quicklook is the same :func:umbra_py.quicklook render every other command uses; the context card is the same offline :meth:~umbra_py.UmbraItem.to_llm_context. The model is shown facts, not asked to invent them.
  2. The model only interprets; it never becomes a filter, a URL, or a coordinate. Its reply is validated into a :class:SceneDescription by the deterministic :func:parse_description -- a description is text about the scene, never data the rest of the library acts on.
  3. Provenance is mandatory. Every :class:SceneDescription carries the CC-BY :data:~umbra_py.constants.ATTRIBUTION and the :data:~umbra_py.constants.AI_PROVENANCE note, so a downstream reader can never mistake a model's reading of radar for a measurement. The same license discipline the library applies to GeoTIFF tags, extended to model text.

Like :mod:umbra_py.planner and :mod:umbra_py.semantic, the model call is an injectable :data:Describer callable and the render step is an injectable :data:Renderer, so the deterministic pieces (:func:build_describe_messages, :func:parse_description) are stdlib-only and fully offline-testable and no test ever touches the network. The default describer is built from environment variables and uses only :mod:requests (a core dependency) -- no heavy SDK. The whole feature lives behind the [ai] extra (plus [viz] for the render) and never runs implicitly: only umbra describe reaches a model, and only when the user invokes it with a key configured.

Where the picture comes from

The default is a fresh render, which costs a cloud-optimized GeoTIFF overview streamed from S3 on every call. Since :meth:~umbra_py.index.CatalogIndex.bake_thumbnails landed -- and since the weekly publish ships a whole-catalog catalog.thumbs.db sidecar -- most of those reads are of a picture this machine already has. So preview="baked" / "auto" (umbra describe --preview) reads the baked quicklook instead: no range read, no rasterio, and the whole C2 capability becomes available from local bytes on an install that has only the [ai] extra.

It is opt-in rather than automatic because it changes what the model was shown: a baked preview is a 128--256 px decibel-stretched quicklook, where a render defaults to 1024 px of whichever asset was asked for. Two consequences, both handled here rather than left to the caller:

  • A request a baked preview cannot answer (a picture of another product, db=False) is refused by :func:baked_preview_refusal under "baked" and quietly rendered under "auto". Advertisement and refusal are one function, so the reason a substitution is unsafe is the same string either way. Which product a preview is comes from the index's own record of the bake (:class:~umbra_py.index.BakedPreview) where there is one, so a deliberate --asset CSI bake answers a --asset CSI reading; only a preview with no record falls back to assuming the default bake.
  • Every description records the picture it was read from (:class:SceneImage, on SceneDescription.image), and a preview smaller than the render that was asked for adds a deterministic caveat saying so -- the library's own, never the model's. A reading of a 128 px preview is not the same evidence as a reading of a 1024 px render, and a description that does not say which cannot be compared with one that read the other.

DescribeError

DescribeError(message='', *, hint=None)

Bases: UmbraError

Raised when a scene cannot be rendered or a model reply cannot be parsed.

Carries a human- and agent-readable message so a caller can show what went wrong (an unrenderable asset, an unparseable reply).

SceneImage dataclass

SceneImage(source, asset, max_size, db=True, width=None, height=None)

Which picture the model was actually shown.

A description is a reading of an image, so the image is part of its provenance: source is "rendered" (a fresh quicklook streamed for this call) or "baked" (the cached preview from the local index), width and height are the picture's real pixel dimensions (read from the PNG header, so they are what the model saw rather than what was requested), and max_size / asset / db are the render settings the call asked for.

The pair matters because they can disagree: a baked preview is 128--256 px where max_size defaults to 1024, and the reading of a scene at those two scales is not the same evidence. width is None only when the bytes are not a PNG this can measure (an injected renderer in a test); the source is still recorded, since which cache a picture came from is the part a reader cannot recover afterwards.

to_dict

to_dict()

A plain JSON-serialisable view of the image record (for --json).

SceneDescription dataclass

SceneDescription(item_id, summary, observed_features=list(), confidence=None, caveats=list(), model=None, asset='GEC', attribution=ATTRIBUTION, provenance=AI_PROVENANCE, image=None)

A validated, provenance-stamped model reading of a SAR scene.

Every field has passed through :func:parse_description. summary is a short plain-language paragraph; observed_features and caveats are lists of short strings; confidence is the model's own hedge ("low"/"medium"/"high" or None). attribution and provenance are filled in deterministically -- the model never sets them -- so the mandatory CC-BY line and the "this is an AI interpretation" note always travel with the text.

to_dict

to_dict()

A plain JSON-serialisable view of the description (for --json).

Published as docs/schemas/scene-description.schema.json. The contract marks which fields a model wrote and which the library stamped on, since that distinction is the whole reason the document exists.

to_text

to_text()

A human-readable rendering for the CLI's default (non-JSON) output.

build_describe_messages

build_describe_messages(context_card, image_png)

Build the {"system", "user", "image_png"} prompt for a vision model.

Deterministic and offline: the system prompt embeds the SAR primer and the required JSON schema; the user message is the item's :meth:~umbra_py.UmbraItem.to_llm_context card as JSON (so the model reads the metadata rather than guessing it), and image_png is the rendered quicklook the model looks at. This is what an injectable :data:Describer receives.

parse_description

parse_description(raw, *, item_id=None, model=None, asset='GEC')

Validate a model's raw reply into a :class:SceneDescription.

This is the interpretation boundary. The model's text is checked into a fixed shape -- summary must be a non-empty string, the two lists are coerced to clean string lists, confidence is normalised to low/medium/high (or dropped) -- and the mandatory :data:~umbra_py.constants.ATTRIBUTION and :data:~umbra_py.constants.AI_PROVENANCE are stamped on deterministically, never taken from the model. Unknown keys are ignored. Raises :class:DescribeError on a missing or ill-typed summary.

render_quicklook_png

render_quicklook_png(item, *, asset='GEC', max_size=1024, db=True)

Render an item's quicklook and return it as PNG bytes for a vision model.

A thin wrapper over :func:umbra_py.quicklook (so the model sees exactly the render a human would) that encodes to PNG in memory rather than to a file. db=True by default -- the decibel stretch is the radiometrically-correct SAR look and reveals the terrain/structure a model needs. Requires the viz extra.

baked_preview_refusal

baked_preview_refusal(*, asset='GEC', db=True, baked=None)

Why a baked preview cannot answer this request, or None if it can.

A substitution is only safe when the cached picture is a smaller version of the one asked for rather than a different one, so this compares the request against what the bake actually was: baked.asset, recorded beside the bytes by :meth:~umbra_py.index.CatalogIndex.bake_thumbnails. A deliberate --asset CSI bake therefore answers a CSI reading, which it could not when the index stored pixels and nothing else.

Two cases keep the older, assumed rule. A preview with no record (baked before the index kept one, or published in a sidecar that predates it) is trusted only for :data:BAKED_PREVIEW_ASSET, since that is what the default bake renders. And the stretch is assumed either way: every bake is the decibel one -- :meth:~umbra_py.index.CatalogIndex.bake_thumbnails has no say in it -- so db=False is refused without needing a record, and without needing a lookup.

One function, two uses, so they cannot drift: preview="baked" raises the string it returns, and preview="auto" reads it as "render this one instead". The same shape as umbra serve's :func:~umbra_py.serve.stats_option_refusal, where the refusal and the advertisement of what an instance supports are also the same function.

default_describer

default_describer(*, model=None)

Build a :data:Describer from environment variables.

Chooses a provider by which key is set, mirroring :func:umbra_py.planner.default_planner:

  • ANTHROPIC_API_KEY -> Anthropic Messages API (ANTHROPIC_BASE_URL overrides the host; model default claude-sonnet-5).
  • else OPENROUTER_API_KEY -> OpenRouter's OpenAI-compatible endpoint (OPENROUTER_BASE_URL overrides the host; model default openai/gpt-4o-mini). Checked before OPENAI_API_KEY because it is an unambiguous opt-in -- nobody sets it by accident -- so it routes to OpenRouter even in an environment that already carries a stray OPENAI_API_KEY.
  • else OPENAI_API_KEY -> OpenAI-compatible chat completions (OPENAI_BASE_URL overrides the host; model default gpt-4o-mini).

Every default is vision-capable. UMBRA_DESCRIBE_MODEL (or the model= argument / --model flag) overrides the model for whichever provider is selected -- name an OpenRouter model like anthropic/claude-3.5-sonnet to pick one there. Raises :class:umbra_py.MissingDependencyError with setup guidance when no key is configured -- the feature never runs without an explicit, user-supplied key.

describe

describe(item, *, describer=None, render=None, previews=None, preview='render', model=None, asset='GEC', max_size=1024, db=True)

Render an item's quicklook and return a validated :class:SceneDescription.

Renders the scene (:func:render_quicklook_png by default, or an injected render), builds the multimodal prompt from the picture and the item's :meth:~umbra_py.UmbraItem.to_llm_context card, calls the describer (default: :func:default_describer, chosen from environment keys), and validates the reply through :func:parse_description. The returned description carries the mandatory attribution and AI-provenance note.

preview chooses where the picture comes from (see :data:PREVIEW_SOURCES). The default "render" streams a fresh quicklook, so nothing about a description changes unless this is asked for; "baked" and "auto" read the cached preview through previews -- a :meth:~umbra_py.index.CatalogIndex.get_preview-shaped callable -- which costs no S3 range read and needs no viz extra. What was read is recorded on :attr:SceneDescription.image, and a preview smaller than the requested max_size adds a deterministic caveat (:func:_baked_preview_caveat) after the model's own, so a reading never quietly claims a resolution it did not have.

The model is only consulted to read the rendered scene; inject a describer, a render and/or previews in tests to avoid any network call or the viz extra.

parse_description

parse_description(raw, *, item_id=None, model=None, asset='GEC')

Validate a model's raw reply into a :class:SceneDescription.

This is the interpretation boundary. The model's text is checked into a fixed shape -- summary must be a non-empty string, the two lists are coerced to clean string lists, confidence is normalised to low/medium/high (or dropped) -- and the mandatory :data:~umbra_py.constants.ATTRIBUTION and :data:~umbra_py.constants.AI_PROVENANCE are stamped on deterministically, never taken from the model. Unknown keys are ignored. Raises :class:DescribeError on a missing or ill-typed summary.

SceneDescription dataclass

SceneDescription(item_id, summary, observed_features=list(), confidence=None, caveats=list(), model=None, asset='GEC', attribution=ATTRIBUTION, provenance=AI_PROVENANCE, image=None)

A validated, provenance-stamped model reading of a SAR scene.

Every field has passed through :func:parse_description. summary is a short plain-language paragraph; observed_features and caveats are lists of short strings; confidence is the model's own hedge ("low"/"medium"/"high" or None). attribution and provenance are filled in deterministically -- the model never sets them -- so the mandatory CC-BY line and the "this is an AI interpretation" note always travel with the text.

to_dict

to_dict()

A plain JSON-serialisable view of the description (for --json).

Published as docs/schemas/scene-description.schema.json. The contract marks which fields a model wrote and which the library stamped on, since that distinction is the whole reason the document exists.

to_text

to_text()

A human-readable rendering for the CLI's default (non-JSON) output.

Change narration

narrate

umbra change --narrate: a vision-language reading of what changed between two SAR passes, grounded in a deterministic per-block dB-delta sidecar.

This is the second Tier C "VLM-in-the-loop" capability (C2; see docs/STRATEGY.md), the sibling of :mod:umbra_py.describe. Where umbra describe has a model read one scene, umbra change --narrate has a model narrate the change between two acquisitions of the same site: it renders the change composite (the classic green-appeared / magenta-vanished image), computes a coarse grid of signed backscatter change in decibels, and sends both the picture and the numbers to a vision model, which returns a structured :class:ChangeNarration -- {summary, changes[], confidence, caveats[]}.

Why the numeric sidecar matters

A model shown only the composite can hallucinate change ("a ship appeared in the harbor") that the pixels do not support. So the narration is grounded in a deterministic artifact: :func:compute_change_stats divides the co-registered scene into a coarse grid and, per block, measures the mean signed change in dB (20*log10(later) - 20*log10(earlier) -- positive means the block brightened, negative means it dimmed) plus the fraction of the block that changed beyond a threshold. The model is handed this grid and told to narrate only change the numbers support, and the same grid ships as a JSON sidecar next to the image -- so every statement in the narration is auditable against a number a human (or a test) can recompute. Narration cites numbers, not vibes.

How it stays honest

The library's determinism boundary (docs/STRATEGY.md §7) holds exactly as it does for :mod:umbra_py.describe:

  1. The picture and the numbers are produced deterministically. The composite is the same :func:umbra_py.change_composite render; the dB grid is plain :func:compute_change_stats. The model is shown facts, not asked to invent them.
  2. The model only interprets. Its reply is validated into a :class:ChangeNarration by :func:parse_narration; nothing it says becomes a filter, a URL, a coordinate, or a measurement -- the measurements are the sidecar's, computed offline.
  3. Provenance is mandatory. Every :class:ChangeNarration carries the CC-BY :data:~umbra_py.constants.ATTRIBUTION and the :data:~umbra_py.constants.AI_PROVENANCE note, so a downstream reader never mistakes a model's reading of radar for ground truth.

Like :mod:umbra_py.describe, the model call is an injectable :data:Narrator and the render step an injectable :data:ChangeRenderer, so the deterministic pieces (:func:compute_change_stats, :func:build_narrate_messages, :func:parse_narration) are stdlib-only and fully offline-testable and no test ever touches the network. The default narrator reuses the same provider plumbing as umbra describe (Anthropic or any OpenAI-compatible endpoint, user-supplied key, :mod:requests only -- no heavy SDK). The whole feature lives behind the [ai] extra (plus [viz] for the render) and never runs implicitly: only umbra change --narrate reaches a model.

NarrateError

NarrateError(message='', *, hint=None)

Bases: UmbraError

Raised when a change scene cannot be rendered or a reply cannot be parsed.

ChangeBlock dataclass

ChangeBlock(row, col, compass, mean_delta_db, mean_abs_delta_db, brightened_fraction, dimmed_fraction, valid_fraction)

Signed backscatter change in one cell of the coarse change grid.

row/col are 0-indexed with row=0 at the north (top) edge. compass is a plain-language location ("northwest", "center", ...). mean_delta_db is the mean signed change (positive = brightened in the later pass, negative = dimmed); mean_abs_delta_db is its magnitude. brightened_fraction / dimmed_fraction are the share of the block's valid pixels whose change exceeded the threshold in each direction, and valid_fraction how much of the block was imaged on both passes.

ChangeStats dataclass

ChangeStats(grid_rows, grid_cols, change_threshold_db, bounds, blocks=list(), scene_mean_abs_delta_db=None, scene_changed_fraction=0.0, peak_compass=None, peak_direction=None, peak_mean_delta_db=None, provenance=dict(), detection=None)

A coarse, deterministic grid of backscatter change between two passes.

This is the auditable artifact the narration is grounded in and the JSON sidecar written next to the composite. It is computed by :func:compute_change_stats from the co-registered dB amplitudes -- no model is involved -- so any statement in a :class:ChangeNarration can be checked against a number here.

provenance says what those decibels are: the UMBRA_* conversion record the compared passes agree on, carried by :func:render_change_png from the rasters it read. It is empty for the usual case -- published GEC products, which umbra-py did not convert -- and carries the calibration, RTC model, noise subtraction, scale and units when the sources were converted, so a dB delta quoted from this grid can be attributed rather than merely believed.

detection says what speckle alone would have produced: the same floor :func:umbra_py.stack_stats reports for a datacube, computed here for the two passes the grid is differenced between. Speckle is not an error bar on a mean -- it is the dominant variation in a single cell, and on single-look imagery the pass-to-pass difference of unchanged ground has a 7.9 dB spread -- so a scene_changed_fraction is evidence only to the degree it stands clear of detection.false_alarm_fraction, and a block's mean_delta_db only to the degree it stands clear of detection.cell_sigma_db. It is None when neither pass held enough homogeneous ground to read a looks estimate off (a scene smaller than one 16-cell block, or one whose every block was structured or nodata), because a floor nobody could measure is not a floor. Its shape is identical to stack_stats's detection block, so a reader parses one contract for both the cube and the composite.

to_dict

to_dict()

A plain JSON-serialisable view of the grid (for the sidecar / --json).

to_grid_text

to_grid_text()

An ASCII heat-grid of signed dB change the model reads spatially.

Each cell shows the block's mean signed change in dB (+ brighter, - dimmer, . when the block was never imaged on both passes), laid out north-up so the model can tie a number to a compass direction.

ChangeNarration dataclass

ChangeNarration(item_ids, period_start, period_end, summary, changes=list(), confidence=None, caveats=list(), change_stats=None, model=None, asset='GEC', attribution=ATTRIBUTION, provenance=AI_PROVENANCE)

A validated, provenance-stamped model narration of two-pass SAR change.

Every field has passed through :func:parse_narration. summary is a short plain-language paragraph; changes and caveats are lists of short strings; confidence is the model's own hedge. change_stats is the deterministic grid the narration is grounded in (embedded so the JSON output is self-contained and auditable). attribution and provenance are filled in deterministically -- the model never sets them.

to_dict

to_dict()

A plain JSON-serialisable view of the narration (for the sidecar).

to_text

to_text()

A human-readable rendering for the CLI's default (non-JSON) output.

compute_change_stats

compute_change_stats(band_earlier, band_later, bounds, *, grid=6, change_threshold_db=3.0)

Measure signed backscatter change between two co-registered SAR bands.

band_earlier and band_later are 2D amplitude arrays on the same pixel grid (co-register first, e.g. with the change composite's own reader). The per-pixel signed change is 20*log10(later) - 20*log10(earlier) in decibels -- positive where the scene brightened in the later pass (new/appeared backscatter, the composite's green), negative where it dimmed (vanished, the composite's magenta). Pixels non-positive or non-finite in either band are excluded (they weren't imaged on both passes).

The scene is divided into a grid x grid array of blocks and each block's mean signed change, change magnitude, and the fraction of it that moved past change_threshold_db in each direction are recorded. The result is the deterministic :class:ChangeStats a narration is grounded in.

Pure NumPy and offline: this never fetches anything and is the same whether or not a model is ever called. Requires the viz extra (for NumPy).

build_narrate_messages

build_narrate_messages(change_card, stats, image_png)

Build the {"system", "user", "image_png"} prompt for a vision model.

Deterministic and offline: the system prompt embeds the SAR primer, the composite's color legend, and the required JSON schema; the user message carries the acquisition card and the dB change grid (both the compact scene-level numbers and the north-up heat-grid) as ground truth, and image_png is the rendered change composite. This is what an injectable :data:Narrator receives.

parse_narration

parse_narration(raw, *, item_ids=None, period_start=None, period_end=None, change_stats=None, model=None, asset='GEC')

Validate a model's raw reply into a :class:ChangeNarration.

This is the interpretation boundary. The model's text is checked into a fixed shape -- summary must be a non-empty string, the two lists are coerced to clean string lists, confidence is normalised to low/medium/high (or dropped) -- and the mandatory :data:~umbra_py.constants.ATTRIBUTION and :data:~umbra_py.constants.AI_PROVENANCE are stamped on deterministically, never taken from the model. The deterministic change_stats grid is carried through unchanged. Unknown keys are ignored. Raises :class:NarrateError on a missing or ill-typed summary.

render_change_png

render_change_png(items, *, asset='GEC', max_size=2048, percentile=(2.0, 98.0), db=False, grid=6, change_threshold_db=3.0)

Render the change composite and compute its dB change grid in one pass.

Co-registers the 2-3 acquisitions onto a single grid once (the expensive step -- range reads of each cloud-optimized GeoTIFF's overview), then both renders the same composite :func:umbra_py.change_composite produces and measures :func:compute_change_stats between the earliest and latest band. Returns (composite_png, stats): the PNG the model looks at and the deterministic grid it is grounded in.

The change magnitudes are always measured in decibels regardless of the composite's db display stretch -- db only affects the picture's contrast, not the physics. Requires the viz extra.

Because those decibels are a measurement, the compared passes must have been made the same way. Each source's UMBRA_* conversion record is read while it is open and checked with :func:umbra_py.load._shared_provenance, so a pair that disagrees on what its pixel values are (a calibrated pass against an uncalibrated one, a terrain-flattened one against a raw one) raises rather than reporting the difference between the two conversions as change on the ground. It is the rule :func:umbra_py.to_stack applies to a datacube, applied to the two passes this quotes numbers between -- and it is why the check lives here rather than in :func:umbra_py.change_composite, which makes a picture: a mixed composite is confusing to look at, a mixed number is wrong. What the sources agree on rides out on :attr:ChangeStats.provenance.

save_change_scene

save_change_scene(png_bytes, dest)

Write composite PNG bytes to dest, flattening alpha for a JPEG target.

A tiny helper so the CLI can persist the same composite bytes it hands the model (rather than re-rendering) while still honouring a .jpg --out. Returns the written :class:~pathlib.Path.

model_key_configured

model_key_configured()

Whether any vision/chat model key is set in the environment.

True when one of ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY is present — the same keys :func:default_narrator (and :func:umbra_py.describe.default_describer) select a provider from. Lets a caller decide whether to attempt narration before building the narrator, so an opt-in bake can skip cleanly rather than raise per item when no key is configured.

default_narrator

default_narrator(*, model=None)

Build a :data:Narrator from environment variables.

Reuses the exact provider plumbing of :func:umbra_py.describe.default_describer (the multimodal message contract is identical): Anthropic when ANTHROPIC_API_KEY is set, else OpenRouter when OPENROUTER_API_KEY is set (its OpenAI-compatible endpoint, checked before the generic OpenAI key so an explicit OpenRouter opt-in wins over a stray OPENAI_API_KEY), else an OpenAI-compatible endpoint when OPENAI_API_KEY is set. UMBRA_NARRATE_MODEL (or model= / --model) overrides the model -- name an OpenRouter model like anthropic/claude-3.5-sonnet to pick one there. Raises :class:umbra_py.MissingDependencyError with setup guidance when no key is configured -- the feature never runs without an explicit, user-supplied key.

narrate

narrate(items, *, narrator=None, render=None, model=None, asset='GEC', max_size=2048, percentile=(2.0, 98.0), db=False, grid=6, change_threshold_db=3.0)

Render two-pass change and return a validated :class:ChangeNarration.

Renders the composite and its dB grid (:func:render_change_png by default, or an injected render), builds the multimodal prompt from the picture, the acquisition card and the change grid, calls the narrator (default: :func:default_narrator, chosen from environment keys), and validates the reply through :func:parse_narration. The returned narration embeds the deterministic grid and carries the mandatory attribution and AI-provenance note.

Pass the acquisitions in chronological order (2 or 3 of them). The model is only consulted to narrate the rendered change; inject a narrator and/or a render in tests to avoid any network call or the viz extra.

compute_change_stats

compute_change_stats(band_earlier, band_later, bounds, *, grid=6, change_threshold_db=3.0)

Measure signed backscatter change between two co-registered SAR bands.

band_earlier and band_later are 2D amplitude arrays on the same pixel grid (co-register first, e.g. with the change composite's own reader). The per-pixel signed change is 20*log10(later) - 20*log10(earlier) in decibels -- positive where the scene brightened in the later pass (new/appeared backscatter, the composite's green), negative where it dimmed (vanished, the composite's magenta). Pixels non-positive or non-finite in either band are excluded (they weren't imaged on both passes).

The scene is divided into a grid x grid array of blocks and each block's mean signed change, change magnitude, and the fraction of it that moved past change_threshold_db in each direction are recorded. The result is the deterministic :class:ChangeStats a narration is grounded in.

Pure NumPy and offline: this never fetches anything and is the same whether or not a model is ever called. Requires the viz extra (for NumPy).

ChangeNarration dataclass

ChangeNarration(item_ids, period_start, period_end, summary, changes=list(), confidence=None, caveats=list(), change_stats=None, model=None, asset='GEC', attribution=ATTRIBUTION, provenance=AI_PROVENANCE)

A validated, provenance-stamped model narration of two-pass SAR change.

Every field has passed through :func:parse_narration. summary is a short plain-language paragraph; changes and caveats are lists of short strings; confidence is the model's own hedge. change_stats is the deterministic grid the narration is grounded in (embedded so the JSON output is self-contained and auditable). attribution and provenance are filled in deterministically -- the model never sets them.

to_dict

to_dict()

A plain JSON-serialisable view of the narration (for the sidecar).

to_text

to_text()

A human-readable rendering for the CLI's default (non-JSON) output.

ChangeStats dataclass

ChangeStats(grid_rows, grid_cols, change_threshold_db, bounds, blocks=list(), scene_mean_abs_delta_db=None, scene_changed_fraction=0.0, peak_compass=None, peak_direction=None, peak_mean_delta_db=None, provenance=dict(), detection=None)

A coarse, deterministic grid of backscatter change between two passes.

This is the auditable artifact the narration is grounded in and the JSON sidecar written next to the composite. It is computed by :func:compute_change_stats from the co-registered dB amplitudes -- no model is involved -- so any statement in a :class:ChangeNarration can be checked against a number here.

provenance says what those decibels are: the UMBRA_* conversion record the compared passes agree on, carried by :func:render_change_png from the rasters it read. It is empty for the usual case -- published GEC products, which umbra-py did not convert -- and carries the calibration, RTC model, noise subtraction, scale and units when the sources were converted, so a dB delta quoted from this grid can be attributed rather than merely believed.

detection says what speckle alone would have produced: the same floor :func:umbra_py.stack_stats reports for a datacube, computed here for the two passes the grid is differenced between. Speckle is not an error bar on a mean -- it is the dominant variation in a single cell, and on single-look imagery the pass-to-pass difference of unchanged ground has a 7.9 dB spread -- so a scene_changed_fraction is evidence only to the degree it stands clear of detection.false_alarm_fraction, and a block's mean_delta_db only to the degree it stands clear of detection.cell_sigma_db. It is None when neither pass held enough homogeneous ground to read a looks estimate off (a scene smaller than one 16-cell block, or one whose every block was structured or nodata), because a floor nobody could measure is not a floor. Its shape is identical to stack_stats's detection block, so a reader parses one contract for both the cube and the composite.

to_dict

to_dict()

A plain JSON-serialisable view of the grid (for the sidecar / --json).

to_grid_text

to_grid_text()

An ASCII heat-grid of signed dB change the model reads spatially.

Each cell shows the block's mean signed change in dB (+ brighter, - dimmer, . when the block was never imaged on both passes), laid out north-up so the model can tie a number to a compass direction.

Visual similarity (embeddings)

SceneEmbeddingIndex

SceneEmbeddingIndex(path=None)

An embedding index over rendered Umbra quicklooks for visual similarity.

Open (creating the database and schema if needed) with a path, or no path to use :func:default_scene_embed_path. Usable as a context manager, which commits and closes on exit::

from umbra_py.embed import SceneEmbeddingIndex, default_image_embedder
from umbra_py import UmbraCatalog

embed = default_image_embedder()               # needs an embedding API key
items = list(UmbraCatalog().search(area="Centerfield", limit=50))
with SceneEmbeddingIndex() as idx:
    idx.build(items, embedder=embed)           # render + embed once
    for m in idx.similar_to_item(items[0], embedder=embed):
        print(m.item_id, round(m.score, 3))    # scenes that look alike

The model is consulted only to embed an image or a text query (the injected embedder); rendering, storage, ranking and thresholding are deterministic and offline.

from_release classmethod

from_release(path=None, *, url=None, progress=None)

Download the published prebuilt scene-embedding sidecar and open it.

Embedding every quicklook in the archive is the one expensive step of visual similarity search -- it renders each scene and calls a model. This skips it: it fetches the published catalog.embed.db from the project's rolling catalog-index GitHub release straight to path (default: :func:default_scene_embed_path) and returns an open index over it, so similar_to_item / similar_to_text work with no rebuild -- the embedding sibling of :meth:umbra_py.index.CatalogIndex.from_release and :func:umbra_py.pmtiles.fetch_prebuilt_pmtiles. Only the query itself still needs an embedding key (the archive vectors arrive pre-built).

The download is resume-safe and always overwrites the existing file; re-run any time to refresh. url overrides the release asset location (e.g. to pull from a fork or a mirror). Because the vectors are model-specific, the published table's :meth:stored_model records the embedding model it was built with -- query it with the matching model (see :meth:similar).

stored_model

stored_model()

The embedding model the stored vectors were produced with, or None for an empty index. Mixing models in one index is disallowed (see :meth:build), so this is single-valued.

build

build(items, *, embedder, render=None, model='default', batch_size=16, skip_render_errors=True, progress=None, on_error=None)

Render and embed each item's quicklook, persisting one vector per item.

Returns the number newly embedded (an item already in the index is skipped, so a rebuild is cheap). render turns an item into PNG bytes (default :func:_render_quicklook, requiring the viz extra); embedder turns a batch of those PNGs into vectors. model is a label recorded with each vector so a query can refuse to compare across embedding models; all vectors in one index must share it (rebuild in a fresh file to switch models). embedder is called in batches of batch_size; progress (if given) receives (done, total) after each batch.

Rendering streams overviews over the network and can fail for one bad asset without dooming the batch: with skip_render_errors (the default) an item whose quicklook won't render is skipped and passed to on_error (if given) rather than aborting the build.

similar

similar(query_vec, *, top_k=10, min_score=0.0, exclude_id=None)

Rank the stored scenes by cosine similarity to query_vec.

Returns the top_k matches with score >= min_score, highest first (empty if nothing clears the threshold). exclude_id drops that item from the results -- used by :meth:similar_to_item so a scene never returns itself as its own best match. Raises :class:EmbedError if the index is empty (build it first) or query_vec's length disagrees with the stored vectors (a model mismatch).

similar_to_item

similar_to_item(item, *, embedder, render=None, top_k=10, min_score=0.0)

Find stored scenes that look like item.

Renders item's quicklook, embeds it, and ranks the stored vectors by :meth:similar. The query item is excluded from its own results by id, so an already-indexed scene does not rank itself first. The render and the embedding are the only model/network touch points and both are injectable.

similar_to_text

similar_to_text(query, text_embedder, *, top_k=10, min_score=0.0)

Find stored scenes that match a plain-language query ("a flooded field", "ships at a berth").

Embeds the text with text_embedder and ranks the stored image vectors by :meth:similar. This only works when the text and image vectors live in the same space -- i.e. the index was built and this query is embedded with a joint CLIP-family model (see :func:default_text_embedder / :func:default_image_embedder). A model whose text encoder has a different dimensionality is caught by :meth:similar as a mismatch; a same-dim but non-joint model would return meaningless scores, so pairing the two is the caller's responsibility (and the model label records which one built the index).

stats

stats()

Summary for umbra embed info: how many scene vectors are stored, the embedding model they came from, and their dimensionality.

SceneMatch dataclass

SceneMatch(item_id, score, task=None, datetime=None, href=None)

One ranked acquisition: the item_id (and its task, datetime and STAC href for context) and its cosine score against the query (1.0 identical, 0.0 unrelated, higher is closer).

A match is a pointer back to a real acquisition, never a model-authored fact: every field except score was recorded at build time from the deterministic item, and score is a measurement a test can recompute.

umbra embed similar|search --json emits these as docs/schemas/scene-matches.schema.json.

cosine_similarity

cosine_similarity(a, b)

Cosine similarity of two equal-length vectors, in plain Python.

Returns 0.0 if either vector is all-zero (undefined direction) rather than dividing by zero. Raises :class:SemanticError on a length mismatch -- comparing vectors from different embedding models is a bug, not a 0.

fetch_prebuilt_embeddings

fetch_prebuilt_embeddings(dest=None, *, url=None, progress=None)

Download the published prebuilt scene-embedding sidecar.

The weekly index workflow can ship a catalog.embed.db on the rolling catalog-index release beside catalog.db / catalog.pmtiles, so a fresh install gets visual similarity search over the whole archive with no rebuild -- the embedding sibling of :meth:umbra_py.index.CatalogIndex.from_release and :func:umbra_py.pmtiles.fetch_prebuilt_pmtiles. This fetches that sidecar straight to dest (default: :func:default_scene_embed_path) and returns its path. Re-run any time to refresh; the download is resume-safe and always overwrites the existing file. url overrides the release asset location (e.g. to pull from a fork or a mirror). Open the result with :class:SceneEmbeddingIndex (or use :meth:SceneEmbeddingIndex.from_release, which wraps this), then query it with the matching embedding model.

default_scene_embed_path

default_scene_embed_path(index_path=None)

Where the scene-embedding database lives by default.

It sits beside the catalog index (catalog.db -> catalog.embed.db) so the two travel together, while staying a separate file: the embedding layer is opt-in and model-backed, and keeping it out of catalog.db means the deterministic index (and its published snapshot) never carries vectors a core install can't use. Pass index_path to derive the sibling name from a non-default index location.