Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7c2b51a
update
profsergiocosta Aug 27, 2026
4f30459
feat: add generic reclassify operator (value lookup table)
profsergiocosta Aug 27, 2026
be4aa51
docs: add BR-MANGUE structural domain example (reclassify + tiling)
profsergiocosta Aug 27, 2026
09f1c33
fix: resolve BDC tile sources in derive(tile_id=...)
profsergiocosta Aug 27, 2026
6c16c6b
fix: don't read the whole source when it doesn't overlap the grid
profsergiocosta Aug 27, 2026
44e8671
docs: add BR-MANGUE domain example on the BDC tile mesh
profsergiocosta Aug 27, 2026
820a5fc
perf: select BDC tiles by valid-data mask, not by extent
profsergiocosta Aug 27, 2026
14c6d59
perf: select tiles by valid-data mask in the ad hoc mesh example too
profsergiocosta Aug 27, 2026
fd12215
fix: use the shared catalog and store in the brmangue examples
profsergiocosta Aug 27, 2026
f57056e
novo exemplo usando halo
profsergiocosta Aug 28, 2026
0cdf099
feat: add CubeClient.tile_layout() and use it in the haloexec example
profsergiocosta Aug 28, 2026
88c6357
fix: tile_layout() conflated the time slices of a temporal variable
profsergiocosta Aug 28, 2026
c39c329
docs: add temporal mangrove series example, and call gc.collect() in it
profsergiocosta Aug 28, 2026
42d42d2
docs: record the derive() memory finding, and correct a stale limitation
profsergiocosta Aug 28, 2026
ba7e730
docs: export a multiband GeoTIFF from the temporal series
profsergiocosta Aug 28, 2026
2a56071
docs: add the temporal series on the BDC mesh, with a windowed TIF ex…
profsergiocosta Aug 28, 2026
04f0fa0
feat: add tools/tiles_to_tif.py to inspect a tile mesh
profsergiocosta Aug 28, 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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,15 @@ Cada chamada a `derive()` carrega o dado completo de um tile em memória. Não h
**Agregação vetorial por rasterização (não área-ponderada)**
Operadores sobre fontes vetoriais (`majority`, `percentage`, `attribute`, `presence`, `minority`) convertem geometrias em raster antes de agregar pixels. A fração de cobertura de cada célula é estimada por contagem de pixels, não por cálculo de área de interseção. Para cobertura proporcional mais precisa, use uma fonte raster em resolução substancialmente maior que a célula-alvo.

**Desambiguação de tiles em `load()`**
`CubeClient.load(name)` sem `tile_id` retorna silenciosamente o primeiro resultado quando múltiplos tiles da mesma variável existem na mesma grade. Erro explícito ou mosaico automático estão planejados. **Especifique sempre `tile_id` em workloads multi-tile.**
**Sem mosaico automático em `load()`**
`CubeClient.load(name)` sem `tile_id` levanta `ValueError` quando múltiplos tiles da mesma variável existem na mesma grade — remontá-los num array único não está implementado. Para consumir uma variável multi-tile, use `tile_layout(name, grid_id)`: ele devolve onde cada pedaço cai na grade (caminho e posição, como dado puro) e deixa a montagem a cargo de quem consome. Para variáveis temporais é preciso escolher a fatia, com `time=<ano>`, já que cada fatia repete as mesmas posições de tile.

**Memória acumula ao longo de um loop de `derive()`**
Cada `derive()` deixa cerca de 50 objetos presos em ciclos de referência, retendo aproximadamente 380 MB. Contagem de referências não desfaz ciclo, e o coletor geracional quase nunca dispara aqui: o gatilho dele é o *número* de alocações, enquanto o NumPy concentra centenas de MB em pouquíssimos objetos — o coletor não enxerga a pressão de memória.

Na prática, um loop longo cresce até estourar. Medido ao derivar 140 tiles: o RSS sobe ~380 MB por tile e passa de 7 GB, sendo morto pelo sistema; com `gc.collect()` após cada chamada, fica estável em ~1 GB. O custo é cerca de 7% (0,05 s por coleta contra ~0,7 s por `derive()`).

