Skip to content

Cpm geozarr eodc changes - #247

Merged
emmanuelmathot merged 26 commits into
EOPF-Explorer:cpm-geozarrfrom
shollendEODC:cpm-geozarr-eodc
Sep 11, 2026
Merged

emmanuelmathot merged 26 commits into
EOPF-Explorer:cpm-geozarrfrom
shollendEODC:cpm-geozarr-eodc

Conversation

@shollendEODC

Copy link
Copy Markdown
Collaborator

Adressing issues #243, #244, #245 and #246 to fit eodcs demands for geozarr ingestion into the eopf cpm framework

…suremetn and non-measurement groups - allows the contuniation of chunking behaviour from input eopf datatree instead if overwriting by force - scale_offset logic not touched
…on (only for s2_optimising, later extend!) response to issue EOPF-Explorer#246
Comment thread pyproject.toml Resolved
@shollendEODC

Copy link
Copy Markdown
Collaborator Author

The introduced changes to the scripts induce errors in the tests as function parameters were changed (and possibly some other stuff as well, which I still have to validate). A modification/check of the tests should be discussed at a later time, if the set issues and their proposed solutions are conceptually validated.


# add attribute value acknoleding the own resampling
trgt_attrs = band_src.attrs
trgt_attrs.update({'_processing_mode': 'Own method. This band was added by ds.coarsening(...) to allow a full colelction of bands at the spatial resolution of 60m to allow the uniform calcualtion of multiscales. Do not use these bands for computation.',

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.

is there documentation somewhere for this attribute? is it part of a published specification?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

no this was implemented test wise to reflect an internal discussion at eodc at an eopf meeting. During the L1C multiscale calculation not all bands are present at 60m, as they are only featured at their native resolution. As we need all bands for the multiscales they have to be provided at a uniform resolution of 60m before being resampled to 120m, ...

Thus, we need to do the resampling during processing (for which the inject_bands functionality is already there! nice!). But we want to differentiate between native resolution bands and our resampled ones, which is why we decided to add additional metadata in the attributes. These are jsut there to inform users.. Naming and content can be adapted

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@shollendEODC It is good to be explicit, but I would suggest to keep it more concise and not overwhelm users with too much metadata. Users should decide themselves how they want to handle it. Often you have some compact metadata attributes and then a detailed explanation in a product user manual (here it does not exist as far as I know, but maybe they need to create a new one anyway for explaining the GeoZarr structure for each product). I would suggest to remove it, since you have anyway the '_resampling_mode'.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I removed '_processing_mode' but left the other parameters

@claxn claxn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Many thanks for quickly addressing all the open issues here in this PR, @shollendEODC ! 👏 I left some minor comments for discussion.


# add attribute value acknoleding the own resampling
trgt_attrs = band_src.attrs
trgt_attrs.update({'_processing_mode': 'Own method. This band was added by ds.coarsening(...) to allow a full colelction of bands at the spatial resolution of 60m to allow the uniform calcualtion of multiscales. Do not use these bands for computation.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@shollendEODC It is good to be explicit, but I would suggest to keep it more concise and not overwhelm users with too much metadata. Users should decide themselves how they want to handle it. Often you have some compact metadata attributes and then a detailed explanation in a product user manual (here it does not exist as far as I know, but maybe they need to create a new one anyway for explaining the GeoZarr structure for each product). I would suggest to remove it, since you have anyway the '_resampling_mode'.

Comment on lines +287 to +293
if filename and 'L2A' in filename:
s2_type = 'L2A'
elif filename and 'L1C' in filename:
s2_type = 'L1C'
else:
s2_type = None
log.info("not found a matching s2_type {}: is not matching L2A or L1C", s2_type=s2_type)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would be nice to put this into an Enum, since then you are less prone to typos and it is easier to compare sth to it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

put it in an enum (for now in s2_multiscale.py) but would move to own utils module once the other modes are implemented to have a unified enum somewhere.
its also likely a similar designation exists within cpm, didnt check if it is accessible but for now its alrigt

Comment on lines +539 to +541
# var_data.chunks is a tuple of per-dimension chunk-size tuples
# (e.g. dask/xarray chunks). Take the first chunk size along each
# dim as the zarr chunk size for that dim.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Outdated description? The maximum is taken, not the first (which will be always the first)?

Comment on lines +545 to +550
if var_data.ndim >= 2:
# assign full chuknsize for multidiomensional metadata chukns
chunks = var_data.shape
else:
# if no chunks are set
chunks = (min(spatial_chunk, var_data.shape[0]),)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What happens if a 2D metadata array has a size larger than the spatial_chunk? Because the rule here seems to be that if the chunking is not set and it is not 1D, there is one chunk for the whole dataset. Why doing it only for the 1D case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

logic to calculate zarr encoding chukns moved to its own function get_chunking_for_encoding to differentiate between xarray chunksizes (a tuple of all chunks: ((1024, 806), (1024, 806)) and zarr chunks (tuple of maximal chunk (1024, 1024).

# --- Shards: cover the whole array, one shard per array -----------
if enable_sharding:
# select next largest mutliple of chunksize to fit full array
shard_dims = tuple(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

shards would be a better name to be aligned with chunks.

else:
var_encoding["shards"] = None

### SHOLLEND: didnt touch anything from here onwards, jsut cp from their encoding functions

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please remove your personal remarks/comments to have less noise in the code.

Comment thread .gitignore Outdated
main.py
.vscode/ownlaunch.json
mappers/*
overwrite_mapper.py No newline at end of file

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try to put your personal things either to an already existing ignored folder or at least into one single folder. Otherwise .gitignore gets spammed if every contributor has special folders. And .vscode should be ignored at a higher level, maybe you could uncomment line 192?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

should be done

Comment thread pyproject.toml Resolved

@claxn claxn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for addressing my comments @shollendEODC ! Just some minor remarks left, where I am not sure if the implementation is intended.

Comment on lines +535 to +536
max_chunksize = max([max(c) for c in var_data.chunks])
encoding_chunks = (max_chunksize, max_chunksize)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

But why not taking the max for each dimension?

"""
requires a prior rechunking of the dataset by calling _rechunk_ds() to rechunk non-metadata arrays to spatial_chunk
get a tuple of maximal chunksize for the dataarray
-> (1024, 1024) for spatial arrays

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(spatial_chunk, spatial_chunk)

encoding_chunks = (max_chunksize, max_chunksize)
return encoding_chunks
else:
# create a single shard for whole array

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would raise a warning here, because this should never happen if both metadata and measurement arrays are rechunked before?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

implemented an Error, as it would lead to an error later on during zarr writing anyway

@claxn claxn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the great work, @shollendEODC - I approved now. Just one minor comment I left.

Comment on lines +533 to +535
# consider the occurance of 1dim arrays, provide the encoding chunk ndim times
if var_data.ndim == 1:
encoding_chunks = (max_chunksizes[0], )

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a bit confused here why there is this clause - shouldn't we get a 1 element list from the call before: max_chunksizes = [...], which we just can convert to a tuple? Could you double-check please?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

yeah, it needs to be a tuple for encoding[chunks] = (chuknsize, ) but a tuple cast works nicer and removes the if clause, many thanks

@shollendEODC

Copy link
Copy Markdown
Collaborator Author

Added new fix to adress issue #250
deployment testing underway, possible changes incoming

@shollendEODC

Copy link
Copy Markdown
Collaborator Author

modified the test suite to represent the implemented changes:

  • switching to zarr v3 in sample data
  • function calls
  • removing tests that were out of data

I also removed all emojis from the code

emmanuelmathot added a commit that referenced this pull request Sep 2, 2026
* 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.
Picks up the CI-on-this-branch fix (EOPF-Explorer#254) so this PR gets its first
check run, plus the accompanying ruff-format fix.

@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.

This branch (cpm-geozarr) had no CI configured until today. Every commit on this PR, including this one, was authored without a single check running. We've just turned it on (#254), and I've merged that fix into this branch so it actually takes effect here.

@shollendEODC, could you take a pass at these first? uv run ruff check --fix && uv run ruff format locally should clear most of it. Once lint and the test matrix are both green we can move to substantive review, but we'd like this PR to actually pass its own checks before that. Thx

@codecov-commenter

codecov-commenter commented Sep 2, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 62.74510% with 57 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/eopf_geozarr/s2_optimization/s2_converter.py 44.11% 19 Missing ⚠️
src/eopf_geozarr/cli.py 5.26% 18 Missing ⚠️
src/eopf_geozarr/s2_optimization/s2_multiscale.py 80.00% 15 Missing ⚠️
src/eopf_geozarr/conversion/geozarr.py 63.63% 4 Missing ⚠️
src/eopf_geozarr/conversion/utils.py 91.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@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.

Thanks @shollendEODC. This is a substantial piece of work, and the suite is green. I don't want to hold it up further, so most of what follows is recorded rather than requested.

Only Two things I'd like before merge, both cheap:

  • s2_multiscale.py:257: drop the _derived_from / _factor / _resampling_mode attributes.
  • s2_multiscale.py:622: just a yes/no on whether --experimental-scale-offset-codec works

I created follow-ups opened so they don't get lost, none blocking this PR but will need a resolution.

Comment thread .gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
.vscode/

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.

tin .vscode, there is a useful launch.json that we ahre to make E2E integration tests

# fill_value = var_data.fill_value

#
fill_value = var_data.attrs.get("fill_value")

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.

attrs["fill_value"] resolves here - the fixtures this PR adds carry fill_value in the array attributes of measurements/reflectance/* (e.g. attrs: {add_offset, dtype, fill_value: 0, long_name, scale_factor, ...}), as do real CPM 3.0.0 products. So the masking does what it intends for the variables this branch covers.

Two things worth considering anyway.

1. The lookup is more brittle than it looks. attrs["fill_value"] is a CPM-specific attribute - not CF _FillValue, and not the zarr-level fill_value. Where a zarr v3 fill value actually surfaces once xarray opens the array:

dtype mask_and_scale zarr.json .encoding["_FillValue"]
uint16 False 0 None
uint16 True 0 None
float32 False NaN None
float32 True NaN nan

For the uint16 reflectance case, neither the CF encoding key nor the zarr-level fill value is reachable through xarray - the CPM attribute is the only thing that works. Fine today, but if CPM stops writing that attribute, or sanitize_array_attrs strips it, this branch silently reverts to unmasked means, with no error and no failing test. A fallback chain (attrs["fill_value"] -> encoding["_FillValue"] -> the zarr array's own fill_value) removes that failure mode cheaply. Prior art on the same confusion: cogeotiff/rio-tiler#914 (comment)

2. This branch has no test. _coarsen_variable has dedicated tests for the classification and quality_mask branches, but not for the reflectance masking path - which is the actual fix for #246. Worth adding one: an array with fill-valued border pixels, asserting the coarsened mean ignores them and the fill value is restored afterwards. That would also catch point 1 if the attribute ever disappears.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

will add a fallback to encoding["_FillValue"]

# add attribute value acknoleding the own resampling
trgt_attrs = band_src.attrs
trgt_attrs.update(
{"_derived_from": f"r{native_res}m", "_factor": factor, "_resampling_mode": "mean"}

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.

These three attributes should be removed. The multiscales convention already declares all of them, and duplicating them here creates a second source of truth that can drift.

Mapping them one for one onto ScaleLevel in data_api/geozarr/multiscales/zcm.py:

stamped here already in the convention
_derived_from ScaleLevel.derived_from
_factor ScaleLevel.transform.scale
_resampling_mode ScaleLevel.resampling_method (also Multiscales.resampling_method)

The first two are already written for every level by add_multiscales_metadata_to_parent() - see the scale_level_data["derived_from"] and scale_level_data["transform"] assignments around line 1100. So _derived_from and _factor are pure duplication today.

resampling_method is the one genuinely missing piece: the model supports it but we never populate it. If the intent is to record that overviews are mean-resampled, the right fix is to set ScaleLevel.resampling_method there, not to stamp a private attribute on each array.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

these are purposely filled in with a '_' before them to highlight that they are not real pyramid overviews but ingested bands. Open for discussion if this nomenclature makes sense or not, but I think its important to highlight it in some way

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

They are kept for now, mutliscale metadata is not written for these injected bands, so some sort of metadata should be added to differentiate between the native resolution and injected bands.

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.

Multiscale metdata are written via the multiscale zarr convention at the root of the pyramid group. This is an unecessary duplication. See https://github.com/shollendEODC/data-model/blob/b629cd284d5ed911b59cd88144b1e3600eefe7be/src/eopf_geozarr/s2_optimization/s2_multiscale.py#L957

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Alright, I agree. Will be removed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

as per our discussion, these attributes are removed. Some other form of attribute/annotation will be added but this will be shifted to a new PR

Comment on lines +621 to +622
if experimental_scale_offset_codec and not keep_scale_offset:
# THIS didnt work when previously tested

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.

For context, this branch is copy-pasted from create_measurements_encoding, where the same code exists without this note - so it isn't a regression introduced here.

But --experimental-scale-offset-codec is a real opt-in flag on both the CLI and the CPM writer, so a shipped feature currently carries an in-code note saying it doesn't work. Could you clarify which it is? If it works now, drop the line. If it doesn't, better to raise an issue and have the flag fail loudly than leave the note in the source.

Suggested change
if experimental_scale_offset_codec and not keep_scale_offset:
# THIS didnt work when previously tested
if experimental_scale_offset_codec and not keep_scale_offset:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

yeah i will test it again and remove the code if it fails

Comment on lines +345 to +348
if dt_input and dt_input.attrs:
utils.write_store_root_stac_metadata(
output_path, root_attrs=cast("dict[str, dict[str, Any]]", dt_input.attrs)
)

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.

Not blocking this PR, but flagging it here so it is on record.

This copies the input product's root attributes through verbatim, which in practice means stac_discovery lands in the output unchanged. That object describes the input layout. EODC's 31 August product (built from this branch) shows the effect: the output root carries stac_discovery with 83 assets, every href pointing at input paths like /conditions/geometry/sun_angles, and not one of them references the pyramid levels this conversion just created (r120m, r360m, r720m).

So the store ships a STAC object that no longer matches the store it lives in, and a consumer has no way to tell it is stale. Arguably worse than not copying it at all.

Two smaller points on the mechanics:

  • The function is a generic root.attrs.update(...) with a STAC-specific name, and it re-opens the store in r+ immediately after write_store_root_bbox() closed it, duplicating the get_storage_options boilerplate. Between this, write_store_root_geo_metadata and write_store_root_bbox (itself already a thin wrapper), there are now three root-writing entry points.
  • Because the update() is unconditional and runs after the minispec writer, any key collision silently wins. I checked: current CPM inputs carry only other_metadata, processing_history and stac_discovery at the root, so nothing collides today. But we are actively asking CPM to start declaring zarr_conventions at the root (https://gitlab.eopf.copernicus.eu/cpm/eopf-cpm/-/issues/1189), which would turn this into a silent overwrite of the computed spatial:bbox / proj:code footprint. Worth making the merge explicit about which side wins.

The broader question of what unspecified CPM metadata we should carry through at all is out of scope here and tracked in #257.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

will include:

  • the omitted pyramid levels in the stac metadata
  • key checks for update()

will not consolidate root writing scripts now -> can be done in general refactoring for inclusion of all modes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pyramid levels will NOT be added to the STAC metadata to avoid its cluttering and prevent users from accessing them. Functionality remains but is not used.

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.

sure this is understandable. Nevertheless, the root pyramid asset "reflectance" should be referenced. Multiscale consumers expect to discover the multiscale attributes in the reference group.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

To clarify: Do you mean the exposure in the root/zarr.json under

"attributes"/"stac_discovery"/"assets"/ ->
        "b01_120m": {
          "href": "/measurements/reflectance/r120m/b01",
          "title": "b01_120m"
        },

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.

Not that, no - those are per-band, per-level entries (b01_120m, b02_120m, ...), which is exactly the clutter you want to avoid. I agree with avoiding them.

What I mean is a single asset pointing at the pyramid group, shaped like a real STAC asset. The reference is what our own catalogue already publishes for this product - see the reflectance asset in this item:

"reflectance": {
  "href": ".../measurements/reflectance",
  "type": "application/vnd.zarr; version=3; profile=multiscales",
  "title": "Surface Reflectance",
  "roles": ["data", "reflectance"],
  "gsd": 10,
  "proj:code": "EPSG:32626",
  "proj:shape": [10980, 10980],
  "bands": [ ... ],
  "cube:dimensions": { ... },
  "cube:variables": { ... }
}

The profile=multiscales media-type parameter is the important part: it tells a consumer this asset is a pyramid root, so it opens that one node, reads multiscales, and resolves r10m ... r720m from the convention. No per-level assets needed - that is the point of the convention.

The gap today: the input carries 25 reflectance assets, all pointing at leaf arrays (/measurements/reflectance/r20m/b03), and none at /measurements/reflectance. A consumer has no entry point to the pyramid at all.

Suggested minimum below - href, type, title, roles, gsd, plus proj:code / proj:shape, all available at this call site. I have deliberately left out bands, cube:dimensions and cube:variables: they are buildable from BAND_INFO, but that needs eo:common_name, descriptions and a nm-to-micrometre conversion which do not exist in the repo yet, so better as a follow-up than scope creep here.

Suggested change
if dt_input and dt_input.attrs:
utils.write_store_root_stac_metadata(
output_path, root_attrs=cast("dict[str, dict[str, Any]]", dt_input.attrs)
)
if dt_input and dt_input.attrs:
root_attrs = cast("dict[str, dict[str, Any]]", dt_input.attrs)
# Reference the pyramid root group, not the individual levels. That
# group carries the `multiscales` attribute, and the
# `profile=multiscales` media-type parameter tells a consumer to look
# for it there and resolve the levels from the convention itself.
stac = root_attrs.get("stac_discovery")
if stac is not None:
reflectance_asset: dict[str, Any] = {
"href": "/measurements/reflectance",
"type": "application/vnd.zarr; version=3; profile=multiscales",
"title": "Surface Reflectance",
"roles": ["data", "reflectance"],
"gsd": 10,
}
crs = initialize_crs_from_dataset(dt_input)
if crs is not None and crs.to_epsg() is not None:
reflectance_asset["proj:code"] = f"EPSG:{crs.to_epsg()}"
base = datasets.get("/measurements/reflectance/r10m")
if isinstance(base, xr.Dataset):
reflectance_asset["proj:shape"] = [base.sizes["y"], base.sizes["x"]]
stac.setdefault("assets", {})["reflectance"] = reflectance_asset
utils.write_store_root_stac_metadata(
output_path,
root_attrs=root_attrs,
)

This also lets add_multiscale_pyramids_to_stac_metadata() be deleted rather than left commented out.

One note on hrefs: every asset in this object uses root-absolute paths (/measurements/...), so I kept that for consistency. The STAC Zarr Convention v0.1 requires hrefs relative to the group carrying the object (measurements/...), but converting one asset alone would make the object inconsistent - that belongs with the stac:item migration in https://gitlab.eopf.copernicus.eu/cpm/eopf-cpm/-/issues/1189.

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.

Not that, those are per-band, per-level entries (b01_120m, b02_120m, …), which is exactly the clutter you want to avoid. I agree with avoiding them.

What I mean is a single asset pointing at the pyramid group, shaped like a real STAC asset. The reference is what our own catalogue already publishes for this product. See the reflectance asset in this item:

"reflectance": {
  "href": ".../measurements/reflectance",
  "type": "application/vnd.zarr; version=3; profile=multiscales",
  "title": "Surface Reflectance",
  "roles": ["data", "reflectance"],
  "gsd": 10,
  "proj:code": "EPSG:32626",
  "proj:shape": [10980, 10980],
  "bands": [ ... ],
  "cube:dimensions": { ... },
  "cube:variables": { ... }
}

The profile=multiscales media-type parameter is the important part: it tells a consumer this asset is a pyramid root, so it opens that one node, reads multiscales, and discovers r10m … r720m from the convention. No per-level assets needed, that is the point of the convention.

The gap today: the input carries 25 reflectance assets, all pointing at leaf arrays (/measurements/reflectance/r20m/b03), and none at /measurements/reflectance. A consumer has no entry point to the pyramid at all.

Suggested minimum below (href, type, title, roles, plus proj:code / proj:shape / gsd), which are all available at this call site. I have deliberately left out bands, cube:dimensions and cube:variables: they are buildable from BAND_INFO, but that needs eo:common_name, descriptions and a nm→µm conversion which does not exist in the repo yet, so it is better as a follow-up than as scope creep here.

Suggested change
if dt_input and dt_input.attrs:
utils.write_store_root_stac_metadata(
output_path, root_attrs=cast("dict[str, dict[str, Any]]", dt_input.attrs)
)
if dt_input and dt_input.attrs:
root_attrs = cast("dict[str, dict[str, Any]]", dt_input.attrs)
# Reference the pyramid root group, not the individual levels. That
# group carries the `multiscales` attribute, and the
# `profile=multiscales` media-type parameter tells a consumer to look
# for it there and resolve the levels from the convention itself.
stac = root_attrs.get("stac_discovery")
if stac is not None:
reflectance_asset: dict[str, Any] = {
"href": "/measurements/reflectance",
"type": "application/vnd.zarr; version=3; profile=multiscales",
"title": "Surface Reflectance",
"roles": ["data", "reflectance"],
}
crs = initialize_crs_from_dataset(dt_input)
if crs is not None and crs.to_epsg() is not None:
reflectance_asset["proj:code"] = f"EPSG:{crs.to_epsg()}"
base = datasets.get("/measurements/reflectance/r10m")
if isinstance(base, xr.Dataset):
reflectance_asset["gsd"] = 10
reflectance_asset["proj:shape"] = [base.sizes["y"], base.sizes["x"]]
stac.setdefault("assets", {})["reflectance"] = reflectance_asset
utils.write_store_root_stac_metadata(
output_path,
root_attrs=root_attrs,
)

This also lets add_multiscale_pyramids_to_stac_metadata() be deleted rather than left commented out.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

okay, I will take a look and let you know

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

As discussed, this PR will be merged without this feature, but will be implemented in a new PR

return isinstance(model, Sentinel2Root)


def _is_sentinel2_dataset_v3(group: zarr.Group) -> bool:

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.

Fine as a temporary measure - the docstring is honest about why it exists, and hand-rolling this beats guessing at a V3 model under time pressure.

Worth recording though: this is a schema expressed as imperative code. Sentinel2Root is V2-only, and pyz/v3.py already provides GroupSpec / ArraySpec over pydantic_zarr.v3 but nothing in src/ imports it yet - so the V3 scaffolding exists, the models just haven't been ported.

Tracked in #258 to fold this back into the pydantic model rather than leave detection split between a model and a function.

…e fallbacks, removing 'experimental_scale_offset_codec'functionality for encoding2
@emmanuelmathot

Copy link
Copy Markdown
Contributor

@shollendEODC Ready for another review?

@shollendEODC

Copy link
Copy Markdown
Collaborator Author

We had a discussion today and I will review the changes irt to its results. I will notify you here once its done.

@shollendEODC

Copy link
Copy Markdown
Collaborator Author

removed the stac addition of multiscales. See new comments above and small annotations in the code. Ready for review

…read zarr store in abscence of eopf-xarray reader, removed large commented section
@shollendEODC

Copy link
Copy Markdown
Collaborator Author

despite the already large size of this PR and the alignment to have smaller PRs I added this last commit as its logic lives withing this PRs framework: _FillValue gets added (basically duplicated) to the encoding of dataarrays for xarray to load the data correctly with decode_cf.

From my side its ready to be merged

@emmanuelmathot
emmanuelmathot merged commit 5bdbf59 into EOPF-Explorer:cpm-geozarr Sep 11, 2026
5 checks passed
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.

5 participants