Skip to content

Commit f873238

Browse files
authored
Merge pull request #1 from vcaries/feat/m2-xfoil-wrapper
feat(solver): wire up XFOIL runner, parsers, and convergence strategi…
2 parents 38af05e + 87b5bea commit f873238

6 files changed

Lines changed: 1023 additions & 58 deletions

File tree

src/aeroforge/solver/convergence/strategies.py

Lines changed: 211 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,27 @@
44
:class:`ConvergenceStrategy` interface so they can be composed in any order by
55
the :class:`ConvergencePipeline`.
66
7-
Strategy reference (planned implementations):
8-
9-
* :class:`IncreaseIterationsStrategy` -- raise ``ITER`` and retry.
10-
* :class:`AlphaContinuationStrategy` -- step alpha in small increments from
11-
the nearest converged neighbour.
12-
* :class:`InviscidInitStrategy` -- run inviscidly first, then switch to
13-
viscous to obtain a better starting BL state.
14-
* :class:`RepanelStrategy` -- apply ``PANE`` to fix degenerate panels before
15-
retrying.
16-
* :class:`PerturbAlphaStrategy` -- nudge alpha by a tiny epsilon (useful near
17-
the stall corner where XFOIL likes to oscillate).
7+
All strategies follow the same contract:
8+
9+
* On success: return a converged :class:`PolarPoint`.
10+
* On failure: raise :class:`ConvergenceError` so the pipeline can fall through
11+
to the next strategy.
12+
13+
Strategies that mutate the underlying solver's parameters do so inside a
14+
``try / finally`` so the original configuration is always restored, which
15+
keeps strategies safe to compose in any order.
1816
"""
1917

2018
from __future__ import annotations
2119

22-
from typing import TYPE_CHECKING
20+
from collections.abc import Iterator
21+
from contextlib import contextmanager, suppress
22+
from dataclasses import replace
23+
from math import ceil
24+
from typing import TYPE_CHECKING, Any
2325

26+
from aeroforge.core.exceptions import AeroforgeError, ConvergenceError
27+
from aeroforge.core.logging import get_logger
2428
from aeroforge.core.types import OperatingPoint
2529
from aeroforge.solver.convergence.base import ConvergenceStrategy
2630

@@ -29,17 +33,72 @@
2933
from aeroforge.solver.base import AbstractSolver
3034
from aeroforge.solver.xfoil.results import PolarPoint
3135

36+
_log = get_logger(__name__)
37+
38+
39+
# --------------------------------------------------------------------------- #
40+
# Small helpers shared across strategies
41+
# --------------------------------------------------------------------------- #
42+
@contextmanager
43+
def _temp_attrs(target: Any, **overrides: Any) -> Iterator[None]:
44+
"""Temporarily set attributes on ``target`` and restore them on exit.
45+
46+
Used by strategies to bump XFOIL parameters (e.g. ``max_iter``) for a
47+
single retry without permanently changing the solver state.
48+
49+
Args:
50+
target: The object whose attributes to override (typically the solver).
51+
**overrides: Attribute names mapped to their temporary values.
52+
53+
Yields:
54+
None. The block runs with the overrides applied; on exit (whether
55+
normal or exceptional) the original values are restored.
56+
"""
57+
sentinel = object()
58+
saved: dict[str, Any] = {}
59+
try:
60+
for name, value in overrides.items():
61+
saved[name] = getattr(target, name, sentinel)
62+
setattr(target, name, value)
63+
yield
64+
finally:
65+
for name, value in saved.items():
66+
if value is sentinel:
67+
# The attribute did not exist before; remove it again.
68+
with suppress(AttributeError):
69+
delattr(target, name)
70+
else:
71+
setattr(target, name, value)
3272

73+
74+
def _wrap_convergence_failure(
75+
strategy: str, point: OperatingPoint, exc: BaseException
76+
) -> ConvergenceError:
77+
"""Build a :class:`ConvergenceError` describing why a strategy failed."""
78+
return ConvergenceError(
79+
f"{strategy} failed at alpha={point.alpha:.3f}: {exc}",
80+
alpha=point.alpha,
81+
)
82+
83+
84+
# --------------------------------------------------------------------------- #
85+
# Strategy: increase the viscous iteration cap
86+
# --------------------------------------------------------------------------- #
3387
class IncreaseIterationsStrategy(ConvergenceStrategy):
3488
"""Raise XFOIL's ``ITER`` cap and retry.
3589
90+
Often enough on its own: many "failed to converge in 200 iter" cases just
91+
needed a few hundred more iterations.
92+
3693
Args:
3794
factor: Multiplicative factor applied to the current iteration cap.
3895
max_iter: Absolute ceiling beyond which the strategy gives up.
3996
"""
4097

4198
def __init__(self, factor: float = 2.0, max_iter: int = 800) -> None:
4299
"""Store the iteration-bump parameters."""
100+
if factor <= 1.0:
101+
raise ValueError("IncreaseIterationsStrategy.factor must be > 1.")
43102
self.factor = float(factor)
44103
self.max_iter = int(max_iter)
45104

@@ -51,20 +110,48 @@ def attempt(
51110
*,
52111
history: list[PolarPoint],
53112
) -> PolarPoint:
54-
"""Retry with a higher iteration cap (planned)."""
55-
raise NotImplementedError("IncreaseIterationsStrategy (planned, M2).")
113+
"""Retry with a higher iteration cap."""
114+
current = int(getattr(solver, "max_iter", 200))
115+
bumped = min(int(current * self.factor), self.max_iter)
116+
if bumped <= current:
117+
raise ConvergenceError(
118+
f"IncreaseIterationsStrategy already at ceiling ({current}).",
119+
alpha=point.alpha,
120+
)
121+
_log.debug(
122+
"IncreaseIterationsStrategy: %d -> %d at alpha=%.3f",
123+
current,
124+
bumped,
125+
point.alpha,
126+
)
127+
try:
128+
with _temp_attrs(solver, max_iter=bumped):
129+
return solver.analyze(airfoil, point)
130+
except ConvergenceError:
131+
raise
132+
except AeroforgeError as exc:
133+
raise _wrap_convergence_failure(self.name, point, exc) from exc
56134

57135

136+
# --------------------------------------------------------------------------- #
137+
# Strategy: alpha continuation from the nearest converged neighbour
138+
# --------------------------------------------------------------------------- #
58139
class AlphaContinuationStrategy(ConvergenceStrategy):
59140
"""Walk alpha in small steps from the nearest converged neighbour.
60141
142+
XFOIL's boundary-layer solver converges much more reliably when warm-
143+
started from a nearby converged solution. This strategy steps from the
144+
closest alpha in ``history`` towards the failing target.
145+
61146
Args:
62147
step: Alpha increment (degrees) used during continuation.
63148
max_steps: Maximum number of intermediate alphas before giving up.
64149
"""
65150

66151
def __init__(self, step: float = 0.25, max_steps: int = 20) -> None:
67152
"""Store the continuation parameters."""
153+
if step <= 0.0:
154+
raise ValueError("AlphaContinuationStrategy.step must be > 0.")
68155
self.step = float(step)
69156
self.max_steps = int(max_steps)
70157

@@ -76,17 +163,69 @@ def attempt(
76163
*,
77164
history: list[PolarPoint],
78165
) -> PolarPoint:
79-
"""Step alpha from the closest converged point (planned)."""
80-
raise NotImplementedError("AlphaContinuationStrategy (planned, M2).")
166+
"""Sweep alpha towards the target from the closest converged neighbour."""
167+
if not history:
168+
raise ConvergenceError(
169+
"AlphaContinuationStrategy needs at least one converged "
170+
"history point to warm-start from.",
171+
alpha=point.alpha,
172+
)
81173

174+
anchor = min(history, key=lambda h: abs(h.operating_point.alpha - point.alpha))
175+
anchor_alpha = anchor.operating_point.alpha
176+
direction = 1.0 if point.alpha > anchor_alpha else -1.0
177+
n_steps = max(1, ceil(abs(point.alpha - anchor_alpha) / self.step))
178+
if n_steps > self.max_steps:
179+
raise ConvergenceError(
180+
f"AlphaContinuationStrategy would need {n_steps} steps "
181+
f"(> max_steps={self.max_steps}).",
182+
alpha=point.alpha,
183+
)
184+
185+
_log.debug(
186+
"AlphaContinuationStrategy: %.3f -> %.3f in %d step(s) of %.3f",
187+
anchor_alpha,
188+
point.alpha,
189+
n_steps,
190+
direction * self.step,
191+
)
192+
193+
last_point = anchor
194+
for k in range(1, n_steps + 1):
195+
intermediate_alpha = anchor_alpha + direction * self.step * k
196+
# Don't overshoot the target.
197+
if (direction > 0 and intermediate_alpha > point.alpha) or (
198+
direction < 0 and intermediate_alpha < point.alpha
199+
):
200+
intermediate_alpha = point.alpha
201+
intermediate = replace(point, alpha=intermediate_alpha)
202+
try:
203+
last_point = solver.analyze(airfoil, intermediate)
204+
except AeroforgeError as exc:
205+
raise _wrap_convergence_failure(self.name, point, exc) from exc
206+
return last_point
82207

83-
class InviscidInitStrategy(ConvergenceStrategy):
84-
"""Run inviscidly first, then switch to viscous with the same alpha.
85208

