Skip to content

feat: Sentinel-3 OLCI GeoZarr export - #212

Merged
emmanuelmathot merged 81 commits into
EOPF-Explorer:mainfrom
d-v-b:feat/sentinel3-export
Aug 11, 2026
Merged

emmanuelmathot merged 81 commits into
EOPF-Explorer:mainfrom
d-v-b:feat/sentinel3-export

Conversation

@d-v-b

@d-v-b d-v-b commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Adds Sentinel-3 OLCI export support, following the same optimized-converter pattern as Sentinel-2:

  • New s3_olci_optimization package: convert_olci_optimized entry point with an output_grid parameter (CLI --output-grid):
    • "native" (default): preserves the instrument swath geometry — raw bands with per-pixel 2-D latitude/longitude/altitude, per-row time_stamp, and no projected CRS. Overview lat/lon are per-block geodesic centroids matching the block-averaged radiance.
    • Any CRS string (e.g. EPSG:4326): warps the swath once onto a regular grid via rasterio's dense geolocation arrays; every pyramid level then carries spatial_ref + grid_mapping per band plus zarr-cm spatial: (affine transform/bbox) and proj: convention attrs, so ds.rio.crs resolves and titiler can tile it.
  • Layout (both modes): fill-aware /2 block-averaged pyramid as named sibling groups measurements/r0 (native res), r2, r4, … — the S2-optimized pattern, so the whole store opens with xr.open_datatree in either mode.
  • data_api/s3_olci.py: pydantic-zarr model with conservative structural detection.
  • CLI: auto-detection in convert (native default; --no-s3-olci-optimized opt-out, --min-dimension forwarded), plus the explicit convert-s3-olci-optimized subcommand.
  • Robustness: store truncation on re-run, fill-collision nudge clamped to dtype range, NaN-aware fill masking, transposed-band normalization, raw (mask_and_scale=False) return-tree semantics.
  • Tests: real-product fixture; per-mode golden snapshots; warp + geodesic-centroid unit tests (behavior + per-error-case); cross-product contract test asserting every product converted to a regular grid (S1, S2, OLCI with output_grid="EPSG:4326") declares a CRS at every level, plus a native-mode swath contract test.
  • Docs: converter documentation for both modes and a demonstration notebook (native pyramid + opt-in regridding) executed against a real EODC product.

conditions/quality are copied through unchanged in both modes.

Review feedback addressed

  • Multiscales layout moved off "asset": "." nesting to named r0 siblings (DataTree/titiler-compatible) — @lhoupert's finding 1.
  • CRS via grid_mapping/spatial_ref + proj: attrs on an optionally reprojected regular grid — finding 2 / swath-tiling question. Regridding is opt-in (--output-grid EPSG:4326) per the follow-up requirement to preserve the instrument grid; pipelines that need xyz tiles should pass a CRS.

Test plan

  • Full test suite passes locally (unit, integration, per-mode snapshots, cross-product contract)
  • pyright clean
  • Demo notebook executed end-to-end against the real EODC S3A product (both modes)
  • CI green on this PR
  • Staging pipeline run with --output-grid EPSG:4326 for the titiler path

🤖 Generated with Claude Code

d-v-b and others added 30 commits June 19, 2026 15:04
Route multiscales, spatial, and proj convention metadata through a single
utils.build_convention_attrs() helper that delegates to zarr_cm.create_many,
so zarr-cm validates each convention and emits its CMO. Previously the CMOs
were hand-placed and the spatial:/proj:/multiscales keys assembled by hand,
which skipped zarr-cm validation (e.g. multiscales' layout>=1 and
derived_from=>transform rules) and duplicated key strings.

Type the helper precisely with zarr-cm's TypedDicts (SpatialAttrs,
GeoProjAttrs, MultiscalesAttrs, MultiConventionAttrs) and a CRSLike Protocol
instead of dict[str, Any]. Multiscales data is still produced by the project's
MultiscaleMeta model (it also covers the TMS encoding zarr-cm doesn't model),
but its CMO and validation now go through zarr-cm. Output is byte-identical to
before (verified against the golden-file snapshot tests).

Add tests/test_conversion/test_convention_attrs.py covering the helper,
including that zarr-cm now rejects an invalid multiscales layout.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Ignore root-level scratch files (test.py, tmp.json, cli_test.sh) and the
machine-local .claude/ session directory so they stop showing in git status.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
zarr-cm's convention TypedDicts now use PEP 728 `extra_items`, which mypy does
not support. Switch the type checker to pyright (which does) and upgrade to the
zarr-cm main git dep that also ships the supporting fixes:
- Mapping-covariant aggregate API + exported JsonType/JsonValue/JsonDict, so
  build_convention_attrs no longer needs a private `_core` import or a cast.
- PEP 695 `type JsonValue` alias, so pydantic resolves MultiscaleGroupAttrs'
  ConventionMetadataObject field directly (removed the model_rebuild workaround).

Toolchain:
- pyproject: replace [tool.mypy] with [tool.pyright]; mypy -> pyright dev dep.
- .pre-commit-config / CI lint job: run `uv run --frozen pyright`.

Type fixes across src/ and tests/ to reach a clean pyright run (0 errors):
- remove stale mypy `# type: ignore[...]` comments (pyright-unnecessary);
- narrow NotRequired TypedDict access (`.get()` + guard) instead of making keys
  Required, preserving runtime validation of optional Sentinel-1/2 members;
- replace casts on external/untyped data with runtime isinstance/validation;
- model construction via `Model.model_validate(...)` instead of `**dict`.

No `typing.Any` introduced. Runtime behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Three casts asserted a type derived from Any or a union without verifying it.
Replace each with an isinstance guard that raises TypeError on violation, so
the assumption is enforced at runtime instead of only asserted to the checker:

- sentinel1_reprojection: rio.write_crs() returns Any -> verify xr.Dataset.
- s2_multiscale: output_group[base_path] is Array | Group -> verify zarr.Group.
- s2_multiscale: client.compute() returns Any -> verify distributed.Future.

The remaining casts bridge types on data we already validated (model_dump /
create_many output), satisfy protocol/TypeVar binding on `self`, or widen a
TypedDict for a third-party API — a runtime check there adds no safety.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
First Sentinel-3 exporter, scoped to OLCI L1 EFR. Grounded in a real EOPF
product introspected from the EODC sample store: 21 radiance bands on a single
~300m curvilinear swath grid geolocated by per-pixel 2D lat/lon (no projected
CRS). Decision: preserve native swath geometry with CF 2D coordinates (no
reprojection); /2 decimation pyramid; measurements-first scope; mirror the S2
package + data_api model + CLI auto-detection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
10-task TDD plan mirroring the S2 exporter: OLCI band mapping, data_api model,
swath /2 decimation, structural detection, swath GeoZarr metadata,
convert_olci_optimized entry point, CLI auto-detect + convert-s3-olci-optimized,
real-product JSON fixture, golden-file snapshot, and verification/docs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
pyproject already pins zarr-cm>=0.4.1; update the lockfile from the git-main
dev build to the released 0.4.1.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add swath_spatial_attrs() function to return GeoZarr spatial: convention
data for curvilinear swath geometry with pixel registration but no
affine transform or bbox (geolocation carried by 2-D lat/lon arrays).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… DataTree omits overviews

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add structure-dump fixture (shapes shrunk to 16x16 / tie-points 16x4)
from S3A_OL_1_EFR NT product fetched via HTTPS .zmetadata, add
s3_olci_group_example conftest fixture, and extend test_s3_olci.py with
is_sentinel3_olci_dataset and Sentinel3OlciRoot round-trip tests.
Also update Sentinel3OlciMeasurementsMembers to include time_stamp and
fix quality/conditions member types to allow nested GroupSpec children.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add a Supported Products section to README.md and a dedicated
Sentinel-3 OLCI L1 EFR Conversion section to docs/converter.md,
covering: auto-detection via `eopf-geozarr convert`, the dedicated
`convert-s3-olci-optimized` command with all key flags, output
layout (measurements + r2/r4/... overviews + conditions/quality
passthrough), and the v1 scope note (encoding wiring is a follow-up).

Also add `# test: skip` to the first code block in the OLCI
implementation plan doc so test_docs.py skips the non-runnable
planning snippets.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…s passthrough, fixture dims

FIX 0 — reduce_swath: genuine 2×2 fill-aware block-average for radiance
  vars (OLCI_BANDS), stride-decimate for 2-D coord arrays (lat/lon/alt);
  replaces literal [::2,::2] decimation of radiance that added no information.

FIX 1 — declare overviews as GeoZarr multiscales: build_convention_attrs
  now receives a MultiscalesAttrs layout (asset "." + "r2"/"r4"/… with
  relative scale=[2,2] transform) and writes the CMO to measurements attrs.

FIX 2 — copy measurements/orphans through (was silently dropped because
  to_dataset() discards child groups); iterate dt_input["/measurements"].children
  and _copy_subtree each one.

FIX 3 — fixture dim consistency: renamed tie-point 'columns' → 'tie_columns'
  in geometry/meteorology arrays, fixed orphan 2-D arrays from [16,16] to
  [16,4] (removed_pixels=4), fixed instrument 2-D arrays to [bands,detectors]
  at consistent sizes, fixed 3-D meteo arrays to [4,16,4], fixed
  nb_removed_pixels shape; xr.open_datatree now opens the whole tree cleanly.
  Snapshot regenerated (conditions, quality, measurements/orphans, r2 overview).

FIX 4 — wire in OLCI_BANDS: is_sentinel3_olci_dataset uses OLCI_BANDS[0],
  reduce_swath identifies radiance vars via OLCI_BANDS frozenset.

FIX 5 — remove --enable-sharding / --keep-scale-offset from README and
  docs/converter.md copy-paste example; keep them in flags table with
  "accepted but not yet wired" note.

All 31 OLCI tests pass; pyright 0 errors; ruff clean; no typing.Any.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…vert tie_columns); regen snapshot

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…egen snapshot

