Skip to content

Commit eb6e287

Browse files
committed
Optimized Rounding
1 parent 3482efd commit eb6e287

File tree

2 files changed

+239
-10
lines changed

2 files changed

+239
-10
lines changed

satcfdi/create/compute.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,11 @@ def make_impuesto(impuesto: dict, base, rnd_fn):
8080

8181
class RoundTracker:
8282
def __init__(self, decimals):
83+
if decimals < 0:
84+
raise NotImplementedError("decimals must be non-negative")
8385
self.decimals = decimals
8486
self.offset = Decimal('0.0')
85-
if decimals is 0:
86-
self.exp = Decimal('1')
87-
else:
88-
self.exp = Decimal('0.' + '0' * (decimals - 1) + '1')
87+
self.exp = Decimal('0.' + '0' * decimals)
8988
self.offset_margin = Decimal('0.' + '0' * decimals + '5')
9089

9190
def round(self, value):
@@ -95,12 +94,10 @@ def round(self, value):
9594

9695
def peak(self, value):
9796
if self.offset >= self.offset_margin:
98-
rounded = value.quantize(self.exp, rounding=ROUND_CEILING)
99-
elif self.offset <= -self.offset_margin:
100-
rounded = value.quantize(self.exp, rounding=ROUND_FLOOR)
101-
else:
102-
rounded = round(value, self.decimals)
103-
return rounded
97+
return value.quantize(self.exp, rounding=ROUND_CEILING)
98+
if self.offset <= -self.offset_margin:
99+
return value.quantize(self.exp, rounding=ROUND_FLOOR)
100+
return round(value, self.decimals)
104101

105102
def __call__(self, value):
106103
return self.round(value)

