Bulk ordering with cloud-aware selection

Placing a large archive order — hundreds or thousands of areas, each possibly at several dates — is not the single-area flow repeated N times. Choosing captures by hand does not scale, and the obvious automation (sort by the vendor’s cloud percentage, take the top result) buys the wrong imagery.

This guide covers the operations that make it tractable, with a worked example measured end to end on live archive data.

StepOperationSide-effectScope
Find candidatescatalog.searchreadcatalog:read
Choose the setcatalog.clear_coverage.plancomputecatalog:read
Portfolio viewcatalog.clear_coverage.campaigncomputecatalog:read
Preview priceorders.archive.estimatereadorders:read
Place orderorders.archive.placespendorders:write

Only the last step spends anything. plan and campaign are compute — you can run them as often as you like while tuning thresholds.

Why cloud percentage is the wrong sort key

Two independent reasons, and the second surprises people more than the first.

The vendor’s cloud figure describes a whole scene, not your area. It is usually accurate — it just isn’t about you. A capture reported at 0% cloud can deliver a quarter of your area; one reported at 30% can deliver all of it. The scene is far larger than most areas of interest, so where the cloud sits matters more than how much there is.

Most areas need more than one capture anyway. A 0.5 m optical scene covers roughly 230 km². Any area larger than that needs a set, and the question stops being “which capture is best?” and becomes “which combination covers my area?“.

catalog.clear_coverage.plan answers the second question. It measures how much of your area each candidate actually delivers clear of cloud and shadow, then selects the smallest set that covers it — and tells you what that set will cost.

Do not filter on cloud when searching

The most important line in this guide. Send cloudCoverage: { "LTE": 100 }.

Filtering candidates on the vendor’s scene-wide percentage discards exactly the captures the planner exists to rescue, before it ever sees them. Let the planner measure them and reject them on evidence.

A worked example

A 698 km² area in Western Australia. catalog.search returned 23 candidates, all 0.5 m optical. Here is the outcome before any of the detail — the same area, the same scale, real pixels from the vendor’s own browse imagery:

best single capture covering a quarter of the area, beside the selected mosaic covering all of it

The best capture in the archive covers a corner. The set the API returns fills the area, and costs the same.

23 candidate footprints against the area of interest

No single footprint covers it. That is not a cloud problem, it is arithmetic — the area is three times a single scene.

What each capture actually delivers

Every one of those 23 captures is reported at 0–1% cloud, and that is correct. Here is what each one delivers over this particular area:

measured clear coverage per capture

The grey band — ground inside the footprint but lost to cloud or shadow — is barely visible. On this area the vendor’s figure is accurate and the shortfall is almost entirely footprint reach. The best single capture reaches 26.3% of the area.

That distinction is worth keeping straight. The measurement is not usually catching the vendor out on cloud; it is answering a question the vendor’s number does not address — how much of my area do I actually get? When cloud is present it is subtracted; when it isn’t, the measurement says so.

What the planner selected

the selected set, coloured by what each capture contributed

11 captures, 100.0% of the area covered, billed 1×.

PickDateOn its ownNew ground added
12025-03-2223.9%+23.9%
22025-03-2223.3%+22.4%
32025-03-2219.9%+19.1%
42025-03-2214.5%+14.0%
52025-03-229.9%+7.4%
62025-03-229.0%+6.1%
72025-03-224.1%+3.5%
82025-03-224.0%+1.5%
92025-03-223.6%+1.4%
102025-03-2515.4%+0.5%
112025-03-220.5%+0.2%

Two things there are the point of the operation.

It did not take the best capture. The single best capture over this area came from a different date entirely, at 26.3%. The planner chose captures that complement each other instead — a set that tiles the area beats a better individual capture that leaves three quarters of it uncovered.

Look at picks 9–11. They add 1.4%, 0.5% and 0.2%. Individually trivial, and a selector optimising for a tidy result would skip them. They are taken because completing the area costs nothing here: summed overlap ends at 1.28×, well inside the 1× band. The last two percent of an area you are paying for is not a rounding error — it is ground you would otherwise have to chase in a second order.

span_days came back 2. Ten of the eleven are from one day and one is three days later, so this is effectively a mosaic rather than a temporal composite. The response reports is_composite: true (more than one capture) alongside span_days: 2 — those answer different questions. Use span_days to judge whether a result is defensible as a single observation, and max_span_days to constrain it.

What it costs

coverage against the billing thresholds

Archive is billed on summed overlap, not on the number of captures and not on a deduplicated union:

