Coverage for neuber_correction\neuber_correction.py: 98%

168 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-08-14 08:34 +0200

1# coding: utf-8 

2""" 

3This module contains the NeuberCorrection class, which is used to correct the 

4stress using the Neuber correction. 

5""" 

6import math 

7from dataclasses import dataclass 

8from typing import List 

9 

10import matplotlib.pyplot as plt 

11import numpy as np 

12 

13 

14@dataclass(frozen=True) 

15class MaterialForNeuberCorrection: 

16 """ 

17 Class to store the material properties for the Neuber correction. 

18 """ 

19 

20 yield_strength: float 

21 sigma_u: float 

22 elastic_mod: float 

23 eps_u: float 

24 yield_offset: float = 0.002 

25 

26 def __post_init__(self): 

27 """Validate material properties after initialization.""" 

28 if self.yield_strength <= 0: 

29 raise ValueError("yield_strength (yield strength) must be positive") 

30 if self.sigma_u <= 0: 

31 raise ValueError("sigma_u (tensile strength) must be positive") 

32 if self.elastic_mod <= 0: 

33 raise ValueError("elastic_mod (Young's modulus) must be positive") 

34 if self.eps_u <= 0: 

35 raise ValueError("eps_u (strain at UTS) must be positive") 

36 if self.sigma_u <= self.yield_strength: 

37 raise ValueError( 

38 "sigma_u (tensile strength) must be greater than " 

39 "yield_strength (yield strength)" 

40 ) 

41 

42 def __hash__(self): 

43 """Custom hash method for memoization.""" 

44 return hash( 

45 ( 

46 self.yield_strength, 

47 self.sigma_u, 

48 self.elastic_mod, 

49 self.eps_u, 

50 self.yield_offset, 

51 ) 

52 ) 

53 

54 

55@dataclass(frozen=True) 

56class NeuberSolverSettings: 

57 """ 

58 Class to store the settings for the Neuber correction. 

59 """ 

60 

61 tolerance: float = 1e-6 

62 max_iterations: int = 10000 

63 memoization_precision: float = 1e-6 

64 

65 def __post_init__(self): 

66 """Validate settings after initialization.""" 

67 if self.tolerance <= 0: 

68 raise ValueError("tolerance must be positive") 

69 if self.max_iterations <= 0: 

70 raise ValueError("max_iterations must be positive") 

71 

72 def __hash__(self): 

73 """Custom hash method for memoization.""" 

74 return hash((self.tolerance, self.max_iterations, self.memoization_precision)) 

75 

76 

77class NeuberCorrection: 

78 """ 

79 Class to correct the stress using the Neuber correction. 

80 It is a iterative process, so we need to correct the stress until the 

81 difference is less than the tolerance 

82 

83 Args: 

84 yield_strength: The yield strength of the material 

85 sigma_u: The tensile strength of the material 

86 elastic_mod: The Young's modulus of the material 

87 eps_u: The strain at the tensile strength 

88 yield_offset: The offset of the yield strength, usually 0.002 

89 tolerance: The tolerance of the correction 

90 max_iterations: The maximum number of iterations 

91 """ 

92 

93 instances = {} 

94 

95 @classmethod 

96 def clear_all_instances(cls): 

97 """Clear all cached instances and their memoization tables.""" 

98 cls.instances.clear() 

99 

100 def __new__( 

101 cls, 

102 material: MaterialForNeuberCorrection, 

103 settings: NeuberSolverSettings = NeuberSolverSettings(), 

104 ): 

105 # Check if instance already exists 

106 instance_hash = (hash(material), hash(settings)) 

107 if instance_hash in cls.instances: 

108 return cls.instances[instance_hash] 

109 

110 # Create new instance 

111 instance = super().__new__(cls) 

112 instance.hash = instance_hash 

113 return instance 

114 

115 def __init__( 

116 self, 

117 material: MaterialForNeuberCorrection, 

118 settings: NeuberSolverSettings = NeuberSolverSettings(), 

119 ): 

120 # Skip initialization if already initialized 

121 if hasattr(self, "material"): 

122 return 

123 

124 self.material = material 

125 self.settings = settings 

