Skip to content

Commit

Permalink
Correcting an issue where prefixes weren't round-tripping correctly. (#…
Browse files Browse the repository at this point in the history
…60)

This was a persistent issue with the test suite, randomly failing a
hypothesis
example occasionally.  I had some confusion in the string formatting for
prefixed units, where the precedence would get out of whack. For
example, when
formatting `(Kilo*Meter)**2`, we'd format that as `Mm²` because a square
kilometer is a million square meters; however the parsing would treat
that as
if it were a `(Mega*Meter)**2`. There were also a few more issues and a
test
flake in MathML formatting.
  • Loading branch information
chrisguidry authored Oct 22, 2023
1 parent 11d5566 commit ac5afdd
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 37 deletions.
104 changes: 70 additions & 34 deletions src/measured/formatting.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import math
from functools import wraps
from typing import TYPE_CHECKING, Callable, TypeVar
from typing import TYPE_CHECKING, Callable, Optional, Sequence, Tuple, TypeVar

from typing_extensions import TypeAlias

if TYPE_CHECKING: # pragma: no cover
from IPython.lib.pretty import RepresentationPrinter
Expand Down Expand Up @@ -199,29 +201,49 @@ def unit_repr(unit: "Unit") -> str:
)


def unit_str(unit: "Unit") -> str:
"""Formats the given Unit as a plaintext string"""
if unit.symbol:
return unit.symbol
UnitTerm: TypeAlias = Tuple["Prefix", Optional[str], int]


def _unit_to_magnitude_and_terms(
unit: "Unit",
) -> Tuple["Numeric", Sequence[UnitTerm]]:
from measured import FractionalDimensionError

# In order to handle cases like `Mega * (Meter**-1)`, which naively becomes
# "Mm⁻¹", which looks like it should parse to `(Mega*Meter)**-1`, take this
# unit's prefix and push it down as the prefix of the first factor, which would
# turn `Mega * (Meter**-1)` into the correct `(Micro*Meter)**-1`.
#
# While it seems odd to have this in `str`, it's just a side-effect of the
# string represeentations not having parentheses.
# string representations not having parentheses.
first, *rest = [
(factor.prefix, factor.symbol, exponent)
for factor, exponent in unit.factors.items()
]

magnitude: Numeric = 1
prefix, symbol, exponent = first
sign = 1 if exponent >= 0 else -1
first = ((unit.prefix * prefix) ** sign, symbol, exponent)
prefix = unit.prefix * prefix
try:
prefix = prefix.root(exponent)
first = (prefix, symbol, exponent)
except FractionalDimensionError:
magnitude = prefix.quantify()

return magnitude, [first, *rest]

