Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: EOPF-Explorer/data-model
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.10.2
Choose a base ref
...
head repository: EOPF-Explorer/data-model
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v0.11.0
Choose a head ref
  • 8 commits
  • 87 files changed
  • 6 contributors

Commits on Aug 10, 2026

  1. fix(docs): update documentation to reflect GeoZarr conventions (#230)

    * fix(docs): update references from GeoZarr-spec 0.4 to GeoZarr format across documentation
    
    * fix(docs): update evolution note to reference GeoZarr 0.4 draft
    emmanuelmathot authored Aug 10, 2026
    Configuration menu
    Copy the full SHA
    0015598 View commit details
    Browse the repository at this point in the history

Commits on Aug 11, 2026

  1. feat: Sentinel-3 OLCI GeoZarr export (#212)

    * fix the types
    
    * refactor: build all conventions via zarr_cm.create_many
    
    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]>
    
    * chore: gitignore local scratch files and .claude/
    
    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]>
    
    * Revert "chore: gitignore local scratch files and .claude/"
    
    This reverts commit 4a5b6e4.
    
    * fix type checking errors
    
    * wire up mypy correctly in pre-commit
    
    * build: migrate type checker mypy -> pyright; upgrade zarr-cm
    
    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]>
    
    * refactor: replace unverified casts with runtime checks
    
    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]>
    
    * chore: use latest version of zarr-cm
    
    * docs: design for Sentinel-3 OLCI L1 EFR -> GeoZarr export
    
    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]>
    
    * docs: implementation plan for Sentinel-3 OLCI export
    
    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]>
    
    * chore: regenerate uv.lock for released zarr-cm 0.4.1
    
    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]>
    
    * feat(s3-olci): add OLCI band mapping
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * feat(s3-olci): add Sentinel3OlciRoot data-api model
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s3-olci): remove Any from data-api model (use precise opaque-group type)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * feat(s3-olci): add swath /2 decimation for overviews
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s3-olci): move xarray import to TYPE_CHECKING; strengthen decimation test
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * feat(s3-olci): add OLCI product detection
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * feat(s3-olci): swath spatial-convention attrs (no transform)
    
    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]>
    
    * feat(s3-olci): add convert_olci_optimized entry point
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s3-olci): correct overview min_dimension guard; document returned DataTree omits overviews
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * feat(s3-olci): CLI auto-detect + convert-s3-olci-optimized command
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * test(s3-olci): real OLCI product fixture + round-trip detection
    
    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]>
    
    * test(s3-olci): golden-file snapshot of converted OLCI structure
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s3-olci): strip inherited v2 encoding before v3 write; fix fixture time_stamp rows
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * docs(s3-olci): document Sentinel-3 OLCI export support
    
    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]>
    
    * fix(s3-olci): fill-aware block-avg overviews, multiscales CMO, orphans 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]>
    
    * fix(s3-olci): restore faithful 'columns' dim name in OLCI fixture (revert tie_columns); regen snapshot
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s3-olci): open with mask_and_scale=False; sanitize stale attrs; regen 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]>
    
    * fix: revert shared sanitize_array_attrs to pre-OLCI semantics; add OLCI-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]>
    
    * fix(s3-olci): align coordinate decimation with coarsen trim on odd dimensions
    
    reduce_swath used slice(None, None, factor) (i.e. [::factor]) for coordinate
    arrays, which yields ceil(N/factor) elements when N is odd.  coarsen with
    boundary="trim" yields floor(N/factor).  For real OLCI products with 4865
    columns (odd), this caused a 1-element mismatch between radiance and coordinate
    arrays in every overview group, making xr.open_dataset raise:
      ValueError: conflicting sizes for dimension 'columns':
        length 2433 on 'altitude' and length 2432 on 'oa01_radiance'
    
    Fix: pre-compute dim_trim = (N // factor) * factor for each swath dimension
    and change coordinate isel slices to slice(0, dim_trim, factor) so both paths
    produce exactly floor(N/factor) elements for any N.
    
    Add 4 unit tests (rows=7, cols=5) and 1 integration test (rows=10, cols=9)
    that confirm consistent shapes across radiance and coordinate arrays after
    reduction, and that overview groups open without conflicting-sizes errors.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * docs(s3-olci): add Sentinel-3 OLCI GeoZarr demonstration notebook
    
    Notebook walks through opening an EOPF OLCI L1 EFR product (real EODC sample,
    with an offline fallback to the bundled fixture), detecting it, converting to a
    multiscale GeoZarr store, and visualizing the pyramid by splitting one field of
    view into four quadrants each rendered from a different overview level.
    
    Adds a `notebooks` dependency group (jupyter, matplotlib, nbformat). Executed
    end-to-end against the real product; outputs (incl. the quadrant figure) are
    saved in the notebook.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s3-olci): open raw input in convert auto-detect path; guard overview rounding
    
    The OLCI auto-detect branch of convert_command passed the CF-decoded
    DataTree (mask_and_scale=True default) to convert_olci_optimized, whose
    documented precondition is raw uint16 radiance with scale_factor /
    _FillValue in attrs. Fill-aware masking in reduce_swath then saw no
    _FillValue, and np.round quantized decoded physical radiance in every
    overview level. Re-open the input with mask_and_scale=False in that
    branch, matching convert_s3_olci_optimized_command, and round in
    reduce_swath only when packing back into an integer dtype.
    
    Also include nested ancillary groups (e.g. conditions/geometry) in the
    DataTree returned by convert_olci_optimized — they were silently absent
    because only top-level groups with direct arrays were assembled — and
    add a routing test asserting the OLCI converter is selected and
    receives packed (non-CF-decoded) input.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): forward --min-dimension in auto-detect path; avoid fill collisions
    
    The OLCI auto-detect branch of convert_command silently used the default
    min_dimension=256 instead of the user's --min-dimension; forward the
    flag so both entry points agree, and assert the forwarding in the
    routing test.
    
    In reduce_swath, a valid block whose mean rounds to the fill sentinel
    was recoded as fill in the overview; nudge such values one step back
    into the valid domain on the side the unrounded mean came from.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): add --no-s3-olci-optimized opt-out; clarify divergent sanitizers
    
    Mirror the S2 escape hatch for the OLCI auto-detect path: a new
    --no-s3-olci-optimized flag forces the generic conversion path, with a
    routing test asserting the opt-out. The CF-decoded detection tree is
    now closed before re-opening the input raw, and the convert subcommand
    description documents the OLCI auto-routing.
    
    Rename the OLCI attr sanitizer to _sanitize_olci_array_attrs_keep_fill
    so its divergence from the shared sanitize_array_attrs (which strips
    _FillValue) is explicit at call sites, and cross-reference the two
    helpers in both docstrings so edits to one aren't assumed to apply to
    the other.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): clamp fill-collision nudge to dtype range; document strict detection
    
    The fill-collision nudge in reduce_swath could wrap for a sentinel at a
    dtype bound (e.g. fill 0 on an unsigned dtype nudging to -1 → dtype
    max). Clamp the nudge candidates so both sides resolve to the in-range
    neighbour when the sentinel sits at a bound.
    
    Document that OLCI auto-detection is intentionally conservative (strict
    structural validation against Sentinel3OlciRoot): near-miss products
    fall through to the generic path, and convert-s3-olci-optimized is the
    explicit bypass. Noted in both detection helpers and the convert
    subcommand description.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * chore: remove superpowers planning docs from version control
    
    Working plan/design documents under docs/superpowers/ are local
    scaffolding, not project documentation; untrack them and gitignore the
    directory. The files remain on disk (and in history).
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): warn on unwired converter options; NaN-aware fill masking
    
    convert_olci_optimized accepts enable_sharding, spatial_chunk,
    compression_level and keep_scale_offset but does not yet apply them;
    it now logs a warning when a non-default value is passed, the dedicated
    subcommand's help marks those flags as not yet applied, and the convert
    auto-detect path stops forwarding them (it would otherwise warn on
    every run since convert's defaults differ).
    
    In reduce_swath, a float source with a NaN _FillValue was masked via an
    equality comparison that is a no-op for NaN; mask explicitly with
    notnull() in that case instead of relying on coarsen's implicit NaN
    skipping.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): defensive return-tree assembly; test fill-collision nudge
    
    DataTree.from_dict enforces parent/child dimension consistency, so an
    ancillary group reusing a swath dim name at a different size could make
    the return-tree assembly fail even though the store was written
    correctly. Catch that case and fall back to a measurements-only view
    with a warning instead of masking a successful write.
    
    Add direct tests for the reduce_swath fill-collision branch: a valid
    block averaging exactly to an interior fill sentinel is nudged one step
    into the valid domain, and an all-fill block keeps the sentinel. (Bound
    sentinels such as 0/65535 are unreachable by a mean of one-sided valid
    values; the dtype clamp remains defensive.)
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * perf(s3-olci): materialize coarsened mean once; never raise from detection
    
    The radiance branch of reduce_swath evaluated the dask-backed
    coarsen+mask graph twice (once via isnull().values for the valid mask,
    again via fill_da.values) — roughly doubling the heaviest compute in
    the converter for real products. Compute the averaged array once up
    front.
    
    is_sentinel3_olci_dataset only caught ValueError, but from_zarr /
    model_dump can raise other types on malformed stores; broaden the
    guard so the classifier never raises for direct callers (the CLI
    wrapper already swallowed everything).
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): clear error for missing swath dims; finer return-tree fallback
    
    Validate up front that the measurements group has the rows/columns
    swath dimensions the whole OLCI pipeline assumes, raising a clear
    ValueError instead of a bare KeyError deep in the converter.
    
    When return-tree assembly hits a DataTree dimension-consistency
    conflict, re-add groups greedily and exclude only the offending ones
    instead of collapsing to a measurements-only view.
    
    On the convert auto-detect path, warn when a user's --spatial-chunk or
    --enable-sharding is dropped (those options are not yet applied by the
    OLCI converter), instead of ignoring them silently.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): explicit skipna in fill-aware mean; depth-sort tree fallback
    
    Pass skipna=True explicitly to the coarsen mean so the fill-aware
    promise (one fill pixel must not contaminate its block) does not hinge
    on a version-dependent default, and add a test asserting a partially
    filled block averages only its valid pixels.
    
    Sort the return-tree greedy fallback by path depth so parents are
    considered before their nested children, preventing an implicit empty
    parent from shadowing the real data-bearing one.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): truncate store on re-run; handle transposed swath dims
    
    Truncate any pre-existing store before the first write so re-running
    convert_olci_optimized against the same output path cannot leave stale
    overview or ancillary groups from a prior run (the per-group writes
    were mode="w" scoped to measurements plus mode="a" appends). Tested by
    converting twice with different min_dimension values.
    
    Make swath detection in reduce_swath order-insensitive: a band stored
    transposed as (columns, rows) is normalized to (rows, columns) and
    fill-aware block-averaged instead of silently falling through to
    stride decimation.
    
    Document the origin-vs-center offset between decimated overview
    coordinates and block-averaged radiance in the reduce_swath docstring.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs(s3-olci): plot each pyramid level on its own native grid in notebook
    
    The quadrant visualization nearest-neighbour upsampled every overview
    back onto the native pixel grid, so nothing in the figure was actually
    shown at its stored resolution. Replace it with a 2x2 panel figure
    where each panel pcolormeshes one level's radiance against that level's
    own decimated 2-D latitude/longitude — same swath coverage per panel,
    visibly larger native cells per level, no resampling. Levels are picked
    dynamically from the finest affordable (<~600k quads) to the coarsest.
    
    Also make the offline fixture fallback independent of the kernel's
    working directory (nbconvert starts in docs/notebooks/), and silence
    consolidated-metadata warnings when opening levels.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * feat(s3-olci): overview lat/lon are geodesic block centroids, not decimated
    
    Overview coordinates were stride-decimated, labeling each block-averaged
    radiance cell with its block's top-left corner pixel — up to half a block
    (~77 km at r512) away from the centroid of the pixels actually averaged,
    and never converging to the swath center under repeated reduction.
    
    reduce_swath now reduces the 2-D latitude/longitude pair (matched by CF
    standard_name, falling back to variable name) jointly: pixel positions are
    mapped to unit vectors on the sphere, block-averaged with fill awareness
    (a pixel fill in either array is excluded from both), and the mean
    direction is converted back to degrees. Each overview cell is therefore
    geolocated at the geodesic centroid of its source block, stable across the
    antimeridian and at the poles, and collapsing the whole swath to one cell
    yields its geodesic center.
    
    Because the converter runs on un-decoded data and OLCI packs geolocation
    as int32 microdegrees (scale_factor=1e-06), values are CF-unpacked before
    the trigonometry and re-packed after, preserving dtype and attrs; feeding
    raw packed integers into the trig collapses every coordinate to ~(0, 0)
    (caught on the real EODC product, covered by regression test).
    
    Other non-band swath variables (e.g. altitude) keep stride decimation.
    Notebook re-executed against the real product; prose updated to describe
    the centroid coordinates.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): raw return tree; block-average altitude in overviews
    
    Address both findings from roborev job 456:
    
    The DataTree returned by convert_olci_optimized was reopened with default
    CF decoding, handing callers float radiance while the store (and the
    converter's own input) holds packed uint16. Reopen with
    mask_and_scale=False and document the raw contract.
    
    Overview altitude was stride-decimated, sitting ~half a block from the
    geodesic-centroid lat/lon locating the same cell. Route altitude (matched
    by standard_name, falling back to name) through the same fill-aware block
    mean as radiance: it is linear, so the mean commutes with CF packing and
    is exact on raw values.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * perf(cli): check --no-s3-olci-optimized before running OLCI detection
    
    Short-circuit the opt-out flag first so an explicit
    --no-s3-olci-optimized skips the structural detection (a full model
    validation of the store) instead of running it and discarding the
    result. Roborev job 457, finding 1.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs(s3-olci): draw swath quadrants from different pyramid levels over a map
    
    Replace the four-panel figure with a single cartopy PlateCarree map:
    the swath is split into four geographic quadrants and each quadrant is
    drawn from a different pyramid level via xarray's curvilinear
    plot.pcolormesh against that level's own centroid lat/lon, over
    coastlines, borders, and labeled gridlines with a shared robust color
    scale. The seamless tiling across quadrant boundaries makes the
    geodesic-centroid coordinate scheme visible: levels agree on where
    things are even though every quadrant is a different stored grid.
    
    Adds cartopy to the notebooks dependency group. Uses explicit
    set_xticks/set_yticks with cartopy's Longitude/LatitudeFormatter for
    axis labels: Gridliner auto-labels (draw_labels=True) blank the whole
    GeoAxes when a colorbar axes is present under cartopy 0.25 with
    matplotlib 3.11.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * chore: re-lock uv.lock after merging main
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    * fix(s3-olci): open OLCI sources with native-chunk-aligned reads
    
    Port #220 to the OLCI converter paths: both OLCI
    source opens (convert routing re-open and convert-s3-olci-optimized) used
    chunks="auto", which lets dask sub-split stored zarr chunks into multiple
    read tasks — duplicate egress/decompression and cache races under
    EOPF-Explorer/data-pipeline#339. open_source_datatree gains a
    mask_and_scale passthrough since the OLCI converter requires raw packed
    input.
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    * fix(s3-olci): write native resolution to measurements/r0 so the store opens with xr.open_datatree
    
    The converter previously wrote the full-resolution arrays directly in
    measurements/ with overview groups (r2, r4, ...) nested beneath, declared
    as "asset": "." in the multiscales layout. Overview children then
    inherited the parent's 2-D latitude/longitude coordinates over the shared
    rows/columns dims at mismatched sizes, so xr.open_datatree — and any
    generic GeoZarr reader, e.g. titiler — rejected the store with an
    alignment error (reported on PR #212 from a real S3A scene).
    
    Native-resolution arrays now live in measurements/r0 as a named sibling
    of the overview groups (the Sentinel-2 pattern); measurements/ itself
    carries only the multiscales/spatial convention metadata, with the layout
    referencing "r0" instead of ".". This makes the hand-built DataTree
    workaround (which silently omitted overview children) unnecessary:
    convert_olci_optimized now returns xr.open_datatree(output) directly,
    and a new acceptance test pins that the whole exported store opens
    cleanly. Snapshot regenerated; docs and demo notebook updated and
    re-executed against the real EODC product.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs: design for OLCI reprojection to regular grid with CRS
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs: implementation plan for OLCI reprojection and cross-product CRS contract
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * feat(s3-olci): add swath-to-grid reprojection via rasterio geolocation arrays
    
    Assisted-by: ClaudeCode:claude-sonnet-5
    
    * fix(s3-olci): guarantee _FillValue on all warped variables; test dims error
    
    Float variables with no pre-existing _FillValue warped to NaN nodata but
    were skipping the _FillValue attr, breaking the "every warped variable
    records its fill" contract. Always write _FillValue on the warped path
    (NaN included for float dtypes) and clarify the docstring to scope the
    guarantee to warped variables only.
    
    Also add the missing test for the 2-D latitude/longitude shape-mismatch
    ValueError, per the one-test-per-error-case convention.
    
    Assisted-by: ClaudeCode:claude-sonnet-5
    
    * refactor(s3-olci): parameterize reduce/decimate over spatial dims for gridded pyramids
    
    Add keyword-only dims parameter to decimate_swath and reduce_swath,
    defaulting to SWATH_DIMS=("rows", "columns") to preserve existing behavior.
    Enables Task 4 to create pyramids on reprojected grids with dims ("y", "x").
    
    Assisted-by: ClaudeCode:claude-haiku-4-5
    
    * feat(s3-olci): add gridded spatial-convention attrs with affine transform
    
    Assisted-by: ClaudeCode:claude-haiku-4-5
    
    * feat(s3-olci): reproject measurements to regular EPSG:4326 grid with per-level CRS
    
    Warp the swath once after sanitize via reproject_olci, build the r0/r2/...
    pyramid on the grid with reduce_swath(dims=(y, x)), and write per-level
    spatial/geo-proj convention attrs plus multiscales on the parent. Replace
    the degenerate collinear lat/lon in the synthetic test fixture with a real
    axis-aligned grid and restructure size assertions relative to the observed
    warped r0. swath_spatial_attrs is deleted (replaced by grid_spatial_attrs).
    
    The golden-snapshot test intentionally fails until the next commit seeds
    real geolocation into the zero-filled fixture.
    
    Assisted-by: ClaudeCode:claude-sonnet-5
    
    * feat(s3-olci): --target-crs flag, regenerated snapshot, gridded-layout docs and notebook
    
    Seed real geolocation into the zero-filled golden fixture inside the
    snapshot test (the warp rejects degenerate extents), regenerate the
    snapshot for the gridded layout, add --target-crs to the CLI subcommand,
    update README/converter.md for the reprojected output, and re-execute the
    demo notebook against the real EODC product. Mark the implementation-plan
    doc's code blocks test-skip so pytest-examples does not execute them.
    
    Assisted-by: ClaudeCode:claude-sonnet-5
    
    * test: cross-product contract - every converted product is a regular grid with CRS
    
    Adds tests/test_geozarr_output_contract.py, a parametrized test over all
    three converters (OLCI, S1, S2) asserting the hard requirement that every
    multiscale level is a regular grid with a declared, pyproj-round-trippable
    CRS. OLCI passes with no marks. S1/S2 whole-store open_datatree is xfailed
    (legacy asset='.' nested layout). S1/S2 per-level CRS detection is also
    xfailed: the generic converter (geozarr.py) only stamps zarr-cm proj:/
    spatial: convention attrs on the base-resolution group, not on overview
    sub-groups, so rioxarray can't auto-detect CRS there on a plain
    xr.open_dataset read.
    
    Also extracts the Sentinel-2 fixture body into an importable
    build_sample_sentinel2_datatree() so the contract test can build the
    same sample tree without needing the pytest fixture machinery.
    
    Assisted-by: ClaudeCode:claude-sonnet-5
    
    * docs(s3-olci): fix stale notebook intro to describe reprojected grid
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(test): open contract levels with decode_coords=all, drop bogus S1/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
    
    * test(s3-olci): cover projected --target-crs end-to-end (EPSG:3857)
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs(s3-olci): re-execute notebook against merged converter
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * feat(s3-olci): restore geodesic-centroid overview geolocation and swath 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
    
    * feat(s3-olci): output_grid parameter — native instrument grid by default, 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
    
    * test(s3-olci): cover --output-grid CLI flag end-to-end in both modes
    
    Assisted-by: ClaudeCode:claude-haiku-4-5
    
    * test(s3-olci): contract + golden snapshots cover native and regridded 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
    
    * docs(s3-olci): document native-default output_grid; notebook demonstrates 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
    
    * docs(s3-olci): fix remaining README intro claiming unconditional warp
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs(s3-olci): scope proj: convention claim to CRS output mode
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): reject min_dimension < 1 before touching the output store
    
    _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
    
    * fix(s3-olci): unpack CF-packed geolocation before warping
    
    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
    
    * fix(s3-olci): warp transposed (columns, rows) bands instead of dropping them
    
    Found by adversarial review; mirrors the native pipeline's normalization.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(s3-olci): edge-align regridded overview georeferencing with the r0 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
    
    * docs(s3-olci): re-execute notebook against corrected regridded georeferencing
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: Loïc Houpert <[email protected]>
    3 people authored Aug 11, 2026
    Configuration menu
    Copy the full SHA
    0bd880d View commit details
    Browse the repository at this point in the history
  2. feat: GeoZarr minispec validator, compliant converter output, and doc…

    … fixes - #7 (#237)
    
    * feat(validation): validate stores against the GeoZarr minispec
    
    Replace the legacy per-variable CF attribute checks in `eopf-geozarr
    validate` with a declarative validator built on the pydantic-zarr store
    models. The validator snapshots the hierarchy with GroupSpec.from_zarr and
    checks every normative minispec requirement:
    
    - store root: zarr_conventions declaration, spatial:bbox, exactly one CRS key
    - multiscale datasets: all three conventions declared, spatial:bbox /
      spatial:dimensions / CRS present, layout entries carry spatial:shape and
      spatial:transform (and transform when derived_from is set), layout assets
      resolve to members, and every level qualifies as a GeoZarr dataset
    - any node using proj:/spatial: convention keys: keys validate and the
      convention is declared (group declarations are inherited by direct child
      arrays)
    - Zarr V3 only; stores with no convention-bearing nodes are reported
    
    The store models in data_api/geozarr/store.py are tightened to carry these
    requirements (root zarr_conventions, multiscale group conventions/CRS/
    dimensions, transform-with-derived_from). The CLI exits non-zero on a
    non-compliant store and reports each violation with its node path.
    
    Refs #233, #163, #88
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(conversion): make converter output minispec-compliant
    
    Fixes surfaced by the new minispec validator, reproducing issue #233:
    
    - create_geozarr_dataset now writes the store-root spatial footprint
      (zarr_conventions + spatial:bbox + proj:code in EPSG:4326); the root
      writer moved to conversion.utils and is shared with convert_s2_optimized,
      which previously wrote the bbox but not the conventions declaration
    - calculate_overview_levels always emits level 0, so groups smaller than
      min_dimension no longer lose their multiscales/proj/spatial metadata to a
      swallowed empty-layout validation error
    - overview level groups (r2, r4, ...) now carry their own spatial + proj
      convention attributes, as the minispec requires of every layout member
    - multiscales layout entries always carry spatial:transform (previously
      skipped when the transform was degenerate)
    - sanitize_array_attrs strips geo-proj convention keys (proj:code,
      proj:wkt2, proj:projjson) that source products carry on arrays without
      the required declaration; legacy external keys like proj:epsg are kept
    
    Integration tests assert both converter paths produce validator-clean
    stores; the recorded example structures are regenerated accordingly.
    
    Fixes #233
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * docs: fix S2 group paths and describe conventions compliance
    
    The Python API samples pointed at /measurements/r10m, but Sentinel-2
    products nest reflectance under /measurements/reflectance/r10m; following
    the samples verbatim silently skipped every group and produced a store
    with no GeoZarr metadata at all (issue #233). All samples now use the
    real paths.
    
    Also replaces the remaining GeoZarr-0.4-era requirement lists
    (_ARRAY_DIMENSIONS, mandatory CF names) with the conventions the
    converter actually writes, documents the S2 auto-routing to the optimized
    layout, and describes the new minispec validator.
    
    Refs #233
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix: address adversarial-review findings in validator and converters
    
    Findings from a multi-agent adversarial review of the minispec branch, each
    independently reproduced before fixing:
    
    Validator (data_api/geozarr):
    - malformed zarr_conventions values (wrong type, non-mapping entries) are now
      diagnosed as validation issues instead of raising TypeError
    - convention declarations are inherited by direct child arrays only; child
      groups no longer inherit them (matches the minispec inheritance rule)
    - empty multiscales.layout is rejected (spec: layout must not be empty)
    - the store root accepts redundant CRS encodings; the invented at-most-one
      rule (inherited from the pre-existing model) is relaxed to the spec's
      at-least-one
    - new structural Members/DataArray checks: no scalar arrays (0-D CF
      grid-mapping containers tolerated), unique string dimension_names, and a
      matching 1-D coordinate array for every data-variable dimension
    
    Converters:
    - write_store_root_geo_metadata reprojects with transform_bounds (edge
      densification) instead of two corner points, resolves the CRS from
      proj:code/proj:wkt2/proj:projjson instead of assuming degrees when the code
      is absent, skips-with-warning groups whose CRS cannot be resolved, falls
      back to the full longitude range for antimeridian-crossing footprints, and
      derives S3 storage options from the output path; the create_geozarr_dataset
      call site is guarded so a late failure cannot abort a finished conversion
    - the CRS of a source variable is captured (proj:epsg, then proj:code/wkt2)
      before sanitize_array_attrs strips the convention keys
    - overview datasets carry over 1-D non-spatial coordinates (e.g. time) so
      every data-variable dimension keeps its coordinate array
    - the optimized path drops scalar (0-D) coordinates such as source band
      labels when writing dataset groups
    
    Tests: error cases for each fix, the level-0 overview fix is pinned directly
    (mutation-detected), the CLI validate non-compliant exit path is covered, and
    the recorded example structures are regenerated.
    
    Assisted-by: ClaudeCode:claude-fable-5
    
    * fix(deps): require aiohttp >= 3.14.3
    
    pip-audit blocks CI on aiohttp 3.14.1 (PYSEC-2026-3545, PYSEC-2026-3546,
    PYSEC-2026-3547, all fixed by 3.14.3). Raise the floor and update the lock;
    the lock also picks up the 0.10.2 package version from the release commit,
    which had not been re-locked.
    
    Assisted-by: ClaudeCode:claude-fable-5
    d-v-b authored Aug 11, 2026
    Configuration menu
    Copy the full SHA
    da604dd View commit details
    Browse the repository at this point in the history

Commits on Sep 10, 2026

  1. deps: bump Python dependencies and pinned GitHub Actions (#264)

    * deps: bump all Python dependencies to latest
    
    Refresh the whole `uv.lock` with `uv lock --upgrade`, superseding the open
    Dependabot PRs #260, #259, #255, #252, #241, #234, #228 and #227. pyright
    1.1.414 narrows the `MISSING` sentinel more precisely than 1.1.410 did, so two
    call sites gain the same `reportArgumentType` ignore the neighbouring line
    already carries.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01JEbpfA8CPcBQJzMpVJBViF
    
    * ci(deps): bump pinned GitHub Actions
    
    Supersedes Dependabot PR #225. Same SHAs it proposed, with the two stale
    `# v6` comments on `actions/setup-python` corrected to `# v7.0.0`.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01JEbpfA8CPcBQJzMpVJBViF
    
    ---------
    
    Co-authored-by: Claude Opus 5 <[email protected]>
    lhoupert and claude authored Sep 10, 2026
    Configuration menu
    Copy the full SHA
    86202d7 View commit details
    Browse the repository at this point in the history

Commits on Sep 18, 2026

  1. feat(s1): Sentinel-1 GRD RTC GeoZarr ingestion, data model and STAC b…

    …uilders (#216)
    
    * feat: Phase 0 — S1 GRD RTC design, S1Tiling integration, and real-data GeoTIFF→GeoZarr V3 prototype
    
    - Implementation plan with Zarr V3 store structure, conventions, and phased roadmap
    - S1Tiling Docker instructions with EODAG 4.0.0 patch (5 breaking changes fixed)
    - GeoTIFF inspector script for S1Tiling output validation
    - Synthetic prototype validating sharding, resize/append, overview generation
    - Real-data conversion: 3 acquisitions (31TCH) → 1.8 GB Zarr store with 6 overview levels
    - Key findings: chunk divisibility constraint, datetime format, coverage variability
    
    * phase 0: integrate all lessons learned into plan and docs
    
    - Update §2 array metadata example to actual zarr-python 3.1.1 on-disk format
      (sharding codec in codecs array, chunk_grid holds shard shape, inner chunks 366)
    - Update conditions group: per-orbit naming (gamma_area_{orbit}), mark LIA and
      incidence_angle as aspirational (not confirmed as S1Tiling outputs)
    - Add Operational Findings subsection: EODAG 4.0.0 patch, DEM tile extent,
      GAMMA_AREA filename format
    - Fix docker instructions: CALIBRATION tag is GammaNaughtRTC (not gamma_naught_rtc)
    - Mark Phase 0 checklist fully complete
    - Update §9 API corrections to reflect plan is now corrected
    
    * phase 1: S1 RTC Pydantic models aligned with S2 pattern
    
    - Add src/eopf_geozarr/data_api/s1_rtc.py — Zarr V3 Pydantic models for
      S1 GRD γ0T RTC GeoZarr stores, using pyz.v3 GroupSpec/ArraySpec with
      TypedDict members (same pattern as s2.py uses pyz.v2)
    - Models: S1RtcRoot, S1RtcOrbitGroup, S1RtcNativeResolutionDataset,
      S1RtcOverviewResolutionDataset, S1RtcConditionsGroup
    - Validation: convention UUIDs, spatial:dimensions, multiscales layout,
      required data arrays (vv/vh/border_mask), gamma_area presence
    - Add tests/_test_data/s1_rtc_examples/s1-grd-rtc-31TCH.json — realistic
      fixture with 3 timesteps, 6 overview levels, 3 gamma_area conditions
    - Add tests/test_data_api/test_s1_rtc.py — 11 tests: round-trip, structure
      validation, negative cases (missing orbit, r10m, UUIDs, etc.)
    - Add conftest fixture s1_rtc_json_example parametrized over all fixtures
    
    * refactor: improve Pydantic model definitions and streamline imports in S1 RTC module
    
    * docs: update Phase 1 checklist, add Phase 1 findings, link tracking issue #139
    
    * fix: standardize spatial dimensions to lowercase in S1 RTC models and test cases
    
    * fix: add 1D spatial coordinate arrays and use lowercase dimension names in S1 GRD RTC prototype
    
    * feat: add detailed implementation plan for Phase 2 GeoTIFF ingestion pipeline
    
    * fix: update Phase 2 GeoTIFF ingestion plan to include 1D spatial coordinate arrays and enforce lowercase dimension names
    
    * feat: Phase 2 — S1 GRD RTC GeoTIFF ingestion pipeline
    
    Productionise the GeoTIFF → Zarr V3 ingestion pipeline for S1Tiling
    gamma-naught RTC outputs.
    
    New module: src/eopf_geozarr/conversion/s1_ingest.py (~500 lines)
    - extract_geotiff_metadata(): rasterio-based metadata extraction with
      tag validation and S1Tiling datetime normalisation
    - create_s1_store(): Zarr V3 store creation with full GeoZarr conventions
      (multiscales, proj:, spatial:), sharded arrays, 1D spatial coordinate
      arrays at every resolution level
    - ingest_s1tiling_acquisition(): main API — create-or-open store, read
      GeoTIFFs, generate overviews (average/nearest), write all levels,
      append coordinate variables. CRS/shape consistency validation on append.
    - consolidate_s1_store(): post-batch metadata consolidation
    - discover_s1tiling_acquisitions(): file discovery with grouping
    
    Tests: tests/test_s1_rtc_ingest.py (27 tests)
    - Metadata extraction, store creation, ingestion (create + append),
      data integrity, xarray roundtrip, CRS/shape mismatch rejection,
      consolidation, file discovery
    
    Uses zarr_cm CMO dicts for convention metadata (not hardcoded UUIDs).
    Reuses calculate_aligned_chunk_size() from utils.py.
    Exports wired in conversion/__init__.py.
    
    Refs: #139
    
    * refactor: use zcm.Multiscales typed model per reviewer feedback
    
    - Replace dict[str, Any] multiscales field with zcm.Multiscales import
    - Remove inline MultiscalesTransform/ScaleLevel/Multiscales classes
    - Update spatial:dimensions validator to lowercase ['y', 'x'] (Python 3.12)
    - Remove typing_extensions import (native in Python 3.12)
    - Update test assertions for Pydantic model attribute access
    - Auto-format fixes from ruff (imports, fixture parens, line length)
    
    * fix: configure ruff TC001 for Pydantic runtime-evaluated base classes
    
    - Add [tool.ruff.lint.flake8-type-checking] runtime-evaluated-base-classes
      for pydantic.BaseModel so Pydantic field type imports aren't flagged
    - Remove 4 stale noqa comments auto-fixed by ruff
    
    * feat: implement S1Tiling conditions ingestion and discovery commands in CLI
    
    * feat(stac): add build_s1_rtc_stac_item and generate-stac-s1 CLI (#173)
    
    * feat(stac): add build_s1_rtc_stac_item and generate-stac-s1 CLI command
    
    Adds eopf_geozarr.stac.s1_rtc.build_s1_rtc_stac_item, which reads a
    consolidated S1 GRD RTC Zarr V3 store and returns a pystac.Item with
    SAR/SAT/projection extensions, WGS84 bbox derived via pyproj, and
    vv/vh asset sub-paths. Ascending orbit is preferred when both are present.
    
    Also adds the generate-stac-s1 CLI subcommand that prints the item as JSON.
    8 unit tests cover all acceptance criteria from the plan.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * fix: support s3:// URIs in S1Tiling discovery and ingest (#175)
    
    Four functions in s1_ingest.py silently failed for s3:// paths:
    - discover_s1tiling_acquisitions / discover_s1tiling_conditions used
      pathlib.Path.glob(), which normalises s3:// to s3:/ and returns 0 matches
    - ingest_s1tiling_acquisition / ingest_s1tiling_conditions coerced paths via
      Path(), corrupting s3:// URIs, and called .exists() which always returns
      False for S3 (existence checks now use path_exists() from fs_utils)
    
    Adds three private helpers:
    - _list_tifs(): uses s3fs.S3FileSystem.glob for s3:// prefixes
    - _coerce_input_path(): preserves str for s3://, returns Path otherwise
    - _rasterio_env(): rasterio.Env(AWSSession) context for s3:// paths;
      strips https:// from AWS_ENDPOINT_URL since GDAL expects hostname only
    
    Tested: rasterio.open("s3://...") confirmed working on OVH S3 with
    AWS_S3_ENDPOINT or AWS_ENDPOINT_URL set; hostname-only required by GDAL.
    
    Refs #139
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    
    * fix(stac): point vv/vh asset hrefs to orbit group root; add tile_matrix_set
    
    Per Emmanuel's review on #173:
    - vv/vh asset hrefs now point to {store}/{orbit} (the zarr group that carries
      multiscales), matching how S2 reflectance assets point to measurements/reflectance.
      TiTiler reads tile_matrix_set from that group's multiscales attributes.
    - create_s1_store now writes tile_matrix_set into the orbit group's multiscales
      so TiTiler can discover the tiling scheme without error.
    - Updated test_asset_hrefs to assert the new orbit-group href.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * fix(ingest): add proj:code to resolution group attrs for TiTiler compatibility
    
    TiTiler's _validate_zarr checks ds.rio.crs on the native resolution group
    (r10m). Without proj:code in the resolution group's attrs, rioxarray returns
    None and the group is excluded → groups=[] → bounds unpack error.
    
    S2 zarr already has proj:code at the resolution group level; mirror that for S1.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * fix(s1): write CF grid_mapping, drop tile_matrix_set from RTC store (#176)
    
    The deployed titiler-eopf reader resolves a group's CRS via rioxarray,
    which reads a CF spatial_ref/crs_wkt coordinate -- not the GeoZarr
    proj:code attr. S1 GRD RTC stores carried only proj:code, so rio.crs
    was None and every multiscale group was rejected (HTTP 500 on
    render/info). The S2 converter already writes a spatial_ref
    grid-mapping; this brings S1 in line.
    
    Also stop writing tile_matrix_set into the orbit-group multiscales: the
    data-model owner confirmed it is not part of the S1 GRD RTC data model,
    and the data_api reader already treats it as optional (MISSING default).
    
    - add _add_grid_mapping() using pyproj CRS.to_cf() (the same source
      rioxarray uses, so no hard-coded projection) and call it for every
      resolution level (both store-creation paths) and the conditions group
    - remove _create_tile_matrix_set() and its multiscales entry
    - tests: assert no tile_matrix_set, spatial_ref present + rio.crs
      resolves to the native EPSG, conditions group carries grid_mapping
    
    This fixes at the source what data-pipeline currently works around in
    ingest_v1_s1_rtc.py (_patch_cf_grid_mapping / _patch_tile_matrix_limits).
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    * Merge #172 (deprecate v0, fix fill values, sanitize attrs) into s1-rtc (#178)
    
    * chore: release 0.9.0
    
    * feat: implement scale-offset and data type casting via codecs
    
    * fix: fix dependency declaration
    
    * chore: use latest version of cast value
    
    * chore: make cast-value a project dependency
    
    * test: expand test coverage
    
    * fix: upgrade pytest to 9.0.3 (CVE-2025-71176)
    
    Fixes insecure /tmp directory handling on UNIX systems.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * - Use zarr-python's presumptive implementation of scale-offset
    - use scalar map for handling NaN
    - ensure that downsampled arrays use scalar map + cast value
    - improve tests across parametrization of relelvant functions
    
    * feat: add store-root spatial:bbox and tighten minispec requirements (#164)
    
    * feat: add store-root spatial:bbox and tighten minispec requirements
    
    Introduces a GeoZarr "Store Root" layer in the minispec so clients can read
    a summary footprint without walking into child groups, and tightens the
    multiscale profile so `spatial:bbox` at the root and `spatial:transform` +
    `spatial:shape` on every layout entry are mandatory.
    
    Adds a new `geozarr.store` pydantic module (`GeoZarrStoreAttrs`,
    `GeoZarrMultiscaleGroupAttrs`, `GeoZarr`) enforcing the tightened profile,
    and updates the S2 converter to union child-group bboxes into an EPSG:4326
    footprint written at the store root.
    
    Closes #156. Addresses the clear parts of #163; the array-level and
    non-multiscale-group parts of that issue need further clarification.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
    
    * feat: update GeoZarr store model and optimize S2 converter for improved bbox handling
    
    * Addresses review feedback on #164:
    
    - Adds a "GeoZarr Hierarchy & Identification" subsection to the Store Root
      section of the minispec, codifying d-v-b's proposed rules: single root,
      root prefix ends with `.zarr`, suffix occurs at most once in the
      hierarchy, and explicit terminal-path conditions.
    - Promotes the store-root CRS to mandatory: at least one of `proj:code`,
      `proj:wkt2`, `proj:projjson` MUST be set; there is no implicit EPSG:4326
      default. Per @vincentsarago's review.
    - Promotes `zarr_conventions` declaration at the store root from
      RECOMMENDED to required.
    - Converter now writes `proj:code: "EPSG:4326"` at the root alongside
      `spatial:bbox`.
    - Pydantic `GeoZarrStoreAttrs` enforces the new CRS-required rule.
    
    Cross-links the new hierarchy + spatial:extent follow-ups upstream:
    zarr-developers/geozarr-spec#132 (hierarchy & root identification, also
    addresses #124 URL parsing) and #133 (STAC-style spatial:extent).
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
    
    * chore: uv.lock
    
    * refactor: use zarr-python 3.2.0
    
    * chore: lockfile
    
    * fix: pin fill value based on data type instead of relying on xarray
    
    * test: update tests to consistently use nan fill value for floats
    
    * chore: bump urllib3 to 2.7.0
    
    * chore: skip quicklook groups (#165)
    
    * chore: group dependabot updates for actions and pip (#160)
    
    * chore: group dependabot updates for actions and pip
    
    - Group all GitHub Actions bumps (minor, patch, major) into one PR
    - Group minor and patch pip bumps into one PR; major bumps remain
      ungrouped for individual review
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * chore: change dependabot schedule day to wednesday
    
    * ci: add comment_on option to security auditing action
    
    * ci: switch dependabot Python ecosystem from pip to uv
    
    uv ecosystem reads uv.lock directly, enabling Dependabot to raise PRs
    for lockfile-pinned versions including security patches.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    
    * fix: derive coarse spatial transforms from coordinates (#168)
    
    * fix: derive coarse spatial transforms from coordinates
    
    * refactor: improve function definitions for clarity and consistency
    
    * chore: release 0.10.0 (#162)
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    
    * fix(converter): align convert layout to S2, strip _eopf_attrs, fix _FillValue (#171)
    
    - Remove TMS layout and /0 /1 /2 numeric overview groups from the general
      'convert' CLI; produce S2-style sibling r{2**level} overviews instead.
      'convert-s2-optimized' continues to emit the same layout.
    - Drop the '--tile-width' CLI option.
    - Sanitize array attrs in all converters: strip stale '_eopf_attrs' and
      source-only '_FillValue' / 'dtype' / 'valid_min' / 'valid_max' on decoded
      floats; rewrite 'units: digital_counts' -> 'units: 1'. The sanitizer now
      returns a new dict and callers reassign 'attrs', so stale keys are
      actually removed (a previous '.update()' pattern left them in place).
    - Sanitize coord attrs too (datetime coords in /conditions/meteorology
      were leaking '_eopf_decode_datetime64' via '_eopf_attrs').
    - Set '_FillValue' properly for float measurements so xarray's CF
      encoder produces the base64-NaN representation needed for
      'use_zarr_fill_value_as_mask=True' round-trip (xarray#11345). Fixes #171.
    - Remove dead 'utils.encode_cf_fill_value' (no callers).
    - Tests: open each r{N} multiscale level separately; migrate
      'test_multiscales_round_trip' from deprecated tms.Multiscales to
      zcm.Multiscales (with tuplify_json to handle extra='allow' fields).
    - Regenerate snapshot fixtures; verified 0 '_eopf_attrs',
      'tile_matrix*', or 'digital_counts' markers across S2A/S2B/S2C.
    
    * test: add guardrails for converter output attrs (#171)
    
    Walks the snapshotted GroupSpec JSON fixtures and asserts:
    - no '_eopf_attrs' anywhere;
    - no 'tile_matrix*' markers (TMS removal);
    - no 'units: digital_counts' on float arrays (decoded scale/offset);
    - every float array under '/measurements/' has '_FillValue' set
      (required for CF NaN-mask round-trip, xarray#11345).
    
    * docs: deprecate v0 references, document r{N} overview layout (#171)
    
    - Drop 'tile_width' parameter from API examples (option removed from CLI/API).
    - Replace '/measurements/r10m/{0,1,2}' nested-pyramid examples with the
      current flat 'r{2**level}' sibling-group layout.
    - Remove the V0 vs V1 split in converter.md / examples.md / quickstart.md;
      the general 'convert' command now produces the same flat layout as
      'convert-s2-optimized'. The S2-optimized section is reframed as a
      feature description rather than a 'V1 vs V0' comparison.
    - Update architecture.md multiscales snippet to the new model
      ('layout' / 'asset' / 'derived_from' / 'transform').
    - Update faq.md inspection snippet to walk the new layout.
    
    * refactor: clean up code formatting and enhance attribute handling in geozarr conversion
    
    * fix(cli-e2e): drop deprecated --tile-width flag from tests and example
    
    * refactor: remove deprecated v0 layout, sanitize attributes, and fix _FillValue handling
    
    * test: add _FillValue masking roundtrip test (#172)
    
    Addresses @vincentsarago's PR review: assert float arrays written with
    the converter's _FillValue convention round-trip through xarray's
    use_zarr_fill_value_as_mask=True so NaN cells come back masked.
    
    * test: drive _FillValue masking test through create_geozarr_dataset (#172)
    
    Replace the standalone xarray write with a real converter run: build a
    small float DataTree, invoke create_geozarr_dataset, then reopen the
    output with use_zarr_fill_value_as_mask=True and assert masking on the
    nodata patch. Exercises the converter's _FillValue + encoding path
    end-to-end, per @vincentsarago's review.
    
    * fix: green CI for #172 — S2 convert flat layout + idna CVE (#177)
    
    * fix(security): bump idna 3.13 -> 3.17 to clear CVE-2026-45409
    
    pip-audit in the Security Audit workflow blocks on idna 3.13
    (CVE-2026-45409, incomplete fix of CVE-2024-3651; fixed in 3.15).
    Bump the transitive pin via uv lock. pip-audit now reports no
    known vulnerabilities.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(cli): route Sentinel-2 convert through the optimized flat layout
    
    Generic `convert` emitted GeoZarr-0.4 multiscales with native data at the
    group root and overviews as nested r{2**N} sub-groups. That tree cannot be
    opened by xr.open_datatree (an overview child's x/y conflict with the
    parent's inherited x/y), so the `info` and `validate` CLI commands crashed
    on the converter's own Sentinel-2 output.
    
    Detect Sentinel-2 inputs and delegate to the existing, tested
    convert_s2_optimized path, which emits flat sibling r{N}m levels (the layout
    PR #172 documents). Non-S2 inputs keep the generic create_geozarr_dataset
    path. Detection is best-effort and never aborts conversion.
    
    Regenerate the geozarr_examples structure snapshots to the flat layout.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * test: make CLI e2e assertions debuggable and fix multiscale round-trip
    
    - The CLI logs errors to stdout via structlog; assert on stdout+stderr in
      test_cli_e2e so a non-zero exit shows the actual error instead of an
      empty stderr.
    - test_multiscale_attrs_round_trip tuplified only one side of its equality,
      so flat-layout level groups carrying list-valued attrs (e.g. spatial:bbox)
      broke a check that is only about JSON list/tuple normalisation. Tuplify
      both sides; the model still round-trips losslessly.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    Co-authored-by: Davis Vann Bennett <[email protected]>
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    Co-authored-by: Emmanuel Mathot <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    Co-authored-by: Davis Vann Bennett <[email protected]>
    Co-authored-by: Emmanuel Mathot <[email protected]>
    
    * Revert "feat(stac): add build_s1_rtc_stac_item and generate-stac-s1 CLI (#173)" (#179)
    
    This reverts commit f749368ac3870790a69b250926f0af1aa6b58d0e.
    
    * fix(s1-ingest): discover multi-frame acquisitions with a masked timestamp (#183) (#184)
    
    discover_s1tiling_acquisitions returned 0 acquisitions for multi-frame S1Tiling
    products: their GeoTIFF filenames mask the acquisition time as '…txxxxxx', which
    the filename regex (\d{8}t\d{6}) rejected, so parse_s1tiling_filename returned
    None and every file was skipped — silently, with no error.
    
    - Relax the acq_stamp pattern to accept a masked time (\d{6}|x{6}), so masked
      files are no longer dropped.
    - In discover, when the stamp is masked, resolve the real YYYYMMDDtHHMMSS stamp
      from the GeoTIFF ACQUISITION_DATETIME tag (via extract_geotiff_metadata) and
      use it for grouping + downstream STAC datetime.
    
    This is the durable upstream fix for the data-pipeline #237 workaround (which
    renamed masked products before discovery); that workaround can now be retired.
    
    Closes #183.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    * chore: merge main into s1-tiling (#190)
    
    * chore: release 0.9.0
    
    * feat: implement scale-offset and data type casting via codecs
    
    * fix: fix dependency declaration
    
    * chore: use latest version of cast value
    
    * chore: make cast-value a project dependency
    
    * test: expand test coverage
    
    * fix: upgrade pytest to 9.0.3 (CVE-2025-71176)
    
    Fixes insecure /tmp directory handling on UNIX systems.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * - Use zarr-python's presumptive implementation of scale-offset
    - use scalar map for handling NaN
    - ensure that downsampled arrays use scalar map + cast value
    - improve tests across parametrization of relelvant functions
    
    * feat: add store-root spatial:bbox and tighten minispec requirements (#164)
    
    * feat: add store-root spatial:bbox and tighten minispec requirements
    
    Introduces a GeoZarr "Store Root" layer in the minispec so clients can read
    a summary footprint without walking into child groups, and tightens the
    multiscale profile so `spatial:bbox` at the root and `spatial:transform` +
    `spatial:shape` on every layout entry are mandatory.
    
    Adds a new `geozarr.store` pydantic module (`GeoZarrStoreAttrs`,
    `GeoZarrMultiscaleGroupAttrs`, `GeoZarr`) enforcing the tightened profile,
    and updates the S2 converter to union child-group bboxes into an EPSG:4326
    footprint written at the store root.
    
    Closes #156. Addresses the clear parts of #163; the array-level and
    non-multiscale-group parts of that issue need further clarification.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
    
    * feat: update GeoZarr store model and optimize S2 converter for improved bbox handling
    
    * Addresses review feedback on #164:
    
    - Adds a "GeoZarr Hierarchy & Identification" subsection to the Store Root
      section of the minispec, codifying d-v-b's proposed rules: single root,
      root prefix ends with `.zarr`, suffix occurs at most once in the
      hierarchy, and explicit terminal-path conditions.
    - Promotes the store-root CRS to mandatory: at least one of `proj:code`,
      `proj:wkt2`, `proj:projjson` MUST be set; there is no implicit EPSG:4326
      default. Per @vincentsarago's review.
    - Promotes `zarr_conventions` declaration at the store root from
      RECOMMENDED to required.
    - Converter now writes `proj:code: "EPSG:4326"` at the root alongside
      `spatial:bbox`.
    - Pydantic `GeoZarrStoreAttrs` enforces the new CRS-required rule.
    
    Cross-links the new hierarchy + spatial:extent follow-ups upstream:
    zarr-developers/geozarr-spec#132 (hierarchy & root identification, also
    addresses #124 URL parsing) and #133 (STAC-style spatial:extent).
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
    
    * chore: uv.lock
    
    * refactor: use zarr-python 3.2.0
    
    * chore: lockfile
    
    * fix: pin fill value based on data type instead of relying on xarray
    
    * test: update tests to consistently use nan fill value for floats
    
    * chore: bump urllib3 to 2.7.0
    
    * chore: skip quicklook groups (#165)
    
    * chore: group dependabot updates for actions and pip (#160)
    
    * chore: group dependabot updates for actions and pip
    
    - Group all GitHub Actions bumps (minor, patch, major) into one PR
    - Group minor and patch pip bumps into one PR; major bumps remain
      ungrouped for individual review
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * chore: change dependabot schedule day to wednesday
    
    * ci: add comment_on option to security auditing action
    
    * ci: switch dependabot Python ecosystem from pip to uv
    
    uv ecosystem reads uv.lock directly, enabling Dependabot to raise PRs
    for lockfile-pinned versions including security patches.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    
    * fix: derive coarse spatial transforms from coordinates (#168)
    
    * fix: derive coarse spatial transforms from coordinates
    
    * refactor: improve function definitions for clarity and consistency
    
    * chore: release 0.10.0 (#162)
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    
    * enh: deprecate v0, fix fill values and sanitize attributes (#172)
    
    * fix(converter): align convert layout to S2, strip _eopf_attrs, fix _FillValue (#171)
    
    - Remove TMS layout and /0 /1 /2 numeric overview groups from the general
      'convert' CLI; produce S2-style sibling r{2**level} overviews instead.
      'convert-s2-optimized' continues to emit the same layout.
    - Drop the '--tile-width' CLI option.
    - Sanitize array attrs in all converters: strip stale '_eopf_attrs' and
      source-only '_FillValue' / 'dtype' / 'valid_min' / 'valid_max' on decoded
      floats; rewrite 'units: digital_counts' -> 'units: 1'. The sanitizer now
      returns a new dict and callers reassign 'attrs', so stale keys are
      actually removed (a previous '.update()' pattern left them in place).
    - Sanitize coord attrs too (datetime coords in /conditions/meteorology
      were leaking '_eopf_decode_datetime64' via '_eopf_attrs').
    - Set '_FillValue' properly for float measurements so xarray's CF
      encoder produces the base64-NaN representation needed for
      'use_zarr_fill_value_as_mask=True' round-trip (xarray#11345). Fixes #171.
    - Remove dead 'utils.encode_cf_fill_value' (no callers).
    - Tests: open each r{N} multiscale level separately; migrate
      'test_multiscales_round_trip' from deprecated tms.Multiscales to
      zcm.Multiscales (with tuplify_json to handle extra='allow' fields).
    - Regenerate snapshot fixtures; verified 0 '_eopf_attrs',
      'tile_matrix*', or 'digital_counts' markers across S2A/S2B/S2C.
    
    * test: add guardrails for converter output attrs (#171)
    
    Walks the snapshotted GroupSpec JSON fixtures and asserts:
    - no '_eopf_attrs' anywhere;
    - no 'tile_matrix*' markers (TMS removal);
    - no 'units: digital_counts' on float arrays (decoded scale/offset);
    - every float array under '/measurements/' has '_FillValue' set
      (required for CF NaN-mask round-trip, xarray#11345).
    
    * docs: deprecate v0 references, document r{N} overview layout (#171)
    
    - Drop 'tile_width' parameter from API examples (option removed from CLI/API).
    - Replace '/measurements/r10m/{0,1,2}' nested-pyramid examples with the
      current flat 'r{2**level}' sibling-group layout.
    - Remove the V0 vs V1 split in converter.md / examples.md / quickstart.md;
      the general 'convert' command now produces the same flat layout as
      'convert-s2-optimized'. The S2-optimized section is reframed as a
      feature description rather than a 'V1 vs V0' comparison.
    - Update architecture.md multiscales snippet to the new model
      ('layout' / 'asset' / 'derived_from' / 'transform').
    - Update faq.md inspection snippet to walk the new layout.
    
    * refactor: clean up code formatting and enhance attribute handling in geozarr conversion
    
    * fix(cli-e2e): drop deprecated --tile-width flag from tests and example
    
    * refactor: remove deprecated v0 layout, sanitize attributes, and fix _FillValue handling
    
    * test: add _FillValue masking roundtrip test (#172)
    
    Addresses @vincentsarago's PR review: assert float arrays written with
    the converter's _FillValue convention round-trip through xarray's
    use_zarr_fill_value_as_mask=True so NaN cells come back masked.
    
    * test: drive _FillValue masking test through create_geozarr_dataset (#172)
    
    Replace the standalone xarray write with a real converter run: build a
    small float DataTree, invoke create_geozarr_dataset, then reopen the
    output with use_zarr_fill_value_as_mask=True and assert masking on the
    nodata patch. Exercises the converter's _FillValue + encoding path
    end-to-end, per @vincentsarago's review.
    
    * fix: green CI for #172 — S2 convert flat layout + idna CVE (#177)
    
    * fix(security): bump idna 3.13 -> 3.17 to clear CVE-2026-45409
    
    pip-audit in the Security Audit workflow blocks on idna 3.13
    (CVE-2026-45409, incomplete fix of CVE-2024-3651; fixed in 3.15).
    Bump the transitive pin via uv lock. pip-audit now reports no
    known vulnerabilities.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(cli): route Sentinel-2 convert through the optimized flat layout
    
    Generic `convert` emitted GeoZarr-0.4 multiscales with native data at the
    group root and overviews as nested r{2**N} sub-groups. That tree cannot be
    opened by xr.open_datatree (an overview child's x/y conflict with the
    parent's inherited x/y), so the `info` and `validate` CLI commands crashed
    on the converter's own Sentinel-2 output.
    
    Detect Sentinel-2 inputs and delegate to the existing, tested
    convert_s2_optimized path, which emits flat sibling r{N}m levels (the layout
    PR #172 documents). Non-S2 inputs keep the generic create_geozarr_dataset
    path. Detection is best-effort and never aborts conversion.
    
    Regenerate the geozarr_examples structure snapshots to the flat layout.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * test: make CLI e2e assertions debuggable and fix multiscale round-trip
    
    - The CLI logs errors to stdout via structlog; assert on stdout+stderr in
      test_cli_e2e so a non-zero exit shows the actual error instead of an
      empty stderr.
    - test_multiscale_attrs_round_trip tuplified only one side of its equality,
      so flat-layout level groups carrying list-valued attrs (e.g. spatial:bbox)
      broke a check that is only about JSON list/tuple normalisation. Tuplify
      both sides; the model still round-trips losslessly.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Loïc Houpert <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    * ci(deps): bump the actions group across 1 directory with 8 updates (#167)
    
    Bumps the actions group with 8 updates in the / directory:
    
    | Package | From | To |
    | --- | --- | --- |
    | [actions/checkout](https://github.com/actions/checkout) | `5.0.1` | `6.0.2` |
    | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `7.6.0` | `8.1.0` |
    | [actions/cache](https://github.com/actions/cache) | `5.0.4` | `5.0.5` |
    | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `5.5.3` | `6.0.0` |
    | [googleapis/release-please-action](https://github.com/googleapis/release-please-action) | `4.4.0` | `5.0.0` |
    | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `6.0.0` | `7.0.1` |
    | [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` |
    | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.13.0` | `1.14.0` |
    
    
    
    Updates `actions/checkout` from 5.0.1 to 6.0.2
    - [Release notes](https://github.com/actions/checkout/releases)
    - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
    - [Commits](https://github.com/actions/checkout/compare/93cb6efe18208431cddfb8368fd83d5badbf9bfd...de0fac2e4500dabe0009e67214ff5f5447ce83dd)
    
    Updates `astral-sh/setup-uv` from 7.6.0 to 8.1.0
    - [Release notes](https://github.com/astral-sh/setup-uv/releases)
    - [Commits](https://github.com/astral-sh/setup-uv/compare/37802adc94f370d6bfd71619e3f0bf239e1f3b78...08807647e7069bb48b6ef5acd8ec9567f424441b)
    
    Updates `actions/cache` from 5.0.4 to 5.0.5
    - [Release notes](https://github.com/actions/cache/releases)
    - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
    - [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae)
    
    Updates `codecov/codecov-action` from 5.5.3 to 6.0.0
    - [Release notes](https://github.com/codecov/codecov-action/releases)
    - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
    - [Commits](https://github.com/codecov/codecov-action/compare/1af58845a975a7985b0beb0cbe6fbbb71a41dbad...57e3a136b779b570ffcdbf80b3bdc90e7fab3de2)
    
    Updates `googleapis/release-please-action` from 4.4.0 to 5.0.0
    - [Release notes](https://github.com/googleapis/release-please-action/releases)
    - [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md)
    - [Commits](https://github.com/googleapis/release-please-action/compare/16a9c90856f42705d54a6fda1823352bdc62cf38...45996ed1f6d02564a971a2fa1b5860e934307cf7)
    
    Updates `actions/upload-artifact` from 6.0.0 to 7.0.1
    - [Release notes](https://github.com/actions/upload-artifact/releases)
    - [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
    
    Updates `actions/download-artifact` from 7.0.0 to 8.0.1
    - [Release notes](https://github.com/actions/download-artifact/releases)
    - [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)
    
    Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0
    - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
    - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e...cef221092ed1bacb1cc03d23a2d87d1d172e277b)
    
    ---
    updated-dependencies:
    - dependency-name: actions/cache
      dependency-version: 5.0.5
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: actions
    - dependency-name: actions/checkout
      dependency-version: 6.0.2
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    - dependency-name: actions/download-artifact
      dependency-version: 8.0.1
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    - dependency-name: actions/upload-artifact
      dependency-version: 7.0.1
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    - dependency-name: astral-sh/setup-uv
      dependency-version: 8.1.0
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    - dependency-name: codecov/codecov-action
      dependency-version: 6.0.0
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    - dependency-name: googleapis/release-please-action
      dependency-version: 5.0.0
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    - dependency-name: pypa/gh-action-pypi-publish
      dependency-version: 1.14.0
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: actions
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * deps(deps-dev): bump mypy from 1.20.2 to 2.1.0 (#170)
    
    Bumps [mypy](https://github.com/python/mypy) from 1.20.2 to 2.1.0.
    - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
    - [Commits](https://github.com/python/mypy/compare/v1.20.2...v2.1.0)
    
    ---
    updated-dependencies:
    - dependency-name: mypy
      dependency-version: 2.1.0
      dependency-type: direct:development
      update-type: version-update:semver-major
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * deps(deps): bump the uv-minor-patch group across 1 directory with 4 updates (#174)
    
    Bumps the uv-minor-patch group with 4 updates in the / directory: [pydantic](https://github.com/pydantic/pydantic), [numpy](https://github.com/numpy/numpy), [cf-xarray](https://github.com/xarray-contrib/cf-xarray) and [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions).
    
    
    Updates `pydantic` from 2.13.3 to 2.13.4
    - [Release notes](https://github.com/pydantic/pydantic/releases)
    - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
    - [Commits](https://github.com/pydantic/pydantic/compare/v2.13.3...v2.13.4)
    
    Updates `numpy` from 2.4.4 to 2.4.6
    - [Release notes](https://github.com/numpy/numpy/releases)
    - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst)
    - [Commits](https://github.com/numpy/numpy/compare/v2.4.4...v2.4.6)
    
    Updates `cf-xarray` from 0.11.0 to 0.11.1
    - [Release notes](https://github.com/xarray-contrib/cf-xarray/releases)
    - [Commits](https://github.com/xarray-contrib/cf-xarray/compare/v0.11.0...v0.11.1)
    
    Updates `pymdown-extensions` from 10.21.2 to 10.21.3
    - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
    - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.21.2...10.21.3)
    
    ---
    updated-dependencies:
    - dependency-name: pydantic
      dependency-version: 2.13.4
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: uv-minor-patch
    - dependency-name: numpy
      dependency-version: 2.4.6
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: uv-minor-patch
    - dependency-name: cf-xarray
      dependency-version: 0.11.1
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: uv-minor-patch
    - dependency-name: pymdown-extensions
      dependency-version: 10.21.3
      dependency-type: direct:development
      update-type: version-update:semver-patch
      dependency-group: uv-minor-patch
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * fix(deps): bump aiohttp to >=3.14.0 to resolve CVE-2026-34993 and CVE-2026-47265 (#182)
    
    The Security Audit job's pip-audit step flagged aiohttp 3.13.5 for
    CVE-2026-34993 and CVE-2026-47265 (both fixed in 3.14.0). Raise the
    floor and refresh the lock to 3.14.1.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    * chore: release 0.10.1 (#181)
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    
    * ci(deps): bump the actions group with 3 updates (#185)
    
    Bumps the actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [codecov/codecov-action](https://github.com/codecov/codecov-action).
    
    
    Updates `actions/checkout` from 6.0.2 to 6.0.3
    - [Release notes](https://github.com/actions/checkout/releases)
    - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
    - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)
    
    Updates `astral-sh/setup-uv` from 8.1.0 to 8.2.0
    - [Release notes](https://github.com/astral-sh/setup-uv/releases)
    - [Commits](https://github.com/astral-sh/setup-uv/compare/08807647e7069bb48b6ef5acd8ec9567f424441b...fac544c07dec837d0ccb6301d7b5580bf5edae39)
    
    Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
    - [Release notes](https://github.com/codecov/codecov-action/releases)
    - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
    - [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354)
    
    ---
    updated-dependencies:
    - dependency-name: actions/checkout
      dependency-version: 6.0.3
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: actions
    - dependency-name: astral-sh/setup-uv
      dependency-version: 8.2.0
      dependency-type: direct:production
      update-type: version-update:semver-minor
      dependency-group: actions
    - dependency-name: codecov/codecov-action
      dependency-version: 6.0.1
      dependency-type: direct:production
      update-type: version-update:semver-patch
      dependency-group: actions
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    ---------
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    Co-authored-by: Davis Vann Bennett <[email protected]>
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    Co-authored-by: Emmanuel Mathot <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * feat(s1-rtc): STAC item builders + ingest fixes for TiTiler rendering (#180)
    
    * feat(stac): add build_s1_rtc_stac_item and generate-stac-s1 CLI command
    
    Adds eopf_geozarr.stac.s1_rtc.build_s1_rtc_stac_item, which reads a
    consolidated S1 GRD RTC Zarr V3 store and returns a pystac.Item with
    SAR/SAT/projection extensions, WGS84 bbox derived via pyproj, and
    vv/vh asset sub-paths. Ascending orbit is preferred when both are present.
    
    Also adds the generate-stac-s1 CLI subcommand that prints the item as JSON.
    8 unit tests cover all acceptance criteria from the plan.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * fix: support s3:// URIs in S1Tiling discovery and ingest (#175)
    
    Four functions in s1_ingest.py silently failed for s3:// paths:
    - discover_s1tiling_acquisitions / discover_s1tiling_conditions used
      pathlib.Path.glob(), which normalises s3:// to s3:/ and returns 0 matches
    - ingest_s1tiling_acquisition / ingest_s1tiling_conditions coerced paths via
      Path(), corrupting s3:// URIs, and called .exists() which always returns
      False for S3 (existence checks now use path_exists() from fs_utils)
    
    Adds three private helpers:
    - _list_tifs(): uses s3fs.S3FileSystem.glob for s3:// prefixes
    - _coerce_input_path(): preserves str for s3://, returns Path otherwise
    - _rasterio_env(): rasterio.Env(AWSSession) context for s3:// paths;
      strips https:// from AWS_ENDPOINT_URL since GDAL expects hostname only
    
    Tested: rasterio.open("s3://...") confirmed working on OVH S3 with
    AWS_S3_ENDPOINT or AWS_ENDPOINT_URL set; hostname-only required by GDAL.
    
    Refs #139
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    
    * fix(stac): point vv/vh asset hrefs to orbit group root; add tile_matrix_set
    
    Per Emmanuel's review on #173:
    - vv/vh asset hrefs now point to {store}/{orbit} (the zarr group that carries
      multiscales), matching how S2 reflectance assets point to measurements/reflectance.
      TiTiler reads tile_matrix_set from that group's multiscales attributes.
    - create_s1_store now writes tile_matrix_set into the orbit group's multiscales
      so TiTiler can discover the tiling scheme without error.
    - Updated test_asset_hrefs to assert the new orbit-group href.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * fix(ingest): add proj:code to resolution group attrs for TiTiler compatibility
    
    TiTiler's _validate_zarr checks ds.rio.crs on the native resolution group
    (r10m). Without proj:code in the resolution group's attrs, rioxarray returns
    None and the group is excluded → groups=[] → bounds unpack error.
    
    S2 zarr already has proj:code at the resolution group level; mirror that for S1.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * fix(s1): write CF grid_mapping, drop tile_matrix_set from RTC store (#176)
    
    The deployed titiler-eopf reader resolves a group's CRS via rioxarray,
    which reads a CF spatial_ref/crs_wkt coordinate -- not the GeoZarr
    proj:code attr. S1 GRD RTC stores carried only proj:code, so rio.crs
    was None and every multiscale group was rejected (HTTP 500 on
    render/info). The S2 converter already writes a spatial_ref
    grid-mapping; this brings S1 in line.
    
    Also stop writing tile_matrix_set into the orbit-group multiscales: the
    data-model owner confirmed it is not part of the S1 GRD RTC data model,
    and the data_api reader already treats it as optional (MISSING default).
    
    - add _add_grid_mapping() using pyproj CRS.to_cf() (the same source
      rioxarray uses, so no hard-coded projection) and call it for every
      resolution level (both store-creation paths) and the conditions group
    - remove _create_tile_matrix_set() and its multiscales entry
    - tests: assert no tile_matrix_set, spatial_ref present + rio.crs
      resolves to the native EPSG, conditions group carries grid_mapping
    
    This fixes at the source what data-pipeline currently works around in
    ingest_v1_s1_rtc.py (_patch_cf_grid_mapping / _patch_tile_matrix_limits).
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    * Merge #172 (deprecate v0, fix fill values, sanitize attrs) into s1-rtc (#178)
    
    * chore: release 0.9.0
    
    * feat: implement scale-offset and data type casting via codecs
    
    * fix: fix dependency declaration
    
    * chore: use latest version of cast value
    
    * chore: make cast-value a project dependency
    
    * test: expand test coverage
    
    * fix: upgrade pytest to 9.0.3 (CVE-2025-71176)
    
    Fixes insecure /tmp directory handling on UNIX systems.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * - Use zarr-python's presumptive implementation of scale-offset
    - use scalar map for handling NaN
    - ensure that downsampled arrays use scalar map + cast value
    - improve tests across parametrization of relelvant functions
    
    * feat: add store-root spatial:bbox and tighten minispec requirements (#164)
    
    * feat: add store-root spatial:bbox and tighten minispec requirements
    
    Introduces a GeoZarr "Store Root" layer in the minispec so clients can read
    a summary footprint without walking into child groups, and tightens the
    multiscale profile so `spatial:bbox` at the root and `spatial:transform` +
    `spatial:shape` on every layout entry are mandatory.
    
    Adds a new `geozarr.store` pydantic module (`GeoZarrStoreAttrs`,
    `GeoZarrMultiscaleGroupAttrs`, `GeoZarr`) enforcing the tightened profile,
    and updates the S2 converter to union child-group bboxes into an EPSG:4326
    footprint written at the store root.
    
    Closes #156. Addresses the clear parts of #163; the array-level and
    non-multiscale-group parts of that issue need further clarification.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
    
    * feat: update GeoZarr store model and optimize S2 converter for improved bbox handling
    
    * Addresses review feedback on #164:
    
    - Adds a "GeoZarr Hierarchy & Identification" subsection to the Store Root
      section of the minispec, codifying d-v-b's proposed rules: single root,
      root prefix ends with `.zarr`, suffix occurs at most once in the
      hierarchy, and explicit terminal-path conditions.
    - Promotes the store-root CRS to mandatory: at least one of `proj:code`,
      `proj:wkt2`, `proj:projjson` MUST be set; there is no implicit EPSG:4326
      default. Per @vincentsarago's review.
    - Promotes `zarr_conventions` declaration at the store root from
      RECOMMENDED to required.
    - Converter now writes `proj:code: "EPSG:4326"` at the root alongside
      `spatial:bbox`.
    - Pydantic `GeoZarrStoreAttrs` enforces the new CRS-required rule.
    
    Cross-links the new hierarchy + spatial:extent follow-ups upstream:
    zarr-developers/geozarr-spec#132 (hierarchy & root identification, also
    addresses #124 URL parsing) and #133 (STAC-style spatial:extent).
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
    
    * chore: uv.lock
    
    * refactor: use zarr-python 3.2.0
    
    * chore: lockfile
    
    * fix: pin fill value based on data type instead of relying on xarray
    
    * test: update tests to consistently use nan fill value for floats
    
    * chore: bump urllib3 to 2.7.0
    
    * chore: skip quicklook groups (#165)
    
    * chore: group dependabot updates for actions and pip (#160)
    
    * chore: group dependabot updates for actions and pip
    
    - Group all GitHub Actions bumps (minor, patch, major) into one PR
    - Group minor and patch pip bumps into one PR; major bumps remain
      ungrouped for individual review
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * chore: change dependabot schedule day to wednesday
    
    * ci: add comment_on option to security auditing action
    
    * ci: switch dependabot Python ecosystem from pip to uv
    
    uv ecosystem reads uv.lock directly, enabling Dependabot to raise PRs
    for lockfile-pinned versions including security patches.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    
    * fix: derive coarse spatial transforms from coordinates (#168)
    
    * fix: derive coarse spatial transforms from coordinates
    
    * refactor: improve function definitions for clarity and consistency
    
    * chore: release 0.10.0 (#162)
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    
    * fix(converter): align convert layout to S2, strip _eopf_attrs, fix _FillValue (#171)
    
    - Remove TMS layout and /0 /1 /2 numeric overview groups from the general
      'convert' CLI; produce S2-style sibling r{2**level} overviews instead.
      'convert-s2-optimized' continues to emit the same layout.
    - Drop the '--tile-width' CLI option.
    - Sanitize array attrs in all converters: strip stale '_eopf_attrs' and
      source-only '_FillValue' / 'dtype' / 'valid_min' / 'valid_max' on decoded
      floats; rewrite 'units: digital_counts' -> 'units: 1'. The sanitizer now
      returns a new dict and callers reassign 'attrs', so stale keys are
      actually removed (a previous '.update()' pattern left them in place).
    - Sanitize coord attrs too (datetime coords in /conditions/meteorology
      were leaking '_eopf_decode_datetime64' via '_eopf_attrs').
    - Set '_FillValue' properly for float measurements so xarray's CF
      encoder produces the base64-NaN representation needed for
      'use_zarr_fill_value_as_mask=True' round-trip (xarray#11345). Fixes #171.
    - Remove dead 'utils.encode_cf_fill_value' (no callers).
    - Tests: open each r{N} multiscale level separately; migrate
      'test_multiscales_round_trip' from deprecated tms.Multiscales to
      zcm.Multiscales (with tuplify_json to handle extra='allow' fields).
    - Regenerate snapshot fixtures; verified 0 '_eopf_attrs',
      'tile_matrix*', or 'digital_counts' markers across S2A/S2B/S2C.
    
    * test: add guardrails for converter output attrs (#171)
    
    Walks the snapshotted GroupSpec JSON fixtures and asserts:
    - no '_eopf_attrs' anywhere;
    - no 'tile_matrix*' markers (TMS removal);
    - no 'units: digital_counts' on float arrays (decoded scale/offset);
    - every float array under '/measurements/' has '_FillValue' set
      (required for CF NaN-mask round-trip, xarray#11345).
    
    * docs: deprecate v0 references, document r{N} overview layout (#171)
    
    - Drop 'tile_width' parameter from API examples (option removed from CLI/API).
    - Replace '/measurements/r10m/{0,1,2}' nested-pyramid examples with the
      current flat 'r{2**level}' sibling-group layout.
    - Remove the V0 vs V1 split in converter.md / examples.md / quickstart.md;
      the general 'convert' command now produces the same flat layout as
      'convert-s2-optimized'. The S2-optimized section is reframed as a
      feature description rather than a 'V1 vs V0' comparison.
    - Update architecture.md multiscales snippet to the new model
      ('layout' / 'asset' / 'derived_from' / 'transform').
    - Update faq.md inspection snippet to walk the new layout.
    
    * refactor: clean up code formatting and enhance attribute handling in geozarr conversion
    
    * fix(cli-e2e): drop deprecated --tile-width flag from tests and example
    
    * refactor: remove deprecated v0 layout, sanitize attributes, and fix _FillValue handling
    
    * test: add _FillValue masking roundtrip test (#172)
    
    Addresses @vincentsarago's PR review: assert float arrays written with
    the converter's _FillValue convention round-trip through xarray's
    use_zarr_fill_value_as_mask=True so NaN cells come back masked.
    
    * test: drive _FillValue masking test through create_geozarr_dataset (#172)
    
    Replace the standalone xarray write with a real converter run: build a
    small float DataTree, invoke create_geozarr_dataset, then reopen the
    output with use_zarr_fill_value_as_mask=True and assert masking on the
    nodata patch. Exercises the converter's _FillValue + encoding path
    end-to-end, per @vincentsarago's review.
    
    * fix: green CI for #172 — S2 convert flat layout + idna CVE (#177)
    
    * fix(security): bump idna 3.13 -> 3.17 to clear CVE-2026-45409
    
    pip-audit in the Security Audit workflow blocks on idna 3.13
    (CVE-2026-45409, incomplete fix of CVE-2024-3651; fixed in 3.15).
    Bump the transitive pin via uv lock. pip-audit now reports no
    known vulnerabilities.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(cli): route Sentinel-2 convert through the optimized flat layout
    
    Generic `convert` emitted GeoZarr-0.4 multiscales with native data at the
    group root and overviews as nested r{2**N} sub-groups. That tree cannot be
    opened by xr.open_datatree (an overview child's x/y conflict with the
    parent's inherited x/y), so the `info` and `validate` CLI commands crashed
    on the converter's own Sentinel-2 output.
    
    Detect Sentinel-2 inputs and delegate to the existing, tested
    convert_s2_optimized path, which emits flat sibling r{N}m levels (the layout
    PR #172 documents). Non-S2 inputs keep the generic create_geozarr_dataset
    path. Detection is best-effort and never aborts conversion.
    
    Regenerate the geozarr_examples structure snapshots to the flat layout.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * test: make CLI e2e assertions debuggable and fix multiscale round-trip
    
    - The CLI logs errors to stdout via structlog; assert on stdout+stderr in
      test_cli_e2e so a non-zero exit shows the actual error instead of an
      empty stderr.
    - test_multiscale_attrs_round_trip tuplified only one side of its equality,
      so flat-layout level groups carrying list-valued attrs (e.g. spatial:bbox)
      broke a check that is only about JSON list/tuple normalisation. Tuplify
      both sides; the model still round-trips losslessly.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    Co-authored-by: Davis Vann Bennett <[email protected]>
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    Co-authored-by: Emmanuel Mathot <[email protected]>
    
    * feat(s1-stac): add render extension with dual-pol RGB composite preview
    
    The previous S1 RTC preview rendered a single VH band as grayscale with an
    incorrect rescale (0,219), unsuitable for linear gamma0 RTC values. Declare a
    render-extension `renders.rgb` config producing the standard dual-pol false-
    colour composite (R=VV, G=VH, B=VV/VH ratio) with rescale 0–0.1 and tilesize
    256, referencing the preferred orbit group. Downstream titiler-based services
    consume this to generate previews/tiles/tilejson.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * refactor(s1-stac): emit a single rescale pair in the RGB render
    
    A single [min, max] pair applies to all bands in titiler, so emitting three
    identical pairs was redundant (and forced collapse logic in the consumer).
    Same rendered output.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): TEMPORARY name store as s1-rtc-{tile} to match titiler render path (#246)
    
    titiler-eopf reconstructs the store path as
    s3://{bucket}/tests-output/{collection}/{item_id}.zarr and ignores the STAC asset
    href, so the GeoZarr cube must be named after the item id (s1-rtc-{tile}) for new
    tiles to preview. Parse the tile from the s1-rtc- prefix (was s1-grd-rtc-); the
    item id (s1-rtc-{tile}) is unchanged, so filename == item id by construction.
    
    Pairs with the data-pipeline direct-write change (store written at the tests-output
    path) which replaces the #250 auto-copy. Dev-phase, no consumers — disposable test
    data. Revert to s1-grd-rtc- when titiler-eopf#108 (resolve store from href) lands.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): build STAC item from a non-consolidated cube store
    
    build_s1_rtc_stac_item opened the cube with zarr.open_consolidated, which
    raises `ValueError: Consolidated metadata ... not found` when the store lacks
    root consolidated metadata. A per-tile cube grown by appending a time-slice to
    an existing same-orbit group ends up in exactly that state — re-consolidating an
    append on the S3 store is unreliable — so live STAC registration failed on the
    2nd+ same-orbit acquisition for a tile (it broke the Pyrenees S1 RTC soak).
    
    The builder must not require consolidated metadata: titiler reads these stores
    fine without it. Fall back to a direct zarr.open_group(mode="r") read when the
    consolidated metadata is absent; re-raise any other ValueError.
    
    Adds a regression test: build from a 2-slice same-orbit store created with
    consolidate=False (fails before the fix in open_consolidated, passes after).
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): CF-encode `time` at every level for datetime-based rendering (#192) (#193)
    
    Per-acquisition previews render via titiler `sel=time={INTEGER index}` — a
    positional index that breaks once a cube's time axis goes non-monotonic (a
    cross-run append of an earlier-dated scene; proved on 31TEH: `[06-08, 06-07]`
    → the 06-07 item has no preview). Keeping positional indices correct would need
    cube reorder + full re-registration on every append (not scalable).
    
    The deployed titiler (v0.5.0) already does label-based `da.sel(..., method=...)`
    via `open_datatree(decode_times=True)`; the only gap is the cube's `time` array,
    which is a bare int64 with no CF datetime metadata, so it can't be a datetime
    index. Encoding it lets previews select by exact datetime, which works even on a
    non-monotonic axis — no reorder, register only the new item.
    
    titiler picks a multiscale level by zoom (previews use a coarse level), so `time`
    must resolve there. Putting it only at the orbit-group level and inheriting fails
    to open while r10m stays bare int64 (AlignmentError: int64 vs datetime64 on the
    shared dim). So encode `time` consistently at EVERY level:
    - new `_create_time_coordinate_array` + `TIME_CF_ATTRS` (units/calendar/
      standard_name); created at every level in create + append-group paths.
    - the append write resizes/writes `time` at every level (was r10m only).
    - absolute_orbit/relative_orbit/platform stay at r10m (not selected on).
    - stored dtype stays int64 ns, so register_per_acquisition's raw read is unaffected.
    
    Adds TestTimeCFDatetime incl. exact datetime `.sel` on a non-monotonic axis at
    the native and a coarse level. 55 passed.
    
    Refs EOPF-Explorer/data-model#192
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): enrich STAC items & consolidate construction into the library (#195) (#196)
    
    * fix(s1-rtc): enrich cube STAC item + fix vv/vh asset model (#195)
    
    The S1 RTC cube STAC item carried duplicate vv/vh assets with byte-identical
    hrefs (bug #1), exposed no asset for the descending orbit group of a dual-orbit
    cube (bug #2), and was missing most of the metadata the S2 L2A reference carries.
    
    Rework `build_s1_rtc_stac_item`:
    
    - Asset model: replace the two indistinguishable vv/vh assets with one
      `gamma0-rtc-backscatter-{asc,desc}` asset per present orbit group, exposing
      VV/VH as named STAC 1.1 `bands` (no more duplicate hrefs), plus a
      `border-mask-{orbit}` asset for the valid-data mask. Each γ⁰ asset carries
      data_type/nodata/unit/gsd.
    - Identity: add `constellation`, `instruments: [c-sar]`, `gsd` (platform stays
      per-acquisition — a cube can mix S1A/S1C).
    - Projection: add proj:bbox, and proj:shape/proj:transform (best-effort from the
      r10m group attrs).
    - Datacube extension: cube:dimensions (time as a bounded extent — not a values
      list, the cube grows by appending — plus x/y) + cube:variables.
    - Descriptive: title/description; created (earliest acquisition, stable across
      rebuilds) / updated (build time) via the timestamps extension.
    - Fold the corrected render rescale (0.0,0.2) into `_rgb_render`.
    
    Out of scope (tracked follow-ups): statistics (#157), processing:software / DEM
    lineage, and per-product CDSE fields are not in the store; per-acquisition item
    construction is moved in a follow-up commit.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * feat(s1-rtc): move per-acquisition + coverage STAC construction into the library
    
    Consolidate the *pure* S1 RTC STAC construction in eopf_geozarr.stac.s1_rtc so
    item shape has a single owner (the library); registration (STAC-API upsert, S3
    alternates, gateway rewrite, TiTiler links, CDSE discovery) stays in
    data-pipeline.
    
    Adds, ported from the data-pipeline scripts (deployment URLs stripped):
    
    - acquisition_id(tile_id, when)
    - build_s1_rtc_per_acquisition_items(store, *, orbit, collection_id) ->
      list[pystac.Item]: one single-datetime item per cube time slice of an orbit,
      reoriented to that orbit (sat:orbit_state + render expression + only that
      orbit's γ⁰/mask assets), per-slice platform, datacube structure dropped (a
      single acquisition is not a cube). Deployment-agnostic: carries the render
      config + datetime; the registration layer derives the cube-endpoint TiTiler
      links (sel=time={datetime}) from it.
    - Slice / pick_slice / slice_coverages: coverage-based preview-slice selection
      (reads border_mask at r720m).
    
    Ported the construction unit tests (pick_slice, slice_coverages, and the
    per-acquisition construction subset); the link/thumbnail/alternate-asset and
    rescale-override tests stay in data-pipeline as registration concerns.
    
    Follow-up (separate data-pipeline PR): bump the data-model pin, delete the moved
    functions from register_per_acquisition.py / register_v1_s1_rtc.py, and rewire
    them to consume this library output.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): per-acquisition items carry their own orbit footprint
    
    Review follow-up. A per-acquisition item cloned the cube base, so on a dual-orbit
    cube every item inherited the WGS84 union bbox/geometry of both orbits and the
    preferred (ascending) orbit's proj:bbox — advertising a footprint wider than, and
    a data bbox from the wrong orbit relative to, the acquisition's own orbit.
    
    Recompute bbox/geometry/proj:bbox from the run orbit's spatial:bbox (factored the
    bbox->polygon construction into `_bbox_to_geometry`, shared with the cube builder),
    and give per-acquisition items a single-acquisition description rather than the
    cube's "datacube" wording.
    
    Tests: per-acq footprint = run orbit (not the union); `created` = earliest
    acquisition (idempotent across rebuilds); ValueError on an invalid or absent orbit.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): omit orbit_state on dual-orbit cubes; expose datacube dimension sizes
    
    Two review observations from inspecting the live items:
    
    - sat:orbit_state is single-valued, so on a dual-orbit cube (both ascending and
      descending groups present) a single value mislabels half the slices. Set it
      (and declare the SAT extension) only for a single-orbit cube; per-acquisition
      items, which are single-orbit, still carry the real per-orbit value. Ensure
      per-acq items declare the SAT extension even when cloned from a dual-orbit base
      that omitted it.
    
    - cube:dimensions previously carried only `extent`, so the number of elements per
      dimension wasn't visible. The time axis is irregularly sampled, so list its
      discrete `values` (count = number of acquisitions); the regular x/y axes get a
      `step` (size derivable from extent; exact pixel count is also in proj:shape).
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): datacube time axis carries extent only (drop values enumeration)
    
    Enumerating every acquisition as cube:dimensions.time.values does not scale as the
    cube is appended over the mission. No STAC extension provides a scalable temporal
    element-count field (datacube temporal_dimension offers only values/step/extent,
    and step is null for S1's irregular sampling; projection proj:shape is spatial-only;
    raster has no dimension shape). So the time axis carries a bounded `extent` only.
    
    x/y element counts remain available via proj:shape (exact) and the datacube x/y
    extent+step. The number of acquisitions is obtained from the per-acquisition
    collection (STAC search count), which stays correct as the cube grows.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * fix(s1-rtc): correct datacube variable_type, platform value, created, time values
    
    Review of the live items surfaced several metadata issues:
    
    - cube:variables used `type` (a non-standard, silently-ignored key) instead of the
      datacube extension's `variable_type`; border_mask is now `auxiliary`, vv/vh `data`.
    - per-acquisition `platform` carried the store's short code (e.g. `s1a`); normalize
      to the STAC convention `sentinel-1a` (mirrors the Sentinel-2 reference).
    - `created` (timestamps ext) was set to an acquisition time, which misuses the field
      (it means the item's creation instant); omit it and keep `updated` = build time.
    - the cube's irregular time axis now lists its discrete `values` (the acquisition
      instants) so the number of time steps is visible; the regular x/y axes stay
      extent + step (their pixel count is in proj:shape).
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * refactor(s1-rtc): robust EPSG parse for datacube reference_system + clarify per-acq proj inheritance
    
    Review follow-ups (no behaviour change for the EPSG:NNNN stores in use):
    - derive the datacube reference_system via pyproj.CRS(...).to_epsg() instead of
      splitting the proj:code string, so non-"EPSG:NNNN" CRS forms don't break it.
    - comment why per-acquisition items recompute proj:bbox from their orbit but
      inherit proj:code/shape/transform (the MGRS grid is shared across orbits).
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * docs(s1-rtc): note the dual-orbit time axis spans both orbits in the datacube
    
    A dual-orbit cube merges two per-orbit sub-cubes (disjoint time axes) onto a shared
    grid. Modelling orbit as a dimension would imply a sparse, mostly-empty orbit×time
    grid; instead orbit stays an attribute of each acquisition, conveyed via the per-orbit
    assets. Add a `description` on the time dimension so the merged axis isn't misread.
    Single-orbit cubes …
    6 people authored Sep 18, 2026
    Configuration menu
    Copy the full SHA
    f19f423 View commit details
    Browse the repository at this point in the history
  2. deps(deps): update zarr[cast-value-rs] requirement (#269)

    Updates the requirements on [zarr[cast-value-rs]](https://github.com/zarr-developers/zarr-python) to permit the latest version.
    - [Release notes](https://github.com/zarr-developers/zarr-python/releases)
    - [Changelog](https://github.com/zarr-developers/zarr-python/blob/main/docs/release-notes.md)
    - [Commits](zarr-developers/zarr-python@v3.2.0...v3.3.0)
    
    ---
    updated-dependencies:
    - dependency-name: zarr[cast-value-rs]
      dependency-version: 3.3.0
      dependency-type: direct:production
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 18, 2026
    Configuration menu
    Copy the full SHA
    df9f27a View commit details
    Browse the repository at this point in the history
  3. ci(deps): bump astral-sh/setup-uv in the actions group (#268)

    Bumps the actions group with 1 update: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv).
    
    
    Updates `astral-sh/setup-uv` from 9.0.0 to 10.0.1
    - [Release notes](https://github.com/astral-sh/setup-uv/releases)
    - [Commits](astral-sh/setup-uv@c771a70...20cfd1b)
    
    ---
    updated-dependencies:
    - dependency-name: astral-sh/setup-uv
      dependency-version: 10.0.1
      dependency-type: direct:production
      update-type: version-update:semver-major
      dependency-group: actions
    ...
    
    Signed-off-by: dependabot[bot] <[email protected]>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 18, 2026
    Configuration menu
    Copy the full SHA
    3e65de4 View commit details
    Browse the repository at this point in the history
  4. chore: release 0.11.0 (#235)

    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    github-actions[bot] authored Sep 18, 2026
    Configuration menu
    Copy the full SHA
    7d662ed View commit details
    Browse the repository at this point in the history
Loading