Search code examples
pythondecimalcalculation

Custom Python Decimal Class Arithmetic Error


I've been working on this project for a while: https://github.com/Mapy542/LaserOMS and have run into an issue while working on a fix for the error introduced by floating point calculations in python. I'm aware that the Python decimal module exists but it functions internally differently and this is more of an exercise of seeing if I could make the class work.

I've had issues with the Decimal class I've created. It holds a number in scientific notation: a value times 10 to a power. It seems to work during unit tests I've created, but not when integrated into the rest of the code. The financial statistics page breaks down orders and expenses that are tracked into sums by month and year, and the decimal class multiplies incorrectly in some cases by moving the decimal point around. The numeric values are consistent and correct, but 7 is very different from 7000.

I've attempted to find the error but am at a loss. It seems to be within either the from string in the initializer or the add or multiply functions.

The specific instance that didn't work is 1 * 7.20 would return 72 even though the decimal class of 7.20 would be 72 * 10 ^ -1. But 1 * 13.95 = 13.95

I've included the decimal class below as well as the other statistics function. I have also linked a database file if you wish to run the whole repo. Thanks in advance

import tinydb
from tinydb.storages import MemoryStorage

db = [
    {
        "expense_ID": 111,
        "expense_name": "LAST_EXPENSE",
        "process_status": "IGNORE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Hammer Stock",
        "expense_quantity": "1",
        "expense_unit_price": "8",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Wood Resupply",
        "expense_quantity": "1",
        "expense_unit_price": "95",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Resin Pan",
        "expense_quantity": "1",
        "expense_unit_price": "20",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Wood Resupply",
        "expense_quantity": "1",
        "expense_unit_price": "350",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Bandsaw",
        "expense_quantity": "1",
        "expense_unit_price": "250",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Bandsaw Blades",
        "expense_quantity": "1",
        "expense_unit_price": "50",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Word Clock Supplies",
        "expense_quantity": "1",
        "expense_unit_price": "80",
        "expense_notes": "\n",
        "expense_date": "12-12-2021",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "1/4 Spiral Endmill",
        "expense_quantity": "1",
        "expense_unit_price": "20",
        "expense_notes": "\n",
        "expense_date": "2-12-2022",
        "process_status": "UTILIZE",
        "expense_image_path": "",
    },
    {
        "expense_name": "Shipping_Label-_2878292147",
        "expense_quantity": "1",
        "expense_unit_price": "7.20",
        "expense_notes": "\n",
        "expense_date": "05-07-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2878292147.pdf",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Shipping_Label-_2876623213",
        "expense_quantity": "1",
        "expense_unit_price": "7.20",
        "expense_notes": "\n",
        "expense_date": "05-07-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2876623213.pdf",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Shipping_Label-_2882817955",
        "expense_quantity": "1",
        "expense_unit_price": "13.74",
        "expense_notes": "\n",
        "expense_date": "05-16-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2882817955.pdf",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Shipping_Label-_2883954451",
        "expense_quantity": "1",
        "expense_unit_price": "9.59",
        "expense_notes": "\n",
        "expense_date": "05-16-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2883954451.pdf",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Lowes_more_wood_and_paint",
        "expense_quantity": "1",
        "expense_unit_price": "165.11",
        "expense_notes": "\n\n",
        "expense_date": "05-18-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Lowes_more_wood_and_paint.JPG",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Shipping_Label-_2900550648",
        "expense_quantity": "1",
        "expense_unit_price": "7.00",
        "expense_notes": "\n",
        "expense_date": "05-26-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2900550648.pdf",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Shipping_Label-_2908328576",
        "expense_quantity": "1",
        "expense_unit_price": "7.00",
        "expense_notes": "\n",
        "expense_date": "05-31-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2908328576.pdf",
        "process_status": "UTILIZE",
    },
    {
        "expense_name": "Shipping_Label-_2909910334",
        "expense_quantity": "1",
        "expense_unit_price": "7.00",
        "expense_notes": "\n",
        "expense_date": "05-31-2023",
        "expense_image_path": "\\\\10.0.0.104\\LeboeufLasing\\09-LaserOMS\\LaserOMS-Images\\Shipping_Label-_2909910334.pdf",
        "process_status": "UTILIZE",
    },
]


