diff --git a/.gitignore b/.gitignore index c6a432f..fce1561 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,9 @@ ENV/ env.bak/ venv.bak/ +# uv lock file +uv.lock + # Spyder project settings .spyderproject .spyproject diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49a2a78..da87a38 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,14 +2,14 @@ default_language_version: python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: end-of-file-fixer - id: check-yaml - id: check-toml - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.9 + rev: v0.15.15 hooks: # Run the linter. - id: ruff @@ -18,7 +18,7 @@ repos: - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell additional_dependencies: diff --git a/src/ucumvert/pint_ucum_defs.txt b/src/ucumvert/pint_ucum_defs.txt index 1ff2f05..57c36cc 100644 --- a/src/ucumvert/pint_ucum_defs.txt +++ b/src/ucumvert/pint_ucum_defs.txt @@ -164,15 +164,32 @@ bel = 1 ; logbase: 10; logfactor: # pH_value = 1 * mole / liter; logbase: 10; logfactor: -1 # mol/l is reduced to mol - A pint issue? -bel_spl = 1 sound_pressure_level; logbase: 10; logfactor: 10 = B_SPL - -bel_volt = 1 volt; logbase: 10; logfactor: 10 = B_V -bel_millivolt = 1 millivolt; logbase: 10; logfactor: 10 = B_mV -bel_microvolt = 1 microvolt; logbase: 10; logfactor: 10 = B_uV -bel_10nanovolt = 10 nanovolt; logbase: 10; logfactor: 10 = B_10nV - -bel_watt = 1 watt; logbase: 10; logfactor: 10 = B_W -bel_kilowatt = 1 kilowatt; logbase: 10; logfactor: 10 = B_kW +# Field-level bels use UCUM's "2lg" function (value = 2*log10(x/ref)), so +# logfactor 2; power-level bels use "lg" (value = log10(x/ref)), so logfactor 1 +# (empty). See ucum-essence.xml vs "lg". +# TODO: cite UCUM spec section for lg/2lg special-unit functions. +bel_spl = 1 sound_pressure_level; logbase: 10; logfactor: 2 = B_SPL + +bel_volt = 1 volt; logbase: 10; logfactor: 2 = B_V +bel_millivolt = 1 millivolt; logbase: 10; logfactor: 2 = B_mV +bel_microvolt = 1 microvolt; logbase: 10; logfactor: 2 = B_uV +bel_10nanovolt = 10 nanovolt; logbase: 10; logfactor: 2 = B_10nV + +bel_watt = 1 watt; logbase: 10; logfactor: = B_W +bel_kilowatt = 1 kilowatt; logbase: 10; logfactor: = B_kW + +# Decibel forms of the special "levels" units. In UCUM these are the metric +# prefix "d" applied to the bel units (e.g. dB[W]); in pint a prefixed +# logarithmic unit is a distinct unit (decibel is not deci*bel), so they are +# defined explicitly. The decibel scales the bel by 10, so field-level forms +# use logfactor 20 ("2lg") and power-level forms use logfactor 10 ("lg"). +# decibel (dB) and decibelwatt (dBW) already exist in pint and are reused. +decibel_spl = 1 sound_pressure_level; logbase: 10; logfactor: 20 +decibel_volt = 1 volt; logbase: 10; logfactor: 20 +decibel_millivolt = 1 millivolt; logbase: 10; logfactor: 20 +decibel_microvolt = 1 microvolt; logbase: 10; logfactor: 20 +decibel_10nanovolt = 10 nanovolt; logbase: 10; logfactor: 20 +decibel_kilowatt = 1 kilowatt; logbase: 10; logfactor: 10 #### UNIT GROUPS #### diff --git a/src/ucumvert/ucum_pint.py b/src/ucumvert/ucum_pint.py index 6ba98f7..299190d 100644 --- a/src/ucumvert/ucum_pint.py +++ b/src/ucumvert/ucum_pint.py @@ -106,6 +106,22 @@ "[h]": "planck_constant", } +# UCUM logarithmic ("special", class "levels") units like the bel are +# non-multiplicative. In pint a prefixed logarithmic unit is a *distinct* unit +# (decibel is not deci*bel), so prefixed UCUM forms cannot be built by +# prepending the prefix to the atom. Map the supported decibel forms to pint. +MAPPINGS_UCUM_PREFIXED_TO_PINT = { + # "UCUM_prefix+atom": "pint_unit_name" + "dB": "decibel", + "dB[SPL]": "decibel_spl", + "dB[V]": "decibel_volt", + "dB[mV]": "decibel_millivolt", + "dB[uV]": "decibel_microvolt", + "dB[10.nV]": "decibel_10nanovolt", + "dB[W]": "decibelwatt", + "dB[kW]": "decibel_kilowatt", +} + class UcumToPintTransformer(Transformer): def __init__(self, ureg=None): @@ -154,6 +170,9 @@ def component(self, args): def simple_unit(self, args): # print("DBGsu>", repr(args), len(args)) if len(args) == 2: # prefix is present # noqa: PLR2004 + prefixed = str(args[0]) + str(args[1]) + if prefixed in MAPPINGS_UCUM_PREFIXED_TO_PINT: + return self.ureg(MAPPINGS_UCUM_PREFIXED_TO_PINT[prefixed]) # Work around a pint bug: parsing of abbreviated custom unit with prefix # that could be a unit (k,m,M) does not detect the prefix but 2 units. # e.g. m[IU] --> instead of @@ -163,7 +182,9 @@ def simple_unit(self, args): with contextlib.suppress(UndefinedUnitError): return self.ureg(args[0] + str(self.ureg(args[1]).units)) - return self.ureg(args[0] + MAPPINGS_UCUM_TO_PINT.get(str(args[1]), str(args[1]))) + return self.ureg( + args[0] + MAPPINGS_UCUM_TO_PINT.get(str(args[1]), str(args[1])) + ) # Substitute UCUM atoms that cannot be defined in pint as units or aliases. return self.ureg(MAPPINGS_UCUM_TO_PINT.get(args[0], args[0])) @@ -211,6 +232,9 @@ def component(self, args): def simple_unit(self, args): # print("DBGsu>", repr(args), len(args)) if len(args) == 2: # prefix is present # noqa: PLR2004 + prefixed = f"{args[0]}{args[1]}" + if prefixed in MAPPINGS_UCUM_PREFIXED_TO_PINT: + return f"({MAPPINGS_UCUM_PREFIXED_TO_PINT[prefixed]})" return f"({args[0]}{args[1]})" # Substitute UCUM atoms that cannot be defined in pint as units or aliases. diff --git a/tests/test_ucum_pint.py b/tests/test_ucum_pint.py index b5744a2..05403d3 100644 --- a/tests/test_ucum_pint.py +++ b/tests/test_ucum_pint.py @@ -126,3 +126,75 @@ def test_prefix_with_unit_that_is_also_a_prefix_issue24(ucum_parser, ureg_ucumve result_str = UcumToPintStrTransformer().transform(parsed_data) assert result_str == "(((m[IU]) / (L)))" + + +def test_decibel_is_not_decibyte_issue62(ucum_parser, transform_ucum_pint): + # "dB" is deci + bel (decibel), it must not be parsed as deci + byte. + parsed_data = ucum_parser.parse("dB") + result = transform_ucum_pint(parsed_data) + assert str(result.units) == "decibel" + + +# UCUM decibel atoms (metric prefix "d" + special "levels" unit) with the +# pint unit they map to and a (reference unit, expected linear value) pair. +# Power-level units (W, kW) use UCUM's "lg": 1 dB means factor 10**(1/10). +# Field-level units (V, SPL, ...) use "2lg": 1 dB means factor 10**(1/20). +decibel_atoms = { + "dB[W]": ("decibelwatt", "W", 10 ** (1 / 10)), + "dB[kW]": ("decibel_kilowatt", "W", 1000 * 10 ** (1 / 10)), + "dB[V]": ("decibel_volt", "V", 10 ** (1 / 20)), + "dB[mV]": ("decibel_millivolt", "V", 1e-3 * 10 ** (1 / 20)), + "dB[uV]": ("decibel_microvolt", "V", 1e-6 * 10 ** (1 / 20)), + "dB[10.nV]": ("decibel_10nanovolt", "V", 10e-9 * 10 ** (1 / 20)), + "dB[SPL]": ("decibel_spl", "Pa", 20e-6 * 10 ** (1 / 20)), +} + + +@pytest.mark.parametrize( + ("ucum_code", "expected"), + decibel_atoms.items(), + ids=list(decibel_atoms), +) +def test_decibel_reference_units_issue62( + ucum_parser, transform_ucum_pint, ucum_code, expected +): + pint_name, ref_unit, expected_value = expected + result = transform_ucum_pint(ucum_parser.parse(ucum_code)) + assert str(result.units) == pint_name + assert result.to(ref_unit).magnitude == pytest.approx(expected_value) + + +@pytest.mark.parametrize( + ("ucum_code", "ref_unit", "expected_value"), + [ + # power level "lg": 1 bel means factor 10**1 over the reference + ("B[W]", "W", 10), + # field level "2lg": 1 bel means factor 10**(1/2) over the reference + ("B[V]", "V", 10 ** (1 / 2)), + ], +) +def test_bel_reference_units_are_true_bels_issue62( + ucum_parser, transform_ucum_pint, ucum_code, ref_unit, expected_value +): + # Without prefix these are *bels*, not decibels: a logarithmic value of 1 + # means factor 10 (power) or 10**(1/2) (field) over the reference, not the + # 10**(1/10) / 10**(1/20) of the decibel forms. + result = transform_ucum_pint(ucum_parser.parse(ucum_code)) + assert result.to(ref_unit).magnitude == pytest.approx(expected_value) + + +def test_decibel_str_transformer_matches_pint_issue62( + ucum_parser, transform_ucum_pint, transform_ucum_str, ureg_ucumvert +): + for ucum_code in ["dB", *decibel_atoms]: + parsed_data = ucum_parser.parse(ucum_code) + expected_quantity = transform_ucum_pint(parsed_data) + result_str = transform_ucum_str(parsed_data) + assert ureg_ucumvert(result_str) == expected_quantity + + +def test_decibel_milliwatt_is_invalid_ucum_issue62(ucum_parser): + # UCUM defines B[W] and B[kW] but no milliwatt reference, so "dB[mW]" + # (pint's dBm) has no UCUM representation and must not parse. + with pytest.raises(LarkError): + ucum_parser.parse("dB[mW]")