Coverage for src / tracekit / analyzers / signal_integrity / sparams.py: 91%

181 statements  

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

1"""S-Parameter handling and Touchstone file support. 

2 

3This module provides Touchstone file loading and S-parameter 

4calculations including return loss and insertion loss. 

5 

6 

7Example: 

8 >>> from tracekit.analyzers.signal_integrity.sparams import load_touchstone 

9 >>> s_params = load_touchstone("cable.s2p") 

10 >>> rl = return_loss(s_params, frequency=1e9) 

11 

12References: 

13 Touchstone 2.0 File Format Specification 

14 IEEE 370-2020: Standard for Electrical Characterization 

15""" 

16 

17from __future__ import annotations 

18 

19import contextlib 

20import re 

21from dataclasses import dataclass, field 

22from pathlib import Path 

23from typing import TYPE_CHECKING 

24 

25import numpy as np 

26 

27from tracekit.core.exceptions import FormatError, LoaderError 

28 

29if TYPE_CHECKING: 

30 from numpy.typing import NDArray 

31 

32 

33@dataclass 

34class SParameterData: 

35 """S-parameter data from Touchstone file. 

36 

37 Attributes: 

38 frequencies: Frequency points in Hz. 

39 s_matrix: Complex S-parameter matrix (n_freq x n_ports x n_ports). 

40 n_ports: Number of ports. 

41 z0: Reference impedance in Ohms. 

42 format: Original format ("db", "ma", "ri"). 

43 source_file: Path to source file. 

44 comments: Comments from file header. 

45 """ 

46 

47 frequencies: NDArray[np.float64] 

48 s_matrix: NDArray[np.complex128] 

49 n_ports: int 

50 z0: float = 50.0 

51 format: str = "ri" 

52 source_file: str | None = None 

53 comments: list[str] = field(default_factory=list) 

54 

55 def __post_init__(self) -> None: 

56 """Validate S-parameter data.""" 

57 if len(self.frequencies) == 0: 

58 raise ValueError("frequencies cannot be empty") 

59 

60 expected_shape = (len(self.frequencies), self.n_ports, self.n_ports) 

61 if self.s_matrix.shape != expected_shape: 

62 raise ValueError( 

63 f"s_matrix shape {self.s_matrix.shape} does not match expected {expected_shape}" 

64 ) 

65 

66 def get_s( 

67 self, 

68 i: int, 

69 j: int, 

70 frequency: float | None = None, 

71 ) -> complex | NDArray[np.complex128]: 

72 """Get S-parameter Sij. 

73 

74 Args: 

75 i: Output port (1-indexed). 

76 j: Input port (1-indexed). 

77 frequency: Frequency for interpolation (None = all). 

78 

79 Returns: 

80 Complex S-parameter value(s). 

81 """ 

82 # Convert to 0-indexed 

83 i_idx = i - 1 

84 j_idx = j - 1 

85 

86 if frequency is None: 

87 return self.s_matrix[:, i_idx, j_idx] 

88 

89 # Interpolate 

90 return np.interp( 

91 frequency, 

92 self.frequencies, 

93 self.s_matrix[:, i_idx, j_idx], 

94 ) 

95 

96 

97def load_touchstone(path: str | Path) -> SParameterData: 

98 """Load S-parameter data from Touchstone file. 

99 

100 Supports .s1p through .s8p formats and both Touchstone 1.0 

101 and 2.0 file formats. 

102 

103 Args: 

104 path: Path to Touchstone file. 

105 

106 Returns: 

107 SParameterData with loaded S-parameters. 

108 

109 Raises: 

110 LoaderError: If file cannot be read. 

111 FormatError: If file format is invalid. 

112 

113 Example: 

114 >>> s_params = load_touchstone("cable.s2p") 

115 >>> print(f"Loaded {s_params.n_ports}-port, {len(s_params.frequencies)} points") 

116 

117 References: 

118 Touchstone 2.0 File Format Specification 

119 """ 

120 path = Path(path) 

121 

122 if not path.exists(): 

123 raise LoaderError(f"File not found: {path}") 

124 

125 # Determine number of ports from extension 

126 suffix = path.suffix.lower() 

