PyChOpMarg 3.3.0__tar.gz → 3.4.0__tar.gz

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.
Files changed (48) hide show
  1. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/PKG-INFO +1 -1
  2. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/pyproject.toml +1 -1
  3. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/PyChOpMarg.egg-info/PKG-INFO +1 -1
  4. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/cli.py +7 -2
  5. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/com.py +280 -79
  6. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/common.py +4 -0
  7. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/noise.py +65 -27
  8. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/optimize.py +67 -25
  9. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/utility/filter.py +27 -3
  10. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/utility/jupyter.py +12 -2
  11. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/utility/probability.py +28 -24
  12. pychopmarg-3.4.0/tests/test_cli.py +64 -0
  13. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_com.py +168 -1
  14. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_probability.py +21 -0
  15. pychopmarg-3.3.0/tests/test_cli.py +0 -24
  16. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/LICENSE +0 -0
  17. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/README.md +0 -0
  18. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/setup.cfg +0 -0
  19. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/PyChOpMarg.egg-info/SOURCES.txt +0 -0
  20. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/PyChOpMarg.egg-info/dependency_links.txt +0 -0
  21. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/PyChOpMarg.egg-info/entry_points.txt +0 -0
  22. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/PyChOpMarg.egg-info/requires.txt +0 -0
  23. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/PyChOpMarg.egg-info/top_level.txt +0 -0
  24. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/__init__.py +0 -0
  25. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/__main__.py +0 -0
  26. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/config/__init__.py +0 -0
  27. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/config/ieee_8023by.py +0 -0
  28. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/config/ieee_8023dj.py +0 -0
  29. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/config/ieee_8023dj_d3p3.py +0 -0
  30. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/config/template.py +0 -0
  31. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/devmodel.py +0 -0
  32. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/excel.py +0 -0
  33. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/matlab.py +0 -0
  34. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/mfiles/pychopmarg_com.m +0 -0
  35. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/mlsd.py +0 -0
  36. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/plot.py +0 -0
  37. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/py.typed +0 -0
  38. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/utility/__init__.py +0 -0
  39. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/utility/general.py +0 -0
  40. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/src/pychopmarg/utility/sparams.py +0 -0
  41. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_devmodel.py +0 -0
  42. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_excel.py +0 -0
  43. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_filter.py +0 -0
  44. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_general.py +0 -0
  45. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_imports.py +0 -0
  46. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_matlab.py +0 -0
  47. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_mlsd.py +0 -0
  48. {pychopmarg-3.3.0 → pychopmarg-3.4.0}/tests/test_template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyChOpMarg
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Python implementation of COM, as per IEEE 802.3-22 Annex 93A/178A.
5
5
  Author-email: David Banas <capn.freako@gmail.com>
6
6
  License: BSD3
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
  [project]
10
10
  name = "PyChOpMarg"
11
11
  description = "Python implementation of COM, as per IEEE 802.3-22 Annex 93A/178A."
12
- version = "3.3.0"
12
+ version = "3.4.0"
13
13
  authors = [{name = "David Banas", email = "capn.freako@gmail.com"}]
14
14
  urls = { documentation = "https://pychopmarg.readthedocs.io/en/latest/"}
15
15
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyChOpMarg
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Python implementation of COM, as per IEEE 802.3-22 Annex 93A/178A.
5
5
  Author-email: David Banas <capn.freako@gmail.com>
6
6
  License: BSD3
@@ -59,11 +59,16 @@ def cli(ctx):
59
59
  # @cli.command(context_settings=dict(help_option_names=["-h", "--help"]))
60
60
  @cli.command(context_settings={"help_option_names": ["-h", "--help"]})
61
61
  @click.argument("notebook-file", type=click.Path(exists=True))
62
- def run_notebook(notebook_file: Path):
62
+ @click.option("--procs", "-p", type=click.IntRange(min=0), default=None,
63
+ help="Processes per EQ optimization (0: one per CPU). Default: $PYCHOPMARG_PROCS, else 1.")
64
+ def run_notebook(notebook_file: Path, procs: Optional[int] = None):
63
65
  """
64
66
  Run a Jupyter notebook in place w/o opening a browser tab.
67
+
68
+ With --procs, each COM calculation's EQ optimization runs in that many processes,
69
+ when it's a full search (no "Local Search"), giving the same results, faster.
65
70
  """
66
- execute_notebook(notebook_file)
71
+ execute_notebook(notebook_file, n_procs=procs)
67
72
 
68
73
 
69
74
  # @cli.command(context_settings=dict(help_option_names=["-h", "--help"]))
@@ -21,6 +21,8 @@ ToDo:
21
21
 
22
22
  from dataclasses import replace
23
23
  from importlib.metadata import version
24
+ import multiprocessing
25
+ import os
24
26
  from pathlib import Path
25
27
  from typing import Any, Optional, cast
26
28
  import warnings
@@ -31,17 +33,53 @@ from numpy import array, arange
31
33
  from scipy.signal import fftconvolve # type: ignore
32
34
 
33
35
  from pychopmarg.common import (
34
- PI, TWOPI, Cvec, Rvec, Cmat, OptMode,
36
+ PI, PROCS_ENV_VAR, TWOPI, Cvec, Rvec, Cmat, OptMode,
35
37
  COMChnl, COMNtwk, ChnlSet, ChnlGrpName, ChnlSetName)
36
38
  from pychopmarg.config.template import COMParams