126 self.memoization_table = {} # Keep as dict for O(1) insertion 

127 self.memoization_keys = [] # Keep sorted keys for binary search 

128 self.__class__.instances[self.hash] = self 

129 

130 def _find_cached_stress(self, target_stress: float) -> float | None: 

131 """ 

132 Find a cached stress value within memoization_precision using binary search. 

133 Returns the cached result if found, None otherwise. 

134 """ 

135 if not self.memoization_keys: 

136 return None 

137 

138 # Binary search for the closest stress value 

139 left, right = 0, len(self.memoization_keys) - 1 

140 

141 while left <= right: 

142 mid = (left + right) // 2 

143 cached_stress = self.memoization_keys[mid] 

144 

145 if abs(target_stress - cached_stress) < self.settings.memoization_precision: 

146 return self.memoization_table[cached_stress] 

147 if cached_stress < target_stress: 

148 left = mid + 1 

149 else: 

150 right = mid - 1 

151 

152 return None 

153 

154 def _insert_sorted(self, stress: float, result: float): 

155 """ 

156 Insert stress value into sorted keys list while maintaining order. 

157 """ 

158 # Find insertion point using binary search 

159 left, right = 0, len(self.memoization_keys) 

160 

161 while left < right: 

162 mid = (left + right) // 2 

163 if self.memoization_keys[mid] < stress: 

164 left = mid + 1 

165 else: 

166 right = mid 

167 

168 # Insert at the correct position 

169 self.memoization_keys.insert(left, stress) 

170 self.memoization_table[stress] = result 

171 

172 def _calculate_ramberg_osgood_parameter_n(self) -> float: 

173 """Calculate the Ramberg-Osgood parameter n.""" 

174 elastic_ultimate = self.material.sigma_u / self.material.elastic_mod 

175 plastic_ultimate = self.material.eps_u - elastic_ultimate 

176 return (math.log(plastic_ultimate) - math.log(0.002)) / math.log( 

177 self.material.sigma_u / self.material.yield_strength 

178 ) 

179 

180 def _calculate_neuber_correction(self, stress: float) -> float: 

181 """ 

182 Calculates the Neuber correction with Ramberg-Osgood equation. 

183 Uses Newton-Raphson iteration to find the corrected stress. 

184 """ 

185 

186 # Check memoization table with efficient binary search 

187 cached_result = self._find_cached_stress(stress) 

188 if cached_result is not None: 

189 return cached_result 

190 

191 # Calculate Ramberg-Osgood parameter n 

192 n = self._calculate_ramberg_osgood_parameter_n() 

193 

194 stress_corr = stress 

195 final_difference = float("inf") 

196 

197 for _ in range(self.settings.max_iterations): 

198 # Safeguard against zero or negative stress_corr 

199 if stress_corr <= 0: 

200 # If stress_corr becomes zero or negative, reset to a small positive value 

201 stress_corr = max(stress * 0.1, 1.0) 

202 continue 

203 

204 # Calculate total strain (elastic + plastic) - full Ramberg-Osgood curve 

205 elastic_strain = stress_corr / self.material.elastic_mod 

206 plastic_strain = ( 

207 self.material.yield_offset 

208 * (stress_corr / self.material.yield_strength) ** n 

209 ) 

210 total_strain = elastic_strain + plastic_strain 

211 

212 # Calculate Neuber strain 

213 neuber_strain = (stress**2) / (self.material.elastic_mod * stress_corr) 

214 

215 # Check convergence 

216 difference = total_strain - neuber_strain 

217 abs_difference = abs(difference) 

218 

219 if abs_difference < self.settings.tolerance: 

220 final_difference = abs_difference 

221 break 

222 

223 # Calculate derivatives for Newton-Raphson 

224 d_elastic = 1 / self.material.elastic_mod 

225 d_plastic = ( 

226 self.material.yield_offset 

227 * n 

228 * (stress_corr / self.material.yield_strength) ** (n - 1) 

229 / self.material.yield_strength 

230 ) 

231 d_total = d_elastic + d_plastic 

232 

233 # Calculate d_neuber 

234 d_neuber = -(stress**2) / (self.material.elastic_mod * stress_corr**2) 

