Skip to content

Improve SkyCoord equality operator - #20266

Merged
taldcroft merged 6 commits into
astropy:mainfrom
taldcroft:coordinates-skycoord-better-equality-simpler
Oct 26, 2026
Merged

taldcroft merged 6 commits into
astropy:mainfrom
taldcroft:coordinates-skycoord-better-equality-simpler

Conversation

@taldcroft

@taldcroft taldcroft commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR updates SkyCoord equality to use a single item-by-item comparison of self.info._represent_as_dict(), the dict that already holds everything needed to reconstruct a SkyCoord. Comparing two of those dicts gives the documented definition of equality directly, and because the items are compared with plain == the result broadcasts like any other array operation.

The goal is that equality always succeeds and returns a result consistent with the broadcasted shape of the two sides. The only time an exception is raised is if the shapes are not broadcastable.

This addresses a long-time issue of inconsistency in behavior, e.g.:

>>> obstime = Time(["2020-01-01", "2021-01-01"])
>>> sc1 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, obstime=obstime)
>>> sc2 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, obstime="2020-01-01")
>>> sc1 == sc2
ValueError: cannot compare: extra frame attribute 'obstime' is not equivalent    # main
array([ True, False])                                                            # this branch

>>> f1 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, frame="fk4", obstime=obstime)
>>> f2 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, frame="fk4", obstime="2020-01-01")
>>> f1 == f2
TypeError: cannot compare: objects must have equivalent frames: ...              # main
array([ True, False])                                                            # this branch

AI disclosure

The code in this PR is largely AI-generated using Claude Opus 5. I planned the Claude prompts carefully and I have examined the code and fully understand the changes in sky_coordinate.py and the tests.

  • I certify that I am human and take responsibility for the code and interactions
    with reviewers.

Details

Click to expand

Compare the two info._represent_as_dict() dicts item by item instead of routing
through frame equality

1. Why the primary/extra split existed

A SkyCoord can carry any attribute registered in frame_transform_graph, not just
those of its own frame, so that a round trip through another frame preserves them.
SkyCoord.__setattr__ files anything that is not an attribute of self.frame into
self._extra_frameattr_names and stores it as self._<name>.

The two kinds then diverge in one way that matters here. A frame broadcasts its own
attributes against the data at construction (BaseCoordinateFrame.__init__ folds their
shapes into self._shape, and Attribute.__get__ broadcasts on access), whereas
SkyCoord.__getattr__ returns the raw stored value. So a frame attribute always has the
frame's shape, and an extra frame attribute can have any shape at all — including one
larger than the coordinate it is attached to.

_represent_as_dict() erases the distinction: it does
attrs.extend(frame_transform_graph.frame_attributes.keys()) and then
getattr(self._parent, key, None) for each, which for a SkyCoord picks up both kinds
identically. Values keep their natural shapes, so the broadcasting difference above is
handled by numpy rather than by explicit shape code.

2. Why False and not an exception

_represent_as_dict() skips items whose value is None, so an unset attribute is
simply an absent key, and the frame name, representation_type and differential_type
are present as plain strings. Every one of the five conditions in the documented
definition of equality is therefore either a differing key or a differing value:

condition how it shows up
identical frame type frame key differs, and usually the component names too
identical representation_type representation_type key differs
identical differentials keys differential_type present on one side only
identical frame attributes attribute key differs in value
identical extra frame attributes same, no separate handling

None of these has any reason to raise — they are all just "not equal". A differing key
short-circuits to all-False, since there is nothing to compare element-wise.

3. Implementation

SkyCoord.__eq__ is now, in full:

  • Frame on the right-hand side: return self == SkyCoord(value, copy=False), so the
    frame goes through the same comparison. The value._data is None guard stays ahead of
    it, otherwise the message would come from the SkyCoord constructor instead.
  • Not a SkyCoord: return NotImplemented, unchanged.
  • repr1.keys() != repr2.keys(): return np.zeros(shape, bool)[()] where shape is
    np.broadcast_shapes(self.shape, value.shape). The [()] is what makes a scalar
    comparison return np.False_ rather than a 0-d array.
  • Otherwise out = out & _item_equal(val1, repr2[attr]) over the items, starting from
    True. The output shape falls out of the broadcasting rather than being computed;
    it has to, because an extra frame attribute can make the result larger than either
    input (see the test below).

_item_equal() is a module-level helper for one case: an attribute whose value is
itself a frame, such as SkyOffsetFrame.origin. Comparing two frames raises rather than
returning False when they are not equivalent, or when only one of them has data, so
for an attribute that has to be translated into "not equal". The catch is narrowed two
ways — the value must be a BaseCoordinateFrame, and the message must start with
cannot compare, which is the prefix used by all five raise sites in
baseframe.py and representation/base.py. Anything else propagates.

