From ANSYS Fluent–Style UDFs to Embedded Python: Design of the CMPS Extension Layer

Background and motivation

User-defined extensions are not new in CFD. The dominant reference for many engineers is UDFs in ANSYS Fluent, where the solver remains compiled and performance-critical, but users can inject custom logic at solver-defined hook points. This model works and has been validated in industrial use for decades.

The practical limitation of classical UDFs is the workflow. Users must write C code, compile shared libraries, match compilers and ABIs, and restart the solver repeatedly. Iteration is slow, and debugging is inconvenient. This becomes a serious obstacle when the logic is exploratory, data-driven, or frequently modified.

The motivation for CMPS was therefore not to invent a new extension concept, but to keep the same idea as Fluent-style UDFs while removing the compilation barrier. The challenge was to do this without weakening the solver’s guarantees on memory ownership, execution order, and numerical stability.

Why embedded Python and not a Python-driven solver

Python is attractive because it is expressive, easy to debug, and supported by a strong numerical ecosystem. NumPy, in particular, is designed to operate efficiently on large arrays, which fits CFD data naturally.

However, Python is not suitable as a solver driver. If Python is allowed to control execution flow, allocate solver memory, or hold references to solver objects, the result is usually fragile and difficult to debug. Performance becomes unpredictable, and ownership rules become unclear.

For this reason, Python in CMPS is used only as an embedded extension layer. The solver remains fully C++ driven. Python code runs only when explicitly invoked by the solver and only with data explicitly provided by the solver.

pybind11 as an embedding mechanism

CMPS uses pybind11 in embedded mode. This means the solver application creates and owns the Python interpreter. Python does not import the solver; the solver imports Python.

This distinction is important. Embedded Python allows the solver to decide when Python code is allowed to run, under which conditions, and on which thread. Python is never allowed to initiate solver actions on its own.

pybind11 is used because it gives fine-grained control over object lifetimes and memory exposure. In particular, it allows NumPy arrays to be created as views over existing memory without copying data. This capability is essential; copying CFD-sized arrays between C++ and Python would make the approach unusable.

Avoiding exposure of solver objects

A central design rule is that Python never sees solver objects. No meshes, no faces, no cells, no boundary-condition classes are exposed. Exposing such objects creates immediate ownership and lifetime problems. Python may keep references longer than intended or mutate internal state at unsafe times.

Instead, Python only sees NumPy arrays and scalar metadata. These arrays represent solver data that has been explicitly gathered and prepared for Python consumption. Python has no way to navigate solver data structures or to access anything beyond what the solver intentionally exposes.

This restriction is the main reason the integration remains robust.

Runtime context and controlled visibility

Each Python execution is associated with a short-lived runtime context. This context defines exactly which data is visible to Python and for how long. It contains the current time, iteration counter, and pointers to solver-owned buffers that hold the relevant data.

The context exists only during the execution of a Python callback. Outside this window, the data is considered invalid. Python does not store the context; instead, helper functions inside the Python module read from the currently active context. If these helpers are used when no valid context exists, execution fails immediately.

This approach makes data lifetime explicit and prevents accidental misuse of stale data.

NumPy views and memory ownership

All arrays exposed to Python are NumPy views backed by solver-owned memory. Python never allocates these arrays and never frees them. Memory ownership remains entirely on the C++ side.

This is enforced by associating each NumPy array with a capsule that has no destructor. The capsule tells Python that the memory is externally owned and must not be managed by Python’s garbage collector. Even if a Python script stores an array reference, the underlying memory remains under solver control.

From the solver’s perspective, no ownership is transferred, and no additional lifetime constraints are introduced.

Read-only inputs and explicit outputs

All input data provided to Python is read-only. Pressure, temperature, velocity, and geometry cannot be modified from Python. If a script attempts to write to these arrays, NumPy raises an error.

Only one array is writable: the output array for the current operation. This array maps directly to solver-owned storage. Python writes results into this array, and the solver reads the results without copying.

This asymmetry is intentional. Python is allowed to compute results, but it is not allowed to modify solver state directly.

Execution contract for Python callbacks

Python callbacks follow a strict execution contract. Each callback is associated with a single solver-defined task and produces a single scalar value per solver entity. Callbacks do not receive arguments and do not return values. All data exchange happens through the helper functions and the provided output array.

This contract avoids ambiguity and keeps the interface stable. Python is used as a vectorized computation layer, not as a general-purpose control mechanism.

Python is never invoked per entity. It always operates on full arrays. This keeps performance predictable and avoids pathological overhead.

Contiguous buffers as an adaptation layer

Solver data structures are usually optimized for numerical methods and parallel execution, not for contiguous array access. NumPy, on the other hand, assumes contiguous memory for efficient vectorized operations.

To bridge this gap, CMPS explicitly gathers solver data into contiguous buffers before invoking Python. These buffers are reused across calls and resized only when necessary. This gather step is a deliberate adaptation layer that preserves solver internals while still enabling efficient NumPy-based computation.

This design choice is critical for performance and clarity.

Script discovery and persistence

Available Python functions are discovered by parsing the script file using Python’s abstract syntax tree. The file is not executed during discovery. Only functions matching the expected structure are considered valid.

Only the names of selected functions are stored persistently. Python objects are never serialized. When a case is loaded or a script is reloaded, the interpreter is initialized, the module is imported, helper functions are injected, and runtime bindings are rebuilt from stored names.

This separation between persisted intent and runtime state keeps the system robust against script errors and version mismatches.

Error isolation and execution safety

Errors in Python code do not crash the solver. If a Python callback fails, the error is reported and execution continues. Python is treated as an optional extension layer, not as a core component.

All Python execution is explicitly serialized and guarded by the global interpreter lock. The solver controls when Python runs and on which thread. If parallel execution is required in the future, it must be implemented using isolated contexts rather than shared Python state.

Closing remarks

The CMPS Python extension layer follows the same conceptual model as ANSYS Fluent–style UDFs, but adapts it to modern tooling. The solver defines the execution points, controls memory ownership, and enforces a strict execution contract. Python is used only where it adds value: expressing custom logic on arrays with fast iteration.

The key idea is containment. Python is powerful, but only when its role is clearly limited. By embedding Python with pybind11, exposing solver data as non-owning NumPy views, and enforcing strict lifetime and ownership rules, CMPS achieves UDF-style extensibility without compromising solver stability or performance.