coverage_ratio      = Σ(each capture's overlap with the AOI) / AOI area
coverage_multiplier = max(1, floor(coverage_ratio × 1.1))
chargeable_area     = base_chargeable_area × coverage_multiplier

which makes the bill a step function:

Summed overlapBilled
up to 1.819×
1.819× – 2.728×
2.728× – 3.637×

Eleven captures tiling this area sum to 1.28× overlap, so it bills — the same as ordering one capture. There is still 0.53× of the area available at no extra cost before the price moves.

That is the counter-intuitive part: tiling distinct ground is free. Only overlap pushes the ratio up. A set that covers your area once, in pieces, costs what a single capture covering it once would cost.

The planner accounts for this. Crossing a threshold is priced as roughly one whole extra cover of the area, and a capture may only push you across one if it adds at least 15% coverage. Without that rule a coverage-maximising selector would double your bill for two more percent of ground.

Calling it

1. Search — no cloud filter

catalog.search is per-host and STAC-shaped. Fan out across hosts yourself, or use catalog.search_stream, which does it for you and emits NDJSON.

bash
curl -s -X POST https://api.geopera.com/v1/op/catalog.search \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "host_name": "the-host",
    "intersects": { "type": "Polygon", "coordinates": [[ ... ]] },
    "datetime": "2025-01-01T00:00:00Z/2025-12-31T23:59:59Z",
    "limit": 200,
    "query": { "cloudCoverage": { "LTE": 100 } }
  }'

The response is a STAC FeatureCollection. Map each feature into a plan scene:

plan fieldFrom the STAC feature
scene_idid
footprintgeometry
thumbnail_urlproperties.thumbnailUrl ?? assets.thumbnail.href
captured_atproperties.datetime
off_nadir_degproperties["view:off_nadir"]
sun_elevation_degproperties["view:sun_elevation"]
vendor_cloud_pctproperties.cloudCoverage ?? properties["eo:cloud_cover"]
vendorthe host you queried, upper-cased

A candidate with no browse image cannot be measured. The planner counts it as contributing nothing rather than assuming it is clear, so drop those before sending. A single plan call accepts up to 10,000 candidate scenes.

2. Plan

bash
curl -s -X POST https://api.geopera.com/v1/op/catalog.clear_coverage.plan \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "aoi": { "type": "Polygon", "coordinates": [[ ... ]] },
    "scenes": [ { "scene_id": "...", "footprint": { ... },
                  "thumbnail_url": "https://.../browse.jpg",
                  "captured_at": "2025-03-22T02:11:04Z",
                  "vendor": "THE-VENDOR", "vendor_cloud_pct": 0,
                  "off_nadir_deg": 1.4, "sun_elevation_deg": 52.3 } ],
    "max_span_days": 45
  }'
json
{
	"coverage": 1.0,
	"complete": true,
	"is_composite": true,
	"span_days": 2,
	"members": [
		{
			"scene_id": "...",
			"captured_at": "2025-03-22T02:11:04+00:00",
			"standalone_clear": 0.2394,
			"marginal_gain": 0.2394,
			"billed_overlap": 0.2394,
			"contribution": { "type": "Polygon", "coordinates": [] }
		}
	],
	"uncovered": null,
	"coverage_ratio": 1.28,
	"billed_multiplier": 1,
	"free_budget_remaining": 0.53,
	"needs_review": false,
	"review_reason": null,
	"excluded_by_filter": {},
	"scenes": [{ "scene_id": "...", "clear_fraction": 0.2632 }]
}

contribution is the geometry each pick added that no earlier pick had — the coloured shapes in the figure above. Render it directly rather than recomputing clear-minus-covered; a second implementation of that arithmetic drifts from the numbers beside it.

3. Preview the price

bash
curl -s -X POST https://api.geopera.com/v1/op/orders.archive.estimate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "captures": [
      {
        "id": "...",
        "vendor": "THE-VENDOR",
        "geometry": { "the capture footprint": "from the STAC feature" },
        "maxGSD": 0.75,
        "AOI": { "id": "my-area-1", "geometry": { "your area": "..." } }
      }
    ]
  }'

Each capture needs its id, vendor, footprint geometry, and maxGSD (from the STAC feature’s properties.gsd). AOI is an object with an id and a geometry, not a bare geometry — the id is yours, and it is how the response attributes area back to each of your areas when one order covers several. A capture that cannot be priced comes back in errors with its input index and a reason, rather than failing the whole request.

