Intercepting AMPL Output: OutputHandler and Kind in amplpy#
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
displayoutput into a Python data structure.
amplpy exposes two building blocks for all of these use cases:
Symbol |
Role |
|---|---|
|
Base class (or duck-typed interface) — implement |
|
Integer constants that identify the type of each message |
|
Register |
|
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 fromamplpy.Kindthat identifies what triggered the message.msg— the raw text that AMPL would normally print tostdout.
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 |
post-solve inspection, testing |
Silent |
empty |
batch jobs, unit tests |
Capture solver log |
buffer |
store solver log in a database |
Route by kind |
dispatch on |
production application integration |
Key points:
Register with
ampl.set_output_handler(handler)and retrieve the current one withampl.get_output_handler().Inherit from
amplpy.OutputHandleror simply provide any object with anoutput(kind, msg)method.amplpy.Kindconstants identify the message source —Kind.SOLVEfor solver output,Kind.DISPLAYfordisplaycommand results,Kind.PRINTforprint/printf.The solver delivers its log in chunks that may include backspace characters for terminal progress lines; buffer and clean before storing.
OutputHandlerdoes not receive errors or warnings — handle those viaamplpy.ErrorHandler.