Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
@@ -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": <double>}` on success, or `{"success": false, "error_type": <exceptionClassName>, "message": <text>}` 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).
8 changes: 8 additions & 0 deletions docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions libvcell/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +22,8 @@

__all__ = [
"__version__",
"VCellExpressionError",
"evaluate_expression",
"sbml_to_finite_volume_input",
"sbml_to_vcml",
"vcell_infix_to_num_expr_infix",
Expand Down
37 changes: 37 additions & 0 deletions libvcell/_internal/native_calls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ctypes
import json
import logging
from pathlib import Path

Expand All @@ -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
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions libvcell/_internal/native_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 42 additions & 1 deletion libvcell/model_utils.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ copyright: Maintained by <a href="https://virtualcell.com">Florian</a>.

nav:
- Home: index.md
- Design: design.md
- Modules: modules.md
plugins:
- search
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <schaff@uchc.edu>", "Ezequiel Valencia <evalencia@uchc.edu>"]
repository = "https://github.com/virtualcell/libvcell"
Expand Down
70 changes: 65 additions & 5 deletions tests/test_libvcell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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]
Loading
Loading