Coverage for src / tracekit / utils / memory.py: 95%

268 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Memory management utilities for TraceKit. 

2 

3This module provides memory estimation, availability checking, and 

4OOM prevention for large signal processing operations. 

5 

6 

7Example: 

8 >>> from tracekit.utils.memory import estimate_memory, check_memory_available 

9 >>> estimate = estimate_memory('fft', samples=1e9) 

10 >>> check = check_memory_available('spectrogram', samples=1e9, nperseg=4096) 

11 

12References: 

13 Python psutil documentation 

14""" 

15 

16from __future__ import annotations 

17 

18import gc 

19import os 

20import platform 

21from dataclasses import dataclass 

22from typing import Any 

23 

24import numpy as np 

25 

26 

27@dataclass 

28class MemoryEstimate: 

29 """Memory requirement estimate for an operation. 

30 

31 Attributes: 

32 data: Memory for input data (bytes). 

33 intermediate: Memory for intermediate buffers (bytes). 

34 output: Memory for output data (bytes). 

35 total: Total memory required (bytes). 

36 operation: Operation name. 

37 parameters: Parameters used for estimate. 

38 """ 

39 

40 data: int 

41 intermediate: int 

42 output: int 

43 total: int 

44 operation: str 

45 parameters: dict # type: ignore[type-arg] 

46 

47 def __repr__(self) -> str: 

48 return ( 

49 f"MemoryEstimate({self.operation}: " 

50 f"total={self.total / 1e9:.2f} GB, " 

51 f"data={self.data / 1e9:.2f} GB, " 

52 f"intermediate={self.intermediate / 1e9:.2f} GB, " 

53 f"output={self.output / 1e9:.2f} GB)" 

54 ) 

55 

56 

57@dataclass 

58class MemoryCheck: 

59 """Result of memory availability check. 

60 

61 Attributes: 

62 sufficient: True if enough memory is available. 

63 available: Available memory (bytes). 

64 required: Required memory (bytes). 

65 recommendation: Suggested action if insufficient. 

66 """ 

67 

68 sufficient: bool 

69 available: int 

70 required: int 

71 recommendation: str 

72 

73 

74class MemoryCheckError(Exception): 

75 """Exception raised when memory check fails. 

76 

77 Attributes: 

78 required: Required memory in bytes. 

79 available: Available memory in bytes. 

80 recommendation: Suggested action. 

81 """ 

82 

83 def __init__(self, message: str, required: int, available: int, recommendation: str): 

84 super().__init__(message) 

85 self.required = required 

86 self.available = available 

87 self.recommendation = recommendation 

88 

89 

90def detect_wsl() -> bool: 

91 """Detect if running in Windows Subsystem for Linux. 

92 

93 Returns: 

94 True if running in WSL. 

95 """ 

96 try: 

97 with open("/proc/version") as f: 

98 version = f.read().lower() 

99 return "microsoft" in version or "wsl" in version 

100 except (FileNotFoundError, PermissionError): 

101 return False 

102 

103 

104def get_total_memory() -> int: 

105 """Get total system memory in bytes. 

106 

107 Returns: 

108 Total physical memory in bytes. 

109 """ 

110 try: 

111 import psutil 

112 

113 return psutil.virtual_memory().total # type: ignore[no-any-return] 

114 except ImportError: 

115 # Fallback without psutil 

116 if platform.system() == "Linux": 

117 try: 

118 with open("/proc/meminfo") as f: 

119 for line in f: 

120 if line.startswith("MemTotal:"): 

121 # Format: "MemTotal: 16384 kB" 

122 return int(line.split()[1]) * 1024 

123 except (FileNotFoundError, PermissionError): 

124 pass 

125 # Default fallback: assume 8 GB 

126 return 8 * 1024 * 1024 * 1024 

127 

128 

129def get_available_memory() -> int: 

130 """Get available memory in bytes. 

131 

132 Accounts for OS overhead and applies WSL conservative factor. 

133 

134 Returns: 

135 Available memory in bytes. 

