Skip to content

Commit a45d669

Browse files
committed
Added smoothing eta to fix #280. The previous algorithm could be really jumpy in some cases and has been replaced with an exponential moving average. Double exponential moving average is also available
1 parent 6984576 commit a45d669

4 files changed

Lines changed: 182 additions & 14 deletions

File tree

progressbar/algorithms.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
import abc
4+
from datetime import timedelta
5+
6+
7+
class SmoothingAlgorithm(abc.ABC):
8+
9+
@abc.abstractmethod
10+
def __init__(self, **kwargs):
11+
raise NotImplementedError
12+
13+
@abc.abstractmethod
14+
def update(self, new_value: float, elapsed: timedelta) -> float:
15+
'''Updates the algorithm with a new value and returns the smoothed
16+
value.
17+
'''
18+
raise NotImplementedError
19+
20+
21+
class ExponentialMovingAverage(SmoothingAlgorithm):
22+
'''
23+
The Exponential Moving Average (EMA) is an exponentially weighted moving
24+
average that reduces the lag that's typically associated with a simple
25+
moving average. It's more responsive to recent changes in data.
26+
'''
27+
28+
def __init__(self, alpha: float = 0.5) -> None:
29+
self.alpha = alpha
30+
self.value = 0
31+
32+
def update(self, new_value: float, elapsed: timedelta) -> float:
33+
self.value = self.alpha * new_value + (1 - self.alpha) * self.value
34+
return self.value
35+
36+
37+
class DoubleExponentialMovingAverage(SmoothingAlgorithm):
38+
'''
39+
The Double Exponential Moving Average (DEMA) is essentially an EMA of an
40+
EMA, which reduces the lag that's typically associated with a simple EMA.
41+
It's more responsive to recent changes in data.
42+
'''
43+
44+
def __init__(self, alpha: float=0.5) -> None:
45+
self.alpha = alpha
46+
self.ema1 = 0
47+
self.ema2 = 0
48+
49+
def update(self, new_value: float, elapsed: timedelta) -> float:
50+
self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1
51+
self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2
52+
return 2 * self.ema1 - self.ema2
53+

tests/test_algorithms.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import pytest
2+
from datetime import timedelta
3+
4+
from progressbar import algorithms
5+
6+
7+
def test_ema_initialization():
8+
ema = algorithms.ExponentialMovingAverage()
9+
assert ema.alpha == 0.5
10+
assert ema.value == 0
11+
12+
@pytest.mark.parametrize('alpha, new_value, expected', [
13+
(0.5, 10, 5),
14+
(0.1, 20, 2),
15+
(0.9, 30, 27),
16+
(0.3, 15, 4.5),
17+
(0.7, 40, 28),
18+
(0.5, 0, 0),
19+
(0.2, 100, 20),
20+
(0.8, 50, 40)
21+
])
22+
def test_ema_update(alpha, new_value, expected):
23+
ema = algorithms.ExponentialMovingAverage(alpha)
24+
result = ema.update(new_value, timedelta(seconds=1))
25+
assert result == expected
26+
27+
def test_dema_initialization():
28+
dema = algorithms.DoubleExponentialMovingAverage()
29+
assert dema.alpha == 0.5
30+
assert dema.ema1 == 0
31+
assert dema.ema2 == 0
32+
33+
@pytest.mark.parametrize('alpha, new_value, expected', [
34+
(0.5, 10, 7.5),
35+
(0.1, 20, 3.8),
36+
(0.9, 30, 29.7),
37+
(0.3, 15, 7.65),
38+
(0.5, 0, 0),
39+
(0.2, 100, 36.0),
40+
(0.8, 50, 48.0)
41+
])
42+
def test_dema_update(alpha, new_value, expected):
43+
dema = algorithms.DoubleExponentialMovingAverage(alpha)
44+
result = dema.update(new_value, timedelta(seconds=1))
45+
assert result == expected
46+
47+
# Additional test functions can be added here as needed.

