← all demos

heat

Warm a finned heatsink from a cold start, then compare it with a solid block and with beam theory.

uv run python examples/cli.py run heat

The finned sink against a solid block of the same bounding box, posed two ways. Top, the same heat flux into each base (a chip of fixed power). The block runs 108 C above ambient, the finned sink only 58 C, roughly halving the thermal resistance (R 0.90 -> 0.48). Bottom, each base held at 400. The finned sink sheds 1.8x the heat with 0.64x the metal, since the fins trade material for surface area.
The finned sink against a solid block of the same bounding box, posed two ways. Top, the same heat flux into each base (a chip of fixed power). The block runs 108 C above ambient, the finned sink only 58 C, roughly halving the thermal resistance (R 0.90 -> 0.48). Bottom, each base held at 400. The finned sink sheds 1.8x the heat with 0.64x the metal, since the fins trade material for surface area.
Fin efficiency, the heat a fin sheds over what it would shed with all of it at the base temperature, against the beam-theory law tanh(mL)/(mL). The computed fins track it closely. Efficiency falls as fins lengthen, because a long fin runs cold toward the tip and carries less of its share. This sink's fins (L = 1.4) sit near 39%, trading efficiency for surface area.
Fin efficiency, the heat a fin sheds over what it would shed with all of it at the base temperature, against the beam-theory law tanh(mL)/(mL). The computed fins track it closely. Efficiency falls as fins lengthen, because a long fin runs cold toward the tip and carries less of its share. This sink's fins (L = 1.4) sit near 39%, trading efficiency for surface area.
The finned sink warming from a cold start, the transient heat equation stepped by Crank-Nicolson. Left, temperature: the warming front climbs each fin and settles into the fin gradient, hot at the root and about 310 at the tips; the title tracks the base temperature as it switches on. Right, the heat flux magnitude recovered from each step: largest in the base and at the fin roots, where the gradient is steepest, and fading toward the tips as the fins run cold; the title tracks the heat shed to ambient through the film, which climbs toward the steady 215.5.
The finned sink warming from a cold start, the transient heat equation stepped by Crank-Nicolson. Left, temperature: the warming front climbs each fin and settles into the fin gradient, hot at the root and about 310 at the tips; the title tracks the base temperature as it switches on. Right, the heat flux magnitude recovered from each step: largest in the base and at the fin roots, where the gradient is steepest, and fading toward the tips as the fins run cold; the title tracks the heat shed to ambient through the film, which climbs toward the steady 215.5.
thermal resistance R (base rise per unit power):
  solid block   0.900
  finned sink   0.480   (1.9x lower)
heat shed with the base held 400 (ambient 300):
  solid block   119.1
  finned sink   215.5   (1.8x, on 0.64x the metal)
fin efficiency at L = 1.4:  0.39  (beam theory close)

What was imposed

Left, the conditions. The bottom face is a chip switching on: its temperature ramps from ambient to 400 over the first 0.6 s and then holds (right). Every other surface carries a Robin film, du/dn + kappa*(u - u_ambient) = 0, shedding heat to ambient. The sink starts cold at ambient, so the transient is a warm-up to the steady dissipating state.
Left, the conditions. The bottom face is a chip switching on: its temperature ramps from ambient to 400 over the first 0.6 s and then holds (right). Every other surface carries a Robin film, du/dn + kappa*(u - u_ambient) = 0, shedding heat to ambient. The sink starts cold at ambient, so the transient is a warm-up to the steady dissipating state.

Source

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

"""A finned heatsink warmed from a cold start, then compared with a solid block and with
beam theory.

`warm_up`, `compare_with_block`, and `fin_efficiency` each state and solve one problem;
`run` calls them and returns a `HeatsinkStudy` of plain results. Nothing here draws:
`figures.py` does that from the `HeatsinkStudy`, and this file is what the gallery shows.
"""
from dataclasses import dataclass

import numpy as np

from fem.algebra.integrators import ThetaMethod
from fem.boundary import Dirichlet, Neumann, Robin
from fem.conditions import Conditions, Initial
from fem.loads import Source
from fem.mesh.mesh import Mesh
from fem.mesh.outline import Outline
from fem.mesh.structured import box_mesh
from fem.physics.equations import Heat, Poisson
from fem.post.solution import TransientSolution
from fem.regions import TimeDependent, in_box, on_plane, union


