Repository navigation
SkyCoord/Baseframe shape changes - #5254
Conversation
| # Check shape is OK. As a useful side product, getting the | ||
| # attribute also validates it. | ||
| value = getattr(self, fnm) | ||
| if self.has_data and (getattr(value, 'size', 1) > 1 and |
There was a problem hiding this comment.
Checking against self.shape and only checking the attributes if the frame has data leads to some issues I think. For example, it allows one to do the following:
In [5]: times.shape
Out[5]: (4, 3)
In [6]: altaz_frame = coord.AltAz(location=lapalma, obstime=times,
... temperature=[20,20]*u. deg_C)That doesn't make much sense I think, but it's allowed because the frame has no data and therefore has no shape. This leads me to think that this PR should look carefully at the attributes of the BaseCoordinateFrame like shape, ndim and isscalar.
|
@mhvk I don't know the coordinates package to comment much on this initial PR. Beyond the comment above I can't see much that's wrong with it. However, here's an example use case of when shape changing frames would be useful - calculating the coordinates of multiple objects at multiple obstimes in a new frame (e.g In [1]: from astropy.time import Time
In [2]: from astropy import coordinates as coord, units as u
In [3]: from astropy.coordinates.tests.utils import randomly_sample_sphere
In [4]: times = Time.now() + np.linspace(0,10,12)*u.day
In [5]: lapalma = coord.EarthLocation.of_site('lapalma')
In [6]: coos = coord.SkyCoord(*randomly_sample_sphere(3))
In [7]: coos[:, np.newaxis].transform_to(coord.CIRS(obstime=times)).shape
Out[7]: (3, 12)But with the code in this PR this fails: ---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-9-e86535bbb6b0> in <module>()
5 lapalma = coord.EarthLocation.of_site('lapalma')
6 coos = coord.SkyCoord(*randomly_sample_sphere(3))
----> 7 coos[:, np.newaxis].transform_to(coord.CIRS(obstime=times)).shape
8
/usr/local/lib/python3.5/site-packages/astropy/coordinates/sky_coordinate.py in transform_to(self, frame)
401 # Do the transformation, returning a coordinate frame of the desired
402 # final type (not generic).
--> 403 new_coord = trans(self.frame, generic_frame)
404
405 # Finally make the new SkyCoord object from the `new_coord` and
/usr/local/lib/python3.5/site-packages/astropy/coordinates/transformations.py in __call__(self, fromcoord, toframe)
913
914 curr_toframe = t.tosys(**frattrs)
--> 915 curr_coord = t(curr_coord, curr_toframe)
916
917 # this is safe even in the case where self.transforms is empty, because
/usr/local/lib/python3.5/site-packages/astropy/coordinates/transformations.py in __call__(self, fromcoord, toframe)
704
705 def __call__(self, fromcoord, toframe):
--> 706 res = self.func(fromcoord, toframe)
707 if not isinstance(res, self.tosys):
708 raise TypeError('the transformation function yielded {0} but '
/usr/local/lib/python3.5/site-packages/astropy/coordinates/builtin_frames/icrs_cirs_transforms.py in icrs_to_cirs(icrs_coo, cirs_frame)
60 distance=srepr.distance, copy=False)
61
---> 62 return cirs_frame.realize_frame(newrep)
63
64
/usr/local/lib/python3.5/site-packages/astropy/coordinates/baseframe.py in realize_frame(self, representation)
821 for attr in self.get_frame_attr_names()
822 if attr not in self._attr_names_with_defaults])
--> 823 return self.__class__(representation, **frattrs)
824
825 def represent_as(self, new_representation, in_frame_units=False):
/usr/local/lib/python3.5/site-packages/astropy/coordinates/baseframe.py in __init__(self, *args, **kwargs)
663 raise ValueError(
664 "frame attribute should have no shape or shape={0}"
--> 665 ", not {1}".format(self.shape, value.shape))
666
667 else:
ValueError: frame attribute should have no shape or shape=(3, 12), not (12,)which is probably not surprising, but I'm more surprised this doesn't work either, as I'd expect the 3 objects to broadcast against the 12 times... In [7]: coos[:, np.newaxis].transform_to(coord.CIRS(obstime=times[np.newaxis, :])).shapeValueError: frame attribute should have no shape or shape=(3, 12), not (1, 12) |
|
@StuartLittlefair - good point about data-less frames. Of course, your particular example would still work if the broadcasting in the transformations makes the data have the right shape. But one would only get an error rather late if this were not the case. The one thing I could do here is to check the shapes against each other. Do you think that would be worth it? p.s. I wondered whether it might make more sense to just disallow non-scalar attributes for data-less frames, but I see that several tests use arrays of |
|
On your example: this could be made to work if I allowed |
|
@mhvk - on dataless frames: I think it would save a lot of user pain if we at least made sure that all the frame attributes are the same shape or can be broadcast. |
|
@StuartLittlefair - OK, that was more work than I had really anticipated, but moving the shape-checking machinery to In the process:
@taldcroft - I think my mucking with |
9c82e3c to
2d65574
Compare
bf54ac8 to
70983f6
Compare
|
Rebased to get it mergeable again, and also added support for shape manipulation to |
70983f6 to
59d9f7e
Compare
Since CartesianRepresentation.__eq__ is not yet implemented.
59d9f7e to
f2e0af9
Compare
|
Another rebase now #5253 is in, which should make this PR easier to review. Note that in process of doing this, I touched Once this is in, we should add test cases for transformations of multidimensional coordinates; quite a few should "just work", but for some the transformations may need to be updated. Also cc @eteq, @adrn, @StuartLittlefair as this is obviously a fairly involved change in coordinates. With the ability of shaped data-less frames, though, I think it is fairly complete. |
f2e0af9 to
b917bb2
Compare
|
I realise this is quite a large PR, but it also brings nice functionality. A review would be good... |
|
@mhvk - I'll try to have a look this week. |
|
@taldcroft - ping... |
taldcroft
left a comment
There was a problem hiding this comment.
With a strong cup of coffee I got through this today. Fantastic work @mhvk.
Aside from some code comments, the only bigger picture request is to put in some narrative docs showing this. For the most part things "just work" as you might expect, but interactions with the object attributes might need explaining in general. This can be modeled on the section for Time, or perhaps factor out into a common section for both?
| if self._data is None: | ||
| # No data: we still need to check that any non-scalar attributes | ||
| # have consistent shapes. | ||
| shapes = {fnm: value.shape for fnm, value in values.items() |
There was a problem hiding this comment.
It looks like this requires that any FrameAttribute in a frame has a shape attribute. I think that is OK, but then this needs to go in the base class. Or else just getattr(value, 'shape', None)?
There was a problem hiding this comment.
It only requires the attribute to have a shape if it has a size (which is larger than 1). I added to the comment above to clarify this.
There was a problem hiding this comment.
Ah right. The postfix conditional threw me off.
| "non-scalar attributes with inconsistent " | ||
| "shapes: {0}".format(shapes)) | ||
|
|
||
| for fnm in shapes: |
There was a problem hiding this comment.
Is this validation really doing something necessary? If so then I would expect it to be checking the shape and raising an exception (basically saying there is some code bug) if not matching. Otherwise I would think that unit testing would cover this.
There was a problem hiding this comment.
There are really two checks done: first, whether the shapes can be broadcast against each other; second, whether the attributes actually can be broadcast (this is true for all those that we define, but may not generally be the case). This is similar to the check done for the case that the frame does have data. (I'll again add a comment, since none of this is all that obvious...)
| @property | ||
| def shape(self): | ||
| return self.data.shape | ||
| return self.data.shape if self.has_data else self._no_data_shape |
There was a problem hiding this comment.
This is inconsistent with the behavior of size below for the no-data case. It's also a little odd because the shape for a no-data coordinate is () which is the same as the shape for a scalar coordinate. Conceptually I think that coord.shape should give an exception (like it now does) for no-data, though I imagine there is a real reason you made this change.
Having coord.shape give an exception for no-data would be nice because you might be able to eliminate the new self._no_data_shape private attribute.
There was a problem hiding this comment.
The reason I implemented it like this is that in the frame attribute I look up the frame shape, and I felt the attribute should not have to worry about whether that frame has data or not.
More generally, it also seemed there should be a way to tell what shape the output would have if you transformed, say, a single coordinate to a frame with multiple times (say, an AltAz frame). With that, it is also clearer what to expect when you reshape or ravel a data-less frame.
But I do agree it is slightly counterintuitive to get an AttributeError when you access size and not when you access shape. Clearly, this is something that I should note explicitly in the to-be-written documentation!
| @@ -120,25 +121,33 @@ def get_name(cls): | |||
| def _apply(self, method, *args, **kwargs): | |||
| """Create a new coordinate object with ``method`` applied to the arrays. | |||
There was a problem hiding this comment.
new representation object, right?
| *[getattr(getattr(self, component), method)(*args, **kwargs) | ||
| for component in self.components], copy=False) | ||
| if callable(method): | ||
| apply_method = functools.partial(method, *args, **kwargs) |
There was a problem hiding this comment.
It seems like this won't actually work if any *args are supplied because the later call of apply_method(array, ...) will put array after the *args according to the functools.partial docs.
In [11]: c.data._apply('reshape', (2,2))
Out[11]:
<UnitSphericalRepresentation (lon, lat) in deg
[[(1.0, 1.0), (2.0, 2.0)],
[(3.0, 3.0), (4.0, 4.0)]]>
In [12]: c.data._apply(np.reshape, (2,2))
...
TypeError: Only dimensionless scalar quantities can be converted to Python scalars
In [13]: c.data._apply(np.ravel)
Out[13]:
<UnitSphericalRepresentation (lon, lat) in deg
[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]>
There was a problem hiding this comment.
OOPS!! Clearly, bad luck that I wrote all my tests using keyword arguments only... I'll just define the function directly...
| assert np.all(s0_broadcast.lon == self.s0.lon) | ||
| assert np.all(s0_broadcast.lat == self.s0.lat) | ||
| assert np.all(s0_broadcast.distance == self.s0.distance) | ||
| assert np.may_share_memory(s0_broadcast.lon, self.s0.lon) |
There was a problem hiding this comment.
Maybe at least one test that the do share memory?
There was a problem hiding this comment.
Yes, good idea. Also noticed that I didn't have a default copy method in SharedLikeNDArray. That is now corrected as well.
| assert np.all(s0_take.lon == self.s0.lon.take((5, 2))) | ||
|
|
||
| def test_broadcast_to(self): | ||
| s0_broadcast = self.s0._apply(np_broadcast_to, shape=(3, 6, 7), |
There was a problem hiding this comment.
Test that the class is the same.
| self.s2.obstime.jd1) | ||
| # TODO: right now, we cannot compare CartesianRepresentation instances, | ||
| # so instead we compare the xyz quantities. This can be made a direct | ||
| # comparison once CartesianRepresentation.__eq__ is implemented. |
There was a problem hiding this comment.
Update comment per decision to not support __eq__.
| from ..extern.six.moves import range, zip_longest, zip | ||
|
|
||
| from ..utils import isiterable | ||
| from ..utils import isiterable, check_broadcast |
There was a problem hiding this comment.
Import IncompatibleShapeError as well for back-compatibility with previously available symbol in this module.
There was a problem hiding this comment.
In the original version, IncompatibleShapeError was not exported, so I think it is OK not to keep it here, no?
There was a problem hiding this comment.
I am not always sure about drawing the line in what is "public", but there could be code like from astropy.modeling.utils import IncompatibleShapeError out there, especially for something that uses check_broadcast and then wants to catch the error. Agreed that none of this is actually in the public API so I don't feel too strongly.
| new_format = self.format | ||
|
|
||
| if callable(method): | ||
| apply_method = functools.partial(method, *args, **kwargs) |
There was a problem hiding this comment.
Same question about whether this works if *args are supplied.
|
@taldcroft - I now also made some additions to the documentation; see in particular 4b21463 (which should be OK apart from the misspelled link that is corrected in the last commit). I think with this, it should be ready to go in... |
|
Thanks! Order doesn't really matter for this (I probably need to rebase either way...), so I'll go ahead here! |
Now that representations can change shape, etc. (#4123), it is time to move up to also allow this for frames
(andeventuallycoordinates~~)~~.For frames, one has to ensure that any frame attributes are reshaped if needed. To get this to work, before anything else, all attributes must have shapes that makes sense, i.e., do not carry extraneous information. Thus, this needs
obsgelocandobsgeovelnot to be quantities with the extraneous dimension for x,y,z, but rather cartesian representations. Hence, this builds on #5253(could be #5207 too; will rebase on master once either is merged).and now that that PR is merged, this is rebased on it.At present, I assume that any attribute will either be a "scalar" or that it has a shape that equals (or, in case of quantities, can be broadcast to) the shape of the coordinates. I.e., I don't broadcast the shape of the coordinates to those of an attribute (as some of the tests assumed would be possible). I think this makes it too complicated (and we don't do this in
Timeeither). The one exception I make is inEarthLocation.get_itrs, where it seems to make sense that a single position would be used with multiple times.Questions for now, especially for @taldcroft and @eteq, are if I do the checks in the initializer and in
_applysensibly.UPDATE: now also includes
SkyCoordUPDATE 2: with #5253 in, this is rebased and should thus be easier to review...