Skip to content

Misc_weeds

Kiguli edited this page Oct 6, 2026 · 4 revisions

Miscellaneous.py, parsing.py, solve_form.py and mosek_solve.py

Sources: src/Miscellaneous.py · src/parsing.py · src/solve_form.py · src/mosek_solve.py

These modules contain the helpers that the three solvers (LP, QP, SDP) and the web interface depend on:

  • the support-list and complexity computation (get_support and its helpers), which produces the $k$ passed to quantify_risk;
  • solver discovery for the web interface's dropdown;
  • file loading;
  • the input checks shared by the solvers (scenarios, $Q$, the regularization term);
  • the safe expression parser that turns strings such as "delta[0]*delta[2]" into functions of $\delta$;
  • the web solve (solve_form), used by the Flask app and by the MOSEK subprocess entry point.

Contents


Miscellaneous.py

SOLVER_CAPABILITIES

SOLVER_CAPABILITIES is a module-level dict that maps a CVXPY solver name to the Scen-Opt program types it is offered for. It is written by hand and was checked against CVXPY 1.7.2's solver metadata (SOLVER_MAP_CONIC and SOLVER_MAP_QP). The third column shows which cones CVXPY's interface to each solver accepts.

Solver SOLVER_CAPABILITIES CVXPY 1.7.2 interface accepts Notes
CLARABEL LP, QP, SDP linear, SOC, exp, power, PSD, quadratic objective All three program types (tested). The web interface falls back to it when the form sends no solver.
SCS LP, QP, SDP linear, SOC, exp, power, PSD All three program types, but a first-order solver: its accuracy can inflate $k$ (see LP.py).
MOSEK LP, QP, SDP linear, SOC, PSD, exp, power All three program types (needs a licence). The recommended solver.
CVXOPT LP, QP, SDP linear, SOC, PSD All three program types.
ECOS LP, QP linear, SOC, exp No PSD support. QPs are solved through second-order cones.
GUROBI, CPLEX LP, QP linear, SOC, quadratic objective No PSD support.
SCIP LP, QP linear, SOC No PSD support.
SDPA SDP linear, PSD SDP only.
OSQP QP QP solver (linear constraints only) Kept off the LP list: on relaxed scenario LPs its duals are too inaccurate for the support-list screen (see LP.py).
SCIPY LP linear only LPs only.
HIGHS LP, QP linear, plus a QP interface
DAQP, PIQP, PROXQP LP, QP QP solvers
GLOP, GLPK, GLPK_MI LP linear only
CBC LP linear only

Two things affect every row:

  • The regularization term decides the cones. solve_lp, solve_qp and solve_sdp add $\tau\lVert x-\bar x\rVert_p$ only when $\tau > 0$ (see regularization_term). Without it, or with $p = 1$ or inf, an LP needs only linear constraints and a QP only a QP interface. With $p = 2$ or fro the term adds a second-order cone, and any other $p$ a power cone, so the solvers without those cones (the LP and QP solvers above) cannot be used.
  • Unlisted solvers get their types from CVXPY. For an installed solver that is missing from the table, get_solvers calls _cvxpy_capabilities(name), which reads CVXPY's metadata: LP if CVXPY has any conic or QP interface for it, QP if it has a QP interface or second-order cones, and SDP if it accepts PSD cones. For example, ECOS_BB and XPRESS get ['LP', 'QP'], COPT gets ['LP', 'QP', 'SDP'] and PDLP gets ['LP']. A solver for which this gives no type (e.g. KNITRO) is left out of the list.

The web interface narrows the dropdown further in the browser, using the cones that the active tab and regularization norm need (the SOLVER_CONE_SUPPORT map in static/js/main.js). For example, OSQP is hidden on the QP tab, and SCIPY on the LP tab, when the regularization uses $p = 2$. A quadratic cost counts as supported by any solver with second-order cones, so ECOS is offered on the QP tab. On the SDP tab, QP support is only required when $Q$ has a nonzero entry, so SDP-only solvers such as SDPA are offered when $Q = 0$. Solvers missing from the map are never hidden by this check.

get_solvers

get_solvers() -> dict[str, list[str]]

