Skip to content

Exporting to Fortran

This tutorial covers the three Fortran export pathways in hyper-surrogate: standalone NN modules, hybrid UMAT subroutines (NN energy + analytical mechanics), and purely analytical UMATs from symbolic material models.


Overview: Three Export Paths

Export Path Class What It Generates NN Required?
Standalone NN FortranEmitter Fortran 90 module with NN forward pass Yes
Hybrid UMAT HybridUMATEmitter Complete Abaqus UMAT (NN energy + analytical stress/tangent) Yes
Analytical UMAT UMATHandler Complete Abaqus UMAT from symbolic material (SymPy CSE) No
              ┌─────────────────┐
              │  Trained Model  │
              └────────┬────────┘
                       │
           ┌───────────┼───────────┐
           ▼           ▼           ▼
    FortranEmitter  HybridUMAT  UMATHandler
     (standalone)   Emitter     (symbolic)
           │           │           │
           ▼           ▼           ▼
      nn_module.f90  umat.f90   umat.f

1. Weight Extraction: ExportedModel

Before any Fortran export, you need to extract the trained model's weights and normalizers into a portable format:

import hyper_surrogate as hs

# After training...
exported = hs.extract_weights(result.model, in_norm, out_norm)

# Save to disk (NumPy .npz)
exported.save("my_model.npz")

# Load later
exported = hs.ExportedModel.load("my_model.npz")

What's inside ExportedModel

Field Type Description
layers List[LayerInfo] Layer metadata (activation, dimensions)
weights Dict[str, ndarray] Weight matrices and bias vectors
input_normalizer Dict Input mean/std for standardization
output_normalizer Dict Output mean/std for de-standardization
metadata Dict Architecture type, input/output dims, branch info

2. Standalone Fortran Module (FortranEmitter)

Generates a self-contained Fortran 90 module with the NN forward pass. All weights are baked in as PARAMETER arrays.

2.1 MLP Export

import hyper_surrogate as hs

# Train an MLP
material = hs.NeoHooke({"C10": 0.5, "KBULK": 1000.0})
train_ds, val_ds, in_norm, out_norm = hs.create_datasets(
    material, n_samples=5000,
    input_type="invariants", target_type="pk2_voigt",
)

model = hs.MLP(input_dim=3, output_dim=6, hidden_dims=[32, 32], activation="tanh")
result = hs.Trainer(model, train_ds, val_ds, loss_fn=hs.StressLoss(), max_epochs=500).fit()

# Export
exported = hs.extract_weights(result.model, in_norm, out_norm)
emitter = hs.FortranEmitter(exported)
emitter.write("nn_surrogate.f90")

2.2 What the generated Fortran looks like

The .f90 file contains:

MODULE nn_surrogate
  IMPLICIT NONE
  ! ── Normalization parameters ──
  DOUBLE PRECISION, PARAMETER :: INPUT_MEAN(3) = (/ ... /)
  DOUBLE PRECISION, PARAMETER :: INPUT_STD(3) = (/ ... /)
  DOUBLE PRECISION, PARAMETER :: OUTPUT_MEAN(6) = (/ ... /)
  DOUBLE PRECISION, PARAMETER :: OUTPUT_STD(6) = (/ ... /)

  ! ── Layer weights (15-digit precision) ──
  DOUBLE PRECISION, PARAMETER :: W1(32, 3) = RESHAPE((/ ... /), (/32, 3/))
  DOUBLE PRECISION, PARAMETER :: B1(32) = (/ ... /)
  ! ... more layers ...

CONTAINS
  SUBROUTINE nn_forward(input, output)
    DOUBLE PRECISION, INTENT(IN)  :: input(3)
    DOUBLE PRECISION, INTENT(OUT) :: output(6)
    DOUBLE PRECISION :: x_norm(3), h1(32), h2(32)

    ! Normalize input
    x_norm = (input - INPUT_MEAN) / INPUT_STD

    ! Layer 1: MATMUL + tanh
    h1 = TANH(MATMUL(W1, x_norm) + B1)

    ! Layer 2: MATMUL + tanh
    h2 = TANH(MATMUL(W2, h1) + B2)

    ! Output layer: MATMUL (no activation)
    output = MATMUL(W3, h2) + B3

    ! Denormalize output
    output = output * OUTPUT_STD + OUTPUT_MEAN
  END SUBROUTINE
END MODULE

2.3 ICNN and PolyconvexICNN Export

The FortranEmitter auto-detects the architecture and generates the appropriate forward pass:

# ICNN export (includes softplus enforcement on z-path weights)
model = hs.ICNN(input_dim=3, hidden_dims=[32, 32])
# ... train ...
exported = hs.extract_weights(result.model, in_norm, out_norm)
hs.FortranEmitter(exported).write("icnn_energy.f90")

