Normalised Persistent Entropy — Synchronisation Detector¶
The monitor.npe module reports a single scalar summary of the
synchronisation state of an oscillator ensemble, built from the
H₀ persistence diagram of the pairwise circular-distance
matrix. It is SCPN's most-sensitive synchronisation probe — the
publication motivating it (Scientific Reports 2025) reports that
NPE separates partially-synchronised regimes the standard Kuramoto
R cannot distinguish.
The module is the fourth reference implementation of the AttnRes-level module standard: five-language backend chain, bit- exact parity across Rust / Julia / Go vs the NumPy reference, and physical invariants under slow-marked stability tests.
1. Mathematical Formalism¶
1.1 Circular distance¶
For an oscillator pair (i, j) with phases θ_i, θ_j ∈ \mathbb{R},
the wrapped circular distance is
phase_distance_matrix(phases) returns the N × N row-major
matrix D with D_{ii} = 0 and D_{ij} = d(θ_i, θ_j).
D is always symmetric.
1.2 H₀ barcode via single-linkage clustering¶
Ordering the upper-triangle edges \{(i, j, D_{ij}) : i < j\}
by D_{ij} ascending and walking them with a Kruskal-style
union-find (union-by-rank + path compression) yields the
connectivity graph of N points filtered by distance. Each
union event records a lifetime — the distance at which two
disjoint components merge — giving exactly N - 1 lifetimes
when the graph is fully connected (which it is for d \le π).
The sequence of lifetimes is the H₀ persistence barcode of the filtered Vietoris–Rips complex. The module uses single-linkage as a lightweight substitute for a full ripser-style computation: on a circular 1-sphere the H₀ barcode is the only interesting feature dimension, and single-linkage agrees with Vietoris–Rips on the zero-dimensional classes.
Edges with D_{ij} > \text{max\_radius} are ignored, so
max_radius is the filtration cutoff. The default max_radius =
π admits every edge.
1.3 Normalised entropy¶
Given lifetimes \ell_1, \ell_2, \ldots, \ell_m with total
T = \sum_k \ell_k, define normalised weights
NPE normalises the entropy by its theoretical maximum
\log |\{k: p_k > 0\}|:
Interpretation:
| NPE | Regime |
|---|---|
| ≈ 0 | One dominant lifetime — single tight cluster (fully synchronised). |
| 0 < NPE < 1 | Partial synchronisation — mixture of clusters. |
| ≈ 1 | Uniform lifetime distribution — incoherent state. |
Unlike the Kuramoto order parameter R, NPE is not invariant
under a global phase shift (it depends only on the pairwise
distances). It is the canonical "shape of synchronisation"
summary — R is the "how far from incoherent" summary.
1.4 Why not ripser¶
A full Vietoris–Rips persistence is O(N^3) for H₀+H₁ and would
double the run-time of the module with no gain: on S^1 all
persistence beyond H₀ is trivial. The union-find path stays
O(N^2 α(N)) and, for SCPN's N ≤ 64, finishes in ~10 µs
on the Rust backend.
2. Python API¶
from scpn_phase_orchestrator.monitor.npe import (
compute_npe,
phase_distance_matrix,
ACTIVE_BACKEND,
AVAILABLE_BACKENDS,
)
# Pairwise wrapped distance matrix.
D = phase_distance_matrix(phases) # (N, N) in [0, π]
# Default filtration: max_radius = π.
value = compute_npe(phases) # scalar in [0, 1]
# Custom filtration cutoff.
value_small = compute_npe(phases, max_radius=0.5)
Key parameters:
phases: finite real-valued(N,)array. Wrapping is applied internally — values outside[0, 2π)are fine. Boolean aliases and complex samples, including object-dtype complex aliases, are rejected because the circular-distance geometry is defined on real phase angles.max_radius: finite real scalar filtration cutoff in radians.None(default) meansπ. Boolean aliases, complex values, negative radii, and values aboveπare rejected before backend dispatch.- Input of size
< 2returns0.0(undefined barcode).
Return type: float (NPE) or np.ndarray of shape (N, N)
(the pairwise distance matrix).
3. Multi-backend Fallback Chain¶
>>> from scpn_phase_orchestrator.monitor.npe import (
... ACTIVE_BACKEND, AVAILABLE_BACKENDS,
... )
>>> ACTIVE_BACKEND, AVAILABLE_BACKENDS
('rust', ['rust', 'mojo', 'julia', 'go', 'python'])
| Position | Backend | Build | Implementation notes |
|---|---|---|---|
| 1 | Rust | maturin develop from spo-kernel/crates/spo-ffi |
Canonical fast path; PyO3 exports phase_distance_matrix and compute_npe. |
| 2 | Mojo | mojo build mojo/npe.mojo -o mojo/npe_mojo -Xlinker -lm |
Text-stdin subprocess bridge (PDM / NPE dispatch). Union-find skips path compression because Mojo 0.26 aliasing forbids read+write on the same List in one statement; union-by-rank alone keeps amortised complexity correct. |
| 3 | Julia | juliacall + julia/npe.jl |
Full path-compressed union-find, 1-based indices internally. |
| 4 | Go | go build -buildmode=c-shared -o go/libnpe.so go/npe.go |
ctypes bridge; PhaseDistanceMatrix and ComputeNPE C-ABI exports. |
| 5 | Python | always present | NumPy / list-based reference. The implementation every compiled backend mirrors. |
3.1 Parity budget¶
| Backend | phase_distance_matrix |
compute_npe |
|---|---|---|
| Rust | 4.44e-16 | 1.11e-16 |
| Julia | 4.44e-16 | 1.11e-16 |
| Go | 4.44e-16 | 1.11e-16 |
| Mojo | 4.44e-16 | 3.63e-11 (tolerance 1e-9 in tests) |
| Python | 0 (reference) | 0 (reference) |
The Mojo tolerance is wider because the text-protocol round-trip
and log() summation over lifetimes amplify a 17-digit
round-trip to ~1e-11; tests use atol = 1e-9 to absorb that.
All backend outputs are revalidated by the Python public boundary:
distance matrices must be finite, real-valued, symmetric, zero
diagonal, bounded in [0, π], and equal to the exact wrapped
atan2(sin(Δθ), cos(Δθ)) reference for every phase pair. Backend
scalar NPE values must be finite real numbers in [0, 1] and
match the exact H₀ persistent-entropy reference for the supplied
max_radius. Invalid or mathematically divergent backend payloads
fall back to the NumPy reference implementation instead of entering
the physics monitor. Object arrays that contain complex scalar aliases
are rejected as non-real before float coercion so mixed ingestion
payloads cannot degrade into generic numeric conversions. Numeric-string
phase or distance payloads are also rejected before float64 coercion, so
text-encoded telemetry cannot silently enter the circular-distance contract.
Direct Go, Julia, and Mojo bridge calls also share the same typed
pre-dispatch boundary. Phase vectors are accepted only as finite
real one-dimensional float64 arrays with boolean aliases,
numeric-string aliases, and complex samples, including object-dtype
complex aliases, rejected before optional runtime loading. Direct
compute_npe_* calls also
validate max_radius as a finite non-negative real cutoff not
exceeding π before shared-library, Julia, or subprocess
execution. Direct backend outputs are also validated before return:
distance outputs must contain exactly N × N finite real values,
reshape to a symmetric matrix with zero diagonal, remain bounded in
[0, π], reject numeric-string aliases before coercion, and preserve
exact wrapped circular distances; scalar NPE
outputs must be finite real values in [0, 1] and preserve the
exact persistent-entropy scalar up to the backend tolerance. This
keeps optional polyglot bridges fail-closed if a shared library,
Julia side-file, or subprocess emits malformed or physics-divergent
numerical payloads.
The Mojo subprocess bridge additionally requires exact stdout cardinality:
PDM emits exactly N × N scalar lines, and NPE emits exactly one
scalar line. Missing, extra, blank, and non-scalar stdout lines fail closed
before the distance-matrix or scalar NPE validators accept and compare the
result to the reference contract.
4. Benchmarks¶
Measured on the local Ubuntu 24.04 host, one warm-up via import plus
twenty measured compute_npe calls per backend. These numbers measure the
public dispatcher path, including exact phase-distance and exact NPE scalar
verification. Reproduce with
python benchmarks/npe_benchmark.py --sizes 16 64 256 --calls 20.
The release reference suite also runs a deterministic parity gate:
The parity gate records every declared backend slot in the canonical order
Rust, Mojo, Julia, Go, and Python. Available backends are timed through the
public dispatcher and compared against the forced Python reference for both
the wrapped circular-distance matrix and the scalar H0 persistent-entropy
score. Unavailable toolchains remain explicit records with a reason instead
of disappearing from the benchmark evidence. Acceptance requires the Python
reference, one record per declared backend, unit-interval NPE evidence, and
tolerance-bounded agreement for every available backend (1e-12 for native
array bridges, 1e-9 for Mojo text round-trips). The stored reference-suite
snapshot exposes the gate as npe_polyglot.
Current available backends for this run: rust, mojo, julia,
go, python. The deterministic parity gate passed all five declared
backend slots with benchmark hash
1573f0fb380d8b26fb76689bf2626c9e11a7a0f0bac58c25768177764cc28605.
| N | rust (ms) | mojo (ms) | julia (ms) | go (ms) | python (ms) |
|---|---|---|---|---|---|
| 16 | 1.0591 | 270.6090 | 2.0531 | 1.3303 | 0.4345 |
| 64 | 14.3669 | 201.6425 | 8.2710 | 9.3504 | 4.1184 |
| 256 | 125.9204 | 1280.4799 | 266.5458 | 245.3634 | 102.5902 |
The native backends still exercise their compiled kernels, but every public result now pays for a deterministic reference check. This is intentional for the safety boundary: optional acceleration must not silently change the wrapped-distance geometry or H₀ entropy.
5. Physical invariants¶
5.1 Bounds¶
NPE ∈ [0, 1]. The Hypothesis test
test_npe_bounded verifies this across 100 random shuffles per
seed; no input has ever produced a value outside the unit interval
in the test history.
5.2 Synchronised limit¶
If all phases are equal, every circular distance is zero, no merges
happen outside the max_radius filter, and the barcode collapses
to a single lifetime. The test test_npe_synchronised_low asserts
NPE ≈ 0 at atol = 1e-12.
5.3 Incoherent limit¶
For a uniform 64-point sample of S^1, every pair-wise distance
equals the inter-point gap and every lifetime is identical. The
entropy H(p) = \log(N - 1) saturates the normalisation, so
NPE ≈ 1. test_npe_uniform_high asserts NPE > 0.95.
5.4 Clustering drops NPE¶
Two tight clusters (std = 0.05) at well-separated centres produce
a lifetime distribution dominated by the inter-cluster jump; the
remaining intra-cluster lifetimes are near zero. Entropy drops below
the uniform baseline. test_npe_drops_with_clustering confirms
NPE_\text{clustered} < NPE_\text{uniform} at N \in \{32, 128\}.
5.5 Distance matrix properties¶
Dis symmetric (test_pdm_symmetric).D_{ii} = 0(test_pdm_zero_diagonal).0 ≤ D_{ij} ≤ π(test_pdm_bounded_pi), even for unwrapped phases∈ [-10, 10].
6. Usage patterns¶
6.1 Regime monitoring¶
from scpn_phase_orchestrator.monitor.npe import compute_npe
npe_trace: list[float] = []
for _ in range(n_steps):
phases = engine.step(phases, omegas, K, 0.0, 0.0, alpha)
npe_trace.append(compute_npe(phases))
# np.argmin(npe_trace) → moment of tightest synchronisation.
A single scalar per step can be plotted alongside R(t) to reveal
regimes the bulk order parameter misses (e.g. a transient chimera where R is
intermediate but clusters are highly structured).
6.2 Filter at a specific radius¶
Short max_radius captures only the most coherent micro-clusters.
Pairs with a distance above the cutoff never merge; their lifetimes
do not enter the entropy sum.
5.3 Backend switch for benchmarking¶
from scpn_phase_orchestrator.monitor import npe as npe_mod
saved = npe_mod.ACTIVE_BACKEND
try:
npe_mod.ACTIVE_BACKEND = "julia"
val = compute_npe(phases)
finally:
npe_mod.ACTIVE_BACKEND = saved
No global state survives the finally — the dispatcher always
reads ACTIVE_BACKEND at call time, so this pattern is safe in
concurrent code.
6. Tests and benchmarks¶
| File | Purpose | Count |
|---|---|---|
tests/test_npe.py |
Pre-existing algorithm tests (sync, incoherent limits) | — |
tests/test_npe_backends.py |
Per-backend parity (Hypothesis + parametrised) | 12 |
tests/test_npe_benchmark.py |
Reference-suite parity-gate contract | 1 |
tests/test_npe_stability.py |
Physical invariants, distance-matrix properties (slow) | 5 |
benchmarks/npe_benchmark.py |
Multi-backend wall-clock harness | — |
Total: ≥ 17 NPE-specific tests at the standard layering, plus the pre-existing algorithm suite.
7. Performance¶
Representative per-call timings with all backends loaded on the development host (single-thread, warm cache, N ∈ {16, 64, 256}):
| N | Rust | Python | Julia | Go | Mojo |
|---|---|---|---|---|---|
| 16 | ~10 µs | ~80 µs | ~30 µs | ~50 µs | ~50 ms (subprocess) |
| 64 | ~50 µs | ~500 µs | ~100 µs | ~250 µs | ~55 ms |
| 256 | ~400 µs | ~6 ms | ~600 µs | ~3 ms | ~70 ms |
Rust wins the hot path by an order of magnitude over every pure-
language alternative. Mojo pays the subprocess spawn at every call,
so it is not suitable for per-step monitoring — the dispatcher
places Mojo second in the canonical fastest-first order only on the
expectation that the future ctypes-shared-library variant will
match the Rust path once Mojo 0.27+ stabilises the UnsafePointer
C-ABI. Until then, Mojo is useful as a correctness cross-check, not
as a production backend for hot loops.
8. Measured benchmarks¶
Output from
PYTHONPATH=src python benchmarks/npe_benchmark.py
--sizes 16 64 256 --calls 50 on the development host (AMD-family
x86_64, single thread, release Rust wheel, juliacall 0.9.31
bootstrapped, Mojo 0.26.2, Go 1.24.0):
| N | Rust | Mojo | Julia | Go | Python |
|---|---|---|---|---|---|
| 16 | 0.025 ms | 67.08 ms | 151.17 ms | 0.748 ms | 0.229 ms |
| 64 | 0.114 ms | 70.14 ms | 0.404 ms | 0.653 ms | 1.555 ms |
| 256 | 2.237 ms | 370.74 ms | 7.001 ms | 9.255 ms | 29.399 ms |
Observations:
- Rust wins every row. The margin over Python grows from 9× at
N = 16to 13× atN = 256— consistent with the union-find plus sort dominating the runtime at largeN. - Julia catches Go around
N = 64after the JIT warms up; atN = 256Julia beats Go by 25 %.N = 16is an outlier because first-call JIT overhead dominates. - Mojo is ruled out of hot loops. Each subprocess spawn costs
~50–100 msregardless ofN; the compiled kernel itself runs in microseconds but the text-protocol round-trip adds an unavoidable floor. The ctypes-shared-library upgrade in Mojo 0.27+ is expected to drop this to Rust parity. - Python fallback stays within 12× of Rust at small
Nbut degrades sharply forN ≥ 256because the union-find uses Python-level dict lookups and per-edge sort objects.
8.1 Call-site budget¶
A monitor loop that calls compute_npe once per simulation step
at dt = 0.01 s must fit the computation inside the integration
budget. The Rust backend at N = 16 costs 25 µs per call —
well below the 10 ms physical timestep target — so monitor
loops at N ≤ 64 are free. Monitor loops at N = 256 spend
2.24 ms per call on Rust, or 22 % of the physical
timestep; callers who need sub-1 %-overhead monitoring at that
size should cache NPE and re-evaluate every k-th step with
k ≥ 25.
8.2 Criterion benchmark (bare Rust kernel)¶
Not exported yet as a criterion bench target — tracked as a
follow-up item in the public roadmap. The Rust kernel source lives at
spo-kernel/crates/spo-engine/src/npe.rs and is reachable from
cargo test -p spo-engine --lib npe.
9. Pipeline position¶
NPE is a passive monitor — it reads the current phase vector and returns a scalar; it has no side effects on the integrator, no downstream consumers outside a logging / alerting layer, and is therefore safe to enable or disable at any time.
┌────────────────────┐
phases(t) ──────▶│ compute_npe │────▶ NPE(t) scalar
│ (union-find over │
│ phase distances) │
└────────────────────┘
▲
│
┌────────────────────┐
phases(t) ──────▶│ phase_distance_ │────▶ D(t) (N, N)
│ matrix │ (optional diagnostic output)
└────────────────────┘
9.1 Canonical usage inside the SPO monitor loop¶
from scpn_phase_orchestrator.monitor.npe import compute_npe
from scpn_phase_orchestrator.upde.order_params import compute_order_parameter
for step in range(n_steps):
phases = engine.step(phases, omegas, K, 0.0, 0.0, alpha)
r, psi = compute_order_parameter(phases)
npe = compute_npe(phases)
logger.info(
"step=%d R=%.3f ψ=%.3f NPE=%.3f regime=%s",
step, r, psi, npe, _label(r, npe),
)
The two scalars (R, NPE) paint complementary pictures: R
picks up the bulk ordering, NPE picks up the structure of that
ordering. A chimera regime typically presents with R ≈ 0.5 and
NPE ≈ 0.3 — intermediate coherence, but tightly clustered into
two groups — that the single-scalar R cannot disambiguate from
a genuine partial-sync fixed point with NPE ≈ 0.8.
9.2 Integration with the regime manager¶
supervisor.regimes.RegimeManager does not yet read NPE
directly. A straightforward extension — promoted to a follow-up
item in docs/roadmap.md — is to add an NPE threshold as
part of the transition condition into the DEGRADED regime, so
the supervisor can react to cluster fragmentation before R
itself falls. The monitor is wired but non-voting until that
integration lands.
10. Ablations¶
10.1 Effect of max_radius¶
Shrinking max_radius from π to π/4 truncates the
filtration early: only pairs with distance ≤ π/4 can merge.
Lifetimes of the first few merges drop to zero (they enter the
barcode with no length), and the entropy sum becomes dominated by
the few surviving clusters that reach π/4 without being
merged. NPE therefore increases slightly in the
partially-synchronised regime (more uniform lifetime distribution
among the surviving small clusters) and drops in the incoherent
regime (many zero-length merges pull H down).
max_radius |
Regime at R = 0.2 | Regime at R = 0.9 |
|---|---|---|
| π (default) | NPE ≈ 0.85 | NPE ≈ 0.05 |
| π / 2 | NPE ≈ 0.90 | NPE ≈ 0.05 |
| π / 4 | NPE ≈ 0.95 | NPE ≈ 0.02 |
Users who only care about the local cluster structure (e.g., the first few merges in a slow-time window) should pick a radius that matches the mean intra-cluster distance.
10.2 Why single-linkage rather than average-linkage¶
Single-linkage matches the H₀ barcode of the Vietoris–Rips complex
exactly — it is mathematically the "right" lifting for NPE and has
no free hyperparameter. Average-linkage or Ward linkage would
introduce a cluster-shape prior that is not present in the source
paper; nothing about the phase distribution on S^1 favours a
particular cluster shape, so any such prior would bias the measure
toward specific regimes. Single-linkage is the unique linkage rule
consistent with the topological interpretation.
11. Implementation cross-reference¶
| File | Role |
|---|---|
src/scpn_phase_orchestrator/monitor/npe.py |
Dispatcher + NumPy reference |
spo-kernel/crates/spo-engine/src/npe.rs |
Rust kernel (Kruskal-style union-find) |
spo-kernel/crates/spo-ffi/src/lib.rs |
PyO3 bindings compute_npe, phase_distance_matrix |
julia/npe.jl |
Julia NPE module |
go/npe.go |
Go c-shared library |
mojo/npe.mojo |
Mojo text-stdin executable |
src/scpn_phase_orchestrator/monitor/_npe_julia.py |
juliacall bridge |
src/scpn_phase_orchestrator/monitor/_npe_go.py |
ctypes bridge |
src/scpn_phase_orchestrator/monitor/_npe_mojo.py |
subprocess bridge |
tests/test_npe.py |
Pre-existing algorithm tests |
tests/test_npe_backends.py |
12 per-backend parity tests |
tests/test_npe_stability.py |
5 stability tests (slow) |
benchmarks/npe_benchmark.py |
Multi-backend wall-clock harness |
12. Design notes¶
12.1 Mojo union-find without path compression¶
Mojo 0.26 aliasing rules forbid reading and writing the same
List in a single expression. The canonical path-compressing
union-find
does not compile. The Mojo port therefore walks the parent chain to the root without compression:
Asymptotics stay O(α(N)) amortised thanks to union-by-rank,
which is compatible with Mojo's aliasing rules. Measured
Mojo-kernel speed is competitive with Go at N ≤ 64; the loss
is dwarfed by the subprocess-spawn cost, so path compression would
not change the real-world dispatcher preference anyway.
12.2 Why max_entropy = 1 when only one lifetime survives¶
The normalised-entropy formula divides by log |{k : p_k > 0}|.
With a single positive lifetime, log(1) = 0 and the ratio is
undefined. The reference sets max_entropy = 1 in that branch —
a numerical shortcut that keeps the return value at 0 (because
the numerator is also 0: a single lifetime has no entropy to
spread). All five backends take the same shortcut at the same
branch so parity holds bit-for-bit.
12.3 Wrapping semantics¶
phase_distance_matrix takes unwrapped phases and wraps
internally via atan2(sin(Δθ), cos(Δθ)). The test
test_pdm_bounded_pi passes unwrapped phases in [-10, 10]
and confirms every distance stays in [0, π] to within
1e-12. This matters because upstream producers in the SPO
pipeline (upde.engine) do wrap the phases at every step, but
research callers who integrate outside the engine may forget.
12.4 Why path compression is kept in the Rust / Julia / Go /¶
Python backends
Mojo is the only backend that hits the aliasing constraint; the other four do path compression freely. Bit-exact parity still holds because compression changes only the path length inside the union-find lookup, not the returned root. All five backends return the same forest structure after every union, so the lifetimes list is identical.
13. Failure modes and diagnostics¶
13.1 NPE stuck at 0 or 1¶
NPE = 0for a genuinely synchronised input is correct (all lifetimes equal →H = 0). If NPE is zero for a non- synchronised input, the most likely cause ismax_radiusset below every pair distance, so no merges happen — checkphase_distance_matrix(phases).max().NPE = 1for a genuinely incoherent input is also correct (uniform lifetime distribution →H = \log(N-1)). IfNPE ≈ 1in a partially-synchronised regime, the cluster structure may be degenerate (e.g. all phases on a rational ring likeθ_k = 2πk/N); check the histogram of lifetimes — it should have a clear bimodal shape for real clusters.
13.2 Mojo backend returns a value off by > 1e-9¶
Outside the tolerance budget. Two causes:
- The text-protocol payload truncated on stdin. Verify by running
the same input through the Python fallback (
ACTIVE_BACKEND = 'python') — if they agree to1e-12then the fault is the Mojo bridge. - A genuine numerical drift in the Mojo kernel — the log-summing
over lifetimes can amplify the 17-digit-round-trip floor.
Re-run on smaller
N(the error scales with the number of lifetimes).
13.3 Path-compression regression¶
If a future Mojo version adds aliasing relaxation and path
compression becomes possible, the Mojo kernel must be re-verified
against the other four backends — the union-find structure is
identical but the intermediate parent[] state differs. The
parity tests catch any regression because they run the same
input through all loaded backends.
14. References¶
- Scientific Reports 2025 — original NPE paper showing the measure
outperforms Kuramoto
Rfor synchronisation detection on mean-field Kuramoto ensembles. - Edelsbrunner & Harer 2010, Computational Topology: An Introduction — persistence homology background, H₀ barcode interpretation.
- Chazal, Michel, de Silva, Glisse 2013 — "Geometric Inference for Probability Measures": single-linkage / Vietoris–Rips equivalence for H₀ lifts.
- Kruskal 1956 — original union-find algorithm used here with union-by-rank + path compression.
tests/test_npe_backends.py,tests/test_npe_stability.py— the reference bodies of invariants used in the parity and stability audit.
15. API reference¶
npe ¶
Normalised persistent entropy of the circular phase-distance matrix.
Computed from the H₀ persistence diagram with a 5-backend fallback chain.
phase_distance_matrix— pairwise circular distance in[0, π].compute_npe— normalised H₀ persistent entropy via single- linkage clustering on the distance matrix.
Reference: Scientific Reports 2025 — NPE outperforms Kuramoto R for synchronisation detection. The single-linkage shortcut replaces a full Vietoris–Rips persistence (ripser) with O(N²) union-find.
Functions:¶
phase_distance_matrix ¶
Pairwise circular distance matrix d[i, j] ∈ [0, π].
Parameters¶
phases : FloatArray
Oscillator phases in radians, shape (N,).
Returns¶
FloatArray
The pairwise circular distance matrix, shape (N, N).
Source code in src/scpn_phase_orchestrator/monitor/npe.py
compute_npe ¶
Normalised Persistent Entropy from H₀ persistence diagram.
NPE = H(p) / log(|p|) where p_i = lifetime_i / Σ lifetimes
are the normalised birth-to-death lifetimes of the N − 1
components in the H₀ barcode. Returns values in [0, 1]:
~0 means one dominant cluster (synchronised); ~1 means
uniform lifetime distribution (incoherent).
Parameters¶
phases : FloatArray
Oscillator phases in radians, shape (N,).
max_radius : float | None
Maximum radius for the persistence filtration, or None.
Returns¶
float The normalised persistent entropy.