Skip to content

Modules

Core Types

postmodal.types

Custom types and data classes for modal analysis.

ComplexityMetrics dataclass

Container for modal complexity metrics.

Attributes:

Name Type Description
mpc ndarray

Modal Phase Collinearity values [n_modes]

map ndarray

Modal Amplitude Proportionality values [n_modes]

mpd ndarray

Mean Phase Deviation values [n_modes] in degrees

Source code in postmodal/types.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@dataclass
class ComplexityMetrics:
    """Container for modal complexity metrics.

    Attributes
    ----------
    mpc : np.ndarray
        Modal Phase Collinearity values [n_modes]
    map : np.ndarray
        Modal Amplitude Proportionality values [n_modes]
    mpd : np.ndarray
        Mean Phase Deviation values [n_modes] in degrees
    """

    mpc: np.ndarray
    map: np.ndarray
    mpd: np.ndarray

    def __post_init__(self) -> None:
        """Validate the metrics after initialization."""
        shapes = [
            self.mpc.shape,
            self.map.shape,
            self.mpd.shape,
        ]
        if not all(s == shapes[0] for s in shapes):
            raise ValueError("All metrics must have the same shape")

__post_init__() -> None

Validate the metrics after initialization.

Source code in postmodal/types.py
 94
 95
 96
 97
 98
 99
100
101
102
def __post_init__(self) -> None:
    """Validate the metrics after initialization."""
    shapes = [
        self.mpc.shape,
        self.map.shape,
        self.mpd.shape,
    ]
    if not all(s == shapes[0] for s in shapes):
        raise ValueError("All metrics must have the same shape")

ModalData dataclass

Container for modal analysis data.

Attributes:

Name Type Description
frequencies ndarray

Array of natural frequencies [n_modes]. Must be positive real values.

modeshapes ndarray

Array of mode shapes [n_modes x n_dof]

damping (ndarray, optional)

Array of damping ratios [n_modes], defaults to zeros. Must be real-valued positive numbers in range [0, 1].

Source code in postmodal/types.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@dataclass
class ModalData:
    """Container for modal analysis data.

    Attributes
    ----------
    frequencies : np.ndarray
        Array of natural frequencies [n_modes]. Must be positive real values.
    modeshapes : np.ndarray
        Array of mode shapes [n_modes x n_dof]
    damping : np.ndarray, optional
        Array of damping ratios [n_modes], defaults to zeros.
        Must be real-valued positive numbers in range [0, 1].
    """

    frequencies: np.ndarray
    modeshapes: np.ndarray
    damping: np.ndarray | None = field(default_factory=lambda: np.array([]))

    def _validate_frequencies(self) -> None:
        """Validate frequency data."""
        if not isinstance(self.frequencies, np.ndarray):
            raise TypeError("frequencies must be a numpy array")
        if not np.isreal(self.frequencies).all():
            raise ValueError("frequencies must be real-valued")
        if np.any(self.frequencies <= 0):
            raise ValueError("frequencies must be positive")

    def _validate_modeshapes(self) -> None:
        """Validate modeshape data."""
        if not isinstance(self.modeshapes, np.ndarray):
            raise TypeError("modeshapes must be a numpy array")
        if self.modeshapes.ndim not in [1, 2]:
            raise ValueError("modeshapes must be 1D or 2D array")
        if self.frequencies.shape[0] != self.modeshapes.shape[0]:
            raise ValueError("Number of frequencies must match number of modes")

    def _validate_damping(self) -> None:
        """Validate damping data."""
        if self.damping is None or len(self.damping) == 0:
            self.damping = np.zeros_like(self.frequencies)
            return

        if not isinstance(self.damping, np.ndarray):
            raise TypeError("damping must be a numpy array")
        if not np.isreal(self.damping).all():
            raise ValueError("damping must be real-valued")
        if self.damping.shape != self.frequencies.shape:
            raise ValueError("damping must have same shape as frequencies")
        if np.any(self.damping < 0):
            raise ValueError("damping ratios must be non-negative")
        if np.any(self.damping > 1):
            raise ValueError("damping ratios must be less than or equal to 1")

    def __post_init__(self) -> None:
        """Validate the data after initialization."""
        self._validate_frequencies()
        self._validate_modeshapes()
        self._validate_damping()

__post_init__() -> None

Validate the data after initialization.

Source code in postmodal/types.py
69
70
71
72
73
def __post_init__(self) -> None:
    """Validate the data after initialization."""
    self._validate_frequencies()
    self._validate_modeshapes()
    self._validate_damping()

options: show_root_heading: true show_source: true

Validation

postmodal.validation

Validation utilities for modal analysis data.

ModalValidator

Validator for modeshape data.

Source code in postmodal/validation.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class ModalValidator:
    """Validator for modeshape data."""

    @staticmethod
    def validate(modeshape: np.ndarray) -> None:
        """Validate a modeshape array.

        Parameters
        ----------
        modeshape : np.ndarray
            The modeshape array to validate

        Raises
        ------
        TypeError
            If modeshape is not a numpy array
        ValueError
            If modeshape has incorrect dimensions
        """
        if not isinstance(modeshape, np.ndarray):
            raise TypeError("Modeshape must be a numpy array")
        if modeshape.ndim not in [1, 2]:
            raise ValueError("Modeshape must be 1D or 2D array")
        if modeshape.size == 0:
            raise ValueError("Modeshape array cannot be empty")

    @staticmethod
    def validate_pair(phi_1: np.ndarray, phi_2: np.ndarray) -> None:
        """Validate a pair of modeshapes for comparison.

        Parameters
        ----------
        phi_1 : np.ndarray
            First modeshape
        phi_2 : np.ndarray
            Second modeshape

        Raises
        ------
        ValueError
            If modeshapes have different shapes
        """
        # Validate individual modeshapes first
        ModalValidator.validate(phi_1)
        ModalValidator.validate(phi_2)
        if phi_1.shape != phi_2.shape:
            raise ValueError("Modeshapes must have the same shape")

    @staticmethod
    def validate_frequency(frequency: np.ndarray) -> None:
        """Validate frequency data.

        Parameters
        ----------
        frequency : np.ndarray
            Array of frequencies to validate

        Raises
        ------
        TypeError
            If frequency is not a numpy array
        ValueError
            If frequency is not 1D or contains non-positive values
        """
        if not isinstance(frequency, np.ndarray):
            raise TypeError("Frequency must be a numpy array")
        if frequency.ndim != 1:
            raise ValueError("Frequency must be 1D array")
        if np.any(frequency <= 0):
            raise ValueError("All frequencies must be positive")

validate(modeshape: np.ndarray) -> None staticmethod

Validate a modeshape array.

Parameters:

Name Type Description Default
modeshape ndarray

The modeshape array to validate

required

Raises:

Type Description
TypeError

If modeshape is not a numpy array

ValueError

If modeshape has incorrect dimensions

Source code in postmodal/validation.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@staticmethod
def validate(modeshape: np.ndarray) -> None:
    """Validate a modeshape array.

    Parameters
    ----------
    modeshape : np.ndarray
        The modeshape array to validate

    Raises
    ------
    TypeError
        If modeshape is not a numpy array
    ValueError
        If modeshape has incorrect dimensions
    """
    if not isinstance(modeshape, np.ndarray):
        raise TypeError("Modeshape must be a numpy array")
    if modeshape.ndim not in [1, 2]:
        raise ValueError("Modeshape must be 1D or 2D array")
    if modeshape.size == 0:
        raise ValueError("Modeshape array cannot be empty")

validate_frequency(frequency: np.ndarray) -> None staticmethod

Validate frequency data.

Parameters:

Name Type Description Default
frequency ndarray

Array of frequencies to validate

required

Raises:

Type Description
TypeError

If frequency is not a numpy array

ValueError

If frequency is not 1D or contains non-positive values

Source code in postmodal/validation.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@staticmethod
def validate_frequency(frequency: np.ndarray) -> None:
    """Validate frequency data.

    Parameters
    ----------
    frequency : np.ndarray
        Array of frequencies to validate

    Raises
    ------
    TypeError
        If frequency is not a numpy array
    ValueError
        If frequency is not 1D or contains non-positive values
    """
    if not isinstance(frequency, np.ndarray):
        raise TypeError("Frequency must be a numpy array")
    if frequency.ndim != 1:
        raise ValueError("Frequency must be 1D array")
    if np.any(frequency <= 0):
        raise ValueError("All frequencies must be positive")

validate_pair(phi_1: np.ndarray, phi_2: np.ndarray) -> None staticmethod

Validate a pair of modeshapes for comparison.

Parameters:

Name Type Description Default
phi_1 ndarray

First modeshape

required
phi_2 ndarray

Second modeshape

required

Raises:

Type Description
ValueError

If modeshapes have different shapes

Source code in postmodal/validation.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@staticmethod
def validate_pair(phi_1: np.ndarray, phi_2: np.ndarray) -> None:
    """Validate a pair of modeshapes for comparison.

    Parameters
    ----------
    phi_1 : np.ndarray
        First modeshape
    phi_2 : np.ndarray
        Second modeshape

    Raises
    ------
    ValueError
        If modeshapes have different shapes
    """
    # Validate individual modeshapes first
    ModalValidator.validate(phi_1)
    ModalValidator.validate(phi_2)
    if phi_1.shape != phi_2.shape:
        raise ValueError("Modeshapes must have the same shape")