235 

236 # Newton-Raphson update 

237 derivative = d_total - d_neuber 

238 if abs(derivative) > 1e-10: 

239 stress_corr = stress_corr - difference / derivative 

240 else: 

241 # Fallback to simple bisection 

242 stress_corr = stress_corr * (0.99 if difference > 0 else 1.01) 

243 

244 # Always store result in memoization table 

245 if final_difference < self.settings.tolerance: 

246 # Converged successfully 

247 self._insert_sorted(stress, stress_corr) 

248 return stress_corr 

249 

250 raise ValueError(f"Neuber correction failed for stress {stress}") 

251 

252 def correct_stress_values(self, stress_values: List[float]) -> List[float]: 

253 """ 

254 Calculates the Neuber correction for the given stress. 

255 """ 

256 return [self._calculate_neuber_correction(stress) for stress in stress_values] 

257 

258 def plot_neuber_diagram( 

259 self, 

260 stress_value: float, 

261 show_plot: bool = True, 

262 plot_file: str | None = None, 

263 plot_pretty_name: str | None = None, 

264 ): 

265 """ 

266 Plots the Neuber diagram showing the Neuber hyperbolic curve and 

267 Ramberg-Osgood stress-strain curve for the given stress value. 

268 

269 Args: 

270 stress_value: The elastic stress value to analyze 

271 show_plot: Whether to display the plot (default: True) 

272 plot_file: Path to the file to save the plot (default: None) 

273 plot_pretty_name: The pretty name of the plot (default: None) 

274 """ 

275 

276 plot_pretty_name = f": {plot_pretty_name}" if plot_pretty_name else "" 

277 

278 # Calculate Ramberg-Osgood parameter n 

279 n = self._calculate_ramberg_osgood_parameter_n() 

280 

281 # Calculate the corrected stress 

282 corrected_stress = self._calculate_neuber_correction(stress_value) 

283 

284 # Create stress range for plotting 

285 stress_range = np.linspace( 

286 1, max(stress_value * 1.2, self.material.sigma_u), 1000 

287 ) 

288 

289 # Calculate Ramberg-Osgood strain for each stress 

290 strains_ro = [] 

291 for stress in stress_range: 

292 elastic_strain = stress / self.material.elastic_mod 

293 plastic_strain = ( 

294 self.material.yield_offset 

295 * (stress / self.material.yield_strength) ** n 

296 ) 

297 strains_ro.append(elastic_strain + plastic_strain) 

298 

299 # Calculate Neuber hyperbolic curve 

300 # Neuber's rule: σ * ε = σ_elastic^2 / E 

301 neuber_constant = stress_value**2 / self.material.elastic_mod 

302 strains_neuber = neuber_constant / stress_range 

303 

304 # Create the plot 

305 fig, ax = plt.subplots(figsize=(12, 8)) 

306 

307 # Plot Ramberg-Osgood curve 

308 ax.plot( 

309 strains_ro, 

310 stress_range, 

311 "b-", 

312 linewidth=2, 

313 label="Ramberg-Osgood Stress-Strain Curve", 

314 ) 

315 

316 # Plot Hooke's law line (elastic line) 

317 hooke_strains = np.linspace(0, self.material.eps_u, 100) 

318 hooke_stresses = hooke_strains * self.material.elastic_mod 

319 ax.plot( 

320 hooke_strains, 

321 hooke_stresses, 

322 "g-", 

323 linewidth=1.5, 

324 alpha=0.7, 

325 label="Hooke's Law (Elastic Line)", 

326 ) 

327 

328 # Plot Neuber hyperbolic curve 

329 ax.plot( 

330 strains_neuber, 

331 stress_range, 

332 "r--", 

333 linewidth=2, 

334 label="Neuber Hyperbolic Curve", 

335 ) 

336 

337 # Mark the elastic stress point 

338 elastic_strain = stress_value / self.material.elastic_mod 

339 ax.plot( 

340 elastic_strain, 

341 stress_value, 

342 "go", 

343 markersize=10, 

344 label=f"Elastic Point (σ={stress_value:.0f} MPa)", 

345 ) 

346 

