Intercepting AMPL Output: OutputHandler and Kind in amplpy#

output_handler.ipynb Open In Colab Open In Deepnote Open In Kaggle Open In Gradient Open In SageMaker Studio Lab Powered by AMPL

Description: Shows how to use amplpy.OutputHandler and amplpy.Kind to intercept, filter, and route AMPL interpreter output — covering silent solving, solver log capture, and structured logging by output kind.

Tags: amplpy, api, output-handler, logging, highlights

Notebook author: Jürgen Lentz <lentz@ampl.com>


By default amplpy writes all AMPL output directly to stdout. That is convenient for notebooks, but when you embed AMPL in a larger application you usually want more control:

  • suppress the solver banner during batch runs,

  • capture the solver log for later analysis,

  • route different kinds of output to a logger instead of stdout, or

  • collect display output into a Python data structure.

amplpy exposes two building blocks for all of these use cases:

Symbol

Role

amplpy.OutputHandler

Base class (or duck-typed interface) — implement output(kind, msg)

amplpy.Kind

Integer constants that identify the type of each message

ampl.set_output_handler(h)

Register h as the active handler

ampl.get_output_handler()

Retrieve the currently active handler

This notebook walks through the interface and then shows four practical handler patterns.

# Install dependencies
%pip install -q amplpy
# Google Colab & Kaggle integration
from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
    modules=["highs"],  # modules to install
    license_uuid="default",  # license to use
)  # instantiate AMPL object and register magics

Running example: the diet problem#

All examples in this notebook use the classic diet problem. The helper below builds a fresh AMPL instance with model and data already loaded — we call it at the start of each section to get a clean slate.

import re
import sys
import logging
import pandas as pd
import numpy as np
import amplpy
from amplpy import AMPL, Kind

foods = ["BEEF", "CHK", "FISH", "HAM", "MCH", "MTL", "SPG", "TUR"]
nutrs = ["A", "B1", "B2", "C"]

food_df = pd.DataFrame(
    {
        "cost":  [3.19, 2.59, 2.29, 2.89, 1.89, 1.99, 1.99, 2.49],
        "f_min": [0] * 8,
        "f_max": [100] * 8,
    },
    index=pd.Index(foods, name="FOOD"),
)
nutr_df = pd.DataFrame(
    {"n_min": [700] * 4, "n_max": [10000] * 4},
    index=pd.Index(nutrs, name="NUTR"),
)
amt_df = pd.DataFrame(
    np.array(
        [
            [60,  8,  8, 40, 15, 70, 25, 60],
            [10, 20, 15, 35, 15, 15, 25, 15],
            [15, 20, 10, 10, 15, 15, 15, 10],
            [20,  0, 10, 40, 35, 30, 50, 20],
        ]
    ),
    index=pd.Index(nutrs, name="NUTR"),
    columns=pd.Index(foods, name="FOOD"),
)

_MODEL = """
set FOOD;
set NUTR;
param cost {FOOD} > 0;
param f_min {FOOD} >= 0;
param f_max {j in FOOD} >= f_min[j];
param n_min {NUTR} >= 0;
param n_max {i in NUTR} >= n_min[i];
param amt {NUTR, FOOD} >= 0;
var Buy {j in FOOD} >= f_min[j], <= f_max[j];
minimize Total_Cost: sum {j in FOOD} cost[j] * Buy[j];
subject to Diet {i in NUTR}:
    n_min[i] <= sum {j in FOOD} amt[i,j] * Buy[j] <= n_max[i];
"""

def make_ampl():
    """Return a fresh AMPL instance with the diet model and data loaded."""
    a = AMPL()
    a.eval(_MODEL)
    a.set["FOOD"] = foods
    a.set["NUTR"] = nutrs
    a.set_data(food_df, "FOOD")
    a.set_data(nutr_df, "NUTR")
    a.param["amt"] = amt_df
    return a

Default behaviour#

Without a custom handler, amplpy uses a built-in OutputHandler that writes everything to stdout.
Calling ampl.solve() therefore prints the solver banner and summary directly in the notebook cell.

ampl = make_ampl()
ampl.solve(solver="highs")  # solver output goes straight to stdout
HiGHS 1.7.1:HiGHS 1.7.1: optimal solution; objective 88.2
1 simplex iterations
0 barrier iterations

The OutputHandler interface#

Register a custom handler by passing any object with an output(kind, msg) method to ampl.set_output_handler(). The simplest approach is to subclass amplpy.OutputHandler:

