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]")