BaseCoordinateFrame._frameattr_equiv is left in place; it is still used by
is_equivalent_frame() and by SkyCoord.__setitem__.

Shape-mismatch messages now name the offending item and carry numpy's detail, and all
of them use the cannot compare: prefix already used elsewhere in the subpackage:

cannot compare: extra frame attribute 'obstime' has shape mismatch: operands could not
be broadcast together with shapes (2,) (3,)

4. Testing

New and rewritten tests in astropy/coordinates/tests/test_sky_coord.py:

test covers
test_equal_fk4_frame_attribute_mismatch scalar attribute mismatch gives np.False_; different frame class likewise, in both directions
test_equal_fk4_frame_attribute_arrays array-valued equinox/obstime compared element-wise
test_equal_fk4_frame_attribute_arrays_default scalar attribute against an array-valued one
test_equal_extra_frame_attributes extra obstime, and the case where it is set on only one side
test_equal_extra_frame_attribute_broadcasting_mismatch (2, 1) attribute against (2, 2) coordinates
test_equal_extra_frame_attribute_larger_than_coord_shape (2, 1) attribute on (2,) coordinates gives a (2, 2) result; array attribute on scalar coordinates
test_equal_extra_frame_attribute_shape_mismatch attributes that do not broadcast still raise
test_equal_radial_velocity_broadcasting scalar against array with velocities
test_equal_frame comparison against a bare frame, including that extra frame attributes are now counted
test_equal_coordinate_frame_attribute SkyOffsetFrame.origin with non-equivalent origins; galcen_coord with data on one side only; an unrelated TypeError still propagates

test_equal_exceptions is removed — the exceptions it asserted no longer exist. In
test_frames.py, test_frame_coord_comparison had two pytest.raises(TypeError) blocks
for comparisons involving a SkyCoord; both now assert np.False_, and the reflected
direction was added.

Attribute types exercised by the above: TimeAttribute, QuantityAttribute,
EarthLocationAttribute, CartesianRepresentationAttribute, DifferentialAttribute
and CoordinateAttribute, plus masked data.

$ python -m pytest astropy/coordinates
2051 passed, 48 skipped, 10 xfailed in 7.44s

$ python -m pytest astropy/coordinates/tests/test_sky_coord.py -k equal
12 passed, 426 deselected in 0.13s

$ python -m pytest docs/coordinates
24 passed in 1.50s

$ python -m pytest astropy docs
32303 passed, 439 skipped, 265 xfailed in 177.70s

The full-suite figure is from an earlier run of this branch, before the shape-mismatch
message change in the final commit; the coordinates and docs figures above are current.

5. Backwards compatibility

SkyCoord.__eq__ and SkyCoord.__ne__ are the only changed API. Comparisons that
succeeded before return the same values; what changes is that comparisons which raised
now return False or an array containing False. Code that relies on catching the
exception will stop working, hence the changelog fragment at
docs/changes/coordinates/20266.api.rst.

Two behaviour changes worth calling out beyond "stops raising":

  • Comparing a SkyCoord to a BaseCoordinateFrame previously ignored the extra frame
    attributes entirely, because it delegated to self.frame == value. It no longer does,
    so a SkyCoord carrying an obstime is not equal to a bare ICRS frame. The old
    behaviour made equality non-transitive: sc(obstime=A) == fr and sc(obstime=B) == fr
    were both True while sc(obstime=A) != sc(obstime=B).
  • Comparing coordinates in different frame classes returns False instead of raising
    TypeError.

BaseCoordinateFrame is unmodified, so frame-to-frame equality, is_equivalent_frame(),
__setitem__, concatenate() and the transformation machinery are bit-for-bit
unchanged.

The docs/coordinates/skycoord.rst "Exact Equality" section still describes the old
raising behaviour and needs updating; that is not in this PR yet.

@taldcroft taldcroft added this to the v8.1.0 milestone Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@taldcroft
taldcroft marked this pull request as ready for review August 30, 2026 14:22
@taldcroft

Copy link
Copy Markdown
Member Author

@StuartLittlefair - ping on this. Feature freeze is Nov 20 and it would be good to try getting this in.

@StuartLittlefair

Copy link
Copy Markdown
Contributor

Hi @taldcroft - i had started a review, I'll try and finish today

@StuartLittlefair StuartLittlefair 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.

Sorry this took me so long @taldcroft

In part this is because it took me ages to find anything to suggest tweaking in the PR. In the end I really couldn't find anything. The solution is elegant, makes sense and I can't think of any tests I'd like to see.

