Skip to content

Steering a beam with phase

Fixed antennas steer a beam by phase alone. The film shows the live wave field, then the beam in 3D.

examples/phased_array.py
"""Steering a beam with phase alone: a phased array.

Eight identical antennas that never move send out the same wave, each one a constant phase step
Δφ behind its neighbour. Their waves add up to a beam whose direction obeys sin θ = Δφ λ/(2π d),
the principle of every phased-array radar and 5G base station. The field is summed live from the
eight sources; the beam angle is measured from the array factor |Σ e^{ij(Δφ − kd sin θ)}| and set
beside the law. At twice the frequency (d = λ) a second, grating lobe appears. An 8×8 array steers
a 3D pencil beam the same way, with a phase step along each side.
"""

import numpy as np

import manimgx as m

N = 8  # antennas per row
WAVELENGTH = 0.6
PITCH = WAVELENGTH / 2  # d, fixed: the antennas never move
FREQUENCY = 1.1  # wave cycles per second
RADIUS = 5.0  # of the half-disc of field above the array
PIXELS = (800, 400)
ROW_Y = -PITCH / 2  # the field is drawn in the plane of one row of the planar array
LOBE = 3.6  # length of the 3D beam at full gain
FIELD = [
    (-1.0, "#c8f4ff"),
    (-0.55, "#2c93d8"),
    (-0.18, "#0a2240"),
    (0.0, "#04060b"),
    (0.18, "#3d1f06"),
    (0.55, "#e38a2b"),
    (1.0, "#fff2cc"),
]
GOLD = "#f0c05a"
GAIN = ["#0d2b52", "#1768ac", "#2fb5c9", "#f2d479", "#fff7e0"]


def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
    """(n, 4) RGBA rows for values in [0, 1], interpolated through color stops."""
    rgb = np.array([m.ManimColor(s).to_rgb() for s in stops])
    x = np.clip(values, 0, 1) * (len(stops) - 1)
    i = np.minimum(x.astype(int), len(stops) - 2)
    f = (x - i)[:, None]
    out = np.ones((len(values), 4))
    out[:, :3] = rgb[i] * (1 - f) + rgb[i + 1] * f
    return out


def field_palette() -> np.ndarray:
    """A 256-entry RGBA lookup table for field values in [−1, 1]."""
    at = np.array([v for v, _ in FIELD])
    rgb = np.array([m.ManimColor(c).to_rgb() for _, c in FIELD])
    x = np.linspace(-1, 1, 256)
    table = np.stack([np.interp(x, at, rgb[:, c]) for c in range(3)], axis=1)
    return np.column_stack([table * 255, np.full(256, 255)]).astype(np.uint8)


def grid_mesh(points: np.ndarray, fill_color: str) -> m.MeshMobject:
    """A smooth lit mesh over an (nu+1, nv+1, 3) grid of points computed with numpy (vertex
    i·(nv + 1) + j), each cell two triangles."""
    nu, nv = points.shape[0] - 1, points.shape[1] - 1
    idx = np.arange((nu + 1) * (nv + 1)).reshape(nu + 1, nv + 1)
    a, b, c, d = idx[:-1, :-1], idx[1:, :-1], idx[1:, 1:], idx[:-1, 1:]
    cells = np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], 2)
    return m.MeshMobject(
        points.reshape(-1, 3),
        cells.reshape(-1, 3),
        shade_in_3d=True,
        fill_color=fill_color,
    )


def array_factor(u: np.ndarray, step: float, kd: float) -> np.ndarray:
    """|Σ_j e^{ij(step − kd u)}| / N for direction cosines u along a row of N antennas, summed
    in closed form: |sin(Nψ/2) / (N sin(ψ/2))| with ψ = step − kd u."""
    half = (step - kd * u) / 2
    below = np.sin(half)
    tiny = np.abs(below) < 1e-9
    return np.abs(
        np.where(tiny, 1.0, np.sin(N * half) / (N * np.where(tiny, 1.0, below)))
    )


SCAN = np.radians(np.linspace(-90, 90, 3601))
_found: dict[tuple[float, float], list[float]] = {}


