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

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 

16 

17class TestNMRProcessor(unittest.TestCase): 

18 """Test suite for NMR Processor class.""" 

19 

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

27 

28 # Close all existing plots 

29 plt.close('all') 

30 

31 # Create temporary directory 

32 self.temp_dir = tempfile.mkdtemp() 

33 

34 def tearDown(self): 

35 """Clean up after each test method.""" 

36 plt.close('all') 

37 

38 # Clean up temporary directory 

39 try: 

40 shutil.rmtree(self.temp_dir) 

41 except: 

42 pass 

43 

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) 

52 

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 

64 

65 # Test data loading 

66 self.processor.load_data("dummy/path") 

67 

68 

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) 

74 

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

80 

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

86 

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

90 

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) 

96 

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 

102 

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) 

108 

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) 

116 

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

121 

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 

128 

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) 

136 

137 

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 

142 

143 result = self.processor.pseudo_voigt(x, x0, amp, width, eta) 

144 

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 

150 

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 

155 

156 result = self.processor.pseudo_voigt(x, x0, amp, width, eta) 

157 

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 

166 

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 

174 

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] 

180 

181 # Perform fit 

182 popt, metrics, fitted = self.processor.fit_peaks(x_data, y_data, 

183 initial_params, fixed_x0) 

184 

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

189 

190 # Check fit quality 

191 residuals = y_data - fitted 

192 self.assertTrue(np.std(residuals) < 0.1) 

193 

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) 

197 

198 self.processor.fixed_params = [(None, None, None, None, None)] 

199 

200 params = [3, 1, 1, 0.5, 0.1] 

201 

202 y = self.processor.pseudo_voigt_multiple(x, *params) 

203 

204 y_exp = self.processor.pseudo_voigt(x, 3, 1, 1, 0.5) + 0.1 

205 

206 residuals = y - y_exp 

207 

208 self.assertTrue(np.std(residuals) < 0.1) 

209 

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 

214 

215 # Set up fixed parameters 

216 self.processor.fixed_params = [(fixed_x0, None, None, None, None)] 

217 

218 # Test parameters: amp=1, width=1, eta=0.5, offset=0.1 

219 params = [1, 1, 0.5, 0.1] 

220 

221 # Calculate using pseudo_voigt_multiple 

222 result = self.processor.pseudo_voigt_multiple(x, *params) 

223 

224 # Calculate individual components for verification 

225 sigma = 1 / (2 * np.sqrt(2 * np.log(2))) # width parameter 

226 gamma = 1 / 2 # width parameter 

227 

228 # Gaussian component 

229 gaussian = np.exp(-0.5 * ((x - fixed_x0) / sigma)**2) 

230 

231 # Lorentzian component 

232 lorentzian = gamma**2 / ((x - fixed_x0)**2 + gamma**2) 

233 

234 # Combined pseudo-Voigt with amplitude and offset 

235 expected = (0.5 * lorentzian + (1 - 0.5) * gaussian) + 0.1 

236 

237 # Scale by amplitude 

238 expected = expected * 1 

239 

240 # Compare results 

241 # Use a lower decimal precision due to numerical differences 

242 np.testing.assert_array_almost_equal(result, expected, decimal=4) 

243 

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) 

247 

248 x0_1, x0_2 = -1.0, 1.0 

249 

250 self.processor.fixed_params = [ 

251 (x0_1, None, None, None, None), 

252 (x0_2, None, None, None, None) 

253 ] 

254 

255 params = [1.0, 1.5, 0.3, 0.1, 0.8, 2.0, 0.7, 0.2] 

256 

257 y = self.processor.pseudo_voigt_multiple(x, *params) 

258 

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] 

265 

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] 

272 

273 # Total expected result 

274 y_exp = peak1 + peak2 - params[3] - params[7] # Subtract offsets to avoid double counting 

275 

276 residuals = y - y_exp 

277 

278 self.assertTrue(np.std(residuals) < 0.1) 

