Skip to content

Add isclose() method to Time and TimeDelta classes - #10646

Merged
taldcroft merged 9 commits into
astropy:masterfrom
taldcroft:time-isclose
Oct 6, 2020
Merged

taldcroft merged 9 commits into
astropy:masterfrom
taldcroft:time-isclose

Conversation

@taldcroft

Copy link
Copy Markdown
Member

Description

This pull request is to add an isclose() method to the Time and TimeDelta classes to make it easier to compare times to within a specified tolerance. The default tolerance is basically 1-bit in the mantissa of a float64 (np.finfo(float).eps = 2.2e-16 days or about 20 picosec), but the atol argument can be provided to be less strict.

This partially addresses #8742.

@taldcroft taldcroft added the time label Aug 13, 2020
@taldcroft taldcroft added this to the v4.2 milestone Aug 13, 2020
@pep8speaks

pep8speaks commented Aug 13, 2020 •

Copy link
Copy Markdown

Hello @taldcroft 👋! It looks like you've made some changes in your pull request, so I've checked the code again for style.

There are no PEP8 style issues with this pull request - thanks! 🎉

Comment last updated at 2020-10-05 12:50:18 UTC

@pllim

pllim commented Aug 13, 2020

Copy link
Copy Markdown
Member

Hmm, I guess rtol doesn't make sense in this context. Does this needs a "what's new" entry?

@mhvk mhvk left a comment

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.

Overall, looks good, but some comments. Most annoying is to uncover a bug... Though perhaps it is good that it is so rare for Time these days!

Comment thread astropy/time/core.py Outdated
raise TypeError("'atol' argument must be a Quantity or TimeDelta instance, got "
f'{atol.__class__.__name__} instead')

# Note: np.abs(self - other) results in an object array of TimeDelta

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.

