Skip to content

Commit 41977c5

Browse files
committed
whenxuan: create the unittest for memd
1 parent e9e1ddc commit 41977c5

1 file changed

Lines changed: 375 additions & 0 deletions

File tree

‎pysdkit/tests/test_memd.py‎

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Unit tests for Multivariate Empirical Mode Decomposition (MEMD).
4+
"""
5+
6+
import unittest
7+
8+
import numpy as np
9+
10+
from pysdkit import MEMD
11+
from pysdkit._emd.memd import MEMD as ModuleMEMD
12+
from pysdkit._emd.memd import (
13+
boundary_conditions,
14+
envelope_mean,
15+
hamm,
16+
is_prime,
17+
local_peaks,
18+
nth_prime,
19+
peaks,
20+
spherical_coordinate_directions,
21+
unit_direction,
22+
zero_crossings,
23+
)
24+
from pysdkit.data import (
25+
load_memd_syn_12channel,
26+
load_memd_syn_16channel,
27+
load_memd_syn_hex,
28+
load_memd_taichi_hex,
29+
)
30+
31+
32+
def _trivariate_tones(n_samples: int = 256, sampling_rate: float = 256.0) -> np.ndarray:
33+
"""Three-channel mixture with a shared 4 Hz tone and extra harmonics."""
34+
time = np.arange(n_samples, dtype=float) / sampling_rate
35+
channel_0 = np.cos(2.0 * np.pi * 4.0 * time) + 0.5 * np.cos(
36+
2.0 * np.pi * 18.0 * time
37+
)
38+
channel_1 = np.cos(2.0 * np.pi * 4.0 * time) + 0.4 * np.sin(
39+
2.0 * np.pi * 11.0 * time
40+
)
41+
channel_2 = 0.8 * np.cos(2.0 * np.pi * 18.0 * time) + 0.3 * np.cos(
42+
2.0 * np.pi * 4.0 * time
43+
)
44+
return np.vstack([channel_0, channel_1, channel_2])
45+
46+
47+
class MEMDTestCase(unittest.TestCase):
48+
"""Tests for :class:`pysdkit.MEMD` and module-level helpers."""
49+
50+
def setUp(self) -> None:
51+
self.signal = _trivariate_tones()
52+
self.n_channels, self.n_samples = self.signal.shape
53+
self.memd = MEMD(n_dir=8, max_iter=40, stop_cnt=2)
54+
55+
def test_str(self) -> None:
56+
"""``str(MEMD)`` reports the algorithm name."""
57+
self.assertEqual(
58+
str(self.memd), "Multivariate Empirical Mode Decomposition (MEMD)"
59+
)
60+
61+
def test_init_stores_parameters(self) -> None:
62+
"""Constructor stores stopping and projection parameters."""
63+
memd = MEMD(
64+
stop_crit="fix_h",
65+
max_iter=50,
66+
n_dir=12,
67+
stop_vec=[0.05, 0.5, 0.05],
68+
stop_cnt=3,
69+
)
70+
self.assertEqual(memd.stop_crit, "fix_h")
71+
self.assertEqual(memd.max_iter, 50)
72+
self.assertEqual(memd.n_dir, 12)
73+
self.assertEqual(memd.stop_cnt, 3)
74+
np.testing.assert_allclose(memd.stop_vec, [0.05, 0.5, 0.05])
75+
self.assertEqual(memd.sd, 0.05)
76+
self.assertEqual(memd.sd2, 0.5)
77+
self.assertEqual(memd.tol, 0.05)
78+
79+
def test_import_from_package_root(self) -> None:
80+
"""MEMD is exported from the package root and the ``_emd`` module."""
81+
self.assertIs(MEMD, ModuleMEMD)
82+
83+
def test_invalid_stop_crit(self) -> None:
84+
"""Unknown stopping criteria raise ``ValueError``."""
85+
with self.assertRaises(ValueError):
86+
MEMD(stop_crit="unknown")
87+
88+
def test_invalid_n_dir(self) -> None:
89+
"""Fewer than 6 direction vectors is rejected."""
90+
with self.assertRaises(ValueError):
91+
MEMD(n_dir=5)
92+
93+
def test_invalid_stop_vec(self) -> None:
94+
"""``stop_vec`` must contain exactly three finite numbers."""
95+
with self.assertRaises(ValueError):
96+
MEMD(stop_vec=[0.1, 0.2])
97+
98+
def test_invalid_stop_cnt(self) -> None:
99+
"""Negative ``stop_cnt`` is rejected."""
100+
with self.assertRaises(ValueError):
101+
MEMD(stop_cnt=-1)
102+
103+
def test_invalid_max_iter(self) -> None:
104+
"""Non-positive ``max_iter`` is rejected."""
105+
with self.assertRaises(ValueError):
106+
MEMD(max_iter=0)
107+
108+
def test_fit_transform_shape(self) -> None:
109+
"""``fit_transform`` returns ``(K, T, C)`` with finite values."""
110+
imfs = self.memd.fit_transform(self.signal)
111+
self.assertEqual(imfs.ndim, 3)
112+
self.assertEqual(imfs.shape[1], self.n_samples)
113+
self.assertEqual(imfs.shape[2], self.n_channels)
114+
self.assertGreaterEqual(imfs.shape[0], 2)
115+
self.assertTrue(np.all(np.isfinite(imfs)))
116+
117+
def test_reconstruction(self) -> None:
118+
"""Summing IMFs including the residue recovers the input."""
119+
imfs = self.memd.fit_transform(self.signal)
120+
reconstructed = imfs.sum(axis=0).T
121+
np.testing.assert_allclose(reconstructed, self.signal, atol=1e-8)
122+
123+
def test_call_matches_fit_transform(self) -> None:
124+
"""Calling the instance is equivalent to ``fit_transform``."""
125+
a = self.memd(self.signal)
126+
b = self.memd.fit_transform(self.signal)
127+
np.testing.assert_allclose(a, b)
128+
129+
def test_n_dir_not_overwritten(self) -> None:
130+
"""``n_dir`` stays at the constructor value after a decomposition."""
131+
memd = MEMD(n_dir=16, max_iter=20)
132+
memd.fit_transform(self.signal)
133+
self.assertEqual(memd.n_dir, 16)
134+
135+
def test_fit_transform_fix_h(self) -> None:
136+
"""``stop_crit='fix_h'`` also yields a reconstructing decomposition."""
137+
imfs = MEMD(stop_crit="fix_h", n_dir=8, max_iter=30, stop_cnt=2).fit_transform(
138+
self.signal
139+
)
140+
self.assertEqual(imfs.shape[1:], (self.n_samples, self.n_channels))
141+
np.testing.assert_allclose(imfs.sum(axis=0).T, self.signal, atol=1e-8)
142+
143+
def test_four_channel_input(self) -> None:
144+
"""Hex-less 4-channel data uses the n-sphere Hammersley map."""
145+
t = np.arange(180, dtype=float) / 180.0
146+
signal = np.vstack(
147+
[
148+
np.cos(2.0 * np.pi * 3.0 * t),
149+
np.cos(2.0 * np.pi * 7.0 * t),
150+
np.sin(2.0 * np.pi * 3.0 * t),
151+
np.sin(2.0 * np.pi * 11.0 * t),
152+
]
153+
)
154+
imfs = MEMD(n_dir=8, max_iter=25).fit_transform(signal)
155+
self.assertEqual(imfs.shape[1:], (signal.shape[1], 4))
156+
np.testing.assert_allclose(imfs.sum(axis=0).T, signal, atol=1e-8)
157+
158+
def test_transposes_time_major_input(self) -> None:
159+
"""A MATLAB-style ``(n_samples, n_channels)`` array is accepted."""
160+
imfs = self.memd.fit_transform(self.signal.T)
161+
self.assertEqual(imfs.shape[1], self.n_samples)
162+
self.assertEqual(imfs.shape[2], self.n_channels)
163+
np.testing.assert_allclose(imfs.sum(axis=0), self.signal.T, atol=1e-8)
164+
165+
def test_rejects_too_few_channels(self) -> None:
166+
"""Univariate / bivariate inputs raise ``ValueError``."""
167+
with self.assertRaises(ValueError):
168+
self.memd.fit_transform(np.ones((2, 64)))
169+
170+
def test_rejects_too_many_channels(self) -> None:
171+
"""More than 16 channels is outside the MATLAB toolbox limit."""
172+
with self.assertRaises(ValueError):
173+
self.memd.fit_transform(np.ones((17, 64)))
174+
175+
def test_rejects_1d_and_short_series(self) -> None:
176+
"""Non-2-D arrays and very short records are rejected."""
177+
with self.assertRaises(ValueError):
178+
self.memd.fit_transform(np.ones(32))
179+
with self.assertRaises(ValueError):
180+
self.memd.fit_transform(np.ones((3, 2)))
181+
182+
def test_init_hammersley_trivariate(self) -> None:
183+
"""Trivariate Hammersley sequence has two coordinates per direction."""
184+
seq = self.memd.init_hammersley(3)
185+
self.assertEqual(seq.shape, (self.memd.n_dir, 2))
186+
self.assertTrue(np.all(np.isfinite(seq)))
187+
self.assertTrue(np.all(seq >= 0.0))
188+
189+
def test_init_hammersley_higher_dim(self) -> None:
190+
"""For n>3 the sequence has one Hammersley coordinate per dimension."""
191+
seq = MEMD(n_dir=8).init_hammersley(5)
192+
self.assertEqual(seq.shape, (8, 5))
193+
194+
def test_direction_vectors_unit_length(self) -> None:
195+
"""Each Hammersley direction is a unit vector."""
196+
for n_dim in (3, 4, 6):
197+
dirs = MEMD(n_dir=8).direction_vectors(n_dim)
198+
self.assertEqual(dirs.shape, (8, n_dim))
199+
np.testing.assert_allclose(np.linalg.norm(dirs, axis=1), 1.0, atol=1e-10)
200+
201+
def test_stop_emd_on_constant(self) -> None:
202+
"""A constant residual has too few extrema on every projection."""
203+
seq = self.memd.init_hammersley(3)
204+
constant = np.ones((40, 3))
205+
self.assertTrue(self.memd.stop_emd(constant, seq, 3))
206+
207+
def test_stop_emd_on_oscillation(self) -> None:
208+
"""An oscillating residual still has extrema, so sifting continues."""
209+
seq = self.memd.init_hammersley(3)
210+
oscillating = self.signal.T
211+
self.assertFalse(self.memd.stop_emd(oscillating, seq, 3))
212+
213+
def test_stop_returns_envelope(self) -> None:
214+
"""``stop`` returns a boolean flag and an envelope of shape ``(T, C)``."""
215+
seq = self.memd.init_hammersley(3)
216+
time = np.arange(1, self.n_samples + 1, dtype=float)
217+
flag, env = self.memd.stop(
218+
self.signal.T, time, seq, self.n_samples, self.n_channels
219+
)
220+
self.assertIsInstance(flag, (bool, np.bool_))
221+
self.assertEqual(env.shape, (self.n_samples, self.n_channels))
222+
self.assertTrue(np.all(np.isfinite(env)))
223+
224+
def test_fix_counter(self) -> None:
225+
"""``fix`` increments or resets the consecutive-sifting counter."""
226+
seq = self.memd.init_hammersley(3)
227+
time = np.arange(1, self.n_samples + 1, dtype=float)
228+
flag, env, counter = self.memd.fix(
229+
self.signal.T, time, seq, self.n_samples, self.n_channels, counter=0
230+
)
231+
self.assertIsInstance(flag, (bool, np.bool_))
232+
self.assertEqual(env.shape, (self.n_samples, self.n_channels))
233+
self.assertIsInstance(counter, int)
234+
self.assertGreaterEqual(counter, 0)
235+
236+
def test_is_prime(self) -> None:
237+
"""Primality matches the list used by MATLAB ``memd.m``."""
238+
self.assertFalse(is_prime(0))
239+
self.assertFalse(is_prime(1))
240+
self.assertTrue(is_prime(2))
241+
self.assertTrue(is_prime(3))
242+
self.assertFalse(is_prime(4))
243+
self.assertFalse(is_prime(9))
244+
self.assertTrue(is_prime(97))
245+
self.assertFalse(is_prime(100))
246+
247+
def test_nth_prime(self) -> None:
248+
"""``nth_prime`` returns the first n primes, including 3 not 4."""
249+
self.assertEqual(nth_prime(1), [2])
250+
self.assertEqual(nth_prime(5), [2, 3, 5, 7, 11])
251+
self.assertEqual(nth_prime(0), [])
252+
253+
def test_hamm_negative_base(self) -> None:
254+
"""Negative Hammersley base uses the MATLAB ``(mod(i, n+1)+0.5)/n`` rule."""
255+
seq = hamm(8, -8)
256+
self.assertEqual(seq.shape, (8,))
257+
expected = (np.remainder(np.arange(1, 9), 9) + 0.5) / 8.0
258+
np.testing.assert_allclose(seq, expected)
259+
260+
def test_hamm_prime_base(self) -> None:
261+
"""Prime-base van der Corput samples lie in (0, 1)."""
262+
seq = hamm(16, 2)
263+
self.assertEqual(seq.shape, (16,))
264+
self.assertTrue(np.all(seq > 0.0) and np.all(seq < 1.0))
265+
266+
def test_peaks_and_local_peaks(self) -> None:
267+
"""Sine-wave extrema are recovered by ``peaks`` and ``local_peaks``."""
268+
time = np.arange(200, dtype=float)
269+
x = np.sin(2.0 * np.pi * time / 20.0)
270+
pks, locs = peaks(x)
271+
self.assertGreater(pks.size, 0)
272+
np.testing.assert_array_equal(pks, x[locs])
273+
self.assertTrue(np.all(pks > 0.5))
274+
275+
indmin, indmax = local_peaks(x)
276+
self.assertGreater(indmin.size, 2)
277+
self.assertGreater(indmax.size, 2)
278+
self.assertTrue(np.all(x[indmax] > 0.5))
279+
self.assertTrue(np.all(x[indmin] < -0.5))
280+
281+
def test_local_peaks_flat_signal(self) -> None:
282+
"""A near-zero / constant projection has no extrema."""
283+
indmin, indmax = local_peaks(np.zeros(50))
284+
self.assertEqual(indmin.size, 0)
285+
self.assertEqual(indmax.size, 0)
286+
287+
def test_zero_crossings(self) -> None:
288+
"""Sign changes and exact zeros are reported."""
289+
x = np.array([1.0, -1.0, -0.5, 0.2, 0.0, 0.3])
290+
zc = zero_crossings(x)
291+
self.assertTrue(np.any(zc == 0))
292+
self.assertTrue(np.any(zc == 4))
293+
294+
def test_zero_crossings_zero_run(self) -> None:
295+
"""A run of exact zeros contributes a midpoint index."""
296+
x = np.array([1.0, 0.0, 0.0, 0.0, -1.0])
297+
zc = zero_crossings(x)
298+
self.assertGreaterEqual(zc.size, 1)
299+
300+
def test_boundary_conditions_mode(self) -> None:
301+
"""Mirror extension is enabled only when there are enough extrema."""
302+
time = np.arange(1, 101, dtype=float)
303+
x = np.sin(2.0 * np.pi * time / 20.0)
304+
z = np.column_stack([x, np.cos(2.0 * np.pi * time / 20.0), 0.5 * x])
305+
indmin, indmax = local_peaks(x)
306+
tmin, tmax, zmin, zmax, mode = boundary_conditions(
307+
indmin, indmax, time, x, z, 2
308+
)
309+
self.assertEqual(mode, 1)
310+
self.assertEqual(zmin.shape[1], 3)
311+
self.assertEqual(zmax.shape[1], 3)
312+
self.assertGreater(tmin.size, indmin.size)
313+
self.assertTrue(np.all(np.diff(tmin) > 0))
314+
self.assertTrue(np.all(np.diff(tmax) > 0))
315+
316+
none_vals = boundary_conditions(np.array([2]), np.array([8]), time, x, z, 2)
317+
self.assertEqual(none_vals[-1], 0)
318+
self.assertTrue(all(v is None for v in none_vals[:-1]))
319+
320+
def test_envelope_mean_shape(self) -> None:
321+
"""``envelope_mean`` returns finite envelopes and extrema counts."""
322+
seq = self.memd.init_hammersley(3)
323+
time = np.arange(1, self.n_samples + 1, dtype=float)
324+
env, nem, nzm, amp = envelope_mean(
325+
self.signal.T, time, seq, self.memd.n_dir, self.n_samples, 3
326+
)
327+
self.assertEqual(env.shape, (self.n_samples, 3))
328+
self.assertEqual(nem.shape, (self.memd.n_dir,))
329+
self.assertEqual(nzm.shape, (self.memd.n_dir,))
330+
self.assertEqual(amp.shape, (self.n_samples,))
331+
self.assertTrue(np.all(np.isfinite(env)))
332+
self.assertTrue(np.all(nem >= 0))
333+
334+
def test_unit_direction_trivariate(self) -> None:
335+
"""The 2-sphere map of a Hammersley sample is a unit vector."""
336+
seq = MEMD(n_dir=8).init_hammersley(3)
337+
vec = unit_direction(seq, 0, 3)
338+
self.assertEqual(vec.shape, (3,))
339+
np.testing.assert_allclose(np.linalg.norm(vec), 1.0, atol=1e-12)
340+
341+
def test_spherical_coordinate_directions(self) -> None:
342+
"""Angular grids cluster near the poles relative to Hammersley."""
343+
grid = spherical_coordinate_directions(n_phi=12, n_theta=6)
344+
self.assertEqual(grid.shape, (72, 3))
345+
np.testing.assert_allclose(np.linalg.norm(grid, axis=1), 1.0, atol=1e-10)
346+
hammersley = MEMD(n_dir=64).direction_vectors(3)
347+
pole_axis = np.abs(grid[:, 0])
348+
self.assertGreater(pole_axis.max(), 0.98)
349+
self.assertTrue(np.any(np.abs(hammersley[:, 2]) < 0.2))
350+
351+
def test_packaged_hex_loader(self) -> None:
352+
"""Packaged MATLAB hex / 12 / 16 / Tai-chi arrays keep channel-major layout."""
353+
hex_data = load_memd_syn_hex()
354+
self.assertEqual(hex_data["signal"].shape, (6, 1001))
355+
self.assertEqual(hex_data["t"].shape, (1001,))
356+
357+
ch12 = load_memd_syn_12channel()
358+
self.assertEqual(ch12["signal"].shape, (12, 1001))
359+
360+
ch16 = load_memd_syn_16channel()
361+
self.assertEqual(ch16["signal"].shape, (16, 1001))
362+
363+
taichi = load_memd_taichi_hex()
364+
self.assertEqual(taichi["signal"].shape, (6, 800))
365+
self.assertTrue(np.all(np.isfinite(taichi["signal"])))
366+
367+
def test_packaged_hex_smoke(self) -> None:
368+
"""A short slice of the MATLAB hex demo reconstructs after MEMD."""
369+
signal = load_memd_syn_hex()["signal"][:, :160]
370+
imfs = MEMD(n_dir=8, max_iter=20).fit_transform(signal)
371+
np.testing.assert_allclose(imfs.sum(axis=0).T, signal, atol=1e-7)
372+
373+
374+
if __name__ == "__main__":
375+
unittest.main()

0 commit comments

Comments
 (0)