Skip to content

Improve SkyCoord and Frame equality operators - #20211

Open
taldcroft wants to merge 6 commits into
astropy:mainfrom
taldcroft:coordinates-skycoord-better-equality
Open

Improve SkyCoord and Frame equality operators#20211
taldcroft wants to merge 6 commits into
astropy:mainfrom
taldcroft:coordinates-skycoord-better-equality

Conversation

@taldcroft

@taldcroft taldcroft commented Aug 5, 2026

Copy link
Copy Markdown
Member

Overview

This PR was originally motivated by work on Table.group_by(), where trying to group by a SkyCoord revealed a known issue that SkyCoord equality itself was inconsistent and gave surprising results. Opening this can of worms led to API changes in the BaseCoordinateFrame class as well. I believe these changes are for the better, but
input from coordinates maintainers is obviously critical here.

Comparing SkyCoord or BaseCoordinateFrame objects with == now takes frame attributes such as equinox, obstime or location into account element-wise, in the same way as the coordinate data itself. Previously any difference in a frame attribute raised an exception, even when only a single element of an array-valued attribute differed.

For example, an obstime that matches for only some of the coordinates now gives a partially True result::

  >>> import astropy.units as u
  >>> from astropy.coordinates import SkyCoord
  >>> from astropy.time import Time
  >>> obstime = Time(["2020-01-01", "2021-01-01"])
  >>> sc1 = SkyCoord([1, 2] * u.deg, [3, 4] * u.deg, frame="fk4", obstime=obstime)
  >>> sc2 = SkyCoord([1, 2] * u.deg, [3, 4] * u.deg, frame="fk4", obstime="2020-01-01")
  >>> sc1 == sc2
  array([ True, False])

AI disclosure

This PR is almost entirely AI-generated using Claude Opus 5. I have examined the code
and fully understand the changes in SkyCoord and the tests. But I must be honest
and say that the changes in BaseCoordinateFrame look reasonable to me but I could not
assess myself the full impact (the blast radius as Claude says). This code base is
sufficiently complex that making this assessment is beyond my expertise. To that end
I have directed Claude to provide a clear statement of what is changed and I hope that
(along with testing) this is sufficient for the maintainer experts.

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

Detailed Description

Click to expand

Compare coordinate frame attributes element-wise instead of requiring equivalence

== on SkyCoord and BaseCoordinateFrame currently refuses to answer whenever the
two operands differ in any frame attribute. This is inconsistent with how the
coordinate data is treated — a single differing element there gives a partially
True array, not an exception — and it is inconsistent with itself, since the same
attribute (obstime) behaves differently depending on whether it happens to be a
primary attribute of the frame or a SkyCoord "extra" attribute (§5.1).

This PR makes frame attributes and extra frame attributes contribute to the
comparison element-wise, exactly like the data. Comparing coordinates of different
frame classes
still raises, since there is no element-wise answer in that case, and
is_equivalent_frame is deliberately untouched.

All examples below were run on main and on this branch; the outputs are verbatim.

Common setup:

import astropy.units as u
from astropy.coordinates import FK5, ICRS, SkyCoord
from astropy.time import Time

obstime = Time(["2020-01-01", "2021-01-01"])

12.1 Primary frame attribute mismatch: TypeError → element-wise

sc1 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, frame="fk4", obstime=obstime)
sc2 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, frame="fk4", obstime="2020-01-01")
sc1 == sc2

main:

TypeError: cannot compare: objects must have equivalent frames:
<FK4 Frame (equinox=B1950.000, obstime=['2020-01-01 00:00:00.000' '2021-01-01 00:00:00.000'])>
vs. <FK4 Frame (equinox=B1950.000, obstime=2020-01-01 00:00:00.000)>

branch:

array([ True, False])

The same holds one tier down, on bare frames:

f1 = FK5([1, 2]*u.deg, [3, 4]*u.deg, equinox=Time(["J2000", "J2001"]))
f2 = FK5([1, 2]*u.deg, [3, 4]*u.deg, equinox=Time("J2000"))
f1 == f2

main: TypeError: cannot compare: objects must have equivalent frames: ...  → 

branch: array([ True, False])

12.2 Extra frame attribute mismatch: ValueError → element-wise

e1 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, obstime=obstime)   # ICRS: obstime is EXTRA
e2 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, obstime="2020-01-01")
e1 == e2

main:

ValueError: cannot compare: extra frame attribute 'obstime' is not equivalent
(perhaps compare the frames directly to avoid this exception)

branch:

array([ True, False])

