Skip to content

Fixing - #22

Merged
profsergiocosta merged 17 commits into
developfrom
fixing
Aug 28, 2026
Merged

Fixing#22
profsergiocosta merged 17 commits into
developfrom
fixing

Conversation

@profsergiocosta

Copy link
Copy Markdown
Member

No description provided.

profsergiocosta and others added 17 commits August 27, 2026 15:53
Adds a domain-agnostic per-pixel value-remapping operator, filling the gap
between the zonal operators (which aggregate several source pixels into one
target cell) and the need to simply remap class codes 1:1.

The mechanism (apply a table) lives in disscube; the table itself is data
supplied by the caller via Variable.mapping, so land-cover, soil-class and
land-tenure regroupings all reuse this operator without disscube knowing
anything about any of those domains.

- Variable.mapping / Derivation.mapping carry {source_value: target_value}.
  Being a plain field, it is folded into spec_hash() automatically, so two
  derivations with different tables are always distinct products.
- Operator.requires_mapping mirrors the existing requires_class_code
  fail-fast contract; Derivation validates it at construction time.
- Lookup is vectorized via np.searchsorted over sorted keys rather than a
  dense 0..max array, so a sparse table with large codes costs the same as
  a dense one.
- Unmapped values, source nodata and non-finite input all become NaN — the
  same "no valid result here" convention the zonal operators already use.
- No purity coordinates are attached: with one source pixel per target cell
  there is no sub-cell composition to summarise, so coverage/dominance
  would be a constant carrying no information at the cost of two extra
  full-size arrays.

Verified against real data: reproducing the BR-MANGUE papel_dominio_2024
classification through this operator (from band 3 of br_mangue_base_v2.vrt)
matches the existing GDAL pipeline's output pixel-for-pixel over a dense
2048x2048 window containing all six classes, including the exact nodata
count.

Tests: 17 new, full suite 95 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
Adds a standalone case study showing the full path from raw tiles to a
categorical domain raster, exercising the new generic `reclassify`
operator together with disscube's existing tile-based derivation.

The example is also the clearest demonstration of where the boundary
between the cube and a project sits: nothing mangrove-specific lives in
disscube — `reclassify` just applies a table that arrives as data — while
the papeis_estados table and the two-band papel/elevation coupling stay in
the example, since Variable derives one band per variable.

Why tile by tile: a single derive() over the full 18352x21350 extent
(391M cells) allocates tens of GB. derive(tile_id=...) keeps memory
bounded; the whole extent completes in ~80s on a modest machine.

Why the example writes the GeoTIFF itself: tools/zarr_to_tif.py converts
one whole Zarr at a time, but reassembling N tiles into a single
multi-band GeoTIFF needs windowed writes.

Verified against the reference implementation it replaces (br_mangue's
GDAL pipeline): identical output over all 391M cells in both bands,
same CRS, transform and band descriptions.

geomosaic is imported with a clear install hint — it is not a disscube
dependency, and examples are not part of the installed package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
The documented BDC workflow did not work. bdc_importer registers tiles as
`BDC_{LEVEL}_{tile}` (e.g. BDC_SM_009002), but derive() looked up only
`{grid_id}_{tile_id}` (e.g. BR/5km_009002), so the exact example in
docs/guides/bdc.md failed with:

    ValueError: Tile 009002 with valid bbox not found for grid BR/5km

BDC tiles are grid-independent — the same envelope serves every grid
sharing the BDC CRS — so they are registered once, not per grid. Tile
lookup now tries, in order: the grid-scoped id, the tile_id as a fully
qualified id, then each BDC level.

Ambiguity is an error, not a precedence rule: tile ids are NOT unique
across levels. In the real V2 grids 189 ids exist in both SM and MD, and
they cover different areas (verified: BDC_SM_005004 is at 3152000,11425600
while BDC_MD_005004 is at 3680000,10897600). Resolving a bare ambiguous id
by trying SM first would silently derive the wrong extent, so it raises
and names the candidates, pointing at the fully-qualified form instead.

Docs updated accordingly: the loop in guides/bdc.md now passes the full
tile source id rather than splitting off the suffix, with a warning about
cross-level collisions, and the lookup order is documented in both
guides/bdc.md and architecture/tiling.md.

Tests: 12 new covering both conventions, precedence, ambiguity and the
not-found message. Full suite 107 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
`_crop_to_grid` caught every exception from `clip_box` and fell back to
returning the unclipped band. One of those exceptions is `NoDataInBounds`,
raised precisely when the source and the target grid do not overlap — so
"there is nothing here" was handled as "read everything", then reprojected
into an all-nodata window.

Measured on real data (BDC_SM tile 027005 against the BR-MANGUE ANADEM
mosaic, EPSG:5880 -> BDC Albers): the fallback materialised the full
18352x21350 source, 31.6x the 3520x3520 target window, peaking at 2.42GB
RSS to produce 50MB of nodata. With the fix the same tile stays at 0.18GB.

This is a normal case, not an error: tile meshes are selected by envelope,
so some tiles fall outside the data. `_crop_to_grid` now returns None for
it and `_align_raster` builds the empty result directly, carrying the
source nodata in `_disscube_nodata` so categorical operators mark every
cell invalid exactly as they would for a reprojected empty window.

Other exceptions keep the previous fallback: an unexpected failure should
degrade to a correct if expensive read, never to wrong output.

Tests: 7 new covering both the crop contract and the aligned result,
including a guard that the shortcut does not swallow the normal path.
Full suite 114 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
Companion to 01_dominio_estrutural.py, producing the same product over the
national BDC_SM mesh with the master grid in BDC Albers instead of an ad hoc
pixel mesh in the source's own CRS.

The pair makes the trade-off concrete rather than theoretical. Measured on
the same data: the ad hoc mesh runs 30 tiles in 80s under 1GB and reproduces
the source bit for bit, while the BDC mesh runs 32 tiles in 100s peaking at
3.7GB and loses ~1% of cells per class to edge resampling — the cost of
being on a canonical, shareable grid. The README now states when each is the
right choice.

Tile selection densifies the source footprint before projecting it: taking
min/max of the four transformed corners overestimates the area, because
straight edges become curves in another projection. That over-selection
picked up 3 tiles that do not touch the data (35 vs the correct 32). They
would now derive cheaply as empty, but selecting them at all is wasted work.

Passes the full tile id (BDC_SM_027005) rather than the bare number, since
ids are not unique across BDC levels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
The mosaic's extent is a rectangle but the valid data is a coastal strip,
so most tiles intersecting the extent cover nothing but nodata: 17 of 32
derived to fully empty rasters.

Tile selection now takes one decimated pass over the source to build a
low-resolution validity mask, and keeps only the tiles that touch it.
Memory for that pass is bounded by the target width, not by the source
size.

Measured on the same data: 32 -> 15 tiles, 99.9s -> 56.9s, peak RSS
3.70GB -> 2.81GB, with class counts identical to the previous run (zero
valid cells lost). Disk was never the problem — the empty Zarrs compressed
to almost nothing, so the store only went 257MB -> 252MB. The win is
compute, not storage.

The mask is dilated by one cell: nearest-neighbour decimation can drop
thin features, and the mangrove strip is a few pixels wide in places. The
costs are asymmetric — a false positive derives one tile for nothing, a
false negative silently loses data from the product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
820a5fc fixed this for the BDC example but left 01_dominio_estrutural.py
deriving the full rectangular mesh, where half the tiles cover nothing but
nodata — the same waste, reported by the user after running it.

Same approach: one decimated pass over the source builds a low-resolution
validity mask, and only tiles touching it are derived. Simpler here than in
02_, since the tiles are pixel windows in the source itself, so the mask is
indexed directly with no CRS transform.

Measured: 30 -> 15 tiles, 80s -> 36.3s, and the output is still bit for bit
identical to the reference GDAL pipeline over all 391M cells in both bands.

Skipping is safe precisely because the mask comes from the state band: the
reference implementation writes 255 to BOTH bands wherever state is nodata,
so a tile with no valid state contributes nothing but the nodata the output
already holds.

README's comparison table updated with the new figures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
Both examples created their own catalog.db and store under a per-example
directory, unlike the other twelve examples, which all use
CubeClient(catalog="catalog.db", store="./data/").

That was not merely cosmetic: with a private catalog the derived variables
never showed up in cube.search() alongside the rest, and could not be
combined through to_lucc_data() — losing the reason to catalog them at all.
Each example was an island.

Now both share the standard catalog and store, and follow the repo's
directory conventions: VRTs go to data/raw/brmangue/ next to the other raw
inputs, the GeoTIFFs to data/.

