"""
Module for eigenvalue analysis.
"""
import logging
from math import ceil, pi
from typing import Iterable
import numpy as np
import scipy.io
from scipy.linalg import solve
from andes.io.txt import dump_data
from andes.plot import set_latex, set_style
from andes.routines.base import BaseRoutine
from andes.shared import div, matrix, plt, sparse, spdiag, spmatrix
from andes.utils.misc import elapsed
from andes.variables.report import report_info
logger = logging.getLogger(__name__)
DPI = None
[docs]class EIG(BaseRoutine):
"""
Eigenvalue analysis routine
"""
[docs] def __init__(self, system, config):
super().__init__(system=system, config=config)
self.config.add(plot=0, tol=1e-6, gy_tol=1e-6)
self.config.add_extra("_help",
plot="show plot after computation",
tol="numerical tolerance to treat eigenvalues as zeros",
gy_tol="row norm threshold for eliminating dead algebraic variables")
self.config.add_extra("_alt", plot=(0, 1))
# internal flags and storage
self.As = None # reduced state matrix
self.mu = None # eigenvalues
self.N = None # right eigenvectors
self.W = None # left eigenvectors
self.pfactors = None
# --- related to states with zero time constants (zs) ---
self.zstate_idx = np.array([], dtype=int)
# --- related to dead algebraic variables ---
self.dead_algeb_idx = np.array([], dtype=int)
# --- statistics --
self.n_positive = 0
self.n_zeros = 0
self.n_negative = 0
self.x_name = []
self.x_tex_name = []
def calc_As(self, dense=True):
r"""
Return state matrix and store to ``self.As``.
Notes
-----
For systems in the mass-matrix formulation,
.. math ::
T \dot{x} = f(x, y) \\
0 = g(x, y)
Assume `T` is non-singular, the state matrix is calculated from
.. math ::
A_s = T^{-1} (f_x - f_y * g_y^{-1} * g_x)
States with zero time constants satisfy :math:`0 = f(x, y)` and
are treated as algebraic equations. They are folded into the
algebraic system before reduction so that a single Schur
complement handles both original algebraic variables and
zero-Tf states uniformly.
Returns
-------
kvxopt.matrix
state matrix
"""
dae = self.system.dae
self.find_zero_states()
self.x_name = np.array(dae.x_name)
self.x_tex_name = np.array(dae.x_tex_name)
fx, fy, gx, gy = dae.fx, dae.fy, dae.gx, dae.gy
Tf = dae.Tf
# Fold zero-Tf states into the algebraic system
if len(self.zstate_idx) > 0:
fx, fy, gx, gy, Tf = self._fold_zstates(fx, fy, gx, gy, Tf)
# Find and eliminate dead algebraic variables (including dead zero-Tf states)
self.find_dead_algebs(gy, gx)
# Extract state constraints (0 = C δx) before dead rows are removed
C = self._extract_state_constraints(gx)
if len(self.dead_algeb_idx) > 0:
fx, fy, gx, gy = self._eliminate_algebs(fx, fy, gx, gy)
# Regularize dead columns (variables absent from all equations)
self._regularize_dead_columns(gy)
self.As = self._reduce(fx, fy, gx, gy, Tf, dense=dense)
# Use state constraints to eliminate dependent states
if C is not None:
self.As = self._apply_state_constraints(self.As, C)
if len(self.x_name) < dae.n:
n_as = len(self.x_name)
logger.info("State matrix is %d x %d (reduced from %d states).", n_as, n_as, dae.n)
return self.As
def _reduce(self, fx, fy, gx, gy, Tf, dense=True):
"""
Reduce algebraic equations (or states associated with zero time constants).
Returns
-------
spmatrix
The reduced state matrix
"""
self.gyx = matrix(gx)
self.solver.linsolve(gy, self.gyx)
Tfnz = Tf + np.ones_like(Tf) * np.equal(Tf, 0.0)
iTf = spdiag((1 / Tfnz).tolist())
self.fxy = (fx - fy * self.gyx)
if dense:
return iTf * self.fxy
else:
return sparse(iTf * self.fxy)
def _fold_zstates(self, fx, fy, gx, gy, Tf):
"""
Fold zero-Tf states into the algebraic system.
States with ``Tf=0`` satisfy ``0 = f(x, y)`` — algebraic, not
differential. This method moves their equation rows from
``(fx, fy)`` into an expanded ``(gx, gy)`` and returns reduced
Jacobians for the remaining (non-zero-Tf) differential states.
Parameters
----------
fx, fy, gx, gy : spmatrix
Original Jacobian sub-matrices (n×n, n×m, m×n, m×m).
Tf : np.ndarray
Time-constant vector (length n).
Returns
-------
fx', fy', gx', gy' : spmatrix
Restructured Jacobians (n'×n', n'×m', m'×n', m'×m')
where n' = n - nz, m' = m + nz.
Tf' : np.ndarray
Time constants for non-zero-Tf states only.
"""
n = fx.size[0]
zs = self.zstate_idx
nz_mask = np.ones(n, dtype=bool)
nz_mask[zs] = False
nz_idx = np.where(nz_mask)[0]
# Selection matrices for non-zero-Tf states (rows/cols of fx)
Sx, Sx_T = self._selection_matrices(list(nz_idx), n)
# Selection matrices for zero-Tf states (rows/cols of fx)
Sz, Sz_T = self._selection_matrices(list(zs), n)
# Build expanded Jacobians:
# fx' = Sx * fx * Sx_T (n' x n')
# fy' = [Sx * fy | Sx * fx * Sz_T] (n' x m')
# gx' = [gx * Sx_T ; Sz * fx * Sx_T] (m' x n')
# gy' = [[gy, gx * Sz_T], [Sz * fy, Sz * fx * Sz_T]] (m' x m')
fx_new = Sx * fx * Sx_T # n' x n'
fy_left = Sx * fy # n' x m
fy_right = Sx * fx * Sz_T # n' x nz
fy_new = sparse([[fy_left], [fy_right]]) # n' x (m + nz)
gx_top = gx * Sx_T # m x n'
gx_bot = Sz * fx * Sx_T # nz x n'
gx_new = sparse([gx_top, gx_bot]) # (m + nz) x n'
gy_new = sparse([[gy, Sz * fy], # (m + nz) x (m + nz)
[gx * Sz_T, Sz * fx * Sz_T]])
# Keep only non-zero-Tf state names
self.x_name = self.x_name[nz_idx]
self.x_tex_name = self.x_tex_name[nz_idx]
return fx_new, fy_new, gx_new, gy_new, Tf[nz_idx]
def calc_eig(self, As=None):
"""
Calculate eigenvalues and right eigen vectors.
This function is a wrapper to ``np.linalg.eig``.
Results are returned but not stored to ``EIG``.
Returns
-------
np.array(dtype=complex)
eigenvalues
np.array()
right eigenvectors
"""
if As is None:
As = self.As
# `mu`: eigenvalues, `N`: right eigenvectors with each column corr. to one eigvalue
mu, N = np.linalg.eig(As)
return mu, N
def _store_stats(self):
"""
Count and store the number of eigenvalues with positive, zero,
and negative real parts using ``self.mu``.
"""
mu_real = self.mu.real
self.n_positive = np.count_nonzero(mu_real > self.config.tol)
self.n_zeros = np.count_nonzero(abs(mu_real) <= self.config.tol)
self.n_negative = np.count_nonzero(mu_real < self.config.tol)
return True
def calc_pfactor(self, As=None):
"""
Compute participation factor of states in eigenvalues.
Each row in the participation factor correspond to one state,
and each column correspond to one mode.
Parameters
----------
As : np.array or None
State matrix to process. If None, use ``self.As``.
Returns
-------
np.array(dtype=complex)
eigenvalues
np.array
participation factor matrix
"""
mu, N = self.calc_eig(As)
n_state = len(mu)
# --- calculate the left eig vector and store to ``W```
# based on orthogonality that `W.T @ N = I`,
# left eigenvector is `inv(N)^T`
Weye = np.eye(n_state)
n_zero = np.count_nonzero(np.abs(mu) < self.config.tol)
if n_zero > 0:
logger.debug("Eigenvector matrix has %d zero-eigenvalue modes "
"(e.g. PLL angles, integrators); participation "
"factors for these modes may be inaccurate.", n_zero)
WT = solve(N, Weye, overwrite_b=True)
W = WT.T
# --- calculate participation factor ---
pfactor = np.abs(W) * np.abs(N)
b = np.ones(n_state)
W_abs = b @ pfactor
pfactor = pfactor.T
# --- normalize participation factor ---
for item in range(n_state):
pfactor[:, item] /= W_abs[item]
pfactor = np.round(pfactor, 5)
return mu, pfactor, N, W
def summary(self):
"""
Print out a summary to ``logger.info``.
"""
out = list()
out.append('')
out.append('-> Eigenvalue Analysis:')
out_str = '\n'.join(out)
logger.info(out_str)
def find_zero_states(self):
"""
Find the indices of states associated with zero time constants in ``x``.
"""
system = self.system
self.zstate_idx = np.array([], dtype=int)
if sum(system.dae.Tf != 0) != len(system.dae.Tf):
self.zstate_idx = np.where(system.dae.Tf == 0)[0]
logger.debug("%d states are associated with zero time constants.", len(self.zstate_idx))
logger.debug([system.dae.x_name[i] for i in self.zstate_idx])
@staticmethod
def _sparse_row_norms(mat):
"""Compute L2 row norms of a sparse matrix using triplet data."""
m = mat.size[0]
row_norm_sq = np.zeros(m)
for i, v in zip(mat.I, mat.V):
row_norm_sq[i] += v * v
return np.sqrt(row_norm_sq)
@staticmethod
def _sparse_col_norms(mat):
"""Compute L2 column norms of a sparse matrix using triplet data."""
n = mat.size[1]
col_norm_sq = np.zeros(n)
for j, v in zip(mat.J, mat.V):
col_norm_sq[j] += v * v
return np.sqrt(col_norm_sq)
@staticmethod
def _selection_matrices(alive, total):
"""Build rectangular selection matrix ``S`` and its transpose.
``S`` is (n_alive x total) and selects only the *alive* rows/cols.
"""
n = len(alive)
alive_int = [int(i) for i in alive]
S = spmatrix([1.0] * n, list(range(n)), alive_int, (n, total))
S_T = spmatrix([1.0] * n, alive_int, list(range(n)), (total, n))
return S, S_T
def find_dead_algebs(self, gy, gx):
"""
Find algebraic variables with near-zero ``gy`` rows.
These variables have degenerate equations (only ``diag_eps``
on the diagonal) and cause ``gy`` to be singular. They are
eliminated before computing the state matrix.
Dead rows with nonzero ``gx`` coupling encode state constraints
``0 = C δx``, which are extracted by ``_extract_state_constraints``
and used to reduce the state dimension in
``_apply_state_constraints``.
Parameters
----------
gy : spmatrix
The algebraic Jacobian to scan. After ``_fold_zstates`` this
includes both original algebraic variables and zero-Tf states.
gx : spmatrix
The algebraic-to-state Jacobian, checked for state constraints.
"""
self.dead_algeb_idx = np.array([], dtype=int)
dead = np.where(self._sparse_row_norms(gy) < self.config.gy_tol)[0]
if len(dead) > 0:
self.dead_algeb_idx = dead
logger.debug("%d algebraic variables have near-zero gy coupling "
"and will be eliminated.", len(dead))
# Check if any eliminated rows encode state constraints (gx ≠ 0)
gx_norms = self._sparse_row_norms(gx)
coupled = dead[gx_norms[dead] >= self.config.gy_tol]
if len(coupled) > 0:
logger.debug(
"%d of these encode state constraints (nonzero gx) "
"and will be used to reduce the state dimension.",
len(coupled))
def _eliminate_algebs(self, fx, fy, gx, gy):
"""
Remove dead algebraic variables from the Jacobian matrices.
Builds a rectangular selection matrix ``S`` (m_alive x m) that
keeps only the alive algebraic rows/cols, then returns the
reduced Jacobians.
Returns ``fx, fy_alive, gx_alive, gy_alive``.
"""
m = gy.size[0]
alive = sorted(set(range(m)) - set(self.dead_algeb_idx))
S, S_T = self._selection_matrices(alive, m)
return fx, fy * S_T, S * gx, S * gy * S_T
def _regularize_dead_columns(self, gy):
"""
Regularize algebraic variables with near-zero ``gy`` columns.
A near-zero column means the variable does not appear in any
algebraic equation. Setting its diagonal to 1.0 makes ``gy``
non-singular and forces the variable's perturbation to zero
in the Schur complement, which is physically correct for an
unconstrained variable.
This handles the complementary case to ``find_dead_algebs``
(dead rows). Typical cause: a zero-Tf state whose
self-coupling vanishes (e.g. ``Lag2ndOrd.x`` with ``T1=0``).
"""
col_norms = self._sparse_col_norms(gy)
dead_cols = np.where(col_norms < self.config.gy_tol)[0]
if len(dead_cols) > 0:
for j in dead_cols:
gy[int(j), int(j)] = 1.0
logger.debug(
"%d algebraic variables with near-zero gy columns "
"regularized (diagonal set to 1).", len(dead_cols))
def _extract_state_constraints(self, gx):
"""
Extract state constraint rows from dead algebraic variables.
Dead algebraic rows with ``gy ≈ 0`` but ``gx ≠ 0`` encode
linear constraints ``0 = C δx`` on the state perturbations.
This method extracts those rows from ``gx`` as a constraint
matrix ``C`` before dead rows are eliminated.
Parameters
----------
gx : spmatrix
Algebraic-to-state Jacobian (possibly expanded by folding).
Returns
-------
spmatrix or None
Constraint matrix ``C`` of shape (nc × n), or ``None``
if no dead rows have nonzero ``gx`` coupling.
"""
if len(self.dead_algeb_idx) == 0:
return None
gx_norms = self._sparse_row_norms(gx)
coupled_mask = gx_norms[self.dead_algeb_idx] >= self.config.gy_tol
coupled = self.dead_algeb_idx[coupled_mask]
if len(coupled) == 0:
return None
m = gx.size[0]
S, _ = self._selection_matrices([int(i) for i in coupled], m)
return S * gx
def _apply_state_constraints(self, As, C):
r"""
Reduce the state matrix using algebraic constraints on states.
Dead algebraic rows with ``gy ≈ 0`` but ``gx ≠ 0`` impose
constraints ``0 = C δx`` on the linearized perturbations.
Each constraint eliminates one dependent state:
.. math::
\delta x_d = -C_d^{-1} C_f \, \delta x_f
where *d* denotes dependent (pivot) states and *f* the remaining
free states. The reduced state matrix is
.. math::
A_{\text{red}} = A_{\text{full}}[\text{free}, :] \; R
where ``R`` is the substitution matrix satisfying ``C R = 0``.
Parameters
----------
As : matrix or ndarray
Full state matrix (n × n) from ``_reduce``, already
includes ``T^{-1}``.
C : spmatrix
Constraint matrix (nc × n) from dead algebraic rows.
Returns
-------
ndarray
Reduced state matrix of size (n - nc) × (n - nc).
"""
n = As.size[0] # kvxopt matrix .size is (rows, cols)
nc = C.size[0]
# Convert sparse C to dense numpy
C_np = np.zeros((nc, n))
for k in range(len(C.V)):
C_np[C.I[k], C.J[k]] = C.V[k]
# Identify pivot (dependent) state for each constraint row.
# Pick the largest unused coefficient as pivot to ensure C_d
# is well-conditioned.
dep_list = []
used = set()
for i in range(nc):
row_abs = np.abs(C_np[i, :])
for pivot in np.argsort(-row_abs):
if pivot not in used and row_abs[pivot] > 0:
dep_list.append(int(pivot))
used.add(pivot)
break
else:
logger.warning("No valid pivot for constraint %d. "
"Skipping state constraint reduction.", i)
return As
dep_states = np.array(dep_list, dtype=int)
free_mask = np.ones(n, dtype=bool)
free_mask[dep_states] = False
free_states = np.where(free_mask)[0]
n_f = len(free_states)
# Build substitution: δx_dep = -C_d^{-1} C_f δx_free
C_d = C_np[:, dep_states]
C_f = C_np[:, free_states]
G = -solve(C_d, C_f)
# Substitution matrix R: δx = R δx_free
R = np.zeros((n, n_f))
R[free_states, np.arange(n_f)] = 1.0
R[dep_states, :] = G
# Convert As to numpy and project
As_np = np.array(As)
if As_np.ndim == 1:
As_np = As_np.reshape((n, n), order='F')
As_red = As_np[free_states, :] @ R
self.x_name = self.x_name[free_states]
self.x_tex_name = self.x_tex_name[free_states]
logger.debug("%d state constraints applied, reducing %d states to %d.",
nc, n, n_f)
return As_red
def sweep(self, params, idxes, values):
"""
Parameter sweep for root loci plot.
Parameters
----------
params : list of NumParam or ConstService
list of parameters indices to sweep. For example, ``[ss.GENCLS.M]``
for GENCLS.M. To update ``ss.GENCLS.M`` for two generators,
``params`` should be set to ``[ss.GENCLS.M, ss.GENCLS.M]``.
idxes : list of int or str
list of indices to sweep. For example, ``["GENCLS_1", "GENCLS_2"]``
for the indices of GENCLS whose corresponding parameter will be
updated. The length of ``idxes`` must match that of ``params`` and
``values``.
values: list of lists
New values of the parameters. Each element in ``values`` is a list
for the corresponding element in ``params`` and ``idxes``.
Examples
--------
To apply 10 parameters evenly spaced between 1 and 10 to
``ss.GENCLS.M`` of ``GENCLS_1``, do
.. code-block:: python
ret = ss.EIG.sweep(ss.GENCLS.M, "GENCLS_1", np.linspace(1, 2, 10))
This is equivalent to the following just for convenience.
.. code-block:: python
ret = ss.EIG.sweep([ss.GENCLS.M],
["GENCLS_1"],
[np.linspace(1, 2, 10)])
Returns
-------
dict
A dictionary of the results where the keys are 0-indexed count of
parameter set, and the values are dictionaries. Each value
dictionary contains a ``mu`` field for the eigenvalues.
"""
ret = False
results = dict()
if not isinstance(params, Iterable):
params = (params, )
if not isinstance(values, Iterable):
logger.error("values must be a list or tuple.")
return ret
elif not isinstance(values[0], Iterable):
values = (values, )
if isinstance(idxes, str):
idxes = (idxes, )
elif not isinstance(idxes, Iterable):
idxes = (idxes, )
if len(params) != len(values):
logger.error("params and values must have the same length.")
return ret
# check if all values are of the same length
if len(values) > 1:
len0 = len(values[0])
idx = 1
for value in values[1:]:
len1 = len(value)
if len1 == len0:
len0 = len1
idx += 1
continue
logger.error(f"value[{idx}] is of length {len1} =/ previous length {len0}.")
return ret
# get position for the parameters
positions = list()
param_names = list()
for param, idx in zip(params, idxes):
positions.append(param.owner.idx2uid(idx))
param_names.append(param.name)
# set parameters and run cases
for count, val in enumerate(zip(*values)):
logger.debug(f"Parameter sweep: round={count}")
for idx, (param, pos) in enumerate(zip(params, positions)):
param.v[pos] = val[idx]
logger.debug(f"Set {param.name} = {param.v[pos]}")
self.system.TDS.init()
self.system.TDS.itm_step()
self.calc_As()
mu, N = self.calc_eig(self.As)
self.mu, self.N = mu, N # save to `EIG` for writing if needed
results[count] = dict(param_values=val, mu=mu,)
return results
def plot_root_loci(self, results, eig_indices, ax=None, dpi=None, figsize=None,
draw_line=False, arrow_threshold=0.2, **kwargs):
"""
Plot the root loci.
Markers increase in size for the first parameter through the last.
Parameters
----------
results : dict
Eigenvalue results from parameter sweeping
eig_indices : Iterable
A list of eigenvalue indices to plot. The indices are 0-based,
whereas the indices in the eigenvalue analysis report are 1-based.
ax : matplotlib.axes.Axes or None
Axes to plot on. If None, create a new figure.
dpi : int or None
DPI of the figure. If None, use the default DPI.
figsize : tuple or None
Figure size. If None, use the default size.
draw_line : bool, optional, False by default
If True, draw lines to connect the roots. Note that due to the
non-fixed ordering of eigenvalues, lines will largely connect
different modesl
arrow_threshold : float
Threshold for plotting arrows. If the begin and end points of a
locus is shorter than this threshold, no arrow is plotted.
Examples
--------
To plot the root loci of the first two eigenvalues, do
.. code-block:: python
fig, ax = ss.EIG.plot_root_loci(ret, [0, 1])
where ``ret`` is the return of :py:meth:`andes.routines.eig.EIG.sweep`.
Returns
-------
matplotlib.figure.Figure
Figure containing the plot.
matplotlib.axes.Axes
Axes of the plot.
"""
if ax is None:
fig = plt.figure(dpi=dpi, figsize=figsize)
ax = plt.gca()
loci_list = list()
# extract data into an array
for data in results.values():
mu = data['mu'][eig_indices]
if not isinstance(mu, Iterable):
mu = np.array([mu])
loci_list.append(mu)
loci_data = np.array(loci_list)
npoints, nloci = loci_data.shape
# plot the eigenvalues - markers increase in size from beginning to the
# end.
for i in range(npoints):
s = 10 + 40 * i / (npoints - 1)
fig, ax = self.plot(mu=loci_data[i, :], s=s, fig=fig, ax=ax, show=False, **kwargs)
# Note:
# We are not able to plot a loci by connecting the eigenvalues because
# the order of the returned eigenvalues are not and cannot be guaranteed.
# draw solid lines to connect the roots
if draw_line:
ax.plot(loci_data.real, loci_data.imag, color='grey', linewidth=1)
# draw arrows using the middle points
if npoints > 1:
leftp = np.floor(npoints / 2 - 1).astype(int)
rightp = leftp + 1
loci_r = loci_data.real
loci_i = loci_data.imag
for i in range(nloci):
# skip drawing arrows if the distance is too small
if np.abs(loci_data[0, i] - loci_data[-1, i]) < arrow_threshold:
continue
left_r = loci_r[leftp, i]
left_i = loci_i[leftp, i]
right_r = loci_r[rightp, i]
right_i = loci_i[rightp, i]
ax.annotate("", xy=(right_r, right_i), xytext=(left_r, left_i),
arrowprops=dict(arrowstyle="simple", color='black'))
return fig, ax
def _pre_check(self):
"""
Helper function for pre-computation checks.
"""
system = self.system
status = True
if system.PFlow.converged is False:
logger.warning('Power flow not solved. Eig analysis will not continue.')
status = False
if system.TDS.initialized is False:
system.TDS.init()
system.TDS.itm_step()
elif system.dae.n == 0:
logger.error('No dynamic model. Eig analysis will not continue.')
status = False
return status
def run(self, **kwargs):
"""
Run small-signal stability analysis.
"""
succeed = False
system = self.system
if not self._pre_check():
system.exit_code += 1
return False
self.summary()
t1, s = elapsed()
self.calc_As()
self.mu, self.pfactors, self.N, self.W = self.calc_pfactor()
self._store_stats()
t2, s = elapsed(t1)
self.exec_time = t2 - t1
logger.info(self.stats())
logger.info('Eigenvalue analysis finished in {:s}.'.format(s))
if not self.system.files.no_output:
self.report()
if system.options.get('state_matrix'):
self.export_mat()
if self.config.plot:
self.plot()
succeed = True
if not succeed:
system.exit_code += 1
return succeed
def stats(self):
"""
Return statistics of results in a string.
"""
out = list()
out.append(' Positive %6g' % self.n_positive)
out.append(' Zeros %6g' % self.n_zeros)
out.append(' Negative %6g' % self.n_negative)
return '\n'.join(out)
def plot(self, mu=None, fig=None, ax=None,
left=-6, right=0.5, ymin=-8, ymax=8, damping=0.05,
line_width=0.5, s=40, dpi=DPI, figsize=None, base_color='black',
show=True, latex=True, style='default',
):
"""
Plot utility for eigenvalues in the S domain.
Parameters
----------
mu : array, optional
an array of complex eigenvalues
fig : figure handl, optional
existing matplotlib figure handle
ax : axis handle, optional
existing axis handle
left : int, optional
left tick for the x-axis, by default -6
right : float, optional
right tick, by default 0.5
ymin : int, optional
bottom tick, by default -8
ymax : int, optional
top tick, by default 8
damping : float, optional
damping value for which the dash plots are drawn
line_width : float, optional
default line width, by default 0.5
s : float or array-like, shape (n, ), optional
The marker size in points**2
dpi : int, optional
figure dpi
figsize : [type], optional
default figure size, by default None
base_color : str, optional
base color for negative eigenvalues
show : bool, optional
True to show figure after plot, by default True
latex : bool, optional
True to use latex, by default True
Returns
-------
figure
matplotlib figure object
axis
matplotlib axis object
"""
set_style(style)
if mu is None:
mu = self.mu
mu_real = mu.real
mu_imag = mu.imag
p_mu_real, p_mu_imag = list(), list()
z_mu_real, z_mu_imag = list(), list()
n_mu_real, n_mu_imag = list(), list()
for re, im in zip(mu_real, mu_imag):
if abs(re) <= self.config.tol:
z_mu_real.append(re)
z_mu_imag.append(im)
elif re > self.config.tol:
p_mu_real.append(re)
p_mu_imag.append(im)
elif re < -self.config.tol:
n_mu_real.append(re)
n_mu_imag.append(im)
if latex:
set_latex()
if fig is None or ax is None:
fig = plt.figure(dpi=dpi, figsize=figsize)
ax = plt.gca()
ax.scatter(z_mu_real, z_mu_imag, marker='o', s=s, linewidth=0.5, facecolors='none', edgecolors='green')
ax.scatter(n_mu_real, n_mu_imag, marker='x', s=s, linewidth=0.5, color=base_color)
ax.scatter(p_mu_real, p_mu_imag, marker='x', s=s, linewidth=0.5, color='red')
# axes lines
ax.axhline(linewidth=0.5, color='grey', linestyle='--')
ax.axvline(linewidth=0.5, color='grey', linestyle='--')
# TODO: Improve the damping and range
# --- plot 5% damping lines ---
xin = np.arange(left, 0, 0.01)
yneg = xin / damping
ypos = - xin / damping
ax.plot(xin, yneg, color='grey', linewidth=line_width, linestyle='--')
ax.plot(xin, ypos, color='grey', linewidth=line_width, linestyle='--')
# --- damping lines end ---
if latex:
ax.set_xlabel('Real [$s^{-1}$]')
ax.set_ylabel('Imaginary [$s^{-1}$]')
else:
ax.set_xlabel('Real [s -1]')
ax.set_ylabel('Imaginary [s -1]')
ax.set_xlim(left=left, right=right)
ax.set_ylim(ymin, ymax)
if show is True:
plt.show()
return fig, ax
def export_mat(self):
"""
Export state matrix to a ``<CaseName>_As.mat`` file with the variable name ``As``, where
``<CaseName>`` is the test case name.
State variable names are stored in variables ``x_name`` and ``x_tex_name``.
Returns
-------
bool
True if successful
"""
system = self.system
out = {'As': self.As,
'x_name': np.array(self.x_name, dtype=object),
'x_tex_name': np.array(self.x_tex_name, dtype=object),
}
scipy.io.savemat(system.files.mat, mdict=out)
logger.info('State matrix saved to "%s"', system.files.mat)
return True
def post_process(self):
"""
Post processing of eigenvalues.
"""
# --- statistics ---
n_states = len(self.mu)
mu_real = self.mu.real
numeral = [''] * n_states
for idx, item in enumerate(range(n_states)):
if abs(mu_real[idx]) <= self.config.tol:
marker = '*'
elif mu_real[idx] > self.config.tol:
marker = '**'
else:
marker = ''
numeral[idx] = '#' + str(idx + 1) + marker
# compute frequency, un-damped frequency and damping
freq = np.zeros(n_states)
ufreq = np.zeros(n_states)
damping = np.zeros(n_states)
for idx, item in enumerate(self.mu):
if item.imag == 0:
freq[idx] = 0
ufreq[idx] = 0
damping[idx] = 0
else:
ufreq[idx] = abs(item) / 2 / pi
freq[idx] = abs(item.imag / 2 / pi)
damping[idx] = -div(item.real, abs(item)) * 100
return freq, ufreq, damping, numeral
def report(self, x_name=None, **kwargs):
"""
Save eigenvalue analysis reports.
Returns
-------
None
"""
if x_name is None:
x_name = self.x_name
n_states = len(self.mu)
mu_real = self.mu.real
mu_imag = self.mu.imag
freq, ufreq, damping, numeral = self.post_process()
# obtain most associated variables
var_assoc = []
for prow in range(n_states):
temp_row = self.pfactors[prow, :]
name_idx = list(temp_row).index(max(temp_row))
var_assoc.append(x_name[name_idx])
text, header, rowname, data = list(), list(), list(), list()
# opening info section
text.append(report_info(self.system))
header.append(None)
rowname.append(None)
data.append(None)
text.append('')
text.append('EIGENVALUE ANALYSIS REPORT')
header.append([])
rowname.append([])
data.append([])
text.append('STATISTICS\n')
header.append([''])
rowname.append(['Positives', 'Zeros', 'Negatives'])
data.append((self.n_positive, self.n_zeros, self.n_negative))
text.append('EIGENVALUE DATA\n')
header.append([
'Most Associated',
'Real',
'Imag.',
'Damped Freq.',
'Frequency',
'Damping [%]'])
rowname.append(numeral)
data.append(
[var_assoc,
list(mu_real),
list(mu_imag),
freq,
ufreq,
damping])
n_cols = 7 # columns per block
n_block = int(ceil(n_states / n_cols))
if n_block <= 100:
for idx in range(n_block):
start = n_cols * idx
end = n_cols * (idx + 1)
text.append('PARTICIPATION FACTORS [{}/{}]\n'.format(
idx + 1, n_block))
header.append(numeral[start:end])
rowname.append(x_name)
data.append(self.pfactors[start:end, :])
dump_data(text, header, rowname, data, self.system.files.eig)
logger.info('Report saved to "%s".', self.system.files.eig)