diff --git a/README.md b/README.md index 54ce693..c821821 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,14 @@ This convention can be used with these parts of the Zarr hierarchy: All properties use the `spatial:` namespace prefix and are placed at the root `attributes` level. When composed with [`multiscales`], `spatial:transform` and `spatial:shape` may additionally be placed inside each `layout` item to specify values per resolution level (see [Usage with `multiscales` convention](#usage-with-multiscales-convention)). -| Property | Type | Description | Required | Reference | -| -------------------------- | ----------- | ------------------------------------------------------------------- | -------- | ------------------------------------------------ | -| **spatial:dimensions** | `string[2]` | Names of the two X/Y spatial dimensions (e.g., ["y", "x"]) | On arrays | [spatial:dimensions](#spatialdimensions) | -| **spatial:bbox** | `number[4]` | 2D bounding box [xmin, ymin, xmax, ymax] | No | [spatial:bbox](#spatialbbox) | -| **spatial:transform_type** | `string` | Type of coordinate transformation (default: "affine") | No | [spatial:transform_type](#spatialtransform_type) | -| **spatial:transform** | `number[6]` | 2D affine transformation coefficients | Conditional | [spatial:transform](#spatialtransform) | -| **spatial:shape** | `integer[2]`| Shape of the two spatial dimensions [height, width] | No | [spatial:shape](#spatialshape) | -| **spatial:registration** | `string` | Grid cell registration (i.e., raster space) type (default: "pixel") | No | [spatial:registration](#spatialregistration) | +| Property | Type | Description | Required | Reference | +| -------------------------- | ------------ | ------------------------------------------------------------------- | ----------- | ------------------------------------------------ | +| **spatial:dimensions** | `string[2]` | Names of the two X/Y spatial dimensions (e.g., ["y", "x"]) | On arrays | [spatial:dimensions](#spatialdimensions) | +| **spatial:bbox** | `number[4]` | 2D bounding box [xmin, ymin, xmax, ymax] | No | [spatial:bbox](#spatialbbox) | +| **spatial:transform_type** | `string` | Type of coordinate transformation (default: "affine") | No | [spatial:transform_type](#spatialtransform_type) | +| **spatial:transform** | `number[6]` | 2D affine transformation coefficients | Conditional | [spatial:transform](#spatialtransform) | +| **spatial:shape** | `integer[2]` | Shape of the two spatial dimensions [height, width] | No | [spatial:shape](#spatialshape) | +| **spatial:registration** | `string` | Grid cell registration (i.e., raster space) type (default: "pixel") | No | [spatial:registration](#spatialregistration) | ### Additional Properties @@ -100,12 +100,14 @@ For 2D spatial data, provide 2 entries, e.g. `["y", "x"]`. Bounding box in coordinate space - **Type**: `number[4]` -- **Required**: No +- **Required**: Conditional. See [Property placement](#property-placement). Bounding box of the X/Y spatial extent in the coordinate space: `[xmin, ymin, xmax, ymax]` (exactly 4 elements). The coordinates represent the minimum and maximum values along the X and Y axes. The interpretation of these coordinates depends on any associated coordinate reference system (e.g., from the [`proj`] convention) or can represent abstract spatial units. +**Antimeridian crossings.** For geographic CRSes (longitude/latitude), `spatial:bbox` MAY have `xmin > xmax`. This indicates a bbox that crosses the antimeridian; the longitude range is `[xmin, 180]` joined with `[-180, xmax]`. Matches STAC's convention for the same case. For projected CRSes, `xmin > xmax` is invalid. + ### spatial:transform_type Type of coordinate transformation @@ -284,6 +286,47 @@ For more detailed information on grid cell registration concepts: - GMT Documentation: [Grid registration](https://docs.generic-mapping-tools.org/6.4/cookbook/options.html#option-nodereg) - GeoTIFF Specification: [Section 2.5.2.2 Raster Space](http://docs.opengeospatial.org/is/19-008r4/19-008r4.html#_raster_space) +## Property placement + +`spatial:` properties have one canonical location each. Storing the same fact in two slots invites floating-point divergence and per-consumer tiebreakers; the schema forbids this. + +| Property | Array level | Group level | +| ------------------------ | ----------------------------------------------------------------- | ---------------------------------- | +| `spatial:dimensions` | Required | Allowed (default for child arrays) | +| `spatial:shape` | Allowed | Forbidden | +| `spatial:transform_type` | Allowed (default `"affine"`) | Forbidden | +| `spatial:transform` | Required when `transform_type` is `"affine"` | Forbidden | +| `spatial:registration` | Allowed | Forbidden | +| `spatial:bbox` | Forbidden when `transform_type` is `"affine"`; required otherwise | Allowed | + +### Affine arrays + +For affine transforms the bbox is fully determined by `spatial:transform` and `spatial:shape`. Walk the four grid corners through the transform and take min/max: + +```python +# spatial:transform = [a, b, c, d, e, f]; spatial:shape = [H, W] +corners = [(0, 0), (W, 0), (W, H), (0, H)] # (col, row) in index space +xs = [a * col + b * row + c for col, row in corners] +ys = [d * col + e * row + f for col, row in corners] +bbox = (min(xs), min(ys), max(xs), max(ys)) +``` + +This matches rasterio's [`array_bounds`](https://github.com/rasterio/rasterio/blob/main/rasterio/transform.py#L198) and GDAL's `gdalinfo` ([`gdalinfo_lib.cpp:1163-1178`](https://github.com/OSGeo/gdal/blob/master/apps/gdalinfo_lib.cpp#L1163)). The four-corner walk handles rotation and skew; using only two corners is wrong for any non-axis-aligned transform. + +Affine arrays MUST NOT carry `spatial:bbox` at the array level. + +### Non-affine arrays + +When `spatial:transform_type` is anything other than `"affine"` (e.g. `"rpc"`, `"polynomial"`, `"lookup"`, custom), no closed-form derivation exists. The producer MUST declare `spatial:bbox` at the array level and it is authoritative for that array. + +### Group level + +A group MAY carry `spatial:bbox` as the axis-aligned bounding rectangle of the union of descendant array extents. No other `spatial:` properties (`spatial:transform`, `spatial:transform_type`, `spatial:shape`, `spatial:registration`) are permitted at the group level. Group-level `spatial:bbox` does not inherit; consumers MUST NOT assume child arrays inherit it. + +### Multiscales layout entries + +When composing with the [multiscales convention](#composability-with-multiscales), each `multiscales.layout[]` entry MAY carry `spatial:shape` and `spatial:transform` to override values per resolution level. The same single-source rule applies: `spatial:bbox` is forbidden at the layout-entry level (it is derivable from `spatial:transform` + `spatial:shape` per level, and the group-level `spatial:bbox` is the discovery slot). + ## Examples ### Basic Array Example with Spatial Convention Only @@ -308,7 +351,6 @@ For non-geospatial data or when CRS is not needed: "spatial:dimensions": ["y", "x"], "spatial:shape": [1024, 1024], "spatial:transform": [1.0, 0.0, 0.0, 0.0, -1.0, 1024.0], - "spatial:bbox": [0.0, 0.0, 1024.0, 1024.0], "spatial:registration": "pixel" } } @@ -338,7 +380,6 @@ For a Digital Elevation Model using node registration (grid-registered): "spatial:transform": [ 0.000277777778, 0.0, -180.0, 0.0, -0.000277777778, 90.0 ], - "spatial:bbox": [-180.0, -90.0, 180.0, 90.0], "spatial:registration": "node" } } @@ -374,10 +415,7 @@ For geospatial data, combine `spatial:` with `proj:` for complete coordinate inf ], "proj:code": "EPSG:3857", "spatial:dimensions": ["Y", "X"], - "spatial:bbox": [ - -20037508.342789244, -20037508.342789244, 20037508.342789244, - 20037508.342789244 - ], + "spatial:shape": [256, 256], "spatial:transform": [ 156543.03392804097, 0.0, -20037508.342789244, 0.0, -156543.03392804097, 20037508.342789244 @@ -492,7 +530,7 @@ Yes! The `spatial:` convention is useful on its own for: ### Does `spatial:` replace explicit coordinate arrays? -**Yes, for affine cases.** The `spatial:transform` provides *implicit* coordinates: applying the affine matrix to an array index yields the coordinate of that cell, so explicit per-axis `x`/`y` coordinate arrays (as commonly stored alongside NetCDF/xarray data) are not required and would be redundant. +**Yes, for affine cases.** The `spatial:transform` provides _implicit_ coordinates: applying the affine matrix to an array index yields the coordinate of that cell, so explicit per-axis `x`/`y` coordinate arrays (as commonly stored alongside NetCDF/xarray data) are not required and would be redundant. Storing explicit coordinate arrays is still allowed โ€” for instance, when: diff --git a/examples/proj.json b/examples/proj.json index 5ef089c..6fd26d5 100644 --- a/examples/proj.json +++ b/examples/proj.json @@ -21,10 +21,7 @@ ], "proj:code": "EPSG:3857", "spatial:dimensions": ["Y", "X"], - "spatial:bbox": [ - -20037508.342789244, -20037508.342789244, 20037508.342789244, - 20037508.342789244 - ], + "spatial:shape": [256, 256], "spatial:transform": [ 156543.03392804097, 0.0, -20037508.342789244, 0.0, -156543.03392804097, 20037508.342789244 diff --git a/notes/single-source-rule-rationale.md b/notes/single-source-rule-rationale.md new file mode 100644 index 0000000..a69ccf9 --- /dev/null +++ b/notes/single-source-rule-rationale.md @@ -0,0 +1,88 @@ +# Single-source rule for `spatial:` properties: rationale + +Companion to the spec text in [README.md, "Property placement"](../README.md#property-placement) and the schema patch in [schema.json](../schema.json). This file collects the reasoning that didn't belong inline: prior art, rejected alternatives, per-issue cross-references, and scope decisions. + +If you just want to know what the rule is and how to write compliant metadata, read the README section above. If you want to know _why_ the rule looks the way it does, read on. + +## Prior art + +Where adjacent specs put "extent": + +| Spec | Per-asset extent | Collection-level extent | Authoritative source | +| ---------------- | ----------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| GeoTIFF / COG | One transform | n/a | Transform + shape | +| rasterio / GDAL | `transform` | n/a | Transform + shape; bbox derived ([`array_bounds`](https://github.com/rasterio/rasterio/blob/main/rasterio/transform.py#L198)) | +| STAC Item | `proj:bbox` (asset) + `bbox` (item) | Collection `extent` | Geometry authoritative; bbox is a discovery shortcut | +| OGC API Features | Optional per-feature bbox | Collection `extent` required | Geometry authoritative | +| CF / NetCDF | Coordinate variables | n/a | Coord arrays authoritative | +| GMT | Range + registration | n/a | Single source | +| TileJSON | n/a | Top-level `bounds` | Single source | + +Every mature spec keeps the precise per-asset description in one slot. None stores the same fact in two slots and asks consumers to reconcile. + +## Where we were before this rule + +`spatial:` allowed both `spatial:bbox` and `spatial:transform` at the array level with no relationship asserted. The usual defense: + +- Producers from GeoTIFF, rasterio, or STAC already have a bbox in hand. +- Consumers can compare with a tolerance. +- The spec stays permissive. + +The cost: "compare with a tolerance" never gets specified. Each consumer picks its own tolerance and its own tiebreaker. Producers don't know which extent will be trusted. Two viewers render the same store as slightly different rectangles. The spec exports a decision it should make once. + +## Why this rule, not a tolerance + +The natural compromise is to allow both at the array level and define a tolerance for comparing them. The spec would need prose along these lines: + +> When both `spatial:bbox` and `spatial:transform` are present on the same array, consumers MUST verify that the bbox equals the axis-aligned extent of the four transformed grid corners within tolerance `eps`. If the discrepancy exceeds `eps`, consumers SHOULD prefer the value derived from `spatial:transform`. Producers SHOULD ensure consistency at write time. Recommended `eps`: `max(1e-9 * abs(coordinate), 1e-6 * pixel_size)`. + +Three problems: + +1. **JSON Schema can't enforce the equality check.** The spec ends up with a normative rule no validator catches; conformance is decided per consumer. +2. **The tolerance formula is contestable.** Pixel sizes range from sub-meter (drone) to degrees (climate); one recommendation doesn't fit. Each consumer will tune theirs. +3. **"SHOULD prefer transform" is the tiebreaker.** If we're picking transform as authoritative anyway, the bbox slot is decorative. Just remove it. + +## Rejected alternatives + +| Option | Pro | Con | Verdict | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | +| **A. Remove `spatial:bbox` entirely** | Simplest. Matches GeoTIFF precedent. | Breaks group-level discovery. Breaks non-affine case (#5). | Rejected: loses two real use cases. | +| **B. Keep both at array level, define a tolerance** | Backward compatible. | Unenforceable in JSON Schema. Each consumer picks a tiebreaker. | Rejected: codifies the bug. | +| **C. Hierarchical split (this rule)** | Single source per fact per level. Enforceable in JSON Schema. Aligns with STAC, OGC, TileJSON. | Two fixture updates. | Accepted. | + +## Why the group level keeps `spatial:bbox` + +Two reasons we can't simply remove `spatial:bbox` everywhere: + +1. **Multiscales has no extent property of its own.** Removing array-level bbox without a group-level slot would lose discovery on every multiscales dataset. +2. **Non-affine arrays can't derive extent from a closed-form transform (#5).** GCP, RPC, polynomial, and lookup transforms have no four-corner walk. The producer must declare it. + +The group-level slot is the only `spatial:bbox` a consumer can read without opening any array metadata. It is the tight union of descendants, not a default; consumers MUST NOT assume child arrays inherit it. + +## What each closed issue gets + +| Issue | Quote | This rule's answer | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #6 | @maxrjones (channeling @dcherian): "the possibility of floating point mismatch between the transform and bbox is a smell to me" | One slot per fact, per level. The mismatch can't happen. No tolerance prose needed. | +| #8 | @rouault: "`spatial` lacks a clear paragraph like the one of `proj` ... a dedicated paragraph would be more explicit" | The README "Property placement" section is that paragraph. Inheritance shrinks to a single fact: group-level `spatial:bbox` does not inherit; it is the union of its children, not a default. | +| #12 | @kylebarron: "I've found that `spatial:transform` is ambiguous ... if `layout` exists, then it **should not be at the root** ... should be included on **all** levels" | `spatial:transform` and `spatial:shape` belong per multiscales `layout` level, not at the multiscales root. Group-level `spatial:bbox` is the only spatial property at the root. | +| #23 | @vincentsarago (from #17): "IMO, `spatial:dimensions` should only be defined at array level" | Per-array properties are array-only by construction. The narrow remaining question (whether to keep group-level `spatial:dimensions` purely for inheritance ergonomics) is decidable independently. | +| #19 | @pvanlaake: splits `spatial:registration` into `registration` + `element` | Composes. The four-corner derivation gains an area+node carve-out when #19 lands. | + +## How this composes with #19 + +Issue #19 proposes splitting `spatial:registration` into `spatial:registration` (where coords map inside an element) and `spatial:element` (point vs area). The split changes the extent formula: + +- area-element + pixel-registration: extent equals the transformed corners. +- point-element + node-registration: extent equals the transformed corners (the cells _are_ the points). +- area-element + node-registration: extent extends half a cell beyond the transformed corners along each axis. + +This rule does not pre-resolve #19. The derivation in the README assumes the current single `spatial:registration` field. When #19 lands, the formula gains the area+node carve-out. + +## Out of scope + +A few related questions that came up but aren't solved here. Each can be added later without breaking the single-source rule. + +- **Per-chunk or per-shard bboxes** for read pruning on huge arrays. These would live in the chunk manifest or shard index, not in the user attributes where `spatial:` operates. Different storage tier, different concern (read planning, not extent description). +- **Non-rectangular footprints** (polygons, swath outlines). A future `spatial:footprint` slot at the group level would cover this. Adding it now means three extent representations at the group (bbox, footprint, transform-derived) and polygon-union arithmetic at write time. Cleaner as a strict additive layer once the hierarchy is settled. +- **Streaming or append-only datasets** where the group bbox grows on each write. Two reasonable contracts (`always grow, never shrink` vs `recompute on every write`) with different ingest-cost tradeoffs. Picking one without producer feedback risks pushback; the spec stays silent for now and revisits once live-ingest producers comment. diff --git a/package.json b/package.json index e9171c4..26593a3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "spatial-zarr-convention", "version": "0.1.0", "scripts": { - "test": "node test.js", + "test": "node test.js && node test-rules.js", "lint": "eslint .", "format": "prettier . --write" }, diff --git a/schema.json b/schema.json index a4b6282..cbdd605 100644 --- a/schema.json +++ b/schema.json @@ -46,6 +46,109 @@ } } } + }, + { + "$comment": "Affine arrays (transform_type absent or 'affine') MUST NOT carry spatial:bbox; it is derivable from spatial:transform and spatial:shape.", + "if": { + "properties": { "node_type": { "const": "array" } }, + "required": ["node_type"] + }, + "then": { + "properties": { + "attributes": { + "if": { + "anyOf": [ + { "not": { "required": ["spatial:transform_type"] } }, + { + "properties": { + "spatial:transform_type": { "const": "affine" } + }, + "required": ["spatial:transform_type"] + } + ] + }, + "then": { + "not": { "required": ["spatial:bbox"] } + } + } + } + } + }, + { + "$comment": "Non-affine arrays (transform_type present and != 'affine') MUST carry spatial:bbox; no closed-form derivation exists.", + "if": { + "properties": { "node_type": { "const": "array" } }, + "required": ["node_type"] + }, + "then": { + "properties": { + "attributes": { + "if": { + "properties": { + "spatial:transform_type": { "not": { "const": "affine" } } + }, + "required": ["spatial:transform_type"] + }, + "then": { + "required": ["spatial:bbox"] + } + } + } + } + }, + { + "$comment": "Groups MUST NOT carry per-array spatial properties; only spatial:bbox (and the existing spatial:dimensions ergonomics slot) belong at the group level.", + "if": { + "properties": { "node_type": { "const": "group" } }, + "required": ["node_type"] + }, + "then": { + "properties": { + "attributes": { + "allOf": [ + { "not": { "required": ["spatial:transform"] } }, + { "not": { "required": ["spatial:transform_type"] } }, + { "not": { "required": ["spatial:shape"] } }, + { "not": { "required": ["spatial:registration"] } } + ] + } + } + } + }, + { + "$comment": "Multiscales layout entries follow the same single-source rule: each carries spatial:shape and spatial:transform per resolution level; spatial:bbox at the layout-entry level is forbidden (derivable, and the group-level bbox is the discovery slot).", + "if": { + "properties": { + "attributes": { + "type": "object", + "properties": { + "multiscales": { + "type": "object", + "required": ["layout"] + } + }, + "required": ["multiscales"] + } + }, + "required": ["attributes"] + }, + "then": { + "properties": { + "attributes": { + "properties": { + "multiscales": { + "properties": { + "layout": { + "items": { + "not": { "required": ["spatial:bbox"] } + } + } + } + } + } + } + } + } } ], "$defs": { diff --git a/test-rules.js b/test-rules.js new file mode 100644 index 0000000..409a1ff --- /dev/null +++ b/test-rules.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node +// Negative-case test suite for the single-source rule on spatial: properties. +// Exercises the four allOf branches in schema.json: +// 1. affine array MUST NOT carry spatial:bbox (derivable), +// 2. non-affine array MUST carry spatial:bbox (no closed-form derivation), +// 3. group MUST NOT carry per-array spatial properties, +// 4. multiscales.layout[] entries MUST NOT carry spatial:bbox (derivable per level). +// Complements test.js (which validates examples/) by verifying that forbidden +// shapes are rejected. + +import fs from "fs"; +import Ajv from "ajv"; + +const schema = JSON.parse(fs.readFileSync("schema.json", "utf8")); +const ajv = new Ajv({ allErrors: true, strict: false }); +const validate = ajv.compile(schema); + +const conventions = [ + { + schema_url: + "https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json", + spec_url: "https://github.com/zarr-conventions/spatial/blob/v1/README.md", + uuid: "689b58e2-cf7b-45e0-9fff-9cfc0883d6b4", + name: "spatial", + description: "Spatial coordinate information", + }, +]; + +const transform = [10.0, 0.0, 500000.0, 0.0, -10.0, 5000000.0]; +const bbox = [500000.0, 4990000.0, 510000.0, 5000000.0]; + +const cases = [ + { + name: "array, affine implicit, no bbox", + expect: true, + data: { + zarr_format: 3, + node_type: "array", + attributes: { + zarr_conventions: conventions, + "spatial:dimensions": ["y", "x"], + "spatial:shape": [1024, 1024], + "spatial:transform": transform, + }, + }, + }, + { + name: "array, affine implicit, WITH bbox (FORBIDDEN)", + expect: false, + data: { + zarr_format: 3, + node_type: "array", + attributes: { + zarr_conventions: conventions, + "spatial:dimensions": ["y", "x"], + "spatial:shape": [1024, 1024], + "spatial:transform": transform, + "spatial:bbox": bbox, + }, + }, + }, + { + name: "array, affine explicit, no bbox", + expect: true, + data: { + zarr_format: 3, + node_type: "array", + attributes: { + zarr_conventions: conventions, + "spatial:dimensions": ["y", "x"], + "spatial:shape": [1024, 1024], + "spatial:transform_type": "affine", + "spatial:transform": transform, + }, + }, + }, + { + name: "array, affine explicit, WITH bbox (FORBIDDEN)", + expect: false, + data: { + zarr_format: 3, + node_type: "array", + attributes: { + zarr_conventions: conventions, + "spatial:dimensions": ["y", "x"], + "spatial:shape": [1024, 1024], + "spatial:transform_type": "affine", + "spatial:transform": transform, + "spatial:bbox": bbox, + }, + }, + }, + { + name: "array, rpc, WITH bbox (REQUIRED, allowed)", + expect: true, + data: { + zarr_format: 3, + node_type: "array", + attributes: { + zarr_conventions: conventions, + "spatial:dimensions": ["y", "x"], + "spatial:shape": [1024, 1024], + "spatial:transform_type": "rpc", + "spatial:bbox": bbox, + }, + }, + }, + { + name: "array, rpc, no bbox (FORBIDDEN, rule B requires it)", + expect: false, + data: { + zarr_format: 3, + node_type: "array", + attributes: { + zarr_conventions: conventions, + "spatial:dimensions": ["y", "x"], + "spatial:shape": [1024, 1024], + "spatial:transform_type": "rpc", + }, + }, + }, + { + name: "group with bbox only", + expect: true, + data: { + zarr_format: 3, + node_type: "group", + attributes: { + zarr_conventions: conventions, + "spatial:bbox": [500000.0, 4988000.0, 512000.0, 5000000.0], + }, + }, + }, + { + name: "group with spatial:transform (FORBIDDEN at group level)", + expect: false, + data: { + zarr_format: 3, + node_type: "group", + attributes: { + zarr_conventions: conventions, + "spatial:bbox": [500000.0, 4988000.0, 512000.0, 5000000.0], + "spatial:transform": transform, + }, + }, + }, + { + name: "group with spatial:shape (FORBIDDEN at group level)", + expect: false, + data: { + zarr_format: 3, + node_type: "group", + attributes: { + zarr_conventions: conventions, + "spatial:shape": [1024, 1024], + }, + }, + }, + { + name: "group with antimeridian-crossing bbox (xmin > xmax)", + expect: true, + data: { + zarr_format: 3, + node_type: "group", + attributes: { + zarr_conventions: conventions, + "spatial:bbox": [170.0, -10.0, -170.0, 10.0], + }, + }, + }, + { + name: "multiscales layout entry with spatial:bbox (FORBIDDEN per-level)", + expect: false, + data: { + zarr_format: 3, + node_type: "group", + attributes: { + zarr_conventions: conventions, + "spatial:bbox": [500000.0, 4988000.0, 512000.0, 5000000.0], + multiscales: { + layout: [ + { + "spatial:shape": [1024, 1024], + "spatial:transform": transform, + "spatial:bbox": bbox, + }, + ], + }, + }, + }, + }, + { + name: "multiscales layout entry with shape + transform only", + expect: true, + data: { + zarr_format: 3, + node_type: "group", + attributes: { + zarr_conventions: conventions, + "spatial:bbox": [500000.0, 4988000.0, 512000.0, 5000000.0], + multiscales: { + layout: [ + { + "spatial:shape": [1024, 1024], + "spatial:transform": transform, + }, + { + "spatial:shape": [512, 512], + "spatial:transform": [20.0, 0.0, 500000.0, 0.0, -20.0, 5000000.0], + }, + ], + }, + }, + }, + }, +]; + +console.log("๐Ÿงช Running rule tests...\n"); +let pass = 0; +let fail = 0; +for (const c of cases) { + const ok = validate(c.data); + const correct = ok === c.expect; + console.log( + `${correct ? "โœ… PASS" : "โŒ FAIL"} ${c.name} (got ${ok}, expected ${c.expect})` + ); + if (!correct) { + console.log(" errors:", JSON.stringify(validate.errors, null, 2)); + fail++; + } else { + pass++; + } +} + +console.log("\n" + "=".repeat(50)); +console.log(`Total: ${cases.length} | Passed: ${pass} | Failed: ${fail}`); +console.log("=".repeat(50)); +process.exit(fail > 0 ? 1 : 0);