Worth doing rather than trusting the plan. plan predicts the price so it can avoid expensive choices, but estimate is the pricing engine, including contracted rates and minimum-area rules the planner does not model. Each returned group carries the coverageMultiplier actually applied — cross-check it against billed_multiplier. They should agree; estimate wins if they ever don’t.

4. Order

bash
curl -s -X POST https://api.geopera.com/v1/op/orders.archive.place \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "captures": [ ], "projectId": "...", "licenseType": "..." }'

The same operation covered in Ordering archive imagery. A planned order and a hand-picked one take an identical path — pricing, approval and delivery included.

Request parameters

FieldDefaultWhat it does
target_coverage1.0How much of the area to cover. Defaults to all of it: coverage is capped by what the archive holds, so this is an instruction to keep going while progress is available, not a promise. Lower it to stop early — the biggest cost lever, since the last capture is the one most likely to cross a threshold.
max_span_days45Hard limit on the date range of a set. 0 forbids combining dates. Not a preference: a set spanning months is not a single observation.
max_scenes20Safety ceiling, not a tuning knob — the selection stops on its own when no ground is left worth covering. Lower it if you want to cap the number of separate deliveries.
target_datePrefer captures near this date. A preference inside your search window, not a filter.
date_prioritybalancedHow hard to pull toward target_date. off; balanced breaks ties without buying a worse capture; strict outweighs even a billing threshold.
max_off_nadir_degHard limit. Off-nadir is already priced, but a preference cannot express never — priced alone, an oblique capture is still chosen if it is the only cover.
min_sun_elevation_degHard limit, same reasoning.
vendorsRestrict to these providers. Absolute, never a ranking: a substitute is not offered even when it is the only capture covering the area.
min_marginal_gain0.001Smallest share of the area a capture must add to be taken when taking it is free (it does not raise the multiplier). Raise it if a delivery for 0.1% of ground is not worth the handling to you.
allow_billing_steptrueWhether any capture may push the order into a higher billing multiplier at all. false caps the order at its current multiplier — a hard price ceiling, at the cost of whatever coverage sat beyond it.
billing_step_min_gain0.15How much a capture must add before it is allowed to cross a billing threshold. The guard against buying 2% more ground for twice the price.

The three threshold parameters exist because the right values depend on what the imagery is for — and a constant you cannot see is a constant you cannot reason about when a result surprises you. The defaults are the behaviour described in this guide.

Anything a hard limit removes is reported in excluded_by_filter, by reason. That matters — “the archive has nothing here” and “your off-nadir limit rejected all 40 candidates” are indistinguishable otherwise and call for opposite responses.

How selection works

Greedy weighted set-cover over clear-area polygons. At each step it takes the candidate maximising

(new clear ground it adds) / (its marginal cost)

stopping when target_coverage is reached or nothing is left worth adding. How much is “worth adding” depends on the price: a capture that does not push the order into a higher multiplier costs nothing, so it only has to contribute 0.1% of the area to be taken. One that would cross a threshold has to earn it — see below. Greedy is the right tool: it is (1 + ln n)-approximate for set cover, n is 40–100 candidates, and an exact solver would buy a few percent of coverage for an unbounded worst case across thousands of areas.

Marginal cost blends a small per-capture penalty, off-nadir, low sun elevation, distance from target_date when you set one, and the billing threshold — priced at roughly one whole extra cover of the area.

Cloud geometry comes from the vendor’s browse image: it is mapped onto the capture’s footprint, cloud and shadow are segmented from it, and

clear = footprint − cloud − shadow  ∩  your AOI

A capture whose browse image cannot be read contributes nothing. It is never assumed clear — unknown ground sold as clear is the failure this exists to prevent. Such an area returns needs_review: true with a reason.

Cloud-geometry measurement is supported for a subset of providers today; others are declined by name with a reason rather than silently mis-measured. excluded_by_filter and review_reason tell you which.

Scaling to thousands of areas

What actually costs anything

Measured on the example above: 23 browse images analysed in ~10 s, cold, including fetching each from the vendor. Selection itself is negligible.

So the cost is browse-image analysis, and the thing that controls it is that cloud geometry is a property of a scene, not of an area–scene pair. One footprint covers ~230 km² and will be a candidate for many nearby areas, so results are cached per scene and reused:

3,000 areas × 4 windows × ~40 candidates  =  480,000 analyses   uncached
with ~8 areas sharing each scene          =   60,000 analyses   cached

A cold analysis is ~0.4 s including fetch; a cache hit is ~0.01 s.