136 """ 

137 # Get memory reserve from environment 

138 reserve_str = os.environ.get("TK_MEMORY_RESERVE", "0") 

139 try: 

140 if reserve_str.upper().endswith("GB"): 

141 reserve = int(float(reserve_str[:-2]) * 1e9) 

142 elif reserve_str.upper().endswith("MB"): 

143 reserve = int(float(reserve_str[:-2]) * 1e6) 

144 else: 

145 reserve = int(float(reserve_str)) 

146 except ValueError: 

147 reserve = 0 

148 

149 try: 

150 import psutil 

151 

152 available = psutil.virtual_memory().available 

153 except ImportError: 

154 # Fallback without psutil 

155 if platform.system() == "Linux": 

156 try: 

157 with open("/proc/meminfo") as f: 

158 for line in f: 

159 if line.startswith("MemAvailable:"): 

160 available = int(line.split()[1]) * 1024 

161 break 

162 else: 

163 available = get_total_memory() // 2 

164 except (FileNotFoundError, PermissionError): 

165 available = get_total_memory() // 2 

166 else: 

167 available = get_total_memory() // 2 

168 

169 # Apply WSL conservative factor 

170 if detect_wsl(): 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 available = int(available * 0.5) 

172 

173 # Apply reserve 

174 available = max(0, available - reserve) 

175 

176 return available # type: ignore[no-any-return] 

177 

178 

179def get_swap_available() -> int: 

180 """Get available swap space in bytes. 

181 

182 Returns: 

183 Available swap in bytes. 

184 """ 

185 try: 

186 import psutil 

187 

188 return psutil.swap_memory().free # type: ignore[no-any-return] 

189 except ImportError: 

190 # Fallback 

191 if platform.system() == "Linux": 

192 try: 

193 with open("/proc/meminfo") as f: 

194 for line in f: 

195 if line.startswith("SwapFree:"): 

196 return int(line.split()[1]) * 1024 

197 except (FileNotFoundError, PermissionError): 

198 pass 

199 return 0 

200 

201 

202def get_memory_pressure() -> float: 

203 """Get current memory utilization (0.0 to 1.0). 

204 

205 Returns: 

206 Memory pressure as fraction of total memory used. 

207 """ 

208 try: 

209 import psutil 

210 

211 return psutil.virtual_memory().percent / 100.0 # type: ignore[no-any-return] 

212 except ImportError: 

213 total = get_total_memory() 

214 available = get_available_memory() 

215 return 1.0 - (available / total) if total > 0 else 0.5 

216 

217 

218def estimate_memory( 

219 operation: str, 

220 samples: int | float | None = None, 

221 *, 

222 nfft: int | None = None, 

223 nperseg: int | None = None, 

224 noverlap: int | None = None, 

225 dtype: str = "float64", 

226 channels: int = 1, 

227 **kwargs: Any, 

228) -> MemoryEstimate: 

229 """Estimate memory requirements for an operation. 

230 

231 Args: 

232 operation: Operation name (fft, psd, spectrogram, eye_diagram, correlate, filter). 

233 samples: Number of samples (can be float for large values). 

234 nfft: FFT length (for fft, psd, spectrogram). 

235 nperseg: Segment length (for spectrogram, psd). 

236 noverlap: Overlap samples (for spectrogram). 

237 dtype: Data type (float32 or float64). 

238 channels: Number of channels. 

239 **kwargs: Additional operation-specific parameters. 

240 

241 Returns: 

242 MemoryEstimate with memory requirements. 

243 

244 Example: 

245 >>> estimate = estimate_memory('fft', samples=1e9, nfft=8192) 

246 >>> print(f"Required: {estimate.total / 1e9:.2f} GB") 

