opt¶
Linear, mixed-integer, and convex quadratic programs built from cells in a sheet and solved through the HiGHS-backed _opt extension. See the Optimization guide for the user-facing commands.
opt
¶
Sheet-level optimization.
Builds a linear, mixed-integer, or convex quadratic program from cells in a
Grid and solves it via the HiGHS-backed _opt extension. The user-facing
model is sheet-resident:
- One objective cell containing a linear formula (e.g.
=3*A1+5*A2). - A list of decision variable cells. They must hold numeric values (or be empty); formula cells are refused so the optimizer doesn't silently overwrite live computations.
- A list of constraint cells, each containing a comparison formula
(e.g.
=A1+A2<=10). Their current evaluated values (True/False) indicate live feasibility; the optimizer reads the underlying AST.
Linearity is enforced by walking gridcalc's formula AST. Cell references that resolve to decision variables become coefficients; everything else is folded into the constant term using the cell's currently evaluated value. This means non-decision cells act as parameters: edit them and re-run.
Supported AST shapes
Number, CellRef, BinOp(+,-,*,/), UnaryOp(+,-), Percent, Call("SUM", RangeRef|expr), parenthesized expressions.
Anything else (Bool, String, ErrorLit, other Call/PyCall, RangeRef outside SUM, Name) raises NotLinear with a message naming the offending node.
OptModel
dataclass
¶
OptModel(
sense: str,
objective: str,
vars: str,
constraints: str,
bounds: str = "",
integers: str = "",
binaries: str = "",
)
A persisted LP model definition stored in the workbook.
The fields hold the string specs the user typed for each component
("A4:A5", "D4:D6", "A1=-inf:10"), not pre-parsed cell coordinates.
This preserves the user's range/list intent verbatim through save/load
round-trips, mirrors how named ranges are stored, and defers cell-ref
resolution (and any errors it would produce) to :opt run time.
SolveResult
dataclass
¶
SolveResult(
status: int,
status_name: str,
objective: float,
values: dict[CellKey, float],
applied: bool,
sensitivity: Sensitivity | None = None,
conflict: list[CellKey] | None = None,
unbounded: list[CellKey] | None = None,
quadratic: bool = False,
)
Sensitivity
dataclass
¶
Sensitivity(
variables: list[VarSensitivity],
constraints: list[ConstraintSensitivity],
)
VarSensitivity
dataclass
¶
VarSensitivity(
cell: CellKey,
value: float,
reduced_cost: float,
obj_coef: float,
obj_from: float,
obj_till: float,
)
Sensitivity of the optimum to one decision variable.
reduced_cost is the amount the objective would change per unit if the
variable were forced away from its bound; it is zero for any variable
already in the basis. obj_from / obj_till bracket the range over
which this variable's objective coefficient can move without changing the
optimal basis (the values themselves would change, the choice of which
variables are non-zero would not).
ConstraintSensitivity
dataclass
¶
ConstraintSensitivity(
cell: CellKey,
shadow_price: float,
rhs: float,
activity: float,
slack: float,
binding: bool,
rhs_from: float,
rhs_till: float,
)
Sensitivity of the optimum to one constraint.
shadow_price is the marginal change in the objective per unit
relaxation of the right-hand side -- the value of one more unit of this
resource, and the number a user actually wants when deciding what to buy
more of. It is valid only within rhs_from..rhs_till; past those
limits the basis changes and the price no longer applies.
SweepPoint
dataclass
¶
SweepPoint(
rhs: float,
status_name: str,
objective: float,
shadow_price: float | None,
delta: float | None,
breakpoint: bool,
)
One re-solve of the model at a substituted right-hand side.
LinearForm
dataclass
¶
A sum of (coefficient * decision_var) terms plus a constant.
coeffs is sparse: missing keys are zero. Two LinearForms can be added,
subtracted, scaled, and negated to compose larger expressions.
QuadForm
dataclass
¶
QuadForm(
quad: dict[tuple[CellKey, CellKey], float] = dict(),
linear: dict[CellKey, float] = dict(),
constant: float = 0.0,
)
A degree-<=2 polynomial over decision variables.
quad is keyed by an ordered pair of cells: (k, k) is a squared
term, (j, k) with j != k is a cross term. Cross terms are tracked
rather than rejected on sight so the error message can name the pair that
made the objective non-separable.
mul
¶
Multiply, refusing anything that would exceed degree 2.
Source code in src/gridcalc/opt.py
squares
¶
Diagonal terms only. Raises if the objective has cross terms.
Source code in src/gridcalc/opt.py
hessian
¶
Dense lower triangle of Q for the 0.5 * x' Q x objective form.
Returned ragged and row-major: row i holds columns 0..i. The
solver takes cross terms directly, so this no longer has to reject
them -- q[(a, b)] * x_a * x_b with a != b contributes q to
both symmetric halves, which in the 0.5 x'Qx convention means
Q[a][b] = Q[b][a] = q. A squared term q * x_a^2 needs
Q[a][a] = 2q for the same reason.
Source code in src/gridcalc/opt.py
OptError
¶
Bases: Exception
Caller-facing error: malformed model, bad cell selection, etc.
NotQuadratic
¶
Bases: NotLinear
An objective is nonlinear in a way this optimizer cannot express.
Deliberately a subclass of :class:NotLinear rather than a sibling. Both
walkers are the safety boundary for the optimizer -- each accepts a closed
whitelist of AST nodes and rejects everything else, so nothing that could
be a sandbox concern (Name, PyCall, attribute-style Call)
reaches an evaluation path. Callers written against that guarantee catch
NotLinear; widening the objective walker to degree 2 must not quietly
slip past those handlers.
solve
¶
solve(
grid: Grid,
objective_cell: CellKey,
decision_vars: list[CellKey],
constraint_cells: list[CellKey],
*,
maximize: bool = True,
bounds: dict[CellKey, tuple[float, float]]
| None = None,
integer_vars: set[CellKey] | None = None,
binary_vars: set[CellKey] | None = None,
apply: bool = True,
sensitivity: bool = False,
diagnose: bool = False,
rhs_override: dict[CellKey, float] | None = None,
) -> SolveResult
Build an LP (or MIP) from the named cells, solve, and (by default) write back.
The objective cell must contain a formula. Decision-variable cells must NOT contain formulas (they get overwritten on success). Each constraint cell must contain a formula whose root is a comparison operator.
integer_vars and binary_vars are subsets of decision_vars;
cells in either set are flagged as integer or binary respectively, which
routes the solve through branch-and-bound. Binary cells have their bounds
clamped to [0,1] regardless of bounds; a cell appearing in both sets
raises OptError.
sensitivity=True additionally returns shadow prices, reduced costs,
and ranging information in SolveResult.sensitivity. It is silently
ignored for MIPs (the field stays None): branch-and-bound duals
describe one LP relaxation rather than the integer problem, so reporting
them would be actively misleading.
rhs_override replaces the right-hand side of the named constraint
cells for this solve only, without touching the sheet. The constraint's
coefficients still come from its formula; only the constant moves. This
is what makes what-if analysis possible without rewriting cells and
recalculating -- see sweep.
diagnose=True explains a failed solve. On INFEASIBLE it populates
SolveResult.conflict with a minimal set of contradictory constraint
cells (one extra solve per constraint); on UNBOUNDED it populates
SolveResult.unbounded with the decision cells that can grow without
limit (two extra solves). Neither runs on a successful solve.
Source code in src/gridcalc/opt.py
764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 | |
sweep
¶
sweep(
grid: Grid,
objective_cell: CellKey,
decision_vars: list[CellKey],
constraint_cells: list[CellKey],
*,
constraint: CellKey,
lo: float,
hi: float,
steps: int = 10,
maximize: bool = True,
bounds: dict[CellKey, tuple[float, float]]
| None = None,
integer_vars: set[CellKey] | None = None,
binary_vars: set[CellKey] | None = None,
) -> list[SweepPoint]
Re-solve the model across a range of right-hand sides for one constraint.
A shadow price answers "what is the next unit worth". It is valid only inside its ranging interval, so it cannot answer "how much more should I buy" -- past the interval edge the basis changes and the marginal value drops. Sweeping re-solves at each point and reports where that happens.
The sheet is never modified: each point substitutes the right-hand side
via solve(rhs_override=...) with apply=False. The constraint's
coefficients still come from its formula; only the constant moves.
steps is the number of intervals, so the result has steps + 1
points spanning lo..hi inclusive. Points where the model becomes
infeasible or unbounded are included with their status rather than
dropped -- discovering that a resource level is unattainable is a real
answer to the question being asked.
Source code in src/gridcalc/opt.py
1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 | |
extract_linear
¶
extract_linear(
node: Node, decision_vars: set[CellKey], grid: Grid
) -> LinearForm
Reduce node to a LinearForm over decision_vars.
Cells in decision_vars contribute coefficients; all other cells are
looked up in grid and folded into the constant term.
Source code in src/gridcalc/opt.py
extract_quadratic
¶
Reduce node to a degree-<=2 form over decision_vars.
The quadratic counterpart of :func:extract_linear, and deliberately a
separate walker: constraints must stay linear, so widening the shared one
would have quietly admitted quadratic constraints the solver cannot take.
Source code in src/gridcalc/opt.py
extract_constraint
¶
extract_constraint(
node: Node, decision_vars: set[CellKey], grid: Grid
) -> tuple[dict[CellKey, float], int, float]
Reduce a comparison-rooted formula to (coeffs, sense, rhs) form.
Both sides are walked as linear forms; variables move to the left and
constants to the right, so the LP sees a single row a^T x OP b.
Source code in src/gridcalc/opt.py
parse_cells
¶
Expand a cell-list spec like A1:B3 or A1,A2,B5 into (col,row)s.
Returns the cells in row-major order within each range and in spec order
across comma-separated parts. Duplicate-detection is the caller's job.
Raises ValueError on a malformed ref.
Source code in src/gridcalc/opt.py
parse_bounds
¶
Parse A1=lo:hi,B2=lo:hi into a bounds dict. Raises ValueError.
Source code in src/gridcalc/opt.py
cells_to_spec
¶
Render a cell list as a comma-separated spec string -- the inverse of
:func:parse_cells.
Inferred models are stored as specs, exactly like typed ones, so they round-trip through the workbook JSON and can be re-run after reopening. A contiguous single-column or single-row run collapses to range syntax so the saved model stays readable.