- CLI and snapshot test now open OLCI source with mask_and_scale=False so
  radiance stays uint16 with scale_factor/_FillValue in .attrs; CF decoding
  no longer silently strips scale/offset or widens dtype to float64.
- Add _sanitize_data_vars() in olci_converter.py: strips _eopf_attrs, dtype,
  valid_min, valid_max from measurement data-var attrs before writing to
  GeoZarr store (both native and overviews); preserves _FillValue,
  scale_factor, add_offset, units, standard_name, coordinates.
- Update sanitize_array_attrs() in conversion/utils.py: always strip
  _eopf_attrs/dtype/valid_min/valid_max; only strip _FillValue when
  is_decoded_float=True (raw-integer path keeps it in attrs for downstream
  fill handling).
- Update _clear_encoding docstring: clarify converter expects raw
  (non-mask-scaled) input; only Zarr v2 codec encoding is stripped.
- Regenerate snapshot: oa01_radiance dtype=uint16, scale_factor/add_offset/
  _FillValue present, no _eopf_attrs/dtype/valid_min/valid_max.
- Add _assert_radiance_dtype_and_attrs() regression guard in snapshot test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…CI-local attr sanitizer

The OLCI work modified the shared sanitize_array_attrs in conversion/utils.py,
changing S2/S1/generic output attrs and breaking 3 S2 golden-file snapshot tests.

- Restore sanitize_array_attrs to c613e24 original: always strips _eopf_attrs +
  _FillValue; strips dtype/fill_value/valid_min/valid_max and rewrites
  digital_counts units only when is_decoded_float=True.
- Add _sanitize_olci_array_attrs in olci_converter.py: strips _eopf_attrs/dtype/
  valid_min/valid_max while preserving _FillValue (needed for raw uint16 data
  opened with mask_and_scale=False).
- Replace sanitize_array_attrs usage in _sanitize_data_vars with the new helper.
- Add unit test asserting _FillValue is preserved and stale keys are stripped.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
d-v-b added 5 commits July 27, 2026 21:54
…/S2 xfail

Opening each pyramid level with the default xarray zarr decode never
promotes a CF grid_mapping/spatial_ref reference into a coordinate, so
ds.rio.crs came back None for S1/S2 overview levels and I wrongly
attributed that to a converter gap and added an xfail. The converter's
own read path already uses decode_coords="all" for exactly this reason
(geozarr.py:1132, :1541). Passing the same flag in the test makes CRS
detection succeed for every product's every level (S2 r2 -> EPSG:32632,
S1 r2 -> EPSG:4326), so REGULAR_GRID_XFAIL is removed entirely -
test_every_level_is_regular_grid_with_crs now genuinely passes for
olci/s1/s2 with no marks. DATATREE_XFAIL (asset='.' nested layout) is
unrelated and unchanged.

Assisted-by: ClaudeCode:claude-sonnet-5
… refinements

Integrates ~25 upstream commits with the reprojection branch. Resolution
policy (maintainer-approved): the reprojected-grid design wins; upstream
work invested in swath-native overview geolocation (geodesic block
centroids, swath map plots) is dropped as superseded by the warp, while
orthogonal improvements are preserved: store truncation on re-run,
fill-collision nudge clamped to dtype range, NaN-aware fill masking,
explicit skipna, single materialization of the coarsened mean,
block-averaged altitude, transposed-band normalization, raw
mask_and_scale=False return-tree semantics, detection hardening, CLI
--no-s3-olci-optimized opt-out and --min-dimension forwarding, and the
docs/superpowers gitignore policy (new plan/spec docs untracked to match).

Assisted-by: ClaudeCode:claude-fable-5
Assisted-by: ClaudeCode:claude-fable-5
height=src_height,
src_geoloc_array=geoloc,
)
assert width is not None
src_geoloc_array=geoloc,
)
assert width is not None
assert height is not None
@d-v-b

d-v-b commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Both review findings are now addressed on this branch (head 547981d):

  1. Multiscales layout — native resolution moved to a named sibling measurements/r0 (the S2-optimized pattern); the whole store now opens cleanly with xr.open_datatree, which is pinned by an acceptance test.
  2. CRS / tiling — measurements are warped once from the swath onto a regular grid (default EPSG:4326, --target-crs to override) via rasterio's dense geolocation arrays. Every pyramid level carries spatial_ref + grid_mapping on each band plus zarr-cm spatial:/proj: convention attrs, so ds.rio.crs resolves and titiler can build the geotransform. A new cross-product contract test asserts regular-grid + declared-CRS for S1, S2, and OLCI outputs.

Notes: per-scan-line time_stamp is dropped from converted measurements (no home on a regular grid; it survives in the source product), and the demo notebook is re-executed against the real EODC scene. @lhoupert when you get a chance, a re-run through the staging pipeline / titiler would be the definitive check.

d-v-b added 7 commits July 28, 2026 14:47
…th spatial attrs

Restores the upstream machinery (d69bd45) dropped when reprojection became
unconditional, parameterized over the reduction dims so the gridded
pipeline is unaffected. Groundwork for the native output_grid mode.

Assisted-by: ClaudeCode:claude-sonnet-5
…ult, opt-in regridding

convert_olci_optimized(..., output_grid: str = "native") replaces
target_crs: str = "EPSG:4326". "native" (default) skips the warp and
builds the r0/r2/... pyramid on (rows, columns) with swath-mode
CRS-free spatial attrs; any other value parses as a CRS and runs the
existing warp pipeline unchanged. Unparseable values raise ValueError
naming output_grid.

