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.
@@ -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.
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}")