User guide · 2.0
Notebook 2 - Solver Features¶
In this notebook, you’ll go beyond building problems and look at the solver itself: how to load a problem from a file instead of building it line by line, how to read and change solver settings, and how to hook into specific parts of the solve with callbacks.
Table of Contents¶
Customizing Solver Behavior with Callbacks
3.1.
NewPrimalSolution3.2.
PrimalCandidateCheck3.3.
TerminationCheck3.4.
DualBoundUpdate3.5.
PrimalCandidateSearch3.6.
HyperplaneSelection3.7.
InteriorPointSearch3.8.
BoundsUpdated3.9.
BoundTightening
0. Setup¶
This notebook picks up where Notebook 1 - Getting Started left off. If you haven’t been through that notebook yet, start there for the installation walkthrough and the basics of variables, objectives, and constraints.
First, install SHOTpy and matplotlib, then import the modules and create a solver.
%pip install SHOTpy matplotlib
import math
import matplotlib.pyplot as plt
import numpy as np
import SHOTpy
# Create a SHOT solver
solver = SHOTpy.Solver()
print("SHOT solver created successfully!")
SHOT solver created successfully!
1. Reading Problems from Files and Printing Problem Structure¶
So far, every problem has been built line by line in Python. SHOT can also read a problem directly from a file, which is useful if your model already exists in another modeling system. The format is recognized from the file extension:
Format |
Extension |
Supported if |
|---|---|---|
OSiL (Optimization Services instance Language) |
|
|
AMPL |
|
|
GAMS |
|
|
The SHOTpy installed with pip install SHOTpy reads OSiL and AMPL files. Reading GAMS files requires SHOT to be built together with a GAMS installation, so SHOTpy.HAS_GAMS is False there.
In this notebook, we read the file shot_ex_jogo.osil, which is in the same folder as the notebooks. It contains the MINLP you built in Python at the end of Notebook 1:
Here’s the general pattern: solver.setProblem() takes the file name instead of a problem object, and returns False if the file cannot be read.
# Problem file
problem_file = "shot_ex_jogo.osil"
# Read problem from file
assert solver.setProblem(problem_file)
# Solve
assert solver.solveProblem()
# Check status
print(f"Status: {solver.getModelReturnStatus().name}")
# Get solution
sol = solver.getPrimalSolution()
print(f"Optimal objective value: {sol.objValue:.6f}")
print(f"Optimal solution: x1 = {sol.point[0]:.6f}, x2 = {sol.point[1]:.0f}")
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.01 -1e+12 | -1e+12 inf. | inf.
2: LP 0.02 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.02 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.02 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.02 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.02 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.02 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.02 -23.4282 | -16.8377 6.6e+00 | 3.9e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.02 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.02 2 | 5 -22.1259 | -20.0386 2.1e+00 | 1.0e-01 -22.1259 | 1: 1.28e+00
4: LP-O 0.02 2 | 7 -21.7557 | -20.0386 1.7e+00 | 8.6e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.02 1 | 8 -21.3837 | -20.0386 1.3e+00 | 6.7e-02 -21.3837 | 1: 4.32e-01
6: MILP-SL1 0.02 2 | 10 -21.3837 | -20.2459 1.1e+00 | 5.6e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.02 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 9.98e-09
7: MILP-O 0.03 -20.9373 | -20.9036 3.4e-02 | 1.6e-03 -20.9373 | 2: 5.14e-02
8: MILP-O 0.04 6 | 16 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 6: 1.31e+00
Status: OptimalGlobal
Optimal objective value: -20.903615
Optimal solution: x1 = 8.903615, x2 = 12
One particularly convenient feature is printing a problem’s structure with toString(). This can be useful for debugging and confirming the model was built the way you intended.
Here, we print the original problem, i.e., the problem as it was read from the file:
original_problem = solver.getOriginalProblem()
print(original_problem.toString())
minimize:
[L-convex ] [L ] -x1 -x2
subject to:
[ 0,NL-convex ] [ E] c1: +(((0.15*((-8+x1))^2)+(0.1*((-6+x2))^2)+(0.025*exp(x1)*x2^-2))) <= 5
[ 1,NL-convex ] [ S ] c2: +1*x1^(-1) +1*x2^(-1) -1*x1^0.5*x2^0.5 <= -4
[ 2,L-convex ] [L ] l: +2*x1 -3*x2 <= 2
variables:
[ 0,C ] [OL N] [L SN] 2.227289 * <= x1 <= * 10.428216
[ 1,I ] [OL N] [L SN] 2.000000 * <= x2 <= * 13.000000
Problem is convex.
How to read the output: each line starts with markers in brackets.
Objective and constraints:
[index, class-convexity], where the class isL(linear),Q(quadratic) orNL(nonlinear), and the convexity isconvex,concave,nonconvexorunknown(the objective has no index). The next bracket lists the kinds of terms:Llinear,Qquadratic,Mmonomial,Ssignomial andEa general nonlinear expression.Variables:
[index, type]with the typeC(continuous),I(integer) orB(binary). The next brackets show where the variable appears,[O L Q N](objective, linear, quadratic and nonlinear constraints), and in which kinds of terms,[L Q M S N](Nis a nonlinear expression). A*next to a bound means that SHOT has tightened it.The last line tells whether SHOT considers the problem convex.
And here the reformulated problem (the version SHOT actually solves, after internal transformations):
reformulated_problem = solver.getReformulatedProblem()
print(reformulated_problem.toString())
minimize:
[L-convex ] [L ] -x1 -x2
subject to:
[ 0,NL-convex ] [L E] s_pnl_3: -s_pnl_3 +((0.15*((-8+x1))^2)) <= 0
[ 1,NL-convex ] [L E] s_pnl_4: -s_pnl_4 +((0.1*((-6+x2))^2)) <= 0
[ 2,NL-convex ] [L E] s_pnl_5: -s_pnl_5 +((0.025*exp(x1)*x2^-2)) <= 0
[ 3,L-convex ] [L ] c1: +s_pnl_3 +s_pnl_4 +s_pnl_5 <= 5
[ 4,NL-convex ] [L S ] cs_psig_6: -s_psig_6 +1*x1^(-1) <= 0
[ 5,NL-convex ] [L S ] cs_psig_7: -s_psig_7 +1*x2^(-1) <= 0
[ 6,NL-convex ] [L S ] cs_psig_8: -s_psig_8 -1*x1^0.5*x2^0.5 <= 0
[ 7,L-convex ] [L ] c2: +s_psig_6 +s_psig_7 +s_psig_8 <= -4
[ 8,L-convex ] [L ] l: +2*x1 -3*x2 <= 2
variables:
[ 0,C ] [OL N] [L SN] 2.227289 * <= x1 <= * 10.428216
[ 1,I ] [OL N] [L SN] 2.000000 * <= x2 <= * 13.000000
[ 2,C ] [ L N] [L ] 0.000000 <= s_pnl_3 <= 4.998628 nonlinear sum part.
[ 3,C ] [ L N] [L ] 0.000000 <= s_pnl_4 <= 4.900000 nonlinear sum part.
[ 4,C ] [ L N] [L ] 0.001372 <= s_pnl_5 <= * 5.000000 nonlinear sum part.
[ 5,C ] [ L N] [L ] 0.095894 <= s_psig_6 <= 0.448976 signomial sum part.
[ 6,C ] [ L N] [L ] 0.076923 <= s_psig_7 <= 0.500000 signomial sum part.
[ 7,C ] [ L N] [L ] -11.643316 <= s_psig_8 <= * -4.172817 signomial sum part.
Problem is convex.
To see everything available on the solver object beyond what this notebook covers, you can print its public methods:
for name in dir(solver):
if not name.startswith("_"): # Skip private/internal attributes
print(name)
getAbsoluteObjectiveGap
getBoolSetting
getCurrentDualBound
getDoubleSetting
getEnvironment
getGlobalDualBound
getIntSetting
getModelReturnStatus
getOptions
getOptionsOSoL
getOriginalProblem
getPrimalBound
getPrimalSolution
getPrimalSolutions
getReformulatedProblem
getRelativeObjectiveGap
getResultsOSrL
getResultsSol
getResultsTrace
getSettingsAsMarkup
getSolutionStatistics
getStartingPoint
getStringSetting
getTerminationReason
getUserSpecifiedConvexityDescription
hasPrimalSolution
outputOptionsReport
outputProblemInstanceReport
outputSolutionReport
outputSolverHeader
registerCallback
removeCallback
setLogFile
setOptionsFromFile
setOptionsFromOSoL
setOptionsFromString
setProblem
setStartingPoint
solveProblem
updateLogLevels
updateSetting
It is worth mentioning that several available methods are other reporting utilities, including outputSolverHeader(), outputOptionsReport(), outputProblemInstanceReport(), and outputSolutionReport(). These functions generate formatted summaries of the solver and can be helpful when debugging models or documenting optimization runs.
solver.outputSolverHeader()
- Supporting Hyperplane Optimization Toolkit (SHOT) ──────────────────────────────────────────────────────────────────╴
Andreas Lundell and Jan Kronqvist, Åbo Akademi University, Finland.
See https://shotsolver.dev for more information, including contributors and utilized software libraries.
Version: 2.0. Git hash: 844836b7. Released: Nov 10 2026.
If you publish work using this software, please cite publications from https://shotsolver.dev/publications, such as:
- A. Lundell, J. Kronqvist and T. Westerlund. The supporting hyperplane optimization toolkit for convex MINLP.
Journal of Global Optimization, 84(1):1-41, 2022. doi:10.1007/s10898-022-01128-0
- A. Lundell and J. Kronqvist. Polyhedral approximation strategies for nonconvex mixed-integer nonlinear
programming in SHOT. Journal of Global Optimization, 82(4):863-896, 2022. doi:10.1007/s10898-021-01006-1
solver.outputOptionsReport()
- Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╴
Non-default options:
- Dual.TreeStrategy = 0 [solver (compatibility)]
- Model.Reformulation.Quadratics.Strategy = 0 [solver (compatibility)]
- Model.Variables.Continuous.MaximumUpperBound = 1e+20 [recommended]
- Model.Variables.Continuous.MinimumLowerBound = -1e+20 [recommended]
Dual strategy: Multi-tree
- cut algorithm: ESH
- solver: HiGHS 1.15.1 with automatic number of threads
Primal NLP solver: Ipopt 3.14.20 (with default linear solver)
solver.outputProblemInstanceReport()
- Problem instance ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Original Reformulated
Problem classification: MINLP, convex MINLP, convex
Objective function direction: minimize minimize
Objective function type: linear linear
Number of constraints: 3 9
- linear: 1 3
- convex nonlinear: 2 6
Number of variables: 2 8
- real: 1 7
- integer: 1 1
- nonlinear: 2 2
Number of transformations performed: 6
- nonlinear expression partitioning: 3
- signomial terms partitioning: 3
solver.outputSolutionReport()
- Solution report ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Terminated since absolute gap met requirements.
Globally optimal primal solution found.
Objective bound (minimization) [dual, primal]: [-20.9036, -20.9036].
Objective gap absolute / relative: 2.57857e-08 / 1.23355e-09.
Fulfilled termination criteria:
- absolute objective gap tolerance 2.57857e-08 <= 0.001
- relative objective gap tolerance 1.23355e-09 <= 0.001
Unfulfilled termination criteria:
- maximal constraint tolerance 1.30681 > 1e-08
- iteration limit 8 <= 200000
- solution time limit (s) 0.0559524 <= 1.79769e+308
Dual problems solved in main step: 8
- LP problems 5
- MILP problems, optimal 2
- MILP problems, feasible 1
Problems solved during interior point search:
- LP problems: 7
Fixed primal NLP problems solved: 1
Number of primal solutions found: 6
- root search: 4
- NLP problem with fixed integers: 1
- Interior point search: 1
Total solution time: 0.0559659
- problem initialization: 0.00142704
- problem reformulation: 0.000212333
- bound tightening: 0.000179083
- feasibility based (original problem): 8.5625e-05
- feasibility based (reformulated problem): 1.5375e-05
- interior point search: 0.00439504
- dual strategy: 0.0199158
- solving relaxed problems: 0.000378541
- solving MIP problems: 0.014528
- root search for constraint cuts: 0.000141083
- primal strategy: 0.0354691
- solving NLP problems: 0.0079395
- performing root searches: 2.876e-06
As hinted above, there are a lot of solver features to cover. To keep these notebooks manageable, we restrict attention to the core features needed for typical usage.
Next, we will turn our attention towards solver settings and the most commonly used configuration options.
2. Configuring Solver Settings¶
SHOT provides extensive configuration options through its settings system.
To configure a solver option in SHOTpy, use the following syntax:
solver.updateSetting("Option.Name", value)
"Option.Name"(str): The name of the solver option to modify.value: The new value for the option (bool,int,floatorstr, depending on the setting).updateSetting()raises aRuntimeErrorif there is no setting with the name, or if the value has the wrong type or is outside the valid values.Important: Change the settings before
solver.setProblem(). That call reformulates the problem, chooses the solution strategy and creates the MIP and NLP subsolvers, so most settings are fixed from then on. Settings that SHOT only reads while solving, such asTermination.TimeLimitand the output settings, can also be changed afterwards.
The current value of a setting is read with solver.getBoolSetting(), getIntSetting(), getDoubleSetting() or getStringSetting(), depending on its type.
All the settings, with their types, default values and descriptions, are listed in the settings reference.
solver.getSettingsAsMarkup()returns the same list for the SHOTpy you are running.
Below are some frequently used settings:
# Create solver
solver = SHOTpy.Solver()
# Termination criteria
solver.updateSetting("Termination.TimeLimit", 300.0) # Max time in seconds
solver.updateSetting("Termination.ObjectiveGap.Absolute", 1e-5) # Absolute gap
solver.updateSetting("Termination.ObjectiveGap.Relative", 1e-4) # Relative gap
# Output settings
solver.updateSetting("Output.Console.LogLevel", 2) # 0: Trace 1: Debug 2: Info 3: Warning 4: Error 5: Critical 6: Off
# MIP solver selection: 0: Cplex 1: Gurobi 2: Cbc 3: Highs
solver.updateSetting("Dual.MIP.Solver", 3)
# Set problem (after changing the settings)
assert solver.setProblem(problem_file)
# Solve
assert solver.solveProblem()
# Get solution
sol = solver.getPrimalSolution()
print(f"Optimal objective value: {sol.objValue:.6f}")
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | -16.8377 6.6e+00 | 3.9e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -22.1259 | -20.0386 2.1e+00 | 1.0e-01 -22.1259 | 1: 1.28e+00
4: LP-O 0.00 2 | 7 -21.7557 | -20.0386 1.7e+00 | 8.6e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.00 1 | 8 -21.3837 | -20.0386 1.3e+00 | 6.7e-02 -21.3837 | 1: 4.32e-01
6: MILP-SL1 0.00 2 | 10 -21.3837 | -20.2459 1.1e+00 | 5.6e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.01 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 9.98e-09
7: MILP-O 0.01 -20.9373 | -20.9036 3.4e-02 | 1.6e-03 -20.9373 | 2: 5.14e-02
8: MILP-O 0.02 6 | 16 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 6: 1.31e+00
Optimal objective value: -20.903615
# Read settings back
print(f"Time limit: {solver.getDoubleSetting('Termination.TimeLimit')} s")
print(f"MIP solver: {SHOTpy.MIPSolver(solver.getIntSetting('Dual.MIP.Solver')).name}")
Time limit: 300.0 s
MIP solver: Highs
# Solver report just to verify that the settings were applied correctly
solver.outputOptionsReport()
- Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╴
Non-default options:
- Dual.TreeStrategy = 0 [solver (compatibility)]
- Model.Reformulation.Quadratics.Strategy = 0 [solver (compatibility)]
- Model.Variables.Continuous.MaximumUpperBound = 1e+20 [recommended]
- Model.Variables.Continuous.MinimumLowerBound = -1e+20 [recommended]
- Termination.ObjectiveGap.Absolute = 1e-05 [user]
- Termination.ObjectiveGap.Relative = 0.0001 [user]
- Termination.TimeLimit = 300 [user]
Dual strategy: Multi-tree
- cut algorithm: ESH
- solver: HiGHS 1.15.1 with automatic number of threads
Primal NLP solver: Ipopt 3.14.20 (with default linear solver)
3. Customizing Solver Behavior with Callbacks¶
Settings cover passive configuration such as limits, tolerances, which subsolver to use. Callbacks go a step further: they let you inject your own logic or data into a specific point of the solve, rather than just toggling a value.
SHOTpy exposes SHOT’s callback system via solver.registerCallback(SHOTpy.CallbackLocation.<Location>, fn): SHOT calls fn(ctx) every time it reaches the location in the solve. A function can also be registered for several locations at once, given as locations combined with | or as a list. Nine <Location>s are available:
Location |
Called |
Action |
|---|---|---|
|
After a primal solution has been stored |
– |
|
Before a primal candidate is checked |
|
|
When SHOT checks whether to terminate |
|
|
When SHOT can accept a dual bound; before and after a direct MIQCQP solve |
|
|
Before the first dual solve, when SHOT collects primal candidates, and during finalization |
|
|
When SHOT selects the hyperplanes (cuts) to add |
|
|
After ESH checks existing points or runs its interior point search, even if no point was found |
|
|
After a primal or dual bound has changed |
– |
|
In |
|
Instead of returning a value, a callback acts through the methods of its context ctx, as in the last column. The context also holds the state of the solver, with these fields at every location:
Field |
Type |
Description |
|---|---|---|
|
|
Current main iteration |
|
|
Objective value of the best primal solution |
|
|
Current dual bound |
|
|
Optimality gaps between the bounds |
|
|
Time since SHOT was started, in seconds |
|
|
|
|
|
Current solver statistics |
|
|
The problem given to the solver / the problem SHOT solves |
Values that are not available yet, e.g., the dual bound before the first dual problem has been solved, are infinite. A few more things to know:
The context can only be used while the callback runs, so copy the values you want to keep, e.g.,
ctx.objectiveValue.If a callback raises an exception, SHOT terminates and
solveProblem()raises it.registerCallback()returns a handle, whichsolver.removeCallback(handle)takes to remove the callback again.
The callbacks guide describes exactly where in the solution process each location is called.
The examples below reuse shot_ex_jogo.osil from Section 1, so you can focus on what each callback actually does rather than on a new model every time. Its optimum is \(x_1 \approx 8.903615\), \(x_2 = 12\), with the objective \(\approx -20.903615\), which will serve as our reference numbers for the examples further down.
3.1. NewPrimalSolution¶
NewPrimalSolution is called every time SHOT has stored a new primal solution. It’s the simplest callback in the list and a good first one to try.
Besides the common fields, its context has:
Field |
Type |
Description |
|---|---|---|
|
|
Objective value of the new solution |
|
|
Variable values of the new solution |
|
|
Whether the solution is better than the best one before it |
|
|
Where the solution comes from, e.g., |
Typical use: logging the convergence history so you can plot the primal/dual bound trajectory afterwards, without slowing the solve down with console output.
# Create solver
solver = SHOTpy.Solver()
# Set problem
assert solver.setProblem(problem_file)
# Register callback
primal_log = []
def on_new_primal(ctx):
primal_log.append({
"iter": ctx.iterationNumber,
"obj": ctx.objectiveValue,
"dual": ctx.dualBound,
"abs gap": ctx.absoluteGap,
"rel gap (%)": 100 * ctx.relativeGap,
"point": ctx.point,
"source": ctx.source.name,
})
solver.registerCallback(SHOTpy.CallbackLocation.NewPrimalSolution, on_new_primal)
# Solve
assert solver.solveProblem()
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | -16.8377 6.6e+00 | 3.9e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -22.1259 | -20.0386 2.1e+00 | 1.0e-01 -22.1259 | 1: 1.28e+00
4: LP-O 0.00 2 | 7 -21.7557 | -20.0386 1.7e+00 | 8.6e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.00 1 | 8 -21.3837 | -20.0386 1.3e+00 | 6.7e-02 -21.3837 | 1: 4.32e-01
6: MILP-SL1 0.00 2 | 10 -21.3837 | -20.2459 1.1e+00 | 5.6e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.01 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 9.98e-09
7: MILP-O 0.01 -20.9373 | -20.9036 3.4e-02 | 1.6e-03 -20.9373 | 2: 5.14e-02
8: MILP-O 0.02 6 | 16 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 6: 1.31e+00
print(f"Primal solution improvements ({len(primal_log)} updates):")
print(f"{'iter':>5} {'objective':>11} {'dual bound':>11} {'abs gap':>8} {'rel gap %':>9} {'x1':>9} {'x2':>4} source")
for row in primal_log:
x1, x2 = row["point"]
print(f"{row['iter']:>5} {row['obj']:>11.6f} {row['dual']:>11.4f} {row['abs gap']:>8.4f} "
f"{row['rel gap (%)']:>9.4f} {x1:>9.6f} {x2:>4.0f} {row['source']}")
Primal solution improvements (6 updates):
iter objective dual bound abs gap rel gap % x1 x2 source
0 -16.837660 -inf inf inf 7.837660 9 InteriorPointSearch
2 -16.857567 -23.4282 6.5706 38.9774 7.857567 9 Rootsearch
2 -16.858557 -23.4282 6.5697 38.9693 7.858557 9 Rootsearch
2 -20.038581 -23.4282 3.3896 16.9155 9.038581 11 Rootsearch
6 -20.245940 -21.3837 1.1378 5.6198 8.245940 12 Rootsearch
6 -20.903615 -21.3837 0.4801 2.2967 8.903615 12 NLPFixedIntegers
3.2. PrimalCandidateCheck¶
PrimalCandidateCheck is called for every primal candidate before SHOT checks whether it is feasible and better than the incumbent (unlike NewPrimalSolution, which is only called after a solution has been stored). Its context has the same objectiveValue, point and source as in 3.1, and ctx.rejectCandidate() rejects the candidate: SHOT skips the check, so the candidate never becomes a primal solution.
This is useful for logging every point the primal heuristics examine, or for filtering candidates on criteria outside the model itself. Note that rejecting candidates only affects the primal side: the dual problems SHOT solves do not know about your criteria, so constraints that the solution must fulfil belong in the model.
The example below solves the problem twice: first accepting all candidates, then rejecting any candidate worse than \(-19\) (remember this is a minimization problem, so “worse” means a larger objective value).
# Run 1: accept all candidates
solver = SHOTpy.Solver()
assert solver.setProblem(problem_file)
cand1, acc1 = [], []
solver.registerCallback(SHOTpy.CallbackLocation.PrimalCandidateCheck,
lambda ctx: cand1.append(round(ctx.objectiveValue, 4)))
solver.registerCallback(SHOTpy.CallbackLocation.NewPrimalSolution,
lambda ctx: acc1.append(round(ctx.objectiveValue, 4)))
assert solver.solveProblem()
# Run 2: reject candidates worse than -19
solver = SHOTpy.Solver()
assert solver.setProblem(problem_file)
cand2, acc2 = [], []
def on_candidate(ctx):
cand2.append(round(ctx.objectiveValue, 4))
if ctx.objectiveValue > -19.0:
ctx.rejectCandidate() # SHOT skips the feasibility check for this candidate
solver.registerCallback(SHOTpy.CallbackLocation.PrimalCandidateCheck, on_candidate)
solver.registerCallback(SHOTpy.CallbackLocation.NewPrimalSolution,
lambda ctx: acc2.append(round(ctx.objectiveValue, 4)))
assert solver.solveProblem()
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | -16.8377 6.6e+00 | 3.9e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -22.1259 | -20.0386 2.1e+00 | 1.0e-01 -22.1259 | 1: 1.28e+00
4: LP-O 0.00 2 | 7 -21.7557 | -20.0386 1.7e+00 | 8.6e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.00 1 | 8 -21.3837 | -20.0386 1.3e+00 | 6.7e-02 -21.3837 | 1: 4.32e-01
6: MILP-SL1 0.00 2 | 10 -21.3837 | -20.2459 1.1e+00 | 5.6e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.01 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 9.98e-09
7: MILP-O 0.01 -20.9373 | -20.9036 3.4e-02 | 1.6e-03 -20.9373 | 2: 5.14e-02
8: MILP-O 0.02 6 | 16 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 6: 1.31e+00
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | inf. inf. | inf. -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -22.1259 | -20.0386 2.1e+00 | 1.0e-01 -22.1259 | 1: 1.28e+00
4: LP-O 0.00 2 | 7 -21.7557 | -20.0386 1.7e+00 | 8.6e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.00 1 | 8 -21.3837 | -20.0386 1.3e+00 | 6.7e-02 -21.3837 | 1: 4.32e-01
6: MILP-SL1 0.00 2 | 10 -21.3837 | -20.2459 1.1e+00 | 5.6e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.01 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 9.98e-09
7: MILP-O 0.01 -20.9373 | -20.9036 3.4e-02 | 1.6e-03 -20.9373 | 2: 5.14e-02
8: MILP-O 0.02 6 | 16 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 6: 1.31e+00
print("Accept all")
print(f" Candidates seen : {cand1}")
print(f" Solutions found : {acc1}")
print()
print("Reject obj > -19")
print(f" Candidates seen : {cand2}")
print(f" Solutions found : {acc2}")
Accept all
Candidates seen : [-16.7427, -23.4282, -16.7941, -16.7966, -19.8419, -22.5374, -17.4697, -17.8281, -22.1259, -18.2301, -19.3924, -21.7557, -19.2114, -21.3837, -20.2127, -18.3084, -20.2459, -20.9036, -20.9373, -20.0999, -20.2095, -20.6824, -20.9036, -20.9036, -20.9036, -20.9036]
Solutions found : [-16.8377, -16.8576, -16.8586, -20.0386, -20.2459, -20.9036]
Reject obj > -19
Candidates seen : [-16.7427, -23.4282, -16.7941, -16.7966, -19.8419, -22.5374, -17.4697, -17.8281, -22.1259, -18.2301, -19.3924, -21.7557, -19.2114, -21.3837, -20.2127, -18.3084, -20.2459, -20.9036, -20.9373, -20.0999, -20.2095, -20.6824, -20.9036, -20.9036, -20.9036, -20.9036]
Solutions found : [-20.0386, -20.2459, -20.9036]
Note that the second run never stores a solution worse than \(-19\). Since the rejected candidates are never stored, SHOT may also take a different path through the solve, so the candidates seen can differ between the two runs.
3.3. TerminationCheck¶
TerminationCheck is called each time SHOT checks whether to terminate, and lets you stop the solver early for reasons of your own, independent of SHOT’s built-in termination criteria. Its context has the common fields from the table above, and ctx.terminate() stops SHOT.
The example below stops as soon as the relative gap drops below \(5\%\), well before SHOT’s own Termination.ObjectiveGap.Relative criterion (by default \(0.1\%\)) would stop it. Since the gap is not closed, the status is FeasibleSolution rather than OptimalGlobal, and the termination reason is UserAbort.
# Create solver
solver = SHOTpy.Solver()
# Set problem
assert solver.setProblem(problem_file)
# Register callback
def early_stop(ctx):
# Terminate as soon as the relative gap is below 5%
if ctx.relativeGap < 0.05:
ctx.terminate()
solver.registerCallback(SHOTpy.CallbackLocation.TerminationCheck, early_stop)
# Solve
assert solver.solveProblem()
# Check status
print(f"Status : {solver.getModelReturnStatus().name}")
print(f"Termination reason : {solver.getTerminationReason().name}")
print(f"Objective value : {solver.getPrimalBound():.6f}")
print(f"Relative gap : {100 * solver.getRelativeObjectiveGap():.2f}%")
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | -16.8377 6.6e+00 | 3.9e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -22.1259 | -20.0386 2.1e+00 | 1.0e-01 -22.1259 | 1: 1.28e+00
4: LP-O 0.00 2 | 7 -21.7557 | -20.0386 1.7e+00 | 8.6e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.00 1 | 8 -21.3837 | -20.0386 1.3e+00 | 6.7e-02 -21.3837 | 1: 4.32e-01
6: MILP-SL1 0.00 2 | 10 -21.3837 | -20.2459 1.1e+00 | 5.6e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.01 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 9.98e-09
Termination requested by a callback at location TerminationCheck.
Status : FeasibleSolution
Termination reason : UserAbort
Objective value : -20.903615
Relative gap : 2.30%
3.4. DualBoundUpdate¶
DualBoundUpdate lets you tighten the solver’s dual bound using information computed outside the model: ctx.setDualBound(value) proposes a dual bound, which SHOT uses if it is better than its own. It is called once per main iteration of the multi-tree strategy, which we select with Dual.TreeStrategy = 0. When SHOT instead solves a quadratic problem directly with CPLEX or Gurobi, it is called before and after that solve, and the proposed bound only affects SHOT’s own gap and termination checks.
A proposed dual bound must be valid, i.e., no feasible solution may be better than it, since SHOT uses it to decide that a solution is optimal.
The example below waits until iteration 3, then proposes the known optimal bound (\(\approx -20.903647\)), closing the gap early rather than letting SHOT tighten it iteration by iteration.
known_dual = -20.903647306104258 # optimal dual bound, from an earlier independent solve
# Create solver
solver = SHOTpy.Solver()
solver.updateSetting("Dual.TreeStrategy", 0) # Multi-tree
# Set problem
assert solver.setProblem(problem_file)
# Register callback
INJECT_AFTER_ITER = 3
event_log = []
def provide_dual_bound(ctx):
current = ctx.dualBound
if ctx.iterationNumber >= INJECT_AFTER_ITER and current < known_dual:
ctx.setDualBound(known_dual)
event_log.append(f"iter {ctx.iterationNumber:>2}: current = {current:9.4f} → proposing known dual {known_dual:.6f}")
else:
event_log.append(f"iter {ctx.iterationNumber:>2}: current = {current:9.4f} → (no update)")
solver.registerCallback(SHOTpy.CallbackLocation.DualBoundUpdate, provide_dual_bound)
# Solve
assert solver.solveProblem()
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | -16.8377 6.6e+00 | 3.9e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.0386 2.5e+00 | 1.2e-01 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -20.9036 | -20.0386 8.7e-01 | 4.3e-02 -22.1259 | 1: 1.28e+00
6: MILP-SL1 0.00 2 | 10 -20.9036 | -20.2459 6.6e-01 | 3.2e-02 -20.2459 | 0: 0.00e+00
1: NLPSOLPT-R 0.01 2 | 10 -20.9036 | -20.9036 3.2e-05 | 1.5e-06 -20.9036 | 0: 9.98e-09
print(f"Status : {solver.getModelReturnStatus().name}")
print(f"Objective : {solver.getPrimalBound():.6f}")
print(f"Iterations: {solver.getSolutionStatistics().numberOfIterations}")
print("\nCallback events:")
for message in event_log:
print(f" {message}")
Status : OptimalGlobal
Objective : -20.903615
Iterations: 6
Callback events:
iter 1: current = -inf → (no update)
iter 2: current = -23.4282 → (no update)
iter 3: current = -22.5374 → proposing known dual -20.903647
iter 4: current = -20.9036 → (no update)
iter 5: current = -20.9036 → (no update)
iter 6: current = -20.9036 → (no update)
3.5. PrimalCandidateSearch¶
PrimalCandidateSearch is the mirror image of DualBoundUpdate: instead of tightening the dual bound, it lets you add a candidate point to the search with ctx.addPrimalSolution(point), as if SHOT itself had found it. The point is a list with a value for each variable. SHOT checks it like any other candidate, so it only becomes a primal solution if it is feasible, and addPrimalSolution() can be called several times.
The example below adds the known optimal point in the first iteration, so SHOT can skip most of its own primal search.
known_point = [8.903615, 12.0] # optimal point, from an earlier independent solve
# Create solver
solver = SHOTpy.Solver()
# Set problem
assert solver.setProblem(problem_file)
# Register callback
event_log = []
def provide_primal_solution(ctx):
current = ctx.primalBound
if ctx.iterationNumber == 1:
ctx.addPrimalSolution(known_point)
event_log.append(f"iter {ctx.iterationNumber:>2}: current = {current:9.4f} → adding known point {known_point}")
else:
event_log.append(f"iter {ctx.iterationNumber:>2}: current = {current:9.4f} → (no update)")
solver.registerCallback(SHOTpy.CallbackLocation.PrimalCandidateSearch, provide_primal_solution)
# Solve
assert solver.solveProblem()
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴
Strategy selected: cutting plane minimax
Iteration │ Time │ Cuts │ Objective value │ Objective diff.
#: type │ tot. │ + | tot. │ problem | line srch │ abs. | rel.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
1: LP 0.00 -1e+12 | -1e+12 inf. | inf.
2: LP 0.00 2 | 2 -7.6275 | 0.328035 8.0e+00 | 1.0e+00
3: LP 0.00 2 | 4 -3.29179 | 0.299795 3.6e+00 | 1.1e+00
4: LP 0.00 2 | 6 -1.72401 | 0.0590611 1.8e+00 | 1.0e+00
5: LP 0.00 2 | 8 -0.382227 | 0.0339402 4.2e-01 | 1.1e+00
6: LP 0.00 2 | 10 -0.374105 | 0.00370268 3.8e-01 | 1.0e+00
7: LP 0.00 2 | 12 -0.374105 | -0.0165915 3.6e-01 | 9.6e-01
Valid interior point with constraint deviation -0.017 found.
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | -20.9036 2.5e+00 | 1.2e-01 -23.4282 | 2: 5.00e+00
2: LP-O 0.00 3 | 3 -22.5374 | -20.9036 1.6e+00 | 7.8e-02 -22.5374 | 1: 1.65e+00
3: LP-O 0.00 2 | 5 -22.1259 | -20.9036 1.2e+00 | 5.8e-02 -22.1259 | 1: 1.28e+00
4: LP-O 0.00 2 | 7 -21.7557 | -20.9036 8.5e-01 | 4.1e-02 -21.7557 | 1: 8.78e-01
5: LP-O 0.00 1 | 8 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -21.3837 | 1: 4.32e-01
1: NLPSOLPT-R 0.01 2 | 10 -21.3837 | -20.9036 4.8e-01 | 2.3e-02 -20.9036 | 0: 1.00e-08
7: MILP-O 0.01 -20.9373 | -20.9036 3.4e-02 | 1.6e-03 -20.9373 | 2: 5.14e-02
8: MILP-O 0.02 6 | 16 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 6: 1.31e+00
print(f"Status : {solver.getModelReturnStatus().name}")
print(f"Objective : {solver.getPrimalBound():.6f}")
print(f"Iterations: {solver.getSolutionStatistics().numberOfIterations}")
print("\nCallback events:")
for message in event_log:
print(f" {message}")
Status : OptimalGlobal
Objective : -20.903615
Iterations: 8
Callback events:
iter 1: current = -16.8377 → adding known point [8.903615, 12.0]
iter 1: current = -20.9036 → adding known point [8.903615, 12.0]
iter 2: current = -20.9036 → (no update)
iter 3: current = -20.9036 → (no update)
iter 4: current = -20.9036 → (no update)
iter 5: current = -20.9036 → (no update)
iter 6: current = -20.9036 → (no update)
iter 7: current = -20.9036 → (no update)
iter 8: current = -20.9036 → (no update)
iter 8: current = -20.9036 → (no update)
3.6. HyperplaneSelection¶
HyperplaneSelection lets you replace SHOT’s own hyperplane (cut) generation with cuts you compute yourself. Its context has the solution points of the current dual problem in ctx.solutionPoints. Each has the point, its objectiveValue, and maxDeviation, the index of the constraint the point violates the most and the violation (value).
A cut is added with ctx.addHyperplane(hyperplane), where hyperplane is an SHOTpy.ExternalHyperplane with the fields:
variableIndexesandvariableCoefficients: the cut issum(variableCoefficients[k] * x[variableIndexes[k]]) <= rhsValue.rhsValue: the right-hand side of the cut.isGlobal: whether the cut is valid for the whole problem, e.g., since it is generated for a convex function.sourceanddescription: where the cut comes from (SHOTpy.HyperplaneSource.External) and a description for the log.
The points, the constraints and the cuts are all in the variables of the reformulated problem (ctx.reformulatedProblem), which is the problem SHOT solves.
At every iteration, the callback below linearizes the most violated nonlinear constraint \(g(x) \le b\) at the solution point \(x_0\),
and adds it to SHOT as a cut. To make SHOT rely exclusively on the callback, we disable its own hyperplane generation with Dual.CutStrategy = 2 (Only external).
# Create solver
solver = SHOTpy.Solver()
# Disable all built-in hyperplane generation; cuts come exclusively from the callback
solver.updateSetting("Dual.CutStrategy", 2) # Only external
solver.updateSetting("Dual.TreeStrategy", 0) # Multi-tree
# Set problem
assert solver.setProblem(problem_file)
# Register callback
cut_log = [] # (iteration, constraint name, violation) for display
def generate_hyperplanes(ctx):
# The nonlinear constraints of the reformulated problem, by index
nonlinear_constraints = {c.index: c for c in ctx.reformulatedProblem.nonlinearConstraints}
for solution_point in ctx.solutionPoints:
index = solution_point.maxDeviation.index
violation = solution_point.maxDeviation.value
# Skip points that violate no nonlinear constraint
if index not in nonlinear_constraints or violation <= 0.0:
continue
constraint = nonlinear_constraints[index]
point = solution_point.point
# Sparse gradient {variable index: partial derivative}
gradient = constraint.calculateGradient(point)
if not gradient:
continue
hyperplane = SHOTpy.ExternalHyperplane()
hyperplane.variableIndexes = list(gradient.keys())
hyperplane.variableCoefficients = list(gradient.values())
hyperplane.rhsValue = sum(gradient[i] * point[i] for i in gradient) - violation # grad . x0 - violation
hyperplane.isGlobal = True # The problem is convex
hyperplane.source = SHOTpy.HyperplaneSource.External
hyperplane.description = f"ext_hp_iter{ctx.iterationNumber}_{constraint.name}"
ctx.addHyperplane(hyperplane)
cut_log.append((ctx.iterationNumber, constraint.name, violation))
break # one cut per callback invocation is sufficient
solver.registerCallback(SHOTpy.CallbackLocation.HyperplaneSelection, generate_hyperplanes)
# Solve
assert solver.solveProblem()
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Performing bound tightening on reformulated problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-23.4282, -4.22729]
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴
Iteration │ Time │ Dual cuts │ Objective value │ Objective gap │ Current solution
#: type │ tot. │ + | tot. │ dual | primal │ abs. | rel. │ obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴
1: LP-O 0.00 -23.4282 | inf. inf. | inf. -23.4282 | 2: 5.00e+00
HP generated for external hyperplane ext_hp_iter2_s_pnl_5 with 3 terms.
Cut added for external constraint ext_hp_iter2_s_pnl_5.
HP generated for external hyperplane ext_hp_iter3_s_pnl_4 with 2 terms.
Cut added for external constraint ext_hp_iter3_s_pnl_4.
3: LP-O 0.00 1 | 2 -22.4482 | inf. inf. | inf. -22.4482 | 2: 1.78e+00
HP generated for external hyperplane ext_hp_iter4_s_pnl_5 with 3 terms.
Cut added for external constraint ext_hp_iter4_s_pnl_5.
4: LP-O 0.00 1 | 3 -21.5015 | inf. inf. | inf. -21.5015 | 2: 6.28e-01
HP generated for external hyperplane ext_hp_iter5_s_pnl_5 with 3 terms.
Cut added for external constraint ext_hp_iter5_s_pnl_5.
5: LP-O 0.00 1 | 4 -21.2025 | inf. inf. | inf. -21.2025 | 2: 1.36e-01
HP generated for external hyperplane ext_hp_iter6_s_pnl_5 with 3 terms.
Cut added for external constraint ext_hp_iter6_s_pnl_5.
6: MILP-SL1 0.01 1 | 5 -21.1019 | inf. inf. | inf. -21.0804 | 0: 1.75e-01
HP generated for external hyperplane ext_hp_iter7_s_pnl_3 with 2 terms.
Cut added for external constraint ext_hp_iter7_s_pnl_3.
7: MILP-O 0.02 1 | 6 -20.97 | inf. inf. | inf. -20.97 | 1: 1.00e-01
1: NLPNEWDB-R 0.02 1 | 6 -20.97 | -20.9036 6.6e-02 | 3.2e-03 -20.9036 | 0: 9.99e-09
HP generated for external hyperplane ext_hp_iter8_s_pnl_4 with 2 terms.
Cut added for external constraint ext_hp_iter8_s_pnl_4.
8: MILP-O 0.03 4 | 10 -20.9036 | -20.9036 0.0e+00 | 0.0e+00 -20.9036 | 4: 1.64e-02
print(f"Status : {solver.getModelReturnStatus().name}")
print(f"Objective : {solver.getPrimalBound():.6f} (known optimum ≈ -20.903615)")
print(f"\nExternal cuts added: {len(cut_log)}")
print(f" {'iter':>4} {'constraint':>10} {'violation':>12}")
for iteration, name, violation in cut_log:
print(f" {iteration:>4} {name:>10} {violation:>12.6f}")
# The cuts of the callback alone give the known optimum
assert abs(solver.getPrimalBound() + 20.903615) < 1e-3
Status : OptimalGlobal
Objective : -20.903615 (known optimum ≈ -20.903615)
External cuts added: 7
iter constraint violation
2 s_pnl_5 4.998628
3 s_pnl_4 4.900000
4 s_pnl_5 1.776555
5 s_pnl_5 0.628132
6 s_pnl_5 0.135976
7 s_pnl_3 0.175092
8 s_pnl_4 0.100000
Visualizing the Cuts¶
The cuts above are in the variables of the reformulated problem, where SHOT has added auxiliary variables (s_pnl_3, s_psig_6, …) for the parts of the nonlinear constraints, so they cannot be drawn in the plane of \(x_1\) and \(x_2\). To see the cuts, the example below builds the same problem with the Python API, as in Notebook 1, and passes it as both the original and the reformulated problem, solver.setProblem(problem, problem), so that SHOT solves it as it is. The callback is the same as above, but also records the solution points and the cuts of each iteration.
# Create solver
solver = SHOTpy.Solver()
solver.updateSetting("Output.Console.LogLevel", 6) # Off
solver.updateSetting("Dual.CutStrategy", 2) # Only external
solver.updateSetting("Dual.TreeStrategy", 0) # Multi-tree
# Create the problem of shot_ex_jogo.osil
problem = SHOTpy.Problem(solver)
x1 = problem.addVariable("x1", SHOTpy.VariableType.Real, 1.0, 20.0)
x2 = problem.addVariable("x2", SHOTpy.VariableType.Integer, 1.0, 20.0)
problem.setObjective(-x1 - x2, SHOTpy.ObjectiveDirection.Minimize)
problem.addConstraint(0.15*(x1 - 8)**2 + 0.1*(x2 - 6)**2 + 0.025*SHOTpy.exp(x1)/x2**2 <= 5, "c1")
problem.addConstraint(1/x1 + 1/x2 - x1**0.5 * x2**0.5 <= -4, "c2")
problem.addConstraint(2*x1 - 3*x2 <= 2, "l")
problem.finalize()
# Set the problem as both the original and the reformulated problem
assert solver.setProblem(problem, problem)
# Register callback
iteration_log = {} # iteration -> the solution points and the cuts (gradient, rhs, point) of that iteration
def generate_and_record_hyperplanes(ctx):
if not ctx.solutionPoints or ctx.iterationNumber == 0:
return
nonlinear_constraints = {c.index: c for c in ctx.reformulatedProblem.nonlinearConstraints}
# The points are those of the dual problem solved in the previous iteration
record = iteration_log.setdefault(ctx.iterationNumber - 1, {"points": [], "cuts": []})
for solution_point in ctx.solutionPoints:
record["points"].append(solution_point.point[:2])
index = solution_point.maxDeviation.index
violation = solution_point.maxDeviation.value
if index not in nonlinear_constraints or violation <= 0.0:
continue
point = solution_point.point
gradient = nonlinear_constraints[index].calculateGradient(point)
if not gradient:
continue
hyperplane = SHOTpy.ExternalHyperplane()
hyperplane.variableIndexes = list(gradient.keys())
hyperplane.variableCoefficients = list(gradient.values())
hyperplane.rhsValue = sum(gradient[i] * point[i] for i in gradient) - violation
hyperplane.isGlobal = True
hyperplane.source = SHOTpy.HyperplaneSource.External
hyperplane.description = f"ext_hp_iter{ctx.iterationNumber}"
ctx.addHyperplane(hyperplane)
record["cuts"].append(((gradient.get(0, 0.0), gradient.get(1, 0.0)), hyperplane.rhsValue, point[:2]))
break
solver.registerCallback(SHOTpy.CallbackLocation.HyperplaneSelection, generate_and_record_hyperplanes)
# Solve
assert solver.solveProblem()
print(f"Status : {solver.getModelReturnStatus().name}")
print(f"Objective : {solver.getPrimalBound():.6f}")
print(f"Cuts added: {sum(len(record['cuts']) for record in iteration_log.values())} "
f"in {len(iteration_log)} iterations")
assert abs(solver.getPrimalBound() + 20.903615) < 1e-3
Status : OptimalGlobal
Objective : -20.903615
Cuts added: 18 in 18 iterations
The figure has a panel for eight of the iterations, from the first to the last. The green region fulfils all the constraints when \(x_2\) is not required to be integer, and the dark green segments are the parts of it where \(x_2\) is integer. The circles are the solution points of the dual problem of the iteration, the stars the points where the constraints are linearized, and the red lines the new cuts; the cuts of earlier iterations are faint. The diamond in the last panel is the optimal solution.
# The feasible region of the problem with x2 continuous, on a grid
X1, X2 = np.meshgrid(np.linspace(1.0, 20.0, 160), np.linspace(1.0, 20.0, 160))
c1 = 0.15*(X1 - 8)**2 + 0.1*(X2 - 6)**2 + 0.025*np.exp(X1)/X2**2 - 5
c2 = 1/X1 + 1/X2 - np.sqrt(X1*X2) + 4
linear = 2*X1 - 3*X2 - 2
feasible = (c1 <= 0) & (c2 <= 0) & (linear <= 0)
# The feasible segments where x2 is integer
segment_x1 = np.linspace(1.0, 20.0, 2000)
segments = []
for value in range(1, 21):
fulfilled = ((0.15*(segment_x1 - 8)**2 + 0.1*(value - 6)**2 + 0.025*np.exp(segment_x1)/value**2 <= 5)
& (1/segment_x1 + 1/value - np.sqrt(segment_x1*value) <= -4) & (2*segment_x1 - 3*value <= 2))
if fulfilled.any():
segments.append((value, np.where(fulfilled, segment_x1, np.nan)))
# Eight iterations, evenly spread from the first to the last
iterations = sorted(iteration_log)
shown = sorted({iterations[round(k * (len(iterations) - 1) / 7)] for k in range(8)})
columns = 4
rows = math.ceil(len(shown) / columns)
figure, axes = plt.subplots(rows, columns, figsize=(2.8 * columns, 2.8 * rows), sharex=True, sharey=True,
constrained_layout=True)
axes = np.atleast_1d(axes).ravel()
for ax, iteration in zip(axes, shown):
ax.contourf(X1, X2, feasible, levels=[0.5, 1.5], colors=["#d7efe1"])
for constraint, color in ((c1, "#d78a27"), (c2, "#8856a7"), (linear, "#333333")):
ax.contour(X1, X2, constraint, levels=[0], colors=color, linewidths=0.8)
for value, row in segments:
ax.plot(row, np.full_like(row, value), color="#16834e", linewidth=2.5)
for earlier in iterations:
if earlier > iteration:
break
current = earlier == iteration
for (g1, g2), rhs, (p1, p2) in iteration_log[earlier]["cuts"]:
ax.contour(X1, X2, g1*X1 + g2*X2 - rhs, levels=[0], colors="#d62728" if current else "#c9a0a0",
linewidths=1.5 if current else 0.6)
if current:
ax.scatter(p1, p2, marker="*", s=90, color="#ee7b19", zorder=5)
for p1, p2 in iteration_log[iteration]["points"]:
ax.scatter(p1, p2, facecolors="none", edgecolors="black", s=45, zorder=6)
if iteration == iterations[-1]:
optimum = solver.getPrimalSolution().point
ax.scatter(optimum[0], optimum[1], marker="D", s=45, color="#159447", edgecolors="black", zorder=7)
ax.set_title(f"Iteration {iteration}", fontsize=8)
ax.tick_params(labelsize=7)
ax.set_xlim(0.5, 20.5)
ax.set_ylim(0.5, 20.5)
for ax in axes[len(shown):]:
ax.axis("off")
for ax in axes[::columns]:
ax.set_ylabel("$x_2$")
for ax in axes[-columns:]:
ax.set_xlabel("$x_1$")
figure.suptitle("Cuts from the HyperplaneSelection callback")
plt.show()
3.7. InteriorPointSearch¶
InteriorPointSearch lets you control which interior points are used by the Extended Supporting Hyperplane (ESH) algorithm. The ESH method needs a strictly interior point of the feasible region to generate supporting hyperplanes; normally SHOT finds this point automatically by solving a cutting-plane minimax problem. The callback gives you two complementary powers:
Inspect or replace the point(s) found by the internal strategy: the callback is called with
ctx.interiorPointscontaining the points the solver just found. Do nothing to keep them, or replace them withctx.setInteriorPoints(points).Bypass the internal search entirely by setting
Dual.ESH.InteriorPoint.Strategy = 1(“Only external (through callback)”). SHOT then skips the minimax problem and relies completely on the points you set. This is useful when you already know a feasible interior point — for example from a previous solve.
Field / action |
Type |
Description |
|---|---|---|
|
|
Points found by the internal strategy (empty with |
|
Replaces the points; each point can be in the variables of the original or the reformulated problem |
The example below demonstrates both powers in a two-phase pattern:
Phase 1: Solve the problem normally and capture the interior point in the callback.
Phase 2: Create a fresh solver with
Dual.ESH.InteriorPoint.Strategy = 1and set the captured point instead of letting SHOT search for one.
When no interior point is available from a previous solve, one can be found with a small auxiliary problem, as shown after the example.
# Phase 1: solve normally, and capture the interior point SHOT finds
print("Phase 1: solving with the default ESH strategy")
solver1 = SHOTpy.Solver()
solver1.updateSetting("Output.Console.LogLevel", 6) # Off
assert solver1.setProblem(problem_file)
captured_points = []
def capture_points(ctx):
captured_points.extend(ctx.interiorPoints)
print(f"[Phase 1] {len(ctx.interiorPoints)} interior point(s) found internally")
# No points are set, so SHOT keeps its own
solver1.registerCallback(SHOTpy.CallbackLocation.InteriorPointSearch, capture_points)
assert solver1.solveProblem()
phase1_obj = solver1.getPrimalBound()
print(f"Phase 1 objective: {phase1_obj:.6f} (known optimum ≈ -20.903615)")
print(f"Captured {len(captured_points)} interior point(s) to reuse in Phase 2\n")
# Phase 2: skip the internal search, and set the captured point instead
print("Phase 2: solving with Dual.ESH.InteriorPoint.Strategy = 1 (only external)")
solver2 = SHOTpy.Solver()
solver2.updateSetting("Output.Console.LogLevel", 6) # Off
solver2.updateSetting("Dual.ESH.InteriorPoint.Strategy", 1) # Only external (through callback)
assert solver2.setProblem(problem_file)
def set_points(ctx):
print(f"[Phase 2] {len(ctx.interiorPoints)} interior point(s) found internally (none, since the search is skipped)")
ctx.setInteriorPoints(captured_points)
solver2.registerCallback(SHOTpy.CallbackLocation.InteriorPointSearch, set_points)
assert solver2.solveProblem()
phase2_obj = solver2.getPrimalBound()
print(f"Phase 2 objective: {phase2_obj:.6f}")
print(f"Objectives match: {abs(phase2_obj - phase1_obj) < 1e-4}")
Phase 1: solving with the default ESH strategy
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
[Phase 1] 1 interior point(s) found internally
Phase 1 objective: -20.903615 (known optimum ≈ -20.903615)
Captured 1 interior point(s) to reuse in Phase 2
Phase 2: solving with Dual.ESH.InteriorPoint.Strategy = 1 (only external)
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
[Phase 2] 0 interior point(s) found internally (none, since the search is skipped)
Phase 2 objective: -20.903615
Objectives match: True
Finding an Interior Point with an Auxiliary Problem¶
When no interior point is available from a previous solve, one can be found by solving a small auxiliary problem: take the original problem, relax the integer variables to continuous ones, add a variable \(\mu\), subtract it from every nonlinear constraint, and minimize \(\mu\):
If the optimal \(\mu^\star < 0\), the solution point is strictly inside all the nonlinear constraints, and can be set in the InteriorPointSearch callback. The point is in the variables of the original problem, \(x_1\) and \(x_2\), without \(\mu\).
# Create the auxiliary problem
solver_aux = SHOTpy.Solver()
solver_aux.updateSetting("Output.Console.LogLevel", 6) # Off
problem_aux = SHOTpy.Problem(solver_aux)
x1 = problem_aux.addVariable("x1", SHOTpy.VariableType.Real, 1.0, 20.0)
x2 = problem_aux.addVariable("x2", SHOTpy.VariableType.Real, 1.0, 20.0) # relaxed from integer
mu = problem_aux.addVariable("mu", SHOTpy.VariableType.Real, -100.0, 100.0)
problem_aux.setObjective(mu, SHOTpy.ObjectiveDirection.Minimize)
problem_aux.addConstraint(0.15*(x1 - 8)**2 + 0.1*(x2 - 6)**2 + 0.025*SHOTpy.exp(x1)/x2**2 - mu <= 5, "c1")
problem_aux.addConstraint(1/x1 + 1/x2 - x1**0.5 * x2**0.5 - mu <= -4, "c2")
problem_aux.addConstraint(2*x1 - 3*x2 <= 2, "l")
problem_aux.finalize()
assert solver_aux.setProblem(problem_aux)
assert solver_aux.solveProblem()
aux_solution = solver_aux.getPrimalSolution()
interior_point = aux_solution.point[:2] # without mu
print(f"Optimal mu : {aux_solution.objValue:.6f}")
print(f"Interior point: x1 = {interior_point[0]:.6f}, x2 = {interior_point[1]:.6f}")
assert aux_solution.objValue < 0 # strictly inside the nonlinear constraints
# Solve the problem with the interior point instead of SHOT's own search
solver3 = SHOTpy.Solver()
solver3.updateSetting("Output.Console.LogLevel", 6) # Off
solver3.updateSetting("Dual.ESH.InteriorPoint.Strategy", 1) # Only external (through callback)
assert solver3.setProblem(problem_file)
solver3.registerCallback(SHOTpy.CallbackLocation.InteriorPointSearch,
lambda ctx: ctx.setInteriorPoints([interior_point]))
assert solver3.solveProblem()
print(f"Objective : {solver3.getPrimalBound():.6f} (known optimum ≈ -20.903615)")
assert abs(solver3.getPrimalBound() + 20.903615) < 1e-3
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 2 passes.
- Objective bounds are: [-4.99983, 100]
Optimal mu : -3.721615
Interior point: x1 = 7.435539, x2 = 8.550524
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
Objective : -20.903615 (known optimum ≈ -20.903615)
3.8. BoundsUpdated¶
BoundsUpdated is called after the primal bound, the current dual bound or the global dual bound has changed. Besides the common fields, its context has the bounds before the change (previousPrimalBound, previousDualBound and previousGlobalDualBound) and which of them changed (primalBoundChanged, dualBoundChanged and globalDualBoundChanged).
Unlike NewPrimalSolution, it is also called when only the dual bound changes, so it is the callback to use to follow how the bounds converge. The example below records the bounds after every change and plots them; Notebook 3 does the same over time for a larger problem.
# Create solver
solver = SHOTpy.Solver()
solver.updateSetting("Output.Console.LogLevel", 6) # Off
# Set problem
assert solver.setProblem(problem_file)
# Register callback
bounds_log = []
def record_bounds(ctx):
bounds_log.append((ctx.iterationNumber, ctx.primalBound, ctx.dualBound,
ctx.primalBoundChanged, ctx.dualBoundChanged))
solver.registerCallback(SHOTpy.CallbackLocation.BoundsUpdated, record_bounds)
# Solve
assert solver.solveProblem()
print(f"{len(bounds_log)} bound updates:")
print(f"{'iter':>5} {'primal bound':>13} {'dual bound':>11} changed")
for iteration, primal, dual, primal_changed, dual_changed in bounds_log:
changed = " and ".join(name for name, flag in (("primal", primal_changed), ("dual", dual_changed)) if flag)
print(f"{iteration:>5} {primal:>13.6f} {dual:>11.6f} {changed}")
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Objective bounds are: [-23.4282, -4.22729]
13 bound updates:
iter primal bound dual bound changed
1 -16.837660 -23.428216 dual
2 -16.857567 -23.428216 primal
2 -16.858557 -23.428216 primal
2 -20.038581 -23.428216 primal
2 -20.038581 -22.537354 dual
3 -20.038581 -22.125908 dual
4 -20.038581 -21.755719 dual
5 -20.038581 -21.383715 dual
6 -20.245940 -21.383715 primal
6 -20.903615 -21.383715 primal
7 -20.903615 -20.937306 dual
8 -20.903615 -20.903615 dual
8 -20.903615 -20.903615 primal
# Plot the bounds after each update; infinite bounds are left out
updates = range(1, len(bounds_log) + 1)
primal = [value if math.isfinite(value) else math.nan for _, value, _, _, _ in bounds_log]
dual = [value if math.isfinite(value) else math.nan for _, _, value, _, _ in bounds_log]
figure, ax = plt.subplots(figsize=(8, 4))
ax.step(updates, primal, where="post", label="Primal bound", color="#4E79A7", linewidth=2)
ax.step(updates, dual, where="post", label="Dual bound", color="#E15759", linewidth=2)
ax.set_xlabel("Bound update")
ax.set_ylabel("Objective value")
ax.set_title("Bounds of shot_ex_jogo from the BoundsUpdated callback")
ax.grid(True, linestyle="--", alpha=0.4)
ax.legend()
plt.show()
3.9. BoundTightening¶
BoundTightening lets you tighten the bounds of the variables with knowledge that SHOT does not have. It is called in solver.setProblem(), so unlike the other callbacks it has to be registered before that call. It is called twice: after SHOT’s bound tightening of the original problem, and again after that of the reformulated problem, which is created with the bounds of the original problem. ctx.isReformulatedProblem tells which call it is.
Field / action |
Description |
|---|---|
|
The problem whose bounds can be tightened |
|
The bounds of the variables after SHOT’s tightening |
|
Proposes bounds for a variable |
The proposed bounds are intersected with SHOT’s bounds, so a bound is never loosened, and the bounds of integer variables are rounded inwards. As for DualBoundUpdate, the bounds must not cut off the solutions you want SHOT to find.
The example below uses the objective value of a known solution, \(-20.903615\) (Section 3.5). A solution that is at least as good has \(-x_1 - x_2 \le -20.903615\), i.e., \(x_2 \ge 20.903615 - x_1 \ge 20.903615 - \bar{x}_1\), where \(\bar{x}_1\) is the upper bound of \(x_1\) after SHOT’s tightening of the original problem.
known_objective = -20.903615 # objective value of a known solution, from an earlier solve
# Create solver
solver = SHOTpy.Solver()
solver.updateSetting("Output.Console.LogLevel", 6) # Off
# Register callback, before setting the problem
event_log = []
def tighten_bounds(ctx):
lower, upper = ctx.variableLowerBounds, ctx.variableUpperBounds
name = "reformulated" if ctx.isReformulatedProblem else "original"
event_log.append(f"{name:>12} problem: x1 in [{lower[0]:.4f}, {upper[0]:.4f}], x2 in [{lower[1]:.0f}, {upper[1]:.0f}]")
if not ctx.isReformulatedProblem:
# A solution at least as good as the known one has x2 >= -known_objective - x1
ctx.setVariableBounds(1, -known_objective - upper[0], upper[1])
solver.registerCallback(SHOTpy.CallbackLocation.BoundTightening, tighten_bounds)
# Set problem
assert solver.setProblem(problem_file)
# Solve
assert solver.solveProblem()
print("Bounds after SHOT's tightening:")
for message in event_log:
print(f" {message}")
print(f"\nStatus : {solver.getModelReturnStatus().name}")
print(f"Objective : {solver.getPrimalBound():.6f}")
assert abs(solver.getPrimalBound() + 20.903615) < 1e-3
- Modeling system ────────────────────────────────────────────────────────────────────────────────────────────────────╴
Modeling system: OSiL
Problem read from file: shot_ex_jogo.osil
- Bound tightening ───────────────────────────────────────────────────────────────────────────────────────────────────╴
Performing bound tightening on original problem.
- Bounds for 2 variables tightened in 0.00 s and 5 passes.
- Bounds for 1 variables tightened by a callback.
- Objective bounds are: [-23.4282, -13.2273]
Bounds after SHOT's tightening:
original problem: x1 in [2.2273, 10.4282], x2 in [2, 13]
reformulated problem: x1 in [3.9236, 9.7351], x2 in [11, 13]
Status : OptimalGlobal
Objective : -20.903615
4. Key Takeaways¶
1. SHOTpy Can Load Problems Directly From Files¶
No need to rebuild your model in Python from scratch. Point SHOT at a file and let it handle the rest.
2. Solver Features and Settings Give You Full Control Over the Solve¶
There are a lot of different debugging features you can utilize. Moreover, one call — solver.updateSetting("Option.Name", value) — puts you in the driver’s seat where you can tune what matters most to you over the solve (termination, subsolvers, etc.). Just remember to change the settings before solver.setProblem().
3. Callbacks Let You Reach Inside the Solver¶
Settings cover passive configuration while callbacks go a step further, as you can inject your own logic in the solve: register a function at a CallbackLocation, read the state of the solver from its context, and act through the context’s methods.
Callbacks are a powerful tool on two fronts: bring domain knowledge directly into the algorithm for fewer iterations and faster solves, or use them as a research sandbox to experiment with custom strategies and probe SHOT’s internals in ways that settings alone never could.
Next Steps¶
SHOTpy runs inside Python — which means the entire Python ecosystem is at your disposal. In Notebook 3, you’ll learn how you can use SHOTpy in a Python ecosystem (for example: feed data straight from pandas, plot convergence with matplotlib, and more). The solver becomes just one component in a larger workflow rather than a standalone black box.
That same flexibility extends to research: use callbacks to instrument the solve, collect iteration-level data, test custom cut strategies, or benchmark algorithmic variants — all from a Jupyter notebook. If you can write it in Python, you can connect it to SHOT.