12
\$\begingroup\$

I kinda got the idea after playing Adventure Capitalist, since that goes up to 10300. I found a website that explained how to go up to 10^3000, so I mainly did this as a challenge to see if I could do it.

Also, I'm fairly new to writing the documentation bits, so feedback on what I did well/not so well would be appreciated. I'm not expecting people to read the entire thing since it ended up quite long, just general feedback would be good.

Basically, it works by initially building a list of every number suffix (like thousand and million) up to nongennovemnonagintillion. Then, when you run the code, it'll probably do similar stuff to conversion codes, with the exception of going a lot higher.

It'll split the number into separate parts matching the exponential values (like 1550 is 1*10^3 + 5*10^2 + 50*10^0, or {3:1, 2:5, 0:50}), then add the remainder on after the iterations finish. I initially had it calculate the remainder too, like 2 hundredths and 62 millions, but I realised nobody speaks like that so took it out aha.

Then with converting that to text, I made a separate function to print any number as text between 0-999. Getting it all working initially was really easy, and most of the code is just fixes for various cases when things didn't work quite right (and I'm still occasionally finding bits that don't work).

Some examples:

>>> LargeNumber('154024500.5').to_text(use_fractions=True)
'154 million, 24 thousand, 5 hundred and 1/2'

>>> LargeNumber('154024500.5').to_text(use_fractions=True, digits=False)
'one hundred and fifty-four million, twenty-four thousand and five hundred point five'

>>> LargeNumber(Decimal('1'+'0'*2000)+Decimal('523.275')).to_text(digits=False)
'one hundred sescenquinsexagintillion, five hundred and twenty-three'

>>> LargeNumber(123456789).to_text(max_iterations=0, num_decimals=2)
'123.46 million'

The script including the documentation is over the character limit so I put it here.

Edit: Before reading the reply from Caridorc, I got all fractions working for text and digits (eg. 3.3333333333333 = 1/3rd or 3.33 = 33.3 hundredths). The result now runs about 10x slower due to the above code causing so many problems I had to patch over, but it's here :)

Here it is without the documentation anyway:

