Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pyomo

Because POUNCE speaks the AMPL NL/SOL protocol, it drops into Pyomo through the AMPL Solver Library interface — exactly how Pyomo drives Ipopt.

The pyomo-pounce package registers pounce as a Pyomo SolverFactory solver:

import pyomo_pounce  # registers 'pounce'
from pyomo.environ import ConcreteModel, Var, Objective, SolverFactory

model = ConcreteModel()
model.x = Var(bounds=(-10, 10))
model.obj = Objective(expr=(model.x - 3) ** 2)

solver = SolverFactory('pounce')
solver.solve(model)

Options pass through the usual Pyomo mechanism:

solver.solve(model, options={'tol': 1e-10, 'max_iter': 500})

Under the hood, Pyomo writes the model to an AMPL .nl file, invokes pounce problem.nl -AMPL, and reads the result back from the .sol file. See Running Solves for the -AMPL solver mode.

Which pounce binary runs

import pyomo_pounce is required before SolverFactory('pounce'). Without it Pyomo does not know the solver and raises a clear UnknownSolver / “plugin not registered” error — it does not silently run some other pounce. With it imported, the plugin runs the binary bundled in the pounce-solver wheel, independent of PATH; only a source/dev install lacking that wheel falls back to a pounce on PATH (and the plugin warns when it does).

Because two builds can report the same version string (X.Y.Z) while behaving differently — a binary from before and after a fix does — a stale pounce on PATH is otherwise hard to notice. To see exactly which executable will run, its build (the git commit from pounce --about), and whether a different pounce earlier on PATH would shadow it:

import pyomo_pounce
pyomo_pounce.check_binary()   # prints a report; returns a dict

Which interface runs — and why it matters for timing

Pyomo has more than one way to drive an NL/SOL solver, and they are genuinely different code paths, not aliases. All of these reach POUNCE (verified against Pyomo 6.10.1):

callworkscarries pyomo-pounce’s extras
SolverFactory('pounce')yesyes
contrib.solver SolverFactory('pounce')yesyes
SolverFactory('pounce_v2')yesyes
SolverFactory('ipopt_v2', executable=<pounce>)yesno
SolverFactory('ipopt', executable=<pounce>)yesno
SolverFactory('asl', executable=<pounce>, solver='pounce')yesno
SolverFactory('appsi_ipopt', …)no — takes no executable

The first three are pyomo-pounce’s own registrations and the supported routes; they are the only ones that bring the rest of this page with them: the scaling_factor suffix handling, the sensitivity path, the preflight/repair helpers, the guard against handing a model with live integer variables to a continuous solver, and the bundled-binary resolution above. The generic routes run the same solver and return the same answer, but silently do without all of that.

ipopt and asl are Pyomo’s legacy solver interface; ipopt_v2 is the newer pyomo.contrib.solver one. Driving POUNCE through ipopt_v2 needs a build carrying the two ASL-compatibility fixes noted in the CHANGELOG under “Pyomo’s modern solver interface could not drive POUNCE at all” — before them it failed on every model, because Pyomo v2 passes options as key="value" in a single argv entry (quotes and all, since no shell is involved) and because POUNCE’s .sol wrote an Options count of 0, which the v2 .sol reader rejects.

Choosing between the legacy and v2 interfaces

Both of pyomo-pounce’s interfaces carry the same extras and return the same numbers — a test in pyomo-pounce/tests/test_v2.py solves one model through both and compares primals, objective, duals and reduced costs, so this is checked on every CI run rather than asserted here.

import pyomo_pounce
from pyomo.environ import SolverFactory
from pyomo.contrib.solver.common.factory import SolverFactory as SolverFactoryV2

solver = SolverFactory('pounce')       # legacy interface
solver = SolverFactoryV2('pounce')     # v2 interface (a Results object)
solver = SolverFactory('pounce_v2')    # v2 engine, legacy-style API

The v2 route needs Pyomo ≥ 6.10.1 (where the SolutionLoader / get_vars API it builds on landed — pyomo.contrib.solver.common exists from 6.9.2, but 6.9.2–6.10.0 ship the older SolutionLoaderBase / get_primals) and pounce-solver > 0.9.0 (Pyomo’s asl_sol_reader is strict where the legacy reader is lenient and needs the per-model .sol Options echo added after 0.9.0). pip install pyomo-pounce[pyomo-v2] asks for both. Neither applies to SolverFactory('pounce'): on an older Pyomo the legacy plugin works exactly as before and pyomo_pounce.HAVE_V2_INTERFACE reports False.

They differ in API and in per-solve overhead. The v2 interface returns a Results object and hands the solution back through a solution loader (so load_solutions=False gives you the values without touching the model); the legacy one returns a SolverResults and loads into the model as a side effect. Options are solver_options={...} on v2 against options={...} on the legacy route.

