Atmosphere Engineering Lab.

CFD最適化システムのクラス設計 — ShapeGenerator / Solver / Evaluator / Optimizer

CFDを使った形状最適化のコードは、放っておくと「メッシュ変形・流体解析・最適化アルゴリズムが1つのスクリプトに全部書いてある」状態になりがちです。 最初は動くのですが、ソルバーを変えたい、最適化手法を差し替えたい、変形方法を増やしたい、といった要求が複雑になってくると、改修範囲がどんどん膨大になっていき、最終的には大きな技術負債になってしまいます。

今回は、こうした形状最適化システムを ShapeGenerator / Solver / Evaluator / Optimizer という4つの責務に分解して設計する考え方を、簡単なクラス設計とクラス図で整理してみます。

最適化ループの全体像

形状最適化は、ざっくり言えば次のループです。

  1. 設計変数 x(形状パラメータ)を決める
  2. x から実際の形状を作る
  3. その形状でCFD解析を行い、目的関数(抗力係数など)を得る
  4. 目的関数をもとに次の x を決める
  5. 収束するまで 1〜4 を繰り返す

このループの中には性質の異なる4つの仕事が混在しています。 「形状を作る仕事」「物理を解く仕事」「目的とする数字を計算する仕事」「次の設計変数を決める仕事」です。 これを1つのスクリプトに詰め込まず、責務ごとに分離するのが今回の設計方針です。

責務分離の方針

役割入力出力関心事
ShapeGeneratorベース形状 base_shape: Shape
設計変数 x: ndarray
変形後形状 shape: Shape形状(ジオメトリ)の変換
Solver形状 shape: Shape解析結果 result: Result物理・数値解法(必要に応じてメッシュ生成を内包)
Evaluator解析結果 result: Result目的関数値 obj: float何を最適化するかの定義
Optimizer設計変数 x: ndarray
目的関数値 objective: float
探索範囲 bounds: tuple[ndarray, ndarray]
次の設計変数 x: ndarray数理最適化アルゴリズム
OptimizationCase初期設計変数 x0: ndarray
探索範囲 bounds: tuple[ndarray, ndarray]
設計結果 design_result: DesignResult4者の連携によるループ制御

各クラスの入出力契約(型)を先に決めてしまい、そこから逸脱しないように実装していきます。

たとえばShapeGeneratorの責務は、ベース形状と設計変数を入力として、変形後形状を作ることに限定されており、ソルバーや最適化の処理には一切関知しません。

このような契約を抽象基底クラス(インターフェース)として定義し、具体的な実装(形状生成の場合はパラメトリックモデリングやFFDなど)はその下にぶら下げます。 これにより手法の切り替えが柔軟にできるようになります。

他のクラスについても、Optimizerをベイズ最適化から遺伝的アルゴリズムに切り替える、目的関数をCdからL/D比に変更する、といった具合に、手法ごとに具体実装クラスを作成して組み替えていくことが可能です。

クラス図

diagram rendering…

OptimizationCase は4つの抽象クラスにのみ依存し(uses)、実装の継承関係(<|--)はそれぞれの階層に閉じています。

図の中段は抽象基底クラス(<<abstract>>)で、ここでOptimizationCase側からの見え方が決まります。 たとえばOptimizationCaseShapeGeneratorが内部でどう変形を計算しているかを知る必要はなく、「ShapeGenerator.generate()に正しくベース形状と設計変数を渡す」ことだけに関心を持てばよい構造になっています。

図の下段が各抽象クラスの具体実装です。 たとえばOpenFOAMSolverでは、_mesh(メッシュ生成)・_set(境界条件などのケース設定)・_run(ソルバー実行)・_parse(結果読み取り)といったOpenFOAM特有の処理をプライベートメソッドとして実装し、solve()の中でこれらをチェーンします。 アンダースコアを先頭につけている通り、これらはOpenFOAMSolver内部に閉じた実装であり、命名や処理をどこまで分離するかは書き手の自由です。 ソルバーによっては_meshを省略したり、_pre_run_postのように独自の分割で実装したりと、粒度は自由に決められます。 OptimizationCaseからはsolve(shape) -> Resultだけを呼び出せばよい形になっています。