from decimal import Decimal, getcontext
class NumberNames:
    #Name lists for 0 to 999
    num_units = ['zero','one','two','three','four','five','six','seven','eight','nine']
    num_teens = ['ten','eleven','twelve']+[i+'teen' for i in ['thir','four','fif']+num_units[6:]]
    num_tens = [i+('ty' if i[-1]!='t' else 'y') for i in ['twen','thir','for','fif']+num_units[6:]]

    #Name rules for 10^33 to 10^300
    num_exp_prefix = ['', 'un','duo','tre','quattor','quin','sex','septen','octo','novem']
    num_exp_amount = ['']+[i+'int' for i in ['vig','trig','quadrag','quinquag','sexag','septuag','octog','nonag']]

    #Name rules for 10^303 to 10^3000
    num_exp_units = [i+'illion' for i in ['m','b','tr','quadr','quint','sext','sept','oct','non']]
    num_exp_tens = [j+'illion' for j in ['dec']+num_exp_amount[1:]]
    num_exp_hundreds = ['']+[i+'en' for i in ['c','duoc','trec','quadring','quing','sesc','septing','octing','nong']]

    #Set up dictionary and manually add hundred and thousand which don't follow the rules
    num_dict = {}
    num_dict[2] = 'hundred'
    num_dict[3] = 'thousand'

    #Create Million through Nonillion (10^6 to 10^30)
    exp_current = 6
    for i in num_exp_units:
        num_dict[exp_current] = i
        exp_current += 3

    #Iterate through exponential hundreds (cen+)
    for prefix_hundreds in num_exp_hundreds:
        #Iterate through exponential tens (decillion+)
        for prefix_tens in num_exp_tens:
            #Iterate through exponential amounts (un, duo, tre, etc)
            for prefix in num_exp_prefix:
                num_dict[exp_current] = prefix_hundreds+prefix+prefix_tens
                exp_current += 3

        #Add 'tillion' before 'decillion' after the first run
        if not prefix_hundreds:
            num_exp_tens = ['tillion']+num_exp_tens

    #Add zero
    num_dict[0] = ''
    num_dict[-1] = ''

    all_available_numbers = tuple(sorted(num_dict.keys()))

    @classmethod
    def num_to_text(self, input=0, as_digits=False, **kwargs):
        use_fractions = kwargs.get('use_fractions', True)
        only_return_decimal = kwargs.get('only_return_decimal', False)
        fraction_precision = kwargs.get('fraction_precision', 100)
        print_warning = kwargs.get('print_warning', False)

        negative_num = False
        #Fix for -0
        if isinstance(input, str):
            if input[0] == '-':
                input = input[1:]
                negative_num = True

        #Convert to float or int
        num_zeroes = 0
        input = str(input)

        #Get number of zeroes that are being removed to add again later
        no_zeroes = input
        if '.' in input:
            no_zeroes = input.rstrip('0')
            num_zeroes = len(input)-len(no_zeroes)

            #Fix number of zeros being 1 too high if all decimal points = 0
            if no_zeroes[-1] == '.':
                num_zeroes -= 1
                no_zeroes += '0'

        input = Decimal(no_zeroes)

        #Make sure number is positive
        if input < 0:
            input = input*-1
            negative_num = True

        #If only integers should be returned
        if as_digits:
            output = str(input)+'0'*num_zeroes

            #Re-add the negative sign
            if negative_num:
                output = '-' + output

            if '.' in output:

                #Split output for when it's been combined with another exponential value (must be between 0 and 1)
                decimal_output = '0.'+output.split('.')[1]

                #Attempt to calculate fraction of decimal point
                fraction_output = CalculateFraction.find(float(decimal_output), fraction_precision)
                if fraction_output and use_fractions:
                    fraction_suffix = CalculateFraction.suffix(*fraction_output)
                    fraction_prefix = ''

                    #Add prefix if not only returning the decimal
                    if not only_return_decimal:
                        fraction_prefix = output.split('.')[0]+' and '

                    return '{p}{0}/{1}{s}'.format(*fraction_output, s=fraction_suffix, p=fraction_prefix)

                else:
                    #Return decimal points if 0.x
                    if only_return_decimal:
                        return '.'+str(output.split('.')[1])
                    #Return number with decimals if not between 0 and 1
                    else:
                        return str(output)

            return output

        #If number out of range, recursively use LargeNumber
        if 0 > input or input > 999:
            if print_warning:
                print "Warning: Number out of range!"
            return LargeNumber(input).to_text(digits=as_digits)

        #Break down number into separate parts
        output_hundreds = int(input/100)
        output_tens = int((input%100)/10)
        output_units = int(input%10)
        output_decimals = str(input).split('.')

        #Fill output decimals with extra zeroes
        if len(output_decimals)>1:
            output_decimals[1] += '0'*num_zeroes

        output_text = ''
        #If number is negative
        if negative_num:
            output_text += 'negative '
        #If number is above 100
        if output_hundreds:
            output_text += self.num_units[output_hundreds] + ' hundred'
        #If last 2 digits are above 10
        if output_tens:
            if output_hundreds:
                output_text += ' and '
            if output_tens != 1:
                output_text += self.num_tens[output_tens-2]
            else:
                output_text += self.num_teens[output_units]
        #If last digit is above 0
        if output_units:
            #If last two digits are not between ten and nineteen (added in the tens)
            if output_tens != 1:
                if output_tens:
                    output_text += '-'
                elif output_hundreds:
                    output_text += ' and '
                output_text += self.num_units[output_units]
        #Add a zero
        if not (output_hundreds or output_tens or output_units) and not only_return_decimal:
            output_text += self.num_units[output_units]

        #Add the decimal points
        if len(output_decimals)>1:
            output_text += ' point'
            if only_return_decimal:
                output_text = 'point'

            #Write list of decimals
            for i in output_decimals[1]:
                output_text += ' '+self.num_units[int(i)]

        return output_text


