Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

PyBMD: A Python BMD package

A Python package for bispectral mode decomposition (BMD) and cross-bispectral mode decomposition (CBMD).

Triadic interactions are the fundamental mechanism of energy transfer in fluid flows. BMD detects quadratic phase coupling through the bispectrum and extracts the coherent structures associated with it, distinguishing sum- from difference-interactions and producing interaction maps that identify the regions of nonlinear coupling.

The architecture follows PySPOD: a params-dict-driven Base/Standard class pair, an optional MPI communicator, disk-backed mode storage, and a YAML config reader.

 f2 or l
^
________|
|\ |\
| \ 7 | \
| 6 \ | 8 /\
| \| / 1 \
----+-------+-------+-> f1 or k
\ 5 / |\ |
\/ 4 | \ 2 |
\ | 3 \ |
\|______\|
|

Regions of the f1–f2 plane, selected with params['regions']. Triads are expressed as frequency triplets {f1, f2, f1+f2}, or index triplets (k, l, k+l).

Installation

pip install -e .# core: numpy, scipy, pyyaml, matplotlib
pip install -e '.[mpi]'# add mpi4py for parallel runs
pip install -e '.[io,test]'# .mat/.nc readers, and pytest

Usage

importnumpyasnpfrompybmd.bmd.standardimportStandardimportpybmd.utils.weightsasutils_weights# data has time first and the variable index last: (nt, *spatial, n_variables)data= ...
params=dict(
n_dft=256, # snapshots per blocktime_step=0.12,
n_space_dims=2,
n_variables=2,
overlap=50, # percent; or n_overlap=128 in snapshotsregions=[1, 2], # sum- and difference-interactionsmax_freq_idx=24, # restrict to |k|, |l| <= 24solver='MengiOverton',
savedir='bmd_results',
)
weights=utils_weights.trapz_2d(x, y, n_vars=2)
bmd=Standard(params=params, weights=weights).fit(data)
# the mode bispectrum, NaN outside the computed triadsL=bmd.bispectrum# look a triad up by its index doublet, then load its two modesi=bmd.triads.find(k=5, l=-2)
psi_sum, psi_prod=bmd.get_modes_at_triad(i) # phi_{k+l}, phi_{k o l}

Plotting:

frompybmd.bmd.postprocimportplot_mode_bispectrum, plot_triad_modesplot_mode_bispectrum(bmd.L, bmd.freq)
plot_triad_modes(bmd.get_modes_at_triad(i), k=5, l=-2, x1=x[:, 0], x2=y[0, :])

Visualizing an existing results directory:

frompybmd.bmd.postprocimport (
load_results, top_triads, plot_mode_bispectrum_from_dir,
plot_triad_modes_from_dir,
)
results=load_results('bmd_results/nfft256_novlp128_nblks9')
top=top_triads(results, n=5)
plot_mode_bispectrum_from_dir(results.path)
plot_triad_modes_from_dir(results.path, triad_idx=int(top[0]['triad_idx']),
x1=x[:, 0], x2=y[0, :])

Running in parallel — the triad loop is distributed across ranks and results are identical to a serial run:

mpirun -n 8 python my_script.py # pass comm=MPI.COMM_WORLD to the constructor

Cross-BMD, for a quadratic term built from different variables:

frompybmd.bmd.crossimportCross# s_0 <- q_1 * r_2, with 0-based variable indicescbmd=Cross(params=dict(params, state_idx=[0], qr_idx=[[1, 2]]),
weights=utils_weights.trapz_2d(x, y, n_vars=None)).fit(data)

See examples/ for the three worked cases, which mirror example1.mexample3.m of the original MATLAB implementation.

Parameters

Required:n_dft, time_step, n_space_dims, n_variables.

OptionalDefaultMeaning
overlap50block overlap, in percent
n_overlapblock overlap in snapshots; takes precedence over overlap
window'hamming''hamming', 'hann', 'boxcar', or an array
mean_type'longtime''longtime', 'blockwise', 'zero'
regions[1, 2]regions of the bispectrum to compute, in 1..8
max_freq_idxNonebound on |k| and |l|; default is Nyquist
solver'MengiOverton'also 'MengiOvertonMATLAB', 'simpleIteration'
tol1e-6solver tolerance
n_it_max500solver iteration cap
dtype'double''double' or 'single'
save_modesTruewrite modes/triad_idx_{i:08d}.npy
store_modesFalsealso keep all modes in memory, exposed as .modes
max_modes_gb8.0refuse to write more than this without an explicit raise
compute_energy_transferTruefill the energy-transfer term T
savedir'bmd_results'results directory

