diff --git a/expression_parsing/CHANGELOG.md b/expression_parsing/CHANGELOG.md index 9e94b36..ec49e8d 100644 --- a/expression_parsing/CHANGELOG.md +++ b/expression_parsing/CHANGELOG.md @@ -43,6 +43,7 @@ The first public release. Further tidy-up after the 0.2.0 extraction: previewing - `SympyParsingConfig.replace` also takes `extra_unsplittable_symbols` and `extra_symbol_assumptions`, alongside parameter changes (`config.replace(simplify=False, extra_unsplittable_symbols=(...))`); they needed `dataclasses.replace` before. - `notation_warnings(response, strict_syntax)` returns the `NotationWarning`s for a response: `EXPONENT` (`^`) and `FACTORIAL` (`!`) under strict syntax, and `TRIPLE_FACTORIAL` (`!!!`) always. `NotationWarning` is a `StrEnum` whose values are compareExpressions' feedback tags (`NOTATION_WARNING_EXPONENT`, ...), so a tag lookup keeps working. Wording the warnings is still up to the caller. It was `notation_warnings` in compareExpressions' `evaluation.py`, and behaves the same: the checks are substring tests, so under strict syntax `!=` also warns `FACTORIAL`, and `!!!` warns both `FACTORIAL` and `TRIPLE_FACTORIAL`. +- (since 0.3.0rc3) `read_written_number(string)` reads the precision a number was written to, for grading a response to a number of significant figures or decimal places. It returns a `WrittenNumber` (its `integer` and `fraction` digits, `has_point` and `exponent`), or `None` unless `string` is exactly one decimal number. `.significant_figures` doesn't count leading zeros, and counts trailing zeros only after a written point (`540` has 2, `540.` has 3, `92.00` has 4). `.decimal_places` subtracts the exponent (`5.02e4` has 0, `5e-3` has 3). It's more lenient than `is_number`, since it reads what a learner wrote: a `+` sign, leading zeros and a trailing point are allowed. The exponent is written `e`, `E`, `*10^` or `*10**`. `round_to_significant_figures(value, figures)` returns a float, and `round_to_decimal_places(value, places)` returns a string that keeps its trailing zeros. They come from compareExpressions#273 and #277, which only read an `e`/`E` exponent; grading with them stays in compareExpressions. - DEBUG logging through module loggers (`lambdafeedback.expression_parsing.*`), with a `NullHandler` on the package logger; nothing is logged at INFO or above. See the repository README. Preprocessing, parsing and `parse_latex` log each step's result. ### Security diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py b/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py index ca69a56..8634262 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py @@ -39,12 +39,16 @@ from .latex import MAX_LATEX_NESTING, extract_latex, latex_symbols, parse_latex, sanitise_latex, sympy_to_latex from .notation import NotationWarning, notation_warnings from .numbers import ( + WrittenNumber, compute_relative_tolerance_from_significant_decimals, generate_arbitrary_number_pattern_matcher, is_complex_number_on_cartesian_form, is_complex_number_on_exponential_form, is_number, plain_number_end, + read_written_number, + round_to_decimal_places, + round_to_significant_figures, ) from .params import Convention, ExpressionParams, SymbolSpec, parse_symbol_assumptions from .preprocessing import ( @@ -95,6 +99,7 @@ "SymbolSpec", "SympyParsingConfig", "UnknownConventionError", + "WrittenNumber", "__version__", "apply_convention", "compute_relative_tolerance_from_significant_decimals", @@ -119,6 +124,9 @@ "parse_symbol_assumptions", "plain_number_end", "preprocess_expression", + "read_written_number", + "round_to_decimal_places", + "round_to_significant_figures", "sanitise_latex", "substitute", "substitute_input_symbols", diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py b/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py index a8c92a8..4ae7275 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py @@ -10,6 +10,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass DEFAULT_SIGNIFICANT_FIGURES = 2 @@ -182,6 +183,76 @@ def matcher(comp_string: str) -> bool: return matcher +@dataclass(frozen=True) +class WrittenNumber: + """A number as written, digit for digit: what its precision is depends on how it was written. + + ``92`` and ``92.00`` are the same value written to 2 and 4 significant + figures; ``540`` has 2 and ``540.`` (with a point) 3. + """ + + integer: str + """The digits before the point (may be empty, as in ``.5``).""" + fraction: str + """The digits after the point (may be empty).""" + has_point: bool + exponent: int = 0 + + @property + def significant_figures(self) -> int: + """Leading zeros don't count; trailing zeros count only after a written point. Zero has 1.""" + digits = (self.integer + self.fraction).lstrip("0") + if not digits: + return 1 + if not self.has_point: + digits = digits.rstrip("0") + return len(digits) or 1 + + @property + def decimal_places(self) -> int: + """The digits after the point, less the exponent: ``5.02e4`` has 0, ``5e-3`` has 3.""" + return max(len(self.fraction) - self.exponent, 0) + + +def read_written_number(string: str) -> WrittenNumber | None: + """``string`` as a :class:`WrittenNumber`, if it is exactly one decimal number; ``None`` otherwise. + + More lenient than :func:`is_number`, since a learner's precision is read + from what they wrote: a ``+`` sign, leading zeros and a trailing point + (``540.``) are allowed. The exponent is written as in :func:`is_number` + (``e``, ``E``, ``*10^`` or ``*10**``), with an optional sign. + """ + string = string.strip() + index = 1 if string[:1] in ("+", "-") else 0 + integer_end = _digits_end(string, index) + has_point = string.startswith(".", integer_end) + fraction_end = _digits_end(string, integer_end + 1) if has_point else integer_end + integer, fraction = string[index:integer_end], string[integer_end + 1 : fraction_end] + if not integer and not fraction: + return None + exponent = 0 + if fraction_end < len(string): + mark = next((mark for mark in _EXPONENT_MARKS if string.startswith(mark, fraction_end)), None) + if mark is None: + return None + exponent_text = string[fraction_end + len(mark) :] + digits = exponent_text[1:] if exponent_text[:1] in ("+", "-") else exponent_text + if not digits or _digits_end(digits, 0) != len(digits): + return None + exponent = int(exponent_text) + return WrittenNumber(integer, fraction, has_point, exponent) + + +def round_to_significant_figures(value: float, figures: int) -> float: + """``value`` rounded to ``figures`` significant figures (``3.14159`` to 3 is ``3.14``).""" + return float(f"{value:.{figures}g}") + + +def round_to_decimal_places(value: float, places: int) -> str: + """``value`` rounded to ``places`` decimal places, as a string to keep trailing zeros (``0`` to 2 is ``0.00``).""" + return f"{value:.{places}f}" + + def compute_relative_tolerance_from_significant_decimals(string: str) -> float: """``5 * 10**-n`` for a number written with ``n`` significant figures (at least 2); 0 for non-numbers.""" string = string.strip() diff --git a/expression_parsing/tests/test_written_number.py b/expression_parsing/tests/test_written_number.py new file mode 100644 index 0000000..8185314 --- /dev/null +++ b/expression_parsing/tests/test_written_number.py @@ -0,0 +1,111 @@ +"""Reading the precision a number was written to (from compareExpressions#273 and #277).""" + +import pytest + +from lambdafeedback.expression_parsing import ( + WrittenNumber, + read_written_number, + round_to_decimal_places, + round_to_significant_figures, +) + + +@pytest.mark.parametrize( + ("string", "expected"), + [ + ("92.00", WrittenNumber("92", "00", True)), + ("92", WrittenNumber("92", "", False)), + ("0.0032", WrittenNumber("0", "0032", True)), + ("540", WrittenNumber("540", "", False)), + ("540.", WrittenNumber("540", "", True)), + (".5", WrittenNumber("", "5", True)), + ("-3.14", WrittenNumber("3", "14", True)), + ("+3.14", WrittenNumber("3", "14", True)), + (" 3.14 ", WrittenNumber("3", "14", True)), + ("007", WrittenNumber("007", "", False)), + ("5.02e4", WrittenNumber("5", "02", True, 4)), + ("5.02E+4", WrittenNumber("5", "02", True, 4)), + ("5e-3", WrittenNumber("5", "", False, -3)), + ("5.02*10^4", WrittenNumber("5", "02", True, 4)), + ("5.02*10**-4", WrittenNumber("5", "02", True, -4)), + ("0", WrittenNumber("0", "", False)), + ], +) +def test_reads_a_written_number(string, expected): + assert read_written_number(string) == expected + + +@pytest.mark.parametrize("string", ["", "two", ".", "-", "--3.14", "3.14e", "3.14e-", "3.1.4", "3x", "1/2", "3.14 m"]) +def test_rejects_anything_else(string): + assert read_written_number(string) is None + + +@pytest.mark.parametrize( + ("string", "expected"), + [ + ("92.00", 4), + ("92", 2), + ("0.0032", 2), + ("540", 2), + ("540.", 3), + ("0", 1), + ("0.00", 1), + ("007", 1), + ("5.02e4", 3), + ("-3.14", 3), + ], +) +def test_significant_figures(string, expected): + assert read_written_number(string).significant_figures == expected + + +@pytest.mark.parametrize( + ("string", "expected"), + [ + ("3.14", 2), + ("3.140", 3), + ("92.00", 2), + ("0.0032", 4), + ("540", 0), + ("540.", 0), + ("0", 0), + ("5.02e4", 0), + ("5e-3", 3), + ("1.5e-3", 4), + ("1.5*10^-3", 4), + ], +) +def test_decimal_places(string, expected): + assert read_written_number(string).decimal_places == expected + + +@pytest.mark.parametrize( + ("value", "figures", "expected"), + [ + (0, 3, 0.0), + (3.14159, 3, 3.14), + (3.14159, 6, 3.14159), + (540, 2, 540.0), + (0.0032, 2, 0.0032), + (50200, 3, 50200.0), + (-3.14159, 3, -3.14), + ], +) +def test_round_to_significant_figures(value, figures, expected): + assert round_to_significant_figures(value, figures) == pytest.approx(expected) + + +@pytest.mark.parametrize( + ("value", "places", "expected"), + [ + (0, 2, "0.00"), + (92, 2, "92.00"), + (3.14159, 2, "3.14"), + (3.14159, 4, "3.1416"), + (3.146, 2, "3.15"), + (2.7, 0, "3"), + (-3.14159, 2, "-3.14"), + ], +) +def test_round_to_decimal_places(value, places, expected): + assert round_to_decimal_places(value, places) == expected