class MyHandler(amplpy.OutputHandler):
    def output(self, kind: int, msg: str) -> None:
        ...
  • kind — an integer constant from amplpy.Kind that identifies what triggered the message.

  • msg — the raw text that AMPL would normally print to stdout.

Errors and warnings are not passed through OutputHandler; use amplpy.ErrorHandler for those.

amplpy.Kind — common values#

The table below lists the constants you will encounter most often.

from amplpy import Kind

print(f"Kind.DISPLAY  = {Kind.DISPLAY:2d}   # output of 'display' commands")
print(f"Kind.EXPAND   = {Kind.EXPAND:2d}   # output of 'expand' commands")
print(f"Kind.PRINT    = {Kind.PRINT:2d}   # output of 'print' and 'printf' commands")
print(f"Kind.SOLUTION = {Kind.SOLUTION:2d}   # solution summary written by the 'solution' command")
print(f"Kind.SOLVE    = {Kind.SOLVE:2d}   # solver banner and log")
print(f"Kind.SHOW     = {Kind.SHOW:2d}   # output of 'show' commands")
print(f"Kind.XREF     = {Kind.XREF:2d}   # output of 'xref' commands")
print(f"Kind.MISC     = {Kind.MISC:2d}   # miscellaneous interpreter output")
Kind.DISPLAY  =  3   # output of 'display' commands
Kind.EXPAND   =  5   # output of 'expand' commands
Kind.PRINT    =  8   # output of 'print' and 'printf' commands
Kind.SOLUTION = 10   # solution summary written by the 'solution' command
Kind.SOLVE    = 11   # solver banner and log
Kind.SHOW     = 12   # output of 'show' commands
Kind.XREF     = 13   # output of 'xref' commands
Kind.MISC     = 16   # miscellaneous interpreter output

Pattern 1 — Collecting all output#

The most general pattern: store every message in a dictionary keyed by Kind.
After the solve you can inspect or post-process each category independently.

Note that a single AMPL operation can trigger output() several times in rapid succession — most importantly, the solver sends its log in multiple chunks as it runs. The get() helper below joins all chunks for a given kind into one string.

class CollectingHandler(amplpy.OutputHandler):
    """Accumulates all AMPL output, grouped by Kind."""

    def __init__(self):
        self._log = {}

    def output(self, kind, msg):
        self._log.setdefault(kind, []).append(msg)

    def get(self, kind):
        """Return the concatenated output for *kind*, stripping solver progress noise."""
        raw = "".join(self._log.get(kind, []))
        # The solver sends backspace characters to overwrite progress lines;
        # remove them so the captured log is clean.
        return re.sub(r"[^\x08\n]*\x08+", "", raw)


ampl = make_ampl()
handler = CollectingHandler()
ampl.set_output_handler(handler)

ampl.eval("display Total_Cost;")   # fires before the solve
ampl.solve(solver="highs")
ampl.eval("display Buy;")

print("Kinds seen:", sorted(handler._log))
print()
print(f"Solver output (Kind.SOLVE = {Kind.SOLVE}):")
print(handler.get(Kind.SOLVE))
print(f"Display output (Kind.DISPLAY = {Kind.DISPLAY}):")
print(handler.get(Kind.DISPLAY))
Kinds seen: [3, 11]

Solver output (Kind.SOLVE = 11):
HiGHS 1.7.1: optimal solution; objective 88.2
1 simplex iterations
0 barrier iterations

Display output (Kind.DISPLAY = 3):
Total_Cost = 0

Buy [*] :=
BEEF   0
 CHK   0
FISH   0
 HAM   0
 MCH  46.6667
 MTL   0
 SPG   0
 TUR   0
;

Pattern 2 — Silencing all output#

An empty output() body discards every message. This is useful in batch scripts or unit tests where solver banners would clutter the log.

class SilentHandler(amplpy.OutputHandler):
    def output(self, kind, msg):
        pass  # discard everything


ampl = make_ampl()
ampl.set_output_handler(SilentHandler())
ampl.solve(solver="highs")

assert ampl.solve_result == "solved"
print(f"Solved silently.  Objective: ${ampl.obj['Total_Cost'].value():.2f}")
Solved silently.  Objective: $88.20

Pattern 3 — Capturing the solver log#

A common need in production code is to capture the solver log for storage or analysis while letting other output (e.g. display results) continue to flow to stdout as usual.

