Source code for engineeringkit.communications.core.base
"""Result containers for engineeringkit.communications.
Most communication models return plain arrays or floats. Channel decoding
returns more than the data alone (which words were corrected, and where, or
the metric of the surviving trellis path), and a Huffman code is a table that
encodes and decodes, so these get frozen dataclasses with the usual
``summary()``.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy import special
__all__ = ["HammingDecodeResult", "HuffmanCode", "ViterbiDecodeResult"]
[docs]
@dataclass(frozen=True)
class HammingDecodeResult:
"""Output of :func:`engineeringkit.communications.hamming74_decode`."""
data: np.ndarray
"""ndarray of int: Decoded data bits, four per codeword."""
syndromes: np.ndarray
"""ndarray of int: Per-word syndrome, the 1-based position of the corrected bit (0 if none)."""
corrected: np.ndarray
"""ndarray of int: Codewords after correction."""
[docs]
def summary(self) -> dict:
"""Number of words decoded and number in which a bit was corrected."""
return {"words": int(self.syndromes.size), "corrected_words": int(np.count_nonzero(self.syndromes))}
[docs]
@dataclass(frozen=True)
class ViterbiDecodeResult:
"""Output of :func:`engineeringkit.communications.viterbi_decode`."""
data: np.ndarray
"""ndarray of int: Decoded data bits, without the zero tail."""
path_metric: float
"""float: Metric of the surviving path: the Hamming distance between the
received bits and the re-encoded decision (hard decisions), or the squared
Euclidean distance to its ``±1`` symbols (soft decisions)."""
[docs]
def summary(self) -> dict:
"""Number of decoded bits and the surviving path's metric."""
return {"bits": int(self.data.size), "path_metric": float(self.path_metric)}
[docs]
@dataclass(frozen=True)
class HuffmanCode:
"""Output of :func:`engineeringkit.communications.huffman_code`: a prefix-free code table."""
probabilities: np.ndarray
"""ndarray of float: Symbol probabilities, in the order given."""
codewords: tuple
"""tuple of str: Binary codeword of each symbol, such as ``"010"``."""
@property
def lengths(self) -> np.ndarray:
"""Codeword length of each symbol [bit]."""
return np.array([len(word) for word in self.codewords])
@property
def average_length(self) -> float:
r"""Expected codeword length :math:`\bar L = \sum p_i \ell_i` [bit per symbol]."""
return float(self.probabilities @ self.lengths)
@property
def entropy(self) -> float:
r"""Source entropy :math:`H = -\sum p_i \log_2 p_i` [bit per symbol], the lower bound on :attr:`average_length`."""
return float(-np.sum(special.xlogy(self.probabilities, self.probabilities)) / np.log(2))
[docs]
def encode(self, symbols) -> np.ndarray:
"""Concatenate the codewords of a sequence of symbol indices.
Parameters
----------
symbols : array_like of int
Indices into :attr:`probabilities`.
Returns
-------
ndarray of int
The encoded bit stream.
"""
s = np.asarray(symbols)
if s.ndim != 1 or not s.size or not np.issubdtype(s.dtype, np.integer) or np.any((s < 0) | (s >= len(self.codewords))):
raise ValueError("symbols must be a nonempty vector of valid symbol indices")
return np.array([int(bit) for index in s for bit in self.codewords[index]], dtype=np.int64)
[docs]
def decode(self, bits) -> np.ndarray:
"""Split a bit stream back into symbol indices; the prefix property makes this unambiguous.
Parameters
----------
bits : array_like
Binary stream made of whole codewords.
Returns
-------
ndarray of int
The decoded symbol indices.
"""
b = np.asarray(bits)
if b.ndim != 1 or not b.size or not np.all((b == 0) | (b == 1)):
raise ValueError("bits must be a nonempty binary vector")
table = {word: index for index, word in enumerate(self.codewords)}
symbols, word = [], ""
for bit in b.astype(int):
word += str(bit)
if word in table:
symbols.append(table[word])
word = ""
if word:
raise ValueError("bits end in the middle of a codeword")
return np.array(symbols, dtype=np.int64)
[docs]
def summary(self) -> dict:
"""Entropy, average length and efficiency ``H / L``."""
return {
"symbols": len(self.codewords),
"entropy": self.entropy,
"average_length": self.average_length,
"efficiency": self.entropy / self.average_length,
}
[docs]
def plot(self, ax=None):
r"""Bar chart of codeword lengths next to the ideal lengths :math:`-\log_2 p_i`; return ``(fig, ax)``.
Parameters
----------
ax : matplotlib.axes.Axes, optional
Axes to draw on; a new figure is created if omitted.
Returns
-------
tuple
``(fig, ax)``.
"""
import matplotlib.pyplot as plt
if ax is None:
_, ax = plt.subplots()
index = np.arange(len(self.codewords))
positive = self.probabilities > 0
ax.bar(index - 0.2, self.lengths, 0.4, label="Huffman length")
ax.bar(index[positive] + 0.2, -np.log2(self.probabilities[positive]), 0.4, label=r"$-\log_2 p$")
ax.set_xticks(index, list(self.codewords), rotation=45)
ax.set(xlabel="codeword", ylabel="length [bit]")
ax.legend()
return ax.figure, ax