#################################################################################
# The Institute for the Design of Advanced Energy Systems Integrated Platform
# Framework (IDAES IP) was produced under the DOE Institute for the
# Design of Advanced Energy Systems (IDAES).
#
# Copyright (c) 2018-2026 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory,
# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon
# University, West Virginia University Research Corporation, et al.
# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md
# for full copyright and license information.
#################################################################################
"""
This module contains utility functions used for computing model diagnostics.
"""
__author__ = "Alexander Dowling, Douglas Allan, Andrew Lee, Robby Parker, Ben Knueven"
import numpy as np
from scipy.sparse.linalg import norm
from pyomo.environ import (
value,
Var,
)
from pyomo.common.collections import ComponentSet
from idaes.core.scaling.util import (
get_jacobian,
get_scaling_factor,
)
[docs]
def check_parallel_jacobian(
model,
tolerance: float = 1e-4,
direction: str = "row",
jac=None,
nlp=None,
):
"""
Check for near-parallel rows or columns in the Jacobian.
Near-parallel rows or columns indicate a potential degeneracy in the model,
as this means that the associated constraints or variables are (near)
duplicates of each other.
For efficiency, the ``jac`` and ``nlp`` arguments may be provided if they are
already available. If these are provided, the provided model is not used. If
either ``jac`` or ``nlp`` is not provided, a Jacobian and ``PyomoNLP`` are
computed using the model.
This method is based on work published in:
Klotz, E., Identification, Assessment, and Correction of Ill-Conditioning and
Numerical Instability in Linear and Integer Programs, Informs 2014, pgs. 54-108
https://pubsonline.informs.org/doi/epdf/10.1287/educ.2014.0130
Args:
model: model to be analysed
tolerance: tolerance to use to determine if constraints/variables are parallel
direction: 'row' (default, constraints) or 'column' (variables)
jac: model Jacobian as a ``scipy.sparse.coo_matrix``, optional
nlp: ``PyomoNLP`` of model, optional
Returns:
list of 2-tuples containing parallel Pyomo components
"""
# Thanks to Robby Parker for the sparse matrix implementation and
# significant performance improvements
if direction not in ["row", "column"]:
raise ValueError(
f"Unrecognised value for direction ({direction}). "
"Must be 'row' or 'column'."
)
if jac is None or nlp is None:
jac, nlp = get_jacobian(model)
# Get vectors that we will check, and the Pyomo components
# they correspond to.
if direction == "row":
components = nlp.get_pyomo_constraints()
csrjac = jac.tocsr()
# Make everything a column vector (CSC) for consistency
vectors = [csrjac[i, :].transpose().tocsc() for i in range(len(components))]
else: # direction == "column"
components = nlp.get_pyomo_variables()
cscjac = jac.tocsc()
vectors = [cscjac[:, i] for i in range(len(components))]
# List to store pairs of parallel components
parallel = []
vectors_by_nz = {}
for vecidx, vec in enumerate(vectors):
maxval = max(np.abs(vec.data))
# Construct tuple of sorted col/row indices that participate
# in this vector (with non-negligible coefficient).
nz = tuple(
sorted(
idx
for idx, val in zip(vec.indices, vec.data)
if abs(val) > tolerance and abs(val) / maxval > tolerance
)
)
if nz in vectors_by_nz:
# Store the index as well so we know what component this
# correrponds to.
vectors_by_nz[nz].append((vec, vecidx))
else:
vectors_by_nz[nz] = [(vec, vecidx)]
for vecs in vectors_by_nz.values():
for idx, (u, uidx) in enumerate(vecs):
# idx is the "local index", uidx is the "global index"
# Frobenius norm of the matrix is 2-norm of this column vector
unorm = norm(u, ord="fro")
for v, vidx in vecs[idx + 1 :]:
vnorm = norm(v, ord="fro")
# Explicitly multiply a row vector * column vector
prod = u.transpose().dot(v)
absprod = abs(prod[0, 0])
diff = abs(absprod - unorm * vnorm)
if diff <= tolerance or diff <= tolerance * max(unorm, vnorm):
parallel.append((uidx, vidx))
parallel = [(components[uidx], components[vidx]) for uidx, vidx in parallel]
return parallel
[docs]
def extreme_jacobian_entries(
jac,
nlp,
large=1e4,
small=1e-4,
zero=1e-10,
):
"""
Show very large and very small Jacobian entries.
Args:
jac: already-existing Jacobian matrix
nlp: already-existing Pynumero NLP object from
get_jacobian (and thus having vlist and clist
attributes)
large: >= to this value is considered large
small: <= to this and >= zero is considered small
zero: <= to this value is ignored
Returns:
(list of tuples), Jacobian entry, Constraint, Variable
"""
el = []
for i, c in enumerate(nlp.clist):
for j in jac[i].indices:
v = nlp.vlist[j]
e = abs(jac[i, j])
if (e <= small and e > zero) or e >= large:
el.append((e, c, v))
return el
[docs]
def extreme_jacobian_rows(
jac,
nlp,
large=1e4,
small=1e-4,
):
"""
Show very large and very small Jacobian rows. Typically indicates a badly-
scaled constraint.
Args:
jac: already-existing Jacobian matrix
nlp: already-existing Pynumero NLP object from
get_jacobian (and thus having vlist and clist
attributes)
large: >= to this value is considered large
small: <= to this is considered small
Returns:
(list of tuples), Row norm, Constraint
"""
row_norms = norm(jac, ord=2, axis=1)
# Array with values of 1 for entries with extreme row norms
# and values of 0 otherwise
condition_vector = np.logical_or(row_norms >= large, row_norms <= small)
# Array of indices for which condition_vector is 1
extreme_indices = np.nonzero(condition_vector)[0]
return [(row_norms[k], nlp.clist[k]) for k in extreme_indices]
[docs]
def extreme_jacobian_columns(
jac,
nlp,
large=1e4,
small=1e-4,
):
"""
Show very large and very small Jacobian columns. A more reliable indicator
of a badly-scaled variable than badly_scaled_var_generator.
Args:
jac: already-existing Jacobian matrix
nlp: already-existing Pynumero NLP object from
get_jacobian (and thus having vlist and clist
attributes)
large: >= to this value is considered large
small: <= to this is considered small
Returns:
(list of tuples), Column norm, Variable
"""
# Convert to csc to make iterating over columns easier
jac = jac.tocsc()
column_norms = norm(jac, ord=2, axis=0)
# Array with values of 1 for entries with extreme row norms
# and values of 0 otherwise
condition_vector = np.logical_or(column_norms >= large, column_norms <= small)
# Array of indices for which condition_vector is 1
extreme_indices = np.nonzero(condition_vector)[0]
return [(column_norms[k], nlp.vlist[k]) for k in extreme_indices]
[docs]
def var_in_block(var, block):
"""
Check if a variable is within a specific block.
Args:
var: The variable to check.
block: The block to check against.
Returns:
True if the variable is within the block, False otherwise.
"""
parent = var.parent_block()
while parent is not None:
if parent is block:
return True
parent = parent.parent_block()
return False
[docs]
def vars_fixed_to_zero(model):
"""
Set of variables fixed to 0.
Args:
model: The model to check.
Returns:
A set of variables fixed to 0.
"""
zero_vars = ComponentSet()
for v in model.component_data_objects(Var, descend_into=True):
if v.fixed and value(v) == 0:
zero_vars.add(v)
return zero_vars
[docs]
def vars_near_zero(model, variable_zero_value_tolerance):
"""
Set of variables with value near 0, as determined by the provided tolerance.
Args:
model: The model to check.
variable_zero_value_tolerance: The tolerance to use when determining if a variable is near zero.
This is applied to the scaled value of the variable, so should be chosen with scaling in mind.
Returns:
A set of variables with value near 0.
"""
near_zero_vars = ComponentSet()
for v in model.component_data_objects(Var, descend_into=True):
sf = get_scaling_factor(v, default=1, warning=False)
if v.value is not None and sf * abs(value(v)) <= variable_zero_value_tolerance:
near_zero_vars.add(v)
return near_zero_vars
[docs]
def vars_violating_bounds(model, tolerance):
"""
Set of variables with values violating their bounds by more than the provided tolerance.
Args:
model: The model to check.
tolerance: The tolerance to use when determining if a variable is violating its bounds.
This is applied to the scaled value of the variable, so should be chosen with scaling in mind.
Returns:
A set of variables with values violating their bounds by more than the provided tolerance.
"""
violated_bounds = ComponentSet()
for v in model.component_data_objects(Var, descend_into=True):
sf = get_scaling_factor(v, default=1, warning=False)
if v.value is not None:
if v.lb is not None and sf * v.value <= sf * v.lb - tolerance:
violated_bounds.add(v)
elif v.ub is not None and sf * v.value >= sf * v.ub + tolerance:
violated_bounds.add(v)
return violated_bounds
[docs]
def vars_with_none_value(model):
"""
Set of variables with value None.
Args:
model: The model to check.
Returns:
A set of variables with value None.
"""
none_value = ComponentSet()
for v in model.component_data_objects(Var, descend_into=True):
if v.value is None:
none_value.add(v)
return none_value
[docs]
def vars_with_extreme_values(model, large, small, zero):
"""
Set of variables with very large or very small values, as determined by the provided tolerances.
Tolerances are applied to the scaled value of the variable, so should be chosen with scaling in mind.
Args:
model: The model to check.
large: The threshold above which a variable is considered to have a very large value.
small: The threshold below which a variable is considered to have a very small value.
zero: The threshold below which a variable is considered to have a zero value and thus ignored.
Returns:
A set of variables with very large or very small values.
"""
extreme_vars = ComponentSet()
for v in model.component_data_objects(Var, descend_into=True):
sf = get_scaling_factor(v, default=1, warning=False)
if v.value is not None:
mag = sf * abs(value(v))
if mag > abs(large):
extreme_vars.add(v)
elif mag < abs(small) and mag > abs(zero):
extreme_vars.add(v)
return extreme_vars