37
39
  from pychopmarg.devmodel import (
38
40
  DeviceModel, PartialHost, TlineStage, gamma_term, renormalize_to, s_partial_host, s_rx_model, s_tx_model)
39
41
  from pychopmarg.mlsd import der_mlsd, inv_cdf, solve_g_an
40
42
  from pychopmarg.noise import NoiseCalc
41
- from pychopmarg.optimize import NormMode, mmse, przf
43
+ from pychopmarg.optimize import NormMode, mmse, mmse_noise_vars, przf
42
44
  from pychopmarg.utility import (
43
45
  import_s32p, sdd_21, sDieLadderSegment, sPkgTline, sCshunt, filt_pr_samps,
44
- delta_pmf, mk_combs, calc_Hffe, calc_Hctle, calc_H21, calc_hJ, loc_curs, calc_vtf, interp_vtf)
46
+ delta_pmf, mk_combs, calc_Hffe, ffe_phase_matrix, calc_Hctle, calc_H21, calc_hJ, loc_curs, calc_vtf, interp_vtf)
47
+
48
+
49
+ def default_n_procs() -> int:
50
+ """
51
+ The default number of EQ optimization processes (``COM.n_procs``): the value of the ``PYCHOPMARG_PROCS``
52
+ environment variable (0 for one per CPU), if set to a non-negative integer; otherwise, 1 (a serial search).
53
+ """
54
+ val = os.environ.get(PROCS_ENV_VAR, "").strip()
55
+ if not val:
56
+ return 1
57
+ try:
58
+ n_procs = int(val)
59
+ if n_procs < 0:
60
+ raise ValueError
61
+ except ValueError:
62
+ warnings.warn(f"Ignoring {PROCS_ENV_VAR}={val!r}: not a non-negative integer.", stacklevel=2)
63
+ return 1
64
+ return n_procs
65
+
66
+
67
+ _WORKER_COM: Optional["COM"] = None # This worker process's copy of the `COM` (see `COM.opt_eq()`'s `n_procs`).
68
+
69
+
70
+ def _init_worker(com: "COM") -> None:
71
+ "Keep this worker process's copy of the ``COM``, for ``_worker_foms()``."
72
+ global _WORKER_COM # pylint: disable=global-statement
73
+ _WORKER_COM = com
74
+
75
+
76
+ def _worker_foms(task: tuple[float, float, list[int], dict[str, Any]]) -> list[float]:
77
+ "The FOM of each of the given Tx FFE candidates (``calc_fom()``), for one CTLE setting, in a worker process."
78
+ gDC, gDC2, tx_ixs, kwargs = task
79
+ com = _WORKER_COM
80
+ assert com is not None, RuntimeError("Worker process not initialized!")
81
+ Hctf = com.calc_Hctf(gDC, gDC2)
82
+ return [com.calc_fom(tx_ix, Hctf, noise_vars=False, **kwargs) for tx_ix in tx_ixs]
45
83
 
46
84
 
47
85
  def _conv_pmfs(p1: Rvec, p2: Rvec) -> Rvec:
@@ -131,8 +169,12 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
131
169
  self.nRxPreTaps: int = 0 #: `dw` from `com_params`
132
170
  self.gDC: float = 0.0 #: Choices are in `com_params.g_DC`.
133
171
  self.gDC2: float = 0.0 #: Choices are in `com_params.g_DC2`.
134
- self.rx_ffe_phase_matrix: Cmat = array([])
135
- self._Htx_cache: dict[int, Cvec] = {} # Tx FFE frequency responses, by `tx_taps_ix` (see `Htx()`).
172
+ self._rx_ffe_phase_matrix: Optional[Cmat] = None # (See `rx_ffe_phase_matrix`.)
173
+ #: Number of processes for EQ optimization; 0 for one per CPU. (See `opt_eq()` and `default_n_procs()`.)
174
+ self.n_procs: int = default_n_procs()
175
+ self._Htx_last: Optional[tuple[int, Cvec]] = None # The last Tx FFE frequency response (see `Htx()`).
176
+ self._tx_phase_matrix: Optional[Cmat] = None # Tx FFE phase matrix, once calculated (see `Htx()`).
177
+ self._next_pr_cache: tuple[Optional[Cvec], dict[int, tuple[Cvec, Rvec]]] = (None, {}) # (`gen_pulse_resps()`)
136
178
 
137
179
  # Channel data
138
180
  self.chnls: list[COMChnl] = []
@@ -210,10 +252,7 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
210
252
  sPkgTx, sPkgNext, sPkgFext, sPkgRx = self._pkg_models(self.freqs)
211
253
  self._sPkgTx, self._sPkgNext, self._sPkgFext, self._sPkgRx = [sPkgTx], [sPkgNext], [sPkgFext], [sPkgRx]
212
254
  self.chnls = list(map(self.add_pkg, ntwks))
213
- if debug:
214
- self.chnls_noPkg = [(ntwks[0], calc_H21(f, ntwks[0][0], self.gamma1_Tx, self.gamma2_Rx))]
215
- self.pulse_resps_nopkg = self.gen_pulse_resps(chnls=[self.chnls_noPkg[0]], apply_eq=False)
216
- self.pulse_resps_noeq = self.gen_pulse_resps(chnls=[self.chnls[0]], apply_eq=False)
255
+ self._calc_uneq_pulse_resps()
217
256
 
218
257
  # Generate all possible combinations of Tx FFE tap weights, in grid order (first tap varying slowest),
219
258
  # along with each one's per-tap grid indices (used by the local search in ``opt_eq()``).
@@ -233,8 +272,8 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
233
272
  # Set Rx FFE quantities.
234
273
  self.nRxTaps = len(com_params.rx_taps_max)
235
274
  self.nRxPreTaps = com_params.dw
236
- self.rx_ffe_phase_matrix = np.exp(np.outer(np.arange(self.nRxTaps), -1j * TWOPI * ui * f))
237
275
  self.null_rx_ffe: Rvec = array([0] * self.nRxPreTaps + [1.0] + [0] * (self.nRxTaps - self.nRxPreTaps - 1))
276
+ self._Hrx_null: Optional[Cvec] = None # `Hffe_Rx(self.null_rx_ffe)`, once calculated.
238
277
  self.empty_array: Rvec = array([])
239
278
 
240
279
  # Misc.
@@ -308,6 +347,7 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
308
347
 
309
348
  #: Instance state that depends on the package test case (restored to the worst case's, by ``__call__()``).
310
349
  _case_state = ("com_params", "chnls", "_sPkgTx", "_sPkgNext", "_sPkgFext", "_sPkgRx", "_pkg_cache",
350
+ "chnls_noPkg", "pulse_resps_nopkg", "pulse_resps_noeq",
311
351
  "tx_ix", "gDC", "gDC2", "rx_taps", "dfe_taps", "cursor_ix", "com_rslts", "fom_rslts",
312
352
  "theNoiseCalc", "mmse_rslt", "n_fom_evals")
313
353
 
@@ -381,6 +421,18 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
381
421
  sPkgTx, sPkgNext, sPkgFext, sPkgRx = self._pkg_models(self.freqs)
382
422
  self._sPkgTx, self._sPkgNext, self._sPkgFext, self._sPkgRx = [sPkgTx], [sPkgNext], [sPkgFext], [sPkgRx]
383
423
  self.chnls = list(map(self.add_pkg, self._ntwks))
424
+ self._calc_uneq_pulse_resps()
425
+
426
+ def _calc_uneq_pulse_resps(self) -> None:
427
+ """
428
+ The victim's unequalized pulse responses, w/ the Tx and Rx filters, for the current package test case:
429
+ ``pulse_resps_noeq`` (w/ its packages; MATLAB COM's ``uneq_pulse_response``, whose peak it reports as
430
+ ``peak_uneq_pulse_mV``), and ``pulse_resps_nopkg`` (w/o them, from ``chnls_noPkg``).
431
+ """
432
+ ntwk = self._ntwks[0]
433
+ self.chnls_noPkg = [(ntwk, calc_H21(self.freqs, ntwk[0], self.gamma1_Tx, self.gamma2_Rx))]
434
+ self.pulse_resps_nopkg = self.gen_pulse_resps(chnls=self.chnls_noPkg, apply_eq=False)
435
+ self.pulse_resps_noeq = self.gen_pulse_resps(chnls=[self.chnls[0]], apply_eq=False)
384
436
 
385
437
  # Instance Properties
386
438
  def _pkg_models(self, freqs: Rvec) -> tuple[rf.Network, rf.Network, rf.Network, rf.Network]:
@@ -486,6 +538,18 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
486
538
  "System frequency vector (Hz); decoupled from system time vector!"
487
539
  return self.f
488
540
 
541
+ @property
542
+ def rx_ffe_phase_matrix(self) -> Cmat:
543
+ "Rx FFE phase matrix (see ``utility.filter.ffe_phase_matrix()``), calculated when first needed."
544
+ if self._rx_ffe_phase_matrix is None: # (~230 MB, for P802.3dj's 87 taps; so, not kept in pickles.)
545
+ self._rx_ffe_phase_matrix = ffe_phase_matrix(self.freqs, self.ui, self.nRxTaps)
546
+ return self._rx_ffe_phase_matrix
547
+
548
+ def __getstate__(self) -> dict[str, Any]:
549
+ state = self.__dict__.copy()
550
+ state["_rx_ffe_phase_matrix"] = None # (Recalculated when needed; see `rx_ffe_phase_matrix`.)
551
+ return state
552
+
489
553
  @property
490
554
  def Xsinc(self) -> Rvec:
491
555
  """Frequency domain sinc(f) corresponding to Rect(ui) in time domain."""
@@ -739,10 +803,15 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
739
803
  """
740
804
  assert 0 <= tx_taps_ix < self.num_tx_combs, ValueError(
741
805
  f"tx_taps_ix ({tx_taps_ix}) must be an integer in: [0, {self.num_tx_combs})!")
742
- # Cached per instance: `functools.cache` on a method would keep every `COM` alive, via its `self` keys.
743
- if tx_taps_ix not in self._Htx_cache:
744
- self._Htx_cache[tx_taps_ix] = calc_Hffe(self.freqs, 1 / self.fb, array(self._tx_combs[tx_taps_ix]), 3)
745
- return self._Htx_cache[tx_taps_ix]
806
+ # Only the last is kept (each EQ candidate's channels share it): all of them would take ~3.5 GB
807
+ # for P802.3dj's 1,279 Tx FFE combinations; and each is cheap, from the shared phase matrix.
808
+ if self._Htx_last is None or self._Htx_last[0] != tx_taps_ix:
809
+ taps = array(self._tx_combs[tx_taps_ix])
810
+ if self._tx_phase_matrix is None: # (Same for every combination; cursor tap included.)
811
+ self._tx_phase_matrix = ffe_phase_matrix(self.freqs, 1 / self.fb, len(taps) + 1)
812
+ self._Htx_last = (tx_taps_ix, calc_Hffe(
813
+ self.freqs, 1 / self.fb, taps, 3, phase_matrix=self._tx_phase_matrix))
814
+ return self._Htx_last[1]
746
815
 
747
816
  def Hffe_Rx(self, taps: Optional[Rvec] = None) -> Cvec:
748
817
  """