The v2 route can be materially faster outside the solve, and how much depends on the model’s shape. Same POUNCE binary, wall clock around solve() minus POUNCE’s own reported time:

modellegacy remainderv2 remainder
plain pyomo.dae four-tank collocation, n = 3,0100.109 s0.104 s
drto/IDAES quad_tank N=100, n = 2,9100.553 s0.301 s
drto/IDAES cart_pole N=100, n = 2,8100.566 s0.295 s

On the plain model the two are indistinguishable; on the IDAES-shaped ones the legacy interface adds roughly 0.25 s per solve (~1.8×). If your models are of that kind and you are solving many of them, the v2 route is worth taking. (Figures from the #552 measurements; the first row was measured on Linux, the other two on Windows, so read down the columns rather than across the rows.)

If you are benchmarking, put both solvers on the same interface. solver.solve(model) is not only the solve: it is Pyomo writing the .nl, launching the process, reading the .sol back and loading it into the model. Timing around that call and subtracting the solver’s own reported time leaves a remainder that is mostly Pyomo’s work, and — as the table above shows — it is not the same work on every interface. On the 3,010-variable collocation model that remainder breaks down as ~0.082 s Pyomo writing the .nl, ~0.020 s process spawn plus POUNCE’s own .nl read and setup, and ~0.008 s Pyomo reading the .sol and loading it. So comparing SolverFactory('pounce') against SolverFactory('ipopt_v2', …) compares two Pyomo interfaces as much as two solvers, and attributing the remainder to either solver’s file handling will mislead you. Use the same interface on both sides, or compare the solvers’ own reported times.

User scaling with the scaling_factor Suffix

A badly conditioned model converges poorly, and often you know its natural units better than the solver can infer from gradients at x0. The standard Pyomo channel for saying so is the scaling_factor Suffix, read exactly as Ipopt reads it:

model.scaling_factor = Suffix(direction=Suffix.EXPORT)
model.scaling_factor[model.obj] = 1e-3           # objective in MW, not W
model.scaling_factor[model.mass_balance] = 1e2   # one constraint
model.scaling_factor[model.energy_balance] = 1e2 # or a whole container

solver.solve(model, options={'nlp_scaling_method': 'user-scaling'})

Both halves are required: without nlp_scaling_method=user-scaling the Suffix is inert (a scaling_factor Suffix also drives Pyomo’s own core.scale_model transformation, which never involves the solver), and without the Suffix the option has nothing to apply — pyomo-pounce warns in that case rather than leaving you to wonder.

Rules, matching AMPL/Ipopt:

  • Only an export-enabled Suffix counts (Suffix.EXPORT or Suffix.IMPORT_EXPORT).
  • Components you do not list are unscaled, as are components listed with a factor of 0.
  • An entry on a container applies to every member.
  • Entries on inactive constraints/objectives and on fixed variables are skipped — none is a row or column of the problem the solver is handed.
  • Scaling changes conditioning, never the answer: solutions, duals, and everything the sensitivity accessors report come back in your model’s units.

Variables can be scaled, and a factor on a Var is applied as a change of variables inside the solver: the algorithm works in the scaled coordinates and the solution, the duals, and the bound multipliers come back in your model’s own units. No clone of the model is made and no propagate_solution step is needed, which is what distinguishes this from Pyomo’s core.scale_model transformation. Factors must be positive and finite. A negative factor would reverse a variable’s direction and swap its bounds, so it raises rather than being applied.

This works on both solve paths: the ordinary ASL/subprocess solve and the in-process path taken when the model carries sensitivity declarations — including the accessors themselves. covariance(), information(), gradient() and estimate() read the solver’s KKT factorization directly rather than through the scaling layer, so they carry the factors through their own natural-units translation and answer in your model’s units on a variable-scaled solve (issue #486).

Preflight and initialization

A Var whose .value was never set is written as 0 into the .nl file, so an uninitialized model actually starts at the origin (see Initialization and Warm Starts). The package ships a preflight check plus an initialization pipeline for exactly this:

import pyomo_pounce

report = pyomo_pounce.preflight(model)   # what will POUNCE see at x0?
print(report)                            # unset vars, bound/constraint
if report.fatal:                         # violations, NaN/inf evaluations
    ...

# fill -> repair -> block-solve, with the decisions held constant:
rep = pyomo_pounce.initialize(model, decisions=[m.feed, m.reflux])
if not rep.block.square:
    print(rep)          # names of what you forgot to specify

preflight evaluates every active constraint and the objective at the current values with unset values treated as 0 (exactly what the NL writer sends), restores the model untouched, and reports what iteration 0 will see; report.fatal means the solve would abort with Invalid_Number_Detected.

initialize follows the workflow you would run by hand on, say, a distillation column: set the decisions (feed, reflux, boilup), solve for a physical profile with them held constant, then let the optimizer move them. Its three stages are also available individually:

pyomo_pounce.initialize_missing_values(model)   # bounds-aware fill
                                                # (midpoint / one unit
                                                # inside / zero)

pyomo_pounce.project_to_feasible(model)         # min-norm repair: move the
                                                # current point onto the
                                                # model's own constraints
                                                # (one POUNCE solve)

rep = pyomo_pounce.block_initialize(            # solve the equality
    model, decisions=[m.feed, m.reflux])        # system's square blocks
                                                # in calculation order

initialize_missing_values fills each variable independently, so the fill can be internally inconsistent (mole fractions that do not sum to one); project_to_feasible repairs that by minimizing sum((v - v0)**2) subject to the model’s active constraints and bounds — the full nonlinear projection, solved with POUNCE, with the original objective restored afterwards.

Both stages guarantee that a failed solve leaves variable values exactly as they were: a diverged projection restores the pre-projection point, and a failed block solve restores that block’s seeds and stops, so initialization can never make your starting point worse than it found it.

block_initialize is IDAES-flavored initialization without hand-written routines. decisions= holds the listed variables at their current values for the solve and releases them afterwards (each must have a value). The active equality constraints are decomposed (Dulmage-Mendelsohn, via pyomo.contrib.incidence_analysis); the square part is solved block by block in topological order by Pyomo’s solve_strongly_connected_components (1x1 blocks by Newton, larger blocks by POUNCE), filling Var.value along the way. When the system is not square, report.square is False and the offending variables and constraints are reported by nameunderconstrained_variables is the list of things you forgot to specify or flag as decisions, overconstrained_constraints the redundant or conflicting specifications. Permanently-known inputs can simply be fix()ed instead of listed as decisions.

The analysis half is also available on its own:

rep = pyomo_pounce.block_analyze(               # the DM partition only:
    model, decisions=[m.feed, m.reflux])        # nothing seeded or solved
rep.underconstrained_variables                  # VarData objects, uncapped
rep.n_extra_degrees_of_freedom                  # how many specs are missing
rep.variable_blocks                             # the calculation order

block_analyze runs the same decision handling and the same Dulmage-Mendelsohn decomposition, but touches nothing: no values are read or written (so, unlike block_initialize, the decisions do not need values), and no solve happens. Where the initialization reports cap their name lists for display, block_analyze returns the full partition as the component objects themselves: the underconstrained and overconstrained subsystems, the square part, and its block-triangular calculation order. Use it to diagnose a large model’s specification, or as the structural front end for tooling that decides what to specify before calling initialize / block_initialize to do the work.

Repairing a bad specification

Some specifications are structurally wrong, not just badly started. On a distillation column at steady state, holding all the flow controls leaves the drum levels undetermined while the holdup balances become redundant — square by count, singular in structure, and no starting point fixes that. block_repair_plan plans a valid specification instead of failing on the broken one:

plan = pyomo_pounce.block_repair_plan(
    model,
    decision_candidates=[m.LT, m.VB, m.D, m.B])  # what you would like held
plan.decisions   # candidates a square system can hold
plan.pruned      # candidates the equalities claim: solved for instead
plan.pinned      # what nothing determines: hold at values you choose

The candidates are pruned to the subset a valid specification can hold: matching prefers plain variables over candidates, which provably minimizes the number pruned, and among candidates earlier-listed ones are preferentially kept, so the listing order acts as an implicit priority when a pruning tie could go either way. The pins need no user input: a variable is pinned when every one of its edges is provably unusable — the key case being an equation 0 == f/g, which cannot determine a variable appearing only in the denominator g, since its sensitivity there vanishes at every solution. That is exactly the shape substituting d/dt = 0 into a dynamic balance produces, which is how loose integrators (drum levels with no weir feedback) hide in steady-state models. Like block_analyze it is a plan, not an action: nothing is fixed, read, or written, and no values are needed. loose_variables (undetermined, not repairable) and redundant_constraints (satisfiable by no specification) are genuine model defects.

initialize and block_initialize run the same check on their decisions automatically (repair="auto", the default). A square specification is used exactly as given, the shipped behavior. A broken one is repaired: the decisions become the candidate pool, conflicting ones are pruned (they need no values), pins are seeded bounds-aware and never at zero when valueless (a pin lives in denominators, so zero is the one forbidden seed), and report.repair records the plan (None when nothing was needed). Pass repair="off" for the strict path: decisions held exactly as given, and a non-square specification is reported (report.square, the name lists) instead of repaired. The repair is call-scoped exactly like the decisions themselves (fixed flags restored, values only), so it never changes your model’s own specification. To apply a plan to a model you intend to solve — a square simulation, say — fix plan.decisions and plan.pinned and leave plan.pruned free; which variables to fix is a modeling decision, so the plan leaves it to you.