Skip to content

Commit 3174edf

Browse files
DLWoodruffclaude
andauthored
add an outer_bound_only option to solve_one (#802)
* add an outer_bound_only option to solve_one * Have the Lagrangian spokes ask only for an outer bound Thread the new solve_one outer_bound_only option through solve_loop (SPOpt and PHBase) and switch the Lagrangian spokes onto it, so their solves skip loading a solution they never look at. Whether a solution is needed is a property of the spoke rather than of each call, so it is an `outer_bound_only` attribute on _LagrangianMixin rather than an argument on lagrangian(). Three cases opt out: - ReducedCostsSpoke reads x and the rc suffix off the solved subproblems (class attribute). - subgradient_while_waiting makes Compute_Xbar read the x values left behind by the previous solve (set in LagrangianOuterBound.main). - lagranger, until the first nonants arrive from the hub: it computes xbar from its own x until it has the hub's. Also in solve_one's outer_bound_only branch: mark solution_available False (no solution was loaded, so the Vars hold pre-solve values, and the staleness check and PRIOR_SOLUTION warmstart must not treat them as a solution), re-raise a solver exception instead of letting it surface as an AttributeError on the missing results object, and fix the assert, which asserted a tuple and so could never fire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix invalid outer bounds and the cgbase positional solve_loop call Two bugs, both caught by CI on the previous commit. Gate the outer_bound_only bound extraction behind the existing not_good_enough_results check instead of running ahead of it. Without prox, a Lagrangian subproblem is routinely unbounded, and the results object then carries no usable bound: gurobi leaves Lower_bound None (so Ebound died on prob * None) while other solvers return garbage, which was published as a valid outer bound -- test_cvar_lagrangian_outer_bound saw 1.9e9 against a true optimum of -220700. A bad solve now takes the path it took before this option existed: gripe, no solution, and outer_bound left at its previous value, which is still a valid outer bound since any Lagrangian dual value bounds the original problem. The remaining None check can only fire on an otherwise usable solve. Pass solve_loop's arguments by keyword in cgbase. It called super() positionally, so inserting outer_bound_only ahead of warmstart in the signature slid warmstart=True into the outer_bound_only slot, tripping the assert. cgbase was the only positional caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Test outer_bound_only first, gated on bound rather than solution not_good_enough_results asks whether there is a usable *solution*, which is the wrong question for a bound-only solve: a subproblem stopped by a time limit or a gap can carry no incumbent at all and still report the outer bound the caller is there for. Gating the bound-only path behind it threw those bounds away. So test outer_bound_only first, with its own gate. no_outer_bound_results is the bound-shaped analog of not_good_enough_results: same disqualifying terminations (infeasible, unbounded, error), minus the two clauses about solution presence and solution status. When it does disqualify a solve there is no meaningful bound to report -- solvers variously return None, a sentinel, or garbage -- so outer_bound keeps its previous value, which is still a valid outer bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Guard the outer bound extraction with a try block The helper rules on the routine, condition-detectable outcomes; the try block catches what it cannot foresee (an empty results.Problem, say) and re-raises with the scenario and status attached. Raising is right there: no_outer_bound_results has already accounted for the bound-less solves, so anything still throwing is a surprise rather than a routine outcome. Note this try block is not what catches an unbounded subproblem. Pyomo assigns None (gurobi) or the last iterate's objective (cplex) to Problem[0].Lower_bound rather than raising, so nothing propagates out of the attribute access; the termination condition is the only signal that survives both plugins, and the None check backstops the rest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address the Copilot review: raise instead of assert, cgbase signature Raise a ValueError rather than assert on outer_bound_only with need_solution. python -O strips asserts, and this guard earns its keep precisely there: it is what caught cgbase passing warmstart into the outer_bound_only slot, whose silent form is bound-only solves in code that builds columns from the solutions. Verified it now fires under both python and python -O. This matches the convention already spelled out in reduced_costs_spoke.py. Accept and forward outer_bound_only in CGBase.solve_loop, which had drifted from the base signature -- that drift is what made the positional call break in the first place. Column generation needs the solutions, so the option is not useful there; it is accepted to keep the override honest, and a caller who asks for it raises out of solve_one because need_solution defaults True. xhat_eval is deliberately left alone: its signature is a different shape and it defaults need_solution=False, so the guard would not catch misuse there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stop screening TerminationCondition.error out of the outer bound xpress reports a MIP that ran out of time with no incumbent as status=aborted, TerminationCondition=error (mip_no_sol_found in xpress_direct.py), and then hands over xprob_attrs.bestbound anyway -- a perfectly good outer bound. Screening on error threw it away and the Lagrangian spoke died in Ebound with no bound to report, which is the case outer_bound_only exists to serve. Screen as little as possible here: a bound from a solve that went badly is the whole point of asking for a bound only. Infeasible and unbounded stay screened, because there the danger is not a missing value but a misleading one -- cplex fills in the last iterate's objective for an unbounded LP, a plausible finite number that is not a bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Start subproblems at the vacuous outer bound instead of no attribute Only solve_one ever wrote outer_bound, so until it did there was nothing to read, and Ebound died with "'ScalarBlock' object has no attribute 'outer_bound'" whenever a first solve reported no bound. Give every subproblem the bound it actually holds before its first solve: -inf minimizing, +inf maximizing. That is what is known at that point, it keeps Ebound arithmetic, and being the worst bound there is it never wins a comparison at the hub. This predates outer_bound_only -- the old not_good_enough_results path left the attribute unset the same way. cgbase reads outer_bound as a reduced cost and tested hasattr to decide whether one existed, which is now always true, so it tests the value instead: it sums these into sum_redcosts, where an infinity would quietly poison the total rather than fail loudly as None did. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Copilot review: keyword-only outer_bound_only, docstrings, traceback, tests - Make outer_bound_only keyword-only and restore warmstart to its original positional slot in solve_one and the three solve_loop overrides, so existing positional calls can't land on it. - Correct the docstrings: a bound-only solve that reports no bound keeps the previous (possibly vacuous) outer bound rather than raising. - Use a bare raise when an unexpected bound-extraction error surfaces so the original traceback is preserved. - Add mpisppy/tests/test_outer_bound_only.py covering the bound-only path, the need_solution guard, the normal solve, and the keyword-only contract; wire it into run_coverage.bash and test_pr_and_main.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Use None, not +/-inf, for a not-yet-computed subproblem bound Addresses bknueven's review: a subproblem that has not been solved (or whose bound-only solve reported no bound) has not *computed* a bound, so it should hold None rather than a vacuous +/-inf dressed up as a real number. - _set_initial_bounds: seed outer_bound/inner_bound with None. - Ebound: return None if any scenario (collectively, across ranks) is missing its outer bound, since the expectation cannot then be formed; the missing check is Allreduce'd so every rank agrees before the sum reduction. - Teach the consumers to treat None as 'no bound to offer': the Lagrangian and Lagranger spokes skip send_bound; PHBase.Iter0 and Subgradient keep the prior best bound; post_solve_bound prints 'not available'; the cross-scenario cut extension folds None to the weakest bound for its max/min aggregation. - Extend test_outer_bound_only with solver-free coverage of the None path: initial bounds are None, Ebound is None when any bound is missing and the weighted sum when all are present, and is a finite number after a successful bound-only solve. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a2f0b82 commit 3174edf

13 files changed

Lines changed: 432 additions & 44 deletions

.github/workflows/test_pr_and_main.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ jobs:
9898
run: |
9999
coverage run $COV_ARGS -m pytest mpisppy/tests/test_cvar.py -v
100100
101+
- name: Test outer_bound_only
102+
run: |
103+
coverage run $COV_ARGS -m pytest mpisppy/tests/test_outer_bound_only.py -v
104+
101105
- name: Test chance_constraint
102106
run: |
103107
coverage run $COV_ARGS -m pytest mpisppy/tests/test_chance_constraint.py -v

mpisppy/cgbase.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# full copyright and license information.
88
###############################################################################
99

10+
import math
1011
import time
1112
import pyomo.environ as pyo
1213

@@ -411,8 +412,13 @@ def build_columns_from_subproblem_solutions(self, sname, scenario):
411412
red_cost=None
412413
if hasattr(model, "base_obj_expr"):
413414
scen_cost = pyo.value(model.base_obj_expr)
414-
if hasattr(model._mpisppy_data, "outer_bound"):
415-
red_cost = model._mpisppy_data.outer_bound
415+
# Subproblems now start life holding the vacuous outer bound rather
416+
# than no attribute at all, so test the value: an infinite bound means
417+
# the solve reported none, and the caller sums these into
418+
# sum_redcosts, where an infinity would quietly poison the total.
419+
outer_bound = getattr(model._mpisppy_data, "outer_bound", None)
420+
if outer_bound is not None and math.isfinite(outer_bound):
421+
red_cost = outer_bound
416422
x_vec = {ndn_i: pyo.value(xvar) for ndn_i, xvar in scenario._mpisppy_data.nonant_indices.items()}
417423
return sname, red_cost, scen_cost,x_vec
418424

@@ -478,7 +484,9 @@ def solve_loop(self, solver_options=None,
478484
tee=False,
479485
verbose=False,
480486
need_solution=True,
481-
warmstart=sputils.WarmstartStatus.FALSE):
487+
warmstart=sputils.WarmstartStatus.FALSE,
488+
*,
489+
outer_bound_only=False):
482490
""" Loop over `local_subproblems` and solve them in a manner
483491
dicated by the arguments.
484492
@@ -506,6 +514,13 @@ def solve_loop(self, solver_options=None,
506514
Default True
507515
warmstart (bool, optional):
508516
If True, warmstart the subproblem solves. Default False.
517+
outer_bound_only (boolean, optional):
518+
If True, populate outer_bound *only*; no solution is loaded, so
519+
need_solution must be False. Column generation builds its
520+
columns from the subproblem solutions, so this is here to keep
521+
the signature honest with the base class rather than because
522+
it is useful: asking for it (with the default need_solution)
523+
raises out of solve_one. Keyword-only. Default False.
509524
"""
510525

