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
¶
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 liveumbracommand 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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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_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
¶
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
¶
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
¶
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.
Semantic task-name search¶
SemanticTaskIndex
¶
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
¶
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
¶
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
¶
Summary for umbra semantic info: how many task vectors are stored,
the embedding model they came from, and their dimensionality.
SemanticMatch
dataclass
¶
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
¶
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
2024or year-month2024-03(seeis_end); today/now/yesterday/tomorrow;- a point offset
"<n> <unit> ago"(unit: day/week/month/year, also"a week ago"); or - a period
this/lastweek/month/year(seeis_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
¶
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:
- The picture and the metadata are produced deterministically. The quicklook
is the same :func:
umbra_py.quicklookrender 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. - The model only interprets; it never becomes a filter, a URL, or a
coordinate. Its reply is validated into a :class:
SceneDescriptionby the deterministic :func:parse_description-- a description is text about the scene, never data the rest of the library acts on. - Provenance is mandatory. Every :class:
SceneDescriptioncarries the CC-BY :data:~umbra_py.constants.ATTRIBUTIONand the :data:~umbra_py.constants.AI_PROVENANCEnote, 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_refusalunder"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 CSIbake answers a--asset CSIreading; only a preview with no record falls back to assuming the default bake. - Every description records the picture it was read from (:class:
SceneImage, onSceneDescription.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
¶
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
¶
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.
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
¶
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.
build_describe_messages
¶
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
¶
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 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
¶
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
¶
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_URLoverrides the host; model defaultclaude-sonnet-5).- else
OPENROUTER_API_KEY-> OpenRouter's OpenAI-compatible endpoint (OPENROUTER_BASE_URLoverrides the host; model defaultopenai/gpt-4o-mini). Checked beforeOPENAI_API_KEYbecause it is an unambiguous opt-in -- nobody sets it by accident -- so it routes to OpenRouter even in an environment that already carries a strayOPENAI_API_KEY. - else
OPENAI_API_KEY-> OpenAI-compatible chat completions (OPENAI_BASE_URLoverrides the host; model defaultgpt-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
¶
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
¶
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.
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:
- The picture and the numbers are produced deterministically. The composite
is the same :func:
umbra_py.change_compositerender; the dB grid is plain :func:compute_change_stats. The model is shown facts, not asked to invent them. - The model only interprets. Its reply is validated into a
:class:
ChangeNarrationby :func:parse_narration; nothing it says becomes a filter, a URL, a coordinate, or a measurement -- the measurements are the sidecar's, computed offline. - Provenance is mandatory. Every :class:
ChangeNarrationcarries the CC-BY :data:~umbra_py.constants.ATTRIBUTIONand the :data:~umbra_py.constants.AI_PROVENANCEnote, 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
¶
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_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.
compute_change_stats
¶
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 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
¶
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
¶
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
¶
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
¶
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.
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_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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
Summary for umbra embed info: how many scene vectors are stored, the
embedding model they came from, and their dimensionality.
SceneMatch
dataclass
¶
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 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
¶
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
¶
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.