Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ List of supported model attributes:
* [`MOI.ConflictStatus()`](@ref)
* [`MOI.NLPBlock()`](@ref)
* [`MOI.Name()`](@ref)
* [`MOI.ObjectiveBound()`](@ref)
* [`MOI.ObjectiveSense()`](@ref)

List of supported constraint attributes:
Expand Down
30 changes: 30 additions & 0 deletions src/optimize.jl
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mutable struct Optimizer{T} <: MOI.AbstractOptimizer
silent::Bool
time_limit_sec::Union{Nothing,Float64}
solve_time_sec::Float64
objective_bound::Union{Nothing,Float64}
# Conflict (IIS) state, populated by `compute_conflict!`. `conflict_status`
# tracks whether a conflict computation ran and its outcome;
# `conflict_constraints` holds the inner `ConstraintIndex`es that findMUS
Expand All @@ -54,6 +55,7 @@ mutable struct Optimizer{T} <: MOI.AbstractOptimizer
false,
nothing,
NaN,
nothing,
MOI.COMPUTE_CONFLICT_NOT_CALLED,
Set{MOI.ConstraintIndex}(),
)
Expand Down Expand Up @@ -93,6 +95,8 @@ function _run_minizinc(dest::Optimizer)
try
_minizinc_exe() do exe
cmd = `$(exe) --solver $(dest.solver) --output-objective -o $(output) $(filename)`
# `-s` makes solvers report statistics, such as the objective bound
cmd = `$cmd -s`
if dest.time_limit_sec !== nothing
limit = round(Int, 1_000 * dest.time_limit_sec::Float64)
cmd = `$cmd --time-limit $limit`
Expand Down Expand Up @@ -150,6 +154,7 @@ function MOI.empty!(model::Optimizer{T}) where {T}
model.primal_objective = zero(T)
empty!(model.primal_solutions)
model.solve_time_sec = NaN
model.objective_bound = nothing
model.conflict_status = MOI.COMPUTE_CONFLICT_NOT_CALLED
empty!(model.conflict_constraints)
return
Expand Down Expand Up @@ -285,6 +290,21 @@ function _parse_solutions!(
return
end

function _parse_objective_bound(ret::String)
# solvers that determine a bound report it as an `objectiveBound` statistic
# with each solution. keep the last finite one that parsed successfully,
# which should be the tightest. the statistic can be a float, see
# https://docs.minizinc.dev/en/stable/fzn-spec.html#statistics-output
bound = nothing
for m in eachmatch(r"^%%%mzn-stat: objectiveBound=(.+?)\r?$"m, ret)
value = tryparse(Float64, m[1])
if value !== nothing && isfinite(value)
bound = value
end
end
return bound
end

function MOI.optimize!(dest::Optimizer{T}, src::MOI.ModelLike) where {T}
time_start = time()
MOI.empty!(dest.inner)
Expand All @@ -294,6 +314,7 @@ function MOI.optimize!(dest::Optimizer{T}, src::MOI.ModelLike) where {T}
empty!(dest.conflict_constraints)
index_map = MOI.copy_to(dest.inner, src)
ret = _run_minizinc(dest)
dest.objective_bound = _parse_objective_bound(ret)
if !isempty(ret)
dest.solver_status = _parse_status(ret)
if dest.solver_status in ("SATISFIABLE", "OPTIMAL")
Expand Down Expand Up @@ -383,3 +404,12 @@ function MOI.get(
end

MOI.get(model::Optimizer, ::MOI.SolveTimeSec) = model.solve_time_sec

function MOI.get(model::Optimizer{T}, attr::MOI.ObjectiveBound) where {T}
if model.objective_bound !== nothing
# throws an InexactError if the bound is not representable in T
return convert(T, model.objective_bound)
end
msg = "the solver did not report an objective bound"
return throw(MOI.GetAttributeNotAllowed(attr, msg))
end
60 changes: 60 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1604,13 +1604,15 @@ function test_highs_optimization()
@test MOI.get(solver, MOI.ResultCount()) >= 1
@test MOI.get(solver, MOI.VariablePrimal(), index_map[x]) == 10.0
@test MOI.get(solver, MOI.ObjectiveValue()) == 10.0
@test MOI.get(solver, MOI.ObjectiveBound()) === 10
MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE)
solver = MiniZinc.Optimizer{Int}("highs")
index_map, _ = MOI.optimize!(solver, model)
@test MOI.get(solver, MOI.TerminationStatus()) === MOI.OPTIMAL
@test MOI.get(solver, MOI.ResultCount()) >= 1
@test MOI.get(solver, MOI.VariablePrimal(), index_map[x]) == 1.0
@test MOI.get(solver, MOI.ObjectiveValue()) == 1.0
@test MOI.get(solver, MOI.ObjectiveBound()) === 1
@test MOI.get(solver, MOI.RawStatusString()) == "OPTIMAL"
return
end
Expand Down Expand Up @@ -1671,6 +1673,64 @@ function test_highs_optimization_timed_out()
MOI.optimize!(solver, model)
@test MOI.get(solver, MOI.PrimalStatus()) == MOI.FEASIBLE_POINT
@test MOI.get(solver, MOI.TerminationStatus()) == MOI.TIME_LIMIT
# the search did not complete, so the bound is reported by highs.
# it should be at least the LP relaxation bound,
# and strictly less than the objective value (assumed we timed out above)
bound = MOI.get(solver, MOI.ObjectiveBound())
@test sum(w) / 100 <= bound < MOI.get(solver, MOI.ObjectiveValue())
return
end

function test_parse_objective_bound()
# statistics reported by CP-SAT with `-s`
ret = """
%%%mzn-stat: objective=76
%%%mzn-stat: objectiveBound=31
%%%mzn-stat-end
_objective = 76;
----------
%%%mzn-stat: objective=74
%%%mzn-stat: objectiveBound=33
%%%mzn-stat-end
_objective = 74;
----------
"""
@test MiniZinc._parse_objective_bound(ret) === 33.0
@test MiniZinc._parse_objective_bound("_objective = 74;\n") === nothing
# an infinite bound is no bound, as reported by HiGHS before it has one
ret = "%%%mzn-stat: objectiveBound=-infinity\n"
@test MiniZinc._parse_objective_bound(ret) === nothing
# the bound is reported as is, without rounding
ret = "%%%mzn-stat: objectiveBound=20.5\n"
@test MiniZinc._parse_objective_bound(ret) === 20.5
return
end

function test_objective_bound_not_integer()
# a non-integer bound cannot be converted for an integer model
model = MiniZinc.Optimizer{Int}("highs")
model.objective_bound = 20.5
@test_throws InexactError MOI.get(model, MOI.ObjectiveBound())
model.objective_bound = 21.0
@test MOI.get(model, MOI.ObjectiveBound()) === 21
return
end

function test_chuffed_objective_bound()
# Chuffed does not report a bound
model = MOI.Utilities.Model{Int}()
x, _ = MOI.add_constrained_variables(model, fill(MOI.Interval(1, 3), 2))
MOI.add_constraint.(model, x, MOI.Integer())
f = 1 * x[1] + x[2]
MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f)
MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE)
solver = MiniZinc.Optimizer{Int}("chuffed")
MOI.optimize!(solver, model)
@test MOI.get(solver, MOI.TerminationStatus()) == MOI.OPTIMAL
@test_throws(
MOI.GetAttributeNotAllowed,
MOI.get(solver, MOI.ObjectiveBound()),
)
return
end

Expand Down
Loading