511526
""" Developer notes:
@@ -519,14 +534,15 @@ def solve_loop(self, solver_options=None,
519534
self.update_subproblem_duals_from_mp(self.pi_values, self.mu_values)
520535

521536
super().solve_loop(
522-
solver_options,
523-
dtiming,
524-
gripe,
525-
disable_pyomo_signal_handling,
526-
tee,
527-
verbose,
528-
need_solution,
529-
warmstart,
537+
solver_options=solver_options,
538+
dtiming=dtiming,
539+
gripe=gripe,
540+
disable_pyomo_signal_handling=disable_pyomo_signal_handling,
541+
tee=tee,
542+
verbose=verbose,
543+
need_solution=need_solution,
544+
outer_bound_only=outer_bound_only,
545+
warmstart=warmstart,
530546
)
531547

532548

mpisppy/cylinders/lagranger_bounder.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ def _lagrangian(self, iternum):
4040
if self.rho_rescale_factors is not None\
4141
and iternum in self.rho_rescale_factors:
4242
self._rescale_rho(self.rho_rescale_factors[iternum])
43+
# Until the first nonants arrive, _update_weights_and_solve computes
44+
# xbar from the x values left behind by this solve, so we cannot skip
45+
# loading the solution. Once they have arrived they never go back to
46+
# nan, and xbar comes from the hub's nonants instead.
47+
self.outer_bound_only = not np.isnan(self.localnonants[0])
4348
return self.lagrangian()
4449

4550
def _rescale_rho(self,rf):
@@ -105,7 +110,10 @@ def main(self):
105110
self.opt.extobject.post_iter0()
106111
self.opt._PHIter = 1
107112

108-
self.send_bound(self.trivial_bound)
113+
# trivial_bound is None if the iter0 solve produced no outer bound;
114+
# there is nothing to send in that case.
115+
if self.trivial_bound is not None:
116+
self.send_bound(self.trivial_bound)
109117
if extensions:
110118
self.opt.extobject.post_iter0_after_sync()
111119

mpisppy/cylinders/lagrangian_bounder.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212

1313
class _LagrangianMixin:
1414

15+
# A Lagrangian solve is only ever asked for its outer bound, so by default
16+
# we don't pay to load a solution. Spokes that also need the x (or rc)
17+
# values off the solved subproblems set this to False; see
18+
# ReducedCostsSpoke and the subgradient_while_waiting option.
19+
outer_bound_only = True
20+
1521
def lagrangian_prep(self):
1622
# Split up PH_Prep? Prox option is important for APH.
1723
# Seems like we shouldn't need the Lagrangian stuff, so attach_prox=False
@@ -24,7 +30,7 @@ def lagrangian_prep(self):
2430
self.opt._reenable_W()
2531
self.opt._create_solvers()
2632

27-
def lagrangian(self, need_solution=True, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION):
33+
def lagrangian(self, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION):
2834
# update the nonant bounds, if possible, for a tighter relaxation
2935
self.receive_nonant_bounds()
3036
verbose = self.opt.options['verbose']
@@ -41,7 +47,8 @@ def lagrangian(self, need_solution=True, warmstart=sputils.WarmstartStatus.PRIOR
4147
gripe=True,
4248
tee=teeme,
4349
verbose=verbose,
44-
need_solution=need_solution,
50+
need_solution=not self.outer_bound_only,
51+
outer_bound_only=self.outer_bound_only,
4552
warmstart=warmstart,
4653
)
4754
''' DTM (dlw edits): This is where PHBase Iter0 checks for scenario
@@ -63,23 +70,28 @@ class LagrangianOuterBound(_PreLoopXhatMixin, _LagrangianMixin, mpisppy.cylinder
6370

6471
converger_spoke_char = 'L'
6572

66-
def _set_weights_and_solve(self, need_solution, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION):
73+
def _set_weights_and_solve(self, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION):
6774
self.opt.W_from_flat_list(self.localWs) # Sets the weights
68-
return self.lagrangian(need_solution=need_solution, warmstart=warmstart)
75+
return self.lagrangian(warmstart=warmstart)
6976

70-
def do_while_waiting_for_new_Ws(self, need_solution, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION):
77+
def do_while_waiting_for_new_Ws(self, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION):
7178
if self.opt.options.get("subgradient_while_waiting", False):
7279
# compute a subgradient step
7380
self.opt.Compute_Xbar(self.verbose)
7481
self.opt.Update_W(self.verbose)
75-
bound = self.lagrangian(need_solution=need_solution, warmstart=warmstart)
82+
bound = self.lagrangian(warmstart=warmstart)
7683
if bound is not None:
7784
self.send_bound(bound)
7885

79-
def main(self, need_solution=False):
86+
def main(self):
8087
self.verbose = self.opt.options['verbose']
8188
extensions = self.opt.extensions is not None
8289

90+
if self.opt.options.get("subgradient_while_waiting", False):
91+
# Compute_Xbar reads the x values left behind by the previous
92+
# solve, so every solve has to load its solution.
93+
self.outer_bound_only = False
94+
8395
self.lagrangian_prep()
8496

8597
if self._jensens_enabled():
@@ -93,7 +105,7 @@ def main(self, need_solution=False):
93105

94106
# setting this for PH extensions used by this Spoke
95107
self.opt._PHIter = 0
96-
self.trivial_bound = self.lagrangian(need_solution=need_solution, warmstart=sputils.WarmstartStatus.USER_SOLUTION)
108+
self.trivial_bound = self.lagrangian(warmstart=sputils.WarmstartStatus.USER_SOLUTION)
97109

98110
if extensions:
99111
self.opt.extobject.post_iter0()
@@ -103,15 +115,18 @@ def main(self, need_solution=False):
103115
# transition; static iterk options come from the layer fold.
104116
self.opt.current_solver_options = {}
105117

106-
self.send_bound(self.trivial_bound)
118+
# trivial_bound is None if the iter0 solve produced no outer bound;
119+
# there is nothing to send in that case.
120+
if self.trivial_bound is not None:
121+
self.send_bound(self.trivial_bound)
107122
if extensions:
108123
self.opt.extobject.post_iter0_after_sync()
109124

110125
while not self.got_kill_signal():
111126
if self.update_Ws():
112127
if extensions:
113128
self.opt.extobject.miditer()
114-
bound = self._set_weights_and_solve(need_solution=need_solution, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION)
129+
bound = self._set_weights_and_solve(warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION)
115130
if extensions:
116131
self.opt.extobject.enditer()
117132
if bound is not None:
@@ -120,4 +135,4 @@ def main(self, need_solution=False):
120135
self.opt.extobject.enditer_after_sync()
121136
self.opt._PHIter += 1
122137
else:
123-
self.do_while_waiting_for_new_Ws(need_solution=need_solution, warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION)
138+
self.do_while_waiting_for_new_Ws(warmstart=sputils.WarmstartStatus.PRIOR_SOLUTION)

mpisppy/cylinders/reduced_costs_spoke.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ class ReducedCostsSpoke(LagrangianOuterBound):
8989

9090
converger_spoke_char = 'R'
9191

92+
# unlike a plain Lagrangian spoke, this one reads the x values and the rc
93+
# suffix off the solved subproblems, so the solves have to load a solution
94+
outer_bound_only = False
95+
9296
def __init__(self, *args, **kwargs):
9397
super().__init__(*args, **kwargs)
9498
self.bound_tol = self.opt.options['rc_bound_tol']
@@ -201,10 +205,8 @@ def lagrangian_prep(self):
201205
s.rc = pyo.Suffix(direction=pyo.Suffix.IMPORT)
202206
super().lagrangian_prep()
203207

204-
def lagrangian(self, need_solution=True, warmstart=False):
205-
if not need_solution:
206-
raise RuntimeError("ReducedCostsSpoke always needs a solution to work")
207-
bound = super().lagrangian(need_solution=need_solution, warmstart=False)
208+
def lagrangian(self, warmstart=False):
209+
bound = super().lagrangian(warmstart=False)
208210
if bound is not None:
209211
self.extract_and_store_reduced_costs()
210212
self.update_bounding_functions(bound)
@@ -436,16 +438,12 @@ def update_nonant_bounds(self):
436438
if bounds_modified > 0:
437439
global_toc(f"{self.__class__.__name__}: tightened {int(bounds_modified)} variable bounds", self.cylinder_rank == 0)
438440

439-
def do_while_waiting_for_new_Ws(self, need_solution, warmstart=False):
441+
def do_while_waiting_for_new_Ws(self, warmstart=False):
440442
# RC is an LP, should not need a warmstart with _value
441-
super().do_while_waiting_for_new_Ws(need_solution=need_solution, warmstart=False)
443+
super().do_while_waiting_for_new_Ws(warmstart=False)
442444
# might as well see if a tighter upper bound has come along
443445
self.extract_and_store_updated_nonant_bounds(new_dual=False)
444446

445-
def main(self):
446-
# need the solution for ReducedCostsSpoke
447-
super().main(need_solution=True)
448-
449447

450448
def _lb_generator(var_iterable):
451449
for v in var_iterable:

mpisppy/extensions/cross_scen_extension.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,15 @@ def _check_bound(self):
102102
need_solution=False,
103103
)
104104

105-
local_obs = np.fromiter((s._mpisppy_data.outer_bound for s in opt.local_scenarios.values()),
106-
dtype="d", count=len(opt.local_scenarios))
105+
# A subproblem that produced no outer bound holds None; for the max/min
106+
# aggregation it should be the weakest possible bound (so it is ignored),
107+
# which is the vacuous bound: -inf when minimizing, +inf when maximizing.
108+
vacuous_ob = -np.inf if opt.is_minimizing else np.inf
109+
local_obs = np.fromiter(
110+
(vacuous_ob if s._mpisppy_data.outer_bound is None
111+
else s._mpisppy_data.outer_bound
112+
for s in opt.local_scenarios.values()),
113+
dtype="d", count=len(opt.local_scenarios))
107114

108115
local_ob = np.empty(1)
109116
if opt.is_minimizing:

mpisppy/opt/subgradient.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,11 @@ def solve_loop(self,
102102
)
103103

104104
# set self.best_bound_obj_val if we don't have any additional fixed variables
105-
if self._can_update_best_bound():
106-
self.best_bound_obj_val = self.Ebound(verbose)
105+
# (Ebound is None if a subproblem produced no outer bound; keep the prior
106+
# best bound in that case rather than overwriting it with nothing)
107+
bound = self.Ebound(verbose)
108+
if bound is not None and self._can_update_best_bound():
109+
self.best_bound_obj_val = bound
107110

108111
def _can_update_best_bound(self):
109112
# TODO: is our class hierarchy wrong?

mpisppy/phbase.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,9 @@ def post_solve_bound(self, solver_options=None, verbose=False):
586586
self._reenable_prox()
587587

588588
if (verbose and self.cylinder_rank == 0):
589-
print(f'Post-solve Lagrangian bound: {bound:.4f}')
589+
# bound is None if any subproblem produced no outer bound.
590+
shown = "not available" if bound is None else f"{bound:.4f}"
591+
print(f'Post-solve Lagrangian bound: {shown}')
590592
return bound
591593

592594

@@ -599,7 +601,9 @@ def solve_loop(self, solver_options=None,
599601
tee=False,
600602
verbose=False,
601603
need_solution=True,
602-
warmstart=sputils.WarmstartStatus.FALSE):
604+
warmstart=sputils.WarmstartStatus.FALSE,
605+
*,
606+
outer_bound_only=False):
603607
""" Loop over `local_scenarios` and solve them in a manner
604608
dictated by the arguments.
605609
@@ -632,6 +636,11 @@ def solve_loop(self, solver_options=None,
632636
Default True
633637
warmstart (bool, optional):
634638
If True, warmstart the subproblem solves. Default False.
639+
outer_bound_only (boolean, optional):
640+
If True, populate outer_bound *only*; no solution is loaded, so
641+
need_solution must be False. If the solve reports no bound, the
642+
previous outer bound is kept (a valid, if vacuous, outer bound).
643+
Keyword-only. Default False.
635644
"""
636645

637646
""" Developer notes:
@@ -662,6 +671,7 @@ def solve_loop(self, solver_options=None,
662671
tee=tee,
663672
verbose=verbose,
664673
need_solution=need_solution,
674+
outer_bound_only=outer_bound_only,
665675
warmstart=warmstart,
666676
)
667677

@@ -1224,7 +1234,7 @@ def _vb(msg):
12241234
self.extobject.post_iter0()
12251235

12261236
self.trivial_bound = self.Ebound(verbose)
1227-
if self._can_update_best_bound():
1237+
if self.trivial_bound is not None and self._can_update_best_bound():
12281238
self.best_bound_obj_val = self.trivial_bound
12291239

12301240
if hasattr(self.spcomm, "sync_nonants"):

mpisppy/spbase.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def __init__(
129129
self._set_sense()
130130
self._use_variable_probability_setter()
131131
self._set_solution_cache()
132+
self._set_initial_bounds() # None until the first solve writes a bound
132133

133134
## SPCommunicator object
134135
self._spcomm = None
@@ -575,6 +576,24 @@ def _set_solution_cache(self):
575576
s._mpisppy_data.best_solution_cache = None
576577
s._mpisppy_data.latest_nonant_solution_cache = np.full(len(s._mpisppy_data.nonant_indices), np.nan)
577578

579+
def _set_initial_bounds(self):
580+
"""Give every subproblem the "not computed yet" bounds it holds before
581+
its first solve.
582+
583+
Only solve_one ever writes these, so until it does there is nothing to
584+
read: Ebound would die with "'ScalarBlock' object has no attribute
585+
'outer_bound'" whenever a first solve reports no bound (a subproblem
586+
that times out with no bound to show for it, say). None says exactly
587+
that -- the bound has not been computed -- rather than dressing the
588+
absence up as a number. Ebound propagates the None (the expected bound
589+
is unavailable if any scenario is missing its bound) and the bound
590+
spokes decline to send it, so a missing bound is never mistaken for a
591+
real one at the hub.
592+
"""
593+
for k,s in self.local_scenarios.items():
594+
s._mpisppy_data.outer_bound = None
595+
s._mpisppy_data.inner_bound = None
596+
578597
def update_best_solution_if_improving(self, obj_val):
579598
""" Call if the variable values have a nonanticipative solution
580599
with associated obj_val. Will update the best_solution_cache

0 commit comments

Comments
 (0)