@@ -764,6 +833,15 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
764
833
  assert len(taps) == self.nRxTaps, ValueError(
765
834
  f"If `taps` is given then its length ({len(taps)}) must equal `nRxTaps` ({self.nRxTaps})!")
766
835
 
836
+ # The null Rx FFE (a `dw` UI delay) is applied to every pulse response, for every EQ candidate;
837
+ # so, it's calculated once (read-only, for safety).
838
+ if taps is self.null_rx_ffe:
839
+ if self._Hrx_null is None:
840
+ # Its one tap's row, which is identical to `taps @ self.rx_ffe_phase_matrix`, w/o forming
841
+ # that whole matrix (~230 MB, for P802.3dj's 87 taps), which EQ optimization doesn't otherwise need.
842
+ self._Hrx_null = ffe_phase_matrix(self.freqs, self.ui, self.nRxTaps, tap_ixs=[self.nRxPreTaps])[0]
843
+ self._Hrx_null.flags.writeable = False
844
+ return self._Hrx_null
767
845
  return taps @ self.rx_ffe_phase_matrix
768
846
 
769
847
  def H( # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments
@@ -826,7 +904,7 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
826
904
  if nRxTaps:
827
905
  Hrx = self.Hffe_Rx(rx_taps)
828
906
  if passive_RxFFE:
829
- Hrx /= max(np.abs(Hrx))
907
+ Hrx = Hrx / max(np.abs(Hrx)) # (Not in place: `Hffe_Rx()` may return a cached, read-only array.)
830
908
  rslt *= Hrx
831
909
 
832
910
  return rslt
@@ -906,12 +984,22 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
906
984
  if Hctf is None:
907
985
  Hctf = self.calc_Hctf(self.gDC, self.gDC2)
908
986
 
987
+ # A NEXT aggressor's pre-Rx-FFE pulse response is the same for every Tx EQ; so, it's cached for the
988
+ # last `Hctf` (by identity: `opt_eq()` passes the same array for each Tx EQ candidate), read-only.
989
+ cache_next = apply_eq and rx_taps is self.null_rx_ffe
990
+ if cache_next and self._next_pr_cache[0] is not Hctf:
991
+ self._next_pr_cache = (Hctf, {})
992
+ next_prs = self._next_pr_cache[1]
993
+
909
994
  pulse_resps = []
910
995
  for (_, ntype), H21 in chnls:
996
+ if cache_next and ntype == 'NEXT' and id(H21) in next_prs and next_prs[id(H21)][0] is H21:
997
+ pulse_resps.append(next_prs[id(H21)][1])
998
+ continue
911
999
  if apply_eq:
912
- if ntype == 'NEXT':
913
- tx_ix = 0
914
- pr = self.pulse_resp(self.H(H21, tx_ix, Hctf=Hctf, rx_taps=rx_taps, dfe_taps=dfe_taps))
1000
+ # NEXT aggressors have no Tx EQ. (Only they: `import_s32p()` interleaves NEXT and FEXT aggressors.)
1001
+ ix = 0 if ntype == 'NEXT' else tx_ix
1002
+ pr = self.pulse_resp(self.H(H21, ix, Hctf=Hctf, rx_taps=rx_taps, dfe_taps=dfe_taps))
915
1003
  else: # Unequalized, but w/ the Tx and Rx filters (as MATLAB COM's `uneq_pulse_response`).
916
1004
  pr = self.pulse_resp(self.Ht * H21 * self.Hr)
917
1005
 
@@ -919,6 +1007,9 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
919
1007
  pr *= self.com_params.A_v
920
1008
  elif ntype == 'NEXT':
921
1009
  pr *= self.com_params.A_ne
1010
+ if cache_next:
1011
+ pr.flags.writeable = False
1012
+ next_prs[id(H21)] = (H21, pr)
922
1013
  else:
923
1014
  pr *= self.com_params.A_fe
924
1015
 
@@ -937,7 +1028,8 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
937
1028
  unit_amp: Optional[bool] = None,
938
1029
  n_pre: Optional[int] = None,
939
1030
  ts_window: int = 0,
940
- ts_center: int = 0
1031
+ ts_center: int = 0,
1032
+ noise_vars: bool = True
941
1033
  ) -> float:
942
1034
  """
943
1035
  Calculate the **figure of merit** (FOM), given the existing linear EQ settings.
@@ -963,6 +1055,9 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
963
1055
  Default: 0
964
1056
  ts_center: MMSE cursor timing search starting point, relative to the pulse peak (vector samples).
965
1057
  Default: 0
1058
+ noise_vars: Calculate the MMSE solution's noise variance breakdown ("varTx", "varJ", "varXT", "varN")
1059
+ when True; else, leave them ``None``. (See ``optimize.mmse_noise_vars()``.)
1060
+ Default: True
966
1061
 
967
1062
  Returns:
968
1063
  The resultant figure of merit.
@@ -1000,6 +1095,10 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1000
1095
  pulse_resps_preFFE = self.gen_pulse_resps( # Assumes no Rx FFE/DFE.
1001
1096
  tx_ix=tx_ix, Hctf=Hctf,
1002
1097
  rx_taps=self.null_rx_ffe, dfe_taps=self.empty_array)
1098
+ varTx: Optional[float] # (`None` from `mmse()`, w/ `noise_vars` False; likewise below.)
1099
+ varJ: Optional[float]
1100
+ varXT: Optional[float]
1101
+ varN: Optional[float]
1003
1102
  match opt_mode:
1004
1103
  case OptMode.PRZF:
1005
1104
  # Step a - Pulse response construction.
@@ -1078,7 +1177,7 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1078
1177
  norm_mode=norm_mode, ts_window=ts_window, ts_center=ts_center,
1079
1178
  ts_range=self.com_params.ts_sample_adj,
1080
1179
  n_fix=self.com_params.Nfix, n_float_groups=self.com_params.Nwg,
1081
- n_float_taps=self.com_params.Nwf)
1180
+ n_float_taps=self.com_params.Nwf, noise_vars=noise_vars)
1082
1181
  fom = rslt["fom"]
1083
1182
  rx_taps = rslt["rx_taps"]
1084
1183
  dfe_tap_weights = rslt["dfe_tap_weights"]
@@ -1087,11 +1186,11 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1087
1186
  vic_peak_loc = np.argmax(vic_pulse_resp)
1088
1187
  cursor_ix = rslt["cursor_ix"]
1089
1188
  As = vic_pulse_resp[cursor_ix]
1090
- varTx = rslt["varTx"]
1189
+ varTx = rslt.get("varTx")
1091
1190
  varISI = rslt["varISI"]
1092
- varJ = rslt["varJ"]
1093
- varXT = rslt["varXT"]
1094
- varN = rslt["varN"]
1191
+ varJ = rslt.get("varJ")
1192
+ varXT = rslt.get("varXT")
1193
+ varN = rslt.get("varN")
1095
1194
  self.fom_rslts['mse'] = rslt.get('mse')
1096
1195
  self.theNoiseCalc = theNoiseCalc # pylint: disable=attribute-defined-outside-init
1097
1196
  self.mmse_rslt = rslt # pylint: disable=attribute-defined-outside-init
@@ -1115,11 +1214,33 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1115
1214
 
1116
1215
  return fom
1117
1216
 
1118
- def opt_eq( # pylint: disable=too-many-locals,too-many-branches
1217
+ def _parallel_foms(
1218
+ self, cands: list[tuple[int, float, float, int]], n_procs: int, fom_kwargs: dict[str, Any]
1219
+ ) -> list[float]:
1220
+ """
1221
+ The FOM of each EQ candidate, ``(i_gDC2, gDC, gDC2, tx_ix)``, calculated in ``n_procs`` worker processes.
1222
+
1223
+ Notes:
1224
+ 1. The candidates are dealt out in runs of those sharing a CTLE setting, about four per worker;
1225
+ so, each run's CTLE response is calculated once, and its NEXT pulse responses are reused.
1226
+ """
1227
+ chunk = max(1, -(-len(cands) // (4 * n_procs)))
1228
+ tasks: list[tuple[float, float, list[int], dict[str, Any]]] = []
1229
+ for _, gDC, gDC2, tx_ix in cands:
1230
+ if tasks and tasks[-1][:2] == (gDC, gDC2) and len(tasks[-1][2]) < chunk:
1231
+ tasks[-1][2].append(tx_ix)
1232
+ else:
1233
+ tasks.append((gDC, gDC2, [tx_ix], fom_kwargs))
1234
+ n_procs = min(n_procs, len(tasks))
1235
+ with multiprocessing.get_context("spawn").Pool(n_procs, initializer=_init_worker, initargs=(self,)) as pool:
1236
+ return [fom for foms in pool.map(_worker_foms, tasks, chunksize=1) for fom in foms]
1237
+
1238
+ def opt_eq( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
1119
1239
  self,
1120
1240
  opt_mode: Optional[OptMode] = None,
1121
1241
  norm_mode: Optional[NormMode] = None,
1122
- unit_amp: Optional[bool] = None
1242
+ unit_amp: Optional[bool] = None,
1243
+ n_procs: Optional[int] = None
1123
1244
  ) -> bool:
1124
1245
  """
1125
1246
  Find and set the optimum values for the linear equalization parameters:
@@ -1134,6 +1255,8 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1134
1255
  unit_amp: Enforce unit pulse response amplitude when True.
1135
1256
  (For comparing ``przf()`` results to ``mmse()`` results.)
1136
1257
  Default: None (i.e. - Use ``self.unit_amp``.)
1258
+ n_procs: Number of processes to evaluate EQ candidates in; 0 for one per CPU. (See Note 5.)
1259
+ Default: None (i.e. - Use ``self.n_procs``.)
1137
1260
 
1138
1261
  Returns:
1139
1262
  True if no errors encountered; False otherwise.
@@ -1154,6 +1277,13 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1154
1277
  4. With ``com_params.ts_srch_mode`` == "middle", each candidate's cursor timing search
1155
1278
  works outward from the pulse peak, as MATLAB COM's (see ``optimize.mmse()``);
1156
1279
  with ``com_params.ts_track`` also, from the best cursor offset so far, after a full search for the first.