247 """ 

248 # Bytes per element 

249 bytes_per_sample = 4 if dtype == "float32" else 8 

250 

251 samples = int(samples or 0) 

252 

253 # Calculate based on operation 

254 if operation == "fft": 

255 nfft = nfft or _next_power_of_2(samples) 

256 data_mem = samples * bytes_per_sample * channels 

257 # FFT needs complex output (2x) plus work buffer 

258 intermediate_mem = nfft * bytes_per_sample * 2 * 2 # complex, work buffer 

259 output_mem = (nfft // 2 + 1) * bytes_per_sample * 2 * channels # complex output 

260 

261 elif operation == "psd": 

262 nperseg = nperseg or 256 

263 nfft = nfft or nperseg 

264 data_mem = samples * bytes_per_sample * channels 

265 # Welch needs segment buffer plus FFT work 

266 intermediate_mem = nperseg * bytes_per_sample * 2 + nfft * bytes_per_sample * 2 

267 output_mem = (nfft // 2 + 1) * bytes_per_sample * channels 

268 

269 elif operation == "spectrogram": 

270 nperseg = nperseg or 256 

271 noverlap = noverlap or nperseg // 2 

272 nfft = nfft or nperseg 

273 hop = nperseg - noverlap 

274 num_segments = max(1, (samples - noverlap) // hop) 

275 

276 data_mem = samples * bytes_per_sample * channels 

277 # STFT needs segment buffer 

278 intermediate_mem = nperseg * bytes_per_sample * 2 + nfft * bytes_per_sample * 2 

279 # Output: (nfft//2+1) frequencies x num_segments times 

280 output_mem = (nfft // 2 + 1) * num_segments * bytes_per_sample * 2 * channels 

281 

282 elif operation == "eye_diagram": 

283 samples_per_ui = kwargs.get("samples_per_ui", 100) 

284 num_uis = kwargs.get("num_uis", 1000) 

285 data_mem = samples * bytes_per_sample * channels 

286 # Eye diagram accumulates traces 

287 intermediate_mem = samples_per_ui * num_uis * bytes_per_sample 

288 output_mem = samples_per_ui * num_uis * bytes_per_sample 

289 

290 elif operation == "correlate": 

291 data_mem = samples * bytes_per_sample * 2 * channels # Two signals 

292 # FFT-based correlation 

293 nfft = _next_power_of_2(samples * 2) 

294 intermediate_mem = nfft * bytes_per_sample * 2 * 2 # Two FFTs 

295 output_mem = (samples * 2 - 1) * bytes_per_sample * channels 

296 

297 elif operation == "filter": 

298 filter_order = kwargs.get("filter_order", 8) 

299 data_mem = samples * bytes_per_sample * channels 

300 # Filter state and buffer 

301 intermediate_mem = (filter_order + samples) * bytes_per_sample 

302 output_mem = samples * bytes_per_sample * channels 

303 

304 else: 

305 # Generic estimate 

306 data_mem = samples * bytes_per_sample * channels 

307 intermediate_mem = samples * bytes_per_sample 

308 output_mem = samples * bytes_per_sample * channels 

309 

310 total_mem = data_mem + intermediate_mem + output_mem 

311 

312 return MemoryEstimate( 

313 data=data_mem, 

314 intermediate=intermediate_mem, 

315 output=output_mem, 

316 total=total_mem, 

317 operation=operation, 

318 parameters={ 

319 "samples": samples, 

320 "nfft": nfft, 

321 "nperseg": nperseg, 

322 "noverlap": noverlap, 

323 "dtype": dtype, 

324 "channels": channels, 

325 **kwargs, 

326 }, 

327 ) 

328 

329 

330def check_memory_available( 

331 operation: str, 

332 samples: int | float | None = None, 

333 **kwargs: Any, 

334) -> MemoryCheck: 

335 """Check if sufficient memory is available for an operation. 

336 

337 Args: 

338 operation: Operation name. 

339 samples: Number of samples. 

340 **kwargs: Additional parameters for estimate_memory. 

341 

342 Returns: 

343 MemoryCheck with sufficiency status and recommendation. 

344 

345 Example: 

346 >>> check = check_memory_available('spectrogram', samples=1e9, nperseg=4096) 

347 >>> if not check.sufficient: 

348 ... print(check.recommendation) 

