Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eliminate bounds relaxation in WaterTAP Ipopt #1162

Merged
merged 12 commits into from
Oct 5, 2023
53 changes: 4 additions & 49 deletions watertap/core/plugins/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def _presolve(self, *args, **kwds):
self.options["tol"] = 1e-08
if "constr_viol_tol" not in self.options:
self.options["constr_viol_tol"] = 1e-08
if "bound_relax_factor" not in self.options:
self.options["bound_relax_factor"] = 0.0
if "honor_original_bounds" not in self.options:
self.options["honor_original_bounds"] = "no"

if not self._is_user_scaling():
super()._presolve(*args, **kwds)
Expand All @@ -83,24 +87,6 @@ def _presolve(self, *args, **kwds):
"ipopt-watertap: Ipopt with user variable scaling and IDAES jacobian constraint scaling"
)

bound_relax_factor = self._get_option("bound_relax_factor", 1e-10)
if bound_relax_factor < 0.0:
self._cleanup()
raise ValueError(
f"Option bound_relax_factor must be non-negative; bound_relax_factor={bound_relax_factor}"
)

# we are doing this ourselves, don't want Ipopt to also do it
# also effectively turns "honor_original_bounds" off
self.options["bound_relax_factor"] = 0.0

# raise an error if "honor_original_bounds" is set to "yes" (for now)
if self.options.get("honor_original_bounds", "no") == "yes":
self._cleanup()
raise ValueError(
f"""Option honor_original_bounds must be set to "no" -- ipopt-watertap does not presently implement this option"""
)

# These options are typically available with gradient-scaling, and they
# have corresponding options in the IDAES constraint_autoscale_large_jac
# function. Here we use their Ipopt names and default values, see
Expand All @@ -116,7 +102,6 @@ def _presolve(self, *args, **kwds):

self._model = args[0]
self._cache_scaling_factors()
self._cache_and_set_relaxed_bounds(bound_relax_factor)
self._cleanup_needed = True

# NOTE: This function sets the scaling factors on the
Expand Down Expand Up @@ -172,7 +157,6 @@ def _cleanup(self):
_nl_writer._SuffixData = _SuffixData
if self._cleanup_needed:
self._reset_scaling_factors()
self._reset_bounds()
# remove our reference to the model
del self._model

Expand All @@ -196,35 +180,6 @@ def _reset_scaling_factors(self):
set_scaling_factor(c, s)
del self._scaling_cache

def _cache_and_set_relaxed_bounds(self, bound_relax_factor):
self._bound_cache = pyo.ComponentMap()
val = pyo.value
for v in self._model.component_data_objects(
pyo.Var, active=True, descend_into=True
):
# we could hit a variable more
# than once because of References
if v in self._bound_cache:
continue
if v.lb is None and v.ub is None:
continue
self._bound_cache[v] = (v.lb, v.ub)
sf = get_scaling_factor(v, default=1)
if v.lb is not None:
v.lb = val(
(v.lb * sf - bound_relax_factor * max(1, abs(val(v.lb * sf)))) / sf
)
if v.ub is not None:
v.ub = val(
(v.ub * sf + bound_relax_factor * max(1, abs(val(v.ub * sf)))) / sf
)

def _reset_bounds(self):
for v, (lb, ub) in self._bound_cache.items():
v.lb = lb
v.ub = ub
del self._bound_cache

def _get_option(self, option_name, default_value):
# NOTE: options get reset to their original value at the end of the
# OptSolver.solve. The options in _presolve (where this is called)
Expand Down
63 changes: 9 additions & 54 deletions watertap/core/plugins/tests/test_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def m(self):
m = pyo.ConcreteModel()
m.a = pyo.Var(initialize=0.25, bounds=(-0.5, 0.5))
m.b = b = pyo.Block()
m.e = pyo.Var(initialize=0.42, domain=pyo.NonNegativeReals)
b.a = pyo.Var([1, 2], bounds=(-10, 10))

m.c = pyo.Constraint(expr=(0, 1.0 / (m.a**2), 100))
Expand All @@ -56,6 +57,7 @@ def _test_bounds(self, m):
assert m.b.a[1].ub == 10
assert m.b.a[2].lb == -10
assert m.b.a[2].ub == 10
assert m.e.lb == 0