class Decimal:  # Replacement for a float that has no floating point error
    def __init__(self, Number=0):
        """
        Args:
           Number (Int, String, Decimal): Number to be held as a decimal)
        """
        self.value = 0  # Holds the value of the decimal
        self.power = 0  # Holds the power of the decimal ie  value * 10^power

        if type(Number) == int:
            self.value = Number

        elif type(Number) == str:
            if "." in Number:
                while Number[-1] == "0":  # Remove trailing zeros
                    Number = Number[:-1]  # Remove last digit

                self.power = (
                    Number.index(".") - len(Number) + 1
                )  # Negative power as decimal is less than 1
                self.value = int(Number.replace(".", ""))

            else:
                self.value = int(Number)
            print(self.value, self.power)

        elif type(Number) == Decimal:
            self.value = Number.value
            self.power = Number.power

        elif type(Number) == float:
            StringFloat = str(Number)
            if "." in StringFloat:
                self.value = int(StringFloat.replace(".", ""))
                self.power = StringFloat.index(".") - len(StringFloat) + 1
            else:
                self.value = int(StringFloat)

        else:
            print(Number)
            raise TypeError  # If the type is not supported raise an error

        self.Simplify()  # Simplify the decimal

    def Simplify(self):
        """Simplifies the decimal by removing trailing zeros

        Returns:
            Decimal: Simplified decimal
        """

        if self.value == 0:  # If the value is 0 the power is 0
            self.power = 0
            return self

        while self.value % 10 == 0:
            self.value /= 10
            self.power += 1

        self.value = int(self.value)

        return self

    def __str__(self) -> str:  # Returns the decimal as a string
        Digits = []
        for i in range(len(str(abs(self.value)))):
            Digits.append(str(self.get_digit(self.value, i)))
        if self.value < 0:
            Digits.insert(0, "-")
        if self.power < 0:
            Digits.insert(len(Digits) + self.power, ".")
        return "".join(Digits)

    def __float__(self) -> float:  # Returns the decimal as a float
        return float(self.value * 10**self.power)

    def __int__(self) -> int:  # Returns the decimal as an int
        return int(self.value * 10**self.power)

    def get_digit(self, number, n):
        """Returns the nth digit of a number

        Args:
            number (int): Number to get the digit from
            n (int): Digit to get

        Returns:
            int: nth digit of number
        """
        if n < 0:
            raise IndexError  # If n is negative raise an error

        if n >= len(str(abs(number))):
            raise IndexError  # If n is greater than the number of digits in number raise an error

        number = abs(
            number
        )  # Make the number positive so that the index does not include a negative sign

        return int(str(number)[n])

    def add(self, Number2=0):
        """Adds the value of Number2 to the value of the decimal

        Args:
            Number2 (Int, String, Decimal): Number to be added to the decimal

        Returns:
            Decimal: Decimal with the value of Number2 added to it
        """
        if not type(Number2) == Decimal:
            Number2 = Decimal(Number2)

        if (
            not self.power == Number2.power
        ):  # If the powers are not equal make them equal by multiplying by 10^difference
            if self.power > Number2.power:
                self.value *= 10 ** (self.power - Number2.power)
                self.power -= self.power - Number2.power
            else:
                Number2.value *= 10 ** (Number2.power - self.power)
                self.power = Number2.power - (Number2.power - self.power)

        self.value += Number2.value  # Add the values
        self.Simplify()  # Simplify the decimal

        # return self

    def subtract(self, Number2=0):
        """Subtracts the value of Number2 from the value of the decimal

        Args:
            Number2 (Int, String, Decimal): Number to be subtracted from the decimal

        Returns:
            Decimal: Decimal with the value of Number2 subtracted from it
        """
        if not type(Number2) == Decimal:
            Number2 = Decimal(Number2)

        if (
            not self.power == Number2.power
        ):  # If the powers are not equal make them equal by multiplying by 10^difference
            if self.power > Number2.power:
                self.value *= 10 ** (self.power - Number2.power)
                self.power -= self.power - Number2.power
            else:
                Number2.value *= 10 ** (Number2.power - self.power)
                self.power = Number2.power - (Number2.power - self.power)

        self.value -= Number2.value  # Subtract the values
        self.Simplify()  # Simplify the decimal

        # return self

    def multiply(self, Number2=0):
        """Multiplies the value of the decimal by the value of Number2

        Args:
            Number2 (Int, String, Decimal): Number to be multiplied by the decimal

        Returns:
            Decimal: Decimal with the value of Number2 multiplied by it
        """
        if not type(Number2) == Decimal:  # If Number2 is not a decimal make it one
            Number2 = Decimal(Number2)

        self.value *= Number2.value  # Multiply the values
        self.power += Number2.power  # Add the powers

        print(self.value, self.power)

        self.Simplify()  # Simplify the decimal

        # return self

    def divide(self, Number2=1, Accuracy=2):
        """Divides the value of the decimal by the value of Number2

        Args:
            Number2 (Int, String, Decimal): Number to be divided by the decimal
            Accuracy (int, optional): Number of additional decimal places to calculate past the decimals of the two numbers. Defaults to 2.

        Returns:
            Decimal: Decimal with the value of itself divided by Number2
        """

        if not type(Number2) == Decimal:  # If Number2 is not a decimal make it one
            Number2 = Decimal(Number2)

        if Number2.value == 0:
            raise ZeroDivisionError  # If Number2 is 0 raise an error

        if self.value / Number2.value is int:  # If the value is evenly divisible
            self.value /= Number2.value
            self.power -= Number2.power
            self.Simplify()
            return self

        # Divide with accuracy given for passes of long division
        ResultantDigits = []  # List of digits in the resultant decimal

        Dividend = self.get_digit(
            self.value, 0
        )  # First digit of the dividend to be divided

        UsedAccuracy = 0  # Number of digits of accuracy used in loop
        ExtraPower = (
            -1
        )  # Power of the decimal to be added to the resultant decimal (is positive to show decimal place is moved x places to the left: 10^(-x))

        Number1Index = (
            0  # Index of the digit of the dividend that has been used in the loop
        )

        while UsedAccuracy < Accuracy:
            if Dividend == 0:  # If the dividend is 0
                break  # Break out of the loop as the division is complete

            if (
                Dividend // Number2.value >= 1
            ):  # If the dividend is wholely divisible by the divisor
                ResultantDigits.append(
                    Dividend // Number2.value
                )  # Add the result of the division to the resultant decimal
                Dividend = (
                    Dividend % Number2.value
                )  # Set the dividend to the remainder of the division
            else:  # If the dividend is not wholely divisible by the divisor
                ResultantDigits.append(0)  # Add a 0 to the resultant decimal

            if (
                Number1Index < len(str(self.value)) - 1
            ):  # If there are more digits of the dividend to be used
                Number1Index += (
                    1  # Increment the index of the digit of the dividend to be used
                )
                Dividend *= 10  # Multiply the dividend by 10 to add the next digit
                Dividend += self.get_digit(
                    self.value, Number1Index
                )  # Add the next digit of the dividend to the dividend
            else:  # If there are no more digits of the dividend to be used
                ExtraPower += 1  # Increment the power of the decimal to be added to the resultant decimal
                UsedAccuracy += (
                    1  # Increment the number of digits of accuracy used in the loop
                )
                Dividend *= 10  # Multiply the dividend by 10 to add a 0 to the dividend

        OriginalValue = self.value  # Store the original value of the decimal

        self.value = int(
            "".join(map(str, ResultantDigits))
        )  # Set the value of the decimal to the resultant decimal

        if (
            OriginalValue < 0 ^ Number2.value < 0
        ):  # If the original value of the decimal was negative or the divisor was negative
            self.value *= -1  # Make the resultant decimal negative

        self.power -= (
            Number2.power + ExtraPower
        )  # Set the power of the decimal to the resultant decimal

        # return self


