Skip to content
Draft
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
1 change: 1 addition & 0 deletions expression_parsing/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -95,6 +99,7 @@
"SymbolSpec",
"SympyParsingConfig",
"UnknownConventionError",
"WrittenNumber",
"__version__",
"apply_convention",
"compute_relative_tolerance_from_significant_decimals",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass

DEFAULT_SIGNIFICANT_FIGURES = 2

Expand Down Expand Up @@ -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()
Expand Down
111 changes: 111 additions & 0 deletions expression_parsing/tests/test_written_number.py
Original file line number Diff line number Diff line change
@@ -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
Loading