-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpythonxfoil.py
More file actions
295 lines (245 loc) · 11.1 KB
/
Copy pathpythonxfoil.py
File metadata and controls
295 lines (245 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import math
import os
import subprocess
import tempfile
import concurrent.futures
class Airfoil:
"""
Author: @cc-aero
Represents a 2D airfoil for analysis.
Users can define an airfoil by providing a custom set of (x, y) coordinates.
Example usage:
# Create airfoil by coords
coords = [(1.0, 0.0013), (0.95, 0.0114), ..., (1.0, -0.0013)]
af1 = Airfoil(name="NACA2412", coords = coords)
af1.set_analysis_params(Re=1e6, alpha_start=0, alpha_end=10, alpha_step=2)
print(af1.run_analysis())
"""
def __init__(self, name: str = None, coords: list = None):
"""
Initialize the Airfoil object.
:param name: String identifying out airfoil (e.g., "c150 airfoil").
Used to identify the airfoil in Xfoil. Irrelevant for now.
:param coords: A list of (x, y) tuples defining the airfoil geometry.
//!TODO: Parse a NACA string and generate coordinates
"""
if coords is not None:
self.name = "PythonXfoil Airfoil"
self.coords = coords
else:
raise ValueError("Must provide 'coords'")
if name is not None:
self.name = name
def set_analysis_params(self, Re, alpha_start, M=None, alpha_end=None, alpha_step=None):
"""
Set parameters for our simulation.
:param Re: Reynolds number for the simulation.
:param alpha_start: Starting angle of attack for the simulation. For single-AOA simulations, this is the only AOA.
:param M: Mach number for the simulation (Optional).
:param alpha_end: Ending angle of attack for the simulation (Optional).
"""
if isinstance(Re, list) and isinstance(alpha_start, list) and isinstance(M, list):
self.Re_list = Re
self.alpha_start_list = alpha_start
self.M_list = M
self.alpha_end = alpha_end
self.alpha_step = alpha_step
else:
self.Re = Re
self.alpha_start = alpha_start
self.M = M if M is not None else 0.0
self.alpha_end = alpha_end
self.alpha_step = alpha_step
if alpha_end is None and alpha_step is None:
# Single AOA simulation
self.alpha = [alpha_start]
elif alpha_end is not None and alpha_step is not None:
# Multi-angle simulation (generate a list from alpha_start to alpha_end)
# Make sure we always move in the correct direction (up or down).
num_steps = int(abs(alpha_end - alpha_start) / alpha_step) + 1
if alpha_end > alpha_start:
self.alpha = [alpha_start + i * alpha_step for i in range(num_steps)]
else:
self.alpha = [alpha_start - i * alpha_step for i in range(num_steps)]
else:
# If alpha_end or alpha_step is given without the other, it's ambiguous
raise ValueError("For multiple AoAs, both alpha_end and alpha_step must be provided. For a single AoA, omit both alpha_end and alpha_step.")
def run_analysis(self, iters: int = 100):
"""
Runs the XFOIL analysis with the airfoil and the current simulation parameters.
:param airfoil: An Airfoil object containing name and coordinate data.
:param iters: Number of iterations to run the simulation for.
:return: A dictionary containing results (e.g., list of angles of attack, lift, drag, etc.)
"""
if self.Re is None or self.alpha is None:
raise AttributeError("Analysis parameters (Re, alpha) must be set before calling run_analysis.")
with tempfile.TemporaryDirectory() as tmpdir:
#1. Write airfoil coords to a file
airfoil_file = os.path.join(tmpdir,"airfoil.dat")
self._write_airfoil_file(airfoil_file)
#2. Create XFOIL input script
xfoil_input_file = os.path.join(tmpdir, "xfoil_input.in")
xfoil_output_file = os.path.join(tmpdir, "xfoil_output.txt")
self._write_xfoil_input_script(
airfoil_file = airfoil_file,
xfoil_input_file = xfoil_input_file,
output_file = xfoil_output_file)
#3. Run XFOIL in batch mode
xfoil_command = ["xfoil.exe" if os.name == "nt" else "xfoil", f"<{xfoil_input_file}"]
try:
completed_process = subprocess.run(
xfoil_command,
check = True,
shell=True,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE
)
except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode("utf-8",errors="ignore")
raise RuntimeError(f"XFOIL execution failed: {error_msg}"
"Ensure that xfoil.exe is in the same directory as this script!")
results = self._parse_xfoil_output(xfoil_output_file)
return results
def _run_single_analysis(self, Re, alpha_start, M, iters):
self.set_analysis_params(Re, alpha_start, M, self.alpha_end, self.alpha_step)
return self.run_analysis(iters)
def run_multithreaded_analysis(self, iters=100):
if not hasattr(self, 'Re_list') or not hasattr(self, 'alpha_start_list') or not hasattr(self, 'M_list'):
raise AttributeError("Analysis parameters (Re, alpha_start, M) must be set as lists before calling run_multithreaded_analysis.")
results = []
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = [executor.submit(self._run_single_analysis, Re, alpha_start, M, iters) for Re, alpha_start, M in zip(self.Re_list, self.alpha_start_list, self.M_list)]
for future in concurrent.futures.as_completed(futures):
results.append(future.result())
return results
def _write_airfoil_file(self, file_path: str):
"""
Writes the airfoil geometry to a .dat file for XFOIL.
"""
with open(file_path, "w") as f:
f.write(f"{self.name}\n")
for (x, y) in self.coords:
f.write(f"{x:.6f} {y:.6f}\n")
def _write_xfoil_input_script(self, airfoil_file, xfoil_input_file, output_file):
"""
Builds the XFOIL batch script to:
1. Load airfoil geometry
2. Set Reynolds, Mach, etc.
3. Run single or multiple AoA
4. Write results to the output file
"""
commands = []
# Load airfoil
commands.append(f"LOAD {airfoil_file}")
commands.append("") # accept default name from file or empty line
# Optional: Increase resolution of the paneling
commands.append("PPAR")
commands.append("N 200") # e.g., set number of panels
commands.append("") # exit PPAR menu
commands.append("") # exit PPAR menu
# Enter OPER menu
commands.append("OPER")
commands.append(f"VISC {self.Re}")
commands.append(f"MACH {self.M:.4f}")
# Save analyses to memory
commands.append("PACC")
commands.append("")
commands.append("")
# Single angle or sweep
if len(self.alpha) == 1:
alpha_val = self.alpha[0]
commands.append(f"ALFA {alpha_val:.2f}")
else:
alpha_start = self.alpha[0]
alpha_end = self.alpha[-1]
alpha_step = self.alpha[1] - self.alpha[0] # assume uniform step
commands.append(f"ASEQ {alpha_start} {alpha_end} {alpha_step}")
# Write out polar (PWRT)
commands.append(f"PWRT")
commands.append(self.name)
commands.append("Y") # yes to overwrite (if necessary)
commands.append("") # blank line to finalize
# Quit
commands.append("QUIT")
# Write commands to the .in file
script_content = "\n".join(commands) + "\n"
with open(xfoil_input_file, "w") as f:
f.write(script_content)
def _parse_xfoil_output(self, output_file: str):
output_file = self.name
"""
Parses the XFOIL output (polar data) from the specified file,
extracting alpha, Cl, Cd, and Cm into a dictionary.
:return: dict of lists, e.g. {
"alpha": [...],
"Cl": [...],
"Cd": [...],
"Cm": [...]
}
"""
if not os.path.exists(output_file):
raise FileNotFoundError("XFOIL output file not found. The run may have failed.")
results = {
"alpha": [],
"Cl": [],
"Cd": [],
"Cm": []
}
with open(output_file, "r") as f:
in_data_section = False
for line in f:
# Detect header or data section
if "alpha" in line and "CL" in line:
in_data_section = True
continue
if in_data_section:
tokens = line.split()
if len(tokens) < 5:
# Not enough columns or end of data
continue
try:
alpha_val = float(tokens[0])
cl_val = float(tokens[1])
cd_val = float(tokens[2])
cm_val = float(tokens[4]) # skipping CDp at tokens[3]
results["alpha"].append(alpha_val)
results["Cl"].append(cl_val)
results["Cd"].append(cd_val)
results["Cm"].append(cm_val)
except ValueError:
# Some lines may not parse cleanly
pass
os.remove(self.name)
return results
def __repr__(self):
return f"<Airfoil name='{self.name}' with {len(self.coords)} points>"
class Utils:
def parse_coords(raw_text: str):
"""
Converts multiline text of coordinates into a list of (x, y) tuples.
:param raw_text: A string where each line has two float values separated by space.
:return: A list of (float, float) tuples.
Example:
raw_data = \"\"\"1.0000 0.0013
0.9500 0.0114
...
1.0000 -0.0013\"\"\"
coords = parse_coords(raw_data)
# coords -> [(1.0, 0.0013), (0.95, 0.0114), ..., (1.0, -0.0013)]
"""
# Split the text into lines
lines = raw_text.strip().splitlines()
# Parse each line into (x, y)
coords = []
for line in lines:
if len(line) < 2:
# Definitely not two numbers, skip
lines = lines[1:]
continue
# Split by whitespace
parts = line.split()
if len(parts) >= 2 and not any(ch.isalpha() for ch in line):
x_str, y_str = parts[0], parts[1]
x_val, y_val = float(x_str), float(y_str)
coords.append((x_val, y_val))
return coords