Coverage for test/test_analyser_2.py: 77%
270 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 17:40 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 17:40 +0200
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 glob
12import sys
13sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
14from src.nmrlineshapeanalyser.core import NMRProcessor
15from unittest.mock import mock_open
16import coverage
17import pandas as pd
18class TestNMRProcessor(unittest.TestCase):
19 """Test suite for NMR Processor class."""
21 def setUp(self):
22 """Set up test fixtures before each test method."""
23 self.processor = NMRProcessor()
24 self.test_data = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
25 self.test_ppm = np.array([10.0, 8.0, 6.0, 4.0, 2.0])
26 self.processor.larmor_freq = 500.0
27 self.larmor_freq = 500.0
28 self.filepath = "dummy/path"
29 self.atomic_no = "1"
30 self.nucleus = "H"
31 self.assertEqual(self.processor.larmor_freq, 500.0, "larmor_freq not set correctly in setUp")
33 # Close all existing plots
34 plt.close('all')
36 # Create temporary directory
37 self.temp_dir = tempfile.mkdtemp()
39 def tearDown(self):
40 """Clean up after each test method."""
41 plt.close('all')
43 # Clean up temporary directory
44 try:
45 shutil.rmtree(self.temp_dir)
46 except:
47 pass
49 @patch('glob.glob')
50 @patch('pandas.read_csv')
51 def test_load_csv(self, mock_read_csv, mock_glob):
52 """Test loading CSV data."""
53 # Mock the glob to return a dummy file path
54 mock_glob.return_value = [os.path.join(self.filepath, 'test.csv')]
56 # Create a mock DataFrame
57 mock_data = pd.DataFrame({
58 'ppm': [10, 20, 30],
59 'Intensity': [100, 200, 300]
60 })
61 mock_read_csv.return_value = mock_data
63 # Call the method
64 self.processor.load_csv(self.filepath, self.atomic_no, self.nucleus, self.larmor_freq)
66 # Verify the data was loaded correctly
67 np.testing.assert_array_equal(self.processor.ppm, np.array([10, 20, 30]))
68 np.testing.assert_array_equal(self.processor.data, np.array([100 + 1j*0, 200 + 1j*0, 300 + 1j*0]))
69 self.assertEqual(self.processor.number, self.atomic_no)
70 self.assertEqual(self.processor.nucleus, self.nucleus)
71 self.assertEqual(self.processor.larmor_freq, self.larmor_freq)
73 @patch('glob.glob')
74 def test_load_csv_file_not_found(self, mock_glob):
75 """Test loading CSV data when no files are found."""
76 # Mock the glob to return an empty list
77 mock_glob.return_value = []
79 with self.assertRaises(FileNotFoundError):
80 self.processor.load_csv(self.filepath, self.atomic_no, self.nucleus, self.larmor_freq)
82 @patch('glob.glob')
83 @patch('pandas.read_csv')
84 def test_load_csv_missing_columns(self, mock_read_csv, mock_glob):
85 """Test loading CSV data with missing columns."""
86 # Mock the glob to return a dummy file path
87 mock_glob.return_value = [os.path.join(self.filepath, 'test.csv')]
89 # Create a mock DataFrame with missing columns
90 mock_data = pd.DataFrame({
91 'ppm': [10, 20, 30]
92 })
93 mock_read_csv.return_value = mock_data
95 with self.assertRaises(ValueError):
96 self.processor.load_csv(self.filepath, self.atomic_no, self.nucleus, self.larmor_freq)
98 def test_select_region(self):
99 """Test region selection functionality."""
100 # Setup test data
101 self.processor.ppm = np.array([0, 1, 2, 3, 4])
102 self.processor.data = np.array([0, 1, 2, 3, 4])
104 # Test normal case
105 x_region, y_region = self.processor.select_region(1, 3)
106 self.assertTrue(np.all(x_region >= 1))
107 self.assertTrue(np.all(x_region <= 3))
108 self.assertEqual(len(x_region), len(y_region))
110 # Test edge cases
111 x_region, y_region = self.processor.select_region(0, 4)
112 self.assertEqual(len(x_region), len(self.processor.ppm))
114 def test_normalize_data(self):
115 # Basic tests
116 x_data = np.array([1, 2, 3, 4, 5])
117 y_data = np.array([2, 4, 6, 8, 10])
118 x_norm, y_norm, y_amp, y_ground = self.processor.normalize_data(x_data, y_data)
120 assert np.array_equal(x_norm, x_data)
121 assert np.min(y_norm) == 0
122 assert np.max(y_norm) == 1
123 assert x_norm.shape == x_data.shape
124 assert y_norm.shape == y_data.shape
126 # Test reversibility
127 y_ground = np.min(y_data)
128 y_amp = np.max(y_data) - y_ground
129 y_reconstructed = y_norm * y_amp + y_ground
130 np.testing.assert_array_almost_equal(y_reconstructed, y_data)
132 # Test negative values with reversibility
133 y_data = np.array([-5, 0, 5])
134 x_norm, y_norm, _, _ = self.processor.normalize_data(x_data[:3], y_data)
135 y_ground = np.min(y_data)
136 y_amp = np.max(y_data) - y_ground
137 y_reconstructed = y_norm * y_amp + y_ground
138 np.testing.assert_array_almost_equal(y_reconstructed, y_data)
140 # Test constant values
141 y_data = np.array([5, 5, 5])
142 x_norm, y_norm, _, _ = self.processor.normalize_data(x_data[:3], y_data)
143 assert np.array_equal(y_norm, np.zeros_like(y_data))
145 # Test empty arrays
146 try:
147 self.processor.normalize_data(np.array([]), np.array([]))
148 assert False, "Expected ValueError for empty arrays"
149 except ValueError:
150 pass
152 # Test input unmodified
153 x_data = np.array([1, 2, 3])
154 y_data = np.array([2, 4, 6])
155 x_copy, y_copy = x_data.copy(), y_data.copy()
156 self.processor.normalize_data(x_data, y_data)
157 assert np.array_equal(x_data, x_copy)
158 assert np.array_equal(y_data, y_copy)
161 def test_pseudo_voigt(self):
162 """Test Pseudo-Voigt function calculation."""
163 x = np.linspace(-10, 10, 100)
164 x0, amp, width, eta = 0, 1, 2, 0.5
166 result = self.processor.pseudo_voigt(x, x0, amp, width, eta)
168 # Verify function properties
169 self.assertEqual(len(result), len(x))
170 self.assertTrue(np.all(result >= 0))
171 np.testing.assert_allclose(np.max(result), amp, rtol=0.01)
172 self.assertEqual(np.argmax(result), len(x)//2) # Peak should be at center
174 def test_pseudo_voigt(self):
175 """Test Pseudo-Voigt function calculation."""
176 x = np.linspace(-10, 10, 1000) # Increased points for better accuracy
177 x0, amp, width, eta = 0, 1, 2, 0.5
179 result = self.processor.pseudo_voigt(x, x0, amp, width, eta)
181 # Verify function properties
182 self.assertEqual(len(result), len(x))
183 self.assertTrue(np.all(result >= 0))
184 # Use looser tolerance for float comparison
185 np.testing.assert_allclose(np.max(result), amp, rtol=0.01, atol=0.01)
186 # Check peak position
187 peak_position = x[np.argmax(result)]
188 np.testing.assert_allclose(peak_position, x0, atol=0.05) # Max should not exceed sum of amplitudes
190 def test_fit_peaks(self):
191 """Test peak fitting functionality."""
192 # Create synthetic data with known peaks
193 x_data = np.linspace(0, 10, 1000)
194 y_data = (self.processor.pseudo_voigt(x_data, 3, 1, 1, 0.5) +
195 self.processor.pseudo_voigt(x_data, 7, 0.8, 1.2, 0.3))
196 y_data += np.random.normal(0, 0.01, len(x_data)) # Add noise
198 initial_params = [
199 3, 1, 1, 0.5, 0, # First peak
200 7, 0.8, 1.2, 0.3, 0 # Second peak
201 ]
202 fixed_x0 = [False, False]
204 # Perform fit
205 popt, metrics, fitted = self.processor.fit_peaks(x_data, y_data,
206 initial_params, fixed_x0)
208 # Verify fitting results
209 self.assertEqual(len(popt), len(initial_params))
210 self.assertEqual(len(metrics), 2)
211 self.assertEqual(len(fitted), len(x_data))
213 # Check fit quality
214 residuals = y_data - fitted
215 self.assertTrue(np.std(residuals) < 0.1)
217 def test_single_peak_no_fixed_params(self):
218 """Test fitting of a single peak with no fixed parameters."""
219 x = np.linspace(-10, 10, 1000)
221 self.processor.fixed_params = [(None, None, None, None, None)]
223 params = [3, 1, 1, 0.5, 0.1]
225 y = self.processor.pseudo_voigt_multiple(x, *params)
227 y_exp = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5) + 0.1
229 residuals = y - y_exp
231 self.assertTrue(np.std(residuals) < 0.1)
233 def test_single_peak_fixed_x0(self):
234 """Test fitting of a single peak with fixed x0."""
235 x = np.linspace(-10, 10, 1000)
236 fixed_x0 = 3
238 # Set up fixed parameters
239 self.processor.fixed_params = [(fixed_x0, None, None, None, None)]
241 # Test parameters: amp=1, width=1, eta=0.5, offset=0.1
242 params = [1, 1, 0.5, 0.1]
244 # Calculate using pseudo_voigt_multiple
245 result = self.processor.pseudo_voigt_multiple(x, *params)
247 # Calculate individual components for verification
248 sigma = 1 / (2 * np.sqrt(2 * np.log(2))) # width parameter
249 gamma = 1 / 2 # width parameter
251 # Gaussian component
252 gaussian = np.exp(-0.5 * ((x - fixed_x0) / sigma)**2)
254 # Lorentzian component
255 lorentzian = gamma**2 / ((x - fixed_x0)**2 + gamma**2)
257 # Combined pseudo-Voigt with amplitude and offset
258 expected = (0.5 * lorentzian + (1 - 0.5) * gaussian) + 0.1
260 # Scale by amplitude
261 expected = expected * 1
263 # Compare results
264 # Use a lower decimal precision due to numerical differences
265 np.testing.assert_array_almost_equal(result, expected, decimal=4)
267 def test_multiple_peaks_no_fixed_params(self):
268 """Test fitting of multiple peaks with no fixed parameters, sharing one offset."""
269 x = np.linspace(-10, 10, 1000)
271 self.processor.fixed_params = [
272 (None, None, None, None, None),
273 (None, None, None, None, None)
274 ]
276 # x0_1, amp1, width1, eta1, x0_2, amp2, width2, eta2, shared_offset
277 params = [-1.0, 1.0, 1.5, 0.3, 1.0, 0.8, 2.0, 0.7, 0.2]
279 y = self.processor.pseudo_voigt_multiple(x, *params)
281 peak1 = self.processor.pseudo_voigt(x, *params[0:4])
282 peak2 = self.processor.pseudo_voigt(x, *params[4:8])
283 y_exp = peak1 + peak2 + params[-1] # single shared offset applied once
285 np.testing.assert_array_almost_equal(y_exp, y, decimal=6)
287 def test_multiple_peaks_fixed_x0(self):
288 """Test fitting of multiple peaks with one fixed x0, sharing one offset."""
289 x = np.linspace(-10, 10, 1000)
291 fixed_x0 = -1.0
293 self.processor.fixed_params = [
294 (fixed_x0, None, None, None, None),
295 (None, None, None, None, None)
296 ]
298 # amp1, width1, eta1, x0_2, amp2, width2, eta2, shared_offset
299 params = [1.0, 1.5, 0.3, 1.0, 0.8, 2.0, 0.7, 0.2]
301 y = self.processor.pseudo_voigt_multiple(x, *params)
303 peak1 = self.processor.pseudo_voigt(x, fixed_x0, *params[0:3])
304 peak2 = self.processor.pseudo_voigt(x, *params[3:7])
305 y_exp = peak1 + peak2 + params[-1] # single shared offset applied once
307 np.testing.assert_array_almost_equal(y_exp, y, decimal=6)
309 def test_multiple_peaks_mixed_fixed_x0(self):
310 """Test fitting of multiple peaks with mixed fixed and unfixed x0, sharing one offset."""
311 x = np.linspace(-10, 10, 1000)
313 self.processor.fixed_params = [(3, None, None, None, None),
314 (None, None, None, None, None)]
316 # amp1, width1, eta1 (peak1, fixed x0), x0_2, amp2, width2, eta2, shared_offset
317 params = [1, 1, 0.5, 7, 0.8, 1.2, 0.3, 0.2]
319 y = self.processor.pseudo_voigt_multiple(x, *params)
321 peak1 = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5)
322 peak2 = self.processor.pseudo_voigt(x, 7, 0.8, 1.2, 0.3)
323 y_exp = peak1 + peak2 + params[-1] # single shared offset applied once
325 np.testing.assert_array_almost_equal(y_exp, y, decimal=6)
327 def test_invalid_params_length(self):
328 """Test handling of invalid parameters length."""
329 x = np.linspace(-10, 10, 1000)
331 self.processor.fixed_params = [(None, None, None, None, None)] * 2
333 # 2 free peaks need 4*2 + 1 = 9 parameters; provide only 8 (missing one)
334 params = [3, 1, 1, 0.5, 7, 0.8, 1.2, 0.3]
336 with self.assertRaises(ValueError):
337 self.processor.pseudo_voigt_multiple(x, *params)
339 def test_edge_cases(self):
340 """Test pseudo_voigt_multiple with edge cases"""
341 # Test with zero amplitude
342 x = np.linspace(-10, 10, 1000)
343 self.processor.fixed_params = [(None, None, None, None, None)]
344 params = [0.0, 0.0, 2.0, 0.5, 0.0]
345 result = self.processor.pseudo_voigt_multiple(x, *params)
346 np.testing.assert_array_almost_equal(result, np.zeros_like(x))
349 # Test with pure Gaussian (eta = 0)
350 params = [0.0, 1.0, 2.0, 0.0, 0.0]
351 result = self.processor.pseudo_voigt_multiple(x, *params)
352 sigma = params[2] / (2 * np.sqrt(2 * np.log(2)))
353 y_exp = params[1] * np.exp(-0.5 * ((x - params[0]) / sigma)**2) + params[4]
355 residuals = result - y_exp
357 self.assertTrue(np.std(residuals) < 0.1)
359 # Test with pure Lorentzian (eta = 1)
360 params = [0.0, 1.0, 2.0, 1.0, 0.0]
361 result = self.processor.pseudo_voigt_multiple(x, *params)
362 sigma = params[2] / (2 * np.sqrt(2 * np.log(2)))
363 y_exp = params[1] * np.exp(-0.5 * ((x - params[0]) / sigma)**2) + params[4]
365 residuals = result - y_exp
367 self.assertTrue(np.std(residuals) < 0.1)
369 def test_invalid_input_handling(self):
370 """Test handling of invalid inputs."""
371 # Set up processor with valid data range
372 self.processor.ppm = np.array([0, 1, 2, 3, 4])
373 self.processor.data = np.array([0, 1, 2, 3, 4])
375 # Test invalid region selection
376 with self.assertRaises(ValueError):
377 # Make sure these values are well outside the data range
378 self.processor.select_region(10, 20) # Changed to clearly invalid range
380 # Test missing data
381 processor_without_data = NMRProcessor()
382 with self.assertRaises(ValueError):
383 processor_without_data.select_region(1, 2)
385 # Test invalid peak fitting parameters
386 x_data = np.linspace(0, 10, 100)
387 y_data = np.zeros_like(x_data)
388 invalid_params = [1, 2, 3] # Invalid number of parameters
389 with self.assertRaises(ValueError):
390 self.processor.fit_peaks(x_data, y_data, invalid_params)
394 def test_plot_results(self):
395 """Test plotting functionality."""
396 # Create test data
397 x_data = np.linspace(0, 10, 100)
398 y_data = np.zeros_like(x_data)
399 fitted_data = np.zeros_like(x_data)
400 popt = np.array([1, 1, 1, 0.5, 0])
402 # Set required attributes
403 self.processor.nucleus = 'O'
404 self.processor.number = '17'
406 # Test plotting - remove metrics parameter since it's not used in plot_results
407 fig, ax1, components = self.processor.plot_results(
408 x_data, y_data, fitted_data, popt
409 )
411 # Verify plot objects
412 self.assertIsNotNone(fig)
413 self.assertIsInstance(ax1, plt.Axes)
414 self.assertIsInstance(components, list)
415 self.assertEqual(len(components), 1) # One component for single peak
417 # Check if the axes have the correct labels and properties
418 self.assertTrue(ax1.xaxis.get_label_text().startswith('$^{17} \\ O$'))
419 self.assertIsNotNone(ax1.get_legend())
421 plt.close(fig)
424 def test_save_results(self):
425 """Test results saving functionality."""
426 import matplotlib
427 matplotlib.use('Agg')
429 self.processor.carrier_freq = self.larmor_freq
431 try:
432 # Create test data
433 x_data = np.linspace(0, 10, 100)
434 y_data = np.zeros_like(x_data)
435 fitted_data = np.zeros_like(x_data)
436 components = [np.zeros_like(x_data)]
437 metrics = [{
438 'x0': (1, 0.1),
439 'amplitude': (1, 0.1),
440 'width': (1, 0.1),
441 'eta': (0.5, 0.1),
442 'offset': (0, 0.1),
443 'gaussian_area': (1, 0.1),
444 'lorentzian_area': (1, 0.1),
445 'total_area': (2, 0.2)
446 }]
447 popt = np.array([1, 1, 1, 0.5, 0])
449 # Create temporary directory for testing
450 with tempfile.TemporaryDirectory() as temp_dir:
451 test_filepath = os.path.join(temp_dir, 'test_')
453 # Mock the figure and its savefig method
454 mock_fig = Mock()
455 mock_axes = Mock()
456 mock_components = [Mock()]
458 # Set up all the required mocks
459 with patch.object(self.processor, 'plot_results',
460 return_value=(mock_fig, mock_axes, mock_components)) as mock_plot:
461 with patch('builtins.open', mock_open()) as mock_file:
462 with patch('pandas.DataFrame.to_csv') as mock_to_csv:
463 with patch.object(mock_fig, 'savefig') as mock_savefig:
464 with patch('matplotlib.pyplot.close') as mock_close:
466 # Call save_results
467 self.processor.save_results(
468 test_filepath, x_data, y_data, fitted_data,
469 metrics, popt, components
470 )
472 # Verify all the saving methods were called correctly
473 # Check if plot_results was called with correct arguments
474 mock_plot.assert_called_once_with(
475 x_data, y_data, fitted_data, popt
476 )
478 mock_savefig.assert_called_once_with(
479 test_filepath + 'pseudoVoigtPeakFit.png',
480 bbox_inches='tight'
481 )
482 mock_close.assert_called_once_with(mock_fig)
484 # Verify DataFrame.to_csv was called for peak data
485 mock_to_csv.assert_called_once_with(
486 test_filepath + 'peak_data.csv',
487 index=False
488 )
490 # Verify metrics file was opened and written
491 mock_file.assert_called_with(
492 test_filepath + 'pseudoVoigtPeak_metrics.txt',
493 'w'
494 )
496 except Exception as e:
497 self.fail(f"Test failed with error: {str(e)}")
499 finally:
500 plt.close('all')
503if __name__ == '__main__':
505 cov = coverage.Coverage()
506 cov.start()
508 unittest.main(verbosity=2, exit=False)
510 cov.stop()
512 cov.save()
514 cov.html_report(directory='coverage_html')