tests/test_compute.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import pytest
2+
from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR
3+
from satcfdi.create.compute import RoundTracker
4+
5+
6+
class TestRoundTracker:
7+
"""Test suite for RoundTracker class"""
8+
9+
def test_initialization_with_valid_decimals(self):
10+
"""Test RoundTracker initializes correctly with valid decimal places"""
11+
rt = RoundTracker(2)
12+
assert rt.decimals == 2
13+
assert rt.offset == Decimal('0.0')
14+
assert rt.exp == Decimal('0.00') # Fixed: exp is '0.' + '0' * decimals
15+
assert rt.offset_margin == Decimal('0.005')
16+
17+
def test_initialization_with_zero_decimals(self):
18+
"""Test RoundTracker with 0 decimal places"""
19+
rt = RoundTracker(0)
20+
assert rt.decimals == 0
21+
assert rt.exp == Decimal('0') # Fixed: exp is '0.' + '0' * 0 = '0.'
22+
assert rt.offset_margin == Decimal('0.5')
23+
24+
def test_initialization_with_negative_decimals_raises_error(self):
25+
"""Test that negative decimals raises NotImplementedError"""
26+
with pytest.raises(NotImplementedError, match="decimals must be non-negative"):
27+
RoundTracker(-1)
28+
29+
def test_basic_round_half_up(self):
30+
"""Test basic rounding behavior (ROUND_HALF_UP)"""
31+
rt = RoundTracker(2)
32+
assert rt.round(Decimal('1.234')) == Decimal('1.23')
33+
assert rt.round(Decimal('1.235')) == Decimal('1.24')
34+
assert rt.round(Decimal('1.236')) == Decimal('1.24')
35+
assert rt.round(Decimal('1.236')) == Decimal('1.23') # Repeated to check consistency
36+
assert rt.round(Decimal('1.236')) == Decimal('1.24')
37+
38+
def test_round_zero_decimals(self):
39+
"""Test rounding with zero decimal places"""
40+
rt = RoundTracker(0)
41+
assert rt.round(Decimal('1.4')) == Decimal('1')
42+
assert rt.round(Decimal('1.5')) == Decimal('2')
43+
assert rt.round(Decimal('1.6')) == Decimal('2')
44+
assert rt.round(Decimal('1.6')) == Decimal('1') # Repeated to check consistency
45+
assert rt.round(Decimal('1.6')) == Decimal('2')
46+
47+
def test_round_three_decimals(self):
48+
"""Test rounding with three decimal places"""
49+
rt = RoundTracker(3)
50+
assert rt.round(Decimal('2.3454')) == Decimal('2.345')
51+
assert rt.round(Decimal('2.3455')) == Decimal('2.346')
52+
assert rt.round(Decimal('2.3456')) == Decimal('2.346')
53+
assert rt.round(Decimal('2.3456')) == Decimal('2.345') # Repeated to check consistency
54+
assert rt.round(Decimal('2.3456')) == Decimal('2.346')
55+
56+
def test_offset_accumulation_positive(self):
57+
"""Test that offset accumulates correctly with positive values"""
58+
rt = RoundTracker(2)
59+
# First round: 0.005 rounds to 0.00, offset becomes 0.005
60+
result1 = rt.round(Decimal('0.005'))
61+
assert result1 == Decimal('0.00')
62+
assert rt.offset == Decimal('0.005')
63+
64+
# Second round: 0.005 rounds to 0.01, offset becomes 0.00 (0.005 + 0.005 - 0.01)
65+
result2 = rt.round(Decimal('0.005'))
66+
assert result2 == Decimal('0.01')
67+
assert rt.offset == Decimal('0.00')
68+
69+
def test_offset_accumulation_negative(self):
70+
"""Test that offset accumulates correctly with negative rounding errors"""
71+
rt = RoundTracker(2)
72+
# 1.994 rounds to 1.99, offset becomes 0.004 (1.994 - 1.99 = 0.004)
73+
result1 = rt.round(Decimal('1.994'))
74+
assert result1 == Decimal('1.99')
75+
assert rt.offset == Decimal('0.004') # Fixed: offset calculation
76+
77+
# 1.994 rounds to 1.99, offset becomes 0.008
78+
result2 = rt.round(Decimal('1.994'))
79+
assert result2 == Decimal('1.99')
80+
assert rt.offset == Decimal('0.008') # Fixed: offset accumulates
81+
82+
# 1.994 rounds to 1.99, offset becomes 0.002
83+
result2 = rt.round(Decimal('1.994'))
84+
assert result2 == Decimal('2.00')
85+
assert rt.offset == Decimal('0.002') # Fixed: offset accumulates
86+
87+
# 1.994 rounds to 1.99, offset becomes 0.002
88+
result2 = rt.round(Decimal('1.996'))
89+
assert result2 == Decimal('2.00')
90+
assert rt.offset == Decimal('-0.002') # Fixed: offset accumulates
91+
92+
def test_peak_without_offset(self):
93+
"""Test peak method when offset is zero"""
94+
rt = RoundTracker(2)
95+
# offset is 0, should use normal ROUND_HALF_UP
96+
assert rt.peak(Decimal('1.234')) == Decimal('1.23')
97+
assert rt.peak(Decimal('1.235')) == Decimal('1.24')
98+
# peak should not modify offset
99+
assert rt.offset == Decimal('0.0')
100+
101+
def test_peak_with_positive_offset_above_margin(self):
102+
"""Test peak method with positive offset above margin (uses ROUND_CEILING)"""
103+
rt = RoundTracker(2)
104+
rt.offset = Decimal('0.006') # Above margin of 0.005
105+
# Should round up (ceiling)
106+
assert rt.peak(Decimal('1.231')) == Decimal('1.24')
107+
assert rt.peak(Decimal('1.234')) == Decimal('1.24')
108+
# peak should not modify offset
109+
assert rt.offset == Decimal('0.006')
110+
111+
def test_peak_with_negative_offset_below_margin(self):
112+
"""Test peak method with negative offset below margin (uses ROUND_FLOOR)"""
113+
rt = RoundTracker(2)
114+
rt.offset = Decimal('-0.006') # Below margin of -0.005
115+
# Should round down (floor)
116+
assert rt.peak(Decimal('1.239')) == Decimal('1.23')
117+
assert rt.peak(Decimal('1.236')) == Decimal('1.23')
118+
# peak should not modify offset
119+
assert rt.offset == Decimal('-0.006')
120+
121+
def test_peak_at_offset_margin_boundary(self):
122+
"""Test peak behavior at exact margin boundaries"""
123+
rt = RoundTracker(2)
124+
125+
# Exactly at positive margin
126+
rt.offset = Decimal('0.005')
127+
assert rt.peak(Decimal('1.231')) == Decimal('1.24') # ROUND_CEILING
128+
129+
# Exactly at negative margin
130+
rt.offset = Decimal('-0.005')
131+
assert rt.peak(Decimal('1.239')) == Decimal('1.23') # ROUND_FLOOR
132+
133+
def test_call_method_equivalence(self):
134+
"""Test that __call__ method works the same as round method"""
135+
rt1 = RoundTracker(2)
136+
rt2 = RoundTracker(2)
137+
138+
value = Decimal('1.234')
139+
assert rt1(value) == rt2.round(value)
140+
141+
# Test multiple calls
142+
values = [Decimal('0.005'), Decimal('0.005'), Decimal('1.994')]
143+
results1 = [rt1(v) for v in values]
144+
results2 = [rt2.round(v) for v in values]
145+
assert results1 == results2
146+
147+
def test_sequence_of_rounds_with_offset_correction(self):
148+
"""Test a sequence of rounds where offset triggers ceiling/floor rounding"""
149+
rt = RoundTracker(2)
150+
151+
# Accumulate positive offset
152+
rt.round(Decimal('0.004')) # rounds to 0.00, offset = 0.004
153+
rt.round(Decimal('0.004')) # rounds to 0.00, offset = 0.008
154+
155+
# Now offset > margin, next round should use ceiling
156+
result = rt.round(Decimal('1.001')) # Should round to 1.01 due to offset
157+
assert result == Decimal('1.01')
158+
159+
# Offset should be adjusted
160+
expected_offset = Decimal('0.008') + Decimal('1.001') - Decimal('1.01')
161+
assert rt.offset == expected_offset
162+
163+
def test_round_with_large_values(self):
164+
"""Test rounding with large decimal values"""
165+
rt = RoundTracker(2)
166+
assert rt.round(Decimal('999999.994')) == Decimal('999999.99')
167+
assert rt.round(Decimal('999999.995')) == Decimal('1000000.00')
168+
169+
def test_round_with_negative_values(self):
170+
"""Test rounding with negative values"""
171+
rt = RoundTracker(2)
172+
assert rt.round(Decimal('-1.234')) == Decimal('-1.23')
173+
assert rt.round(Decimal('-1.235')) == Decimal('-1.24')
174+
assert rt.round(Decimal('-1.236')) == Decimal('-1.24')
175+
176+
def test_offset_correction_over_multiple_rounds(self):
177+
"""Test that offset correction works correctly over many rounds"""
178+
rt = RoundTracker(2)
179+
180+
# Add values that would accumulate rounding errors
181+
values = [Decimal('0.333333')] * 3 # 0.333333 * 3 = 0.999999
182+
results = [rt.round(v) for v in values]
183+
184+
# First two should round to 0.33, third might round to 0.34 due to offset
185+
assert sum(results) in [Decimal('0.99'), Decimal('1.00')]
186+
187+
def test_realistic_scenario_currency_rounding(self):
188+
"""Test realistic scenario with currency calculations"""
189+
rt = RoundTracker(2)
190+
191+
# Simulating splitting a payment into three parts
192+
total = Decimal('10.00')
193+
part = total / 3 # 3.333333...
194+
195+
p1 = rt.round(part) # 3.33
196+
p2 = rt.round(part) # 3.33
197+
p3 = rt.round(part) # Should be 3.34 to compensate
198+
199+
assert p1 == Decimal('3.33')
200+
assert p2 == Decimal('3.33')
201+
assert p3 == Decimal('3.34')
202+
assert p1 + p2 + p3 == total
203+
204+
def test_high_precision_decimals(self):
205+
"""Test with high precision decimal places"""
206+
rt = RoundTracker(6)
207+
assert rt.round(Decimal('1.2345674')) == Decimal('1.234567')
208+
assert rt.round(Decimal('1.2345675')) == Decimal('1.234568')
209+
assert rt.exp == Decimal('0.000000') # Fixed: exp is '0.' + '0' * 6
210+
assert rt.offset_margin == Decimal('0.0000005')
211+
212+
213+
@pytest.mark.parametrize("decimals,value,expected", [
214+
(2, Decimal('1.234'), Decimal('1.23')),
215+
(2, Decimal('1.235'), Decimal('1.24')),
216+
(2, Decimal('1.236'), Decimal('1.24')),
217+
(0, Decimal('1.5'), Decimal('2')),
218+
(0, Decimal('1.4'), Decimal('1')),
219+
(0, Decimal('2.5'), Decimal('2')), # Banker's rounding
220+
(3, Decimal('2.3454'), Decimal('2.345')),
221+
(3, Decimal('2.3455'), Decimal('2.346')),
222+
(3, Decimal('2.3456'), Decimal('2.346')),
223+
(4, Decimal('9.99995'), Decimal('10.0000')),
224+
(2, Decimal('-1.234'), Decimal('-1.23')),
225+
(2, Decimal('-1.235'), Decimal('-1.24')),
226+
])
227+
def test_roundtracker_parametrized(decimals, value, expected):
228+
"""Parametrized test for various decimal places and values"""
229+
rt = RoundTracker(decimals)
230+
result = rt.round(value)
231+
assert result == expected
232+

0 commit comments

Comments
 (0)