127 match = re.match(r"\.s(\d+)p", suffix) 

128 if not match: 

129 raise FormatError(f"Unsupported file extension: {suffix}") 

130 

131 n_ports = int(match.group(1)) 

132 

133 try: 

134 with open(path) as f: 

135 lines = f.readlines() 

136 except Exception as e: 

137 raise LoaderError(f"Failed to read file: {e}") # noqa: B904 

138 

139 return _parse_touchstone(lines, n_ports, str(path)) 

140 

141 

142def _parse_touchstone( 

143 lines: list[str], 

144 n_ports: int, 

145 source_file: str, 

146) -> SParameterData: 

147 """Parse Touchstone file content. 

148 

149 Args: 

150 lines: File lines. 

151 n_ports: Number of ports. 

152 source_file: Source file path. 

153 

154 Returns: 

155 Parsed SParameterData. 

156 

157 Raises: 

158 FormatError: If file format is invalid. 

159 """ 

160 comments = [] 

161 option_line = None 

162 data_lines = [] 

163 

164 for line in lines: 

165 line = line.strip() 

166 

167 if not line: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 continue 

169 

170 if line.startswith("!"): 

171 comments.append(line[1:].strip()) 

172 elif line.startswith("#"): 

173 option_line = line 

174 else: 

175 data_lines.append(line) 

176 

177 # Parse option line 

178 freq_unit = 1e9 # Default GHz 

179 format_type = "ma" # Default MA (magnitude/angle) 

180 z0 = 50.0 

181 

182 if option_line: 182 ↛ 203line 182 didn't jump to line 203 because the condition on line 182 was always true

183 option_line = option_line.lower() 

184 parts = option_line.split() 

185 

186 for i, part in enumerate(parts): 

187 if part in ("hz", "khz", "mhz", "ghz"): 

188 freq_unit = { 

189 "hz": 1.0, 

190 "khz": 1e3, 

191 "mhz": 1e6, 

192 "ghz": 1e9, 

193 }[part] 

194 elif part in ("db", "ma", "ri"): 

195 format_type = part 

196 elif part == "r": 

197 # Reference impedance follows 

198 if i + 1 < len(parts): 198 ↛ 186line 198 didn't jump to line 186 because the condition on line 198 was always true

199 with contextlib.suppress(ValueError): 

200 z0 = float(parts[i + 1]) 

201 

202 # Parse data 

203 frequencies = [] 

204 s_data = [] 

205 

206 # Number of S-parameters per frequency 

207 n_s_params = n_ports * n_ports 

208 

209 i = 0 

210 while i < len(data_lines): 

211 # First line has frequency and first S-parameters 

212 parts = data_lines[i].split() 

213 

214 if len(parts) < 1: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 i += 1 

216 continue 

217 

218 freq = float(parts[0]) * freq_unit 

219 frequencies.append(freq) 

220 

221 # Collect all S-parameter values for this frequency 

222 s_values = [] 

223 

224 # Add values from first line 

225 for j in range(1, len(parts), 2): 

226 if j + 1 < len(parts): 226 ↛ 225line 226 didn't jump to line 225 because the condition on line 226 was always true

227 val1 = float(parts[j]) 

228 val2 = float(parts[j + 1]) 

229 s_values.append((val1, val2)) 

230 

231 i += 1 

232 

233 # Continue collecting from subsequent lines if needed 

234 while len(s_values) < n_s_params and i < len(data_lines): 

235 parts = data_lines[i].split() 

236 

237 # Check if this is a new frequency (has odd number of values) 

238 try: 

239 float(parts[0]) 

240 if len(parts) % 2 == 1: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true

241 break # New frequency line 

242 except (ValueError, IndexError): 

243 pass 

244 

245 for j in range(0, len(parts), 2): 

246 if j + 1 < len(parts): 246 ↛ 245line 246 didn't jump to line 245 because the condition on line 246 was always true

247 val1 = float(parts[j]) 

248 val2 = float(parts[j + 1]) 

249 s_values.append((val1, val2)) 

250 

251 i += 1 

252 

253 # Convert to complex based on format 

254 s_complex = [] 

255 for val1, val2 in s_values: 

256 if format_type == "ri": 

257 # Real/Imaginary 

