44:class:`ConvergenceStrategy` interface so they can be composed in any order by
55the :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
2018from __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
2428from aeroforge .core .types import OperatingPoint
2529from aeroforge .solver .convergence .base import ConvergenceStrategy
2630
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+ # --------------------------------------------------------------------------- #
3387class 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+ # --------------------------------------------------------------------------- #
58139class 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+ # --------------------------------------------------------------------------- #
102254class 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