クラス設計

ここまでの設計を実際のコードに落とし込むと、以下のようになります。

class ShapeGenerator(ABC):
    @abstractmethod
    def n_design_vars(self) -> int: ...

    @abstractmethod
    def generate(self, base_shape: Shape, x: np.ndarray) -> Shape: ...


class Solver(ABC):
    @abstractmethod
    def solve(self, shape: Shape) -> Result: ...


class Evaluator(ABC):
    @abstractmethod
    def evaluate(self, result: Result) -> float: ...


class Optimizer(ABC):
    @abstractmethod
    def step(
        self,
        x: np.ndarray,
        objective: float,
        bounds: tuple[np.ndarray, np.ndarray],
    ) -> np.ndarray: ...


class OptimizationCase:
    """4つのコンポーネントを束ねるオーケストレーター"""

    def __init__(
        self,
        shape_generator: ShapeGenerator,
        solver: Solver,
        evaluator: Evaluator,
        optimizer: Optimizer,
        base_shape: Shape,
    ):
        self.shape_generator = shape_generator
        self.solver = solver
        self.evaluator = evaluator
        self.optimizer = optimizer
        self.base_shape = base_shape

    def run(
        self, x0: np.ndarray, bounds: tuple[np.ndarray, np.ndarray], n_iter: int
    ) -> DesignResult:
        x = x0
        for _ in range(n_iter):
            shape = self.shape_generator.generate(self.base_shape, x)
            result = self.solver.solve(shape)
            obj = self.evaluator.evaluate(result)
            x = self.optimizer.step(x, obj, bounds)
        design_result = DesignResult(x=x, objective=obj)
        return design_result

Solverの具体実装として、OpenFOAMSolverは次のようになります。

class OpenFOAMSolver(Solver):
    def solve(self, shape: Shape) -> Result:
        case_dir = self._mesh(shape)  # 形状からメッシュを生成
        self._set(case_dir)  # 境界条件などケースを設定
        self._run(case_dir)  # OpenFOAMソルバーを実行
        return self._parse(case_dir)  # 結果ファイルを読み取りResultに変換

    def _mesh(self, shape: Shape) -> Path: ...

    def _set(self, case_dir: Path) -> None: ...

    def _run(self, case_dir: Path) -> None: ...

    def _parse(self, case_dir: Path) -> Result: ...

この設計の効きどころ

  • ソルバーの差し替え: 序盤はXFOILSolverのような軽量ソルバーで最適化ループの動作確認や大域探索を行い、有望な形状が絞り込めてきたらOpenFOAMSolverのような高忠実度ソルバーに差し替えて精密評価する、という段階的なワークフローが取りやすくなります。
  • 目的関数の差し替え: 「まずはCdを最小化」「次はL/D比を最大化」「複数の巡航条件を重み付けした多点目的に変える」といった、実務でも頻繁に発生する要求変更を Evaluator の実装差し替えだけで完結させられます。 Solver・ShapeGenerator・Optimizerのコードは一切触りません。
  • Optimizerの差し替え: 序盤は遺伝的アルゴリズムで大域探索し、有望な領域が見えてきたらCMA-ESで局所的に絞り込む、という2段階の最適化戦略も OptimizationCase の構成を変えるだけで実現できます。
  • テスト容易性: ShapeGenerator / Solver / Evaluator / Optimizer はそれぞれ単体でテスト可能です。 特に Solver は重い処理になりがちなので、フェイク実装(既知の解析解を返すだけのクラス)を用意しておくと、ループ制御やOptimizerのロジックをCFDを実行せずに検証できます。

責務を分けること自体は目新しい話ではありませんが、CFD最適化のように「重い計算」「幾何処理」「数理アルゴリズム」という異質な要素が同居する領域ほど、このインターフェース設計の効果は大きく感じます。