Skip to content

feat: sample a WKT MULTIPOINT on /position into a MultiPoint coverage#78

Open
chuckwondo wants to merge 11 commits into
mainfrom
feat/65-position-multipoint
Open

feat: sample a WKT MULTIPOINT on /position into a MultiPoint coverage#78
chuckwondo wants to merge 11 commits into
mainfrom
feat/65-position-multipoint

Conversation

@chuckwondo

Copy link
Copy Markdown
Contributor

Summary

/position now accepts a WKT MULTIPOINT alongside POINT. MULTIPOINT((x y), ...) returns a CoverageJSON MultiPoint coverage with one value per band at each position; POINT is unchanged. Closes #65.

A spatial-only multi-position sample is a MultiPoint, not a Trajectory: the CoverageJSON schema forces a t coordinate onto every Trajectory tuple, so a time-free multi-position sample cannot be one. This makes /position the non-temporal home for multi-position sampling (ADR-0005).

Behavior

  • Out-of-bounds is a value, not an error. A position outside the dataset becomes null, so the request still succeeds even when every position is outside (200, all-null). This is deliberate set semantics: MULTIPOINT((999 999)) returns 200/null, while POINT(999 999) stays 400.
  • Positions are capped by max_samples (default 1000), checked before any read.
  • Duplicate positions are rejected (a CoverageJSON axis cannot index a value twice).
  • Vertical selection is rejected (a Z/M/ZM tag, or a z), consistent with the 2-D raster backing.

Notable pieces

  • geometry.MultiPoint: a frozen value object owning its own invariants (non-empty, finite, distinct positions).
  • input.MultiPointInput + pointdata_to_multipoint_input: assemble per-position reads into a (bands, positions) array, aligning an out-of-bounds position to an all-masked column and stamping the band dtype from the stacked array (so the declared range type matches what is serialized).
  • modeler: a composite tuple axis, plus one N-D range builder now shared by the Grid and MultiPoint domains.
  • wkt: parse_multipoint_wkt (accepting both ((x y), ...) and (x y, ...) spellings) behind a parse_position_coords dispatcher.
  • Also folds in an exhaustive match/assert_never dispatch on the /position route, plus small test and smoke-tooling cleanups.

Known limitation

MULTIPOINT + expression inherits a pre-existing bug: at a nodata pixel, rio-tiler's PointData.apply_expression drops the mask and returns a fabricated value instead of null (tracked in #72, upstream cogeotiff/rio-tiler#965). This already affects POINT + expression; this PR neither fixes nor worsens it.

The geometry a MultiPoint-domain coverage is sampled over: a frozen set
of (x, y) positions. Bare pairs, not Position values, so an unservable z
cannot be constructed rather than being rejected downstream.

Rejects duplicate positions at construction. A repeat would be ambiguous
as a coverage axis value, and the CoverageJSON schema rejects it via
uniqueItems. No bounds property: nothing samples a bounding box over a
set of points.
The CoverageInput variant pairing a MultiPoint geometry with a 2-D
(bands, positions) masked array: one sampled value per band per
position, the position axis aligned to geometry.positions. A masked
entry marks a band with no value at that position, serializing as null.

The shape check tests ndim before indexing data.shape[1], so a non-2-D
array raises ValueError rather than IndexError. Not yet in the
CoverageInput union: it joins there with the modeler arm that dispatches
it, so the exhaustiveness check stays satisfied at every commit.
Add MultiPointInput to the CoverageInput union and the modeler's match:
a MultiPoint domain whose single composite axis lists one (x, y) tuple
per position, and a range running 1-D over that composite axis, one
value per band per position. Masked entries serialize as null in place.

The composite lists positions directly (values=[[x, y], ...]), inverting
the Polygon domain's wrap of its one geometry. coordinates stays
["x", "y"] even when a latitude-first CRS gives referencing ["y", "x"]:
the two describe different things and unifying them would misread a
position.
pointdata_to_multipoint_input takes one read per position, None where a
position fell outside the dataset, and stacks them into a (bands,
positions) masked array: each read is a column, each None an all-masked
column, so an out-of-bounds position serializes as null in place rather
than dropping out.

bands and crs are required, unlike the single-point converter: when
every position is out of bounds there is no read to derive them from, so
the caller resolves both up front and passes them in.

The band dtype is stamped from the stacked array, so the declared range
type equals the values serialized and does not depend on which positions
landed inside. A synthesized masked column takes the band dtype rather
than masked_all's float64 default, which would otherwise promote an
integer read.
parse_multipoint_wkt reads both WKT spellings, MULTIPOINT((x y), (x y))
and MULTIPOINT(x y, x y), and a mix of the two. It strips the per-point
parentheses so both reduce to the one x y, x y grammar _parse_xy_pairs
already reads, then hands the points to MultiPoint for the set
invariants. A strip, not a findall of parenthesized groups: a findall
would silently drop the bare points of a mixed list.

parse_position_coords is the single entry point the position endpoint
dispatches on: a POINT yields a Position, a MULTIPOINT a MultiPoint, and
anything else an InvalidCoords naming both accepted forms. Named for the
coords parameter it reads, since it spans two grammars rather than one.

Neither is wired into the route yet; that is the next commit.
Dispatch /position on parse_position_coords: a POINT keeps the existing
Point path unchanged, a MULTIPOINT reads one sample per position and
builds a MultiPoint coverage. A position outside the dataset becomes a
null value rather than an error, so the request succeeds even when every
position is outside.

max_samples (default 1000) caps the number of positions, rejected before
any read. It bounds the point-read count, a different resource from
max_cells.
A MULTIPOINT position in the dataset's last row or column, read with
nodata set, trips a rio-tiler off-by-one that flags the edge-aligned
window boundless; a WarpedVRT then refuses the read and the ValueError
propagates rather than nulling that one position. This is the one hole
in the out-of-bounds-becomes-null contract.

Pin the current behavior and note it at the catch site, both citing #73
(upstream cogeotiff/rio-tiler#966) so the test flags when the fix lands.
Request a MULTIPOINT from the container smoke check and assert a valid
MultiPoint coverage: a composite tuple axis over the sampled positions
and a 1-D range per band. Note MULTIPOINT on the README /position
bullet, and add MULTIPOINT (and an /area stat) curl example to the
README and the dev_app docstring.
Print each request, its status, and the coverage it returned along the
way, so a passing run shows what was served rather than only a summary
line: e.g. "GET /position?coords=MULTIPOINT(...) -> 200  MultiPoint
(2 positions, 2 bands)". Labels pad to a common width so the status
columns align, and a rejected request shows its error detail.
The route chose the Point vs MultiPoint path with an isinstance
if/else, leaving exhaustiveness to be caught only indirectly (by the
typed _read_point call rejecting a non-Position). Replace it with a
match over Position | MultiPoint whose default asserts never, mirroring
the modeler's to_coverage dispatch, so a future position geometry is a
compile error at the dispatch itself. Behavior is unchanged; the
InvalidCoords guard stays ahead of the shared setup.
Both are subsets of stronger existing tests:

- test_wkt: the 'duplicate' and 'nan' reject cases re-parse inputs that
  test_parse_multipoint_wkt_reports_duplicate_and_non_finite already
  checks with message assertions.
- test_factory: test_position_multipoint_nodata_serializes_as_null
  duplicates the full-body multipoint test, which asserts the same
  request's b2 == [None, 3.0] verbatim.
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.

Implement the EDR /position MULTIPOINT input (MultiPoint domain) end-to-end slice

1 participant