Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/uncertainty dec place rounding #171

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
17 changes: 5 additions & 12 deletions src/sciform/format_utils/make_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,6 @@ def construct_num_str(
def construct_val_unc_str( # noqa: PLR0913
val_mantissa_str: str,
unc_mantissa_str: str,
val_mantissa: Decimal,
unc_mantissa: Decimal,
decimal_separator: DecimalSeparatorEnums,
*,
paren_uncertainty: bool,
Expand All @@ -113,17 +111,12 @@ def construct_val_unc_str( # noqa: PLR0913
pm_symb = f" {pm_symb} "
val_unc_str = f"{val_mantissa_str}{pm_symb}{unc_mantissa_str}"
else:
if (
paren_uncertainty_trim
and unc_mantissa.is_finite()
and val_mantissa.is_finite()
and 0 < unc_mantissa < abs(val_mantissa)
):
if paren_uncertainty_trim:
"""
Don't strip the unc_mantissa_str if val_mantissa is non-finite.
Don't strip the unc_mantissa_str if unc_mantissa == 0 (because then the
empty string would remain).
Don't left strip the unc_mantissa_str if unc_mantissa >= val_mantissa
Strip out all leading zeros and separators except the
decimal separator if it has non-zero numbers to the left.
(000_000.004 567) -> (4567)
(0_123.456 789) -> (123.456)
"""
for separator in SeparatorEnum:
if separator != decimal_separator:
Expand Down
5 changes: 3 additions & 2 deletions src/sciform/format_utils/rounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,13 @@ def round_val_unc(
val: Decimal,
unc: Decimal,
ndigits: int | NDigitsEnum,
round_mode: RoundModeEnum,
) -> tuple[Decimal, Decimal, int]:
"""Simultaneously round the value and uncertainty."""
if unc.is_finite() and unc != 0:
round_digit = get_round_dec_place(
unc,
RoundModeEnum.SIG_FIG,
round_mode,
ndigits,
)
unc_rounded = round(unc, -round_digit)
Expand All @@ -97,7 +98,7 @@ def round_val_unc(
elif val.is_finite() and val != 0:
round_digit = get_round_dec_place(
val,
RoundModeEnum.SIG_FIG,
round_mode,
ndigits,
)
unc_rounded = unc
Expand Down
107 changes: 65 additions & 42 deletions src/sciform/formatting/number_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from __future__ import annotations

import re
from dataclasses import replace
from decimal import Decimal
from typing import TYPE_CHECKING, cast
from warnings import warn

from sciform.api.formatted_number import FormattedNumber
from sciform.format_utils.exponents import get_exp_str, get_val_unc_exp
Expand All @@ -28,6 +28,7 @@
ExpFormatEnum,
ExpModeEnum,
ExpValEnum,
NDigitsEnum,
RoundModeEnum,
SignModeEnum,
)
Expand All @@ -38,6 +39,47 @@
from sciform.options.input_options import InputOptions


def re_round_mantissa_exp_decomposition(
number: Number,
exp_mode: ExpModeEnum,
input_exp_val: int | ExpValEnum,
round_mode: RoundModeEnum,
ndigits: int | NDigitsEnum,
) -> tuple[Decimal, int, int, int]:
"""Decompose a number into a mantissa and exponent using repeated rounding."""
first_mantissa, first_exp_val, base = get_mantissa_exp_base(
number,
exp_mode,
input_exp_val,
)
first_round_digit = get_round_dec_place(first_mantissa, round_mode, ndigits)
first_mantissa_rounded = round(first_mantissa, -first_round_digit)

number_rounded = first_mantissa_rounded * base ** Decimal(first_exp_val)

"""
Repeat mantissa + exponent discovery after rounding in case rounding
altered the required exponent.
"""
second_mantissa, exp_val, _ = get_mantissa_exp_base(
number_rounded, exp_mode, input_exp_val
)
round_digit = get_round_dec_place(second_mantissa, round_mode, ndigits)
mantissa = round(second_mantissa, -round_digit)
mantissa = cast(Decimal, mantissa)

if mantissa == 0:
"""
This catches an edge case involving negative ndigits when the
resulting mantissa is zero after the second rounding. This
result is technically correct (e.g. 0e+03 = 0e+00), but sciform
always presents zero values with an exponent of zero.
"""
exp_val = 0

return mantissa, exp_val, base, round_digit


def format_from_options(
value: Number,
uncertainty: Number | None = None,
Expand Down Expand Up @@ -117,38 +159,15 @@ def format_num(num: Decimal, options: FinalizedOptions) -> str:
num *= 100
num = num.normalize()

exp_val = options.exp_val
round_mode = options.round_mode
exp_mode = options.exp_mode
ndigits = options.ndigits
mantissa, temp_exp_val, base = get_mantissa_exp_base(num, exp_mode, exp_val)
round_digit = get_round_dec_place(mantissa, round_mode, ndigits)
mantissa_rounded = round(mantissa, -round_digit)

"""
Repeat mantissa + exponent discovery after rounding in case rounding
altered the required exponent.
"""
rounded_num = mantissa_rounded * Decimal(base) ** Decimal(temp_exp_val)
mantissa, exp_val, base = get_mantissa_exp_base(rounded_num, exp_mode, exp_val)
round_digit = get_round_dec_place(mantissa, round_mode, ndigits)
mantissa_rounded = round(mantissa, -int(round_digit))
mantissa_rounded = cast(Decimal, mantissa_rounded)

if mantissa_rounded == 0:
"""
This catches an edge case involving negative ndigits when the
resulting mantissa is zero after the second rounding. This
result is technically correct (e.g. 0e+03 = 0e+00), but sciform
always presents zero values with an exponent of zero.
"""
exp_val = 0
mantissa, exp_val, base, round_dec_place = re_round_mantissa_exp_decomposition(
num, options.exp_mode, options.exp_val, options.round_mode, options.ndigits
)

left_pad_char = options.left_pad_char.value
mantissa_str = construct_num_str(
mantissa_rounded.normalize(),
mantissa.normalize(),
options.left_pad_dec_place,
round_digit,
round_dec_place,
options.sign_mode,
left_pad_char,
)
Expand All @@ -166,7 +185,7 @@ def format_num(num: Decimal, options: FinalizedOptions) -> str:

exp_str = get_exp_str(
exp_val=exp_val,
exp_mode=exp_mode,
exp_mode=options.exp_mode,
exp_format=options.exp_format,
capitalize=options.capitalize,
superscript=options.superscript,
Expand All @@ -190,13 +209,6 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str
)
raise NotImplementedError(msg)

if options.round_mode is RoundModeEnum.DEC_PLACE:
msg = (
"Precision round mode not available for value/uncertainty formatting. "
"Rounding is always applied as significant figures for the uncertainty."
)
warn(msg, stacklevel=2)

unc = abs(unc)
if exp_mode is ExpModeEnum.PERCENT:
val *= 100
Expand All @@ -220,11 +232,13 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str
val,
unc,
options.ndigits,
options.round_mode,
)
val_rounded, unc_rounded, round_digit = round_val_unc(
val_rounded,
unc_rounded,
options.ndigits,
options.round_mode,
)

exp_val = get_val_unc_exp(
Expand All @@ -234,6 +248,11 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str
options.exp_val,
)

if options.round_mode is RoundModeEnum.SIG_FIG:
ndigits = -round_digit + exp_val
else:
ndigits = options.ndigits

val_mantissa, _, _ = get_mantissa_exp_base(
val_rounded,
exp_mode=exp_mode,
Expand All @@ -252,8 +271,6 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str
left_pad_matching=options.left_pad_matching,
)

ndigits = -round_digit + exp_val

"""
We will format the val and unc mantissas
* using decimal place rounding mode with the ndigits calculated
Expand Down Expand Up @@ -289,15 +306,21 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str
val_mantissa_str = parse_mantissa_from_ascii_exp_str(val_mantissa_exp_str)
unc_mantissa_str = parse_mantissa_from_ascii_exp_str(unc_mantissa_exp_str)

paren_uncertainty_trim = (
options.paren_uncertainty_trim
and val.is_finite()
and unc.is_finite()
and unc < abs(val)
and re.search(r"[1-9]", unc_mantissa_str) is not None
)

val_unc_str = construct_val_unc_str(
val_mantissa_str=val_mantissa_str,
unc_mantissa_str=unc_mantissa_str,
val_mantissa=val_mantissa,
unc_mantissa=unc_mantissa,
decimal_separator=options.decimal_separator,
paren_uncertainty=options.paren_uncertainty,
pm_whitespace=options.pm_whitespace,
paren_uncertainty_trim=options.paren_uncertainty_trim,
paren_uncertainty_trim=paren_uncertainty_trim,
)

if val.is_finite() or unc.is_finite() or options.nan_inf_exp:
Expand Down
20 changes: 16 additions & 4 deletions tests/feature/test_val_unc_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,29 @@ def test_paren_unc_invalid_unc(self):
[
(Formatter(paren_uncertainty=True), "0(0)"),
(Formatter(paren_uncertainty=True, ndigits=3), "0(0)"),
(
Formatter(
paren_uncertainty=True,
round_mode="dec_place",
ndigits=3,
),
"0.000(0.000)",
),
],
),
(
(0, float("inf")),
[
(Formatter(paren_uncertainty=True), "0(inf)"),
(Formatter(paren_uncertainty=True, ndigits=3), "0(inf)"),
(
Formatter(
paren_uncertainty=True,
round_mode="dec_place",
ndigits=3,
),
"0.000(inf)",
),
],
),
]
Expand Down Expand Up @@ -426,10 +442,6 @@ def test_pdg_sig_figs(self):

self.run_val_unc_formatter_cases(cases_list)

def test_dec_place_warn(self):
formatter = Formatter(round_mode="dec_place")
self.assertWarns(Warning, formatter, 42, 24)

def test_left_pad_matching(self):
formatter = Formatter(left_pad_matching=True)
result = formatter(123, 0.123)
Expand Down
32 changes: 32 additions & 0 deletions tests/feature/test_val_unc_fsml.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ def test_fixed(self):
("!3f", "123.456 ± 0.789"),
("!4f", "123.4560 ± 0.7890"),
("!2f()", "123.46(79)"),
(".-3f", "0 ± 0"),
(".-2f", "100 ± 0"),
(".-1f", "120 ± 0"),
(".0f", "123 ± 1"),
(".1f", "123.5 ± 0.8"),
(".2f", "123.46 ± 0.79"),
(".3f", "123.456 ± 0.789"),
(".4f", "123.4560 ± 0.7890"),
(".2f()", "123.46(79)"),
],
),
(
Expand Down Expand Up @@ -65,6 +74,24 @@ def test_fixed(self):
("!5f()", "0.79(123.46)"),
("!6f()", "0.789(123.456)"),
("!7f()", "0.7890(123.4560)"),
(".1f", "0.8 ± 123.5"),
(".2f", "0.79 ± 123.46"),
(".3f", "0.789 ± 123.456"),
(".4f", "0.7890 ± 123.4560"),
(".5f", "0.78900 ± 123.45600"),
(".6f", "0.789000 ± 123.456000"),
(".7f", "0.7890000 ± 123.4560000"),
(".-3f()", "0(0)"),
(".-2f()", "0(100)"),
(".-1f()", "0(120)"),
(".0f()", "1(123)"),
(".1f()", "0.8(123.5)"),
(".2f()", "0.79(123.46)"),
(".3f()", "0.789(123.456)"),
(".4f()", "0.7890(123.4560)"),
(".5f()", "0.78900(123.45600)"),
(".6f()", "0.789000(123.456000)"),
(".7f()", "0.7890000(123.4560000)"),
],
),
(
Expand Down Expand Up @@ -196,6 +223,11 @@ def test_engineering_shifted(self):
("#!3r", "(0.123456 ± 0.000789)e+03"),
("#!4r", "(0.1234560 ± 0.0007890)e+03"),
("#!2r()", "0.12346(79)e+03"),
("#.1r", "(0.1 ± 0.0)e+03"),
("#.2r", "(0.12 ± 0.00)e+03"),
("#.3r", "(0.123 ± 0.001)e+03"),
("#.4r", "(0.1235 ± 0.0008)e+03"),
("#.2r()", "0.12(0.00)e+03"),
],
),
(
Expand Down
Loading