Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
874 changes: 794 additions & 80 deletions tools/parity/expected_changes.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions units/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The first public release. This is a further tidy-up after the 0.2.0 extraction:

- A unit spelled out from a unit set that isn't selected raises the new `DisallowedUnitError` (a `QuantityParseError`, with `.unit` and `.unit_sets`): with only `SI`, `10 litre`, `3 feet` and `5 °` are rejected, naming the set that has the unit. Before, most were read as letters in the value, so `10 litre` was `10*E*i*l*r*t` with no unit (the `e` read as Euler's number), and a few raised an unrelated error. Names, alternative spellings and plurals are checked, with or without a prefix; symbols (`L`, `h`) are still read as variables.
- **Angles are in radians.** The radian and the steradian are now 1, and the degree is `pi/180`. Before, the radian was `1/(2*pi)`, the steradian `1/(4*pi)` and the degree `1/360` (fractions of a turn), while the arcminute and the arcsecond were already in radians, so `60 angleminute` didn't equal `1 degree`. This changes `standard_value`, `unit_factor`, `expanded_unit` and `dimension` for every angle unit.
- **The angle is a dimension, and the hertz is a cycle per second.** The radian is a base unit (`RADIAN`, in the `common` set) with the dimension `angle`; the new `BASE_UNITS` holds it with `SI_BASE_UNITS`. The degree, arcminute and arcsecond are multiples of it, the steradian is `radian**2`, the lumen `candela*radian**2` and the lux `candela*radian**2/metre**2`. The hertz is `2*pi*radian/second`, so `f Hz` equals `2*pi*f rad/s`. Before, the radian was 1 and the hertz `1/second`, so `1 rad/s` equalled `1 Hz`. `1 rad` and `1`, and `1 Hz` and `1/s` (or `1 Bq`), now have different dimensions (`same_dimension` is false).
- `parse_quantity` raises only `QuantityError`s. A parser error (`slr_parsing.ParseError`/`ScanError`, e.g. for `2 m)`) and a value SymPy can't parse (`ExpressionParsingError`, e.g. `2 x;y`) are now raised as `QuantityParseError`, with the original error as `__cause__`. Every `QuantityError` carries `expression` and `name`.
- `QuantityParams` validates `unit_sets`: an unknown name (such as `"si"`) raises `QuantityParamsError`. Before, it was silently ignored, and no units were recognised. `from_dict` splits `units_string` on spaces and commas instead of testing for substrings, so `"SIcommon"` is rejected rather than read as both sets.
- An unknown `strictness` raises `QuantityParamsError` (with `.field`). It is still an `ExpressionParsingError`, so existing handlers keep working.
Expand Down
8 changes: 6 additions & 2 deletions units/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,17 @@ parse_quantity(text, params) # PhysicalQuantity(value='2', unit='micrometre')
| `standard_unit` | The unit in SI base units, without its numeric factor. |
| `expanded_unit` | The unit in SI base units, with its factor. |
| `unit_factor` | The factor between the unit and SI base units. |
| `base_dimension` | The unit's dimension in terms of `length`, `mass`, `time`, `electric_current`, `temperature`, `amount_of_substance` and `luminous_intensity` (`km/h` gives `length/time`). `1` for a quantity without a unit, and for angles. Compare two quantities with `same_dimension(a, b)`. |
| `base_dimension` | The unit's dimension in terms of `length`, `mass`, `time`, `electric_current`, `temperature`, `amount_of_substance`, `luminous_intensity` and `angle` (`km/h` gives `length/time`, `Hz` gives `angle/time`). `1` for a quantity without a unit. Compare two quantities with `same_dimension(a, b)`. |
| `dimension` | The same, but keeping the unit's factor: `km/h` gives `10**3*length/((3600*time))`. |
| `value_latex`, `unit_latex`, `latex` | LaTeX previews. |
| `reverted_units` | `RevertedUnit(before, marked, after)` records for unit-like text that was read as part of the value (e.g. `kg` in `2 kg + 3`). |

