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

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

20 

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

32 

33 # Close all existing plots 

34 plt.close('all') 

35 

36 # Create temporary directory 

37 self.temp_dir = tempfile.mkdtemp() 

38 

39 def tearDown(self): 

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

41 plt.close('all') 

42 

43 # Clean up temporary directory 

44 try: 

45 shutil.rmtree(self.temp_dir) 

46 except: 

47 pass 

48 

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

55 

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 

62 

63 # Call the method 

64 self.processor.load_csv(self.filepath, self.atomic_no, self.nucleus, self.larmor_freq) 

65 

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) 

72 

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 = [] 

78 

79 with self.assertRaises(FileNotFoundError): 

80 self.processor.load_csv(self.filepath, self.atomic_no, self.nucleus, self.larmor_freq) 

81 

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

88 

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 

94 

95 with self.assertRaises(ValueError): 

96 self.processor.load_csv(self.filepath, self.atomic_no, self.nucleus, self.larmor_freq) 

97 

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

103 

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

109 

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

113 

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) 

119 

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 

125 

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) 

131 

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) 

139 

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

144 

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 

151 

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) 

159 

160 

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 

165 

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

167 

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 

173 

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 

178 

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

180 

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 

189 

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 

197 

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] 

203 

204 # Perform fit 

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

206 initial_params, fixed_x0) 

207 

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

212 

213 # Check fit quality 

214 residuals = y_data - fitted 

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

216 

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) 

220 

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

222 

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

224 

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

226 

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

228 

229 residuals = y - y_exp 

230 

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

232 

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 

237 

238 # Set up fixed parameters 

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

240 

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

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

243 

244 # Calculate using pseudo_voigt_multiple 

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

246 

247 # Calculate individual components for verification 

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

249 gamma = 1 / 2 # width parameter 

250 

251 # Gaussian component 

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

253 

254 # Lorentzian component 

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

256 

257 # Combined pseudo-Voigt with amplitude and offset 

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

259 

260 # Scale by amplitude 

261 expected = expected * 1 

262 

263 # Compare results 

264 # Use a lower decimal precision due to numerical differences 

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

266 

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) 

270 

271 self.processor.fixed_params = [ 

272 (None, None, None, None, None), 

273 (None, None, None, None, None) 

274 ] 

275 

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] 

278 

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

280 

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 

284 

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

286 

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) 

290 

291 fixed_x0 = -1.0 

292 

293 self.processor.fixed_params = [ 

294 (fixed_x0, None, None, None, None), 

295 (None, None, None, None, None) 

296 ] 

297 

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] 

300 

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

302 

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 

306 

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

308 

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) 

312 

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

314 (None, None, None, None, None)] 

315 

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] 

318 

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

320 

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 

324 

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

326 

327 def test_invalid_params_length(self): 

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

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

330 

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

332 

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] 

335 

336 with self.assertRaises(ValueError): 

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

338 

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

347 

348 

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] 

354 

355 residuals = result - y_exp 

356 

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

358 

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] 

364 

365 residuals = result - y_exp 

366 

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

368 

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

374 

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 

379 

380 # Test missing data 

381 processor_without_data = NMRProcessor() 

382 with self.assertRaises(ValueError): 

383 processor_without_data.select_region(1, 2) 

384 

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) 

391 

392 

393 

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

401 

402 # Set required attributes 

403 self.processor.nucleus = 'O' 

404 self.processor.number = '17' 

405 

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 ) 

410 

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 

416 

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

420 

421 plt.close(fig) 

422 

423 

424 def test_save_results(self): 

425 """Test results saving functionality.""" 

426 import matplotlib 

427 matplotlib.use('Agg') 

428 

429 self.processor.carrier_freq = self.larmor_freq 

430 

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

448 

449 # Create temporary directory for testing 

450 with tempfile.TemporaryDirectory() as temp_dir: 

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

452 

453 # Mock the figure and its savefig method 

454 mock_fig = Mock() 

455 mock_axes = Mock() 

456 mock_components = [Mock()] 

457 

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: 

465 

466 # Call save_results 

467 self.processor.save_results( 

468 test_filepath, x_data, y_data, fitted_data, 

469 metrics, popt, components 

470 ) 

471 

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 ) 

477 

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) 

483 

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 ) 

489 

490 # Verify metrics file was opened and written 

491 mock_file.assert_called_with( 

492 test_filepath + 'pseudoVoigtPeak_metrics.txt', 

493 'w' 

494 ) 

495 

496 except Exception as e: 

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

498 

499 finally: 

500 plt.close('all') 

501 

502 

503if __name__ == '__main__': 

504 

505 cov = coverage.Coverage() 

506 cov.start() 

507 

508 unittest.main(verbosity=2, exit=False) 

509 

510 cov.stop() 

511 

512 cov.save() 

513 

514 cov.html_report(directory='coverage_html') 

515