Hide code cell content

import mmf_setup; mmf_setup.nbinit()
import os
from pathlib import Path
FIG_DIR = Path(mmf_setup.ROOT) / '../Docs/_build/figures/'
os.makedirs(FIG_DIR, exist_ok=True)
import logging; logging.getLogger("matplotlib").setLevel(logging.CRITICAL)
%matplotlib inline
import numpy as np, matplotlib.pyplot as plt
try: from myst_nb import glue
except: glue = None

This cell adds /home/docs/checkouts/readthedocs.org/user_builds/physics-571-math-methods/checkouts/latest/src to your path, and contains some definitions for equations and some CSS for styling the notebook. If things look a bit strange, please try the following:

  • Choose "Trust Notebook" from the "File" menu.
  • Re-execute this cell.
  • Reload the notebook.

11. Complex Analysis#

Complex analysis provides a powerful framework unifying the following concepts, which are extremely useful for physical applications.

  • Conformal maps: Transformations that preserve angles can often help solve problems. The real and imaginary parts of holomorphic functions are harmonic functions, which provide solutions to Laplace’s equation. Conformal maps preserve this property, allowing one to find simple symmetric solutions to e.g. the wave equation, then transform them to more complicated boundaries or geometries. On very interesting example is the using the Joukowsky transform to take the symmetric solution for laminar flow past a cylinder into the to flow across the Joukowsky airfoil.

Analytic Functions#

Recall that many functions can be defined in terms of a power series:

\[\begin{gather*} f(x) = \sum_{n=0}^{\infty} c_{n} x^{n}, \end{gather*}\]

with some range of convergence \(-R < x < R\). By expanding about different points, one can often extend the definition of the function through a sequence of converging power series. This is called analytic continuation.

This can be easily extended to the complex plane:

\[\begin{gather*} f(z) = \sum_{n=0}^{\infty} c_n z^{n}, \qquad z = x + \I y. \end{gather*}\]

For example,

\[\begin{gather*} e^{z} = e^{x + \I y} = e^{x}e^{\I y} = e^{x}(\cos y + \I \sin y). \end{gather*}\]

In the complex plane, analytic functions can be expressed in terms of \(z\) only: e.g. \(z^2\), \(e^z\), \(\sin(z)\). [Hassani, 2013] formalizes this in Box 10.2.3: If a function is complex analytic, then, after substituting \(x = (z+z^*)/2\) and \(y=(z-z^*)/2\), it will be independent of \(z^*\).

Multifunctions#

Consider the behaviour of \(z \mapsto w = e^{z}\) shown below (from [Needham, 2023]).

Hide code cell source

# Code to produce figure 2.19 from [Needham:2023]
from matplotlib import patches
from scipy.interpolate import CubicSpline


