Skip to content

Debugging a wrong answer

What to read when a model solves to the wrong number, or does not solve, in the order that finds the fault soonest. Each step needs the file, or the file and its data, and none needs a solver run you have not already paid for.

1. Check the file against the sink

import specsolve as sps

sps.check('dispatch.yaml', sink='highs')

check raises on a construct outside the language, and with sink= on one the solver cannot take: an sos: set on highs, which has no such concept, until Spec.expand() writes it out as binaries (what each sink takes).

2. Read the shape the build produced

model = sps.build('dispatch.yaml', sources)
report = model.diagnostics()
report.columns, report.rows, report.nonzeros  # 8, 4, 8
report.omissions  # rows a constraint declared and did not build
report.sparse_parameters  # parameters whose table is short of their coordinates

A count smaller than you expected is a mask, or a table with a row missing. omissions names the constraint, sparse_parameters the parameter; which one you have is the difference between a where: you wrote and a row you lost (diagnostics).

3. Read the row that is wrong

print(model.row('power_balance', snapshot=2))
# power_balance[snapshot=2]: +1 p[2, wind] +1 p[2, gas] == 180

The line is the row as the solver got it: every coefficient the data produced, and no term for a variable a where: removed. Here solar is absent because its p_max is 0.0. A term you expected and do not see is a mask; a coefficient you did not expect is the data (Model.row).

4. When the solve is infeasible

result = model.solve()
result.status, result.termination_condition  # 'warning', 'infeasible'
result.has_primal  # False: nothing to read, and every reader raises

There is no IIS (irreducible infeasible subsystem) read-back. Locate the fault instead with a slack: add a variable to the balance row, minimise it, and read where it is nonzero. The feasibility model is that file for a dispatch. Then read the row at a snapshot the slack lands on, as in step 3.

5. When the number is wrong and the rows look right

Rows that read right and a number you still believe is wrong mean the file says something other than what you meant. Render it as math and read the constraint as written: typeset.

6. When a loop of re-solves is slow

Run the loop once with each keep= and read the clock the package keeps. kept confirms the request was honoured rather than quietly downgraded:

for keep in ('solver', 'progress'):
    model = sps.build('dispatch.yaml', sources)
    for numbers in walk:
        assert model.update(numbers).solve(keep=keep).kept in {keep, 'nothing'}
    print(keep, model.diagnostics().seconds['solve'])

Take the faster one. 'nothing' on every iteration means each update moved a mask and the model was rebuilt, so the loop is paying for the build, not the solve (Model.update).

keep='progress' can lose by an order of magnitude and win by a factor of two, so measure rather than guess. Over six updates on HiGHS (#815), carrying the solver's work cost 76.6 s against 4.3 s on a dispatch model whose presolve cracks the problem outright, an 18× loss, and 111.2 s against 213.9 s on a storage model whose cyclic recurrence presolve cannot crack, a 1.9× win. It pays where the model is hard for its solver's preprocessing and consecutive solves differ by a small step: a rolling horizon, a myopic pathway, a search that inches. The answer does not change either way: across both models the objectives agreed to 2e-15 relative. No solver option reaches the same thing; on both solvers that ship, an option asking for it did not produce it (#815).

Time with keep='nothing'. It discards the held solver before the load, so no basis, incumbent or solver-internal state survives. A benchmark needs that, and so does comparing two sets of solver_options.

A rebuild carries no progress. A cutting-plane master re-solved after gaining a cut has gained a row, and a basis spans the model it was read from. #382 tracks that case.