86-
Provides a better-conditioned initial BL state, which often unblocks
87-
near-stall operating points.
209+
# --------------------------------------------------------------------------- #
210+
# Strategy: nudge alpha by a small epsilon to escape limit cycles
211+
# --------------------------------------------------------------------------- #
212+
class PerturbAlphaStrategy(ConvergenceStrategy):
213+
"""Retry at ``alpha + epsilon`` or ``alpha - epsilon``.
214+
215+
XFOIL occasionally limit-cycles on a single ITER count near stall; a tiny
216+
perturbation often breaks the deadlock and gives a result close enough to
217+
the requested point to be usable.
218+
219+
Args:
220+
epsilon: Magnitude of the perturbation, in degrees.
88221
"""
89222

223+
def __init__(self, epsilon: float = 0.05) -> None:
224+
"""Store the perturbation magnitude."""
225+
if epsilon <= 0.0:
226+
raise ValueError("PerturbAlphaStrategy.epsilon must be > 0.")
227+
self.epsilon = float(epsilon)
228+
90229
def attempt(
91230
self,
92231
solver: AbstractSolver,
@@ -95,15 +234,28 @@ def attempt(
95234
*,
96235
history: list[PolarPoint],
97236
) -> PolarPoint:
98-
"""Two-stage inviscid -> viscous run (planned)."""
99-
raise NotImplementedError("InviscidInitStrategy (planned, M2).")
237+
"""Try ``alpha + epsilon`` first, then ``alpha - epsilon``."""
238+
last_exc: BaseException | None = None
239+
for sign in (+1.0, -1.0):
240+
perturbed = replace(point, alpha=point.alpha + sign * self.epsilon)
241+
try:
242+
_log.debug("PerturbAlphaStrategy: trying alpha=%.4f", perturbed.alpha)
243+
return solver.analyze(airfoil, perturbed)
244+
except AeroforgeError as exc:
245+
last_exc = exc
246+
continue
247+
assert last_exc is not None
248+
raise _wrap_convergence_failure(self.name, point, last_exc) from last_exc
100249

101250

251+
# --------------------------------------------------------------------------- #
252+
# Strategy: apply XFOIL's auto-repaneling
253+
# --------------------------------------------------------------------------- #
102254
class RepanelStrategy(ConvergenceStrategy):
103-
"""Apply XFOIL's ``PANE`` automatic repaneling and retry.
255+
"""Force XFOIL's ``PANE`` automatic repaneling and retry.
104256
105-
Useful when the airfoil arrives with degenerate panels (e.g. from a
106-
user-supplied ``.dat`` file with awkward spacing).
257+
Useful when the airfoil arrives with awkward panel spacing (e.g. from an
258+
imported ``.dat`` file) that confuses the BL solver.
107259
"""
108260

109261
def attempt(
@@ -114,20 +266,37 @@ def attempt(
114266
*,
115267
history: list[PolarPoint],
116268
) -> PolarPoint:
117-
"""Run ``PANE`` and retry (planned)."""
118-
raise NotImplementedError("RepanelStrategy (planned, M2).")
269+
"""Enable repaneling for this single call and retry."""
270+
try:
271+
with _temp_attrs(solver, repanel=True):
272+
return solver.analyze(airfoil, point)
273+
except ConvergenceError:
274+
raise
275+
except AeroforgeError as exc:
276+
raise _wrap_convergence_failure(self.name, point, exc) from exc
119277

120278

121-
class PerturbAlphaStrategy(ConvergenceStrategy):
122-
"""Nudge alpha by a small epsilon to escape a limit-cycle oscillation.
279+
# --------------------------------------------------------------------------- #
280+
# Strategy: inviscid initialisation
281+
# --------------------------------------------------------------------------- #
282+
class InviscidInitStrategy(ConvergenceStrategy):
283+
"""Solve inviscidly first, then retry viscously.
123284
124-
Args:
125-
epsilon: Magnitude of the perturbation, in degrees.
285+
The inviscid Cp distribution provides a better starting guess for the
286+
boundary-layer iteration, particularly near stall. We don't keep the
287+
inviscid result; it's only used to warm up XFOIL's internal state.
288+
289+
Note:
290+
XFOIL's internal warm-start would require keeping the binary alive
291+
across both calls. Since :class:`XfoilRunner` is one-shot today, this
292+
strategy currently just retries viscously with a bumped iteration
293+
cap, which captures most of the practical benefit; a true warm-start
294+
is planned for the next milestone.
126295
"""
127296

128-
def __init__(self, epsilon: float = 0.05) -> None:
129-
"""Store the perturbation magnitude."""
130-
self.epsilon = float(epsilon)
297+
def __init__(self, max_iter_bump: int = 400) -> None:
298+
"""Store the iteration-cap to use for the viscous retry."""
299+
self.max_iter_bump = int(max_iter_bump)
131300

132301
def attempt(
133302
self,
@@ -137,5 +306,11 @@ def attempt(
137306
*,
138307
history: list[PolarPoint],
139308
) -> PolarPoint:
140-
"""Retry at ``alpha +/- epsilon`` (planned)."""
141-
raise NotImplementedError("PerturbAlphaStrategy (planned, M2).")
309+
"""Approximate the inviscid-warm-start by retrying with more iterations."""
310+
try:
311+
with _temp_attrs(solver, max_iter=self.max_iter_bump):
312+
return solver.analyze(airfoil, point)
313+
except ConvergenceError:
314+
raise
315+
except AeroforgeError as exc:
316+
raise _wrap_convergence_failure(self.name, point, exc) from exc

0 commit comments

Comments
 (0)