python / cpython

The Python programming language
https://www.python.org
Other
63.87k stars 30.57k forks source link

turtle.setworldscoordinates() and floating point precision #101113

Open iuriguilherme opened 1 year ago

iuriguilherme commented 1 year ago

I made a turtle draw a spiral while rescaling the screen according to the last position of the turtle.

Simple code to reproduce (increasing the speed variable will make it crash sooner):

"""Crash a Turtle with negative infinity canvas scale"""
import turtle

speed = 3

angle = 0
radius = 1
boundaries = {'llx': -1.0, 'lly': -1.0, 'urx': 1.0, 'ury': 1.0}
pen = turtle.Turtle()
pen.speed(10)
pen.getscreen().tracer(1e3)
while True:
    print(f"radius {radius}, angle {angle}")
    print(f"screen xscale {pen.getscreen().xscale}")
    print(f"screen yscale {pen.getscreen().yscale}")
    pen.circle(radius, angle)
    radius *= speed
    angle += 1
    pos = pen.pos()
    boundaries['llx'] = min(boundaries['llx'], pos[0])
    boundaries['lly'] = min(boundaries['lly'], pos[1])
    boundaries['urx'] = max(boundaries['urx'], pos[0])
    boundaries['ury'] = max(boundaries['ury'], pos[1])
    pen.getscreen().setworldcoordinates(**boundaries)

This will raise _tkinter.TclError: expected floating-point number but got "floating" when x or y scale gets near 1e-306.

But in the code I'm using with a RawTurtle and a TurtleScreen I get ZeroDivisionError because somewhere after 6.7e-307 (in my machine) both TurtleScreen's x and y scales becomes 0.0, RawTurtle's position becomes (-inf, -inf) and then this line of code is reached: https://github.com/python/cpython/blob/3ef9f6b508a8524f385cdc9fdd4b4afca0eac59b/Lib/turtle.py#L1105

I had to set this in my code to prevent screen becoming blank (because of scale becoming 0.0), so I could stop drawing before it crashes:

precision: int = 1e-306
while True:
    logger.debug(f"""
boundaries = {boundaries}
screen.xscale = {screen.xscale}
screen.yscale = {screen.yscale}
""")
    try:
        1 / screen.xscale
        1 / screen.yscale
    except ZeroDivisionError:
        logger.warning(f"reached infinity")
        break
    if precision != min(precision, screen.xscale, screen.yscale):
        logger.warning(f"reached floating point precision limit")
        break
    screen.setworldcoordinates(**boundaries)
## Make screen still without going blank
root.mainloop()
stevendaprano commented 1 year ago

It is not clear to me exactly what you think is the bug here, and why it is a bug.

You have two code snippets, one of which fails with a TclError error, and the second which would have failed with a ZeroDivisionError except that you catch it. It is not clear to me why you are showing us both.

In the second, you try to divide by zero. What else do you expect to happen if not a ZeroDivisionError?

stevendaprano commented 1 year ago

Here is the last output before error:

radius 166085052802334249071698173012318266377090314221836038405624081264312004535368411213882210420911325849217643483175642178117589293984700913410158163128380945274525164734707988099102348195826982095574448167592415830999693168152203192072486723685128099869307736906836693804557289630130245874228969230203908723929, angle 646
screen xscale 1.9171917571829891e-305
screen yscale 1.2686018129018626e-305