class Conformal:
    """Demonstration of a conformal map."""
    
    x0, y0 = 1, 5     # Initial grid is [-x0, x0] × [0, y0] before scaling
    Nx = 11           # Number of gridlines along x
    shift, scale = 0j, 1,  # Shift and scale before f(z).
    resolution = 100  # Number of points to add to make lines curved.
    
    # Plotting parameters for various elements
    grid_kw = dict(lw=0.5, c='k', ls='-')

    # Various shapes
    T = np.array([1+0j, 2+0j, 2+2j, 3+2j, 3+3j, 0+3j, 0+2j, 1+2j])
    L = np.array([1+0j, 2+0j, 2+2j, 3+2j, 3+3j, 1+3j])
    T_scale = 1
    T_shift = 1+21j
    T_args = dict(fc='C0')

    L_scale = T_scale
    L_shift = T_shift
    L_args = T_args

    def __init__(self, **kw):
        for key in kw:
            if not hasattr(self, key):
                raise ValueError(f"Unknown {key=}")
            setattr(self, key, kw[key])
        self.init()
    
    def init(self):
        self.dx = 2 * self.x0 / (self.Nx - 1)
        self.Ny = int(np.ceil(self.y0/self.dx))
        self.y0 = self.dx * self.Ny
    
    def f(self, z):
        """The conformal transformation."""
        return z
        
    def _f(self, z):
        """Private version subclasses can change."""
        return self.f(z)
    
    def fun(self, z):
        """Return f((z + shift)*scale)."""
        return self._f((np.asarray(z) + self.shift)*self.scale)
        
    def transform(self, z):
        """Return transformed coordinates (x, y)."""
        fz = self.fun(z)
        return fz.real, fz.imag
        
    def interpolate(self, zs, resolution=None, closed=True):
        """Return a new path with intermediate points added to each segment."""
        if resolution is None:
            resolution = self.resolution
        lam = np.arange(resolution) / resolution
        if closed:
            z0s = zs
            z1s = np.concatenate([zs[1:], zs[:1]])
        else:
            z0s = zs[:-1]
            z1s = zs[1:]
        Zs = [(1-lam)*z0 + lam*z1 for z0, z1 in zip(z0s, z1s)]
        if not closed:
            Zs.append([zs[-1]])
        return np.concatenate(Zs)

    arrow_kw = dict(length_includes_head=True,
                    overhang=0.4,
                    color='k')
    # Arrows and dots
    def draw_arrows(self, zs, N=1, length=0.2):
        """Draws N arrows equally spaced along zs.

        Equally spaced means equally along the index of zs.
        """
        ax = self._ax
        spl = CubicSpline(np.linspace(0, 1, len(zs)), zs)
        lams = np.linspace(0, 1, N+1)[1:]
        for lam in lams:
            z, dz = spl(lam), spl.derivative()(lam)
            dz *= length/abs(dz)
            z0 = z-dz

            arrow = patches.FancyArrow(
                z0.real, z0.imag, dz.real, dz.imag,
                head_width=length, head_length=length,
                **self.arrow_kw)
            ax.add_patch(arrow)

    def draw_line(self, z0, z1, ls='-', c='k', Narrows=0,
                  marker=None, ms=5.0, mfc='w', markeredgewidth=0.2,
                  **kw):
        """Draw a line from z0 to z1 with N arrows."""
        ax = self._ax
        if z0 is not None and z1 is not None:
            zs = self.interpolate([z0, z1], closed=False)
            ax.plot(*self.transform(zs), ls=ls, c=c, **kw)
        if marker:
            ax.plot(*self.transform([z1]), c=c, 
                    marker=marker, ms=ms, mfc=mfc, markeredgewidth=markeredgewidth,
                    zorder=100)

            # Currently a fudge factor so arrows do not overlap maerker.
            # Ideally we will  convert from ms
            dz = z1-z0
            dz *= 0.2 * self.dx / abs(dz)
            zs = self.interpolate([z0, z1-dz], closed=False)
        if Narrows > 0:
            self.draw_arrows(self.fun(zs), N=Narrows)

    def draw_grid(self):
        ax = self._ax

        # Main grid
        x0, y0 = self.x0, self.y0
        X = np.linspace(-x0, x0, self.Nx)[:-1]
        Y = np.arange(self.Ny+1) * self.dx
        x = np.linspace(-x0, x0, self.resolution)
        y = np.linspace(0, y0, self.resolution)

        X, y = np.meshgrid(X, y, indexing='ij')
        x, Y = np.meshgrid(x, Y, indexing='ij')
        ax.plot(*self.transform(x+1j*Y), **self.grid_kw)
        ax.plot(*self.transform(X.T + 1j*y.T), **self.grid_kw)

        # Grey patch
        P = np.array([-x0, 0, 0+y0*1j, -x0 + 1j*y0])
        P = self.interpolate(P, closed=True)
        P = self.fun(P)
        ax.add_patch(patches.Polygon(np.transpose([P.real, P.imag]), color='lightgrey'))
        ax.set(aspect=1)
    
    def draw_T(self):
        ax = self._ax
        T = self.interpolate(self.T, resolution=10, closed=True)
        T = (T * self.T_scale + self.T_shift) * self.dx
        ax.add_patch(patches.Polygon(np.transpose(self.transform(T)), **self.T_args))

    def draw_L(self):
        ax = self._ax
        L = self.interpolate(self.L, resolution=10, closed=True)
        L = (L * self.L_scale + self.L_shift) * self.dx
        ax.add_patch(patches.Polygon(np.transpose(self.transform(L)), **self.L_args))
    
    def draw(self, ax=None, z=1-0.5j):
        """Demonstrates a conformal map."""
        if ax is None:
            fig, ax = plt.subplots()
        self._ax = ax

        self.draw_grid()
        self.draw_L()

        # The lines
        x0 = self.x0
        for s in [2, 4]:
            self.draw_line(-x0+0j, x0+1j*s*x0, ls='-', c='k')
        
        self.draw_line(z, z - 2*self.x0, Narrows=4, ls='-')
        self.draw_line(z, z+2j*np.pi, ls='--', dashes=(8, 4), lw=1, Narrows=4,
                       marker='o')
        
        # Some extra points
        zs = np.array([0.0, z])
        ax.plot(*self.transform(zs), 'ok', mfc='w', markeredgewidth=0.2)

        return z

f = np.exp

map0 = Conformal()
map1 = Conformal(f=f)

fig, axs = plt.subplots(1, 2, width_ratios=(1, 3), figsize=(6, 4))

ax = axs[0]
ax.axvline(0, c='k', lw=0.3)
ax.axhline(0, c='k', lw=0.3)

z1 = map0.draw(ax=ax)

kw = dict(xytext=(1, 0), textcoords='offset fontsize', va='center')
ax.annotate("$z$", (z1.real, z1.imag), **kw)
ax.annotate("$z+2\pi\mathrm{I}$", (z1.real, z1.imag + 2*np.pi), **kw)
ax.annotate("$0$", (0, 0), **dict(kw, xytext=(-0.3, -0.3), ha='right', va='top'))

ax = axs[1]
map1.draw(ax=ax)
ax.annotate("$1$", (1, 0), **dict(kw, xytext=(0, -0.5), ha='center', va='top'))
ax.annotate("$w$", (f(z1).real, f(z1).imag), 
                   **dict(kw, xytext=(1, -1), ha='center', va='center'))
plt.suptitle("Effect of the mapping $e^z$")
for ax in axs:
    ax.set_axis_off()
_images/6e7b0c206e8546804d902fd34fd04138062a2292287a4e1fc50ee9c1ce21a2f7.png

Notice that it takes maps a strip \((-\infty, \infty)\times [y, y+2\pi \I]\) into the entire plane \(\mathbb{C} \equiv \mathbb{R}^2\). Specifically, the points with \(x\leq 0\) get mapped into the unit ball \(r\leq 0\), while the points \(x >0\) are mapped to the outside disk. Different strips in the \(y\) direction of width \(2\pi\) will be mapped on top of each other. Hence, the inverse function \(\ln z\) must be multi-valued – a multifunction as [Needham, 2023] calls them.

To see this, consider \(\ln z\) where \(z = re^{\I\theta} \equiv re^{\I\theta + 2\pi \I n}\) since \(e^{2\pi \I n} = 1\) where \(n\) is an integer. Thus:

\[\begin{gather*} \ln z = \ln r + \I (\theta + 2\pi n) \end{gather*}\]

where the integer \(n\) specifics which branch of the function we are “on”. To be definite, one usually defines the following functions with capital letters to denote that they yield the principal branch or principal value:

\[\begin{gather*} \DeclareMathOperator{\Log}{Log} \DeclareMathOperator{\Arg}{Arg} z = r e^{\I\theta},\qquad \abs{z} = r, \qquad \Arg(z) = \theta \in (-\pi, \pi],\qquad \Log(z) = \ln\,\abs{z} + \I \Arg(z). \end{gather*}\]

