"""Parse an AVL geometry file (.avl) into a nested Python structure."""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from pathlib import Path
[docs]
@dataclass
class AvlControlEntry:
"""One control-surface definition attached to a section."""
name: str
gain: float
xhinge: float
xyzhvec: tuple[float, float, float]
sgn_dup: float = 1.0
[docs]
@dataclass
class AvlSectionEntry:
"""One chordwise section of a lifting surface."""
xle: float
yle: float
zle: float
chord: float
ainc: float = 0.0
nspan: float = 0.0
sspan: float = 0.0
naca: str | None = None
afile: str | None = None
claf: float | None = None
controls: list[AvlControlEntry] = field(default_factory=list)
[docs]
@dataclass
class AvlSurface:
nchord: float = 0.0
cspace: float = 0.0
nspan: float | str = ""
sspace: float | str = ""
ydupl: float | str = ""
lcomp: float | None = None
scale: list[float] | None = None
trans: list[float] | None = None
dainc: float | None = None
nowake: bool = False
noalbe: bool = False
noload: bool = False
sections: list[AvlSectionEntry] = field(default_factory=list)
[docs]
@dataclass
class AvlBody:
name: str = ""
nbody: float = 0.0
bspace: float = 0.0
ydupl: float | str = ""
scale: list[float] | None = None
trans: list[float] | None = None
bfile: str = ""
bfile_x: list[float] = field(default_factory=list)
bfile_y: list[float] = field(default_factory=list)
[docs]
@dataclass
class AvlGeometry:
header: AvlHeader = field(default_factory=AvlHeader)
body: AvlBody | None = None
surface: dict[str, AvlSurface] = field(default_factory=dict)
@property
def ctrl_names(self) -> list[str]:
"""Return ordered unique control-surface names across all surfaces.
Example
-------
>>> from avl_aero_tables.avl_fileread import avl_fileread
>>> geom = avl_fileread("examples/bd.avl")
>>> geom.ctrl_names
['flap', 'aileron', 'elevator', 'rudder']
"""
all_names = (
ctrl.name
for surf in self.surface.values()
for section in surf.sections
for ctrl in section.controls
if ctrl.name
)
return list(dict.fromkeys(all_names))
def _to_valid_key(name: str) -> str:
"""Convert a surface name to a valid Python identifier (mirrors MATLAB genvarname).""" # noqa: E501
key = name.strip().replace(" ", "_").replace("-", "_")
if key and key[0].isdigit():
key = "x" + key
return key
def _floats(line: str) -> list[float]:
"""Parse leading numeric tokens from a line, stopping at non-numeric text."""
result = []
for token in line.split():
try:
result.append(float(token))
except ValueError:
break
return result
[docs]
def avl_fileread(avl_file: str | Path) -> AvlGeometry:
"""Parse an AVL geometry file and return an AvlGeometry dataclass tree.
Example
-------
>>> from avl_aero_tables.avl_fileread import avl_fileread
>>> geom = avl_fileread("examples/bd.avl")
>>> geom.header.name
'Bubble Dancer RES'
>>> list(geom.surface.keys())
['Wing', 'Horizontal_tail', 'Vertical_tail']
>>> geom.ctrl_names
['flap', 'aileron', 'elevator', 'rudder']
"""
avl_file = Path(avl_file)
eval_lines = [
line
for line in avl_file.read_text().splitlines()
if line.strip() and not line.strip().startswith(("!", "#"))
]
geom = AvlGeometry()
hdr = geom.header
hdr.name = eval_lines[0].strip()
hdr.Mach = _floats(eval_lines[1])[0]
vals = _floats(eval_lines[2])
hdr.iYsym, hdr.iZsym, hdr.Zsym = int(vals[0]), int(vals[1]), vals[2]
vals = _floats(eval_lines[3])
hdr.Sref, hdr.Cref, hdr.Bref = vals[0], vals[1], vals[2]
vals = _floats(eval_lines[4])
hdr.Xref, hdr.Yref, hdr.Zref = vals[0], vals[1], vals[2]
cdo_vals = _floats(eval_lines[5])
if cdo_vals:
hdr.CDoref = cdo_vals[0]
i = 6
else:
i = 5
n = len(eval_lines)
cur_surf: AvlSurface | None = None
while i < n:
tline = eval_lines[i].strip()
# BODY and SURFACE are sequential ifs (not elif): after BODY's inner loop
# exits on a SURFACE line, the SURFACE block fires in the same iteration.
if tline.upper() == "BODY":
body = AvlBody()
geom.body = body
i += 1
vals = _floats(eval_lines[i])
if len(vals) >= 2:
body.nbody, body.bspace = vals[0], vals[1]
i += 1
while i < n and eval_lines[i].strip().upper() != "SURFACE":
kw = eval_lines[i].strip().upper()
if kw == "YDUPLICATE":
i += 1
body.ydupl = _floats(eval_lines[i])[0]
elif kw == "SCALE":
i += 1
body.scale = _floats(eval_lines[i])
elif kw == "TRANSLATE":
i += 1
body.trans = _floats(eval_lines[i])
elif kw == "BFIL":
i += 1
body.bfile = eval_lines[i].strip()
bfil_path = avl_file.parent / body.bfile
if bfil_path.exists():
for bl in bfil_path.read_text().splitlines()[1:]:
bparts = bl.split()
if len(bparts) >= 2:
body.bfile_x.append(float(bparts[0]))
body.bfile_y.append(float(bparts[1]))
else:
warnings.warn(f"Body file not found: {bfil_path}")
i += 1
if i >= n:
break
tline = eval_lines[i].strip()
if tline.upper() == "SURFACE":
i += 1
surf_name = _to_valid_key(eval_lines[i])
i += 1
vals = _floats(eval_lines[i])
cur_surf = AvlSurface(
nchord=vals[0],
cspace=vals[1],
nspan=vals[2] if len(vals) > 2 else "",
sspace=vals[3] if len(vals) > 3 else "",
)
geom.surface[surf_name] = cur_surf
elif cur_surf is not None:
kw = tline.upper()
if kw == "COMPONENT":
i += 1
cur_surf.lcomp = _floats(eval_lines[i])[0]
elif kw == "YDUPLICATE":
i += 1
cur_surf.ydupl = _floats(eval_lines[i])[0]
elif kw == "SCALE":
i += 1
cur_surf.scale = _floats(eval_lines[i])
elif kw == "TRANSLATE":
i += 1
cur_surf.trans = _floats(eval_lines[i])
elif kw == "ANGLE":
i += 1
cur_surf.dainc = _floats(eval_lines[i])[0]
elif kw == "NOWAKE":
cur_surf.nowake = True
elif kw == "NOALBE":
cur_surf.noalbe = True
elif kw == "NOLOAD":
cur_surf.noload = True
elif kw == "SECTION":
i += 1
vals = _floats(eval_lines[i])
cur_surf.sections.append(
AvlSectionEntry(
xle=vals[0] if vals else 0.0,
yle=vals[1] if len(vals) > 1 else 0.0,
zle=vals[2] if len(vals) > 2 else 0.0,
chord=vals[3] if len(vals) > 3 else 0.0,
ainc=vals[4] if len(vals) > 4 else 0.0,
nspan=vals[5] if len(vals) > 5 else 0.0,
sspan=vals[6] if len(vals) > 6 else 0.0,
)
)
elif kw == "NACA":
i += 1
cur_surf.sections[-1].naca = eval_lines[i].strip()
elif kw in ("AIRFOIL", "DESIGN"):
pass # TODO: not yet implemented
elif kw == "AFIL":
i += 1
cur_surf.sections[-1].afile = eval_lines[i].strip()
elif kw == "CONTROL":
i += 1
parts = eval_lines[i].split()
if len(parts) < 6:
raise ValueError(
f"CONTROL line has {len(parts)} tokens, expected at least 6 "
f"(name gain xhinge XYZhvec[0-2] [SgnDup]): {eval_lines[i]!r}"
)
cur_surf.sections[-1].controls.append(
AvlControlEntry(
name=parts[0],
gain=float(parts[1]),
xhinge=float(parts[2]),
xyzhvec=(float(parts[3]), float(parts[4]), float(parts[5])),
sgn_dup=float(parts[6]) if len(parts) > 6 else 1.0,
)
)
elif kw == "CLAF":
i += 1
cur_surf.sections[-1].claf = _floats(eval_lines[i])[0]
i += 1
return geom