Mass is in **grams**, so that SI prefixes apply uniformly: `kilogram` expands
to `1000*gram`. Angles are dimensionless, with the radian equal to 1.
to `1000*gram`. **Angles have a dimension**, `angle`, with the radian as
its base unit: the degree is `pi/180*radian` and the steradian `radian**2`.
The hertz is a cycle per second, `2*pi*radian/second`, so `f Hz` equals
`2*pi*f rad/s`, while `1 Hz` and `1/s` (or `1 Bq`) have different
dimensions, as do `1 rad` and `1`.

## Parameters

Expand Down
4 changes: 4 additions & 0 deletions units/src/lambdafeedback/units/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
from ._split import MAX_UNIT_EXPONENT
from .data import (
ALL_UNITS,
BASE_UNITS,
COMMON_UNITS,
CONVERSION_TO_BASE_SI,
IMPERIAL_UNITS,
RADIAN,
SI_BASE_UNITS,
SI_DERIVED_UNITS,
SI_PREFIXES,
Expand Down Expand Up @@ -55,12 +57,14 @@

__all__ = [
"ALL_UNITS",
"BASE_UNITS",
"COMMON_UNITS",
"CONVERSION_TO_BASE_SI",
"IMPERIAL_UNITS",
"MAX_INPUT_LENGTH",
"MAX_LEGACY_LENGTH",
"MAX_UNIT_EXPONENT",
"RADIAN",
"SI_BASE_UNITS",
"SI_DERIVED_UNITS",
"SI_PREFIXES",
Expand Down
8 changes: 4 additions & 4 deletions units/src/lambdafeedback/units/_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@
)
from lambdafeedback.slr_parsing import ExprNode, SLRParser

from .data import CONVERSION_TO_BASE_SI, SI_BASE_UNITS, unsplittable_names
from .data import BASE_UNITS, CONVERSION_TO_BASE_SI, unsplittable_names
from .errors import UnitConversionError
from .params import QuantityParams
from .parser import Label

_BASE_UNIT_NAMES = frozenset(unit.name for unit in SI_BASE_UNITS)
_BASE_UNIT_NAMES = frozenset(unit.name for unit in BASE_UNITS)

_BASE_UNIT_DIMENSIONS = [(unit.name, unit.dimension) for unit in SI_BASE_UNITS]
_BASE_UNIT_DIMENSIONS = [(unit.name, unit.dimension) for unit in BASE_UNITS]

_DIMENSION_NAMES = frozenset(unit.dimension for unit in SI_BASE_UNITS)
_DIMENSION_NAMES = frozenset(unit.dimension for unit in BASE_UNITS)


def _without_factor(dimension: Basic) -> Basic:
Expand Down
40 changes: 24 additions & 16 deletions units/src/lambdafeedback/units/data.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Units and prefixes, and their conversion to SI base units.

Remarks: kelvin is the only temperature scale; angle measures, the bel and
the neper are all treated as dimensionless, with the radian and the
steradian equal to 1 (so ``degree`` is ``pi/180``). The gram (not the
kilogram) is the base unit of mass, so that SI prefixes apply uniformly.
Remarks: kelvin is the only temperature scale. The angle is a dimension, with
the radian as its base unit (:data:`RADIAN`): ``degree`` is ``pi/180*radian``,
the steradian is ``radian**2``, and the hertz is a cycle per second,
``2*pi*radian/second`` (so ``1 Hz`` is not ``1/s``, unlike the becquerel). The
bel and the neper are dimensionless. The gram (not the kilogram) is the base
unit of mass, so that SI prefixes apply uniformly.

Every unit set's symbols are recognised, and any SI prefix can be attached to
any unit (``kft`` is a kilofoot). Where a prefixed form is also the symbol of
Expand Down Expand Up @@ -86,10 +88,16 @@ class Unit(NamedTuple):
BaseUnit("candela", "cd", "luminous_intensity", ("Candela",), ("candelas", "Candelas")),
)

# 'r', not 'rad', to avoid a collision with the rad. In the common set, not SI.
RADIAN = BaseUnit("radian", "r", "angle", (), ("radians",))

