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

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 

15 

16class TestNMRProcessor(unittest.TestCase): 

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

18 

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

26 

27 # Close all existing plots 

28 plt.close('all') 

29 

30 # Create temporary directory 

31 self.temp_dir = tempfile.mkdtemp() 

32 

33 def tearDown(self): 

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

35 plt.close('all') 

36 

37 # Clean up temporary directory 

38 try: 

39 shutil.rmtree(self.temp_dir) 

40 except: 

41 pass 

42 

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) 

51 

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 

63 

64 # Test data loading 

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

66 

67 

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) 

73 

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

79 

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

85 

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

89 

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) 

95 

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 

101 

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) 

107 

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) 

115 

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

120 

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 

127 

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) 

135 

136 

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 

141 

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

143 

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 

149 

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 

154 

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

156 

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 

165 

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 

173 

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] 

179 

180 # Perform fit 

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

182 initial_params, fixed_x0) 

183 

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

188 

189 # Check fit quality 

190 residuals = y_data - fitted 

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

192 

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) 

196 

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

198 

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

200 

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

202 

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

204 

205 residuals = y - y_exp 

206 

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

208 

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 

213 

214 # Set up fixed parameters 

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

216 

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

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

219 

220 # Calculate using pseudo_voigt_multiple 

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

222 

223 # Calculate individual components for verification 

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

225 gamma = 1 / 2 # width parameter 

226 

227 # Gaussian component 

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

229 

230 # Lorentzian component 

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

232 

233 # Combined pseudo-Voigt with amplitude and offset 

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

235 

236 # Scale by amplitude 

237 expected = expected * 1 

238 

239 # Compare results 

240 # Use a lower decimal precision due to numerical differences 

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

242 

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) 

246 

247 x0_1, x0_2 = -1.0, 1.0 

248 

249 self.processor.fixed_params = [ 

250 (x0_1, None, None, None, None), 

251 (x0_2, None, None, None, None) 

252 ] 

253 

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

255 

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

257 

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] 

264 

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] 

271 

272 # Total expected result 

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

274 

275 residuals = y - y_exp 

276 

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

278 

279 def test_multiple_peaks_fixed_x0(self): 

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

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

282 

283 fixed_x0 = -1.0 

284 

285 self.processor.fixed_params = [ 

286 (fixed_x0, None, None, None, None), 

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

288 ] 

289 

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

291 

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

293 

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] 

300 

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] 

307 

308 # Total expected result 

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

310 

311 residuals = y - y_exp 

312 

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

314 

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) 

318 

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

320 (None, None, None, None, None)] 

321 

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) 

324 

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

326 

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 

330 

331 y_exp = peak1 + peak2 

332 

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

334 

335 def test_invalid_params_length(self): 

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

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

338 

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

340 

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

342 

343 with self.assertRaises(ValueError): 

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

345 

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

354 

355 

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] 

361 

362 residuals = result - y_exp 

363 

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

365 

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] 

371 

372 residuals = result - y_exp 

373 

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

375 

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

381 

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 

386 

387 # Test missing data 

388 processor_without_data = NMRProcessor() 

389 with self.assertRaises(ValueError): 

390 processor_without_data.select_region(1, 2) 

391 

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) 

398 

399 

400 

401 def test_save_results(self): 

402 """Test results saving functionality.""" 

403 import matplotlib 

404 matplotlib.use('Agg') 

405 

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

424 

425 # Create temporary directory for testing 

426 with tempfile.TemporaryDirectory() as temp_dir: 

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

428 

429 # Mock the figure and its savefig method 

430 mock_fig = Mock() 

431 mock_axes = (Mock(), Mock()) 

432 mock_components = [Mock()] 

433 

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: 

441 

442 # Call save_results 

443 self.processor.save_results( 

444 test_filepath, x_data, y_data, fitted_data, 

445 metrics, popt, components 

446 ) 

447 

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) 

455 

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 ) 

461 

462 # Verify metrics file was opened and written 

463 mock_file.assert_called_with( 

464 test_filepath + 'pseudoVoigtPeak_metrics.txt', 

465 'w' 

466 ) 

467 

468 except Exception as e: 

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

470 

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) 

479 

480 # self.processor.carrier_freq = 500.0 

481 

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

493 

494 # Test plotting 

495 fig, ax1, components = self.processor.plot_results( 

496 x_data, y_data, fitted_data, metrics, popt 

497 ) 

498 

499 # Verify plot objects 

500 self.assertIsNotNone(fig) 

501 self.assertEqual(len(components), 1) 

502 self.assertTrue(isinstance(ax1, plt.Axes)) 

503 

504 

505if __name__ == '__main__': 

506 

507 cov = coverage.Coverage() 

508 cov.start() 

509 

510 unittest.main(verbosity=2) 

511 

512 cov.stop() 

513 

514 cov.save() 

515 

516 cov.html_report(directory='coverage_html') 

517 

518 # webbrowser.open(os.path.join('coverage_html', 'index.html'))