Summary
While working on #42 / PR #113, the methanol benchmark exposed a GDPopt LOA objective-sense discrepancy. Solving the same GDP model as minimize(-profit) and maximize(profit) should be mathematically equivalent, but GDPopt returns different local solutions and terminates much earlier on the maximization form.
This is probably not part of PR #113, because that PR should keep the currently verified minimize(-profit) formulation. This issue tracks the behavior so we can reduce it to a smaller Pyomo reproducer and, if confirmed, report/fix it upstream in Pyomo.
Related:
Local reproducer
Environment:
- GDPlib branch:
fix/issue-42-methanol-gdpopt
- commit tested:
840671515c70a722f5e67fc8eb7bc921bf6d28ef
- Pyomo:
6.10.0 from the committed Pixi environment
- solver path:
SolverFactory("gdpopt").solve(..., algorithm="LOA", mip_solver="gams", nlp_solver="gams")
- local GAMS interface was available
Script:
import pyomo.environ as pyo
import pyomo.gdp as gdp
from gdplib.methanol.methanol import build_model
def solve_case(label, maximize_profit):
m = build_model()
if maximize_profit:
m.objective.deactivate()
m.max_profit_objective = pyo.Objective(expr=m.profit, sense=pyo.maximize)
res = pyo.SolverFactory("gdpopt").solve(
m,
algorithm="LOA",
mip_solver="gams",
nlp_solver="gams",
tee=False,
)
active = [
d.name
for d in m.component_data_objects(
gdp.Disjunct, active=True, sort=True, descend_into=True
)
if pyo.value(d.indicator_var)
]
print(f"CASE: {label}")
print(f"termination: {res.solver.termination_condition}")
print(f"objective: {pyo.value(m.objective) if m.objective.active else pyo.value(m.max_profit_objective)}")
print(f"profit: {pyo.value(m.profit)}")
print(f"iterations: {getattr(res.solver, 'iterations', None)}")
print(f"active: {active}")
print(f"feed1: {pyo.value(m.flows[1])}")
print(f"feed2: {pyo.value(m.flows[2])}")
print(f"product_flow: {pyo.value(m.flows[23])}")
print(f"purity: {pyo.value(m.component_flows[23, 'CH3OH'] / m.flows[23])}")
print("---")
solve_case("minimize_negative_profit", maximize_profit=False)
solve_case("maximize_profit", maximize_profit=True)
Observed output:
CASE: minimize_negative_profit
termination: optimal
objective: -1793.4292381783353
profit: 1793.4292381783353
iterations: 15
active: ['cheap_reactor', 'expensive_feed_disjunct', 'single_stage_recycle_compressor_disjunct', 'two_stage_feed_compressor_disjunct']
feed1: 0
feed2: 3.408977431099573
product_flow: 1.0
purity: 0.9
---
CASE: maximize_profit
termination: optimal
objective: -242.05445652916296
profit: -242.05445652916296
iterations: 1
active: ['cheap_feed_disjunct', 'cheap_reactor', 'two_stage_feed_compressor_disjunct', 'two_stage_recycle_compressor_disjunct']
feed1: 4.820913073983858
feed2: 0
product_flow: 1.0
purity: 0.9
---
Why this is suspicious
The two formulations are algebraically equivalent. For a local GDP/MINLP method, different starts or linearizations can lead to different local solutions, but simply expressing the same objective as maximize(profit) instead of minimize(-profit) should not make the discrete master pursue the opposite economic direction or terminate after one iteration with a dominated solution.
The LOA paper reports Example 3 optimal profit around $1.794M/yr, and the minimize(-profit) form reproduces this scale. The maximize(profit) form returns a negative-profit flowsheet.
Initial Pyomo source hypothesis
In Pyomo 6.10.0, pyomo.contrib.gdpopt.loa.GDP_LOA_Solver._setup_augmented_penalty_objective() deactivates the discrete problem objective and creates:
discrete_problem_util_block.oa_obj = Objective(sense=minimize)
This appears unconditional. _update_augmented_penalty_objective() adjusts the slack penalty sign for maximize/minimize, but it assigns:
discrete_problem_util_block.oa_obj.expr = (
discrete_objective.expr + OA_penalty_expr
)
without changing oa_obj.sense to match the original discrete objective. The rest of GDPopt does have objective-sense-aware bound updates, so this looks like a possible discrete master objective sense bug rather than a purely expected nonconvex-local-solver artifact.
This hypothesis still needs a smaller standalone reproducer before filing upstream.
Acceptance criteria
- Build a minimal GDP/GDPopt reproducer where
minimize(-f) and maximize(f) diverge under LOA.
- Confirm whether the unconditional minimization objective in GDPopt LOA is the root cause.
- If confirmed, open a Pyomo issue or PR with the minimal reproducer and candidate fix.
- After the upstream status is clear, decide whether GDPlib should keep the workaround, add an xfail regression, or update the methanol benchmark solve path.
Summary
While working on #42 / PR #113, the methanol benchmark exposed a GDPopt LOA objective-sense discrepancy. Solving the same GDP model as
minimize(-profit)andmaximize(profit)should be mathematically equivalent, but GDPopt returns different local solutions and terminates much earlier on the maximization form.This is probably not part of PR #113, because that PR should keep the currently verified
minimize(-profit)formulation. This issue tracks the behavior so we can reduce it to a smaller Pyomo reproducer and, if confirmed, report/fix it upstream in Pyomo.Related:
Local reproducer
Environment:
fix/issue-42-methanol-gdpopt840671515c70a722f5e67fc8eb7bc921bf6d28ef6.10.0from the committed Pixi environmentSolverFactory("gdpopt").solve(..., algorithm="LOA", mip_solver="gams", nlp_solver="gams")Script:
Observed output:
Why this is suspicious
The two formulations are algebraically equivalent. For a local GDP/MINLP method, different starts or linearizations can lead to different local solutions, but simply expressing the same objective as
maximize(profit)instead ofminimize(-profit)should not make the discrete master pursue the opposite economic direction or terminate after one iteration with a dominated solution.The LOA paper reports Example 3 optimal profit around
$1.794M/yr, and theminimize(-profit)form reproduces this scale. Themaximize(profit)form returns a negative-profit flowsheet.Initial Pyomo source hypothesis
In Pyomo 6.10.0,
pyomo.contrib.gdpopt.loa.GDP_LOA_Solver._setup_augmented_penalty_objective()deactivates the discrete problem objective and creates:This appears unconditional.
_update_augmented_penalty_objective()adjusts the slack penalty sign for maximize/minimize, but it assigns:without changing
oa_obj.senseto match the original discrete objective. The rest of GDPopt does have objective-sense-aware bound updates, so this looks like a possible discrete master objective sense bug rather than a purely expected nonconvex-local-solver artifact.This hypothesis still needs a smaller standalone reproducer before filing upstream.
Acceptance criteria
minimize(-f)andmaximize(f)diverge under LOA.