258 s_complex.append(complex(val1, val2)) 

259 elif format_type == "ma": 

260 # Magnitude/Angle (degrees) 

261 mag = val1 

262 angle_rad = np.radians(val2) 

263 s_complex.append(mag * np.exp(1j * angle_rad)) 

264 elif format_type == "db": 264 ↛ 255line 264 didn't jump to line 255 because the condition on line 264 was always true

265 # dB/Angle (degrees) 

266 mag = 10 ** (val1 / 20) 

267 angle_rad = np.radians(val2) 

268 s_complex.append(mag * np.exp(1j * angle_rad)) 

269 

270 # Reshape into matrix 

271 if len(s_complex) == n_s_params: 271 ↛ 210line 271 didn't jump to line 210 because the condition on line 271 was always true

272 s_matrix = np.array(s_complex).reshape(n_ports, n_ports) 

273 s_data.append(s_matrix) 

274 

275 if len(frequencies) == 0: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 raise FormatError("No valid frequency points found") 

277 

278 frequencies_arr = np.array(frequencies, dtype=np.float64) 

279 s_matrix_arr = np.array(s_data, dtype=np.complex128) 

280 

281 return SParameterData( 

282 frequencies=frequencies_arr, 

283 s_matrix=s_matrix_arr, 

284 n_ports=n_ports, 

285 z0=z0, 

286 format=format_type, 

287 source_file=source_file, 

288 comments=comments, 

289 ) 

290 

291 

292def return_loss( 

293 s_params: SParameterData, 

294 frequency: float | None = None, 

295 *, 

296 port: int = 1, 

297) -> float | NDArray[np.float64]: 

298 """Calculate return loss from S-parameters. 

299 

300 Return loss = -20 * log10(|S11|) 

301 

302 Args: 

303 s_params: S-parameter data. 

304 frequency: Frequency in Hz (None = all frequencies). 

305 port: Port number (1-indexed). 

306 

307 Returns: 

308 Return loss in dB. 

309 

310 Example: 

311 >>> rl = return_loss(s_params, frequency=1e9) 

312 >>> print(f"Return loss: {rl:.1f} dB") 

313 

314 References: 

315 IEEE 370-2020 Section 5.2 

316 """ 

317 s11 = s_params.get_s(port, port, frequency) 

318 magnitude = np.abs(s11) 

319 

320 # Avoid log(0) 

321 magnitude = np.maximum(magnitude, 1e-10) 

322 

323 rl = -20 * np.log10(magnitude) 

324 

325 if isinstance(rl, np.ndarray): 

326 return rl 

327 return float(rl) 

328 

329 

330def insertion_loss( 

331 s_params: SParameterData, 

332 frequency: float | None = None, 

333 *, 

334 input_port: int = 1, 

335 output_port: int = 2, 

336) -> float | NDArray[np.float64]: 

337 """Calculate insertion loss from S-parameters. 

338 

339 Insertion loss = -20 * log10(|S21|) 

340 

341 Args: 

342 s_params: S-parameter data. 

343 frequency: Frequency in Hz (None = all frequencies). 

344 input_port: Input port number (1-indexed). 

345 output_port: Output port number (1-indexed). 

346 

347 Returns: 

348 Insertion loss in dB. 

349 

350 Example: 

351 >>> il = insertion_loss(s_params, frequency=1e9) 

352 >>> print(f"Insertion loss: {il:.2f} dB") 

353 

354 References: 

355 IEEE 370-2020 Section 5.3 

356 """ 

357 s21 = s_params.get_s(output_port, input_port, frequency) 

358 magnitude = np.abs(s21) 

359 

360 # Avoid log(0) 

361 magnitude = np.maximum(magnitude, 1e-10) 

362 

363 il = -20 * np.log10(magnitude) 

364 

365 if isinstance(il, np.ndarray): 

366 return il 

367 return float(il) 

368 

369 

370def s_to_abcd( 

371 s_params: SParameterData, 

372 frequency_idx: int | None = None, 

373) -> NDArray[np.complex128]: 

