PyChOpMarg 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, David Banas
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.1
2
+ Name: PyChOpMarg
3
+ Version: 0.1
4
+ Summary: Python implementation of COM, as per IEEE 802.3-22 Annex 93A.
5
+ Author-email: David Banas <capn.freako@gmail.com>
6
+ License: BSD3
7
+ Keywords: com,channel,margin
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: Intended Audience :: Telecommunications Industry
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: Implementation :: CPython
21
+ Classifier: Topic :: Adaptive Technologies
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
24
+ Classifier: Topic :: Scientific/Engineering :: Visualization
25
+ Classifier: Topic :: System :: Emulators
26
+ Classifier: Topic :: System :: Networking
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: <3.12,>=3.9
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: scikit-rf >=0.29
32
+ Requires-Dist: traitsui >=8
33
+
34
+ # PyChOpMarg
35
+ Python implementation of COM, as per IEEE 802.3-22 Annex 93A.
@@ -0,0 +1,10 @@
1
+ pychopmarg/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pychopmarg/com.py,sha256=DrwhRpp78MKjTFH-5-5GUZJjnDuVMbKxNI0zlQLTv3k,35096
3
+ pychopmarg/common.py,sha256=5eD4JSl1aZRkWzY4aFVaV9KRide1MfOzkSs_RzuhZf4,695
4
+ pychopmarg/utility.py,sha256=I3DfhEvCYs0Il0ukphEX7QXLS_L8q9BcJiMxI6mz3pc,6895
5
+ PyChOpMarg-0.1.dist-info/LICENSE,sha256=VRMCdafVsUW1f27QIl9ux4hpudwS4xelQF0tE7iK0L8,1498
6
+ PyChOpMarg-0.1.dist-info/METADATA,sha256=YzGdERj1UyBQ7Ap026NXMc2leiss82hx3SJP3Qh5Xro,1433
7
+ PyChOpMarg-0.1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
8
+ PyChOpMarg-0.1.dist-info/entry_points.txt,sha256=2fyoTM8oK6ZBo91T6aHJ6biwJD8C_UcerXr3b2BlA9Q,50
9
+ PyChOpMarg-0.1.dist-info/top_level.txt,sha256=QwSERRGWYBocozXwQoX7n-dnlaT7IKgx8dnOXCABp6g,11
10
+ PyChOpMarg-0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pychopmarg = pychopmarg.com:cli
@@ -0,0 +1 @@
1
+ pychopmarg
pychopmarg/__init__.py ADDED
File without changes
pychopmarg/com.py ADDED
@@ -0,0 +1,955 @@
1
+ """
2
+ The Channel Operating Margin (COM) model, as per IEEE 802.3-22 Annex 93A.
3
+
4
+ Original author: David Banas <capn.freako@gmail.com>
5
+
6
+ Original date: February 29, 2024
7
+
8
+ Copyright (c) 2024 David Banas; all rights reserved World wide.
9
+
10
+ Notes:
11
+ 1. Throughout this file, equation numbers refer to Annex 93A of the IEEE 802.3-22 standard.
12
+
13
+ ToDo:
14
+ 1. Provide type hints for imports.
15
+ """
16
+
17
+ import numpy as np # type: ignore
18
+ import skrf as rf # type: ignore
19
+
20
+ from scipy.interpolate import interp1d
21
+ from traits.api import HasTraits, Property, Array, Float, cached_property # type: ignore
22
+ from typing import TypeVar, Any, Dict
23
+
24
+ from pychopmarg.common import Rvec, Cvec, COMParams, PI, TWOPI
25
+ from pychopmarg.utility import import_s32p, sdd_21
26
+
27
+ # Globals used by `calc_Hffe()`, to minimize the size of its cache.
28
+ # They are initialized by `COM.__init__()`.
29
+ gFreqs: Rvec = None # type: ignore
30
+ gFb: float = None # type: ignore
31
+ gC0min: float = None # type: ignore
32
+ gNtaps: int = None # type: ignore
33
+
34
+ T = TypeVar('T', Any, Any)
35
+
36
+
37
+ def all_combs(xss: list[list[T]]) -> list[list[T]]:
38
+ """
39
+ Generate all combinations of input.
40
+
41
+ Args:
42
+ xss([[T]]): The lists of candidates for each position in the final output.
43
+
44
+ Returns:
45
+ [[T]]: All possible combinations of input lists.
46
+ """
47
+ if not xss:
48
+ return [[]]
49
+ head, *tail = xss
50
+ yss = all_combs(tail)
51
+ return [[x] + ys for x in head for ys in yss]
52
+
53
+
54
+ # @cache
55
+ # def calc_Hffe(tap_weights: Rvec) -> Cvec:
56
+ def calc_Hffe(tap_weights: list[float]) -> Cvec:
57
+ """
58
+ Calculate the voltage transfer function, H(f), for the Tx FFE,
59
+ according to (93A-21).
60
+
61
+ Args:
62
+ tap_weights: The vector of filter tap weights, excluding the cursor tap.
63
+
64
+ Returns:
65
+ The complex voltage transfer function, H(f), for the Tx FFE.
66
+
67
+ Raises:
68
+ RuntimeError: If the global variables above haven't been initialized.
69
+ ValueError: If the length of the given tap weight vector is incorrect.
70
+
71
+ Notes:
72
+ 1. This function has been (awkwardly) pulled outside of the
73
+ `COM` class and made to use global variables, strictly for
74
+ performance reasons.
75
+ (Note that `@cached_property` decorated instance functions
76
+ of `HasTraits` subclasses are not actually memoized, like
77
+ `@cache` decorated ordinary functions are.)
78
+ (It is used in the innermost layer of the nested loop structure
79
+ used to find the optimal EQ solution. And its input argument
80
+ is repeated often.)
81
+ (See the `opt_eq()` method of the `COM` class.)
82
+ 2. Currently, a single post-cursor tap is assumed.
83
+
84
+ ToDo:
85
+ 1. Remove the single post-cursor tap assumption.
86
+ """
87
+
88
+ assert len(gFreqs) and gFb and gC0min and gNtaps, RuntimeError(
89
+ "Called before global variables were initialized!")
90
+ assert len(tap_weights) == gNtaps, ValueError(
91
+ "Length of given tap weight vector is incorrect!")
92
+
93
+ c0 = 1 - sum(list(map(abs, tap_weights)))
94
+ if c0 < gC0min:
95
+ return np.ones(len(gFreqs))
96
+ else:
97
+ cs = tap_weights
98
+ cs.insert(-1, c0) # Note the assumption of only one post-cursor tap!
99
+ return sum(list(map(lambda n_c: n_c[1] * np.exp(-1j * TWOPI * n_c[0] * gFreqs / gFb),
100
+ enumerate(cs))))
101
+
102
+
103
+ class COM(HasTraits):
104
+ """
105
+ Encoding of the IEEE 802.3-22 Annex 93A "Channel Operating Margin"
106
+ (COM) specification, as a Python class making use of the Enthought
107
+ Traits/UI machinery, for both calculation efficiency and easy GUI display.
108
+ """
109
+
110
+ # Independent variable definitions
111
+ ui = Float(100e-12) # Unit interval (s).
112
+ freqs = Array(value=np.arange(0, 40_010e6, 10e6)) # System frequencies (Hz).
113
+ gDC = Float(0) # D.C. gain of Rx CTLE first stage (dB).
114
+ gDC2 = Float(0) # D.C. gain of Rx CTLE first stage (dB).
115
+
116
+ # Dependent variable definitions
117
+ Xsinc = Property(Array, depends_on=["ui", "freqs"])
118
+
119
+ @cached_property
120
+ def _get_Xsinc(self):
121
+ """Frequency domain sinc(f) corresponding to Rect(ui)."""
122
+ ui = self.ui
123
+ w = int(ui / self.Ts)
124
+ return w * np.sinc(ui * self.freqs)
125
+
126
+ Hr = Property(Array, depends_on=['freqs'])
127
+
128
+ @cached_property
129
+ def _get_Hr(self):
130
+ """
131
+ Return the voltage transfer function, H(f), of the Rx AFE,
132
+ according to (93A-20).
133
+ """
134
+ f = self.freqs / (self.params['fr'] * self.fb)
135
+ return 1 / (1 - 3.414214 * f**2 + f**4 + 2.613126j * (f - f**3))
136
+
137
+ Hctf = Property(Array, depends_on=['freqs', 'gDC', 'gDC2'])
138
+
139
+ @cached_property
140
+ def _get_Hctf(self):
141
+ """
142
+ Return the voltage transfer function, H(f), of the Rx CTLE,
143
+ according to (93A-22).
144
+ """
145
+ f = self.freqs
146
+ g1 = pow(10, self.gDC / 20)
147
+ g2 = pow(10, self.gDC2 / 20)
148
+ fz = self.params['fz'] * 1e9
149
+ fp1 = self.params['fp1'] * 1e9
150
+ fp2 = self.params['fp2'] * 1e9
151
+ fLF = self.params['fLF'] * 1e9
152
+ num = (g1 + 1j * f / fz) * (g2 + 1j * f / fLF)
153
+ den = (1 + 1j * f / fp1) * (1 + 1j * f / fp2) * (1 + 1j * f / fLF)
154
+ return num / den
155
+
156
+ # Reserved functions
157
+
158
+ def __call__(self, do_opt_eq=True, tx_taps: Rvec = None):
159
+ """
160
+ Calculate the COM value.
161
+
162
+ KeywordArgs:
163
+ opt_eq: Perform optimization of linear equalization when True.
164
+ Default: True
165
+ tx_taps: Used when `do_opt_eq` = False.
166
+ Default: None
167
+ """
168
+
169
+ assert self.opt_eq(do_opt_eq=do_opt_eq, tx_taps=tx_taps), RuntimeError(
170
+ "EQ optimization failed!")
171
+ self.gDC = self.rslts['gDC']
172
+ self.gDC2 = self.rslts['gDC2']
173
+ self.tx_taps = self.rslts['tx_taps']
174
+
175
+ As, Ani, cursor_ix = self.calc_noise()
176
+ com = 20 * np.log10(As / Ani)
177
+ self.com = com
178
+ self.rslts['As'] = As * 1_000 # (mV)
179
+ self.rslts['Ani'] = Ani * 1_000 # (mV)
180
+ self.rslts['com'] = com
181
+ self.cursor_ix = cursor_ix
182
+ return com
183
+
184
+ def __init__(self, params: COMParams, chnl_files: list[str], vic_chnl_ix: int,
185
+ zp_sel: int = 1, num_ui: int = 100, gui: bool = True):
186
+ """
187
+ COM class initializer.
188
+
189
+ Args:
190
+ params: COM configuration parameters for desired standard.
191
+ Note: Assumed immutable. ToDo: Can we encode this assumption, using type annotations?
192
+ chnl_files: Touchstone file(s) representing channel, either:
193
+ 1. 8 s4p files: [victim, ], or
194
+ 2. 1 s32p file, according to VITA 68.2 convention.
195
+ vic_chnl_ix: Victim channel index (from 1).
196
+
197
+ KeywordArgs:
198
+ zp_sel: User selection of package T-line length option (from 1).
199
+ Default: 1
200
+ num_ui: Number of unit intervals to include in system time vector.
201
+ Default: 100
202
+ gui: Set to `False` for script/CLI based usage.
203
+ Default: True
204
+ """
205
+
206
+ # Super-class initialization is ABSOLUTELY NECESSARY, in order
207
+ # to get all the Traits/UI machinery setup correctly.
208
+ super().__init__()
209
+
210
+ # Set default parameter values, as necessary.
211
+ if 'zc' not in params:
212
+ params['zc'] = 78.2
213
+ if 'fLF' not in params:
214
+ params['fLF'] = 1
215
+
216
+ # Stash function parameters.
217
+ self.params = params
218
+ self.chnl_files = chnl_files
219
+ self.gui = gui
220
+
221
+ # Calculate intermediate variable values.
222
+ self.rslts = {}
223
+ self.fom_rslts: Dict[str, Any] = {}
224
+ self.dbg: Dict[str, Any] = {}
225
+ fb = params['fb'] * 1e9
226
+ ui = 1 / fb
227
+ M = self.params['M']
228
+ sample_per = ui / M
229
+ fstep = self.params['fstep']
230
+ trips = list(zip(self.params['tx_min'], self.params['tx_max'], self.params['tx_step']))
231
+ self.tx_combs = all_combs(list(map(
232
+ lambda trip: list(np.arange(trip[0], trip[1] + trip[2], trip[2])),
233
+ trips)))
234
+ self.gamma1 = (self.params['Rd'] - self.params['R0']) / (self.params['Rd'] + self.params['R0'])
235
+ self.gamma2 = self.gamma1
236
+
237
+ # Import channel Touchstone file(s).
238
+ if len(chnl_files) == 1:
239
+ _ntwks = import_s32p(chnl_files[0], vic_chnl_ix)
240
+ fmax = _ntwks[0][0].f[-1]
241
+ else: # ToDo: Do I have the ordering correct here?
242
+ _ntwks = []
243
+ fmax = 1000e9
244
+ n_files = len(chnl_files)
245
+ for n, chnl_file in enumerate(chnl_files):
246
+ ntwk = sdd_21(rf.Network(chnl_file))
247
+ fmax = min(fmax, ntwk.f[-1])
248
+ if n >= n_files // 2: # This one is a NEXT aggressor.
249
+ _ntwks.append((ntwk, 'NEXT'))
250
+ elif n > 0: # This one is a FEXT aggressor.
251
+ _ntwks.append((ntwk, 'FEXT'))
252
+ else: # This is the victim.
253
+ _ntwks.append((ntwk, 'THRU'))
254
+
255
+ # Calculate system time/frequency vectors.
256
+ # - We use "decoupled" time/frequency vectors.
257
+ freqs = np.arange(0, fmax + fstep, fstep) # [0,fN], i.e., one extra element
258
+ times = np.array([n * sample_per for n in range(num_ui * M)]) # independent of `freqs`
259
+ # - That requires a third vector for correctly timing `irfft()` results.
260
+ Ts = 0.5 / fmax # Sample period satisfying Nyquist criteria.
261
+ t_irfft = np.array([n * Ts for n in range(2 * (len(freqs) - 1))])
262
+ self.freqs = freqs
263
+ self.times = times
264
+ self.Ts = Ts # Not the same as `sample_per`!
265
+ self.t_irfft = t_irfft
266
+
267
+ # Augment w/ packages.
268
+ sTx_NEXT = self.sPkg(zp_opt=1, isTx=True)
269
+ sTx = self.sPkg(zp_opt=zp_sel, isTx=True)
270
+ sRx = self.sPkg(zp_opt=zp_sel, isTx=False)
271
+ ntwks = []
272
+ for ntwk, ntype in _ntwks:
273
+ if ntype == 'NEXT':
274
+ ntwks.append((sTx_NEXT ** ntwk ** sRx, ntype))
275
+ else:
276
+ ntwks.append((sTx ** ntwk ** sRx, ntype))
277
+
278
+ # Generate the pulse responses before/after adding the packages, for reference.
279
+ self.ntwks = _ntwks
280
+ self.pulse_resps_nopkg = self.gen_pulse_resps([], apply_eq=False)
281
+ self.ntwks = ntwks
282
+ self.pulse_resps_noeq = self.gen_pulse_resps([], apply_eq=False)
283
+ self.rslts['vic_pulse_pk'] = params['Av'] * max(self.pulse_resps_noeq[0]) * 1_000 # (mV)
284
+
285
+ # Store calculated results.
286
+ self.fb = fb
287
+ self.ui = ui
288
+ self.sample_per = sample_per
289
+ self.fmax = fmax
290
+ self.nDFE = len(self.params['dfe_max'])
291
+
292
+ # Initialize global variables.
293
+ global gFreqs
294
+ global gFb
295
+ global gC0min
296
+ global gNtaps
297
+ gFreqs = freqs
298
+ gFb = fb
299
+ gC0min = params['c0min']
300
+ gNtaps = len(params['tx_min'])
301
+
302
+ # General functions
303
+ def sC(self, c: float) -> rf.Network:
304
+ """
305
+ Return the 2-port network corresponding to a shunt capacitance,
306
+ according to (93A-8).
307
+
308
+ Args:
309
+ c: Value of shunt capacitance (F).
310
+
311
+ Returns:
312
+ ntwk: 2-port network equivalent to shunt capacitance, calculated at given frequencies.
313
+
314
+ Raises:
315
+ None
316
+ """
317
+
318
+ r0 = self.params['R0']
319
+ freqs = self.freqs
320
+ w = TWOPI * freqs
321
+ s = 1j * w
322
+ s2p = np.array(
323
+ [1 / (2 + _s * c * r0) * np.array(
324
+ [[-_s * c * r0, 2],
325
+ [2, -_s * c * r0]])
326
+ for _s in s])
327
+ return rf.Network(s=s2p, f=freqs, z0=[2 * r0, 2 * r0])
328
+
329
+ def sZp(self, zp_opt: int = 1) -> rf.Network:
330
+ """
331
+ Return the 2-port network corresponding to a package transmission line,
332
+ according to (93A-9:14).
333
+
334
+ KeywordArgs:
335
+ zp_opt: Package TL length option (from 1).
336
+ Default: 1
337
+
338
+ Returns:
339
+ ntwk: 2-port network equivalent to package transmission line.
340
+
341
+ Raises:
342
+ None
343
+ """
344
+
345
+ n_zp_opts = len(self.params['zp'])
346
+ assert zp_opt <= n_zp_opts, ValueError(
347
+ f"Asked for zp option {zp_opt}, but there are only {n_zp_opts}!")
348
+ zc = self.params['zc']
349
+ r0 = self.params['R0']
350
+ zp = self.params['zp'][zp_opt - 1]
351
+
352
+ f_GHz = self.freqs / 1e9
353
+ a1 = 1.734e-3 # sqrt(ns)/mm
354
+ a2 = 1.455e-4 # ns/mm
355
+ tau = 6.141e-3 # ns/mm
356
+ rho = (zc - 2 * r0) / (zc + 2 * r0)
357
+ gamma0 = 0 # 1/mm
358
+ gamma1 = a1 * (1 + 1j)
359
+
360
+ def gamma2(f):
361
+ "f in GHz!"
362
+ return a2 * (1 - 1j * (2 / PI) * np.log(f)) + 1j * TWOPI * tau
363
+
364
+ def gamma(f: float) -> complex:
365
+ "Return complex propagation coefficient at frequency f (GHz)."
366
+ if f == 0:
367
+ return gamma0
368
+ else:
369
+ return gamma0 + gamma1 * np.sqrt(f) + gamma2(f) * f
370
+
371
+ g = np.array(list(map(gamma, f_GHz)))
372
+ s11 = s22 = rho * (1 - np.exp(-g * 2 * zp)) / (1 - rho**2 * np.exp(-g * 2 * zp))
373
+ s21 = s12 = (1 - rho**2) * np.exp(-g * zp) / (1 - rho**2 * np.exp(-g * 2 * zp))
374
+ s2p = np.array(
375
+ [[[_s11, _s12],
376
+ [_s21, _s22]]
377
+ for _s11, _s12, _s21, _s22 in zip(s11, s12, s21, s22)])
378
+ return rf.Network(s=s2p, f=self.freqs, z0=[2 * r0, 2 * r0])
379
+
380
+ def sPkg(self, zp_opt: int = 1, isTx: bool = True) -> rf.Network:
381
+ """
382
+ Return the 2-port network corresponding to a complete package model,
383
+ according to (93A-15:16).
384
+
385
+ KeywordArgs:
386
+ zp_opt: Package TL length option (from 1).
387
+ Default: 1
388
+ isTx: Requesting Tx package when True.
389
+ Default: True
390
+
391
+ Returns:
392
+ ntwk: 2-port network equivalent to complete package model.
393
+
394
+ Raises:
395
+ None
396
+ """
397
+
398
+ sd = self.sC(self.params['Cd'] / 1e12)
399
+ sp = self.sC(self.params['Cp'] / 1e12)
400
+ sl = self.sZp(zp_opt)
401
+ if isTx:
402
+ return sd ** sl ** sp
403
+ else:
404
+ return sp ** sl ** sd
405
+
406
+ def H21(self, s2p: rf.Network) -> Cvec:
407
+ """
408
+ Return the voltage transfer function, H21(f), of a terminated two
409
+ port network, according to (93A-18).
410
+
411
+ Args:
412
+ s2p: Two port network of interest.
413
+
414
+ Returns:
415
+ Complex voltage transfer function at given frequencies.
416
+
417
+ Raises:
418
+ ValueError: If given network is not two port.
419
+
420
+ Notes:
421
+ 1. It is at this point in the analysis that the "raw" Touchstone
422
+ data gets interpolated to our system frequency vector.
423
+ 2. After this step, the package and R0/Rd mismatch have been
424
+ accounted for, but not the EQ.
425
+ """
426
+
427
+ assert s2p.s[0].shape == (2, 2), ValueError("I can only convert 2-port networks.")
428
+ s2p.interpolate_self(self.freqs)
429
+ g1 = self.gamma1
430
+ g2 = self.gamma2
431
+ s11 = s2p.s11.s.flatten()
432
+ s12 = s2p.s12.s.flatten()
433
+ s21 = s2p.s21.s.flatten()
434
+ s22 = s2p.s22.s.flatten()
435
+ dS = s11 * s22 - s12 * s21
436
+ return (s21 * (1 - g1) * (1 + g2)) / (1 - s11 * g1 - s22 * g2 + g1 * g2 * dS)
437
+
438
+ def H(self, s2p: rf.Network, tap_weights: Rvec) -> Cvec:
439
+ """
440
+ Return the voltage transfer function, H(f), of a complete COM signal path,
441
+ according to (93A-19).
442
+
443
+ Args:
444
+ s2p: Two port network of interest.
445
+ tap_weights: Tx FFE tap weights.
446
+
447
+ Returns:
448
+ Complex voltage transfer function of complete path.
449
+
450
+ Raises:
451
+ ValueError: If given network is not two port,
452
+ or length of `tap_weights` is incorrect.
453
+
454
+ Notes:
455
+ 1. Assumes `self.gDC` and `self.gDC2` have been set correctly.
456
+ 2. It is in this processing step that linear EQ is first applied.
457
+ """
458
+
459
+ assert s2p.s[0, :, :].shape == (2, 2), ValueError(
460
+ f"I can only convert 2-port networks. {s2p}")
461
+ self.H21_temp = self.H21(s2p)
462
+ return calc_Hffe(list(tap_weights)) * self.H21_temp * self.Hr * self.Hctf
463
+
464
+ def pulse_resp(self, H: Cvec) -> Rvec:
465
+ """
466
+ Return the unit pulse response, p(t), corresponding to the given
467
+ voltage transfer function, H(f), according to (93A-24).
468
+
469
+ Args:
470
+ H: The voltage transfer function, H(f).
471
+ Note: Possitive frequency components only, including fN.
472
+
473
+ Returns:
474
+ p: The pulse response corresponding to the given voltage transfer function.
475
+
476
+ Raises:
477
+ ValueError: If the length of the given voltage transfer
478
+ function differs from that of the system frequency vector.
479
+
480
+ Notes:
481
+ 1. It is at this point in the signal processing chain that we change
482
+ time domains.
483
+ """
484
+
485
+ assert len(H) == len(self.freqs), ValueError(
486
+ "Length of given H(f) does not match length of f!")
487
+
488
+ Xsinc = self.Xsinc
489
+ p = np.fft.irfft(Xsinc * H)
490
+ p_mag = np.abs(p)
491
+ p_beg = np.where(p_mag > 0.01 * max(p_mag))[0][0] - int(5 * self.ui / self.Ts) # Give it some "front porch".
492
+ spln = interp1d(self.t_irfft, np.roll(p, -p_beg)) # `p` is not yet in our system time domain!
493
+ return spln(self.times) # Now, it is.
494
+
495
+ def gen_pulse_resps(self, tx_taps: Rvec, apply_eq=True) -> list[Rvec]:
496
+ """
497
+ Generate pulse responses for all networks.
498
+
499
+ Args:
500
+ tx_taps: Desired Tx tap weights.
501
+
502
+ KeywordArgs:
503
+ apply_eq: Include linear EQ when True; otherwise, exclude it.
504
+ Default: True
505
+
506
+ Returns:
507
+ List of pulse responses.
508
+
509
+ Raises:
510
+ None
511
+
512
+ Notes:
513
+ 1. Assumes `self.gDC` and `self.gDC2` have been set correctly.
514
+ """
515
+
516
+ pulse_resps = []
517
+ for ntwk, ntype in self.ntwks:
518
+ if apply_eq:
519
+ pr = self.pulse_resp(self.H(ntwk, tx_taps))
520
+ else:
521
+ pr = self.pulse_resp(self.H21(ntwk))
522
+
523
+ if ntype == 'THRU':
524
+ pr *= self.params['Av']
525
+ elif ntype == 'NEXT':
526
+ pr *= self.params['Ane']
527
+ else:
528
+ pr *= self.params['Afe']
529
+
530
+ pulse_resps.append(pr)
531
+
532
+ return pulse_resps
533
+
534
+ def filt_pr_samps(self, pr_samps: Rvec, As: float, rel_thresh: float = 0.001) -> Rvec:
535
+ """
536
+ Filter a list of pulse response samples for minimum magnitude.
537
+
538
+ Args:
539
+ pr_samps: The pulse response samples to filter.
540
+ As: Signal amplitude, as per 93A.1.6.c.
541
+
542
+ KeywordArgs:
543
+ rel_thresh: Filtration threshold (As).
544
+ Default: 0.001 (i.e. - 0.1%, as per Note 2 of 93A.1.7.1)
545
+
546
+ Returns:
547
+ filtered_samps: The subset of `pr_samps` passing filtration.
548
+ """
549
+
550
+ thresh = As * rel_thresh
551
+ return np.array(list(filter(lambda x: abs(x) >= thresh, pr_samps)))
552
+
553
+ def calc_hJ(self, pulse_resp: Rvec, As: float, cursor_ix: int, rel_thresh: float = 0.001) -> Rvec:
554
+ """
555
+ Calculate the set of slopes for valid pulse response samples.
556
+
557
+ Args:
558
+ pulse_resp: The pulse response of interest.
559
+ As: Signal amplitude, as per 93A.1.6.c.
560
+ cursor_ix: Cursor index.
561
+
562
+ KeywordArgs:
563
+ rel_thresh: Filtration threshold (As).
564
+ Default: 0.001 (i.e. - 0.1%, as per Note 2 of 93A.1.7.1)
565
+
566
+ Returns:
567
+ slopes: The calculated slopes around the valid samples.
568
+ """
569
+
570
+ M = self.params['M']
571
+ thresh = As * rel_thresh
572
+ valid_pr_samp_ixs = np.array(list(filter(lambda ix: abs(pulse_resp[ix]) >= thresh,
573
+ range(cursor_ix, len(pulse_resp) - 1, M))))
574
+ m1s = pulse_resp[valid_pr_samp_ixs - 1]
575
+ p1s = pulse_resp[valid_pr_samp_ixs + 1]
576
+ return (p1s - m1s) / (2 / M) # (93A-28)
577
+
578
+ def loc_curs(self, pulse_resp: Rvec, max_range: int = 4) -> int:
579
+ """
580
+ Locate the cursor position for the given pulse response,
581
+ according to (93A-25) and (93A-26).
582
+
583
+ Args:
584
+ pulse_resp: The pulse response of interest.
585
+
586
+ KeywordArgs:
587
+ max_range: The search radius, from the peak.
588
+
589
+ Returns:
590
+ The index in the given pulse response vector of the cursor.
591
+
592
+ Notes:
593
+ 1. As per v3.70 of the COM MATLAB code, we only minimize the
594
+ residual of (93A-25); we don't try to solve it exactly.
595
+ """
596
+
597
+ M = self.params['M']
598
+ dfe_max = self.params['dfe_max']
599
+ dfe_min = self.params['dfe_min']
600
+
601
+ # Find zero crossings.
602
+ peak_loc = np.argmax(pulse_resp)
603
+ peak_val = pulse_resp[peak_loc]
604
+ search_start = max(0, peak_loc - 4 * M)
605
+ zxi = np.where(np.diff(np.sign(pulse_resp[search_start:peak_loc] - .01 * peak_val)) >= 1)[0] + search_start
606
+ assert zxi, RuntimeError("No zero crossings found!")
607
+ zxi = zxi[-1]
608
+
609
+ # Minimize Muller-Mueller criterion within a 2UI range after zero crossing.
610
+ ix_best = zxi
611
+ res_min = 1e6
612
+ for ix in range(zxi, zxi + 2 * M):
613
+ b_1 = min(dfe_max[0],
614
+ max(dfe_min[0],
615
+ pulse_resp[ix + M] / pulse_resp[ix])) # (93A-26)
616
+ res = abs(pulse_resp[ix - M] - (pulse_resp[ix + M] - b_1 * pulse_resp[ix])) # (93A-25)
617
+ if res < res_min:
618
+ ix_best = ix
619
+ res_min = res
620
+ return ix_best
621
+
622
+ def calc_fom(self, tx_taps: Rvec) -> float:
623
+ """
624
+ Calculate the _figure of merit_ (FOM), given the existing linear EQ settings.
625
+
626
+ Args:
627
+ tx_taps: The Tx FFE tap weights, excepting the cursor.
628
+ (The cursor takes whatever is left.)
629
+
630
+ Returns:
631
+ FOM: The resultant figure of merit.
632
+
633
+ Raises:
634
+ None.
635
+
636
+ Notes:
637
+ 1. Assumes that `self.gDC` and `self.gDC2` have been set correctly.
638
+ 2. See: IEEE 802.3-2022 93A.1.6.
639
+ """
640
+
641
+ L = self.params['L']
642
+ M = self.params['M']
643
+ freqs = self.freqs
644
+
645
+ # Step a - Pulse response construction.
646
+ pulse_resps = self.gen_pulse_resps(np.array(tx_taps))
647
+
648
+ # Step b - Cursor identification.
649
+ vic_pulse_resp = np.array(pulse_resps[0])
650
+ vic_peak_loc = np.argmax(vic_pulse_resp)
651
+ cursor_ix = self.loc_curs(vic_pulse_resp)
652
+
653
+ # Step c - As.
654
+ vic_curs_val = vic_pulse_resp[cursor_ix]
655
+ As = self.params['RLM'] * vic_curs_val / (L - 1)
656
+
657
+ # Step d - Tx noise.
658
+ varX = (L**2 - 1) / (3 * (L - 1)**2) # (93A-29)
659
+ varTx = vic_curs_val**2 * pow(10, -self.params['TxSNR'] / 10) # (93A-30)
660
+
661
+ # Step e - ISI.
662
+ nDFE = self.nDFE
663
+ # This is not compliant to the standaard, but is consistent w/ v2.60 of MATLAB code.
664
+ n_pre = cursor_ix // M
665
+ first_pre_ix = cursor_ix - n_pre * M
666
+ vic_pulse_resp_isi_samps = np.concatenate((vic_pulse_resp[first_pre_ix:cursor_ix:M],
667
+ vic_pulse_resp[cursor_ix + M::M]))
668
+ vic_pulse_resp_post_samps = vic_pulse_resp_isi_samps[n_pre:]
669
+ dfe_tap_weights = np.maximum( # (93A-26)
670
+ self.params['dfe_min'],
671
+ np.minimum(
672
+ self.params['dfe_max'],
673
+ (vic_pulse_resp_post_samps[:nDFE] / vic_curs_val)))
674
+ hISI = vic_pulse_resp_isi_samps \
675
+ - vic_curs_val * np.pad(dfe_tap_weights,
676
+ (n_pre, len(vic_pulse_resp_post_samps) - nDFE),
677
+ mode='constant',
678
+ constant_values=0) # (93A-27)
679
+ varISI = varX * sum(hISI**2) # (93A-31)
680
+ self.dbg['vic_pulse_resp_isi_samps'] = vic_pulse_resp_isi_samps
681
+ self.dbg['vic_pulse_resp_post_samps'] = vic_pulse_resp_post_samps
682
+ self.dbg['hISI'] = hISI
683
+
684
+ # Step f - Jitter noise.
685
+ hJ = self.calc_hJ(vic_pulse_resp, As, cursor_ix)
686
+ varJ = (self.params['Add']**2 + self.params['sigma_Rj']**2) * varX * sum(hJ**2) # (93A-32)
687
+
688
+ # Step g - Crosstalk.
689
+ varXT = 0
690
+ for pulse_resp in pulse_resps[1:]: # (93A-34)
691
+ varXT += max([sum(np.array(self.filt_pr_samps(pulse_resp[m::M], As))**2) for m in range(M)]) # (93A-33)
692
+ varXT *= varX
693
+
694
+ # Step h - Spectral noise.
695
+ df = freqs[1]
696
+ varN = self.params['eta0'] * sum(abs(self.Hr * self.Hctf)**2) * (df / 1e9) # (93A-35)
697
+
698
+ # Step i - FOM calculation.
699
+ fom = 10 * np.log10(As**2 / (varTx + varISI + varJ + varXT + varN)) # (93A-36)
700
+
701
+ # Stash our calculation results.
702
+ self.fom_rslts['pulse_resps'] = pulse_resps
703
+ self.fom_rslts['vic_pulse_resp'] = vic_pulse_resp
704
+ self.fom_rslts['vic_peak_loc'] = vic_peak_loc
705
+ self.fom_rslts['cursor_ix'] = cursor_ix
706
+ self.fom_rslts['As'] = As
707
+ self.fom_rslts['varTx'] = varTx
708
+ self.fom_rslts['dfe_tap_weights'] = dfe_tap_weights
709
+ self.fom_rslts['varISI'] = varISI
710
+ self.fom_rslts['varJ'] = varJ
711
+ self.fom_rslts['varXT'] = varXT
712
+ self.fom_rslts['varN'] = varN
713
+
714
+ return fom
715
+
716
+ def opt_eq(self, do_opt_eq: bool = True, tx_taps: Rvec = None) -> bool:
717
+ """
718
+ Find the optimum values for the linear equalization parameters:
719
+ c(-2), c(-1), c(1), gDC, and gDC2, as per IEEE 802.3-22 93A.1.6.
720
+
721
+ KeywordArgs:
722
+ do_opt_eq: Perform optimization of linear EQ when True.
723
+ Default: True
724
+ tx_taps: Used when `do_opt_eq` = False.
725
+ Default: None
726
+
727
+ Returns:
728
+ success: True if no errors encountered; False otherwise.
729
+ """
730
+
731
+ if do_opt_eq:
732
+ # Run the nested optimization loops.
733
+ def check_taps(tx_taps: Rvec) -> bool:
734
+ if (1 - sum(abs(np.array(tx_taps)))) < self.params['c0min']:
735
+ return False
736
+ else:
737
+ return True
738
+
739
+ fom_max = -100.0
740
+ fom_max_changed = False
741
+ foms = []
742
+ for gDC2 in self.params['gDC2']:
743
+ self.gDC2 = gDC2
744
+ for gDC in self.params['gDC']:
745
+ self.gDC = gDC
746
+ for n, tx_taps in enumerate(self.tx_combs):
747
+ if not check_taps(np.array(tx_taps)):
748
+ continue
749
+ fom = self.calc_fom(tx_taps)
750
+ foms.append(fom)
751
+ if fom > fom_max:
752
+ fom_max_changed = True
753
+ fom_max = fom
754
+ gDC2_best = gDC2
755
+ gDC_best = gDC
756
+ tx_taps_best = tx_taps
757
+ dfe_tap_weights_best = self.fom_rslts['dfe_tap_weights']
758
+ cursor_ix_best = self.fom_rslts['cursor_ix']
759
+ As_best = self.fom_rslts['As']
760
+ varTx_best = self.fom_rslts['varTx']
761
+ varISI_best = self.fom_rslts['varISI']
762
+ varJ_best = self.fom_rslts['varJ']
763
+ varXT_best = self.fom_rslts['varXT']
764
+ varN_best = self.fom_rslts['varN']
765
+ else:
766
+ assert tx_taps, RuntimeError("You must define `tx_taps` when setting `do_opt_eq` False!")
767
+ fom = self.calc_fom(tx_taps)
768
+ foms = [fom]
769
+ fom_max = fom
770
+ fom_max_changed = True
771
+ gDC2_best = self.gDC2
772
+ gDC_best = self.gDC
773
+ tx_taps_best = tx_taps
774
+ dfe_tap_weights_best = self.fom_rslts['dfe_tap_weights']
775
+ cursor_ix_best = self.fom_rslts['cursor_ix']
776
+ As_best = self.fom_rslts['As']
777
+ varTx_best = self.fom_rslts['varTx']
778
+ varISI_best = self.fom_rslts['varISI']
779
+ varJ_best = self.fom_rslts['varJ']
780
+ varXT_best = self.fom_rslts['varXT']
781
+ varN_best = self.fom_rslts['varN']
782
+
783
+ # Check for error and save the best results.
784
+ if not fom_max_changed:
785
+ return False
786
+ self.rslts['fom'] = fom_max
787
+ self.rslts['gDC2'] = gDC2_best
788
+ self.rslts['gDC'] = gDC_best
789
+ self.rslts['tx_taps'] = tx_taps_best
790
+ self.rslts['dfe_tap_weights'] = dfe_tap_weights_best
791
+ self.rslts['cursor_ix'] = cursor_ix_best
792
+ self.rslts['As'] = As_best * 1_000 # (mV)
793
+ self.rslts['sigma_ISI'] = np.sqrt(varISI_best) * 1_000 # (mV)
794
+ self.rslts['sigma_J'] = np.sqrt(varJ_best) * 1_000 # (mV)
795
+ self.rslts['sigma_XT'] = np.sqrt(varXT_best) * 1_000 # (mV)
796
+ # These two are also calculated by `calc_noise()`; so, add "_best".
797
+ self.rslts['sigma_Tx_best'] = np.sqrt(varTx_best) * 1_000 # (mV)
798
+ self.rslts['sigma_N_best'] = np.sqrt(varN_best) * 1_000 # (mV)
799
+ self.rslts['foms'] = foms
800
+ return True
801
+
802
+ def calc_noise(self) -> tuple[float, float, int]:
803
+ """
804
+ Calculate the interference and noise for COM.
805
+
806
+ KeywordArgs:
807
+ npts: Number of vector points.
808
+ Default: 2001
809
+
810
+ Returns:
811
+ (As, Ani, cursor_ix): Triple containing:
812
+ - signal amplitude,
813
+ - noise + interference amplitude (V), and
814
+ - cursor location within victim pulse response vector.
815
+
816
+ Raises:
817
+ None
818
+
819
+ Warns:
820
+ 1. If `2*As/npts` rises above 10 uV, against standard's recommendation.
821
+
822
+ Notes:
823
+ 1. Assumes the following instance variables have been set:
824
+ - gDC
825
+ - gDC2
826
+ - tx_taps
827
+ """
828
+
829
+ L = self.params['L']
830
+ M = self.params['M']
831
+ freqs = self.freqs
832
+ nDFE = self.nDFE
833
+
834
+ pulse_resps = self.gen_pulse_resps(np.array(self.tx_taps))
835
+ vic_pulse_resp = pulse_resps[0]
836
+ cursor_ix = self.loc_curs(vic_pulse_resp)
837
+ vic_curs_val = vic_pulse_resp[cursor_ix]
838
+ As = self.params['RLM'] * vic_curs_val / (L - 1)
839
+ npts = 2 * max(int(As / 0.001), 1_000) + 1 # Note 1 of 93A.1.7.1; MUST BE ODD!
840
+ y = np.linspace(-As, As, npts)
841
+ ystep = 2 * As / (npts - 1)
842
+
843
+ delta = np.zeros(npts)
844
+ delta[npts // 2] = 1
845
+ varX = (L**2 - 1) / (3 * (L - 1)**2) # (93A-29)
846
+ df = freqs[1]
847
+
848
+ def pn(hn: float) -> Rvec:
849
+ """
850
+ (93A-39)
851
+ """
852
+ return 1 / L * sum([np.roll(delta, int((2 * l / (L - 1) - 1) * hn / ystep))
853
+ for l in range(L)])
854
+
855
+ def p(h_samps: Rvec) -> Rvec:
856
+ """
857
+ Calculate the "delta-pmf" for a set of pulse response samples,
858
+ as per (93A-40).
859
+
860
+ Args:
861
+ h_samps: Vector of pulse response samples.
862
+ As: Signal amplitude, as per 93A.1.6.c.
863
+
864
+ Returns:
865
+ Vector of "deltas" giving amplitude probability distribution.
866
+
867
+ Raises:
868
+ None
869
+
870
+ Notes:
871
+ 1. The input set of pulse response samples is filtered,
872
+ as per Note 2 of 93A.1.7.1.
873
+ """
874
+
875
+ pns = []
876
+ rslts = []
877
+ rslt = delta
878
+ rslts.append(rslt)
879
+ for hn in h_samps:
880
+ _pn = pn(hn)
881
+ pns.append(_pn)
882
+ rslt = np.convolve(rslt, _pn, mode='same')
883
+ rslts.append(rslt)
884
+ rslt /= sum(rslt) # Enforce a PMF. Commenting out didn't make a difference.
885
+ self.dbg['pns'] = pns
886
+ self.dbg['rslts'] = rslts
887
+ return rslt
888
+
889
+ # Sec. 93A.1.7.2
890
+ varN = self.params['eta0'] * sum(abs(self.Hr * self.Hctf)**2) * (df / 1e9) # (93A-35)
891
+ varTx = vic_curs_val**2 * pow(10, -self.params['TxSNR'] / 10) # (93A-30)
892
+ hJ = self.calc_hJ(vic_pulse_resp, As, cursor_ix)
893
+ pJ = p(self.params['Add'] * hJ)
894
+ self.dbg['pJ'] = pJ
895
+ self.dbg['hJ'] = hJ
896
+ varG = varTx + self.params['sigma_Rj']**2 * varX * sum(hJ**2) + varN # (93A-41)
897
+ pG = np.exp(-y**2 / (2 * varG)) / np.sqrt(TWOPI * varG) # (93A-42)
898
+ pN = np.convolve(pG, pJ, mode='same') # (93A-43)
899
+
900
+ # Sec. 93A.1.7.3
901
+ # - ISI (Inconsistent w/ IEEE 802.3-22, but consistent w/ v2.60 of MATLAB code.)
902
+ n_pre = cursor_ix // M
903
+ first_pre_ix = cursor_ix - n_pre * M
904
+ vic_pulse_resp_isi_samps = np.concatenate((vic_pulse_resp[first_pre_ix:cursor_ix:M],
905
+ vic_pulse_resp[cursor_ix + M::M]))
906
+ vic_pulse_resp_post_samps = vic_pulse_resp_isi_samps[n_pre:]
907
+ dfe_tap_weights = np.maximum( # (93A-26)
908
+ self.params['dfe_min'],
909
+ np.minimum(
910
+ self.params['dfe_max'],
911
+ (vic_pulse_resp_post_samps[:nDFE] / vic_curs_val)))
912
+ hISI = vic_pulse_resp_isi_samps \
913
+ - vic_curs_val * np.pad(dfe_tap_weights,
914
+ (n_pre, len(vic_pulse_resp_post_samps) - nDFE),
915
+ mode='constant',
916
+ constant_values=0) # (93A-27)
917
+ hISI = self.filt_pr_samps(hISI, As)
918
+ py = p(hISI) # `hISI` from (93A-27); `p(y)` as per (93A-40)
919
+
920
+ # - Crosstalk
921
+ self.rslts['py0'] = py.copy() # For debugging.
922
+ xt_samps = []
923
+ pks = [] # For debugging.
924
+ for pulse_resp in pulse_resps[1:]: # (93A-44)
925
+ i = np.argmax([sum(np.array(self.filt_pr_samps(pulse_resp[m::M], As))**2) for m in range(M)]) # (93A-33)
926
+ samps = self.filt_pr_samps(pulse_resp[i::M], As)
927
+ xt_samps.append(samps)
928
+ pk = p(samps) # For debugging.
929
+ pks.append(pk)
930
+ py = np.convolve(py, pk, mode='same')
931
+ self.rslts['py1'] = py.copy() # For debugging.
932
+ self.xt_samps = xt_samps
933
+ self.pks = pks
934
+ py = np.convolve(py, pN, mode='same') # (93A-45)
935
+
936
+ # Final calculation
937
+ Py = np.cumsum(py)
938
+ Py /= Py[-1] # Enforce cumulative probability distribution.
939
+
940
+ # Store some results.
941
+ self.pulse_resps = pulse_resps
942
+ self.cursor_ix = cursor_ix
943
+ self.rslts['sigma_Tx'] = np.sqrt(varTx) * 1_000 # (mV)
944
+ self.rslts['sigma_G'] = np.sqrt(varG) * 1_000 # (mV)
945
+ self.rslts['sigma_N'] = np.sqrt(varN) * 1_000 # (mV)
946
+ self.rslts['pG'] = pG
947
+ self.rslts['pN'] = pN
948
+ self.rslts['py'] = py
949
+ self.rslts['Py'] = Py
950
+ self.rslts['y'] = y
951
+ self.rslts['dfe_taps'] = dfe_tap_weights
952
+
953
+ return (As,
954
+ abs(np.where(Py >= self.params['DER'])[0][0] - npts // 2) * ystep,
955
+ cursor_ix)
pychopmarg/common.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ Definitions common to all PyChOpMarg modules.
3
+
4
+ Original author: David Banas <capn.freako@gmail.com>
5
+
6
+ Original date: March 3, 2024 (Copied from `pybert.utility`.)
7
+
8
+ Copyright (c) 2024 David Banas; all rights reserved World wide.
9
+ """
10
+
11
+ import numpy as np # type: ignore
12
+ import numpy.typing as npt # type: ignore
13
+ from typing import TypeVar, TypeAlias, Any
14
+
15
+ Real = TypeVar('Real', float, float)
16
+ Comp = TypeVar('Comp', complex, complex)
17
+ Rvec: TypeAlias = npt.NDArray[Real]
18
+ Cvec: TypeAlias = npt.NDArray[Comp]
19
+ COMParams: TypeAlias = dict[str, Any] # ToDo: Specify this concretely, perhaps in `standards` module.
20
+ COMFiles: TypeAlias = str | list[str]
21
+
22
+ PI: float = np.pi
23
+ TWOPI: float = 2 * np.pi
pychopmarg/utility.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ General purpose utilities for PyChOpMarg.
3
+
4
+ Original author: David Banas <capn.freako@gmail.com>
5
+
6
+ Original date: March 3, 2024 (Copied from `pybert.utility`.)
7
+
8
+ Copyright (c) 2024 David Banas; all rights reserved World wide.
9
+ """
10
+
11
+ import numpy as np # type: ignore
12
+ import skrf as rf
13
+
14
+
15
+ def sdd_21(ntwk: rf.Network, norm: float = 0.5, renumber: bool = False) -> rf.Network:
16
+ """
17
+ Given a 4-port single-ended network, return its differential throughput
18
+ as a 2-port network.
19
+
20
+ Args:
21
+ ntwk: 4-port single ended network.
22
+
23
+ KeywordArgs:
24
+ norm: Normalization factor. (Default = 0.5)
25
+ renumber: Automatically detect correct through path when True.
26
+ Default: False
27
+
28
+ Returns:
29
+ Sdd (2-port).
30
+
31
+ Notes:
32
+ 1. A "1->2/3->4" port ordering convention is assumed when `renumber` is False.
33
+ 2. Automatic renumbering should not be used unless a solid d.c. thru path exists.
34
+ """
35
+ mm = se2mm(ntwk, norm=norm, renumber=renumber)
36
+ return rf.Network(frequency=ntwk.f, s=mm.s[:, 0:2, 0:2], z0=mm.z0[:, 0:2])
37
+
38
+
39
+ def se2mm(ntwk: rf.Network, norm: float = 0.5, renumber: bool = False) -> rf.Network:
40
+ """
41
+ Given a 4-port single-ended network, return its mixed mode equivalent.
42
+
43
+ Args:
44
+ ntwk: 4-port single ended network.
45
+
46
+ KeywordArgs:
47
+ norm: Normalization factor. (Default = 0.5)
48
+ renumber: Automatically detect correct through path when True.
49
+ Default: False
50
+
51
+ Returns:
52
+ skrf.Network: Mixed mode equivalent network, in the following format:
53
+ Sdd11 Sdd12 Sdc11 Sdc12
54
+ Sdd21 Sdd22 Sdc21 Sdc22
55
+ Scd11 Scd12 Scc11 Scc12
56
+ Scd21 Scd22 Scc21 Scc22
57
+
58
+ Notes:
59
+ 1. A "1->2/3->4" port ordering convention is assumed when `renumber` is False.
60
+ 2. Automatic renumbering should not be used unless a solid d.c. thru path exists.
61
+ """
62
+ # Confirm correct network dimmensions.
63
+ (fs, rs, cs) = ntwk.s.shape
64
+ assert rs == cs, "Non-square Touchstone file S-matrix!"
65
+ assert rs == 4, "Touchstone file must have 4 ports!"
66
+
67
+ # Detect/correct "1 => 3" port numbering if requested.
68
+ if renumber:
69
+ ix = 1
70
+ if abs(ntwk.s21.s[ix, 0, 0]) < abs(ntwk.s31.s[ix, 0, 0]): # 1 ==> 3 port numbering?
71
+ ntwk.renumber((1, 2), (2, 1))
72
+
73
+ # Convert S-parameter data.
74
+ s = np.zeros(ntwk.s.shape, dtype=complex)
75
+ s[:, 0, 0] = norm * (ntwk.s11 - ntwk.s13 - ntwk.s31 + ntwk.s33).s.flatten()
76
+ s[:, 0, 1] = norm * (ntwk.s12 - ntwk.s14 - ntwk.s32 + ntwk.s34).s.flatten()
77
+ s[:, 0, 2] = norm * (ntwk.s11 + ntwk.s13 - ntwk.s31 - ntwk.s33).s.flatten()
78
+ s[:, 0, 3] = norm * (ntwk.s12 + ntwk.s14 - ntwk.s32 - ntwk.s34).s.flatten()
79
+ s[:, 1, 0] = norm * (ntwk.s21 - ntwk.s23 - ntwk.s41 + ntwk.s43).s.flatten()
80
+ s[:, 1, 1] = norm * (ntwk.s22 - ntwk.s24 - ntwk.s42 + ntwk.s44).s.flatten()
81
+ s[:, 1, 2] = norm * (ntwk.s21 + ntwk.s23 - ntwk.s41 - ntwk.s43).s.flatten()
82
+ s[:, 1, 3] = norm * (ntwk.s22 + ntwk.s24 - ntwk.s42 - ntwk.s44).s.flatten()
83
+ s[:, 2, 0] = norm * (ntwk.s11 - ntwk.s13 + ntwk.s31 - ntwk.s33).s.flatten()
84
+ s[:, 2, 1] = norm * (ntwk.s12 - ntwk.s14 + ntwk.s32 - ntwk.s34).s.flatten()
85
+ s[:, 2, 2] = norm * (ntwk.s11 + ntwk.s13 + ntwk.s31 + ntwk.s33).s.flatten()
86
+ s[:, 2, 3] = norm * (ntwk.s12 + ntwk.s14 + ntwk.s32 + ntwk.s34).s.flatten()
87
+ s[:, 3, 0] = norm * (ntwk.s21 - ntwk.s23 + ntwk.s41 - ntwk.s43).s.flatten()
88
+ s[:, 3, 1] = norm * (ntwk.s22 - ntwk.s24 + ntwk.s42 - ntwk.s44).s.flatten()
89
+ s[:, 3, 2] = norm * (ntwk.s21 + ntwk.s23 + ntwk.s41 + ntwk.s43).s.flatten()
90
+ s[:, 3, 3] = norm * (ntwk.s22 + ntwk.s24 + ntwk.s42 + ntwk.s44).s.flatten()
91
+
92
+ # Convert port impedances.
93
+ f = ntwk.f
94
+ z = np.zeros((len(f), 4), dtype=complex)
95
+ z[:, 0] = ntwk.z0[:, 0] + ntwk.z0[:, 2]
96
+ z[:, 1] = ntwk.z0[:, 1] + ntwk.z0[:, 3]
97
+ z[:, 2] = (ntwk.z0[:, 0] + ntwk.z0[:, 2]) / 2
98
+ z[:, 3] = (ntwk.z0[:, 1] + ntwk.z0[:, 3]) / 2
99
+
100
+ return rf.Network(frequency=f, s=s, z0=z)
101
+
102
+
103
+ def import_s32p(filename: str, vic_chnl: int = 1) -> list[tuple[rf.Network, str]]:
104
+ """Read in a 32-port Touchstone file, and return an equivalent list
105
+ of 8 2-port differential networks: a single victim through channel and
106
+ 7 crosstalk aggressors, according to the VITA 68.2 convention.
107
+
108
+ Args:
109
+ filename: Name of Touchstone file to read in.
110
+
111
+ KeywordArgs:
112
+ vic_chnl: Victim channel number (from 1).
113
+ Default = 1
114
+
115
+ Returns:
116
+ [(skrf.Network, str)]: List of 8 pairs, each consisting of:
117
+ - a 2-port network representing a *differential* channel, and
118
+ - the type of that channel, one of: 'THRU', 'NEXT', or 'FEXT.
119
+ (First element is the victim and the only one of type 'THRU'.)
120
+
121
+ Raises:
122
+ ValueError: If Touchstone file is not 32-port.
123
+
124
+ Notes:
125
+ 1. Input Touchstone file is assumed single-ended.
126
+ 2. The differential through and xtalk channels are returned.
127
+ 3. Port 2 of all returned channels correspond to the same
128
+ physical circuit node, typically, the Rx input node.
129
+ """
130
+
131
+ # Import and sanity check the Touchstone file.
132
+ ntwk = rf.Network(filename)
133
+ (fs, rs, cs) = ntwk.s.shape
134
+ assert rs == cs, "Non-square Touchstone file S-matrix!"
135
+ assert rs == 32, f"Touchstone file must have 32 ports!\n\t{ntwk}"
136
+
137
+ # Extract the victim and aggressors.
138
+ def ports_from_chnls(left, right):
139
+ """
140
+ Return list of 4 ports (from 0) corresponding to a particular
141
+ left and right channel ID (from 1), assuming "1=>2/3=>4" convention.
142
+
143
+ Args:
144
+ left(int): Left side channel number (from 1).
145
+ right(int): Right side channel number (from 1).
146
+
147
+ Returns:
148
+ [int]: List of ports (from 0) for desired channel.
149
+ """
150
+ left0 = left - 1 # 0-based
151
+ right0 = right - 1
152
+ return [left0 * 4, right0 * 4 + 1, left0 * 4 + 2, right0 * 4 + 3]
153
+
154
+ vic_ports = ports_from_chnls(vic_chnl, vic_chnl)
155
+ vic = sdd_21(rf.subnetwork(ntwk, vic_ports))
156
+ vic = (vic, 'THRU')
157
+ if vic_chnl % 2: # odd?
158
+ vic_rx_ports = [vic_ports[n] for n in [0, 2]]
159
+ else:
160
+ vic_rx_ports = [vic_ports[n] for n in [1, 3]]
161
+ agg_chnls = list(np.array(range(8)) + 1)
162
+ agg_chnls.remove(vic_chnl)
163
+ aggs = []
164
+ for agg_chnl in agg_chnls:
165
+ agg_ports = ports_from_chnls(agg_chnl, agg_chnl)
166
+ if agg_chnl % 2: # odd?
167
+ agg_tx_ports = [agg_ports[n] for n in [1, 3]]
168
+ else:
169
+ agg_tx_ports = [agg_ports[n] for n in [0, 2]]
170
+ sub_ports = np.concatenate(list(zip(agg_tx_ports, vic_rx_ports)))
171
+ subntwk = sdd_21(ntwk.subnetwork(sub_ports))
172
+ if (vic_chnl + agg_chnl) % 2:
173
+ subntwk = (subntwk, 'NEXT')
174
+ else:
175
+ subntwk = (subntwk, 'FEXT')
176
+ aggs.append(subntwk)
177
+ return [vic] + aggs