Physics first: what are we predicting?
In one lineDecide which quantities the model must reproduce before choosing the model.
A surrogate is only useful if it predicts the quantities an engineer would read from the simulation.
- Surface
- Pressure, wall shear stress, heat flux. Loads, lift and drag, hot spots: what designers integrate.
- Volume
- Velocity, pressure, vorticity, temperature, turbulence quantities. Wakes, vortices, separation, mixing: what explains the surface numbers.
- Integral
- Lift, drag and moment coefficients, efficiency. The few numbers that decide a design; scalar surrogates stop here.
Choose the quantities from the decision the model has to support: integrated loads need accurate surface fields, explaining a design needs the volume, and a quick screening may only need the integrals.
Non-dimensional quantities
Engineers rarely predict pressure in pascals. They predict the pressure coefficient, the pressure rise over the free stream divided by the dynamic pressure:
The same idea gives the lift and drag coefficients CL and CD (force divided by dynamic pressure and a reference area), and the numbers that describe the flow regime: the Reynolds number Re = ρVL/μ (inertia against viscosity) and the Mach number M = V/a (speed against the speed of sound). Non-dimensional quantities help a surrogate in three ways: runs at different speeds or sizes but the same Re and M look alike, so the model has less to learn; values are of order one, which suits fitting (more in normalising); and the result transfers between test conditions.
Integrals come from fields
Lift, drag and moments are integrals of the surface field. The pressure pushes on every panel of the surface along its inward normal; summing those forces and keeping the component in the lift direction gives CL:
So a full-field model gives the integrals for free, and consistently with the field (when the field and the forces come from one solution, as in CFD; the site’s simple solver computes them separately, see the dataset notes in your first surrogate). The reverse is not true: a model of CL alone cannot say where the lift comes from. Small field errors can also add up in an integral, which is why later lessons check both (see measuring error).
import numpy as np
# One run on a coarse surface mesh: 4 panels on top, 4 below.
# Per panel: pressure coefficient, area (m²), outward normal.
cp = np.array([-1.1, -0.8, -0.5, -0.2, 0.3, 0.15, 0.05, 0.0])
area = np.full(8, 0.25)
up, down = [0, 0, 1], [0, 0, -1]
normal = np.array([up] * 4 + [down] * 4, dtype=float)
# Reference area in m².
S_ref = 1.0
force = cp[:, None] * normal * area[:, None]
coeffs = -force.sum(axis=0) / S_ref
# Lift: the component normal to the flow (here z).
CL = coeffs[2]
print(f"CL = {CL:.3f}") # CL = 0.775
Suction on top (negative Cp, outward normal up) and overpressure underneath (positive Cp, normal down) both push upward. With the flow at an angle, use the lift direction (perpendicular to the free stream) instead of the vertical axis.
Check yourself
Why predict Cp rather than pressure in pascals?
Cp removes the effect of free-stream pressure, density and speed, so runs at different conditions look alike, values are of order one, and the model has less to learn.
A field model predicts Cp on the wing. How do you get CL?
Integrate: sum −Cp times the lift-direction component of each panel’s outward normal times its area, then divide by the reference area.
Your study ranks designs by drag only. Do you need a volume-field surrogate?
Probably not. A surface or even a scalar model is enough; the volume helps to explain a design, not to rank it.
Go further
Next lessonFrom runs to arrays
From runs to arrays.
In one lineEach simulation becomes rows of numbers: inputs X, outputs Y, and a run id on every row.
A model reads numbers, not result files. The first job is to turn every run into rows of numbers with a fixed layout.
A run has inputs (the design parameters and operating conditions you chose) and outputs (what the solver returned). How you lay them out depends on whether the outputs are a few numbers or a field. NumPy arrays are the common currency: scikit-learn and PyTorch both read them directly.
Scalars: one row per run
For scalar outputs, stack the runs as rows. X holds the inputs, one column per parameter; Y holds the outputs, one column per quantity. Row i of X and row i of Y are the same run.
- X: (n_runs, n_params)
- The aircraft: (44, 2), the angle of attack and the sweep of each run.
- Y: (n_runs, n_outputs)
- The aircraft: (44, 2) if you keep the lift and induced-drag coefficients.
Fields: runs × points × channels
When every run uses the same mesh (same points, in the same order), a field fits in a three-dimensional array: one slice per run, one row per point, one column per channel (a physical quantity at that point, such as Cp, or one component of the wall shear stress).
- Y: (n_runs, n_points, n_channels)
- The aircraft: (44, 7,952, 1), Cp at every surface point of every run. With Cp and three wall-shear components it would be (44, 7,952, 4).
- A list of (n_points_i, n_channels)
- When the geometry changes from run to run, so does the mesh, and the arrays cannot be stacked. Keep one array per run. How networks deal with that is the subject of grids, meshes and point clouds.
The point-wise table
Some models, such as the neural field on this site, learn from one point at a time. For them, flatten the fields into a long table with one row per point per run: where the point is, which design it belongs to, and the value there. The aircraft gives 44 × 7,952 = 349,888 rows.
| run | x | y | z | α (°) | Λ (°) | Cp |
|---|---|---|---|---|---|---|
| 0 | 1.92 | 0.31 | 0.12 | 6.03 | 30.25 | -0.84 |
| 0 | 1.95 | 0.33 | 0.12 | 6.03 | 30.25 | -0.61 |
| 0 | 2.03 | 0.36 | 0.11 | 6.03 | 30.25 | -0.22 |
| 1 | 1.92 | 0.31 | 0.12 | 6.33 | 24.14 | -0.47 |
| 1 | 1.95 | 0.33 | 0.12 | 6.33 | 24.14 | -0.38 |
| … 349,883 more rows | ||||||
The design columns are the site’s first two runs; the point values are for illustration.
import numpy as np
from pathlib import Path
# Stand-in for solver output: params.csv (run, alpha, sweep)
# and one CSV per run with one row per surface point
# (x, y, z, cp). The mesh is the same in every run.
rng = np.random.default_rng(0)
root = Path("runs")
root.mkdir(exist_ok=True)
params = np.column_stack([np.arange(44),
rng.uniform(-2, 8, 44),
rng.uniform(20, 36, 44)])
np.savetxt(root / "params.csv", params, delimiter=",",
header="run,alpha,sweep", comments="")
xyz = rng.uniform(-1, 1, (500, 3))
for run, a, s in params:
cp = 0.1 * a * np.sin(3 * xyz[:, 0]) - 0.01 * s * xyz[:, 1]
out = np.column_stack([xyz, cp])
np.savetxt(root / f"run_{int(run):03d}.csv", out,
delimiter=",")
# Read it back as arrays. X: (runs, params).
P = np.loadtxt(root / "params.csv", delimiter=",", skiprows=1)
run_id, X = P[:, 0].astype(int), P[:, 1:]
F = np.stack([np.loadtxt(root / f"run_{r:03d}.csv",
delimiter=",") for r in run_id])
# Y: (runs, points, channels).
Y = F[:, :, 3:]
print(X.shape, F.shape, Y.shape)
# (44, 2) (44, 500, 4) (44, 500, 1)
# The point-wise table: one row per point per run.
n_runs, n_pts, _ = F.shape
table = np.column_stack([
np.repeat(run_id, n_pts), # run id
F[:, :, :3].reshape(-1, 3), # x, y, z
np.repeat(X, n_pts, axis=0), # alpha, sweep
Y.reshape(-1, 1), # cp
])
print(table.shape) # (22000, 7)
With the data laid out, the next question is which runs to make in the first place: sampling the design space.
Check yourself
44 runs, 8,000 surface points, outputs Cp and three wall-shear components. Shape of Y?
(44, 8000, 4): runs × points × channels.
Why keep the run id on every row of the point-wise table?
So training and test sets can be split by design. Without it, points of the same run end up on both sides and the test error is far too optimistic.
Every run has its own mesh. Can you stack the fields into one array?
No: the point counts differ. Keep a list with one (points × channels) array per run, or resample onto a shared set of points.
Go further
Next lessonSampling the design space
Sample the design space.
In one lineSpread a limited simulation budget evenly over the designs you care about.
Simulations are expensive, so each one should teach the surrogate something new.
First choose the design variables (what changes from run to run) and their ranges. The ranges matter: the surrogate will be trusted only inside them. Then decide where in that box to put a limited number of runs. This choice is called the design of experiments.
A regular grid looks natural but scales badly: 10 levels for each of 5 variables is 10⁵ runs, and each level of each variable is repeated thousands of times. Purely random points are cheap but leave clumps and gaps. Space-filling designs spread a fixed number of runs evenly:
- Latin hypercube
- Cut each variable’s range into N equal slices, then place N samples so that every slice of every variable holds exactly one of them, with the slices paired at random. Seen along any one axis, the samples are perfectly spread. A maximin variant draws many such designs and keeps the one whose two closest samples are farthest apart.
- Sobol sequence
- A deterministic low-discrepancy sequence, built digit by digit in base 2: every block of 2m points puts the right number of points in each binary sub-box of the space, so any prefix of the sequence stays evenly spread. You can add points later, which suits budgets that grow; the balance is exact only at powers of two (32, 64, 128 runs). Sobol indices, a sensitivity measure met in the design unit, are a different tool by the same mathematician (and are often estimated with Sobol samples).
How many runs? A common starting point for smooth scalar outputs is about ten runs per design variable. Rough responses, many variables or full fields need more, and the only reliable check is the error on held-out runs (see train, validation and test). The code below compares designs by their discrepancy, a measure of how unevenly the points fill the box (lower is more even).
import numpy as np
from scipy.stats import qmc
# Ranges of alpha and sweep, in degrees.
lo, hi = [-2.0, 20.0], [8.0, 36.0]
# 44 Latin hypercube points in the unit square, then in degrees.
u = qmc.LatinHypercube(d=2, rng=0).random(n=44)
designs = qmc.scale(u, lo, hi)
# 2^5 = 32 Sobol points, and plain random ones to compare.
sobol = qmc.Sobol(d=2, rng=0).random_base2(m=5)
plain = np.random.default_rng(0).random((44, 2))
# Discrepancy: how unevenly the points fill the box.
for name, pts in [("LHS", u), ("Sobol", sobol),
("random", plain)]:
print(name, round(qmc.discrepancy(pts), 4))
# Hold out validation designs now, before any training.
order = np.random.default_rng(1).permutation(len(designs))
val, train = designs[order[:8]], designs[order[8:]]
print(train.shape, val.shape) # (36, 2) (8, 2)
The rng= argument needs SciPy 1.15 or later; older versions call it seed=.
Not every run has to be expensive. Many cheap runs (a coarse mesh, a simpler physics model) can be mixed with a few accurate ones; see cheap and expensive data. And instead of fixing all runs up front, the surrogate itself can choose where to simulate next: choosing the next simulation. Before any of the runs train a model, check that they are right: the next lesson, is the training data right?
Check yourself
What does a Latin hypercube guarantee?
Each variable’s range is cut into N equal slices, and every slice holds exactly one of the N samples. Projected onto any one variable, the samples are evenly spread.
Why not a full grid with 10 levels per variable?
With 5 variables that is 10⁵ runs, and each level of each variable is repeated thousands of times. Space-filling designs give good coverage with far fewer runs.
When should the validation designs be chosen?
Before training, and they must never be used to fit the model. Choosing them afterwards invites picking the ones the model happens to get right.
Go further
- Quasi-Monte Carlo (scipy.stats.qmc)SciPy documentation ↗
- LatinHypercubeSciPy documentation ↗
- SobolSciPy documentation ↗
- Latin hypercube samplingSMT documentation ↗
- Multi-fidelity scalingOn this siteTreats simulation fidelity as a spectrum and finds the mix of cheap and accurate training samples that minimises error for a fixed data-generation budget, across six PDEs, two error sources and four architectures. NeurIPS 2026
Next lessonIs the training data right?
Is the training data right?
In one lineA surrogate learns the solver’s errors too: check mesh convergence, failed runs and outliers before training.
A surrogate learns whatever the solver says, including its errors. Check the runs before you train on them.
Every lesson after this one compares the surrogate with the solver and calls the solver the truth. It is not: it is a numerical approximation of a physical model, which is itself an approximation of reality. Three checks, familiar from any careful simulation campaign, decide whether the runs deserve to be training data.
1. Is each run converged?
Mesh independence. Run a few representative designs on three meshes, each refined by the same ratio, and watch the quantities you care about. If they still move between the medium and the fine mesh, the dataset will teach the surrogate a mesh error. Richardson extrapolation estimates the mesh-independent value, and the grid convergence index (GCI, Celik et al. 2008) turns the change into an error band for the fine mesh.
Iterative convergence. For each run, check that the residuals have dropped by several orders of magnitude and, more importantly, that the monitored forces have stopped drifting. A run stopped early looks like a valid result and is not. Runs that never settle may be physically unsteady (vortex shedding, buffet), and a steady-state value for them is an arbitrary snapshot.
2. Did any run fail quietly?
In a batch of hundreds of runs, a few always go wrong: a bad mesh, a diverged solution, a setup typo, a different solver version. They rarely crash; they just come out wrong. Look for outliers: plot each integrated quantity against each input, and compare every run with a smooth fit through all the others. A run far from its neighbours is either a real physical change, which you should understand, or an error, which you should fix or drop. Also check that every run used the same setup: the same turbulence model, boundary conditions, reference values and post-processing.
import numpy as np
# 1. Mesh independence: drag coefficient on three meshes,
# each refined by r = 2 in every direction.
cd = {"coarse": 0.02910, "medium": 0.02840, "fine": 0.02820}
f3, f2, f1 = cd["coarse"], cd["medium"], cd["fine"]
r = 2.0
# Observed order of convergence, Richardson extrapolation,
# and the grid convergence index (GCI) of the fine mesh.
p = np.log((f3 - f2) / (f2 - f1)) / np.log(r)
f_exact = f1 + (f1 - f2) / (r ** p - 1)
gci = 1.25 * abs((f2 - f1) / f1) / (r ** p - 1)
print(f"order {p:.2f}, extrapolated CD {f_exact:.5f},"
f" GCI {gci:.2%}")
# 2. Convergence: final residual and drift of the
# monitored lift over the last 200 iterations, per run.
rng = np.random.default_rng(0)
n_runs = 44
final_residual = 10.0 ** rng.uniform(-7, -5, n_runs)
final_residual[[7, 30]] = [3e-3, 1e-4]
cl_drift = np.abs(rng.normal(0, 2e-4, n_runs))
cl_drift[30] = 0.02
unconverged = (final_residual > 1e-5) | (cl_drift > 1e-3)
print("not converged:", np.flatnonzero(unconverged))
# 3. Outliers: compare each run's lift with a smooth fit
# of all the others (leave one out).
alpha = rng.uniform(-2, 8, n_runs)
cl = 0.14 + 0.08 * alpha + rng.normal(0, 0.005, n_runs)
cl[12] += 0.15
res = np.empty(n_runs)
for i in range(n_runs):
keep = np.arange(n_runs) != i
w = np.polyfit(alpha[keep], cl[keep], deg=2)
res[i] = cl[i] - np.polyval(w, alpha[i])
# A robust spread: 1.4826 x the median absolute deviation.
sigma = 1.4826 * np.median(np.abs(res - np.median(res)))
print("outliers:", np.flatnonzero(np.abs(res) > 5 * sigma))
3. How large is each error?
The error that matters to the design is the surrogate’s error plus the solver’s error against reality. Put numbers on both before tuning the surrogate. Solver-against-test error comes from validation cases (wind tunnel, rig or flight data); mesh error from the GCI; surrogate error from held-out runs (see measuring error).
Check yourself
CD changes by 2 % between the medium and the fine mesh. What does the surrogate learn?
The mesh error along with the physics. Refine until the change is small against the accuracy you need, or estimate the error with Richardson extrapolation and a GCI.
One run’s lift sits far from a smooth fit through all the others. Drop it?
Not before you know why. It may be a failed or mis-set run (fix or drop it) or a real physical change such as separation (keep it, and expect the model to need more data there).
The solver is 5 % off the wind tunnel and the surrogate 1 % off the solver. Where should effort go?
Into the physics or a calibration against the tunnel: halving the surrogate error barely changes the total error.
Go further
Next unit · Fitting and testingFitting a response surface