def MonetarySubtract(Number1, Number2):
    """Subtracts two monetary values together with correct rounding for USD. (2 decimal places)

    Args:
        Number1 (Int, String, Decimal(Common/Class)): First Number to be subtracted
        Number2 (Int, String, Decimal(Common/Class)): Second Number to be subtracted

    Returns:
        Float: Difference of the two numbers
    """
    if not type(Number1) == Decimal:
        Number1 = Decimal(Number1)
    if not type(Number2) == Decimal:
        Number2 = Decimal(Number2)

    Number1.subtract(Number2)
    return round(Number1.__float__(), 2)


def GetExpenseStats(database):
    expenses = database.table("Expenses")  # load all expenses

    ActiveExpenses = expenses.search((tinydb.where("process_status") == "UTILIZE"))

    YearlyExpenses = {}
    MonthlyExpenses = {}

    for expense in ActiveExpenses:  # for each expense
        year = expense["expense_date"].split("-")[2]  # get year
        if len(year) > 4:
            # cut off the time or other data included after year
            year = year[: len(year) - 4]
        year = int(year)
        month = int(expense["expense_date"].split("-")[0])  # get month

        if year not in YearlyExpenses:  # add year if not in years
            YearlyExpenses[year] = Decimal("0")
            MonthlyExpenses[year] = {}
        # add month to months if not in months
        if month not in MonthlyExpenses[year]:
            MonthlyExpenses[year][month] = Decimal("0")

        total = Decimal(expense["expense_quantity"])
        total.multiply(expense["expense_unit_price"])  # calculate total
        YearlyExpenses[year].add(total)  # add total to applicable
        MonthlyExpenses[year][month].add(total)
        print(
            MonthlyExpenses[year][month],
            expense["expense_name"],
            month,
            total.value,
            total.power,
        )

    return YearlyExpenses, MonthlyExpenses


database = tinydb.TinyDB(storage=MemoryStorage)
database.table("Expenses").insert_multiple(db)

YearlyExpenses, MonthlyExpenses = GetExpenseStats(database)

for year in MonthlyExpenses:
    for month in MonthlyExpenses[year]:
        print(year, month, MonthlyExpenses[year][month])

DB file for running whole repo here. (Ignore settings conflict message): https://leboeuflasing.ddns.net/Downloads/OMS-Data.json


Solution

  • What an interesting problem. The bug is in your "add" and "subtract" routines.

    If the current exponent is LESS than the incoming exponent, you adjust the exponent of Number2, but you never put it back. You have permanently increased the value of Number2. Perhaps you should just create a temporary. Also note that the final statement serves no purpose. A - (A - B) is equal to B.

    By the same token, self.power -= self.power - Number2.power is the same as self.power = Number2.power, which is really what you want.

               if self.power > Number2.power:
                    self.value *= 10 ** (self.power - Number2.power)
                    self.power -= self.power - Number2.power
                else:
                    Number2.value *= 10 ** (Number2.power - self.power)
                    self.power = Number2.power - (Number2.power - self.power)