validate_modal_data(frequencies: np.ndarray, modeshapes: np.ndarray) -> None

Validate modal data (frequencies and modeshapes).

Parameters:

Name Type Description Default
frequencies ndarray

Array of natural frequencies [n_modes]

required
modeshapes ndarray

Array of mode shapes [n_modes x n_dof]

required

Raises:

Type Description
ValueError

If data dimensions are incompatible

Source code in postmodal/validation.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def validate_modal_data(frequencies: np.ndarray, modeshapes: np.ndarray) -> None:
    """Validate modal data (frequencies and modeshapes).

    Parameters
    ----------
    frequencies : np.ndarray
        Array of natural frequencies [n_modes]
    modeshapes : np.ndarray
        Array of mode shapes [n_modes x n_dof]

    Raises
    ------
    ValueError
        If data dimensions are incompatible
    """
    ModalValidator.validate(modeshapes)
    ModalValidator.validate_frequency(frequencies)

    if frequencies.shape[0] != modeshapes.shape[0]:
        raise ValueError("Number of frequencies must match number of modes")

options: show_root_heading: true show_source: true

Complexity Metrics

postmodal.complexity

Functions for calculating modal complexity metrics.

calculate_complexity_metrics(modeshape: np.ndarray) -> ComplexityMetrics

Calculate all complexity metrics for a modeshape or set of modeshapes.

This function computes all available complexity metrics: - Modal Phase Collinearity (MPC) - Modal Amplitude Proportionality (MAP) - Mean Phase Deviation (MPD)

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required

Returns:

Type Description
ComplexityMetrics

Container with all computed complexity metrics. Each metric has shape [] for single modeshape or [n_modes] for multiple modeshapes.

Raises:

Type Description
ValueError

If modeshape has incorrect dimensions

Source code in postmodal/complexity.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def calculate_complexity_metrics(modeshape: np.ndarray) -> ComplexityMetrics:
    """Calculate all complexity metrics for a modeshape or set of modeshapes.

    This function computes all available complexity metrics:
    - Modal Phase Collinearity (MPC)
    - Modal Amplitude Proportionality (MAP)
    - Mean Phase Deviation (MPD)

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.

    Returns
    -------
    ComplexityMetrics
        Container with all computed complexity metrics.
        Each metric has shape [] for single modeshape or [n_modes] for multiple modeshapes.

    Raises
    ------
    ValueError
        If modeshape has incorrect dimensions
    """
    ModalValidator.validate(modeshape)

    # Calculate all metrics
    mpc_values = calculate_mpc(modeshape)
    map_values = calculate_map(modeshape)
    mpd_values = calculate_mpd(modeshape)

    # Return as ComplexityMetrics container
    return ComplexityMetrics(
        mpc=mpc_values,
        map=map_values,
        mpd=mpd_values,
    )

calculate_map(modeshape: np.ndarray) -> np.ndarray

Calculate Modal Amplitude Proportionality (MAP) for a modeshape or set of modeshapes.

MAP assesses the amplitude proportionality of a complex mode shape. A higher MAP value (≈ 1) indicates that the mode shape amplitudes are largely proportional to their real parts, suggesting a more 'real' amplitude distribution. A lower MAP value (< 1) indicates a deviation from real amplitude proportionality, suggesting a more complex amplitude distribution and significant imaginary components.

Math:

.. math:: MAP_r = \frac{ \sum_{j=1}^{n} |Re(\Phi_{jr})| }{ \sum_{j=1}^{n} |\Phi_{jr}| }

Where: - :math:\Phi_{jr}: j-th component of the r-th complex mode shape. - :math:Re(\Phi_{jr}): Real part of :math:\Phi_{jr}. - :math:n: Number of DOFs. - :math:| ... |: Magnitude (absolute value for scalar, modulus for complex).

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required

Returns:

Type Description
ndarray

MAP value(s). - Scalar if input is a single modeshape []. - 1D array [n_modes] if input is a set of modeshapes.

Raises:

Type Description
ValueError

If the input modeshape is not a NumPy array.

NotImplementedError

If the input modeshape has dimensions other than 1 or 2.

Source code in postmodal/complexity.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def calculate_map(modeshape: np.ndarray) -> np.ndarray:
    """Calculate Modal Amplitude Proportionality (MAP) for a modeshape or set of modeshapes.

    MAP assesses the amplitude proportionality of a complex mode shape.
    A higher MAP value (≈ 1) indicates that the mode shape amplitudes are largely
    proportional to their real parts, suggesting a more 'real' amplitude distribution.
    A lower MAP value (< 1) indicates a deviation from real amplitude proportionality,
    suggesting a more complex amplitude distribution and significant imaginary components.

    Math:
    -----
    .. math::
        MAP_r = \\frac{ \\sum_{j=1}^{n} |Re(\\Phi_{jr})| }{ \\sum_{j=1}^{n} |\\Phi_{jr}| }

    Where:
    - :math:`\\Phi_{jr}`: j-th component of the r-th complex mode shape.
    - :math:`Re(\\Phi_{jr})`: Real part of :math:`\\Phi_{jr}`.
    - :math:`n`: Number of DOFs.
    - :math:`| ... |`: Magnitude (absolute value for scalar, modulus for complex).

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.

    Returns
    -------
    np.ndarray
        MAP value(s).
        - Scalar if input is a single modeshape [].
        - 1D array [n_modes] if input is a set of modeshapes.

    Raises
    ------
    ValueError
        If the input `modeshape` is not a NumPy array.
    NotImplementedError
        If the input `modeshape` has dimensions other than 1 or 2.
    """
    ModalValidator.validate(modeshape)

    if modeshape.ndim == 1:
        numerator = np.sum(np.abs(modeshape.real))
        denominator = np.sum(np.abs(modeshape))
        map_value = numerator / denominator if denominator != 0 else 0.0  # Handle potential division by zero
        return np.array(map_value)  # Return as 0D array for consistency

    elif modeshape.ndim == 2:
        map_values = []
        for mode in modeshape:
            numerator = np.sum(np.abs(mode.real))
            denominator = np.sum(np.abs(mode))
            map_value = numerator / denominator if denominator != 0 else 0.0  # Handle potential division by zero
            map_values.append(map_value)
        return np.array(map_values)

    else:
        raise NotImplementedError(f"modeshape has dimensions: {modeshape.ndim}, expecting 1 or 2.")

calculate_mpc(modeshape: np.ndarray, method: str = 'mac') -> np.ndarray

Calculate Modal Phase Collinearity (MPC) for a modeshape or set of modeshapes.

MPC quantifies the 'realness' of a mode shape by measuring the phase alignment of its components. A higher MPC value (≈ 1) indicates a more 'real' mode shape with phases close to 0° or 180°, suggesting proportionally damped or undamped behavior. A lower MPC value (≈ 0) suggests a more complex mode shape with scattered phases, indicating non-proportional damping or mode coupling.

Math:

.. math:: MPC(\phi_j) = \frac{||Re(\tilde{\phi}j)||_2^2 + ||Im(\tilde{\phi}_j)||_2^2} {||Re(\tilde{\phi}_j)||_2^2 + \epsilon{MPC}^{-1} Re(\tilde{\phi}j^T)Im(\tilde{\phi}_j) (2(\epsilon{MPC}^2 + 1)\sin^2(\theta_{MPC}) - 1)}

Where: - :math:\tilde{\phi}_j: Centered mode shape - :math:\epsilon_{MPC}: MPC epsilon parameter - :math:\theta_{MPC}: MPC angle parameter - :math:||...||_2^2: Squared L2 norm

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required
method str

Method to use for MPC calculation: - "old": Original implementation using epsilon and theta parameters - "eigenvalue": Implementation using eigenvalue decomposition - "mac": Implementation using Modal Assurance Criterion formula (default)

'mac'

Returns:

Type Description
ndarray

MPC value(s). - Scalar if input is a single modeshape []. - 1D array [n_modes] if input is a set of modeshapes.

Raises:

Type Description
ValueError

If the input modeshape is not a NumPy array or if method is invalid.

NotImplementedError

If the input modeshape has dimensions other than 1 or 2.