You should already be familiar with this idea: for example both \(+1\) and \(-1\) square to \(1\), so you should be familiar with \(z^{1/2}\) being a multifunction with two branches.

Hide code cell source

class ConformalSq(Conformal):
    y0 = 1.0
    x0 = 0.8
    shift = 0
    T_scale = -1j
    T_args = dict(fc='k')
    L_scale = -1j
    L_args = dict(fc='C0')

    def init(self):
        super().init()
        self.L_shift = self.Ny*1j
    
    def _f(self, z):
        # We use np.exp to setup the base figure here.
        return self.f(np.exp(z))
    
    def draw(self, ax=None):
        """Demonstrates a conformal map."""
        if ax is None:
            fig, ax = plt.subplots()
        self._ax = ax

        self.draw_grid()
        self.draw_L()

        # Unit circle.
        zs = np.exp(1j*np.linspace(0, 2*np.pi, self.resolution))
        ax.plot(zs.real, zs.imag, ls='-', lw=0.5)

        # The lines
        x0 = self.x0
        kw = dict(ls='-', c='k', Narrows=1)
        self.draw_line(0, self.x0 + self.dx/2, **kw)
        self.draw_line(0 + self.y0*1j, self.x0 + self.dx/2 + self.y0*1j, **kw)
        
        self.draw_line(self.x0, self.x0 + self.y0*1j, ls='--', dashes=(8, 4), lw=1, Narrows=4,
                       marker='o')

f = lambda z: z**2

fig, axs = plt.subplots(3, 2, width_ratios=(1, 1), figsize=(8, 12))

for n, y0 in enumerate([1.0, 2.0, 3.4]):
    map0 = ConformalSq(y0=y0)
    map1 = ConformalSq(f=f, y0=y0)

    ax = axs[n, 0]
    ax.axvline(0, c='k', lw=0.3)
    ax.axhline(0, c='k', lw=0.3)

    map0.draw(ax=ax)

    ax = axs[n, 1]
    ax.axvline(0, c='k', lw=0.3)
    ax.axhline(0, c='k', lw=0.3)
    map1.draw(ax=ax)

plt.suptitle("Effect of the mapping $z^2$")
for ax in np.ravel(axs):
    ax.set(xlim=(-6.5, 6.5), ylim=(-6.5, 6.5))
    ax.set_axis_off()
_images/ac32f69849890113bbd24bdb08bef90b877f7897d229db34db2ab69b022c74d5.png

Since the angle doubles, the map \(z\mapsto z^2\) effects a double cover of the plane. Similarly \(z^n\) for positive integer \(z\) will effect an \(n\)-cover.

Clausen’s Paradox: \((e^{a})^{b} = e^{ab} \Rightarrow 1=e^{-2\pi}\).

We know that \(e^{2\pi \I} = 1\), therefore \((e^{2\pi \I})^{\I} = 1^\I = 1\), but if \((e^{a})^{b} = e^{ab}\), then we must conclude that \((e^{2\pi \I})^{\I} = e^{-2\pi} = 1\), which is rather absurd.

One must be careful with complex powers. The multifunction \(z^k\) is defined to be

\[\begin{gather*} z^{k} = e^{k \ln z} = e^{k(\Log z + 2\pi \I n)}, \end{gather*}\]

but it does not follow that \((e^{a})^{b} = e^{ab}\) as it does for real numbers. Instead, we should let \(z=e^{a}\) and \(k=b\) to get

\[\begin{gather*} (e^{a})^{b} = e^{b \ln e^{a}} = e^{b (a + 2\pi \I n)}. \end{gather*}\]

Applying this to our original expression, we have

\[\begin{gather*} (e^{2\pi \I})^{\I} = e^{\I (2\pi \I + 2\pi \I n)} = e^{-2\pi (1+n)}. \end{gather*}\]

This is \(1\) for the appropriate branch \(n=-1\). In summary, when discussing multifunctions, one should restrict oneself carefully to use the definitions like \(z^{k} = e^{k \ln z} = e^{k(\Log z + 2\pi \I n)}\).

Do It! Show that the branches of \(k^{z}\) are unrelated.

Follow [Needham, 2023] Ex. 29 in §2.9 and show that the “branches” of \(k^{z}\) are

\[\begin{gather*} k^{z} = e^{l_n z}, \qquad l_n = (\Log k + 2\pi \I n), \end{gather*}\]

but that each of these branches is “unrelated” in the sense that, if you start on one branch, you cannot get to another by moving continuously (analytic continuation). Thus, the multifunction \(k^{z}\) has no branch cuts, but should be thought of as a collection \(\{\dots, e^{l_{-1}z}, e^{l_0z}, e^{l_1z}, \dots\}\) of unrelated single-valued functions.

Derivatives: Cauchy-Riemann Conditions#

The first idea is to extend the notion of the derivative to complex-valued functions. Proceeding in analogy from calculus, we would like

\[\begin{gather*} \diff{f(z)}{z} = \lim_{h \rightarrow 0} \frac{f(z+h) - f(z)}{h}. \end{gather*}\]

The difficulty now is that we must consider \(h\in \mathbb{C}\) as complex. Thus, we can write, for real \(h\):

\[\begin{gather*} \diff{f(z)}{z} = \lim_{h \rightarrow 0} \frac{f(z+hw) - f(z)}{hw}, \qquad w = e^{\I\phi}. \end{gather*}\]

This gives the directional derivative along the direction \(w=e^{\I\phi}\). For such a derivative to make sense, the result must be independent of \(w\), which leads to the Cauchy-Riemann equations:

\[\begin{gather*} \pdiff{u}{x} = \pdiff{v}{y}, \qquad \pdiff{u}{y} = -\pdiff{v}{x}. \end{gather*}\]

Functions that satisfy these Cauchy-Riemann equations in a neighbourhood of a point \(z_0\) are said to be complex analytic (or just analytic) at \(z_0\). Complex analytic functions are also called holomorphic functions.

