Wrapping MATLAB Code

View on GitHub

Context

MATLAB is one of the most widely used tools in engineering for simulation, control system design, and signal processing. Many organizations have decades of validated MATLAB code that they want to make accessible through modern APIs without rewriting. Tesseract provides a way to wrap MATLAB code by using the official MathWorks MATLAB Docker image as the base image and calling MATLAB via matlab -batch.

This example demonstrates the pattern with a spring-mass-damper ODE solver. The approach works for any MATLAB script or function that can run in batch mode.

Example Tesseract (examples/_matlab_springmass)

The MATLAB solver

The solver simulates a damped harmonic oscillator under a step force input:

\[m \ddot{x} + c \dot{x} + k x = F_0\]

using MATLAB’s ode45 (Dormand-Prince Runge-Kutta). It reads input parameters from a JSON file and writes results to a JSON file:

function spring_mass_damper(input_file, output_file)
%SPRING_MASS_DAMPER Solve a spring-mass-damper ODE system.
%
%   Solves the damped harmonic oscillator equation:
%       m * x'' + c * x' + k * x = F0  (step force input)
%
%   using MATLAB's ode45 (Dormand-Prince Runge-Kutta).
%
%   Arguments:
%       input_file  - Path to JSON file with input parameters
%       output_file - Path to write JSON results

    % Read input parameters
    raw = fileread(input_file);
    params = jsondecode(raw);

    mass = params.mass;
    damping = params.damping;
    stiffness = params.stiffness;
    force_amplitude = params.force_amplitude;
    t_end = params.t_end;
    n_output_points = params.n_output_points;

    % Define the ODE system as first-order:
    %   y(1) = x   (displacement)
    %   y(2) = x'  (velocity)
    %
    %   y(1)' = y(2)
    %   y(2)' = (F0 - c*y(2) - k*y(1)) / m
    odefun = @(t, y) [
        y(2);
        (force_amplitude - damping * y(2) - stiffness * y(1)) / mass
    ];

    % Initial conditions: at rest
    y0 = [0; 0];

    % Output time points
    t_eval = linspace(0, t_end, n_output_points);

    % Solve with ode45
    opts = odeset('RelTol', 1e-8, 'AbsTol', 1e-10);
    [t_sol, y_sol] = ode45(odefun, t_eval, y0, opts);

    % Build output structure
    result = struct();
    result.time = t_sol(:)';
    result.displacement = y_sol(:, 1)';
    result.velocity = y_sol(:, 2)';

    % Write output
    json_str = jsonencode(result);
    fid = fopen(output_file, 'w');
    fprintf(fid, '%s', json_str);
    fclose(fid);

    fprintf('Solver completed: %d time steps, t_end = %.3f s\n', ...
        n_output_points, t_end);
end

Input and output schemas

The InputSchema defines the physical parameters of the spring-mass-damper system:

class InputSchema(BaseModel):
    """Input parameters for the spring-mass-damper ODE solver.

    Solves: m * x'' + c * x' + k * x = F0 (step force)
    with initial conditions x(0) = 0, x'(0) = 0.
    """

    mass: float = Field(
        default=1.0,
        gt=0.0,
        description="Mass [kg].",
    )
    damping: float = Field(
        default=0.5,
        ge=0.0,
        description="Damping coefficient [N*s/m]. Set to 0 for undamped oscillation.",
    )
    stiffness: float = Field(
        default=10.0,
        gt=0.0,
        description="Spring stiffness [N/m].",
    )
    force_amplitude: float = Field(
        default=1.0,
        description="Step force amplitude [N]. Applied as a constant force for t >= 0.",
    )
    t_end: float = Field(
        default=10.0,
        gt=0.0,
        description="Simulation end time [s].",
    )
    n_output_points: int = Field(
        default=200,
        ge=10,
        le=10000,
        description="Number of output time points.",
    )

The OutputSchema returns time histories along with analytical system characteristics:

class OutputSchema(BaseModel):
    """Output from the spring-mass-damper solver."""

    time: Array[(None,), Float64] = Field(
        description="Time values [s]. Shape: (n_output_points,)",
    )
    displacement: Array[(None,), Float64] = Field(
        description="Displacement x(t) [m]. Shape: (n_output_points,)",
    )
    velocity: Array[(None,), Float64] = Field(
        description="Velocity x'(t) [m/s]. Shape: (n_output_points,)",
    )
    steady_state: float = Field(
        description="Analytical steady-state displacement F0/k [m].",
    )
    damping_ratio: float = Field(
        description="Damping ratio zeta = c / (2 * sqrt(k * m)) [-]. "
        "< 1: underdamped, = 1: critically damped, > 1: overdamped.",
    )
    natural_frequency: float = Field(
        description="Natural frequency omega_n = sqrt(k / m) [rad/s].",
    )

Subprocess integration

