The Writing a model in R article builds a
model in R. This article takes that model to a solution and back: what
the model object contains once it has data, where its files go, how
multimod turns it into a matrix or into code for another toolchain, how
to run that code, how the results come back, and what to look at when
two routes disagree. The example is example_models$toy,
three technologies supplying two regions. The chunks that need a solver
run where it is installed; the text quotes the numbers from a run with
all of them present.
The model as an object
toy <- example_models$toy
toy
#> Model: toy
#> Description: two-region capacity expansion toy model
#> Language: multimod
#> Sets: 2
#> Mappings: 2
#> Aliases: 0
#> Parameters: 4
#> Variables: 3
#> Equations: 3A model is a list of named components. It holds two kinds of content, which are best kept apart:
- Declarations say what exists. A parameter has dimensions, a variable has dimensions and a domain, an equation has sides, a relation and a domain. Rendering to any language only needs declarations.
- Data says how much of it exists. Sets carry their members, mappings their tuples, parameters their values. Solving needs data.
The domain connects the two. A variable declared over
c("tech", "region") with domain "mTR" has one
instance per row of the mapping mTR, not one per
combination of the sets. That is how a sparse model stays sparse, and it
is also the first thing to check when an equation produces no rows.
toy$variables$vGen$domain
#> [1] "mTR"
nrow(toy$mappings$mTR$data)
#> [1] 6
toy$equations$eqDemand$domain$name
#> [1] "mR"
toy$parameters$pDemand$data
#> region value
#> 1 north 1000
#> 2 south 600The workspace
Everything written for a solver goes into one directory, the workspace. Its layout is fixed so that the generated code can find the data and so that results land where multimod expects to read them:
<model_dir>/
model.rds, metadata.json, format.txt the structure and the data format
sets/<name>/data.csv one file per set
mappings/<name>/data.csv one file per mapping
parameters/<name>/data.csv one file per parameter
solvers/gmpl/ model.mod, data.dat MathProg, written by write_gmpl()
solvers/jump/ model.jl, data.jl JuMP, written by write_jump()
solvers/pyomo/ model.py, data.py Pyomo, written by write_pyomo()
solvers/<backend>/solution/ what the solver run leaves behind
variables/<name>/data.csv solutions loaded back into the model
save_model() creates a workspace explicitly (see “Model
workspace and data” for formats and lazy loading). The writers create
one themselves when the directory they are given is empty, which is what
this article does:
ws <- tempfile("toy-")
dir.create(ws)Solving in R
The matrix route does not generate code. model_to_lp()
walks the equations and the data and assembles the LP arrays
directly.
lp <- model_to_lp(toy)
names(lp)
#> [1] "A" "obj" "row_lo" "row_up"
#> [5] "col_lo" "col_up" "maximum" "objective_symbol"
#> [9] "row_index" "col_index" "zeroed"
dim(lp$A)
#> [1] 9 13Each equation contributes one row per tuple of its domain mapping:
six for eqCapLim (every tuple of mTR), two for
eqDemand (every region in mR), one for the
scalar eqCost. Each variable contributes one column per
tuple of its domain: six, six, and one for vCost. The two
index tables are the dictionary between matrix positions and model
symbols, and every result table is built on them:
lp$row_index
#> i symbol i1 i2 sense rhs
#> <int> <char> <char> <char> <char> <num>
#> 1: 1 eqCapLim coal north <= NA
#> 2: 2 eqCapLim gas north <= NA
#> 3: 3 eqCapLim wind north <= NA
#> 4: 4 eqCapLim coal south <= NA
#> 5: 5 eqCapLim gas south <= NA
#> 6: 6 eqCapLim wind south <= NA
#> 7: 7 eqDemand north <NA> >= NA
#> 8: 8 eqDemand south <NA> >= NA
#> 9: 9 eqCost <NA> <NA> == NA
lp$col_index[c(1, 7, 13), ]
#> j symbol i1 i2 lo up vtype
#> <int> <char> <char> <char> <num> <num> <char>
#> 1: 1 vCap coal north 0 Inf positive
#> 2: 7 vGen coal north 0 Inf positive
#> 3: 13 vCost <NA> <NA> -Inf Inf continuous
lp$objective_symbol
#> [1] "vCost"row_lo/row_up and
col_lo/col_up hold the bounds; an equality row
has both equal, a <= row has -Inf below.
Before solving a large model, check_matrix_numbers()
reports the magnitude spectrum of the coefficients, right-hand sides and
bounds. Coefficients spanning more than about eight orders of magnitude,
or infinite entries, are the usual cause of a solver reporting
“unbounded” or “infeasible” for a model that is neither, and the
offenders_* tables name the equations and variables
responsible.
cm <- check_matrix_numbers(lp)
#> section entries zeros nonfinite min_abs max_abs orders below_small
#> COLUMNS 29 0 0 0.25 120 2.7 0
#> OBJECTIVE 1 0 0 1.00 1 0.0 0
#> RHS 10 8 0 600.00 1000 0.2 0
#> BOUNDS 0 0 0 NA NA NA 0
#> above_large
#> 0
#> 0
#> 0
#> 0
names(cm)
#> [1] "summary" "decades" "offenders_row"
#> [4] "offenders_col" "nonfinite_row" "nonfinite_col"
#> [7] "structure" "empty_col_symbols" "empty_row_symbols"
#> [10] "small" "large"solve_highs() passes the arrays to the HiGHS solver in
the ‘highs’ package. attach = FALSE returns the result as a
list rather than storing it in the model.
r <- solve_highs(toy, attach = FALSE)
r$objective
#> [1] 198000
r$status_message
#> [1] "Optimal"status is HiGHS’s model-status code and
status_message its name; 7 is “Optimal”. The primal table
is the column index with the solution and the reduced cost added, the
dual table the row index with the row activity and the marginal. Here
the marginal on eqDemand is the cost of one more unit of
demand in each region:
r$primal[r$primal$value > 0, c("symbol", "i1", "i2", "value", "reduced_cost")]
#> symbol i1 i2 value reduced_cost
#> <char> <char> <char> <num> <num>
#> 1: vCap wind north 1111.1111 0
#> 2: vCap coal south 666.6667 0
#> 3: vGen wind north 1000.0000 0
#> 4: vGen coal south 600.0000 0
#> 5: vCost <NA> <NA> 198000.0000 0
r$dual[r$dual$symbol == "eqDemand", c("symbol", "i1", "value", "marginal")]
#> symbol i1 value marginal
#> <char> <char> <num> <num>
#> 1: eqDemand north 1000 100.0000
#> 2: eqDemand south 600 163.3333The arrays can also leave R: write_mps() writes them as
an MPS file for any solver that reads one, and solve_mps()
solves such a file locally with HiGHS and writes a solution file
read_mps_solution() understands. mm_session()
keeps the assembled matrix between repeated solves of the same structure
with changed data.
Generating code
The code-generation routes render the model in another language and leave the solve to that language’s toolchain. MathProg is shown first and in full, because the generated model is short; the JuMP and Pyomo code is described by how it differs from it.
MathProg, in full
f <- write_gmpl(toy, model_dir = ws)
mod <- readLines(f$model)
cat(mod[1:grep("^solve;", mod)], sep = "\n")
#> # Model: toy
#> # two-region capacity expansion toy model
#>
#> set tech; # generation technologies
#> set region; # model regions
#>
#>
#> set _IF_; # Singleton set for GAMS $ operator emulation
#>
#> set mTR dimen 2; # technology x region pairs
#> set mR dimen 1; # regions
#>
#> param pCapCost{tech}; # annualized capacity cost, EUR/kW
#> param pVarCost{tech}; # variable cost, EUR/MWh
#> param pAvail{tech,region}; # availability factor
#> param pDemand{region}; # annual demand, MWh
#>
#> var vCap{t in tech, r in region} >= 0; # installed capacity, kW
#> var vGen{t in tech, r in region} >= 0; # generation, MWh
#>
#> minimize vCost: sum{t in tech, r in region} (pCapCost[t] * vCap[t,r] + pVarCost[t] * vGen[t,r]);
#>
#> # generation is limited by available capacity
#> s.t. eqCapLim{(h,r) in mTR}: vGen[h,r] <= pAvail[h,r] * vCap[h,r];
#>
#> # demand is met in every region
#> s.t. eqDemand{r in mR}: sum{t in tech} vGen[t,r] >= pDemand[r];
#>
#>
#> solve;Reading top to bottom:
- Sets are declared without members, mappings as sets of tuples with
their dimension (
dimen 2). Members and tuples come from the data file. -
_IF_is a one-element set multimod uses to express GAMS-style$conditions as MathProg set filters; the toy has no conditions, so it is declared and not used. - Parameters are declared with their index sets only; values come from the data file.
- Variables are declared over the full set product with iterator names
taken from the model’s index aliases (
t,r). Equations are declared over their domain mapping,{(h,r) in mTR}, with the iterator names the equation itself carries. This is where the domain shows up in generated code: a tuple absent frommTRyields no constraint. - The objective equation
eqCostis not written as a constraint. The objective statement takes its right-hand side, becausetoy$objectivesnameseqCostas the equation definingvCost.
After solve; the file continues with printf
statements, in two groups. The first writes
constraint_stats.csv and variable_stats.csv,
one line per equation and variable with the number of instances. The
second writes the solution: one CSV per variable with the index columns
and the value, filtered to non-zero entries, plus a log. These files are
what solve_gmpl() reads back.
cat(mod[grep("^printf \"tech,region,value", mod)[1] + 0:3], sep = "\n")
#> printf "tech,region,value\n" > "solution/vCap.csv";
#> for{t in tech, r in region : vCap[t,r] <> 0} {
#> printf "%s,%s,%f\n", t,r,vCap[t,r] >> "solution/vCap.csv";
#> }The data file is a MathProg data section:
dat <- readLines(f$data)
cat(dat[grep("^param pAvail", dat) + 0:7], sep = "\n")
#> param pAvail default 0 :=
#> [coal,north] 0.84999999999999998
#> [gas,north] 0.84999999999999998
#> [wind,north] 0.90000000000000002
#> [coal,south] 0.90000000000000002
#> [gas,south] 0.34999999999999998
#> [wind,south] 0.25;Two rules of the format matter when a model misbehaves. MathProg has
no implicit default: a parameter referenced for a tuple that is not in
the data is an error unless the declaration carries
default, so every parameter is written with one (the
model’s defVal, else 0). And every declared set, mapping
and parameter must appear in the data section even when it is empty, so
an empty mapping is written as set name := ;. A model whose
data lives on disk is written one parameter at a time, so the data file
for a large model never requires the whole model in memory.
JuMP
write_jump() writes two files. model.jl
holds the declarations and the constraints; data.jl loads
the data from the workspace at run time, so data can change without
regenerating the model.
jl <- write_jump(toy, model_dir = ws)
cat(grep("^@variable|^@objective", jl, value = TRUE), sep = "\n")
#> @variable(model, vCap[mTR] >= 0) # installed capacity, kW
#> @variable(model, vGen[mTR] >= 0) # generation, MWh
#> @objective(model, Min, sum(get(pCapCost, h, pCapCostDef) * vCap[(h, r)] + get(pVarCost, h, pVarCostDef) * vGen[(h, r)] for h in tech, r in region))
i <- grep("eqCapLim\\[", jl)[1]
cat(jl[(i - 2):(i + 2)], sep = "\n")
#> _t0 = time()
#> # generation is limited by available capacity
#> @constraint(
#> model,
#> eqCapLim[(h, r) in mTR],
#> vGen[(h, r)] <= get(pAvail, (h, r), pAvailDef) * vCap[(h, r)]
#> )
#> _log_constraint(constraint_stats, "eqCapLim", eqCapLim, length(eqCapLim), time() - _t0)Three properties of this code matter:
-
Variables are indexed by their domain mapping,
vCap[mTR], and constraints iterate over it,eqCapLim[(h, r) in mTR]. The mapping is loaded as a JuliaSetof tuples (or of strings when it has one dimension), so membership tests are constant time. -
Parameters are dictionaries read through
get()with a default,get(pCapCost, h, pCapCostDef). A tuple missing from the data yields the declared default rather than an error, which mirrors the GAMS semantics the AST follows. -
Every constraint block is timed and counted. The
generated code writes
constraint_stats.csv(instances, dimension, size of the domain mapping, build time, non-zeros per row) andvariable_stats.csv(instances, bound types). On a translated model these two files are the first thing to read: an equation with zero instances or a variable with no finite bound is visible there before any solve.
data.jl finds the workspace relative to its own location
and detects the format from format.txt, so the same file
works for CSV, Arrow IPC and Parquet workspaces. With
data_mode = "embedded" the data is written into the Julia
source instead, which makes the model file self-contained but means it
has to be regenerated when the data changes.
Pyomo
write_pyomo() also writes two files,
model.py and data.py. The model is a Pyomo
AbstractModel: declarations only, instantiated with data by
data.py at run time.
py <- write_pyomo(toy, model_dir = ws, export_vars = TRUE)
mp <- readLines(py)
cat(grep("^model\\.(tech|mTR|pAvail|vGen|eqCapLim) ", mp, value = TRUE), sep = "\n")
#> model.tech = Set(doc="generation technologies")
#> model.mTR = Set(dimen=2, doc="technology x region pairs")
#> model.pAvail = Param(model.tech, model.region, default=0, domain=Reals, doc="availability factor")
#> model.vGen = Var(model.mTR, within=NonNegativeReals, bounds=(0, None)) # generation, MWh
#> model.eqCapLim = Constraint(model.tech, model.region, rule=_eqCapLim_rule)Sets and parameters are declared with their index sets and defaults;
variables over their domain mapping; constraints through a rule function
per equation. The runner at the end of model.py builds the
instance, picks the solver named in the PYOMO_SOLVER
environment variable (set by solve_pyomo(solver = )),
solves, and writes solution/status.json. With
export_vars = TRUE it also writes
solution/variables.json, which is what
load_results reads.
Running the code
Each solve_*() function runs the toolchain on the
workspace and returns a list with success,
objective, status, timing, the raw solver
output, and, when a model is passed, the model with solutions attached
under $model.
g <- solve_gmpl(toy, ws, verbose = FALSE)
g$objective
#> [1] 198000
g$status
#> [1] "OPTIMAL"
j <- solve_jump(toy, ws, verbose = FALSE)
#> Loading solutions from: solvers/jump/solution
j$objective
#> [1] 198000
j$status
#> [1] "OPTIMAL"
p <- solve_pyomo(toy, ws, solver = "highs", verbose = FALSE, load_results = TRUE)
p$objective
#> [1] 198000
p$status$termination
#> [1] "optimal"The command each route runs, which also runs the code by hand from the backend’s directory for closer inspection:
| Route | Command | Leaves behind in solvers/<backend>/
|
|---|---|---|
| GLPK | glpsol -m model.mod -d data.dat -o solution.sol --log solver.log |
solution.sol, solver.log,
solution/*.csv, the two stats files |
| JuMP | julia model.jl |
solution/*.csv, the two stats files, the Julia
output |
| Pyomo | python model.py |
solution/status.json,
solution/variables.json
|
The first Julia run installs and compiles the packages the model
needs, which makes it much slower than later runs.
solve_jump() and solve_gmpl() accept
timeout =; solve_pyomo(ensure_env = TRUE)
checks the Python environment with mm_check_python() before
running.
Results back in R
With load_results = TRUE (the default when a model is
passed) every route stores each variable’s values as a data frame under
model$variables[[name]]$solution, with the dimension names
as columns and value last, and copies the files to
variables/ in the workspace.
g$model$variables$vGen$solution
#> tech region value
#> 1 coal south 600
#> 2 wind north 1000
g$model$solution_metadata$variables_loaded
#> [1] "vCap" "vCost" "vGen"Because every table has the same shape, results from different routes line up directly. The objective is the simplest comparison:
objectives <- c(highs = r$objective)
if (has_glpsol) objectives["gmpl"] <- g$objective
if (has_julia) objectives["jump"] <- j$objective
if (has_python) objectives["pyomo"] <- p$objective
objectives
#> highs gmpl jump pyomo
#> 198000 198000 198000 198000and one variable across routes is a merge on the index columns:
v_highs <- r$primal[r$primal$symbol == "vGen", c("i1", "i2", "value")]
names(v_highs) <- c("tech", "region", "highs")
v_gmpl <- g$model$variables$vGen$solution
names(v_gmpl)[3] <- "gmpl"
merge(v_highs, v_gmpl, all = TRUE)
#> Key: <tech, region>
#> tech region highs gmpl
#> <char> <char> <num> <num>
#> 1: coal north 0 NA
#> 2: coal south 600 600
#> 3: gas north 0 NA
#> 4: gas south 0 NA
#> 5: wind north 1000 1000
#> 6: wind south 0 NAThe GLPK table lists only non-zero entries (the generated
printf loop filters them), so the merge fills the zeros
with NA; the matrix route reports every column.
When routes disagree
Same model, same data, different objective: the translation has read something differently. The cases below are the common causes, in the order in which they are checked.
-
An equation produces no rows. Its domain mapping
has no tuples, or the tuples do not match the data. In R,
table(lp$row_index$symbol)shows the row count per equation, andmodel_to_lp(on_empty_row = )decides whether an empty row is an error (the default for user constraints), a warning or ignored. In JuMP,constraint_stats.csvshowscount0; in MathProg the same file shows the cardinality product, which is only an upper bound. -
A parameter’s default differs. The matrix route and
JuMP use the declared
defValfor a tuple missing from the data; MathProg uses thedefaultwritten in the data file, which isdefValor 0. A parameter whosedefValisNULLand whose data is incomplete is read as 0 by every route, which may not be what the model meant.validate()lists parameters without data. -
Infinite values.
Infin parameter data cannot enter a matrix. The matrix route drops the affected rows and maps an infinite default to 0, as the energyRt writers do; MathProg receives1e20(write_gmpl(INF = )); JuMP and Pyomo receiveInfas a bound where the value is a bound and as a coefficient otherwise, where the solver decides. Search the data forInfbefore comparing. -
Missing index aliases. The code generators write
iterator names from the model’s index aliases. A model built in R has
none until
add_index_aliases()adds them; without them asum{}is written with no iterator and glpsol rejects the file.has_index_aliases()reports whether they are present. -
Row counts, not objectives. When the objectives
differ, compare per-equation instance counts first
(
table(lp$row_index$symbol)againstconstraint_stats.csv), then per-variable bounds (lp$col_indexagainstvariable_stats.csv). A count that differs identifies the equation; an objective that differs does not.
Choosing a route
| Route | Needs | Returns | Use when |
|---|---|---|---|
solve_highs() |
the ‘highs’ R package | primal, duals, reduced costs, the LP arrays | the model fits in memory and duals or repeated solves
(mm_session()) are needed |
write_mps() |
the ‘highs’ R package | an MPS file | the solver runs elsewhere |
solve_gmpl() |
glpsol |
primal values | a small or medium LP, or when a readable model file is the point |
solve_jump() |
Julia | primal values, build statistics | a large model, or a solver only JuMP wraps
(optimizer =) |
solve_pyomo() |
Python with pyomo | primal values, status | the results continue in a Python pipeline |
write_gams() |
GAMS elsewhere | GAMS source | hand-over to a GAMS installation or NEOS |
Whatever the route, the same model object, data and workspace are behind it, which is what makes the comparison in the previous section possible.
