test_splines.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. # pylint: disable=missing-docstring
  2. import math
  3. import numpy as np
  4. import pytest
  5. from scipy._lib._array_api import is_cupy, xp_assert_close, xp_default_dtype, concat_1d
  6. from scipy.signal._spline import (
  7. symiirorder1_ic, symiirorder2_ic_fwd, symiirorder2_ic_bwd)
  8. from scipy.signal import symiirorder1, symiirorder2
  9. skip_xp_backends = pytest.mark.skip_xp_backends
  10. xfail_xp_backends = pytest.mark.xfail_xp_backends
  11. def _compute_symiirorder2_bwd_hs(k, cs, rsq, omega):
  12. cssq = cs * cs
  13. k = np.abs(k)
  14. rsupk = np.power(rsq, k / 2.0)
  15. c0 = (cssq * (1.0 + rsq) / (1.0 - rsq) /
  16. (1 - 2 * rsq * np.cos(2 * omega) + rsq * rsq))
  17. gamma = (1.0 - rsq) / (1.0 + rsq) / np.tan(omega)
  18. return c0 * rsupk * (np.cos(omega * k) + gamma * np.sin(omega * k))
  19. class TestSymIIR:
  20. @skip_xp_backends(np_only=True, reason="_ic functions are private and numpy-only")
  21. @pytest.mark.parametrize(
  22. 'dtype', ['float32', 'float64', 'complex64', 'complex128'])
  23. @pytest.mark.parametrize('precision', [-1.0, 0.7, 0.5, 0.25, 0.0075])
  24. def test_symiir1_ic(self, dtype, precision, xp):
  25. dtype = getattr(xp, dtype)
  26. c_precision = precision
  27. if precision <= 0.0 or precision > 1.0:
  28. if dtype in {xp.float32, xp.complex64}:
  29. c_precision = 1e-6
  30. else:
  31. c_precision = 1e-11
  32. # Symmetrical initial conditions for a IIR filter of order 1 are:
  33. # x[0] + z1 * \sum{k = 0}^{n - 1} x[k] * z1^k
  34. # Check the initial condition for a low-pass filter
  35. # with coefficient b = 0.85 on a step signal. The initial condition is
  36. # a geometric series: 1 + b * \sum_{k = 0}^{n - 1} u[k] b^k.
  37. # Finding the initial condition corresponds to
  38. # 1. Computing the index n such that b**n < precision, which
  39. # corresponds to ceil(log(precision) / log(b))
  40. # 2. Computing the geometric series until n, this can be computed
  41. # using the partial sum formula: (1 - b**n) / (1 - b)
  42. # This holds due to the input being a step signal.
  43. b = 0.85
  44. n_exp = int(math.ceil(math.log(c_precision) / math.log(b)))
  45. expected = xp.asarray([[(1 - b ** n_exp) / (1 - b)]], dtype=dtype)
  46. expected = 1 + b * expected
  47. # Create a step signal of size n + 1
  48. x = xp.ones(n_exp + 1, dtype=dtype)
  49. xp_assert_close(symiirorder1_ic(x, b, precision), expected,
  50. atol=2e-6, rtol=2e-7)
  51. # Check the conditions for an exponential decreasing signal with base 2.
  52. # Same conditions hold, as the product of 0.5^n * 0.85^n is
  53. # still a geometric series
  54. b_d = xp.asarray(b, dtype=dtype)
  55. expected = np.asarray(
  56. [[(1 - (0.5 * b_d) ** n_exp) / (1 - (0.5 * b_d))]], dtype=dtype)
  57. expected = 1 + b_d * expected
  58. # Create an exponential decreasing signal of size n + 1
  59. x = 2 ** -xp.arange(n_exp + 1, dtype=dtype)
  60. xp_assert_close(symiirorder1_ic(x, b, precision), expected,
  61. atol=2e-6, rtol=2e-7)
  62. @skip_xp_backends(np_only=True, reason="_ic functions are private and numpy-only")
  63. def test_symiir1_ic_fails(self, xp):
  64. # Test that symiirorder1_ic fails whenever \sum_{n = 1}^{n} b^n > eps
  65. b = 0.85
  66. # Create a step signal of size 100
  67. x = xp.ones(100, dtype=xp.float64)
  68. # Compute the closed form for the geometrical series
  69. precision = 1 / (1 - b)
  70. pytest.raises(ValueError, symiirorder1_ic, x, b, precision)
  71. # Test that symiirorder1_ic fails when |z1| >= 1
  72. pytest.raises(ValueError, symiirorder1_ic, x, 1.0, -1)
  73. pytest.raises(ValueError, symiirorder1_ic, x, 2.0, -1)
  74. @skip_xp_backends(
  75. cpu_only=True, exceptions=["cupy"], reason="internals are numpy-only"
  76. )
  77. @xfail_xp_backends("cupy", reason="sum did not converge")
  78. @skip_xp_backends("jax.numpy", reason="item assignment in tests")
  79. @pytest.mark.parametrize(
  80. 'dtype', ['float32', 'float64', 'complex64', 'complex128'])
  81. @pytest.mark.parametrize('precision', [-1.0, 0.7, 0.5, 0.25, 0.0075])
  82. def test_symiir1(self, dtype, precision, xp):
  83. dtype = getattr(xp, dtype)
  84. c_precision = precision
  85. if precision <= 0.0 or precision > 1.0:
  86. if dtype in {xp.float32, xp.complex64}:
  87. c_precision = 1e-6
  88. else:
  89. c_precision = 1e-11
  90. # Test for a low-pass filter with c0 = 0.15 and z1 = 0.85
  91. # using an unit step over 200 samples.
  92. c0 = 0.15
  93. z1 = 0.85
  94. n = 200
  95. signal = xp.ones(n, dtype=dtype)
  96. # Find the initial condition. See test_symiir1_ic for a detailed
  97. # explanation
  98. n_exp = int(math.ceil(math.log(c_precision) / math.log(z1)))
  99. initial = xp.asarray((1 - z1 ** n_exp) / (1 - z1), dtype=dtype)
  100. initial = 1 + z1 * initial
  101. # Forward pass
  102. # The transfer function for the system 1 / (1 - z1 * z^-1) when
  103. # applied to an unit step with initial conditions y0 is
  104. # 1 / (1 - z1 * z^-1) * (z^-1 / (1 - z^-1) + y0)
  105. # Solving the inverse Z-transform for the given expression yields:
  106. # y[n] = y0 * z1**n * u[n] +
  107. # -z1 / (1 - z1) * z1**(k - 1) * u[k - 1] +
  108. # 1 / (1 - z1) * u[k - 1]
  109. # d is the Kronecker delta function, and u is the unit step
  110. # y0 * z1**n * u[n]
  111. pos = xp.astype(xp.arange(n), dtype)
  112. comp1 = initial * z1**pos
  113. # -z1 / (1 - z1) * z1**(k - 1) * u[k - 1]
  114. comp2 = xp.zeros(n, dtype=dtype)
  115. comp2[1:] = -z1 / (1 - z1) * z1**pos[:-1]
  116. # 1 / (1 - z1) * u[k - 1]
  117. comp3 = xp.zeros(n, dtype=dtype)
  118. comp3[1:] = 1 / (1 - z1)
  119. expected_fwd = comp1 + comp2 + comp3
  120. # Reverse condition
  121. sym_cond = -c0 / (z1 - 1.0) * expected_fwd[-1]
  122. # Backward pass
  123. # The transfer function for the forward result is equivalent to
  124. # the forward system times c0 / (1 - z1 * z).
  125. # Computing a closed form for the complete expression is difficult
  126. # The result will be computed iteratively from the difference equation
  127. exp_out = xp.zeros(n, dtype=dtype)
  128. exp_out[0] = sym_cond
  129. for i in range(1, n):
  130. exp_out[i] = c0 * expected_fwd[n - 1 - i] + z1 * exp_out[i - 1]
  131. exp_out = xp.flip(exp_out)
  132. out = symiirorder1(signal, c0, z1, precision)
  133. xp_assert_close(out, exp_out, atol=4e-6, rtol=6e-7)
  134. @xfail_xp_backends("cupy", reason="sum did not converge")
  135. @skip_xp_backends(
  136. cpu_only=True, exceptions=["cupy"], reason="internals are numpy-only"
  137. )
  138. @pytest.mark.parametrize('dtype', ['float32', 'float64'])
  139. def test_symiir1_values(self, dtype, xp):
  140. rng = np.random.RandomState(1234)
  141. s = rng.uniform(size=16).astype(dtype)
  142. dtype = getattr(xp, dtype)
  143. s = xp.asarray(s)
  144. res = symiirorder1(s, 0.5, 0.1)
  145. # values from scipy 1.9.1
  146. exp_res = xp.asarray([
  147. 0.14387447, 0.35166047, 0.29735238, 0.46295986, 0.45174927,
  148. 0.19982875, 0.20355805, 0.47378628, 0.57232247, 0.51597393,
  149. 0.25935107, 0.31438554, 0.41096728, 0.4190693 , 0.25812255,
  150. 0.33671467], dtype=res.dtype)
  151. atol = {xp.float64: 1e-15, xp.float32: 1e-7}[dtype]
  152. xp_assert_close(res, exp_res, atol=atol)
  153. I1 = xp.asarray(
  154. 1 + 1j, dtype=xp.result_type(s, xp.complex64)
  155. )
  156. s = s * I1
  157. res = symiirorder1(s, 0.5, 0.1)
  158. assert res.dtype == xp.complex64 if dtype == xp.float32 else xp.complex128
  159. xp_assert_close(res, I1 * exp_res, atol=atol)
  160. @skip_xp_backends(np_only=True,
  161. reason="_initial_fwd functions are private and numpy-only")
  162. @pytest.mark.parametrize(
  163. 'dtype', ['float32', 'float64'])
  164. @pytest.mark.parametrize('precision', [-1.0, 0.7, 0.5, 0.25, 0.0075])
  165. def test_symiir2_initial_fwd(self, dtype, precision, xp):
  166. dtype = getattr(xp, dtype)
  167. c_precision = precision
  168. if precision <= 0.0 or precision > 1.0:
  169. if dtype in {xp.float32, xp.complex64}:
  170. c_precision = 1e-6
  171. else:
  172. c_precision = 1e-11
  173. # Compute the initial conditions for a order-two symmetrical low-pass
  174. # filter with r = 0.5 and omega = pi / 3 for an unit step input.
  175. r = xp.asarray(0.5, dtype=dtype)
  176. omega = xp.asarray(np.pi / 3.0, dtype=dtype)
  177. cs = 1 - 2 * r * xp.cos(omega) + r**2
  178. # The index n for the initial condition is bound from 0 to the
  179. # first position where sin(omega * (n + 2)) = 0 => omega * (n + 2) = pi
  180. # For omega = pi / 3, the maximum initial condition occurs when
  181. # sqrt(3) / 2 * r**n < precision.
  182. # => n = log(2 * sqrt(3) / 3 * precision) / log(r)
  183. ub = xp.ceil(xp.log(c_precision / xp.sin(omega)) / math.log(c_precision))
  184. lb = xp.ceil(math.pi / omega) - 2
  185. n_exp = min(ub, lb)
  186. # The forward initial condition for a filter of order two is:
  187. # \frac{cs}{\sin(\omega)} \sum_{n = 0}^{N - 1} {
  188. # r^(n + 1) \sin{\omega(n + 2)}} + cs
  189. # The closed expression for this sum is:
  190. # s[n] = 2 * r * np.cos(omega) -
  191. # r**2 - r**(n + 2) * np.sin(omega * (n + 3)) / np.sin(omega) +
  192. # r**(n + 3) * np.sin(omega * (n + 2)) / np.sin(omega) + cs
  193. fwd_initial_1 = (
  194. cs +
  195. 2 * r * xp.cos(omega) -
  196. r**2 -
  197. r**(n_exp + 2) * xp.sin(omega * (n_exp + 3)) / xp.sin(omega) +
  198. r**(n_exp + 3) * xp.sin(omega * (n_exp + 2)) / xp.sin(omega))
  199. # The second initial condition is given by
  200. # s[n] = 1 / np.sin(omega) * (
  201. # r**2 * np.sin(3 * omega) -
  202. # r**3 * np.sin(2 * omega) -
  203. # r**(n + 3) * np.sin(omega * (n + 4)) +
  204. # r**(n + 4) * np.sin(omega * (n + 3)))
  205. ub = xp.ceil(xp.log(c_precision / xp.sin(omega)) / math.log(c_precision))
  206. lb = xp.ceil(xp.pi / omega) - 3
  207. n_exp = min(ub, lb)
  208. fwd_initial_2 = (
  209. cs + cs * 2 * r * xp.cos(omega) +
  210. (r**2 * xp.sin(3 * omega) -
  211. r**3 * xp.sin(2 * omega) -
  212. r**(n_exp + 3) * xp.sin(omega * (n_exp + 4)) +
  213. r**(n_exp + 4) * xp.sin(omega * (n_exp + 3))) / xp.sin(omega))
  214. expected = concat_1d(xp, fwd_initial_1, fwd_initial_2)[None, :]
  215. expected = xp.astype(expected, dtype)
  216. n = 100
  217. signal = np.ones(n, dtype=dtype)
  218. out = symiirorder2_ic_fwd(signal, r, omega, precision)
  219. xp_assert_close(out, expected, atol=4e-6, rtol=6e-7)
  220. @skip_xp_backends(np_only=True,
  221. reason="_initial_bwd functions are private and numpy-only")
  222. @pytest.mark.parametrize(
  223. 'dtype', ['float32', 'float64'])
  224. @pytest.mark.parametrize('precision', [-1.0, 0.7, 0.5, 0.25, 0.0075])
  225. def test_symiir2_initial_bwd(self, dtype, precision, xp):
  226. dtype = getattr(xp, dtype)
  227. c_precision = precision
  228. if precision <= 0.0 or precision > 1.0:
  229. if dtype in {xp.float32, xp.complex64}:
  230. c_precision = 1e-6
  231. else:
  232. c_precision = 1e-11
  233. r = xp.asarray(0.5, dtype=dtype)
  234. omega = xp.asarray(xp.pi / 3.0, dtype=dtype)
  235. cs = 1 - 2 * r * xp.cos(omega) + r * r
  236. a2 = 2 * r * xp.cos(omega)
  237. a3 = -r * r
  238. n = 100
  239. signal = xp.ones(n, dtype=dtype)
  240. # Compute initial forward conditions
  241. ic = symiirorder2_ic_fwd(signal, r, omega, precision)
  242. out = xp.zeros(n + 2, dtype=dtype)
  243. out[:2] = ic[0]
  244. # Apply the forward system cs / (1 - a2 * z^-1 - a3 * z^-2))
  245. for i in range(2, n + 2):
  246. out[i] = cs * signal[i - 2] + a2 * out[i - 1] + a3 * out[i - 2]
  247. # Find the backward initial conditions
  248. ic2 = xp.zeros(2, dtype=dtype)
  249. idx = xp.arange(n)
  250. diff = (_compute_symiirorder2_bwd_hs(idx, cs, r * r, omega) +
  251. _compute_symiirorder2_bwd_hs(idx + 1, cs, r * r, omega))
  252. ic2_0_all = np.cumsum(diff * out[:1:-1])
  253. pos = xp.nonzero(diff ** 2 < c_precision)[0]
  254. ic2[0] = ic2_0_all[pos[0]]
  255. diff = (_compute_symiirorder2_bwd_hs(idx - 1, cs, r * r, omega) +
  256. _compute_symiirorder2_bwd_hs(idx + 2, cs, r * r, omega))
  257. ic2_1_all = xp.cumulative_sum(diff * out[:1:-1])
  258. pos = xp.nonzero(diff ** 2 < c_precision)[0]
  259. ic2[1] = ic2_1_all[pos[0]]
  260. out_ic = symiirorder2_ic_bwd(out, r, omega, precision)[0]
  261. xp_assert_close(out_ic, ic2, atol=4e-6, rtol=6e-7)
  262. @skip_xp_backends(cpu_only=True, reason="internals are numpy-only")
  263. @skip_xp_backends("jax.numpy", reason="item assignment in tests")
  264. @pytest.mark.parametrize(
  265. 'dtype', ['float32', 'float64'])
  266. @pytest.mark.parametrize('precision', [-1.0, 0.7, 0.5, 0.25, 0.0075])
  267. def test_symiir2(self, dtype, precision, xp):
  268. dtype = getattr(xp, dtype)
  269. r = 0.5
  270. omega = math.pi / 3.0
  271. cs = 1 - 2 * r * math.cos(omega) + r * r
  272. a2 = 2 * r * math.cos(omega)
  273. a3 = -r * r
  274. n = 100
  275. signal = xp.ones(n, dtype=dtype)
  276. # Compute initial forward conditions
  277. signal_np = np.asarray(signal)
  278. ic = symiirorder2_ic_fwd(signal_np, r, omega, precision)
  279. ic = xp.asarray(ic)
  280. out1 = xp.zeros(n + 2, dtype=dtype)
  281. out1[:2] = ic[0, :]
  282. # Apply the forward system cs / (1 - a2 * z^-1 - a3 * z^-2))
  283. for i in range(2, n + 2):
  284. out1[i] = cs * signal[i - 2] + a2 * out1[i - 1] + a3 * out1[i - 2]
  285. # Find the backward initial conditions
  286. ic2 = symiirorder2_ic_bwd(np.asarray(out1), r, omega, precision)[0]
  287. ic2 = xp.asarray(ic2)
  288. # Apply the system cs / (1 - a2 * z - a3 * z^2)) in backwards
  289. exp = xp.empty(n, dtype=dtype)
  290. exp[-2:] = xp.flip(ic2)
  291. for i in range(n - 3, -1, -1):
  292. exp[i] = cs * out1[i] + a2 * exp[i + 1] + a3 * exp[i + 2]
  293. out = symiirorder2(signal, r, omega, precision)
  294. xp_assert_close(out, exp, atol=4e-6, rtol=6e-7)
  295. @skip_xp_backends(cpu_only=True, exceptions=["cupy"], reason="C internals")
  296. @pytest.mark.parametrize('dtyp', ['float32', 'float64'])
  297. def test_symiir2_values(self, dtyp, xp):
  298. rng = np.random.RandomState(1234)
  299. s = rng.uniform(size=16).astype(dtyp)
  300. s = xp.asarray(s)
  301. # cupy returns f64 for f32 inputs
  302. dtype = xp.float64 if is_cupy(xp) else getattr(xp, dtyp)
  303. res = symiirorder2(s, 0.1, 0.1, precision=1e-10)
  304. # values from scipy 1.9.1
  305. exp_res = xp.asarray(
  306. [0.26572609, 0.53408018, 0.51032696, 0.72115829, 0.69486885,
  307. 0.3649055 , 0.37349478, 0.74165032, 0.89718521, 0.80582483,
  308. 0.46758053, 0.51898709, 0.65025605, 0.65394321, 0.45273595,
  309. 0.53539183], dtype=dtype
  310. )
  311. # The values in SciPy 1.14 agree with those in SciPy 1.9.1 to this
  312. # accuracy only. Implementation differences are twofold:
  313. # 1. boundary conditions are computed differently
  314. # 2. the filter itself uses sosfilt instead of a hardcoded iteration
  315. # The boundary conditions seem are tested separately (see
  316. # test_symiir2_initial_{fwd,bwd} above, so the difference is likely
  317. # due to a different way roundoff errors accumulate in the filter.
  318. # In that respect, sosfilt is likely doing a better job.
  319. xp_assert_close(res, exp_res, atol=2e-6)
  320. I1 = xp.asarray(1 + 1j, dtype=xp.result_type(s, xp.complex64))
  321. s = s * I1
  322. with pytest.raises((TypeError, ValueError)):
  323. res = symiirorder2(s, 0.5, 0.1)
  324. @skip_xp_backends(cpu_only=True, exceptions=["cupy"], reason="C internals")
  325. @xfail_xp_backends("cupy", reason="cupy does not accept integer arrays")
  326. def test_symiir1_integer_input(self, xp):
  327. s = xp.where(
  328. xp.astype(xp.arange(100) % 2, xp.bool),
  329. xp.asarray(-1),
  330. xp.asarray(1),
  331. )
  332. expected = symiirorder1(xp.astype(s, xp_default_dtype(xp)), 0.5, 0.5)
  333. out = symiirorder1(s, 0.5, 0.5)
  334. xp_assert_close(out, expected)
  335. @skip_xp_backends(cpu_only=True, exceptions=["cupy"], reason="C internals")
  336. @xfail_xp_backends("cupy", reason="cupy does not accept integer arrays")
  337. def test_symiir2_integer_input(self, xp):
  338. s = xp.where(
  339. xp.astype(xp.arange(100) % 2, xp.bool),
  340. xp.asarray(-1),
  341. xp.asarray(1),
  342. )
  343. expected = symiirorder2(xp.astype(s, xp_default_dtype(xp)), 0.5, xp.pi / 3.0)
  344. out = symiirorder2(s, 0.5, xp.pi / 3.0)
  345. xp_assert_close(out, expected)