Repository: holoviz/holoviews

Issue:
Wrong location of custom datetime ticks
Software information:
Holoviews 1.14.2
Bokeh 2.3.0
Python 3.8.8
Ubuntu 18.04
Browser Firefox 87.0, Chromium 89.0

With holoviews v1.14.2, we have support for custom `datetime` ticks (#4831). I updated to this version because I needed this feature.

When supplying a list of `datetime` objects, ticks are indeed shown with the correct spacing, but I've had an issue with the resulting location of ticks, which do not correspond to whatever I set. There are consistently shifted to the left by two hours.

Below is a small example reproducing the issue, with the output I get.

```
line1 = hv.VLine(dt.datetime(2021, 4, 8, 12, 0, 0)).opts(color='black', line_dash='dotted')
line2 = hv.VLine(dt.datetime(2021, 4, 8, 13, 0, 0)).opts(color='black', line_dash='dotted')
line3 = hv.VLine(dt.datetime(2021, 4, 8, 14, 0, 0)).opts(color='black', line_dash='dotted')
df = pd.DataFrame({'var': [1, 2, 3, 4],
                   'date': [dt.datetime(2021, 4, 8, 11, 30, 0), dt.datetime(2021, 4, 8, 12, 30, 0),
                            dt.datetime(2021, 4, 8, 13, 30, 0), dt.datetime(2021, 4, 8, 14, 30, 0)]})
scatter = hv.Scatter(df, 'date', 'var').opts(size=12, tools=['hover'])
plot = line1*line2*line3*scatter.opts(xticks=[dt.datetime(2021, 4, 8, 12, 0, 0),
                                              dt.datetime(2021, 4, 8, 13, 0, 0),
                                              dt.datetime(2021, 4, 8, 14, 0, 0)])

```

![image](https://user-images.githubusercontent.com/49269671/114039714-42930080-9883-11eb-9e9e-5b0afde467a4.png)
Note that tick labels correspond to the actual tick location, meaning the labels themselves are not misleading. Therefore a workaround is to account for the shift in the argument supplied. I get the desired output when I add two hours to the `xticks` I ask for.

I hope there can be a quick fix for this issue, but as an easy workaround is available, this is no urgent matter.



Discussion:
Maybe related to #4863. 
Indeed.

From your example (one hour shift on 10th March) and mine it seems likely that the input datetimes are converted to UTC time.

Resolving patch:
diff --git a/holoviews/core/util.py b/holoviews/core/util.py
--- a/holoviews/core/util.py
+++ b/holoviews/core/util.py
@@ -2110,12 +2110,13 @@ def dt_to_int(value, time_unit='us'):
         if isinstance(value, pd.Timestamp):
             try:
                 value = value.to_datetime64()
-            except:
+            except Exception:
                 value = np.datetime64(value.to_pydatetime())
     elif isinstance(value, cftime_types):
         return cftime_to_timestamp(value, time_unit)
 
-    if isinstance(value, dt.date):
+    # date class is a parent for datetime class
+    if isinstance(value, dt.date) and not isinstance(value, dt.datetime):
         value = dt.datetime(*value.timetuple()[:6])
 
     # Handle datetime64 separately
@@ -2124,7 +2125,7 @@ def dt_to_int(value, time_unit='us'):
             value = np.datetime64(value, 'ns')
             tscale = (np.timedelta64(1, time_unit)/np.timedelta64(1, 'ns'))
             return value.tolist()/tscale
-        except:
+        except Exception:
             # If it can't handle ns precision fall back to datetime
             value = value.tolist()
 
@@ -2135,8 +2136,12 @@ def dt_to_int(value, time_unit='us'):
 
     try:
         # Handle python3
-        return int(value.timestamp() * tscale)
-    except:
+        if value.tzinfo is None:
+            _epoch = dt.datetime(1970, 1, 1)
+        else:
+            _epoch = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)
+        return int((value - _epoch).total_seconds() * tscale)
+    except Exception:
         # Handle python2
         return (time.mktime(value.timetuple()) + value.microsecond / 1e6) * tscale
 


Test patch:
diff --git a/holoviews/tests/core/testutils.py b/holoviews/tests/core/testutils.py
--- a/holoviews/tests/core/testutils.py
+++ b/holoviews/tests/core/testutils.py
@@ -660,6 +660,26 @@ def test_date_range_1_sec(self):
         self.assertEqual(drange[0], start+np.timedelta64(50, 'ms'))
         self.assertEqual(drange[-1], end-np.timedelta64(50, 'ms'))
 
+    @pd_skip
+    def test_timezone_to_int(self):
+        if py_version != 3:
+            raise SkipTest
+
+        import pytz
+        timezone = pytz.timezone("Europe/Copenhagen")
+
+        values = [
+            datetime.datetime(2021, 4, 8, 12, 0, 0, 0),
+            datetime.datetime(2021, 4, 8, 12, 0, 0, 0, datetime.timezone.utc),
+            datetime.datetime(2021, 4, 8, 12, 0, 0, 0, timezone),
+            datetime.date(2021, 4, 8),
+            np.datetime64(datetime.datetime(2021, 4, 8, 12, 0, 0, 0)),
+        ]
+
+        for value in values:
+            x1 = dt_to_int(value)
+            x2 = dt_to_int(pd.to_datetime(value))
+            self.assertEqual(x1, x2)
 
 class TestNumericUtilities(ComparisonTestCase):