Also rewires the convert-s3-olci-optimized CLI's --target-crs flag to
--output-grid (default native), since the converter signature change
broke the subcommand's call site; updates the matching one-line flag
docs in README.md and docs/converter.md.

Assisted-by: ClaudeCode:claude-sonnet-5
… modes

The regular-grid+CRS contract entry for OLCI now opts into
output_grid=EPSG:4326 explicitly; a new native-mode contract test asserts
the swath store opens as a DataTree with 2-D geolocation and no fabricated
CRS. The golden-snapshot test is parametrized over both modes with
per-mode golden files replacing the single regridded one.

Assisted-by: ClaudeCode:claude-sonnet-5
…ates both modes

README and converter.md prose still described the old always-warped
behavior even though the --output-grid flag already defaulted to
native, contradicting the flag table. Rewrite the "What is converted"
bullets, the converter.md intro/flag-table/layout-tree/metadata prose,
and the demo notebook to describe native swath geometry as the default
with regridding as an opt-in EPSG:4326 warp. Notebook gains a new
"Opt-in regridding" section and was re-executed end-to-end against the
live EODC sample product.

Assisted-by: ClaudeCode:claude-sonnet-5
@d-v-b

d-v-b commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Update (head e8236b0): per the follow-up requirement to preserve the instrument grid, conversion now takes an output_grid parameter (CLI --output-grid). The default is native — swath geometry preserved, per-pixel 2-D lat/lon, per-row time_stamp, geodesic-centroid overview geolocation, no fabricated CRS. Passing a CRS (e.g. --output-grid EPSG:4326) produces the reprojected, titiler-tileable output described earlier. Both modes use the r0 named-sibling layout and open cleanly with xr.open_datatree; golden snapshots, the cross-product contract test, and the demo notebook now cover both modes.

@lhoupert note for the staging pipeline: to serve xyz tiles, pass --output-grid EPSG:4326 explicitly — the default output is now the instrument grid.

d-v-b added 5 commits July 28, 2026 17:13
_overview_levels floor-halves dimensions; min(r, c) // 2 >= min_dimension
never terminates for min_dimension <= 0, so --min-dimension 0 hung the
converter after truncating the store. Found by adversarial review.

Assisted-by: ClaudeCode:claude-fable-5
reproject_olci fed raw lat/lon values to src_geoloc_array as degrees, but
the pipeline contract is mask_and_scale=False and real OLCI stores
geolocation as int32 microdegrees with scale_factor=1e-6 — regridded
output was silently georeferenced a factor of 1e6 off on real products.
Unpack scale/offset (and mask declared fill to NaN) exactly like the
native pipeline's geodesic path does. Found by adversarial review.

Assisted-by: ClaudeCode:claude-fable-5
…ng them

Found by adversarial review; mirrors the native pipeline's normalization.

Assisted-by: ClaudeCode:claude-fable-5
…0 grid

Radiance is block-averaged, so an overview coordinate is the center of the
block it aggregates; stride-decimating the 1-D coords shifted every
level's recomputed spatial:transform/bbox by (2^l - 1)/2 base pixels and
contradicted the multiscales layout's {scale: [2,2], translation: [0,0]}.
Overview y/x are now derived from the r0 transform (common origin, pixel
size doubling per level). Snapshot seeding writes raw int32 microdegrees
(matching the real product's CF packing, exercised by the geolocation
unpacking fix); both goldens regenerated. Found by adversarial review.

Assisted-by: ClaudeCode:claude-fable-5
…erencing

Assisted-by: ClaudeCode:claude-fable-5

@emmanuelmathot emmanuelmathot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@emmanuelmathot
emmanuelmathot merged commit 0bd880d into EOPF-Explorer:main Aug 11, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 11, 2026
emmanuelmathot added a commit to d-v-b/data-model that referenced this pull request Aug 11, 2026
PR EOPF-Explorer#212 (Sentinel-3 OLCI GeoZarr export) merged to main after EOPF-Explorer#238 was
branched, leaving select_pipeline unable to reach it: OLCI products fell
into the generic pipeline and failed on the missing 'groups' option.

Adds S3_OLCI_PRODUCT_TYPE_PREFIXES and looks_like_sentinel3_olci to
routing.py, structural fallback included -- oa01_radiance is a data
variable of the measurements node, not a child node, unlike the S2
r{N}m pyramid groups. Wires convert_olci_optimized into the writer with
a new s3_olci_optimized flag and an output_grid option, kept additive
to the existing write() signature (s2_optimized is untouched) since
that's the EOWriter entry point other CPM code calls into.
emmanuelmathot added a commit that referenced this pull request Sep 28, 2026
* feat(cpm): add CPM writer plugin registering the geozarr engine

Adds eopf_geozarr.cpm, an integration package for eopf-cpm (the eopf
package, available via the new 'cpm' extra on Python >= 3.13):

- GeoZarrWriter, registered in CPM's EOWriterRegistry as engine
  "geozarr" (by-name only: cpm_zarr already claims .zarr targets in
  by-target discovery). Routes products to the Sentinel-2 optimized
  pipeline or the generic pipeline based on the declared CPM
  product:type, with a structural fallback. Zarr v3 only; rejects
  unsupported modes, formats, and options loudly.
- an 'eopf convert-geozarr' subcommand exposed through CPM's eopf.cli
  entry-point group, delegating to eopf.store.convert with the engine
  preset, enabling one-pass SAFE -> GeoZarr conversion.
- routing helpers importable without eopf installed.

convert_s2/convert_s2_optimized now skip backing-store validation for
DataTrees with no zarr backend (e.g. built in memory by the CPM SAFE
reader) instead of crashing in get_zarr_group.

Verified against eopf 3.0.0 on Python 3.13: engine registration,
convert-geozarr discovery in the eopf CLI, and an end-to-end
write_datatree(engine="geozarr") producing the flat r10m..r720m
pyramid from an S2 example store.

Assisted-by: ClaudeCode:claude-fable-5

* fix(cpm): apply adversarial-review findings to the writer plugin

Fixes the confirmed findings from a multi-agent adversarial review:

- write() now runs every argument-derivable check (target type, remote
  scheme, w- existence, pipeline routing, required 'groups') BEFORE the
  destructive mode="w" removal of an existing store. Previously a pure
  options mistake (e.g. missing groups) deleted the user's previous
  product and then failed with nothing written.
- accept compute=True (rejecting only compute=False): CPM's staged-output
  path (staging.py) injects compute=True when a remote Dask client is
  active, so the strict kwargs rejection broke the exact --stage-output
  flow the writer's own error message recommends.
- validate_write_options now applies the target checks (type, remote
  scheme), so CPM's triggering preflight rejects unsupported targets at
  parse time instead of at write time.
- routing: only S02MSIL1C/S02MSIL2A products go to the S2-optimized
  pipeline; other MSI types (S02MSIL0_, S02MSIRAW) lack the reflectance
  pyramid structure and now route generic.
- eopf_geozarr.cpm raises a clear ImportError explaining the Python
  >= 3.13 floor when eopf is missing (pip install 'eopf-geozarr[cpm]'
  silently installs nothing on 3.12).
- _validate_s2_input: cover both branches with tests; also skip
  validation for zarr-v3-backed trees (the S2 input model reads Zarr v2
  only and raised TypeError on CPM 3.x products).

Verified on Python 3.13 with eopf 3.0.0: 42 tests pass, including new
regression tests for the destroy-then-fail ordering and compute
handling.

Assisted-by: ClaudeCode:claude-fable-5

* ci(cpm): run CPM integration tests in CI and add a CLI end-to-end test

- test-cpm job: installs the cpm extra (eopf pinned via uv.lock) on
  Python 3.13 and runs the CPM writer/routing tests on every push/PR,
  so they no longer silently skip in CI.