def lobes(step: float, kd: float) -> list[float]:
    """Measured beam directions (degrees from broadside): the peaks of the array factor."""
    if (step, kd) not in _found:
        a = array_factor(np.sin(SCAN), step, kd)
        peak = (a > 0.8) & (a >= np.roll(a, 1)) & (a >= np.roll(a, -1))
        peak[[0, -1]] = a[[0, -1]] > 0.8
        _found.clear()
        _found[step, kd] = [float(np.degrees(t)) for t in SCAN[peak]]
    return _found[step, kd]


class Array:
    """The live state: phase step(s), frequency, time; the field over the half-disc."""

    def __init__(self) -> None:
        self.time = 0.0
        self.step = m.ValueTracker(0.0)  # Δφ along x (radians)
        self.step_y = m.ValueTracker(0.0)  # Δφ along y (the planar array)
        self.octave = m.ValueTracker(1.0)  # frequency multiplier
        w, h = PIXELS
        xs = np.linspace(-RADIUS, RADIUS, w)
        zs = np.linspace(RADIUS, 0, h)
        self.x, self.z = np.meshgrid(xs, zs)
        rim = np.hypot(self.x, self.z)
        edge = np.clip((RADIUS - rim) / (0.12 * RADIUS), 0, 1)
        self.edge = (edge**1.5).ravel()
        self.compensate = (np.sqrt(rim + 0.6) / N).astype(np.float32).ravel()
        self.sources = (np.arange(N) - (N - 1) / 2) * PITCH
        # each source's distance to every pixel, and its 1/√r fall-off
        r = np.hypot(self.x[None] - self.sources[:, None, None], self.z[None])
        self.r = r.reshape(N, -1).astype(np.float32)
        self.falloff = (1 / np.sqrt(self.r + 0.03)).astype(np.float32)
        self.palette = (
            field_palette().view(np.uint32).ravel()
        )  # one RGBA word per entry
        self.basis_octave = -1.0
        self.basis = np.zeros((N, w * h), np.complex64)

    @property
    def k(self) -> float:
        return 2 * np.pi * self.octave.get_value() / WAVELENGTH

    def phases(self) -> np.ndarray:
        """Each antenna's drive phase now: j Δφ − ωt."""
        omega = 2 * np.pi * FREQUENCY * self.octave.get_value()
        return np.arange(N) * self.step.get_value() - omega * self.time

    def picture(self, shown: float = 1.0) -> np.ndarray:
        """Re Σ_j e^{i(k r_j + jΔφ − ωt)} / √r_j over the half-disc, as RGBA pixels."""
        if self.basis_octave != self.octave.get_value():
            kr = np.float32(self.k) * self.r
            self.basis = (self.falloff * np.cos(kr)) + 1j * (self.falloff * np.sin(kr))
            self.basis = self.basis.astype(np.complex64)
            self.basis_octave = self.octave.get_value()
        drive = np.exp(1j * self.phases()).astype(np.complex64)
        value = (drive @ self.basis).real * self.compensate
        index = np.clip((value + 1) * 127.5, 0, 255).astype(np.uint8)
        rgba = self.palette[index].view(np.uint8).reshape(*self.x.shape, 4)
        rgba[..., 3] = (255 * shown * self.edge).astype(np.uint8).reshape(self.x.shape)
        return rgba


