Coverage for test_analyser_2.py: 93%
270 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-12-05 13:53 +0100
« prev ^ index » next coverage.py v7.6.8, created at 2024-12-05 13:53 +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
12sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
13from src.nmrlineshapeanalyser.core import NMRProcessor
14from unittest.mock import mock_open
15import coverage
17class TestNMRProcessor(unittest.TestCase):
18 """Test suite for NMR Processor class."""
20 def setUp(self):
21 """Set up test fixtures before each test method."""
22 self.processor = NMRProcessor()
23 self.test_data = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
24 self.test_ppm = np.array([10.0, 8.0, 6.0, 4.0, 2.0])
25 self.processor.carrier_freq = 500.0
26 self.assertEqual(self.processor.carrier_freq, 500.0, "carrier_freq not set correctly in setUp")
28 # Close all existing plots
29 plt.close('all')
31 # Create temporary directory
32 self.temp_dir = tempfile.mkdtemp()
34 def tearDown(self):
35 """Clean up after each test method."""
36 plt.close('all')
38 # Clean up temporary directory
39 try:
40 shutil.rmtree(self.temp_dir)
41 except:
42 pass
44 @patch('nmrglue.bruker.read_pdata')
45 @patch('nmrglue.bruker.guess_udic')
46 def test_load_data(self, mock_guess_udic, mock_read_pdata):
47 """Test data loading functionality."""
48 # Mock the returned values
49 mock_dic = {}
50 mock_data = np.array([1.0, 2.0, 3.0])
51 mock_read_pdata.return_value = (mock_dic, mock_data)
53 # Mock udic with all required keys
54 mock_udic = [{
55 'label': '17O',
56 'size': 1024,
57 'complex': True,
58 'sw': 100.0,
59 'sf': 500.0,
60 'car': 0.0,
61 'obs': 500.0
62 }]
63 mock_guess_udic.return_value = mock_udic
65 # Test data loading
66 self.processor.load_data("dummy/path")
69 # Verify the data was loaded correctly
70 self.assertIsNotNone(self.processor.data)
71 self.assertEqual(self.processor.nucleus, 'O')
72 self.assertEqual(self.processor.number, '17')
73 self.assertEqual(self.processor.carrier_freq, 500.0)
75 def test_select_region(self):
76 """Test region selection functionality."""
77 # Setup test data
78 self.processor.ppm = np.array([0, 1, 2, 3, 4])
79 self.processor.data = np.array([0, 1, 2, 3, 4])
81 # Test normal case
82 x_region, y_region = self.processor.select_region(1, 3)
83 self.assertTrue(np.all(x_region >= 1))
84 self.assertTrue(np.all(x_region <= 3))
85 self.assertEqual(len(x_region), len(y_region))
87 # Test edge cases
88 x_region, y_region = self.processor.select_region(0, 4)
89 self.assertEqual(len(x_region), len(self.processor.ppm))
91 def test_normalize_data(self):
92 # Basic tests
93 x_data = np.array([1, 2, 3, 4, 5])
94 y_data = np.array([2, 4, 6, 8, 10])
95 x_norm, y_norm = self.processor.normalize_data(x_data, y_data)
97 assert np.array_equal(x_norm, x_data)
98 assert np.min(y_norm) == 0
99 assert np.max(y_norm) == 1
100 assert x_norm.shape == x_data.shape
101 assert y_norm.shape == y_data.shape
103 # Test reversibility
104 y_ground = np.min(y_data)
105 y_amp = np.max(y_data) - y_ground
106 y_reconstructed = y_norm * y_amp + y_ground
107 np.testing.assert_array_almost_equal(y_reconstructed, y_data)
109 # Test negative values with reversibility
110 y_data = np.array([-5, 0, 5])
111 x_norm, y_norm = self.processor.normalize_data(x_data[:3], y_data)
112 y_ground = np.min(y_data)
113 y_amp = np.max(y_data) - y_ground
114 y_reconstructed = y_norm * y_amp + y_ground
115 np.testing.assert_array_almost_equal(y_reconstructed, y_data)
117 # Test constant values
118 y_data = np.array([5, 5, 5])
119 x_norm, y_norm = self.processor.normalize_data(x_data[:3], y_data)
120 assert np.array_equal(y_norm, np.zeros_like(y_data))
122 # Test empty arrays
123 try:
124 self.processor.normalize_data(np.array([]), np.array([]))
125 assert False, "Expected ValueError for empty arrays"
126 except ValueError:
127 pass
129 # Test input unmodified
130 x_data = np.array([1, 2, 3])
131 y_data = np.array([2, 4, 6])
132 x_copy, y_copy = x_data.copy(), y_data.copy()
133 self.processor.normalize_data(x_data, y_data)
134 assert np.array_equal(x_data, x_copy)
135 assert np.array_equal(y_data, y_copy)
138 def test_pseudo_voigt(self):
139 """Test Pseudo-Voigt function calculation."""
140 x = np.linspace(-10, 10, 100)
141 x0, amp, width, eta = 0, 1, 2, 0.5
143 result = self.processor.pseudo_voigt(x, x0, amp, width, eta)
145 # Verify function properties
146 self.assertEqual(len(result), len(x))
147 self.assertTrue(np.all(result >= 0))
148 np.testing.assert_allclose(np.max(result), amp, rtol=0.01)
149 self.assertEqual(np.argmax(result), len(x)//2) # Peak should be at center
151 def test_pseudo_voigt(self):
152 """Test Pseudo-Voigt function calculation."""
153 x = np.linspace(-10, 10, 1000) # Increased points for better accuracy
154 x0, amp, width, eta = 0, 1, 2, 0.5
156 result = self.processor.pseudo_voigt(x, x0, amp, width, eta)
158 # Verify function properties
159 self.assertEqual(len(result), len(x))
160 self.assertTrue(np.all(result >= 0))
161 # Use looser tolerance for float comparison
162 np.testing.assert_allclose(np.max(result), amp, rtol=0.01, atol=0.01)
163 # Check peak position
164 peak_position = x[np.argmax(result)]
165 np.testing.assert_allclose(peak_position, x0, atol=0.05) # Max should not exceed sum of amplitudes
167 def test_fit_peaks(self):
168 """Test peak fitting functionality."""
169 # Create synthetic data with known peaks
170 x_data = np.linspace(0, 10, 1000)
171 y_data = (self.processor.pseudo_voigt(x_data, 3, 1, 1, 0.5) +
172 self.processor.pseudo_voigt(x_data, 7, 0.8, 1.2, 0.3))
173 y_data += np.random.normal(0, 0.01, len(x_data)) # Add noise
175 initial_params = [
176 3, 1, 1, 0.5, 0, # First peak
177 7, 0.8, 1.2, 0.3, 0 # Second peak
178 ]
179 fixed_x0 = [False, False]
181 # Perform fit
182 popt, metrics, fitted = self.processor.fit_peaks(x_data, y_data,
183 initial_params, fixed_x0)
185 # Verify fitting results
186 self.assertEqual(len(popt), len(initial_params))
187 self.assertEqual(len(metrics), 2)
188 self.assertEqual(len(fitted), len(x_data))
190 # Check fit quality
191 residuals = y_data - fitted
192 self.assertTrue(np.std(residuals) < 0.1)
194 def test_single_peak_no_fixed_params(self):
195 """Test fitting of a single peak with no fixed parameters."""
196 x = np.linspace(-10, 10, 1000)
198 self.processor.fixed_params = [(None, None, None, None, None)]
200 params = [3, 1, 1, 0.5, 0.1]
202 y = self.processor.pseudo_voigt_multiple(x, *params)
204 y_exp = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5) + 0.1
206 residuals = y - y_exp
208 self.assertTrue(np.std(residuals) < 0.1)
210 def test_single_peak_fixed_x0(self):
211 """Test fitting of a single peak with fixed x0."""
212 x = np.linspace(-10, 10, 1000)
213 fixed_x0 = 3
215 # Set up fixed parameters
216 self.processor.fixed_params = [(fixed_x0, None, None, None, None)]
218 # Test parameters: amp=1, width=1, eta=0.5, offset=0.1
219 params = [1, 1, 0.5, 0.1]
221 # Calculate using pseudo_voigt_multiple
222 result = self.processor.pseudo_voigt_multiple(x, *params)
224 # Calculate individual components for verification
225 sigma = 1 / (2 * np.sqrt(2 * np.log(2))) # width parameter
226 gamma = 1 / 2 # width parameter
228 # Gaussian component
229 gaussian = np.exp(-0.5 * ((x - fixed_x0) / sigma)**2)
231 # Lorentzian component
232 lorentzian = gamma**2 / ((x - fixed_x0)**2 + gamma**2)
234 # Combined pseudo-Voigt with amplitude and offset
235 expected = (0.5 * lorentzian + (1 - 0.5) * gaussian) + 0.1
237 # Scale by amplitude
238 expected = expected * 1
240 # Compare results
241 # Use a lower decimal precision due to numerical differences
242 np.testing.assert_array_almost_equal(result, expected, decimal=4)
244 def test_multiple_peaks_no_fixed_params(self):
245 """Test fitting of multiple peaks with no fixed parameters."""
246 x = np.linspace(-10, 10, 1000)
248 x0_1, x0_2 = -1.0, 1.0
250 self.processor.fixed_params = [
251 (x0_1, None, None, None, None),
252 (x0_2, None, None, None, None)
253 ]
255 params = [1.0, 1.5, 0.3, 0.1, 0.8, 2.0, 0.7, 0.2]
257 y = self.processor.pseudo_voigt_multiple(x, *params)
259 # Calculate expected result for first peak
260 sigma1 = params[1] / (2 * np.sqrt(2 * np.log(2)))
261 gamma1 = params[1] / 2
262 lorentzian1 = params[0] * (gamma1**2 / ((x - x0_1)**2 + gamma1**2))
263 gaussian1 = params[0] * np.exp(-0.5 * ((x - x0_1) / sigma1)**2)
264 peak1 = params[2] * lorentzian1 + (1 - params[2]) * gaussian1 + params[3]
266 # Calculate expected result for second peak
267 sigma2 = params[5] / (2 * np.sqrt(2 * np.log(2)))
268 gamma2 = params[5] / 2
269 lorentzian2 = params[4] * (gamma2**2 / ((x - x0_2)**2 + gamma2**2))
270 gaussian2 = params[4] * np.exp(-0.5 * ((x - x0_2) / sigma2)**2)
271 peak2 = params[6] * lorentzian2 + (1 - params[6]) * gaussian2 + params[7]
273 # Total expected result
274 y_exp = peak1 + peak2 - params[3] - params[7] # Subtract offsets to avoid double counting
276 residuals = y - y_exp
278 self.assertTrue(np.std(residuals) < 0.1)
280 def test_multiple_peaks_fixed_x0(self):
281 """Test fitting of multiple peaks with fixed x0."""
282 x = np.linspace(-10, 10, 1000)
284 fixed_x0 = -1.0
286 self.processor.fixed_params = [
287 (fixed_x0, None, None, None, None),
288 (None, None, None, None, None)
289 ]
291 params = [1.0, 1.5, 0.3, 0.1, 1.0, 0.8, 2.0, 0.7, 0.2]
293 y = self.processor.pseudo_voigt_multiple(x, *params)
295 # Calculate expected result for first peak (fixed x0)
296 sigma1 = params[1] / (2 * np.sqrt(2 * np.log(2)))
297 gamma1 = params[1] / 2
298 lorentzian1 = params[0] * (gamma1**2 / ((x - fixed_x0)**2 + gamma1**2))
299 gaussian1 = params[0] * np.exp(-0.5 * ((x - fixed_x0) / sigma1)**2)
300 peak1 = params[2] * lorentzian1 + (1 - params[2]) * gaussian1 + params[3]
302 # Calculate expected result for second peak (unfixed x0)
303 sigma2 = params[6] / (2 * np.sqrt(2 * np.log(2)))
304 gamma2 = params[6] / 2
305 lorentzian2 = params[5] * (gamma2**2 / ((x - params[4])**2 + gamma2**2))
306 gaussian2 = params[5] * np.exp(-0.5 * ((x - params[4]) / sigma2)**2)
307 peak2 = params[7] * lorentzian2 + (1 - params[7]) * gaussian2 + params[8]
309 # Total expected result
310 y_exp = peak1 + peak2 - params[3] - params[8] # Subtract offsets to avoid double counting
312 residuals = y - y_exp
314 self.assertTrue(np.std(residuals) < 0.1)
316 def test_multiple_peaks_mixed_fixed_x0(self):
317 """Test fitting of multiple peaks with mixed fixed and unfixed x0."""
318 x = np.linspace(-10, 10, 1000)
320 self.processor.fixed_params = [(3, None, None, None, None),
321 (None, None, None, None, None)]
323 params = [1, 1, 0.5, 0.1, # First peak (fixed x0)
324 7, 0.8, 1.2, 0.3, 0.2] # Second peak (unfixed x0)
326 y = self.processor.pseudo_voigt_multiple(x, *params)
328 # Calculate expected result - each peak includes its own offset
329 peak1 = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5) + 0.1
330 peak2 = self.processor.pseudo_voigt(x, 7, 0.8, 1.2, 0.3) + 0.2
332 y_exp = peak1 + peak2
334 np.testing.assert_array_almost_equal(y_exp, y, decimal=6)
336 def test_invalid_params_length(self):
337 """Test handling of invalid parameters length."""
338 x = np.linspace(-10, 10, 1000)
340 self.processor.fixed_params = [(None, None, None, None, None)] * 2
342 params = [3, 1, 1, 0.5, 0.1, 7, 0.8, 1.2, 0.3] # Missing one parameter
344 with self.assertRaises(ValueError):
345 self.processor.pseudo_voigt_multiple(x, *params)
347 def test_edge_cases(self):
348 """Test pseudo_voigt_multiple with edge cases"""
349 # Test with zero amplitude
350 x = np.linspace(-10, 10, 1000)
351 self.processor.fixed_params = [(None, None, None, None, None)]
352 params = [0.0, 0.0, 2.0, 0.5, 0.0]
353 result = self.processor.pseudo_voigt_multiple(x, *params)
354 np.testing.assert_array_almost_equal(result, np.zeros_like(x))
357 # Test with pure Gaussian (eta = 0)
358 params = [0.0, 1.0, 2.0, 0.0, 0.0]
359 result = self.processor.pseudo_voigt_multiple(x, *params)
360 sigma = params[2] / (2 * np.sqrt(2 * np.log(2)))
361 y_exp = params[1] * np.exp(-0.5 * ((x - params[0]) / sigma)**2) + params[4]
363 residuals = result - y_exp
365 self.assertTrue(np.std(residuals) < 0.1)
367 # Test with pure Lorentzian (eta = 1)
368 params = [0.0, 1.0, 2.0, 1.0, 0.0]
369 result = self.processor.pseudo_voigt_multiple(x, *params)
370 sigma = params[2] / (2 * np.sqrt(2 * np.log(2)))
371 y_exp = params[1] * np.exp(-0.5 * ((x - params[0]) / sigma)**2) + params[4]
373 residuals = result - y_exp
375 self.assertTrue(np.std(residuals) < 0.1)
377 def test_invalid_input_handling(self):
378 """Test handling of invalid inputs."""
379 # Set up processor with valid data range
380 self.processor.ppm = np.array([0, 1, 2, 3, 4])
381 self.processor.data = np.array([0, 1, 2, 3, 4])
383 # Test invalid region selection
384 with self.assertRaises(ValueError):
385 # Make sure these values are well outside the data range
386 self.processor.select_region(10, 20) # Changed to clearly invalid range
388 # Test missing data
389 processor_without_data = NMRProcessor()
390 with self.assertRaises(ValueError):
391 processor_without_data.select_region(1, 2)
393 # Test invalid peak fitting parameters
394 x_data = np.linspace(0, 10, 100)
395 y_data = np.zeros_like(x_data)
396 invalid_params = [1, 2, 3] # Invalid number of parameters
397 with self.assertRaises(ValueError):
398 self.processor.fit_peaks(x_data, y_data, invalid_params)
402 def test_plot_results(self):
403 """Test plotting functionality."""
404 # Create test data
405 x_data = np.linspace(0, 10, 100)
406 y_data = np.zeros_like(x_data)
407 fitted_data = np.zeros_like(x_data)
408 popt = np.array([1, 1, 1, 0.5, 0])
410 # Set required attributes
411 self.processor.nucleus = 'O'
412 self.processor.number = '17'
414 # Test plotting - remove metrics parameter since it's not used in plot_results
415 fig, ax1, components = self.processor.plot_results(
416 x_data, y_data, fitted_data, popt
417 )
419 # Verify plot objects
420 self.assertIsNotNone(fig)
421 self.assertIsInstance(ax1, plt.Axes)
422 self.assertIsInstance(components, list)
423 self.assertEqual(len(components), 1) # One component for single peak
425 # Check if the axes have the correct labels and properties
426 self.assertTrue(ax1.xaxis.get_label_text().startswith('$^{17} \\ O$'))
427 self.assertIsNotNone(ax1.get_legend())
429 plt.close(fig)
432 def test_save_results(self):
433 """Test results saving functionality."""
434 import matplotlib
435 matplotlib.use('Agg')
437 try:
438 # Create test data
439 x_data = np.linspace(0, 10, 100)
440 y_data = np.zeros_like(x_data)
441 fitted_data = np.zeros_like(x_data)
442 components = [np.zeros_like(x_data)]
443 metrics = [{
444 'x0': (1, 0.1),
445 'amplitude': (1, 0.1),
446 'width': (1, 0.1),
447 'eta': (0.5, 0.1),
448 'offset': (0, 0.1),
449 'gaussian_area': (1, 0.1),
450 'lorentzian_area': (1, 0.1),
451 'total_area': (2, 0.2)
452 }]
453 popt = np.array([1, 1, 1, 0.5, 0])
455 # Create temporary directory for testing
456 with tempfile.TemporaryDirectory() as temp_dir:
457 test_filepath = os.path.join(temp_dir, 'test_')
459 # Mock the figure and its savefig method
460 mock_fig = Mock()
461 mock_axes = Mock()
462 mock_components = [Mock()]
464 # Set up all the required mocks
465 with patch.object(self.processor, 'plot_results',
466 return_value=(mock_fig, mock_axes, mock_components)) as mock_plot:
467 with patch('builtins.open', mock_open()) as mock_file:
468 with patch('pandas.DataFrame.to_csv') as mock_to_csv:
469 with patch.object(mock_fig, 'savefig') as mock_savefig:
470 with patch('matplotlib.pyplot.close') as mock_close:
472 # Call save_results
473 self.processor.save_results(
474 test_filepath, x_data, y_data, fitted_data,
475 metrics, popt, components
476 )
478 # Verify all the saving methods were called correctly
479 # Check if plot_results was called with correct arguments
480 mock_plot.assert_called_once_with(
481 x_data, y_data, fitted_data, popt
482 )
484 mock_savefig.assert_called_once_with(
485 test_filepath + 'pseudoVoigtPeakFit.png',
486 bbox_inches='tight'
487 )
488 mock_close.assert_called_once_with(mock_fig)
490 # Verify DataFrame.to_csv was called for peak data
491 mock_to_csv.assert_called_once_with(
492 test_filepath + 'peak_data.csv',
493 index=False
494 )
496 # Verify metrics file was opened and written
497 mock_file.assert_called_with(
498 test_filepath + 'pseudoVoigtPeak_metrics.txt',
499 'w'
500 )
502 except Exception as e:
503 self.fail(f"Test failed with error: {str(e)}")
505 finally:
506 plt.close('all')
509if __name__ == '__main__':
511 cov = coverage.Coverage()
512 cov.start()
514 unittest.main(verbosity=2)
516 cov.stop()
518 cov.save()
520 cov.html_report(directory='coverage_html')