Results are written to <savedir>/nfft{n_dft}_novlp{n_overlap}_nblks{n_blocks}/, holding bispectrum.npz, triads.npz, coeffs.npy, weights.npy, ltm_modes.npy, params_modes.yaml and modes/.

coeffs.npy holds the maximisers of the numerical radius, one short vector per triad. Since the modes are just Q @ a, they can be rebuilt from these without re-running the optimizer — which is what makes it practical to run a large case with save_modes=False and decide afterwards which triads are worth reconstructing.

Deviations from the reference implementation

The algorithm is ported from O. T. Schmidt's MATLAB bmd.m and cbmd.m. Three deliberate departures, each of which changes results:

  1. max_fov uses the signed largest eigenvalue of the Hermitian part, not the largest in modulus. The Mengi–Overton level set is defined by the signed λ_max; filtering the crossing angles by modulus discards valid ones, so the search terminates at a local maximum. Measured on a random 7×7 complex matrix: 3.4973 against a true 4.4346.
  2. The matrix is pre-scaled by a power of two before the level-set search. The unit-circle test |‖D‖ − 1| ≤ sqrt(eps)·‖A‖₁ is an absolute tolerance scaled by the norm, and the matrices BMD produces are small — B carries a 1/n_blocks and the quadrature weights. Without rescaling, every crossing is rejected and the solver returns a local maximum; measured at ‖A‖₁ ~ 1e-6, it returned 93.7 % of the true value. Scaling by a power of two is exact in binary floating point, so this only re-conditions the problem.
  3. The energy-transfer term T is computed, and the solvers use a deterministic start vector rather than a global RNG, so results do not depend on how triads are distributed across MPI ranks.

solver='simpleIteration' reproduces the reference's only solver. It is not globally convergent — on random matrices it under-estimated the numerical radius in 14 of 40 cases, worst case 62 % low — so MengiOverton is the default.

solver='MengiOvertonMATLAB' reverts deviations 1 and 2 above (and the level-set filter's sqrt(eps)*w tolerance) to reproduce bmd.m's own MengiOverton bug-for-bug, confirmed live against the real MATLAB source under Octave to a few micro-relative on well-scaled problems. It exists only to reproduce a specific published MATLAB result — it reproduces a confirmed under-estimation bug and should never be used to analyse new data. See docs/octave_cross_validation.md for the measured figures and pybmd.bmd.optimizers.mengi_overton's docstring for the caveats.

Testing

pytest # everything
pytest -m "not slow"# fast subset, ~35 s

The suite verifies the bispectrum against a closed-form analytic result — for an on-grid, boxcar-windowed, block-random-phase signal, L(k1,k2) = (a1 a2 a3 / 8) Σ w conj(φ3) φ1 φ2 exactly — as well as conjugate symmetry, exact triad counts, CBMD reducing to BMD when the three variables coincide, bit-identical results across MPI rank counts, and a regression against an independent implementation on the cylinder-wake dataset.

References

The original MATLAB implementation: https://github.com/olivertschmidt/bmd

@article{schmidt2020bispectral,
title = {Bispectral mode decomposition of nonlinear flows},
author = {Schmidt, Oliver T.},
journal = {Nonlinear Dynamics},
volume = {102},
number = {4},
pages = {2479--2501},
year = {2020},
doi = {10.1007/s11071-020-06037-z}
}

The architectural template:

@article{mengaldo2021pyspod,
title = {PySPOD: A {P}ython package for Spectral Proper Orthogonal Decomposition ({SPOD})},
author = {Mengaldo, Gianmarco and Maulik, Romit},
journal = {Journal of Open Source Software},
volume = {6},
number = {60},
pages = {2862},
year = {2021},
doi = {10.21105/joss.02862}
}

License

MIT — see LICENSE. The cylinder-wake test fixture is subsampled from the dataset distributed with the reference MATLAB implementation.

About

A Python package for bispectral mode decomposition (BMD).

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages