Skip to content

Commit e6ff3be

Browse files
authored
Merge pull request #53 from virtualcell/moving-boundary-solver
Add moving-boundary solver support
2 parents 3e088f4 + f5c25c7 commit e6ff3be

12 files changed

Lines changed: 695 additions & 32 deletions

File tree

docs/getting-started/installation.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ features live behind optional extras — install the ones you need, or everythin
1515

1616
```bash
1717
pip install "pyvcell[viz]" # plotting / VTK / PyVista
18-
pip install "pyvcell[all]" # full feature set (solver, viz, remote, io, convert, native)
18+
pip install "pyvcell[mb]" # moving-boundary solver (pyvcell-mbsolver)
19+
pip install "pyvcell[all]" # full feature set (solver, viz, remote, io, convert, native, mb)
1920
```
2021

22+
> The moving-boundary solver (`mb`) pulls `pyvcell-mbsolver` plus
23+
> `libvcell >= 0.0.17` (the `native` extra) for moving-boundary input generation.
24+
2125
## Install for development (uv)
2226

2327
```bash

pyproject.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,17 @@ convert = [
5959
"sympy>=1.13.1,<2",
6060
]
6161
native = [
62-
"libvcell (>=0.0.15.3)",
62+
# 0.0.17 ships universal py3-none wheels and includes moving-boundary support.
63+
"libvcell (>=0.0.17)",
64+
]
65+
# Moving-boundary solver. libvcell (the `native` extra) generates the solver
66+
# input; pyvcell-mbsolver runs it.
67+
mb = [
68+
"pyvcell-mbsolver>=1.0.3,<2",
69+
"pyvcell[native]",
6370
]
6471
all = [
65-
"pyvcell[solver,viz,remote,io,convert,native]",
72+
"pyvcell[solver,viz,remote,io,convert,native,mb]",
6673
]
6774

6875
[dependency-groups]
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Thin wrapper around the ``pyvcell_mbsolver`` moving-boundary solver.
2+
3+
The solver reports its solution through observer callbacks rather than an output
4+
file: once per internal time step it hands back the moving front geometry and
5+
the per-element field values. :func:`solve_moving_boundary` runs the solver with
6+
a collecting observer that snapshots those callbacks at the requested output
7+
times into a :class:`~pyvcell.sim_results.moving_boundary_result.MovingBoundaryResult`.
8+
9+
Callers who need full control can use ``pyvcell_mbsolver`` directly; this wrapper
10+
covers the common "run it and give me the trajectory" case.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from collections.abc import Sequence
16+
from os import PathLike
17+
from typing import Any
18+
19+
import numpy as np
20+
import pyvcell_mbsolver as mb
21+
22+
from pyvcell.sim_results.moving_boundary_result import MovingBoundaryFrame, MovingBoundaryResult
23+
24+
# Time tolerance (relative to the output step) for deciding a step has reached an
25+
# output time; the solver advances on its own internal step, not the output step.
26+
_OUTPUT_TIME_TOL = 1e-9
27+
28+
29+
def solve_moving_boundary(
30+
setup_xml_file: PathLike[str] | str,
31+
species_names: Sequence[str],
32+
output_times: Sequence[float] | None = None,
33+
) -> MovingBoundaryResult:
34+
"""Run the moving-boundary solver on a ``MovingBoundarySetup`` XML file.
35+
36+
Args:
37+
setup_xml_file: the ``*_mb.xml`` setup produced by
38+
``libvcell.vcml_to_moving_boundary_input``.
39+
species_names: the volume species, in the order the solver reports
40+
concentrations (i.e. the order of the ``<species>`` entries in the
41+
setup file's ``<physiology>`` block).
42+
output_times: times at which to snapshot a frame. The first solver step
43+
at or past each requested time is kept; the final step is always
44+
kept. When None, every solver step is kept.
45+
46+
Returns:
47+
a :class:`MovingBoundaryResult` with one frame per kept output time.
48+
"""
49+
species = list(species_names)
50+
targets = sorted(float(t) for t in output_times) if output_times is not None else None
51+
result = MovingBoundaryResult(species_names=species)
52+
53+
class _Collector(mb.SimulationObserver):
54+
def __init__(self) -> None:
55+
super().__init__()
56+
self._target_index = 0
57+
self._keep = False
58+
self._time = 0.0
59+
self._front: np.ndarray = np.empty((0, 2), dtype=float)
60+
self._buffer: list[tuple[float, float, int, int, list[float]]] = []
61+
62+
def on_time(self, t: float, generation: int, last: bool, geometry: Any) -> None:
63+
keep = bool(last)
64+
if targets is None:
65+
keep = True
66+
else:
67+
tol = _OUTPUT_TIME_TOL * (targets[-1] - targets[0] + 1.0)
68+
while self._target_index < len(targets) and t + tol >= targets[self._target_index]:
69+
keep = True
70+
self._target_index += 1
71+
self._keep = keep
72+
if keep:
73+
self._time = t
74+
boundary = list(getattr(geometry, "boundary", []) or [])
75+
self._front = np.array(boundary, dtype=float).reshape(-1, 2) if boundary else np.empty((0, 2))
76+
self._buffer = []
77+
78+
def on_element(self, node: Any) -> None:
79+
if not self._keep or node.is_outside:
80+
return
81+
self._buffer.append((
82+
float(node.x),
83+
float(node.y),
84+
int(node.grid_i),
85+
int(node.grid_j),
86+
[float(node.concentration(i)) for i in range(len(species))],
87+
))
88+
89+
def on_iteration_complete(self) -> None:
90+
if not self._keep:
91+
return
92+
self._keep = False
93+
x = np.array([row[0] for row in self._buffer], dtype=float)
94+
y = np.array([row[1] for row in self._buffer], dtype=float)
95+
grid_i = np.array([row[2] for row in self._buffer], dtype=int)
96+
grid_j = np.array([row[3] for row in self._buffer], dtype=int)
97+
concentrations = {
98+
name: np.array([row[4][i] for row in self._buffer], dtype=float) for i, name in enumerate(species)
99+
}
100+
result.frames.append(
101+
MovingBoundaryFrame(
102+
time=self._time,
103+
front=self._front,
104+
x=x,
105+
y=y,
106+
grid_i=grid_i,
107+
grid_j=grid_j,
108+
concentrations=concentrations,
109+
)
110+
)
111+
112+
def on_complete(self) -> None:
113+
pass
114+
115+
solver = mb.MovingBoundarySolver.from_xml(str(setup_xml_file))
116+
solver.add_element_observer(_Collector(), name="pyvcell_collector")
117+
solver.run()
118+
return result
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Result of a moving-boundary simulation.
2+
3+
Unlike the finite-volume solver, the moving-boundary solver does not write a
4+
fixed Cartesian grid. Its solution lives on a mesh that moves with the boundary,
5+
so a result is a time series of frames, where each frame carries the moving
6+
front (a polygon of ``(x, y)`` points) and the field values of the "inside"
7+
mesh elements at that time. This mirrors VCell's own moving-boundary data model
8+
(elements + per-species values on the moving mesh), rather than the
9+
zarr/4-D Cartesian :class:`~pyvcell.sim_results.result.Result`.
10+
11+
These objects hold only numpy arrays and plain Python, so they can be inspected
12+
and post-processed without any heavy optional dependency.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass, field
18+
19+
import numpy as np
20+
21+
22+
@dataclass
23+
class MovingBoundaryFrame:
24+
"""A single output time step of a moving-boundary simulation.
25+
26+
Attributes:
27+
time: the simulation time of this frame.
28+
front: ``(N, 2)`` array of the moving-front boundary polygon ``(x, y)``.
29+
x, y: ``(M,)`` coordinates of the inside mesh elements.
30+
grid_i, grid_j: ``(M,)`` integer grid indices of the inside elements.
31+
concentrations: species name -> ``(M,)`` array of element concentrations,
32+
aligned with ``x``/``y``/``grid_i``/``grid_j``.
33+
"""
34+
35+
time: float
36+
front: np.ndarray
37+
x: np.ndarray
38+
y: np.ndarray
39+
grid_i: np.ndarray
40+
grid_j: np.ndarray
41+
concentrations: dict[str, np.ndarray] = field(default_factory=dict)
42+
43+
44+
@dataclass
45+
class MovingBoundaryResult:
46+
"""The full time series of a moving-boundary simulation.
47+
48+
Attributes:
49+
species_names: the volume species, in the same order the solver reports
50+
concentrations.
51+
frames: the output-time frames, in increasing time order.
52+
"""
53+
54+
species_names: list[str]
55+
frames: list[MovingBoundaryFrame] = field(default_factory=list)
56+
57+
@property
58+
def times(self) -> np.ndarray:
59+
"""The output times as a 1-D array."""
60+
return np.array([frame.time for frame in self.frames], dtype=float)
61+
62+
def front(self, time_index: int) -> np.ndarray:
63+
"""The moving-front polygon ``(N, 2)`` at the given output index."""
64+
return self.frames[time_index].front
65+
66+
def concentrations(self, species_name: str, time_index: int) -> np.ndarray:
67+
"""Inside-element concentrations of ``species_name`` at the given output index."""
68+
return self.frames[time_index].concentrations[species_name]
69+
70+
def __repr__(self) -> str:
71+
n = len(self.frames)
72+
span = f"{self.frames[0].time:g}..{self.frames[-1].time:g}" if n else "empty"
73+
return f"MovingBoundaryResult(species={self.species_names}, frames={n}, t={span})"

pyvcell/vcml/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
Biomodel,
2222
BoundaryType,
2323
Compartment,
24+
FrontVelocity,
2425
Kinetics,
2526
KineticsParameter,
2627
Model,
2728
ModelParameter,
29+
MovingBoundarySolverOptions,
2830
Reaction,
2931
Simulation,
3032
Species,
@@ -68,6 +70,7 @@
6870
if TYPE_CHECKING:
6971
# Heavy names — eager only for type checkers / IDE autocomplete.
7072
from pyvcell._internal.geometry import SegmentedImageGeometry
73+
from pyvcell.sim_results.moving_boundary_result import MovingBoundaryResult
7174
from pyvcell.vcml.field import Field
7275
from pyvcell.vcml.session import SimulationJob, VCellSession
7376
from pyvcell.vcml.utils import (
@@ -90,6 +93,7 @@
9093
write_sbml_file,
9194
write_vcml_file,
9295
)
96+
from pyvcell.vcml.vcml_mb_simulation import simulate_moving_boundary
9397
from pyvcell.vcml.vcml_remote import connect, logout
9498
from pyvcell.vcml.vcml_simulation import cartesian_mesh_from_geometry, simulate
9599
from pyvcell.vcml.vcml_writer import VcmlWriter
@@ -104,6 +108,8 @@
104108
**dict.fromkeys(["SimulationJob", "VCellSession"], "pyvcell.vcml.session"),
105109
**dict.fromkeys(["connect", "logout"], "pyvcell.vcml.vcml_remote"),
106110
**dict.fromkeys(["simulate", "cartesian_mesh_from_geometry"], "pyvcell.vcml.vcml_simulation"),
111+
"simulate_moving_boundary": "pyvcell.vcml.vcml_mb_simulation",
112+
"MovingBoundaryResult": "pyvcell.sim_results.moving_boundary_result",
107113
**dict.fromkeys(["get_workspace_dir", "set_workspace_dir"], "pyvcell.vcml.workspace"),
108114
**dict.fromkeys(
109115
[
@@ -135,6 +141,7 @@
135141
"libvcell": "native",
136142
"pyvcell_fvsolver": "solver",
137143
"fvsolver": "solver",
144+
"pyvcell_mbsolver": "mb",
138145
"vtk": "viz",
139146
"pyvista": "viz",
140147
"matplotlib": "viz",
@@ -192,6 +199,7 @@ def __dir__() -> list[str]:
192199
"Constant",
193200
"Effect",
194201
"Field",
202+
"FrontVelocity",
195203
"Geometry",
196204
"Image",
197205
"JumpCondition",
@@ -206,6 +214,8 @@ def __dir__() -> list[str]:
206214
"MembraneSubDomain",
207215
"Model",
208216
"ModelParameter",
217+
"MovingBoundaryResult",
218+
"MovingBoundarySolverOptions",
209219
"OdeEquation",
210220
"ParticleInitialCount",
211221
"ParticleJumpProcess",
@@ -246,6 +256,7 @@ def __dir__() -> list[str]:
246256
"restore_stdout",
247257
"set_workspace_dir",
248258
"simulate",
259+
"simulate_moving_boundary",
249260
"suppress_stdout",
250261
"to_antimony_str",
251262
"to_sbml_str",

pyvcell/vcml/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
from pyvcell.vcml.models_app import (
1515
CompartmentMapping as CompartmentMapping,
1616
)
17+
from pyvcell.vcml.models_app import (
18+
FrontVelocity as FrontVelocity,
19+
)
20+
from pyvcell.vcml.models_app import (
21+
MovingBoundarySolverOptions as MovingBoundarySolverOptions,
22+
)
1723
from pyvcell.vcml.models_app import (
1824
ReactionMapping as ReactionMapping,
1925
)

0 commit comments

Comments
 (0)