Source code in postmodal/complexity.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def calculate_mpc(modeshape: np.ndarray, method: str = "mac") -> np.ndarray:
    """Calculate Modal Phase Collinearity (MPC) for a modeshape or set of modeshapes.

    MPC quantifies the 'realness' of a mode shape by measuring the phase alignment
    of its components. A higher MPC value (≈ 1) indicates a more 'real' mode shape
    with phases close to 0° or 180°, suggesting proportionally damped or undamped behavior.
    A lower MPC value (≈ 0) suggests a more complex mode shape with scattered phases,
    indicating non-proportional damping or mode coupling.

    Math:
    -----
    .. math::
        MPC(\\phi_j) = \\frac{||Re(\\tilde{\\phi}_j)||_2^2 + ||Im(\\tilde{\\phi}_j)||_2^2}
        {||Re(\\tilde{\\phi}_j)||_2^2 + \\epsilon_{MPC}^{-1} Re(\\tilde{\\phi}_j^T)Im(\\tilde{\\phi}_j)
        (2(\\epsilon_{MPC}^2 + 1)\\sin^2(\\theta_{MPC}) - 1)}

    Where:
    - :math:`\\tilde{\\phi}_j`: Centered mode shape
    - :math:`\\epsilon_{MPC}`: MPC epsilon parameter
    - :math:`\\theta_{MPC}`: MPC angle parameter
    - :math:`||...||_2^2`: Squared L2 norm

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.
    method : str, optional
        Method to use for MPC calculation:
        - "old": Original implementation using epsilon and theta parameters
        - "eigenvalue": Implementation using eigenvalue decomposition
        - "mac": Implementation using Modal Assurance Criterion formula (default)

    Returns
    -------
    np.ndarray
        MPC value(s).
        - Scalar if input is a single modeshape [].
        - 1D array [n_modes] if input is a set of modeshapes.

    Raises
    ------
    ValueError
        If the input `modeshape` is not a NumPy array or if method is invalid.
    NotImplementedError
        If the input `modeshape` has dimensions other than 1 or 2.
    """
    ModalValidator.validate(modeshape)

    if method not in ["old", "eigenvalue", "mac"]:
        raise ValueError('method must be one of "old", "eigenvalue", or "mac"')

    calculate_single_mpc = {
        "old": _calculate_mpc_old,
        "eigenvalue": _calculate_mpc_eigvals,
        "mac": _calculate_mpc_mac,
    }[method]

    if modeshape.ndim == 1:
        return np.array(calculate_single_mpc(modeshape))
    elif modeshape.ndim == 2:
        return np.array([calculate_single_mpc(mode) for mode in modeshape])
    else:
        raise NotImplementedError(f"modeshape has dimensions: {modeshape.ndim}, expecting 1 or 2.")

calculate_mpd(modeshape: np.ndarray, weights: str = 'magnitude') -> np.ndarray

Calculate Mean Phase Deviation (MPD) for a modeshape or set of modeshapes.

MPD quantifies the phase scatter within a mode shape by measuring the weighted average of phase deviations from the mean phase angle. The mean phase is determined by solving a total least squares problem using SVD to find the best straight line fit through the mode shape in the complex plane.

Math:

.. math:: MP(\phi_j) = \arctan\left(\frac{-V_{12}}{V_{22}}\right)

MPD(\phi_j) = \frac{\sum_{o=1}^{n_y} w_o \arccos\left|\frac{Re(\phi_{jo})V_{22} - Im(\phi_{jo})V_{12}}{\sqrt{V_{12}^2 + V_{22}^2}|\phi_{jo}|}\right|}{\sum_{o=1}^{n_y} w_o}

Where: - :math:MP(\phi_j): Mean phase angle determined by SVD - :math:V_{12}, V_{22}: Elements of the V matrix from SVD of [Re(φj) Im(φj)] - :math:w_o: Weighting factors (either |φjo| or 1 for equal weights) - :math:\phi_{jo}: Complex mode shape components - :math:n_y: Number of DOFs

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required
weights str

Weighting scheme for phase deviations: - "magnitude": weights are the magnitude of each mode shape component (default) - "equal": equal weights for all components

'magnitude'

Returns:

Type Description
ndarray

MPD value(s) in degrees. - Scalar if input is a single modeshape []. - 1D array [n_modes] if input is a set of modeshapes.

Raises:

Type Description
TypeError

If the input modeshape is not a NumPy array.

NotImplementedError

If the input modeshape has dimensions other than 1 or 2.

ValueError

If weights is not one of "magnitude" or "equal".

Source code in postmodal/complexity.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def calculate_mpd(modeshape: np.ndarray, weights: str = "magnitude") -> np.ndarray:
    """Calculate Mean Phase Deviation (MPD) for a modeshape or set of modeshapes.

    MPD quantifies the phase scatter within a mode shape by measuring the weighted average
    of phase deviations from the mean phase angle. The mean phase is determined by solving
    a total least squares problem using SVD to find the best straight line fit through
    the mode shape in the complex plane.

    Math:
    -----
    .. math::
        MP(\\phi_j) = \\arctan\\left(\\frac{-V_{12}}{V_{22}}\\right)

        MPD(\\phi_j) = \\frac{\\sum_{o=1}^{n_y} w_o \\arccos\\left|\\frac{Re(\\phi_{jo})V_{22} - Im(\\phi_{jo})V_{12}}{\\sqrt{V_{12}^2 + V_{22}^2}|\\phi_{jo}|}\\right|}{\\sum_{o=1}^{n_y} w_o}

    Where:
    - :math:`MP(\\phi_j)`: Mean phase angle determined by SVD
    - :math:`V_{12}, V_{22}`: Elements of the V matrix from SVD of [Re(φj) Im(φj)]
    - :math:`w_o`: Weighting factors (either |φjo| or 1 for equal weights)
    - :math:`\\phi_{jo}`: Complex mode shape components
    - :math:`n_y`: Number of DOFs

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.
    weights : str, optional
        Weighting scheme for phase deviations:
        - "magnitude": weights are the magnitude of each mode shape component (default)
        - "equal": equal weights for all components

    Returns
    -------
    np.ndarray
        MPD value(s) in degrees.
        - Scalar if input is a single modeshape [].
        - 1D array [n_modes] if input is a set of modeshapes.

    Raises
    ------
    TypeError
        If the input `modeshape` is not a NumPy array.
    NotImplementedError
        If the input `modeshape` has dimensions other than 1 or 2.
    ValueError
        If `weights` is not one of "magnitude" or "equal".
    """
    if not isinstance(modeshape, np.ndarray):
        raise TypeError("Input modeshape must be a NumPy array.")

    if weights not in ["magnitude", "equal"]:
        raise ValueError('weights must be either "magnitude" or "equal"')

    def calculate_single_mpd(mode: np.ndarray) -> float:
        # Form matrix with real and imaginary parts
        A = np.column_stack((mode.real, mode.imag))

        # Compute SVD
        U, S, Vh = np.linalg.svd(A)
        V = Vh.T  # Convert to V matrix

        # Extract V12 and V22
        V12, V22 = V[0, 1], V[1, 1]

        # Calculate weights based on chosen scheme
        if weights == "magnitude":
            weights_array = np.abs(mode).astype(np.float64)
        else:  # equal weights
            weights_array = np.ones(len(mode), dtype=np.float64)

        # Calculate denominator term
        denom_term = np.sqrt(V12**2 + V22**2)

        # Calculate phase deviations for each component
        phase_deviations = np.zeros(len(mode), dtype=np.float64)
        for i, (phi, w) in enumerate(zip(mode, weights_array, strict=False)):
            if w == 0:  # Skip zero components
                continue

            # Calculate numerator term
            num_term = (phi.real * V22 - phi.imag * V12) / (denom_term * w)

            # Ensure argument is in [-1, 1] for arccos
            num_term = np.clip(num_term, -1.0, 1.0)

            # Calculate phase deviation
            phase_deviations[i] = np.arccos(np.abs(num_term))

        # Calculate weighted average
        if np.sum(weights_array) == 0:
            return 0.0

        mpd_radians = float(np.sum(weights_array * phase_deviations) / np.sum(weights_array))
        return float(np.degrees(mpd_radians))

    if modeshape.ndim == 1:
        return np.array(calculate_single_mpd(modeshape))
    elif modeshape.ndim == 2:
        return np.array([calculate_single_mpd(mode) for mode in modeshape])
    else:
        raise NotImplementedError(f"modeshape has dimensions: {modeshape.ndim}, expecting 1 or 2.")

options: show_root_heading: true show_source: true

Comparison

postmodal.comparison

Modal comparison module.

best_match(match_matrix: np.ndarray, threshold: float = 0.6) -> tuple[int, int] | None

Find indices of best matching mode below threshold.

This function finds the pair of modes with the lowest matching value that is still below the specified threshold. It is used internally by match_modes.

Parameters:

Name Type Description Default
match_matrix ndarray

Matching matrix [n_modes_1 x n_modes_2] where lower values indicate better matches

required
threshold float

Threshold for acceptable matches, by default 0.6

0.6

Returns:

Type Description
Optional[Tuple[int, int]]

Indices of best match, or None if no match below threshold

Raises:

Type Description
ValueError

If matrix contains non-positive values or is not 2D

Source code in postmodal/comparison/matrix.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def best_match(match_matrix: np.ndarray, threshold: float = 0.6) -> tuple[int, int] | None:
    """Find indices of best matching mode below threshold.

    This function finds the pair of modes with the lowest matching value that is
    still below the specified threshold. It is used internally by match_modes.

    Parameters
    ----------
    match_matrix : np.ndarray
        Matching matrix [n_modes_1 x n_modes_2] where lower values indicate better matches
    threshold : float, optional
        Threshold for acceptable matches, by default 0.6

    Returns
    -------
    Optional[Tuple[int, int]]
        Indices of best match, or None if no match below threshold

    Raises
    ------
    ValueError
        If matrix contains non-positive values or is not 2D
    """
    if not np.all(match_matrix > 0.0):
        raise ValueError("All matrix values must be positive")
    if match_matrix.ndim != 2:
        raise ValueError("Matrix must be 2D")

    if not np.any(match_matrix <= threshold):
        return None

    indices = np.unravel_index(np.argmin(match_matrix, axis=None), match_matrix.shape)
    return (int(indices[0]), int(indices[1]))