Together with §12.1 this closes the inconsistency in §5.1: obstime now behaves the
same way whether or not it belongs to the coordinate's own frame.

12.3 Extra attribute present on only one side: ValueError → all-False

e1 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg, obstime=obstime)
o2 = SkyCoord([1, 2]*u.deg, [3, 4]*u.deg)                    # no obstime
e1 == o2

main: the same ValueError as §12.2  → 

branch: array([False, False])

An attribute set on one side and unset on the other is a genuine difference, so it
compares False rather than raising. This matches what _frameattr_equiv already
did for the None case.

12.4 Representation attributes with differentials: warn-and-False → compared properly

Setup (a frame with a representation-valued attribute that retains differentials):

from astropy.coordinates import (BaseCoordinateFrame, CartesianDifferential,
                                 CartesianRepresentation)
from astropy.coordinates.attributes import CartesianRepresentationAttribute

class RepFrame(BaseCoordinateFrame):
    default_representation = CartesianRepresentation
    myrep = CartesianRepresentationAttribute(unit=u.km)

base = CartesianRepresentation([1., 2., 3.]*u.km)
mk = lambda v: base.with_differentials(CartesianDifferential(v*u.km/u.s))
data = CartesianRepresentation([[1., 2.], [3., 4.], [5., 6.]]*u.km)

ra = RepFrame(data, myrep=mk([1., 2., 3.]))
rb = RepFrame(data, myrep=mk([1., 2., 3.]))   # equal
rc = RepFrame(data, myrep=mk([9., 9., 9.]))   # differs

main — note this fires even when the attributes are equal:

ra == rb
# AstropyWarning: Two representation frame attributes were checked for equivalence
#   when at least one of them has differentials.  This yields False even if the
#   underlying representations are equivalent (although this may change in future
#   versions of Astropy)
# TypeError: cannot compare: objects must have equivalent frames: ...
ra == rc      # same warning, same TypeError

branch — no warning, and the two cases are now distinguished:

ra == rb      # array([ True,  True])
ra == rc      # array([False, False])

This retires the warning's own "this may change in future versions of Astropy" promise.

12.5 Result shape now accounts for extra-attribute shape

Extra frame attributes are not broadcast against the data when they are set, so a
scalar SkyCoord can carry an array-valued extra attribute. The comparison result now
reflects that shape instead of collapsing it:

p = SkyCoord(1*u.deg, 2*u.deg, obstime=Time(["2020-01-01", "2021-01-01"]))
q = SkyCoord(1*u.deg, 2*u.deg, obstime=Time(["2020-01-01", "2021-01-01"]))
p.shape, p.obstime.shape        # ((), (2,))
p == q

main: np.True_  → 

branch: array([ True, True])

This is what makes a partially-matching extra attribute on a scalar coordinate
expressible at all, and it resolves §5.2.

12.6 What deliberately does not change

case main branch
different frame classes (ICRS vs FK5) TypeError TypeError (unchanged)
is_equivalent_frame with differing obstime False False (unchanged)
concatenate of non-equivalent frames ValueError ValueError (unchanged)
data-less frames, same attrs True True (unchanged)
data-less frames, differing attrs False False (unchanged)
data shape mismatch ValueError: cannot compare: shape mismatch: ... unchanged
data differs, attributes equal array([ True, False]) unchanged

is_equivalent_frame is untouched, so everything that gates on it — item assignment,
concatenate, frame transformations — behaves exactly as before. Only == / !=
change.

12.7 Implementation

  • baseframe.py: new BaseCoordinateFrame._frameattr_eq_elementwise static method,
    mirroring _frameattr_equiv's branch structure but returning the element-wise result
    instead of collapsing it with np.all. _frameattr_equiv and is_equivalent_frame
    are untouched.
  • baseframe.py: BaseCoordinateFrame.__eq__ raises TypeError only on a class
    mismatch. It compares self._data == value._data first, so shape mismatches still
    produce the informative message from the representation classes, then &s in
    _frameattr_eq_elementwise over self.frame_attributes.
  • sky_coordinate.py: _extra_frameattr_equiv folds the extra attributes into the
    frame comparison result rather than raising, using np.logical_and so the result
    broadcasts against array-valued extra attributes (§12.5).

See §10 for a block-by-block comparison of _frameattr_equiv and
_frameattr_eq_elementwise, and §10.1 for why they are not merged in this PR.

12.8 Backwards compatibility

