Source code for idaes.core.scaling.custom_scaler_base

#################################################################################
# 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 custom scaling routines.

Author: Andrew Lee
"""
from copy import copy

from pyomo.environ import ComponentMap, units, value
from pyomo.core.base.units_container import UnitsError
from pyomo.core.expr import identify_variables
from pyomo.core.expr.calculus.derivatives import Modes, differentiate

from idaes.core.scaling.scaling_base import CONFIG, ScalerBase
from idaes.core.scaling.util import get_scaling_factor, NominalValueExtractionVisitor
import idaes.logger as idaeslog
from idaes.core.util.misc import StrEnum

# Set up logger
_log = idaeslog.getLogger(__name__)

CSCONFIG = CONFIG()

DEFAULT_UNIT_SCALING = {
    # "QuantityName: (reference units, scaling factor)
    # Model developers should be careful when using these, especially when
    # dealing with differential measurements (e.g. pressure and temperature differences)
    "Temperature": (units.K, 1e-2),
    "Pressure": (units.Pa, 1e-5),
}


[docs] class ConstraintScalingScheme(StrEnum): """ Schemes available for calculating constraint scaling factors. * harmonicMean ('harmonic_mean'): sf = sum(1/abs(nominal value)) * inverseSum ('inverse_sum'): sf = 1 / sum(abs(nominal value)) * inverseRSS ('inverse_root_sum_squared'): sf = 1 / sqrt(sum(abs(nominal value)**2)) * inverseMaximum ('inverse_maximum'): sf = 1 / max(abs(nominal value) * inverseMinimum ('inverse_minimum'): sf = 1 / min(abs(nominal value) """ harmonicMean = "harmonic_mean" inverseSum = "inverse_sum" inverseRSS = "inverse_root_sum_squared" inverseMaximum = "inverse_maximum" inverseMinimum = "inverse_minimum"
[docs] class CustomScalerBase(ScalerBase): """ Base class for custom scaling routines. """ CONFIG = ScalerBase.CONFIG() # Common data structures for default scaling # DEFAULT_SCALING_FACTORS = {"component_local_name": DEFAULT_SCALING} DEFAULT_SCALING_FACTORS = None # UNIT_SCALING_FACTORS = {"units": UNIT_BASED_SCALING} UNIT_SCALING_FACTORS = copy(DEFAULT_UNIT_SCALING) def __init__(self, **kwargs): super().__init__(**kwargs) if self.DEFAULT_SCALING_FACTORS is not None: self.default_scaling_factors = copy(self.DEFAULT_SCALING_FACTORS) else: self.default_scaling_factors = {} if self.UNIT_SCALING_FACTORS is not None: self.unit_scaling_factors = copy(self.UNIT_SCALING_FACTORS) else: self.unit_scaling_factors = {}
[docs] def scale_model( self, model, first_stage_fill_in: list = None, second_stage_fill_in: list = None, submodel_scalers: ComponentMap = None, ): """ Default model scaling routine. This method performs a four-step scaling routine: 1. Scale variables using variable_scaling_routine 2. Perform first-stage scaling factor fill in using user provided method(s), called in order declared 3. Scale constraints using constraint_scaling_routine 4. Perform second-stage scaling factor fill in using user provided method(s), called in order declared Args: model: model to be scaled first_stage_fill_in: list of methods to use for first-stage scaling factor fill in second_stage_fill_in: list of methods to use for second-stage scaling factor fill in submodel_scalers: ComponentMap of Scalers to use for sub-models Returns: None """ # Step 1: Call variable scaling routine self.variable_scaling_routine( model, overwrite=self.config.overwrite, submodel_scalers=submodel_scalers ) # Step 2: Call variable fill in if first_stage_fill_in is not None: for i in first_stage_fill_in: i(model) # Step 3: Call constraint scaling routine self.constraint_scaling_routine( model, overwrite=self.config.overwrite, submodel_scalers=submodel_scalers ) # Step 4: Call constraint fill in if second_stage_fill_in is not None: for i in second_stage_fill_in: i(model)
[docs] def variable_scaling_routine( self, model, overwrite: bool = False, submodel_scalers: ComponentMap = None ): """ Routine to apply scaling factors to variables in model. Derived classes must overload this method. Args: model: model to be scaled overwrite: whether to overwrite existing scaling factors submodel_scalers: ComponentMap of Scalers to use for sub-models Returns: None """ raise NotImplementedError( "Custom Scaler has not implemented a variable_scaling_routine method." )
[docs] def constraint_scaling_routine( self, model, overwrite: bool = False, submodel_scalers: ComponentMap = None ): """ Routine to apply scaling factors to constraints in model. Derived classes must overload this method. Args: model: model to be scaled overwrite: whether to overwrite existing scaling factors submodel_scalers: ComponentMap of Scalers to use for sub-models Returns: None """ raise NotImplementedError( "Custom Scaler has not implemented a constraint_scaling_routine method." )
[docs] def get_default_scaling_factor(self, component): """ Get scaling factor for component from dict of default values. Args: component: component to get default scaling factor for Returns: default scaling factor if it exists, else None """ try: return self.default_scaling_factors[component.local_name] except KeyError: # Might be indexed, see if parent component has default scaling parent = component.parent_component() try: return self.default_scaling_factors[parent.local_name] except KeyError: # No default scaling found, give up pass # Log a message and return nothing _log.debug(f"No default scaling factor found for {component.name}") return None
# Common methods for variable scaling
[docs] def scale_variable_by_component( self, target_variable, scaling_component, overwrite: bool = False ): """ Set scaling factor for target_variable equal to that of scaling_component. Args: target_variable: variable to set scaling factor for scaling_component: component to use for scaling factor overwrite: whether to overwrite existing scaling factors Returns: None """ sf = get_scaling_factor(scaling_component) if sf is not None: self.set_variable_scaling_factor( variable=target_variable, scaling_factor=sf, overwrite=overwrite ) else: _log.debug( f"Could not set scaling factor for {target_variable.name}, " f"no scaling factor set for {scaling_component.name}" )
[docs] def scale_variable_by_bounds(self, variable, overwrite: bool = False): """ Set scaling factor for variable based on bounds. If variable has both upper and lower bounds, scaling factor will be based on the mean of the bounds. If variable has only one bound, scaling factor will be based on that bound. If variable has no bounds, scaling factor will not be set. Args: variable: variable to set scaling factor for overwrite: whether to overwrite existing scaling factors Returns: None """ if variable.lb is not None: if variable.ub is not None: # Both bounds, use mean xmag = 0.5 * (variable.ub + variable.lb) else: # Only lower bound xmag = variable.lb elif variable.ub is not None: # Only upper bound xmag = variable.ub else: # No bounds _log.debug( f"No scaling factor set for {variable.name}; variable has no bounds." ) return if xmag == 0: sf = 1 else: sf = 1 / abs(xmag) self.set_variable_scaling_factor( variable=variable, scaling_factor=sf, overwrite=overwrite )
[docs] def scale_variable_by_default(self, variable, overwrite: bool = False): """ Set scaling factor for variable based on default scaling factor. Args: variable: variable to set scaling factor for overwrite: whether to overwrite existing scaling factors Returns: None """ sf = self.get_default_scaling_factor(variable) if sf is not None: self.set_variable_scaling_factor( variable=variable, scaling_factor=sf, overwrite=overwrite ) else: _log.debug( f"Could not set scaling factor for {variable.name}, " f"no default scaling factor set." )
[docs] def scale_variable_by_units(self, variable, overwrite: bool = False): """ Set scaling factor for variable based on units of measurement. Units of measurement for variable are compared to those stored in self.unit_scaling_factors, and if a match is found the scaling factor set using the associated value. Args: variable: variable to set scaling factor for overwrite: whether to overwrite existing scaling factors Returns: None """ uom = units.get_units(variable) sf = None # Keys in self.unit_scaling_factors are not used - only required because Pyomo # units are non-hashable and thus cannot be keys for refunits, unit_scale in self.unit_scaling_factors.values(): try: # Try convert the reference scaling factor to variable units # TODO: Have not found a more efficient way to do this, as Pyomo basically # TODO: involves a try/except anyway # Need to invert reference scaling factor, and then invert result sf = 1 / units.convert_value( 1 / unit_scale, from_units=refunits, to_units=uom ) # Break once we have a match - no need to continue break except UnitsError: pass if sf is not None: self.set_variable_scaling_factor( variable=variable, scaling_factor=sf, overwrite=overwrite, ) else: _log.debug( f"No scaling factor set for {variable.name}; no match for units {uom} found " "in self.unit_scaling_factors" )
# Common methods for constraint scaling
[docs] def scale_constraint_by_component( self, target_constraint, scaling_component, overwrite: bool = False ): """ Set scaling factor for target_constraint equal to that of scaling_component. Args: target_constraint: constraint to set scaling factor for scaling_component: component to use for scaling factor overwrite: whether to overwrite existing scaling factors Returns: None """ sf = get_scaling_factor(scaling_component) if sf is not None: self.set_constraint_scaling_factor( constraint=target_constraint, scaling_factor=sf, overwrite=overwrite ) else: _log.debug( f"Could not set scaling factor for {target_constraint.name}, " f"no scaling factor set for {scaling_component.name}" )
[docs] def scale_constraint_by_default(self, constraint, overwrite: bool = False): """ Set scaling factor for constraint based on default scaling factor. Args: constraint: constraint to set scaling factor for overwrite: whether to overwrite existing scaling factors Returns: None """ sf = self.get_default_scaling_factor(constraint) if sf is not None: self.set_constraint_scaling_factor( constraint=constraint, scaling_factor=sf, overwrite=overwrite ) else: _log.debug( f"Could not set scaling factor for {constraint.name}, " f"no default scaling factor set." )
[docs] def get_expression_nominal_values(self, expression): """ Calculate nominal values for each additive term in a Pyomo expression. The nominal value of any Var is defined as the inverse of its scaling factor (if assigned, else 1). Args: expression: Pyomo expression to collect nominal values for Returns: list of nominal values for each additive term """ # For convenience, if expression is a Pyomo component with an expr attribute, # redirect to the expr attribute if hasattr(expression, "expr"): expression = expression.expr return NominalValueExtractionVisitor().walk_expression(expression)
[docs] def scale_constraint_by_nominal_value( self, constraint, scheme: ConstraintScalingScheme = ConstraintScalingScheme.inverseMaximum, overwrite: bool = False, ): """ Set scaling factor for constraint based on the nominal value(s). Terms with expected magnitudes of 0 will be ignored. Args: constraint: constraint to set scaling factor for scheme: ConstraintScalingScheme Enum indicating method to apply for determining constraint scaling. overwrite: whether to overwrite existing scaling factors Returns: None """ nominal = self.get_expression_nominal_values(constraint.expr) # Remove any 0 terms nominal = [j for j in nominal if j != 0] if len(nominal) == 0: # No non-zero terms... sf = 1 elif scheme == ConstraintScalingScheme.harmonicMean: sf = sum(1 / abs(i) for i in nominal) elif scheme == ConstraintScalingScheme.inverseSum: sf = 1 / sum(abs(i) for i in nominal) elif scheme == ConstraintScalingScheme.inverseRSS: sf = 1 / sum(abs(i) ** 2 for i in nominal) ** 0.5 elif scheme == ConstraintScalingScheme.inverseMaximum: sf = 1 / max(abs(i) for i in nominal) elif scheme == ConstraintScalingScheme.inverseMinimum: sf = 1 / min(abs(i) for i in nominal) else: raise ValueError( f"Invalid value for 'scheme' argument ({scheme}) in " "scale_constraint_by_nominal_value." ) self.set_constraint_scaling_factor( constraint=constraint, scaling_factor=sf, overwrite=overwrite )
[docs] def scale_constraint_by_nominal_derivative_norm( self, constraint, norm: int = 2, overwrite: bool = False ): """ Scale constraint by norm of partial derivatives. Calculates partial derivatives of constraint at nominal variable values, and then scaled the constraint by the user-selected norm of these derivatives. Given perfect variable scaling, this should provide a similar result to applying scaling based on the Jacobian norm, however this approach does not require an initial solution for the problem (relying on nominal values instead). Args: constraint: constraint to be scaled. norm: type of norm to use for scaling. Must be a positive integer. overwrite: whether to overwrite existing scaling factors. Returns: None """ # Cast norm to int to make sure it is valid norm = int(norm) if norm < 1: raise ValueError(f"norm must be a positive integer (received {norm})") var_data = [] try: # Iterate over all variables in constraint for v in identify_variables(constraint.body): # Store current value for restoration ov = v.value # original value sf = self.get_scaling_factor(v) # scaling factor if sf is None: # If no scaling factor set, use nominal value of 1 sf = 1 var_data.append((v, ov, sf)) # Get partial derivatives pjac = [] for v in var_data: # Iterate over all variable and set values for w in var_data: if w is not v: # Set all other variables to their nominal magnitude # nominal_value = 1/ scaling_factor w[0].value = 1 / w[2] else: # Set derivative var to scaled value of 1 # With perfect scaling, scaling_factor * value = 1 w[0].value = 1 pjac.append( value( differentiate( expr=constraint.body, wrt=v[0], mode=Modes.reverse_symbolic ) * (1 / v[2]) # Need to divide by scaling_factor ) ) finally: # Restore all values for clean up for v in var_data: v[0].value = v[1] # Calculate norm cnorm = sum(abs(j) ** norm for j in pjac) ** (1 / norm) if cnorm != 0: sf = 1 / cnorm else: sf = 1 self.set_constraint_scaling_factor(constraint, sf, overwrite=overwrite)
# Other methods
[docs] def propagate_state_scaling( self, target_state, source_state, overwrite: bool = False ): """ Propagate scaling of state variables from one StateBlock to another. Indexing of target and source StateBlocks must match. Args: target_state: StateBlock to set scaling factors on source_state: StateBlock to use as source for scaling factors overwrite: whether to overwrite existing scaling factors Returns: None """ for bidx, target_data in target_state.items(): target_vars = target_data.define_state_vars() source_vars = source_state[bidx].define_state_vars() for state, var in target_vars.items(): for vidx, vardata in var.items(): self.scale_variable_by_component( target_variable=vardata, scaling_component=source_vars[state][vidx], overwrite=overwrite, )
[docs] def call_submodel_scaler_method( self, submodel, method: str, submodel_scalers: ComponentMap = None, overwrite: bool = False, ): """ Call scaling method for submodel. Scaler for submodel is taken from submodel_scalers if present, otherwise the default scaler for the submodel is used. Args: submodel: submodel to be scaled submodel_scalers: user provided ComponentMap of Scalers to use for submodels method: name of method to call from submodel (as string) overwrite: whether to overwrite existing scaling factors Returns: None """ if submodel_scalers is None: submodel_scalers = {} # Iterate over indices of submodel for smdata in submodel.values(): # Get Scaler for submodel if submodel in submodel_scalers: scaler = submodel_scalers[submodel] if callable(scaler): # Check to see if Scaler is callable - this implies it is a class and not an instance # Call the class to create an instance scaler = scaler() _log.debug(f"Using user-defined Scaler for {submodel}.") else: try: scaler = smdata.default_scaler _log.debug(f"Using default Scaler for {submodel}.") except AttributeError: _log.debug( f"No default Scaler set for {submodel}. Cannot call {method}." ) return if scaler is not None: scaler = scaler() else: _log.debug(f"No Scaler found for {submodel}. Cannot call {method}.") # If a Scaler is found, call desired method if scaler is not None: try: smeth = getattr(scaler, method) except AttributeError: raise AttributeError( f"Scaler for {submodel} does not have a method named {method}." ) smeth(smdata, overwrite=overwrite)