Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
605cb56
phase 1 of issue #509
espg Aug 24, 2026
f70d60a
fold review: expand cover words to bucket midpoints (issue #509)
espg Aug 24, 2026
1cec8ed
fold review: make the cross-store union canonical at the bucket grid …
espg Aug 24, 2026
a6642f6
fold review: honor each block's effective temporal order (issue #509)
espg Aug 24, 2026
970d7a0
fold review: pin the golden fixture epochs as literals (issue #509)
espg Aug 24, 2026
16777c4
phase 2 of issue #509
espg Aug 24, 2026
e6efd13
fold review: snap equal-time runs to their first record (issue #509)
espg Aug 24, 2026
fc2a53e
fold review: refuse NaT in either input (issue #509)
espg Aug 24, 2026
ad6a311
fold review: exact unsigned flank distances across the full span (iss…
espg Aug 24, 2026
be41a26
phase 3 of issue #509
espg Aug 24, 2026
96ca33b
fold review: stop advertising a mask the derived map does not carry (…
espg Aug 24, 2026
ccb6b9b
fold review: ledger the epochs of shards the catalog never reaches (i…
espg Aug 24, 2026
23e391f
fold review: emit the paired instant as the entry's datetime (issue #…
espg Aug 24, 2026
2d72afa
fold review: apply the aoi to both sides of the join (issue #509)
espg Aug 24, 2026
ada8a48
fold review: warn that a reprojected map loses the pairing provenance…
espg Aug 24, 2026
f26f20c
phase 4 of issue #509
espg Aug 24, 2026
e9ac3c8
fold review: pin the gap test's shoulder selections by name (issue #509)
espg Aug 24, 2026
1228b3e
fold review: make store A contribute uniquely to the union (issue #509)
espg Aug 24, 2026
bb870dc
fold review: pass the gate in the dry-run example so violations is re…
espg Aug 24, 2026
8a925d1
Merge remote-tracking branch 'origin/main' into claude/509-s2-closest…
espg Aug 24, 2026
e9a23ce
fold review: raise the function-zip budget to 32 MB (issue #509)
espg Aug 24, 2026
ecca696
fold review: tolerance-aware handling of coarsened cover blocks (issu…
espg Aug 24, 2026
073242e
fold review: gate cover resolution before the spatial lookup (issue #…
espg Aug 24, 2026
ae30bab
fold review: warn loudly when a coarsened cover drops epochs (issue #…
espg Aug 24, 2026
7df30da
fold review: refuse a negative cover block order at the read boundary…
espg Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions deployment/aws/build_function.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,12 @@ ITEM_COUNT=$(ls -1 "$BUILD_DIR" | wc -l)
echo ""
echo "Function code: ${UNZIPPED_SIZE} (${UNZIPPED_BYTES} bytes)"

# Function code budget: 30MB leaves room for the ~220MB layer
FUNCTION_BUDGET=$((30 * 1024 * 1024))
# Function code budget: 32MB (espg ruling 2026-08-24, PR #511 question 1) —
# an early-warning tripwire under AWS's 50MB direct-upload zip limit, leaving
# room for the ~220MB layer; mirrored in tests/test_lambda_build.py.
FUNCTION_BUDGET=$((32 * 1024 * 1024))
if [ "$UNZIPPED_BYTES" -gt "$FUNCTION_BUDGET" ]; then
echo "WARNING: Function code exceeds 30MB budget!"
echo "WARNING: Function code exceeds 32MB budget!"
echo " Top directories by size:"
du -sh "$BUILD_DIR"/*/ 2>/dev/null | sort -rh | head -10
fi
Expand Down
79 changes: 79 additions & 0 deletions docs/api/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,82 @@ cannot see.
::: zagg.catalog.polygon_to_bbox

::: zagg.catalog.load_antarctic_basins

## Closest-observation pairing (issue #509)

One raster store (e.g. Sentinel-2 L2A) can serve several point-cloud
reference stores (ATL03 + GEDI): for every reference *epoch* a shard's
stores actually observed, ingest the single **nearest** acquisition from the
raster catalog. The pairing is a property of the ingest *query*, not the
store schema — the raster store stays a plain raster store, which granules
were ingested *is* the pairing, and coincidence at read time is toc
intersection.

Epochs are **store-derived**, never catalog-derived: each reference store's
`coverage.toc` sibling (spec §10.5) records per-shard word-set covers of the
data that actually landed, quantized at temporal order 18 (2^45 ns ≈ 9.77 h
buckets). The builder expands each cover word into its constituent buckets
and takes one epoch per bucket midpoint — good to ±4.9 h against Sentinel-2's
~4.3-day revisit. Granule catalogs would inherit the CMR-hull
over-assignment (~70 assigned granules vs 49 contributing pass-days on a
measured Californian shard); covers reflect contribution, not assignment.

```python
from zagg.catalog.closest_obs import closest_obs_shardmap
from zagg.catalog.sources import Catalog
from zagg.grids import HealpixGrid
import numpy as np

grid = HealpixGrid(9, 13) # parent_order must equal the covers' shard order
s2 = Catalog.from_geoparquet("catalog_s2_ca.parquet")

# Size the run first — the dry-run builds nothing and prices the fan-out:
est = closest_obs_shardmap(
s2,
["s3://bucket/atl03_store", "s3://bucket/gedi_store"],
grid=grid,
aoi="california.geojson",
max_time_offset=np.timedelta64(3, "D"),
max_granules_per_shard=200, # the same gate the build below applies
estimate=True,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

The dry-run example cannot report what the prose two paragraphs down promises it reports: "a shard past max_granules_per_shard raises naming the worst shards (estimate=True reports the violations instead, so the gate can be sized first)". This estimate=True call omits max_granules_per_shard, and violations is computed from that argument — [] whenever it is None.

Probe (uv run python, synthetic two-store fixture, one shard with 5 selected granules):

closest_obs_shardmap(..., estimate=True)                            -> violations []
closest_obs_shardmap(..., estimate=True, max_granules_per_shard=3)  -> violations [('11213', 5)]
closest_obs_shardmap(..., max_granules_per_shard=3)                 -> ValueError: 1 shard(s) exceed max_granules_per_shard=3 (worst: 11213=5)

So a reader who copies this block verbatim to "size the run first" gets a silent violations == [], then hits the raise on the very next call — the failure the dry run exists to prevent. (estimate returns before the violations raise in closest_obs_shardmap, so passing the gate here is safe and non-raising.)

Fix: pass the same gate in the dry run as in the build, and surface it:

    max_time_offset=np.timedelta64(3, "D"),
    max_granules_per_shard=200,
    estimate=True,
)
est["histogram"], est["max_cost_usd"], est["violations"]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in bb870dc. The dry-run call now passes max_granules_per_shard=200 — the same gate the build call below it uses — and the trailing line reads est["histogram"], est["max_cost_usd"], est["violations"], with a short comment saying why the gate has to be repeated here and that estimate returns before the raise.

Re-probed the behaviour the fix depends on, on a synthetic two-store fixture:

estimate=True                            -> violations []
estimate=True, max_granules_per_shard=3  -> violations [("11213", 6)]
              max_granules_per_shard=3   -> ValueError: 1 shard(s) exceed max_granules_per_shard=3 (worst: 11213=6)

so the gated dry run is non-raising and reports what the prose promises. mkdocs build --strict is green.

)
# violations is [] unless the gate is passed here too -- estimate returns
# before the build's raise, so this is the safe way to size it.
est["histogram"], est["max_cost_usd"], est["violations"]

# Then build the map; dispatch consumes it like any other ShardMap:
sm = closest_obs_shardmap(
s2,
["s3://bucket/atl03_store", "s3://bucket/gedi_store"],
grid=grid,
aoi="california.geojson",
max_time_offset=np.timedelta64(3, "D"),
max_granules_per_shard=200,
)
sm.to_json("s2_closest_obs.json")
```

Everything refuses or records **loudly**, never silently: a reference store
with no readable `coverage.toc` raises (sweep the store first); an epoch
whose nearest acquisition lies beyond `max_time_offset` selects nothing and
is recorded per-epoch in `metadata["closest_obs"]["dropped"]` with its
near-miss offset; a shard past `max_granules_per_shard` raises naming the
worst shards (`estimate=True` reports the violations instead, so the gate
can be sized first); a cover block coarsened below the §10.5 pin is warned
about and reported in `coarsened_orders` — and under a `max_time_offset`,
epochs whose coarse-bucket half-span exceeds the stated offset cannot be
paired to that precision, so they drop into the ledger as their own category
(`epochs_dropped_low_resolution`, rows naming the block's effective order;
espg tolerance ruling 2026-08-24). Selected granule entries carry
`paired_epochs` / `epoch_offsets_ns` provenance so the paired product is
reconstructable from the manifest alone. Epochs are bucket midpoints —
size `max_time_offset` with `ReferenceEpochs.tolerance()`'s half-bucket
slack in mind.

::: zagg.catalog.closest_obs.reference_epochs

::: zagg.catalog.closest_obs.ReferenceEpochs

::: zagg.catalog.closest_obs.nearest_acquisitions

::: zagg.catalog.closest_obs.closest_obs_shardmap
Loading
Loading