# PolyconvexICNN export (per-branch forward pass)
model = hs.PolyconvexICNN(groups=[[0], [1], [2]], hidden_dims=[32, 32])
# ... train ...
exported = hs.extract_weights(result.model, in_norm, out_norm)
hs.FortranEmitter(exported).write("polyconvex_energy.f90")

Key properties of generated Fortran

Property Details
Precision 15-digit double precision (:.15e format)
Array layout Column-major (native Fortran ordering)
Forward pass MATMUL-based (efficient on modern compilers)
Dependencies None — fully self-contained
Activations TANH, softplus (\(\ln(1 + e^x)\)), ReLU, sigmoid

3. Hybrid UMAT Emitter (HybridUMATEmitter)

The most powerful export: generates a complete Abaqus UMAT subroutine where:

  • The neural network provides \(W(\bar{I}_1, \bar{I}_2, J)\) (strain energy)
  • Analytical Fortran code handles kinematics, stress, tangent, and push-forward

3.1 How it works

Abaqus calls UMAT with F (deformation gradient)
  │
  ├─ 1. Kinematics:  F → C = F^T·F → I1_bar, I2_bar, J (analytical)
  │
  ├─ 2. NN Forward:  [I1_bar, I2_bar, J] → normalize → hidden layers → W (energy)
  │
  ├─ 3. NN Backward: Backprop dW/dI1_bar, dW/dI2_bar, dW/dJ
  │
  ├─ 4. PK2 Stress:  S = 2·Σ(dW/dI_k)·(dI_k/dC)  (analytical chain rule)
  │
  ├─ 5. Hessian:     Forward-mode Jacobian for d²W/dI² (NN second derivatives)
  │
  ├─ 6. Tangent:     C_mat from dI/dC and d²W/dI²  (analytical)
  │
  ├─ 7. Push-forward: σ = (1/J)·F·S·F^T  (Cauchy stress)
  │
  └─ 8. Jaumann:     Spatial tangent + Jaumann rate correction

3.2 Example: Isotropic Hybrid UMAT

import numpy as np
import hyper_surrogate as hs
from hyper_surrogate.data.dataset import MaterialDataset, Normalizer
from hyper_surrogate.data.deformation import DeformationGenerator
from hyper_surrogate.mechanics.kinematics import Kinematics

# 1. Generate data
material = hs.NeoHooke({"C10": 0.5, "KBULK": 1000.0})
n = 20000
gen = DeformationGenerator(seed=42)
F = gen.combined(n, stretch_range=(0.8, 1.3), shear_range=(-0.2, 0.2))
rng = np.random.default_rng(99)
J_target = rng.uniform(0.95, 1.05, size=n)
F = F * (J_target / np.linalg.det(F))[:, None, None] ** (1.0 / 3.0)
C = Kinematics.right_cauchy_green(F)

# 2. Invariants and targets
i1 = Kinematics.isochoric_invariant1(C)
i2 = Kinematics.isochoric_invariant2(C)
j = np.sqrt(Kinematics.det_invariant(C))
inputs = np.column_stack([i1, i2, j])
energy = material.evaluate_energy(C)
dW_dI = material.evaluate_energy_grad_invariants(C)

# 3. Normalize and build datasets
in_norm = Normalizer().fit(inputs)
X = in_norm.transform(inputs).astype(np.float32)
W = energy.reshape(-1, 1).astype(np.float32)
S = (dW_dI * in_norm.params["std"]).astype(np.float32)

n_val = int(n * 0.15)
idx = np.random.default_rng(42).permutation(n)
train_ds = MaterialDataset(X[idx[n_val:]], (W[idx[n_val:]], S[idx[n_val:]]))
val_ds = MaterialDataset(X[idx[:n_val]], (W[idx[:n_val]], S[idx[:n_val]]))

# 4. Train
model = hs.MLP(input_dim=3, output_dim=1, hidden_dims=[64, 64, 64], activation="softplus")
result = hs.Trainer(
    model, train_ds, val_ds,
    loss_fn=hs.EnergyStressLoss(alpha=1.0, beta=1.0),
    max_epochs=3000, lr=1e-3, patience=300, batch_size=512,
).fit()

# 5. Export hybrid UMAT
energy_norm = Normalizer().fit(energy.reshape(-1, 1))
exported = hs.extract_weights(result.model, in_norm, energy_norm)
emitter = hs.HybridUMATEmitter(exported)
emitter.write("hybrid_umat.f90")

3.3 Anisotropic Hybrid UMAT (5D invariants)

When input_dim=5, the emitter auto-detects anisotropic mode and generates fiber invariant computation (\(I_4, I_5\)) plus the corresponding derivatives (\(\partial I_4 / \partial \mathbf{C}\), \(\partial I_5 / \partial \mathbf{C}\)):

# After training with 5D invariants (I1_bar, I2_bar, J, I4, I5)...
exported = hs.extract_weights(result.model, in_norm, energy_norm)
emitter = hs.HybridUMATEmitter(exported)
emitter.write("hybrid_umat_aniso.f90")
# Fiber direction is passed via PROPS(1:3) in the Abaqus input file