347 # Mark the corrected stress point 

348 corrected_strain = corrected_stress / self.material.elastic_mod 

349 plastic_strain = ( 

350 self.material.yield_offset 

351 * (corrected_stress / self.material.yield_strength) ** n 

352 ) 

353 corrected_strain += plastic_strain 

354 

355 # Choose marker color based on whether corrected stress is below yield 

356 if corrected_stress < self.material.yield_strength: 

357 marker_color = "orange" # Orange for below yield 

358 marker_label = ( 

359 f"Corrected Point (σ={corrected_stress:.0f} MPa) - BELOW YIELD" 

360 ) 

361 else: 

362 marker_color = "mo" # Magenta for above yield 

363 marker_label = f"Corrected Point (σ={corrected_stress:.0f} MPa)" 

364 

365 ax.plot( 

366 corrected_strain, 

367 corrected_stress, 

368 marker_color, 

369 markersize=10, 

370 label=marker_label, 

371 ) 

372 

373 # Mark yield strength line 

374 ax.axhline( 

375 y=self.material.yield_strength, 

376 color="orange", 

377 linestyle=":", 

378 label=f"Yield Strength ({self.material.yield_strength:.0f} MPa)", 

379 ) 

380 

381 # Mark tensile strength line 

382 ax.axhline( 

383 y=self.material.sigma_u, 

384 color="purple", 

385 linestyle=":", 

386 label=f"Tensile Strength ({self.material.sigma_u:.0f} MPa)", 

387 ) 

388 

389 # Add intersection point 

390 intersection_strain = neuber_constant / corrected_stress 

391 ax.plot( 

392 intersection_strain, 

393 corrected_stress, 

394 "ko", 

395 markersize=8, 

396 label="Intersection Point", 

397 ) 

398 

399 # Add vertical and horizontal lines at intersection point 

400 ax.axvline( 

401 x=intersection_strain, 

402 color="black", 

403 linestyle="-", 

404 alpha=0.5, 

405 linewidth=1, 

406 ) 

407 ax.axhline( 

408 y=corrected_stress, 

409 color="black", 

410 linestyle="-", 

411 alpha=0.5, 

412 linewidth=1, 

413 ) 

414 

415 # Add value annotations 

416 ax.annotate( 

417 f"σ = {corrected_stress:.0f} MPa", 

418 xy=(intersection_strain, corrected_stress), 

419 xytext=(intersection_strain + 0.005, corrected_stress + 50), 

420 fontsize=10, 

421 ha="left", 

422 va="bottom", 

423 bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8}, 

424 arrowprops={"arrowstyle": "->", "connectionstyle": "arc3,rad=0"}, 

425 ) 

426 

427 ax.annotate( 

428 f"ε = {intersection_strain:.4f}", 

429 xy=(intersection_strain, corrected_stress), 

430 xytext=(intersection_strain + 0.005, corrected_stress - 100), 

431 fontsize=10, 

432 ha="left", 

433 va="top", 

434 bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8}, 

435 arrowprops={"arrowstyle": "->", "connectionstyle": "arc3,rad=0"}, 

436 ) 

437 

438 # Customize the plot 

439 ax.set_xlabel("Strain ε [-]", fontsize=12) 

440 ax.set_ylabel("Stress σ [MPa]", fontsize=12) 

441 ax.set_title( 

442 f"Neuber Diagram{plot_pretty_name}\n" 

443 f"Elastic Stress: {stress_value:.0f} MPa → " 

444 f"Corrected Stress: {corrected_stress:.0f} MPa", 

445 fontsize=14, 

446 fontweight="bold", 

447 ) 

448 ax.grid(True, alpha=0.3) 

449 ax.legend(loc="upper right", fontsize=10) 

450 

451 # Set reasonable axis limits 

452 ax.set_xlim(0, self.material.eps_u * 1.1) 

453 ax.set_ylim(0, max(stress_range) * 1.05) 

454 

455 plt.tight_layout() 

456 

457 if plot_file: 

458 plt.savefig(plot_file) 

459 

460 if show_plot: 

461 plt.show() 

462 else: 

463 plt.close(fig) 

464 

465 return fig, ax