Architecture¶
NeoFOAM is a multi-language repository containing both C++ and Python code. This document describes the architecture of NeoFOAM, including its C++ core and Python interface components.
Note
- This section of the documentation provides:
a high-level overview of the planned architecture
guidance through the review process
example implementations serving only as proof of concept to illustrate the planned architecture
a note that detailed features will evolve and refine the architecture, updating examples as development progresses
Overview¶
NeoFOAM is designed as a multi-physics, Python-based simulation framework. Additionally, it includes a set of C++-based legacy solvers, such as neoIcoFoam, to simplify the transition from existing OpenFOAM solvers and workflows. It provides a flexible and modular architecture that allows users to easily extend and customize their simulation setup.
The architecture provides the following features to achieve the goals outlined in the goals and features document:
Easy coupling of multiple domains and physical models
Field and model initialization based on dependency graphs
Modular solver design that computes data dependencies at runtime
Plugin architecture for extending models and fields
To support multi-physics capabilities, multiple computational domains are supported. Each domain has one solver assigned, which defines the governing equations, operations, and optional additional physical models. Coupling between domains is automatically handled based on the selected physics modules.
The following sections describe the main architectural features and implementation examples, providing a high-level overview.
Extensible Solver Architecture¶
To promote code reuse and maintainability, solver execution operations can be configured at runtime based on the selected physics models. This is illustrated in the diagram below, where a fluid solver is extended with three physics submodules.
flowchart TD
subgraph MAIN ["Main Solver Loop"]
OP1["Solver </br> Momentum Equation"]
OP2["Added by Model </br> Temperature Equation"]
OP3["Solver </br> Continuity Equation"]
OP4["Solver </br> Update Turbulence"]
end
OP1 --> OP2
OP2 --> OP3
OP3 --> OP4
%% Physics Extensions (simplified)
subgraph AddPhysics ["Additional Physics Modules"]
direction TB
POROSITY["Porosity"]
ROTATION["Rotating Reference Frame"]
BUOYANCY["Boussinesq Approximation"]
end
POROSITY -.-> OP1
ROTATION -.-> OP1
BUOYANCY -.-> OP2
BUOYANCY -.-> OP3
style MAIN fill:#E3F2FD
style AddPhysics fill:#E3F2FD
style OP1 fill:#2196F3,color:#fff
style OP2 fill:#FF9800,color:#fff
style OP3 fill:#9C27B0,color:#fff
style OP4 fill:#607D8B,color:#fff
The solver defines the main operations to be executed: momentum, continuity, and turbulence model updates. Additional physics models, such as porosity, rotation, or buoyancy, can modify or add operations. In this case, the porosity model and the Rotating Reference Frame add momentum source terms, while the Boussinesq model adds a temperature equation and modifies the continuity equation.
Each solver maintains a list of additional physics models that can add or modify operations. These additional physics models are defined externally from the solver and only need to comply with the IncompressibleFluidModel interface, which can be defined separately by each solver. The execution order is determined at runtime based on the metadata specified in each model.
# Pseudocode illustrating solver and model structure
@Solver
class IncompressibleFluidSolver:
models: list[IncompressibleFluidModel] # Additional physics models
@Solver.operation(...)
def momentum(self, ...): pass
@Solver.operation(...)
def continuity(self, ...): pass
@Solver.operation(...)
def update_turbulence(self, ...): pass
@IncompressibleFluidModel.register
class BoussinesqModel:
@IncompressibleFluidModel.operation(...)
def temperature_equation(self, ...): pass
@IncompressibleFluidModel.register
class PorosityModel:
@IncompressibleFluidModel.operation(...)
def add_momentum_source(self, ...): pass
@IncompressibleFluidModel.register
class RotatingReferenceFrame:
@IncompressibleFluidModel.operation(...)
def add_momentum_source(self, ...): pass
As a result, the solver can be easily extended with new physics using minimal additional code. Users only need to define a new model that adds the desired operations to the solver at runtime, without modifying the core solver. New models can be registered via the plugin system and selected in input files.
Conceptual Implementation¶
After solvers and models are initialized, their operations must be identified and sorted to determine the correct execution order. This order is managed by the Operations class, shown conceptually below:
# Pseudocode showing how operations are stored and executed
class Operations:
ops: list[Operation] # All operations to execute
def run(self, ...): pass
class Operation:
func: callable
sub_operations: list[Operation] # Nested operations
metadata: Any # Metadata for sorting or description
def run(self, ...): pass
An operation represents a single computational operation in a solver or model, functioning as a callable task. Each solver or model can define multiple operations stored as Operation objects. These can hold sub-operations and metadata to assist in sorting and dependency management.
After sorting (detailed in future documentation), the Operations class holds all operations required to run the newly-configured solver.
This modular design allows users to add or remove physical effects without altering the core solver structure, encouraging maintainability and reuse.
Note
Implementation is still a work in progress.
Simulation with Multiple Domains/Solvers¶
The same concept can be extended to multi-physics scenarios with multiple domains and solvers. Each solver defines its own operations, while coupling between solvers is managed automatically at runtime based on defined physics models and settings.
The diagram below illustrates this for two domains in a conjugate heat transfer example:
%%{init: {'flowchart': { 'htmlLabels': true, 'wrap': true }}}%%
flowchart TB
subgraph setup1 ["Setup"]
direction TB
S1A["Initialize Fields"]
end
subgraph setup2 ["Setup"]
direction TB
S2A["Initialize Temperature Field"]
end
subgraph S1 ["Non-Thermal Fluid Solver"]
direction TB
S1C["Momentum Predictor"]
S1C --> S1E["Solve Energy Equation"]
S1E --> S1F["Pressure Corrector<br/>PISO Loop"]
S1F --> S1J["Update Turbulence Model"]
end
subgraph S2 ["Thermal Solid Solver"]
direction TB
S2B["Solve Energy Equation"] --> S2D["Update Solid Properties"]
end
setup1 --> S1C
setup2 --> S2B
S1E --> S2B
S2B --> S1E
Here, a non-thermal fluid solver and a thermal solid solver solve a conjugate heat transfer problem. Each solver has its own initialization phase. Once the Operations are determined, coupling between them is established. The operation sequences of both solvers are then updated to include inter-solver data exchange.
Note
Implementation is still a work in progress.
Initialization Stage¶
The initialization stage sets up the simulation environment — reading input files, initializing fields, and preparing solvers and models.
Although not yet finalized, the planned initialization stage will perform the following tasks:
Read and parse input files and configuration settings.
Initialize computational domains and meshes.
Exchange dependencies between operations (e.g., adding a Boussinesq approximation modifies the pressure equation formulation).
Define solver operations based on selected physics models.
Sort operations to determine correct execution order.
Plugin Architecture¶
Motivation¶
Modern scientific and engineering workflows require flexible, extensible frameworks. NeoFOAM’s plugin architecture enables users and developers to add new physics models, boundary conditions, and solver modules without modifying core code. This promotes maintainability, collaboration, and rapid prototyping.
Concept¶
NeoFOAM implements a runtime-extensible plugin/configuration system using Pydantic discriminated unions and a registry pattern. The core idea is to allow new plugin types (e.g., models, fields, solvers) to be dynamically registered at runtime or via Python entry points (setuptools). Each plugin type (such as a physics model or boundary condition) is managed by a registry, which collects available classes and exposes a unified configuration model for validation and schema generation.
Background: Pydantic Discriminated Unions
Pydantic supports discriminated unions f or type-safe configuration, but union members must be defined at model creation time. For example, the following ensures a pet is either a Cat, Dog, or Lizard:
from typing import Literal, Union
from pydantic import BaseModel, Field
class Cat(BaseModel):
pet_type: Literal['cat']
meows: int
class Dog(BaseModel):
pet_type: Literal['dog']
barks: float
class Lizard(BaseModel):
pet_type: Literal['lizard']
scales: bool
class Model(BaseModel):
pet: Union[Cat, Dog, Lizard] = Field(discriminator='pet_type')
n: int
This works for static unions but cannot be extended dynamically — a limitation for plugin systems.
How NeoFOAM Solves This
Plugins are registered using a decorator-based API. Each registration automatically rebuilds the Pydantic model for that plugin type, updating the discriminated union to include new plugins. Thus, the configuration model always reflects available plugins and remains valid for schema generation and input validation.
Example:
ShapeBase.register(TriangleConfig)
shape = ShapeBase.plugin_model(shape={"shape_type": "triangle", "base": 3.0, "height": 4.0}, color="yellow")
This approach enables true runtime extensibility while maintaining strong input validation. The updated model can always be retrieved via the plugin_model attribute.
Usage¶
To add a new plugin, users define a Python class and register it with the corresponding base class:
from neofoam.core.plugin_system import PluginSystem
@PluginSystem.register(discriminator_variable="model", discriminator="model_type")
class ModelBase(BaseModel):
name: str
@ModelBase.register
class MyCustomModel(BaseModel):
model_type: Literal["custom"]
parameter: float
# Instantiate a model config
config = ModelBase.create(model={"model_type": "custom", "parameter": 1.23}, name="example")
Plugins can also be discovered and registered automatically via Python entry points, allowing third-party packages to extend NeoFOAM seamlessly. The unified configuration and schema system simplifies UI generation, input validation, and documentation of available plugins.
Model Introspection and Schema Generation¶
Model configuration and validation in NeoFOAM are powered by Pydantic, which provides input validation and automatic JSON Schema generation. This mechanism enables model discovery, UI integration, and automatic documentation.
Pydantic’s schema generation supports:
Input validation — ensures consistent and type-safe configurations
UI generation — enables dynamic form or editor creation
Automatic documentation — exposes all field names, types, and constraints
Metadata discovery — supports downstream analysis and automation
AI-assisted workflows — provides structured schemas for AI-based reasoning
To retrieve a model’s JSON Schema, use:
# For a registered plugin or configuration model
schema = ShapeBase.plugin_model.model_json_schema()
# For any Pydantic model
schema = MyModel.model_json_schema()
This unified mechanism allows programmatic discovery of fields, types, validation rules, and defaults. All solvers, models, and plugins must therefore use Pydantic for input configuration.