|
| 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