# ____________________________________________________________________________________
#
# Pyomo: Python Optimization Modeling Objects
# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC
# Under the terms of Contract DE-NA0003525 with National Technology and Engineering
# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this
# software. This software is distributed under the 3-clause BSD License.
# ____________________________________________________________________________________
import io
from typing import Sequence, Optional, Mapping
from pyomo.common.collections import ComponentMap
from pyomo.common.errors import MouseTrap
from pyomo.core.base.constraint import ConstraintData
from pyomo.core.base.var import VarData
from pyomo.core.expr import value
from pyomo.core.staleflag import StaleFlagManager
from pyomo.core.expr.visitor import replace_expressions
from pyomo.repn.plugins.nl_writer import NLWriterInfo
from pyomo.contrib.solver.common.util import SolverError
from pyomo.contrib.solver.common.results import (
Results,
SolutionStatus,
TerminationCondition,
)
from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase
[docs]
class ASLSolFileData:
"""
Defines the data fields found within an ASL .sol file
"""
[docs]
def __init__(self) -> None:
self.message: str = None
self.objno: int = 0
self.solve_code: int = None
self.ampl_options: list[int | float] = None
self.primals: list[float] = None
self.duals: list[float] = None
self.var_suffixes: dict[str, dict[int, int | float]] = {}
self.con_suffixes: dict[str, dict[int, int | float]] = {}
self.obj_suffixes: dict[str, dict[int, int | float]] = {}
self.problem_suffixes: dict[str, int | float] = {}
self.suffix_table: dict[(int, str), list[int | float, str, ...]] = {}
self.unparsed: str = None
[docs]
class ASLSolFileSolutionLoader(SolutionLoaderBase):
"""
Loader for solvers that create ASL .sol files (e.g., ipopt)
"""
[docs]
def __init__(self, sol_data: ASLSolFileData, nl_info: NLWriterInfo) -> None:
self._sol_data = sol_data
self._nl_info = nl_info
[docs]
def load_vars(self, vars_to_load: Optional[Sequence[VarData]] = None) -> None:
if vars_to_load is not None:
# If we are given a list of variables to load, it is easiest
# to use the filtering in get_primals and then just set
# those values.
for var, val in self.get_primals(vars_to_load).items():
var.set_value(val, skip_validation=True)
StaleFlagManager.mark_all_as_stale(delayed=True)
return
if not self._sol_data.primals:
# SOL file contained no primal values
assert len(self._nl_info.variables) == 0
else:
# Load the primals provided by the SOL file (scaling if necessary)
assert len(self._nl_info.variables) == len(self._sol_data.primals)
if self._nl_info.scaling:
for var, val, scale in zip(
self._nl_info.variables,
self._sol_data.primals,
self._nl_info.scaling.variables,
):
var.set_value(val / scale, skip_validation=True)
else:
for var, val in zip(self._nl_info.variables, self._sol_data.primals):
var.set_value(val, skip_validation=True)
# Compute all variables presolved out of the model
for var, v_expr in self._nl_info.eliminated_vars:
var.set_value(value(v_expr), skip_validation=True)
StaleFlagManager.mark_all_as_stale(delayed=True)
[docs]
def get_primals(
self, vars_to_load: Optional[Sequence[VarData]] = None
) -> Mapping[VarData, float]:
result = ComponentMap()
if not self._sol_data.primals:
# SOL file contained no primal values
assert len(self._nl_info.variables) == 0
else:
# Load the primals provided by the SOL file (scaling if necessary)
assert len(self._nl_info.variables) == len(self._sol_data.primals)
if self._nl_info.scaling:
for var, val, scale in zip(
self._nl_info.variables,
self._sol_data.primals,
self._nl_info.scaling.variables,
):
result[var] = val / scale
else:
for var, val in zip(self._nl_info.variables, self._sol_data.primals):
result[var] = val
# If we have eliminated variables, then we need to compute
# them. Unfortunately, the expressions that we kept are in
# terms of the actual variable values (which we don't want to
# modify). We will make use of an expression replacement
# visitor to perform the substitution and computation.
#
# It would be great if we could do this without creating the
# entire (unfiltered) result, but we just don't (easily) know
# which variable values we are going to need (either in the
# vars_to_load list, or in any expression that might be needed
# to compute an eliminated variable value. So to keep things
# simple (i.e., fewer bugs), we will go ahead and always compute
# everything.
if self._nl_info.eliminated_vars:
val_map = {id(k): v for k, v in result.items()}
for var, v_expr in self._nl_info.eliminated_vars:
val = value(replace_expressions(v_expr, substitution_map=val_map))
val_map[id(var)] = val
result[var] = val
if vars_to_load is not None:
result = ComponentMap((v, result[v]) for v in vars_to_load)
return result
[docs]
def get_duals(
self, cons_to_load: Optional[Sequence[ConstraintData]] = None
) -> dict[ConstraintData, float]:
if len(self._nl_info.eliminated_vars) > 0:
raise MouseTrap(
'Complete duals are not available when variables have '
'been presolved from the model. Turn presolve off '
'(solver.config.writer_config.linear_presolve=False) to get '
'dual variable values.'
)
scaling = self._nl_info.scaling
if scaling:
_iter = zip(
self._nl_info.constraints, self._sol_data.duals, scaling.constraints
)
inv_obj_scale = 1.0
if self._nl_info.scaling.objectives:
inv_obj_scale /= self._nl_info.scaling.objectives[self._sol_data.objno]
else:
_iter = zip(self._nl_info.constraints, self._sol_data.duals)
if cons_to_load is not None:
cons_to_load = set(cons_to_load)
_iter = filter(lambda x: x[0] in cons_to_load, _iter)
if scaling:
return {con: val * scale * inv_obj_scale for con, val, scale in _iter}
else:
return {con: val for con, val in _iter}
[docs]
def asl_solve_code_to_solution_status(
sol_data: ASLSolFileData, result: Results
) -> None:
"""Convert the ASL "solve code" integer into a Pyomo status
The ASL returns an indication of the solution status and termination
condition using a single "solve code" integer. This function
implements the translation of the numeric value into the Pyomo
equivalents (:class:`TerminationCondition` and
:class:`SolutionStatus`), as well as a general string description,
using the table from Section 14.2 in the AMPL Book [FGK02]_.
"""
#
# This table (the values and the string interpretations) are from
# Chapter 14 in the AMPL Book:
#
code = sol_data.solve_code
status = SolutionStatus.unknown if sol_data.primals else SolutionStatus.noSolution
if code is None:
message = f"AMPL({code}): solver did not generate a SOL file"
term = TerminationCondition.error
elif (code >= 0) and (code <= 99):
# message = f"AMPL({code}:solved): optimal solution found"
message = ''
status = SolutionStatus.optimal
term = TerminationCondition.convergenceCriteriaSatisfied
elif (code >= 100) and (code <= 199):
message = f"AMPL({code}:solved?): optimal solution indicated, but error likely"
status = SolutionStatus.feasible
term = TerminationCondition.error
elif (code >= 200) and (code <= 299):
message = f"AMPL({code}:infeasible): constraints cannot be satisfied"
status = SolutionStatus.infeasible
term = TerminationCondition.locallyInfeasible
elif (code >= 300) and (code <= 399):
message = f"AMPL({code}:unbounded): objective can be improved without limit"
term = TerminationCondition.unbounded
elif (code >= 400) and (code <= 499):
message = f"AMPL({code}:limit): stopped by a limit that you set"
term = TerminationCondition.iterationLimit # this is not always correct
elif (code >= 500) and (code <= 599):
message = f"AMPL({code}:failure): stopped by an error condition in the solver"
term = TerminationCondition.error
else:
message = f"AMPL({code}): unexpected solve code"
term = TerminationCondition.error
if sol_data.message:
# TBD: [JDS 10/2025]: Why do we convert newlines to semicolons?
result.extra_info.solver_message = sol_data.message.replace('\n', '; ')
if message:
result.extra_info.solver_message += '; ' + message
else:
result.extra_info.solver_message = message
result.solution_status = status
result.termination_condition = term
[docs]
def parse_asl_sol_file(FILE: io.TextIOBase) -> ASLSolFileData:
"""Parse an ASL .sol file.
This is a standalone routine to parse the AMPL Solver Library (ASL)
"``.sol``" file format. The resulting :class:`ASLSolFileData`
object is a faithful representation of the data from the file.
Translating the parsed data back into the context of a Pyomo model
requires additional information (at the very least, the Pyomo model
and the :class:`NLWriterInfo` data structure generated by the writer
that originally created the ``.nl`` file that was sent to the
solver.
"""
sol_data = ASLSolFileData()
# Parse the initial solver message and the AMPL options sections
z = _parse_message_and_options(FILE, sol_data)
#
# Parse the duals and variable values
#
num_duals = z[1] # "m" in writesol.c
assert num_duals == z[0] or not num_duals
sol_data.duals = [float(FILE.readline()) for i in range(num_duals)]
num_primals = z[3] # "n" in writesol.c
assert num_primals == z[2] or not num_primals
sol_data.primals = [float(FILE.readline()) for i in range(num_primals)]
# Parse the OBJNO (objective number and solver exit code)
_parse_objno_and_exitcode(FILE, sol_data)
# Parse the suffix data
_parse_suffixes(FILE, sol_data)
return sol_data
def _parse_message_and_options(FILE: io.TextIOBase, data: ASLSolFileData) -> list[int]:
msg = []
# Some solvers (minto) do not write a message. We will assume
# all non-blank lines up the 'Options' line is the message.
while True:
line = FILE.readline()
if not line:
# EOF
raise SolverError("Error reading `sol` file: no 'Options' line found.")
line = line.strip()
if line == 'Options':
break
if line:
msg.append(line)
data.message = "\n".join(msg)
# WARNING: This appears to be undocumented outside of the ASL
# writesol.c implementation. Before changing this logic, please
# familiarize yourself with that code.
#
# The AMPL options are a sequence of ints, the first of which
# specifies the number of options to expect, followed by the
# options (all ints), followed by the 4 int-elements of "z".
#
n_opts = int(FILE.readline())
#
# The ASL will occasionally "lie" about the number of options: if
# the second option (not including the number of options) is "3",
# then the ASL will add 2 to the number of options reported, and
# will add *one* option (vbtol, a float) *after* the elements of
# "z".
#
# Because of this, we will read the first two options from the file
# first so we can know how to correctly parse the remaining options.
assert n_opts >= 2
ampl_options = [int(FILE.readline()), int(FILE.readline())]
read_vbtol = ampl_options[1] == 3
if read_vbtol:
n_opts -= 2
ampl_options.extend(int(FILE.readline()) for i in range(n_opts - 2))
# Note: "z" comes from the name used for this data structure in
# `writesol.c`. It is unknown to us what motivated that name.
#
# Z: [ #cons; #duals, #vars, #var_vals ]
# #duals will either be #cons or 0
# #var_vals will either be #vars or 0
z = [int(FILE.readline()) for i in range(4)]
if read_vbtol:
ampl_options.append(float(FILE.readline()))
data.ampl_options = ampl_options
return z
def _parse_objno_and_exitcode(FILE: io.TextIOBase, data: ASLSolFileData) -> None:
line = FILE.readline().strip()
objno = line.split(maxsplit=2)
if not objno or objno[0] != 'objno':
raise SolverError(
f"Error reading `sol` file: expected 'objno'; received {line!r}."
)
elif len(objno) != 3:
# TBD: [JDS, 10/2025] there are paths where writesol.c will
# generate `objno` lines that contain only the objective number
# and not the solve_code. It is not clear to me that we should
# generate an exception here.
raise SolverError(
"Error reading `sol` file: expected two numbers in 'objno' line; "
f"received {line!r}."
)
data.objno = int(objno[1])
data.solve_code = int(objno[2])
def _parse_suffixes(FILE: io.TextIOBase, data: ASLSolFileData) -> None:
while line := FILE.readline():
line = line.strip()
if not line:
continue
line = line.split(maxsplit=6)
if line[0] != 'suffix':
# We assume this is the start of a section (like
# kestrel_option) that comes *after* all suffixes. We
# will capture it (and everything after it) and return
# it as a single "unparsed" text string.
data.unparsed = ' '.join(line) + "\n" + ''.join(FILE)
break
# Each suffix is introduced by:
#
# 'suffix' <kind> <n> <namelen> <tablelen> <tablines>
# <sufname>
#
# Where:
# kind (int): bitmask indicating suffix data type and target
# n (int): number of values returned
# namelen (int): suffix name string length (including NULL termination)
# tablelen (int): length of the "table" string (including NULL)
# tablines (int): number of lines in the table
# sufname (str): suffix name
kind = int(line[1])
value_converter = float if kind & 4 else int
suffix_target = kind & 3 # 0-var, 1-con, 2-obj, 3-prob
num_values = int(line[2])
# Note: we will use namelen to strip off the newline instead of
# strip() in case the suffix name actually ended with whitespace
# (evil, but technically allowed by the NL spec)
suffix_name = FILE.readline()[: int(line[3]) - 1]
# If the Suffix includes a value <-> string table, parse it.
# The table should be a series of <tablines> lines of the form:
#
# <val> <str> <description>
#
# The string representation of a suffix value is the row in the
# table whose <val> is the largest value less than or equal to
# the suffix value. The table should be ordered by <val>.
if int(line[4]):
data.suffix_table[suffix_target, suffix_name] = [
FILE.readline().strip().split(maxsplit=2) for _ in range(int(line[5]))
]
for entry in data.suffix_table[suffix_target, suffix_name]:
entry[0] = value_converter(entry[0])
# Parse the actual suffix values
if suffix_target == 0: # Var
data.var_suffixes[suffix_name] = suffix = {}
elif suffix_target == 1: # Con
data.con_suffixes[suffix_name] = suffix = {}
elif suffix_target == 2: # Obj
data.obj_suffixes[suffix_name] = suffix = {}
elif suffix_target == 3: # Prob
suffix = {}
# else: # Unreachable: kind & 3 can ONLY be 0..3
for cnt in range(num_values):
suf_line = FILE.readline().split(maxsplit=1)
suffix[int(suf_line[0])] = value_converter(suf_line[1])
if suffix_target == 3 and suffix:
assert len(suffix) == 1
data.problem_suffixes[suffix_name] = next(iter(suffix.values()))
return