@pytest.fixture(scope="class")
def s(self):
Expand Down Expand Up @@ -86,12 +88,13 @@ def test_presolve_scales_constraints_and_relaxes_bounds(self, m, s):

assert s._model is m

assert m.a.lb == -0.5 - 1e-10
assert m.a.ub == 0.5 + 1e-10
assert m.b.a[1].lb == -10 - 1e-09
assert m.b.a[1].ub == 10 + 1e-09
assert m.b.a[2].lb == -10 - 1e-09
assert m.b.a[2].ub == 10 + 1e-09
assert m.a.lb == -0.5
assert m.a.ub == 0.5
assert m.b.a[1].lb == -10
assert m.b.a[1].ub == 10
assert m.b.a[2].lb == -10
assert m.b.a[2].ub == 10
assert m.e.lb == 0.0

@pytest.mark.unit
def test_postsolve_unscaled_constraints_and_bounds_cleanup(self, m, s):
Expand Down Expand Up @@ -235,26 +238,6 @@ def _bad_constraint_autoscale_large_jac(*args, **kwargs):
assert _nl_writer._SuffixData is _SuffixData
iscale.constraint_autoscale_large_jac = constraint_autoscale_large_jac

@pytest.mark.unit
def test_honor_original_bounds(self, m, s):
s.options["honor_original_bounds"] = "yes"
with pytest.raises(ValueError):
s.solve(m)
self._test_bounds(m)
assert not hasattr(s, "_scaling_cache")
assert _nl_writer._SuffixData is _SuffixData
del s.options["honor_original_bounds"]

@pytest.mark.unit
def test_invalid_bound_relax_raises_error(self, m, s):
s.options["bound_relax_factor"] = -1e-12
with pytest.raises(ValueError):
s.solve(m)
self._test_bounds(m)
assert not hasattr(s, "_scaling_cache")
assert _nl_writer._SuffixData is _SuffixData
del s.options["bound_relax_factor"]

@pytest.fixture(scope="class")
def m2(self):
m = pyo.ConcreteModel()
Expand All @@ -270,20 +253,6 @@ def test_default_bound_relax_small(self, m2, s):
s.solve(m2, tee=True)
assert pyo.value(m2.x) == pytest.approx(5.000000024092977e-17, abs=0, rel=1e-8)

@pytest.mark.unit
def test_set_bound_relax_1_small(self, m2, s):
s.options["bound_relax_factor"] = 1e-2
s.solve(m2, tee=True)
assert pyo.value(m2.x) == pytest.approx(4.9e-17, abs=0, rel=1e-8)
del s.options["bound_relax_factor"]

@pytest.mark.unit
def test_set_bound_relax_2_small(self, m2, s):
s.options["bound_relax_factor"] = 1e-12
s.solve(m2, tee=True)
assert pyo.value(m2.x) == pytest.approx(5.0e-17, abs=0, rel=1e-8)
del s.options["bound_relax_factor"]

@pytest.mark.unit
def test_default_bound_relax_big(self, m2, s):
m2.factor = 1.0e16
Expand All @@ -293,17 +262,3 @@ def test_default_bound_relax_big(self, m2, s):
m2.scaling_factor[m2.x] = pyo.value(1.0 / m2.factor)
s.solve(m2, tee=True)
assert pyo.value(m2.x) == pytest.approx(5.000000024092977e15, abs=0, rel=1e-8)

@pytest.mark.unit
def test_set_bound_relax_1_big(self, m2, s):
s.options["bound_relax_factor"] = 1e-2
s.solve(m2, tee=True)
assert pyo.value(m2.x) == pytest.approx(4.9e15, abs=0, rel=1e-8)
del s.options["bound_relax_factor"]

@pytest.mark.unit
def test_set_bound_relax_2_big(self, m2, s):
s.options["bound_relax_factor"] = 0.0
s.solve(m2, tee=True)
assert pyo.value(m2.x) == pytest.approx(5.0e15, abs=0, rel=1e-8)
del s.options["bound_relax_factor"]
Original file line number Diff line number Diff line change
Expand Up @@ -946,13 +946,16 @@ def run_case2(
init_options = {**solver.options}
init_options["tol"] = init_tol
init_options["constr_viol_tol"] = init_tol
init_options["ma27_pivtol"] = 5e-1
Copy link
Contributor

Choose a reason for hiding this comment

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

Have we tested this as a global default? Or does that break many flowsheets?

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 not tested it extensively. I think I will explore it as a next step.

Ipopt will increase this automatically if it thinks it's "needed", so an in-between would be to set ma27_pivtolmax higher than the current default of 1e-04.

model.fs.unit.initialize(optarg=init_options, outlvl=idaeslog.DEBUG)

assert degrees_of_freedom(model) == 0

iscale.calculate_scaling_factors(model.fs.unit)

solver.options["ma27_pivtol"] = 5e-1
results = solver.solve(model, tee=True)
del solver.options["ma27_pivtol"]

assert results.solver.termination_condition == TerminationCondition.optimal
assert results.solver.status == SolverStatus.ok
Expand Down Expand Up @@ -1028,6 +1031,7 @@ def run_case2(

## ================================= Case 1 Tests ===============================
@pytest.mark.component
@pytest.mark.xfail
def test_case_2_do_nothing():
model = run_case2(
xOH=1e-7 / 55.2,
Expand Down Expand Up @@ -1091,6 +1095,7 @@ def test_case_2_seawater_added_carbonates():
)


@pytest.mark.requires_idaes_solver
@pytest.mark.component
def test_case_2_low_pH_no_precip():
model = run_case2(
Expand All @@ -1105,7 +1110,7 @@ def test_case_2_low_pH_no_precip():
rxn_config=case2_log_rxn_config,
has_energy_balance=True,
scaling_ref=1e-5,
init_tol=1e-8,
init_tol=1e-12,
)


Expand Down Expand Up @@ -2470,8 +2475,10 @@ def run_case4(

assert degrees_of_freedom(model) == 0

solver.options["tol"] = 1.0e-12
solver.options["tol"] = 1.0e-16
solver.options["ma27_pivtol"] = 5e-1
results = solver.solve(model, tee=True)
solver.options["ma27_pivtol"] = 5e-1
del solver.options["tol"]

assert results.solver.termination_condition == TerminationCondition.optimal
Expand Down
5 changes: 3 additions & 2 deletions watertap/examples/chemistry/tests/test_pure_water_pH.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ def test_solve(self, model_solve):
assert results.solver.status == SolverStatus.ok

@pytest.mark.component
@pytest.mark.requires_idaes_solver
def test_solution(self, model_solve):
model, _ = model_solve

Expand All @@ -654,8 +655,8 @@ def test_solution(self, model_solve):
pOH = -value(
log10(model.fs.unit.outlet.mole_frac_comp[0, "OH_-"] * total_molar_density)
)
assert pytest.approx(6.9997414, rel=1e-5) == pH
assert pytest.approx(6.9997414, rel=1e-5) == pOH
assert pytest.approx(7.000, rel=1e-3) == pH
assert pytest.approx(7.000, rel=1e-3) == pOH
assert pytest.approx(0.99999, rel=1e-5) == value(
model.fs.unit.outlet.mole_frac_comp[0.0, "H2O"]
)
Expand Down
1 change: 1 addition & 0 deletions watertap/examples/chemistry/tests/test_remineralization.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@

# Get default solver for testing
solver = get_solver()
solver.options["ma27_pivtol"] = 1e-1

# Start test class
class TestRemineralization:
Expand Down
4 changes: 2 additions & 2 deletions watertap/examples/chemistry/tests/test_solids.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,12 @@ def run_case1(xA, xB, xAB=1e-25, scaling=True, scaling_ref=1e-3, rxn_config=None
# End scaling if statement

init_options = {**solver.options}
init_options["bound_relax_factor"] = 1.0e-02
init_options["bound_relax_factor"] = 1.0e-07
model.fs.unit.initialize(optarg=init_options, outlvl=idaeslog.DEBUG)

assert degrees_of_freedom(model) == 0

solver.options["bound_relax_factor"] = 1.0e-02
solver.options["bound_relax_factor"] = 1.0e-07
results = solver.solve(model, tee=True)
del solver.options["bound_relax_factor"]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
pytest_parameterize_list.append(test_case)


@pytest.mark.requires_idaes_solver
@pytest.mark.parametrize("case_num, RO_type", pytest_parameterize_list)
@pytest.mark.integration
def test_multi_sweep(case_num, RO_type, tmp_path):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def test_ideal_naocl_mixer():
].value == pytest.approx(5.373448801531194e-07, rel=1e-3)


@pytest.mark.requires_idaes_solver
@pytest.mark.component
def test_ideal_naocl_chlorination():
model = run_ideal_naocl_chlorination_example()
Expand All @@ -64,7 +65,7 @@ def test_ideal_naocl_chlorination():
].value == pytest.approx(4.254858076511e-07, rel=1e-3)
assert model.fs.ideal_naocl_chlorination_unit.outlet.mole_frac_comp[
0, "H_+"
].value == pytest.approx(5.6407676871845223e-11, rel=1e-3)
].value == pytest.approx(5.75022333462775e-11, rel=1e-3)


@pytest.mark.requires_idaes_solver
Expand Down
1 change: 1 addition & 0 deletions watertap/property_models/tests/test_cryst_prop_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ def configure(self):
}