The solver sends its output in small chunks, including backspace characters that overwrite progress lines on a terminal. The handler below buffers chunks, reassembles complete lines, and strips the progress noise before storing them.

class SolverLogHandler(amplpy.OutputHandler):
    """Captures solver output; passes everything else through to stdout."""

    def __init__(self):
        self._buf = ""
        self._lines = []

    def output(self, kind, msg):
        if kind == Kind.SOLVE:
            # Buffer chunks and emit whole lines, stripping progress noise.
            self._buf += msg
            while "\n" in self._buf:
                line, self._buf = self._buf.split("\n", 1)
                clean = re.sub(r"[^\x08]*\x08+", "", line)
                if clean.strip():
                    self._lines.append(clean)
        else:
            sys.stdout.write(msg)

    @property
    def solver_log(self):
        return "\n".join(self._lines)


ampl = make_ampl()
slh = SolverLogHandler()
ampl.set_output_handler(slh)

ampl.eval("display Total_Cost;")   # passes through to stdout
ampl.solve(solver="highs")          # captured by the handler
ampl.eval("display Total_Cost;")   # passes through to stdout

print("Captured solver log:")
print(slh.solver_log)
Total_Cost = 0

Total_Cost = 88.2

Captured solver log:
HiGHS 1.7.1: optimal solution; objective 88.2
1 simplex iterations
0 barrier iterations

Pattern 4 — Routing output to the Python logging module#

When AMPL is embedded in a larger application that uses Python’s logging module, it is natural to route AMPL output through the same logger. The handler below maps each Kind to a log level and writes clean lines to a named logger.

logging.basicConfig(
    level=logging.DEBUG,
    format="%(levelname)-8s [%(name)s] %(message)s",
    stream=sys.stdout,
    force=True,
)
logger = logging.getLogger("ampl")


class RoutingHandler(amplpy.OutputHandler):
    """Logs SOLVE output via Python logging; echoes DISPLAY and PRINT to stdout."""

    def __init__(self):
        self._buf = ""

    def output(self, kind, msg):
        if kind == Kind.SOLVE:
            self._buf += msg
            while "\n" in self._buf:
                line, self._buf = self._buf.split("\n", 1)
                clean = re.sub(r"[^\x08]*\x08+", "", line)
                if clean.strip():
                    logger.info(clean)
        elif kind in (Kind.DISPLAY, Kind.PRINT):
            sys.stdout.write(msg)
        # PROMPT and other housekeeping kinds are silenced


ampl = make_ampl()
ampl.set_output_handler(RoutingHandler())

ampl.eval('print "Starting optimisation...";')
ampl.solve(solver="highs")
ampl.eval("display Total_Cost;")
Starting optimisation...
INFO     [ampl] HiGHS 1.7.1: optimal solution; objective 88.2
INFO     [ampl] 1 simplex iterations
INFO     [ampl] 0 barrier iterations
Total_Cost = 88.2

Saving and restoring the active handler#

ampl.get_output_handler() returns the currently active handler, so you can save it and restore it later. This is useful when you only want to suppress or redirect output for a specific block of code.

class SilentHandler(amplpy.OutputHandler):
    def output(self, kind, msg):
        pass


ampl = make_ampl()

default_handler = ampl.get_output_handler()
print("Default handler type:", type(default_handler).__name__)

# Temporarily silence output for a single solve
ampl.set_output_handler(SilentHandler())
ampl.solve(solver="highs")       # no output

# Restore the default handler
ampl.set_output_handler(default_handler)
ampl.eval("display Total_Cost;")  # visible again
Default handler type: OutputHandler
Total_Cost = 88.2

Summary#

Pattern

Handler skeleton

Typical use

Collect all

store every (kind, msg) pair

post-solve inspection, testing

Silent

empty output() body

batch jobs, unit tests

Capture solver log

buffer Kind.SOLVE; pass the rest through

store solver log in a database

Route by kind

dispatch on kind to logger / stdout / file

production application integration

Key points:

  • Register with ampl.set_output_handler(handler) and retrieve the current one with ampl.get_output_handler().

  • Inherit from amplpy.OutputHandler or simply provide any object with an output(kind, msg) method.

  • amplpy.Kind constants identify the message source — Kind.SOLVE for solver output, Kind.DISPLAY for display command results, Kind.PRINT for print/printf.

  • The solver delivers its log in chunks that may include backspace characters for terminal progress lines; buffer and clean before storing.

  • OutputHandler does not receive errors or warnings — handle those via amplpy.ErrorHandler.