Skip to content

Commit

Permalink
Merge pull request #22 from bwoodsend/metric
Browse files Browse the repository at this point in the history
  • Loading branch information
hugovk committed Jun 19, 2022
2 parents 7688f20 + 19726a0 commit 29d37fb
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 23 deletions.
2 changes: 2 additions & 0 deletions src/humanize/__init__.py
Expand Up @@ -8,6 +8,7 @@
fractional,
intcomma,
intword,
metric,
ordinal,
scientific,
)
Expand Down Expand Up @@ -38,6 +39,7 @@
"fractional",
"intcomma",
"intword",
"metric",
"naturaldate",
"naturalday",
"naturaldelta",
Expand Down
84 changes: 62 additions & 22 deletions src/humanize/number.py
Expand Up @@ -337,7 +337,7 @@ def scientific(value: NumberOrString, precision: int = 2) -> str:
>>> scientific(int(500))
'5.00 x 10²'
>>> scientific(-1000)
'1.00 x 10³'
'-1.00 x 10³'
>>> scientific(1000, 1)
'1.0 x 10³'
>>> scientific(1000, 3)
Expand Down Expand Up @@ -369,35 +369,19 @@ def scientific(value: NumberOrString, precision: int = 2) -> str:
"7": "⁷",
"8": "⁸",
"9": "⁹",
"+": "⁺",
"-": "⁻",
}
negative = False
try:
if "-" in str(value):
value = str(value).replace("-", "")
negative = True

if isinstance(value, str):
value = float(value)

fmt = "{:.%se}" % str(int(precision))
n = fmt.format(value)

value = float(value)
except (ValueError, TypeError):
return str(value)

fmt = "{:.%se}" % str(int(precision))
n = fmt.format(value)
part1, part2 = n.split("e")
if "-0" in part2:
part2 = part2.replace("-0", "-")

if "+0" in part2:
part2 = part2.replace("+0", "")
# Remove redundant leading '+' or '0's (preserving the last '0' for 10⁰).
part2 = re.sub(r"^\+?(\-?)0*(.+)$", r"\1\2", part2)

new_part2 = []
if negative:
new_part2.append(exponents["-"])

for char in part2:
new_part2.append(exponents[char])

Expand Down Expand Up @@ -474,3 +458,59 @@ def clamp(
"Invalid format. Must be either a valid formatting string, or a function "
"that accepts value and returns a string."
)


def metric(value: float, unit: str = "", precision: int = 3) -> str:
"""Return a value with a metric SI unit-prefix appended.
Examples:
```pycon
>>> metric(1500, "V")
'1.50 kV'
>>> metric(2e8, "W")
'200 MW'
>>> metric(220e-6, "F")
'220 μF'
>>> metric(1e-14, precision=4)
'10.00 f'
```
The unit prefix is always chosen so that non-significant zero digits are required.
i.e. `123,000` will become `123k` instead of `0.123M` and `1,230,000` will become
`1.23M` instead of `1230K`. For numbers that are either too huge or too tiny to
represent without resorting to either leading or trailing zeroes, it falls back to
`scientific()`.
```pycon
>>> metric(1e40)
'1.00 x 10⁴⁰'
```
Args:
value (int, float): Input number.
unit (str): Optional base unit.
precision (int): The number of digits the output should contain.
Returns:
str:
"""
exponent = int(math.floor(math.log10(abs(value))))

if exponent >= 27 or exponent < -24:
return scientific(value, precision - 1) + unit

value /= 10 ** (exponent // 3 * 3)
if exponent >= 3:
ordinal = "kMGTPEZY"[exponent // 3 - 1]
elif exponent < 0:
ordinal = "mμnpfazy"[(-exponent - 1) // 3]
else:
ordinal = ""
value_ = format(value, ".%if" % (precision - (exponent % 3) - 1))
if not (unit or ordinal) or unit in ("°", "′", "″"):
space = ""
else:
space = " "

return f"{value_}{space}{ordinal}{unit}"
43 changes: 42 additions & 1 deletion tests/test_number.py
Expand Up @@ -144,7 +144,7 @@ def test_fractional(test_input: float | str, expected: str) -> None:
"test_args, expected",
[
([1000], "1.00 x 10³"),
([-1000], "1.00 x 10³"),
([-1000], "-1.00 x 10³"),
([5.5], "5.50 x 10⁰"),
([5781651000], "5.78 x 10⁹"),
(["1000"], "1.00 x 10³"),
Expand All @@ -156,6 +156,10 @@ def test_fractional(test_input: float | str, expected: str) -> None:
([float(0.3), 1], "3.0 x 10⁻¹"),
([1000, 0], "1 x 10³"),
([float(0.3), 0], "3 x 10⁻¹"),
([float(1e20)], "1.00 x 10²⁰"),
([float(2e-20)], "2.00 x 10⁻²⁰"),
([float(-3e20)], "-3.00 x 10²⁰"),
([float(-4e-20)], "-4.00 x 10⁻²⁰"),
],
)
def test_scientific(test_args: list[typing.Any], expected: str) -> None:
Expand All @@ -177,3 +181,40 @@ def test_scientific(test_args: list[typing.Any], expected: str) -> None:
)
def test_clamp(test_args: list[typing.Any], expected: str) -> None:
assert humanize.clamp(*test_args) == expected


@pytest.mark.parametrize(
"test_args, expected",
[
([1, "Hz"], "1.00 Hz"),
([1.0, "W"], "1.00 W"),
([3, "C"], "3.00 C"),
([3, "W", 5], "3.0000 W"),
([1.23456], "1.23"),
([12.3456], "12.3"),
([123.456], "123"),
([1234.56], "1.23 k"),
([12345, "", 6], "12.3450 k"),
([200_000], "200 k"),
([1e25, "m"], "10.0 Ym"),
([1e26, "m"], "100 Ym"),
([1e27, "A"], "1.00 x 10²⁷A"),
([1.234e28, "A"], "1.23 x 10²⁸A"),
([-1500, "V"], "-1.50 kV"),
([0.12], "120 m"),
([0.012], "12.0 m"),
([0.0012], "1.20 m"),
([0.00012], "120 μ"),
([1e-23], "10.0 y"),
([1e-24], "1.00 y"),
([1e-25], "1.00 x 10⁻²⁵"),
([1e-26], "1.00 x 10⁻²⁶"),
([1, "°"], "1.00°"),
([0.1, "°"], "100m°"),
([100], "100"),
([0.1], "100 m"),
],
ids=str,
)
def test_metric(test_args: list[typing.Any], expected: str) -> None:
assert humanize.metric(*test_args) == expected

0 comments on commit 29d37fb

Please sign in to comment.