The apply function writes input parameters as JSON, invokes MATLAB via matlab -batch, and reads back the JSON results:

def apply(inputs: InputSchema) -> OutputSchema:
    """Run the MATLAB spring-mass-damper solver.

    This function:
    1. Writes input parameters to a temporary JSON file
    2. Calls MATLAB via `matlab -batch` to run the solver
    3. Reads the JSON output file and returns results
    """
    with TemporaryDirectory() as tmpdir:
        tmpdir_path = Path(tmpdir)
        input_file = tmpdir_path / "input.json"
        output_file = tmpdir_path / "output.json"

        # Write input parameters as JSON
        input_data = {
            "mass": inputs.mass,
            "damping": inputs.damping,
            "stiffness": inputs.stiffness,
            "force_amplitude": inputs.force_amplitude,
            "t_end": inputs.t_end,
            "n_output_points": inputs.n_output_points,
        }
        input_file.write_text(json.dumps(input_data))

        # Build the MATLAB command.
        # addpath so MATLAB can find spring_mass_damper.m,
        # then call it with input/output file paths.
        matlab_cmd = (
            f"addpath('{SOLVER_DIR}'); "
            f"spring_mass_damper('{input_file}', '{output_file}')"
        )

        process = subprocess.Popen(
            [MATLAB_BIN, "-batch", matlab_cmd],
            stdout=None,  # Inherit stdout — streams to parent process
            stderr=None,  # Inherit stderr — streams to parent process
        )
        returncode = process.wait()

        if returncode != 0:
            raise RuntimeError(f"MATLAB solver failed with return code {returncode}.")

        # Read output JSON
        output_data = json.loads(output_file.read_text())

    time = np.array(output_data["time"], dtype=np.float64)
    displacement = np.array(output_data["displacement"], dtype=np.float64)
    velocity = np.array(output_data["velocity"], dtype=np.float64)

    # Compute analytical quantities
    steady_state = inputs.force_amplitude / inputs.stiffness
    natural_frequency = math.sqrt(inputs.stiffness / inputs.mass)
    damping_ratio = inputs.damping / (2.0 * math.sqrt(inputs.stiffness * inputs.mass))

    return OutputSchema(
        time=time,
        displacement=displacement,
        velocity=velocity,
        steady_state=steady_state,
        damping_ratio=damping_ratio,
        natural_frequency=natural_frequency,
    )

Build configuration

The tesseract_config.yaml uses the official MathWorks MATLAB Docker image as the base, so MATLAB is pre-installed — no compilation or additional toolboxes are needed:

name: "matlab-springmass"
version: "1.0.0"
description: |
  Spring-mass-damper ODE solver demonstrating MATLAB integration.

  This example shows how to wrap MATLAB code as a Tesseract using the
  official MathWorks MATLAB Docker image. The solver uses ode45 to solve
  the damped harmonic oscillator equation:

    m * x'' + c * x' + k * x = F(t)

  Industry relevance: vibration analysis, mechanical system design,
  control system prototyping, and structural dynamics.

  Prerequisites:
    - A MATLAB network license server reachable from the container
    - Set MLM_LICENSE_FILE at runtime (e.g. 27000@your-license-server)

build_config:
  # Official MathWorks MATLAB image — includes a full MATLAB installation.
  # Change the tag to match your license version if needed.
  base_image: "mathworks/matlab:r2025b"

  # Copy the MATLAB .m source file into the container
  package_data:
    - ["matlab/spring_mass_damper.m", "matlab/spring_mass_damper.m"]

Key points:

  • base_image: Uses mathworks/matlab:r2025b, which has a full MATLAB installation

  • package_data: Copies the .m source file into the container

  • No compilation step: MATLAB runs the .m file directly via matlab -batch

Runtime requirements

Because the container includes MATLAB but not a license, you must provide a network license server at runtime:

tesseract run matlab-springmass apply '{}' \
  --runtime-args "\
    -e MLM_LICENSE_FILE=27000@your-license-server \
    --add-host=your-license-server:YOUR_SERVER_IP \
    --shm-size=512M"

The --shm-size=512M flag is required because MATLAB’s JVM uses POSIX shared memory, and Docker’s default of 64MB is insufficient.

Adapting this pattern

To wrap your own MATLAB code:

  1. Choose a base image: Use mathworks/matlab:<release> matching your license. Tags are available for R2024a and later.

  2. Structure your MATLAB code: Write a function that takes file paths as arguments for I/O. Use jsondecode/jsonencode (available since R2016b) for data exchange.

  3. Create schemas: Map your MATLAB function’s parameters to Pydantic models.

  4. Implement apply(): Write the subprocess glue that calls matlab -batch.

  5. Configure licensing: Ensure MLM_LICENSE_FILE is passed at runtime.

For distributing Tesseracts to users without a MATLAB license, consider the MATLAB Compiler SDK: compile .m files into standalone executables and bundle the free MATLAB Runtime instead.