diff --git a/.gitignore b/.gitignore index 914e116..2bccf3b 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,10 @@ ipython_config.py # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock +# uv +# This project uses Poetry, not uv. Ignore stray uv.lock files. +uv.lock + # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..79e0515 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,77 @@ +# Design + +libvcell is a thin **pure-Python layer** over a **GraalVM `native-image` shared library** built from a subset of VCell's Java code. The Python side contains no compiled CPython extension; the native library is loaded and called via `ctypes`. + +## Two layers + +**Python layer** (`libvcell/`) + +- `__init__.py` — public API surface. +- `model_utils.py` / `solver_utils.py` — thin wrappers that instantiate `VCellNativeCalls` and translate its structured results into friendly return values or exceptions. +- `_internal/native_utils.py` — locates and loads the platform shared library (`.so`/`.dylib`/`.dll`) from `libvcell/lib/`, declares each entry point's `ctypes` signature, and provides `IsolateManager` for GraalVM isolate lifecycle. +- `_internal/native_calls.py` — one method per native entry point; marshals arguments, manages the isolate, and parses the returned JSON document into a pydantic model. + +**Native/Java layer** (`vcell-native/`) + +- `Entrypoints.java` — `@CEntryPoint` methods exported as C symbols. Each returns a **JSON document** as a C string (`CCharPointer`) describing success/failure. +- `ModelUtils.java` / `SolverUtils.java` — the actual logic, calling vcell-core from `vcell_submodule`. +- `MainRecorder.java` — exercises each entry point under `native-image-agent` so the build records the required reflection/resource config. + +## FFI conventions + +- **Every call returns a JSON string.** A native entry point never returns a bare number/bool across the boundary; it returns a JSON document (via `createString`, whose memory is tracked in `Entrypoints.allocatedMemory`). The Python side decodes the C string and validates it into a pydantic model (`ReturnValue`, `EvalReturnValue`, …). +- **Errors are data, not crashes.** Entry points catch `Throwable` and encode the failure into the JSON document (a `success:false` flag plus a message and/or error type). The Python wrapper decides whether to return a status tuple or raise. +- **One isolate per call.** `IsolateManager` creates a GraalVM isolate for the duration of a call and tears it down afterward. +- **New entry points are `hasattr`-guarded** in `native_utils.py` so the package still imports against an older shared library that predates the symbol (the Python tests `skipif` on the same check). + +## Entry points + +| Native symbol | Python API | Purpose | +| ------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------- | +| `vcmlToFiniteVolumeInput` / `sbmlToFiniteVolumeInput` | `vcml_to_finite_volume_input` / `sbml_to_finite_volume_input` | write Finite Volume solver input | +| `vcmlToMovingBoundaryInput` | `vcml_to_moving_boundary_input` | write Moving Boundary solver input (`MovingBoundarySetup` XML) | +| `vcmlToSbml` / `sbmlToVcml` / `vcmlToVcml` | `vcml_to_sbml` / `sbml_to_vcml` / `vcml_to_vcml` | model format conversion | +| `vcellInfixToPythonInfix` / `vcellInfixToNumExprInfix` | `vcell_infix_to_python_infix` / `vcell_infix_to_num_expr_infix` | translate VCell infix to other syntaxes | +| `evaluateExpression` | `evaluate_expression` | evaluate a VCell infix expression to a float | + +## `evaluate_expression` + +Evaluates a native-syntax VCell infix expression given a symbol table of values, returning a 64-bit float. + +```python +from libvcell import evaluate_expression, VCellExpressionError + +evaluate_expression("a + b/c", {"a": 10.0, "b": 20.0, "c": 5.0}) # -> 14.0 +evaluate_expression("2 + 3*sqrt(4)", {}) # -> 8.0 + +try: + evaluate_expression("1/c", {"c": 0.0}) +except VCellExpressionError as e: + print(e.error_type) # "DivideByZeroException" +``` + +**Semantics** + +- Any symbol referenced by the expression must be present in the symbol table; extra (unreferenced) symbols are permitted and ignored. +- The value is computed via vcell-core's `Expression`: parse → `bindExpression(new SimpleSymbolTable(names))` → `evaluateVector(values)`. `SimpleSymbolTable` provides the lightweight "dummy" binding — no VCell model or `MathDescription` is required. + +**Native boundary** + +- Input: the infix string and the symbol table serialized as a JSON object of `{name: number}` (`json.dumps` of the dict; integers are accepted). +- Output: a JSON document — `{"success": true, "value": }` on success, or `{"success": false, "error_type": , "message": }` on failure. This is parsed into `EvalReturnValue`. + +**Error handling** + +- `native_calls.evaluate_expression(...)` returns the raw `EvalReturnValue` (branch on `.success`). +- The public `model_utils.evaluate_expression(...)` returns the `float` or raises `VCellExpressionError`, which exposes `.error_type` (the originating Java exception's simple class name) and `.message`. Categories include `ParseException` (syntax), `ExpressionBindingException` (a referenced symbol was not supplied), `DivideByZeroException`, `FunctionDomainException` (e.g. `sqrt(-1)`, `log(0)`), and `IllegalArgumentException` (malformed symbol-table JSON). +- Non-finite results (`Infinity`/`NaN`) cannot be represented in JSON and are surfaced as an error (`error_type = "NonFiniteResultException"`). + +## Adding a new entry point + +1. Implement the logic in `ModelUtils.java` / `SolverUtils.java`. +2. Add a `@CEntryPoint` method in `Entrypoints.java` that returns a JSON document. +3. Exercise it in `MainRecorder.java` (so native-image records its config). +4. Declare its `ctypes` signature (`hasattr`-guarded) in `native_utils.py`. +5. Add a `VCellNativeCalls` method returning a pydantic model in `native_calls.py`. +6. Add the friendly wrapper in `model_utils.py` / `solver_utils.py` and export it from `__init__.py`. +7. Add Java tests (JVM-level) and Python tests (`skipif` on the new symbol until the native library is rebuilt). diff --git a/docs/modules.md b/docs/modules.md index 950dfa7..963ccc9 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -19,3 +19,11 @@ #### vcml_to_finite_volume_input ::: libvcell.solver_utils.vcml_to_finite_volume_input + +#### evaluate_expression + +::: libvcell.model_utils.evaluate_expression + +#### VCellExpressionError + +::: libvcell.model_utils.VCellExpressionError diff --git a/libvcell/__init__.py b/libvcell/__init__.py index 7816d49..f6b53ad 100644 --- a/libvcell/__init__.py +++ b/libvcell/__init__.py @@ -1,6 +1,8 @@ from importlib.metadata import PackageNotFoundError, version from libvcell.model_utils import ( + VCellExpressionError, + evaluate_expression, sbml_to_vcml, vcell_infix_to_num_expr_infix, vcell_infix_to_python_infix, @@ -20,6 +22,8 @@ __all__ = [ "__version__", + "VCellExpressionError", + "evaluate_expression", "sbml_to_finite_volume_input", "sbml_to_vcml", "vcell_infix_to_num_expr_infix", diff --git a/libvcell/_internal/native_calls.py b/libvcell/_internal/native_calls.py index 89d35fb..adf7c69 100644 --- a/libvcell/_internal/native_calls.py +++ b/libvcell/_internal/native_calls.py @@ -1,4 +1,5 @@ import ctypes +import json import logging from pathlib import Path @@ -12,6 +13,20 @@ class ReturnValue(BaseModel): message: str +class EvalReturnValue(BaseModel): + """Structured result of evaluating a VCell expression. + + On success, ``value`` holds the evaluated 64-bit float. On failure, ``error_type`` holds + the originating Java exception's simple class name (e.g. ``DivideByZeroException``) and + ``message`` holds its message. + """ + + success: bool + value: float | None = None + error_type: str | None = None + message: str | None = None + + class MutableString: def __init__(self, value: str): self.value: str = value @@ -22,6 +37,28 @@ def __init__(self) -> None: self.loader = VCellNativeLibraryLoader() self.lib = self.loader.lib + def evaluate_expression(self, expression_infix: str, symbol_table: dict[str, float]) -> EvalReturnValue: + try: + symbol_table_json = json.dumps(symbol_table) + with IsolateManager(self.lib) as isolate_thread: + json_ptr: ctypes.c_char_p = self.lib.evaluateExpression( + isolate_thread, + ctypes.c_char_p(expression_infix.encode("utf-8")), + ctypes.c_char_p(symbol_table_json.encode("utf-8")), + ) + value: bytes | None = ctypes.cast(json_ptr, ctypes.c_char_p).value + if value is None: + logging.error("Failed to evaluate expression") + return EvalReturnValue( + success=False, error_type="NativeError", message="null return from evaluateExpression" + ) + json_str: str = value.decode("utf-8") + # self.lib.freeString(json_ptr) + return EvalReturnValue.model_validate_json(json_data=json_str) + except Exception as e: + logging.exception("Error in evaluate_expression()", exc_info=e) + raise + def vcml_to_finite_volume_input( self, vcml_content: str, simulation_name: str, output_dir_path: Path ) -> ReturnValue: diff --git a/libvcell/_internal/native_utils.py b/libvcell/_internal/native_utils.py index ed7c035..fb4daec 100644 --- a/libvcell/_internal/native_utils.py +++ b/libvcell/_internal/native_utils.py @@ -64,6 +64,16 @@ def _define_entry_points(self) -> None: ctypes.c_char_p, ] + # evaluateExpression is only present in newer native libraries; guard so the package + # still loads against older shared libraries that predate it. + if hasattr(self.lib, "evaluateExpression"): + self.lib.evaluateExpression.restype = ctypes.c_char_p + self.lib.evaluateExpression.argtypes = [ + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_char_p, + ] + self.lib.vcellInfixToPythonInfix.restype = ctypes.c_char_p self.lib.vcellInfixToPythonInfix.argtypes = [ ctypes.c_void_p, diff --git a/libvcell/model_utils.py b/libvcell/model_utils.py index 69dc2a5..e054759 100644 --- a/libvcell/model_utils.py +++ b/libvcell/model_utils.py @@ -1,6 +1,47 @@ from pathlib import Path -from libvcell._internal.native_calls import MutableString, ReturnValue, VCellNativeCalls +from libvcell._internal.native_calls import EvalReturnValue, MutableString, ReturnValue, VCellNativeCalls + + +class VCellExpressionError(Exception): + """Raised when a VCell expression cannot be evaluated. + + Attributes: + error_type: the originating Java exception's simple class name (e.g. ``DivideByZeroException``, + ``ExpressionBindingException``, ``ParseException``, ``FunctionDomainException``), or ``None``. + message: the error message, or ``None``. + """ + + def __init__(self, error_type: str | None, message: str | None) -> None: + self.error_type = error_type + self.message = message + super().__init__(f"{error_type}: {message}") + + +def evaluate_expression(expression_infix: str, symbol_table: dict[str, float]) -> float: + """ + Evaluate a native-syntax VCell infix expression against a table of symbol values. + + Any symbol referenced by the expression must be present in ``symbol_table``; extra + (unreferenced) symbols are permitted and ignored. + + Args: + expression_infix (str): native VCell infix expression string (e.g. ``"a + b/c"``) + symbol_table (dict[str, float]): mapping of symbol name to 64-bit float value + + Returns: + float: the evaluated value + + Raises: + VCellExpressionError: if the expression fails to parse, references an unsupplied symbol, + fails to evaluate (e.g. division by zero, math domain error), or evaluates to a + non-finite value. + """ + native = VCellNativeCalls() + result: EvalReturnValue = native.evaluate_expression(expression_infix, symbol_table) + if not result.success or result.value is None: + raise VCellExpressionError(result.error_type, result.message) + return result.value def vcml_to_sbml( diff --git a/mkdocs.yml b/mkdocs.yml index 30dc30c..3f4b6ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ copyright: Maintained by Florian. nav: - Home: index.md + - Design: design.md - Modules: modules.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index 770604c..10e0878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "libvcell" -version = "0.0.17" +version = "0.0.18" description = "This is a python package which wraps a subset of VCell Java code as a native python package." authors = ["Jim Schaff ", "Ezequiel Valencia "] repository = "https://github.com/virtualcell/libvcell" diff --git a/tests/test_libvcell.py b/tests/test_libvcell.py index bf4ad58..2794c60 100644 --- a/tests/test_libvcell.py +++ b/tests/test_libvcell.py @@ -4,6 +4,8 @@ import pytest from libvcell import ( + VCellExpressionError, + evaluate_expression, sbml_to_finite_volume_input, sbml_to_vcml, vcell_infix_to_num_expr_infix, @@ -16,16 +18,19 @@ from libvcell._internal.native_utils import VCellNativeLibraryLoader -def _native_lib_has_moving_boundary() -> bool: - """The vcmlToMovingBoundaryInput symbol only exists in native libraries rebuilt with - moving-boundary support; older shared libraries won't have it yet. Returns False (skip) - if the native library is missing or too old to even load.""" +def _native_lib_has_symbol(symbol: str) -> bool: + """A given entry-point symbol only exists in native libraries new enough to define it. + Returns False (skip) if the native library is missing or too old to even load.""" try: - return hasattr(VCellNativeLibraryLoader().lib, "vcmlToMovingBoundaryInput") + return hasattr(VCellNativeLibraryLoader().lib, symbol) except Exception: return False +def _native_lib_has_moving_boundary() -> bool: + return _native_lib_has_symbol("vcmlToMovingBoundaryInput") + + def test_vcml_to_finite_volume_input(temp_output_dir: Path, vcml_file_path: Path, vcml_sim_name: str) -> None: vcml_content = vcml_file_path.read_text() success, msg = vcml_to_finite_volume_input( @@ -147,3 +152,58 @@ def test_bad_vcell_infix_through_num_expr_conversion() -> None: success, msg, value = vcell_infix_to_num_expr_infix(vcellInfix) assert success is False assert "Parse Error while parsing expression" in msg + + +_skip_no_evaluate = pytest.mark.skipif( + not _native_lib_has_symbol("evaluateExpression"), + reason="native library not yet rebuilt with evaluateExpression; run scripts/local_build_native.sh", +) + + +@_skip_no_evaluate +def test_evaluate_expression_with_symbols() -> None: + assert evaluate_expression("a + b/c", {"a": 10.0, "b": 20.0, "c": 5.0}) == 14.0 + + +@_skip_no_evaluate +def test_evaluate_expression_constant() -> None: + assert evaluate_expression("2 + 3 * sqrt(4)", {}) == 8.0 + + +@_skip_no_evaluate +def test_evaluate_expression_extra_symbols_ignored() -> None: + assert evaluate_expression("a * 2", {"a": 3.0, "unused": 99.0}) == 6.0 + + +@_skip_no_evaluate +def test_evaluate_expression_unbound_symbol_raises() -> None: + with pytest.raises(VCellExpressionError) as exc_info: + evaluate_expression("a + x", {"a": 1.0}) + assert exc_info.value.error_type == "ExpressionBindingException" + + +@_skip_no_evaluate +def test_evaluate_expression_divide_by_zero_raises() -> None: + with pytest.raises(VCellExpressionError) as exc_info: + evaluate_expression("1 / c", {"c": 0.0}) + assert exc_info.value.error_type == "DivideByZeroException" + + +@_skip_no_evaluate +def test_evaluate_expression_domain_error_raises() -> None: + with pytest.raises(VCellExpressionError) as exc_info: + evaluate_expression("sqrt(a)", {"a": -1.0}) + assert exc_info.value.error_type == "FunctionDomainException" + + +@_skip_no_evaluate +def test_evaluate_expression_parse_error_raises() -> None: + with pytest.raises(VCellExpressionError): + evaluate_expression("1 / + /", {}) + + +@_skip_no_evaluate +def test_evaluate_expression_bad_symbol_table_raises() -> None: + # a symbol value that is not a number -> IllegalArgumentException on the Java side + with pytest.raises(VCellExpressionError): + evaluate_expression("a", {"a": "not a number"}) # type: ignore[dict-item] diff --git a/vcell-native/src/main/java/org/vcell/libvcell/Entrypoints.java b/vcell-native/src/main/java/org/vcell/libvcell/Entrypoints.java index 8acc2b2..0c96d44 100644 --- a/vcell-native/src/main/java/org/vcell/libvcell/Entrypoints.java +++ b/vcell-native/src/main/java/org/vcell/libvcell/Entrypoints.java @@ -10,6 +10,7 @@ import org.graalvm.nativeimage.c.type.CTypeConversion; import org.graalvm.word.UnsignedWord; import org.graalvm.word.WordFactory; +import org.json.simple.JSONObject; import org.json.simple.JSONValue; import java.io.File; @@ -101,6 +102,64 @@ public String toJson() { } } + @CEntryPoint( + name = "evaluateExpression", + documentation = """ + Evaluate a native VCell infix expression using a JSON symbol table. + expression_infix: native VCell infix expression string + symbol_table_json: JSON object mapping symbol name -> numeric value, e.g. {"a":1.0,"b":2.0} + Returns a JSON document: + {"success":true,"value":} on success, or + {"success":false,"error_type":,"message":} on failure""" + ) + public static CCharPointer entrypoint_evaluateExpression( + IsolateThread ignoredThread, + CCharPointer expressionInfixPtr, + CCharPointer symbolTableJsonPtr) { + String json; + try { + String infix = CTypeConversion.toJavaString(expressionInfixPtr); + String symbolTableJson = CTypeConversion.toJavaString(symbolTableJsonPtr); + Map symbolValues = parseSymbolTableJson(symbolTableJson); + double result = evaluateExpression(infix, symbolValues); + if (Double.isFinite(result)) { + json = "{\"success\":true,\"value\":" + result + "}"; + } else { + // JSON cannot represent Infinity/NaN; surface non-finite results as an error. + json = evalErrorJson("NonFiniteResultException", "expression evaluated to a non-finite value: " + result); + } + } catch (Throwable t) { + logger.error("Error evaluating expression", t); + json = evalErrorJson(t.getClass().getSimpleName(), t.getMessage()); + } + logger.info("Returning from evaluateExpression: " + json); + return createString(json); + } + + private static String evalErrorJson(String errorType, String message) { + String safeType = JSONValue.escape(errorType == null ? "" : errorType); + String safeMessage = JSONValue.escape(message == null ? "" : message); + return "{\"success\":false,\"error_type\":\"" + safeType + "\",\"message\":\"" + safeMessage + "\"}"; + } + + // Parse a JSON object of {name: number} into an ordered name->value map. + // JSONValue.parse returns null on malformed input, so validate the shape explicitly. + private static Map parseSymbolTableJson(String symbolTableJson) { + Object parsed = JSONValue.parse(symbolTableJson); + if (!(parsed instanceof JSONObject jsonObject)) { + throw new IllegalArgumentException("symbol table must be a JSON object of {name: number}"); + } + Map symbolValues = new LinkedHashMap<>(); + for (Object key : jsonObject.keySet()) { + Object value = jsonObject.get(key); + if (!(value instanceof Number number)) { + throw new IllegalArgumentException("value for symbol '" + key + "' is not a number"); + } + symbolValues.put((String) key, number.doubleValue()); + } + return symbolValues; + } + @CEntryPoint( name = "vcmlToFiniteVolumeInput", diff --git a/vcell-native/src/main/java/org/vcell/libvcell/MainRecorder.java b/vcell-native/src/main/java/org/vcell/libvcell/MainRecorder.java index 9d9cbf5..712271e 100644 --- a/vcell-native/src/main/java/org/vcell/libvcell/MainRecorder.java +++ b/vcell-native/src/main/java/org/vcell/libvcell/MainRecorder.java @@ -10,6 +10,7 @@ import static org.vcell.libvcell.SolverUtils.sbmlToFiniteVolumeInput; import static org.vcell.libvcell.SolverUtils.vcmlToFiniteVolumeInput; import static org.vcell.libvcell.SolverUtils.vcmlToMovingBoundaryInput; +import static org.vcell.libvcell.ModelUtils.evaluateExpression; import static org.vcell.libvcell.ModelUtils.sbml_to_vcml; import static org.vcell.libvcell.ModelUtils.vcml_to_sbml; import static org.vcell.libvcell.ModelUtils.vcml_to_vcml; @@ -119,6 +120,14 @@ public static void main(String[] args) { logger.warn("Failed to exercise vcmlToMovingBoundaryInput during recording: " + e.getMessage()); } + // exercise expression evaluation so native-image records the parser / SimpleSymbolTable config + try { + double evaluated = evaluateExpression("a + b/c", java.util.Map.of("a", 10.0, "b", 20.0, "c", 5.0)); + logger.info("evaluateExpression recording result: " + evaluated); + } catch (Exception e) { + logger.warn("Failed to exercise evaluateExpression during recording: " + e.getMessage()); + } + // use reflection to load jsbml classes and call their default constructors Class.forName("org.sbml.jsbml.AlgebraicRule").getDeclaredConstructor().newInstance(); Class.forName("org.sbml.jsbml.Annotation").getDeclaredConstructor().newInstance(); diff --git a/vcell-native/src/main/java/org/vcell/libvcell/ModelUtils.java b/vcell-native/src/main/java/org/vcell/libvcell/ModelUtils.java index d200bc5..664d135 100644 --- a/vcell-native/src/main/java/org/vcell/libvcell/ModelUtils.java +++ b/vcell-native/src/main/java/org/vcell/libvcell/ModelUtils.java @@ -13,6 +13,7 @@ import cbit.vcell.mongodb.VCMongoMessage; import cbit.vcell.parser.Expression; import cbit.vcell.parser.ExpressionException; +import cbit.vcell.parser.SimpleSymbolTable; import cbit.vcell.xml.XMLSource; import cbit.vcell.xml.XmlHelper; import cbit.vcell.xml.XmlParseException; @@ -28,6 +29,7 @@ import java.io.InputStream; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Map; public class ModelUtils { @@ -140,4 +142,29 @@ public static String get_numexpr_infix(String vcellInfix) throws ExpressionExcep VCMongoMessage.enabled = false; return new Expression(vcellInfix).infix_NumExpr(); } + + /** + * Evaluate a native VCell infix expression given a table of symbol values. + *

