Coupled aero-structural adjoint
In one minuteA flow coupled to an elastic skin, with the coupled derivative assembled outside Gyre from loads, displacements and reverse total products — the pattern an MDO framework uses, in sixty lines.
This example exists to show an interface, not a structural model. The structure is a toy — a smooth linear compliance mapping surface loads to surface displacements — but the coupling is real: loads deform the surface, the deformed surface changes the loads.
The flow is coupled to a linear elastic skin (loads in, displacements out) by a fixed-point iteration written outside Gyre. The coupled derivative of lift with respect to angle of attack is assembled from Gyre's reduced reverse products and compared with a finite difference of the fully re-converged coupled system.
- coupled adjoint vs finite difference (rel.)9.63e-08expected < 1e-5pass
- coupling is active: max deflection [chords]0.005559expected > 1e-5pass
gyre 0.4.0 · 39979fcf90 · · cpu · quick suite · record (JSON)Same code as this build
| cl (flexible) | max deflection [c] | dcl/dα coupled adjoint | dcl/dα finite difference | relative difference | dcl/dα frozen shape | aeroelastic effect [%] |
|---|---|---|---|---|---|---|
| 0.279698 | 0.00555919 | 7.76502 | 7.76502 | 9.6349e-08 | 7.71574 | 0.638621 |
gyre 0.4.0 · 39979fcf90 · · cpu · quick suite · record (JSON)Same code as this build
The derivative of lift with respect to angle of attack for the coupled system differs from the frozen-shape value by the aeroelastic effect in the last column. The coupled adjoint reproduces a finite difference of the fully re-converged coupled system to the relative difference shown — using only operations any MDO framework can call.
The coupled analysis#
def coupled_solve(flow, C, tol=1e-9, relax=1.0, max_iter=40):
d = np.asarray(flow.d_surface).copy()
for it in range(max_iter):
flow.set_inputs(x_surface=d) # displacements in
converge(flow) # warm-started flow solve; raises if it does not converge
f = flow.outputs(("f_surface",))["f_surface"] # consistent nodal loads out
d_new = (C @ f.reshape(-1)).reshape(d.shape) # the "structure"
...
converge is a small helper of the example: it calls flow.solve() and raises if the history
says the solve did not converge. A failed solve leaves the discipline’s last converged state
in place, the flow of the previous shape, so loads read after it would belong to another
design.
The coupled adjoint#
tot = flow.totals(of=("cl",), wrt=("alpha", "x_surface"))["derivatives"]["cl"]
dJ_dd = tot["x_surface"].reshape(-1)
lam = dJ_dd.copy()
for _ in range(max_iter): # transposed fixed-point iteration
seed = (C.T @ lam).reshape(shape)
back = flow.adjoint_product({"f_surface": seed}, wrt=("x_surface",))["derivatives"]
lam_new = dJ_dd + back["x_surface"].reshape(-1)
...
cross = flow.adjoint_product({"f_surface": (C.T @ lam).reshape(shape)}, wrt=("alpha",))
dJ_dalpha = tot["alpha"] + cross["derivatives"]["alpha"]
Three things, all part of the discipline contract:
f_surface, loads that are conservative and work-conjugate tox_surface;totals(…, wrt=["alpha", "x_surface"]), the reduced gradient of a scalar;adjoint_product({"f_surface": seed}, …), the reverse product — one flow-adjoint solve regardless of the number of load components.
The loop, the structure and the choice of solver for the coupled system live outside. The
derivation is in Adjoints and sensitivities; the full
script is examples/aerostructural_coupling.py, and the same logic is exercised by
tests/test_integrations.py.
The same calls exist on a remote session: flow.adjoint({"f_surface": seed}, wrt=[...]) on a
RemoteDiscipline, or POST /api/v1/sessions/{id}/adjoint. A structural code on another
tailnet machine can therefore own the coupled iteration.