diff --git a/pyomo/contrib/doe/examples/update_suffix_doe_example.py b/pyomo/contrib/doe/examples/update_suffix_doe_example.py new file mode 100644 index 00000000000..de0233137e3 --- /dev/null +++ b/pyomo/contrib/doe/examples/update_suffix_doe_example.py @@ -0,0 +1,74 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ +from pyomo.common.dependencies import numpy as np + +from pyomo.contrib.doe.examples.reactor_experiment import ReactorExperiment +from pyomo.contrib.doe import DesignOfExperiments +from pyomo.contrib.doe import utils + +from pyomo.contrib.parmest.utils.model_utils import update_model_from_suffix +from os.path import join, abspath, dirname + +import pyomo.environ as pyo + +import json + + +# Example to run a DoE on the reactor +def main(): + # Read in file + file_dirname = dirname(abspath(str(__file__))) + file_path = abspath(join(file_dirname, "result.json")) + + # Read in data + with open(file_path) as f: + data_ex = json.load(f) + + # Put temperature control time points into correct format for reactor experiment + data_ex["control_points"] = { + float(k): v for k, v in data_ex["control_points"].items() + } + + # Create a ReactorExperiment object; data and discretization information are part + # of the constructor of this object + experiment = ReactorExperiment(data=data_ex, nfe=10, ncp=3) + + # Call the experiment's model using get_labeled_model + reactor_model = experiment.get_labeled_model() + + # Show the model + reactor_model.pprint() + # The suffix object 'measurement_error' stores measurement error values for each component. + # Here, we retrieve the original values from the suffix for inspection. + suffix_obj = reactor_model.measurement_error + me_vars = list(suffix_obj.keys()) # components + orig_vals = np.array(list(suffix_obj.values())) + + # Original values + print("Original sigma values") + print("-----------------------") + suffix_obj.display() + + # Update the suffix with new values + new_vals = orig_vals + 1 + # Here we are updating the values of the measurement error + # You must know the length of the list and order of the suffix items to update them correctly + update_model_from_suffix(suffix_obj, new_vals) + + # Updated values + print("Updated sigma values :") + print("-----------------------") + suffix_obj.display() + return suffix_obj, orig_vals, new_vals + + +if __name__ == "__main__": + main() diff --git a/pyomo/contrib/doe/tests/test_doe_build.py b/pyomo/contrib/doe/tests/test_doe_build.py index 39615f47808..51fc70f6db9 100644 --- a/pyomo/contrib/doe/tests/test_doe_build.py +++ b/pyomo/contrib/doe/tests/test_doe_build.py @@ -474,6 +474,17 @@ def test_generate_blocks_without_model(self): doe_obj.model.find_component("scenario_blocks[" + str(i) + "]") ) + def test_reactor_update_suffix_items(self): + """Test the reactor example with updating suffix items.""" + from pyomo.contrib.doe.examples.update_suffix_doe_example import main + + # Run the reactor update suffix items example + suffix_obj, _, new_vals = main() + + # Check that the suffix object has been updated correctly + for i, v in enumerate(suffix_obj.values()): + self.assertAlmostEqual(v, new_vals[i], places=6) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/parmest/examples/reactor_design/update_suffix_example.py b/pyomo/contrib/parmest/examples/reactor_design/update_suffix_example.py new file mode 100644 index 00000000000..85142f7c0ed --- /dev/null +++ b/pyomo/contrib/parmest/examples/reactor_design/update_suffix_example.py @@ -0,0 +1,67 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import numpy as np, pandas as pd +from os.path import join, abspath, dirname +import pyomo.contrib.parmest.parmest as parmest +from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( + ReactorDesignExperiment, +) + +import pyomo.environ as pyo +from pyomo.contrib.parmest.utils.model_utils import update_model_from_suffix + + +def main(): + # Read in file + # Read in data + file_dirname = dirname(abspath(str(__file__))) + file_name = abspath(join(file_dirname, "reactor_data.csv")) + data = pd.read_csv(file_name) + + experiment = ReactorDesignExperiment(data, 0) + + # Call the experiment's model using get_labeled_model + reactor_model = experiment.get_labeled_model() + + example_suffix = "unknown_parameters" + suffix_obj = reactor_model.unknown_parameters + var_list = list(suffix_obj.keys()) # components + orig_var_vals = np.array([pyo.value(v) for v in var_list]) # numeric var values + + # Print original values + print("Original sigma values") + print("----------------------") + print(orig_var_vals) + + # Update the suffix with new values + new_vals = orig_var_vals + 0.5 + + print("New sigma values") + print("----------------") + print(new_vals) + + # Here we are updating the values of the unknown parameters + # You must know the length of the list and order of the suffix items to update them correctly + update_model_from_suffix(suffix_obj, new_vals) + + # Updated values + print("Updated sigma values :") + print("-----------------------") + new_var_vals = np.array([pyo.value(v) for v in var_list]) + print(new_var_vals) + + # Return the suffix obj, original and new values for further use if needed + return suffix_obj, new_vals, new_var_vals + + +if __name__ == "__main__": + main() diff --git a/pyomo/contrib/parmest/tests/test_examples.py b/pyomo/contrib/parmest/tests/test_examples.py index 69b8cc67140..8d9331a6166 100644 --- a/pyomo/contrib/parmest/tests/test_examples.py +++ b/pyomo/contrib/parmest/tests/test_examples.py @@ -194,6 +194,15 @@ def test_datarec_example(self): datarec_example.main() + def test_update_suffix_example(self): + from pyomo.contrib.parmest.examples.reactor_design import update_suffix_example + + suffix_obj, new_vals, new_var_vals = update_suffix_example.main() + + # Check that the suffix object has been updated correctly + for i, v in enumerate(new_var_vals): + self.assertAlmostEqual(new_var_vals[i], new_vals[i], places=6) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/parmest/tests/test_utils.py b/pyomo/contrib/parmest/tests/test_utils.py index f29f04e1d15..b4e4a05a874 100644 --- a/pyomo/contrib/parmest/tests/test_utils.py +++ b/pyomo/contrib/parmest/tests/test_utils.py @@ -9,14 +9,37 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from pyomo.common.dependencies import pandas as pd, pandas_available +from pyomo.common.dependencies import ( + pandas as pd, + pandas_available, + numpy as np, + numpy_available, +) + +import os.path +import json import pyomo.environ as pyo + +from pyomo.common.fileutils import this_file_dir import pyomo.common.unittest as unittest + import pyomo.contrib.parmest.parmest as parmest from pyomo.opt import SolverFactory -ipopt_available = SolverFactory("ipopt").available() +from pyomo.contrib.parmest.utils.model_utils import update_model_from_suffix +from pyomo.contrib.doe.examples.reactor_example import ( + ReactorExperiment as FullReactorExperiment, +) + +currdir = this_file_dir() +file_path = os.path.join(currdir, "..", "..", "doe", "examples", "result.json") + +with open(file_path) as f: + data_ex = json.load(f) +data_ex["control_points"] = {float(k): v for k, v in data_ex["control_points"].items()} + +ipopt_available = pyo.SolverFactory("ipopt").available() @unittest.skipIf( @@ -60,6 +83,126 @@ def test_convert_param_to_var(self): self.assertEqual(pyo.value(c), pyo.value(c_old)) self.assertTrue(c in m_vars.unknown_parameters) + def test_update_model_from_suffix_experiment_outputs(self): + from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( + ReactorDesignExperiment, + ) + + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + experiment = ReactorDesignExperiment(data, 0) + test_model = experiment.get_labeled_model() + + suffix_obj = test_model.experiment_outputs # a Suffix + var_list = list(suffix_obj.keys()) # components + orig_var_vals = np.array([pyo.value(v) for v in var_list]) + orig_suffix_val = np.array([tag for _, tag in suffix_obj.items()]) + new_vals = orig_var_vals + 0.5 + # Update the model from the suffix + update_model_from_suffix(suffix_obj, new_vals) + # ── Check results ──────────────────────────────────────────────────── + new_var_vals = np.array([pyo.value(v) for v in var_list]) + new_suffix_val = np.array(list(suffix_obj.values())) + # (1) Variables have been overwritten with `new_vals` + self.assertTrue(np.allclose(new_var_vals, new_vals)) + # (2) Suffix tags are unchanged + self.assertTrue(np.array_equal(new_suffix_val, orig_suffix_val)) + + def test_update_model_from_suffix_measurement_error(self): + experiment = FullReactorExperiment(data_ex, 10, 3) + test_model = experiment.get_labeled_model() + + suffix_obj = test_model.measurement_error # a Suffix + var_list = list(suffix_obj.keys()) # components + orig_var_vals = np.array([suffix_obj[v] for v in var_list]) + new_vals = orig_var_vals + 0.5 + # Update the model from the suffix + update_model_from_suffix(suffix_obj, new_vals) + # ── Check results ──────────────────────────────────────────────────── + new_var_vals = np.array([suffix_obj[v] for v in var_list]) + # (1) Variables have been overwritten with `new_vals` + self.assertTrue(np.allclose(new_var_vals, new_vals)) + + def test_update_model_from_suffix_length_mismatch(self): + m = pyo.ConcreteModel() + + # Create a suffix with a Var component + m.x = pyo.Var(initialize=0.0) + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters[m.x] = 0.0 # tag a Var + with self.assertRaisesRegex( + ValueError, "values length does not match suffix length" + ): + # Attempt to update with a list of different length + update_model_from_suffix(m.unknown_parameters, [42, 43, 44]) + + def test_update_model_from_suffix_not_numeric(self): + m = pyo.ConcreteModel() + + # Create a suffix with a Var component + m.x = pyo.Var(initialize=0.0) + m.y = pyo.Var(initialize=1.0) + bad_value = "not_a_number" + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + # Make multiple values + m.unknown_parameters[m.x] = 0.0 # tag a Var + m.unknown_parameters[m.y] = bad_value # tag a Var with a bad value + # Attempt to update with a list of mixed types + # This should raise an error because this utility only allows numeric values + # in the model to be updated. + + with self.assertRaisesRegex( + ValueError, f"could not convert string to float: '{bad_value}'" + ): + # Attempt to update with a non-numeric value + update_model_from_suffix(m.unknown_parameters, [42, bad_value]) + + def test_update_model_from_suffix_wrong_component_type(self): + m = pyo.ConcreteModel() + + # Create a suffix with a Var component + m.x = pyo.Var(initialize=0.0) + m.e = pyo.Expression(expr=m.x + 1) # not Var/Param + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters[m.x] = 0.0 + m.unknown_parameters[m.e] = 1.0 # tag an Expression + # Attempt to update with a list of wrong component type + with self.assertRaisesRegex( + TypeError, + f"Unsupported component type {type(m.e)}; expected VarData or ParamData.", + ): + update_model_from_suffix(m.unknown_parameters, [42, 43]) + + def test_update_model_from_suffix_unsupported_component(self): + m = pyo.ConcreteModel() + + # Create a suffix with a ConstraintData component + m.x = pyo.Var(initialize=0.0) + m.c = pyo.Constraint(expr=m.x == 0) # not Var/Param! + + m.bad_suffix = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.bad_suffix[m.c] = 0 # tag a Constraint + + with self.assertRaisesRegex( + TypeError, r"Unsupported component type .*Constraint.*" + ): + update_model_from_suffix(m.bad_suffix, [1.0]) + + def test_update_model_from_suffix_empty(self): + m = pyo.ConcreteModel() + + # Create an empty suffix + m.empty_suffix = pyo.Suffix(direction=pyo.Suffix.LOCAL) + + # This should not raise an error + update_model_from_suffix(m.empty_suffix, []) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/contrib/parmest/utils/model_utils.py b/pyomo/contrib/parmest/utils/model_utils.py index b5ba8da6924..24821cc8f76 100644 --- a/pyomo/contrib/parmest/utils/model_utils.py +++ b/pyomo/contrib/parmest/utils/model_utils.py @@ -13,8 +13,8 @@ import pyomo.environ as pyo from pyomo.core.expr import replace_expressions, identify_mutable_parameters -from pyomo.core.base.var import IndexedVar -from pyomo.core.base.param import IndexedParam +from pyomo.core.base.var import IndexedVar, VarData +from pyomo.core.base.param import IndexedParam, ParamData from pyomo.common.collections import ComponentMap from pyomo.environ import ComponentUID @@ -201,3 +201,46 @@ def convert_params_to_vars(model, param_names=None, fix_vars=False): # solver.solve(model) return model + + +def update_model_from_suffix(suffix_obj: pyo.Suffix, values): + """ + Overwrite each variable/parameter referenced by ``suffix_obj`` with the + corresponding value in ``values``. The provided values are expected to + be in the same order as the components in the suffix from when it was + created. + + Parameters + ---------- + suffix_obj : pyomo.core.base.suffix.Suffix + The suffix whose *keys* are the components you want to update. + Call like ``update_from_suffix(model.unknown_parameters, vals)``. + values : iterable of numbers + New numerical values for the components referenced by the suffix. + Must be the same length as ``suffix_obj``. + + Notes + ----- + The measurement_error suffix is a special case: instead of updating the value of the + keys (variables/parameters), it updates the value stored in the suffix itself. + """ + # Check that the length of values matches the suffix length + comps = list(suffix_obj.keys()) + if len(comps) != len(values): + raise ValueError("values length does not match suffix length") + + # Add a check for measurement error suffix + is_me_err = "measurement_error" in suffix_obj.name + # Iterate through the keys in the suffix and update their values + # First loop: check all values are the right type + for comp in comps: + if not isinstance(comp, (VarData, ParamData)): + raise TypeError( + f"Unsupported component type {type(comp)}; expected VarData or ParamData." + ) + # Second loop: adjust the values + for comp, new_val in zip(comps, values): + if is_me_err: + suffix_obj[comp] = float(new_val) + else: + comp.set_value(float(new_val))