Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ ENV/
env.bak/
venv.bak/

# uv lock file
uv.lock

# Spyder project settings
.spyderproject
.spyproject
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
35 changes: 26 additions & 9 deletions src/ucumvert/pint_ucum_defs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <function name="lgTimes2"/> 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 ####

Expand Down
26 changes: 25 additions & 1 deletion src/ucumvert/ucum_pint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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] --> <Quantity(1, 'meter * [IU]')> instead of <Quantity(1, 'milli[IU]')>
Expand All @@ -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]))
Expand Down Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions tests/test_ucum_pint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")