349 """ 

350 estimate = estimate_memory(operation, samples, **kwargs) 

351 available = get_available_memory() 

352 

353 sufficient = estimate.total <= available 

354 

355 if sufficient: 

356 recommendation = "Memory sufficient for operation." 

357 else: 

358 # Generate recommendations 

359 ratio = estimate.total / available 

360 if ratio < 2: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 recommendation = ( 

362 f"Need {estimate.total / 1e9:.1f} GB, have {available / 1e9:.1f} GB. " 

363 "Consider closing other applications or using chunked processing." 

364 ) 

365 elif ratio < 10: 

366 recommendation = ( 

367 f"Need {estimate.total / 1e9:.1f} GB, have {available / 1e9:.1f} GB. " 

368 f"Use chunked processing or downsample by {int(ratio)}x." 

369 ) 

370 else: 

371 recommendation = ( 

372 f"Need {estimate.total / 1e9:.1f} GB, have {available / 1e9:.1f} GB. " 

373 "Data too large for available memory. Use streaming/chunked processing " 

374 "or process a subset of the data." 

375 ) 

376 

377 return MemoryCheck( 

378 sufficient=sufficient, 

379 available=available, 

380 required=estimate.total, 

381 recommendation=recommendation, 

382 ) 

383 

384 

385def require_memory( 

386 operation: str, 

387 samples: int | float | None = None, 

388 **kwargs: Any, 

389) -> None: 

390 """Raise exception if insufficient memory for operation. 

391 

392 Args: 

393 operation: Operation name. 

394 samples: Number of samples. 

395 **kwargs: Additional parameters. 

396 

397 Raises: 

398 MemoryCheckError: If insufficient memory. 

399 """ 

400 check = check_memory_available(operation, samples, **kwargs) 

401 if not check.sufficient: 

402 raise MemoryCheckError( 

403 f"Insufficient memory for {operation}", 

404 required=check.required, 

405 available=check.available, 

406 recommendation=check.recommendation, 

407 ) 

408 

409 

410def _next_power_of_2(n: int) -> int: 

411 """Return next power of 2 >= n.""" 

412 if n <= 0: 

413 return 1 

414 return 1 << (n - 1).bit_length() 

415 

416 

417# Memory configuration 

418_max_memory: int | None = None 

419 

420 

421def set_max_memory(limit: int | str | None) -> None: 

422 """Set global memory limit for TraceKit operations. 

423 

424 Args: 

425 limit: Maximum memory in bytes, or string like "4GB", "512MB". 

426 

427 Example: 

428 >>> set_max_memory("4GB") 

429 >>> set_max_memory(4 * 1024 * 1024 * 1024) 

430 """ 

431 global _max_memory 

432 

433 if limit is None: 

434 _max_memory = None 

435 return 

436 

437 if isinstance(limit, str): 

438 limit = limit.upper().strip() 

439 if limit.endswith("GB"): 

440 _max_memory = int(float(limit[:-2]) * 1e9) 

441 elif limit.endswith("MB"): 

442 _max_memory = int(float(limit[:-2]) * 1e6) 

443 elif limit.endswith("KB"): 443 ↛ 446line 443 didn't jump to line 446 because the condition on line 443 was always true

444 _max_memory = int(float(limit[:-2]) * 1e3) 

445 else: 

446 _max_memory = int(float(limit)) 

447 else: 

448 _max_memory = int(limit) 

449 

450 

451def get_max_memory() -> int: 

452 """Get the current memory limit. 

453 

454 Returns: 

455 Memory limit in bytes (default: 80% of available). 

456 """ 

457 if _max_memory is not None: 

458 return _max_memory 

459 

460 # Check environment variable 

461 env_limit = os.environ.get("TK_MAX_MEMORY") 

462 if env_limit: 

463 set_max_memory(env_limit) 

464 if _max_memory is not None: 464 ↛ 468line 464 didn't jump to line 468 because the condition on line 464 was always true

465 return _max_memory # type: ignore[unreachable] 

466 

467 # Default: 80% of available 

468 return int(get_available_memory() * 0.8) 

469 

470 

471def gc_collect() -> int: 

472 """Force garbage collection. 

473 

474 Returns: 

475 Number of unreachable objects collected. 

476 """ 

477 return gc.collect() 

478 

479 

480def get_memory_info() -> dict[str, int]: 

481 """Get comprehensive memory information. 

482 

483 Returns: 

484 Dictionary with memory statistics. 

