diff --git a/README.md b/README.md index b1d8ed6..b7aada0 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ exceptions (`expression_parsing.ExpressionParsingError`, `.expression`) rather than returning feedback tags for failures. Non-fatal diagnostics stay as plain structured facts instead: `units.PhysicalQuantity` exposes unit-like text found inside a value as -`reverted_units: list[RevertedUnit]` (`before`/`marked`/`after`). Consumers +`reverted_units: tuple[RevertedUnit, ...]` (`before`/`marked`/`after`). Consumers decide entirely on their own whether/how to surface any of this as feedback. `CriteriaGraph.export_feedback` turns reached criteria into feedback on a result object such as `lf_toolkit.evaluation.Result`; subclass the graph and diff --git a/units/CHANGELOG.md b/units/CHANGELOG.md index b051260..fd09cec 100644 --- a/units/CHANGELOG.md +++ b/units/CHANGELOG.md @@ -22,7 +22,8 @@ The first public release. This is a further tidy-up after the 0.2.0 extraction: - `parse_quantity` and `preprocess_quantity` now take `QuantityParams` only; the `Mapping[str, Any]` (raw dict) fallback is gone, along with the `as_quantity_params` helper. Convert evaluation-function parameters explicitly at the call site with `QuantityParams.from_dict(evaluation_params)`. - `preview_function` and `fix_exponents` have moved out of this package entirely, to compareExpressions (`app/context/physical_quantity_preview.py`): building a LaTeX/SymPy preview string is an app-layer display concern, not unit conversion. Import them from there instead of `lambdafeedback.units`. - `preprocess_quantity` now returns a plain `str` (`expression_parsing.preprocess_expression`, which it matches the shape of, no longer returns `Preprocessed`). -- `FeedbackTag` is removed, matching `expression_parsing`'s move from feedback tags to typed exceptions: mapping a parsing fact to feedback text is an app-layer concern, not this package's. `PhysicalQuantity.messages` (`(message_id, FeedbackTag)` pairs) is replaced by `PhysicalQuantity.reverted_units: list[RevertedUnit]` — plain `before`/`marked`/`after` facts about unit-like text found inside a value, with no tag name or synthetic id. This isn't a raised exception (unlike `expression_parsing`'s failures): it's non-fatal and can fire multiple times per response, so it stays a collected list. +- `FeedbackTag` is removed, matching `expression_parsing`'s move from feedback tags to typed exceptions: mapping a parsing fact to feedback text is an app-layer concern, not this package's. `PhysicalQuantity.messages` (`(message_id, FeedbackTag)` pairs) is replaced by `PhysicalQuantity.reverted_units: tuple[RevertedUnit, ...]` — plain `before`/`marked`/`after` facts about unit-like text found inside a value, with no tag name or synthetic id. This isn't a raised exception (unlike `expression_parsing`'s failures): it's non-fatal and can fire multiple times per response, so it stays a collection. +- **`PhysicalQuantity` is a frozen result.** Its attributes can't be set (`dataclasses.FrozenInstanceError`); `value`, `unit` and `ast_root` are still ordinary parse trees. `parse_quantity` builds it through the new `PhysicalQuantity.from_tree(name, params, ast_root, parser)`, which runs each step (splitting value from unit, restoring the value's text, LaTeX, SI forms) as a separate function; the constructor now takes every attribute. Quantities still compare by identity, and pickle and deep-copy without a custom `__getstate__`/`__setstate__`. - `PhysicalQuantity.value_latex` no longer calls `expression_parsing.preview_function` (removed): it builds the LaTeX directly from `preprocess_expression` + `parse_expression` + `sympy_to_latex`, catching `BracketNotationError`/`AbsoluteValueNotationError` to preview with the best-guess rewrite rather than failing outright (matching the old behaviour of ignoring preprocessing feedback). - Requires `lambdafeedback-expression-parsing` 0.3.0 or later. @@ -61,10 +62,12 @@ The first public release. This is a further tidy-up after the 0.2.0 extraction: ### Changed +- `quantity` keeps the public `PhysicalQuantity`, `RevertedUnit`, `parse_quantity` and `same_dimension`; the steps of reading a quantity are in private modules (`_split`, `_latex`, `_conversion`). Import public names from `lambdafeedback.units`: `MAX_UNIT_EXPONENT` is no longer in `lambdafeedback.units.quantity`. - The regular expressions in preprocessing and parsing are replaced by string scanners, with the same results. The quantity grammar's tokens are `slr_parsing.Lexeme`s (requires `lambdafeedback-slr-parsing` with literal token patterns). ### Deprecated +- `PhysicalQuantity.parser` emits a `DeprecationWarning` and will be removed in 1.0: the quantity no longer holds its parser. It returns the same cached parser as `build_quantity_parser(params.unit_sets, params.strictness)`; call that instead. - `strictness="legacy"` (and constructing `QuantityParams(legacy_preprocessing=True)` directly) now emits a `DeprecationWarning`, and will be removed in 1.0. The behaviour is unchanged (natural strictness with the old `legacy` preprocessing rewrites), but new callers should use `strictness="natural"` (the default). ## 0.2.0 (internal, not published) diff --git a/units/src/lambdafeedback/units/__init__.py b/units/src/lambdafeedback/units/__init__.py index 5386a18..d11501a 100644 --- a/units/src/lambdafeedback/units/__init__.py +++ b/units/src/lambdafeedback/units/__init__.py @@ -16,6 +16,7 @@ import logging from importlib.metadata import PackageNotFoundError, version +from ._split import MAX_UNIT_EXPONENT from .data import ( ALL_UNITS, COMMON_UNITS, @@ -37,7 +38,6 @@ from .parser import build_quantity_parser from .preprocessing import MAX_LEGACY_LENGTH, preprocess_legacy, preprocess_quantity, transform_prefixes_to_standard from .quantity import ( - MAX_UNIT_EXPONENT, PhysicalQuantity, RevertedUnit, parse_quantity, diff --git a/units/src/lambdafeedback/units/_conversion.py b/units/src/lambdafeedback/units/_conversion.py new file mode 100644 index 0000000..ed13c2a --- /dev/null +++ b/units/src/lambdafeedback/units/_conversion.py @@ -0,0 +1,111 @@ +"""Converting a quantity to SI base units: its SymPy forms, dimension and unit factor.""" + +from __future__ import annotations + +from functools import cache +from typing import Any, NamedTuple + +from sympy import Basic, Integer, simplify + +from lambdafeedback.expression_parsing import ( + SympyParsingConfig, + parse_expression, + parse_expressions, + parse_latex, + substitute, +) +from lambdafeedback.slr_parsing import ExprNode, SLRParser + +from .data import CONVERSION_TO_BASE_SI, SI_BASE_UNITS, unsplittable_names +from .errors import UnitConversionError +from .params import QuantityParams +from .parser import Label + +_BASE_UNIT_NAMES = frozenset(unit.name for unit in SI_BASE_UNITS) + +_BASE_UNIT_DIMENSIONS = [(unit.name, unit.dimension) for unit in SI_BASE_UNITS] + +_DIMENSION_NAMES = frozenset(unit.dimension for unit in SI_BASE_UNITS) + + +def _without_factor(dimension: Basic) -> Basic: + """The dimension with its numeric factor removed: ``10**3*length/(3600*time)`` → ``length/time``.""" + factor = dimension.subs({symbol: 1 for symbol in dimension.free_symbols if str(symbol) in _DIMENSION_NAMES}) + return simplify(dimension / factor) + + +@cache +def _conversion_tree(parser: SLRParser, unit_name: str) -> ExprNode: + """A unit's expansion in SI base units, parsed once per parser (callers copy it before changing it).""" + tree: ExprNode = parser.parse(parser.scan(CONVERSION_TO_BASE_SI[unit_name]))[0] + return tree + + +def quantity_parsing_config(params: QuantityParams) -> SympyParsingConfig: + """The SymPy configuration for a quantity: unit, prefix and dimension names are positive and never split.""" + unsplittable = unsplittable_names(params.unit_sets, all_forms=False) + return SympyParsingConfig.from_params( + params, + unsplittable_symbols=unsplittable, + symbol_assumptions=tuple((name, "positive") for name in unsplittable), + ) + + +def _expand_units(node: ExprNode, parser: SLRParser) -> ExprNode: + """Replace non-base units by their expression in SI base units (in place where possible).""" + if node.label == Label.UNIT and not node.children and node.content not in _BASE_UNIT_NAMES: + node = _conversion_tree(parser, node.content).copy() + node.children = [_expand_units(child, parser) for child in node.children] + return node + + +class SympyForms(NamedTuple): + """The SymPy fields of a :class:`~lambdafeedback.units.PhysicalQuantity`.""" + + standard_values: tuple[Basic, ...] | None + standard_unit: Basic | None + expanded_unit: Basic | None + dimension: Basic + base_dimension: Basic + unit_factor: Basic + + +def sympy_forms( + name: str, + value: ExprNode | None, + unit: ExprNode | None, + params: QuantityParams, + config: SympyParsingConfig, + parser: SLRParser, +) -> SympyForms: + """The SymPy forms: the standard value and unit, the expanded unit, dimensions and unit factor. + + Raises :class:`UnitConversionError` (naming ``name``) if the unit's expansion doesn't parse. + """ + value_text: Any = value.content_string() if value is not None else None + if value_text is not None and params.is_latex: + value_text = parse_latex(value_text, params.symbols, params.simplify) + standard_unit: Any = None + expanded_unit: Any = None + dimension: Basic = Integer(1) + unit_factor: Basic = Integer(1) + if unit is not None: + expanded_unit_string = _expand_units(unit.copy(), parser).content_string() + try: + expanded_unit = standard_unit = parse_expression(expanded_unit_string, config) + except Exception as e: # SymPy raises many types; all mean the expansion didn't parse + raise UnitConversionError(f"SymPy was unable to parse the {name} unit") from e + if value is not None: + base_symbols = {symbol: 1 for symbol in standard_unit.free_symbols if str(symbol) in _BASE_UNIT_NAMES} + unit_factor = standard_unit.subs(base_symbols).simplify() + standard_unit = (standard_unit / unit_factor).simplify(rational=True) + value_text = f"({value_text})*({unit_factor})" + dimension = parse_expression(substitute(expanded_unit_string, _BASE_UNIT_DIMENSIONS), config) + return SympyForms( + standard_values=None if value_text is None else parse_expressions(value_text, config), + standard_unit=standard_unit, + expanded_unit=expanded_unit, + dimension=dimension, + base_dimension=_without_factor(dimension), + unit_factor=unit_factor, + ) diff --git a/units/src/lambdafeedback/units/_latex.py b/units/src/lambdafeedback/units/_latex.py new file mode 100644 index 0000000..21af3b4 --- /dev/null +++ b/units/src/lambdafeedback/units/_latex.py @@ -0,0 +1,115 @@ +"""LaTeX for a quantity: a preview of its value, and its unit (one renderer per node label).""" + +from __future__ import annotations + +from collections.abc import Callable + +from lambdafeedback.expression_parsing import ( + AbsoluteValueNotationError, + BracketNotationError, + SympyParsingConfig, + parse_expressions, + preprocess_expression, + sympy_to_latex, +) +from lambdafeedback.slr_parsing import GROUP_LABEL, ExprNode + +from .data import unsplittable_names +from .params import QuantityParams +from .parser import Label +from .text import called_names + + +def latex_for_unit(unit: ExprNode) -> str: + """LaTeX for a unit subtree: ``\\mathrm{}`` names, ``\\cdot`` products, fractions and powers.""" + return "".join(_unit_latex(unit)) + + +def _unit_latex(node: ExprNode) -> list[str]: + """LaTeX for a unit subtree, as pieces to join.""" + render = _UNIT_LATEX.get(node.label, _plain_latex) + return render(node) + + +def _braced_latex(node: ExprNode) -> list[str]: + """LaTeX for a node already grouped by braces (a numerator, denominator or exponent): no brackets of its own.""" + while node.label == GROUP_LABEL and len(node.children) == 1: # (x) or ((x)), not juxtaposition + node = node.children[0] + return _unit_latex(node) + + +def _plain_latex(node: ExprNode) -> list[str]: + """A number, or any other node without a renderer of its own: its content.""" + return [node.content] + + +def _unit_name_latex(node: ExprNode) -> list[str]: + return ["\\mathrm{", node.content, "}"] + + +def _product_latex(node: ExprNode) -> list[str]: + left, right = node.children + return [*_unit_latex(left), "\\cdot", *_unit_latex(right)] + + +def _space_latex(node: ExprNode) -> list[str]: + left, right = node.children + return [*_unit_latex(left), "~", *_unit_latex(right)] + + +def _power_latex(node: ExprNode) -> list[str]: + base, exponent = node.children + return [*_unit_latex(base), "^{", *_braced_latex(exponent), "}"] + + +def _solidus_latex(node: ExprNode) -> list[str]: + numerator, denominator = node.children + return ["\\frac{", *_braced_latex(numerator), "}{", *_braced_latex(denominator), "}"] + + +def _group_latex(node: ExprNode) -> list[str]: + if node.delimiters is None: + return _plain_latex(node) + opening, closing = node.delimiters + return [opening.content, *(part for child in node.children for part in _unit_latex(child)), closing.content] + + +_UNIT_LATEX: dict[str, Callable[[ExprNode], list[str]]] = { + Label.UNIT: _unit_name_latex, + Label.PRODUCT: _product_latex, + Label.SPACE: _space_latex, + Label.POWER: _power_latex, + Label.SOLIDUS: _solidus_latex, + GROUP_LABEL: _group_latex, +} + + +def latex_for_value(value: ExprNode | None, params: QuantityParams) -> str | None: + """LaTeX previewing the value (``None`` without one): the value as SymPy reads it, unit forms kept whole.""" + if value is None: + return None + original = value.original_string() + # Unit forms inside a called name (``s`` and ``in`` in ``sin(2)``) would split + # the call in the preview, though the value itself is parsed as the call. + calls = called_names(original) + unit_forms = [ + form + for form in unsplittable_names(params.unit_sets, all_forms=True) + if not any(form in call and form != call for call in calls) + ] + reserved = (*params.reserved_keywords, *unit_forms) + preview_params = params.replace(reserved_keywords=reserved, rationalise=False) + if preview_params.is_latex: + # A LaTeX value is previewed as-is (as expression_parsing.preview_function used to). + return original + try: + preprocessed = preprocess_expression(original, preview_params, name="response") + except (BracketNotationError, AbsoluteValueNotationError) as exc: + # Best-effort: preview with the guessed rewrite rather than failing outright. + preprocessed = exc.expression + parsed = parse_expressions(preprocessed, SympyParsingConfig.from_params(preview_params)) + settings = {"mul_symbol": r" \cdot "} + if len(parsed) > 1: + parts = sorted(sympy_to_latex(expr, preview_params.symbols, settings=settings) for expr in parsed) + return "\\left\\{" + ",~".join(parts) + "\\right\\}" + return sympy_to_latex(parsed[0], preview_params.symbols, settings=settings) diff --git a/units/src/lambdafeedback/units/_split.py b/units/src/lambdafeedback/units/_split.py new file mode 100644 index 0000000..92773b5 --- /dev/null +++ b/units/src/lambdafeedback/units/_split.py @@ -0,0 +1,143 @@ +"""Splitting a quantity's parse tree into its value and its unit.""" + +from __future__ import annotations + +import logging + +from lambdafeedback.slr_parsing import GROUP_LABEL, ExprNode, TagHandler + +from .errors import QuantityParseError +from .parser import Label +from .tags import QuantityTag + +logger = logging.getLogger(__name__) + +MAX_UNIT_EXPONENT = 100 +"""Largest exponent a unit may have (``m^100``), of either sign.""" + +_EXPONENT_LABELS = frozenset({Label.NUMBER, GROUP_LABEL, Label.SOLIDUS, Label.PRODUCT, Label.SPACE}) + + +def _exponent_problem(unit: ExprNode) -> str | None: + """Why the exponents in a unit can't be used, if they can't. + + An exponent must be numbers combined with brackets, ``*``, ``/`` or spaces, + and stay within :data:`MAX_UNIT_EXPONENT`. Otherwise converting the unit + could build an enormous number: ``km^(10^10)`` has the factor + ``1000^(10^10)``, and ``km^Pa^km`` (a unit as an exponent) hung. + """ + stack = [unit] + while stack: + node = stack.pop() + stack.extend(node.children) + if node.label != Label.POWER: + continue + bound = 1.0 # at least the exponent's magnitude: the product of its numbers' sizes + exponent_nodes = [node.children[1]] + while exponent_nodes: + part = exponent_nodes.pop() + if part.label not in _EXPONENT_LABELS: + return f"A unit's exponent must be a number, not {part.original_string()!r}." + if part.label == Label.NUMBER: + bound *= max(1.0, abs(float(part.content))) + exponent_nodes.extend(part.children) + if bound > MAX_UNIT_EXPONENT: + return f"The exponent of {node.original_string()!r} is too large; at most {MAX_UNIT_EXPONENT} is allowed." + return None + + +def _has_stray_number(node: ExprNode) -> bool: + """Whether a unit subtree contains a number outside an exponent or the ``1`` of ``1/unit``. + + Groups are not looked into: a bracketed group is kept whole (see + :func:`_rotate_until_root_is_split`). + """ + stack = [node] # iterative: a long unit such as m*m*...*m is a deep tree + while stack: + node = stack.pop() + if node.label == Label.NUMBER: + return True + if node.label == GROUP_LABEL: + continue + if node.label == Label.POWER: + stack.append(node.children[0]) + elif node.label == Label.SOLIDUS and node.children[0].label == Label.NUMBER and node.children[0].content == "1": + stack.append(node.children[1]) + else: + stack.extend(node.children) + return False + + +def _rotate(root: ExprNode, right: bool, tag_handler: TagHandler) -> ExprNode: + """Tree rotation at ``root``, to the right (the left child becomes the root) or to the left. + + Returns the new root. + """ + # The child that becomes the root, and which of its children moves across to the old root. + pivot, inner = (0, -1) if right else (1, 0) + new_root = root.children[pivot] + # Only binary nodes can be rotated; groups (one child) are atomic. + if len(new_root.children) < 2: + raise QuantityParseError(f"Cannot rotate {'right' if right else 'left'}.") + root.children[pivot] = new_root.children[inner] + new_root.children[inner] = root + # Retagging can change the nodes too: in strict mode it strips the unit tag from a juxtaposition's children. + root.tags = tag_handler(root) + new_root.tags = tag_handler(new_root) + return new_root + + +def _rotate_until_root_is_split(root: ExprNode, tag_handler: TagHandler) -> ExprNode: + """Rotate ``SPACE`` roots until the root separates value (left) from unit (right); returns the new root. + + Only binary nodes are rotated into: a group (e.g. ``(2 m)``) is kept whole. + A rotation followed by the opposite one would undo it, so a change of + direction means there is no split (e.g. ``5 m/s x``, a unit between + value parts). + """ + previous: bool | None = None # the direction of the last rotation (True: right) + while root.label == Label.SPACE: + left, right = root.children + if QuantityTag.UNIT not in root.tags and len(right.children) > 1: + rotate_right = False + elif QuantityTag.UNIT in left.tags and len(left.children) > 1: + rotate_right = True + else: + return root + if previous is not None and rotate_right != previous: + raise QuantityParseError(f"Cannot separate the value from the unit in {root.original!r}.") + logger.debug("rotate %s to split value from unit", "right" if rotate_right else "left") + root = _rotate(root, rotate_right, tag_handler) + previous = rotate_right + return root + + +def split_value_and_unit(root: ExprNode, tag_handler: TagHandler) -> tuple[ExprNode, ExprNode | None, ExprNode | None]: + """The root after rotating it to separate value from unit, the value and the unit. + + See :func:`_rotate_until_root_is_split`. Raises :class:`QuantityParseError` if + they can't be separated, or the unit's exponents can't be used. + """ + root = _rotate_until_root_is_split(root, tag_handler) + value: ExprNode | None = None + unit: ExprNode | None = None + if ( + root.label == Label.SPACE + and QuantityTag.UNIT not in root.children[0].tags + and QuantityTag.UNIT in root.children[1].tags + ): + value, unit = root.children + elif QuantityTag.UNIT in root.tags: + unit = root + else: + value = root + if logger.isEnabledFor(logging.DEBUG): + value_text = None if value is None else value.content_string() + unit_text = None if unit is None else unit.content_string() + logger.debug("split %r into value %r and unit %r", root.original, value_text, unit_text) + # e.g. "2 m 3 s" or "1 hour 30 minutes": the unit would contain a number. + if unit is not None and _has_stray_number(unit): + raise QuantityParseError(f"Cannot separate the value from the unit in {root.original!r}.") + if unit is not None and (problem := _exponent_problem(unit)) is not None: + raise QuantityParseError(problem) + return root, value, unit diff --git a/units/src/lambdafeedback/units/data.py b/units/src/lambdafeedback/units/data.py index b58d2d6..d3b561e 100644 --- a/units/src/lambdafeedback/units/data.py +++ b/units/src/lambdafeedback/units/data.py @@ -190,6 +190,20 @@ def units_in(unit_sets: frozenset[str] | set[str]) -> tuple[AnyUnit, ...]: return tuple(dict.fromkeys(unit for name, units in UNIT_SETS.items() if name in unit_sets for unit in units)) +def unsplittable_names(unit_sets: frozenset[str] | set[str], all_forms: bool) -> list[str]: + """Names that must not be split into letters: prefixes, units, and dimension names. + + For the units in ``unit_sets``. ``all_forms`` adds unit symbols, + alternative spellings and plurals (used for the value's preview) to the + unit names (used for parsing). + """ + names = {prefix.name for prefix in SI_PREFIXES} | {unit.name for unit in SI_BASE_UNITS} + names |= {unit.dimension for unit in SI_BASE_UNITS} + for unit in units_in(unit_sets): + names |= {unit.name, unit.symbol, *unit.alternatives, *unit.plurals} if all_forms else {unit.name} + return sorted(names) + + def _conversion_to_base_si() -> dict[str, str]: unprefixed = {unit.name: unit.si_expansion for unit in ALL_UNITS if isinstance(unit, Unit)} unprefixed.update({unit.name: unit.name for unit in SI_BASE_UNITS}) diff --git a/units/src/lambdafeedback/units/quantity.py b/units/src/lambdafeedback/units/quantity.py index b7ab7a4..a5d82a6 100644 --- a/units/src/lambdafeedback/units/quantity.py +++ b/units/src/lambdafeedback/units/quantity.py @@ -1,96 +1,35 @@ -"""Physical quantities: splitting a parsed response into value and unit, and converting it.""" +"""Physical quantities: the result of reading a response as a value and a unit, and how it is read. + +:meth:`PhysicalQuantity.from_tree` runs the steps in order: splitting the +parse tree into value and unit (``_split``), restoring the value's text +(here), LaTeX (``_latex``) and the SymPy forms in SI base units +(``_conversion``). +""" from __future__ import annotations import logging -from collections.abc import Callable +import warnings from dataclasses import dataclass -from functools import cache from typing import Any -from sympy import Basic, Integer, simplify - -from lambdafeedback.expression_parsing import ( - AbsoluteValueNotationError, - BracketNotationError, - ExpressionParsingError, - SympyParsingConfig, - parse_expression, - parse_expressions, - parse_latex, - preprocess_expression, - substitute, - sympy_to_latex, -) +from sympy import Basic, simplify + +from lambdafeedback.expression_parsing import ExpressionParsingError, SympyParsingConfig from lambdafeedback.slr_parsing import GROUP_LABEL, ExprNode, SLRError, SLRParser -from .data import CONVERSION_TO_BASE_SI, SI_BASE_UNITS, SI_PREFIXES, UNIT_SETS, units_in -from .errors import DisallowedUnitError, QuantityError, QuantityParseError, UnitConversionError +from ._conversion import quantity_parsing_config, sympy_forms +from ._latex import latex_for_unit, latex_for_value +from ._split import split_value_and_unit +from .data import UNIT_SETS +from .errors import DisallowedUnitError, QuantityError, QuantityParseError from .params import MAX_INPUT_LENGTH, QuantityParams from .parser import Label, build_quantity_parser from .tags import QuantityTag -from .text import called_names, disallowed_unit, mentions_celsius_or_fahrenheit, normalise_input +from .text import disallowed_unit, mentions_celsius_or_fahrenheit, normalise_input logger = logging.getLogger(__name__) -_BASE_UNIT_NAMES = frozenset(unit.name for unit in SI_BASE_UNITS) -_BASE_UNIT_DIMENSIONS = [(unit.name, unit.dimension) for unit in SI_BASE_UNITS] -_DIMENSION_NAMES = frozenset(unit.dimension for unit in SI_BASE_UNITS) -MAX_UNIT_EXPONENT = 100 -"""Largest exponent a unit may have (``m^100``), of either sign.""" - -_EXPONENT_LABELS = frozenset({Label.NUMBER, GROUP_LABEL, Label.SOLIDUS, Label.PRODUCT, Label.SPACE}) - - -def _exponent_problem(unit: ExprNode) -> str | None: - """Why the exponents in a unit can't be used, if they can't. - - An exponent must be numbers combined with brackets, ``*``, ``/`` or spaces, - and stay within :data:`MAX_UNIT_EXPONENT`. Otherwise converting the unit - could build an enormous number: ``km^(10^10)`` has the factor - ``1000^(10^10)``, and ``km^Pa^km`` (a unit as an exponent) hung. - """ - stack = [unit] - while stack: - node = stack.pop() - stack.extend(node.children) - if node.label != Label.POWER: - continue - bound = 1.0 # at least the exponent's magnitude: the product of its numbers' sizes - exponent_nodes = [node.children[1]] - while exponent_nodes: - part = exponent_nodes.pop() - if part.label not in _EXPONENT_LABELS: - return f"A unit's exponent must be a number, not {part.original_string()!r}." - if part.label == Label.NUMBER: - bound *= max(1.0, abs(float(part.content))) - exponent_nodes.extend(part.children) - if bound > MAX_UNIT_EXPONENT: - return f"The exponent of {node.original_string()!r} is too large; at most {MAX_UNIT_EXPONENT} is allowed." - return None - - -def _has_stray_number(node: ExprNode) -> bool: - """Whether a unit subtree contains a number outside an exponent or the ``1`` of ``1/unit``. - - Groups are not looked into: a bracketed group is kept whole (see - :meth:`PhysicalQuantity._rotate_until_root_is_split`). - """ - stack = [node] # iterative: a long unit such as m*m*...*m is a deep tree - while stack: - node = stack.pop() - if node.label == Label.NUMBER: - return True - if node.label == GROUP_LABEL: - continue - if node.label == Label.POWER: - stack.append(node.children[0]) - elif node.label == Label.SOLIDUS and node.children[0].label == Label.NUMBER and node.children[0].content == "1": - stack.append(node.children[1]) - else: - stack.extend(node.children) - return False - def _content(node: ExprNode) -> Any: return node.content @@ -112,19 +51,6 @@ def _subtree_texts(root: ExprNode) -> dict[int, str]: return texts -def _without_factor(dimension: Basic) -> Basic: - """The dimension with its numeric factor removed: ``10**3*length/(3600*time)`` → ``length/time``.""" - factor = dimension.subs({symbol: 1 for symbol in dimension.free_symbols if str(symbol) in _DIMENSION_NAMES}) - return simplify(dimension / factor) - - -@cache -def _conversion_tree(parser: SLRParser, unit_name: str) -> ExprNode: - """A unit's expansion in SI base units, parsed once per parser (callers copy it before changing it).""" - tree: ExprNode = parser.parse(parser.scan(CONVERSION_TO_BASE_SI[unit_name]))[0] - return tree - - @dataclass(frozen=True) class RevertedUnit: """Unit-like text found inside a value, restored to what was typed. @@ -139,83 +65,31 @@ class RevertedUnit: after: str -def _unsplittable_names(params: QuantityParams, all_forms: bool) -> list[str]: - """Names that must not be split into letters: prefixes, units, and dimension names. +def _revert_value_text(value: ExprNode) -> tuple[RevertedUnit, ...]: + """Restore the value's text as written; returns its unit-like parts (parents first). - ``all_forms`` adds unit symbols, alternative spellings and plurals (used - for the value's preview) to the unit names (used for parsing). + The value's nodes get their typed text back as their content, in place: + the tree was just parsed, and nothing else holds it. A unit-like node's + marked text is its restored content around its children's text from + before restoring (computed once, bottom-up, by :func:`_subtree_texts`, + rather than per node, which was quadratic). """ - names = {prefix.name for prefix in SI_PREFIXES} | _BASE_UNIT_NAMES - names |= {unit.dimension for unit in SI_BASE_UNITS} - for unit in units_in(params.unit_sets): - names |= {unit.name, unit.symbol, *unit.alternatives, *unit.plurals} if all_forms else {unit.name} - return sorted(names) - - -# ---------------------------------------------------------------------- -# LaTeX for units: one renderer per node label (see _UNIT_LATEX) -# ---------------------------------------------------------------------- - - -def _unit_latex(node: ExprNode) -> list[str]: - """LaTeX for a unit subtree, as pieces to join.""" - render = _UNIT_LATEX.get(node.label, _plain_latex) - return render(node) - - -def _braced_latex(node: ExprNode) -> list[str]: - """LaTeX for a node already grouped by braces (a numerator, denominator or exponent): no brackets of its own.""" - while node.label == GROUP_LABEL and len(node.children) == 1: # (x) or ((x)), not juxtaposition - node = node.children[0] - return _unit_latex(node) - - -def _plain_latex(node: ExprNode) -> list[str]: - """A number, or any other node without a renderer of its own: its content.""" - return [node.content] - - -def _unit_name_latex(node: ExprNode) -> list[str]: - return ["\\mathrm{", node.content, "}"] - - -def _product_latex(node: ExprNode) -> list[str]: - left, right = node.children - return [*_unit_latex(left), "\\cdot", *_unit_latex(right)] - - -def _space_latex(node: ExprNode) -> list[str]: - left, right = node.children - return [*_unit_latex(left), "~", *_unit_latex(right)] - - -def _power_latex(node: ExprNode) -> list[str]: - base, exponent = node.children - return [*_unit_latex(base), "^{", *_braced_latex(exponent), "}"] - - -def _solidus_latex(node: ExprNode) -> list[str]: - numerator, denominator = node.children - return ["\\frac{", *_braced_latex(numerator), "}{", *_braced_latex(denominator), "}"] - - -def _group_latex(node: ExprNode) -> list[str]: - if node.delimiters is None: - return _plain_latex(node) - opening, closing = node.delimiters - return [opening.content, *(part for child in node.children for part in _unit_latex(child)), closing.content] - - -_UNIT_LATEX: dict[str, Callable[[ExprNode], list[str]]] = { - Label.UNIT: _unit_name_latex, - Label.PRODUCT: _product_latex, - Label.SPACE: _space_latex, - Label.POWER: _power_latex, - Label.SOLIDUS: _solidus_latex, - GROUP_LABEL: _group_latex, -} - - + texts = _subtree_texts(value) + reverted = [] + for node in value.walk(): + if node.label != GROUP_LABEL: + node.content = node.original[node.start : node.end + 1] + if node.label == Label.UNIT or QuantityTag.UNIT in node.tags: + marked = "".join( + part if is_output else texts[id(part)] for is_output, part in node.traverse_step(node, _content) + ) + reverted.append( + RevertedUnit(before=node.original[: node.start], marked=marked, after=node.original[node.end + 1 :]) + ) + return tuple(reverted) + + +@dataclass(frozen=True, eq=False, repr=False) class PhysicalQuantity: """A response read as a quantity: its value and unit, their LaTeX, and SI forms. @@ -223,6 +97,9 @@ class PhysicalQuantity: units of :data:`~lambdafeedback.units.SI_BASE_UNITS` as symbols; mass is in grams (``kilogram`` expands to ``1000*gram``). + Its attributes can't be set (it is frozen), but ``value``, ``unit`` and + ``ast_root`` are ordinary parse trees. Quantities compare by identity. + Attributes: value, unit: the value and unit subtrees (either may be ``None``). ast_root: the whole parse tree. @@ -244,11 +121,10 @@ class PhysicalQuantity: name: str params: QuantityParams ast_root: ExprNode - parser: SLRParser parsing_config: SympyParsingConfig - reverted_units: list[RevertedUnit] value: ExprNode | None unit: ExprNode | None + reverted_units: tuple[RevertedUnit, ...] value_latex: str | None unit_latex: str | None latex: str @@ -259,202 +135,55 @@ class PhysicalQuantity: base_dimension: Basic unit_factor: Basic - def __init__(self, name: str, params: QuantityParams, ast_root: ExprNode, parser: SLRParser) -> None: - self.name = name - self.params = params - self.ast_root = ast_root - self.parser = parser - unsplittable = _unsplittable_names(params, all_forms=False) - self.parsing_config = SympyParsingConfig.from_params( - params, - unsplittable_symbols=unsplittable, - symbol_assumptions=tuple((name, "positive") for name in unsplittable), + @classmethod + def from_tree(cls, name: str, params: QuantityParams, ast_root: ExprNode, parser: SLRParser) -> PhysicalQuantity: + """Read a quantity from its parse tree (built by ``parser``, the quantity parser for ``params``). + + :func:`parse_quantity` parses the text and calls this. ``ast_root`` is + rotated and its value's text restored in place. + """ + config = quantity_parsing_config(params) + root, value, unit = split_value_and_unit(ast_root, parser.tag_handler) + reverted_units = _revert_value_text(value) if value is not None else () + value_latex = latex_for_value(value, params) + unit_latex = latex_for_unit(unit) if unit is not None else None + separator = "~" if value_latex is not None and unit_latex is not None else "" + forms = sympy_forms(name, value, unit, params, config, parser) + return cls( + name=name, + params=params, + ast_root=root, + parsing_config=config, + value=value, + unit=unit, + reverted_units=reverted_units, + value_latex=value_latex, + unit_latex=unit_latex, + latex=(value_latex or "") + separator + (unit_latex or ""), + standard_values=forms.standard_values, + standard_unit=forms.standard_unit, + expanded_unit=forms.expanded_unit, + dimension=forms.dimension, + base_dimension=forms.base_dimension, + unit_factor=forms.unit_factor, + ) + + @property + def parser(self) -> SLRParser: + """Deprecated (removed in 1.0): the quantity parser for ``params``; use :func:`build_quantity_parser`.""" + warnings.warn( + "PhysicalQuantity.parser is deprecated and will be removed in 1.0; " + "use build_quantity_parser(params.unit_sets, params.strictness) instead.", + DeprecationWarning, + stacklevel=2, ) - self.reverted_units = [] - self.value = None - self.unit = None - - self._split_value_and_unit() - if self.value is not None: - self._revert_value_text(self.value) - self.value_latex = self._value_latex() - self.unit_latex = "".join(_unit_latex(self.unit)) if self.unit is not None else None - separator = "~" if self.value_latex is not None and self.unit_latex is not None else "" - self.latex = (self.value_latex or "") + separator + (self.unit_latex or "") - self._set_sympy_forms() - - def __getstate__(self) -> dict[str, Any]: - # The parser holds closures, so it can't be pickled; it is rebuilt (from the cache) on unpickling. - state = dict(vars(self)) - del state["parser"] - return state - - def __setstate__(self, state: dict[str, Any]) -> None: - self.__dict__.update(state) - self.parser = build_quantity_parser(self.params.unit_sets, self.params.strictness) + return build_quantity_parser(self.params.unit_sets, self.params.strictness) def __repr__(self) -> str: value = None if self.value is None else self.value.content_string() unit = None if self.unit is None else self.unit.content_string() return f"PhysicalQuantity(value={value!r}, unit={unit!r})" - # ------------------------------------------------------------------ - # Splitting value and unit - # ------------------------------------------------------------------ - - def _rotate(self, right: bool) -> None: - """Tree rotation at the root, to the right (the left child becomes the root) or to the left.""" - old_root = self.ast_root - # The child that becomes the root, and which of its children moves across to the old root. - pivot, inner = (0, -1) if right else (1, 0) - new_root = old_root.children[pivot] - # Only binary nodes can be rotated; groups (one child) are atomic. - if len(new_root.children) < 2: - raise QuantityParseError(f"Cannot rotate {'right' if right else 'left'}.") - old_root.children[pivot] = new_root.children[inner] - new_root.children[inner] = old_root - old_root.tags = self.parser.tag_handler(old_root) - new_root.tags = self.parser.tag_handler(new_root) - self.ast_root = new_root - - def _rotate_until_root_is_split(self) -> None: - """Rotate ``SPACE`` roots until the root separates value (left) from unit (right). - - Only binary nodes are rotated into: a group (e.g. ``(2 m)``) is kept whole. - A rotation followed by the opposite one would undo it, so a change of - direction means there is no split (e.g. ``5 m/s x``, a unit between - value parts). - """ - previous: bool | None = None # the direction of the last rotation (True: right) - while self.ast_root.label == Label.SPACE: - left, right = self.ast_root.children - if QuantityTag.UNIT not in self.ast_root.tags and len(right.children) > 1: - rotate_right = False - elif QuantityTag.UNIT in left.tags and len(left.children) > 1: - rotate_right = True - else: - return - if previous is not None and rotate_right != previous: - raise QuantityParseError(f"Cannot separate the value from the unit in {self.ast_root.original!r}.") - logger.debug("rotate %s to split value from unit", "right" if rotate_right else "left") - self._rotate(rotate_right) - previous = rotate_right - - def _split_value_and_unit(self) -> None: - self._rotate_until_root_is_split() - root = self.ast_root - if ( - root.label == Label.SPACE - and QuantityTag.UNIT not in root.children[0].tags - and QuantityTag.UNIT in root.children[1].tags - ): - self.value, self.unit = root.children - elif QuantityTag.UNIT in root.tags: - self.unit = root - else: - self.value = root - # e.g. "2 m 3 s" or "1 hour 30 minutes": the unit would contain a number. - if logger.isEnabledFor(logging.DEBUG): - value = None if self.value is None else self.value.content_string() - unit = None if self.unit is None else self.unit.content_string() - logger.debug("split %r into value %r and unit %r", root.original, value, unit) - if self.unit is not None and _has_stray_number(self.unit): - raise QuantityParseError(f"Cannot separate the value from the unit in {root.original!r}.") - if self.unit is not None and (problem := _exponent_problem(self.unit)) is not None: - raise QuantityParseError(problem) - - def _revert_value_text(self, value: ExprNode) -> None: - """Restore the value's text as written, noting its unit-like parts in ``reverted_units`` (parents first). - - A unit-like node's marked text is its restored content around its - children's text from before restoring (computed once, bottom-up, by - :func:`_subtree_texts`, rather than per node, which was quadratic). - """ - texts = _subtree_texts(value) - for node in value.walk(): - if node.label != GROUP_LABEL: - node.content = node.original[node.start : node.end + 1] - if node.label == Label.UNIT or QuantityTag.UNIT in node.tags: - marked = "".join( - part if is_output else texts[id(part)] for is_output, part in node.traverse_step(node, _content) - ) - self.reverted_units.append( - RevertedUnit(before=node.original[: node.start], marked=marked, after=node.original[node.end + 1 :]) - ) - - # ------------------------------------------------------------------ - # LaTeX - # ------------------------------------------------------------------ - - def _value_latex(self) -> str | None: - if self.value is None: - return None - original = self.value.original_string() - # Unit forms inside a called name (``s`` and ``in`` in ``sin(2)``) would split - # the call in the preview, though the value itself is parsed as the call. - calls = called_names(original) - unit_forms = [ - form - for form in _unsplittable_names(self.params, all_forms=True) - if not any(form in call and form != call for call in calls) - ] - reserved = (*self.params.reserved_keywords, *unit_forms) - params = self.params.replace(reserved_keywords=reserved, rationalise=False) - if params.is_latex: - # A LaTeX value is previewed as-is (as expression_parsing.preview_function used to). - return original - try: - preprocessed = preprocess_expression(original, params, name="response") - except (BracketNotationError, AbsoluteValueNotationError) as exc: - # Best-effort: preview with the guessed rewrite rather than failing outright. - preprocessed = exc.expression - parsed = parse_expressions(preprocessed, SympyParsingConfig.from_params(params)) - settings = {"mul_symbol": r" \cdot "} - if len(parsed) > 1: - parts = sorted(sympy_to_latex(expr, params.symbols, settings=settings) for expr in parsed) - return "\\left\\{" + ",~".join(parts) + "\\right\\}" - return sympy_to_latex(parsed[0], params.symbols, settings=settings) - - # ------------------------------------------------------------------ - # Conversion to SI base units - # ------------------------------------------------------------------ - - def _expand_units(self, node: ExprNode) -> ExprNode: - """Replace non-base units by their expression in SI base units (in place where possible).""" - if node.label == Label.UNIT and not node.children and node.content not in _BASE_UNIT_NAMES: - node = _conversion_tree(self.parser, node.content).copy() - node.children = [self._expand_units(child) for child in node.children] - return node - - def _set_sympy_forms(self) -> None: - """Set the SymPy forms: the standard value and unit, the expanded unit, dimensions and unit factor.""" - config = self.parsing_config - value: Any = self.value.content_string() if self.value is not None else None - if value is not None and self.params.is_latex: - value = parse_latex(value, self.params.symbols, self.params.simplify) - unit: Any = None - expanded_unit: Any = None - dimension: Basic = Integer(1) - unit_factor: Basic = Integer(1) - if self.unit is not None: - expanded_unit_string = self._expand_units(self.unit.copy()).content_string() - try: - expanded_unit = unit = parse_expression(expanded_unit_string, config) - except Exception as e: # SymPy raises many types; all mean the expansion didn't parse - raise UnitConversionError(f"SymPy was unable to parse the {self.name} unit") from e - if self.value is not None: - base_symbols = {symbol: 1 for symbol in unit.free_symbols if str(symbol) in _BASE_UNIT_NAMES} - unit_factor = unit.subs(base_symbols).simplify() - unit = (unit / unit_factor).simplify(rational=True) - value = f"({value})*({unit_factor})" - dimension = parse_expression(substitute(expanded_unit_string, _BASE_UNIT_DIMENSIONS), config) - self.standard_values = None if value is None else parse_expressions(value, config) - self.standard_unit = unit - self.expanded_unit = expanded_unit - self.dimension = dimension - self.base_dimension = _without_factor(dimension) - self.unit_factor = unit_factor - def parse_quantity(expr: str, params: QuantityParams, name: str = "response") -> PhysicalQuantity: """Read ``expr`` as a physical quantity (value and/or unit). @@ -503,7 +232,7 @@ def parse_quantity(expr: str, params: QuantityParams, name: str = "response") -> roots = parser.parse(parser.scan(text.strip())) if len(roots) > 1: raise QuantityParseError("Parsed quantity does not have a single root.") - return PhysicalQuantity(name, params, roots[0], parser) + return PhysicalQuantity.from_tree(name, params, roots[0], parser) except QuantityError as exc: if exc.expression is None: exc.expression = expr diff --git a/units/tests/test_regressions.py b/units/tests/test_regressions.py index 52bf86f..899d60c 100644 --- a/units/tests/test_regressions.py +++ b/units/tests/test_regressions.py @@ -1,9 +1,20 @@ """Regression tests for bugs carried over from the v0.1 extraction.""" import pytest -from sympy import simplify - -from lambdafeedback.units import DisallowedUnitError, QuantityParams, QuantityParseError, RevertedUnit, parse_quantity +from sympy import Basic, simplify + +from lambdafeedback.slr_parsing import GROUP_LABEL, ExprNode +from lambdafeedback.units import ( + DisallowedUnitError, + PhysicalQuantity, + QuantityParams, + QuantityParseError, + RevertedUnit, + build_quantity_parser, + parse_quantity, +) +from lambdafeedback.units._split import split_value_and_unit +from lambdafeedback.units.quantity import _revert_value_text NATURAL = QuantityParams(strictness="natural") @@ -170,7 +181,47 @@ def test_strict_unit_before_juxtaposed_value_is_reverted_once(self, expr, revert # from the "kg 2" it now holds, or that span is reported as a unit too ("kilogram 2"). quantity = parse(expr, QuantityParams(strictness="strict")) assert quantity.value.content_string() == expr and quantity.unit is None - assert quantity.reverted_units == [RevertedUnit(*parts) for parts in reverted] + assert quantity.reverted_units == tuple(RevertedUnit(*parts) for parts in reverted) + + +class TestReadingSteps: + """The steps PhysicalQuantity.from_tree runs, each on a freshly parsed tree.""" + + @staticmethod + def tree(expr, params=NATURAL): + parser = build_quantity_parser(params.unit_sets, params.strictness) + return parser.parse(parser.scan(expr))[0], parser.tag_handler + + def test_split_raises_when_a_unit_is_between_value_parts(self): + # The rotations change direction: there is no split. + with pytest.raises(QuantityParseError, match="Cannot separate the value from the unit"): + split_value_and_unit(*self.tree("5 m/s x")) + + def test_split_keeps_a_group_whole(self): + root, value, unit = split_value_and_unit(*self.tree("(2 m)")) + assert root.label == GROUP_LABEL and unit is root and value is None + + def test_split_returns_the_rotated_root(self): + root, value, unit = split_value_and_unit(*self.tree("2 x m")) + assert (value.content_string(), unit.content_string()) == ("2 x", "metre") + assert root.children == [value, unit] + + def test_revert_value_text_reports_unit_like_parts(self): + _, value, unit = split_value_and_unit(*self.tree("2 kg + 3")) + assert unit is None + # The first entry is the group enclosing the unit, with garbled text (a known issue, see the README). + assert _revert_value_text(value) == ( + RevertedUnit(before="2", marked="2 kilogram", after="kg + 3"), + RevertedUnit(before="2 ", marked="kg", after=" + 3"), + ) + assert value.content_string() == "2 kg + 3" # restored as typed + + def test_quantity_is_frozen(self): + from dataclasses import FrozenInstanceError + + quantity = parse("5 km/h") + with pytest.raises(FrozenInstanceError): + quantity.value = None class TestPickling: @@ -183,15 +234,35 @@ def test_params_error_round_trips(self): error = pickle.loads(pickle.dumps(QuantityParamsError("strictness", "bad"))) assert (error.field, error.message, str(error)) == ("strictness", "bad", "Invalid parameter 'strictness': bad") - def test_parser_is_left_out_and_rebuilt(self): + def test_quantity_pickles_every_field_without_custom_state(self): + import pickle + from dataclasses import fields + + # The quantity no longer holds the parser (closures, which can't be pickled), so nothing is left out. + assert "__getstate__" not in vars(PhysicalQuantity) and "__setstate__" not in vars(PhysicalQuantity) + quantity = parse("5 km/h") + copy = pickle.loads(pickle.dumps(quantity)) + + def comparable(value): + if isinstance(value, ExprNode): + return value.content_string() + if isinstance(value, tuple): + return tuple(comparable(item) for item in value) + if isinstance(value, Basic): + # SymPy evaluates unevaluated expressions when unpickling (5*5/18 comes back as 25/18). + return str(simplify(value)) + return value # params, config, strings and RevertedUnits compare with == + + for field in fields(PhysicalQuantity): + assert comparable(getattr(copy, field.name)) == comparable(getattr(quantity, field.name)), field.name + + def test_parser_is_deprecated_and_still_the_cached_parser(self): from lambdafeedback.units import build_quantity_parser quantity = parse("5 km/h") - state = quantity.__getstate__() - assert "parser" not in state # it holds closures, which can't be pickled - copy = object.__new__(type(quantity)) - copy.__setstate__(state) - assert copy.parser is build_quantity_parser(quantity.params.unit_sets, quantity.params.strictness) + with pytest.deprecated_call(match="PhysicalQuantity.parser"): + parser = quantity.parser + assert parser is build_quantity_parser(quantity.params.unit_sets, quantity.params.strictness) def test_quantity_round_trips(self): import pickle