This is an API change: code that relied on == raising for mismatched frame
attributes will now get an array instead. Code that catches TypeError/ValueError
around == to detect "not comparable" should use is_equivalent_frame instead, which
is unchanged. A docs/changes/coordinates/20211.api.rst fragment and a
docs/whatsnew/8.1.rst entry are included.

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions

github-actions Bot commented Aug 5, 2026

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.

@pllim pllim added this to the v8.1.0 milestone Aug 5, 2026
@taldcroft

taldcroft commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Detailed explanation of _frameattr_equiv vs. _frameattr_eq_elementwise

Click to expand The two helpers sit next to each other in [baseframe.py:1551](astropy/coordinates/baseframe.py#L1551) and [baseframe.py:1604](astropy/coordinates/baseframe.py#L1604), and they look near-duplicated. This section walks the two side by side and explains, for each block, whether it is genuinely the same code or only superficially similar.

Short version: the dispatch skeleton is identical and the four leaf comparisons
all differ.
The repetition is in the type-dispatch, not in the logic that
matters.

Block 1 — signature: identical

@staticmethod
def _frameattr_equiv(left_fattr, right_fattr):          # noqa: PLR0911
def _frameattr_eq_elementwise(left_fattr, right_fattr): # noqa: PLR0911

Both are staticmethods taking two raw attribute values, and both trip ruff's
"too many return statements" rule for the same reason: they are dispatch tables
written as a chain of early returns.

Block 2 — identity and None shortcuts: identical

if left_fattr is right_fattr:
    return True
elif left_fattr is None or right_fattr is None:
    return False

Byte-for-byte the same (the new one has an extra clause in the comment noting
that the first branch also covers both being None).

Worth knowing how load-bearing that first line is: because Attribute.__get__
caches the broadcast value back onto the instance, two frames sharing an
attribute object hit the is shortcut and never reach a real comparison. That
is why several array-valued-frame-attribute tests passed before this work even
though the element-wise path did not exist yet — they compared a Time object
against itself.

Note this shortcut is also where both helpers return a plain scalar True
regardless of the attribute's shape. For the element-wise helper that is fine:
the caller &s the result into the data comparison, which carries the shape.

Block 3 — representation type-mismatch guard: identical

left_is_repr = isinstance(left_fattr, r.BaseRepresentationOrDifferential)
if left_is_repr ^ isinstance(right_fattr, r.BaseRepresentationOrDifferential):
    return False

Identical. A representation compared against a non-representation is False
with no element-wise nuance available, so there is nothing to change.

Block 4 — representation body: substantially different

Old:

if getattr(left_fattr, "differentials", False) or getattr(right_fattr, "differentials", False):
    warnings.warn("... at least one of them has differentials.  This yields False "
                  "even if the underlying representations are equivalent ...", AstropyWarning)
    return False
return np.all(
    left_fattr == right_fattr
    if type(left_fattr) is type(right_fattr)
    else left_fattr.to_cartesian() == right_fattr.to_cartesian()
)

New:

left_diffs = getattr(left_fattr, "differentials", {})
right_diffs = getattr(right_fattr, "differentials", {})
if left_diffs.keys() != right_diffs.keys() or any(
    type(left_diffs[key]) is not type(right_diffs[key]) for key in left_diffs
):
    return False

if type(left_fattr) is not type(right_fattr):
    if left_diffs or any(isinstance(fattr, r.BaseDifferential)
                         for fattr in (left_fattr, right_fattr)):
        return False
    left_fattr = left_fattr.to_cartesian()
    right_fattr = right_fattr.to_cartesian()

return left_fattr == right_fattr

This is the block that actually diverges, and it is the only semantic change
between the two helpers (see §10.1). Four differences:

  1. The differentials bail-out is gone. The old code refuses to compare as
    soon as either side has differentials, warns, and returns False — its own
    warning text admits this is wrong ("this may change in future versions"). The
    new code compares them, which is sub-decision 5 from §6.
  2. A narrower guard replaces it. BaseRepresentation.__eq__ raises
    ValueError on mismatched differential keys and TypeError on mismatched
    differential classes, so those two cases are checked up front and return
    False rather than being caught after the fact. This is preferred over a
    try/except so that a genuine shape-mismatch ValueError still propagates.
  3. The cross-class conversion became a statement instead of a conditional
    expression
    , because it now needs the extra guard: to_cartesian() silently
    drops differentials, and on a BaseDifferential it requires a base argument
    that is not available here. The old one-liner would raise TypeError on
    cross-class differentials — latent, since no built-in frame has an attribute
    that can hit it.
  4. No np.all(). The point of the exercise.

Block 5 — coordinate type-mismatch guard: identical

left_is_coord = isinstance(left_fattr, BaseCoordinateFrame)
if left_is_coord ^ isinstance(right_fattr, BaseCoordinateFrame):
    return False

Identical, same reasoning as block 3.

Block 6 — coordinate body: different, but converging

Old:

return left_fattr.is_equivalent_frame(right_fattr) and np.all(left_fattr == right_fattr)

New:

if left_fattr.__class__ is not right_fattr.__class__:
    return False
if left_fattr.has_data != right_fattr.has_data:
    return False
return left_fattr == right_fattr

Both exist for the same reason, stated in the old docstring: comparing
coordinates "first checks whether they themselves are in equivalent frames
before checking for equality in the normal fashion. This is because checking for
equality with non-equivalent frames raises an error."

The new version guards the same hazard, but the hazard shrank. After this PR,
frame == only raises on a class mismatch or a data/no-data mismatch, so those
are exactly the two conditions checked. Everything else recurses into the
element-wise frame comparison — which is the point, since a CoordinateAttribute
should get the same element-wise treatment as any other coordinate.

Using is_equivalent_frame here would have been wrong for the new helper: it
would collapse a nested attribute difference back to a scalar False. This is
the deviation from the original plan noted in §9.

Block 7 — fallback: differs only by np.all()

return np.all(left_fattr == right_fattr)   # old
return left_fattr == right_fattr           # new

This is the path taken by Time, Quantity and EarthLocation attributes —
i.e. almost every real frame attribute. Here the two helpers really are the same
code modulo the collapse.

10.1 Could they be merged?

Nearly. Running both over 25 representative attribute pairs and comparing
np.all(_frameattr_eq_elementwise(a, b)) against _frameattr_equiv(a, b), they
agree on 24 (script: compare_helpers.py). Only two cases diverge, and neither
is a case where the old behavior is defensible:

case _frameattr_equiv np.all(elementwise)
representation attrs with equal differentials False + AstropyWarning True
coordinate attr, one has data and the other does not raises ValueError False

The first is the intentional improvement (sub-decision 5). The second is a
pre-existing bug in _frameattr_equiv, and therefore in
is_equivalent_frame, which is documented as returning bool and raising only
TypeError. It is reachable from the public API, because
CoordinateAttribute.convert_input passes a value through untouched when it is
already an instance of the right frame class, data or not:

g1 = Galactocentric(..., galcen_coord=ICRS(1*u.deg, 2*u.deg))
g2 = Galactocentric(..., galcen_coord=ICRS())          # no data, accepted
g1.is_equivalent_frame(g2)
# ValueError: cannot compare: one frame has data and the other does not

So _frameattr_equiv could in principle become:

@staticmethod
def _frameattr_equiv(left_fattr, right_fattr):
    return bool(np.all(BaseCoordinateFrame._frameattr_eq_elementwise(left_fattr, right_fattr)))

deleting ~35 lines and the duplicated dispatch entirely. That was not done
here, deliberately:

  • It would change is_equivalent_frame, and through it __setitem__,
    concatenate, spherical_offsets_to and transform loopback detection — frames
    whose representation attributes carry equal differentials would become
    "equivalent" where they previously were not. That is almost certainly the
    correct behavior, but it is a second API change with a much wider blast radius
    than the one this PR is making, and it deserves its own review.
  • The ValueError fix is a genuine bugfix that stands on its own and would be
    easier to review as a separate PR against main.

Recommendation: land this PR as-is with the duplication, then follow up with a
PR that fixes the is_equivalent_frame ValueError and collapses
_frameattr_equiv into a np.all() wrapper. Splitting it that way keeps the
is_equivalent_frame semantic change reviewable on its own terms rather than
riding along inside an equality change.

@taldcroft

Copy link
Copy Markdown
Member Author

In astropy/astropy-project#538 (comment), @astrofrog commented that this is an example where PRs should be split into smaller parts where possible.

I strongly agree with the principle. In this particular case I'm not so sure because one of the key outcomes of this PR is unifying the behavior for equality of SkyCoord and Frame. I certainly could (and am willing) to split this into two PRs:

  1. Improve BaseCoordinateFrame equality.
  2. Improve SkyCoord and unify with BaseCoordinateFrame.

The hitch here is first that (1) is not hugely valuable on its own. Another hitch is that both of these are API changes that would merit a What's New, so (1) would provide an intermediate version and then (2) the final version. Because these changes are intrinsically coupled I'm not convinced that splitting into two PR's will reduce the overall level of effort.

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.

2 participants