485 """ 

486 return { 

487 "total": get_total_memory(), 

488 "available": get_available_memory(), 

489 "swap_available": get_swap_available(), 

490 "max_memory": get_max_memory(), 

491 "pressure_pct": int(get_memory_pressure() * 100), 

492 "wsl": detect_wsl(), 

493 } 

494 

495 

496# ========================================================================== 

497# MEM-009, MEM-010, MEM-011: Memory Configuration & Limits 

498# ========================================================================== 

499 

500 

501@dataclass 

502class MemoryConfig: 

503 """Global memory configuration for TraceKit operations. 

504 

505 

506 Attributes: 

507 max_memory: Global memory limit in bytes (None = 80% of available). 

508 warn_threshold: Warning threshold (0.0-1.0, default 0.7). 

509 critical_threshold: Critical threshold (0.0-1.0, default 0.9). 

510 auto_degrade: Automatically downsample if memory exceeded. 

511 """ 

512 

513 max_memory: int | None = None 

514 warn_threshold: float = 0.7 

515 critical_threshold: float = 0.9 

516 auto_degrade: bool = False 

517 

518 def __post_init__(self) -> None: 

519 """Validate thresholds.""" 

520 if not 0.0 <= self.warn_threshold <= 1.0: 

521 raise ValueError(f"warn_threshold must be 0.0-1.0, got {self.warn_threshold}") 

522 if not 0.0 <= self.critical_threshold <= 1.0: 

523 raise ValueError(f"critical_threshold must be 0.0-1.0, got {self.critical_threshold}") 

524 if self.warn_threshold >= self.critical_threshold: 

525 raise ValueError( 

526 f"warn_threshold ({self.warn_threshold}) must be < critical_threshold " 

527 f"({self.critical_threshold})" 

528 ) 

529 

530 

531# Global memory configuration instance 

532_memory_config = MemoryConfig() 

533 

534 

535def configure_memory( 

536 *, 

537 max_memory: int | str | None = None, 

538 warn_threshold: float | None = None, 

539 critical_threshold: float | None = None, 

540 auto_degrade: bool | None = None, 

541) -> None: 

542 """Configure global memory limits and thresholds. 

543 

544 

545 Args: 

546 max_memory: Maximum memory in bytes or string ("4GB", "512MB"). 

547 warn_threshold: Warning threshold (0.0-1.0). 

548 critical_threshold: Critical threshold (0.0-1.0). 

549 auto_degrade: Enable automatic downsampling. 

550 

551 Example: 

552 >>> configure_memory(max_memory="4GB", warn_threshold=0.7, critical_threshold=0.9) 

553 >>> configure_memory(auto_degrade=True) 

554 """ 

555 global _memory_config # noqa: PLW0602 

556 

557 if max_memory is not None: 

558 if isinstance(max_memory, str): 

559 # Parse string format 

560 limit_upper = max_memory.upper().strip() 

561 if limit_upper.endswith("GB"): 

562 _memory_config.max_memory = int(float(limit_upper[:-2]) * 1e9) 

563 elif limit_upper.endswith("MB"): 563 ↛ 565line 563 didn't jump to line 565 because the condition on line 563 was always true

564 _memory_config.max_memory = int(float(limit_upper[:-2]) * 1e6) 

565 elif limit_upper.endswith("KB"): 

566 _memory_config.max_memory = int(float(limit_upper[:-2]) * 1e3) 

567 else: 

568 _memory_config.max_memory = int(float(limit_upper)) 

569 else: 

570 _memory_config.max_memory = int(max_memory) 

571 

572 if warn_threshold is not None: 

573 _memory_config.warn_threshold = warn_threshold 

574 if critical_threshold is not None: 

575 _memory_config.critical_threshold = critical_threshold 

576 if auto_degrade is not None: 

577 _memory_config.auto_degrade = auto_degrade 

578 

579 # Validate after updates 

580 _memory_config.__post_init__() 

581 

582 

583def get_memory_config() -> MemoryConfig: 

584 """Get current memory configuration. 

585 

586 Returns: 

587 Current MemoryConfig instance. 

588 """ 

589 return _memory_config 

590 

591 

592# ========================================================================== 

593# ========================================================================== 

594 

595 

596@dataclass 

597class DownsamplingRecommendation: 

598 """Recommendation for downsampling to fit memory constraints. 

599 

600 Attributes: 

601 factor: Suggested downsampling factor (2, 4, 8, 16, etc.). 

602 required_memory: Memory required without downsampling (bytes). 

603 available_memory: Available memory (bytes). 

604 new_sample_rate: Effective sample rate after downsampling (Hz). 

605 message: Human-readable recommendation message. 

606 """ 

607 

608 factor: int 

609 required_memory: int 

610 available_memory: int 

611 new_sample_rate: float 

612 message: str 

613 

614 

615def suggest_downsampling( 

616 operation: str, 

617 samples: int | float, 

618 sample_rate: float, 

619 **kwargs: Any, 

620) -> DownsamplingRecommendation | None: 

621 """Suggest downsampling factor if operation would exceed memory limits. 

622 

623 

624 Args: 

625 operation: Operation name. 

626 samples: Number of samples. 

627 sample_rate: Current sample rate in Hz. 

628 **kwargs: Additional parameters for memory estimation. 

629 

630 Returns: 

631 DownsamplingRecommendation if downsampling needed, None if sufficient memory. 

632 

633 Example: 

634 >>> rec = suggest_downsampling('spectrogram', samples=1e9, sample_rate=1e9, nperseg=4096) 

635 >>> if rec: 

636 ... print(f"Downsample by {rec.factor}x to {rec.new_sample_rate/1e6:.1f} MSa/s") 

637 """ 

638 estimate = estimate_memory(operation, samples, **kwargs) 

639 available = get_available_memory() 

640 

641 if estimate.total <= available: 

642 return None # Sufficient memory 

643 

644 # Calculate required downsampling factor 

645 ratio = estimate.total / available 

646 # Round up to next power of 2 

647 factor = 2 ** int(np.ceil(np.log2(ratio))) 

648 # Limit to reasonable factors 

649 factor = min(factor, 16) 

650 

651 new_sample_rate = sample_rate / factor 

652 new_samples = int(samples) // factor 

653 

654 # Re-estimate with downsampled size 

655 new_estimate = estimate_memory(operation, new_samples, **kwargs) 

656 

657 message = ( 

658 f"Insufficient memory for {operation}. " 

659 f"Need {estimate.total / 1e9:.1f} GB, have {available / 1e9:.1f} GB. " 

660 f"Recommend downsampling by {factor}x (new rate: {new_sample_rate / 1e6:.1f} MSa/s). " 

661 f"Estimated memory after downsampling: {new_estimate.total / 1e9:.2f} GB." 

662 ) 

663 

664 return DownsamplingRecommendation( 

665 factor=factor, 

666 required_memory=estimate.total, 

667 available_memory=available, 

668 new_sample_rate=new_sample_rate, 

669 message=message, 

670 ) 

671 

672 

673# ========================================================================== 

674# ========================================================================== 

675 

676 

677class MemoryMonitor: 

678 """Context manager for monitoring memory usage and preventing OOM crashes. 

679 

680 

681 Attributes: 

682 operation: Name of the operation being monitored. 

683 max_memory: Maximum allowed memory (None = use global config). 

684 check_interval: How often to check memory (number of iterations). 

685 

686 Example: 

687 >>> with MemoryMonitor('spectrogram', max_memory=4e9) as monitor: 

688 ... for i in range(1000): 

689 ... # Perform work 

690 ... monitor.check(i) # Check memory periodically 

691 """ 

692 

693 def __init__( 

694 self, 

695 operation: str, 

696 *, 

697 max_memory: int | str | None = None, 

698 check_interval: int = 100, 

699 ): 

700 self.operation = operation 

701 self.check_interval = check_interval 

702 self.start_memory = 0 

703 self.peak_memory = 0 

704 self.current_memory = 0 

705 self._iteration = 0 

706 

707 # Parse max_memory 

708 if max_memory is None: 

709 self.max_memory = get_max_memory() 

710 elif isinstance(max_memory, str): 

711 limit_upper = max_memory.upper().strip() 

712 if limit_upper.endswith("GB"): 

713 self.max_memory = int(float(limit_upper[:-2]) * 1e9) 

714 elif limit_upper.endswith("MB"): 714 ↛ 717line 714 didn't jump to line 717 because the condition on line 714 was always true

715 self.max_memory = int(float(limit_upper[:-2]) * 1e6) 

716 else: 

717 self.max_memory = int(float(limit_upper)) 

718 else: 

719 self.max_memory = int(max_memory) 

720 

721 def __enter__(self) -> MemoryMonitor: 

722 """Enter context and record starting memory.""" 

723 self.start_memory = self._get_process_memory() 

724 self.peak_memory = self.start_memory 

725 self.current_memory = self.start_memory 

726 return self 

727 

728 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 

729 """Exit context.""" 

730 # Note: exc_val and exc_tb intentionally unused but required for Python 3.11+ compatibility 

731 pass 

732 

733 def check(self, iteration: int | None = None) -> None: 

734 """Check memory usage and raise error if limit approached. 

