Skip to content

Commit 69e3bfe

Browse files
EliEli
authored andcommitted
Fixed bugs having to do with interval arithmetic.
1 parent f671594 commit 69e3bfe

5 files changed

Lines changed: 101 additions & 24 deletions

File tree

tests/test_filter.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,49 @@ def test_butterworth_noevenorder():
5858
with pytest.raises(ValueError):
5959
butterworth(ts0, order=7)
6060

61+
def _make_hourly_ts(n=200):
62+
start = pd.Timestamp(2000, 2, 3)
63+
freq = hours(1)
64+
data = np.sin(np.linspace(0, 10 * np.pi, n))
65+
return rts(data, start, freq)
66+
67+
68+
def test_butterworth_both_period_and_frequency_raises():
69+
ts0 = _make_hourly_ts()
70+
with pytest.raises(ValueError, match="simultaneously"):
71+
butterworth(ts0, cutoff_period=hours(40), cutoff_frequency=0.2)
72+
73+
74+
def test_butterworth_missing_cutoff_raises():
75+
ts0 = _make_hourly_ts()
76+
with pytest.raises(ValueError, match="must be given"):
77+
butterworth(ts0)
78+
79+
80+
def test_butterworth_cutoff_frequency_branch_preserves_freq():
81+
ts0 = _make_hourly_ts()
82+
ts_filt = butterworth(ts0, cutoff_frequency=0.2)
83+
assert ts_filt.index.freq == ts0.index.freq
84+
assert ts_filt.shape == ts0.shape
85+
86+
87+
def test_butterworth_cutoff_period_string_branch_preserves_freq():
88+
ts0 = _make_hourly_ts()
89+
# use lowercase 'h' to avoid Pandas4Warning for 'H'
90+
ts_filt = butterworth(ts0, cutoff_period="40h")
91+
assert ts_filt.index.freq == ts0.index.freq
92+
assert ts_filt.shape == ts0.shape
93+
94+
95+
def test_butterworth_cutoff_period_calendar_rejected():
96+
ts0 = _make_hourly_ts()
97+
# Calendar-dependent periods (month/year) should not be allowed for fixed-interval math
98+
with pytest.raises(TypeError):
99+
butterworth(ts0, cutoff_period="1ME")
100+
101+
102+
103+
61104
def test_godin(filter_params):
62105
"""Test Godin filter on a 1-hour interval series with four frequencies."""
63106
st = pd.Timestamp(1990, 2, 3, 11, 15)

tests/test_interval_ops.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,19 @@ def test_year_rejected():
3939
def test_zero_division():
4040
with pytest.raises(ZeroDivisionError):
4141
safe_divide_interval("1D", "0H")
42+
43+
def test_ratio_ok_when_not_int_required():
44+
assert safe_divide_interval("1D", "7h", require_int=False) == pytest.approx(24/7)
45+
46+
def test_mixed_scalar_interval_rejected():
47+
with pytest.raises(TypeError):
48+
safe_divide_interval(24, "1h")
49+
with pytest.raises(TypeError):
50+
safe_divide_interval("1h", 24)
51+
52+
53+
def test_zero_division():
54+
with pytest.raises(ZeroDivisionError):
55+
safe_divide_interval("1D", "0h")
56+
57+

vtools/data/vtime.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,24 @@ def to_timedelta(x):
107107
"""
108108
Convert x to pandas.Timedelta if and only if it represents
109109
a fixed-length duration.
110+
111+
Notes
112+
-----
113+
Numeric values are rejected because they are unit-ambiguous.
110114
"""
111115
if isinstance(x, (int, np.integer, float)):
112-
return pd.Timedelta(x, unit="ns")
116+
raise TypeError(
117+
"Numeric values are ambiguous as time intervals; "
118+
"use a Timedelta, offset (hours(1)), or a string like '1h'."
119+
)
113120

114121
if isinstance(x, pd.Timedelta):
115122
return x
116123

117-
# FIRST: try Timedelta parsing (handles "1H", "1D", etc.)
124+
# FIRST: try Timedelta parsing (handles '1h', '1D', etc.)
118125
try:
119126
if isinstance(x, str):
120-
x = x.replace("H", "h").replace("d", "D").replace("T", "t") # standardize case
121-
print(x)
127+
x = x.replace("H", "h").replace("d", "D").replace("T", "t")
122128
return pd.Timedelta(x)
123129
except Exception:
124130
pass
@@ -138,8 +144,34 @@ def to_timedelta(x):
138144
return pd.Timedelta(off.nanos, unit="ns")
139145

