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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""S-Parameter handling and Touchstone file support.
3This module provides Touchstone file loading and S-parameter
4calculations including return loss and insertion loss.
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)
12References:
13 Touchstone 2.0 File Format Specification
14 IEEE 370-2020: Standard for Electrical Characterization
15"""
17from __future__ import annotations
19import contextlib
20import re
21from dataclasses import dataclass, field
22from pathlib import Path
23from typing import TYPE_CHECKING
25import numpy as np
27from tracekit.core.exceptions import FormatError, LoaderError
29if TYPE_CHECKING:
30 from numpy.typing import NDArray
33@dataclass
34class SParameterData:
35 """S-parameter data from Touchstone file.
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 """
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)
55 def __post_init__(self) -> None:
56 """Validate S-parameter data."""
57 if len(self.frequencies) == 0:
58 raise ValueError("frequencies cannot be empty")
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 )
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.
74 Args:
75 i: Output port (1-indexed).
76 j: Input port (1-indexed).
77 frequency: Frequency for interpolation (None = all).
79 Returns:
80 Complex S-parameter value(s).
81 """
82 # Convert to 0-indexed
83 i_idx = i - 1
84 j_idx = j - 1
86 if frequency is None:
87 return self.s_matrix[:, i_idx, j_idx]
89 # Interpolate
90 return np.interp(
91 frequency,
92 self.frequencies,
93 self.s_matrix[:, i_idx, j_idx],
94 )
97def load_touchstone(path: str | Path) -> SParameterData:
98 """Load S-parameter data from Touchstone file.
100 Supports .s1p through .s8p formats and both Touchstone 1.0
101 and 2.0 file formats.
103 Args:
104 path: Path to Touchstone file.
106 Returns:
107 SParameterData with loaded S-parameters.
109 Raises:
110 LoaderError: If file cannot be read.
111 FormatError: If file format is invalid.
113 Example:
114 >>> s_params = load_touchstone("cable.s2p")
115 >>> print(f"Loaded {s_params.n_ports}-port, {len(s_params.frequencies)} points")
117 References:
118 Touchstone 2.0 File Format Specification
119 """
120 path = Path(path)
122 if not path.exists():
123 raise LoaderError(f"File not found: {path}")
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}")
131 n_ports = int(match.group(1))
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
139 return _parse_touchstone(lines, n_ports, str(path))
142def _parse_touchstone(
143 lines: list[str],
144 n_ports: int,
145 source_file: str,
146) -> SParameterData:
147 """Parse Touchstone file content.
149 Args:
150 lines: File lines.
151 n_ports: Number of ports.
152 source_file: Source file path.
154 Returns:
155 Parsed SParameterData.
157 Raises:
158 FormatError: If file format is invalid.
159 """
160 comments = []
161 option_line = None
162 data_lines = []
164 for line in lines:
165 line = line.strip()
167 if not line: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 continue
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)
177 # Parse option line
178 freq_unit = 1e9 # Default GHz
179 format_type = "ma" # Default MA (magnitude/angle)
180 z0 = 50.0
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()
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])
202 # Parse data
203 frequencies = []
204 s_data = []
206 # Number of S-parameters per frequency
207 n_s_params = n_ports * n_ports
209 i = 0
210 while i < len(data_lines):
211 # First line has frequency and first S-parameters
212 parts = data_lines[i].split()
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
218 freq = float(parts[0]) * freq_unit
219 frequencies.append(freq)
221 # Collect all S-parameter values for this frequency
222 s_values = []
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))
231 i += 1
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()
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
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))
251 i += 1
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))
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)
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")
278 frequencies_arr = np.array(frequencies, dtype=np.float64)
279 s_matrix_arr = np.array(s_data, dtype=np.complex128)
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 )
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.
300 Return loss = -20 * log10(|S11|)
302 Args:
303 s_params: S-parameter data.
304 frequency: Frequency in Hz (None = all frequencies).
305 port: Port number (1-indexed).
307 Returns:
308 Return loss in dB.
310 Example:
311 >>> rl = return_loss(s_params, frequency=1e9)
312 >>> print(f"Return loss: {rl:.1f} dB")
314 References:
315 IEEE 370-2020 Section 5.2
316 """
317 s11 = s_params.get_s(port, port, frequency)
318 magnitude = np.abs(s11)
320 # Avoid log(0)
321 magnitude = np.maximum(magnitude, 1e-10)
323 rl = -20 * np.log10(magnitude)
325 if isinstance(rl, np.ndarray):
326 return rl
327 return float(rl)
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.
339 Insertion loss = -20 * log10(|S21|)
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).
347 Returns:
348 Insertion loss in dB.
350 Example:
351 >>> il = insertion_loss(s_params, frequency=1e9)
352 >>> print(f"Insertion loss: {il:.2f} dB")
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)
360 # Avoid log(0)
361 magnitude = np.maximum(magnitude, 1e-10)
363 il = -20 * np.log10(magnitude)
365 if isinstance(il, np.ndarray):
366 return il
367 return float(il)
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.
376 Used for cascading networks.
378 Args:
379 s_params: S-parameter data (2-port only).
380 frequency_idx: Index for specific frequency (None = all).
382 Returns:
383 ABCD matrix (2x2) or array of matrices.
385 Raises:
386 ValueError: If s_params is not a 2-port network.
388 Example:
389 >>> abcd = s_to_abcd(s_params)
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")
397 z0 = s_params.z0
399 if frequency_idx is not None:
400 s = s_params.s_matrix[frequency_idx]
401 return _s_to_abcd_single(s, z0)
403 # Convert all frequencies
404 n_freq = len(s_params.frequencies)
405 abcd = np.zeros((n_freq, 2, 2), dtype=np.complex128)
407 for i in range(n_freq):
408 abcd[i] = _s_to_abcd_single(s_params.s_matrix[i], z0)
410 return abcd
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]
417 denominator = 2 * s21
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)
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
428 return np.array([[A, B], [C, D]], dtype=np.complex128)
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.
437 Args:
438 abcd: ABCD matrix (2x2) or array of matrices.
439 z0: Reference impedance.
441 Returns:
442 S-parameter matrix.
444 References:
445 Pozar, "Microwave Engineering", Chapter 4
446 """
447 if abcd.ndim == 2:
448 return _abcd_to_s_single(abcd, z0)
450 # Handle array of matrices
451 n_freq = abcd.shape[0]
452 s = np.zeros((n_freq, 2, 2), dtype=np.complex128)
454 for i in range(n_freq):
455 s[i] = _abcd_to_s_single(abcd[i], z0)
457 return s
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]
464 denominator = A + B / z0 + C * z0 + D
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)
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
474 return np.array([[S11, S12], [S21, S22]], dtype=np.complex128)
477__all__ = [
478 "SParameterData",
479 "abcd_to_s",
480 "insertion_loss",
481 "load_touchstone",
482 "return_loss",
483 "s_to_abcd",
484]