Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3aa2b84
Add new svd
dallan-keylogic Feb 11, 2025
105e518
hotfix
dallan-keylogic Feb 11, 2025
892b5cf
hotfix 2
dallan-keylogic Feb 11, 2025
159c743
Added regularization for singular factorization
dallan-keylogic Feb 13, 2025
86637c7
dangerous regularization
dallan-keylogic Feb 24, 2025
3624970
test functions
dallan-keylogic Feb 25, 2025
f56d63d
Merge branch 'main' into sparse_svd
dallan-keylogic Mar 21, 2025
95c3121
rayleigh ritz
dallan-keylogic Mar 27, 2025
86c7156
bug fixes
dallan-keylogic Mar 28, 2025
fb5d861
just one more qr bro
dallan-keylogic Mar 31, 2025
d841a33
can I make things simpler?
dallan-keylogic Apr 2, 2025
a022f61
tests
dallan-keylogic Apr 3, 2025
022c4d6
run Black
dallan-keylogic Apr 3, 2025
009c02f
Merge branch 'main' into sparse_svd
dallan-keylogic Apr 3, 2025
de16430
spelling fixes
dallan-keylogic Apr 4, 2025
7bf6008
actually add cached matrices
dallan-keylogic Apr 4, 2025
2fd9843
actually add cached matrices and pylint change
dallan-keylogic Apr 4, 2025
1ec214f
Merge branch 'sparse_svd_weird' into sparse_svd
dallan-keylogic Apr 4, 2025
d28dd53
Merge branch 'main' into sparse_svd
dallan-keylogic Jul 17, 2025
21979e2
null bug
dallan-keylogic Jul 18, 2025
64bd0ca
Merge branch 'main' into sparse_svd
blnicho Sep 24, 2025
e085b43
accept theirs
dallan-keylogic Mar 26, 2026
c5dc37a
reintegrate changes
dallan-keylogic Mar 26, 2026
993cf5d
Black
dallan-keylogic Mar 26, 2026
e97ad2c
Merge branch 'sparse_svd_weird' into sparse_svd
dallan-keylogic Mar 26, 2026
3b3dcb8
return dictionary
dallan-keylogic Mar 27, 2026
3c4c95a
seed rng and unicode characters
dallan-keylogic Mar 27, 2026
1db8818
Merge branch 'main' into sparse_svd
dallan-keylogic Apr 30, 2026
6cc2d84
stash
dallan-keylogic May 1, 2026
32b5c88
Merge branch 'main' into sparse_svd
dallan-keylogic May 12, 2026
32a9b0a
fix sphinx error
dallan-keylogic May 12, 2026
aee3ce8
forgot matrix and slightly loosen tolerance
dallan-keylogic May 13, 2026
395891f
raw docstring
dallan-keylogic May 13, 2026
45158d1
add 2 norm condition number option
dallan-keylogic May 13, 2026
b3aea48
spelling and black
dallan-keylogic May 13, 2026
7af0bad
formatting
dallan-keylogic May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
469 changes: 469 additions & 0 deletions idaes/core/scaling/tests/test_util.py

Large diffs are not rendered by default.

139 changes: 124 additions & 15 deletions idaes/core/scaling/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

from scipy.linalg import norm, pinv
from scipy.sparse import diags
from scipy.sparse.linalg import inv as spinv, norm as spnorm
from scipy.sparse.linalg import inv as spinv, norm as spnorm, svds