def heatsink_outline(width: float = 3.0, base_height: float = 0.5, fin_height: float = 1.4,
                     fin_width: float = 0.22, n_fins: int = 7, margin: float = 0.18) -> Outline:
    """A finned heatsink cross-section (a comb) as a single loop.

    A `width` x `base_height` base slab carries `n_fins` fins of `fin_width` x
    `fin_height` standing on top, evenly spaced and kept `margin` clear of the ends. The
    bottom edge is the heated face (a chip beneath it); every other edge is a surface
    that sheds heat, so a solver reads the whole top and sides as a convective film.
    """
    span = width - 2 * margin
    pitch = (span - fin_width) / (n_fins - 1) if n_fins > 1 else 0.0
    lefts = margin + pitch * np.arange(n_fins)

    # Traced counter-clockwise: the bottom edge, up the right side, then the top from
    # right to left, going up and over each fin, and finally down the left side.
    outline = [(0.0, 0.0), (width, 0.0), (width, base_height)]
    for x_l in lefts[::-1]:
        x_r = x_l + fin_width
        outline += [(x_r, base_height), (x_r, base_height + fin_height),
                    (x_l, base_height + fin_height), (x_l, base_height)]
    outline.append((0.0, base_height))
    return Outline.from_polygons([np.array(outline)])

FIN_THICKNESS = 0.22    # matches the sink's own fins (heatsink_outline's fin_width default)
FIN_LENGTH = 1.4        # the length of this sink's fins (heatsink_outline's fin_height default)


def heatsink_film(mesh):
    """The convective film: every boundary but the heated bottom edge (the surfaces above
    the base, plus the base's two sides down to the corners)."""
    w = float(np.max(mesh.vertices[:, 0]))
    return union(in_box([None, 1e-6], [None, None]), on_plane(0, 0.0), on_plane(0, w))


def heatsink_bc(mesh, base, kappa, u_ambient):
    """The boundary spec: `base` on the bottom edge, a Robin film everywhere else."""
    return Conditions(base, Robin(heatsink_film(mesh), kappa=kappa, g=kappa * u_ambient))


def steady_heatsink(mesh, bc, kappa, u_ambient):
    """Steady heat field for `bc` (a base condition plus a Robin film).

    Returns (u, heat_shed), where heat_shed is the convective loss through the film,
    kappa * integral_film (u - u_ambient). At steady state that equals the heat entering
    the base, so it is the sink's dissipation.
    """
    problem = Poisson().problem(mesh, bc + Source(0))
    u = problem.solve().dofs
    return u, float(problem.robin_flux(u))


def solid_block(width, height, target_area):
    """A structured mesh of a solid `width` x `height` block, at roughly `target_area` per
    element so it matches a Ruppert's mesh built to the same cap."""
    nx = max(2, round(width / np.sqrt(target_area)))
    ny = max(2, round(height / np.sqrt(target_area)))
    return box_mesh(corners=[[0.0, 0.0], [width, height]], resolution=(nx, ny))


def fin_efficiency(kappa, u_ambient, u_hot, thickness, lengths):
    """Fin efficiency for a single straight fin at each length, computed and from theory.

    Efficiency is the heat a fin sheds over what it would shed with all of it at the base
    temperature: eta = shed / (kappa * A_fin * (u_hot - u_ambient)), A_fin = 2L + t the
    convecting surface (two sides and the tip, per unit depth). Beam theory gives
    eta = tanh(m*Lc)/(m*Lc), with m = sqrt(2*kappa/(k*t)) and the corrected length
    Lc = L + t/2 standing in for the convecting tip.
    """
    hot = Dirichlet(on_plane(1, 0.0), u_hot)

    eta_fem = []
    for length in lengths:
        ny = max(10, round(10 * length / thickness))    # ~10 elements across the thickness
        fin = box_mesh(corners=[[0.0, 0.0], [thickness, length]], resolution=(10, ny))
        _, shed = steady_heatsink(fin, heatsink_bc(fin, hot, kappa, u_ambient),
                                  kappa, u_ambient)
        area = 2 * length + thickness
        eta_fem.append(shed / (kappa * area * (u_hot - u_ambient)))
    return np.array(lengths), np.array(eta_fem), theory_efficiency(kappa, thickness, lengths)


def theory_efficiency(kappa, thickness, lengths):
    """Beam theory's fin efficiency tanh(m*Lc)/(m*Lc) at each length (see `fin_efficiency`)."""
    m = np.sqrt(2 * kappa / thickness)      # conductivity k = 1
    lc = np.asarray(lengths, dtype=float) + thickness / 2
    return np.tanh(m * lc) / (m * lc)


