15 May 2025

Mark Hobbs

A universal interface...

Integrating models, ranging from simple analytical models to complex numerical simulations, into larger workflows is often hindered by technical complexity. In this post, we motivate the need for a universal interface to wrap models and encapsulate them as modular building blocks, before exploring the design of a simple and consistent abstraction.

Motivation

A model $F(\textbf{X})$ is a function that takes a set of input parameters $\textbf{X}$ and returns an output $y$. The model might be a computationally expensive numerical simulation or…

I am in the process of writing a similar post on model calibration.

Design

The goal is to design a generic Model base class that abstracts the complexity of the numerical or machine learning model behind a consistent interface.

from abc import ABC, abstractmethod

class Model(ABC):

    @abstractmethod
    def __call__(self, X) -> y:
        """
        Evaluate the model on input X
        """
        pass

    @abstractmethod
    def get_input_size(self) -> int:
        pass

    @abstractmethod
    def get_output_size(self) -> int:
        pass

    @abstractmethod
    def forward(self, X) -> y:
        """
        Alias for __call__, can override for clarity
        """
        pass

    def fitness(self):
        """
        Objective function
        """
        pass

Usage

By wrapping a computationally expensive peridynamic model that simulates the fracture behaviour of a three-point beam in bending… we can integrate the model into different workflows with ease. For example, here we will demonstrate the calibration of the constitutive model parameters ($\alpha$, $k$) to minimise the discrepancy between the model predictions and experimental data.

class Beam(Model):

    import pypd

    def __init__(self):
        super().__init__()
        self.simulation = pypd.simulation(n_time_steps=100000, damping=0)

    def __call__(self, x):
        model = setup_problem(x)
        self.simulation.run(model)

    def get_input_size(self) -> int:
        """
        alpha and k
        """
        return 2

    def forward(self):
        self.simulation.run(self.model)

    def fitness(self):
        pass


def setup_problem(k, alpha):
    x = build_particle_coordinates(dx, n_div_x, n_div_y)
    flag, unit_vector = build_boundary_conditions(x)  # TODO: not needed

    material = pypd.Material(name="quasi-brittle", E=37e9, Gf=143.2, density=2346, ft=3.9e6)
    bc = pypd.BoundaryConditions(flag, unit_vector, magnitude=0)
    particles = pypd.ParticleSet(x, dx, bc, material)

    radius = 25 * mm_to_m
    penetrators = []
    penetrators.append(
        pypd.Penetrator(
            np.array([0.5 * length, depth + radius - dx]),
            np.array([0, 1]),
            np.array([0, -0.4 * mm_to_m]),
            radius,
            particles,
            name="Penetrator",
            plot=False,
        )
    )
    penetrators.append(
        pypd.Penetrator(
            np.array([0.5 * depth, -radius]),
            np.array([0, 0]),
            np.array([0, 0]),
            radius,
            particles,
            name="Support - left",
            plot=False,
        )
    )
    penetrators.append(
        pypd.Penetrator(
            np.array([3 * depth, -radius]),
            np.array([0, 0]),
            np.array([0, 0]),
            radius,
            particles,
            name="Support - right",
            plot=False,
        )
    )

    observations = []
    observations.append(
        pypd.Observation(
            np.array([77.5 * mm_to_m, 0]), particles, period=1, name="CMOD - left"
        )
    )
    observations.append(
        pypd.Observation(
            np.array([97.5 * mm_to_m, 0]), particles, period=1, name="CMOD - right"
        )
    )

    bonds = pypd.BondSet(particles),
                         constitutive_law=pypd.NonLinear, 
                         constitutive_law_params={'alpha': alpha, 'k': k},
                         surface_correction=True, 
                         notch=notch)
    model = pypd.Model(particles, 
                       bonds, 
                       penetrators, 
                       observations)

from scipy.optimize import minimize


beam = Beam()
result = minimize(beam, x0=[alpha, k], method='Nelder-Mead', options={'maxiter': 100})

Microservices inspired

The above concept can be taken to the next level by adopting a microservices inspired workflow where a Docker image is built of the model…

Clean separation between model logic and serving logic…

Whilst thinking about the above ideas… um-bridgehttps://github.com/um-bridge

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import numpy as np

app = FastAPI()

class Input(BaseModel):
    X: list[float]

def serve_model(model: Model):
    @app.post("/evaluate")
    def evaluate(input_data: Input):
        try:
            X = np.array(input_data.X).reshape(1, -1)
            y = model(X)
            return {"y": y.tolist()}
        except Exception as e:
            raise HTTPException(status_code=500, detail=str(e))

    @app.get("/meta")
    def meta():
        return {
            "input_size": model.get_input_size(),
            "output_size": model.get_output_size()
        }

Example

class ToyModel(Model):
    def __call__(self, X: np.ndarray) -> np.ndarray:
        return X ** 2

    def get_input_size(self):
        return 1

    def get_output_size(self):
        return 1

    def forward(self, X: np.ndarray) -> np.ndarray:
        return self.__call__(X)
if __name__ == "__main__":
    import uvicorn
    toy_model = ToyModel()
    serve_model(toy_model)
    uvicorn.run(app, host="0.0.0.0", port=8000)