calculate_mac(phi_1: np.ndarray, phi_2: np.ndarray) -> float

Calculate the MAC value of two (complex) modeshape vectors.

The Modal Assurance Criterion (MAC) is a measure of the correlation between two mode shapes. A MAC value close to 1 indicates strong correlation between the mode shapes, while a value close to 0 indicates weak correlation.

Math:

.. math:: MAC = \frac{ |\phi_1^H \phi_2|^2 }{ (\phi_1^H \phi_1)(\phi_2^H \phi_2) }

Where: - :math:\phi_1, \phi_2: Complex mode shape vectors - :math:^H: Hermitian transpose - :math:|...|: Magnitude of complex number

Parameters:

Name Type Description Default
phi_1 ndarray

First modeshape vector [n_dof]

required
phi_2 ndarray

Second modeshape vector [n_dof]

required

Returns:

Type Description
float

MAC value between 0 and 1

Raises:

Type Description
ValueError

If modeshapes have different shapes

Source code in postmodal/comparison/mac.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def calculate_mac(phi_1: np.ndarray, phi_2: np.ndarray) -> float:
    """Calculate the MAC value of two (complex) modeshape vectors.

    The Modal Assurance Criterion (MAC) is a measure of the correlation between
    two mode shapes. A MAC value close to 1 indicates strong correlation between
    the mode shapes, while a value close to 0 indicates weak correlation.

    Math:
    -----
    .. math::
        MAC = \\frac{ |\\phi_1^H \\phi_2|^2 }{ (\\phi_1^H \\phi_1)(\\phi_2^H \\phi_2) }

    Where:
    - :math:`\\phi_1, \\phi_2`: Complex mode shape vectors
    - :math:`^H`: Hermitian transpose
    - :math:`|...|`: Magnitude of complex number

    Parameters
    ----------
    phi_1 : np.ndarray
        First modeshape vector [n_dof]
    phi_2 : np.ndarray
        Second modeshape vector [n_dof]

    Returns
    -------
    float
        MAC value between 0 and 1

    Raises
    ------
    ValueError
        If modeshapes have different shapes
    """
    ModalValidator.validate_pair(phi_1, phi_2)

    numerator = np.abs(np.vdot(phi_1, phi_2)) ** 2
    denominator = np.vdot(phi_1, phi_1) * np.vdot(phi_2, phi_2)
    return float((numerator / denominator).real)

calculate_mac_matrix(phi_1: np.ndarray, phi_2: np.ndarray) -> NDArray

Calculate the MAC matrix between two sets of modeshapes.

The Modal Assurance Criterion (MAC) matrix quantifies the correlation between all pairs of mode shapes from two sets. Each element (i,j) represents the MAC value between mode i from the first set and mode j from the second set.

Math:

.. math:: MAC_{ij} = \frac{ |\phi_{1i}^H \phi_{2j}|^2 }{ (\phi_{1i}^H \phi_{1i})(\phi_{2j}^H \phi_{2j}) }

Where: - :math:\phi_{1i}: i-th mode shape from first set - :math:\phi_{2j}: j-th mode shape from second set - :math:^H: Hermitian transpose - :math:|...|: Magnitude of complex number

Parameters:

Name Type Description Default
phi_1 ndarray

First set of modeshapes [n_modes_1 x n_dof]

required
phi_2 ndarray

Second set of modeshapes [n_modes_2 x n_dof]

required

Returns:

Type Description
NDArray

MAC matrix [n_modes_1 x n_modes_2] with values between 0 and 1

Raises:

Type Description
ValueError

If modeshapes have different shapes

Source code in postmodal/comparison/matrix.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def calculate_mac_matrix(phi_1: np.ndarray, phi_2: np.ndarray) -> NDArray:
    """Calculate the MAC matrix between two sets of modeshapes.

    The Modal Assurance Criterion (MAC) matrix quantifies the correlation between
    all pairs of mode shapes from two sets. Each element (i,j) represents the MAC
    value between mode i from the first set and mode j from the second set.

    Math:
    -----
    .. math::
        MAC_{ij} = \\frac{ |\\phi_{1i}^H \\phi_{2j}|^2 }{ (\\phi_{1i}^H \\phi_{1i})(\\phi_{2j}^H \\phi_{2j}) }

    Where:
    - :math:`\\phi_{1i}`: i-th mode shape from first set
    - :math:`\\phi_{2j}`: j-th mode shape from second set
    - :math:`^H`: Hermitian transpose
    - :math:`|...|`: Magnitude of complex number

    Parameters
    ----------
    phi_1 : np.ndarray
        First set of modeshapes [n_modes_1 x n_dof]
    phi_2 : np.ndarray
        Second set of modeshapes [n_modes_2 x n_dof]

    Returns
    -------
    NDArray
        MAC matrix [n_modes_1 x n_modes_2] with values between 0 and 1

    Raises
    ------
    ValueError
        If modeshapes have different shapes
    """
    ModalValidator.validate_pair(phi_1, phi_2)

    # discern between real and complex modes
    # numpy.vdot cannot be used here, since
    # "it should only be used for vectors."
    # https://numpy.org/doc/stable/reference/generated/numpy.vdot.html
    if np.iscomplexobj(phi_1) or np.iscomplexobj(phi_2):
        return cast(
            NDArray,
            np.square(
                np.abs(np.dot(phi_1, phi_2.conj().T))
                / (np.linalg.norm(phi_1, axis=1)[:, np.newaxis] * np.linalg.norm(phi_2, axis=1))
            ),
        )
    else:
        return cast(
            NDArray,
            np.square(
                np.abs(np.dot(phi_1, phi_2.T))
                / (np.linalg.norm(phi_1, axis=1)[:, np.newaxis] * np.linalg.norm(phi_2, axis=1))
            ),
        )

calculate_mode_matching_matrix(frequencies_1: np.ndarray, modeshapes_1: np.ndarray, frequencies_2: np.ndarray, modeshapes_2: np.ndarray, modeshape_weight: float = 1.0) -> NDArray

Calculate mode matching matrix considering frequency and MAC.

Based on Simoen et al., 2014, "Dealing with uncertainty in model updating in damage assessment". The matching matrix combines frequency differences and MAC values to find corresponding modes between two sets. A lower value indicates a better match.

Math:

.. math:: M_{ij} = w(1 - MAC_{ij}) + |1 - f_{1i}/f_{2j}|

Where: - :math:MAC_{ij}: MAC value between modes i and j - :math:f_{1i}: Frequency of mode i from first set - :math:f_{2j}: Frequency of mode j from second set - :math:w: Weight for modeshape contribution

Parameters:

Name Type Description Default
frequencies_1 ndarray

Frequencies of first set [n_modes_1]

required
modeshapes_1 ndarray

Modeshapes of first set [n_modes_1 x n_dof]

required
frequencies_2 ndarray

Frequencies of second set [n_modes_2]

required
modeshapes_2 ndarray

Modeshapes of second set [n_modes_2 x n_dof]

required
modeshape_weight float

Weight for modeshape contribution, by default 1.0

1.0

Returns:

Type Description
NDArray

Matching matrix [n_modes_1 x n_modes_2] where lower values indicate better matches

Raises:

Type Description
ValueError

If input arrays have incompatible shapes

Source code in postmodal/comparison/matrix.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def calculate_mode_matching_matrix(
    frequencies_1: np.ndarray,
    modeshapes_1: np.ndarray,
    frequencies_2: np.ndarray,
    modeshapes_2: np.ndarray,
    modeshape_weight: float = 1.0,
) -> NDArray:
    """Calculate mode matching matrix considering frequency and MAC.

    Based on Simoen et al., 2014, "Dealing with uncertainty in model updating in damage assessment".
    The matching matrix combines frequency differences and MAC values to find corresponding modes
    between two sets. A lower value indicates a better match.

    Math:
    -----
    .. math::
        M_{ij} = w(1 - MAC_{ij}) + |1 - f_{1i}/f_{2j}|

    Where:
    - :math:`MAC_{ij}`: MAC value between modes i and j
    - :math:`f_{1i}`: Frequency of mode i from first set
    - :math:`f_{2j}`: Frequency of mode j from second set
    - :math:`w`: Weight for modeshape contribution

    Parameters
    ----------
    frequencies_1 : np.ndarray
        Frequencies of first set [n_modes_1]
    modeshapes_1 : np.ndarray
        Modeshapes of first set [n_modes_1 x n_dof]
    frequencies_2 : np.ndarray
        Frequencies of second set [n_modes_2]
    modeshapes_2 : np.ndarray
        Modeshapes of second set [n_modes_2 x n_dof]
    modeshape_weight : float, optional
        Weight for modeshape contribution, by default 1.0

    Returns
    -------
    NDArray
        Matching matrix [n_modes_1 x n_modes_2] where lower values indicate better matches

    Raises
    ------
    ValueError
        If input arrays have incompatible shapes
    """
    mac_matrix = calculate_mac_matrix(modeshapes_1, modeshapes_2)
    freq_matrix = np.abs(1 - frequencies_1[:, np.newaxis] / frequencies_2[np.newaxis, :])

    return cast(NDArray, modeshape_weight * (1 - mac_matrix) + freq_matrix)