def warm_up(mesh, dt, steps, kappa, u_ambient, u_hot, ramp):
    """Warm the sink from a cold start.

    The bottom face is a chip switching on: its temperature ramps from ambient to hot
    over `ramp` seconds, then holds. Every other surface is a convective film,
    du/dn + kappa*(u - u_ambient) = 0. A cold start at ambient makes the run a
    warm-up, the front climbing the fins to a steady gradient. Returns the conditions,
    the series, the heat flux magnitude at each step, and the heat shed at each step.
    """
    def base_temperature(p, t):
        return u_ambient + (u_hot - u_ambient) * min(t / ramp, 1.0)

    bc = Conditions(
        Dirichlet(on_plane(1, 0.0), TimeDependent(base_temperature)),
        Robin(heatsink_film(mesh), kappa=kappa, g=kappa * u_ambient),
        Initial(u_ambient),
    )
    # The heat equation is Poisson's operator integrated in time (see fem.problem.heat).
    heat = Heat().problem(mesh, bc)
    solution = ThetaMethod(dt=dt, steps=steps).solve(heat)
    # Each step as a steady solution carries the recovered heat flux -grad u; the heat
    # shed through the film at each step is the Robin flux the steady comparison reads.
    flux_values = [np.linalg.norm(step.nodal_gradient(), axis=1) for step in solution]
    shed_values = [float(heat.robin_flux(u)) for u in solution.dofs]
    return bc, solution, flux_values, shed_values


def compare_with_block(mesh, block, kappa, u_ambient, u_hot, flux):
    """The block and the finned sink at steady state, posed two ways.

    Fixed power: the same heat flux into each base (a chip of fixed wattage); compare the
    base temperature. Fixed temperature: each base held hot; compare the heat shed. The
    thermal resistance R = (base rise)/power is the shape's property either way. Returns
    the four fields and the two heats shed with the base held hot.
    """
    flux_in = Neumann(on_plane(1, 0.0), [flux])
    hot = Dirichlet(on_plane(1, 0.0), u_hot)

    u_block_p, _ = steady_heatsink(block, heatsink_bc(block, flux_in, kappa, u_ambient),
                                   kappa, u_ambient)
    u_fin_p, _ = steady_heatsink(mesh, heatsink_bc(mesh, flux_in, kappa, u_ambient),
                                 kappa, u_ambient)
    u_block_t, q_block = steady_heatsink(block, heatsink_bc(block, hot, kappa, u_ambient),
                                         kappa, u_ambient)
    u_fin_t, q_fin = steady_heatsink(mesh, heatsink_bc(mesh, hot, kappa, u_ambient),
                                     kappa, u_ambient)
    return u_block_p, u_fin_p, u_block_t, q_block, u_fin_t, q_fin


@dataclass
class HeatsinkStudy:
    """Everything `run` computed, for the figures and the summary to read."""
    kappa: float
    u_ambient: float
    u_hot: float
    ramp: float
    flux: float
    mesh: Mesh                      # the finned sink
    block: Mesh                     # the solid block of the same bounding box
    bc: Conditions          # the transient's conditions
    solution: TransientSolution     # the warm-up
    flux_values: list[np.ndarray]   # |grad u| at each step
    shed_values: list[float]        # heat shed through the film at each step
    u_block_p: np.ndarray           # fixed power: block
    u_fin_p: np.ndarray             # fixed power: finned
    u_block_t: np.ndarray           # base held hot: block
    u_fin_t: np.ndarray             # base held hot: finned
    q_block: float                  # heat shed with the base held hot
    q_fin: float
    fin_lengths: np.ndarray         # the single fins swept for efficiency
    eta_fem: np.ndarray
    eta_theory: np.ndarray

    @property
    def u_values(self) -> np.ndarray:
        return self.solution.dofs

    @property
    def t_values(self) -> np.ndarray:
        return self.solution.t

    @property
    def width(self) -> float:
        return float(np.max(self.mesh.vertices[:, 0]))

    def base_temperature(self, t) -> float:
        """The chip's temperature schedule: ambient to hot over `ramp` seconds, then held."""
        return self.u_ambient + (self.u_hot - self.u_ambient) * min(t / self.ramp, 1.0)

    @property
    def metal_ratio(self) -> float:
        """The finned sink's material over the block's: the fins carve channels out of it."""
        return self.mesh.area / self.block.area

    @property
    def power(self) -> float:
        return self.flux * self.width

    @property
    def block_rise(self) -> float:
        """The block's base temperature above ambient at fixed power."""
        return float(self.u_block_p.max()) - self.u_ambient

    @property
    def fin_rise(self) -> float:
        """The finned sink's base temperature above ambient at fixed power."""
        return float(self.u_fin_p.max()) - self.u_ambient

    @property
    def r_block(self) -> float:
        """Thermal resistance, base rise per unit power."""
        return self.block_rise / self.power

    @property
    def r_fin(self) -> float:
        return self.fin_rise / self.power

    @property
    def effectiveness(self) -> float:
        """Heat shed by the finned sink over the block's, both bases held hot."""
        return self.q_fin / self.q_block

    @property
    def tip(self) -> float:
        """The coldest point at the end of the warm-up: the fin tips."""
        return float(self.u_values[-1].min())

    @property
    def eta_here(self) -> float:
        """The computed efficiency of a fin the length of this sink's own."""
        return float(self.eta_fem[np.argmin(np.abs(self.fin_lengths - FIN_LENGTH))])


