Repository navigation
Conversation
|
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.
|
|
@StuartLittlefair - ping on this. Feature freeze is Nov 20 and it would be good to try getting this in. |
|
Hi @taldcroft - i had started a review, I'll try and finish today |
StuartLittlefair
left a comment
There was a problem hiding this comment.
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
|
Thanks @StuartLittlefair! You certainly provided valuable input on the first iteration of this (#20211), so overall a good team effort. 😄 |
|
Close and re-open to try getting one stalled test to run. |
Pull request was closed
|
Please rebase to pick up #20719 . Thank! |
4bf724f to
18c2293
Compare
|
Thanks @pllim, rebased! 🤞 |
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]>
Summary
This PR updates
SkyCoordequality to use a single item-by-item comparison ofself.info._represent_as_dict(), the dict that already holds everything needed to reconstruct aSkyCoord. 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.:
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.pyand the tests.with reviewers.
Details
Click to expand
Compare the two
info._represent_as_dict()dicts item by item instead of routingthrough frame equality
1. Why the primary/extra split existed
A
SkyCoordcan carry any attribute registered inframe_transform_graph, not justthose of its own frame, so that a round trip through another frame preserves them.
SkyCoord.__setattr__files anything that is not an attribute ofself.frameintoself._extra_frameattr_namesand stores it asself._<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 theirshapes into
self._shape, andAttribute.__get__broadcasts on access), whereasSkyCoord.__getattr__returns the raw stored value. So a frame attribute always has theframe'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 doesattrs.extend(frame_transform_graph.frame_attributes.keys())and thengetattr(self._parent, key, None)for each, which for aSkyCoordpicks up both kindsidentically. Values keep their natural shapes, so the broadcasting difference above is
handled by numpy rather than by explicit shape code.
2. Why
Falseand not an exception_represent_as_dict()skips items whose value isNone, so an unset attribute issimply an absent key, and the frame name,
representation_typeanddifferential_typeare 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:
framekey differs, and usually the component names toorepresentation_typerepresentation_typekey differsdifferential_typepresent on one side onlyNone 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:return self == SkyCoord(value, copy=False), so theframe goes through the same comparison. The
value._data is Noneguard stays ahead ofit, otherwise the message would come from the
SkyCoordconstructor instead.SkyCoord:return NotImplemented, unchanged.repr1.keys() != repr2.keys(): returnnp.zeros(shape, bool)[()]whereshapeisnp.broadcast_shapes(self.shape, value.shape). The[()]is what makes a scalarcomparison return
np.False_rather than a 0-d array.out = out & _item_equal(val1, repr2[attr])over the items, starting fromTrue. 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 isitself a frame, such as
SkyOffsetFrame.origin. Comparing two frames raises rather thanreturning
Falsewhen they are not equivalent, or when only one of them has data, sofor 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 withcannot compare, which is the prefix used by all five raise sites inbaseframe.pyandrepresentation/base.py. Anything else propagates.BaseCoordinateFrame._frameattr_equivis left in place; it is still used byis_equivalent_frame()and bySkyCoord.__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:4. Testing
New and rewritten tests in
astropy/coordinates/tests/test_sky_coord.py:test_equal_fk4_frame_attribute_mismatchnp.False_; different frame class likewise, in both directionstest_equal_fk4_frame_attribute_arraysequinox/obstimecompared element-wisetest_equal_fk4_frame_attribute_arrays_defaulttest_equal_extra_frame_attributesobstime, and the case where it is set on only one sidetest_equal_extra_frame_attribute_broadcasting_mismatch(2, 1)attribute against(2, 2)coordinatestest_equal_extra_frame_attribute_larger_than_coord_shape(2, 1)attribute on(2,)coordinates gives a(2, 2)result; array attribute on scalar coordinatestest_equal_extra_frame_attribute_shape_mismatchtest_equal_radial_velocity_broadcastingtest_equal_frametest_equal_coordinate_frame_attributeSkyOffsetFrame.originwith non-equivalent origins;galcen_coordwith data on one side only; an unrelatedTypeErrorstill propagatestest_equal_exceptionsis removed — the exceptions it asserted no longer exist. Intest_frames.py,test_frame_coord_comparisonhad twopytest.raises(TypeError)blocksfor comparisons involving a
SkyCoord; both now assertnp.False_, and the reflecteddirection was added.
Attribute types exercised by the above:
TimeAttribute,QuantityAttribute,EarthLocationAttribute,CartesianRepresentationAttribute,DifferentialAttributeand
CoordinateAttribute, plus masked data.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__andSkyCoord.__ne__are the only changed API. Comparisons thatsucceeded before return the same values; what changes is that comparisons which raised
now return
Falseor an array containingFalse. Code that relies on catching theexception will stop working, hence the changelog fragment at
docs/changes/coordinates/20266.api.rst.Two behaviour changes worth calling out beyond "stops raising":
SkyCoordto aBaseCoordinateFramepreviously ignored the extra frameattributes entirely, because it delegated to
self.frame == value. It no longer does,so a
SkyCoordcarrying anobstimeis not equal to a bareICRSframe. The oldbehaviour made equality non-transitive:
sc(obstime=A) == frandsc(obstime=B) == frwere both
Truewhilesc(obstime=A) != sc(obstime=B).Falseinstead of raisingTypeError.BaseCoordinateFrameis unmodified, so frame-to-frame equality,is_equivalent_frame(),__setitem__,concatenate()and the transformation machinery are bit-for-bitunchanged.
The
docs/coordinates/skycoord.rst"Exact Equality" section still describes the oldraising behaviour and needs updating; that is not in this PR yet.