From 79d702f123e75469d0654c0adfeca5cea09443bb Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 11 Jul 2023 09:03:26 -0400 Subject: [PATCH 01/48] Initial outline of diagnostics toolbox --- idaes/core/util/model_diagnostics.py | 357 +++++++++++++++--- .../core/util/tests/test_model_diagnostics.py | 157 +++++--- 2 files changed, 424 insertions(+), 90 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 60d4d84adf..89e2a65fee 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -17,29 +17,300 @@ __author__ = "Alexander Dowling, Douglas Allan, Andrew Lee" - from operator import itemgetter +from sys import stdout -import pyomo.environ as pyo -from pyomo.core.expr.visitor import identify_variables -from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP -from pyomo.contrib.pynumero.asl import AmplInterface -from pyomo.core.base.block import _BlockData import numpy as np from scipy.linalg import svd from scipy.sparse.linalg import svds, norm from scipy.sparse import issparse, find +from pyomo.environ import ( + Binary, + Block, + check_optimal_termination, + ConcreteModel, + Constraint, + Objective, + Param, + Set, + SolverFactory, + value, + Var, +) +from pyomo.core.base.block import _BlockData +from pyomo.common.collections import ComponentSet +from pyomo.util.check_units import assert_units_consistent +from pyomo.core.base.units_container import UnitsError +from pyomo.contrib.incidence_analysis import IncidenceGraphInterface +from pyomo.core.expr.visitor import identify_variables +from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP +from pyomo.contrib.pynumero.asl import AmplInterface + +import idaes.core.util.scaling as iscale from idaes.core.util.model_statistics import ( + activated_blocks_set, + deactivated_blocks_set, + activated_equalities_set, + deactivated_equalities_set, + activated_inequalities_set, + deactivated_inequalities_set, + activated_objectives_set, + deactivated_objectives_set, + variables_in_activated_constraints_set, + variables_not_in_activated_constraints_set, + degrees_of_freedom, large_residuals_set, variables_near_bounds_set, ) -import idaes.core.util.scaling as iscale import idaes.logger as idaeslog _log = idaeslog.getLogger(__name__) +class DiagnosticsToolbox: + def __init__(self, model: Block): + if not isinstance(model, Block): + raise ValueError("model argument must be an instance of a Pyomo Block.") + self.model = model + + # Create placeholders for data + self._activated__block_set = ComponentSet() + self._deactivated_block_set = ComponentSet() + self._activated_equalities_set = ComponentSet() + self._deactivated_equalities_set = ComponentSet() + self._activated_inequalities_set = ComponentSet() + self._deactivated_inequalities_set = ComponentSet() + self._activated_objectives_set = ComponentSet() + self._deactivated_objectives_set = ComponentSet() + self._variables_fixed_to_zero_set = ComponentSet() + self._variables_in_activated_constraints_set = ComponentSet() + self._fixed_variables_in_activated_constraints_set = ComponentSet() + self._unfixed_variables_in_activated_constraints_set = ComponentSet() + self._external_fixed_variables_in_activated_constraints_set = ComponentSet() + self._external_unfixed_variables_in_activated_constraints_set = ComponentSet() + self._variables_not_in_activated_constraints_set = ComponentSet() + self._fixed_variables_not_in_activated_constraints_set = ComponentSet() + self._degrees_of_freedom = None + + self._constraints_with_inconsistent_units = ComponentSet() + + self._var_dm_partition = None + self._con_dm_partition = None + self._uc_var = None + self._uc_con = None + self._oc_var = None + self._oc_con = None + + def collect_model_statistics(self): + # For now, just use model_statistics tools. + # In future, we may want to look at reworking these to avoid repeatedly + # iterating over the model. + + # TODO: Variables with bounds + + # Block Statistics + self._activated_block_set = activated_blocks_set(self.model) + self._deactivated_block_set = deactivated_blocks_set(self.model) + + # # Constraint statistics + self._activated_equalities_set = activated_equalities_set(self.model) + self._deactivated_equalities_set = deactivated_equalities_set(self.model) + self._activated_inequalities_set = activated_inequalities_set(self.model) + self._deactivated_inequalities_set = deactivated_inequalities_set(self.model) + + # # Objective statistics + self._activated_objectives_set = activated_objectives_set(self.model) + self._deactivated_objectives_set = deactivated_objectives_set(self.model) + + # Variable statistics + self._variables_in_activated_constraints_set = ( + variables_in_activated_constraints_set(self.model) + ) + self._fixed_variables_in_activated_constraints_set = ComponentSet() + self._unfixed_variables_in_activated_constraints_set = ComponentSet() + self._external_fixed_variables_in_activated_constraints_set = ComponentSet() + self._external_unfixed_variables_in_activated_constraints_set = ComponentSet() + + def var_in_block(var, block): + parent = var.parent_block() + while parent is not None: + if parent is block: + return True + parent = parent.parent_block() + return False + + for v in self._variables_in_activated_constraints_set: + if v.fixed: + self._fixed_variables_in_activated_constraints_set.add(v) + if not var_in_block(v, self.model): + self._external_fixed_variables_in_activated_constraints_set.add(v) + else: + self._unfixed_variables_in_activated_constraints_set.add(v) + if not var_in_block(v, self.model): + self._external_unfixed_variables_in_activated_constraints_set.add(v) + + # Set of variables fixed to 0 + self._variables_fixed_to_zero_set = ComponentSet() + for v in self.model.component_data_objects(Var, descend_into=True): + if v.fixed and value(v) == 0: + self._variables_fixed_to_zero_set.add(v) + + # TODO: Need to see if this includes inequalities or not + self._variables_not_in_activated_constraints_set = ( + variables_not_in_activated_constraints_set(self.model) + ) + # Set of Unused fixed variables + self._fixed_variables_not_in_activated_constraints_set = ComponentSet() + for v in self._variables_not_in_activated_constraints_set: + if v.fixed: + self._fixed_variables_not_in_activated_constraints_set.add(v) + + # Calculate DoF + self._degrees_of_freedom = degrees_of_freedom(self.model) + + def check_unit_consistency(self): + # Check unit consistency of each constraint + self._constraints_with_inconsistent_units = ComponentSet() + for c in self.model.component_data_objects(Constraint, descend_into=True): + try: + assert_units_consistent(c) + except UnitsError: + self._constraints_with_inconsistent_units.add(c) + + def check_dulmage_mendelsohn_partition(self): + self._var_dm_partition = None + self._con_dm_partition = None + self._uc_var = None + self._uc_con = None + self._oc_var = None + self._oc_con = None + + igraph = IncidenceGraphInterface(self.model) + self._var_dm_partition, self._con_dm_partition = igraph.dulmage_mendelsohn() + + # Collect under- and order-constrained sub-system + self._uc_var = ( + self._var_dm_partition.unmatched + self._var_dm_partition.underconstrained + ) + self._uc_con = self._con_dm_partition.underconstrained + self._oc_var = self._var_dm_partition.overconstrained + self._oc_con = ( + self._con_dm_partition.overconstrained + self._con_dm_partition.unmatched + ) + + # TODO: Block triangularization analysis + # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? + + def report_structural_issues(self, rerun_analysis=True, stream=stdout): + # Potential evaluation errors + # High Index + + # Run checks unless told not to + if rerun_analysis: + self.collect_model_statistics() + self.check_unit_consistency() + self.check_dulmage_mendelsohn_partition() + + # Collect warnings + tab = " " * 4 + warnings = [] + if self._degrees_of_freedom != 0: + dstring = "Degrees" + if self._degrees_of_freedom == abs(1): + dstring = "Degree" + warnings.append( + f"\n{tab}WARNING: {self._degrees_of_freedom} {dstring} of Freedom" + ) + if len(self._constraints_with_inconsistent_units) > 0: + cstring = "Constraints" + if len(self._constraints_with_inconsistent_units) == 1: + cstring = "Constraint" + warnings.append( + f"\n{tab}WARNING: {len(self._constraints_with_inconsistent_units)} " + f"{cstring} with inconsistent units" + ) + if any( + len(x) > 0 for x in [self._uc_var, self._uc_con, self._oc_var, self._oc_con] + ): + warnings.append( + f"\n{tab}WARNING: Structural singularity found\n" + f"{tab*2}Under-Constrained Set: {len(self._uc_var)} " + f"variables, {len(self._uc_con)} constraints\n" + f"{tab * 2}Over-Constrained Set: {len(self._oc_var)} " + f"variables, {len(self._oc_con)} constraints" + ) + + # Collect cautions + cautions = [] + if len(self._variables_fixed_to_zero_set) > 0: + vstring = "variables" + if len(self._variables_fixed_to_zero_set) == 1: + vstring = "variable" + cautions.append( + f"\n{tab}Caution: {len(self._variables_fixed_to_zero_set)} " + f"{vstring} fixed to 0" + ) + if len(self._variables_not_in_activated_constraints_set) > 0: + vstring = "variables" + if len(self._variables_not_in_activated_constraints_set) == 1: + vstring = "variable" + cautions.append( + f"\n{tab}Caution: {len(self._variables_not_in_activated_constraints_set)} " + f"unused {vstring} " + f"({len(self._fixed_variables_not_in_activated_constraints_set)} fixed)" + ) + + # Generate report + max_str_length = 84 + stream.write("\n" + "=" * max_str_length + "\n") + stream.write("Model Statistics\n\n") + stream.write( + f"{tab}Activated Blocks: {len(self._activated_block_set)} " + f"(Deactivated: {len(self._deactivated_block_set)})\n" + ) + stream.write( + f"{tab}Free Variables in Activated Constraints: " + f"{len(self._unfixed_variables_in_activated_constraints_set)} " + f"(External: {len(self._external_unfixed_variables_in_activated_constraints_set)})\n" + ) + stream.write( + f"{tab}Fixed Variables in Activated Constraints: " + f"{len(self._fixed_variables_in_activated_constraints_set)} " + f"(External: {len(self._external_fixed_variables_in_activated_constraints_set)})\n" + ) + stream.write( + f"{tab}Activated Equality Constraints: {len(self._activated_equalities_set)} " + f"(Deactivated: {len(self._deactivated_equalities_set)})\n" + ) + stream.write( + f"{tab}Activated Inequality Constraints: {len(self._activated_inequalities_set)} " + f"(Deactivated: {len(self._deactivated_inequalities_set)})\n" + ) + stream.write( + f"{tab}Activated Objectives: {len(self._activated_objectives_set)} " + f"(Deactivated: {len(self._deactivated_objectives_set)})\n" + ) + + stream.write("\n" + "-" * max_str_length + "\n") + if len(warnings) > 0: + stream.write(f"{len(warnings)} WARNINGS\n") + for w in warnings: + stream.write(w) + else: + stream.write("No warnings found!\n") + + stream.write("\n\n" + "-" * max_str_length + "\n") + if len(cautions) > 0: + stream.write(f"{len(cautions)} Cautions\n") + for c in cautions: + stream.write(c) + else: + stream.write("No cautions found!\n") + + stream.write("\n\n" + "=" * max_str_length + "\n") + + class DegeneracyHunter: """ Degeneracy Hunter is a collection of utility functions to assist in mathematical @@ -60,7 +331,7 @@ def __init__(self, block_or_jac, solver=None): block_like = False try: - block_like = issubclass(block_or_jac.ctype, pyo.Block) + block_like = issubclass(block_or_jac.ctype, Block) except AttributeError: pass @@ -103,7 +374,7 @@ def __init__(self, block_or_jac, solver=None): # Initialize solver if solver is None: # TODO: Test performance with open solvers such as cbc - self.solver = pyo.SolverFactory("gurobi") + self.solver = SolverFactory("gurobi") self.solver.options = {"NumericFocus": 3} else: @@ -273,16 +544,16 @@ def _prepare_ids_milp(jac_eq, M=1e5): n_var = jac_eq.shape[1] # Create Pyomo model for irreducible degenerate set - m_dh = pyo.ConcreteModel() + m_dh = ConcreteModel() # Create index for constraints - m_dh.C = pyo.Set(initialize=range(n_eq)) + m_dh.C = Set(initialize=range(n_eq)) - m_dh.V = pyo.Set(initialize=range(n_var)) + m_dh.V = Set(initialize=range(n_var)) # Add variables - m_dh.nu = pyo.Var(m_dh.C, bounds=(-M, M), initialize=1.0) - m_dh.y = pyo.Var(m_dh.C, domain=pyo.Binary) + m_dh.nu = Var(m_dh.C, bounds=(-M, M), initialize=1.0) + m_dh.y = Var(m_dh.C, domain=Binary) # Constraint to enforce set is degenerate if issparse(jac_eq): @@ -299,19 +570,19 @@ def eq_degenerate(m_dh, v): def eq_degenerate(m_dh, v): return sum(m_dh.J[c, v] * m_dh.nu[c] for c in m_dh.C) == 0 - m_dh.degenerate = pyo.Constraint(m_dh.V, rule=eq_degenerate) + m_dh.degenerate = Constraint(m_dh.V, rule=eq_degenerate) def eq_lower(m_dh, c): return -M * m_dh.y[c] <= m_dh.nu[c] - m_dh.lower = pyo.Constraint(m_dh.C, rule=eq_lower) + m_dh.lower = Constraint(m_dh.C, rule=eq_lower) def eq_upper(m_dh, c): return m_dh.nu[c] <= M * m_dh.y[c] - m_dh.upper = pyo.Constraint(m_dh.C, rule=eq_upper) + m_dh.upper = Constraint(m_dh.C, rule=eq_upper) - m_dh.obj = pyo.Objective(expr=sum(m_dh.y[c] for c in m_dh.C)) + m_dh.obj = Objective(expr=sum(m_dh.y[c] for c in m_dh.C)) return m_dh @@ -335,23 +606,23 @@ def _prepare_find_candidates_milp(jac_eq, M=1e5, m_small=1e-5): n_var = jac_eq.shape[1] # Create Pyomo model for irreducible degenerate set - m_dh = pyo.ConcreteModel() + m_dh = ConcreteModel() # Create index for constraints - m_dh.C = pyo.Set(initialize=range(n_eq)) + m_dh.C = Set(initialize=range(n_eq)) - m_dh.V = pyo.Set(initialize=range(n_var)) + m_dh.V = Set(initialize=range(n_var)) # Specify minimum size for nu to be considered non-zero m_dh.m_small = m_small # Add variables - m_dh.nu = pyo.Var(m_dh.C, bounds=(-M - m_small, M + m_small), initialize=1.0) - m_dh.y_pos = pyo.Var(m_dh.C, domain=pyo.Binary) - m_dh.y_neg = pyo.Var(m_dh.C, domain=pyo.Binary) - m_dh.abs_nu = pyo.Var(m_dh.C, bounds=(0, M + m_small)) + m_dh.nu = Var(m_dh.C, bounds=(-M - m_small, M + m_small), initialize=1.0) + m_dh.y_pos = Var(m_dh.C, domain=Binary) + m_dh.y_neg = Var(m_dh.C, domain=Binary) + m_dh.abs_nu = Var(m_dh.C, bounds=(0, M + m_small)) - m_dh.pos_xor_neg = pyo.Constraint(m_dh.C) + m_dh.pos_xor_neg = Constraint(m_dh.C) # Constraint to enforce set is degenerate if issparse(jac_eq): @@ -364,7 +635,7 @@ def eq_degenerate(m_dh, v): return sum(m_dh.J[c, v] * m_dh.nu[c] for c in C_) == 0 else: # This variable does not appear in any constraint - return pyo.Constraint.Skip + return Constraint.Skip else: m_dh.J = jac_eq @@ -374,58 +645,58 @@ def eq_degenerate(m_dh, v): return sum(m_dh.J[c, v] * m_dh.nu[c] for c in m_dh.C) == 0 else: # This variable does not appear in any constraint - return pyo.Constraint.Skip + return Constraint.Skip m_dh.pprint() - m_dh.degenerate = pyo.Constraint(m_dh.V, rule=eq_degenerate) + m_dh.degenerate = Constraint(m_dh.V, rule=eq_degenerate) # When y_pos = 1, nu >= m_small # When y_pos = 0, nu >= - m_small def eq_pos_lower(m_dh, c): return m_dh.nu[c] >= -m_small + 2 * m_small * m_dh.y_pos[c] - m_dh.pos_lower = pyo.Constraint(m_dh.C, rule=eq_pos_lower) + m_dh.pos_lower = Constraint(m_dh.C, rule=eq_pos_lower) # When y_pos = 1, nu <= M + m_small # When y_pos = 0, nu <= m_small def eq_pos_upper(m_dh, c): return m_dh.nu[c] <= M * m_dh.y_pos[c] + m_small - m_dh.pos_upper = pyo.Constraint(m_dh.C, rule=eq_pos_upper) + m_dh.pos_upper = Constraint(m_dh.C, rule=eq_pos_upper) # When y_neg = 1, nu <= -m_small # When y_neg = 0, nu <= m_small def eq_neg_upper(m_dh, c): return m_dh.nu[c] <= m_small - 2 * m_small * m_dh.y_neg[c] - m_dh.neg_upper = pyo.Constraint(m_dh.C, rule=eq_neg_upper) + m_dh.neg_upper = Constraint(m_dh.C, rule=eq_neg_upper) # When y_neg = 1, nu >= -M - m_small # When y_neg = 0, nu >= - m_small def eq_neg_lower(m_dh, c): return m_dh.nu[c] >= -M * m_dh.y_neg[c] - m_small - m_dh.neg_lower = pyo.Constraint(m_dh.C, rule=eq_neg_lower) + m_dh.neg_lower = Constraint(m_dh.C, rule=eq_neg_lower) # Absolute value def eq_abs_lower(m_dh, c): return -m_dh.abs_nu[c] <= m_dh.nu[c] - m_dh.abs_lower = pyo.Constraint(m_dh.C, rule=eq_abs_lower) + m_dh.abs_lower = Constraint(m_dh.C, rule=eq_abs_lower) def eq_abs_upper(m_dh, c): return m_dh.nu[c] <= m_dh.abs_nu[c] - m_dh.abs_upper = pyo.Constraint(m_dh.C, rule=eq_abs_upper) + m_dh.abs_upper = Constraint(m_dh.C, rule=eq_abs_upper) # At least one constraint must be in the degenerate set - m_dh.degenerate_set_nonempty = pyo.Constraint( + m_dh.degenerate_set_nonempty = Constraint( expr=sum(m_dh.y_pos[c] + m_dh.y_neg[c] for c in m_dh.C) >= 1 ) # Minimize the L1-norm of nu - m_dh.obj = pyo.Objective(expr=sum(m_dh.abs_nu[c] for c in m_dh.C)) + m_dh.obj = Objective(expr=sum(m_dh.abs_nu[c] for c in m_dh.C)) return m_dh @@ -455,7 +726,7 @@ def _check_candidate_ids(ids_milp, solver, c, eq_con_list=None, tee=False): ids_milp.nu[c].unfix() - if pyo.check_optimal_termination(results): + if check_optimal_termination(results): # We found an irreducible degenerate set # Create empty dictionary @@ -493,7 +764,7 @@ def _find_candidate_eqs(candidates_milp, solver, eq_con_list=None, tee=False): results = solver.solve(candidates_milp, tee=tee) - if pyo.check_optimal_termination(results): + if check_optimal_termination(results): # We found a degenerate set # Create empty dictionary @@ -774,7 +1045,7 @@ def set_bounds_from_valid_range(component, descend_into=True): set_bounds_from_valid_range(component[k]) elif isinstance(component, _BlockData): for i in component.component_data_objects( - ctype=[pyo.Var, pyo.Param], descend_into=descend_into + ctype=[Var, Param], descend_into=descend_into ): set_bounds_from_valid_range(i) elif not hasattr(component, "bounds"): @@ -815,14 +1086,14 @@ def list_components_with_values_outside_valid_range(component, descend_into=True ) elif isinstance(component, _BlockData): for i in component.component_data_objects( - ctype=[pyo.Var, pyo.Param], descend_into=descend_into + ctype=[Var, Param], descend_into=descend_into ): comp_list.extend(list_components_with_values_outside_valid_range(i)) else: valid_range = get_valid_range_of_component(component) if valid_range is not None: - cval = pyo.value(component) + cval = value(component) if cval is not None and (cval < valid_range[0] or cval > valid_range[1]): comp_list.append(component) @@ -847,7 +1118,7 @@ def ipopt_solve_halt_on_error(model, options=None): if options is None: options = {} - solver = pyo.SolverFactory("ipopt") + solver = SolverFactory("ipopt") solver.options = options solver.options["halt_on_ampl_error"] = "yes" diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 97ff1e3da2..acd63c5eb7 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -16,8 +16,18 @@ import pytest -# Need to update -import pyomo.environ as pyo +from pyomo.environ import ( + Block, + ConcreteModel, + Constraint, + Expression, + log, + Objective, + Set, + SolverFactory, + units, + Var, +) from pyomo.contrib.pynumero.asl import AmplInterface import numpy as np import idaes.core.util.scaling as iscale @@ -34,6 +44,7 @@ # Need to update from idaes.core.util.model_diagnostics import ( + DiagnosticsToolbox, DegeneracyHunter, get_valid_range_of_component, set_bounds_from_valid_range, @@ -44,13 +55,65 @@ __author__ = "Alex Dowling, Douglas Allan" +@pytest.mark.integration +def test_DiagnosticsToolbox(): + m = ConcreteModel() + + # A var outside the model + m.v = Var(units=units.kg) + + # Model to be tested + m.b = Block() + + m.b.v1 = Var(units=units.s) + m.b.v2 = Var(units=units.s) + m.b.v3 = Var(units=units.s) + m.b.v4 = Var(units=units.s) + + # Unused var + m.b.v5 = Var(units=units.s) + m.b.v5.fix(0) + + # Linearly dependent constraints + m.b.c1 = Constraint(expr=m.b.v1 + m.b.v2 == 10 * units.s) + m.b.c2 = Constraint(expr=2 * m.b.v1 + 2 * m.b.v2 == 20 * units.s) + m.b.c3 = Constraint(expr=m.b.v3 + m.b.v4 == 20 * units.s) + + # An inequality + m.b.c4 = Constraint(expr=m.b.v1 + m.v >= 10 * units.s) + + # Deactivated constraint + m.b.c5 = Constraint(expr=m.b.v1 == 15 * units.s) + m.b.c5.deactivate() + + # An objective + m.b.o1 = Objective(expr=m.b.v2) + # A deactivated objective + m.b.o2 = Objective(expr=m.b.v2**2) + m.b.o2.deactivate() + + # Create instance of Diagnostics Toolbox + dt = DiagnosticsToolbox(model=m.b) + + dt.report_structural_issues() + + m.b.v3.fix(5) + + dt.report_structural_issues() + for c in dt._uc_con: + print(c.name) + + # TODO: Current checks do not detect linearly dependent equation + assert False + + @pytest.fixture() def dummy_problem(): - m = pyo.ConcreteModel() + m = ConcreteModel() - m.I = pyo.Set(initialize=[i for i in range(5)]) + m.I = Set(initialize=[i for i in range(5)]) - m.x = pyo.Var(m.I, initialize=1.0) + m.x = Var(m.I, initialize=1.0) diag = [100, 1, 10, 0.1, 5] out = [1, 1, 1, 1, 1] @@ -59,7 +122,7 @@ def dummy_problem(): def dummy_eqn(b, i): return out[i] == diag[i] * m.x[i] - m.obj = pyo.Objective(expr=0) + m.obj = Objective(expr=0) return m @@ -208,10 +271,10 @@ def test_sv_value_error(dummy_problem): ) @pytest.mark.unit def test_single_eq_error(capsys): - m = pyo.ConcreteModel() - m.x = pyo.Var(initialize=1) - m.con = pyo.Constraint(expr=(2 * m.x == 1)) - m.obj = pyo.Objective(expr=0) + m = ConcreteModel() + m.x = Var(initialize=1) + m.con = Constraint(expr=(2 * m.x == 1)) + m.obj = Objective(expr=0) dh = DegeneracyHunter(m) with pytest.raises( @@ -232,17 +295,17 @@ def test_single_eq_error(capsys): # This was from # @pytest.fixture() def problem1(): - m = pyo.ConcreteModel() + m = ConcreteModel() - m.I = pyo.Set(initialize=[i for i in range(5)]) + m.I = Set(initialize=[i for i in range(5)]) - m.x = pyo.Var(m.I, bounds=(-10, 10), initialize=1.0) + m.x = Var(m.I, bounds=(-10, 10), initialize=1.0) - m.con1 = pyo.Constraint(expr=m.x[0] + m.x[1] - m.x[3] >= 10) - m.con2 = pyo.Constraint(expr=m.x[0] * m.x[3] + m.x[1] >= 0) - m.con3 = pyo.Constraint(expr=m.x[4] * m.x[3] + m.x[0] * m.x[3] - m.x[4] == 0) + m.con1 = Constraint(expr=m.x[0] + m.x[1] - m.x[3] >= 10) + m.con2 = Constraint(expr=m.x[0] * m.x[3] + m.x[1] >= 0) + m.con3 = Constraint(expr=m.x[4] * m.x[3] + m.x[0] * m.x[3] - m.x[4] == 0) - m.obj = pyo.Objective(expr=sum(m.x[i] ** 2 for i in m.I)) + m.obj = Objective(expr=sum(m.x[i] ** 2 for i in m.I)) return m @@ -257,21 +320,21 @@ def example2(with_degenerate_constraint=True): m2: Pyomo model """ - m2 = pyo.ConcreteModel() + m2 = ConcreteModel() - m2.I = pyo.Set(initialize=[i for i in range(1, 4)]) + m2.I = Set(initialize=[i for i in range(1, 4)]) - m2.x = pyo.Var(m2.I, bounds=(0, 5), initialize=1.0) + m2.x = Var(m2.I, bounds=(0, 5), initialize=1.0) - m2.con1 = pyo.Constraint(expr=m2.x[1] + m2.x[2] >= 1) - m2.con2 = pyo.Constraint(expr=m2.x[1] + m2.x[2] + m2.x[3] == 1) - m2.con3 = pyo.Constraint(expr=m2.x[2] - 2 * m2.x[3] <= 1) - m2.con4 = pyo.Constraint(expr=m2.x[1] + m2.x[3] >= 1) + m2.con1 = Constraint(expr=m2.x[1] + m2.x[2] >= 1) + m2.con2 = Constraint(expr=m2.x[1] + m2.x[2] + m2.x[3] == 1) + m2.con3 = Constraint(expr=m2.x[2] - 2 * m2.x[3] <= 1) + m2.con4 = Constraint(expr=m2.x[1] + m2.x[3] >= 1) if with_degenerate_constraint: - m2.con5 = pyo.Constraint(expr=m2.x[1] + m2.x[2] + m2.x[3] == 1) + m2.con5 = Constraint(expr=m2.x[1] + m2.x[2] + m2.x[3] == 1) - m2.obj = pyo.Objective(expr=sum(m2.x[i] for i in m2.I)) + m2.obj = Objective(expr=sum(m2.x[i] for i in m2.I)) return m2 @@ -297,14 +360,14 @@ def extract_constraint_names(cs): @pytest.mark.skipif( not AmplInterface.available(), reason="pynumero_ASL is not available" ) -@pytest.mark.skipif(not pyo.SolverFactory("ipopt").available(False), reason="no Ipopt") +@pytest.mark.skipif(not SolverFactory("ipopt").available(False), reason="no Ipopt") @pytest.mark.unit def test_problem1(): # Create test problem m = problem1() # Specify Ipopt as the solver - opt = pyo.SolverFactory("ipopt") + opt = SolverFactory("ipopt") # Specifying an iteration limit of 0 allows us to inspect the initial point opt.options["max_iter"] = 0 @@ -352,14 +415,14 @@ def test_problem1(): @pytest.mark.skipif( not AmplInterface.available(), reason="pynumero_ASL is not available" ) -@pytest.mark.skipif(not pyo.SolverFactory("ipopt").available(False), reason="no Ipopt") +@pytest.mark.skipif(not SolverFactory("ipopt").available(False), reason="no Ipopt") @pytest.mark.unit def test_problem2_without_degenerate_constraint(): # Create test problem instance m2 = example2(with_degenerate_constraint=False) # Specify Ipopt as the solver - opt = pyo.SolverFactory("ipopt") + opt = SolverFactory("ipopt") # Specifying an iteration limit of 0 allows us to inspect the initial point opt.options["max_iter"] = 0 @@ -401,14 +464,14 @@ def test_problem2_without_degenerate_constraint(): @pytest.mark.skipif( not AmplInterface.available(), reason="pynumero_ASL is not available" ) -@pytest.mark.skipif(not pyo.SolverFactory("ipopt").available(False), reason="no Ipopt") +@pytest.mark.skipif(not SolverFactory("ipopt").available(False), reason="no Ipopt") @pytest.mark.unit def test_problem2_with_degenerate_constraint(): # Create test problem instance m2 = example2(with_degenerate_constraint=True) # Specify Ipopt as the solver - opt = pyo.SolverFactory("ipopt") + opt = SolverFactory("ipopt") # Specifying an iteration limit of 0 allows us to inspect the initial point opt.options["max_iter"] = 0 @@ -460,7 +523,7 @@ def test_problem2_with_degenerate_constraint(): @pytest.mark.unit def test_get_valid_range_of_component(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -478,7 +541,7 @@ def test_get_valid_range_of_component(): @pytest.mark.unit def test_get_valid_range_of_component_no_metadata(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() with pytest.raises( @@ -489,7 +552,7 @@ def test_get_valid_range_of_component_no_metadata(): @pytest.mark.unit def test_get_valid_range_of_component_no_metadata_entry(caplog): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -506,7 +569,7 @@ def test_get_valid_range_of_component_no_metadata_entry(caplog): @pytest.mark.unit def test_set_bounds_from_valid_range_scalar(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -528,7 +591,7 @@ def test_set_bounds_from_valid_range_scalar(): @pytest.mark.unit def test_set_bounds_from_valid_range_indexed(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -553,7 +616,7 @@ def test_set_bounds_from_valid_range_indexed(): @pytest.mark.unit def test_set_bounds_from_valid_range_block(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -572,10 +635,10 @@ def test_set_bounds_from_valid_range_block(): @pytest.mark.unit def test_set_bounds_from_valid_range_invalid_type(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() - m.fs.foo = pyo.Expression(expr=1) + m.fs.foo = Expression(expr=1) with pytest.raises( TypeError, @@ -586,7 +649,7 @@ def test_set_bounds_from_valid_range_invalid_type(): @pytest.mark.unit def test_list_components_with_values_outside_valid_range_scalar(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -624,7 +687,7 @@ def test_list_components_with_values_outside_valid_range_scalar(): @pytest.mark.unit def test_list_components_with_values_outside_valid_range_indexed(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -653,7 +716,7 @@ def test_list_components_with_values_outside_valid_range_indexed(): @pytest.mark.unit def test_list_components_with_values_outside_valid_range_block(): - m = pyo.ConcreteModel() + m = ConcreteModel() m.fs = FlowsheetBlock() m.fs.params = PhysicalParameterTestBlock() @@ -683,11 +746,11 @@ def test_list_components_with_values_outside_valid_range_block(): @pytest.mark.component def test_ipopt_solve_halt_on_error(capsys): - m = pyo.ConcreteModel() + m = ConcreteModel() - m.v = pyo.Var(initialize=-5, bounds=(None, -1)) - m.e = pyo.Expression(expr=pyo.log(m.v)) - m.c = pyo.Constraint(expr=m.e == 1) + m.v = Var(initialize=-5, bounds=(None, -1)) + m.e = Expression(expr=log(m.v)) + m.c = Constraint(expr=m.e == 1) try: results = ipopt_solve_halt_on_error(m) From 07067d335ec5a24b9f752edbb71842db4cd7222f Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 11 Jul 2023 10:02:05 -0400 Subject: [PATCH 02/48] Adding iniital display methods --- idaes/core/util/model_diagnostics.py | 112 +++++++++++++++--- .../core/util/tests/test_model_diagnostics.py | 11 +- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 89e2a65fee..09614218ee 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -68,6 +68,10 @@ _log = idaeslog.getLogger(__name__) +MAX_STR_LENGTH = 84 +TAB = " " * 4 + + class DiagnosticsToolbox: def __init__(self, model: Block): if not isinstance(model, Block): @@ -144,6 +148,7 @@ def var_in_block(var, block): if v.fixed: self._fixed_variables_in_activated_constraints_set.add(v) if not var_in_block(v, self.model): + # TODO: Should we track which constraints these appear in too? self._external_fixed_variables_in_activated_constraints_set.add(v) else: self._unfixed_variables_in_activated_constraints_set.add(v) @@ -169,6 +174,40 @@ def var_in_block(var, block): # Calculate DoF self._degrees_of_freedom = degrees_of_freedom(self.model) + # TODO: deactivated blocks, constraints, objectives, + def display_external_variables(self, stream=stdout): + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write( + "The following external variables appear in constraint within the model:\n\n" + ) + + for v in self._external_fixed_variables_in_activated_constraints_set: + stream.write(f"{TAB}{v.name}\n") + for v in self._external_unfixed_variables_in_activated_constraints_set: + stream.write(f"{TAB}{v.name}\n") + + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + + def display_unused_variables(self, stream=stdout): + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write( + "The following variables do not appear in activated constraints:\n\n" + ) + + for v in self._variables_not_in_activated_constraints_set: + stream.write(f"{TAB}{v.name}\n") + + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + + def display_variables_fixed_to_zero(self, stream=stdout): + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("The following variables are fixed to zero:\n\n") + + for v in self._variables_fixed_to_zero_set: + stream.write(f"{TAB}{v.name}\n") + + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + def check_unit_consistency(self): # Check unit consistency of each constraint self._constraints_with_inconsistent_units = ComponentSet() @@ -178,6 +217,15 @@ def check_unit_consistency(self): except UnitsError: self._constraints_with_inconsistent_units.add(c) + def display_constraints_with_inconsistent_units(self, stream=stdout): + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("The following constraints have unit consistency issues:\n\n") + + for c in self._constraints_with_inconsistent_units: + stream.write(f"{TAB}{c.name}\n") + + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + def check_dulmage_mendelsohn_partition(self): self._var_dm_partition = None self._con_dm_partition = None @@ -199,6 +247,34 @@ def check_dulmage_mendelsohn_partition(self): self._con_dm_partition.overconstrained + self._con_dm_partition.unmatched ) + def display_underconstrained_set(self, stream=stdout): + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("Dulmage_Mendelsohn Under-Constrained Set\n\n") + + stream.write(f"{TAB}Variables:\n\n") + for v in self._uc_var: + stream.write(f"{2*TAB}{v.name}\n") + + stream.write(f"\n{TAB}Constraints:\n\n") + for c in self._uc_con: + stream.write(f"{2*TAB}{c.name}\n") + + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + + def display_overconstrained_set(self, stream=stdout): + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("Dulmage_Mendelsohn Over-Constrained Set\n\n") + + stream.write(f"{TAB}Variables:\n\n") + for v in self._oc_var: + stream.write(f"{2*TAB}{v.name}\n") + + stream.write(f"\n{TAB}Constraints:\n\n") + for c in self._oc_con: + stream.write(f"{2*TAB}{c.name}\n") + + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + # TODO: Block triangularization analysis # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? @@ -213,31 +289,30 @@ def report_structural_issues(self, rerun_analysis=True, stream=stdout): self.check_dulmage_mendelsohn_partition() # Collect warnings - tab = " " * 4 warnings = [] if self._degrees_of_freedom != 0: dstring = "Degrees" if self._degrees_of_freedom == abs(1): dstring = "Degree" warnings.append( - f"\n{tab}WARNING: {self._degrees_of_freedom} {dstring} of Freedom" + f"\n{TAB}WARNING: {self._degrees_of_freedom} {dstring} of Freedom" ) if len(self._constraints_with_inconsistent_units) > 0: cstring = "Constraints" if len(self._constraints_with_inconsistent_units) == 1: cstring = "Constraint" warnings.append( - f"\n{tab}WARNING: {len(self._constraints_with_inconsistent_units)} " + f"\n{TAB}WARNING: {len(self._constraints_with_inconsistent_units)} " f"{cstring} with inconsistent units" ) if any( len(x) > 0 for x in [self._uc_var, self._uc_con, self._oc_var, self._oc_con] ): warnings.append( - f"\n{tab}WARNING: Structural singularity found\n" - f"{tab*2}Under-Constrained Set: {len(self._uc_var)} " + f"\n{TAB}WARNING: Structural singularity found\n" + f"{TAB*2}Under-Constrained Set: {len(self._uc_var)} " f"variables, {len(self._uc_con)} constraints\n" - f"{tab * 2}Over-Constrained Set: {len(self._oc_var)} " + f"{TAB * 2}Over-Constrained Set: {len(self._oc_var)} " f"variables, {len(self._oc_con)} constraints" ) @@ -248,7 +323,7 @@ def report_structural_issues(self, rerun_analysis=True, stream=stdout): if len(self._variables_fixed_to_zero_set) == 1: vstring = "variable" cautions.append( - f"\n{tab}Caution: {len(self._variables_fixed_to_zero_set)} " + f"\n{TAB}Caution: {len(self._variables_fixed_to_zero_set)} " f"{vstring} fixed to 0" ) if len(self._variables_not_in_activated_constraints_set) > 0: @@ -256,43 +331,42 @@ def report_structural_issues(self, rerun_analysis=True, stream=stdout): if len(self._variables_not_in_activated_constraints_set) == 1: vstring = "variable" cautions.append( - f"\n{tab}Caution: {len(self._variables_not_in_activated_constraints_set)} " + f"\n{TAB}Caution: {len(self._variables_not_in_activated_constraints_set)} " f"unused {vstring} " f"({len(self._fixed_variables_not_in_activated_constraints_set)} fixed)" ) # Generate report - max_str_length = 84 - stream.write("\n" + "=" * max_str_length + "\n") + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Model Statistics\n\n") stream.write( - f"{tab}Activated Blocks: {len(self._activated_block_set)} " + f"{TAB}Activated Blocks: {len(self._activated_block_set)} " f"(Deactivated: {len(self._deactivated_block_set)})\n" ) stream.write( - f"{tab}Free Variables in Activated Constraints: " + f"{TAB}Free Variables in Activated Constraints: " f"{len(self._unfixed_variables_in_activated_constraints_set)} " f"(External: {len(self._external_unfixed_variables_in_activated_constraints_set)})\n" ) stream.write( - f"{tab}Fixed Variables in Activated Constraints: " + f"{TAB}Fixed Variables in Activated Constraints: " f"{len(self._fixed_variables_in_activated_constraints_set)} " f"(External: {len(self._external_fixed_variables_in_activated_constraints_set)})\n" ) stream.write( - f"{tab}Activated Equality Constraints: {len(self._activated_equalities_set)} " + f"{TAB}Activated Equality Constraints: {len(self._activated_equalities_set)} " f"(Deactivated: {len(self._deactivated_equalities_set)})\n" ) stream.write( - f"{tab}Activated Inequality Constraints: {len(self._activated_inequalities_set)} " + f"{TAB}Activated Inequality Constraints: {len(self._activated_inequalities_set)} " f"(Deactivated: {len(self._deactivated_inequalities_set)})\n" ) stream.write( - f"{tab}Activated Objectives: {len(self._activated_objectives_set)} " + f"{TAB}Activated Objectives: {len(self._activated_objectives_set)} " f"(Deactivated: {len(self._deactivated_objectives_set)})\n" ) - stream.write("\n" + "-" * max_str_length + "\n") + stream.write("\n" + "-" * MAX_STR_LENGTH + "\n") if len(warnings) > 0: stream.write(f"{len(warnings)} WARNINGS\n") for w in warnings: @@ -300,7 +374,7 @@ def report_structural_issues(self, rerun_analysis=True, stream=stdout): else: stream.write("No warnings found!\n") - stream.write("\n\n" + "-" * max_str_length + "\n") + stream.write("\n\n" + "-" * MAX_STR_LENGTH + "\n") if len(cautions) > 0: stream.write(f"{len(cautions)} Cautions\n") for c in cautions: @@ -308,7 +382,7 @@ def report_structural_issues(self, rerun_analysis=True, stream=stdout): else: stream.write("No cautions found!\n") - stream.write("\n\n" + "=" * max_str_length + "\n") + stream.write("\n\n" + "=" * MAX_STR_LENGTH + "\n") class DegeneracyHunter: diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index acd63c5eb7..797b229c11 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -97,11 +97,14 @@ def test_DiagnosticsToolbox(): dt.report_structural_issues() - m.b.v3.fix(5) + dt.display_constraints_with_inconsistent_units() - dt.report_structural_issues() - for c in dt._uc_con: - print(c.name) + dt.display_underconstrained_set() + dt.display_overconstrained_set() + + dt.display_external_variables() + dt.display_unused_variables() + dt.display_variables_fixed_to_zero() # TODO: Current checks do not detect linearly dependent equation assert False From 28a7f7f66d3deaeaf86ccf0c6ea24029568a2ac8 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 11 Jul 2023 16:17:38 -0400 Subject: [PATCH 03/48] Removing caching --- idaes/core/util/model_diagnostics.py | 284 +++++++----------- .../core/util/tests/test_model_diagnostics.py | 4 +- 2 files changed, 112 insertions(+), 176 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 09614218ee..ad3466874a 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -31,6 +31,7 @@ check_optimal_termination, ConcreteModel, Constraint, + Expression, Objective, Param, Set, @@ -40,6 +41,8 @@ ) from pyomo.core.base.block import _BlockData from pyomo.common.collections import ComponentSet + +# from pyomo.util.check_units import identify_inconsistent_units from pyomo.util.check_units import assert_units_consistent from pyomo.core.base.units_container import UnitsError from pyomo.contrib.incidence_analysis import IncidenceGraphInterface @@ -72,205 +75,125 @@ TAB = " " * 4 +def _var_in_block(var, block): + parent = var.parent_block() + while parent is not None: + if parent is block: + return True + parent = parent.parent_block() + return False + + class DiagnosticsToolbox: def __init__(self, model: Block): if not isinstance(model, Block): raise ValueError("model argument must be an instance of a Pyomo Block.") self.model = model - # Create placeholders for data - self._activated__block_set = ComponentSet() - self._deactivated_block_set = ComponentSet() - self._activated_equalities_set = ComponentSet() - self._deactivated_equalities_set = ComponentSet() - self._activated_inequalities_set = ComponentSet() - self._deactivated_inequalities_set = ComponentSet() - self._activated_objectives_set = ComponentSet() - self._deactivated_objectives_set = ComponentSet() - self._variables_fixed_to_zero_set = ComponentSet() - self._variables_in_activated_constraints_set = ComponentSet() - self._fixed_variables_in_activated_constraints_set = ComponentSet() - self._unfixed_variables_in_activated_constraints_set = ComponentSet() - self._external_fixed_variables_in_activated_constraints_set = ComponentSet() - self._external_unfixed_variables_in_activated_constraints_set = ComponentSet() - self._variables_not_in_activated_constraints_set = ComponentSet() - self._fixed_variables_not_in_activated_constraints_set = ComponentSet() - self._degrees_of_freedom = None - - self._constraints_with_inconsistent_units = ComponentSet() - - self._var_dm_partition = None - self._con_dm_partition = None - self._uc_var = None - self._uc_con = None - self._oc_var = None - self._oc_con = None - - def collect_model_statistics(self): - # For now, just use model_statistics tools. - # In future, we may want to look at reworking these to avoid repeatedly - # iterating over the model. - - # TODO: Variables with bounds - - # Block Statistics - self._activated_block_set = activated_blocks_set(self.model) - self._deactivated_block_set = deactivated_blocks_set(self.model) - - # # Constraint statistics - self._activated_equalities_set = activated_equalities_set(self.model) - self._deactivated_equalities_set = deactivated_equalities_set(self.model) - self._activated_inequalities_set = activated_inequalities_set(self.model) - self._deactivated_inequalities_set = deactivated_inequalities_set(self.model) - - # # Objective statistics - self._activated_objectives_set = activated_objectives_set(self.model) - self._deactivated_objectives_set = deactivated_objectives_set(self.model) - - # Variable statistics - self._variables_in_activated_constraints_set = ( - variables_in_activated_constraints_set(self.model) - ) - self._fixed_variables_in_activated_constraints_set = ComponentSet() - self._unfixed_variables_in_activated_constraints_set = ComponentSet() - self._external_fixed_variables_in_activated_constraints_set = ComponentSet() - self._external_unfixed_variables_in_activated_constraints_set = ComponentSet() - - def var_in_block(var, block): - parent = var.parent_block() - while parent is not None: - if parent is block: - return True - parent = parent.parent_block() - return False - - for v in self._variables_in_activated_constraints_set: - if v.fixed: - self._fixed_variables_in_activated_constraints_set.add(v) - if not var_in_block(v, self.model): - # TODO: Should we track which constraints these appear in too? - self._external_fixed_variables_in_activated_constraints_set.add(v) - else: - self._unfixed_variables_in_activated_constraints_set.add(v) - if not var_in_block(v, self.model): - self._external_unfixed_variables_in_activated_constraints_set.add(v) - + def _vars_fixed_to_zero(self): # Set of variables fixed to 0 - self._variables_fixed_to_zero_set = ComponentSet() + zero_vars = ComponentSet() for v in self.model.component_data_objects(Var, descend_into=True): if v.fixed and value(v) == 0: - self._variables_fixed_to_zero_set.add(v) - - # TODO: Need to see if this includes inequalities or not - self._variables_not_in_activated_constraints_set = ( - variables_not_in_activated_constraints_set(self.model) - ) - # Set of Unused fixed variables - self._fixed_variables_not_in_activated_constraints_set = ComponentSet() - for v in self._variables_not_in_activated_constraints_set: - if v.fixed: - self._fixed_variables_not_in_activated_constraints_set.add(v) - - # Calculate DoF - self._degrees_of_freedom = degrees_of_freedom(self.model) + zero_vars.add(v) + return zero_vars # TODO: deactivated blocks, constraints, objectives, def display_external_variables(self, stream=stdout): stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write( - "The following external variables appear in constraint within the model:\n\n" + "The following external variable(s) appear in constraints within the model:\n\n" ) - for v in self._external_fixed_variables_in_activated_constraints_set: - stream.write(f"{TAB}{v.name}\n") - for v in self._external_unfixed_variables_in_activated_constraints_set: - stream.write(f"{TAB}{v.name}\n") + for v in variables_in_activated_constraints_set(self.model): + if not _var_in_block(v, self.model): + stream.write(f"{TAB}{v.name}\n") stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") def display_unused_variables(self, stream=stdout): stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write( - "The following variables do not appear in activated constraints:\n\n" + "The following variable(s) do not appear in activated constraints:\n\n" ) - for v in self._variables_not_in_activated_constraints_set: + for v in variables_not_in_activated_constraints_set(self.model): stream.write(f"{TAB}{v.name}\n") stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") def display_variables_fixed_to_zero(self, stream=stdout): stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("The following variables are fixed to zero:\n\n") + stream.write("The following variable(s) are fixed to zero:\n\n") - for v in self._variables_fixed_to_zero_set: + for v in self._vars_fixed_to_zero(): stream.write(f"{TAB}{v.name}\n") stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") def check_unit_consistency(self): - # Check unit consistency of each constraint - self._constraints_with_inconsistent_units = ComponentSet() - for c in self.model.component_data_objects(Constraint, descend_into=True): + # Check unit consistency + # TODO: replace once Pyomo method ready + # return identify_inconsistent_units(self.model) + inconsistent_units = ComponentSet() + for o in self.model.component_data_objects( + [Constraint, Expression, Objective], descend_into=True + ): try: - assert_units_consistent(c) + assert_units_consistent(o) except UnitsError: - self._constraints_with_inconsistent_units.add(c) + inconsistent_units.add(o) + return inconsistent_units - def display_constraints_with_inconsistent_units(self, stream=stdout): + def display_components_with_inconsistent_units(self, stream=stdout): stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("The following constraints have unit consistency issues:\n\n") + stream.write("The following component(s) have unit consistency issues:\n\n") - for c in self._constraints_with_inconsistent_units: + for c in self.check_unit_consistency(): stream.write(f"{TAB}{c.name}\n") stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") def check_dulmage_mendelsohn_partition(self): - self._var_dm_partition = None - self._con_dm_partition = None - self._uc_var = None - self._uc_con = None - self._oc_var = None - self._oc_con = None - igraph = IncidenceGraphInterface(self.model) - self._var_dm_partition, self._con_dm_partition = igraph.dulmage_mendelsohn() + var_dm_partition, con_dm_partition = igraph.dulmage_mendelsohn() # Collect under- and order-constrained sub-system - self._uc_var = ( - self._var_dm_partition.unmatched + self._var_dm_partition.underconstrained - ) - self._uc_con = self._con_dm_partition.underconstrained - self._oc_var = self._var_dm_partition.overconstrained - self._oc_con = ( - self._con_dm_partition.overconstrained + self._con_dm_partition.unmatched - ) + uc_var = var_dm_partition.unmatched + var_dm_partition.underconstrained + uc_con = con_dm_partition.underconstrained + oc_var = var_dm_partition.overconstrained + oc_con = con_dm_partition.overconstrained + con_dm_partition.unmatched + + return uc_var, uc_con, oc_var, oc_con def display_underconstrained_set(self, stream=stdout): + uc_var, uc_con, _, _ = self.check_dulmage_mendelsohn_partition() + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage_Mendelsohn Under-Constrained Set\n\n") stream.write(f"{TAB}Variables:\n\n") - for v in self._uc_var: + for v in uc_var: stream.write(f"{2*TAB}{v.name}\n") stream.write(f"\n{TAB}Constraints:\n\n") - for c in self._uc_con: + for c in uc_con: stream.write(f"{2*TAB}{c.name}\n") stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") def display_overconstrained_set(self, stream=stdout): + _, _, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() + stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage_Mendelsohn Over-Constrained Set\n\n") stream.write(f"{TAB}Variables:\n\n") - for v in self._oc_var: + for v in oc_var: stream.write(f"{2*TAB}{v.name}\n") stream.write(f"\n{TAB}Constraints:\n\n") - for c in self._oc_con: + for c in oc_con: stream.write(f"{2*TAB}{c.name}\n") stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") @@ -278,92 +201,105 @@ def display_overconstrained_set(self, stream=stdout): # TODO: Block triangularization analysis # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? - def report_structural_issues(self, rerun_analysis=True, stream=stdout): + def report_structural_issues(self, stream=stdout): # Potential evaluation errors # High Index - # Run checks unless told not to - if rerun_analysis: - self.collect_model_statistics() - self.check_unit_consistency() - self.check_dulmage_mendelsohn_partition() + vars_in_constraints = variables_in_activated_constraints_set(self.model) + fixed_vars_in_constraints = ComponentSet() + free_vars_in_constraints = ComponentSet() + ext_fixed_vars_in_constraints = ComponentSet() + ext_free_vars_in_constraints = ComponentSet() + for v in vars_in_constraints: + if v.fixed: + fixed_vars_in_constraints.add(v) + if not _var_in_block(v, self.model): + ext_fixed_vars_in_constraints.add(v) + else: + free_vars_in_constraints.add(v) + if not _var_in_block(v, self.model): + ext_free_vars_in_constraints.add(v) + + uc = self.check_unit_consistency() + uc_var, uc_con, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() # Collect warnings warnings = [] - if self._degrees_of_freedom != 0: + dof = degrees_of_freedom(self.model) + if dof != 0: dstring = "Degrees" - if self._degrees_of_freedom == abs(1): + if dof == abs(1): dstring = "Degree" + warnings.append(f"\n{TAB}WARNING: {dof} {dstring} of Freedom") + if len(uc) > 0: + cstring = "Components" + if len(uc) == 1: + cstring = "Component" warnings.append( - f"\n{TAB}WARNING: {self._degrees_of_freedom} {dstring} of Freedom" - ) - if len(self._constraints_with_inconsistent_units) > 0: - cstring = "Constraints" - if len(self._constraints_with_inconsistent_units) == 1: - cstring = "Constraint" - warnings.append( - f"\n{TAB}WARNING: {len(self._constraints_with_inconsistent_units)} " - f"{cstring} with inconsistent units" + f"\n{TAB}WARNING: {len(uc)} " f"{cstring} with inconsistent units" ) - if any( - len(x) > 0 for x in [self._uc_var, self._uc_con, self._oc_var, self._oc_con] - ): + if any(len(x) > 0 for x in [uc_var, uc_con, oc_var, oc_con]): warnings.append( f"\n{TAB}WARNING: Structural singularity found\n" - f"{TAB*2}Under-Constrained Set: {len(self._uc_var)} " - f"variables, {len(self._uc_con)} constraints\n" - f"{TAB * 2}Over-Constrained Set: {len(self._oc_var)} " - f"variables, {len(self._oc_con)} constraints" + f"{TAB*2}Under-Constrained Set: {len(uc_var)} " + f"variables, {len(uc_con)} constraints\n" + f"{TAB * 2}Over-Constrained Set: {len(oc_var)} " + f"variables, {len(oc_con)} constraints" ) # Collect cautions cautions = [] - if len(self._variables_fixed_to_zero_set) > 0: + zero_vars = self._vars_fixed_to_zero() + if len(zero_vars) > 0: vstring = "variables" - if len(self._variables_fixed_to_zero_set) == 1: + if len(zero_vars) == 1: vstring = "variable" cautions.append( - f"\n{TAB}Caution: {len(self._variables_fixed_to_zero_set)} " - f"{vstring} fixed to 0" + f"\n{TAB}Caution: {len(zero_vars)} " f"{vstring} fixed to 0" ) - if len(self._variables_not_in_activated_constraints_set) > 0: + unused_vars = variables_not_in_activated_constraints_set(self.model) + unused_vars_fixed = 0 + for v in unused_vars: + if v.fixed: + unused_vars_fixed += 1 + if len(unused_vars) > 0: vstring = "variables" - if len(self._variables_not_in_activated_constraints_set) == 1: + if len(unused_vars) == 1: vstring = "variable" cautions.append( - f"\n{TAB}Caution: {len(self._variables_not_in_activated_constraints_set)} " - f"unused {vstring} " - f"({len(self._fixed_variables_not_in_activated_constraints_set)} fixed)" + f"\n{TAB}Caution: {len(unused_vars)} " + f"unused {vstring} ({unused_vars_fixed} fixed)" ) # Generate report + # TODO: Variables with bounds stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Model Statistics\n\n") stream.write( - f"{TAB}Activated Blocks: {len(self._activated_block_set)} " - f"(Deactivated: {len(self._deactivated_block_set)})\n" + f"{TAB}Activated Blocks: {len(activated_blocks_set(self.model))} " + f"(Deactivated: {len(deactivated_blocks_set(self.model))})\n" ) stream.write( f"{TAB}Free Variables in Activated Constraints: " - f"{len(self._unfixed_variables_in_activated_constraints_set)} " - f"(External: {len(self._external_unfixed_variables_in_activated_constraints_set)})\n" + f"{len(free_vars_in_constraints)} " + f"(External: {len(ext_free_vars_in_constraints)})\n" ) stream.write( f"{TAB}Fixed Variables in Activated Constraints: " - f"{len(self._fixed_variables_in_activated_constraints_set)} " - f"(External: {len(self._external_fixed_variables_in_activated_constraints_set)})\n" + f"{len(fixed_vars_in_constraints)} " + f"(External: {len(ext_fixed_vars_in_constraints)})\n" ) stream.write( - f"{TAB}Activated Equality Constraints: {len(self._activated_equalities_set)} " - f"(Deactivated: {len(self._deactivated_equalities_set)})\n" + f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(self.model))} " + f"(Deactivated: {len(deactivated_equalities_set(self.model))})\n" ) stream.write( - f"{TAB}Activated Inequality Constraints: {len(self._activated_inequalities_set)} " - f"(Deactivated: {len(self._deactivated_inequalities_set)})\n" + f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(self.model))} " + f"(Deactivated: {len(deactivated_inequalities_set(self.model))})\n" ) stream.write( - f"{TAB}Activated Objectives: {len(self._activated_objectives_set)} " - f"(Deactivated: {len(self._deactivated_objectives_set)})\n" + f"{TAB}Activated Objectives: {len(activated_objectives_set(self.model))} " + f"(Deactivated: {len(deactivated_objectives_set(self.model))})\n" ) stream.write("\n" + "-" * MAX_STR_LENGTH + "\n") diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 797b229c11..15314ee66f 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -97,7 +97,7 @@ def test_DiagnosticsToolbox(): dt.report_structural_issues() - dt.display_constraints_with_inconsistent_units() + dt.display_components_with_inconsistent_units() dt.display_underconstrained_set() dt.display_overconstrained_set() @@ -106,7 +106,7 @@ def test_DiagnosticsToolbox(): dt.display_unused_variables() dt.display_variables_fixed_to_zero() - # TODO: Current checks do not detect linearly dependent equation + # TODO: Current checks do not detect linearly dependent constraints assert False From 46a5d9ca5a2982e9e558e6dacdb42fd7517ecc04 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 14 Jul 2023 12:33:47 -0400 Subject: [PATCH 04/48] Refining code and formatting of reports --- idaes/core/util/model_diagnostics.py | 151 ++++++++++++++++----------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index ad3466874a..b33c81f919 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -201,52 +201,42 @@ def display_overconstrained_set(self, stream=stdout): # TODO: Block triangularization analysis # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? - def report_structural_issues(self, stream=stdout): - # Potential evaluation errors - # High Index - - vars_in_constraints = variables_in_activated_constraints_set(self.model) - fixed_vars_in_constraints = ComponentSet() - free_vars_in_constraints = ComponentSet() - ext_fixed_vars_in_constraints = ComponentSet() - ext_free_vars_in_constraints = ComponentSet() - for v in vars_in_constraints: - if v.fixed: - fixed_vars_in_constraints.add(v) - if not _var_in_block(v, self.model): - ext_fixed_vars_in_constraints.add(v) - else: - free_vars_in_constraints.add(v) - if not _var_in_block(v, self.model): - ext_free_vars_in_constraints.add(v) - + def _collect_structural_warnings(self): uc = self.check_unit_consistency() uc_var, uc_con, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() # Collect warnings warnings = [] + next_steps = [] dof = degrees_of_freedom(self.model) if dof != 0: dstring = "Degrees" if dof == abs(1): dstring = "Degree" - warnings.append(f"\n{TAB}WARNING: {dof} {dstring} of Freedom") + warnings.append(f"WARNING: {dof} {dstring} of Freedom") if len(uc) > 0: cstring = "Components" if len(uc) == 1: cstring = "Component" - warnings.append( - f"\n{TAB}WARNING: {len(uc)} " f"{cstring} with inconsistent units" - ) + warnings.append(f"WARNING: {len(uc)} " f"{cstring} with inconsistent units") + next_steps.append("display_components_with_inconsistent_units()") if any(len(x) > 0 for x in [uc_var, uc_con, oc_var, oc_con]): warnings.append( - f"\n{TAB}WARNING: Structural singularity found\n" + f"WARNING: Structural singularity found\n" f"{TAB*2}Under-Constrained Set: {len(uc_var)} " f"variables, {len(uc_con)} constraints\n" - f"{TAB * 2}Over-Constrained Set: {len(oc_var)} " + f"{TAB*2}Over-Constrained Set: {len(oc_var)} " f"variables, {len(oc_con)} constraints" ) + if any(len(x) > 0 for x in [uc_var, uc_con]): + next_steps.append("display_underconstrained_set()") + if any(len(x) > 0 for x in [oc_var, oc_con]): + next_steps.append("display_overconstrained_set()") + + return warnings, next_steps + + def _collect_structural_cautions(self): # Collect cautions cautions = [] zero_vars = self._vars_fixed_to_zero() @@ -254,9 +244,7 @@ def report_structural_issues(self, stream=stdout): vstring = "variables" if len(zero_vars) == 1: vstring = "variable" - cautions.append( - f"\n{TAB}Caution: {len(zero_vars)} " f"{vstring} fixed to 0" - ) + cautions.append(f"Caution: {len(zero_vars)} " f"{vstring} fixed to 0") unused_vars = variables_not_in_activated_constraints_set(self.model) unused_vars_fixed = 0 for v in unused_vars: @@ -267,58 +255,103 @@ def report_structural_issues(self, stream=stdout): if len(unused_vars) == 1: vstring = "variable" cautions.append( - f"\n{TAB}Caution: {len(unused_vars)} " + f"Caution: {len(unused_vars)} " f"unused {vstring} ({unused_vars_fixed} fixed)" ) + return cautions + + def assert_no_structural_warnings(self): + warnings, _ = self._collect_structural_warnings() + if len(warnings) > 0: + raise AssertionError(f"Structural issues found ({len(warnings)}).") + + def _write_report_section( + self, stream, lines_list, title=None, else_line=None, header="-" + ): + stream.write(f"\n{header * MAX_STR_LENGTH}\n") + if title is not None: + stream.write(f"{title}\n\n") + if len(lines_list) > 0: + for i in lines_list: + stream.write(f"{TAB}{i}\n") + elif else_line is not None: + stream.write(f"{TAB}{else_line}\n") + + def report_structural_issues(self, stream=stdout): + # Potential evaluation errors + # High Index + + vars_in_constraints = variables_in_activated_constraints_set(self.model) + fixed_vars_in_constraints = ComponentSet() + free_vars_in_constraints = ComponentSet() + ext_fixed_vars_in_constraints = ComponentSet() + ext_free_vars_in_constraints = ComponentSet() + for v in vars_in_constraints: + if v.fixed: + fixed_vars_in_constraints.add(v) + if not _var_in_block(v, self.model): + ext_fixed_vars_in_constraints.add(v) + else: + free_vars_in_constraints.add(v) + if not _var_in_block(v, self.model): + ext_free_vars_in_constraints.add(v) + # Generate report # TODO: Variables with bounds - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("Model Statistics\n\n") - stream.write( + stats = [] + stats.append( f"{TAB}Activated Blocks: {len(activated_blocks_set(self.model))} " - f"(Deactivated: {len(deactivated_blocks_set(self.model))})\n" + f"(Deactivated: {len(deactivated_blocks_set(self.model))})" ) - stream.write( + stats.append( f"{TAB}Free Variables in Activated Constraints: " f"{len(free_vars_in_constraints)} " - f"(External: {len(ext_free_vars_in_constraints)})\n" + f"(External: {len(ext_free_vars_in_constraints)})" ) - stream.write( + stats.append( f"{TAB}Fixed Variables in Activated Constraints: " f"{len(fixed_vars_in_constraints)} " - f"(External: {len(ext_fixed_vars_in_constraints)})\n" + f"(External: {len(ext_fixed_vars_in_constraints)})" ) - stream.write( + stats.append( f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(self.model))} " - f"(Deactivated: {len(deactivated_equalities_set(self.model))})\n" + f"(Deactivated: {len(deactivated_equalities_set(self.model))})" ) - stream.write( + stats.append( f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(self.model))} " - f"(Deactivated: {len(deactivated_inequalities_set(self.model))})\n" + f"(Deactivated: {len(deactivated_inequalities_set(self.model))})" ) - stream.write( + stats.append( f"{TAB}Activated Objectives: {len(activated_objectives_set(self.model))} " - f"(Deactivated: {len(deactivated_objectives_set(self.model))})\n" + f"(Deactivated: {len(deactivated_objectives_set(self.model))})" ) - stream.write("\n" + "-" * MAX_STR_LENGTH + "\n") - if len(warnings) > 0: - stream.write(f"{len(warnings)} WARNINGS\n") - for w in warnings: - stream.write(w) - else: - stream.write("No warnings found!\n") - - stream.write("\n\n" + "-" * MAX_STR_LENGTH + "\n") - if len(cautions) > 0: - stream.write(f"{len(cautions)} Cautions\n") - for c in cautions: - stream.write(c) - else: - stream.write("No cautions found!\n") + warnings, next_steps = self._collect_structural_warnings() + cautions = self._collect_structural_cautions() - stream.write("\n\n" + "=" * MAX_STR_LENGTH + "\n") + self._write_report_section( + stream=stream, lines_list=stats, title="Model Statistics", header="=" + ) + self._write_report_section( + stream=stream, + lines_list=warnings, + title=f"{len(warnings)} WARNINGS", + else_line="No warnings found!", + ) + self._write_report_section( + stream=stream, + lines_list=cautions, + title=f"{len(warnings)} Cautions", + else_line="No cautions found!", + ) + self._write_report_section( + stream=stream, + lines_list=next_steps, + title="Suggested next steps:", + else_line="report_numerical_issues()", + ) + self._write_report_section(stream=stream, lines_list=[], title=None, header="=") class DegeneracyHunter: From fbb5178ca2705d4f80e183f24d8d132cab569964 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 14 Jul 2023 15:11:22 -0400 Subject: [PATCH 05/48] Adding first numerical checks --- idaes/core/util/model_diagnostics.py | 248 ++++++++++++++++-- .../core/util/tests/test_model_diagnostics.py | 44 +++- 2 files changed, 258 insertions(+), 34 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index b33c81f919..da36cdd289 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -66,6 +66,7 @@ large_residuals_set, variables_near_bounds_set, ) +from idaes.core.util.scaling import list_badly_scaled_variables import idaes.logger as idaeslog _log = idaeslog.getLogger(__name__) @@ -89,6 +90,10 @@ def __init__(self, model: Block): if not isinstance(model, Block): raise ValueError("model argument must be an instance of a Pyomo Block.") self.model = model + # TODO: Work out how to manage and document these + self.residual_tolerance = 1e-5 + self.zero_tolerance = 1e-6 + # TODO: Add scaling tolerance parameters def _vars_fixed_to_zero(self): # Set of variables fixed to 0 @@ -98,38 +103,111 @@ def _vars_fixed_to_zero(self): zero_vars.add(v) return zero_vars + def _vars_near_zero(self): + # Set of variables with values close to 0 + near_zero_vars = ComponentSet() + for v in self.model.component_data_objects(Var, descend_into=True): + if v.value is not None and abs(value(v)) <= self.zero_tolerance: + near_zero_vars.add(v) + return near_zero_vars + + def _vars_outside_bounds(self): + violated_bounds = ComponentSet() + for v in self.model.component_data_objects(Var, descend_into=True): + if v.value is not None: + if v.lb is not None and v.value < v.lb: + violated_bounds.add(v) + elif v.ub is not None and v.value > v.lb: + violated_bounds.add(v) + + return violated_bounds + + def _vars_with_none_value(self): + none_value = ComponentSet() + for v in self.model.component_data_objects(Var, descend_into=True): + if v.value is None: + none_value.add(v) + + return none_value + # TODO: deactivated blocks, constraints, objectives, def display_external_variables(self, stream=stdout): - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write( - "The following external variable(s) appear in constraints within the model:\n\n" - ) - + ext_vars = [] for v in variables_in_activated_constraints_set(self.model): if not _var_in_block(v, self.model): - stream.write(f"{TAB}{v.name}\n") + ext_vars.append(v.name) - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + self._write_report_section( + stream=stream, + lines_list=ext_vars, + title=f"The following external variable(s) appear in constraints within the model:", + else_line="No warnings found!", + header="=", + footer="=", + ) def display_unused_variables(self, stream=stdout): - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write( - "The following variable(s) do not appear in activated constraints:\n\n" + self._write_report_section( + stream=stream, + lines_list=variables_not_in_activated_constraints_set(self.model), + title=f"The following external variable(s) appear in constraints within the model:", + header="=", + footer="=", ) - for v in variables_not_in_activated_constraints_set(self.model): - stream.write(f"{TAB}{v.name}\n") + def display_variables_fixed_to_zero(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=self._vars_fixed_to_zero(), + title=f"The following variable(s) are fixed to zero:", + header="=", + footer="=", + ) - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + def display_variables_outside_bounds(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=self._vars_outside_bounds(), + title=f"The following variable(s) have values outside their bounds:", + header="=", + footer="=", + ) - def display_variables_fixed_to_zero(self, stream=stdout): - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("The following variable(s) are fixed to zero:\n\n") + def display_variables_with_none_value(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=self._vars_with_none_value(), + title=f"The following variable(s) have a value of None:", + header="=", + footer="=", + ) - for v in self._vars_fixed_to_zero(): - stream.write(f"{TAB}{v.name}\n") + def display_variables_with_value_near_zero(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=self._vars_near_zero(), + title=f"The following variable(s) have a value close to zero:", + header="=", + footer="=", + ) - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + def display_poorly_scaled_variables(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=list_badly_scaled_variables(self.model), + title=f"The following variable(s) are poorly scaled:", + header="=", + footer="=", + ) + + def display_variables_near_bounds(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=variables_near_bounds_set(self.model), + title=f"The following variable(s) have values close to their bounds:", + header="=", + footer="=", + ) def check_unit_consistency(self): # Check unit consistency @@ -146,13 +224,22 @@ def check_unit_consistency(self): return inconsistent_units def display_components_with_inconsistent_units(self, stream=stdout): - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("The following component(s) have unit consistency issues:\n\n") - - for c in self.check_unit_consistency(): - stream.write(f"{TAB}{c.name}\n") + self._write_report_section( + stream=stream, + lines_list=self.check_unit_consistency(), + title=f"The following component(s) have unit consistency issues:", + header="=", + footer="=", + ) - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + def display_constraints_with_large_residuals(self, stream=stdout): + self._write_report_section( + stream=stream, + lines_list=large_residuals_set(self.model, tol=self.residual_tolerance), + title=f"The following constraint(s) have large residuals:", + header="=", + footer="=", + ) def check_dulmage_mendelsohn_partition(self): igraph = IncidenceGraphInterface(self.model) @@ -218,7 +305,7 @@ def _collect_structural_warnings(self): cstring = "Components" if len(uc) == 1: cstring = "Component" - warnings.append(f"WARNING: {len(uc)} " f"{cstring} with inconsistent units") + warnings.append(f"WARNING: {len(uc)} {cstring} with inconsistent units") next_steps.append("display_components_with_inconsistent_units()") if any(len(x) > 0 for x in [uc_var, uc_con, oc_var, oc_con]): warnings.append( @@ -244,7 +331,7 @@ def _collect_structural_cautions(self): vstring = "variables" if len(zero_vars) == 1: vstring = "variable" - cautions.append(f"Caution: {len(zero_vars)} " f"{vstring} fixed to 0") + cautions.append(f"Caution: {len(zero_vars)} {vstring} fixed to 0") unused_vars = variables_not_in_activated_constraints_set(self.model) unused_vars_fixed = 0 for v in unused_vars: @@ -261,13 +348,88 @@ def _collect_structural_cautions(self): return cautions + def _collect_numerical_warnings(self): + warnings = [] + next_steps = [] + + # Large residuals + large_residuals = large_residuals_set(self.model, tol=self.residual_tolerance) + if len(large_residuals) > 0: + cstring = "Constraints" + if len(large_residuals) == 1: + cstring = "Constraint" + warnings.append( + f"WARNING: {len(large_residuals)} {cstring} with large residuals" + ) + next_steps.append("display_constraints_with_large_residuals()") + + # Variables outside bounds + violated_bounds = self._vars_outside_bounds() + if len(violated_bounds) > 0: + cstring = "Variables" + if len(violated_bounds) == 1: + cstring = "Variable" + warnings.append( + f"WARNING: {len(violated_bounds)} {cstring} with bounds violations" + ) + next_steps.append("display_variables_outside_bounds()") + + # Poor scaling + var_scaling = list_badly_scaled_variables(self.model) + if len(var_scaling) > 0: + cstring = "Variables" + if len(var_scaling) == 1: + cstring = "Variable" + warnings.append(f"WARNING: {len(var_scaling)} {cstring} with poor scaling") + next_steps.append("display_poorly_scaled_variables()") + + return warnings, next_steps + + def _collect_numerical_cautions(self): + cautions = [] + + # Variables near bounds + near_bounds = variables_near_bounds_set(self.model) + if len(near_bounds) > 0: + cstring = "Variables" + if len(near_bounds) == 1: + cstring = "Variable" + cautions.append( + f"Caution: {len(near_bounds)} {cstring} with value close to their bounds" + ) + + # Variables near zero + near_zero = self._vars_near_zero() + if len(near_zero) > 0: + cstring = "Variables" + if len(near_zero) == 1: + cstring = "Variable" + cautions.append( + f"Caution: {len(near_zero)} {cstring} with value close to zero" + ) + + # Variables with value None + none_value = self._vars_with_none_value() + if len(none_value) > 0: + cstring = "Variables" + if len(none_value) == 1: + cstring = "Variable" + cautions.append(f"Caution: {len(none_value)} {cstring} with None value") + + return cautions + def assert_no_structural_warnings(self): warnings, _ = self._collect_structural_warnings() if len(warnings) > 0: raise AssertionError(f"Structural issues found ({len(warnings)}).") + def assert_no_numerical_warnings(self): + warnings, _ = self._collect_numerical_warnings() + if len(warnings) > 0: + raise AssertionError(f"Numerical issues found ({len(warnings)}).") + def _write_report_section( - self, stream, lines_list, title=None, else_line=None, header="-" + self, stream, lines_list, title=None, else_line=None, header="-", footer=None ): stream.write(f"\n{header * MAX_STR_LENGTH}\n") if title is not None: @@ -277,6 +439,8 @@ def _write_report_section( stream.write(f"{TAB}{i}\n") elif else_line is not None: stream.write(f"{TAB}{else_line}\n") + if footer is not None: + stream.write(f"\n{footer * MAX_STR_LENGTH}\n") def report_structural_issues(self, stream=stdout): # Potential evaluation errors @@ -350,8 +514,34 @@ def report_structural_issues(self, stream=stdout): lines_list=next_steps, title="Suggested next steps:", else_line="report_numerical_issues()", + footer="=", + ) + + def report_numerical_issues(self, stream=stdout): + warnings, next_steps = self._collect_numerical_warnings() + cautions = self._collect_numerical_cautions() + + self._write_report_section( + stream=stream, + lines_list=warnings, + title=f"{len(warnings)} WARNINGS", + else_line="No warnings found!", + header="=", + ) + self._write_report_section( + stream=stream, + lines_list=cautions, + title=f"{len(warnings)} Cautions", + else_line="No cautions found!", + ) + self._write_report_section( + stream=stream, + lines_list=next_steps, + title="Suggested next steps:", + else_line=f"If you still have issues converging your model consider:\n" + f"{TAB*2}svd_analysis(TBA)\n{TAB*2}degeneracy_hunter (TBA)", + footer="=", ) - self._write_report_section(stream=stream, lines_list=[], title=None, header="=") class DegeneracyHunter: diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 15314ee66f..52cdddc7af 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -97,14 +97,48 @@ def test_DiagnosticsToolbox(): dt.report_structural_issues() - dt.display_components_with_inconsistent_units() + m.b.v3.fix(1) - dt.display_underconstrained_set() - dt.display_overconstrained_set() + dt.report_structural_issues() dt.display_external_variables() - dt.display_unused_variables() - dt.display_variables_fixed_to_zero() + + # TODO: Current checks do not detect linearly dependent constraints + assert False + + +@pytest.mark.integration +def test_DiagnosticsToolbox2(): + m = ConcreteModel() + + # A var outside the model + m.v = Var(initialize=10, units=units.s, bounds=(0, 1)) + + # Model to be tested + m.b = Block() + + m.b.v1 = Var(initialize=1, units=units.s) + m.b.v2 = Var(initialize=2, units=units.s) + m.b.v3 = Var(initialize=3, units=units.s, bounds=(10, 20)) + m.b.v4 = Var(units=units.s) + m.b.v5 = Var(initialize=1e-8, bounds=(0, 1)) + + # Equality constraints + m.b.c1 = Constraint(expr=2 * m.b.v1 == m.b.v2) # OK + m.b.c2 = Constraint(expr=m.b.v3 == m.v) # Not Converged + + # Inequality constraints + m.b.c3 = Constraint(expr=m.b.v2 <= 10) # OK + m.b.c4 = Constraint(expr=m.b.v2 <= 0) # Not OK + + # Create instance of Diagnostics Toolbox + dt = DiagnosticsToolbox(model=m.b) + + # dt.report_structural_issues() + + dt.report_numerical_issues() + dt.display_constraints_with_large_residuals() + dt.display_variables_near_bounds() # TODO: Current checks do not detect linearly dependent constraints assert False From f08fc2bc0ed917fde29de563932a0346533127c5 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 14 Jul 2023 15:19:20 -0400 Subject: [PATCH 06/48] Adding basic doc string for class --- idaes/core/util/model_diagnostics.py | 15 +++++++++++++++ idaes/core/util/tests/test_model_diagnostics.py | 2 ++ 2 files changed, 17 insertions(+) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index da36cdd289..abfad98968 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -86,6 +86,21 @@ def _var_in_block(var, block): class DiagnosticsToolbox: + """ + The IDAES Model DiagnosticsToolbox. + + To get started: + + 1. Create an instance of the toolbox the model to debug as the model argument. + 2. Call the report_structural_issues() method. + + Model diagnostics is an iterative process and you will likely need to run these + tools multiple times to resolve all issues. After making a change to your model, + you should always start from the beginning again to ensure hte change did not + introduce any new issues; i.e., always start from the report_structural_issues() + method. + """ + def __init__(self, model: Block): if not isinstance(model, Block): raise ValueError("model argument must be an instance of a Pyomo Block.") diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 52cdddc7af..4546701541 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -140,6 +140,8 @@ def test_DiagnosticsToolbox2(): dt.display_constraints_with_large_residuals() dt.display_variables_near_bounds() + help(DiagnosticsToolbox) + # TODO: Current checks do not detect linearly dependent constraints assert False From 1becf4193d9932411d3055b8555b825d4b579ebb Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 14 Jul 2023 17:08:53 -0400 Subject: [PATCH 07/48] More doc strings --- idaes/core/util/model_diagnostics.py | 255 ++++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 6 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index abfad98968..5fccba2844 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -91,14 +91,19 @@ class DiagnosticsToolbox: To get started: - 1. Create an instance of the toolbox the model to debug as the model argument. - 2. Call the report_structural_issues() method. + 1. Create an instance of your model - this does not need to be initialized yet. + 2. Create an instance of the toolbox the model to debug as the model argument. + 3. Call the report_structural_issues() method. Model diagnostics is an iterative process and you will likely need to run these tools multiple times to resolve all issues. After making a change to your model, - you should always start from the beginning again to ensure hte change did not + you should always start from the beginning again to ensure the change did not introduce any new issues; i.e., always start from the report_structural_issues() method. + + Note that structural checks do not require the model to be initialized, thus users + should start with these. Numerical checks require at least a partial solution to the + model and should only be run once all structural issues have been resolved. """ def __init__(self, model: Block): @@ -147,6 +152,17 @@ def _vars_with_none_value(self): # TODO: deactivated blocks, constraints, objectives, def display_external_variables(self, stream=stdout): + """ + Prints a list of variables that appear within Constraints in the model + but are not contained within the model themselves. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ ext_vars = [] for v in variables_in_activated_constraints_set(self.model): if not _var_in_block(v, self.model): @@ -162,6 +178,16 @@ def display_external_variables(self, stream=stdout): ) def display_unused_variables(self, stream=stdout): + """ + Prints a list of variables that do not appear in any activated Constraints. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=variables_not_in_activated_constraints_set(self.model), @@ -171,6 +197,16 @@ def display_unused_variables(self, stream=stdout): ) def display_variables_fixed_to_zero(self, stream=stdout): + """ + Prints a list of variables that are fixed to an absolute value of 0. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=self._vars_fixed_to_zero(), @@ -180,6 +216,17 @@ def display_variables_fixed_to_zero(self, stream=stdout): ) def display_variables_outside_bounds(self, stream=stdout): + """ + Prints a list of variables with values that fall outside the bounds + on the variable. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=self._vars_outside_bounds(), @@ -189,6 +236,16 @@ def display_variables_outside_bounds(self, stream=stdout): ) def display_variables_with_none_value(self, stream=stdout): + """ + Prints a list of variables with a value of None. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=self._vars_with_none_value(), @@ -198,6 +255,18 @@ def display_variables_with_none_value(self, stream=stdout): ) def display_variables_with_value_near_zero(self, stream=stdout): + """ + Prints a list of variables with a value close to zero. The tolerance + for determining what is close to zero can be set in the class configuration + options. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=self._vars_near_zero(), @@ -207,6 +276,18 @@ def display_variables_with_value_near_zero(self, stream=stdout): ) def display_poorly_scaled_variables(self, stream=stdout): + """ + Prints a list of variables with poor scaling based on their current values. + Tolerances for determining poor scaling can be set in the class configuration + options. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=list_badly_scaled_variables(self.model), @@ -216,6 +297,17 @@ def display_poorly_scaled_variables(self, stream=stdout): ) def display_variables_near_bounds(self, stream=stdout): + """ + Prints a list of variables with values close to their bounds. Tolerance can + be set in the class configuration options. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=variables_near_bounds_set(self.model), @@ -224,7 +316,7 @@ def display_variables_near_bounds(self, stream=stdout): footer="=", ) - def check_unit_consistency(self): + def _check_unit_consistency(self): # Check unit consistency # TODO: replace once Pyomo method ready # return identify_inconsistent_units(self.model) @@ -239,15 +331,37 @@ def check_unit_consistency(self): return inconsistent_units def display_components_with_inconsistent_units(self, stream=stdout): + """ + Prints a list of all Constraints, Expressions and Objectives in the + model with inconsistent units of measurement. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, - lines_list=self.check_unit_consistency(), + lines_list=self._check_unit_consistency(), title=f"The following component(s) have unit consistency issues:", header="=", footer="=", ) def display_constraints_with_large_residuals(self, stream=stdout): + """ + Prints a list of Constraints with residuals greater than a specified tolerance. + Tolerance can be set in the class configuration options. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ self._write_report_section( stream=stream, lines_list=large_residuals_set(self.model, tol=self.residual_tolerance), @@ -257,6 +371,17 @@ def display_constraints_with_large_residuals(self, stream=stdout): ) def check_dulmage_mendelsohn_partition(self): + """ + Performs a Dulmage-Mendelsohn partioning on the model and returns + the over- and under-constraint sub-problems.. + + Returns: + list of variables in the under-constrained set + list of constraints in the under-constrained set + list of variables in the over-constrained set + list of constraints in the over-constrained set + + """ igraph = IncidenceGraphInterface(self.model) var_dm_partition, con_dm_partition = igraph.dulmage_mendelsohn() @@ -269,6 +394,20 @@ def check_dulmage_mendelsohn_partition(self): return uc_var, uc_con, oc_var, oc_con def display_underconstrained_set(self, stream=stdout): + """ + Prints the variables and constraints in the under-constrained sub-problem + from a Dulmage-Mendelsohn partitioning. + + This cane be used to indentify the under-defined part of a model and thus + where additional information (fixed variables or constraints) are required. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ uc_var, uc_con, _, _ = self.check_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") @@ -285,6 +424,20 @@ def display_underconstrained_set(self, stream=stdout): stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") def display_overconstrained_set(self, stream=stdout): + """ + Prints the variables and constraints in the over-constrained sub-problem + from a Dulmage-Mendelsohn partitioning. + + This cane be used to indentify the over-defined part of a model and thus + where constraints must be removed or variables unfixed. + + Args: + stream: an I/O object to write the list to (default = stdout) + + Returns: + None + + """ _, _, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") @@ -304,7 +457,15 @@ def display_overconstrained_set(self, stream=stdout): # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? def _collect_structural_warnings(self): - uc = self.check_unit_consistency() + """ + Runs checks for structural warnings and returns two lists. + + Returns: + warnings - list of warning messages from structural analysis + next_steps - list of suggested next steps to further investigate warnings + + """ + uc = self._check_unit_consistency() uc_var, uc_con, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() # Collect warnings @@ -339,6 +500,13 @@ def _collect_structural_warnings(self): return warnings, next_steps def _collect_structural_cautions(self): + """ + Runs checks for structural cautions and returns a list. + + Returns: + cautions - list of caution messages from structural analysis + + """ # Collect cautions cautions = [] zero_vars = self._vars_fixed_to_zero() @@ -364,6 +532,14 @@ def _collect_structural_cautions(self): return cautions def _collect_numerical_warnings(self): + """ + Runs checks for numerical warnings and returns two lists. + + Returns: + warnings - list of warning messages from numerical analysis + next_steps - list of suggested next steps to further investigate warnings + + """ warnings = [] next_steps = [] @@ -401,6 +577,13 @@ def _collect_numerical_warnings(self): return warnings, next_steps def _collect_numerical_cautions(self): + """ + Runs checks for numerical cautions and returns a list. + + Returns: + cautions - list of caution messages from numerical analysis + + """ cautions = [] # Variables near bounds @@ -434,11 +617,27 @@ def _collect_numerical_cautions(self): return cautions def assert_no_structural_warnings(self): + """ + Checks for structural warnings in the model and raises an AssertionError + if any are found. + + Raises: + AssertionError if any warnings are identified by structural analysis. + + """ warnings, _ = self._collect_structural_warnings() if len(warnings) > 0: raise AssertionError(f"Structural issues found ({len(warnings)}).") def assert_no_numerical_warnings(self): + """ + Checks for numerical warnings in the model and raises an AssertionError + if any are found. + + Raises: + AssertionError if any warnings are identified by numerical analysis. + + """ warnings, _ = self._collect_numerical_warnings() if len(warnings) > 0: raise AssertionError(f"Numerical issues found ({len(warnings)}).") @@ -446,6 +645,21 @@ def assert_no_numerical_warnings(self): def _write_report_section( self, stream, lines_list, title=None, else_line=None, header="-", footer=None ): + """ + Writes output in standard format for report and display methods. + + Args: + stream: stream to write to + lines_list: list containing lines ot be writen in body of report + title: title to be put at top of report + else_line: line ot be written if lines_list is empty + header: character to use to write header separation line + footer: character to use to write footer separation line + + Returns: + None + + """ stream.write(f"\n{header * MAX_STR_LENGTH}\n") if title is not None: stream.write(f"{title}\n\n") @@ -458,6 +672,21 @@ def _write_report_section( stream.write(f"\n{footer * MAX_STR_LENGTH}\n") def report_structural_issues(self, stream=stdout): + """ + Generates a summary report of any structural issues identified in the model provided + and suggest next steps for debugging model. + + This should be the first method called when debugging a model and after any change + is made to the model. These checks can be run before trying to initialize and solve + the model. + + Args: + stream: I/O object to write report to (default = stdout) + + Returns: + None + + """ # Potential evaluation errors # High Index @@ -533,6 +762,20 @@ def report_structural_issues(self, stream=stdout): ) def report_numerical_issues(self, stream=stdout): + """ + Generates a summary report of any numerical issues identified in the model provided + and suggest next steps for debugging model. + + Numerical checks should only be performed once all structural issues have been resolved, + and require that at least a partial solution to the model is available. + + Args: + stream: I/O object to write report to (default = stdout) + + Returns: + None + + """ warnings, next_steps = self._collect_numerical_warnings() cautions = self._collect_numerical_cautions() From 474ff3c8d8f67024140958564ff1cfb04c6774f3 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 17 Jul 2023 11:18:21 -0400 Subject: [PATCH 08/48] Import DiagnosticsToolbox in __init__ --- idaes/core/util/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/idaes/core/util/__init__.py b/idaes/core/util/__init__.py index 19a594f593..4a12b0b2a9 100644 --- a/idaes/core/util/__init__.py +++ b/idaes/core/util/__init__.py @@ -15,3 +15,4 @@ from .model_serializer import to_json, from_json, StoreSpec from .tags import svg_tag, ModelTag, ModelTagGroup +from .model_diagnostics import DiagnosticsToolbox From 608763c12aef46a148a436dfdb3a89c210a55636 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 18 Jul 2023 15:59:41 -0400 Subject: [PATCH 09/48] Fixing issues identified in tutorial --- idaes/core/util/model_diagnostics.py | 97 ++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 19 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 5fccba2844..9ca7e2531f 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -92,8 +92,12 @@ class DiagnosticsToolbox: To get started: 1. Create an instance of your model - this does not need to be initialized yet. - 2. Create an instance of the toolbox the model to debug as the model argument. - 3. Call the report_structural_issues() method. + 2. Fix variables until you have 0 degrees of freedom - many of these tools presume + a square model, and a square model should always be the foundation of any more + advanced model. + 3. Create an instance of the DiagnosticsToolbox and provide the model to debug as + the model argument. + 4. Call the report_structural_issues() method. Model diagnostics is an iterative process and you will likely need to run these tools multiple times to resolve all issues. After making a change to your model, @@ -104,6 +108,21 @@ class DiagnosticsToolbox: Note that structural checks do not require the model to be initialized, thus users should start with these. Numerical checks require at least a partial solution to the model and should only be run once all structural issues have been resolved. + + Report methods will print a summary containing three parts: + + 1. Warnings - these are critical issues that should be resolved before continuing. + For each warning, a method will be suggested in the Next Steps section to get + additional information. + 2. Cautions - these are things that could be correct but could also be hte source of + solver issues. Not all cautions need ot be addressed, but users should investigate + each one to ensure that the behavior is correct and that they will not be hte source + of difficulties later. Methods exist to provide more information on all cautions, + but these will not appear in the Next Steps section. + 3. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to + get further information on warnings. If no warnings are found, this will suggest + the next report method to call. + """ def __init__(self, model: Block): @@ -135,9 +154,9 @@ def _vars_outside_bounds(self): violated_bounds = ComponentSet() for v in self.model.component_data_objects(Var, descend_into=True): if v.value is not None: - if v.lb is not None and v.value < v.lb: + if v.lb is not None and v.value <= v.lb: violated_bounds.add(v) - elif v.ub is not None and v.value > v.lb: + elif v.ub is not None and v.value >= v.ub: violated_bounds.add(v) return violated_bounds @@ -172,7 +191,6 @@ def display_external_variables(self, stream=stdout): stream=stream, lines_list=ext_vars, title=f"The following external variable(s) appear in constraints within the model:", - else_line="No warnings found!", header="=", footer="=", ) @@ -191,7 +209,7 @@ def display_unused_variables(self, stream=stdout): self._write_report_section( stream=stream, lines_list=variables_not_in_activated_constraints_set(self.model), - title=f"The following external variable(s) appear in constraints within the model:", + title=f"The following variable(s) do not appear in any activated constraints within the model:", header="=", footer="=", ) @@ -229,7 +247,10 @@ def display_variables_outside_bounds(self, stream=stdout): """ self._write_report_section( stream=stream, - lines_list=self._vars_outside_bounds(), + lines_list=[ + f"{v.name} ({'fixed' if v.fixed else 'free'}): value={value(v)} bounds={v.bounds}" + for v in self._vars_outside_bounds() + ], title=f"The following variable(s) have values outside their bounds:", header="=", footer="=", @@ -269,7 +290,7 @@ def display_variables_with_value_near_zero(self, stream=stdout): """ self._write_report_section( stream=stream, - lines_list=self._vars_near_zero(), + lines_list=[f"{v.name}: value={value(v)}" for v in self._vars_near_zero()], title=f"The following variable(s) have a value close to zero:", header="=", footer="=", @@ -290,7 +311,9 @@ def display_poorly_scaled_variables(self, stream=stdout): """ self._write_report_section( stream=stream, - lines_list=list_badly_scaled_variables(self.model), + lines_list=[ + f"{i.name}: {j}" for i, j in list_badly_scaled_variables(self.model) + ], title=f"The following variable(s) are poorly scaled:", header="=", footer="=", @@ -310,7 +333,10 @@ def display_variables_near_bounds(self, stream=stdout): """ self._write_report_section( stream=stream, - lines_list=variables_near_bounds_set(self.model), + lines_list=[ + f"{v.name}: value={value(v)} bounds={v.bounds}" + for v in variables_near_bounds_set(self.model) + ], title=f"The following variable(s) have values close to their bounds:", header="=", footer="=", @@ -346,6 +372,8 @@ def display_components_with_inconsistent_units(self, stream=stdout): stream=stream, lines_list=self._check_unit_consistency(), title=f"The following component(s) have unit consistency issues:", + end_line="For more details on constraint violations, import the " + "assert_units_consistent method\nfrom pyomo.util.check_units", header="=", footer="=", ) @@ -411,7 +439,7 @@ def display_underconstrained_set(self, stream=stdout): uc_var, uc_con, _, _ = self.check_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("Dulmage_Mendelsohn Under-Constrained Set\n\n") + stream.write("Dulmage-Mendelsohn Under-Constrained Set\n\n") stream.write(f"{TAB}Variables:\n\n") for v in uc_var: @@ -441,7 +469,7 @@ def display_overconstrained_set(self, stream=stdout): _, _, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") - stream.write("Dulmage_Mendelsohn Over-Constrained Set\n\n") + stream.write("Dulmage-Mendelsohn Over-Constrained Set\n\n") stream.write(f"{TAB}Variables:\n\n") for v in oc_var: @@ -643,7 +671,14 @@ def assert_no_numerical_warnings(self): raise AssertionError(f"Numerical issues found ({len(warnings)}).") def _write_report_section( - self, stream, lines_list, title=None, else_line=None, header="-", footer=None + self, + stream, + lines_list, + title=None, + else_line=None, + end_line=None, + header="-", + footer=None, ): """ Writes output in standard format for report and display methods. @@ -652,7 +687,8 @@ def _write_report_section( stream: stream to write to lines_list: list containing lines ot be writen in body of report title: title to be put at top of report - else_line: line ot be written if lines_list is empty + else_line: line to be written if lines_list is empty + end_line: line to be written at end of report header: character to use to write header separation line footer: character to use to write footer separation line @@ -660,7 +696,7 @@ def _write_report_section( None """ - stream.write(f"\n{header * MAX_STR_LENGTH}\n") + stream.write(f"{header * MAX_STR_LENGTH}\n") if title is not None: stream.write(f"{title}\n\n") if len(lines_list) > 0: @@ -668,8 +704,11 @@ def _write_report_section( stream.write(f"{TAB}{i}\n") elif else_line is not None: stream.write(f"{TAB}{else_line}\n") + stream.write("\n") + if end_line is not None: + stream.write(f"{end_line}\n") if footer is not None: - stream.write(f"\n{footer * MAX_STR_LENGTH}\n") + stream.write(f"{footer * MAX_STR_LENGTH}\n") def report_structural_issues(self, stream=stdout): """ @@ -693,6 +732,9 @@ def report_structural_issues(self, stream=stdout): vars_in_constraints = variables_in_activated_constraints_set(self.model) fixed_vars_in_constraints = ComponentSet() free_vars_in_constraints = ComponentSet() + free_vars_lb = ComponentSet() + free_vars_ub = ComponentSet() + free_vars_lbub = ComponentSet() ext_fixed_vars_in_constraints = ComponentSet() ext_free_vars_in_constraints = ComponentSet() for v in vars_in_constraints: @@ -704,6 +746,13 @@ def report_structural_issues(self, stream=stdout): free_vars_in_constraints.add(v) if not _var_in_block(v, self.model): ext_free_vars_in_constraints.add(v) + if v.lb is not None: + if v.ub is not None: + free_vars_lbub.add(v) + else: + free_vars_lb.add(v) + elif v.ub is not None: + free_vars_ub.add(v) # Generate report # TODO: Variables with bounds @@ -717,6 +766,16 @@ def report_structural_issues(self, stream=stdout): f"{len(free_vars_in_constraints)} " f"(External: {len(ext_free_vars_in_constraints)})" ) + stats.append( + f"{TAB*2}Free Variables with only lower bounds: " f"{len(free_vars_lb)} " + ) + stats.append( + f"{TAB * 2}Free Variables with only upper bounds: " f"{len(free_vars_ub)} " + ) + stats.append( + f"{TAB * 2}Free Variables with upper and lower bounds: " + f"{len(free_vars_lbub)} " + ) stats.append( f"{TAB}Fixed Variables in Activated Constraints: " f"{len(fixed_vars_in_constraints)} " @@ -750,14 +809,14 @@ def report_structural_issues(self, stream=stdout): self._write_report_section( stream=stream, lines_list=cautions, - title=f"{len(warnings)} Cautions", + title=f"{len(cautions)} Cautions", else_line="No cautions found!", ) self._write_report_section( stream=stream, lines_list=next_steps, title="Suggested next steps:", - else_line="report_numerical_issues()", + else_line="Try to initialize/solve your model and then call report_numerical_issues()", footer="=", ) @@ -789,7 +848,7 @@ def report_numerical_issues(self, stream=stdout): self._write_report_section( stream=stream, lines_list=cautions, - title=f"{len(warnings)} Cautions", + title=f"{len(cautions)} Cautions", else_line="No cautions found!", ) self._write_report_section( From ca8ebd004e536248b6783636c49bb2b52cd84c13 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 25 Jul 2023 09:44:41 -0400 Subject: [PATCH 10/48] Cleaning up for initial PR --- idaes/core/util/model_diagnostics.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 9ca7e2531f..b13eb10894 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -42,6 +42,7 @@ from pyomo.core.base.block import _BlockData from pyomo.common.collections import ComponentSet +# TODO: Switch to this once the Pyomo PR is merged # from pyomo.util.check_units import identify_inconsistent_units from pyomo.util.check_units import assert_units_consistent from pyomo.core.base.units_container import UnitsError @@ -150,7 +151,7 @@ def _vars_near_zero(self): near_zero_vars.add(v) return near_zero_vars - def _vars_outside_bounds(self): + def _vars_violating_bounds(self): violated_bounds = ComponentSet() for v in self.model.component_data_objects(Var, descend_into=True): if v.value is not None: @@ -233,9 +234,9 @@ def display_variables_fixed_to_zero(self, stream=stdout): footer="=", ) - def display_variables_outside_bounds(self, stream=stdout): + def display_variables_with_bounds_violations(self, stream=stdout): """ - Prints a list of variables with values that fall outside the bounds + Prints a list of variables with values that fall at or outside the bounds on the variable. Args: @@ -249,9 +250,9 @@ def display_variables_outside_bounds(self, stream=stdout): stream=stream, lines_list=[ f"{v.name} ({'fixed' if v.fixed else 'free'}): value={value(v)} bounds={v.bounds}" - for v in self._vars_outside_bounds() + for v in self._vars_violating_bounds() ], - title=f"The following variable(s) have values outside their bounds:", + title=f"The following variable(s) have values at or outside their bounds:", header="=", footer="=", ) @@ -583,7 +584,7 @@ def _collect_numerical_warnings(self): next_steps.append("display_constraints_with_large_residuals()") # Variables outside bounds - violated_bounds = self._vars_outside_bounds() + violated_bounds = self._vars_violating_bounds() if len(violated_bounds) > 0: cstring = "Variables" if len(violated_bounds) == 1: @@ -591,7 +592,7 @@ def _collect_numerical_warnings(self): warnings.append( f"WARNING: {len(violated_bounds)} {cstring} with bounds violations" ) - next_steps.append("display_variables_outside_bounds()") + next_steps.append("display_variables_with_bounds_violations()") # Poor scaling var_scaling = list_badly_scaled_variables(self.model) From aa57b94a3fd4e32cac74c884b7c4971e733f9c4a Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Wed, 26 Jul 2023 11:01:53 -0400 Subject: [PATCH 11/48] Fixing typos --- idaes/core/util/model_diagnostics.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index b13eb10894..c0de6cb97b 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -115,9 +115,9 @@ class DiagnosticsToolbox: 1. Warnings - these are critical issues that should be resolved before continuing. For each warning, a method will be suggested in the Next Steps section to get additional information. - 2. Cautions - these are things that could be correct but could also be hte source of - solver issues. Not all cautions need ot be addressed, but users should investigate - each one to ensure that the behavior is correct and that they will not be hte source + 2. Cautions - these are things that could be correct but could also be the source of + solver issues. Not all cautions need to be addressed, but users should investigate + each one to ensure that the behavior is correct and that they will not be the source of difficulties later. Methods exist to provide more information on all cautions, but these will not appear in the Next Steps section. 3. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to @@ -401,7 +401,7 @@ def display_constraints_with_large_residuals(self, stream=stdout): def check_dulmage_mendelsohn_partition(self): """ - Performs a Dulmage-Mendelsohn partioning on the model and returns + Performs a Dulmage-Mendelsohn partitioning on the model and returns the over- and under-constraint sub-problems.. Returns: @@ -427,7 +427,7 @@ def display_underconstrained_set(self, stream=stdout): Prints the variables and constraints in the under-constrained sub-problem from a Dulmage-Mendelsohn partitioning. - This cane be used to indentify the under-defined part of a model and thus + This cane be used to identify the under-defined part of a model and thus where additional information (fixed variables or constraints) are required. Args: @@ -457,7 +457,7 @@ def display_overconstrained_set(self, stream=stdout): Prints the variables and constraints in the over-constrained sub-problem from a Dulmage-Mendelsohn partitioning. - This cane be used to indentify the over-defined part of a model and thus + This cane be used to identify the over-defined part of a model and thus where constraints must be removed or variables unfixed. Args: @@ -686,7 +686,7 @@ def _write_report_section( Args: stream: stream to write to - lines_list: list containing lines ot be writen in body of report + lines_list: list containing lines to be written in body of report title: title to be put at top of report else_line: line to be written if lines_list is empty end_line: line to be written at end of report From 24ca54c52be24876ee5dae0cd183300776a40174 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Wed, 9 Aug 2023 14:10:54 -0400 Subject: [PATCH 12/48] Addressing comments and clean up --- idaes/core/util/model_diagnostics.py | 384 ++++++++++++++------------- 1 file changed, 199 insertions(+), 185 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index c0de6cb97b..1ef3e99b71 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -41,11 +41,8 @@ ) from pyomo.core.base.block import _BlockData from pyomo.common.collections import ComponentSet - -# TODO: Switch to this once the Pyomo PR is merged -# from pyomo.util.check_units import identify_inconsistent_units -from pyomo.util.check_units import assert_units_consistent -from pyomo.core.base.units_container import UnitsError +from pyomo.common.config import ConfigDict, ConfigValue, document_kwargs_from_configdict +from pyomo.util.check_units import identify_inconsistent_units from pyomo.contrib.incidence_analysis import IncidenceGraphInterface from pyomo.core.expr.visitor import identify_variables from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP @@ -77,15 +74,28 @@ TAB = " " * 4 -def _var_in_block(var, block): - parent = var.parent_block() - while parent is not None: - if parent is block: - return True - parent = parent.parent_block() - return False +CONFIG = ConfigDict() +CONFIG.declare("model", ConfigValue(description="Pyomo model object to be diagnosed.")) +CONFIG.declare( + "residual_tolerance", + ConfigValue( + default=1e-5, + domain=float, + description="Absolute tolerance to use when checking constraint residuals.", + ), +) +CONFIG.declare( + "zero_tolerance", + ConfigValue( + default=1e-6, + domain=float, + description="Absolute tolerance to use when comparing values to zero.", + ), +) +# TODO: Add scaling tolerance parameters +@document_kwargs_from_configdict(CONFIG) class DiagnosticsToolbox: """ The IDAES Model DiagnosticsToolbox. @@ -126,51 +136,11 @@ class DiagnosticsToolbox: """ - def __init__(self, model: Block): - if not isinstance(model, Block): - raise ValueError("model argument must be an instance of a Pyomo Block.") - self.model = model - # TODO: Work out how to manage and document these - self.residual_tolerance = 1e-5 - self.zero_tolerance = 1e-6 - # TODO: Add scaling tolerance parameters - - def _vars_fixed_to_zero(self): - # Set of variables fixed to 0 - zero_vars = ComponentSet() - for v in self.model.component_data_objects(Var, descend_into=True): - if v.fixed and value(v) == 0: - zero_vars.add(v) - return zero_vars - - def _vars_near_zero(self): - # Set of variables with values close to 0 - near_zero_vars = ComponentSet() - for v in self.model.component_data_objects(Var, descend_into=True): - if v.value is not None and abs(value(v)) <= self.zero_tolerance: - near_zero_vars.add(v) - return near_zero_vars - - def _vars_violating_bounds(self): - violated_bounds = ComponentSet() - for v in self.model.component_data_objects(Var, descend_into=True): - if v.value is not None: - if v.lb is not None and v.value <= v.lb: - violated_bounds.add(v) - elif v.ub is not None and v.value >= v.ub: - violated_bounds.add(v) - - return violated_bounds - - def _vars_with_none_value(self): - none_value = ComponentSet() - for v in self.model.component_data_objects(Var, descend_into=True): - if v.value is None: - none_value.add(v) - - return none_value - - # TODO: deactivated blocks, constraints, objectives, + def __init__(self, **kwargs): + self.config = CONFIG(kwargs) + if not isinstance(self.config.model, Block): + raise TypeError("model argument must be an instance of a Pyomo Block.") + def display_external_variables(self, stream=stdout): """ Prints a list of variables that appear within Constraints in the model @@ -184,14 +154,14 @@ def display_external_variables(self, stream=stdout): """ ext_vars = [] - for v in variables_in_activated_constraints_set(self.model): - if not _var_in_block(v, self.model): + for v in variables_in_activated_constraints_set(self.config.model): + if not _var_in_block(v, self.config.model): ext_vars.append(v.name) - self._write_report_section( + _write_report_section( stream=stream, lines_list=ext_vars, - title=f"The following external variable(s) appear in constraints within the model:", + title="The following external variable(s) appear in constraints within the model:", header="=", footer="=", ) @@ -207,10 +177,10 @@ def display_unused_variables(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, - lines_list=variables_not_in_activated_constraints_set(self.model), - title=f"The following variable(s) do not appear in any activated constraints within the model:", + lines_list=variables_not_in_activated_constraints_set(self.config.model), + title="The following variable(s) do not appear in any activated constraints within the model:", header="=", footer="=", ) @@ -226,10 +196,10 @@ def display_variables_fixed_to_zero(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, - lines_list=self._vars_fixed_to_zero(), - title=f"The following variable(s) are fixed to zero:", + lines_list=_vars_fixed_to_zero(self.config.model), + title="The following variable(s) are fixed to zero:", header="=", footer="=", ) @@ -246,13 +216,13 @@ def display_variables_with_bounds_violations(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, lines_list=[ f"{v.name} ({'fixed' if v.fixed else 'free'}): value={value(v)} bounds={v.bounds}" - for v in self._vars_violating_bounds() + for v in _vars_violating_bounds(self.config.model) ], - title=f"The following variable(s) have values at or outside their bounds:", + title="The following variable(s) have values at or outside their bounds:", header="=", footer="=", ) @@ -268,10 +238,10 @@ def display_variables_with_none_value(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, - lines_list=self._vars_with_none_value(), - title=f"The following variable(s) have a value of None:", + lines_list=_vars_with_none_value(self.config.model), + title="The following variable(s) have a value of None:", header="=", footer="=", ) @@ -289,10 +259,13 @@ def display_variables_with_value_near_zero(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, - lines_list=[f"{v.name}: value={value(v)}" for v in self._vars_near_zero()], - title=f"The following variable(s) have a value close to zero:", + lines_list=[ + f"{v.name}: value={value(v)}" + for v in _vars_near_zero(self.config.model, self.config.zero_tolerance) + ], + title="The following variable(s) have a value close to zero:", header="=", footer="=", ) @@ -310,12 +283,13 @@ def display_poorly_scaled_variables(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, lines_list=[ - f"{i.name}: {j}" for i, j in list_badly_scaled_variables(self.model) + f"{i.name}: {j}" + for i, j in list_badly_scaled_variables(self.config.model) ], - title=f"The following variable(s) are poorly scaled:", + title="The following variable(s) are poorly scaled:", header="=", footer="=", ) @@ -332,31 +306,17 @@ def display_variables_near_bounds(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, lines_list=[ f"{v.name}: value={value(v)} bounds={v.bounds}" - for v in variables_near_bounds_set(self.model) + for v in variables_near_bounds_set(self.config.model) ], - title=f"The following variable(s) have values close to their bounds:", + title="The following variable(s) have values close to their bounds:", header="=", footer="=", ) - def _check_unit_consistency(self): - # Check unit consistency - # TODO: replace once Pyomo method ready - # return identify_inconsistent_units(self.model) - inconsistent_units = ComponentSet() - for o in self.model.component_data_objects( - [Constraint, Expression, Objective], descend_into=True - ): - try: - assert_units_consistent(o) - except UnitsError: - inconsistent_units.add(o) - return inconsistent_units - def display_components_with_inconsistent_units(self, stream=stdout): """ Prints a list of all Constraints, Expressions and Objectives in the @@ -369,10 +329,10 @@ def display_components_with_inconsistent_units(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, - lines_list=self._check_unit_consistency(), - title=f"The following component(s) have unit consistency issues:", + lines_list=identify_inconsistent_units(self.config.model), + title="The following component(s) have unit consistency issues:", end_line="For more details on constraint violations, import the " "assert_units_consistent method\nfrom pyomo.util.check_units", header="=", @@ -391,15 +351,17 @@ def display_constraints_with_large_residuals(self, stream=stdout): None """ - self._write_report_section( + _write_report_section( stream=stream, - lines_list=large_residuals_set(self.model, tol=self.residual_tolerance), - title=f"The following constraint(s) have large residuals:", + lines_list=large_residuals_set( + self.config.model, tol=self.config.residual_tolerance + ), + title="The following constraint(s) have large residuals:", header="=", footer="=", ) - def check_dulmage_mendelsohn_partition(self): + def get_dulmage_mendelsohn_partition(self): """ Performs a Dulmage-Mendelsohn partitioning on the model and returns the over- and under-constraint sub-problems.. @@ -411,7 +373,7 @@ def check_dulmage_mendelsohn_partition(self): list of constraints in the over-constrained set """ - igraph = IncidenceGraphInterface(self.model) + igraph = IncidenceGraphInterface(self.config.model) var_dm_partition, con_dm_partition = igraph.dulmage_mendelsohn() # Collect under- and order-constrained sub-system @@ -437,7 +399,7 @@ def display_underconstrained_set(self, stream=stdout): None """ - uc_var, uc_con, _, _ = self.check_dulmage_mendelsohn_partition() + uc_var, uc_con, _, _ = self.get_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Under-Constrained Set\n\n") @@ -467,7 +429,7 @@ def display_overconstrained_set(self, stream=stdout): None """ - _, _, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() + _, _, oc_var, oc_con = self.get_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Over-Constrained Set\n\n") @@ -494,13 +456,13 @@ def _collect_structural_warnings(self): next_steps - list of suggested next steps to further investigate warnings """ - uc = self._check_unit_consistency() - uc_var, uc_con, oc_var, oc_con = self.check_dulmage_mendelsohn_partition() + uc = identify_inconsistent_units(self.config.model) + uc_var, uc_con, oc_var, oc_con = self.get_dulmage_mendelsohn_partition() # Collect warnings warnings = [] next_steps = [] - dof = degrees_of_freedom(self.model) + dof = degrees_of_freedom(self.config.model) if dof != 0: dstring = "Degrees" if dof == abs(1): @@ -538,13 +500,13 @@ def _collect_structural_cautions(self): """ # Collect cautions cautions = [] - zero_vars = self._vars_fixed_to_zero() + zero_vars = _vars_fixed_to_zero(self.config.model) if len(zero_vars) > 0: vstring = "variables" if len(zero_vars) == 1: vstring = "variable" cautions.append(f"Caution: {len(zero_vars)} {vstring} fixed to 0") - unused_vars = variables_not_in_activated_constraints_set(self.model) + unused_vars = variables_not_in_activated_constraints_set(self.config.model) unused_vars_fixed = 0 for v in unused_vars: if v.fixed: @@ -573,7 +535,9 @@ def _collect_numerical_warnings(self): next_steps = [] # Large residuals - large_residuals = large_residuals_set(self.model, tol=self.residual_tolerance) + large_residuals = large_residuals_set( + self.config.model, tol=self.config.residual_tolerance + ) if len(large_residuals) > 0: cstring = "Constraints" if len(large_residuals) == 1: @@ -584,7 +548,7 @@ def _collect_numerical_warnings(self): next_steps.append("display_constraints_with_large_residuals()") # Variables outside bounds - violated_bounds = self._vars_violating_bounds() + violated_bounds = _vars_violating_bounds(self.config.model) if len(violated_bounds) > 0: cstring = "Variables" if len(violated_bounds) == 1: @@ -595,7 +559,7 @@ def _collect_numerical_warnings(self): next_steps.append("display_variables_with_bounds_violations()") # Poor scaling - var_scaling = list_badly_scaled_variables(self.model) + var_scaling = list_badly_scaled_variables(self.config.model) if len(var_scaling) > 0: cstring = "Variables" if len(var_scaling) == 1: @@ -616,7 +580,7 @@ def _collect_numerical_cautions(self): cautions = [] # Variables near bounds - near_bounds = variables_near_bounds_set(self.model) + near_bounds = variables_near_bounds_set(self.config.model) if len(near_bounds) > 0: cstring = "Variables" if len(near_bounds) == 1: @@ -626,7 +590,7 @@ def _collect_numerical_cautions(self): ) # Variables near zero - near_zero = self._vars_near_zero() + near_zero = _vars_near_zero(self.config.model, self.config.zero_tolerance) if len(near_zero) > 0: cstring = "Variables" if len(near_zero) == 1: @@ -636,7 +600,7 @@ def _collect_numerical_cautions(self): ) # Variables with value None - none_value = self._vars_with_none_value() + none_value = _vars_with_none_value(self.config.model) if len(none_value) > 0: cstring = "Variables" if len(none_value) == 1: @@ -671,46 +635,6 @@ def assert_no_numerical_warnings(self): if len(warnings) > 0: raise AssertionError(f"Numerical issues found ({len(warnings)}).") - def _write_report_section( - self, - stream, - lines_list, - title=None, - else_line=None, - end_line=None, - header="-", - footer=None, - ): - """ - Writes output in standard format for report and display methods. - - Args: - stream: stream to write to - lines_list: list containing lines to be written in body of report - title: title to be put at top of report - else_line: line to be written if lines_list is empty - end_line: line to be written at end of report - header: character to use to write header separation line - footer: character to use to write footer separation line - - Returns: - None - - """ - stream.write(f"{header * MAX_STR_LENGTH}\n") - if title is not None: - stream.write(f"{title}\n\n") - if len(lines_list) > 0: - for i in lines_list: - stream.write(f"{TAB}{i}\n") - elif else_line is not None: - stream.write(f"{TAB}{else_line}\n") - stream.write("\n") - if end_line is not None: - stream.write(f"{end_line}\n") - if footer is not None: - stream.write(f"{footer * MAX_STR_LENGTH}\n") - def report_structural_issues(self, stream=stdout): """ Generates a summary report of any structural issues identified in the model provided @@ -730,7 +654,7 @@ def report_structural_issues(self, stream=stdout): # Potential evaluation errors # High Index - vars_in_constraints = variables_in_activated_constraints_set(self.model) + vars_in_constraints = variables_in_activated_constraints_set(self.config.model) fixed_vars_in_constraints = ComponentSet() free_vars_in_constraints = ComponentSet() free_vars_lb = ComponentSet() @@ -741,11 +665,11 @@ def report_structural_issues(self, stream=stdout): for v in vars_in_constraints: if v.fixed: fixed_vars_in_constraints.add(v) - if not _var_in_block(v, self.model): + if not _var_in_block(v, self.config.model): ext_fixed_vars_in_constraints.add(v) else: free_vars_in_constraints.add(v) - if not _var_in_block(v, self.model): + if not _var_in_block(v, self.config.model): ext_free_vars_in_constraints.add(v) if v.lb is not None: if v.ub is not None: @@ -759,8 +683,8 @@ def report_structural_issues(self, stream=stdout): # TODO: Variables with bounds stats = [] stats.append( - f"{TAB}Activated Blocks: {len(activated_blocks_set(self.model))} " - f"(Deactivated: {len(deactivated_blocks_set(self.model))})" + f"{TAB}Activated Blocks: {len(activated_blocks_set(self.config.model))} " + f"(Deactivated: {len(deactivated_blocks_set(self.config.model))})" ) stats.append( f"{TAB}Free Variables in Activated Constraints: " @@ -768,10 +692,10 @@ def report_structural_issues(self, stream=stdout): f"(External: {len(ext_free_vars_in_constraints)})" ) stats.append( - f"{TAB*2}Free Variables with only lower bounds: " f"{len(free_vars_lb)} " + f"{TAB*2}Free Variables with only lower bounds: {len(free_vars_lb)} " ) stats.append( - f"{TAB * 2}Free Variables with only upper bounds: " f"{len(free_vars_ub)} " + f"{TAB * 2}Free Variables with only upper bounds: {len(free_vars_ub)} " ) stats.append( f"{TAB * 2}Free Variables with upper and lower bounds: " @@ -783,41 +707,41 @@ def report_structural_issues(self, stream=stdout): f"(External: {len(ext_fixed_vars_in_constraints)})" ) stats.append( - f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(self.model))} " - f"(Deactivated: {len(deactivated_equalities_set(self.model))})" + f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(self.config.model))} " + f"(Deactivated: {len(deactivated_equalities_set(self.config.model))})" ) stats.append( - f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(self.model))} " - f"(Deactivated: {len(deactivated_inequalities_set(self.model))})" + f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(self.config.model))} " + f"(Deactivated: {len(deactivated_inequalities_set(self.config.model))})" ) stats.append( - f"{TAB}Activated Objectives: {len(activated_objectives_set(self.model))} " - f"(Deactivated: {len(deactivated_objectives_set(self.model))})" + f"{TAB}Activated Objectives: {len(activated_objectives_set(self.config.model))} " + f"(Deactivated: {len(deactivated_objectives_set(self.config.model))})" ) warnings, next_steps = self._collect_structural_warnings() cautions = self._collect_structural_cautions() - self._write_report_section( + _write_report_section( stream=stream, lines_list=stats, title="Model Statistics", header="=" ) - self._write_report_section( + _write_report_section( stream=stream, lines_list=warnings, title=f"{len(warnings)} WARNINGS", - else_line="No warnings found!", + line_if_empty="No warnings found!", ) - self._write_report_section( + _write_report_section( stream=stream, lines_list=cautions, title=f"{len(cautions)} Cautions", - else_line="No cautions found!", + line_if_empty="No cautions found!", ) - self._write_report_section( + _write_report_section( stream=stream, lines_list=next_steps, title="Suggested next steps:", - else_line="Try to initialize/solve your model and then call report_numerical_issues()", + line_if_empty="Try to initialize/solve your model and then call report_numerical_issues()", footer="=", ) @@ -839,24 +763,24 @@ def report_numerical_issues(self, stream=stdout): warnings, next_steps = self._collect_numerical_warnings() cautions = self._collect_numerical_cautions() - self._write_report_section( + _write_report_section( stream=stream, lines_list=warnings, title=f"{len(warnings)} WARNINGS", - else_line="No warnings found!", + line_if_empty="No warnings found!", header="=", ) - self._write_report_section( + _write_report_section( stream=stream, lines_list=cautions, title=f"{len(cautions)} Cautions", - else_line="No cautions found!", + line_if_empty="No cautions found!", ) - self._write_report_section( + _write_report_section( stream=stream, lines_list=next_steps, title="Suggested next steps:", - else_line=f"If you still have issues converging your model consider:\n" + line_if_empty=f"If you still have issues converging your model consider:\n" f"{TAB*2}svd_analysis(TBA)\n{TAB*2}degeneracy_hunter (TBA)", footer="=", ) @@ -1425,10 +1349,10 @@ def underdetermined_variables_and_constraints(self, n_calc=1, tol=0.1, dense=Fal n_sv = len(self.s) if n_sv < n_calc: raise ValueError( - "User wanted constraints and variables associated " + f"User wanted constraints and variables associated " f"with the {n_calc}-th smallest singular value, " f"but only {n_sv} small singular values have been " - "calculated. Run svd_analysis again and specify " + f"calculated. Run svd_analysis again and specify " f"n_sv>={n_calc}." ) print("Column: Variable") @@ -1676,3 +1600,93 @@ def ipopt_solve_halt_on_error(model, options=None): return solver.solve( model, tee=True, symbolic_solver_labels=True, export_defined_variables=False ) + + +# ------------------------------------------------------------------------------------------- +# Private module functions +def _var_in_block(var, block): + parent = var.parent_block() + while parent is not None: + if parent is block: + return True + parent = parent.parent_block() + return False + + +def _vars_fixed_to_zero(model): + # 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 + + +def _vars_near_zero(model, zero_tolerance): + # Set of variables with values close to 0 + near_zero_vars = ComponentSet() + for v in model.component_data_objects(Var, descend_into=True): + if v.value is not None and abs(value(v)) <= zero_tolerance: + near_zero_vars.add(v) + return near_zero_vars + + +def _vars_violating_bounds(model): + violated_bounds = ComponentSet() + for v in model.component_data_objects(Var, descend_into=True): + if v.value is not None: + if v.lb is not None and v.value <= v.lb: + violated_bounds.add(v) + elif v.ub is not None and v.value >= v.ub: + violated_bounds.add(v) + + return violated_bounds + + +def _vars_with_none_value(model): + 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 + + +def _write_report_section( + stream, + lines_list, + title=None, + line_if_empty=None, + end_line=None, + header="-", + footer=None, +): + """ + Writes output in standard format for report and display methods. + + Args: + stream: stream to write to + lines_list: list containing lines to be written in body of report + title: title to be put at top of report + line_if_empty: line to be written if lines_list is empty + end_line: line to be written at end of report + header: character to use to write header separation line + footer: character to use to write footer separation line + + Returns: + None + + """ + stream.write(f"{header * MAX_STR_LENGTH}\n") + if title is not None: + stream.write(f"{title}\n\n") + if len(lines_list) > 0: + for i in lines_list: + stream.write(f"{TAB}{i}\n") + elif line_if_empty is not None: + stream.write(f"{TAB}{line_if_empty}\n") + stream.write("\n") + if end_line is not None: + stream.write(f"{end_line}\n") + if footer is not None: + stream.write(f"{footer * MAX_STR_LENGTH}\n") From 94b304ee0671d7fa7aa5ef1090a5b960cb6b9015 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Wed, 9 Aug 2023 14:39:58 -0400 Subject: [PATCH 13/48] DM independent blocks --- idaes/core/util/model_diagnostics.py | 64 ++++++++++++++++------------ 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 1ef3e99b71..694654fb2e 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -204,7 +204,7 @@ def display_variables_fixed_to_zero(self, stream=stdout): footer="=", ) - def display_variables_with_bounds_violations(self, stream=stdout): + def display_variables_at_or_outside_bounds(self, stream=stdout): """ Prints a list of variables with values that fall at or outside the bounds on the variable. @@ -333,7 +333,7 @@ def display_components_with_inconsistent_units(self, stream=stdout): stream=stream, lines_list=identify_inconsistent_units(self.config.model), title="The following component(s) have unit consistency issues:", - end_line="For more details on constraint violations, import the " + end_line="For more details on cunit inconsistencies, import the " "assert_units_consistent method\nfrom pyomo.util.check_units", header="=", footer="=", @@ -364,13 +364,13 @@ def display_constraints_with_large_residuals(self, stream=stdout): def get_dulmage_mendelsohn_partition(self): """ Performs a Dulmage-Mendelsohn partitioning on the model and returns - the over- and under-constraint sub-problems.. + the over- and under-constraint sub-problems. Returns: - list of variables in the under-constrained set - list of constraints in the under-constrained set - list of variables in the over-constrained set - list of constraints in the over-constrained set + list-of-lists variables in each independent block of the under-constrained set + list-of-lists constraints in each independent block of the under-constrained set + list-of-lists variables in each independent block of the over-constrained set + list-of-lists constraints in each independent block of the over-constrained set """ igraph = IncidenceGraphInterface(self.config.model) @@ -382,7 +382,10 @@ def get_dulmage_mendelsohn_partition(self): oc_var = var_dm_partition.overconstrained oc_con = con_dm_partition.overconstrained + con_dm_partition.unmatched - return uc_var, uc_con, oc_var, oc_con + uc_vblocks, uc_cblocks = igraph.get_connected_components(uc_var, uc_con) + oc_vblocks, oc_cblocks = igraph.get_connected_components(oc_var, oc_con) + + return uc_vblocks, uc_cblocks, oc_vblocks, oc_cblocks def display_underconstrained_set(self, stream=stdout): """ @@ -399,20 +402,23 @@ def display_underconstrained_set(self, stream=stdout): None """ - uc_var, uc_con, _, _ = self.get_dulmage_mendelsohn_partition() + uc_vblocks, uc_cblocks, _, _ = self.get_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Under-Constrained Set\n\n") - stream.write(f"{TAB}Variables:\n\n") - for v in uc_var: - stream.write(f"{2*TAB}{v.name}\n") + for i in range(len(uc_vblocks)): + stream.write(f"{TAB}Independent Block {i}:\n\n") + stream.write(f"{2*TAB}Variables:\n\n") + for v in uc_vblocks[i]: + stream.write(f"{3*TAB}{v.name}\n") - stream.write(f"\n{TAB}Constraints:\n\n") - for c in uc_con: - stream.write(f"{2*TAB}{c.name}\n") + stream.write(f"\n{2*TAB}Constraints:\n\n") + for c in uc_cblocks[i]: + stream.write(f"{3*TAB}{c.name}\n") + stream.write("\n") - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("=" * MAX_STR_LENGTH + "\n") def display_overconstrained_set(self, stream=stdout): """ @@ -429,20 +435,23 @@ def display_overconstrained_set(self, stream=stdout): None """ - _, _, oc_var, oc_con = self.get_dulmage_mendelsohn_partition() + _, _, oc_vblocks, oc_cblocks = self.get_dulmage_mendelsohn_partition() stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Over-Constrained Set\n\n") - stream.write(f"{TAB}Variables:\n\n") - for v in oc_var: - stream.write(f"{2*TAB}{v.name}\n") + for i in range(len(oc_vblocks)): + stream.write(f"{TAB}Independent Block {i}:\n\n") + stream.write(f"{2*TAB}Variables:\n\n") + for v in oc_vblocks[i]: + stream.write(f"{3*TAB}{v.name}\n") - stream.write(f"\n{TAB}Constraints:\n\n") - for c in oc_con: - stream.write(f"{2*TAB}{c.name}\n") + stream.write(f"\n{2*TAB}Constraints:\n\n") + for c in oc_cblocks[i]: + stream.write(f"{3*TAB}{c.name}\n") + stream.write("\n") - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("=" * MAX_STR_LENGTH + "\n") # TODO: Block triangularization analysis # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? @@ -556,7 +565,7 @@ def _collect_numerical_warnings(self): warnings.append( f"WARNING: {len(violated_bounds)} {cstring} with bounds violations" ) - next_steps.append("display_variables_with_bounds_violations()") + next_steps.append("display_variables_at_or_outside_bounds()") # Poor scaling var_scaling = list_badly_scaled_variables(self.config.model) @@ -652,8 +661,7 @@ def report_structural_issues(self, stream=stdout): """ # Potential evaluation errors - # High Index - + # TODO: High Index? vars_in_constraints = variables_in_activated_constraints_set(self.config.model) fixed_vars_in_constraints = ComponentSet() free_vars_in_constraints = ComponentSet() @@ -680,7 +688,7 @@ def report_structural_issues(self, stream=stdout): free_vars_ub.add(v) # Generate report - # TODO: Variables with bounds + # TODO: Binary and boolean vars stats = [] stats.append( f"{TAB}Activated Blocks: {len(activated_blocks_set(self.config.model))} " From 499f4ccfcd5597fcb5b39ad955a9d2f52afb6655 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Wed, 9 Aug 2023 15:41:06 -0400 Subject: [PATCH 14/48] Unit tests of private functions --- .../core/util/tests/test_model_diagnostics.py | 218 +++++++++++++----- 1 file changed, 154 insertions(+), 64 deletions(-) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 4546701541..7f7b724f99 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -13,7 +13,7 @@ """ This module contains model diagnostic utility functions for use in IDAES (Pyomo) models. """ - +from io import StringIO import pytest from pyomo.environ import ( @@ -28,6 +28,7 @@ units, Var, ) +from pyomo.common.collections import ComponentSet from pyomo.contrib.pynumero.asl import AmplInterface import numpy as np import idaes.core.util.scaling as iscale @@ -50,100 +51,189 @@ set_bounds_from_valid_range, list_components_with_values_outside_valid_range, ipopt_solve_halt_on_error, + _var_in_block, + _vars_fixed_to_zero, + _vars_near_zero, + _vars_violating_bounds, + _vars_with_none_value, + _write_report_section, ) -__author__ = "Alex Dowling, Douglas Allan" +__author__ = "Alex Dowling, Douglas Allan, Andrew Lee" -@pytest.mark.integration -def test_DiagnosticsToolbox(): +@pytest.fixture +def model(): m = ConcreteModel() + m.b = Block() - # A var outside the model - m.v = Var(units=units.kg) + m.v1 = Var() + m.v2 = Var() + m.v3 = Var() + m.v4 = Var() - # Model to be tested - m.b = Block() + m.v1.fix(0) + m.v2.fix(3) + m.v3.set_value(0) - m.b.v1 = Var(units=units.s) - m.b.v2 = Var(units=units.s) - m.b.v3 = Var(units=units.s) - m.b.v4 = Var(units=units.s) + return m - # Unused var - m.b.v5 = Var(units=units.s) - m.b.v5.fix(0) - # Linearly dependent constraints - m.b.c1 = Constraint(expr=m.b.v1 + m.b.v2 == 10 * units.s) - m.b.c2 = Constraint(expr=2 * m.b.v1 + 2 * m.b.v2 == 20 * units.s) - m.b.c3 = Constraint(expr=m.b.v3 + m.b.v4 == 20 * units.s) +@pytest.mark.unit +def test_var_in_block(model): + assert _var_in_block(model.v, model) + assert not _var_in_block(model.v, model.b) - # An inequality - m.b.c4 = Constraint(expr=m.b.v1 + m.v >= 10 * units.s) - # Deactivated constraint - m.b.c5 = Constraint(expr=m.b.v1 == 15 * units.s) - m.b.c5.deactivate() +@pytest.mark.unit +def test_vars_fixed_to_zero(model): + zero_vars = _vars_fixed_to_zero(model) + assert isinstance(zero_vars, ComponentSet) + assert len(zero_vars) == 1 + for i in zero_vars: + assert i is model.v1 - # An objective - m.b.o1 = Objective(expr=m.b.v2) - # A deactivated objective - m.b.o2 = Objective(expr=m.b.v2**2) - m.b.o2.deactivate() - # Create instance of Diagnostics Toolbox - dt = DiagnosticsToolbox(model=m.b) +@pytest.mark.unit +def test_vars_near_zero(model): + model.v3.set_value(1e-5) - dt.report_structural_issues() + near_zero_vars = _vars_near_zero(model, zero_tolerance=1e-5) + assert isinstance(near_zero_vars, ComponentSet) + assert len(near_zero_vars) == 2 + for i in near_zero_vars: + assert i.local_name in ["v1", "v3"] - m.b.v3.fix(1) + near_zero_vars = _vars_near_zero(model, zero_tolerance=1e-6) + assert isinstance(near_zero_vars, ComponentSet) + assert len(near_zero_vars) == 1 + for i in near_zero_vars: + assert i is model.v1 - dt.report_structural_issues() - dt.display_external_variables() +@pytest.mark.unit +def test_vars_with_none_value(model): + none_value = _vars_with_none_value(model) - # TODO: Current checks do not detect linearly dependent constraints - assert False + assert isinstance(none_value, ComponentSet) + assert len(none_value) == 1 + for i in none_value: + assert i is model.v4 -@pytest.mark.integration -def test_DiagnosticsToolbox2(): - m = ConcreteModel() +@pytest.mark.unit +def test_vars_with_bounds_issues(model): + model.v1.setlb(2) + model.v1.setub(6) + model.v2.setlb(0) + model.v2.setub(10) + model.v4.set_value(10) + model.v4.setlb(0) + model.v4.setub(1) + + bounds_issue = _vars_violating_bounds(model) + assert isinstance(bounds_issue, ComponentSet) + assert len(bounds_issue) == 2 + for i in bounds_issue: + assert i.local_name in ["v1", "v4"] - # A var outside the model - m.v = Var(initialize=10, units=units.s, bounds=(0, 1)) - # Model to be tested - m.b = Block() +@pytest.mark.unit +def test_write_report_section_all(): + stream = StringIO() + + _write_report_section( + stream=stream, + lines_list=["a", "b", "c"], + title="foo", + line_if_empty="bar", + end_line="baz", + header="-", + footer="=", + ) - m.b.v1 = Var(initialize=1, units=units.s) - m.b.v2 = Var(initialize=2, units=units.s) - m.b.v3 = Var(initialize=3, units=units.s, bounds=(10, 20)) - m.b.v4 = Var(units=units.s) - m.b.v5 = Var(initialize=1e-8, bounds=(0, 1)) + expected = """------------------------------------------------------------------------------------ +foo - # Equality constraints - m.b.c1 = Constraint(expr=2 * m.b.v1 == m.b.v2) # OK - m.b.c2 = Constraint(expr=m.b.v3 == m.v) # Not Converged + a + b + c - # Inequality constraints - m.b.c3 = Constraint(expr=m.b.v2 <= 10) # OK - m.b.c4 = Constraint(expr=m.b.v2 <= 0) # Not OK +baz +==================================================================================== +""" + assert stream.getvalue() == expected + + +@pytest.mark.unit +def test_write_report_section_no_lines(): + stream = StringIO() + + _write_report_section( + stream=stream, + lines_list=[], + title="foo", + line_if_empty="bar", + end_line="baz", + header="-", + footer="=", + ) + + expected = """------------------------------------------------------------------------------------ +foo - # Create instance of Diagnostics Toolbox - dt = DiagnosticsToolbox(model=m.b) + bar - # dt.report_structural_issues() +baz +==================================================================================== +""" + assert stream.getvalue() == expected + + +@pytest.mark.unit +def test_write_report_section_lines_only(): + stream = StringIO() - dt.report_numerical_issues() - dt.display_constraints_with_large_residuals() - dt.display_variables_near_bounds() + _write_report_section( + stream=stream, + lines_list=["a", "b", "c"], + ) - help(DiagnosticsToolbox) + expected = """------------------------------------------------------------------------------------ + a + b + c - # TODO: Current checks do not detect linearly dependent constraints - assert False +""" + assert stream.getvalue() == expected + + +class TestDiagnosticsToolbox: + @pytest.fixture + def model(self): + m = ConcreteModel() + + m.v1 = Var(units=units.m) + m.v2 = Var(units=units.m) + m.v3 = Var(bounds=(0, 5)) + m.v4 = Var() + m.v5 = Var(bounds=(0, 1)) + m.v6 = Var() + m.v7 = Var( + units=units.m, bounds=(0, 1) + ) # Poorly scaled variable with lower bound + m.v8 = Var() # unused variable + + m.c1 = Constraint(expr=m.v1 + m.v2 == 10) # Unit consistency issue + m.c2 = Constraint(expr=m.v3 == m.v4 + m.v5) + m.c3 = Constraint(expr=2 * m.v3 == 3 * m.v4 + 4 * m.v5 + m.v6) + m.c4 = Constraint(expr=m.v7 == 1e-8 * m.v1) # Poorly scaled constraint + + m.v4.fix(2) + m.v5.fix(2) + m.v6.fix(0) + + return m @pytest.fixture() From 5b371448a40d3da73a73e0142934e77618068561 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 10:15:14 -0400 Subject: [PATCH 15/48] Adding lots of tests --- idaes/core/util/model_diagnostics.py | 6 +- .../core/util/tests/test_model_diagnostics.py | 400 +++++++++++++++++- 2 files changed, 384 insertions(+), 22 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 694654fb2e..c3d5f48e8a 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -333,7 +333,7 @@ def display_components_with_inconsistent_units(self, stream=stdout): stream=stream, lines_list=identify_inconsistent_units(self.config.model), title="The following component(s) have unit consistency issues:", - end_line="For more details on cunit inconsistencies, import the " + end_line="For more details on unit inconsistencies, import the " "assert_units_consistent method\nfrom pyomo.util.check_units", header="=", footer="=", @@ -404,7 +404,7 @@ def display_underconstrained_set(self, stream=stdout): """ uc_vblocks, uc_cblocks, _, _ = self.get_dulmage_mendelsohn_partition() - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Under-Constrained Set\n\n") for i in range(len(uc_vblocks)): @@ -437,7 +437,7 @@ def display_overconstrained_set(self, stream=stdout): """ _, _, oc_vblocks, oc_cblocks = self.get_dulmage_mendelsohn_partition() - stream.write("\n" + "=" * MAX_STR_LENGTH + "\n") + stream.write("=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Over-Constrained Set\n\n") for i in range(len(oc_vblocks)): diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 7f7b724f99..1e4ded048b 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -14,6 +14,7 @@ This module contains model diagnostic utility functions for use in IDAES (Pyomo) models. """ from io import StringIO +import numpy as np import pytest from pyomo.environ import ( @@ -30,10 +31,10 @@ ) from pyomo.common.collections import ComponentSet from pyomo.contrib.pynumero.asl import AmplInterface -import numpy as np + import idaes.core.util.scaling as iscale import idaes.logger as idaeslog - +from idaes.core.solvers import get_solver from idaes.core import FlowsheetBlock from idaes.core.util.testing import PhysicalParameterTestBlock @@ -208,33 +209,394 @@ def test_write_report_section_lines_only(): assert stream.getvalue() == expected +@pytest.mark.solver class TestDiagnosticsToolbox: - @pytest.fixture + @pytest.fixture(scope="class") def model(self): m = ConcreteModel() - - m.v1 = Var(units=units.m) - m.v2 = Var(units=units.m) - m.v3 = Var(bounds=(0, 5)) - m.v4 = Var() - m.v5 = Var(bounds=(0, 1)) - m.v6 = Var() - m.v7 = Var( + m.b = Block() + + m.v1 = Var(units=units.m) # External variable + m.b.v2 = Var(units=units.m) + m.b.v3 = Var(bounds=(0, 5)) + m.b.v4 = Var() + m.b.v5 = Var(bounds=(0, 1)) + m.b.v6 = Var() + m.b.v7 = Var( units=units.m, bounds=(0, 1) ) # Poorly scaled variable with lower bound - m.v8 = Var() # unused variable + m.b.v8 = Var() # unused variable - m.c1 = Constraint(expr=m.v1 + m.v2 == 10) # Unit consistency issue - m.c2 = Constraint(expr=m.v3 == m.v4 + m.v5) - m.c3 = Constraint(expr=2 * m.v3 == 3 * m.v4 + 4 * m.v5 + m.v6) - m.c4 = Constraint(expr=m.v7 == 1e-8 * m.v1) # Poorly scaled constraint + m.b.c1 = Constraint(expr=m.v1 + m.b.v2 == 10) # Unit consistency issue + m.b.c2 = Constraint(expr=m.b.v3 == m.b.v4 + m.b.v5) + m.b.c3 = Constraint(expr=2 * m.b.v3 == 3 * m.b.v4 + 4 * m.b.v5 + m.b.v6) + m.b.c4 = Constraint(expr=m.b.v7 == 1e-8 * m.v1) # Poorly scaled constraint - m.v4.fix(2) - m.v5.fix(2) - m.v6.fix(0) + m.b.v2.fix(5) + m.b.v5.fix(2) + m.b.v6.fix(0) + + solver = get_solver() + solver.solve(m) return m + @pytest.mark.component + def test_display_external_variables(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_external_variables(stream) + + expected = """==================================================================================== +The following external variable(s) appear in constraints within the model: + + v1 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_unused_variables(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_unused_variables(stream) + + expected = """==================================================================================== +The following variable(s) do not appear in any activated constraints within the model: + + b.v8 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_variables_fixed_to_zero(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_variables_fixed_to_zero(stream) + + expected = """==================================================================================== +The following variable(s) are fixed to zero: + + b.v6 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_variables_at_or_outside_bounds(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_variables_at_or_outside_bounds(stream) + + expected = """==================================================================================== +The following variable(s) have values at or outside their bounds: + + b.v3 (free): value=0.0 bounds=(0, 5) + b.v5 (fixed): value=2 bounds=(0, 1) + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_variables_with_none_value(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_variables_with_none_value(stream) + + expected = """==================================================================================== +The following variable(s) have a value of None: + + b.v8 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_variables_with_value_near_zero(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_variables_with_value_near_zero(stream) + + expected = """==================================================================================== +The following variable(s) have a value close to zero: + + b.v3: value=0.0 + b.v6: value=0 + b.v7: value=5.002439135661953e-08 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_poorly_scaled_variables(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_poorly_scaled_variables(stream) + + expected = """==================================================================================== +The following variable(s) are poorly scaled: + + b.v7: 5.002439135661953e-08 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_variables_near_bounds(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_variables_near_bounds(stream) + + expected = """==================================================================================== +The following variable(s) have values close to their bounds: + + b.v3: value=0.0 bounds=(0, 5) + b.v5: value=2 bounds=(0, 1) + b.v7: value=5.002439135661953e-08 bounds=(0, 1) + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_components_with_inconsistent_units(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_components_with_inconsistent_units(stream) + + expected = """==================================================================================== +The following component(s) have unit consistency issues: + + b.c1 + +For more details on unit inconsistencies, import the assert_units_consistent method +from pyomo.util.check_units +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_constraints_with_large_residuals(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.display_constraints_with_large_residuals(stream) + + expected = """==================================================================================== +The following constraint(s) have large residuals: + + b.c2 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_get_dulmage_mendelsohn_partition(self, model): + # Clone model so we can add some singularities + m = model.clone() + + # Create structural singularities + m.b.v2.unfix() + m.b.v4.fix(2) + + # Add a second set of structural singularities + m.b.b2 = Block() + m.b.b2.v1 = Var() + m.b.b2.v2 = Var() + m.b.b2.v3 = Var() + m.b.b2.v4 = Var() + + m.b.b2.c1 = Constraint(expr=m.b.b2.v1 == m.b.b2.v2) + m.b.b2.c2 = Constraint(expr=2 * m.b.b2.v1 == 3 * m.b.b2.v2) + m.b.b2.c3 = Constraint(expr=m.b.b2.v3 == m.b.b2.v4) + + m.b.b2.v2.fix(42) + + dt = DiagnosticsToolbox(model=m.b) + + ( + uc_vblocks, + uc_cblocks, + oc_vblocks, + oc_cblocks, + ) = dt.get_dulmage_mendelsohn_partition() + + assert len(uc_vblocks) == 2 + assert len(uc_vblocks[0]) == 3 + for i in uc_vblocks[0]: + assert i.name in ["v1", "b.v2", "b.v7"] + assert len(uc_vblocks[1]) == 2 + for i in uc_vblocks[1]: + assert i.name in ["b.b2.v3", "b.b2.v4"] + + assert len(uc_cblocks) == 2 + assert len(uc_cblocks[0]) == 2 + for i in uc_cblocks[0]: + assert i.name in ["b.c1", "b.c4"] + assert len(uc_cblocks[1]) == 1 + for i in uc_cblocks[1]: + assert i.name in ["b.b2.c3"] + + assert len(oc_vblocks) == 2 + assert len(oc_vblocks[0]) == 1 + for i in oc_vblocks[0]: + assert i.name in ["b.v3"] + assert len(oc_vblocks[1]) == 1 + for i in oc_vblocks[1]: + assert i.name in ["b.b2.v1"] + + assert len(oc_cblocks) == 2 + assert len(oc_cblocks[0]) == 2 + for i in oc_cblocks[0]: + assert i.name in ["b.c2", "b.c3"] + assert len(oc_cblocks[1]) == 2 + for i in oc_cblocks[1]: + assert i.name in ["b.b2.c1", "b.b2.c2"] + + @pytest.mark.component + def test_display_underconstrained_set(self, model): + # Clone model so we can add some singularities + m = model.clone() + + # Create structural singularities + m.b.v2.unfix() + m.b.v4.fix(2) + + # Add a second set of structural singularities + m.b.b2 = Block() + m.b.b2.v1 = Var() + m.b.b2.v2 = Var() + m.b.b2.v3 = Var() + m.b.b2.v4 = Var() + + m.b.b2.c1 = Constraint(expr=m.b.b2.v1 == m.b.b2.v2) + m.b.b2.c2 = Constraint(expr=2 * m.b.b2.v1 == 3 * m.b.b2.v2) + m.b.b2.c3 = Constraint(expr=m.b.b2.v3 == m.b.b2.v4) + + m.b.b2.v2.fix(42) + + dt = DiagnosticsToolbox(model=m.b) + + stream = StringIO() + dt.display_underconstrained_set(stream) + + expected = """==================================================================================== +Dulmage-Mendelsohn Under-Constrained Set + + Independent Block 0: + + Variables: + + b.v2 + v1 + b.v7 + + Constraints: + + b.c1 + b.c4 + + Independent Block 1: + + Variables: + + b.b2.v4 + b.b2.v3 + + Constraints: + + b.b2.c3 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_overconstrained_set(self, model): + # Clone model so we can add some singularities + m = model.clone() + + # Create structural singularities + m.b.v2.unfix() + m.b.v4.fix(2) + + # Add a second set of structural singularities + m.b.b2 = Block() + m.b.b2.v1 = Var() + m.b.b2.v2 = Var() + m.b.b2.v3 = Var() + m.b.b2.v4 = Var() + + m.b.b2.c1 = Constraint(expr=m.b.b2.v1 == m.b.b2.v2) + m.b.b2.c2 = Constraint(expr=2 * m.b.b2.v1 == 3 * m.b.b2.v2) + m.b.b2.c3 = Constraint(expr=m.b.b2.v3 == m.b.b2.v4) + + m.b.b2.v2.fix(42) + + dt = DiagnosticsToolbox(model=m.b) + + stream = StringIO() + dt.display_overconstrained_set(stream) + + expected = """==================================================================================== +Dulmage-Mendelsohn Over-Constrained Set + + Independent Block 0: + + Variables: + + b.v3 + + Constraints: + + b.c2 + b.c3 + + Independent Block 1: + + Variables: + + b.b2.v1 + + Constraints: + + b.b2.c1 + b.b2.c2 + +==================================================================================== +""" + + assert stream.getvalue() == expected + @pytest.fixture() def dummy_problem(): From 3ebf494011b90992e82487ceeb78e811b512f516 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 10:34:23 -0400 Subject: [PATCH 16/48] Tests for collect structural warnings --- idaes/core/util/model_diagnostics.py | 2 +- .../core/util/tests/test_model_diagnostics.py | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index c3d5f48e8a..999f709dbd 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -474,7 +474,7 @@ def _collect_structural_warnings(self): dof = degrees_of_freedom(self.config.model) if dof != 0: dstring = "Degrees" - if dof == abs(1): + if abs(dof) == 1: dstring = "Degree" warnings.append(f"WARNING: {dof} {dstring} of Freedom") if len(uc) > 0: diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 1e4ded048b..3ce25e6d7e 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -597,6 +597,71 @@ def test_display_overconstrained_set(self, model): assert stream.getvalue() == expected + @pytest.mark.component + def test_collect_structural_warnings_base_case(self, model): + dt = DiagnosticsToolbox(model=model.b) + + warnings, next_steps = dt._collect_structural_warnings() + + assert len(warnings) == 1 + assert warnings == ["WARNING: 1 Component with inconsistent units"] + assert len(next_steps) == 1 + assert next_steps == ["display_components_with_inconsistent_units()"] + + @pytest.mark.component + def test_collect_structural_warnings_underconstrained(self, model): + # Clone model so we can add some singularities + m = model.clone() + + # Create structural singularities + m.b.v2.unfix() + + dt = DiagnosticsToolbox(model=m.b) + + warnings, next_steps = dt._collect_structural_warnings() + + assert len(warnings) == 3 + assert "WARNING: 1 Component with inconsistent units" in warnings + assert "WARNING: 1 Degree of Freedom" in warnings + assert ( + """WARNING: Structural singularity found + Under-Constrained Set: 1 variables, 1 constraints + Over-Constrained Set: 0 variables, 0 constraints""" + in warnings + ) + + assert len(next_steps) == 2 + assert "display_components_with_inconsistent_units()" in next_steps + assert "display_underconstrained_set()" in next_steps + + @pytest.mark.component + def test_collect_structural_warnings_overconstrained(self, model): + # Clone model so we can add some singularities + m = model.clone() + + # Fix units + m.b.del_component(m.b.c1) + m.b.c1 = Constraint(expr=m.v1 + m.b.v2 == 10 * units.m) + + # Create structural singularities + m.b.v4.fix(2) + + dt = DiagnosticsToolbox(model=m.b) + + warnings, next_steps = dt._collect_structural_warnings() + + assert len(warnings) == 2 + assert "WARNING: -1 Degree of Freedom" in warnings + assert ( + """WARNING: Structural singularity found + Under-Constrained Set: 0 variables, 0 constraints + Over-Constrained Set: 1 variables, 1 constraints""" + in warnings + ) + + assert len(next_steps) == 1 + assert "display_overconstrained_set()" in next_steps + @pytest.fixture() def dummy_problem(): From faa3e26b8035737b72fbc2f6dcac4628df922996 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 10:49:00 -0400 Subject: [PATCH 17/48] More tests for collection methods --- idaes/core/util/model_diagnostics.py | 2 +- .../core/util/tests/test_model_diagnostics.py | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 999f709dbd..ad58d6d342 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -563,7 +563,7 @@ def _collect_numerical_warnings(self): if len(violated_bounds) == 1: cstring = "Variable" warnings.append( - f"WARNING: {len(violated_bounds)} {cstring} with bounds violations" + f"WARNING: {len(violated_bounds)} {cstring} at or outside bounds" ) next_steps.append("display_variables_at_or_outside_bounds()") diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 3ce25e6d7e..8f526c24af 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -26,6 +26,8 @@ Objective, Set, SolverFactory, + Suffix, + TransformationFactory, units, Var, ) @@ -662,6 +664,69 @@ def test_collect_structural_warnings_overconstrained(self, model): assert len(next_steps) == 1 assert "display_overconstrained_set()" in next_steps + @pytest.mark.component + def test_collect_structural_cautions(self, model): + dt = DiagnosticsToolbox(model=model.b) + + cautions = dt._collect_structural_cautions() + + assert len(cautions) == 2 + assert "Caution: 1 variable fixed to 0" in cautions + assert "Caution: 1 unused variable (0 fixed)" in cautions + + @pytest.mark.component + def test_collect_numerical_warnings(self, model): + dt = DiagnosticsToolbox(model=model.b) + + warnings, next_steps = dt._collect_numerical_warnings() + + assert len(warnings) == 3 + assert "WARNING: 1 Constraint with large residuals" in warnings + assert "WARNING: 2 Variables at or outside bounds" in warnings + assert "WARNING: 1 Variable with poor scaling" in warnings + + assert len(next_steps) == 3 + assert "display_constraints_with_large_residuals()" in next_steps + assert "display_variables_at_or_outside_bounds()" in next_steps + assert "display_poorly_scaled_variables()" in next_steps + + @pytest.mark.component + def test_collect_numerical_warnings_corrected(self, model): + m = model.clone() + + # Fix numerical issues + m.b.v3.setlb(-5) + m.b.v5.setub(10) + + m.scaling_factor = Suffix(direction=Suffix.EXPORT) + m.scaling_factor[m.b.v7] = 1e8 + m.scaling_factor[m.b.c4] = 1e-8 + + scaling = TransformationFactory("core.scale_model") + scaled_model = scaling.create_using(m, rename=False) + + solver = get_solver() + solver.solve(scaled_model) + + dt = DiagnosticsToolbox(model=scaled_model.b) + + warnings, next_steps = dt._collect_numerical_warnings() + + assert len(warnings) == 0 + + assert len(next_steps) == 0 + + @pytest.mark.component + def test_collect_numerical_cautions(self, model): + dt = DiagnosticsToolbox(model=model.b) + + cautions = dt._collect_numerical_cautions() + + assert len(cautions) == 3 + assert "Caution: 3 Variables with value close to their bounds" in cautions + assert "Caution: 3 Variables with value close to zero" in cautions + assert "Caution: 1 Variable with None value" in cautions + @pytest.fixture() def dummy_problem(): From 778f4141312fc4a83fa12e6f2a2666320dc155fa Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 11:03:44 -0400 Subject: [PATCH 18/48] Tests for reprot methods --- idaes/core/util/model_diagnostics.py | 135 +++++++++--------- .../core/util/tests/test_model_diagnostics.py | 112 +++++++++++++++ 2 files changed, 182 insertions(+), 65 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index ad58d6d342..9e1f6a1859 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -662,71 +662,7 @@ def report_structural_issues(self, stream=stdout): """ # Potential evaluation errors # TODO: High Index? - vars_in_constraints = variables_in_activated_constraints_set(self.config.model) - fixed_vars_in_constraints = ComponentSet() - free_vars_in_constraints = ComponentSet() - free_vars_lb = ComponentSet() - free_vars_ub = ComponentSet() - free_vars_lbub = ComponentSet() - ext_fixed_vars_in_constraints = ComponentSet() - ext_free_vars_in_constraints = ComponentSet() - for v in vars_in_constraints: - if v.fixed: - fixed_vars_in_constraints.add(v) - if not _var_in_block(v, self.config.model): - ext_fixed_vars_in_constraints.add(v) - else: - free_vars_in_constraints.add(v) - if not _var_in_block(v, self.config.model): - ext_free_vars_in_constraints.add(v) - if v.lb is not None: - if v.ub is not None: - free_vars_lbub.add(v) - else: - free_vars_lb.add(v) - elif v.ub is not None: - free_vars_ub.add(v) - - # Generate report - # TODO: Binary and boolean vars - stats = [] - stats.append( - f"{TAB}Activated Blocks: {len(activated_blocks_set(self.config.model))} " - f"(Deactivated: {len(deactivated_blocks_set(self.config.model))})" - ) - stats.append( - f"{TAB}Free Variables in Activated Constraints: " - f"{len(free_vars_in_constraints)} " - f"(External: {len(ext_free_vars_in_constraints)})" - ) - stats.append( - f"{TAB*2}Free Variables with only lower bounds: {len(free_vars_lb)} " - ) - stats.append( - f"{TAB * 2}Free Variables with only upper bounds: {len(free_vars_ub)} " - ) - stats.append( - f"{TAB * 2}Free Variables with upper and lower bounds: " - f"{len(free_vars_lbub)} " - ) - stats.append( - f"{TAB}Fixed Variables in Activated Constraints: " - f"{len(fixed_vars_in_constraints)} " - f"(External: {len(ext_fixed_vars_in_constraints)})" - ) - stats.append( - f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(self.config.model))} " - f"(Deactivated: {len(deactivated_equalities_set(self.config.model))})" - ) - stats.append( - f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(self.config.model))} " - f"(Deactivated: {len(deactivated_inequalities_set(self.config.model))})" - ) - stats.append( - f"{TAB}Activated Objectives: {len(activated_objectives_set(self.config.model))} " - f"(Deactivated: {len(deactivated_objectives_set(self.config.model))})" - ) - + stats = _collect_model_statistics(self.config.model) warnings, next_steps = self._collect_structural_warnings() cautions = self._collect_structural_cautions() @@ -1698,3 +1634,72 @@ def _write_report_section( stream.write(f"{end_line}\n") if footer is not None: stream.write(f"{footer * MAX_STR_LENGTH}\n") + + +def _collect_model_statistics(model): + vars_in_constraints = variables_in_activated_constraints_set(model) + fixed_vars_in_constraints = ComponentSet() + free_vars_in_constraints = ComponentSet() + free_vars_lb = ComponentSet() + free_vars_ub = ComponentSet() + free_vars_lbub = ComponentSet() + ext_fixed_vars_in_constraints = ComponentSet() + ext_free_vars_in_constraints = ComponentSet() + for v in vars_in_constraints: + if v.fixed: + fixed_vars_in_constraints.add(v) + if not _var_in_block(v, model): + ext_fixed_vars_in_constraints.add(v) + else: + free_vars_in_constraints.add(v) + if not _var_in_block(v, model): + ext_free_vars_in_constraints.add(v) + if v.lb is not None: + if v.ub is not None: + free_vars_lbub.add(v) + else: + free_vars_lb.add(v) + elif v.ub is not None: + free_vars_ub.add(v) + + # Generate report + # TODO: Binary and boolean vars + stats = [] + stats.append( + f"{TAB}Activated Blocks: {len(activated_blocks_set(model))} " + f"(Deactivated: {len(deactivated_blocks_set(model))})" + ) + stats.append( + f"{TAB}Free Variables in Activated Constraints: " + f"{len(free_vars_in_constraints)} " + f"(External: {len(ext_free_vars_in_constraints)})" + ) + stats.append( + f"{TAB * 2}Free Variables with only lower bounds: {len(free_vars_lb)} " + ) + stats.append( + f"{TAB * 2}Free Variables with only upper bounds: {len(free_vars_ub)} " + ) + stats.append( + f"{TAB * 2}Free Variables with upper and lower bounds: " + f"{len(free_vars_lbub)} " + ) + stats.append( + f"{TAB}Fixed Variables in Activated Constraints: " + f"{len(fixed_vars_in_constraints)} " + f"(External: {len(ext_fixed_vars_in_constraints)})" + ) + stats.append( + f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(model))} " + f"(Deactivated: {len(deactivated_equalities_set(model))})" + ) + stats.append( + f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(model))} " + f"(Deactivated: {len(deactivated_inequalities_set(model))})" + ) + stats.append( + f"{TAB}Activated Objectives: {len(activated_objectives_set(model))} " + f"(Deactivated: {len(deactivated_objectives_set(model))})" + ) + + return stats diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 8f526c24af..e4d1033b97 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -727,6 +727,118 @@ def test_collect_numerical_cautions(self, model): assert "Caution: 3 Variables with value close to zero" in cautions assert "Caution: 1 Variable with None value" in cautions + @pytest.mark.component + def test_assert_no_structural_warnings(self, model): + m = model.clone() + dt = DiagnosticsToolbox(model=m.b) + + with pytest.raises(AssertionError, match="Structural issues found \(1\)."): + dt.assert_no_structural_warnings() + + # Fix units issue + m.b.del_component(m.b.c1) + m.b.c1 = Constraint(expr=m.v1 + m.b.v2 == 10 * units.m) + dt.assert_no_structural_warnings() + + @pytest.mark.component + def test_assert_no_numerical_warnings(self, model): + m = model.clone() + dt = DiagnosticsToolbox(model=m.b) + + with pytest.raises(AssertionError, match="Numerical issues found \(3\)."): + dt.assert_no_numerical_warnings() + + # Fix numerical issues + m.b.v3.setlb(-5) + m.b.v5.setub(10) + + m.scaling_factor = Suffix(direction=Suffix.EXPORT) + m.scaling_factor[m.b.v7] = 1e8 + m.scaling_factor[m.b.c4] = 1e-8 + + scaling = TransformationFactory("core.scale_model") + scaled_model = scaling.create_using(m, rename=False) + + solver = get_solver() + solver.solve(scaled_model) + + dt = DiagnosticsToolbox(model=scaled_model.b) + dt.assert_no_numerical_warnings() + + @pytest.mark.component + def test_report_structural_issues(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.report_structural_issues(stream) + + expected = """==================================================================================== +Model Statistics + + Activated Blocks: 1 (Deactivated: 0) + Free Variables in Activated Constraints: 4 (External: 1) + Free Variables with only lower bounds: 0 + Free Variables with only upper bounds: 0 + Free Variables with upper and lower bounds: 2 + Fixed Variables in Activated Constraints: 3 (External: 0) + Activated Equality Constraints: 4 (Deactivated: 0) + Activated Inequality Constraints: 0 (Deactivated: 0) + Activated Objectives: 0 (Deactivated: 0) + +------------------------------------------------------------------------------------ +1 WARNINGS + + WARNING: 1 Component with inconsistent units + +------------------------------------------------------------------------------------ +2 Cautions + + Caution: 1 variable fixed to 0 + Caution: 1 unused variable (0 fixed) + +------------------------------------------------------------------------------------ +Suggested next steps: + + display_components_with_inconsistent_units() + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_report_numerical_issues(self, model): + dt = DiagnosticsToolbox(model=model.b) + + stream = StringIO() + dt.report_numerical_issues(stream) + + expected = """==================================================================================== +3 WARNINGS + + WARNING: 1 Constraint with large residuals + WARNING: 2 Variables at or outside bounds + WARNING: 1 Variable with poor scaling + +------------------------------------------------------------------------------------ +3 Cautions + + Caution: 3 Variables with value close to their bounds + Caution: 3 Variables with value close to zero + Caution: 1 Variable with None value + +------------------------------------------------------------------------------------ +Suggested next steps: + + display_constraints_with_large_residuals() + display_variables_at_or_outside_bounds() + display_poorly_scaled_variables() + +==================================================================================== +""" + + assert stream.getvalue() == expected + @pytest.fixture() def dummy_problem(): From 5d3425530b4f2e9c5fa763a000c20f40efb089f5 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 11:21:01 -0400 Subject: [PATCH 19/48] Trying to fix Sphinx issues --- idaes/core/util/model_diagnostics.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 9e1f6a1859..74fe9bd0bf 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -102,13 +102,13 @@ class DiagnosticsToolbox: To get started: - 1. Create an instance of your model - this does not need to be initialized yet. - 2. Fix variables until you have 0 degrees of freedom - many of these tools presume + #. Create an instance of your model - this does not need to be initialized yet. + #. Fix variables until you have 0 degrees of freedom - many of these tools presume a square model, and a square model should always be the foundation of any more advanced model. - 3. Create an instance of the DiagnosticsToolbox and provide the model to debug as + #. Create an instance of the DiagnosticsToolbox and provide the model to debug as the model argument. - 4. Call the report_structural_issues() method. + #. Call the report_structural_issues() method. Model diagnostics is an iterative process and you will likely need to run these tools multiple times to resolve all issues. After making a change to your model, @@ -122,15 +122,15 @@ class DiagnosticsToolbox: Report methods will print a summary containing three parts: - 1. Warnings - these are critical issues that should be resolved before continuing. + #. Warnings - these are critical issues that should be resolved before continuing. For each warning, a method will be suggested in the Next Steps section to get additional information. - 2. Cautions - these are things that could be correct but could also be the source of + #. Cautions - these are things that could be correct but could also be the source of solver issues. Not all cautions need to be addressed, but users should investigate each one to ensure that the behavior is correct and that they will not be the source of difficulties later. Methods exist to provide more information on all cautions, but these will not appear in the Next Steps section. - 3. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to + #. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to get further information on warnings. If no warnings are found, this will suggest the next report method to call. From f6b4cba756cd422c30ea6131923cd3b006db77b4 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 11:35:37 -0400 Subject: [PATCH 20/48] Still fighting Sphinx --- idaes/core/util/model_diagnostics.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 74fe9bd0bf..dd8123049b 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -102,13 +102,16 @@ class DiagnosticsToolbox: To get started: - #. Create an instance of your model - this does not need to be initialized yet. - #. Fix variables until you have 0 degrees of freedom - many of these tools presume + 1. Create an instance of your model - this does not need to be initialized yet. + + 2. Fix variables until you have 0 degrees of freedom - many of these tools presume a square model, and a square model should always be the foundation of any more advanced model. - #. Create an instance of the DiagnosticsToolbox and provide the model to debug as + + 3. Create an instance of the DiagnosticsToolbox and provide the model to debug as the model argument. - #. Call the report_structural_issues() method. + + 4. Call the report_structural_issues() method. Model diagnostics is an iterative process and you will likely need to run these tools multiple times to resolve all issues. After making a change to your model, @@ -122,15 +125,17 @@ class DiagnosticsToolbox: Report methods will print a summary containing three parts: - #. Warnings - these are critical issues that should be resolved before continuing. + 1. Warnings - these are critical issues that should be resolved before continuing. For each warning, a method will be suggested in the Next Steps section to get additional information. - #. Cautions - these are things that could be correct but could also be the source of + + 2. Cautions - these are things that could be correct but could also be the source of solver issues. Not all cautions need to be addressed, but users should investigate each one to ensure that the behavior is correct and that they will not be the source of difficulties later. Methods exist to provide more information on all cautions, but these will not appear in the Next Steps section. - #. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to + + 3. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to get further information on warnings. If no warnings are found, this will suggest the next report method to call. @@ -407,10 +412,10 @@ def display_underconstrained_set(self, stream=stdout): stream.write("=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Under-Constrained Set\n\n") - for i in range(len(uc_vblocks)): + for i, uc_vblock in enumerate(uc_vblocks): stream.write(f"{TAB}Independent Block {i}:\n\n") stream.write(f"{2*TAB}Variables:\n\n") - for v in uc_vblocks[i]: + for v in uc_vblock: stream.write(f"{3*TAB}{v.name}\n") stream.write(f"\n{2*TAB}Constraints:\n\n") @@ -440,10 +445,10 @@ def display_overconstrained_set(self, stream=stdout): stream.write("=" * MAX_STR_LENGTH + "\n") stream.write("Dulmage-Mendelsohn Over-Constrained Set\n\n") - for i in range(len(oc_vblocks)): + for i, oc_vblock in enumerate(oc_vblocks): stream.write(f"{TAB}Independent Block {i}:\n\n") stream.write(f"{2*TAB}Variables:\n\n") - for v in oc_vblocks[i]: + for v in oc_vblock: stream.write(f"{3*TAB}{v.name}\n") stream.write(f"\n{2*TAB}Constraints:\n\n") From 0ab0c44614a4d97425bffa4e4e52c23e822f4667 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 11:57:37 -0400 Subject: [PATCH 21/48] REsolving Sphinx and pylint issues --- idaes/core/util/model_diagnostics.py | 32 +++++++++++----------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index dd8123049b..fdb7602dbe 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -31,7 +31,6 @@ check_optimal_termination, ConcreteModel, Constraint, - Expression, Objective, Param, Set, @@ -102,15 +101,12 @@ class DiagnosticsToolbox: To get started: - 1. Create an instance of your model - this does not need to be initialized yet. - - 2. Fix variables until you have 0 degrees of freedom - many of these tools presume - a square model, and a square model should always be the foundation of any more - advanced model. - + 1. Create an instance of your model (this does not need to be initialized yet). + 2. Fix variables until you have 0 degrees of freedom. Many of these tools presume + a square model, and a square model should always be the foundation of any more + advanced model. 3. Create an instance of the DiagnosticsToolbox and provide the model to debug as - the model argument. - + the model argument. 4. Call the report_structural_issues() method. Model diagnostics is an iterative process and you will likely need to run these @@ -126,18 +122,16 @@ class DiagnosticsToolbox: Report methods will print a summary containing three parts: 1. Warnings - these are critical issues that should be resolved before continuing. - For each warning, a method will be suggested in the Next Steps section to get - additional information. - + For each warning, a method will be suggested in the Next Steps section to get + additional information. 2. Cautions - these are things that could be correct but could also be the source of - solver issues. Not all cautions need to be addressed, but users should investigate - each one to ensure that the behavior is correct and that they will not be the source - of difficulties later. Methods exist to provide more information on all cautions, - but these will not appear in the Next Steps section. - + solver issues. Not all cautions need to be addressed, but users should investigate + each one to ensure that the behavior is correct and that they will not be the source + of difficulties later. Methods exist to provide more information on all cautions, + but these will not appear in the Next Steps section. 3. Next Steps - these are recommended methods to call from the DiagnosticsToolbox to - get further information on warnings. If no warnings are found, this will suggest - the next report method to call. + get further information on warnings. If no warnings are found, this will suggest + the next report method to call. """ From e9fab7a9bf83d3a360daab31170089ea0901e9a4 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 13:37:12 -0400 Subject: [PATCH 22/48] Fixing external var test --- idaes/core/util/tests/test_model_diagnostics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index e4d1033b97..2dcfa037e7 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -84,8 +84,8 @@ def model(): @pytest.mark.unit def test_var_in_block(model): - assert _var_in_block(model.v, model) - assert not _var_in_block(model.v, model.b) + assert _var_in_block(model.v1, model) + assert not _var_in_block(model.v1, model.b) @pytest.mark.unit From f92ed17b275f4296700c17644a73d7d28d7fac98 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 14:19:53 -0400 Subject: [PATCH 23/48] testing for stats writer --- idaes/core/util/model_diagnostics.py | 10 +- .../core/util/tests/test_model_diagnostics.py | 168 ++++++++++++++++++ 2 files changed, 171 insertions(+), 7 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index fdb7602dbe..8cb5c3811e 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -1673,15 +1673,11 @@ def _collect_model_statistics(model): f"{len(free_vars_in_constraints)} " f"(External: {len(ext_free_vars_in_constraints)})" ) - stats.append( - f"{TAB * 2}Free Variables with only lower bounds: {len(free_vars_lb)} " - ) - stats.append( - f"{TAB * 2}Free Variables with only upper bounds: {len(free_vars_ub)} " - ) + stats.append(f"{TAB * 2}Free Variables with only lower bounds: {len(free_vars_lb)}") + stats.append(f"{TAB * 2}Free Variables with only upper bounds: {len(free_vars_ub)}") stats.append( f"{TAB * 2}Free Variables with upper and lower bounds: " - f"{len(free_vars_lbub)} " + f"{len(free_vars_lbub)}" ) stats.append( f"{TAB}Fixed Variables in Activated Constraints: " diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 2dcfa037e7..36d6118c17 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -60,6 +60,7 @@ _vars_violating_bounds, _vars_with_none_value, _write_report_section, + _collect_model_statistics, ) __author__ = "Alex Dowling, Douglas Allan, Andrew Lee" @@ -211,6 +212,173 @@ def test_write_report_section_lines_only(): assert stream.getvalue() == expected +@pytest.mark.unit +class TestStatsWriter: + def test_blocks(self): + m = ConcreteModel() + m.b1 = Block() + m.b1.b2 = Block() + m.b3 = Block() + m.b3.b4 = Block() + m.b5 = Block() + m.b5.b6 = Block() + + m.b3.b4.deactivate() + m.b5.deactivate() + + stats = _collect_model_statistics(m) + + assert len(stats) == 9 + assert stats[0] == " Activated Blocks: 4 (Deactivated: 3)" + assert ( + stats[1] == " Free Variables in Activated Constraints: 0 (External: 0)" + ) + assert stats[2] == " Free Variables with only lower bounds: 0" + assert stats[3] == " Free Variables with only upper bounds: 0" + assert stats[4] == " Free Variables with upper and lower bounds: 0" + assert ( + stats[5] == " Fixed Variables in Activated Constraints: 0 (External: 0)" + ) + assert stats[6] == " Activated Equality Constraints: 0 (Deactivated: 0)" + assert stats[7] == " Activated Inequality Constraints: 0 (Deactivated: 0)" + assert stats[8] == " Activated Objectives: 0 (Deactivated: 0)" + + def test_constraints(self): + m = ConcreteModel() + m.b1 = Block() + m.b2 = Block() + + m.b1.v1 = Var() + m.b1.v2 = Var() + m.b1.v3 = Var() + m.b1.v4 = Var() + + m.b1.c1 = Constraint(expr=m.b1.v1 == m.b1.v2) + m.b1.c2 = Constraint(expr=m.b1.v1 >= m.b1.v2) + m.b1.c3 = Constraint(expr=m.b1.v1 == m.b1.v2) + m.b1.c4 = Constraint(expr=m.b1.v1 >= m.b1.v2) + + m.b2.c1 = Constraint(expr=m.b1.v1 == m.b1.v2) + m.b2.c2 = Constraint(expr=m.b1.v1 >= m.b1.v2) + + m.b2.deactivate() + m.b1.c3.deactivate() + m.b1.c4.deactivate() + + stats = _collect_model_statistics(m) + + assert len(stats) == 9 + assert stats[0] == " Activated Blocks: 2 (Deactivated: 1)" + assert ( + stats[1] == " Free Variables in Activated Constraints: 2 (External: 0)" + ) + assert stats[2] == " Free Variables with only lower bounds: 0" + assert stats[3] == " Free Variables with only upper bounds: 0" + assert stats[4] == " Free Variables with upper and lower bounds: 0" + assert ( + stats[5] == " Fixed Variables in Activated Constraints: 0 (External: 0)" + ) + assert stats[6] == " Activated Equality Constraints: 1 (Deactivated: 1)" + assert stats[7] == " Activated Inequality Constraints: 1 (Deactivated: 1)" + assert stats[8] == " Activated Objectives: 0 (Deactivated: 0)" + + def test_objectives(self): + m = ConcreteModel() + m.b1 = Block() + m.b2 = Block() + + m.b1.o1 = Objective(expr=1) + m.b1.o2 = Objective(expr=1) + + m.b2.o1 = Objective(expr=1) + + m.b2.deactivate() + m.b1.o2.deactivate() + + stats = _collect_model_statistics(m) + + assert len(stats) == 9 + assert stats[0] == " Activated Blocks: 2 (Deactivated: 1)" + assert ( + stats[1] == " Free Variables in Activated Constraints: 0 (External: 0)" + ) + assert stats[2] == " Free Variables with only lower bounds: 0" + assert stats[3] == " Free Variables with only upper bounds: 0" + assert stats[4] == " Free Variables with upper and lower bounds: 0" + assert ( + stats[5] == " Fixed Variables in Activated Constraints: 0 (External: 0)" + ) + assert stats[6] == " Activated Equality Constraints: 0 (Deactivated: 0)" + assert stats[7] == " Activated Inequality Constraints: 0 (Deactivated: 0)" + assert stats[8] == " Activated Objectives: 1 (Deactivated: 1)" + + def test_free_variables(self): + m = ConcreteModel() + m.b1 = Block() + + m.v1 = Var(bounds=(0, 1)) + + m.b1.v2 = Var(bounds=(0, None)) + m.b1.v3 = Var(bounds=(None, 0)) + m.b1.v4 = Var(bounds=(0, 1)) + m.b1.v5 = Var() + m.b1.v6 = Var(bounds=(0, 1)) + + m.b1.c1 = Constraint(expr=0 == m.v1 + m.b1.v2 + m.b1.v3 + m.b1.v4 + m.b1.v5) + + stats = _collect_model_statistics(m.b1) + + assert len(stats) == 9 + assert stats[0] == " Activated Blocks: 1 (Deactivated: 0)" + assert ( + stats[1] == " Free Variables in Activated Constraints: 5 (External: 1)" + ) + assert stats[2] == " Free Variables with only lower bounds: 1" + assert stats[3] == " Free Variables with only upper bounds: 1" + assert stats[4] == " Free Variables with upper and lower bounds: 2" + assert ( + stats[5] == " Fixed Variables in Activated Constraints: 0 (External: 0)" + ) + assert stats[6] == " Activated Equality Constraints: 1 (Deactivated: 0)" + assert stats[7] == " Activated Inequality Constraints: 0 (Deactivated: 0)" + assert stats[8] == " Activated Objectives: 0 (Deactivated: 0)" + + def test_fixed_variables(self): + m = ConcreteModel() + m.b1 = Block() + + m.v1 = Var(bounds=(0, 1)) + + m.b1.v2 = Var(bounds=(0, None)) + m.b1.v3 = Var(bounds=(None, 0)) + m.b1.v4 = Var(bounds=(0, 1)) + m.b1.v5 = Var() + m.b1.v6 = Var(bounds=(0, 1)) + + m.b1.c1 = Constraint(expr=0 == m.v1 + m.b1.v2 + m.b1.v3 + m.b1.v4 + m.b1.v5) + + m.v1.fix(0.5) + m.b1.v2.fix(-0.5) + m.b1.v5.fix(-0.5) + + stats = _collect_model_statistics(m.b1) + + assert len(stats) == 9 + assert stats[0] == " Activated Blocks: 1 (Deactivated: 0)" + assert ( + stats[1] == " Free Variables in Activated Constraints: 2 (External: 0)" + ) + assert stats[2] == " Free Variables with only lower bounds: 0" + assert stats[3] == " Free Variables with only upper bounds: 1" + assert stats[4] == " Free Variables with upper and lower bounds: 1" + assert ( + stats[5] == " Fixed Variables in Activated Constraints: 3 (External: 1)" + ) + assert stats[6] == " Activated Equality Constraints: 1 (Deactivated: 0)" + assert stats[7] == " Activated Inequality Constraints: 0 (Deactivated: 0)" + assert stats[8] == " Activated Objectives: 0 (Deactivated: 0)" + + @pytest.mark.solver class TestDiagnosticsToolbox: @pytest.fixture(scope="class") From 7c5ed546569b324843412f13c4b05c7e2fcb18e9 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 10 Aug 2023 15:52:53 -0400 Subject: [PATCH 24/48] Fixing some tests --- idaes/core/util/tests/test_model_diagnostics.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 36d6118c17..301fddefaa 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -945,9 +945,9 @@ def test_report_structural_issues(self, model): Activated Blocks: 1 (Deactivated: 0) Free Variables in Activated Constraints: 4 (External: 1) - Free Variables with only lower bounds: 0 - Free Variables with only upper bounds: 0 - Free Variables with upper and lower bounds: 2 + Free Variables with only lower bounds: 0 + Free Variables with only upper bounds: 0 + Free Variables with upper and lower bounds: 2 Fixed Variables in Activated Constraints: 3 (External: 0) Activated Equality Constraints: 4 (Deactivated: 0) Activated Inequality Constraints: 0 (Deactivated: 0) @@ -971,7 +971,7 @@ def test_report_structural_issues(self, model): ==================================================================================== """ - + print(stream.getvalue()) assert stream.getvalue() == expected @pytest.mark.component From 296a5e069a3969f1a88f7624f0b9c905f9ef208f Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 11 Aug 2023 11:41:44 -0400 Subject: [PATCH 25/48] Adding Jacobian checks --- idaes/core/util/model_diagnostics.py | 165 +++++++++++++++++- .../core/util/tests/test_model_diagnostics.py | 150 +++++++++++++--- 2 files changed, 280 insertions(+), 35 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 8cb5c3811e..2206dcbcd1 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -63,7 +63,12 @@ large_residuals_set, variables_near_bounds_set, ) -from idaes.core.util.scaling import list_badly_scaled_variables +from idaes.core.util.scaling import ( + list_badly_scaled_variables, + extreme_jacobian_columns, + extreme_jacobian_rows, + extreme_jacobian_entries, +) import idaes.logger as idaeslog _log = idaeslog.getLogger(__name__) @@ -91,6 +96,30 @@ description="Absolute tolerance to use when comparing values to zero.", ), ) +CONFIG.declare( + "jacobian_large_value", + ConfigValue( + default=1e4, + domain=float, + description="Tolerance for considering a Jacobian value to be large.", + ), +) +CONFIG.declare( + "jacobian_small_value", + ConfigValue( + default=1e-4, + domain=float, + description="Tolerance for considering a Jacobian value to be small.", + ), +) +CONFIG.declare( + "jacobian_zero_value", + ConfigValue( + default=1e-10, + domain=float, + description="Tolerance for considering a Jacobian value to be equal to zero.", + ), +) # TODO: Add scaling tolerance parameters @@ -452,6 +481,95 @@ def display_overconstrained_set(self, stream=stdout): stream.write("=" * MAX_STR_LENGTH + "\n") + def display_variables_with_extreme_jacobians(self, stream=stdout): + """ + Prints the variables associated with columns in the Jacobian with extreme + L2 norms. This often indicates poorly scaled variables. + + Tolerances can be set via the DiagnosticsToolbox config. + + Args: + stream: an I/O object to write the output to (default = stdout) + + Returns: + None + + """ + _write_report_section( + stream=stream, + lines_list=[ + f"{i[1].name}: {i[0]}" + for i in extreme_jacobian_columns( + m=self.config.model, + large=self.config.jacobian_large_value, + small=self.config.jacobian_small_value, + ) + ], + title="The following variables(s) are associated with extreme Jacobian values:", + header="=", + footer="=", + ) + + def display_constraints_with_extreme_jacobians(self, stream=stdout): + """ + Prints the constraints associated with rows in the Jacobian with extreme + L2 norms. This often indicates poorly scaled constraints. + + Tolerances can be set via the DiagnosticsToolbox config. + + Args: + stream: an I/O object to write the output to (default = stdout) + + Returns: + None + + """ + _write_report_section( + stream=stream, + lines_list=[ + f"{i[1].name}: {i[0]}" + for i in extreme_jacobian_rows( + m=self.config.model, + large=self.config.jacobian_large_value, + small=self.config.jacobian_small_value, + ) + ], + title="The following constraints(s) are associated with extreme Jacobian values:", + header="=", + footer="=", + ) + + def display_extreme_jacobians_entries(self, stream=stdout): + """ + Prints variables and constraints associated with entries in the Jacobian with extreme + values. This can be indicative of poor scaling, especially for isolated terms (e.g. + variables which appear only in one term of a single constraint). + + Tolerances can be set via the DiagnosticsToolbox config. + + Args: + stream: an I/O object to write the output to (default = stdout) + + Returns: + None + + """ + _write_report_section( + stream=stream, + lines_list=[ + f"{i[1].name}, {i[2].name}: {i[0]}" + for i in extreme_jacobian_entries( + m=self.config.model, + large=self.config.jacobian_large_value, + small=self.config.jacobian_small_value, + zero=self.config.jacobian_zero_value, + ) + ], + title="The following variable(s) and constraints(s) are associated with extreme Jacobian\nvalues:", + header="=", + footer="=", + ) + # TODO: Block triangularization analysis # Number and size of blocks, polynomial degree of 1x1 blocks, simple pivot check of moderate sized sub-blocks? @@ -566,14 +684,34 @@ def _collect_numerical_warnings(self): ) next_steps.append("display_variables_at_or_outside_bounds()") - # Poor scaling - var_scaling = list_badly_scaled_variables(self.config.model) - if len(var_scaling) > 0: + # Extreme Jacobian rows and columns + jac_col = extreme_jacobian_columns( + m=self.config.model, + large=self.config.jacobian_large_value, + small=self.config.jacobian_small_value, + ) + if len(jac_col) > 0: cstring = "Variables" - if len(var_scaling) == 1: + if len(jac_col) == 1: cstring = "Variable" - warnings.append(f"WARNING: {len(var_scaling)} {cstring} with poor scaling") - next_steps.append("display_poorly_scaled_variables()") + warnings.append( + f"WARNING: {len(jac_col)} {cstring} with extreme Jacobian values" + ) + next_steps.append("display_variables_with_extreme_jacobians()") + + jac_row = extreme_jacobian_rows( + m=self.config.model, + large=self.config.jacobian_large_value, + small=self.config.jacobian_small_value, + ) + if len(jac_row) > 0: + cstring = "Constraints" + if len(jac_row) == 1: + cstring = "Constraint" + warnings.append( + f"WARNING: {len(jac_row)} {cstring} with extreme Jacobian values" + ) + next_steps.append("display_constraints_with_extreme_jacobians()") return warnings, next_steps @@ -615,6 +753,19 @@ def _collect_numerical_cautions(self): cstring = "Variable" cautions.append(f"Caution: {len(none_value)} {cstring} with None value") + # Extreme Jacobian entries + extreme_jac = extreme_jacobian_entries( + m=self.config.model, + large=self.config.jacobian_large_value, + small=self.config.jacobian_small_value, + zero=self.config.jacobian_zero_value, + ) + if len(extreme_jac) > 0: + cstring = "Entries" + if len(extreme_jac) == 1: + cstring = "Entry" + cautions.append(f"Caution: {len(extreme_jac)} extreme Jacobian {cstring}") + return cautions def assert_no_structural_warnings(self): diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 301fddefaa..dfbd6e0026 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -767,6 +767,90 @@ def test_display_overconstrained_set(self, model): assert stream.getvalue() == expected + @pytest.mark.component + def test_display_variables_with_extreme_jacobians(self): + model = ConcreteModel() + model.v1 = Var(initialize=1e-8) + model.v2 = Var() + model.v3 = Var() + + model.c1 = Constraint(expr=model.v1 == model.v2) + model.c2 = Constraint(expr=model.v1 == 1e-8 * model.v3) + model.c3 = Constraint(expr=1e8 * model.v1 + 1e10 * model.v2 == 1e-6 * model.v3) + + dt = DiagnosticsToolbox(model=model) + + stream = StringIO() + dt.display_variables_with_extreme_jacobians(stream) + + expected = """==================================================================================== +The following variables(s) are associated with extreme Jacobian values: + + v1: 100000000.00000001 + v2: 10000000000.0 + v3: 1.0000499987500626e-06 + +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_constraints_with_extreme_jacobians(self): + model = ConcreteModel() + model.v1 = Var(initialize=1e-8) + model.v2 = Var() + model.v3 = Var() + + model.c1 = Constraint(expr=model.v1 == model.v2) + model.c2 = Constraint(expr=model.v1 == 1e-8 * model.v3) + model.c3 = Constraint(expr=1e8 * model.v1 + 1e10 * model.v2 == 1e-6 * model.v3) + + dt = DiagnosticsToolbox(model=model) + + stream = StringIO() + dt.display_constraints_with_extreme_jacobians(stream) + + expected = """==================================================================================== +The following constraints(s) are associated with extreme Jacobian values: + + c3: 10000499987.500626 + +==================================================================================== +""" + print(stream.getvalue()) + assert stream.getvalue() == expected + + @pytest.mark.component + def test_display_extreme_jacobians_entries(self): + model = ConcreteModel() + model.v1 = Var(initialize=1e-8) + model.v2 = Var() + model.v3 = Var() + + model.c1 = Constraint(expr=model.v1 == model.v2) + model.c2 = Constraint(expr=model.v1 == 1e-8 * model.v3) + model.c3 = Constraint(expr=1e8 * model.v1 + 1e10 * model.v2 == 1e-6 * model.v3) + + dt = DiagnosticsToolbox(model=model) + + stream = StringIO() + dt.display_extreme_jacobians_entries(stream) + + expected = """==================================================================================== +The following variable(s) and constraints(s) are associated with extreme Jacobian +values: + + c2, v3: 1e-08 + c3, v1: 100000000.0 + c3, v2: 10000000000.0 + c3, v3: 1e-06 + +==================================================================================== +""" + print(stream.getvalue()) + assert stream.getvalue() == expected + @pytest.mark.component def test_collect_structural_warnings_base_case(self, model): dt = DiagnosticsToolbox(model=model.b) @@ -848,15 +932,13 @@ def test_collect_numerical_warnings(self, model): warnings, next_steps = dt._collect_numerical_warnings() - assert len(warnings) == 3 + assert len(warnings) == 2 assert "WARNING: 1 Constraint with large residuals" in warnings assert "WARNING: 2 Variables at or outside bounds" in warnings - assert "WARNING: 1 Variable with poor scaling" in warnings - assert len(next_steps) == 3 + assert len(next_steps) == 2 assert "display_constraints_with_large_residuals()" in next_steps assert "display_variables_at_or_outside_bounds()" in next_steps - assert "display_poorly_scaled_variables()" in next_steps @pytest.mark.component def test_collect_numerical_warnings_corrected(self, model): @@ -866,17 +948,10 @@ def test_collect_numerical_warnings_corrected(self, model): m.b.v3.setlb(-5) m.b.v5.setub(10) - m.scaling_factor = Suffix(direction=Suffix.EXPORT) - m.scaling_factor[m.b.v7] = 1e8 - m.scaling_factor[m.b.c4] = 1e-8 - - scaling = TransformationFactory("core.scale_model") - scaled_model = scaling.create_using(m, rename=False) - solver = get_solver() - solver.solve(scaled_model) + solver.solve(m) - dt = DiagnosticsToolbox(model=scaled_model.b) + dt = DiagnosticsToolbox(model=m.b) warnings, next_steps = dt._collect_numerical_warnings() @@ -884,16 +959,43 @@ def test_collect_numerical_warnings_corrected(self, model): assert len(next_steps) == 0 + @pytest.mark.component + def test_collect_numerical_warnings_jacobian(self): + model = ConcreteModel() + model.v1 = Var(initialize=1e-8) + model.v2 = Var(initialize=0) + model.v3 = Var(initialize=0) + + model.c1 = Constraint(expr=model.v1 == model.v2) + model.c2 = Constraint(expr=model.v1 == 1e-8 * model.v3) + model.c3 = Constraint(expr=1e8 * model.v1 + 1e10 * model.v2 == 1e-6 * model.v3) + + dt = DiagnosticsToolbox(model=model) + + warnings, next_steps = dt._collect_numerical_warnings() + + print(warnings) + assert len(warnings) == 3 + assert "WARNING: 3 Variables with extreme Jacobian values" in warnings + assert "WARNING: 1 Constraint with extreme Jacobian values" in warnings + assert "WARNING: 1 Constraint with large residuals" in warnings + + assert len(next_steps) == 3 + assert "display_variables_with_extreme_jacobians()" in next_steps + assert "display_constraints_with_extreme_jacobians()" in next_steps + assert "display_constraints_with_large_residuals()" in next_steps + @pytest.mark.component def test_collect_numerical_cautions(self, model): dt = DiagnosticsToolbox(model=model.b) cautions = dt._collect_numerical_cautions() - assert len(cautions) == 3 + assert len(cautions) == 4 assert "Caution: 3 Variables with value close to their bounds" in cautions assert "Caution: 3 Variables with value close to zero" in cautions assert "Caution: 1 Variable with None value" in cautions + assert "Caution: 1 extreme Jacobian Entry" in cautions @pytest.mark.component def test_assert_no_structural_warnings(self, model): @@ -913,24 +1015,17 @@ def test_assert_no_numerical_warnings(self, model): m = model.clone() dt = DiagnosticsToolbox(model=m.b) - with pytest.raises(AssertionError, match="Numerical issues found \(3\)."): + with pytest.raises(AssertionError, match="Numerical issues found \(2\)."): dt.assert_no_numerical_warnings() # Fix numerical issues m.b.v3.setlb(-5) m.b.v5.setub(10) - m.scaling_factor = Suffix(direction=Suffix.EXPORT) - m.scaling_factor[m.b.v7] = 1e8 - m.scaling_factor[m.b.c4] = 1e-8 - - scaling = TransformationFactory("core.scale_model") - scaled_model = scaling.create_using(m, rename=False) - solver = get_solver() - solver.solve(scaled_model) + solver.solve(m) - dt = DiagnosticsToolbox(model=scaled_model.b) + dt = DiagnosticsToolbox(model=m.b) dt.assert_no_numerical_warnings() @pytest.mark.component @@ -982,25 +1077,24 @@ def test_report_numerical_issues(self, model): dt.report_numerical_issues(stream) expected = """==================================================================================== -3 WARNINGS +2 WARNINGS WARNING: 1 Constraint with large residuals WARNING: 2 Variables at or outside bounds - WARNING: 1 Variable with poor scaling ------------------------------------------------------------------------------------ -3 Cautions +4 Cautions Caution: 3 Variables with value close to their bounds Caution: 3 Variables with value close to zero Caution: 1 Variable with None value + Caution: 1 extreme Jacobian Entry ------------------------------------------------------------------------------------ Suggested next steps: display_constraints_with_large_residuals() display_variables_at_or_outside_bounds() - display_poorly_scaled_variables() ==================================================================================== """ From 120d592bfdcae0e7569c5550ab0d86eaa96f8ef4 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 11 Aug 2023 13:18:19 -0400 Subject: [PATCH 26/48] Working on scaling tools --- idaes/core/util/model_diagnostics.py | 106 +++++++++++------- .../core/util/tests/test_model_diagnostics.py | 50 ++++++--- 2 files changed, 99 insertions(+), 57 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 2206dcbcd1..de9d949474 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -64,7 +64,6 @@ variables_near_bounds_set, ) from idaes.core.util.scaling import ( - list_badly_scaled_variables, extreme_jacobian_columns, extreme_jacobian_rows, extreme_jacobian_entries, @@ -89,35 +88,27 @@ ), ) CONFIG.declare( - "zero_tolerance", - ConfigValue( - default=1e-6, - domain=float, - description="Absolute tolerance to use when comparing values to zero.", - ), -) -CONFIG.declare( - "jacobian_large_value", + "large_value_tolerance", ConfigValue( default=1e4, domain=float, - description="Tolerance for considering a Jacobian value to be large.", + description="Absolute tolerance for considering a value to be large.", ), ) CONFIG.declare( - "jacobian_small_value", + "small_value_tolerance", ConfigValue( default=1e-4, domain=float, - description="Tolerance for considering a Jacobian value to be small.", + description="Absolute tolerance for considering a value to be small.", ), ) CONFIG.declare( - "jacobian_zero_value", + "zero_value_tolerance", ConfigValue( - default=1e-10, + default=1e-8, domain=float, - description="Tolerance for considering a Jacobian value to be equal to zero.", + description="Absolute tolerance for considering a value to be equal to zero.", ), ) # TODO: Add scaling tolerance parameters @@ -291,18 +282,20 @@ def display_variables_with_value_near_zero(self, stream=stdout): stream=stream, lines_list=[ f"{v.name}: value={value(v)}" - for v in _vars_near_zero(self.config.model, self.config.zero_tolerance) + for v in _vars_near_zero( + self.config.model, self.config.zero_value_tolerance + ) ], title="The following variable(s) have a value close to zero:", header="=", footer="=", ) - def display_poorly_scaled_variables(self, stream=stdout): + def display_variables_with_extreme_values(self, stream=stdout): """ - Prints a list of variables with poor scaling based on their current values. - Tolerances for determining poor scaling can be set in the class configuration - options. + Prints a list of variables with extreme values. + + Tolerances can be set in the class configuration options. Args: stream: an I/O object to write the list to (default = stdout) @@ -314,10 +307,15 @@ def display_poorly_scaled_variables(self, stream=stdout): _write_report_section( stream=stream, lines_list=[ - f"{i.name}: {j}" - for i, j in list_badly_scaled_variables(self.config.model) + f"{i.name}: {value(i)}" + for i in _vars_with_extreme_values( + model=self.config.model, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, + zero=self.config.zero_value_tolerance, + ) ], - title="The following variable(s) are poorly scaled:", + title="The following variable(s) have extreme values:", header="=", footer="=", ) @@ -501,8 +499,8 @@ def display_variables_with_extreme_jacobians(self, stream=stdout): f"{i[1].name}: {i[0]}" for i in extreme_jacobian_columns( m=self.config.model, - large=self.config.jacobian_large_value, - small=self.config.jacobian_small_value, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, ) ], title="The following variables(s) are associated with extreme Jacobian values:", @@ -530,8 +528,8 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): f"{i[1].name}: {i[0]}" for i in extreme_jacobian_rows( m=self.config.model, - large=self.config.jacobian_large_value, - small=self.config.jacobian_small_value, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, ) ], title="The following constraints(s) are associated with extreme Jacobian values:", @@ -560,9 +558,9 @@ def display_extreme_jacobians_entries(self, stream=stdout): f"{i[1].name}, {i[2].name}: {i[0]}" for i in extreme_jacobian_entries( m=self.config.model, - large=self.config.jacobian_large_value, - small=self.config.jacobian_small_value, - zero=self.config.jacobian_zero_value, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, + zero=self.config.zero_value_tolerance, ) ], title="The following variable(s) and constraints(s) are associated with extreme Jacobian\nvalues:", @@ -687,8 +685,8 @@ def _collect_numerical_warnings(self): # Extreme Jacobian rows and columns jac_col = extreme_jacobian_columns( m=self.config.model, - large=self.config.jacobian_large_value, - small=self.config.jacobian_small_value, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, ) if len(jac_col) > 0: cstring = "Variables" @@ -701,8 +699,8 @@ def _collect_numerical_warnings(self): jac_row = extreme_jacobian_rows( m=self.config.model, - large=self.config.jacobian_large_value, - small=self.config.jacobian_small_value, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, ) if len(jac_row) > 0: cstring = "Constraints" @@ -736,7 +734,7 @@ def _collect_numerical_cautions(self): ) # Variables near zero - near_zero = _vars_near_zero(self.config.model, self.config.zero_tolerance) + near_zero = _vars_near_zero(self.config.model, self.config.zero_value_tolerance) if len(near_zero) > 0: cstring = "Variables" if len(near_zero) == 1: @@ -745,6 +743,19 @@ def _collect_numerical_cautions(self): f"Caution: {len(near_zero)} {cstring} with value close to zero" ) + # Variables with extreme values + xval = _vars_with_extreme_values( + model=self.config.model, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, + zero=self.config.zero_value_tolerance, + ) + if len(xval) > 0: + cstring = "Variables" + if len(xval) == 1: + cstring = "Variable" + cautions.append(f"Caution: {len(xval)} {cstring} with extreme value") + # Variables with value None none_value = _vars_with_none_value(self.config.model) if len(none_value) > 0: @@ -756,9 +767,9 @@ def _collect_numerical_cautions(self): # Extreme Jacobian entries extreme_jac = extreme_jacobian_entries( m=self.config.model, - large=self.config.jacobian_large_value, - small=self.config.jacobian_small_value, - zero=self.config.jacobian_zero_value, + large=self.config.large_value_tolerance, + small=self.config.small_value_tolerance, + zero=self.config.zero_value_tolerance, ) if len(extreme_jac) > 0: cstring = "Entries" @@ -1716,11 +1727,11 @@ def _vars_fixed_to_zero(model): return zero_vars -def _vars_near_zero(model, zero_tolerance): +def _vars_near_zero(model, zero_value_tolerance): # Set of variables with values close to 0 near_zero_vars = ComponentSet() for v in model.component_data_objects(Var, descend_into=True): - if v.value is not None and abs(value(v)) <= zero_tolerance: + if v.value is not None and abs(value(v)) <= zero_value_tolerance: near_zero_vars.add(v) return near_zero_vars @@ -1746,6 +1757,19 @@ def _vars_with_none_value(model): return none_value +def _vars_with_extreme_values(model, large, small, zero): + extreme_vars = ComponentSet() + for v in model.component_data_objects(Var, descend_into=True): + if v.value is not None: + mag = 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 + + def _write_report_section( stream, lines_list, diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index dfbd6e0026..bf93d1bb19 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -59,6 +59,7 @@ _vars_near_zero, _vars_violating_bounds, _vars_with_none_value, + _vars_with_extreme_values, _write_report_section, _collect_model_statistics, ) @@ -142,6 +143,24 @@ def test_vars_with_bounds_issues(model): assert i.local_name in ["v1", "v4"] +@pytest.mark.unit +def test_vars_with_extreme_values(): + m = ConcreteModel() + m.v1 = Var(initialize=1e-12) # below zero + m.v2 = Var(initialize=1e-8) # small + m.v3 = Var(initialize=1e-4) + m.v4 = Var(initialize=1e0) + m.v5 = Var(initialize=1e4) + m.v6 = Var(initialize=1e8) + m.v6 = Var(initialize=1e12) # large + + xvars = _vars_with_extreme_values(m, large=1e9, small=1e-7, zero=1e-10) + + assert len(xvars) == 2 + for i in xvars: + assert i.name in ["v2", "v6"] + + @pytest.mark.unit def test_write_report_section_all(): stream = StringIO() @@ -400,7 +419,7 @@ def model(self): m.b.c1 = Constraint(expr=m.v1 + m.b.v2 == 10) # Unit consistency issue m.b.c2 = Constraint(expr=m.b.v3 == m.b.v4 + m.b.v5) m.b.c3 = Constraint(expr=2 * m.b.v3 == 3 * m.b.v4 + 4 * m.b.v5 + m.b.v6) - m.b.c4 = Constraint(expr=m.b.v7 == 1e-8 * m.v1) # Poorly scaled constraint + m.b.c4 = Constraint(expr=m.b.v7 == 2e-8 * m.v1) # Poorly scaled constraint m.b.v2.fix(5) m.b.v5.fix(2) @@ -509,7 +528,6 @@ def test_display_variables_with_value_near_zero(self, model): b.v3: value=0.0 b.v6: value=0 - b.v7: value=5.002439135661953e-08 ==================================================================================== """ @@ -517,16 +535,16 @@ def test_display_variables_with_value_near_zero(self, model): assert stream.getvalue() == expected @pytest.mark.component - def test_display_poorly_scaled_variables(self, model): + def test_display_variables_with_extreme_values(self, model): dt = DiagnosticsToolbox(model=model.b) stream = StringIO() - dt.display_poorly_scaled_variables(stream) + dt.display_variables_with_extreme_values(stream) expected = """==================================================================================== -The following variable(s) are poorly scaled: +The following variable(s) have extreme values: - b.v7: 5.002439135661953e-08 + b.v7: 1.0000939326524314e-07 ==================================================================================== """ @@ -545,7 +563,7 @@ def test_display_variables_near_bounds(self, model): b.v3: value=0.0 bounds=(0, 5) b.v5: value=2 bounds=(0, 1) - b.v7: value=5.002439135661953e-08 bounds=(0, 1) + b.v7: value=1.0000939326524314e-07 bounds=(0, 1) ==================================================================================== """ @@ -818,7 +836,7 @@ def test_display_constraints_with_extreme_jacobians(self): ==================================================================================== """ - print(stream.getvalue()) + assert stream.getvalue() == expected @pytest.mark.component @@ -841,14 +859,13 @@ def test_display_extreme_jacobians_entries(self): The following variable(s) and constraints(s) are associated with extreme Jacobian values: - c2, v3: 1e-08 c3, v1: 100000000.0 c3, v2: 10000000000.0 c3, v3: 1e-06 ==================================================================================== """ - print(stream.getvalue()) + assert stream.getvalue() == expected @pytest.mark.component @@ -974,7 +991,6 @@ def test_collect_numerical_warnings_jacobian(self): warnings, next_steps = dt._collect_numerical_warnings() - print(warnings) assert len(warnings) == 3 assert "WARNING: 3 Variables with extreme Jacobian values" in warnings assert "WARNING: 1 Constraint with extreme Jacobian values" in warnings @@ -991,11 +1007,12 @@ def test_collect_numerical_cautions(self, model): cautions = dt._collect_numerical_cautions() - assert len(cautions) == 4 + assert len(cautions) == 5 assert "Caution: 3 Variables with value close to their bounds" in cautions - assert "Caution: 3 Variables with value close to zero" in cautions + assert "Caution: 2 Variables with value close to zero" in cautions assert "Caution: 1 Variable with None value" in cautions assert "Caution: 1 extreme Jacobian Entry" in cautions + assert "Caution: 1 Variable with extreme value" in cautions @pytest.mark.component def test_assert_no_structural_warnings(self, model): @@ -1066,7 +1083,7 @@ def test_report_structural_issues(self, model): ==================================================================================== """ - print(stream.getvalue()) + assert stream.getvalue() == expected @pytest.mark.component @@ -1083,10 +1100,11 @@ def test_report_numerical_issues(self, model): WARNING: 2 Variables at or outside bounds ------------------------------------------------------------------------------------ -4 Cautions +5 Cautions Caution: 3 Variables with value close to their bounds - Caution: 3 Variables with value close to zero + Caution: 2 Variables with value close to zero + Caution: 1 Variable with extreme value Caution: 1 Variable with None value Caution: 1 extreme Jacobian Entry From 2c23b283b296de4788ef21f261fa5655dbc66ba2 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 11 Aug 2023 13:54:58 -0400 Subject: [PATCH 27/48] Fixing more deprecation warnings from expr.current --- idaes/core/plugins/simple_equality_eliminator.py | 2 +- idaes/core/plugins/variable_replace.py | 2 +- idaes/core/surrogate/pysmo/utils.py | 3 ++- idaes/core/util/expr_doc.py | 3 ++- idaes/core/util/model_diagnostics.py | 6 ++---- idaes/core/util/scaling.py | 2 +- idaes/core/util/tests/test_model_diagnostics.py | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/idaes/core/plugins/simple_equality_eliminator.py b/idaes/core/plugins/simple_equality_eliminator.py index 804f15ba3f..143fafafac 100644 --- a/idaes/core/plugins/simple_equality_eliminator.py +++ b/idaes/core/plugins/simple_equality_eliminator.py @@ -17,7 +17,7 @@ import pyomo.environ as pyo from pyomo.core.base.transformation import TransformationFactory from pyomo.core.plugins.transform.hierarchy import NonIsomorphicTransformation -from pyomo.core.expr import current as EXPR +import pyomo.core.expr as EXPR from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr from pyomo.repn import generate_standard_repn import idaes.logger as idaeslog diff --git a/idaes/core/plugins/variable_replace.py b/idaes/core/plugins/variable_replace.py index 444c2de6fd..c94626dc31 100644 --- a/idaes/core/plugins/variable_replace.py +++ b/idaes/core/plugins/variable_replace.py @@ -16,7 +16,7 @@ from pyomo.core.base.transformation import TransformationFactory from pyomo.core.plugins.transform.hierarchy import NonIsomorphicTransformation -from pyomo.core.expr import current as EXPR +import pyomo.core.expr as EXPR from pyomo.common.config import ( ConfigBlock, ConfigValue, diff --git a/idaes/core/surrogate/pysmo/utils.py b/idaes/core/surrogate/pysmo/utils.py index 6d641c798b..f5ac81023d 100644 --- a/idaes/core/surrogate/pysmo/utils.py +++ b/idaes/core/surrogate/pysmo/utils.py @@ -29,7 +29,8 @@ __author__ = "Oluwamayowa Amusat, John Siirola" -from pyomo.core.expr import current as EXPR, native_types +import pyomo.core.expr as EXPR +from pyomo.core.expr import native_types from pyomo.core.expr.numvalue import value _numpy_available = True diff --git a/idaes/core/util/expr_doc.py b/idaes/core/util/expr_doc.py index 168e10a06f..600307d0b3 100644 --- a/idaes/core/util/expr_doc.py +++ b/idaes/core/util/expr_doc.py @@ -28,7 +28,8 @@ from pyomo.core.base.block import _BlockData from pyomo.core.expr.visitor import StreamBasedExpressionVisitor from pyomo.core.expr.numeric_expr import ExternalFunctionExpression -from pyomo.core.expr import current as EXPR, native_types +import pyomo.core.expr as EXPR +from pyomo.core.expr import native_types from pyomo.common.collections import ComponentMap diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index de9d949474..28139a22c4 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -47,7 +47,6 @@ from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP from pyomo.contrib.pynumero.asl import AmplInterface -import idaes.core.util.scaling as iscale from idaes.core.util.model_statistics import ( activated_blocks_set, deactivated_blocks_set, @@ -64,6 +63,7 @@ variables_near_bounds_set, ) from idaes.core.util.scaling import ( + get_jacobian, extreme_jacobian_columns, extreme_jacobian_rows, extreme_jacobian_entries, @@ -925,9 +925,7 @@ def __init__(self, block_or_jac, solver=None): self.nlp = PyomoNLP(self.block) # Get the scaled Jacobian of equality constraints - self.jac_eq = iscale.get_jacobian( - self.block, equality_constraints_only=True - )[0] + self.jac_eq = get_jacobian(self.block, equality_constraints_only=True)[0] # Create a list of equality constraint names self.eq_con_list = self.nlp.get_pyomo_equality_constraints() diff --git a/idaes/core/util/scaling.py b/idaes/core/util/scaling.py index 53cfd24844..6fad146bb8 100644 --- a/idaes/core/util/scaling.py +++ b/idaes/core/util/scaling.py @@ -46,7 +46,7 @@ from pyomo.dae import DerivativeVar from pyomo.dae.flatten import slice_component_along_sets from pyomo.util.calc_var_value import calculate_variable_from_constraint -from pyomo.core.expr import current as EXPR +import pyomo.core.expr as EXPR from pyomo.core.expr.numvalue import native_types, pyomo_constant_types from pyomo.core.base.units_container import _PyomoUnit diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index bf93d1bb19..7fba678339 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -103,13 +103,13 @@ def test_vars_fixed_to_zero(model): def test_vars_near_zero(model): model.v3.set_value(1e-5) - near_zero_vars = _vars_near_zero(model, zero_tolerance=1e-5) + near_zero_vars = _vars_near_zero(model, zero_value_tolerance=1e-5) assert isinstance(near_zero_vars, ComponentSet) assert len(near_zero_vars) == 2 for i in near_zero_vars: assert i.local_name in ["v1", "v3"] - near_zero_vars = _vars_near_zero(model, zero_tolerance=1e-6) + near_zero_vars = _vars_near_zero(model, zero_value_tolerance=1e-6) assert isinstance(near_zero_vars, ComponentSet) assert len(near_zero_vars) == 1 for i in near_zero_vars: From 25edaddddb02f71c8392023cfa29b514a02afcca Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 11 Aug 2023 15:21:21 -0400 Subject: [PATCH 28/48] Adding separate abs and rel tolerances to vars near bounds --- idaes/core/util/model_diagnostics.py | 34 ++- idaes/core/util/model_statistics.py | 92 +++++--- .../core/util/tests/test_model_diagnostics.py | 16 ++ .../core/util/tests/test_model_statistics.py | 218 ++++++++++++------ 4 files changed, 261 insertions(+), 99 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 28139a22c4..07fac13069 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -46,6 +46,7 @@ from pyomo.core.expr.visitor import identify_variables from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP from pyomo.contrib.pynumero.asl import AmplInterface +from pyomo.common.deprecation import deprecation_warning from idaes.core.util.model_statistics import ( activated_blocks_set, @@ -79,6 +80,22 @@ CONFIG = ConfigDict() CONFIG.declare("model", ConfigValue(description="Pyomo model object to be diagnosed.")) +CONFIG.declare( + "absolute_tolerance", + ConfigValue( + default=1e-4, + domain=float, + description="Absolute tolerance to for variables close to bounds.", + ), +) +CONFIG.declare( + "relative_tolerance", + ConfigValue( + default=1e-4, + domain=float, + description="Relative tolerance to for variables close to bounds.", + ), +) CONFIG.declare( "residual_tolerance", ConfigValue( @@ -336,7 +353,11 @@ def display_variables_near_bounds(self, stream=stdout): stream=stream, lines_list=[ f"{v.name}: value={value(v)} bounds={v.bounds}" - for v in variables_near_bounds_set(self.config.model) + for v in variables_near_bounds_set( + self.config.model, + abs_tol=self.config.absolute_tolerance, + rel_tol=self.config.relative_tolerance, + ) ], title="The following variable(s) have values close to their bounds:", header="=", @@ -724,7 +745,11 @@ def _collect_numerical_cautions(self): cautions = [] # Variables near bounds - near_bounds = variables_near_bounds_set(self.config.model) + near_bounds = variables_near_bounds_set( + self.config.model, + abs_tol=self.config.absolute_tolerance, + rel_tol=self.config.relative_tolerance, + ) if len(near_bounds) > 0: cstring = "Variables" if len(near_bounds) == 1: @@ -908,6 +933,11 @@ def __init__(self, block_or_jac, solver=None): Passing a Jacobian to Degeneracy Hunter is current untested. """ + msg = ( + "DegeneracyHunter is being deprecated in favor of the new " + "DiagnosticsToolbox." + ) + deprecation_warning(msg=msg, logger=_log, version="2.0.0", remove_in="3.0.0") block_like = False try: diff --git a/idaes/core/util/model_statistics.py b/idaes/core/util/model_statistics.py index c0ac0b1d5c..be6d0c613f 100644 --- a/idaes/core/util/model_statistics.py +++ b/idaes/core/util/model_statistics.py @@ -24,7 +24,11 @@ from pyomo.dae import DerivativeVar from pyomo.core.expr import identify_variables from pyomo.common.collections import ComponentSet +from pyomo.common.deprecation import deprecation_warning +import idaes.logger as idaeslog + +_log = idaeslog.getLogger(__name__) # ------------------------------------------------------------------------- # Generator to handle cases where the input is an indexed Block @@ -682,7 +686,13 @@ def number_unfixed_variables(block): def variables_near_bounds_generator( - block, tol=1e-4, relative=True, skip_lb=False, skip_ub=False + block, + tol=None, + relative=None, + skip_lb=False, + skip_ub=False, + abs_tol=1e-4, + rel_tol=1e-4, ): """ Generator which returns all Var components in a model which have a value @@ -690,8 +700,8 @@ def variables_near_bounds_generator( Args: block : model to be studied - tol : (relative) tolerance for inclusion in generator (default = 1e-4) - relative : Boolean, use relative tolerance (default = True) + abs_tol : absolute tolerance for inclusion in generator (default = 1e-4) + rel_tol : relative tolerance for inclusion in generator (default = 1e-4) skip_lb: Boolean to skip lower bound (default = False) skip_ub: Boolean to skip upper bound (default = False) @@ -699,6 +709,23 @@ def variables_near_bounds_generator( A generator which returns all Var components block that are close to a bound """ + # Check for deprecated arguments + if relative is not None: + msg = ( + "variables_near_bounds_generator has deprecated the relative argument. " + "Please set abs_tol and rel_tol arguments instead." + ) + deprecation_warning(msg=msg, logger=_log, version="2.0.0", remove_in="3.0.0") + if tol is not None: + msg = ( + "variables_near_bounds_generator has deprecated the tol argument. " + "Please set abs_tol and rel_tol arguments instead." + ) + deprecation_warning(msg=msg, logger=_log, version="2.0.0", remove_in="3.0.0") + # Set tolerances using the provided value + abs_tol = tol + rel_tol = tol + for v in _iter_indexed_block_data_objects( block, ctype=Var, active=True, descend_into=True ): @@ -706,39 +733,45 @@ def variables_near_bounds_generator( if v.value is None: continue - if relative: - # First, determine absolute tolerance to apply to bounds - if v.ub is not None and v.lb is not None: - # Both upper and lower bounds, apply tol to (upper - lower) - atol = value((v.ub - v.lb) * tol) - elif v.ub is not None: - # Only upper bound, apply tol to bound value - atol = abs(value(v.ub * tol)) - elif v.lb is not None: - # Only lower bound, apply tol to bound value - atol = abs(value(v.lb * tol)) - else: - continue + # First, magnitude of variable + if v.ub is not None and v.lb is not None: + # Both upper and lower bounds, apply tol to (upper - lower) + mag = value(v.ub - v.lb) + elif v.ub is not None: + # Only upper bound, apply tol to bound value + mag = abs(value(v.ub)) + elif v.lb is not None: + # Only lower bound, apply tol to bound value + mag = abs(value(v.lb)) else: - atol = tol + mag = 0 - if v.ub is not None and not skip_lb and value(v.ub - v.value) <= atol: + # Calculate largest tolerance from absolute and relative + tol = max(abs_tol, mag * rel_tol) + + if v.ub is not None and not skip_lb and value(v.ub - v.value) <= tol: yield v - elif v.lb is not None and not skip_ub and value(v.value - v.lb) <= atol: + elif v.lb is not None and not skip_ub and value(v.value - v.lb) <= tol: yield v def variables_near_bounds_set( - block, tol=1e-4, relative=True, skip_lb=False, skip_ub=False + block, + tol=None, + relative=None, + skip_lb=False, + skip_ub=False, + abs_tol=1e-4, + rel_tol=1e-4, ): """ Method to return a ComponentSet of all Var components in a model which have - a value within tol (relative) of a bound. + a value within tolerance of a bound. Args: block : model to be studied - tol : relative tolerance for inclusion in generator (default = 1e-4) - relative : Boolean, use relative tolerance (default = True) + abs_tol : absolute tolerance for inclusion in generator (default = 1e-4) + rel_tol : relative tolerance for inclusion in generator (default = 1e-4) skip_lb: Boolean to skip lower bound (default = False) skip_ub: Boolean to skip upper bound (default = False) @@ -747,23 +780,28 @@ def variables_near_bounds_set( bound """ return ComponentSet( - variables_near_bounds_generator(block, tol, relative, skip_lb, skip_ub) + variables_near_bounds_generator( + block, tol, relative, skip_lb, skip_ub, abs_tol, rel_tol + ) ) -def number_variables_near_bounds(block, tol=1e-4): +def number_variables_near_bounds(block, tol=None, abs_tol=1e-4, rel_tol=1e-4): """ Method to return the number of all Var components in a model which have a value within tol (relative) of a bound. Args: block : model to be studied - tol : relative tolerance for inclusion in generator (default = 1e-4) + abs_tol : absolute tolerance for inclusion in generator (default = 1e-4) + rel_tol : relative tolerance for inclusion in generator (default = 1e-4) Returns: Number of components block that are close to a bound """ - return len(variables_near_bounds_set(block, tol)) + return len( + variables_near_bounds_set(block, tol=tol, abs_tol=abs_tol, rel_tol=rel_tol) + ) # ------------------------------------------------------------------------- diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 7fba678339..927ad4fe56 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -1158,6 +1158,22 @@ def v_exp(): ).T +@pytest.mark.unit +def test_deprecate_degeneracy_hunter(caplog): + m = ConcreteModel() + m.v = Var() + m.o = Objective(expr=m.v) + dh = DegeneracyHunter(m) + + msg = ( + "DEPRECATED: DegeneracyHunter is being deprecated in favor of the new " + "DiagnosticsToolbox. (deprecated in 2.0.0, will be removed in (or after) 3.0.0)" + ) + assert msg.replace(" ", "") in caplog.records[0].message.replace("\n", "").replace( + " ", "" + ) + + @pytest.mark.skipif( not AmplInterface.available(), reason="pynumero_ASL is not available" ) diff --git a/idaes/core/util/tests/test_model_statistics.py b/idaes/core/util/tests/test_model_statistics.py index 02b85a6220..85818dc7b5 100644 --- a/idaes/core/util/tests/test_model_statistics.py +++ b/idaes/core/util/tests/test_model_statistics.py @@ -312,76 +312,154 @@ def test_number_unfixed_variables(m): @pytest.mark.unit -def test_variables_near_bounds_set(m): - tset = variables_near_bounds_set(m) - assert len(tset) == 6 - for i in tset: - assert i in ComponentSet( - [ - m.b2["a"].v1, - m.b2["b"].v1, - m.b2["a"].v2["a"], - m.b2["a"].v2["b"], - m.b2["b"].v2["a"], - m.b2["b"].v2["b"], - ] - ) - - m.b2["a"].v1.value = 1.001 - tset = variables_near_bounds_set(m) - assert len(tset) == 5 - for i in tset: - assert i in ComponentSet( - [ - m.b2["b"].v1, - m.b2["a"].v2["a"], - m.b2["a"].v2["b"], - m.b2["b"].v2["a"], - m.b2["b"].v2["b"], - ] - ) - - tset = variables_near_bounds_set(m, tol=1e-3) - assert len(tset) == 6 - for i in tset: - assert i in ComponentSet( - [ - m.b2["a"].v1, - m.b2["b"].v1, - m.b2["a"].v2["a"], - m.b2["a"].v2["b"], - m.b2["b"].v2["a"], - m.b2["b"].v2["b"], - ] - ) - - m.b2["a"].v1.setlb(None) - tset = variables_near_bounds_set(m) - assert len(tset) == 5 - for i in tset: - assert i in ComponentSet( - [ - m.b2["b"].v1, - m.b2["a"].v2["a"], - m.b2["a"].v2["b"], - m.b2["b"].v2["a"], - m.b2["b"].v2["b"], - ] - ) - - m.b2["a"].v2["a"].setub(None) - tset = variables_near_bounds_set(m) - assert len(tset) == 4 - for i in tset: - assert i in ComponentSet( - [m.b2["b"].v1, m.b2["a"].v2["b"], m.b2["b"].v2["a"], m.b2["b"].v2["b"]] - ) - - m.b2["a"].v2["b"].value = None - tset = variables_near_bounds_set(m) - assert len(tset) == 3 - for i in tset: - assert i in ComponentSet([m.b2["b"].v1, m.b2["b"].v2["a"], m.b2["b"].v2["b"]]) +def test_variables_near_bounds_tol_deprecation(m, caplog): + variables_near_bounds_set(m, tol=1e-3) + + msg = ( + "DEPRECATED: variables_near_bounds_generator has deprecated the tol argument. " + "Please set abs_tol and rel_tol arguments instead. (deprecated in " + "2.0.0, will be removed in (or after) 3.0.0)" + ) + assert msg.replace(" ", "") in caplog.records[0].message.replace("\n", "").replace( + " ", "" + ) + + +@pytest.mark.unit +def test_variables_near_bounds_relative_deprecation(m, caplog): + variables_near_bounds_set(m, relative=False) + + msg = ( + "DEPRECATED: variables_near_bounds_generator has deprecated the relative argument. " + "Please set abs_tol and rel_tol arguments instead. (deprecated in " + "2.0.0, will be removed in (or after) 3.0.0)" + ) + assert msg.replace(" ", "") in caplog.records[0].message.replace("\n", "").replace( + " ", "" + ) + + +@pytest.mark.unit +def test_variables_near_bounds_set(): + m = ConcreteModel() + m.v = Var(initialize=0.5, bounds=(0, 1)) + + # Small value, both bounds + # Away from bounds + vset = variables_near_bounds_set(m) + assert len(vset) == 0 + + # Near lower bound, relative + m.v.set_value(1e-6) + vset = variables_near_bounds_set(m, abs_tol=1e-8, rel_tol=1e-4) + assert len(vset) == 1 + + # Near lower bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1e-4, rel_tol=1e-8) + assert len(vset) == 1 + + # Near upper bound, relative + m.v.set_value(1 - 1e-6) + vset = variables_near_bounds_set(m, abs_tol=1e-8, rel_tol=1e-4) + assert len(vset) == 1 + + # Near upper bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1e-4, rel_tol=1e-8) + assert len(vset) == 1 + + # Small value, lower bound + # Away from bounds + m.v.set_value(0.5) + m.v.setub(None) + vset = variables_near_bounds_set(m) + assert len(vset) == 0 + + # Near lower bound, relative + m.v.set_value(1e-6) + vset = variables_near_bounds_set(m, abs_tol=1e-8, rel_tol=1e-4) + # Lower bound of 0 means relative tolerance is 0 + assert len(vset) == 0 + + # Near lower bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1e-4, rel_tol=1e-8) + assert len(vset) == 1 + + # Small value, upper bound + # Away from bounds + m.v.set_value(0.5) + m.v.setub(1) + m.v.setlb(None) + vset = variables_near_bounds_set(m) + assert len(vset) == 0 + + # Near upper bound, relative + m.v.set_value(1 - 1e-6) + vset = variables_near_bounds_set(m, abs_tol=1e-8, rel_tol=1e-4) + assert len(vset) == 1 + + # Near upper bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1e-4, rel_tol=1e-8) + assert len(vset) == 1 + + # Large value, both bounds + # Relative tolerance based on magnitude of 100 + m.v.setlb(450) + m.v.setub(550) + m.v.set_value(500) + vset = variables_near_bounds_set(m) + assert len(vset) == 0 + + # Near lower bound, relative + m.v.set_value(451) + vset = variables_near_bounds_set(m, rel_tol=1e-2) + assert len(vset) == 1 + + # Near lower bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1) + assert len(vset) == 1 + + # Near upper bound, relative + m.v.set_value(549) + vset = variables_near_bounds_set(m, rel_tol=1e-2) + assert len(vset) == 1 + + # Near upper bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1) + assert len(vset) == 1 + + # Large value, lower bound + # Relative tolerance based on magnitude of 450 + m.v.setlb(450) + m.v.setub(None) + m.v.set_value(500) + vset = variables_near_bounds_set(m) + assert len(vset) == 0 + + # Near lower bound, relative + m.v.set_value(451) + vset = variables_near_bounds_set(m, rel_tol=1e-2) + assert len(vset) == 1 + + # Near lower bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1) + assert len(vset) == 1 + + # Large value, upper bound + # Relative tolerance based on magnitude of 550 + m.v.setlb(None) + m.v.setub(550) + m.v.set_value(500) + vset = variables_near_bounds_set(m) + assert len(vset) == 0 + + # Near upper bound, relative + m.v.set_value(549) + vset = variables_near_bounds_set(m, rel_tol=1e-2) + assert len(vset) == 1 + + # Near upper bound, absolute + vset = variables_near_bounds_set(m, abs_tol=1) + assert len(vset) == 1 @pytest.mark.unit From c85e9178d7b3010c328269f07494829ae3db3367 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 11 Aug 2023 15:25:24 -0400 Subject: [PATCH 29/48] Fixing incorrect logic --- idaes/core/util/model_statistics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/model_statistics.py b/idaes/core/util/model_statistics.py index be6d0c613f..25a16044b9 100644 --- a/idaes/core/util/model_statistics.py +++ b/idaes/core/util/model_statistics.py @@ -749,9 +749,9 @@ def variables_near_bounds_generator( # Calculate largest tolerance from absolute and relative tol = max(abs_tol, mag * rel_tol) - if v.ub is not None and not skip_lb and value(v.ub - v.value) <= tol: + if v.ub is not None and not skip_ub and value(v.ub - v.value) <= tol: yield v - elif v.lb is not None and not skip_ub and value(v.value - v.lb) <= tol: + elif v.lb is not None and not skip_lb and value(v.value - v.lb) <= tol: yield v From cd986f15cb6f6ed1797b399d04781c38858939c2 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 11 Aug 2023 16:46:51 -0400 Subject: [PATCH 30/48] Testing with tutorial --- idaes/core/util/model_diagnostics.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 07fac13069..284d91a987 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -128,7 +128,6 @@ description="Absolute tolerance for considering a value to be equal to zero.", ), ) -# TODO: Add scaling tolerance parameters @document_kwargs_from_configdict(CONFIG) @@ -622,10 +621,10 @@ def _collect_structural_warnings(self): if any(len(x) > 0 for x in [uc_var, uc_con, oc_var, oc_con]): warnings.append( f"WARNING: Structural singularity found\n" - f"{TAB*2}Under-Constrained Set: {len(uc_var)} " - f"variables, {len(uc_con)} constraints\n" - f"{TAB*2}Over-Constrained Set: {len(oc_var)} " - f"variables, {len(oc_con)} constraints" + f"{TAB*2}Under-Constrained Set: {len(sum(uc_var, []))} " + f"variables, {len(sum(uc_con, []))} constraints\n" + f"{TAB*2}Over-Constrained Set: {len(sum(oc_var, []))} " + f"variables, {len(sum(oc_con, []))} constraints" ) if any(len(x) > 0 for x in [uc_var, uc_con]): @@ -789,6 +788,8 @@ def _collect_numerical_cautions(self): cstring = "Variable" cautions.append(f"Caution: {len(none_value)} {cstring} with None value") + # TODO: Condition number + # Extreme Jacobian entries extreme_jac = extreme_jacobian_entries( m=self.config.model, From ddfdf5c7ff838fe7c71e0209dd8d8e387bfabbff Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 14 Aug 2023 11:23:04 -0400 Subject: [PATCH 31/48] Better Jacobian analysis --- idaes/core/util/model_diagnostics.py | 127 ++++++++++++++---- idaes/core/util/scaling.py | 2 +- .../core/util/tests/test_model_diagnostics.py | 85 +++++++++++- 3 files changed, 184 insertions(+), 30 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 284d91a987..1c9c34cfa0 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -68,6 +68,7 @@ extreme_jacobian_columns, extreme_jacobian_rows, extreme_jacobian_entries, + jacobian_cond, ) import idaes.logger as idaeslog @@ -77,6 +78,7 @@ MAX_STR_LENGTH = 84 TAB = " " * 4 +# TODO: Add suggested steps to cautions CONFIG = ConfigDict() CONFIG.declare("model", ConfigValue(description="Pyomo model object to be diagnosed.")) @@ -125,7 +127,39 @@ ConfigValue( default=1e-8, domain=float, - description="Absolute tolerance for considering a value to be equal to zero.", + description="Absolute tolerance for considering a value to be near to zero.", + ), +) +CONFIG.declare( + "jacobian_large_value_caution", + ConfigValue( + default=1e4, + domain=float, + description="Tolerance for raising a caution for large Jacobian values.", + ), +) +CONFIG.declare( + "jacobian_large_value_warning", + ConfigValue( + default=1e8, + domain=float, + description="Tolerance for raising a warning for large Jacobian values.", + ), +) +CONFIG.declare( + "jacobian_small_value_caution", + ConfigValue( + default=1e-4, + domain=float, + description="Tolerance for raising a caution for small Jacobian values.", + ), +) +CONFIG.declare( + "jacobian_small_value_warning", + ConfigValue( + default=1e-8, + domain=float, + description="Tolerance for raising a warning for small Jacobian values.", ), ) @@ -519,8 +553,8 @@ def display_variables_with_extreme_jacobians(self, stream=stdout): f"{i[1].name}: {i[0]}" for i in extreme_jacobian_columns( m=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, ) ], title="The following variables(s) are associated with extreme Jacobian values:", @@ -548,8 +582,8 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): f"{i[1].name}: {i[0]}" for i in extreme_jacobian_rows( m=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, ) ], title="The following constraints(s) are associated with extreme Jacobian values:", @@ -578,9 +612,9 @@ def display_extreme_jacobians_entries(self, stream=stdout): f"{i[1].name}, {i[2].name}: {i[0]}" for i in extreme_jacobian_entries( m=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, - zero=self.config.zero_value_tolerance, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + zero=0, ) ], title="The following variable(s) and constraints(s) are associated with extreme Jacobian\nvalues:", @@ -666,7 +700,7 @@ def _collect_structural_cautions(self): return cautions - def _collect_numerical_warnings(self): + def _collect_numerical_warnings(self, jac=None, nlp=None): """ Runs checks for numerical warnings and returns two lists. @@ -675,6 +709,9 @@ def _collect_numerical_warnings(self): next_steps - list of suggested next steps to further investigate warnings """ + if jac is None or nlp is None: + jac, nlp = get_jacobian(self.config.model, scaled=False) + warnings = [] next_steps = [] @@ -704,9 +741,10 @@ def _collect_numerical_warnings(self): # Extreme Jacobian rows and columns jac_col = extreme_jacobian_columns( - m=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, + jac=jac, + nlp=nlp, + large=self.config.jacobian_large_value_warning, + small=self.config.jacobian_small_value_warning, ) if len(jac_col) > 0: cstring = "Variables" @@ -718,9 +756,10 @@ def _collect_numerical_warnings(self): next_steps.append("display_variables_with_extreme_jacobians()") jac_row = extreme_jacobian_rows( - m=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, + jac=jac, + nlp=nlp, + large=self.config.jacobian_large_value_warning, + small=self.config.jacobian_small_value_warning, ) if len(jac_row) > 0: cstring = "Constraints" @@ -733,7 +772,7 @@ def _collect_numerical_warnings(self): return warnings, next_steps - def _collect_numerical_cautions(self): + def _collect_numerical_cautions(self, jac=None, nlp=None): """ Runs checks for numerical cautions and returns a list. @@ -741,6 +780,9 @@ def _collect_numerical_cautions(self): cautions - list of caution messages from numerical analysis """ + if jac is None or nlp is None: + jac, nlp = get_jacobian(self.config.model, scaled=False) + cautions = [] # Variables near bounds @@ -788,14 +830,42 @@ def _collect_numerical_cautions(self): cstring = "Variable" cautions.append(f"Caution: {len(none_value)} {cstring} with None value") - # TODO: Condition number + # Extreme Jacobian rows and columns + jac_col = extreme_jacobian_columns( + jac=jac, + nlp=nlp, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + ) + if len(jac_col) > 0: + cstring = "Variables" + if len(jac_col) == 1: + cstring = "Variable" + cautions.append( + f"Caution: {len(jac_col)} {cstring} with extreme Jacobian values" + ) + + jac_row = extreme_jacobian_rows( + jac=jac, + nlp=nlp, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + ) + if len(jac_row) > 0: + cstring = "Constraints" + if len(jac_row) == 1: + cstring = "Constraint" + cautions.append( + f"Caution: {len(jac_row)} {cstring} with extreme Jacobian values" + ) # Extreme Jacobian entries extreme_jac = extreme_jacobian_entries( - m=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, - zero=self.config.zero_value_tolerance, + jac=jac, + nlp=nlp, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + zero=0, ) if len(extreme_jac) > 0: cstring = "Entries" @@ -891,15 +961,24 @@ def report_numerical_issues(self, stream=stdout): None """ - warnings, next_steps = self._collect_numerical_warnings() - cautions = self._collect_numerical_cautions() + jac, nlp = get_jacobian(self.config.model, scaled=False) + + warnings, next_steps = self._collect_numerical_warnings(jac=jac, nlp=nlp) + cautions = self._collect_numerical_cautions(jac=jac, nlp=nlp) + # TODO: Condition number + stats = [] + stats.append( + f"Jacobian Condition Number: {jacobian_cond(jac=jac, scaled=False)}" + ) + _write_report_section( + stream=stream, lines_list=stats, title="Model Statistics", header="=" + ) _write_report_section( stream=stream, lines_list=warnings, title=f"{len(warnings)} WARNINGS", line_if_empty="No warnings found!", - header="=", ) _write_report_section( stream=stream, diff --git a/idaes/core/util/scaling.py b/idaes/core/util/scaling.py index 6fad146bb8..31ee50d005 100644 --- a/idaes/core/util/scaling.py +++ b/idaes/core/util/scaling.py @@ -866,7 +866,7 @@ def jacobian_cond(m=None, scaled=True, order=None, pinv=False, jac=None): (float) Condition number """ if jac is None: - jac, nlp = get_jacobian(m, scaled) # pylint: disable=unused-variable + jac, _ = get_jacobian(m, scaled) jac = jac.tocsc() if jac.shape[0] != jac.shape[1] and not pinv: _log.warning("Nonsquare Jacobian using pseudo inverse") diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 927ad4fe56..b9ca8d3020 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -859,6 +859,7 @@ def test_display_extreme_jacobians_entries(self): The following variable(s) and constraints(s) are associated with extreme Jacobian values: + c2, v3: 1e-08 c3, v1: 100000000.0 c3, v2: 10000000000.0 c3, v3: 1e-06 @@ -896,7 +897,7 @@ def test_collect_structural_warnings_underconstrained(self, model): assert "WARNING: 1 Degree of Freedom" in warnings assert ( """WARNING: Structural singularity found - Under-Constrained Set: 1 variables, 1 constraints + Under-Constrained Set: 3 variables, 2 constraints Over-Constrained Set: 0 variables, 0 constraints""" in warnings ) @@ -926,7 +927,7 @@ def test_collect_structural_warnings_overconstrained(self, model): assert ( """WARNING: Structural singularity found Under-Constrained Set: 0 variables, 0 constraints - Over-Constrained Set: 1 variables, 1 constraints""" + Over-Constrained Set: 1 variables, 2 constraints""" in warnings ) @@ -990,9 +991,9 @@ def test_collect_numerical_warnings_jacobian(self): dt = DiagnosticsToolbox(model=model) warnings, next_steps = dt._collect_numerical_warnings() - + print(warnings) assert len(warnings) == 3 - assert "WARNING: 3 Variables with extreme Jacobian values" in warnings + assert "WARNING: 2 Variables with extreme Jacobian values" in warnings assert "WARNING: 1 Constraint with extreme Jacobian values" in warnings assert "WARNING: 1 Constraint with large residuals" in warnings @@ -1006,7 +1007,7 @@ def test_collect_numerical_cautions(self, model): dt = DiagnosticsToolbox(model=model.b) cautions = dt._collect_numerical_cautions() - + print(cautions) assert len(cautions) == 5 assert "Caution: 3 Variables with value close to their bounds" in cautions assert "Caution: 2 Variables with value close to zero" in cautions @@ -1014,6 +1015,27 @@ def test_collect_numerical_cautions(self, model): assert "Caution: 1 extreme Jacobian Entry" in cautions assert "Caution: 1 Variable with extreme value" in cautions + @pytest.mark.component + def test_collect_numerical_cautions_jacobian(self): + model = ConcreteModel() + model.v1 = Var(initialize=1e-8) + model.v2 = Var(initialize=0) + model.v3 = Var(initialize=0) + + model.c1 = Constraint(expr=model.v1 == model.v2) + model.c2 = Constraint(expr=model.v1 == 1e-8 * model.v3) + model.c3 = Constraint(expr=1e8 * model.v1 + 1e10 * model.v2 == 1e-6 * model.v3) + + dt = DiagnosticsToolbox(model=model) + + cautions = dt._collect_numerical_cautions() + print(cautions) + assert len(cautions) == 4 + assert "Caution: 3 Variables with value close to zero" in cautions + assert "Caution: 3 Variables with extreme Jacobian values" in cautions + assert "Caution: 1 Constraint with extreme Jacobian values" in cautions + assert "Caution: 4 extreme Jacobian Entries" in cautions + @pytest.mark.component def test_assert_no_structural_warnings(self, model): m = model.clone() @@ -1094,6 +1116,11 @@ def test_report_numerical_issues(self, model): dt.report_numerical_issues(stream) expected = """==================================================================================== +Model Statistics + + Jacobian Condition Number: 17.0 + +------------------------------------------------------------------------------------ 2 WARNINGS WARNING: 1 Constraint with large residuals @@ -1114,6 +1141,54 @@ def test_report_numerical_issues(self, model): display_constraints_with_large_residuals() display_variables_at_or_outside_bounds() +==================================================================================== +""" + + assert stream.getvalue() == expected + + @pytest.mark.component + def test_report_numerical_issues_jacobian(self): + model = ConcreteModel() + model.v1 = Var(initialize=1e-8) + model.v2 = Var(initialize=0) + model.v3 = Var(initialize=0) + + model.c1 = Constraint(expr=model.v1 == model.v2) + model.c2 = Constraint(expr=model.v1 == 1e-8 * model.v3) + model.c3 = Constraint(expr=1e8 * model.v1 + 1e10 * model.v2 == 1e-6 * model.v3) + + dt = DiagnosticsToolbox(model=model) + + stream = StringIO() + dt.report_numerical_issues(stream) + + expected = """==================================================================================== +Model Statistics + + Jacobian Condition Number: 1.4073002942618775e+18 + +------------------------------------------------------------------------------------ +3 WARNINGS + + WARNING: 1 Constraint with large residuals + WARNING: 2 Variables with extreme Jacobian values + WARNING: 1 Constraint with extreme Jacobian values + +------------------------------------------------------------------------------------ +4 Cautions + + Caution: 3 Variables with value close to zero + Caution: 3 Variables with extreme Jacobian values + Caution: 1 Constraint with extreme Jacobian values + Caution: 4 extreme Jacobian Entries + +------------------------------------------------------------------------------------ +Suggested next steps: + + display_constraints_with_large_residuals() + display_variables_with_extreme_jacobians() + display_constraints_with_extreme_jacobians() + ==================================================================================== """ From 126ef568ac618afccbe8cf93914da13a49adc048 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 14 Aug 2023 12:13:52 -0400 Subject: [PATCH 32/48] Some clean up before doing docs --- idaes/core/util/model_diagnostics.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 1c9c34cfa0..85330edf7d 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -78,7 +78,7 @@ MAX_STR_LENGTH = 84 TAB = " " * 4 -# TODO: Add suggested steps to cautions +# TODO: Add suggested steps to cautions - how? CONFIG = ConfigDict() CONFIG.declare("model", ConfigValue(description="Pyomo model object to be diagnosed.")) @@ -553,6 +553,7 @@ def display_variables_with_extreme_jacobians(self, stream=stdout): f"{i[1].name}: {i[0]}" for i in extreme_jacobian_columns( m=self.config.model, + scaled=False, large=self.config.jacobian_large_value_caution, small=self.config.jacobian_small_value_caution, ) @@ -582,6 +583,7 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): f"{i[1].name}: {i[0]}" for i in extreme_jacobian_rows( m=self.config.model, + scaled=False, large=self.config.jacobian_large_value_caution, small=self.config.jacobian_small_value_caution, ) @@ -612,6 +614,7 @@ def display_extreme_jacobians_entries(self, stream=stdout): f"{i[1].name}, {i[2].name}: {i[0]}" for i in extreme_jacobian_entries( m=self.config.model, + scaled=False, large=self.config.jacobian_large_value_caution, small=self.config.jacobian_small_value_caution, zero=0, @@ -966,7 +969,6 @@ def report_numerical_issues(self, stream=stdout): warnings, next_steps = self._collect_numerical_warnings(jac=jac, nlp=nlp) cautions = self._collect_numerical_cautions(jac=jac, nlp=nlp) - # TODO: Condition number stats = [] stats.append( f"Jacobian Condition Number: {jacobian_cond(jac=jac, scaled=False)}" From aab2acc7250442fb04643cccb501c6673b8b2bb2 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 14 Aug 2023 16:54:27 -0400 Subject: [PATCH 33/48] Adding initial docs for diagnostics toolbox --- docs/explanations/index.rst | 1 + .../diagnostics_workflow.png | Bin 0 -> 52420 bytes docs/explanations/model_diagnostics/index.rst | 45 ++++++++++++++++++ .../modeling_extensions/diagnostics/index.rst | 8 +++- .../util/diagnostics/degeneracy_hunter.rst | 12 +++++ .../util/diagnostics/diagnostics_toolbox.rst | 7 +++ .../core/util/model_diagnostics.rst | 20 ++++---- 7 files changed, 80 insertions(+), 13 deletions(-) create mode 100755 docs/explanations/model_diagnostics/diagnostics_workflow.png create mode 100644 docs/explanations/model_diagnostics/index.rst create mode 100644 docs/reference_guides/core/util/diagnostics/degeneracy_hunter.rst create mode 100644 docs/reference_guides/core/util/diagnostics/diagnostics_toolbox.rst diff --git a/docs/explanations/index.rst b/docs/explanations/index.rst index f41dd5e6b7..d243f51371 100644 --- a/docs/explanations/index.rst +++ b/docs/explanations/index.rst @@ -8,6 +8,7 @@ Explanations concepts components/index conventions + model_diagnostics/index modeling_extensions/index related_packages/index faq diff --git a/docs/explanations/model_diagnostics/diagnostics_workflow.png b/docs/explanations/model_diagnostics/diagnostics_workflow.png new file mode 100755 index 0000000000000000000000000000000000000000..8019d49abe19cd24ccd7fae358d89c9fb2d12f51 GIT binary patch literal 52420 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
54wHs4XYIc|IkJ-A2f1=H9G0U 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 instead provides a centralized interface for accessing diagnostics tools and guiding users through the diagnostics workflow. High level methods are provided for each of the model diagnosis steps (green boxes) in the workflow above which will provide a summary of any issues identified, and these will recommend additional methods and tools to use to further examine each issue identified. + +Modeling Log Book +----------------- + +Model development and diagnostics is inherently an iterative process, and often you will try one approach to resolve an issue only to find it made things worse or that you can no longer reproduce an import result from a previous model. Due to this, users are strongly encouraged to maintain a "modeling log book" which records their model development activities. This log book should record each change made to the model and why, along with a Git hash (or equivalent version control marker) and a record on any important results that were generated with the current version of the model. This log book will prove to be invaluable when (and not if) you need to revert to an older version of the model to undo some unsuccessful changes or to work out why a previous result can no longer be reproduced. + +Handling Critical Solver Failures +--------------------------------- + +TBA diff --git a/docs/explanations/modeling_extensions/diagnostics/index.rst b/docs/explanations/modeling_extensions/diagnostics/index.rst index 4f40d9694a..b85ab07c88 100644 --- a/docs/explanations/modeling_extensions/diagnostics/index.rst +++ b/docs/explanations/modeling_extensions/diagnostics/index.rst @@ -2,8 +2,14 @@ Degeneracy Hunter ================================== +.. note:: + + v2.2: The Degeneracy Hunter tool is being deprecated in favor of the newer Diagnostics Toolbox. + + Over the next few releases the functionality of Degeneracy Hunter will be moved over to the new Diagnostics Toolbox which will also contain a number of other tools for diagnosing model issues. + Degeneracy Hunter is a collection of tools for diagnostics of mathematical programs. * `Core ideas (conference paper) `_ * `Example notebook `_ -* :ref:`Full documentation` \ No newline at end of file +* :ref:`Full documentation` diff --git a/docs/reference_guides/core/util/diagnostics/degeneracy_hunter.rst b/docs/reference_guides/core/util/diagnostics/degeneracy_hunter.rst new file mode 100644 index 0000000000..c5ecf06d13 --- /dev/null +++ b/docs/reference_guides/core/util/diagnostics/degeneracy_hunter.rst @@ -0,0 +1,12 @@ +Degeneracy Hunter +================= + +.. note:: + + v2.2: The Degeneracy Hunter tool is being deprecated in favor of the newer Diagnostics Toolbox. + + Over the next few releases the functionality of Degeneracy Hunter will be moved over to the new Diagnostics Toolbox which will also contain a number of other tools for diagnosing model issues. + +.. autoclass:: idaes.core.util.model_diagnostics.DegeneracyHunter + :members: + diff --git a/docs/reference_guides/core/util/diagnostics/diagnostics_toolbox.rst b/docs/reference_guides/core/util/diagnostics/diagnostics_toolbox.rst new file mode 100644 index 0000000000..af22a493b3 --- /dev/null +++ b/docs/reference_guides/core/util/diagnostics/diagnostics_toolbox.rst @@ -0,0 +1,7 @@ +Diagnostics Toolbox +=================== + +The IDAES Diagnostics Toolbox is intended to provide a comprehensive collection of tools for identifying potential modeling issues, and guiding the user through the model diagnosis workflow. For more in-depth discussion of the model diagnosis workflow see the :ref:`Model Diagnostics Workflow` documentation. + +.. autoclass:: idaes.core.util.model_diagnostics.DiagnosticsToolbox + :members: diff --git a/docs/reference_guides/core/util/model_diagnostics.rst b/docs/reference_guides/core/util/model_diagnostics.rst index 379f1ee445..2ede432576 100644 --- a/docs/reference_guides/core/util/model_diagnostics.rst +++ b/docs/reference_guides/core/util/model_diagnostics.rst @@ -3,20 +3,16 @@ Model Diagnostic Functions The IDAES toolset contains a number of utility functions which can be useful for identifying modeling issues and debugging solver issues. -.. contents:: Contents - :depth: 2 +.. toctree:: + :maxdepth: 1 + + diagnostics/diagnostics_toolbox + diagnostics/degeneracy_hunter -Degeneracy Hunter -^^^^^^^^^^^^^^^^^ - -.. autoclass:: idaes.core.util.model_diagnostics.DegeneracyHunter - :members: - - -Available Methods -^^^^^^^^^^^^^^^^^ +Other Methods +^^^^^^^^^^^^^ .. automodule:: idaes.core.util.model_diagnostics - :exclude-members: DegeneracyHunter + :exclude-members: DegeneracyHunter, DiagnosticsToolbox :members: From 8faa222a8af523f08d63193915085bc08b54cea7 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 14 Aug 2023 16:58:32 -0400 Subject: [PATCH 34/48] Fixing typo in docs --- docs/explanations/model_diagnostics/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/explanations/model_diagnostics/index.rst b/docs/explanations/model_diagnostics/index.rst index c2a7d0624f..f662113731 100644 --- a/docs/explanations/model_diagnostics/index.rst +++ b/docs/explanations/model_diagnostics/index.rst @@ -32,7 +32,7 @@ If you are still having trouble solving your model after running the structural Diagnostics Toolbox ------------------- -Whilst the workflow outlined above gives a high-level overview of the model development and diagnostics process, there is a lot of detail buried in each fo the steps. Rather than provide the user with a long series of steps and check-boxes to complete, the :ref:`IDAES Diagnostics Toolbox` instead provides a centralized interface for accessing diagnostics tools and guiding users through the diagnostics workflow. High level methods are provided for each of the model diagnosis steps (green boxes) in the workflow above which will provide a summary of any issues identified, and these will recommend additional methods and tools to use to further examine each issue identified. +Whilst the workflow outlined above gives a high-level overview of the model development and diagnostics process, there is a lot of detail buried in each of the steps. Rather than provide the user with a long series of steps and check-boxes to complete, the :ref:`IDAES Diagnostics Toolbox` instead provides a centralized interface for accessing diagnostics tools and guiding users through the diagnostics workflow. High level methods are provided for each of the model diagnosis steps (green boxes) in the workflow above which will provide a summary of any issues identified, and these will recommend additional methods and tools to use to further examine each issue identified. Modeling Log Book ----------------- From 749f6feecd5d290872b21e9c2b71d52e90c78935 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 17 Aug 2023 17:07:48 -0400 Subject: [PATCH 35/48] Fixing typo and condition number formatting --- idaes/core/util/model_diagnostics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 85330edf7d..75f175e3cd 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -593,7 +593,7 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): footer="=", ) - def display_extreme_jacobians_entries(self, stream=stdout): + def display_extreme_jacobian_entries(self, stream=stdout): """ Prints variables and constraints associated with entries in the Jacobian with extreme values. This can be indicative of poor scaling, especially for isolated terms (e.g. @@ -971,7 +971,7 @@ def report_numerical_issues(self, stream=stdout): stats = [] stats.append( - f"Jacobian Condition Number: {jacobian_cond(jac=jac, scaled=False)}" + f"Jacobian Condition Number: {jacobian_cond(jac=jac, scaled=False):.3E}" ) _write_report_section( stream=stream, lines_list=stats, title="Model Statistics", header="=" From 8a635598cda4bffe33461253e3ab090d6e6aa682 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 18 Aug 2023 09:22:43 -0400 Subject: [PATCH 36/48] Fixing typos in tests --- idaes/core/util/tests/test_model_diagnostics.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index b9ca8d3020..8644654181 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -840,7 +840,7 @@ def test_display_constraints_with_extreme_jacobians(self): assert stream.getvalue() == expected @pytest.mark.component - def test_display_extreme_jacobians_entries(self): + def test_display_extreme_jacobian_entries(self): model = ConcreteModel() model.v1 = Var(initialize=1e-8) model.v2 = Var() @@ -853,7 +853,7 @@ def test_display_extreme_jacobians_entries(self): dt = DiagnosticsToolbox(model=model) stream = StringIO() - dt.display_extreme_jacobians_entries(stream) + dt.display_extreme_jacobian_entries(stream) expected = """==================================================================================== The following variable(s) and constraints(s) are associated with extreme Jacobian @@ -1118,7 +1118,7 @@ def test_report_numerical_issues(self, model): expected = """==================================================================================== Model Statistics - Jacobian Condition Number: 17.0 + Jacobian Condition Number: 1.700E+01 ------------------------------------------------------------------------------------ 2 WARNINGS @@ -1143,7 +1143,7 @@ def test_report_numerical_issues(self, model): ==================================================================================== """ - + print(stream.getvalue()) assert stream.getvalue() == expected @pytest.mark.component @@ -1165,7 +1165,7 @@ def test_report_numerical_issues_jacobian(self): expected = """==================================================================================== Model Statistics - Jacobian Condition Number: 1.4073002942618775e+18 + Jacobian Condition Number: 1.407E+18 ------------------------------------------------------------------------------------ 3 WARNINGS @@ -1191,7 +1191,7 @@ def test_report_numerical_issues_jacobian(self): ==================================================================================== """ - + print(stream.getvalue()) assert stream.getvalue() == expected From 8fe7b7020f61f512eee9178b03ed08cf3bc3dbf2 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 18 Aug 2023 11:53:05 -0400 Subject: [PATCH 37/48] Fixing ordering of terms --- idaes/core/util/model_diagnostics.py | 2 +- idaes/core/util/tests/test_model_diagnostics.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 75f175e3cd..597b4a406c 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -620,7 +620,7 @@ def display_extreme_jacobian_entries(self, stream=stdout): zero=0, ) ], - title="The following variable(s) and constraints(s) are associated with extreme Jacobian\nvalues:", + title="The following constraints(s) and variable(s) are associated with extreme Jacobian\nvalues:", header="=", footer="=", ) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 8644654181..7e9f0b94a4 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -856,7 +856,7 @@ def test_display_extreme_jacobian_entries(self): dt.display_extreme_jacobian_entries(stream) expected = """==================================================================================== -The following variable(s) and constraints(s) are associated with extreme Jacobian +The following constraints(s) and variable(s) are associated with extreme Jacobian values: c2, v3: 1e-08 From 4cb0e265cb84ebd0453f28259a7cf00a315cabdf Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 22 Aug 2023 11:07:16 -0400 Subject: [PATCH 38/48] Addressing some comments --- docs/explanations/model_diagnostics/index.rst | 18 +++++++ docs/index.rst | 1 + idaes/core/util/model_diagnostics.py | 50 ++++++++++--------- .../core/util/tests/test_model_diagnostics.py | 4 +- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/docs/explanations/model_diagnostics/index.rst b/docs/explanations/model_diagnostics/index.rst index f662113731..20480f8f7b 100644 --- a/docs/explanations/model_diagnostics/index.rst +++ b/docs/explanations/model_diagnostics/index.rst @@ -17,16 +17,34 @@ The diagram below shows a high-level overview of the model development and diagn .. image:: diagnostics_workflow.png +Chose a Model to Debug +"""""""""""""""""""""" + As shown above, all model development begins with a model and the IDAES team recommends starting with the simplest possible model you can. It is always easier to debug small changes, so users should apply this workflow from the very beginning of model development starting with the simplest possible representation of their system (e.g., a single unit model or set of material balances). At each step of the process (i.e., each change or new constraint), you should check to ensure that your model is well-posed and that it solves robustly before making additional changes. In this way, it will be clear where to start looking for new issues as they arise, as they will be related in some way to the change you just made. +Start with a Square Model +""""""""""""""""""""""""" + Next, you should ensure you model has zero degrees of freedom (as best you can); whilst your ultimate goal may be to some an optimization problem with degrees of freedom, you should always start from a square model first. Firstly, this is because many of the model diagnosis tools work best with square models. Secondly, all models are based on the foundation of a square model; an optimization problem is just a square model with some degrees of freedom added. If your underlying square model is not well-posed then any more advanced problem you solve based on it is fundamentally flawed (even if it solves). +Check for Structural Issues +""""""""""""""""""""""""""" + Once you have a square model, the next step is to check to see if there are any issues with the structure of the model; for example structural singularities or unit consistency issues. As these issues exist in the very structure of the model, it is possible to check for these before calling a solver and in doing so make it more likely that you will be able to successfully solve the model. Any issues identified at this stage should be resolved before trying to move forwards. +Try to Solve Model +"""""""""""""""""" + Once you have ensured there are no structural issues with your model, the next step is to try to solve your model (or initialize it if you haven't already) using the solver of your choice (you should also experiment with different solvers if needed). Hopefully you will get some solution back from the solver, even if it is not optimal and/or feasible (in cases where you get critical solver failures, see the section later in this documentation). +Check for Numerical Issues +"""""""""""""""""""""""""" + Once you have at least a partial solution to your model, the next step is to check for numerical issues in your model, such as possible bounds violations and poor scaling. Due to their nature, numerical issues depend on having a solution to the model, and they can often be limited to certain model states (i.e., it is possible to have a model which behaves well at one model state only to fail badly if you change your model state). As such, numerical checks should be performed at a number of points across the full range of expected states to try to ensure that the model is well-posed across the full modeling range. Any issues that are identified here should be addressed before moving on, and remember that after any changes you should always start by checking for structural issues again. +Apply Advanced Diagnostics Tools (if required) +"""""""""""""""""""""""""""""""""""""""""""""" + If you are still having trouble solving your model after running the structural and numerical checks, then you will need to look to more advanced (and computationally expensive) tools to try and resolve your issues. Finally, once you are satisfied that your model is robust and well behaved, you can move on to solving more complex problems. Diagnostics Toolbox diff --git a/docs/index.rst b/docs/index.rst index f32ebade8d..c547931882 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,6 +33,7 @@ See also :doc:`IDAES concepts ` in this documentation. | :doc:`Concepts ` | :doc:`Components of IDAES ` | :doc:`Conventions ` + | :doc:`Model Diagnostics Workflow ` | :doc:`Modeling Extensions ` | :doc:`Related Packages ` | :doc:`FAQ ` diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 597b4a406c..7de786e62d 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -83,7 +83,7 @@ CONFIG = ConfigDict() CONFIG.declare("model", ConfigValue(description="Pyomo model object to be diagnosed.")) CONFIG.declare( - "absolute_tolerance", + "variable_bounds_absolute_tolerance", ConfigValue( default=1e-4, domain=float, @@ -91,7 +91,7 @@ ), ) CONFIG.declare( - "relative_tolerance", + "variable_bounds_relative_tolerance", ConfigValue( default=1e-4, domain=float, @@ -99,7 +99,7 @@ ), ) CONFIG.declare( - "residual_tolerance", + "constraint_residual_tolerance", ConfigValue( default=1e-5, domain=float, @@ -107,7 +107,7 @@ ), ) CONFIG.declare( - "large_value_tolerance", + "variable_large_value_tolerance", ConfigValue( default=1e4, domain=float, @@ -115,7 +115,7 @@ ), ) CONFIG.declare( - "small_value_tolerance", + "variable_small_value_tolerance", ConfigValue( default=1e-4, domain=float, @@ -123,7 +123,7 @@ ), ) CONFIG.declare( - "zero_value_tolerance", + "variable_zero_value_tolerance", ConfigValue( default=1e-8, domain=float, @@ -177,7 +177,7 @@ class DiagnosticsToolbox: advanced model. 3. Create an instance of the DiagnosticsToolbox and provide the model to debug as the model argument. - 4. Call the report_structural_issues() method. + 4. Call the ``report_structural_issues()`` method. Model diagnostics is an iterative process and you will likely need to run these tools multiple times to resolve all issues. After making a change to your model, @@ -333,7 +333,7 @@ def display_variables_with_value_near_zero(self, stream=stdout): lines_list=[ f"{v.name}: value={value(v)}" for v in _vars_near_zero( - self.config.model, self.config.zero_value_tolerance + self.config.model, self.config.variable_zero_value_tolerance ) ], title="The following variable(s) have a value close to zero:", @@ -360,9 +360,9 @@ def display_variables_with_extreme_values(self, stream=stdout): f"{i.name}: {value(i)}" for i in _vars_with_extreme_values( model=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, - zero=self.config.zero_value_tolerance, + large=self.config.variable_large_value_tolerance, + small=self.config.variable_small_value_tolerance, + zero=self.config.variable_zero_value_tolerance, ) ], title="The following variable(s) have extreme values:", @@ -388,8 +388,8 @@ def display_variables_near_bounds(self, stream=stdout): f"{v.name}: value={value(v)} bounds={v.bounds}" for v in variables_near_bounds_set( self.config.model, - abs_tol=self.config.absolute_tolerance, - rel_tol=self.config.relative_tolerance, + abs_tol=self.config.variable_bounds_absolute_tolerance, + rel_tol=self.config.variable_bounds_relative_tolerance, ) ], title="The following variable(s) have values close to their bounds:", @@ -434,7 +434,7 @@ def display_constraints_with_large_residuals(self, stream=stdout): _write_report_section( stream=stream, lines_list=large_residuals_set( - self.config.model, tol=self.config.residual_tolerance + self.config.model, tol=self.config.constraint_residual_tolerance ), title="The following constraint(s) have large residuals:", header="=", @@ -453,7 +453,7 @@ def get_dulmage_mendelsohn_partition(self): list-of-lists constraints in each independent block of the over-constrained set """ - igraph = IncidenceGraphInterface(self.config.model) + igraph = IncidenceGraphInterface(self.config.model, include_inequality=False) var_dm_partition, con_dm_partition = igraph.dulmage_mendelsohn() # Collect under- and order-constrained sub-system @@ -720,7 +720,7 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): # Large residuals large_residuals = large_residuals_set( - self.config.model, tol=self.config.residual_tolerance + self.config.model, tol=self.config.constraint_residual_tolerance ) if len(large_residuals) > 0: cstring = "Constraints" @@ -791,8 +791,8 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): # Variables near bounds near_bounds = variables_near_bounds_set( self.config.model, - abs_tol=self.config.absolute_tolerance, - rel_tol=self.config.relative_tolerance, + abs_tol=self.config.variable_bounds_absolute_tolerance, + rel_tol=self.config.variable_bounds_relative_tolerance, ) if len(near_bounds) > 0: cstring = "Variables" @@ -803,7 +803,9 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): ) # Variables near zero - near_zero = _vars_near_zero(self.config.model, self.config.zero_value_tolerance) + near_zero = _vars_near_zero( + self.config.model, self.config.variable_zero_value_tolerance + ) if len(near_zero) > 0: cstring = "Variables" if len(near_zero) == 1: @@ -815,9 +817,9 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): # Variables with extreme values xval = _vars_with_extreme_values( model=self.config.model, - large=self.config.large_value_tolerance, - small=self.config.small_value_tolerance, - zero=self.config.zero_value_tolerance, + large=self.config.variable_large_value_tolerance, + small=self.config.variable_small_value_tolerance, + zero=self.config.variable_zero_value_tolerance, ) if len(xval) > 0: cstring = "Variables" @@ -1837,11 +1839,11 @@ def _vars_fixed_to_zero(model): return zero_vars -def _vars_near_zero(model, zero_value_tolerance): +def _vars_near_zero(model, variable_zero_value_tolerance): # Set of variables with values close to 0 near_zero_vars = ComponentSet() for v in model.component_data_objects(Var, descend_into=True): - if v.value is not None and abs(value(v)) <= zero_value_tolerance: + if v.value is not None and abs(value(v)) <= variable_zero_value_tolerance: near_zero_vars.add(v) return near_zero_vars diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 7e9f0b94a4..4c6b5b9024 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -103,13 +103,13 @@ def test_vars_fixed_to_zero(model): def test_vars_near_zero(model): model.v3.set_value(1e-5) - near_zero_vars = _vars_near_zero(model, zero_value_tolerance=1e-5) + near_zero_vars = _vars_near_zero(model, variable_zero_value_tolerance=1e-5) assert isinstance(near_zero_vars, ComponentSet) assert len(near_zero_vars) == 2 for i in near_zero_vars: assert i.local_name in ["v1", "v3"] - near_zero_vars = _vars_near_zero(model, zero_value_tolerance=1e-6) + near_zero_vars = _vars_near_zero(model, variable_zero_value_tolerance=1e-6) assert isinstance(near_zero_vars, ComponentSet) assert len(near_zero_vars) == 1 for i in near_zero_vars: From 312f88a4e53399f631b307e0621161662e386c70 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 22 Aug 2023 13:38:19 -0400 Subject: [PATCH 39/48] Addressing second round of comments --- idaes/core/util/model_diagnostics.py | 118 ++++++++++++------ .../core/util/tests/test_model_diagnostics.py | 41 +++++- 2 files changed, 122 insertions(+), 37 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 7de786e62d..6082345561 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -81,7 +81,6 @@ # TODO: Add suggested steps to cautions - how? CONFIG = ConfigDict() -CONFIG.declare("model", ConfigValue(description="Pyomo model object to be diagnosed.")) CONFIG.declare( "variable_bounds_absolute_tolerance", ConfigValue( @@ -98,6 +97,17 @@ description="Relative tolerance to for variables close to bounds.", ), ) +CONFIG.declare( + "variable_bounds_violation_tolerance", + ConfigValue( + default=0, + domain=float, + description="Absolute tolerance to for considering a variable to violate its bounds.", + doc="Absolute tolerance to for considering a variable to violate its bounds. " + "Some solvers relax bounds on variables thus allowing a small violation to be " + "considered acceptable.", + ), +) CONFIG.declare( "constraint_residual_tolerance", ConfigValue( @@ -203,12 +213,43 @@ class DiagnosticsToolbox: get further information on warnings. If no warnings are found, this will suggest the next report method to call. + Args: + model - model to be diagnosed. The DiagnosticsToolbox does not support indexed Blocks. + """ - def __init__(self, **kwargs): + def __init__(self, model: _BlockData, **kwargs): + self.set_model(model) self.config = CONFIG(kwargs) - if not isinstance(self.config.model, Block): - raise TypeError("model argument must be an instance of a Pyomo Block.") + + @property + def model(self): + """ + Model currently being diagnosed. + """ + return self._model + + def set_model(self, model): + """ + Changes the models currently being diagnosed. + + Args: + model: new model to be diagnosed. Must be an scalar Block or an element of + an indexed Block. + + Returns: + None + + """ + # TODO: In future may want to generalise this to accept indexed blocks + # However, for now some of the tools do not support indexed blocks + if not isinstance(model, _BlockData): + raise TypeError( + "model argument must be an instance of an Pyomo BlockData object " + "(either a scalar Block or an element of an indexed Block)." + ) + + self._model = model def display_external_variables(self, stream=stdout): """ @@ -223,8 +264,8 @@ def display_external_variables(self, stream=stdout): """ ext_vars = [] - for v in variables_in_activated_constraints_set(self.config.model): - if not _var_in_block(v, self.config.model): + for v in variables_in_activated_constraints_set(self._model): + if not _var_in_block(v, self._model): ext_vars.append(v.name) _write_report_section( @@ -248,7 +289,7 @@ def display_unused_variables(self, stream=stdout): """ _write_report_section( stream=stream, - lines_list=variables_not_in_activated_constraints_set(self.config.model), + lines_list=variables_not_in_activated_constraints_set(self._model), title="The following variable(s) do not appear in any activated constraints within the model:", header="=", footer="=", @@ -267,7 +308,7 @@ def display_variables_fixed_to_zero(self, stream=stdout): """ _write_report_section( stream=stream, - lines_list=_vars_fixed_to_zero(self.config.model), + lines_list=_vars_fixed_to_zero(self._model), title="The following variable(s) are fixed to zero:", header="=", footer="=", @@ -289,7 +330,10 @@ def display_variables_at_or_outside_bounds(self, stream=stdout): stream=stream, lines_list=[ f"{v.name} ({'fixed' if v.fixed else 'free'}): value={value(v)} bounds={v.bounds}" - for v in _vars_violating_bounds(self.config.model) + for v in _vars_violating_bounds( + self._model, + tolerance=self.config.variable_bounds_violation_tolerance, + ) ], title="The following variable(s) have values at or outside their bounds:", header="=", @@ -309,7 +353,7 @@ def display_variables_with_none_value(self, stream=stdout): """ _write_report_section( stream=stream, - lines_list=_vars_with_none_value(self.config.model), + lines_list=_vars_with_none_value(self._model), title="The following variable(s) have a value of None:", header="=", footer="=", @@ -333,7 +377,7 @@ def display_variables_with_value_near_zero(self, stream=stdout): lines_list=[ f"{v.name}: value={value(v)}" for v in _vars_near_zero( - self.config.model, self.config.variable_zero_value_tolerance + self._model, self.config.variable_zero_value_tolerance ) ], title="The following variable(s) have a value close to zero:", @@ -359,7 +403,7 @@ def display_variables_with_extreme_values(self, stream=stdout): lines_list=[ f"{i.name}: {value(i)}" for i in _vars_with_extreme_values( - model=self.config.model, + model=self._model, large=self.config.variable_large_value_tolerance, small=self.config.variable_small_value_tolerance, zero=self.config.variable_zero_value_tolerance, @@ -387,7 +431,7 @@ def display_variables_near_bounds(self, stream=stdout): lines_list=[ f"{v.name}: value={value(v)} bounds={v.bounds}" for v in variables_near_bounds_set( - self.config.model, + self._model, abs_tol=self.config.variable_bounds_absolute_tolerance, rel_tol=self.config.variable_bounds_relative_tolerance, ) @@ -411,7 +455,7 @@ def display_components_with_inconsistent_units(self, stream=stdout): """ _write_report_section( stream=stream, - lines_list=identify_inconsistent_units(self.config.model), + lines_list=identify_inconsistent_units(self._model), title="The following component(s) have unit consistency issues:", end_line="For more details on unit inconsistencies, import the " "assert_units_consistent method\nfrom pyomo.util.check_units", @@ -434,7 +478,7 @@ def display_constraints_with_large_residuals(self, stream=stdout): _write_report_section( stream=stream, lines_list=large_residuals_set( - self.config.model, tol=self.config.constraint_residual_tolerance + self._model, tol=self.config.constraint_residual_tolerance ), title="The following constraint(s) have large residuals:", header="=", @@ -453,7 +497,7 @@ def get_dulmage_mendelsohn_partition(self): list-of-lists constraints in each independent block of the over-constrained set """ - igraph = IncidenceGraphInterface(self.config.model, include_inequality=False) + igraph = IncidenceGraphInterface(self._model, include_inequality=False) var_dm_partition, con_dm_partition = igraph.dulmage_mendelsohn() # Collect under- and order-constrained sub-system @@ -552,7 +596,7 @@ def display_variables_with_extreme_jacobians(self, stream=stdout): lines_list=[ f"{i[1].name}: {i[0]}" for i in extreme_jacobian_columns( - m=self.config.model, + m=self._model, scaled=False, large=self.config.jacobian_large_value_caution, small=self.config.jacobian_small_value_caution, @@ -582,7 +626,7 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): lines_list=[ f"{i[1].name}: {i[0]}" for i in extreme_jacobian_rows( - m=self.config.model, + m=self._model, scaled=False, large=self.config.jacobian_large_value_caution, small=self.config.jacobian_small_value_caution, @@ -613,7 +657,7 @@ def display_extreme_jacobian_entries(self, stream=stdout): lines_list=[ f"{i[1].name}, {i[2].name}: {i[0]}" for i in extreme_jacobian_entries( - m=self.config.model, + m=self._model, scaled=False, large=self.config.jacobian_large_value_caution, small=self.config.jacobian_small_value_caution, @@ -637,13 +681,13 @@ def _collect_structural_warnings(self): next_steps - list of suggested next steps to further investigate warnings """ - uc = identify_inconsistent_units(self.config.model) + uc = identify_inconsistent_units(self._model) uc_var, uc_con, oc_var, oc_con = self.get_dulmage_mendelsohn_partition() # Collect warnings warnings = [] next_steps = [] - dof = degrees_of_freedom(self.config.model) + dof = degrees_of_freedom(self._model) if dof != 0: dstring = "Degrees" if abs(dof) == 1: @@ -681,13 +725,13 @@ def _collect_structural_cautions(self): """ # Collect cautions cautions = [] - zero_vars = _vars_fixed_to_zero(self.config.model) + zero_vars = _vars_fixed_to_zero(self._model) if len(zero_vars) > 0: vstring = "variables" if len(zero_vars) == 1: vstring = "variable" cautions.append(f"Caution: {len(zero_vars)} {vstring} fixed to 0") - unused_vars = variables_not_in_activated_constraints_set(self.config.model) + unused_vars = variables_not_in_activated_constraints_set(self._model) unused_vars_fixed = 0 for v in unused_vars: if v.fixed: @@ -713,14 +757,14 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): """ if jac is None or nlp is None: - jac, nlp = get_jacobian(self.config.model, scaled=False) + jac, nlp = get_jacobian(self._model, scaled=False) warnings = [] next_steps = [] # Large residuals large_residuals = large_residuals_set( - self.config.model, tol=self.config.constraint_residual_tolerance + self._model, tol=self.config.constraint_residual_tolerance ) if len(large_residuals) > 0: cstring = "Constraints" @@ -732,7 +776,9 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): next_steps.append("display_constraints_with_large_residuals()") # Variables outside bounds - violated_bounds = _vars_violating_bounds(self.config.model) + violated_bounds = _vars_violating_bounds( + self._model, tolerance=self.config.variable_bounds_violation_tolerance + ) if len(violated_bounds) > 0: cstring = "Variables" if len(violated_bounds) == 1: @@ -784,13 +830,13 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): """ if jac is None or nlp is None: - jac, nlp = get_jacobian(self.config.model, scaled=False) + jac, nlp = get_jacobian(self._model, scaled=False) cautions = [] # Variables near bounds near_bounds = variables_near_bounds_set( - self.config.model, + self._model, abs_tol=self.config.variable_bounds_absolute_tolerance, rel_tol=self.config.variable_bounds_relative_tolerance, ) @@ -804,7 +850,7 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): # Variables near zero near_zero = _vars_near_zero( - self.config.model, self.config.variable_zero_value_tolerance + self._model, self.config.variable_zero_value_tolerance ) if len(near_zero) > 0: cstring = "Variables" @@ -816,7 +862,7 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): # Variables with extreme values xval = _vars_with_extreme_values( - model=self.config.model, + model=self._model, large=self.config.variable_large_value_tolerance, small=self.config.variable_small_value_tolerance, zero=self.config.variable_zero_value_tolerance, @@ -828,7 +874,7 @@ def _collect_numerical_cautions(self, jac=None, nlp=None): cautions.append(f"Caution: {len(xval)} {cstring} with extreme value") # Variables with value None - none_value = _vars_with_none_value(self.config.model) + none_value = _vars_with_none_value(self._model) if len(none_value) > 0: cstring = "Variables" if len(none_value) == 1: @@ -924,7 +970,7 @@ def report_structural_issues(self, stream=stdout): """ # Potential evaluation errors # TODO: High Index? - stats = _collect_model_statistics(self.config.model) + stats = _collect_model_statistics(self._model) warnings, next_steps = self._collect_structural_warnings() cautions = self._collect_structural_cautions() @@ -966,7 +1012,7 @@ def report_numerical_issues(self, stream=stdout): None """ - jac, nlp = get_jacobian(self.config.model, scaled=False) + jac, nlp = get_jacobian(self._model, scaled=False) warnings, next_steps = self._collect_numerical_warnings(jac=jac, nlp=nlp) cautions = self._collect_numerical_cautions(jac=jac, nlp=nlp) @@ -1848,13 +1894,13 @@ def _vars_near_zero(model, variable_zero_value_tolerance): return near_zero_vars -def _vars_violating_bounds(model): +def _vars_violating_bounds(model, tolerance): violated_bounds = ComponentSet() for v in model.component_data_objects(Var, descend_into=True): if v.value is not None: - if v.lb is not None and v.value <= v.lb: + if v.lb is not None and v.value <= v.lb - tolerance: violated_bounds.add(v) - elif v.ub is not None and v.value >= v.ub: + elif v.ub is not None and v.value >= v.ub + tolerance: violated_bounds.add(v) return violated_bounds diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 4c6b5b9024..63999657d3 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -136,12 +136,19 @@ def test_vars_with_bounds_issues(model): model.v4.setlb(0) model.v4.setub(1) - bounds_issue = _vars_violating_bounds(model) + bounds_issue = _vars_violating_bounds(model, tolerance=0) assert isinstance(bounds_issue, ComponentSet) assert len(bounds_issue) == 2 for i in bounds_issue: assert i.local_name in ["v1", "v4"] + m = ConcreteModel() + m.v = Var(initialize=-1e-8, bounds=(0, 1)) + + bounds_issue = _vars_violating_bounds(m, tolerance=1e-6) + assert isinstance(bounds_issue, ComponentSet) + assert len(bounds_issue) == 0 + @pytest.mark.unit def test_vars_with_extreme_values(): @@ -400,6 +407,38 @@ def test_fixed_variables(self): @pytest.mark.solver class TestDiagnosticsToolbox: + @pytest.mark.unit + def test_set_model(self): + m = ConcreteModel() + m.b = Block() + + dt = DiagnosticsToolbox(model=m) + assert dt.model is m + + dt.set_model(m.b) + assert dt.model is m.b + + @pytest.mark.unit + def test_invalid_model_type(self): + with pytest.raises( + TypeError, + match="model argument must be an instance of an Pyomo BlockData object " + "\(either a scalar Block or an element of an indexed Block\).", + ): + DiagnosticsToolbox(model="foo") + + # Check for indexed Blocks + m = ConcreteModel() + m.s = Set(initialize=[1, 2, 3]) + m.b = Block(m.s) + + with pytest.raises( + TypeError, + match="model argument must be an instance of an Pyomo BlockData object " + "\(either a scalar Block or an element of an indexed Block\).", + ): + DiagnosticsToolbox(model=m.b) + @pytest.fixture(scope="class") def model(self): m = ConcreteModel() From bee8dc64ebb8698e2e7e2e48fdd4ce20ec6904af Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 24 Aug 2023 10:50:23 -0400 Subject: [PATCH 40/48] Sorting extreme Jacobian entries --- idaes/core/util/model_diagnostics.py | 57 ++++++++++--------- .../core/util/tests/test_model_diagnostics.py | 16 +++--- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 6082345561..b9276f50d9 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -24,6 +24,7 @@ from scipy.linalg import svd from scipy.sparse.linalg import svds, norm from scipy.sparse import issparse, find +from math import log from pyomo.environ import ( Binary, @@ -591,17 +592,17 @@ def display_variables_with_extreme_jacobians(self, stream=stdout): None """ + xjc = extreme_jacobian_columns( + m=self._model, + scaled=False, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + ) + xjc.sort(key=lambda i: abs(log(i[0])), reverse=True) + _write_report_section( stream=stream, - lines_list=[ - f"{i[1].name}: {i[0]}" - for i in extreme_jacobian_columns( - m=self._model, - scaled=False, - large=self.config.jacobian_large_value_caution, - small=self.config.jacobian_small_value_caution, - ) - ], + lines_list=[f"{i[1].name}: {i[0]:.3E}" for i in xjc], title="The following variables(s) are associated with extreme Jacobian values:", header="=", footer="=", @@ -621,17 +622,17 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): None """ + xjr = extreme_jacobian_rows( + m=self._model, + scaled=False, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + ) + xjr.sort(key=lambda i: abs(log(i[0])), reverse=True) + _write_report_section( stream=stream, - lines_list=[ - f"{i[1].name}: {i[0]}" - for i in extreme_jacobian_rows( - m=self._model, - scaled=False, - large=self.config.jacobian_large_value_caution, - small=self.config.jacobian_small_value_caution, - ) - ], + lines_list=[f"{i[1].name}: {i[0]:.3E}" for i in xjr], title="The following constraints(s) are associated with extreme Jacobian values:", header="=", footer="=", @@ -652,18 +653,18 @@ def display_extreme_jacobian_entries(self, stream=stdout): None """ + xje = extreme_jacobian_entries( + m=self._model, + scaled=False, + large=self.config.jacobian_large_value_caution, + small=self.config.jacobian_small_value_caution, + zero=0, + ) + xje.sort(key=lambda i: abs(log(i[0])), reverse=True) + _write_report_section( stream=stream, - lines_list=[ - f"{i[1].name}, {i[2].name}: {i[0]}" - for i in extreme_jacobian_entries( - m=self._model, - scaled=False, - large=self.config.jacobian_large_value_caution, - small=self.config.jacobian_small_value_caution, - zero=0, - ) - ], + lines_list=[f"{i[1].name}, {i[2].name}: {i[0]:.3E}" for i in xje], title="The following constraints(s) and variable(s) are associated with extreme Jacobian\nvalues:", header="=", footer="=", diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 63999657d3..c55df476fb 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -843,9 +843,9 @@ def test_display_variables_with_extreme_jacobians(self): expected = """==================================================================================== The following variables(s) are associated with extreme Jacobian values: - v1: 100000000.00000001 - v2: 10000000000.0 - v3: 1.0000499987500626e-06 + v2: 1.000E+10 + v1: 1.000E+08 + v3: 1.000E-06 ==================================================================================== """ @@ -871,7 +871,7 @@ def test_display_constraints_with_extreme_jacobians(self): expected = """==================================================================================== The following constraints(s) are associated with extreme Jacobian values: - c3: 10000499987.500626 + c3: 1.000E+10 ==================================================================================== """ @@ -898,10 +898,10 @@ def test_display_extreme_jacobian_entries(self): The following constraints(s) and variable(s) are associated with extreme Jacobian values: - c2, v3: 1e-08 - c3, v1: 100000000.0 - c3, v2: 10000000000.0 - c3, v3: 1e-06 + c3, v2: 1.000E+10 + c2, v3: 1.000E-08 + c3, v1: 1.000E+08 + c3, v3: 1.000E-06 ==================================================================================== """ From 316d7b0ffffb25906da260866ba26a939ba769ee Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 24 Aug 2023 11:21:36 -0400 Subject: [PATCH 41/48] Fixing import order for Pylint --- idaes/core/util/model_diagnostics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index b9276f50d9..88dc954db3 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -19,12 +19,12 @@ from operator import itemgetter from sys import stdout +from math import log import numpy as np from scipy.linalg import svd from scipy.sparse.linalg import svds, norm from scipy.sparse import issparse, find -from math import log from pyomo.environ import ( Binary, From e626a9f7a9c11bdc81ab1d5312a94405f2374e8f Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 24 Aug 2023 14:02:38 -0400 Subject: [PATCH 42/48] Removing set_model method --- idaes/core/util/model_diagnostics.py | 30 +++++-------------- .../core/util/tests/test_model_diagnostics.py | 11 ------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 88dc954db3..3b59b006d5 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -220,28 +220,6 @@ class DiagnosticsToolbox: """ def __init__(self, model: _BlockData, **kwargs): - self.set_model(model) - self.config = CONFIG(kwargs) - - @property - def model(self): - """ - Model currently being diagnosed. - """ - return self._model - - def set_model(self, model): - """ - Changes the models currently being diagnosed. - - Args: - model: new model to be diagnosed. Must be an scalar Block or an element of - an indexed Block. - - Returns: - None - - """ # TODO: In future may want to generalise this to accept indexed blocks # However, for now some of the tools do not support indexed blocks if not isinstance(model, _BlockData): @@ -251,6 +229,14 @@ def set_model(self, model): ) self._model = model + self.config = CONFIG(kwargs) + + @property + def model(self): + """ + Model currently being diagnosed. + """ + return self._model def display_external_variables(self, stream=stdout): """ diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index c55df476fb..59f6281149 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -407,17 +407,6 @@ def test_fixed_variables(self): @pytest.mark.solver class TestDiagnosticsToolbox: - @pytest.mark.unit - def test_set_model(self): - m = ConcreteModel() - m.b = Block() - - dt = DiagnosticsToolbox(model=m) - assert dt.model is m - - dt.set_model(m.b) - assert dt.model is m.b - @pytest.mark.unit def test_invalid_model_type(self): with pytest.raises( From 01f0b873251d033ad48bfc7cfd793069e39c1f80 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 24 Aug 2023 16:04:24 -0400 Subject: [PATCH 43/48] Fixing typos in docs --- docs/explanations/model_diagnostics/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/explanations/model_diagnostics/index.rst b/docs/explanations/model_diagnostics/index.rst index 20480f8f7b..984dd2b21d 100644 --- a/docs/explanations/model_diagnostics/index.rst +++ b/docs/explanations/model_diagnostics/index.rst @@ -17,15 +17,15 @@ The diagram below shows a high-level overview of the model development and diagn .. image:: diagnostics_workflow.png -Chose a Model to Debug -"""""""""""""""""""""" +Choose a Model to Debug +""""""""""""""""""""""" As shown above, all model development begins with a model and the IDAES team recommends starting with the simplest possible model you can. It is always easier to debug small changes, so users should apply this workflow from the very beginning of model development starting with the simplest possible representation of their system (e.g., a single unit model or set of material balances). At each step of the process (i.e., each change or new constraint), you should check to ensure that your model is well-posed and that it solves robustly before making additional changes. In this way, it will be clear where to start looking for new issues as they arise, as they will be related in some way to the change you just made. Start with a Square Model """"""""""""""""""""""""" -Next, you should ensure you model has zero degrees of freedom (as best you can); whilst your ultimate goal may be to some an optimization problem with degrees of freedom, you should always start from a square model first. Firstly, this is because many of the model diagnosis tools work best with square models. Secondly, all models are based on the foundation of a square model; an optimization problem is just a square model with some degrees of freedom added. If your underlying square model is not well-posed then any more advanced problem you solve based on it is fundamentally flawed (even if it solves). +Next, you should ensure your model has zero degrees of freedom (as best you can); whilst your ultimate goal may be to solve some optimization problem with degrees of freedom, you should always start from a square model first. Firstly, this is because many of the model diagnosis tools work best with square models. Secondly, all models are based on the foundation of a square model; an optimization problem is just a square model with some degrees of freedom added. If your underlying square model is not well-posed then any more advanced problem you solve based on it is fundamentally flawed (even if it solves). Check for Structural Issues """"""""""""""""""""""""""" From 378699014f8b565e0636726b78693c5d32a3d944 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 25 Aug 2023 08:36:28 -0400 Subject: [PATCH 44/48] Fixing doc string formatting --- idaes/core/util/model_diagnostics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 3b59b006d5..06baf61e0e 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -215,7 +215,8 @@ class DiagnosticsToolbox: the next report method to call. Args: - model - model to be diagnosed. The DiagnosticsToolbox does not support indexed Blocks. + + model: model to be diagnosed. The DiagnosticsToolbox does not support indexed Blocks. """ From 696e0f281f487b63c71a8b7378424decb4f95d11 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 25 Aug 2023 12:37:47 -0400 Subject: [PATCH 45/48] Adding extra doc link to toolbox API --- docs/explanations/model_diagnostics/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/explanations/model_diagnostics/index.rst b/docs/explanations/model_diagnostics/index.rst index 984dd2b21d..5e097fcc8e 100644 --- a/docs/explanations/model_diagnostics/index.rst +++ b/docs/explanations/model_diagnostics/index.rst @@ -8,7 +8,7 @@ Model Diagnostics Workflow Introduction ------------ -Writing well-posed equation-oriented models is a significant challenge, and even the most experienced developers often have to spend a lot of time diagnosing and resolving issues before a model is able to solve reliably. This documentation is intended to assist users with this process by outlining a general workflow for model development and debugging in order to more easily identify and resolve modeling issues. +Writing well-posed equation-oriented models is a significant challenge, and even the most experienced developers often have to spend a lot of time diagnosing and resolving issues before a model is able to solve reliably. This documentation is intended to assist users with this process by outlining a general workflow for model development and debugging in order to more easily identify and resolve modeling issues. IDAES also provides a ``DiagnosticsToolbox`` to assist users with this workflow, and a detailed description of the API can be found :ref:`here`. General Workflow ---------------- From 73c875ac04ece8a91fc04be0cfa9050e1342eb75 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 25 Aug 2023 16:03:02 -0400 Subject: [PATCH 46/48] Fixing version in deprecation warning --- idaes/core/util/model_diagnostics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 06baf61e0e..417866f563 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -1055,7 +1055,7 @@ def __init__(self, block_or_jac, solver=None): "DegeneracyHunter is being deprecated in favor of the new " "DiagnosticsToolbox." ) - deprecation_warning(msg=msg, logger=_log, version="2.0.0", remove_in="3.0.0") + deprecation_warning(msg=msg, logger=_log, version="2.2.0", remove_in="3.0.0") block_like = False try: From 86bf0cbda9bca984ede50dca94053b7483470ffe Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 28 Aug 2023 18:22:31 -0400 Subject: [PATCH 47/48] Addressing more comments --- docs/explanations/model_diagnostics/index.rst | 2 +- idaes/core/util/model_diagnostics.py | 56 +++++++++++-------- idaes/core/util/model_statistics.py | 4 +- .../core/util/tests/test_model_diagnostics.py | 12 ++-- .../core/util/tests/test_model_statistics.py | 4 +- 5 files changed, 45 insertions(+), 33 deletions(-) diff --git a/docs/explanations/model_diagnostics/index.rst b/docs/explanations/model_diagnostics/index.rst index 5e097fcc8e..0b07cfe24f 100644 --- a/docs/explanations/model_diagnostics/index.rst +++ b/docs/explanations/model_diagnostics/index.rst @@ -55,7 +55,7 @@ Whilst the workflow outlined above gives a high-level overview of the model deve Modeling Log Book ----------------- -Model development and diagnostics is inherently an iterative process, and often you will try one approach to resolve an issue only to find it made things worse or that you can no longer reproduce an import result from a previous model. Due to this, users are strongly encouraged to maintain a "modeling log book" which records their model development activities. This log book should record each change made to the model and why, along with a Git hash (or equivalent version control marker) and a record on any important results that were generated with the current version of the model. This log book will prove to be invaluable when (and not if) you need to revert to an older version of the model to undo some unsuccessful changes or to work out why a previous result can no longer be reproduced. +Model development and diagnostics is inherently an iterative process, and often you will try one approach to resolve an issue only to find it made things worse or that you can no longer reproduce an important result from a previous model. Due to this, users are strongly encouraged to maintain a "modeling log book" which records their model development activities. This log book should record each change made to the model and why, along with a Git hash (or equivalent version control marker) and a record on any important results that were generated with the current version of the model. This log book will prove to be invaluable when (and not if) you need to revert to an older version of the model to undo some unsuccessful changes or to work out why a previous result can no longer be reproduced. Handling Critical Solver Failures --------------------------------- diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 417866f563..94e200ef37 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -87,7 +87,8 @@ ConfigValue( default=1e-4, domain=float, - description="Absolute tolerance to for variables close to bounds.", + description="Absolute tolerance for considering a variable to be close " + "to its bounds.", ), ) CONFIG.declare( @@ -95,7 +96,8 @@ ConfigValue( default=1e-4, domain=float, - description="Relative tolerance to for variables close to bounds.", + description="Relative tolerance for considering a variable to be close " + "to its bounds.", ), ) CONFIG.declare( @@ -103,8 +105,8 @@ ConfigValue( default=0, domain=float, - description="Absolute tolerance to for considering a variable to violate its bounds.", - doc="Absolute tolerance to for considering a variable to violate its bounds. " + description="Absolute tolerance for considering a variable to violate its bounds.", + doc="Absolute tolerance for considering a variable to violate its bounds. " "Some solvers relax bounds on variables thus allowing a small violation to be " "considered acceptable.", ), @@ -225,7 +227,7 @@ def __init__(self, model: _BlockData, **kwargs): # However, for now some of the tools do not support indexed blocks if not isinstance(model, _BlockData): raise TypeError( - "model argument must be an instance of an Pyomo BlockData object " + "model argument must be an instance of a Pyomo BlockData object " "(either a scalar Block or an element of an indexed Block)." ) @@ -241,8 +243,8 @@ def model(self): def display_external_variables(self, stream=stdout): """ - Prints a list of variables that appear within Constraints in the model - but are not contained within the model themselves. + Prints a list of variables that appear within activated Constraints in the + model but are not contained within the model themselves. Args: stream: an I/O object to write the list to (default = stdout) @@ -476,7 +478,7 @@ def display_constraints_with_large_residuals(self, stream=stdout): def get_dulmage_mendelsohn_partition(self): """ Performs a Dulmage-Mendelsohn partitioning on the model and returns - the over- and under-constraint sub-problems. + the over- and under-constrained sub-problems. Returns: list-of-lists variables in each independent block of the under-constrained set @@ -488,7 +490,7 @@ def get_dulmage_mendelsohn_partition(self): igraph = IncidenceGraphInterface(self._model, include_inequality=False) var_dm_partition, con_dm_partition = igraph.dulmage_mendelsohn() - # Collect under- and order-constrained sub-system + # Collect under- and over-constrained sub-system uc_var = var_dm_partition.unmatched + var_dm_partition.underconstrained uc_con = con_dm_partition.underconstrained oc_var = var_dm_partition.overconstrained @@ -504,7 +506,7 @@ def display_underconstrained_set(self, stream=stdout): Prints the variables and constraints in the under-constrained sub-problem from a Dulmage-Mendelsohn partitioning. - This cane be used to identify the under-defined part of a model and thus + This can be used to identify the under-defined part of a model and thus where additional information (fixed variables or constraints) are required. Args: @@ -537,7 +539,7 @@ def display_overconstrained_set(self, stream=stdout): Prints the variables and constraints in the over-constrained sub-problem from a Dulmage-Mendelsohn partitioning. - This cane be used to identify the over-defined part of a model and thus + This can be used to identify the over-defined part of a model and thus where constraints must be removed or variables unfixed. Args: @@ -590,7 +592,7 @@ def display_variables_with_extreme_jacobians(self, stream=stdout): _write_report_section( stream=stream, lines_list=[f"{i[1].name}: {i[0]:.3E}" for i in xjc], - title="The following variables(s) are associated with extreme Jacobian values:", + title="The following variable(s) are associated with extreme Jacobian values:", header="=", footer="=", ) @@ -620,7 +622,7 @@ def display_constraints_with_extreme_jacobians(self, stream=stdout): _write_report_section( stream=stream, lines_list=[f"{i[1].name}: {i[0]:.3E}" for i in xjr], - title="The following constraints(s) are associated with extreme Jacobian values:", + title="The following constraint(s) are associated with extreme Jacobian values:", header="=", footer="=", ) @@ -652,7 +654,7 @@ def display_extreme_jacobian_entries(self, stream=stdout): _write_report_section( stream=stream, lines_list=[f"{i[1].name}, {i[2].name}: {i[0]:.3E}" for i in xje], - title="The following constraints(s) and variable(s) are associated with extreme Jacobian\nvalues:", + title="The following constraint(s) and variabl(s) are associated with extreme Jacobian\nvalues:", header="=", footer="=", ) @@ -686,7 +688,9 @@ def _collect_structural_warnings(self): if len(uc) == 1: cstring = "Component" warnings.append(f"WARNING: {len(uc)} {cstring} with inconsistent units") - next_steps.append("display_components_with_inconsistent_units()") + next_steps.append( + self.display_components_with_inconsistent_units.__name__ + "()" + ) if any(len(x) > 0 for x in [uc_var, uc_con, oc_var, oc_con]): warnings.append( f"WARNING: Structural singularity found\n" @@ -697,9 +701,9 @@ def _collect_structural_warnings(self): ) if any(len(x) > 0 for x in [uc_var, uc_con]): - next_steps.append("display_underconstrained_set()") + next_steps.append(self.display_underconstrained_set.__name__ + "()") if any(len(x) > 0 for x in [oc_var, oc_con]): - next_steps.append("display_overconstrained_set()") + next_steps.append(self.display_overconstrained_set.__name__ + "()") return warnings, next_steps @@ -761,7 +765,9 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): warnings.append( f"WARNING: {len(large_residuals)} {cstring} with large residuals" ) - next_steps.append("display_constraints_with_large_residuals()") + next_steps.append( + self.display_constraints_with_large_residuals.__name__ + "()" + ) # Variables outside bounds violated_bounds = _vars_violating_bounds( @@ -774,7 +780,9 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): warnings.append( f"WARNING: {len(violated_bounds)} {cstring} at or outside bounds" ) - next_steps.append("display_variables_at_or_outside_bounds()") + next_steps.append( + self.display_variables_at_or_outside_bounds.__name__ + "()" + ) # Extreme Jacobian rows and columns jac_col = extreme_jacobian_columns( @@ -790,7 +798,9 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): warnings.append( f"WARNING: {len(jac_col)} {cstring} with extreme Jacobian values" ) - next_steps.append("display_variables_with_extreme_jacobians()") + next_steps.append( + self.display_variables_with_extreme_jacobians.__name__ + "()" + ) jac_row = extreme_jacobian_rows( jac=jac, @@ -805,7 +815,9 @@ def _collect_numerical_warnings(self, jac=None, nlp=None): warnings.append( f"WARNING: {len(jac_row)} {cstring} with extreme Jacobian values" ) - next_steps.append("display_constraints_with_extreme_jacobians()") + next_steps.append( + self.display_constraints_with_extreme_jacobians.__name__ + "()" + ) return warnings, next_steps @@ -943,7 +955,7 @@ def assert_no_numerical_warnings(self): def report_structural_issues(self, stream=stdout): """ Generates a summary report of any structural issues identified in the model provided - and suggest next steps for debugging model. + and suggests next steps for debugging the model. This should be the first method called when debugging a model and after any change is made to the model. These checks can be run before trying to initialize and solve diff --git a/idaes/core/util/model_statistics.py b/idaes/core/util/model_statistics.py index 25a16044b9..392e6a8fc2 100644 --- a/idaes/core/util/model_statistics.py +++ b/idaes/core/util/model_statistics.py @@ -715,13 +715,13 @@ def variables_near_bounds_generator( "variables_near_bounds_generator has deprecated the relative argument. " "Please set abs_tol and rel_tol arguments instead." ) - deprecation_warning(msg=msg, logger=_log, version="2.0.0", remove_in="3.0.0") + deprecation_warning(msg=msg, logger=_log, version="2.2.0", remove_in="3.0.0") if tol is not None: msg = ( "variables_near_bounds_generator has deprecated the tol argument. " "Please set abs_tol and rel_tol arguments instead." ) - deprecation_warning(msg=msg, logger=_log, version="2.0.0", remove_in="3.0.0") + deprecation_warning(msg=msg, logger=_log, version="2.2.0", remove_in="3.0.0") # Set tolerances using the provided value abs_tol = tol rel_tol = tol diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index 59f6281149..f88eec94d8 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -411,7 +411,7 @@ class TestDiagnosticsToolbox: def test_invalid_model_type(self): with pytest.raises( TypeError, - match="model argument must be an instance of an Pyomo BlockData object " + match="model argument must be an instance of a Pyomo BlockData object " "\(either a scalar Block or an element of an indexed Block\).", ): DiagnosticsToolbox(model="foo") @@ -423,7 +423,7 @@ def test_invalid_model_type(self): with pytest.raises( TypeError, - match="model argument must be an instance of an Pyomo BlockData object " + match="model argument must be an instance of a Pyomo BlockData object " "\(either a scalar Block or an element of an indexed Block\).", ): DiagnosticsToolbox(model=m.b) @@ -830,7 +830,7 @@ def test_display_variables_with_extreme_jacobians(self): dt.display_variables_with_extreme_jacobians(stream) expected = """==================================================================================== -The following variables(s) are associated with extreme Jacobian values: +The following variable(s) are associated with extreme Jacobian values: v2: 1.000E+10 v1: 1.000E+08 @@ -858,7 +858,7 @@ def test_display_constraints_with_extreme_jacobians(self): dt.display_constraints_with_extreme_jacobians(stream) expected = """==================================================================================== -The following constraints(s) are associated with extreme Jacobian values: +The following constraint(s) are associated with extreme Jacobian values: c3: 1.000E+10 @@ -884,7 +884,7 @@ def test_display_extreme_jacobian_entries(self): dt.display_extreme_jacobian_entries(stream) expected = """==================================================================================== -The following constraints(s) and variable(s) are associated with extreme Jacobian +The following constraint(s) and variabl(s) are associated with extreme Jacobian values: c3, v2: 1.000E+10 @@ -1270,7 +1270,7 @@ def test_deprecate_degeneracy_hunter(caplog): msg = ( "DEPRECATED: DegeneracyHunter is being deprecated in favor of the new " - "DiagnosticsToolbox. (deprecated in 2.0.0, will be removed in (or after) 3.0.0)" + "DiagnosticsToolbox. (deprecated in 2.2.0, will be removed in (or after) 3.0.0)" ) assert msg.replace(" ", "") in caplog.records[0].message.replace("\n", "").replace( " ", "" diff --git a/idaes/core/util/tests/test_model_statistics.py b/idaes/core/util/tests/test_model_statistics.py index 85818dc7b5..3f45d08809 100644 --- a/idaes/core/util/tests/test_model_statistics.py +++ b/idaes/core/util/tests/test_model_statistics.py @@ -318,7 +318,7 @@ def test_variables_near_bounds_tol_deprecation(m, caplog): msg = ( "DEPRECATED: variables_near_bounds_generator has deprecated the tol argument. " "Please set abs_tol and rel_tol arguments instead. (deprecated in " - "2.0.0, will be removed in (or after) 3.0.0)" + "2.2.0, will be removed in (or after) 3.0.0)" ) assert msg.replace(" ", "") in caplog.records[0].message.replace("\n", "").replace( " ", "" @@ -332,7 +332,7 @@ def test_variables_near_bounds_relative_deprecation(m, caplog): msg = ( "DEPRECATED: variables_near_bounds_generator has deprecated the relative argument. " "Please set abs_tol and rel_tol arguments instead. (deprecated in " - "2.0.0, will be removed in (or after) 3.0.0)" + "2.2.0, will be removed in (or after) 3.0.0)" ) assert msg.replace(" ", "") in caplog.records[0].message.replace("\n", "").replace( " ", "" From deeb154d91a42fba3964b610fb52fee51dec7482 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 28 Aug 2023 18:45:02 -0400 Subject: [PATCH 48/48] Fixing typo --- idaes/core/util/model_diagnostics.py | 2 +- idaes/core/util/tests/test_model_diagnostics.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index 94e200ef37..92127f7c2e 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -654,7 +654,7 @@ def display_extreme_jacobian_entries(self, stream=stdout): _write_report_section( stream=stream, lines_list=[f"{i[1].name}, {i[2].name}: {i[0]:.3E}" for i in xje], - title="The following constraint(s) and variabl(s) are associated with extreme Jacobian\nvalues:", + title="The following constraint(s) and variable(s) are associated with extreme Jacobian\nvalues:", header="=", footer="=", ) diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index f88eec94d8..7da1b77f2a 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -884,7 +884,7 @@ def test_display_extreme_jacobian_entries(self): dt.display_extreme_jacobian_entries(stream) expected = """==================================================================================== -The following constraint(s) and variabl(s) are associated with extreme Jacobian +The following constraint(s) and variable(s) are associated with extreme Jacobian values: c3, v2: 1.000E+10