- test-cpm-latest job: weekly scheduled run against the newest published
  eopf release to catch upstream contract drift (writer registry,
  staging, CLI entry-point loading) independently of PR failures.
- new test: invoke the real `eopf convert-geozarr` console script via
  subprocess on a synthetic S2 store, covering entry-point discovery,
  click option wiring, and convert()'s reader dispatch through to the
  geozarr engine.

Assisted-by: ClaudeCode:claude-fable-5

* feat(cpm): route Sentinel-3 OLCI products to the optimized pipeline

PR #212 (Sentinel-3 OLCI GeoZarr export) merged to main after #238 was
branched, leaving select_pipeline unable to reach it: OLCI products fell
into the generic pipeline and failed on the missing 'groups' option.

Adds S3_OLCI_PRODUCT_TYPE_PREFIXES and looks_like_sentinel3_olci to
routing.py, structural fallback included -- oa01_radiance is a data
variable of the measurements node, not a child node, unlike the S2
r{N}m pyramid groups. Wires convert_olci_optimized into the writer with
a new s3_olci_optimized flag and an output_grid option, kept additive
to the existing write() signature (s2_optimized is untouched) since
that's the EOWriter entry point other CPM code calls into.

* ci: run CI on the cpm-geozarr branch (#254)

* ci: run CI on the cpm-geozarr branch

The workflow only triggered on main, so no work on the cpm-geozarr
feature branch ever got checks -- including #247, which is 20 commits
and +29105/-3763 across 23 files. The Samples Service runs this branch
in its September GeoZarr production track, so changes reached a
production pipeline unchecked.

Closes #253

* style: wrap the resolved_spatial_chunk conditional to satisfy ruff format

Introduced by the OLCI routing commit, which renamed the local
`pipeline` variable to `selected_pipeline` and pushed the line past the
limit. It went unnoticed because the branch had no CI -- which is what
this PR fixes.

* Cpm geozarr eodc changes (#247)

* init project setup

* adding and harmonising the chunking/sharding functionality across measuremetn and non-measurement groups - allows the contuniation of chunking behaviour from input eopf datatree instead if overwriting by force - scale_offset logic not touched

* addign nan masking for coarsening operation for multiscale calualtation (only for s2_optimising, later extend!) response to issue #246

* addign rechunking after readin to circumvent chunking at read-in (which is hardwired to eopf/store/mappers)

* adding L1C injected bands for mutliscale calculation -> with rechukning of added bands to spatial_chunk size

* readding prior launch script

* implementing changes recommended by claxn and d-v-b, mainly docs, cleanup, simplification

* adressing change requests, improving chunk workflow

* simplifing encoding retrieval for (all and) 1d arrays

* hotfix: adding stac metadata to root level was previously not inlcuded -> copying root metadata to new tree root now

* adding docstrings

* working on the tests, frustrated with test layout

* adding zarr v3 test samples

* change of the data_api tests

* removing test structure which is not adhering to s2 specs, also 'notebook' scripts referenced in tests didnt seem to exist

* changing the s2 zarr v3 structure and all associated tests. Also removing all emojis from output messages

* adapting to new zarr layout, changed function calls, and multiscale calculation changes

* removing emojis

* adding checks to add metadata to arrays if keep_scale_offset=False, modifying checks for s2 structure to align with tests, addig docs

* fixing enum to strenum to allow detection of S2type in simple == comparison

* applying changes to validate pre-commit pyright issues

* addressign requested chagnes for stAc metadata addition adn fill_value fallbacks, removing 'experimental_scale_offset_codec'functionality for encoding2

* small issue of not rechuking in tests

* removing addition of multiscales to stac asset metadata, other small commetns

* adding _FillValue to encoding to allow xarray decode_cf to correctly read zarr store in abscence of eopf-xarray reader, removed large commented section

---------

Co-authored-by: Emmanuel Mathot <[email protected]>

* adding single /measurement/refectance group to stac as requested during pr247 (#267)

* adding single /measurement/refectance group to stac as requested during pr247

* adressing comments for crs reuse and proj_attrs_for_crs use

* adding test to incorporate new functionalities and expanding comments

* fix(s2): inject missing bands for trees opened from a store

L1C/L2A detection read dt_input.name, which is None for trees opened from
a Zarr store, so no band was injected: L2A lost b08 at r20m-r720m and L1C
kept only b01/b09/b10 from r60m. Detect the level from stac_discovery
product:type (set by every CPM S2 mapping), with the name as fallback.

inject_missing_bands also updated the source band's attrs in place, so
native r10m bands sometimes carried the injected bands' provenance attrs,
depending on write timing. Copy the attrs instead.

Restore the golden snapshot test, run on the Zarr v2 inputs, and
regenerate the snapshots for this branch's output
(REGENERATE_SNAPSHOTS=1 rewrites them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Davis Vann Bennett <[email protected]>
Co-authored-by: shollendEODC <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
emmanuelmathot added a commit that referenced this pull request Oct 12, 2026
* feat(cpm): add CPM writer plugin registering the geozarr engine

Adds eopf_geozarr.cpm, an integration package for eopf-cpm (the eopf
package, available via the new 'cpm' extra on Python >= 3.13):

- GeoZarrWriter, registered in CPM's EOWriterRegistry as engine
  "geozarr" (by-name only: cpm_zarr already claims .zarr targets in
  by-target discovery). Routes products to the Sentinel-2 optimized
  pipeline or the generic pipeline based on the declared CPM
  product:type, with a structural fallback. Zarr v3 only; rejects
  unsupported modes, formats, and options loudly.
- an 'eopf convert-geozarr' subcommand exposed through CPM's eopf.cli
  entry-point group, delegating to eopf.store.convert with the engine
  preset, enabling one-pass SAFE -> GeoZarr conversion.
- routing helpers importable without eopf installed.

convert_s2/convert_s2_optimized now skip backing-store validation for
DataTrees with no zarr backend (e.g. built in memory by the CPM SAFE
reader) instead of crashing in get_zarr_group.

Verified against eopf 3.0.0 on Python 3.13: engine registration,
convert-geozarr discovery in the eopf CLI, and an end-to-end
write_datatree(engine="geozarr") producing the flat r10m..r720m
pyramid from an S2 example store.

Assisted-by: ClaudeCode:claude-fable-5

* fix(cpm): apply adversarial-review findings to the writer plugin

Fixes the confirmed findings from a multi-agent adversarial review:

- write() now runs every argument-derivable check (target type, remote
  scheme, w- existence, pipeline routing, required 'groups') BEFORE the
  destructive mode="w" removal of an existing store. Previously a pure
  options mistake (e.g. missing groups) deleted the user's previous
  product and then failed with nothing written.
- accept compute=True (rejecting only compute=False): CPM's staged-output
  path (staging.py) injects compute=True when a remote Dask client is
  active, so the strict kwargs rejection broke the exact --stage-output
  flow the writer's own error message recommends.
- validate_write_options now applies the target checks (type, remote
  scheme), so CPM's triggering preflight rejects unsupported targets at
  parse time instead of at write time.
- routing: only S02MSIL1C/S02MSIL2A products go to the S2-optimized
  pipeline; other MSI types (S02MSIL0_, S02MSIRAW) lack the reflectance
  pyramid structure and now route generic.
- eopf_geozarr.cpm raises a clear ImportError explaining the Python
  >= 3.13 floor when eopf is missing (pip install 'eopf-geozarr[cpm]'
  silently installs nothing on 3.12).
- _validate_s2_input: cover both branches with tests; also skip
  validation for zarr-v3-backed trees (the S2 input model reads Zarr v2
  only and raised TypeError on CPM 3.x products).

Verified on Python 3.13 with eopf 3.0.0: 42 tests pass, including new
regression tests for the destroy-then-fail ordering and compute
handling.

Assisted-by: ClaudeCode:claude-fable-5

* ci(cpm): run CPM integration tests in CI and add a CLI end-to-end test

- test-cpm job: installs the cpm extra (eopf pinned via uv.lock) on
  Python 3.13 and runs the CPM writer/routing tests on every push/PR,
  so they no longer silently skip in CI.
- test-cpm-latest job: weekly scheduled run against the newest published
  eopf release to catch upstream contract drift (writer registry,
  staging, CLI entry-point loading) independently of PR failures.
- new test: invoke the real `eopf convert-geozarr` console script via
  subprocess on a synthetic S2 store, covering entry-point discovery,
  click option wiring, and convert()'s reader dispatch through to the
  geozarr engine.

Assisted-by: ClaudeCode:claude-fable-5

* feat(cpm): route Sentinel-3 OLCI products to the optimized pipeline

PR #212 (Sentinel-3 OLCI GeoZarr export) merged to main after #238 was
branched, leaving select_pipeline unable to reach it: OLCI products fell
into the generic pipeline and failed on the missing 'groups' option.

Adds S3_OLCI_PRODUCT_TYPE_PREFIXES and looks_like_sentinel3_olci to
routing.py, structural fallback included -- oa01_radiance is a data
variable of the measurements node, not a child node, unlike the S2
r{N}m pyramid groups. Wires convert_olci_optimized into the writer with
a new s3_olci_optimized flag and an output_grid option, kept additive
to the existing write() signature (s2_optimized is untouched) since
that's the EOWriter entry point other CPM code calls into.

* ci: run CI on the cpm-geozarr branch (#254)

* ci: run CI on the cpm-geozarr branch

The workflow only triggered on main, so no work on the cpm-geozarr
feature branch ever got checks -- including #247, which is 20 commits
and +29105/-3763 across 23 files. The Samples Service runs this branch
in its September GeoZarr production track, so changes reached a
production pipeline unchecked.

Closes #253

* style: wrap the resolved_spatial_chunk conditional to satisfy ruff format

Introduced by the OLCI routing commit, which renamed the local
`pipeline` variable to `selected_pipeline` and pushed the line past the
limit. It went unnoticed because the branch had no CI -- which is what
this PR fixes.

* Cpm geozarr eodc changes (#247)

* init project setup

* adding and harmonising the chunking/sharding functionality across measuremetn and non-measurement groups - allows the contuniation of chunking behaviour from input eopf datatree instead if overwriting by force - scale_offset logic not touched

* addign nan masking for coarsening operation for multiscale calualtation (only for s2_optimising, later extend!) response to issue #246

* addign rechunking after readin to circumvent chunking at read-in (which is hardwired to eopf/store/mappers)

* adding L1C injected bands for mutliscale calculation -> with rechukning of added bands to spatial_chunk size

* readding prior launch script

* implementing changes recommended by claxn and d-v-b, mainly docs, cleanup, simplification

* adressing change requests, improving chunk workflow

* simplifing encoding retrieval for (all and) 1d arrays

* hotfix: adding stac metadata to root level was previously not inlcuded -> copying root metadata to new tree root now

* adding docstrings

* working on the tests, frustrated with test layout

* adding zarr v3 test samples

* change of the data_api tests

* removing test structure which is not adhering to s2 specs, also 'notebook' scripts referenced in tests didnt seem to exist

* changing the s2 zarr v3 structure and all associated tests. Also removing all emojis from output messages

* adapting to new zarr layout, changed function calls, and multiscale calculation changes

* removing emojis

* adding checks to add metadata to arrays if keep_scale_offset=False, modifying checks for s2 structure to align with tests, addig docs

* fixing enum to strenum to allow detection of S2type in simple == comparison

* applying changes to validate pre-commit pyright issues

* addressign requested chagnes for stAc metadata addition adn fill_value fallbacks, removing 'experimental_scale_offset_codec'functionality for encoding2

* small issue of not rechuking in tests

* removing addition of multiscales to stac asset metadata, other small commetns

* adding _FillValue to encoding to allow xarray decode_cf to correctly read zarr store in abscence of eopf-xarray reader, removed large commented section

---------

Co-authored-by: Emmanuel Mathot <[email protected]>

* adding single /measurement/refectance group to stac as requested during pr247 (#267)

* adding single /measurement/refectance group to stac as requested during pr247

* adressing comments for crs reuse and proj_attrs_for_crs use

* adding test to incorporate new functionalities and expanding comments

* first explorative introduction of the generic geozarr processing. Addition of generalised functionality to utils which are not used in other functions

* swiching consolodation methodology

* updating the processing of consolidated metadata and harmonizing group traversal across metadata-ingestion mdoes

* removal of functions from utils.py which are not essential for this PR

* adding new routing which keeps the original routing same (allows ->geozarr) but allows a path to  which is needed for rechunking

* small restructure of Error raising in writer, adjust test to catch adjusted error messge from writer

* updating docstrings and removing unneeded root consolidation function

* adding tests for generic converter logic, two tests are still failing due to utils.create_uniform_encoding, Requesting input to check if tests or encoding needs to be adapted (in sight of new scale/offset codec)

* configuring create_uniform_encoding(..) to NOT cast uint16 inputs to float32 per default if keep_scale_offset=False;

* adding S1_GRDH zarrv3 fixture instead of SLC as it can be contained as an atomic product due to SLC size

* removal of unneccassary fucntions from utils.py

* adding 'generic_rechunker' flags to eopf 'convert-geozarr' and 'validate_write_options', removing assert and introducing new pass-through error

* removing  from s2_multiscales.py and all associated changes

* removing  from s2_multiscales.py and all associated changes - forgot 2 scripts

* removing functions from s2 processing (optimization_summary, create_result_datatree, rechunk_dataset_for_encoding, get_chunking_for_encoding), keeping functions from s2 processing (simple_root_consolidation, stream_write_dataset->stream_write_s2dataset)

* reverting uv.lock changes to 39b06d6

* fixing tests-changes induced by changes in code, NOT fixing  as this needs to be discussed

* add shapely to dependency so tests dont fail as they call utils.write_store_root_geo_metadata() which uses shapely for bounds calculation from geojson geometry

* switch to new encoding logic, move of functions to new file to keep encoding atomic and separated, inclusion of new encoding logic and coordinate are not compressed anymore in create_uniform_encoding()

* adding scipy to dependencies for SLC conversion, also updating uv.lock for this change

* modifying tests to reflect switch to scale_offset_codec

* removal of

* removal of

* moving 'valid_min' and 'valid_max' to encoding_utils.py/_decode_packed() to only be removed during flaot decoding and keep for int dtypes

* remodelling of chunking functions, small additions

* adding to  for harmonization

* adressing requested cahgnes in utils.py/updated_root_consolidaiton to improve readabillity, small associated refactors in other files due to function name changes

* removing check which was only valid for enable_sharding=True

* removing raster:scale and raster:offset

* regenerating S2 fixtures

* simplifying create_uniform_encoding() for variable and coordinate chunk/sharding

* small updates to utils and tests to reflect changes

* adding suggestions by emmanuel, mostly smaller changes adn refactors

* adding tests to catch the changed routing flags for generic-rechunker

* updating logs to new standard

* removing shapely dependency

* fixing pass through of zarr groups with children AND data variables attached -> relevant especially for S3

* re-adding the multiscale induced bands to stac_discovery/assets (again), they got lost during the refactor, also adding/modifing tests to catch the new additin of metadta

---------

Co-authored-by: Davis Vann Bennett <[email protected]>
Co-authored-by: Emmanuel Mathot <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
claxn added a commit that referenced this pull request Oct 27, 2026
…classification layers (supersedes #313) (#318)

* feat(cpm): add CPM writer plugin registering the geozarr engine

Adds eopf_geozarr.cpm, an integration package for eopf-cpm (the eopf
package, available via the new 'cpm' extra on Python >= 3.13):

- GeoZarrWriter, registered in CPM's EOWriterRegistry as engine
  "geozarr" (by-name only: cpm_zarr already claims .zarr targets in
  by-target discovery). Routes products to the Sentinel-2 optimized
  pipeline or the generic pipeline based on the declared CPM
  product:type, with a structural fallback. Zarr v3 only; rejects
  unsupported modes, formats, and options loudly.
- an 'eopf convert-geozarr' subcommand exposed through CPM's eopf.cli
  entry-point group, delegating to eopf.store.convert with the engine
  preset, enabling one-pass SAFE -> GeoZarr conversion.
- routing helpers importable without eopf installed.

convert_s2/convert_s2_optimized now skip backing-store validation for
DataTrees with no zarr backend (e.g. built in memory by the CPM SAFE
reader) instead of crashing in get_zarr_group.

Verified against eopf 3.0.0 on Python 3.13: engine registration,
convert-geozarr discovery in the eopf CLI, and an end-to-end
write_datatree(engine="geozarr") producing the flat r10m..r720m
pyramid from an S2 example store.

Assisted-by: ClaudeCode:claude-fable-5

* fix(cpm): apply adversarial-review findings to the writer plugin

Fixes the confirmed findings from a multi-agent adversarial review:

- write() now runs every argument-derivable check (target type, remote
  scheme, w- existence, pipeline routing, required 'groups') BEFORE the
  destructive mode="w" removal of an existing store. Previously a pure
  options mistake (e.g. missing groups) deleted the user's previous
  product and then failed with nothing written.
- accept compute=True (rejecting only compute=False): CPM's staged-output
  path (staging.py) injects compute=True when a remote Dask client is
  active, so the strict kwargs rejection broke the exact --stage-output
  flow the writer's own error message recommends.
- validate_write_options now applies the target checks (type, remote
  scheme), so CPM's triggering preflight rejects unsupported targets at
  parse time instead of at write time.
- routing: only S02MSIL1C/S02MSIL2A products go to the S2-optimized
  pipeline; other MSI types (S02MSIL0_, S02MSIRAW) lack the reflectance
  pyramid structure and now route generic.
- eopf_geozarr.cpm raises a clear ImportError explaining the Python
  >= 3.13 floor when eopf is missing (pip install 'eopf-geozarr[cpm]'
  silently installs nothing on 3.12).
- _validate_s2_input: cover both branches with tests; also skip
  validation for zarr-v3-backed trees (the S2 input model reads Zarr v2
  only and raised TypeError on CPM 3.x products).

Verified on Python 3.13 with eopf 3.0.0: 42 tests pass, including new
regression tests for the destroy-then-fail ordering and compute
handling.

Assisted-by: ClaudeCode:claude-fable-5

* ci(cpm): run CPM integration tests in CI and add a CLI end-to-end test

- test-cpm job: installs the cpm extra (eopf pinned via uv.lock) on
  Python 3.13 and runs the CPM writer/routing tests on every push/PR,
  so they no longer silently skip in CI.
- test-cpm-latest job: weekly scheduled run against the newest published
  eopf release to catch upstream contract drift (writer registry,
  staging, CLI entry-point loading) independently of PR failures.
- new test: invoke the real `eopf convert-geozarr` console script via
  subprocess on a synthetic S2 store, covering entry-point discovery,
  click option wiring, and convert()'s reader dispatch through to the
  geozarr engine.

Assisted-by: ClaudeCode:claude-fable-5

* feat(cpm): route Sentinel-3 OLCI products to the optimized pipeline

PR #212 (Sentinel-3 OLCI GeoZarr export) merged to main after #238 was
branched, leaving select_pipeline unable to reach it: OLCI products fell
into the generic pipeline and failed on the missing 'groups' option.

Adds S3_OLCI_PRODUCT_TYPE_PREFIXES and looks_like_sentinel3_olci to
routing.py, structural fallback included -- oa01_radiance is a data
variable of the measurements node, not a child node, unlike the S2
r{N}m pyramid groups. Wires convert_olci_optimized into the writer with
a new s3_olci_optimized flag and an output_grid option, kept additive
to the existing write() signature (s2_optimized is untouched) since
that's the EOWriter entry point other CPM code calls into.

* ci: run CI on the cpm-geozarr branch (#254)

* ci: run CI on the cpm-geozarr branch

The workflow only triggered on main, so no work on the cpm-geozarr
feature branch ever got checks -- including #247, which is 20 commits
and +29105/-3763 across 23 files. The Samples Service runs this branch
in its September GeoZarr production track, so changes reached a
production pipeline unchecked.

Closes #253

* style: wrap the resolved_spatial_chunk conditional to satisfy ruff format

Introduced by the OLCI routing commit, which renamed the local
`pipeline` variable to `selected_pipeline` and pushed the line past the
limit. It went unnoticed because the branch had no CI -- which is what
this PR fixes.

* Cpm geozarr eodc changes (#247)

* init project setup

* adding and harmonising the chunking/sharding functionality across measuremetn and non-measurement groups - allows the contuniation of chunking behaviour from input eopf datatree instead if overwriting by force - scale_offset logic not touched

* addign nan masking for coarsening operation for multiscale calualtation (only for s2_optimising, later extend!) response to issue #246

* addign rechunking after readin to circumvent chunking at read-in (which is hardwired to eopf/store/mappers)

* adding L1C injected bands for mutliscale calculation -> with rechukning of added bands to spatial_chunk size

* readding prior launch script

* implementing changes recommended by claxn and d-v-b, mainly docs, cleanup, simplification

* adressing change requests, improving chunk workflow

* simplifing encoding retrieval for (all and) 1d arrays

* hotfix: adding stac metadata to root level was previously not inlcuded -> copying root metadata to new tree root now

* adding docstrings

* working on the tests, frustrated with test layout

* adding zarr v3 test samples

* change of the data_api tests

* removing test structure which is not adhering to s2 specs, also 'notebook' scripts referenced in tests didnt seem to exist

* changing the s2 zarr v3 structure and all associated tests. Also removing all emojis from output messages

* adapting to new zarr layout, changed function calls, and multiscale calculation changes

* removing emojis

* adding checks to add metadata to arrays if keep_scale_offset=False, modifying checks for s2 structure to align with tests, addig docs

* fixing enum to strenum to allow detection of S2type in simple == comparison

* applying changes to validate pre-commit pyright issues

* addressign requested chagnes for stAc metadata addition adn fill_value fallbacks, removing 'experimental_scale_offset_codec'functionality for encoding2

* small issue of not rechuking in tests

* removing addition of multiscales to stac asset metadata, other small commetns

* adding _FillValue to encoding to allow xarray decode_cf to correctly read zarr store in abscence of eopf-xarray reader, removed large commented section

---------

Co-authored-by: Emmanuel Mathot <[email protected]>

* adding single /measurement/refectance group to stac as requested during pr247 (#267)

* adding single /measurement/refectance group to stac as requested during pr247

* adressing comments for crs reuse and proj_attrs_for_crs use

* adding test to incorporate new functionalities and expanding comments

* first explorative introduction of the generic geozarr processing. Addition of generalised functionality to utils which are not used in other functions

* swiching consolodation methodology

* updating the processing of consolidated metadata and harmonizing group traversal across metadata-ingestion mdoes

* removal of functions from utils.py which are not essential for this PR

* adding new routing which keeps the original routing same (allows ->geozarr) but allows a path to  which is needed for rechunking

* small restructure of Error raising in writer, adjust test to catch adjusted error messge from writer

* updating docstrings and removing unneeded root consolidation function

* adding tests for generic converter logic, two tests are still failing due to utils.create_uniform_encoding, Requesting input to check if tests or encoding needs to be adapted (in sight of new scale/offset codec)

* configuring create_uniform_encoding(..) to NOT cast uint16 inputs to float32 per default if keep_scale_offset=False;

* adding S1_GRDH zarrv3 fixture instead of SLC as it can be contained as an atomic product due to SLC size

* removal of unneccassary fucntions from utils.py

* adding 'generic_rechunker' flags to eopf 'convert-geozarr' and 'validate_write_options', removing assert and introducing new pass-through error

* removing  from s2_multiscales.py and all associated changes

* removing  from s2_multiscales.py and all associated changes - forgot 2 scripts

* removing functions from s2 processing (optimization_summary, create_result_datatree, rechunk_dataset_for_encoding, get_chunking_for_encoding), keeping functions from s2 processing (simple_root_consolidation, stream_write_dataset->stream_write_s2dataset)

* reverting uv.lock changes to 39b06d6

* fixing tests-changes induced by changes in code, NOT fixing  as this needs to be discussed

* add shapely to dependency so tests dont fail as they call utils.write_store_root_geo_metadata() which uses shapely for bounds calculation from geojson geometry

* switch to new encoding logic, move of functions to new file to keep encoding atomic and separated, inclusion of new encoding logic and coordinate are not compressed anymore in create_uniform_encoding()

* adding scipy to dependencies for SLC conversion, also updating uv.lock for this change

* modifying tests to reflect switch to scale_offset_codec

* removal of

* removal of

* moving 'valid_min' and 'valid_max' to encoding_utils.py/_decode_packed() to only be removed during flaot decoding and keep for int dtypes

* remodelling of chunking functions, small additions

* adding to  for harmonization

* adressing requested cahgnes in utils.py/updated_root_consolidaiton to improve readabillity, small associated refactors in other files due to function name changes

* removing check which was only valid for enable_sharding=True

* removing raster:scale and raster:offset

* regenerating S2 fixtures

* simplifying create_uniform_encoding() for variable and coordinate chunk/sharding

* small updates to utils and tests to reflect changes

* not working, erroneous commit for s2 cld/snw/scl-multiscales and measureemtn multiscales harmonisation

* not working, erroneous commit for s2 cld/snw/scl-multiscales and measureemtn multiscales harmonisation

* fixing bugs in MS calculation, tests/test_s2_multiscale.py::test_create_multiscale_from_datatree_snapshot, tests/test_s2_multiscale.py::test_create_multiscale_from_datatree_behavior failing duie to missing geozarr attrs before MS calculation

* first addition of cld&snw and l1c&l2a_classification multiscales

* adding ms bands addtion to stac_discovery similar to 6f9476a

* small refactrs of s2_multiscale.py,  restructure of determine_variable_type()

* applying changes to tests and derived changes&bugfixes&simplifications to mutliscale ingestion

* adding consolidatin for MS groups

* rm shapely, fell through in merge

* improving consolidation to only check the datasets that were actually added

* deriving transform for lvl 0 mutliscale from _spatial_level_attrs() which is more stable than accessign spatial:xx directly

* refactor: aux multiscales are now optional; removed redundant output_path; import fixes; unique variable type function; downsample factor error handling

* test: consolidated metadata for aux multiscales and preserving native bands

* fix: implemented nearest downsampling of classification

* feat: added new MultiscaleSpec class as a new interface to generic_multiscale(...)

* added trim comment

* fix: eopf-geozarr version added for all products and based on git and installed package version

* fix: SCL '_FillValue' addition to encoding

---------

Co-authored-by: Davis Vann Bennett <[email protected]>
Co-authored-by: Emmanuel Mathot <[email protected]>
Co-authored-by: shollendEODC <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: claudio.schein-navacchi <[email protected]>
Co-authored-by: Loïc Houpert <[email protected]>
claxn added a commit that referenced this pull request Oct 29, 2026
… (supersedes #314) (#328)

* feat(cpm): add CPM writer plugin registering the geozarr engine

Adds eopf_geozarr.cpm, an integration package for eopf-cpm (the eopf
package, available via the new 'cpm' extra on Python >= 3.13):

- GeoZarrWriter, registered in CPM's EOWriterRegistry as engine
  "geozarr" (by-name only: cpm_zarr already claims .zarr targets in
  by-target discovery). Routes products to the Sentinel-2 optimized
  pipeline or the generic pipeline based on the declared CPM
  product:type, with a structural fallback. Zarr v3 only; rejects
  unsupported modes, formats, and options loudly.
- an 'eopf convert-geozarr' subcommand exposed through CPM's eopf.cli
  entry-point group, delegating to eopf.store.convert with the engine
  preset, enabling one-pass SAFE -> GeoZarr conversion.
- routing helpers importable without eopf installed.

convert_s2/convert_s2_optimized now skip backing-store validation for
DataTrees with no zarr backend (e.g. built in memory by the CPM SAFE
reader) instead of crashing in get_zarr_group.

Verified against eopf 3.0.0 on Python 3.13: engine registration,
convert-geozarr discovery in the eopf CLI, and an end-to-end
write_datatree(engine="geozarr") producing the flat r10m..r720m
pyramid from an S2 example store.

Assisted-by: ClaudeCode:claude-fable-5

* fix(cpm): apply adversarial-review findings to the writer plugin

Fixes the confirmed findings from a multi-agent adversarial review:

- write() now runs every argument-derivable check (target type, remote
  scheme, w- existence, pipeline routing, required 'groups') BEFORE the
  destructive mode="w" removal of an existing store. Previously a pure
  options mistake (e.g. missing groups) deleted the user's previous
  product and then failed with nothing written.
- accept compute=True (rejecting only compute=False): CPM's staged-output
  path (staging.py) injects compute=True when a remote Dask client is
  active, so the strict kwargs rejection broke the exact --stage-output
  flow the writer's own error message recommends.
- validate_write_options now applies the target checks (type, remote
  scheme), so CPM's triggering preflight rejects unsupported targets at
  parse time instead of at write time.
- routing: only S02MSIL1C/S02MSIL2A products go to the S2-optimized
  pipeline; other MSI types (S02MSIL0_, S02MSIRAW) lack the reflectance
  pyramid structure and now route generic.
- eopf_geozarr.cpm raises a clear ImportError explaining the Python
  >= 3.13 floor when eopf is missing (pip install 'eopf-geozarr[cpm]'
  silently installs nothing on 3.12).
- _validate_s2_input: cover both branches with tests; also skip
  validation for zarr-v3-backed trees (the S2 input model reads Zarr v2
  only and raised TypeError on CPM 3.x products).

Verified on Python 3.13 with eopf 3.0.0: 42 tests pass, including new
regression tests for the destroy-then-fail ordering and compute
handling.

Assisted-by: ClaudeCode:claude-fable-5

* ci(cpm): run CPM integration tests in CI and add a CLI end-to-end test

- test-cpm job: installs the cpm extra (eopf pinned via uv.lock) on
  Python 3.13 and runs the CPM writer/routing tests on every push/PR,
  so they no longer silently skip in CI.
- test-cpm-latest job: weekly scheduled run against the newest published
  eopf release to catch upstream contract drift (writer registry,
  staging, CLI entry-point loading) independently of PR failures.
- new test: invoke the real `eopf convert-geozarr` console script via
  subprocess on a synthetic S2 store, covering entry-point discovery,
  click option wiring, and convert()'s reader dispatch through to the
  geozarr engine.

Assisted-by: ClaudeCode:claude-fable-5

* feat(cpm): route Sentinel-3 OLCI products to the optimized pipeline

PR #212 (Sentinel-3 OLCI GeoZarr export) merged to main after #238 was
branched, leaving select_pipeline unable to reach it: OLCI products fell
into the generic pipeline and failed on the missing 'groups' option.

Adds S3_OLCI_PRODUCT_TYPE_PREFIXES and looks_like_sentinel3_olci to
routing.py, structural fallback included -- oa01_radiance is a data
variable of the measurements node, not a child node, unlike the S2
r{N}m pyramid groups. Wires convert_olci_optimized into the writer with
a new s3_olci_optimized flag and an output_grid option, kept additive
to the existing write() signature (s2_optimized is untouched) since
that's the EOWriter entry point other CPM code calls into.

* ci: run CI on the cpm-geozarr branch (#254)

* ci: run CI on the cpm-geozarr branch

The workflow only triggered on main, so no work on the cpm-geozarr
feature branch ever got checks -- including #247, which is 20 commits
and +29105/-3763 across 23 files. The Samples Service runs this branch
in its September GeoZarr production track, so changes reached a
production pipeline unchecked.

Closes #253

* style: wrap the resolved_spatial_chunk conditional to satisfy ruff format

Introduced by the OLCI routing commit, which renamed the local
`pipeline` variable to `selected_pipeline` and pushed the line past the
limit. It went unnoticed because the branch had no CI -- which is what
this PR fixes.

* Cpm geozarr eodc changes (#247)

* init project setup

* adding and harmonising the chunking/sharding functionality across measuremetn and non-measurement groups - allows the contuniation of chunking behaviour from input eopf datatree instead if overwriting by force - scale_offset logic not touched

* addign nan masking for coarsening operation for multiscale calualtation (only for s2_optimising, later extend!) response to issue #246

* addign rechunking after readin to circumvent chunking at read-in (which is hardwired to eopf/store/mappers)

* adding L1C injected bands for mutliscale calculation -> with rechukning of added bands to spatial_chunk size

* readding prior launch script

* implementing changes recommended by claxn and d-v-b, mainly docs, cleanup, simplification

* adressing change requests, improving chunk workflow

* simplifing encoding retrieval for (all and) 1d arrays

* hotfix: adding stac metadata to root level was previously not inlcuded -> copying root metadata to new tree root now

* adding docstrings

* working on the tests, frustrated with test layout

* adding zarr v3 test samples

* change of the data_api tests

* removing test structure which is not adhering to s2 specs, also 'notebook' scripts referenced in tests didnt seem to exist

* changing the s2 zarr v3 structure and all associated tests. Also removing all emojis from output messages

* adapting to new zarr layout, changed function calls, and multiscale calculation changes

* removing emojis

* adding checks to add metadata to arrays if keep_scale_offset=False, modifying checks for s2 structure to align with tests, addig docs

* fixing enum to strenum to allow detection of S2type in simple == comparison

* applying changes to validate pre-commit pyright issues

* addressign requested chagnes for stAc metadata addition adn fill_value fallbacks, removing 'experimental_scale_offset_codec'functionality for encoding2

* small issue of not rechuking in tests

* removing addition of multiscales to stac asset metadata, other small commetns

* adding _FillValue to encoding to allow xarray decode_cf to correctly read zarr store in abscence of eopf-xarray reader, removed large commented section

---------

Co-authored-by: Emmanuel Mathot <[email protected]>

* adding single /measurement/refectance group to stac as requested during pr247 (#267)

* adding single /measurement/refectance group to stac as requested during pr247

* adressing comments for crs reuse and proj_attrs_for_crs use

* adding test to incorporate new functionalities and expanding comments

* first explorative introduction of the generic geozarr processing. Addition of generalised functionality to utils which are not used in other functions

* swiching consolodation methodology

* updating the processing of consolidated metadata and harmonizing group traversal across metadata-ingestion mdoes

* removal of functions from utils.py which are not essential for this PR

* adding new routing which keeps the original routing same (allows ->geozarr) but allows a path to  which is needed for rechunking

* small restructure of Error raising in writer, adjust test to catch adjusted error messge from writer

* updating docstrings and removing unneeded root consolidation function

* adding tests for generic converter logic, two tests are still failing due to utils.create_uniform_encoding, Requesting input to check if tests or encoding needs to be adapted (in sight of new scale/offset codec)

* configuring create_uniform_encoding(..) to NOT cast uint16 inputs to float32 per default if keep_scale_offset=False;

* adding S1_GRDH zarrv3 fixture instead of SLC as it can be contained as an atomic product due to SLC size

* removal of unneccassary fucntions from utils.py

* adding 'generic_rechunker' flags to eopf 'convert-geozarr' and 'validate_write_options', removing assert and introducing new pass-through error

* removing  from s2_multiscales.py and all associated changes

* removing  from s2_multiscales.py and all associated changes - forgot 2 scripts

* removing functions from s2 processing (optimization_summary, create_result_datatree, rechunk_dataset_for_encoding, get_chunking_for_encoding), keeping functions from s2 processing (simple_root_consolidation, stream_write_dataset->stream_write_s2dataset)

* reverting uv.lock changes to 39b06d6

* fixing tests-changes induced by changes in code, NOT fixing  as this needs to be discussed

* add shapely to dependency so tests dont fail as they call utils.write_store_root_geo_metadata() which uses shapely for bounds calculation from geojson geometry

* switch to new encoding logic, move of functions to new file to keep encoding atomic and separated, inclusion of new encoding logic and coordinate are not compressed anymore in create_uniform_encoding()

* adding scipy to dependencies for SLC conversion, also updating uv.lock for this change

* modifying tests to reflect switch to scale_offset_codec

* removal of

* removal of

* moving 'valid_min' and 'valid_max' to encoding_utils.py/_decode_packed() to only be removed during flaot decoding and keep for int dtypes

* remodelling of chunking functions, small additions

* adding to  for harmonization

* adressing requested cahgnes in utils.py/updated_root_consolidaiton to improve readabillity, small associated refactors in other files due to function name changes

* removing check which was only valid for enable_sharding=True

* removing raster:scale and raster:offset

* regenerating S2 fixtures

* simplifying create_uniform_encoding() for variable and coordinate chunk/sharding

* small updates to utils and tests to reflect changes

* not working, erroneous commit for s2 cld/snw/scl-multiscales and measureemtn multiscales harmonisation

* not working, erroneous commit for s2 cld/snw/scl-multiscales and measureemtn multiscales harmonisation

* fixing bugs in MS calculation, tests/test_s2_multiscale.py::test_create_multiscale_from_datatree_snapshot, tests/test_s2_multiscale.py::test_create_multiscale_from_datatree_behavior failing duie to missing geozarr attrs before MS calculation

* first addition of cld&snw and l1c&l2a_classification multiscales

* adding ms bands addtion to stac_discovery similar to 6f9476a

* small refactrs of s2_multiscale.py,  restructure of determine_variable_type()

* applying changes to tests and derived changes&bugfixes&simplifications to mutliscale ingestion

* adding consolidatin for MS groups

* stash and test updated_root_consolidation

* working status of root consolidation for all products, with slight issues remaining in S3

* switching globvally to updated uniform root_consodlidation

* massive claude assisted refactor and reprocessing of the Sentinel-2 functions -> induced changes in other places as well

* re-outsourcing the S2-stac adding  logic to own function as its independent

* adding hotfix implemted in 655e5e6 for retrieval of spatial attrs from spatial:transform or array.rio accessor

* removal of commented code, improving docstrings, sorting helper functions in utils

* addressing non-consolidation of slstr in potential generic conversion

* refactor: updated all tests and addressed all review comments

* build: updated msgpack version to 1.2.4

* docs: function call updates and removed max_entries

* fix: pass compression_level through the chain

* test: added test_parent_measurements_group_is_consolidated

* refactor!: changed geo utility function return behaviour to guarded behaviour with deterministic return types

---------

Co-authored-by: Davis Vann Bennett <[email protected]>
Co-authored-by: Emmanuel Mathot <[email protected]>
Co-authored-by: shollendEODC <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: claudio.schein-navacchi <[email protected]>
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.

6 participants