#################################################################################
# The Institute for the Design of Advanced Energy Systems Integrated Platform
# Framework (IDAES IP) was produced under the DOE Institute for the
# Design of Advanced Energy Systems (IDAES).
#
# Copyright (c) 2018-2023 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory,
# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon
# University, West Virginia University Research Corporation, et al.
# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md
# for full copyright and license information.
#################################################################################
"""
Base class for initializer objects
"""
from enum import Enum
from pyomo.environ import (
BooleanVar,
Block,
check_optimal_termination,
Constraint,
Var,
)
from pyomo.core.base.var import _VarData
from pyomo.common.config import ConfigDict, ConfigValue, String_ConfigFormatter
from idaes.core.util.model_serializer import to_json, from_json, StoreSpec, _only_fixed
from idaes.core.util.exceptions import InitializationError
from idaes.core.util.model_statistics import (
degrees_of_freedom,
large_residuals_set,
variables_in_activated_constraints_set,
)
import idaes.logger as idaeslog
from idaes.core.solvers import get_solver
__author__ = "Andrew Lee"
_log = idaeslog.getLogger(__name__)
class InitializationStatus(Enum):
"""
Enum of expected outputs from Initialization routines.
"""
Ok = 1 # Successfully converged to tolerance
none = 0 # Initiazation has not yet been run
Failed = -1 # Run, but failed to converge to tolerance
DoF = -2 # Failed due to Degrees of Freedom issue
PrecheckFailed = -3 # Failed pre-check step (other than DoF)
Error = -4 # Exception raised during execution (other than DoF or convergence)
# Store spec needs to use some internals from Pyomo
StoreState = StoreSpec(
data_classes={
Var._ComponentDataClass: ( # pylint: disable=protected-access
("fixed", "value"),
_only_fixed,
),
BooleanVar._ComponentDataClass: ( # pylint: disable=protected-access
("fixed", "value"),
_only_fixed,
),
Block._ComponentDataClass: ( # pylint: disable=protected-access
("active",),
None,
),
Constraint._ComponentDataClass: ( # pylint: disable=protected-access
("active",),
None,
),
}
)
[docs]class InitializerBase:
"""
Base class for Initializer objects.
This implements a default workflow and methods for common tasks.
Developers should feel free to overload these as necessary.
"""
CONFIG = ConfigDict()
CONFIG.declare(
"constraint_tolerance",
ConfigValue(
default=1e-5,
domain=float,
description="Tolerance for checking constraint convergence",
),
)
CONFIG.declare(
"output_level",
ConfigValue(
default=idaeslog.NOTSET,
description="Set output level for logging messages",
),
)
def __init__(self, **kwargs):
self.config = self.CONFIG(kwargs)
self.initial_state = {}
self.summary = {}
self._local_logger_level = (
None # To allow calls to initialize to override global setting
)
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.__doc__ = cls.__doc__ + cls.CONFIG.generate_documentation(
format=String_ConfigFormatter(
block_start="%s\n",
block_end="",
item_start="%s\n",
item_body="%s",
item_end="\n",
),
indent_spacing=4,
width=66,
)
[docs] def get_output_level(self):
"""
Get local output level.
This method returns either the local logger level set when calling
initialize, or if that was not set then the logger level set in
the Initializer configuration.
Returns:
Output level to use in log handler
"""
outlvl = self._local_logger_level
if outlvl is None:
outlvl = self.config.output_level
return outlvl
[docs] def get_logger(self, model):
"""Get logger for model by name"""
return idaeslog.getInitLogger(model.name, self.get_output_level())
[docs] def initialize(
self,
model: Block,
initial_guesses: dict = None,
json_file: str = None,
output_level=None,
exclude_unused_vars: bool = False,
):
"""
Execute full initialization routine.
Args:
model: Pyomo model to be initialized.
initial_guesses: dict of initial guesses to load.
json_file: file name of json file to load initial guesses from as str.
output_level: (optional) output level to use during initialization run (overrides global setting).
exclude_unused_vars: whether to ignore unused variables when doing post-initialization checks.
Note - can only provide one of initial_guesses or json_file.
Returns:
InitializationStatus Enum
"""
self._local_logger_level = output_level
# 1. Get current model state
self.get_current_state(model)
# 2. Load initial guesses
self.load_initial_guesses(
model, initial_guesses=initial_guesses, json_file=json_file
)
# 3. Fix states to make square
self.fix_initialization_states(model)
# 4. Prechecks
self.precheck(model)
# 5. try: Call specified initialization routine
try:
# Base method does not have a return (NotImplementedError),
# but we expect this to be overloaded, disable pylint warning
# pylint: disable=E1111
results = self.initialization_routine(model)
# 6. finally: Restore model state
finally:
self.restore_model_state(model)
# Also revert local logger level so it does not get carried over to
# later runs
self._local_logger_level = None
# 7. Check convergence
return self.postcheck(
model, results_obj=results, exclude_unused_vars=exclude_unused_vars
)
[docs] def get_current_state(self, model: Block):
"""
Get and store current state of variables (fixed/unfixed) and constraints/objectives
activated/deactivated in model.
Args:
model: Pyomo model to get state from.
Returns:
dict serializing current model state.
"""
self.initial_state[model] = to_json(model, wts=StoreState, return_dict=True)
return self.initial_state[model]
[docs] def load_initial_guesses(
self,
model: Block,
initial_guesses: dict = None,
json_file: str = None,
exception_on_fixed: bool = True,
):
"""
Load initial guesses for variables into model.
Args:
model: Pyomo model to be initialized.
initial_guesses: dict of initial guesses to load.
json_file: file name of json file to load initial guesses from as str.
exception_on_fixed: (optional, initial_guesses only) bool indicating whether to suppress
exceptions when guess provided for a fixed variable (default=True).
Note - can only provide one of initial_guesses or json_file.
Returns:
None
Raises:
ValueError if both initial_guesses and json_file are provided.
"""
if initial_guesses is not None and json_file is not None:
self._update_summary(model, "status", InitializationStatus.Error)
raise ValueError(
"Cannot provide both a set of initial guesses and a json file to load."
)
if initial_guesses is not None:
# TODO: Need tests for exception_on_fixed
self._load_values_from_dict(
model, initial_guesses, exception_on_fixed=exception_on_fixed
)
elif json_file is not None:
# Only load variable values
from_json(
model, fname=json_file, wts=StoreSpec().value(only_not_fixed=True)
)
else:
_log.info_high(
f"No initial guesses provided during initialization of model {model.name}."
)
[docs] def fix_initialization_states(self, model: Block):
"""
Call to model.fix_initialization_states method. Method will pass if
fix_initialization_states not found.
Args:
model: Pyomo Block to fix states on.
Returns:
None
"""
try:
model.fix_initialization_states()
except AttributeError:
_log.info_high(
f"Model {model.name} does not have a fix_initialization_states method - attempting to continue."
)
[docs] def precheck(self, model: Block):
"""
Check for satisfied degrees of freedom before running initialization.
Args:
model: Pyomo Block to fix states on.
Returns:
None
Raises:
InitializationError if Degrees of Freedom do not equal 0.
"""
dof = degrees_of_freedom(model)
self._update_summary(model, "DoF", dof)
if not dof == 0:
self._update_summary(model, "status", InitializationStatus.DoF)
raise InitializationError(
f"Degrees of freedom for {model.name} were not equal to zero during "
f"initialization (DoF = {degrees_of_freedom(model)})."
)
[docs] def initialization_routine(self, model: Block):
"""
Placeholder method to run initialization routine. Derived classes should overload
this with the desired routine.
Args:
model: Pyomo Block to initialize.
Returns:
Overloaded method should return a Pyomo solver results object is available,
otherwise None
Raises:
NotImplementedError
"""
self._update_summary(model, "status", InitializationStatus.Error)
raise NotImplementedError()
[docs] def restore_model_state(self, model: Block):
"""
Restore model state to that stored in self.initial_state.
This method restores the following:
- fixed status of all variables,
- value of any fixed variables,
- active status of all Constraints and Blocks.
Args:
model: Pyomo Block to restore state on.
Returns:
None
Raises:
ValueError if no initial state is stored.
"""
if model in self.initial_state:
from_json(model, sd=self.initial_state[model], wts=StoreState)
else:
self._update_summary(model, "status", InitializationStatus.Error)
raise ValueError("No initial state stored.")
[docs] def postcheck(
self, model: Block, results_obj: dict = None, exclude_unused_vars: bool = False
):
"""
Check the model has been converged after initialization.
If a results_obj is provided, this will be checked using check_optimal_termination,
otherwise this will walk all constraints in the model and check that they are within
tolerance (set via the Initializer constraint_tolerance config argument).
Args:
model: model to be checked for convergence.
results_obj: Pyomo solver results dict (if applicable, default=None).
exclude_unused_vars: bool indicating whether to check if uninitialized vars appear in active
constraints and ignore if this is the case. Checking for unused vars required determining the
set of variables in active constraint. Default = False.
Returns:
InitializationStatus Enum
"""
if results_obj is not None:
self._update_summary(
model, "solver_status", check_optimal_termination(results_obj)
)
if not self.summary[model]["solver_status"]:
# Final solver call did not return optimal
self._update_summary(model, "status", InitializationStatus.Failed)
raise InitializationError(
f"{model.name} failed to initialize successfully: solver did not return "
"optimal termination. Please check the output logs for more information."
)
else:
# Need to manually check initialization
# First, check that all Vars have values
if exclude_unused_vars:
# Need to get set of Vars in active constraints
active_vars = variables_in_activated_constraints_set(model)
else:
# Placeholder, this should not get accessed unless exclude_unused_vars is True
active_vars = None
uninit_vars = []
def _append_uninit_vars(block_data):
for v in block_data.component_data_objects(Var, descend_into=True):
if v.value is None:
if not exclude_unused_vars or v in active_vars:
uninit_vars.append(v)
if model.is_indexed():
for d in model.values():
_append_uninit_vars(d)
else:
_append_uninit_vars(model)
# Next check for unconverged equality constraints
uninit_const = large_residuals_set(
model, self.config.constraint_tolerance, return_residual_values=True
)
try:
max_res = max(i for i in uninit_const.values() if i is not None)
except ValueError:
max_res = "N/A"
self._update_summary(model, "uninitialized_vars", uninit_vars)
self._update_summary(model, "unconverged_constraints", uninit_const)
if len(uninit_const) > 0 or len(uninit_vars) > 0:
self._update_summary(model, "status", InitializationStatus.Failed)
raise InitializationError(
f"{model.name} failed to initialize successfully: uninitialized variables or "
f"unconverged equality constraints detected. Please check postcheck summary "
f"for more information (largest residual above tolerance = {max_res})."
)
self._update_summary(model, "status", InitializationStatus.Ok)
return self.summary[model]["status"]
[docs] def plugin_prepare(self, plugin: Block):
"""
Prepare plug-in model for initialization. This deactivates the plug-in model.
Derived Initializers should overload this as required.
Args:
plugin: model to be prepared for initialization
Returns:
None.
"""
try:
plugin.deactivate()
except AttributeError:
raise InitializationError(
f"Could not deactivate plug-in {plugin.name}: this suggests it is not a Pyomo Block."
)
[docs] def plugin_initialize(
self, plugin: Block, initial_guesses: dict = None, json_file: str = None
):
"""
Initialize plug-in model. This activates the Block and then calls self.initialize(plugin).
Derived Initializers should overload this as required.
Args:
plugin: Pyomo model to be initialized.
initial_guesses: dict of initial guesses to load.
json_file: file name of json file to load initial guesses from as str.
Note - can only provide one of initial_guesses or json_file.
Returns:
InitializationStatus Enum
"""
plugin.activate()
return self.initialize(
plugin, initial_guesses=initial_guesses, json_file=json_file
)
[docs] def plugin_finalize(self, plugin):
"""
Final clean up of plug-ins after initialization. This method does nothing.
Derived Initializers should overload this as required.
Args:
plugin: model to be cleaned-up after initialization
Returns:
None.
"""
def _load_values_from_dict(self, model, initial_guesses, exception_on_fixed=True):
"""
Internal method to iterate through items in initial_guesses and set value if Var and not fixed.
"""
for c, v in initial_guesses.items():
component = model.find_component(c)
if component is None:
raise ValueError(f"Could not find a component with name {c}.")
elif not isinstance(component, (Var, _VarData)):
self._update_summary(model, "status", InitializationStatus.Error)
raise TypeError(
f"Component {c} is not a Var. Initial guesses should only contain values for variables."
)
else:
if component.is_indexed():
for i in component.values():
if not i.fixed:
i.set_value(v)
elif exception_on_fixed:
self._update_summary(
model, "status", InitializationStatus.Error
)
raise InitializationError(
f"Attempted to change the value of fixed variable {i.name}. "
"Initialization from initial guesses does not support changing the value "
"of fixed variables."
)
else:
_log.debug(
f"Found initial guess for fixed Var {i.name} - ignoring."
)
elif not component.fixed:
component.set_value(v)
elif exception_on_fixed:
self._update_summary(model, "status", InitializationStatus.Error)
raise InitializationError(
f"Attempted to change the value of fixed variable {component.name}. "
"Initialization from initial guesses does not support changing the value "
"of fixed variables."
)
else:
_log.debug(
f"Found initial guess for fixed Var {component.name} - ignoring."
)
def _update_summary(self, model, attribute, state):
if model not in self.summary:
self.summary[model] = {}
self.summary[model]["status"] = InitializationStatus.none
self.summary[model][attribute] = state
[docs]class ModularInitializerBase(InitializerBase):
"""
Base class for modular Initializer objects.
This extends the base Initializer class to include attributes and methods for
defining initializer objects for sub-models.
"""
CONFIG = InitializerBase.CONFIG()
CONFIG.declare(
"solver",
ConfigValue(
default=None, # TODO: Can we add a square problem solver as the default here?
# At the moment there is an issue with the scipy solvers not supporting the tee argument.
description="Solver to use for initialization",
),
)
CONFIG.declare(
"solver_options",
ConfigDict(
implicit=True,
description="Dict of options to pass to solver",
),
)
CONFIG.declare(
"default_submodel_initializer",
ConfigValue(
default=None,
description="Default Initializer object to use for sub-models.",
doc="Default Initializer object to use for sub-models. Only used if no Initializer "
"defined in submodel_initializers.",
),
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._solver = None
self.submodel_initializers = {}
[docs] def add_submodel_initializer(self, submodel: Block, initializer: InitializerBase):
"""
Define an Initializer for a give submodel or type of submodel.
Args:
submodel: submodel or type of submodel to define Initializer for.
initializer: Initializer object to use for this/these submodels.
Returns:
None
"""
self.submodel_initializers[submodel] = initializer
[docs] def get_submodel_initializer(self, submodel: Block):
"""
Lookup Initializer object to use for specified sub-model.
This method will return Initializers in the following order:
1. Initializer defined for a specific submodel.
2. Initializer defined for a type of model (e.g. UnitModel).
3. submodel.default_initializer (if present).
4. Initializer for submodel.params (in case of StateBlocks and ReactionBlocks).
5. Global default Initializer defined in config.default_submodel_initializer.
6. None.
Args:
submodel: sub-model to get initializer for.
Returns:
Initializer object or None.
"""
initializer = None
if submodel in self.submodel_initializers:
# First look for specific model instance
initializer = self.submodel_initializers[submodel]
elif type(submodel) in self.submodel_initializers:
# Then look for types
initializer = self.submodel_initializers[type(submodel)]
else:
# Then try the model's default initializer
try:
initializer = submodel.default_initializer
except AttributeError:
pass
if initializer is None and hasattr(submodel, "params"):
# For StateBlocks and ReactionBlocks, look to the associated parameter block
initializer = self.get_submodel_initializer(submodel.params)
if initializer is None:
# If initializer is still None, try the master initializer's default
initializer = self.config.default_submodel_initializer
if initializer is None:
# If we still have no initializer, log a warning and keep going
_log.warning(
f"No Initializer found for submodel {submodel.name} - attempting to continue."
)
if callable(initializer):
initializer = initializer()
return initializer
[docs] def initialization_routine(
self, model: Block, plugin_initializer_args: dict = None, **kwargs
):
"""
Common initialization routine for models with plugins.
Args:
model: Pyomo Block to be initialized
plugin_initializer_args: dict-of-dicts containing arguments to be passed to plug-in Initializers.
Keys should be submodel components.
kwargs: case specific arguments to be passed to initialize_submodels method.
Returns:
Pyomo solver results object
"""
if plugin_initializer_args is None:
plugin_initializer_args = {}
# Get logger
_log = self.get_logger(model)
# Prepare plug-ins for initialization
sub_initializers, plugin_initializer_args = self.prepare_plugins(
model, plugin_initializer_args
)
_log.info_high("Step 1: preparation complete.")
# Initialize model and sub-models
results = self.initialize_submodels(
model, plugin_initializer_args, sub_initializers, **kwargs
)
_log.info_high("Step 2: sub-model initialization complete.")
# Solve full model including plug-ins
results = self.solve_full_model(model, results)
_log.info_high(
f"Step 3: full model initialization {idaeslog.condition(results)}."
)
# Clean up plug-ins
self.cleanup(model, plugin_initializer_args, sub_initializers)
_log.info_high("Step 4: clean up completed.")
return results
[docs] def prepare_plugins(self, model, plugin_initializer_args):
"""
Prepare plugins for initialization.
Iterates through model.initialization_order and collects Initializer objects
for each plugin and calls plugin_prepare for each.
Args:
model: current model being initialized
plugin_initializer_args: dict of arguments to be passed to plugin Initializer methods
Returns:
dict of Initializers indexed by plugin
copy of plugin_initializer_args (to prevent mutation of origin dict)
"""
sub_initializers = {}
plugin_initializer_args = dict(plugin_initializer_args)
for sm in model.initialization_order:
if sm is not model:
# Get initializers for plug-ins
sub_initializers[sm] = self.get_submodel_initializer(sm)
if sm not in plugin_initializer_args.keys():
plugin_initializer_args[sm] = {}
# Call prepare method for plug-ins
sub_initializers[sm].plugin_prepare(sm, **plugin_initializer_args[sm])
return sub_initializers, plugin_initializer_args
[docs] def initialize_submodels(
self, model, plugin_initializer_args, sub_initializers, **kwargs
):
"""
Initialize sub-models in order defined by model.initialization_order.
For the main model, self.initialize_main_model is called. For plugins,
plugin_initialize is called from the associated Initializer.
Args:
model: current model being initialized
plugin_initializer_args: dict of arguments to be passed to plugin Initializer methods
sub_initializers: dict of Initializers for each plugin
Returns:
Pyomo solver results object returned from self.initialize_main_model
"""
results = None
for sm in model.initialization_order:
if sm is model:
results = self.initialize_main_model(model, **kwargs)
else:
sub_initializers[sm].plugin_initialize(
sm, **plugin_initializer_args[sm]
)
if results is None:
raise InitializationError(
f"Main model ({model.name}) was not initialized (no results returned). "
f"This is likely due to an error in the model.initialization_order."
)
return results
[docs] def initialize_main_model(self, model, **kwargs):
"""
Placeholder method - derived classes should overload this with an appropriate
initialization routine.
Args:
model: current model being initialized
**kwargs: placeholder for case specific arguments
Returns:
This method is expected to return a Pyomo solver results object
"""
return NotImplementedError(
"Initializer has not implemented an initialize_main_model method. Derived classes "
"are required to overload this."
)
[docs] def solve_full_model(self, model, results):
"""
Call solver on full model. If no plugins are present, just return results from
previous solve step (main model).
Args:
model: current model being initialized
results: Pyomo solver results object from previous solver. This is used as
the final state if no plugins are present.
Returns:
Pyomo solver results object
"""
# Check to see if there are any plug-ins
if len(model.initialization_order) > 1:
# Solve model with plugins
solve_log = idaeslog.getSolveLogger(
model.name, self.get_output_level(), tag="unit"
)
with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc:
results = self._get_solver().solve(model, tee=slc.tee)
return results
[docs] def cleanup(self, model, plugin_initializer_args, sub_initializers):
"""
Post-initialization clean-up of plugins.
Iterates through model.initialization_order in reverse and calls plugin_cleanup
method from the associated Initializer for each plugin.
Args:
model: current model being initialized
plugin_initializer_args: dict of arguments to be passed to plugin Initializer methods
sub_initializers: dict of Initializers for each plugin
Returns:
None
"""
for sm in reversed(model.initialization_order):
if sm is not model:
sub_initializers[sm].plugin_finalize(sm, **plugin_initializer_args[sm])
def _get_solver(self):
if self._solver is None:
self._solver = get_solver(self.config.solver, self.config.solver_options)
return self._solver