from pyomo.environ import (
Binary,
Expand Down Expand Up @@ -66,13 +66,36 @@
from pyomo.contrib.pynumero.asl import AmplInterface

from idaes.core.util.exceptions import BurntToast
from idaes.core.util.linalg import svd_rayleigh_ritz
from idaes.core.util.misc import StrEnum
import idaes.logger as idaeslog

_log = idaeslog.getLogger(__name__)

TAB = " " * 4


class MatrixNorm(StrEnum):
"""
Types of norm available for calculating the matrix condition number.
Let A be an m by n matrix and let sigma be a vector of the singular
values of A. Then the matrix norms can be expressed as follows:

twoNorm ("2"): max(sigma)
frobeniusNorm ("fro"): sqrt(sum(a**2 for a in row for row in A))
= sqrt(sum(s**2 for s in sigma))

So the Frobenius norm is the square root of the sum of squares of
all the matrix entries, while the matrix two norm is the largest
singular value of A. For the matrix two norm, we have
norm(A @ v, 2) <= norm(A, 2) * norm(v, 2) for all vectors v with
a length of n.
"""

twoNorm = "2"
frobeniusNorm = "fro"


def _filter_unknown(block_data):
# It can be confusing to users to see a block named "unknown" appear in
# an error message, but that's, unfortunately, what Pyomo uses as the
Expand Down Expand Up @@ -1219,17 +1242,28 @@
return jac, nlp


# TODO should we calculate the 2-norm condition number from the SVD
# once #1566 is merged?
def jacobian_cond(m=None, scaled=True, jac=None):
def jacobian_cond(
m=None, scaled=True, jac=None, norm_type=MatrixNorm.frobeniusNorm, svd_options=None
):
"""
Get the Frobenius condition number of the scaled or unscaled Jacobian matrix
of a model.

Args:
m: calculate the condition number of the Jacobian from this model.
scaled: if True use scaled Jacobian, else use unscaled
jac: (optional) previously calculated Jacobian
m: Calculate the condition number of the Jacobian from this model.
scaled: If True use scaled Jacobian, else use unscaled
jac: (Optional) previously calculated Jacobian
norm_type: matrix norm with which to calculate the matrix condition number.
MatrixNorm.twoNorm is faster for large matrices, but takes into
account only the largest and smallest singular values.
MatrixNorm.frobeniusNorm takes into account all of the matrix's
singular values, but requires forming the (pseudo) inverse, which
is a large, dense matrix and therefore can take a very long time
for large models.
svd_options: Dictionary of options to pass to SVD solvers to calculate
the two norm condition number. It should contain two keys:
"large_svd" for options to pass to scipy.sparse.linalg.svds
"small_svd" for options to pass to idaes.core.util.linalg.svd_rayleigh_ritz

Returns:
(float) Condition number
Expand All @@ -1242,15 +1276,90 @@
)
jac, _ = get_jacobian(m, scaled)
jac = jac.tocsc()
if jac.shape[0] != jac.shape[1]:
_log.info(
"Nonsquare Jacobian. Using pseudoinverse to calculate Frobenius norm."
)
jac_inv = pinv(jac.toarray())
return spnorm(jac, ord="fro") * norm(jac_inv, ord="fro")
if norm_type == MatrixNorm.frobeniusNorm:
if svd_options is not None:
raise ValueError(
"Received dictionary of options for calculating the matrix svd, but the "
"svd is not computed when calculating the Frobenius norm condition number."
)
if jac.shape[0] != jac.shape[1]:
_log.info(
"Nonsquare Jacobian. Using pseudoinverse to calculate Frobenius norm."
)
jac_inv = pinv(jac.toarray())
return spnorm(jac, ord="fro") * norm(jac_inv, ord="fro")
else:
jac_inv = spinv(jac)
return spnorm(jac, ord="fro") * spnorm(jac_inv, ord="fro")
elif norm_type == MatrixNorm.twoNorm:
if svd_options is None:
svd_options = {}

if min(jac.shape) == 1:
# Matrix with minimum dimension of 1
if spnorm(jac) == 0:
raise ValueError(
"Both largest and smallest singular value are zero. The "
"matrix's condition number is undefined."
)
else:
# A matrix with a dimension of length 1 has
# a condition number equal to 1.
return 1

if "large_svd" not in svd_options:
svd_options["large_svd"] = {}

if (
"return_singular_vectors" in svd_options["large_svd"]
and svd_options["large_svd"]["return_singular_vectors"] == True

Check notice on line 1315 in idaes/core/scaling/util.py

View workflow job for this annotation

GitHub Actions / Pylint

C0121 (singleton-comparison)

Comparison 'svd_options['large_svd']['return_singular_vectors'] == True' should be 'svd_options['large_svd']['return_singular_vectors'] is True' if checking for the singleton value True, or 'bool(svd_options['large_svd']['return_singular_vectors'])' if testing for truthiness
):
raise ValueError(
"Received the option return_singular_vectors=True for "
"scipy.sparse.linalg.svds. Singular vectors are not necessary "
"to calculate the matrix norm, so this option should be "
"set to False or removed."
)
else:
svd_options["large_svd"]["return_singular_vectors"] = False

if "k" in svd_options["large_svd"] and svd_options["large_svd"]["k"] != 1:
raise ValueError(
f"Received the option k={svd_options['large_svd']['k']} for "
"scipy.sparse.linalg.svds. We only need the largest singular "
"value to calculate the matrix norm, so this option should be "
"set to False or removed. To increase the number of Lanczos "
"vectors that Arpack uses, use the 'ncv' option instead."
)
else:
svd_options["large_svd"]["k"] = 1

if "small_svd" not in svd_options:
svd_options["small_svd"] = {}

large_singular_values = svds(
jac, **svd_options["large_svd"]
) # Sorted from greatest to least
out = svd_rayleigh_ritz(jac, **svd_options["small_svd"])
small_singular_values = out["singular_values"] # Sorted from least to greatest
if small_singular_values[0] == 0 and large_singular_values[0] == 0:
raise ValueError(
"Both the largest and smallest singular values are zero. The "
"matrix's condition number is undefined."
)
elif small_singular_values[0] == 0:
raise ValueError(
"The smallest singular value is zero. The matrix's condition "
"number is infinity."
)
else:
return large_singular_values[0] / small_singular_values[0]

else:
jac_inv = spinv(jac)
return spnorm(jac, ord="fro") * spnorm(jac_inv, ord="fro")
raise ValueError(
"This function supports calculating the matrix condition number only for "
f"the two norm and frobenius norm. Instead got the unknown option {norm_type}."
)


def scale_time_discretization_equations(blk, time_set, time_scaling_factor):
Expand Down
30 changes: 27 additions & 3 deletions idaes/core/util/diagnostics_tools/svd_toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)
from pyomo.common.errors import PyomoException

from idaes.core.util.linalg import svd_rayleigh_ritz
from idaes.core.util.model_statistics import (
greybox_block_set,
)
Expand Down Expand Up @@ -114,6 +115,29 @@ def svd_sparse(jacobian, number_singular_values):
return u, s, vT.transpose()


def svd_rayleigh_ritz_callback(jacobian, number_singular_values, **kwargs):
"""
Callback for performing SVD analysis using idaes.core.util.linalg.svd_rayleigh_ritz

Args:
jacobian: Jacobian to be analysed
number_singular_values: number of singular values to compute
**kwargs: Dictionary of keyword arguments to pass to svd_rayleigh_ritz

Returns:
u, s and v numpy arrays

"""
# This method also returns the null space, which is not used by
# the model diagnostics at present
out_dict = svd_rayleigh_ritz(jacobian, number_singular_values, **kwargs)
return (
out_dict["left_singular_vectors"],
out_dict["singular_values"],
out_dict["right_singular_vectors"],
)


SVDCONFIG = ConfigDict()
SVDCONFIG.declare(
"number_of_smallest_singular_values",
Expand All @@ -125,10 +149,10 @@ def svd_sparse(jacobian, number_singular_values):
SVDCONFIG.declare(
"svd_callback",
ConfigValue(
default=svd_dense,
default=svd_rayleigh_ritz_callback,
domain=svd_callback_validator,
description="Callback to SVD method of choice (default = svd_dense)",
doc="Callback to SVD method of choice (default = svd_dense). "
description="Callback to SVD method of choice (default = svd_rayleigh_ritz_callback)",
doc="Callback to SVD method of choice (default = svd_rayleigh_ritz_callback). "
"Callbacks should take the Jacobian and number of singular values "
"to compute as options, plus any method specific arguments, and should "
"return the u, s and v matrices as numpy arrays.",
Expand Down
52 changes: 45 additions & 7 deletions idaes/core/util/diagnostics_tools/tests/test_svd_toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
SVDToolbox,
svd_dense,
svd_sparse,
svd_rayleigh_ritz_callback,
)
from idaes.core.util.diagnostics_tools.tests.utils import (
dummy_problem,
Expand Down Expand Up @@ -134,7 +135,32 @@ def test_init_small_model(self):

@pytest.mark.unit
def test_run_svd_analysis(self, dummy_problem):
svd = SVDToolbox(dummy_problem)
svd = SVDToolbox(dummy_problem, svd_callback_arguments={"seed": 3508})

assert svd.config.svd_callback is svd_rayleigh_ritz_callback

svd.run_svd_analysis()

# SVD Rayleigh-Ritz is not consistent with signs - manually iterate and check abs value
for i in range(5):
for j in range(4):
if (i, j) in [(1, 1), (2, 3), (3, 0), (4, 2)]:
assert abs(svd.u[i, j]) == pytest.approx(1, abs=1e-6, rel=1e-6)
else:
assert svd.u[i, j] == pytest.approx(0, abs=1e-6)

np.testing.assert_array_almost_equal(svd.s, np.array([0.1, 1, 5, 10]))

for i in range(5):
for j in range(4):
if (i, j) in [(1, 1), (2, 3), (3, 0), (4, 2)]:
assert abs(svd.v[i, j]) == pytest.approx(1, abs=1e-6, rel=1e-6)
else:
assert svd.v[i, j] == pytest.approx(0, abs=1e-6)

@pytest.mark.unit
def test_run_svd_analysis_dense(self, dummy_problem):
svd = SVDToolbox(dummy_problem, svd_callback=svd_dense)

assert svd.config.svd_callback is svd_dense

Expand Down Expand Up @@ -202,7 +228,7 @@ def test_run_svd_analysis_sparse_limit(self, dummy_problem):

@pytest.mark.unit
def test_display_rank_of_equality_constraints(self, dummy_problem):
svd = SVDToolbox(dummy_problem)
svd = SVDToolbox(dummy_problem, svd_callback_arguments={"seed": 3508})

stream = StringIO()
svd.display_rank_of_equality_constraints(stream=stream)
Expand All @@ -218,14 +244,18 @@ def test_display_rank_of_equality_constraints(self, dummy_problem):

@pytest.mark.unit
def test_display_rank_of_equality_constraints(self, dummy_problem):
svd = SVDToolbox(dummy_problem, singular_value_tolerance=1)
svd = SVDToolbox(
dummy_problem,
singular_value_tolerance=0.9,
svd_callback_arguments={"seed": 3508},
)

stream = StringIO()
svd.display_rank_of_equality_constraints(stream=stream)

expected = """====================================================================================

Number of Singular Values less than 1.0E+00 is 1
Number of Singular Values less than 9.0E-01 is 1

====================================================================================
"""
Expand All @@ -234,7 +264,11 @@ def test_display_rank_of_equality_constraints(self, dummy_problem):

@pytest.mark.unit
def test_display_underdetermined_variables_and_constraints(self, dummy_problem):
svd = SVDToolbox(dummy_problem)
svd = SVDToolbox(
dummy_problem,
size_cutoff_in_singular_vector=1.1,
svd_callback_arguments={"seed": 3508},
)

stream = StringIO()
svd.display_underdetermined_variables_and_constraints(stream=stream)
Expand Down Expand Up @@ -291,7 +325,7 @@ def test_display_underdetermined_variables_and_constraints(self, dummy_problem):
def test_display_underdetermined_variables_and_constraints_specific(
self, dummy_problem
):
svd = SVDToolbox(dummy_problem)
svd = SVDToolbox(dummy_problem, svd_callback_arguments={"seed": 3508})

stream = StringIO()
svd.display_underdetermined_variables_and_constraints(
Expand All @@ -318,7 +352,11 @@ def test_display_underdetermined_variables_and_constraints_specific(

@pytest.mark.unit
def test_display_underdetermined_variables_and_constraints(self, dummy_problem):
svd = SVDToolbox(dummy_problem, size_cutoff_in_singular_vector=1)
svd = SVDToolbox(
dummy_problem,
size_cutoff_in_singular_vector=1.1,
svd_callback_arguments={"seed": 3508},
)

stream = StringIO()
svd.display_underdetermined_variables_and_constraints(stream=stream)
Expand Down
Loading
Loading