Add the interpreter

This commit is contained in:
Reid 'arrdem' McKenzie 2023-06-03 15:39:34 -06:00
parent 95d67b4aeb
commit 42ad82d96e
3 changed files with 886 additions and 0 deletions

View file

@ -0,0 +1,3 @@
py_project(
name = "gcode-interpreter",
)

View file

@ -0,0 +1,15 @@
# Gcode Interpreter
Extracted [from
octoprint](https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/src/octoprint/util/gcodeInterpreter.py), this
package provides static analysis (ahem. abstract interpretation) of GCODE scripts for 3d printers to provide key data
such as the bounding box through which the tool(s) move, estimated net movement time and the net amount of material
extruded.
## License
This artifact is licensed under the GNU Affero General Public License http://www.gnu.org/licenses/agpl.html
Copyright © Reid McKenzie <me@arrdem.com>
Copyright © Gina Häußge <osd@foosel.net>
Copyright © David Braam

View file

@ -0,0 +1,868 @@
__author__ = "Gina Häußge <osd@foosel.net> based on work by David Braam"
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
__copyright__ = "Copyright (C) 2013 David Braam, Gina Häußge - Released under terms of the AGPLv3 License"
import base64
import codecs
import io
import logging
import math
import os
import re
import zlib
class Vector3D:
"""
3D vector value
Supports addition, subtraction and multiplication with a scalar value (float, int) as well as calculating the
length of the vector.
Examples:
>>> a = Vector3D(1.0, 1.0, 1.0)
>>> b = Vector3D(4.0, 4.0, 4.0)
>>> a + b == Vector3D(5.0, 5.0, 5.0)
True
>>> b - a == Vector3D(3.0, 3.0, 3.0)
True
>>> abs(a - b) == Vector3D(3.0, 3.0, 3.0)
True
>>> a * 2 == Vector3D(2.0, 2.0, 2.0)
True
>>> a * 2 == 2 * a
True
>>> a.length == math.sqrt(a.x ** 2 + a.y ** 2 + a.z ** 2)
True
>>> copied_a = Vector3D(a)
>>> a == copied_a
True
>>> copied_a.x == a.x and copied_a.y == a.y and copied_a.z == a.z
True
"""
def __init__(self, *args):
if len(args) == 3:
(self.x, self.y, self.z) = args
elif len(args) == 1:
# copy constructor
other = args[0]
if not isinstance(other, Vector3D):
raise ValueError("Object to copy must be a Vector3D instance")
self.x = other.x
self.y = other.y
self.z = other.z
@property
def length(self):
return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
def __add__(self, other):
try:
if len(other) == 3:
return Vector3D(self.x + other[0], self.y + other[1], self.z + other[2])
except TypeError:
# doesn't look like a 3-tuple
pass
try:
return Vector3D(self.x + other.x, self.y + other.y, self.z + other.z)
except AttributeError:
# also doesn't look like a Vector3D
pass
raise TypeError(
"other must be a Vector3D instance or a list or tuple of length 3"
)
def __sub__(self, other):
try:
if len(other) == 3:
return Vector3D(self.x - other[0], self.y - other[1], self.z - other[2])
except TypeError:
# doesn't look like a 3-tuple
pass
try:
return Vector3D(self.x - other.x, self.y - other.y, self.z - other.z)
except AttributeError:
# also doesn't look like a Vector3D
pass
raise TypeError(
"other must be a Vector3D instance or a list or tuple of length 3"
)
def __mul__(self, other):
try:
return Vector3D(self.x * other, self.y * other, self.z * other)
except TypeError:
# doesn't look like a scalar
pass
raise ValueError("other must be a float or int value")
def __rmul__(self, other):
return self.__mul__(other)
def __abs__(self):
return Vector3D(abs(self.x), abs(self.y), abs(self.z))
def __eq__(self, other):
if not isinstance(other, Vector3D):
return False
return self.x == other.x and self.y == other.y and self.z == other.z
def __str__(self):
return "Vector3D(x={}, y={}, z={}, length={})".format(
self.x, self.y, self.z, self.length
)
class MinMax3D:
"""
Tracks minimum and maximum of recorded values
Examples:
>>> minmax = MinMax3D()
>>> minmax.record(Vector3D(2.0, 2.0, 2.0))
>>> minmax.min.x == 2.0 == minmax.max.x and minmax.min.y == 2.0 == minmax.max.y and minmax.min.z == 2.0 == minmax.max.z
True
>>> minmax.record(Vector3D(1.0, 2.0, 3.0))
>>> minmax.min.x == 1.0 and minmax.min.y == 2.0 and minmax.min.z == 2.0
True
>>> minmax.max.x == 2.0 and minmax.max.y == 2.0 and minmax.max.z == 3.0
True
>>> minmax.size == Vector3D(1.0, 0.0, 1.0)
True
>>> empty = MinMax3D()
>>> empty.size == Vector3D(0.0, 0.0, 0.0)
True
>>> weird = MinMax3D(min_z=-1.0)
>>> weird.record(Vector3D(2.0, 2.0, 2.0))
>>> weird.record(Vector3D(1.0, 2.0, 3.0))
>>> weird.min.z == -1.0
True
>>> weird.size == Vector3D(1.0, 0.0, 4.0)
True
"""
def __init__(
self,
min_x=None,
min_y=None,
min_z=None,
max_x=None,
max_y=None,
max_z=None,
):
min_x = min_x if min_x is not None else float("inf")
min_y = min_y if min_y is not None else float("inf")
min_z = min_z if min_z is not None else float("inf")
max_x = max_x if max_x is not None else -float("inf")
max_y = max_y if max_y is not None else -float("inf")
max_z = max_z if max_z is not None else -float("inf")
self.min = Vector3D(min_x, min_y, min_z)
self.max = Vector3D(max_x, max_y, max_z)
def record(self, coordinate):
"""
Records the coordinate, storing the min and max values.
The input vector components must not be None.
"""
self.min.x = min(self.min.x, coordinate.x)
self.min.y = min(self.min.y, coordinate.y)
self.min.z = min(self.min.z, coordinate.z)
self.max.x = max(self.max.x, coordinate.x)
self.max.y = max(self.max.y, coordinate.y)
self.max.z = max(self.max.z, coordinate.z)
@property
def size(self):
result = Vector3D()
for c in "xyz":
min = getattr(self.min, c)
max = getattr(self.max, c)
value = abs(max - min) if max >= min else 0.0
setattr(result, c, value)
return result
@property
def dimensions(self):
size = self.size
return {"width": size.x, "depth": size.y, "height": size.z}
@property
def area(self):
return {
"minX": None if math.isinf(self.min.x) else self.min.x,
"minY": None if math.isinf(self.min.y) else self.min.y,
"minZ": None if math.isinf(self.min.z) else self.min.z,
"maxX": None if math.isinf(self.max.x) else self.max.x,
"maxY": None if math.isinf(self.max.y) else self.max.y,
"maxZ": None if math.isinf(self.max.z) else self.max.z,
}
class AnalysisAborted(Exception):
def __init__(self, reenqueue=True, *args, **kwargs):
self.reenqueue = reenqueue
Exception.__init__(self, *args, **kwargs)
regex_command = re.compile(
r"^\s*((?P<codeGM>[GM]\d+)(\.(?P<subcode>\d+))?|(?P<codeT>T)(?P<tool>\d+))"
)
"""Regex for a GCODE command."""
class gcode:
def __init__(self, incl_layers=False, progress_callback=None):
self._logger = logging.getLogger(__name__)
self.extrusionAmount = [0]
self.extrusionVolume = [0]
self.totalMoveTimeMinute = 0
self.filename = None
self._abort = False
self._reenqueue = True
self._filamentDiameter = 0
self._print_minMax = MinMax3D()
self._travel_minMax = MinMax3D()
self._progress_callback = progress_callback
self._incl_layers = incl_layers
self._layers = []
self._current_layer = None
def _track_layer(self, pos, arc=None):
if not self._incl_layers:
return
if self._current_layer is None or self._current_layer["z"] != pos.z:
self._current_layer = {"z": pos.z, "minmax": MinMax3D(), "commands": 1}
self._layers.append(self._current_layer)
elif self._current_layer:
self._current_layer["minmax"].record(pos)
if arc is not None:
self._addArcMinMax(
self._current_layer["minmax"],
arc["startAngle"],
arc["endAngle"],
arc["center"],
arc["radius"],
)
def _track_command(self):
if self._current_layer:
self._current_layer["commands"] += 1
@property
def dimensions(self):
return self._print_minMax.dimensions
@property
def travel_dimensions(self):
return self._travel_minMax.dimensions
@property
def printing_area(self):
return self._print_minMax.area
@property
def travel_area(self):
return self._travel_minMax.area
@property
def layers(self):
return [
{
"num": num + 1,
"z": layer["z"],
"commands": layer["commands"],
"bounds": {
"minX": layer["minmax"].min.x,
"maxX": layer["minmax"].max.x,
"minY": layer["minmax"].min.y,
"maxY": layer["minmax"].max.y,
},
}
for num, layer in enumerate(self._layers)
]
def load(
self,
filename,
throttle=None,
speedx=6000,
speedy=6000,
offsets=None,
max_extruders=10,
g90_extruder=False,
bed_z=0.0,
):
self._print_minMax.min.z = self._travel_minMax.min.z = bed_z
if os.path.isfile(filename):
self.filename = filename
self._fileSize = os.stat(filename).st_size
with codecs.open(filename, encoding="utf-8", errors="replace") as f:
self._load(
f,
throttle=throttle,
speedx=speedx,
speedy=speedy,
offsets=offsets,
max_extruders=max_extruders,
g90_extruder=g90_extruder,
)
def abort(self, reenqueue=True):
self._abort = True
self._reenqueue = reenqueue
def _load(
self,
gcodeFile,
throttle=None,
speedx=6000,
speedy=6000,
offsets=None,
max_extruders=10,
g90_extruder=False,
):
lineNo = 0
readBytes = 0
pos = Vector3D(0.0, 0.0, 0.0)
currentE = [0.0]
totalExtrusion = [0.0]
maxExtrusion = [0.0]
currentExtruder = 0
totalMoveTimeMinute = 0.0
relativeE = False
relativeMode = False
duplicationMode = False
scale = 1.0
fwretractTime = 0
fwretractDist = 0
fwrecoverTime = 0
feedrate = min(speedx, speedy)
if feedrate == 0:
# some somewhat sane default if axes speeds are insane...
feedrate = 2000
if offsets is None or not isinstance(offsets, (list, tuple)):
offsets = []
if len(offsets) < max_extruders:
offsets += [(0, 0)] * (max_extruders - len(offsets))
for line in gcodeFile:
if self._abort:
raise AnalysisAborted(reenqueue=self._reenqueue)
lineNo += 1
readBytes += len(line.encode("utf-8"))
if isinstance(gcodeFile, (io.IOBase, codecs.StreamReaderWriter)):
percentage = readBytes / self._fileSize
elif isinstance(gcodeFile, (list)):
percentage = lineNo / len(gcodeFile)
else:
percentage = None
try:
if (
self._progress_callback is not None
and (lineNo % 1000 == 0)
and percentage is not None
):
self._progress_callback(percentage)
except Exception as exc:
self._logger.debug(
"Progress callback %r error: %s", self._progress_callback, exc
)
if ";" in line:
comment = line[line.find(";") + 1 :].strip()
if comment.startswith("filament_diameter"):
# Slic3r
filamentValue = comment.split("=", 1)[1].strip()
try:
self._filamentDiameter = float(filamentValue)
except ValueError:
try:
self._filamentDiameter = float(
filamentValue.split(",")[0].strip()
)
except ValueError:
self._filamentDiameter = 0.0
elif comment.startswith("CURA_PROFILE_STRING") or comment.startswith(
"CURA_OCTO_PROFILE_STRING"
):
# Cura 15.04.* & OctoPrint Cura plugin
if comment.startswith("CURA_PROFILE_STRING"):
prefix = "CURA_PROFILE_STRING:"
else:
prefix = "CURA_OCTO_PROFILE_STRING:"
curaOptions = self._parseCuraProfileString(comment, prefix)
if "filament_diameter" in curaOptions:
try:
self._filamentDiameter = float(
curaOptions["filament_diameter"]
)
except ValueError:
self._filamentDiameter = 0.0
elif comment.startswith("filamentDiameter,"):
# Simplify3D
filamentValue = comment.split(",", 1)[1].strip()
try:
self._filamentDiameter = float(filamentValue)
except ValueError:
self._filamentDiameter = 0.0
line = line[0 : line.find(";")]
match = regex_command.search(line)
gcode = tool = None
if match:
values = match.groupdict()
if "codeGM" in values and values["codeGM"]:
gcode = values["codeGM"]
elif "codeT" in values and values["codeT"]:
gcode = values["codeT"]
tool = int(values["tool"])
# G codes
if gcode in ("G0", "G1", "G00", "G01"): # Move
x = getCodeFloat(line, "X")
y = getCodeFloat(line, "Y")
z = getCodeFloat(line, "Z")
e = getCodeFloat(line, "E")
f = getCodeFloat(line, "F")
if x is not None or y is not None or z is not None:
# this is a move
move = True
else:
# print head stays on position
move = False
oldPos = pos
# Use new coordinates if provided. If not provided, use prior coordinates (minus tool offset)
# in absolute and 0.0 in relative mode.
newPos = Vector3D(
x * scale if x is not None else (0.0 if relativeMode else pos.x),
y * scale if y is not None else (0.0 if relativeMode else pos.y),
z * scale if z is not None else (0.0 if relativeMode else pos.z),
)
if relativeMode:
# Relative mode: add to current position
pos += newPos
else:
# Absolute mode: apply tool offsets
pos = newPos
if f is not None and f != 0:
feedrate = f
if e is not None:
if relativeMode or relativeE:
# e is already relative, nothing to do
pass
else:
e -= currentE[currentExtruder]
totalExtrusion[currentExtruder] += e
currentE[currentExtruder] += e
maxExtrusion[currentExtruder] = max(
maxExtrusion[currentExtruder], totalExtrusion[currentExtruder]
)
if currentExtruder == 0 and len(currentE) > 1 and duplicationMode:
# Copy first extruder length to other extruders
for i in range(1, len(currentE)):
totalExtrusion[i] += e
currentE[i] += e
maxExtrusion[i] = max(maxExtrusion[i], totalExtrusion[i])
else:
e = 0
# If move, calculate new min/max coordinates
if move:
self._travel_minMax.record(oldPos)
self._travel_minMax.record(pos)
if e > 0:
# store as print move if extrusion is > 0
self._print_minMax.record(oldPos)
self._print_minMax.record(pos)
# move time in x, y, z, will be 0 if no movement happened
moveTimeXYZ = abs((oldPos - pos).length / feedrate)
# time needed for extruding, will be 0 if no extrusion happened
extrudeTime = abs(e / feedrate)
# time to add is maximum of both
totalMoveTimeMinute += max(moveTimeXYZ, extrudeTime)
# process layers if there's extrusion
if e:
self._track_layer(pos)
if gcode in ("G2", "G3", "G02", "G03"): # Arc Move
x = getCodeFloat(line, "X")
y = getCodeFloat(line, "Y")
z = getCodeFloat(line, "Z")
e = getCodeFloat(line, "E")
i = getCodeFloat(line, "I")
j = getCodeFloat(line, "J")
r = getCodeFloat(line, "R")
f = getCodeFloat(line, "F")
# this is a move or print head stays on position
move = (
x is not None
or y is not None
or z is not None
or i is not None
or j is not None
or r is not None
)
oldPos = pos
# Use new coordinates if provided. If not provided, use prior coordinates (minus tool offset)
# in absolute and 0.0 in relative mode.
newPos = Vector3D(
x * scale if x is not None else (0.0 if relativeMode else pos.x),
y * scale if y is not None else (0.0 if relativeMode else pos.y),
z * scale if z is not None else (0.0 if relativeMode else pos.z),
)
if relativeMode:
# Relative mode: add to current position
pos += newPos
else:
# Absolute mode: apply tool offsets
pos = newPos
if f is not None and f != 0:
feedrate = f
# get radius and offset
i = 0 if i is None else i
j = 0 if j is None else j
r = math.sqrt(i * i + j * j) if r is None else r
# calculate angles
centerArc = Vector3D(oldPos.x + i, oldPos.y + j, oldPos.z)
startAngle = math.atan2(oldPos.y - centerArc.y, oldPos.x - centerArc.x)
endAngle = math.atan2(pos.y - centerArc.y, pos.x - centerArc.x)
arcAngle = endAngle - startAngle
if gcode in ("G2", "G02"):
startAngle, endAngle = endAngle, startAngle
arcAngle = -arcAngle
if startAngle < 0:
startAngle += math.pi * 2
if endAngle < 0:
endAngle += math.pi * 2
if arcAngle < 0:
arcAngle += math.pi * 2
# from now on we only think in counter-clockwise direction
if e is not None:
if relativeMode or relativeE:
# e is already relative, nothing to do
pass
else:
e -= currentE[currentExtruder]
totalExtrusion[currentExtruder] += e
currentE[currentExtruder] += e
maxExtrusion[currentExtruder] = max(
maxExtrusion[currentExtruder], totalExtrusion[currentExtruder]
)
if currentExtruder == 0 and len(currentE) > 1 and duplicationMode:
# Copy first extruder length to other extruders
for i in range(1, len(currentE)):
totalExtrusion[i] += e
currentE[i] += e
maxExtrusion[i] = max(maxExtrusion[i], totalExtrusion[i])
else:
e = 0
# If move, calculate new min/max coordinates
if move:
self._travel_minMax.record(oldPos)
self._travel_minMax.record(pos)
self._addArcMinMax(
self._travel_minMax, startAngle, endAngle, centerArc, r
)
if e > 0:
# store as print move if extrusion is > 0
self._print_minMax.record(oldPos)
self._print_minMax.record(pos)
self._addArcMinMax(
self._print_minMax, startAngle, endAngle, centerArc, r
)
# calculate 3d arc length
arcLengthXYZ = math.sqrt((oldPos.z - pos.z) ** 2 + (arcAngle * r) ** 2)
# move time in x, y, z, will be 0 if no movement happened
moveTimeXYZ = abs(arcLengthXYZ / feedrate)
# time needed for extruding, will be 0 if no extrusion happened
extrudeTime = abs(e / feedrate)
# time to add is maximum of both
totalMoveTimeMinute += max(moveTimeXYZ, extrudeTime)
# process layers if there's extrusion
if e:
self._track_layer(
pos,
{
"startAngle": startAngle,
"endAngle": endAngle,
"center": centerArc,
"radius": r,
},
)
elif gcode == "G4": # Delay
S = getCodeFloat(line, "S")
if S is not None:
totalMoveTimeMinute += S / 60
P = getCodeFloat(line, "P")
if P is not None:
totalMoveTimeMinute += P / 60 / 1000
elif gcode == "G10": # Firmware retract
totalMoveTimeMinute += fwretractTime
elif gcode == "G11": # Firmware retract recover
totalMoveTimeMinute += fwrecoverTime
elif gcode == "G20": # Units are inches
scale = 25.4
elif gcode == "G21": # Units are mm
scale = 1.0
elif gcode == "G28": # Home
x = getCodeFloat(line, "X")
y = getCodeFloat(line, "Y")
z = getCodeFloat(line, "Z")
origin = Vector3D(0.0, 0.0, 0.0)
if x is None and y is None and z is None:
pos = origin
else:
pos = Vector3D(pos)
if x is not None:
pos.x = origin.x
if y is not None:
pos.y = origin.y
if z is not None:
pos.z = origin.z
elif gcode == "G90": # Absolute position
relativeMode = False
if g90_extruder:
relativeE = False
elif gcode == "G91": # Relative position
relativeMode = True
if g90_extruder:
relativeE = True
elif gcode == "G92":
x = getCodeFloat(line, "X")
y = getCodeFloat(line, "Y")
z = getCodeFloat(line, "Z")
e = getCodeFloat(line, "E")
if e is None and x is None and y is None and z is None:
# no parameters, set all axis to 0
currentE[currentExtruder] = 0.0
pos.x = 0.0
pos.y = 0.0
pos.z = 0.0
else:
# some parameters set, only set provided axes
if e is not None:
currentE[currentExtruder] = e
if x is not None:
pos.x = x
if y is not None:
pos.y = y
if z is not None:
pos.z = z
# M codes
elif gcode == "M82": # Absolute E
relativeE = False
elif gcode == "M83": # Relative E
relativeE = True
elif gcode in ("M207", "M208"): # Firmware retract settings
s = getCodeFloat(line, "S")
f = getCodeFloat(line, "F")
if s is not None and f is not None:
if gcode == "M207":
# Ensure division is valid
if f > 0:
fwretractTime = s / f
else:
fwretractTime = 0
fwretractDist = s
else:
if f > 0:
fwrecoverTime = (fwretractDist + s) / f
else:
fwrecoverTime = 0
elif gcode == "M605": # Duplication/Mirroring mode
s = getCodeInt(line, "S")
if s in [2, 4, 5, 6]:
# Duplication / Mirroring mode selected. Printer firmware copies extrusion commands
# from first extruder to all other extruders
duplicationMode = True
else:
duplicationMode = False
# T codes
elif tool is not None:
if tool > max_extruders:
self._logger.warning(
"GCODE tried to select tool %d, that looks wrong, ignoring for GCODE analysis"
% tool
)
elif tool == currentExtruder:
pass
else:
pos.x -= (
offsets[currentExtruder][0]
if currentExtruder < len(offsets)
else 0
)
pos.y -= (
offsets[currentExtruder][1]
if currentExtruder < len(offsets)
else 0
)
currentExtruder = tool
pos.x += (
offsets[currentExtruder][0]
if currentExtruder < len(offsets)
else 0
)
pos.y += (
offsets[currentExtruder][1]
if currentExtruder < len(offsets)
else 0
)
if len(currentE) <= currentExtruder:
for _ in range(len(currentE), currentExtruder + 1):
currentE.append(0.0)
if len(maxExtrusion) <= currentExtruder:
for _ in range(len(maxExtrusion), currentExtruder + 1):
maxExtrusion.append(0.0)
if len(totalExtrusion) <= currentExtruder:
for _ in range(len(totalExtrusion), currentExtruder + 1):
totalExtrusion.append(0.0)
if gcode or tool:
self._track_command()
if throttle is not None:
throttle(lineNo, readBytes)
if self._progress_callback is not None:
self._progress_callback(100.0)
self.extrusionAmount = maxExtrusion
self.extrusionVolume = [0] * len(maxExtrusion)
for i in range(len(maxExtrusion)):
radius = self._filamentDiameter / 2
self.extrusionVolume[i] = (
self.extrusionAmount[i] * (math.pi * radius * radius)
) / 1000
self.totalMoveTimeMinute = totalMoveTimeMinute
def _parseCuraProfileString(self, comment, prefix):
return {
key: value
for (key, value) in map(
lambda x: x.split(b"=", 1),
zlib.decompress(base64.b64decode(comment[len(prefix) :])).split(b"\b"),
)
}
def _intersectsAngle(self, start, end, angle):
if end < start and angle == 0:
# angle crosses 0 degrees
return True
else:
return start <= angle <= end
def _addArcMinMax(self, minmax, startAngle, endAngle, centerArc, radius):
startDeg = math.degrees(startAngle)
endDeg = math.degrees(endAngle)
if self._intersectsAngle(startDeg, endDeg, 0):
# arc crosses positive x
minmax.max.x = max(minmax.max.x, centerArc.x + radius)
if self._intersectsAngle(startDeg, endDeg, 90):
# arc crosses positive y
minmax.max.y = max(minmax.max.y, centerArc.y + radius)
if self._intersectsAngle(startDeg, endDeg, 180):
# arc crosses negative x
minmax.min.x = min(minmax.min.x, centerArc.x - radius)
if self._intersectsAngle(startDeg, endDeg, 270):
# arc crosses negative y
minmax.min.y = min(minmax.min.y, centerArc.y - radius)
def get_result(self):
result = {
"total_time": self.totalMoveTimeMinute,
"extrusion_length": self.extrusionAmount,
"extrusion_volume": self.extrusionVolume,
"dimensions": self.dimensions,
"printing_area": self.printing_area,
"travel_dimensions": self.travel_dimensions,
"travel_area": self.travel_area,
}
if self._incl_layers:
result["layers"] = self.layers
return result
def getCodeInt(line, code):
return getCode(line, code, int)
def getCodeFloat(line, code):
return getCode(line, code, float)
def getCode(line, code, c):
n = line.find(code) + 1
if n < 1:
return None
m = line.find(" ", n)
try:
if m < 0:
result = c(line[n:])
else:
result = c(line[n:m])
except ValueError:
return None
if math.isnan(result) or math.isinf(result):
return None
return result