1280
+
1281
+ 5. With ``n_procs`` > 1, the candidates' FOMs are calculated in that many worker processes,
1282
+ each w/ its own copy of this instance; then, the winner's results are recalculated here.
1283
+ The outcome is identical to a serial search's. Since that needs the candidates to be independent,
1284
+ it's done only w/o a local search (``com_params.local_search`` = 0); otherwise, the search is serial.
1285
+ The workers are spawned; so, a script using this must guard its top level code,
1286
+ w/ ``if __name__ == "__main__":``.
1157
1287
  """
1158
1288
 
1159
1289
  # Honor any mode overrides.
@@ -1161,6 +1291,9 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1161
1291
  norm_mode = norm_mode or self.norm_mode
1162
1292
  if unit_amp is None: # Don't try the more Pythonic syntax above; it doesn't work for Boolean values.
1163
1293
  unit_amp = self.unit_amp
1294
+ if n_procs is None:
1295
+ n_procs = self.n_procs
1296
+ n_procs = n_procs or os.cpu_count() or 1
1164
1297
 
1165
1298
  cp = self.com_params
1166
1299
  local_search = cp.local_search
@@ -1186,49 +1319,81 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1186
1319
  return True
1187
1320
  return i_gDC > 0 and abs(i_gDC2 - best['i_gDC2']) > local_search
1188
1321
 
1189
- # Run the nested optimization loops, keeping the best candidate's results.
1322
+ def record(i_gDC2: int, gDC: float, gDC2: float, tx_ix: int) -> dict[str, Any]:
1323
+ "The results the last ``calc_fom()`` left behind, for the given candidate."
1324
+ rslts = self.fom_rslts
1325
+ return {
1326
+ 'gDC2': gDC2, 'gDC': gDC, 'tx_ix': tx_ix,
1327
+ 'i_gDC2': i_gDC2, 'tx_grid_ixs': self._tx_grid_ixs[tx_ix],
1328
+ **{k: rslts[k] for k in ('rx_taps', 'pr_samps', 'dfe_tap_weights', 'cursor_ix', 'As',
1329
+ 'varTx', 'varISI', 'varJ', 'varXT', 'varN', 'vic_pulse_resp')},
1330
+ 'mse': rslts.get('mse', 0),
1331
+ # Everything `calc_fom()` leaves behind for this candidate, overwritten by later ones;
1332
+ # notably its MMSE noise model, which `calc_noise()` takes the Tx noise from.
1333
+ 'fom_rslts': dict(rslts),
1334
+ 'theNoiseCalc': getattr(self, 'theNoiseCalc', None),
1335
+ 'mmse_rslt': getattr(self, 'mmse_rslt', None),
1336
+ }
1337
+
1338
+ fom_kwargs: dict[str, Any] = {"opt_mode": opt_mode, "norm_mode": norm_mode, "unit_amp": unit_amp}
1190
1339
  fom_max = -1000.0
1191
1340
  best: Optional[dict[str, Any]] = None
1192
1341
  self.n_fom_evals = 0 # pylint: disable=attribute-defined-outside-init
1193
- for i_gDC, _gDC in enumerate(cp.g_DC):
1194
- for i_gDC2, _gDC2 in enumerate(cp.g_DC2):
1195
- _Hctle: Optional[Cvec] = None
1196
- for _tx_ix in tx_ixs:
1197
- if skip(i_gDC, i_gDC2, _tx_ix, best):
1198
- continue
1199
- if _Hctle is None:
1200
- _Hctle = self.calc_Hctf(_gDC, _gDC2)
1201
- ts_window = local_search if middle else 0
1202
- ts_center = 0
1203
- if middle and cp.ts_track:
1204
- if best is None:
1205
- ts_window = 0
1206
- else:
1207
- ts_center = int(best['fom_rslts']['cursor_ix'] - best['fom_rslts']['vic_peak_loc'])
1208
- fom = self.calc_fom(
1209
- _tx_ix, _Hctle,
1210
- opt_mode=opt_mode, norm_mode=norm_mode, unit_amp=unit_amp,
1211
- ts_window=ts_window, ts_center=ts_center)
1212
- self.n_fom_evals += 1
1213
- if fom > fom_max:
1214
- fom_max = fom
1215
- rslts = self.fom_rslts
1216
- best = {
1217
- 'gDC2': _gDC2, 'gDC': _gDC, 'tx_ix': _tx_ix,
1218
- 'i_gDC2': i_gDC2, 'tx_grid_ixs': self._tx_grid_ixs[_tx_ix],
1219
- **{k: rslts[k] for k in ('rx_taps', 'pr_samps', 'dfe_tap_weights', 'cursor_ix', 'As',
1220
- 'varTx', 'varISI', 'varJ', 'varXT', 'varN', 'vic_pulse_resp')},
1221
- 'mse': rslts.get('mse', 0),
1222
- # Everything `calc_fom()` leaves behind for this candidate, overwritten by later ones;
1223
- # notably its MMSE noise model, which `calc_noise()` takes the Tx noise from.
1224
- 'fom_rslts': dict(rslts),
1225
- 'theNoiseCalc': getattr(self, 'theNoiseCalc', None),
1226
- 'mmse_rslt': getattr(self, 'mmse_rslt', None),
1227
- }
1342
+ if n_procs > 1 and local_search:
1343
+ warnings.warn(f"EQ optimization is serial: parallel search ({n_procs} processes) "
1344
+ f"needs a full search (`local_search` = 0, not {local_search}).", stacklevel=2)
1345
+ if n_procs > 1 and not local_search:
1346
+ # Every candidate, in the serial search's order, w/ its FOM calculated in the worker processes.
1347
+ # (W/o a local search, the timing search is full, so independent of the best so far.)
1348
+ cands = [(i_gDC2, _gDC, _gDC2, _tx_ix)
1349
+ for _gDC in cp.g_DC for i_gDC2, _gDC2 in enumerate(cp.g_DC2) for _tx_ix in tx_ixs]
1350
+ foms = self._parallel_foms(cands, n_procs, {**fom_kwargs, "ts_window": 0, "ts_center": 0})
1351
+ self.n_fom_evals = len(cands)
1352
+ winner = None
1353
+ for cand, fom in zip(cands, foms):
1354
+ if fom > fom_max: # (The first of any equal ones, as the serial search.)
1355
+ fom_max, winner = fom, cand
1356
+ if winner is not None: # Recalculate the winner's results, here.
1357
+ i_gDC2, _gDC, _gDC2, _tx_ix = winner
1358
+ fom = self.calc_fom(_tx_ix, self.calc_Hctf(_gDC, _gDC2), **fom_kwargs,
1359
+ ts_window=0, ts_center=0, noise_vars=False)
1360
+ assert fom == fom_max, RuntimeError(
1361
+ f"The winning EQ's FOM ({fom} dB) differs from its worker's ({fom_max} dB)!")
1362
+ best = record(i_gDC2, _gDC, _gDC2, _tx_ix)
1363
+ else:
1364
+ # Run the nested optimization loops, keeping the best candidate's results.
1365
+ for i_gDC, _gDC in enumerate(cp.g_DC):
1366
+ for i_gDC2, _gDC2 in enumerate(cp.g_DC2):
1367
+ _Hctle: Optional[Cvec] = None
1368
+ for _tx_ix in tx_ixs:
1369
+ if skip(i_gDC, i_gDC2, _tx_ix, best):
1370
+ continue
1371
+ if _Hctle is None:
1372
+ _Hctle = self.calc_Hctf(_gDC, _gDC2)
1373
+ ts_window = local_search if middle else 0
1374
+ ts_center = 0
1375
+ if middle and cp.ts_track:
1376
+ if best is None:
1377
+ ts_window = 0
1378
+ else:
1379
+ ts_center = int(best['fom_rslts']['cursor_ix'] - best['fom_rslts']['vic_peak_loc'])
1380
+ fom = self.calc_fom(
1381
+ _tx_ix, _Hctle, **fom_kwargs,
1382
+ ts_window=ts_window, ts_center=ts_center, noise_vars=False)
1383
+ self.n_fom_evals += 1
1384
+ if fom > fom_max:
1385
+ fom_max = fom
1386
+ best = record(i_gDC2, _gDC, _gDC2, _tx_ix)
1228
1387
 
1229
1388
  # Check for error and save the best results.
1230
1389
  if best is None:
1231
1390
  return False # Flags the caller that the following settings have NOT been made.
1391
+ # The winner's MMSE noise variance breakdown, skipped above, since it's reported, not optimized.
1392
+ # (Its noise calculator was left at its chosen sampling time.)
1393
+ if best['mmse_rslt'] is not None and best['varTx'] is None:
1394
+ noise_vars = mmse_noise_vars(best['theNoiseCalc'], best['mmse_rslt']['rx_taps_raw'], self.nRxPreTaps)
1395
+ for d in (best, best['fom_rslts'], best['mmse_rslt']):
1396
+ d.update(noise_vars)
1232
1397
  # Note the normalization of the Rx FFE tap weights, to produce a unit amplitude cursor tap.
1233
1398
  # This is not dictated by the spec., but is what the MATLAB code does.
1234
1399
  self.gDC2 = best['gDC2']
@@ -1445,13 +1610,32 @@ class COM: # pylint: disable=too-many-instance-attributes,too-many-public-metho
1445
1610
  """
1446
1611
 
1447
1612
 
1448
- def run_com( # pylint: disable=too-many-arguments,too-many-positional-arguments
1613
+ def _run_one_com( # pylint: disable=too-many-arguments,too-many-positional-arguments
1614
+ com_params: COMParams, ch_set: ChnlSet, opt_mode: OptMode, norm_mode: NormMode, unit_amp: bool,
1615
+ debug: bool, n_procs: int
1616
+ ) -> COM:
1617
+ "COM for one channel set (see ``run_com()``)."
1618
+ theCOM = COM(com_params, ch_set, debug=debug)
1619
+ theCOM.n_procs = n_procs
1620
+ # Calling the object calculates the COM value, as well as many other intermediate results.
1621
+ theCOM(opt_mode=opt_mode, norm_mode=norm_mode, unit_amp=unit_amp)
1622
+ return theCOM
1623
+
1624
+
1625
+ def _run_one_com_task(task: tuple[COMParams, ChnlSet, OptMode, NormMode, bool, bool]) -> COM:
1626
+ "``_run_one_com()``, in a worker process (see ``run_com()``), w/ a serial EQ search."
1627
+ com_params, ch_set, opt_mode, norm_mode, unit_amp, debug = task
1628
+ return _run_one_com(com_params, ch_set, opt_mode, norm_mode, unit_amp, debug, n_procs=1)
1629
+
1630
+
1631
+ def run_com( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
1449
1632
  chnl_sets: list[tuple[ChnlGrpName, ChnlSet]],
1450
1633
  com_params: COMParams,
1451
1634
  opt_mode: OptMode = OptMode.MMSE,
1452
1635
  norm_mode: NormMode = NormMode.P8023dj,
1453
1636
  unit_amp: bool = True,
1454
- dbg_dict: Optional[dict[str, Any]] = None
1637
+ dbg_dict: Optional[dict[str, Any]] = None,
1638
+ n_procs: Optional[int] = None
1455
1639
  ) -> dict[ChnlGrpName, dict[ChnlSetName, COM]]:
1456
1640
  """
1457
1641
  Run COM on a list of grouped channel sets.
@@ -1470,28 +1654,45 @@ def run_com( # pylint: disable=too-many-arguments,too-many-positional-arguments
1470
1654
  dbg_dict: Optional dictionary into which debugging values may be stashed,
1471
1655
  for later analysis.
1472
1656
  Default: None
1657
+ n_procs: Number of processes; 0 for one per CPU. (See Note 1.)
1658
+ Default: None (``default_n_procs()``)
1473
1659
 
1474
1660
  Returns:
1475
1661
  2D dictionary indexed by channel group name, then by channel set name,
1476
1662
  containing the completed COM objects.
1477
- """
1478
1663
 
1479
- theCOMs: dict[ChnlGrpName, dict[ChnlSetName, COM]] = {}
1480
- for grp, ch_set in chnl_sets:
1481
- lbl = ch_set['THRU'][0].stem
1482
- print(f"{grp} : {lbl}")
1483
- if dbg_dict is not None:
1484
- theCOM = COM(com_params, ch_set, debug=True)
1485
- else:
1486
- theCOM = COM(com_params, ch_set)
1664
+ Notes:
1665
+ 1. With ``n_procs`` > 1 and several channel sets, they're run in that many worker processes, one channel set
1666
+ each at a time, each w/ a serial EQ search (``COM.opt_eq()``): that works w/ a local search, too, and helps
1667
+ however small each search is. With one channel set, its EQ search is parallel instead, when it can be
1668
+ (see ``COM.opt_eq()``). Either way, the results are identical to a serial run's.
1669
+ The workers are spawned; so, a script using this must guard its top level code,
1670
+ w/ ``if __name__ == "__main__":``.
1671
+ """
1487
1672
 
1488
- # Calling the object calculates the COM value, as well as many other intermediate results.
1489
- theCOM(opt_mode=opt_mode, norm_mode=norm_mode, unit_amp=unit_amp, dbg_dict=dbg_dict)
1673
+ n_procs = default_n_procs() if n_procs is None else n_procs
1674
+ n_procs = n_procs or os.cpu_count() or 1
1675
+ debug = dbg_dict is not None
1676
+ labels = [(grp, ch_set['THRU'][0].stem) for grp, ch_set in chnl_sets]
1677
+ if n_procs > 1 and len(chnl_sets) > 1:
1678
+ tasks = [(com_params, ch_set, opt_mode, norm_mode, unit_amp, debug) for _, ch_set in chnl_sets]
1679
+ with multiprocessing.get_context("spawn").Pool(min(n_procs, len(tasks))) as pool:
1680
+ coms = pool.imap(_run_one_com_task, tasks) # (In order; so, reported in order, as each finishes.)
1681
+ results = []
1682
+ for (grp, lbl), theCOM in zip(labels, coms):
1683
+ print(f"{grp} : {lbl}")
1684
+ results.append(theCOM)
1685
+ else:
1686
+ results = []
1687
+ for (grp, lbl), (_, ch_set) in zip(labels, chnl_sets):
1688
+ print(f"{grp} : {lbl}")
1689
+ results.append(_run_one_com(com_params, ch_set, opt_mode, norm_mode, unit_amp, debug, n_procs))
1490
1690
 
1491
- if grp in theCOMs:
1492
- theCOMs[grp].update({ch_set['THRU'][0].stem: theCOM})
1493
- else:
1494
- theCOMs.update({grp: {ch_set['THRU'][0].stem: theCOM}})
1691
+ theCOMs: dict[ChnlGrpName, dict[ChnlSetName, COM]] = {}
1692
+ for (grp, lbl), theCOM in zip(labels, results):
1693
+ if debug:
1694
+ theCOM.dbg_dict = dbg_dict # (The caller's own, rather than a worker's copy.)
1695
+ theCOMs.setdefault(grp, {})[lbl] = theCOM
1495
1696
 
1496
1697
  return theCOMs
1497
1698
 
@@ -31,6 +31,10 @@ COMChnl: TypeAlias = tuple[COMNtwk, Cvec]
31
31
  PI = math.pi #: Value of Pi (`math.pi`, a plain float; `np.pi` made autodoc barf)
32
32
  TWOPI = 2.0 * PI #: 2 * Pi
33
33
 
34
+ #: Environment variable giving the default number of EQ optimization processes (``COM.n_procs``);
35
+ #: set by ``pychopmarg run-notebook --procs``.
36
+ PROCS_ENV_VAR = "PYCHOPMARG_PROCS"
37
+
34
38
  ChnlGrpName: TypeAlias = str # channel group name
35
39
  ChnlSetName: TypeAlias = str # channel set name (the stem of the thru channel s4p file name)
36
40
  ChnlTypName: TypeAlias = str # channel type name ("thru", "next", or "fext")