735 

736 Args: 

737 iteration: Current iteration number (for periodic checking). 

738 

739 Raises: 

740 MemoryError: If memory usage exceeds 95% of max_memory. 

741 """ 

742 self._iteration += 1 

743 

744 # Only check periodically 

745 if iteration is not None and iteration % self.check_interval != 0: 

746 return 

747 

748 self.current_memory = self._get_process_memory() 

749 self.peak_memory = max(self.peak_memory, self.current_memory) 

750 

751 # Check against available memory 

752 available = get_available_memory() 

753 critical_threshold = _memory_config.critical_threshold 

754 

755 if available < self.max_memory * (1 - critical_threshold): 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true

756 raise MemoryError( 

757 f"Memory limit approached during {self.operation}. " 

758 f"Available: {available / 1e9:.2f} GB, " 

759 f"Limit: {self.max_memory / 1e9:.2f} GB. " 

760 f"Operation aborted to prevent system crash." 

761 ) 

762 

763 def _get_process_memory(self) -> int: 

764 """Get current process memory usage in bytes.""" 

765 try: 

766 import psutil 

767 

768 process = psutil.Process() 

769 return process.memory_info().rss # type: ignore[no-any-return] 

770 except ImportError: 

771 # Fallback: use system available memory 

772 return get_total_memory() - get_available_memory() 

773 

774 def get_stats(self) -> dict[str, int]: 

775 """Get memory statistics for this monitoring session. 

776 

777 Returns: 

778 Dictionary with start, current, and peak memory usage. 

779 

780 Example: 

781 >>> with MemoryMonitor('fft') as monitor: 

782 ... # ... do work ... 

783 ... stats = monitor.get_stats() 

784 >>> print(f"Peak memory: {stats['peak'] / 1e6:.1f} MB") 

785 """ 

786 return { 

787 "start": self.start_memory, 

788 "current": self.current_memory, 

789 "peak": self.peak_memory, 

790 "delta": self.peak_memory - self.start_memory, 

791 } 

792 

793 

794# ========================================================================== 

795# ========================================================================== 

796 

797 

798@dataclass 

799class ProgressInfo: 

800 """Progress information with memory metrics. 

801 

802 

803 Attributes: 

804 current: Current progress value. 

805 total: Total progress value. 

806 eta_seconds: Estimated time to completion in seconds. 

807 memory_used: Current memory usage in bytes. 

808 memory_peak: Peak memory usage since start in bytes. 

809 operation: Name of the operation. 

810 """ 

811 

812 current: int 

813 total: int 

814 eta_seconds: float 

815 memory_used: int 

816 memory_peak: int 

817 operation: str 

818 

819 @property 

820 def percent(self) -> float: 

821 """Progress percentage (0.0-100.0).""" 

822 if self.total == 0: 

823 return 100.0 

824 return (self.current / self.total) * 100.0 

825 

826 def format_progress(self) -> str: 

827 """Format progress as human-readable string. 

828 

829 Returns: 

830 Formatted string like "42.5% | 1.2 GB used | 2.1 GB peak | ETA 5s" 

831 """ 

832 return ( 

833 f"{self.percent:.1f}% | " 

834 f"{self.memory_used / 1e9:.2f} GB used | " 

835 f"{self.memory_peak / 1e9:.2f} GB peak | " 

836 f"ETA {self.eta_seconds:.0f}s" 

837 )