def run(dt=0.05, steps=30, kappa=0.3, u_ambient=300.0, u_hot=400.0, ramp=0.6, flux=40.0,
        fin_lengths=(0.4, 0.8, 1.4, 2.0, 2.8), min_angle=28,
        max_area_fraction=0.0004) -> HeatsinkStudy:
    """Mesh the sink, warm it up, compare it with a block, and validate its fins."""
    # A heatsink conducts heat up its fins and sheds it, so the shape is worth measuring;
    # the mesh is built here because it is part of what the demo says.
    outline = heatsink_outline()
    target_area = max_area_fraction * outline.area()
    mesh = outline.mesh(min_angle=min_angle, max_area=target_area)
    width = float(np.max(mesh.vertices[:, 0]))
    height = float(np.max(mesh.vertices[:, 1]))
    # The naive baseline: a solid block of the same bounding box. The fins carve channels
    # out of it, trading metal for surface area.
    block = solid_block(width, height, target_area)

    bc, solution, flux_values, shed_values = warm_up(
        mesh, dt, steps, kappa, u_ambient, u_hot, ramp)
    u_block_p, u_fin_p, u_block_t, q_block, u_fin_t, q_fin = compare_with_block(
        mesh, block, kappa, u_ambient, u_hot, flux)
    lengths, eta_fem, eta_theory = fin_efficiency(
        kappa, u_ambient, u_hot, thickness=FIN_THICKNESS, lengths=fin_lengths)
    return HeatsinkStudy(kappa, u_ambient, u_hot, ramp, flux, mesh, block, bc, solution,
                         flux_values, shed_values, u_block_p, u_fin_p, u_block_t, u_fin_t,
                         q_block, q_fin, lengths, eta_fem, eta_theory)
The figures, and the demo that assembles them
"""The figures and summary of the finned heatsink demo, drawn from a `HeatsinkStudy`."""
import numpy as np
from demo_registry import Demo, DemoResult, Figure
from matplotlib.lines import Line2D

from demos.heat import physics
from demos.heat.physics import (
    FIN_LENGTH,
    FIN_THICKNESS,
    HeatsinkStudy,
    run,
    theory_efficiency,
)
from fem.plot.plotter import Plotter


def _mark_base(ax, width, kind):
    """Draw the base condition just below the domain, off the coloured field so it does not
    clash with the warm colormap: upward arrows for a Neumann heat flux, a bar for a held
    Dirichlet temperature. The Robin film (every other surface) is left to the legend."""
    y0 = -0.22
    if kind == 'flux':
        xs = np.linspace(0.1 * width, 0.9 * width, 8)
        ax.quiver(xs, np.full_like(xs, y0), np.zeros_like(xs), np.ones_like(xs),
                  color='red', angles='xy', scale_units='xy', scale=1 / 0.17, width=0.010,
                  headwidth=4, headlength=5, clip_on=False, zorder=6)
    else:
        ax.plot([0.05 * width, 0.95 * width], [y0, y0], color='tab:blue', lw=4,
                solid_capstyle='round', clip_on=False, zorder=6)
    ax.set_ylim(bottom=y0 - 0.12)


