Algorithm Search⌘K

Schur-Cohn test in Python

Counts the zeros of a complex polynomial in a disc without computing them, by at most n Schur transforms that each lower the degree. Exact in rational arithmetic; it reports singular steps.

File
schur_cohn.py, 143 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
schur_cohn.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/schur-cohn-test/. The authors are listed in prov/AXFQZ4.jsonl.

"""The Schur-Cohn test: count the zeros of a polynomial inside a disc, exactly.

Coefficients are given from the constant term up: coeffs[i] is the
coefficient of z**i. They may be int, Fraction, float or complex; floats
and complex floats are converted exactly to rationals, so the count is
exact for the polynomial whose coefficients are the given machine numbers.
"""

from dataclasses import dataclass, field
from fractions import Fraction
from math import gcd
from typing import Any, List, Optional, Sequence, Tuple

GQ = Tuple[Fraction, Fraction]  # a Gaussian rational (real part, imaginary part)
ZERO: GQ = (Fraction(0), Fraction(0))


@dataclass
class SchurCohnResult:
    """The number of zeros in the open disc, or None, and the work done."""

    count: Optional[int]
    reason: str  # "count" or "singular" (a zero on the circle, or a degenerate step)
    degree: int
    steps: int  # number of Schur transforms computed
    deltas: List[Fraction] = field(default_factory=list)  # delta_1, delta_2, ...

    @property
    def converged(self) -> bool:
        return self.reason == "count"


def to_gq(v: Any) -> GQ:
    """Convert int, Fraction, float, complex or a pair to an exact Gaussian rational."""
    if isinstance(v, tuple):
        return (Fraction(v[0]), Fraction(v[1]))
    if isinstance(v, complex):
        return (Fraction(v.real), Fraction(v.imag))
    return (Fraction(v), Fraction(0))


def _mul(a: GQ, b: GQ) -> GQ:
    return (a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0])


def _sub(a: GQ, b: GQ) -> GQ:
    return (a[0] - b[0], a[1] - b[1])


def _add(a: GQ, b: GQ) -> GQ:
    return (a[0] + b[0], a[1] + b[1])


def _conj(a: GQ) -> GQ:
    return (a[0], -a[1])


def _trim(p: List[GQ]) -> List[GQ]:
    while p and p[-1] == ZERO:
        p.pop()
    return p


def _primitive(p: List[GQ]) -> List[GQ]:
    """Divide by a positive rational so that the parts are coprime integers."""
    den = 1
    for c in p:
        for x in c:
            den = den * x.denominator // gcd(den, x.denominator)
    g = 0
    for c in p:
        for x in c:
            g = gcd(g, int(x * den))
    s = Fraction(den, g)
    return [(c[0] * s, c[1] * s) for c in p]


def taylor_shift_scale(coeffs: Sequence[Any], center: Any = 0, radius: Any = 1) -> List[GQ]:
    """Coefficients of q(w) = p(center + radius * w), exactly."""
    p = [to_gq(c) for c in coeffs]
    c = to_gq(center)
    r = Fraction(radius)
    n = len(p) - 1
    # Horner-style Taylor shift: after it, p holds the coefficients of p(c + w).
    for i in range(n):
        for k in range(n - 1, i - 1, -1):
            p[k] = _add(p[k], _mul(c, p[k + 1]))
    s = Fraction(1)
    for k in range(n + 1):
        p[k] = (p[k][0] * s, p[k][1] * s)
        s *= r
    return p


def schur_cohn(coeffs: Sequence[Any], center: Any = 0, radius: Any = 1) -> SchurCohnResult:
    """Count the zeros of p, with multiplicity, in the open disc |z - center| < radius."""
    if Fraction(radius) <= 0:
        raise ValueError("radius must be positive")
    p = _trim(taylor_shift_scale(coeffs, center, radius))
    if not p:  # @line L1
        raise ValueError("the zero polynomial has no finite zero count")
    n0 = len(p) - 1  # @line L2
    degrees: List[int] = []
    deltas: List[Fraction] = []
    while len(p) - 1 >= 1:  # @line L3
        n = len(p) - 1
        a0, an = p[0], p[n]
        pstar = [_conj(p[n - k]) for k in range(n + 1)]  # @line L4
        q = [_sub(_mul(_conj(a0), p[k]), _mul(an, pstar[k])) for k in range(n + 1)]  # @line L5
        delta = q[0][0]  # q(0) = |a0|^2 - |an|^2 is real
        deltas.append(delta)
        degrees.append(n)
        if delta == 0:  # @line L6
            return SchurCohnResult(None, "singular", n0, len(deltas), deltas)
        q = _trim(q)  # @line L7
        p = _primitive(q)  # the sign of q(0) and the zeros are unchanged
    k = 0  # @line L8
    for j in range(len(deltas) - 1, -1, -1):  # @line L9
        if deltas[j] < 0:
            k = degrees[j] - k
    return SchurCohnResult(k, "count", n0, len(deltas), deltas)  # @line L10


def is_schur_stable(coeffs: Sequence[Any]) -> bool:
    """True when every zero lies in the open unit disc (discrete-time stability)."""
    r = schur_cohn(coeffs)
    return r.converged and r.count == r.degree


