diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..8376e0c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,37 @@ +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-slim + +# Matches the Python version used by GitHub Actions (test-lint.yml, staging-deploy.yml) +# and the production image (app/Dockerfile), so tests behave the same locally. + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + build-essential \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Node.js, required to run the Claude Code CLI +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code + +# Claude Code refuses --dangerously-skip-permissions as root unless it knows +# it's already inside an isolated sandbox (this container). +ENV IS_SANDBOX=1 + +# Run `claude` in this container with permission prompts bypassed by default, +# since the container itself (not the CLI) is the safety boundary here. This +# alias only affects the container image, never the host's `claude` config. +RUN echo "alias claude='claude --dangerously-skip-permissions'" >> /etc/bash.bashrc + +WORKDIR /workspace + +COPY app/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r /tmp/requirements.txt \ + && pip install --no-cache-dir pytest flake8 + +ENV PYTHONPATH=/workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8b4515f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +{ + "name": "compareExpressions", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", + + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/root/.claude,type=bind,consistency=cached" + ], + + "containerEnv": { + "PYTHONPATH": "/workspace", + "IS_SANDBOX": "1" + }, + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.flake8", + "anthropic.claude-code" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [] + } + } + }, + + "postCreateCommand": "pip install --no-cache-dir -r app/requirements.txt", + + "remoteUser": "root" +} diff --git a/.gitignore b/.gitignore index 14885ce..fc8ed43 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,18 @@ app/benchmark.py app/profiling_examples.py .idea/ + +# Extracted packages live here as their own separate git repo +/compareExpressions-packages/ + +# OS / editor / local tool files +.DS_Store +.vscode/ +.junie/ +.mcp.json + +# Local virtual environments (e.g. .venv38) +.venv*/ + +# Test reports written by CI +reports/ diff --git a/README.md b/README.md old mode 100755 new mode 100644 index c9040e9..e8b0029 --- a/README.md +++ b/README.md @@ -20,15 +20,15 @@ This version is specifically for python, however the ultimate goal is to make si - [Production API Gateway Integrations](https://eu-west-2.console.aws.amazon.com/apigateway/main/develop/integrations/attach?api=cttolq2oph&integration=qpbgva8®ion=eu-west-2&routes=0xsoy4q) 4. Merge commits into the default branch - - This will trigger the `test-and-deploy.yml` workflow, which will build the docker image, push it to a shared ECR repository, then call the backend `grading-function/ensure` route to build the necessary infrastructure to make the function available from the client app. + - This will trigger the `staging-deploy.yml` workflow (and `production-deploy.yml` on release), which will build the docker image, push it to a shared ECR repository, then call the backend `grading-function/ensure` route to build the necessary infrastructure to make the function available from the client app. 5. You are now ready to start developing your function: - Edit the `app/evaluation.py` file, which ultimately gets called when the function is given the `eval` command - - Edit the `app/evaluation_tests.py` file to add tests which get run: - - Every time you commit to this repo, before the image is built and deployed - - Whenever the `healthcheck` command is supplied to the deployed function - - Edit the `app/docs.md` file to reflect your changes. This file is baked into the function's image, and is made available using the `docs` command. This feature is used to display this function's documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website once it's been hooked up! + - Add pytest tests to `app/evaluation_test.py`, `app/preview_test.py` or `app/tests/`. They get run: + - On every pull request and before the image is built and deployed (`pytest` from the repo root) + - Whenever the `healthcheck` command is supplied to the deployed function (via `app/evaluation_tests.py`) + - Edit `app/docs/user.md` and `app/docs/dev.md` to reflect your changes. These files are baked into the function's image, and is made available using the `docs` command. This feature is used to display this function's documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website once it's been hooked up! --- @@ -36,6 +36,10 @@ This version is specifically for python, however the ultimate goal is to make si The function is built on top of a custom base layer, [BaseEvaluationFunctionLayer](https://github.com/lambda-feedback/BaseEvalutionFunctionLayer), which tools, tests and schema checking relevant to all evaluation functions. +### Packages + +Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages) repository (currently the `v0.3.0-rc.2` release candidate), installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. + ### Docker & Amazon Web Services (AWS) The grading scripts are hosted AWS Lambda, using containers to run a docker image of the app. Docker is a popular tool in software development that allows programs to be hosted on any machine by bundling all its requirements and dependencies into a single file called an **image**. @@ -57,7 +61,7 @@ Once the code passes all these tests, it will then be uploaded to AWS and will b ## Pre-requisites Although all programming can be done through the GitHub interface, it is recommended you do this locally on your machine. To do this, you must have installed: -- Python 3.8 or higher. +- Python 3.12 (the version used by CI and the production image). - GitHub Desktop or the `git` CLI. diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..e6a0295 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,12 @@ +# Build files +Dockerfile +.dockerignore + +# The base image provides the app package's __init__.py and handler +__init__.py + +# Local and generated files +**/__pycache__ +**/*.py[cod] +**/.DS_Store +**/.pytest_cache diff --git a/app/Dockerfile b/app/Dockerfile index 22e2aee..a28cf7c 100755 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,6 +1,4 @@ -# Base image that bundles AWS Lambda Python 3.8 image with some middleware functions -# FROM base-eval-tmp -# FROM rabidsheep55/python-base-eval-layer +# Base image that bundles the AWS Lambda Python image with the lambda-feedback middleware (app.handler) FROM ghcr.io/lambda-feedback/baseevalutionfunctionlayer:main-3.12 RUN dnf install -y git findutils && dnf clean all @@ -13,46 +11,11 @@ ENV PYTHONPATH=/app COPY requirements.txt . RUN pip3 install -r requirements.txt -# Copy main scripts -COPY evaluation.py ./app/ -COPY evaluation_test.py ./app/ -COPY preview.py ./app/ -COPY preview_test.py ./app/ - -# Copy contexts -COPY context/physical_quantity.py ./app/context/ -COPY context/symbolic.py ./app/context/ - -# Copy feedback messages -COPY feedback/physical_quantity.py ./app/feedback/ -COPY feedback/symbolic.py ./app/feedback/ - -# Copy preview implementations -COPY preview_implementations/physical_quantity_preview.py ./app/preview_implementations/ -COPY preview_implementations/symbolic_preview.py ./app/preview_implementations/ - -# Copy tests -COPY tests/example_test.py ./app/tests/ -COPY tests/physical_quantity_evaluation_test.py ./app/tests/ -COPY tests/physical_quantity_preview_test.py ./app/tests/ -COPY tests/slr_quantity_test.py ./app/tests/ -COPY tests/symbolic_evaluation_test.py ./app/tests/ -COPY tests/symbolic_preview_test.py ./app/tests/ - -# Copy utility code -COPY utility/criteria_graph_utilities.py ./app/utility/ -COPY utility/criteria_parsing.py ./app/utility/ -COPY utility/evaluation_result_utilities.py ./app/utility/ -COPY utility/expression_utilities.py ./app/utility/ -COPY utility/physical_quantity_utilities.py ./app/utility/ -COPY utility/preview_utilities.py ./app/utility/ -COPY utility/slr_parsing_utilities.py ./app/utility/ -COPY utility/syntactical_comparison_utilities.py ./app/utility/ -COPY utility/unit_system_conversions.py ./app/utility/ - -# Copy Documentation -COPY docs/dev.md ./app/docs/dev.md -COPY docs/user.md ./app/docs/user.md +# The healthcheck command runs the pytest suite (see evaluation_tests.py) +RUN pip3 install pytest + +# Copy the evaluation function source, tests and docs (exclusions in .dockerignore) +COPY . ./app/ # Set permissions so files and directories can be accessed on AWS RUN chmod 644 $(find . -type f) diff --git a/app/benchmarking.py b/app/benchmarking.py deleted file mode 100644 index 3bff3f9..0000000 --- a/app/benchmarking.py +++ /dev/null @@ -1,57 +0,0 @@ -benchmarks = [ - { - "response": "2a", - "answer": "a", - "params": { - "strict_syntax": False, - "elementary_functions": True, - "criteria": "response/answer=2", - } - }, - { - "response": "2*x**2 = 10*y**2+20", - "answer": "x**2-5*y**2-10=0", - "params": {"strict_syntax": False} - }, - { - "response": "1.24 mile/hour", - "answer": "1.24 mile/hour", - "params": { - "strict_syntax": False, - "elementary_functions": True, - "physical_quantity": True, - } - }, - { - "response": "sin(x)+2", - "answer": "sin(x)", - "params": { - "strict_syntax": False, - "elementary_functions": True, - "criteria": "Derivative(response,x)=cos(x)", - } - }, - { - "response": "cos(x)**2 + sin(x)**2 + y", - "answer": "y + 1", - "params": {"strict_syntax": False} - }, - { - "response": "log(2)/2+I*(3*pi/4 plus_minus 2*n*pi)", - "answer": "log(2)/2+I*(3*pi/4 plus_minus 2*n*pi)", - "params": { - "strict_syntax": False, - "elementary_functions": True, - } - }, - { - "response": "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)", - "answer": "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)", - "params": {"strict_syntax": False} - }, - { - "response": "-(sin(xy)y+(e^y))/(x(e^y+sin(xy)x))", - "answer": "-(y*sin(x*y) + e^(y)) / (x*(e^(y) + sin(x*y)))", - "params": {"strict_syntax": False} - }, -] diff --git a/app/conftest.py b/app/conftest.py new file mode 100644 index 0000000..adb5e82 --- /dev/null +++ b/app/conftest.py @@ -0,0 +1,2 @@ +def pytest_configure(config): + config.addinivalue_line("markers", "slow: excluded from the deployed healthcheck (see evaluation_tests.py)") diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 66f8923..b178fc9 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -2,47 +2,40 @@ # IMPORTS # ------- -import re from copy import deepcopy -from ..utility.physical_quantity_utilities import ( - SLR_quantity_parser, - SLR_quantity_parsing, expression_preprocess -) -from ..preview_implementations.physical_quantity_preview import preview_function -from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from ..utility.expression_utilities import default_parameters as symbolic_default_parameters -from ..utility.expression_utilities import ( - substitute_input_symbols, - create_sympy_parsing_params, +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import ( + ExpressionParams, + SympyParsingConfig, compute_relative_tolerance_from_significant_decimals, - parse_expression + parse_expression, ) -from ..utility.physical_quantity_utilities import ( - units_sets_dictionary, - set_of_SI_prefixes, - set_of_SI_base_unit_dimensions, - set_of_derived_SI_units_in_SI_base_units, - set_of_common_units_in_SI, - set_of_very_common_units_in_SI, - set_of_imperial_units, -) -from ..utility.slr_parsing_utilities import create_node, infix, operate, catch_undefined, SLR_Parser +from lambdafeedback.units import QuantityParams, preprocess_quantity +from lambdafeedback.units import parse_quantity as parse_physical_quantity +from .physical_quantity_preview import preview_function +from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators +from ..feedback.physical_quantity import reverted_unit_feedback +from .symbolic import default_parameters as symbolic_default_parameters +from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate from sympy import Symbol -from ..utility.criteria_graph_utilities import CriteriaGraph + +def expression_preprocess(name, expr, parameters): + return preprocess_quantity(expr, QuantityParams.from_dict(parameters), name=name) def parse_quantity(name, expr, parameters, evaluation_result): - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(expr, parameters, parser, name) - for message in quantity.messages: - evaluation_result.add_feedback(message) - if quantity.standard_value is not None: - standard_value = quantity.standard_value - else: + quantity = parse_physical_quantity(expr, parameters["quantity"], name) + for index, reverted in enumerate(quantity.reverted_units): + evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) + if quantity.standard_values is None: standard_value = None + elif len(quantity.standard_values) == 1: + standard_value = quantity.standard_values[0] + else: + standard_value = set(quantity.standard_values) if quantity.value is not None: - value = parse_expression(quantity.value.content_string(), quantity.parsing_params) + value = parse_expression(quantity.value.content_string(), quantity.parsing_config) else: value = None if quantity.standard_unit is not None: @@ -50,7 +43,7 @@ def parse_quantity(name, expr, parameters, evaluation_result): else: standard_unit = None if quantity.unit is not None: - unit = parse_expression(quantity.unit.content_string(), quantity.parsing_params) + unit = parse_expression(quantity.unit.content_string(), quantity.parsing_config) else: unit = None quantity_dict = { @@ -84,19 +77,19 @@ def generate_criteria_parser(reserved_expressions): } token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" *BOOL *", "BOOL"), - (" *QUANTITY *", "QUANTITY"), - (" *DIMENSION *", "DIMENSION"), - ("\( *", "START_DELIMITER"), - (" *\)", "END_DELIMITER"), - ("response", "QUANTITY"), - ("answer", "QUANTITY"), - ("INPUT", "INPUT", catch_undefined), + TokenPattern(start_symbol, start_symbol), + TokenPattern(end_symbol, end_symbol), + TokenPattern(null_symbol, null_symbol), + TokenPattern(Lexeme(("BOOL",), space_before=True, space_after=True), "BOOL"), + TokenPattern(Lexeme(("QUANTITY",), space_before=True, space_after=True), "QUANTITY"), + TokenPattern(Lexeme(("DIMENSION",), space_before=True, space_after=True), "DIMENSION"), + TokenPattern(Lexeme(("(",), space_after=True), "START_DELIMITER"), + TokenPattern(Lexeme((")",), space_before=True), "END_DELIMITER"), + TokenPattern("response", "QUANTITY"), + TokenPattern("answer", "QUANTITY"), + CatchAll("INPUT", "INPUT"), ] - token_list += [(" *"+x+" *", " "+x+" ") for x in criteria_operations] + token_list += [TokenPattern(Lexeme((x,), space_before=True, space_after=True), " "+x+" ") for x in criteria_operations] productions = [ ("START", "BOOL", create_node), @@ -111,7 +104,7 @@ def generate_criteria_parser(reserved_expressions): ("DIMENSION", "dimension(QUANTITY)", operate(1)), ] - return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) + return SLRParser(token_list, productions, start_symbol, end_symbol, null_symbol) def comparison_function(comparison_operator): @@ -136,16 +129,9 @@ def comparison_function_inner(lhs, rhs, substitutions): def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() - parsing_params = deepcopy(parameters["parsing_parameters"]) - if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: - ans = parameters["reserved_expressions"]["answer"]["quantity"].value - if ans is not None: - rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) - parsing_params.update({'rtol': rtol}) - parsing_params.update({"simplify": False, "evaluate": False}) + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) if label is None: label = criterion.content_string() @@ -171,7 +157,7 @@ def compare_evaluate(unused_input): details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", evaluate=compare_evaluate ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", @@ -179,7 +165,7 @@ def compare_evaluate(unused_input): feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", @@ -214,16 +200,15 @@ def less_than_or_equal_node(criterion, parameters, label=None): def criterion_match_node(criterion, parameters, label=None): graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() - parsing_params = deepcopy(parameters["parsing_parameters"]) + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) + rtol = parameters["parsing_parameters"]["rtol"] + atol = parameters["parsing_parameters"]["atol"] if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: ans = parameters["reserved_expressions"]["answer"]["quantity"].value if ans is not None: rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) - parsing_params.update({'rtol': rtol}) - parsing_params.update({"simplify": False, "evaluate": False}) if label is None: label = criterion.content_string() @@ -277,16 +262,16 @@ def quantity_match(unused_inputs): if value_match is False: # TODO: better analysis of where `answer` is found in the criteria so that # numerical tolerances can be applied appropriately - if parsing_params.get('rtol', 0) > 0 or parsing_params.get('atol', 0) > 0: + if rtol > 0 or atol > 0: if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify() res = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify() if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): - if parsing_params.get('rtol', 0) > 0 and (ans != 0): - value_match = bool(abs(float((ans-res)/ans)) < parsing_params['rtol']) - elif parsing_params.get('atol', 0) > 0 or (ans == 0): - answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].converted_unit_factor) - value_match = bool(abs(float(ans-res)) < parsing_params['atol']*answer_unit_factor) + if rtol > 0 and (ans != 0): + value_match = bool(abs(float((ans-res)/ans)) < rtol) + elif atol > 0 or (ans == 0): + answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].unit_factor) + value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] unit_match = is_equal(lhs, rhs, substitutions) @@ -349,21 +334,21 @@ def unit_comparison(unused_inputs): details=f"Converts {inputs[0]} and {inputs[1]} match to a common set of base units and compares their values.", evaluate=quantity_match ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=f"The quantities {inputs[0]} and {inputs[1]} match.", details=f"The quantities {inputs[0]} and {inputs[1]} match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MATCH") ) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=f"The quantities {inputs[0]} and {inputs[1]} does not match.", details=f"The quantities {inputs[0]} and {inputs[1]} does not match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MISMATCH") ) - graph.attach( + graph.attach_new_criterion( label, label+"_MISSING_VALUE", summary="The response is missing a value.", @@ -371,7 +356,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE") ) graph.attach(label+"_MISSING_VALUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNEXPECTED_VALUE", summary="The response is expected only have unit(s), no value.", @@ -379,7 +364,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE") ) graph.attach(label+"_UNEXPECTED_VALUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_MISSING_UNIT", summary="The response is missing unit(s).", @@ -387,7 +372,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT") ) graph.attach(label+"_MISSING_UNIT", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNEXPECTED_UNIT", summary="The response is expected to be a value without unit(s).", @@ -395,21 +380,21 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") ) graph.attach(label+"_UNEXPECTED_UNIT", END.label) - graph.attach( + graph.attach_new_evaluation( label+"_FALSE", label+"_DIMENSION_MATCH", summary=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", details=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", evaluate=dimension_match ) - graph.attach( + graph.attach_new_criterion( label+"_DIMENSION_MATCH", label+"_DIMENSION_MATCH"+"_TRUE", summary=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", details=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MATCH") ) - graph.attach( + graph.attach_new_criterion( label+"_DIMENSION_MATCH", label+"_DIMENSION_MATCH"+"_FALSE", summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", @@ -418,28 +403,28 @@ def unit_comparison(unused_inputs): ) graph.attach(label+"_DIMENSION_MATCH"+"_TRUE", END.label) graph.attach(label+"_DIMENSION_MATCH"+"_FALSE", END.label) - graph.attach( + graph.attach_new_evaluation( label+"_TRUE", label+"_UNIT_COMPARISON", summary=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", details=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", evaluate=unit_comparison ) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_IDENTICAL", summary=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", details=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_IDENTICAL") ) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_SIMILAR", summary=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", details=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_SIMILAR") ) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", @@ -447,7 +432,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE") ) graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", END.label) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", @@ -473,29 +458,33 @@ def feedback_procedure_generator(parameters_dict): graph = graph_template(criterion, parameters_dict) for evaluation in graph.evaluations.values(): if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END + evaluation.replacement = CriteriaGraph.END_LABEL graphs.update({label: graph}) return graphs -def feedback_string_generator(tags, graph, parameters_dict): - strings = dict() - for tag in tags: - # feedback_string = graph.criteria[tag].feedback_string_generator(inputs) - feedback_string = "PLACEHOLDER" - if feedback_string is not None: - strings.update({tag: feedback_string}) - return +# The evaluation parameters that affect how quantities are read. Other parameters +# (e.g. `symbols`, `units_string` and `is_latex`) have never applied when reading +# the response and answer as quantities during evaluation, only in the preview. +QUANTITY_PARSING_PARAMETERS = ( + "complexNumbers", + "convention", + "elementary_functions", + "rationalise", + "reserved_keywords", + "simplify", + "strict_syntax", + "strictness", +) def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): - parsing_parameters = create_sympy_parsing_params(params) - parsing_parameters.update({ - "strictness": params.get("strictness", "natural"), + return { + "sympy": SympyParsingConfig.from_params(ExpressionParams.from_dict(params)), + "quantity": QuantityParams.from_dict({key: params[key] for key in QUANTITY_PARSING_PARAMETERS if key in params}), "rtol": float(params.get("rtol", 0)), "atol": float(params.get("atol", 0)), - }) - return parsing_parameters + } # CONSIDER: Move these to separate file so that they can be shared with @@ -521,6 +510,5 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as "default_parameters": default_parameters, "default_criteria": default_criteria, "feedback_procedure_generator": feedback_procedure_generator, - "feedback_string_generator": feedback_string_generator, "parsing_parameters_generator": parsing_parameters_generator, } diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py new file mode 100644 index 0000000..427510e --- /dev/null +++ b/app/context/physical_quantity_preview.py @@ -0,0 +1,91 @@ +"""Previewing a quantity response: LaTeX and SymPy renderings of value and unit.""" + +from __future__ import annotations + +import tokenize +from collections.abc import Mapping +from typing import Any + +from lambdafeedback.expression_parsing import ( + SympyParsingConfig, + find_matching_parenthesis, + parse_expression, + parse_latex, + sanitise_latex, + sympy_to_latex, +) +from lambdafeedback.units import ( + QuantityError, + QuantityParams, + QuantityParseError, + parse_quantity, + preprocess_quantity, +) + +from ..symbolic_preview import Preview, Result + + +def fix_exponents(response: str) -> str: + """Rewrite LaTeX exponents ``^{...}`` / ``**{...}`` as ``**(...)``. + + Only a brace directly after the exponent operator is unwrapped; other + exponents (``x^2``) just get ``**``. + """ + for notation in ("^", "**"): + processed = [] + index = 0 + while index < len(response): + exponent_start = response.find(notation, index) + if exponent_start < 0: + processed.append(response[index:]) + break + processed.append(response[index:exponent_start] + "**") + index = exponent_start + len(notation) + if response.startswith("{", index): + exponent_end = find_matching_parenthesis(response, index, delimiters=("{", "}")) + if exponent_end > 0: + processed.append("(" + response[index + 1:exponent_end] + ")") + index = exponent_end + 1 + response = "".join(processed) + return response + + +def preview_function(response: str, params: QuantityParams | Mapping[str, Any]) -> Result: + """Preview a quantity response as LaTeX and SymPy syntax. + + ``params`` may be :class:`QuantityParams` or evaluation-function + parameters. Raises :class:`QuantityParseError` if the response can't be + read as a quantity. + """ + params = params if isinstance(params, QuantityParams) else QuantityParams.from_dict(params) + if not response: + return Result(preview=Preview(latex="", sympy="")) + + try: + if params.is_latex: + response = fix_exponents(sanitise_latex(response)) + quantity = parse_quantity(response, params) + value, unit = quantity.value, quantity.unit + value_latex = value_sympy = "" + if value is not None: + value_string = parse_latex(value.content_string(), params.symbols, params.simplify) + value_expr = parse_expression(value_string, SympyParsingConfig.from_params(params)) + value_latex = sympy_to_latex(value_expr, params.symbols) + value_sympy = str(value_expr) + separators = ("~", " ") if value is not None and unit is not None else ("", "") + unit_latex = quantity.unit_latex if unit is not None else "" + unit_sympy = unit.content_string() if unit is not None else "" + latex_out = value_latex + separators[0] + (unit_latex or "") + sympy_out = value_sympy + separators[1] + unit_sympy + else: + preprocessed = preprocess_quantity(response, params, name="response") + latex_out = parse_quantity(preprocessed, params).latex + sympy_out = response + except QuantityError: + raise + except (SyntaxError, tokenize.TokenError) as e: + raise QuantityParseError("Failed to parse Sympy expression") from e + except ValueError as e: + raise QuantityParseError("Failed to parse LaTeX expression") from e + + return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 960c989..04beb90 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,39 +1,54 @@ -from copy import deepcopy -from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo +from sympy import Add, Pow, Mul, Equality, pi, im, I, N from sympy import re as real_part -from ..utility.expression_utilities import ( - default_parameters, +from lambdafeedback.expression_parsing import ( + ExpressionParams, + SympyParsingConfig, + generate_arbitrary_number_pattern_matcher, parse_expression, - create_sympy_parsing_params, + parse_expressions, preprocess_expression, ) -from ..preview_implementations.symbolic_preview import preview_function +from ..symbolic_preview import preview_function from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ..feedback.syntactical_forms import PATTERNS as syntactical_forms -from ..utility.syntactical_comparison_utilities import patterns as syntactical_forms -from ..utility.syntactical_comparison_utilities import generate_arbitrary_number_pattern_matcher +from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser -from ..utility.criteria_parsing import generate_criteria_parser -from ..utility.criteria_graph_utilities import CriteriaGraph + +default_parameters = { + "complexNumbers": False, + "convention": "equal_precedence", + "elementary_functions": False, + "strict_syntax": False, + "multiple_answers_criteria": "all", +} def expression_preprocess(name, expr, parameters): - return preprocess_expression(name, expr, parameters) + return preprocess_expression(expr, ExpressionParams.from_dict(parameters), name=name) def expression_parse(name, expr, parameters, evaluation_result): - return parse_expression(expr, parameters) + expressions = parse_expressions(expr, parameters) + return expressions[0] if len(expressions) == 1 else set(expressions) default_criteria = ["response = answer"] +def unsimplified_with_unsplittable(parsing_params, symbols): + """`parsing_params` without simplification, with `symbols` also read as single symbols.""" + return parsing_params.replace( + simplify=False, + extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(symbols), + ) + + def check_criterion(criterion, parameters_dict, generate_feedback=True): label = criterion.label.strip() - parsing_params = deepcopy(parameters_dict["parsing_parameters"]) - parsing_params.update({"simplify": False}) + parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) if label in {"EQUALITY", "WRITTEN_AS"}: result = check_equality(criterion, parameters_dict) elif label == "ORDER": @@ -61,20 +76,14 @@ def check_criterion(criterion, parameters_dict, generate_feedback=True): def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): - parsing_params = deepcopy(parameters_dict["parsing_parameters"]) + parsing_params = parameters_dict["parsing_parameters"] reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params.update( - { - "simplify": False, - "evaluate": False, - "unsplittable_symbols": parsing_params["unsplittable_symbols"]+list((parameters_dict["reserved_expressions"].keys())), - } - ) + parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) rhs_expr = parse_expression(rhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) - if parsing_params.get("complexNumbers", False): + if parsing_params.params.complex_numbers: simplified_lhs_expr = lhs_expr.cancel().simplify() simplified_rhs_expr = rhs_expr.cancel().simplify() if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): @@ -345,7 +354,7 @@ def equality_equivalence(unused_input): symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols else: symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"]["constants"]) + result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) if result is True: return { label+"_TRUE": None @@ -356,20 +365,14 @@ def equality_equivalence(unused_input): } graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() def same_symbols(unused_input): - parsing_params = deepcopy(parameters_dict["parsing_parameters"]) + parsing_params = parameters_dict["parsing_parameters"] local_substitutions = list(parameters_dict["reserved_expressions"].items()) - parsing_params.update( - { - "simplify": False, - "unsplittable_symbols": parsing_params["unsplittable_symbols"]+list((parameters_dict["reserved_expressions"].keys())), - } - ) + parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols @@ -406,14 +409,14 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+"="+str(rhs)+".", evaluate=set_equivalence ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(lhs)+"="+str(rhs), details=str(lhs)+" is equal to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) - graph.attach( + graph.attach_new_criterion( label, label+"_MULTIPLE_ANSWER_FAIL_ALL", summary=str(lhs)+" is not equal to "+str(rhs), @@ -421,7 +424,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") ) graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", summary="Unexpected element in response.", @@ -429,7 +432,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE") ) graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_MULTIPLE_ANSWER_FAIL_ANSWER", summary="Missing element in response", @@ -444,7 +447,7 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", evaluate=equality_equivalence ) - graph.attach( + graph.attach_new_criterion( label, label+"_UNKNOWN", summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), @@ -452,7 +455,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN") ) graph.attach(label+"_UNKNOWN", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(lhs)+" is equivalent to "+str(rhs), @@ -460,7 +463,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=str(lhs)+" is not equivalent to "+str(rhs), @@ -468,7 +471,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") ) graph.attach(label+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_EXPRESSION_NOT_EQUALITY", summary=str(lhs)+" is an expression, not an equality.", @@ -476,7 +479,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") ) graph.attach(label+"_EXPRESSION_NOT_EQUALITY", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_EQUALITY_NOT_EXPRESSION", summary=str(lhs)+" is an equality, not an expression.", @@ -491,21 +494,21 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+"="+str(rhs)+".", evaluate=mathematical_equivalence ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(lhs)+"="+str(rhs), details=str(lhs)+" is equal to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) - graph.attach( + graph.attach_new_evaluation( label+"_TRUE", label+"_SAME_SYMBOLS", summary=str(lhs)+" has the same symbols as "+str(rhs), details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", evaluate=same_symbols ) - graph.attach( + graph.attach_new_criterion( label+"_SAME_SYMBOLS", label+"_SAME_SYMBOLS"+"_TRUE", summary=str(lhs)+" has the same symbols as "+str(rhs), @@ -513,7 +516,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") ) graph.attach(label+"_SAME_SYMBOLS"+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label+"_SAME_SYMBOLS", label+"_SAME_SYMBOLS"+"_FALSE", summary=str(lhs)+" does not have the same symbols as "+str(rhs), @@ -521,7 +524,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") ) graph.attach(label+"_SAME_SYMBOLS"+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=str(lhs)+"=\\="+str(rhs), @@ -529,7 +532,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") ) graph.attach(label+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNKNOWN", summary="Cannot detrmine if "+str(lhs)+"="+str(rhs), @@ -548,7 +551,7 @@ def written_as_known_form(string, name): def find_form(unused_input): forms = set() for form_label in syntactical_forms.keys(): - if syntactical_forms[form_label]["matcher"](string) is True: + if syntactical_forms[form_label].matcher(string) is True: forms.add(form_label) if len(forms) > 0: return {name+"_WRITTEN_AS_"+form_label: None for form_label in forms} @@ -559,7 +562,7 @@ def find_form(unused_input): def compare_to_form(string, form_label): def inner(unused_input): result = dict() - if syntactical_forms[form_label]["matcher"](string) is True: + if syntactical_forms[form_label].matcher(string) is True: result.update({label+"_TRUE": None}) else: result.update({label+"_FALSE": None}) @@ -567,8 +570,7 @@ def inner(unused_input): return inner graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + graph.add_end_node() lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() @@ -592,14 +594,14 @@ def response_written_as_answer(unused_input): details=f"Checks if {rhs} is written in a known form.", evaluate=written_as_known_form(answer, rhs) ) - graph.attach( + graph.attach_new_criterion( str(rhs)+"_WRITTEN_AS_KNOWN_FORM", str(rhs)+"_WRITTEN_AS_UNKNOWN", summary=str(rhs)+" is not written in a known form.", details=str(rhs)+" is not written in a known form.", feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"]("UNKNOWN") ) - graph.attach( + graph.attach_new_evaluation( str(rhs)+"_WRITTEN_AS_UNKNOWN", label, summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".", @@ -607,14 +609,14 @@ def response_written_as_answer(unused_input): evaluate=response_written_as_answer, sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"} ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(rhs)+" written in the same form as "+str(rhs)+".", details=str(rhs)+" written in the same form as "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("TRUE") ) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=str(rhs)+" written in the same form as "+str(rhs)+".", @@ -623,18 +625,18 @@ def response_written_as_answer(unused_input): ) for form_label in syntactical_forms.keys(): - graph.attach( + graph.attach_new_criterion( str(rhs)+"_WRITTEN_AS_KNOWN_FORM", str(rhs)+"_WRITTEN_AS_"+form_label, - summary=str(rhs)+" in written in "+syntactical_forms[form_label]["name"]+" form.", - details=str(rhs)+" in written in "+syntactical_forms[form_label]["name"]+" form.", + summary=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", + details=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"](form_label), ) - graph.attach( + graph.attach_new_evaluation( str(rhs)+"_WRITTEN_AS_"+form_label, str(lhs)+"_COMPARE_FORM_TO_"+form_label, - summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label]["name"]+" form.", - details="Checks if "+str(lhs)+" is written in "+syntactical_forms[form_label]["name"]+" form.", + summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label].name+" form.", + details="Checks if "+str(lhs)+" is written in "+syntactical_forms[form_label].name+" form.", evaluate=compare_to_form(response, form_label), sufficiencies={str(rhs)+"_WRITTEN_AS_"+form_label} ) @@ -673,15 +675,14 @@ def expression_check(unused_input): return expression_check graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() graph.add_evaluation_node( label, summary=label, details="Checks if "+expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+".", evaluate=create_expression_check(expression) ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), @@ -689,7 +690,7 @@ def expression_check(unused_input): feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary="not "+expression.content_string(), @@ -741,14 +742,14 @@ def identify_reason(unused_input): label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) } return reasons - graph.attach( + graph.attach_new_evaluation( label+"_FALSE", label+"_IDENTIFY_REASON", summary="Identify reason.", details="Attempt to identify why the response is incorrect.", evaluate=identify_reason ) - graph.attach( + graph.attach_new_criterion( label+"_IDENTIFY_REASON", label+"_UNKNOWN", summary="Unknown reason", @@ -764,14 +765,14 @@ def get_candidates(unused_input): return candidates for (group_label, group_info) in variation_groups.items(): - graph.attach( + graph.attach_new_criterion( label+"_IDENTIFY_REASON", label+"_"+group_label, summary=group_info["summary"](expression_to_vary, group_info["variations"]), details=group_info["details"](expression_to_vary, group_info["variations"]), feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) - graph.attach( + graph.attach_new_evaluation( label+"_"+group_label, label+"_GET_CANDIDATES_"+group_label, summary="Get candidate responses that satisfy "+expression.content_string(), @@ -785,7 +786,7 @@ def get_candidates(unused_input): if group_label != "UNKNOWN": group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: - graph.attach( + graph.attach_new_criterion( label+"_GET_CANDIDATES_"+group_label, label+"_RESPONSE_CANDIDATES_"+group_label, summary="response = "+str(value), @@ -819,10 +820,9 @@ def evaluation_node_internal(unused_input): label = criterion.content_string() graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary="True", @@ -830,7 +830,7 @@ def evaluation_node_internal(unused_input): feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary="True", @@ -838,7 +838,7 @@ def evaluation_node_internal(unused_input): feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") ) graph.attach(label+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNKNOWN", summary="True", @@ -862,30 +862,22 @@ def feedback_procedure_generator(parameters_dict): graph = graph_template(criterion, parameters_dict) for evaluation in graph.evaluations.values(): if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END + evaluation.replacement = CriteriaGraph.END_LABEL graphs.update({label: graph}) return graphs -def feedback_string_generator(tags, graph, parameters_dict): - strings = dict() - for tag in tags: - # feedback_string = graph.criteria[tag].feedback_string_generator(inputs) - feedback_string = "PLACEHOLDER" - if feedback_string is not None: - strings.update({tag: feedback_string}) - return +def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): + return SympyParsingConfig.from_params(ExpressionParams.from_dict(params), unsplittable_symbols, symbol_assumptions) context = { "expression_preview": preview_function, - "generate_criteria_parser": generate_criteria_parser, - "generate_feedback": generate_criteria_parser, + "generate_criteria_parser": build_criteria_parser, "expression_preprocess": expression_preprocess, "expression_parse": expression_parse, "default_parameters": default_parameters, "default_criteria": default_criteria, "feedback_procedure_generator": feedback_procedure_generator, - "feedback_string_generator": feedback_string_generator, - "parsing_parameters_generator": create_sympy_parsing_params, + "parsing_parameters_generator": parsing_parameters_generator, } diff --git a/app/docs/dev.md b/app/docs/dev.md index 7ada921..e3782fd 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -48,7 +48,7 @@ The overall flow of the evaluation procedure can be described as follows: ### Context The context is a data structure that contains at least the following seven pieces of information: -- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `utility\expression_utilities.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.py`. +- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `context\symbolic.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.py`. - `expression_parse` function that parses expressions (i.e. the `response` and `answer` inputs) into the form used by the feedback generation procedure. - `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. - `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. @@ -206,11 +206,34 @@ The function returns result dictionary with the following fields: # Preview function -When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions can be found in `preview_implementations`. +When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions are `symbolic_preview.py` (symbolic expressions) and `context/physical_quantity_preview.py` (physical quantities), built on the parsing from the `lambdafeedback-expression-parsing` and `lambdafeedback-units` packages, see [Packages](#packages). **Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case the `preview.py` and `evaluation.py` could also share the same code for determining the right context to use. -`E` is treated as `E` in when `is_latex` is enabled. As latex2sympy2 does not parse E correctly. More details can be found ont the PR: https://github.com/lambda-feedback/compareExpressions/pull/235 +When `is_latex` is enabled, `e` and `E` are read as plain symbols by the LaTeX parser (latex2sympy2_extended); Euler's number comes from `elementary_functions` when the result is parsed, as for text input. See the PR for the earlier latex2sympy2 workaround: https://github.com/lambda-feedback/compareExpressions/pull/235 + +# Packages + +The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.2` release candidate. Each package's `CHANGELOG.md` lists the API changes: + +| Package | Import | Used for | +|---|---|---| +| `lambdafeedback-slr-parsing` | `lambdafeedback.slr_parsing` | The SLR parser behind the criteria and quantity grammars | +| `lambdafeedback-criteria` | `lambdafeedback.criteria` | Parsing criteria, criteria graphs, turning reached criteria into feedback | +| `lambdafeedback-expression-parsing` | `lambdafeedback.expression_parsing` | Preprocessing and parsing expressions with SymPy and LaTeX | +| `lambdafeedback-units` | `lambdafeedback.units` | Unit data, reading physical quantities, dimensional analysis | + +The packages don't contain feedback text. They raise typed exceptions (e.g. `BracketNotationError`) or return plain facts (e.g. `PhysicalQuantity.reverted_units`); the app maps these to a `FeedbackTag` (`feedback/tags.py`), and `feedback/symbolic.py` and `feedback/physical_quantity.py` turn them into text. The "written as" forms (`feedback/syntactical_forms.py`) live in the app for the same reason. `result.py` holds the `EvaluationResult` that collects feedback and serialises the response. + +To work on the app and the packages together, install the packages from a local checkout in editable mode instead of from git: + +```bash +pip install -e ../lambda-feedback-packages/slr_parsing -e ../lambda-feedback-packages/criteria \ + -e ../lambda-feedback-packages/expression_parsing -e ../lambda-feedback-packages/units +grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin +``` # Tests -There are two main groups of tests, evaluation tests and preview tests. The evaluation test can be run by calling `evaluation_tests.py` \ No newline at end of file +There are two main groups of tests, evaluation tests (`evaluation_test.py` and `tests/*_evaluation_test.py`) and preview tests (`preview_test.py` and `tests/*_preview_test.py`). Run them all with `pytest` from the repository root. + +The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. \ No newline at end of file diff --git a/app/evaluation.py b/app/evaluation.py index 4f86527..8b7f4f4 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,10 +1,18 @@ from copy import deepcopy -from .utility.evaluation_result_utilities import EvaluationResult -from .utility.preview_utilities import parse_latex +from lambdafeedback.expression_parsing import ( + AbsoluteValueNotationError, + BracketNotationError, + ExpressionParams, + parse_latex, +) + +from .result import EvaluationResult from .context.symbolic import context as symbolic_context from .context.physical_quantity import context as quantity_context from .feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from .feedback.symbolic import internal_feedback +from .feedback.tags import FeedbackTag, FeedbackTagName from collections.abc import Mapping @@ -13,6 +21,13 @@ } +def notation_error_feedback(error, original_text): + """`FeedbackTag` for a `BracketNotationError`/`AbsoluteValueNotationError` raised while preprocessing.""" + if isinstance(error, BracketNotationError): + return FeedbackTag(FeedbackTagName.BRACKET_NOTATION_MISMATCH, {"x": original_text}) + return FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": error.name}) + + class FrozenValuesDictionary(dict): """ A dictionary where new key:value pairs can be added, @@ -57,11 +72,8 @@ def determine_context(parameters): reserved_keywords_codes = {"where", "written as", "contains", "proportional to"} reserved_keywords_aliases = {"plus_minus", "minus_plus"} - for re in parameters["reserved_expressions_strings"].values(): - reserved_keywords_aliases = reserved_keywords_aliases.union(set(re.keys())) - - for value in parameters["reserved_expressions_strings"].values(): - reserved_keywords = reserved_keywords_aliases.union(set(value.keys())) + for expressions in parameters["reserved_expressions_strings"].values(): + reserved_keywords_aliases = reserved_keywords_aliases.union(set(expressions.keys())) reserved_keywords_codes_collisions = [] for keyword in reserved_keywords_codes: @@ -75,7 +87,6 @@ def determine_context(parameters): reserved_keywords_aliases_collisions = [] for keyword in reserved_keywords_aliases: if keyword in input_symbols_reserved_aliases: - print("Collision found") reserved_keywords_aliases_collisions.append(keyword) if len(reserved_keywords_aliases_collisions) > 0: if len(reserved_keywords_aliases_collisions) == 1: @@ -83,8 +94,7 @@ def determine_context(parameters): else: raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` are reserved keywords and cannot be used as input symbol alternatives.") - reserved_keywords = reserved_keywords_codes - context.update({"reserved_keywords": list(reserved_keywords)}) + context.update({"reserved_keywords": list(reserved_keywords_codes)}) return context @@ -114,23 +124,25 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): reserved_expressions_dict.update({key: FrozenValuesDictionary()}) for (label, expr) in reserved_expressions[key].items(): expr_parsed = None - preprocess_success, expr, preprocess_feedback = preprocess(key, expr, parameters) - if preprocess_success is False: + original_expr = expr + try: + expr = preprocess(key, expr, parameters) + except (BracketNotationError, AbsoluteValueNotationError) as e: + feedback = notation_error_feedback(e, original_expr) + expr = e.expression if key == "learner": - result.add_feedback(preprocess_feedback) - preprocess_success = True # Preprocess can only create warnings for responses in this case + # Preprocess can only create warnings for responses in this case + result.add_feedback(str(feedback.tag), internal_feedback(feedback)) else: - raise Exception(preprocess_feedback[1], preprocess_feedback[0]) + raise Exception(internal_feedback(feedback), str(feedback.tag)) from e reserved_expressions[key][label] = expr if not isinstance(expr, str): raise Exception(f"Reserved expression {label} must be given as a string.") if len(expr.strip()) == 0: if key == "learner": result.add_feedback( - ( - f"RESERVED_EXPRESSION_MISSING_{label}", - messages["RESERVED_EXPRESSION_MISSING"](label) - ) + f"RESERVED_EXPRESSION_MISSING_{label}", + messages["RESERVED_EXPRESSION_MISSING"](label) ) else: raise Exception(messages["RESERVED_EXPRESSION_MISSING"](label), f"RESERVED_EXPRESSION_MISSING_{label}") @@ -143,10 +155,8 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): success = False if key == "learner": result.add_feedback( - ( - f"PARSE_ERROR_{label}", - symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) - ) + f"PARSE_ERROR_{label}", + symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) ) else: raise Exception(symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})) from e @@ -165,10 +175,12 @@ def get_criteria_string(parameters): def create_criteria_dict(criteria_parser, parsing_params): preprocess = parsing_params["context"]["expression_preprocess"] - criteria_string = get_criteria_string(parsing_params) - preprocess_success, criteria_string, preprocess_feedback = preprocess("criteria", criteria_string, parsing_params) - if preprocess_success is False: - raise Exception(preprocess_feedback[1], preprocess_feedback[0]) + original_criteria_string = get_criteria_string(parsing_params) + try: + criteria_string = preprocess("criteria", original_criteria_string, parsing_params) + except (BracketNotationError, AbsoluteValueNotationError) as e: + feedback = notation_error_feedback(e, original_criteria_string) + raise Exception(internal_feedback(feedback), str(feedback.tag)) from e criteria_string_list = [] delims = [ ("(", ")"), @@ -205,7 +217,7 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): response = evaluation_parameters["reserved_expressions"]["response"] criteria_feedback = set() is_correct = True - custom_feedback = evaluation_parameters.get("custom_feedback",{}) + custom_feedback = evaluation_parameters.get("custom_feedback", {}) for (criterion_identifier, graph) in criteria_graphs.items(): # TODO: Find better way to identify main criteria for criteria graph main_criteria = criterion_identifier+"_TRUE" @@ -213,14 +225,14 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): # TODO: Implement way to define completeness of task other than "all main criteria satisfied" is_correct = is_correct and main_criteria in criteria_feedback - evaluation_result.add_criteria_graph(criterion_identifier, graph) + evaluation_result.criteria_graphs[criterion_identifier] = graph # Generate feedback strings from found feedback # NOTE: Feedback strings are generated for each graph due to the # assumption that some way to return partial feedback # before script has executed completely will be available # in the future - evaluation_result.add_feedback_from_tags(criteria_feedback, graph, custom_feedback=custom_feedback) + graph.export_feedback(evaluation_result, criteria_feedback, custom_feedback=custom_feedback) evaluation_result.is_correct = is_correct return @@ -270,7 +282,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di "reserved_keywords": context["reserved_keywords"]+reserved_expressions_keys, } ) - parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys) + parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys) parameters.update( { "parsing_parameters": parsing_parameters, @@ -283,20 +295,20 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di if parameters.get("is_latex", False): parameters["reserved_expressions_strings"]["learner"].update( { - "response": parse_latex(response, parameters.get("symbols", {}), False, parameters=parameters), + "response": parse_latex(response, ExpressionParams.from_dict(parameters).symbols), } ) - # FIXME: Move this into expression_utilities + # FIXME: Move these notation warnings into lambdafeedback.expression_parsing if params.get("strict_syntax", False): if "^" in response: - evaluation_result.add_feedback(("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict()))) + evaluation_result.add_feedback("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict())) if "!" in response: - evaluation_result.add_feedback(("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict()))) + evaluation_result.add_feedback("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict())) if "!!!" in response: evaluation_result.add_feedback( - ("NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict()))) + "NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict())) reserved_expressions_success, reserved_expressions = parse_reserved_expressions(reserved_expressions_strings, parameters, evaluation_result) if reserved_expressions_success is False: @@ -335,7 +347,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di "numerical": parameters.get("numerical", False), "atol": parameters.get("atol", 0), "rtol": parameters.get("rtol", 0), - "custom_feedback": parameters.get("custom_feedback",{}), + "custom_feedback": parameters.get("custom_feedback", {}), } ) diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 6e97e9b..1239a68 100755 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -1,8 +1,6 @@ import pytest -import os from .preview import preview_function -from .utility.preview_utilities import Params from .evaluation import evaluation_function @@ -24,15 +22,6 @@ class TestEvaluationFunction(): Use evaluation_function() to call the evaluation function. """ - # Import tests that makes sure that mathematical expression comparison works as expected - from .tests.symbolic_evaluation_test import TestEvaluationFunction as TestSymbolicComparison - - # Import tests that makes sure that physical quantities are handled as expected - from .tests.physical_quantity_evaluation_test import TestEvaluationFunction as TestQuantities - - # Import tests that corresponds to examples in documentation and examples module - from .tests.example_test import TestEvaluationFunction as TestExamples - def test_eval_function_can_handle_latex_input(self): response = r"\sin x + x^{7}" answer = "sin(x)+x**7" @@ -96,7 +85,7 @@ def test_physical_qualities_no_tolerance(self): def test_euler_preview_evaluate(self): response = "ER_2" - params = Params(is_latex=True, elementary_functions=False, strict_syntax=False) + params = dict(is_latex=True, elementary_functions=False, strict_syntax=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -117,7 +106,6 @@ def test_euler_preview_evaluate(self): result = evaluation_function(response, answer, params) assert result["is_correct"] is True - def test_theta_character_implict_multi_variable(self): params = { "strict_syntax": False, @@ -190,8 +178,6 @@ def test_e_latex(self, response, is_latex, is_correct): result = evaluation_function(response, answer, params) assert result["is_correct"] == is_correct - - @pytest.mark.parametrize("params, is_correct", [ ({}, True), ({"atol": 0.0, "rtol": 0.0, "strict_syntax": False, "physical_quantity": False, "elementary_functions": False}, True), @@ -214,7 +200,7 @@ def test_strict_syntax_rejects_implicit_multiplication(self, params, is_correct) def test_mu_preview_evaluate(self): response = "10 μA" - params = Params(is_latex=False, elementary_functions=False, strict_syntax=False, physical_quantity=True) + params = dict(is_latex=False, elementary_functions=False, strict_syntax=False, physical_quantity=True) result = preview_function(response, params) assert "preview" in result.keys() @@ -257,8 +243,3 @@ def test_laplace_transforms(self, response, is_latex, is_correct): result = evaluation_function(response, answer, params) assert result["is_correct"] == is_correct - - - -if __name__ == "__main__": - pytest.main(['-xk not slow', '--tb=short', '--durations=10', os.path.abspath(__file__)]) diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py new file mode 100644 index 0000000..a29f356 --- /dev/null +++ b/app/evaluation_tests.py @@ -0,0 +1,21 @@ +""" + Entry point for the `healthcheck` command of the deployed function. + + The base image's healthcheck loads `TestEvaluationFunction` from this module + with `unittest`, so this wraps a run of the pytest suite in this directory + (`*_test.py` and `tests/`). Tests marked `@pytest.mark.slow` are left out so + the healthcheck stays within the request timeout; CI runs them all. pytest + doesn't collect `*_tests.py`, so this module isn't picked up by that run. +""" +import os +import unittest + +import pytest + + +class TestEvaluationFunction(unittest.TestCase): + + def test_pytest_suite(self): + # The Lambda filesystem is read-only, so pytest's cache is disabled + exit_code = pytest.main([os.path.dirname(os.path.abspath(__file__)), "-q", "-m", "not slow", "-p", "no:cacheprovider"]) + self.assertEqual(exit_code, pytest.ExitCode.OK) diff --git a/app/feedback/physical_quantity.py b/app/feedback/physical_quantity.py index f4320f7..905af92 100644 --- a/app/feedback/physical_quantity.py +++ b/app/feedback/physical_quantity.py @@ -20,3 +20,10 @@ "TRUE": "", # TODO: Replace with more specialised messages for different comparisons "FALSE": "", # TODO: Replace with more specialised messages for different comparisons }[tag] + + +def reverted_unit_feedback(reverted_unit): + """Text for a `RevertedUnit` fact from the units package.""" + return feedback_string_generators["INTERNAL"]("REVERTED_UNIT")( + {"before": reverted_unit.before, "marked": reverted_unit.marked, "after": reverted_unit.after} + ) diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 366ac07..69c65f4 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -13,13 +13,13 @@ feedback_generators = dict() feedback_generators["EQUIVALENCES"] = criteria_equivalences feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { - "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name','')} might be ambiguous, use `Abs(.)` instead of `|.|`", - "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x','')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", + "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name', '')} might be ambiguous, use `Abs(.)` instead of `|.|`", + "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x', '')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", "NO_RESPONSE": "No response submitted.", "MULTIPLE_ANSWER_FAIL_ALL": "At least one answer or response was incorrect.", "MULTIPLE_ANSWER_FAIL_RESPONSE": "At least one response was incorrect.", "MULTIPLE_ANSWER_FAIL_ANSWER": "At least one answer is missing in the response.", - "PARSE_ERROR": f"`{inputs.get('x','')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.", + "PARSE_ERROR": f"`{inputs.get('x', '')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.", "NOTATION_WARNING_EXPONENT": "Note that `^` cannot be used to denote exponentiation, use `**` instead.", "NOTATION_WARNING_FACTORIAL": "Note that `!` cannot be used to denote factorial, use `factorial(...)` instead.", "NOTATION_WARNING_TRIPLE_FACTORIAL": "Note that `!!!` is not supported.", @@ -77,3 +77,8 @@ "FALSE": None, "UNKNOWN": None, }[tag] + + +def internal_feedback(feedback_tag): + """Text for a `FeedbackTag` raised by the lambdafeedback packages (e.g. `BRACKET_NOTATION_MISMATCH`).""" + return feedback_generators["INTERNAL"](str(feedback_tag.tag))(feedback_tag.inputs) diff --git a/app/feedback/syntactical_forms.py b/app/feedback/syntactical_forms.py new file mode 100644 index 0000000..9bfcb00 --- /dev/null +++ b/app/feedback/syntactical_forms.py @@ -0,0 +1,57 @@ +"""Written forms of numbers for "written as" criteria (moved out of lambdafeedback.expression_parsing).""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from lambdafeedback.expression_parsing import ( + is_complex_number_on_cartesian_form, + is_complex_number_on_exponential_form, + is_number, +) + + +@dataclass(frozen=True) +class SyntacticalPattern: + """A written form of numbers, for "written as" criteria. + + ``summary``/``details`` render descriptions from the criterion node (whose + two children are the compared expressions) and the parameters. + """ + + matcher: Callable[[str], bool] + name: str + summary: Callable[[Any, Any], str] + details: Callable[[Any, Any], str] + + +def _both(criterion: Any) -> str: + return f"{criterion.children[0].content_string()} and {criterion.children[1].content_string()}" + + +PATTERNS: dict[str, SyntacticalPattern] = { + "NUMBER": SyntacticalPattern( + matcher=is_number, + name="simplified number", + summary=lambda criterion, parameters: f"{_both(criterion)} are both numbers written in simplified form.", + details=lambda criterion, parameters: f"{_both(criterion)} are both numbers written in simplified form.", + ), + "CARTESIAN": SyntacticalPattern( + matcher=is_complex_number_on_cartesian_form, + name="cartesian", + summary=lambda criterion, parameters: f"{_both(criterion)} are both complex numbers written in cartesian form", + details=lambda criterion, parameters: ( + f"{_both(criterion)} are both complex numbers written in cartesian form, i.e. $a+bi$." + ), + ), + "EXPONENTIAL": SyntacticalPattern( + matcher=is_complex_number_on_exponential_form, + name="exponential", + summary=lambda criterion, parameters: ( + f"{_both(criterion)} are both complex numbers written in exponential form" + ), + details=lambda criterion, parameters: ( + f"{_both(criterion)} are both complex numbers written in exponential form, i.e. $a exp(bi)$." + ), + ), +} diff --git a/app/feedback/tags.py b/app/feedback/tags.py new file mode 100644 index 0000000..9bac22e --- /dev/null +++ b/app/feedback/tags.py @@ -0,0 +1,35 @@ +"""Feedback tags raised by the lambdafeedback packages, and this app's own preview logic. + +The packages don't carry compareExpressions' feedback text: a +:class:`FeedbackTag` is the tag plus the inputs needed to render it (see +``feedback/symbolic.py`` and ``feedback/physical_quantity.py`` for the +tag -> text mapping). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class FeedbackTagName(StrEnum): + """Tags emitted while previewing or preprocessing a symbolic response.""" + + BRACKET_NOTATION_MISMATCH = "BRACKET_NOTATION_MISMATCH" + """Brackets closed with a different kind than opened (inputs: ``x``, the expression).""" + + ABSOLUTE_VALUE_NOTATION_AMBIGUITY = "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" + """``|...|`` pairs that could not be matched unambiguously (inputs: ``name``, e.g. ``"response"``).""" + + PARSE_ERROR = "PARSE_ERROR" + """The expression could not be parsed (inputs: ``response``).""" + + +@dataclass(frozen=True) +class FeedbackTag: + """A feedback tag and the inputs needed to render its text.""" + + tag: str + inputs: Mapping[str, Any] = field(default_factory=dict) diff --git a/app/preview.py b/app/preview.py index cebefd6..0dca3cd 100644 --- a/app/preview.py +++ b/app/preview.py @@ -1,13 +1,12 @@ -from .utility.preview_utilities import ( - Params, - Result, -) +from collections.abc import Mapping +from typing import Any -from .preview_implementations.physical_quantity_preview import preview_function as physical_quantity_preview -from .preview_implementations.symbolic_preview import preview_function as symbolic_preview +from .symbolic_preview import Result +from .symbolic_preview import preview_function as symbolic_preview +from .context.physical_quantity_preview import preview_function as physical_quantity_preview -def preview_function(response: str, params: Params) -> Result: +def preview_function(response: str, params: Mapping[str, Any]) -> Result: """ Function used to preview a student response. --- diff --git a/app/preview_implementations/__init__.py b/app/preview_implementations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/preview_implementations/physical_quantity_preview.py b/app/preview_implementations/physical_quantity_preview.py deleted file mode 100644 index 7653f1a..0000000 --- a/app/preview_implementations/physical_quantity_preview.py +++ /dev/null @@ -1,128 +0,0 @@ -import tokenize -from copy import deepcopy - -from ..utility.expression_utilities import ( - find_matching_parenthesis, - parse_expression, - SymbolDict, - sympy_to_latex, -) - -from ..utility.preview_utilities import ( - Params, - Preview, - Result, - parse_latex, - sanitise_latex, -) - -from ..utility.expression_utilities import default_parameters as symbolic_default_parameters -from ..utility.physical_quantity_utilities import SLR_quantity_parser as quantity_parser, expression_preprocess -from ..utility.physical_quantity_utilities import SLR_quantity_parsing as quantity_parsing - -# CONSIDER: Move these to separate file so that they can be shared with -# the physical quantity context (or move preview implementation into context file) -default_parameters = deepcopy(symbolic_default_parameters) -default_parameters.update( - { - "physical_quantity": True, - "strictness": "natural", - "units_string": "SI common imperial", - } -) - - -def fix_exponents(response): - processed_response = [] - exponents_notation = ['^', '**'] - for notation in exponents_notation: - index = 0 - while index < len(response): - exponent_start = response.find(notation, index) - if exponent_start > -1: - processed_response.append(response[index:exponent_start]) - exponent_start += len(notation) - processed_response.append("**") - exponent_end = find_matching_parenthesis(response, exponent_start, delimiters=('{', '}')) - if exponent_end > 0: - inside_exponent = '('+response[(exponent_start+len(notation)):exponent_end]+')' - processed_response.append(inside_exponent) - index = exponent_end+1 - else: - index = exponent_start - else: - processed_response.append(response[index:]) - break - response = "".join(processed_response) - processed_response = [] - return response - - -def preview_function(response: str, params: Params) -> Result: - """ - Function used to preview a student response. - --- - The handler function passes three arguments to preview_function(): - - - `response` which are the answers provided by the student. - - `params` which are any extra parameters that may be useful, - e.g., error tolerances. - - The output of this function is what is returned as the API response - and therefore must be JSON-encodable. It must also conform to the - response schema. - - Any standard python library may be used, as well as any package - available on pip (provided it is added to requirements.txt). - - The way you wish to structure you code (all in this function, or - split into many) is entirely up to you. - """ - for (key, value) in default_parameters.items(): - if key not in params.keys(): - params.update({key: value}) - symbols: SymbolDict = params.get("symbols", {}) - - if not response: - return Result(preview=Preview(latex="", sympy="")) - - latex_out = "" - sympy_out = "" - - parser = quantity_parser(params) - - try: - if params.get("is_latex", False): - response = sanitise_latex(response) - response = fix_exponents(response) - res_parsed = quantity_parsing(response, params, parser, "response") - value = res_parsed.value - unit = res_parsed.unit - value_latex = "" - if value is not None: - value_string = parse_latex(value.content_string(), symbols, params.get("simplify", False)) - params.update({"is_latex": False}) - value = parse_expression(value_string, params) - value_latex = sympy_to_latex(value, symbols) - separator_latex = "" - separator_sympy = "" - if value is not None and unit is not None: - separator_latex = "~" - separator_sympy = " " - unit_latex = res_parsed.unit_latex_string if unit is not None else "" - latex_out = value_latex+separator_latex+unit_latex - value_sympy = str(value) - unit_sympy = res_parsed.unit.content_string() if unit is not None else "" - sympy_out = value_sympy+separator_sympy+unit_sympy - else: - _, res_pre_processed, _ = expression_preprocess("response", response, params) - res_parsed = quantity_parsing(res_pre_processed, params, parser, "response") - latex_out = res_parsed.latex_string - sympy_out = response - - except (SyntaxError, tokenize.TokenError) as e: - raise Exception("Failed to parse Sympy expression") from e - except ValueError as e: - raise Exception("Failed to parse LaTeX expression") from e - - return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) diff --git a/app/preview_implementations/symbolic_preview.py b/app/preview_implementations/symbolic_preview.py deleted file mode 100644 index 79e286b..0000000 --- a/app/preview_implementations/symbolic_preview.py +++ /dev/null @@ -1,134 +0,0 @@ -from sympy.parsing.sympy_parser import T as parser_transformations -from ..utility.expression_utilities import ( - default_parameters, - convert_absolute_notation, - create_expression_set, - create_sympy_parsing_params, - parse_expression, - substitute_input_symbols, - SymbolDict, - sympy_symbols, - sympy_to_latex, - preprocess_expression, -) -from ..utility.preview_utilities import ( - Params, - Preview, - Result, - parse_latex -) -from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators - - -def parse_symbolic(response: str, params): - symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] - response_list_in = create_expression_set(response, params) - response_list_out = [] - feedback = [] - for response in response_list_in: - response = substitute_input_symbols([response.strip()], params)[0] - - # Converting absolute value notation to a form that SymPy accepts - response, response_feedback = convert_absolute_notation(response, "response") - if response_feedback is not None: - feedback.append(response_feedback) - response_list_out.append(response) - - parsing_params = create_sympy_parsing_params(params) - parsing_params["extra_transformations"] = parser_transformations[9] # Add conversion of equal signs - parsing_params["symbol_dict"].update(sympy_symbols(params.get("symbols", {}))) - result_sympy_expression = [] - for response in response_list_out: - # Safely try to parse answer and response into symbolic expressions - try: - if "atol" in params.keys(): - parsing_params.update({"atol": params["atol"]}) - if "rtol" in params.keys(): - parsing_params.update({"rtol": params["rtol"]}) - res = parse_expression(response, parsing_params) - except Exception as exc: - raise SyntaxError(symbolic_comparison_internal_messages("PARSE_ERROR")({"response": response})) from exc - result_sympy_expression.append(res) - - return result_sympy_expression, feedback - - -def preview_function(response: str, params: Params) -> Result: - """ - Function used to preview a student response. - --- - The handler function passes three arguments to preview_function(): - - - `response` which are the answers provided by the student. - - `params` which are any extra parameters that may be useful, - e.g., error tolerances. - - The output of this function is what is returned as the API response - and therefore must be JSON-encodable. It must also conform to the - response schema. - - Any standard python library may be used, as well as any package - available on pip (provided it is added to requirements.txt). - - The way you wish to structure you code (all in this function, or - split into many) is entirely up to you. - """ - for (key, value) in default_parameters.items(): - if key not in params.keys(): - params.update({key: value}) - - original_response = response - - symbols: SymbolDict = params.get("symbols", {}) - - if not response: - return Result(preview=Preview(latex="", sympy="")) - - response_list = response.split("=") - response_latex = [] - response_sympy = [] - - for response in response_list: - try: - if params.get("is_latex", False) is True: - sympy_out = [parse_latex(response, symbols, params.get("simplify", False))] - latex_out = [response] - else: - params.update({"rationalise": False}) - _, response, _ = preprocess_expression("response", response, params) - expression_list, _ = parse_symbolic(response, params) - - parsing_params = create_sympy_parsing_params(params) - printing_symbols = dict() - for key in parsing_params["symbol_dict"].keys(): - if key in symbols.keys(): - printing_symbols.update({key: symbols[key]["latex"]}) - - latex_out = [] - sympy_out = [] - for expression in expression_list: - latex_out.append(sympy_to_latex(expression, symbols, settings={"mul_symbol": r" \cdot "})) - - sympy_out.append(response) - - sympy_out = sorted(sympy_out) - latex_out = sorted(latex_out) - - if len(sympy_out) == 1: - sympy_out = sympy_out[0] - sympy_out = str(sympy_out) - - if len(latex_out) > 1: - latex_out = "\\left\\{"+",~".join(latex_out)+"\\right\\}" - else: - latex_out = latex_out[0] - - except SyntaxError as exc: - raise ValueError(f"Failed to parse SymPy expression: {original_response}") from exc - except ValueError as exc: - raise ValueError(f"Failed to parse LaTeX expression: {original_response}") from exc - - response_latex.append(latex_out) - response_sympy.append(sympy_out) - - return Result(preview=Preview(latex="=".join(response_latex), sympy="=".join(response_sympy))) diff --git a/app/preview_test.py b/app/preview_test.py index eca9121..ba762d5 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -1,7 +1,5 @@ -import os import pytest -from .utility.preview_utilities import Params from .preview import preview_function @@ -22,17 +20,14 @@ class TestPreviewFunction(): Use preview_function() to check your algorithm works as it should. - """ - - # Import tests that makes sure that mathematical expression comparison works as expected - from .tests.symbolic_preview_test import TestPreviewFunction as TestSymbolicComparison - # Import tests that makes sure that physical quantities are handled as expected - from .tests.physical_quantity_preview_test import TestPreviewFunction as TestQuantityComparison + The symbolic preview itself is tested in the lambdafeedback-expression-parsing + package. The physical quantity preview is tested in physical_quantity_preview_test.py. + """ def test_empty_latex_expression(self): response = "" - params = Params(is_latex=True) + params = dict(is_latex=True) result = preview_function(response, params) assert "preview" in result.keys() @@ -41,7 +36,7 @@ def test_empty_latex_expression(self): def test_empty_non_latex_expression(self): response = "" - params = Params(is_latex=False) + params = dict(is_latex=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -50,7 +45,7 @@ def test_empty_non_latex_expression(self): def test_latex_and_sympy_are_returned(self): response = "x+1" - params = Params(is_latex=True) + params = dict(is_latex=True) result = preview_function(response, params) assert "preview" in result.keys() @@ -59,7 +54,7 @@ def test_latex_and_sympy_are_returned(self): assert "sympy" in preview.keys() response = "x+1" - params = Params(is_latex=False) + params = dict(is_latex=False) result = preview_function(response, params) assert "preview" in result @@ -69,7 +64,7 @@ def test_latex_and_sympy_are_returned(self): def test_natural_logarithm_notation(self): response = "ln(x)" - params = Params(is_latex=False) + params = dict(is_latex=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -88,27 +83,27 @@ def test_natural_logarithm_notation(self): ("e * x", False, False, "e \\cdot x", "e * x"), ("E", False, False, "E", "E",), ("E", True, True, "E", "E",), - ("e", True, False, "e", "E",), - ("ex", True, False, "ex", "E*x"), - ("e * x", True, False, "e * x", "E*x"), + ("e", True, False, "e", "e",), + ("ex", True, False, "ex", "e*x"), + ("e * x", True, False, "e * x", "e*x"), ("E", True, False, "E", "E",), ("ER_2", True, False, "ER_2", "E*R_2",), ("exp(1)", False, True, "e^{1}", "exp(1)"), ("e**1", False, True, "e^{1}", "E**1"), - ("e^{1}", True, True, "e^{1}", "E"), + ("e^{1}", True, True, "e^{1}", "e"), ("exp(0)", False, True, "e^{0}", "exp(0)"), ("e**0", False, True, "e^{0}", "E**0"), ("e^{0}", True, True, "e^{0}", "1"), ("exp(2)", False, True, "e^{2}", "exp(2)"), ("e**2", False, True, "e^{2}", "E**2"), - ("e^{2}", True, True, "e^{2}", "exp(2)"), + ("e^{2}", True, True, "e^{2}", "e**2"), ("exp(x)", False, True, "e^{x}", "exp(x)"), ("e**x", False, True, "e^{x}", "E**x"), - ("e^{x}", True, True, "e^{x}", "exp(x)") + ("e^{x}", True, True, "e^{x}", "e**x") ] ) def test_eulers_number_notation(self, response, is_latex, elementary_functions, response_latex, response_sympy): - params = Params(is_latex=is_latex, elementary_functions=elementary_functions, strict_syntax=False) + params = dict(is_latex=is_latex, elementary_functions=elementary_functions, strict_syntax=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -120,11 +115,11 @@ def test_eulers_number_notation(self, response, is_latex, elementary_functions, "response, is_latex, response_latex, response_sympy, symbols", [ ("e**ea", False, "e^{ea}", "E**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), ("e**Ea", False, "e^{ea}", "E**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), - ("e^{ea}", True, "e^{ea}", "exp(ea)", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), + ("e^{ea}", True, "e^{ea}", "e**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), ("e^{Ea}", True, "e^{Ea}", "e**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), ("e**aea", False, "e^{aea}", "E**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), ("e**aEa", False, "e^{aea}", "E**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), - ("e^{aea}", True, "e^{aea}", "exp(aea)", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), + ("e^{aea}", True, "e^{aea}", "e**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), ("e^{aEa}", True, "e^{aEa}", "e**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), ] ) @@ -175,7 +170,7 @@ def test_alias(self, response, is_latex, response_latex, response_sympy, symbols [ ("plus_minus x", False, '\\left\\{- x,~x\\right\\}', "plus_minus x"), ("\\pm x", True, '\\pm x', '{-x, x}'), - (r"\pm x^{2}+\mp y^{2}", True, "\pm x^{2}+\mp y^{2}", "{-x**2 + y**2, x**2 - y**2}"), + (r"\pm x^{2}+\mp y^{2}", True, r"\pm x^{2}+\mp y^{2}", "{-x**2 + y**2, x**2 - y**2}"), ("plus_minus x**2 + minus_plus y**2", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}", "plus_minus x**2 + minus_plus y**2"), ("- minus_plus x^2 minus_plus y^2", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}", "- minus_plus x^2 minus_plus y^2"), ("- minus_plus x^2 - plus_minus y^2", False, r"\left\{- x^{2} - - y^{2},~x^{2} - y^{2}\right\}", "- minus_plus x^2 - plus_minus y^2"), @@ -199,7 +194,7 @@ def test_using_plus_minus_symbols(self, response, is_latex, response_latex, resp }, }, } - params = Params(**params) + params = dict(**params) result = preview_function(response, params) assert result["preview"]["latex"] == response_latex assert result["preview"]["sympy"] == response_sympy @@ -220,7 +215,7 @@ def test_lh_rh_response(self): }, }, } - params = Params(**params) + params = dict(**params) result = preview_function("x + y = y + x", params) assert result["preview"]["latex"] == "x + y=x + y" assert result["preview"]["sympy"] == "x + y=y + x" @@ -250,15 +245,15 @@ def test_multi_character_implicit_multi_variable(self): assert result["preview"]["sympy"] == "a/(bcd)" result = preview_function(response_implicit_no_bracket, params) - assert result["preview"]["latex"] =='\\frac{a}{bc \\cdot d}' + assert result["preview"]["latex"] == '\\frac{a}{bc \\cdot d}' assert result["preview"]["sympy"] == "a/bcd" @pytest.mark.parametrize( "response, is_latex, latex, sympy", [ ("A/s(e**(-a*s)-e**(-b*s))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-a*s)- E**(-b*s))"), - ("A/s(e**(-as)-e**(-bs))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-a*s)- E**(-bs))"), - ("(A/s)( e^{-a*s}-e^{-b*s})", True, r"(A/s)( e^{-a*s}-e^{-b*s})", "A*(-exp(-b*s) + exp(-a*s))/s"), - ("(A/s)( e^{-as}-e^{-bs})", True, r"(A/s)( e^{-as}-e^{-bs})", "A*(-exp(-b*s) + exp(-a*s))/s"), + ("A/s(e**(-as)-e**(-bs))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-as)- E**(-bs))"), + ("(A/s)( e^{-a*s}-e^{-b*s})", True, r"(A/s)( e^{-a*s}-e^{-b*s})", "A*(-1/e**(b*s) + e**(-a*s))/s"), + ("(A/s)( e^{-as}-e^{-bs})", True, r"(A/s)( e^{-as}-e^{-bs})", "A*(-1/e**(b*s) + e**(-a*s))/s"), ] ) def test_laplace_transforms(self, response, is_latex, latex, sympy): @@ -275,7 +270,7 @@ def test_laplace_transforms(self, response, is_latex, latex, sympy): @pytest.mark.parametrize( "response, is_latex, latex, sympy", [ - ("(x+y)*(x+z)", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)","(x+y)*(x+z)"), + ("(x+y)*(x+z)", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)", "(x+y)*(x+z)"), ("(x+y)*x+z", False, "x \\cdot \\left(x + y\\right) + z", "(x+y)*x+z"), ("x+y*(x+z)", False, "x + y \\cdot \\left(x + z\\right)", "x+y*(x+z)"), ("[x+y]*[x+z]", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)", "(x+y)*(x+z)"), @@ -336,13 +331,15 @@ def test_brackets_rejected_with_strict_syntax(self, response): preview_function(response, params) @pytest.mark.parametrize( - "response, latex, sympy", [ - ("{x+y}*{x+z}", "\\left\\{x + y\\right\\} \\cdot \\left\\{x + z\\right\\}", "{x+y}*{x+z}"), - ("{x+y}*x+z", "\\left\\{x + y\\right\\} \\cdot x + z", "{x+y}*x+z"), - ("x+y*{x+z}", "x + y \\cdot \\left\\{x + z\\right\\}", "x+y*{x+z}"), + "response", [ + "{x+y}*{x+z}", + "{x+y}*x+z", + "x+y*{x+z}", ] ) - def test_curly_braces_not_converted_with_strict_syntax(self, response, latex, sympy): + def test_curly_braces_not_converted_with_strict_syntax(self, response): + # With strict syntax `{}` is a set literal, not a bracket, and arithmetic on + # sets is rejected (SymPy deprecates it, and evaluating it used to crash). params = { "is_latex": False, "strict_syntax": True, @@ -350,9 +347,8 @@ def test_curly_braces_not_converted_with_strict_syntax(self, response, latex, sy "convention": "implicit_higher_precedence", } - result = preview_function(response, params) - assert result["preview"]["latex"] == latex - assert result["preview"]["sympy"] == sympy + with pytest.raises(ValueError): + preview_function(response, params) @pytest.mark.parametrize("strict_syntax", [False, True]) def test_curly_brace_multiple_answers_still_works(self, strict_syntax): @@ -366,7 +362,3 @@ def test_curly_brace_multiple_answers_still_works(self, strict_syntax): result = preview_function("{1, 2}", params) assert result["preview"]["latex"] == "\\left\\{1,~2\\right\\}" assert result["preview"]["sympy"] == "{1, 2}" - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/preview_tests.py b/app/preview_tests.py new file mode 100644 index 0000000..11a30ca --- /dev/null +++ b/app/preview_tests.py @@ -0,0 +1,9 @@ +""" + The base image's healthcheck loads `TestPreviewFunction` from this module. + The preview tests are run as part of the pytest suite in `evaluation_tests.py`. +""" +import unittest + + +class TestPreviewFunction(unittest.TestCase): + pass diff --git a/app/requirements.txt b/app/requirements.txt index 1ee4286..94aabae 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,6 +1,11 @@ pydot -typing_extensions mpmath==1.3.0 sympy==1.14 -antlr4-python3-runtime==4.7.2 -git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2 \ No newline at end of file +# Parsing, units and criteria packages extracted from this function +# (https://github.com/lambda-feedback/lambda-feedback-packages), pinned to the +# v0.3.0-rc.2 release candidate (0.3.0rc2). All four are listed: they depend on +# each other by name and aren't published on PyPI. +lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=slr_parsing +lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=criteria +lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=expression_parsing +lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=units diff --git a/app/result.py b/app/result.py new file mode 100644 index 0000000..081d837 --- /dev/null +++ b/app/result.py @@ -0,0 +1,44 @@ +from lambdafeedback.criteria import CriteriaGraph + + +class EvaluationResult: + """ + Collects the outcome of an evaluation and serialises it into the + response returned by `evaluation_function`. + + Satisfies `lambdafeedback.criteria.ResultLike`, so + `CriteriaGraph.export_feedback` can add to it. + """ + def __init__(self): + self.is_correct = False + self.latex = "" + self.simplified = "" + self.criteria_graphs = {} # name -> CriteriaGraph, included in test data + self._feedback = [] # (tag, text) pairs in the order they were added + + @property + def tags(self): + return list(dict.fromkeys(tag for (tag, _) in self._feedback)) + + def get_feedback(self, tag): + return [text for (t, text) in self._feedback if t == tag] + + def add_feedback(self, tag, feedback): + """Record `feedback` under `tag`. Blank feedback records the tag without adding any text.""" + self._feedback.append((tag, feedback or "")) + + def _serialise_feedback(self): + return "
".join(text.strip() for (_, text) in self._feedback if len(text.strip()) > 0) + + def serialise(self, include_test_data=False) -> dict: + out = dict(is_correct=self.is_correct, feedback=self._serialise_feedback(), tags=self.tags) + if include_test_data is True: + out.update(CriteriaGraph.test_data(self.criteria_graphs)) + if self.latex is not None: + out.update(dict(response_latex=self.latex)) + if self.simplified is not None: + simplified = self.simplified + if isinstance(simplified, list): + simplified = ", ".join(simplified) + out.update(dict(response_simplified=simplified)) + return out diff --git a/app/symbolic_preview.py b/app/symbolic_preview.py new file mode 100644 index 0000000..350a4ca --- /dev/null +++ b/app/symbolic_preview.py @@ -0,0 +1,126 @@ +"""Previewing a learner's response: LaTeX and SymPy renderings of what was typed. + +Moved here from ``lambdafeedback.expression_parsing``: rendering a +response for display, and mapping a parse failure to a tag for the learner, +are app-layer concerns. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, NotRequired, TypedDict + +from sympy import Basic + +from lambdafeedback.expression_parsing import ( + AbsoluteValueNotationError, + BracketNotationError, + ExpressionParams, + ExpressionParsingError, + SympyParsingConfig, + convert_absolute_notation, + create_expression_set, + parse_expression, + parse_latex, + preprocess_expression, + substitute_input_symbols, + sympy_to_latex, +) + +from .feedback.tags import FeedbackTag, FeedbackTagName + + +class ExpressionSyntaxError(ExpressionParsingError): + """A response could not be parsed; ``feedback`` is the tag to show the learner.""" + + def __init__(self, feedback: FeedbackTag) -> None: + super().__init__(f"{feedback.tag}: {dict(feedback.inputs)}") + self.feedback = feedback + + +class Preview(TypedDict): + """Structurally compatible with ``lf_toolkit.preview.Preview`` (whose ``feedback`` is required).""" + + latex: str + sympy: str + feedback: NotRequired[str] + + +class Result(TypedDict): + """Structurally compatible with ``lf_toolkit.preview.Result``.""" + + preview: Preview + + +def _as_params(params: ExpressionParams | Mapping[str, Any]) -> ExpressionParams: + return params if isinstance(params, ExpressionParams) else ExpressionParams.from_dict(params) + + +def parse_symbolic(response: str, params: ExpressionParams) -> tuple[list[Basic | set[Basic]], list[FeedbackTag]]: + """Parse a (preprocessed) response into SymPy expressions, one per expression it stands for. + + Also returns the feedback raised while converting ``|...|`` notation. + Raises :class:`ExpressionSyntaxError` (with ``PARSE_ERROR`` feedback) + when an expression can't be parsed. + """ + responses = [] + feedback: list[FeedbackTag] = [] + for expression in create_expression_set(response, params): + expression = substitute_input_symbols(expression.strip(), params) + try: + expression = convert_absolute_notation(expression, name="response") + except AbsoluteValueNotationError as exc: + feedback.append(FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": exc.name})) + expression = exc.expression + responses.append(expression) + + config = SympyParsingConfig.from_params(params, unsplittable_symbols=params.symbols) + parsed = [] + for expression in responses: + try: + parsed.append(parse_expression(expression, config)) + except Exception as exc: + raise ExpressionSyntaxError(FeedbackTag(FeedbackTagName.PARSE_ERROR, {"response": expression})) from exc + return parsed, feedback + + +def preview_function(response: str, params: ExpressionParams | Mapping[str, Any]) -> Result: + """Preview a learner's response as LaTeX and SymPy syntax. + + ``params`` may be :class:`ExpressionParams` or evaluation-function + parameters. Each side of an ``=`` is previewed separately; a response + standing for several expressions (``{a, b}``, ``±``) previews as a set. + Raises :class:`ExpressionParsingError` if the response can't be parsed. + """ + params = _as_params(params) + if not response: + return Result(preview=Preview(latex="", sympy="")) + + latex_parts = [] + sympy_parts = [] + for part in response.split("="): + try: + if params.is_latex: + latex_out = [part] + sympy_out = [parse_latex(part, params.symbols, params.simplify)] + else: + symbolic_params = params.replace(rationalise=False) + try: + preprocessed = preprocess_expression(part, symbolic_params, name="response") + except (BracketNotationError, AbsoluteValueNotationError) as exc: + preprocessed = exc.expression + expressions, _ = parse_symbolic(preprocessed, symbolic_params) + latex_out = sorted( + sympy_to_latex(expression, params.symbols, settings={"mul_symbol": r" \cdot "}) + for expression in expressions + ) + sympy_out = [preprocessed] + except ExpressionSyntaxError as exc: + raise ExpressionParsingError(f"Failed to parse SymPy expression: {response}") from exc + except ValueError as exc: + raise ExpressionParsingError(f"Failed to parse LaTeX expression: {response}") from exc + + sympy_parts.append(sympy_out[0] if len(sympy_out) == 1 else str(sympy_out)) + latex_parts.append(latex_out[0] if len(latex_out) == 1 else "\\left\\{" + ",~".join(latex_out) + "\\right\\}") + + return Result(preview=Preview(latex="=".join(latex_parts), sympy="=".join(sympy_parts))) diff --git a/app/tests/example_test.py b/app/tests/example_test.py index 4e99325..8cf716e 100644 --- a/app/tests/example_test.py +++ b/app/tests/example_test.py @@ -1,20 +1,8 @@ -import os import pytest from ..evaluation import evaluation_function from ..preview import preview_function -def create_diagram_for_documentation(filename, result): - for (index, graph) in enumerate(result["criteria_graphs_vis"].values()): - with open(filename+"_"+str(index)+".md", "w") as f: - #f.write(r''+'\n') - f.write("```mermaid\n") - for g in result["criteria_graphs_vis"].values(): - print(g) - #f.write('
\n'+g+'\n
\n') - f.write(g+"\n") - #f.write('') - f.write("```\n") class TestEvaluationFunction(): """ @@ -67,6 +55,7 @@ def test_setting_input_symbols_to_be_assumed_positive_to_avoid_issues_with_fract ("+- x**2 + -+ y**2", False, r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), ] ) + @pytest.mark.slow def test_using_plus_minus_symbols(self, response, is_latex, response_latex): answer = "plus_minus x**2 + minus_plus y**2" params = { @@ -90,7 +79,6 @@ def test_using_plus_minus_symbols(self, response, is_latex, response_latex): params.update({"is_latex": True}) # Checking latex output disabled as the function return a few different # variants of the latex in an unpredictable way - # preview = preview_function(response, params)["preview"] # assert preview["latex"] == response_latex result = evaluation_function(response, answer, params) assert result["is_correct"] is True @@ -131,7 +119,7 @@ def test_equalities_in_the_answer_and_response(self, response, response_latex): ("109.12 foot/minute", "2.00 km/h", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), ("0.556 m/s", "0.556 metre/second", r"0.556~\frac{\mathrm{metre}}{\mathrm{second}}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_IDENTICAL"}), ("5.56 dm/s", "0.556 metre/second", r"5.56~\frac{\mathrm{decimetre}}{\mathrm{second}}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), - ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{(-1)}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), + ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{-1}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), ("1.24 mile/hour", "1.24 mile/hour", r"1.24~\frac{\mathrm{mile}}{\mathrm{hour}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_IDENTICAL"}), ("2 km/h", "1.24 mile/hour", r"2~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), # Ideally False but works with base SI units ("109.12 foot/minute", "1.24 mile/hour", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), @@ -154,7 +142,7 @@ def test_checking_the_value_of_a_physical_quantity(self, response, answer, respo assert tags == set(result["tags"]) assert result["is_correct"] == value - def test_checking_the_value_of_a_physical_quantity_and_create_diagram_for_docs(self): + def test_checking_the_value_of_a_physical_quantity_long_and_short_unit_names(self): params = { "strict_syntax": False, "elementary_functions": True, @@ -163,8 +151,7 @@ def test_checking_the_value_of_a_physical_quantity_and_create_diagram_for_docs(s response = "2.00 kilometre/hour" answer = "2.00 km/h" result = evaluation_function(response, answer, params, include_test_data=True) - create_diagram_for_documentation("physical_quantity", result) - assert result["is_correct"] == True + assert result["is_correct"] is True @pytest.mark.parametrize( "res,ans,convention,value", @@ -208,6 +195,7 @@ def test_implicit_multiplication_convention(self, res, ans, convention, value): ) ] ) + @pytest.mark.slow def test_setting_absolute_or_relative_tolerances_for_numerical_comparison(self, answer, atol_response_true, atol_response_false, rtol_response_true, rtol_response_false): params = { "strict_syntax": False, @@ -286,6 +274,7 @@ def test_setting_absolute_or_relative_tolerances_for_numerical_comparison(self, ("cos(x)+2", "sin(x)", r"\cos{\left(x \right)} + 2", "diff(response,x)=cos(x)", False, [], dict()), ] ) + @pytest.mark.slow def test_customizing_comparison(self, response, answer, response_latex, criteria, value, feedback_tags, extra_params): params = { "strict_syntax": False, @@ -330,6 +319,7 @@ def test_using_input_symbols_alternatives(self, response): ("Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)", True), ] ) + @pytest.mark.slow def test_MECH50001_2_24_a(self, response, value): params = { "strict_syntax": False, @@ -596,13 +586,6 @@ def test_custom_comparison_with_criteria_order(self, response, value, tags): } answer = "2*x^2" result = evaluation_function(response, answer, params, include_test_data=True) - create_diagram_for_documentation("custom_comparison_with_criteria_order", result) -# with open("diagrams.html", "w") as f: -# f.write(r''+'\n') -# for g in result["criteria_graphs_vis"].values(): -# print(g) -# f.write('
\n'+g+'\n
\n') -# f.write('') assert result["is_correct"] is value assert set(tags) == set(result["tags"]) @@ -673,6 +656,3 @@ def test_criteria_custom_feedback(self, response, answer, criteria, value, feedb assert set(feedback_tags) == set(result["tags"]) for string in custom_feedback.values(): assert string in result["feedback"] - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py deleted file mode 100644 index c743291..0000000 --- a/app/tests/expression_utilities_test.py +++ /dev/null @@ -1,483 +0,0 @@ -import pytest -from sympy import Symbol, sqrt, sin as sympy_sin - -from ..utility.expression_utilities import ( - compute_relative_tolerance_from_significant_decimals, - convert_absolute_notation, - convert_bracket_notation, - convert_unicode_dashes, - create_expression_set, - extract_latex, - find_matching_parenthesis, - has_matching_brackets, - is_multiple_answers_wrapper, - latex_symbols, - preprocess_expression, - protect_elementary_functions_substitutions, - substitute, - substitute_input_symbols, - substitutions_sort_key, - sympy_symbols, - sympy_to_latex, - transform_unicode_greek_symbols, -) - - -class TestConvertUnicodeDashes: - - @pytest.mark.parametrize( - "expr, expected", - [ - # No dashes at all - ("x+y", []), - # ASCII hyphen-minus is not a unicode dash — no substitution - ("x-y", []), - # Empty string - ("", []), - # HYPHEN (U+2010) - ("x‐y", [("‐", "-")]), - # NON-BREAKING HYPHEN (U+2011) - ("x‑y", [("‑", "-")]), - # FIGURE DASH (U+2012) - ("x‒y", [("‒", "-")]), - # EN DASH (U+2013) - ("x–y", [("–", "-")]), - # EM DASH (U+2014) - ("x—y", [("—", "-")]), - # MINUS SIGN (U+2212) - ("x−y", [("−", "-")]), - # SMALL HYPHEN-MINUS (U+FE63) - ("x﹣y", [("﹣", "-")]), - # FULLWIDTH HYPHEN-MINUS (U+FF0D) - ("x-y", [("-", "-")]), - # Multiple different unicode dashes in one expression - ("x–y−z", [("–", "-"), ("−", "-")]), - # Repeated occurrences of the same dash — still one substitution pair - ("x−y−z", [("−", "-")]), - ] - ) - def test_convert_unicode_dashes(self, expr, expected): - result = convert_unicode_dashes(expr) - assert result == expected - - -class TestConvertAbsoluteNotation: - - @pytest.mark.parametrize( - "expr, expected_expr, has_feedback", - [ - # No pipes — unchanged - ("x+y", "x+y", False), - # Exactly two pipes — simple conversion - ("|x|", "Abs(x)", False), - ("|x+y|", "Abs(x+y)", False), - # Two non-adjacent absolute values - ("|x|+|y|", "Abs(x)+Abs(y)", False), - ] - ) - def test_convert_absolute_notation(self, expr, expected_expr, has_feedback): - result_expr, feedback = convert_absolute_notation(expr, "response") - assert result_expr == expected_expr - assert (feedback is not None) == has_feedback - - def test_ambiguous_pipes_produce_feedback(self): - # More than 2 pipes with ambiguous positions produces feedback - expr, feedback = convert_absolute_notation("|x|y|z|", "response") - assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - - -class TestTransformUnicodeGreekSymbols: - - def test_no_greek_symbols_returns_empty(self): - assert transform_unicode_greek_symbols("x + y") == [] - - def test_named_greek_symbol_returns_self_substitution(self): - result = transform_unicode_greek_symbols("alpha + 1") - assert ("alpha", " alpha ") in result - - def test_unicode_greek_alias_maps_to_name(self): - # α is an alias for "alpha" - result = transform_unicode_greek_symbols("α") - assert ("α", " alpha ") in result - - def test_multiple_greek_symbols(self): - result = transform_unicode_greek_symbols("alpha + beta") - assert ("alpha", " alpha ") in result - assert ("beta", " beta ") in result - - def test_unicode_beta_alias(self): - result = transform_unicode_greek_symbols("β") - assert ("β", " beta ") in result - - -class TestProtectElementaryFunctionsSubstitutions: - - def test_no_functions_returns_empty(self): - assert protect_elementary_functions_substitutions("x + y") == [] - - def test_sin_generates_self_substitution(self): - result = protect_elementary_functions_substitutions("sin(x)") - assert ("sin", " sin ") in result - - def test_alias_maps_to_canonical_name(self): - # arctan is an alias for atan - result = protect_elementary_functions_substitutions("arctan(x)") - assert ("arctan", " atan ") in result - - def test_multiple_functions(self): - result = protect_elementary_functions_substitutions("sin(x) + cos(x)") - assert ("sin", " sin ") in result - assert ("cos", " cos ") in result - - -class TestSubstituteInputSymbols: - - def test_plain_expression_unchanged(self): - result = substitute_input_symbols("x+y", {}) - assert result == ["x+y"] - - def test_lambda_replaced_with_lamda(self): - result = substitute_input_symbols("lambda", {}) - assert result == ["lamda"] - - def test_alias_replaced_with_symbol_code(self): - params = {"symbols": {"x": {"latex": r"\(x\)", "aliases": ["x_var"]}}} - result = substitute_input_symbols(["x_var"], params) - assert result == ["x"] - - def test_symbol_code_preserved(self): - params = {"symbols": {"x": {"latex": r"\(x\)", "aliases": ["x_var"]}}} - result = substitute_input_symbols(["x"], params) - assert result == ["x"] - - def test_list_input_accepted(self): - result = substitute_input_symbols(["x", "y"], {}) - assert result == ["x", "y"] - - -class TestFindMatchingParenthesis: - - @pytest.mark.parametrize( - "string, index, delimiters, expected", - [ - # Simple pair - ("(x)", 0, None, 2), - # Longer content - ("(x+y)", 0, None, 4), - # Nested — outer pair - ("((x+y))", 0, None, 6), - # Nested — inner pair - ("((x+y))", 1, None, 5), - # No closing delimiter - ("(x", 0, None, -1), - # Custom square-bracket delimiters - ("[x+y]", 0, ("[", "]"), 4), - # Starts mid-string - ("a(b+c)d", 1, None, 5), - ] - ) - def test_find_matching_parenthesis(self, string, index, delimiters, expected): - if delimiters is None: - result = find_matching_parenthesis(string, index) - else: - result = find_matching_parenthesis(string, index, delimiters) - assert result == expected - - -class TestHasMatchingBrackets: - - @pytest.mark.parametrize( - "expr, expected", [ - ("x+y", True), - ("(x+y)", True), - ("[x+y]", True), - ("{x+y}", True), - ("[x+(y-1)]", True), - ("{(x+1), (x-1)}", True), - # Mismatched bracket types - ("[x+y)", False), - ("(x+y]", False), - ("{x+y)", False), - ("[(x+y]", False), - # Unbalanced brackets - ("[x+y", False), - ("x+y]", False), - ("((x+y)", False), - ] - ) - def test_has_matching_brackets(self, expr, expected): - assert has_matching_brackets(expr) is expected - - -class TestConvertBracketNotation: - - @pytest.mark.parametrize( - "expr, expected", [ - ("[x+y]", "(x+y)"), - ("[x+(y-1)]", "(x+(y-1))"), - ("{x+1}*{x-2}", "(x+1)*(x-2)"), - ] - ) - def test_matched_brackets_are_converted(self, expr, expected): - result, feedback = convert_bracket_notation(expr) - assert result == expected - assert feedback is None - - def test_multiple_answers_wrapper_left_untouched(self): - result, feedback = convert_bracket_notation("{x+1, x-1}") - assert result == "{x+1, x-1}" - assert feedback is None - - @pytest.mark.parametrize( - "expr", [ - "[x+y)", - "(x+y]", - "{x+y)", - "[x+y", - "x+y]", - ] - ) - def test_mismatched_brackets_are_rejected(self, expr): - result, feedback = convert_bracket_notation(expr) - assert result == expr - assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" - - -class TestSubstitute: - - @pytest.mark.parametrize( - "string, substitutions, expected", - [ - # Empty substitutions — unchanged - ("hello", [], "hello"), - # Single substitution - ("hello world", [("world", "earth")], "hello earth"), - # Whole string replaced - ("abc", [("abc", "xyz")], "xyz"), - # No match — unchanged - ("hello", [("world", "earth")], "hello"), - # Earlier substitution in list wins at the same position - ("abc", [("a", "p"), ("ab", "q")], "pbc"), - # Longer substitution listed first wins - ("abc", [("ab", "q"), ("a", "p")], "qc"), - # Multiple non-overlapping substitutions - ("a b c", [("a", "x"), ("b", "y"), ("c", "z")], "x y z"), - # List input is joined into a single result - (["hello", " ", "world"], [("world", "earth")], "hello earth"), - ] - ) - def test_substitute(self, string, substitutions, expected): - assert substitute(string, substitutions) == expected - - def test_lookahead_tuple_matches_with_following_context(self): - # (("sin", ["("]), " sin ") matches "sin(" but not "sin " or "sinx" - subs = [(("sin", ["("]), " sin ")] - assert substitute("sin(x)", subs) == " sin (x)" - - def test_lookahead_tuple_does_not_match_without_context(self): - subs = [(("sin", ["("]), " sin ")] - assert substitute("sinx", subs) == "sinx" - - -class TestComputeRelativeTolerance: - - @pytest.mark.parametrize( - "string, expected", - [ - # Non-numeric → 0 - ("not_a_number", 0), - # 1 sig char, below DEFAULT_SIGNIFICANT_FIGURES=2 floor → 5e-2 - ("1", 5e-2), - # "0.5" → chars "05" → lstrip "5" → len 1 → floor applies → 5e-2 - ("0.5", 5e-2), - # "1.5" → chars "15" → len 2 → max(2,2)=2 → 5e-2 - ("1.5", 5e-2), - # "100" → chars "100" (lstrip removes nothing, '1' stops it) → len 3 → 5e-3 - ("100", 5e-3), - # "1.23" → chars "123" (decimal removed) → len 3 → 5e-3 - ("1.23", 5e-3), - # Scientific notation: mantissa "1.23" → len 3 → 5e-3 - ("1.23e5", 5e-3), - # Negative: "-1.23" → lstrip removes "-" → "123" → len 3 → 5e-3 - ("-1.23", 5e-3), - ] - ) - def test_relative_tolerance(self, string, expected): - result = compute_relative_tolerance_from_significant_decimals(string) - assert result == pytest.approx(expected) - - -class TestSympySymbols: - - def test_returns_symbol_objects(self): - result = sympy_symbols({"x": {}, "y": {}}) - assert result == {"x": Symbol("x"), "y": Symbol("y")} - - def test_empty_dict(self): - assert sympy_symbols({}) == {} - - def test_symbol_names_preserved(self): - result = sympy_symbols({"alpha": {}}) - assert result["alpha"] == Symbol("alpha") - - -class TestExtractLatex: - - @pytest.mark.parametrize( - "symbol, expected", - [ - # LaTeX delimiters removed - (r"\(x^2\)", "x^2"), - ("$x^2$", "x^2"), - ("$$x^2$$", "x^2"), - (r"\(\alpha\)", r"\alpha"), - # No delimiters — returned as-is - ("x^2", "x^2"), - ("plain", "plain"), - ] - ) - def test_extract_latex(self, symbol, expected): - assert extract_latex(symbol) == expected - - -class TestLatexSymbols: - - def test_maps_symbol_to_latex_string(self): - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = latex_symbols(syms) - assert result == {Symbol("x"): "x"} - - def test_greek_latex_preserved(self): - syms = {"alpha": {"latex": r"\(\alpha\)", "aliases": []}} - result = latex_symbols(syms) - assert result == {Symbol("alpha"): r"\alpha"} - - def test_empty_dict(self): - assert latex_symbols({}) == {} - - -class TestSympyToLatex: - - def test_simple_power(self): - expr = Symbol("x") ** 2 - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == "x^{2}" - - def test_custom_latex_name_used(self): - expr = Symbol("alpha") - syms = {"alpha": {"latex": r"\(\alpha\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == r"\alpha" - - def test_sqrt(self): - expr = sqrt(Symbol("x")) - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == r"\sqrt{x}" - - -class TestSubstitutionsSortKey: - - def test_longer_left_element_sorts_first(self): - long_sub = ("abc", "p") - short_sub = ("ab", "p") - assert substitutions_sort_key(long_sub) < substitutions_sort_key(short_sub) - - def test_equal_left_length_longer_right_sorts_first(self): - long_right = ("ab", "pqr") - short_right = ("ab", "p") - assert substitutions_sort_key(long_right) < substitutions_sort_key(short_right) - - def test_sort_orders_longer_substitutions_first(self): - subs = [("a", "x"), ("abc", "y"), ("ab", "z")] - subs.sort(key=substitutions_sort_key) - assert subs[0][0] == "abc" - assert subs[1][0] == "ab" - assert subs[2][0] == "a" - - -class TestCreateExpressionSet: - - def test_plain_string_wrapped_in_list(self): - result = create_expression_set("x+y", {}) - assert result == ["x+y"] - - def test_set_notation_split_into_list(self): - result = create_expression_set("{x, y}", {}) - assert sorted(result) == ["x", "y"] - - def test_list_input_accepted(self): - result = create_expression_set(["x", "y"], {}) - assert sorted(result) == ["x", "y"] - - def test_plus_minus_expands_to_two_expressions(self): - params = {"plus_minus": "±"} - result = create_expression_set("±x", params) - assert sorted(result) == sorted(["+x", "-x"]) or sorted(result) == sorted(["x", "-x"]) - assert len(result) == 2 - - def test_curly_braces_used_for_grouping_are_not_split(self): - result = create_expression_set("{x+1}*{x-2}", {}) - assert result == ["{x+1}*{x-2}"] - - -class TestIsMultipleAnswersWrapper: - - @pytest.mark.parametrize( - "expr, expected", [ - ("{x, y}", True), - ("{x+1}", True), - ("{(x+1), (x-1)}", True), - ("x+y", False), - ("{x+1}*{x-2}", False), - ("{x+1}*x", False), - ] - ) - def test_is_multiple_answers_wrapper(self, expr, expected): - assert is_multiple_answers_wrapper(expr) is expected - - -class TestPreprocessExpression: - - def test_plain_expression_succeeds(self): - success, expr, feedback = preprocess_expression("response", "x+y", {}) - assert success is True - assert expr == "x+y" - assert feedback is None - - def test_absolute_value_notation_converted(self): - success, expr, feedback = preprocess_expression("response", "|x|", {}) - assert success is True - assert expr == "Abs(x)" - assert feedback is None - - def test_ambiguous_pipes_returns_failure(self): - success, expr, feedback = preprocess_expression("response", "|x|y|z|", {}) - assert success is False - assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - - def test_square_brackets_converted(self): - success, expr, feedback = preprocess_expression("response", "[x+y]", {}) - assert success is True - assert expr == "(x+y)" - assert feedback is None - - @pytest.mark.parametrize( - "expr", [ - "[x+y)", - "(x+y]", - "{x+y)", - "[x+y", - "x+y]", - ] - ) - def test_mismatched_brackets_returns_failure(self, expr): - success, result, feedback = preprocess_expression("response", expr, {}) - assert success is False - assert result == expr - assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" \ No newline at end of file diff --git a/app/tests/multi_character_test.py b/app/tests/multi_character_test.py deleted file mode 100644 index ed072e9..0000000 --- a/app/tests/multi_character_test.py +++ /dev/null @@ -1,223 +0,0 @@ -import pytest -from ..utility.expression_utilities import parse_expression, create_sympy_parsing_params - -class TestMultiCharImplicitMultiHigherPrecedenceIntegration: - """ - Integration tests for multi-characters symbols and the convention of implicit_higher_precedence enabled. - """ - - def assert_expression_equality(self, response, answer, symbols_dict, local_dict=None): - """ - Helper to parse both response and answer with the same parameters - and assert they result in equivalent SymPy expressions. - """ - if local_dict is None: - local_dict = {} - - # define parameters - params = { - "complexNumbers": False, - "strict_syntax": False, - "elementary_functions": True, - "convention": "implicit_higher_precedence", - "symbols": symbols_dict, - } - - parsing_params = create_sympy_parsing_params(params) - - try: - parsed_response = parse_expression(response, parsing_params) - parsed_answer = parse_expression(answer, parsing_params) - except Exception as e: - pytest.fail(f"Parsing failed for input '{response}' or '{answer}': {str(e)}") - - assert parsed_response == parsed_answer, \ - f"\nInput: {response}\nExpected: {parsed_answer}\nGot: {parsed_response}" - - def test_multi_character_implicit_multi_variable(self): - symbols = { - "a": {"aliases": ["a"]}, - "bc": {"aliases": ["bc"]}, - "d": {"aliases": ["d"]} - } - answer = "a/(bc*d)" - - # Case 1: Full explicit - self.assert_expression_equality("a/(bc*d)", answer, symbols) - - # Case 2: Implicit with brackets - self.assert_expression_equality("a/(bcd)", answer, symbols) - - # Case 3: Implicit no brackets - self.assert_expression_equality("a/bcd", answer, symbols) - - def test_multiple_divisions_with_implicit(self): - """Test multiple divisions in sequence""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]} - } - - answer = "a/(b*c)/(d*f)" - response = "a/bc/df" - self.assert_expression_equality(response, answer, symbols) - - def test_addition_with_division_and_implicit(self): - """Test that addition doesn't interfere""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]} - } - answer = "a/(b*c) + d" - response = "a/bc + d" - self.assert_expression_equality(response, answer, symbols) - - def test_multiplication_after_division(self): - """Test explicit multiplication after division""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]} - } - - answer = "(a/(b*c))*d" - response = "a/bc*d" - self.assert_expression_equality(response, answer, symbols) - - def test_power_with_implicit_multiplication(self): - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - } - answer = "a/(b**2*c)" - response = "a/b^2c" - self.assert_expression_equality(response, answer, symbols) - - def test_longer_multi_character_symbols(self): - symbols = { - "x": {"aliases": ["x"]}, - "abc": {"aliases": ["abc"]}, - "df": {"aliases": ["df"]}, - } - answer = "x/(abc*df)" - test_cases = [ - "x/(abc*df)", - "x/(abcdf)", - "x/abcdf", - ] - for response in test_cases: - self.assert_expression_equality(response, answer, symbols) - - def test_overlapping_symbol_names(self): - symbols = { - "a": {"aliases": ["a"]}, - "ab": {"aliases": ["ab"]}, - "abc": {"aliases": ["abc"]}, - "c": {"aliases": ["c"]}, - } - answer = "a/(abc*c)" - response = "a/abcc" - self.assert_expression_equality(response, answer, symbols) - - def test_numbers_with_implicit_multiplication(self): - """Test with numeric literals""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - } - - answer = "a/(2*b)" - response = "a/2b" - self.assert_expression_equality(response, answer, symbols) - - def test_number_variable_number(self): - """Test number followed by variable followed by number""" - symbols = { - "x": {"aliases": ["x"]}, - } - - answer = "1/(2*x*3)" - response = "1/2x3" - self.assert_expression_equality(response, answer, symbols) - - def test_implicit_before_parentheses(self): - """Test implicit multiplication before parentheses""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - } - - answer = "a*(b*c*d)" - response = "a(bcd)" - self.assert_expression_equality(response, answer, symbols) - - def test_implicit_before_parentheses_with_multichar(self): - """Test implicit multiplication before parentheses with multi-char symbols""" - symbols = { - "a": {"aliases": ["a"]}, - "bc": {"aliases": ["bc"]}, - "d": {"aliases": ["d"]}, - } - - answer = "a*(bc*d)" - response = "a(bcd)" - self.assert_expression_equality(response, answer, symbols) - - def test_complex_expression(self): - """Test a complex expression""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]}, - "g": {"aliases": ["g"]}, - } - answer = "a/(b*c) + d/(g*f)" - response = "a/bc + d/gf" - self.assert_expression_equality(response, answer, symbols) - - def test_three_way_implicit_multiplication(self): - """Test three or more implicitly multiplied terms""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]}, - } - answer = "a/(b*c*d*f)" - response = "a/bcdf" - self.assert_expression_equality(response, answer, symbols) - - def test_explicit_multiplication_should_not_be_grouped(self): - """Test that explicit multiplication maintains standard precedence""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - } - answer = "(a/b)*c" - response = "a/b*c" - self.assert_expression_equality(response, answer, symbols) - - def test_mixed_implicit_and_explicit(self): - """Test mixing implicit and explicit multiplication""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - } - answer = "(a/(b*c))*d" - response = "a/bc*d" - self.assert_expression_equality(response, answer, symbols) \ No newline at end of file diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index 6d1f635..276517a 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -1,15 +1,15 @@ import pytest -import os # Import necessary data and reference cases for tests -from .slr_quantity_test import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases +from .physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases from ..evaluation import evaluation_function -from ..utility.unit_system_conversions import ( - set_of_SI_prefixes, - set_of_SI_base_unit_dimensions, - set_of_derived_SI_units_in_SI_base_units, - set_of_common_units_in_SI, set_of_very_common_units_in_SI, - set_of_imperial_units +from lambdafeedback.units import ( + COMMON_UNITS, + IMPERIAL_UNITS, + SI_BASE_UNITS, + SI_DERIVED_UNITS, + SI_PREFIXES, + VERY_COMMON_UNITS, ) @@ -31,19 +31,8 @@ class TestEvaluationFunction(): Use evaluation_function() to call the evaluation function. """ - # Import tests that makes sure that physical quantity parsing works as expected - from .slr_quantity_test import TestEvaluationFunction as TestStrictSLRSyntax - - log_details = True - - def log_details_to_file(self, details, filename): - if self.log_details: - f = open(filename, "w") - f.write(details) - f.close() - return - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) + @pytest.mark.slow def test_strict_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, @@ -59,6 +48,7 @@ def test_strict_syntax_cases(self, string, value, unit, content, value_latex, un assert result["response_latex"] == "~".join([latex for latex in [value_latex, unit_latex] if latex is not None]) @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) + @pytest.mark.slow def test_natural_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, @@ -76,12 +66,11 @@ def test_natural_syntax_cases(self, string, value, unit, content, value_latex, u @pytest.mark.skip("Too resource intensive") def test_slow_quantity_alternative_names_natural_syntax(self): params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI common imperial", "strictness": "natural"} - units = set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units + units = SI_BASE_UNITS + SI_DERIVED_UNITS + COMMON_UNITS + VERY_COMMON_UNITS + IMPERIAL_UNITS incorrect = [] errors = [] n = 0 - k = 0 - for prefix in set_of_SI_prefixes: + for prefix in SI_PREFIXES: for u1 in units: for u2 in units: if u1 is not u2: @@ -97,25 +86,19 @@ def test_slow_quantity_alternative_names_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - print("Incorrect: "+str((answer, response, result["response_latex"]))) - if n-k > 0: - print(k) - k += 1000 m = len(errors)+len(incorrect) details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) - self.log_details_to_file(details, "test_quantity_alternative_names_natural_syntax_log.txt") # Current number of collisions caused by concatenating two units, e.g. "barnewton" has "barn" as substring, "aremole" has "rem" as substring etc. - assert len(errors)+len(incorrect) <= 144 + assert m <= 144, details @pytest.mark.skip("Too resource intensive") def test_slow_quantity_short_forms_natural_syntax(self): params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI common imperial", "strictness": "natural"} - units = set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units + units = SI_BASE_UNITS + SI_DERIVED_UNITS + COMMON_UNITS + VERY_COMMON_UNITS + IMPERIAL_UNITS incorrect = [] errors = [] n = 0 - k = 0 - for prefix in set_of_SI_prefixes: + for prefix in SI_PREFIXES: for u1 in units: for u2 in units: if u1 is not u2 and (prefix[1]+u1[1] not in [u[1] for u in units]): @@ -130,14 +113,10 @@ def test_slow_quantity_short_forms_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - if n-k > 0: - print(k) - k += 1000 m = len(errors)+len(incorrect) details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) - self.log_details_to_file(details, "test_quantity_short_forms_natural_syntax_units_log.txt") # Current number of collisions caused by concatenating short forms for units, e.g. 'femtometre inch' and 'femtominute' both have short form 'fmin' - assert len(errors)+len(incorrect) <= 551 + assert m <= 551, details @pytest.mark.parametrize( "value,unit,small_diff,large_diff", @@ -150,6 +129,7 @@ def test_slow_quantity_short_forms_natural_syntax(self): ("100*10**10", "kg m/s^2", 4*10**9, 6*10**9) ] ) + @pytest.mark.slow def test_compute_relative_tolerance_from_significant_digits(self, value, unit, small_diff, large_diff): ans = value+" "+unit res_correct_under = str(eval(value)-small_diff)+" "+unit @@ -319,6 +299,7 @@ def test_print_floating_point_approximation_of_very_large_numbers(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is False + @pytest.mark.filterwarnings("ignore:legacy_preprocessing is deprecated:DeprecationWarning") def test_legacy_strictness(self): ans = "100*kilo*pascal*ohm" res = "100 kilopascal ohm" @@ -327,7 +308,8 @@ def test_legacy_strictness(self): 'physical_quantity': True, 'strictness': 'legacy', } - result = evaluation_function(res, ans, params, include_test_data=True) + with pytest.warns(DeprecationWarning, match="legacy_preprocessing is deprecated"): + result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is True ans = "8650*watt" res = "8.65kW" @@ -425,7 +407,6 @@ def test_answer_zero_value(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is False - def test_physical_quantity_with_atol_and_kg_unit(self): ans = "20 kg" params = { @@ -576,6 +557,7 @@ def test_physical_quantity_with_rtol_and_imperial_mass_unit(self): ("30 degree", "30 °"), ] ) + @pytest.mark.slow def test_greek_letter_units(self, ans, res): params = { 'strict_syntax': False, @@ -584,7 +566,3 @@ def test_greek_letter_units(self, ans, res): } result = evaluation_function(res, ans, params) assert result["is_correct"] is True - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--no-header", os.path.abspath(__file__)]) diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index 147cf69..392ce81 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -1,11 +1,12 @@ -import os +from copy import deepcopy + import pytest -from ..preview_implementations.physical_quantity_preview import preview_function -from .slr_quantity_test import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases +from ..context.physical_quantity_preview import fix_exponents, preview_function +from .physical_quantity_test_cases import slr_natural_si_syntax_test_cases, slr_strict_si_syntax_test_cases -class TestPreviewFunction(): +class TestPreviewFunction: """ TestCase Class used to test the algorithm. --- @@ -24,14 +25,17 @@ class TestPreviewFunction(): as it should. """ - @pytest.mark.parametrize("response,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) + @pytest.mark.parametrize( + "response,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases + ) + @pytest.mark.slow def test_strict_syntax_cases(self, response, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict", - "elementary_functions": True + "elementary_functions": True, } result = preview_function(response, params)["preview"] latex = "" @@ -40,17 +44,19 @@ def test_strict_syntax_cases(self, response, value, unit, content, value_latex, elif value_latex is not None and unit_latex is None: latex = value_latex elif value_latex is not None and unit_latex is not None: - latex = value_latex+"~"+unit_latex + latex = value_latex + "~" + unit_latex assert result["latex"] == latex - @pytest.mark.parametrize("response,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) + @pytest.mark.parametrize( + "response,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases + ) def test_natural_syntax_cases(self, response, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "natural", - "elementary_functions": True + "elementary_functions": True, } result = preview_function(response, params)["preview"] latex = "" @@ -59,7 +65,7 @@ def test_natural_syntax_cases(self, response, value, unit, content, value_latex, elif value_latex is not None and unit_latex is None: latex = value_latex elif value_latex is not None and unit_latex is not None: - latex = value_latex+"~"+unit_latex + latex = value_latex + "~" + unit_latex assert result["latex"] == latex @pytest.mark.parametrize( @@ -67,7 +73,7 @@ def test_natural_syntax_cases(self, response, value, unit, content, value_latex, [ ("sin(123)", r"\sin{\left(123 \right)}", "sin(123)"), ("sqrt(162)", r"\sqrt{162}", "sqrt(162)"), - ] + ], ) def test_issue_with_function_name_that_can_be_compound_unit(self, response, preview_latex, preview_sympy): params = { @@ -86,15 +92,16 @@ def test_handwritten_input(self): } response = "162 \\mathrm{~N} / \\mathrm{m}^{2}" result = preview_function(response, params)["preview"] - assert result["latex"] == r'162~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}' # TODO: Fix so that unnecessary parenthesis are simplified away + assert result["latex"] == r"162~\frac{\mathrm{newton}}{\mathrm{metre}^{2}}" assert result["sympy"] == "162 newton/metre**(2)" @pytest.mark.parametrize( - "response, latex, sympy", [ + "response, latex, sympy", + [ (r"\frac{F}{p \cdot \mu}", r"\frac{F}{\mu p}", "F/((mu*p))"), (r"F \cdot p", "F p", "F*p"), (r"\frac{10}{2} \mathrm{~kg}", r"\frac{10}{2}~\mathrm{kilogram}", "10/2 kilogram"), - ] + ], ) def test_latex_physical_quantity_preview(self, response, latex, sympy): params = { @@ -106,6 +113,33 @@ def test_latex_physical_quantity_preview(self, response, latex, sympy): assert result["latex"] == latex assert result["sympy"] == sympy + @pytest.mark.parametrize( + ("latex", "expected"), + [ + ("m^{-2}", "m**(-2)"), + ("x^{23}", "x**(23)"), + # With '**' the notation used to be skipped twice, eating the exponent's first character. + ("m**{-2}", "m**(-2)"), + ("x**{23}", "x**(23)"), + ("x^2", "x**2"), + # An unbraced exponent used to borrow the next brace anywhere in the string. + ("x^2y^{3}", "x**2y**(3)"), + ], + ) + def test_fix_exponents(self, latex, expected): + assert fix_exponents(latex) == expected + + def test_latex_preview_keeps_the_exponent_sign(self): + preview = preview_function(r"162 \mathrm{~N} \mathrm{~m}**{-2}", {"is_latex": True})["preview"] + assert preview["sympy"] == "162 newton metre**(-2)" + + def test_latex_preview_of_a_unit_alone(self): + assert preview_function(r"\mathrm{kg}", {"is_latex": True})["preview"]["sympy"] == "kilogram" -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) + def test_preview_does_not_mutate_params(self): + # Fixed while porting units to the typed expression_parsing API + # (the LaTeX branch set params["is_latex"] = False on the caller's dict). + params = {"is_latex": True, "symbols": {"F": {"latex": "F", "aliases": []}}} + before = deepcopy(params) + preview_function(r"3 \mathrm{kg}", params) + assert params == before diff --git a/app/tests/slr_quantity_test.py b/app/tests/physical_quantity_test_cases.py similarity index 65% rename from app/tests/slr_quantity_test.py rename to app/tests/physical_quantity_test_cases.py index 5f0ac5a..2a75336 100644 --- a/app/tests/slr_quantity_test.py +++ b/app/tests/physical_quantity_test_cases.py @@ -1,10 +1,6 @@ -import pytest -import os - -from ..context.physical_quantity import SLR_quantity_parser, SLR_quantity_parsing, default_parameters -from ..utility.unit_system_conversions import\ - set_of_SI_base_unit_dimensions, set_of_derived_SI_units_in_SI_base_units,\ - set_of_common_units_in_SI, set_of_very_common_units_in_SI, set_of_imperial_units +# Physical quantities with their expected value, unit and LaTeX forms, shared by +# the physical quantity evaluation tests. The quantity parser itself is tested in +# the lambdafeedback-units package. slr_strict_si_syntax_test_cases = [ ("q", # String that will be used as response / answer @@ -54,21 +50,21 @@ "kilogram/(metre second^2)", "kilogram/(metre second^2)", None, - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["NO_VALUE"]), ("metre^(3/2)", None, "metre^(3/2)", "metre^(3/2)", None, - r"\mathrm{metre}^{(\frac{3}{2})}", + r"\mathrm{metre}^{\frac{3}{2}}", ["NO_VALUE"]), ("10 kilogram/(metre second^2)", "10", "kilogram/(metre second^2)", "10 kilogram/(metre second^2)", "10", - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10 kilogram*metre/second**2", "10", @@ -89,7 +85,7 @@ "kilogram*metre*second**(-2)", "10 kilogram*metre*second**(-2)", "10", - r"\mathrm{kilogram}\cdot\mathrm{metre}\cdot\mathrm{second}^{(-2)}", + r"\mathrm{kilogram}\cdot\mathrm{metre}\cdot\mathrm{second}^{-2}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10*pi kilogram*metre/second^2", "10*pi", @@ -109,26 +105,26 @@ "(kilogram megametre^2)/(fs^4 daA)", "(kilogram megametre^2)/(femtosecond^4 decaampere)", None, - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["NO_VALUE"]), ("(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3)", "(kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{4.3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(5.27*pi/sqrt(11) + 5*7)^(2+2.3) (kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(2+2.3)", "(kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(2+2.3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{2 + 2.3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(5*27/11 + 5*7)^(2*3) (kilogram megametre^2)/(fs^4 daA)", "(5*27/11 + 5*7)^(2*3)", "(kilogram megametre^2)/(fs^4 daA)", "(5*27/11 + 5*7)^(2*3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(5 \cdot 27 \cdot \frac{1}{11} + 5 \cdot 7\right)^{2 \cdot 3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(pi+10) kg*m/s^2", "(pi+10)", @@ -298,14 +294,14 @@ "kilogram/(metresecond^2)", "kilogram/(metre second^2)", None, - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["NO_VALUE"]), ("10 kilogram/(metresecond^2)", "10", "kilogram/(metresecond^2)", "10 kilogram/(metre second^2)", "10", - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10 kilogrammetre/second**2", "10", @@ -340,7 +336,7 @@ "kilogrammetresecond**(-2)", "10 kilogram metre second**(-2)", "10", - r"\mathrm{kilogram}~\mathrm{metre}~\mathrm{second}^{(-2)}", + r"\mathrm{kilogram}~\mathrm{metre}~\mathrm{second}^{-2}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10*pi kilogrammetre/second^2", "10*pi", @@ -447,130 +443,3 @@ r"\frac{1}{\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ] - - -class TestEvaluationFunction(): - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) - def test_strict_si_syntax(self, string, value, unit, content, value_latex, unit_latex, criteria): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI", - "strictness": "strict", - "elementary_functions": True, - } - ) - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(string, parameters, parser, "quantity") - parsed_value = quantity.value.original_string() if quantity.value is not None else None - parsed_unit = quantity.unit.original_string() if quantity.unit is not None else None - parsed_value_latex = quantity.value_latex_string - parsed_unit_latex = quantity.unit_latex_string - parsed_content = quantity.ast_root.content_string() - assert parsed_value == value - assert parsed_unit == unit - assert parsed_content == content - assert parsed_value_latex == value_latex - assert parsed_unit_latex == unit_latex - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units] - ) - def test_short_forms_strict_SI(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI] - ) - def test_short_forms_common_SI(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "common", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_imperial_units] - ) - def test_short_forms_imperial(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "imperial", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units] - ) - def test_short_forms_all(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI common imperial", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) - def test_natural_si_syntax(self, string, value, unit, content, value_latex, unit_latex, criteria): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI common imperial", - "strictness": "natural", - "elementary_functions": True - } - ) - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(string, parameters, parser, "quantity") - parsed_unit_latex = quantity.unit_latex_string - parsed_value = quantity.value.original_string() if quantity.value is not None else None - parsed_unit = quantity.unit.original_string() if quantity.unit is not None else None - parsed_value_latex = quantity.value_latex_string - parsed_unit_latex = quantity.unit_latex_string - parsed_content = quantity.ast_root.content_string() - assert parsed_value == value - assert parsed_unit == unit - assert parsed_content == content - assert parsed_value_latex == value_latex - assert parsed_unit_latex == unit_latex - - -if __name__ == "__main__": - pytest.main(["-xs", "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 89243cb..9752e91 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -1,8 +1,8 @@ import pytest -import os from ..evaluation import evaluation_function -from ..utility.expression_utilities import elementary_functions_names, substitute +from lambdafeedback.expression_parsing import substitute +from lambdafeedback.expression_parsing.symbol_tables import ELEMENTARY_FUNCTIONS as elementary_functions_names # REMARK: If a case is marked with an alternative output, this means that it is difficult in this case to prevent sympy from simplifying for that particular case elementary_function_test_cases = [ @@ -211,6 +211,7 @@ def test_complicated_expression_correct(self, response, answer): input_variations += generate_input_variations(response, answer) @pytest.mark.parametrize("response,answer", input_variations) + @pytest.mark.slow def test_simple_fractional_powers_correct(self, response, answer): params = {"strict_syntax": False, "symbol_assumptions": "('g','positive') ('v','positive')"} result = evaluation_function(response, answer, params) @@ -452,6 +453,7 @@ def test_special_functions(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_correct(self, response, answer): params = { "strict_syntax": False, @@ -467,6 +469,7 @@ def test_plus_minus_all_correct(self, response, answer): answer="+- x**2 + -+ y**2" ) ) + @pytest.mark.slow def test_plus_minus_replace_symbols_all_correct(self, response, answer): params = {"plus_minus": "+-", "minus_plus": "-+", "strict_syntax": False} result = evaluation_function(response, answer, params) @@ -479,6 +482,7 @@ def test_plus_minus_replace_symbols_all_correct(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_incorrect(self, response, answer): params = {"strict_syntax": False} result = evaluation_function(response, answer, params) @@ -503,6 +507,7 @@ def test_plus_minus_all_responses_correct(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_responses_incorrect(self, response, answer): params = {"multiple_answers_criteria": "all_responses", "strict_syntax": False} result = evaluation_function(response, answer, params) @@ -1004,6 +1009,7 @@ def test_pi_with_rel_tol(self): ] ] ) + @pytest.mark.slow def test_PHYS40002_2_2_b(self, response, outcome): params = {"strict_syntax": False} answer = "-(y*sin(x*y) + e^(y)) / (x*(e^(y) + sin(x*y)))" @@ -1031,6 +1037,7 @@ def test_PHYS40002_2_2_b(self, response, outcome): ] ] ) + @pytest.mark.slow def test_PHYS40002_2_6_a(self, response, outcome): params = {"strict_syntax": False} answer = "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)" @@ -1054,6 +1061,7 @@ def test_PHYS40002_2_6_a(self, response, outcome): [] ) ) + @pytest.mark.slow def test_elementary_functions(self, description, response, answer): params = {"strict_syntax": False, "elementary_functions": True} result = evaluation_function(response, answer, params) @@ -1272,6 +1280,7 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): ("2*pi", "0", "tan(response) = 0", True, [], {}), ] ) + @pytest.mark.slow def test_criteria_based_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): params = { "strict_syntax": False, @@ -1892,6 +1901,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac ), ] ) + @pytest.mark.slow def test_criteria_where_numerical_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): params = { "strict_syntax": False, @@ -2042,6 +2052,7 @@ def test_alternatives_to_input_symbols_takes_priority_over_elementary_function_a result = evaluation_function(response, answer, params) assert result["is_correct"] is False + @pytest.mark.slow def test_elementary_function_symbol_with_implicit_multiplication_on_both_sides(self): response = "momega^3 l^2/2(exp(2omegat)-exp(-2omegat))" answer = "m*omega^3*l^2*sinh(2*omega*t)" @@ -2053,7 +2064,7 @@ def test_elementary_function_symbol_with_implicit_multiplication_on_both_sides(s assert result["is_correct"] is True def test_curly_braces_used_for_grouping_in_response(self): - response = "2 pi e^{-a |omega|}" # {} is accepted as another way of writing () for grouping + response = "2 pi e^{-a |omega|}" # {} is accepted as another way of writing () for grouping answer = "2 pi e^(-a|omega|)" params = { 'atol': 0, @@ -2066,8 +2077,8 @@ def test_curly_braces_used_for_grouping_in_response(self): assert result["is_correct"] is True def test_unexpected_equalities_in_response_that_generates_set(self): - response = "z= plus_minus 1 + 2*i" # plus_minus generates a set of two equalities - answer = "2i plus_minus 1" # plus_minus generates a set of two expressions + response = "z= plus_minus 1 + 2*i" # plus_minus generates a set of two equalities + answer = "2i plus_minus 1" # plus_minus generates a set of two expressions params = { 'atol': 0, 'rtol': 0, @@ -2144,7 +2155,7 @@ def test_equality_with_both_sides_equal_in_response(self): } result = evaluation_function(response, answer, params) assert result["is_correct"] is False - + def test_integral(self): response = "6y" answer = "int(3*x*y, (x, 0, 2))" @@ -2247,7 +2258,3 @@ def test_greek_letters_in_expressions(self, unicode_expr, letter_expr): } result = evaluation_function(response, answer, params) assert result["is_correct"] is True - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", '--durations=10', os.path.abspath(__file__)]) diff --git a/app/tests/symbolic_preview_test.py b/app/tests/symbolic_preview_test.py deleted file mode 100644 index cb86d62..0000000 --- a/app/tests/symbolic_preview_test.py +++ /dev/null @@ -1,258 +0,0 @@ -import os -import pytest - -from ..utility.preview_utilities import Params, extract_latex -from ..preview_implementations.symbolic_preview import preview_function -from .symbolic_evaluation_test import elementary_function_test_cases - - -class TestPreviewFunction(): - """ - TestCase Class used to test the algorithm. - --- - Tests are used here to check that the algorithm written - is working as it should. - - It's best practise to write these tests first to get a - kind of 'specification' for how your algorithm should - work, and you should run these tests before committing - your code to AWS. - - Read the docs on how to use unittest here: - https://docs.python.org/3/library/unittest.html - - Use preview_function() to check your algorithm works - as it should. - """ - - def test_doesnt_simplify_latex_by_default(self): - response = "\\frac{x + x^2 + x}{x}" - params = Params(is_latex=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("sympy") == "(x**2 + x + x)/x" - - def test_doesnt_simplify_sympy_by_default(self): - response = "(x + x**2 + x)/x" - params = Params(is_latex=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\frac{x^{2} + x + x}{x}" - - def test_simplifies_latex_on_param(self): - response = "\\frac{x + x^2 + x}{x}" - params = Params(is_latex=True, simplify=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("sympy") == "x + 2" - - def test_simplifies_sympy_on_param(self): - response = "(x + x**2 + x)/x" - params = Params(is_latex=False, simplify=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("latex") == "x + 2" - - def test_sympy_handles_implicit_multiplication(self): - response = "sin(x) + cos(2x) - 3x**2" - params = Params(is_latex=False, strict_syntax=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == r"- 3 \cdot x^{2} + \sin{\left(x \right)} + \cos{\left(2 \cdot x \right)}" - - def test_latex_with_equality_symbol(self): - response = "\\frac{x + x^2 + x}{x} = y" - params = Params(is_latex=True, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") == '(x**2 + x + x)/x=y' - - def test_sympy_with_equality_symbol(self): - response = "Eq((x + x**2 + x)/x, 1)" - params = Params(is_latex=False, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\frac{x^{2} + x + x}{x} = 1" - - def test_latex_with_plus_minus(self): - response = r"\pm \frac{3}{\sqrt{5}} i" - params = Params( - is_latex=True, - simplify=False, - complexNumbers=True, - symbols={ - "I": { - "latex": "$i$", - "aliases": ["i"], - }, - "plus_minus": { - "latex": "$\\pm$", - "aliases": ["pm", "+-"], - }, - } - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in {'{3*(sqrt(5)/5)*I, -3*sqrt(5)/5*I}', '{-3*sqrt(5)/5*I, 3*(sqrt(5)/5)*I}'} - assert preview.get("latex") == r'\pm \frac{3}{\sqrt{5}} i' - response = r"4 \pm \sqrt{6}}" - params = Params( - is_latex=True, - simplify=False, - complexNumbers=True, - symbols={ - "plus_minus": { - "latex": "$\\pm$", - "aliases": ["pm", "+-"], - }, - } - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in {'{sqrt(6) + 4, 4 - sqrt(6)}', '{4 - sqrt(6), sqrt(6) + 4}'} - assert preview.get("latex") == r'4 \pm \sqrt{6}}' - - def test_latex_conversion_preserves_default_symbols(self): - response = "\\mu + x + 1" - params = Params(is_latex=True, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in "mu + x + 1" - - def test_sympy_conversion_preserves_default_symbols(self): - response = "mu + x + 1" - params = Params(is_latex=False, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\mu + x + 1" - - def test_latex_conversion_preserves_optional_symbols(self): - response = "m_{ \\text{table} } + \\text{hello}_\\text{world} - x + 1" - params = Params( - is_latex=True, - simplify=False, - symbols={ - "m_table": { - "latex": r"hello \( m_{\text{table}} \) world", - "aliases": [], - }, - "test": { - "latex": r"hello $ \text{hello}_\text{world} $ world.", - "aliases": [], - }, - }, - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") == "m_table + test - x + 1" - - def test_sympy_conversion_preserves_optional_symbols(self): - response = "m_table + test + x + 1" - params = Params( - is_latex=False, - simplify=False, - symbols={ - "m_table": {"latex": "m_{\\text{table}}", "aliases": []}, - "test": { - "latex": "\\text{hello}_\\text{world}", - "aliases": [], - }, - }, - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "m_{\\text{table}} + \\text{hello}_\\text{world} + x + 1" - - def test_invalid_latex_returns_error(self): - response = "\frac{ m_{ \\text{table} } + x + 1 }{x" - params = Params( - is_latex=True, - simplify=False, - symbols={"m_table": {"latex": "m_{\\text{table}}", "aliases": []}}, - ) - - with pytest.raises(ValueError): - preview_function(response, params) - - def test_invalid_sympy_returns_error(self): - response = "x + x***2 - 3 / x 4" - params = Params(simplify=False, is_latex=False) - - with pytest.raises(ValueError): - preview_function(response, params) - - def test_extract_latex_in_delimiters(self): - parentheses = r"\( x + 1 \)" - dollars = r"$ x ** 2 + 1 $" - double_dollars = r"$$ \sin x + \tan x $$" - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - assert extract_latex(double_dollars) == r" \sin x + \tan x " - - def test_extract_latex_in_delimiters_and_text(self): - parentheses = r"hello \( x + 1 \) world." - dollars = r"hello $ x ** 2 + 1 $ world." - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - - def test_extract_latex_no_delimiters(self): - test = r"'\sin x + \left ( \text{hello world} \right ) + \cos x" - assert extract_latex(test) == test - - def test_extract_latex_multiple_expressions(self): - parentheses = r"hello \( x + 1 \) world. \( \sin x + \cos x \) yes." - dollars = r"hello $ x ** 2 + 1 $ world. \( \sigma \times \alpha \) no." - mixture = r"hello $ x ** 2 - 1 $ world. $ \sigma \times \alpha $ !." - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - assert extract_latex(mixture) == " x ** 2 - 1 " - - def test_elementary_functions_preview(self): - params = {"strict_syntax": False, "elementary_functions": True} - for case in elementary_function_test_cases: - response = case[1] - result = preview_function(response, params) - assert result["preview"]["latex"] == case[3] - - def test_implicit_multiplication_convention_implicit_higher_precedence(self): - response = "1/ab" - latex = r"1 \cdot \frac{1}{a \cdot b}" # REMARK: Here it would be preferable to not have the '1 \cdot ' at the start - params = {"strict_syntax": False, "convention": "implicit_higher_precedence"} - result = preview_function(response, params) - assert result["preview"]["latex"] == latex - - def test_implicit_multiplication_convention_equal_precedence(self): - latex = r"1 \cdot \frac{1}{a} \cdot b" # REMARK: Here it would be preferable to not have the '1 \cdot ' at the start - params = {"strict_syntax": False, "convention": "equal_precedence"} - response_a = "1/ab" - result_a = preview_function(response_a, params) - assert result_a["preview"]["latex"] == latex - response_b = "1/a*b" - result_b = preview_function(response_b, params) - assert result_b["preview"]["latex"] == latex - - def test_MECH50001_2_24_a(self): - params = { - "strict_syntax": False, - "elementary_functions": True, - 'symbols': { - 'alpha': {'aliases': [], 'latex': r'\alpha'}, - 'Derivative(q,t)': {'aliases': ['q_{dot}', 'q_dot'], 'latex': r'\dot{q}'}, - 'Derivative(T,t)': {'aliases': ['dT/dt'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}t}'}, - 'Derivative(T,x)': {'aliases': ['dT/dx'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}x}'}, - 'Derivative(T,x,x)': {'aliases': ['(d^2 T)/(dx^2)', 'd^2 T/dx^2', 'd^2T/dx^2'], 'latex': r'\frac{\mathrm{d}^2 T}{\mathrm{d}x^2}'}, - }, - } - response = "d^2T/dx^2 + q_dot/k = 1/alpha*(dT/dt)" - result = preview_function(response, params) - assert result["preview"]["latex"] == r'\frac{d^{2}}{d x^{2}} T + \frac{\frac{d}{d t} q}{k}=1 \cdot \frac{1}{\alpha} \cdot \frac{d}{d t} T' - - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/syntactical_comparison_utilities_test.py b/app/tests/syntactical_comparison_utilities_test.py deleted file mode 100644 index df62243..0000000 --- a/app/tests/syntactical_comparison_utilities_test.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from ..utility.syntactical_comparison_utilities import generate_arbitrary_number_pattern_matcher - - -class TestGenerateArbitraryNumberPatternMatcher: - - @pytest.mark.parametrize( - "pattern_string, matching_strings, non_matching_strings", - [ - ("2x+3", ["2x+3", "5x+7"], ["2x-3", "2x+3+1"]), - # Regression test: the text following the last matched number - # (here the closing bracket ")") used to be inserted into the - # generated regex unescaped and unwrapped, which could produce - # an invalid pattern and raise `re.error: unbalanced parenthesis`. - ("(2x-5)(3x+2)", ["(2x-5)(3x+2)", "(7x-1)(4x+9)"], ["(3x+2)(2x-5)", "3x^2-11x-10"]), - ("(x-4)^2-5", ["(x-4)^2-5", "(x-6)^2-1"], ["(x-4)^2+5", "x^2-8x+11"]), - ] - ) - def test_matcher(self, pattern_string, matching_strings, non_matching_strings): - matcher = generate_arbitrary_number_pattern_matcher(pattern_string) - for matching_string in matching_strings: - assert matcher(matching_string) is True - for non_matching_string in non_matching_strings: - assert matcher(non_matching_string) is False diff --git a/app/utility/__init__.py b/app/utility/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/utility/criteria_graph_utilities.py b/app/utility/criteria_graph_utilities.py deleted file mode 100644 index b2d56d0..0000000 --- a/app/utility/criteria_graph_utilities.py +++ /dev/null @@ -1,374 +0,0 @@ -import json - -evaluation_style = ("([", "])") -starting_evaluation_style = (">", "]") -criterion_style = ("[", "]") -output_style = ("{{", "}}") -special_style = ("[[", "]]") - - -class CriteriaGraph: - - class Node: - def __init__(self, label, summary, details): - self.label = label - self.summary = summary - self.details = details - self.incoming = [] - self.outgoing = [] - return - - def __eq__(self, other): - return self.label == other.label and self.summary == other.summary and self.details == other.details - - class Evaluation(Node): - def __init__(self, label, summary, details, evaluate, replacement=None): - super().__init__(label, summary, details) - self.results = self.outgoing - self.evaluate = evaluate - self.replacement = replacement - return - - class Criterion(Node): - def __init__(self, label, summary, details, tags=None, feedback_string_generator=None): - super().__init__(label, summary, details) - self.consequences = self.outgoing - if feedback_string_generator is not None: - self.feedback_string_generator = feedback_string_generator - else: - self.feedback_string_generator = lambda x: None - if tags is None: - self.tags = set() - else: - self.tags = tags - return - - class Output(Node): - def __init__(self, label, summary, details, tags=None): - super().__init__(label, summary, details) - self.outgoing = [] - if tags is None: - self.tags = set() - else: - self.tags = tags - return - - class Edge: - def __init__(self, source, target): - self.source = source - self.target = target - return - - def __eq__(self, other): - return self.source.label == other.source.label and self.target.label == other.target.label - - def __hash__(self): - return (self.source.label, self.target.label).__hash__() - - RETURN = Output("RETURN", "RETURN", "Reached a previously visited node.") - END = Output("END", "END", "Evaluation completed.") - - class Tree(Node): - - def __init__(self, node, parent=None, outgoing=None, identifier=0, main_criteria=None): - super().__init__(node.label, node.summary, node.details) - self.identifier = "_"+str(identifier) - self.style = self.get_node_style(node) - self.type_label = self.get_node_type(node, main_criteria) - if parent is not None: - self.incoming = [parent] - if isinstance(node, CriteriaGraph.Output) or outgoing is None: - self.outgoing = [] - else: - self.outgoing = outgoing - return - - def get_node_type(self, node, main_criteria=None): - if main_criteria is None: - main_criteria = [] - if isinstance(node, CriteriaGraph.Evaluation): - type_label = "evaluation" - elif isinstance(node, CriteriaGraph.Criterion): - if node.label in main_criteria: - type_label = "main_criterion" - else: - type_label = "criterion" - elif isinstance(node, CriteriaGraph.Output): - type_label = "output" - else: - raise Exception("Cannot find style for this kind of node.") - return type_label - - def get_node_style(self, node): - if isinstance(node, CriteriaGraph.Evaluation): - style = evaluation_style - elif isinstance(node, CriteriaGraph.Criterion): - style = criterion_style - elif isinstance(node, CriteriaGraph.Output): - style = output_style - else: - raise Exception("Cannot find style for this kind of node.") - return style - - def mermaid(self, special_nodes=None): - if special_nodes is None: - special_nodes = [] - nodes = [self.label+self.identifier+('"'+self.summary+'"').join(starting_evaluation_style)] - edges = [self.label+self.identifier+" --> "+child.label+child.identifier for child in self.outgoing] - stack = self.outgoing - while len(stack) > 0: - child = stack.pop() - if child.label in special_nodes: - style = special_style - else: - style = child.style - nodes.append(child.label+child.identifier+('"'+child.summary+'"').join(style)) - edges += [child.label+child.identifier+" --> "+next_child.label+next_child.identifier for next_child in child.outgoing] - stack += child.outgoing - output = ["graph TD"]+nodes+edges - return "\n\t".join(output) - - def as_dictionary(self): - tree_dict = { - "label": self.label, - "summary": self.summary, - "details": self.details, - "type": self.type_label, - "children": [node.as_dictionary() for node in self.outgoing] - } - return tree_dict - - def json(self): - return str(json.dumps(self.as_dictionary())) - - def __init__(self, identifier, entry_evaluations=None): - self.identifier = identifier - self.evaluations = {} - self.criteria = {} - self.outputs = {} - self.sufficiencies = {} - self.entry_evaluations = entry_evaluations - return - - def json(self): - graph = { - "evaluations": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming], - "outgoing": [n.target.label for n in node.outgoing] - } for (label, node) in self.evaluations.items() - }, - "criteria": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming], - "outgoing": [n.target.label for n in node.outgoing] - } for (label, node) in self.criteria.items() - }, - "outputs": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming] - } for (label, node) in self.outputs.items() - }, - "sufficiencies": {label: list(suffs) for (label, suffs) in self.sufficiencies.items() if suffs is not None}, - } - return str(json.dumps(graph)) - - def mermaid(self): - output = ["flowchart TD"] - linebreak = '
---
' - edges = set() - sufficiencies = set() - node_sets = [self.evaluations, self.criteria, self.outputs] - node_styles = [evaluation_style, criterion_style, output_style] - node_keys = {} - for set_index, nodes in enumerate(node_sets): - index = 0 - for (label, node) in nodes.items(): - node_keys.update({label: "N_"+str(set_index)+"_"+str(index)}) - index += 1 - for set_index, nodes in enumerate(node_sets): - style = node_styles[set_index] - for (label, node) in nodes.items(): - output.append(node_keys[label]+style[0]+'"'+label+linebreak+node.details+'"'+style[1]) - edges.update([(node_keys[edge.source.label], node_keys[edge.target.label]) for edge in node.outgoing+node.incoming]) - if self.sufficiencies.get(label, None) is not None: - sufficiencies.update([(label, sufficiency) for sufficiency in self.sufficiencies.get(label, None)]) - for edge in edges: - output.append(" --> ".join(edge)) - for sufficiency in sufficiencies: - output.append(" -.-> ".join(sufficiency)) - return "\n\t".join(output) - - def add_evaluation_node(self, label, summary, details, sufficiencies=None, evaluate=None, feedback_string_generator=None): - if feedback_string_generator is not None: - raise Exception(f"{label} is an evaluation node, evaluation nodes cannot generate feedback strings.") - if label in self.evaluations.keys(): - raise Exception(f"Evaluation node {label} is already defined.") - else: - node = CriteriaGraph.Evaluation(label, summary, details, evaluate) - self.evaluations.update({label: node}) - self.sufficiencies.update({label: sufficiencies}) - return node - - def add_criterion_node(self, label, summary, details, sufficiencies=None, evaluate=None, feedback_string_generator=None): - if label in self.criteria.keys(): - raise Exception(f"Criterion node {label} is already defined.") - if sufficiencies is not None: - raise Exception("Criterion nodes cannot have sufficiencies.") - node = CriteriaGraph.Criterion(label, summary=summary, details=details, feedback_string_generator=feedback_string_generator) - self.criteria.update({label: node}) - self.sufficiencies.update({label: sufficiencies}) - return node - - def add_output_node(self, label, summary, details): - if label in self.outputs.keys(): - raise Exception(f"Output node {label} is already defined.") - else: - node = CriteriaGraph.Output(label, summary, details) - self.outputs.update({label: node}) - return node - - def add_node(self, node): - if isinstance(node, CriteriaGraph.Evaluation): - self.add_evaluation_node(node.label, node.summary, node.details, node.sufficiencies) - elif isinstance(node, CriteriaGraph.Criterion): - self.add_criterion_node(node.label, node.summary, node.details) - elif isinstance(node, CriteriaGraph.Output): - self.add_output_node(node.label, node.summary, node.details) - else: - raise Exception("Can only add evaluation, criterion or output nodes to criteria graph.") - - def attach(self, source_label, target_label, summary=None, details=None, sufficiencies=None, evaluate=None, feedback_string_generator=None): - source = self.evaluations.get(source_label, None) - if source is None: - source = self.criteria.get(source_label, None) - if source is None: - raise Exception(f"Unknown node {source_label}.") - - if isinstance(source, CriteriaGraph.Evaluation): - target_set = self.criteria - target_generator = self.add_criterion_node - target_alternative_set = {} - target_wrong_type_set = self.evaluations - type_name = "evaluation" - other_type_name = "criterion" - elif isinstance(source, CriteriaGraph.Criterion): - target_set = self.evaluations - target_generator = self.add_evaluation_node - target_alternative_set = self.outputs - target_wrong_type_set = self.criteria - type_name = "criterion" - other_type_name = "evaluation" - elif isinstance(source, CriteriaGraph.Output): - raise Exception(f"{source_label} is an output nodes. Output nodes cannot have outgoing edges.") - else: - raise Exception(f"Source node {source_label} is an invalid type.") - - target = target_set.get(target_label, None) - if target is None: - target = target_alternative_set.get(target_label, None) - if target is None: - target = target_wrong_type_set.get(target_label, None) - if target is None: - if summary is None or details is None: - raise Exception(f"Unknown node {target_label}. If you wish to create a new node summary and details must be specified.") - else: - target = target_generator(target_label, summary=summary, details=details, sufficiencies=sufficiencies, evaluate=evaluate, feedback_string_generator=feedback_string_generator) - else: - raise Exception(f"Both {source_label} and {target_label} are {type_name} nodes. Only {other_type_name} nodes can be attached to {type_name} nodes.") - - edge = CriteriaGraph.Edge(source, target) - if edge in source.outgoing: - raise Exception(f"{target_label} is already attached to {source_label}.") - else: - source.outgoing.append(edge) - target.incoming.append(edge) - return - - def add_sufficiencies(self, source_label, sufficiencies): - if source_label in self.evaluations.keys(): - if self.sufficiencies.get(source_label, None) is None: - self.sufficiencies.update({source_label: []}) - for sufficiency in sufficiencies: - if sufficiency not in self.sufficiencies[source_label]: - self.sufficiencies[source_label].append(sufficiency) - else: - raise Exception(f"Unknown evaluation node {source_label}. Only evaluation nodes can have sufficiencies.") - return - - def starting_evaluations(self, label): - # TODO: Consider if starting evaluations should only accept evaluation nodes - # instead of guessing the intent when using criteria nodes as targets - if label in self.criteria.keys(): - main_criteria = self.criteria[label] - base_starting_evaluations = set(edge.source.label for edge in main_criteria.incoming) - elif label in self.evaluations.keys(): - base_starting_evaluations = set([label]) - else: - raise Exception(f"No criterion or evaluation with label {label}.") - starting_evaluations = set() - candidate_starting_evaluations = base_starting_evaluations - while len(candidate_starting_evaluations) > 0: - label = candidate_starting_evaluations.pop() - if self.sufficiencies.get(label, None) is None: - starting_evaluations.update([label]) - else: - for sufficiency in self.sufficiencies.get(label, []): - candidate_starting_evaluations.update([edge.source.label for edge in self.criteria[sufficiency].incoming]) - if len(starting_evaluations) == 0: - starting_evaluations = base_starting_evaluations - return starting_evaluations - - def build_tree(self, starting_evaluation, return_node=RETURN, main_criteria=None): - node = self.evaluations.get(starting_evaluation, None) - if node is None: - raise Exception(f"Unknown evaluation node {node.label}.") - identifier = 0 - root_node = CriteriaGraph.Tree(node, identifier=identifier, main_criteria=main_criteria) - stack = [(edge.target, root_node) for (k, edge) in enumerate(node.outgoing)] - visited_nodes = [node.label] - while len(stack) > 0: - node, parent = stack.pop() - if node.label in visited_nodes: - parent.outgoing.append(CriteriaGraph.Tree(CriteriaGraph.Output("RETURN"+str(identifier), "Go to: "+node.label, "Reached a previously visited node."), parent=parent, identifier=identifier, main_criteria=main_criteria)) - else: - tree_node = CriteriaGraph.Tree(node, parent=parent, identifier=identifier, main_criteria=main_criteria) - parent.outgoing.append(tree_node) - if not isinstance(node, CriteriaGraph.Output): - visited_nodes.append(node.label) - stack += [(edge.target, tree_node) for (k, edge) in enumerate(node.outgoing)] - identifier += 1 - return root_node - - def trees(self, label): - trees = [self.build_tree(start, main_criteria=[label]) for start in self.starting_evaluations(label)] - return trees - - def generate_feedback(self, response, main_criteria): - evaluations = set().union(self.starting_evaluations(main_criteria)) - visited_evaluations = set() - feedback = dict() - while len(evaluations) > 0: - e = evaluations.pop() - if e in self.evaluations.keys() and self.evaluations[e].replacement is not None: - visited_evaluations.update({e}) - e = self.evaluations[e].replacement.label - if e not in visited_evaluations and e in self.evaluations.keys(): - visited_evaluations.update({e}) - try: - results = self.evaluations[e].evaluate(response) - except Exception as exc: - print(e) - print(self.evaluations) - raise exc - feedback.update(results) - for criterion in results.keys(): - labels = {edge.target.label for edge in self.criteria[criterion].outgoing} - evaluations = evaluations.union(labels) - return feedback diff --git a/app/utility/criteria_parsing.py b/app/utility/criteria_parsing.py deleted file mode 100644 index 2f06ea2..0000000 --- a/app/utility/criteria_parsing.py +++ /dev/null @@ -1,93 +0,0 @@ -from .slr_parsing_utilities import SLR_Parser, catch_undefined, infix, create_node, join, proceed, append_last - -start_symbol = "START" -end_symbol = "END" -null_symbol = "NULL" - -base_token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" *BOOL *", "BOOL"), - (" *EQUALITY *", "EQUALITY"), - (" *EQUAL *", "EQUAL"), - (" *EQUAL_LIST *", "EQUAL_LIST"), - (" *RESERVED *", "RESERVED"), - (" *= *", "EQUALITY"), - (" *(>=?|<=?|ORDER) *", "ORDER"), # less than (or equal), < (<=), greater than (or equal), > (>=) - (" *where *", "WHERE"), - (" *written +as *", "WRITTEN_AS"), - (" *proportional +to *", "PROPORTIONAL_TO"), - (" *contains *", "CONTAINS"), - (" *; *", "SEPARATOR"), - (" *OTHER *", "OTHER", catch_undefined), -] - -base_productions = [ - ("START", "BOOL", create_node), - ("BOOL", "EQUAL", proceed), - ("BOOL", "ORDER", proceed), - ("BOOL", "EQUAL where EQUAL", infix), - ("BOOL", "EQUAL where EQUAL_LIST", infix), - ("BOOL", "RESERVED written as OTHER", infix), - ("BOOL", "RESERVED written as RESERVED", infix), - ("EQUAL", "OTHER proportional to OTHER", infix), - ("EQUAL", "RESERVED proportional to OTHER", infix), - ("EQUAL", "OTHER proportional to RESERVED", infix), - ("EQUAL", "RESERVED proportional to RESERVED", infix), - ("BOOL", "RESERVED contains OTHER", infix), - ("BOOL", "RESERVED contains RESERVED", infix), - ("EQUAL_LIST", "EQUAL;EQUAL", infix), - ("EQUAL_LIST", "EQUAL_LIST;EQUAL", append_last), - ("EQUAL", "OTHER = OTHER", infix), - ("EQUAL", "RESERVED = OTHER", infix), - ("EQUAL", "OTHER = RESERVED", infix), - ("EQUAL", "RESERVED = RESERVED", infix), - ("EQUAL", "OTHER ORDER OTHER", infix), - ("EQUAL", "RESERVED ORDER OTHER", infix), - ("EQUAL", "OTHER ORDER RESERVED", infix), - ("EQUAL", "RESERVED ORDER RESERVED", infix), - ("OTHER", "RESERVED OTHER", join), - ("OTHER", "OTHER RESERVED", join), - ("OTHER", "OTHER OTHER", join), -] - - -def generate_criteria_parser(reserved_expressions, token_list=base_token_list, productions=base_productions): - - for value in reserved_expressions.values(): - token_list += [(key, "RESERVED") for key in value.keys()] - - return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) - - -if __name__ == "__main__": - test_criteria = [] - for comparison in ["=", ">", "<", ">=", "<="]: - test_criteria += [ - f"a {comparison} b", - f"response {comparison} b", - f"a {comparison} response", - f"response {comparison} answer", - ] - test_criteria += [ - "response = b*answer", - "response = q where q = a*b", - "response = q+p where q = a*b; p = b*c", - "response written as answer", - "response written as a*b*c", - "response - answer = 0", - ] - reserved_expressions = { - "learner": - {"response": "a*b*c", }, - "task": - {"answer": "c*b*a", } - } - criteria_parser = generate_criteria_parser(reserved_expressions) - for criteria in test_criteria: - tokens = criteria_parser.scan(criteria) - print(tokens) - tree = criteria_parser.parse(tokens) - print(tree) - print("---------------------------------------------------") diff --git a/app/utility/evaluation_result_utilities.py b/app/utility/evaluation_result_utilities.py deleted file mode 100644 index f1c0eb5..0000000 --- a/app/utility/evaluation_result_utilities.py +++ /dev/null @@ -1,71 +0,0 @@ -class EvaluationResult: - def __init__(self): - self.is_correct = False - self.latex = None - self._feedback = [] # A list that will hold all feedback items - self._feedback_tags = {} # A dictionary that holds a list with indices to all feedback items with the same tag - self._criteria_graphs = {} - self._criteria_graphs_vis = {} - self.latex = "" - self.simplified = "" - - def get_feedback(self, tag): - return self._feedback_tags.get(tag, None) - - def get_tags(self): - return list(self._feedback_tags.keys()) - - def add_feedback(self, feedback_item): - if isinstance(feedback_item, tuple): - self._feedback.append(feedback_item[1]) - if feedback_item[0] not in self._feedback_tags.keys(): - self._feedback_tags.update({feedback_item[0]: [len(self._feedback)-1]}) - else: - self._feedback_tags[feedback_item[0]].append(len(self._feedback)-1) - else: - raise TypeError("Feedback must be on the form (tag, feedback).") - self._feedback_tags - - def add_feedback_from_tags(self, tags, graph, custom_feedback=None): - if custom_feedback is None: - custom_feedback = {} - for (tag, inputs) in tags.items(): - if tag not in self._feedback_tags.keys(): - if tag in custom_feedback.keys(): - feedback_string = custom_feedback[tag] - else: - if inputs is None: - feedback_string = graph.criteria[tag].feedback_string_generator(dict()) - else: - feedback_string = graph.criteria[tag].feedback_string_generator(inputs) - self.add_feedback((tag, feedback_string)) - - def add_criteria_graph(self, name, graph): - self._criteria_graphs.update({name: graph.json()}) - self._criteria_graphs_vis.update({name: graph.mermaid()}) - - def _serialise_feedback(self) -> str: - feedback = [] - for x in self._feedback: - if (isinstance(x, tuple) and len(x[1].strip())) > 0: - feedback.append(x[1].strip()) - elif x is not None and len(x.strip()) > 0: - feedback.append(x.strip()) - return "
".join(feedback) - - def serialise(self, include_test_data=False) -> dict: - out = dict(is_correct=self.is_correct, feedback=self._serialise_feedback()) - out.update(dict(tags=list(self._feedback_tags.keys()))) - if include_test_data is True: - out.update(dict(criteria_graphs=self._criteria_graphs)) - out.update(dict(criteria_graphs_vis=self._criteria_graphs_vis)) - if self.latex is not None: - out.update(dict(response_latex=self.latex)) - if self.simplified is not None: - if isinstance(self.simplified, list): - self.simplified = ", ".join(self.simplified) - out.update(dict(response_simplified=self.simplified)) - return out - - def __getitem__(self, key): - return self.serialise(include_test_data=True)[key] diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py deleted file mode 100644 index 50656da..0000000 --- a/app/utility/expression_utilities.py +++ /dev/null @@ -1,868 +0,0 @@ -# Default parameters for expression handling -# Any contexts that use this collection of utility functions -# must define values for theses parameters - - -DEFAULT_SIGNIFICANT_FIGURES = 2 - -default_parameters = { - "complexNumbers": False, - "convention": "equal_precedence", - "elementary_functions": False, - "strict_syntax": False, - "multiple_answers_criteria": "all", -} - -# -------- String Manipulation imports -from .slr_parsing_utilities import ( - SLR_expression_parser, - infix, - group, - compose -) -from .syntactical_comparison_utilities import is_number_regex - -# (Sympy) Expression Parsing imports -from sympy.parsing.sympy_parser import parse_expr, split_symbols_custom, _token_splittable -from sympy.parsing.sympy_parser import T as parser_transformations -from sympy.printing.latex import LatexPrinter -from sympy import Basic, Symbol, Equality, Function - -import re -from typing import Dict, List, TypedDict - -from ..feedback.symbolic import feedback_generators as feedback_string_generators - - -class ModifiedLatexPrinter(LatexPrinter): - """Modified LatexPrinter class that prints logarithms other than the natural logarithm correctly. - """ - def _print_log(self, expr, exp=None): - if self._settings["ln_notation"] and len(expr.args) < 2: - log_not = r"\ln" - else: - log_not = r"\log" - if len(expr.args) > 1: - base = self._print(expr.args[1]) - log_not = r"\log_{%s}" % base - tex = r"%s{\left(%s \right)}" % (log_not, self._print(expr.args[0])) - - if exp is not None: - return r"%s^{%s}" % (tex, exp) - else: - return tex - - -elementary_functions_names = [ - ('sin', []), ('sinc', []), ('csc', ['cosec']), ('cos', []), ('sec', []), ('tan', []), ('cot', ['cotan']), - ('asin', ['arcsin']), ('acsc', ['arccsc', 'arccosec', 'acosec']), ('acos', ['arccos']), ('asec', ['arcsec']), - ('atan', ['arctan']), ('acot', ['arccot', 'arccotan', 'acotan']), ('atan2', ['arctan2']), - ('sinh', []), ('cosh', []), ('tanh', []), ('csch', ['cosech']), ('sech', []), - ('asinh', ['arcsinh']), ('acosh', ['arccosh']), ('atanh', ['arctanh']), - ('acsch', ['arccsch', 'arccosech']), ('asech', ['arcsech']), - ('exp', ['Exp']), ('E', ['e']), ('log', ['ln']), - ('sqrt', []), ('sign', []), ('Abs', ['abs']), ('Max', ['max']), ('Min', ['min']), ('arg', []), ('ceiling', ['ceil']), ('floor', []), - ('oo',['Infinity', 'inf', 'infinity', '∞']), - # Special symbols to make sure plus_minus and minus_plus are not destroyed during preprocessing - ('plus_minus', []), ('minus_plus', []), - # Below this line should probably not be collected with elementary functions. Some like 'common operations' would be a better name - ('summation', ['sum', 'Sum']), ('Integral', ['int']), ('Derivative', ['diff']), ('re', ['real']), ('im', ['imag']), ('conjugate', ['conj']) -] -for data in elementary_functions_names: - upper_case_alternatives = [data[0].upper()] - for alternative in data[1]: - if alternative.upper() not in upper_case_alternatives: - upper_case_alternatives.append(alternative.upper()) - data[1].extend(upper_case_alternatives) - -special_symbols_names = [ - ("Alpha", ["Α", "𝚨", "𝛢", "𝜜", "𝝖", "𝞐"]), - ("alpha", ["α", "𝛂", "𝛼", "𝜶", "𝝰", "𝞪"]), - ("Beta", ["Β", "𝚩", "𝛣", "𝜝", "𝝗", "𝞑"]), - ("beta", ["β", "ϐ", "𝛃", "𝛽", "𝜷", "𝝱", "𝞫"]), - ("Gamma", ["Γ", "𝚪", "𝛤", "𝜞", "𝝘", "𝞒"]), - ("gamma", ["γ", "𝛄", "𝛾", "𝜸", "𝝲", "𝞬"]), - ("Delta", ["Δ", "𝚫", "𝛥", "𝜟", "𝝙", "𝞓"]), - ("delta", ["δ", "𝛅", "𝛿", "𝜹", "𝝳", "𝞭"]), - ("Epsilon", ["Ε", "𝚬", "𝛦", "𝜠", "𝝚", "𝞔"]), - ("epsilon", ["ε", "ϵ", "𝛆", "𝜀", "𝜺", "𝝴", "𝞊", "𝞮"]), - ("Zeta", ["Ζ", "𝚭", "𝛧", "𝜡", "𝝛", "𝞕"]), - ("zeta", ["ζ", "𝛇", "𝜁", "𝜻", "𝝵", "𝞯"]), - ("Eta", ["Η", "𝚮", "𝛨", "𝜢", "𝝜", "𝞖"]), - ("eta", ["η", "𝛈", "𝜂", "𝜼", "𝝶", "𝞰"]), - ("Theta", ["Θ", "ϴ", "𝚯", "𝛩", "𝜣", "𝝝", "𝞗"]), - ("theta", ["θ", "ϑ", "𝛉", "𝜃", "𝜗", "𝜽", "𝝑", "𝝷", "𝞋", "𝞱"]), - ("Iota", ["Ι", "𝚰", "𝛪", "𝜤", "𝝞", "𝞘"]), - ("iota", ["ι", "𝛊", "𝜄", "𝜾", "𝝸", "𝞲"]), - ("Kappa", ["Κ", "𝚱", "𝛫", "𝜥", "𝝟", "𝞙"]), - ("kappa", ["κ", "ϰ", "𝛋", "𝜅", "𝜘", "𝜿", "𝝒", "𝝹", "𝞌", "𝞳"]), - ("Lambda", ["Λ", "𝚲", "𝛬", "𝜦", "𝝠", "𝞚"]), - ("lamda", ["λ", "𝛌", "𝜆", "𝝀", "𝝺", "𝞴"]), # lambda mispelt here to minimise conflict with Python in-built - ("Mu", ["Μ", "𝚳", "𝛭", "𝜧", "𝝡", "𝞛"]), - ("mu", ["μ", "µ", "𝛍", "𝜇", "𝝁", "𝝻", "𝞵"]), - ("Nu", ["Ν", "𝚴", "𝛮", "𝜨", "𝝢", "𝞜"]), - ("nu", ["ν", "𝛎", "𝜈", "𝝂", "𝝼", "𝞶"]), - ("Xi", ["Ξ", "𝚵", "𝛯", "𝜩", "𝝣", "𝞝"]), - ("xi", ["ξ", "𝛏", "𝜉", "𝝃", "𝝽", "𝞷"]), - ("Omicron", ["Ο", "𝚶", "𝛰", "𝜪", "𝝤", "𝞞"]), - ("omicron", ["ο", "𝛐", "𝜊", "𝝄", "𝝾", "𝞸"]), - ("Pi", ["Π", "𝚷", "𝛱", "𝜫", "𝝥", "𝞟"]), - ("pi", ["π", "ϖ", "𝛑", "𝜋", "𝝅", "𝝿", "𝞹"]), - ("Rho", ["Ρ", "𝚸", "𝛲", "𝜬", "𝝦", "𝞠"]), - ("rho", ["ρ", "ϱ", "𝛒", "𝜌", "𝝆", "𝞀", "𝞺"]), - ("Sigma", ["Σ", "𝚺", "𝛴", "𝜮", "𝝨", "𝞢"]), - ("sigma", ["σ", "ς", "𝛔", "𝜎", "𝝈", "𝞂", "𝞼"]), - ("Tau", ["Τ", "𝚻", "𝛵", "𝜯", "𝝩", "𝞣"]), - ("tau", ["τ", "𝛕", "𝜏", "𝝉", "𝞃", "𝞽"]), - ("Upsilon", ["Υ", "𝚼", "𝛶", "𝜰", "𝝪", "𝞤"]), - ("upsilon", ["υ", "𝛖", "𝜐", "𝝊", "𝞄", "𝞾"]), - ("Phi", ["Φ", "𝚽", "𝛷", "𝜱", "𝝫", "𝞥"]), - ("phi", ["φ", "ϕ", "𝛗", "𝜑", "𝜙", "𝝋", "𝝓", "𝞅", "𝞍", "𝞿", "𝟇"]), - ("Chi", ["Χ", "𝚾", "𝛸", "𝜲", "𝝬", "𝞦"]), - ("chi", ["χ", "𝛘", "𝜒", "𝝌", "𝞆", "𝟀"]), - ("Psi", ["Ψ", "𝚿", "𝛹", "𝜳", "𝝭", "𝞧"]), - ("psi", ["ψ", "𝛙", "𝜓", "𝝍", "𝞇", "𝟁"]), - ("Omega", ["Ω", "𝛀", "𝛺", "𝜴", "𝝮", "𝞨"]), - ("omega", ["ω", "𝛚", "𝜔", "𝝎", "𝞈", "𝟂"]) -] - - - -# -------- String Manipulation Utilities -def is_multiple_answers_wrapper(expr): - """ - True if expr, as a whole, is wrapped in a top-level {...} pair that - create_expression_set will treat as a set of multiple acceptable - answers, as opposed to {} that merely sit at the string's edges - incidentally (e.g. "{x+1}*{x-2}"). - """ - stripped = expr.strip() - if not (stripped.startswith('{') and stripped.endswith('}')): - return False - return find_matching_parenthesis(stripped, 0, delimiters=('{', '}')) == len(stripped) - 1 - - -def create_expression_set(exprs, params): - if isinstance(exprs, str): - if is_multiple_answers_wrapper(exprs): - exprs = [expr.strip() for expr in exprs[1:-1].split(',')] - else: - exprs = [exprs] - expr_set = set() - for expr in exprs: - expr = substitute_input_symbols(expr, params)[0] - if "plus_minus" in params.keys(): - expr = expr.replace(params["plus_minus"], "plus_minus") - - if "minus_plus" in params.keys(): - expr = expr.replace(params["minus_plus"], "minus_plus") - - if ("plus_minus" in expr) or ("minus_plus" in expr): - for pm_mp_ops in [("+", "-"), ("-", "+")]: - expr_string = expr.replace("plus_minus", pm_mp_ops[0]).replace("minus_plus", pm_mp_ops[1]).strip() - while expr_string[0] == "+": - expr_string = expr_string[1:] - expr_set.add(expr_string.strip()) - else: - expr_set.add(expr) - - return list(expr_set) - - -def convert_bracket_notation(expr): - """ - Accept [] and {} as other ways of writing (), SymPy only accepts () - for grouping since [], {}, and () are otherwise reserved for lists, - sets, and tuples respectively. {} is left untouched when it spans the - entire expression, since that denotes a set of multiple acceptable - answers (see create_expression_set). - - Brackets must be closed with the matching type, e.g. "[x+y)" is - rejected rather than silently reinterpreted as "(x+y)", since this - project does not support interval/limit notation. If the brackets in - expr are mismatched, expr is returned unconverted together with - feedback describing the problem. - """ - if not has_matching_brackets(expr): - tag = "BRACKET_NOTATION_MISMATCH" - return expr, (tag, feedback_string_generators["INTERNAL"](tag)({'x': expr})) - expr = expr.replace("[", "(").replace("]", ")") - if is_multiple_answers_wrapper(expr): - return expr, None - return expr.replace("{", "(").replace("}", ")"), None - - -def convert_absolute_notation(expr, name): - """ - Accept || as another form of writing modulus of an expression. - Function makes the input parseable by SymPy, SymPy only accepts Abs() - REMARK: this function cannot handle nested ||. It will attempt to pair - each | with the closest | to the right and return a string with a warning - if it detects an ambiguity. - - Parameters - ---------- - expr : string - Expression to convert, might have || - - Returns - ------- - expr : string - converted response input - """ - - # positions of the || values - n_expr = expr.count('|') - if n_expr == 2: - expr = list(expr) - expr[expr.index("|")] = "Abs(" - expr[expr.index("|")] = ")" - expr = "".join(expr) - elif n_expr > 0: - expr_start_abs_pos = [] - expr_end_abs_pos = [] - expr_ambiguous_abs_pos = [] - - if expr[0] == "|": - expr_start_abs_pos.append(0) - for i in range(1, len(expr)-1): - if expr[i] == "|": - if (expr[i-1].isalnum() or expr[i-1] in "()[]{}") and not (expr[i+1].isalnum() or expr[i+1] in "()[]{}"): - expr_end_abs_pos.append(i) - elif (expr[i+1].isalnum() or expr[i+1] in "()[]{}") and not (expr[i-1].isalnum() or expr[i-1] in "()[]{}"): - expr_start_abs_pos.append(i) - else: - expr_ambiguous_abs_pos.append(i) - if expr[-1] == "|": - expr_end_abs_pos.append(len(expr)-1) - expr = list(expr) - for i in expr_start_abs_pos: - expr[i] = "Abs(" - for i in expr_end_abs_pos: - expr[i] = ")" - k = 0 - prev_ambiguous = -1 - for i in expr_ambiguous_abs_pos: - prev_start = -1 - for j in expr_start_abs_pos: - if j < i: - prev_start = j - else: - break - prev_end = -1 - for j in expr_end_abs_pos: - if j < i: - prev_end = j - else: - break - if max(prev_start, prev_end, prev_ambiguous) == prev_end: - if expr[i-1].isalnum(): - expr[i] = "*Abs(" - else: - expr[i] = "Abs(" - elif max(prev_start, prev_end, prev_ambiguous) == prev_ambiguous: - if k % 2 == 0: - if expr[i-1].isalnum(): - expr[i] = "*Abs(" - else: - expr[i] = "Abs(" - else: - expr[i] = ")" - k += 1 - else: - expr[i] = ")" - prev_ambiguous = i - expr = "".join(expr) - - ambiguity_tag = "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - remark = "" - if n_expr > 2 and len(expr_ambiguous_abs_pos) > 0: - remark = feedback_string_generators["INTERNAL"](ambiguity_tag)({'name': name}) - - feedback = None - if len(remark) > 0: - feedback = (ambiguity_tag, remark) - - return expr, feedback - - -def SLR_implicit_multiplication_convention_parser(convention): - delimiters = [ - (("(", ")"), group(1)) - ] - - costum_tokens = [ - (" *(\*|\+|-| ) *", "SPLIT"), (" */ *", "SOLIDUS") - ] - - infix_operators = [] - costum_productions = [("E", "*E", group(2, empty=True)), ("E", "EE", group(2, empty=True))] - if convention == "equal_precedence": - costum_productions += [("E", "E/E", infix)] - elif convention == "implicit_higher_precedence": - costum_productions += [("E", "E/E", compose(infix, group(1, empty=True, delimiters=["(", ")"])))] - else: - raise Exception(f"Unknown convention {convention}") - - undefined = ("O", "OTHER") - expression_node = ("E", "EXPRESSION_NODE") - return SLR_expression_parser(delimiters=delimiters, infix_operators=infix_operators, undefined=undefined, expression_node=expression_node, costum_tokens=costum_tokens, costum_productions=costum_productions) - - -def preprocess_according_to_chosen_convention(expression, parameters): - convention = parameters.get("convention", None) - if convention is not None: - parser = SLR_implicit_multiplication_convention_parser(convention) - expression = parser.parse(parser.scan(expression))[0].content_string() - return expression - -def transform_unicode_greek_symbols(expr): - alias_substitutions = [] - for (name, alias_list) in special_symbols_names: - if name in expr: - alias_substitutions += [(name, " "+name+" ")] - for alias in alias_list: - if alias in expr: - alias_substitutions += [(alias, " "+name+" ")] - return alias_substitutions - -def convert_unicode_dashes(expr): - unicode_dashes = [ - "‐", # HYPHEN - "‑", # NON-BREAKING HYPHEN - "‒", # FIGURE DASH - "–", # EN DASH - "—", # EM DASH - "−", # MINUS SIGN - "﹣", # SMALL HYPHEN-MINUS - "-", # FULLWIDTH HYPHEN-MINUS - ] - return [(dash, "-") for dash in unicode_dashes if dash in expr] - - -def protect_elementary_functions_substitutions(expr): - alias_substitutions = [] - for (name, alias_list) in elementary_functions_names: - if name in expr: - alias_substitutions += [(name, " "+name+" ")] - for alias in alias_list: - if alias in expr: - alias_substitutions += [(alias, " "+name+" ")] - return alias_substitutions - - -def substitute_input_symbols(exprs, params): - ''' - Input: - exprs : a string or a list of strings - params : Evaluation function parameter dictionary - Output: - List of strings where alternatives for input symbols have been replaced with - their corresponsing input symbol code. - Remark: - Alternatives are sorted before substitution so that longer alternatives takes precedence. - ''' - if isinstance(exprs, str): - exprs = [exprs] - - substitutions = [(expr, expr) for expr in params.get("reserved_keywords", [])] - substitutions += [(expr, expr) for expr in params.get("unsplittable_symbols", [])] - - if "plus_minus" in params.keys(): - substitutions += [(params["plus_minus"], "plus_minus")] - - if "minus_plus" in params.keys(): - substitutions += [(params["minus_plus"], "minus_plus")] - - input_symbols = params.get("symbols",dict()) - - input_symbols_alternatives = [] - for (code, definition) in input_symbols.items(): - input_symbols_alternatives += definition["aliases"] - - if params.get("elementary_functions", False) is True: - alias_substitutions = [] - for expr in exprs: - for (name, alias_list) in elementary_functions_names+special_symbols_names: - if name in input_symbols_alternatives: - continue - else: - if (name in expr) and not (name in input_symbols_alternatives): - alias_substitutions += [(name, " "+name)] - for alias in alias_list: - if (alias in expr) and not (alias in input_symbols_alternatives): - alias_substitutions += [(alias, " "+name)] - substitutions += alias_substitutions - - if "symbols" in params.keys(): - # Removing invalid input symbols - input_symbols_to_remove = [] - aliases_to_remove = [] - for (code, symbol_data) in input_symbols.items(): - if len(code) == 0: - input_symbols_to_remove += [code] - else: - if len(code.strip()) == 0: - input_symbols_to_remove += [code] - else: - aliases = symbol_data["aliases"] - for i in range(0, len(aliases)): - if len(aliases[i]) > 0: - aliases[i].strip() - if len(aliases[i]) == 0: - aliases_to_remove += [(code, i)] - for (code, i) in aliases_to_remove: - del input_symbols[code]["aliases"][i] - for code in input_symbols_to_remove: - del input_symbols[code] - - # Since 'lambda' is a reserved keyword in python - # it needs to be replaced with 'lamda' for expression - # parsing to work properly - lambda_value = input_symbols.pop("lambda", {"latex": r"\lambda", "aliases": ["lambda"]}) - if lambda_value is not None: - lambda_value["aliases"].append("lambda") - input_symbols.update({"lamda": lambda_value}) - params.update({"symbols": input_symbols}) - - for (code, symbol_data) in input_symbols.items(): - substitutions.append((code, code)) - for alias in symbol_data["aliases"]: - if len(alias) > 0: - substitutions.append((alias, code)) - - # REMARK: This is to ensure capability with response areas that use the old formatting - # for input_symbols. Should be removed when all response areas are updated. - if "input_symbols" in params.keys(): - input_symbols = params["input_symbols"] - input_symbols_to_remove = [] - alternatives_to_remove = [] - for k in range(0, len(input_symbols)): - if len(input_symbols[k]) > 0: - input_symbols[k][0].strip() - if len(input_symbols[k][0]) == 0: - input_symbols_to_remove += [k] - else: - for i in range(0, len(input_symbols[k][1])): - if len(input_symbols[k][1][i]) > 0: - input_symbols[k][1][i].strip() - if len(input_symbols[k][1][i]) == 0: - alternatives_to_remove += [(k, i)] - for (k, i) in alternatives_to_remove: - del input_symbols[k][1][i] - for k in input_symbols_to_remove: - del input_symbols[k] - for input_symbol in params["input_symbols"]: - substitutions.append((input_symbol[0], input_symbol[0])) - for alternative in input_symbol[1]: - if len(alternative) > 0: - substitutions.append((alternative, input_symbol[0])) - - # Since 'lambda' is a reserved keyword in python - # we need to make sure it is not substituted back in - substitutions = [(original, subs.replace("lambda", "lamda")) for (original, subs) in substitutions] - - # Since 'as' is a reserved keyword in python, we add a subsitution of 'as' to 'a*s' if 'as' is not a defined symbol - if 'as' not in input_symbols: - substitutions += [('as', 'a*s')] - - substitutions = list(set(substitutions)) - if len(substitutions) > 0: - substitutions.sort(key=substitutions_sort_key) - for k in range(0, len(exprs)): - exprs[k] = substitute(exprs[k], substitutions) - exprs[k] = " ".join(exprs[k].split()) - - return exprs - - -def has_matching_brackets(expr): - """ - True if every occurrence of (, [, { in expr is closed by the closing - bracket of the same type, correctly nested. Mismatched types (e.g. the - "[x+y)" in interval/limit notation) or unbalanced brackets are rejected. - """ - closing_to_opening = {')': '(', ']': '[', '}': '{'} - stack = [] - for char in expr: - if char in '([{': - stack.append(char) - elif char in ')]}': - if not stack or stack.pop() != closing_to_opening[char]: - return False - return not stack - - -def find_matching_parenthesis(string, index, delimiters=None): - depth = 0 - if delimiters is None: - delimiters = ('(', ')') - for k in range(index, len(string)): - if string[k] == delimiters[0]: - depth += 1 - continue - if string[k] == delimiters[1]: - depth += -1 - if depth == 0: - return k - return -1 - - -def substitute(string, substitutions): - ''' - Input: - string (required) : a string or a list of strings - substitutions (required) : a list with elements of the form (string,string) - or ((string,list of strings),string) - Output: - A string that is the input string where any occurence of the left element - of each pair in substitutions have been replaced with the corresponding right element. - If the first element in the substitution is of the form (string,list of strings) then - the substitution will only happen if the first element followed by one of the strings - in the list in the second element. - Remarks: - Substitutions are made in the input order but if a substitutions left element is a - substring of a preceding substitutions right element there will be no substitution. - In most cases it is good practice to sort the substitutions by the length of the left - element in descending order. - Examples: - substitute("abc bc c", [("abc","p"), ("bc","q"), ("c","r")]) - returns: "p q r" - substitute("abc bc c", [("c","r"), ("bc","q"), ("abc","p")]) - returns: "abr br r" - substitute("p bc c", [("p","abc"), ("bc","q"), ("c","r")]) - returns: "abc q c" - substitute("p bc c", [("c","r"), ("bc","q"), ("p","abc")]) - returns: "abc br r" - ''' - if isinstance(string, str): - string = [string] - - # Perform substitutions - new_string = [] - for part in string: - if not isinstance(part, str): - new_string.append(part) - else: - index = 0 - string_buffer = "" - while index < len(part): - matched_start = False - for k, pair in enumerate(substitutions): - if isinstance(pair[0], tuple): - match = False - for look_ahead in pair[0][1]: - if part.startswith(pair[0][0]+look_ahead, index): - match = True - break - substitution_length = len(pair[0][0]) - else: - match = part.startswith(pair[0], index) - substitution_length = len(pair[0]) - if match: - matched_start = True - if len(string_buffer) > 0: - new_string.append(string_buffer) - string_buffer = "" - new_string.append(k) - index += substitution_length - break - if not matched_start: - string_buffer += part[index] - index += 1 - if len(string_buffer) > 0: - new_string.append(string_buffer) - - for k, elem in enumerate(new_string): - if isinstance(elem, int): - new_string[k] = substitutions[elem][1] - - return "".join(new_string) - - -def compute_relative_tolerance_from_significant_decimals(string): - rtol = None - string = string.strip() - if re.fullmatch(is_number_regex, string) is None: - rtol = 0 - else: - if "e" in string.casefold(): - string = "".join(string.split()) - separators = "e*^ " - separator_indices = [] - for separator in separators: - if separator in string: - separator_indices.append(string.index(separator)) - else: - separator_indices.append(len(string)) - index = min(separator_indices) - significant_characters = string[0:index].replace(".", "") - significant_characters = significant_characters.lstrip("-0") - rtol = 5*10**(-max(len(significant_characters), DEFAULT_SIGNIFICANT_FIGURES)) - return rtol - - -# -------- (Sympy) Expression Parsing Utilities -class SymbolData(TypedDict): - latex: str - aliases: List[str] - - -SymbolDict = Dict[str, SymbolData] - -symbol_latex_re = re.compile( - r"(?P\\\(|\$\$|\$)(?P.*?)(?P\\\)|\$\$|\$)" -) - - -def sympy_symbols(symbols): - """Create a mapping of local variables for parsing sympy expressions. - - Args: - symbols (SymbolDict): A dictionary of sympy symbol strings to LaTeX - symbol strings. - - Note: - Only the sympy string is used in this function. - - Returns: - Dict[str, Symbol]: A dictionary of sympy symbol strings to sympy - Symbol objects. - """ - return {k: Symbol(k) for k in symbols} - - -def extract_latex(symbol): - """Returns the latex portion of a symbol string. - - Note: - Only the first matched expression is returned. - - Args: - symbol (str): The string to extract latex from. - - Returns: - str: The latex string. - """ - if (match := symbol_latex_re.search(symbol)) is None: - return symbol - - return match.group("latex") - - -def latex_symbols(symbols): - """Create a mapping between custom Symbol objects and LaTeX symbol strings. - Used when parsing a sympy Expression to a LaTeX string. - - Args: - symbols (SymbolDict): A dictionary of sympy symbol strings to LaTeX - symbol strings. - - Returns: - Dict[Symbol, str]: A dictionary of sympy Symbol objects to LaTeX - strings. - """ - symbol_dict = { - Symbol(k): extract_latex(v["latex"]) - for (k, v) in symbols.items() - } - return symbol_dict - - -def sympy_to_latex(equation, symbols, settings=None): - default_settings = { - "symbol_names": latex_symbols(symbols), - "ln_notation": True, - } - if settings is None: - settings = default_settings - else: - for key in default_settings.keys(): - if key not in settings.keys(): - settings[key] = default_settings[key] - latex_out = ModifiedLatexPrinter(settings).doprint(equation) - return latex_out - - -def create_sympy_parsing_params(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): - ''' - Input: - params : evaluation function parameter dictionary - unsplittable_symbols : list of strings that will not be split when parsing - even if implicit multiplication is used. - Output: - parsing_params: A dictionary that contains necessary info for the - parse_expression function. - ''' - - unsplittable_symbols = list(unsplittable_symbols)+params.get("reserved_keywords", []) - if "symbols" in params.keys(): - for symbol in params["symbols"].keys(): - if len(symbol) > 1: - unsplittable_symbols.append(symbol) - - if params.get("specialFunctions", False) is True: - from sympy import beta, gamma, zeta, Lambda, Chi - else: - beta = Symbol("beta") - gamma = Symbol("gamma") - zeta = Symbol("zeta") - Lambda = Symbol("Lambda") - Chi = Symbol("Chi") - if params["complexNumbers"] is True: - from sympy import I - else: - I = Symbol("I") - if params["elementary_functions"] is True: - from sympy import E - else: - E = Symbol("E") - N = Symbol("N") - O = Symbol("O") - Q = Symbol("Q") - S = Symbol("S") - - symbol_dict = { - "beta": beta, - "gamma": gamma, - "zeta": zeta, - "Lambda": Lambda, - "Chi": Chi, - "I": I, - "N": N, - "O": O, - "Q": Q, - "S": S, - "E": E - } - - symbol_dict.update(sympy_symbols(unsplittable_symbols)) - - strict_syntax = params.get("strict_syntax", False) - - parsing_params = { - "unsplittable_symbols": unsplittable_symbols, - "strict_syntax": strict_syntax, - "symbol_dict": symbol_dict, - "extra_transformations": tuple(), - "elementary_functions": params["elementary_functions"], - "convention": params["convention"], - "simplify": params.get("simplify", False), - "rationalise": params.get("rationalise", True), - "constants": set(), - "complexNumbers": params["complexNumbers"], - "reserved_keywords": params.get("reserved_keywords",[]), - } - - symbol_assumptions = list(symbol_assumptions) - if "symbol_assumptions" in params.keys(): - symbol_assumptions_strings = params["symbol_assumptions"] - index = symbol_assumptions_strings.find("(") - while index > -1: - index_match = find_matching_parenthesis(symbol_assumptions_strings, index) - try: - symbol_assumption = eval(symbol_assumptions_strings[index+1:index_match]) - symbol_assumptions.append(symbol_assumption) - except (SyntaxError, TypeError) as e: - raise Exception("List of symbol assumptions not written correctly.") from e - index = symbol_assumptions_strings.find('(', index_match+1) - for symbol, assumption in symbol_assumptions: - try: - if assumption.lower() == "constant": - parsing_params["constants"] = parsing_params["constants"].union({symbol}) - if assumption.lower() == "function": - parsing_params["symbol_dict"].update({symbol: eval("Function('"+symbol+"')")}) - else: - parsing_params["symbol_dict"].update({symbol: eval("Symbol('"+symbol+"',"+assumption+"=True)")}) - except Exception as e: - raise Exception(f"Assumption {assumption} for symbol {symbol} caused a problem.") from e - - - return parsing_params - - -def substitutions_sort_key(x): - return -len(x[0])-len(x[1])/(10**(1+len(str(len(x[1]))))) - - -def preprocess_expression(name, expr, parameters): - expr = substitute_input_symbols(expr.strip(), parameters) - expr = expr[0] - bracket_feedback = None - if not parameters.get("strict_syntax", False): - expr, bracket_feedback = convert_bracket_notation(expr) - expr, abs_feedback = convert_absolute_notation(expr, name) - feedback = bracket_feedback or abs_feedback - success = feedback is None - return success, expr, feedback - -def parse_expression(expr_string, parsing_params): - ''' - Input: - expr_string : string to be parsed into a sympy expression - parsing_params : dictionary that contains parsing parameters - Output: - sympy expression created by parsing expr configured according - to the parameters in parsing_params - ''' - - expr_set = create_expression_set(expr_string, parsing_params) - - strict_syntax = parsing_params.get("strict_syntax", False) - extra_transformations = parsing_params.get("extra_transformations", ()) - unsplittable_symbols = parsing_params.get("unsplittable_symbols", ()) - symbol_dict = parsing_params.get("symbol_dict", {}) - separate_unsplittable_symbols = [(x, " " + x + " ") for x in unsplittable_symbols] - substitutions = separate_unsplittable_symbols - - parsed_expr_set = set() - - for expr in expr_set: - if not strict_syntax: - expr, _ = convert_bracket_notation(expr) - expr = preprocess_according_to_chosen_convention(expr, parsing_params) - - substitutions = list(set(substitutions)) - substitutions += transform_unicode_greek_symbols(expr) - substitutions.sort(key=substitutions_sort_key) - - if parsing_params["elementary_functions"] is True: - substitutions += protect_elementary_functions_substitutions(expr) - - substitutions = list(set(substitutions)) - substitutions.sort(key=substitutions_sort_key) - expr = substitute(expr, substitutions) - expr = " ".join(expr.split()) - can_split = lambda x: False if x in unsplittable_symbols else _token_splittable(x) - - if strict_syntax is True: - transformations = parser_transformations[0:4, 10] + extra_transformations - else: - transformations = (parser_transformations[0:5, 6, 10] + extra_transformations + - (split_symbols_custom(can_split),) + parser_transformations[8, 9]) - - if parsing_params.get("rationalise", False): - transformations += parser_transformations[11] - - - if "=" in expr: - expr_parts = expr.split("=") - lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) - rhs = parse_expr(expr_parts[1], transformations=transformations, local_dict=symbol_dict) - parsed_expr = Equality(lhs, rhs, evaluate=False) - elif parsing_params.get("simplify", False): - parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict) - if not isinstance(parsed_expr, Equality): - parsed_expr = parsed_expr.simplify() - else: - parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict, evaluate=False) - - if not isinstance(parsed_expr, Basic): - raise ValueError(f"Failed to parse Sympy expression `{expr}`") - - parsed_expr_set.add(parsed_expr) - - if len(expr_set) == 1: - return parsed_expr_set.pop() - else: - return parsed_expr_set diff --git a/app/utility/physical_quantity_utilities.py b/app/utility/physical_quantity_utilities.py deleted file mode 100644 index 1b73df9..0000000 --- a/app/utility/physical_quantity_utilities.py +++ /dev/null @@ -1,602 +0,0 @@ -import re -from enum import Enum -from .expression_utilities import ( - substitute, - create_sympy_parsing_params, - parse_expression -) -from .slr_parsing_utilities import ( - SLR_Parser, - relabel, - catch_undefined, - infix, - insert_infix, - group, - tag_removal, - create_node, - ExprNode -) -from .unit_system_conversions import\ - set_of_SI_prefixes, set_of_SI_base_unit_dimensions, set_of_derived_SI_units_in_SI_base_units,\ - set_of_common_units_in_SI, set_of_very_common_units_in_SI, set_of_imperial_units, conversion_to_base_si_units -from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators - -from ..preview_implementations.symbolic_preview import preview_function as symbolic_preview -from .preview_utilities import parse_latex - -QuantityTags = Enum("QuantityTags", {v: i for i, v in enumerate("UVNR", 1)}) - -units_sets_dictionary = { - "SI": set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units, - "common": set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI, - "imperial": set_of_imperial_units, -} - - -class PhysicalQuantity: - - def __init__(self, name, parameters, ast_root, parser, messages=None, tag_handler=lambda x: x): - self.name = name - self.parameters = parameters - prefixes = set(x[0] for x in set_of_SI_prefixes) - fundamental_units = set(x[0] for x in set_of_SI_base_unit_dimensions) - units_string = parameters.get("units_string", "SI common imperial") - valid_units = set() - for key in units_sets_dictionary.keys(): - if key in units_string: - for unit in units_sets_dictionary[key]: - valid_units = valid_units.union(set((unit[0],))) - dimensions = set(x[2] for x in set_of_SI_base_unit_dimensions) - unsplittable_symbols = list(prefixes | fundamental_units | valid_units | dimensions) - symbol_assumptions = tuple((f'{s}', 'positive') for s in unsplittable_symbols) - self.parsing_params = create_sympy_parsing_params( - parameters, - unsplittable_symbols=unsplittable_symbols, - symbol_assumptions=symbol_assumptions, - ) - if messages is None: - self.messages = [] - else: - self.messages = messages - self.ast_root = ast_root - self.parser = parser - self.tag_handler = tag_handler - self.value = None - self.unit = None - self._rotate_until_root_is_split() - if self.ast_root.label == "SPACE"\ - and QuantityTags.U not in self.ast_root.children[0].tags\ - and QuantityTags.U in self.ast_root.children[1].tags: - self.value = self.ast_root.children[0] - self.unit = self.ast_root.children[1] - elif QuantityTags.U in self.ast_root.tags: - self.unit = self.ast_root - else: - self.value = self.ast_root - if self.value is not None: - def revert_content(node): - if node.label != "GROUP": - node.content = node.original[node.start:node.end+1] - if node.label == "UNIT" or QuantityTags.U in node.tags: - self.messages += [ - ( - name+"_REVERTED_UNIT_"+str(len(self.messages)), - physical_quantity_feedback_string_generators["INTERNAL"]("REVERTED_UNIT")( - { - 'before': node.original[:node.start], - 'marked': node.content_string(), - 'after': node.original[node.end+1:] - } - ) - ) - ] - return ["", ""] - self.value.traverse(revert_content) - self.value_latex_string = self._value_latex(parameters) - self.unit_latex_string = None - if self.unit is not None: - self.unit_latex_string = "".join(self._unit_latex(self.unit)) - separator = "" - if self.value_latex_string is not None and self.unit_latex_string is not None: - separator = "~" - value_latex = self.value_latex_string if self.value_latex_string is not None else "" - unit_latex = self.unit_latex_string if self.unit_latex_string is not None else "" - self.latex_string = value_latex+separator+unit_latex - self.standard_value, self.standard_unit, self.expanded_unit, self.dimension, self.converted_unit_factor = self._all_forms() - return - - def _rotate(self, direction): - # right: direction = 1 - # left: direction = 0 - if direction not in {0, 1}: - raise Exception("Unknown direction: "+str(direction)) - old_root = self.ast_root - new_root = old_root.children[1-direction] - if len(new_root.children) == 1: - old_root.children = old_root.children[1-direction:len(old_root.children)-direction] - a = [] if direction == 0 else [old_root] - b = [old_root] if direction == 0 else [] - new_root.children = a+new_root.children+b - elif len(new_root.children) > 1: - switch = new_root.children[-direction] - old_root.children[1-direction] = switch - new_root.children[-direction] = old_root - else: - direction_string = "right" if direction == 1 else "left" - raise Exception("Cannot rotate "+direction_string+".") - old_root.tags = self.tag_handler(old_root) - new_root.tags = self.tag_handler(new_root) - self.ast_root = new_root - return - - def _rotate_right(self): - if len(self.ast_root.children) > 0: - self._rotate(1) - else: - raise Exception("Cannot rotate right.") - return - - def _rotate_left(self): - if len(self.ast_root.children) > 0: - self._rotate(0) - else: - raise Exception("Cannot rotate left.") - return - - def _rotate_until_root_is_split(self): - if self.ast_root.label == "SPACE": - if QuantityTags.U not in self.ast_root.tags and len(self.ast_root.children[1].children) > 0: - self._rotate_left() - self._rotate_until_root_is_split() - elif QuantityTags.U in self.ast_root.children[0].tags and len(self.ast_root.children[0].children) > 0: - self._rotate_right() - self._rotate_until_root_is_split() - return - - def _value_latex(self, parameters): - if self.value is not None: - preview_parameters = {**parameters} - prefixes = set(x[0] for x in set_of_SI_prefixes) - fundamental_units = set(x[0] for x in set_of_SI_base_unit_dimensions) - units_string = parameters.get("units_string", "SI common imperial") - valid_units = set() - for key in units_sets_dictionary.keys(): - if key in units_string: - for unit in units_sets_dictionary[key]: - valid_units = valid_units.union(set((unit[0], unit[1])+unit[3]+unit[4])) - dimensions = set(x[2] for x in set_of_SI_base_unit_dimensions) - unsplittable_symbols = list(prefixes | fundamental_units | valid_units | dimensions) - preview_parameters.update({"reserved_keywords": preview_parameters.get("reserved_keywords", [])+unsplittable_symbols}) - if "rtol" not in preview_parameters.keys(): - preview_parameters.update({"rtol": 1e-12}) - original_string = self.value.original_string() - value = symbolic_preview(original_string, preview_parameters) - value_latex = value["preview"]["latex"] - return value_latex - return None - - def _unit_latex(self, node): - # TODO: skip unnecessary parenthesis (i.e. check for GROUP children for powers and fraction and inside groups) - content = node.content - children = node.children - if node.label == "PRODUCT": - return self._unit_latex(children[0])+["\\cdot"]+self._unit_latex(children[1]) - elif node.label == "NUMBER": - return [content] - elif node.label == "SPACE": - return self._unit_latex(children[0])+["~"]+self._unit_latex(children[1]) - elif node.label == "UNIT": - return ["\\mathrm{"]+[content]+["}"] - elif node.label == "GROUP": - out = [content[0]] - for child in children: - out += self._unit_latex(child) - return out+[content[1]] - elif node.label == "POWER": - return self._unit_latex(children[0])+["^{"]+self._unit_latex(children[1])+["}"] - elif node.label == "SOLIDUS": - return ["\\frac{"]+self._unit_latex(children[0])+["}{"]+self._unit_latex(children[1])+["}"] - else: - return [content] - - def _expand_units(self, node): - if node.label == "UNIT" and len(node.children) == 0 and node.content not in [x[0] for x in set_of_SI_base_unit_dimensions]: - expanded_unit_content = conversion_to_base_si_units[node.content] - node = self.parser.parse(self.parser.scan(expanded_unit_content))[0] - for k, child in enumerate(node.children): - node.children[k] = self._expand_units(child) - return node - - def _all_forms(self): - parsing_params = self.parsing_params - converted_value = self.value.content_string() if self.value is not None else None - - if converted_value is not None and self.parameters.get("is_latex", False): - symbols = self.parameters.get("symbols", {}) - converted_value = parse_latex(converted_value, symbols, self.parameters.get("simplify", False)) - converted_unit = None - expanded_unit = None - converted_dimension = parse_expression("1", parsing_params) - converted_unit_factor = parse_expression("1", parsing_params) - if self.unit is not None: - converted_unit = self.unit.copy() - expanded_unit = self._expand_units(converted_unit) - converted_unit_string = expanded_unit.content_string() - try: - expanded_unit = parse_expression(converted_unit_string, parsing_params) - converted_unit = expanded_unit - except Exception as e: - raise Exception("SymPy was unable to parse the "+self.name+" unit") from e - base_unit_dimensions = [(base_unit[0], base_unit[2]) for base_unit in set_of_SI_base_unit_dimensions] - if self.value is not None: - substitution_dict = {symbol: 1 for symbol in converted_unit.free_symbols if str(symbol) in [x[0] for x in set_of_SI_base_unit_dimensions]} - converted_unit_factor = converted_unit.subs(substitution_dict).simplify() - converted_unit = (converted_unit/converted_unit_factor).simplify(rational=True) - converted_value = "("+str(converted_value)+")*("+str(converted_unit_factor)+")" - converted_dimension = substitute(converted_unit_string, base_unit_dimensions) - converted_dimension = parse_expression(converted_dimension, parsing_params) - if converted_value is not None: - converted_value = parse_expression(converted_value, parsing_params) - return converted_value, converted_unit, expanded_unit, converted_dimension, converted_unit_factor - - -def SLR_generate_unit_dictionaries(units_string, strictness): - - if strictness == "legacy": - strictness = "natural" - - units_tuples = set() - for key in units_sets_dictionary.keys(): - if key in units_string: - units_tuples |= units_sets_dictionary[key] - - units_end = dict() - if strictness == "strict": - units = {x[0]: x[0] for x in units_tuples} - units_short_to_long = {x[1]: x[0] for x in units_tuples} - units_long_to_short = {x[0]: x[1] for x in units_tuples} - elif strictness == "natural": - units = {x[0]: x[0] for x in units_tuples} - for unit in units_tuples: - units.update({x: unit[0] for x in unit[3]}) - units_end.update({x: unit[0] for x in unit[4]}) - units_short_to_long = {x[1]: x[0] for x in units_tuples} - units_long_to_short = {x[0]: x[1] for x in units_tuples} - for unit in units_tuples: - units_long_to_short.update({x: unit[1] for x in unit[3]}) - units_long_to_short.update({x: unit[1] for x in unit[4]}) - - prefixes = {x[0]: x[0] for x in set_of_SI_prefixes} - prefixes_long_to_short = {x[0]: x[1] for x in set_of_SI_prefixes} - prefixed_units = {**units} - for unit in units.keys(): - for prefix in prefixes.keys(): - prefixed_units.update({prefix+unit: prefix+units[unit]}) - # If prefixed short form overlaps with short form for other unit, do not include prefixed form - if prefixes_long_to_short[prefix]+units_long_to_short[unit] not in units_short_to_long.keys(): - prefixed_units.update( - { - prefixes_long_to_short[prefix]+units_long_to_short[unit]: prefix+units[unit] - } - ) - if prefix + units_long_to_short[unit] not in units_short_to_long.keys(): - prefixed_units.update( - { - prefix + units_long_to_short[unit]: prefix + units[unit] - } - ) - - - prefixed_units_end = {**units_end} - for unit in units_end.keys(): - for prefix in prefixes.keys(): - prefixed_units_end.update( - { - prefix+unit: prefix+units_end[unit], - } - ) - - return {**units, **units_short_to_long}, prefixed_units, units_end, prefixed_units_end - - -def set_tags(strictness): - def tag_handler(node): - tags = set() - for child in node.children: - tags = tags.union(child.tags) - if node.label == "UNIT": - tags.add(QuantityTags.U) - elif node.label == "NUMBER": - tags.add(QuantityTags.N) - elif node.label == "NON-UNIT": - tags.add(QuantityTags.V) - elif node.label == "POWER" and QuantityTags.U in node.children[0].tags and node.children[1].tags == {QuantityTags.N}: - tags.remove(QuantityTags.N) - elif node.label == "SOLIDUS" and node.children[0].content == "1" and node.children[1].tags == {QuantityTags.U}: - tags.remove(QuantityTags.N) - elif node.label == "SOLIDUS" and node.children[0].tags == {QuantityTags.N} and node.children[1].tags == {QuantityTags.N}: - tags = tags # Do not change tags - elif node.label in ["PRODUCT", "SOLIDUS", "POWER"]: - if any(x in tags for x in [QuantityTags.N, QuantityTags.V, QuantityTags.R]): - if QuantityTags.U in tags: - tags.remove(QuantityTags.U) - if QuantityTags.N in tags: - tags.add(QuantityTags.V) - elif node.label in "SPACE" and QuantityTags.V in node.children[1].tags: - if QuantityTags.U in tags: - tags.remove(QuantityTags.U) - elif node.label == "GROUP" and len(node.content[0]+node.content[1]) == 0: - if strictness == "strict": - for (k, child) in enumerate(node.children): - node.children[k] = tag_removal(child, QuantityTags.U) - if QuantityTags.U in tags: - tags.remove(QuantityTags.U) - tags.add(QuantityTags.R) - elif strictness == "natural": - for child in node.children: - if QuantityTags.V in child.tags and QuantityTags.U in tags: - tags.remove(QuantityTags.U) - break - return tags - return tag_handler - - -def SLR_quantity_parser(parameters): - units_string = parameters.get("units_string", "SI common imperial") - strictness = parameters.get("strictness", "natural") - if strictness == "legacy": - strictness = "natural" - units_dictionary, prefixed_units_dictionary, units_end_dictionary, prefixed_units_end_dictionary = \ - SLR_generate_unit_dictionaries(units_string, strictness) - max_unit_name_length = max(len(x) for x in [units_dictionary.keys()]+[units_end_dictionary.keys()]) - - if strictness == "strict": - units_dictionary.update(prefixed_units_dictionary) - - def starts_with_unit(string): - token = None - unit = None - for k in range(max_unit_name_length, -1, -1): - unit = units_dictionary.get(string[0:k+1], None) - if unit is not None: - token = string[0:k+1] - break - return token, unit - elif strictness == "natural": - chars_in_keys = set() - for key in { - **units_dictionary, - **prefixed_units_dictionary, - **prefixed_units_end_dictionary, - **units_end_dictionary - }.keys(): - for c in key: - chars_in_keys.add(c) - - def starts_with_unit(string): - units_end = {**prefixed_units_end_dictionary, **units_end_dictionary} - units = {**prefixed_units_dictionary, **units_dictionary} - token = None - unit = None - end_point = len(string) - for k, c in enumerate(string): - if c not in chars_in_keys: - end_point = k - break - if end_point > 0: - local_string = string[0:end_point] - # Check if string is end unit alternative - unit = units_end.get(local_string, None) - if unit is not None: - token = local_string - else: - # Check if string starts with unit - for k in range(len(local_string), -1, -1): - unit = units.get(local_string[0:k], None) - if unit is not None: - token = local_string[0:k] - break - return token, unit - - def starts_with_number(string): - match_content = re.match('^-?(0|[1-9]\d*)?(\.\d+)?(?<=\d)(e-?(0|[1-9]\d*))?', string) - match_content = match_content.group() if match_content is not None else None - return match_content, match_content - - start_symbol = "START" - end_symbol = "END" - null_symbol = "NULL" - - token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" +", "SPACE"), - (" *\* *", "PRODUCT"), - (" */ *", "SOLIDUS"), - (" *\^ *", "POWER"), - (" *\*\* *", "POWER"), - ("\( *", "START_DELIMITER"), - (" *\)", "END_DELIMITER"), - ("N", "NUMBER", starts_with_number), - ("U", "UNIT", starts_with_unit), - ("V", "NON-UNIT", catch_undefined), - ("Q", "QUANTITY_NODE", None), - ] - - if strictness == "strict": - juxtaposition = group(2, empty=True) - elif strictness == "natural": - def juxtaposition_natural(production, output, tag_handler): - is_unit = [False, False] - for k, elem in enumerate(output[-2:], -2): - if isinstance(elem, ExprNode): - is_unit[k] = QuantityTags.U in elem.tags and elem.label != "GROUP" - is_unit[k] = is_unit[k] or (elem.tags == {QuantityTags.U} and elem.label == "GROUP") - else: - is_unit[k] = elem.label == "UNIT" - if all(is_unit): - return insert_infix(" ", "SPACE")(production, output, tag_handler) - else: - for k, elem in enumerate(output[-2:], -2): - if is_unit[k] is True: - elem.tags.add(QuantityTags.V) - return group(2, empty=True)(production, output, tag_handler) - juxtaposition = juxtaposition_natural - - productions = [(start_symbol, "Q", relabel)] - productions += [("Q", "Q"+x+"Q", infix) for x in list(" */")] - productions += [("Q", "QQ", juxtaposition)] - productions += [("Q", "Q^Q", infix)] - productions += [("Q", "(Q)", group(1))] - productions += [("Q", "U", create_node)] - productions += [("Q", "N", create_node)] - productions += [("Q", "V", create_node)] - - def error_action_null(p, s, a, i, t, o): - raise Exception("Parser reached impossible state, no 'NULL' token should exists in token list.") - - def error_action_start(p, s, a, i, t, o): - raise Exception("Parser reached impossible state, 'START' should only be found once in token list.") - - def error_condition_incomplete_expression(items_token, next_symbol): - if next_symbol.label == "END": - return True - else: - return False - - def error_action_incomplete_expression(p, s, a, i, t, o): - raise Exception("Input ended before expression was completed.") - - def error_condition_infix_missing_argument(items_token, next_symbol): - if next_symbol.label in ["PRODUCT", "SOLIDUS", "POWER"]: - return True - else: - return False - - def error_action_infix_missing_argument(p, s, a, i, t, o): - raise Exception("Infix operator requires an argument on either side.") - - error_handler = [ - (lambda items_token, next_symbol: next_symbol.label == "NULL", error_action_null), - (lambda items_token, next_symbol: next_symbol.label == "START", error_action_start), - (error_condition_incomplete_expression, error_action_incomplete_expression), - (error_condition_infix_missing_argument, error_action_infix_missing_argument), - ] - - parser = SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol, tag_handler=set_tags(strictness), error_handler=error_handler) - return parser - - -def SLR_quantity_parsing(expr, parameters, parser, name): - expr = expr.strip() - tokens = parser.scan(expr) - - quantity = parser.parse(tokens, verbose=False) - - if len(quantity) > 1: - raise Exception("Parsed quantity does not have a single root.") - - tag_handler = set_tags(parameters.get("strictness", "strict")) - return PhysicalQuantity(name, parameters, quantity[0], parser, messages=[], tag_handler=tag_handler) - -def expression_preprocess(name, expr, parameters): - if parameters.get("strictness", "natural") == "legacy": - expr = preprocess_legacy(expr, parameters) - return True, expr, None - - expr = transform_prefixes_to_standard(expr) - - return True, expr, None - -def preprocess_legacy(expr, parameters): - prefix_data = {(p[0], p[1], tuple(), p[3]) for p in set_of_SI_prefixes} - prefixes = [] - for prefix in prefix_data: - prefixes = prefixes + [prefix[0]] + list(prefix[-1]) - prefix_short_forms = [prefix[1] for prefix in prefix_data] - unit_data = set_of_SI_base_unit_dimensions \ - | set_of_derived_SI_units_in_SI_base_units \ - | set_of_common_units_in_SI \ - | set_of_very_common_units_in_SI \ - | set_of_imperial_units - unit_long_forms = prefixes - for unit in unit_data: - unit_long_forms = unit_long_forms + [unit[0]] + list(unit[-2]) + list(unit[-1]) - unit_long_forms = "(" + "|".join(unit_long_forms) + ")" - # Rewrite any expression on the form "*UNIT" (but not "**UNIT") as " UNIT" - # Example: "newton*metre" ---> "newton metre" - search_string = r"(? "kilometre" - search_string = prefixes + " " + unit_long_forms - match_content = re.search(search_string, expr) - while match_content is not None: - expr = expr[0:match_content.span()[0]] + " " + "".join(match_content.group().split()) + expr[ - match_content.span()[ - 1]:] - match_content = re.search(search_string, expr) - unit_short_forms = [u[1] for u in unit_data] - short_forms = "(" + "|".join(list(set(prefix_short_forms + unit_short_forms))) + ")" - # Add space before short forms of prefixes or unit names if they are preceded by numbers or multiplication - # Example: "100Pa" ---> "100 Pa" - search_string = r"[0-9\*\(\)]" + short_forms - match_content = re.search(search_string, expr) - while match_content is not None: - expr = expr[0:match_content.span()[0] + 1] + " " + expr[match_content.span()[0] + 1:] - match_content = re.search(search_string, expr) - # Remove space after prefix short forms if they are preceded by numbers, multiplication or space - # Example: "100 m Pa" ---> "100 mPa" - prefix_short_forms = "(" + "|".join(prefix_short_forms) + ")" - search_string = r"[0-9\*\(\) ]" + prefix_short_forms + " " - match_content = re.search(search_string, expr) - while match_content is not None: - expr = expr[0:match_content.span()[0] + 1] + match_content.group()[0:-1] + expr[match_content.span()[1]:] - match_content = re.search(search_string, expr) - # Remove multiplication and space after prefix short forms if they are preceded by numbers, multiplication or space - # Example: "100 m* Pa" ---> "100 mPa" - search_string = r"[0-9\*\(\) ]" + prefix_short_forms + "\* " - match_content = re.search(search_string, expr) - while match_content is not None: - expr = expr[0:match_content.span()[0] + 1] + match_content.group()[0:-2] + expr[match_content.span()[1]:] - match_content = re.search(search_string, expr) - # Replace multiplication followed by space before unit short forms with only spaces if they are preceded by numbers or space - # Example: "100* Pa" ---> "100 Pa" - unit_short_forms = "(" + "|".join(unit_short_forms) + ")" - search_string = r"[0-9\(\) ]\* " + unit_short_forms - match_content = re.search(search_string, expr) - while match_content is not None: - expr = expr[0:match_content.span()[0]] + match_content.group().replace("*", " ") + expr[ - match_content.span()[1]:] - match_content = re.search(search_string, expr) - - return expr - -def transform_prefixes_to_standard(expr): - """ - Transform ONLY alternative prefix spellings to standard prefix names. - Ensure there's exactly one space after the prefix before the unit. - Works for both attached (e.g. 'km') and spaced (e.g. 'k m') forms. - """ - - for prefix_name, symbol, power, alternatives in set_of_SI_prefixes: - for alt in alternatives: - if not alt: - continue - - # Match the alternative prefix either attached to or followed by spaces before a unit - # Examples matched: "km", "k m", "microsecond", "micro second" - pattern = rf'(? tuple: - """ - Replace all symbols starting with 'E' or 'e' with placeholders, - so latex2sympy does not interpret 'E' as Euler's number. - Returns the modified string and a dict mapping replaced chars to their placeholders. - """ - replacements = {} - - # Find placeholder for uppercase E if needed - if re.search(r'(? original char - placeholder_to_char = {v: k for k, v in replacements.items()} - - subs = {} - for s in expr.free_symbols: - name = str(s) - # Check if this symbol starts with any of our placeholders - for placeholder, original_char in placeholder_to_char.items(): - if name.startswith(placeholder): - new_name = original_char + name[len(placeholder):] - subs[s] = Symbol(new_name) - break # Only replace once per symbol - - return expr.xreplace(subs) - - - -def parse_latex(response: str, symbols: SymbolDict, simplify: bool, parameters=None) -> str: - """Parse a LaTeX string to a sympy string while preserving custom symbols. - - Args: - response (str): The LaTeX expression to parse. - symbols (SymbolDict): A mapping of sympy symbol strings and LaTeX - symbol strings. - simplify (bool): If set to false the preview will attempt to preserve - the way that the response was written as much as possible. If set - to True the response will be simplified before the preview string - is generated. - parameters (dict): parameters used when generating sympy output when - the response is written in LaTeX - - Raises: - ValueError: If the LaTeX string or symbol couldn't be parsed. - - Returns: - str: The expression in sympy syntax. - """ - if parameters is not None: - for (key, value) in default_parameters.items(): - if key not in parameters.keys(): - parameters.update({key: value}) - else: - parameters = deepcopy(default_parameters) - - substitutions = {} - - pm_placeholder = None - mp_placeholder = None - - if r"\pm " in response or r"\mp " in response: - response_set = set() - for char in 'abcdefghjkoqrtvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ': - if char not in response and pm_placeholder is None: - pm_placeholder = char - substitutions[pm_placeholder] = Symbol(pm_placeholder, commutative=False) - elif char not in response and mp_placeholder is None: - mp_placeholder = char - substitutions[mp_placeholder] = Symbol(mp_placeholder, commutative=False) - if pm_placeholder is not None and mp_placeholder is not None: - break - for expr in create_expression_set(response.replace(r"\pm ", 'plus_minus').replace(r"\mp ", 'minus_plus'), parameters): - response_set.add(expr) - response = response_set - else: - response_set = {response} - - for sympy_symbol_str in symbols: - symbol_str = symbols[sympy_symbol_str]["latex"] - latex_symbol_str = extract_latex(symbol_str) - - if "\pm" not in symbol_str and "\mp" not in symbol_str: - try: - latex_symbol_str_postprocess = latex2sympy(latex_symbol_str) - except Exception: - try: - latex_symbol_str_preprocessed, replacements = preprocess_E(latex_symbol_str) - latex_symbol_parsed = latex2sympy(latex_symbol_str_preprocessed) - latex_symbol_str_postprocess = postprocess_E(latex_symbol_parsed, replacements) - - except Exception: - raise ValueError( - f"Couldn't parse latex symbol {latex_symbol_str} " - f"to sympy symbol." - ) - substitutions[latex_symbol_str_postprocess] = Symbol(sympy_symbol_str) - - - aliases = symbols[sympy_symbol_str]['aliases'] - transformations = (standard_transformations + (implicit_multiplication_application,)) - for alias in aliases: - if not alias.strip(): - continue - try: - parsed_alias = parse_expr( - alias, - transformations=transformations, - global_dict={}, - local_dict={'Symbol': Symbol,'E': Symbol("E")} - ) - substitutions[parsed_alias] = Symbol(sympy_symbol_str) - except Exception as e: - print(e) - substitutions[Symbol(alias)] = Symbol(sympy_symbol_str) - - - parsed_responses = set() - for expression in response_set: - try: - expression_postprocess = latex2sympy(expression, substitutions) - except Exception: - try: - expression_preprocessed, replacements = preprocess_E(expression) - expression_parsed = latex2sympy(expression_preprocessed, substitutions) - if isinstance(expression_parsed, list): - expression_parsed = expression_parsed.pop() - - expression_postprocess = postprocess_E(expression_parsed, replacements) - except Exception as e: - raise ValueError("Failed to pass expression during preview: ", str(e)) - - if simplify is True: - expression_postprocess = expression_postprocess.simplify() - - parsed_responses.add(str(expression_postprocess.subs(substitutions))) - - if len(parsed_responses) < 2: - return parsed_responses.pop() - else: - return '{'+', '.join(sorted(parsed_responses))+'}' - - -def sanitise_latex(response): - response = "".join(response.split()) - response = response.replace('~', ' ') - wrappers = [r"\mathrm", r"\text"] - for wrapper in wrappers: - processed_response = [] - index = 0 - while index < len(response): - wrapper_start = response.find(wrapper+"{", index) - if wrapper_start > -1: - processed_response.append(response[index:wrapper_start]) - wrapper_end = find_matching_parenthesis(response, wrapper_start+1, delimiters=('{', '}')) - inside_wrapper = response[(wrapper_start+len(wrapper+"{")):wrapper_end] - processed_response.append(inside_wrapper) - index = wrapper_end+1 - else: - processed_response.append(response[index:]) - index = len(response) - response = "".join(processed_response) - return response diff --git a/app/utility/slr_parsing_utilities.py b/app/utility/slr_parsing_utilities.py deleted file mode 100644 index 5841f66..0000000 --- a/app/utility/slr_parsing_utilities.py +++ /dev/null @@ -1,845 +0,0 @@ -# ----------------- -# ----------------- -# UTILITY FUNCTIONS -# ----------------- -# ----------------- - -# ----------------- -# Scanner utilities -# ----------------- -import re - - -def catch_undefined(label, content, original, start, end): - return Token(label, content, original, start, end) - -# ----------------- -# Parser utilities -# ----------------- - - -# Syntax tree building utilities -def proceed(production, output, tag_handler): - return output - - -def package(production, output, tag_handler): - label = production[0].label - handle = production[1] - children = output[-len(handle):] - output = output[0:(-len(handle))] - package_content = "".join(str(children)) - new_package = ExprNode(Token(label, package_content, children[0].original, children[0].start, children[0].end), children, tag_handler=tag_handler) - output.append(new_package) - return output - - -def append(production, output, tag_handler): - handle = production[1] - children = output[1-len(handle):] - output = output[0:(1-len(handle))] - output[-1].children += children - return output - - -def append_last(production, output, tag_handler): - handle = production[1] - last = output[-1] - output = output[0:(1-len(handle))] - output[-1].children.append(last) - return output - - -def join(production, output, tag_handler): - label = production[0].label - handle = production[1] - content = [] - for node in output[-len(handle):]: - try: - content.append(node.content_string()) - except Exception: - content.append(node.content) - joined_content = "".join(content) - joined_end = output[-1].end - output = output[0:(1-len(handle))] - output[-1].label = label - output[-1].content = joined_content - output[-1].end = joined_end - return output - - -def create_node(production, output, tag_handler): - a = output.pop() - node = ExprNode(a, [], tag_handler=tag_handler) - output.append(node) - return output - - -def relabel(production, output, tag_handler): - a = output.pop() - output.append(Token(production[0].label, a.content, a.original, a.start, a.end)) - return output - - -def group(number_of_elements, empty=False, delimiters=["", ""]): - if number_of_elements < 1: - raise Exception("Groups must have at least one element.") - - def wrap(production, output, tag_handler): - if empty: - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - end_delim = Token("START_DELIMITER", delimiters[1], content[0].original, content[0].start, content[0].end) - start_delim = Token("END_DELIMITER", delimiters[0], content[0].original, content[0].start, content[0].end) - else: - end_delim = output.pop() - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - start_delim = output.pop() - for k, elem in enumerate(content): - if isinstance(elem, Token) and not isinstance(elem, ExprNode): - content[k] = ExprNode(elem, [], tag_handler=tag_handler) - output.append( - ExprNode( - Token( - "GROUP", - [start_delim.content, end_delim.content], - content[0].original, - start_delim.start, end_delim.end - ), - content, traverse_step=traverse_group, tag_handler=tag_handler) - ) - return output - - return wrap - - -def operate(number_of_elements, empty=False): - if number_of_elements < 0: - raise Exception("Operations must have at least one argument.") - - def wrap(production, output, tag_handler): - if empty: - end_index = output[-1].end - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - else: - end_delim = output.pop() - end_index = end_delim.end - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - _ = output.pop() - for k, elem in enumerate(content): - if isinstance(elem, Token) and not isinstance(elem, ExprNode): - content[k] = ExprNode(elem, [], tag_handler=tag_handler) - operation = output.pop() - node = ExprNode(operation, content, tag_handler=tag_handler) - node.end = end_index - output.append(node) - return output - - return wrap - - -def infix(production, output, tag_handler): - right = output.pop() - operator = output.pop() - left = output.pop() - output.append(ExprNode(operator, [left, right], traverse_step=traverse_infix, tag_handler=tag_handler)) - return output - - -def insert_infix(content, label): - def apply(production, output, tag_handler): - operator_token = Token(label, content, output[-1].original, len(output[-1].original), -1) - return infix(production, output[0:-1]+[operator_token]+[output[-1]], tag_handler=tag_handler) - return apply - - -def compose(*funcs): - def composed_functions(production, output, tag_handler): - for f in reversed(funcs): - output = f(production, output, tag_handler) - return output - return composed_functions - - -def flatten(production, output, tag_handler): - node = output[-1] - flattened_children = [] - for child in node.children: - if node.label == child.label and node.content == child.content: - flattened_children += child.children - else: - flattened_children.append(child) - node.children = flattened_children - return output - - -# Tag management utilities - -def tag_rule_union(x, y): - return x | y - - -def tag_rule_intersection(x, y): - return x & y - - -def tag_transfer(node, rule=tag_rule_union): - tags = set() - if len(node.children) > 0: - tags = node.children[0].tags - for child in node.children[1:]: - tags = rule(tags, child.tags) - return tags - - -def tag_removal(node, tag, rule=lambda x: True): - if rule(node.tags) and tag in node.tags: - node.tags.remove(tag) - return node - - -def tag(node, tag=None, rule=lambda x: True): - if tag is not None: - if node.tags is None: - node.tags = set() - if rule(node.tags): - node.tags.add(tag) - return node - - -def tag_replace(node, old_tag, new_tag): - if old_tag in node.tags: - node = tag(node, new_tag) - node = tag_removal(node, old_tag) - return node - -# ------------------------ -# Node traversal utilities -# ------------------------ - - -def traverse_prefix(expr_node, action): - out = [(True, action(expr_node))] - for x in expr_node.children: - out += [(False, x)] - return out - - -def traverse_postfix(expr_node, action): - out = [] - for x in expr_node.children: - out += [(False, x)] - return out+[(True, action(expr_node))] - - -def traverse_infix(expr_node, action): - out = [] - for x in expr_node.children[0:-1]: - out += [(False, x), (True, action(expr_node))] - return out+[(False, expr_node.children[-1])] - - -def traverse_group(expr_node, action): - out = [(True, action(expr_node)[0])] - for x in expr_node.children: - out += [(False, x)] - return out+[(True, action(expr_node)[1])] - - -# error handling utilities -def new_root_on_error(parser, stack, a, input_tokens, tokens, output): - a = parser.end_token - return stack, a, input_tokens, tokens, output - - -def discard_output_until_on_error(condition): - def error(parser, stack, a, input_tokens, tokens, output): - while not condition(output[-1]): - output.pop() - a = parser.end_token - return stack, a, input_tokens, tokens, output - return error - -# -------------------------- -# Parser generator utilities -# -------------------------- - - -def SLR_expression_parser(nodes=[], infix_operators=[], delimiters=[], undefined=None, costum_tokens=[], costum_productions=[], group_node=None, expression_node=None, start=None, null=None, end=None, error_handler=[]): - infix_operators_dictionary = dict() - unique_infix_operator_symbols = [] - for (symbol, label) in infix_operators: - if label in infix_operators_dictionary.keys(): - infix_operators_dictionary[label] += [symbol] - else: - unique_infix_operator_symbols += [symbol] - infix_operators_dictionary.update({label: [symbol]}) - infix_operators_token = [] - for (label, symbols) in infix_operators_dictionary.items(): - infix_operators_token.append(((" *("+"|".join([re.escape(s) for s in symbols])+") *"), label)) - - if undefined is None: - undefined_symbol = "UNDEFINED" - undefined = (undefined_symbol, undefined_symbol, catch_undefined) - else: - undefined += (catch_undefined,) - - if expression_node is None: - expression_node_symbol = "EXPRESSION_NODE" - expression_node = (expression_node_symbol, expression_node_symbol, None) - else: - undefined += (None,) - - if group_node is None: - group_node_symbol = "GROUP_NODE" - group_node = (group_node_symbol, group_node_symbol, None) - - if start is None: - start_symbol = "START" - start = (start_symbol, start_symbol) - - if end is None: - end_symbol = "END" - end = (end_symbol, end_symbol) - - if null is None: - null_symbol = "NULL" - null = (null_symbol, null_symbol) - - token_list = [undefined, null, expression_node, start, end]+nodes+infix_operators_token+costum_tokens - - productions = [(start[0], expression_node[0], relabel)] - productions += [(expression_node[0], n[0], create_node) for n in nodes] - productions += [(expression_node[0], undefined[0], create_node)] - productions += [(expression_node[0], expression_node[0]+operator+expression_node[0], infix) for operator in [op[0] for op in unique_infix_operator_symbols]] - - for (delims, action) in delimiters: - token_list += [(re.escape(delims[0])+" *", "START_DELIMITER"), (" *"+re.escape(delims[1]), "END_DELIMITER")] - productions += [(expression_node[0], delims[0]+expression_node[0]+delims[1], action)] - - productions += costum_productions - - return SLR_Parser(token_list, productions, start[1], end[1], null[1], error_handler=error_handler) - -# ------- -# ------- -# CLASSES -# ------- -# ------- - - -class Token: - - def __init__(self, label, content, original, start, end): - self.label = label - self.content = content - self.original = original - self.start = start - self.end = end - return - - def __eq__(self, other): - return isinstance(other, Token) and self.label == other.label - - def __hash__(self): - return hash(self.label) - - def __str__(self): - return str(self.label)+": "+str(self.content) - - def __repr__(self): - # REMARK(KarlLundengaard): This is not a good repr function, but it means that the most - # relevant info is printed in the watch window of my preferred debugger - return str(self.label)+": "+str(self.content) - - -class ExprNode(Token): - - def __init__(self, token, children, tag_handler=None, tags=set(), traverse_step=traverse_prefix): - super().__init__(token.label, token.content, token.original, token.start, token.end) - self.tags = set() - self.children = [] - for child in children: - if isinstance(child, ExprNode): - self.children.append(child) - elif isinstance(child, Token): - self.children.append(ExprNode(child, [])) - else: - raise Exception(f"Invalid child {str(child)}") - self._traverse_step = traverse_step - if tag_handler is not None: - self.tags = tag_handler(self) - else: - self.tags = tags - return - - def copy(self): - token = Token(self.label, self.content, self.original, self.start, self.end) - children = [] - for child in self.children: - children.append(child.copy()) - return ExprNode(token, children, tags=self.tags, traverse_step=self._traverse_step) - - def tree_string(self): - s = str(self) - for k, child in enumerate(self.children): - padding = "\n| " if k < len(self.children)-1 else "\n " - s += "\n"+str(k)+": "+child.tree_string().replace("\n", padding) - return s - - def content_string(self, max_depth=None): - output = self.traverse(lambda x: x.content, max_depth) - return "".join(output) - - def traverse(self, action, max_depth=None): - stack = [x+(0,) for x in self._traverse_step(self, action)[::-1]] - output = [] - while len(stack) > 0: - (is_output, elem, depth) = stack.pop() - if max_depth is None or depth <= max_depth: - if is_output: - output.append(elem) - else: - stack += [x+(depth+1,) for x in elem._traverse_step(elem, action)[::-1]] - return output - - def original_string(self): - left_children = self.children - right_children = self.children - start = self.start - end = self.end - while len(left_children) > 0: - start = min(start, left_children[0].start) - left_children = left_children[0].children - while len(right_children) > 0: - end = max(end, right_children[-1].end) - right_children = right_children[-1].children - return self.original[start:end+1] - - def __str__(self): - tags = " tags: " - tags += str(self.tags) if len(self.tags) > 1 else " {}" - return str(self.label)+": "+str(self.content)+tags - - def __repr__(self): - # REMARK(KarlLundengaard): This is not a good repr function, but it means that the most - # relevant info is printed in the watch window of my preferred debugger - return str(self.label)+": "+str(self.content)+" tags: "+str(self.tags) - - -class SLR_Parser: - - def default_error_action(parser, stack, a, input_tokens, tokens, output): - m = 70 - raise Exception( - f"\n{'-'*m}\n" + - f"ERROR:\n{'-'*m}\n" + - f"accepted: {input_tokens[:-len(tokens)]}\n" + - f"current: {a}, {parser._symbols_index[a]}\n" + - f"remaining: {tokens}\n" + - f"stack: {stack}\n" + - f"output: {output}\n" + - f"state: {parser.state_string(parser._states_index[stack[-1]])}\n" + - f"{'-'*m}") - - def __init__(self, token_list, productions, start_symbol, end_symbol, null_symbol, error_handler=[], tag_handler=tag_transfer): - self.token_list = token_list - self.token_list.sort(key=lambda x: -len(x[0])) - self.productions = productions - self.start_symbol = start_symbol - self.end_symbol = end_symbol - self.null_symbol = null_symbol - self.start_token = self.scan(start_symbol, mode="bnf")[0] - self.end_token = self.scan(end_symbol, mode="bnf")[0] - self.null_token = self.scan(null_symbol, mode="bnf")[0] - start_token = self.start_token - end_token = self.end_token - null_token = self.null_token - self.error_handler = error_handler - self.tag_handler = tag_handler - - # Check if there are any duplicate productions - checked_productions = [] - duplicate_error_string = [] - for prod in [(x[0], x[1]) for x in productions]: - if prod in checked_productions: - duplicate_error_string.append(f"duplicate: {prod}") - checked_productions.append(prod) - if len(duplicate_error_string) > 0: - raise Exception("There are duplicate productions:\n" + "\n".join(duplicate_error_string)) - - # Tokenize productions - productions_token = [ - (self.scan(x[0], mode="bnf")[0], self.scan(x[1], mode="bnf")) for x in productions - ] - self.productions_token = productions_token - - # Analyse productions to find terminals and non-terminals - non_terminals_token = [] - terminals_token = [self.end_token, self.null_token] - for token in [prod[0] for prod in productions_token]: - if token not in non_terminals_token: - non_terminals_token.append(token) - for production in productions_token: - for token in production[1]: - if token not in terminals_token and token not in non_terminals_token: - terminals_token.append(token) - self.symbols = terminals_token+non_terminals_token - self.terminals_token = terminals_token - self.non_terminals_token = non_terminals_token - - # Create reductions dictionary - self.reductions = {tuple(productions_token[k][1]): productions[k][2] for k in range(0, len(productions))} - - # Compute dictionary with FIRST for all single tokens - first_dict = {**{x: [x] for x in terminals_token}, **{x: [] for x in non_terminals_token}} - lengths = [-1]*len(non_terminals_token) - any_first_changed = True - while any_first_changed: - any_first_changed = False - for k, x in enumerate(non_terminals_token): - if lengths[k] != len(first_dict[x]): - lengths[k] = len(first_dict[x]) - any_first_changed = True - for nt in non_terminals_token: - prods = [x[1] for x in productions_token if x[0] == nt] - for prod in prods: - for token in prod: - for x in first_dict[token]: - if x not in first_dict[nt]: - first_dict[nt].append(x) - if null_token not in first_dict[token]: - break - self._first_dict = first_dict - - # Compute dictionary with FOLLOW for all non_terminals - first = self.first - follow = {x: [] for x in non_terminals_token} - follow[start_token].append(end_token) - lengths = [-1]*len(non_terminals_token) - while lengths != [len(follow[x]) for x in non_terminals_token]: - lengths = [len(follow[x]) for x in non_terminals_token] - for (head, body) in productions_token: - for k, token in enumerate(body): - if token in non_terminals_token: - if null_token in self.first(body[k+1:]) or len(body[k+1:]) == 0: - for item in follow[head]: - if item not in follow[token]: - follow[token].append(item) - for item in first(body[k+1:]): - if item != null_token and item not in follow[token]: - follow[token].append(item) - self._follow = follow - - # Compute all states and the transitions between them - closure = self.closure - compute_transitions = self.compute_transitions - start_productions = tuple([(k, 0) for k in range(0, len(productions_token)) if productions_token[k][0] == start_token]) - states = {start_productions: closure(start_productions)} - transitions = {} - new_states = [start_productions] - while len(new_states) > 0: - state = new_states.pop(0) - trans = compute_transitions(closure(list(state))) - transitions.update({state: trans}) - for t in trans: - if tuple(t[1]) not in states.keys(): - states.update({tuple(t[1]): closure(t[1])}) - new_states.append(tuple(t[1])) - self.states = states - self.transitions = transitions - - # Create index dictionaries to simplify state table construction - states_index = {} - for i, s in enumerate(states): - states_index.update({s: i, i: s}) - self._states_index = states_index - - symbols_index = {} - symbols = terminals_token+non_terminals_token - for j, h in enumerate(symbols): - symbols_index.update({h: j, j: h}) - self._symbols_index = symbols_index - - # Compute parsing table - parsing_table = [] - for i in range(0, len(states)): - parsing_table.append([]) - for j in range(0, len(symbols)): - parsing_table[i].append([]) - - for state in states: - for (production_index, dot_index) in closure(state): - # Fill in shift actions and goto table - (head, body) = productions_token[production_index] - if dot_index < len(body): - a = body[dot_index] - table_entry = parsing_table[states_index[state]][symbols_index[a]] - for (symbol, next_state) in transitions[state]: - if symbol == a: - table_entry.append((production_index, states_index[tuple(next_state)])) - break - elif head == self.start_token: - parsing_table[states_index[state]][symbols_index[self.end_token]].append((production_index, len(self.states))) - else: - # Fill in reduce actions - for a in follow[head]: - table_entry = parsing_table[states_index[state]][symbols_index[a]] - table_entry.append((production_index, len(states)+production_index)) - - # Choose correct table entry based on precedence, - # precedence is determined by location in productions - # array (higher index in array means higher precedence) - for i in range(0, len(states)): - for j in range(0, len(symbols)): - table_entry = parsing_table[i][j] - if len(table_entry) == 0: - # items_token gives a list of pairs that contain the parts of the production before and after the current point - items_token = [(productions_token[x[0]][1][0:x[1]], productions_token[x[0]][1][x[1]:]) for x in states_index[i]] - next_symbol = symbols_index[j] - parsing_table[i][j] = -1 - for (index, condition) in enumerate([x[0] for x in self.error_handler], 2): - if condition(items_token, next_symbol): - parsing_table[i][j] = -index - break - elif len(table_entry) == 1: - parsing_table[i][j] = table_entry[0][1] - else: - precedence = table_entry[0][0] - index = 0 - for k, action in enumerate(table_entry[1:], 1): - if precedence < action[0]: - precedence = action[0] - index = k - parsing_table[i][j] = table_entry[index][1] - self.parsing_table = parsing_table - - # Check for unreachable states and reductions - unreachable = list(range(0, len(states)+len(self.reductions))) - for i in range(0, len(states)): - for j in range(0, len(symbols)): - if parsing_table[i][j] in unreachable: - unreachable.remove(parsing_table[i][j]) - self.parsing_table = parsing_table - - if len(unreachable) > 1: - print("Unreachable states:") - for x in [y for y in unreachable if y < len(states)]: - print("\t"+self.state_string(self._states_index[x])) - print("Unreachable reductions:") - for x in [y for y in unreachable if y >= len(states) and y < len(states)+len(productions_token)]: - print("\t"+self.productions[x-len(states)][0]+"-->"+str(self.productions[x-len(states)][1])) - - return - - def scan(self, expr, mode="expression"): - token_list = self.token_list - tokens = [] - - def new_token(token_label, token_content, token_start, token_end): - return Token(token_label, token_content, expr, token_start, token_end) - - token_catch_undefined = [x for x in token_list if len(x) > 2 and x[2] == catch_undefined] - if len(token_catch_undefined) > 1: - raise Exception("Only one token type can be used to catch undefined lexemes.") - elif len(token_catch_undefined) < 1: - token_catch_undefined = None - else: - token_catch_undefined = token_catch_undefined[0] - - if "expression" in mode: - token_rules = [x for x in token_list if len(x) > 2 and x[2] not in {None, catch_undefined}] - token_symbols = [x for x in token_list if len(x) == 2] - elif "bnf" in mode: - token_rules = [] - token_symbols = [(x[0], x[1]) for x in token_list] - - index = 0 - string = "" - while index-len(string) < len(expr): - end_token = None - end_token_length = 0 - content = "" - label = None - for (re_content, current_label) in token_symbols: - match_content = re.match(re_content, expr[index:]) - if match_content is not None: - current_content = match_content.group() - if len(current_content) > end_token_length: - content = current_content - end_token_length = len(content) - label = current_label - for token in token_rules: - current_label = token[1] - match_rule, match_content = token[2](expr[index:]) - if match_rule is not None: - if len(match_rule) > end_token_length: - content = match_content - end_token_length = len(match_rule) - label = current_label - if label is None: - string = string+expr[index] - index += 1 - else: - end_token = new_token(label, content, index, index+end_token_length-1) - if len(string) > 0 and (end_token is not None or index >= len(expr)): - if token_catch_undefined is not None: - tokens.append(token_catch_undefined[2](token_catch_undefined[1], string, expr, index-len(string), index-1)) - else: - raise Exception(f"Undefined input: {string}") - string = "" - if end_token is not None: - tokens.append(end_token) - index += end_token_length - return tokens - - def closure(self, item_set): - non_terminals = self.non_terminals_token - productions = self.productions_token - # Items are represented as (i,j) where i indicates index - # in production list and j position of inserted dot - closure_set = [] - offset = -1 - new_items = item_set - added_to_closure = [False]*len(non_terminals) - while len(new_items) > 0: - closure_set += new_items - offset += len(new_items) - new_items = [] - for item in closure_set: - i = item[0] - j = item[1] - follow = None - if j < len(productions[i][1]): - for k, nt in enumerate(non_terminals): - if productions[i][1][j] == nt: - follow = k - break - if follow is not None and not added_to_closure[follow]: - for k, production in enumerate(productions): - if production[0] == non_terminals[follow]: - new_items.append((k, 0)) - added_to_closure[follow] = True - return closure_set - - def compute_transitions(self, item_set): - productions_token = self.productions_token - transitions = [] - for item in item_set: - i = item[0] - j = item[1] - if j < len(productions_token[i][1]): - token = productions_token[i][1][j] - if token not in [x[0] for x in transitions]: - transitions.append((token, [(i, j+1)])) - else: - transitions[[x[0] for x in transitions].index(token)][1].append((i, j+1)) - return transitions - - def first(self, tokens): - # Computes FIRST for strings of tokens - null_token = self.null_token - first_dict = self._first_dict - if len(tokens) == 1: - return first_dict[tokens[0]] - fs = [] - for token in tokens: - for item in first_dict[token]: - if item not in fs and item != null_token: - fs.append(item) - if token != null_token: - break - return fs - - def parsing_table_to_string(self): - parsing_table = self.parsing_table - symbols = [x.content for x in self.terminals_token+self.non_terminals_token] - states = self.states - parsing_table_string = ["\t"+"\t".join(symbols)+"\n"] - - for i in range(0, len(parsing_table)): - parsing_table_string += [str(i)+"\t"] - for j in range(0, len(parsing_table[i])): - if parsing_table[i][j] < 0: - parsing_table_string += ['e'+str(-parsing_table[i][j])] - elif parsing_table[i][j] < len(states): - parsing_table_string += ['s'+str(parsing_table[i][j])] - else: - parsing_table_string += ['r'+str(parsing_table[i][j]-len(states))] - parsing_table_string += ['\t'] - parsing_table_string += ['\n'] - - return "".join(parsing_table_string) - - def state_string(self, state): - items = self.closure(state) - prod_strings = [] - for k in range(0, len(items)): - production = self.productions_token[items[k][0]] - dot_index = items[k][1] - prod_string = "".join(x.content for x in production[1][0:dot_index])+"."+"".join(x.content for x in production[1][dot_index:]) - prod_strings.append(prod_string) - return "I"+str(self._states_index[state])+": ("+", ".join(prod_strings)+")" - - def state_string_list(self, state): - items = self.closure(state) - prod_strings = [] - for k in range(0, len(items)): - production = self.productions_token[items[k][0]] - dot_index = items[k][1] - prod_string = "".join(x.content for x in production[1][0:dot_index])+"."+"".join(x.content for x in production[1][dot_index:]) - prod_strings.append(prod_string) - return ["I"+str(self._states_index[state])]+prod_strings - - def parsing_action(self, s, a): - return self.parsing_table[s][self._symbols_index[a]] - - def parse(self, input_tokens, verbose=False): - productions_token = self.productions_token - tokens = list(input_tokens) - if tokens[-1] != self.end_token: - tokens += [self.end_token] - a = tokens.pop(0) - stack = [0] - output = [] - while True: - parse_action = self.parsing_action(stack[-1], a) - while parse_action < 0: - if parse_action == -1: - self.default_error_action(stack, a, input_tokens, tokens, output) - else: - stack, a, input_tokens, tokens, output = self.error_handler[-2-parse_action][1](self, stack, a, input_tokens, tokens, output) - parse_action = self.parsing_action(stack[-1], a) - if parse_action < len(self.states): - stack.append(parse_action) - output.append(ExprNode(a, [])) - if verbose: - print("shift and transition to: "+self.state_string(self._states_index[parse_action])+" \t"+str(output)) - a = tokens.pop(0) - elif parse_action == len(self.states): - if verbose: - print("ACCEPT") - if len(tokens) > 0 and tokens != [self.end_token]: - output += ExprNode(self.parse(tokens), []) - break - elif parse_action <= len(self.states)+len(self.productions_token): - production = productions_token[parse_action-len(self.states)] - reduction = self.reductions[tuple(production[1])] - output = reduction(production, output, self.tag_handler) - # print("-----------------------") - # print(output[0].tree_string()) - # print("-----------------------") - stack = stack[0:-len(production[1])] - stack.append(self.parsing_action(stack[-1], production[0])) - if verbose: - print("reduce by: "+str(production[0].content)+" --> "+"".join([x.content for x in production[1]])+" \t"+str(output)) - print("new state: "+self.state_string(self._states_index[stack[-1]])) - print("next input: "+str(a)) - else: - m = 70 - raise Exception(f"{'-'*m}\nINVALID ENTRY:\n{'-'*m}\naccepted: {input_tokens[:-len(tokens)]}\ncurrent: {a}\nremaining: {tokens}\nstack: {stack}\noutput: {output}\n{'-'*m}") - break - return output diff --git a/app/utility/syntactical_comparison_utilities.py b/app/utility/syntactical_comparison_utilities.py deleted file mode 100644 index 89c5a76..0000000 --- a/app/utility/syntactical_comparison_utilities.py +++ /dev/null @@ -1,100 +0,0 @@ -import re - -is_nonnegative_number_regex = '((0|[1-9]\d*)?(\.\d+)?(?<=\d)(e-?(0|[1-9]\d*))?)' - - -is_number_regex = '(-?(0|[1-9]\d*)?(\.\d+)?(?<=\d)( *(e|E|\*10^|\*10\*\*)-?(0|[1-9]\d*))?)' - - -def is_number(string): - match_content = re.fullmatch(is_number_regex, string) - return match_content is not None and len(match_content.group(0)) > 0 - - -def is_complex_number_on_cartesian_form(string): - string = "".join(string.split()) - result = re.fullmatch(is_number_regex+"?\+?"+is_number_regex+"?\*?I?", string) - return result is not None - - -def is_complex_number_on_exponential_form(string): - string = "".join(string.split()) - result = re.fullmatch(is_number_regex+"?\*?(E\^|E\*\*|exp)\(?"+is_number_regex+"*\*?I\)?", string) - return result is not None - - -def escape_regex_reserved_characters(string): - list = '+*?^$.[]{}()|/' - string = string.replace('\\', '\\\\') - for s in list: - string = string.replace(s, '\\'+s) - return string - - -def generate_arbitrary_number_pattern_matcher(string): - non_numbers = [] - number_pattern = '(\\('+is_number_regex+'\\))' - nonneg_number_pattern = is_nonnegative_number_regex - full_pattern = '('+number_pattern+'|'+nonneg_number_pattern+')' - number = re.search(number_pattern, string) - nonneg_number = re.search(nonneg_number_pattern, string) - start = 0 - end = 0 - offset = 0 - while (number is not None) or (nonneg_number is not None): - start_number = len(string) - end_number = len(string) - start_nonneg_number = len(string) - end_nonneg_number = len(string) - if number is not None: - start_number, end_number = number.span() - if nonneg_number is not None: - start_nonneg_number, end_nonneg_number = nonneg_number.span() - if start_number < start_nonneg_number: - start, end = number.span() - else: - start, end = nonneg_number.span() - start += offset - end += offset - non_number = escape_regex_reserved_characters(string[offset:start]) - if len(non_number) > 0: - non_number = '('+non_number+')' - non_number = ''.join(non_number.split()) - non_numbers.append(non_number) - offset = end - number = re.search(number_pattern, string[offset:]) - nonneg_number = re.search(nonneg_number_pattern, string[offset:]) - tail = escape_regex_reserved_characters(string[offset:]) - if len(tail) > 0: - tail = '('+tail+')' - tail = ''.join(tail.split()) - non_numbers.append(tail) - pattern = full_pattern.join(non_numbers) - - def matcher(comp_string): - comp_string = ''.join(comp_string.split()) - result = re.fullmatch(pattern, comp_string) - return result is not None - return matcher - - -patterns = { - "NUMBER": { - "matcher": is_number, - "name": "simplified number", - "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both numbers written in simplified form.", - "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both numbers written in simplified form.", - }, - "CARTESIAN": { - "matcher": is_complex_number_on_cartesian_form, - "name": "cartesian", - "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in cartesian form", - "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in cartesian form, i.e. $a+bi$.", - }, - "EXPONENTIAL": { - "matcher": is_complex_number_on_exponential_form, - "name": "exponential", - "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in exponential form", - "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in exponential form, i.e. $a exp(bi)$.", - }, -} diff --git a/app/utility/unit_system_conversions.py b/app/utility/unit_system_conversions.py deleted file mode 100644 index 884ddb5..0000000 --- a/app/utility/unit_system_conversions.py +++ /dev/null @@ -1,145 +0,0 @@ -# Remarks: -# only K, no other temperature scales included -# angles measures, bel an neper are all treated as identical dimensionless units - -""" -Prefixes taken from Table 5 https://physics.nist.gov/cuu/Units/prefixes.html and https://www.bipm.org/en/cgpm-2022/resolution-3 -""" -set_of_SI_prefixes = { - ('quetta', 'Q', '(10**30)', tuple()), - ('ronna', 'R', '(10**27)', tuple()), - ('yotta', 'Y', '(10**24)', tuple()), - ('zetta', 'Z', '(10**21)', tuple()), - ('exa', 'E', '(10**18)', tuple()), - ('peta', 'P', '(10**15)', tuple()), - ('tera', 'T', '(10**12)', tuple()), - ('giga', 'G', '(10**9)', tuple()), - ('mega', 'M', '(10**6)', tuple()), - ('kilo', 'k', '(10**3)', tuple()), - ('hecto', 'h', '(10**2)', tuple()), - ('deca', 'da', '(10**1)', ('deka',)), - ('deci', 'd', '(10**(-1))', tuple()), - ('centi', 'c', '(10**(-2))', tuple()), - ('milli', 'm', '(10**(-3))', tuple()), - ('micro', 'mu', '(10**(-6))', ("μ", "µ", "𝛍", "𝜇", "𝝁", "𝝻", "𝞵")), - ('nano', 'n', '(10**(-9))', tuple()), - ('pico', 'p', '(10**(-12))', tuple()), - ('femto', 'f', '(10**(-15))', tuple()), - ('atto', 'a', '(10**(-18))', tuple()), - ('zepto', 'z', '(10**(-21))', tuple()), - ('yocto', 'y', '(10**(-24))', tuple()), - ('ronto', 'r', '(10**(-27))', tuple()), - ('quecto', 'q', '(10**(-30))', tuple()) -} - -""" -SI base units taken from Table 1 https://physics.nist.gov/cuu/Units/units.html -Note that gram is used as a base unit instead of kilogram. -""" -set_of_SI_base_unit_dimensions = { - ('metre', 'm', 'length', ('meter',), ('metres', 'meters')), - ('gram', 'g', 'mass', tuple(), ('grams',)), - ('second', 's', 'time', tuple(), ('seconds',)), - ('ampere', 'A', 'electric_current', ('Ampere',), ('amperes', 'Amperes')), - ('kelvin', 'K', 'temperature', ('Kelvin',), ('kelvins', 'Kelvins')), - ('mole', 'mol', 'amount_of_substance', tuple(), ('moles',)), - ('candela', 'cd', 'luminous_intensity', ('Candela',), ('candelas', 'Candelas')), -} - -""" -Derived SI units taken from Table 3 https://physics.nist.gov/cuu/Units/units.html -Note that radians and degree have been moved to list_of_very_common_units_in_SI to reduce collisions when substituting. -Note that degrees Celsius is omitted. -""" -set_of_derived_SI_units_in_SI_base_units = { - ('hertz', 'Hz', '(second**(-1))', ('Hertz',), tuple()), - ('newton', 'N', '(metre*kilogram*second**(-2))', ('Newton',), ('newtons', 'Newtons')), - ('pascal', 'Pa', '(metre**(-1)*kilogram*second**(-2))', ('Pascal',), ('pascals', 'Pascals')), - ('joule', 'J', '(metre**2*kilogram*second**(-2))', ('Joule',), ('joules', 'Joules')), - ('watt', 'W', '(metre**2*kilogram*second**(-3))', ('Watt',), ('watts', 'Watts')), - ('coulomb', 'C', '(second*ampere)', ('Coulomb',), ('coulombs', 'Coulombs')), - ('volt', 'V', '(metre**2*kilogram*second**(-3)*ampere**(-1))', ('Volt',), ('volts', 'Volts')), - ('farad', 'F', '(metre**(-2)*(kilogram)**(-1)*second**4*ampere**2)', ('Farad',), ('farads', 'Farads')), - ('ohm', 'O', '(metre**2*kilogram*second**(-3)*ampere**(-2))', ('Ohm', "Ω", "𝛀", "𝛺", "𝜴", "𝝮", "𝞨"), ('ohms', 'Ohms')), - ('siemens', 'S', '(metre**(-2)*kilogram**(-1)*second**3*ampere**2)', ('Siemens',), tuple()), - ('weber', 'Wb', '(metre**2*kilogram*second**(-2)*ampere**(-1))', ('Weber',), ('webers', 'Webers')), - ('tesla', 'T', '(kilogram*second**(-2)*ampere**(-1))', ('Tesla',), ('teslas', 'Teslas')), - ('henry', 'H', '(metre**2*kilogram*second**(-2)*ampere**(-2))', ('Henry',), ('henrys', 'Henrys')), - ('lumen', 'lm', '(candela)', tuple(), ('lumens',)), - ('lux', 'lx', '(metre**(-2)*candela)', tuple(), tuple()), - ('becquerel', 'Bq', '(second**(-1))', ('Becquerel',), ('becquerels', 'Becquerels')), - ('gray', 'Gy', '(metre**2*second**(-2))', ('Gray',), ('grays', 'Grays')), - ('sievert', 'Sv', '(metre**2*second**(-2))', ('Sievert',), ('sieverts', 'Sieverts')), - ('katal', 'kat', '(second**(-1)*mole)', ('Katal',), ('katals', 'Katals')) -} - -""" -Commonly used non-SI units taken from Table 6 and 7 https://physics.nist.gov/cuu/Units/outside.html -Note that radian and steradian from Table 3 have been moved here to reduce collisions when substituting. -This is the subset of common symbols whose short form symbols are allowed -""" -set_of_very_common_units_in_SI = { - ('radian', 'r', '(1/(2*pi))', tuple(), ('radians',)), # Note: here 'r' is used instead of the more common 'rad' to avoid collision - ('steradian', 'sr', '(1/(4*pi))', tuple(), ('steradians',)), - ('minute', 'min', '(60*second)', tuple(), ('minutes',)), - ('hour', 'h', '(3600*second)', tuple(), ('hours',)), - ('degree', 'deg', '(1/360)', ('°', ), ('degrees',)), - ('litre', 'L', '(10**(-3)*metre**3)', ('liter',), ('litres,liters',)), - ('metricton', 't', '(10**3*kilogram)', ('tonne',), ('tonnes',)), - ('neper', 'Np', '(1)', ('Neper',), ('nepers', 'Nepers')), - ('bel', 'B', '((1/2)*2.30258509299405)', ('Bel',), ('bels', 'Bels')), # Note: log(10) = 2.30258509299405 in base 2 - ('electronvolt', 'eV', '(1.60218*10**(-19)*joule)', tuple(), ('electronvolts',)), - ('atomic_mass_unit', 'u', '(1.66054*10**(-27)*kilogram)', tuple(), ('atomic_mass_units',)), - ('angstrom', 'Å', '(10**(-10)*metre)', ('Angstrom', 'Ångström'), ('angstroms', 'Angstroms')), -} - - -""" -Commonly used non-SI units taken from Table 6 and 7 https://physics.nist.gov/cuu/Units/outside.html -Note that short form symbols are defined here, but not used since they cause to many ambiguities -""" -set_of_common_units_in_SI = { - ('day', 'd', '(86400*second)', tuple(), ('days',)), - ('angleminute', "'", '(pi/10800)', tuple(), tuple()), - ('anglesecond', '"', '(pi/648000)', tuple(), tuple()), - ('astronomical_unit', 'au', '(149597870700*metre)', tuple(), ('astronomical_units',)), - ('nautical_mile', 'nmi', '(1852*metre)', tuple(), ('nauticalmiles',)), # Note: no short form in source, short form from Wikipedia - ('knot', 'kn', '((1852/3600)*metre/second)', tuple(), ('knots',)), # Note: no short form in source, short form from Wikipedia - ('are', 'a', '(10**2*metre**2)', tuple(), ('ares',)), - ('hectare', 'ha', '(10**4*metre**2)', tuple(), ('hectares',)), - ('bar', 'bar', '(10**5*pascal)', tuple(), ('bars',)), - ('barn', 'b', '(10**(-28)*metre**2)', tuple(), ('barns',)), - ('curie', 'Ci', '(3.7*10**10*becquerel)', ('Curie',), ('curies',)), - ('roentgen', 'R', '(2.58*10**(-4)*kelvin/(kilogram))', ('Roentgen', 'Röntgen'), ('roentgens', 'Roentgens')), - ('rad', 'rad', '(10**(-2)*gray)', tuple(), ('rads',)), - ('rem', 'rem', '(10**(-2)*sievert)', tuple(), ('rems',)), -} - -""" -Imperial (UK) units taken from https://en.wikipedia.org/wiki/Imperial_units and converted to SI base units -""" -set_of_imperial_units = { - ('inch', 'in', '(0.0254*metre)', tuple(), ('inches',)), - ('foot', 'ft', '(0.3048*metre)', tuple(), ('feet',)), - ('yard', 'yd', '(0.9144*metre)', tuple(), ('yards',)), - ('mile', 'mi', '(1609.344*metre)', tuple(), ('miles',)), - ('fluid_ounce', 'fl_oz', '(0.00002841310625*metre**3)', tuple(), ('fluid_ounces',)), - ('gill', 'gi', '(0.0001420653125*metre**3)', tuple(), ('gills',)), - ('pint', 'pt', '(0.00056826*metre**3)', tuple(), ('pints',)), - ('quart', 'qt', '(0.0011365225*metre**3)', tuple(), ('quarts',)), - ('gallon', 'gal', '(0.00454609*metre**3)', tuple(), ('gallons',)), - ('ounce', 'oz', '(28.349523125*gram)', tuple(), ('ounces',)), - ('pound', 'lb', '(0.45359237*kilogram)', tuple(), ('pounds',)), - ('stone', 'st', '(6.35029318*kilogram)', tuple(), tuple()), -} - -""" -Conversion of other units to base SI -""" -non_base_units = set_of_common_units_in_SI | set_of_derived_SI_units_in_SI_base_units | set_of_very_common_units_in_SI | set_of_imperial_units -conversion_to_base_si_units = {x[0]: x[2] for x in non_base_units} -conversion_to_base_si_units.update({x[0]: x[0] for x in set_of_SI_base_unit_dimensions}) -temp_dict = dict() -for item in set_of_SI_prefixes: - temp_dict.update({item[0]+x: "("+item[2]+"*"+conversion_to_base_si_units[x]+")" for x in conversion_to_base_si_units.keys()}) -conversion_to_base_si_units.update(temp_dict) diff --git a/custom_comparison_with_criteria_order_0.md b/custom_comparison_with_criteria_order_0.md deleted file mode 100644 index f35067d..0000000 --- a/custom_comparison_with_criteria_order_0.md +++ /dev/null @@ -1,26 +0,0 @@ -```mermaid -flowchart TD - N_0_0(["2+answer > response
---
Checks if 2+answer > response is true."]) - N_1_0["2+answer > response_TRUE
---
2+answer > response is true."] - N_1_1["2+answer > response_FALSE
---
2+answer > response is false."] - N_1_2["2+answer > response_UNKNOWN
---
2+answer > response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -flowchart TD - N_0_0(["answer <= response
---
Checks if answer <= response is true."]) - N_1_0["answer <= response_TRUE
---
answer <= response is true."] - N_1_1["answer <= response_FALSE
---
answer <= response is false."] - N_1_2["answer <= response_UNKNOWN
---
answer <= response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -``` diff --git a/custom_comparison_with_criteria_order_1.md b/custom_comparison_with_criteria_order_1.md deleted file mode 100644 index f35067d..0000000 --- a/custom_comparison_with_criteria_order_1.md +++ /dev/null @@ -1,26 +0,0 @@ -```mermaid -flowchart TD - N_0_0(["2+answer > response
---
Checks if 2+answer > response is true."]) - N_1_0["2+answer > response_TRUE
---
2+answer > response is true."] - N_1_1["2+answer > response_FALSE
---
2+answer > response is false."] - N_1_2["2+answer > response_UNKNOWN
---
2+answer > response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -flowchart TD - N_0_0(["answer <= response
---
Checks if answer <= response is true."]) - N_1_0["answer <= response_TRUE
---
answer <= response is true."] - N_1_1["answer <= response_FALSE
---
answer <= response is false."] - N_1_2["answer <= response_UNKNOWN
---
answer <= response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -``` diff --git a/physical_quantity_0.md b/physical_quantity_0.md deleted file mode 100644 index 12df3d3..0000000 --- a/physical_quantity_0.md +++ /dev/null @@ -1,41 +0,0 @@ -```mermaid -flowchart TD - N_0_0(["response matches answer
---
Converts QUANTITY: response tags: {} and QUANTITY: answer tags: {} match to a common set of base units and compares their values."]) - N_0_1(["response matches answer_DIMENSION_MATCH
---
Do the dimensions of QUANTITY: response tags: {} and QUANTITY: answer tags: {} match?"]) - N_0_2(["response matches answer_UNIT_COMPARISON
---
Compares how similar the units of QUANTITY: response tags: {} and QUANTITY: answer tags: {} are."]) - N_1_0["response matches answer_TRUE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} match."] - N_1_1["response matches answer_FALSE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} does not match."] - N_1_2["response matches answer_MISSING_VALUE
---
The response is missing a value."] - N_1_3["response matches answer_UNEXPECTED_VALUE
---
The response is expected only have unit(s), no value."] - N_1_4["response matches answer_MISSING_UNIT
---
The response is missing unit(s)."] - N_1_5["response matches answer_UNEXPECTED_UNIT
---
The response is expected to be a value without unit(s)."] - N_1_6["response matches answer_DIMENSION_MATCH_TRUE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} have the same dimensions."] - N_1_7["response matches answer_DIMENSION_MATCH_FALSE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} have different dimensions."] - N_1_8["response matches answer_UNIT_COMPARISON_IDENTICAL
---
The units of quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} are identical."] - N_1_9["response matches answer_UNIT_COMPARISON_SIMILAR
---
The units of quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} are similar."] - N_1_10["response matches answer_UNIT_COMPARISON_PREFIX_IS_LARGE
---
The units of QUANTITY: response tags: {} are at least 1000 times greater than the units of QUANTITY: answer tags: {}."] - N_1_11["response matches answer_UNIT_COMPARISON_PREFIX_IS_SMALL
---
The units of QUANTITY: response tags: {} are at least 1000 times smaller than the units of QUANTITY: answer tags: {}."] - N_2_0{{"END
---
Evaluation completed."}} - N_0_2 --> N_1_9 - N_1_4 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_1 - N_0_1 --> N_1_7 - N_1_5 --> N_2_0 - N_0_1 --> N_1_6 - N_0_0 --> N_1_3 - N_0_2 --> N_1_11 - N_1_6 --> N_2_0 - N_0_0 --> N_1_5 - N_0_2 --> N_1_8 - N_0_2 --> N_1_10 - N_1_11 --> N_2_0 - N_1_1 --> N_0_1 - N_1_3 --> N_2_0 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_1_7 --> N_2_0 - N_0_0 --> N_1_4 - N_1_10 --> N_2_0 - N_1_0 --> N_0_2 -```