Skip to content

get_coord_manager silently rewrites the values of an explicitly built coordinate #896

Description

@d-chambers

Description

Passing an already-constructed BaseCoord to get_coord_manager (and therefore to dc.Patch) does not preserve it. Any coord that is not a CoordRange is dumped back to a dict and re-inferred through get_coord, which snaps near-evenly-sampled values onto a regular range. Because that inference is tolerant, the result is not merely a different coordinate class — the sample values themselves are silently rewritten.

The re-parse is deliberate and documented in _get_coord (dascore/core/coordmanager.py, ~line 1225): array coords can be left non-canonical by slicing, so an evenly spaced subset should collapse back to a CoordRange. That intent is reasonable. The problem is that the canonicalization is implemented with get_coord, whose evenness test is all_diffs_close_enough (dascore/utils/misc.py:586):

return np.allclose(diffs, med, rtol=0.001)

So any coordinate whose spacing varies by less than 0.1% is collapsed, and its values are replaced by an idealized ramp. Canonicalization that changes data is lossy; it should be reserved for the exactly-even case where it is a pure representation change. The comment on that rtol already notes it "is a bit arbitrary."

This is the mechanism behind the caveat in #895: a reader can build an exact coordinate with get_exact_coord and still have the coord manager convert it back to a CoordRange, so exactness cannot be guaranteed at the reader layer. There it costs the real per-sample jitter in a Febus acquisition-window coordinate, but nothing about the behavior is Febus-specific — it applies to any coordinate carrying small, physically meaningful irregularity: GPS-derived positions, per-sample timing jitter, measured channel spacing.

Example

import numpy as np
import dascore as dc
from dascore.core.coords import CoordMonotonicArray

vals = np.array([0.0, 1.0, 2.0005, 3.0015, 4.002])
coord = CoordMonotonicArray(values=vals)

coord.values
# array([0.    , 1.    , 2.0005, 3.0015, 4.002 ])   <- exact, as constructed

cm = dc.get_coord_manager({"distance": coord}, dims=("distance",))
cm.coord_map["distance"].values
# array([0.    , 1.0005, 2.001 , 3.0015, 4.002 ])   <- values 1 and 2 rewritten

Same through the public Patch constructor:

patch = dc.Patch(
    data=np.zeros((5, 2)),
    coords={"distance": coord, "time": dc.to_datetime64(np.arange(2.0))},
    dims=("distance", "time"),
)
patch.get_coord("distance").values
# array([0.    , 1.0005, 2.001 , 3.0015, 4.002 ])

Maximum absolute error here is 5e-4 on a nominal spacing of 1.0. There is no warning, and the coordinate reports itself as an evenly sampled CoordRange afterwards, so downstream code has no way to tell the spacing was invented.

Expected behavior

Constructing a coordinate explicitly and handing it to a coord manager should never change its values. Canonicalizing an array coord to a CoordRange is fine when the range reproduces the original values exactly — that is a representation change. When it does not, the original coordinate should be kept.

Concretely, cm.coord_map["distance"].values should equal vals in the example above, while an exactly evenly spaced CoordMonotonicArray should still collapse to a CoordRange as it does today.

Worth deciding separately: whether get_coord(data=...) should keep snapping this aggressively when inferring from a raw array. That path is at least documented as tolerant inference, so it is defensible; silently discarding a caller's explicit, exact coordinate is not.

Versions

  • OS: Linux 6.8.0-137-generic (Ubuntu, glibc 2.39)
  • DASCore Version: 0.1.21.dev8+g32cce2e5b (dev)
  • Python Version: 3.13.7

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions