Skip to content

Scaling hints#1649

Merged
dallan-keylogic merged 19 commits intoIDAES:scaling_toolboxfrom
dallan-keylogic:scaling-hints-limited
Aug 15, 2025
Merged

Scaling hints#1649
dallan-keylogic merged 19 commits intoIDAES:scaling_toolboxfrom
dallan-keylogic:scaling-hints-limited

Conversation

@dallan-keylogic
Copy link
Contributor

@dallan-keylogic dallan-keylogic commented Aug 12, 2025

Summary/Motivation:

While the old scaling toolbox supported setting scaling factors for both Vars and Expressions, the Pyomo team indicated to us that we were abusing the scaling suffix by setting scaling factors for Expressions because they would not be exported to the NLP. However, using the NominalValueExpressionWalker for the sorts of giga-expressions that are regularly found in IDAES thermodynamic packages can be dangerous. As a result, I implemented a system of "scaling hints" that could be assigned to named Expressions, sequestering them in the scaling_hint suffix.

Additionally, I modified the call_submodel_scaler_method so that it passes the parent Scaler's configuration and map of submodel scalers to the child Scaler objects. Note that some child scalers were not written expecting the submodel_scalers argument, so this does break backwards compatibility, but I doubt that there has been any major adoption of these new scaling tools.

This resolves point (1) in #1648.