Até que a coleta seja feita internamente, **quem deriva muitos tiles ou muitas fatias em sequência deve chamar `gc.collect()` no loop** — ver `examples/case_studies/brmangue_dominio/04_serie_temporal_mangue.py`. Rodar cada tile em subprocesso separado também resolve, ao custo de reabrir o catálogo a cada vez.

**`SpatialRelation` não atua no pipeline**
O modelo `SpatialRelation` é persistido no catálogo, mas nenhum estágio do pipeline usa as relações durante a derivação — e por isso elas são **excluídas do `spec_hash`**. Incluí-las tornaria a chave de cache sensível a metadados que não afetam o resultado, quebrando a garantia de reprodutibilidade. A integração com estratégias hierárquicas de grades está reservada para versão futura.
Expand Down
212 changes: 209 additions & 3 deletions disscube/client/cube_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,7 @@ def derive(self, derivation: SpatialDerivation, tile_id: Optional[str] = None) -
raise ValueError(f"Grid not found: {derivation.grid_id}")

if tile_id:
tile_source = self.catalog.get_spatial_source(f"{grid.id}_{tile_id}")
if not tile_source or not tile_source.bbox:
raise ValueError(f"Tile {tile_id} with valid bbox not found for grid {grid.id}")
tile_source = self._resolve_tile_source(grid.id, tile_id)

grid = GridSpec(
id=grid.id,
Expand Down Expand Up @@ -94,6 +92,72 @@ def derive(self, derivation: SpatialDerivation, tile_id: Optional[str] = None) -
if d.spec_hash == spec_hash
]

# BDC tile levels, coarsest-last. Order is only used for reporting; an
# ambiguous id is always an error, never resolved by precedence.
_BDC_TILE_LEVELS = ("SM", "MD", "LG")

def _resolve_tile_source(self, grid_id: str, tile_id: str) -> SpatialSource:
"""
Find the ``SpatialSource`` whose ``bbox`` defines ``tile_id``.

Two registration conventions are supported, tried in this order:

1. ``{grid_id}_{tile_id}`` — a tile mesh defined for one specific
grid (see ``docs/architecture/tiling.md``).
2. ``BDC_{LEVEL}_{tile_id}`` — the national BDC tile grids, as
registered by ``disscube.utils.bdc_importer``. These are
grid-independent: the same tile envelope serves every grid that
shares the BDC CRS, so they are not duplicated per grid.

A fully-qualified id (``"BDC_SM_027005"``) is also accepted directly,
which is the way to disambiguate a bare id that exists at more than
one BDC level.

Raises
------
ValueError
If nothing matches, if the match has no ``bbox``, or if a bare
tile id exists at several BDC levels. The levels cover different
areas (SM ~1.5°, MD ~3°, LG ~6°) and share many ids, so guessing
a level would silently derive the wrong extent.
"""
scoped = self.catalog.get_spatial_source(f"{grid_id}_{tile_id}")
if scoped is not None:
if not scoped.bbox:
raise ValueError(
f"Tile source {scoped.id!r} has no bbox; a tile source must "
"carry the bbox of the partition to process."
)
return scoped

qualified = self.catalog.get_spatial_source(tile_id)
if qualified is not None and qualified.bbox:
return qualified

matches = []
for level in self._BDC_TILE_LEVELS:
candidate = self.catalog.get_spatial_source(f"BDC_{level}_{tile_id}")
if candidate is not None and candidate.bbox:
matches.append((level, candidate))

if len(matches) == 1:
return matches[0][1]

if len(matches) > 1:
found = ", ".join(f"BDC_{lvl}_{tile_id}" for lvl, _ in matches)
raise ValueError(
f"Tile id {tile_id!r} is ambiguous: it exists at several BDC "
f"levels ({found}), which cover different areas. Pass the "
f"fully-qualified id instead, e.g. tile_id='BDC_"
f"{matches[0][0]}_{tile_id}'."
)

raise ValueError(
f"Tile {tile_id!r} with valid bbox not found for grid {grid_id!r}. "
f"Looked for {grid_id}_{tile_id}, {tile_id}, and BDC_"
f"{{{','.join(self._BDC_TILE_LEVELS)}}}_{tile_id}."
)

