From 3b935a0e0aa431e54fe677eb42f84e71727639c6 Mon Sep 17 00:00:00 2001
From: Paul Dechamps <paul.dechamps@uliege.be>
Date: Sun, 29 Sep 2024 10:56:00 +0200
Subject: [PATCH] (feat) Added MDAO api for aerodynamics optimization

The api features 3 components: The solver, the geometry and the initial mesh. The api is tested in a aerodynamic shape case where the NACA0012 is constrained at 0deg AoA with cl=0.40 and the objective function to minimize is the drag wrt the max camber and the max camber position. The test case ends with a final shape close to the NACA4412 (camber = 35% & max camber pos = 41%)
---
 blast/api/mdaAPI.py         | 294 ++++++++++++++++++++++++++++++++++++
 blast/mdao/naca0012_mdao.py | 201 ++++++++++++++++++++++++
 2 files changed, 495 insertions(+)
 create mode 100644 blast/api/mdaAPI.py
 create mode 100644 blast/mdao/naca0012_mdao.py

diff --git a/blast/api/mdaAPI.py b/blast/api/mdaAPI.py
new file mode 100644
index 0000000..43fc618
--- /dev/null
+++ b/blast/api/mdaAPI.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+# Copyright 2024 University of Liège
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+## BLASTER api for openMDAO
+# Paul Dechamps
+
+import numpy as np
+import openmdao.api as om
+import blast
+import blast.utils as viscUtils
+
+# Solver openMDAO object
+class BlasterSolver(om.ExplicitComponent):
+    """OpenMDAO component for the Blaster solver
+
+    Parameters
+    ----------
+    - cfg {dict} -- Configuration dictionary for the VII coupling
+    - icfg {dict} -- Configuration dictionary for the inviscid solver interface
+    - iSolverName {str} -- Inviscid solver name (default: 'DART')
+
+    Inputs
+    ------
+    - aoa {float} -- Angle of attack
+    - x_aero {np.array} -- Mesh coordinates
+
+    Outputs
+    -------
+    - cl {float} -- Lift coefficient
+    - cd {float} -- Drag coefficient
+    """
+
+    def __init__(self, cfg, icfg, iSolverName='DART'):
+        super().__init__()
+
+        self.nCpt = 0
+
+        if iSolverName == 'DART':
+            from blast.interfaces.dart.DartInterface import DartInterface as interface
+        else:
+            raise RuntimeError('Solver '+iSolverName+' currently not implemented')
+
+        for i in range(len(cfg['xtrF'])):
+            if cfg['xtrF'][i] is None:
+                cfg['xtrF'][i] = -1
+        self.vsol = blast.Driver(cfg['Re'], cfg['Minf'], cfg['CFL0'], cfg['nSections'], xtrF_top=cfg['xtrF'][0], xtrF_bot=cfg['xtrF'][1], _span=cfg['span'], _verbose=cfg['Verb'])
+
+        self.isol = interface(icfg, self.vsol, cfg)
+
+        # Coupler
+        import blast.coupler as blastCoupler
+        self.coupler = blastCoupler.Coupler(self.isol, self.vsol, _maxCouplIter=cfg['couplIter'], _couplTol=cfg['couplTol'], _iterPrint=cfg['iterPrint'], _resetInv=cfg['resetInv'])
+
+        # Adjoint
+        self.adjointSolver = blast.CoupledAdjoint(self.isol.adjointSolver, self.vsol)
+
+        # Single coupler run to initialize viscous sections (which depend on size of upper/lower sides)
+        self.coupler.run(write=False)
+        self.coupler.reset()
+        print('object created')
+    
+    def setup(self):
+        self.add_input('aoa', val=0.0)
+        self.add_input('x_aero', val=np.zeros(self.isol.iobj['bnd'].nodes.size() * self.isol.getnDim()))
+        self.add_output('cl', val=0.0)
+        self.add_output('cd', val=0.0)
+    
+    def setup_partials(self):
+        self.declare_partials('cl', 'aoa', method='exact')
+        self.declare_partials('cd', 'aoa', method='exact')
+        self.declare_partials('cl', 'x_aero', method='exact')
+        self.declare_partials('cd', 'x_aero', method='exact')
+
+
+    def compute_partials(self, inputs, partials, discrete_inputs=None):
+        """Compute partial derivatives using the adjoint solver
+        """
+        # Run adjoint solver
+        self.isol.iobj['adj'].run()
+        self.adjointSolver.reset()
+        self.adjointSolver.run()
+
+        # AoA derivatives
+        partials['cl', 'aoa'] = self.adjointSolver.tdCl_AoA
+        partials['cd', 'aoa'] = self.adjointSolver.tdCd_AoA
+
+        # Mesh derivative
+        nDim = self.isol.getnDim()
+        # Get mesh matrix
+        for inod, n in enumerate(self.isol.iobj['bnd'].nodes):
+            for idim in range(nDim):
+                partials['cl', 'x_aero'][0][inod*nDim + idim] = self.adjointSolver.tdCl_x[n.row][idim]
+                partials['cd', 'x_aero'][0][inod*nDim + idim] = self.adjointSolver.tdCd_x[n.row][idim]
+    
+    def compute(self, inputs, outputs):
+        """Update the mesh, aoa and run the solver
+        """
+        nDim = self.isol.getnDim()
+        # Modify surface and deform mesh
+        self.isol.iobj['mrf'].savePos()
+        for inod, n in enumerate(self.isol.iobj['bnd'].nodes):
+            for idim in range(nDim):
+                n.pos[idim] = inputs['x_aero'][inod*nDim + idim]
+        self.isol.iobj['mrf'].deform()
+
+        # Update viscous mesh
+        self.isol.updateMesh()
+
+        # Update angle of attack
+        aoa = inputs['aoa'][0]
+        self.isol.setAoA(aoa*np.pi/180) # aoa is given in deg
+
+        # Reset coupler
+        self.coupler.reset()
+
+        # Run forward problem
+        self.coupler.run(write=False)
+        outputs['cl'] = self.isol.getCl()
+        outputs['cd'] = self.isol.getCd() + self.vsol.Cdf
+
+        # Write results
+        self.isol.writeCp(sfx='_'+str(self.nCpt))
+        viscUtils.getSolution(self.isol.sec, write=True, toW=['x', 'y', 'cf'], sfx='_'+str(self.nCpt))
+        self.nCpt += 1
+
+    def getBoundaryMesh(self):
+        """Initial surface mesh coordinates
+        """
+        return BlasterMesh(nDim=self.isol.getnDim(), bnd=self.isol.iobj['bnd'])
+    
+    def getBoundaryGeometry(self):
+        """Airfoil geometry
+        """
+        return BlasterGeometry(nDim=self.isol.getnDim(), nNodes=self.isol.iobj['bnd'].nodes.size(), nodeRows=self.isol.vBnd[0][0].connectListNodes)
+
+# Surface mesh
+class BlasterMesh(om.IndepVarComp):
+    """Initial 2D surface mesh coordinates
+    
+    Parameters
+    ----------
+    - nDim {int} -- Number of dimensions
+    - bnd {object} -- Boundary object containing the mesh
+
+    Outputs
+    -------
+    - x_aero0 {np.array} -- Initial mesh coordinates
+    """
+
+    def initialize(self):
+        self.options.declare('nDim')
+        self.options.declare('bnd', recordable=False)
+    
+    def setup(self):
+        bnd = self.options['bnd']
+        nDim = self.options['nDim']
+        x_aero0 = np.zeros(bnd.nodes.size() * nDim)
+        for inod, n in enumerate(bnd.nodes):
+            for idim in range(nDim):
+                x_aero0[inod*nDim + idim] = n.pos[idim]
+        self.add_output('x_aero0', val=x_aero0)
+
+class BlasterGeometry(om.ExplicitComponent):
+    """Airfoil parametrization as a NACA 4-digit airfoil.
+    Uses the NACA 4-digit airfoil parametrization to generate an airfoil from a given x
+    distribution.
+    Parameters become tau (thickness), eps (max camber) and p (max camber position).
+
+    Parameters
+    ----------
+    - nDim {int} -- Number of dimensions
+    - nNodes {int} -- Number of nodes
+    - nodeRows {np.array} -- Nodal indexes
+
+    Inputs
+    ------
+    - x_aero0 {np.array} -- Initial mesh
+    - tau {float} -- Airfoil thickness
+    - eps {float} -- Airfoil max camber
+    - p {float} -- Airfoil max camber position
+
+    Outputs
+    -------
+    - x_aero_out {np.array} -- Airfoil coordinates
+    """
+
+    def initialize(self):
+        self.options.declare('nDim')
+        self.options.declare('nNodes')
+        self.options.declare('nodeRows')
+
+    def setup(self):
+        nNodes = self.options['nNodes']
+        nDim = self.options['nDim']
+        self.add_input('x_aero0', np.zeros(nNodes*nDim)) # Initial mesh
+        self.add_input('tau', val = 0.12, desc='Airfoil thickness')
+        self.add_input('eps', val = 0., desc='Airfoil max camber')
+        self.add_input('p', val = 0., desc='Airfoil max camber position')
+        self.add_output('x_aero_out', val = np.zeros(nNodes*nDim))
+
+    def setup_partials(self):
+        self.declare_partials('x_aero_out', 'tau', method='fd')
+        self.declare_partials('x_aero_out', 'eps', method='fd')
+        self.declare_partials('x_aero_out', 'p', method='fd')
+    
+    def compute(self, inputs, outputs):
+        """Compute airfoil coordinates
+        Points are sorted in seilig format to identify upper and lower surfaces.
+        Then the NACA 4-digit airfoil is generated and the points are sorted back
+        to the original mesh format."""
+
+        x_aero0 = inputs['x_aero0']
+        tau = inputs['tau'][0]
+        eps = inputs['eps'][0]
+        p = inputs['p'][0]
+        nDim = self.options['nDim']
+
+        # Retreive selig coordinates
+        x_seilig = np.zeros(len(self.options['nodeRows']))
+        for i, val in enumerate(self.options['nodeRows']):
+            x_seilig[i] = x_aero0[val*nDim]
+        # Generate airfoil
+        # Format tau eps and p with 2 digits after coma
+        print(f"tau: {tau:.10f}, eps: {eps:.10f}, p: {p:.10f}")
+        xb, yb = self.__generateAirfoil(x_seilig, tau, eps, p)
+        # Go back to normal mesh
+        x_aero_out = np.zeros(len(x_aero0))
+        for i, val in enumerate(self.options['nodeRows']):
+            x_aero_out[val*nDim] = xb[i]
+            x_aero_out[val*nDim+1] = yb[i]
+        outputs['x_aero_out'] = x_aero_out
+    
+    def __generateAirfoil(self, x, tau, eps, p):
+        """Generate NACA 4-digit airfoil from x coordinates points
+        
+        Arguments
+        ---------
+        - x {np.array} -- x coordinates sorted in seilig format
+        - tau {float} -- Airfoil thickness
+        - eps {float} -- Airfoil max camber
+        - p {float} -- Airfoil max camber position
+        
+        Returns
+        -------
+        - tuple -- x and y coordinates
+        """
+
+        T = 10 * tau * (
+          0.2969 * np.sqrt(x) 
+        - 0.1260 * (x) 
+        - 0.3537 * (x)**2 
+        + 0.2843 * (x)**3 
+        - 0.1015 * (x)**4) # Thickness
+
+        YBar     = np.zeros(len(T))
+        dYBar_dx = np.zeros(len(T))
+        Y        = np.zeros(len(T))
+        xB       = np.zeros(len(T))
+        yB       = np.zeros(len(T))
+
+        for i, xi in enumerate(x):
+            # Camber
+            if xi < p:
+                YBar[i]     = ((eps * xi)/(p**2)) * (2 * p - xi)
+                dYBar_dx[i] = (2 * eps)/(p**2) * (p - xi)
+            elif xi >= p:
+                YBar[i]     = (eps * (1 - xi)/((1-p)**2)) * (1 + xi - 2 * p)
+                dYBar_dx[i] = (2 * eps)/((1 - p)**2) * (p - xi)
+
+            # Final x & y coordinates
+            if i >= len(x)/2 - 1: 
+                # Lower surface
+                yB[i] = YBar[i] - 0.5 * T[i] * np.cos(np.arctan(dYBar_dx[i]))
+                xB[i] = x[i]    + 0.5 * T[i] * np.sin(np.arctan(dYBar_dx[i]))
+            else: 
+                # Upper surface
+                yB[i] = YBar[i] + 0.5 * T[i] * np.cos(np.arctan(dYBar_dx[i]))
+                xB[i] = x[i]    - 0.5 * T[i] * np.sin(np.arctan(dYBar_dx[i]))    
+        return (xB, yB)
diff --git a/blast/mdao/naca0012_mdao.py b/blast/mdao/naca0012_mdao.py
new file mode 100644
index 0000000..ec34a6e
--- /dev/null
+++ b/blast/mdao/naca0012_mdao.py
@@ -0,0 +1,201 @@
+import blast.utils as viscUtils
+import numpy as np
+
+from fwk.wutils import parseargs
+import fwk
+from fwk.testing import *
+from fwk.coloring import ccolors
+
+from blast.api.mdaAPI import BlasterSolver
+
+import openmdao.api as om
+
+import math
+
+def cfgInviscid(nthrds, verb):
+    import os.path
+    # Parameters
+    return {
+    # Options
+    'scenario' : 'aerodynamic',
+    'task' : 'optimization',
+    'Threads' : nthrds, # number of threads
+    'Verb' : verb, # verbosity
+    # Model (geometry or mesh)
+    'File' : os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + '/models/dart/n0012.geo', # Input file containing the model
+    'Pars' : {'xLgt' : 50, 'yLgt' : 50, 'msF' : 30, 'msTe' : 0.01, 'msLe' : 0.0075}, # parameters for input file model
+    'Dim' : 2, # problem dimension
+    'Format' : 'gmsh', # save format (vtk or gmsh)
+    # Markers
+    'Fluid' : 'field', # name of physical group containing the fluid
+    'Farfield' : ['upstream', 'farfield', 'downstream'], # LIST of names of physical groups containing the farfield boundaries (upstream/downstream should be first/last element)
+    'Wing' : 'airfoil', # LIST of names of physical groups containing the lifting surface boundary
+    'Wake' : 'wake', # LIST of names of physical group containing the wake
+    'WakeTip' : 'wakeTip', # LIST of names of physical group containing the edge of the wake
+    'Te' : 'te', # LIST of names of physical group containing the trailing edge
+    'dbc' : False,
+    'Upstream' : 'upstream',
+    # Freestream
+    'M_inf' : 0.2, # freestream Mach number
+    'AoA' : 2., # freestream angle of attack
+    # Geometry
+    'S_ref' : 1., # reference surface length
+    'c_ref' : 1., # reference chord length
+    'x_ref' : .25, # reference point for moment computation (x)
+    'y_ref' : 0.0, # reference point for moment computation (y)
+    'z_ref' : 0.0, # reference point for moment computation (z)
+    # Numerical
+    'LSolver' : 'GMRES', # inner solver (Pardiso, MUMPS or GMRES)
+    'G_fill' : 2, # fill-in factor for GMRES preconditioner
+    'G_tol' : 1e-5, # tolerance for GMRES
+    'G_restart' : 50, # restart for GMRES
+    'Rel_tol' : 1e-6, # relative tolerance on solver residual
+    'Abs_tol' : 1e-8, # absolute tolerance on solver residual
+    'Max_it' : 20 # solver maximum number of iterations
+    }
+
+def cfgBlast(verb):
+    return {
+        'Re' : 1e7,                 # Freestream Reynolds number
+        'Minf' : 0.2,               # Freestream Mach number (used for the computation of the time step only)
+        'CFL0' : 1.,                 # Inital CFL number of the calculation
+        'Verb': verb,               # Verbosity level of the solver
+        'couplIter': 50,            # Maximum number of iterations
+        'couplTol' : 1e-3,          # Tolerance of the VII methodology
+        'iterPrint': 5,             # int, number of iterations between outputs
+        'resetInv' : True,          # bool, flag to reset the inviscid calculation at every iteration.
+        'sections' : [0],           # List of sections for boundary layer calculation
+        'xtrF' : [0.05, 0.05],       # Forced transition location
+        'interpolator' : 'Matching',# Interpolator for the coupling
+        'nSections' : 1,            # Number of sections in the domain
+        'span' : 1.0                # Wing span
+    }
+
+class groupMDA(om.Group):
+    def setup(self):
+        args = parseargs()
+        icfg = cfgInviscid(args.k, args.verb)
+        vcfg = cfgBlast(args.verb)
+
+        #cycle = self.add_subsystem('cycle', om.Group())
+
+        # Add the MDAO components
+        blaster = BlasterSolver(vcfg, icfg)
+
+        # Design variables box
+        self.add_subsystem('dvs', om.IndepVarComp(), promotes=['*'])
+        self.add_subsystem('mesh', blaster.getBoundaryMesh())
+        self.add_subsystem('geometry', blaster.getBoundaryGeometry())
+        self.add_subsystem('solver', blaster)
+
+        # Add objective function
+        self.add_subsystem('obj_cmp', om.ExecComp('obj = cd'), promotes=['obj', 'cd'])
+
+        # Connect meshes
+        self.connect('mesh.x_aero0', 'geometry.x_aero0')
+        self.connect('geometry.x_aero_out', 'solver.x_aero')
+
+        #nonlinear_solver = om.NewtonSolver(solve_subsystems=False)
+        self.linear_solver = om.DirectSolver()
+    
+    def configure(self):
+        # Design vars
+        self.dvs.add_output('aoa', val=0.0)
+        self.connect('aoa', 'solver.aoa')
+        #self.add_design_var('aoa', lower=0.0, upper=3.0)
+
+        self.dvs.add_output('tau', val=0.12)
+        self.connect('tau', 'geometry.tau')
+        #self.add_design_var('tau', lower=0.10, upper=0.2)
+
+        self.dvs.add_output('eps', val=0.0)
+        self.connect('eps', 'geometry.eps')
+        self.add_design_var('eps', lower=0.0, upper=0.05)
+
+        self.dvs.add_output('p', val=0.4)
+        self.connect('p', 'geometry.p')
+        self.add_design_var('p', lower=0.35, upper=0.45)
+
+        self.add_constraint('solver.cl', equals=0.4)
+
+        #self.connect('solver.cl', 'cl')
+        self.connect('solver.cd', 'cd')
+        self.add_objective('obj')
+
+def main():
+    # Timer.
+    tms = fwk.Timers()
+    tms['total'].start()
+
+    prob = om.Problem()
+    prob.model = groupMDA()
+
+    prob.driver = om.ScipyOptimizeDriver()
+    prob.driver.options['optimizer'] = 'SLSQP'
+    prob.driver.options['tol'] = 1e-3
+
+    recorder = om.SqliteRecorder('reports/BlasterSolver_file.sql')
+
+    prob.driver.add_recorder(recorder)
+    prob.driver.recording_options['record_objectives'] = True
+    prob.driver.recording_options['record_desvars'] = True
+    prob.driver.recording_options['includes'] = ['aoa', 'solver.cl', 'solver.cd', 'mesh.x_aero0', 'geometry.x_aero_out']
+
+    prob.setup()
+    #prob.check_partials(includes=['cl', 'aoa'], compact_print=True)
+    prob.run_driver()
+
+    # Instantiate your CaseReader
+    cr = om.CaseReader(prob.get_reports_dir() / "../BlasterSolver_file.sql")
+    # Isolate "problem" as your source
+    driver_cases = cr.list_cases('driver', out_stream=None)
+
+    # Get evolution of f and x
+    aoa_evol = []
+    cl_evol = []
+    cd_evol = []
+    shape = []
+    shape0 = []
+
+    for c in driver_cases:
+        case = cr.get_case(c)
+        aoa_evol.append(case.get_val('aoa')[0]*180/np.pi)
+        cl_evol.append(case.get_val('solver.cl')[0])
+        cd_evol.append(case.get_val('solver.cd')[0])
+        shape0.append(case.get_val('mesh.x_aero0'))
+        shape.append(case.get_val('geometry.x_aero_out'))
+
+    print('success')
+    print('aoa:', prob['aoa']*np.pi/180)
+    print('cl:', prob['solver.cl'])
+    print('cd:', prob['solver.cd'])
+    print('obj:', prob['obj'])
+
+    # Recover selig format
+    shape0_selig = np.array(shape0[0])
+    shape1_selig = np.array(shape[-1])
+    for i, val in enumerate(prob.model.solver.isol.vBnd[0][0].connectListNodes):
+        for j in range(prob.model.solver.isol.getnDim()):
+            shape0_selig[i*prob.model.solver.isol.getnDim()+j] = shape0[0][val*prob.model.solver.isol.getnDim()+j]
+            shape1_selig[i*prob.model.solver.isol.getnDim()+j] = shape[-1][val*prob.model.solver.isol.getnDim()+j]
+    from matplotlib import pyplot as plt
+
+    plt.plot(shape0_selig[0::2], shape0_selig[1::2], '--', color='grey', label='Initial shape - cl = {:.2f} - cd = {:.5f}'.format(cl_evol[0], cd_evol[0]))
+    plt.plot(shape1_selig[0::2], shape1_selig[1::2], '-', color='blue', label='Final shape - cl = {:.2f} - cd = {:.5f}'.format(cl_evol[-1], cd_evol[-1]))
+    plt.legend()
+    plt.axis('equal')
+    plt.show()
+    quit()
+
+
+
+    plt.plot(shape0[0][::2], shape0[0][1::2], 'x', color='grey')
+    for xx in shape:
+        plt.plot(xx[::2], xx[1::2], 'x', color='blue')
+    plt.axis('equal')
+    plt.show()
+    quit()
+
+
+if __name__ == "__main__":
+    main()
-- 
GitLab