374 """Convert S-parameters to ABCD (chain) parameters. 

375 

376 Used for cascading networks. 

377 

378 Args: 

379 s_params: S-parameter data (2-port only). 

380 frequency_idx: Index for specific frequency (None = all). 

381 

382 Returns: 

383 ABCD matrix (2x2) or array of matrices. 

384 

385 Raises: 

386 ValueError: If s_params is not a 2-port network. 

387 

388 Example: 

389 >>> abcd = s_to_abcd(s_params) 

390 

391 References: 

392 Pozar, "Microwave Engineering", Chapter 4 

393 """ 

394 if s_params.n_ports != 2: 

395 raise ValueError("ABCD conversion only supported for 2-port networks") 

396 

397 z0 = s_params.z0 

398 

399 if frequency_idx is not None: 

400 s = s_params.s_matrix[frequency_idx] 

401 return _s_to_abcd_single(s, z0) 

402 

403 # Convert all frequencies 

404 n_freq = len(s_params.frequencies) 

405 abcd = np.zeros((n_freq, 2, 2), dtype=np.complex128) 

406 

407 for i in range(n_freq): 

408 abcd[i] = _s_to_abcd_single(s_params.s_matrix[i], z0) 

409 

410 return abcd 

411 

412 

413def _s_to_abcd_single(s: NDArray[np.complex128], z0: float) -> NDArray[np.complex128]: 

414 """Convert single frequency S-matrix to ABCD.""" 

415 s11, s12, s21, s22 = s[0, 0], s[0, 1], s[1, 0], s[1, 1] 

416 

417 denominator = 2 * s21 

418 

419 if abs(denominator) < 1e-12: 419 ↛ 421line 419 didn't jump to line 421 because the condition on line 419 was never true

420 # Singular - return identity-ish 

421 return np.array([[1, 0], [0, 1]], dtype=np.complex128) 

422 

423 A = ((1 + s11) * (1 - s22) + s12 * s21) / denominator 

424 B = z0 * ((1 + s11) * (1 + s22) - s12 * s21) / denominator 

425 C = ((1 - s11) * (1 - s22) - s12 * s21) / (z0 * denominator) 

426 D = ((1 - s11) * (1 + s22) + s12 * s21) / denominator 

427 

428 return np.array([[A, B], [C, D]], dtype=np.complex128) 

429 

430 

431def abcd_to_s( 

432 abcd: NDArray[np.complex128], 

433 z0: float = 50.0, 

434) -> NDArray[np.complex128]: 

435 """Convert ABCD parameters to S-parameters. 

436 

437 Args: 

438 abcd: ABCD matrix (2x2) or array of matrices. 

439 z0: Reference impedance. 

440 

441 Returns: 

442 S-parameter matrix. 

443 

444 References: 

445 Pozar, "Microwave Engineering", Chapter 4 

446 """ 

447 if abcd.ndim == 2: 

448 return _abcd_to_s_single(abcd, z0) 

449 

450 # Handle array of matrices 

451 n_freq = abcd.shape[0] 

452 s = np.zeros((n_freq, 2, 2), dtype=np.complex128) 

453 

454 for i in range(n_freq): 

455 s[i] = _abcd_to_s_single(abcd[i], z0) 

456 

457 return s 

458 

459 

460def _abcd_to_s_single(abcd: NDArray[np.complex128], z0: float) -> NDArray[np.complex128]: 

461 """Convert single ABCD matrix to S-parameters.""" 

462 A, B, C, D = abcd[0, 0], abcd[0, 1], abcd[1, 0], abcd[1, 1] 

463 

464 denominator = A + B / z0 + C * z0 + D 

465 

466 if abs(denominator) < 1e-12: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 return np.zeros((2, 2), dtype=np.complex128) 

468 

469 S11 = (A + B / z0 - C * z0 - D) / denominator 

470 S12 = 2 * (A * D - B * C) / denominator 

471 S21 = 2 / denominator 

472 S22 = (-A + B / z0 - C * z0 + D) / denominator 

473 

474 return np.array([[S11, S12], [S21, S22]], dtype=np.complex128) 

475 

476 

477__all__ = [ 

478 "SParameterData", 

479 "abcd_to_s", 

480 "insertion_loss", 

481 "load_touchstone", 

482 "return_loss", 

483 "s_to_abcd", 

484]