There should be no need to do that - TimeDelta should be smart enough! (And I think it is - if not, that's a bug...)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I tried the original version first and it revealed the bug. I'll open an issue, but in the meantime the workaround code is needed.

In [2]: dt = TimeDelta([-1, 1]*u.s)                                                                                                                           

In [3]: np.abs(dt)                                                                                                                                            
Out[3]: 
array([<TimeDelta object: scale='None' format='jd' value=1.1574074074074073e-05>,
       <TimeDelta object: scale='None' format='jd' value=1.1574074074074073e-05>],
      dtype=object)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

See #10653

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.

As noted there, plain abs(...) should work.

Comment thread astropy/time/core.py Outdated
Default is one bit in the 128-bit JD time representation, equivalent
to about 20 picosecs.
"""
if not isinstance(other, Time):

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.

I'm wondering whether the checks are necessary, since ideally the comparison just breaks. Indeed,

In [20]: TimeDelta(1*u.day) < Time.now()                                                              
TypeError: '<' not supported between instances of 'TimeDelta' and 'Time'

But very strangely,

In [21]: TimeDelta(1*u.day, scale='tai') < Time('J2000')                                                                            
Out[21]: True

It looks like it has something to do with the specific scale picked - I'll investigate.

Ignoring that, would it be an idea to remove the checks? It might allow Time and TimeDelta can share the implementation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I favor a more verbose implementation that does input validation up front and gives a clear message about what is wrong. With your version the user is likely to not immediately know what the problem is. IMHO we need more explicit validation in astropy.

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.

I guess it is the usual "look before you leap" vs "easier to ask for forgiveness than permission" - https://docs.python.org/3/glossary.html#term-eafp

Generally, python seems to prefer the latter, and I think it is not illogical here either. E.g., suppose someone writes a different MyTime class (say, one that is integer + fraction in seconds), which they try to ensure can interact with Time, e.g., by defining subtraction with astropy's Time (which they can make work, because python will give their __rsub__ a chance after our __sub__ fails). If this also returns something that can have its absolute value taken and be compared with a quantity or TimeDelta (as they might well, since those are methods we've had for a long time), then the actual isclose() code will "just work". But in the current implementation, the isinstance check makes it impossible to ever work. (This is similar to numpy having np.asarray sprinkled throughout their code, making it not work with Quantity, and indeed np.abs sort-of working rather than just plain failing).

p.s. In principle, in EAFP, one does try/except and gives perhaps more informative error messages, but I've slowly moved to a state where I think one should just count on the user looking at the docstring when an error is raised. And in this case, one would generally get a quite informative TypeError from the subtraction anyway.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I see your point and it is valid. OTOH, it happens frequently (based on real astropy issues I've seen) that users get confused because of an obscure error message raised in an unrelated bit of code when they pass a bad input. So there is a balance between addressing a problem that it almost sure to occur (bad inputs) vs. a new MyTime class that is less likely.

It's also worth noting that a major theme of recent Python development is type annotation. I've actually started paying some attention to this since there must be a reason the core devs are spending so much time on it. From this perspective we would annotate the input with something. Agreed that I'm not sure how this really works with duck-typing, which is certainly an incredibly useful concept.

I guess the middle ground is putting the main bit of the function in a try / except TypeError and re-raising with a better exception.

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.

Agreed that the MyTime class is not very likely, and I should add that this is not a big deal! But if one does it without anything, the type of exception one gets is already quite clear:

In [11]: class TTime(Time): 
    ...:     def isclose(self, other, atol=1*u.ns): 
    ...:         return abs(self - other)<atol 
    ...:               
    ...:                                                                                               

In [12]: t = TTime.now()                                                                               

In [13]: dt = TimeDelta(100, format='jd')                                                              

In [14]: t.isclose('J2000')                                                                            
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-14-15f66b730fe3> in <module>
----> 1 t.isclose('J2000')

<ipython-input-11-b8b243443d6c> in isclose(self, other, atol)
      1 class TTime(Time):
      2     def isclose(self, other, atol=1*u.ns):
----> 3         return abs(self - other)<atol
      4 
      5 

TypeError: unsupported operand type(s) for -: 'TTime' and 'str'

In [15]: t.isclose(dt)                                                                                 
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-15-f21ef40edf4f> in <module>
----> 1 t.isclose(dt)

<ipython-input-11-b8b243443d6c> in isclose(self, other, atol)
      1 class TTime(Time):
      2     def isclose(self, other, atol=1*u.ns):
----> 3         return abs(self - other)<atol
      4 
      5 

TypeError: bad operand type for abs(): 'TTime'

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.

p.s. The annotations are indeed intriguing. I think currently they are only to help writing correct code, not execution, but I think it is a good way to make the intent clearer. On annotations, our very own quantity_input decorator is an exception in that it actually uses annotations at runtime. Actually, that is seeing some nice discussion over at #10156 with a draft new implementation at #10655 - the latter might even get to your long-standing suggestion of having a way to ask for Quantity input that gives the function plain arrays in the right units! Would be good to get your input!

Comment thread astropy/time/core.py Resolved
Comment thread astropy/time/core.py Resolved
@maxnoe

maxnoe commented Oct 3, 2020

Copy link
Copy Markdown
Member

Is isclose a ufunc? If yes, does it make sense to create a WIP branch adding all the ufuncs we need that different people can contribute?

I now see at least three issues / PRs that started implememting Time.__array_ufunc__, maybe we should bundle the effort, work in a separste branch until all ufincs we want are implemented and then make a PR from that branch.

I usually don't like longer lived dev branches, but in this case it might be appropriate.

@mhvk

mhvk commented Oct 3, 2020

Copy link
Copy Markdown
Contributor

👍 to a separate branch to work on ufunc support! You may want to take a look at quantity_helpers to see how it was done for units (since eventually we'll want to support functions like isclose too...).


In [3]: type(np.isclose)
Out[3]: function

In [4]: type(np.isnat)
Out[4]: numpy.ufunc

@taldcroft

Copy link
Copy Markdown
Member Author

since eventually we'll want to support functions like isclose too...

I still don't see that np.isclose should be supported.

For Time, the np.isclose arg rtol literally makes no sense, and equal_nan at best requires a stretch to interpret "NaN" as missing values. Furthermore the default for atol=1e-8 is not appropriate since it is a float and we require a Quantity or TimeDelta.

@maxnoe

maxnoe commented Oct 5, 2020 •

Copy link
Copy Markdown
Member

For Time, the np.isclose arg rtol literally makes no sense

For calendar dates (UT1, UTC, ...) I would agree. For timescales base on linear time offsets from an epoch, I think rtol would be well-defined. (Unix, TT, TAI, ...)

@taldcroft

Copy link
Copy Markdown
Member Author

Sure, for unix time there is technically a way to define rtol. But in practice this would become quite dodgy since those relative values don't have units that are programmatically specified. In other words the TimeUnix class does not know that the unix representation is in u.s.

Perhaps more importantly, I cannot see any practical use for rtol even with unix time. Do you have a use case?

@maxnoe

maxnoe commented Oct 5, 2020 •

Copy link
Copy Markdown
Member

@taldcroft no, not really. Maybe unit testing of arithmetic with Time / TimeDelta?

@taldcroft

Copy link
Copy Markdown
Member Author

@mhvk - I think I have addressed all the points, including a compromise on duck-typing the input. The code that relies on other is now in a try/except with a reasonable error message and some care taken to make it easier for the user to see what went wrong.

@taldcroft

Copy link
Copy Markdown
Member Author

Maybe unit testing of arithmetic with Time / TimeDelta?

That testing is already solid. I think that using rtol would only degrade the testing by introducing additional multiplies done at 64-bit resolution (vs. 128 everywhere else).

@maxnoe

maxnoe commented Oct 5, 2020

Copy link
Copy Markdown
Member

That testing is already solid. I think that using rtol would only degrade the testing by introducing additional multiplies done at 64-bit resolution (vs. 128 everywhere else).

I did not mean unit testing inside astropy but of packages using astropy, but you are probably right that there is no real use case here.

@mhvk

mhvk commented Oct 5, 2020 •

Copy link
Copy Markdown
Contributor

@maxnoe - I'm a bit more with you on this, that np.isclose(t1, t2) might as well do something sensible (i.e., with rtol=0 assumed). But where it makes most sense is for TimeDelta: np.isclose(dt1, dt2, atol=...*u.ns, rtol=1e-9) is reasonable and would have the benefit that it doesn't matter if dt1/2 are quantities and/or TimeDelta.

Anyway, the method here is good and discoverable and definitely seems the best way to go for Time itself (@taldcroft - I'll review later today).

@mhvk mhvk left a comment

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.

Looks very nice. Two nitpicks, but OK to ignore, so approving.

Comment thread astropy/time/core.py Resolved
Comment thread astropy/time/core.py Resolved
@taldcroft
taldcroft merged commit ad7b6c9 into astropy:master Oct 6, 2020
@taldcroft
taldcroft deleted the time-isclose branch October 6, 2020 09:47
@taldcroft

Copy link
Copy Markdown
Member Author

@mhvk - thanks for the review. I thought about your comments for a bit and in the end I think for atol it is reasonable to be strict about the type. I think it would be unusual for a program using astropy to find it inconvenient to specify atol as a quantity.

In general I still lean toward guaranteeing correctness with tests that run on the advertised API data types, rather than hoping for correctness based only on not generating an exception. Understood this is a big fuzzy line that we'll continually be straddling.

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.

5 participants