Verified by running both: one catalog holds the two grids (brmangue/30m in
EPSG:5880 and brmangue/30m_bdc in BDC Albers) with 60 DerivedVariable
entries — papel and elevacao, 15 tiles each, on both grids. The two meshes
are now comparable within the same cube.

Note that both examples write the same VRTs and register the same
SpatialSource ids, deriving them from the same ANADEM tiles. That is
idempotent today, but the second run would silently overwrite the first if
they ever diverge.

Reported by the user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
A variable derived tile by tile exists as N Zarr stores, and load() refuses
that case because there is no automatic mosaic (docs/architecture/tiling.md).
Until now the package stated the problem and offered no way out: the caller
hit a ValueError listing the tiles and was on their own.

tile_layout() answers the question load() cannot — where each piece sits on
the master grid — without assembling anything and without imposing a
destination. It returns plain data (path plus position), not an object from
another package, so whoever consumes it decides: load into memory, write to
a disk workspace, or just inspect coverage. Same arrangement geomosaic
already uses when it hands back tile_offsets.

Dimensions come from the registered bbox, not from the file: it is the same
bbox derive(tile_id=...) cropped with, so it states the intended position. A
store whose contents disagree is an inconsistency to catch, not to paper
over.

A variable with no tiles returns a single item covering the whole grid, so
callers can treat both cases the same way.

This makes the example 03 collapse from ~100 lines of offset arithmetic to
six, and it now demonstrates use rather than working around a limitation.
Its seam checks moved to haloexec as tests (f22057d) — they were proving the
assembly code, not the data, and re-running them on every load protects
nothing. Verified after the rewrite: the same 10 seams on the real BDC grid
still check out against the source Zarrs, 0 divergences.

Tests: 13, aimed at the position arithmetic, which is where an error hides —
a wrong sign on row_off flips the mosaic vertically and nothing raises. Also
covers both tile registration conventions, the global case, and the errors.

Full suite 127 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
A variable derived with valid_from/valid_until has one set of pieces PER
SLICE, all at the same tile positions. tile_layout() returned them
together, so every position appeared once per year — and assembling from
that would let one year overwrite another with no error at all.

Found by running the real thing: the MapBiomas mangrove series over five
marker years came back as 140 pieces in 28 positions, each repeated five
times with a different spec_hash in the URL. The existing tests missed it
because they only covered static variables.

tile_layout() now takes an optional `time`, and refuses a temporal
variable when it is omitted, naming the available slices — the same
discipline load() already applies to multi-tile. Each item also carries
`times`, empty for static variables, so a caller can tell which slice it
is holding.

Verified on the series: without time it raises listing
[1985, 1995, 2005, 2015, 2024]; with time=<year> each slice yields 28
pieces in 28 distinct positions, no overlap. Static variables are
unaffected and still need no time.

Unrelated but worth recording, since it was the run that found this: the
derived tiles were checked one by one against the source VRT window —
140/140 identical, and the 2024 count (7,527,925 mangrove cells) matches
exactly what the structural domain reports for class 1, by an independent
route.

Tests: 7 new for the temporal cases. Full suite 134 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
Brings the MapBiomas binary mangrove presence into the cube as a TEMPORAL
variable: one slice per year, so load() returns (time, y, x). The earlier
examples only derive static variables, so nothing yet exercised the slice
machinery — the SpatialSource time=<year>, the valid_from/valid_until on
the derivation, or tile_layout()'s per-slice selection.

Source shape drives the structure: the mangue_* products hold one DECADE
per file with one year per band, and write_vrt mosaics a single band per
call, so each year becomes its own VRT.

The loop calls gc.collect() after every derive, which is not incidental.
Each derive() leaves ~50 objects in reference cycles holding ~380 MB.
Reference counting cannot break a cycle; only the generational collector
can, and it almost never fires here because its trigger is the NUMBER of
allocations while numpy concentrates hundreds of MB in very few objects —
the collector never sees the memory pressure.

Measured on a cold run of all 140 derivations: without the collect, RSS
grows ~380 MB per tile, passes 7 GB and the run was killed at tile 21 of
year 2015; with it, peak RSS is 1.07 GB and the run finishes in 100.8s.
Seven times less memory for about 7% more time (0.05s per collect against
~0.7s per derive).

