Repository: SciTools/nc-time-axis

Issue:
Why can't we just visualise a vanilla netcdftime.datetime
I think it is worth putting the rationale here for why we can't handle a calendar-less netcdftime datetime as I'm certain this will come up regularly.


Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) Report
> Merging [#80](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) (ea208f6) into [main](https://codecov.io/gh/SciTools/nc-time-axis/commit/59c567d307eb8ced44f2c95b393c93ba571e486c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) (59c567d) will **increase** coverage by `0.10%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/SciTools/nc-time-axis/pull/80/graphs/tree.svg?width=650&height=150&src=pr&token=JicwCCHwLd&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools)](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools)

```diff
@@            Coverage Diff             @@
##             main      #80      +/-   ##
==========================================
+ Coverage   90.58%   90.69%   +0.10%     
==========================================
  Files           1        1              
  Lines         170      172       +2     
  Branches       41       42       +1     
==========================================
+ Hits          154      156       +2     
  Misses         11       11              
  Partials        5        5              
```


| [Impacted Files](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools) | Coverage Δ | |
|---|---|---|
| [nc\_time\_axis/\_\_init\_\_.py](https://codecov.io/gh/SciTools/nc-time-axis/pull/80/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools#diff-bmNfdGltZV9heGlzL19faW5pdF9fLnB5) | `90.69% <100.00%> (+0.10%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools). Last update [59c567d...ea208f6](https://codecov.io/gh/SciTools/nc-time-axis/pull/80?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=SciTools).


Patch:
diff --git a/README.md b/README.md
index d1fc951..132b072 100644
--- a/README.md
+++ b/README.md
@@ -58,18 +58,16 @@ Or `pip`:
 
     import cftime
     import matplotlib.pyplot as plt
-
-    from nc_time_axis import CalendarDateTime
+    import nc_time_axis
 
     calendar = "360_day"
     dt = [
         cftime.datetime(year=2017, month=2, day=day, calendar=calendar)
         for day in range(1, 31)
     ]
-    cdt = [CalendarDateTime(item, calendar) for item in dt]
-    temperatures = [round(random.uniform(0, 12), 3) for _ in range(len(cdt))]
+    temperatures = [round(random.uniform(0, 12), 3) for _ in range(len(dt))]
 
-    plt.plot(cdt, temperatures)
+    plt.plot(dt, temperatures)
     plt.margins(0.1)
     plt.ylim(0, 12)
     plt.xlabel("Date")
diff --git a/nc_time_axis/__init__.py b/nc_time_axis/__init__.py
index a683cdc..2a3f79f 100644
--- a/nc_time_axis/__init__.py
+++ b/nc_time_axis/__init__.py
@@ -308,6 +308,10 @@ class NetCDFTimeConverter(mdates.DateConverter):
             else:
                 calendar = sample_point.calendar
             date_type = type(sample_point)
+        if calendar == "":
+            raise ValueError(
+                "A calendar must be defined to plot dates using a cftime axis."
+            )
         return calendar, cls.standard_unit, date_type
 
     @classmethod
@@ -373,6 +377,7 @@ if CalendarDateTime not in munits.registry:
     munits.registry[CalendarDateTime] = NetCDFTimeConverter()
 
 CFTIME_TYPES = [
+    cftime.datetime,
     cftime.DatetimeNoLeap,
     cftime.DatetimeAllLeap,
     cftime.DatetimeProlepticGregorian,
diff --git a/requirements/py37.yml b/requirements/py37.yml
index 40e3a9d..321ba26 100644
--- a/requirements/py37.yml
+++ b/requirements/py37.yml
@@ -11,13 +11,13 @@ dependencies:
   - setuptools-scm
 
 # core dependencies
-  - cftime
+  - cftime >=1.5
   - matplotlib
   - numpy
 
 # test dependencies
   - codecov
-  - pytest>=6.0
+  - pytest >=6.0
   - pytest-cov
 
 # dev dependencies
diff --git a/requirements/py38.yml b/requirements/py38.yml
index 4f97610..1ff1b39 100644
--- a/requirements/py38.yml
+++ b/requirements/py38.yml
@@ -11,13 +11,13 @@ dependencies:
   - setuptools-scm
 
 # core dependencies
-  - cftime
+  - cftime >=1.5
   - matplotlib
   - numpy
 
 # test dependencies
   - codecov
-  - pytest>=6.0
+  - pytest >=6.0
   - pytest-cov
 
 # dev dependencies
diff --git a/requirements/py39.yml b/requirements/py39.yml
index e141ecb..d6519f1 100644
--- a/requirements/py39.yml
+++ b/requirements/py39.yml
@@ -11,13 +11,13 @@ dependencies:
   - setuptools-scm
 
 # core dependencies
-  - cftime
+  - cftime >=1.5
   - matplotlib
   - numpy
 
 # test dependencies
   - codecov
-  - pytest>=6.0
+  - pytest >=6.0
   - pytest-cov
 
 # dev dependencies
diff --git a/setup.cfg b/setup.cfg
index 8a0fe29..69c4fb7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -34,7 +34,7 @@ version = attr: nc_time_axis.__version__
 [options]
 include_package_data = True
 install_requires =
-    cftime
+    cftime >=1.5
     matplotlib
     numpy
 packages = find:


Test patch:
diff --git a/nc_time_axis/tests/integration/test_plot.py b/nc_time_axis/tests/integration/test_plot.py
index 4b90584..4d9ef70 100644
--- a/nc_time_axis/tests/integration/test_plot.py
+++ b/nc_time_axis/tests/integration/test_plot.py
@@ -44,6 +44,23 @@ class Test(unittest.TestCase):
         result_ydata = line1.get_ydata()
         np.testing.assert_array_equal(result_ydata, datetimes)
 
+    def test_360_day_calendar_raw_universal_dates(self):
+        datetimes = [
+            cftime.datetime(1986, month, 30, calendar="360_day")
+            for month in range(1, 6)
+        ]
+        (line1,) = plt.plot(datetimes)
+        result_ydata = line1.get_ydata()
+        np.testing.assert_array_equal(result_ydata, datetimes)
+
+    def test_no_calendar_raw_universal_dates(self):
+        datetimes = [
+            cftime.datetime(1986, month, 30, calendar=None)
+            for month in range(1, 6)
+        ]
+        with self.assertRaisesRegex(ValueError, "defined"):
+            plt.plot(datetimes)
+
     def test_fill_between(self):
         calendar = "360_day"
         dt = [
diff --git a/nc_time_axis/tests/unit/test_NetCDFTimeConverter.py b/nc_time_axis/tests/unit/test_NetCDFTimeConverter.py
index b564801..935ffab 100644
--- a/nc_time_axis/tests/unit/test_NetCDFTimeConverter.py
+++ b/nc_time_axis/tests/unit/test_NetCDFTimeConverter.py
@@ -78,6 +78,33 @@ class Test_default_units(unittest.TestCase):
         result = NetCDFTimeConverter().default_units(val, None)
         self.assertEqual(result, (calendar, unit, cftime.Datetime360Day))
 
+    def test_360_day_calendar_point_raw_universal_date(self):
+        calendar = "360_day"
+        unit = "days since 2000-01-01"
+        val = cftime.datetime(2014, 8, 12, calendar=calendar)
+        result = NetCDFTimeConverter().default_units(val, None)
+        self.assertEqual(result, (calendar, unit, cftime.datetime))
+
+    def test_360_day_calendar_list_raw_universal_date(self):
+        calendar = "360_day"
+        unit = "days since 2000-01-01"
+        val = [cftime.datetime(2014, 8, 12, calendar=calendar)]
+        result = NetCDFTimeConverter().default_units(val, None)
+        self.assertEqual(result, (calendar, unit, cftime.datetime))
+
+    def test_360_day_calendar_nd_raw_universal_date(self):
+        # Test the case where the input is an nd-array.
+        calendar = "360_day"
+        unit = "days since 2000-01-01"
+        val = np.array(
+            [
+                [cftime.datetime(2014, 8, 12, calendar=calendar)],
+                [cftime.datetime(2014, 8, 13, calendar=calendar)],
+            ]
+        )
+        result = NetCDFTimeConverter().default_units(val, None)
+        self.assertEqual(result, (calendar, unit, cftime.datetime))
+
     def test_nonequal_calendars(self):
         # Test that different supplied calendars causes an error.
         calendar_1 = "360_day"
@@ -89,6 +116,12 @@ class Test_default_units(unittest.TestCase):
         with self.assertRaisesRegex(ValueError, "not all equal"):
             NetCDFTimeConverter().default_units(val, None)
 
+    def test_no_calendar_point_raw_universal_date(self):
+        calendar = None
+        val = cftime.datetime(2014, 8, 12, calendar=calendar)
+        with self.assertRaisesRegex(ValueError, "defined"):
+            NetCDFTimeConverter().default_units(val, None)
+
 
 class Test_convert(unittest.TestCase):
     def test_numpy_array(self):
@@ -141,6 +174,27 @@ class Test_convert(unittest.TestCase):
         assert result == expected
         assert len(result) == 1
 
+    def test_cftime_raw_universal_date(self):
+        val = cftime.datetime(2014, 8, 12, calendar="noleap")
+        result = NetCDFTimeConverter().convert(val, None, None)
+        expected = 5333.0
+        assert result == expected
+        assert np.isscalar(result)
+
+    def test_cftime_list_universal_date(self):
+        val = [cftime.datetime(2014, 8, 12, calendar="noleap")]
+        result = NetCDFTimeConverter().convert(val, None, None)
+        expected = 5333.0
+        assert result == expected
+        assert len(result) == 1
+
+    def test_cftime_tuple_univeral_date(self):
+        val = (cftime.datetime(2014, 8, 12, calendar="noleap"),)
+        result = NetCDFTimeConverter().convert(val, None, None)
+        expected = 5333.0
+        assert result == expected
+        assert len(result) == 1
+
     def test_cftime_np_array_CalendarDateTime(self):
         val = np.array(
             [CalendarDateTime(cftime.datetime(2012, 6, 4), "360_day")],
@@ -154,6 +208,13 @@ class Test_convert(unittest.TestCase):
         result = NetCDFTimeConverter().convert(val, None, None)
         self.assertEqual(result, np.array([4473.0]))
 
+    def test_cftime_np_array_raw_universal_date(self):
+        val = np.array(
+            [cftime.datetime(2012, 6, 4, calendar="360_day")], dtype=object
+        )
+        result = NetCDFTimeConverter().convert(val, None, None)
+        self.assertEqual(result, np.array([4473.0]))
+
     def test_non_cftime_datetime(self):
         val = CalendarDateTime(4, "360_day")
         msg = (
diff --git a/nc_time_axis/tests/unit/test_NetCDFTimeDateLocator.py b/nc_time_axis/tests/unit/test_NetCDFTimeDateLocator.py
index 11310a6..a1f060a 100644
--- a/nc_time_axis/tests/unit/test_NetCDFTimeDateLocator.py
+++ b/nc_time_axis/tests/unit/test_NetCDFTimeDateLocator.py
@@ -83,6 +83,23 @@ class Test_compute_resolution(unittest.TestCase):
         )
 
 
+class Test_compute_resolution_universal_datetime(unittest.TestCase):
+    def check(self, max_n_ticks, num1, num2):
+        locator = NetCDFTimeDateLocator(
+            max_n_ticks=max_n_ticks,
+            calendar=self.calendar,
+            date_unit=self.date_unit,
+        )
+        date1 = cftime.num2date(num1, self.date_unit, calendar=self.calendar)
+        date2 = cftime.num2date(num2, self.date_unit, calendar=self.calendar)
+        return locator.compute_resolution(
+            num1,
+            num2,
+            cftime.datetime(*date1.timetuple(), calendar=self.calendar),
+            cftime.datetime(*date2.timetuple(), calendar=self.calendar),
+        )
+
+
 class Test_tick_values(unittest.TestCase):
     def setUp(self):
         self.date_unit = "days since 2004-01-01 00:00"