From 1768a6371f6ce729f3046cb4ff8f15be4ea5a98b Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:09:43 -0700 Subject: [PATCH 01/20] Allow use of additional keys to store information about subdomains in mesh, include 2d mesh creation in xy plane (removing z coord in gmsh) --- smart/mesh.py | 37 +++++- smart/mesh_tools.py | 287 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 2 deletions(-) diff --git a/smart/mesh.py b/smart/mesh.py index 96774ce..ba93abd 100644 --- a/smart/mesh.py +++ b/smart/mesh.py @@ -152,6 +152,10 @@ class ParentMesh(_Mesh): as an xml or xdmf file containing a vertex mesh function of type double extra_keys (list): list of names of extra keys to load from hdf5 mesh file, specifying other subdomains besides main compartments (optional) + key_for_cells (string): a string specifying the extra key to use as a + mesh function to define cell markers in mesh (optional) + key_for_facets (string): a string specifying the extra key to use as a + mesh function to define facet markers in mesh (optional) """ mesh_filename: str @@ -161,6 +165,8 @@ class ParentMesh(_Mesh): use_partition: bool curvature: d.MeshFunction extra_keys: list = [] + key_for_cells: str = "" + key_for_facets: str = "" def __init__( self, @@ -171,6 +177,8 @@ def __init__( mpi_comm=d.MPI.comm_world, curvature=None, extra_keys=[], + key_for_cells="", + key_for_facets="", ): super().__init__(name) self.use_partition = use_partition @@ -202,6 +210,30 @@ def __init__( # Otherwise just take what we got self.curvature = curvature + # set cells and/or facets according to extra keys if applicable + if key_for_cells != "": + try: + idx = self.extra_keys.index(key_for_cells) + except ValueError: + raise ValueError(f"'{key_for_cells}' does not match an extra key") + assert self.subdomains[idx].dim() == self.dimensionality, ( + f"Mesh function associated with '{key_for_cells}' " + "does not match mesh cell dimension" + ) + self.mf["cells"] = self.subdomains[idx] + logger.info(f"Cell mesh function loaded from key '{key_for_cells}'") + if key_for_facets != "": + try: + idx = self.extra_keys.index(key_for_facets) + except ValueError: + raise ValueError(f"'{key_for_cells}' does not match an extra key") + assert self.subdomains[idx].dim() == self.dimensionality - 1, ( + f"Mesh function associated with '{key_for_facets}' " + "does not match mesh facet dimension" + ) + self.mf["facets"] = self.subdomains[idx] + logger.info(f"Facet mesh function loaded from key '{key_for_facets}'") + def get_mesh_from_id(self, id): "Find the mesh that has the matching id." # find the mesh in that has the matching id @@ -290,8 +322,9 @@ def read_parent_mesh_functions_from_file(self): assert len(self.child_meshes) > 0 # Init mesh functions - self.mf["cells"] = self._read_parent_mesh_function_from_file(volume_dim) - if self.has_surface: + if "cells" not in self.mf.keys(): + self.mf["cells"] = self._read_parent_mesh_function_from_file(volume_dim) + if self.has_surface and "facets" not in self.mf.keys(): self.mf["facets"] = self._read_parent_mesh_function_from_file(surface_dim) # If any cell markers are given as a list we also create mesh diff --git a/smart/mesh_tools.py b/smart/mesh_tools.py index e1a700a..fb04478 100644 --- a/smart/mesh_tools.py +++ b/smart/mesh_tools.py @@ -1300,6 +1300,291 @@ def meshSizeCallback(dim, tag, x, y, z, lc): return (dmesh, mf2, mf3) +def create_2Dcell_xy( + outerExpr: str = "", + innerExpr: str = "", + hEdge: float = 0, + hInnerEdge: float = 0, + interface_marker: int = 12, + outer_marker: int = 10, + inner_tag: int = 2, + outer_tag: int = 1, + comm: MPI.Comm = d.MPI.comm_world, + verbose: bool = False, + half_cell: bool = True, + return_curvature: bool = False, +) -> Tuple[d.Mesh, d.MeshFunction, d.MeshFunction]: + """ + Creates a 2D mesh of a cell profile, with the bounding curve defined in + terms of r and z (e.g. unit circle would be "r**2 + (z-1)**2 - 1) + It is assumed that substrate is present at z = 0, so if the curve extends + below z = 0 , there is a sharp cutoff. + If half_cell = True, only have of the contour is constructed, with a + left zero-flux boundary at r = 0. + Can include one compartment inside another compartment. + Recommended for use with the axisymmetric feature of SMART. + + Args: + outerExpr: String implicitly defining an r-z curve for the outer surface + innerExpr: String implicitly defining an r-z curve for the inner surface + hEdge: maximum mesh size at the outer edge + hInnerEdge: maximum mesh size at the edge + of the inner compartment + interface_marker: The value to mark facets on the interface with + outer_marker: The value to mark facets on edge of the outer ellipse with + inner_tag: The value to mark the inner ellipse surface with + outer_tag: The value to mark the outer ellipse surface with + comm: MPI communicator to create the mesh with + verbose: If true print gmsh output, else skip + half_cell: If true, consider r=0 the symmetry axis for an axisymm shape + Returns: + Tuple (mesh, facet_marker, cell_marker) + """ + import gmsh + + if outerExpr == "": + ValueError("Outer surface is not defined") + + if return_curvature: + # create full mesh for curvature analysis and then map onto half mesh + # if half_cell_with_curvature is True + half_cell_with_curvature = half_cell + half_cell = False + + rValsOuter, zValsOuter = implicit_curve(outerExpr) + + if not innerExpr == "": + rValsInner, zValsInner = implicit_curve(innerExpr) + zMid = np.mean(zValsInner) + ROuterVec = np.sqrt(rValsOuter**2 + (zValsOuter - zMid) ** 2) + RInnerVec = np.sqrt(rValsInner**2 + (zValsInner - zMid) ** 2) + maxOuterDim = max(ROuterVec) + maxInnerDim = max(RInnerVec) + else: + zMid = np.mean(zValsOuter) + ROuterVec = np.sqrt(rValsOuter**2 + (zValsOuter - zMid) ** 2) + maxOuterDim = max(ROuterVec) + if np.isclose(hEdge, 0): + hEdge = 0.1 * maxOuterDim + if np.isclose(hInnerEdge, 0): + hInnerEdge = 0.2 * maxOuterDim if innerExpr == "" else 0.2 * maxInnerDim + # Create the 2D mesh using gmsh + gmsh.initialize() + gmsh.option.setNumber("General.Terminal", int(verbose)) + gmsh.model.add("2DCell") + # first add outer body and revolve + outer_tag_list = [] + for i in range(len(rValsOuter)): + cur_tag = gmsh.model.occ.add_point(rValsOuter[i], zValsOuter[i], 0.0) + outer_tag_list.append(cur_tag) + outer_spline_tag = gmsh.model.occ.add_spline(outer_tag_list) + if not half_cell: + outer_tag_list2 = [] + for i in range(len(rValsOuter)): + cur_tag = gmsh.model.occ.add_point(-rValsOuter[i], zValsOuter[i], 0.0) + outer_tag_list2.append(cur_tag) + outer_spline_tag2 = gmsh.model.occ.add_spline(outer_tag_list2) + if np.isclose(zValsOuter[-1], 0): # then include substrate at z=0 + if half_cell: + origin_tag = gmsh.model.occ.add_point(0, 0, 0) + symm_axis_tag = gmsh.model.occ.add_line(origin_tag, outer_tag_list[0]) + bottom_tag = gmsh.model.occ.add_line(origin_tag, outer_tag_list[-1]) + outer_loop_tag = gmsh.model.occ.add_curve_loop( + [outer_spline_tag, bottom_tag, symm_axis_tag] + ) + else: + bottom_tag = gmsh.model.occ.add_line(outer_tag_list[-1], outer_tag_list2[-1]) + outer_loop_tag = gmsh.model.occ.add_curve_loop( + [outer_spline_tag, outer_spline_tag2, bottom_tag] + ) + else: + if half_cell: + symm_axis_tag = gmsh.model.occ.add_line(outer_tag_list[0], outer_tag_list[-1]) + outer_loop_tag = gmsh.model.occ.add_curve_loop([outer_spline_tag, symm_axis_tag]) + else: + outer_loop_tag = gmsh.model.occ.add_curve_loop([outer_spline_tag, outer_spline_tag2]) + cell_plane_tag = gmsh.model.occ.add_plane_surface([outer_loop_tag]) + + if innerExpr == "": + # No inner shape in this case + gmsh.model.occ.synchronize() + gmsh.model.add_physical_group(2, [cell_plane_tag], tag=outer_tag) + facets = gmsh.model.getBoundary([(2, cell_plane_tag)]) + facet_tag_list = [] + for i in range(len(facets)): + facet_tag_list.append(facets[i][1]) + if half_cell: # if half, set symmetry axis to 0 (no flux) + xmin, ymin, zmin = (-hInnerEdge / 10, -1, -hInnerEdge / 10) + xmax, ymax, zmax = (hInnerEdge / 10, max(zValsOuter) + 1, hInnerEdge / 10) + all_symm_bound = gmsh.model.occ.get_entities_in_bounding_box( + xmin, ymin, zmin, xmax, ymax, zmax, dim=1 + ) + symm_bound_markers = [] + for i in range(len(all_symm_bound)): + symm_bound_markers.append(all_symm_bound[i][1]) + gmsh.model.add_physical_group(1, symm_bound_markers, tag=0) + gmsh.model.add_physical_group(1, facet_tag_list, tag=outer_marker) + else: + # Add inner shape + inner_tag_list = [] + for i in range(len(rValsInner)): + cur_tag = gmsh.model.occ.add_point(rValsInner[i], zValsInner[i], 0.0) + inner_tag_list.append(cur_tag) + inner_spline_tag = gmsh.model.occ.add_spline(inner_tag_list) + if half_cell: + symm_inner_tag = gmsh.model.occ.add_line(inner_tag_list[0], inner_tag_list[-1]) + inner_loop_tag = gmsh.model.occ.add_curve_loop([inner_spline_tag, symm_inner_tag]) + else: + inner_tag_list2 = [] + for i in range(len(rValsInner)): + cur_tag = gmsh.model.occ.add_point(-rValsInner[i], zValsInner[i], 0.0) + inner_tag_list2.append(cur_tag) + inner_spline_tag2 = gmsh.model.occ.add_spline(inner_tag_list2) + inner_loop_tag = gmsh.model.occ.add_curve_loop([inner_spline_tag, inner_spline_tag2]) + inner_plane_tag = gmsh.model.occ.add_plane_surface([inner_loop_tag]) + cell_plane_list = [cell_plane_tag] + inner_plane_list = [inner_plane_tag] + + outer_volume = [] + inner_volume = [] + all_volumes = [] + inner_marker_list = [] + outer_marker_list = [] + for i in range(len(cell_plane_list)): + cell_plane_tag = cell_plane_list[i] + inner_plane_tag = inner_plane_list[i] + # Create interface between 2 objects + two_shapes, (outer_shape_map, inner_shape_map) = gmsh.model.occ.fragment( + [(2, cell_plane_tag)], [(2, inner_plane_tag)] + ) + gmsh.model.occ.synchronize() + + # Get the outer boundary + outer_shell = gmsh.model.getBoundary(two_shapes, oriented=False) + for i in range(len(outer_shell)): + outer_marker_list.append(outer_shell[i][1]) + # Get the inner boundary + inner_shell = gmsh.model.getBoundary(inner_shape_map, oriented=False) + for i in range(len(inner_shell)): + inner_marker_list.append(inner_shell[i][1]) + for tag in outer_shape_map: + all_volumes.append(tag[1]) + for tag in inner_shape_map: + inner_volume.append(tag[1]) + + for vol in all_volumes: + if vol not in inner_volume: + outer_volume.append(vol) + + # Add physical markers for facets + if half_cell: # if half, set symmetry axis to 0 (no flux) + xmin, ymin, zmin = (-hInnerEdge / 10, -1, -hInnerEdge / 10) + xmax, ymax, zmax = (hInnerEdge / 10, max(zValsOuter) + 1, hInnerEdge / 10) + all_symm_bound = gmsh.model.occ.get_entities_in_bounding_box( + xmin, ymin, zmin, xmax, ymax, zmax, dim=1 + ) + symm_bound_markers = [] + for i in range(len(all_symm_bound)): + symm_bound_markers.append(all_symm_bound[i][1]) + # note that this first call sets the symmetry axis to tag 0 and + # this is not overwritten by the next calls to add_physical_group + gmsh.model.add_physical_group(1, symm_bound_markers, tag=0) + gmsh.model.add_physical_group(1, outer_marker_list, tag=outer_marker) + gmsh.model.add_physical_group(1, inner_marker_list, tag=interface_marker) + + # Physical markers for "volumes" + gmsh.model.add_physical_group(2, outer_volume, tag=outer_tag) + gmsh.model.add_physical_group(2, inner_volume, tag=inner_tag) + + def meshSizeCallback(dim, tag, x, y, z, lc): + # mesh length is hEdge at the PM and hInnerEdge at the inner membrane + # between these, the value is interpolated based on the relative distance + # between the two membranes. + # Inside the inner shape, the value is interpolated between hInnerEdge + # and lc3, where lc3 = max(hInnerEdge, 0.2*maxInnerDim) + # if innerRad=0, then the mesh length is interpolated between + # hEdge at the PM and 0.2*maxOuterDim in the center + rCur = np.sqrt(x**2 + z**2) + RCur = np.sqrt(rCur**2 + (y - zMid) ** 2) + outer_dist = np.sqrt((rCur - rValsOuter) ** 2 + (z - zValsOuter) ** 2) + np.append(outer_dist, z) # include the distance from the substrate + dist_to_outer = min(outer_dist) + if innerExpr == "": + lc3 = 0.2 * maxOuterDim + dist_to_inner = RCur + in_outer = True + else: + inner_dist = np.sqrt((rCur - rValsInner) ** 2 + (y - zValsInner) ** 2) + dist_to_inner = min(inner_dist) + inner_idx = np.argmin(inner_dist) + inner_rad = RInnerVec[inner_idx] + R_rel_inner = RCur / inner_rad + lc3 = max(hInnerEdge, 0.2 * maxInnerDim) + in_outer = R_rel_inner > 1 + lc1 = hEdge + lc2 = hInnerEdge + if in_outer: + lcTest = lc1 + (lc2 - lc1) * (dist_to_outer) / (dist_to_inner + dist_to_outer) + else: + lcTest = lc2 + (lc3 - lc2) * (1 - R_rel_inner) + return lcTest + + gmsh.model.mesh.setSizeCallback(meshSizeCallback) + # set off the other options for mesh size determination + gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 0) + gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 0) + gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0) + # this changes the algorithm from Frontal-Delaunay to Delaunay, + # which may provide better results when there are larger gradients in mesh size + gmsh.option.setNumber("Mesh.Algorithm", 5) + + gmsh.model.mesh.generate(2) + rank = MPI.COMM_WORLD.rank + tmp_folder = pathlib.Path(f"tmp_2DCell_{rank}") + tmp_folder.mkdir(exist_ok=True) + gmsh_file = tmp_folder / "2DCell.msh" + gmsh.write(str(gmsh_file)) + gmsh.finalize() + + # return dolfin mesh of max dimension (parent mesh) and marker functions mf2 and mf3 + dmesh, mf2, mf3 = gmsh_to_dolfin(str(gmsh_file), tmp_folder, 2, comm) + # remove tmp mesh and tmp folder + gmsh_file.unlink(missing_ok=False) + tmp_folder.rmdir() + # return dolfin mesh, mf2 (2d tags) and mf3 (3d tags) + if return_curvature: + if innerExpr == "": + facet_list = [outer_marker] + cell_list = [outer_tag] + else: + facet_list = [outer_marker, interface_marker] + cell_list = [outer_tag, inner_tag] + if half_cell_with_curvature: # will likely not work in parallel... + dmesh_half, mf2_half, mf3_half = create_2Dcell( + outerExpr, + innerExpr, + hEdge, + hInnerEdge, + interface_marker, + outer_marker, + inner_tag, + outer_tag, + comm, + verbose, + half_cell=True, + return_curvature=False, + ) + kappa_mf = compute_curvature( + dmesh, mf2, mf3, facet_list, cell_list, half_mesh_data=(dmesh_half, mf2_half) + ) + (dmesh, mf2, mf3) = (dmesh_half, mf2_half, mf3_half) + else: + kappa_mf = compute_curvature(dmesh, mf2, mf3, facet_list, cell_list) + return (dmesh, mf2, mf3, kappa_mf) + else: + return (dmesh, mf2, mf3) + + def gmsh_to_dolfin( gmsh_file_name: str, tmp_folder: pathlib.Path = pathlib.Path("tmp_folder"), @@ -1340,6 +1625,8 @@ def gmsh_to_dolfin( # convert cell mesh cells = mesh_in.get_cells_type(cell_type) cell_data = mesh_in.get_cell_data("gmsh:physical", cell_type) # extract values of tags + if dimension == 2 and np.all(mesh_in.points[:, 2] == 0): + mesh_in.points = mesh_in.points[:, :2] # then prune z values out_mesh_cell = meshio.Mesh( points=mesh_in.points, cells={cell_type: cells}, From 10aef259a3e34919fa37255fa722930fdbeeb634 Mon Sep 17 00:00:00 2001 From: emmetfrancis Date: Mon, 23 Jun 2025 22:30:24 -0700 Subject: [PATCH 02/20] New feature to allow parameters with assigned values over a region of the mesh, introduce new version of example 5 to illustrate SOCE --- examples/example5/example5_withSOCE.ipynb | 837 ++++++++++++++++++++++ examples/example5/model_cur.pkl | Bin 0 -> 3487 bytes smart/model.py | 10 + smart/model_assembly.py | 34 +- 4 files changed, 880 insertions(+), 1 deletion(-) create mode 100644 examples/example5/example5_withSOCE.ipynb create mode 100644 examples/example5/model_cur.pkl diff --git a/examples/example5/example5_withSOCE.ipynb b/examples/example5/example5_withSOCE.ipynb new file mode 100644 index 0000000..d1405bd --- /dev/null +++ b/examples/example5/example5_withSOCE.ipynb @@ -0,0 +1,837 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "f65f18d7", + "metadata": {}, + "source": [ + "# Example 5: Generic cell signaling system in 3D, illustrating a SOCE-like effect\n", + "\n", + "This example is slightly altered from the normal version of example 5 to illustrate the role of store-operated calcium entry (SOCE) accompanying calcium release from an interior compartment.\n", + "\n", + "Geometry is divided into 4 domains; two volumes, and two surfaces:\n", + "- cytosol (Cyto): $\\Omega_{Cyto}$\n", + "- endoplasmic reticulum volume (ER): $\\Omega_{ER}$\n", + "- plasma membrane (PM): $\\Gamma_{PM}$\n", + "- ER membrane (ERm): $\\Gamma_{ERm}$\n", + "\n", + "For simplicity, here we consider a \"cube-within-a-cube\" geometry, in which the smaller\n", + "inner cube represents a section of ER and one face of the outer cube ($x=0$) represents the PM. The other\n", + "faces of the outer cube are treated as no flux boundaries. The space outside\n", + "the inner cube but inside the outer cube is classified as cytosol.\n", + "\n", + "There are three function-spaces on these three domains:\n", + "\n", + "$$\n", + "u^{Cyto} = [A, B] \\quad \\text{on} \\quad \\Omega^{Cyto}\\\\\n", + "u^{ER} = [AER] \\quad \\text{on} \\quad \\Omega^{ER}\\\\\n", + "v^{ERm} = [R, Ro] \\quad \\text{on} \\quad \\Gamma^{ERm}\n", + "$$\n", + "\n", + "In words, this says that species A and B reside in the cytosolic volume, \n", + "species AER corresponds to an amount of species A that lives in the ER volume,\n", + "and species R (closed receptor/channel) and Ro (open receptor/channel) reside on the ER membrane.\n", + "\n", + "In this model, species B reacts with a receptor/channel, R, on the ER membrane, causing it to open (change state from R->Ro), \n", + "allowing species A to flow out of the ER and into the cytosol. \n", + "Note that this is roughly similar to an IP3 pulse at the PM, leading to Ca2+ release from the ER,\n", + "where, by analogy, species B is similar to IP3 and species A is similar to Ca2+. A more comprehensive\n", + "model of Ca2+ dynamics in particular is implemented in Example 6.\n", + "\n", + "Here, we also introduce a flux analogous to SOCE, wherein the flux of A through the PM depends on depletion of A from the ER.\n", + "For simplicity, we scale this flux with the distance between the PM and ER ($d_{PM-ER}$) and the concentration of A at closest point of ER to the PM ($A_{ER,close}$):\n", + "\n", + "$$\n", + "J_{SOCE} = \\frac{d_0}{d_{PM-ER} + d_0} \\frac{1}{1 + \\left(\\frac{A_{ER}}{A_{ref}}\\right)^4},\n", + "$$\n", + "\n", + "where $d_0$ is a characteristic length scale controlling the likelihood of STIM-Orai binding and $A_{ref}$ governs the ER calcium depletion required to trigger SOCE." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5edff5d", + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib import pyplot as plt\n", + "import matplotlib.image as mpimg\n", + "img_A = mpimg.imread('example5-diagram.png')\n", + "plt.imshow(img_A)\n", + "plt.axis('off')" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "f543d75e", + "metadata": {}, + "source": [ + "As specified in our [mathematical documentation](https://rangamanilabucsd.github.io/smart/docs/math.html), assuming diffusive transport, the PDE and boundary condition for each of these volumetric species takes the form:\n", + "\n", + "$$\n", + "\\partial_t u_i^m - \\nabla \\cdot ( D_i^m \\nabla (u_i^m) ) - f_i^m(u_i^m) = 0 \\qquad \\text{ in } \\Omega^m\\\\\n", + "D_i \\nabla u_i^m \\cdot n^m - R_i^{q} (u^m, u^n, v^q) = 0 \\qquad \\text{ on } \\Gamma^{q}\n", + "$$\n", + "\n", + "and the surface species take the form:\n", + "\n", + "$$\n", + "\\partial_t v_i^q - \\nabla_S \\cdot (D_i^q \\nabla_S v ) - g_i^q ( u^m, u^n, v^q ) = 0 \\qquad \\text{ on } \\Gamma^{q}\\\\\n", + "D_i \\nabla v_i^q \\cdot n^q = 0 \\qquad \\text{ on } \\partial\\Gamma^{q}\n", + "$$\n", + "\n", + "Our reaction terms and boundary conditions are chosen according to the system described above. For the purposes of this simplified example we use linear mass action in all reaction terms except SOCE. Explicitly writing out this system of PDEs, we have:\n", + "\n", + "\\begin{align}\n", + " \\partial_t u_B^{Cyto} - D_B^{Cyto} \\nabla^2 u_B^{Cyto} + k_{2f} u_B^{Cyto} &= 0 \\qquad \\text{ in } \\Omega^{Cyto}\\\\\n", + " D_B^{Cyto} \\nabla u_B^{Cyto} \\cdot n^{Cyto} + j_1[t] &= 0 \\qquad \\text{ on } \\Gamma^{PM} \\nonumber\\\\\n", + " D_B^{Cyto} \\nabla u_B^{Cyto} \\cdot n^{Cyto} + k_{3f} v_R^{ERm} u_B^{Cyto} - k_{3r} v_{Ro}^{ERm} &= 0 \\qquad \\text{ on } \\Gamma^{ERm} \\nonumber\\\\\n", + " \\nonumber \\\\\n", + " \\partial_t u_A^{Cyto} - D_A^{Cyto} \\nabla^2 u_A^{Cyto} &= 0 \\qquad \\text{ in } \\Omega^{Cyto}\\\\\n", + " D_A^{Cyto} \\nabla u_A^{Cyto} \\cdot n^{Cyto} - J_{SOCE} &= 0 \\qquad \\text{ on } \\Gamma^{PM} \\nonumber\\\\\n", + " D_A^{Cyto} \\nabla u_A^{Cyto} \\cdot n^{Cyto} - k_{4,Vmax} v_{Ro}^{ERm} (u_{AER}^{ER} - u_A^{Cyto}) &= 0 \\qquad \\text{ on } \\Gamma^{ERm} \\nonumber\\\\\n", + " \\nonumber \\\\\n", + " \\partial_t u_{AER}^{ER} - D_{AER}^{ER} \\nabla^2 u_{AER}^{ER} &= 0 \\qquad \\text{ in } \\Omega^{ER}\\\\\n", + " D_{AER}^{ER} \\nabla u_{AER}^{ER} \\cdot n^{ER} + k_{4,Vmax} v_{Ro}^{ERm} (u_{AER}^{ER} - u_A^{Cyto}) &= 0 \\qquad \\text{ on } \\Gamma^{ERm} \\nonumber\\\\\n", + " \\nonumber \\\\\n", + " \\partial_t v_{R}^{ERm} - D_{R}^{ERm} \\nabla^2 v_{R}^{ERm} - \n", + " k_{3f} v_R^{ERm} u_B^{Cyto} + k_{3r} v_{Ro}^{ERm} &= 0 \\qquad \\text{ on } \\Gamma^{ERm}\\\\\n", + " \\nonumber \\\\\n", + " \\partial_t v_{Ro}^{ERm} - D_{Ro}^{ERm} \\nabla^2 v_{Ro}^{ERm} +\n", + " k_{3f} v_R^{ERm} u_B^{Cyto} - k_{3r} v_{Ro}^{ERm} &= 0 \\qquad \\text{ on } \\Gamma^{ERm}\\\\\n", + "\\end{align}" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "28f77cbf", + "metadata": {}, + "source": [ + "## Code imports and initialization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc398816", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "\n", + "import dolfin as d\n", + "import sympy as sym\n", + "import numpy as np\n", + "import pathlib\n", + "import gmsh # must be imported before pyvista if dolfin is imported first\n", + "\n", + "from smart import config, mesh, model, mesh_tools, visualization\n", + "from smart.model_assembly import (\n", + " Compartment,\n", + " Parameter,\n", + " Reaction,\n", + " Species,\n", + " SpeciesContainer,\n", + " ParameterContainer,\n", + " CompartmentContainer,\n", + " ReactionContainer,\n", + ")\n", + "from smart.units import unit" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "c8650536", + "metadata": {}, + "source": [ + "We will set the logging level to `INFO`. This will display some output during the simulation. If you want to get even more output you could set the logging level to `DEBUG`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ed0899a", + "metadata": {}, + "outputs": [], + "source": [ + "logger = logging.getLogger(\"smart\")\n", + "logger.setLevel(logging.INFO)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "defc1095", + "metadata": {}, + "source": [ + "Futhermore, you could also save the logs to a file by attaching a file handler to the logger as follows.\n", + "\n", + "```\n", + "file_handler = logging.FileHandler(\"filename.log\")\n", + "file_handler.setFormatter(logging.Formatter(smart.config.base_format))\n", + "logger.addHandler(file_handler)\n", + "```" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "95b9d865", + "metadata": {}, + "source": [ + "First, we define the various units for the inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f4023cf", + "metadata": {}, + "outputs": [], + "source": [ + "# Aliases - base units\n", + "uM = unit.uM\n", + "um = unit.um\n", + "molecule = unit.molecule\n", + "sec = unit.sec\n", + "# Aliases - units used in model\n", + "D_unit = um**2 / sec\n", + "flux_unit = molecule / (um**2 * sec)\n", + "vol_unit = uM\n", + "surf_unit = molecule / um**2" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "46582d26", + "metadata": {}, + "source": [ + "## Generate model\n", + "Next we generate the model described in the equations above.\n", + "\n", + "### Compartments\n", + "As described above, the three compartments are the cytosol (\"Cyto\"), the plasma membrane (\"PM\"), the ER membrane (\"ERm\"), and the ER interior volume (\"ER\").\n", + "\n", + "Note that, as shown, we can also specify nonadjacency for compartments; this is not strictly necessary, but will generally speed up the simulations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2f34b3c", + "metadata": {}, + "outputs": [], + "source": [ + "Cyto = Compartment(\"Cyto\", 3, um, 1)\n", + "PM = Compartment(\"PM\", 2, um, 10)\n", + "ER = Compartment(\"ER\", 3, um, 2)\n", + "ERm = Compartment(\"ERm\", 2, um, 12)\n", + "PM.specify_nonadjacency(['ERm', 'ER'])\n", + "ERm.specify_nonadjacency(['PM'])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "d93ce862", + "metadata": {}, + "source": [ + "Initialize a compartment container and add the 4 compartments to it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "701577e8", + "metadata": {}, + "outputs": [], + "source": [ + "cc = CompartmentContainer()\n", + "cc.add([ERm, ER, PM, Cyto])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "0a6acf0b", + "metadata": {}, + "source": [ + "### Species\n", + "In this case, we have 5 species across 3 different compartments. We define each in turn:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d991278", + "metadata": {}, + "outputs": [], + "source": [ + "A = Species(\"A\", 0.01, vol_unit, 1.0, D_unit, \"Cyto\")\n", + "B = Species(\"B\", 0.0, vol_unit, 1.0, D_unit, \"Cyto\")\n", + "AER = Species(\"AER\", 200.0, vol_unit, 5.0, D_unit, \"ER\")\n", + "# Uniform initial condition of R\n", + "R1 = Species(\"R1\", 1.0, surf_unit, 0.02, D_unit, \"ERm\")\n", + "R1o = Species(\"R1o\", 0.0, surf_unit, 0.02, D_unit, \"ERm\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "b60826cc", + "metadata": {}, + "source": [ + "Create species container and add the 5 species objects to it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e86bebaf", + "metadata": {}, + "outputs": [], + "source": [ + "sc = SpeciesContainer()\n", + "sc.add([R1o, R1, AER, B, A])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "af900a73", + "metadata": {}, + "source": [ + "### Parameters and Reactions\n", + "\n", + "Parameters and reactions are generally defined together, although the order does not strictly matter. We define them in turn as follows:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2120ac6", + "metadata": {}, + "outputs": [], + "source": [ + "# Degradation of B in the cytosol\n", + "k2f = Parameter(\"k2f\", 10, 1 / sec)\n", + "r2 = Reaction(\n", + " \"r2\", [\"B\"], [], param_map={\"on\": \"k2f\"}, reaction_type=\"mass_action_forward\"\n", + ")\n", + "\n", + "# Activating receptors on ERm with B\n", + "k3f = Parameter(\"k3f\", 100, 1 / (uM * sec))\n", + "k3r = Parameter(\"k3r\", 100, 1 / sec)\n", + "r3 = Reaction(\"r3\", [\"B\", \"R1\"], [\"R1o\"], {\"on\": \"k3f\", \"off\": \"k3r\"})\n", + "# Release of A from ERm to cytosol\n", + "k4Vmax = Parameter(\"k4Vmax\", 2000, 1 / (uM * sec))\n", + "r4 = Reaction(\n", + " \"r4\",\n", + " [\"AER\"],\n", + " [\"A\"],\n", + " param_map={\"Vmax\": \"k4Vmax\"},\n", + " species_map={\"R1o\": \"R1o\", \"uER\": \"AER\", \"u\": \"A\"},\n", + " eqn_f_str=\"Vmax*R1o*(uER-u)\",\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "7fdd58b8", + "metadata": {}, + "source": [ + "We define an additional reaction as the time-dependent production of species B at the plasma membrane. In this case, we define a pulse-type function as the derivative of an arctan function. Note that this is useful because we can provide an expression to use for pre-integration.\n", + "\n", + "$$\n", + "j_{int}[t] = V_{max} \\arctan\\left({m (t - t_0)}\\right)\\\\\n", + "j_1[t] = \\frac{dj_{int}[t]}{dt} = \\frac{m V_{max}}{1 + m^2 (t-t_0)^2}\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51abf270", + "metadata": {}, + "outputs": [], + "source": [ + "Vmax, t0, m = 500, 0.1, 200\n", + "t = sym.symbols(\"t\")\n", + "pulseI = Vmax * sym.atan(m * (t - t0))\n", + "pulse = sym.diff(pulseI, t)\n", + "j1pulse = Parameter.from_expression(\n", + " \"j1pulse\", pulse, flux_unit, use_preintegration=True, preint_sym_expr=pulseI\n", + ")\n", + "r1 = Reaction(\n", + " \"r1\",\n", + " [],\n", + " [\"B\"],\n", + " param_map={\"J\": \"j1pulse\"},\n", + " eqn_f_str=\"J\",\n", + " explicit_restriction_to_domain=\"PM\",\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "b5e218b0", + "metadata": {}, + "source": [ + "We can plot the time-dependent input by converting the sympy expression to a numpy function using lambdify." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a0829a6b", + "metadata": {}, + "outputs": [], + "source": [ + "from sympy.utilities.lambdify import lambdify\n", + "pulse_func = lambdify(t, pulse, 'numpy') # returns a numpy-ready function\n", + "tArray = np.linspace(0, 1, 100)\n", + "pulse_vals = pulse_func(tArray)\n", + "plt.plot(tArray, pulse_vals)" + ] + }, + { + "cell_type": "markdown", + "id": "55c27105", + "metadata": {}, + "source": [ + "Finally, we define the store-operated calcium (or here, A) influx. \n", + "This is not immediately straightforward, as a flux at the PM depends on concentration of A in the ER.\n", + "One option would be to explicitly introduce contacts between the ER and PM, but this would imply direct entry of calcium into the ER through Orai1.\n", + "Instead, we assume the likelihood of STIM-Orai1 binding scales with PM-ER distance (if they are very close, membrane fluctuations are likely to allow contact between the ERM and PM occasionally, for instance).\n", + "To adopt this strategy, we must construct a map between each point on the PM and the closest point on the ER, which we do after initializing the model below.\n", + "For now, we define the flux as a parameter varying over the PM mesh by calling `Parameter.mesh_quantity()`\n", + "" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a56bbccb", + "metadata": {}, + "outputs": [], + "source": [ + "PSOCE = Parameter.mesh_quantity(\"PSOCE\", 0, unit.dimensionless, compartment=\"PM\")\n", + "J0_SOCE = Parameter(\"J0_SOCE\", 1e4, flux_unit)\n", + "rSOCE = Reaction(\n", + " \"rSOCE\",\n", + " [],\n", + " [\"A\"],\n", + " param_map={\"P\": \"PSOCE\", \"J0\": \"J0_SOCE\"},\n", + " eqn_f_str=\"P*J0\",\n", + " explicit_restriction_to_domain=\"PM\",\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "3d883c6e", + "metadata": {}, + "source": [ + "Create containers for parameters and reactions and add all the parameters and reaction objects to them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8ae2c2c1", + "metadata": {}, + "outputs": [], + "source": [ + "pc = ParameterContainer()\n", + "rc = ReactionContainer()\n", + "pc.add([k4Vmax, k3r, k3f, k2f, j1pulse, PSOCE, J0_SOCE])\n", + "rc.add([r1, r2, r3, r4, rSOCE])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "15c35d39", + "metadata": {}, + "source": [ + "## Create/load in mesh\n", + "\n", + "In SMART we have different levels of meshes:\n", + "- Parent mesh: contains the entire geometry of the problem, including all surfaces and volumes\n", + "- Child meshes: submeshes (sections of the parent mesh) associated with individual compartments. Here, the child meshes are:\n", + " - Cyto: the portion of the outer cube outside of the inner cube, defined by `cell_markers = 1`\n", + " - ER: the inside portion of the inner cube, defined by `cell_markers = 2`\n", + " - PM: surface mesh where x=0, defined by `facet_markers = 10`\n", + " - ERm: surface mesh corresponding to all faces of the inner cube, defined by `facet_markers = 12`\n", + "\n", + "Here we create a UnitCube mesh as the Parent mesh, defined by\n", + "\n", + "$$\n", + "\\Omega = [0, 1] \\times [0, 1] \\times [0, 1] \\subset \\mathbb{R}^3\n", + "$$\n", + "\n", + "\n", + "The ER is a cube within the exterior cube, with dimensions 0.4 by 0.4 by 0.4 and a tunable gap between the ER and PM to test for different strengths of SOCE below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e77f97fe", + "metadata": {}, + "outputs": [], + "source": [ + "ER_PM_gap = 0 # must be greater than 0 and less than 0.6\n", + "ER_PM_gap = 0.05 * round(ER_PM_gap/0.05) # round to ensure flat edges in mesh\n", + "def cur_cube_condition(cell, xmin=ER_PM_gap, xmax=ER_PM_gap+0.4):\n", + " \"\"\"\n", + " Returns true when inside an inner cube region defined as:\n", + " xmin <= x <= xmax, ymin <= y <= ymax, zmin <= z <= zmax\n", + " \"\"\"\n", + " ymin = 0.3\n", + " ymax = 0.7\n", + " zmin = 0.3\n", + " zmax = 0.7\n", + " return (\n", + " (xmin - d.DOLFIN_EPS < cell.midpoint().x() < xmax + d.DOLFIN_EPS)\n", + " and (ymin - d.DOLFIN_EPS < cell.midpoint().y() < ymax + d.DOLFIN_EPS)\n", + " and (zmin - d.DOLFIN_EPS < cell.midpoint().z() < zmax + d.DOLFIN_EPS)\n", + " )\n", + "domain, facet_markers, cell_markers = mesh_tools.create_cubes(condition=cur_cube_condition, N=20)\n", + "visualization.plot_dolfin_mesh(domain, cell_markers, clip_plane=(1,\n", + " 1, 0), clip_origin=(0.5, 0.5, 0.5))\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "035113ed", + "metadata": {}, + "source": [ + "By default, `smart.mesh_tools.create_cubes` marks all faces of the outer cube as \"10\", our marker value associated with PM. Here, since we are only treating the x=0 face as PM, we alter the facet markers on all other faces, setting them equal to zero. They are then treated as no-flux boundaries not belonging to a designated surface compartment. The resultant mesh with the new facet and volume markers is displayed below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe56e162", + "metadata": {}, + "outputs": [], + "source": [ + "for face in d.faces(domain):\n", + " if face.midpoint().x() > d.DOLFIN_EPS and facet_markers[face] == 10:\n", + " facet_markers[face] = 0\n", + "img_mesh = mpimg.imread('example5-mesh.png')\n", + "plt.imshow(img_mesh)\n", + "plt.axis('off')" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "c17ebcbb", + "metadata": {}, + "source": [ + "We now save the mesh as an h5 file and then read it into SMART as a `ParentMesh` object. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5657aab1", + "metadata": {}, + "outputs": [], + "source": [ + "mesh_folder = pathlib.Path(\"mesh\")\n", + "mesh_folder.mkdir(exist_ok=True)\n", + "mesh_path = mesh_folder / \"DemoCuboidsMesh.h5\"\n", + "mesh_tools.write_mesh(\n", + " domain, facet_markers, cell_markers, filename=mesh_path\n", + ")\n", + "parent_mesh = mesh.ParentMesh(\n", + " mesh_filename=str(mesh_path),\n", + " mesh_filetype=\"hdf5\",\n", + " name=\"parent_mesh\",\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "0f1cf8a4", + "metadata": {}, + "source": [ + "## Model and solver initialization\n", + "\n", + "Now we are ready to set up the model. First we load the default configurations and set some configurations for the current solver." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8c1a2a92", + "metadata": {}, + "outputs": [], + "source": [ + "conf = config.Config()\n", + "conf.solver.update(\n", + " {\n", + " \"final_t\": 1,\n", + " \"initial_dt\": 0.01,\n", + " \"time_precision\": 6,\n", + " }\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "1511acc7", + "metadata": {}, + "source": [ + "We create a model using the different containers and the parent mesh. For later reference, we save the model information as a pickle file. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3c3c30f", + "metadata": {}, + "outputs": [], + "source": [ + "model_cur = model.Model(pc, sc, cc, rc, conf, parent_mesh)\n", + "model_cur.to_pickle('model_cur.pkl')" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "dfb70c74", + "metadata": {}, + "source": [ + "Note that we could later load the model information from the pickle file using the line:\n", + "```Python\n", + "model_cur = model.from_pickle(model_cur.pkl)\n", + "```\n", + "\n", + "Next we need to initialize the model and solver." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8976aa2", + "metadata": {}, + "outputs": [], + "source": [ + "model_cur.initialize()" + ] + }, + { + "cell_type": "markdown", + "id": "70f79003", + "metadata": {}, + "source": [ + "Now create mapping between PM and closest point on the ER surface for spatially dependent SOCE." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db8d9dca", + "metadata": {}, + "outputs": [], + "source": [ + "Pfun = PSOCE.dolfin_function # function associated with PSOCE values\n", + "V_PM = Pfun.function_space() # PM function space\n", + "# pull coordinate lists for PM and ERM\n", + "PMcoord = V_PM.tabulate_dof_coordinates()\n", + "ERMcoord = ERm.dolfin_mesh.coordinates()\n", + "# initialize vectors for distances and AER indices\n", + "AER_idx = np.zeros_like(Pfun.vector())\n", + "dist_vals = np.zeros_like(Pfun.vector())\n", + "# get parent mesh and vertex mapping to meshviews\n", + "mesh_ref = model_cur.parent_mesh.dolfin_mesh\n", + "ERM_map = ERm.dolfin_mesh.topology().mapping()[mesh_ref.id()].vertex_map()\n", + "ER_map = ER.dolfin_mesh.topology().mapping()[mesh_ref.id()].vertex_map()\n", + "# find closest ERM point for each PM coordinate\n", + "for i in range(len(PMcoord)):\n", + " curCoord = PMcoord[i]\n", + " dists = np.sqrt((curCoord[0]-ERMcoord[:,0])**2 + \n", + " (curCoord[1]-ERMcoord[:,1])**2 + \n", + " (curCoord[2]-ERMcoord[:,2])**2)\n", + " ERMidx = np.argmin(dists)\n", + " dist_vals[i] = dists[ERMidx]\n", + " global_idx = ERM_map[ERMidx]\n", + " ER_idx = np.nonzero(np.array(ER_map)==global_idx)[0]\n", + " if len(ER_idx) != 1:\n", + " raise ValueError(\"Node not found in ER mesh\")\n", + " AER_idx[i] = d.vertex_to_dof_map(AER.V)[ER_idx][0]\n", + "# now define PSOCE function values according to dist vals and AER values\n", + "d0 = 0.01\n", + "cref = 2.0\n", + "vals_new = (d0/(dist_vals+d0)) * (1/(1+(AER.u[\"u\"].vector()[AER_idx]/cref)**4))\n", + "PSOCE.dolfin_function.vector()[:] = vals_new\n", + "PSOCE.dolfin_function.vector().apply(\"insert\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "d05a1a75", + "metadata": {}, + "source": [ + "## Solving system and storing data\n", + "\n", + "We create some XDMF files where we will store the output " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5df9509e", + "metadata": {}, + "outputs": [], + "source": [ + "# Write initial condition(s) to file\n", + "results = dict()\n", + "result_folder = pathlib.Path(f\"results_ER_PM_gap_{ER_PM_gap}\")\n", + "result_folder.mkdir(exist_ok=True)\n", + "for species_name, species in model_cur.sc.items:\n", + " results[species_name] = d.XDMFFile(\n", + " model_cur.mpi_comm_world, str(result_folder / f\"{species_name}.xdmf\")\n", + " )\n", + " results[species_name].parameters[\"flush_output\"] = True\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + "SOCEfile = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"PSOCE.xdmf\"))\n", + "SOCEfile.parameters[\"flush_output\"] = True\n", + "SOCEfile.write(PSOCE.dolfin_function, model_cur.t)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "183376e6", + "metadata": {}, + "source": [ + "We now run the the solver and store the data at each time point to the initialized files. We also integrate A over the cytosolic volume at each time step to monitor the elevation in A over time and we display the concentration of A in the cytosol at t = 0.2 s." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31d42278", + "metadata": {}, + "outputs": [], + "source": [ + "# Set loglevel to warning in order not to pollute notebook output\n", + "logger.setLevel(logging.WARNING)\n", + "\n", + "avg_A = [A.initial_condition]\n", + "# define integration measure and total volume for computing average A at each time point\n", + "dx = d.Measure(\"dx\", domain=model_cur.cc['Cyto'].dolfin_mesh)\n", + "volume = d.assemble_mixed(1.0*dx)\n", + "# Solve\n", + "displayed = False\n", + "while model_cur.t < model_cur.final_t:\n", + " # Solve the system\n", + " model_cur.monolithic_solve()\n", + " # Update PSOCE\n", + " PSOCE.dolfin_function.vector()[:] = (d0/(dist_vals+d0)) * (1/(1+(AER.u[\"u\"].vector()[AER_idx]/cref)**4))\n", + " PSOCE.dolfin_function.vector().apply(\"insert\")\n", + " # Save results for post processing\n", + " for species_name, species in model_cur.sc.items:\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + " SOCEfile.write(PSOCE.dolfin_function, model_cur.t)\n", + " # compute average A concentration at each time step\n", + " int_val = d.assemble_mixed(model_cur.sc['A'].u['u']*dx)\n", + " avg_A.append(int_val / volume)\n", + " if model_cur.t >= 0.2 and not displayed:\n", + " visualization.plot(model_cur.sc['A'].u['u'],\n", + " clip_plane=(1, 1, 0), clip_origin=(0.5, 0.5, 0.5))\n", + " displayed = True" + ] + }, + { + "cell_type": "markdown", + "id": "621600d7", + "metadata": {}, + "source": [ + "Finally, we plot the average concentration of A over time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8651e71", + "metadata": {}, + "outputs": [], + "source": [ + "plt.plot(model_cur.tvec, avg_A)\n", + "plt.xlabel('Time (s)')\n", + "plt.ylabel('Cytosolic concentration of A (μM)')" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "d302bf3f", + "metadata": {}, + "source": [ + "We also compare the AUC for A with previous numerical simulations (regression test)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f8b3c866", + "metadata": {}, + "outputs": [], + "source": [ + "tvec = np.zeros(len(model_cur.tvec))\n", + "for i in range(len(model_cur.tvec)):\n", + " tvec[i] = float(model_cur.tvec[i])\n", + "auc_cur = np.trapz(np.array(avg_A), tvec)\n", + "auc_compare = 4.646230684534995\n", + "percent_error = 100*np.abs(auc_cur - auc_compare)/auc_compare\n", + "assert percent_error < .01,\\\n", + " f\"Failed regression test: Example 5 results deviate {percent_error:.3f}% from the previous numerical solution\"" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/example5/model_cur.pkl b/examples/example5/model_cur.pkl new file mode 100644 index 0000000000000000000000000000000000000000..7830aa97204403756203f5688e83bd692ce48e07 GIT binary patch literal 3487 zcmcInO^h5z72cki{qg?U-mGGggCXFA$%+iNV~ZQvu(22K$m3k{@iFSBy< z{X$A+yq(3`^eX4m=H62fYa=R8kK+Ha zL-pvP`uL&x1SV&C>am`Byr({?o>ZL?2L1apXsd@Uhnf+b^gl8>?--r=KWQ?(doHhH zEv+`$r{;h0`X7FJx4GIhgZPs{6-$F~t0R(;WH#PMMBZ1+1NG>EH76~?QTcu+$O_p> zDgdB)6(hX5G_sLa$u=XDrmt{xT3Bqn{)#v8vU$nSi4n>?yNO+DZe}<6lF{2m7-HiY zjISBBNzH31M-Myxn%cU$`dz+DeMLQoy~)cpVA{}ZAsG1qV84cP-3uISFB(9%kU^vw zEZvLxTZT@K2!mI|prNVj?gavQ^|`qqOOmPt%x*|?fP=3v_pNS&rQr9$AJWYA=V`YEOvvz1Z*8M6C7`?h9W0?T4qS)`0MYNNp88TfF1xR2(V9IuQP6^i0yJ)(N zZqo^;`c@Uby8A0@m2ALpAl(oED>TcHm)e?u@uxb^8?`5D*iCL;zqWRXBgqBp#Khf^ zbRQfXj00|k780q|?R@K44nR1)an>I&T>bfhOl<~t_xF1=Qyg6jAeq3+- z#BTgdy~XybPp?K}r*Eguhr8Y#dGpQn)#aLa>_%!_a!#l(byNt8fkkCCenD$( zzs&B?>YL7X4}67s$7&^Me#B%0Pb*KoMT;S7Udmn&F<13&gHDdneu!(-l{?KqbAgOw zA7yM0NvS(;Rrb^`)o;2@_1oIgzq9)OzU!>)QDgI}0YT#~g4z1`JNG>9Rg#VsQaI?F zVK37t&LQi?iq)fL;hx^-j6mw*LJH|}PGsJOQI|B$)nQ*Rt0Q%!QBmXSmahZYBHaP8 zI_ha!7%=l)xtYpg<|2kCv#^XQuzmGqc{ts?5dCD?rt0QV` zoimbIQFH5DW(0F=?@oG%;m=Sr>!UcIt(Y~4)zR)eHr%ktZ#+~i4|aQYy=T{Y6@;4` z5$-{Rx?qJuQ3Ix|_T)X|1frJhVAYg0Lff*i12roU@1BAfK{W((Fta-0t>9PapRNK6XxEgn=D*HW*5O|_AX~L@-~h74mCE;8ufi^F((bP zw=Q#hbmh1wuJC~|H%WApqTtMD%R@hkrAvXQc>d{UWsZVa$7XRUA0b8 zM-J#TKe@%On_1}tPf{S%_jM@|(KI_KGGU35P^UFICc<4{%g17m7Hu~^(sfMH`0U++ Date: Mon, 23 Jun 2025 22:31:09 -0700 Subject: [PATCH 03/20] Remove model specs file for example 5 --- examples/example5/model_cur.pkl | Bin 3487 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 examples/example5/model_cur.pkl diff --git a/examples/example5/model_cur.pkl b/examples/example5/model_cur.pkl deleted file mode 100644 index 7830aa97204403756203f5688e83bd692ce48e07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3487 zcmcInO^h5z72cki{qg?U-mGGggCXFA$%+iNV~ZQvu(22K$m3k{@iFSBy< z{X$A+yq(3`^eX4m=H62fYa=R8kK+Ha zL-pvP`uL&x1SV&C>am`Byr({?o>ZL?2L1apXsd@Uhnf+b^gl8>?--r=KWQ?(doHhH zEv+`$r{;h0`X7FJx4GIhgZPs{6-$F~t0R(;WH#PMMBZ1+1NG>EH76~?QTcu+$O_p> zDgdB)6(hX5G_sLa$u=XDrmt{xT3Bqn{)#v8vU$nSi4n>?yNO+DZe}<6lF{2m7-HiY zjISBBNzH31M-Myxn%cU$`dz+DeMLQoy~)cpVA{}ZAsG1qV84cP-3uISFB(9%kU^vw zEZvLxTZT@K2!mI|prNVj?gavQ^|`qqOOmPt%x*|?fP=3v_pNS&rQr9$AJWYA=V`YEOvvz1Z*8M6C7`?h9W0?T4qS)`0MYNNp88TfF1xR2(V9IuQP6^i0yJ)(N zZqo^;`c@Uby8A0@m2ALpAl(oED>TcHm)e?u@uxb^8?`5D*iCL;zqWRXBgqBp#Khf^ zbRQfXj00|k780q|?R@K44nR1)an>I&T>bfhOl<~t_xF1=Qyg6jAeq3+- z#BTgdy~XybPp?K}r*Eguhr8Y#dGpQn)#aLa>_%!_a!#l(byNt8fkkCCenD$( zzs&B?>YL7X4}67s$7&^Me#B%0Pb*KoMT;S7Udmn&F<13&gHDdneu!(-l{?KqbAgOw zA7yM0NvS(;Rrb^`)o;2@_1oIgzq9)OzU!>)QDgI}0YT#~g4z1`JNG>9Rg#VsQaI?F zVK37t&LQi?iq)fL;hx^-j6mw*LJH|}PGsJOQI|B$)nQ*Rt0Q%!QBmXSmahZYBHaP8 zI_ha!7%=l)xtYpg<|2kCv#^XQuzmGqc{ts?5dCD?rt0QV` zoimbIQFH5DW(0F=?@oG%;m=Sr>!UcIt(Y~4)zR)eHr%ktZ#+~i4|aQYy=T{Y6@;4` z5$-{Rx?qJuQ3Ix|_T)X|1frJhVAYg0Lff*i12roU@1BAfK{W((Fta-0t>9PapRNK6XxEgn=D*HW*5O|_AX~L@-~h74mCE;8ufi^F((bP zw=Q#hbmh1wuJC~|H%WApqTtMD%R@hkrAvXQc>d{UWsZVa$7XRUA0b8 zM-J#TKe@%On_1}tPf{S%_jM@|(KI_KGGU35P^UFICc<4{%g17m7Hu~^(sfMH`0U++ Date: Mon, 23 Jun 2025 22:42:12 -0700 Subject: [PATCH 04/20] Change version number in pyproject --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0bbb4e3..7a80519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel"] [project] name = "fenics-smart" -version = "2.2.3" +version = "2.3.0" description = "Spatial Modeling Algorithms for Reactions and Transport (SMART) is a high-performance finite-element-based simulation package for model specification and numerical simulation of spatially-varying reaction-transport processes in biological cells." authors = [{ name = "Justin Laughlin", email = "justinglaughlin@gmail.com" }] license = { file = "LICENSE" } From a25eabc6c17e1bdb5baf40bd802472e5d4f9ea70 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:42:30 -0700 Subject: [PATCH 05/20] Update ER_PM_gap value and save Avals in example5 with SOCE --- examples/example5/example5_withSOCE.ipynb | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/examples/example5/example5_withSOCE.ipynb b/examples/example5/example5_withSOCE.ipynb index d1405bd..c70075b 100644 --- a/examples/example5/example5_withSOCE.ipynb +++ b/examples/example5/example5_withSOCE.ipynb @@ -276,7 +276,7 @@ "A = Species(\"A\", 0.01, vol_unit, 1.0, D_unit, \"Cyto\")\n", "B = Species(\"B\", 0.0, vol_unit, 1.0, D_unit, \"Cyto\")\n", "AER = Species(\"AER\", 200.0, vol_unit, 5.0, D_unit, \"ER\")\n", - "# Uniform initial condition of R\n", + "# Uniform initial condition of R for simplicity\n", "R1 = Species(\"R1\", 1.0, surf_unit, 0.02, D_unit, \"ERm\")\n", "R1o = Species(\"R1o\", 0.0, surf_unit, 0.02, D_unit, \"ERm\")" ] @@ -490,7 +490,7 @@ "metadata": {}, "outputs": [], "source": [ - "ER_PM_gap = 0 # must be greater than 0 and less than 0.6\n", + "ER_PM_gap = 0.05 # must be greater than 0 and less than 0.6\n", "ER_PM_gap = 0.05 * round(ER_PM_gap/0.05) # round to ensure flat edges in mesh\n", "def cur_cube_condition(cell, xmin=ER_PM_gap, xmax=ER_PM_gap+0.4):\n", " \"\"\"\n", @@ -517,7 +517,7 @@ "id": "035113ed", "metadata": {}, "source": [ - "By default, `smart.mesh_tools.create_cubes` marks all faces of the outer cube as \"10\", our marker value associated with PM. Here, since we are only treating the x=0 face as PM, we alter the facet markers on all other faces, setting them equal to zero. They are then treated as no-flux boundaries not belonging to a designated surface compartment. The resultant mesh with the new facet and volume markers is displayed below." + "By default, `smart.mesh_tools.create_cubes` marks all faces of the outer cube as \"10\", our marker value associated with PM. Here, since we are only treating the x=0 face as PM, we alter the facet markers on all other faces, setting them equal to zero. They are then treated as no-flux boundaries not belonging to a designated surface compartment." ] }, { @@ -529,10 +529,7 @@ "source": [ "for face in d.faces(domain):\n", " if face.midpoint().x() > d.DOLFIN_EPS and facet_markers[face] == 10:\n", - " facet_markers[face] = 0\n", - "img_mesh = mpimg.imread('example5-mesh.png')\n", - "plt.imshow(img_mesh)\n", - "plt.axis('off')" + " facet_markers[face] = 0" ] }, { @@ -677,8 +674,8 @@ " raise ValueError(\"Node not found in ER mesh\")\n", " AER_idx[i] = d.vertex_to_dof_map(AER.V)[ER_idx][0]\n", "# now define PSOCE function values according to dist vals and AER values\n", - "d0 = 0.01\n", - "cref = 2.0\n", + "d0 = 0.05\n", + "cref = 100.0\n", "vals_new = (d0/(dist_vals+d0)) * (1/(1+(AER.u[\"u\"].vector()[AER_idx]/cref)**4))\n", "PSOCE.dolfin_function.vector()[:] = vals_new\n", "PSOCE.dolfin_function.vector().apply(\"insert\")" @@ -776,9 +773,13 @@ "metadata": {}, "outputs": [], "source": [ - "plt.plot(model_cur.tvec, avg_A)\n", + "plt.plot(model_cur.tvec, avg_A, label='PM-ER gap = 50 nm')\n", + "other_case = np.loadtxt('/root/shared/gitrepos/smart-dev/examples/example5/results_ER_PM_gap_0.2/Avals.txt')\n", + "plt.plot(other_case[0,:], other_case[1,:], label='PM-ER gap = 200 nm')\n", "plt.xlabel('Time (s)')\n", - "plt.ylabel('Cytosolic concentration of A (μM)')" + "plt.ylabel('Cytosolic concentration of A (μM)')\n", + "plt.legend()\n", + "np.savetxt(str(result_folder / f\"Avals.txt\"), [model_cur.tvec, avg_A])" ] }, { From 3ec6baafbffb64224568a878b7f2bd851c872f95 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:04:44 -0700 Subject: [PATCH 06/20] Replace function strings for log to use std::log --- smart/model.py | 22 ++++++++++++---------- smart/model_assembly.py | 10 ++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/smart/model.py b/smart/model.py index 8f51475..24d07cf 100644 --- a/smart/model.py +++ b/smart/model.py @@ -756,14 +756,14 @@ def _init_4_0_initialize_dolfin_parameters(self): if parameter.type == ParameterType.constant: parameter.dolfin_constant = d.Constant(parameter.value, name=parameter.name) elif parameter.type == ParameterType.expression and parameter.is_space_dependent: + c_code = sym.printing.ccode(parameter.sym_expr) + c_code = c_code.replace("log(", "std::log(") # use higher degree to avoid interpolation error - parameter.dolfin_expression = d.Expression( - sym.printing.ccode(parameter.sym_expr), t=self.T, degree=3 - ) + parameter.dolfin_expression = d.Expression(c_code, t=self.T, degree=3) elif parameter.type == ParameterType.expression and not parameter.use_preintegration: - parameter.dolfin_expression = d.Expression( - sym.printing.ccode(parameter.sym_expr), t=self.T, degree=1 - ) + c_code = sym.printing.ccode(parameter.sym_expr) + c_code = c_code.replace("log(", "std::log(") + parameter.dolfin_expression = d.Expression(c_code, t=self.T, degree=1) elif parameter.type == ParameterType.expression and parameter.use_preintegration: parameter.dolfin_constant = d.Constant(parameter.value, name=parameter.name) elif parameter.type == ParameterType.from_file: @@ -1909,9 +1909,9 @@ def update_time_dependent_parameters(self): a = parameter.preint_sym_expr.subs({"t": tn}).evalf() b = parameter.preint_sym_expr.subs({"t": t}).evalf() if parameter.is_space_dependent: - parameter.dolfin_expression = d.Expression( - sym.printing.ccode((b - a) / dt), degree=3 - ) + c_code = sym.printing.ccode((b - a) / dt) + c_code = c_code.replace("log(", "std::log(") + parameter.dolfin_expression = d.Expression(c_code, degree=3) logger.debug( f"Time-dependent parameter {parameter_name} updated by " f"pre-integrated expression", @@ -2006,7 +2006,9 @@ def dolfin_set_function_values(self, sp, ukey, unew): else: x = d.SpatialCoordinate(sp.compartment.dolfin_mesh) curv = sp.compartment.curv_func - full_expr = d.Expression(sym.printing.ccode(sym_expr), curv=curv, degree=1) + c_code = sym.printing.ccode(sym_expr) + c_code = c_code.replace("log(", "std::log(") + full_expr = d.Expression(c_code, curv=curv, degree=1) ufunc = d.interpolate(full_expr, sp.V) d.assign(sp.u[ukey], ufunc) elif isinstance(unew, d.Expression): diff --git a/smart/model_assembly.py b/smart/model_assembly.py index b8c6ef0..5c76908 100644 --- a/smart/model_assembly.py +++ b/smart/model_assembly.py @@ -775,7 +775,9 @@ def from_expression( if is_time_dependent and not is_space_dependent: value = float(sym_expr.subs({"t": 0.0})) else: - dolfin_expression = d.Expression(sym.printing.ccode(sym_expr), t=0.0, degree=3) + c_code = sym.printing.ccode(sym_expr) + c_code = c_code.replace("log(", "std::log(") + dolfin_expression = d.Expression(c_code, t=0.0, degree=3) value = float(sym_expr.subs({"t": 0.0, "x[0]": 0.0, "x[1]": 0.0, "x[2]": 0.0})) parameter = cls( @@ -1002,9 +1004,9 @@ def __post_init__(self): f"Creating dolfin object for space-dependent initial condition {self.name}", extra=dict(format_type="log"), ) - self.initial_condition_expression = d.Expression( - sym.printing.ccode(sym_expr), degree=1 - ) + c_code = sym.printing.ccode(sym_expr) + c_code = c_code.replace("log(", "std::log(") + self.initial_condition_expression = d.Expression(c_code, degree=1) elif isinstance(self.initial_condition, Path): pass # keep as path else: From be9bb82255bbe262cf3adb4a70de8eee9fa0ef35 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Fri, 11 Jul 2025 14:08:12 -0700 Subject: [PATCH 07/20] Add and test code for advective transport --- .../example2/example2_withadvection.ipynb | 544 ++++++++++++++++++ smart/model.py | 96 +++- smart/model_assembly.py | 4 + 3 files changed, 635 insertions(+), 9 deletions(-) create mode 100644 examples/example2/example2_withadvection.ipynb diff --git a/examples/example2/example2_withadvection.ipynb b/examples/example2/example2_withadvection.ipynb new file mode 100644 index 0000000..f0f47d9 --- /dev/null +++ b/examples/example2/example2_withadvection.ipynb @@ -0,0 +1,544 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "f65f18d7", + "metadata": {}, + "source": [ + "# Example 2: Simple 2D cell signaling model\n", + "\n", + "We model a reaction between the cell interior and cell membrane in a 2D geometry:\n", + "- Cyto - 2D cell \"volume\"\n", + "- PM - 1D cell boundary (represents plasma membrane)\n", + "\n", + "Model from [Rangamani et al, 2013, Cell](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3874130/). A cytosolic species, \"A\", reacts with a species on the PM, \"B\", to form a new species on the PM, \"X\". The resulting PDE and boundary condition for species A are as follows:\n", + "\n", + "$$\n", + "\\frac{\\partial{C_A}}{\\partial{t}} = D_A \\nabla ^2 C_A \\quad \\text{in} \\; \\Omega_{Cyto}\\\\\n", + "\\text{B.C. for A:} \\quad D_A (\\textbf{n} \\cdot \\nabla C_A) = -k_{on} C_A N_X + k_{off} N_B \\quad \\text{on} \\; \\Gamma_{PM}\n", + "$$\n", + "\n", + "Similarly, the PDEs for X and B are given by:\n", + "$$\n", + "\\frac{\\partial{N_X}}{\\partial{t}} = D_X \\nabla ^2 N_X - k_{on} C_A N_X + k_{off} N_B \\quad \\text{on} \\; \\Gamma_{PM}\\\\\n", + "\\frac{\\partial{N_B}}{\\partial{t}} = D_B \\nabla ^2 N_B + k_{on} C_A N_X - k_{off} N_B \\quad \\text{on} \\; \\Gamma_{PM}\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b224bea7", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "from matplotlib import pyplot as plt\n", + "import matplotlib.image as mpimg\n", + "img_A = mpimg.imread('axb-diagram.png')\n", + "plt.imshow(img_A)\n", + "plt.axis('off')" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "a59f3428", + "metadata": {}, + "source": [ + "Imports and logger initialization:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc398816", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "import dolfin as d\n", + "import sympy as sym\n", + "import numpy as np\n", + "import pathlib\n", + "import logging\n", + "import gmsh # must be imported before pyvista if dolfin is imported first\n", + "\n", + "from smart import config, common, mesh, model, mesh_tools, visualization\n", + "from smart.units import unit\n", + "from smart.model_assembly import (\n", + " Compartment,\n", + " Parameter,\n", + " Reaction,\n", + " Species,\n", + " SpeciesContainer,\n", + " ParameterContainer,\n", + " CompartmentContainer,\n", + " ReactionContainer,\n", + " Form,\n", + ")\n", + "from matplotlib import pyplot as plt\n", + "import matplotlib.image as mpimg\n", + "\n", + "logger = logging.getLogger(\"smart\")\n", + "logger.setLevel(logging.INFO)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "95b9d865", + "metadata": {}, + "source": [ + "First, we define the various units for use in the model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f4023cf", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "um = unit.um\n", + "molecule = unit.molecule\n", + "sec = unit.sec\n", + "dimensionless = unit.dimensionless\n", + "D_unit = um**2 / sec\n", + "surf_unit = molecule / um**2\n", + "flux_unit = molecule / (um * sec)\n", + "edge_unit = molecule / um" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "46582d26", + "metadata": {}, + "source": [ + "Next we generate the model by assembling the compartment, species, parameter, and reaction containers (see Example 1 or API documentation for more details)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09079b17", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "# =============================================================================================\n", + "# Compartments\n", + "# =============================================================================================\n", + "# name, topological dimensionality, length scale units, marker value\n", + "Cyto = Compartment(\"Cyto\", 2, um, 1, vel=(\"-0.1*y*(1 - (x**2+y**2))\",\"0.1*x*(1 - (x**2+y**2))\",\"0\"))\n", + "PM = Compartment(\"PM\", 1, um, 3)\n", + "cc = CompartmentContainer()\n", + "cc.add([Cyto, PM])\n", + "\n", + "# =============================================================================================\n", + "# Species\n", + "# =============================================================================================\n", + "# name, initial concentration, concentration units, diffusion, diffusion units, compartment\n", + "A = Species(\"A\", \"10*exp(-(x**2+(y-0.8)**2)/0.2**2)\", surf_unit, 0.001, D_unit, \"Cyto\")\n", + "X = Species(\"X\", 1.0, edge_unit, 1.0, D_unit, \"PM\")\n", + "B = Species(\"B\", 0.0, edge_unit, 1.0, D_unit, \"PM\")\n", + "sc = SpeciesContainer()\n", + "sc.add([A, X, B])\n", + "\n", + "# =============================================================================================\n", + "# Parameters and Reactions\n", + "# =============================================================================================\n", + "\n", + "# Reaction of A and X to make B (Cyto-PM reaction)\n", + "kon = Parameter(\"kon\", 0.0, 1/(surf_unit*sec))\n", + "koff = Parameter(\"koff\", 1.0, 1/sec)\n", + "r1 = Reaction(\"r1\", [\"A\", \"X\"], [\"B\"],\n", + " param_map={\"on\": \"kon\", \"off\": \"koff\"},\n", + " species_map={\"A\": \"A\", \"X\": \"X\", \"B\": \"B\"})\n", + "\n", + "pc = ParameterContainer()\n", + "pc.add([kon, koff])\n", + "rc = ReactionContainer()\n", + "rc.add([r1])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "15c35d39", + "metadata": {}, + "source": [ + "Now we create a circular mesh (mesh built using gmsh in `smart.mesh_tools`), along with marker functions `mf2` and `mf1`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe56e162", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "# Create mesh\n", + "h_ellipse = 0.1\n", + "xrad = 1.0\n", + "yrad = 1.0\n", + "surf_tag = 1\n", + "edge_tag = 3\n", + "ellipse_mesh, mf1, mf2 = mesh_tools.create_ellipses(xrad, yrad, hEdge=h_ellipse,\n", + " outer_tag=surf_tag, outer_marker=edge_tag)\n", + "visualization.plot_dolfin_mesh(ellipse_mesh, mf2, view_xy=True)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "fe04ad6b", + "metadata": {}, + "source": [ + "Write mesh and meshfunctions to file, then create `mesh.ParentMesh` object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e15255a1", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "mesh_folder = pathlib.Path(\"ellipse_mesh_AR1\")\n", + "mesh_folder.mkdir(exist_ok=True)\n", + "mesh_file = mesh_folder / \"ellipse_mesh.h5\"\n", + "mesh_tools.write_mesh(ellipse_mesh, mf1, mf2, mesh_file)\n", + "\n", + "parent_mesh = mesh.ParentMesh(\n", + " mesh_filename=str(mesh_file),\n", + " mesh_filetype=\"hdf5\",\n", + " name=\"parent_mesh\",\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "d1c0cab2", + "metadata": {}, + "source": [ + "Initialize model and solvers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b059df37", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "config_cur = config.Config()\n", + "config_cur.solver.update(\n", + " {\n", + " \"final_t\": 10.0,\n", + " \"initial_dt\": 0.05,\n", + " \"time_precision\": 6,\n", + " }\n", + ")\n", + "\n", + "model_cur = model.Model(pc, sc, cc, rc, config_cur, parent_mesh)\n", + "model_cur.initialize()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "e97c54a3", + "metadata": {}, + "source": [ + "Save model information to .pkl file and write initial conditions to file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "274cc41d", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "model_cur.to_pickle('model_cur.pkl')\n", + "results = dict()\n", + "result_folder = pathlib.Path(\"resultsEllipse_AR1_withLagrangeRecalc\")\n", + "result_folder.mkdir(exist_ok=True)\n", + "for species_name, species in model_cur.sc.items:\n", + " results[species_name] = d.XDMFFile(\n", + " model_cur.mpi_comm_world, str(result_folder / f\"{species_name}.xdmf\")\n", + " )\n", + " results[species_name].parameters[\"flush_output\"] = True\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + "\n", + "# save deformation field as well\n", + "V_vector = sc[\"A\"].compartment.vel_func.function_space()\n", + "V = d.FunctionSpace(cc[\"Cyto\"].dolfin_mesh, \"P\", 1)\n", + "velfile = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"vel.xdmf\"))\n", + "velfile.parameters[\"flush_output\"] = True\n", + "velfile.write(sc[\"A\"].compartment.vel_func, model_cur.t)\n", + "udef = d.Function(V_vector)\n", + "# uexpr = d.Expression(('-0.1*t*x[1]*(1 - (pow(x[0],2)+pow(x[1],2)))', \n", + "# '0.1*t*x[0]*(1 - (pow(x[0],2)+pow(x[1],2)))', '0'),\n", + "# degree=1, t=tval)\n", + "# uvar = d.interpolate(uexpr, V_vector)\n", + "ufile = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"uvar.xdmf\"))\n", + "ufile.parameters[\"flush_output\"] = True\n", + "ufile.write(udef, model_cur.t)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "355a426e", + "metadata": {}, + "source": [ + "Solve the system until `model_cur.t > model_cur.final_t`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40b213ea", + "metadata": { + "metadata": {} + }, + "outputs": [], + "source": [ + "tvec = [0]\n", + "dx_Cyto = d.Measure(\"dx\", domain=model_cur.cc['Cyto'].dolfin_mesh)\n", + "dx_PM = d.Measure(\"dx\", domain=model_cur.cc['PM'].dolfin_mesh)\n", + "volume = d.assemble_mixed(1.0*dx_Cyto)\n", + "PM_SA = d.assemble_mixed(1.0*dx_PM)\n", + "avg_A = [d.assemble_mixed(A.u['u']*dx_Cyto) / volume]\n", + "avg_X = [d.assemble_mixed(X.u[\"u\"]*dx_PM) / PM_SA]\n", + "avg_B = [d.assemble_mixed(B.u[\"u\"]*dx_PM) / PM_SA]\n", + "# Set loglevel to warning in order not to pollute notebook output\n", + "logger.setLevel(logging.WARNING)\n", + "# variables for lagrangian mapping\n", + "linear_wrt_comp = {k: True for k in model_cur.cc.keys}\n", + "x = d.SpatialCoordinate(sc[\"A\"].compartment.dolfin_mesh)\n", + "xcur = x[0]\n", + "ycur = x[1]\n", + "u = sc[\"A\"]._usplit[\"u\"]\n", + "un = sc[\"A\"]._usplit[\"n\"]\n", + "v = sc[\"A\"].v\n", + "dx = sc[\"A\"].compartment.mesh.dx\n", + "Dform_units = (\n", + " sc[\"A\"].diffusion_units\n", + " * sc[\"A\"].concentration_units\n", + " * sc[\"A\"].compartment.compartment_units ** (sc[\"A\"].compartment.dimensionality - 2)\n", + " )\n", + "mass_form_units = (\n", + " sc[\"A\"].concentration_units / unit.s\n", + " * sc[\"A\"].compartment.compartment_units**sc[\"A\"].compartment.dimensionality\n", + " )\n", + "D_constant = d.Constant(sc[\"A\"].D, name=f\"D_{species.name}\")\n", + "vel = sc[\"A\"].compartment.vel_func\n", + "I = d.Identity(3)\n", + "\n", + "while True:\n", + " # Solve the system\n", + " model_cur.monolithic_solve()\n", + " # Save results for post processing\n", + " for species_name, species in model_cur.sc.items:\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + " avg_A.append(d.assemble_mixed(A.u['u']*dx_Cyto) / volume)\n", + " avg_X.append(d.assemble_mixed(X.u[\"u\"]*dx_PM) / PM_SA)\n", + " avg_B.append(d.assemble_mixed(B.u[\"u\"]*dx_PM) / PM_SA)\n", + " tvec.append(model_cur.t)\n", + " # save deformation\n", + " # tval.assign(model_cur.t)\n", + " # uvar.assign(d.interpolate(uexpr, V_vector))\n", + " # ufile.write(uvar, model_cur.t)\n", + " # update Lagrangian operators\n", + " udef.assign(udef + vel*d.Constant(model_cur.tvec[-1]-model_cur.tvec[-2]))\n", + " ufile.write(udef, model_cur.t)\n", + " xcur = x[0] + udef[0]\n", + " ycur = x[1] + udef[1]\n", + " vcur_x = d.project(-0.1*ycur*(1 - (xcur**2+ycur**2)), V_vector.sub(0).collapse())\n", + " vcur_y = d.project(0.1*xcur*(1 - (xcur**2+ycur**2)), V_vector.sub(1).collapse())\n", + " vel.vector()[V_vector.sub(0).dofmap().dofs()] = vcur_x.vector()\n", + " vel.vector()[V_vector.sub(1).dofmap().dofs()] = vcur_y.vector()\n", + " vel.vector().apply(\"insert\")\n", + " \n", + " velfile.write(vel, model_cur.t)\n", + " F = I + d.grad(udef)\n", + " J = d.det(F)\n", + " if model_cur.config.flags[\"axisymmetric_model\"]:\n", + " Dform = x[0]*D_constant * J * d.inner(d.dot(d.inv(F.T), d.grad(u)),d.dot(d.inv(F.T), d.grad(v)))*dx\n", + " else:\n", + " Dform = D_constant * J * d.inner(d.dot(d.inv(F.T), d.grad(u)),d.dot(d.inv(F.T), d.grad(v)))*dx\n", + " model_cur.forms[\"diffusion_A\"] = Form(\n", + " f\"diffusion_{sc['A'].name}\",\n", + " Dform,\n", + " sc[\"A\"],\n", + " \"diffusion\",\n", + " Dform_units,\n", + " True,\n", + " linear_wrt_comp,\n", + " )\n", + " if model_cur.config.flags[\"axisymmetric_model\"]:\n", + " Aform = J*x[0] * (u*v*d.inner(d.inv(F.T),d.grad(vel))*dx)\n", + " else:\n", + " Aform = J*u*v*d.inner(d.inv(F.T),d.grad(vel))*dx\n", + " model_cur.forms[\"advection_A\"] = Form(\n", + " f\"advection_A\",\n", + " Aform,\n", + " sc[\"A\"],\n", + " \"advection\",\n", + " mass_form_units,\n", + " True,\n", + " linear_wrt_comp,\n", + " )\n", + " if model_cur.config.flags[\"axisymmetric_model\"]:\n", + " Muform = J*x[0] * (u) * v / model_cur.dT * dx\n", + " Munform = J*x[0] * (-un) * v / model_cur.dT * dx\n", + " else:\n", + " Muform = J*(u) * v / model_cur.dT * dx\n", + " Munform = J*(-un) * v / model_cur.dT * dx\n", + " model_cur.forms[\"mass_u_A\"] = Form(\n", + " \"mass_u_A\",\n", + " Muform,\n", + " sc[\"A\"],\n", + " \"mass_u\",\n", + " mass_form_units,\n", + " True,\n", + " linear_wrt_comp,\n", + " )\n", + " model_cur.forms[\"mass_un_A\"] = Form(\n", + " f\"mass_un_A\",\n", + " Munform,\n", + " sc[\"A\"],\n", + " \"mass_un\",\n", + " mass_form_units,\n", + " True,\n", + " linear_wrt_comp,\n", + " )\n", + " model_cur.initialize_discrete_variational_problem_and_solver()\n", + " print(f\"Done with time {model_cur.t} s\")\n", + " # End if we've passed the final time\n", + " if model_cur.t >= model_cur.final_t:\n", + " break" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "52c0fd7e", + "metadata": {}, + "source": [ + "Now we plot the concentration of A in the cell over time and compare this to analytical predictions for a high value of the diffusion coefficient. As $D_A \\rightarrow \\infty$, the steady state concentration of A will be given by the positive root of the following polynomial:\n", + "\n", + "$$\n", + "-k_{on} c_A ^2 - \\left( k_{on} c_{X} (t=0) \\frac{SA_{PM}}{vol_{cyto}} + k_{off} - k_{on} c_A (t=0) \\right) c_A + k_{off} c_A(t=0)\n", + "$$\n", + "\n", + "Note that in this 2D case, $SA_{PM}$ is the perimeter of the ellipse and $vol_{cyto}$ is the area of the ellipse. These can be thought of as a surface area and volume if we extrude the 2D shape by some characteristic thickness." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4afd3149", + "metadata": {}, + "outputs": [], + "source": [ + "plt.plot(tvec, avg_A, label='SMART simulation')\n", + "plt.xlabel('Time (s)')\n", + "plt.ylabel('A concentration $\\mathrm{(molecules/μm^2)}$')\n", + "SA_vol = 4/(xrad + yrad)\n", + "root_vals = np.roots([-kon.value,\n", + " -kon.value*avg_X[0]*SA_vol - koff.value + kon.value*avg_A[0],\n", + " koff.value*avg_A[0]])\n", + "ss_pred = root_vals[root_vals > 0]\n", + "plt.plot(tvec, np.ones(len(avg_A))*ss_pred, '--', label='Steady-state analytical prediction')\n", + "plt.legend()\n", + "percent_error_analytical = 100*np.abs(ss_pred-avg_A[-1])/ss_pred\n", + "assert percent_error_analytical < 0.1,\\\n", + " f\"Failed test: Example 2 results deviate {percent_error_analytical:.3f}% from the analytical prediction\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffae481c", + "metadata": {}, + "outputs": [], + "source": [ + "from cowpy import cow\n", + "if True:#percent_error_analytical > 0.1:\n", + " stegy = cow.Stegosaurus(thoughts=True)\n", + " stegy_msg = stegy.milk(\"Failed test: Example 2 results deviate from the analytical prediction\")\n", + " print(stegy_msg)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "05f30a3c", + "metadata": {}, + "source": [ + "Plot A concentration in the cell at the final time point." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d950b475", + "metadata": {}, + "outputs": [], + "source": [ + "visualization.plot(model_cur.sc[\"A\"].u[\"u\"], view_xy=True)\n", + "# also save to file for comparison visualization in the end\n", + "meshimg_folder = pathlib.Path(\"mesh_images\")\n", + "meshimg_folder = meshimg_folder.resolve()\n", + "meshimg_folder.mkdir(exist_ok=True)\n", + "meshimg_file = meshimg_folder / \"ellipse_mesh_AR1.png\"" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/smart/model.py b/smart/model.py index 8f51475..6e8246b 100644 --- a/smart/model.py +++ b/smart/model.py @@ -1035,6 +1035,25 @@ def _init_4_7_set_initial_conditions(self): new_vals = vec_new[mesh_map] # reorder to match dof ordering vec.set_local(new_vals) vec.apply("insert") + for compartment in self._active_compartments: + # if vel is string, generate expr for advection + if compartment.vel != 0: + x, y, z = (sym.Symbol(f"x[{i}]") for i in range(3)) + vel_expr = [None] * len(compartment.vel) + # then this needs to have free symbols inserted + for i in range(len(compartment.vel)): + vel_str = compartment.vel[i] + # Parse the given string to create a sympy expression + sym_expr = parse_expr(vel_str).subs({"x": x, "y": y, "z": z, "t": self.t}) + free_symbols = [str(x) for x in sym_expr.free_symbols] + if not {"x[0]", "x[1]", "x[2]"}.issuperset(free_symbols): + # could add other keywords for spatial dependence in the future + raise NotImplementedError + else: + vel_expr[i] = sym.printing.ccode(sym_expr) + compartment.vel_expr = d.Expression(vel_expr, degree=1) + V_vector = d.VectorFunctionSpace(compartment.dolfin_mesh, "P", 1) + compartment.vel_func = d.interpolate(compartment.vel_expr, V_vector) def _init_5_1_reactions_to_fluxes(self): """Convert reactions to flux objects""" @@ -1110,20 +1129,79 @@ def _init_5_2_create_variational_forms(self): extra=dict(format_type="log"), ) else: - D_constant = d.Constant(D, name=f"D_{species.name}") - if self.config.flags["axisymmetric_model"]: - Dform = x[0] * D_constant * d.inner(d.grad(u), d.grad(v)) * dx + lagrange = True + if lagrange and species.compartment.vel != 0: # then nonzero advection + # alt Lagrangian form + D_constant = d.Constant(D, name=f"D_{species.name}") + vel = species.compartment.vel_func + udef = vel * d.Constant(self.t - self.tvec[0]) + F = d.Identity(3) + d.grad(udef) + J = d.det(F) + if self.config.flags["axisymmetric_model"]: + Dform = ( + x[0] + * D_constant + * J + * d.inner(d.dot(d.inv(F.T), d.grad(u)), d.dot(d.inv(F.T), d.grad(v))) + * dx + ) + else: + Dform = ( + D_constant + * J + * d.inner(d.dot(d.inv(F.T), d.grad(u)), d.dot(d.inv(F.T), d.grad(v))) + * dx + ) + self.forms.add( + Form( + f"diffusion_{species.name}", + Dform, + species, + "diffusion", + Dform_units, + True, + linear_wrt_comp, + ) + ) else: - Dform = D_constant * d.inner(d.grad(u), d.grad(v)) * dx - # exponent is -2 because of two gradients + D_constant = d.Constant(D, name=f"D_{species.name}") + if self.config.flags["axisymmetric_model"]: + Dform = x[0] * D_constant * d.inner(d.grad(u), d.grad(v)) * dx + else: + Dform = D_constant * d.inner(d.grad(u), d.grad(v)) * dx + # exponent is -2 because of two gradients + + self.forms.add( + Form( + f"diffusion_{species.name}", + Dform, + species, + "diffusion", + Dform_units, + True, + linear_wrt_comp, + ) + ) + if species.compartment.vel != 0: # then nonzero advection + vel = species.compartment.vel_func + if lagrange: + if self.config.flags["axisymmetric_model"]: + Aform = J * x[0] * (u * v * d.inner(d.inv(F), d.grad(vel)) * dx) + else: + Aform = J * u * v * d.inner(d.inv(F), d.grad(vel)) * dx + else: + if self.config.flags["axisymmetric_model"]: + Aform = x[0] * (u * v * d.div(vel) * dx + d.inner(v * vel, d.grad(u))) + else: + Aform = u * v * d.div(vel) * dx + d.inner(v * vel, d.grad(u)) * dx self.forms.add( Form( - f"diffusion_{species.name}", - Dform, + f"advection_{species.name}", + Aform, species, - "diffusion", - Dform_units, + "advection", + mass_form_units, True, linear_wrt_comp, ) diff --git a/smart/model_assembly.py b/smart/model_assembly.py index b8c6ef0..7b40ac5 100644 --- a/smart/model_assembly.py +++ b/smart/model_assembly.py @@ -1135,12 +1135,14 @@ class Compartment(ObjectInstance): dimensionality: topological dimensionality (e.g. 3 for volume, 2 for surface) compartment_units: length units for the compartment cell_marker: marker value identifying the compartment in the parent mesh + vel: string expression for advective velocity field within compartment """ name: str dimensionality: int compartment_units: pint.Unit cell_marker: Any + vel: Union[str, float] = 0.0 def to_dict(self): "Convert to a dict that can be used to recreate the object." @@ -1170,6 +1172,8 @@ def __post_init__(self): self._usplit = dict() self.V = None self.v = None + self.vel_expr = None + self.vel_func = None def check_validity(self): """ From 9ef3822fd50d7e4ea01cbfd36e16e3e58d942da7 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Sat, 12 Jul 2025 19:03:46 -0700 Subject: [PATCH 08/20] Add new meshing function for multicellular meshes --- smart/mesh_tools.py | 153 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 8 deletions(-) diff --git a/smart/mesh_tools.py b/smart/mesh_tools.py index fb04478..43456c1 100644 --- a/smart/mesh_tools.py +++ b/smart/mesh_tools.py @@ -719,6 +719,143 @@ def meshSizeCallback(dim, tag, x, y, z, lc): return (dmesh, mf2, mf3) +def create_multicell( + cubeSize: float = 100.0, + locVec: list = [[0, 0, 0]], + cellRad: float = 10.0, + hCube: float = 0, + hCell: float = 0, + interface_marker: int = 12, + outer_marker: int = 10, + extracell_tag: int = 2, + cell_vol_tag: int = 1, + comm: MPI.Comm = d.MPI.comm_world, + verbose: bool = False, +) -> Tuple[d.Mesh, d.MeshFunction, d.MeshFunction]: + """ + Creates a mesh with an outer cube containing embedded cells at specified locations. + Args: + cubeSize: Length of cube sides + locVec: vector of cell locations + hCube: maximum mesh size for cube + hCell: maximum mesh size for cell surfaces + interface_marker: The value to mark facets on the interface with + outer_marker: The value to mark facets on the outer ellipsoid with + inner_vol_tag: The value to mark the inner spherical volume with + outer_vol_tag: The value to mark the outer spherical volume with + comm: MPI communicator to create the mesh with + verbose: If true print gmsh output, else skip + Returns: + A triplet (mesh, facet_marker, cell_marker) + """ + import gmsh + + if np.isclose(cubeSize, 0): + ValueError("Outer cube size is equal to zero") + if np.isclose(hCube, 0): + hCube = 0.1 * max(cubeSize) + if np.isclose(hCell, 0): + hCell = 0.2 * cubeSize if np.isclose(cellRad, 0) else 0.2 * cellRad + # if innerRad > outerRad or innerLength >= outerLength: + # ValueError("Inner cylinder does not fit inside outer cylinder") + # Create the two cylinder mesh using gmsh + gmsh.initialize() + gmsh.option.setNumber("General.Terminal", int(verbose)) + + gmsh.model.add("multicell") + # first add outer cube + cube = gmsh.model.occ.addBox( + -cubeSize / 2, -cubeSize / 2, -cubeSize / 2, cubeSize, cubeSize, cubeSize + ) + if np.isclose(cellRad, 0): + # Just a cube! + gmsh.model.occ.synchronize() + gmsh.model.add_physical_group(3, [cube], tag=extracell_tag) + facets = gmsh.model.getBoundary([(3, cube)]) + gmsh.model.add_physical_group(2, [facets[0][1]], tag=outer_marker) + else: + # Add cells + cell_list = [] + for i in range(len(locVec)): + cur_tag = gmsh.model.occ.addSphere(locVec[i][0], locVec[i][1], locVec[i][2], cellRad) + cell_list.append((3, cur_tag)) + # Create interface between cells and extracell + full_geo, maps = gmsh.model.occ.fragment([(3, cube)], cell_list) + cube_map = maps[0] + cell_maps = maps[1:] + gmsh.model.occ.synchronize() + + # Get the outer boundary + outer_shells = gmsh.model.getBoundary(full_geo, oriented=False) + # Get the inner boundary + inner_shells = [] + for i in range(len(cell_maps)): + inner_shells.append(gmsh.model.getBoundary(cell_maps[i], oriented=False)) + # Add physical markers for facets + gmsh.model.add_physical_group(2, [faces[1] for faces in outer_shells], tag=outer_marker) + gmsh.model.add_physical_group( + 2, [faces[0][1] for faces in inner_shells], tag=interface_marker + ) + + # Physical markers for + all_volumes = [tag[1] for tag in cube_map] + inner_volume = [tag[0][1] for tag in cell_maps] + outer_volume = [] + for vol in all_volumes: + if vol not in inner_volume: + outer_volume.append(vol) + gmsh.model.add_physical_group(3, outer_volume, tag=extracell_tag) + gmsh.model.add_physical_group(3, inner_volume, tag=cell_vol_tag) + + def meshSizeCallback(dim, tag, x, y, z, lc): + # mesh length is hEdge at the PM (defaults to 0.1*outerRad, + # or set when calling function) and hInnerEdge at the ERM + # (defaults to 0.2*innerRad, or set when calling function) + # between these, the value is interpolated based on r (polar coord), + # and inside the value is interpolated between hInnerEdge and 0.2*innerRad + # if innerRad=0, then the mesh length is interpolated between + # hEdge at the PM and 0.2*outerRad in the center + + if np.isclose(cellRad, 0): + return hCube + cell_locs = np.sqrt( + (x - np.array(locVec)[:, 0]) ** 2 + + (y - np.array(locVec)[:, 1]) ** 2 + + (z - np.array(locVec)[:, 2]) ** 2 + ) + closest_cell = min(cell_locs) + cellWeight = np.exp(-(closest_cell - cellRad) / (0.2 * cellRad)) + if closest_cell < cellRad: + return hCell + else: + return hCell * cellWeight + hCube * (1 - cellWeight) + + gmsh.model.mesh.setSizeCallback(meshSizeCallback) + # set off the other options for mesh size determination + gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 0) + gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 0) + gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0) + # this changes the algorithm from Frontal-Delaunay to Delaunay, + # which may provide better results when there are larger gradients in mesh size + gmsh.option.setNumber("Mesh.Algorithm", 5) + + gmsh.model.mesh.generate(3) + rank = MPI.COMM_WORLD.rank + tmp_folder = pathlib.Path(f"tmp_extracell_{cubeSize}_{cellRad}_{rank}") + tmp_folder.mkdir(exist_ok=True) + gmsh_file = tmp_folder / "extracell.msh" + gmsh.write(str(gmsh_file)) + gmsh.finalize() + + # return dolfin mesh of max dimension (parent mesh) and marker functions mf2 and mf3 + dmesh, mf2, mf3 = gmsh_to_dolfin(str(gmsh_file), tmp_folder, 3, comm) + # remove tmp mesh and tmp folder + gmsh_file.unlink(missing_ok=False) + tmp_folder.rmdir() + # return dolfin mesh, mf2 (2d tags) and mf3 (3d tags) + return (dmesh, mf2, mf3) + + def create_ellipses( xrad_outer: float = 3.0, yrad_outer: float = 1.0, @@ -1547,11 +1684,11 @@ def meshSizeCallback(dim, tag, x, y, z, lc): gmsh.finalize() # return dolfin mesh of max dimension (parent mesh) and marker functions mf2 and mf3 - dmesh, mf2, mf3 = gmsh_to_dolfin(str(gmsh_file), tmp_folder, 2, comm) + dmesh, mf1, mf2 = gmsh_to_dolfin(str(gmsh_file), tmp_folder, 2, comm) # remove tmp mesh and tmp folder gmsh_file.unlink(missing_ok=False) tmp_folder.rmdir() - # return dolfin mesh, mf2 (2d tags) and mf3 (3d tags) + # return dolfin mesh, mf1 (1d tags) and mf2 (2d tags) if return_curvature: if innerExpr == "": facet_list = [outer_marker] @@ -1560,7 +1697,7 @@ def meshSizeCallback(dim, tag, x, y, z, lc): facet_list = [outer_marker, interface_marker] cell_list = [outer_tag, inner_tag] if half_cell_with_curvature: # will likely not work in parallel... - dmesh_half, mf2_half, mf3_half = create_2Dcell( + dmesh_half, mf1_half, mf2_half = create_2Dcell( outerExpr, innerExpr, hEdge, @@ -1575,14 +1712,14 @@ def meshSizeCallback(dim, tag, x, y, z, lc): return_curvature=False, ) kappa_mf = compute_curvature( - dmesh, mf2, mf3, facet_list, cell_list, half_mesh_data=(dmesh_half, mf2_half) + dmesh, mf1, mf2, facet_list, cell_list, half_mesh_data=(dmesh_half, mf1_half) ) - (dmesh, mf2, mf3) = (dmesh_half, mf2_half, mf3_half) + (dmesh, mf1, mf2) = (dmesh_half, mf1_half, mf2_half) else: - kappa_mf = compute_curvature(dmesh, mf2, mf3, facet_list, cell_list) - return (dmesh, mf2, mf3, kappa_mf) + kappa_mf = compute_curvature(dmesh, mf1, mf2, facet_list, cell_list) + return (dmesh, mf1, mf2, kappa_mf) else: - return (dmesh, mf2, mf3) + return (dmesh, mf1, mf2) def gmsh_to_dolfin( From b1d24be32953cceb33ccb852aff7becdaad96bef Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:25:03 -0700 Subject: [PATCH 09/20] Update new example 7 for testing reaction-diffusion through multicellular network, ensure non-negativity of solution --- examples/example7/example7.ipynb | 304 +++++++++++++++++++++++++++++++ smart/mesh_tools.py | 101 +++++++--- 2 files changed, 379 insertions(+), 26 deletions(-) create mode 100644 examples/example7/example7.ipynb diff --git a/examples/example7/example7.ipynb b/examples/example7/example7.ipynb new file mode 100644 index 0000000..2c86caa --- /dev/null +++ b/examples/example7/example7.ipynb @@ -0,0 +1,304 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "f65f18d7", + "metadata": {}, + "source": [ + "# Example 7: Reaction-diffusion of molecule in a multicellular network\n", + "\n", + "Here, we consider the diffusion of a molecule from a central cell in a multicellular mesh to other cells in the environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc398816", + "metadata": {}, + "outputs": [], + "source": [ + "import dolfin as d\n", + "import sympy as sym\n", + "import numpy as np\n", + "import pathlib\n", + "import logging\n", + "import gmsh # must be imported before pyvista if dolfin is imported first\n", + "\n", + "from smart import config, mesh, model, mesh_tools, visualization\n", + "from smart.units import unit\n", + "from smart.model_assembly import (\n", + " Compartment,\n", + " Parameter,\n", + " Reaction,\n", + " Species,\n", + " SpeciesContainer,\n", + " ParameterContainer,\n", + " CompartmentContainer,\n", + " ReactionContainer,\n", + ")\n", + "\n", + "from matplotlib import pyplot as plt\n", + "import matplotlib.image as mpimg\n", + "from matplotlib import rcParams\n", + "\n", + "logger = logging.getLogger(\"smart\")\n", + "logger.setLevel(logging.INFO)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "95b9d865", + "metadata": {}, + "source": [ + "We define the relevant units here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f4023cf", + "metadata": {}, + "outputs": [], + "source": [ + "# Aliases - base units\n", + "uM = unit.uM\n", + "um = unit.um\n", + "molecule = unit.molecule\n", + "sec = unit.sec\n", + "dimensionless = unit.dimensionless\n", + "# Aliases - units used in model\n", + "D_unit = um**2 / sec\n", + "flux_unit = uM * um / sec\n", + "vol_unit = uM\n", + "surf_unit = molecule / um**2" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "46582d26", + "metadata": {}, + "source": [ + "## Model generation\n", + "\n", + "We define the compartments and species first, with their respective containers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02a000f2", + "metadata": {}, + "outputs": [], + "source": [ + "EC = Compartment(\"EC\", 3, um, 1)\n", + "PM1 = Compartment(\"PM1\", 2, um, 11) # source cell\n", + "PM2 = Compartment(\"PM2\", 2, um, 12) # other cells\n", + "PM1.specify_nonadjacency(['PM2'])\n", + "PM2.specify_nonadjacency(['PM1'])\n", + "\n", + "cc = CompartmentContainer()\n", + "cc.add([EC, PM1, PM2])\n", + "\n", + "G = Species(\"G\", 1.0, vol_unit, 1000.0, D_unit, \"EC\")\n", + "Rbound = Species(\"Rbound\", 0.0, surf_unit, 0.0, D_unit, \"PM2\")\n", + "sc = SpeciesContainer()\n", + "sc.add([G, Rbound])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "3c56e840", + "metadata": {}, + "source": [ + "Define parameters and reactions, then place in respective containers. Here, there are 3 reactions:\n", + "* r1: release of G from PM1\n", + "* r2: binding of G to PM2\n", + "* r3: degradation of G" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e1f6882", + "metadata": {}, + "outputs": [], + "source": [ + "# G release from cell 1\n", + "j1pulse = Parameter(\"j1pulse\", 1000.0, flux_unit)\n", + "r1 = Reaction(\n", + " \"r1\",\n", + " [],\n", + " [\"G\"],\n", + " param_map={\"J\": \"j1pulse\"},\n", + " eqn_f_str=\"J\",\n", + " explicit_restriction_to_domain=\"PM1\",\n", + ")\n", + "# G binding to PM2\n", + "Rtot = Parameter(\"Rtot\", 100.0, surf_unit)\n", + "kbind = Parameter(\"kbind\", 1.0, 1/(uM*sec))\n", + "kunbind = Parameter(\"kunbind\", 0.01, 1/sec)\n", + "r2 = Reaction(\"r2\", [\"G\"], [\"Rbound\"],\n", + " param_map={\"on\":\"kbind\",\"off\":\"kunbind\",\"Rtot\":\"Rtot\"},\n", + " eqn_f_str=\"on*G*(Rtot-Rbound) - off*Rbound\",\n", + " explicit_restriction_to_domain=\"PM2\")\n", + "\n", + "# G degradation\n", + "kdeg = Parameter(\"kdeg\", 0.01, 1/sec)\n", + "r3 = Reaction(\"r3\", [\"G\"], [], param_map={\"k\":\"kdeg\"},\n", + " eqn_f_str=\"k*G\")\n", + "\n", + "pc = ParameterContainer()\n", + "pc.add([j1pulse,Rtot,kbind,kunbind,kdeg])\n", + "rc = ReactionContainer()\n", + "rc.add([r1,r2,r3])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "15c35d39", + "metadata": {}, + "source": [ + "## Create and load in mesh\n", + "\n", + "Here, we consider cells embedded in a cube mesh. The source cell is located at (0,0,0) and 8 other cells are spread equidistant through the mesh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe56e162", + "metadata": {}, + "outputs": [], + "source": [ + "domain, facet_markers, cell_markers = mesh_tools.create_multicell(cubeSize=100.0, locVec1=[[0,0,0]],\n", + " locVec2=[[-30,-30,-30], [-30,30,-30], [-30,-30,30], [-30,30,30], \n", + " [30,-30,-30], [30,30,-30], [30,-30,30], [30,30,30]],\n", + " cellRad1 = 10.0, cellRad2 = 10.0, hCube = 5.0, hCell = 2.0)\n", + "# Write mesh and meshfunctions to file\n", + "mesh_folder = pathlib.Path(\"mesh\")\n", + "mesh_folder.mkdir(exist_ok=True)\n", + "mesh_path = mesh_folder / \"multicell_mesh.h5\"\n", + "mesh_tools.write_mesh(\n", + " domain, facet_markers, cell_markers, filename=mesh_path\n", + ")\n", + "parent_mesh = mesh.ParentMesh(\n", + " mesh_filename=str(mesh_path),\n", + " mesh_filetype=\"hdf5\",\n", + " name=\"parent_mesh\",\n", + ")\n", + "visualization.plot_dolfin_mesh(domain, cell_markers, facet_markers)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "0943588e", + "metadata": {}, + "source": [ + "Initialize model and solver." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac88bdec", + "metadata": {}, + "outputs": [], + "source": [ + "config_cur = config.Config()\n", + "config_cur.flags.update({\"allow_unused_components\": True})\n", + "model_cur = model.Model(pc, sc, cc, rc, config_cur, parent_mesh)\n", + "config_cur.solver.update(\n", + " {\n", + " \"final_t\": 10.0,\n", + " \"initial_dt\": 0.001,\n", + " \"time_precision\": 8,\n", + " \"reset_timestep_for_negative_solution\": True,\n", + " }\n", + ")\n", + "model_cur.initialize()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "5d5aacbd", + "metadata": {}, + "source": [ + "Initialize XDMF files for saving results, save model information to .pkl file, then solve the system until `model_cur.t > model_cur.final_t`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b54d28ca", + "metadata": {}, + "outputs": [], + "source": [ + "# Write initial condition(s) to file\n", + "results = dict()\n", + "result_folder = pathlib.Path(f\"results\")\n", + "result_folder.mkdir(exist_ok=True)\n", + "for species_name, species in model_cur.sc.items:\n", + " results[species_name] = d.XDMFFile(\n", + " model_cur.mpi_comm_world, str(result_folder / f\"{species_name}.xdmf\")\n", + " )\n", + " results[species_name].parameters[\"flush_output\"] = True\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + "model_cur.to_pickle(\"model_cur.pkl\")\n", + "\n", + "# Set loglevel to warning in order not to pollute notebook output\n", + "logger.setLevel(logging.WARNING)\n", + "# Solve\n", + "displayed = False\n", + "while True:\n", + " # Solve the system\n", + " model_cur.monolithic_solve()\n", + " model_cur.adjust_dt()\n", + " # Save results for post processing\n", + " for species_name, species in model_cur.sc.items:\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + " print(f\"Done with t={model_cur.t}\")\n", + " # End if we've passed the final time\n", + " if model_cur.t >= model_cur.final_t:\n", + " break" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "vscode": { + "interpreter": { + "hash": "916dbcbb3f70747c44a77c7bcd40155683ae19c65e1c03b4aa3499c5328201f1" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/smart/mesh_tools.py b/smart/mesh_tools.py index 43456c1..4ffabef 100644 --- a/smart/mesh_tools.py +++ b/smart/mesh_tools.py @@ -721,14 +721,18 @@ def meshSizeCallback(dim, tag, x, y, z, lc): def create_multicell( cubeSize: float = 100.0, - locVec: list = [[0, 0, 0]], - cellRad: float = 10.0, + locVec1: list = [[0, 0, 0]], + locVec2: list = [], + cellRad1: float = 10.0, + cellRad2: float = 10.0, hCube: float = 0, hCell: float = 0, - interface_marker: int = 12, + interface_marker1: int = 11, + interface_marker2: int = 12, outer_marker: int = 10, - extracell_tag: int = 2, - cell_vol_tag: int = 1, + extracell_tag: int = 1, + cell_vol_tag1: int = 2, + cell_vol_tag2: int = 3, comm: MPI.Comm = d.MPI.comm_world, verbose: bool = False, ) -> Tuple[d.Mesh, d.MeshFunction, d.MeshFunction]: @@ -755,7 +759,7 @@ def create_multicell( if np.isclose(hCube, 0): hCube = 0.1 * max(cubeSize) if np.isclose(hCell, 0): - hCell = 0.2 * cubeSize if np.isclose(cellRad, 0) else 0.2 * cellRad + hCell = 0.2 * cubeSize if np.isclose(cellRad1, 0) else 0.2 * cellRad1 # if innerRad > outerRad or innerLength >= outerLength: # ValueError("Inner cylinder does not fit inside outer cylinder") # Create the two cylinder mesh using gmsh @@ -767,7 +771,9 @@ def create_multicell( cube = gmsh.model.occ.addBox( -cubeSize / 2, -cubeSize / 2, -cubeSize / 2, cubeSize, cubeSize, cubeSize ) - if np.isclose(cellRad, 0): + if (np.isclose(cellRad1, 0) or len(locVec1) == 0) and ( + np.isclose(cellRad2, 0) or len(locVec2) == 0 + ): # Just a cube! gmsh.model.occ.synchronize() gmsh.model.add_physical_group(3, [cube], tag=extracell_tag) @@ -776,8 +782,17 @@ def create_multicell( else: # Add cells cell_list = [] - for i in range(len(locVec)): - cur_tag = gmsh.model.occ.addSphere(locVec[i][0], locVec[i][1], locVec[i][2], cellRad) + # first add source cell(s) + for i in range(len(locVec1)): + cur_tag = gmsh.model.occ.addSphere( + locVec1[i][0], locVec1[i][1], locVec1[i][2], cellRad1 + ) + cell_list.append((3, cur_tag)) + # now add additional cells + for i in range(len(locVec2)): + cur_tag = gmsh.model.occ.addSphere( + locVec2[i][0], locVec2[i][1], locVec2[i][2], cellRad2 + ) cell_list.append((3, cur_tag)) # Create interface between cells and extracell full_geo, maps = gmsh.model.occ.fragment([(3, cube)], cell_list) @@ -788,24 +803,32 @@ def create_multicell( # Get the outer boundary outer_shells = gmsh.model.getBoundary(full_geo, oriented=False) # Get the inner boundary - inner_shells = [] - for i in range(len(cell_maps)): - inner_shells.append(gmsh.model.getBoundary(cell_maps[i], oriented=False)) + inner_shells1 = [] + inner_shells2 = [] + for i in range(0, len(locVec1)): + inner_shells1.append(gmsh.model.getBoundary(cell_maps[i], oriented=False)) + for i in range(len(locVec1), len(cell_maps)): + inner_shells2.append(gmsh.model.getBoundary(cell_maps[i], oriented=False)) # Add physical markers for facets gmsh.model.add_physical_group(2, [faces[1] for faces in outer_shells], tag=outer_marker) gmsh.model.add_physical_group( - 2, [faces[0][1] for faces in inner_shells], tag=interface_marker + 2, [faces[0][1] for faces in inner_shells1], tag=interface_marker1 + ) + gmsh.model.add_physical_group( + 2, [faces[0][1] for faces in inner_shells2], tag=interface_marker2 ) # Physical markers for all_volumes = [tag[1] for tag in cube_map] - inner_volume = [tag[0][1] for tag in cell_maps] + inner_volumes1 = [tag[0][1] for tag in cell_maps[0 : len(locVec1)]] + inner_volumes2 = [tag[0][1] for tag in cell_maps[len(locVec1) :]] outer_volume = [] for vol in all_volumes: - if vol not in inner_volume: + if (vol not in inner_volumes1) and (vol not in inner_volumes2): outer_volume.append(vol) gmsh.model.add_physical_group(3, outer_volume, tag=extracell_tag) - gmsh.model.add_physical_group(3, inner_volume, tag=cell_vol_tag) + gmsh.model.add_physical_group(3, inner_volumes1, tag=cell_vol_tag1) + gmsh.model.add_physical_group(3, inner_volumes2, tag=cell_vol_tag2) def meshSizeCallback(dim, tag, x, y, z, lc): # mesh length is hEdge at the PM (defaults to 0.1*outerRad, @@ -816,18 +839,44 @@ def meshSizeCallback(dim, tag, x, y, z, lc): # if innerRad=0, then the mesh length is interpolated between # hEdge at the PM and 0.2*outerRad in the center - if np.isclose(cellRad, 0): + if (np.isclose(cellRad1, 0) or len(locVec1) == 0) and ( + np.isclose(cellRad2, 0) or len(locVec2) == 0 + ): return hCube - cell_locs = np.sqrt( - (x - np.array(locVec)[:, 0]) ** 2 - + (y - np.array(locVec)[:, 1]) ** 2 - + (z - np.array(locVec)[:, 2]) ** 2 - ) - closest_cell = min(cell_locs) - cellWeight = np.exp(-(closest_cell - cellRad) / (0.2 * cellRad)) - if closest_cell < cellRad: + elif np.isclose(cellRad1, 0) or len(locVec1) == 0: + cell_locs1 = [np.inf] + cell_locs2 = np.sqrt( + (x - np.array(locVec2)[:, 0]) ** 2 + + (y - np.array(locVec2)[:, 1]) ** 2 + + (z - np.array(locVec2)[:, 2]) ** 2 + ) + elif np.isclose(cellRad2, 0) or len(locVec2) == 0: + cell_locs1 = np.sqrt( + (x - np.array(locVec1)[:, 0]) ** 2 + + (y - np.array(locVec1)[:, 1]) ** 2 + + (z - np.array(locVec1)[:, 2]) ** 2 + ) + cell_locs2 = [np.inf] + else: + cell_locs1 = np.sqrt( + (x - np.array(locVec1)[:, 0]) ** 2 + + (y - np.array(locVec1)[:, 1]) ** 2 + + (z - np.array(locVec1)[:, 2]) ** 2 + ) + cell_locs2 = np.sqrt( + (x - np.array(locVec2)[:, 0]) ** 2 + + (y - np.array(locVec2)[:, 1]) ** 2 + + (z - np.array(locVec2)[:, 2]) ** 2 + ) + closest_cell1 = min(cell_locs1) + closest_cell2 = min(cell_locs2) + if (closest_cell1 < cellRad1) or (closest_cell2 < cellRad2): return hCell else: + if closest_cell1 < closest_cell2: + cellWeight = np.exp(-(closest_cell1 - cellRad1) / (0.2 * cellRad1)) + else: + cellWeight = np.exp(-(closest_cell2 - cellRad2) / (0.2 * cellRad2)) return hCell * cellWeight + hCube * (1 - cellWeight) gmsh.model.mesh.setSizeCallback(meshSizeCallback) @@ -841,7 +890,7 @@ def meshSizeCallback(dim, tag, x, y, z, lc): gmsh.model.mesh.generate(3) rank = MPI.COMM_WORLD.rank - tmp_folder = pathlib.Path(f"tmp_extracell_{cubeSize}_{cellRad}_{rank}") + tmp_folder = pathlib.Path(f"tmp_extracell_{cubeSize}_{cellRad1}_{cellRad2}_{rank}") tmp_folder.mkdir(exist_ok=True) gmsh_file = tmp_folder / "extracell.msh" gmsh.write(str(gmsh_file)) From 25e70770bc1f1b98fa846ec0a2f6acd397ad37bc Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:32:20 -0700 Subject: [PATCH 10/20] bump version number in pyproject --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7a80519..e444256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel"] [project] name = "fenics-smart" -version = "2.3.0" +version = "2.3.0.beta.1" description = "Spatial Modeling Algorithms for Reactions and Transport (SMART) is a high-performance finite-element-based simulation package for model specification and numerical simulation of spatially-varying reaction-transport processes in biological cells." authors = [{ name = "Justin Laughlin", email = "justinglaughlin@gmail.com" }] license = { file = "LICENSE" } From c5e33d7c275758fd3595890c5500acbf8049e9c7 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Sun, 20 Jul 2025 18:17:43 -0700 Subject: [PATCH 11/20] Update Lagrangian formulas, add example 2 file to test Lagrangian vs Eulerian meshes --- examples/example2/example2.ipynb | 1 + examples/example2/example2_lagrangeTest.py | 257 ++++++++++++++++++ .../example2/example2_withadvection.ipynb | 230 +++++++--------- smart/model.py | 169 ++++++++---- smart/model_assembly.py | 83 +++++- 5 files changed, 541 insertions(+), 199 deletions(-) create mode 100644 examples/example2/example2_lagrangeTest.py diff --git a/examples/example2/example2.ipynb b/examples/example2/example2.ipynb index 46b7a3c..5555dfa 100644 --- a/examples/example2/example2.ipynb +++ b/examples/example2/example2.ipynb @@ -429,6 +429,7 @@ " outer_tag=surf_tag, outer_marker=edge_tag)\n", " mesh_folder = pathlib.Path(f\"ellipse_mesh_AR{aspect_ratios[i]}\")\n", " mesh_folder.mkdir(exist_ok=True)\n", + " mesh_file = mesh_folder / \"ellipse_mesh.h5\"\n", " mesh_tools.write_mesh(ellipse_mesh, mf1, mf2, mesh_file)\n", " parent_mesh = mesh.ParentMesh(\n", " mesh_filename=str(mesh_file),\n", diff --git a/examples/example2/example2_lagrangeTest.py b/examples/example2/example2_lagrangeTest.py new file mode 100644 index 0000000..28e05d7 --- /dev/null +++ b/examples/example2/example2_lagrangeTest.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Example 2: Simple 2D cell signaling model +# Test alterations in mesh vs. Lagrangian mapping for different cases + +import dolfin as d +import numpy as np +import pathlib +import logging + +from smart import config, mesh, model, mesh_tools +from smart.units import unit +from smart.model_assembly import ( + Compartment, + Parameter, + Reaction, + Species, + SpeciesContainer, + ParameterContainer, + CompartmentContainer, + ReactionContainer, +) +from matplotlib import pyplot as plt + +logger = logging.getLogger("smart") +logger.setLevel(logging.INFO) + +um = unit.um +molecule = unit.molecule +sec = unit.sec +dimensionless = unit.dimensionless +D_unit = um**2 / sec +surf_unit = molecule / um**2 +flux_unit = molecule / (um * sec) +edge_unit = molecule / um + +# ============================================================================================= +# Compartments +# ============================================================================================= +# name, topological dimensionality, length scale units, marker value +Cyto = Compartment("Cyto", 2, um, 1) +PM = Compartment("PM", 1, um, 3) +cc = CompartmentContainer() +cc.add([Cyto, PM]) + +# ============================================================================================= +# Species +# ============================================================================================= +# name, initial concentration, concentration units, diffusion, diffusion units, compartment +A = Species("A", 1.0, surf_unit, 0.001, D_unit, "Cyto") +X = Species("X", 1.0, edge_unit, 0.001, D_unit, "PM") +B = Species("B", 0.0, edge_unit, 0.001, D_unit, "PM") +sc = SpeciesContainer() +sc.add([A, X, B]) + +# ============================================================================================= +# Parameters and Reactions +# ============================================================================================= + +# Reaction of A and X to make B (Cyto-PM reaction) +kon = Parameter("kon", 1.0, 1 / (surf_unit * sec)) +koff = Parameter("koff", 1.0, 1 / sec) +r1 = Reaction( + "r1", + ["A", "X"], + ["B"], + param_map={"on": "kon", "off": "koff"}, + species_map={"A": "A", "X": "X", "B": "B"}, +) + +pc = ParameterContainer() +pc.add([kon, koff]) +rc = ReactionContainer() +rc.add([r1]) + +h_ellipse = 0.2 +xrad = 1.0 +yrad = 1.0 +surf_tag = 1 +edge_tag = 3 +config_cur = config.Config() +config_cur.solver.update( + { + "final_t": 2.0, + "initial_dt": 0.1, + "time_precision": 6, + } +) + +# iterate over additional aspect ratios, enforcing the same ellipsoid area in all cases +aspect_ratios = [1, 1.5**2, 4, 9, 16] +l2_lagrange = [] +l2_eul = [] +Avecs = [] + +parent_folder = pathlib.Path("lagrange_testing") +parent_folder.mkdir(exist_ok=True) + +for i in range(len(aspect_ratios)): + + udefs = [] + lagrangeSS = [] + refSS = [] + for lagrange in [False, True]: + if lagrange: + Cyto = Compartment( + "Cyto", + 2, + um, + 1, + deform=( + f"x*{np.sqrt(aspect_ratios[i])-1}", + f"y*{1/np.sqrt(aspect_ratios[i]) - 1}", + "0", + ), + ) + PM = Compartment( + "PM", + 1, + um, + 3, + deform=( + f"x*{np.sqrt(aspect_ratios[i])-1}", + f"y*{1/np.sqrt(aspect_ratios[i]) - 1}", + "0", + ), + ) + else: + Cyto = Compartment("Cyto", 2, um, 1) + PM = Compartment("PM", 1, um, 3) + cc = CompartmentContainer() + cc.add([Cyto, PM]) + + # Create mesh + if not lagrange: + xrad = 1.0 * np.sqrt(aspect_ratios[i]) + yrad = 1.0 / np.sqrt(aspect_ratios[i]) + ellipse_mesh, mf1, mf2 = mesh_tools.create_ellipses( + xrad, + yrad, + hEdge=h_ellipse, + hInnerEdge=h_ellipse, + outer_tag=surf_tag, + outer_marker=edge_tag, + ) + mesh_folder = parent_folder / f"ellipse_mesh_AR{aspect_ratios[i]}" + mesh_folder.mkdir(exist_ok=True) + else: + mesh_folder = parent_folder / "ellipse_mesh_AR1" + + mesh_refinements = [0, 1, 2, 3, 4, 5, 6] + for j in range(len(mesh_refinements)): + mesh_file = mesh_folder / f"ellipse_mesh_{mesh_refinements[j]}.h5" + if not lagrange: + if mesh_refinements[j] > 0: + d.parameters["refinement_algorithm"] = "plaza_with_parent_facets" + ellipse_mesh = d.adapt(ellipse_mesh) + mf2 = d.adapt(mf2, ellipse_mesh) + mf1 = d.adapt(mf1, ellipse_mesh) + mesh_tools.write_mesh(ellipse_mesh, mf1, mf2, mesh_file) + + parent_mesh = mesh.ParentMesh( + mesh_filename=str(mesh_file), + mesh_filetype="hdf5", + name="parent_mesh", + ) + # init model + model_cur = model.Model(pc, sc, cc, rc, config_cur, parent_mesh) + model_cur.initialize() + results = dict() + result_folder = ( + parent_folder + / f"resultsEllipse_AR{aspect_ratios[i]}_lagrange{lagrange}_{mesh_refinements[j]}" + ) + result_folder.mkdir(exist_ok=True) + for species_name, species in model_cur.sc.items: + results[species_name] = d.XDMFFile( + model_cur.mpi_comm_world, str(result_folder / f"{species_name}.xdmf") + ) + results[species_name].parameters["flush_output"] = True + results[species_name].write(model_cur.sc[species_name].u["u"], model_cur.t) + if lagrange: + u1file = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / "u1var.xdmf")) + u1file.parameters["flush_output"] = True + u1file.write(cc["Cyto"].deform_func, model_cur.t) + udef = cc["Cyto"].deform_func + Fcur = d.Identity(3) + d.grad(udef) + Jcur = d.det(Fcur) + else: + Jcur = d.Constant(1.0) + avg_A = [A.initial_condition] + dx = d.Measure("dx", domain=model_cur.cc["Cyto"].dolfin_mesh) + volume = d.assemble_mixed(Jcur * dx) + while True: + # Solve the system + model_cur.monolithic_solve() + # Save results for post processing + for species_name, species in model_cur.sc.items: + results[species_name].write(model_cur.sc[species_name].u["u"], model_cur.t) + if lagrange: + u1file.write(cc["Cyto"].deform_func, model_cur.t) + int_val = d.assemble_mixed(Jcur * model_cur.sc["A"].u["u"] * dx) + avg_A.append(int_val / volume) + # End if we've passed the final time + if model_cur.t >= model_cur.final_t: + break + + np.savetxt(str(result_folder / "avg_A.txt"), avg_A) + Avecs.append(avg_A) + + if lagrange: + plt.plot( + model_cur.tvec, + avg_A, + linestyle="dashed", + label=f"Aspect ratio = {aspect_ratios[i]}, Lagrange", + ) + lagrangeSS.append(model_cur.sc["A"].sol.copy()) + udefs.append(udef) + else: + plt.plot(model_cur.tvec, avg_A, label=f"Aspect ratio = {aspect_ratios[i]}") + refSS.append(model_cur.sc["A"].sol.copy()) + + refFcn = refSS[-1] # use the most refined reference (eulerian) mesh + Vcur = refFcn.function_space() + coords = Vcur.tabulate_dof_coordinates() + dx = d.Measure("dx", domain=Vcur.mesh()) + l2_lagrangeCur = [] + l2_refCur = [] + for k in range(len(refSS) - 1): + lagrange_fun = d.Function(Vcur) + lagrange_vec = lagrange_fun.vector().get_local() + eul_fun = d.Function(Vcur) + eul_vec = eul_fun.vector().get_local() + + refSS[k].set_allow_extrapolation(True) + Lmesh = lagrangeSS[k].function_space().mesh() + d.ALE.move(Lmesh, udefs[k]) + Lmesh.bounding_box_tree().build(Lmesh) + lagrangeSS[k].set_allow_extrapolation(True) + for idx in range(len(lagrange_vec)): + cur_coord = coords[idx] + eul_vec[idx] = refSS[k](cur_coord) + lagrange_vec[idx] = lagrangeSS[k](cur_coord) + eul_fun.vector().set_local(eul_vec) + eul_fun.vector().apply("insert") + lagrange_fun.vector().set_local(lagrange_vec) + lagrange_fun.vector().apply("insert") + l2_lagrangeCur.append(np.sqrt(d.assemble((lagrange_fun - refFcn) ** 2 * dx))) + l2_refCur.append(np.sqrt(d.assemble((eul_fun - refFcn) ** 2 * dx))) + + np.savetxt(str(parent_folder / f"l2_lagrange_AR{aspect_ratios[i]}.txt"), l2_lagrangeCur) + np.savetxt(str(parent_folder / f"l2_eul_AR{aspect_ratios[i]}.txt"), l2_refCur) + +plt.legend() +plt.show() diff --git a/examples/example2/example2_withadvection.ipynb b/examples/example2/example2_withadvection.ipynb index f0f47d9..1944777 100644 --- a/examples/example2/example2_withadvection.ipynb +++ b/examples/example2/example2_withadvection.ipynb @@ -137,8 +137,12 @@ "# Compartments\n", "# =============================================================================================\n", "# name, topological dimensionality, length scale units, marker value\n", - "Cyto = Compartment(\"Cyto\", 2, um, 1, vel=(\"-0.1*y*(1 - (x**2+y**2))\",\"0.1*x*(1 - (x**2+y**2))\",\"0\"))\n", - "PM = Compartment(\"PM\", 1, um, 3)\n", + "# Cyto = Compartment(\"Cyto\", 2, um, 1, vel=(\"-0.1*y*(1 - (x**2+y**2))\",\"0.1*x*(1 - (x**2+y**2))\",\"0\"))\n", + "# Cyto = Compartment(\"Cyto\", 2, um, 1, deform=(\"-0.1*y*t*(1 - (x**2+y**2))\",\"0.1*x*t*(1 - (x**2+y**2))\",\"0\"), manual_update=True)\n", + "Cyto = Compartment(\"Cyto\", 2, um, 1, deform=(\"((1+sign(t-10))/2)*0.005*(t-10)*x/(1-0.005*(t-10))\",\n", + " \"-((1+sign(t-10))/2)*0.005*(t-10)*y\",\"0\")) # dilation\n", + "PM = Compartment(\"PM\", 1, um, 3, deform=(\"((1+sign(t-10))/2)*0.005*(t-10)*x/(1-0.005*(t-10))\",\n", + " \"-((1+sign(t-10))/2)*0.005*(t-10)*y\",\"0\"))\n", "cc = CompartmentContainer()\n", "cc.add([Cyto, PM])\n", "\n", @@ -146,9 +150,10 @@ "# Species\n", "# =============================================================================================\n", "# name, initial concentration, concentration units, diffusion, diffusion units, compartment\n", - "A = Species(\"A\", \"10*exp(-(x**2+(y-0.8)**2)/0.2**2)\", surf_unit, 0.001, D_unit, \"Cyto\")\n", - "X = Species(\"X\", 1.0, edge_unit, 1.0, D_unit, \"PM\")\n", - "B = Species(\"B\", 0.0, edge_unit, 1.0, D_unit, \"PM\")\n", + "# A = Species(\"A\", \"10*exp(-(x**2+(y-0.8)**2)/0.2**2)\", surf_unit, 0.001, D_unit, \"Cyto\")\n", + "A = Species(\"A\", 1.0, surf_unit, 0.001, D_unit, \"Cyto\")\n", + "X = Species(\"X\", 1.0, edge_unit, 0.001, D_unit, \"PM\")\n", + "B = Species(\"B\", 0.0, edge_unit, 0.001, D_unit, \"PM\")\n", "sc = SpeciesContainer()\n", "sc.add([A, X, B])\n", "\n", @@ -157,7 +162,7 @@ "# =============================================================================================\n", "\n", "# Reaction of A and X to make B (Cyto-PM reaction)\n", - "kon = Parameter(\"kon\", 0.0, 1/(surf_unit*sec))\n", + "kon = Parameter(\"kon\", 1.0, 1/(surf_unit*sec))\n", "koff = Parameter(\"koff\", 1.0, 1/sec)\n", "r1 = Reaction(\"r1\", [\"A\", \"X\"], [\"B\"],\n", " param_map={\"on\": \"kon\", \"off\": \"koff\"},\n", @@ -193,7 +198,7 @@ "yrad = 1.0\n", "surf_tag = 1\n", "edge_tag = 3\n", - "ellipse_mesh, mf1, mf2 = mesh_tools.create_ellipses(xrad, yrad, hEdge=h_ellipse,\n", + "ellipse_mesh, mf1, mf2 = mesh_tools.create_ellipses(2*xrad, 0.5*yrad, hEdge=h_ellipse,\n", " outer_tag=surf_tag, outer_marker=edge_tag)\n", "visualization.plot_dolfin_mesh(ellipse_mesh, mf2, view_xy=True)" ] @@ -249,8 +254,8 @@ "config_cur = config.Config()\n", "config_cur.solver.update(\n", " {\n", - " \"final_t\": 10.0,\n", - " \"initial_dt\": 0.05,\n", + " \"final_t\": 130.0,\n", + " \"initial_dt\": 1.0,\n", " \"time_precision\": 6,\n", " }\n", ")\n", @@ -279,7 +284,7 @@ "source": [ "model_cur.to_pickle('model_cur.pkl')\n", "results = dict()\n", - "result_folder = pathlib.Path(\"resultsEllipse_AR1_withLagrangeRecalc\")\n", + "result_folder = pathlib.Path(\"resultsEllipse_testShapeChange\")\n", "result_folder.mkdir(exist_ok=True)\n", "for species_name, species in model_cur.sc.items:\n", " results[species_name] = d.XDMFFile(\n", @@ -288,20 +293,22 @@ " results[species_name].parameters[\"flush_output\"] = True\n", " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", "\n", - "# save deformation field as well\n", - "V_vector = sc[\"A\"].compartment.vel_func.function_space()\n", - "V = d.FunctionSpace(cc[\"Cyto\"].dolfin_mesh, \"P\", 1)\n", - "velfile = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"vel.xdmf\"))\n", - "velfile.parameters[\"flush_output\"] = True\n", - "velfile.write(sc[\"A\"].compartment.vel_func, model_cur.t)\n", - "udef = d.Function(V_vector)\n", - "# uexpr = d.Expression(('-0.1*t*x[1]*(1 - (pow(x[0],2)+pow(x[1],2)))', \n", - "# '0.1*t*x[0]*(1 - (pow(x[0],2)+pow(x[1],2)))', '0'),\n", - "# degree=1, t=tval)\n", - "# uvar = d.interpolate(uexpr, V_vector)\n", - "ufile = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"uvar.xdmf\"))\n", - "ufile.parameters[\"flush_output\"] = True\n", - "ufile.write(udef, model_cur.t)" + "# save deformation or velocity field as well\n", + "if cc[\"Cyto\"].vel_logic:\n", + " velfile = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"vel.xdmf\"))\n", + " velfile.parameters[\"flush_output\"] = True\n", + " velfile.write(sc[\"A\"].compartment.vel_func, model_cur.t)\n", + "elif cc[\"Cyto\"].deform_logic:\n", + " u1file = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"u1var.xdmf\"))\n", + " u1file.parameters[\"flush_output\"] = True\n", + " u1file.write(cc[\"Cyto\"].deform_func, model_cur.t)\n", + " u2file = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"u2var.xdmf\"))\n", + " u2file.parameters[\"flush_output\"] = True\n", + " u2file.write(cc[\"PM\"].deform_func, model_cur.t)\n", + " # v1file = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"v1.xdmf\"))\n", + " # v1file.parameters[\"flush_output\"] = True\n", + " # v2file = d.XDMFFile(model_cur.mpi_comm_world, str(result_folder / f\"v2.xdmf\"))\n", + " # v2file.parameters[\"flush_output\"] = True" ] }, { @@ -332,27 +339,17 @@ "avg_B = [d.assemble_mixed(B.u[\"u\"]*dx_PM) / PM_SA]\n", "# Set loglevel to warning in order not to pollute notebook output\n", "logger.setLevel(logging.WARNING)\n", - "# variables for lagrangian mapping\n", - "linear_wrt_comp = {k: True for k in model_cur.cc.keys}\n", "x = d.SpatialCoordinate(sc[\"A\"].compartment.dolfin_mesh)\n", - "xcur = x[0]\n", - "ycur = x[1]\n", - "u = sc[\"A\"]._usplit[\"u\"]\n", - "un = sc[\"A\"]._usplit[\"n\"]\n", - "v = sc[\"A\"].v\n", - "dx = sc[\"A\"].compartment.mesh.dx\n", - "Dform_units = (\n", - " sc[\"A\"].diffusion_units\n", - " * sc[\"A\"].concentration_units\n", - " * sc[\"A\"].compartment.compartment_units ** (sc[\"A\"].compartment.dimensionality - 2)\n", - " )\n", - "mass_form_units = (\n", - " sc[\"A\"].concentration_units / unit.s\n", - " * sc[\"A\"].compartment.compartment_units**sc[\"A\"].compartment.dimensionality\n", - " )\n", - "D_constant = d.Constant(sc[\"A\"].D, name=f\"D_{species.name}\")\n", - "vel = sc[\"A\"].compartment.vel_func\n", - "I = d.Identity(3)\n", + "# if cc[\"Cyto\"].deform_logic:\n", + "# udef = cc[\"Cyto\"].deform_func\n", + "# udef_local = udef.copy()\n", + "# V_vector = udef.function_space()\n", + "# vel_expr = d.Expression((\"-0.1*x[1]*(1 - (pow(x[0],2)+pow(x[1],2)))\",\n", + "# \"0.1*x[0]*(1 - (pow(x[0],2)+pow(x[1],2)))\",\"0\"), degree=1)\n", + "# vel = d.interpolate(vel_expr, V_vector)\n", + " # v1file.write(vel, model_cur.t)\n", + " # vel_approx = d.project((udef - cc[\"Cyto\"].deform_prev) / model_cur.dT, V_vector)\n", + " # v2file.write(vel_approx, model_cur.t)\n", "\n", "while True:\n", " # Solve the system\n", @@ -364,76 +361,50 @@ " avg_X.append(d.assemble_mixed(X.u[\"u\"]*dx_PM) / PM_SA)\n", " avg_B.append(d.assemble_mixed(B.u[\"u\"]*dx_PM) / PM_SA)\n", " tvec.append(model_cur.t)\n", - " # save deformation\n", - " # tval.assign(model_cur.t)\n", - " # uvar.assign(d.interpolate(uexpr, V_vector))\n", - " # ufile.write(uvar, model_cur.t)\n", - " # update Lagrangian operators\n", - " udef.assign(udef + vel*d.Constant(model_cur.tvec[-1]-model_cur.tvec[-2]))\n", - " ufile.write(udef, model_cur.t)\n", - " xcur = x[0] + udef[0]\n", - " ycur = x[1] + udef[1]\n", - " vcur_x = d.project(-0.1*ycur*(1 - (xcur**2+ycur**2)), V_vector.sub(0).collapse())\n", - " vcur_y = d.project(0.1*xcur*(1 - (xcur**2+ycur**2)), V_vector.sub(1).collapse())\n", - " vel.vector()[V_vector.sub(0).dofmap().dofs()] = vcur_x.vector()\n", - " vel.vector()[V_vector.sub(1).dofmap().dofs()] = vcur_y.vector()\n", - " vel.vector().apply(\"insert\")\n", - " \n", - " velfile.write(vel, model_cur.t)\n", - " F = I + d.grad(udef)\n", - " J = d.det(F)\n", - " if model_cur.config.flags[\"axisymmetric_model\"]:\n", - " Dform = x[0]*D_constant * J * d.inner(d.dot(d.inv(F.T), d.grad(u)),d.dot(d.inv(F.T), d.grad(v)))*dx\n", - " else:\n", - " Dform = D_constant * J * d.inner(d.dot(d.inv(F.T), d.grad(u)),d.dot(d.inv(F.T), d.grad(v)))*dx\n", - " model_cur.forms[\"diffusion_A\"] = Form(\n", - " f\"diffusion_{sc['A'].name}\",\n", - " Dform,\n", - " sc[\"A\"],\n", - " \"diffusion\",\n", - " Dform_units,\n", - " True,\n", - " linear_wrt_comp,\n", - " )\n", - " if model_cur.config.flags[\"axisymmetric_model\"]:\n", - " Aform = J*x[0] * (u*v*d.inner(d.inv(F.T),d.grad(vel))*dx)\n", - " else:\n", - " Aform = J*u*v*d.inner(d.inv(F.T),d.grad(vel))*dx\n", - " model_cur.forms[\"advection_A\"] = Form(\n", - " f\"advection_A\",\n", - " Aform,\n", - " sc[\"A\"],\n", - " \"advection\",\n", - " mass_form_units,\n", - " True,\n", - " linear_wrt_comp,\n", - " )\n", - " if model_cur.config.flags[\"axisymmetric_model\"]:\n", - " Muform = J*x[0] * (u) * v / model_cur.dT * dx\n", - " Munform = J*x[0] * (-un) * v / model_cur.dT * dx\n", - " else:\n", - " Muform = J*(u) * v / model_cur.dT * dx\n", - " Munform = J*(-un) * v / model_cur.dT * dx\n", - " model_cur.forms[\"mass_u_A\"] = Form(\n", - " \"mass_u_A\",\n", - " Muform,\n", - " sc[\"A\"],\n", - " \"mass_u\",\n", - " mass_form_units,\n", - " True,\n", - " linear_wrt_comp,\n", - " )\n", - " model_cur.forms[\"mass_un_A\"] = Form(\n", - " f\"mass_un_A\",\n", - " Munform,\n", - " sc[\"A\"],\n", - " \"mass_un\",\n", - " mass_form_units,\n", - " True,\n", - " linear_wrt_comp,\n", - " )\n", - " model_cur.initialize_discrete_variational_problem_and_solver()\n", - " print(f\"Done with time {model_cur.t} s\")\n", + " # save deformation or velocity\n", + " if cc[\"Cyto\"].vel_logic:\n", + " velfile.write(sc[\"A\"].compartment.vel_func, model_cur.t)\n", + " elif cc[\"Cyto\"].deform_logic:\n", + " # cc[\"Cyto\"].deform_prev.assign(udef_local)\n", + " # udef.assign(udef_local + vel*d.Constant(model_cur.tvec[-1]-model_cur.tvec[-2]))\n", + " u1file.write(cc[\"Cyto\"].deform_func, model_cur.t)\n", + " u2file.write(cc[\"PM\"].deform_func, model_cur.t)\n", + " # multCur = d.project(model_cur.fc[\"r1 [X (f)]\"].integral_factor,X.V)\n", + " # if np.any(np.isnan(multCur.vector()[:])):\n", + " # udef = cc[\"Cyto\"].deform_func\n", + " # Fcur = d.Identity(3) + d.grad(udef)\n", + " # Jcur = d.det(Fcur)\n", + " # Nexpr = d.Expression((\"x[0]/R\", \"x[1]/R\", \"0.0\"), degree=1, R=1)\n", + " # test_factor = Jcur*d.sqrt(d.inner(d.dot(Nexpr,d.inv(Fcur)),d.dot(Nexpr,d.inv(Fcur))))\n", + " # Vcur = d.VectorFunctionSpace(X.compartment.dolfin_mesh, \"P\", 1)\n", + " # N = d.interpolate(Nexpr, Vcur)\n", + " # N = self.surface.normals\n", + " \n", + " # for name, flux in model_cur.fc.items:\n", + " # if \"r1\" in name:\n", + " # Vcur = flux.integral_factor.function_space()\n", + " # not_assigned = True\n", + " # while not_assigned:\n", + " # try:\n", + " # flux.integral_factor.assign(d.project(test_factor, Vcur))\n", + " # not_assigned = False\n", + " # except:\n", + " # print('Try again!')\n", + " # multfile.write(model_cur.fc[\"r1 [X (f)]\"].integral_factor, model_cur.t)\n", + " # udef_local = udef.copy()\n", + " # # compute new velocities\n", + " # xcur = x[0] + udef[0]\n", + " # ycur = x[1] + udef[1]\n", + " # vcur_x = d.project(-0.1*ycur*(1 - (xcur**2+ycur**2)), V_vector.sub(0).collapse())\n", + " # vcur_y = d.project(0.1*xcur*(1 - (xcur**2+ycur**2)), V_vector.sub(1).collapse())\n", + " # vel.vector()[V_vector.sub(0).dofmap().dofs()] = vcur_x.vector()\n", + " # vel.vector()[V_vector.sub(1).dofmap().dofs()] = vcur_y.vector()\n", + " # vel.vector().apply(\"insert\")\n", + " # v1file.write(vel, model_cur.t)\n", + " # vel_approx.assign(d.project((udef - cc[\"Cyto\"].deform_prev) / model_cur.dT, V_vector))\n", + " # v2file.write(vel_approx, model_cur.t)\n", + "\n", + " print(f\"Done with t={model_cur.t}\")\n", " # End if we've passed the final time\n", " if model_cur.t >= model_cur.final_t:\n", " break" @@ -471,23 +442,11 @@ "ss_pred = root_vals[root_vals > 0]\n", "plt.plot(tvec, np.ones(len(avg_A))*ss_pred, '--', label='Steady-state analytical prediction')\n", "plt.legend()\n", - "percent_error_analytical = 100*np.abs(ss_pred-avg_A[-1])/ss_pred\n", - "assert percent_error_analytical < 0.1,\\\n", - " f\"Failed test: Example 2 results deviate {percent_error_analytical:.3f}% from the analytical prediction\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ffae481c", - "metadata": {}, - "outputs": [], - "source": [ - "from cowpy import cow\n", - "if True:#percent_error_analytical > 0.1:\n", - " stegy = cow.Stegosaurus(thoughts=True)\n", - " stegy_msg = stegy.milk(\"Failed test: Example 2 results deviate from the analytical prediction\")\n", - " print(stegy_msg)" + "# from cowpy import cow\n", + "# if True:#percent_error_analytical > 0.1:\n", + "# stegy = cow.Stegosaurus(thoughts=True)\n", + "# stegy_msg = stegy.milk(\"Failed test: Example 2 results deviate from the analytical prediction\")\n", + "# print(stegy_msg)" ] }, { @@ -502,16 +461,11 @@ { "cell_type": "code", "execution_count": null, - "id": "d950b475", + "id": "ffae481c", "metadata": {}, "outputs": [], "source": [ - "visualization.plot(model_cur.sc[\"A\"].u[\"u\"], view_xy=True)\n", - "# also save to file for comparison visualization in the end\n", - "meshimg_folder = pathlib.Path(\"mesh_images\")\n", - "meshimg_folder = meshimg_folder.resolve()\n", - "meshimg_folder.mkdir(exist_ok=True)\n", - "meshimg_file = meshimg_folder / \"ellipse_mesh_AR1.png\"" + "visualization.plot(model_cur.sc[\"A\"].u[\"u\"], view_xy=True)" ] } ], diff --git a/smart/model.py b/smart/model.py index 6e8246b..5e36a9a 100644 --- a/smart/model.py +++ b/smart/model.py @@ -1037,23 +1037,80 @@ def _init_4_7_set_initial_conditions(self): vec.apply("insert") for compartment in self._active_compartments: # if vel is string, generate expr for advection - if compartment.vel != 0: + compartment.vel_logic = np.any([vel != 0.0 for vel in compartment.vel]) + compartment.deform_logic = np.any([deform != 0.0 for deform in compartment.deform]) + if compartment.vel_logic and compartment.deform_logic: + raise ValueError("Cannot prescribe both velocity and deformation") + elif compartment.vel_logic: x, y, z = (sym.Symbol(f"x[{i}]") for i in range(3)) vel_expr = [None] * len(compartment.vel) # then this needs to have free symbols inserted for i in range(len(compartment.vel)): - vel_str = compartment.vel[i] - # Parse the given string to create a sympy expression - sym_expr = parse_expr(vel_str).subs({"x": x, "y": y, "z": z, "t": self.t}) - free_symbols = [str(x) for x in sym_expr.free_symbols] - if not {"x[0]", "x[1]", "x[2]"}.issuperset(free_symbols): - # could add other keywords for spatial dependence in the future - raise NotImplementedError + if isinstance(compartment.vel[i], float): + vel_expr[i] = f"{compartment.vel[i]}" + elif isinstance(compartment.vel[i], str): + vel_str = compartment.vel[i] + # Parse the given string to create a sympy expression + sym_expr = parse_expr(vel_str).subs({"x": x, "y": y, "z": z}) + free_symbols = [str(x) for x in sym_expr.free_symbols] + if not {"x[0]", "x[1]", "x[2]", "t"}.issuperset(free_symbols): + # could add other keywords for spatial dependence in the future + raise NotImplementedError + else: + vel_expr[i] = sym.printing.ccode(sym_expr) else: - vel_expr[i] = sym.printing.ccode(sym_expr) - compartment.vel_expr = d.Expression(vel_expr, degree=1) + raise NotImplementedError("Velocity must be float or string") + compartment.vel_expr = d.Expression(vel_expr, degree=1, t=self.T) V_vector = d.VectorFunctionSpace(compartment.dolfin_mesh, "P", 1) compartment.vel_func = d.interpolate(compartment.vel_expr, V_vector) + elif compartment.deform_logic: + x, y, z = (sym.Symbol(f"x[{i}]") for i in range(3)) + deform_expr = [None] * len(compartment.deform) + # then this needs to have free symbols inserted + for i in range(len(compartment.deform)): + if isinstance(compartment.deform[i], float): + deform_expr[i] = f"{compartment.deform[i]}" + elif isinstance(compartment.deform[i], str): + deform_str = compartment.deform[i] + # Parse the given string to create a sympy expression + sym_expr = parse_expr(deform_str).subs({"x": x, "y": y, "z": z}) + free_symbols = [str(x) for x in sym_expr.free_symbols] + if not {"x[0]", "x[1]", "x[2]", "t"}.issuperset(free_symbols): + # could add other keywords for spatial dependence in the future + raise NotImplementedError + else: + deform_expr[i] = sym.printing.ccode(sym_expr) + else: + raise NotImplementedError("Deformation must be float or string") + compartment.deform_expr = d.Expression(deform_expr, degree=1, t=self.T) + V_vector = d.VectorFunctionSpace(compartment.dolfin_mesh, "P", 1) + compartment.deform_func = d.interpolate(compartment.deform_expr, V_vector) + compartment.deform_prev = d.interpolate(compartment.deform_expr, V_vector) + if not compartment.is_volume: # then is a surface and must compute normals + mesh_ref = compartment.mesh.parent_mesh.dolfin_mesh + ref_normals = d.FacetNormal(mesh_ref) + Vcur = d.VectorFunctionSpace(mesh_ref, "P", 1) + ucur = d.TrialFunction(Vcur) + vcur = d.TestFunction(Vcur) + ds = d.Measure("ds", mesh_ref) + a = d.inner(ucur, vcur) * ds + lform = d.inner(ref_normals, vcur) * ds + A = d.assemble(a, keep_diagonal=True) + L = d.assemble(lform) + A.ident_zeros() + nh = d.Function(Vcur) + d.solve(A, nh.vector(), L) # project facet normals onto CG1 + Vbound = d.VectorFunctionSpace(compartment.mesh.dolfin_mesh, "P", 1) + norm_calc = d.interpolate(nh, Vbound) + nvec = norm_calc.vector()[:] + # normalize magnitudes for consistency (unit normal) + for i in range(int(len(nvec) / 3)): + vec_cur = nvec[3 * i : 3 * (i + 1)] + mag_cur = np.sqrt(vec_cur[0] ** 2 + vec_cur[1] ** 2 + vec_cur[2] ** 2) + nvec[3 * i : 3 * (i + 1)] = vec_cur / mag_cur + norm_calc.vector().set_local(nvec) + norm_calc.vector().apply("insert") + compartment.normals = norm_calc def _init_5_1_reactions_to_fluxes(self): """Convert reactions to flux objects""" @@ -1121,6 +1178,16 @@ def _init_5_2_create_variational_forms(self): / unit.s * species.compartment.compartment_units**species.compartment.dimensionality ) + lagrange = species.compartment.deform_logic + # define current jacobian + if lagrange: + udef = species.compartment.deform_func + F = d.Identity(3) + d.grad(udef) + J = d.det(F) + else: + J = d.Constant(1.0) + if self.config.flags["axisymmetric_model"]: + J = x[0] * J # diffusion term if species.D == 0: logger.debug( @@ -1129,29 +1196,15 @@ def _init_5_2_create_variational_forms(self): extra=dict(format_type="log"), ) else: - lagrange = True - if lagrange and species.compartment.vel != 0: # then nonzero advection + if lagrange: # then nonzero advection # alt Lagrangian form D_constant = d.Constant(D, name=f"D_{species.name}") - vel = species.compartment.vel_func - udef = vel * d.Constant(self.t - self.tvec[0]) - F = d.Identity(3) + d.grad(udef) - J = d.det(F) - if self.config.flags["axisymmetric_model"]: - Dform = ( - x[0] - * D_constant - * J - * d.inner(d.dot(d.inv(F.T), d.grad(u)), d.dot(d.inv(F.T), d.grad(v))) - * dx - ) - else: - Dform = ( - D_constant - * J - * d.inner(d.dot(d.inv(F.T), d.grad(u)), d.dot(d.inv(F.T), d.grad(v))) - * dx - ) + Dform = ( + D_constant + * J + * d.inner(d.dot(d.inv(F.T), d.grad(u)), d.dot(d.inv(F.T), d.grad(v))) + * dx + ) self.forms.add( Form( f"diffusion_{species.name}", @@ -1165,10 +1218,7 @@ def _init_5_2_create_variational_forms(self): ) else: D_constant = d.Constant(D, name=f"D_{species.name}") - if self.config.flags["axisymmetric_model"]: - Dform = x[0] * D_constant * d.inner(d.grad(u), d.grad(v)) * dx - else: - Dform = D_constant * d.inner(d.grad(u), d.grad(v)) * dx + Dform = J * D_constant * d.inner(d.grad(u), d.grad(v)) * dx # exponent is -2 because of two gradients self.forms.add( @@ -1182,19 +1232,24 @@ def _init_5_2_create_variational_forms(self): linear_wrt_comp, ) ) - - if species.compartment.vel != 0: # then nonzero advection + if lagrange: # account for volume changes due to advection + vel_approx = (udef - species.compartment.deform_prev) / self.dT + Aform = J * u * v * d.inner(d.inv(F.T), d.grad(vel_approx)) * dx + # Aform = J * u * v * d.inner(vel, d.dot(d.inv(F.T), d.grad(u))) * dx + self.forms.add( + Form( + f"advection_{species.name}", + Aform, + species, + "advection", + mass_form_units, + True, + linear_wrt_comp, + ) + ) + elif species.compartment.vel_logic: # then nonzero advection vel = species.compartment.vel_func - if lagrange: - if self.config.flags["axisymmetric_model"]: - Aform = J * x[0] * (u * v * d.inner(d.inv(F), d.grad(vel)) * dx) - else: - Aform = J * u * v * d.inner(d.inv(F), d.grad(vel)) * dx - else: - if self.config.flags["axisymmetric_model"]: - Aform = x[0] * (u * v * d.div(vel) * dx + d.inner(v * vel, d.grad(u))) - else: - Aform = u * v * d.div(vel) * dx + d.inner(v * vel, d.grad(u)) * dx + Aform = J * u * v * d.div(vel) * dx + d.inner(v * vel, d.grad(u)) * dx self.forms.add( Form( f"advection_{species.name}", @@ -1208,10 +1263,7 @@ def _init_5_2_create_variational_forms(self): ) # mass (time derivative) terms - if self.config.flags["axisymmetric_model"]: - Muform = x[0] * (u) * v / self.dT * dx - else: - Muform = (u) * v / self.dT * dx + Muform = J * u * v / self.dT * dx self.forms.add( Form( f"mass_u_{species.name}", @@ -1223,10 +1275,7 @@ def _init_5_2_create_variational_forms(self): linear_wrt_comp, ) ) - if self.config.flags["axisymmetric_model"]: - Munform = x[0] * (-un) * v / self.dT * dx - else: - Munform = (-un) * v / self.dT * dx + Munform = J * (-un) * v / self.dT * dx self.forms.add( Form( f"mass_un_{species.name}", @@ -1933,6 +1982,16 @@ def update_time_dependent_parameters(self): dt = float(self.dt) tn = float(self.tn) + # Update velocity or deformation if applicable + for compartment in self._active_compartments: + if compartment.vel_logic and not compartment.manual_update: + Vcur = compartment.vel_func.function_space() + compartment.vel_func.assign(d.interpolate(compartment.vel_expr, Vcur)) + elif compartment.deform_logic and not compartment.manual_update: + Vcur = compartment.deform_func.function_space() + compartment.deform_prev.assign(compartment.deform_func.copy()) + compartment.deform_func.assign(d.interpolate(compartment.deform_expr, Vcur)) + # Update time dependent parameters for parameter_name, parameter in self.pc.items: new_value = None diff --git a/smart/model_assembly.py b/smart/model_assembly.py index 7b40ac5..0c59f1f 100644 --- a/smart/model_assembly.py +++ b/smart/model_assembly.py @@ -1135,14 +1135,19 @@ class Compartment(ObjectInstance): dimensionality: topological dimensionality (e.g. 3 for volume, 2 for surface) compartment_units: length units for the compartment cell_marker: marker value identifying the compartment in the parent mesh - vel: string expression for advective velocity field within compartment + vel: string expressions for advective velocity field within compartment + deform: string expressions for deformation field within compartment """ name: str dimensionality: int compartment_units: pint.Unit cell_marker: Any - vel: Union[str, float] = 0.0 + # vel: Union[list[str], list[float]] = [0.0, 0.0, 0.0] + # deform: Union[list[str], list[float]] = [0.0, 0.0, 0.0] + vel: list = dataclass.field(default_factory=lambda: [0.0, 0.0, 0.0]) + deform: list = dataclass.field(default_factory=lambda: [0.0, 0.0, 0.0]) + manual_update: bool = False def to_dict(self): "Convert to a dict that can be used to recreate the object." @@ -1174,6 +1179,10 @@ def __post_init__(self): self.v = None self.vel_expr = None self.vel_func = None + self.deform_expr = None + self.deform_func = None + self.vel_logic = False + self.deform_logic = False def check_validity(self): """ @@ -1747,9 +1756,9 @@ def _post_init_get_integration_measure(self): "volume-surface_to_volume", ]: # intersection of this surface with boundary of source volume(s) - logger.debug( - "DEBUGGING INTEGRATION MEASURE (only fully defined domains are enabled for now)" - ) + # logger.debug( + # "DEBUGGING INTEGRATION MEASURE (only fully defined domains are enabled for now)" + # ) self.measure = self.surface.mesh.dx self.measure_units = self.surface.compartment_units**self.surface.dimensionality @@ -1787,7 +1796,6 @@ def equation_lambda_eval(self, input_type="quantity"): return unit_to_quantity(self._equation_quantity.units) # Seems like setting this as a @property doesn't cause fenics to recompile - @property def form(self): """-1 factor because terms are defined as if they were on the @@ -1806,10 +1814,72 @@ def form(self): mult = u_mask_new else: mult = d.Constant(-1.0, name="-1") + + # alphaExpr = d.Expression("1.0", degree=1) + # Vcur = d.FunctionSpace(self.surface.mesh.dolfin_mesh, "P", 1) + # self.integral_factor = d.interpolate(alphaExpr, Vcur) + # self.integral_factor = alphaExpr + + if self.topology in ["volume", "surface"]: + if self.destination_compartment.deform_logic: + udef = self.destination_compartment.deform_func + Fcur = d.Identity(3) + d.grad(udef) + Jcur = d.det(Fcur) + if self.topology == "surface": + # Nexpr = d.Expression(("x[0]/R", "x[1]/R", "0.0"), degree=1, R=1) + # Vcur = d.VectorFunctionSpace(self.surface.mesh.dolfin_mesh, "P", 1) + # N = d.interpolate(Nexpr, Vcur) + N = self.surface.normals + self.integral_factor = Jcur * d.sqrt( + d.inner(d.dot(N, d.inv(Fcur)), d.dot(N, d.inv(Fcur))) + ) + else: # then volume + self.integral_factor = Jcur + # mult *= Jcur + else: + self.integral_factor = d.Expression("1.0", degree=1) + elif self.topology in [ + "volume_to_surface", + "surface_to_volume", + "volume-volume_to_surface", + "volume-surface_to_volume", + ]: + source_list = list(self.source_compartments.values()) + if ( + self.destination_compartment.deform_logic + and np.all([source.deform_logic for source in source_list]) + and self.surface.deform_logic + ): + # if (self.topology == "volume_to_surface" or + # self.topology == "volume-volume_to_surface"): + # vol_ref = source_list[0] + # else: + # vol_ref = self.destination_compartment + udef = self.surface.deform_func + Fcur = d.Identity(3) + d.grad(udef) + Jcur = d.det(Fcur) + # Nexpr = d.Expression(("x[0]/R", "x[1]/R", "0.0"), degree=1, R=1) + # Vcur = d.VectorFunctionSpace(self.surface.mesh.dolfin_mesh, "P", 1) + # N = d.interpolate(Nexpr, Vcur) + N = self.surface.normals + self.integral_factor = Jcur * d.sqrt( + d.inner(d.dot(N, d.inv(Fcur)), d.dot(N, d.inv(Fcur))) + ) + # mult *= self.integral_factor + elif ( + self.destination_compartment.deform_logic + or np.any([source.deform_logic for source in source_list]) + or self.surface.deform_logic + ): + raise ValueError("Deformation must be continuous across interface") + else: + self.integral_factor = d.Expression("1.0", degree=1) + if self.axisymm: return ( mult * x[0] + * self.integral_factor * self.equation_lambda_eval(input_type="value") * self.destination_species.v * self.measure @@ -1817,6 +1887,7 @@ def form(self): else: return ( mult + * self.integral_factor * self.equation_lambda_eval(input_type="value") * self.destination_species.v * self.measure From 9c95a2f936281811dd1ef9f5caadeb6b52ac07d5 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:22:24 -0700 Subject: [PATCH 12/20] Introduce new example to test advection feature, add functionality to multicellular meshing function for tutorial --- examples/example5/example5_withSOCE.ipynb | 27 +- examples/example5/test_data.h5 | Bin 224520 -> 0 bytes examples/example5/test_data.xdmf | 143 ----------- examples/example7/example7.ipynb | 34 ++- examples/example8/example8.ipynb | 299 ++++++++++++++++++++++ smart/mesh_tools.py | 128 ++++++--- smart/model.py | 20 +- smart/model_assembly.py | 7 +- smart/solvers.py | 9 +- smart/test_mesh.ipynb | 36 +++ 10 files changed, 505 insertions(+), 198 deletions(-) delete mode 100644 examples/example5/test_data.h5 delete mode 100644 examples/example5/test_data.xdmf create mode 100644 examples/example8/example8.ipynb create mode 100644 smart/test_mesh.ipynb diff --git a/examples/example5/example5_withSOCE.ipynb b/examples/example5/example5_withSOCE.ipynb index c70075b..532d0a1 100644 --- a/examples/example5/example5_withSOCE.ipynb +++ b/examples/example5/example5_withSOCE.ipynb @@ -278,7 +278,10 @@ "AER = Species(\"AER\", 200.0, vol_unit, 5.0, D_unit, \"ER\")\n", "# Uniform initial condition of R for simplicity\n", "R1 = Species(\"R1\", 1.0, surf_unit, 0.02, D_unit, \"ERm\")\n", - "R1o = Species(\"R1o\", 0.0, surf_unit, 0.02, D_unit, \"ERm\")" + "R1o = Species(\"R1o\", 0.0, surf_unit, 0.02, D_unit, \"ERm\")\n", + "\n", + "# Add a species to represent Orai1\n", + "Orai1_open = Species(\"Orai1_open\", 0.0, surf_unit, 0.0, D_unit, \"PM\")" ] }, { @@ -298,7 +301,7 @@ "outputs": [], "source": [ "sc = SpeciesContainer()\n", - "sc.add([R1o, R1, AER, B, A])" + "sc.add([R1o, R1, AER, B, A, Orai1_open])" ] }, { @@ -425,8 +428,20 @@ "source": [ "PSOCE = Parameter.mesh_quantity(\"PSOCE\", 0, unit.dimensionless, compartment=\"PM\")\n", "J0_SOCE = Parameter(\"J0_SOCE\", 1e4, flux_unit)\n", - "rSOCE = Reaction(\n", - " \"rSOCE\",\n", + "\n", + "tauSOCE = Parameter(\"tauSOCE\", 1.0, sec)\n", + "rhoTot = Parameter(\"rhoTot\", 1.0, surf_unit)\n", + "rSOCEOpen = Reaction(\n", + " \"rSOCEOpen\",\n", + " [],\n", + " [\"Orai1_open\"],\n", + " param_map={\"P\": \"PSOCE\", \"rhoTot\": \"rhoTot\", \"tauSOCE\": \"tauSOCE\"},\n", + " eqn_f_str=\"(rhoTot*P - Orai1_open)/tauSOCE\",\n", + " explicit_restriction_to_domain=\"PM\",\n", + ")\n", + "\n", + "rSOCEFlux = Reaction(\n", + " \"rSOCEFlux\",\n", " [],\n", " [\"A\"],\n", " param_map={\"P\": \"PSOCE\", \"J0\": \"J0_SOCE\"},\n", @@ -453,8 +468,8 @@ "source": [ "pc = ParameterContainer()\n", "rc = ReactionContainer()\n", - "pc.add([k4Vmax, k3r, k3f, k2f, j1pulse, PSOCE, J0_SOCE])\n", - "rc.add([r1, r2, r3, r4, rSOCE])" + "pc.add([k4Vmax, k3r, k3f, k2f, j1pulse, PSOCE, J0_SOCE, tauSOCE, rhoTot])\n", + "rc.add([r1, r2, r3, r4, rSOCEFlux, rSOCEOpen])" ] }, { diff --git a/examples/example5/test_data.h5 b/examples/example5/test_data.h5 deleted file mode 100644 index 8b18d271effe73edebca35f621eee05f199c51a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224520 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 - - - - - - - test_data.h5:/Mesh/0/mesh/topology - - - test_data.h5:/Mesh/0/mesh/geometry - - - - - test_data.h5:/Mesh/1/mesh/topology - - - test_data.h5:/Mesh/1/mesh/geometry - - - - - test_data.h5:/Mesh/2/mesh/topology - - - test_data.h5:/Mesh/2/mesh/geometry - - - - - test_data.h5:/Mesh/3/mesh/topology - - - test_data.h5:/Mesh/3/mesh/geometry - - - - - test_data.h5:/Mesh/4/mesh/topology - - - test_data.h5:/Mesh/4/mesh/geometry - - - - - test_data.h5:/Mesh/5/mesh/topology - - - test_data.h5:/Mesh/5/mesh/geometry - - - - - test_data.h5:/Mesh/6/mesh/topology - - - test_data.h5:/Mesh/6/mesh/geometry - - - - - test_data.h5:/Mesh/7/mesh/topology - - - test_data.h5:/Mesh/7/mesh/geometry - - - - - test_data.h5:/Mesh/8/mesh/topology - - - test_data.h5:/Mesh/8/mesh/geometry - - - - - test_data.h5:/Mesh/9/mesh/topology - - - test_data.h5:/Mesh/9/mesh/geometry - - - - - test_data.h5:/Mesh/10/mesh/topology - - - test_data.h5:/Mesh/10/mesh/geometry - - - - - diff --git a/examples/example7/example7.ipynb b/examples/example7/example7.ipynb index 2c86caa..a667dd0 100644 --- a/examples/example7/example7.ipynb +++ b/examples/example7/example7.ipynb @@ -252,6 +252,29 @@ " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", "model_cur.to_pickle(\"model_cur.pkl\")\n", "\n", + "locVec2 = [[-30,-30,-30], [-30,30,-30], [-30,-30,30], [-30,30,30], \n", + " [30,-30,-30], [30,30,-30], [30,-30,30], [30,30,30]]\n", + "cell_id_fcn = d.MeshFunction(\"size_t\", cc[\"PM2\"].dolfin_mesh, 2, 0)\n", + "for c in d.cells(cc[\"PM2\"].dolfin_mesh):\n", + " xCur = c.midpoint().x()\n", + " yCur = c.midpoint().y()\n", + " zCur = c.midpoint().z()\n", + " for i in range(len(locVec2)):\n", + " print(f\"Checking cell {i+1}, facet {c.index()}\")\n", + " Rcur = np.sqrt((locVec2[i][0]-xCur)**2 + \n", + " (locVec2[i][1]-yCur)**2 +\n", + " (locVec2[i][2]-zCur)**2)\n", + " if Rcur < 11.0:\n", + " cell_id_fcn[c] = i+1\n", + "dx_PM2 = d.Measure(\"dx\", domain=cc[\"PM2\"].dolfin_mesh, subdomain_data=cell_id_fcn)\n", + "avg_concs = []\n", + "avg_conc_cur = []\n", + "sas = []\n", + "for i in range(len(locVec2)):\n", + " sas.append(d.assemble_mixed(1.0*dx_PM2(i+1)))\n", + " avg_conc_cur.append(d.assemble_mixed(sc[\"G\"].sol*dx_PM2(i+1))/sas[-1])\n", + "avg_concs.append(avg_conc_cur)\n", + "\n", "# Set loglevel to warning in order not to pollute notebook output\n", "logger.setLevel(logging.WARNING)\n", "# Solve\n", @@ -263,10 +286,19 @@ " # Save results for post processing\n", " for species_name, species in model_cur.sc.items:\n", " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + " avg_conc_cur = []\n", + " for i in range(len(locVec2)):\n", + " avg_conc_cur.append(d.assemble_mixed(sc[\"G\"].sol*dx_PM2(i+1))/sas[i])\n", + " avg_concs.append(avg_conc_cur)\n", " print(f\"Done with t={model_cur.t}\")\n", " # End if we've passed the final time\n", " if model_cur.t >= model_cur.final_t:\n", - " break" + " break\n", + "\n", + "np.savetxt(\"test_data.txt\", avg_concs)\n", + "for i in range(len(locVec2)):\n", + " plt.plot(model_cur.tvec, np.array(avg_concs)[:,i])\n", + "# plt.plot(model_cur.tvec,)" ] } ], diff --git a/examples/example8/example8.ipynb b/examples/example8/example8.ipynb new file mode 100644 index 0000000..47ddbca --- /dev/null +++ b/examples/example8/example8.ipynb @@ -0,0 +1,299 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "f65f18d7", + "metadata": {}, + "source": [ + "# Example 7: Advection-diffusion in a cylindrical geometry\n", + "\n", + "Here, we consider release of a molecule from a cylindrical wall with Pouiselle flow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc398816", + "metadata": {}, + "outputs": [], + "source": [ + "import dolfin as d\n", + "import sympy as sym\n", + "import numpy as np\n", + "import pathlib\n", + "import logging\n", + "import gmsh # must be imported before pyvista if dolfin is imported first\n", + "\n", + "from smart import config, mesh, model, mesh_tools, visualization\n", + "from smart.units import unit\n", + "from smart.model_assembly import (\n", + " Compartment,\n", + " Parameter,\n", + " Reaction,\n", + " Species,\n", + " SpeciesContainer,\n", + " ParameterContainer,\n", + " CompartmentContainer,\n", + " ReactionContainer,\n", + ")\n", + "\n", + "from matplotlib import pyplot as plt\n", + "import matplotlib.image as mpimg\n", + "from matplotlib import rcParams\n", + "\n", + "logger = logging.getLogger(\"smart\")\n", + "logger.setLevel(logging.INFO)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "95b9d865", + "metadata": {}, + "source": [ + "We define the relevant units here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f4023cf", + "metadata": {}, + "outputs": [], + "source": [ + "# Aliases - base units\n", + "uM = unit.uM\n", + "um = unit.um\n", + "molecule = unit.molecule\n", + "sec = unit.sec\n", + "dimensionless = unit.dimensionless\n", + "# Aliases - units used in model\n", + "D_unit = um**2 / sec\n", + "flux_unit = uM * um / sec\n", + "vol_unit = uM\n", + "surf_unit = molecule / um**2" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "46582d26", + "metadata": {}, + "source": [ + "## Model generation\n", + "\n", + "We define the compartments and species first, with their respective containers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02a000f2", + "metadata": {}, + "outputs": [], + "source": [ + "Cyto = Compartment(\"Cyto\", 3, um, 1)\n", + "PM = Compartment(\"PM\", 2, um, 10,\n", + " vel=[\"0\",\"0\",\"100.0*[1-(x[0]**2 + x[1]**2//4)]\"])\n", + "\n", + "cc = CompartmentContainer()\n", + "cc.add([Cyto, PM])\n", + "\n", + "A = Species(\"A\", \"1+0.1*z\", vol_unit, 1.0, D_unit, \"Cyto\")\n", + "sc = SpeciesContainer()\n", + "sc.add([A])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "3c56e840", + "metadata": {}, + "source": [ + "Define parameters and reactions, then place in respective containers.\n", + "* r1: release of A from PM" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e1f6882", + "metadata": {}, + "outputs": [], + "source": [ + "# flux to keep constant \n", + "Awall = Parameter.from_expression(\"Awall\", \"1 + 0.1*z\", vol_unit)\n", + "Jwall = Parameter(\"Jwall\", 1000, flux_unit / vol_unit)\n", + "r1 = Reaction(\n", + " \"r1\",\n", + " [],\n", + " [\"A\"],\n", + " param_map={\"Awall\": \"Awall\", \"Jwall\": \"Jwall\"},\n", + " eqn_f_str=\"Jwall*(Awall-A)\",\n", + " explicit_restriction_to_domain=\"PM\",\n", + ")\n", + "\n", + "pc = ParameterContainer()\n", + "pc.add([Awall, Jwall])\n", + "rc = ReactionContainer()\n", + "rc.add([r1])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "15c35d39", + "metadata": {}, + "source": [ + "## Create and load in mesh\n", + "\n", + "Here, we consider cells embedded in a cube mesh. The source cell is located at (0,0,0) and 8 other cells are spread equidistant through the mesh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe56e162", + "metadata": {}, + "outputs": [], + "source": [ + "domain, facet_markers, cell_markers = mesh_tools.create_cylinders(outerRad = 2.0, innerRad=0, outerLength=10.0, innerLength=0,\n", + " hEdge=0.5)\n", + "# Write mesh and meshfunctions to file\n", + "mesh_folder = pathlib.Path(\"mesh\")\n", + "mesh_folder.mkdir(exist_ok=True)\n", + "mesh_path = mesh_folder / \"cyl_mesh.h5\"\n", + "mesh_tools.write_mesh(\n", + " domain, facet_markers, cell_markers, filename=mesh_path\n", + ")\n", + "parent_mesh = mesh.ParentMesh(\n", + " mesh_filename=str(mesh_path),\n", + " mesh_filetype=\"hdf5\",\n", + " name=\"parent_mesh\",\n", + ")\n", + "visualization.plot_dolfin_mesh(domain, cell_markers, facet_markers)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "0943588e", + "metadata": {}, + "source": [ + "Initialize model and solver." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac88bdec", + "metadata": {}, + "outputs": [], + "source": [ + "config_cur = config.Config()\n", + "config_cur.flags.update({\"allow_unused_components\": True})\n", + "model_cur = model.Model(pc, sc, cc, rc, config_cur, parent_mesh)\n", + "config_cur.solver.update(\n", + " {\n", + " \"final_t\": 100.0,\n", + " \"initial_dt\": 0.01,\n", + " \"time_precision\": 8,\n", + " \"reset_timestep_for_negative_solution\": True,\n", + " }\n", + ")\n", + "model_cur.initialize()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "5d5aacbd", + "metadata": {}, + "source": [ + "Initialize XDMF files for saving results, save model information to .pkl file, then solve the system until `model_cur.t > model_cur.final_t`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b54d28ca", + "metadata": {}, + "outputs": [], + "source": [ + "# Write initial condition(s) to file\n", + "results = dict()\n", + "result_folder = pathlib.Path(f\"results\")\n", + "result_folder.mkdir(exist_ok=True)\n", + "for species_name, species in model_cur.sc.items:\n", + " results[species_name] = d.XDMFFile(\n", + " model_cur.mpi_comm_world, str(result_folder / f\"{species_name}.xdmf\")\n", + " )\n", + " results[species_name].parameters[\"flush_output\"] = True\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + "model_cur.to_pickle(\"model_cur.pkl\")\n", + "\n", + "# Set loglevel to warning in order not to pollute notebook output\n", + "logger.setLevel(logging.WARNING)\n", + "# Solve\n", + "displayed = False\n", + "while True:\n", + " # Solve the system\n", + " model_cur.monolithic_solve()\n", + " model_cur.adjust_dt()\n", + " # Save results for post processing\n", + " for species_name, species in model_cur.sc.items:\n", + " results[species_name].write(model_cur.sc[species_name].u[\"u\"], model_cur.t)\n", + "\n", + " print(f\"Done with t={model_cur.t}\")\n", + " # End if we've passed the final time\n", + " if model_cur.t >= model_cur.final_t:\n", + " break\n", + "\n", + "# plt.plot(model_cur.tvec,)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b755f19b", + "metadata": {}, + "outputs": [], + "source": [ + "u = sc[\"A\"].sol\n", + "udiff = u - d.Expression(\"1.0+0.1*x[2]\")" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "vscode": { + "interpreter": { + "hash": "916dbcbb3f70747c44a77c7bcd40155683ae19c65e1c03b4aa3499c5328201f1" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/smart/mesh_tools.py b/smart/mesh_tools.py index 4ffabef..4c71afd 100644 --- a/smart/mesh_tools.py +++ b/smart/mesh_tools.py @@ -738,15 +738,22 @@ def create_multicell( ) -> Tuple[d.Mesh, d.MeshFunction, d.MeshFunction]: """ Creates a mesh with an outer cube containing embedded cells at specified locations. + Cells can be of two types, type 1 and type 2 throughout. Args: cubeSize: Length of cube sides - locVec: vector of cell locations + locVec1: list of cell 1 locations (each list element is a list [x,y,z]) + locVec2: list of cell 2 locations (each list element is a list [x,y,z]) + cellRad1: either a float, a list of floats, or a list of lists, each + containing a list of three floats for each axis [rx,ry,rz] + cellRad2: either a float, a list of floats, or a list of lists, each + containing a list of three floats for each axis [rx,ry,rz] hCube: maximum mesh size for cube - hCell: maximum mesh size for cell surfaces - interface_marker: The value to mark facets on the interface with - outer_marker: The value to mark facets on the outer ellipsoid with - inner_vol_tag: The value to mark the inner spherical volume with - outer_vol_tag: The value to mark the outer spherical volume with + hCell: maximum mesh size for cell surface + interface_marker1: The value to mark facets on cell 1 surface + outer_marker: The value to mark facets on the surface of the cube + extracell_tag: Tag for extracellular volume + cell_vol_tag1: Tag for the volume of type 1 cells + outer_vol_tag2: Tag for the volume of type 2 cells comm: MPI communicator to create the mesh with verbose: If true print gmsh output, else skip Returns: @@ -758,11 +765,7 @@ def create_multicell( ValueError("Outer cube size is equal to zero") if np.isclose(hCube, 0): hCube = 0.1 * max(cubeSize) - if np.isclose(hCell, 0): - hCell = 0.2 * cubeSize if np.isclose(cellRad1, 0) else 0.2 * cellRad1 - # if innerRad > outerRad or innerLength >= outerLength: - # ValueError("Inner cylinder does not fit inside outer cylinder") - # Create the two cylinder mesh using gmsh + gmsh.initialize() gmsh.option.setNumber("General.Terminal", int(verbose)) @@ -771,9 +774,9 @@ def create_multicell( cube = gmsh.model.occ.addBox( -cubeSize / 2, -cubeSize / 2, -cubeSize / 2, cubeSize, cubeSize, cubeSize ) - if (np.isclose(cellRad1, 0) or len(locVec1) == 0) and ( - np.isclose(cellRad2, 0) or len(locVec2) == 0 - ): + meanRad1 = 0 + meanRad2 = 0 + if (len(locVec1) == 0) and (len(locVec2) == 0): # Just a cube! gmsh.model.occ.synchronize() gmsh.model.add_physical_group(3, [cube], tag=extracell_tag) @@ -782,18 +785,63 @@ def create_multicell( else: # Add cells cell_list = [] + cellRad1Vec = [] + cellRad2Vec = [] # first add source cell(s) for i in range(len(locVec1)): - cur_tag = gmsh.model.occ.addSphere( - locVec1[i][0], locVec1[i][1], locVec1[i][2], cellRad1 - ) + if isinstance(cellRad1, float) or isinstance(cellRad1, int): + cellRadCur = float(cellRad1) + elif len(locVec1) == 1 and len(cellRad1) == 3: + cellRadCur = cellRad1 + elif len(cellRad1) == len(locVec1): + cellRadCur = cellRad1[i] + else: + raise ValueError("Radii must be floats or lists of 3 floats") + if isinstance(cellRadCur, float): + cellRads = [cellRadCur, cellRadCur, cellRadCur] + if len(cellRadCur) == 3: + cellRads = cellRadCur + else: + raise ValueError("Radii must be floats or lists of 3 floats") + + cur_tag = gmsh.model.occ.addSphere(locVec1[i][0], locVec1[i][1], locVec1[i][2], 1.0) + gmsh.model.occ.dilate([(3, cur_tag)], 0, 0, 0, cellRads[0], cellRads[1], cellRads[2]) cell_list.append((3, cur_tag)) + meanRad1 += np.mean(cellRads) + cellRad1Vec.append(cellRads) + if meanRad1 > 0: + meanRad1 /= len(locVec1) # now add additional cells for i in range(len(locVec2)): - cur_tag = gmsh.model.occ.addSphere( - locVec2[i][0], locVec2[i][1], locVec2[i][2], cellRad2 - ) + if isinstance(cellRad2, float) or isinstance(cellRad2, int): + cellRadCur = float(cellRad2) + elif len(locVec2) == 1 and len(cellRad2) == 3: + cellRadCur = cellRad2 + elif len(cellRad2) == len(locVec2): + cellRadCur = cellRad2[i] + else: + raise ValueError("Radii must be floats or lists of 3 floats") + if isinstance(cellRadCur, float): + cellRads = [cellRadCur, cellRadCur, cellRadCur] + if len(cellRadCur) == 3: + cellRads = cellRadCur + else: + raise ValueError("Radii must be floats or lists of 3 floats") + + cur_tag = gmsh.model.occ.addSphere(locVec2[i][0], locVec2[i][1], locVec2[i][2], 1.0) + gmsh.model.occ.dilate([(3, cur_tag)], 0, 0, 0, cellRads[0], cellRads[1], cellRads[2]) cell_list.append((3, cur_tag)) + meanRad2 = np.mean(cellRads) + cellRad2Vec.append(cellRads) + if meanRad2 > 0: + meanRad2 /= len(locVec2) + # define hCell if it was previously set to zero + if np.isclose(hCell, 0): + hCell = ( + 0.2 * cubeSize + if (meanRad1 == 0 and meanRad2 == 0) + else 0.2 * max([meanRad1, meanRad2]) + ) # Create interface between cells and extracell full_geo, maps = gmsh.model.occ.fragment([(3, cube)], cell_list) cube_map = maps[0] @@ -818,7 +866,7 @@ def create_multicell( 2, [faces[0][1] for faces in inner_shells2], tag=interface_marker2 ) - # Physical markers for + # Physical markers for all volumes all_volumes = [tag[1] for tag in cube_map] inner_volumes1 = [tag[0][1] for tag in cell_maps[0 : len(locVec1)]] inner_volumes2 = [tag[0][1] for tag in cell_maps[len(locVec1) :]] @@ -839,44 +887,42 @@ def meshSizeCallback(dim, tag, x, y, z, lc): # if innerRad=0, then the mesh length is interpolated between # hEdge at the PM and 0.2*outerRad in the center - if (np.isclose(cellRad1, 0) or len(locVec1) == 0) and ( - np.isclose(cellRad2, 0) or len(locVec2) == 0 - ): + if len(locVec1) == 0 and len(locVec2) == 0: return hCube - elif np.isclose(cellRad1, 0) or len(locVec1) == 0: + elif len(locVec1) == 0: cell_locs1 = [np.inf] cell_locs2 = np.sqrt( - (x - np.array(locVec2)[:, 0]) ** 2 - + (y - np.array(locVec2)[:, 1]) ** 2 - + (z - np.array(locVec2)[:, 2]) ** 2 + ((x - np.array(locVec2)[:, 0]) / np.array(cellRad2Vec)[:, 0]) ** 2 + + ((y - np.array(locVec2)[:, 1]) / np.array(cellRad2Vec)[:, 1]) ** 2 + + ((z - np.array(locVec2)[:, 2]) / np.array(cellRad2Vec)[:, 2]) ** 2 ) - elif np.isclose(cellRad2, 0) or len(locVec2) == 0: + elif len(locVec2) == 0: cell_locs1 = np.sqrt( - (x - np.array(locVec1)[:, 0]) ** 2 - + (y - np.array(locVec1)[:, 1]) ** 2 - + (z - np.array(locVec1)[:, 2]) ** 2 + ((x - np.array(locVec1)[:, 0]) / np.array(cellRad1Vec)[:, 0]) ** 2 + + ((y - np.array(locVec1)[:, 1]) / np.array(cellRad1Vec)[:, 1]) ** 2 + + ((z - np.array(locVec1)[:, 2]) / np.array(cellRad1Vec)[:, 2]) ** 2 ) cell_locs2 = [np.inf] else: cell_locs1 = np.sqrt( - (x - np.array(locVec1)[:, 0]) ** 2 - + (y - np.array(locVec1)[:, 1]) ** 2 - + (z - np.array(locVec1)[:, 2]) ** 2 + ((x - np.array(locVec1)[:, 0]) / cellRad1Vec[:, 0]) ** 2 + + ((y - np.array(locVec1)[:, 1]) / cellRad1Vec[:, 1]) ** 2 + + ((z - np.array(locVec1)[:, 2]) / cellRad1Vec[:, 2]) ** 2 ) cell_locs2 = np.sqrt( - (x - np.array(locVec2)[:, 0]) ** 2 - + (y - np.array(locVec2)[:, 1]) ** 2 - + (z - np.array(locVec2)[:, 2]) ** 2 + ((x - np.array(locVec2)[:, 0]) / cellRad2Vec[:, 0]) ** 2 + + ((y - np.array(locVec2)[:, 1]) / cellRad2Vec[:, 1]) ** 2 + + ((z - np.array(locVec2)[:, 2]) / cellRad2Vec[:, 2]) ** 2 ) closest_cell1 = min(cell_locs1) closest_cell2 = min(cell_locs2) - if (closest_cell1 < cellRad1) or (closest_cell2 < cellRad2): + if (closest_cell1 < 1.0) or (closest_cell2 < 1.0): return hCell else: if closest_cell1 < closest_cell2: - cellWeight = np.exp(-(closest_cell1 - cellRad1) / (0.2 * cellRad1)) + cellWeight = np.exp(-(closest_cell1 - 1.0) / 0.1) else: - cellWeight = np.exp(-(closest_cell2 - cellRad2) / (0.2 * cellRad2)) + cellWeight = np.exp(-(closest_cell2 - 1.0) / 0.1) return hCell * cellWeight + hCube * (1 - cellWeight) gmsh.model.mesh.setSizeCallback(meshSizeCallback) diff --git a/smart/model.py b/smart/model.py index 0a960b5..82c2c9a 100644 --- a/smart/model.py +++ b/smart/model.py @@ -1098,7 +1098,21 @@ def _init_4_7_set_initial_conditions(self): compartment.deform_func = d.interpolate(compartment.deform_expr, V_vector) compartment.deform_prev = d.interpolate(compartment.deform_expr, V_vector) if not compartment.is_volume: # then is a surface and must compute normals - mesh_ref = compartment.mesh.parent_mesh.dolfin_mesh + surf_coords = compartment.dolfin_mesh.coordinates() + for c in self.cc: # find an adjacent volume! + if c.is_volume: + mesh_test = c.dolfin_mesh + test_coords = mesh_test.coordinates() + adjacent = True + for i in range(len(surf_coords)): + if surf_coords[i] not in test_coords: + adjacent = False + break + if adjacent: + mesh_ref = mesh_test + break + if not adjacent: + raise ValueError("Could not find an adjacent volume to compute normals!") ref_normals = d.FacetNormal(mesh_ref) Vcur = d.VectorFunctionSpace(mesh_ref, "P", 1) ucur = d.TrialFunction(Vcur) @@ -1260,7 +1274,7 @@ def _init_5_2_create_variational_forms(self): ) elif species.compartment.vel_logic: # then nonzero advection vel = species.compartment.vel_func - Aform = J * u * v * d.div(vel) * dx + d.inner(v * vel, d.grad(u)) * dx + Aform = J * u * v * d.div(vel) * dx + J * d.inner(v * vel, d.grad(u)) * dx self.forms.add( Form( f"advection_{species.name}", @@ -2000,7 +2014,7 @@ def update_time_dependent_parameters(self): compartment.vel_func.assign(d.interpolate(compartment.vel_expr, Vcur)) elif compartment.deform_logic and not compartment.manual_update: Vcur = compartment.deform_func.function_space() - compartment.deform_prev.assign(compartment.deform_func.copy()) + compartment.deform_prev.assign(compartment.deform_func.copy(deepcopy=True)) compartment.deform_func.assign(d.interpolate(compartment.deform_expr, Vcur)) # Update time dependent parameters diff --git a/smart/model_assembly.py b/smart/model_assembly.py index 03bafdf..2181ef2 100644 --- a/smart/model_assembly.py +++ b/smart/model_assembly.py @@ -1179,8 +1179,8 @@ class Compartment(ObjectInstance): cell_marker: Any # vel: Union[list[str], list[float]] = [0.0, 0.0, 0.0] # deform: Union[list[str], list[float]] = [0.0, 0.0, 0.0] - vel: list = dataclass.field(default_factory=lambda: [0.0, 0.0, 0.0]) - deform: list = dataclass.field(default_factory=lambda: [0.0, 0.0, 0.0]) + vel: list = dataclasses.field(default_factory=lambda: [0.0, 0.0, 0.0]) + deform: list = dataclasses.field(default_factory=lambda: [0.0, 0.0, 0.0]) manual_update: bool = False def to_dict(self): @@ -1905,7 +1905,8 @@ def form(self): or np.any([source.deform_logic for source in source_list]) or self.surface.deform_logic ): - raise ValueError("Deformation must be continuous across interface") + logger.warning("FIX: Ensure that deformation must be continuous across interface") + self.integral_factor = d.Expression("1.0", degree=1) else: self.integral_factor = d.Expression("1.0", degree=1) diff --git a/smart/solvers.py b/smart/solvers.py index b3b9e24..1bd6727 100644 --- a/smart/solvers.py +++ b/smart/solvers.py @@ -9,6 +9,8 @@ from .common import Stopwatch from .model_assembly import Compartment +import numpy as np + logger = logging.getLogger(__name__) __all__ = ["smartSNESProblem"] @@ -356,7 +358,12 @@ def assemble_Fnest(self, Fnest): extra=dict(format_type="log"), ) continue - Fvecs[j].append(d.as_backend_type(d.assemble_mixed(form))) + # Fvecs[j].append(d.as_backend_type(d.assemble_mixed(form))) + # assemble_mixed sometimes returns nan (nondeterministic???) + cur_vec = d.assemble_mixed(self.Fforms[j][k]) + while any(np.isnan(cur_vec.get_local())): + cur_vec = d.assemble_mixed(self.Fforms[j][k]) + Fvecs[j].append(d.as_backend_type(cur_vec)) Fj_petsc[j].zeroEntries() for k, vec in enumerate(Fvecs[j]): Fj_petsc[j].axpy(1, vec.vec()) diff --git a/smart/test_mesh.ipynb b/smart/test_mesh.ipynb new file mode 100644 index 0000000..30d5f4c --- /dev/null +++ b/smart/test_mesh.ipynb @@ -0,0 +1,36 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "9baf2f11", + "metadata": {}, + "outputs": [], + "source": [ + "from smart import mesh_tools\n", + "mesh_tools.create_nested_box_with_nanopillars()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 9dd5a758ed1198b1325dff11da627acdfa881afd Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:25:36 -0700 Subject: [PATCH 13/20] Add back data files for example5 --- examples/example5/test_data.h5 | Bin 0 -> 224520 bytes examples/example5/test_data.xdmf | 143 +++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 examples/example5/test_data.h5 create mode 100644 examples/example5/test_data.xdmf diff --git a/examples/example5/test_data.h5 b/examples/example5/test_data.h5 new file mode 100644 index 0000000000000000000000000000000000000000..8b18d271effe73edebca35f621eee05f199c51a3 GIT binary patch literal 224520 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 + + + + + + + test_data.h5:/Mesh/0/mesh/topology + + + test_data.h5:/Mesh/0/mesh/geometry + + + + + test_data.h5:/Mesh/1/mesh/topology + + + test_data.h5:/Mesh/1/mesh/geometry + + + + + test_data.h5:/Mesh/2/mesh/topology + + + test_data.h5:/Mesh/2/mesh/geometry + + + + + test_data.h5:/Mesh/3/mesh/topology + + + test_data.h5:/Mesh/3/mesh/geometry + + + + + test_data.h5:/Mesh/4/mesh/topology + + + test_data.h5:/Mesh/4/mesh/geometry + + + + + test_data.h5:/Mesh/5/mesh/topology + + + test_data.h5:/Mesh/5/mesh/geometry + + + + + test_data.h5:/Mesh/6/mesh/topology + + + test_data.h5:/Mesh/6/mesh/geometry + + + + + test_data.h5:/Mesh/7/mesh/topology + + + test_data.h5:/Mesh/7/mesh/geometry + + + + + test_data.h5:/Mesh/8/mesh/topology + + + test_data.h5:/Mesh/8/mesh/geometry + + + + + test_data.h5:/Mesh/9/mesh/topology + + + test_data.h5:/Mesh/9/mesh/geometry + + + + + test_data.h5:/Mesh/10/mesh/topology + + + test_data.h5:/Mesh/10/mesh/geometry + + + + + From b166fb4fbdf5319b0926d42cf1763ecea41cc4cf Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:27:30 -0700 Subject: [PATCH 14/20] Remove mesh test file --- smart/test_mesh.ipynb | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 smart/test_mesh.ipynb diff --git a/smart/test_mesh.ipynb b/smart/test_mesh.ipynb deleted file mode 100644 index 30d5f4c..0000000 --- a/smart/test_mesh.ipynb +++ /dev/null @@ -1,36 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "9baf2f11", - "metadata": {}, - "outputs": [], - "source": [ - "from smart import mesh_tools\n", - "mesh_tools.create_nested_box_with_nanopillars()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From f48b001ebcd29c4edc3c7d5b35ac4dda8c3d4200 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:19:40 -0700 Subject: [PATCH 15/20] Bump version to gamma --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e444256..6ff7197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel"] [project] name = "fenics-smart" -version = "2.3.0.beta.1" +version = "2.3.0.gamma" description = "Spatial Modeling Algorithms for Reactions and Transport (SMART) is a high-performance finite-element-based simulation package for model specification and numerical simulation of spatially-varying reaction-transport processes in biological cells." authors = [{ name = "Justin Laughlin", email = "justinglaughlin@gmail.com" }] license = { file = "LICENSE" } From e60d813cfc5460d15c11b6b0663d16e557781f73 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:22:07 -0700 Subject: [PATCH 16/20] Remove nan loop in solver --- smart/solvers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/smart/solvers.py b/smart/solvers.py index 1bd6727..40bca3c 100644 --- a/smart/solvers.py +++ b/smart/solvers.py @@ -9,7 +9,7 @@ from .common import Stopwatch from .model_assembly import Compartment -import numpy as np +# import numpy as np logger = logging.getLogger(__name__) @@ -361,8 +361,8 @@ def assemble_Fnest(self, Fnest): # Fvecs[j].append(d.as_backend_type(d.assemble_mixed(form))) # assemble_mixed sometimes returns nan (nondeterministic???) cur_vec = d.assemble_mixed(self.Fforms[j][k]) - while any(np.isnan(cur_vec.get_local())): - cur_vec = d.assemble_mixed(self.Fforms[j][k]) + # while any(np.isnan(cur_vec.get_local())): + # cur_vec = d.assemble_mixed(self.Fforms[j][k]) Fvecs[j].append(d.as_backend_type(cur_vec)) Fj_petsc[j].zeroEntries() for k, vec in enumerate(Fvecs[j]): From af81db224355dab07c29da1b249619edd2593b35 Mon Sep 17 00:00:00 2001 From: emmetfrancis <99422170+emmetfrancis@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:28:39 -0700 Subject: [PATCH 17/20] change version to 2.3.1.alpha --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6ff7197..ca2b855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel"] [project] name = "fenics-smart" -version = "2.3.0.gamma" +version = "2.3.1.alpha" description = "Spatial Modeling Algorithms for Reactions and Transport (SMART) is a high-performance finite-element-based simulation package for model specification and numerical simulation of spatially-varying reaction-transport processes in biological cells." authors = [{ name = "Justin Laughlin", email = "justinglaughlin@gmail.com" }] license = { file = "LICENSE" } From 17cb903f112e44148f46011d4c1e9f5a038e161f Mon Sep 17 00:00:00 2001 From: emmetfrancis Date: Wed, 6 Aug 2025 00:16:27 -0700 Subject: [PATCH 18/20] fix to multicell meshing function, add back nan fix with protection against infinite loop --- examples/example5/example5_withSOCE.ipynb | 3 ++- pyproject.toml | 2 +- smart/mesh_tools.py | 32 +++++++++++++++++------ smart/solvers.py | 8 +++--- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/examples/example5/example5_withSOCE.ipynb b/examples/example5/example5_withSOCE.ipynb index 532d0a1..198c844 100644 --- a/examples/example5/example5_withSOCE.ipynb +++ b/examples/example5/example5_withSOCE.ipynb @@ -521,7 +521,8 @@ " and (ymin - d.DOLFIN_EPS < cell.midpoint().y() < ymax + d.DOLFIN_EPS)\n", " and (zmin - d.DOLFIN_EPS < cell.midpoint().z() < zmax + d.DOLFIN_EPS)\n", " )\n", - "domain, facet_markers, cell_markers = mesh_tools.create_cubes(condition=cur_cube_condition, N=20)\n", + "# domain, facet_markers, cell_markers = mesh_tools.create_cubes(condition=cur_cube_condition, N=20)\n", + "domain, facet_markers, cell_markers = mesh_tools.create_nested_substrate(hEdge=5.0)\n", "visualization.plot_dolfin_mesh(domain, cell_markers, clip_plane=(1,\n", " 1, 0), clip_origin=(0.5, 0.5, 0.5))\n" ] diff --git a/pyproject.toml b/pyproject.toml index ca2b855..4a5e39a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel"] [project] name = "fenics-smart" -version = "2.3.1.alpha" +version = "2.3.1.alpha.1" description = "Spatial Modeling Algorithms for Reactions and Transport (SMART) is a high-performance finite-element-based simulation package for model specification and numerical simulation of spatially-varying reaction-transport processes in biological cells." authors = [{ name = "Justin Laughlin", email = "justinglaughlin@gmail.com" }] license = { file = "LICENSE" } diff --git a/smart/mesh_tools.py b/smart/mesh_tools.py index 4c71afd..63fe61b 100644 --- a/smart/mesh_tools.py +++ b/smart/mesh_tools.py @@ -805,7 +805,15 @@ def create_multicell( raise ValueError("Radii must be floats or lists of 3 floats") cur_tag = gmsh.model.occ.addSphere(locVec1[i][0], locVec1[i][1], locVec1[i][2], 1.0) - gmsh.model.occ.dilate([(3, cur_tag)], 0, 0, 0, cellRads[0], cellRads[1], cellRads[2]) + gmsh.model.occ.dilate( + [(3, cur_tag)], + locVec1[i][0], + locVec1[i][1], + locVec1[i][2], + cellRads[0], + cellRads[1], + cellRads[2], + ) cell_list.append((3, cur_tag)) meanRad1 += np.mean(cellRads) cellRad1Vec.append(cellRads) @@ -829,7 +837,15 @@ def create_multicell( raise ValueError("Radii must be floats or lists of 3 floats") cur_tag = gmsh.model.occ.addSphere(locVec2[i][0], locVec2[i][1], locVec2[i][2], 1.0) - gmsh.model.occ.dilate([(3, cur_tag)], 0, 0, 0, cellRads[0], cellRads[1], cellRads[2]) + gmsh.model.occ.dilate( + [(3, cur_tag)], + locVec2[i][0], + locVec2[i][1], + locVec2[i][2], + cellRads[0], + cellRads[1], + cellRads[2], + ) cell_list.append((3, cur_tag)) meanRad2 = np.mean(cellRads) cellRad2Vec.append(cellRads) @@ -905,14 +921,14 @@ def meshSizeCallback(dim, tag, x, y, z, lc): cell_locs2 = [np.inf] else: cell_locs1 = np.sqrt( - ((x - np.array(locVec1)[:, 0]) / cellRad1Vec[:, 0]) ** 2 - + ((y - np.array(locVec1)[:, 1]) / cellRad1Vec[:, 1]) ** 2 - + ((z - np.array(locVec1)[:, 2]) / cellRad1Vec[:, 2]) ** 2 + ((x - np.array(locVec1)[:, 0]) / np.array(cellRad1Vec)[:, 0]) ** 2 + + ((y - np.array(locVec1)[:, 1]) / np.array(cellRad1Vec)[:, 1]) ** 2 + + ((z - np.array(locVec1)[:, 2]) / np.array(cellRad1Vec)[:, 2]) ** 2 ) cell_locs2 = np.sqrt( - ((x - np.array(locVec2)[:, 0]) / cellRad2Vec[:, 0]) ** 2 - + ((y - np.array(locVec2)[:, 1]) / cellRad2Vec[:, 1]) ** 2 - + ((z - np.array(locVec2)[:, 2]) / cellRad2Vec[:, 2]) ** 2 + ((x - np.array(locVec2)[:, 0]) / np.array(cellRad2Vec)[:, 0]) ** 2 + + ((y - np.array(locVec2)[:, 1]) / np.array(cellRad2Vec)[:, 1]) ** 2 + + ((z - np.array(locVec2)[:, 2]) / np.array(cellRad2Vec)[:, 2]) ** 2 ) closest_cell1 = min(cell_locs1) closest_cell2 = min(cell_locs2) diff --git a/smart/solvers.py b/smart/solvers.py index 40bca3c..e488160 100644 --- a/smart/solvers.py +++ b/smart/solvers.py @@ -9,7 +9,7 @@ from .common import Stopwatch from .model_assembly import Compartment -# import numpy as np +import numpy as np logger = logging.getLogger(__name__) @@ -361,8 +361,10 @@ def assemble_Fnest(self, Fnest): # Fvecs[j].append(d.as_backend_type(d.assemble_mixed(form))) # assemble_mixed sometimes returns nan (nondeterministic???) cur_vec = d.assemble_mixed(self.Fforms[j][k]) - # while any(np.isnan(cur_vec.get_local())): - # cur_vec = d.assemble_mixed(self.Fforms[j][k]) + count = 1 + while any(np.isnan(cur_vec.get_local())) and count < 10: + cur_vec = d.assemble_mixed(self.Fforms[j][k]) + count += 1 Fvecs[j].append(d.as_backend_type(cur_vec)) Fj_petsc[j].zeroEntries() for k, vec in enumerate(Fvecs[j]): From 4eb20f8266e8b1e3d71bc120e975a90717592d0f Mon Sep 17 00:00:00 2001 From: emmetfrancis Date: Thu, 14 Aug 2025 09:56:28 -0700 Subject: [PATCH 19/20] Change title of example 8 --- examples/example2/example2.ipynb | 46 +++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/examples/example2/example2.ipynb b/examples/example2/example2.ipynb index 5555dfa..6b72ef1 100644 --- a/examples/example2/example2.ipynb +++ b/examples/example2/example2.ipynb @@ -311,7 +311,7 @@ "avg_X = [X.initial_condition]\n", "avg_B = [B.initial_condition]\n", "# Set loglevel to warning in order not to pollute notebook output\n", - "logger.setLevel(logging.WARNING)\n", + "logger.setLevel(logging.DEBUG)\n", "\n", "while True:\n", " # Solve the system\n", @@ -333,6 +333,50 @@ " break" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "72884c41", + "metadata": {}, + "outputs": [], + "source": [ + "import petsc4py.PETSc as PETSc\n", + "Adof = model_cur.sc[\"A\"].dof_map\n", + "Avec = model_cur.sc[\"A\"].sol.vector()[Adof]\n", + "Bdof = model_cur.sc[\"B\"].dof_map\n", + "Bvec = model_cur.sc[\"B\"].sol.vector()[Bdof]\n", + "Xdof = model_cur.sc[\"X\"].dof_map\n", + "Xvec = model_cur.sc[\"X\"].sol.vector()[Xdof]\n", + "data_dict = dict()\n", + "data_dict[\"A\"] = Avec\n", + "data_dict[\"B\"] = Bvec\n", + "data_dict[\"X\"] = Xvec\n", + "\n", + "def weak_residual(model_cur, data_dict):\n", + " for s in model_cur.sc:\n", + " if s.name not in data_dict.keys():\n", + " raise ValueError(\"Must provide vector for all species\")\n", + " else:\n", + " cur_data = data_dict[s.name]\n", + " cur_vec = s.u[\"u\"].vector()\n", + " cur_dof = s.dof_map\n", + " cur_vec[cur_dof] = cur_data\n", + " s.u[\"u\"].vector().set_local(cur_vec)\n", + " s.u[\"u\"].vector().apply(\"insert\")\n", + " if len(model_cur.problem.global_sizes) == 1:\n", + " model_cur._ubackend = model_cur.u[\"u\"]._functions[0].vector().vec().copy()\n", + " else:\n", + " model_cur._ubackend = PETSc.Vec().createNest(\n", + " [usub.vector().vec().copy() for usub in model_cur.u[\"u\"]._functions],\n", + " comm=model_cur.mpi_comm_world,\n", + " )\n", + " return model_cur.solver.getFunctionNorm()\n", + "\n", + "print(f\"Residual is {weak_residual(model_cur, data_dict)}\")\n", + "data_dict[\"A\"] += 1.0\n", + "print(f\"Perturbed residual is {weak_residual(model_cur, data_dict)}\")" + ] + }, { "attachments": {}, "cell_type": "markdown", From baad40f02318d775a933cd9e5f530b0385a20031 Mon Sep 17 00:00:00 2001 From: emmetfrancis Date: Thu, 14 Aug 2025 10:00:22 -0700 Subject: [PATCH 20/20] Change title of example 8 --- examples/example2/example2.ipynb | 46 +------------------------------- examples/example8/example8.ipynb | 2 +- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/examples/example2/example2.ipynb b/examples/example2/example2.ipynb index 6b72ef1..5555dfa 100644 --- a/examples/example2/example2.ipynb +++ b/examples/example2/example2.ipynb @@ -311,7 +311,7 @@ "avg_X = [X.initial_condition]\n", "avg_B = [B.initial_condition]\n", "# Set loglevel to warning in order not to pollute notebook output\n", - "logger.setLevel(logging.DEBUG)\n", + "logger.setLevel(logging.WARNING)\n", "\n", "while True:\n", " # Solve the system\n", @@ -333,50 +333,6 @@ " break" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "72884c41", - "metadata": {}, - "outputs": [], - "source": [ - "import petsc4py.PETSc as PETSc\n", - "Adof = model_cur.sc[\"A\"].dof_map\n", - "Avec = model_cur.sc[\"A\"].sol.vector()[Adof]\n", - "Bdof = model_cur.sc[\"B\"].dof_map\n", - "Bvec = model_cur.sc[\"B\"].sol.vector()[Bdof]\n", - "Xdof = model_cur.sc[\"X\"].dof_map\n", - "Xvec = model_cur.sc[\"X\"].sol.vector()[Xdof]\n", - "data_dict = dict()\n", - "data_dict[\"A\"] = Avec\n", - "data_dict[\"B\"] = Bvec\n", - "data_dict[\"X\"] = Xvec\n", - "\n", - "def weak_residual(model_cur, data_dict):\n", - " for s in model_cur.sc:\n", - " if s.name not in data_dict.keys():\n", - " raise ValueError(\"Must provide vector for all species\")\n", - " else:\n", - " cur_data = data_dict[s.name]\n", - " cur_vec = s.u[\"u\"].vector()\n", - " cur_dof = s.dof_map\n", - " cur_vec[cur_dof] = cur_data\n", - " s.u[\"u\"].vector().set_local(cur_vec)\n", - " s.u[\"u\"].vector().apply(\"insert\")\n", - " if len(model_cur.problem.global_sizes) == 1:\n", - " model_cur._ubackend = model_cur.u[\"u\"]._functions[0].vector().vec().copy()\n", - " else:\n", - " model_cur._ubackend = PETSc.Vec().createNest(\n", - " [usub.vector().vec().copy() for usub in model_cur.u[\"u\"]._functions],\n", - " comm=model_cur.mpi_comm_world,\n", - " )\n", - " return model_cur.solver.getFunctionNorm()\n", - "\n", - "print(f\"Residual is {weak_residual(model_cur, data_dict)}\")\n", - "data_dict[\"A\"] += 1.0\n", - "print(f\"Perturbed residual is {weak_residual(model_cur, data_dict)}\")" - ] - }, { "attachments": {}, "cell_type": "markdown", diff --git a/examples/example8/example8.ipynb b/examples/example8/example8.ipynb index 47ddbca..e1058fa 100644 --- a/examples/example8/example8.ipynb +++ b/examples/example8/example8.ipynb @@ -6,7 +6,7 @@ "id": "f65f18d7", "metadata": {}, "source": [ - "# Example 7: Advection-diffusion in a cylindrical geometry\n", + "# Example 8: Advection-diffusion in a cylindrical geometry\n", "\n", "Here, we consider release of a molecule from a cylindrical wall with Pouiselle flow." ]