Writing a Solver
The solver is where the actual physics computations happen in your simulation plugin. This page explains how to implement a solver for your plugin, covering the overall structure, key components, and best practices.
Solver Architecture
The Sim4Life Plugin Framework uses a clean separation between the UI model and the solver. This is done through:
- API Models: Simple data transfer objects that pass simulation parameters to the solver
- Solver Driver: The main implementation of your numerical solver
- Command-Line Interface: A standardized input/output mechanism
This architecture allows the solver to run as a separate process, making it more robust and independent of the UI.
graph LR
UI[UI Model]
API[API Models]
Solver[Solver Driver]
Output[Result Files]
UI -- as_api_model --> API
API -- JSON --> Solver
Solver -- Generate --> Output
Output -- Extracted by --> UI
Solver Location
Your solver implementation should be located in the solver/driver package of your plugin:
src/
└── my_package_name/
└── solver/
├── __init__.py
└── driver/
├── __init__.py
├── api_models.py # Data transfer objects
└── main.py # Main solver implementation
Command-Line Interface
The solver is executed as a separate process with a standardized command-line interface:
You can customize how the solver process is launched by overriding the solver_backend method in your simulation class (see Creating a Plugin). By default, this method returns the backend type and no additional configuration:
def solver_backend(self) -> tuple[SolverBackend, config_type_t | None]:
"""
Specifies which type of solver backend to use for running this simulation.
Returns:
A tuple containing the solver backend type and optional configuration
"""
return SolverBackend.PROCESS, None
You can partially override this to provide a custom process configuration. For example:
def solver_backend(self) -> tuple[SolverBackend, config_type_t | None]:
config = {
"cmd": [source_script, "&&", mpi_prefix, f"/usr/bin/python3", str(solver_entry_point), "-i", str(inputs_dir), "-o", str(outputs_dir)],
"cwd": "/my_driver",
"env": {"OMP_NUM_THREADS": "1"}
}
return SolverBackend.PROCESS, config
This allows you to control the command, working directory, and environment variables used to launch the solver process.
Your main.py should parse these arguments and handle input/output accordingly:
import argparse
import json
import logging
import os
import sys
def main():
# --- CLI Argument Parsing ---
parser = argparse.ArgumentParser(description="My Physics Solver")
parser.add_argument(
"-i",
"--inputfile",
type=str,
required=True,
help="Path to simulation input JSON file"
)
parser.add_argument(
"-o",
"--outputfolder",
type=str,
required=True,
help="Path to output folder"
)
args = parser.parse_args()
# --- Setup Logging ---
input_path = os.path.abspath(args.inputfile)
output_dir = os.path.abspath(args.outputfolder)
os.makedirs(output_dir, exist_ok=True)
log_path = os.path.join(output_dir, "solver.log")
# Configure logging
logger = configure_logging(log_path)
# --- Load Input Model ---
logger.info("Loading input model from %s", input_path)
with open(input_path, "r") as f:
sim = Simulation.from_json(f.read())
# --- Run Solver ---
logger.info("Starting solver...")
results = run_solver(sim, logger)
# --- Save Results ---
logger.info("Saving results to %s", output_dir)
save_results(results, output_dir)
logger.info("Solver completed successfully")
if __name__ == "__main__":
main()
Input Handling
Your solver should load the input model from the provided JSON file:
def load_input_model(input_path: str) -> api_models.Simulation:
"""Load the simulation model from the input JSON file."""
with open(input_path, "r") as f:
sim = api_models.Simulation.from_json(f.read())
return sim
Solver Implementation
The core of your solver will be the implementation of your physics equations. Here's a simplified example for a heat conduction solver:
def run_solver(sim: api_models.Simulation, logger: logging.Logger) -> dict:
"""Run the solver algorithm and return the results."""
# Extract parameters from the simulation model
material = sim.material_settings[0]
grid = sim.grid_settings
solver_settings = sim.solver_settings
# Log configuration summary
logger.info("Thermal conductivity: %f W/mK", material.thermal_conductivity)
logger.info("Grid spacing: dx=%f, dy=%f, dz=%f", grid.dx, grid.dy, grid.dz)
logger.info("Solver: method=%s, tol=%e, max_iter=%d",
solver_settings.solver_method,
solver_settings.tolerance,
solver_settings.max_iter)
# Create computational grid
nx = int((material.xmax - material.xmin) / grid.dx) + 1
ny = int((material.ymax - material.ymin) / grid.dy) + 1
nz = int((material.zmax - material.zmin) / grid.dz) + 1
# Initialize fields
temperature = np.zeros((nx, ny, nz))
sources = np.zeros((nx, ny, nz))
# Apply sources
for src in sim.source_setings:
i = int((src.x - material.xmin) / grid.dx)
j = int((src.y - material.ymin) / grid.dy)
k = int((src.z - material.zmin) / grid.dz)
if 0 <= i < nx and 0 <= j < ny and 0 <= k < nz:
sources[i, j, k] = src.volumetric_heat_source
# Apply boundary conditions
# ... (code to apply boundary conditions)
# Solve the system
temperature = solve_heat_equation(
temperature,
sources,
material.thermal_conductivity,
grid.dx,
solver_settings.tolerance,
solver_settings.max_iter,
logger
)
# Compute derived quantities (e.g., heat flux)
heat_flux = compute_heat_flux(temperature, material.thermal_conductivity, grid.dx)
# Return results
return {
"temperature": temperature,
"heat_flux": heat_flux,
"grid": {
"nx": nx, "ny": ny, "nz": nz,
"dx": grid.dx, "dy": grid.dy, "dz": grid.dz,
"xmin": material.xmin, "ymin": material.ymin, "zmin": material.zmin,
"xmax": material.xmax, "ymax": material.ymax, "zmax": material.zmax
}
}
Output Generation
Your solver should generate standardized output files that can be read by the extractor:
def save_results(results: dict, output_dir: str) -> None:
"""Save the solver results to files in the output directory."""
# Save a summary JSON with key statistics
summary = {
"min_temperature": float(np.min(results["temperature"])),
"max_temperature": float(np.max(results["temperature"])),
"mean_temperature": float(np.mean(results["temperature"])),
"total_heat_input": float(np.sum(results["sources"]))
}
with open(os.path.join(output_dir, "summary.json"), "w") as f:
json.dump(summary, f, indent=2)
# Save temperature field as VTK file
save_vtk_rectilinear_grid(
results["temperature"],
"Temperature",
results["grid"],
os.path.join(output_dir, "Temperature.vtr")
)
# Save heat flux field as VTK file
save_vtk_vector_field(
results["heat_flux"],
"HeatFlux",
results["grid"],
os.path.join(output_dir, "HeatFlux.vtr")
)
Using VTK for Output
The Sim4Life framework expects results in VTK format. You can use libraries like PyVista to generate these files:
def save_vtk_rectilinear_grid(data, data_name, grid, output_path):
"""Save a scalar field as a VTK rectilinear grid file."""
import pyvista as pv
# Create coordinates for the rectilinear grid
x = np.linspace(grid["xmin"], grid["xmax"], grid["nx"])
y = np.linspace(grid["ymin"], grid["ymax"], grid["ny"])
z = np.linspace(grid["zmin"], grid["zmax"], grid["nz"])
# Create the grid and add the data
vtk_grid = pv.RectilinearGrid(x, y, z)
vtk_grid.point_data[data_name] = data.flatten(order="F")
# Save to file
vtk_grid.save(output_path)
Error Handling
Your solver should handle errors gracefully:
def solve_heat_equation(temperature, sources, k, dx, tol, max_iter, logger):
"""Solve the heat equation using an iterative method."""
try:
# Solver implementation
# ...
return temperature
except Exception as e:
logger.error("Solver failed: %s", str(e))
logger.exception(e)
raise RuntimeError(f"Solver failed: {str(e)}")
Logging
Use detailed logging to help users understand what's happening:
def configure_logging(log_path):
"""Configure logging to both file and console."""
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Clear existing handlers
logger.handlers = []
# Create formatters and handlers
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
# File handler
file_handler = logging.FileHandler(log_path)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger