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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion units/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion units/src/lambdafeedback/units/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions units/src/lambdafeedback/units/_conversion.py
Original file line number Diff line number Diff line change
@@ -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,
)
115 changes: 115 additions & 0 deletions units/src/lambdafeedback/units/_latex.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading