Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Docs: README rewritten with a banner, runnable examples that show their real
outputs, a "Why trust the numbers" validation table, a guide to choosing a
measure, and an "Honest limits" section. The quickstart examples in the README
and on the docs site crashed (`window=20` on an 8-point series); they now run,
and `tests/test_readme.py` executes every example and checks the numbers it
claims.

- CI: removed the tag-triggered PyPI publish workflow (`publish.yml`). Releases
are uploaded to PyPI manually; see "Releasing" in `CONTRIBUTING.md`.

Expand Down
326 changes: 185 additions & 141 deletions README.md

Large diffs are not rendered by default.

Binary file added docs/assets/banner-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/banner-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
104 changes: 104 additions & 0 deletions docs/assets/make_banner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Regenerate the README banner (banner-light.png and banner-dark.png next to this file).

python docs/assets/make_banner.py

Adapted from the original banner design in assets/make_banner.py. The curve is
entroscope's own rolling spectral entropy of a signal that starts as noise and
settles into a clean oscillation, so the banner shows the library doing its job.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")

import matplotlib.font_manager as fm
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt

from entroscope import spectral

OUT = Path(__file__).resolve().parent

THEMES = {
"light": {
"bg": "#ffffff", "ink": "#1b1f24", "sub": "#3a4149", "mute": "#6b7280",
"line": "#2a78d6", "grid": "#e7e9ec", "rule": "#d7dbe0",
},
"dark": {
"bg": "#0d1117", "ink": "#e6edf3", "sub": "#c9d1d9", "mute": "#8b949e",
"line": "#4493f8", "grid": "#21262d", "rule": "#30363d",
},
} # fmt: skip

MEASURES = (
"shannon · permutation · sample · approximate · spectral · "
"differential · multiscale · transfer · divergence"
)


def _pick(names, default="DejaVu Sans"):
available = {f.name for f in fm.fontManager.ttflist}
return next((name for name in names if name in available), default)


SANS = _pick(["Helvetica Neue", "Helvetica", "Arial"])
MONO = _pick(["SF Mono", "Menlo", "DejaVu Sans Mono"], default="DejaVu Sans Mono")


def make_signal(n=600, seed=7):
"""Pure noise that ramps into a clean sine over the middle of the series."""
rng = np.random.default_rng(seed)
t = np.linspace(0, 24 * np.pi, n)
emergence = np.clip((np.arange(n) - n * 0.30) / (n * 0.40), 0, 1)
return pd.Series((1 - emergence) * 1.6 * rng.standard_normal(n) + emergence * 2.2 * np.sin(t))


def draw(entropy, c):
fig = plt.figure(figsize=(12.8, 4.6), dpi=100)
fig.patch.set_facecolor(c["bg"])

fig.text(0.05, 0.84, "entroscope", color=c["ink"], fontsize=44, fontweight="bold",
fontfamily=SANS, va="center") # fmt: skip
fig.text(0.05, 0.69, "the definitive entropy toolkit for time series data",
color=c["sub"], fontsize=15, fontfamily=SANS, va="center") # fmt: skip
fig.text(0.05, 0.605, MEASURES, color=c["mute"], fontsize=10.5, fontfamily=SANS,
va="center") # fmt: skip
fig.text(0.95, 0.84, "pip install entroscope", color=c["ink"], fontsize=13,
fontfamily=MONO, ha="right", va="center") # fmt: skip
fig.add_artist(plt.Line2D([0.05, 0.95], [0.53, 0.53], transform=fig.transFigure,
color=c["rule"], lw=1.0)) # fmt: skip

ax = fig.add_axes([0.05, 0.08, 0.90, 0.38])
ax.set_facecolor(c["bg"])
ax.grid(axis="y", color=c["grid"], lw=1.0)
ax.set_axisbelow(True)
ax.plot(entropy.index, entropy.to_numpy(), color=c["line"], lw=1.8,
solid_capstyle="round") # fmt: skip
for side, spine in ax.spines.items():
spine.set_visible(side == "bottom")
ax.spines["bottom"].set_color(c["rule"])
ax.set_xticks([])
ax.set_yticks([])
ax.margins(x=0.0)
ax.set_ylim(bottom=-0.06 * float(entropy.max())) # keep the flat tail off the baseline
ax.text(0.99, 0.95, "rolling spectral entropy falls as noise turns into a rhythm",
transform=ax.transAxes, color=c["mute"], fontsize=9.5, fontfamily=SANS,
ha="right", va="top") # fmt: skip
return fig


def main():
entropy = spectral.rolling(make_signal(), window=100) # two full cycles
for name, colors in THEMES.items():
fig = draw(entropy, colors)
path = OUT / f"banner-{name}.png"
fig.savefig(path, dpi=100, facecolor=colors["bg"])
plt.close(fig)
print("wrote", path.relative_to(OUT.parent.parent))