match_modes(match_matrix: np.ndarray, threshold: float = 0.6) -> tuple[Sequence[int], Sequence[int]]

Find best matching modes using matching matrix.

This function finds pairs of modes that best match based on the matching matrix. It uses a greedy approach to find the best matches below the specified threshold. Each mode can only be matched once.

Parameters:

Name Type Description Default
match_matrix ndarray

Matching matrix [n_modes_1 x n_modes_2] where lower values indicate better matches

required
threshold float

Threshold for acceptable matches, by default 0.6

0.6

Returns:

Type Description
Tuple[Sequence[int], Sequence[int]]

Indices of matching modes from first and second set

Raises:

Type Description
ValueError

If matrix contains non-positive values or is not 2D

Source code in postmodal/comparison/matrix.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def match_modes(match_matrix: np.ndarray, threshold: float = 0.6) -> tuple[Sequence[int], Sequence[int]]:
    """Find best matching modes using matching matrix.

    This function finds pairs of modes that best match based on the matching matrix.
    It uses a greedy approach to find the best matches below the specified threshold.
    Each mode can only be matched once.

    Parameters
    ----------
    match_matrix : np.ndarray
        Matching matrix [n_modes_1 x n_modes_2] where lower values indicate better matches
    threshold : float, optional
        Threshold for acceptable matches, by default 0.6

    Returns
    -------
    Tuple[Sequence[int], Sequence[int]]
        Indices of matching modes from first and second set

    Raises
    ------
    ValueError
        If matrix contains non-positive values or is not 2D
    """
    if not np.all(match_matrix > 0.0):
        raise ValueError("All matrix values must be positive")
    if match_matrix.ndim != 2:
        raise ValueError("Matrix must be 2D")

    n_modes_1, n_modes_2 = match_matrix.shape
    n_modes_min = min(n_modes_1, n_modes_2)

    _match_matrix = match_matrix.copy()
    matching_modes_1 = []
    matching_modes_2 = []

    for _ in range(n_modes_min):
        match = best_match(_match_matrix, threshold)
        if match is None:
            break

        matching_modes_1.append(match[0])
        matching_modes_2.append(match[1])

        # Mark matched modes
        _match_matrix[match[0], :] = threshold + 1.0
        _match_matrix[:, match[1]] = threshold + 1.0

    return tuple(matching_modes_1), tuple(matching_modes_2)

options: show_root_heading: true show_source: true

Manipulation

postmodal.manipulation

Modeshape manipulation utilities.

This module provides functions for manipulating modeshapes, including normalization, phase alignment, and complex-to-real conversion.

.. note:: The :func:normalize_modeshape function is deprecated and will be removed in a future version. Use :func:normalize_modeshape_unit_norm_vector_length instead.

align_phase(modeshape: NDArray[np.complex128], reference_dof: int | None = None) -> NDArray[np.complex128]

Align the phase of a modeshape to a reference DOF.

This function aligns the phase of a modeshape by rotating it so that the phase at a reference DOF is zero. If no reference DOF is specified, the DOF that minimizes the phase range is used.

Parameters:

Name Type Description Default
modeshape ndarray

Complex modeshape array [n_dof]

required
reference_dof Optional[int]

Index of the reference DOF, by default None

None

Returns:

Type Description
ndarray

Phase-aligned modeshape [n_dof]

Notes

The phase alignment is achieved by multiplying the modeshape by :math:e^{-i\phi_{ref}}, where :math:\phi_{ref} is the phase angle at the reference DOF.

Source code in postmodal/manipulation/phase.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def align_phase(
    modeshape: NDArray[np.complex128],
    reference_dof: int | None = None,
) -> NDArray[np.complex128]:
    """Align the phase of a modeshape to a reference DOF.

    This function aligns the phase of a modeshape by rotating it so that
    the phase at a reference DOF is zero. If no reference DOF is specified,
    the DOF that minimizes the phase range is used.

    Parameters
    ----------
    modeshape : np.ndarray
        Complex modeshape array [n_dof]
    reference_dof : Optional[int], optional
        Index of the reference DOF, by default None

    Returns
    -------
    np.ndarray
        Phase-aligned modeshape [n_dof]

    Notes
    -----
    The phase alignment is achieved by multiplying the modeshape by
    :math:`e^{-i\\phi_{ref}}`, where :math:`\\phi_{ref}` is the phase
    angle at the reference DOF.
    """
    if reference_dof is not None:
        # Use specified reference DOF
        phase_ref = np.angle(modeshape[reference_dof])
        return cast(NDArray[np.complex128], modeshape * np.exp(-1j * phase_ref))

    # Find DOF that minimizes phase range
    phases = np.angle(modeshape)
    min_range = float("inf")
    best_ref_dof = 0

    for i in range(len(modeshape)):
        # Try each DOF as reference
        aligned_phases = phases - phases[i]
        # Normalize to [-π, π]
        aligned_phases = np.mod(aligned_phases + np.pi, 2 * np.pi) - np.pi
        phase_range = np.max(aligned_phases) - np.min(aligned_phases)

        if phase_range < min_range:
            min_range = phase_range
            best_ref_dof = i

    # Align using the best reference DOF
    phase_ref = phases[best_ref_dof]
    return cast(NDArray[np.complex128], modeshape * np.exp(-1j * phase_ref))

calculate_conversion_error(complex_modeshape: np.ndarray, real_modeshape: np.ndarray) -> tuple[float, float]

Calculate the error between complex and real modeshapes.

This function computes two error metrics: 1. Magnitude error: Relative difference in magnitudes 2. Phase error: Average absolute phase difference

Parameters:

Name Type Description Default
complex_modeshape ndarray

Original complex modeshape [n_dof]

required
real_modeshape ndarray

Converted real modeshape [n_dof]

required

Returns:

Type Description
tuple[float, float]

Tuple containing (magnitude_error, phase_error) - magnitude_error: Relative difference in magnitudes - phase_error: Average absolute phase difference in radians

Source code in postmodal/manipulation/complex_to_real.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def calculate_conversion_error(
    complex_modeshape: np.ndarray,
    real_modeshape: np.ndarray,
) -> tuple[float, float]:
    """Calculate the error between complex and real modeshapes.

    This function computes two error metrics:
    1. Magnitude error: Relative difference in magnitudes
    2. Phase error: Average absolute phase difference

    Parameters
    ----------
    complex_modeshape : np.ndarray
        Original complex modeshape [n_dof]
    real_modeshape : np.ndarray
        Converted real modeshape [n_dof]

    Returns
    -------
    tuple[float, float]
        Tuple containing (magnitude_error, phase_error)
        - magnitude_error: Relative difference in magnitudes
        - phase_error: Average absolute phase difference in radians
    """
    # Calculate magnitude error
    complex_mag = np.abs(complex_modeshape)
    real_mag = np.abs(real_modeshape)
    magnitude_error = np.mean(np.abs(complex_mag - real_mag) / complex_mag)

    # Calculate phase error
    complex_phase = np.angle(complex_modeshape)
    real_phase = np.angle(real_modeshape)
    phase_error = np.mean(np.abs(complex_phase - real_phase))

    return magnitude_error, phase_error

calculate_phase_distribution(modeshape: NDArray[np.complex128], bins: int = 36) -> tuple[NDArray[np.int_], NDArray[np.float64]]

Calculate the phase angle distribution of a modeshape.

This function computes a histogram of phase angles in the modeshape, which can be useful for analyzing phase clustering and identifying dominant phase patterns.

Parameters:

Name Type Description Default
modeshape ndarray

Complex modeshape array [n_dof]

required
bins int

Number of bins for the histogram, by default 36

36

Returns:

Type Description
tuple[ndarray, ndarray]

Tuple containing (histogram, bin_edges) - histogram: Array of phase angle counts - bin_edges: Array of bin edges in radians

Source code in postmodal/manipulation/phase.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def calculate_phase_distribution(
    modeshape: NDArray[np.complex128],
    bins: int = 36,
) -> tuple[NDArray[np.int_], NDArray[np.float64]]:
    """Calculate the phase angle distribution of a modeshape.

    This function computes a histogram of phase angles in the modeshape,
    which can be useful for analyzing phase clustering and identifying
    dominant phase patterns.

    Parameters
    ----------
    modeshape : np.ndarray
        Complex modeshape array [n_dof]
    bins : int, optional
        Number of bins for the histogram, by default 36

    Returns
    -------
    tuple[np.ndarray, np.ndarray]
        Tuple containing (histogram, bin_edges)
        - histogram: Array of phase angle counts
        - bin_edges: Array of bin edges in radians
    """
    phase = np.angle(modeshape)
    hist, edges = np.histogram(phase, bins=bins, range=(-np.pi, np.pi))
    return hist, edges