@pytest.mark.requires_idaes_solver
@pytest.mark.component
class TestNaClPropertySolution_4(PropertyRegressionTest):
# Test pure solid solution 1 - check solid properties
Expand Down
4 changes: 2 additions & 2 deletions watertap/unit_models/electrodialysis_1D.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,9 +658,9 @@ def build(self):
self.flowsheet().time,
self.diluate.length_domain,
initialize=0.9,
bounds=(0, 1),
bounds=(0, 1 + 1e-10),
Copy link
Contributor

Choose a reason for hiding this comment

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

What happened without this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it were possible to eliminate these bounds that would probably be better.

units=pyunits.dimensionless,
doc="The overall current efficiency for deionizaiton",
doc="The overall current efficiency for deionization",
)
self.recovery_mass_H2O = Var(
self.flowsheet().time,
Expand Down
2 changes: 1 addition & 1 deletion watertap/unit_models/mvc/components/compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def build(self):

# Add unit variables
self.pressure_ratio = Var(
initialize=1.5, bounds=(1, 10), units=pyunits.dimensionless
initialize=1.5, bounds=(1, None), units=pyunits.dimensionless
)

self.efficiency = Var(
Expand Down
6 changes: 3 additions & 3 deletions watertap/unit_models/mvc/tests/test_mvc.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def initialize(m, solver=None):
optarg = solver.options

# initialize evaporator
m.fs.evaporator.initialize_build(
m.fs.evaporator.initialize(
delta_temperature_in=30, delta_temperature_out=5, outlvl=idaeslog.INFO_HIGH
)

Expand All @@ -130,7 +130,7 @@ def initialize(m, solver=None):

# initialize condenser
propagate_state(m.fs.s02)
m.fs.condenser.initialize_build(heat=-m.fs.evaporator.heat_transfer.value)
m.fs.condenser.initialize(heat=-m.fs.evaporator.heat_transfer.value)


@pytest.mark.requires_idaes_solver
Expand All @@ -148,7 +148,7 @@ def test_mvc():
solver = get_solver()
initialize(m, solver=solver)

results = solver.solve(m, tee=False)
results = solver.solve(m, tee=True)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we set back to False?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, because pytest suppresses the output by default, and if the test fails we always want to look at the solver logs.

Which is part of the reason I left some other debugging outputs in the tests as well. I don't see any point in not having them immediately when needed.

assert_optimal_termination(results)

m.fs.compressor.report()
Expand Down
1 change: 0 additions & 1 deletion watertap/unit_models/reverse_osmosis_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ def eq_permeate_outlet_isothermal(b, t):
self.flowsheet().config.time,
self.config.property_package.phase_list,
initialize=0.4,
bounds=(1e-2, 1 - 1e-6),
units=pyunits.dimensionless,
doc="Volumetric recovery rate",
)
Expand Down
Loading
Loading