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
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)
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 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)})