This has some interesting consequences for derivatives. Independence of direction means

\[\begin{gather*} f'(z) = \underbrace{\overbrace{\pdiff{f}{x}}^{f_{,x}}}_{\pdiff{f}{x}/\pdiff{z}{x}} = \underbrace{-\I \overbrace{\pdiff{f}{y}}^{f_{,y}}}_{\pdiff{f}{y}/\pdiff{z}{y}}. \end{gather*}\]
\[\begin{gather*} \end{gather*}\]

Since \(f'(z)\) is analytic at the same places as \(f(z)\), we can repeat this:

\[\begin{gather*} f''(z) = f_{,xx} = -f_{,yy} = -\I f_{,xy}. \end{gather*}\]

You can use and rearrange these as needed.

Conformal Maps and Harmonic Functions#

Consider how an analytic function \(f(z)\) behaves near \(z_0\):

\[\begin{gather*} f(z) = \underbrace{f(z_0)}_{b} + \underbrace{f'(z_0)}_{a}(z-z_0) + O\Bigl((z-z_0)^2\Bigr). \end{gather*}\]

This means that, locally, \(f(z)\) acts like \(z \mapsto az + b\) where \(a, b\in\mathbb{C}\) are complex numbers. This means that locally, analytic functions are amplitwists: the factor \(a\) scales and rotates the function, while the constant \(b\) shifts the function.

To see this, use polar coordinates:

\[\begin{gather*} z = re^{\I\theta}, \qquad a = f'(z_0) = se^{\I\phi}, \qquad az = (sr)e^{\I(\theta+\phi)}. \end{gather*}\]

This is a combination of an “amplification” scaling the radius by a factor \(s\) and a “twist”, rotating the region of the plane by \(\phi\). Significantly, such transformations preserve angles: hence an analytic functions \(f: \mathbb{C}\mapsto\mathbb{C}\) locally defines a conformal map. This property has many uses, as will be discussed below.

As a consequence of the Cauchy-Riemann equations, the real and imaginary parts of analytic functions are harmonic functions, meaning that they satisfy Laplace’s equation:

\[\begin{gather*} f(z) = u(x, y) + \I v(x, y), \qquad \nabla^2 u(x, y) = \nabla^2 v(x, y) = 0. \end{gather*}\]

This, combined with the property than analytic functions are conformal maps, provides a powerful tool for solving Laplace’s equation and related equations. The idea is to find a solution with simple boundary conditions, then transform that solution to another more complicated domain. The power of this approach is embodied in the following theorem:

Theorem 1 (Riemann Mapping Theorem)

There is a one-to-on conformal map between any two non-empty simply-connected open subsets \(R, S\subset \mathbb{C}\).

The following shows, for example, how you can map the half-plane into a corner, allowing one to solve the corner flow problem:

Hide code cell source

class ConformalCorner(Conformal):
    y0 = 10.0
    x0 = 10.0
    Nx = 21
    shift = 0
    T_scale = -1j
    T_args = dict(fc='k')
    L_scale = -1j
    L_args = dict(fc='C0')

    def init(self):
        super().init()
        self.L_shift = self.Ny*1j
    
    def _f(self, z):
        # We use np.exp to setup the base figure here.
        return self.f(z)
    
    def draw(self, ax=None):
        """Demonstrates a conformal map."""
        if ax is None:
            fig, ax = plt.subplots()
        self._ax = ax

        self.draw_grid()
        self.draw_L()

        # Unit circle.
        #zs = np.exp(1j*np.linspace(0, 2*np.pi, self.resolution))
        #ax.plot(zs.real, zs.imag, ls='-', lw=0.5)

        # The lines
        #x0 = self.x0
        #kw = dict(ls='-', c='k', Narrows=1)
        #self.draw_line(0, self.x0 + self.dx/2, **kw)
        #self.draw_line(0 + self.y0*1j, self.x0 + self.dx/2 + self.y0*1j, **kw)
        
        #self.draw_line(self.x0, self.x0 + self.y0*1j, ls='--', dashes=(8, 4), lw=1, Narrows=4,
        #               marker='o')

f = lambda z: z**2

fig, axs = plt.subplots(2, 3, figsize=(12, 6))

map0 = ConformalCorner()
ax = axs[0, 1]
ax.axvline(0, c='k', lw=0.3)
ax.axhline(0, c='k', lw=0.3)

map0.draw(ax=ax)


for n, (a, b) in enumerate([(3, 2), (1, 2), (1, 3)]):
    map1 = ConformalCorner(f=lambda z:z**(a/b))
    ax = axs[1, n]
    ax.axvline(0, c='k', lw=0.3)
    ax.axhline(0, c='k', lw=0.3)
    map1.draw(ax=ax)
    ax.set(title=f"$z^{{{a}/{b}}}$")

plt.suptitle("Effect of the mapping $z^{k}$")
for ax in np.ravel(axs):
    ax.set_axis_off()
_images/4c4b2af67ff91bb69c9254608c61faa6ec00d8d50357580924730417f36f19ce.png

Example: Laminar flow.

The hydrodynamic equations for an ideal (inviscid) fluid with density \(n\), velocity \(\ket{u}\) and equation of state (energy-density) \(\mathcal{E}(n)\) in an external potential \(V\) are x(index notation on the right):

\[\begin{align*} \dot{n} + \braket{\nabla|nu} &= 0, & \dot{n} + (nu_{,a})_{,b} &= 0, \\ \ket{\dot{u}} + \braket{u|\nabla}\ket{u} &= \underbrace{-\ket{\nabla}\frac{\Bigl(V + \mathcal{E}'(n)\Bigr)}{m}}_{-\ket{\nabla}\phi}, & \dot{u}_{a} + u_{i}u_{a,i} &= -\phi_{,a}, \end{align*}\]

The first is the continuity equation, while the second is Newton’s law in the form \(a = F/m\) where \(F = -\nabla V\). For stationary incompressible flow, time derivatives vanish and the density is constant. Many fluids like water and even air because like they are virtually incompressible when flowing a low speeds (low Mach number). The fluid equations simplify to:

\[\begin{align*} \braket{\nabla|u} &= 0, & u_{a,a} &= 0,\\ \braket{u|\nabla}\ket{u} &= -\ket{\nabla}\phi, & u_{a,i}u_{i} &= -\phi_{,a}. \end{align*}\]

Laminar flow – as opposed to turbulent flow – occurs when the fluid is irrotational in the bulk: I.e., no vortices or vorticity:

\[\begin{align*} \vect{\omega} &= \vect{\nabla}\times\vect{u} = 0, & \omega_{c} &= \varepsilon_{abc}u_{b,a} = 0. \end{align*}\]

Thus, in addition to Newton’s law \(u_{a,i}u_{i} = -\phi_{,a}\), stationary incompressible laminar flow satisfies

\[\begin{gather*} u_{a,a} = 0, \qquad \varepsilon_{abc}u_{a, b} = 0. \end{gather*}\]

In two dimensions, these become

\[\begin{gather*} u_{x,x} + u_{y,y} = 0, \qquad u_{x,y} - u_{y,x} = 0. \end{gather*}\]

Note that these look very similar to the Cauchy-Riemann conditions. In particular, if we define (note the minus sign)

\[\begin{gather*} f(z) = u_{x} - \I u_y, \qquad z=x+\I y, \end{gather*}\]

then these two conditions are precisely the Cauchy-Riemann conditions for \(f(z)\) to be analytic. This means that, if we can find a solution in one coordinate system, then we can transform this to any other coordinate system using a conformal map.

For example, consider

\[\begin{gather*} f(z) = 1 - \frac{1}{z^2}. \end{gather*}\]

The corresponding flow is tangent to the unit circle meaning that this is valid. Using the Joukowsky transform – a particular conformal map – one can transform this flow into that about the Joukowsky airfoil, giving an analytic model of lift on a wing.

Infinity#

One can consider the extended complex numbers \(\bar{\mathbb{C}} = \mathbb{C} \cup \{\infty\}\) which includes “the point at infinity”. To study the properties there, define \(w = 1/z\) and look at \(w\) at the origin. These can be organized through the stereographic projection of the Riemann sphere. This is a mapping between points \(z = x+\I y\) in \(\bar{\mathbb{C}}\) and points on the unit sphere \(\uvect{r} = (X, Y, Z)\):

\[\begin{gather*} X+\I Y = \frac{2z}{1+\abs{z}^2}, \qquad Z = \frac{\abs{z}^2-1}{\abs{z}^2+1}, \end{gather*}\]

or, in spherical coordinates \((r=1, \theta, \phi)\):

\[\begin{gather*} z = \cot \tfrac{\theta}{2}\;e^{\I\phi}. \end{gather*}\]

Möbius Transformations#

Möbius transformations are a generalization of this stereographic projection and correspond to projecting the complex plane to the Riemann sphere at one point, then moving (possibly rotating) the sphere, then projecting back. These are represented by the following analytic function (hence conformal map):

\[\begin{gather*} f(z) = \frac{az + b}{cz+d}. \end{gather*}\]

I.e. a linear rational function (i.e. a meromorphic function with a single pole).

To visualize these consider first translating a point \(z_0\) to the origin, then scaling and rotating (amplitwist), then inverting, then translating to another point \(z_1\), and finally scaling and rotating (amplitwist).

\[\begin{gather*} z \mapsto z - z_0 \mapsto c(z-z_0) \mapsto \frac{1}{c(z-z_0)} \mapsto \frac{1}{c(z-z_0) + z_1} \mapsto B\left(\frac{1}{c(z-z_0) + z_1}\right),\\ f(z) = \frac{B + cB(z-z_0)z_1}{c(z-z_0)} = \frac{\overbrace{cBz_1}^{a} z + \overbrace{B(1 - cz_0z_1)}^{b}} {cz + \underbrace{(-cz_0)}_{d}}. \end{gather*}\]

See [Needham, 2023] for details and pictures.

Contour Integrals#

Given a smooth path (contour) \(C = \{z(s) | s \in [0, 1]\}\) in the complex plane starting at \(a = z(0)\) and ending at \(b = z(1)\), we can define the following contour integral of a function \(f(z)\):

\[\begin{gather*} \int_{C} f(z)\d{z} \equiv \int_{0}^{1}f\bigl(z(s)\bigr)z'(s)\d{s}. \end{gather*}\]

The key property is that the contour integral is independent of the contour \(C\) in regions where \(f(z)\) is analytic. I.e.: you can deform the contour as long as you start at \(a\) and end at \(b\) and avoid any parts of the plane where \(f(z)\) is not analytic.

Combined with the residue theorem, we obtain a variety of powerful tricks for evaluating integrals in the complex plane – including integrals along the real axis. The strategy is

  1. Find a way of closing the contour so that the contour integral along the added pieces is known. Often we choose these so that the added piece is zero (most-commonly a semi-circular contour that vanishes as \(\Re z \rightarrow \pm \infty\)). Another option is to choose contours that are related to the original integral (often multiplied by a phase). The latter can often occur along a branch cut. Note: you must not cross a branch cut to close the contour.

  2. The full integral is then computed by deforming the contour until it either vanishes, or encircles (encloses) various poles of the function \(f(z)\) where it is not analytic. The contribution to the integral from these poles are then evaluated using the residue theorem.

  3. Finally, the original integral is extracted from the integral around the closed contour.

Although this is the standard approach, contour integration can be useful without trying to find a closed contour. Applications include finding saddle-point approximations, and numerically computing troublesome integrals – typically highly oscillatory – by finding a deformed contour where the integrand is smooth.

Residues#

The Cauchy residue formula states that, for a function \(f(z)\) which is analytic in an open neighbourhood of \(z_0\):

\[\begin{gather*} f(z_0) = \oint \frac{f(z)}{z-z_0}\frac{\d{z}}{2\pi \I}. \end{gather*}\]

This allows integration to be used to differentiate an analytic function:

\[\begin{gather*} f^{(n)}(z_0) = \frac{n!}{2\pi \I} \oint \frac{f(z)}{(z-z_0)^{n+1}}\d{z} \end{gather*}\]

Do It!

Derive the formula for the derivative by applying the residue theorem to

\[\begin{gather*} f'(z_0) = \lim_{h\rightarrow 0} \oint \frac{f(z)}{(z-z_0)^2 - h^2} \frac{\d{z}}{2\pi\I} = \oint \frac{f(z)}{(z-z_0)^2} \frac{\d{z}}{2\pi \I}. \end{gather*}\]

Do It!

Alternatively, derive the formula for the derivative by using partial fractions and the residue theorem to compute

\[\begin{gather*} \oint \frac{f(z)}{(z-z_0)^2}\frac{\d{z}}{2\pi \I} = \lim_{z_1 \rightarrow z_0} \oint\frac{f(z)}{(z-z_0)(z-z_1)}\frac{\d{z}}{2\pi \I} = f'(z_0). \end{gather*}\]

Important

Here are some important points:

  1. Knowing the values of an analytic function \(f(z)\) on a contour \(C = \partial A\) completely determines the value \(f(z_0)\) at all points inside the contour \(A\) since

    \[\begin{gather*} f(z_0) = \oint_{C} \frac{f(z)}{z-z_0}\frac{\d{z}}{2\pi \I}. \end{gather*}\]
  2. Knowing that function is analytic means that all derivatives are defined, and that these derivatives can be found by integration!

Laurent Series, Isolated Singularities, and The Residue#

If a function \(f(z)\) is analytic in an annular region centered about \(z_0\) (including on the boundaries), then at all points in the annulus, then function can be expressed as a Laurent series:

\[\begin{gather*} f(z) = \sum_{n=-\infty}^{n=\infty} a_n(z-z_0)^n, \qquad a_n = \oint_{C} \frac{f(z)}{(z-z_0)^{n+1}}\frac{\d{z}}{2\pi \I} \end{gather*}\]

where the coefficients can be calculated using the second contour integral over any contour \(C\) within the annulus that circles \(z_0\). Such a function is said to have an isolated singularity at \(z_0\). The residue of the function \(f(z)\) at the singularity \(z_0\) is the coefficient \(\Res[f(z_0)] = a_{-1}\):

\[\begin{gather*} f(z-z_0) = \cdots + \frac{a_{-2}}{(z-z_0)^2} + \frac{\Res[f(z_0)]}{z-z_0} + a_{0} + a_{1}(z-z_0) + \cdots. \end{gather*}\]

Hide code cell source

c = np.exp(1j*np.linspace(0, 2*np.pi, 200))

z0 = 0.7+0.4j
fig, ax = plt.subplots(figsize=(2,2))
cplot = lambda z, *v, **kw: ax.plot(np.real(z), np.imag(z), *v, **kw)
cplot(abs(z0)*c+z0, 'C0-', lw=3)
cplot([0, z0], 'kx', zorder=100)
ax.set(xlim=(-2, 2), ylim=(-2, 2), 
       xlabel=r"$\mathrm{Re}z$", xticks=[-2, -1, 0, 1, 2],
       ylabel=r"$\mathrm{Im}z$", yticks=[-2, -1, 0, 1, 2],
       aspect=1);
_images/167234ab8757c707cb7b0bb54ef1aef42bff39ec2cdca0992bed49c658095929.png

Example: \(f(z) = 1/z\).

Consider \(f(z) = 1/z\). It obviously has a Laurent series about \(z_0=0\), but what about other points? We might start by considering a geometric series

\[\begin{gather*} f(z) = \frac{1}{z} = \frac{1}{z-z_0 + z_0}. \end{gather*}\]

We can expand this two ways: first by considering \(z - z_0\) to be “small”:

\[\begin{gather*} f(z) = \frac{1}{z_0}\frac{1}{1 + \frac{z-z_0}{z_0}} = \frac{1}{z_0}\sum_{n=0}^{\infty}\left(\frac{z_0-z}{z_0}\right)^n. \end{gather*}\]

This is simply a Taylor series, and it converges for \(\abs{z-z_0} < \abs{z_0}\): i.e. inside the circle centered about \(z_0\) that goes through the pole at the origin \(z=0\).

We might also consider \(z-z_0\) to be “large”:

\[\begin{gather*} f(z) = \frac{1}{z-z_0}\frac{1}{1 + \frac{z}{z-z_0}} = \frac{1}{z-z_0}\sum_{n=0}^{\infty}\left(\frac{z_0}{z_0-z}\right)^n = \frac{-1}{z_0}\sum_{n=1}^{\infty}\left(\frac{z_0}{z_0-z}\right)^n. \end{gather*}\]

This is now a Laurent series that converges for \(\abs{z_0} < \abs{z-z_0}\): i.e. outside the circle centered about \(z_0\) that goes through the pole at the origin \(z=0\). Thus, two different series have different regions of convergence.

Residue Theorem#

The core idea of contour integration is that integrals over a closed contour \(C\) can be expressed as a sum of the enclosed residues:

\[\begin{gather*} \oint_{C} f(z)\frac{\d{z}}{2\pi\I} = \sum_n \Res[f(z_n)]. \end{gather*}\]

Residue at Infinity#

If a function has an isolated singularity at \(z=\infty\), then it would be useful to be able to use this in the residue theorem. Consider a contour \(C\) and a suitable function \(f(z)\) that has no singularities outside except at \(z=\infty\). We would like to have

\[\begin{gather*} \oint_C f(z) \frac{\d{z}}{2\pi \I} = -\Res[f(z=\infty)]. \end{gather*}\]

Changing variables to \(w=1/z\), \(\d{w} = -\d{z}/z^2\) we have

\[\begin{gather*} \Res[f(z=\infty)] = \oint_C f(z) \frac{-z^2\d{w}}{2\pi \I} = -\oint_C \frac{f(1/w)}{w^2} \frac{\d{w}}{2\pi \I} = -\Res\left[\left.\frac{f(1/w)}{w^2}\right|_{w=0}\right]. \end{gather*}\]

Hide code cell source

fig, ax = plt.subplots(figsize=(3, 2))
x = np.linspace(-5, -0.01)
ax.plot(x, np.exp(1/x), '-C0')
x = np.linspace(0.01, 5)
ax.plot(x, np.exp(1/x), '-C0')
ax.axvline([0], c='k', ls=':', lw=0.5)
ax.axhline([1], c='k', ls=':', lw=0.5)
ax.set(xlabel="$x$", ylabel="$e^{1/x}$", ylim=(-0.1, 3));
_images/7ee582e6a2a747fae0692f47ffbe5a51013f72160eb2ca4d3050bc9c84ca0cb9.png

Example: \(f(z) = e^{1/z}\).

Consider \(f(z) = e^{1/z}\). This is the quintessential example of a function with an essential singularity at \(z=0\). Despite this, the residue still exists, and can be useful for computing contour integrals. To find it, one might try computing the Laurent series, which is straightforward in this case since we know the Taylor series for \(e^{z}\):

\[\begin{gather*} e^{1/z} = \sum_{n=0}^{\infty} \frac{1}{z^{n} n!} = 1 + \frac{1}{z} + \cdots, \qquad \Res[e^{1/z_0}|_{z_0=0}] = 1. \end{gather*}\]

In other cases, however, manipulating products of infinite series might be a pain. Another approach is to note that if the function is meromorphic (analytic everywhere with only isolated singularities), then the sum of all residues is zero:

\[\begin{gather*} \sum_{n} \Res[f(z_n)] = 0. \end{gather*}\]

In this case, we can use the residue at \(z=\infty\):

\[\begin{gather*} \Res[e^{1/z_0}|_{z_0=0}] + \Res[e^{1/z_0}|_{z_0=\infty}] = 0. \end{gather*}\]

Jordan’s Lemma#

When using contours to compute integrals, it is often important to argue that the contribution from a semi-circular contour vanishes as the radius of that contour is taken to infinity. This is often referred to as Jordan’s Lemma. The Wikipedia formulation is quite useful

Lemma 1 (Jordan’s Lemma)

Let \(f(z)\) be a continuous function in the complex plane \(z \in \mathbb{C}\) with

\[\begin{gather*} f(z) = e^{\I a z}g(z), \qquad a>0, \qquad C_{R} = \{Re^{\I \theta} \mid \theta \in [0, \pi]\}. \end{gather*}\]

Jordan’s Lemma then bounds

\[\begin{gather*} \Abs{\int_{C_{R}} f(z)\d{z}} \leq \frac{\pi}{a}\max_{z\in C_{R}}\abs{g(z)}. \end{gather*}\]

This is especially useful if \(\lim_{R\rightarrow \infty}g(z) = 0\), in which case the contour vanishes. A couple of comments:

  1. This is almost obvious since \(e^{\I a z}\) contains an exponentially decaying piece – the subtlety comes from the regions \(\theta \approx 0\) and \(\theta \approx \pi\) where this exponential piece also vanishes. The lemma proves that one’s intuition remains correct even at these endpoints.

  2. The version in [Hassani, 2013] places an additional constraint that \(R\abs{f(zRe^{\I\theta})} \rightarrow 0\), allowing for the case \(a = 0\), but this excludes the interesting case above. I am not sure why only this from is presented.

Computing Integrals#

The general strategy for computing integrals using contour integration and the residue theorem can be summarized as follows:

  1. Express you original integral as a contour integral.

  2. Try to close the contour in such a way that the added contributions are known, or are expressible in terms of the original integral. JordansLemma is useful here.

  3. Deform the closed contour and compute the integral using the residue theorem.

  4. Remove the additional contributions and solve for the original integral.

# Numerical check.
import math
from scipy.integrate import quad

# Quick check.  The integrator has difficulty, but convergence looks good
ns = 2**np.arange(10)
Is = np.array([quad(lambda x: math.sin(x)/x, 0, 2*math.pi * n)[0]
               for n in ns])

fig, ax = plt.subplots(figsize=(2, 1), constrained_layout=True)
ax.loglog(ns, np.pi/2 - Is, '+-')
ax.set(xlabel="$n$", ylabel=r"$\frac{\pi}{2} - I_n$")

# Here we are more careful, using a special purpose integrator
# and pre-computing the first interval so 1/x does not diverge.

I1, I1err = quad(lambda x: math.sin(x)/x, 0, 2*math.pi)
I2, I2err = quad(lambda x: 1/x, 2*math.pi, np.inf, weight='sin', wvar=1.0)
I, Ierr = I1 + I2, I1err + I2err
abs(np.pi/2 - I)
/tmp/ipykernel_3786/3402906312.py:7: IntegrationWarning: The maximum number of subdivisions (50) has been achieved.
  If increasing the limit yields no improvement it is advised to analyze 
  the integrand in order to determine the difficulties.  If the position of a 
  local difficulty can be determined (singularity, discontinuity) one will 
  probably gain from splitting up the interval and calling the integrator 
  on the subranges.  Perhaps a special-purpose integrator should be used.
  Is = np.array([quad(lambda x: math.sin(x)/x, 0, 2*math.pi * n)[0]
3.018052474601518e-11
_images/672878c6d6a1e3b6c17426719136fdd1ac23fab7ea4d60d895ec55514cdc7a62.png

Example: \(I = \int_{0}^{\infty}\tfrac{\sin{x}}{x}\d{x}\).

Let’s start with the example from an early assignment:

\[\begin{gather*} I = \int_{0}^{\infty}\frac{\sin x}{x}\d{x}. \end{gather*}\]

We start by noting that that the integrand is even, so we can compute

\[\begin{gather*} I = \frac{1}{2}\int_{-\infty}^{\infty}\frac{\sin x}{x}\d{x}. \end{gather*}\]

Next, we note that we can express the integral as a contour integral and that the integrand is an analytic function. To close the contour, we consider semi-circle, hoping that this contribution vanishes. Unfortunately, the present integrand does not have nice behaviour at infinity, however, we can split it into two exponentials, each of which vanishes on one of the semicircles:

\[\begin{gather*} I = \frac{1}{2} \int_{-\infty}^{\infty} \left( \frac{e^{\I z}}{2\I z} - \frac{e^{-\I z}}{2\I z}\right) \d{z} \end{gather*}\]

The first term will integrate to zero along an upper semicircle, while the second term will integrate to zero along the lower semicircle. Splitting the integral introduces a pole at \(z=0\) in each term. To deal with this, we must consistently choose to deform the contour either above or below the pole in each term. The result should not depend on which way we choose, as long as we are consistent. Let’s shift it up so that it is contained in the first contour but not the second. This will cause the second contour integral to vanish since there are no poles. Thus, we have

\[\begin{gather*} I = \frac{\pi}{2} \oint \frac{e^{\I z}}{z-\I 0^+}\frac{\d{z}}{2\pi \I} = \frac{\pi}{2} e^{\I (\I 0^+)} = \frac{\pi}{2}. \end{gather*}\]

As a check, if we had shifted the contour down, we would have needed to use the second term which has a minus sign, however, the orientation of the contour also becomes negative, so the two signs cancel and we end up with the same result, as expected.

Saddle-point Approximation#

Certain integrals of analytic functions – especially with exponential factors \(e^{f(z)}\) – can be well approximated by deforming the integration contour to include a region where \(\Re f(z)\) is extremal. Since \(f(z)\) is analytic, these extrema must be saddle points since \(u(z) = \Re f(z)\) and \(v(z) = \Im f(z)\) are harmonic. The idea is to approximate the full integral by integrating along the contour of steepest descent, approximating the integrand as a Gaussian along this path:

\[\begin{gather*} \int_{C} e^{f(z)}\d{z} \approx \pm \I e^{f(z_0)}\sqrt{\frac{2\pi}{f''(z_0)}}. \end{gather*}\]

The sign must be chosen to ensure that the saddle is crossed in the appropriate direction consistent with the initial and final points without unnecessary crossings of the “mountain range” over which the saddle provides the cleanest path.

Note

Sometimes the idea of the saddle-point approximation is explained by approximating the integral by the point that maximizes \(u = \Re z\). For contour integrals this does not make much sense since \(u\) is always a saddle point. A better idea is that of stationary phase: by following a contour tangent to the contour of constant \(v(z) = \Im f(z_0)\) that passes through the saddle-point, we can find a contour of constant phase passing through the valley. This contour will give a clean approximation of the integral. Other contours will have larger contributions, but these will cancel due to the rapidly oscillating phase. The contour of constant phase follows the path that descends most rapidly from the saddle point (why?). For this reason, the method is also called the method of steepest descent .

Hide code cell source

x = np.linspace(-5, 10, 201)
y = np.linspace(-2, 6, 200)
t = x[:, None] + 1j*y[None, :]
z = 3 + 3j

def f(t, z=z, d=0):
    if d == 0:
        return z*np.log(t) - t
    elif d == 1:
        return z/t - 1
    elif d == 2:
        return -z/t**2

u, v = f(t).real, f(t).imag
fig, ax = plt.subplots(figsize=(4, 2))
mesh = ax.pcolormesh(x, y, u.T, vmin=f(z).real-3, vmax=f(z).real+3, cmap='coolwarm')
ax.contour(x, y, u.T, levels=f(z).real+np.linspace(-1, 1, 7), colors='k',
           linestyles='-', linewidths=1)
ax.contour(x, y, v.T, levels=f(z).imag+np.linspace(-1, 1, 5), colors='k',
           linestyles='--', linewidths=[1, 1, 2, 1, 1])
ax.plot(z.real, z.imag, 'go')
fig.colorbar(mesh)
ax.set(xlabel=r"$x=\mathrm{Re}\;t$", 
       ylabel="$y=\mathrm{Im}\;t$", 
       title=r"$\mathrm{Re}\;f(z)$", 
       aspect=1);
_images/b554888b55604cfa68a2ea7c36b3da06ee835c4caf895421a4cc50461afe50b2.png

Example: Sterling’s approximation of \(z! = \Gamma(z+1)\) for large \(\Re z\).

Recall that one can express \(z!\) as

\[\begin{gather*} z! = \int_0^{\infty} t^{z}e^{-t}\d{t} = \int_0^{\infty} e^{z\ln t -t}\d{t}. \end{gather*}\]

Taking \(f(t) = z\ln t - t\), we identify the saddle point \(t_0 = z\) where \(f'(t_0) = 0\):

\[\begin{gather*} f'(t) = \frac{z}{t} - 1,\qquad f''(t) = -\frac{z}{t^2}, \qquad f''(t_0) = -\frac{1}{z}. \end{gather*}\]

The saddle-point approximation gives Stirling’s approximation:

\[\begin{gather*} z! \approx \I z^{z} e^{-z} \sqrt{-2\pi z} = \sqrt{2\pi}\;z^{z+1/2}e^{-z}. \end{gather*}\]

Further Information#

[Needham, 2023] provides an extremely visually pleasing (but lengthy) presentation of complex analysis. Highly recommended if you want to better understand this material. 3Blue1Brown also has some nice videos discussion complex numbers that might help you visualize some of what is going on.