140146

141-
142147
def safe_divide_interval(a, b, *, tol=1e-12, require_int=True):
148+
"""
149+
Divide two intervals (or two scalars) safely.
150+
151+
- interval / interval -> float ratio (or int if require_int)
152+
- scalar / scalar -> numeric ratio
153+
- mixed scalar/interval -> TypeError
154+
"""
155+
a_is_num = isinstance(a, (int, np.integer, float))
156+
b_is_num = isinstance(b, (int, np.integer, float))
157+
158+
if a_is_num and b_is_num:
159+
if b == 0:
160+
raise ZeroDivisionError("Division by zero")
161+
r = a / b
162+
if require_int:
163+
r_int = int(round(r))
164+
if abs(r - r_int) > tol:
165+
raise ValueError(f"Scalars are not evenly divisible: {a!r} / {b!r} = {r}")
166+
return r_int
167+
return float(r)
168+
169+
if a_is_num or b_is_num:
170+
raise TypeError(
171+
"safe_divide_interval does not support scalar/interval division; "
172+
"use explicit Timedelta scaling instead."
173+
)
174+
143175
td_a = to_timedelta(a)
144176
td_b = to_timedelta(b)
145177

@@ -157,4 +189,3 @@ def safe_divide_interval(a, b, *, tol=1e-12, require_int=True):
157189
return r_int
158190

159191
return float(ratio)
160-

vtools/functions/colname_align.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ def transition_ts(ts0, ts1, names=None, ...):
5555

5656

5757
def align_names(result, names):
58-
if not names:
58+
59+
if names is None:
5960
return result
6061

6162
# Series case

vtools/functions/filter.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ def _lanczos_impl(
382382
m = int(1.25 * 2.0 / cf)
383383
elif type(m) != int:
384384
try:
385-
m = int(m / freq)
385+
m = safe_divide_interval(m, freq)
386386
except:
387387
raise TypeError(
388388
"filter_len was not an int or divisible by filter_len (probably a type incompatiblity)"
@@ -550,7 +550,7 @@ def butterworth(ts, cutoff_period=None, cutoff_frequency=None, order=4):
550550
if cf is None:
551551
if not (cutoff_period is None):
552552
cutoff_period = pd.tseries.frequencies.to_offset(cutoff_period)
553-
cf = 2.0 * freq / cutoff_period
553+
cf = 2.0 * safe_divide_interval(freq, cutoff_period, require_int=False)
554554
else:
555555
cf = butterworth_cutoff_frequencies[interval]
556556

@@ -560,12 +560,6 @@ def butterworth(ts, cutoff_period=None, cutoff_frequency=None, order=4):
560560
out = ts.copy(deep=True)
561561
out[:] = d2
562562

563-
# prop={}
564-
# for key,val in ts.props.items():
565-
# prop[key]=val
566-
# prop[TIMESTAMP]=INST
567-
# prop[AGGREGATION]=INDIVIDUAL
568-
# time_interval
569563
return out
570564

571565

@@ -642,14 +636,6 @@ def godin(ts):
642636
return dfg
643637

644638

645-
def convert_span_to_nstep(freq, span):
646-
if type(span) == int:
647-
return span
648-
span = pd.tseries.frequencies.to_offset(span)
649-
freq = pd.tseries.frequencies.to_offset(freq)
650-
return int(span / freq)
651-
652-
653639
def _gf1d(ts, sigma, order, mode, cval, truncate):
654640
tscopy = ts.copy()
655641
tscopy.loc[:] = gaussian_filter1d(
@@ -689,7 +675,7 @@ def ts_gaussian_filter(ts, sigma, order=0, mode="reflect", cval=0.0, truncate=4.
689675
"""
690676
freq = ts.index.freq
691677
if type(sigma) != int:
692-
sigma = convert_span_to_nstep(freq, sigma)
678+
sigma = safe_divide_interval(freq, sigma, require_int=True)
693679

694680
if isinstance(ts, pd.Series):
695681
tsout = _gf1d(

0 commit comments

Comments
 (0)