Skip to content

Conversation

@michaelbynum
Copy link
Contributor

Summary/Motivation:

This PR extracts the code for detecting changes in models from the persistent solver interfaces to its own, independent functionality. I will have a separate PR shortly that removes the code from the solver interfaces and updates them to use this code.

Changes proposed in this PR:

  • add a module to contrib called observer that can be used to inform other classes of what changed in a pyomo model.

Legal Acknowledgement

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

  1. I agree my contributions are submitted under the BSD license.
  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.

@codecov
Copy link

codecov bot commented Aug 13, 2025

Codecov Report

❌ Patch coverage is 91.39073% with 52 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.33%. Comparing base (7816915) to head (bb60959).

Files with missing lines Patch % Lines
pyomo/contrib/observer/model_observer.py 90.79% 50 Missing ⚠️
pyomo/contrib/observer/component_collector.py 96.72% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3695      +/-   ##
==========================================
+ Coverage   89.27%   89.33%   +0.05%     
==========================================
  Files         896      898       +2     
  Lines      103687   104291     +604     
==========================================
+ Hits        92567    93166     +599     
- Misses      11120    11125       +5     
Flag Coverage Δ
builders 29.06% <21.68%> (?)
default 85.96% <91.39%> (?)
expensive 35.78% <21.68%> (?)
linux 86.99% <91.36%> (-2.06%) ⬇️
linux_other 86.99% <91.36%> (+0.02%) ⬆️
osx 83.14% <91.36%> (+0.04%) ⬆️
win 85.26% <91.36%> (+0.04%) ⬆️
win_other 85.26% <91.36%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

@mrmundt mrmundt self-requested a review August 19, 2025 18:39
Copy link
Member

@jsiirola jsiirola left a comment

Choose a reason for hiding this comment

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

Overall, this is great. I have lots of questions, but very few should prohibit merging this.



def handle_var(node, collector):
collector.variables[id(node)] = node
Copy link
Member

Choose a reason for hiding this comment

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

This is basically reimplementing a version of ComponentSet. Is there a reason not to re-use that object?

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 have found that doing this manually is faster. That is the only reason. I'm not sure if the difference is enough to justify or not.

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'll try to get some numbers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This seems to be about twice as fast.

Copy link
Member

Choose a reason for hiding this comment

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

OK - but we ought to see why ComponentSet is slower and try to fix it there.

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 don't disagree, but I don't think that needs to be part of the PR. We can always change the underlying implementation of this later. None of this is exposed to the user.

Comment on lines 617 to 618
elif v.value != _value:
vars_to_update.append(v)
Copy link
Member

Choose a reason for hiding this comment

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

Don't you also have to update a constraint if the var is fixed?

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 think this is up to the observer... Worth a discussion.

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'm not sure what the best solution is for this. I think we need more use cases. I vote we leave it for now. Here are a couple of examples that make this challenging:

m.x * (m.y + m.z) == 0
m.x.fix(1)
opt.solve(m)
m.x.value = 2

Right now, this works fine for the existing persistent solver interfaces because changing the value of m.x does not change the "structure" of the constraint (i.e., it remains linear). Essentially, we create a "mutable linear coefficient" and update that coefficient when the value of the variable changes. Furthermore, this does not require reconstructing the constraint in any way. However,

(m.y + m.z) ** m.x == 0
m.x.fix(1)

will currently just result in an exception for both gurobi persistent and highs persistent (scip is fine).

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've been thinking about this more. Maybe we can add some public methods to ModelChangeDetector to provide more information if necessary. For example, we could add a method to get all of the constraints used by a variable (the change detector already has that data).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jsiirola and I discussed this further and agreed that expanding the interface was a good solution. I added to the docstring explaining that we can expand the interface, but we are waiting to do so until we have a need.

@jsiirola
Copy link
Member

Additional question: who is responsible for verifying that the model does not contain any active components that the Solver/Observer doesn't know how to handle? This appears to assume that the responsibility lies with the Solver, but does that mean that the solver needs to also implement it's own observer to make sure that "unallowable" active components aren't added between solves? That makes me think that maybe the responsibility needs to lie in this observer...

@michaelbynum michaelbynum marked this pull request as ready for review October 21, 2025 06:32
@michaelbynum
Copy link
Contributor Author

Okay, I did a fairly substantial refactor of this. The ModelChangeDetector now also tells the Observer the reason for the change instead of just the fact that something changed. For example, if a variable was modified, there is an enum to specify what part of the variable changed (e.g., bounds, domain, etc.). This refactor also makes some improvements to the logic so that we don't remove and add a modified constraint (in the past, that could potentially also lead to removing and re-adding variables unnecessarily).

I'm still working on adding more tests, but I think this should be ready for review.

Copy link
Contributor Author

@michaelbynum michaelbynum left a comment

Choose a reason for hiding this comment

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