For each name in cvxpy.installed_solvers(), this returns SOLVER_CAPABILITIES[name] if the solver is in the table, and otherwise the types derived from CVXPY's metadata (see above). Solvers with no type are dropped. app.py passes the dict to the page template, which filters the solver dropdown by the active tab (LP, QP or SDP). With only requirements.txt installed, the result is {'CLARABEL': ['LP', 'QP', 'SDP'], 'MOSEK': ['LP', 'QP', 'SDP'], 'OSQP': ['QP'], 'SCIPY': ['LP'], 'SCS': ['LP', 'QP', 'SDP']}.

get_norm_types

get_norm_types() -> list

This returns [1, 2, "inf", "fro"], the common norm types. Neither the solvers nor the web interface call it; only the tests do. The list is not exhaustive: the regularization term is the vector $p$-norm of $x - \bar{x}$ in all three solvers, so any number $p \ge 1$ works (for example 1.5 or 3), and "fro" is the same as $p = 2$.

load_file

load_file(file_path: str) -> numpy.ndarray | None

This reads a numeric data file, for example a scenario matrix, into a NumPy array. The format is chosen from the extension:

Extension Reader Notes
.csv pd.read_csv(file_path, header=None).values The first row is data, not a header.
.txt np.loadtxt(file_path), then np.loadtxt(file_path, delimiter=',') if that fails Values may be separated by whitespace or by commas.
.xlsx pd.read_excel(file_path, engine='openpyxl', header=None).values
.json pd.read_json(file_path).values A list of rows ([[1,2,3],[4,5,6]]), a list of records ([{"a":1,"b":2}, ...]) or pandas' default column format ({"0": {"0": 1, "1": 4}, ...}). Each loads one scenario per row.

Errors. Every exception is caught, including an unsupported extension, a missing file and parse errors. The function prints Error: <message> and returns None, as its docstring says. The web interface does not use load_file; it has its own parse_uploaded_file in app.py, which also reads .tsv, .npy, .npz, .mat, .xls and .parquet.

check_scenarios

check_scenarios(deltas) -> int

Returns the number of scenarios N = len(deltas). If deltas is None or empty, it raises ValueError("The input `deltas` must have at least one row."). All three solvers call it first.

check_psd

check_psd(Q, tol=1e-9) -> numpy.ndarray

Checks that Q is symmetric and positive semidefinite, and returns $\tfrac12 (Q + Q^\top)$. Both checks use the absolute tolerance tol * max(1, max|Q_jk|): symmetry with np.allclose(Q, Q.T, rtol=0, atol=...), and semidefiniteness by requiring the smallest eigenvalue from np.linalg.eigvalsh to be at least minus that tolerance. Rank-deficient matrices such as $D^\top D$, whose zero eigenvalues come out as about $-10^{-16}$, therefore pass, and any such tiny negative eigenvalues are then rounded up to zero, so the returned matrix is exactly positive semidefinite. A failure raises AssertionError ("Q must be symmetric" or "Q must be positive semi-definite (eigenvalues: ...)"). Used by solve_qp and solve_sdp; see QP.py.

regularization_term

regularization_term(tau, x, x_ref, norm_type) -> cvxpy expression or 0

Returns tau * cp.norm(x - x_ref, p), or 0 when tau == 0. An (n, 1) difference is flattened to a vector, so the norm is the vector $p$-norm for every solver, and "fro" is mapped to p = 2. Leaving the term out when $\tau = 0$ keeps a robust or relaxed LP a pure LP: with the default $p = 2$ the norm would add a second-order cone, which LP-only solvers cannot handle. Removing the zero term also made the LP solves more accurate in practice.

get_support

get_support(constraints, non_risk_constraints, prob, objective,
            solver=None, threshold=1e-8, sol_tol=1e-6) -> tuple[int, list, bool]

This function finds a support list of a solved scenario program and returns its size, which is the complexity $k$. A support list is all the scenarios violated at the optimum, plus an irreducible subset of the active ones, chosen so that together they reproduce the optimum. It also returns a flag saying whether the instance might be degenerate. All three solvers call it right after their main solve. It works on any CVXPY problem whose scenario constraints are kept in a list with one constraint object per scenario.

Parameters

Name Type Default Meaning
constraints list of CVXPY constraints required The scenario constraints, one object per scenario. A vector or matrix constraint counts as one scenario.
non_risk_constraints list/tuple of constraints, or a single constraint required Hard constraints. They are always included in re-solves and never counted. A single constraint is wrapped in a list.
prob cvxpy.Problem required The problem, already solved. Only prob.value is read; it becomes the reference optimum. Duals and primal values are read from constraints, so call this straight after prob.solve().
objective cvxpy.Minimize required The same objective object that was used in prob. Every re-solve uses it.
solver str or None None The solver for all re-solves. Use the same one as the main solve, so that optimal values can be compared.
threshold float 1e-8 A constraint is a candidate if $\max \lvert \text{dual} \rvert$ is above this value.
sol_tol float 1e-6 Relative tolerance for "the optimal value was reproduced".

Returns

Name Type Meaning
complexity int len(support).
support list The support list: a sublist of constraints (the same objects, in their original order).
degeneracy bool True when the support list is not certified to be unique, so the lower risk bound is not certified. See below.

Raises ValueError("Error calculating support list! Perhaps try another solver?") if the fallback path ends with a list that fails a final test_support.

Algorithm

ref       = {cost: prob.value, tol: sol_tol}
screened  = [c in constraints with dual_value not None and max|dual_value| > threshold]
spurious  = {c in screened : _is_slack(c)}           # read NOW, before any re-solve overwrites x

if test_support(objective, screened, hard, ref):      # do the screened scenarios reproduce the optimum?
    support   = _prune(objective, screened, hard, ref)
    removed   = screened \ support
    degeneracy = any(c not in spurious for c in removed)
else:                                                  # the screen failed: inaccurate duals or degenerate
    degeneracy = True
    support    = _prune(objective, constraints, hard, ref)
    if not test_support(objective, support, hard, ref):
        raise ValueError(...)
return len(support), support, degeneracy

The steps work as follows:

  • Dual screen. A nonzero dual marks a constraint that is active or violated. Under relaxation, stationarity in $\zeta_i$ makes the duals of a violated scenario sum to $\rho$ (for an LMI, $\operatorname{tr} Z_i = \rho$), so violated scenarios pass the screen too. These screened constraints are the support scenarios.
  • Membership is decided by optimal value, not by $x$. A scenario is of support exactly when removing it changes the optimal value. Removing a violated scenario changes the value by its penalty contribution. Removing an active support constraint changes it through the relaxation that the removal allows. Removing a redundant scenario leaves the value unchanged. Comparing values, rather than solution vectors, stays well defined when the optimizer is not unique.
  • Spurious screens. Interior-point solvers often leave tiny nonzero duals on constraints that are strictly slack. These pass the screen and are then pruned. Such a removal says nothing about degeneracy, so it is ignored when setting the flag.

When is degeneracy raised?

Situation degeneracy Message printed
The screen reproduces the optimum and pruning removes nothing False none
Pruning removes only strictly slack (spurious) constraints False "Discarded … spuriously screened constraint(s) …"
Pruning removes a constraint that was active or violated at the optimum True "PRUNE removed … active/violated support scenario(s) …"
The screen does not reproduce the optimum, so the fallback runs True "Dual-screened candidates did not reproduce the optimum; …"

In the third case the support scenarios are reducible, so more than one support list exists and non-degeneracy fails. In the fallback case the code cannot tell whether the screen failed because of inaccurate duals or because the instance really is degenerate, so it raises the flag anyway, to be safe. Either way, use only the upper risk bound (see Risk.py → Degeneracy).

Printed messages

get_support reports through print (stdout), not through logging or warnings. The three possible messages are:

PRUNE removed {g} active/violated support scenario(s) ({s} screened -> {k} irreducible); flagging degeneracy (lower risk bound not certified).
Discarded {r} spuriously screened constraint(s) (strictly slack, near-threshold duals); remaining support scenarios are irreducible: non-degenerate.
Dual-screened candidates did not reproduce the optimum; recovering the support list from the full constraint set (flagging degeneracy: lower risk bound not certified).

Properties and cost

  • Irreducible, but not necessarily smallest. _prune makes one greedy pass in list order. Removing constraints can never raise a minimum. So once a constraint is found necessary, it stays necessary after later removals, and one pass gives an irreducible list. In a degenerate instance it may still be larger than the smallest support list. For example, when a scenario appears twice, the earlier copy is tested first and removed.

  • Number of re-solves.

    Path Re-solves
    Normal path ($s$ screened scenarios) $1 + s$ small problems
    Fallback path $N + 2$ problems of up to $N - 1$ scenario constraints

    Measured on 100 scenarios with $s = 2$: 3 calls to test_support on the normal path, and 102 when the fallback was forced.

  • No parallelism. The screen, the margin checks and the pruning loop all run in sequence. The pruning loop cannot easily be parallelised anyway, because each removal changes the list that later tests see.

  • Side effects. Each re-solve overwrites the values of the CVXPY variables, and the duals of the constraints that took part in it. After get_support returns, the variables hold the solution of the last re-solve.