Changes proposed in this PR:

  • Add system of scaling hints for Expressions
  • Pass config and submodel_scalers to child scalers in call_submodel_scaler_method of a parent scaler
  • Add scale_constraint_by_component method to CustomScalerBase in order to take the scaling factor from a Var or Expression and assign it to a constraint.
  • Modify scale_variable_from_default in order to avoid silent failures, as well as to make it clear to the user when their input into scaling is necessary.
  • Raise an exception if people try to set scaling factors on indexed components (this is related to point (10) in Outstanding issues with the new scaling tools #1648)

Legal Acknowledgement

By contributing to this software project, I agree to the following terms and conditions for my contribution:

  1. I agree my contributions are submitted under the license terms described in the LICENSE.txt file at the top level of this directory.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

Copy link
Contributor

@MarcusHolly MarcusHolly left a comment

Choose a reason for hiding this comment

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

Minor comments, but nothing that should hold this back

raise TypeError(f"{constraint} is not a constraint (or is indexed).")
self._scale_component_by_default(component=constraint, overwrite=overwrite)

def get_expression_nominal_value(self, expression):
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there not a better way to combine/differentiate the three nominal value functions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We have get_expression_nominal_values deprecated, and it can be removed in the next release. The difference between get_sum_terms_nominal_values and get_expression_nominal_value is that the former returns a list while the latter returns a float.

Comment on lines 641 to 664
def propagate_state_data_scaling(
self, target_state_data, source_state_data, overwrite: bool = False
):
"""
Propagate scaling of state variables from one StateBlockData to another.

Args:
target_state_data: StateBlockData to set scaling factors on
source_state_data: StateBlockData to use as source for scaling factors
overwrite: whether to overwrite existing scaling factors

Returns:
None
"""
target_vars = target_state_data.define_state_vars()
source_vars = source_state_data.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,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar point with this - is there a simple way to combine this with the existing function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is the proposed behavior to have propagate_state_scaling check whether the inputs are indexed or unindexed, then handle those cases appropriately?

Copy link
Contributor

Choose a reason for hiding this comment

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

Correct

Copy link
Contributor

Choose a reason for hiding this comment

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

^^^ I think this is right. The function signature and return value are the same, so might as well allow it to handle both types of inputs - that being said, I would make sure that the documentation clearly explains it.

CONFIG.declare(
"max_variable_scaling_factor",
ConfigValue(
# default=float("inf"),
Copy link
Contributor

Choose a reason for hiding this comment

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

Better to just delete these default values?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I really want to get rid of these max and min values, because we're going to need to override them in pretty much every scaler in PrOMMiS, but they're required for the Autoscalers to function. I can certainly get rid of these comments, though.

@ksbeattie ksbeattie added the Priority:High High Priority Issue or PR label Aug 14, 2025
Copy link
Contributor

@strahlw strahlw left a comment

Choose a reason for hiding this comment

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

First pass of the PR (it is very large) - there are certainly nuances that I am missing, but I left some comments of things that popped out.

None
"""
if not (isinstance(variable, VarData) or isinstance(variable, ExpressionData)):
raise TypeError(f"{variable} is not a variable/expression (or is indexed).")
Copy link
Contributor

Choose a reason for hiding this comment

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

See comment on scale_variable_by_units. I think it would be beneficial to have clearer feedback to the user if they encounter these types of errors.

Comment on lines 641 to 664
def propagate_state_data_scaling(
self, target_state_data, source_state_data, overwrite: bool = False
):
"""
Propagate scaling of state variables from one StateBlockData to another.

Args:
target_state_data: StateBlockData to set scaling factors on
source_state_data: StateBlockData to use as source for scaling factors
overwrite: whether to overwrite existing scaling factors

Returns:
None
"""
target_vars = target_state_data.define_state_vars()
source_vars = source_state_data.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,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

^^^ I think this is right. The function signature and return value are the same, so might as well allow it to handle both types of inputs - that being said, I would make sure that the documentation clearly explains it.

_log.debug(f"Created new scaling hint suffix for {_filter_unknown(blk)}")
sfx = blk.scaling_hint = Suffix(direction=Suffix.EXPORT)

return sfx
Copy link
Contributor

Choose a reason for hiding this comment

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

There's duplicate code in 84-94 and 121-131. I suggest breaking it out into a separate function and having an argument that determines whether it uses "hints" or "factors" in the type error message.

if comp is not None:
if valid_types is not None and not any(
[isinstance(comp, cls) for cls in valid_types]
):
Copy link
Contributor

Choose a reason for hiding this comment

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

You can just use the generator here, no need to encapsulate in a list

jac, _ = get_jacobian(sm, scaled=False)
assert (jacobian_cond(jac=jac, scaled=False)) == pytest.approx(
4.987e05, rel=1e-3
5.445e05, rel=1e-3
Copy link
Contributor

Choose a reason for hiding this comment

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

I take it this changes because we now scale fixed variables for the "nominal" values.


# Nominal value should be value
assert NominalValueExtractionVisitor().walk_expression(expr=m.var) == [-1]
assert NominalValueExtractionVisitor().walk_expression(expr=m.var) == [-4]
Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming this is an understood/intended change -> looking further down in the PR it looks like a "fundamental" change going on is no longer using fixed values as "nominal" values. If this is a correct understanding, please update the comment on line 130.

Inverse Sum || 9.077E+15 | Failed 200 || 1.502E+06 | Solved 6
Inverse Root Sum Squares || 1.083E+16 | Failed 200 || 9.600E+05 | Solved 6
Inverse Maximum || 1.066E+16 | Failed 200 || 7.846E+05 | Solved 6
Inverse Minimum || 7.543E+18 | Failed 200 || 5.599E+12 | Solved 16
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it clear why only the inverse minimum changed for the "perfect scaling" case. That seems a little odd to me (although percentage-wise it is a small change). Also, the user-scaling cases fail many times more - is this understood behavior, or just an observed response? (Does it need to be understood?)

@codecov
Copy link

codecov bot commented Aug 14, 2025

Codecov Report

❌ Patch coverage is 89.44724% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.02%. Comparing base (3b4ebdb) to head (0318102).
⚠️ Report is 38 commits behind head on scaling_toolbox.

Files with missing lines Patch % Lines
idaes/core/scaling/util.py 88.59% 5 Missing and 8 partials ⚠️
idaes/core/scaling/scaling_base.py 66.66% 5 Missing and 2 partials ⚠️
idaes/core/scaling/custom_scaler_base.py 98.41% 1 Missing ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           scaling_toolbox    #1649      +/-   ##
===================================================
+ Coverage            77.01%   77.02%   +0.01%     
===================================================
  Files                  395      395              
  Lines                63555    63689     +134     
  Branches             10365    10405      +40     
===================================================
+ Hits                 48944    49056     +112     
- Misses               12171    12184      +13     
- Partials              2440     2449       +9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dallan-keylogic dallan-keylogic changed the base branch from main to scaling_toolbox August 15, 2025 15:18
@dallan-keylogic dallan-keylogic merged commit ff5c700 into IDAES:scaling_toolbox Aug 15, 2025
39 checks passed
@dallan-keylogic dallan-keylogic deleted the scaling-hints-limited branch August 15, 2025 18:20
dallan-keylogic added a commit that referenced this pull request Nov 6, 2025
* rescue files from overloaded git branch

* fix due to api tweaks

* run black

* forgot to add a file

* fix test errors

* pin coolprop version

* update version, disable superancillaries

* run black

* respond to Marcus's feedback

* getting close

* address Will's comments

* tests for set_scaling_factor

* support for unions in python 3.9

* testing the scaling profiler is way too fragile

* modify test to be less fragile

* remove pdb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority:High High Priority Issue or PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants