Skip to content

Implement modeler with grid domain support only#21

Merged
chuckwondo merged 3 commits into
mainfrom
modeler-grid
Jun 23, 2026
Merged

Implement modeler with grid domain support only#21
chuckwondo merged 3 commits into
mainfrom
modeler-grid

Conversation

@chuckwondo

Copy link
Copy Markdown
Contributor

Story 3 (PR 1): CoverageJSON modeler — Grid domain

Implements the modeler layer that converts a CoverageInput into a covjson-pydantic Coverage, scoped to the Grid domain (gridded rasters). Story 3 is split by domain type so each PR stays relatively small and independently reviewable; this is the first part. Part of #8; closes #17.

What's included

  • modeler.py: to_coverage(coverage_input) and helpers, implemented as stateless module-level functions (no class). Builds the Grid Domain (x/y CompactAxis), spatial referencing, one Parameter per band, and range NdArrays, reusing the existing helpers.py utilities. Non-grid geometry and non-3-D data raise NotImplementedError until later PRs.
  • input.py: CoverageInput now resolves bands at construction (synthesizing b1, b2, ... when none are supplied), so consumers always read a populated tuple and the default-band convention lives in one place. Added domain-independent guards: reject empty data axes and duplicate band names (both become CoverageJSON keys and would otherwise fail opaquely downstream).
  • Tests: tests/test_modeler.py (new) covers every Grid path (single/multi-band, float/int/string dtypes, nodata→null, UCUM unit resolution, synthesized bands, projected CRS, single-cell axis midpoint, and the two guards), all validated against the vendored OGC CoverageJSON schema; plus new CoverageInput validation/synthesis tests in tests/test_input.py.
  • Docsdocs/01,04,05,06 updated to reflect the function-based modeler, the construction-time band resolution, and the Polygon-blocker correction (covjson-pydantic 0.8.0 ships the Polygon domain types).

Key design decisions

  • Functions, not a class: the conversion is stateless; if configuration is ever needed (e.g., a TiledNdArray threshold), introduce a config argument then.
  • bands resolved once, at construction: CoverageInput is the post-resolution representation; precedence/enrichment stays upstream in the converters. Rationale captured in docs/04 section 3.
  • Grid axes: x west-east, y north-south (raster row 0 is north); a single-cell axis collapses to its bounds midpoint (CompactAxis requires start == stop when num == 1).
  • Multi-domain support deferred: Point/PointSeries (Modeler part 2: per-domain input union + Point/PointSeries + CoverageCollection #18), Trajectory (Modeler part 3: Trajectory domain #19), and Polygon/PolygonSeries (Modeler part 4: Polygon / PolygonSeries domain #20) arrive via the per-domain input union (docs/04 section 7), introduced when the second domain type lands.

@emmanuelmathot emmanuelmathot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work, Chuck — clean, well-documented, and the test coverage is excellent. Approving. Left two minor notes: one half-pixel CompactAxis question worth a tracking issue, and a small nit. Neither blocks merge.

Comment thread src/titiler_covjson/modeler.py Outdated

# CompactAxis describes a regular axis by its endpoints and cell count.
# x runs west->east; y runs north->south to match raster row order
# (row 0 is the north edge). Endpoints use the bounds edges; pixel-center

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 Reviewer-found (not from the original design notes).

CompactAxis uses the bounds edges as start/stop, not cell centers.

For num > 1 the axis is built as start=west, stop=east, num=width, so the generated coordinates are the outer bounds edges. In CoverageJSON a CompactAxis describes the coordinate values at which cells are defined (the cell centers), so this is off by half a pixel — e.g. 2 columns over -10..10 yields coords -10 and 10, but the actual cell centers are -5 and +5.

It is also inconsistent with the num == 1 path in _compact_axis, which does collapse to the midpoint (a true center).

The inline comment already flags this ("pixel-center offsets are a possible later refinement"), so I am fine deferring the fix in this PR — but could you please open a tracking issue so the half-pixel offset is not lost? Suggested scope: shift start/stop to cell centers (west + dx/2 … east - dx/2) and add a roundtrip test asserting cell-center coordinates. Reference this thread from the issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. I thought I had caught that, but I must be think of something else I was working on with a compact axis (perhaps my own experimenting elsewhere).

# No data axis may be empty: a zero-size height/width/sample axis is a
# degenerate coverage and would otherwise surface as an opaque CompactAxis
# error (num must be a positive cell count) deep in the modeler.
if 0 in self.data.shape:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 Reviewer-found.

nit: 0 in self.data.shape also matches shape[0] == 0, already rejected above with a different message — so a (0, h, w) array reports "must be 2-D or 3-D" instead of "non-empty". Harmless; could narrow to data.shape[-2:] for a consistent message.

@chuckwondo chuckwondo Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This proposed change does not change the error a user sees, and actually makes the code harder to reason about because the slicing causes the reader to pause to grok the reason for the slicing, which is merely to avoid a potentially redundant check of shape[0] against 0. That's additional cognitive load that's not worth merely skipping an unnecessary check of a single element in the shape tuple for no change in behavior.

chuckwondo and others added 2 commits June 23, 2026 10:50
A CovJSON CompactAxis describes the coordinates at which cells are
defined -- the cell centers -- but the Grid modeler passed the raster
bounds edges straight through, leaving x/y coordinates off by half a
cell. It was also inconsistent with the num == 1 path, which already
collapsed to a true center.

_compact_axis now insets the bounds edges by half a cell so the axis
runs first + dx/2 .. last - dx/2. The unified formula naturally yields
start == stop for a single cell, subsuming the former special case.

Addresses reviewer feedback on PR #21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chuckwondo

Copy link
Copy Markdown
Contributor Author

Thanks @emmanuelmathot. I skipped the nit, but addressed the cell-centered compact axis suggestion. I preferred to keep it in this PR (not much to change) rather than splitting the logic to a separate tracking PR.

@chuckwondo chuckwondo merged commit 383a315 into main Jun 23, 2026
12 checks passed
@chuckwondo chuckwondo deleted the modeler-grid branch June 23, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modeler part 1: skeleton + Grid domain

2 participants