test_support

test_support(objective, support, non_risk, ref, solver=None) -> bool

This re-solves cp.Problem(objective, list(support) + list(non_risk)) and returns True exactly when

np.isclose(ref["cost"], prob2.value, rtol=ref["tol"], atol=1e-9)

It returns False in three other cases:

  • the value is None;
  • the status is not optimal or optimal_inaccurate (for example, a reduced problem that becomes unbounded);
  • the solve raises any exception.

In each case the tested constraint is kept, which is the conservative choice. The reduced problem keeps the full objective, so the slack variables $\zeta_j$ of dropped scenarios remain in it. Because $\rho &gt; 0$ and $\zeta_j \ge 0$, they simply go to zero.

Parameter Meaning
objective The objective of the original problem.
support The candidate scenario constraints.
non_risk The hard constraints, always included.
ref A dict with keys "cost" (the reference optimal value) and "tol" (the relative tolerance).
solver The CVXPY solver name, or None.

_is_slack

_is_slack(constraint, atol=1e-6, rtol=1e-6) -> bool      # private

This returns True when constraint is strictly satisfied at the solution currently stored in its variables, meaning it is neither active nor violated. get_support calls it on the screened constraints before any re-solve.

Constraint type Margin Scale
cvxpy.constraints.PSD (made by A << B, so args[0] is B - A) $\lambda_{\min}\big(\tfrac12 (X + X^\top)\big)$ with $X$ = args[0].value $\max \lvert X_{jk} \rvert$
Anything else (in practice an Inequality lhs <= rhs) $\min(\text{rhs} - \text{lhs})$ over all entries $\max(\max\lvert\text{lhs}\rvert, \max\lvert\text{rhs}\rvert)$

The constraint is strictly slack when $\text{margin} &gt; \texttt{atol} + \texttt{rtol}\cdot\text{scale}$. A vector constraint counts as slack only if every row is slack. If the margin cannot be evaluated (any exception), the function returns False, so the constraint is treated as active and the degeneracy test errs towards flagging.

_prune

_prune(objective, candidates, non_risk, ref, solver=None) -> list   # private

This is the greedy reduction. Going through the list in order, it drops a constraint keep[i] whenever test_support(keep[:i] + keep[i+1:]) is True (and then does not advance i). Otherwise it moves on to the next constraint. It makes exactly len(candidates) calls to test_support and returns the irreducible list.


parsing.py

The web interface sends matrices as strings, for example [["delta[0]+1", 0], [0, "delta[1]"]]. The functions below turn these strings into constant NumPy arrays, or into callables of $\delta$ that the solvers can use directly as A_d, b_d and F_d. Expressions are never passed to eval. Each cell is parsed into a Python AST and walked by a restricted interpreter, safe_eval.

Expression syntax

The outer structure is a Python literal read by ast.literal_eval:

  • a matrix is a list of rows, each row a list of cells; a cell is a number or a string;
  • a tensor (for SDPs) is a dict of such matrices keyed by "0", "1", ….

JSON arrays and objects of numbers and strings are valid Python literals. JSON's null, true and false are not, and they raise ValueError: malformed node or string. (The program loader in the browser removes empty optional fields before they reach the parser.)

A number cell is used as it is (converted to float). A string cell is a scalar expression evaluated by safe_eval:

