How to add a solution description to a problem
By default, the web-GUI shows a solution as a list of objective values. For many
problems, those numbers alone do not tell a decision maker what the solution
actually means. They may also want to know which investments it makes, what each
part of the cost is, or how many hours of demand go unmet. A
SolutionDescriptionMetaData attached to a problem tells the web-API how to turn
any solution of that problem into a short, human-readable text. The web-GUI then
shows that text in a "Solution Description" panel next to the selected solution.
This guide explains how a description is defined, how to attach one to a problem, and what to watch out for.
Where it is used
The NIMBUS and CUMULUS views in the web-GUI show the description panel. It appears whenever the problem has solution description metadata, unless the problem also has forest (map) metadata, in which case the map is shown instead.
The idea: a template made of parts
A description is a list of
DescriptionPart
objects. When a solution is described, each part is rendered to a string, and the
strings are joined with separator (a newline by default):
from desdeo.api.models.problem import DescriptionPart
parts = [
DescriptionPart(text="Investment decisions:"),
DescriptionPart(label=" Battery capacity", symbol="E", format_spec=".1f", suffix=" kWh"),
DescriptionPart(label=" Solar panels", symbol="n", format_spec=".0f"),
DescriptionPart(
label=" Solar panel cost",
expression=["Multiply", 200, "n"],
format_spec=".0f",
suffix=" €",
),
]
For a solution with E = 12.0 and n = 8, this renders as:
The panel preserves whitespace, so leading spaces in a label indent that line.
Fields of a part
Each part should set exactly one of text, symbol or expression:
| Field | Meaning |
|---|---|
text |
Static text, included as is. label, format_spec and suffix are ignored. |
symbol |
The symbol of a value in the solution (see below). |
expression |
A MathJSON expression, evaluated with the solution's values. |
The remaining fields only apply to symbol and expression parts:
| Field | Meaning |
|---|---|
format_spec |
A Python format spec applied to the value, e.g. ".0f", ".2f", ",.0f", ".1%". Without it, the value is shown with str(). |
label |
Placed before the value, followed by ": ". |
suffix |
Appended right after the value, e.g. a unit such as " kWh". Include the leading space yourself. |
A rendered part therefore looks like <label>: <formatted value><suffix>, or
<formatted value><suffix> when there is no label.
Expressions
An expression is a MathJSON list, the same format the problem's own functions
use. Use one to show a quantity that is not itself a symbol of the problem, such
as one term of a cost:
DescriptionPart(label="Battery fixed cost", expression=["Multiply", 2000, "y"], format_spec=".0f", suffix=" €")
DescriptionPart(label="Worst objective", expression=["Max", "f_1", "f_2", "f_3"], format_spec=".2f")
Before the expression is evaluated, every symbol in it is replaced by its numeric
value. This means symbol names that SymPy normally reserves, such as E or I,
are safe to use as long as the problem defines them.
Which values are available
A symbol, or a symbol inside an expression, can refer to:
- decision variables,
- objectives,
- constraints,
- extra functions,
- scalarization functions,
- the problem's constants.
These are read from the solver results of the selected solution. Constants come from the problem itself. If a symbol is both a constant and part of the solution, the solution's value is used.
Tensors are expanded element by element using DESDEO's 1-based naming. A
tensor variable x of shape [3] gives x_1, x_2 and x_3. One of shape
[2, 3] gives x_1_1 … x_2_3. A bare tensor symbol such as x refers to its
first element, not to the whole tensor. To aggregate over a tensor, list its
elements explicitly:
DescriptionPart(label="Peak load", expression=["Max", "x_1", "x_2", "x_3"], format_spec=".1f", suffix=" kW")
If the template is long, generate these lists in Python, e.g.
["Add", *[f"x_{i}" for i in range(1, 25)]].
Saved solutions
A saved solution (in NIMBUS or CUMULUS) only stores its decision variable
and objective values. A part that refers to a constraint, extra function or
scalarization function then renders as [unknown symbol: ...]. If saved
solutions also need to be described, write the template in terms of
variables, objectives and constants where possible.
Attaching a description to a problem
There are two ways to attach a description. Neither is possible through the "Create custom problem" screen of the web-GUI.
Option A: in a database init script
This is the way to go when you add problems ahead of time (see
How to solve problems with the web-GUI).
After storing the problem, create a ProblemMetaDataDB row for it if one does not
exist yet. Then add a SolutionDescriptionMetaData that points to it:
from sqlmodel import Session, select
from desdeo.api.db import engine
from desdeo.api.models import ProblemDB, User
from desdeo.api.models.problem import DescriptionPart, ProblemMetaDataDB, SolutionDescriptionMetaData
from desdeo.api.routers.solution_description import validate_description_parts
parts = [
DescriptionPart(label="Battery capacity", symbol="E", format_spec=".1f", suffix=" kWh"),
DescriptionPart(label="Investment cost", symbol="f_2", format_spec=".0f", suffix=" €"),
]
# Fail early if a part refers to a symbol the problem does not have.
validate_description_parts(parts, problem)
with Session(engine) as session:
user = session.exec(select(User).where(User.username == "analyst")).one()
problem_db = ProblemDB.from_problem(problem, user)
session.add(problem_db)
session.commit()
session.refresh(problem_db)
metadata = ProblemMetaDataDB(problem_id=problem_db.id, problem=problem_db)
session.add(metadata)
session.commit()
session.refresh(metadata)
session.add(
SolutionDescriptionMetaData(
metadata_id=metadata.id,
parts=[p.model_dump(exclude_none=True) for p in parts],
separator="\n",
)
)
session.commit()
Things to note:
partsis stored as a JSON column, so pass dicts (p.model_dump(...)), notDescriptionPartobjects.- Inserting rows directly skips the validation that the API endpoint does, which
is why the example calls
validate_description_partsitself. Without it, a typo in a symbol only shows up when a solution is viewed. A plainsymbolpart then renders as[unknown symbol: ...], and anexpressionpart makes the request fail.
desdeo/api/db_init_summer_cabin.py
is a complete working example. It defines a longer template with section headings,
cost breakdowns computed by expressions, and expected-value and worst-case
objectives.
Option B: through the web-API
To attach or change the description of a problem that is already in the
database, send a POST request to /solution-description/update_metadata:
{
"problem_id": 3,
"parts": [
{"text": "Investment decisions:"},
{"label": " Battery capacity", "symbol": "E", "format_spec": ".1f", "suffix": " kWh"},
{"label": " Solar panel cost", "expression": ["Multiply", 200, "n"], "format_spec": ".0f", "suffix": " €"}
],
"separator": "\n"
}
The endpoint checks every part against the problem first. If an expression does
not parse, or a part refers to a symbol the problem does not have, it responds
with 422 and a message naming the part. It responds with 404 if the problem
has no ProblemMetaDataDB row yet.
The endpoint adds a new SolutionDescriptionMetaData rather than overwriting
the old one. When a description is generated, the most recently added one is
used, so in effect, posting again replaces the description.
Getting a description
The web-GUI does this for you. To call it yourself, send a POST request to
/solution-description/get with the problem and a reference to a solution
(the state_id of the state that produced it and the solution_index within
that state):
The response is {"available": true, "description": "..."}. If the problem has
no description metadata or the solution cannot be found, it is
{"available": false, "description": ""}.
Derived problems
When CUMULUS creates a new problem from an existing one (e.g. by adding soft constraints or uncertainty measures), the original problem's metadata, including its solution description, is copied to the new problem. So a description written for a base problem also describes the solutions of the problems CUMULUS derives from it.
A template may therefore mention symbols that only the derived problem has, such
as the E_f_1 and robust_f_1 symbols of expected-value and worst-case
objectives. The scenario base problem in db_init_summer_cabin.py does this.
Such a template has to be inserted directly (Option A, without calling
validate_description_parts against the base problem), because
/update_metadata would reject the unknown symbols. Those parts show
[unknown symbol: ...] for solutions of the base problem itself.