Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add diagnostics tests for all unit models #1375

Merged
merged 20 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions idaes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
import os
import copy
import logging
from typing import Optional, List

from pyomo.common.fileutils import find_library

from . import config
from .ver import __version__ # noqa
Expand Down Expand Up @@ -83,6 +86,29 @@ def _handle_optional_compat_activation(
_log.debug("'idaes' logger debug test")


# TODO: Remove once AMPL bug is fixed
# TODO: https://github.com/ampl/asl/issues/13
# There appears to be a bug in the ASL which causes terminal failures
# if you try to create multiple ASL structs with different external
# functions in the same process. This causes pytest to crash during testing.
# To avoid this, register all known external functions at initialization.
def _ensure_external_functions_libs_in_env(
ext_funcs: List[str], var_name: str = "AMPLFUNC", sep: str = "\n"
):
libraries_str = os.environ.get(var_name, "")
libraries = [lib for lib in libraries_str.split(sep) if lib.strip()]
for func_name in ext_funcs:
lib: Optional[str] = find_library(os.path.join(bin_directory, func_name))
if lib is not None and lib not in libraries:
libraries.append(lib)
os.environ[var_name] = sep.join(libraries)


_ensure_external_functions_libs_in_env(
["cubic_roots", "general_helmholtz_external", "functions"]
)


def _create_data_dir():
"""Create the IDAES directory to store data files in."""
config.create_dir(data_directory)
Expand Down
57 changes: 44 additions & 13 deletions idaes/core/util/model_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,16 +997,25 @@
# TODO: Block triangularization analysis
# Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks?

def _collect_structural_warnings(self):
def _collect_structural_warnings(
self, ignore_evaluation_errors=False, ignore_unit_consistency=False
):
"""
Runs checks for structural warnings and returns two lists.

Args:
ignore_evaluation_errors - ignore potential evaluation error warnings
ignore_unit_consistency - ignore unit consistency warnings

Returns:
warnings - list of warning messages from structural analysis
next_steps - list of suggested next steps to further investigate warnings

"""
uc = identify_inconsistent_units(self._model)
if not ignore_unit_consistency:
uc = identify_inconsistent_units(self._model)
else:
uc = []
uc_var, uc_con, oc_var, oc_con = self.get_dulmage_mendelsohn_partition()

# Collect warnings
Expand Down Expand Up @@ -1040,12 +1049,15 @@
if any(len(x) > 0 for x in [oc_var, oc_con]):
next_steps.append(self.display_overconstrained_set.__name__ + "()")

eval_warnings = self._collect_potential_eval_errors()
if len(eval_warnings) > 0:
warnings.append(
f"WARNING: Found {len(eval_warnings)} potential evaluation errors."
)
next_steps.append(self.display_potential_evaluation_errors.__name__ + "()")
if not ignore_evaluation_errors:
eval_warnings = self._collect_potential_eval_errors()
if len(eval_warnings) > 0:
warnings.append(

Check warning on line 1055 in idaes/core/util/model_diagnostics.py

View check run for this annotation

Codecov / codecov/patch

idaes/core/util/model_diagnostics.py#L1055

Added line #L1055 was not covered by tests
f"WARNING: Found {len(eval_warnings)} potential evaluation errors."
)
next_steps.append(

Check warning on line 1058 in idaes/core/util/model_diagnostics.py

View check run for this annotation

Codecov / codecov/patch

idaes/core/util/model_diagnostics.py#L1058

Added line #L1058 was not covered by tests
self.display_potential_evaluation_errors.__name__ + "()"
)

return warnings, next_steps

Expand Down Expand Up @@ -1289,16 +1301,27 @@

return cautions

def assert_no_structural_warnings(self):
def assert_no_structural_warnings(
self,
ignore_evaluation_errors: bool = False,
ignore_unit_consistency: bool = False,
):
"""
Checks for structural warnings in the model and raises an AssertionError
if any are found.

Args:
ignore_evaluation_errors - ignore potential evaluation error warnings
ignore_unit_consistency - ignore unit consistency warnings

Raises:
AssertionError if any warnings are identified by structural analysis.

"""
warnings, _ = self._collect_structural_warnings()
warnings, _ = self._collect_structural_warnings(
ignore_evaluation_errors=ignore_evaluation_errors,
ignore_unit_consistency=ignore_unit_consistency,
)
if len(warnings) > 0:
raise AssertionError(f"Structural issues found ({len(warnings)}).")

Expand Down Expand Up @@ -1387,9 +1410,17 @@
cautions = self._collect_numerical_cautions(jac=jac, nlp=nlp)

stats = []
stats.append(
f"Jacobian Condition Number: {jacobian_cond(jac=jac, scaled=False):.3E}"
)
try:
stats.append(
f"Jacobian Condition Number: {jacobian_cond(jac=jac, scaled=False):.3E}"
)
except RuntimeError as err:
if "Factor is exactly singular" in str(err):
_log.info(err)
stats.append("Jacobian Condition Number: Undefined (Exactly Singular)")
else:
raise

Check warning on line 1422 in idaes/core/util/model_diagnostics.py

View check run for this annotation

Codecov / codecov/patch

idaes/core/util/model_diagnostics.py#L1422

Added line #L1422 was not covered by tests

_write_report_section(
stream=stream, lines_list=stats, title="Model Statistics", header="="
)
Expand Down
43 changes: 42 additions & 1 deletion idaes/core/util/tests/test_model_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
acos,
sqrt,
Objective,
PositiveIntegers,
Set,
SolverFactory,
Suffix,
Expand Down Expand Up @@ -1342,6 +1343,46 @@ def test_report_numerical_issues_ok(self):
prepare_degeneracy_hunter()
prepare_svd_toolbox()

====================================================================================
"""

assert stream.getvalue() == expected

@pytest.mark.component
def test_report_numerical_issues_exactly_singular(self):
m = ConcreteModel()
m.x = Var([1, 2], initialize=1.0)
m.eq = Constraint(PositiveIntegers)
m.eq[1] = m.x[1] * m.x[2] == 1.5
m.eq[2] = m.x[2] * m.x[1] == 1.5
m.obj = Objective(expr=m.x[1] ** 2 + 2 * m.x[2] ** 2)

dt = DiagnosticsToolbox(m)
dt.report_numerical_issues()

stream = StringIO()
dt.report_numerical_issues(stream)

expected = """====================================================================================
Model Statistics

Jacobian Condition Number: Undefined (Exactly Singular)

------------------------------------------------------------------------------------
1 WARNINGS

WARNING: 2 Constraints with large residuals (>1.0E-05)

------------------------------------------------------------------------------------
0 Cautions

No cautions found!

------------------------------------------------------------------------------------
Suggested next steps:

display_constraints_with_large_residuals()

====================================================================================
"""

Expand Down Expand Up @@ -2241,7 +2282,7 @@ def test_run_ipopt_with_stats(self):
assert iters == 1
assert iters_in_restoration == 0
assert iters_w_regularization == 0
assert time < 0.01
assert isinstance(time, float)

@pytest.mark.component
@pytest.mark.solver
Expand Down
4 changes: 2 additions & 2 deletions idaes/models/properties/examples/saponification_thermo.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ def build(self):

# Heat capacity of water
self.cp_mol = Param(
mutable=False,
mutable=True,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the reason for making the Params mutable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As they have units, Pyomo was implicitly making them mutable and logging a warning. I changed them to be explicitly mutable to suppress the warning.

initialize=75.327,
doc="Molar heat capacity of water [J/mol.K]",
units=units.J / units.mol / units.K,
)

# Density of water
self.dens_mol = Param(
mutable=False,
mutable=True,
initialize=55388.0,
doc="Molar density of water [mol/m^3]",
units=units.mol / units.m**3,
Expand Down
4 changes: 2 additions & 2 deletions idaes/models/unit_models/heat_exchanger.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,11 +814,11 @@ def _get_performance_contents(self, time_point=0):
var_dict["Heat Duty"] = self.heat_duty[time_point]
if self.config.flow_pattern == HeatExchangerFlowPattern.crossflow:
var_dict["Crossflow Factor"] = self.crossflow_factor[time_point]
var_dict["Delta T In"] = self.delta_temperature_in[time_point]
var_dict["Delta T Out"] = self.delta_temperature_out[time_point]

expr_dict = {}
expr_dict["Delta T Driving"] = self.delta_temperature[time_point]
expr_dict["Delta T In"] = self.delta_temperature_in[time_point]
expr_dict["Delta T Out"] = self.delta_temperature_out[time_point]

return {"vars": var_dict, "exprs": expr_dict}

Expand Down
27 changes: 14 additions & 13 deletions idaes/models/unit_models/tests/test_cstr.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import pytest

from pyomo.environ import check_optimal_termination, ConcreteModel, units, value
from pyomo.util.check_units import assert_units_consistent, assert_units_equivalent

from idaes.core import (
FlowsheetBlock,
Expand All @@ -34,7 +33,6 @@
SaponificationReactionParameterBlock,
)
from idaes.core.util.model_statistics import (
degrees_of_freedom,
number_variables,
number_total_constraints,
number_unused_variables,
Expand All @@ -50,6 +48,8 @@
SingleControlVolumeUnitInitializer,
InitializationStatus,
)
from idaes.core.util import DiagnosticsToolbox


# -----------------------------------------------------------------------------
# Get default solver for testing
Expand Down Expand Up @@ -109,8 +109,8 @@ def sapon(self):
m.fs.unit.inlet.conc_mol_comp[0, "H2O"].fix(55388.0)
m.fs.unit.inlet.conc_mol_comp[0, "NaOH"].fix(100.0)
m.fs.unit.inlet.conc_mol_comp[0, "EthylAcetate"].fix(100.0)
m.fs.unit.inlet.conc_mol_comp[0, "SodiumAcetate"].fix(0.0)
m.fs.unit.inlet.conc_mol_comp[0, "Ethanol"].fix(0.0)
m.fs.unit.inlet.conc_mol_comp[0, "SodiumAcetate"].fix(1e-8)
m.fs.unit.inlet.conc_mol_comp[0, "Ethanol"].fix(1e-8)

m.fs.unit.inlet.temperature.fix(303.15)
m.fs.unit.inlet.pressure.fix(101325.0)
Expand Down Expand Up @@ -149,15 +149,9 @@ def test_build(self, sapon):
assert number_unused_variables(sapon) == 0

@pytest.mark.component
def test_units(self, sapon):
assert_units_consistent(sapon)
assert_units_equivalent(sapon.fs.unit.volume[0], units.m**3)
assert_units_equivalent(sapon.fs.unit.heat_duty[0], units.W)
assert_units_equivalent(sapon.fs.unit.deltaP[0], units.Pa)

@pytest.mark.unit
def test_dof(self, sapon):
assert degrees_of_freedom(sapon) == 0
def test_structural_issues(self, sapon):
dt = DiagnosticsToolbox(sapon)
dt.assert_no_structural_warnings()

@pytest.mark.solver
@pytest.mark.skipif(solver is None, reason="Solver not available")
Expand Down Expand Up @@ -248,6 +242,13 @@ def test_conservation(self, sapon):
<= 1e-3
)

@pytest.mark.solver
@pytest.mark.skipif(solver is None, reason="Solver not available")
@pytest.mark.component
def test_numerical_issues(self, sapon):
dt = DiagnosticsToolbox(sapon)
dt.assert_no_numerical_warnings()

@pytest.mark.ui
@pytest.mark.unit
def test_get_performance_contents(self, sapon):
Expand Down
27 changes: 13 additions & 14 deletions idaes/models/unit_models/tests/test_equilibrium_reactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
SaponificationReactionParameterBlock,
)
from idaes.core.util.model_statistics import (
degrees_of_freedom,
number_variables,
number_total_constraints,
number_unused_variables,
Expand All @@ -44,14 +43,12 @@
initialization_tester,
)
from idaes.core.solvers import get_solver
from pyomo.util.check_units import assert_units_consistent, assert_units_equivalent
from idaes.core.util.exceptions import InitializationError

from idaes.core.initialization import (
BlockTriangularizationInitializer,
SingleControlVolumeUnitInitializer,
InitializationStatus,
)
from idaes.core.util import DiagnosticsToolbox

# -----------------------------------------------------------------------------
# Get default solver for testing
Expand Down Expand Up @@ -116,8 +113,8 @@ def sapon(self):
m.fs.unit.inlet.conc_mol_comp[0, "H2O"].fix(55388.0)
m.fs.unit.inlet.conc_mol_comp[0, "NaOH"].fix(100.0)
m.fs.unit.inlet.conc_mol_comp[0, "EthylAcetate"].fix(100.0)
m.fs.unit.inlet.conc_mol_comp[0, "SodiumAcetate"].fix(0.0)
m.fs.unit.inlet.conc_mol_comp[0, "Ethanol"].fix(0.0)
m.fs.unit.inlet.conc_mol_comp[0, "SodiumAcetate"].fix(1e-8)
m.fs.unit.inlet.conc_mol_comp[0, "Ethanol"].fix(1e-8)

m.fs.unit.inlet.temperature.fix(303.15)
m.fs.unit.inlet.pressure.fix(101325.0)
Expand Down Expand Up @@ -153,14 +150,9 @@ def test_build(self, sapon):
assert number_unused_variables(sapon) == 0

@pytest.mark.component
def test_units(self, sapon):
assert_units_consistent(sapon)
assert_units_equivalent(sapon.fs.unit.heat_duty[0], units.W)
assert_units_equivalent(sapon.fs.unit.deltaP[0], units.Pa)

@pytest.mark.unit
def test_dof(self, sapon):
assert degrees_of_freedom(sapon) == 0
def test_structural_issues(self, sapon):
dt = DiagnosticsToolbox(sapon)
dt.assert_no_structural_warnings()

@pytest.mark.solver
@pytest.mark.skipif(solver is None, reason="Solver not available")
Expand Down Expand Up @@ -241,6 +233,13 @@ def test_conservation(self, sapon):
<= 1e-1
)

@pytest.mark.solver
@pytest.mark.skipif(solver is None, reason="Solver not available")
@pytest.mark.component
def test_numerical_issues(self, sapon):
dt = DiagnosticsToolbox(sapon)
dt.assert_no_numerical_warnings()

@pytest.mark.ui
@pytest.mark.unit
def test_get_performance_contents(self, sapon):
Expand Down
Loading
Loading