← all demos

poisson

Poisson's equation as potential flow over a NACA airfoil, on P2 elements.

uv run python examples/cli.py run poisson

Ideal (irrotational, incompressible) flow over a NACA 2412 airfoil at a 12-degree angle of attack, generated from the standard formula rather than a data file. Left: the velocity potential phi (Laplace) with its equipotentials, which crowd over the upper surface where the flow speeds up. Right: the flow speed, faster over the top than the bottom, with stagnation near the leading and trailing edges. The wing takes no condition at all, which in the weak form is zero flux, so it is a streamline the flow parts around. The speed is clipped near the sharp edges, where ideal flow with no Kutta condition predicts an unphysical velocity spike.
Ideal (irrotational, incompressible) flow over a NACA 2412 airfoil at a 12-degree angle of attack, generated from the standard formula rather than a data file. Left: the velocity potential phi (Laplace) with its equipotentials, which crowd over the upper surface where the flow speeds up. Right: the flow speed, faster over the top than the bottom, with stagnation near the leading and trailing edges. The wing takes no condition at all, which in the weak form is zero flux, so it is a streamline the flow parts around. The speed is clipped near the sharp edges, where ideal flow with no Kutta condition predicts an unphysical velocity spike.

What was imposed

A potential difference across the channel (phi = 0 at the inlet, 1 at the outlet) drives the flow left to right; the walls and the wing surface take no condition, so no flow crosses them.
A potential difference across the channel (phi = 0 at the inlet, 1 at the outlet) drives the flow left to right; the walls and the wing surface take no condition, so no flow crosses them.

Source

The functions that pose and solve the problem. The figures are below the fold.

"""Poisson's equation as potential flow over a NACA airfoil, on P2 elements.

`run` meshes the airfoil in its channel and solves Laplace's equation for the velocity
potential, returning a `FlowStudy` of plain results. Nothing here draws: `figures.py`
does that from the `FlowStudy`, and this file is what the gallery shows.
"""
from dataclasses import dataclass

import numpy as np

from fem.boundary import Dirichlet
from fem.conditions import Conditions
from fem.elements import QuadraticTriangleElement
from fem.loads import Source
from fem.mesh.mesh import Mesh
from fem.mesh.outline import Outline
from fem.physics.equations import Poisson
from fem.post.solution import DiffusionSolution
from fem.regions import on_plane


def _naca4_outline(camber: float, camber_pos: float, thickness: float, n: int,
                   te_trim: float = 0.05) -> np.ndarray:
    """A NACA 4-digit airfoil as a closed loop of points, unit chord along +x.

    `camber` (m), `camber_pos` (p), and `thickness` (t) are the usual fractions: a NACA
    2412 is (0.02, 0.4, 0.12). Cosine node spacing clusters points at the leading and
    trailing edges, where the curvature is highest.

    `te_trim` cuts that fraction of the chord off the trailing edge, leaving a blunt
    edge in place of the near-cusp a full 4-digit section tapers to, which the mesher
    would chase with unboundedly many tiny triangles.
    """
    beta = np.linspace(0, np.pi, n)
    x = 0.5 * (1 - np.cos(beta)) * (1 - te_trim)    # cosine spacing, 0 (LE) to 1-te_trim (TE)
    yt = 5 * thickness * (0.2969 * np.sqrt(x) - 0.1260 * x - 0.3516 * x**2
                          + 0.2843 * x**3 - 0.1015 * x**4)
    if camber > 0 and 0 < camber_pos < 1:
        m, p = camber, camber_pos
        yc = np.where(x < p, m / p**2 * (2 * p * x - x**2),
                      m / (1 - p)**2 * ((1 - 2 * p) + 2 * p * x - x**2))
        dyc = np.where(x < p, 2 * m / p**2 * (p - x), 2 * m / (1 - p)**2 * (p - x))
    else:
        yc = dyc = np.zeros_like(x)                 # symmetric section (m = 0)
    theta = np.arctan(dyc)
    upper = np.column_stack([x - yt * np.sin(theta), yc + yt * np.cos(theta)])
    lower = np.column_stack([x + yt * np.sin(theta), yc - yt * np.cos(theta)])
    # Trailing edge over the top to the leading edge, then back under; the shared leading
    # edge is dropped so it is not duplicated.
    return np.vstack([upper[::-1], lower[1:]])


def airfoil_channel_outline(length: float = 7.0, height: float = 4.0, chord: float = 3.0,
                         angle_of_attack: float = 6.0, camber: float = 0.02,
                         camber_pos: float = 0.4, thickness: float = 0.12,
                            n_points: int = 100) -> Outline:
    """A rectangular channel with a NACA 4-digit airfoil obstacle in it.

    The airfoil is generated analytically (no data file needed), scaled to `chord`,
    pitched `angle_of_attack` degrees nose-up into a left-to-right flow, and placed in
    the channel. The default is a NACA 2412. Under the even-odd rule the airfoil loop is
    a hole, so a mesh covers the fluid and stops at the wing, making its surface a
    boundary the solver sees (and, taking no condition, a streamline).
    """
    foil = _naca4_outline(camber, camber_pos, thickness, n_points)
    foil = foil * chord - [0.35 * chord, 0.0]       # pivot near the quarter-chord
    a = np.deg2rad(angle_of_attack)
    c, s = np.cos(a), np.sin(a)
    foil = foil @ np.array([[c, -s], [s, c]])       # nose up into the +x flow
    foil = foil + [0.42 * length, 0.5 * height]
    channel = np.array([[0.0, 0.0], [length, 0.0], [length, height], [0.0, height]])
    return Outline.from_polygons([channel, foil])