Add a test for nested Expressions (done)

@michaelbynum
Copy link
Contributor Author

I think I am finally done with this PR.

@michaelbynum
Copy link
Contributor Author

I think that codecov is confused. Coverage should be over 90%.

@blnicho blnicho moved this from Todo to Review In Progress in Pyomo 6.10 Nov 11, 2025
Copy link
Member

@jsiirola jsiirola left a comment

Choose a reason for hiding this comment

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

Overall, this looks really good. Some minor questions / nits that would be nice to discuss / implement, but nothing that needs to block merging.

if v in self._referenced_variables:
raise ValueError(f'Variable {v.name} has already been added')
self._updates.vars_to_update[v] |= Reason.added
self._referenced_variables[v] = (OrderedSet(), OrderedSet(), ComponentSet())
Copy link
Member

Choose a reason for hiding this comment

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

The code will be more readable / maintainable if we use a namedtuple here

if p in self._referenced_params:
raise ValueError(f'Parameter {p.name} has already been added')
self._updates.params_to_update[p] |= Reason.added
self._referenced_params[p] = (
Copy link
Member

Choose a reason for hiding this comment

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

The code will be more readable / maintainable if we use a namedtuple here

self.objs_to_update = DefaultComponentMap(_default_reason)
self.observers = observers

def run(self):
Copy link
Member

Choose a reason for hiding this comment

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

Stylistic nit, but would notify() be more descriptive than run()?

v._lb,
v._ub,
v.fixed,
v.domain.get_interval(),
Copy link
Member

Choose a reason for hiding this comment

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

This is OK for now, but:

  • will break down as soon as we have solver interfaces that support anything other than simple bounded domains (e.g., this won't work for semi-continuous domains)
  • get_interval() can be inefficient for custom sets

The long-term solution is to probably maintain a list of known domains and perform change detection on them (much like we do for Params). The short-term solution is probably to document this issue in a comment here.

Comment on lines +935 to +939
reason = Reason.no_change
if _val != val:
reason |= Reason.value
if reason:
self._updates.params_to_update[p] |= reason
Copy link
Member

Choose a reason for hiding this comment

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

Cant this all be simplified to:

Suggested change
reason = Reason.no_change
if _val != val:
reason |= Reason.value
if reason:
self._updates.params_to_update[p] |= reason
if _val != val:
self._updates.params_to_update[p] |= Reason.value

self._referenced_params.pop(p)
self._params.pop(p)

def _update_var_bounds(self, v: VarData):
Copy link
Member

Choose a reason for hiding this comment

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

Is update the right verb here? Would _record_var_bounds be more descriptive?

Comment on lines +1069 to +1070
reason |= Reason.sos_items
self._updates.sos_to_update[c] |= reason
Copy link
Member

Choose a reason for hiding this comment

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

As above, can't this be simplified to remove the reason local variable:

Suggested change
reason |= Reason.sos_items
self._updates.sos_to_update[c] |= reason
self._updates.sos_to_update[c] |= Reason.sos_items

self._update_sos_constraints(cons)
self._updates.run()

def _update_obj_expr(self, obj: ObjectiveData):
Copy link
Member

Choose a reason for hiding this comment

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

As above, should the verb be changed from update to record?

] = ComponentMap() # maps objective to (expression, sense)

# maps constraints/objectives to list of tuples (named_expr, named_expr.expr)
self._named_expressions: MutableMapping[
Copy link
Member

Choose a reason for hiding this comment

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

Why is this _named_expressions and not _con_named_expressions?

ObjectiveData, Tuple[Union[NumericValue, float, int, None], ObjectiveSense]
] = ComponentMap() # maps objective to (expression, sense)

# maps constraints/objectives to list of tuples (named_expr, named_expr.expr)
Copy link
Member

Choose a reason for hiding this comment

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

I think this implementation is OK for now, but named expressions logically form a tree. By flattening the tree here the observer will end up performing repeated work any time an expression is shared. Maybe not a huge issue for Gurobi, but this will be a bigger deal for things like IDAES / ipopt models.

I would recommend adding a TODO to the code to remind us that eventually we should consider updating the internals to store that hierarchy so that we never need to walk any (sub) expression more than once.

@github-project-automation github-project-automation bot moved this from Review In Progress to Reviewer Approved in Pyomo 6.10 Nov 12, 2025
@michaelbynum
Copy link
Contributor Author

Thanks for the feedback, @jsiirola. These are all great suggestions. As we discussed, I am going to go ahead an hit merge, so that #3698 can be reviewed more easily. I'll start working on these changes in a separate PR.

@michaelbynum michaelbynum merged commit 4696230 into Pyomo:main Nov 12, 2025
64 of 65 checks passed
@github-project-automation github-project-automation bot moved this from Reviewer Approved to Done in Pyomo 6.10 Nov 12, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants