Repository: pyqtgraph/pyqtgraph

Issue:
Infinite loop in `DateAxisItem`
In certain situations the `DateAxisItem` runs into an infinite loop because of floating point errors.

To reproduce this, 
1. Run the "DateAxisItem" example,
2. zoom in to around 1970-00-00 00:01:04.100 and a zoom level, such that you get tick labels in the format "ss.fff" (s=seconds, f=fraction).
3. As soon as the tick label "04.100" is going to be calculated, the application freezes.

Reason
-------
The program gets stuck in the `while` loop in `TickSpec.makeTicks` (`DateAxisItem.py` line 104). This happens, because the function `step` in line 106 returns the same value as it gets. 

In the specific situation, described above, `step` is the function `stepper` returned by `makeMSStepper` (line 32).
`stepper` is called with `val=64.1`, which is a `float` in seconds. In line 37, `val *= 1000` turns this into a milliseconds value, but due to floating point errors, the actual result is something like `64099.99999999999`. This possibility isn't considered in line 39, where `//` does an floor operation and we get a value which is one integer too small.

Possible solutions
------------------
I first thought some rounding within `stepper` should solve the problem, but this seems not possible, because we need to do the floor operation at least in the first call of the function.

To understand this, one needs to take a close look:
Clearly, the combination of `while` loop and `stepper` function builds up all the tick values which shall be shown, for a specific level. 
When `stepper` is called the first time (`x = self.step(minVal, n)`, line 104), `minVal` is start of the current visible axis range (`float` in seconds as unix timestamp, i.e. relative to 1970-00-...). Here, we need to do some kind of rounding, to get the first tick value. The rounding is currently achived by the `//` operation. But any rounding can go into the wrong direction, due to floating point errors.

Important is, that for the subsequent calls of `stepper` (`x = self.step(x, n)`, line 107), `x` is always a previous tick value and therefore it shall simply add the current tick-step. (Which can be not so simple in case of months or years.)

So the best idea, I can currently come up with, would be to separate the first and the subsequent calls of `stepper` and to simplify the code for the subsequent calls, to be able to ensure, that the returned value is always greater than the input value.
This could be something like this here https://github.com/bbc131/pyqtgraph/commit/4fff10f4090bf80bc677e5e2b2ff7be859b455cc
I would appreciate some feedback on this.

PS: A solution, which would get rid of the error-prone `while` loop would be nice. For the cases of the MS-stepper and S-stepper, we could use something like `numpy.linspace`. But the M-stepper and Y-stepper need a custom solution.


Discussion:
Hi @bbc131 

Thanks for the report; the DateAxisItem was introduced by a contributor a few years back, unfortunately none of the currently active maintainers are that familiar with the code; so while I recognize that this is a critical issue, it may take us some time to try and come up with a fix.  This is a long-winded way of saying a PR here would be _very_ welcome. 
Hi @j9ac9k

I totally understand this. 
Before I come up with a PR, I wanted to get some feedback about my idea, see my draft in https://github.com/bbc131/pyqtgraph/commit/4fff10f4090bf80bc677e5e2b2ff7be859b455cc.
It's a rather simple solution but maybe not that nice. Further it changes some internal api. 
So, if you think that something like this could be acceptable, I'll clean it up and do a PR.

Hi @bbc131 

Thanks for sharing the commit; internal API changes are fine, DateAxisItem is relatively new, and not much else on the library depends on it. 

The commit you linked to looks good to me 👍🏻 
Alright, I'll do a PR in the next days

Resolving patch:
diff --git a/pyqtgraph/graphicsItems/DateAxisItem.py b/pyqtgraph/graphicsItems/DateAxisItem.py
--- a/pyqtgraph/graphicsItems/DateAxisItem.py
+++ b/pyqtgraph/graphicsItems/DateAxisItem.py
@@ -29,46 +29,65 @@ def utcfromtimestamp(timestamp):
 MAX_REGULAR_TIMESTAMP = (datetime(9999, 1, 1) - datetime(1970,1,1)).total_seconds()
 SEC_PER_YEAR = 365.25*24*3600
 
+
+# The stepper functions provide
+#   'first' == True: The first tick value for 'val' being the minimum of the current view.
+#   'first' == False: The next tick value for 'val' being the previous tick value.
+
+
 def makeMSStepper(stepSize):
-    def stepper(val, n):
+    def stepper(val, n, first: bool):
         if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
             return np.inf
-        
-        val *= 1000
-        f = stepSize * 1000
-        return (val // (n*f) + 1) * (n*f) / 1000.0
+
+        if first:
+            val *= 1000
+            f = stepSize * 1000
+            return (val // (n * f) + 1) * (n * f) / 1000.0
+        else:
+            return val + n * stepSize
+
     return stepper
 
+
 def makeSStepper(stepSize):
-    def stepper(val, n):
+    def stepper(val, n, first: bool):
         if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
             return np.inf
-        
-        return (val // (n*stepSize) + 1) * (n*stepSize)
+
+        if first:
+            return (val // (n * stepSize) + 1) * (n * stepSize)
+        else:
+            return val + n * stepSize
+
     return stepper
 
+
 def makeMStepper(stepSize):
-    def stepper(val, n):
+    def stepper(val, n, first: bool):
         if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
             return np.inf
-        
+
         d = utcfromtimestamp(val)
-        base0m = (d.month + n*stepSize - 1)
+        base0m = d.month + n * stepSize - 1
         d = datetime(d.year + base0m // 12, base0m % 12 + 1, 1)
         return (d - datetime(1970, 1, 1)).total_seconds()
+
     return stepper
 
+
 def makeYStepper(stepSize):
-    def stepper(val, n):
+    def stepper(val, n, first: bool):
         if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP:
             return np.inf
-        
+
         d = utcfromtimestamp(val)
-        next_year = (d.year // (n*stepSize) + 1) * (n*stepSize)
+        next_year = (d.year // (n * stepSize) + 1) * (n * stepSize)
         if next_year > 9999:
             return np.inf
         next_date = datetime(next_year, 1, 1)
         return (next_date - datetime(1970, 1, 1)).total_seconds()
+
     return stepper
 
 class TickSpec:
@@ -100,10 +119,10 @@ def __init__(self, spacing, stepper, format, autoSkip=None):
     def makeTicks(self, minVal, maxVal, minSpc):
         ticks = []
         n = self.skipFactor(minSpc)
-        x = self.step(minVal, n)
+        x = self.step(minVal, n, first=True)
         while x <= maxVal:
             ticks.append(x)
-            x = self.step(x, n)
+            x = self.step(x, n, first=False)
         return (np.array(ticks), n)
 
     def skipFactor(self, minSpc):


Test patch: