convergio 0.1.0__tar.gz
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.
- convergio-0.1.0/LICENSE +21 -0
- convergio-0.1.0/PKG-INFO +116 -0
- convergio-0.1.0/README.md +94 -0
- convergio-0.1.0/convergio/__init__.py +412 -0
- convergio-0.1.0/convergio.egg-info/PKG-INFO +116 -0
- convergio-0.1.0/convergio.egg-info/SOURCES.txt +10 -0
- convergio-0.1.0/convergio.egg-info/dependency_links.txt +1 -0
- convergio-0.1.0/convergio.egg-info/requires.txt +1 -0
- convergio-0.1.0/convergio.egg-info/top_level.txt +1 -0
- convergio-0.1.0/pyproject.toml +27 -0
- convergio-0.1.0/setup.cfg +4 -0
- convergio-0.1.0/tests/test_convergio.py +145 -0
convergio-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maximilian Jurak
|
|
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.
|
convergio-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: convergio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic convergence detection for iterative numerical methods. Stop wasting compute.
|
|
5
|
+
Author: Maximilian Jurak
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/maxjurak/convergio
|
|
8
|
+
Keywords: convergence,simulation,solver,early-stopping,FEM,CFD,optimization
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.20
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Convergio
|
|
24
|
+
|
|
25
|
+
**Automatic convergence detection for iterative numerical methods.**
|
|
26
|
+
|
|
27
|
+
Stop wasting compute. One function call tells you if your solver has converged, is oscillating, is stuck between two states, or is diverging.
|
|
28
|
+
|
|
29
|
+
*"Your solver finished 3000 iterations ago."*
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install convergio
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Only dependency: NumPy.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Analyze a completed run
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from convergio import detect
|
|
45
|
+
|
|
46
|
+
result = detect(my_residual_history)
|
|
47
|
+
|
|
48
|
+
print(result.state) # "converged" | "oscillating" | "bistable" | "diverging"
|
|
49
|
+
print(result.quality) # 0.0 — 1.0
|
|
50
|
+
print(result.converged_at) # step where convergence was detected
|
|
51
|
+
print(result.n_modes) # number of distinct stable states
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Monitor a running simulation
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from convergio import watch
|
|
58
|
+
|
|
59
|
+
mon = watch(var_threshold=1e-6)
|
|
60
|
+
|
|
61
|
+
for step in range(10000):
|
|
62
|
+
residual = solver.step()
|
|
63
|
+
stop, info = mon.step(residual)
|
|
64
|
+
if stop:
|
|
65
|
+
print(f"Converged at step {step}")
|
|
66
|
+
break
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What it detects
|
|
70
|
+
|
|
71
|
+
| State | Meaning | Recommendation |
|
|
72
|
+
|-------|---------|----------------|
|
|
73
|
+
| `converged` | Signal has stabilized | Stop |
|
|
74
|
+
| `oscillating` | Signal oscillates without settling | Continue or adjust parameters |
|
|
75
|
+
| `bistable` | Signal jumps between 2+ distinct states | Investigate — multiple solutions exist |
|
|
76
|
+
| `diverging` | Variance is growing | Restart with different parameters |
|
|
77
|
+
| `warming_up` | Not enough data yet | Continue |
|
|
78
|
+
|
|
79
|
+
## Works with
|
|
80
|
+
|
|
81
|
+
- FEM / CFD solvers (residual monitoring)
|
|
82
|
+
- ML training (loss convergence)
|
|
83
|
+
- Monte Carlo simulations (running averages)
|
|
84
|
+
- Optimization loops (objective function)
|
|
85
|
+
- Molecular dynamics (energy equilibration)
|
|
86
|
+
- Any iterative numerical process that produces a scalar time series
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `detect(signal, window=0, var_threshold=1e-6)`
|
|
91
|
+
|
|
92
|
+
Analyze a complete time series. Returns `ConvergenceResult`.
|
|
93
|
+
|
|
94
|
+
### `watch(var_threshold=1e-6, check_every=50, min_steps=100)`
|
|
95
|
+
|
|
96
|
+
Create a live monitor. Returns `Watch` object. Call `.step(value)` each iteration.
|
|
97
|
+
|
|
98
|
+
### `ConvergenceResult`
|
|
99
|
+
|
|
100
|
+
| Field | Type | Description |
|
|
101
|
+
|-------|------|-------------|
|
|
102
|
+
| `state` | str | converged, oscillating, bistable, diverging, warming_up |
|
|
103
|
+
| `quality` | float | 0.0 — 1.0 convergence quality |
|
|
104
|
+
| `converged_at` | int or None | Step where convergence detected |
|
|
105
|
+
| `final_variance` | float | Variance of last window |
|
|
106
|
+
| `oscillation_freq` | float | Detected oscillation frequency |
|
|
107
|
+
| `n_modes` | int | Number of distinct stable states |
|
|
108
|
+
| `recommendation` | str | stop, continue, restart |
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
113
|
+
|
|
114
|
+
## Author
|
|
115
|
+
|
|
116
|
+
Maximilian Jurak — 3 Kaiserberge Engineering & Research
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Convergio
|
|
2
|
+
|
|
3
|
+
**Automatic convergence detection for iterative numerical methods.**
|
|
4
|
+
|
|
5
|
+
Stop wasting compute. One function call tells you if your solver has converged, is oscillating, is stuck between two states, or is diverging.
|
|
6
|
+
|
|
7
|
+
*"Your solver finished 3000 iterations ago."*
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install convergio
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Only dependency: NumPy.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Analyze a completed run
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from convergio import detect
|
|
23
|
+
|
|
24
|
+
result = detect(my_residual_history)
|
|
25
|
+
|
|
26
|
+
print(result.state) # "converged" | "oscillating" | "bistable" | "diverging"
|
|
27
|
+
print(result.quality) # 0.0 — 1.0
|
|
28
|
+
print(result.converged_at) # step where convergence was detected
|
|
29
|
+
print(result.n_modes) # number of distinct stable states
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Monitor a running simulation
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from convergio import watch
|
|
36
|
+
|
|
37
|
+
mon = watch(var_threshold=1e-6)
|
|
38
|
+
|
|
39
|
+
for step in range(10000):
|
|
40
|
+
residual = solver.step()
|
|
41
|
+
stop, info = mon.step(residual)
|
|
42
|
+
if stop:
|
|
43
|
+
print(f"Converged at step {step}")
|
|
44
|
+
break
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## What it detects
|
|
48
|
+
|
|
49
|
+
| State | Meaning | Recommendation |
|
|
50
|
+
|-------|---------|----------------|
|
|
51
|
+
| `converged` | Signal has stabilized | Stop |
|
|
52
|
+
| `oscillating` | Signal oscillates without settling | Continue or adjust parameters |
|
|
53
|
+
| `bistable` | Signal jumps between 2+ distinct states | Investigate — multiple solutions exist |
|
|
54
|
+
| `diverging` | Variance is growing | Restart with different parameters |
|
|
55
|
+
| `warming_up` | Not enough data yet | Continue |
|
|
56
|
+
|
|
57
|
+
## Works with
|
|
58
|
+
|
|
59
|
+
- FEM / CFD solvers (residual monitoring)
|
|
60
|
+
- ML training (loss convergence)
|
|
61
|
+
- Monte Carlo simulations (running averages)
|
|
62
|
+
- Optimization loops (objective function)
|
|
63
|
+
- Molecular dynamics (energy equilibration)
|
|
64
|
+
- Any iterative numerical process that produces a scalar time series
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `detect(signal, window=0, var_threshold=1e-6)`
|
|
69
|
+
|
|
70
|
+
Analyze a complete time series. Returns `ConvergenceResult`.
|
|
71
|
+
|
|
72
|
+
### `watch(var_threshold=1e-6, check_every=50, min_steps=100)`
|
|
73
|
+
|
|
74
|
+
Create a live monitor. Returns `Watch` object. Call `.step(value)` each iteration.
|
|
75
|
+
|
|
76
|
+
### `ConvergenceResult`
|
|
77
|
+
|
|
78
|
+
| Field | Type | Description |
|
|
79
|
+
|-------|------|-------------|
|
|
80
|
+
| `state` | str | converged, oscillating, bistable, diverging, warming_up |
|
|
81
|
+
| `quality` | float | 0.0 — 1.0 convergence quality |
|
|
82
|
+
| `converged_at` | int or None | Step where convergence detected |
|
|
83
|
+
| `final_variance` | float | Variance of last window |
|
|
84
|
+
| `oscillation_freq` | float | Detected oscillation frequency |
|
|
85
|
+
| `n_modes` | int | Number of distinct stable states |
|
|
86
|
+
| `recommendation` | str | stop, continue, restart |
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
91
|
+
|
|
92
|
+
## Author
|
|
93
|
+
|
|
94
|
+
Maximilian Jurak — 3 Kaiserberge Engineering & Research
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convergio — Automatic Convergence Detection for Iterative Methods
|
|
3
|
+
================================================================
|
|
4
|
+
Detects convergence, oscillation, bistability, and divergence
|
|
5
|
+
in any numerical time series. One function call. No configuration needed.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from convergio import detect, watch
|
|
9
|
+
|
|
10
|
+
# Analyze a completed run
|
|
11
|
+
result = detect(my_time_series)
|
|
12
|
+
print(result.state) # "converged" | "oscillating" | "bistable" | "diverging"
|
|
13
|
+
print(result.converged_at) # step where convergence was detected
|
|
14
|
+
print(result.quality) # 0.0 - 1.0
|
|
15
|
+
|
|
16
|
+
# Monitor a running simulation
|
|
17
|
+
mon = watch(var_threshold=1e-6)
|
|
18
|
+
for step in range(10000):
|
|
19
|
+
value = my_solver_step()
|
|
20
|
+
should_stop, info = mon.step(value)
|
|
21
|
+
if should_stop:
|
|
22
|
+
print(f"Converged at step {step}, saved {10000-step} steps")
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
(c) 2026 Maximilian Jurak — 3 Kaiserberge Engineering & Research
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
__author__ = "Maximilian Jurak"
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from typing import Optional, Tuple, List, Union
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================
|
|
37
|
+
# Result Object
|
|
38
|
+
# =============================================================
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ConvergenceResult:
|
|
42
|
+
"""Result of convergence analysis."""
|
|
43
|
+
|
|
44
|
+
state: str
|
|
45
|
+
"""One of: 'converged', 'oscillating', 'bistable', 'diverging', 'warming_up'"""
|
|
46
|
+
|
|
47
|
+
quality: float
|
|
48
|
+
"""Convergence quality score from 0.0 (no convergence) to 1.0 (perfect)."""
|
|
49
|
+
|
|
50
|
+
converged_at: Optional[int]
|
|
51
|
+
"""Step index where convergence was first detected. None if not converged."""
|
|
52
|
+
|
|
53
|
+
final_variance: float
|
|
54
|
+
"""Variance of the signal in the last analysis window."""
|
|
55
|
+
|
|
56
|
+
oscillation_freq: float
|
|
57
|
+
"""Detected oscillation frequency (0.0 if not oscillating)."""
|
|
58
|
+
|
|
59
|
+
n_modes: int
|
|
60
|
+
"""Number of distinct stable modes detected (1 = unimodal, 2+ = multimodal)."""
|
|
61
|
+
|
|
62
|
+
recommendation: str
|
|
63
|
+
"""One of: 'stop', 'continue', 'restart'."""
|
|
64
|
+
|
|
65
|
+
saved_steps: int = 0
|
|
66
|
+
"""Steps saved by early stopping (0 if not applicable)."""
|
|
67
|
+
|
|
68
|
+
total_steps: int = 0
|
|
69
|
+
"""Total steps in the analyzed series."""
|
|
70
|
+
|
|
71
|
+
def __repr__(self):
|
|
72
|
+
return (f"ConvergenceResult(state='{self.state}', quality={self.quality:.2f}, "
|
|
73
|
+
f"converged_at={self.converged_at}, recommendation='{self.recommendation}')")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# =============================================================
|
|
77
|
+
# Core Analysis
|
|
78
|
+
# =============================================================
|
|
79
|
+
|
|
80
|
+
def rolling_variance(signal: np.ndarray, window: int = 50) -> np.ndarray:
|
|
81
|
+
"""
|
|
82
|
+
Compute rolling variance of a signal.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
signal: 1D array of values
|
|
86
|
+
window: window size for variance computation
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Array of rolling variance values
|
|
90
|
+
"""
|
|
91
|
+
signal = np.asarray(signal, dtype=float)
|
|
92
|
+
n = len(signal)
|
|
93
|
+
|
|
94
|
+
if n < window:
|
|
95
|
+
return np.array([np.var(signal)])
|
|
96
|
+
|
|
97
|
+
# Efficient rolling variance using cumulative sums
|
|
98
|
+
result = np.zeros(n - window + 1)
|
|
99
|
+
for i in range(len(result)):
|
|
100
|
+
result[i] = np.var(signal[i:i + window])
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def count_modes(signal: np.ndarray, n_bins: int = 30) -> int:
|
|
106
|
+
"""
|
|
107
|
+
Count distinct modes (peaks in distribution) of signal values.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
signal: 1D array of values
|
|
111
|
+
n_bins: number of histogram bins
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Number of detected modes (>= 1)
|
|
115
|
+
"""
|
|
116
|
+
signal = np.asarray(signal, dtype=float)
|
|
117
|
+
|
|
118
|
+
if len(signal) < 10:
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
hist, _ = np.histogram(signal, bins=n_bins)
|
|
122
|
+
hist_smooth = np.convolve(hist, [0.25, 0.5, 0.25], mode='same')
|
|
123
|
+
|
|
124
|
+
peaks = 0
|
|
125
|
+
threshold = np.max(hist_smooth) * 0.15
|
|
126
|
+
|
|
127
|
+
for i in range(1, len(hist_smooth) - 1):
|
|
128
|
+
if (hist_smooth[i] > hist_smooth[i - 1] and
|
|
129
|
+
hist_smooth[i] > hist_smooth[i + 1] and
|
|
130
|
+
hist_smooth[i] > threshold):
|
|
131
|
+
peaks += 1
|
|
132
|
+
|
|
133
|
+
return max(1, peaks)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def detect(
|
|
137
|
+
signal: Union[list, np.ndarray],
|
|
138
|
+
window: int = 0,
|
|
139
|
+
var_threshold: float = 1e-6,
|
|
140
|
+
osc_threshold: float = 0.3,
|
|
141
|
+
) -> ConvergenceResult:
|
|
142
|
+
"""
|
|
143
|
+
Analyze a time series for convergence.
|
|
144
|
+
|
|
145
|
+
This is the main entry point. Pass any 1D numerical time series
|
|
146
|
+
and get back a complete convergence analysis.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
signal: 1D array of scalar values (residuals, energies, loss, etc.)
|
|
150
|
+
window: analysis window size (0 = auto, typically 5-10% of signal length)
|
|
151
|
+
var_threshold: variance below this = converged
|
|
152
|
+
osc_threshold: zero-crossing frequency above this = oscillating
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
ConvergenceResult with state, quality, recommendation, etc.
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
>>> import numpy as np
|
|
159
|
+
>>> from convergio import detect
|
|
160
|
+
>>>
|
|
161
|
+
>>> # Converging signal
|
|
162
|
+
>>> signal = np.exp(-np.linspace(0, 5, 1000)) + np.random.randn(1000) * 0.001
|
|
163
|
+
>>> result = detect(signal)
|
|
164
|
+
>>> result.state
|
|
165
|
+
'converged'
|
|
166
|
+
>>>
|
|
167
|
+
>>> # Oscillating signal
|
|
168
|
+
>>> signal = np.sin(np.linspace(0, 50, 1000))
|
|
169
|
+
>>> result = detect(signal)
|
|
170
|
+
>>> result.state
|
|
171
|
+
'oscillating'
|
|
172
|
+
"""
|
|
173
|
+
signal = np.asarray(signal, dtype=float)
|
|
174
|
+
n = len(signal)
|
|
175
|
+
|
|
176
|
+
# Auto window size
|
|
177
|
+
if window <= 0:
|
|
178
|
+
window = max(10, min(200, n // 10))
|
|
179
|
+
|
|
180
|
+
# Not enough data
|
|
181
|
+
if n < window * 2:
|
|
182
|
+
return ConvergenceResult(
|
|
183
|
+
state="warming_up",
|
|
184
|
+
quality=0.0,
|
|
185
|
+
converged_at=None,
|
|
186
|
+
final_variance=float(np.var(signal)) if n > 1 else 0.0,
|
|
187
|
+
oscillation_freq=0.0,
|
|
188
|
+
n_modes=1,
|
|
189
|
+
recommendation="continue",
|
|
190
|
+
total_steps=n,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# --- Rolling variance ---
|
|
194
|
+
rv = rolling_variance(signal, window)
|
|
195
|
+
final_var = float(rv[-1]) if len(rv) > 0 else float(np.var(signal))
|
|
196
|
+
|
|
197
|
+
# --- Check divergence (variance growing or signal unbounded) ---
|
|
198
|
+
if len(rv) > 10:
|
|
199
|
+
tail = rv[-min(20, len(rv)):]
|
|
200
|
+
trend = np.polyfit(np.arange(len(tail)), tail, 1)[0]
|
|
201
|
+
|
|
202
|
+
# Compare first and last quarter variance
|
|
203
|
+
q1 = np.mean(rv[:max(1, len(rv) // 4)])
|
|
204
|
+
q4 = np.mean(rv[-max(1, len(rv) // 4):])
|
|
205
|
+
|
|
206
|
+
# Also check if signal magnitude itself is growing (random walk)
|
|
207
|
+
first_quarter_range = np.ptp(signal[:n // 4]) if n > 4 else 0
|
|
208
|
+
last_quarter_range = np.ptp(signal[-n // 4:]) if n > 4 else 0
|
|
209
|
+
|
|
210
|
+
is_growing = (
|
|
211
|
+
(trend > 0 and q4 > q1 * 2) or
|
|
212
|
+
(last_quarter_range > first_quarter_range * 2 and final_var > var_threshold * 10) or
|
|
213
|
+
(final_var > var_threshold * 1000 and trend > 0)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if is_growing and final_var > var_threshold * 10:
|
|
217
|
+
return ConvergenceResult(
|
|
218
|
+
state="diverging",
|
|
219
|
+
quality=0.0,
|
|
220
|
+
converged_at=None,
|
|
221
|
+
final_variance=final_var,
|
|
222
|
+
oscillation_freq=0.0,
|
|
223
|
+
n_modes=1,
|
|
224
|
+
recommendation="restart",
|
|
225
|
+
total_steps=n,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# --- Find convergence point ---
|
|
229
|
+
converged_at = None
|
|
230
|
+
for i, v in enumerate(rv):
|
|
231
|
+
if v < var_threshold:
|
|
232
|
+
converged_at = i + window
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
# --- Oscillation detection ---
|
|
236
|
+
detrended = signal - np.mean(signal)
|
|
237
|
+
zero_crossings = int(np.sum(np.diff(np.sign(detrended)) != 0))
|
|
238
|
+
osc_freq = zero_crossings / (2.0 * n)
|
|
239
|
+
|
|
240
|
+
# Check if variance is stable (not decreasing) — sign of sustained oscillation
|
|
241
|
+
variance_stable = False
|
|
242
|
+
if len(rv) > 4:
|
|
243
|
+
first_half_var = np.mean(rv[:len(rv) // 2])
|
|
244
|
+
second_half_var = np.mean(rv[len(rv) // 2:])
|
|
245
|
+
if first_half_var > 0 and second_half_var / (first_half_var + 1e-30) > 0.5:
|
|
246
|
+
variance_stable = True
|
|
247
|
+
|
|
248
|
+
# Oscillating = enough zero crossings + variance not decreasing + not converged
|
|
249
|
+
is_oscillating = (zero_crossings > n * 0.01 and
|
|
250
|
+
variance_stable and
|
|
251
|
+
final_var > var_threshold)
|
|
252
|
+
|
|
253
|
+
# --- Mode count (bistability) ---
|
|
254
|
+
tail_len = min(n, window * 4)
|
|
255
|
+
n_modes = count_modes(signal[-tail_len:])
|
|
256
|
+
|
|
257
|
+
# --- Classify ---
|
|
258
|
+
if final_var < var_threshold:
|
|
259
|
+
state = "converged"
|
|
260
|
+
quality = min(1.0, 1.0 - final_var / var_threshold)
|
|
261
|
+
recommendation = "stop"
|
|
262
|
+
elif n_modes >= 2 and not is_oscillating:
|
|
263
|
+
state = "bistable"
|
|
264
|
+
quality = 0.3
|
|
265
|
+
recommendation = "continue"
|
|
266
|
+
elif is_oscillating:
|
|
267
|
+
state = "oscillating"
|
|
268
|
+
quality = max(0.1, 0.5 * (1.0 - min(1.0, final_var / (var_threshold * 100))))
|
|
269
|
+
recommendation = "continue"
|
|
270
|
+
else:
|
|
271
|
+
# Slow convergence or unclear
|
|
272
|
+
if converged_at is not None:
|
|
273
|
+
state = "converged"
|
|
274
|
+
quality = min(1.0, 1.0 - final_var / var_threshold)
|
|
275
|
+
recommendation = "stop"
|
|
276
|
+
else:
|
|
277
|
+
state = "unconverged"
|
|
278
|
+
quality = max(0.0, 1.0 - min(1.0, final_var / (var_threshold * 1000)))
|
|
279
|
+
recommendation = "continue"
|
|
280
|
+
|
|
281
|
+
return ConvergenceResult(
|
|
282
|
+
state=state,
|
|
283
|
+
quality=quality,
|
|
284
|
+
converged_at=converged_at,
|
|
285
|
+
final_variance=final_var,
|
|
286
|
+
oscillation_freq=osc_freq,
|
|
287
|
+
n_modes=n_modes,
|
|
288
|
+
recommendation=recommendation,
|
|
289
|
+
total_steps=n,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# =============================================================
|
|
294
|
+
# Live Monitor (for running simulations)
|
|
295
|
+
# =============================================================
|
|
296
|
+
|
|
297
|
+
class Watch:
|
|
298
|
+
"""
|
|
299
|
+
Live convergence monitor for running simulations.
|
|
300
|
+
|
|
301
|
+
Call .step(value) every iteration. Returns (should_stop, result).
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
mon = Watch(var_threshold=1e-6, check_every=100, min_steps=200)
|
|
305
|
+
for i in range(10000):
|
|
306
|
+
residual = solver.step()
|
|
307
|
+
stop, info = mon.step(residual)
|
|
308
|
+
if stop:
|
|
309
|
+
print(f"Converged at step {i}")
|
|
310
|
+
break
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
var_threshold: float = 1e-6,
|
|
316
|
+
check_every: int = 50,
|
|
317
|
+
min_steps: int = 100,
|
|
318
|
+
window: int = 0,
|
|
319
|
+
):
|
|
320
|
+
self.var_threshold = var_threshold
|
|
321
|
+
self.check_every = check_every
|
|
322
|
+
self.min_steps = min_steps
|
|
323
|
+
self.window = window
|
|
324
|
+
|
|
325
|
+
self._history: List[float] = []
|
|
326
|
+
self._step_count = 0
|
|
327
|
+
self._converged = False
|
|
328
|
+
self._last_result: Optional[ConvergenceResult] = None
|
|
329
|
+
|
|
330
|
+
def step(self, value: float) -> Tuple[bool, Optional[ConvergenceResult]]:
|
|
331
|
+
"""
|
|
332
|
+
Feed one value. Returns (should_stop, result_or_None).
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
value: current scalar value (residual, energy, loss, etc.)
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
(True, ConvergenceResult) if should stop
|
|
339
|
+
(False, None) if should continue
|
|
340
|
+
(False, ConvergenceResult) at check points
|
|
341
|
+
"""
|
|
342
|
+
self._history.append(float(value))
|
|
343
|
+
self._step_count += 1
|
|
344
|
+
|
|
345
|
+
if self._converged:
|
|
346
|
+
return True, self._last_result
|
|
347
|
+
|
|
348
|
+
if self._step_count < self.min_steps:
|
|
349
|
+
return False, None
|
|
350
|
+
|
|
351
|
+
if self._step_count % self.check_every != 0:
|
|
352
|
+
return False, None
|
|
353
|
+
|
|
354
|
+
# Analyze
|
|
355
|
+
result = detect(
|
|
356
|
+
np.array(self._history),
|
|
357
|
+
window=self.window,
|
|
358
|
+
var_threshold=self.var_threshold,
|
|
359
|
+
)
|
|
360
|
+
result.total_steps = self._step_count
|
|
361
|
+
|
|
362
|
+
self._last_result = result
|
|
363
|
+
|
|
364
|
+
if result.recommendation == "stop":
|
|
365
|
+
self._converged = True
|
|
366
|
+
result.converged_at = self._step_count
|
|
367
|
+
return True, result
|
|
368
|
+
|
|
369
|
+
return False, result
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def history(self) -> np.ndarray:
|
|
373
|
+
"""Return the full recorded history."""
|
|
374
|
+
return np.array(self._history)
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def n_steps(self) -> int:
|
|
378
|
+
"""Number of steps recorded."""
|
|
379
|
+
return self._step_count
|
|
380
|
+
|
|
381
|
+
def reset(self):
|
|
382
|
+
"""Reset the monitor for a new run."""
|
|
383
|
+
self._history = []
|
|
384
|
+
self._step_count = 0
|
|
385
|
+
self._converged = False
|
|
386
|
+
self._last_result = None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def watch(
|
|
390
|
+
var_threshold: float = 1e-6,
|
|
391
|
+
check_every: int = 50,
|
|
392
|
+
min_steps: int = 100,
|
|
393
|
+
window: int = 0,
|
|
394
|
+
) -> Watch:
|
|
395
|
+
"""
|
|
396
|
+
Create a live convergence monitor.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
var_threshold: variance below this = converged
|
|
400
|
+
check_every: check convergence every N steps
|
|
401
|
+
min_steps: minimum steps before first check
|
|
402
|
+
window: analysis window (0 = auto)
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Watch instance. Call .step(value) in your loop.
|
|
406
|
+
"""
|
|
407
|
+
return Watch(
|
|
408
|
+
var_threshold=var_threshold,
|
|
409
|
+
check_every=check_every,
|
|
410
|
+
min_steps=min_steps,
|
|
411
|
+
window=window,
|
|
412
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: convergio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic convergence detection for iterative numerical methods. Stop wasting compute.
|
|
5
|
+
Author: Maximilian Jurak
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/maxjurak/convergio
|
|
8
|
+
Keywords: convergence,simulation,solver,early-stopping,FEM,CFD,optimization
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.20
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Convergio
|
|
24
|
+
|
|
25
|
+
**Automatic convergence detection for iterative numerical methods.**
|
|
26
|
+
|
|
27
|
+
Stop wasting compute. One function call tells you if your solver has converged, is oscillating, is stuck between two states, or is diverging.
|
|
28
|
+
|
|
29
|
+
*"Your solver finished 3000 iterations ago."*
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install convergio
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Only dependency: NumPy.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Analyze a completed run
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from convergio import detect
|
|
45
|
+
|
|
46
|
+
result = detect(my_residual_history)
|
|
47
|
+
|
|
48
|
+
print(result.state) # "converged" | "oscillating" | "bistable" | "diverging"
|
|
49
|
+
print(result.quality) # 0.0 — 1.0
|
|
50
|
+
print(result.converged_at) # step where convergence was detected
|
|
51
|
+
print(result.n_modes) # number of distinct stable states
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Monitor a running simulation
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from convergio import watch
|
|
58
|
+
|
|
59
|
+
mon = watch(var_threshold=1e-6)
|
|
60
|
+
|
|
61
|
+
for step in range(10000):
|
|
62
|
+
residual = solver.step()
|
|
63
|
+
stop, info = mon.step(residual)
|
|
64
|
+
if stop:
|
|
65
|
+
print(f"Converged at step {step}")
|
|
66
|
+
break
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What it detects
|
|
70
|
+
|
|
71
|
+
| State | Meaning | Recommendation |
|
|
72
|
+
|-------|---------|----------------|
|
|
73
|
+
| `converged` | Signal has stabilized | Stop |
|
|
74
|
+
| `oscillating` | Signal oscillates without settling | Continue or adjust parameters |
|
|
75
|
+
| `bistable` | Signal jumps between 2+ distinct states | Investigate — multiple solutions exist |
|
|
76
|
+
| `diverging` | Variance is growing | Restart with different parameters |
|
|
77
|
+
| `warming_up` | Not enough data yet | Continue |
|
|
78
|
+
|
|
79
|
+
## Works with
|
|
80
|
+
|
|
81
|
+
- FEM / CFD solvers (residual monitoring)
|
|
82
|
+
- ML training (loss convergence)
|
|
83
|
+
- Monte Carlo simulations (running averages)
|
|
84
|
+
- Optimization loops (objective function)
|
|
85
|
+
- Molecular dynamics (energy equilibration)
|
|
86
|
+
- Any iterative numerical process that produces a scalar time series
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `detect(signal, window=0, var_threshold=1e-6)`
|
|
91
|
+
|
|
92
|
+
Analyze a complete time series. Returns `ConvergenceResult`.
|
|
93
|
+
|
|
94
|
+
### `watch(var_threshold=1e-6, check_every=50, min_steps=100)`
|
|
95
|
+
|
|
96
|
+
Create a live monitor. Returns `Watch` object. Call `.step(value)` each iteration.
|
|
97
|
+
|
|
98
|
+
### `ConvergenceResult`
|
|
99
|
+
|
|
100
|
+
| Field | Type | Description |
|
|
101
|
+
|-------|------|-------------|
|
|
102
|
+
| `state` | str | converged, oscillating, bistable, diverging, warming_up |
|
|
103
|
+
| `quality` | float | 0.0 — 1.0 convergence quality |
|
|
104
|
+
| `converged_at` | int or None | Step where convergence detected |
|
|
105
|
+
| `final_variance` | float | Variance of last window |
|
|
106
|
+
| `oscillation_freq` | float | Detected oscillation frequency |
|
|
107
|
+
| `n_modes` | int | Number of distinct stable states |
|
|
108
|
+
| `recommendation` | str | stop, continue, restart |
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
113
|
+
|
|
114
|
+
## Author
|
|
115
|
+
|
|
116
|
+
Maximilian Jurak — 3 Kaiserberge Engineering & Research
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
convergio/__init__.py
|
|
5
|
+
convergio.egg-info/PKG-INFO
|
|
6
|
+
convergio.egg-info/SOURCES.txt
|
|
7
|
+
convergio.egg-info/dependency_links.txt
|
|
8
|
+
convergio.egg-info/requires.txt
|
|
9
|
+
convergio.egg-info/top_level.txt
|
|
10
|
+
tests/test_convergio.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
numpy>=1.20
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
convergio
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "convergio"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Automatic convergence detection for iterative numerical methods. Stop wasting compute."
|
|
9
|
+
authors = [{name = "Maximilian Jurak"}]
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
dependencies = ["numpy>=1.20"]
|
|
14
|
+
keywords = ["convergence", "simulation", "solver", "early-stopping", "FEM", "CFD", "optimization"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Scientific/Engineering",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/maxjurak/convergio"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Tests for OptionE convergence detection."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
8
|
+
from convergio import detect, watch, rolling_variance, count_modes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_converging_signal():
|
|
12
|
+
signal = np.exp(-np.linspace(0, 10, 1000))
|
|
13
|
+
result = detect(signal, var_threshold=1e-6)
|
|
14
|
+
assert result.state == "converged", f"Expected converged, got {result.state}"
|
|
15
|
+
assert result.converged_at is not None
|
|
16
|
+
assert result.quality > 0.5
|
|
17
|
+
assert result.recommendation == "stop"
|
|
18
|
+
print("PASS: converging signal")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_oscillating_signal():
|
|
22
|
+
signal = np.sin(np.linspace(0, 100, 2000))
|
|
23
|
+
result = detect(signal, var_threshold=1e-6)
|
|
24
|
+
assert result.state == "oscillating", f"Expected oscillating, got {result.state}"
|
|
25
|
+
assert result.oscillation_freq > 0.001, f"Expected osc_freq > 0.001, got {result.oscillation_freq}"
|
|
26
|
+
assert result.recommendation == "continue"
|
|
27
|
+
print("PASS: oscillating signal")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_diverging_signal():
|
|
31
|
+
np.random.seed(42)
|
|
32
|
+
signal = np.cumsum(np.random.randn(1000) * 0.5)
|
|
33
|
+
result = detect(signal, var_threshold=1e-4)
|
|
34
|
+
assert result.state == "diverging", f"Expected diverging, got {result.state}"
|
|
35
|
+
assert result.recommendation == "restart"
|
|
36
|
+
print("PASS: diverging signal")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_constant_signal():
|
|
40
|
+
signal = np.ones(500) * 3.14 + np.random.randn(500) * 1e-8
|
|
41
|
+
result = detect(signal, var_threshold=1e-6)
|
|
42
|
+
assert result.state == "converged", f"Expected converged, got {result.state}"
|
|
43
|
+
assert result.quality > 0.9
|
|
44
|
+
print("PASS: constant signal")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_bistable_signal():
|
|
48
|
+
np.random.seed(42)
|
|
49
|
+
signal = np.zeros(2000)
|
|
50
|
+
state = 1.0
|
|
51
|
+
for i in range(2000):
|
|
52
|
+
if np.random.random() < 0.02:
|
|
53
|
+
state = -state
|
|
54
|
+
signal[i] = state + np.random.randn() * 0.05
|
|
55
|
+
result = detect(signal, var_threshold=0.001)
|
|
56
|
+
assert result.n_modes >= 2, f"Expected >=2 modes, got {result.n_modes}"
|
|
57
|
+
print("PASS: bistable signal")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_short_signal():
|
|
61
|
+
signal = [1.0, 2.0, 3.0]
|
|
62
|
+
result = detect(signal)
|
|
63
|
+
assert result.state == "warming_up"
|
|
64
|
+
assert result.recommendation == "continue"
|
|
65
|
+
print("PASS: short signal")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_rolling_variance():
|
|
69
|
+
signal = np.ones(100)
|
|
70
|
+
rv = rolling_variance(signal, window=10)
|
|
71
|
+
assert len(rv) == 91
|
|
72
|
+
assert np.all(rv < 1e-10)
|
|
73
|
+
print("PASS: rolling variance")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_count_modes_unimodal():
|
|
77
|
+
signal = np.random.randn(1000) + 5.0
|
|
78
|
+
n = count_modes(signal)
|
|
79
|
+
assert n == 1, f"Expected 1 mode, got {n}"
|
|
80
|
+
print("PASS: count modes unimodal")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_count_modes_bimodal():
|
|
84
|
+
signal = np.concatenate([
|
|
85
|
+
np.random.randn(500) - 3.0,
|
|
86
|
+
np.random.randn(500) + 3.0
|
|
87
|
+
])
|
|
88
|
+
n = count_modes(signal)
|
|
89
|
+
assert n >= 2, f"Expected >=2 modes, got {n}"
|
|
90
|
+
print("PASS: count modes bimodal")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_watch_early_stopping():
|
|
94
|
+
mon = watch(var_threshold=1e-6, check_every=50, min_steps=100)
|
|
95
|
+
stopped = False
|
|
96
|
+
for i in range(5000):
|
|
97
|
+
value = 10.0 * np.exp(-i / 100.0) + np.random.randn() * 1e-8
|
|
98
|
+
stop, result = mon.step(value)
|
|
99
|
+
if stop:
|
|
100
|
+
stopped = True
|
|
101
|
+
assert i < 2000, f"Should have stopped much earlier, stopped at {i}"
|
|
102
|
+
break
|
|
103
|
+
assert stopped, "Watch should have triggered early stopping"
|
|
104
|
+
print(f"PASS: watch early stopping (stopped at step {i})")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_watch_reset():
|
|
108
|
+
mon = watch(var_threshold=1e-4)
|
|
109
|
+
for i in range(200):
|
|
110
|
+
mon.step(float(i))
|
|
111
|
+
assert mon.n_steps == 200
|
|
112
|
+
mon.reset()
|
|
113
|
+
assert mon.n_steps == 0
|
|
114
|
+
print("PASS: watch reset")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
tests = [
|
|
119
|
+
test_converging_signal,
|
|
120
|
+
test_oscillating_signal,
|
|
121
|
+
test_diverging_signal,
|
|
122
|
+
test_constant_signal,
|
|
123
|
+
test_bistable_signal,
|
|
124
|
+
test_short_signal,
|
|
125
|
+
test_rolling_variance,
|
|
126
|
+
test_count_modes_unimodal,
|
|
127
|
+
test_count_modes_bimodal,
|
|
128
|
+
test_watch_early_stopping,
|
|
129
|
+
test_watch_reset,
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
passed = 0
|
|
133
|
+
failed = 0
|
|
134
|
+
|
|
135
|
+
for t in tests:
|
|
136
|
+
try:
|
|
137
|
+
t()
|
|
138
|
+
passed += 1
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"FAIL: {t.__name__}: {e}")
|
|
141
|
+
failed += 1
|
|
142
|
+
|
|
143
|
+
print(f"\n{'='*40}")
|
|
144
|
+
print(f"Results: {passed} passed, {failed} failed")
|
|
145
|
+
print(f"{'='*40}")
|