complex_to_real_batch(modeshapes: np.ndarray, method: str = 'phase', reference_dof: int | None = None) -> np.ndarray

Convert a batch of complex modeshapes to real-valued modeshapes.

This function converts multiple complex modeshapes to real-valued modeshapes using the specified method.

Parameters:

Name Type Description Default
modeshapes ndarray

Array of complex modeshapes [n_modes x n_dof]

required
method str

Conversion method, by default "phase"

'phase'
reference_dof Optional[int]

Index of the reference DOF for phase alignment, by default None

None

Returns:

Type Description
ndarray

Array of real-valued modeshapes [n_modes x n_dof]

See Also

complex_to_real : Convert a single complex modeshape

Source code in postmodal/manipulation/complex_to_real.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def complex_to_real_batch(
    modeshapes: np.ndarray,
    method: str = "phase",
    reference_dof: int | None = None,
) -> np.ndarray:
    """Convert a batch of complex modeshapes to real-valued modeshapes.

    This function converts multiple complex modeshapes to real-valued modeshapes
    using the specified method.

    Parameters
    ----------
    modeshapes : np.ndarray
        Array of complex modeshapes [n_modes x n_dof]
    method : str, optional
        Conversion method, by default "phase"
    reference_dof : Optional[int], optional
        Index of the reference DOF for phase alignment, by default None

    Returns
    -------
    np.ndarray
        Array of real-valued modeshapes [n_modes x n_dof]

    See Also
    --------
    complex_to_real : Convert a single complex modeshape
    """
    # Explicitly cast the result to np.ndarray to satisfy the return type
    return np.array([complex_to_real(phi, method, reference_dof) for phi in modeshapes])

normalize_modeshape(modeshape: np.ndarray) -> np.ndarray

Normalize a single modeshape or set of modeshapes (real part only).

.. deprecated:: 1.0.0 Use :func:normalize_modeshape_unit_norm_vector_length instead, which supports both real and complex modeshapes. This function will be removed in a future version.

This function normalizes only the real part of the modeshape(s) to unit norm. For a single modeshape, the 2-norm is used. For multiple modeshapes, each modeshape is normalized independently.

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]

required

Returns:

Type Description
ndarray

Normalized modeshape(s) with the same shape as input

Raises:

Type Description
ValueError

If modeshape has incorrect dimensions

Source code in postmodal/manipulation/normalize.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def normalize_modeshape(modeshape: np.ndarray) -> np.ndarray:
    """Normalize a single modeshape or set of modeshapes (real part only).

    .. deprecated:: 1.0.0
       Use :func:`normalize_modeshape_unit_norm_vector_length` instead, which supports both real and complex modeshapes.
       This function will be removed in a future version.

    This function normalizes only the real part of the modeshape(s) to unit norm.
    For a single modeshape, the 2-norm is used.
    For multiple modeshapes, each modeshape is normalized independently.

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]

    Returns
    -------
    np.ndarray
        Normalized modeshape(s) with the same shape as input

    Raises
    ------
    ValueError
        If modeshape has incorrect dimensions
    """
    warnings.warn(
        "normalize_modeshape is deprecated and will be removed in a future version. "
        "Use normalize_modeshape_unit_norm_vector_length instead.",
        DeprecationWarning,
        stacklevel=2,
    )

    ModalValidator.validate(modeshape)
    return normalize_modeshape_unit_norm_vector_length(modeshape.real)

normalize_modeshape_reference_dof(modeshape: np.ndarray, ref_dof_index: int) -> np.ndarray

Normalize a single modeshape or set of modeshapes using a reference degree of freedom (DOF).

This normalization method scales each mode shape vector such that the component at the specified reference DOF index is normalized to unity (value of 1). This is useful when you want to scale mode shapes relative to a specific point on the structure, often a sensor location in experimental modal analysis. Works for both real and complex mode shapes.

Math:

For a mode shape :math:\vec{\Phi}_r and a chosen reference DOF index k (ref_dof_index), the normalized mode shape :math:\vec{\Phi}_{r, normalized} is:

.. math:: \vec{\Phi}{r, normalized} = \frac{\vec{\Phi}_r}{\Phi{kr}}

Where :math:\Phi_{kr} is the component of the mode shape vector at the reference DOF index k.

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required
ref_dof_index int

Index of the reference degree of freedom (0-indexed). Must be a valid index within the modeshape dimension (0 <= ref_dof_index < n_dof).

required

Returns:

Type Description
ndarray

Normalized modeshape(s) with the same shape as input. Each mode shape will have value 1 at the reference DOF.

Raises:

Type Description
ValueError

If modeshape has incorrect dimensions or ref_dof_index is invalid

Source code in postmodal/manipulation/normalize.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def normalize_modeshape_reference_dof(modeshape: np.ndarray, ref_dof_index: int) -> np.ndarray:
    """Normalize a single modeshape or set of modeshapes using a reference degree of freedom (DOF).

    This normalization method scales each mode shape vector such that the component at the specified
    reference DOF index is normalized to unity (value of 1). This is useful when you want to scale mode shapes
    relative to a specific point on the structure, often a sensor location in experimental modal analysis.
    Works for both real and complex mode shapes.

    Math:
    -----
    For a mode shape :math:`\\vec{\\Phi}_r` and a chosen reference DOF index `k` (ref_dof_index),
    the normalized mode shape :math:`\\vec{\\Phi}_{r, normalized}` is:

    .. math::
        \\vec{\\Phi}_{r, normalized} = \\frac{\\vec{\\Phi}_r}{\\Phi_{kr}}

    Where :math:`\\Phi_{kr}` is the component of the mode shape vector at the reference DOF index `k`.

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.
    ref_dof_index : int
        Index of the reference degree of freedom (0-indexed).
        Must be a valid index within the modeshape dimension (0 <= ref_dof_index < n_dof).

    Returns
    -------
    np.ndarray
        Normalized modeshape(s) with the same shape as input.
        Each mode shape will have value 1 at the reference DOF.

    Raises
    ------
    ValueError
        If modeshape has incorrect dimensions or ref_dof_index is invalid
    """
    ModalValidator.validate(modeshape)

    if not isinstance(ref_dof_index, int):
        raise TypeError("ref_dof_index must be an integer")

    if modeshape.ndim == 1:
        if not 0 <= ref_dof_index < modeshape.shape[0]:
            raise ValueError(
                f"ref_dof_index {ref_dof_index} is out of bounds for modeshape of length {modeshape.shape[0]}"
            )
        ref_val = modeshape[ref_dof_index]
        return modeshape / ref_val if ref_val != 0 else modeshape

    # modeshape.ndim == 2
    if not 0 <= ref_dof_index < modeshape.shape[1]:
        raise ValueError(f"ref_dof_index {ref_dof_index} is out of bounds for modeshape with {modeshape.shape[1]} DOFs")
    ref_vals = modeshape[:, ref_dof_index]
    return np.where(ref_vals[:, np.newaxis] != 0, modeshape / ref_vals[:, np.newaxis], modeshape)

normalize_modeshape_unit_norm_max_amplitude(modeshape: np.ndarray) -> np.ndarray

Normalize a single modeshape or set of modeshapes to unit norm (maximum amplitude).

This normalization method scales each mode shape vector such that the component with the maximum absolute value is normalized to unity (magnitude of 1). This method emphasizes the largest displacement component and is useful when you want to scale mode shapes based on their peak amplitude. Works for both real and complex mode shapes.

Math:

For a mode shape :math:\vec{\Phi}_r, let :math:\Phi_{max, r} be the component with the maximum absolute value in :math:\vec{\Phi}_r. The normalized mode shape :math:\vec{\Phi}_{r, normalized} is:

.. math:: \vec{\Phi}{r, normalized} = \frac{\vec{\Phi}_r}{\Phi{max, r}}

Note: In case of multiple components having the same maximum magnitude, the first encountered component with maximum magnitude is used as the reference.

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required

Returns:

Type Description
ndarray

Normalized modeshape(s) with the same shape as input. Each mode shape will have maximum amplitude of 1.

Raises:

Type Description
ValueError

If modeshape has incorrect dimensions

Source code in postmodal/manipulation/normalize.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def normalize_modeshape_unit_norm_max_amplitude(modeshape: np.ndarray) -> np.ndarray:
    """Normalize a single modeshape or set of modeshapes to unit norm (maximum amplitude).

    This normalization method scales each mode shape vector such that the component with the maximum absolute
    value is normalized to unity (magnitude of 1). This method emphasizes the largest displacement component and
    is useful when you want to scale mode shapes based on their peak amplitude. Works for both real and complex mode shapes.

    Math:
    -----
    For a mode shape :math:`\\vec{\\Phi}_r`, let :math:`\\Phi_{max, r}` be the component with the maximum absolute value in :math:`\\vec{\\Phi}_r`.
    The normalized mode shape :math:`\\vec{\\Phi}_{r, normalized}` is:

    .. math::
        \\vec{\\Phi}_{r, normalized} = \\frac{\\vec{\\Phi}_r}{\\Phi_{max, r}}

    Note: In case of multiple components having the same maximum magnitude, the first encountered component with maximum magnitude is used as the reference.

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.

    Returns
    -------
    np.ndarray
        Normalized modeshape(s) with the same shape as input.
        Each mode shape will have maximum amplitude of 1.

    Raises
    ------
    ValueError
        If modeshape has incorrect dimensions
    """
    ModalValidator.validate(modeshape)

    if modeshape.ndim == 1:
        max_val = np.max(np.abs(modeshape))
        return modeshape / max_val if max_val != 0 else modeshape

    # modeshape.ndim == 2
    max_vals = np.max(np.abs(modeshape), axis=1)
    return np.where(max_vals[:, np.newaxis] != 0, modeshape / max_vals[:, np.newaxis], modeshape)