and the full traceback:

Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
  File "/usr/local/lib/python3.10/turtle.py", line 1992, in circle
    self._go(l)
  File "/usr/local/lib/python3.10/turtle.py", line 1606, in _go
    self._goto(ende)
  File "/usr/local/lib/python3.10/turtle.py", line 3195, in _goto
    self._newLine()
  File "/usr/local/lib/python3.10/turtle.py", line 3287, in _newLine
    self.screen._drawline(self.currentLineItem, self.currentLine,
  File "/usr/local/lib/python3.10/turtle.py", line 544, in _drawline
    self.cv.coords(lineitem, *cl)
  File "<string>", line 1, in coords
  File "/usr/local/lib/python3.10/tkinter/__init__.py", line 2793, in coords
    return [self.tk.getdouble(x) for x in
  File "/usr/local/lib/python3.10/tkinter/__init__.py", line 2793, in <listcomp>
    return [self.tk.getdouble(x) for x in
_tkinter.TclError: expected floating-point number but got "floating"

Because I ran it in the interactive interpreter, the first line isn't shown, but "line 5" is inside the while loop:

    pen.circle(radius, angle)

The actual failing line begins with return [self.tk.getdouble(x) for x in but the rest is not shown. Here is the full line, in context:

# This is from tkinter/__init__.py
# The Canvas class
    def coords(self, *args):
        """Return a list of coordinates for the item given in ARGS."""
        # XXX Should use _flatten on args
        return [self.tk.getdouble(x) for x in
                           self.tk.splitlist(
                   self.tk.call((self._w, 'coords') + args))]
stevendaprano commented 1 year ago

Now this is interesting, I tried to make a shorter reproducer, by setting the program state straight to the last set of values which existed before the TclError. It raises a completely different exception.

radius = 166085052802334249071698173012318266377090314221836038405624081264312004535368411213882210420911325849217643483175642178117589293984700913410158163128380945274525164734707988099102348195826982095574448167592415830999693168152203192072486723685128099869307736906836693804557289630130245874228969230203908723929
angle = 646
import turtle
pen = turtle.Turtle()
pen.getscreen().xscale = 1.9171917571829891e-305
pen.getscreen().yscale = 1.2686018129018626e-305
pen.circle(radius, angle)

Which gives a different exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.10/turtle.py", line 1992, in circle
    self._go(l)
  File "/usr/local/lib/python3.10/turtle.py", line 1606, in _go
    self._goto(ende)
  File "/usr/local/lib/python3.10/turtle.py", line 3168, in _goto
    nhops = 1+int((diffsq**0.5)/(3*(1.1**self._speed)*self._speed))
OverflowError: cannot convert float infinity to integer
stevendaprano commented 1 year ago

@iuriguilherme I think at this point you need to:

I fear that this will end up being some quirk or bug in tcl/tk that we can't do anything about, but we might be lucky and maybe we can improve it from the tkinter end.

iuriguilherme commented 1 year ago

In the second, you try to divide by zero. What else do you expect to happen if not a ZeroDivisionError?

The point is why would screen.xscale and/or screen.yscale be equal to 0.0?

This happens when you try to use llx, lly, urx, ury values so high for screen.setworldcoordinates() that some line in the code of that function makes the scale revert to 0.0. Which will cause a ZeroDivisionError at the next screen.setworldcoordinates() call because of the line I referenced: https://github.com/python/cpython/blob/3ef9f6b508a8524f385cdc9fdd4b4afca0eac59b/Lib/turtle.py#L1105

The reason I deliberately try to raise a ZeroDivisionError before trying to call that function is because I wouldn't be able to "rollback" from a failed setworldcoordinates() attempt. If I just let that function raise a ZeroDivisionError, my screen would white out because self._rescale(self.xscale/oldxscale, self.yscale/oldyscale) at line 1105 raises that exception after changing other properties of the screen. It doesn't have its own try/except block so the screen is partially reconfigured, which is undesired behavior.

I had fixed the code to prevent that happening, I'll try to wreck the code again and will update this issue. My OP uses 3.11

stevendaprano commented 1 year ago

On Sun, Jan 22, 2023 at 06:57:11PM -0800, Iuri Guilherme wrote:

The point is why would screen.xscale and/or screen.yscale be equal to 0.0?

Maybe I'm missing something, but isn't that just underflow?

You're zooming out to see an ever increasing section of the turtle world, which makes the scale get smaller and smaller, and eventually it underflows to zero.

iuriguilherme commented 1 year ago

eventually it underflows to zero.

That is the problem, it shouldn't become zero. If the new scale is a number too close to zero that the system can't handle because of a floating point precision problem, it should round up and not default to zero IMO

Anyway, the side effect is that TurtleScreen.setworldcoordinates() will fail as a consequence of tha

iuriguilherme commented 1 year ago

Here a code example that can be used to see how and when will it break: https://gist.github.com/iuriguilherme/7b5843bf3a78850d859d384002b6871d

stevendaprano commented 1 year ago

Can you replicate this issue with a minimal reproducible example? Something that doesn't require an entire script using async, logging and sympy, but will still demonstrate the scale going to zero.

Ideally, it should be only four or five lines, and one of them is import turtle. I tried earlier, but got confusingly different exceptions.

  1. I can confirm that your first script does what you say (I haven't tried your second and third scripts).

  2. But I can't replicate the issue directly. That's probably because I don't understand turtle well enough.

iuriguilherme commented 1 year ago

I further "minimalized" the same gist without all the cosmetics. From my end I observed the following (YMMV):

There's a j variable which is the rate of the increasing

j = 2 (or 1) will raise ZeroDivisionError: float division by zero because screen scale is zero

j = 3 will raise _tkinter.TclError: expected floating-point number but got "floating" because turtle.pos() will be (inf, inf) which is NaN and tk.getdouble() can't handle it

j = 4 will raise OverflowError: int too large to convert to float because it will be too large for turtle.forward()