279 

280 def test_multiple_peaks_fixed_x0(self): 

281 """Test fitting of multiple peaks with fixed x0.""" 

282 x = np.linspace(-10, 10, 1000) 

283 

284 fixed_x0 = -1.0 

285 

286 self.processor.fixed_params = [ 

287 (fixed_x0, None, None, None, None), 

288 (None, None, None, None, None) 

289 ] 

290 

291 params = [1.0, 1.5, 0.3, 0.1, 1.0, 0.8, 2.0, 0.7, 0.2] 

292 

293 y = self.processor.pseudo_voigt_multiple(x, *params) 

294 

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] 

301 

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] 

308 

309 # Total expected result 

310 y_exp = peak1 + peak2 - params[3] - params[8] # Subtract offsets to avoid double counting 

311 

312 residuals = y - y_exp 

313 

314 self.assertTrue(np.std(residuals) < 0.1) 

315 

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) 

319 

320 self.processor.fixed_params = [(3, None, None, None, None), 

321 (None, None, None, None, None)] 

322 

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) 

325 

326 y = self.processor.pseudo_voigt_multiple(x, *params) 

327 

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 

331 

332 y_exp = peak1 + peak2 

333 

334 np.testing.assert_array_almost_equal(y_exp, y, decimal=6) 

335 

336 def test_invalid_params_length(self): 

337 """Test handling of invalid parameters length.""" 

338 x = np.linspace(-10, 10, 1000) 

339 

340 self.processor.fixed_params = [(None, None, None, None, None)] * 2 

341 

342 params = [3, 1, 1, 0.5, 0.1, 7, 0.8, 1.2, 0.3] # Missing one parameter 

343 

344 with self.assertRaises(ValueError): 

345 self.processor.pseudo_voigt_multiple(x, *params) 

346 

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

355 

356 

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] 

362 

363 residuals = result - y_exp 

364 

365 self.assertTrue(np.std(residuals) < 0.1) 

366 

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] 

372 

373 residuals = result - y_exp 

374 

375 self.assertTrue(np.std(residuals) < 0.1) 

376 

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

382 

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 

387 

388 # Test missing data 

389 processor_without_data = NMRProcessor() 

390 with self.assertRaises(ValueError): 

391 processor_without_data.select_region(1, 2) 

392 

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) 

399 

400 

401 

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

409 

410 # Set required attributes 

411 self.processor.nucleus = 'O' 

412 self.processor.number = '17' 

413 

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 ) 

418 

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 

424 

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

428 

429 plt.close(fig) 

430 

431 

432 def test_save_results(self): 

433 """Test results saving functionality.""" 

434 import matplotlib 

435 matplotlib.use('Agg') 

436 

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

454 

455 # Create temporary directory for testing 

456 with tempfile.TemporaryDirectory() as temp_dir: 

457 test_filepath = os.path.join(temp_dir, 'test_') 

458 

459 # Mock the figure and its savefig method 

460 mock_fig = Mock() 

461 mock_axes = Mock() 

462 mock_components = [Mock()] 

463 

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: 

471 

472 # Call save_results 

473 self.processor.save_results( 

474 test_filepath, x_data, y_data, fitted_data, 

475 metrics, popt, components 

476 ) 

477 

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 ) 

483 

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) 

489 

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 ) 

495 

496 # Verify metrics file was opened and written 

497 mock_file.assert_called_with( 

498 test_filepath + 'pseudoVoigtPeak_metrics.txt', 

499 'w' 

500 ) 

501 

502 except Exception as e: 

503 self.fail(f"Test failed with error: {str(e)}") 

504 

505 finally: 

506 plt.close('all') 

507 

508 

509if __name__ == '__main__': 

510 

511 cov = coverage.Coverage() 

512 cov.start() 

513 

514 unittest.main(verbosity=2) 

515 

516 cov.stop() 

517 

518 cov.save() 

519 

520 cov.html_report(directory='coverage_html') 

521