normalize_modeshape_unit_norm_vector_length(modeshape: np.ndarray) -> np.ndarray

Normalize a single modeshape or set of modeshapes to unit norm (vector length).

This normalization method scales each mode shape vector such that its Euclidean norm (vector length, 2-norm) becomes unity (length of 1). This is a mathematically simple and common method for general mode shape normalization, especially when focusing on the shape itself rather than physical scaling. Works for both real and complex mode shapes.

Math:

For a mode shape :math:\vec{\Phi}_r, the normalized mode shape :math:\vec{\Phi}_{r, normalized} is:

.. math:: \vec{\Phi}_{r, normalized} = \frac{\vec{\Phi}_r}{||\vec{\Phi}_r||_2}

Where :math:||\vec{\Phi}_r||_2 is the Euclidean norm (2-norm) of the mode shape vector.

Parameters:

Name Type Description Default
modeshape ndarray

Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof]. Can be real or complex-valued.

required

Returns:

Type Description
ndarray

Normalized modeshape(s) with the same shape as input. Each mode shape vector will have unit length (2-norm = 1).

Raises:

Type Description
ValueError

If modeshape has incorrect dimensions

Source code in postmodal/manipulation/normalize.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def normalize_modeshape_unit_norm_vector_length(modeshape: np.ndarray) -> np.ndarray:
    """Normalize a single modeshape or set of modeshapes to unit norm (vector length).

    This normalization method scales each mode shape vector such that its Euclidean norm (vector length, 2-norm)
    becomes unity (length of 1). This is a mathematically simple and common method for general mode shape normalization,
    especially when focusing on the shape itself rather than physical scaling. Works for both real and complex mode shapes.

    Math:
    -----
    For a mode shape :math:`\\vec{\\Phi}_r`, the normalized mode shape :math:`\\vec{\\Phi}_{r, normalized}` is:

    .. math::
        \\vec{\\Phi}_{r, normalized} = \\frac{\\vec{\\Phi}_r}{||\\vec{\\Phi}_r||_2}

    Where :math:`||\\vec{\\Phi}_r||_2` is the Euclidean norm (2-norm) of the mode shape vector.

    Parameters
    ----------
    modeshape : np.ndarray
        Single modeshape [n_dof] or set of modeshapes [n_modes x n_dof].
        Can be real or complex-valued.

    Returns
    -------
    np.ndarray
        Normalized modeshape(s) with the same shape as input.
        Each mode shape vector will have unit length (2-norm = 1).

    Raises
    ------
    ValueError
        If modeshape has incorrect dimensions
    """
    ModalValidator.validate(modeshape)

    if modeshape.ndim == 1:
        norm_val = np.linalg.norm(modeshape)
        return modeshape / norm_val if norm_val != 0 else modeshape

    # modeshape.ndim == 2
    norms = np.linalg.norm(modeshape, axis=1)
    return np.where(norms[:, np.newaxis] != 0, modeshape / norms[:, np.newaxis], modeshape)

normalize_phase(modeshape: NDArray[np.complex128], method: str = 'reference', reference_dof: int | None = None) -> NDArray[np.complex128]

Normalize the phase of a modeshape.

This function normalizes the phase of a modeshape using one of several methods: - "reference": Align phase to a reference DOF - "unwrap": Unwrap phase to ensure continuity - "both": Apply both reference alignment and unwrapping

Parameters:

Name Type Description Default
modeshape ndarray

Complex modeshape array [n_dof]

required
method str

Phase normalization method, by default "reference"

'reference'
reference_dof Optional[int]

Index of the reference DOF for reference method, by default None

None

Returns:

Type Description
ndarray

Phase-normalized modeshape [n_dof]

Raises:

Type Description
ValueError

If method is not one of ["reference", "unwrap", "both"]

Source code in postmodal/manipulation/phase.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def normalize_phase(
    modeshape: NDArray[np.complex128],
    method: str = "reference",
    reference_dof: int | None = None,
) -> NDArray[np.complex128]:
    """Normalize the phase of a modeshape.

    This function normalizes the phase of a modeshape using one of several methods:
    - "reference": Align phase to a reference DOF
    - "unwrap": Unwrap phase to ensure continuity
    - "both": Apply both reference alignment and unwrapping

    Parameters
    ----------
    modeshape : np.ndarray
        Complex modeshape array [n_dof]
    method : str, optional
        Phase normalization method, by default "reference"
    reference_dof : Optional[int], optional
        Index of the reference DOF for reference method, by default None

    Returns
    -------
    np.ndarray
        Phase-normalized modeshape [n_dof]

    Raises
    ------
    ValueError
        If method is not one of ["reference", "unwrap", "both"]
    """
    if method not in ["reference", "unwrap", "both"]:
        raise ValueError('method must be one of ["reference", "unwrap", "both"]')

    result = modeshape.copy()

    if method in ["reference", "both"]:
        result = align_phase(result, reference_dof)

    if method in ["unwrap", "both"]:
        result = unwrap_phase(result)

    return result

optimize_conversion(modeshape: np.ndarray, method: str = 'phase', reference_dof: int | None = None) -> tuple[np.ndarray, float, float]

Optimize the conversion of a complex modeshape to real.

This function finds the optimal real-valued modeshape by minimizing the conversion error. It tries different reference DOFs and returns the one with the smallest error.

Parameters:

Name Type Description Default
modeshape ndarray

Complex modeshape array [n_dof]

required
method str

Conversion method, by default "phase"

'phase'
reference_dof Optional[int]

Initial reference DOF to try, by default None

None

Returns:

Type Description
tuple[ndarray, float, float]

Tuple containing (optimal_modeshape, magnitude_error, phase_error) - optimal_modeshape: Best real-valued modeshape - magnitude_error: Magnitude error for optimal conversion - phase_error: Phase error for optimal conversion

Source code in postmodal/manipulation/complex_to_real.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def optimize_conversion(
    modeshape: np.ndarray,
    method: str = "phase",
    reference_dof: int | None = None,
) -> tuple[np.ndarray, float, float]:
    """Optimize the conversion of a complex modeshape to real.

    This function finds the optimal real-valued modeshape by minimizing
    the conversion error. It tries different reference DOFs and returns
    the one with the smallest error.

    Parameters
    ----------
    modeshape : np.ndarray
        Complex modeshape array [n_dof]
    method : str, optional
        Conversion method, by default "phase"
    reference_dof : Optional[int], optional
        Initial reference DOF to try, by default None

    Returns
    -------
    tuple[np.ndarray, float, float]
        Tuple containing (optimal_modeshape, magnitude_error, phase_error)
        - optimal_modeshape: Best real-valued modeshape
        - magnitude_error: Magnitude error for optimal conversion
        - phase_error: Phase error for optimal conversion
    """
    n_dof = len(modeshape)
    best_error = float("inf")
    # Initialize with a default value to ensure we never return None
    best_modeshape = complex_to_real(modeshape, method, reference_dof)
    best_mag_error = 0.0
    best_phase_error = 0.0

    # Try each DOF as reference
    for ref in range(n_dof):
        real_phi = complex_to_real(modeshape, method, ref)
        mag_error, phase_error = calculate_conversion_error(modeshape, real_phi)
        total_error = mag_error + phase_error

        if total_error < best_error:
            best_error = total_error
            best_modeshape = real_phi
            best_mag_error = mag_error
            best_phase_error = phase_error

    return best_modeshape, best_mag_error, best_phase_error

unwrap_phase(modeshape: NDArray[np.complex128], axis: int | None = None) -> NDArray[np.complex128]

Unwrap the phase of a modeshape.

Source code in postmodal/manipulation/phase.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def unwrap_phase(
    modeshape: NDArray[np.complex128],
    axis: int | None = None,
) -> NDArray[np.complex128]:
    """Unwrap the phase of a modeshape."""
    # Original phases
    phase = np.angle(modeshape)

    # Unwrap the phases - ensure we're using the correct axis
    axis_to_use = 0 if axis is None and phase.ndim > 0 else axis if axis is not None else -1

    unwrapped_phase = np.unwrap(phase, axis=axis_to_use)

    # Important: After unwrapping, phases may be outside [-π, π]
    # but we need to convert back to complex numbers
    magnitude = np.abs(modeshape)
    result = magnitude * np.exp(1j * unwrapped_phase)

    return cast(NDArray[np.complex128], result)

options: show_root_heading: true show_source: true

Visualization

postmodal.visualization

MAC matrix visualization module.

