ptyche 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ptyche-0.1.0.dist-info/METADATA +117 -0
- ptyche-0.1.0.dist-info/RECORD +6 -0
- ptyche-0.1.0.dist-info/WHEEL +5 -0
- ptyche-0.1.0.dist-info/licenses/LICENSE +21 -0
- ptyche-0.1.0.dist-info/top_level.txt +1 -0
- ptyche.py +397 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ptyche
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Trustworthy nonlinear-dynamics invariants (D2, Lyapunov, K2) from non-generic sensor data
|
|
5
|
+
Author-email: Christian Knopp <cknopp@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Zynerji/Ptyche
|
|
8
|
+
Project-URL: Repository, https://github.com/Zynerji/Ptyche
|
|
9
|
+
Keywords: nonlinear dynamics,chaos,correlation dimension,lyapunov exponent,kolmogorov-sinai entropy,takens embedding,attractor reconstruction,time series,grassberger-procaccia
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.21
|
|
20
|
+
Requires-Dist: scipy>=1.7
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Ptyche
|
|
26
|
+
|
|
27
|
+
**Trustworthy nonlinear-dynamics invariants from real (non-generic) sensor data.**
|
|
28
|
+
|
|
29
|
+
*Ptyche* (Greek πτυχή, "a fold") detects the attractor **folding** that corrupts chaos
|
|
30
|
+
invariants when a sensor records a non-generic observable, and recovers the true values.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install ptyche
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The bottleneck it breaks
|
|
37
|
+
|
|
38
|
+
Most sensors don't record a system's state — they record a *nonlinear function* of it:
|
|
39
|
+
intensity (∝ amplitude²), power, magnitude `|x|`, rectified or log signals. Takens' embedding
|
|
40
|
+
theorem only guarantees attractor reconstruction for **generic** observables. An even/symmetric
|
|
41
|
+
observable of a (near-)symmetric system — the usual case for intensity/power — is **non-generic**:
|
|
42
|
+
the delay embedding reconstructs only the *symmetry quotient* of the attractor. Standard
|
|
43
|
+
pipelines then pick an embedding dimension `m` by hand and report a correlation dimension `D2`,
|
|
44
|
+
Lyapunov exponent `λ₁`, or entropy `K2` that is **silently wrong** (folded, under-resolved) — and
|
|
45
|
+
sometimes lands on a famous constant, producing a false "discovery."
|
|
46
|
+
|
|
47
|
+
Ptyche breaks this **reliability bottleneck** (it is not magic and breaks no law of computation)
|
|
48
|
+
with the same three-step machinery applied to every invariant:
|
|
49
|
+
|
|
50
|
+
1. **Converge** — estimate the invariant across embedding dimensions and report the **plateau /
|
|
51
|
+
most-converged value**, not a single hand-picked `m` (GP estimates inflate at large `m`; the
|
|
52
|
+
low-`m` value under-resolves).
|
|
53
|
+
2. **Detect folding** — flag genericity failure (`D2` rises sharply from low `m` to the plateau)
|
|
54
|
+
and recover the true dimension.
|
|
55
|
+
3. **Guard against false constants** — warn when an *unconverged* estimate sits on an **exotic**
|
|
56
|
+
number (golden ratio, plastic number, √2, e, π, Feigenbaum δ, …). Integers and simple
|
|
57
|
+
fractions are deliberately excluded — a limit cycle's `D2 = 1` is real, not a trap.
|
|
58
|
+
|
|
59
|
+
## Three invariants, one wrapper
|
|
60
|
+
|
|
61
|
+
| function | invariant | method |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `correlation_dimension` / `analyze` | `D2` | Grassberger–Procaccia, plateau-converged |
|
|
64
|
+
| `lyapunov_rosenstein` | `λ₁` | Rosenstein mean-log-divergence (+ `r²` fit quality) |
|
|
65
|
+
| `k2_entropy` | `K2` | GP correlation entropy, convergence-aware (KS lower bound) |
|
|
66
|
+
|
|
67
|
+
## Worked example (`python demo.py`)
|
|
68
|
+
|
|
69
|
+
A square-law (intensity) detector watching the Lorenz attractor (true `D2 ≈ 2.06`,
|
|
70
|
+
`λ₁ ≈ 0.906/t`):
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
--- square-law observable x(t)^2 (intensity/power sensor) ---
|
|
74
|
+
D2 by embedding m: {3: 1.306, 4: 1.615, 5: 1.881, 6: 1.937, 7: 1.964, 8: 1.998}
|
|
75
|
+
naive D2 (low m) = 1.306 | converged/best D2 = 1.998
|
|
76
|
+
verdict: FOLDED (use converged D2, distrust the naive value)
|
|
77
|
+
* FALSE-CONSTANT TRAP: low-m D2=1.306 ~ plastic number rho (1.3247) -- a resolution
|
|
78
|
+
artifact, NOT a coincidence; the converged D2 = 2.00.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The naive pipeline would have reported `D2 ≈ 1.33` (the plastic ratio!). Ptyche flags the
|
|
82
|
+
folding, recovers `D2 ≈ 2`, and names the trap. On the *generic* observable it returns the
|
|
83
|
+
trustworthy `D2 ≈ 2.06`, `λ₁ ≈ 0.91/t`.
|
|
84
|
+
|
|
85
|
+
## Use
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import numpy as np, ptyche as p
|
|
89
|
+
|
|
90
|
+
rep = p.analyze(signal, fs=1/dt) # fs = sampling rate -> rates in per-time units
|
|
91
|
+
print(rep.verdict, rep.D2_converged, rep.folded, rep.false_constant_traps)
|
|
92
|
+
print(rep.lyapunov1["lambda1"], rep.k2_entropy["K2"])
|
|
93
|
+
for note in rep.notes: # plain-language caveats (folding, fit quality, convergence)
|
|
94
|
+
print(note)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Individual estimators: `p.lyapunov_rosenstein(x, fs=...)`, `p.k2_entropy(x, fs=...)`,
|
|
98
|
+
`p.embedding_scan`, `p.converged_dimension`, `p.folding_score`, `p.false_constant_warnings`,
|
|
99
|
+
and `p.logperiodic_squarelaw_check` (for discrete-scale-invariant signals: a square-law detector
|
|
100
|
+
measures the apparent rescaling ratio as √λ, not λ).
|
|
101
|
+
|
|
102
|
+
## Honest scope
|
|
103
|
+
|
|
104
|
+
Ptyche makes existing scalar-series estimators **harder to fool**; it does not beat dedicated
|
|
105
|
+
multi-channel Lyapunov packages on precision. `D2` and `λ₁` are essentially exact on Lorenz;
|
|
106
|
+
`K2` from a single scalar series converges slowly and is reported **with** its convergence flag
|
|
107
|
+
(treat a non-converged `K2` as an upper estimate). Slow-`λ` systems (e.g. Rössler) may need a
|
|
108
|
+
wider `fit_window` — the `r²` field tells you when the fit is poor.
|
|
109
|
+
|
|
110
|
+
## Provenance
|
|
111
|
+
|
|
112
|
+
Derived from the *Systrophē* chronology-protection study, where a gravitational-wave
|
|
113
|
+
cross-polarization `ψ ∝ ω²` (an even observable of the Z₂-symmetric Lorenz attractor)
|
|
114
|
+
under-reconstructed the attractor, and a `D2 ≈ 1.30 ≈` plastic-number coincidence turned out to
|
|
115
|
+
be a low-embedding artifact. Ptyche packages that lesson as a general guard.
|
|
116
|
+
|
|
117
|
+
Dependencies: `numpy`, `scipy`. License: MIT.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ptyche.py,sha256=Zt82_PxG36d4w1xUNHlv0gt4Ryqwk3xRg2fhGFGsN5k,19418
|
|
2
|
+
ptyche-0.1.0.dist-info/licenses/LICENSE,sha256=fR9yA8xD4ZbEKaSXyWCpb06OttIN2BY3h_d6yCljnGs,1072
|
|
3
|
+
ptyche-0.1.0.dist-info/METADATA,sha256=CcCTBTzs-zt6QBAdFqh6RFwmxsBc2w1JxTmVyml3Fjo,5792
|
|
4
|
+
ptyche-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
ptyche-0.1.0.dist-info/top_level.txt,sha256=ww5woN6aVWr18V0VHZCxo569e7Qc5_h3W7P9pnWvoX0,7
|
|
6
|
+
ptyche-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Knopp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ptyche
|
ptyche.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Ptyche (Greek pi-tau-upsilon-chi-eta, "a fold") -- trustworthy nonlinear-dynamics
|
|
2
|
+
invariants from non-generic (e.g. square-law) observations.
|
|
3
|
+
|
|
4
|
+
The bottleneck
|
|
5
|
+
--------------
|
|
6
|
+
Real sensors usually record a *nonlinear function* of a system's state -- intensity
|
|
7
|
+
(proportional to amplitude^2), power, magnitude |x|, rectified or log signals. Takens'
|
|
8
|
+
embedding theorem guarantees attractor reconstruction only for *generic* observables; an
|
|
9
|
+
even/symmetric observable of a symmetric system (the common case for intensity/power) is
|
|
10
|
+
NON-generic: the delay embedding reconstructs only the symmetry *quotient* of the attractor.
|
|
11
|
+
Standard pipelines (pick an embedding dimension m, compute the correlation dimension D2 /
|
|
12
|
+
Lyapunov / entropy) then return values that are SILENTLY WRONG -- folded and under-resolved --
|
|
13
|
+
and occasionally land near a famous constant, producing false "discoveries."
|
|
14
|
+
|
|
15
|
+
Ptyche breaks this in three steps:
|
|
16
|
+
1. CONVERGE -- estimate the invariant across embedding dimensions and report the converged
|
|
17
|
+
value (a single low-m number is untrustworthy).
|
|
18
|
+
2. DETECT -- flag attractor folding / genericity failure (D2 still climbing with m;
|
|
19
|
+
signal sign-collapse) and recover the true dimension.
|
|
20
|
+
3. GUARD -- warn when an UNCONVERGED estimate coincides with a famous constant
|
|
21
|
+
(the "plastic-ratio trap": D2(m=3)=1.33 ~ plastic number, but D2 -> 2.06).
|
|
22
|
+
|
|
23
|
+
The same converge/detect/guard machinery wraps three invariants:
|
|
24
|
+
* D2 -- correlation dimension (Grassberger-Procaccia),
|
|
25
|
+
* lambda1 -- largest Lyapunov exponent (Rosenstein), and
|
|
26
|
+
* K2 -- correlation (Kolmogorov-Sinai lower-bound) entropy.
|
|
27
|
+
|
|
28
|
+
It is not magic and breaks no law of computation: it breaks the *reliability* bottleneck of
|
|
29
|
+
nonlinear-dynamics estimation under realistic, non-generic sensing.
|
|
30
|
+
|
|
31
|
+
Dependencies: numpy, scipy.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
from scipy.spatial import cKDTree
|
|
40
|
+
from scipy.spatial.distance import pdist
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
# Exotic constants people get falsely excited to "discover" in an unconverged dimension
|
|
45
|
+
# estimate. Deliberately EXCLUDES integers and simple fractions (1, 2, 3, 1/2, ...): those are
|
|
46
|
+
# legitimate attractor dimensions (limit cycle = 1, torus/Lorenz ~ 2), not numerology traps.
|
|
47
|
+
FAMOUS_CONSTANTS = {
|
|
48
|
+
"golden ratio phi": 1.6180339887,
|
|
49
|
+
"plastic number rho": 1.3247179572,
|
|
50
|
+
"sqrt(2)": 1.4142135624,
|
|
51
|
+
"sqrt(3)": 1.7320508076,
|
|
52
|
+
"e": 2.7182818285,
|
|
53
|
+
"pi": 3.1415926536,
|
|
54
|
+
"ln 2": 0.6931471806,
|
|
55
|
+
"Feigenbaum delta": 4.6692016091,
|
|
56
|
+
"Euler-Mascheroni gamma": 0.5772156649,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# Correlation dimension (Grassberger-Procaccia) with robust scaling-band fit.
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
def _gp_dimension(points: np.ndarray, n_eps: int = 28) -> float:
|
|
64
|
+
d = pdist(points)
|
|
65
|
+
d = d[d > 0]
|
|
66
|
+
if d.size < 50:
|
|
67
|
+
return float("nan")
|
|
68
|
+
dmin, dmax = np.percentile(d, 0.5), np.percentile(d, 99)
|
|
69
|
+
if not (dmax > dmin > 0):
|
|
70
|
+
return float("nan")
|
|
71
|
+
eps = np.logspace(np.log10(dmin), np.log10(dmax), n_eps)
|
|
72
|
+
npair = d.size
|
|
73
|
+
C = np.array([np.count_nonzero(d < e) / npair for e in eps])
|
|
74
|
+
valid = C > 0
|
|
75
|
+
le, lc = np.log(eps[valid]), np.log(C[valid])
|
|
76
|
+
band = (C[valid] >= 3e-3) & (C[valid] <= 0.15) # below saturation, above depletion
|
|
77
|
+
if band.sum() < 4:
|
|
78
|
+
lo, hi = int(0.2 * len(le)), int(0.6 * len(le))
|
|
79
|
+
band = np.zeros(len(le), bool); band[lo:hi] = True
|
|
80
|
+
slope, _ = np.polyfit(le[band], lc[band], 1)
|
|
81
|
+
return float(slope)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _delay_embed(x: np.ndarray, m: int, tau: int) -> np.ndarray:
|
|
85
|
+
n = len(x) - (m - 1) * tau
|
|
86
|
+
if n <= 0:
|
|
87
|
+
raise ValueError("series too short for embedding")
|
|
88
|
+
return np.column_stack([x[i * tau:i * tau + n] for i in range(m)])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def autocorr_delay(x: np.ndarray, max_lag: int = 2000) -> int:
|
|
92
|
+
"""Takens delay = first lag where autocorrelation drops below 1/e."""
|
|
93
|
+
x = np.asarray(x, float) - np.mean(x)
|
|
94
|
+
var = float(np.dot(x, x))
|
|
95
|
+
if var == 0:
|
|
96
|
+
return 1
|
|
97
|
+
for k in range(1, min(max_lag, len(x) - 1)):
|
|
98
|
+
if np.dot(x[:-k], x[k:]) / var < np.exp(-1.0):
|
|
99
|
+
return max(k, 1)
|
|
100
|
+
return max(1, len(x) // 20)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def correlation_dimension(x: np.ndarray, m: int = 5, tau: int | None = None,
|
|
104
|
+
n_points: int = 6000) -> float:
|
|
105
|
+
"""GP correlation dimension of a scalar series via delay embedding.
|
|
106
|
+
|
|
107
|
+
Subsamples to n_points (temporal decorrelation). NOTE: GP estimates inflate at large m
|
|
108
|
+
with finite data (curse of dimensionality); trust the plateau, not the largest m -- which
|
|
109
|
+
is exactly why `converged_dimension` reports the flattest m-window rather than max(m)."""
|
|
110
|
+
x = np.asarray(x, float)
|
|
111
|
+
if tau is None:
|
|
112
|
+
tau = autocorr_delay(x)
|
|
113
|
+
emb = _delay_embed(x, m, tau)
|
|
114
|
+
idx = np.linspace(0, len(emb) - 1, min(n_points, len(emb))).astype(int)
|
|
115
|
+
return _gp_dimension(emb[idx])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Largest Lyapunov exponent (Rosenstein 1993) -- from a scalar series.
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
def lyapunov_rosenstein(x: np.ndarray, m: int = 7, tau: int | None = None, fs: float = 1.0,
|
|
122
|
+
theiler: int | None = None, cap: int = 8000, k_max: int | None = None,
|
|
123
|
+
fit_window: tuple = (0.02, 0.4)) -> dict:
|
|
124
|
+
"""Largest Lyapunov exponent via Rosenstein's mean-log-divergence method.
|
|
125
|
+
|
|
126
|
+
Tracks how nearest-neighbour trajectory pairs separate: <ln d(k)> grows linearly with
|
|
127
|
+
slope = lambda1 (per sample). Pass fs = 1/dt to get lambda1 per unit time (default per
|
|
128
|
+
sample). Uses a CONTIGUOUS slice (no striding -- the step size must equal the sampling
|
|
129
|
+
interval) and a Theiler window to exclude temporally-correlated neighbours.
|
|
130
|
+
|
|
131
|
+
Returns {'lambda1', 'lambda1_per_sample', 'divergence_curve', 'fit_k', 'r2'}.
|
|
132
|
+
"""
|
|
133
|
+
x = np.asarray(x, float)
|
|
134
|
+
if tau is None:
|
|
135
|
+
tau = autocorr_delay(x)
|
|
136
|
+
xc = x[:min(len(x), cap)]
|
|
137
|
+
emb = _delay_embed(xc, m, tau)
|
|
138
|
+
M = len(emb)
|
|
139
|
+
if M < 200:
|
|
140
|
+
return {"lambda1": float("nan"), "lambda1_per_sample": float("nan"),
|
|
141
|
+
"divergence_curve": np.array([]), "fit_k": (0, 0), "r2": float("nan")}
|
|
142
|
+
if theiler is None:
|
|
143
|
+
theiler = max(tau * (m - 1), tau) # one embedding window ~ one orbit
|
|
144
|
+
if k_max is None:
|
|
145
|
+
k_max = min(int(2.0 * theiler), M // 4)
|
|
146
|
+
tree = cKDTree(emb)
|
|
147
|
+
# nearest neighbour of each point that is at least `theiler` samples away in time
|
|
148
|
+
kq = min(M, 2 * theiler + 5)
|
|
149
|
+
dists, idxs = tree.query(emb, k=kq)
|
|
150
|
+
nbr = np.full(M, -1, int)
|
|
151
|
+
for i in range(M):
|
|
152
|
+
for j, di in zip(idxs[i][1:], dists[i][1:]):
|
|
153
|
+
if abs(int(j) - i) > theiler:
|
|
154
|
+
nbr[i] = int(j); break
|
|
155
|
+
valid0 = np.where(nbr >= 0)[0]
|
|
156
|
+
div = np.full(k_max + 1, np.nan)
|
|
157
|
+
for k in range(k_max + 1):
|
|
158
|
+
ii = valid0[(valid0 + k < M) & (nbr[valid0] + k < M)]
|
|
159
|
+
if ii.size < 20:
|
|
160
|
+
break
|
|
161
|
+
d = np.linalg.norm(emb[ii + k] - emb[nbr[ii] + k], axis=1)
|
|
162
|
+
d = d[d > 0]
|
|
163
|
+
if d.size:
|
|
164
|
+
div[k] = float(np.mean(np.log(d)))
|
|
165
|
+
ks = np.where(np.isfinite(div))[0]
|
|
166
|
+
if ks.size < 5:
|
|
167
|
+
return {"lambda1": float("nan"), "lambda1_per_sample": float("nan"),
|
|
168
|
+
"divergence_curve": div, "fit_k": (0, 0), "r2": float("nan")}
|
|
169
|
+
lo = max(1, int(fit_window[0] * k_max)); hi = max(lo + 3, int(fit_window[1] * k_max))
|
|
170
|
+
seg = ks[(ks >= lo) & (ks <= hi)]
|
|
171
|
+
if seg.size < 3:
|
|
172
|
+
seg = ks[:max(3, ks.size // 2)]
|
|
173
|
+
slope, intercept = np.polyfit(seg, div[seg], 1)
|
|
174
|
+
resid = div[seg] - (slope * seg + intercept)
|
|
175
|
+
ss = float(np.sum((div[seg] - np.mean(div[seg])) ** 2))
|
|
176
|
+
r2 = float(1.0 - np.sum(resid ** 2) / ss) if ss > 0 else float("nan")
|
|
177
|
+
return {"lambda1": float(slope * fs), "lambda1_per_sample": float(slope),
|
|
178
|
+
"divergence_curve": div, "fit_k": (int(seg[0]), int(seg[-1])), "r2": r2}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------- #
|
|
182
|
+
# K2 correlation entropy (Grassberger-Procaccia) -- KS-entropy lower bound.
|
|
183
|
+
# --------------------------------------------------------------------------- #
|
|
184
|
+
def _corr_sum(points: np.ndarray, eps: np.ndarray) -> np.ndarray:
|
|
185
|
+
d = pdist(points); d = d[d > 0]; n = d.size
|
|
186
|
+
if n < 50:
|
|
187
|
+
return np.full(len(eps), np.nan)
|
|
188
|
+
return np.array([np.count_nonzero(d < e) / n for e in eps])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _k2_pair(x, m1, m2, tau, n_points):
|
|
192
|
+
"""Single (m, m+1) GP entropy estimate: (fs-free) ln[C_m/C_{m+1}] / tau, per sample."""
|
|
193
|
+
e1 = _delay_embed(x, m1, tau); e2 = _delay_embed(x, m2, tau)
|
|
194
|
+
i1 = np.linspace(0, len(e1) - 1, min(n_points, len(e1))).astype(int)
|
|
195
|
+
i2 = np.linspace(0, len(e2) - 1, min(n_points, len(e2))).astype(int)
|
|
196
|
+
p1, p2 = e1[i1], e2[i2]
|
|
197
|
+
d1 = pdist(p1); d1 = d1[d1 > 0]; d2 = pdist(p2); d2 = d2[d2 > 0]
|
|
198
|
+
if d1.size < 50 or d2.size < 50:
|
|
199
|
+
return float("nan")
|
|
200
|
+
lo = np.log10(max(np.percentile(d1, 1), np.percentile(d2, 1)))
|
|
201
|
+
hi = np.log10(min(np.percentile(d1, 50), np.percentile(d2, 50)))
|
|
202
|
+
if not (hi > lo):
|
|
203
|
+
return float("nan")
|
|
204
|
+
eps = np.logspace(lo, hi, 18)
|
|
205
|
+
C1, C2 = _corr_sum(p1, eps), _corr_sum(p2, eps)
|
|
206
|
+
band = np.isfinite(C1) & np.isfinite(C2) & (C1 >= 1e-2) & (C1 <= 0.1) & (C2 > 0)
|
|
207
|
+
if band.sum() < 3:
|
|
208
|
+
return float("nan")
|
|
209
|
+
return float(np.mean(np.log(C1[band] / C2[band])) / tau)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def k2_entropy(x: np.ndarray, m_values=(3, 4, 5, 6, 7, 8), tau: int | None = None,
|
|
213
|
+
fs: float = 1.0, n_points: int = 4000, tol: float = 0.15) -> dict:
|
|
214
|
+
"""Correlation entropy K2 (a lower bound on the Kolmogorov-Sinai entropy h_KS).
|
|
215
|
+
|
|
216
|
+
From the embedding-dimension scaling of the correlation sum,
|
|
217
|
+
C_m(eps) ~ eps^{D2} * exp(-m * tau * K2),
|
|
218
|
+
the finite-m estimate K2(m) = (fs/tau) * <ln[ C_m / C_{m+1} ]>_eps DECREASES toward the true
|
|
219
|
+
K2 as m grows (low-m overestimates). Mirroring the dimension pillar, this returns the whole
|
|
220
|
+
K2(m) sequence, the most-converged (largest reliable m) value, and a convergence flag --
|
|
221
|
+
rather than one misleading low-m number. Pass fs = 1/dt for per-time units.
|
|
222
|
+
|
|
223
|
+
Returns {'K2', 'K2_per_sample', 'k2_by_m', 'converged', 'descending'}. A positive converged
|
|
224
|
+
K2 is a chaos signature; K2 -> 0 for periodic/quasiperiodic signals. It remains an ESTIMATE
|
|
225
|
+
(a KS lower bound that converges slowly from above); read it with its convergence flag.
|
|
226
|
+
"""
|
|
227
|
+
x = np.asarray(x, float)
|
|
228
|
+
if tau is None:
|
|
229
|
+
tau = autocorr_delay(x)
|
|
230
|
+
ms = sorted(int(m) for m in m_values)
|
|
231
|
+
k2_by_m = {}
|
|
232
|
+
for m1, m2 in zip(ms[:-1], ms[1:]):
|
|
233
|
+
v = _k2_pair(x, m1, m2, tau, n_points)
|
|
234
|
+
if np.isfinite(v):
|
|
235
|
+
k2_by_m[m1] = float(v * fs)
|
|
236
|
+
if not k2_by_m:
|
|
237
|
+
return {"K2": float("nan"), "K2_per_sample": float("nan"), "k2_by_m": {},
|
|
238
|
+
"converged": False, "descending": False}
|
|
239
|
+
keys = sorted(k2_by_m); vals = [k2_by_m[k] for k in keys]
|
|
240
|
+
k2 = vals[-1] # most-converged (largest m) value
|
|
241
|
+
converged = bool(len(vals) >= 2 and abs(vals[-1] - vals[-2]) / max(abs(vals[-1]), 1e-9) < tol)
|
|
242
|
+
descending = bool(len(vals) >= 2 and vals[-1] < vals[0])
|
|
243
|
+
return {"K2": float(k2), "K2_per_sample": float(k2 / fs), "k2_by_m": k2_by_m,
|
|
244
|
+
"converged": converged, "descending": descending}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --------------------------------------------------------------------------- #
|
|
248
|
+
# The three pillars: converge, detect folding, guard against false constants.
|
|
249
|
+
# --------------------------------------------------------------------------- #
|
|
250
|
+
def embedding_scan(x: np.ndarray, m_values=(3, 4, 5, 6, 7, 8), tau: int | None = None,
|
|
251
|
+
n_points: int = 6000) -> dict:
|
|
252
|
+
"""D2 across embedding dimensions m (the curve a single number hides). The default m
|
|
253
|
+
range stays below the finite-sample inflation regime for low-dimensional attractors."""
|
|
254
|
+
if tau is None:
|
|
255
|
+
tau = autocorr_delay(x)
|
|
256
|
+
d2 = {int(m): correlation_dimension(x, m=int(m), tau=tau, n_points=n_points)
|
|
257
|
+
for m in m_values}
|
|
258
|
+
return {"tau": int(tau), "d2_by_m": d2}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def converged_dimension(d2_by_m: dict, plateau_tol: float = 0.10) -> dict:
|
|
262
|
+
"""Best D2 = the PLATEAU (flattest consecutive-m window), NOT max(m).
|
|
263
|
+
|
|
264
|
+
GP estimates rise from an underestimate at small m, plateau near the true dimension, then
|
|
265
|
+
inflate at large m (finite-sample). The plateau is the trustworthy value. The low-m value
|
|
266
|
+
and the rise-to-plateau quantify folding / under-resolution.
|
|
267
|
+
"""
|
|
268
|
+
ms = sorted(d2_by_m)
|
|
269
|
+
vals = np.array([d2_by_m[m] for m in ms], float)
|
|
270
|
+
ok = np.isfinite(vals); ms = list(np.array(ms)[ok]); vals = vals[ok]
|
|
271
|
+
if len(vals) < 2:
|
|
272
|
+
return {"D2": float(vals[-1]) if len(vals) else float("nan"),
|
|
273
|
+
"converged": False, "rise": float("nan"), "D2_lowm": float("nan"),
|
|
274
|
+
"m_at_D2": (ms[-1] if ms else None), "top_spread": float("nan")}
|
|
275
|
+
# GP underestimates from below within the (capped, pre-inflation) m range, so the best
|
|
276
|
+
# estimate is the maximum; convergence = the top of the curve has flattened.
|
|
277
|
+
D2 = float(np.max(vals))
|
|
278
|
+
top_spread = float(abs(vals[-1] - vals[-2]))
|
|
279
|
+
return {"D2": D2, "converged": bool(top_spread < plateau_tol),
|
|
280
|
+
"top_spread": top_spread, "m_at_D2": int(ms[int(np.argmax(vals))]),
|
|
281
|
+
"D2_lowm": float(vals[0]), "rise": float(D2 - vals[0])}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def false_constant_warnings(value: float, tol_rel: float = 0.02) -> list:
|
|
285
|
+
"""Flag a value suspiciously close to a famous constant (the numerology trap)."""
|
|
286
|
+
if not np.isfinite(value):
|
|
287
|
+
return []
|
|
288
|
+
out = []
|
|
289
|
+
for name, c in FAMOUS_CONSTANTS.items():
|
|
290
|
+
if c > 0 and abs(value - c) / c < tol_rel:
|
|
291
|
+
out.append({"constant": name, "value": c,
|
|
292
|
+
"rel_diff": float(abs(value - c) / c)})
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _folding_score(D2_lowm: float, D2_plateau: float) -> float:
|
|
297
|
+
"""0..1: relative rise from the low-m estimate to the plateau (folding signature).
|
|
298
|
+
~0 = generic/well-reconstructed; large = the low-m value is a folded under-estimate."""
|
|
299
|
+
if not (np.isfinite(D2_lowm) and np.isfinite(D2_plateau)) or D2_plateau <= 0:
|
|
300
|
+
return float("nan")
|
|
301
|
+
return float(np.clip((D2_plateau - D2_lowm) / D2_plateau, 0.0, 1.0))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def folding_score(x: np.ndarray, **kw) -> float:
|
|
305
|
+
"""Convenience: folding score of a series (0 generic .. 1 strongly folded)."""
|
|
306
|
+
scan = embedding_scan(x, **kw)
|
|
307
|
+
conv = converged_dimension(scan["d2_by_m"])
|
|
308
|
+
return _folding_score(conv["D2_lowm"], conv["D2"])
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass
|
|
312
|
+
class FoldReport:
|
|
313
|
+
D2_naive_lowm: float
|
|
314
|
+
D2_converged: float
|
|
315
|
+
converged: bool
|
|
316
|
+
folded: bool
|
|
317
|
+
folding_score: float
|
|
318
|
+
rise_with_m: float
|
|
319
|
+
tau: int
|
|
320
|
+
d2_by_m: dict
|
|
321
|
+
false_constant_traps: list = field(default_factory=list)
|
|
322
|
+
verdict: str = ""
|
|
323
|
+
notes: list = field(default_factory=list)
|
|
324
|
+
lyapunov1: dict | None = None
|
|
325
|
+
k2_entropy: dict | None = None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def analyze(x: np.ndarray, m_values=(3, 4, 5, 6, 7, 8), tau: int | None = None,
|
|
329
|
+
n_points: int = 6000, dynamics: bool = True, fs: float = 1.0) -> FoldReport:
|
|
330
|
+
"""One-call diagnosis. Returns a FoldReport with the trustworthy invariant + warnings.
|
|
331
|
+
|
|
332
|
+
With dynamics=True also estimates the largest Lyapunov exponent (Rosenstein) and the
|
|
333
|
+
correlation entropy K2 behind the same convergence/folding caveats. Pass fs = 1/dt for the
|
|
334
|
+
dynamical rates in per-time units (default per-sample)."""
|
|
335
|
+
x = np.asarray(x, float)
|
|
336
|
+
scan = embedding_scan(x, m_values=m_values, tau=tau, n_points=n_points)
|
|
337
|
+
conv = converged_dimension(scan["d2_by_m"])
|
|
338
|
+
fold_s = _folding_score(conv["D2_lowm"], conv["D2"])
|
|
339
|
+
folded = bool(np.isfinite(fold_s) and fold_s > 0.25)
|
|
340
|
+
# only warn about a famous-constant coincidence when the naive value is actually
|
|
341
|
+
# misleading (folded / not converged); an integer D2 of a genuine limit cycle is not a trap.
|
|
342
|
+
traps = false_constant_warnings(conv.get("D2_lowm", float("nan"))) if folded else []
|
|
343
|
+
notes = []
|
|
344
|
+
if folded:
|
|
345
|
+
notes.append(
|
|
346
|
+
f"Attractor FOLDING / non-generic observable: D2 climbs from "
|
|
347
|
+
f"{conv['D2_lowm']:.2f} (m={m_values[0]}) to {conv['D2']:.2f} (m={m_values[-1]}); "
|
|
348
|
+
f"the low-m value is an under-resolved quotient, not the true dimension.")
|
|
349
|
+
if traps:
|
|
350
|
+
names = ", ".join(t["constant"] for t in traps)
|
|
351
|
+
notes.append(
|
|
352
|
+
f"FALSE-CONSTANT TRAP: the unconverged low-m D2={conv.get('D2_lowm', float('nan')):.3f} "
|
|
353
|
+
f"~ {names}. This is a resolution artifact, NOT a real coincidence -- the converged "
|
|
354
|
+
f"D2={conv['D2']:.2f}.")
|
|
355
|
+
if not conv["converged"]:
|
|
356
|
+
notes.append("D2 not yet plateaued: report as a LOWER BOUND; increase m / data length.")
|
|
357
|
+
if conv["converged"] and not folded:
|
|
358
|
+
notes.append("Reconstruction looks generic and converged; D2 is trustworthy.")
|
|
359
|
+
verdict = ("FOLDED (use converged D2, distrust the naive value)" if folded
|
|
360
|
+
else ("CONVERGED" if conv["converged"] else "UNDER-RESOLVED (lower bound only)"))
|
|
361
|
+
lya = ent = None
|
|
362
|
+
if dynamics:
|
|
363
|
+
lya = lyapunov_rosenstein(x, tau=scan["tau"], fs=fs)
|
|
364
|
+
ent = k2_entropy(x, m_values=m_values, tau=scan["tau"], fs=fs)
|
|
365
|
+
if folded:
|
|
366
|
+
notes.append("Under folding the embedding reconstructs the symmetry quotient, so "
|
|
367
|
+
"lambda1 / K2 are quotient-attractor estimates -- interpret with the "
|
|
368
|
+
"same caution as the folded D2.")
|
|
369
|
+
if lya and np.isfinite(lya.get("r2", float("nan"))) and lya["r2"] < 0.9:
|
|
370
|
+
notes.append("lambda1 divergence fit is poor (r2 < 0.9): widen fit_window / add data.")
|
|
371
|
+
if ent and not ent.get("converged", False):
|
|
372
|
+
notes.append("K2 has not converged across m (still descending): treat as an upper "
|
|
373
|
+
"estimate of the KS-entropy lower bound.")
|
|
374
|
+
return FoldReport(
|
|
375
|
+
D2_naive_lowm=conv.get("D2_lowm", float("nan")),
|
|
376
|
+
D2_converged=conv["D2"], converged=conv["converged"], folded=folded,
|
|
377
|
+
folding_score=fold_s, rise_with_m=conv["rise"], tau=scan["tau"],
|
|
378
|
+
d2_by_m=scan["d2_by_m"], false_constant_traps=traps, verdict=verdict, notes=notes,
|
|
379
|
+
lyapunov1=lya, k2_entropy=ent)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --------------------------------------------------------------------------- #
|
|
383
|
+
# Bonus: log-periodic (discrete-scale-invariance) square-law correction.
|
|
384
|
+
# --------------------------------------------------------------------------- #
|
|
385
|
+
def logperiodic_squarelaw_check(signal_in_u: np.ndarray, u: np.ndarray) -> dict:
|
|
386
|
+
"""For a log-periodic (DSI) signal sampled uniformly in u=ln r, a square-law detector
|
|
387
|
+
measures DOUBLE the log-frequency (apparent rescaling ratio sqrt(lambda), not lambda).
|
|
388
|
+
Returns the measured log-frequency of the signal and of its square."""
|
|
389
|
+
def logfreq(s):
|
|
390
|
+
s = s - np.mean(s); sgn = np.sign(s); sgn[sgn == 0] = 1
|
|
391
|
+
n_zero = int(np.sum(np.abs(np.diff(sgn)) > 0))
|
|
392
|
+
return n_zero * np.pi / float(u[-1] - u[0])
|
|
393
|
+
a_lin = logfreq(signal_in_u)
|
|
394
|
+
a_sq = logfreq(signal_in_u ** 2)
|
|
395
|
+
return {"logfreq_linear": a_lin, "logfreq_squarelaw": a_sq,
|
|
396
|
+
"doubling_ratio": (a_sq / a_lin if a_lin else float("nan")),
|
|
397
|
+
"apparent_dsi_is_sqrt_of_true": True}
|