diff --git a/pyavlpackage/CMakeLists.txt b/pyavlpackage/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..ba9acde4f422aa40fde43f4759ff4653b1a29483 --- /dev/null +++ b/pyavlpackage/CMakeLists.txt @@ -0,0 +1,6 @@ +# Add the package to the package list for exporting the target +# and propagate the resulting list back to the parent scope +list( APPEND PYTHON_TARGETS ${CMAKE_CURRENT_LIST_DIR} ) +set( PYTHON_TARGETS ${PYTHON_TARGETS} PARENT_SCOPE ) + +install(DIRECTORY ${CMAKE_CURRENT_LIST_DIR} DESTINATION lib) diff --git a/pyavlpackage/README.md b/pyavlpackage/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5a581c8d007c8e76063264659deb3eb2c51b4655 --- /dev/null +++ b/pyavlpackage/README.md @@ -0,0 +1,11 @@ +# PYAVL Package - Python package for working with AVL (Athena vortex lattice) + +## Usage +Used for Interfacing AVL + +## License +This project is licensed under the GNU General Public License, Version 3 +- see the License.md file for details. + +## Contact +For questions or feedback, please contact contacts@unicado.io diff --git a/pyavlpackage/pyavl/__init__.py b/pyavlpackage/pyavl/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..eb989e1ef2ad5831b6b59efed2649917e9d38f3b --- /dev/null +++ b/pyavlpackage/pyavl/__init__.py @@ -0,0 +1,24 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +from .pyavlcore import * +from .pyavlfileread import * +from .pyavlfilewrite import * +from .pyavlrunner import * \ No newline at end of file diff --git a/pyavlpackage/pyavl/pyavlcore.py b/pyavlpackage/pyavl/pyavlcore.py new file mode 100644 index 0000000000000000000000000000000000000000..645ea2cb2b092a4b0ab72553e830f24e8d9b7d00 --- /dev/null +++ b/pyavlpackage/pyavl/pyavlcore.py @@ -0,0 +1,423 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +PyAvl Core classes +""" +from .pyavlfilewrite import * +from .pyavlfileread import * +from ambiance import Atmosphere + +""" +Geometry classes (.avl) +""" + + +class Header: + def __init__(self, runcase: str="base", mach: float=0.0, sym: list=[0, 0, 0.0], base: list[float]=[1.0, 1.0, 1.0], ref: list[float]=[0.0, 0.0, 0.0]): + """ Initialize avl header - please see avl primer + + Args: + runcase (str, optional): Runcase name. Defaults to "base". + mach (float, optional): mach number. Defaults to 0.0. + sym (list, optional): symmetry elements according to AVL. Defaults to [0, 0, 0.0]. + base (list[float], optional): Basic aerodynamic parameters (Sref, cref, bref). Defaults to [1.0, 1.0, 1.0]. + ref (list[float], optional): Reference cg point. Defaults to [0.0, 0.0, 0.0]. + """ + self.runcase = runcase + self.mach = mach + self.sym = sym + self.base = base + self.ref = ref + self.author = "" + + def write(self, fh) -> None: + """ Write data to file via fh -> filehandler + + Args: + fh (): filehandler/filedescriptor + """ + write_separation_big(fh, "BASE FILE") + write_headline(fh, "Geometric Name") + write_elem_tab(fh, self.runcase, 2) + write_headline(fh, "Mach (normal to wing)") + write_elem_tab(fh, self.mach, 2) + write_headline(fh, "Symmetric properties (iYsym, iZsym, Zsym)") + write_elem_tab(fh, self.sym, 2) + write_headline(fh, "Reference properties Sref, Cref, Bref") + write_elem_tab(fh, self.base, 2) + write_headline(fh, "Reference CG position (X,Y,Z)") + write_elem_tab(fh, self.ref, 2) + write_separation_big(fh, "Begin of Geometry") + +class Control: + def __init__(self, tag="ctrl", gain=1.0, xhinge=0.7, xyzhvec=[0.0, 1.0, 0.0], sgndup=1.0): + self.key = "CONTROL" + self.tag = tag + self.gain = gain + self.xhinge = xhinge + self.xyzhvec = xyzhvec + self.sgndup = sgndup + self.description = ["!name", "gain", "xhinge", "XYZhvec", "SgnDup"] + + def write(self, fh) -> None: + """ Write data to file via fh -> filehandler + + Args: + fh (): filehandler/filedescriptor + """ + write_key(fh, self.key) + write_elem_tab(fh, self.description, 1) + write_elem_tab(fh, self.tag) + write_elem_tab(fh, self.gain) + write_elem_tab(fh, self.xhinge) + write_elem_tab(fh, self.xyzhvec) + write_elem_tab(fh, self.sgndup, 2) + + +class Section: + def __init__(self, refLE: list[float]=[0.0, 0.0, 0.0], chord: float=1.0, dihedral: float=0.0, nspan: int=1, sspace: float=1.0, afileKey: str="NACA", + afile: str="0012"): + """ Section class + + Args: + refLE (list[float], optional): Position of section reference point (leading edge). Defaults to [0.0, 0.0, 0.0]. + chord (float, optional): chord length. Defaults to 1.0. + dihedral (float, optional): dihedral. Defaults to 0.0. + nspan (int, optional): number of spanwise horseshoe vortices. Defaults to 1. + sspace (float, optional): spanwise vortex spacing. Defaults to 1.0. + afileKey (str, optional): airfoil file key (NACA or AFILE). Defaults to "NACA". + afile (str, optional): for key NACA -> enter 4 digit naca profile, otherwise if AFILE -> enter path to airfoil. Defaults to "0012". + """ + self.key = "SECTION" + self.ref = refLE + self.chord = chord + self.dihedral = dihedral + self.nspan = nspan + self.sspace = sspace + self.afileKey = afileKey + self.afile = afile + self.controls = [] + self.description = ["!Xle", "Yle", "Zle", "Chord", "dihedral", "nspan", "sspace"] + + def add_control_device(self, control: Control) -> None: + """ + + Args: + control (Control): Add control object + """ + self.controls.append(control) + + def write(self, fh) -> None: + """ Write data to file via fh -> filehandler + + Args: + fh (): filehandler/filedescriptor + """ + write_separation_small(fh, "Section") + write_key(fh, self.key) + write_elem_tab(fh, self.description, 1) + write_elem_tab(fh, self.ref) + write_elem_tab(fh, self.chord) + write_elem_tab(fh, self.dihedral) + write_elem_tab(fh, self.nspan) + write_elem_tab(fh, self.sspace, 2) + write_afile(fh, self.afileKey, self.afile) + write_new_line(fh) + for control in self.controls: + control.write(fh) + + +class Yduplicate: + def __init__(self, ydupl: float=0.0): + """ Yduplicate + + Args: + ydupl (float, optional): Y postion of X-Z plane -> only use if iYsym is 0. Defaults to 0.0. + """ + self.key = "YDUPLICATE" + self.ydupl = ydupl + self.description = ["!YDuplicate - Geometric Symmetry, non aerodynamic symmetry"] + + def write(self, fh) -> None: + """ Write yduplicate + + Args: + fh (): filehandler/filedescriptor + """ + write_key(fh, self.key) + write_elem_tab(fh, self.description, 1) + write_elem_tab(fh, self.ydupl, 2) + + +class Surface: + def __init__(self, tag: str="surf", nchord: int=10, cspace: float=1.0, nspan: int=20, sspace: float=1.0): + """ General surface + + Args: + tag (str, optional): Surface tag. Defaults to "surf". + nchord (int, optional): Number of chordwise horseshoe vortices. Defaults to 10. + cspace (float, optional): Chordwise vortex spacing parameter. Defaults to 1.0. + nspan (int, optional): Number of spanwise horseshoe vortices. Defaults to 20. + sspace (float, optional): Spanwise vortex spacing. Defaults to 1.0. + """ + self.key = "SURFACE" + self.tag = tag + self.nchord = nchord + self.cspace = cspace + self.nspan = nspan + self.sspace = sspace + self.yduplicates = [] + self.angles = [] + self.scales = [] + self.translates = [] + self.sections = [] + + def add_yduplicate(self, ydupl) -> None: + """ Adds yduplicate object + + Args: + ydupl (object): yduplicate object + """ + self.yduplicates.append(ydupl) + + def add_angle(self, angle) -> None: + """ Adds angle object + + Args: + angle (object): angle object + """ + self.angles.append(angle) + + def add_scale(self, scale) -> None: + """ Adds scale object + + Args: + scale (object): scale object + """ + self.scales.append(scale) + + def add_translate(self, translate) -> None: + """ Add translate object + + Args: + translate (object): translate object + """ + self.translates.append(translate) + + def add_section(self, section) -> None: + """ Add section object + + Args: + section (object): section object + """ + self.sections.append(section) + + def write(self, fh) -> None: + """ Write data to file via fh -> filehandler + + Args: + fh (): filehandler/filedescriptor + """ + write_separation_big(fh, f'Begin of Surface [{self.tag}]') + write_key(fh, self.key) + write_tag(fh, self.tag) + write_elem_tab(fh, self.nchord) + write_elem_tab(fh, self.cspace) + write_elem_tab(fh, self.nspan) + write_elem_tab(fh, self.sspace, 2) + + for ydl in self.yduplicates: + ydl.write(fh) + for angle in self.angles: + angle.write(fh) + for scale in self.scales: + scale.write(fh) + for translate in self.translates: + translate.write(fh) + for section in self.sections: + section.write(fh) + + write_separation_big(fh, f'End of Surface [{self.tag}]') + + +class Scale: + def __init__(self, xscale: float=1.0, yscale: float=1.0, zscale: float=1.0): + """ Scale object + + Args: + xscale (float, optional): x-scale factor. Defaults to 1.0. + yscale (float, optional): y-scale factor. Defaults to 1.0. + zscale (float, optional): z-scale factor. Defaults to 1.0. + """ + self.key = "SCALE" + self.xscale = xscale + self.yscale = yscale + self.zscale = zscale + self.description = ["!Xscale", "Yscale", "Zscale"] + + def write(self, fh) -> None: + """ Write to file + + Args: + fh (): filehandler/filedescriptor + """ + write_key(fh, self.key) + write_elem_tab(fh, self.description, 1) + write_elem_tab(fh, self.xscale) + write_elem_tab(fh, self.yscale) + write_elem_tab(fh, self.zscale, 2) + + +class Translate: + def __init__(self, dX: float=0.0, dY: float=0.0, dZ: float=0.0): + """ Translate object (right hand coordinate system starting in nose through back (x), + left (y), up (z)) + + Args: + dX (float, optional): x translation. Defaults to 0.0. + dY (float, optional): y translation. Defaults to 0.0. + dZ (float, optional): z translation. Defaults to 0.0. + """ + self.key = "TRANSLATE" + self.dX = dX + self.dY = dY + self.dZ = dZ + self.description = ["!dX", "dY", "dZ"] + + def write(self, fh) -> None: + """ Write to file + + Args: + fh (): filehandler/filedescriptor + """ + write_key(fh, self.key) + write_elem_tab(fh, self.description, 1) + write_elem_tab(fh, self.dX) + write_elem_tab(fh, self.dY) + write_elem_tab(fh, self.dZ, 2) + + +class Angle: + def __init__(self, dAinc: float=0.0) -> None: + """ Angle object + + Args: + dAinc (float, optional): Offset added on to the Ainc values (deg). Defaults to 0.0. + """ + self.key = "ANGLE" + self.dAinc = dAinc + self.description = ["!Dihedral"] + + def write(self, fh) -> None: + """ Write to file + + Args: + fh (): filehandler/filedescriptor + """ + write_key(fh, self.key) + write_elem_tab(fh, self.description, 1) + write_elem_tab(fh, self.dAinc, 1) + + +""" +Runcase class (.run) +""" + + +class Runcase: + def __init__(self, alpha: list[float]=[0.0], beta: float=0.0, mach: float=0.0, cgID: int=1, cgpos: list[float]=[0.0, 0.0, 0.0], height: float=11000.0, + aileron: float=0.0, + elevator: float=0.0, rudder: float=0.0): + """ Initialize a single runcase for avl -> inputs for .run file + + Args: + alpha (list[float], optional): angle of attack. Defaults to [0.0]. + mach (float, optional): mach number. Defaults to 0.0. + beta (float, optional): angle. Defaults to 0.0. + cgID (int, optional): center of gravity id (depends on user). Defaults to 1. + cgpos (list[float], optional): cgpos w.r.t. cgID. Defaults to [0.0, 0.0, 0.0]. + height (list[float], optional): height/altitude. Defaults to [11000.0]. + aileron (float, optional): aileron (roll) control surface deflection. Defaults to 0.0. + elevator (float, optional): elevator (pitch) control surface deflection. Defaults to 0.0. + rudder (float, optional): rudder (yaw) control surface deflection. Defaults to 0.0. + """ + atmo = Atmosphere(height) + + if alpha is None: + alpha = [0.0] + self.alpha = alpha + self.beta = beta + self.mach = mach + self.aileron = aileron + self.elevator = elevator + self.rudder = rudder + self.cgID = cgID + self.cgpos = cgpos + self.density = float(atmo.density[0]) + self.a = float(atmo.speed_of_sound[0]) + self.numOfRuncases = len(alpha) + self.cases = [] + self.caseName = None + + def create_runcase_fileName(self) -> str: + """ Create runcase filename + + Returns: + str -- runcase filename + """ + strmach = f'mach{self.mach}_' + strcg = f'cgID{self.cgID}' + s = "Runcase_" + strmach + strcg + s = s.split('.') + s = '_'.join(s) + return s + + def create_runcase_file(self,id: int) -> None: + """ Creates a .run file based on a Runcase + + Args: + id (int): Runcase file id + """ + self.caseName = self.create_runcase_fileName() + idx = 1 + with open(self.caseName + ".run", "w") as file: + for a in self.alpha: + self.cases.append(f'file_id{id+idx}') + write_runcase(file, idx, self.cases[-1], alpha=a, beta=self.beta, mach=self.mach, density=self.density, + a=self.a, aileron=self.aileron, elevator=self.elevator, + rudder=self.rudder, cgref=self.cgpos) + idx += 1 + + def get_runcases(self) -> list: + """ Returns list of runcases + + Returns: + list: list of runcases + """ + return self.cases + + def get_runcase_filename(self) -> str: + """ Get runcase filename + + Returns: + str: runcase filename + """ + return self.caseName diff --git a/pyavlpackage/pyavl/pyavlfileread.py b/pyavlpackage/pyavl/pyavlfileread.py new file mode 100644 index 0000000000000000000000000000000000000000..48a6fa0f25a178c4f63c816d05006f05ddbfca24 --- /dev/null +++ b/pyavlpackage/pyavl/pyavlfileread.py @@ -0,0 +1,321 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +Python AVL Class for Reading Data + +This module provides classes to process AVL stability files and extract relevant aerodynamics data. +""" +import numpy as np +import re +import csv +import math + +class PyAvlStabilityFilesConvert: + """ + Converts AVL stability files to a structured format and writes them to a CSV file. + + Attributes: + filenames (list): List of AVL stability filenames. + datatable (numpy.ndarray): Table of extracted data. + keys (list): List of keys corresponding to the data columns. + stabfiles (list): List of PyAvlStabilityFile objects. + keyListControls (list, optional): List of control keys. + """ + def __init__(self, filenames=[], keyListControls=None, fileout="default.csv"): + """ + Initializes the PyAvlStabilityFilesConvert class and processes the given stability files. + + Args: + filenames (list): List of filenames to process. + keyListControls (list, optional): List of control keys. + fileout (str): Output CSV filename. + """ + filenames.sort() + self.filenames = filenames + self.datatable = None + self.keys = None + self.stabfiles = [] + self.keyListControls = keyListControls + for filename in self.filenames: + self.stabfiles.append(PyAvlStabilityFile(filename, keylistControls=keyListControls)) + + with open(fileout, "w", newline='') as file: + if self.keys is None: + self.keys = self.stabfiles[0].get_keys() + + writer = csv.DictWriter(file, fieldnames=self.keys) + writer.writeheader() + datatable = [] + for stabfile in self.stabfiles: + writer.writerow(stabfile.get_data()) + tmp = stabfile.get_data() + datatable.append(list(tmp.values())) + self.datatable = np.array(datatable) + + def get_keys(self): + """Returns the list of keys from the stability files.""" + return self.keys + + def get_data(self): + """Returns the numerical data extracted from the stability files.""" + return self.datatable + + def get_data_by_key(self,key): + """ + Retrieves data for a specific key. + + Args: + key (str): Key for which data is requested. + + Returns: + numpy.ndarray or None: Data for the given key, or None if not found. + """ + try: + key_index = self.keys.index(key) + return self.datatable[:,key_index] + except ValueError: + return None + + + def get_data_by_control_key(self,key: str ,controlkey: str, exact: bool=False): + """ + Retrieves data based on control key modifications. + + Args: + key (str): The aerodynamic coefficient key. + controlkey (str): The control surface key. + exact (bool, optional): Whether to look for an exact match of the control key. + + Returns: + numpy.ndarray or None: The extracted data. + """ + available_keys = ["CL", "CY", "Cl", "Cm", "Cn","CDff"] + try: + # Get key in available key list -> set to 1 since zero would be direct control key + available_key_index = available_keys.index(key) + 1 + if not exact: + control_key_indices = [idx + available_key_index for idx, item in enumerate(self.keys) if item.startswith(controlkey)] + # if more than one key index is available + if len(control_key_indices) > 1: + # Combine data + return np.sum([self.datatable[:, idx]*math.degrees(1) for idx in control_key_indices], axis=1) + else: + # else + return self.datatable[:, control_key_indices[0]]*math.degrees(1) + else: + control_key_index = self.keys.index(controlkey) + available_key_index + return self.datatable[:, control_key_index]*math.degrees(1) + + + except ValueError: + return None + + + + +class PyAvlStabilityFiles: + """ + Manages multiple AVL stability files. + + Attributes: + filenames (list): List of AVL stability filenames. + stabfiles (list): List of processed stability file data. + keys (list): List of data keys. + ctrlKeys (list, optional): List of control keys. + """ + def __init__(self,filenames=[], keyListControls=None): + """ + Initializes the class and processes the given stability files. + + Args: + filenames (list): List of filenames. + keyListControls (list, optional): List of control keys. + """ + self.filenames = filenames + + self.stabfiles = [] + self.keys = None + self.ctrlKeys = keyListControls + for filename in self.filenames: + stabilityfile = PyAvlStabilityFile(filename, keylistControls=keyListControls) + if self.keys is None: + self.keys = stabilityfile.get_keys() + self.stabfiles.append(stabilityfile.get_data()) + + def get_data(self): + """Returns the data extracted from the stability files.""" + return self.stabfiles + + def get_keys(self): + """Returns the list of keys from the stability files.""" + return self.keys + +class PyAvlStabilityFile: + """ + Represents an AVL stability file. + + Attributes: + filename (str): Name of the AVL stability file. + keylist (list): List of extracted data keys. + keylistControls (list): List of control keys. + data (dict): Extracted data from the file. + """ + + def __init__(self, filename, keylistControls=None): + """ + Initializes the stability file by parsing its contents. + + Args: + filename (str): Filename of the AVL stability file. + keylistControls (list, optional): List of control keys. + """ + self.filename = filename + + self.keylist = self.init_dictionary() + self.keylistControls = self.set_control_keys(keylistControls) + self.keyControlNums = [] + self.data = self.fill_dictionary() + + def get_keys(self): + """Returns the list of keys in the stability file.""" + return self.keylist + + def get_data(self): + """Returns the extracted data from the stability file.""" + return self.data + + def init_dictionary(self): + """Initializes the dictionary with predefined aerodynamic data keys.""" + keylist = [] + + keylist += ["Altitude","Sref", "Cref", "Bref"] + keylist += ["Xref", "Yref", "Zref", "Xnp"] + keylist += ["Alpha", "Beta", "Mach", "pb/2V", "qc/2V", "rb/2V"] + keylist += ["CLtot","CYtot", "CDtot", "CDvis", "CDind", "Cltot", "Cmtot", "Cntot"] + + namederiv = ["CL", "CY", "Cl", "Cm", "Cn"] + addderiv = ["a", "b", "p", "q", "r"] + + for id in addderiv: + keylist += [val + id for val in namederiv] + return keylist + + def fill_dictionary(self): + """ + Reads the AVL stability file and fills the data dictionary. + + Returns: + dict: Extracted aerodynamic data mapped to their respective keys. + """ + with open(self.filename, "r") as file: + lines = file.readlines() + + addderiv = [] + namederiv = ["CL", "CY", "Cl", "Cm", "Cn","CDff"] + for ctrlkey in self.keylistControls: + id = self.get_control_device_num(lines, ctrlkey) + self.keylist.append(ctrlkey) + self.keylist += [val + id for val in namederiv] + + data = dict.fromkeys(self.keylist, None) + + for key in self.keylist: + if key == "Altitude": + data["Altitude"] = self.get_ft_from_runcase(lines) + else: + data[key] = self.get_x_value(lines, key) + + return data + + def set_control_keys(self, controlnames=None): + """Sets the control keys.""" + controlnames = list(controlnames) + keylistControls = [] + if controlnames is None or type(controlnames) is not type([]): + return keylistControls + else: + keylistControls = controlnames + return keylistControls + + def get_x_value(self, lines, key): + """ + Extracts the numerical value associated with a given key from the file. + + Args: + lines (list): Lines from the AVL stability file. + key (str): The key whose value needs to be extracted. + + Returns: + float or None: Extracted numerical value or None if not found. + """ + searchObj = re.compile(rf'{key} * = *.\d*.\d*') + for line in lines: + res = searchObj.search(line) + if res is not None: + ret = res.group().split("=") + return float(ret[1]) + + def get_control_device_num(self, lines, ctrlkey): + """ + Retrieves the control device number for a given control key. + + Args: + lines (list): Lines from the AVL stability file. + ctrlkey (str): The control key whose number is needed. + + Returns: + str: Control device number as a string. + """ + searchObj = re.compile(rf'{ctrlkey[:]} * d\d*.\d*') + for line in lines: + res = searchObj.search(line) + if res is not None: + ret = res.group().split() + return ret[1] + + def get_ft_from_runcase(self,lines): + """ + Extracts altitude information from the run case. + + Args: + lines (list): Lines from the AVL stability file. + + Returns: + float: Altitude value extracted from the run case. + """ + searchObj = re.compile(rf'Run case: RunCase \d* - \d*.\d*') + for line in lines: + res = searchObj.search(line) + if res is not None: + ret = res.group().split("-") + return float(ret[-1]) + + def write(self, fileout): + """ + Writes the extracted data to a CSV file. + + Args: + fileout (str): The output filename without extension. + """ + with open(fileout + ".csv", "w", newline='') as file: + writer = csv.DictWriter(file, fieldnames=self.keylist) + writer.writeheader() + writer.writerow(self.data) diff --git a/pyavlpackage/pyavl/pyavlfilewrite.py b/pyavlpackage/pyavl/pyavlfilewrite.py new file mode 100644 index 0000000000000000000000000000000000000000..45def28a7c525a17017db9af2545918e05a15e7a --- /dev/null +++ b/pyavlpackage/pyavl/pyavlfilewrite.py @@ -0,0 +1,233 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +Python AVL Class for Reading Data + +This module provides classes to process AVL stability files and extract relevant aerodynamics data. +""" + +def write_elem_new_line(fh, val): + """ + Writes a value to the file and adds a new line. + + Args: + fh (file object): File handle. + val (str): Value to write. + """ + if len(val): + fh.write(f'{val}\n') + + +def write_elem_tab(fh, elems, newline=0): + """ + Writes elements separated by spaces and optionally adds a new line. + + Args: + fh (file object): File handle. + elems (list or str): Elements to write. + newline (int, optional): Number of new lines to add. + """ + if type(elems) is list: + for elem in elems: + fh.write(f'{elem} ') + else: + fh.write(f'{elems} ') + + write_new_line(fh, newline) + + +def write_key(fh, key): + """Writes a key as a new line.""" + write_elem_new_line(fh, key) + + +def write_tag(fh, tag): + """Writes a tag as a new line.""" + write_elem_new_line(fh, tag) + + +def write_afile(fh, key, afile): + """ + Writes a key followed by a filename as new lines. + + Args: + fh (file object): File handle. + key (str): Key to write. + afile (str): Associated filename. + """ + write_elem_new_line(fh, key) + write_elem_new_line(fh, f'{afile}') + + +def write_commentline(list, commentstring): + """ + Adds a commented line to a list. + + Args: + lst (list): List to append the comment. + commentstring (str): Comment text. + """ + commentline = "# " + commentstring + "\n" + list.append(commentline) + + +def write_separation_big(file, token=""): + """ + Writes a large separation line with an optional token. + + Args: + file (file object): File handle. + token (str, optional): Text to include in the separation. + """ + sepString = "# ==" + if len(token): + sepString += f' {token} ' + + lsepString = len(sepString) + for i in range(0, 80 - lsepString): + sepString += "=" + sepString += "\n" + file.write(sepString) + + +def write_separation_small(file, token=""): + """ + Writes a small separation line with an optional token. + + Args: + file (file object): File handle. + token (str, optional): Text to include in the separation. + """ + sepString = "# --" + if len(token): + sepString += f' {token} ' + + lsepString = len(sepString) + for i in range(0, 80 - lsepString): + sepString += "-" + sepString += "\n" + file.write(sepString) + + +def write_headline(file, short=""): + """Writes a headline in a formatted manner.""" + file.write(f'# -- {short} --\n') + + +def write_new_line(fh, num=1): + """ + Writes the specified number of new lines. + + Args: + fh (file object): File handle. + num (int): Number of new lines to write. + """ + crnl = "" + for i in range(0, num): + crnl += "\n" + fh.write(crnl) + + +def write_avl_file(author="", datalist=[], filename="filename", postfix=".avl"): + """ + Writes AVL data to a specified file. + + Args: + author (str, optional): Author name. + datalist (list): Data elements to write. + filename (str): Base filename. + postfix (str, optional): File extension. + """ + with open(filename + postfix, "w") as avlFile: + for item in datalist: + item.write(avlFile) + + +def write_runcase(file, runcasenum=1, runcasename="default", alpha=0.0, beta=0.0, pb2v=0.0, qc2v=0.0, rb2v=0.0, + controls=None, mach=0.0, CD0=0.02, density=1.225,a=340.294, cgref=[0.0, 0.0, 0.0]): + """ + Writes a run case block to the file. + + Args: + file (file object): File handle. + runcasenum (int, optional): Run case number. + runcasename (str, optional): Run case name. + alpha (float, optional): Angle of attack. + beta (float, optional): Sideslip angle. + pb2v, qc2v, rb2v (float, optional): Non-dimensional rotational rates. + controls (list, optional): List of control surfaces. + mach (float, optional): Mach number. + CD0 (float, optional): Zero-lift drag coefficient. + density (float, optional): Air density. + a (float, optional): Speed of sound. + cgref (list, optional): Center of gravity reference coordinates. + """ + file.write(f' ---------------------------------------------\n') + file.write(f' Run case {runcasenum}: {runcasename}\n') + file.write(f' alpha -> alpha = {alpha}\n') + file.write(f' beta -> beta = {beta}\n') + file.write(f' pb/2V -> pb/2V = {pb2v}\n') + file.write(f' qc/2V -> qc/2V = {qc2v}\n') + file.write(f' rb/2V -> rb/2V = {rb2v}\n') + if controls is not None and isinstance(controls,list): + for control in controls: + file.write(f' {control}\t\t->{control}\t\t=\t{0.0}\n') + # if aileron is not None: + # file.write(f' aileron -> aileron = {aileron}\n') + # if elevator is not None: + # file.write(f' elevator -> elevator = {elevator}\n') + # if rudder is not None: + # file.write(f' rudder -> rudder = {rudder}\n') + + file.write("\n") + + default = 0.0 + velocity = mach*a[0] + file.write(f' alpha = {alpha} deg\n') + file.write(f' beta = {beta} deg\n') + file.write(f' pb/2V = {default}\n') + file.write(f' qc/2V = {default}\n') + file.write(f' rb/2V = {default}\n') + file.write(f' CL = {default}\n') + file.write(f' CDo = {CD0}\n') + file.write(f' bank = {default} deg\n') + file.write(f' elevation = {default} deg\n') + file.write(f' heading = {default} deg\n') + file.write(f' Mach = {mach}\n') + file.write(f' velocity = {velocity:.4f} Lunit/Tunit\n') + file.write(f' density = {density[0]:.4f} Munit/Lunit^3\n') + file.write(f' grav.acc. = {9.80665} Lunit/Tunit^2\n') + file.write(f' turn_rad. = {default} Lunit\n') + file.write(f' load_fac. = {1.00000}\n') + file.write(f' X_cg = {cgref[0]} Lunit\n') + file.write(f' Y_cg = {cgref[1]} Lunit\n') + file.write(f' Z_cg = {cgref[2]} Lunit\n') + file.write(f' mass = {1.00000} Munit\n') + file.write(f' Ixx = {1.00000} Munit-Lunit^2\n') + file.write(f' Iyy = {1.00000} Munit-Lunit^2\n') + file.write(f' Izz = {1.00000} Munit-Lunit^2\n') + file.write(f' Ixy = {0.00000} Munit-Lunit^2\n') + file.write(f' Iyz = {0.00000} Munit-Lunit^2\n') + file.write(f' Izx = {0.00000} Munit-Lunit^2\n') + file.write(f' visc CL_a = {0.00000}\n') + file.write(f' visc CL_u = {0.00000}\n') + file.write(f' visc CM_a = {0.00000}\n') + file.write(f' visc CM_u = {0.00000}\n\n') diff --git a/pyavlpackage/pyavl/pyavlrunner.py b/pyavlpackage/pyavl/pyavlrunner.py new file mode 100644 index 0000000000000000000000000000000000000000..a2cb5a2a346168d94465a1c616a0ae58d3d19b25 --- /dev/null +++ b/pyavlpackage/pyavl/pyavlrunner.py @@ -0,0 +1,211 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +PyAvl - A Python to AVL conversion script for generating lifting surfaces based on given geometry. + +This module provides utilities to run Athena Vortex Lattice (AVL) simulations and install AVL/XFOIL tools. +""" +import os +import subprocess +import platform +import urllib.request +import shutil +import stat + +class PyAvlRunner: + """ + Handles execution of AVL simulations, manages run cases, and processes stability results. + """ + def __init__(self,avlFile=None,runcasefile=None,runcases=["default"]): + """ + Initializes the PyAvlRunner class. + + Args: + avlFile (str, optional): Path to AVL geometry file. + runcasefile (str, optional): Path to AVL run case file. + runcases (list, optional): List of run case names. + """ + self.avlproc = None + self.cmdline = "" + self.avlFile = avlFile + self.runcaseFile = runcasefile + self.runcases = runcases + self.count = 1 + self.path_to_session_folder = None + self.path_to_stability_files = None + self.path_to_runcase_files = None + + def session_setup(self): + """ + Sets up the AVL session with geometry and run cases. + """ + # Load geometry and case + self.cmdline += f"load {self.avlFile}\n" + self.cmdline += f"case {self.runcaseFile}.run\n" + + + # Switch to operation mode + self.cmdline += f"oper\n" + + # Select runcase + for id, runcase in enumerate(self.runcases): + self.cmdline += f"{id+1}\n" + self.cmdline += "x\n" + self.cmdline += f"st\n{runcase}_{id}\n" + self.cmdline += "\nquit\n" + + def session_setup_single_rc(self, path_to_session_folder, runcase_files_folder, stability_files_folder, results_folder, runcase_ids): + + # Setup paths to session and resulting files + self.path_to_session_folder = path_to_session_folder + self.path_to_runcase_files = runcase_files_folder + self.path_to_stability_files = stability_files_folder + self.path_to_session_result_file = results_folder + + # Load geometry and case + self.cmdline += f"load {path_to_session_folder}/{self.avlFile}\n" + for _, id in enumerate(runcase_ids): + self.cmdline += f"case {runcase_files_folder}/rc_{id:03}.run\n" + + # Switch to operation mode + self.cmdline += f"oper\n" + self.cmdline += f"I\n" + self.cmdline += "x\n" + self.cmdline += f"st {stability_files_folder}/res_{id:03}.stab\n" + self.cmdline += "\n" + self.cmdline += "quit\n" + + return self.cmdline + + def run_avl_session(self, cmdline=None): + """ + Executes the AVL session. + + Args: + cmdline (str, optional): Custom command sequence for AVL. + """ + if cmdline is None: + cmdline = self.cmdline + print("Computing aerodynamics with Athena Vortex Lattice (AVL) ... ",end="") + process = self.avl_process() + process.communicate(input=cmdline.encode()) + process.wait() + print("finished") + + + def avl_process(self, path_to_avl_executable: str="."): + """ + Initializes the AVL process. + + Args: + path_to_avl_executable (str, optional): Path to the AVL executable. + + Returns: + subprocess.Popen: AVL process. + """ + avl_executable = "avl" + if platform.system() == "Windows": + avl_executable = "avl.exe" + return subprocess.Popen(args=[f'{path_to_avl_executable}/{avl_executable}'], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + bufsize=0) + + def session_End(self): + """End avl session""" + self.avlproc.terminate() + self.avlproc.kill() + + def session_folder(self): + """Return session folder path""" + return self.path_to_session_folder + + def stability_files_folder(self): + """Returns stability files folder path""" + return self.path_to_stability_files + + def runcase_files_folder(self): + """Returns runcases files folder path""" + return self.path_to_runcase_files + + def result_files_folder(self): + """Returns result file folder path""" + return self.path_to_session_result_file + + +class AVLinstaller: + """ + Downloads and installs AVL based on the user's operating system. + """ + def __init__(self): + """Initializes AVLinstaller with appropriate download URLs.""" + self.avl_url = {"root": "http://web.mit.edu/drela/Public/web/avl/", + "Windows": "avl3.40_execs/WIN64/avl.exe", + "Darwin-x86_64": "avl3.40_execs/DARWIN64/avl", + "Darwin-arm64": "avl3.40_execs/DARWINM1/avl", + "Linux": "avl3.40_execs/LINUX64/avl"} + self.operating_system: str=self.check_os() + self.machine_architecture: str=self.check_architecture() + self.avl_download_link: str=None + + def check_os(self) -> str: + """Returns the operating system name.""" + return platform.system() + + def check_architecture(self) -> str: + """Returns the system architecture.""" + return platform.machine() + + def set_avl_download_link(self) -> None: + """Determines the correct AVL download link based on the OS and architecture.""" + if self.operating_system == "Darwin": + self.operating_system += "-" + self.machine_architecture + + self.avl_download_link = self.avl_url["root"] + self.avl_url[self.operating_system] + + def is_command_installed(self, command): + """Determines if command is installed correctly""" + return shutil.which(command) is not None + + def download_avl(self) -> bool: + """Downloads and installs AVL if not already installed.""" + print("Checking AVL ...") + executable = "avl" + if self.operating_system == "Windows": + executable += ".exe" + path_to_executable = "./" + executable + + if self.is_command_installed(path_to_executable) or os.path.exists(path_to_executable): + print("AVL is already installed.") + return True + self.set_avl_download_link() + print("Downloading AVL ...") + print(self.avl_download_link) + print(os.getcwd()) + urllib.request.urlretrieve(self.avl_download_link, os.getcwd()+"/" +executable) + if self.is_command_installed(path_to_executable) or os.path.exists(path_to_executable): + print("AVL is installed.") + print("Changing rights") + os.chmod(path_to_executable, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + return True + else: + return False diff --git a/pyavlpackage/pyproject.toml b/pyavlpackage/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..9098306081ae1d03ee075359ba77f1bd2f6d7ae4 --- /dev/null +++ b/pyavlpackage/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyavl" +version = "1.0.0" +description = "Simple python avl interface to build .avl files and runcase files." +authors = [{ name = "Christopher Ruwisch", email = "christopher.ruwisch@tu-berlin.de" }] +readme = "README.md" +license = { file = "LICENSE" } +keywords = ["avl"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", +] +dependencies = [ + "numpy>=1.26.4", +] + +[tool.setuptools] +packages = ["pyavl"] + +[project.urls] +homepage = "https://unicado.pages.rwth-aachen.de/unicado.gitlab.io/" +repository = "https://git.rwth-aachen.de/unicado/libraries" \ No newline at end of file diff --git a/pyavlunicadopackage/CMakeLists.txt b/pyavlunicadopackage/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..ba9acde4f422aa40fde43f4759ff4653b1a29483 --- /dev/null +++ b/pyavlunicadopackage/CMakeLists.txt @@ -0,0 +1,6 @@ +# Add the package to the package list for exporting the target +# and propagate the resulting list back to the parent scope +list( APPEND PYTHON_TARGETS ${CMAKE_CURRENT_LIST_DIR} ) +set( PYTHON_TARGETS ${PYTHON_TARGETS} PARENT_SCOPE ) + +install(DIRECTORY ${CMAKE_CURRENT_LIST_DIR} DESTINATION lib) diff --git a/pyavlunicadopackage/README.md b/pyavlunicadopackage/README.md new file mode 100644 index 0000000000000000000000000000000000000000..965890db22ebc55907404c960fd4ab1f5882bf39 --- /dev/null +++ b/pyavlunicadopackage/README.md @@ -0,0 +1,11 @@ +# PYAVLUNICADO Package - Python package for working with AVL (Athena vortex lattice) in combination with Unicado + +## Usage +Used for Interfacing AVL and UNICADO to compute aerodynamic derivatives + +## License +This project is licensed under the GNU General Public License, Version 3 +- see the License.md file for details. + +## Contact +For questions or feedback, please contact contacts@unicado.io diff --git a/pyavlunicadopackage/pyavlunicado/__init__.py b/pyavlunicadopackage/pyavlunicado/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fcd206fcb4468e1fd47109ad8a5e5e60b0a8f8cb --- /dev/null +++ b/pyavlunicadopackage/pyavlunicado/__init__.py @@ -0,0 +1,20 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + diff --git a/pyavlunicadopackage/pyavlunicado/avl b/pyavlunicadopackage/pyavlunicado/avl new file mode 100755 index 0000000000000000000000000000000000000000..01cdfec90ad2f14f496fc4c613d3fcdf489879ac Binary files /dev/null and b/pyavlunicadopackage/pyavlunicado/avl differ diff --git a/pyavlunicadopackage/pyavlunicado/avlunicadogeometry.py b/pyavlunicadopackage/pyavlunicado/avlunicadogeometry.py new file mode 100644 index 0000000000000000000000000000000000000000..b9fc6d1ff6ed3bcb00c4c478115c7abb542e7ee8 --- /dev/null +++ b/pyavlunicadopackage/pyavlunicado/avlunicadogeometry.py @@ -0,0 +1,488 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +PyAvlUnicado - Geometry Processing for AVL + +This module defines classes for handling aerodynamic surfaces and profile scaling within the AVL simulation framework. + +Features: +- Constructs AVL-compatible aerodynamic surface representations. +- Computes 3D transformations and rotations. +- Integrates with `pyaircraftgeometry2` for wing and control surface geometry. +- Scales airfoil profiles for AVL using direct geometric transformations based on XFOIL code. + +Dependencies: +- numpy +- pyaircraftgeometry2 +- pyavl +""" +import pyaircraftgeometry2 as geom2 +import pyavl as avl +import os + +import shutil +import numpy as np + +from collections import defaultdict +from .xfoilgeometryscale import * + +class AerodynamicSurface: + """ + Represents an aerodynamic surface for AVL simulations. + + This class constructs an AVL-compatible aerodynamic surface from aircraft geometry data, + allowing for transformations, profile scaling, and control device integration. + """ + def __init__(self, paths, folder_to_create_data: str = ".") -> None: + """ + Initializes an aerodynamic surface object. + + Args: + paths (dict): Dictionary containing paths to required data. + folder_to_create_data (str, optional): Path where processed airfoil data will be stored. Defaults to "./". + """ + self.aircraft_name = None + self.surface = None + self.paths = paths + self.rotation_geom2_to_avl = None + self.folder_to_create_data = folder_to_create_data + + def rotation_matrix(self, input_vector, target_vector): + """ + Computes the rotation matrix that aligns one 3D vector with another. + + Args: + input_vector (array-like): The initial 3D vector. + target_vector (array-like): The target 3D vector. + + Returns: + numpy.ndarray: A 3x3 rotation matrix. + """ + + # w_hat = np.cross(input_vector, target_vector) + + theta = np.arccos(np.dot(input_vector, target_vector)) + + c = np.cos(theta) + s = np.sin(theta) + R = np.array([[1, 0, 0], [0, c, -s], [0, s, c]]) + + return R + + def build(self, reference_position, aerodynamic_surface, include_control_devices=True): + """ + Constructs the AVL-compatible aerodynamic surface from the given input data. + + Args: + reference_position (dict): Reference position of the aerodynamic surface. + aerodynamic_surface (dict): Dictionary containing geometry and control device data. + include_control_devices (bool, optional): Whether to include control devices. Defaults to True. + + Returns: + avl.Surface: The constructed AVL aerodynamic surface. + """ + geometry = aerodynamic_surface["geometry"] + controls = aerodynamic_surface["control_devices"] + + # Determine rotation matrix for geometry origin -> necessary due to extrusion of aerodynamic objects according to aircraftgeometry2 + geometry_normal = np.array([geometry.normal.dx(), geometry.normal.dy(), geometry.normal.dz()]) + extrusion_normal = np.array([0, 0, 1]) + self.origin_unicado_to_avl = self.rotation_matrix(extrusion_normal, geometry_normal) + + # Initialize avl sections list + avl_sections = [] + + # Set number of geometry sections + number_of_geometry_sections = len(geometry.sections) + + for id in range(number_of_geometry_sections): + + section = geometry.sections[id] + chord = section.get_chord_length() + + # leading_edge_reference = np.dot(self.origin_unicado_to_avl, np.array( + # [section.origin.x(), section.origin.y(), section.origin.z()])) + leading_edge_reference = self.xyz_unicado_to_avl( + [section.origin.x(), section.origin.y(), section.origin.z()]) # leading_edge_reference.tolist() + + # remove small elements from leading edge reference to avoid asymmetric behaviour + leading_edge_reference = [0.0 if abs(elem) < 1E-6 else elem for elem in leading_edge_reference] + + scaling = section.get_thickness_scale() + + # Profile scaling + + # XFoil profile destination + xfoil_destination_dir = f"{self.folder_to_create_data}/profiles" + # Create xfoil_destintation_dir if not existing + os.makedirs(xfoil_destination_dir, exist_ok=True) + profile_path = f"{self.paths['airfoil_data_directory']}/{section.name}.dat" + xfoil_profile_path = f"{xfoil_destination_dir}/{section.name}.dat" + + # Copy profile to xfoil_destination_dir -> needs to be copied due to limitation of profiles + # todo -> remove xfoil usage by scaling thickness directly + shutil.copy(profile_path, xfoil_profile_path) + + section_profile_file_name = Profile(xfoil_profile_path, geometry.name, + "geometry", id).save_to_file(xfoil_destination_dir, scaling) + + avl_section = avl.Section(refLE=leading_edge_reference, chord=chord, + dihedral=0.0, nspan=5, afileKey="AFILE", afile=section_profile_file_name) + + avl_sections.append(avl_section) + + if include_control_devices: + avl_sections = self.build_control_sections(avl_sections, geometry, controls) + + self.surface = avl.Surface(tag=geometry.name) + + for section in avl_sections: + self.surface.add_section(section) + # Do translation, angle etc at end + self.surface.add_translate(avl.Translate([reference_position["x"] + geometry.origin.x(), + reference_position["y"] + geometry.origin.y(), + reference_position["z"] + geometry.origin.z()])) + self.surface.add_angle(avl.Angle(geometry.rotation_z)) + if geometry.is_symmetric: # symmetric + self.surface.add_yduplicate(avl.Yduplicate(0.0)) + + return self.surface + + def xyz_unicado_to_avl(self, geom2_xyz=[]): + """ + Converts coordinates from UNICADO format to AVL format. + + Args: + geom2_xyz (list, optional): List of x, y, z coordinates in UNICADO format. + + Returns: + list: Transformed coordinates in AVL format. + """ + return np.dot(self.origin_unicado_to_avl, geom2_xyz).tolist() + + def build_control_sections(self, avl_sections, geometry, controls): + """ + Builds control surface sections for AVL. + + Args: + avl_sections (list): List of existing AVL sections. + geometry (object): Aircraft geometry object. + controls (list): List of control surfaces. + + Returns: + list: Updated AVL sections with control devices. + """ + half_span = geom2.measure.span(geometry) + if geometry.is_symmetric: + half_span *= 0.5 + sections_available = [section.origin.z() for section in geometry.sections] + + # Set control surface vector parallel to surface normal + xyzhvec = [abs(geometry.normal.dx()), abs(geometry.normal.dy()), abs(geometry.normal.dz())] + + for control in controls: + for idx, section in enumerate(control.sections): + # Build section + position_spanwise = abs(half_span * section.origin.z())*-1.0 + current_id = None + for id, position in enumerate(sections_available): + if self.float_equality(position, position_spanwise): + current_id = id + break + else: + continue + + # if current position is not existing -> create section + if current_id is None: + sections_available.append(position_spanwise) + origin = geom2.measure.offset_LE(geometry, position_spanwise) + leading_edge_reference = self.xyz_unicado_to_avl([origin.x(), origin.y(), origin.z()]) + leading_edge_reference = [0.0 if abs(elem) < 1E-6 else elem for elem in leading_edge_reference] + + id_left, id_right = self.find_adjacent_sections(geometry, position_spanwise) + position_scale, position_profile = self.find_thickness_scaling_and_profile( + geometry, id_left, id_right, position_spanwise) + + chord = geom2.measure.chord(geometry, position_spanwise) + + # Profile scaling + # XFoil profile destination + xfoil_destination_dir = f"{self.folder_to_create_data}/profiles" + # Create xfoil_destintation_dir if not existing + os.makedirs(xfoil_destination_dir, exist_ok=True) + profile_path = f"{self.paths['airfoil_data_directory']}/{position_profile}.dat" + xfoil_profile_path = f"{xfoil_destination_dir}/{section.name}.dat" + + # Copy profile to xfoil_destination_dir -> needs to be copied due to limitation of profiles + # todo -> remove xfoil usage by scaling thickness directly + shutil.copy(profile_path, xfoil_profile_path) + + section_profile_file_name = Profile(xfoil_profile_path, geometry.name, + control.name, idx).save_to_file(xfoil_destination_dir, position_scale) + + avl_section = avl.Section(refLE=leading_edge_reference, chord=chord, + dihedral=0.0, nspan=5, afileKey="AFILE", afile=section_profile_file_name) + + contour = section.get_contour(False) + + + # Hinge line according to avl documentation + # LE Surface 0 ... -xhinge + # TE Surface xhinge ... 1 + + # Assume trailing edge device + xhinge = contour.vertex(0).x() + # Change if xhinge is at zero + if abs(xhinge) < 1E-4: + xhinge = -contour.vertex(1).x() + # Direction for control surface - according to avl documentation + sgndup = 1 + gain = 1 + if control.name.startswith("aileron"): + sgndup = -1 + if control.name.startswith("rudder") or control.name.startswith("slat"): + gain = -1 + + # Create avl control device + control_device = avl.Control(tag=control.name, gain=gain, xyzhvec=xyzhvec, + xhinge=xhinge, sgndup=sgndup) + + # Add control device section + avl_section.add_control_device(control_device) + avl_sections.append(avl_section) + else: + # add_control to section + contour = section.get_contour(False) + # Hinge line according to avl documentation + # LE Surface 0 ... -xhinge + # TE Surface xhinge ... 1 + + # Assume trailing edge device + xhinge = contour.vertex(0).x() + # Change if xhinge is at zero + if abs(xhinge) < 1E-4: + xhinge = -contour.vertex(1).x() + # Direction for control surface - according to avl documentation + sgndup = 1 + gain = 1 + if control.name.startswith("aileron_") or control.name == "aileron": + sgndup = -1 + if control.name.startswith("rudder") or control.name.startswith("slat"): + gain = -1 + + control_device = avl.Control(tag=control.name, gain=gain, xyzhvec=xyzhvec, + xhinge=xhinge, sgndup=sgndup) + avl_sections[current_id].add_control_device(control_device) + + # Sort avl_sections + sorted_section_idx = sorted(range(len(sections_available)), key=lambda x: sections_available[x], reverse=True) + reordered_sections = [avl_sections[i] for i in sorted_section_idx] + + # Get control section ids start + end + control_devices = defaultdict(list) + for idx, section in enumerate(reordered_sections): + for control in section.controls: + control_devices[control.tag].append(idx) + + # Add control to section if control sections not next to eachother + for device, boundary_ids in control_devices.items(): + first_section_id = boundary_ids[0] + last_section_id = boundary_ids[-1] + missing_section_ids = list(range(first_section_id+1,last_section_id)) + + # Append missing control to existing section + boundary_controls = [control for controls in [reordered_sections[first_section_id].controls, reordered_sections[last_section_id].controls] for control in controls if control.tag == device] + for id in missing_section_ids: + # Get first and last control devices + first_hinge = boundary_controls[0].xhinge + first_gain = boundary_controls[0].gain + last_hinge = boundary_controls[-1].xhinge + last_gain = boundary_controls[-1].gain + + control_to_add = boundary_controls[0] + if not self.float_equality(first_hinge, last_hinge): + # Get first and last position in main extrusion direction -> xyzhvec + first_pos = [reordered_sections[first_section_id].ref[i] for i, val in enumerate(xyzhvec) if abs(val) > 0.9][0] + last_pos = [reordered_sections[last_section_id].ref[i] for i, val in enumerate(xyzhvec) if abs(val) > 0.9][0] + + current_pos = [reordered_sections[id].ref[i] for i, val in enumerate(xyzhvec) if abs(val) > 0.9][0] + + t = (current_pos-first_pos)/(last_pos-first_pos) + current_xhinge = self.lerp(first_hinge, last_hinge, t) + current_gain = self.lerp(first_gain,last_gain, t) + control_to_add.xhinge = current_xhinge + control_to_add.gain = current_gain + + reordered_sections[id].controls.append(control_to_add) + + # Assume that a device is always unique (e.g. slats are not separated -> otherwise it would be slat_1, slat_2 etc. ) + + + + return reordered_sections + + def lerp(self, a: float, b: float, t: float) -> float: + """ + Performs linear interpolation between two values. + + Args: + a (float): The starting value. + b (float): The ending value. + t (float): The interpolation factor (0 ≤ t ≤ 1). + + Returns: + float: The interpolated value. + """ + return (1-t)* a + t*b + + def float_equality(self, value, to_check, tol=1e-4): + """ + Checks if two floating-point numbers are equal within a given tolerance. + + Args: + value (float): The first number to compare. + to_check (float): The second number to compare. + tol (float, optional): The allowed tolerance for equality. Defaults to 1e-4. + + Returns: + bool: True if values are considered equal, otherwise False. + """ + return abs(value-to_check) < tol + + + def find_adjacent_sections(self, geometry, position): + """ + Finds the indices of the two adjacent sections in the given geometry closest to the specified position. + + Args: + geometry (object): The geometry containing sections. + position (float): The spanwise position to find the nearest sections. + + Returns: + tuple[int, int]: Indices of the left and right adjacent sections. + """ + section_positions = [section.origin.z() for section in geometry.sections] + id_left = 0 + id_right = 1 + if not position in section_positions: + for idx, section_position in enumerate(section_positions): + if position <= section_position: + id_left = idx + id_right = idx+1 + + return id_left, id_right + + def find_thickness_scaling_and_profile(self, geometry, left_section_id, right_section_id, position): + """ + Determines the thickness scaling and profile name based on the given spanwise position. + + Args: + geometry (object): The aerodynamic geometry. + left_section_id (int): The index of the left section. + right_section_id (int): The index of the right section. + position (float): The spanwise position to evaluate. + + Returns: + tuple[float, str]: The interpolated thickness scaling factor and the selected airfoil profile name. + """ + left_section = geometry.sections[left_section_id] + left_section_profile = left_section.name + left_section_scale = left_section.get_thickness_scale() + + right_section = geometry.sections[right_section_id] + right_section_profile = right_section.name + right_section_scale = right_section.get_thickness_scale() + + normalized_position = (position-left_section.origin.z())/(right_section.origin.z()-left_section.origin.z()) + + position_scale: float = left_section_scale * \ + (1 - normalized_position) + right_section_scale * normalized_position + position_profile: str = left_section_profile + if right_section_profile != left_section_profile: + if normalized_position > 0.5: + position_profile = right_section_profile + else: + position_profile = left_section_profile + + return position_scale, position_profile + + # left_section_profile + + + +class Profile: + """Profile storage and scaling class.""" + def __init__(self, profile_file: str, section_geometry_name: str, section_type: str = "geometry", section_id: int = 0) -> None: + """ + Initializes the Profile class. + + Args: + profile_file (str): The filename of the profile. + section_geometry_name (str): The name of the geometry section. + section_type (str, optional): The type of the section. Defaults to "geometry". + section_id (int, optional): The ID of the section. Defaults to 0. + """ + + self.profile_file = profile_file + self.section_id = section_id + self.output_file = f"section_{section_geometry_name}_{section_type}_{section_id}.dat" + + def remove_existing_profile(self, path: str) -> None: + """ + Removes an existing profile file if it exists. + + Args: + path (str): The directory path where the profile file is located. + """ + path_to_file = f"{path}/{self.output_file}" + if os.path.exists(path_to_file): + os.remove(path_to_file) + else: + pass + + def save_to_file(self, path: str, scale: float = 1.) -> str: + """ + Applies thickness scaling to the profile and saves the modified profile. + + Args: + path (str, optional): The directory path for saving the scaled profile. Defaults to an empty string. + scale (float, optional): The scaling factor for thickness. Defaults to 1.0. + """ + + self.remove_existing_profile(path) + + # Scale thickness + self.run_thickness_scaling(path, scale) + return f"{path}/{self.output_file}" + + def run_thickness_scaling(self, path:str="", scale: float=1.0) -> None: + """ Runs thickness scaling + + Args: + path (str, optional): path to airfoil directory. Defaults to "". + scale (float, optional): scales thickness according to value. Defaults to 1.0. + """ + output_path = f"{path}/{self.output_file}" + + coordinates = load_profile(self.profile_file) + scaled_coordinates = rescale_profile(coordinates=coordinates, scale_t_to_c=scale) + + save_profile(scaled_coordinates, output_path) diff --git a/pyavlunicadopackage/pyavlunicado/avlunicadoio.py b/pyavlunicadopackage/pyavlunicado/avlunicadoio.py new file mode 100644 index 0000000000000000000000000000000000000000..f0e808ecd6e4fc9fde507efcd4c8a4494d328a1d --- /dev/null +++ b/pyavlunicadopackage/pyavlunicado/avlunicadoio.py @@ -0,0 +1,290 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +PyAvlUnicado - A Python Interface for Athena Vortex Lattice (AVL) for use with UNICADO + +This module provides a Python interface for generating and executing AVL simulations. It includes: +- Geometry initialization and manipulation +- Run case setup and execution +- Interaction with AVL for aerodynamic analysis + +Features: +- Reads and processes aircraft geometry +- Manages aerodynamic surfaces and control devices +- Creates AVL input files for simulations +- Automates AVL run case execution and result extraction + +Dependencies: +- numpy +- ambiance +- pyaircraftgeometry2 +- pyavl +""" +import pyaircraftgeometry2 as geom2 +import pyavl as avl +import os +import shutil +from .avlunicadogeometry import AerodynamicSurface +from .avlunicadoutility import load_aerodynamic_surfaces +from ambiance import Atmosphere +import numpy as np + + +class Avlinterface: + """ + Manages the interface for AVL simulations, including geometry initialization and run case management. + """ + aerodynamic_surfaces = None + aerodynamic_components: list=[] + aerodynamic_reference_data: list=[] + aerodynamic_surface_control_keys = set() + paths: str=None + aircraft_name: str=None + runcases: list=[] + derivative_data = None + none_high_lift_devices: list = ["aileron", "elevator", "rudder"] + + def __init__(self, paths: str = None, aircraft_name: str = "default_ac") -> None: + """ + Initializes the Avlinterface class. + + Args: + paths (str, optional): Path to aircraft geometry files. + aircraft_name (str, optional): Name of the aircraft model. + """ + self.paths = paths + self.aircraft_name = aircraft_name + + def initialize_geometry(self, component_list: list[str] = [], use_control_devices=False, use_high_lift_devices=False): + """ + Initializes aerodynamic geometry based on input components. + + Args: + component_list (list, optional): List of components to include. + use_control_devices (bool, optional): Whether to include control devices. + use_high_lift_devices (bool, optional): Whether to include high lift devices. + """ + # clean up aerodynamic components + if self.aerodynamic_components: + self.aerodynamic_components.clear() + + # Load aerodynamic surfaces from acxml + self.aerodynamic_surfaces = load_aerodynamic_surfaces(self.paths, component_list) + + # Read reference data (S_ref, mac, Span) + if "wing" in self.aerodynamic_surfaces: + reference_area: float = 0 + reference_mac: float = 0 + reference_span: float = 0 + for aerodynamic_surface in self.aerodynamic_surfaces["wing"]["aerodynamic_surfaces"]: + current_wing = aerodynamic_surface["geometry"] + current_area = geom2.measure.reference_area(current_wing) + + # select reference values from biggest area + if current_area > reference_area: + reference_area = current_area + reference_mac = geom2.measure.mean_aerodynamic_chord(current_wing) + reference_span = geom2.measure.span(current_wing) + reference_phi_25 = -geom2.measure.sweep(current_wing, 0.25*reference_span, 0.25) + self.aerodynamic_reference_data = [reference_area, reference_mac, reference_span, reference_phi_25] + + for component in self.aerodynamic_surfaces: + # Read reference position + component_reference_position = self.aerodynamic_surfaces[component]["reference_position"] + for aerodynamic_surface in self.aerodynamic_surfaces[component]["aerodynamic_surfaces"]: + + # If use_high_lift_devices -> False ... only none_high_lift_devices are used for geometry, else all elements are used + # WARNING -> if all devices are used, make sure that they are not too close to eachother, otherwise avl has issues with computation + if not use_high_lift_devices: + aerodynamic_surface["control_devices"] = [control for control in aerodynamic_surface["control_devices"] if control.name in self.none_high_lift_devices] + + self.aerodynamic_components.append(AerodynamicSurface(self.paths, self.aircraft_name).build( + component_reference_position, aerodynamic_surface, use_control_devices)) + + + for control in aerodynamic_surface["control_devices"]: + self.aerodynamic_surface_control_keys.add(control.name) + + + def create_avl_geometry_file(self, author: str = "", filename: str = "", runcasename: str = "default", mach_number: float = 0.78, reference_data: list[float] = None, reference_cog: list[float] = None): + """ + Creates an AVL geometry input file. + """ + if filename == "": + filename = f"{self.aircraft_name}/{self.aircraft_name}" + else: + filename = f"{self.aircraft_name}/{filename}" + if reference_data is None: + reference_data = self.aerodynamic_reference_data + if reference_cog is None: + reference_cog = [0., 0., 0.] + print(f"Reference center of gravity is None ... set to {reference_cog}") + + header_avl_file = [avl.Header(runcase=runcasename, mach=mach_number, + sym=[0, 0, 0.0], base=reference_data[:3], ref=reference_cog)] + + avl.write_avl_file(author, header_avl_file + self.aerodynamic_components, filename) + + + + def add_runcase(self, runcase): + """ Add runcase + + Args: + runcase (object): Runcase + """ + if not isinstance(runcase, AvlRuncase): + print("Runcase will not be appended - no AvlRuncase...") + + else: + self.runcases.append(runcase) + + def number_of_runcases(self): + """Return numer of runcases""" + return len(self.runcases) + + def runcase_ids(self): + """Return list of runcase ids""" + return [rc.runcase_id for rc in self.runcases] + + def define_runcase_batch(self, runcase_folder: str=".", alphas: list[float]=[0.0], betas: list[float]=[0.0], pb2vs: list[float]=[0.0], qc2vs: list[float]=[0.0], rb2vs: list[float]=[0.0], mach_numbers: list[float]=[0.0], CD0: float=0.02, heights_in_m: list[float]=[0.0], cog: list[float]=[0.0,0.0,0.0], controls: list[str]=[], clear_runcase_folder: bool=True): + """ + Defines a batch of AVL run cases by varying aerodynamic parameters. + + Args: + runcase_folder (str, optional): Path to store run case files. Defaults to ".". + alphas (list[float], optional): List of angle of attack values. Defaults to [0.0]. + betas (list[float], optional): List of sideslip angle values. Defaults to [0.0]. + pb2vs (list[float], optional): List of roll rate values. Defaults to [0.0]. + qc2vs (list[float], optional): List of pitch rate values. Defaults to [0.0]. + rb2vs (list[float], optional): List of yaw rate values. Defaults to [0.0]. + mach_numbers (list[float], optional): List of Mach numbers. Defaults to [0.0]. + CD0 (float, optional): Zero-lift drag coefficient. Defaults to 0.02. + heights_in_m (list[float], optional): List of altitude values in meters. Defaults to [0.0]. + cog (list[float], optional): Center of gravity coordinates [x, y, z]. Defaults to [0.0, 0.0, 0.0]. + controls (list[str], optional): List of control surface names. Defaults to an empty list. + clear_runcase_folder (bool, optional): Whether to clear the run case folder before creation. Defaults to True. + """ + runcase_id = self.number_of_runcases() + 1 + print("Clear existing runcases ...") + self.runcases.clear() + if os.path.isdir(runcase_folder) and clear_runcase_folder: + if not self.is_directory_empty(runcase_folder): + self.empty_directory(runcase_folder) + + # Create list from set + if len(controls) == 0: + controls=list(self.aerodynamic_surface_control_keys) + + # Create different runcase batch + for mach_number, height_in_m in zip(mach_numbers,heights_in_m): + for rb2v in rb2vs: + for qc2v in qc2vs: + for pb2v in pb2vs: + for beta in betas: + for alpha in alphas: + self.runcases.append(AvlRuncase(runcase_folder, runcase_id, f"RunCase {runcase_id} - {height_in_m:.2f}", alpha, beta, pb2v, qc2v, rb2v, mach_number*np.cos(self.aerodynamic_reference_data[3]), CD0, height_in_m, cog, controls).create()) + runcase_id += 1 + + print(f"Current number of Runcases: ... {self.number_of_runcases()}") + + def run_avl(self, runcase_folder, stability_files_folder, results_folder, derivative_collection_file=None): + """ + Executes an AVL simulation using the specified run cases and stability file storage. + + Args: + runcase_folder (str): Path to the folder containing AVL run case files. + stability_files_folder (str): Path to the folder where AVL stability results will be stored. + results_folder (str): Path to the folder where final results will be saved. + derivative_collection_file (str, optional): Name of the output CSV file to store aerodynamic derivatives. + + Returns: + avl.PyAvlStabilityFilesConvert or None: Returns processed aerodynamic derivatives if `derivative_collection_file` is provided, otherwise None. + """ + geometry_file_name = f"{self.aircraft_name}.avl" + runner = avl.pyavlrunner.PyAvlRunner(geometry_file_name) + runner.session_setup_single_rc(path_to_session_folder=self.aircraft_name, runcase_files_folder=runcase_folder, stability_files_folder=stability_files_folder, results_folder=results_folder, runcase_ids=self.runcase_ids()) + runner.run_avl_session() + + if isinstance(derivative_collection_file, str): + + os.makedirs(results_folder, exist_ok=True) + stability_files = [f"{runner.stability_files_folder()}/{file}" for file in os.listdir(runner.stability_files_folder()) if file.endswith(".stab")] + self.derivative_data = avl.PyAvlStabilityFilesConvert(filenames=stability_files, + keyListControls=list(self.aerodynamic_surface_control_keys), fileout=f"{results_folder}/{derivative_collection_file}.csv") + return self.derivative_data + + def is_directory_empty(self,directory): + """Check whether a directory is empty or ot""" + return not any(os.scandir(directory)) + + def empty_directory(self,directory): + """ Empty directory + + Args: + directory (str): removes the content of this directory + """ + for item in os.listdir(directory): + item_path = os.path.join(directory, item) + if os.path.isfile(item_path): + os.remove(item_path) + elif os.path.isdir(item_path): + shutil.rmtree(item_path) + +class AvlRuncase: + """ + Defines an AVL run case with aerodynamic parameters. + """ + def __init__(self, runcase_folder: str=".", runcase_id: int=1, runcase_name: str="default_runcase", alpha: float=0.0, beta: float=0.0, pb2v: float=0.0, qc2v: float=0.0, rb2v: float=0.0, mach_number: float=0.0, CD0: float=0.02, height_in_m: float=0.0, cog: list[float]=[0.0,0.0,0.0], controls=None): + """ + Initializes an AVL run case. + """ + self.runcase_directory = runcase_folder + self.runcase_id = runcase_id + self.runcase_name = runcase_name + self.runcase_file: str= f"rc_{runcase_id:03}.run" + self.alpha = alpha + self.beta = beta + self.pb2v = pb2v + self.qc2v = qc2v + self.rb2v = rb2v + self.controls = controls + self.CD0 = CD0 + self.mach_number = mach_number + self.altitude = height_in_m + self.density = Atmosphere(self.altitude).density + self.speed_of_sound = Atmosphere(self.altitude).speed_of_sound + self.velocity = mach_number * self.speed_of_sound + self.cog = cog + + def create(self): + """ + Creates an AVL run case file in the specified directory. + """ + # Create runcase directory + os.makedirs(self.runcase_directory, exist_ok=True) + + full_path_to_runcase = f"{self.runcase_directory}/{self.runcase_file}" + if os.path.exists(full_path_to_runcase): + os.remove(full_path_to_runcase) + with open(full_path_to_runcase, "+w") as rcfile: + avl.write_runcase(file=rcfile, runcasenum=1, runcasename=self.runcase_name, alpha=self.alpha, beta=self.beta, pb2v=self.pb2v, qc2v=self.qc2v, rb2v=self.rb2v, mach=self.mach_number, CD0=self.CD0, density=self.density, a=self.speed_of_sound, cgref=self.cog, controls=self.controls) + return self diff --git a/pyavlunicadopackage/pyavlunicado/avlunicadoutility.py b/pyavlunicadopackage/pyavlunicado/avlunicadoutility.py new file mode 100644 index 0000000000000000000000000000000000000000..bc7a79277726c3e9ebe57904946228aeb8a55bc2 --- /dev/null +++ b/pyavlunicadopackage/pyavlunicado/avlunicadoutility.py @@ -0,0 +1,179 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +PyAvlUnicado - A Python Interface for Athena Vortex Lattice (AVL) for use with UNICADO + +This module provides utility functions for handling aircraft geometry data, +specifically for interacting with aircraft XML files and extracting aerodynamic +surface information. + +Functions: + - get_element_path(element, tree): Retrieves the hierarchical path of an XML element. + - load_aerodynamic_surfaces(paths, component_names): Loads aerodynamic surfaces from aircraft XML data. + - load_aerodynamic_surfaces_from_information(paths, aerodynamic_surfaces_information): Extracts aerodynamic surface details. + - aerodynamic_surfaces_from_component_available(component): Identifies aerodynamic surfaces in a component. + - load_reference_position(component): Extracts the reference position of a component. +""" + +import pyaircraftgeometry2 as geom2 +import pyaixml as aixml + + +def get_element_path(element, tree): + """ + Retrieves the hierarchical path of an XML element relative to the root. + + Args: + element (ET.Element): The XML element whose path is to be determined. + tree (ET.Element): The root of the XML tree. + + Returns: + str: The hierarchical path of the element. + """ + element_path = [] + while element != tree: + parent = tree.find(f".//{element.tag}/..") + element_path.append(element.tag) + element = parent + element_path.reverse() + return '/'.join(element_path) + + +def load_aerodynamic_surfaces(paths, component_names: list[str]=[]): + """ + Loads aerodynamic surface data from aircraft XML files. + + Args: + paths (dict): Dictionary containing paths to aircraft exchange files. + component_names (list[str], optional): List of component names to extract data from. + + Returns: + dict: A dictionary containing aerodynamic surface details for each component. + + Raises: + RuntimeError: If component_names is not a valid list of strings. + """ + component_design_items = None + if component_names or isinstance(component_names, list) and all(isinstance(name, str) for name in component_names): + + if component_names: + component_design_items = [] + for component_name in component_names: + component_design_items.append(paths["root_of_aircraft_exchange_tree"].find( + f".//component_design/{component_name}")) + + else: + component_design_items = paths["root_of_aircraft_exchange_tree"].find('.//component_design') + + aerodynamic_surfaces = {} + if component_design_items is not None: + for component in component_design_items: + info = aerodynamic_surfaces_from_component_available(component) + if info is not None: + aerodynamic_surfaces[component.tag] = { + "aerodynamic_surfaces": load_aerodynamic_surfaces_from_information(paths, info), + "reference_position": load_reference_position(component), + "info": info + } + return aerodynamic_surfaces + else: + raise RuntimeError( + f"component_names does not match required type list - current type: {type(component_names)}") + + +def load_aerodynamic_surfaces_from_information(paths, aerodynamic_surfaces_information): + """ + Extracts aerodynamic surfaces from the given aircraft XML data. + + Args: + paths (dict): Dictionary containing paths to aircraft exchange files. + aerodynamic_surfaces_information (list): List of aerodynamic surface information. + + Returns: + list: A list of aerodynamic surfaces including geometry and control devices. + """ + acxml = aixml.openDocument(paths["path_to_aircraft_exchange_file"]) + aerodynamic_surfaces = [] + for aerodynamic_surface_info in aerodynamic_surfaces_information: + aerodynamic_surface = {} + aerodynamic_surface["geometry"] = geom2.factory.WingFactory( + acxml, paths["airfoil_data_directory"]).create(aerodynamic_surface_info["geometry_path"]) + + aerodynamic_surface["control_devices"] = [] + for control_device in aerodynamic_surface_info["control_devices"]: + device = geom2.factory.ControlDeviceFactory(acxml, paths["airfoil_data_directory"]).create(control_device["path"]) + device.name = control_device["name"] + aerodynamic_surface["control_devices"].append(device) + aerodynamic_surfaces.append(aerodynamic_surface) + return aerodynamic_surfaces + + +def aerodynamic_surfaces_from_component_available(component): + """ + Identifies aerodynamic surfaces in an aircraft component. + + Args: + component (ET.Element): XML element representing the aircraft component. + + Returns: + list or None: A list of aerodynamic surfaces found in the component, or None if no surfaces are available. + """ + components_aerodynamic_surfaces = component.findall(".//aerodynamic_surface[@ID]") + if components_aerodynamic_surfaces: + aerodynamic_surfaces_available = [] + + for components_aerodynamic_surface in components_aerodynamic_surfaces: + aerodynamic_surface_available = {} + aerodynamic_surface_available["geometry_path"] = f"{component.tag}/{get_element_path(components_aerodynamic_surface, component)}@{components_aerodynamic_surface.attrib['ID']}" + aerodynamic_surface_available["control_devices"] = [] + + # look for control devices + control_devices = components_aerodynamic_surface.findall(".//control_device[@ID]") + if control_devices: + for control_device in control_devices: + control_device_info = { + "path": f"{aerodynamic_surface_available['geometry_path']}/{get_element_path(control_device, components_aerodynamic_surface)}@{control_device.attrib['ID']}", + "name": control_device.attrib["description"] + } + aerodynamic_surface_available["control_devices"].append(control_device_info) + aerodynamic_surfaces_available.append(aerodynamic_surface_available) + return aerodynamic_surfaces_available + else: + return None + +def load_reference_position(component): + """ + Extracts the reference position of an aircraft component. + + Args: + component (ET.Element): XML element representing the aircraft component. + + Returns: + dict: Dictionary containing x, y, and z coordinates of the reference position. + """ + position = { + "x": float(component.find("./position/x/value").text), + "y": float(component.find("./position/y/value").text), + "z": float(component.find("./position/z/value").text) + } + + return position + diff --git a/pyavlunicadopackage/pyavlunicado/xfoilgeometryscale.py b/pyavlunicadopackage/pyavlunicado/xfoilgeometryscale.py new file mode 100644 index 0000000000000000000000000000000000000000..0f9d1df2ba69c5391ae902e521e964c342782b9b --- /dev/null +++ b/pyavlunicadopackage/pyavlunicado/xfoilgeometryscale.py @@ -0,0 +1,885 @@ +# UNICADO - UNIversity Conceptual Aircraft Design and Optimization +# +# Copyright (C) 2025 UNICADO consortium +# +# This program 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/>. +# +# Description: +# This file is part of UNICADO. + +""" +------------------------------------------------------------------------------ +This file is a direct translation and copy of portions of the XFOIL code, +originally written by Mark Drela. The implementation below is based on +the XFOIL routines and is provided as-is under the original XFOIL license. + +XFOIL License: +-------------- +Copyright (C) 1996, 1998, 2002, 2005 by Mark Drela. All rights reserved. + +XFOIL is provided "as is" without warranty of any kind, either expressed +or implied, including but not limited to the implied warranties of +merchantability and fitness for a particular purpose. + +For more information on the XFOIL license, please refer to the license file +included with the original XFOIL distribution or visit: +http://web.mit.edu/drela/Public/web/xfoil/ + +------------------------------------------------------------------------------ +""" + +""" +xfoil_geometry.py + +A collection of routines to compute spline derivatives, +evaluate splines, find leading edge parameters, opposite points, +arc lengths, curvatures, geometric properties, and to update airfoil +camber and thickness. +""" + +import numpy as np + + +def tri_solve(A: np.ndarray, B: np.ndarray, C: np.ndarray, D: np.ndarray, kk: int) -> None: + """ + Solve a tri-diagonal system in place. + + The system is assumed to be: + A[i]*D[i] + B[i+1]*D[i+1] + C[i]*D[i-1] = original_D[i] + where D is replaced with the solution. + + Parameters + ---------- + A : ndarray + Main diagonal (length kk). + B : ndarray + Upper diagonal (length kk); note B[0] is unused. + C : ndarray + Lower diagonal (length kk); note C[-1] is unused. + D : ndarray + Right-hand side (length kk); will be overwritten with solution. + kk : int + Number of equations. + """ + for k in range(1, kk): + km = k - 1 + C[km] /= A[km] + D[km] /= A[km] + A[k] -= B[k] * C[km] + D[k] -= B[k] * D[km] + D[kk - 1] /= A[kk - 1] + for k in range(kk - 2, -1, -1): + D[k] -= C[k] * D[k + 1] + # The solution is now in D. + + +def splind(X: np.ndarray, S: np.ndarray, xs1: float, xs2: float) -> np.ndarray: + """ + Compute spline derivative array for a function defined by X(S). + + End conditions: + - If xs1 or xs2 equal 999.0, use the usual zero second derivative condition. + - If xs1 or xs2 equal -999.0, use the zero third derivative condition. + - Otherwise, use the provided derivative. + + Parameters + ---------- + X : ndarray + Dependent variable array. + S : ndarray + Independent variable (parameter) array. + xs1 : float + Left endpoint derivative condition. + xs2 : float + Right endpoint derivative condition. + + Returns + ------- + ndarray + Spline derivative array. + """ + N = len(X) + if N > 1000: + raise ValueError("SPLIND: array overflow, increase NMAX") + A = np.zeros(N) + B = np.zeros(N) + C = np.zeros(N) + D = np.zeros(N) + + # Interior points (i = 1 to N-2) + for i in range(1, N - 1): + dsm = S[i] - S[i - 1] + dsp = S[i + 1] - S[i] + B[i] = dsp + A[i] = 2.0 * (dsm + dsp) + C[i] = dsm + D[i] = 3.0 * (((X[i + 1] - X[i]) * dsm / dsp) + + ((X[i] - X[i - 1]) * dsp / dsm)) + # Left endpoint condition + if xs1 == 999.0: + A[0] = 2.0 + C[0] = 1.0 + D[0] = 3.0 * (X[1] - X[0]) / (S[1] - S[0]) + elif xs1 == -999.0: + A[0] = 1.0 + C[0] = 1.0 + D[0] = 2.0 * (X[1] - X[0]) / (S[1] - S[0]) + else: + A[0] = 1.0 + C[0] = 0.0 + D[0] = xs1 + + # Right endpoint condition + if xs2 == 999.0: + B[-1] = 1.0 + A[-1] = 2.0 + D[-1] = 3.0 * (X[-1] - X[-2]) / (S[-1] - S[-2]) + elif xs2 == -999.0: + B[-1] = 1.0 + A[-1] = 1.0 + D[-1] = 2.0 * (X[-1] - X[-2]) / (S[-1] - S[-2]) + else: + A[-1] = 1.0 + B[-1] = 0.0 + D[-1] = xs2 + if N == 2 and xs1 == -999.0 and xs2 == -999.0: + B[-1] = 1.0 + A[-1] = 2.0 + D[-1] = 3.0 * (X[1] - X[0]) / (S[1] - S[0]) + tri_solve(A, B, C, D, N) + return D + + +def segspl(X: np.ndarray, S: np.ndarray) -> np.ndarray: + """ + Compute segmented spline derivatives for X(S), allowing discontinuities at duplicate S values. + + Parameters + ---------- + X : ndarray + Data array. + S : ndarray + Parameter array. + + Returns + ------- + ndarray + Derivative array computed piecewise using splind. + + Raises + ------ + ValueError + If the first or last S values are duplicated. + """ + N = len(S) + if np.isclose(S[0], S[1]): + raise ValueError("SEGSPL: First input point duplicated") + if np.isclose(S[-1], S[-2]): + raise ValueError("SEGSPL: Last input point duplicated") + XS = np.empty_like(X) + seg_start = 0 + for i in range(1, N - 1): + if np.isclose(S[i], S[i + 1]): + seg_slice = slice(seg_start, i + 1) + XS[seg_slice] = splind(X[seg_slice].copy(), S[seg_slice].copy(), xs1=-999.0, xs2=-999.0) + seg_start = i + 1 + seg_slice = slice(seg_start, N) + XS[seg_slice] = splind(X[seg_slice].copy(), S[seg_slice].copy(), xs1=-999.0, xs2=-999.0) + return XS + + +def seval(ss: float, X: np.ndarray, XS: np.ndarray, S: np.ndarray) -> float: + """ + Evaluate the spline defined by X(S) with derivatives XS at parameter value ss. + + Parameters + ---------- + ss : float + Parameter value at which to evaluate. + X : ndarray + Data array. + XS : ndarray + Spline derivative array. + S : ndarray + Parameter array. + + Returns + ------- + float + Interpolated value. + """ + i = np.searchsorted(S, ss, side='right') + if i == 0: + i = 1 + if i >= len(S): + i = len(S) - 1 + ds = S[i] - S[i - 1] + t = (ss - S[i - 1]) / ds + cx1 = ds * XS[i - 1] - X[i] + X[i - 1] + cx2 = ds * XS[i] - X[i] + X[i - 1] + return t * X[i] + (1.0 - t) * X[i - 1] + (t - t * t) * ((1.0 - t) * cx1 - t * cx2) + + +def deval(ss: float, X: np.ndarray, XS: np.ndarray, S: np.ndarray) -> float: + """ + Evaluate the first derivative of the spline at parameter value ss. + + Parameters + ---------- + ss : float + Parameter value. + X : ndarray + Data array. + XS : ndarray + Spline derivative array. + S : ndarray + Parameter array. + + Returns + ------- + float + First derivative dX/dS evaluated at ss. + """ + i = np.searchsorted(S, ss, side='right') + if i == 0: + i = 1 + if i >= len(S): + i = len(S) - 1 + ds = S[i] - S[i - 1] + t = (ss - S[i - 1]) / ds + cx1 = ds * XS[i - 1] - X[i] + X[i - 1] + cx2 = ds * XS[i] - X[i] + X[i - 1] + return (X[i] - X[i - 1] + + (1.0 - 4.0 * t + 3.0 * t * t) * cx1 + + t * (3.0 * t - 2.0) * cx2) / ds + + +def d2val(ss: float, X: np.ndarray, XS: np.ndarray, S: np.ndarray) -> float: + """ + Estimate the second derivative at ss using central finite differences. + + Parameters + ---------- + ss : float + Parameter value. + X : ndarray + Data array. + XS : ndarray + Spline derivative array. + S : ndarray + Parameter array. + + Returns + ------- + float + Approximated second derivative. + """ + eps = 1e-6 * (S[-1] - S[0]) + return (deval(ss + eps, X, XS, S) - deval(ss - eps, X, XS, S)) / (2 * eps) + + +def lefind(X: np.ndarray, XP: np.ndarray, Y: np.ndarray, YP: np.ndarray, S: np.ndarray) -> float: + """ + Locate the leading edge (LE) parameter S_LE. The LE is defined so that the + surface tangent is normal to the chord connecting the LE and the trailing edge. + + Parameters + ---------- + X, Y : ndarray + Coordinate arrays. + XP, YP : ndarray + Spline derivative arrays for X and Y. + S : ndarray + Parameter (arc length) array. + + Returns + ------- + float + The parameter value at the leading edge. + """ + N = len(S) + dseps = (S[-1] - S[0]) * 1.0e-5 + x_te = 0.5 * (X[0] + X[-1]) + y_te = 0.5 * (Y[0] + Y[-1]) + s_le = None + for i in range(2, N - 2): + dx_te = X[i] - x_te + dy_te = Y[i] - y_te + dx = X[i + 1] - X[i] + dy = Y[i + 1] - Y[i] + dotp = dx_te * dx + dy_te * dy + if dotp < 0.0: + s_le = S[i] + break + if s_le is None: + s_le = S[N // 2] + i_guess = np.searchsorted(S, s_le) + if i_guess > 0 and np.isclose(S[i_guess], S[i_guess - 1]): + return S[i_guess] + for _ in range(50): + x_le = seval(s_le, X, XP, S) + y_le = seval(s_le, Y, YP, S) + dxds = deval(s_le, X, XP, S) + dyds = deval(s_le, Y, YP, S) + dxdd = d2val(s_le, X, XP, S) + dydd = d2val(s_le, Y, YP, S) + x_chord = x_le - x_te + y_chord = y_le - y_te + res = x_chord * dxds + y_chord * dyds + ress = dxds**2 + dyds**2 + x_chord * dxdd + y_chord * dydd + ds_le = -res / ress + ds_le = max(ds_le, -0.02 * abs(x_chord + y_chord)) + ds_le = min(ds_le, 0.02 * abs(x_chord + y_chord)) + s_le += ds_le + if abs(ds_le) < dseps: + return s_le + print("LEFIND: LE point not found. Continuing...") + return s_le + + +def sopps( + si: float, + X: np.ndarray, + XP: np.ndarray, + Y: np.ndarray, + YP: np.ndarray, + S: np.ndarray, + s_le: float +) -> float: + """ + Find the opposite point parameter for a given point at parameter si. + The opposite point is determined by matching the chordwise coordinate. + + Parameters + ---------- + si : float + Parameter value of the given point. + X, Y : ndarray + Coordinate arrays. + XP, YP : ndarray + Spline derivative arrays. + S : ndarray + Parameter (arc-length) array. + s_le : float + Leading edge parameter. + + Returns + ------- + float + Parameter of the opposite point. + """ + N = len(S) + s_len = S[-1] - S[0] + x_le = seval(s_le, X, XP, S) + y_le = seval(s_le, Y, YP, S) + x_te = 0.5 * (X[0] + X[-1]) + y_te = 0.5 * (Y[0] + Y[-1]) + chord = np.sqrt((x_te - x_le) ** 2 + (y_te - y_le) ** 2) + dxc = (x_te - x_le) / chord + dyc = (y_te - y_le) / chord + if si < s_le: + i_idx = 0 + i_opp = N - 1 + else: + i_idx = N - 1 + i_opp = 0 + denom = S[i_idx] - s_le + s_frac = (si - s_le) / denom if denom != 0 else 0.0 + s_opp = s_le + s_frac * (S[i_opp] - s_le) + if abs(s_frac) <= 1.0e-5: + return s_le + xi = seval(si, X, XP, S) + yi = seval(si, Y, YP, S) + x_le = seval(s_le, X, XP, S) + y_le = seval(s_le, Y, YP, S) + xbar = (xi - x_le) * dxc + (yi - y_le) * dyc + for _ in range(12): + x_opp = seval(s_opp, X, XP, S) + y_opp = seval(s_opp, Y, YP, S) + xopp_d = deval(s_opp, X, XP, S) + yopp_d = deval(s_opp, Y, YP, S) + res = (x_opp - x_le) * dxc + (y_opp - y_le) * dyc - xbar + resd = xopp_d * dxc + yopp_d * dyc + if abs(res) / s_len < 1.0e-5: + break + if resd == 0.0: + print("SOPPS: Opposite-point location failed. Continuing...") + s_opp = s_le + s_frac * (S[i_opp] - s_le) + break + ds_opp = -res / resd + s_opp += ds_opp + if abs(ds_opp) / s_len < 1.0e-5: + break + return s_opp + + +def scalc(X: np.ndarray, Y: np.ndarray) -> np.ndarray: + """ + Compute the cumulative arc-length array for the 2-D curve defined by (X, Y). + + Parameters + ---------- + X, Y : ndarray + Coordinate arrays. + + Returns + ------- + ndarray + Cumulative arc-length. + """ + N = len(X) + S = np.empty(N) + S[0] = 0.0 + for i in range(1, N): + S[i] = S[i - 1] + np.hypot(X[i] - X[i - 1], Y[i] - Y[i - 1]) + return S + + +def curv(ss: float, X: np.ndarray, XS: np.ndarray, Y: np.ndarray, YS: np.ndarray, S: np.ndarray) -> float: + """ + Compute the curvature of a splined 2-D curve at parameter ss. + + Parameters + ---------- + ss : float + Parameter value. + X, Y : ndarray + Coordinate arrays. + XS, YS : ndarray + Spline derivative arrays. + S : ndarray + Parameter (arc-length) array. + + Returns + ------- + float + Curvature. + """ + N = len(S) + i = np.searchsorted(S, ss, side='right') + if i == 0: + i = 1 + if i >= N: + i = N - 1 + ds = S[i] - S[i - 1] + t = (ss - S[i - 1]) / ds + f1 = ds * XS[i - 1] + f2 = -ds * (2.0 * XS[i - 1] + XS[i]) + 3.0 * (X[i] - X[i - 1]) + f3 = ds * (XS[i - 1] + XS[i]) - 2.0 * (X[i] - X[i - 1]) + xd = f1 + t * (2.0 * f2 + t * 3.0 * f3) + xdd = 2.0 * f2 + t * 6.0 * f3 + + g1 = ds * YS[i - 1] + g2 = -ds * (2.0 * YS[i - 1] + YS[i]) + 3.0 * (Y[i] - Y[i - 1]) + g3 = ds * (YS[i - 1] + YS[i]) - 2.0 * (Y[i] - Y[i - 1]) + yd = g1 + t * (2.0 * g2 + t * 3.0 * g3) + ydd = 2.0 * g2 + t * 6.0 * g3 + + return (xd * ydd - yd * xdd) / np.sqrt((xd**2 + yd**2) ** 3) + + +def ae_calc(N: int, X: np.ndarray, Y: np.ndarray, T: np.ndarray, itype: int): + """ + Calculate geometric properties of the shape defined by X, Y. + + Parameters + ---------- + N : int + Number of points. + X, Y : ndarray + Coordinate arrays. + T : ndarray + Skin-thickness array (used only if itype == 2). + itype : int + 1 to integrate over whole area (dx dy); 2 to integrate over skin area (T ds). + + Returns + ------- + tuple + (area, xcen, ycen, ei11, ei22, apx1, apx2) + """ + PI = np.pi + sint = 0.0 + aint = 0.0 + xint = 0.0 + yint = 0.0 + xxint = 0.0 + xyint = 0.0 + yyint = 0.0 + for i in range(N): + ip = i + 1 if i < N - 1 else 0 + dx = X[i] - X[ip] + dy = Y[i] - Y[ip] + xa = 0.5 * (X[i] + X[ip]) + ya = 0.5 * (Y[i] + Y[ip]) + ta = 0.5 * (T[i] + T[ip]) + ds = np.hypot(dx, dy) + sint += ds + if itype == 1: + da = ya * dx + aint += da + xint += xa * da + yint += ya * da / 2.0 + xxint += xa**2 * da + xyint += xa * ya * da / 2.0 + yyint += ya**2 * da / 3.0 + else: + da = ta * ds + aint += da + xint += xa * da + yint += ya * da + xxint += xa**2 * da + xyint += xa * ya * da + yyint += ya**2 * da + if aint == 0.0: + return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + xcen = xint / aint + ycen = yint / aint + eiixx = yyint - ycen**2 * aint + eixy = xyint - xcen * ycen * aint + eiiyy = xxint - xcen**2 * aint + eisq = 0.25 * (eiixx - eiiyy)**2 + eixy**2 + sgn = 1.0 if (eiiyy - eiixx) >= 0.0 else -1.0 + ei11 = 0.5 * (eiixx + eiiyy) - sgn * np.sqrt(eisq) + ei22 = 0.5 * (eiixx + eiiyy) + sgn * np.sqrt(eisq) + if ei11 == 0.0 or ei22 == 0.0: + apx1 = 0.0 + apx2 = np.arctan2(1.0, 0.0) + elif eisq / (ei11 * ei22) < (0.001 * sint)**4: + apx1 = 0.0 + apx2 = np.arctan2(1.0, 0.0) + else: + c1 = eixy + s1 = eiixx - ei11 + c2 = eixy + s2 = eiixx - ei22 + if abs(s1) > abs(s2): + apx1 = np.arctan2(s1, c1) + apx2 = apx1 + 0.5 * PI + else: + apx2 = np.arctan2(s2, c2) + apx1 = apx2 - 0.5 * PI + if apx1 < -0.5 * PI: + apx1 += PI + if apx1 > 0.5 * PI: + apx1 -= PI + if apx2 < -0.5 * PI: + apx2 += PI + if apx2 > 0.5 * PI: + apx2 -= PI + return aint, xcen, ycen, ei11, ei22, apx1, apx2 + + +def tc_calc(X: np.ndarray, XP: np.ndarray, Y: np.ndarray, YP: np.ndarray, S: np.ndarray): + """ + Compute the maximum thickness and camber from discrete airfoil points. + + Parameters + ---------- + X, Y : ndarray + Coordinate arrays. + XP, YP : ndarray + Spline derivative arrays. + S : ndarray + Parameter (arc-length) array. + + Returns + ------- + tuple + (thickness, x_at_thickness, camber, x_at_camber) + """ + s_le = lefind(X, XP, Y, YP, S) + x_le = seval(s_le, X, XP, S) + y_le = seval(s_le, Y, YP, S) + x_te = 0.5 * (X[0] + X[-1]) + y_te = 0.5 * (Y[0] + Y[-1]) + chord = np.hypot(x_te - x_le, y_te - y_le) + dxc = (x_te - x_le) / chord + dyc = (y_te - y_le) / chord + thickness = 0.0 + x_thick = 0.0 + camber = 0.0 + x_camber = 0.0 + N = len(S) + for i in range(N): + x_bar = (X[i] - x_le) * dxc + (Y[i] - y_le) * dyc + y_bar = (Y[i] - y_le) * dxc - (X[i] - x_le) * dyc + s_opp = sopps(S[i], X, XP, Y, YP, S, s_le) + x_opp = seval(s_opp, X, XP, S) + y_opp = seval(s_opp, Y, YP, S) + y_bar_opp = (y_opp - y_le) * dxc - (x_opp - x_le) * dyc + yc = 0.5 * (y_bar + y_bar_opp) + yt = abs(y_bar - y_bar_opp) + if abs(yc) > abs(camber): + camber = yc + x_camber = x_opp + if abs(yt) > abs(thickness): + thickness = yt + x_thick = x_opp + return thickness, x_thick, camber, x_camber + + +def geopar( + X: np.ndarray, XP: np.ndarray, Y: np.ndarray, YP: np.ndarray, + S: np.ndarray, T: np.ndarray, verbose: bool=False +) -> dict: + """ + Compute overall geometric parameters of the airfoil shape. + + Parameters + ---------- + X, Y : ndarray + Coordinate arrays. + XP, YP : ndarray + Spline derivative arrays. + S : ndarray + Parameter (arc-length) array. + T : ndarray + Skin-thickness array (usually ones). + + Returns + ------- + dict + Dictionary of geometric parameters. + """ + N = len(S) + s_le = lefind(X, XP, Y, YP, S) + x_le = seval(s_le, X, XP, S) + y_le = seval(s_le, Y, YP, S) + x_te = 0.5 * (X[0] + X[-1]) + y_te = 0.5 * (Y[0] + Y[-1]) + chord = np.hypot(x_te - x_le, y_te - y_le) + curv_le = curv(s_le, X, XP, Y, YP, S) + rad_le = 1.0 / curv_le if abs(curv_le) > 0.001 * (S[-1] - S[0]) else 0.0 + ang1 = np.arctan2(-YP[0], -XP[0]) + ang2 = np.arctan2(YP[-1], XP[-1]) + ang_te = ang2 - ang1 + area, xcena, ycena, ei11a, ei22a, apx1a, apx2a = ae_calc(N, X, Y, T, itype=1) + slen, xcent, ycent, ei11t, ei22t, apx1t, apx2t = ae_calc(N, X, Y, T, itype=2) + thick, x_thick, cambr, x_camber = tc_calc(X, XP, Y, YP, S) + if verbose: + print(f"Max thickness = {thick: .6f} at x = {x_thick: .3f}") + print(f"Max camber = {cambr: .6f} at x = {x_camber: .3f}") + return { + "SLE": s_le, "CHORD": chord, "AREA": area, "RADLE": rad_le, "ANGTE": ang_te, + "EI11A": ei11a, "EI22A": ei22a, "APX1A": apx1a, "APX2A": apx2a, + "EI11T": ei11t, "EI22T": ei22t, "APX1T": apx1t, "APX2T": apx2t, + "THICK": thick, "CAMBR": cambr + } + + +def sortol(x_arr: np.ndarray, y_arr: np.ndarray, tol: float) -> (np.ndarray, np.ndarray): + """ + Sort (x, y) pairs by x. + + Parameters + ---------- + x_arr : ndarray + x-coordinate array. + y_arr : ndarray + y-coordinate array. + tol : float + Tolerance (unused here, kept for compatibility). + + Returns + ------- + tuple + Sorted (x_arr, y_arr) arrays. + """ + order = np.argsort(x_arr) + return x_arr[order], y_arr[order] + + +def getcam( + X: np.ndarray, XP: np.ndarray, Y: np.ndarray, YP: np.ndarray, S: np.ndarray +) -> (np.ndarray, np.ndarray, int, np.ndarray, np.ndarray, int): + """ + Compute the camber line and thickness distribution for an airfoil. + + Parameters + ---------- + X, Y : ndarray + Coordinate arrays. + XP, YP : ndarray + Spline derivative arrays. + S : ndarray + Parameter (arc-length) array. + + Returns + ------- + tuple + (x_camber, y_camber, n_camber, x_thick, y_thick, n_thick) + """ + N = len(S) + s_le = lefind(X, XP, Y, YP, S) + x_le = seval(s_le, X, XP, S) + y_le = seval(s_le, Y, YP, S) + x_camber = np.empty(N) + y_camber = np.empty(N) + x_thick = np.empty(N) + y_thick = np.empty(N) + for i in range(N): + if i == 0: + x_opp = X[-1] + y_opp = Y[-1] + elif i == N - 1: + x_opp = X[0] + y_opp = Y[0] + else: + s_opp = sopps(S[i], X, XP, Y, YP, S, s_le) + x_opp = seval(s_opp, X, XP, S) + y_opp = seval(s_opp, Y, YP, S) + x_camber[i] = 0.5 * (X[i] + x_opp) + y_camber[i] = 0.5 * (Y[i] + y_opp) + x_thick[i] = 0.5 * (X[i] + x_opp) + y_thick[i] = abs(0.5 * (Y[i] - y_opp)) + # Append the leading-edge point + x_camber = np.append(x_camber, x_le) + y_camber = np.append(y_camber, y_le) + n_camber = len(x_camber) + x_thick = np.append(x_thick, x_le) + y_thick = np.append(y_thick, 0.0) + n_thick = len(x_thick) + tol_val = 1.0e-5 * (S[-1] - S[0]) + x_camber, y_camber = sortol(x_camber, y_camber, tol_val) + y_camber -= y_camber[0] + x_thick, y_thick = sortol(x_thick, y_thick, tol_val) + return x_camber, y_camber, n_camber, x_thick, y_thick, n_thick + + +def thkcam( + tfac: float, + cfac: float, + XB: np.ndarray, + XBP: np.ndarray, + YB: np.ndarray, + YBP: np.ndarray, + SB: np.ndarray +) -> (np.ndarray, np.ndarray, dict): + """ + Adjust the airfoil (buffer) thickness and camber. + + Parameters + ---------- + tfac : float + Thickness scaling factor. + cfac : float + Camber scaling factor. + XB, YB : ndarray + Buffer airfoil coordinate arrays. + XBP, YBP : ndarray + Buffer airfoil spline derivative arrays. + SB : ndarray + Parameter (arc-length) array for the buffer. + + Returns + ------- + tuple + Updated XB, YB arrays and a dictionary of geometric parameters. + """ + NB = len(SB) + s_le = lefind(XB, XBP, YB, YBP, SB) + x_le = seval(s_le, XB, XBP, SB) + y_le = seval(s_le, YB, YBP, SB) + x_te = 0.5 * (XB[0] + XB[-1]) + y_te = 0.5 * (YB[0] + YB[-1]) + chord = np.hypot(x_te - x_le, y_te - y_le) + dxc = (x_te - x_le) / chord + dyc = (y_te - y_le) / chord + W1 = np.empty(NB) + W2 = np.empty(NB) + for i in range(NB): + if i == 0: + xb_opp = XB[-1] + yb_opp = YB[-1] + elif i == NB - 1: + xb_opp = XB[0] + yb_opp = YB[0] + else: + s_opp = sopps(SB[i], XB, XBP, YB, YBP, SB, s_le) + xb_opp = seval(s_opp, XB, XBP, SB) + yb_opp = seval(s_opp, YB, YBP, SB) + xc_avg = 0.5 * (XB[i] + xb_opp) * dxc + 0.5 * (YB[i] + yb_opp) * dyc + yc_avg = cfac * (0.5 * (YB[i] + yb_opp) * dxc - 0.5 * (XB[i] + xb_opp) * dyc) + xc_del = 0.5 * (XB[i] - xb_opp) * dxc + 0.5 * (YB[i] - yb_opp) * dyc + yc_del = tfac * (0.5 * (YB[i] - yb_opp) * dxc - 0.5 * (XB[i] - xb_opp) * dyc) + W1[i] = (xc_avg + xc_del) * dxc - (yc_avg + yc_del) * dyc + W2[i] = (yc_avg + yc_del) * dxc + (xc_avg + xc_del) * dyc + + # Update buffer coordinates with new values + XB[:] = W1 + YB[:] = W2 + + # Recalculate arc-length and update spline derivatives + SB_new = scalc(XB, YB) + XBP = segspl(XB, SB_new) + YBP = segspl(YB, SB_new) + # For GEOPAR, define a thickness array (using ones) + T = np.ones_like(XB) + geo_params = geopar(XB, XBP, YB, YBP, SB_new, T) + return XB, YB, geo_params + +def load_profile(file_path: str=""): + """ + Reads an airfoil profile file and returns the x and y coordinates. + Assumes the file contains two whitespace-separated columns. + """ + try: + data = np.loadtxt(file_path) + except Exception as e: + print(f"Error reading {file_path}: {e}") + return None, None + if data.ndim == 1 or data.shape[1] < 2: + print(f"File {file_path} does not have two columns.") + return None, None + + return data # x, y coordinates + + +def rescale_profile(coordinates: np.ndarray=None, scale_t_to_c: float=1.0, scale_camber: float=1.0) -> np.ndarray: + """ Rescale profile according to thickness to chord or to camber + + Keyword Arguments: + coordinates {np.ndarray} -- coordinates x, y (from TE,UP -> LE -> TE,DN) (default: {None}) + scale_t_to_c {float} -- scaling factor to scale current thickness to chord (default: {1.0}) + scale_camber {float} -- scaling factor to scale current camber (default: {1.0}) + + Returns: + np.ndarray -- scaled coordinates + """ + x = coordinates[:, 0] + y = coordinates[:, 1] + + # Spline points + s = np.linspace(0, 1, coordinates.shape[0]) + xp = segspl(x, s) + yp = segspl(y, s) + + xcpy = x.copy() + ycpy = y.copy() + scpy = s.copy() + xpcpy = xp.copy() + ypcpy = yp.copy() + x_new, y_new, _ = thkcam(scale_t_to_c, scale_camber, xcpy, xpcpy, ycpy, ypcpy, scpy) + return np.column_stack((x_new, y_new)) + +def save_profile(coordinates: np.ndarray=None, file: str="./scaled_profile.dat") -> None: + """ Save profile coordinates in file + + Keyword Arguments: + coordinates {np.ndarray} -- profile coordinates (default: {None}) + file {str} -- file + (default: {"./scaled_profile.dat"}) + """ + np.savetxt(file, coordinates, fmt="%.8f", delimiter=" ") + return \ No newline at end of file diff --git a/pyavlunicadopackage/pyproject.toml b/pyavlunicadopackage/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..ab380c63ed8f549170078113797d7be030c1bbf9 --- /dev/null +++ b/pyavlunicadopackage/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyavlunicado" +version = "1.0.0" +description = "Unicado avl interface to build generate aerodynamic derivatives." +authors = [{ name = "Christopher Ruwisch", email = "christopher.ruwisch@tu-berlin.de" }] +readme = "README.md" +license = { file = "LICENSE" } +keywords = ["avl"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", +] +dependencies = [ + "numpy>=1.26.4", +] + +[tool.setuptools] +packages = ["pyavlunicado"] + +[project.urls] +homepage = "https://unicado.pages.rwth-aachen.de/unicado.gitlab.io/" +repository = "https://git.rwth-aachen.de/unicado/libraries" \ No newline at end of file