def poly_from_roots(roots: Sequence[Any]) -> List[GQ]:
    """Exact coefficients, constant term first, of the monic polynomial with these roots."""
    p: List[GQ] = [(Fraction(1), Fraction(0))]
    for r in roots:
        z = to_gq(r)
        q = [ZERO] * (len(p) + 1)
        for i, c in enumerate(p):
            q[i + 1] = _add(q[i + 1], c)
            q[i] = _sub(q[i], _mul(z, c))
        p = q
    return p
schur_cohn_test.py 15 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/schur-cohn-test/. The authors are listed in prov/AXFQZ4.jsonl.

"""The Schur-Cohn test. The code is in schur_cohn.py; this module re-exports it
under the name of the page (a file name ending in _test is read as a test file
by some tools, so the implementation itself lives in schur_cohn.py)."""

from schur_cohn import (  # noqa: F401
    SchurCohnResult,
    is_schur_stable,
    poly_from_roots,
    schur_cohn,
    taylor_shift_scale,
    to_gq,
)
test_schur_cohn_test.py 108 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/schur-cohn-test/. The authors are listed in prov/AXFQZ4.jsonl.

import random
from fractions import Fraction

import pytest

from schur_cohn_test import is_schur_stable, poly_from_roots, schur_cohn


def test_page_example_trace():
    # 6z^3 - 19z^2 + 2z + 3 = (2z - 1)(3z + 1)(z - 3)
    r = schur_cohn([3, 2, -19, 6])
    assert r.converged and r.count == 2
    assert r.steps == 3 == r.degree
    assert r.deltas == [-27, -448, -9]


def test_constant_and_linear():
    assert schur_cohn([5]).count == 0 and schur_cohn([5]).steps == 0
    assert schur_cohn([Fraction(-1, 2), 1]).count == 1  # z - 1/2
    assert schur_cohn([-2, 1]).count == 0  # z - 2


def test_zero_polynomial_and_bad_radius():
    with pytest.raises(ValueError):
        schur_cohn([0, 0])
    with pytest.raises(ValueError):
        schur_cohn([1, 1], radius=0)


def test_leading_zeros_trimmed():
    assert schur_cohn([Fraction(-1, 2), 1, 0, 0]).count == 1


def test_zeros_at_origin():
    r = schur_cohn([0, 0, 0, 1])  # z^3
    assert r.count == 3 and r.steps == 1
    assert schur_cohn(poly_from_roots([0, 0, 3])).count == 2


def test_zero_on_circle_is_singular():
    for roots in ([1], [-1, Fraction(1, 2)], [1j, 3], [(Fraction(3, 5), Fraction(4, 5))]):
        r = schur_cohn(poly_from_roots(roots))
        assert r.reason == "singular" and r.count is None and not r.converged


def test_symmetric_pair_and_accidental_singular():
    assert schur_cohn(poly_from_roots([Fraction(1, 2), 2])).reason == "singular"
    # z^2 + z + i: no zero on the circle, but |a0| = |a2|
    assert schur_cohn([1j, 1, 1]).reason == "singular"


def test_other_discs():
    p = [3, 2, -19, 6]  # zeros 1/2, -1/3, 3
    assert schur_cohn(p, center=3, radius=Fraction(1, 10)).count == 1
    assert schur_cohn(p, center=0, radius=4).count == 3
    assert schur_cohn(p, center=Fraction(1, 2), radius=Fraction(1, 2)).count == 1
    assert schur_cohn(p, center=10, radius=1).count == 0


def test_complex_and_float_coefficients():
    roots = [0.25 + 0.5j, -0.75j, 1.5 - 0.5j]
    assert schur_cohn(poly_from_roots(roots)).count == 2
    # z^2 - z + 0.5 has zeros (1 +- i)/2, inside: discrete-time stable
    assert is_schur_stable([0.5, -1, 1])
    assert not is_schur_stable([1, -1, 0.5])  # zeros 1 +- i


def test_reciprocal_count():
    # p and its reversal: zeros z and 1/z, so counts add up to n
    p = [3, 2, -19, 6]
    assert schur_cohn(p).count + schur_cohn(p[::-1]).count == 3


def _random_root(rng):
    while True:
        re = Fraction(rng.randint(-40, 40), 16)
        im = Fraction(rng.randint(-40, 40), 16) if rng.random() < 0.6 else Fraction(0)
        m2 = re * re + im * im
        if abs(m2 - 1) > Fraction(1, 20):
            return (re, im), m2 < 1


def test_random_known_roots():
    rng = random.Random(20261108)
    regular = 0
    for _ in range(600):
        n = rng.randint(1, 8)
        roots, flags = [], []
        for _ in range(n):
            if roots and rng.random() < 0.15:
                z, ins = roots[-1], flags[-1]  # a repeated root
            else:
                z, ins = _random_root(rng)
            roots.append(z)
            flags.append(ins)
        lead = Fraction(rng.randint(1, 9), rng.randint(1, 4))
        p = [(c[0] * lead, c[1] * lead) for c in poly_from_roots(roots)]
        r = schur_cohn(p)
        assert r.steps <= n
        if r.converged:
            regular += 1
            assert r.count == sum(flags)
        else:
            assert r.reason == "singular"
    assert regular >= 450

What the next level needs

  • the tests did not run in this build

Read the full page about Schur-Cohn test