Skip to content

SkyCoord/Baseframe shape changes - #5254

Merged
mhvk merged 22 commits into
astropy:masterfrom
mhvk:baseframe-shape-changes
Oct 21, 2016
Merged

mhvk merged 22 commits into
astropy:masterfrom
mhvk:baseframe-shape-changes

Conversation

@mhvk

@mhvk mhvk commented Aug 22, 2016 •

Copy link
Copy Markdown
Contributor

Now that representations can change shape, etc. (#4123), it is time to move up to also allow this for frames ( and eventually coordinates~~)~~.

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 obsgeloc and obsgeovel not 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 Time either). The one exception I make is in EarthLocation.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 _apply sensibly.

UPDATE: now also includes SkyCoord

UPDATE 2: with #5253 in, this is rebased and should thus be easier to review...

Comment thread astropy/coordinates/baseframe.py Outdated
# 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

@StuartLittlefair StuartLittlefair Aug 22, 2016 •

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.

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.

@StuartLittlefair

Copy link
Copy Markdown
Contributor

@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 CIRS or AltAz. Something like the below works in the current 1.2 release:

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, :])).shape
ValueError: frame attribute should have no shape or shape=(3, 12), not (1, 12)

@mhvk

mhvk commented Aug 23, 2016

Copy link
Copy Markdown
Contributor Author

@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 obstime and those make a lot of sense (data gets broadcast in transformation, so the tests work).

@mhvk

mhvk commented Aug 23, 2016

Copy link
Copy Markdown
Contributor Author

On your example: this could be made to work if I allowed Time to be broadcast... That would mean moving the shape-checking to the frame attributes proper, which may not be so bad... I'll try.

@StuartLittlefair

Copy link
Copy Markdown
Contributor

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

@mhvk

mhvk commented Aug 23, 2016

Copy link
Copy Markdown
Contributor Author

@StuartLittlefair - OK, that was more work than I had really anticipated, but moving the shape-checking machinery to FrameAttribute was definitely the right thing to do.

In the process:

  • TimeFrameAttribute can now be broadcast, with a little modification to Time._apply
  • CartesianRepresentationFrameAttribute: idem
  • Data-less frames now have the shapes of the attributes checked against each other. They also have acquired a shape.

@taldcroft - I think my mucking with BaseCoordinateFrame is all OK, but it could definitely do with a bit of checking...

@mhvk
mhvk force-pushed the baseframe-shape-changes branch from 9c82e3c to 2d65574 Compare August 23, 2016 21:30
@mhvk mhvk changed the title Baseframe shape changes SkyCoord/Baseframe shape changes Aug 24, 2016
@mhvk
mhvk force-pushed the baseframe-shape-changes branch 2 times, most recently from bf54ac8 to 70983f6 Compare August 24, 2016 18:07
@mhvk

mhvk commented Aug 24, 2016

Copy link
Copy Markdown
Contributor Author

Rebased to get it mergeable again, and also added support for shape manipulation to SkyCoord itself, as it turned out to be very easy, as it has no internal attributes to worry about apart from the base frame.

mhvk added 2 commits August 31, 2016 16:01
Since CartesianRepresentation.__eq__ is not yet implemented.
@mhvk
mhvk force-pushed the baseframe-shape-changes branch from 59d9f7e to f2e0af9 Compare August 31, 2016 21:11
@mhvk

mhvk commented Aug 31, 2016

Copy link
Copy Markdown
Contributor Author

Another rebase now #5253 is in, which should make this PR easier to review. Note that in process of doing this, I touched Time to make it broadcastable, and moved a piece from modeling.utils to utils.misc as it was generally useful. Hence, cc @taldcroft, @nden.

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.

@mhvk

mhvk commented Oct 3, 2016

Copy link
Copy Markdown
Contributor Author

I realise this is quite a large PR, but it also brings nice functionality. A review would be good...

@taldcroft

Copy link
Copy Markdown
Member

@mhvk - I'll try to have a look this week.

@mhvk mhvk mentioned this pull request Oct 13, 2016
@mhvk

mhvk commented Oct 18, 2016

Copy link
Copy Markdown
Contributor Author

@taldcroft - ping...

@taldcroft taldcroft left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right. The postfix conditional threw me off.

"non-scalar attributes with inconsistent "
"shapes: {0}".format(shapes))

for fnm in shapes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread astropy/coordinates/representation.py Outdated
@@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new representation object, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes...

Comment thread astropy/coordinates/representation.py Outdated
*[getattr(getattr(self, component), method)(*args, **kwargs)
for component in self.components], copy=False)
if callable(method):
apply_method = functools.partial(method, *args, **kwargs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)]>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe at least one test that the do share memory?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test that the class is the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good idea. Done.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update comment per decision to not support __eq__.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread astropy/modeling/utils.py
from ..extern.six.moves import range, zip_longest, zip

from ..utils import isiterable
from ..utils import isiterable, check_broadcast

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import IncompatibleShapeError as well for back-compatibility with previously available symbol in this module.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the original version, IncompatibleShapeError was not exported, so I think it is OK not to keep it here, no?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread astropy/time/core.py Outdated
new_format = self.format

if callable(method):
apply_method = functools.partial(method, *args, **kwargs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question about whether this works if *args are supplied.

@taldcroft taldcroft self-assigned this Oct 18, 2016
@mhvk

mhvk commented Oct 20, 2016

Copy link
Copy Markdown
Contributor Author

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

@taldcroft

Copy link
Copy Markdown
Member

OK 👍 for merge. @mhvk - I'll let you press the button in case you want to merge #5301 first (if that matters).

@mhvk

mhvk commented Oct 21, 2016

Copy link
Copy Markdown
Contributor Author

Thanks! Order doesn't really matter for this (I probably need to rebase either way...), so I'll go ahead here!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Affects-dev PRs and issues that do not impact an existing Astropy release coordinates Enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants