Skip to content

Commit 2f83614

Browse files
authored
Merge pull request #25 from virtualcell/add-evaluate-expression
Add evaluate_expression entrypoint
2 parents 6e58289 + 2d9b314 commit 2f83614

14 files changed

Lines changed: 389 additions & 7 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ ipython_config.py
107107
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108108
#poetry.lock
109109

110+
# uv
111+
# This project uses Poetry, not uv. Ignore stray uv.lock files.
112+
uv.lock
113+
110114
# pdm
111115
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112116
#pdm.lock

docs/design.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Design
2+
3+
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`.
4+
5+
## Two layers
6+
7+
**Python layer** (`libvcell/`)
8+
9+
- `__init__.py` — public API surface.
10+
- `model_utils.py` / `solver_utils.py` — thin wrappers that instantiate `VCellNativeCalls` and translate its structured results into friendly return values or exceptions.
11+
- `_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.
12+
- `_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.
13+
14+
**Native/Java layer** (`vcell-native/`)
15+
16+
- `Entrypoints.java``@CEntryPoint` methods exported as C symbols. Each returns a **JSON document** as a C string (`CCharPointer`) describing success/failure.
17+
- `ModelUtils.java` / `SolverUtils.java` — the actual logic, calling vcell-core from `vcell_submodule`.
18+
- `MainRecorder.java` — exercises each entry point under `native-image-agent` so the build records the required reflection/resource config.
19+
20+
## FFI conventions
21+
22+
- **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`, …).
23+
- **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.
24+
- **One isolate per call.** `IsolateManager` creates a GraalVM isolate for the duration of a call and tears it down afterward.
25+
- **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).
26+
27+
## Entry points
28+
29+
| Native symbol | Python API | Purpose |
30+
| ------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------- |
31+
| `vcmlToFiniteVolumeInput` / `sbmlToFiniteVolumeInput` | `vcml_to_finite_volume_input` / `sbml_to_finite_volume_input` | write Finite Volume solver input |
32+
| `vcmlToMovingBoundaryInput` | `vcml_to_moving_boundary_input` | write Moving Boundary solver input (`MovingBoundarySetup` XML) |
33+
| `vcmlToSbml` / `sbmlToVcml` / `vcmlToVcml` | `vcml_to_sbml` / `sbml_to_vcml` / `vcml_to_vcml` | model format conversion |
34+
| `vcellInfixToPythonInfix` / `vcellInfixToNumExprInfix` | `vcell_infix_to_python_infix` / `vcell_infix_to_num_expr_infix` | translate VCell infix to other syntaxes |
35+
| `evaluateExpression` | `evaluate_expression` | evaluate a VCell infix expression to a float |
36+
37+
## `evaluate_expression`
38+
39+
Evaluates a native-syntax VCell infix expression given a symbol table of values, returning a 64-bit float.
40+
41+
```python
42+
from libvcell import evaluate_expression, VCellExpressionError
43+
44+
evaluate_expression("a + b/c", {"a": 10.0, "b": 20.0, "c": 5.0}) # -> 14.0
45+
evaluate_expression("2 + 3*sqrt(4)", {}) # -> 8.0
46+
47+
try:
48+
evaluate_expression("1/c", {"c": 0.0})
49+
except VCellExpressionError as e:
50+
print(e.error_type) # "DivideByZeroException"
51+
```
52+
53+
**Semantics**
54+
55+
- Any symbol referenced by the expression must be present in the symbol table; extra (unreferenced) symbols are permitted and ignored.
56+
- 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.
57+
58+
**Native boundary**
59+
60+
- Input: the infix string and the symbol table serialized as a JSON object of `{name: number}` (`json.dumps` of the dict; integers are accepted).
61+
- Output: a JSON document — `{"success": true, "value": <double>}` on success, or `{"success": false, "error_type": <exceptionClassName>, "message": <text>}` on failure. This is parsed into `EvalReturnValue`.
62+
63+
**Error handling**
64+
65+
- `native_calls.evaluate_expression(...)` returns the raw `EvalReturnValue` (branch on `.success`).
66+
- 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).
67+
- Non-finite results (`Infinity`/`NaN`) cannot be represented in JSON and are surfaced as an error (`error_type = "NonFiniteResultException"`).
68+
69+
## Adding a new entry point
70+
71+
1. Implement the logic in `ModelUtils.java` / `SolverUtils.java`.
72+
2. Add a `@CEntryPoint` method in `Entrypoints.java` that returns a JSON document.
73+
3. Exercise it in `MainRecorder.java` (so native-image records its config).
74+
4. Declare its `ctypes` signature (`hasattr`-guarded) in `native_utils.py`.
75+
5. Add a `VCellNativeCalls` method returning a pydantic model in `native_calls.py`.
76+
6. Add the friendly wrapper in `model_utils.py` / `solver_utils.py` and export it from `__init__.py`.
77+
7. Add Java tests (JVM-level) and Python tests (`skipif` on the new symbol until the native library is rebuilt).