plot_mac_matrix(mac_matrix: np.ndarray, x_tick_labels: list[str], y_tick_labels: list[str], text_color_variable: bool = True, invert_scale: bool = False, ax: Axes | None = None) -> tuple[Figure, Axes]

Plot the MAC matrix.

Parameters:

Name Type Description Default
mac_matrix ndarray

MAC matrix [n_modes_1 x n_modes_2]

required
x_tick_labels list[str]

Labels for x-axis ticks (columns of the MAC matrix)

required
y_tick_labels list[str]

Labels for y-axis ticks (rows of the MAC matrix)

required
text_color_variable bool

Whether to vary text color based on MAC value, by default True

True
invert_scale bool

Whether to invert the colormap scale, by default False

False
ax Optional[Axes]

Matplotlib axes to plot on, by default None

None

Returns:

Type Description
tuple[Figure, Axes]

The figure and axes objects

Raises:

Type Description
ValueError

If dimensions of inputs are incompatible

Source code in postmodal/visualization.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def plot_mac_matrix(
    mac_matrix: np.ndarray,
    x_tick_labels: list[str],
    y_tick_labels: list[str],
    text_color_variable: bool = True,
    invert_scale: bool = False,
    ax: Axes | None = None,
) -> tuple[Figure, Axes]:
    """Plot the MAC matrix.

    Parameters
    ----------
    mac_matrix : np.ndarray
        MAC matrix [n_modes_1 x n_modes_2]
    x_tick_labels : list[str]
        Labels for x-axis ticks (columns of the MAC matrix)
    y_tick_labels : list[str]
        Labels for y-axis ticks (rows of the MAC matrix)
    text_color_variable : bool, optional
        Whether to vary text color based on MAC value, by default True
    invert_scale : bool, optional
        Whether to invert the colormap scale, by default False
    ax : Optional[Axes], optional
        Matplotlib axes to plot on, by default None

    Returns
    -------
    tuple[Figure, Axes]
        The figure and axes objects

    Raises
    ------
    ValueError
        If dimensions of inputs are incompatible
    """
    if ax is None:
        fig, ax = plt.subplots()
    else:
        fig = cast(Figure, ax.figure)

    # Validate dimensions
    if len(x_tick_labels) != mac_matrix.shape[1]:
        raise ValueError("Number of x_tick_labels must match number of columns in mac_matrix")
    if len(y_tick_labels) != mac_matrix.shape[0]:
        raise ValueError("Number of y_tick_labels must match number of rows in mac_matrix")

    # Set up colormap
    cmap = "Greys_r" if invert_scale else "Greys"
    norm = Normalize(vmin=0, vmax=1)

    # Plot matrix
    _ = ax.matshow(mac_matrix, norm=norm, cmap=cmap)

    # Configure grid
    ax.grid(False, which="major")
    ax.xaxis.set_minor_locator(FixedLocator([x + 0.5 for x in range(mac_matrix.shape[1] - 1)]))
    ax.yaxis.set_minor_locator(FixedLocator([x + 0.5 for x in range(mac_matrix.shape[0] - 1)]))

    # Configure ticks and labels
    ax.xaxis.set_major_locator(FixedLocator(range(mac_matrix.shape[1])))
    ax.set_xticklabels(x_tick_labels, rotation=90)
    ax.yaxis.set_major_locator(FixedLocator(range(mac_matrix.shape[0])))
    ax.yaxis.set_major_formatter(FixedFormatter(y_tick_labels))

    # Add grid lines
    ax.grid(True, which="minor", color="0.7", linestyle="-", linewidth=1.5)

    # Configure labels
    ax.xaxis.set_label_position("top")
    # ax.set(xlabel="Numerical modes", ylabel="OMA modes")

    # Add text annotations
    def _get_text_color(value: float) -> str:
        """Return text color based on MAC value.

        Parameters
        ----------
        value : float
            The MAC value to determine text color for

        Returns
        -------
        str
            The text color as a string
        """
        if invert_scale:
            return "0.8" if value < 0.4 else "0.2"
        else:
            return "0.8" if value > 0.6 else "0.2"

    for irow in range(mac_matrix.shape[0]):
        for jcol in range(mac_matrix.shape[1]):
            ax.text(
                jcol + 0.0,
                irow + 0.0,
                f"{mac_matrix[irow, jcol]:.2f}",
                color=("C0" if not text_color_variable else _get_text_color(mac_matrix[irow, jcol])),
                size=10,
                fontweight="bold",
                va="center",
                ha="center",
            )

    return fig, ax

plot_modeshape_complexity(modeshape: np.ndarray, ax: PolarAxes | None = None) -> tuple[Figure, PolarAxes]

Plot the complexity of a modeshape using a polar Argand diagram.

Parameters:

Name Type Description Default
modeshape ndarray

Complex modeshape vector

required
ax Optional[PolarAxes]

Matplotlib polar axes to plot on, by default None

None

Returns:

Type Description
tuple[Figure, PolarAxes]

The figure and axes containing the complexity plot

Raises:

Type Description
ValueError

If the provided axes is not a polar projection

Source code in postmodal/visualization.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def plot_modeshape_complexity(
    modeshape: np.ndarray,
    ax: PolarAxes | None = None,
) -> tuple[Figure, PolarAxes]:
    """Plot the complexity of a modeshape using a polar Argand diagram.

    Parameters
    ----------
    modeshape : np.ndarray
        Complex modeshape vector
    ax : Optional[PolarAxes], optional
        Matplotlib polar axes to plot on, by default None

    Returns
    -------
    tuple[Figure, PolarAxes]
        The figure and axes containing the complexity plot

    Raises
    ------
    ValueError
        If the provided axes is not a polar projection
    """
    if ax is None:
        fig = plt.figure()
        ax = cast(PolarAxes, fig.add_subplot(111, projection="polar"))
    else:
        fig = cast(Figure, ax.get_figure())
        if ax.name != "polar":
            raise ValueError("Axes must be polar projection")

    # Calculate magnitude and phase
    magnitude = np.abs(modeshape)
    phase = np.angle(modeshape, deg=True)

    # Plot the complex mode shape points
    ax.scatter(np.radians(phase), magnitude, marker="o")

    # Add lines from origin to each point
    for i in range(len(modeshape)):
        ax.plot([0, np.radians(phase[i])], [0, magnitude[i]], "k--", alpha=0.3)

    # Add grid
    ax.grid(True)

    # Set the position of the radial tick labels to 90 degrees
    ax.set_rlabel_position(90)

    return fig, ax

plot_modeshape_complexity_grid(frequencies: np.ndarray, modeshapes: np.ndarray, figsize: tuple[float, float] | None = None, n_row: int | None = None, n_col: int | None = None, hspace: float = 0.4, wspace: float = 0.4) -> tuple[Figure, np.ndarray]

Plot multiple modeshape complexity plots in a grid layout.

Parameters:

Name Type Description Default
frequencies ndarray

Array of frequencies for each mode

required
modeshapes ndarray

Array of complex modeshapes

required
figsize Optional[tuple[float, float]]

Figure size, by default None

None
n_row Optional[int]

Number of rows in grid, by default None

None
n_col Optional[int]

Number of columns in grid, by default None

None
hspace float

Horizontal spacing between plots, by default 0.4

0.4
wspace float

Vertical spacing between plots, by default 0.4

0.4

Returns:

Type Description
tuple[Figure, ndarray]

The figure and array of axes containing the complexity plots

Source code in postmodal/visualization.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def plot_modeshape_complexity_grid(
    frequencies: np.ndarray,
    modeshapes: np.ndarray,
    figsize: tuple[float, float] | None = None,
    n_row: int | None = None,
    n_col: int | None = None,
    hspace: float = 0.4,
    wspace: float = 0.4,
) -> tuple[Figure, np.ndarray]:
    """Plot multiple modeshape complexity plots in a grid layout.

    Parameters
    ----------
    frequencies : np.ndarray
        Array of frequencies for each mode
    modeshapes : np.ndarray
        Array of complex modeshapes
    figsize : Optional[tuple[float, float]], optional
        Figure size, by default None
    n_row : Optional[int], optional
        Number of rows in grid, by default None
    n_col : Optional[int], optional
        Number of columns in grid, by default None
    hspace : float, optional
        Horizontal spacing between plots, by default 0.4
    wspace : float, optional
        Vertical spacing between plots, by default 0.4

    Returns
    -------
    tuple[Figure, np.ndarray]
        The figure and array of axes containing the complexity plots
    """
    n_modes = len(frequencies)

    if n_row is None or n_col is None:
        n_row, n_col = _calculateGridLayout(n_modes)

    if figsize is None:
        figsize = (4 * n_col, 4 * n_row)

    fig, axs = plt.subplots(
        figsize=figsize,
        nrows=n_row,
        ncols=n_col,
        gridspec_kw={"hspace": hspace, "wspace": wspace},
        sharex=False,
        sharey=False,
        subplot_kw={"projection": "polar"},
    )

    for i in range(n_modes):
        ax = cast(PolarAxes, axs.flatten()[i])
        _, _ = plot_modeshape_complexity(modeshapes[i], ax=ax)
        ax.set_title(f"{frequencies[i]:.2f} Hz")

    return fig, axs

options: show_root_heading: true show_source: true