def derive_declarative(
self,
derivation: "Derivation", # noqa: F821 — imported lazily to avoid circular refs
Expand Down Expand Up @@ -206,6 +270,148 @@ def _exists(d: DerivedVariable) -> bool:
derived = static[0]
return xr.open_zarr(derived.asset_url, consolidated=False)[derived.name]

def tile_layout(
self,
variable_id: str,
grid_id: str,
time: Optional[int] = None,
) -> List[dict]:
"""
Onde cada pedaço de uma variável derivada cai na grade mestra.

Responde a pergunta que ``load()`` não responde: uma variável
derivada tile a tile existe como N stores Zarr, e ``load()`` recusa
o caso multi-tile porque não há mosaico automático (ver
``docs/architecture/tiling.md``). Este método devolve a informação
necessária para montá-la — sem montar nada, e sem impor um destino.

O retorno é **dado puro**, não um objeto de outro pacote: uma lista
de dicionários com caminho e posição. Quem consome decide o que
fazer — carregar em memória, escrever num workspace em disco,
inspecionar a cobertura. É o mesmo arranjo que o ``geomosaic`` usa
ao devolver ``tile_offsets`` sem conhecer quem vai lê-los.

Uma variável derivada sem tiles (``tile_id`` nulo, partição
``global``) devolve uma lista de UM elemento cobrindo a grade
inteira, para que quem consome trate os dois casos igual.

Parameters
----------
variable_id : str
Nome da variável derivada (ex.: ``"papel"``).
grid_id : str
Grade mestra sobre a qual os pedaços se posicionam.
time : int, optional
Fatia temporal a devolver, para variáveis derivadas com janela
de validade (``valid_from``/``valid_until``). Uma variável
temporal tem um conjunto de pedaços POR ANO, todos nas mesmas
posições — devolvê-los juntos daria um layout em que cada
posição aparece várias vezes, e quem montasse a partir dele
sobrescreveria um ano com outro sem perceber. Por isso, se a
variável tiver mais de uma fatia e ``time`` não for informado,
o método falha em vez de escolher (mesma disciplina de
``load()`` diante de multi-tile).

Returns
-------
list[dict]
Ordenada por ``(row_off, col_off)``. Cada item tem:

- ``tile_id`` — identificador do tile, ou ``None`` se global
- ``variable`` — nome da variável dentro do store Zarr
- ``url`` — caminho do store
- ``row_off`` — linha, em pixel, onde o pedaço começa na grade
- ``col_off`` — coluna, em pixel
- ``height`` — altura do pedaço, em pixel
- ``width`` — largura do pedaço, em pixel
- ``times`` — anos cobertos por este pedaço (lista, vazia
quando a variável é estática)

As dimensões vêm do ``bbox`` registrado, não do arquivo: é o
mesmo ``bbox`` que ``derive(tile_id=...)`` usou para recortar,
então descrevem a posição pretendida. Um store cujo conteúdo
divirja disso é inconsistência a detectar, não a mascarar.

Raises
------
ValueError
Se a grade não existir, se a variável não tiver nenhum derivado
nela, ou se um tile não tiver ``SpatialSource`` com ``bbox`` —
sem o bbox não há como saber onde o pedaço cai.
"""
grid = self.catalog.get_grid(grid_id)
if grid is None:
raise ValueError(f"Grid not found: {grid_id}")

derived = [
d for d in self.catalog.search_derived_variables(grid_id=grid_id)
if d.name == variable_id
]
if not derived:
raise ValueError(
f"Derived variable not found: {variable_id} on grid {grid_id}"
)

# Uma variável temporal repete cada posição uma vez por fatia; sem
# escolher a fatia, o layout descreveria a mesma célula N vezes.
fatias = sorted({t for d in derived for t in (d.times or [])})
if time is not None:
derived = [d for d in derived if time in (d.times or [])]
if not derived:
raise ValueError(
f"Derived variable {variable_id!r} on grid {grid_id!r} has no "
f"slice for time {time}. Available: {fatias}"
)
elif len(fatias) > 1:
raise ValueError(
f"Derived variable {variable_id!r} on grid {grid_id!r} is temporal "
f"and spans several slices: {fatias}. Each slice repeats the same "
f"tile positions, so a combined layout would describe every cell "
f"more than once — pass time=<year> to pick one."
)

layout: List[dict] = []
for d in derived:
if not d.tile_id:
layout.append({
"tile_id": None,
"variable": d.name,
"url": d.asset_url,
"row_off": 0,
"col_off": 0,
"height": grid.rows,
"width": grid.cols,
"times": list(d.times or []),
})
continue

# Duas convenções de registro de tile, na mesma ordem que
# derive() usa para resolvê-las.
source = (
self.catalog.get_spatial_source(f"{grid_id}_{d.tile_id}")
or self.catalog.get_spatial_source(d.tile_id)
)
if source is None or not source.bbox:
raise ValueError(
f"Tile {d.tile_id!r} of {variable_id!r} has no SpatialSource "
f"with a bbox, so its position on grid {grid_id!r} is unknown. "
f"Looked for {grid_id}_{d.tile_id} and {d.tile_id}."
)

minx, miny, maxx, maxy = source.bbox
layout.append({
"tile_id": d.tile_id,
"variable": d.name,
"url": d.asset_url,
"row_off": int(round((grid.bbox[3] - maxy) / grid.resolution)),
"col_off": int(round((minx - grid.bbox[0]) / grid.resolution)),
"height": int(round((maxy - miny) / grid.resolution)),
"width": int(round((maxx - minx) / grid.resolution)),
"times": list(d.times or []),
})

return sorted(layout, key=lambda t: (t["row_off"], t["col_off"]))

def to_lucc_data(
self,
variables: List[str],
Expand Down
11 changes: 11 additions & 0 deletions disscube/derivation.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ class Derivation(BaseModel):
class_code : int | None
Target class code used by class-aware operators (e.g. ``"percentage"``).
Required when the operator demands it; reserved but optional otherwise.
mapping : dict[int, int] | None
Source-value -> target-value lookup table used by value-remapping
operators (e.g. ``"reclassify"``). Required when the operator demands
it. Folded into ``spec_hash()`` via ``Variable``, so changing the
table always yields a distinct product.
role : str
Semantic role of the variable (e.g. ``"driver"``, ``"state"``).
Defaults to ``"driver"``.
Expand All @@ -66,6 +71,7 @@ class Derivation(BaseModel):
source_id: str
operator: str
class_code: int | None = None
mapping: dict[int, int] | None = None
role: str = "driver"
valid_from: str | None = None
valid_until: str | None = None
Expand All @@ -85,6 +91,10 @@ def _validate_operator(self) -> "Derivation":
raise ValueError(
f"Operator {self.operator!r} requires class_code to be set."
)
if meta.requires_mapping and not self.mapping:
raise ValueError(
f"Operator {self.operator!r} requires mapping to be set."
)
return self

# ── Conversion helpers ────────────────────────────────────────────────────
Expand All @@ -103,6 +113,7 @@ def to_variable(self) -> Variable:
name=self.target,
operator=self.operator,
class_code=self.class_code,
mapping=self.mapping,
)

def to_spatial_derivation(self, grid_id: str) -> SpatialDerivation:
Expand Down
6 changes: 6 additions & 0 deletions disscube/models/variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class Variable(BaseModel):
operator: str
class_code: int | None = None

# Explicit source-value -> target-value lookup table, used by value-remapping
# operators (e.g. "reclassify"). Kept as plain data (not a callable) so it
# travels through the catalog and is folded into spec_hash() — two
# derivations using different tables are always distinct products.
mapping: dict[int, int] | None = None


class SpatialSource(BaseModel):
id: str
Expand Down
2 changes: 1 addition & 1 deletion disscube/operators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"""

# Import submodules to trigger auto-registration of all operator classes.
from . import zonal, proximity # noqa: F401
from . import zonal, proximity, reclassify # noqa: F401

from .zonal import ZonalAggregator
from .proximity import ProximityAggregator
Expand Down
5 changes: 5 additions & 0 deletions disscube/operators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class body is executed (via ``__init_subclass__``), so there is no

name: ClassVar[str]
requires_class_code: ClassVar[bool] = False
# When True, ``Derivation`` enforces that ``mapping`` (a source-value ->
# target-value lookup table) is set at construction time, the same
# fail-fast contract ``requires_class_code`` provides for class-aware
# operators.
requires_mapping: ClassVar[bool] = False
_resampling: ClassVar[Resampling] = Resampling.nearest

# When True, GridAligner must NOT pre-aggregate the band with this
Expand Down
Loading
Loading