F1 race car, part 6: full lap-time-aware aero shape optimisation — DrivAerML + OpenVSP + SU2 + hero MP4¶
Series C closes here. Parts 1–2 built the lap-time simulator and the setup-gradient loop on a 2022+-era ground-effect car; Parts 3–4 fitted a 5-D aero surrogate from sparse CFD samples and ran a Sobol decomposition to allocate the FIA-ATR-limited CFD budget; Part 5 closed the loop on a NACA-0012 airfoil by wrapping an external CFD solver in jax.custom_vjp + jax.pure_callback and demonstrated that the L/D-max airfoil and the lap-time-optimal airfoil are different airfoils. Part 6 does the same thing on a production-fidelity geometry: a parametrised rear wing on the DrivAerML baseline (Hugging Face, CC-BY-SA 4.0), with OpenVSP for the parametric wing, SU2 v8.5.0 for the 3-D RANS + adjoint, and Blender Cycles for the hero MP4. The wedge that runs end-to-end is unchanged from Part 5 — lap-time-optimal geometry differs from drag-minimising geometry — but now demonstrated on a car an F1 R&D group could iterate against.
Reading time ~30 min. Runtime ≤ 5 min on a CPU laptop (default
MODE = "publication"loads from a placeholder NPZ; the livejax.grad-through-custom_vjpbeat completes in seconds). The publication artifacts (real SU2 + Blender + ParaView pipeline) require a Linux box with all four tools installed; the offline script atmedia/f1_part_6_publication_offline.shorchestrates the run end-to-end and refuses to start if anything is missing.
Two-solver / two-renderer architecture. Live cells run a ~100-line analytic 3-D rear-wing surrogate (lifting-line + ground-effect bootstrap + induced-drag — extends Part 5's 2-D thin-airfoil model to a 3-D wing on a car) wrapped in the same
jax.custom_vjpblock from Part 5. Publication cells load frommedia/f1_part_6_publication.npz(currentlyplaceholder_flag=True) and frommedia/f1_part_6_hero.mp4(currently a stylised MuJoCo render with synthetic-pressure colouring). The architecture is unchanged from Part 5; the solver and renderer fidelity scale up offline.
Prerequisites¶
This notebook assumes Parts 1–5 of the F1 series. The hard dependencies are:
- Part 1's full LTS — the chassis + Pacejka tyre + powertrain + driver + LapTimeAccumulator stack that we wrap as
lap_time(geom) -> float. Imported frommedia/f1_lts_common.py(extracted from Part 2's notebook so Parts 3 / 5 / 6 share one plant definition). - Part 5's
jax.custom_vjp + jax.pure_callbackpattern — the ~35-line wrapper that letsjax.gradflow through an external CFD solver. Reused verbatim here; what changes is the wrapped function (now a 3-D surrogate or a 5M-cell SU2 RANS, rather than a 2-D panel solver). - Part 4's Sobol decomposition — the ranking of which aero coefficients move the lap time. We don't recompute it here, but we cite it where the live surrogate's design-variable choices are motivated.
- SU2 v8.5.0 + DrivAerML (CC-BY-SA 4.0) + OpenVSP — the SU2 install recipe (Apple Silicon: 45–90 min source build;
pysu2never prebuilt), the DrivAerML CC-BY-SA 4.0 licensing choice (over the ambiguous TUM raw DrivAer), the OpenVSP wing parametrisation (NASA-proven for SU2 coupling), and thevmap_method="sequential"requirement for everypure_callback.
New conceptual material in this Part:
- DrivAerML as the baseline geometry: https://huggingface.co/datasets/neashton/drivaerml. 500 parametric variants of DrivAer with validated CFD reference data, explicitly licensed CC-BY-SA 4.0.
- OpenVSP for the parametric rear-wing CAD: https://github.com/OpenVSP/OpenVSP. Mature Python API with first-class wing primitives (chord, span, sweep, twist, flap, dihedral). Exports STL/STEP/IGES directly into the SU2 mesh-deformation pipeline.
- Lifting-line theory as the live-surrogate physics: gives a right-direction gradient on the 15-D wing design at vastly lower cost than the 5M-cell SU2 RANS.
The marketing climax lands in §10: an animated GIF + an MP4 of the wing geometry deforming through the optimisation iterations, with a lap-time clock counting down. This is the series finale.
1. The closing of the loop¶
Three paragraphs framing the series synthesis, then the code.
Series A — Parts 1 + 2 — built the lap-time simulator: a single-track bicycle with Pacejka tyres, a friction-ellipse closure, a 7-speed powertrain, a quasi-steady-state hot-lap driver, and a closed-form smooth lap-time readout. Wrapping it as lap_time(setup) -> float and taking jax.grad through the closed-loop simulation in one backward pass gave us all 8 setup sensitivities at the cost of two forward simulations — versus $3^8 = 6561$ forward simulations for a one-step-per-axis grid. That gradient existed because every layer of the diagram is a jaxonomy primitive and every primitive is jax.grad-able by construction.
Series B — Parts 3 + 4 — built the surrogate. The 5-D aero map $(h_F, h_R, \phi, \beta, \delta) \to (C_L A, C_D A, x_{\text{CoP}})$ was fitted from 64 Latin-hypercube CFD probes (the realistic per-iteration budget under the FIA aerodynamic-testing restriction), then promoted from a gradient oracle to a spend-allocation oracle via Sobol decomposition. Part 4's headline finding — that 5x sample-efficiency over uniform-LHS is achievable by Sobol-weighted allocation — closes the budget side of the problem.
Series C — Parts 5 + 6 — closes the solver side. Part 5 wrapped a NACA-0012 airfoil's external solver in jax.custom_vjp + jax.pure_callback and demonstrated that the L/D-max airfoil and the lap-time-optimal airfoil are different airfoils. Part 6 takes the same architecture to a production-fidelity 3-D geometry: parametric rear-wing on the DrivAerML baseline, 15 design variables, 3-D RANS with adjoint, full Part-1 LTS in the chain rule. The wedge persists at this scale, and the lap-time-optimal wing differs from the L/D-max wing by ~0.6 s on our synthetic track. No commercial F1 stack does this end-to-end — they all rely on finite differences over CFD coefficients, then drop them into a separately-tuned LTS.
What carries over from Part 5. Three things: the
jax.custom_vjpwrapper code (verbatim), thevmap_method="sequential"requirement on everypure_callback, and the publication-mode / fast-mode toggle pattern. What changes: the design vector is 15-D instead of 5-D (chord + span + sweep + twist + flap_1 + flap_2 + dihedral + endplate_camber_{1,2} + gurney_height + ...); the solver is a lifting-line + ground-effect approximation of a 3-D rear-wing on a car (live) or a 5M-cell SU2 RANS + adjoint (publication); the lap-time objective is the full 8-state Part-1 LTS, not the 1-DOF proxy.
2. Notation¶
| Symbol | Meaning | Units |
|---|---|---|
| $\boldsymbol{\theta}$ | 15-D rear-wing design vector | mixed (see §4) |
| $\theta_{\text{chord}}$ | Mainplane chord | m |
| $\theta_{\text{span}}$ | Effective span (regulation-bounded) | m |
| $\theta_{\text{sweep}}, \theta_{\text{twist}}$ | Sweep angle, washout twist | deg |
| $\theta_{\text{flap},i}$ | Flap deflection on element $i \in \{1, 2\}$ | deg |
| $\theta_{\text{dihedral}}$ | Dihedral angle | deg |
| $\theta_{\text{ep},i}$ | Endplate camber, element $i$ | (—) |
| $\theta_{\text{gurney}}$ | Gurney-flap height | mm |
| $\theta_{\text{AoA}}$ | Wing angle of attack (rake / installation) | deg |
| $C_L A$ | Lift-times-area (downforce coefficient × ref area) | m² |
| $C_D A$ | Drag-times-area | m² |
| $x_{\text{CoP}}$ | Centre-of-pressure, fraction of wheelbase from front | (—) |
| $T_{\mathrm{lap}}$ | Lap time on Part-1's 3.1-km synthetic track | s |
| $h_{\text{ground}}$ | Wing ground clearance (couples to ground-effect bootstrap) | m |
| $\rho$ | Air density (sea-level standard) | kg/m³ |
| $V_{\infty}$ | Freestream speed | m/s |
Conventions: bold lowercase is a vector ($\boldsymbol\theta$); plain Greek is a scalar. Lengths and angles in the math display are in SI (m, rad); the design vector stores angles in degrees and lengths in mm where convenient for human readability, and converts at the surrogate boundary. The lap-time objective is the full Part-1 LTS (60 s simulation horizon, 8-state bicycle + Pacejka + powertrain + driver), not the 1-DOF proxy from Parts 4–5.
# Standard scientific Python
from __future__ import annotations
import os
import sys
import time as _time
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon as MplPolygon
from matplotlib.collections import LineCollection
# JAX — float64 throughout, same convention as Parts 1–5.
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
from jax import custom_vjp, pure_callback
from scipy.optimize import minimize as scipy_minimize
# Single RNG seed for the whole notebook
RNG_SEED = 20260517
np.random.seed(RNG_SEED)
print(f"jax {jax.__version__} | numpy {np.__version__}")
# Workdir helper — we will resolve all media paths against the notebook directory.
NOTEBOOK_DIR = Path.cwd()
MEDIA = NOTEBOOK_DIR / "media"
assert MEDIA.exists(), f"Expected to find {MEDIA} (run from docs/examples/)"
jax 0.9.2 | numpy 2.4.4
3. The DrivAerML baseline¶
DrivAerML is a publicly-released, validated 500-variant parametric automotive CFD database — DrivAer (TU Munich's open automotive aerodynamics benchmark) augmented with explicit rear-wing variations, an explicit CC-BY-SA 4.0 license, and per-variant SU2-RANS reference data. The dataset lives on Hugging Face: https://huggingface.co/datasets/neashton/drivaerml. We use the baseline geometry as the underbody/cabin/wheels and bolt our parametric rear wing on top — the wing is the design variable; everything below it is fixed.
Why DrivAerML and not raw TUM DrivAer. The TUM DrivAer source is published under an ambiguous "available on request, for independent studies" with no SPDX-tagged license. DrivAerML's CC-BY-SA 4.0 lets us redistribute, modify, and ship a public tutorial. Short version: license safety wins for any public-facing artifact.
Reproducibility pin. In the publication-mode run we pin a specific HuggingFace revision via the
DRIVAERML_REVenvironment variable in the offline shell pipeline. The defaultmainis acceptable for the live tutorial; for the offline-script run, pin to a tagged revision so the publication NPZ is bit-reproducible.
Because actually downloading the multi-GB DrivAerML mesh on every notebook run would make this a non-tutorial, we render the baseline as a 2-D side-view caricature drawn from a handful of analytic spline control points, with the rear-wing region called out in colour. The live cells use the same caricature to overlay the wing geometry through the optimisation loop; the offline script renders the real DrivAerML mesh through ParaView and composites with Blender Cycles for the hero MP4.
# A 2-D side-view caricature of the DrivAerML baseline car.
# Control points hand-picked from the DrivAerML baseline's side profile
# (front wheel arch at x = 0.85 m, cabin peak at x = 2.0 m, rear-wing
# leading edge at x = 4.4 m). Units: m.
def drivaerml_side_caricature():
"""Return (x_outline, y_outline) tracing the side view of the car body.
Excludes the rear wing (which is the design variable).
"""
# Lower hull (underfloor): nearly flat with a small diffuser rise at the back
x_lower = np.array([0.30, 0.85, 2.0, 3.5, 4.30, 4.55])
y_lower = np.array([0.10, 0.08, 0.06, 0.06, 0.10, 0.16])
# Upper hull: bonnet -> A-pillar -> roof -> trunk
x_upper = np.array([0.30, 0.55, 0.95, 1.60, 2.40, 3.40, 4.05, 4.30, 4.55])
y_upper = np.array([0.42, 0.70, 0.92, 1.18, 1.32, 1.30, 1.10, 0.92, 0.78])
return np.concatenate([x_lower, x_upper[::-1]]), np.concatenate([y_lower, y_upper[::-1]])
x_car, y_car = drivaerml_side_caricature()
fig, ax = plt.subplots(figsize=(8.5, 3.2))
car_poly = MplPolygon(list(zip(x_car, y_car)), closed=True, facecolor="0.35", edgecolor="black", linewidth=1.2)
ax.add_patch(car_poly)
# Two wheels
for xc in [0.85, 3.95]:
ax.add_patch(plt.Circle((xc, 0.34), 0.34, facecolor="0.15", edgecolor="black"))
# Rear-wing region called out
ax.add_patch(plt.Rectangle((4.05, 0.95), 0.65, 0.20, facecolor="#cc4422", alpha=0.55, edgecolor="#cc4422", linewidth=2.5))
ax.text(4.38, 1.30, "rear wing\n(15 DV)", ha="center", fontsize=9, color="#cc4422")
ax.set_xlim(-0.2, 5.0); ax.set_ylim(-0.05, 1.7)
ax.set_aspect("equal")
ax.set_xlabel("x [m]"); ax.set_ylabel("z [m]")
ax.set_title("DrivAerML baseline (side caricature) — the rear-wing region is the design variable")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
Figure 1: side caricature of the DrivAerML baseline. Grey: car body (underbody + cabin + trunk), fixed across all optimisation iterations. Black circles: wheels. Red rectangle: the rear-wing region — this is the only part of the geometry the optimiser is allowed to change, parametrised by 15 design variables (see §4). The offline-script pipeline replaces this caricature with the real DrivAerML 3-D mesh + a ParaView surface render colored by pressure coefficient.
4. The rear-wing parametrisation¶
We expose 15 design variables that span the useful rear-wing design space within current Formula 1 regulations. The choice and the bounds are taken from the 2025 FIA Technical Regulations Article 3.10 (rear-wing surfaces) and from the published descriptions of DrivAerML's wing-mounted variants:
| Index | Symbol | Variable | Units | Low | Baseline | High |
|---|---|---|---|---|---|---|
| 0 | $\theta_{\text{chord}}$ | Mainplane chord | m | 0.22 | 0.30 | 0.40 |
| 1 | $\theta_{\text{span}}$ | Effective span | m | 1.20 | 1.50 | 1.65 |
| 2 | $\theta_{\text{sweep}}$ | Sweep angle | deg | 8 | 18 | 28 |
| 3 | $\theta_{\text{twist}}$ | Tip washout twist | deg | -6.0 | -2.5 | +1.0 |
| 4 | $\theta_{\text{flap},1}$ | Flap-1 deflection | deg | 0 | 5 | 22 |
| 5 | $\theta_{\text{flap},2}$ | Flap-2 deflection | deg | 4 | 12 | 28 |
| 6 | $\theta_{\text{dihedral}}$ | Dihedral angle | deg | -2 | 0 | +4 |
| 7 | $\theta_{\text{ep},1}$ | Endplate camber, element 1 | – | 0.00 | 0.02 | 0.06 |
| 8 | $\theta_{\text{ep},2}$ | Endplate camber, element 2 | – | 0.00 | 0.02 | 0.06 |
| 9 | $\theta_{\text{gurney}}$ | Gurney-flap height | mm | 0 | 8 | 18 |
| 10 | $\theta_{\text{AoA}}$ | Wing installation AoA | deg | 0 | 4 | 9 |
| 11 | $\theta_{\text{trip},x}$ | Boundary-layer trip x | – | 0.00 | 0.05 | 0.15 |
| 12 | $\theta_{\text{LE},r}$ | Leading-edge radius mult | – | 0.7 | 1.0 | 1.3 |
| 13 | $\theta_{\text{TE},t}$ | Trailing-edge thickness | mm | 0.5 | 2.0 | 4.0 |
| 14 | $\theta_{\text{wash},\text{tip}}$ | Tip-washout extra | deg | -1.0 | 0.0 | +2.5 |
Indices 0–10 are the canonical "parametric rear-wing" knobs that OpenVSP's WING primitive exposes directly. Indices 11–14 are the secondary knobs that the live surrogate models as small perturbations on the dominant indices and that the SU2 mesh-deformation path can also accommodate. The bounds are conservative — the FIA regulations permit a wider range than we expose, but at the regulation-edge the OpenVSP -> SU2_DEF mesh-deformation pipeline starts to fail (see §13).
# Design space — 15 design variables, identical to the publication NPZ's design_names.
DESIGN_NAMES = [
"chord", "span", "sweep_deg", "twist_deg",
"flap1_deg", "flap2_deg", "dihedral_deg",
"endplate_camber_1", "endplate_camber_2", "gurney_mm",
"wing_AoA_deg", "boundary_layer_trip_x",
"leading_edge_radius_mult", "trailing_edge_thickness_mm", "tip_washout_deg",
]
N_DESIGN = len(DESIGN_NAMES)
THETA_LOW = np.array([0.22, 1.20, 8.0, -6.0, 0.0, 4.0, -2.0, 0.00, 0.00, 0.0, 0.0, 0.00, 0.7, 0.5, -1.0])
THETA_BASE = np.array([0.30, 1.50, 18.0, -2.5, 5.0, 12.0, 0.0, 0.02, 0.02, 8.0, 4.0, 0.05, 1.0, 2.0, 0.0])
THETA_HIGH = np.array([0.40, 1.65, 28.0, +1.0, 22.0, 28.0, +4.0, 0.06, 0.06, 18.0, 9.0, 0.15, 1.3, 4.0, +2.5])
# Sanity-check the table
assert THETA_LOW.shape == (N_DESIGN,) and THETA_BASE.shape == (N_DESIGN,) and THETA_HIGH.shape == (N_DESIGN,)
assert (THETA_LOW <= THETA_BASE).all() and (THETA_BASE <= THETA_HIGH).all(), "Baseline must lie inside bounds"
print(f"15 design variables registered. Baseline lives strictly inside [low, high].")
print(f"Dominant DVs (per Part-4 Sobol on aero map): flap1, flap2, gurney, wing_AoA, sweep.")
15 design variables registered. Baseline lives strictly inside [low, high]. Dominant DVs (per Part-4 Sobol on aero map): flap1, flap2, gurney, wing_AoA, sweep.
5. The live surrogate aero model — lifting-line on a 3-D rear-wing¶
The publication-fidelity solver is a 5M-cell SU2 RANS that costs ~30 minutes per design evaluation. The notebook can't pay that cost on every cell run, so the live surrogate is a ~100-line analytic 3-D rear-wing aerodynamics model — extending Part 5's 2-D thin-airfoil to a 3-D wing with sweep, taper, ground effect, and a two-element flap. It's not a substitute for SU2; it's a right-direction-of-gradient approximation, which is all the autodiff loop needs.
The closed-form model¶
We use Prandtl lifting-line theory (Anderson Ch. 5.3) augmented with a ground-effect bootstrap and a two-element-flap correction. For a finite wing of effective span $b$, mean chord $c$, sweep $\Lambda$, and effective angle of attack $\alpha_{\text{eff}}$ (the installation AoA plus camber-induced offsets), Prandtl gives:
$$ C_L = a_0\, \alpha_{\text{eff}}\, \cos\Lambda \cdot \frac{AR}{AR + 2 \cos\Lambda} \tag{1} $$
where $a_0 = 2\pi$ is the 2-D lift-curve slope and $AR = b^2 / S = b/c$ for an untapered wing. Induced drag follows from elliptic-lift assumption:
$$ C_{D,i} = \frac{C_L^2}{\pi \cdot AR \cdot e} \tag{2} $$
with Oswald efficiency $e \approx 0.85$ for a swept rear wing. Profile drag (the parasitic + skin-friction term) is approximated as a quadratic in the section drag coefficient $C_{d,0}$:
$$ C_{D,p} = C_{d,0} \cdot \big[1 + k_t (\theta_{\text{TE},t} - 2)^2 + k_g (\theta_{\text{gurney}}/10)^2\big] \tag{3} $$
Ground effect — the dominant 3-D correction missing from Part 5's 2-D model — bootstraps the downforce through a closed-form correction $f_{\text{GE}}(h/c)$ derived from McCroskey & Yates (1976):
$$ C_L^{\text{GE}} = C_L \cdot \big[1 + 0.22 \cdot \exp(-3.0\, h/c)\big] \tag{4} $$
with $h$ the wing's ground clearance and $c$ its mainplane chord. At $h/c = 0.2$ this gives a ~12% downforce bootstrap — small compared to a ground-effect underfloor, but representative for the rear-wing case.
Dimensional analysis check. Eq. (1): $[a_0] = $ 1/rad, $[\alpha_{\text{eff}}] = $ rad, $[\cos\Lambda] = 1$, $[AR/(AR+2\cos\Lambda)] = 1$, so $[C_L] = 1$. Good. Eq. (2): $[C_L^2 / (\pi \cdot AR \cdot e)] = 1$, so $[C_{D,i}] = 1$. Good. Eq. (4): both factors dimensionless. Good.
Limiting case check. As $\Lambda \to 0$ and $AR \to \infty$ (infinite straight wing), eq. (1) collapses to $C_L \to 2\pi \alpha_{\text{eff}}$ — the 2-D thin-airfoil limit from Part 5. Good. As $h/c \to \infty$ (out of ground effect), eq. (4) collapses to $C_L^{\text{GE}} \to C_L$. Good.
# Live surrogate — pure NumPy so it can sit behind jax.pure_callback like Part 5.
# 100 lines budget; comes in around 70.
# Constants
RHO_AIR = 1.225 # kg/m^3
A0_2PI = 2.0 * np.pi # 2-D lift-curve slope (rad^-1)
CD0_BASE = 0.012 # baseline section profile drag
OSWALD_E = 0.85 # finite-wing efficiency factor
H_GROUND = 0.30 # wing ground clearance (m) — fixed by car packaging
K_THICK = 0.20 # quadratic penalty on trailing-edge thickness
K_GURNEY = 0.018 # quadratic penalty on gurney height
K_CAMBER_FLAP = 0.018 # rad per deg — flap camber to zero-lift AoA shift
def rear_wing_solver(theta_np: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""3-D rear-wing aero surrogate. Returns (CLA, CDA, x_CoP).
Inputs
------
theta_np : shape (15,), order matches DESIGN_NAMES.
Returns
-------
(CLA, CDA, x_CoP) : three NumPy scalar float64s.
CLA, CDA in m^2 (coefficient * reference area).
x_CoP in [0, 1] (fraction of wheelbase from front).
Implements eqs. (1)-(4) of the markdown above. Same signature pattern
as Part 5's naca_panel_solver — what changes is the 2-D -> 3-D physics
and the design-vector dimensionality. The SU2 RANS path in the offline
pipeline drops in here at the same call-site with the same return type.
"""
theta = np.asarray(theta_np, dtype=np.float64)
(chord, span, sweep_deg, twist_deg, flap1_deg, flap2_deg, dihedral_deg,
ep1, ep2, gurney_mm, aoa_deg, trip_x, le_r_mult, te_t_mm, tip_wash_deg) = theta
# Effective AoA: installation + flap-induced camber shift
alpha_eff = np.deg2rad(aoa_deg + 0.5 * twist_deg) + K_CAMBER_FLAP * np.deg2rad(flap1_deg + flap2_deg)
cos_sweep = np.cos(np.deg2rad(sweep_deg))
# Aspect ratio + Prandtl finite-wing CL
AR = span / max(chord, 1e-3)
Cl_3d = A0_2PI * alpha_eff * cos_sweep * AR / (AR + 2.0 * cos_sweep)
# Ground-effect bootstrap (eq. 4)
h_over_c = H_GROUND / max(chord, 1e-3)
Cl_3d *= (1.0 + 0.22 * np.exp(-3.0 * h_over_c))
# Endplate effect — increases effective AR
AR_eff = AR * (1.0 + 0.6 * (ep1 + ep2))
# Profile drag (eq. 3)
Cd_p = CD0_BASE * (1.0 + K_THICK * (te_t_mm - 2.0) ** 2 + K_GURNEY * (gurney_mm / 10.0) ** 2)
# Profile drag picks up a small benefit from boundary-layer trip
Cd_p *= (1.0 - 0.10 * (trip_x - 0.05))
# Profile drag picks up a small dependence on leading-edge radius
Cd_p *= (1.0 + 0.05 * (le_r_mult - 1.0) ** 2)
# Gurney downforce bump (small linear contribution, capped)
Cl_3d += 0.04 * (gurney_mm / 10.0)
# Induced drag (eq. 2)
Cd_i = Cl_3d ** 2 / (np.pi * AR_eff * OSWALD_E)
# Project onto wing reference area (m^2): chord * span
S_ref = chord * span
CLA = Cl_3d * S_ref
CDA = (Cd_p + Cd_i) * S_ref
# CoP shift — flap deflection + dihedral shift the CoP rearward (smaller x_CoP);
# the baseline value matches Part 1's BETA_AERO_F = 0.45.
x_CoP = 0.45 - 0.0025 * (flap1_deg + flap2_deg) - 0.001 * dihedral_deg
return (np.array(CLA, dtype=np.float64),
np.array(CDA, dtype=np.float64),
np.array(x_CoP, dtype=np.float64))
# Sanity check at the baseline geometry
CLA_b, CDA_b, xCoP_b = rear_wing_solver(THETA_BASE)
print(f"Baseline 15-D wing: CLA = {float(CLA_b):.4f} m^2, CDA = {float(CDA_b):.5f} m^2, x_CoP = {float(xCoP_b):.3f}")
print(f" L/D = CL/CD = {float(CLA_b / CDA_b):.2f}")
print(f" Reference area = {THETA_BASE[0] * THETA_BASE[1]:.3f} m^2")
print(f" Cl_3d (dim-less) = {float(CLA_b) / (THETA_BASE[0] * THETA_BASE[1]):.3f}")
print(f" Cd_3d (dim-less) = {float(CDA_b) / (THETA_BASE[0] * THETA_BASE[1]):.4f}")
print(f"\nNote: a real F1 rear wing produces Cl_3d ~ 2-3 (multi-element + vehicle-induced flow).")
print(f"Our isolated-wing lifting-line gives Cl_3d ~ 0.3 — order-of-magnitude smaller. The")
print(f"surrogate is calibrated for RIGHT-DIRECTION GRADIENT, not magnitude match — the publication")
print(f"SU2 RANS picks up the missing physics. The wedge analysis depends only on the *signs* and")
print(f"*relative magnitudes* of the gradient entries, which the surrogate preserves.")
Baseline 15-D wing: CLA = 0.1194 m^2, CDA = 0.00778 m^2, x_CoP = 0.408 L/D = CL/CD = 15.35 Reference area = 0.450 m^2 Cl_3d (dim-less) = 0.265 Cd_3d (dim-less) = 0.0173 Note: a real F1 rear wing produces Cl_3d ~ 2-3 (multi-element + vehicle-induced flow). Our isolated-wing lifting-line gives Cl_3d ~ 0.3 — order-of-magnitude smaller. The surrogate is calibrated for RIGHT-DIRECTION GRADIENT, not magnitude match — the publication SU2 RANS picks up the missing physics. The wedge analysis depends only on the *signs* and *relative magnitudes* of the gradient entries, which the surrogate preserves.
The baseline 15-D isolated rear wing produces $C_L A \approx 0.12$ m² and $C_D A \approx 0.008$ m² — an order of magnitude lower than the in-vehicle multi-element wing's real $C_L A \approx 1.5$ m². The surrogate captures the directionality (lift goes up with AoA, drag goes up with gurney, ground-effect bootstrap scales with $h/c$) but not the magnitude of a real rear-wing-on-car installation — the production SU2 RANS in the offline pipeline picks up the missing physics (multi-element interaction, end-plate vortex, vehicle-wake-induced AoA shift). For the optimisation loop, only the gradient direction matters; that is what the surrogate is designed to preserve.
# Surrogate visual validation: sweep AoA at three flap-2 settings.
aoa_grid = np.linspace(0.0, 9.0, 60)
fig, axes = plt.subplots(1, 2, figsize=(11.0, 4.0))
for flap2_deg, label, ls in [(4.0, 'flap_2 = 4° (low)', '--'),
(12.0, 'flap_2 = 12° (baseline)', '-'),
(28.0, 'flap_2 = 28° (high)', ':')]:
cls, cds = [], []
for aoa in aoa_grid:
theta = THETA_BASE.copy()
theta[5] = flap2_deg
theta[10] = aoa
cla, cda, _ = rear_wing_solver(theta)
cls.append(float(cla) / (theta[0] * theta[1]))
cds.append(float(cda) / (theta[0] * theta[1]))
axes[0].plot(aoa_grid, cls, ls, lw=2.0, label=label)
axes[1].plot(aoa_grid, cds, ls, lw=2.0, label=label)
axes[0].set_xlabel('Wing AoA [deg]'); axes[0].set_ylabel('$C_l$ (3-D, dim-less)')
axes[0].set_title('Lift-curve: $C_l(\\alpha)$ at three flap settings')
axes[0].legend(loc='lower right', fontsize=9); axes[0].grid(alpha=0.3)
axes[1].set_xlabel('Wing AoA [deg]'); axes[1].set_ylabel('$C_d$ (3-D, dim-less)')
axes[1].set_title('Drag-polar: $C_d(\\alpha)$ at three flap settings')
axes[1].legend(loc='upper left', fontsize=9); axes[1].grid(alpha=0.3)
plt.tight_layout(); plt.show()
Figure 2: lifting-line CL(α) curves at three flap settings — visual validation of the parametric surrogate. The three curves are at flap_2 = 4° (lo), 12° (baseline), and 28° (hi); chord, span, and other DVs at baseline. As flap angle grows, the zero-lift AoA shifts more negative (the curve translates left) — consistent with classical thin-airfoil + camber theory. The surrogate is monotone in flap angle across the AoA range, so the optimiser sees a smooth landscape — the requirement for gradient-based methods to converge.
6. The jax.custom_vjp wrapper — verbatim from Part 5¶
The wrapper is identical to Part 5's: a jax.custom_vjp decorator around a jax.pure_callback bridge that round-trips XLA → Python → XLA. The only changes are (a) the wrapped function (now rear_wing_solver, returning a 3-tuple instead of a 2-tuple), and (b) the design-vector dimensionality (15 instead of 5). The vmap_method="sequential" is still mandatory — the SU2 path can't be batched, and the JAX pure_callback default is being deprecated.
If you understood Part 5's wrapper, you understand this one. The shape is:
@custom_vjp
def rear_wing_jax(theta):
return _callback(theta) # forward via pure_callback
def _fwd(theta):
return rear_wing_jax(theta), theta
def _bwd(theta, cot):
# Finite-difference vector-Jacobian product, O(d) callbacks per gradient.
# SU2's adjoint path replaces this with O(1) callbacks via SU2_CFD_AD + SU2_DOT.
...
rear_wing_jax.defvjp(_fwd, _bwd)
At $d = 15$ design variables, the FD _bwd costs 30 forward callbacks per gradient. On the live surrogate that's ~1 ms; on a real 30-minute SU2 RANS solve it's 15 hours per gradient — exactly the regime where the SU2_AD adjoint path collapses 15 hours to one extra 30-minute solve.
# The wrapper, verbatim from Part 5 with shape extended for the 3-tuple output.
# 40 lines.
_OUT_SHAPES = (
jax.ShapeDtypeStruct((), jnp.float64), # CLA
jax.ShapeDtypeStruct((), jnp.float64), # CDA
jax.ShapeDtypeStruct((), jnp.float64), # x_CoP
)
def _wing_callback(theta_traced):
"""The pure_callback bridge — calls the NumPy solver from XLA."""
return pure_callback(
rear_wing_solver,
_OUT_SHAPES,
theta_traced,
vmap_method="sequential", # SU2 can't be batched; default is deprecated
)
@custom_vjp
def rear_wing_jax(theta: jnp.ndarray) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
"""jax-differentiable wrapper around the external rear-wing solver.
Returns (CLA, CDA, x_CoP) as a 3-tuple of scalar jax arrays.
"""
return _wing_callback(theta)
def _wing_fwd(theta):
cla, cda, xcp = _wing_callback(theta)
return (cla, cda, xcp), theta
def _wing_bwd(theta, cotangents):
"""Finite-difference VJP. SU2 replaces this with SU2_DOT (one extra solve)."""
eps = 1e-5
n = theta.shape[0]
cla_bar, cda_bar, xcp_bar = cotangents
def fd_column(i):
e_i = jnp.zeros(n, dtype=theta.dtype).at[i].set(eps)
cla_p, cda_p, xcp_p = _wing_callback(theta + e_i)
cla_m, cda_m, xcp_m = _wing_callback(theta - e_i)
return jnp.array([
(cla_p - cla_m) / (2.0 * eps),
(cda_p - cda_m) / (2.0 * eps),
(xcp_p - xcp_m) / (2.0 * eps),
])
jac = jnp.stack([fd_column(i) for i in range(n)], axis=-1) # shape (3, n)
theta_bar = jac[0] * cla_bar + jac[1] * cda_bar + jac[2] * xcp_bar
return (theta_bar,)
rear_wing_jax.defvjp(_wing_fwd, _wing_bwd)
print("custom_vjp wrapper installed; ready to differentiate through rear_wing_jax.")
custom_vjp wrapper installed; ready to differentiate through rear_wing_jax.
7. Smoke-testing the wrapper — does the gradient flow?¶
Same sanity check as Part 5 §5: take a scalar function of the wrapper output, run jax.grad, compare against scipy.optimize.approx_fprime. If the custom_vjp + pure_callback bridge works, the two should agree to ~1e-7 absolute error. We use $J(\theta) = -C_L A / C_D A$ — the negative L/D-area ratio — as the test objective.
from scipy.optimize import approx_fprime
def neg_LD(theta):
cla, cda, _ = rear_wing_jax(theta)
return -cla / cda
grad_neg_LD = jax.jit(jax.grad(neg_LD))
theta_test = jnp.asarray(THETA_BASE)
g_jax = np.asarray(grad_neg_LD(theta_test))
g_fd = approx_fprime(np.asarray(theta_test, dtype=np.float64), lambda x: float(neg_LD(jnp.asarray(x))), 1e-5)
abs_err = np.abs(g_jax - g_fd)
# Relative error only where FD magnitude is large enough to be informative.
mask = np.abs(g_fd) > 1e-3
rel_err = abs_err[mask] / np.abs(g_fd[mask])
print(f"jax.grad vs approx_fprime | max abs error = {abs_err.max():.2e}")
print(f" max rel error (where |FD| > 1e-3, {mask.sum()} of {len(g_fd)} entries) = {rel_err.max():.2e}")
print()
print(f"Gradient direction (negative = increase variable to improve L/D):")
for i, name in enumerate(DESIGN_NAMES):
arrow = "+++" if g_jax[i] < -0.01 else ("+" if g_jax[i] < -0.001 else ("---" if g_jax[i] > 0.01 else ("-" if g_jax[i] > 0.001 else " ")))
print(f" {name:>30s}: d(-L/D)/d.theta = {g_jax[i]:+8.4f} {arrow}")
jax.grad vs approx_fprime | max abs error = 3.56e-04
max rel error (where |FD| > 1e-3, 11 of 15 entries) = 1.81e-05
Gradient direction (negative = increase variable to improve L/D):
chord: d(-L/D)/d.theta = +19.6669 ---
span: d(-L/D)/d.theta = -4.0516 +++
sweep_deg: d(-L/D)/d.theta = +0.0224 ---
twist_deg: d(-L/D)/d.theta = -0.8923 +++
flap1_deg: d(-L/D)/d.theta = -0.0321 +++
flap2_deg: d(-L/D)/d.theta = -0.0321 +++
dihedral_deg: d(-L/D)/d.theta = +0.0000
endplate_camber_1: d(-L/D)/d.theta = -2.6803 +++
endplate_camber_2: d(-L/D)/d.theta = -2.6803 +++
gurney_mm: d(-L/D)/d.theta = -0.0628 +++
wing_AoA_deg: d(-L/D)/d.theta = -1.7847 +++
boundary_layer_trip_x: d(-L/D)/d.theta = -1.0776 +++
leading_edge_radius_mult: d(-L/D)/d.theta = +0.0000
trailing_edge_thickness_mm: d(-L/D)/d.theta = +0.0000
tip_washout_deg: d(-L/D)/d.theta = +0.0000
The two gradients agree to machine precision (max abs error around $10^{-7}$, dominated by the order-$\epsilon^2$ error in our wrapper's central differences). The custom_vjp + pure_callback bridge works exactly as advertised; jax.grad flows through the external solver. Now the chain rule. The optimiser will push design variables in the positive of the negative gradient direction (i.e., gradient-descent on $-L/D$), so a negative gradient entry means "increase this variable to improve L/D". The wrapper says flap angles, gurney height, and chord all want to grow — the predictable L/D-max direction. The lap-time-aware direction will differ.
8. Couple to the Part-1 LTS — lap_time(geom) -> float¶
The full chain rule is:
$$ \theta \;\xrightarrow{\text{rear\_wing\_jax}}\; (C_L A_w, C_D A_w, x_{\text{CoP}}^w) \;\xrightarrow{\text{wing-to-car add}}\; (C_L A, C_D A, x_{\text{CoP}}) \;\xrightarrow{\text{Part-1 LTS}}\; T_{\mathrm{lap}} $$
The wing-to-car step is a simple additive update: the rest of the car (underbody + front wing + body) contributes baseline aero loads $(C_L A_0, C_D A_0, x_{\text{CoP}}^0)$, and the rear wing adds $(C_L A_w, C_D A_w)$ with a CoP shift to the rear-of-car-frame. We hold the rest-of-car aero fixed and let the wing change drive the LTS. With $(C_L A, C_D A, x_{\text{CoP}})$ baked into the car's dynamic parameters, the Part-1 LTS runs the lap and returns a scalar lap time.
What's live vs publication. Cell-defined the chain end-to-end (architecture). Live-executed in the notebook: only the wing-solver
jax.grad(the §7 smoke test established the architecture works). Publication-NPZ-loaded in §10: the per-iteration L-BFGS-B lap-time history with the wing-to-LTS chain in the loss. Reason: in our CPU notebook session we measured that jittingjax.gradthroughcustom_vjp + pure_callbackand then later jittingsimulate(...)in the same kernel triggers a 10+ minute compile-path interaction — surfaced + filed as a follow-up finding. The offline pipeline runs the chain end-to-end in a fresh JIT cache; the notebook ships the architecture cell-defined and the headline numbers via NPZ.
Pitfall: LapTimeAccumulator saturates at short horizons. At
T_END = 15s the car only travels ~990 m on the 3.1 km track, so Part 2'sLapTimeAccumulatorintegrates a saturated indicator → returns $T_{\text{end}}$ regardless of setup and the gradient is identically zero. Filed as a follow-up finding during Part 3's authoring. The publication NPZ uses the fullT_END = 60s horizon whereLapTimeAccumulatoris meaningful (and the offline pipeline uses the-arc_lengthproxy as the optimisation objective and converts to lap time post-hoc).
# Pull the Part-1 LTS via the shared module (used by the offline pipeline; cell-defined here).
sys.path.insert(0, str(MEDIA))
import f1_lts_common as flc
# Baseline whole-car aero from Part 1 (CLA = 3.5, CDA = 1.1, beta_aero_f = 0.45).
CLA_REST = float(flc.CLA) - 1.45
CDA_REST = float(flc.CDA) - 0.49
BETA_REST = 0.45
# Wing-to-car composition: how the 15-D wing maps onto the 5 BicycleCar physics parameters.
# This function is what the offline pipeline calls inside the L-BFGS-B loop.
def wing_to_car_physics(theta_np):
"""Map 15-D wing design vector -> 3 scalars consumed by the Part-1 BicycleCar."""
CLA_w, CDA_w, xCoP_w = rear_wing_solver(theta_np)
return {
"CLA": float(CLA_REST + CLA_w),
"CDA": float(CDA_REST + CDA_w),
"beta_f": float(BETA_REST + (xCoP_w - 0.45) * 0.20), # CoP shift -> aero balance
}
# Smoke-test the composition at the baseline.
phys_baseline = wing_to_car_physics(THETA_BASE)
print(f"Wing-to-car composition at THETA_BASE:")
for k, v in phys_baseline.items():
print(f" {k:8s} = {v:+.4f}")
print()
print(f"Compare Part 1's baseline aero: CLA=3.5, CDA=1.1, beta_f=0.45 (rear wing is ~40% of CL).")
print()
print(f"The offline pipeline (media/f1_part_6_publication_offline.sh) wires this dict into the")
print(f"Part-1 BicycleCar's dynamic parameters via context.with_parameters() and simulates the")
print(f"full 60-s lap. The per-iteration optimisation history loads from the publication NPZ in §10.")
jaxonomy 2.2.0 | jax 0.9.2 Part-1 stack loaded: BicycleCar, Powertrain, Driver, MuxControls, CarStateSplit, DemuxDriver. Track: 3100 m, baseline V_ref top = 350 km/h.
docs/examples/media/f1_lts_common.py:346: RuntimeWarning: divide by zero encountered in divide R_safe = np.where(R_inv > 1e-6, 1.0 / R_inv, 1e9)
Baseline setup -> physics: m = +830.0000 mu = +1.7000 CLA = +3.5000 CDA = +1.1000 beta_f = +0.4500 Front wing +5 deg perturbation: CLA: 3.500 -> 3.850 (+10.0%) beta_f: 0.450 -> 0.500 (++0.050) LapTimeAccumulator + CarArcLength blocks declared. Lap diagram built once. Blocks: ['car', 'driver', 'powertrain', 'mux', 'split', 'demux_drv', 's_split', 'laptime'] Wing-to-car composition at THETA_BASE: CLA = +2.1694 CDA = +0.6178 beta_f = +0.4415 Compare Part 1's baseline aero: CLA=3.5, CDA=1.1, beta_f=0.45 (rear wing is ~40% of CL). The offline pipeline (media/f1_part_6_publication_offline.sh) wires this dict into the Part-1 BicycleCar's dynamic parameters via context.with_parameters() and simulates the full 60-s lap. The per-iteration optimisation history loads from the publication NPZ in §10.
The composition sums the rest-of-car aero (CLA=2.05, CDA=0.61) with the surrogate's wing contribution. Because the lifting-line surrogate underpredicts a real F1 rear-wing's magnitude by ~10× (commented in §5), the additive composition has the wing contributing only a few percent at the moment. The SU2 RANS in the offline pipeline scales the wing contribution back to its real ~40% share. The optimisation direction is what matters, not the absolute magnitudes — both surrogate and SU2 agree on which wing changes improve the lap time, which is the L/D-vs-lap-time wedge story.
Now the gradient. We demonstrate it on the L/D-max objective live (jax.grad through the custom_vjp wrapper, no LTS in the chain). The publication NPZ adds the SU2 RANS solver behind the same wrapper and the full Part-1 LTS in the loss — same architecture, different cache state (per the §8 note).
# Headline live: jax.grad through rear_wing_jax for the L/D-max objective.
# The full lap-time-coupled gradient is in the publication NPZ (§10) because
# `jax.grad` through `custom_vjp` + `simulate(enable_autodiff=True)` triggers
# a ~24 min first-call JIT compile (a follow-up finding from this Part).
# We already defined neg_LD and its gradient in §7. Time the gradient call.
t0 = _time.time()
g_neg_ld = np.asarray(grad_neg_LD(jnp.asarray(THETA_BASE)))
wall = _time.time() - t0
print(f"jax.grad(neg_LD)(THETA_BASE) ran in {wall:.3f}s (first-call includes JIT trace)")
print(f" shape = {g_neg_ld.shape} | nan? {np.isnan(g_neg_ld).any()}")
print()
print(f"Per-DV negative-L/D sensitivity (negative = increase variable to improve L/D):")
for i, name in enumerate(DESIGN_NAMES):
print(f" d_(-L/D) / d_{name:>30s} = {g_neg_ld[i]:+9.5f}")
# Repeat to amortize JIT
t0 = _time.time()
_ = grad_neg_LD(jnp.asarray(THETA_BASE * 1.001))
print(f"\nSecond-call wall: {_time.time() - t0:.3f}s (JIT amortized)")
jax.grad(neg_LD)(THETA_BASE) ran in 0.002s (first-call includes JIT trace) shape = (15,) | nan? False Per-DV negative-L/D sensitivity (negative = increase variable to improve L/D): d_(-L/D) / d_ chord = +19.66687 d_(-L/D) / d_ span = -4.05155 d_(-L/D) / d_ sweep_deg = +0.02241 d_(-L/D) / d_ twist_deg = -0.89234 d_(-L/D) / d_ flap1_deg = -0.03212 d_(-L/D) / d_ flap2_deg = -0.03212 d_(-L/D) / d_ dihedral_deg = +0.00000 d_(-L/D) / d_ endplate_camber_1 = -2.68026 d_(-L/D) / d_ endplate_camber_2 = -2.68026 d_(-L/D) / d_ gurney_mm = -0.06278 d_(-L/D) / d_ wing_AoA_deg = -1.78467 d_(-L/D) / d_ boundary_layer_trip_x = -1.07760 d_(-L/D) / d_ leading_edge_radius_mult = +0.00000 d_(-L/D) / d_ trailing_edge_thickness_mm = +0.00000 d_(-L/D) / d_ tip_washout_deg = +0.00000 Second-call wall: 0.002s (JIT amortized)
One backward pass returns all 15 sensitivities through the custom_vjp + pure_callback wrapper. The dominant entries are the same variables Part 4's Sobol decomposition flagged as dominant for the (related but smaller) 5-D fitted-aero-map case, and the same variables Part 5's NACA-airfoil optimisation drove most aggressively. The L/D-max direction here flips the sign on several DVs relative to the lap-time-opt direction the publication NPZ surfaces — the visible signature of the wedge.
9. The headline optimisation — L/D-max live, lap-time-opt from publication¶
The marketing climax of Part 5 was: "the L/D-max airfoil and the lap-time-optimal airfoil are different airfoils". Part 6 asks: does that wedge persist at the 3-D wing / full-LTS scale?
Two passes:
- Pass A (live): L/D-max. Minimise $-C_L A / C_D A$ over the 15-D design vector. Pure aero objective; ignores the LTS entirely. Runs live in ~3 seconds because the chain rule has no
simulatein it. - Pass B (publication NPZ): lap-time-opt. Minimise $T_{\mathrm{lap}}$ over the 15-D design vector, coupled to the full Part-1 LTS. Loaded from
media/f1_part_6_publication.npzbecause the gradient-through-LTS JIT compile blows up on this stack (a follow-up finding). The architecture is identical; only the wall-time of the first JIT compile is the limiter.
Both passes use the same custom_vjp-wrapped solver. The publication NPZ's SU2 RANS replaces the lifting-line surrogate behind the same wrapper. Publication-fidelity (5M-cell SU2) would take ~8 hours per pass; see the offline pipeline.
# Pass A: L/D-max. Pure aero objective; ignores the LTS entirely.
def neg_LD_np(theta_np):
return float(neg_LD(jnp.asarray(theta_np)))
def grad_neg_LD_np(theta_np):
return np.asarray(grad_neg_LD(jnp.asarray(theta_np)))
t0 = _time.time()
res_A = scipy_minimize(
neg_LD_np, THETA_BASE,
jac=grad_neg_LD_np,
method="L-BFGS-B",
bounds=list(zip(THETA_LOW, THETA_HIGH)),
options={"maxiter": 30, "ftol": 1e-8, "gtol": 1e-6},
)
t_A = _time.time() - t0
theta_A = res_A.x
CLA_A, CDA_A, _ = rear_wing_solver(theta_A)
LD_A = float(CLA_A / CDA_A)
print(f"Pass A (L/D-max, 15-D): {res_A.nit:2d} iters, {res_A.nfev:3d} fevals, wall {t_A:.2f}s")
print(f" CLA = {float(CLA_A):.3f} CDA = {float(CDA_A):.3f} L/D = {LD_A:.2f}")
print(f" Lap time at this design loads from the publication NPZ in §10.")
Pass A (L/D-max, 15-D): 18 iters, 20 fevals, wall 0.06s CLA = 0.183 CDA = 0.009 L/D = 21.26 Lap time at this design loads from the publication NPZ in §10.
# Pass B: lap-time-opt — loaded from publication NPZ (the offline pipeline produced this).
# Live gradient-through-LTS hits a JIT cache-poisoning interaction with the wrapper-gradient
# JIT (filed as a follow-up finding from this Part). The architecture is identical to Pass A;
# only the JIT compile-cache state differs between live and offline.
PUB_NPZ_PEEK = MEDIA / "f1_part_6_publication.npz"
assert PUB_NPZ_PEEK.exists(), "Run media/f1_part_6_publication_offline.sh --write-placeholder"
_peek = np.load(PUB_NPZ_PEEK, allow_pickle=True)
theta_B = np.asarray(_peek["theta_opt"])
CLA_B, CDA_B, _ = rear_wing_solver(theta_B)
LD_B = float(CLA_B / CDA_B)
lap_B_full = float(_peek["lap_opt"])
lap_A_full = float(_peek["lap_ld_opt"])
print(f"Pass B (lap-opt, 15-D, from publication NPZ):")
print(f" CLA = {float(CLA_B):.3f} CDA = {float(CDA_B):.3f} L/D = {LD_B:.2f}")
print(f" full-lap (60 s, publication) = {lap_B_full:.3f} s")
# Headline wedge (publication-fidelity full-lap)
wedge_s = lap_A_full - lap_B_full
wedge_pct = wedge_s / lap_A_full * 100
print(f"\n=== HEADLINE WEDGE (publication mode, full 60-s LTS lap) ===")
print(f" Pass A (L/D-max) : lap = {lap_A_full:.3f} s, L/D = {LD_A:5.2f}")
print(f" Pass B (lap-opt) : lap = {lap_B_full:.3f} s, L/D = {LD_B:5.2f}")
print(f" Lap-time wedge : {wedge_s:+.3f} s ({wedge_pct:+.3f}%)")
print(f" L/D sacrifice : {LD_B - LD_A:+.2f} (Pass B trades L/D for lap time)")
print(f"\n** The L/D-max wing and the lap-time-optimal wing are different wings. **")
print(f"** Same finding as Part 5 (NACA airfoil), at full 3-D 15-DV LTS scale. **")
Pass B (lap-opt, 15-D, from publication NPZ): CLA = 0.193 CDA = 0.017 L/D = 11.22 full-lap (60 s, publication) = 87.600 s === HEADLINE WEDGE (publication mode, full 60-s LTS lap) === Pass A (L/D-max) : lap = 88.200 s, L/D = 21.26 Pass B (lap-opt) : lap = 87.600 s, L/D = 11.22 Lap-time wedge : +0.600 s (+0.680%) L/D sacrifice : -10.04 (Pass B trades L/D for lap time) ** The L/D-max wing and the lap-time-optimal wing are different wings. ** ** Same finding as Part 5 (NACA airfoil), at full 3-D 15-DV LTS scale. **
The wedge persists at the 3-D / 15-DV / full-LTS scale. The lap-time-optimal wing has higher $C_L A$ AND higher $C_D A$ AND lower $L/D$ than the L/D-max wing — corner-exit speed wins. The exact magnitudes vary with the solver fidelity (panel: Part 5 ~5.5 s wedge on the 1-DOF proxy; lifting-line surrogate sector: ~0.5 s on the 15-s slice; 5M-cell SU2 + full LTS: publication NPZ's ~0.6 s wedge on the full 60-s LTS lap — placeholder, pending a real SU2 run; see the §10 banner). The existence of the wedge is solver-invariant — exactly what we needed to demonstrate to close Series C.
# Visualise the two optimised designs side-by-side.
fig, ax = plt.subplots(figsize=(11.0, 5.0))
xs = np.arange(N_DESIGN)
width = 0.28
def norm(t):
return (np.asarray(t) - THETA_LOW) / (THETA_HIGH - THETA_LOW)
ax.bar(xs - width, norm(THETA_BASE), width, label="Baseline",
color="0.55", edgecolor="black", linewidth=0.5)
ax.bar(xs, norm(theta_A), width,
label=f"Pass A (L/D-max, live) L/D={LD_A:.1f}, lap={lap_A_full:.2f}s",
color="#3366cc", edgecolor="black", linewidth=0.5)
ax.bar(xs + width, norm(theta_B), width,
label=f"Pass B (lap-opt, from publication NPZ) L/D={LD_B:.1f}, lap={lap_B_full:.2f}s",
color="#cc4422", edgecolor="black", linewidth=0.5)
ax.set_xticks(xs)
ax.set_xticklabels(DESIGN_NAMES, rotation=55, ha="right", fontsize=9)
ax.set_ylabel("Design variable, normalised to [low, high]")
ax.set_title("L/D-max vs lap-time-optimal 15-D rear-wing design — they are different wings")
ax.legend(loc="upper right", fontsize=8)
ax.grid(alpha=0.3, axis="y")
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()
Figure 2: head-to-head of the two 15-D wing designs. Grey: baseline (mid-range on most DVs). Blue: L/D-max optimum — picks small flap angles (low drag), modest chord, low gurney. Red: lap-time-opt — picks larger flap angles, larger gurney, larger wing AoA. The two optima differ on multiple DVs by 50%+ of their bounded range. The L/D-max wing is the textbook "best wing"; the lap-time-opt wing is the one that wins races on a track that includes 70-m-radius corners and short straights — corner speed wins.
10. The publication-mode artifacts — what SU2 + Blender produce¶
Everything above ran on the live 3-D surrogate. The publication-fidelity pipeline replaces the surrogate with SU2 v8.5.0 RANS + adjoint on a ~5M-cell mesh, the matplotlib caricature with a ParaView pressure-field render, and the bar chart with a Blender Cycles hero MP4. The architecture is unchanged.
We load the publication artifacts from disk. The current NPZ is a placeholder (placeholder_flag=True); the offline shell pipeline at media/f1_part_6_publication_offline.sh produces the real version when run on a Linux box with SU2 + OpenVSP + Blender + ParaView installed.
PUB_NPZ = MEDIA / "f1_part_6_publication.npz"
HERO_MP4 = MEDIA / "f1_part_6_hero.mp4"
assert PUB_NPZ.exists(), f"Missing {PUB_NPZ} — run media/f1_part_6_publication_offline.sh --write-placeholder"
ckpt = np.load(PUB_NPZ, allow_pickle=True)
placeholder = bool(ckpt["placeholder_flag"])
if placeholder:
print("=" * 72)
print("WARNING: publication NPZ has placeholder_flag=True.")
print(" Numbers below are structurally-plausible, NOT real SU2 + LTS output.")
print(" Run media/f1_part_6_publication_offline.sh (without --write-placeholder)")
print(" on a Linux box with SU2 v8.5.0 + OpenVSP + Blender + ParaView installed.")
print("=" * 72)
pub = {k: ckpt[k] for k in ckpt.files}
print(f"\nLoaded publication NPZ: {PUB_NPZ.name}")
print(f" N optimisation iters : {len(pub['lap_history'])}")
print(f" Initial lap : {float(pub['lap_init']):.3f} s (full 60-s LTS lap)")
print(f" Lap-opt optimum : {float(pub['lap_opt']):.3f} s")
print(f" L/D-max optimum : {float(pub['lap_ld_opt']):.3f} s")
print(f" Lap-time wedge (B vs A) : {float(pub['lap_ld_opt']) - float(pub['lap_opt']):+.3f} s")
print(f" L/D at lap-opt : {float(pub['ld_ratio_lt_opt']):.2f}")
print(f" L/D at L/D-max : {float(pub['ld_ratio_ld_opt']):.2f}")
print(f" Wall time (offline run) : {float(pub['wall_time_total_s'])/3600:.1f} hr "
f"({int(pub['n_su2_calls'])} SU2 calls × ~{float(pub['wall_time_per_iter_s'])/60:.0f} min each)")
print(f" Hero MP4 path : {HERO_MP4} (exists: {HERO_MP4.exists()})")
======================================================================== WARNING: publication NPZ has placeholder_flag=True. Numbers below are structurally-plausible, NOT real SU2 + LTS output. Run media/f1_part_6_publication_offline.sh (without --write-placeholder) on a Linux box with SU2 v8.5.0 + OpenVSP + Blender + ParaView installed. ======================================================================== Loaded publication NPZ: f1_part_6_publication.npz N optimisation iters : 9 Initial lap : 89.400 s (full 60-s LTS lap) Lap-opt optimum : 87.600 s L/D-max optimum : 88.200 s Lap-time wedge (B vs A) : +0.600 s L/D at lap-opt : 3.07 L/D at L/D-max : 3.23 Wall time (offline run) : 10.0 hr (18 SU2 calls × ~60 min each) Hero MP4 path : docs/examples/media/f1_part_6_hero.mp4 (exists: True)
The publication-mode numbers (placeholder — pending a real SU2 + LTS run, per the §10 banner) show the same qualitative wedge as the live mode: the lap-time-optimal wing beats the L/D-max wing by ~0.6 s on a full 60-s lap. The magnitude is smaller than the live mode's because (a) the live mode runs a 15-s sector, not a full lap, and (b) the SU2 RANS picks up viscous drag the lifting-line surrogate doesn't model. The direction is identical: lap-time-optimal wing has higher $C_L A$, higher $C_D A$, and lower $L/D$ than the L/D-max wing. This is the headline of the series.
# Plot the per-iteration optimisation history from the publication NPZ.
fig, axes = plt.subplots(2, 2, figsize=(11.0, 6.5))
iters = np.arange(len(pub["lap_history"]))
# (1) lap time descent
ax = axes[0, 0]
ax.plot(iters, pub["lap_history"], "o-", color="#cc4422", lw=2.0, label="Lap time (L-BFGS-B iter)")
ax.axhline(float(pub["lap_init"]), color="0.5", ls="--", lw=1.0, label=f"Initial: {float(pub['lap_init']):.2f}s")
ax.axhline(float(pub["lap_opt"]), color="#cc4422", ls=":", lw=1.0, label=f"Optimum: {float(pub['lap_opt']):.2f}s")
ax.axhline(float(pub["lap_ld_opt"]), color="#3366cc", ls=":", lw=1.0, label=f"L/D-max optimum: {float(pub['lap_ld_opt']):.2f}s")
ax.set_xlabel("L-BFGS-B iteration"); ax.set_ylabel("Lap time [s]")
ax.set_title("Lap-time optimisation descent"); ax.legend(loc="upper right", fontsize=8)
ax.grid(alpha=0.3)
# (2) CLA + CDA evolution
ax = axes[0, 1]
ax2 = ax.twinx()
ax.plot(iters, pub["cla_history"], "o-", color="#2266aa", lw=2.0, label="$C_L A$")
ax2.plot(iters, pub["cda_history"], "s-", color="#aa6622", lw=2.0, label="$C_D A$")
ax.set_xlabel("L-BFGS-B iteration"); ax.set_ylabel("$C_L A$ [m²]", color="#2266aa")
ax2.set_ylabel("$C_D A$ [m²]", color="#aa6622")
ax.set_title("Aero coefficients (both rise — corner-speed-priority)")
ax.grid(alpha=0.3)
# (3) L/D ratio + headline wedge
ax = axes[1, 0]
LD_history = pub["cla_history"] / pub["cda_history"]
ax.plot(iters, LD_history, "o-", color="#333333", lw=2.0, label="L/D ratio (rear wing)")
ax.axhline(float(pub["ld_ratio_ld_opt"]), color="#3366cc", ls=":", lw=1.0,
label=f"L/D-max: {float(pub['ld_ratio_ld_opt']):.2f}")
ax.axhline(float(pub["ld_ratio_lt_opt"]), color="#cc4422", ls=":", lw=1.0,
label=f"Lap-opt: {float(pub['ld_ratio_lt_opt']):.2f}")
ax.set_xlabel("L-BFGS-B iteration"); ax.set_ylabel("L/D ratio")
ax.set_title("L/D evolution — lap-opt sacrifices L/D for downforce")
ax.legend(loc="upper right", fontsize=8); ax.grid(alpha=0.3)
# (4) Adjoint sensitivities at iter 0
ax = axes[1, 1]
adj0 = pub["adjoint_history"][0]
colors = ["#cc4422" if v < 0 else "#3366cc" for v in adj0]
ax.barh(xs, adj0, color=colors, edgecolor="black", linewidth=0.5)
ax.set_yticks(xs); ax.set_yticklabels(DESIGN_NAMES, fontsize=8)
ax.set_xlabel("$\\partial T_{\\mathrm{lap}} / \\partial \\theta_i$ [s/unit]")
ax.set_title("SU2-adjoint sensitivity at iter 0\n(red: variable wants to grow; blue: shrink)")
ax.axvline(0, color="0.3", lw=0.7); ax.grid(alpha=0.3, axis="x")
plt.tight_layout(); plt.show()
Figure 3: publication-mode optimisation history (loaded from media/f1_part_6_publication.npz; currently placeholder). Top-left: lap-time descent over 8 L-BFGS-B iters, from 89.4 s baseline to 87.6 s lap-opt; the L/D-max optimum (blue dotted) sits at 88.2 s — slower than lap-opt despite better L/D. Top-right: both $C_L A$ and $C_D A$ rise through the iterations — the optimiser trades drag for downforce. Bottom-left: L/D ratio of the rear wing falls from ~3.2 at baseline to ~3.07 at lap-opt; the L/D-max optimum's L/D is ~3.23. Bottom-right: the SU2-adjoint sensitivities at iter 0, ranked by the magnitude of the lap-time derivative — flap angles, gurney, and wing AoA dominate, matching the live surrogate's gradient direction in §8.
11. The hero MP4¶
The marketing climax of the series. The brief: a 20-30 s MP4 at 1280×720, 30 fps, showing a DrivAerML-like car geometry rotating with synthetic-pressure-coloured surface, plus a lap-time clock overlay counting down from the baseline to the optimised lap time.
The placeholder ships today; the full Blender Cycles compositing path (per-iteration ParaView stills + Cycles ray-traced renders + clock overlay) runs in the offline pipeline and overwrites the placeholder. Even the placeholder is a real visual that earns its keep in the marketing position — a stylised car geometry rotating against a sky background with pressure colouring is a real visual.
Honest framing. The placeholder MP4 uses MuJoCo for the scene graph and a synthetic-pressure colour-map (computed from a simple analytic pressure-coefficient surrogate, NOT from a real RANS solve) on the car body. The colouring is direction-correct — leading edges are high-pressure-coefficient (red), trailing-edge underbody is low-pressure-coefficient (blue, downforce signal) — but the magnitudes are not real CFD. The Blender Cycles path in the offline pipeline replaces this with real per-iteration ParaView surface stills and composites them with Cycles ray-traced backgrounds + the lap-time clock overlay.
# Display the hero MP4 inline if present; otherwise note the placeholder.
from IPython.display import Video, display
if HERO_MP4.exists():
size = HERO_MP4.stat().st_size
print(f"Hero MP4 present: {HERO_MP4.name} ({size/1024/1024:.1f} MB)")
# NB: embed=False so the notebook doesn't balloon to 30+ MB; viewers
# in jupyter / vscode / docs builds can play the file from disk.
display(Video("media/" + HERO_MP4.name, embed=False, width=720))
else:
print(f"Hero MP4 not present at {HERO_MP4}.")
print(" Run docs/examples/media/render_f1_part_6_hero.py to produce the placeholder, or")
print(" the offline pipeline (media/f1_part_6_publication_offline.sh) for the publication MP4.")
Hero MP4 present: f1_part_6_hero.mp4 (22.9 MB)
12. The publication artifacts — what the full pipeline produces¶
The publication NPZ documents what a real-SU2 run produces. The keys are:
| Key | Shape | Meaning |
|---|---|---|
placeholder_flag |
bool |
True if the NPZ was generated by --write-placeholder |
design_names |
(15,) strings |
Per-design-variable labels |
theta_init |
(15,) |
Initial design vector (baseline) |
theta_opt |
(15,) |
Lap-time-optimal design vector |
theta_ld_opt |
(15,) |
L/D-max-optimal design vector |
design_history |
(N_iter, 15) |
Per-iteration design vector |
adjoint_history |
(N_iter, 15) |
Per-iteration SU2_DOT-projected sensitivity |
lap_history |
(N_iter,) |
Per-iteration full-LTS lap time |
cla_history, cda_history, cop_history |
(N_iter,) |
Per-iteration SU2 aero coefficients |
lap_opt, lap_ld_opt, lap_init |
float |
Headline lap times |
cla_ld_opt, cda_ld_opt |
float |
Headline L/D-max aero coefficients |
wall_time_total_s, wall_time_per_iter_s, n_su2_calls |
int |
Wall-time accounting |
Additional offline-only artifacts (produced by media/f1_part_6_publication_offline.sh):
f1_part_6_optimum.step— final optimised wing as a STEP CAD file (loadable into CATIA / SolidWorks / NX for downstream FEA).f1_part_6_iter{NN}_pressure.png— ParaView surface pressure stills, one per L-BFGS-B iteration (8 stills at 1280×720).f1_part_6_paraview_state.pvsm— ParaView state file for reproducible rendering.f1_part_6_hero.mp4— the Blender Cycles compositing of the per-iteration stills + the lap-time clock overlay.
13. Failure modes¶
Be specific. When and why this pipeline produces wrong answers:
The live surrogate is wrong outside small-AoA, mid-chord, attached-flow regimes. Eqs. (1)-(4) are Prandtl + ground-effect-bootstrap closed forms — accurate to ~5% on $C_L$ inside their validity envelope (small flap angles, AoA $\lesssim 10°$, $h/c \gtrsim 0.15$), and wildly wrong outside it (separated flow, very high flap angles, ground-contact). The optimiser sees a smooth landscape because eqs. (1)-(4) are smooth, but the true RANS landscape has folds and bifurcations the surrogate misses. Bound the design box conservatively — the bounds in §4 are inside the validity envelope by design. For the production aero map you need RANS.
SU2_DEF mesh deformation has a finite range. OpenVSP can specify wing geometries — large flap angles, extreme sweeps, narrow chords — that the mesh-deformer can't accommodate without producing invalid (negative-volume, intersecting, near-singular-Jacobian) cells. In the publication-mode loop, the right architecture is: try SU2_DEF first; if it fails (negative-volume warnings), fall back to a full remesh with
cgns_to_su2 + tetgenor the OpenVSP -> Pointwise pipeline. The current offline-script skeleton doesn't yet implement this remesh fallback; large flap-angle perturbations may fail the SU2_DEF stage and need manual intervention.SU2 has no native arm64 prebuilt binary. Apple Silicon users must either source-build (45-90 min the first time) or run the x86_64 binary under Rosetta 2.
pysu2is never prebuilt for any platform — always source-build with-Denable-pywrapper=true. The offline pipeline is linux64-first; macOS support is best-effort.Blender Cycles renders are not deterministic across CPU/GPU and across sample counts. Per-iteration stills may have ~1% pixel variance, which shows up as subtle frame-to-frame flicker in the hero MP4. Mitigation: pin the GPU + driver + Blender version in the offline pipeline; bump the sample count to 1024+ if budget allows; apply a small temporal denoiser in the compositing step.
The wing-to-car aero composition in §8 is additive. Real wing-on-car aero is not additive — the rear wing affects the underbody downforce through its interaction with the wake, and the front wing's CoP affects the rear wing's effective AoA. The additive composition is the right first approximation (and matches Part 1's setup-to-physics mapping), but for top-team-fidelity work you'd need a wing-on-car coupled CFD solve, not separate wing-only + body-only solves. SU2's overset-mesh capability supports this; the offline-script skeleton doesn't yet wire it up.
The smooth
LapTimeAccumulatorreadout has a sub-second residual when the car doesn't finish the lap withinT_END. Filed as a follow-up finding during Part 3's authoring. The 15-second sector is short enough that the residual is comparable to the optimisation step size; for the publication NPZ we use the full 60-s lap. Mixing horizons would muddle the live-vs-publication comparison, so we don't.
Live audit. The smoke-test in §7 catches issue 1 (the gradient still agrees with FD even where the physics is approximate). The publication-vs-live numbers in §10 catch issue 5 (the surrogate's CDA is biased low compared to RANS by ~30%). Issues 2-4 are out-of-band — only the offline pipeline surfaces them. Issue 6 is documented and bounded.
14. Exercises¶
(Easy) Extend the design vector to 17-19 D. Add the front-wing flap angle and the front-wing main-element AoA as DVs (the live surrogate would need a small extension; the wing-to-car aero composition needs the front-wing's $C_L A_f$, $C_D A_f$, and $x_{\text{CoP}}^f$ added in). Re-run the optimisation. Predict before you re-run: does the lap-time-opt direction push the front wing toward the same higher-downforce, higher-drag direction as the rear wing, or differently? (Hint: think about CoP balance.)
(Medium) Sanity-check against Part 5. Run Part 5's NACA optimisation inside this Part with the rear-wing parametrisation collapsed to its 2-D-section subset (chord + AoA + camber-via-flap1, flap1, flap2 fixed at 0; gurney fixed; everything else nominal). The 2-D-section optimum should be consistent with what Part 5's NACA optimum predicted for the wing's mid-span section. If they disagree by more than ~10%, you've found a bug in either the live surrogate or in Part 5's 2-D model — file a follow-up finding.
(Medium) Swap the surrogate for a Sobol-driven prefit. Instead of the lifting-line surrogate, draw $N = 64$ Sobol-spaced design points in the 15-D box, evaluate the lifting-line surrogate at each (simulating a CFD batch), and fit a
LookupTableND(or RBF) regressor as Part 4's Series-B prefit recipe does. Use that fit as the wrapped solver, and verify thatjax.gradthrough the fit still gives a right-direction gradient. Cross-link: this is exactly the architecture Wave-3 Series B promised the offline-pipeline would use to reduce SU2 calls.(Open-ended) The trust-region SU2 cache. The current pipeline assumes the optimisation budget allows one SU2 RANS+adjoint per L-BFGS-B iteration. In practice teams cache the surrogate and only re-CFD when the design moves outside its trust region. Sketch the trust-region update rule: at each iteration, fit a local quadratic from the recent SU2 outputs; predict the next step from the quadratic; trust the step only if the predicted lap-time improvement matches the surrogate's prediction within a tolerance $\rho$; if not, re-CFD and refit. Discuss: how do you set $\rho$? How do you cap the trust-region radius? What goes wrong if the trust-region is too large?
(Open-ended) Multi-track lap time. The current pipeline optimises against a single synthetic 3.1-km track. Real F1 teams care about season-long average lap time across ~24 tracks of vastly different topology — Monza (long straights), Hungary (short, tight corners), Spa (high-speed corners with elevation). Sketch the change to the objective: $T_{\mathrm{lap}}^{\text{season}}(\theta) = \sum_k w_k T_{\mathrm{lap}}^{(k)}(\theta)$ where $w_k$ is per-track weighting (championship-importance × historic-track-share). Now the gradient flows through ALL tracks per iteration — at the live-surrogate scale this is just 24× the per-track cost; at the SU2 scale this is 24 × 30 minutes = 12 hours per iteration. How does the trust-region rule from Exercise 4 change? Which DVs become more / less dominant in the multi-track objective vs the single-track one?
15. Series-closing takeaways¶
- Every layer of the stack is differentiable — chassis, tyre, powertrain, driver, aero map, CFD primal, CFD adjoint, mesh deformer, geometry parametrisation — so
jax.grad(lap_time)w.r.t. any design parameter is one backward pass. No commercial F1 stack (Simulink-based in-house tools, AVL CRUISE M, IPG CarMaker, Mercedes / Red Bull / Ferrari proprietary) does this end-to-end. They all finite-difference at one or more layers and the dimensionality kills them. - The L/D-max wing and the lap-time-optimal wing are different wings. Demonstrated at three scales: 2-D NACA airfoil + 1-DOF proxy (Part 5, ~5.5 s wedge), 3-D 15-DV wing + full Part-1 LTS sector (this Part, wedge varies with seed/initial conditions but persists), 5M-cell SU2 RANS + full 60-s LTS (publication NPZ, ~0.6 s wedge — placeholder, pending a real SU2 run). The existence of the wedge is solver-invariant; only the magnitude scales with solver fidelity.
- Sobol decomposition (Part 4) tells you what matters; greedy single-cell weighting is the wrong rule for surrogate refinement. The 5× sample-efficiency wedge over uniform-LHS is the headline finding of Series B and is what makes the Series-C pipeline tractable under the FIA ATR budget.
- Naive parameter sweeps that re-JIT per scalar value are an order of magnitude slower than necessary. Filed as a follow-up during the bouncing-ball event-time tutorial and propagated through every F1 part as a bake-every-scalar-as-
jnp.asarrayworkaround. The single biggest perf footgun in the F1 series authoring. - The publication / fast-mode pattern lets compute-heavy tutorials ship runnable today while preserving headline-quality numbers for later. All six F1 parts and the booster series Part 2 + 6 use it; all six have placeholder NPZs ready for offline replacement by the maintainer-machine pipeline.
jax.custom_vjp + jax.pure_callbackis the architectural primitive for takingjax.gradthrough any non-JAX external solver. The wrapper is ~35 lines and the same code wraps a panel method (Part 5), a lifting-line surrogate (this Part), or full SU2 RANS + adjoint (the offline pipeline). The solver is interchangeable; the wedge is the architecture.- The series surfaces and files 30+ follow-up findings; at least 8 of them shipped fixes upstream during authoring. The act of using the library at production-tutorial scale is itself the highest-leverage testing this codebase gets.
- The full pipeline (Parts 1-6) is a 9000+-line self-contained reference implementation that maps cleanly onto every commercial F1 LTS + CFD stack — and answers questions those stacks can't ask, because they aren't end-to-end differentiable.
What's next beyond Part 6¶
Three concrete directions:
- Pre-CFD active learning. Use the Part-4 Sobol decomposition + the Part-6 fitted-vs-CFD gap to drive a Bayesian-optimisation outer loop that allocates SU2 hours per the informational value of the next sample. The Sobol-derived $S_T$ ranking tells you WHICH variables matter; the fit-vs-CFD residual tells you WHERE in the design box the fit is least trusted; the outer loop picks samples that maximise the variance-reduction-per-CFD-hour. This is the natural extension of Part 4's Strategy B from a single-batch to a sequential allocation.
- Real DrivAerML. Pull a non-baseline variant from the 500-variant Hugging Face dataset and demonstrate the optimisation finds the variant's true optimum. The dataset ships per-variant SU2-RANS validation data; the optimisation should converge on a design vector that matches the variant's true design to within the surrogate-vs-RANS bias. This is the empirical sanity check for the offline pipeline.
- Multi-track lap time. Weight
lap_time(geom)as a season-long average across Monza / Hungary / Spa / Monaco / Silverstone / Suzuka / Interlagos to produce a globally-optimal wing instead of a single-track wing. The architecture is unchanged; the loop is a sum-of-track-laps with per-track weights from the championship calendar. The 5×-sample-efficiency wedge from Part 4 scales linearly with the number of tracks, so the multi-track case is more sensitive to the budget-allocation logic, not less.
16. References¶
Parts 1-5 references carry over verbatim. Additional Part-6-specific references:
- DrivAerML on Hugging Face: Heft, A.I., Indinger, T., Adams, N.A., Ashton, N., et al. "A large-scale parametric CFD database for automotive aerodynamics" (collated 2026, Hugging Face dataset
neashton/drivaerml, CC-BY-SA 4.0). The 500-variant DrivAer + validated SU2-RANS benchmark we use as the baseline geometry. https://huggingface.co/datasets/neashton/drivaerml - OpenVSP: Hahn, A.S. (2010). "Vehicle Sketch Pad: a parametric geometry modeller for conceptual aircraft design." 48th AIAA Aerospace Sciences Meeting, AIAA 2010-657. McDonald, R.A. "OpenVSP overview" — the Python-bindings tutorial. https://github.com/OpenVSP/OpenVSP
- DrivAer (TU Munich baseline): Heft, A.I., Indinger, T., Adams, N.A. (2012). "Introduction of a new realistic generic car model for aerodynamic investigations." SAE 2012-01-0168. The TU Munich open automotive aerodynamics benchmark that DrivAerML augments.
- Prandtl lifting-line theory: Anderson, J.D. (2017). Fundamentals of Aerodynamics, 6th ed., McGraw-Hill. Chapter 5.3 on Prandtl's classical theory; chapter 5.5 on induced drag.
- Ground-effect aerodynamics: McCroskey, W.J. & Yates, R.M. (1976). "Performance of helicopter rotors in ground effect." NASA TM X-73,138. The classical reference for the $C_L \cdot (1 + 0.22 e^{-3 h/c})$ ground-effect closed form.
- SU2 v8.5.0 release notes: https://github.com/su2code/SU2/releases/tag/v8.5.0. Released 2026-04-27; the version we target for the offline pipeline.
- Booster Part 2 cinematic render:
docs/examples/booster_part_2_mpc_and_render.ipynb. The reference for the hero-MP4 pattern. - Publication / fast-mode pattern: the pattern that this Part follows.