Skip to content

Surrogate API

flowdesign.surrogate

Surrogate model package for FlowDesign.

DummyAirfoilEvaluator

Lightweight differentiable CST evaluator for examples and tests.

Source code in flowdesign/surrogate/evaluators.py
class DummyAirfoilEvaluator:
    """Lightweight differentiable CST evaluator for examples and tests."""

    def __init__(self, mach: float = 0.15, reynolds: float = 1.0e6, alpha: float = 2.0):
        self.mach = float(mach)
        self.reynolds = float(reynolds)
        self.alpha = float(alpha)

    def __call__(self, x: torch.Tensor, conditions: dict[str, Any] | None = None) -> dict[str, torch.Tensor]:
        x = x.reshape(x.shape[0], -1).float()
        half = x.shape[1] // 2
        upper = x[:, :half]
        lower = x[:, half : 2 * half]
        camber = 0.5 * (upper + lower).mean(dim=1)
        thickness = (upper - lower).mean(dim=1)
        alpha = float((conditions or {}).get("alpha", self.alpha))
        cl = 0.12 * alpha + 2.8 * camber + 1.4 * thickness
        cd = 0.008 + 0.08 * (cl - 0.35).square() + 0.01 * (thickness - 0.12).square()
        cm = -0.04 - 0.15 * camber + 0.02 * (self.mach - 0.15)
        return {"CL": cl, "CD": cd, "CM": cm, "thickness": thickness}

DummySuperWingEvaluator

Fast SuperWing evaluator with AeroTransformer-compatible output keys.

Source code in flowdesign/surrogate/evaluators.py
class DummySuperWingEvaluator:
    """Fast SuperWing evaluator with AeroTransformer-compatible output keys."""

    def __init__(self, mach: float = 0.85, alpha: float = 2.2):
        self.mach = float(mach)
        self.alpha = float(alpha)

    def __call__(self, x: torch.Tensor, conditions: dict[str, Any] | None = None) -> dict[str, torch.Tensor]:
        x = x.reshape(x.shape[0], -1).float()
        alpha = float((conditions or {}).get("alpha", self.alpha))
        ar = x[:, 3].clamp_min(1.0) if x.shape[1] > 3 else torch.full((x.shape[0],), 8.0, device=x.device)
        tr = x[:, 4].clamp_min(0.05) if x.shape[1] > 4 else torch.full((x.shape[0],), 0.35, device=x.device)
        twist = x[:, 13:17].mean(dim=1) if x.shape[1] >= 17 else torch.zeros(x.shape[0], device=x.device)
        cl = 0.05 * alpha + 0.035 * ar + 0.12 * tr - 0.01 * twist
        cd = 0.012 + cl.square() / (3.14159 * ar * 0.78) + 0.004 * (self.mach - 0.75) ** 2
        cm = -0.06 - 0.015 * twist
        return {"CL": cl, "CD": cd, "CM": cm}

AeroTransformerSurrogate

Bases: Module

FlowDesign surrogate wrapper for AeroTransformer ATsurf_S.

Public forward input

DFlow design variables, usually SuperWing geometry parameters [B, 38].

Public forward output

Tensor [B, 3] in order [CL, CD, CM]. CL/CD are required by DFlow.

Internal workflow
  • Reconstruct SuperWing reference meshes from the first 38 geometry columns of configs.dat.
  • Run the real ATsurf_S checkpoint on geom0 with condition [AoA, Mach].
  • Integrate predicted [Cp, Cf_tau, Cf_z] over origingeom to return [CL, CD, CMz].