def _comparison_figure(s: HeatsinkStudy) -> Figure:
    # One colour scale across all four (ambient to the block's fixed-power peak), so the
    # panels compare directly, one shared bar per row on the right.
    clim = (s.u_ambient, max(float(s.u_block_p.max()), s.u_hot))
    comparison = Plotter(2, 2, panel_aspect=1.6, axis_labels=False, figsize=(10.5, 7.2),
                         title='Heatsink vs a solid block of the same size')
    comparison.plot(s.block, s.u_block_p, mode='colored', idx=(0, 0), cmap='inferno',
                    clim=clim, colorbar=False,
                    title=f'Same power in: solid block\nbase +{s.block_rise:.0f} C  '
                          f'(R = {s.r_block:.2f})')
    comparison.plot(s.mesh, s.u_fin_p, mode='colored', idx=(0, 1), cmap='inferno', clim=clim,
                    label='temperature',
                    title=f'Same power in: finned\nbase +{s.fin_rise:.0f} C  '
                          f'(R = {s.r_fin:.2f})')
    comparison.plot(s.block, s.u_block_t, mode='colored', idx=(1, 0), cmap='inferno',
                    clim=clim, colorbar=False,
                    title=f'Base held at {s.u_hot:.0f}: solid block\nsheds Q = {s.q_block:.0f}')
    comparison.plot(s.mesh, s.u_fin_t, mode='colored', idx=(1, 1), cmap='inferno', clim=clim,
                    label='temperature',
                    title=f'Base held at {s.u_hot:.0f}: finned\nsheds Q = {s.q_fin:.0f}  '
                          f'({s.effectiveness:.1f}x on {s.metal_ratio:.2f}x the metal)')
    # Mark only the base, below the field: arrows for the Neumann flux (fixed-power row),
    # a bar for the held Dirichlet base (fixed-temperature row). The Robin film is every
    # other surface, named in the legend.
    for idx, kind in (((0, 0), 'flux'), ((0, 1), 'flux'), ((1, 0), 'held'), ((1, 1), 'held')):
        _mark_base(comparison.get_ax(idx), s.width, kind)
        comparison.get_ax(idx).tick_params(left=False, bottom=False,
                                           labelleft=False, labelbottom=False)
    comparison.fig.legend(handles=[
        Line2D([], [], color='red', marker='^', linestyle='', markersize=9,
               label='Neumann: heat flux into the base'),
        Line2D([], [], color='tab:blue', lw=4, label='Dirichlet: base held hot'),
        Line2D([], [], color='tab:orange', lw=3, label='Robin: film on all other surfaces'),
    ], loc='outside lower center', ncol=3, frameon=False, fontsize='small')
    return Figure(
        comparison,
        'The finned sink against a solid block of the same bounding box, posed two '
        'ways. Top, the same heat flux into each base (a chip of fixed power). The '
        f'block runs {s.block_rise:.0f} C above ambient, the finned sink '
        f'only {s.fin_rise:.0f} C, roughly halving the thermal resistance '
        f'(R {s.r_block:.2f} -> {s.r_fin:.2f}). Bottom, each base held at {s.u_hot:.0f}. The '
        f'finned sink sheds {s.effectiveness:.1f}x the heat with {s.metal_ratio:.2f}x the '
        'metal, since the fins trade material for surface area.',
        'comparison', thumbnail=True)


def _efficiency_figure(s: HeatsinkStudy) -> Figure:
    efficiency = Plotter(1, 1, title='Fin efficiency against beam theory')
    ax = efficiency.chart_ax(xlabel='fin length L', ylabel='fin efficiency (heat shed / ideal)')
    dense = np.linspace(min(s.fin_lengths), max(s.fin_lengths), 100)
    ax.plot(dense, theory_efficiency(s.kappa, FIN_THICKNESS, dense), '-', color='tab:red',
            alpha=0.6, label='theory  tanh(mL)/mL')
    ax.plot(s.fin_lengths, s.eta_fem, 'o', color='tab:blue', label='computed')
    ax.axvline(FIN_LENGTH, color='0.6', ls=':', label=f"this sink's fins (L = {FIN_LENGTH})")
    ax.set_title('Longer fins shed more, but run less efficiently')
    ax.grid(alpha=0.3)
    ax.legend()
    return Figure(
        efficiency,
        'Fin efficiency, the heat a fin sheds over what it would shed with all of it '
        'at the base temperature, against the beam-theory law tanh(mL)/(mL). The '
        'computed fins track it closely. Efficiency falls as fins lengthen, because a '
        "long fin runs cold toward the tip and carries less of its share. This sink's "
        f'fins (L = {FIN_LENGTH}) sit near {s.eta_here:.0%}, trading efficiency for '
        'surface area.',
        'efficiency')