BASE_UNITS: tuple[BaseUnit, ...] = (*SI_BASE_UNITS, RADIAN)
"""Every base unit: the SI ones and the radian. Unit expansions are in these units."""

# Table 3 of https://physics.nist.gov/cuu/Units/units.html. Radian and steradian are in
# VERY_COMMON_UNITS (fewer collisions when substituting); degree Celsius is omitted.
SI_DERIVED_UNITS: tuple[Unit, ...] = (
Unit("hertz", "Hz", "(second**(-1))", ("Hertz",)),
Unit("hertz", "Hz", "(2*pi*radian*second**(-1))", ("Hertz",)),
Unit("newton", "N", "(metre*kilogram*second**(-2))", ("Newton",), ("newtons", "Newtons")),
Unit("pascal", "Pa", "(metre**(-1)*kilogram*second**(-2))", ("Pascal",), ("pascals", "Pascals")),
Unit("joule", "J", "(metre**2*kilogram*second**(-2))", ("Joule",), ("joules", "Joules")),
Expand All @@ -108,8 +116,8 @@ class Unit(NamedTuple):
Unit("weber", "Wb", "(metre**2*kilogram*second**(-2)*ampere**(-1))", ("Weber",), ("webers", "Webers")),
Unit("tesla", "T", "(kilogram*second**(-2)*ampere**(-1))", ("Tesla",), ("teslas", "Teslas")),
Unit("henry", "H", "(metre**2*kilogram*second**(-2)*ampere**(-2))", ("Henry",), ("henrys", "Henrys")),
Unit("lumen", "lm", "(candela)", (), ("lumens",)),
Unit("lux", "lx", "(metre**(-2)*candela)"),
Unit("lumen", "lm", "(candela*radian**2)", (), ("lumens",)),
Unit("lux", "lx", "(metre**(-2)*candela*radian**2)"),
Unit("becquerel", "Bq", "(second**(-1))", ("Becquerel",), ("becquerels", "Becquerels")),
Unit("gray", "Gy", "(metre**2*second**(-2))", ("Gray",), ("grays", "Grays")),
Unit("sievert", "Sv", "(metre**2*second**(-2))", ("Sievert",), ("sieverts", "Sieverts")),
Expand All @@ -118,12 +126,12 @@ class Unit(NamedTuple):

# Tables 6 and 7 of https://physics.nist.gov/cuu/Units/outside.html: the subset whose
# symbols are accepted.
VERY_COMMON_UNITS: tuple[Unit, ...] = (
Unit("radian", "r", "(1)", (), ("radians",)), # 'r', not 'rad', to avoid a collision with the rad
Unit("steradian", "sr", "(1)", (), ("steradians",)),
VERY_COMMON_UNITS: tuple[BaseUnit | Unit, ...] = (
RADIAN,
Unit("steradian", "sr", "(radian**2)", (), ("steradians",)),
Unit("minute", "min", "(60*second)", (), ("minutes",)),
Unit("hour", "h", "(3600*second)", (), ("hours",)),
Unit("degree", "deg", "(pi/180)", ("°",), ("degrees",)),
Unit("degree", "deg", "(pi/180*radian)", ("°",), ("degrees",)),
Unit("litre", "L", "(10**(-3)*metre**3)", ("liter",), ("litres", "liters")),
Unit("metricton", "t", "(10**3*kilogram)", ("tonne",), ("tonnes",)),
Unit("neper", "Np", "(1)", ("Neper",), ("nepers", "Nepers")),
Expand All @@ -138,8 +146,8 @@ class Unit(NamedTuple):
# day, 'a' an are).
COMMON_UNITS: tuple[Unit, ...] = (
Unit("day", "d", "(86400*second)", (), ("days",)),
Unit("angleminute", "'", "(pi/10800)"),
Unit("anglesecond", '"', "(pi/648000)"),
Unit("angleminute", "'", "(pi/10800*radian)"),
Unit("anglesecond", '"', "(pi/648000*radian)"),
Unit("astronomical_unit", "au", "(149597870700*metre)", (), ("astronomical_units",)),
Unit("nautical_mile", "nmi", "(1852*metre)", (), ("nautical_miles", "nauticalmiles")), # symbol from Wikipedia
Unit("knot", "kn", "((1852/3600)*metre/second)", (), ("knots",)), # symbol from Wikipedia
Expand Down Expand Up @@ -197,16 +205,16 @@ def unsplittable_names(unit_sets: frozenset[str] | set[str], all_forms: bool) ->
alternative spellings and plurals (used for the value's preview) to the
unit names (used for parsing).
"""
names = {prefix.name for prefix in SI_PREFIXES} | {unit.name for unit in SI_BASE_UNITS}
names |= {unit.dimension for unit in SI_BASE_UNITS}
names = {prefix.name for prefix in SI_PREFIXES} | {unit.name for unit in BASE_UNITS}
names |= {unit.dimension for unit in BASE_UNITS}
for unit in units_in(unit_sets):
names |= {unit.name, unit.symbol, *unit.alternatives, *unit.plurals} if all_forms else {unit.name}
return sorted(names)


def _conversion_to_base_si() -> dict[str, str]:
unprefixed = {unit.name: unit.si_expansion for unit in ALL_UNITS if isinstance(unit, Unit)}
unprefixed.update({unit.name: unit.name for unit in SI_BASE_UNITS})
unprefixed.update({unit.name: unit.name for unit in BASE_UNITS})
prefixed = {
prefix.name + name: f"({prefix.factor}*{expansion})"
for prefix in SI_PREFIXES
Expand Down
11 changes: 6 additions & 5 deletions units/src/lambdafeedback/units/quantity.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ def _revert_value_text(value: ExprNode) -> tuple[RevertedUnit, ...]:
class PhysicalQuantity:
"""A response read as a quantity: its value and unit, their LaTeX, and SI forms.

Use :func:`parse_quantity` to create one. SymPy results use the SI base
units of :data:`~lambdafeedback.units.SI_BASE_UNITS` as symbols; mass is
in grams (``kilogram`` expands to ``1000*gram``).
Use :func:`parse_quantity` to create one. SymPy results use the base
units of :data:`~lambdafeedback.units.BASE_UNITS` (the SI ones and the
radian) as symbols; mass is in grams (``kilogram`` expands to ``1000*gram``).

Its attributes can't be set (it is frozen), but ``value``, ``unit`` and
``ast_root`` are ordinary parse trees. Quantities compare by identity.
Expand Down Expand Up @@ -252,7 +252,8 @@ def same_dimension(first: PhysicalQuantity, second: PhysicalQuantity) -> bool:
"""Whether two quantities have the same dimension, whatever their units' factors.

``5 km/h`` and ``3 m/s`` have the same dimension (``length/time``); so do
``1 L`` and ``1 m^3``. Quantities without units are dimensionless, as are
angles (``2 deg`` has the same dimension as ``2``).
``1 L`` and ``1 m^3``. Quantities without units are dimensionless. Angles
are not (``2 deg`` is an ``angle``, not ``2``), so ``1 Hz`` (a cycle per
second) has the dimension of ``1 rad/s``, not of ``1/s``.
"""
return bool(simplify(first.base_dimension - second.base_dimension) == 0)
29 changes: 22 additions & 7 deletions units/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from lambdafeedback.expression_parsing import ExpressionParsingError
from lambdafeedback.units import (
ALL_UNITS,
BASE_UNITS,
CONVERSION_TO_BASE_SI,
SI_BASE_UNITS,
SI_PREFIXES,
Expand Down Expand Up @@ -120,10 +121,10 @@ def test_unit_names_are_unique(self):
assert len(names) == len(set(names))

@pytest.mark.parametrize("unit", ALL_UNITS, ids=lambda unit: unit.name)
def test_every_unit_converts_to_si_base_units(self, unit):
def test_every_unit_converts_to_base_units(self, unit):
quantity = parse_quantity(f"1 {unit.name}", QuantityParams())
assert quantity.unit is not None
base_names = {base.name for base in SI_BASE_UNITS}
base_names = {base.name for base in BASE_UNITS}
assert {str(symbol) for symbol in quantity.expanded_unit.free_symbols} <= base_names


Expand All @@ -142,7 +143,9 @@ class TestDimensions:
("1 L", "length**3"),
("2 m^(1/2)", "sqrt(length)"),
("2 kΩ", "length**2*mass/(electric_current**2*time**3)"),
("10 deg", "1"),
("10 deg", "angle"),
("1 sr", "angle**2"),
("3 Hz", "angle/time"),
("2 x", "1"),
],
)
Expand All @@ -163,17 +166,28 @@ def test_dimension_keeps_the_factor(self):
("1 dm^3", "2 gal"),
("1 N", "1 kg m/s^2"),
("1 J", "1 eV"),
("1 Hz", "1/s"),
("1 Hz", "1 r/s"),
("2 m^0.5", "3 m^(1/2)"),
("10 deg", "2"),
("10 deg", "2 r"),
("kg", "3 lb"),
],
)
def test_same_dimension(self, left, right):
params = QuantityParams()
assert same_dimension(parse_quantity(left, params), parse_quantity(right, params))

@pytest.mark.parametrize("left, right", [("1 m", "1 s"), ("1 J", "1 N"), ("1 m^2", "1 m"), ("1 kg", "2")])
@pytest.mark.parametrize(
"left, right",
[
("1 m", "1 s"),
("1 J", "1 N"),
("1 m^2", "1 m"),
("1 kg", "2"),
("1 radian", "1"),
("1 Hz", "1/s"),
("1 Hz", "1 Bq"),
],
)
def test_different_dimension(self, left, right):
params = QuantityParams()
assert not same_dimension(parse_quantity(left, params), parse_quantity(right, params))
Expand All @@ -186,7 +200,8 @@ class TestConversions:
("60 angleminute", "1 degree"),
("60 anglesecond", "1 angleminute"),
("180 degree", "pi radian"),
("1 radian", "1"),
("1 sr", "1 radian^2"),
("1 Hz", "2 pi radian/s"),
("1 h", "60 min"),
("1 day", "24 hour"),
("1 L", "1 dm^3"),
Expand Down
17 changes: 15 additions & 2 deletions units/tests/test_regressions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Regression tests for bugs carried over from the v0.1 extraction."""

import pytest
from sympy import Basic, simplify
from sympy import Basic, pi, simplify

from lambdafeedback.slr_parsing import GROUP_LABEL, ExprNode
from lambdafeedback.units import (
Expand Down Expand Up @@ -39,9 +39,22 @@ def test_roentgen_is_charge_per_mass(self):

def test_angle_units_share_one_base(self):
# Degrees and radians were in turns but arcminutes in radians, so 60' != 1°.
assert parse("1 radian").unit_factor == 1
assert str(parse("1 radian").standard_unit) == "radian"
assert simplify(parse("60 angleminute").standard_values[0] - parse("1 degree").standard_values[0]) == 0

def test_hertz_is_a_cycle_per_second(self):
# 1 rad/s was 1 Hz: the radian was 1 and the hertz 1/s, losing the 2*pi per cycle.
hertz, radian_per_second = parse("1 Hz"), parse("1 radian/second")
assert hertz.standard_unit == radian_per_second.standard_unit
assert simplify(hertz.standard_values[0] / radian_per_second.standard_values[0] - 2 * pi) == 0

@pytest.mark.parametrize("params", [NATURAL, QuantityParams(strictness="natural", elementary_functions=True)])
def test_angular_frequency_matches_frequency(self, params):
# Upstream test_radians_to_frequency: 2*pi*f rad/s is f Hz.
angular, frequency = parse("2*pi*f radian/second", params), parse("f Hz", params)
assert angular.standard_unit == frequency.standard_unit
assert simplify(angular.standard_values[0] - frequency.standard_values[0]) == 0


class TestParsing:
@pytest.mark.parametrize("expr", ["(2 m) s", "(2 m) (s)", "(x m) s"])
Expand Down
Loading