The only thing I could possibly think of is if we used frame attributes with the same name, but different meanings, for two different frames. But I couldn't find an example of that, so I'm happy to approve

@taldcroft

Copy link
Copy Markdown
Member Author

Thanks @StuartLittlefair! You certainly provided valuable input on the first iteration of this (#20211), so overall a good team effort. 😄

@taldcroft
taldcroft enabled auto-merge (squash) October 26, 2026 15:10
@taldcroft

Copy link
Copy Markdown
Member Author

Close and re-open to try getting one stalled test to run.

@taldcroft taldcroft closed this Oct 26, 2026
auto-merge was automatically disabled October 26, 2026 15:11

Pull request was closed

@taldcroft taldcroft reopened this Oct 26, 2026
@taldcroft
taldcroft enabled auto-merge (squash) October 26, 2026 15:12
@pllim

pllim commented Oct 26, 2026

Copy link
Copy Markdown
Member

Please rebase to pick up #20719 . Thank!

@taldcroft
taldcroft force-pushed the coordinates-skycoord-better-equality-simpler branch from 4bf724f to 18c2293 Compare October 26, 2026 16:00
@taldcroft

Copy link
Copy Markdown
Member Author

Thanks @pllim, rebased! 🤞

@taldcroft
taldcroft merged commit bf1d238 into astropy:main Oct 26, 2026
44 checks passed
@taldcroft
taldcroft deleted the coordinates-skycoord-better-equality-simpler branch October 26, 2026 16:49
icweaver added a commit to JuliaAstro/SkyCoords.jl that referenced this pull request Nov 10, 2026
This PR keeps `==` strict and documents `separation(a, b) < tol` as the
on-sky comparison. Making that the recommended route exposed a bug with
projected coordinates, so `separation`, `position_angle`, and `offset`
are fixed here as well.

## Usage

Comparing two descriptions of the same point on the sky:

```julia-repl
julia> c = ICRSCoords(1.0, 0.2);

julia> c == convert(GalCoords, c)
false

julia> separation(c, convert(GalCoords, c)) < 1e-9
true
```

Projected coordinates now work as the left argument:

```julia-repl
julia> c0 = ICRSCoords(0.1, -0.2); c1 = ICRSCoords(0.10001, -0.19997);

julia> p = SkyCoords.project(c0, c1);

julia> separation(p, c0) # Was: ArgumentError
3.15603173906638e-5

julia> position_angle(p, c0) # Was: ArgumentError
3.4573512132621755

julia> offset(p, c0) # Was: ArgumentError
(3.15603173906638e-5, 3.4573512132621755)

julia> offset(p, 1e-5, 0.5) # Was: MethodError
ProjectedCoords{ICRSCoords{Float64}, Float64}(ICRSCoords{Float64}(0.1, -0.2), [1.459488348425262e-5, 3.8775827948173136e-5])
```

## Design

### Comparison semantics

`==` keeps its current meaning: same frame, same representation, and
exactly equal values. Converting first would not make it reliable,
because conversions round. Over 100,000 random ICRS points, a round trip
through another frame returned identical values for only 9% to 26% of
them, depending on the frame, with a worst error around 1e-15.

Converting into the left argument's frame would also make `==`
asymmetric: `convert(GalCoords, c) == c` would be `true` while `c ==
convert(GalCoords, c)` would usually be `false`. Converting into a fixed
frame like ICRS restores symmetry, but returns `false` for about 85% of
ICRS/Galactic pairs.

This matches astropy, which deliberately does no conversion in `==`
(astropy/astropy#10154), returns `False` for `SkyCoord`s in different
frames starting with 8.1 (astropy/astropy#20266), and points users to
`separation` for tolerance checks.

`≈` is unchanged. It is not defined between spherical coordinates in
different frames today, so extending it later would not break anything
if there is demand.

### Projected coordinates

The mixed-type methods converted the right argument to the left
argument's type, which cannot work for a projected left argument because
its origin is a value rather than a type parameter. `separation` and
`position_angle` now compare in the origin's frame instead, following
nested projections through `_unprojected_type`. The two-coordinate
`offset` is built from those two, so it needs no change of its own.
Every other coordinate type maps to itself, and same-type `convert`
returns its input, so existing results do not change.

`offset(p, sep, pa)` documents that it returns the input's type, so the
result is re-projected around the same origin, sharing its arithmetic
with `project` through `_project_offset`. This is handled inside the
existing `offset` method because a dedicated `::AbstractProjectedCoords`
method would be ambiguous with the DynamicQuantities extension's
`offset` methods.

---------

Co-authored-by: Chris Garling <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants