Skip to content

Add AffineTransform transform class #6218

Merged
eteq merged 46 commits intoastropy:masterfrom
adrn:coordinates/affine-transforms
Jun 20, 2017
Merged

Add AffineTransform transform class #6218
eteq merged 46 commits intoastropy:masterfrom
adrn:coordinates/affine-transforms

Conversation

@adrn
Copy link
Member

@adrn adrn commented Jun 15, 2017

This is the 2nd of several PR's that add support for velocities in astropy.coordinates.

The main feature of this PR is to add a new CoordinateTransform class "AffineTransform" for handling rotations plus translations.

Next in the PR chain is #6219

@adrn adrn added this to the v2.0.0 milestone Jun 15, 2017
@adrn adrn requested review from eteq and mhvk June 15, 2017 17:21
@mhvk
Copy link
Contributor

mhvk commented Jun 15, 2017

Small comments are in your own PR, but here some larger ones:

  • it might be good for to_cartesian to also transform the differentials, if they are present.
  • The translation vectiors should probably be CartesianRepresentation; indeed, I'd argue for a CartesianRepresentation with an associated differential (which means one for the case of no positional offset, but a velocity offset, one has to use pos=0,0,0 -- does this happen much?)
  • Instead of _apply_transform, how about expanding expand CartesianRepresentation.transform to take an optional offset (which can carry a differential).

@adrn
Copy link
Member Author

adrn commented Jun 16, 2017

  1. Yes, @eteq noticed that and added an option to do this as well - see the new commits I pushed from one of his branches in Allow representations to contain differentials #6169
  2. Ah, yes, I like that idea!
  3. OK, I'll look in to implementing that.

@adrn adrn force-pushed the coordinates/affine-transforms branch from db13a87 to 3abf575 Compare June 16, 2017 01:55
@adrn
Copy link
Member Author

adrn commented Jun 16, 2017

@mhvk I think I took care of your first two points. On the third, I'm not totally sold on adding the offset functionality to CartesianRepresentation, but am happy to discuss more. Already implementing the other changes I think the code has become a bit cleaner.

@mhvk
Copy link
Contributor

mhvk commented Jun 16, 2017

@adrn - up late, like I last night... On .transform -- I guess it is obvious that it is useful generally for those who want to use representations as vectors, but thinking again about that case I now see it is of course already quite easy to do: what would be r2 = r1.transform(M, t) is just as short and arguably more comprehensible as r2 = r1.transform(M) + t. OK, so, drop that suggestion and just use the latter in the code!

@StuartLittlefair
Copy link
Contributor

Love this PR. Nice work @adrn et al!

@adrn adrn force-pushed the coordinates/affine-transforms branch from 4de3f11 to df5081f Compare June 18, 2017 14:17
@adrn
Copy link
Member Author

adrn commented Jun 18, 2017

I've done the rebase already, just fixing a few lingering issues now.

@mhvk
Copy link
Contributor

mhvk commented Jun 18, 2017

Just so comments don't get lost on a sub-PR, on to_cartesian_with_differentials() - my preference is doing this in baseframe

"""
Am still not too keen to add this to representation, as I don't like the duplication of to_cartesian and find the name rather long (but don't have a better suggestion).

How about using it for .cartesian on a frame, though? For that case, I feel there is no doubt one wants to transform the derivatives as well, and we know that we only have first-order to worry about. I guess that means adapting represent_as. We can always decide to move it down to representation later.
"""

@adrn
Copy link
Member Author

adrn commented Jun 18, 2017

I'd be fine with that - that is, just make frame.cartesian do what is in to_cartesian_with_differentials. @eteq would that satisfy your needs?

@adrn adrn force-pushed the coordinates/affine-transforms branch from 5a93662 to 4888e93 Compare June 18, 2017 19:11
@adrn
Copy link
Member Author

adrn commented Jun 18, 2017

OK, now this diff should be much cleaner. Closing the sub-PR on my fork. Please leave any future comments here.

Copy link
Contributor

@mhvk mhvk left a comment

Choose a reason for hiding this comment

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

Mostly small & nitpicks, but also one possible buglet.

Most relevant would be a decision on frame.cartesian as the way to get to_cartesian_with_differentials().

bary_sun_vel = None

if bary_sun_vel is not None:
bary_sun_pos = bary_sun_pos.with_differentials(bary_sun_vel)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not do this above, inside the if statement? Then no need to set bary_sun_vel = None either.
(perhaps get_body_barycentric_posvel should return the combination... for another time...)

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

rotation = rotation_matrix(-np.arcsin(z_d), 'y')
representation = (intrep - translation).transform(rotation)
translation = CartesianRepresentation(gcf.galcen_distance * [1., 0., 0.])
z_d = (gcf.z_sun / gcf.galcen_distance)
Copy link
Contributor

Choose a reason for hiding this comment

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

nitpick: why the parentheses?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure - removed!

bary_sun_pos = get_body_barycentric('sun', hcrs_frame.obstime)
bary_sun_vel = None

bary_sun_pos = -bary_sun_pos
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd again do this in the if statement, but now of course for the else clause you need a minus sign in front of get_body_barycentric.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

# Note: the above docstring gets overridden for differentials.
raise NotImplementedError()

def to_cartesian_with_differentials(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Hopefully, it does make sense in BaseFrame.cartesian -- the only thing I don't like is this PR!


assert diff.get_name() == 'cartesian'
assert not diff.differentials
cart = sr.to_cartesian_with_differentials()
Copy link
Contributor

@mhvk mhvk Jun 18, 2017

Choose a reason for hiding this comment

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

This test would need to move to test_baseframe.

" supported (differentials: {0})."
.format(str(rep.differentials)))

if isinstance(fromcoord.data, UnitSphericalRepresentation):
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we sure that this can only happen without offset? Or would it make sense to test that?
(I guess since any offset would be guaranteed to have units, while a unitspherical representation does not, this would already error, so probably no action needed)

if isinstance(fromcoord.data, UnitSphericalRepresentation):
# need to special-case this because otherwise the new class will
# think it has a valid distance
diff_class = dict([(k, diff.__class__)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm confused by the differentials needing to be transformed if the class stays the same -- I think this should it have been an iteration over fromcoord.data.differentials.items(), no?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, fixed

.format(str(rep.differentials)))

if isinstance(fromcoord.data, UnitSphericalRepresentation):
# need to special-case this because otherwise the new class will
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm actually confused - why would there be confusion?
More generally, should we always transform back to whatever the input representation and differential were? Or is it sometimes not wanted? ... looking further down, I guess the idea is that there's no need to convert to, say, Spherical, until someone actually wants something with the coordinates. So, this is just so one doesn't have to check the unit on the cartesian representation to know whether it is unitspherical. I almost wonder if we should have a little "marker" on cartesian... Anyway, I think I understand it now, so if the above is indeed correct, no action needed (except if you think you can clarify the comment further).


A transformation that includes a linear matrix operation and a translation
(vector offset). These transformations are defined by a 3x3 matrix and a
3-vector for the offset. The transformation is applied to the Cartesian
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add "3-vector for the offset (supplied as a cartesian representation)" -- not very important, though.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

transforming the Cartesian representation of one frame into the
target frame's Cartesian representation. The static version is for
The matrix transforms are `~astropy.coordinates.AffineTransform`'s without
a translation, e.g., a rotation. The static version is for
Copy link
Contributor

Choose a reason for hiding this comment

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

e.g. -> i.e.

@mhvk
Copy link
Contributor

mhvk commented Jun 18, 2017

Very nice! I'm toast, though, and have to get up early, so will leave it at this for tonight.

@mhvk
Copy link
Contributor

mhvk commented Jun 18, 2017

p.s. Just to be clear, my only real request for change is to go with frame.cartesian for the "with differentials" route -- which at least in the context of this PR seems very logical. With that, this PR can be merged as far as I'm concerned.

@eteq
Copy link
Member

eteq commented Jun 18, 2017

After looking back at #6226, esentially all of my use cases were someframe.data.to_carteisan(includediff=True). So I think @mhvk is right that someframe.cartesian ends up much cleaner!

@adrn
Copy link
Member Author

adrn commented Jun 19, 2017

@mhvk @eteq In implementing BaseCoordinateFrame.cartesian, I found two bugs that I don't know how to resolve, both related to having a UnitSphericalRepresentation base.

  • The first is that I don't know how to handle calling .cartesian on a frame with a UnitSphericalRepresentation & SphericalDifferential (a fairly common situation: sky position, but proper motions and radial velocity for a target). Converting the SphericalDifferential
    to CartesianDifferential fails because of the UnitSphericalRepresentation base. But, we need to use .cartesian in BaseAffineTransform._apply_transform(), even if only a matrix transform gets applied.
  • The second bug I found noticed is when a RadialDifferential is attached to a UnitSphericalRepresentation, the auto-determination of the key for the differentials dict fails. If you look at how it works now, it pairs up a repr component name with a diff component name and divides. But, in this case there are no pairs (UnitSphericalRepresentation has lon and lat, RadialDifferential has d_distance).

@mhvk
Copy link
Contributor

mhvk commented Jun 19, 2017

@adrn - on your points:

  1. Coordinates + PM + RV: here, I think the failure is real in case of a proper AffineTransform, since you need the distance to see how PM and RV change into each other. I think at least for now this should raise an error, which the error message stating that the only way to solve this is to put an (estimated) distance in the coordinate (and that for large distances this is not going to change most transformations within the solar system). For MatrixTransform, there should not be a real problem, so perhaps the MatrixtTransform has to explicitly not exclude any radial velocity component (i.e., transform SphericalDifferential to a combination of UnitSphericalDifferential and RadialDiffferential).
  2. For RV + UnitSphericalRepresentation: I think in this case expected_key returns None, correct? I think for this case we just skip the checking (for now) and trust the key the user puts in -- hence, your baseframe code has to ensure it puts in the right key...

n_diffs = len(self.data.differentials)
has_diffs = n_diffs > 0

if isinstance(new_representation, six.string_types):
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think you want the if statement. _get_repr_cls will work for classes and strings

Copy link
Member Author

Choose a reason for hiding this comment

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

Oops, yes that's right.

'represent_as() only accepts a new '
'representation class.')

if isinstance(new_differential, six.string_types):
Copy link
Contributor

Choose a reason for hiding this comment

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

As above, if statement not needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

Here it is needed because new_differential might be a dict. That is, when we pass this to BaseRepresentation.represent_as, it has to be either a class or a dict

@eteq
Copy link
Member

eteq commented Jun 19, 2017

  1. UnitSpherical + SphericalDifferential: This is a critical one. For now maybe just special-case it as we do with a lot of the UnitSpherical stuff? I think it's absolutely necessary we support this, but should think of UnitSpherical as having "infinite" distance, where applicable. This is arguable the most common case (and in most implementations, the easiest because its only rotations), and most users will find it very strange if they can't do that. I'd also say that the general behavior should be that AffineTransform only applies the matrix part and not the (spatial) offset for the UnitsSpherical case.
  2. UnitSpherical + RadialDifferential: I'd say special-case this also, as a specialer case of the above, thinking of it as having pm=0 . Thats' what's implicitly meant by this construction, I think. (I think that's also what @mhvk was suggeting?)

@adrn
Copy link
Member Author

adrn commented Jun 19, 2017

Cool and agreed, working on this logic now.

@mhvk
Copy link
Contributor

mhvk commented Jun 19, 2017

Yes, indeed, taking UnitSpherical as being at infinite distance is also a reasonable choice for transformations - though it is perhaps more special-casing than is immediately obvious. E.g., in a naive implementation, taking .cartesian on a UnitSphericalRepresentation with proper motion you'd get infinite velocities. This is in part why I suggested asking users to put in an estimated distance -- typically this would be "infinite enough" (but has the advantage that you actually get to see whether that assumption is correct).