@dataclass
class FlowStudy:
    """Everything `run` computed, for the figures to read."""
    angle_of_attack: float
    mesh: Mesh
    bc: Conditions
    solution: DiffusionSolution      # the velocity potential phi, on P2

    @property
    def speed(self) -> np.ndarray:
        """|v| = |grad(phi)|, read at the nodes so the P2 tessellation draws it smoothly."""
        return np.linalg.norm(self.solution.nodal_gradient(), axis=1)   # (n_nodes,)

    @property
    def speed_cap(self) -> float:
        """Ideal flow with no Kutta condition predicts a near-singular velocity at the
        sharp edges; clip it to a high percentile so the flow over the wing stays legible."""
        return float(np.percentile(self.speed, 96))


def run(length=7.0, height=4.0, chord=3.0, angle_of_attack=12.0, n_points=80, min_angle=20,
        max_area_fraction=0.0015) -> FlowStudy:
    """Mesh the airfoil in its channel and solve for the velocity potential."""
    # An ideal (incompressible, irrotational) flow has a velocity potential phi with
    # v = grad(phi) and div(v) = 0, so phi solves Laplace's equation, Poisson's with no
    # source. The wing carries no
    # flow through it, the natural (zero-flux) condition of the weak form: say nothing
    # on its surface and it becomes a streamline the flow parts around.
    outline = airfoil_channel_outline(length, height, chord, angle_of_attack, n_points=n_points)
    mesh = outline.mesh(min_angle=min_angle, max_area_fraction=max_area_fraction)

    equation = Poisson()   # Laplace: no sources in the flow
    # phi rises from inlet to outlet, so v = grad(phi) runs left to right. The wing and
    # the walls take no condition, so they are no-flux streamlines.
    bc = Conditions(
        Dirichlet(on_plane(0, 0.0), 0.0),
        Dirichlet(on_plane(0, length), 1.0),
    )

    problem = equation.problem(mesh, bc + Source(0), element_type=QuadraticTriangleElement)
    solution = problem.solve()
    return FlowStudy(angle_of_attack, mesh, bc, solution)
The figures, and the demo that assembles them
"""The figures of the airfoil potential-flow demo, drawn from a `FlowStudy`."""
from demo_registry import Demo, DemoResult, Figure

from demos._charts import conditions_figure
from demos.poisson import physics
from demos.poisson.physics import FlowStudy, run
from fem.plot.plotter import Plotter


def _flow_figure(s: FlowStudy) -> Figure:
    # `space=solution.space` opts both panels onto the P2 tessellation: the potential
    # shows its within-element curvature and the recovered speed draws smoothly.
    plotter = Plotter(1, 2, title='Potential flow over an airfoil', panel_aspect=1.8)
    plotter.plot(s.mesh, s.solution.dofs, mode='colored', idx=(0, 0), label='velocity potential',
                 title='Potential and its equipotentials', contour=22, space=s.solution.space)
    plotter.plot(s.mesh, s.speed, mode='colored', idx=(0, 1), label='flow speed',
                 clim=(0.0, s.speed_cap), title='Flow speed (clipped near the edges)',
                 space=s.solution.space)
    return Figure(
        plotter,
        'Ideal (irrotational, incompressible) flow over a NACA 2412 airfoil at a '
        f'{s.angle_of_attack:g}-degree angle of attack, generated from the standard '
        'formula rather than a data file. Left: the velocity potential phi (Laplace) '
        'with its equipotentials, which crowd over the upper surface where the flow '
        'speeds up. Right: the flow speed, faster over the top than the bottom, with '
        'stagnation near the leading and trailing edges. The wing takes no condition '
        'at all, which in the weak form is zero flux, so it is a streamline the flow '
        'parts around. The speed is clipped near the sharp edges, where ideal flow '
        'with no Kutta condition predicts an unphysical velocity spike.',
        'flow')


def _conditions_figure(s: FlowStudy) -> Figure:
    return conditions_figure(
        s.mesh, s.bc,
        'A potential difference across the channel (phi = 0 at the inlet, 1 at the '
        'outlet) drives the flow left to right; the walls and the wing surface take no '
        'condition, so no flow crosses them.',
        panel_aspect=1.8)


def demo(**kwargs) -> DemoResult:
    """Poisson's equation as potential flow over a NACA airfoil, on P2 elements."""
    s = run(**kwargs)
    return DemoResult([_flow_figure(s), _conditions_figure(s)])


# Builds its own airfoil-in-a-channel from the NACA formula, so it takes no domain.
DEMO = Demo('poisson', demo, section='Meshing & solving PDEs',
            show_source=physics,
            smoke_kwargs={'n_points': 40, 'max_area_fraction': 0.02})