Structuring the run

  1. Search once per area across your whole horizon, not once per area–window. Eight areas over a year is 8 searches, not 32; campaign slices the pool per window.
  2. Bound your concurrency. Provider catalogue and asset hosts are shared resources — 4–8 requests in flight is plenty, and the cache absorbs the rest.
  3. Plan per area, then order in whatever batches suit your workflow.
python
import asyncio, httpx

BASE, TOKEN = "https://api.geopera.com", "..."
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
SEM = asyncio.Semaphore(6)


async def plan_one(client, aoi, window, target_date=None):
    async with SEM:
        found = await client.post(f"{BASE}/v1/op/catalog.search", headers=HEADERS, json={
            "host_name": "the-host",
            "intersects": aoi["geometry"],
            "datetime": f"{window[0]}/{window[1]}",
            "limit": 200,
            "query": {"cloudCoverage": {"LTE": 100}},   # deliberately unfiltered
        })
        found.raise_for_status()

        scenes = [{
            "scene_id": f["id"],
            "footprint": f["geometry"],
            "thumbnail_url": (f["properties"].get("thumbnailUrl")
                              or f.get("assets", {}).get("thumbnail", {}).get("href")),
            "captured_at": f["properties"].get("datetime"),
            "off_nadir_deg": f["properties"].get("view:off_nadir"),
            "sun_elevation_deg": f["properties"].get("view:sun_elevation"),
            "vendor_cloud_pct": (f["properties"].get("cloudCoverage")
                                 or f["properties"].get("eo:cloud_cover")),
        } for f in found.json().get("features", [])]

        scenes = [s for s in scenes if s["thumbnail_url"]]   # unmeasurable otherwise
        if not scenes:
            return {"aoi_id": aoi["id"], "outcome": "nothing_available"}

        planned = await client.post(
            f"{BASE}/v1/op/catalog.clear_coverage.plan", headers=HEADERS,
            json={"aoi": aoi["geometry"], "scenes": scenes,
                  "max_span_days": 45, "target_date": target_date},
            timeout=180.0)
        planned.raise_for_status()
        return {"aoi_id": aoi["id"], **planned.json()}


async def main(aois, window):
    async with httpx.AsyncClient(timeout=180.0) as client:
        results = await asyncio.gather(
            *(plan_one(client, a, window) for a in aois), return_exceptions=True)

    ready, review = [], []
    for r in results:
        if isinstance(r, Exception):
            continue
        # Never auto-order something the planner does not vouch for.
        (review if r.get("needs_review") or not r.get("complete") else ready).append(r)
    return ready, review

Two habits matter at this scale, both visible above: drop candidates with no browse image rather than letting them silently contribute nothing, and route needs_review or complete: false to a human instead of ordering them. At 3,000 areas a 2% review rate is 60 areas — small enough to inspect, large enough to matter if you don’t.

The portfolio question

catalog.clear_coverage.campaign takes many areas and many windows at once and returns a cell per (area, window) — single_capture, set_of_captures, incomplete or nothing_available — plus the window in which the largest share of your portfolio is obtainable.

json
{
	"aois": [{ "aoi_id": "...", "geometry": {} }],
	"epochs": [{ "label": "2026-Q1", "start": "2026-01-01", "end": "2026-03-31" }],
	"scenes": []
}

Within each window it prefers captures near that window’s midpoint — “2026-Q1” means the quarter, and a 1 January capture is not an equally good representative of it as one from mid-February.

This operation is synchronous and capped at 40 areas per call. Chunk beyond that.

Availability is a real constraint — check it first

Measured across 20 areas over a year: one capture reached ≥99% coverage in 21% of windows, a set in 73%. But some months yield nothing at all: in one region, four separate months returned zero captures across every area.

No amount of selection fixes an empty archive. Run campaign before committing to a quarterly schedule, so an unachievable quarter is a known fact rather than a discovery halfway through.

Things worth knowing

Areas must be in WGS84 longitude/latitude. GeoJSON requires it (RFC 7946 §4). Projected coordinates — UTM eastings and northings — are rejected, because every intersection, area figure and price derived from them would be meaningless. If you are exporting from a GIS, reproject to EPSG:4326 first.

complete: false is not a failure. It means the archive could not cover your area to your target, and uncovered is the geometry it could not reach. Decide whether to lower target_coverage, widen the window, or wait.

Check needs_review. It is set when the planner does not trust its own answer. Do not auto-order those.

is_composite and span_days answer different questions — more than one capture, versus how far apart they are.

Distinguish “nothing available” from “your filters rejected everything.” Read excluded_by_filter before concluding the archive is empty.

Related