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)
subThousand
is very similar to mynum_to_text
, though I imagine anyone doing this ends up with a similar function to that haha. ThesplitByThousands
bit is a really good idea, but that method wouldn't really work with mymin_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 theByOne
values :) \$\endgroup\$