Construct Allowed Notes
Numbers int, float literals (2, 0.5, 1e-3) True/False, complex numbers (1j) and strings are rejected.
Arithmetic + - * / // % ** Python semantics; integer arithmetic stays integer (7//2 gives 3).
Power with ^ delta[0]^2, 2^3^2 As in SymPy, ^ means power. It is replaced by ** before parsing, so it has the precedence of ** (1 + 2^2 is 5, -2^2 is -4, 2^3^2 is 512).
Unary operators +x, -x
Names delta, and the functions and constants below delta evaluates to the δ that was passed in. Any other name (np, x, sec, Pi, __import__) is rejected with disallowed name: ....
Functions sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh, exp, log, ln, log10, log2, sqrt, abs, Abs, floor, ceil, ceiling, min, Min, max, Max SymPy-style names for the math functions (ln is log, Abs is abs, ceiling is ceil) and the built-ins abs, min, max. log(8, 2) takes a base, as math.log does.
Constants pi, e, E $\pi$ and Euler's number.
Indexing delta[i] i is itself an expression, passed through int(). So delta[1+1] works, negative indices work, and delta[1.7] truncates to delta[1]. Slices (delta[0:2]) are rejected. If delta is a scalar (a scenario with one component), delta[0] is that scalar. An index out of range raises ValueError("delta[1] does not exist: the scenario has 1 component(s)").
math attributes math.<name> where <name> does not start with _ Any public attribute of Python's math module: functions (math.sin, math.fabs, …) and constants (math.pi, math.inf, …).
Calls positional arguments only, on something callable The functions above and math functions (sin(delta[0]), math.pow(2, 3)). Keyword arguments are rejected, and so are calls on non-callables (math.pi()). A call with the wrong arguments, e.g. sin(1, 2), raises ValueError("invalid expression ...").
Result must be a number An expression that does not evaluate to a number, such as a bare delta or sin, raises ValueError("expression ... does not evaluate to a number").
Anything else rejected with ValueError lambdas, comparisons, boolean operators, conditional expressions, list literals, comprehensions, np.*, dunder attributes, …

Safety. No builtins are reachable apart from abs, min and max, attributes are reachable only on math, and names starting with _ are blocked. This closes the code-execution path that eval with an open namespace would expose: __import__('os').system('ls') is rejected with disallowed attribute access: system. The evaluator limits what can be written. It does not limit how expensive an expression is to evaluate.

safe_eval

safe_eval(expr: str | number, delta=None) -> number
Parameter Meaning
expr One cell: an expression string, or a plain number (int, float or a NumPy number), which is returned as a float.
delta The vector that delta[i] reads from (a scalar counts as a vector with one component), or None for constant expressions. If delta is None and the expression mentions it, ValueError("delta is not available in this expression") is raised.

It returns a Python int or float, or a NumPy scalar when delta is a NumPy array. Disallowed or malformed syntax, an index out of range, wrong function arguments, math domain errors (sqrt(-1)), division by zero, overflow (exp(1000)) and non-numeric entries all raise ValueError. Matrix strings that cannot be read (e.g. a missing bracket) also raise ValueError.

generate_matrix_function

generate_matrix_function(expr_matrix_str: str) -> Callable[[delta], numpy.ndarray]

The string is parsed once with ast.literal_eval. The returned function matrix_function(delta) evaluates every cell with safe_eval(cell, delta) and returns np.array of the rows. The dtype is float64 if any cell evaluates to a float, otherwise int64. Cells may be numbers or strings, as in [[1, "delta[0]"]]. The web interface uses this function to build A_d and b_d in symbolic mode. Write b_d as a column, for example [["delta[2]"], ["-delta[2]"]]; the solvers reshape $b(\delta)$ to a column in any case.

generate_matrix

generate_matrix(expr_matrix_str: str) -> numpy.ndarray

This is the constant version: every cell is evaluated with safe_eval(cell), with no delta. Numbers, arithmetic, the functions and constants above, and math are allowed ('[["1/2", "-3"]]' gives [[0.5, -3.0]], '[[0], [1]]' gives a float64 column). A matrix of integer strings comes back with int64 dtype. The web interface uses it for G, h, c and Q.

generate_tensor_function

generate_tensor_function(expr_matrix_str: str) -> Callable[[delta], dict[str, numpy.ndarray]]

The input looks like '{"0": [[...]], "1": [[...]], ...}'. The returned function evaluates every cell of every matrix with safe_eval(cell, delta) and returns a dict with the same keys. JSON keys are strings, which is exactly what solve_sdp needs. The web interface uses it to build F_d in symbolic mode.

generate_tensor

generate_tensor(expr_matrix_str: str) -> dict[str, numpy.ndarray]

This is the constant version, used for the hard LMI E. Like generate_matrix, it evaluates every cell with safe_eval(cell), so numbers, numeric strings and constant expressions such as "1/2" or "pi" are accepted, and delta is not. The keys are kept as they are.

generate_numeric_A_b

generate_numeric_A_b(rows_A: int, n_x: int) -> tuple[Callable, Callable]