class CalculateFraction(object):

    @staticmethod
    def find(input, precision=100):
        #Convert to float
        if not isinstance(input, float):
            input = float(input)

        #Make sure it is between 0 and 1
        original_input = int(input)+1
        if not (0 < input < 1):
            input = input%1

        #Loop through all calculations until a match is found
        for j in xrange(2, precision+1):
            j = float(j)
            for i in xrange(1, int(j)):
                if i/j == input:
                    return i*original_input, int(j)

    @staticmethod
    def suffix(x, y):
        #Convert to str and get important characters
        y = str(y)
        x = str(x)
        last_num = y[-1]
        try:
            second_num = y[-2]
        except IndexError:
            second_num = 0

        #Define rules
        if y == '1':
            suffix = ''
        elif last_num == '3':
            suffix = 'rd'
        elif second_num == '1':
            suffix = 'th'
        elif last_num == '2':
            if second_num:
                suffix = 'nd'
            else:
                suffix = ''
        elif last_num == '4' and not second_num:
            suffix = ''
        elif last_num == '1':
            suffix = 'st'
        else:
            suffix = 'th'

        #Make plural if x is above 1
        if x not in ('1', '0') and suffix:
            suffix += 's'

        return suffix   



class LargeNumber(Decimal):

    def __init__(self, input, **kwargs):

        #Copy over class objects from decimal
        Decimal.__init__(input)
        self.input = self.format_input(input, True)
        self.all_available_numbers = NumberNames.all_available_numbers

    def __repr__(self):
        #Remove the exponent if a large int/float value is input
        #Squared length seems to stop precision errors but increase if error happens
        if 'E+' in str(self.input):
            original_precision = getcontext().prec
            getcontext().prec = len(str(self.input))**2
            formatted_input = self.remove_exponent(self.input)
            getcontext().prec = original_precision
        else:
            formatted_input = self.input

        return "LargeNumber('{}')".format(formatted_input)

    @staticmethod
    def format_input(input, set_prec=False):
        input = str(input).replace(" ","").replace("\n","")
        getcontext().prec = max(28, len(input))
        return Decimal(input)

    @staticmethod
    def _round_with_precision(input, num_decimals=None, force_decimals=False):
        input = str(input)
        if '.' in str(input):
            original_input = input.rstrip('0')
        else:
            original_input = input+'.0'
        input = Decimal(original_input)

        #Accurately round the number
        if num_decimals is not None:

            #Increase the precision to stop errors on large num_decimals values
            current_context = getcontext().prec
            getcontext().prec = max(current_context, num_decimals*1.1)
            if num_decimals:
                input = input.quantize(Decimal('0.'+'0'*(num_decimals-1)+'1'))
            else:
                input = input.quantize(Decimal('1'))
            getcontext().prec = current_context
        input = str(input)

        if force_decimals:

            #Convert output to string and fill in decimals
            if '.' not in input:
                if num_decimals is not None:
                    input += '.'+'0'*num_decimals
                else:
                    input += '.0'

            elif num_decimals is not None:
                current_decimals = len(input.split('.')[1])
                input += '0'*(num_decimals-current_decimals)

        #Trim decimal places if not forcing decimals, and if decimal places haven't been cut off
        # eg. 70500 = 70.5 thousand, no matter the decimal places
        # however, 70500.005 = 70.500005, or to 3 decimal places is 70.500 thousand
        if not force_decimals and original_input == input.rstrip('0'):
            input = input.rstrip('0')
            if input[-1] == '.':
                input = input[:-1]

        return input

    @classmethod
    def remove_exponent(self, input):
        try:
            if input == input.to_integral():
                return input.quantize(Decimal(1))
            else:
                return input.normalize()
        except AttributeError:
            return self.remove_exponent(self.format_input(input))

    @staticmethod
    def _find_matching_exp(input, all_available_numbers):
        for i in xrange(len(all_available_numbers)):
            try:
                #Return first match
                if input < all_available_numbers[i+1]:
                    return all_available_numbers[i]
            #If number is higher than max index
            except IndexError:
                return all_available_numbers[i]

    def _calculate_number_parts(self, **kwargs):
        max_iterations = kwargs.get('max_iterations', -1)
        num_decimals = kwargs.get('num_decimals', None)
        force_decimals = kwargs.get('force_decimals', False)

        min_amount = Decimal(str(kwargs.get('min_amount', 1)))
        min_amount_limit = Decimal(str(kwargs.get('min_amount_limit', -1)))

        #If more than one iteration, set the min amount to 1 otherwise you get an infinite loop
        if max_iterations and min_amount < 1:
            min_amount = Decimal('1')

        min_offset = min_amount.logb()

        input = self.input
        num_output = {}
        num_exp = 1
        first_run = True       

        #Get multiplier from the min_amount variable
        min_amount_multiplier = Decimal(('%.2E'%min_amount).split('E')[0])

        #Match values to exponentials
        count = 0
        while num_exp > 0 and (input >= 1 or not count):

            #Reset min_amount to default
            if count == min_amount_limit:
                min_offset = Decimal('1').logb()
            count += 1

            #Figure which name to use
            if input:
                num_digits = (input/min_amount_multiplier).logb()
            else:
                #Fix to stop logb() error if input is zero
                num_digits = Decimal(1).logb()

            num_digits -= min_offset
            num_exp = self._find_matching_exp(num_digits, self.all_available_numbers)

            #Fix when given a high min_amount value that pushes num_exp below 0
            if num_exp <= 0:
                num_exp = 0

            #Fix for values between 0 and 1
            if -1 < input < 1:
                num_exp = 0

            #Get matching amount 
            current_multiplier = pow(Decimal(10), Decimal(num_exp))
            current_output = input/current_multiplier

            #Add to output
            if len(num_output)+1 > max_iterations and max_iterations >= 0:
                current_output = self._round_with_precision(current_output, num_decimals, force_decimals)
                num_output[num_exp] = (str(current_output))
                input = 0
                break

            else:

                #Continue
                num_output[num_exp] = (str(current_output).split('.')[0])
                input = input%pow(Decimal(10), Decimal(num_exp))

            #Make number positive after first run
            if input < 0:
                input *= -1

        num_output[-1] = str(input)

        #Re-run the code if only one iteration is output (basically a fix for low min_amount not working)
        if len(num_output) == 2 and min_amount != Decimal(str(kwargs.get('min_amount', 1))) and max_iterations != 0:
            kwargs['max_iterations'] = 0
            num_output = self._calculate_number_parts(**kwargs)

        return num_output


    def to_text(self, **kwargs):
        use_fractions = kwargs.get('use_fractions', False)
        fraction_precision = kwargs.get('fraction_precision', 100)

        num_decimals = kwargs.get('num_decimals', None)
        force_decimals = kwargs.get('force_decimals', False)
        min_decimals = kwargs.get('min_decimals', None)

        as_digits = kwargs.get('digits', True)

        num_name = []
        num_name_joined = ''
        num_output = self._calculate_number_parts(**kwargs)

        #Get remaining decimals for later and remove from output
        remaining_decimals = Decimal(num_output.pop(-1))

        #Fix for values between 0 and 1
        if not num_output:
            num_output[0] = 0

        #Fix to merge decimal with lowest exponential value to stop errors such as 1.000.35
        if force_decimals and remaining_decimals and num_output.get(0, 0):
            only_exponential = sorted(num_output.keys())[0]
            num_output[only_exponential] = str(self._round_with_precision(str(Decimal(num_output[only_exponential])+
                                                                              Decimal(remaining_decimals)*
                                                                              Decimal('0.'+'0'*(only_exponential-1)+'1')),
                                                                          num_decimals, force_decimals))
            remaining_decimals = 0

        #Convert numbers to words
        sorted_keys = sorted(num_output.keys())[::-1]
        for i in sorted_keys:
            current_value = num_output[i]
            additional_value = ''
            if NumberNames.num_dict[i]:
                additional_value = ' '+NumberNames.num_dict[i]

            #Avoid using fractions if using prefix (eg. 0.5 billion not 1/2 billion)
            should_use_fractions = use_fractions
            if i:
                should_use_fractions = False

            #Add decimals only if it's the last number, and there's not remaining decimals
            if i == sorted_keys[-1] and not remaining_decimals:

                if force_decimals:

                    if num_decimals is not None:

                        #Check if existing decimal points (fixes things like 46.500.000 million)
                        current_num_decimals = 0
                        if '.' in current_value:
                            current_num_decimals = len(current_value.split('.')[1])
                        else:
                            current_value += '.'
                        required_decimals = max(0, num_decimals-current_num_decimals)
                        current_value += '0'*required_decimals

                    else:
                        #Stip zeroes from end of number
                        if '.' in current_value:
                            current_value = current_value.rstrip('0')

                        #Only add zero if the last number is a decimal point, or also add a decimal point
                        if '.' not in current_value[:-1]:
                            if current_value[-1] != '.':
                                current_value += '.'
                            current_value += '0'

                    #Pad out the decimals
                    if min_decimals:
                        num_decimal_points = len(current_value.split('.')[1])
                        if min_decimals > num_decimal_points:
                            current_value += '0'*(min_decimals-num_decimal_points)

            text_num = NumberNames.num_to_text(current_value, as_digits, 
                                   use_fractions=should_use_fractions, 
                                   fraction_precision=fraction_precision,
                                   print_warning=True) + additional_value


            #Fix for min_amount under 0
            if i and current_value[:10] == 'zero point':
                text_num = text_num[5:]

            num_name.append(text_num)

        #Join list
        if len(num_name)-1:
            num_name_joined += ', '.join(num_name[:-1])
        #If there are decimals, don't use a final and
        if num_name_joined:
            if remaining_decimals and as_digits:
                num_name_joined += ', '
            else:
                num_name_joined += ' and '
        num_name_joined += num_name[-1]

        #Add decimal point
        if remaining_decimals:

            if not remaining_decimals:
                remaining_decimals = '0.'
                if num_decimals is not None:
                    remaining_decimals += '0'*num_decimals
                else:
                    remaining_decimals += '0'

            #Convert to text
            remaining_decimals = self._round_with_precision(remaining_decimals, num_decimals, force_decimals)

            decimal_num = NumberNames.num_to_text(remaining_decimals, as_digits, 
                                      use_fractions=use_fractions, 
                                      fraction_precision=fraction_precision,
                                      only_return_decimal=True,
                                      print_warning=True)

            #Add space before 'point five'
            if not as_digits:
                decimal_num = ' '+decimal_num

            #Fix to stop fractions not removing a zero (eg. 0 and 2/5ths should be 2/5ths)
            if '/' in decimal_num:
                if num_name_joined and num_name_joined != '0':
                    num_name_joined += ' and '
                else:
                    num_name_joined = ''

            #Fix if there are no units (1 hundred.5 to 1 hundred and 0.5)
            if 0 not in num_output:
                if as_digits and '/' not in decimal_num:
                    num_name_joined += ' and 0'
                else:
                    pass
                    #'one hundred point five' works, leaving this note here in case it needs to change
                    #num_name_joined += ' and zero'

            num_name_joined += decimal_num

        return num_name_joined

    def quick(self, num_decimals=3):
        kwargs = {}
        kwargs['max_iterations'] = 0
        kwargs['num_decimals'] = num_decimals
        kwargs['force_decimals'] = True
        kwargs['min_decimals'] = 3
        kwargs['use_fractions'] = False
        kwargs['digits'] = True
        return self.to_text(**kwargs)
\$\endgroup\$
  • \$\begingroup\$ Have a look at: codereview.stackexchange.com/questions/43744/… \$\endgroup\$ – sobolevn May 22 '15 at 17:20
  • \$\begingroup\$ I like some of the bits he did (no idea why I didn't go for a simpler negative number approach). His subThousand is very similar to my num_to_text, though I imagine anyone doing this ends up with a similar function to that haha. The splitByThousands bit is a really good idea, but that method wouldn't really work with my min_amount value, and also wouldn't allow extra things such as a googol since he's presuming the exponents are all 3 apart. It fails after 10^62 though, and he never got the decimals working, which is a bit surprising since he'd just need to print the ByOne values :) \$\endgroup\$ – Peter May 22 '15 at 23:48
4
\$\begingroup\$

Use syntax highlighting

Many nice modern IDEs have a feature called Syntax highlighting that will give nice colours to your code making it more readable and help you avoid shadowing built-in:

def remove_exponent(self, input):

should be:

def remove_exponent(self, input_):

to avoid deleting the native input function.

Be helpful

:) What a nice day to learn about this class:

>>> help(LargeNumber.__repr__)
Help on method __repr__ in module __main__:

__repr__(self) unbound __main__.LargeNumber method

:(

Instead if you change your code to be:

def __repr__(self):
    """
    Remove the exponent if a large int/float value is input
    Squared length seems to stop precision errors but increase if error happens
    """
    if 'E+' in str(self.input):
        original_precision = getcontext().prec
        getcontext().prec = len(str(self.input))**2
        formatted_input = self.remove_exponent(self.input)
        getcontext().prec = original_precision
    else:
        formatted_input = self.input

    return "LargeNumber('{}')".format(formatted_input)

The result is better:

>>> help(LargeNumber.__repr__)
Help on method __repr__ in module __main__:

__repr__(self) unbound __main__.LargeNumber method
    Remove the exponent if a large int/float value is input
    Squared length seems to stop precision errors but increase if error happens

:)

Must CalculateFraction be a class?

class CalculateFraction(object):

    @staticmethod
    def find(input, precision=100):
        #Convert to float
        if not isinstance(input, float):
            input = float(input)

        #Make sure it is between 0 and 1
        original_input = int(input)+1
        if not (0 < input < 1):
            input = input%1

        #Loop through all calculations until a match is found
        for j in xrange(2, precision+1):
            j = float(j)
            for i in xrange(1, int(j)):
                if i/j == input:
                    return i*original_input, int(j)

    @staticmethod
    def suffix(x, y):
        #Convert to str and get important characters
        y = str(y)
        x = str(x)
        last_num = y[-1]
        try:
            second_num = y[-2]
        except IndexError:
            second_num = 0

        #Define rules
        if y == '1':
            suffix = ''
        elif last_num == '3':
            suffix = 'rd'
        elif second_num == '1':
            suffix = 'th'
        elif last_num == '2':
            if second_num:
                suffix = 'nd'
            else:
                suffix = ''
        elif last_num == '4' and not second_num:
            suffix = ''
        elif last_num == '1':
            suffix = 'st'
        else:
            suffix = 'th'

        #Make plural if x is above 1
        if x not in ('1', '0') and suffix:
            suffix += 's'

        return suffix   

When you write staticmethod you mean 'this should be free floating but I put it in a class just because it belongs there logically. But your class contains only 2 staticmethod functions, I would let those float around.