3.4 PolyconvexICNN Hybrid UMAT

# After training a PolyconvexICNN...
exported = hs.extract_weights(result.model, in_norm, energy_norm)
emitter = hs.HybridUMATEmitter(exported)
emitter.write("hybrid_umat_polyconvex.f90")
# Block-diagonal Hessian: more efficient tangent computation

3.5 What the hybrid UMAT contains

Section Description
Properties block Material parameters from PROPS array
Kinematics \(\mathbf{F} \to \mathbf{C} \to \bar{I}_1, \bar{I}_2, J\) (+ \(I_4, I_5\) for anisotropic)
NN Forward pass Normalized input → hidden layers → \(W\) (energy scalar)
NN Backward pass Backpropagation: \(\partial W / \partial I_\alpha\)
Jacobian propagation Forward-mode AD: \(\partial^2 W / \partial I_\alpha \partial I_\beta\) (Hessian)
PK2 stress \(\mathbf{S} = 2 \sum_\alpha (\partial W / \partial I_\alpha)(\partial I_\alpha / \partial \mathbf{C})\)
Cauchy push-forward \(\boldsymbol{\sigma} = (1/J)\,\mathbf{F}\,\mathbf{S}\,\mathbf{F}^T\)
Spatial tangent Full 4th-order tensor push-forward + Jaumann rate correction

4. Analytical UMAT (UMATHandler)

For when you want a Fortran UMAT directly from a symbolic material definition — no neural network involved:

from hyper_surrogate.mechanics.materials import NeoHooke
from hyper_surrogate.export.fortran.analytical import UMATHandler

material = NeoHooke({"C10": 0.5, "KBULK": 1000.0})
handler = UMATHandler(material)
handler.generate("neohooke_umat.f")

print("Generated: neohooke_umat.f")

How it works

Material (symbolic SEF)
  │
  ├─ SymPy: W(C) → S = ∂W/∂C (symbolic differentiation)
  │
  ├─ SymPy: S(C) → C_mat = ∂S/∂C (symbolic 2nd derivative)
  │
  ├─ SymPy CSE: Common Subexpression Elimination
  │
  └─ Fortran code generation: optimized UMAT subroutine

Features

Feature Details
Exact derivatives Symbolic differentiation (no numerical errors)
CSE optimization SymPy identifies and eliminates repeated subexpressions
Cauchy stress Push-forward from PK2 to Cauchy
Spatial tangent Full tangent with Jaumann rate correction
Compatible Works with any symbolic Material in the library

Works with any material

from hyper_surrogate.mechanics.materials import MooneyRivlin, Yeoh, Demiray

# Mooney-Rivlin
mat = MooneyRivlin({"C10": 0.3, "C01": 0.2, "KBULK": 1000.0})
UMATHandler(mat).generate("mooneyrivlin_umat.f")

# Yeoh
mat = Yeoh({"C10": 0.5, "C20": -0.01, "C30": 0.001, "KBULK": 1000.0})
UMATHandler(mat).generate("yeoh_umat.f")

# Demiray
mat = Demiray({"C1": 0.05, "C2": 8.0, "KBULK": 1000.0})
UMATHandler(mat).generate("demiray_umat.f")

5. Choosing an Export Path

Question Answer → Export Path
Do you need a NN surrogate? NoUMATHandler (analytical)
Do you need stress + tangent for FE? YesHybridUMATEmitter
Do you only need the NN forward pass? YesFortranEmitter (standalone)
Is the material energy-based (scalar NN)? YesHybridUMATEmitter
Is the material stress-based (6D NN)? FortranEmitter + custom UMAT wrapper
Is thermodynamic consistency critical? YesHybridUMATEmitter with ICNN/PolyconvexICNN

Decision flowchart

Need a surrogate?
├── No  ──────────────> UMATHandler (analytical, symbolic)
└── Yes
    └── Need full UMAT (stress + tangent)?
        ├── No  ──────> FortranEmitter (standalone NN module)
        └── Yes ──────> HybridUMATEmitter (NN energy + analytical mechanics)

6. Using the Generated UMAT in Abaqus

Abaqus input file setup

*MATERIAL, NAME=NN_SURROGATE
*DEPVAR
  1,
*USER MATERIAL, CONSTANTS=2
** KBULK, (unused)
  1000.0, 0.0

For anisotropic models, pass the fiber direction via PROPS:

*USER MATERIAL, CONSTANTS=5
** KBULK, fiber_x, fiber_y, fiber_z, (unused)
  1000.0, 1.0, 0.0, 0.0, 0.0

Compilation

# Compile the UMAT with Abaqus
abaqus job=my_simulation user=hybrid_umat.f90

# Or with Intel Fortran directly
ifort -c -O2 hybrid_umat.f90

The generated code uses only standard Fortran 90 features and requires no external libraries.