Source code for fastoad.models.aerodynamics.external.xfoil.xfoil_polar

"""
This module launches XFOIL computations
"""
#  This file is part of FAST-OAD : A framework for rapid Overall Aircraft Design
#  Copyright (C) 2021 ONERA & ISAE-SUPAERO
#  FAST is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.

import logging
import os
import os.path as pth
import shutil
from importlib.resources import path
from pathlib import Path
from tempfile import TemporaryDirectory

import numpy as np
from openmdao.components.external_code_comp import ExternalCodeComp
from openmdao.utils.file_wrap import InputFileGenerator

from fastoad._utils.resource_management.copy import copy_resource
from fastoad.models.aerodynamics.external.xfoil import xfoil699
from fastoad.models.geometry.profiles import get_profile
from . import resources

OPTION_RESULT_POLAR_FILENAME = "result_polar_filename"
OPTION_RESULT_FOLDER_PATH = "result_folder_path"
OPTION_PROFILE_NAME = "profile_name"
OPTION_XFOIL_EXE_PATH = "xfoil_exe_path"
OPTION_ALPHA_START = "alpha_start"
OPTION_ALPHA_END = "alpha_end"
OPTION_ITER_LIMIT = "iter_limit"

DEFAULT_2D_CL_MAX = 1.9

_INPUT_FILE_NAME = "polar_session.txt"
_STDOUT_FILE_NAME = "polar_calc.log"
_STDERR_FILE_NAME = "polar_calc.err"
_TMP_PROFILE_FILE_NAME = "in"  # as short as possible to avoid problems of path length
_TMP_RESULT_FILE_NAME = "out"  # as short as possible to avoid problems of path length

XFOIL_EXE_NAME = "xfoil.exe"  # name of embedded XFoil executable
DEFAULT_PROFILE_FILENAME = "BACJ.txt"

_LOGGER = logging.getLogger(__name__)

_XFOIL_PATH_LIMIT = 64


[docs]class XfoilPolar(ExternalCodeComp): """ Runs a polar computation with XFOIL and returns the 2D max lift coefficient """ _xfoil_output_names = ["alpha", "CL", "CD", "CDp", "CM", "Top_Xtr", "Bot_Xtr"] """Column names in XFOIL polar result"""
[docs] def initialize(self): self.options.declare(OPTION_XFOIL_EXE_PATH, default="", types=str, allow_none=True) self.options.declare(OPTION_PROFILE_NAME, default="BACJ.txt", types=str) self.options.declare(OPTION_RESULT_FOLDER_PATH, default="", types=str) self.options.declare(OPTION_RESULT_POLAR_FILENAME, default="polar_result.txt", types=str) self.options.declare(OPTION_ALPHA_START, default=0.0, types=float) self.options.declare(OPTION_ALPHA_END, default=30.0, types=float) self.options.declare(OPTION_ITER_LIMIT, default=500, types=int)
[docs] def setup(self): self.add_input("xfoil:reynolds", val=np.nan) self.add_input("xfoil:mach", val=np.nan) self.add_input("data:geometry:wing:thickness_ratio", val=np.nan) self.add_output("xfoil:CL_max_2D") self.declare_partials("*", "*", method="fd")
[docs] def compute(self, inputs, outputs): # Create result folder first (if it must fail, let it fail as soon as possible) result_folder_path = self.options[OPTION_RESULT_FOLDER_PATH] if result_folder_path != "": os.makedirs(result_folder_path, exist_ok=True) # Get inputs reynolds = inputs["xfoil:reynolds"] mach = inputs["xfoil:mach"] thickness_ratio = inputs["data:geometry:wing:thickness_ratio"] # Pre-processing (populating temp directory) ----------------------------------------------- # XFoil exe tmp_directory = self._create_tmp_directory() if self.options[OPTION_XFOIL_EXE_PATH]: # if a path for Xfoil has been provided, simply use it self.options["command"] = [self.options[OPTION_XFOIL_EXE_PATH]] else: # otherwise, copy the embedded resource in tmp dir copy_resource(xfoil699, XFOIL_EXE_NAME, tmp_directory.name) self.options["command"] = [pth.join(tmp_directory.name, XFOIL_EXE_NAME)] # I/O files self.stdin = pth.join(tmp_directory.name, _INPUT_FILE_NAME) self.stdout = pth.join(tmp_directory.name, _STDOUT_FILE_NAME) self.stderr = pth.join(tmp_directory.name, _STDERR_FILE_NAME) # profile file tmp_profile_file_path = pth.join(tmp_directory.name, _TMP_PROFILE_FILE_NAME) profile = get_profile( file_name=self.options[OPTION_PROFILE_NAME], thickness_ratio=thickness_ratio ).get_sides() np.savetxt( tmp_profile_file_path, profile.to_numpy(), fmt="%.15f", delimiter=" ", header="Wing", comments="", ) # standard input file tmp_result_file_path = pth.join(tmp_directory.name, _TMP_RESULT_FILE_NAME) parser = InputFileGenerator() with path(resources, _INPUT_FILE_NAME) as input_template_path: parser.set_template_file(input_template_path) parser.set_generated_file(self.stdin) # Fills numeric values parser.mark_anchor("RE") parser.transfer_var(float(reynolds), 1, 1) parser.mark_anchor("M") parser.transfer_var(float(mach), 1, 1) parser.mark_anchor("ITER") parser.transfer_var(self.options[OPTION_ITER_LIMIT], 1, 1) parser.mark_anchor("ASEQ") parser.transfer_var(self.options[OPTION_ALPHA_START], 1, 1) parser.transfer_var(self.options[OPTION_ALPHA_END], 2, 1) # Fills string values # If a provide path contains the string that is used as next anchor, the process # will fail. Doing these replacements at the end prevent this to happen. parser.reset_anchor() parser.mark_anchor("LOAD") parser.transfer_var(tmp_profile_file_path, 1, 1) parser.mark_anchor("PACC", -2) parser.transfer_var(tmp_result_file_path, 1, 1) parser.generate() # Run XFOIL -------------------------------------------------------------------------------- self.options["external_input_files"] = [self.stdin, tmp_profile_file_path] self.options["external_output_files"] = [tmp_result_file_path] super().compute(inputs, outputs) # Post-processing -------------------------------------------------------------------------- result_array = self._read_polar(tmp_result_file_path) outputs["xfoil:CL_max_2D"] = self._get_max_cl(result_array["alpha"], result_array["CL"]) # Getting output files if needed if self.options[OPTION_RESULT_FOLDER_PATH]: if pth.exists(tmp_result_file_path): polar_file_path = pth.join( result_folder_path, self.options[OPTION_RESULT_POLAR_FILENAME] ) shutil.move(tmp_result_file_path, polar_file_path) if pth.exists(self.stdin): stdin_file_path = pth.join(result_folder_path, _INPUT_FILE_NAME) shutil.move(self.stdin, stdin_file_path) if pth.exists(self.stdout): stdout_file_path = pth.join(result_folder_path, _STDOUT_FILE_NAME) shutil.move(self.stdout, stdout_file_path) if pth.exists(self.stderr): stderr_file_path = pth.join(result_folder_path, _STDERR_FILE_NAME) shutil.move(self.stderr, stderr_file_path) tmp_directory.cleanup()
@staticmethod def _read_polar(xfoil_result_file_path: str) -> np.ndarray: """ :param xfoil_result_file_path: :return: numpy array with XFoil polar results """ if os.path.isfile(xfoil_result_file_path): dtypes = [(name, "f8") for name in XfoilPolar._xfoil_output_names] result_array = np.genfromtxt(xfoil_result_file_path, skip_header=12, dtype=dtypes) return result_array _LOGGER.error("XFOIL results file not found") return np.array([]) @staticmethod def _get_max_cl(alpha: np.ndarray, lift_coeff: np.ndarray) -> float: """ :param alpha: :param lift_coeff: CL :return: max CL if enough alpha computed, or default value otherwise """ if len(alpha) > 0 and max(alpha) >= 5.0: return max(lift_coeff) _LOGGER.warning("2D CL max not found. Using default value (%s)", DEFAULT_2D_CL_MAX) return DEFAULT_2D_CL_MAX @staticmethod def _create_tmp_directory() -> TemporaryDirectory: # Dev Note: XFOIL fails if length of provided file path exceeds 64 characters. # Changing working directory to the tmp dir would allow to just provide file name, # but it is not really safe (at least, it does mess with the coverage report). # Then the point is to get a tmp directory with a short path. # On Windows, the default (user-dependent) tmp dir can exceed the limit. # Therefore, as a second choice, tmp dir is created as close of user home # directory as possible. tmp_base_candidates = [None, pth.join(str(Path.home()), ".fast")] tmp_candidates = [] for tmp_base_path in tmp_base_candidates: if tmp_base_path is not None: os.makedirs(tmp_base_path, exist_ok=True) tmp_directory = TemporaryDirectory(dir=tmp_base_path) tmp_candidates.append(tmp_directory.name) tmp_profile_file_path = pth.join(tmp_directory.name, _TMP_PROFILE_FILE_NAME) tmp_result_file_path = pth.join(tmp_directory.name, _TMP_RESULT_FILE_NAME) if max(len(tmp_profile_file_path), len(tmp_result_file_path)) <= _XFOIL_PATH_LIMIT: # tmp_directory is OK. Stop there break # tmp_directory has a too long path. Erase and continue... tmp_directory.cleanup() if max(len(tmp_profile_file_path), len(tmp_result_file_path)) > _XFOIL_PATH_LIMIT: raise IOError( "Could not create a tmp directory where file path will respect XFOIL " "limitation (%i): tried %s" % (_XFOIL_PATH_LIMIT, tmp_candidates) ) return tmp_directory