+ * The expression is parsed, bound against a {@link SimpleSymbolTable} built from the supplied + * symbol names, and evaluated. Any symbol referenced by the expression must be present in + * {@code symbolValues}; extra (unreferenced) symbols are permitted and ignored. + * + * @param vcellInfix native VCell infix expression string + * @param symbolValues map of symbol name to value; iteration order defines the binding order + * @return the evaluated value + * @throws ExpressionException on parse errors ({@code ParserException}), unbound symbols + * ({@code ExpressionBindingException}), or evaluation errors + * ({@code DivideByZeroException}, {@code FunctionDomainException}, ...) + */ + public static double evaluateExpression(String vcellInfix, Map symbolValues) throws ExpressionException { + String[] names = symbolValues.keySet().toArray(new String[0]); + double[] values = new double[names.length]; + for (int i = 0; i < names.length; i++) { + values[i] = symbolValues.get(names[i]); + } + Expression exp = new Expression(vcellInfix); + exp.bindExpression(new SimpleSymbolTable(names)); + return exp.evaluateVector(values); + } } diff --git a/vcell-native/src/test/java/org/vcell/libvcell/ModelEntrypointsTest.java b/vcell-native/src/test/java/org/vcell/libvcell/ModelEntrypointsTest.java index 220a87f..b368545 100644 --- a/vcell-native/src/test/java/org/vcell/libvcell/ModelEntrypointsTest.java +++ b/vcell-native/src/test/java/org/vcell/libvcell/ModelEntrypointsTest.java @@ -4,7 +4,10 @@ import cbit.util.xml.VCLoggerException; import cbit.vcell.geometry.GeometryException; import cbit.vcell.mapping.MappingException; +import cbit.vcell.parser.DivideByZeroException; +import cbit.vcell.parser.ExpressionBindingException; import cbit.vcell.parser.ExpressionException; +import cbit.vcell.parser.FunctionDomainException; import cbit.vcell.xml.XmlParseException; import org.junit.jupiter.api.Test; import org.vcell.sbml.SbmlException; @@ -13,7 +16,10 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.vcell.libvcell.ModelUtils.*; import static org.vcell.libvcell.TestUtils.getFileContentsAsString; @@ -114,4 +120,43 @@ public void test_bad_num_expr_infix_attempt(){ System.err.println("test_bad_python_infix_attempt did not throw an exception"); assert(false); } + + @Test + public void test_evaluateExpression_with_symbols() throws ExpressionException { + double result = evaluateExpression("a + b/c", Map.of("a", 10.0, "b", 20.0, "c", 5.0)); + assertEquals(14.0, result, 0.0); + } + + @Test + public void test_evaluateExpression_constant() throws ExpressionException { + double result = evaluateExpression("2 + 3 * sqrt(4)", Map.of()); + assertEquals(8.0, result, 0.0); + } + + @Test + public void test_evaluateExpression_extra_symbols_ignored() throws ExpressionException { + double result = evaluateExpression("a * 2", Map.of("a", 3.0, "unused", 99.0)); + assertEquals(6.0, result, 0.0); + } + + @Test + public void test_evaluateExpression_unbound_symbol() { + // 'x' is referenced but not supplied in the symbol table + assertThrows(ExpressionBindingException.class, () -> evaluateExpression("a + x", Map.of("a", 1.0))); + } + + @Test + public void test_evaluateExpression_parse_error() { + assertThrows(ExpressionException.class, () -> evaluateExpression("1 / + /", Map.of())); + } + + @Test + public void test_evaluateExpression_divide_by_zero() { + assertThrows(DivideByZeroException.class, () -> evaluateExpression("1 / c", Map.of("c", 0.0))); + } + + @Test + public void test_evaluateExpression_domain_error() { + assertThrows(FunctionDomainException.class, () -> evaluateExpression("sqrt(a)", Map.of("a", -1.0))); + } }