docs/modules.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@
1919
#### vcml_to_finite_volume_input
2020

2121
::: libvcell.solver_utils.vcml_to_finite_volume_input
22+
23+
#### evaluate_expression
24+
25+
::: libvcell.model_utils.evaluate_expression
26+
27+
#### VCellExpressionError
28+
29+
::: libvcell.model_utils.VCellExpressionError

libvcell/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from importlib.metadata import PackageNotFoundError, version
22

33
from libvcell.model_utils import (
4+
VCellExpressionError,
5+
evaluate_expression,
46
sbml_to_vcml,
57
vcell_infix_to_num_expr_infix,
68
vcell_infix_to_python_infix,
@@ -20,6 +22,8 @@
2022

2123
__all__ = [
2224
"__version__",
25+
"VCellExpressionError",
26+
"evaluate_expression",
2327
"sbml_to_finite_volume_input",
2428
"sbml_to_vcml",
2529
"vcell_infix_to_num_expr_infix",

libvcell/_internal/native_calls.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ctypes
2+
import json
23
import logging
34
from pathlib import Path
45

@@ -12,6 +13,20 @@ class ReturnValue(BaseModel):
1213
message: str
1314

1415

16+
class EvalReturnValue(BaseModel):
17+
"""Structured result of evaluating a VCell expression.
18+
19+
On success, ``value`` holds the evaluated 64-bit float. On failure, ``error_type`` holds
20+
the originating Java exception's simple class name (e.g. ``DivideByZeroException``) and
21+
``message`` holds its message.
22+
"""
23+
24+
success: bool
25+
value: float | None = None
26+
error_type: str | None = None
27+
message: str | None = None
28+
29+
1530
class MutableString:
1631
def __init__(self, value: str):
1732
self.value: str = value
@@ -22,6 +37,28 @@ def __init__(self) -> None:
2237
self.loader = VCellNativeLibraryLoader()
2338
self.lib = self.loader.lib
2439

40+
def evaluate_expression(self, expression_infix: str, symbol_table: dict[str, float]) -> EvalReturnValue:
41+
try:
42+
symbol_table_json = json.dumps(symbol_table)
43+
with IsolateManager(self.lib) as isolate_thread:
44+
json_ptr: ctypes.c_char_p = self.lib.evaluateExpression(
45+
isolate_thread,
46+
ctypes.c_char_p(expression_infix.encode("utf-8")),
47+
ctypes.c_char_p(symbol_table_json.encode("utf-8")),
48+
)
49+
value: bytes | None = ctypes.cast(json_ptr, ctypes.c_char_p).value
50+
if value is None:
51+
logging.error("Failed to evaluate expression")
52+
return EvalReturnValue(
53+
success=False, error_type="NativeError", message="null return from evaluateExpression"
54+
)
55+
json_str: str = value.decode("utf-8")
56+
# self.lib.freeString(json_ptr)
57+
return EvalReturnValue.model_validate_json(json_data=json_str)
58+
except Exception as e:
59+
logging.exception("Error in evaluate_expression()", exc_info=e)
60+
raise
61+
2562
def vcml_to_finite_volume_input(
2663
self, vcml_content: str, simulation_name: str, output_dir_path: Path
2764
) -> ReturnValue:

libvcell/_internal/native_utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ def _define_entry_points(self) -> None:
6464
ctypes.c_char_p,
6565
]
6666

67+
# evaluateExpression is only present in newer native libraries; guard so the package
68+
# still loads against older shared libraries that predate it.
69+
if hasattr(self.lib, "evaluateExpression"):
70+
self.lib.evaluateExpression.restype = ctypes.c_char_p
71+
self.lib.evaluateExpression.argtypes = [
72+
ctypes.c_void_p,
73+
ctypes.c_char_p,
74+
ctypes.c_char_p,
75+
]
76+
6777
self.lib.vcellInfixToPythonInfix.restype = ctypes.c_char_p
6878
self.lib.vcellInfixToPythonInfix.argtypes = [
6979
ctypes.c_void_p,

libvcell/model_utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,47 @@
11
from pathlib import Path
22

3-
from libvcell._internal.native_calls import MutableString, ReturnValue, VCellNativeCalls
3+
from libvcell._internal.native_calls import EvalReturnValue, MutableString, ReturnValue, VCellNativeCalls
4+
5+
6+
class VCellExpressionError(Exception):
7+
"""Raised when a VCell expression cannot be evaluated.
8+
9+
Attributes:
10+
error_type: the originating Java exception's simple class name (e.g. ``DivideByZeroException``,
11+
``ExpressionBindingException``, ``ParseException``, ``FunctionDomainException``), or ``None``.
12+
message: the error message, or ``None``.
13+
"""
14+
15+
def __init__(self, error_type: str | None, message: str | None) -> None:
16+
self.error_type = error_type
17+
self.message = message
18+
super().__init__(f"{error_type}: {message}")
19+
20+
21+
def evaluate_expression(expression_infix: str, symbol_table: dict[str, float]) -> float:
22+
"""
23+
Evaluate a native-syntax VCell infix expression against a table of symbol values.
24+
25+
Any symbol referenced by the expression must be present in ``symbol_table``; extra
26+
(unreferenced) symbols are permitted and ignored.
27+
28+
Args:
29+
expression_infix (str): native VCell infix expression string (e.g. ``"a + b/c"``)
30+
symbol_table (dict[str, float]): mapping of symbol name to 64-bit float value
31+
32+
Returns:
33+
float: the evaluated value
34+
35+
Raises:
36+
VCellExpressionError: if the expression fails to parse, references an unsupplied symbol,
37+
fails to evaluate (e.g. division by zero, math domain error), or evaluates to a
38+
non-finite value.
39+
"""
40+
native = VCellNativeCalls()
41+
result: EvalReturnValue = native.evaluate_expression(expression_infix, symbol_table)
42+
if not result.success or result.value is None:
43+
raise VCellExpressionError(result.error_type, result.message)
44+
return result.value
445

546

647
def vcml_to_sbml(

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ copyright: Maintained by <a href="https://virtualcell.com">Florian</a>.
99

1010
nav:
1111
- Home: index.md
12+
- Design: design.md
1213
- Modules: modules.md
1314
plugins:
1415
- search

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "libvcell"
7-
version = "0.0.17"
7+
version = "0.0.18"
88
description = "This is a python package which wraps a subset of VCell Java code as a native python package."
99
authors = ["Jim Schaff <schaff@uchc.edu>", "Ezequiel Valencia <evalencia@uchc.edu>"]
1010
repository = "https://github.com/virtualcell/libvcell"

tests/test_libvcell.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import pytest
55

66
from libvcell import (
7+
VCellExpressionError,
8+
evaluate_expression,
79
sbml_to_finite_volume_input,
810
sbml_to_vcml,
911
vcell_infix_to_num_expr_infix,
@@ -16,16 +18,19 @@
1618
from libvcell._internal.native_utils import VCellNativeLibraryLoader
1719

1820

19-
def _native_lib_has_moving_boundary() -> bool:
20-
"""The vcmlToMovingBoundaryInput symbol only exists in native libraries rebuilt with
21-
moving-boundary support; older shared libraries won't have it yet. Returns False (skip)
22-
if the native library is missing or too old to even load."""
21+
def _native_lib_has_symbol(symbol: str) -> bool:
22+
"""A given entry-point symbol only exists in native libraries new enough to define it.
23+
Returns False (skip) if the native library is missing or too old to even load."""
2324
try:
24-
return hasattr(VCellNativeLibraryLoader().lib, "vcmlToMovingBoundaryInput")
25+
return hasattr(VCellNativeLibraryLoader().lib, symbol)
2526
except Exception:
2627
return False
2728

2829

30+
def _native_lib_has_moving_boundary() -> bool:
31+
return _native_lib_has_symbol("vcmlToMovingBoundaryInput")
32+
33+
2934
def test_vcml_to_finite_volume_input(temp_output_dir: Path, vcml_file_path: Path, vcml_sim_name: str) -> None:
3035
vcml_content = vcml_file_path.read_text()
3136
success, msg = vcml_to_finite_volume_input(
@@ -147,3 +152,58 @@ def test_bad_vcell_infix_through_num_expr_conversion() -> None:
147152
success, msg, value = vcell_infix_to_num_expr_infix(vcellInfix)
148153
assert success is False
149154
assert "Parse Error while parsing expression" in msg
155+
156+
157+
_skip_no_evaluate = pytest.mark.skipif(
158+
not _native_lib_has_symbol("evaluateExpression"),
159+
reason="native library not yet rebuilt with evaluateExpression; run scripts/local_build_native.sh",
160+
)
161+
162+
163+
@_skip_no_evaluate
164+
def test_evaluate_expression_with_symbols() -> None:
165+
assert evaluate_expression("a + b/c", {"a": 10.0, "b": 20.0, "c": 5.0}) == 14.0
166+
167+
168+
@_skip_no_evaluate
169+
def test_evaluate_expression_constant() -> None:
170+
assert evaluate_expression("2 + 3 * sqrt(4)", {}) == 8.0
171+
172+
173+
@_skip_no_evaluate
174+
def test_evaluate_expression_extra_symbols_ignored() -> None:
175+
assert evaluate_expression("a * 2", {"a": 3.0, "unused": 99.0}) == 6.0
176+
177+
178+
@_skip_no_evaluate
179+
def test_evaluate_expression_unbound_symbol_raises() -> None:
180+
with pytest.raises(VCellExpressionError) as exc_info:
181+
evaluate_expression("a + x", {"a": 1.0})
182+
assert exc_info.value.error_type == "ExpressionBindingException"
183+
184+
185+
@_skip_no_evaluate
186+
def test_evaluate_expression_divide_by_zero_raises() -> None:
187+
with pytest.raises(VCellExpressionError) as exc_info:
188+
evaluate_expression("1 / c", {"c": 0.0})
189+
assert exc_info.value.error_type == "DivideByZeroException"
190+
191+
192+
@_skip_no_evaluate
193+
def test_evaluate_expression_domain_error_raises() -> None:
194+
with pytest.raises(VCellExpressionError) as exc_info:
195+
evaluate_expression("sqrt(a)", {"a": -1.0})
196+
assert exc_info.value.error_type == "FunctionDomainException"
197+
198+
199+
@_skip_no_evaluate
200+
def test_evaluate_expression_parse_error_raises() -> None:
201+
with pytest.raises(VCellExpressionError):
202+
evaluate_expression("1 / + /", {})
203+
204+
205+
@_skip_no_evaluate
206+
def test_evaluate_expression_bad_symbol_table_raises() -> None:
207+
# a symbol value that is not a number -> IllegalArgumentException on the Java side
208+
with pytest.raises(VCellExpressionError):
209+
evaluate_expression("a", {"a": "not a number"}) # type: ignore[dict-item]

0 commit comments

Comments
 (0)