if __name__ == "__main__":
main()
48 changes: 30 additions & 18 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,46 @@ pip install entroscope

## Compute entropy

A series that is pure noise for 200 steps, then turns into a clean 20-step cycle:

```python
import numpy as np
import pandas as pd
from entroscope import shannon, permutation, spectral

s = pd.Series([10, 20, 15, 80, 90, 85, 88, 92])

shannon.compute(s) # single value
shannon.rolling(s, window=20) # rolling Series (index preserved)
shannon.delta(s, window=20) # rate of change
shannon.normalized(s) # 0-1 scaled
fig = shannon.plot(s, window=20) # matplotlib Figure
from entroscope import shannon, spectral

rng = np.random.default_rng(0)
t = np.arange(400)
noise = rng.normal(size=400)
s = pd.Series(np.where(t < 200, noise, np.sin(2 * np.pi * t / 20) + 0.2 * noise))

spectral.compute(s[:200]) # -> 6.05 bits: noise spreads power over every frequency
spectral.compute(s[200:]) # -> 0.88 bits: the cycle concentrates it
spectral.normalized(s[200:]) # -> 0.13 on a 0-1 scale
roll = spectral.rolling(s, window=40) # -> Series, same index, NaN for the first 39 steps
spectral.delta(s, window=40) # -> step-to-step change in the rolling entropy
fig = spectral.plot(s, window=40) # -> matplotlib Figure
shannon.compute(s) # every measure has the same call shape
```

## Compare measures

```python
from entroscope import plot

fig = plot.compare(s, measures=["shannon", "permutation", "spectral"], window=20)
fig = plot.dashboard(s, window=20)
fig = plot.compare(s, measures=["shannon", "permutation", "spectral"], window=40)
fig = plot.dashboard(s, window=40)
fig = plot.drop_events(s, measure="spectral", window=40, threshold=0.5)
```

Every measure follows the same contract:

| Method | Returns |
| ------------- | ------------------------------------ |
| `compute` | `float` |
| `rolling` | Series/ndarray, same length |
| `delta` | Series/ndarray (first difference) |
| `normalized` | `float` in [0, 1] (where defined) |
| `plot` | `matplotlib.figure.Figure` |
| Method | Returns |
| ------------- | ----------------------------------------------------------- |
| `compute` | `float` |
| `rolling` | same type and length as the input, NaN during warm-up |
| `delta` | first difference of `rolling` |
| `normalized` | `float` in [0, 1] (shannon, permutation, spectral) |
| `plot` | `matplotlib.figure.Figure` |

Inputs can be a pandas Series (index kept), a polars Series (name kept) or a numpy
array. Any NaN or ±inf in a window makes that window's result NaN.
44 changes: 44 additions & 0 deletions tests/test_readme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Every Python example in the README and the docs quickstart must run as written.

Blocks in one file run in order in a shared namespace, the way a reader works
through the page. `# -> value` comments that start with a number are checked
against what the line actually returns.
"""

import re
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pytest

ROOT = Path(__file__).resolve().parent.parent
PAGES = ["README.md", "docs/quickstart.md"]
BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL)
# `expr # -> 6.05 ...` or `expr # -> (3.64, 0.70) ...`: the numbers to check.
CLAIM = re.compile(r"^(?P<expr>[^#]+?)\s+# -> (?P<value>\(?-?\d[\d.,\s-]*\)?)")
ASSIGNMENT = re.compile(r"^\s*[A-Za-z_][\w.\[\]]*\s*=(?!=)")


def _numbers(text):
return [float(v) for v in re.findall(r"-?\d+(?:\.\d+)?", text)]


@pytest.mark.parametrize("page", PAGES)
def test_examples_run_and_claims_hold(page, monkeypatch):
monkeypatch.setenv("MPLBACKEND", "Agg")
blocks = BLOCK.findall((ROOT / page).read_text())
assert blocks, f"no python blocks in {page}"
namespace = {}
for i, block in enumerate(blocks, 1):
# Running the docs' own example code is the point of this test.
exec(compile(block, f"{page}:block{i}", "exec"), namespace) # noqa: S102
plt.close("all")
for line in block.splitlines():
claim = CLAIM.match(line)
if not claim or ASSIGNMENT.match(line):
continue
expected = _numbers(claim["value"])
actual = np.atleast_1d(np.asarray(eval(claim["expr"], namespace), dtype=float))
# Values are shown rounded to 2-3 significant decimals.
assert actual == pytest.approx(expected, abs=0.006), f"{page}: {line.strip()}"
Loading