This is used in numeric mode, where each scenario row already contains the matrices: the row-major flattening of $A_i$ followed by $b_i$, giving rows_A * n_x + rows_A numbers. It returns:

A_d(delta) = delta[:rows_A*n_x].reshape(rows_A, n_x)
b_d(delta) = delta[rows_A*n_x:].reshape(rows_A, 1)

delta must be a NumPy array. The web solve (solve_form) checks the column count before solving. If you call these functions directly, extra columns make b_d's reshape fail.

generate_numeric_F

generate_numeric_F(lmi_size: int, n_x: int) -> Callable[[delta], dict[str, numpy.ndarray]]

This is numeric mode for SDPs. Each row holds the row-major flattenings of $F_0, F_1, \dots, F_{n_x}$, each lmi_size × lmi_size, one after another, for a total length of (n_x + 1) * lmi_size**2. It returns F_d(delta) = {"0": F0, "1": F1, ..., str(n_x): F_nx}. The matrices must be exactly symmetric, because solve_sdp asserts it. Extra trailing columns are ignored (the web solve rejects them earlier).


solve_form.py

solve_form(form: dict, scenarios: numpy.ndarray | None, solver=None) -> dict

solve_form is the web interface's solve: it turns the form fields into a program, solves every $(\tau, \rho)$ pair and builds the result JSON. The Flask app (app.py) and the MOSEK subprocess (mosek_solve.py) both call it, so the two paths compute and report results in exactly the same way. Step by step:

  1. Matrices. In symbolic mode, A_d, b_d and F_d are built with generate_matrix_function and generate_tensor_function. In numeric mode, the column count of the scenario file is checked (rows_A*n_x + rows_A, or (n_x+1)*lmi_size²) and generate_numeric_A_b or generate_numeric_F is used. G, h, c and Q come from generate_matrix, and E from generate_tensor; empty fields give empty arrays.
  2. Confidence. parse_confidence requires $0 &lt; \beta &lt; 1$. A missing or out-of-range value raises ValueError (Enter the confidence parameter β, ... or The confidence parameter β must lie strictly between 0 and 1 (got ...)).
  3. Sweeps and option. rho and tau are comma-separated lists. The selected option decides which apply: for robust and regularization the $\rho$ list is replaced by [0], and for robust and relaxation the $\tau$ list is replaced by [0], so values left in hidden fields are neither applied nor swept. include_slack is True for relaxation and regularization-relaxation, False for the other two options, and None when the form has no option.
  4. Reference point and norm. parse_theta_bar reads $\bar{x}$ (a JSON array or comma-separated numbers; empty means 0). parse_norm reads $p$: empty means 2, inf and fro are kept, and any other value must be a single number $\ge 1$.
  5. Solve loop. For each $\tau$, then each $\rho$, it calls solve_lp, solve_qp or solve_sdp with the form's solver (default CLARABEL) or the solver argument, then replaces the cost by the base cost $c^\top x$ (plus $\tfrac12 x^\top Q x$ for QP/SDP; compute_base_cost), then calls quantify_risk(k, N, beta) with the entered $\beta$. An exception inside one run is stored in that run's errorcode (licence errors are shortened to "MOSEK license cannot be located or is incorrect."), and the other runs continue.
  6. Result. A dict with one list entry per run for optimal_x, optimal_s, optimal_cost, num_deltas, tot_con, active_con, risk, degeneracy, rho_, tau_, errorcode, solve_time and risk_time, plus conf $= 1 - \beta \cdot n_{\text{attempts}}$, n_attempts $= n_\tau n_\rho$ and form_data. tot_con is $N$ plus the number of hard constraints (the rows of $G$, or 1 for a hard LMI), and 0 for a failed run.

Errors. Input errors raised before the loop (an unreadable matrix, a bad $\beta$, $p &lt; 1$, a wrong numeric column count) propagate to the caller. app.py returns a ValueError or TypeError (which covers these) as {"error": "<message>"} with HTTP 400, and the browser shows the message in a red box. This includes malformed matrix strings and entries that cannot be evaluated, such as exp(1000) or 1/0. The /download-mat route stores the None values of failed runs as NaN, so Download .MAT works for sweeps in which some runs failed.

mosek_solve.py

