Repository: lbl-anp/becquerel

Issue:
IsotopeQuantity has a kwarg `date` that cannot be a `datetime.date` object
The kwarg `date` in IsotopeQuantity (and its docstring) suggest that a `datetime.date` object should work, but it won't:
(note for posterity that the actual calibration date of this source is Apr 1, 2019)
```
>>> bq.IsotopeQuantity('Cs-137', uci=189.6, date=datetime.date(2020, 11, 5))

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-90-1eb84c4f3676> in <module>
----> 1 bq.IsotopeQuantity('Cs-137', uci=189.6, date=datetime.date(2020, 11, 5))

~/becquerel/becquerel/tools/isotope_qty.py in __init__(self, isotope, date, stability, **kwargs)
     99 
    100         self._init_isotope(isotope, stability)
--> 101         self._init_date(date)
    102         self._ref_quantities = self._quantities_from_kwargs(**kwargs)
    103 

~/becquerel/becquerel/tools/isotope_qty.py in _init_date(self, date)
    129 
    130         self.ref_date = utils.handle_datetime(
--> 131             date, error_name='IsotopeQuantity date', allow_none=True)
    132         if self.ref_date is None:
    133             # assume a long-lived source in the current epoch

~/becquerel/becquerel/core/utils.py in handle_datetime(input_time, error_name, allow_none)
    130     else:
    131         raise TypeError(
--> 132             'Unknown type for {}: {}'.format(error_name, input_time))
    133 
    134 

TypeError: Unknown type for IsotopeQuantity date: 2020-11-05
```

Instead it requires a`datetime.datetime` object, i.e., an additional time specification. Can we just set the time to 12 noon like I think most of these sources do, and maybe throw a warning when doing so?

Discussion:

Patch:
diff --git a/becquerel/core/utils.py b/becquerel/core/utils.py
index 94395b2..e175407 100644
--- a/becquerel/core/utils.py
+++ b/becquerel/core/utils.py
@@ -5,6 +5,7 @@ import sys
 import datetime
 from dateutil.parser import parse as dateutil_parse
 from uncertainties import UFloat, unumpy
+import warnings
 import numpy as np
 try:
     # Python 3.x
@@ -105,7 +106,7 @@ def handle_uncs(x_array, x_uncs, default_unc_func):
 
 
 def handle_datetime(input_time, error_name='datetime arg', allow_none=False):
-    """Parse an argument as a datetime, date+time string, or None.
+    """Parse an argument as a date, datetime, date+time string, or None.
 
     Args:
       input_time: the input argument to be converted to a datetime
@@ -115,7 +116,7 @@ def handle_datetime(input_time, error_name='datetime arg', allow_none=False):
         (default: False)
 
     Raises:
-      TypeError: if input_time is not a string, datetime, or None
+      TypeError: if input_time is not a string, datetime, date, or None
 
     Returns:
       a datetime.datetime, or None
@@ -123,6 +124,13 @@ def handle_datetime(input_time, error_name='datetime arg', allow_none=False):
 
     if isinstance(input_time, datetime.datetime):
         return input_time
+    elif isinstance(input_time, datetime.date):
+        warnings.warn(
+            'datetime.date passed in with no time; defaulting to 0:00 on date'
+        )
+        return datetime.datetime(
+            input_time.year, input_time.month, input_time.day
+        )
     elif isstring(input_time):
         return dateutil_parse(input_time)
     elif input_time is None and allow_none:
diff --git a/becquerel/tools/isotope_qty.py b/becquerel/tools/isotope_qty.py
index 24d55ff..8382a69 100644
--- a/becquerel/tools/isotope_qty.py
+++ b/becquerel/tools/isotope_qty.py
@@ -4,6 +4,7 @@ from __future__ import print_function
 import datetime
 import copy
 import numpy as np
+import warnings
 from .isotope import Isotope
 from ..core import utils
 from collections import OrderedDict
@@ -263,11 +264,11 @@ class IsotopeQuantity(object):
     #   *_at()
     # ----------------------------
 
-    def quantity_at(self, quantity, date):
+    def quantity_at(self, quantity, date=None):
         """Return a quantity at a given time.
 
         Args:
-          date: the date to calculate for
+          date: the date to calculate for (default now)
 
         Returns:
           a float of the number of atoms at date
@@ -276,16 +277,16 @@ class IsotopeQuantity(object):
           TypeError: if date is not recognized
         """
 
+        date = date if date is not None else datetime.datetime.now()
         t1 = utils.handle_datetime(date)
         dt = (t1 - self.ref_date).total_seconds()
         return self._ref_quantities[quantity] * 2**(-dt / self.half_life)
 
-
-    def atoms_at(self, date):
+    def atoms_at(self, date=None):
         """Calculate the number of atoms at a given time.
 
         Args:
-          date: the date to calculate for
+          date: the date to calculate for (default now)
 
         Returns:
           a float of the number of atoms at date
@@ -296,7 +297,7 @@ class IsotopeQuantity(object):
 
         return self.quantity_at("atoms", date)
 
-    def bq_at(self, date):
+    def bq_at(self, date=None):
         """Calculate the activity [Bq] at a given time.
 
         As atoms_at() except for return value.
@@ -304,7 +305,7 @@ class IsotopeQuantity(object):
 
         return self.quantity_at("bq", date)
 
-    def uci_at(self, date):
+    def uci_at(self, date=None):
         """Calculate the activity [uCi] at a given time.
 
         As atoms_at() except for return value.
@@ -312,7 +313,7 @@ class IsotopeQuantity(object):
 
         return self.quantity_at("uci", date)
 
-    def g_at(self, date):
+    def g_at(self, date=None):
         """Calculate the mass [g] at a given time.
 
         As atoms_at() except for return value.
@@ -331,7 +332,12 @@ class IsotopeQuantity(object):
           a float of the number of atoms at datetime.datetime.now()
         """
 
-        return self.quantity_at("atoms", datetime.datetime.now())
+        warnings.warn(
+            'atoms_now() is deprecated and will be removed in a future release'
+            '. Use atoms_at(date=None) instead.',
+            DeprecationWarning
+        )
+        return self.quantity_at("atoms", date=None)
 
     def bq_now(self):
         """Calculate the activity [Bq] now.
@@ -339,7 +345,12 @@ class IsotopeQuantity(object):
         As atoms_now() except for return value.
         """
 
-        return self.quantity_at("bq", datetime.datetime.now())
+        warnings.warn(
+            'bq_now() is deprecated and will be removed in a future release'
+            '. Use bq_at(date=None) instead.',
+            DeprecationWarning
+        )
+        return self.quantity_at("bq", date=None)
 
     def uci_now(self):
         """Calculate the activity [uCi] now.
@@ -347,7 +358,12 @@ class IsotopeQuantity(object):
         As atoms_now() except for return value.
         """
 
-        return self.quantity_at("uci", datetime.datetime.now())
+        warnings.warn(
+            'uci_now() is deprecated and will be removed in a future release'
+            '. Use uci_at(date=None) instead.',
+            DeprecationWarning
+        )
+        return self.quantity_at("uci", date=None)
 
     def g_now(self):
         """Calculate the mass [g] now.
@@ -355,7 +371,12 @@ class IsotopeQuantity(object):
         As atoms_now() except for return value.
         """
 
-        return self.quantity_at("g", datetime.datetime.now())
+        warnings.warn(
+            'g_now() is deprecated and will be removed in a future release'
+            '. Use g_at(date=None) instead.',
+            DeprecationWarning
+        )
+        return self.quantity_at("g", date=None)
 
     # ----------------------------
     #   *_from()


Test patch:
diff --git a/tests/isotope_qty_test.py b/tests/isotope_qty_test.py
index 2bf0edd..df7bb84 100644
--- a/tests/isotope_qty_test.py
+++ b/tests/isotope_qty_test.py
@@ -65,6 +65,7 @@ def iq_kwargs(request):
 
 @pytest.fixture(params=[
     datetime.datetime.now(),
+    datetime.date.today(),
     '2015-01-08 00:00:00',
     None
 ])
@@ -143,6 +144,8 @@ def test_isotopequantity_ref_date_rad(radioisotope, iq_date):
     iq = IsotopeQuantity(radioisotope, date=iq_date, atoms=1e24)
     if isinstance(iq_date, datetime.datetime):
         assert iq.ref_date == iq_date
+    elif isinstance(iq_date, datetime.date):
+        assert iq.ref_date.date() == iq_date
     elif bq.core.utils.isstring(iq_date):
         assert iq.ref_date == dateutil_parse(iq_date)
     else:
@@ -155,12 +158,21 @@ def test_isotopequantity_ref_date_stable(stable_isotope, iq_date):
     iq = IsotopeQuantity(stable_isotope, date=iq_date, atoms=1e24)
     if isinstance(iq_date, datetime.datetime):
         assert iq.ref_date == iq_date
+    elif isinstance(iq_date, datetime.date):
+        assert iq.ref_date.date() == iq_date
     elif bq.core.utils.isstring(iq_date):
         assert iq.ref_date == dateutil_parse(iq_date)
     else:
         assert (datetime.datetime.now() - iq.ref_date).total_seconds() < 5
 
 
+@pytest.mark.parametrize('date_in', [
+    '2021-2-20 0:00:00', '2021-2-20', datetime.date(2021, 2, 20)
+])
+def test_handle_datetime(date_in):
+    assert bq.utils.handle_datetime(date_in) == datetime.datetime(2021, 2, 20, 0, 0, 0)
+
+
 @pytest.mark.parametrize('iso, date, kwargs, error', [
     (['Cs-137'], None, {'atoms': 1e24}, TypeError),
     ('Cs-137', 123, {'bq': 456}, TypeError),
@@ -244,10 +256,26 @@ def test_isotopequantity_time_when_error(stable_isotope):
 def test_isotopequantity_activity_now(iq):
     """Test IsotopeQuantity.*_now()"""
 
-    assert np.isclose(iq.bq_now(), iq.bq_at(datetime.datetime.now()))
-    assert np.isclose(iq.uci_now(), iq.uci_at(datetime.datetime.now()))
-    assert np.isclose(iq.atoms_now(), iq.atoms_at(datetime.datetime.now()))
-    assert np.isclose(iq.g_now(), iq.g_at(datetime.datetime.now()))
+    # Test that the *_now() methods are deprecated
+    with pytest.warns(DeprecationWarning):
+        iq.bq_now()
+    with pytest.warns(DeprecationWarning):
+        iq.uci_now()
+    with pytest.warns(DeprecationWarning):
+        iq.atoms_now()
+    with pytest.warns(DeprecationWarning):
+        iq.g_now()
+
+    # Rather than test e.g.,
+    #   assert np.isclose(iq.bq_at(), iq.bq_at(datetime.datetime.now()))
+    # which may fail if the two times are computed after a long delay on a slow
+    # machine, just test that an explicit datetime.now() is sandwiched between
+    # two implicit calls.
+    for q in ["atoms", "bq", "uci", "g"]:
+        x0 = iq.quantity_at(q)
+        x1 = iq.quantity_at(q, datetime.datetime.now())
+        x2 = iq.quantity_at(q)
+        assert x0 >= x1 >= x2
 
 
 def test_isotopequantity_decays_from(iq):