tests/test_monitor_progress.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,18 @@ def test_rapid_updates(testdir):
140140
)
141141
result.stderr.lines = _non_empty_lines(result.stderr.lines)
142142
pprint.pprint(result.stderr.lines, width=70)
143-
result.stderr.fnmatch_lines(
144-
[
145-
' 0% (0 of 10) | | Elapsed Time: ?:00:00 ETA: --:--:--',
146-
' 10% (1 of 10) | | Elapsed Time: ?:00:01 ETA: ?:00:09',
147-
' 20% (2 of 10) |# | Elapsed Time: ?:00:02 ETA: ?:00:08',
148-
' 30% (3 of 10) |# | Elapsed Time: ?:00:03 ETA: ?:00:07',
149-
' 40% (4 of 10) |## | Elapsed Time: ?:00:04 ETA: ?:00:06',
150-
' 50% (5 of 10) |### | Elapsed Time: ?:00:05 ETA: ?:00:05',
151-
' 60% (6 of 10) |### | Elapsed Time: ?:00:07 ETA: ?:00:06',
152-
' 70% (7 of 10) |#### | Elapsed Time: ?:00:09 ETA: ?:00:06',
153-
' 80% (8 of 10) |#### | Elapsed Time: ?:00:11 ETA: ?:00:04',
154-
' 90% (9 of 10) |##### | Elapsed Time: ?:00:13 ETA: ?:00:02',
155-
'100% (10 of 10) |#####| Elapsed Time: ?:00:15 Time: ?:00:15',
156-
],
143+
result.stderr.fnmatch_lines([' 0% (0 of 10) | | Elapsed Time: 0:00:00 ETA: --:--:--',
144+
' 10% (1 of 10) | | Elapsed Time: 0:00:01 ETA: 0:00:09',
145+
' 20% (2 of 10) |# | Elapsed Time: 0:00:02 ETA: 0:00:08',
146+
' 30% (3 of 10) |# | Elapsed Time: 0:00:03 ETA: 0:00:07',
147+
' 40% (4 of 10) |## | Elapsed Time: 0:00:04 ETA: 0:00:06',
148+
' 50% (5 of 10) |### | Elapsed Time: 0:00:05 ETA: 0:00:05',
149+
' 60% (6 of 10) |### | Elapsed Time: 0:00:07 ETA: 0:00:04',
150+
' 70% (7 of 10) |#### | Elapsed Time: 0:00:09 ETA: 0:00:03',
151+
' 80% (8 of 10) |#### | Elapsed Time: 0:00:11 ETA: 0:00:02',
152+
' 90% (9 of 10) |##### | Elapsed Time: 0:00:13 ETA: 0:00:01',
153+
'100% (10 of 10) |#####| Elapsed Time: 0:00:15 Time: 0:00:15',
154+
]
157155
)
158156

159157

tests/test_windows.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import time
2+
import sys
3+
import pytest
4+
5+
if sys.platform.startswith('win'):
6+
import win32console # "pip install pypiwin32" to get this
7+
else:
8+
pytest.skip('skipping windows-only tests', allow_module_level=True)
9+
10+
11+
import progressbar
12+
13+
_WIDGETS = [progressbar.Percentage(), ' ',
14+
progressbar.Bar(), ' ',
15+
progressbar.FileTransferSpeed(), ' ',
16+
progressbar.ETA()]
17+
_MB = 1024 * 1024
18+
19+
20+
# ---------------------------------------------------------------------------
21+
def scrape_console(line_count):
22+
pcsb = win32console.GetStdHandle(win32console.STD_OUTPUT_HANDLE)
23+
csbi = pcsb.GetConsoleScreenBufferInfo()
24+
col_max = csbi['Size'].X
25+
row_max = csbi['CursorPosition'].Y
26+
27+
line_count = min(line_count, row_max)
28+
lines = []
29+
for row in range(line_count):
30+
pct = win32console.PyCOORDType(0, row + row_max - line_count)
31+
line = pcsb.ReadConsoleOutputCharacter(col_max, pct)
32+
lines.append(line.rstrip())
33+
return lines
34+
35+
36+
# ---------------------------------------------------------------------------
37+
def runprogress():
38+
print('***BEGIN***')
39+
b = progressbar.ProgressBar(widgets=['example.m4v: '] + _WIDGETS,
40+
max_value=10 * _MB)
41+
for i in range(10):
42+
b.update((i + 1) * _MB)
43+
time.sleep(0.25)
44+
b.finish()
45+
print('***END***')
46+
return 0
47+
48+
49+
# ---------------------------------------------------------------------------
50+
def find(L, x):
51+
try:
52+
return L.index(x)
53+
except ValueError:
54+
return -sys.maxsize
55+
56+
57+
# ---------------------------------------------------------------------------
58+
def test_windows(argv):
59+
runprogress()
60+
61+
scraped_lines = scrape_console(100)
62+
scraped_lines.reverse() # reverse lines so we find the LAST instances of output.
63+
index_begin = find(scraped_lines, '***BEGIN***')
64+
index_end = find(scraped_lines, '***END***')
65+
66+
if index_end + 2 != index_begin:
67+
print('ERROR: Unexpected multi-line output from progressbar')
68+
print(f'{index_begin=} {index_end=}')
69+
return 1
70+
return 0

0 commit comments

Comments
 (0)