But perhaps the answer is to have a configuration parameter that would allow unitspherical=inf.distance to happen. I think that, by default, this should be False (I don't like warnings for things like this), and at least my recommendation would be for this to be a ScienceState one, so that it can be used as a context manager (which would warn anybody reading code that something funny may happen).

Obviously, I may be too paranoid, but ideally we don't help people make silly mistakes -- while ignoring distances for observation planning is probably fine, anything to go with, say, galactic motion is not.

@adrn adrn force-pushed the coordinates/affine-transforms branch from 41d33d6 to f39e2f1 Compare June 19, 2017 20:08
Copy link
Member

@eteq eteq left a comment

Choose a reason for hiding this comment

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

Looking basically there! All minor doc (or PEP8-y) comments except the one about the RadialDifferential

particular to this frame

new_differential : subclass of `~astropy.coordinates.BaseDifferential`, str, optional
Class in which the differential should be represented. May be
Copy link
Member

Choose a reason for hiding this comment

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

"Must be" rather than "may be"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oops – copy-pasted from above, will fix both.

@property
def sphericalcoslat(self):
"""
Shorthand for a spherical representation of the coordinates in this object.
Copy link
Member

Choose a reason for hiding this comment

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

(80 char)

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy-pasted from spherical, fixed that as well.

Also fixed a bug I found in sphericalcoslat and will add a test in the next PR, but I realized it won't actually work in this PR because of some code that needs to be in the frame initializer...

class BaseAffineTransform(CoordinateTransform):
"""
Base class for common functionality between the ``AffineTransform``-type
subclasses.
Copy link
Member

Choose a reason for hiding this comment

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

Maybe add some clarification here for why there needs to be a base? (As opposed to just having AffineTransform) I can imagine someone wanting to subclass being confused which to subclass from...

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

Parameters
----------
transform_func : callable
A callable that has the signature ``transform_func(fromcoord, toframe)``
Copy link
Member

Choose a reason for hiding this comment

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

(80 char)

Copy link
Member Author

Choose a reason for hiding this comment

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

My editor says this is 80 chars

Copy link
Member

Choose a reason for hiding this comment

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

Oh, weird. I could have sworn this was 81, but I guess not...

and returns a length-2 tuple that contains: [0] a (3, 3) matrix that
converts ``fromcoord`` in a cartesian representation to the new
coordinate system, and [1] a (N, 3) set of vectors that contain
offsets for differentials (or None).
Copy link
Member

Choose a reason for hiding this comment

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

Maybe you could add a notes section with a toy example of this functions call/return values (implementation itself not needed although it might be clearer)? I understand it from thinking about it for a while but just reading this description its hard to understand that you mean basically M, (origin, velocity_offset) =transform_func(fromcoord, toframe) (but with an option for more differentials)

Copy link
Member Author

Choose a reason for hiding this comment

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

Oops - this docstring was old. I updated and tried to clarify.

# having a RadialDifferential
if has_velocity and rad_vel_diff:
newrep = newrep.represent_as(fromcoord.data.__class__)
newrep = newrep.with_differentials({'s': fromcoord.data.differentials['s']})
Copy link
Member

Choose a reason for hiding this comment

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

This is confusing to me. If I rotate a RadialDifferential it shouldn't be the same differential again, right? In the extreme case of a 90 degree rotation it should be 0?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not a spherical rotation with the same origin. Transforming from ICRS to Galactic coordinates doesn't change a radial velocity, right?

Copy link
Member

Choose a reason for hiding this comment

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

I take it back! I was thinking about the wrong kind of "radial" here. This is fine, as-you-were

@adrn
Copy link
Member Author

adrn commented Jun 20, 2017

Tests passing locally so I canceled the CI runs. Tests from previous commit were all clear, except stalled remote-data tests.

@eteq
Copy link
Member

eteq commented Jun 20, 2017

LGTM now, @adrn - thanks!

@adrn
Copy link
Member Author

adrn commented Jun 20, 2017

Thanks for the clarification!

@eteq
Copy link
Member

eteq commented Jun 20, 2017

OK, while @mhvk is still "requesting changes", I'm pretty sure that's a hold-over and not intended based on the comment that it looks good pending the one change, which @adrn did indeed make. So I'm going to merge this to make rebasing others easier, but @mhvk, if you had a concern here that I missed, we can still entertain that between now and release!

@eteq eteq merged commit 0935e75 into astropy:master Jun 20, 2017
@eteq
Copy link
Member

eteq commented Jun 20, 2017

Thanks @adrn for all the work on this, and @mhvk and @StuartLittlefair for the reviews! It's really good to see this many eyes on this stuff.

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.

4 participants