codex/moving-average-signal-delay.py

moving-average-signal-delay.py
"""Exact steady sinusoidal inputs demonstrate cancellation and causal delay."""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def main():
    plt.rcParams.update({"font.size": 11, "figure.facecolor": "white"})
    m = 5
    n = np.arange(0, 101)
    f_slow, f_fast = .025, .2
    slow = lambda indices: np.sin(2 * np.pi * f_slow * indices)
    fast = lambda indices: .45 * np.sin(2 * np.pi * f_fast * indices)
    signal = lambda indices: slow(indices) + fast(indices)
    output = np.mean([signal(n - j) for j in range(m)], axis=0)
    h = np.ones(m) / m
    full_input = signal(np.arange(-(m - 1), n[-1] + 1))
    assert np.allclose(output, np.convolve(full_input, h, mode="valid"))
    gain = np.sin(np.pi * m * f_slow) / (m * np.sin(np.pi * f_slow))
    delay = (m - 1) / 2
    predicted = gain * slow(n - delay)
    assert np.allclose(output, predicted, atol=2e-14)
    assert np.allclose(np.mean([fast(n - j) for j in range(m)], axis=0), 0, atol=2e-14)
    fig, axes = plt.subplots(3, 1, figsize=(11.2, 7), layout="constrained", sharex=True)
    axes[0].plot(n, slow(n), color="#167e8d", lw=2, label="Slow component: f = 0.025")
    axes[0].plot(n, fast(n), color="#c26030", lw=1.5, label="Fast component: f = 0.2")
    axes[0].set_title("Two known input components; frequencies are cycles per sample")
    axes[1].plot(n, signal(n), color="#8996a1", lw=1.3, label="Input: sum of the components")
    axes[1].plot(n, output, color="#167e8d", lw=2.4, label="Five-sample trailing mean")
    axes[1].set_title("The fast component cancels; the slow component survives with delay")
    axes[2].plot(n, slow(n), "--", color="#697a89", lw=1.7, label="Original slow component")
    axes[2].plot(n, predicted, color="#167e8d", lw=2.4,
                 label=f"Filtered: gain {gain:.4f}, delay {delay:g} samples")
    axes[2].annotate("", xy=(12, gain), xytext=(10, gain),
                     arrowprops={"arrowstyle": "<->", "color": "#c26030", "lw": 1.8})
    axes[2].text(14, .8, "Peak shifts by 2 samples", color="#a74d25", fontsize=10)
    for ax in axes:
        ax.set(ylabel="Amplitude", ylim=(-1.6, 1.6))
        ax.grid(alpha=.18)
        ax.spines[["top", "right"]].set_visible(False)
        ax.legend(loc="lower left", fontsize=9, ncol=2)
    axes[-1].set(xlabel="Sample index n", xlim=(0, 100))
    fig.suptitle("A moving average changes timing as well as amplitude", fontsize=17)
    fig.get_layout_engine().set(rect=(0, .04, 1, .96))
    fig.text(.5, .015, "Input sinusoids are defined for all integer indices; early outputs use their specified past.",
             ha="center", fontsize=10)
    fig.savefig(Path(__file__).with_suffix(".png").name, dpi=100, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()