diff --git a/README.md b/README.md index f113f74c..a721feec 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ The main Python package provides: - Optional OME-Zarr data model validation during reading - Writes OME-Zarr v0.4 to v0.6 - v0.6 adds RFC-5 coordinate systems and transformations +- Reads and writes the opt-in development version `0.9.dev1`, which adopts RFC-3 + (expanded axis count, names, types and order) - [Sharded Zarr] stores - Optional writing via zarr-python 2, zarr-python 3, [zarrista] or zarrita (TypeScript) - [Anatomical orientation metadata](./docs/rfc4.md) (RFC-4) @@ -67,7 +69,7 @@ The TypeScript package provides universal OME-Zarr support for modern JavaScript - ๐Ÿฆ• **Deno-first** with first-class TypeScript support - ๐Ÿ“ฆ **Universal compatibility** - Works in Deno, Node.js, and browsers - ๐Ÿ” **Type-safe** with Zod schema validation -- ๐Ÿ—‚๏ธ **OME-Zarr v0.4, v0.5, and v0.6** support (v0.6 adds RFC-5 coordinate systems and transformations) +- ๐Ÿ—‚๏ธ **OME-Zarr v0.4, v0.5, and v0.6** support (v0.6 adds RFC-5 coordinate systems and transformations), plus the opt-in development version `0.9.dev1` which adopts RFC-3 (expanded axis count, names, types and order) - ๐ŸŒ **Web ready** - No filesystem dependencies, works with remote stores - ๐Ÿ—๏ธ **Mirrors Python API** - Familiar interfaces for Python users - ๐Ÿ“š **Lazy loading** - Efficient handling of large datasets diff --git a/docs/cli.md b/docs/cli.md index a8bed89b..b0134bfc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -196,8 +196,10 @@ ngff-zarr upgrade src.zarr -o dst.zarr --to 0.5 ``` The target version is selected with `--to` (alias `--version`), one of `0.4`, -`0.5`, or `0.6` (default `0.6`). Add `--validate` to validate the source -metadata against the NGFF schema while reading. For the write-to-new-store mode, +`0.5`, `0.6`, or `0.9.dev1` (default `0.6`). `0.9.dev1` is the development +version that adopts RFC-3; OME publishes no JSON Schema for it yet, so +`--validate` cannot check a store at that version. Add `--validate` to validate +the source metadata against the NGFF schema while reading. For the write-to-new-store mode, `--overwrite` (the default) replaces any pre-existing data at the output store, while `--no-overwrite` refuses to; both flags are ignored for an in-place upgrade, which never overwrites array data. diff --git a/docs/python.md b/docs/python.md index f2615c1c..d68523ae 100644 --- a/docs/python.md +++ b/docs/python.md @@ -213,7 +213,7 @@ To read an OME-Zarr file, use [`from_ngff_zarr`], which returns the >>> multiscales = nz.from_ngff_zarr('cthead1.ome.zarr') ``` -OME-Zarr version 0.1 to 0.6 is supported. Version 0.6 adds RFC-5 coordinate systems and transformations. +OME-Zarr version 0.1 to 0.6 is supported. Version 0.6 adds RFC-5 coordinate systems and transformations. The opt-in development version `0.9.dev1` additionally adopts RFC-3, that expands supported axis counts, names, types and order. The `store` argument accepts: @@ -551,8 +551,10 @@ boundary (0.5/0.6 to 0.4) -- cannot preserve chunk keys and raises a **Write-to-new-store.** When an `output` store distinct from `input` is given, the source is read lazily and re-written to `output` at the requested version -through the standard write pipeline. Every supported transition (0.4, 0.5, 0.6, -in either direction) works in this mode, and the source store is never erased. +through the standard write pipeline. Every transition among 0.4, 0.5, 0.6 and +0.9.dev1 works in this mode whenever the target version can express the axis +model, and the source store is never erased. An RFC-3 axis model is refused +below 0.9.dev1, so a store using one only converts upward. Upgrade a 0.5 store to 0.6 in place, keeping every array chunk: diff --git a/docs/spec_features.md b/docs/spec_features.md index 5aebbad5..a453f580 100644 --- a/docs/spec_features.md +++ b/docs/spec_features.md @@ -38,6 +38,11 @@ supported by `ngff-zarr`. - **OME-Zarr v0.4 to v0.6**: Writes OME-Zarr versions 0.4 to 0.6, including RFC-4 anatomical orientation. v0.6 additionally adds RFC-5 coordinate systems and transformations. +- **OME-Zarr 0.9.dev1**: Reads and writes the development version that adopts + RFC-3, which extends support for the number, names, types and order + of axes. It is opt-in: pass `version="0.9.dev1"` explicitly. The default + target is unchanged, and OME publishes no JSON Schema for it yet, so schema + validation is unavailable at that version. ## High Content Screening (HCS) diff --git a/docs/typescript.md b/docs/typescript.md index b4310b53..43336ac5 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -9,7 +9,7 @@ NGFF-Zarr provides a TypeScript implementation for working with OME-Zarr data st - ๐Ÿฆ• **Deno-first**: Built for Deno with first-class TypeScript support - ๐Ÿ“ฆ **Universal compatibility**: Works in Deno, Node.js, and browsers - ๐Ÿ” **Type-safe**: Full TypeScript support with Zod schema validation -- ๐Ÿ—‚๏ธ **OME-Zarr support**: Read and write OME-Zarr v0.4, v0.5, and v0.6 (v0.6 adds RFC-5 coordinate systems and transformations) +- ๐Ÿ—‚๏ธ **OME-Zarr support**: Read and write OME-Zarr v0.4, v0.5, and v0.6 (v0.6 adds RFC-5 coordinate systems and transformations), plus the opt-in development version `0.9.dev1` which adopts RFC-3 (extended axis count, names, types and order) - ๐Ÿงช **Well-tested**: Comprehensive test suite with browser validation - ๐Ÿ—๏ธ **Mirrors Python API**: Familiar interfaces for Python users - ๐Ÿ“– **Lazy loading**: Efficient handling of large datasets @@ -378,7 +378,7 @@ async function fromNgffZarr( store: string | MemoryStore | FetchStore, options?: { validate?: boolean; - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; } ): Promise ``` @@ -414,7 +414,7 @@ async function toNgffZarr( store: string, multiscales: NgffMultiscales, options?: { - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; chunksPerShard?: number | number[] | Record; } ): Promise @@ -815,7 +815,7 @@ function upgradeOmeZarr( input: string | MemoryStore | FetchStore | Readable, options?: { output?: string | MemoryStore; // FetchStore is read-only, not a destination - version?: "0.4" | "0.5" | "0.6"; // default "0.6" + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; // default "0.6" validate?: boolean; overwrite?: boolean; // write-to-new-store only; default true }, @@ -870,8 +870,10 @@ await upgradeOmeZarr(store, { version: "0.6" }); **Write-to-new-store.** When `output` is a store distinct from `input`, the source is read lazily and re-written to `output` at the requested version -through the standard write pipeline. Every supported transition (0.4, 0.5, 0.6, -in either direction) works in this mode, and the source store is never mutated. +through the standard write pipeline. Every transition among 0.4, 0.5, 0.6 and +0.9.dev1 works in this mode whenever the target version can express the axis +model, and the source store is never mutated. An RFC-3 axis model is refused +below 0.9.dev1, so a store using one only converts upward. ```typescript import { upgradeOmeZarr, type MemoryStore } from "@fideus-labs/ngff-zarr"; diff --git a/docs/validation/api.md b/docs/validation/api.md index 88abe5b3..4733f151 100644 --- a/docs/validation/api.md +++ b/docs/validation/api.md @@ -41,11 +41,12 @@ from ngff_zarr import ( ) ``` -`validate_structural(metadata, options=None)` runs the image/multiscales rules -against either metadata model โ€” the flat v0.4/v0.5 `Metadata` or the v0.6 -`coordinateSystems` one that `from_ome_zarr` and `to_multiscales` return (see -[[overview]]). When `options` is `None` it uses `ValidateOptions()`, i.e. -`ValidationLevel.STRICT`. A `ValidationError` carries `.rule` (a `SpecRule`), +`validate_structural(metadata, options=None, version=None)` runs the +image/multiscales rules. When `options` is `None` it uses `ValidateOptions()`, +i.e. `ValidationLevel.STRICT`. `version` is the OME-Zarr version the metadata +declares; the axis rules are inert for the versions that adopt the RFC-3 axis +model (see [[parity]]), so omitting it holds every store to the v0.4 axis +caps. A `ValidationError` carries `.rule` (a `SpecRule`), `.message` (str), and `.location` (`str | None`); `str(exc)` is `Spec rule [] violated: `. @@ -99,9 +100,12 @@ import { } from "@fideus-labs/ngff-zarr"; ``` -`validateStructural(metadata, options?)` runs the image/multiscales rules. When -`options` (or its `level`) is omitted, the level resolves to -`ValidationLevel.Strict`. A `ValidationError` carries a readonly `rule` +`validateStructural(metadata, options?, version?)` runs the image/multiscales +rules. When `options` (or its `level`) is omitted, the level resolves to +`ValidationLevel.Strict`. `version` is the OME-Zarr version the metadata +declares; the axis rules are inert for the versions that adopt the RFC-3 axis +model (see [[parity]]), so omitting it holds every store to the v0.4 axis +caps. A `ValidationError` carries a readonly `rule` (`SpecRule`) and an optional `location` (`string`); its `message` is `Spec rule [] violated: `. diff --git a/docs/validation/parity.md b/docs/validation/parity.md index 66677717..a7bb1f27 100644 --- a/docs/validation/parity.md +++ b/docs/validation/parity.md @@ -26,7 +26,7 @@ for usage, see [[api]]. ## The contract -Both ports must agree on four observable dimensions: +Both ports must agree on five observable dimensions: 1. **Rule identifiers** โ€” the same `SpecRule` string values, in the same canonical declaration/iteration order. @@ -38,6 +38,10 @@ Both ports must agree on four observable dimensions: (`validate_structural` / `validateStructural`) evaluates rules in the same canonical order, so the same metadata yields the same first violation in both languages. +5. **The RFC-3 version set** โ€” the four axis rules take a `version` and are + inert for the versions that adopt the RFC-3 free-form axis model. Both ports + must treat exactly the same version strings as RFC-3, or the same metadata + validates in one language and not the other. Because both test suites assert these facts against the **same literal identifier list**, adding, removing, renaming, or reordering a rule โ€” or @@ -53,23 +57,34 @@ The canonical `SpecRule` set, in evaluation order: 1. `axis-count` 2. `axis-type` 3. `axis-order` -4. `scale-length-mismatch` -5. `global-coord-transform-after-per-level` -6. `dataset-order-highest-to-lowest` -7. `omero-channel-color-format` -8. `axis-orientation-anatomical-type` -9. `axis-orientation-on-non-space` -10. `axis-orientation-unique-axis` -11. `zarr-format` -12. `ome-namespace` -13. `plate-row-index-consistency` -14. `well-acquisition-missing` - -Entries 11โ€“12 are the v0.5 namespacing rules; they fire only for v0.5 metadata +4. `axis-names-unique` +5. `scale-length-mismatch` +6. `global-coord-transform-after-per-level` +7. `dataset-order-highest-to-lowest` +8. `omero-channel-color-format` +9. `axis-orientation-anatomical-type` +10. `axis-orientation-on-non-space` +11. `axis-orientation-unique-axis` +12. `zarr-format` +13. `ome-namespace` +14. `plate-row-index-consistency` +15. `well-acquisition-missing` + +Entries 12โ€“13 are the v0.5 namespacing rules; they fire only for v0.5 metadata and are inert for v0.4. Each suite pins this list as a `CANONICAL_SPEC_RULE_IDS` literal โ€” byte-identical between the two languages so the tests are line-for-line comparable. +The versions that adopt the RFC-3 axis model are pinned the same way, as a +`CANONICAL_RFC3_VERSIONS` literal: + +1. `0.9.dev1` + +Rules 1โ€“3 (and the spatial arm of 3) are inert at those versions and enforced +at every other. Rule 4, `axis-names-unique`, is never inert: RFC-3 *adds* it, +and ngff-zarr applies it at all versions as a strictness choice (see +[[rule-reference]]). + ## The parity tests | Language | Test file | @@ -93,17 +108,21 @@ Each suite independently locks: `strict`, and `strict` is the default. - **Fail-fast evaluation order** โ€” driving the orchestrator with v0.4 metadata that violates the earliest rule plus every later rule, then repairing exactly - one rule per stage across ten stages, the rule raised at each stage builds a - sequence equal to a shared `EXPECTED_EVALUATION_ORDER`. This proves every rule - is evaluated strictly before all rules after it; a final, fully-repaired + one rule per stage across eleven stages, the rule raised at each stage builds + a sequence equal to a shared `EXPECTED_EVALUATION_ORDER`. This proves every + rule is evaluated strictly before all rules after it; a final, fully-repaired metadata is accepted with no violation. - -The two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) run last in the -image orchestrator but are inert for the v0.4 metadata the order test exercises, -so they never appear in `EXPECTED_EVALUATION_ORDER`. The two HCS rules -(`plate-row-index-consistency`, `well-acquisition-missing`) are deliberately -absent from the image orchestrator's evaluation order; they are dispatched by -the separate plate/well orchestrators (see [[rule-reference]]). +- **RFC-3 version set** โ€” the axis rules are inert at exactly the versions in + `CANONICAL_RFC3_VERSIONS` and enforced at every other supported version and + when no version is given, asserted through the public orchestrator rather + than through the internal predicate. + +Four of the fifteen rules never appear in `EXPECTED_EVALUATION_ORDER`, each for +its own reason. The two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) +run last in the image orchestrator but are inert for the v0.4 metadata the order +test exercises. The two HCS rules (`plate-row-index-consistency`, +`well-acquisition-missing`) are deliberately absent; they are dispatched by the +separate plate/well orchestrators (see [[rule-reference]]). ## Sanctioned TypeScript-only adaptations @@ -115,12 +134,12 @@ departures in the TypeScript suite: Python an `UPPER_SNAKE` `str, Enum` (`SpecRule.AXIS_COUNT`, `ValidationLevel.STRICT`). The `.value` strings are identical, so the cross-language assertions hold. -- **Axis-name labels and the default check** โ€” TypeScript's ordering fixtures - use valid `SupportedDims` members in place of the Python helper's free-form - axis names, and assert the `strict` default *observably* (because the - TypeScript options type is a bare interface with no constructor) rather than - through a constructable options object. Neither alters the observed rule set - or evaluation order. +- **The default check** โ€” TypeScript asserts the `strict` default *observably*, + because its options type is a bare interface with no constructor, rather than + through a constructable options object. This alters neither the observed rule + set nor the evaluation order. The ordering fixtures themselves are identical + in both ports: `Axis.name` is `AxisName`, so TypeScript uses the same + free-form axis names as Python. Internal message-fidelity helpers in the TypeScript port (rendering whole-number scales with a trailing `.0`, Python-style quoted name lists, and `repr`-style diff --git a/docs/validation/rule-reference.md b/docs/validation/rule-reference.md index 85bc2749..2aea8ae1 100644 --- a/docs/validation/rule-reference.md +++ b/docs/validation/rule-reference.md @@ -36,20 +36,21 @@ the `SpecRule` enum declares them and the orchestrators evaluate them. | # | Identifier | Applies to | What it checks | Spec MUST | Example location | | -- | ---------- | ---------- | -------------- | --------- | ---------------- | -| 1 | `axis-count` | images/multiscales | The number of axes is within the closed range 2โ€“5. | v0.4: between 2 and 5 axes, inclusive. | `multiscales[0].axes` | -| 2 | `axis-type` | images/multiscales | At most one `time` axis and at most one `channel` axis. | v0.4: โ‰ค 1 time axis and โ‰ค 1 channel axis. | `multiscales[0].axes` | -| 3 | `axis-order` | images/multiscales | Axes ordered time โ†’ channel โ†’ space; at most 3 space axes; the space-axis names are the length-N suffix of `(z, y, x)`. | v0.4: axes ordered time, then channel, then space, with spatial names a suffix of `(z, y, x)`. | `multiscales[0].axes[1]` | -| 4 | `scale-length-mismatch` | images/multiscales | Every `scale`/`translation` vector length equals the axis count, for both the global and per-dataset transforms. At v0.6 the multiscale-level transforms map between named coordinate systems, so only the per-dataset ones are measured. | v0.4: each scale/translation has exactly one entry per axis. | `multiscales[0].datasets[2].coordinateTransformations[0]` | -| 5 | `global-coord-transform-after-per-level` | images/multiscales | Exactly one `scale` per dataset, and a `translation` must follow โ€” not precede โ€” its `scale`. | v0.4: each dataset defines exactly one scale; a translation follows its scale. | `multiscales[0].datasets[1].coordinateTransformations` | -| 6 | `dataset-order-highest-to-lowest` | images/multiscales | Datasets ordered finest โ†’ coarsest; the spatial scale must not decrease as the level index rises. | v0.4: multiscale datasets ordered from highest to lowest resolution. | `multiscales[0].datasets[2]` | -| 7 | `omero-channel-color-format` | OMERO | Each OMERO channel `color` is exactly six hexadecimal digits (RGB). | v0.4: OMERO channel color is 6 hex digits. | `multiscales[0].omero.channels[0].color` | -| 8 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. | RFC 4: an orientation's `type` is `anatomical`. | `multiscales[0].axes` | -| 9 | `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. | RFC 4: orientation applies to spatial axes only. | `multiscales[0].axes[0]` | -| 10 | `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. | RFC 4: each spatial axis describes a distinct anatomical axis. | `multiscales[0].axes` | -| 11 | `zarr-format` | images/multiscales (v0.5) | A v0.5 entry implies a Zarr v3 store; a `zarr_format` value that leaked into the entry must be exactly `3`. Inert for v0.4. | v0.5: metadata is backed by a Zarr v3 store (`zarr_format == 3`). | `multiscales[0]` | -| 12 | `ome-namespace` | images/multiscales (v0.5) | A v0.5 entry must not retain a group-level `ome` or `multiscales` wrapper key โ€” the `ome` namespace wraps the group attributes, not each entry. Inert for v0.4. | v0.5: multiscales live under the top-level `ome` namespace, with `version` hoisted to `ome.version`. | `multiscales[0]` | -| 13 | `plate-row-index-consistency` | HCS plate | Each well's `path` is `/`, naming declared row/column entries, with `rowIndex`/`columnIndex` equal to those entries' positions. | v0.4: well `rowIndex`/`columnIndex` match the named row/column positions in `plate.rows`/`plate.columns`. | `plate.wells[3]` | -| 14 | `well-acquisition-missing` | HCS well | When the plate declares more than one acquisition, every well image references one via `acquisition`. | v0.4: with multiple acquisitions, each well image references an acquisition. | `well.images[0].acquisition` | +| 1 | `axis-count` | images/multiscales | The number of axes is within the closed range 2โ€“5. Inert for 0.9.dev1. | v0.4: between 2 and 5 axes, inclusive. | `multiscales[0].axes` | +| 2 | `axis-type` | images/multiscales | At most one `time` axis and at most one `channel` axis. Inert for 0.9.dev1. | v0.4: โ‰ค 1 time axis and โ‰ค 1 channel axis. | `multiscales[0].axes` | +| 3 | `axis-order` | images/multiscales | Axes ordered time โ†’ channel โ†’ space; 2 or 3 space axes; the space-axis names are the length-N suffix of `(z, y, x)`. Inert for 0.9.dev1; a v0.6 `array` coordinate system is exempt from the two-space-axis floor, but not from the three-axis cap or the suffix check. | v0.4: axes ordered time, then channel, then space, with 2 or 3 `space` axes whose names are a suffix of `(z, y, x)`. | `multiscales[0].axes[1]` | +| 4 | `axis-names-unique` | images/multiscales | No two axes share a `name`. Never inert. | RFC-3 rule 5: axis names MUST NOT be repeated within a dataset. No released schema states it, so below 0.9.dev1 this is a strictness choice, not a spec MUST of those versions. | `multiscales[0].axes[2]` | +| 5 | `scale-length-mismatch` | images/multiscales | Every `scale`/`translation` vector length equals the axis count, for both the global and per-dataset transforms. At v0.6 the multiscale-level transforms map between named coordinate systems, so only the per-dataset ones are measured. | v0.4: each scale/translation has exactly one entry per axis. | `multiscales[0].datasets[2].coordinateTransformations[0]` | +| 6 | `global-coord-transform-after-per-level` | images/multiscales | Exactly one `scale` per dataset, and a `translation` must follow โ€” not precede โ€” its `scale`. | v0.4: each dataset defines exactly one scale; a translation follows its scale. | `multiscales[0].datasets[1].coordinateTransformations` | +| 7 | `dataset-order-highest-to-lowest` | images/multiscales | Datasets ordered finest โ†’ coarsest; the spatial scale must not decrease as the level index rises. | v0.4: multiscale datasets ordered from highest to lowest resolution. | `multiscales[0].datasets[2]` | +| 8 | `omero-channel-color-format` | OMERO | Each OMERO channel `color` is exactly six hexadecimal digits (RGB). | v0.4: OMERO channel color is 6 hex digits. | `multiscales[0].omero.channels[0].color` | +| 9 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. | RFC 4: an orientation's `type` is `anatomical`. | `multiscales[0].axes` | +| 10| `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. | RFC 4: orientation applies to spatial axes only. | `multiscales[0].axes[0]` | +| 11| `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. | RFC 4: each spatial axis describes a distinct anatomical axis. | `multiscales[0].axes` | +| 12| `zarr-format` | images/multiscales (v0.5) | A v0.5 entry implies a Zarr v3 store; a `zarr_format` value that leaked into the entry must be exactly `3`. Inert for v0.4. | v0.5: metadata is backed by a Zarr v3 store (`zarr_format == 3`). | `multiscales[0]` | +| 13| `ome-namespace` | images/multiscales (v0.5) | A v0.5 entry must not retain a group-level `ome` or `multiscales` wrapper key โ€” the `ome` namespace wraps the group attributes, not each entry. Inert for v0.4. | v0.5: multiscales live under the top-level `ome` namespace, with `version` hoisted to `ome.version`. | `multiscales[0]` | +| 14| `plate-row-index-consistency` | HCS plate | Each well's `path` is `/`, naming declared row/column entries, with `rowIndex`/`columnIndex` equal to those entries' positions. | v0.4: well `rowIndex`/`columnIndex` match the named row/column positions in `plate.rows`/`plate.columns`. | `plate.wells[3]` | +| 15| `well-acquisition-missing` | HCS well | When the plate declares more than one acquisition, every well image references one via `acquisition`. | v0.4: with multiple acquisitions, each well image references an acquisition. | `well.images[0].acquisition` | ## Evaluation order and orchestrators @@ -57,31 +58,42 @@ The rules are evaluated fail-fast: the first violation raises a `ValidationError` and later rules do not run. Three orchestrators dispatch the rules: -- **`validate_structural` / `validateStructural`** evaluates rules **1โ€“12** (the +- **`validate_structural` / `validateStructural`** evaluates rules **1โ€“13** (the image/multiscales rules, including the OMERO color check, the RFC 4 - orientation checks, and the two v0.5 namespacing rules). Rules 3 and 5 each + orientation checks, and the two v0.5 namespacing rules). Rules 3 and 6 each have two internal enforcement points that share a single identifier โ€” axis class-ordering then spatial-name ordering for `axis-order`; per-dataset scale-count then transform-ordering for - `global-coord-transform-after-per-level`. The v0.5 namespacing rules (11 and - 12) run last and are inert for v0.4 input. The orientation rules - `axis-orientation-on-non-space` (9) and `axis-orientation-unique-axis` (10) - fire only for specific orientation shapes, so the linear fail-fast cascade for - a v0.4 metadata is a 10-step sequence ending at - `axis-orientation-anatomical-type` (identifiers 1โ€“8). -- **`validate_plate` / `validatePlate`** evaluates rule **13** + `global-coord-transform-after-per-level`. The v0.5 namespacing rules (12 and + 13) run last and are inert for v0.4 input. The orientation rules + `axis-orientation-on-non-space` (10) and `axis-orientation-unique-axis` (11) + fire only for specific axis shapes, so the linear fail-fast cascade for a v0.4 + metadata is an 11-step sequence ending at + `axis-orientation-anatomical-type`. +- **`validate_plate` / `validatePlate`** evaluates rule **14** (`plate-row-index-consistency`). -- **`validate_well` / `validateWell`** evaluates rule **14** +- **`validate_well` / `validateWell`** evaluates rule **15** (`well-acquisition-missing`), in the context of its parent plate. -The two HCS rules (13 and 14) are deliberately absent from the image +The two HCS rules (14 and 15) are deliberately absent from the image orchestrator's order; they operate on separate metadata objects. The exact fail-fast sequence is locked by the parity tests described in [[parity]]. +A fourth dispatcher sits on the write path: `_gate_axis_model` / +`gateAxisModel` refuses to serialize an axis model the target version cannot +express. It is **not** the same pass as `validate_structural`. It runs rules +**1โ€“4** only, and it applies them to *every* coordinate system the target +version serializes, where the structural orchestrator reads one flat axis list. +It sees only what will be written: a v0.4/v0.5 target writes a single flat +`axes` and drops the coordinate systems, so a system the downgrade discards is +not gated. Python reaches that set by gating after `Metadata.to_version`; +TypeScript by checking the target version in `axisViews`. + ## Rule categories at a glance - **Image / multiscales** โ€” `axis-count`, `axis-type`, `axis-order`, - `scale-length-mismatch`, `global-coord-transform-after-per-level`, + `axis-names-unique`, `scale-length-mismatch`, + `global-coord-transform-after-per-level`, `dataset-order-highest-to-lowest`. - **OMERO** โ€” `omero-channel-color-format`. - **RFC 4 orientation** โ€” `axis-orientation-anatomical-type`, diff --git a/py/ngff_zarr/__init__.py b/py/ngff_zarr/__init__.py index e94da07f..7e01c92b 100644 --- a/py/ngff_zarr/__init__.py +++ b/py/ngff_zarr/__init__.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: MIT from .__about__ import __version__ -from ._supported_versions import SUPPORTED_VERSIONS +from ._supported_versions import SUPPORTED_VERSIONS, NgffVersion from .cli_input_to_ngff_image import cli_input_to_ngff_image from .codecs import codec_from_name, get_available_codecs from .compute_omero import ( @@ -82,6 +82,7 @@ from .v04.zarr_metadata import ( AxesType, Axis, + AxisUnit, Dataset, Identity, Metadata, @@ -110,6 +111,7 @@ __all__ = [ "__version__", "SUPPORTED_VERSIONS", + "NgffVersion", "config", # OMERO computation "compute_omero_from_ngff_image", @@ -155,6 +157,7 @@ "Metadata", "MethodMetadata", "AxesType", + "AxisUnit", "SpatialDims", "SupportedDims", "SpaceUnits", diff --git a/py/ngff_zarr/_supported_versions.py b/py/ngff_zarr/_supported_versions.py index 7f4f6ee3..8b2012cc 100644 --- a/py/ngff_zarr/_supported_versions.py +++ b/py/ngff_zarr/_supported_versions.py @@ -13,6 +13,10 @@ class NgffVersion(StrEnum): V05 = "0.5" V06 = "0.6" V06dev4 = "0.6.dev4" + # OME-Zarr 0.9 in development: 0.6 plus RFC-3 (any axis count, names, + # types and ordering). LATEST stays 0.6.dev4, so 0.9.dev1 is opt-in. + V09dev1 = "0.9.dev1" + # An alias of V06dev4 (same value): it must stay last. LATEST = "0.6.dev4" @@ -25,4 +29,20 @@ class NgffVersion(StrEnum): NgffVersion.V05, NgffVersion.V06, NgffVersion.V06dev4, + NgffVersion.V09dev1, ) + + +def is_v06_version(version: object | None) -> bool: + """Whether ``version`` identifies OME-Zarr v0.6, dev releases included. + + Mirrors the TypeScript port's ``isV06Version`` so a store written by + either implementation is recognized the same way. Anything that is not a + string -- ``None`` included -- is not v0.6. + + Tested with ``isinstance`` rather than ``str()``: a :class:`NgffVersion` + member is a ``str`` subclass under both the stdlib ``StrEnum`` (3.11+) and + the backport above, but only the former renders as its value under + ``str()``; the backport renders as ``"NgffVersion.V06dev4"``. + """ + return isinstance(version, str) and version.startswith("0.6") diff --git a/py/ngff_zarr/cli.py b/py/ngff_zarr/cli.py index 16f841e2..79c74b22 100755 --- a/py/ngff_zarr/cli.py +++ b/py/ngff_zarr/cli.py @@ -1260,9 +1260,11 @@ def _upgrade_main(argv: list[str] | None = None) -> None: "--to", "--version", dest="version", - choices=["0.4", "0.5", "0.6"], + choices=["0.4", "0.5", "0.6", "0.9.dev1"], default="0.6", - help="Target OME-Zarr version (default: 0.6).", + help="Target OME-Zarr version (default: 0.6). 0.9.dev1 is the " + "development version that adopts RFC-3; it is opt-in and has no " + "published JSON Schema, so --validate cannot check it.", ) parser.add_argument( "--validate", diff --git a/py/ngff_zarr/from_ngff_zarr.py b/py/ngff_zarr/from_ngff_zarr.py index 3e369058..f47cfb62 100644 --- a/py/ngff_zarr/from_ngff_zarr.py +++ b/py/ngff_zarr/from_ngff_zarr.py @@ -358,7 +358,19 @@ def from_ome_zarr( "within the plate (e.g., 'plate.zarr/A/1/0' for well A1, field 0)." ) - if version.startswith("0.6"): + if version == "0.9.dev1": + from .v09.zarr_metadata import Metadata + + # No 0.9.dev1 JSON Schema is published; `validate` is forwarded so + # validate=True reports that rather than validating nothing. + metadata_obj, images = Metadata._from_zarr_attrs( + root_attrs, store, validate=validate, subpath=subpath + ) + method, method_type, method_metadata = _extract_method_metadata( + root_attrs["ome"]["multiscales"][0] + ) + + elif version.startswith("0.6"): from .v06.zarr_metadata import Metadata metadata_obj, images = Metadata._from_zarr_attrs( @@ -416,7 +428,11 @@ def from_ome_zarr( root_attrs["multiscales"][0] ) - metadata_obj = metadata_obj.to_version("0.6") + # Normalize to the richest model the store can express. A 0.9.dev1 store + # stays 0.9.dev1; downgrading it to 0.6 would discard its RFC-3 axis model. + metadata_obj = metadata_obj.to_version( + "0.9.dev1" if version == "0.9.dev1" else "0.6" + ) metadata_obj.type = method_type metadata_obj.metadata = method_metadata diff --git a/py/ngff_zarr/multiscales.py b/py/ngff_zarr/multiscales.py index c6d9a079..6cd78a98 100644 --- a/py/ngff_zarr/multiscales.py +++ b/py/ngff_zarr/multiscales.py @@ -12,12 +12,13 @@ from .v04.zarr_metadata import Metadata as Metadata_v04 from .v05.zarr_metadata import Metadata as Metadata_v05 from .v06.zarr_metadata import Metadata as Metadata_v06 +from .v09.zarr_metadata import Metadata as Metadata_v09 @dataclass class NgffMultiscales: images: list[NgffImage] - metadata: Metadata_v04 | Metadata_v05 | Metadata_v06 + metadata: Metadata_v04 | Metadata_v05 | Metadata_v06 | Metadata_v09 scale_factors: Sequence[dict[str, int] | int] | None = None method: Methods | None = None chunks: ( diff --git a/py/ngff_zarr/ngff_image.py b/py/ngff_zarr/ngff_image.py index ce5a2542..16bbf46b 100644 --- a/py/ngff_zarr/ngff_image.py +++ b/py/ngff_zarr/ngff_image.py @@ -6,7 +6,7 @@ from dask.array.core import Array as DaskArray from .rfc4 import AnatomicalOrientation -from .v04.zarr_metadata import Units +from .v04.zarr_metadata import AxisUnit ComputedCallback = Callable[[], None] @@ -18,7 +18,7 @@ class NgffImage: scale: dict[str, float] translation: dict[str, float] name: str = "image" - axes_units: Mapping[str, Units] | None = None + axes_units: Mapping[str, AxisUnit] | None = None axes_orientations: Mapping[str, AnatomicalOrientation] | None = None axes_types: Mapping[str, str] | None = None channel_names: list[str] | None = None diff --git a/py/ngff_zarr/structural_validation.py b/py/ngff_zarr/structural_validation.py index 70bc0c54..a15d1b3b 100644 --- a/py/ngff_zarr/structural_validation.py +++ b/py/ngff_zarr/structural_validation.py @@ -45,6 +45,9 @@ # axis-order # time before channel before space; space names a suffix of (z, y, x) # e.g. multiscales[0].axes[1] +# axis-names-unique +# no two axes share a name (RFC-3 rule 5) +# e.g. multiscales[0].axes[2] # scale-length-mismatch # every scale/translation length == axis count (global + per-dataset) # e.g. multiscales[0].datasets[2].coordinateTransformations[0] @@ -85,6 +88,8 @@ from enum import StrEnum from typing import TYPE_CHECKING, Any +from ._supported_versions import NgffVersion, is_v06_version + if TYPE_CHECKING: from .v04.zarr_metadata import Axis, Dataset, Metadata, Plate, Transform, Well @@ -101,6 +106,7 @@ class SpecRule(StrEnum): AXIS_COUNT = "axis-count" AXIS_TYPE = "axis-type" AXIS_ORDER = "axis-order" + AXIS_NAMES_UNIQUE = "axis-names-unique" SCALE_LENGTH_MISMATCH = "scale-length-mismatch" GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL = "global-coord-transform-after-per-level" DATASET_ORDER_HIGHEST_TO_LOWEST = "dataset-order-highest-to-lowest" @@ -328,11 +334,45 @@ def _flat_model(metadata: Any) -> Any: # 3 -> ("z", "y", "x") (see ``validate_spatial_axis_order``). _SPATIAL_AXIS_NAMES = ("z", "y", "x") +#: The v0.6 ``axes`` schema is a ``oneOf``: either 2 or 3 ``space`` axes, or +#: two or more ``array`` axes. An RFC-5 array coordinate system takes the +#: second branch and declares no ``space`` axis at all (see +#: ``_takes_array_schema_branch``). +_MIN_ARRAY_AXES = 2 + + +def _takes_array_schema_branch(axes: list, version: object | None) -> bool: + """Whether ``axes`` satisfies the ``array`` arm of the v0.6 axes schema. + + Only v0.6 has that arm; the v0.4 and v0.5 schemas require 2 or 3 ``space`` + axes unconditionally, so the space-axis floor is not relaxed for them. + """ + if not is_v06_version(version): + return False + return sum(1 for ax in axes if ax.type == "array") >= _MIN_ARRAY_AXES -def validate_axis_count(metadata: Metadata) -> None: + +def is_rfc3_axis_model_allowed(version: object | None = None) -> bool: + """Whether ``version`` adopts the RFC-3 free-form axis model. + + Only OME-Zarr ``0.9.dev1`` does: the bundled 0.4, 0.5 and 0.6 ``axes`` + schemas all cap the axis count at 5, and require 2 or 3 ``space`` axes + (0.6 excepted for an ``array`` coordinate system, see + :func:`_takes_array_schema_branch`). ``None`` applies the restrictions. + + Compared by string equality: a dev release precedes its release, so + ``packaging.version.parse("0.9.dev1")`` sorts below ``0.9`` and a ``>=`` + test against ``"0.9"`` is ``False``. + """ + return version is not None and version == NgffVersion.V09dev1 + + +def validate_axis_count(metadata: Metadata, version: object | None = None) -> None: """Validate that the axis count is within the v0.4-permitted range. - OME-Zarr v0.4 requires between 2 and 5 axes, inclusive. + OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes, inclusive. + Inert when ``version`` adopts RFC-3 (see + :func:`is_rfc3_axis_model_allowed`). Raises ------ @@ -340,19 +380,24 @@ def validate_axis_count(metadata: Metadata) -> None: With :attr:`SpecRule.AXIS_COUNT` when ``len(metadata.axes)`` lies outside ``2..=5``; location ``multiscales[0].axes``. """ + if is_rfc3_axis_model_allowed(version): + return count = len(metadata.axes) if not (2 <= count <= 5): raise ValidationError( SpecRule.AXIS_COUNT, - f"OME-Zarr v0.4 requires between 2 and 5 axes, inclusive; found {count}.", + f"OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes," + f" inclusive; found {count}.", "multiscales[0].axes", ) -def validate_axis_type(metadata: Metadata) -> None: +def validate_axis_type(metadata: Metadata, version: object | None = None) -> None: """Validate axis-type multiplicity. At most one ``time`` axis and at most one ``channel`` axis may be present. + Inert when ``version`` adopts RFC-3 (see + :func:`is_rfc3_axis_model_allowed`). Raises ------ @@ -361,6 +406,8 @@ def validate_axis_type(metadata: Metadata) -> None: more than one ``channel`` axis is present; location ``multiscales[0].axes``. """ + if is_rfc3_axis_model_allowed(version): + return time_count = sum(1 for ax in metadata.axes if ax.type == "time") if time_count > 1: raise ValidationError( @@ -377,13 +424,14 @@ def validate_axis_type(metadata: Metadata) -> None: ) -def validate_axis_order(metadata: Metadata) -> None: +def validate_axis_order(metadata: Metadata, version: object | None = None) -> None: """Validate the class ordering of axes. Axes are ranked by type (``time`` < ``channel`` < ``space``) and must be listed in non-decreasing rank order: every ``time`` axis precedes every ``channel`` axis, which precedes every ``space`` axis. The first adjacent - pair that inverts this ranking is reported. + pair that inverts this ranking is reported. Inert when ``version`` adopts + RFC-3 (see :func:`is_rfc3_axis_model_allowed`). Raises ------ @@ -392,6 +440,8 @@ def validate_axis_order(metadata: Metadata) -> None: lower-ranked axis type follows a higher-ranked one; location ``multiscales[0].axes[i+1]``. """ + if is_rfc3_axis_model_allowed(version): + return axes = metadata.axes for i in range(len(axes) - 1): current = axes[i] @@ -410,27 +460,42 @@ def validate_axis_order(metadata: Metadata) -> None: ) -def validate_spatial_axis_order(metadata: Metadata) -> None: +def validate_spatial_axis_order( + metadata: Metadata, version: object | None = None +) -> None: """Validate the count and names of spatial axes. - The ``space`` axes, taken in order, must be the matching-length suffix of - ``(z, y, x)``: one spatial axis must be ``(x,)``, two must be - ``(y, x)``, and three must be ``(z, y, x)``. At most three ``space`` axes - are permitted. + The spec requires 2 or 3 ``space`` axes, and, taken in order, they must be + the matching-length suffix of ``(z, y, x)``: two spatial axes must be + ``(y, x)`` and three must be ``(z, y, x)``. Inert when ``version`` adopts + RFC-3 (see :func:`is_rfc3_axis_model_allowed`). + + At v0.6 the space-axis floor does not apply to an RFC-5 *array* coordinate + system, which the schema's ``oneOf`` admits with no ``space`` axis at all + (see :func:`_takes_array_schema_branch`). Raises ------ ValidationError - With :attr:`SpecRule.AXIS_ORDER` when there are more than three - ``space`` axes, or when their names are not the expected suffix of + With :attr:`SpecRule.AXIS_ORDER` when there are not 2 or 3 ``space`` + axes, or when their names are not the expected suffix of ``(z, y, x)``. """ + if is_rfc3_axis_model_allowed(version): + return space_indices = [i for i, ax in enumerate(metadata.axes) if ax.type == "space"] count = len(space_indices) if count > 3: raise ValidationError( SpecRule.AXIS_ORDER, - f"OME-Zarr v0.4 permits at most 3 'space' axes; found {count}.", + f"OME-Zarr v0.4, v0.5 and v0.6 permit at most 3 'space' axes;" + f" found {count}.", + "multiscales[0].axes", + ) + if count < 2 and not _takes_array_schema_branch(metadata.axes, version): + raise ValidationError( + SpecRule.AXIS_ORDER, + f"OME-Zarr v0.4, v0.5 and v0.6 require 2 or 3 'space' axes; found {count}.", "multiscales[0].axes", ) expected = _SPATIAL_AXIS_NAMES[len(_SPATIAL_AXIS_NAMES) - count :] @@ -445,6 +510,40 @@ def validate_spatial_axis_order(metadata: Metadata) -> None: ) +def validate_axis_names_unique( + metadata: Metadata, version: object | None = None +) -> None: + """Validate that axis names are unique within the dataset. + + This rule is **never** inert. It states RFC-3 rule 5, "axis names MUST NOT + be repeated within a dataset". No released schema carries it: v0.4 and v0.5 + say nothing, and v0.6 has only a non-normative ``description`` on + ``axis.name``. Below 0.9.dev1 it is therefore a strictness choice rather + than a spec MUST of those versions. + + RFC-3 rule 5 also says names SHOULD NOT differ only by case. That is a + SHOULD and is not enforced. + + ``version`` is accepted so callers can invoke every axis rule uniformly. + + Raises + ------ + ValidationError + With :attr:`SpecRule.AXIS_NAMES_UNIQUE` for the first axis whose + ``name`` duplicates an earlier one; location ``multiscales[0].axes[i]``. + """ + seen: set[str] = set() + for i, axis in enumerate(metadata.axes): + if axis.name in seen: + raise ValidationError( + SpecRule.AXIS_NAMES_UNIQUE, + f"Axis name '{axis.name}' is repeated; axis names must be unique " + f"within a dataset.", + f"multiscales[0].axes[{i}]", + ) + seen.add(axis.name) + + def validate_per_dataset_scale_count(metadata: Metadata) -> None: """Validate that each dataset defines exactly one ``scale`` transform. @@ -904,7 +1003,9 @@ def validate_well_acquisition(plate: Plate, well: Well) -> None: def validate_structural( - metadata: Metadata, options: ValidateOptions | None = None + metadata: Metadata, + options: ValidateOptions | None = None, + version: object | None = None, ) -> None: """Run the structural image/multiscales rules in canonical spec order. @@ -919,16 +1020,17 @@ def validate_structural( 2. :func:`validate_axis_type` 3. :func:`validate_axis_order` 4. :func:`validate_spatial_axis_order` - 5. :func:`validate_per_dataset_scale_count` - 6. :func:`validate_scale_length` - 7. :func:`validate_transform_order` - 8. :func:`validate_dataset_order` - 9. :func:`validate_omero_color_hex` - 10. :func:`validate_axis_orientation` - 11. :func:`validate_zarr_format_for_version` - 12. :func:`validate_ome_namespace` - - Rules 11 and 12 are the OME-Zarr v0.5 namespacing checks; they fire only for + 5. :func:`validate_axis_names_unique` + 6. :func:`validate_per_dataset_scale_count` + 7. :func:`validate_scale_length` + 8. :func:`validate_transform_order` + 9. :func:`validate_dataset_order` + 10. :func:`validate_omero_color_hex` + 11. :func:`validate_axis_orientation` + 12. :func:`validate_zarr_format_for_version` + 13. :func:`validate_ome_namespace` + + Rules 12 and 13 are the OME-Zarr v0.5 namespacing checks; they fire only for v0.5 metadata and are inert (a no-op) for v0.4. Parameters @@ -945,6 +1047,10 @@ def validate_structural( without running any structural rule -- shape/schema validation is the separate concern of :mod:`ngff_zarr.validate`, which checks the raw attribute dict. + version: + The OME-Zarr version the metadata declares. Rules 1-3 are inert for the + versions that adopt the RFC-3 axis model, so omitting it holds every + store to the v0.4 axis caps. Raises ------ @@ -966,10 +1072,11 @@ def validate_structural( if options.level == ValidationLevel.SCHEMA_ONLY: return metadata = _flat_model(metadata) - validate_axis_count(metadata) - validate_axis_type(metadata) - validate_axis_order(metadata) - validate_spatial_axis_order(metadata) + validate_axis_count(metadata, version) + validate_axis_type(metadata, version) + validate_axis_order(metadata, version) + validate_spatial_axis_order(metadata, version) + validate_axis_names_unique(metadata, version) validate_per_dataset_scale_count(metadata) validate_scale_length(metadata) validate_transform_order(metadata) diff --git a/py/ngff_zarr/to_ngff_image.py b/py/ngff_zarr/to_ngff_image.py index 76fd6171..d063292f 100644 --- a/py/ngff_zarr/to_ngff_image.py +++ b/py/ngff_zarr/to_ngff_image.py @@ -16,7 +16,7 @@ ) from .methods._support import _spatial_dims from .ngff_image import NgffImage -from .v04.zarr_metadata import SupportedDims, Units +from .v04.zarr_metadata import AxisUnit, SupportedDims # Group node types across the read backends: the compat-layer local reader # and the pure-Python mapping-store reader. @@ -87,7 +87,7 @@ def to_ngff_image( scale: Mapping[Hashable, float] | None = None, translation: Mapping[Hashable, float] | None = None, name: str = "image", - axes_units: Mapping[str, Units] | None = None, + axes_units: Mapping[str, AxisUnit] | None = None, channel_names: Sequence[str] | None = None, ) -> NgffImage: """ diff --git a/py/ngff_zarr/to_ngff_zarr.py b/py/ngff_zarr/to_ngff_zarr.py index 2a94636d..cdcc7ec6 100644 --- a/py/ngff_zarr/to_ngff_zarr.py +++ b/py/ngff_zarr/to_ngff_zarr.py @@ -4,7 +4,7 @@ import shutil import tempfile import warnings -from dataclasses import asdict +from dataclasses import asdict, dataclass from pathlib import Path, PurePosixPath from typing import Any, Literal @@ -256,7 +256,12 @@ def _validate_ngff_parameters( if isinstance(version, str): version = NgffVersion(version) - if version not in [NgffVersion.V04, NgffVersion.V05, NgffVersion.V06]: + if version not in [ + NgffVersion.V04, + NgffVersion.V05, + NgffVersion.V06, + NgffVersion.V09dev1, + ]: raise ValueError(f"Unsupported version: {version}") if chunks_per_shard is not None and version == NgffVersion.V04: @@ -265,6 +270,99 @@ def _validate_ngff_parameters( ) +@dataclass(frozen=True) +class _AxisView: + """Minimal stand-in exposing only ``axes``. + + The axis rules in :mod:`ngff_zarr.structural_validation` read nothing else + off the metadata. A v0.6 ``Metadata`` has no ``axes`` attribute, only + ``coordinateSystems``. + """ + + axes: list + + +def _axis_views(metadata) -> list[tuple[str, _AxisView]]: + """Every axis list ``metadata`` will serialize, paired with its location. + + v0.4/v0.5 metadata carry a flat ``axes``; v0.6 and 0.9.dev1 carry + ``coordinateSystems``, and ``coordinate_systems.schema`` applies + ``axes.schema`` to each one, so every system is returned. + + ``coordinateSystems`` is checked first: v0.9.dev1 also exposes an ``axes`` + property, which returns only the intrinsic system's axes. + """ + systems = list(getattr(metadata, "coordinateSystems", None) or []) + if systems: + return [ + (f"multiscales[0].coordinateSystems[{i}].axes", _AxisView(list(cs.axes))) + for i, cs in enumerate(systems) + ] + axes = getattr(metadata, "axes", None) + if axes is not None: + return [("multiscales[0].axes", _AxisView(list(axes)))] + return [] + + +def _gate_axis_model(metadata, version) -> None: + """Refuse to serialize an axis model the target ``version`` cannot express. + + Reuses the axis rules of :mod:`ngff_zarr.structural_validation`, but this + is a second dispatcher, not the same pass as + :func:`ngff_zarr.validate_structural`, and it differs from it two ways: + + 1. It runs only the five axis rules, not the full image cascade. + 2. It checks **every** coordinate system (see :func:`_axis_views`), while + ``validate_structural`` reads a single flat ``axes`` list. + + Called after ``to_version``: the converted object is what ``asdict`` + serializes, and for a 0.4/0.5 target it exposes the axes that survive the + downgrade. The corollary is that this cannot refuse what the downgrade has + already dropped -- ``Metadata._to_v05`` keeps only the coordinate system + the datasets reference -- so a model refused at 0.6 may be accepted at 0.4 + with the other systems silently discarded. + """ + from .structural_validation import ( + SpecRule, + ValidationError, + validate_axis_count, + validate_axis_names_unique, + validate_axis_order, + validate_axis_type, + validate_spatial_axis_order, + ) + + rules = ( + validate_axis_count, + validate_axis_type, + validate_axis_order, + validate_spatial_axis_order, + validate_axis_names_unique, + ) + for location, view in _axis_views(metadata): + for rule in rules: + try: + rule(view, version) + except ValidationError as exc: + rendered = ", ".join( + f"{ax.name!r}(type={ax.type!r})" for ax in view.axes + ) + if exc.rule is SpecRule.AXIS_NAMES_UNIQUE: + # Required at every version, 0.9.dev1 included. + raise ValueError( + f'Cannot write OME-Zarr version="{version}": {exc.message} ' + f"Axes at {location}: [{rendered}]." + ) from exc + raise ValueError( + f'Cannot write OME-Zarr version="{version}": this axis model ' + f"violates that version's [{exc.rule.value}] rule. " + f"{exc.message} Axes at {location}: [{rendered}]. " + f'Pass version="{NgffVersion.V09dev1.value}" to write it: ' + "0.9.dev1 is the only OME-Zarr version that adopts RFC-3 " + "(arbitrary axis count, names, types and ordering)." + ) from exc + + def _prepare_metadata( multiscales: NgffMultiscales, version: str ) -> tuple[Metadata_v04 | Metadata_v05, tuple[str, ...], dict]: @@ -279,6 +377,7 @@ def _prepare_metadata( method_metadata = get_method_metadata(multiscales.method) metadata = metadata.to_version(version) + _gate_axis_model(metadata, version) metadata.type = method_type metadata.metadata = method_metadata diff --git a/py/ngff_zarr/upgrade_ome_zarr.py b/py/ngff_zarr/upgrade_ome_zarr.py index 6de98c10..b86185df 100644 --- a/py/ngff_zarr/upgrade_ome_zarr.py +++ b/py/ngff_zarr/upgrade_ome_zarr.py @@ -70,6 +70,7 @@ from_ome_zarr, ) from .to_ngff_zarr import ( + _gate_axis_model, _pop_metadata_optionals, _root_ome_attrs, to_ome_zarr, @@ -80,11 +81,12 @@ def _normalize_target_version(version: str | NgffVersion) -> str: - """Return the API version string (``"0.4"``/``"0.5"``/``"0.6"``). + """Return the API version string (``"0.4"``/``"0.5"``/``"0.6"``/``"0.9.dev1"``). Both the API alias ``"0.6"`` and the on-disk development string ``"0.6.dev4"`` normalize to ``"0.6"`` so the value can be handed to - ``Metadata.to_version`` (which only knows the three released versions). + ``Metadata.to_version``. ``"0.9.dev1"`` needs no such collapse: unlike + v0.6 it is itself the on-disk string. """ nv = NgffVersion(version) if nv in (NgffVersion.V06, NgffVersion.V06dev4): @@ -185,13 +187,13 @@ def _validate_target_version(target_version: str, requested: str | NgffVersion) ``NgffVersion`` also admits the pre-0.4 drafts (0.1--0.3) that live in ``SUPPORTED_VERSIONS`` for read compatibility, but the metadata - ``to_version`` chains only convert among 0.4/0.5/0.6. Fail early with an - actionable message rather than deep in a conversion. + ``to_version`` chains only convert among 0.4/0.5/0.6 and 0.9.dev1. Fail + early with an actionable message rather than deep in a conversion. """ - if target_version not in ("0.4", "0.5", "0.6"): + if target_version not in ("0.4", "0.5", "0.6", "0.9.dev1"): raise ValueError( f"Unsupported target version {requested!r}. upgrade_ome_zarr() can " - "upgrade to OME-Zarr 0.4, 0.5, or 0.6." + "upgrade to OME-Zarr 0.4, 0.5, 0.6, or 0.9.dev1." ) @@ -240,6 +242,7 @@ def _upgrade_in_place( # deliberately avoided: it resets ``type``/``metadata`` to ``None`` for any # store whose method type is not a recognized ``Methods`` value. new_metadata = multiscales.metadata.to_version(target_version) + _gate_axis_model(new_metadata, target_version) metadata_dict = asdict(new_metadata) metadata_dict = _pop_metadata_optionals(metadata_dict) metadata_dict["@type"] = "ngff:Image" @@ -518,6 +521,9 @@ def upgrade_ome_zarr( # Cross-format upgrade (0.4 -> 0.5/0.6): rewrite array + group # metadata to Zarr v3 while preserving every chunk file on disk. new_metadata = multiscales.metadata.to_version(target_version) + # _rewrite_v2_group_to_v3 deletes the v2 sidecars and creates v3 + # arrays; gate before it so a refusal leaves the store intact. + _gate_axis_model(new_metadata, target_version) _rewrite_v2_group_to_v3(input, new_metadata, target_version) else: # Cross-format downgrade (0.5/0.6 -> 0.4) in place: Zarr v3 default diff --git a/py/ngff_zarr/v04/zarr_metadata.py b/py/ngff_zarr/v04/zarr_metadata.py index 31602dc0..22393d38 100644 --- a/py/ngff_zarr/v04/zarr_metadata.py +++ b/py/ngff_zarr/v04/zarr_metadata.py @@ -16,6 +16,7 @@ from ..ngff_image import NgffImage from ..v05.zarr_metadata import Metadata as Metadata_v05 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 logger = logging.getLogger(__name__) @@ -80,6 +81,13 @@ ] Units = Union[SpaceUnits, TimeUnits] +#: Axis unit as RFC-3 allows it: the controlled vocabulary, or any other +#: string. Every published axes schema declares ``"unit": {"type": "string"}`` +#: with no ``enum``, and an axis of an arbitrary type has no unit in the closed +#: space/time vocabulary. The union keeps the vocabulary so editors still +#: complete it. Mirrors the TypeScript port's ``AxisUnit``. +AxisUnit = Union[Units, str] + supported_dims = ["x", "y", "z", "c", "t"] space_units = [ @@ -163,17 +171,15 @@ def _filter_axis_dict(axis_dict: dict) -> dict: Logs a warning if unknown fields are encountered. Raises: - ValueError: If required fields 'name' or 'type' are missing from the axis dictionary. + ValueError: If the required field 'name' is missing from the axis + dictionary. """ - # Check for required fields before filtering + # `name` is the only required axis field: the v0.4 schema accepts an axis + # that declares no `type`, so a missing one is read as undefined. if "name" not in axis_dict: raise ValueError( f"Axis dictionary is missing required field 'name': {axis_dict}" ) - if "type" not in axis_dict: - raise ValueError( - f"Axis dictionary is missing required field 'type': {axis_dict}" - ) axis_fields = _get_axis_fields() unknown_fields = set(axis_dict.keys()) - axis_fields @@ -183,13 +189,15 @@ def _filter_axis_dict(axis_dict: dict) -> dict: f"Ignoring unknown fields {unknown_fields} in axis '{axis_name}'. " f"These fields are not part of the OME-NGFF v0.4 specification." ) - return {k: v for k, v in axis_dict.items() if k in axis_fields} + filtered = {k: v for k, v in axis_dict.items() if k in axis_fields} + filtered.setdefault("type", None) + return filtered @dataclass class Axis: name: SupportedDims - type: AxesType + type: AxesType | None unit: Units | None = None orientation: AnatomicalOrientation | None = None @@ -343,7 +351,7 @@ class Metadata: def to_version( self, version: Union[str, NgffVersion] - ) -> Union["Metadata", "Metadata_v05", "Metadata_v06"]: + ) -> Union["Metadata", "Metadata_v05", "Metadata_v06", "Metadata_v09"]: if isinstance(version, str): # raise error for invalid version string version = NgffVersion(version) @@ -354,17 +362,25 @@ def to_version( return self._to_v05() if version == NgffVersion.V06: return self._to_v05()._to_v06() + if version == NgffVersion.V09dev1: + from ..v09.zarr_metadata import Metadata as Metadata_v09 + + return Metadata_v09.from_version(self) raise ValueError(f"Unsupported version conversion: 0.4 -> {version}") @classmethod def from_version( - cls, metadata: Union["Metadata", "Metadata_v05", "Metadata_v06"] + cls, + metadata: Union["Metadata", "Metadata_v05", "Metadata_v06", "Metadata_v09"], ) -> "Metadata": from ..v05.zarr_metadata import Metadata as Metadata_v05 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 if isinstance(metadata, Metadata_v05): return cls._from_v05(metadata) + if isinstance(metadata, Metadata_v09): + return cls._from_v05(Metadata_v05._from_v06(metadata._to_v06())) if isinstance(metadata, Metadata_v06): return cls._from_v05(Metadata_v05._from_v06(metadata)) raise ValueError(f"Unsupported metadata type: {type(metadata)}") diff --git a/py/ngff_zarr/v05/zarr_metadata.py b/py/ngff_zarr/v05/zarr_metadata.py index f0535fb8..abdd94bd 100644 --- a/py/ngff_zarr/v05/zarr_metadata.py +++ b/py/ngff_zarr/v05/zarr_metadata.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 @dataclass @@ -29,7 +30,7 @@ class Metadata: def to_version( self, version: str | NgffVersion - ) -> Union["Metadata", "Metadata_v04", "Metadata_v06"]: + ) -> Union["Metadata", "Metadata_v04", "Metadata_v06", "Metadata_v09"]: """Convert metadata to specified NGFF version.""" if isinstance(version, str): version = NgffVersion(version) @@ -40,18 +41,26 @@ def to_version( return self if version == NgffVersion.V06: return self._to_v06() + if version == NgffVersion.V09dev1: + from ..v09.zarr_metadata import Metadata as Metadata_v09 + + return Metadata_v09.from_version(self) raise ValueError(f"Unsupported version conversion: 0.5 -> {version}") @classmethod def from_version( - cls, metadata: Union["Metadata", "Metadata_v04", "Metadata_v06"] + cls, + metadata: Union["Metadata", "Metadata_v04", "Metadata_v06", "Metadata_v09"], ) -> "Metadata": """Convert metadata from specified NGFF version.""" from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v06.zarr_metadata import Metadata as Metadata_v06 + from ..v09.zarr_metadata import Metadata as Metadata_v09 if isinstance(metadata, Metadata_v04): return cls._from_v04(metadata) + if isinstance(metadata, Metadata_v09): + return cls._from_v06(metadata._to_v06()) if isinstance(metadata, Metadata_v06): return cls._from_v06(metadata) raise ValueError(f"Unsupported metadata type: {type(metadata)}") diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index b68ada67..27f3fc49 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -21,6 +21,7 @@ from ..ngff_image import NgffImage from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v09.zarr_metadata import Metadata as Metadata_v09 # OME-Zarr v0.6 (RFC-5) extends the axis types with the discrete vector-field @@ -498,7 +499,7 @@ def axes(self) -> list[Axis]: def to_version( self, version: Union[str, NgffVersion] - ) -> Union["Metadata", "Metadata_v05", "Metadata_v04"]: + ) -> Union["Metadata", "Metadata_v05", "Metadata_v04", "Metadata_v09"]: if isinstance(version, str): # raise error for invalid version string version = NgffVersion(version) @@ -509,15 +510,23 @@ def to_version( return self._to_v05() if version == NgffVersion.V06: return self + if version == NgffVersion.V09dev1: + from ..v09.zarr_metadata import Metadata as Metadata_v09 + + return Metadata_v09.from_version(self) raise ValueError(f"Unsupported version conversion: 0.6 -> {version}") @classmethod def from_version( - cls, metadata: Union["Metadata", "Metadata_v05", "Metadata_v04"] + cls, + metadata: Union["Metadata", "Metadata_v05", "Metadata_v04", "Metadata_v09"], ) -> "Metadata": from ..v04.zarr_metadata import Metadata as Metadata_v04 from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v09.zarr_metadata import Metadata as Metadata_v09 + if isinstance(metadata, Metadata_v09): + return metadata._to_v06() if isinstance(metadata, Metadata_v05): return cls._from_v05(metadata) if isinstance(metadata, Metadata_v04): diff --git a/py/ngff_zarr/v09/__init__.py b/py/ngff_zarr/v09/__init__.py new file mode 100644 index 00000000..9bd9d864 --- /dev/null +++ b/py/ngff_zarr/v09/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT diff --git a/py/ngff_zarr/v09/zarr_metadata.py b/py/ngff_zarr/v09/zarr_metadata.py new file mode 100644 index 00000000..ec69d4c7 --- /dev/null +++ b/py/ngff_zarr/v09/zarr_metadata.py @@ -0,0 +1,388 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""OME-Zarr ``0.9.dev1`` metadata model (RFC-3). + +``0.9.dev1`` is OME-Zarr 0.6 plus RFC-3: an image may declare any number of +axes, with any names, any type strings, and in any order. The RFC-5 +coordinate-system and transformation model of 0.6 is unchanged. + +The dataclasses here carry no version condition. The axis restrictions live in +:mod:`ngff_zarr.structural_validation` and are applied against the *target* +version by the writer. + +This module defines :class:`Axis`, :class:`CoordinateSystem` and +:class:`Metadata`; every other symbol is re-exported from +:mod:`ngff_zarr.v06.zarr_metadata`. +""" + +import functools +import logging +from dataclasses import dataclass, field, fields +from typing import TYPE_CHECKING, Union + +from .._store_types import StoreLike +from .._supported_versions import NgffVersion +from ..rfc4 import AnatomicalOrientation + +# RFC-3 touches the axis model only; these symbols are unchanged from v0.6. +# They must be the same classes, not copies: ``v06.Metadata._to_v05`` +# dispatches on ``isinstance(t, Scale)`` and ``isinstance(t, +# TransformSequence)``, so copies would fall through those branches and emit +# default scale/translation transforms instead. +from ..v04.zarr_metadata import AxisUnit, SupportedDims +from ..v06.zarr_metadata import ( # noqa: F401 + Affine, + BaseTransform, + Coordinates, + CoordinateSystemIdentifier, + Dataset, + Displacements, + Identity, + MethodMetadata, + Omero, + Rotation, + Scale, + Transform, + TransformSequence, + Translation, +) +from ..v06.zarr_metadata import AxesType as AxesTypeV06 + +if TYPE_CHECKING: + from ..ngff_image import NgffImage + from ..v04.zarr_metadata import Metadata as Metadata_v04 + from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v06.zarr_metadata import Metadata as Metadata_v06 + +logger = logging.getLogger(__name__) + +#: RFC-3 axis name. Any non-empty string is legal; the union keeps the +#: v0.4 ``SupportedDims`` vocabulary so editors still complete the conventional +#: names. Mirrors the TypeScript port's ``AxisName``. +AxisName = Union[SupportedDims, str] + +#: RFC-3 axis type. Any string is legal alongside the spec-defined vocabulary, +#: which the union keeps for the same reason. ``None`` is permitted: ``type`` +#: is optional in every published axes schema (``required: ["name"]``). +AxesType = Union[AxesTypeV06, str, None] + + +@dataclass +class Axis: + """An RFC-3 axis. + + Same fields as :class:`ngff_zarr.v06.zarr_metadata.Axis`, with ``name``, + ``type`` and ``unit`` widened: each keeps the spec-defined vocabulary in a + union with the free-form string RFC-3 allows. + + ``orientation`` (RFC-4) and ``discrete`` (RFC-5) are carried so a 0.6 + document round-trips without loss. + """ + + name: AxisName + type: AxesType = None + unit: AxisUnit | None = None + orientation: AnatomicalOrientation | None = None + discrete: bool | None = None + + +@dataclass +class CoordinateSystem: + """A named set of RFC-3 axes.""" + + name: str + axes: list[Axis] + + +@functools.lru_cache(maxsize=1) +def _get_axis_fields() -> set[str]: + """Valid field names of :class:`Axis`, cached.""" + return {f.name for f in fields(Axis)} + + +def _filter_axis_dict(axis_dict: dict) -> dict: + """Filter an axis dict down to the recognized :class:`Axis` fields. + + Unknown keys are logged and dropped. The 0.6 axes schema permits keys this + dataclass does not declare, such as ``longName``. + + Raises + ------ + ValueError + If the required ``name`` key is absent. + """ + if "name" not in axis_dict: + raise ValueError( + f"Axis dictionary is missing required field 'name': {axis_dict}" + ) + + axis_fields = _get_axis_fields() + unknown_fields = set(axis_dict.keys()) - axis_fields + if unknown_fields: + logger.warning( + f"Ignoring unknown fields {unknown_fields} in axis " + f"'{axis_dict['name']}'. These fields are not modelled by the " + f"ngff-zarr OME-Zarr 0.9.dev1 axis dataclass." + ) + filtered = {k: v for k, v in axis_dict.items() if k in axis_fields} + # The v0.6 reader builds axes with a bare ``Axis(**axis)`` and its ``type`` + # has no default, so an omitted ``type`` must be made explicit here. + filtered.setdefault("type", None) + return filtered + + +def _axis_from(axis: object) -> Axis: + """Re-instantiate any version's axis object as a v0.9.dev1 :class:`Axis`. + + ``discrete`` is read with :func:`getattr`: ``v06.Metadata._from_v05`` + assigns the v0.5 axis list straight into a ``CoordinateSystem``, so a v0.6 + ``Metadata`` reached from a 0.4/0.5 store holds v0.4 ``Axis`` instances, + which have no ``discrete`` field. + """ + return Axis( + name=axis.name, + type=axis.type, + unit=axis.unit, + orientation=axis.orientation, + discrete=getattr(axis, "discrete", None), + ) + + +@dataclass +class Metadata: + """OME-Zarr ``0.9.dev1`` multiscales metadata. + + Same fields as :class:`ngff_zarr.v06.zarr_metadata.Metadata`. It carries + ``coordinateSystems``, not the flat ``axes`` list of v0.4/v0.5, so the + v0.6 <-> 0.9.dev1 hop is a lossless structural copy; routing through v0.5 + would drop every rotation, affine, coordinates and displacements + transform. + + There is no ``version`` field, as in v0.5 and v0.6. From v0.5 on the spec + version lives in the group-level ``ome`` namespace, written by + ``to_ngff_zarr._write_root_ome_attrs`` from the writer's ``version`` + argument. A field here would be emitted by ``asdict`` inside the + ``multiscales[]`` entry, which the ``ome-namespace`` rule rejects. + """ + + coordinateSystems: list[CoordinateSystem] + datasets: list[Dataset] + coordinateTransformations: list[Transform] | None = None + omero: Omero | None = None + name: str = "image" + type: str | None = None + metadata: MethodMetadata | None = None + #: Unrecognized keys captured on read (see + #: :attr:`ngff_zarr.v04.zarr_metadata.Metadata.extra`). Carried across + #: version conversion; a read-side validation aid, never serialized. + extra: dict = field(default_factory=dict) + + def __post_init__(self): + """On-the-fly validation not well covered by the JSON schemas.""" + _ = self.intrinsic_coordinate_system + + @property + def intrinsic_coordinate_system(self) -> CoordinateSystem: + output_cs = [ds.coordinateTransformations[0].output for ds in self.datasets] + + if output_cs[0] is None: + raise ValueError( + "No output coordinate system found in dataset coordinate transformations. " + ) + + if not all(output == output_cs[0] for output in output_cs): + raise ValueError( + "Multiple different outputs coordinate systems found in" + f" coordinate transformations for multiscales: {output_cs}. " + "This is out of spec for this ome-zarr 0.9.dev1." + ) + + for cs in self.coordinateSystems: + if cs.name == output_cs[0].name: + return cs + raise ValueError( + f"Dataset coordinate transformations reference coordinate system" + f" {output_cs[0].name!r}, which is not declared in" + f" coordinateSystems: {[cs.name for cs in self.coordinateSystems]}." + ) + + @property + def axes(self) -> list[Axis]: + """The intrinsic coordinate system's axes. + + A property, not a field, so ``dataclasses.asdict`` and + ``dataclasses.fields`` ignore it and the serialized entry is unchanged. + The axis rules in :mod:`ngff_zarr.structural_validation` read + ``metadata.axes``. + """ + return self.intrinsic_coordinate_system.axes + + @property + def dimension_names(self) -> tuple: + return tuple(ax.name for ax in self.intrinsic_coordinate_system.axes) + + def to_version( + self, version: Union[str, NgffVersion] + ) -> Union["Metadata", "Metadata_v04", "Metadata_v05", "Metadata_v06"]: + if isinstance(version, str): + version = NgffVersion(version) + + if version == NgffVersion.V09dev1: + return self + if version in (NgffVersion.V06, NgffVersion.V06dev4): + return self._to_v06() + if version == NgffVersion.V05: + return self._to_v06()._to_v05() + if version == NgffVersion.V04: + return self._to_v06()._to_v05()._to_v04() + raise ValueError(f"Unsupported version conversion: 0.9.dev1 -> {version}") + + @classmethod + def from_version( + cls, + metadata: Union["Metadata", "Metadata_v04", "Metadata_v05", "Metadata_v06"], + ) -> "Metadata": + from ..v04.zarr_metadata import Metadata as Metadata_v04 + from ..v05.zarr_metadata import Metadata as Metadata_v05 + from ..v06.zarr_metadata import Metadata as Metadata_v06 + + if isinstance(metadata, cls): + return metadata + if isinstance(metadata, Metadata_v06): + return cls._from_v06(metadata) + if isinstance(metadata, (Metadata_v04, Metadata_v05)): + return cls._from_v06(Metadata_v06.from_version(metadata)) + raise ValueError( + f"Unsupported metadata type ({type(metadata)}) for conversion to 0.9.dev1" + ) + + def _to_v06(self) -> "Metadata_v06": + """Structurally map to v0.6, re-instantiating every axis. + + This does not enforce the v0.6 axis restrictions; the writer applies + them against the target version. This converter is also used on the + way to v0.5 and v0.4. + """ + from ..v06.zarr_metadata import Axis as Axis_v06 + from ..v06.zarr_metadata import CoordinateSystem as CoordinateSystem_v06 + from ..v06.zarr_metadata import Metadata as Metadata_v06 + + coordinate_systems = [ + CoordinateSystem_v06( + name=cs.name, + axes=[ + Axis_v06( + name=ax.name, + type=ax.type, + unit=ax.unit, + orientation=ax.orientation, + discrete=ax.discrete, + ) + for ax in cs.axes + ], + ) + for cs in self.coordinateSystems + ] + + return Metadata_v06( + coordinateSystems=coordinate_systems, + datasets=list(self.datasets), + coordinateTransformations=self.coordinateTransformations, + omero=self.omero, + name=self.name, + type=self.type, + metadata=self.metadata, + extra=dict(self.extra), + ) + + @classmethod + def _from_v06(cls, metadata_v06: "Metadata_v06") -> "Metadata": + """Structurally map from v0.6, re-instantiating every axis. + + ``v06.Metadata._from_v05`` assigns the v0.5 axis list straight into + ``CoordinateSystem.axes``, so a v0.6 ``Metadata`` reached from a + 0.4/0.5 store holds v0.4 ``Axis`` objects, in the same list object. + Aliasing them would share mutable state with the source, drop fields + under ``dataclasses.asdict`` (which walks the runtime class), and break + equality, which dataclasses compare class-exact. + """ + coordinate_systems = [ + CoordinateSystem(name=cs.name, axes=[_axis_from(ax) for ax in cs.axes]) + for cs in metadata_v06.coordinateSystems + ] + + return cls( + coordinateSystems=coordinate_systems, + datasets=list(metadata_v06.datasets), + coordinateTransformations=metadata_v06.coordinateTransformations, + omero=metadata_v06.omero, + name=metadata_v06.name, + type=metadata_v06.type, + metadata=metadata_v06.metadata, + extra=dict(metadata_v06.extra), + ) + + @classmethod + def _from_zarr_attrs( + cls, + root_attrs: dict, + store: StoreLike, + validate: bool = False, + subpath: str | None = None, + ) -> tuple["Metadata", list["NgffImage"]]: + """Read a ``0.9.dev1`` store. + + Dataset transform parsing and ``NgffImage`` construction are delegated + to the v0.6 reader. Handled here first: + + 1. ``validate=True`` is refused: no ``0.9.dev1`` JSON Schema is + published, so no ``spec/0.9.dev1/schemas`` tree is bundled. + 2. A 0.5-shaped entry (flat ``axes``, no ``coordinateSystems``) is + normalized to a single ``intrinsic`` coordinate system, so either + shape is readable. + 3. Unknown axis keys are stripped; the v0.6 reader builds axes with a + bare ``Axis(**axis)``. + """ + import copy + + from ..v06.zarr_metadata import Metadata as Metadata_v06 + + if validate: + raise NotImplementedError( + "Schema validation is unavailable for OME-Zarr 0.9.dev1: OME has " + "published no JSON Schema for it, so ngff-zarr ships no " + "spec/0.9.dev1/schemas tree. Read with validate=False and use " + "ngff_zarr.validate_structural() for the structural rules." + ) + + if "ome" not in root_attrs or "multiscales" not in root_attrs.get("ome", {}): + raise ValueError( + "Invalid OME-Zarr 0.9.dev1 metadata: missing 'ome' or 'multiscales' field." + ) + + # Attributes are plain JSON, so a deep copy is cheap and keeps the + # caller's dict untouched while the entry is normalized in place. + patched = dict(root_attrs) + patched["ome"] = copy.deepcopy(root_attrs["ome"]) + entry = patched["ome"]["multiscales"][0] + + if "coordinateSystems" not in entry and "axes" in entry: + # A 0.5-shaped entry: flat axes, and dataset transforms that carry + # no coordinate-system identifiers. The v0.6 reader dereferences + # those identifiers, so read this shape with the v0.5 reader and + # convert up. + from ..v05.zarr_metadata import Metadata as Metadata_v05 + + patched["ome"]["version"] = "0.5" + metadata_v05, images = Metadata_v05._from_zarr_attrs( + patched, store, validate=False, subpath=subpath + ) + return cls.from_version(metadata_v05), images + + for cs in entry.get("coordinateSystems", []): + cs["axes"] = [_filter_axis_dict(axis) for axis in cs.get("axes", [])] + + metadata_v06, images = Metadata_v06._from_zarr_attrs( + patched, store, validate=False, subpath=subpath + ) + return cls._from_v06(metadata_v06), images diff --git a/py/test/test_cli_upgrade.py b/py/test/test_cli_upgrade.py index c0a04e59..f9c04b50 100644 --- a/py/test/test_cli_upgrade.py +++ b/py/test/test_cli_upgrade.py @@ -28,7 +28,13 @@ ) # The on-disk ``ome.version`` string each API version is written as. -DISK_VERSION = {"0.4": "0.4", "0.5": "0.5", "0.6": "0.6.dev4"} +DISK_VERSION = { + "0.4": "0.4", + "0.5": "0.5", + "0.6": "0.6.dev4", + # Unlike 0.6 this is itself the on-disk string. + "0.9.dev1": "0.9.dev1", +} # Metadata sidecars across Zarr v2 and v3, excluded when isolating chunk data. _METADATA_NAMES = {"zarr.json", ".zarray", ".zattrs", ".zgroup", ".zmetadata"} @@ -138,6 +144,26 @@ def test_write_to_new_store_0_4_to_0_5(tmp_path): assert _all_files_digest(src) == source_before +def test_in_place_upgrade_0_6_to_0_9dev1(tmp_path): + """``--to 0.9.dev1`` is a real CLI target, not just a library one. + + ``upgrade_ome_zarr`` accepts 0.9.dev1, so the CLI in front of it must + offer the same set or the RFC-3 target is reachable from Python only. + """ + store = tmp_path / "image.ome.zarr" + _write_source(str(store), "0.6") + chunks_before = _chunk_files(store) + + result = _run_ngff_zarr("upgrade", str(store), "--to", "0.9.dev1") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + root = zarr.open_group(str(store), mode="r", zarr_format=3) + assert root.attrs["ome"]["version"] == DISK_VERSION["0.9.dev1"] + + # Same Zarr format on both sides, so this stays metadata-only. + assert _chunk_files(store) == chunks_before + + def test_in_place_cross_format_downgrade_exits_nonzero(tmp_path): """0.6 -> 0.4 in place is rejected with guidance and a non-zero exit code.""" store = tmp_path / "image.ome.zarr" diff --git a/py/test/test_rfc3_axes.py b/py/test/test_rfc3_axes.py new file mode 100644 index 00000000..087dcb78 --- /dev/null +++ b/py/test/test_rfc3_axes.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""RFC-3 ("More Dimensions for Thee") end-to-end axis handling. + +RFC-3 removes the historical OME-Zarr caps on the number, names, types, and +ordering of axes. These tests exercise the read path and a read/write round-trip +against local, in-memory synthetic fixtures mirroring the official sample +datasets (astronaut_xcy, ecg_1d, ramp_6d, FLIM, EBSD) -- no network access. The +focus is that reading keeps the exact axis order, names and types, and that +writing the result back at 0.9.dev1 -- which runs the write gate -- preserves +all three. +""" + +import numpy as np +import pytest +import zarr +from ngff_zarr import from_ome_zarr, to_ome_zarr +from packaging import version as packaging_version + +#: Writing OME-Zarr 0.5 and later needs a Zarr v3 hierarchy. +needs_zarr_v3 = pytest.mark.skipif( + packaging_version.parse(zarr.__version__) < packaging_version.parse("3.0.0b2"), + reason="zarr version >= 3.0.0b2 required for OME-Zarr version >= 0.5", +) + + +def _open_group(root): + """Open a writable v2 group at ``root`` across zarr-python 2 and 3. + + v0.4 OME-Zarr is a Zarr v2 hierarchy, so force ``zarr_format=2`` under + zarr-python 3 (which defaults to v3). + """ + if hasattr(zarr.storage, "LocalStore"): # zarr-python 3 + store = zarr.storage.LocalStore(root) + return zarr.open_group(store, mode="w", zarr_format=2) + store = zarr.DirectoryStore(root) # zarr-python 2 + return zarr.open_group(store, mode="w") + + +def _ramp(shape): + """Deterministic uint8 payload for a shape (values wrap modulo 256).""" + return np.arange(int(np.prod(shape)), dtype="uint8").reshape(shape) + + +def _axis_pairs(multiscales): + """The ``(name, type)`` of every axis, read off the metadata as parsed. + + Axis types are declared per axis and are not recoverable from + :attr:`NgffImage.dims`, so they need their own assertion: a regression that + coerced or dropped a custom type would leave ``dims`` intact. + """ + axes = multiscales.metadata.coordinateSystems[0].axes + return [(axis.name, axis.type) for axis in axes] + + +def _write_v04(root, axes, shape): + """Write a minimal single-level v0.4 OME-Zarr with the given axes/shape.""" + group = _open_group(root) + data = _ramp(shape) + if hasattr(group, "create_array"): # zarr-python 3 + array = group.create_array("0", shape=shape, dtype="uint8", chunks=shape) + else: # zarr-python 2 + array = group.create_dataset("0", shape=shape, dtype="uint8") + array[...] = data + group.attrs["multiscales"] = [ + { + "axes": axes, + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + {"type": "scale", "scale": [1.0] * len(axes)} + ], + } + ], + "version": "0.4", + } + ] + return str(root) + + +# (label, axes, shape): one entry per official RFC-3 sample-dataset shape. +_RFC3_SHAPES = [ + # ecg_1d: a single time axis (breaks the historical 2-5 count floor). + ("ecg_1d", [{"name": "t", "type": "time"}], (16,)), + # ramp_6d: six axes (exceeds the historical max of 5). + ("ramp_6d", [{"name": n, "type": "space"} for n in "abcdef"], (2,) * 6), + # astronaut_xcy: non-TCZYX order (x, c, y). + ( + "astronaut_xcy", + [ + {"name": "x", "type": "space"}, + {"name": "c", "type": "channel"}, + {"name": "y", "type": "space"}, + ], + (4, 3, 5), + ), + # FLIM-like: multiple axes of the same type (two channel-family axes). + ( + "flim_multi", + [ + {"name": "c", "type": "channel"}, + {"name": "tau", "type": "channel"}, + {"name": "y", "type": "space"}, + {"name": "x", "type": "space"}, + ], + (2, 3, 4, 5), + ), + # EBSD-like: arbitrary names and custom (non-standard) axis types. The + # official EBSD sample uses four type:space axes; this variant additionally + # exercises a custom type string, which RFC-3 permits. + ( + "ebsd_custom", + [ + {"name": "sy", "type": "space"}, + {"name": "sx", "type": "space"}, + {"name": "dy", "type": "diffraction"}, + {"name": "dx", "type": "diffraction"}, + ], + (2, 2, 4, 5), + ), +] + + +@pytest.mark.parametrize("label, axes, shape", _RFC3_SHAPES, ids=lambda v: v) +def test_rfc3_read_preserves_axis_order(tmp_path, label, axes, shape): + """Reading an RFC-3 dataset keeps the exact declared axis order/names.""" + root = _write_v04(tmp_path / f"{label}.ome.zarr", axes, shape) + multiscales = from_ome_zarr(root, validate=False) + + image = multiscales.images[0] + dims = tuple(image.dims) + assert dims == tuple(ax["name"] for ax in axes) + # The array is read at full dimensionality (not squeezed or transposed) and + # its payload is intact. + assert image.data.ndim == len(axes) + assert image.data.shape == shape + assert np.array_equal(np.asarray(image.data), _ramp(shape)) + # Types too, including the custom ones RFC-3 permits (ebsd_custom). + assert _axis_pairs(multiscales) == [(ax["name"], ax["type"]) for ax in axes] + + +@needs_zarr_v3 +@pytest.mark.parametrize("label, axes, shape", _RFC3_SHAPES, ids=lambda v: v) +def test_rfc3_round_trip_preserves_axis_order(tmp_path, label, axes, shape): + """A read then write then read keeps the axis order and data.""" + src = _write_v04(tmp_path / f"{label}-src.ome.zarr", axes, shape) + multiscales = from_ome_zarr(src, validate=False) + dims_in = tuple(multiscales.images[0].dims) + pairs_in = _axis_pairs(multiscales) + + # 0.9.dev1 is the only version that can express an RFC-3 axis model; writing + # these shapes at 0.4/0.5/0.6 is refused by the write gate (asserted below). + out = str(tmp_path / f"{label}-out.ome.zarr") + to_ome_zarr(out, multiscales, version="0.9.dev1") + multiscales_out = from_ome_zarr(out, validate=False) + image_out = multiscales_out.images[0] + + assert tuple(image_out.dims) == dims_in + assert _axis_pairs(multiscales_out) == pairs_in + assert np.array_equal(np.asarray(image_out.data), _ramp(shape)) + + +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6"]) +@pytest.mark.parametrize("label, axes, shape", _RFC3_SHAPES, ids=lambda v: v) +def test_rfc3_write_is_refused_below_v09dev1(tmp_path, label, axes, shape, version): + """Serializing an RFC-3 axis model to a pre-0.9.dev1 version is refused.""" + src = _write_v04(tmp_path / f"{label}-{version}-src.ome.zarr", axes, shape) + multiscales = from_ome_zarr(src, validate=False) + + out = str(tmp_path / f"{label}-{version}-out.ome.zarr") + with pytest.raises(ValueError, match="Cannot write OME-Zarr"): + to_ome_zarr(out, multiscales, version=version) + + +@needs_zarr_v3 +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6"]) +def test_channel_last_write_is_refused_below_v09dev1(tmp_path, version): + """Axis order is a spec MUST, so the writer refuses it, it does not warn. + + ``(z, y, x, c)`` satisfies every other axis rule: 4 axes, one channel, and + the space names are the ``(z, y, x)`` suffix. Only the class ordering is + wrong, so this reaches ``validate_axis_order`` and nothing else. + """ + axes = [{"name": n, "type": "space"} for n in "zyx"] + axes.append({"name": "c", "type": "channel"}) + src = _write_v04(tmp_path / f"cl-{version}-src.ome.zarr", axes, (2, 3, 4, 2)) + multiscales = from_ome_zarr(src, validate=False) + + out = str(tmp_path / f"cl-{version}-out.ome.zarr") + with pytest.raises(ValueError, match="axis-order"): + to_ome_zarr(out, multiscales, version=version) + + # RFC-3 lifts the ordering rule, so the same model writes at 0.9.dev1. + to_ome_zarr( + str(tmp_path / f"cl-{version}-ok.ome.zarr"), multiscales, version="0.9.dev1" + ) + + +@needs_zarr_v3 +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6", "0.9.dev1"]) +def test_conventional_axes_write_at_every_version(tmp_path, version): + """A conventional (z, y, x) image still writes everywhere, 0.9.dev1 included.""" + axes = [{"name": n, "type": "space"} for n in "zyx"] + src = _write_v04(tmp_path / f"zyx-{version}-src.ome.zarr", axes, (2, 3, 4)) + multiscales = from_ome_zarr(src, validate=False) + + out = str(tmp_path / f"zyx-{version}-out.ome.zarr") + to_ome_zarr(out, multiscales, version=version) + image_out = from_ome_zarr(out, validate=False).images[0] + assert tuple(image_out.dims) == ("z", "y", "x") + assert np.array_equal(np.asarray(image_out.data), _ramp((2, 3, 4))) + + +# The gate's message bytes are part of the cross-language contract: these +# literals are pinned identically in ``ts/test/write_gate_test.ts``. +CANONICAL_GATE_MESSAGE_REPEATED_NAME = ( + "Cannot write OME-Zarr version=\"0.9.dev1\": Axis name 'z' is repeated; " + "axis names must be unique within a dataset. Axes at multiscales[0].axes: " + "['z'(type='space'), 'z'(type='space'), 'x'(type='space')]." +) + +CANONICAL_GATE_MESSAGE_AXIS_COUNT = ( + 'Cannot write OME-Zarr version="0.4": this axis model violates that ' + "version's [axis-count] rule. OME-Zarr v0.4, v0.5 and v0.6 require between " + "2 and 5 axes, inclusive; found 6. Axes at multiscales[0].axes: " + "['a'(type='space'), 'b'(type='space'), 'c'(type='space'), " + "'d'(type='space'), 'e'(type='space'), 'f'(type='space')]. Pass " + 'version="0.9.dev1" to write it: 0.9.dev1 is the only OME-Zarr version ' + "that adopts RFC-3 (arbitrary axis count, names, types and ordering)." +) + + +def _flat_metadata(names): + """Single-level v0.4 metadata over ``names``, all ``space`` axes.""" + from ngff_zarr.v04.zarr_metadata import Axis, Dataset, Metadata, Scale + + return Metadata( + axes=[Axis(name=name, type="space") for name in names], + datasets=[ + Dataset( + path="0", + coordinateTransformations=[Scale([1.0] * len(names))], + ) + ], + coordinateTransformations=None, + ) + + +def test_gate_message_bytes_match_the_typescript_port(): + """The refusal text is part of the contract, not an implementation detail.""" + from ngff_zarr.to_ngff_zarr import _gate_axis_model + + with pytest.raises(ValueError) as exc_info: + _gate_axis_model(_flat_metadata(["z", "z", "x"]), "0.9.dev1") + assert str(exc_info.value) == CANONICAL_GATE_MESSAGE_REPEATED_NAME + + with pytest.raises(ValueError) as exc_info: + _gate_axis_model(_flat_metadata(list("abcdef")), "0.4") + assert str(exc_info.value) == CANONICAL_GATE_MESSAGE_AXIS_COUNT + + +@needs_zarr_v3 +@pytest.mark.parametrize("version", ["0.4", "0.5"]) +def test_gate_skips_coordinate_systems_the_target_version_drops(tmp_path, version): + """A coordinate system the downgrade discards must not block the write. + + ``_gate_axis_model`` runs after ``Metadata.to_version``, so a 0.4/0.5 target + sees only the surviving system. The TypeScript port reaches the same set by + checking the target version in ``axisViews``. + """ + from ngff_zarr.v06.zarr_metadata import Axis as AxisV06 + from ngff_zarr.v06.zarr_metadata import CoordinateSystem + + src = _write_v04( + tmp_path / "src.ome.zarr", + [{"name": n, "type": "space"} for n in ("z", "y", "x")], + (2, 3, 4), + ) + multiscales = from_ome_zarr(src, validate=False) + multiscales.metadata.coordinateSystems.append( + CoordinateSystem( + name="extra", + axes=[AxisV06(name=n, type="space") for n in "abcdef"], + ) + ) + + out = str(tmp_path / f"out-{version}.ome.zarr") + to_ome_zarr(out, multiscales, version=version) + assert tuple(from_ome_zarr(out, validate=False).images[0].dims) == ( + "z", + "y", + "x", + ) + + # 0.6 writes both systems, so there the six-axis one is refused. + with pytest.raises(ValueError, match="coordinateSystems\\[1\\].axes"): + to_ome_zarr(str(tmp_path / "out-0.6.ome.zarr"), multiscales, version="0.6") diff --git a/py/test/test_structural_validation.py b/py/test/test_structural_validation.py index a4011082..41c35a9a 100644 --- a/py/test/test_structural_validation.py +++ b/py/test/test_structural_validation.py @@ -20,6 +20,7 @@ ValidationError, ValidationLevel, validate_axis_count, + validate_axis_names_unique, validate_axis_order, validate_axis_type, validate_dataset_order, @@ -153,6 +154,7 @@ def test_spatial_axis_order_valid(valid_metadata): [ ("suffix-mismatch", "multiscales[0].axes[1]"), ("too-many-spatial", "multiscales[0].axes"), + ("too-few-spatial", "multiscales[0].axes"), ], ) def test_spatial_axis_order_invalid(valid_metadata, case, location): @@ -163,6 +165,12 @@ def test_spatial_axis_order_invalid(valid_metadata, case, location): Axis(name="x", type="space"), Axis(name="y", type="space"), ] + elif case == "too-few-spatial": + # The bundled axes schemas state minContains: 2 for 'space'. + valid_metadata.axes = [ + Axis(name="c", type="channel"), + Axis(name="x", type="space"), + ] else: # Four space axes exceed the v0.4 maximum of three. valid_metadata.axes = [ @@ -177,6 +185,91 @@ def test_spatial_axis_order_invalid(valid_metadata, case, location): assert exc_info.value.location == location +class _BackportStyleVersion(str): + """A ``str`` subclass whose ``str()`` is not its value. + + This is how the ``str, Enum`` backport renders below Python 3.11, where the + stdlib ``StrEnum`` is unavailable. Reproducing it here keeps the assertion + below meaningful on every interpreter rather than only on 3.10. + """ + + def __str__(self) -> str: + return "NgffVersion.V06dev4" + + +def test_is_v06_version_accepts_enum_members_and_strings(): + """The v0.6 predicate must not depend on the Python version. + + ``NgffVersion`` is a stdlib ``StrEnum`` from 3.11 and a ``str, Enum`` + backport below it. Only the former renders as its value under ``str()``, + so a ``str()``-based check silently returns ``False`` for enum members on + 3.10 -- the version the zarr-python 2 CI matrix runs. + """ + from ngff_zarr._supported_versions import NgffVersion, is_v06_version + + assert is_v06_version(NgffVersion.V06) + assert is_v06_version(NgffVersion.V06dev4) + assert is_v06_version("0.6") + assert is_v06_version("0.6.dev4") + assert not is_v06_version(NgffVersion.V05) + assert not is_v06_version("0.4") + assert not is_v06_version(NgffVersion.V09dev1) + assert not is_v06_version(None) + assert not is_v06_version(6) + # The value is what counts, not what ``str()`` renders. + assert is_v06_version(_BackportStyleVersion("0.6.dev4")) + assert not is_v06_version(_BackportStyleVersion("0.4")) + + +def test_spatial_axis_order_accepts_v06_array_coordinate_system(valid_metadata): + """An RFC-5 array coordinate system declares no ``space`` axis, and may. + + The v0.6 ``axes`` schema is a ``oneOf``: 2 or 3 ``space`` axes, *or* two or + more ``array`` axes. The space-axis floor must not fire on the second arm, + or a store the bundled schema accepts becomes unwritable at v0.6. + """ + valid_metadata.axes = [Axis(name=f"i{i}", type="array") for i in range(3)] + + validate_spatial_axis_order(valid_metadata, "0.6") + validate_spatial_axis_order(valid_metadata, "0.6.dev4") + + +def test_spatial_axis_order_array_branch_is_v06_only(valid_metadata): + """v0.4 and v0.5 have no ``array`` arm, so the floor applies unconditionally.""" + valid_metadata.axes = [Axis(name=f"i{i}", type="array") for i in range(3)] + + for version in ("0.4", "0.5", None): + with pytest.raises(ValidationError) as exc_info: + validate_spatial_axis_order(valid_metadata, version) + assert exc_info.value.rule == SpecRule.AXIS_ORDER + + +def test_spatial_axis_order_needs_two_array_axes(valid_metadata): + """A single ``array`` axis does not reach the schema's ``minContains: 2``.""" + valid_metadata.axes = [ + Axis(name="i0", type="array"), + Axis(name="c", type="channel"), + ] + with pytest.raises(ValidationError): + validate_spatial_axis_order(valid_metadata, "0.6") + + +def test_axis_names_unique_valid(valid_metadata): + validate_axis_names_unique(valid_metadata) + + +def test_axis_names_unique_invalid(valid_metadata): + valid_metadata.axes = [ + Axis(name="c", type="channel"), + Axis(name="y", type="space"), + Axis(name="y", type="space"), + ] + with pytest.raises(ValidationError) as exc_info: + validate_axis_names_unique(valid_metadata) + assert exc_info.value.rule == SpecRule.AXIS_NAMES_UNIQUE + assert exc_info.value.location == "multiscales[0].axes[2]" + + def test_per_dataset_scale_count_valid(valid_metadata): validate_per_dataset_scale_count(valid_metadata) diff --git a/py/test/test_structural_validation_parity.py b/py/test/test_structural_validation_parity.py index 6047dbd9..6e6b1841 100644 --- a/py/test/test_structural_validation_parity.py +++ b/py/test/test_structural_validation_parity.py @@ -16,13 +16,16 @@ """ import re +from pathlib import Path import pytest from ngff_zarr import ( + SUPPORTED_VERSIONS, SpecRule, ValidateOptions, ValidationError, ValidationLevel, + structural_validation, validate_structural, ) from ngff_zarr.v04.zarr_metadata import ( @@ -38,8 +41,8 @@ # The locked rule manifest: every active OME-Zarr structural rule, in canonical # declaration order. This identical literal list appears in the Deno mirror test -# so the two are directly comparable. The first eleven entries are the -# image/multiscales rules dispatched by validate_structural -- the first nine +# so the two are directly comparable. The first thirteen entries are the +# image/multiscales rules dispatched by validate_structural -- the first eleven # are the v0.4 rules and the next two (zarr-format, ome-namespace) are the v0.5 # namespacing rules, inert for v0.4; the final two are the HCS plate/well rules # dispatched by validate_plate / validate_well. @@ -47,6 +50,7 @@ "axis-count", "axis-type", "axis-order", + "axis-names-unique", "scale-length-mismatch", "global-coord-transform-after-per-level", "dataset-order-highest-to-lowest", @@ -60,6 +64,26 @@ "well-acquisition-missing", ] + +# The locked RFC-3 version manifest: the versions whose axis model is +# unrestricted, so that validate_axis_count / validate_axis_type / +# validate_axis_order / validate_spatial_axis_order are inert for them. This +# identical literal list appears in the Deno mirror test. axis-names-unique is +# deliberately absent: RFC-3 adds that rule rather than lifting it, so it is +# never inert (see docs/validation/rule-reference.md). +CANONICAL_RFC3_VERSIONS = [ + "0.9.dev1", +] + +# Every other supported version, plus the no-version default, must enforce the +# axis rules. Read off SUPPORTED_VERSIONS so a newly supported version has to +# be classified here rather than silently defaulting to "restricted". +NON_RFC3_VERSIONS = [ + version.value + for version in SUPPORTED_VERSIONS + if version.value not in CANONICAL_RFC3_VERSIONS +] + [None] + # The canonical fail-fast evaluation order of the image/multiscales # orchestrator (validate_structural). Each entry is the SpecRule the # orchestrator must raise when that rule -- and every rule after it -- is @@ -67,7 +91,7 @@ # share each: validate_axis_order and validate_spatial_axis_order both surface # AXIS_ORDER (positions 3-4), and validate_per_dataset_scale_count and # validate_transform_order both surface GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL -# (positions 5 and 7). The two HCS rules are absent: they are not part of the +# (positions 6 and 8). The two HCS rules are absent: they are not part of the # image/multiscales orchestrator. The two v0.5 namespacing rules (zarr-format, # ome-namespace) run last in the orchestrator but are inert for the v0.4 # metadata exercised here, so they never appear in the observed order. @@ -76,6 +100,7 @@ SpecRule.AXIS_TYPE, SpecRule.AXIS_ORDER, # validate_axis_order (class ordering) SpecRule.AXIS_ORDER, # validate_spatial_axis_order (spatial suffix) + SpecRule.AXIS_NAMES_UNIQUE, SpecRule.GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL, # per-dataset scale count SpecRule.SCALE_LENGTH_MISMATCH, SpecRule.GLOBAL_COORD_TRANSFORM_AFTER_PER_LEVEL, # transform order @@ -131,6 +156,19 @@ def _valid_axes_with_inconsistent_orientation() -> list[Axis]: ] +def _axes_with_repeated_name() -> list[Axis]: + """Return valid ``[c, z, y, x]`` axes with the channel axis renamed ``z``. + + Every rule before :attr:`SpecRule.AXIS_NAMES_UNIQUE` still passes: the + class order is channel-then-space, the spatial names remain the ``(z, y, + x)`` suffix, and the count is 4. Only the repeated ``z`` is left for the + orchestrator to catch, which pins the rule's position in the cascade. + """ + axes = _valid_axes_with_inconsistent_orientation() + axes[0].name = "z" + return axes + + def _first_violated_rule(metadata: Metadata) -> SpecRule: """Run the orchestrator and return the single :class:`SpecRule` it raises.""" with pytest.raises(ValidationError) as exc_info: @@ -187,8 +225,9 @@ def test_orchestrator_evaluation_order(): bad_omero = _omero("xyz") # OMERO violation (rule 9), present until repaired # Stage 1 -> AXIS_COUNT. Six axes simultaneously trip axis-type (two - # channels), axis-order (a space precedes a channel), and spatial-order - # (names are not the (z, y, x) suffix); axis-count is evaluated first. + # channels), axis-order (a space precedes a channel), spatial-order (names + # are not the (z, y, x) suffix) and axis-names-unique (a repeated "z"); + # axis-count is evaluated first. metadata = Metadata( axes=[ Axis(name="x", type="space"), @@ -196,7 +235,7 @@ def test_orchestrator_evaluation_order(): Axis(name="c2", type="channel"), Axis(name="t", type="time"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ], datasets=[ Dataset( @@ -216,44 +255,51 @@ def test_orchestrator_evaluation_order(): metadata.omero = bad_omero observed.append(_first_violated_rule(metadata)) - # Stage 2 -> AXIS_TYPE. Count fixed (5 axes); two channels remain, and a - # space still precedes a channel and the spatial names are still wrong. + # Stage 2 -> AXIS_TYPE. Count fixed (5 axes); two channels remain, a space + # still precedes a channel, the spatial names are still wrong and "z" is + # still repeated. metadata.axes = [ Axis(name="x", type="space"), Axis(name="c", type="channel"), Axis(name="c2", type="channel"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ] observed.append(_first_violated_rule(metadata)) # Stage 3 -> AXIS_ORDER (class ordering). One channel now, but a space axis - # still precedes it; the spatial names are still not the (z, y, x) suffix. + # still precedes it; the spatial names are still not the (z, y, x) suffix + # and "z" is still repeated. metadata.axes = [ Axis(name="x", type="space"), Axis(name="c", type="channel"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ] observed.append(_first_violated_rule(metadata)) # Stage 4 -> AXIS_ORDER (spatial suffix). Class order fixed (channel first), - # but spatial names (x, z, w) are not the length-3 suffix of (z, y, x). + # but spatial names (x, z, z) are not the length-3 suffix of (z, y, x). metadata.axes = [ Axis(name="c", type="channel"), Axis(name="x", type="space"), Axis(name="z", type="space"), - Axis(name="w", type="space"), + Axis(name="z", type="space"), ] observed.append(_first_violated_rule(metadata)) - # Stage 5 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] + # Stage 5 -> AXIS_NAMES_UNIQUE. Every axis rule before it now passes, but + # the channel axis is named "z" like the first space axis. + metadata.axes = _axes_with_repeated_name() + observed.append(_first_violated_rule(metadata)) + + # Stage 6 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] # (with an inconsistent-orientation violation lurking as rule 10). Dataset # 0 still has two scales, so the per-dataset-scale-count rule fires. metadata.axes = _valid_axes_with_inconsistent_orientation() observed.append(_first_violated_rule(metadata)) - # Stage 6 -> SCALE_LENGTH_MISMATCH. Dataset 0 now has exactly one scale, but + # Stage 7 -> SCALE_LENGTH_MISMATCH. Dataset 0 now has exactly one scale, but # its length is 3 against 4 axes; a scale-after-translation (rule 7) lurks. metadata.datasets[0].coordinateTransformations = [ Translation([0.0, 0.0, 0.0]), @@ -261,7 +307,7 @@ def test_orchestrator_evaluation_order(): ] observed.append(_first_violated_rule(metadata)) - # Stage 7 -> transform order. Lengths fixed to 4; dataset 0's scale still + # Stage 8 -> transform order. Lengths fixed to 4; dataset 0's scale still # follows a translation. Dataset 1 is made coarser-but-smaller so the # dataset-order rule (8) lurks behind the transform-order violation. metadata.datasets[0].coordinateTransformations = [ @@ -271,7 +317,7 @@ def test_orchestrator_evaluation_order(): metadata.datasets[1].coordinateTransformations = [Scale([1.0, 0.5, 0.5, 0.5])] observed.append(_first_violated_rule(metadata)) - # Stage 8 -> dataset order. Transform order fixed (scale before + # Stage 9 -> dataset order. Transform order fixed (scale before # translation); dataset 1 is still coarser-but-smaller than dataset 0. metadata.datasets[0].coordinateTransformations = [ Scale([1.0, 1.0, 1.0, 1.0]), @@ -279,12 +325,12 @@ def test_orchestrator_evaluation_order(): ] observed.append(_first_violated_rule(metadata)) - # Stage 9 -> OMERO color. Dataset order fixed (level 1 coarser-larger); + # Stage 10 -> OMERO color. Dataset order fixed (level 1 coarser-larger); # only the bad OMERO color and the orientation violation remain. metadata.datasets[1].coordinateTransformations = [Scale([1.0, 2.0, 2.0, 2.0])] observed.append(_first_violated_rule(metadata)) - # Stage 10 -> orientation. OMERO color fixed; the y axis still declares a + # Stage 11 -> orientation. OMERO color fixed; the y axis still declares a # different orientation type than its spatial siblings. metadata.omero = _omero("00FF88") observed.append(_first_violated_rule(metadata)) @@ -299,3 +345,106 @@ def test_orchestrator_evaluation_order(): orientation=_orientation("anatomical", "anterior-to-posterior"), ) validate_structural(metadata) + + +# --------------------------------------------------------------------------- +# Manifest: the locked RFC-3 version set +# --------------------------------------------------------------------------- + + +def _rfc3_axis_metadata() -> Metadata: + """Six same-type axes: legal under RFC-3, illegal at every other version. + + Violates axis-count (6 > 5) and the spatial-axis rules (6 > 3 ``space`` + axes) at once, and nothing else, so the orchestrator accepts it exactly + when the axis rules are inert. + """ + names = ["a", "b", "c", "d", "e", "f"] + return Metadata( + axes=[Axis(name=name, type="space") for name in names], + datasets=[ + Dataset( + path="0", + coordinateTransformations=[ + Scale([1.0] * len(names)), + Translation([0.0] * len(names)), + ], + ) + ], + coordinateTransformations=None, + ) + + +def test_rfc3_version_manifest_is_locked(): + # Inert at exactly the manifest versions... + for version in CANONICAL_RFC3_VERSIONS: + validate_structural(_rfc3_axis_metadata(), version=version) + + # ...and enforced at every other supported version, and by default. + for version in NON_RFC3_VERSIONS: + with pytest.raises(ValidationError) as exc_info: + validate_structural(_rfc3_axis_metadata(), version=version) + assert exc_info.value.rule == SpecRule.AXIS_COUNT, version + + +def _repeated_name_metadata() -> Metadata: + """A repeated axis name that no *other* axis rule can catch. + + ``(time "x", space "y", space "x")`` satisfies all four restricted axis + rules: 3 axes, one ``time`` and two ``space``, ordered time then space, and + the spatial names are the ``(y, x)`` suffix. ``axis-names-unique`` is + therefore the only rule that can fire, at every version. + """ + return Metadata( + axes=[ + Axis(name="x", type="time"), + Axis(name="y", type="space"), + Axis(name="x", type="space"), + ], + datasets=[ + Dataset( + path="0", + coordinateTransformations=[ + Scale([1.0, 1.0, 1.0]), + Translation([0.0, 0.0, 0.0]), + ], + ) + ], + coordinateTransformations=None, + ) + + +def test_axis_names_unique_is_never_inert(): + # RFC-3 *adds* this rule rather than lifting one, so unlike the other four + # axis rules it fires at the RFC-3 versions too -- and at every other. + for version in CANONICAL_RFC3_VERSIONS + NON_RFC3_VERSIONS: + with pytest.raises(ValidationError) as exc_info: + validate_structural(_repeated_name_metadata(), version=version) + assert exc_info.value.rule == SpecRule.AXIS_NAMES_UNIQUE, version + + +def _commented_rule_ids() -> list[str]: + """The rule ids listed in the canonical table at the top of the module. + + The table is a comment block, so nothing but this test keeps it in step + with :class:`SpecRule`; it lost ``axis-names-unique`` once already. + """ + source = Path(structural_validation.__file__).read_text() + table = source.split("# Canonical rule table", 1)[1].split("\nfrom ", 1)[0] + return re.findall(r"^# ([a-z0-9-]+)$", table, re.M) + + +def _documented_rule_ids() -> list[str]: + """The rule ids listed in the rule-reference table.""" + reference = ( + Path(__file__).parents[2] / "docs" / "validation" / "rule-reference.md" + ).read_text() + return re.findall(r"^\| *\d+ *\| *`([a-z0-9-]+)`", reference, re.M) + + +def test_module_rule_table_matches_the_manifest(): + assert _commented_rule_ids() == CANONICAL_SPEC_RULE_IDS + + +def test_rule_reference_doc_matches_the_manifest(): + assert _documented_rule_ids() == CANONICAL_SPEC_RULE_IDS diff --git a/py/test/test_unknown_axis_fields.py b/py/test/test_unknown_axis_fields.py index 121fcf31..b07c941c 100644 --- a/py/test/test_unknown_axis_fields.py +++ b/py/test/test_unknown_axis_fields.py @@ -153,8 +153,8 @@ def test_missing_required_name_field(zarr_helpers, tmp_path): from_ngff_zarr(store, version=version) -def test_missing_required_type_field(zarr_helpers, tmp_path): - """Test that missing 'type' field raises ValueError.""" +def test_missing_type_field_is_optional(zarr_helpers, tmp_path): + """A type-less axis is read with type None instead of being rejected.""" # Create a basic image data = np.random.rand(10, 20, 30).astype(np.float32) image = to_ngff_image( @@ -171,17 +171,17 @@ def test_missing_required_type_field(zarr_helpers, tmp_path): version = "0.4" to_ngff_zarr(store, multiscales, version=version) - # Manually corrupt the metadata by removing 'type' field + # Drop the 'type' field from the first axis. attrs = zarr_helpers["get"](store) - - # Remove the 'type' field from one axis del attrs["multiscales"][0]["axes"][0]["type"] - zarr_helpers["set"](store, attrs) - # Try to load the data - should raise ValueError - with pytest.raises(ValueError, match="missing required field 'type'"): - from_ngff_zarr(store, version=version) + # The axis reads with type None; the 'name' field stays required (below). + result = from_ngff_zarr(store, version=version) + axes = result.metadata.to_version("0.4").axes + assert axes[0].type is None + assert axes[0].name == "z" + assert axes[1].type == "space" def test_only_unknown_fields(zarr_helpers, tmp_path): diff --git a/py/test/test_v09_metadata.py b/py/test/test_v09_metadata.py new file mode 100644 index 00000000..7e930ab9 --- /dev/null +++ b/py/test/test_v09_metadata.py @@ -0,0 +1,243 @@ +# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +# SPDX-License-Identifier: MIT +"""OME-Zarr 0.9.dev1 metadata model: reading, conversion and schema reporting. + +Covers the store shapes a 0.9.dev1 reader has to accept (RFC-5 coordinate +systems, a 0.5-style flat axis list, and axes that declare no ``type``), the +conversions to and from every other version, and the axis view the write gate +builds. +""" + +import numpy as np +import pytest +import zarr +from ngff_zarr import from_ome_zarr +from ngff_zarr._supported_versions import NgffVersion +from ngff_zarr.v04.zarr_metadata import Metadata as Metadata_v04 +from ngff_zarr.v05.zarr_metadata import Metadata as Metadata_v05 +from ngff_zarr.v06.zarr_metadata import Metadata as Metadata_v06 +from ngff_zarr.v09.zarr_metadata import ( + Axis, + CoordinateSystem, + CoordinateSystemIdentifier, + Dataset, + Metadata, + Scale, + TransformSequence, + Translation, +) +from packaging import version + +zarr_v3 = pytest.mark.skipif( + version.parse(zarr.__version__) < version.parse("3.0.0b1"), + reason="OME-Zarr 0.9.dev1 is a Zarr v3 hierarchy; zarr version < 3.0.0b1", +) + + +def _write_v09(root, axes, shape, *, flat=False): + """Write a single-level 0.9.dev1 store, in either entry shape.""" + group = zarr.open_group(zarr.storage.LocalStore(str(root)), mode="w", zarr_format=3) + array = group.create_array("0", shape=shape, dtype="uint8", chunks=shape) + array[...] = np.arange(int(np.prod(shape)), dtype="uint8").reshape(shape) + + scale = {"type": "scale", "scale": [1.0] * len(axes)} + if flat: + # 0.5-style: a flat axis list, and dataset transforms that carry no + # coordinate-system identifiers. + entry = { + "axes": axes, + "datasets": [{"path": "0", "coordinateTransformations": [scale]}], + "name": "image", + } + else: + entry = { + "coordinateSystems": [{"name": "intrinsic", "axes": axes}], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "sequence", + "transformations": [scale], + "output": {"name": "intrinsic"}, + } + ], + } + ], + "name": "image", + } + group.attrs["ome"] = {"version": "0.9.dev1", "multiscales": [entry]} + return str(root) + + +def _axis(name, axis_type="space"): + return Axis(name=name, type=axis_type) + + +def _metadata(coordinate_systems): + output = CoordinateSystemIdentifier(name=coordinate_systems[0].name) + ndim = len(coordinate_systems[0].axes) + dataset = Dataset( + path="0", + coordinateTransformations=[ + TransformSequence( + transformations=[ + Scale(scale=[1.0] * ndim), + Translation(translation=[0.0] * ndim), + ], + output=output, + ) + ], + ) + return Metadata(coordinateSystems=coordinate_systems, datasets=[dataset]) + + +@zarr_v3 +def test_read_coordinate_system_shape(tmp_path): + """The RFC-5 entry shape reads back at 0.9.dev1 with its axes intact.""" + axes = [{"name": n, "type": "space"} for n in "abcdef"] + root = _write_v09(tmp_path / "cs.ome.zarr", axes, (2,) * 6) + + multiscales = from_ome_zarr(root, validate=False) + + assert type(multiscales.metadata).__module__.endswith("v09.zarr_metadata") + assert multiscales.metadata.dimension_names == tuple("abcdef") + + +@zarr_v3 +def test_read_flat_axes_shape(tmp_path): + """A 0.5-style flat axis list is accepted, not only the RFC-5 shape. + + The v0.6 reader dereferences a transform's output coordinate system, which + this shape does not declare. + """ + axes = [{"name": n, "type": "space"} for n in "zyx"] + root = _write_v09(tmp_path / "flat.ome.zarr", axes, (2, 3, 4), flat=True) + + multiscales = from_ome_zarr(root, validate=False) + + assert type(multiscales.metadata).__module__.endswith("v09.zarr_metadata") + assert multiscales.metadata.dimension_names == ("z", "y", "x") + + +@zarr_v3 +@pytest.mark.parametrize("flat", [False, True], ids=["coordinate-systems", "flat-axes"]) +def test_read_axis_without_type(tmp_path, flat): + """An axis declaring only ``name`` reads back with ``type`` ``None``.""" + axes = [{"name": "a"}, {"name": "b"}, {"name": "c"}] + root = _write_v09(tmp_path / f"untyped-{flat}.ome.zarr", axes, (2, 3, 4), flat=flat) + + multiscales = from_ome_zarr(root, validate=False) + + assert multiscales.metadata.dimension_names == ("a", "b", "c") + assert [ax.type for ax in multiscales.metadata.axes] == [None, None, None] + + +@zarr_v3 +def test_read_keeps_unknown_axis_keys_out(tmp_path): + """A schema-legal key the dataclass does not model is dropped, not fatal.""" + axes = [{"name": n, "type": "space", "longName": f"{n} axis"} for n in "zyx"] + root = _write_v09(tmp_path / "longname.ome.zarr", axes, (2, 3, 4)) + + multiscales = from_ome_zarr(root, validate=False) + + assert multiscales.metadata.dimension_names == ("z", "y", "x") + + +@pytest.mark.parametrize("version", ["0.4", "0.5", "0.6", "0.9.dev1"]) +def test_to_version_round_trip(version): + """0.9.dev1 converts to every version and back without losing the axes.""" + metadata = _metadata([CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"])]) + + converted = metadata.to_version(version) + expected = { + "0.4": Metadata_v04, + "0.5": Metadata_v05, + "0.6": Metadata_v06, + "0.9.dev1": Metadata, + }[version] + assert isinstance(converted, expected) + + back = Metadata.from_version(converted) + assert isinstance(back, Metadata) + assert back.dimension_names == ("z", "y", "x") + + +def test_from_version_reinstantiates_axes(): + """Converting up yields v0.9.dev1 axes, not the source version's objects.""" + metadata = _metadata([CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"])]) + downgraded = metadata.to_version("0.4") + + upgraded = Metadata.from_version(downgraded) + + assert all(isinstance(ax, Axis) for ax in upgraded.axes) + # The source keeps its own axis objects. + assert upgraded.axes is not downgraded.axes + + +def test_axis_views_cover_every_coordinate_system(): + """The write gate inspects every coordinate system, not just the intrinsic one. + + ``Metadata`` exposes an ``axes`` property returning only the intrinsic + system's axes, so a view built from that attribute would miss the others. + """ + from ngff_zarr.to_ngff_zarr import _axis_views + + metadata = _metadata( + [ + CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"]), + CoordinateSystem("other", [_axis(n) for n in "abcdef"]), + ] + ) + + views = _axis_views(metadata) + + assert len(views) == 2 + assert [len(view.axes) for _, view in views] == [3, 6] + + +def test_axes_property_is_not_a_field(): + """``axes`` must not be serialized into the multiscales entry.""" + from dataclasses import asdict + + metadata = _metadata([CoordinateSystem("intrinsic", [_axis(n) for n in "zyx"])]) + + assert "axes" not in asdict(metadata) + assert metadata.axes == metadata.coordinateSystems[0].axes + + +def test_no_bundled_schema_is_reported_explicitly(): + """``load_schema`` names the missing 0.9.dev1 schema instead of failing on I/O.""" + from ngff_zarr.validate import load_schema + + with pytest.raises(ValueError, match="0.9.dev1"): + load_schema(version="0.9.dev1") + + +def test_version_is_supported(): + assert NgffVersion("0.9.dev1") is NgffVersion.V09dev1 + # 0.9.dev1 is opt-in: it must not become the default target. + assert NgffVersion.LATEST is NgffVersion.V06dev4 + + +@pytest.mark.parametrize( + "version, accepted", + [("0.4", False), ("0.5", False), ("0.6", False), ("0.9.dev1", True)], +) +def test_validate_structural_is_version_aware(version, accepted): + """The axis rules are inert only at 0.9.dev1. + + The conformance driver depends on this: validating an RFC-3 dataset at a + pre-0.9.dev1 version would report every case as a failure. + """ + from ngff_zarr.structural_validation import ValidationError, validate_structural + + metadata = _metadata( + [CoordinateSystem("intrinsic", [_axis(n) for n in "abcdef"])] + ).to_version("0.4") + + if accepted: + validate_structural(metadata, version=version) + else: + with pytest.raises(ValidationError): + validate_structural(metadata, version=version) diff --git a/ts/src/io/from_ngff_zarr-browser.ts b/ts/src/io/from_ngff_zarr-browser.ts index af463d9c..834462bf 100644 --- a/ts/src/io/from_ngff_zarr-browser.ts +++ b/ts/src/io/from_ngff_zarr-browser.ts @@ -7,7 +7,7 @@ import * as zarr from "zarrita"; import { MetadataSchema } from "../schemas/zarr_metadata.ts"; import { NgffMultiscales } from "../types/multiscales.ts"; import { NgffImage } from "../types/ngff_image.ts"; -import type { Units } from "../types/units.ts"; +import type { AxisUnit } from "../types/units.ts"; import type { Metadata, Omero } from "../types/zarr_metadata.ts"; import { extractMethodMetadata } from "../utils/parse_metadata.ts"; import { fromZarrAttrsV06 } from "../utils/from_zarr_attrs.ts"; @@ -19,7 +19,7 @@ export interface FromOmeZarrOptions { /** Enable schema validation of OME-Zarr metadata. */ validate?: boolean; /** Expected OME-Zarr version. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; /** * Optional decoded-chunk cache passed to `zarrGet` calls. * @@ -278,7 +278,7 @@ export async function fromOmeZarr( } return acc; }, - {} as Record, + {} as Record, ); const ngffImage = new NgffImage({ diff --git a/ts/src/io/from_ngff_zarr.ts b/ts/src/io/from_ngff_zarr.ts index 96cec909..eef12098 100644 --- a/ts/src/io/from_ngff_zarr.ts +++ b/ts/src/io/from_ngff_zarr.ts @@ -21,7 +21,7 @@ export interface FromOmeZarrOptions { /** Enable schema validation of OME-Zarr metadata. */ validate?: boolean; /** Expected OME-Zarr version. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; /** * Optional decoded-chunk cache passed to `zarrGet` calls. * @@ -141,10 +141,19 @@ export async function fromOmeZarr( } // Parse metadata using version-specific function. The v0.6 reader handles - // both `0.6` and the draft `0.6.dev4` on-disk version strings. + // both `0.6` and the draft `0.6.dev4` on-disk version strings, and + // `0.9.dev1`, which uses the same coordinate-system layout. let result; - if (isV06Version(detectedVersion)) { + if ( + isV06Version(detectedVersion) || detectedVersion === NgffVersion.V09dev1 + ) { result = await fromZarrAttrsV06(rootAttrs, resolvedStore, validate); + // The v0.6 parser records `0.6`, which is right for the whole 0.6 + // family but not for a 0.9.dev1 store: that string is the on-disk + // version, and callers inspect `metadata.version` to tell them apart. + if (detectedVersion === NgffVersion.V09dev1) { + result.metadata.version = NgffVersion.V09dev1; + } } else if (detectedVersion === NgffVersion.V05) { result = await fromZarrAttrsV05(rootAttrs, resolvedStore, validate); } else { diff --git a/ts/src/io/to_ngff_zarr-browser.ts b/ts/src/io/to_ngff_zarr-browser.ts index cb8855ce..c0e07830 100644 --- a/ts/src/io/to_ngff_zarr-browser.ts +++ b/ts/src/io/to_ngff_zarr-browser.ts @@ -20,7 +20,7 @@ export { isOzxPath } from "./rfc9_zip.ts"; export interface ToOmeZarrOptions { overwrite?: boolean; - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; chunksPerShard?: number | number[] | Record; /** * Custom codec pipeline for array compression. When omitted the default diff --git a/ts/src/io/to_ngff_zarr.ts b/ts/src/io/to_ngff_zarr.ts index 7921ecda..cfc0a6ad 100644 --- a/ts/src/io/to_ngff_zarr.ts +++ b/ts/src/io/to_ngff_zarr.ts @@ -23,7 +23,7 @@ export interface ToOmeZarrOptions { * it defaults to 0.5. If explicitly set to any other value for .ozx files, * an error will be thrown. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; chunksPerShard?: number | number[] | Record; /** * Custom codec pipeline for array compression. When omitted the default diff --git a/ts/src/io/to_ngff_zarr_ozx_common.ts b/ts/src/io/to_ngff_zarr_ozx_common.ts index 92fa4150..e34a967e 100644 --- a/ts/src/io/to_ngff_zarr_ozx_common.ts +++ b/ts/src/io/to_ngff_zarr_ozx_common.ts @@ -12,12 +12,111 @@ import * as zarr from "zarrita"; import type { NgffMultiscales } from "../types/multiscales.ts"; import type { NgffImage } from "../types/ngff_image.ts"; import type { Axis, MetadataInterface } from "../types/zarr_metadata.ts"; +import type { MemoryStore } from "./rfc9_zip.ts"; import { buildV06MultiscalesEntry, legacyTopLevelTransforms, } from "../utils/v06_metadata.ts"; -import { V06_ONDISK_VERSION } from "../types/supported_versions.ts"; -import type { MemoryStore } from "./rfc9_zip.ts"; +import { + NgffVersion, + V06_ONDISK_VERSION, +} from "../types/supported_versions.ts"; +import { + SpecRule, + validateAxisCount, + validateAxisNamesUnique, + validateAxisOrder, + validateAxisType, + validateSpatialAxisOrder, + ValidationError, +} from "../utils/structural_validation.ts"; +import { pyRepr, pyReprOptional } from "../utils/py_format.ts"; + +/** + * Every axis list `metadata` will serialize at `version`, paired with its + * location. + * + * Only the v0.6 family writes `coordinateSystems`, and + * `coordinate_systems.schema` applies `axes.schema` to each one, so every + * system is returned there. A v0.4/v0.5 target writes a single flat `axes` and + * drops the systems, so that is the only list to gate: refusing a system the + * downgrade discards would reject a document the writer can emit, and name a + * node absent from it. Python reaches the same set by gating after + * `Metadata.to_version`. + */ +function axisViews( + metadata: MetadataInterface, + version: string, +): Array<{ location: string; axes: Axis[] }> { + const systems = metadata.coordinateSystems ?? []; + const serializesSystems = version === "0.6" || + version === NgffVersion.V09dev1; + if (serializesSystems && systems.length > 0) { + // `buildV06MultiscalesEntry` serializes the first system from + // `metadata.axes` and the later ones verbatim, and `MetadataInterface` + // does not tie `axes` to `coordinateSystems[0].axes`. Gate what is + // written, not what is declared. + return systems.map((system, index) => ({ + location: `multiscales[0].coordinateSystems[${index}].axes`, + axes: index === 0 ? metadata.axes : system.axes, + })); + } + return [{ location: "multiscales[0].axes", axes: metadata.axes }]; +} + +/** + * Refuse to serialize an axis model the target `version` cannot express. + * + * Reuses the axis rules of the structural pass, but this is a second + * dispatcher, not the same pass as {@link validateStructural}, and it differs + * from it two ways: + * + * 1. It runs only the five axis rules, not the full image cascade. + * 2. Where the target version serializes coordinate systems, it checks *every* + * one of them (see {@link axisViews}), while the structural pass reduces the + * metadata to the intrinsic system's axes. + */ +function gateAxisModel( + metadata: MetadataInterface, + version: string, +): void { + const rules = [ + validateAxisCount, + validateAxisType, + validateAxisOrder, + validateSpatialAxisOrder, + validateAxisNamesUnique, + ]; + for (const view of axisViews(metadata, version)) { + for (const rule of rules) { + try { + rule({ axes: view.axes }, version); + } catch (error) { + if (!(error instanceof ValidationError)) { + throw error; + } + const rendered = view.axes + .map((ax) => `${pyRepr(ax.name)}(type=${pyReprOptional(ax.type)})`) + .join(", "); + if (error.rule === SpecRule.AxisNamesUnique) { + // Required at every version, 0.9.dev1 included. + throw new Error( + `Cannot write OME-Zarr version="${version}": ${error.detail} ` + + `Axes at ${view.location}: [${rendered}].`, + ); + } + throw new Error( + `Cannot write OME-Zarr version="${version}": this axis model violates ` + + `that version's [${error.rule}] rule. ${error.detail} ` + + `Axes at ${view.location}: [${rendered}]. ` + + `Pass version="${NgffVersion.V09dev1}" to write it: 0.9.dev1 is the ` + + `only OME-Zarr version that adopts RFC-3 (arbitrary axis count, ` + + `names, types and ordering).`, + ); + } + } + } +} /** * Process axes for serialization. @@ -72,11 +171,25 @@ export function processAxes( */ export function buildRootAttributes( metadata: MetadataInterface, - version: "0.4" | "0.5" | "0.6", + version: "0.4" | "0.5" | "0.6" | "0.9.dev1", ): Record { + gateAxisModel(metadata, version); + // Process axes (orientation included when present). const processedAxes = processAxes(metadata.axes); + if (version === "0.9.dev1") { + // "0.9.dev1" is already the on-disk string. + const v09Entry = buildV06MultiscalesEntry(metadata, processedAxes); + return { + ome: { + version: NgffVersion.V09dev1, + multiscales: [v09Entry], + ...(metadata.omero && { omero: metadata.omero }), + }, + }; + } + if (version === "0.6") { const v06Entry = buildV06MultiscalesEntry(metadata, processedAxes); return { diff --git a/ts/src/io/upgrade_ome_zarr_common.ts b/ts/src/io/upgrade_ome_zarr_common.ts index fdf20974..920c3f67 100644 --- a/ts/src/io/upgrade_ome_zarr_common.ts +++ b/ts/src/io/upgrade_ome_zarr_common.ts @@ -37,7 +37,7 @@ export interface UpgradeOmeZarrOptions { */ output?: string | MemoryStore; /** Target OME-Zarr specification version. Defaults to `"0.6"`. */ - version?: "0.4" | "0.5" | "0.6"; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; /** Validate the source metadata against the NGFF schema while reading. */ validate?: boolean; /** @@ -52,12 +52,18 @@ export interface UpgradeOmeZarrOptions { export interface UpgradeOmeZarrDeps { fromOmeZarr: ( store: UpgradeInput, - options?: { validate?: boolean; version?: "0.4" | "0.5" | "0.6" }, + options?: { + validate?: boolean; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; + }, ) => Promise; toOmeZarr: ( store: string | MemoryStore | zarr.FetchStore, multiscales: NgffMultiscales, - options?: { overwrite?: boolean; version?: "0.4" | "0.5" | "0.6" }, + options?: { + overwrite?: boolean; + version?: "0.4" | "0.5" | "0.6" | "0.9.dev1"; + }, ) => Promise; /** * Resolve `input` to a writable store for an in-place rewrite. Must reject a diff --git a/ts/src/types/ngff_image.ts b/ts/src/types/ngff_image.ts index ae57348b..b71e0de5 100644 --- a/ts/src/types/ngff_image.ts +++ b/ts/src/types/ngff_image.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC // SPDX-License-Identifier: MIT import * as zarr from "zarrita"; -import type { Units } from "./units.ts"; +import type { AxisUnit } from "./units.ts"; import type { AnatomicalOrientation } from "./rfc4.ts"; export type ComputedCallback = () => void; @@ -12,7 +12,7 @@ export interface NgffImageOptions { scale: Record; translation: Record; name: string | undefined; - axesUnits: Record | undefined; + axesUnits: Record | undefined; axesOrientations?: Record | undefined; axesTypes?: Record | undefined; computedCallbacks: ComputedCallback[] | undefined; @@ -24,7 +24,7 @@ export class NgffImage { public readonly scale: Record; public readonly translation: Record; public readonly name: string; - public readonly axesUnits: Record | undefined; + public readonly axesUnits: Record | undefined; public readonly axesOrientations: | Record | undefined; diff --git a/ts/src/types/supported_versions.ts b/ts/src/types/supported_versions.ts index 5543d86f..0ebc9d1f 100644 --- a/ts/src/types/supported_versions.ts +++ b/ts/src/types/supported_versions.ts @@ -18,6 +18,12 @@ export enum NgffVersion { * see {@link V06_ONDISK_VERSION}. Temporary โ€” tracks the upstream dev release. */ V06dev4 = "0.6.dev4", + /** + * OME-Zarr 0.9 in development: v0.6 plus RFC-3 (any axis count, names, + * types and ordering). Unlike {@link V06dev4} this is the on-disk string. + * {@link LATEST} stays `0.6.dev4`, so 0.9.dev1 is opt-in. + */ + V09dev1 = "0.9.dev1", LATEST = "0.6.dev4", } @@ -41,6 +47,7 @@ export const SUPPORTED_VERSIONS: readonly NgffVersion[] = [ NgffVersion.V05, NgffVersion.V06, NgffVersion.V06dev4, + NgffVersion.V09dev1, ] as const; /** @@ -59,3 +66,15 @@ export function isSupportedVersion(version: string): version is NgffVersion { export function isV06Version(version: string): boolean { return version.startsWith("0.6"); } + +/** + * Whether a version adopts the RFC-3 free-form axis model. + * + * Only `0.9.dev1` does: the bundled 0.4, 0.5 and 0.6 axes schemas all cap the + * axis count at 5, and require 2-3 `space` axes (0.6 excepted for an `array` + * coordinate system, see `takesArraySchemaBranch`). `undefined` applies the + * restrictions. + */ +export function isRfc3AxisModelAllowed(version?: string): boolean { + return version !== undefined && version === NgffVersion.V09dev1; +} diff --git a/ts/src/types/units.ts b/ts/src/types/units.ts index ad99315f..2e50151e 100644 --- a/ts/src/types/units.ts +++ b/ts/src/types/units.ts @@ -12,6 +12,19 @@ export type AxesType = | "coordinate" | "displacement"; +/** + * Axis name. RFC-3 (OME-Zarr 0.9.dev1) permits any string; below 0.9.dev1 the + * `SupportedDims` convention is enforced by structural validation, not the + * type system. The union with the literal set keeps editor completion. + */ +export type AxisName = SupportedDims | (string & Record); + +/** + * Axis type. RFC-3 (OME-Zarr 0.9.dev1) permits any string alongside the + * spec-defined `AxesType` set, and `type` may be omitted entirely. + */ +export type AxisType = AxesType | (string & Record); + export type SpaceUnits = | "angstrom" | "attometer" @@ -67,6 +80,12 @@ export type TimeUnits = export type Units = SpaceUnits | TimeUnits; +/** + * Axis unit. RFC-3 (OME-Zarr 0.9.dev1) permits any string alongside the + * spec-defined `Units` vocabulary, and `unit` may be omitted entirely. + */ +export type AxisUnit = Units | (string & Record); + export const supportedDims: SupportedDims[] = ["x", "y", "z", "c", "t"]; export const spaceUnits: SpaceUnits[] = [ diff --git a/ts/src/types/zarr_metadata.ts b/ts/src/types/zarr_metadata.ts index 98fb33cb..e820e786 100644 --- a/ts/src/types/zarr_metadata.ts +++ b/ts/src/types/zarr_metadata.ts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC // SPDX-License-Identifier: MIT -import type { AxesType, SupportedDims, Units } from "./units.ts"; +import type { AxisName, AxisType, AxisUnit, SupportedDims } from "./units.ts"; import { NgffVersion } from "./supported_versions.ts"; import type { NgffImage } from "./ngff_image.ts"; import type { AnatomicalOrientation } from "./rfc4.ts"; @@ -22,9 +22,10 @@ export interface AxisOrientation { } export interface Axis { - name: SupportedDims; - type: AxesType; - unit: Units | undefined; + name: AxisName; + // The v0.4 schema accepts an axis that declares no `type`. + type: AxisType | undefined; + unit: AxisUnit | undefined; orientation?: AxisOrientation | AnatomicalOrientation | undefined; discrete?: boolean; } diff --git a/ts/src/utils/factory.ts b/ts/src/utils/factory.ts index 1da70d6b..b9d8f880 100644 --- a/ts/src/utils/factory.ts +++ b/ts/src/utils/factory.ts @@ -12,7 +12,7 @@ import type { Scale, Translation, } from "../types/zarr_metadata.ts"; -import type { AxesType, SupportedDims, Units } from "../types/units.ts"; +import type { AxesType, AxisUnit, SupportedDims } from "../types/units.ts"; import type { Methods } from "../types/methods.ts"; import type { AnatomicalOrientation } from "../types/rfc4.ts"; @@ -64,7 +64,7 @@ export async function createNgffImage( export function createAxis( name: SupportedDims, type: AxesType, - unit?: Units, + unit?: AxisUnit, orientation?: AxisOrientation | AnatomicalOrientation, ): Axis { const axis: Axis = { diff --git a/ts/src/utils/from_zarr_attrs.ts b/ts/src/utils/from_zarr_attrs.ts index 7c347a61..90e00ad8 100644 --- a/ts/src/utils/from_zarr_attrs.ts +++ b/ts/src/utils/from_zarr_attrs.ts @@ -21,7 +21,7 @@ import type { import { SUPPORTED_DIMS } from "../types/zarr_metadata.ts"; import { extractScaleTranslation, parseV06Transforms } from "./v06_metadata.ts"; import { NgffImage } from "../types/ngff_image.ts"; -import type { AxesType, SupportedDims, Units } from "../types/units.ts"; +import type { AxesType, AxisUnit, SupportedDims } from "../types/units.ts"; import { parseOmero } from "./parse_metadata.ts"; import type { MemoryStore } from "../io/from_ngff_zarr.ts"; import { @@ -215,7 +215,7 @@ export async function fromZarrAttrsV04( // Handle backwards compatibility for version <= 0.3 let dims: string[]; let axes: Axis[]; - let units: Record; + let units: Record; if (!("axes" in multiscalesMetadata)) { // Version <= 0.3 - use default dims @@ -257,9 +257,14 @@ export async function fromZarrAttrsV04( axes = axesData.map((axis) => { const axisObj = axis as Record; return { - name: String(axisObj.name) as SupportedDims, - type: String(axisObj.type) as AxesType, - unit: axisObj.unit as Units | undefined, + // RFC-3 permits any axis name, so the parsed value stays a string + // rather than being narrowed to the closed `SupportedDims` union. + name: String(axisObj.name), + // `type` is optional; keep undefined rather than the "undefined" string + type: axisObj.type === undefined || axisObj.type === null + ? undefined + : String(axisObj.type) as AxesType, + unit: axisObj.unit as AxisUnit | undefined, // Preserve RFC 4 orientation on the parsed axis so the strict // structural pass below can run validateAxisOrientation; dropping it // here makes that rule a no-op. This mirrors the Python port, whose @@ -294,7 +299,7 @@ export async function fromZarrAttrsV04( if (typeof axis === "object") { const axisObj = axis as Record; const name = axisObj.name as string; - const unit = axisObj.unit as Units | undefined; + const unit = axisObj.unit as AxisUnit | undefined; if (name !== undefined && unit !== undefined) { units[name] = unit; } @@ -398,7 +403,7 @@ export async function fromZarrAttrsV04( coordinateTransformations, }); - const filteredUnits: Record = {}; + const filteredUnits: Record = {}; for (const [axis, unit] of Object.entries(units)) { if (unit !== undefined && unit !== null) { filteredUnits[axis] = unit; @@ -473,7 +478,11 @@ export async function fromZarrAttrsV04( // scoped to v0.4 and newer. if (validate) { if (isSpecVersionAtLeastV04(metadata.version)) { - validateStructural(metadata, { level: ValidationLevel.Strict }); + validateStructural( + metadata, + { level: ValidationLevel.Strict }, + metadata.version, + ); } } @@ -551,6 +560,9 @@ export async function fromZarrAttrsV06( // v0.6 wraps the multiscales under the "ome" key; tolerate a root-level // layout for symmetry with the v0.5 reader. const omeData = rootAttrs.ome as Record | undefined; + // v0.6 and 0.9.dev1 share this reader; the group-level `version` says which. + const declaredVersion = (omeData?.version as string | undefined) ?? + (rootAttrs.version as string | undefined) ?? "0.6"; let entry: Record; let omeroRaw: unknown; if (omeData && "multiscales" in omeData) { @@ -600,9 +612,14 @@ export async function fromZarrAttrsV06( return { name: String(cs.name), axes: (cs.axes as Array>).map((axis) => ({ - name: String(axis.name) as SupportedDims, - type: String(axis.type) as AxesType, - unit: axis.unit as Units | undefined, + // RFC-3 permits any axis name, so the parsed value stays a string + // rather than being narrowed to the closed `SupportedDims` union. + name: String(axis.name), + // `type` is optional; keep undefined rather than the "undefined" string + type: axis.type === undefined || axis.type === null + ? undefined + : String(axis.type) as AxesType, + unit: axis.unit as AxisUnit | undefined, ...(axis.orientation !== undefined && axis.orientation !== null ? { orientation: axis.orientation as Axis["orientation"] } : {}), @@ -623,7 +640,7 @@ export async function fromZarrAttrsV06( >; const dims = intrinsic.axes.map((axis) => String(axis.name)); - const units: Record = {}; + const units: Record = {}; for (const axis of intrinsic.axes) { if (axis.unit !== undefined && axis.unit !== null) { units[axis.name] = axis.unit; @@ -774,8 +791,18 @@ export async function fromZarrAttrsV06( // reconstructed into the same shape, so the same rules apply. Full RFC-5 wire // validation (coordinate-system graphs, arbitrary transform chains) remains // deferred. + // + // The declared version is what decides whether the axis rules apply: it + // selects the RFC-3 axis model at 0.9.dev1 and the `array` coordinate-system + // arm of the v0.6 axes schema. Validating without it enforces the v0.4 caps + // on every store, so a document this port's own writer emits is refused on + // the way back in. if (validate) { - validateStructural(metadata, { level: ValidationLevel.Strict }); + validateStructural( + metadata, + { level: ValidationLevel.Strict }, + declaredVersion, + ); } return { metadata, images }; diff --git a/ts/src/utils/py_format.ts b/ts/src/utils/py_format.ts index 077cba9b..6bbc14e9 100644 --- a/ts/src/utils/py_format.ts +++ b/ts/src/utils/py_format.ts @@ -7,16 +7,41 @@ * which the cross-language parity tests assert. */ +/** + * Render a string as Python's `repr()` renders it. + * + * A single-quoted literal, switching to double quotes when the value contains a + * single quote but no double quote. RFC-3 puts arbitrary strings in `name` and + * `type`, and no schema excludes a quote, so the switch is reachable. + */ +export function pyRepr(value: string): string { + const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; + const escaped = value.replaceAll("\\", "\\\\").replaceAll( + quote, + `\\${quote}`, + ); + return `${quote}${escaped}${quote}`; +} + /** * Render a list of names as a Python-style list literal, e.g. `['z', 'y']`. * * Mirrors Python's `str(list_of_str)` / f-string rendering: bracketed, - * single-quoted elements, comma-space separated. Both the structural + * `repr`-rendered elements, comma-space separated. Both the structural * spatial-axis-order message and the RFC 4 axis-orientation-on-non-space * message interpolate this verbatim, so the two ports stay byte-for-byte - * identical. Axis names reaching these rules are single-quote-free, so the - * plain single-quoted element form is exact. + * identical. */ export function formatNameList(names: readonly string[]): string { - return `[${names.map((name) => `'${name}'`).join(", ")}]`; + return `[${names.map(pyRepr).join(", ")}]`; +} + +/** + * Render an optional string the way Python renders `{value!r}`. + * + * An axis may declare no `type`; Python renders that `None`, TypeScript would + * render `undefined` (or `null` when the JSON carried an explicit null). + */ +export function pyReprOptional(value: string | null | undefined): string { + return value === undefined || value === null ? "None" : pyRepr(value); } diff --git a/ts/src/utils/structural_validation.ts b/ts/src/utils/structural_validation.ts index e08c91e9..c605fd9b 100644 --- a/ts/src/utils/structural_validation.ts +++ b/ts/src/utils/structural_validation.ts @@ -37,7 +37,11 @@ import { hasRfc4OrientationMetadata, validateRfc4Orientation, } from "./rfc4_validation.ts"; -import { formatNameList } from "./py_format.ts"; +import { formatNameList, pyRepr } from "./py_format.ts"; +import { + isRfc3AxisModelAllowed, + isV06Version, +} from "../types/supported_versions.ts"; /** * Stable, kebab-case identifiers for the structural specification rules. @@ -54,6 +58,8 @@ export const SpecRule = { AxisType: "axis-type", /** time before channel before space; spatial names suffix (z, y, x). */ AxisOrder: "axis-order", + /** Axis names are unique within a dataset. */ + AxisNamesUnique: "axis-names-unique", /** Every scale/translation vector length equals the axis count. */ ScaleLengthMismatch: "scale-length-mismatch", /** Exactly one scale per dataset; a translation must follow it. */ @@ -128,9 +134,17 @@ export class ValidationError extends Error { readonly rule: SpecRule; /** Dotted-segment location of the offending metadata node, if known. */ readonly location?: string; + /** + * The rule text without the `Spec rule [...] violated: ` prefix. + * + * `Error.message` is the prefixed form, matching Python's `str(exc)`; this is + * Python's `exc.message`, for callers that supply their own prefix. + */ + readonly detail: string; constructor(rule: SpecRule, message: string, location?: string) { super(`Spec rule [${rule}] violated: ${message}`); + this.detail = message; this.name = "ValidationError"; this.rule = rule; if (location !== undefined) { @@ -209,6 +223,29 @@ const AXIS_TYPE_RANK: Record = { */ const SPATIAL_AXIS_NAMES = ["z", "y", "x"] as const; +/** + * The v0.6 `axes` schema is a `oneOf`: either 2 or 3 `space` axes, or two or + * more `array` axes. An RFC-5 array coordinate system takes the second branch + * and declares no `space` axis at all. + */ +const MIN_ARRAY_AXES = 2; + +/** + * Whether `axes` satisfies the `array` arm of the v0.6 axes schema. + * + * Only v0.6 has that arm; the v0.4 and v0.5 schemas require 2 or 3 `space` + * axes unconditionally, so the space-axis floor is not relaxed for them. + */ +function takesArraySchemaBranch( + axes: Pick[], + version?: string, +): boolean { + if (version === undefined || !isV06Version(version)) { + return false; + } + return axes.filter((ax) => ax.type === "array").length >= MIN_ARRAY_AXES; +} + /** * Render a number the way Python's `str()`/f-string renders a `float`: a * whole-number value keeps a trailing `.0` (e.g. `2` -> `"2.0"`), matching the @@ -225,18 +262,25 @@ function pyFloat(value: number): string { /** * Validate that the axis count is within the v0.4-permitted range. * - * OME-Zarr v0.4 requires between 2 and 5 axes, inclusive. + * OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes, inclusive. * * @param metadata - The parsed multiscales metadata to validate. * @throws {ValidationError} With {@link SpecRule.AxisCount} when * `metadata.axes.length` lies outside `2..5`; location `multiscales[0].axes`. */ -export function validateAxisCount(metadata: Metadata): void { +export function validateAxisCount( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const count = metadata.axes.length; if (count < 2 || count > 5) { throw new ValidationError( SpecRule.AxisCount, - `OME-Zarr v0.4 requires between 2 and 5 axes, inclusive; found ${count}.`, + `OME-Zarr v0.4, v0.5 and v0.6 require between 2 and 5 axes, ` + + `inclusive; found ${count}.`, "multiscales[0].axes", ); } @@ -252,7 +296,13 @@ export function validateAxisCount(metadata: Metadata): void { * `time` axis or more than one `channel` axis is present; location * `multiscales[0].axes`. */ -export function validateAxisType(metadata: Metadata): void { +export function validateAxisType( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const timeCount = metadata.axes.filter((ax) => ax.type === "time").length; if (timeCount > 1) { throw new ValidationError( @@ -286,13 +336,25 @@ export function validateAxisType(metadata: Metadata): void { * adjacent pair where a lower-ranked axis type follows a higher-ranked one; * location `multiscales[0].axes[i+1]`. */ -export function validateAxisOrder(metadata: Metadata): void { +export function validateAxisOrder( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const axes = metadata.axes; for (let i = 0; i < axes.length - 1; i++) { const current = axes[i]; const following = axes[i + 1]; - const currentRank = AXIS_TYPE_RANK[current.type]; - const followingRank = AXIS_TYPE_RANK[following.type]; + // An axis with no `type` has no rank, mirroring the Python port's dict + // `.get()`; the pair is then skipped by the guard below. + const currentRank = current.type === undefined + ? undefined + : AXIS_TYPE_RANK[current.type]; + const followingRank = following.type === undefined + ? undefined + : AXIS_TYPE_RANK[following.type]; if (currentRank === undefined || followingRank === undefined) { continue; } @@ -320,7 +382,13 @@ export function validateAxisOrder(metadata: Metadata): void { * than three `space` axes, or when their names are not the expected suffix of * `(z, y, x)`; location `multiscales[0].axes` or the first offending axis. */ -export function validateSpatialAxisOrder(metadata: Metadata): void { +export function validateSpatialAxisOrder( + metadata: Pick, + version?: string, +): void { + if (isRfc3AxisModelAllowed(version)) { + return; + } const spaceIndices: number[] = []; metadata.axes.forEach((ax, i) => { if (ax.type === "space") { @@ -331,7 +399,16 @@ export function validateSpatialAxisOrder(metadata: Metadata): void { if (count > 3) { throw new ValidationError( SpecRule.AxisOrder, - `OME-Zarr v0.4 permits at most 3 'space' axes; found ${count}.`, + `OME-Zarr v0.4, v0.5 and v0.6 permit at most 3 'space' axes; ` + + `found ${count}.`, + "multiscales[0].axes", + ); + } + if (count < 2 && !takesArraySchemaBranch(metadata.axes, version)) { + throw new ValidationError( + SpecRule.AxisOrder, + `OME-Zarr v0.4, v0.5 and v0.6 require 2 or 3 'space' axes; ` + + `found ${count}.`, "multiscales[0].axes", ); } @@ -348,6 +425,38 @@ export function validateSpatialAxisOrder(metadata: Metadata): void { } } +/** + * Validate that axis names are unique within the dataset. + * + * Never inert. It states RFC-3 rule 5, "axis names MUST NOT be repeated within + * a dataset". No released schema carries it, so below `0.9.dev1` it is a + * strictness choice rather than a spec MUST of those versions. Rule 5 also says + * names SHOULD NOT differ only by case. That is a SHOULD and is not enforced. + * + * @param metadata - The parsed multiscales metadata to validate. + * @throws {ValidationError} With {@link SpecRule.AxisNamesUnique} for the first + * axis whose `name` duplicates an earlier one; location + * `multiscales[0].axes[i]`. + */ +export function validateAxisNamesUnique( + metadata: Pick, + _version?: string, +): void { + const seen = new Set(); + for (let i = 0; i < metadata.axes.length; i++) { + const name = metadata.axes[i].name; + if (seen.has(name)) { + throw new ValidationError( + SpecRule.AxisNamesUnique, + `Axis name '${name}' is repeated; axis names must be unique ` + + `within a dataset.`, + `multiscales[0].axes[${i}]`, + ); + } + seen.add(name); + } +} + /** * Validate that each dataset defines exactly one `scale` transform. * @@ -668,26 +777,6 @@ export function validateAxisOrientation(metadata: Metadata): void { } } -/** - * Render a string as Python's `repr()` renders it, for byte-identical messages. - * - * The plate/well rule messages interpolate row, column, and well-path strings - * the way the Python implementation does with `{value!r}`: a single-quoted - * literal, switching to double quotes only when the value contains a single - * quote but no double quote. Keeping this byte-for-byte identical to Python is a - * property the cross-language parity tests assert. Inputs reaching these rules - * are alphanumeric path segments, so this resolves to a plain single-quoted - * form in practice. - */ -function pyRepr(value: string): string { - const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; - const escaped = value.replaceAll("\\", "\\\\").replaceAll( - quote, - `\\${quote}`, - ); - return `${quote}${escaped}${quote}`; -} - /** * Validate that a v0.5 multiscales entry implies a Zarr v3 store. * @@ -876,16 +965,17 @@ export function validateWellAcquisition( * 2. {@link validateAxisType} * 3. {@link validateAxisOrder} * 4. {@link validateSpatialAxisOrder} - * 5. {@link validatePerDatasetScaleCount} - * 6. {@link validateScaleLength} - * 7. {@link validateTransformOrder} - * 8. {@link validateDatasetOrder} - * 9. {@link validateOmeroColorHex} - * 10. {@link validateAxisOrientation} - * 11. {@link validateZarrFormatForVersion} - * 12. {@link validateOmeNamespace} - * - * Rules 11 and 12 are the OME-Zarr v0.5 namespacing checks; they fire only for + * 5. {@link validateAxisNamesUnique} + * 6. {@link validatePerDatasetScaleCount} + * 7. {@link validateScaleLength} + * 8. {@link validateTransformOrder} + * 9. {@link validateDatasetOrder} + * 10. {@link validateOmeroColorHex} + * 11. {@link validateAxisOrientation} + * 12. {@link validateZarrFormatForVersion} + * 13. {@link validateOmeNamespace} + * + * Rules 12 and 13 are the OME-Zarr v0.5 namespacing checks; they fire only for * v0.5 metadata and are inert (a no-op) for v0.4. * * Under {@link ValidationLevel.SchemaOnly} this function returns immediately @@ -902,6 +992,9 @@ export function validateWellAcquisition( * @param metadata - The parsed OME-Zarr v0.4 multiscales metadata to validate. * @param options - Validation options; defaults to * `{ level: "strict", allowUnknownFields: true }`. + * @param version - The OME-Zarr version the metadata declares. Rules 1-3 are + * inert for the versions that adopt the RFC-3 axis model, so omitting it holds + * every store to the v0.4 axis caps. * @throws {ValidationError} For the first structural rule violated, carrying * the offending {@link SpecRule} and `location`. Never thrown under * {@link ValidationLevel.SchemaOnly}, which runs no structural rule. @@ -909,6 +1002,7 @@ export function validateWellAcquisition( export function validateStructural( metadata: Metadata, options?: ValidateOptions, + version?: string, ): void { const resolved: Required = { level: options?.level ?? ValidationLevel.Strict, @@ -917,10 +1011,11 @@ export function validateStructural( if (resolved.level === ValidationLevel.SchemaOnly) { return; } - validateAxisCount(metadata); - validateAxisType(metadata); - validateAxisOrder(metadata); - validateSpatialAxisOrder(metadata); + validateAxisCount(metadata, version); + validateAxisType(metadata, version); + validateAxisOrder(metadata, version); + validateSpatialAxisOrder(metadata, version); + validateAxisNamesUnique(metadata, version); validatePerDatasetScaleCount(metadata); validateScaleLength(metadata); validateTransformOrder(metadata); diff --git a/ts/test/structural_validation_parity_test.ts b/ts/test/structural_validation_parity_test.ts index dc7f586e..f7270b14 100644 --- a/ts/test/structural_validation_parity_test.ts +++ b/ts/test/structural_validation_parity_test.ts @@ -17,18 +17,13 @@ * therefore be a deliberate edit to {@link CANONICAL_SPEC_RULE_IDS} (and its * Python twin), not an accident. * - * Two adaptations to TypeScript are unavoidable and are the only intentional - * departures from the Python twin: - * - * 1. Axis names. TypeScript constrains `Axis.name` to the closed `SupportedDims` - * union (`"c" | "x" | "y" | "z" | "t"`), so the Python ordering test's - * free-form helper names (`"c2"`, `"w"`) are replaced with valid members - * (a repeated `"c"`, a `"y"`). The per-stage violations -- and therefore the - * observed evaluation order -- are identical; only the labels differ. - * 2. The default level. Python pins it on a constructable options object - * (`ValidateOptions().level == STRICT`); TypeScript's `ValidateOptions` is a - * bare interface whose default is resolved inside {@link validateStructural}. - * The same default is asserted observably instead (see the level test). + * One adaptation to TypeScript is unavoidable and is the only intentional + * departure from the Python twin: the default level. Python pins it on a + * constructable options object (`ValidateOptions().level == STRICT`); + * TypeScript's `ValidateOptions` is a bare interface whose default is resolved + * inside {@link validateStructural}. The same default is asserted observably + * instead (see the level test). `Axis.name` is `AxisName`, so the ordering + * fixtures use the same free-form names as the Python twin. * * The validation surface is imported from the package root (`../src/mod.ts`), * mirroring the Python test's import from `ngff_zarr`; the metadata constructors @@ -39,6 +34,7 @@ import { assertEquals, assertMatch, assertThrows } from "@std/assert"; import { SpecRule, + SUPPORTED_VERSIONS, validateStructural, ValidationError, ValidationLevel, @@ -54,15 +50,16 @@ import { // The locked rule manifest: every active OME-Zarr structural rule, in canonical // declaration order. This identical literal list appears in the Python twin so -// the two are directly comparable. The first eleven entries are the -// image/multiscales rules dispatched by validateStructural -- the first nine are -// the v0.4 rules and the next two (zarr-format, ome-namespace) are the v0.5 +// the two are directly comparable. The first thirteen entries are the +// image/multiscales rules dispatched by validateStructural -- the first eleven +// are the v0.4 rules and the next two (zarr-format, ome-namespace) are the v0.5 // namespacing rules, inert for v0.4; the final two are the HCS plate/well rules // dispatched by validatePlate / validateWell. const CANONICAL_SPEC_RULE_IDS: string[] = [ "axis-count", "axis-type", "axis-order", + "axis-names-unique", "scale-length-mismatch", "global-coord-transform-after-per-level", "dataset-order-highest-to-lowest", @@ -76,6 +73,26 @@ const CANONICAL_SPEC_RULE_IDS: string[] = [ "well-acquisition-missing", ]; +// The locked RFC-3 version manifest: the versions whose axis model is +// unrestricted, so that validateAxisCount / validateAxisType / +// validateAxisOrder / validateSpatialAxisOrder are inert for them. This +// identical literal list appears in the Python twin. axis-names-unique is +// deliberately absent: RFC-3 adds that rule rather than lifting it, so it is +// never inert (see docs/validation/rule-reference.md). +const CANONICAL_RFC3_VERSIONS: string[] = [ + "0.9.dev1", +]; + +// Every other supported version, plus the no-version default, must enforce the +// axis rules. Read off SUPPORTED_VERSIONS so a newly supported version has to +// be classified here rather than silently defaulting to "restricted". +const NON_RFC3_VERSIONS: (string | undefined)[] = [ + ...SUPPORTED_VERSIONS + .map((version) => version as string) + .filter((version) => !CANONICAL_RFC3_VERSIONS.includes(version)), + undefined, +]; + // The canonical fail-fast evaluation order of the image/multiscales // orchestrator (validateStructural). Each entry is the SpecRule the // orchestrator must raise when that rule -- and every rule after it -- is @@ -83,7 +100,7 @@ const CANONICAL_SPEC_RULE_IDS: string[] = [ // share each: validateAxisOrder and validateSpatialAxisOrder both surface // AxisOrder (positions 3-4), and validatePerDatasetScaleCount and // validateTransformOrder both surface GlobalCoordTransformAfterPerLevel -// (positions 5 and 7). The two HCS rules are absent: they are not part of the +// (positions 6 and 8). The two HCS rules are absent: they are not part of the // image/multiscales orchestrator. The two v0.5 namespacing rules (zarr-format, // ome-namespace) run last in the orchestrator but are inert for the v0.4 // metadata exercised here, so they never appear in the observed order. @@ -92,6 +109,7 @@ const EXPECTED_EVALUATION_ORDER: SpecRule[] = [ SpecRule.AxisType, SpecRule.AxisOrder, // validateAxisOrder (class ordering) SpecRule.AxisOrder, // validateSpatialAxisOrder (spatial suffix) + SpecRule.AxisNamesUnique, SpecRule.GlobalCoordTransformAfterPerLevel, // per-dataset scale count SpecRule.ScaleLengthMismatch, SpecRule.GlobalCoordTransformAfterPerLevel, // transform order @@ -100,6 +118,20 @@ const EXPECTED_EVALUATION_ORDER: SpecRule[] = [ SpecRule.AxisOrientationAnatomicalType, ]; +/** + * Valid `[c, z, y, x]` axes with the channel axis renamed `z`. + * + * Every rule before AxisNamesUnique still passes: the class order is + * channel-then-space, the spatial names remain the `(z, y, x)` suffix, and the + * count is 4. Only the repeated `z` is left for the orchestrator to catch, + * which pins the rule's position in the cascade. + */ +function axesWithRepeatedName(): Axis[] { + const axes = validAxesWithInconsistentOrientation(); + axes[0] = { ...axes[0], name: "z" }; + return axes; +} + /** Build a single-channel OMERO block with the given channel `color`. */ function makeOmero(color: string): Omero { return { @@ -226,16 +258,17 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { const badOmero = makeOmero("xyz"); // OMERO violation (rule 9), until repaired // Stage 1 -> AxisCount. Six axes simultaneously trip axis-type (two - // channels), axis-order (a space precedes a channel), and spatial-order - // (names are not the (z, y, x) suffix); axis-count is evaluated first. + // channels), axis-order (a space precedes a channel), spatial-order (names + // are not the (z, y, x) suffix) and axis-names-unique (a repeated "z"); + // axis-count is evaluated first. const metadata: Metadata = { axes: [ { name: "x", type: "space", unit: undefined }, { name: "c", type: "channel", unit: undefined }, - { name: "c", type: "channel", unit: undefined }, + { name: "c2", type: "channel", unit: undefined }, { name: "t", type: "time", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, ], datasets: [ { @@ -257,44 +290,51 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { }; observed.push(firstViolatedRule(metadata)); - // Stage 2 -> AxisType. Count fixed (5 axes); two channels remain, and a - // space still precedes a channel and the spatial names are still wrong. + // Stage 2 -> AxisType. Count fixed (5 axes); two channels remain, a space + // still precedes a channel, the spatial names are still wrong and "z" is + // still repeated. metadata.axes = [ { name: "x", type: "space", unit: undefined }, { name: "c", type: "channel", unit: undefined }, - { name: "c", type: "channel", unit: undefined }, + { name: "c2", type: "channel", unit: undefined }, + { name: "z", type: "space", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, ]; observed.push(firstViolatedRule(metadata)); // Stage 3 -> AxisOrder (class ordering). One channel now, but a space axis - // still precedes it; the spatial names are still not the (z, y, x) suffix. + // still precedes it; the spatial names are still not the (z, y, x) suffix + // and "z" is still repeated. metadata.axes = [ { name: "x", type: "space", unit: undefined }, { name: "c", type: "channel", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, ]; observed.push(firstViolatedRule(metadata)); // Stage 4 -> AxisOrder (spatial suffix). Class order fixed (channel first), - // but spatial names (x, z, y) are not the length-3 suffix of (z, y, x). + // but spatial names (x, z, z) are not the length-3 suffix of (z, y, x). metadata.axes = [ { name: "c", type: "channel", unit: undefined }, { name: "x", type: "space", unit: undefined }, { name: "z", type: "space", unit: undefined }, - { name: "y", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, ]; observed.push(firstViolatedRule(metadata)); - // Stage 5 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] + // Stage 5 -> AxisNamesUnique. Every axis rule before it now passes, but the + // channel axis is named "z" like the first space axis. + metadata.axes = axesWithRepeatedName(); + observed.push(firstViolatedRule(metadata)); + + // Stage 6 -> per-dataset scale count. Axes are now fully valid [c, z, y, x] // (with an inconsistent-orientation violation lurking as rule 10). Dataset 0 // still has two scales, so the per-dataset-scale-count rule fires. metadata.axes = validAxesWithInconsistentOrientation(); observed.push(firstViolatedRule(metadata)); - // Stage 6 -> ScaleLengthMismatch. Dataset 0 now has exactly one scale, but a + // Stage 7 -> ScaleLengthMismatch. Dataset 0 now has exactly one scale, but a // length-3 vector against 4 axes; a scale-after-translation (rule 7) lurks. metadata.datasets[0].coordinateTransformations = [ createTranslation([0.0, 0.0, 0.0]), @@ -302,7 +342,7 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { ]; observed.push(firstViolatedRule(metadata)); - // Stage 7 -> transform order. Lengths fixed to 4; dataset 0's scale still + // Stage 8 -> transform order. Lengths fixed to 4; dataset 0's scale still // follows a translation. Dataset 1 is made coarser-but-smaller so the // dataset-order rule (8) lurks behind the transform-order violation. metadata.datasets[0].coordinateTransformations = [ @@ -314,7 +354,7 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { ]; observed.push(firstViolatedRule(metadata)); - // Stage 8 -> dataset order. Transform order fixed (scale before + // Stage 9 -> dataset order. Transform order fixed (scale before // translation); dataset 1 is still coarser-but-smaller than dataset 0. metadata.datasets[0].coordinateTransformations = [ createScale([1.0, 1.0, 1.0, 1.0]), @@ -322,14 +362,14 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { ]; observed.push(firstViolatedRule(metadata)); - // Stage 9 -> OMERO color. Dataset order fixed (level 1 coarser-larger); only + // Stage 10 -> OMERO color. Dataset order fixed (level 1 coarser-larger); only // the bad OMERO color and the orientation violation remain. metadata.datasets[1].coordinateTransformations = [ createScale([1.0, 2.0, 2.0, 2.0]), ]; observed.push(firstViolatedRule(metadata)); - // Stage 10 -> orientation. OMERO color fixed; the y axis still declares a + // Stage 11 -> orientation. OMERO color fixed; the y axis still declares a // different orientation type than its spatial siblings. metadata.omero = makeOmero("00FF88"); observed.push(firstViolatedRule(metadata)); @@ -346,3 +386,97 @@ Deno.test("validateStructural evaluates rules in canonical order", () => { }; validateStructural(metadata); }); + +// --------------------------------------------------------------------------- +// Manifest: the locked RFC-3 version set +// --------------------------------------------------------------------------- + +/** + * Six same-type axes: legal under RFC-3, illegal at every other version. + * + * Violates axis-count (6 > 5) and the spatial-axis rules (6 > 3 `space` axes) + * at once, and nothing else, so the orchestrator accepts it exactly when the + * axis rules are inert. + */ +function rfc3AxisMetadata(): Metadata { + const names = ["a", "b", "c", "d", "e", "f"]; + return { + axes: names.map((name) => ({ + name, + type: "space", + unit: undefined, + } as Axis)), + datasets: [ + { + path: "0", + coordinateTransformations: [ + createScale(names.map(() => 1.0)), + createTranslation(names.map(() => 0.0)), + ], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +Deno.test("RFC-3 version manifest is locked", () => { + // Inert at exactly the manifest versions... + for (const version of CANONICAL_RFC3_VERSIONS) { + validateStructural(rfc3AxisMetadata(), undefined, version); + } + + // ...and enforced at every other supported version, and by default. + for (const version of NON_RFC3_VERSIONS) { + const error = assertThrows( + () => validateStructural(rfc3AxisMetadata(), undefined, version), + ValidationError, + ); + assertEquals(error.rule, SpecRule.AxisCount, String(version)); + } +}); + +/** + * A repeated axis name that no *other* axis rule can catch. + * + * `(time "x", space "y", space "x")` satisfies all four restricted axis rules: + * 3 axes, one `time` and two `space`, ordered time then space, and the spatial + * names are the `(y, x)` suffix. `axis-names-unique` is therefore the only rule + * that can fire, at every version. + */ +function repeatedNameMetadata(): Metadata { + return { + axes: [ + { name: "x", type: "time", unit: undefined } as Axis, + { name: "y", type: "space", unit: undefined } as Axis, + { name: "x", type: "space", unit: undefined } as Axis, + ], + datasets: [ + { + path: "0", + coordinateTransformations: [ + createScale([1.0, 1.0, 1.0]), + createTranslation([0.0, 0.0, 0.0]), + ], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +Deno.test("axis-names-unique is never inert", () => { + // RFC-3 *adds* this rule rather than lifting one, so unlike the other four + // axis rules it fires at the RFC-3 versions too -- and at every other. + for (const version of [...CANONICAL_RFC3_VERSIONS, ...NON_RFC3_VERSIONS]) { + const error = assertThrows( + () => validateStructural(repeatedNameMetadata(), undefined, version), + ValidationError, + ); + assertEquals(error.rule, SpecRule.AxisNamesUnique, String(version)); + } +}); diff --git a/ts/test/structural_validation_reader_test.ts b/ts/test/structural_validation_reader_test.ts index a620f9c8..dc0c5dfd 100644 --- a/ts/test/structural_validation_reader_test.ts +++ b/ts/test/structural_validation_reader_test.ts @@ -309,3 +309,121 @@ Deno.test( assertEquals(result.metadata.extra, {}); }, ); + +// --- The declared version drives the axis rules on the read path --- + +/** + * Build an in-memory store whose group attributes are the given `ome` block, + * with one zarr array per declared dataset path. + * + * This is the v0.6 / 0.9.dev1 layout: the multiscales sit under `ome`, and the + * axes under a coordinate system rather than a flat `axes` list. + */ +async function createOmeNamespacedStore( + ome: Record, + shape: number[], +): Promise { + const store: MemoryStore = new Map(); + const root = zarr.root(store); + await zarr.create(root, { attributes: { ome } }); + const entry = (ome.multiscales as Array>)[0]; + for (const dataset of entry.datasets as Array<{ path: string }>) { + await zarr.create(root.resolve(dataset.path), { + shape, + data_type: "uint8", + chunk_shape: shape, + fill_value: 0, + }); + } + return store; +} + +/** A single-level `ome` block over `axes`, tagged with the given version. */ +function omeBlock( + version: string, + axes: Array<{ name: string; type: string }>, +): Record { + const rank = axes.length; + return { + version, + multiscales: [{ + name: "image", + coordinateSystems: [{ name: "intrinsic", axes }], + datasets: [{ + path: "0", + coordinateTransformations: [{ + input: { path: "0" }, + output: { name: "intrinsic" }, + name: "scale0_to_intrinsic", + type: "sequence", + transformations: [ + { type: "scale", scale: Array(rank).fill(1.0) }, + { type: "translation", translation: Array(rank).fill(0.0) }, + ], + }], + }], + }], + }; +} + +const SIX_SPACE_AXES = ["a", "b", "c", "d", "e", "f"].map((name) => ({ + name, + type: "space", +})); + +Deno.test( + "reader - a 0.9.dev1 store with six axes passes strict validation", + async () => { + // The axis rules are inert at 0.9.dev1, so this store is legal. Reading it + // under `validate: true` only works if the declared version reaches + // validateStructural: without it every store is held to the v0.4 caps, and + // a document this port's own writer emits is refused on the way back in. + const store = await createOmeNamespacedStore( + omeBlock("0.9.dev1", SIX_SPACE_AXES), + [2, 2, 2, 2, 2, 2], + ); + + const result = await fromOmeZarr(store, { validate: true }); + assertEquals( + result.images[0].dims, + ["a", "b", "c", "d", "e", "f"], + ); + }, +); + +Deno.test( + "reader - the same six-axis store tagged 0.6 is refused", + async () => { + // The negative control for the test above: the version is what relaxes the + // rules, not the read path going soft on every store. + const store = await createOmeNamespacedStore( + omeBlock("0.6.dev4", SIX_SPACE_AXES), + [2, 2, 2, 2, 2, 2], + ); + + const error = await assertRejects( + () => fromOmeZarr(store, { validate: true }), + Error, + ); + assertStringIncludes(error.message, SpecRule.AxisCount); + }, +); + +Deno.test( + "reader - a v0.6 array coordinate system needs no space axis", + async () => { + // The v0.6 axes schema is a `oneOf`: 2 or 3 `space` axes, *or* two or more + // `array` axes. The reader must not hold the second arm to the space-axis + // floor, or a store this port's own writer accepts at 0.6 cannot be read. + const store = await createOmeNamespacedStore( + omeBlock("0.6.dev4", [ + { name: "i", type: "array" }, + { name: "j", type: "array" }, + ]), + [4, 4], + ); + + const result = await fromOmeZarr(store, { validate: true }); + assertEquals(result.images[0].dims, ["i", "j"]); + }, +); diff --git a/ts/test/structural_validation_test.ts b/ts/test/structural_validation_test.ts index 547ad685..b5a620a7 100644 --- a/ts/test/structural_validation_test.ts +++ b/ts/test/structural_validation_test.ts @@ -22,6 +22,7 @@ import { assertEquals, assertInstanceOf, assertThrows } from "@std/assert"; import { SpecRule, validateAxisCount, + validateAxisNamesUnique, validateAxisOrder, validateAxisType, validateDatasetOrder, @@ -197,6 +198,24 @@ Deno.test("validateSpatialAxisOrder - rejects bad suffix and too many", () => { ); }); +Deno.test("validateAxisNamesUnique - accepts distinct names", () => { + validateAxisNamesUnique(buildValidMetadata()); +}); + +Deno.test("validateAxisNamesUnique - rejects a repeated name", () => { + const repeated = buildValidMetadata(); + repeated.axes = [ + { name: "c", type: "channel", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + ]; + assertRuleViolation( + () => validateAxisNamesUnique(repeated), + SpecRule.AxisNamesUnique, + "multiscales[0].axes[2]", + ); +}); + Deno.test("validatePerDatasetScaleCount - accepts one scale per level", () => { validatePerDatasetScaleCount(buildValidMetadata()); }); @@ -436,3 +455,98 @@ Deno.test("validateStructural - option defaults: strict, allowUnknownFields true "multiscales[0].datasets[0].coordinateTransformations[0]", ); }); + +Deno.test("axis rules are inert at 0.9.dev1 but enforced below it", () => { + // Six axes exceed the 2..5 range every released version imposes. + const sixAxes = buildValidMetadata(); + sixAxes.axes = ["a", "b", "c", "d", "e", "f"].map((name) => ({ + name, + type: "space" as const, + unit: undefined, + })); + + assertRuleViolation( + () => validateAxisCount(sixAxes), + SpecRule.AxisCount, + "multiscales[0].axes", + ); + for (const version of ["0.4", "0.5", "0.6"]) { + assertRuleViolation( + () => validateAxisCount(sixAxes, version), + SpecRule.AxisCount, + "multiscales[0].axes", + ); + } + // RFC-3 lifts the restriction, and only at 0.9.dev1. + validateAxisCount(sixAxes, "0.9.dev1"); + validateAxisType(sixAxes, "0.9.dev1"); + validateAxisOrder(sixAxes, "0.9.dev1"); + validateSpatialAxisOrder(sixAxes, "0.9.dev1"); +}); + +Deno.test("axis names must stay unique at 0.9.dev1", () => { + // RFC-3 *adds* "axis names MUST NOT be repeated" (rule 5), so unlike the + // other axis rules this one is never inert. + const repeated = buildValidMetadata(); + repeated.axes = [ + { name: "c", type: "channel", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + ]; + assertRuleViolation( + () => validateAxisNamesUnique(repeated, "0.9.dev1"), + SpecRule.AxisNamesUnique, + "multiscales[0].axes[2]", + ); +}); + +Deno.test("fewer than two space axes is rejected below 0.9.dev1", () => { + // The v0.4 and v0.5 axes schemas state minContains: 2 on `space` axes. + // v0.6 adds an `array` arm, covered separately below. + const oneSpace = buildValidMetadata(); + oneSpace.axes = [ + { name: "c", type: "channel", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ]; + assertRuleViolation( + () => validateSpatialAxisOrder(oneSpace, "0.4"), + SpecRule.AxisOrder, + "multiscales[0].axes", + ); + validateSpatialAxisOrder(oneSpace, "0.9.dev1"); +}); + +Deno.test("a v0.6 array coordinate system needs no space axis", () => { + // The v0.6 axes schema is a `oneOf`: 2-3 `space` axes, *or* two or more + // `array` axes. The space-axis floor must not fire on the second arm, or a + // store the bundled schema accepts becomes unwritable at v0.6. + const arrayCs = buildValidMetadata(); + arrayCs.axes = [ + { name: "i0", type: "array", unit: undefined }, + { name: "i1", type: "array", unit: undefined }, + { name: "i2", type: "array", unit: undefined }, + ]; + validateSpatialAxisOrder(arrayCs, "0.6"); + validateSpatialAxisOrder(arrayCs, "0.6.dev4"); + + // v0.4 and v0.5 have no `array` arm, so the floor applies unconditionally. + for (const version of ["0.4", "0.5"]) { + assertRuleViolation( + () => validateSpatialAxisOrder(arrayCs, version), + SpecRule.AxisOrder, + "multiscales[0].axes", + ); + } + + // A single `array` axis does not reach the schema's minContains: 2. + const oneArray = buildValidMetadata(); + oneArray.axes = [ + { name: "i0", type: "array", unit: undefined }, + { name: "c", type: "channel", unit: undefined }, + ]; + assertRuleViolation( + () => validateSpatialAxisOrder(oneArray, "0.6"), + SpecRule.AxisOrder, + "multiscales[0].axes", + ); +}); diff --git a/ts/test/to_multiscales_itkwasm_test.ts b/ts/test/to_multiscales_itkwasm_test.ts index 2cade1e1..5351d854 100644 --- a/ts/test/to_multiscales_itkwasm_test.ts +++ b/ts/test/to_multiscales_itkwasm_test.ts @@ -57,7 +57,11 @@ Deno.test("downsample zycx", async () => { }); const store: MemoryStore = new Map(); - await toNgffZarr(store, multiscales); + // These dims are not the spec order (time, channel, space), which the + // writer requires below 0.9.dev1. The test is about downsampling with + // the channel axis in that position, so write at the version whose + // axis model allows it. + await toNgffZarr(store, multiscales, { version: "0.9.dev1" }); // The non-canonical input is normalized to (c, z, y, x). Mirrors // py/test/test_to_ngff_zarr_itkwasm.py::test_downsample_zycx. @@ -134,7 +138,11 @@ Deno.test("downsample tzycx", async () => { }); const store: MemoryStore = new Map(); - await toNgffZarr(store, multiscales); + // These dims are not the spec order (time, channel, space), which the + // writer requires below 0.9.dev1. The test is about downsampling with + // the channel axis in that position, so write at the version whose + // axis model allows it. + await toNgffZarr(store, multiscales, { version: "0.9.dev1" }); // The non-canonical input is normalized to (t, c, z, y, x). Mirrors // py/test/test_to_ngff_zarr_itkwasm.py::test_downsample_tzycx. diff --git a/ts/test/write_gate_test.ts b/ts/test/write_gate_test.ts new file mode 100644 index 00000000..2addd352 --- /dev/null +++ b/ts/test/write_gate_test.ts @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT +/** + * Tests for the RFC-3 write gate in `buildRootAttributes`, the single function + * behind every TypeScript writer. + * + * An RFC-3 axis model can only be serialized at OME-Zarr 0.9.dev1; targeting + * 0.4, 0.5 or 0.6 throws. Mirrors `py/test/test_rfc3_axes.py`. + */ + +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { buildRootAttributes } from "../src/io/to_ngff_zarr_ozx_common.ts"; +import { + type Axis, + createScale, + type MetadataInterface, +} from "../src/types/zarr_metadata.ts"; + +type TargetVersion = "0.4" | "0.5" | "0.6" | "0.9.dev1"; + +const PRE_RFC3: TargetVersion[] = ["0.4", "0.5", "0.6"]; + +/** Multiscales metadata carrying `axes`, with one matching scale per level. */ +function buildMetadata(axes: Axis[]): MetadataInterface { + return { + axes, + datasets: [ + { + path: "0", + coordinateTransformations: [createScale(axes.map(() => 1.0))], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +function space(name: string): Axis { + return { name, type: "space", unit: undefined }; +} + +/** One entry per RFC-3 sample-dataset shape, with the rule each one trips. */ +const RFC3_SHAPES: Array<[label: string, axes: Axis[]]> = [ + ["ramp_6d", ["a", "b", "c", "d", "e", "f"].map(space)], + ["ecg_1d", [{ name: "t", type: "time", unit: undefined }]], + ["astronaut_xcy", [ + space("x"), + { name: "c", type: "channel", unit: undefined }, + space("y"), + ]], +]; + +Deno.test("write gate - refuses RFC-3 axis models below 0.9.dev1", () => { + for (const [label, axes] of RFC3_SHAPES) { + for (const version of PRE_RFC3) { + const error = assertThrows( + () => buildRootAttributes(buildMetadata(axes), version), + Error, + undefined, + `${label} should be refused at ${version}`, + ); + assertStringIncludes(error.message, "Cannot write OME-Zarr"); + assertStringIncludes(error.message, "0.9.dev1"); + } + } +}); + +Deno.test("write gate - accepts RFC-3 axis models at 0.9.dev1", () => { + for (const [, axes] of RFC3_SHAPES) { + const attrs = buildRootAttributes(buildMetadata(axes), "0.9.dev1") as { + ome: { version: string }; + }; + assertEquals(attrs.ome.version, "0.9.dev1"); + } +}); + +Deno.test("write gate - conventional axes still write at every version", () => { + const axes = ["z", "y", "x"].map(space); + for (const version of [...PRE_RFC3, "0.9.dev1"] as TargetVersion[]) { + buildRootAttributes(buildMetadata(axes), version); + } +}); + +Deno.test("write gate - a non-canonical class order is refused below 0.9.dev1", () => { + // Axis order is a spec MUST: the writer refuses what validateStructural + // rejects, rather than reporting it and writing anyway. + const channelLast = buildMetadata([ + { name: "z", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + { name: "c", type: "channel", unit: undefined }, + ]); + for (const version of PRE_RFC3) { + const error = assertThrows( + () => buildRootAttributes(channelLast, version), + Error, + ); + assertStringIncludes(error.message, "axis-order"); + } + // RFC-3 lifts the ordering rule, so the same model writes at 0.9.dev1. + buildRootAttributes(channelLast, "0.9.dev1"); +}); + +Deno.test("write gate - repeated axis names are refused at every version", () => { + // RFC-3 *adds* the unique-name rule rather than lifting one, so 0.9.dev1 + // is not a way out of this one. + const axes = [space("y"), space("y"), space("x")]; + for (const version of [...PRE_RFC3, "0.9.dev1"] as TargetVersion[]) { + const error = assertThrows( + () => buildRootAttributes(buildMetadata(axes), version), + Error, + ); + assertStringIncludes(error.message, "Cannot write OME-Zarr"); + } +}); + +Deno.test("the gate reads the axes the writer serializes", () => { + // `buildV06MultiscalesEntry` writes the first coordinate system from + // `metadata.axes`; `coordinateSystems[0].axes` is not tied to it, so a gate + // reading the declared list would miss what actually lands on disk. + const metadata = buildMetadata([ + { name: "z", type: "space", unit: undefined }, + { name: "z", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ]); + metadata.coordinateSystems = [ + { + name: "intrinsic", + axes: [ + { name: "z", type: "space", unit: undefined }, + { name: "y", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ], + }, + ]; + assertThrows( + () => buildRootAttributes(metadata, "0.9.dev1"), + Error, + "axis names must be unique", + ); +}); + +Deno.test("the gate skips coordinate systems the target version drops", () => { + // v0.4 and v0.5 serialize the flat `axes` and drop `coordinateSystems`, so a + // system the downgrade discards must not block the write. Mirrors Python, + // which gates after `Metadata.to_version`. + const metadata = buildMetadata([space("z"), space("y"), space("x")]); + metadata.coordinateSystems = [ + { name: "intrinsic", axes: [space("z"), space("y"), space("x")] }, + { name: "extra", axes: ["a", "b", "c", "d", "e", "f"].map(space) }, + ]; + + for (const version of ["0.4", "0.5"] as TargetVersion[]) { + buildRootAttributes(metadata, version); + } + // 0.6 writes both systems, so there the six-axis one is refused. + const error = assertThrows( + () => buildRootAttributes(metadata, "0.6"), + Error, + ); + assertStringIncludes( + error.message, + "multiscales[0].coordinateSystems[1].axes", + ); +}); + +// The gate's message bytes are part of the cross-language contract: this +// literal is pinned identically in `py/test/test_rfc3_axes.py`. +const CANONICAL_GATE_MESSAGE_REPEATED_NAME = + "Cannot write OME-Zarr version=\"0.9.dev1\": Axis name 'z' is repeated; " + + "axis names must be unique within a dataset. Axes at multiscales[0].axes: " + + "['z'(type='space'), 'z'(type='space'), 'x'(type='space')]."; + +const CANONICAL_GATE_MESSAGE_AXIS_COUNT = + 'Cannot write OME-Zarr version="0.4": this axis model violates that ' + + "version's [axis-count] rule. OME-Zarr v0.4, v0.5 and v0.6 require between " + + "2 and 5 axes, inclusive; found 6. Axes at multiscales[0].axes: " + + "['a'(type='space'), 'b'(type='space'), 'c'(type='space'), " + + "'d'(type='space'), 'e'(type='space'), 'f'(type='space')]. Pass " + + 'version="0.9.dev1" to write it: 0.9.dev1 is the only OME-Zarr version ' + + "that adopts RFC-3 (arbitrary axis count, names, types and ordering)."; + +Deno.test("write gate - message bytes match the Python port", () => { + const repeated = assertThrows( + () => + buildRootAttributes( + buildMetadata([space("z"), space("z"), space("x")]), + "0.9.dev1", + ), + Error, + ); + assertEquals(repeated.message, CANONICAL_GATE_MESSAGE_REPEATED_NAME); + + const sixAxis = assertThrows( + () => + buildRootAttributes( + buildMetadata(["a", "b", "c", "d", "e", "f"].map(space)), + "0.4", + ), + Error, + ); + assertEquals(sixAxis.message, CANONICAL_GATE_MESSAGE_AXIS_COUNT); +}); + +Deno.test("0.9.dev1 round-trip goes through the v0.6 reader", async () => { + // `isV06Version` does not cover 0.9.dev1, so a store tagged 0.9.dev1 would + // otherwise fall through to the v0.4 reader and fail on its coordinate + // systems. + const { fromNgffZarr, toNgffZarr } = await import("../src/mod.ts"); + + const testStorePath = new URL( + "../../py/test/data/input/v04/6001240.zarr", + import.meta.url, + ); + const resolvedPath = testStorePath.pathname.replace(/^\/([A-Za-z]:)/, "$1"); + const source = await fromNgffZarr(resolvedPath, { version: "0.4" }); + + const store: Map = new Map(); + await toNgffZarr(store, source, { version: "0.9.dev1" }); + + const roundTripped = await fromNgffZarr(store, { version: "0.9.dev1" }); + assertEquals( + roundTripped.metadata.axes.map((ax) => ax.name), + source.metadata.axes.map((ax) => ax.name), + ); + // The v0.6 parser records `0.6`; a 0.9.dev1 store must report its own + // version, which is how a caller tells the two models apart. + assertEquals(roundTripped.metadata.version, "0.9.dev1"); +});