Coverage for test_analyser_2.py: 93%
260 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-12-04 17:20 +0100
« prev ^ index » next coverage.py v7.6.8, created at 2024-12-04 17:20 +0100
1import unittest
2import numpy as np
3import webbrowser
4import os
5import nmrglue as ng
6from unittest.mock import Mock, patch, mock_open
7import matplotlib.pyplot as plt
8import os
9import shutil
10import tempfile
11import sys
12from nmrlineshapeanalyser.core import NMRProcessor
13from unittest.mock import mock_open
14import coverage
16class TestNMRProcessor(unittest.TestCase):
17 """Test suite for NMR Processor class."""
19 def setUp(self):
20 """Set up test fixtures before each test method."""
21 self.processor = NMRProcessor()
22 self.test_data = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
23 self.test_ppm = np.array([10.0, 8.0, 6.0, 4.0, 2.0])
24 self.processor.carrier_freq = 500.0
25 self.assertEqual(self.processor.carrier_freq, 500.0, "carrier_freq not set correctly in setUp")
27 # Close all existing plots
28 plt.close('all')
30 # Create temporary directory
31 self.temp_dir = tempfile.mkdtemp()
33 def tearDown(self):
34 """Clean up after each test method."""
35 plt.close('all')
37 # Clean up temporary directory
38 try:
39 shutil.rmtree(self.temp_dir)
40 except:
41 pass
43 @patch('nmrglue.bruker.read_pdata')
44 @patch('nmrglue.bruker.guess_udic')
45 def test_load_data(self, mock_guess_udic, mock_read_pdata):
46 """Test data loading functionality."""
47 # Mock the returned values
48 mock_dic = {}
49 mock_data = np.array([1.0, 2.0, 3.0])
50 mock_read_pdata.return_value = (mock_dic, mock_data)
52 # Mock udic with all required keys
53 mock_udic = [{
54 'label': '17O',
55 'size': 1024,
56 'complex': True,
57 'sw': 100.0,
58 'sf': 500.0,
59 'car': 0.0,
60 'obs': 500.0
61 }]
62 mock_guess_udic.return_value = mock_udic
64 # Test data loading
65 self.processor.load_data("dummy/path")
68 # Verify the data was loaded correctly
69 self.assertIsNotNone(self.processor.data)
70 self.assertEqual(self.processor.nucleus, 'O')
71 self.assertEqual(self.processor.number, '17')
72 self.assertEqual(self.processor.carrier_freq, 500.0)
74 def test_select_region(self):
75 """Test region selection functionality."""
76 # Setup test data
77 self.processor.ppm = np.array([0, 1, 2, 3, 4])
78 self.processor.data = np.array([0, 1, 2, 3, 4])
80 # Test normal case
81 x_region, y_region = self.processor.select_region(1, 3)
82 self.assertTrue(np.all(x_region >= 1))
83 self.assertTrue(np.all(x_region <= 3))
84 self.assertEqual(len(x_region), len(y_region))
86 # Test edge cases
87 x_region, y_region = self.processor.select_region(0, 4)
88 self.assertEqual(len(x_region), len(self.processor.ppm))
90 def test_normalize_data(self):
91 # Basic tests
92 x_data = np.array([1, 2, 3, 4, 5])
93 y_data = np.array([2, 4, 6, 8, 10])
94 x_norm, y_norm = self.processor.normalize_data(x_data, y_data)
96 assert np.array_equal(x_norm, x_data)
97 assert np.min(y_norm) == 0
98 assert np.max(y_norm) == 1
99 assert x_norm.shape == x_data.shape
100 assert y_norm.shape == y_data.shape
102 # Test reversibility
103 y_ground = np.min(y_data)
104 y_amp = np.max(y_data) - y_ground
105 y_reconstructed = y_norm * y_amp + y_ground
106 np.testing.assert_array_almost_equal(y_reconstructed, y_data)
108 # Test negative values with reversibility
109 y_data = np.array([-5, 0, 5])
110 x_norm, y_norm = self.processor.normalize_data(x_data[:3], y_data)
111 y_ground = np.min(y_data)
112 y_amp = np.max(y_data) - y_ground
113 y_reconstructed = y_norm * y_amp + y_ground
114 np.testing.assert_array_almost_equal(y_reconstructed, y_data)
116 # Test constant values
117 y_data = np.array([5, 5, 5])
118 x_norm, y_norm = self.processor.normalize_data(x_data[:3], y_data)
119 assert np.array_equal(y_norm, np.zeros_like(y_data))
121 # Test empty arrays
122 try:
123 self.processor.normalize_data(np.array([]), np.array([]))
124 assert False, "Expected ValueError for empty arrays"
125 except ValueError:
126 pass
128 # Test input unmodified
129 x_data = np.array([1, 2, 3])
130 y_data = np.array([2, 4, 6])
131 x_copy, y_copy = x_data.copy(), y_data.copy()
132 self.processor.normalize_data(x_data, y_data)
133 assert np.array_equal(x_data, x_copy)
134 assert np.array_equal(y_data, y_copy)
137 def test_pseudo_voigt(self):
138 """Test Pseudo-Voigt function calculation."""
139 x = np.linspace(-10, 10, 100)
140 x0, amp, width, eta = 0, 1, 2, 0.5
142 result = self.processor.pseudo_voigt(x, x0, amp, width, eta)
144 # Verify function properties
145 self.assertEqual(len(result), len(x))
146 self.assertTrue(np.all(result >= 0))
147 np.testing.assert_allclose(np.max(result), amp, rtol=0.01)
148 self.assertEqual(np.argmax(result), len(x)//2) # Peak should be at center
150 def test_pseudo_voigt(self):
151 """Test Pseudo-Voigt function calculation."""
152 x = np.linspace(-10, 10, 1000) # Increased points for better accuracy
153 x0, amp, width, eta = 0, 1, 2, 0.5
155 result = self.processor.pseudo_voigt(x, x0, amp, width, eta)
157 # Verify function properties
158 self.assertEqual(len(result), len(x))
159 self.assertTrue(np.all(result >= 0))
160 # Use looser tolerance for float comparison
161 np.testing.assert_allclose(np.max(result), amp, rtol=0.01, atol=0.01)
162 # Check peak position
163 peak_position = x[np.argmax(result)]
164 np.testing.assert_allclose(peak_position, x0, atol=0.05) # Max should not exceed sum of amplitudes
166 def test_fit_peaks(self):
167 """Test peak fitting functionality."""
168 # Create synthetic data with known peaks
169 x_data = np.linspace(0, 10, 1000)
170 y_data = (self.processor.pseudo_voigt(x_data, 3, 1, 1, 0.5) +
171 self.processor.pseudo_voigt(x_data, 7, 0.8, 1.2, 0.3))
172 y_data += np.random.normal(0, 0.01, len(x_data)) # Add noise
174 initial_params = [
175 3, 1, 1, 0.5, 0, # First peak
176 7, 0.8, 1.2, 0.3, 0 # Second peak
177 ]
178 fixed_x0 = [False, False]
180 # Perform fit
181 popt, metrics, fitted = self.processor.fit_peaks(x_data, y_data,
182 initial_params, fixed_x0)
184 # Verify fitting results
185 self.assertEqual(len(popt), len(initial_params))
186 self.assertEqual(len(metrics), 2)
187 self.assertEqual(len(fitted), len(x_data))
189 # Check fit quality
190 residuals = y_data - fitted
191 self.assertTrue(np.std(residuals) < 0.1)
193 def test_single_peak_no_fixed_params(self):
194 """Test fitting of a single peak with no fixed parameters."""
195 x = np.linspace(-10, 10, 1000)
197 self.processor.fixed_params = [(None, None, None, None, None)]
199 params = [3, 1, 1, 0.5, 0.1]
201 y = self.processor.pseudo_voigt_multiple(x, *params)
203 y_exp = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5) + 0.1
205 residuals = y - y_exp
207 self.assertTrue(np.std(residuals) < 0.1)
209 def test_single_peak_fixed_x0(self):
210 """Test fitting of a single peak with fixed x0."""
211 x = np.linspace(-10, 10, 1000)
212 fixed_x0 = 3
214 # Set up fixed parameters
215 self.processor.fixed_params = [(fixed_x0, None, None, None, None)]
217 # Test parameters: amp=1, width=1, eta=0.5, offset=0.1
218 params = [1, 1, 0.5, 0.1]
220 # Calculate using pseudo_voigt_multiple
221 result = self.processor.pseudo_voigt_multiple(x, *params)
223 # Calculate individual components for verification
224 sigma = 1 / (2 * np.sqrt(2 * np.log(2))) # width parameter
225 gamma = 1 / 2 # width parameter
227 # Gaussian component
228 gaussian = np.exp(-0.5 * ((x - fixed_x0) / sigma)**2)
230 # Lorentzian component
231 lorentzian = gamma**2 / ((x - fixed_x0)**2 + gamma**2)
233 # Combined pseudo-Voigt with amplitude and offset
234 expected = (0.5 * lorentzian + (1 - 0.5) * gaussian) + 0.1
236 # Scale by amplitude
237 expected = expected * 1
239 # Compare results
240 # Use a lower decimal precision due to numerical differences
241 np.testing.assert_array_almost_equal(result, expected, decimal=4)
243 def test_multiple_peaks_no_fixed_params(self):
244 """Test fitting of multiple peaks with no fixed parameters."""
245 x = np.linspace(-10, 10, 1000)
247 x0_1, x0_2 = -1.0, 1.0
249 self.processor.fixed_params = [
250 (x0_1, None, None, None, None),
251 (x0_2, None, None, None, None)
252 ]
254 params = [1.0, 1.5, 0.3, 0.1, 0.8, 2.0, 0.7, 0.2]
256 y = self.processor.pseudo_voigt_multiple(x, *params)
258 # Calculate expected result for first peak
259 sigma1 = params[1] / (2 * np.sqrt(2 * np.log(2)))
260 gamma1 = params[1] / 2
261 lorentzian1 = params[0] * (gamma1**2 / ((x - x0_1)**2 + gamma1**2))
262 gaussian1 = params[0] * np.exp(-0.5 * ((x - x0_1) / sigma1)**2)
263 peak1 = params[2] * lorentzian1 + (1 - params[2]) * gaussian1 + params[3]
265 # Calculate expected result for second peak
266 sigma2 = params[5] / (2 * np.sqrt(2 * np.log(2)))
267 gamma2 = params[5] / 2
268 lorentzian2 = params[4] * (gamma2**2 / ((x - x0_2)**2 + gamma2**2))
269 gaussian2 = params[4] * np.exp(-0.5 * ((x - x0_2) / sigma2)**2)
270 peak2 = params[6] * lorentzian2 + (1 - params[6]) * gaussian2 + params[7]
272 # Total expected result
273 y_exp = peak1 + peak2 - params[3] - params[7] # Subtract offsets to avoid double counting
275 residuals = y - y_exp
277 self.assertTrue(np.std(residuals) < 0.1)
279 def test_multiple_peaks_fixed_x0(self):
280 """Test fitting of multiple peaks with fixed x0."""
281 x = np.linspace(-10, 10, 1000)
283 fixed_x0 = -1.0
285 self.processor.fixed_params = [
286 (fixed_x0, None, None, None, None),
287 (None, None, None, None, None)
288 ]
290 params = [1.0, 1.5, 0.3, 0.1, 1.0, 0.8, 2.0, 0.7, 0.2]
292 y = self.processor.pseudo_voigt_multiple(x, *params)
294 # Calculate expected result for first peak (fixed x0)
295 sigma1 = params[1] / (2 * np.sqrt(2 * np.log(2)))
296 gamma1 = params[1] / 2
297 lorentzian1 = params[0] * (gamma1**2 / ((x - fixed_x0)**2 + gamma1**2))
298 gaussian1 = params[0] * np.exp(-0.5 * ((x - fixed_x0) / sigma1)**2)
299 peak1 = params[2] * lorentzian1 + (1 - params[2]) * gaussian1 + params[3]
301 # Calculate expected result for second peak (unfixed x0)
302 sigma2 = params[6] / (2 * np.sqrt(2 * np.log(2)))
303 gamma2 = params[6] / 2
304 lorentzian2 = params[5] * (gamma2**2 / ((x - params[4])**2 + gamma2**2))
305 gaussian2 = params[5] * np.exp(-0.5 * ((x - params[4]) / sigma2)**2)
306 peak2 = params[7] * lorentzian2 + (1 - params[7]) * gaussian2 + params[8]
308 # Total expected result
309 y_exp = peak1 + peak2 - params[3] - params[8] # Subtract offsets to avoid double counting
311 residuals = y - y_exp
313 self.assertTrue(np.std(residuals) < 0.1)
315 def test_multiple_peaks_mixed_fixed_x0(self):
316 """Test fitting of multiple peaks with mixed fixed and unfixed x0."""
317 x = np.linspace(-10, 10, 1000)
319 self.processor.fixed_params = [(3, None, None, None, None),
320 (None, None, None, None, None)]
322 params = [1, 1, 0.5, 0.1, # First peak (fixed x0)
323 7, 0.8, 1.2, 0.3, 0.2] # Second peak (unfixed x0)
325 y = self.processor.pseudo_voigt_multiple(x, *params)
327 # Calculate expected result - each peak includes its own offset
328 peak1 = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5) + 0.1
329 peak2 = self.processor.pseudo_voigt(x, 7, 0.8, 1.2, 0.3) + 0.2
331 y_exp = peak1 + peak2
333 np.testing.assert_array_almost_equal(y_exp, y, decimal=6)
335 def test_invalid_params_length(self):
336 """Test handling of invalid parameters length."""
337 x = np.linspace(-10, 10, 1000)
339 self.processor.fixed_params = [(None, None, None, None, None)] * 2
341 params = [3, 1, 1, 0.5, 0.1, 7, 0.8, 1.2, 0.3] # Missing one parameter
343 with self.assertRaises(ValueError):
344 self.processor.pseudo_voigt_multiple(x, *params)
346 def test_edge_cases(self):
347 """Test pseudo_voigt_multiple with edge cases"""
348 # Test with zero amplitude
349 x = np.linspace(-10, 10, 1000)
350 self.processor.fixed_params = [(None, None, None, None, None)]
351 params = [0.0, 0.0, 2.0, 0.5, 0.0]
352 result = self.processor.pseudo_voigt_multiple(x, *params)
353 np.testing.assert_array_almost_equal(result, np.zeros_like(x))
356 # Test with pure Gaussian (eta = 0)
357 params = [0.0, 1.0, 2.0, 0.0, 0.0]
358 result = self.processor.pseudo_voigt_multiple(x, *params)
359 sigma = params[2] / (2 * np.sqrt(2 * np.log(2)))
360 y_exp = params[1] * np.exp(-0.5 * ((x - params[0]) / sigma)**2) + params[4]
362 residuals = result - y_exp
364 self.assertTrue(np.std(residuals) < 0.1)
366 # Test with pure Lorentzian (eta = 1)
367 params = [0.0, 1.0, 2.0, 1.0, 0.0]
368 result = self.processor.pseudo_voigt_multiple(x, *params)
369 sigma = params[2] / (2 * np.sqrt(2 * np.log(2)))
370 y_exp = params[1] * np.exp(-0.5 * ((x - params[0]) / sigma)**2) + params[4]
372 residuals = result - y_exp
374 self.assertTrue(np.std(residuals) < 0.1)
376 def test_invalid_input_handling(self):
377 """Test handling of invalid inputs."""
378 # Set up processor with valid data range
379 self.processor.ppm = np.array([0, 1, 2, 3, 4])
380 self.processor.data = np.array([0, 1, 2, 3, 4])
382 # Test invalid region selection
383 with self.assertRaises(ValueError):
384 # Make sure these values are well outside the data range
385 self.processor.select_region(10, 20) # Changed to clearly invalid range
387 # Test missing data
388 processor_without_data = NMRProcessor()
389 with self.assertRaises(ValueError):
390 processor_without_data.select_region(1, 2)
392 # Test invalid peak fitting parameters
393 x_data = np.linspace(0, 10, 100)
394 y_data = np.zeros_like(x_data)
395 invalid_params = [1, 2, 3] # Invalid number of parameters
396 with self.assertRaises(ValueError):
397 self.processor.fit_peaks(x_data, y_data, invalid_params)
401 def test_save_results(self):
402 """Test results saving functionality."""
403 import matplotlib
404 matplotlib.use('Agg')
406 try:
407 # Create test data
408 x_data = np.linspace(0, 10, 100)
409 y_data = np.zeros_like(x_data)
410 fitted_data = np.zeros_like(x_data)
411 components = [np.zeros_like(x_data)]
412 # self.processor.carrier_freq = 500.0
413 metrics = [{
414 'x0': (1, 0.1),
415 'amplitude': (1, 0.1),
416 'width': (1, 0.1),
417 'eta': (0.5, 0.1),
418 'offset': (0, 0.1),
419 'gaussian_area': (1, 0.1),
420 'lorentzian_area': (1, 0.1),
421 'total_area': (2, 0.2)
422 }]
423 popt = np.array([1, 1, 1, 0.5, 0])
425 # Create temporary directory for testing
426 with tempfile.TemporaryDirectory() as temp_dir:
427 test_filepath = os.path.join(temp_dir, 'test_')
429 # Mock the figure and its savefig method
430 mock_fig = Mock()
431 mock_axes = (Mock(), Mock())
432 mock_components = [Mock()]
434 # Set up all the required mocks
435 with patch.object(self.processor, 'plot_results',
436 return_value=(mock_fig, mock_axes, mock_components)) as mock_plot, \
437 patch('builtins.open', mock_open()) as mock_file, \
438 patch('pandas.DataFrame.to_csv') as mock_to_csv, \
439 patch.object(mock_fig, 'savefig') as mock_savefig, \
440 patch('matplotlib.pyplot.close') as mock_close:
442 # Call save_results
443 self.processor.save_results(
444 test_filepath, x_data, y_data, fitted_data,
445 metrics, popt, components
446 )
448 # Verify all the saving methods were called correctly
449 mock_plot.assert_called_once()
450 mock_savefig.assert_called_once_with(
451 test_filepath + 'pseudoVoigtPeakFit.png',
452 bbox_inches='tight'
453 )
454 mock_close.assert_called_once_with(mock_fig)
456 # Verify DataFrame.to_csv was called for peak data
457 mock_to_csv.assert_called_once_with(
458 test_filepath + 'peak_data.csv',
459 index=False
460 )
462 # Verify metrics file was opened and written
463 mock_file.assert_called_with(
464 test_filepath + 'pseudoVoigtPeak_metrics.txt',
465 'w'
466 )
468 except Exception as e:
469 self.fail(f"Test failed with error: {str(e)}")
471 finally:
472 plt.close('all')
473 def test_plot_results(self):
474 """Test plotting functionality."""
475 # Create test data
476 x_data = np.linspace(0, 10, 100)
477 y_data = np.zeros_like(x_data)
478 fitted_data = np.zeros_like(x_data)
480 # self.processor.carrier_freq = 500.0
482 metrics = [{
483 'x0': (1, 0.1),
484 'amplitude': (1, 0.1),
485 'width': (1, 0.1),
486 'eta': (0.5, 0.1),
487 'offset': (0, 0.1),
488 'gaussian_area': (1, 0.1),
489 'lorentzian_area': (1, 0.1),
490 'total_area': (2, 0.2)
491 }]
492 popt = np.array([1, 1, 1, 0.5, 0])
494 # Test plotting
495 fig, ax1, components = self.processor.plot_results(
496 x_data, y_data, fitted_data, metrics, popt
497 )
499 # Verify plot objects
500 self.assertIsNotNone(fig)
501 self.assertEqual(len(components), 1)
502 self.assertTrue(isinstance(ax1, plt.Axes))
505if __name__ == '__main__':
507 cov = coverage.Coverage()
508 cov.start()
510 unittest.main(verbosity=2)
512 cov.stop()
514 cov.save()
516 cov.html_report(directory='coverage_html')
518 # webbrowser.open(os.path.join('coverage_html', 'index.html'))