The call is in the example rather than inside derive() because imposing
that cost on every caller is the package's decision, not this example's.
The reasoning is written at the bottom of the file so it does not read as
a stray defensive line.

README updated with 03 and 04, which were both missing from the listing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
Two things, both found by running the pipeline on real data.

The memory one is new and practical: each derive() leaves ~50 objects in
reference cycles holding ~380 MB, and the generational collector almost
never fires to reclaim them, because its trigger is the NUMBER of
allocations while NumPy concentrates hundreds of MB in very few objects.
A long loop therefore grows until it dies — measured at 140 tiles, RSS
passed 7 GB and the run was killed; with gc.collect() per call it stayed
near 1 GB, for about 7% more time. Documented under known limitations,
with the workaround and a pointer to example 04, until the collection is
done internally.

The stale one: both the README and docs/architecture/tiling.md still said
load() "silently returns the first result" for a multi-tile variable. It
raises ValueError naming the tiles — the explicit error they described as
planned is already there. Corrected, and tiling.md now documents
tile_layout() as the way to actually consume a multi-tile variable,
including the temporal case where a slice must be chosen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
One band per year, written window by window from the Zarr tiles of each
slice. It is not needed by anything in the cube — a multi-tile Zarr is not
directly viewable, so this exists to open in QGIS and confirm the slices
landed correctly.

tools/zarr_to_tif.py does not cover this: it converts one whole store at a
time, so it cannot reassemble N tiles, and it currently picks data_vars[0],
which in a DisSCube Zarr is spatial_ref rather than the data.

Verified against the same source the series was checked against: the five
bands report 2.91% to 2.97% mangrove, matching what the Zarrs hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
…port

Combines what 04 and 02 do separately: the temporal mangrove series, but on
the national BDC_SM mesh with the master grid in BDC Albers, so every tile
goes through a real 5880 -> Albers reprojection rather than the identity
path. 120 derivations, 170s cold, peaking at 1.47 GB — the gc.collect()
from 04 is what keeps that bounded.

The export deserves a note, because a first version of it was wrong in a
way that produced invented data. Writing tile by tile leaves the uncovered
part of each border block filled with GDAL's default zero rather than the
declared nodata, and here zero is a VALID value (non-mangrove). The result
had 19.6 million cells outside the study area reading as observed
non-mangrove, in two of the five bands.

The cause is that BDC tiles are 3520x3520 while the GeoTIFF blocks are 512,
and 3520 is not a multiple of 512, so every tile edge lands mid-block.
Examples 01 and 02 never hit this because their tiles are 4096, an exact
multiple. The fix is the same lesson the Zarr loader already encodes: walk
the blocks of the DESTINATION, not the tiles, and build each block from
whatever covers it.

Verified after the fix: all five bands report 206,007,691 valid cells,
matching the Zarrs exactly, with zero cells outside the tiled area.

Also records what the reprojection costs — class counts land ~1% below the
EPSG:5880 series of example 04, which does not resample. That is the price
of a canonical mesh, not an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
Checking whether tiles landed in the right place meant opening the whole
mosaic, and where the values on both sides of a boundary agree, that shows
nothing at all.

This crops only the requested tiles to their own envelope and adds a band
carrying the tile index, which makes the boundaries visible even there.
Styled as categorical in QGIS, the seams show up directly.

It complements tools/zarr_to_tif.py rather than replacing it: that one
converts a single store, this one assembles several tiles — which is the
case a multi-tile variable actually presents, and the one zarr_to_tif
cannot cover.

Writes by walking the blocks of the GeoTIFF, not the tiles. When the tile
side is not a multiple of the block (BDC tiles are 3520, blocks are 512),
every tile edge lands mid-block and the uncovered part of those blocks
keeps GDAL's default zero rather than the declared nodata. Where zero is a
valid value that is invented data — the same trap example 05 fell into
before being fixed.

Resolves the catalog's relative asset_url against the catalog's own
directory, so the tool works when run from anywhere, not only from the
repository root.

Errors speak the tool's language rather than the API's: an unknown tile is
refused listing what exists, and a temporal variable without --tempos names
the available slices and shows the flag to use.

README documents it with both modes and explains the block-walking, so the
reason survives longer than this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHivBmXr3hkX1H7SayXXkB
@profsergiocosta
profsergiocosta merged commit 58f746e into develop Aug 28, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant