Minimal-width formatting of decimal approximations
This module supplies tools to help create nice decimal literals
for numeric types, particularly of type fractions.Fraction.
Let's set up some helpers. This file is executed by Python's doctest module to verify all the examples work exactly as shown, so we need to define everything we use.
>>> from frac2dec import format_fixed, frac2dec
>>> from fractions import Fraction as F
>>> from decimal import Decimal as D
>>> def show(fs, **kws):
... for s in format_fixed(fs, **kws):
... print(s)format_fixed(fs, /, *, minfrac=0, extra=0) ia used to format a compact
column of numbers. fs is an iterable of numbers, which can be of any
type convertible to Fraction (int, float, Fraction, Decimal). Such
conversions are exact.
Return a list of strings, one per input number. Each string is a decimal fixed-point literal, correctly rounded (nearest/even) to the number of digits given. All strings have the same length, and the same number of digits after the decimal point, and are right-justified (if needed) with blanks on the left. So they make a nice, readable visual display if printed on successive lines.
In general, and this is the real point, the fewest number of fractional digits are used so that non-equal inputs produce different strings.
If all values are exact integers, there is no decimal point:
>>> args = [8, .125 * 64, D('.008e3'), F(240, 3)]
>>> show(args)
8
8
8
80However, a minimum number of fractional digits can be asked for:
>>> show(args, minfrac=2)
8.00
8.00
8.00
80.00If not all inputs are exact integers, at least one fractional digit will be produced even if all the inputs round to different integers:
>>> show([7, 8.01, 9])
7.0
8.0
9.0Keyword argument extra can also be used to add that many additional
fractional digits than would otherwise be produced.
>>> import math
>>> pic = 3, F(22, 7), F(333, 106), F(355, 113)
>>> print("convergents to pi:", *pic)
convergents to pi: 3 22/7 333/106 355/113
>>> print(math.pi); show(pic)
3.141592653589793
3.0000
3.1429
3.1415
3.1416
>>> print(math.pi); show(pic, extra=3)
3.141592653589793
3.0000000
3.1428571
3.1415094
3.1415929Now for a useful case 😉. Suppose you have large Fractions to display. That's hard to do in a readable way!
>>> from random import randrange, seed
>>> seed(123443210)
>>> pick = lambda: randrange(10**30)
>>> args = sorted(F(pick(), pick()) for i in range(8))
>>> for f in args:
... print(f)
100383010337217770703803396671/873361031336288422121036518616
46287472345949520949917544096/351877543542290630359106324699
209242411848949090107270270805/972625171789306895730155705174
137906878848974880762102969909/438084787371253463626047013078
505578408574423373749931556697/960216838779667583921785159607
348837406967948132874002110042/644965611579915714804829263423
407533651144918602524371484247/16370165895327991366035343826
7474266912747442329837528014/140583509049204837043199255Quite a mess! Behold:
>>> show(args)
0.11
0.13
0.22
0.31
0.53
0.54
24.89
53.17Those are correctly rounded decimal approximations, with just enough fractional digits so that all strings are pairwise distinct.
Caution: if you have just one distinct small number, it will probably round to 0:
>>> show([-80.1, 1e-12, 1e-12])
-80.1
0.0
0.0However, if you have more than one, enough fractional digits will be produced so that they can be distinguished:
>>> show([-80.1, 1e-22, 1e-23])
-80.0999999999999943156581
0.0000000000000000000001
0.0000000000000000000000What happened to -80.1? That's in fact the correctly rounded decimal approximation to the binary approximation floats use for -80.1.
>>> D(-80.1) # thw exact decimal value
Decimal('-80.099999999999994315658113919198513031005859375')Error cases:
>>> format_fixed()
Traceback (most recent call last):
...
TypeError: format_fixed() missing 1 required positional argument: 'fs'
>>> format_fixed([1], 0)
Traceback (most recent call last):
...
TypeError: format_fixed() takes 1 positional argument but 2 were given
>>> format_fixed([1], minfrac=-1)
Traceback (most recent call last):
...
ValueError: ('minfrac must be >= 0, not', -1)
>>> format_fixed([1], extra=-1)
Traceback (most recent call last):
...
ValueError: ('extra must be >= 0, not', -1)c = frac2dec(f) creates an object that can be used to show
increasingly accurate decimal-string approximations to the number f's
infinitely precise value.
f can be anything convertible to a fraction.Fraction. c.get(nfrac)
returns a decimal literal string, correctly rounded (nearest/even)
to nfrac fractional digits.
Before .get() is called, c.nfrac is -1, and c.exact is True if
and only if the value is an exact integer (no fractional part).
>>> anint = frac2dec(D("0.103e4"))
>>> anint.nfrac; anint.exact
-1
True
>>> notint = frac2dec(D("10.3"))
>>> notint.nfrac; notint.exact
-1
FalseAfter calling c.get(nfrac), c.nfrac is nfrac, and c.exact is
True if and only if the string returned reproduces the exact infinitely
precise value. When .exact is True, calling get() with larger
nfrac values will only add trailing zeroes.
>>> c = frac2dec(F(-7, 4))
>>> for nfrac in range(5):
... print(c.get(nfrac), c.nfrac, c.exact)
-2 0 False
-1.8 1 False
-1.75 2 True
-1.750 3 True
-1.7500 4 TrueFor any float input, c.exact will eventually become True as nfrac
increases. Bur c.exact will always remain False for an input like
Fraction(2, 3).
>>> c = frac2dec(0.1)
>>> for nfrac in range(505):
... ignore = c.get(nfrac)
... if c.exact:
... break
>>> print("exact at nfrac =", c.nfrac)
exact at nfrac = 55
>>> print(c.get(c.nfrac))
0.1000000000000000055511151231257827021181583404541015625
>>> print(D(.1)) # the same
0.1000000000000000055511151231257827021181583404541015625
>>> c.get(c.nfrac) == str(D(0.1)) # verify!
True
>>> c = frac2dec(F(2, 3))
>>> for nfrac in range(8):
... print(c.get(nfrac), c.exact)
1 False
0.7 False
0.67 False
0.667 False
0.6667 False
0.66667 False
0.666667 False
0.6666667 False
>>> c.get(-1)
Traceback (most recent call last):
...
ValueError: ('nfrac must be >= 0 not', -1)Q:: Why would anyone want this?
A: You may not! It's niche. It came up in the context of voting software for proportional representation election methods, where reweighting ballots can create fractions with very large denominators (over 100 decimal digits). The exact values matter for the purpose of figuring out who wins, but for human display along the way such very long values are incomprehensible. It just needs to display enough information so that humans can easily see the relative order of aggregate candidate scores. A mere handful of decimal digits total is typically enough for this purpose, and the fewer the better.
Note: in that context, while denominators can become very large, so can numerators. An aggregate score is typically in a range from 0 to a small multiple of the number of voters. So rhe number of digits in the integer part of the ratio is always reasonably small. That's the why focus here is on minimizing the number of fractional digits.
Q: How accurate is this?
A: While it produces decimal literals that approximate the
infinitely precise values, the method used to produce them is exact: no
float arithmetic is used, only exact integer arithmetic (including exact
Fraction arithmetic). Results should be reproducible across all
hardware and Python releases.
Q: How fast is it? There's suspiciously little code.
A: Yes, speed wasn't a goal. Simplicity was. The code is straightforward and "almost obviously" correct. Those are primary goals in this project; I have no interest in speeding any of it at the cost of clarity.