According to the module docstring, MOSEK reads the location of its licence (MOSEKLM_LICENSE_FILE) when it is first imported. A long-running web server process therefore cannot switch licences from one request to the next. So when a user uploads their own licence, app.py runs the solve in a fresh Python process, which sets the variable before importing CVXPY. This lets several users with different licences work at the same time.

How app.py uses it. It takes this path when the /solve request contains a non-empty mosek_license file:

  1. Save the licence as mosek.lic in a new temporary directory, tempfile.mkdtemp(prefix='mosek_<uuid>_'). An empty file is rejected with HTTP 400.
  2. Parse the uploaded scenario file in the parent process.
  3. Run python -m src.mosek_solve <licence path> with the repository root as working directory and a timeout of 7200 s. The input on stdin is the JSON {"form": <form fields>, "scenarios": <list or null>}.
  4. Return the subprocess's JSON. A non-zero exit or invalid JSON gives HTTP 500, and a timeout gives HTTP 504.
  5. Delete the temporary directory in a finally block, so the licence file exists on disk only while the request is running.

What the subprocess does.

  1. Sets the licence environment variable from argv[1], then imports solve_form (and with it CVXPY and MOSEK).
  2. Redirects print output to stderr, so that messages such as those from get_support cannot corrupt the JSON on stdout.
  3. Calls solve_form(form, scenarios, solver="MOSEK") and writes the result JSON. This is the same code as the in-process path, so $\beta$, the reported confidence, the base cost and n_attempts are the same.
  4. Any uncaught exception, including an input error, produces {"error": "<message>"} with exit code 0, which app.py passes on with HTTP 200; the browser shows it in the same red box.

Running it by hand. From the repository root, use echo '<json>' | python -m src.mosek_solve /path/to/mosek.lic, as the module docstring says.


Example

Run from the repository root:

import numpy as np
import cvxpy as cp
from src.Miscellaneous import get_solvers, get_support
from src.parsing import safe_eval, generate_matrix_function, generate_numeric_A_b

print(get_solvers())

# Symbolic strings -> callables of delta
print(safe_eval("delta[0]*delta[2] + math.sin(delta[1])", [2.0, 0.0, 0.5]))
print(safe_eval("sqrt(delta[0]) + delta[1]^2", [4.0, 3.0]))
A_d = generate_matrix_function('[["delta[0]", -1], [1, "delta[1]^2"]]')
print(A_d(np.array([3.0, 2.0])))
try:
    safe_eval("__import__('os').system('ls')")
except ValueError as err:
    print("rejected:", err)

# Numeric mode: each scenario row is [A_i flattened row-wise | b_i]
A_num, b_num = generate_numeric_A_b(rows_A=2, n_x=2)
row = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
print(A_num(row).tolist(), b_num(row).tolist())

# get_support on any solved CVXPY scenario program
rng = np.random.default_rng(0)
deltas = rng.standard_normal((100, 2))
x = cp.Variable(2)
scenario = [deltas[i] @ x <= 1 for i in range(100)]   # one constraint object per scenario
hard = [x >= -10]                                     # always enforced, never counted
objective = cp.Minimize(-cp.sum(x))
prob = cp.Problem(objective, scenario + hard)
prob.solve(solver="CLARABEL")
k, support, degenerate = get_support(scenario, hard, prob, objective, solver="CLARABEL")
idx = [i for i, con in enumerate(scenario) if any(con is s for s in support)]
print("k =", k, " support scenarios =", idx, " degenerate =", degenerate)

Output (CVXPY 1.7.2; the solver list depends on what is installed):

{'CLARABEL': ['LP', 'QP', 'SDP'], 'MOSEK': ['LP', 'QP', 'SDP'], 'OSQP': ['QP'], 'SCIPY': ['LP'], 'SCS': ['LP', 'QP', 'SDP']}
1.0
11.0
[[ 3. -1.]
 [ 1.  4.]]
rejected: disallowed attribute access: system
[[1.0, 2.0], [3.0, 4.0]] [[5.0], [6.0]]
k = 2  support scenarios = [23, 24]  degenerate = False

The last part shows how to use get_support on your own CVXPY model. Keep the scenario constraints in their own list (one object per scenario) and the hard constraints in a second list. Call get_support right after prob.solve(), with the same objective and the same solver. Use is to match the returned constraints with your list.

See also

Using The Tool

Home

Starting Up

Convex Optimization

Graphical Results

Getting into the Weeds

Scenario Theory

Implementation Details

Clone this wiki locally