class PhasedArray(m.ThreeDScene):
    def construct(self) -> None:
        self.set_camera_orientation(
            phi=90 * m.DEGREES,
            theta=-90 * m.DEGREES,
            zoom=0.92,
            frame_center=np.array([1.74, 0, 2.95]),
        )
        state = Array()
        clock = m.ValueTracker(0.0)

        def tick(_: m.Mobject, dt: float) -> None:
            state.time += dt

        clock.add_updater(tick)
        shown = m.ValueTracker(1.0)  # the field's visibility

        # ── the field, a textured half-disc standing in the plane of the array ──────────────
        field = m.ImageMobject(state.picture())
        field.scale_to_fit_width(2 * RADIUS)
        field.rotate(90 * m.DEGREES, axis=m.RIGHT)
        field.move_to(np.array([0, ROW_Y, RADIUS / 2]))

        def paint_field(mob: m.Mobject) -> None:
            mob.paint = mob.paint.but(texture=state.picture(shown.get_value()))

        field.add_updater(paint_field)

        # ── the antennas, pulsing with their own phase, and a phasor dial under each ────────
        xs = state.sources
        antennas = m.VGroup(
            *[m.Dot3D(np.array([x, ROW_Y, 0]), radius=0.07) for x in xs]
        )

        antennas_on = m.ValueTracker(1.0)

        def pulse(group: m.Mobject) -> None:
            glow = (1 + np.cos(state.phases())) / 2
            dim, bright = m.ManimColor("#6b4a1f"), m.ManimColor("#ffe7a8")
            for dot, g in zip(group.submobjects, glow, strict=True):
                dot.set_color(m.interpolate_color(dim, bright, g))
                dot.set_opacity(antennas_on.get_value())

        antennas.add_updater(pulse)
        dials_on = m.ValueTracker(1.0)

        def dials() -> m.VGroup:
            group = m.VGroup()
            for x, phase in zip(xs, state.phases(), strict=True):
                center = np.array([x, ROW_Y, -0.45])
                hand = 0.12 * np.array([np.cos(phase), 0, np.sin(phase)])
                group.add(
                    m.Circle(radius=0.13, stroke_width=1.5, color=m.GREY_B)
                    .rotate(90 * m.DEGREES, axis=m.RIGHT)
                    .move_to(center),
                    m.Line(center, center + hand, stroke_width=2.5, color="#ffe7a8"),
                )
            return group.set_stroke(opacity=dials_on.get_value())

        dial_group = m.always_redraw(dials)

        # ── heads-up display ─────────────────────────────────────────────────────────────────
        title = m.Text("Steering a beam with phase alone", font_size=36).to_corner(m.UL)
        subtitle = m.Text(
            "eight fixed antennas, each a phase step Δφ behind the last", font_size=22
        ).set_color(m.GREY_B)
        subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)
        law = m.MathTex(
            r"\sin\theta = \frac{\Delta\phi\,\lambda}{2\pi d}", font_size=38
        )
        law.to_corner(m.UR).shift(0.15 * m.LEFT)

        # polar plot of the array factor: angle from broadside, radius = |AF|
        hub = np.array([5.05, 0.35, 0])
        size = 1.55
        polar = m.VGroup(
            m.Arc(radius=size, start_angle=0, angle=np.pi, arc_center=hub),
            m.Arc(radius=size / 2, start_angle=0, angle=np.pi, arc_center=hub),
            m.Line(hub - size * m.RIGHT, hub + size * m.RIGHT),
            *[
                m.Line(hub, hub + size * np.array([np.sin(a), np.cos(a), 0]))
                for a in np.radians([-60, -30, 0, 30, 60])
            ],
        ).set_stroke(m.GREY_D, 1.2)
        ticks = m.VGroup(
            *[
                m.MathTex(rf"{a}^\circ", font_size=20)
                .set_color(m.GREY_B)
                .move_to(
                    hub
                    + (size + 0.25)
                    * np.array([np.sin(np.radians(a)), np.cos(np.radians(a)), 0])
                )
                for a in (-60, 0, 60)
            ]
        )
        angles = np.radians(np.linspace(-90, 90, 721))

        def pattern() -> m.VGroup:
            kd = state.k * PITCH
            a = array_factor(np.sin(angles), state.step.get_value(), kd)
            curve = m.VMobject(stroke_color=GOLD, stroke_width=2.5)
            curve.set_points_as_corners(
                hub
                + size
                * a[:, None]
                * np.stack([np.sin(angles), np.cos(angles), 0 * angles], 1)
            )
            beams = m.VGroup(
                *[
                    m.Line(
                        hub,
                        hub
                        + size
                        * np.array([np.sin(np.radians(b)), np.cos(np.radians(b)), 0]),
                        stroke_width=1.5,
                        color=m.WHITE,
                    )
                    for b in lobes(state.step.get_value(), kd)
                ]
            )
            return m.VGroup(curve, beams)

        pattern_on = m.ValueTracker(0.0)
        polar_curve = m.always_redraw(
            lambda: pattern().set_stroke(opacity=pattern_on.get_value())
        )

        def readout(tex: str, *values: m.Mobject) -> m.VGroup:
            return m.VGroup(m.MathTex(tex, font_size=30), *values).arrange(
                m.RIGHT, buff=0.15
            )

        def number(
            places: int, unit: str | None = None, color: str = "#ffffff"
        ) -> m.DecimalNumber:
            return m.DecimalNumber(
                0, num_decimal_places=places, font_size=30, unit=unit, color=color
            )

        step_value = number(2, r"\pi")
        step_value.add_updater(lambda d: d.set_value(state.step.get_value() / np.pi))
        spacing_value = number(2)
        spacing_value.add_updater(lambda d: d.set_value(PITCH * state.k / (2 * np.pi)))
        # the measured beams: the main one (nearest the law's) and a grating lobe, if any
        main_value = number(1, r"^\circ", color=GOLD)
        second_value = number(1, r"^\circ", color=GOLD)
        comma = m.MathTex(",", font_size=30, color=GOLD)
        law_value = number(1, r"^\circ")

        def principal() -> float:
            s = state.step.get_value() / (state.k * PITCH)
            return float(np.degrees(np.arcsin(np.clip(s, -1, 1))))

        def measure_beams(_: m.Mobject) -> None:
            found = sorted(
                lobes(state.step.get_value(), state.k * PITCH),
                key=lambda b: abs(b - principal()),
            )
            main_value.set_value(found[0])
            second_value.set_value(found[1] if len(found) > 1 else 0)
            second_value.set_opacity(1 if len(found) > 1 else 0)
            comma.set_opacity(1 if len(found) > 1 else 0)
            law_value.set_value(principal())

        law_value.add_updater(measure_beams)
        rows = m.VGroup(
            readout(r"\Delta\phi =", step_value),
            readout(r"d/\lambda =", spacing_value),
            readout(r"\theta_{\text{beam}} =", main_value, comma, second_value),
            readout(r"\arcsin\tfrac{\Delta\phi\,\lambda}{2\pi d} =", law_value),
        ).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
        rows[2][2].shift(0.1 * m.LEFT + 0.08 * m.DOWN)
        rows.next_to(hub + size * m.DOWN, m.DOWN, buff=0.15).align_to(
            hub + (size + 0.25) * m.LEFT, m.LEFT
        )
        hud_1d = m.VGroup(polar, ticks, rows)

        # ── the planar array: 8×8 patches, each lit by its phase ─────────────────────────────
        grid = (np.arange(N) - (N - 1) / 2) * PITCH
        gx, gy = np.meshgrid(grid, grid, indexing="ij")
        centers = np.column_stack([gx.ravel(), gy.ravel(), np.full(N * N, 0.01)])
        half = 0.1
        corners = np.array(
            [[-half, -half, 0], [half, -half, 0], [half, half, 0], [-half, half, 0]]
        )
        verts = (centers[:, None, :] + corners[None]).reshape(-1, 3)
        base = 4 * np.arange(N * N)[:, None]
        tris = np.concatenate([base + [0, 1, 2], base + [0, 2, 3]])
        patches = m.MeshMobject(verts, tris, fill_color="#ffe7a8")
        board = m.Prism(
            dimensions=[N * PITCH + 0.3, N * PITCH + 0.3, 0.06], fill_color="#1a2230"
        )
        board.move_to(np.array([0, 0, -0.035]))
        planar_on = m.ValueTracker(0.0)

        def light_patches(mob: m.Mobject) -> None:
            omega = 2 * np.pi * FREQUENCY * state.octave.get_value()
            phase = (
                gx.ravel() / PITCH * state.step.get_value()
                + gy.ravel() / PITCH * state.step_y.get_value()
                - omega * state.time
            )
            rows_ = colormap((1 + np.cos(phase)) / 2, ["#3a2a12", "#ffe7a8"])
            rows_[:, 3] = planar_on.get_value()
            mob.paint = mob.paint.but(fill=np.repeat(rows_, 4, axis=0))

        patches.add_updater(light_patches)

        # ── the 3D beam: |AF_x · AF_y| over the upper hemisphere, as a lit surface ──────────
        th = np.linspace(0, np.pi / 2, 121)
        ph = np.linspace(0, 2 * np.pi, 181)
        tt, pp = np.meshgrid(th, ph, indexing="ij")
        dirs = np.stack(
            [np.sin(tt) * np.cos(pp), np.sin(tt) * np.sin(pp), np.cos(tt)], -1
        )
        grow = m.ValueTracker(0.0)

        def gain(d: np.ndarray) -> np.ndarray:
            kd = state.k * PITCH
            return array_factor(d[..., 0], state.step.get_value(), kd) * array_factor(
                d[..., 1], state.step_y.get_value(), kd
            )

        lobe = grid_mesh(dirs * 0.01, fill_color=GAIN[2])

        def shape_lobe(mob: m.Mobject) -> None:
            g = gain(dirs)
            mob.points = (
                LOBE * grow.get_value() * g[..., None] * dirs + 1e-3 * dirs
            ).reshape(-1, 3)
            mob.paint = mob.paint.but(fill=colormap(g.ravel(), GAIN))

        lobe.add_updater(shape_lobe)

        beam = np.zeros(
            2
        )  # the measured beam direction (θ, φ in degrees), once a frame

        def peak(_: m.Mobject) -> None:
            """The maximum of |AF| over the hemisphere, refined on a fine patch around it."""
            g = gain(dirs)
            i, j = np.unravel_index(int(np.argmax(g)), g.shape)
            fine_t = np.linspace(th[max(i - 1, 0)], th[min(i + 1, len(th) - 1)], 41)
            fine_p = np.linspace(ph[j] - 0.05, ph[j] + 0.05, 41)
            ft, fp = np.meshgrid(fine_t, fine_p, indexing="ij")
            fd = np.stack(
                [np.sin(ft) * np.cos(fp), np.sin(ft) * np.sin(fp), np.cos(ft)], -1
            )
            k = np.unravel_index(int(np.argmax(gain(fd))), ft.shape)
            beam[:] = np.degrees(ft[k]), np.degrees(fp[k]) % 360

        axis_line = m.Line3D(np.zeros(3), m.OUT, thickness=0.012, color=m.WHITE)

        def point_axis(mob: m.Mobject) -> None:
            t, p = np.radians(beam)
            d = np.array([np.sin(t) * np.cos(p), np.sin(t) * np.sin(p), np.cos(t)])
            mob.become(
                m.Line3D(
                    np.zeros(3),
                    (LOBE + 0.6) * grow.get_value() * d + 1e-3 * d,
                    thickness=0.012,
                    color=m.WHITE,
                )
            )

        axis_line.add_updater(peak)
        axis_line.add_updater(point_axis)

        beam_t, beam_p = (
            number(1, r"^\circ", color=GOLD),
            number(0, r"^\circ", color=GOLD),
        )
        law_t, law_p = number(1, r"^\circ"), number(0, r"^\circ")

        def planar_law(_: m.Mobject) -> None:
            kd = state.k * PITCH
            u, v = state.step.get_value() / kd, state.step_y.get_value() / kd
            beam_t.set_value(beam[0])
            beam_p.set_value(beam[1])
            law_t.set_value(np.degrees(np.arcsin(min(1.0, float(np.hypot(u, v))))))
            law_p.set_value(np.degrees(np.arctan2(v, u)) % 360)

        law_p.add_updater(planar_law)

        def pair(name: str, t: m.Mobject, p: m.Mobject, color: str) -> m.VGroup:
            row = m.VGroup(
                m.Text(name, font_size=24).set_color(color),
                m.MathTex(r"\theta =", font_size=30),
                t,
                m.MathTex(r"\varphi =", font_size=30),
                p,
            ).arrange(m.RIGHT, buff=0.15)
            row[3:].shift(0.25 * m.RIGHT)
            return row

        hud_2d = m.VGroup(
            pair("beam", beam_t, beam_p, GOLD), pair("law", law_t, law_p, "#ffffff")
        ).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
        hud_2d.to_corner(m.DR).shift(0.25 * m.UP)
        law_2d = (
            m.MathTex(
                r"\sin\theta\,(\cos\varphi,\ \sin\varphi) = \frac{\lambda}{2\pi"
                r" d}\,(\Delta\phi_x,\ \Delta\phi_y)",
                font_size=28,
            )
            .to_corner(m.UR)
            .shift(0.15 * m.LEFT + 0.1 * m.DOWN)
        )

        self.add(clock, field, antennas, dial_group)
        self.add_fixed_in_frame_mobjects(title, subtitle, law, hud_1d, polar_curve)
        self.remove(title, subtitle, law, hud_1d)

        # 0–3 s: in step, the waves add up straight ahead
        self.play(m.Write(title), m.FadeIn(subtitle), run_time=1.5)
        self.play(
            m.FadeIn(law),
            m.FadeIn(hud_1d),
            pattern_on.animate.set_value(1),
            run_time=1.2,
        )
        self.wait(0.4)
        # 3–11 s: a phase step between neighbours tilts the wavefronts and the beam
        self.play(state.step.animate.set_value(0.5 * np.pi), run_time=2.2)
        self.wait(0.5)
        self.play(state.step.animate.set_value(-0.7 * np.pi), run_time=3.0)
        self.wait(0.4)
        self.play(state.step.animate.set_value(0.25 * np.pi), run_time=1.5)
        # 11–15 s: the same antennas at twice the frequency: d = λ, and a second beam appears
        note = m.Text(
            "same antennas, twice the frequency: d = λ, and a second beam", font_size=22
        ).set_color(m.GREY_B)
        note.next_to(subtitle, m.DOWN, aligned_edge=m.LEFT, buff=0.2)
        self.add_fixed_in_frame_mobjects(note)
        self.remove(note)
        self.play(
            state.octave.animate.set_value(2.0),
            m.FadeIn(note, shift=0.1 * m.DOWN),
            run_time=2.5,
        )
        self.wait(1.5)
        self.play(state.octave.animate.set_value(1.0), m.FadeOut(note), run_time=1.5)

        # 16–20 s: the full 8×8 panel steers a pencil beam in 3D
        def first(
            t: float,
        ) -> float:  # the old readouts leave in the first 40% of the move
            return m.smooth(min(1.0, t / 0.4))

        def last(t: float) -> float:  # the new ones arrive in the last half
            return m.smooth(max(0.0, 2 * t - 1))

        self.add(board, patches, lobe, axis_line)
        peak(
            axis_line
        )  # the readouts hold still while they fade in: give them their values
        planar_law(law_p)
        self.add_fixed_in_frame_mobjects(law_2d, hud_2d)
        self.remove(law_2d, hud_2d)
        self.move_camera(
            phi=62 * m.DEGREES,
            theta=-58 * m.DEGREES,
            zoom=1.2,
            frame_center=np.array([0.2, 0, 1.95]),
            run_time=3.5,
            added_anims=[
                planar_on.animate.set_value(1),
                dials_on.animate.set_value(0),
                antennas_on.animate(rate_func=first).set_value(0),
                grow.animate.set_value(1),
                shown.animate.set_value(0),
                m.FadeOut(hud_1d, rate_func=first),
                pattern_on.animate(rate_func=first).set_value(0),
                m.FadeOut(law, rate_func=first),
                m.FadeIn(law_2d, rate_func=last),
                m.FadeIn(hud_2d, rate_func=last),
            ],
        )
        # 20–28 s: a conical scan: the step turns around the panel, the beam sweeps a cone
        self.begin_ambient_camera_rotation(rate=0.1)
        sweep = m.ValueTracker(0.0)
        tilt = 0.55 * np.pi

        def steer(_: m.Mobject) -> None:
            a = sweep.get_value()
            state.step.set_value(tilt * np.cos(a) * min(1.0, a / 0.6 + 0.45))
            state.step_y.set_value(tilt * np.sin(a) * min(1.0, a / 0.6 + 0.45))

        sweep.add_updater(steer)
        self.add(sweep)
        self.play(
            sweep.animate.set_value(2 * np.pi),
            run_time=7.0,
            rate_func=m.linear,
        )
        closing = m.Text("No moving parts: the phase steers the beam.", font_size=28)
        closing.to_edge(m.DOWN, buff=0.35).to_edge(m.LEFT, buff=0.5)
        self.add_fixed_in_frame_mobjects(closing)
        self.remove(closing)
        self.play(
            m.FadeIn(closing, shift=0.2 * m.UP),
            sweep.animate.set_value(2.6 * np.pi),
            run_time=2.0,
            rate_func=m.linear,
        )
        self.wait(1.0)


if __name__ == "__main__":
    PhasedArray().render("phased_array.mp4")