Source code in flowdesign/surrogate/aero_transformer_surrogate.py
class AeroTransformerSurrogate(nn.Module):
    """
    FlowDesign surrogate wrapper for AeroTransformer ATsurf_S.

    Public forward input:
        DFlow design variables, usually SuperWing geometry parameters [B, 38].

    Public forward output:
        Tensor [B, 3] in order [CL, CD, CM]. CL/CD are required by DFlow.

    Internal workflow:
        - Reconstruct SuperWing reference meshes from the first 38 geometry
          columns of `configs.dat`.
        - Run the real ATsurf_S checkpoint on `geom0` with condition
          `[AoA, Mach]`.
        - Integrate predicted `[Cp, Cf_tau, Cf_z]` over `origingeom` to return
          `[CL, CD, CMz]`.
    """

    def __init__(
        self,
        config_path: str | None = None,
        checkpoint_path: str | None = None,
        model_class: str | None = None,
        design_dim: int = 38,
        field_size: tuple[int, int] = (128, 256),
        mach: float = 0.85,
        alpha: float = 2.0,
        ref_area: float | None = None,
        cd_bias: float = 0.02,
        strict_load: bool = False,
        use_superwing_builder: bool = True,
        allow_proxy_coefficients: bool = False,
        require_upstream_model: bool = True,
        geometry_clamp: bool = True,
        device: str | torch.device | None = None,
    ):
        super().__init__()
        self.design_dim = int(design_dim)
        self.field_size = tuple(field_size)
        self.mach = float(mach)
        self.alpha = float(alpha)
        self.ref_area_override = None if ref_area is None or float(ref_area) <= 0.0 else float(ref_area)
        self.cd_bias = float(cd_bias)
        self.use_superwing_builder = bool(use_superwing_builder)
        self.allow_proxy_coefficients = bool(allow_proxy_coefficients)
        self.device_hint = torch.device(device) if device is not None else None
        self.geometry_builder = (
            TorchSuperWingGeometryBuilder(clamp_inputs=geometry_clamp)
            if self.use_superwing_builder
            else None
        )

        self.config = self._load_config(config_path)
        init_kwargs = dict(self.config.get("init_kwargs", {}))
        init_kwargs.setdefault("fun_dim", 3)
        init_kwargs.setdefault("out_dim", 3)
        init_kwargs.setdefault("n_hidden", 16)
        init_kwargs.setdefault("n_layers", 5)

        if model_class is None:
            try:
                import flowvae.app.wing.models  # noqa: F401

                model_cls = UpstreamAeroTransformerAdapter
                self.uses_upstream_model = True
            except Exception as exc:
                if require_upstream_model:
                    raise ImportError(
                        "The real ATsurf_S path requires the author's flowvae package. "
                        "Install it with: python -m pip install git+https://github.com/YangYunjia/floGen.git"
                    ) from exc
                model_cls = WingPDETransformer
                self.uses_upstream_model = False
            self.uses_fallback_model = not self.uses_upstream_model
        else:
            model_cls = _load_class(model_class)
            self.uses_upstream_model = False
            self.uses_fallback_model = False

        self.model = model_cls(*self.config.get("init_args", []), **init_kwargs)
        if checkpoint_path:
            self.load_checkpoint(checkpoint_path, strict=strict_load)

    @staticmethod
    def _load_config(config_path: str | None) -> dict:
        if config_path is None:
            return {
                "model_name": "WingPDETransformer",
                "schema_version": 1,
                "init_args": [],
                "init_kwargs": {
                    "fun_dim": 3,
                    "mlp_ratio": 4,
                    "n_hidden": 16,
                    "n_layers": 5,
                    "out_dim": 3,
                    "patch_size": [4, 4],
                    "type_cond": "inj",
                    "window_size": [8, 8],
                },
            }
        with open(config_path, "r", encoding="utf-8") as handle:
            return json.load(handle)

    def load_checkpoint(self, checkpoint_path: str, strict: bool = False) -> None:
        state = torch.load(checkpoint_path, map_location="cpu")
        if isinstance(state, dict):
            state = state.get("state_dict", state.get("model_state_dict", state))
        try:
            result = self.model.load_state_dict(state, strict=strict)
        except RuntimeError:
            if strict:
                raise
            print(
                "AeroTransformerSurrogate: checkpoint could not be loaded into "
                "the local fallback model. Provide --model-class with the real "
                "WingPDETransformer implementation for faithful ATsurf_S weights."
            )
            return
        if not strict and (result.missing_keys or result.unexpected_keys):
            print(
                "AeroTransformerSurrogate: checkpoint did not exactly match the "
                f"local model class. missing={len(result.missing_keys)} "
                f"unexpected={len(result.unexpected_keys)}"
            )

    def _normalize_design(self, x: torch.Tensor) -> torch.Tensor:
        if x.dim() == 3 and x.shape[1] == 1:
            x = x.squeeze(1)
        elif x.dim() == 1:
            x = x.reshape(1, -1)
        if x.dim() != 2:
            raise ValueError(f"Expected design variables [B,D], got {tuple(x.shape)}")
        if x.shape[1] < self.design_dim:
            raise ValueError(
                f"Expected at least {self.design_dim} design variables, got {x.shape[1]}"
            )
        return x[:, : self.design_dim]

    def make_condition(self, batch_size: int, dtype: torch.dtype, device: torch.device) -> torch.Tensor:
        """AeroTransformer/floGen uses condition order `[AoA, Mach]`."""
        return torch.tensor([self.alpha, self.mach], dtype=dtype, device=device).reshape(1, 2).expand(batch_size, 2)

    def _proxy_preprocess(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        x = self._normalize_design(x).float()
        height, width = self.field_size
        bsz = x.shape[0]

        # Differentiable proxy geometry channels. They preserve gradients from
        # the field/coefficient losses back to the generated design variables.
        span = torch.linspace(0.0, 1.0, height, dtype=x.dtype, device=x.device)
        chord = torch.linspace(0.0, 1.0, width, dtype=x.dtype, device=x.device)
        yy, xx = torch.meshgrid(span, chord, indexing="ij")
        xx = xx.expand(bsz, 1, height, width)
        yy = yy.expand(bsz, 1, height, width)

        planform = x[:, 0:1].reshape(bsz, 1, 1, 1)
        twist = x[:, min(13, self.design_dim - 1) : min(14, self.design_dim)].reshape(
            bsz, 1, 1, 1
        )
        thickness = x[:, min(17, self.design_dim - 1) : min(18, self.design_dim)].reshape(
            bsz, 1, 1, 1
        )
        zz = 0.01 * planform * yy + 0.02 * twist * (xx - 0.5) + 0.01 * thickness

        field_input = torch.cat([xx, yy, zz.expand_as(xx)], dim=1)
        condition = self.make_condition(bsz, x.dtype, x.device)
        return field_input, condition

    def preprocess(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        """Convert DFlow design variables to ATsurf_S inputs."""
        x = self._normalize_design(x).float()
        if self.geometry_builder is None:
            return self._proxy_preprocess(x)
        geometry = self.geometry_builder(x)
        condition = self.make_condition(x.shape[0], geometry.geom0.dtype, geometry.geom0.device)
        return geometry.geom0, condition

    def build_geometry(self, x: torch.Tensor) -> SuperWingGeometry:
        if self.geometry_builder is None:
            raise RuntimeError("AeroTransformerSurrogate was created without the SuperWing geometry builder.")
        return self.geometry_builder(self._normalize_design(x).float())

    def predict_fields(self, x: torch.Tensor) -> torch.Tensor:
        field_input, condition = self.preprocess(x)
        return self.model(field_input, condition)

    def fields_to_coefficients(
        self,
        fields: torch.Tensor,
        geometry: SuperWingGeometry | None = None,
    ) -> torch.Tensor:
        """
        Convert predicted [Cp, Cf_tau, Cf_z] fields to [CL, CD, CM].
        """
        if fields.dim() != 4 or fields.shape[1] < 3:
            raise ValueError(f"Expected fields [B,3,H,W], got {tuple(fields.shape)}")
        if geometry is not None:
            batch = fields.shape[0]
            ref_area = geometry.ref_area.to(dtype=fields.dtype, device=fields.device)
            if self.ref_area_override is not None:
                ref_area = torch.full((batch,), self.ref_area_override, dtype=fields.dtype, device=fields.device)
            aoa = torch.full((batch,), self.alpha, dtype=fields.dtype, device=fields.device)
            return _integrate_surface_fields(
                origingeom=geometry.origingeom.to(dtype=fields.dtype, device=fields.device),
                fields=fields,
                aoa=aoa,
                ref_area=ref_area,
                ref_chord=geometry.ref_chord.to(dtype=fields.dtype, device=fields.device),
                xref=geometry.xref.to(dtype=fields.dtype, device=fields.device),
                yref=geometry.yref.to(dtype=fields.dtype, device=fields.device),
            )

        if not self.allow_proxy_coefficients:
            raise ValueError(
                "fields_to_coefficients() requires SuperWingGeometry for real surface integration. "
                "Pass allow_proxy_coefficients=True only for debugging."
            )

        cp = fields[:, 0]
        cf_tau = fields[:, 1]
        cf_z = fields[:, 2]

        alpha = torch.as_tensor(self.alpha, dtype=fields.dtype, device=fields.device)
        alpha_rad = torch.deg2rad(alpha)
        pressure_lift = -cp.mean(dim=(1, 2)) * torch.cos(alpha_rad)
        friction_drag = cf_tau.abs().mean(dim=(1, 2)) + 0.1 * cf_z.abs().mean(dim=(1, 2))
        pressure_drag = cp.abs().mean(dim=(1, 2)) * torch.sin(alpha_rad).abs()

        ref_area = self.ref_area_override if self.ref_area_override is not None else 1.0
        cl = pressure_lift / max(ref_area, 1e-8)
        cd = self.cd_bias + (friction_drag + pressure_drag) / max(ref_area, 1e-8)
        cm = (cp * torch.linspace(0.0, 1.0, cp.shape[-1], device=cp.device)).mean(
            dim=(1, 2)
        )
        return torch.stack([cl, cd, cm], dim=1)

    def predict_fields_and_coefficients(self, x: torch.Tensor) -> AeroTransformerOutput:
        if self.geometry_builder is None:
            fields = self.predict_fields(x)
            coefficients = self.fields_to_coefficients(fields)
            return AeroTransformerOutput(fields=fields, coefficients=coefficients)
        geometry = self.build_geometry(x)
        condition = self.make_condition(geometry.geom0.shape[0], geometry.geom0.dtype, geometry.geom0.device)
        fields = self.model(geometry.geom0, condition)
        coefficients = self.fields_to_coefficients(fields, geometry=geometry)
        return AeroTransformerOutput(fields=fields, coefficients=coefficients)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.predict_fields_and_coefficients(x).coefficients

make_condition(batch_size, dtype, device)

AeroTransformer/floGen uses condition order [AoA, Mach].

Source code in flowdesign/surrogate/aero_transformer_surrogate.py
def make_condition(self, batch_size: int, dtype: torch.dtype, device: torch.device) -> torch.Tensor:
    """AeroTransformer/floGen uses condition order `[AoA, Mach]`."""
    return torch.tensor([self.alpha, self.mach], dtype=dtype, device=device).reshape(1, 2).expand(batch_size, 2)

preprocess(x)

Convert DFlow design variables to ATsurf_S inputs.

Source code in flowdesign/surrogate/aero_transformer_surrogate.py
def preprocess(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
    """Convert DFlow design variables to ATsurf_S inputs."""
    x = self._normalize_design(x).float()
    if self.geometry_builder is None:
        return self._proxy_preprocess(x)
    geometry = self.geometry_builder(x)
    condition = self.make_condition(x.shape[0], geometry.geom0.dtype, geometry.geom0.device)
    return geometry.geom0, condition

fields_to_coefficients(fields, geometry=None)

Convert predicted [Cp, Cf_tau, Cf_z] fields to [CL, CD, CM].

Source code in flowdesign/surrogate/aero_transformer_surrogate.py
def fields_to_coefficients(
    self,
    fields: torch.Tensor,
    geometry: SuperWingGeometry | None = None,
) -> torch.Tensor:
    """
    Convert predicted [Cp, Cf_tau, Cf_z] fields to [CL, CD, CM].
    """
    if fields.dim() != 4 or fields.shape[1] < 3:
        raise ValueError(f"Expected fields [B,3,H,W], got {tuple(fields.shape)}")
    if geometry is not None:
        batch = fields.shape[0]
        ref_area = geometry.ref_area.to(dtype=fields.dtype, device=fields.device)
        if self.ref_area_override is not None:
            ref_area = torch.full((batch,), self.ref_area_override, dtype=fields.dtype, device=fields.device)
        aoa = torch.full((batch,), self.alpha, dtype=fields.dtype, device=fields.device)
        return _integrate_surface_fields(
            origingeom=geometry.origingeom.to(dtype=fields.dtype, device=fields.device),
            fields=fields,
            aoa=aoa,
            ref_area=ref_area,
            ref_chord=geometry.ref_chord.to(dtype=fields.dtype, device=fields.device),
            xref=geometry.xref.to(dtype=fields.dtype, device=fields.device),
            yref=geometry.yref.to(dtype=fields.dtype, device=fields.device),
        )

    if not self.allow_proxy_coefficients:
        raise ValueError(
            "fields_to_coefficients() requires SuperWingGeometry for real surface integration. "
            "Pass allow_proxy_coefficients=True only for debugging."
        )

    cp = fields[:, 0]
    cf_tau = fields[:, 1]
    cf_z = fields[:, 2]

    alpha = torch.as_tensor(self.alpha, dtype=fields.dtype, device=fields.device)
    alpha_rad = torch.deg2rad(alpha)
    pressure_lift = -cp.mean(dim=(1, 2)) * torch.cos(alpha_rad)
    friction_drag = cf_tau.abs().mean(dim=(1, 2)) + 0.1 * cf_z.abs().mean(dim=(1, 2))
    pressure_drag = cp.abs().mean(dim=(1, 2)) * torch.sin(alpha_rad).abs()

    ref_area = self.ref_area_override if self.ref_area_override is not None else 1.0
    cl = pressure_lift / max(ref_area, 1e-8)
    cd = self.cd_bias + (friction_drag + pressure_drag) / max(ref_area, 1e-8)
    cm = (cp * torch.linspace(0.0, 1.0, cp.shape[-1], device=cp.device)).mean(
        dim=(1, 2)
    )
    return torch.stack([cl, cd, cm], dim=1)

WingPDETransformer

Bases: Module

Compact, upstream-compatible ATsurf_S PDE transformer.

Upstream source: YangYunjia/floGen flowvae.base_model.pdet.pde_transformer.PDEImpl. The ATsurf_S checkpoint maps to depth=[2,5,8,5,2], hidden_size=16, max_hidden_size=64, num_heads=8, inj_active=True, and condition [AoA, Mach].

Source code in flowdesign/surrogate/aerotransformer_model.py
class WingPDETransformer(nn.Module):
    """
    Compact, upstream-compatible ATsurf_S PDE transformer.

    Upstream source: YangYunjia/floGen flowvae.base_model.pdet.pde_transformer.PDEImpl.
    The ATsurf_S checkpoint maps to depth=[2,5,8,5,2], hidden_size=16,
    max_hidden_size=64, num_heads=8, inj_active=True, and condition [AoA, Mach].
    """

    def __init__(
        self,
        fun_dim: int = 3,
        out_dim: int = 3,
        n_hidden: int = 16,
        n_layers: int = 5,
        mlp_ratio: int = 4,
        patch_size: tuple[int, int] | list[int] | int = (4, 4),
        window_size: tuple[int, int] | list[int] | int = (8, 8),
        type_cond: str = "inj",
    ):
        super().__init__()
        if type_cond != "inj":
            raise ValueError("ATsurf_S checkpoint expects injected [AoA, Mach] conditioning.")
        if isinstance(patch_size, (tuple, list)):
            patch_size = int(patch_size[0])
        if isinstance(window_size, (tuple, list)):
            window_size = int(window_size[0])

        self.in_channels = fun_dim
        self.out_channels = out_dim
        self.patch_size = patch_size
        self.num_encoder_layers = 2
        self.hidden_size_layers = [n_hidden, n_hidden * 2, n_hidden * 4]

        depth = [2, n_layers, 8, n_layers, 2]
        num_heads = 8

        self.x_embedder = SimplePatchEmbed(fun_dim, n_hidden, patch_size)
        for idx, hidden in enumerate(self.hidden_size_layers):
            setattr(self, f"c_embedder_{idx}", mlp(2, hidden, [hidden]))

        self.encoder_level_0 = PDEStage(n_hidden, depth[0], num_heads, window_size, mlp_ratio)
        self.down0_1 = Downsample(n_hidden, keep_dim=False)
        self.encoder_level_1 = PDEStage(n_hidden * 2, depth[1], num_heads, window_size, mlp_ratio)
        self.down1_2 = Downsample(n_hidden * 2, keep_dim=False)
        self.latent = PDEStage(n_hidden * 4, depth[2], num_heads, window_size, mlp_ratio)

        self.up1_0 = Upsample(n_hidden * 2, keep_dim=False)
        self.reduce_chan_level0 = nn.Conv2d(2 * n_hidden, n_hidden * 2, kernel_size=1)
        self.decoder_level_0 = PDEStage(n_hidden * 2, depth[3], num_heads, window_size, mlp_ratio)

        self.up2_1 = Upsample(n_hidden * 4, keep_dim=False)
        self.reduce_chan_level1 = nn.Conv2d(n_hidden * 4, n_hidden * 2, kernel_size=1)
        self.decoder_level_1 = PDEStage(n_hidden * 2, depth[4], num_heads, window_size, mlp_ratio)

        hidden_size_out = n_hidden * 2
        self.output = nn.Conv2d(hidden_size_out, hidden_size_out, kernel_size=3, stride=1, padding=1)
        self.final_layer = FinalLayer(hidden_size_out, out_dim * patch_size * patch_size, is_adp=True)

    def forward(self, x: torch.Tensor, condition: torch.Tensor) -> torch.Tensor:
        condition = condition[:, :2].to(dtype=x.dtype, device=x.device)
        x = self.x_embedder(x)
        emb_list = [
            getattr(self, f"c_embedder_{idx}")(condition)
            for idx in range(self.num_encoder_layers + 1)
        ]

        residuals = []
        for idx, emb in enumerate(emb_list[:-1]):
            enc = getattr(self, f"encoder_level_{idx}")(x, emb)
            residuals.append(enc)
            x = getattr(self, f"down{idx}_{idx + 1}")(enc)

        x = self.latent(x, emb_list[-1])

        x = self.up2_1(x)
        x = torch.cat([x, residuals[1]], dim=1)
        x = self.reduce_chan_level1(x)
        x = self.decoder_level_1(x, emb_list[1])

        x = self.up1_0(x)
        x = torch.cat([x, residuals[0]], dim=1)
        x = self.reduce_chan_level0(x)
        x = self.decoder_level_0(x, emb_list[1])

        x = self.output(x)
        x = self.final_layer(x, emb_list[1])
        x = x.permute(0, 2, 3, 1)
        x = x.reshape(x.shape[:3] + (self.patch_size, self.patch_size, self.out_channels))
        height, width = x.shape[1], x.shape[2]
        x = torch.einsum("nhwpqc->nchpwq", x)
        return x.reshape(-1, self.out_channels, height * self.patch_size, width * self.patch_size)