def _animation_figure(s: HeatsinkStudy) -> Figure:
    # Temperature and heat flux side by side, stepping together: a warm colormap for a
    # warming shape on a scale fixed from ambient to the heated base, and the flux on
    # its own scale.
    animation = Plotter(1, 2, panel_aspect=1.6, title='Heatsink warming up')
    animation.plot_animation(
        s.mesh, s.u_values, mode='colored', label='temperature', cmap='inferno',
        clim=(s.u_ambient, s.u_hot), idx=(0, 0),
        titles=[f't = {t:.2f}   base at {s.base_temperature(t):.0f}' for t in s.t_values])
    animation.plot_animation(
        s.mesh, s.flux_values, mode='colored', label='|grad u|', cmap='viridis', idx=(0, 1),
        titles=[f't = {t:.2f}   heat shed {shed:.1f}'
                for t, shed in zip(s.t_values, s.shed_values, strict=True)])
    return Figure(
        animation,
        'The finned sink warming from a cold start, the transient heat equation '
        'stepped by Crank-Nicolson. Left, temperature: the warming front climbs each '
        'fin and settles into the fin gradient, hot at the root and about '
        f'{s.tip:.0f} at the tips; the title tracks the base temperature as it switches '
        'on. Right, the heat flux magnitude recovered from each step: largest in the '
        'base and at the fin roots, where the gradient is steepest, and fading toward '
        'the tips as the fins run cold; the title tracks the heat shed to ambient '
        f'through the film, which climbs toward the steady {s.q_fin:.1f}.',
        'animation', frames=len(s.t_values))


def _setup_figure(s: HeatsinkStudy) -> Figure:
    setup = Plotter(1, 2, title='How the heatsink is posed')
    setup.plot(s.mesh, mode='bc', conditions=s.bc, title='Boundary conditions', idx=(0, 0))
    schedule = setup.chart_ax(idx=(0, 1), xlabel='t', ylabel='base temperature')
    t_dense = np.linspace(0.0, float(s.t_values[-1]), 400)
    schedule.plot(t_dense, [s.base_temperature(t) for t in t_dense], color='tab:red',
                  label='base (Dirichlet)')
    schedule.axhline(s.u_ambient, color='tab:orange', ls='--', label='ambient (Robin film)')
    schedule.set_ylim(s.u_ambient - 10, s.u_hot + 10)
    schedule.set_title(f'The base switches on over {s.ramp:.1f} s')
    schedule.grid(alpha=0.3)
    schedule.legend(loc='lower right')
    return Figure(
        setup,
        'Left, the conditions. The bottom face is a chip switching on: its '
        f'temperature ramps from ambient to {s.u_hot:.0f} over the first {s.ramp:.1f} s '
        'and then holds (right). Every other surface carries a Robin film, '
        'du/dn + kappa*(u - u_ambient) = 0, shedding heat to ambient. The sink starts '
        'cold at ambient, so the transient is a warm-up to the steady dissipating state.',
        'conditions', setup=True)


def _summary(s: HeatsinkStudy) -> str:
    return (f'thermal resistance R (base rise per unit power):\n'
            f'  solid block   {s.r_block:.3f}\n'
            f'  finned sink   {s.r_fin:.3f}   ({s.r_block/s.r_fin:.1f}x lower)\n'
            f'heat shed with the base held {s.u_hot:.0f} (ambient {s.u_ambient:.0f}):\n'
            f'  solid block   {s.q_block:.1f}\n'
            f'  finned sink   {s.q_fin:.1f}   ({s.effectiveness:.1f}x, on '
            f'{s.metal_ratio:.2f}x the metal)\n'
            f'fin efficiency at L = {FIN_LENGTH}:  {s.eta_here:.2f}  (beam theory close)')


def demo(**kwargs) -> DemoResult:
    """Warm a finned heatsink from a cold start, then compare it with a solid block and
    with beam theory."""
    s = run(**kwargs)
    return DemoResult([
        _comparison_figure(s),
        _efficiency_figure(s),
        _animation_figure(s),
        _setup_figure(s),
    ], text=_summary(s))


# Builds its own heatsink and a solid-block baseline, so it takes no domain.
DEMO = Demo('heat', demo, section='Meshing & solving PDEs',
            show_source=physics,
            smoke_kwargs={'max_area_fraction': 0.03, 'steps': 4, 'fin_lengths': (0.8, 2.0)})