Noisy commenting

For example:

#Iterate through exponential hundreds (cen+)
for prefix_hundreds in num_exp_hundreds:
    #Iterate through exponential tens (decillion+)
    for prefix_tens in num_exp_tens:
        #Iterate through exponential amounts (un, duo, tre, etc)
        for prefix in num_exp_prefix:
            num_dict[exp_current] = prefix_hundreds+prefix+prefix_tens
            exp_current += 3

Almost 1/3 of the lines are comments, and you are telling the what, it is like:

# a is assigned to 10
a = 10

Many of your comments can be removed and the code will improve as they are just noise ad will get obsolete. (Docstrings are great because they give general information, keep them all)

\$\endgroup\$
  • \$\begingroup\$ You write, "to avoid deleting the native input function" — presumably you mean "shadowing" rather than "deleting" (nothing gets deleted). Can you expand this advice to explain why it is important not to shadow the built-in function here? \$\endgroup\$ – Gareth Rees May 24 '15 at 10:01
  • \$\begingroup\$ Thanks a lot for the feedback :) I was wondering if there was something wrong with using input and self.input separately, though with the help(LargeNumber.__repr__) bit, am I supposed to be returning exactly what calculation is performed as opposed to what output is expected? You got my thought process nailed on grouping the fraction class haha, is it really that bad using classes to group similar functions? \$\endgroup\$ – Peter May 25 '15 at 10:01
  • \$\begingroup\$ I did kinda realise I added a few more comments than usual, though to be honest I find them really useful to keep track of what's going on. Before I never commented much, which made it a total nightmare to go back to old code since I'd have to print the values of variables for 10+ mins just to figure out which part does what :) \$\endgroup\$ – Peter May 25 '15 at 10:04
  • \$\begingroup\$ @GarethRees shadowing a built-in ( a thing similar to max = 'foo' is bad because will make it impossible to use the (say) max function. \$\endgroup\$ – Caridorc May 25 '15 at 18:51
  • \$\begingroup\$ @Caridorc: But in this case the function does not use the built-in input, so that can't be the reason. \$\endgroup\$ – Gareth Rees May 25 '15 at 18:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.