return "⋅".join(
f"{prefix}{symbol}{superscript(exponent)}"
for prefix, symbol, exponent in [first, *rest]

def unit_str(unit: "Unit") -> str:
"""Formats the given Unit as a plaintext string"""
if unit.symbol:
return unit.symbol

magnitude, terms = _unit_to_magnitude_and_terms(unit)
return (str(magnitude) + " " if magnitude != 1 else "") + (
"⋅".join(
f"{prefix}{symbol}{superscript(exponent)}"
for prefix, symbol, exponent in terms
)
)


Expand Down Expand Up @@ -260,27 +282,9 @@ def unit_pretty(unit: "Unit", pretty: "RepresentationPrinter", cycle: bool) -> N
pretty.text(repr(unit))


def unit_mathml(unit: "Unit") -> str:
"""Formats the given Unit as a MathML expression"""
if unit.symbol:
return f"<mi>{unit.symbol}</mi>"

# In order to handle cases like `Mega * (Meter**-1)`, which naively becomes
# "Mm⁻¹", which looks like it should parse to `(Mega*Meter)**-1`, take this
# unit's prefix and push it down as the prefix of the first factor, which would
# turn `Mega * (Meter**-1)` into the correct `(Micro*Meter)**-1`.
#
# While it seems odd to have this in `str`, it's just a side-effect of the
# string represeentations not having parentheses.
first, *rest = [
(unit.prefix, unit.symbol, exponent) for unit, exponent in unit.factors.items()
]
prefix, symbol, exponent = first
sign = 1 if exponent >= 0 else -1
first = ((unit.prefix * prefix) ** sign, symbol, exponent)

numerator = [(p, s, e) for p, s, e in [first, *rest] if e >= 0]
denominator = [(p, s, e * -1) for p, s, e in [first, *rest] if e < 0]
def _unit_terms_mathml(magnitude: "Numeric", terms: Sequence[UnitTerm]) -> str:
numerator = [(p, s, e) for p, s, e in terms if e >= 0]
denominator = [(p, s, e * -1) for p, s, e in terms if e < 0]

n = (
"<mo>⋅</mo>".join(
Expand All @@ -296,6 +300,8 @@ def unit_mathml(unit: "Unit") -> str:
)
or "<mi>1</mi>"
)
if magnitude != 1:
n = f"<mn>{magnitude}</mn><mo>⋅</mo>" + n

d = "<mo>⋅</mo>".join(
(
Expand All @@ -315,14 +321,33 @@ def unit_mathml(unit: "Unit") -> str:
return f"<mfrac><mrow>{n}</mrow><mrow>{d}</mrow></mfrac>"


def unit_mathml(unit: "Unit") -> str:
"""Formats the given Unit as a MathML expression"""
if unit.symbol:
return f"<mi>{unit.symbol}</mi>"

magnitude, terms = _unit_to_magnitude_and_terms(unit)
return _unit_terms_mathml(magnitude, terms)


def quantity_repr(quantity: "Quantity") -> str:
"""Formats the given Quantity as a Python `repr`"""
return f"Quantity(magnitude={quantity.magnitude!r}, unit={quantity.unit!r})"


def quantity_str(quantity: "Quantity") -> str:
"""Formats the given Quantity as a plaintext string"""
return f"{quantity.magnitude} {quantity.unit}"
if quantity.unit.symbol:
return f"{quantity.magnitude} {quantity.unit.symbol}"

unit_magnitude, unit_terms = _unit_to_magnitude_and_terms(quantity.unit)
quantity = quantity * unit_magnitude
return f"{quantity.magnitude} " + (
"⋅".join(
f"{prefix}{symbol}{superscript(exponent)}"
for prefix, symbol, exponent in unit_terms
)
)


def quantity_format(quantity: "Quantity", format_specifier: str) -> str:
Expand Down Expand Up @@ -351,11 +376,22 @@ def quantity_pretty(

def quantity_mathml(quantity: "Quantity") -> str:
"""Formats the given Quantity as a MathML expression"""
if quantity.unit.symbol:
return (
"<mrow>"
f"<mn>{quantity.magnitude}</mn>"
"<mo></mo>"
f"<mi>{quantity.unit.symbol}</mi>"
"</mrow>"
)

unit_magnitude, unit_terms = _unit_to_magnitude_and_terms(quantity.unit)
quantity = quantity * unit_magnitude
return (
"<mrow>"
f"<mn>{quantity.magnitude}</mn>"
"<mo></mo>"
f"{unit_mathml(quantity.unit)}"
f"{_unit_terms_mathml(1, unit_terms)}"
"</mrow>"
)

Expand Down
30 changes: 27 additions & 3 deletions tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from IPython.lib.pretty import RepresentationPrinter

from measured import systems # noqa: F401
from measured import (
Area,
Decibel,
Expand All @@ -29,6 +30,7 @@
Volume,
VolumetricFlow,
)
from measured.electronics import dBW
from measured.formatting import superscript
from measured.si import Hertz, Kilo, Mega, Meter, Milli, Ohm, Second, Watt

Expand Down Expand Up @@ -92,6 +94,8 @@ def test_strings_of_dimensions(dimension: Dimension, string: str) -> None:
(Meter, "m"),
(Meter**2, "m²"),
(Meter**3, "m³"),
((Kilo * Meter) ** 2, "km²"),
(Kilo * Meter**2, "1000 m²"),
(Meter**3 / Second**2, "m³⋅s⁻²"),
(Hertz, "Hz"),
(Hertz**2, "s⁻²"),
Expand All @@ -109,10 +113,13 @@ def test_strings_of_units(unit: Unit, string: str) -> None:
(5 * Meter, "5 m"),
(5.1 * Meter**2, "5.1 m²"),
(5 * (Kilo * Meter), "5 km"),
(5.1 * (Kilo * (Meter**2) / Second), "5.1 km²⋅s⁻¹"),
(5.1 * (Kilo * Meter**2) / Second, "5.1 km²⋅s⁻¹"),
(5.1 * (Kilo * Meter) ** 2 / Second, "5.1 Mm²⋅s⁻¹"),
(5.1 * (Kilo * Meter) ** 2, "5.1 km²"),
(5.1 * (Kilo * (Meter**2)), "5100.0 m²"),
(5.1 * (Kilo * (Meter**2) / Second), "5100.0 m²⋅s⁻¹"),
(5.1 * (Kilo * Meter**2) / Second, "5100.0 m²⋅s⁻¹"),
(5.1 * (Kilo * Meter) ** 2 / Second, "5.1 km²⋅s⁻¹"),
(5.1 * (Mega * Meter**-1), "5.1 μm⁻¹"),
(5.1 * ((Mega * Meter) ** -1), "5.1 Mm⁻¹"),
],
)
def test_strings_of_quantities(quantity: Quantity, string: str) -> None:
Expand Down Expand Up @@ -212,6 +219,15 @@ def test_pretty_repr_includes_string_of_self(
Neper,
Decibel[1 * Watt],
30 * Decibel[1 * Watt],
(Kilo * Meter) ** 2,
(Kilo * (Meter**2)),
(5.1 * (Kilo * Meter) ** 2),
(5.1 * (Kilo * (Meter**2))),
(5.1 * (Kilo * (Meter**2) / Second)),
(5.1 * (Kilo * Meter**2) / Second),
(5.1 * (Kilo * Meter) ** 2 / Second),
(5.1 * (Mega * Meter**-1)),
(5.1 * ((Mega * Meter) ** -1)),
],
)
def test_html_is_mathml(formattable: Formattable) -> None:
Expand All @@ -238,6 +254,7 @@ def test_mathml_root_is_fraction(formattable: Formattable) -> None:
Meter,
Hertz,
Neper,
dBW,
Decibel[1 * Watt],
],
)
Expand All @@ -257,6 +274,13 @@ def test_mathml_root_is_identifier(formattable: Formattable) -> None:
Decibel[1 * Meter], # a dB that we wouldn't have a symbol for
Neper[1 * Meter],
30 * Decibel[1 * Watt],
(5.1 * (Kilo * Meter) ** 2),
(5.1 * (Kilo * (Meter**2))),
(5.1 * (Kilo * (Meter**2) / Second)),
(5.1 * (Kilo * Meter**2) / Second),
(5.1 * (Kilo * Meter) ** 2 / Second),
(5.1 * (Mega * Meter**-1)),
(5.1 * ((Mega * Meter) ** -1)),
],
)
def test_mathml_root_is_subexpression(formattable: Formattable) -> None:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from measured.parsing import ParseError
from measured.si import (
Ampere,
Centi,
Gram,
Hertz,
Kilo,
Expand Down Expand Up @@ -41,12 +42,33 @@ def test_kilogram_converts_to_kilogram() -> None:
assert Unit.parse(str(Kilo * Gram)) is Kilogram


def test_prefixes_have_precedence_over_exponents() -> None:
"""
From Wikipedia (https://en.wikipedia.org/wiki/Square_kilometre):
The symbol "km²" means (km)², square kilometre or kilometre squared and not
k(m²), kilo-square metre. For example, 3 km² is equal to 3*(1,000m)² = 3,000,000
m², not 3,000 m².
"""
assert Unit.parse("km²") == (Kilo * Meter) ** 2 == Mega * Meter**2
assert str(Unit.parse("km²")) == "km²"

assert Quantity.parse("3 km²") == 3 * (Kilo * Meter) ** 2 == 3 * Mega * Meter**2
assert str(Quantity.parse("3 km²")) == "3 km²"


@given(
magnitude=integers(min_value=-1000000000000, max_value=1000000000000),
unit=units(),
)
@example(magnitude=1, unit=Liter)
@example(magnitude=1, unit=Mega * (Meter**-1))
@example(magnitude=1, unit=(Kilo * Meter) ** 2)
@example(magnitude=1, unit=Kilo * (Meter**2))
@example(magnitude=5, unit=(Mega * Meter**-1))
@example(magnitude=5, unit=((Mega * Meter) ** -1))
@example(magnitude=1, unit=Centi * (Meter**-2))
@example(magnitude=1, unit=(Centi * Meter) ** -2)
def test_small_integer_quantities_parse_exactly(magnitude: Numeric, unit: Unit) -> None:
assert Quantity.parse(str(magnitude * unit)) == magnitude * unit

Expand Down

0 comments on commit ac5afdd

Please sign in to comment.