quicopt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quicopt/__init__.py +31 -0
- quicopt/client.py +351 -0
- quicopt/ir.py +154 -0
- quicopt/mathopt.py +166 -0
- quicopt/pyomo.py +193 -0
- quicopt/wire.py +605 -0
- quicopt-0.1.0.dist-info/METADATA +124 -0
- quicopt-0.1.0.dist-info/RECORD +11 -0
- quicopt-0.1.0.dist-info/WHEEL +5 -0
- quicopt-0.1.0.dist-info/licenses/LICENSE +201 -0
- quicopt-0.1.0.dist-info/top_level.txt +1 -0
quicopt/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
"""
|
|
4
|
+
quicopt — the Python client for the Quicopt optimization service.
|
|
5
|
+
|
|
6
|
+
Authors an optimization model in a Python front-end (Pyomo, …) and converts it to
|
|
7
|
+
Quicopt's wire IR — the versioned, language-neutral contract the service consumes.
|
|
8
|
+
This package is a thin front-end; see ``README.md`` for usage.
|
|
9
|
+
|
|
10
|
+
Layers:
|
|
11
|
+
ir — the ``Program`` IR data model (front-end-agnostic)
|
|
12
|
+
wire — ``Program`` → versioned wire bytes (front-end-agnostic)
|
|
13
|
+
pyomo — a Pyomo model → ``Program`` importer (a front-end)
|
|
14
|
+
mathopt — an OR-Tools MathOpt model → ``Program`` importer (a front-end)
|
|
15
|
+
client — POST the wire bytes to the service and read the result (HTTP, stdlib)
|
|
16
|
+
"""
|
|
17
|
+
from .ir import (Const, Param, Var, Apply, Reduce, SetRef,
|
|
18
|
+
Zero, Nonneg, Indicator,
|
|
19
|
+
VarDecl, IndexSet, Constraint, Program,
|
|
20
|
+
Domain, CONTINUOUS, INTEGER, BINARY)
|
|
21
|
+
from .wire import encode, encode_params, SCHEMA_VERSION
|
|
22
|
+
from .client import Client, Job, Result, QuicoptError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Const", "Param", "Var", "Apply", "Reduce", "SetRef",
|
|
26
|
+
"Zero", "Nonneg", "Indicator",
|
|
27
|
+
"VarDecl", "IndexSet", "Constraint", "Program",
|
|
28
|
+
"Domain", "CONTINUOUS", "INTEGER", "BINARY",
|
|
29
|
+
"encode", "encode_params", "SCHEMA_VERSION",
|
|
30
|
+
"Client", "Job", "Result", "QuicoptError",
|
|
31
|
+
]
|
quicopt/client.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
"""
|
|
4
|
+
quicopt.client — talk to the Quicopt service over HTTP.
|
|
5
|
+
|
|
6
|
+
Encode a model to wire bytes (``quicopt.wire``), POST it, read the result back.
|
|
7
|
+
Stdlib-only (``urllib``): the transport adds no dependency, like the rest of the
|
|
8
|
+
core. The request body is the versioned wire bytes; the response is the service's
|
|
9
|
+
result JSON (``status`` / ``objective`` / ``solution`` / a ready-to-print
|
|
10
|
+
``display`` / …). The first keyless call mints an API key, returned in the
|
|
11
|
+
``X-Quicopt-Api-Key`` response header and remembered on the :class:`Client` so
|
|
12
|
+
later calls replay it as ``Authorization: Bearer``.
|
|
13
|
+
|
|
14
|
+
Two entry points mirror the two service endpoints:
|
|
15
|
+
|
|
16
|
+
- :meth:`Client.solve` — POST ``/v1/solve``, block for the result (synchronous).
|
|
17
|
+
- :meth:`Client.submit` — POST ``/v1/jobs``, return a :class:`Job` to poll.
|
|
18
|
+
|
|
19
|
+
A non-2xx response raises :class:`QuicoptError`, carrying the service's stable
|
|
20
|
+
``reason`` code and the framed ``display`` text.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import gzip as _gzip
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Dict, Optional, Union
|
|
31
|
+
from urllib.parse import urlencode
|
|
32
|
+
|
|
33
|
+
from .ir import Program
|
|
34
|
+
from .wire import encode
|
|
35
|
+
|
|
36
|
+
__all__ = ["Client", "Job", "Result", "QuicoptError"]
|
|
37
|
+
|
|
38
|
+
_OCTET = "application/octet-stream"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Result:
|
|
43
|
+
"""A finished solve, parsed from the service's result JSON. ``objective`` and
|
|
44
|
+
``feasible`` are ``None`` when the class or outcome leaves them undefined
|
|
45
|
+
(e.g. an unconstrained heuristic, or no incumbent). ``display`` is the framed,
|
|
46
|
+
ready-to-print summary the service renders for every backend alike."""
|
|
47
|
+
job_id: str
|
|
48
|
+
status: str
|
|
49
|
+
objective: Optional[float]
|
|
50
|
+
feasible: Optional[bool]
|
|
51
|
+
solution: Dict[str, float]
|
|
52
|
+
solve_time_seconds: float
|
|
53
|
+
solver_data: Dict[str, Any]
|
|
54
|
+
display: str
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def model_class(self) -> Optional[str]:
|
|
58
|
+
"""The class the service routed the model to (``LP``/``MILP``/``QUBO``/…).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The ``model_class`` recorded in ``solver_data``, or ``None`` if the
|
|
62
|
+
service did not report one.
|
|
63
|
+
"""
|
|
64
|
+
return self.solver_data.get("model_class")
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _from_json(cls, d: Dict[str, Any]) -> "Result":
|
|
68
|
+
"""Build a ``Result`` from the service's decoded result JSON.
|
|
69
|
+
|
|
70
|
+
Missing optional keys default rather than raise, so a partial result (e.g. a
|
|
71
|
+
heuristic backend that reports no ``objective``) still parses.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
d: The decoded result JSON object.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Result: The parsed result.
|
|
78
|
+
"""
|
|
79
|
+
return cls(
|
|
80
|
+
job_id=d.get("job_id", ""),
|
|
81
|
+
status=d["status"],
|
|
82
|
+
objective=d.get("objective"),
|
|
83
|
+
feasible=d.get("feasible"),
|
|
84
|
+
solution=dict(d.get("solution") or {}),
|
|
85
|
+
solve_time_seconds=d.get("solve_time_seconds", 0.0),
|
|
86
|
+
solver_data=dict(d.get("solver_data") or {}),
|
|
87
|
+
display=d.get("display", ""),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class QuicoptError(Exception):
|
|
92
|
+
"""A non-2xx service response. ``reason`` is the service's stable snake_case
|
|
93
|
+
code (``size_exceeded``, ``unsupported_model``, ``quota_exhausted``, …),
|
|
94
|
+
``display`` the framed message to print, ``status_code`` the HTTP status."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, status_code: int, body: Dict[str, Any]):
|
|
97
|
+
self.status_code = status_code
|
|
98
|
+
self.body = body if isinstance(body, dict) else {"error": str(body)}
|
|
99
|
+
self.reason = self.body.get("reason")
|
|
100
|
+
self.display = self.body.get("display")
|
|
101
|
+
super().__init__(self.body.get("error") or f"HTTP {status_code}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_Model = Any # a front-end model (Pyomo, MathOpt), a Program, or pre-encoded wire bytes
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _import_frontend(model: Any) -> Program:
|
|
108
|
+
"""A modeling-front-end object → ``Program``, via that front-end's importer,
|
|
109
|
+
dispatched by type. The importers are imported lazily so the stdlib-only core
|
|
110
|
+
never pulls in an optional front-end dependency until a model of that kind is
|
|
111
|
+
actually solved."""
|
|
112
|
+
mod = type(model).__module__ or ""
|
|
113
|
+
if mod.startswith("ortools") or hasattr(model, "export_model"):
|
|
114
|
+
from .mathopt import import_model
|
|
115
|
+
return import_model(model)
|
|
116
|
+
if mod.startswith("pyomo"):
|
|
117
|
+
from .pyomo import import_model
|
|
118
|
+
return import_model(model)
|
|
119
|
+
raise TypeError(f"cannot solve a {type(model).__name__}: pass a Pyomo or OR-Tools "
|
|
120
|
+
"MathOpt model, a Program, or wire bytes")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _to_wire(model: _Model) -> bytes:
|
|
124
|
+
"""Coerce to wire bytes: pre-encoded bytes pass through, a ``Program`` is
|
|
125
|
+
encoded, and a front-end model (Pyomo, MathOpt) is imported then encoded — so
|
|
126
|
+
a caller solves the *model*, never a hand-built ``Program``."""
|
|
127
|
+
if isinstance(model, (bytes, bytearray)):
|
|
128
|
+
return bytes(model)
|
|
129
|
+
if isinstance(model, Program):
|
|
130
|
+
return encode(model)
|
|
131
|
+
return encode(_import_frontend(model))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _query(config: Optional[Dict[str, Any]]) -> str:
|
|
135
|
+
"""Render a config mapping as a URL query-string suffix.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
config: Optional service parameters.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
str: ``"?k=v&…"`` if ``config`` is non-empty, else ``""``.
|
|
142
|
+
"""
|
|
143
|
+
return "?" + urlencode(config) if config else ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Client:
|
|
147
|
+
"""A connection to a Quicopt service at ``base_url``. Holds the API key: pass
|
|
148
|
+
a known one, or let the first keyless call mint one (then read it back from
|
|
149
|
+
``client.api_key`` to persist)."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None, *, timeout: float = 60.0):
|
|
152
|
+
"""Bind a client to a service endpoint.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
base_url: The service base URL; a trailing slash is stripped.
|
|
156
|
+
api_key: A known API key, or ``None`` to let the first keyless call mint
|
|
157
|
+
one (afterwards readable back from ``self.api_key``).
|
|
158
|
+
timeout: Per-request socket timeout, in seconds.
|
|
159
|
+
"""
|
|
160
|
+
self.base_url = base_url.rstrip("/")
|
|
161
|
+
self.api_key = api_key
|
|
162
|
+
self.timeout = timeout
|
|
163
|
+
|
|
164
|
+
def solve(self, model: _Model, *, config: Optional[Dict[str, Any]] = None,
|
|
165
|
+
gzip: bool = False) -> Result:
|
|
166
|
+
"""Solve ``model`` synchronously, blocking until the result returns.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
model: A front-end model (Pyomo, OR-Tools MathOpt) — imported to the
|
|
170
|
+
wire IR here — or a ``Program`` / pre-encoded wire bytes if you built
|
|
171
|
+
them yourself.
|
|
172
|
+
config: Optional service parameters, sent as the query string.
|
|
173
|
+
gzip: If ``True``, gzip-compress the request body.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Result: The finished solve.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
QuicoptError: On a non-2xx response.
|
|
180
|
+
"""
|
|
181
|
+
return Result._from_json(
|
|
182
|
+
self._request("POST", "/v1/solve", _to_wire(model), config=config, gzip=gzip))
|
|
183
|
+
|
|
184
|
+
def submit(self, model: _Model, *, config: Optional[Dict[str, Any]] = None,
|
|
185
|
+
gzip: bool = False) -> "Job":
|
|
186
|
+
"""Submit ``model`` for asynchronous solving and return a handle to poll.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
model: A Pyomo/MathOpt model, a ``Program``, or pre-encoded wire bytes.
|
|
190
|
+
config: Optional service parameters, sent as the query string.
|
|
191
|
+
gzip: If ``True``, gzip-compress the request body.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Job: A handle to the queued job; call :meth:`Job.result` to await it.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
QuicoptError: On a non-2xx response.
|
|
198
|
+
"""
|
|
199
|
+
body = self._request("POST", "/v1/jobs", _to_wire(model), config=config, gzip=gzip)
|
|
200
|
+
return Job(self, body["job_id"])
|
|
201
|
+
|
|
202
|
+
# ── transport ───────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
def _open(self, method: str, path: str, data: Optional[bytes] = None, *,
|
|
205
|
+
config: Optional[Dict[str, Any]] = None, gzip: bool = False) -> bytes:
|
|
206
|
+
"""Perform one HTTP request and return the raw response body.
|
|
207
|
+
|
|
208
|
+
Sets the octet-stream content type (and gzip encoding, if requested) for a
|
|
209
|
+
body, attaches the bearer key when known, and captures a freshly minted key
|
|
210
|
+
off the response — including from an ``HTTPError``, since a 502/504 on a
|
|
211
|
+
first call may still have minted one.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
method: The HTTP method.
|
|
215
|
+
path: The path under ``base_url`` (e.g. ``/v1/solve``).
|
|
216
|
+
data: The request body, or ``None`` for a bodyless request.
|
|
217
|
+
config: Optional service parameters, sent as the query string.
|
|
218
|
+
gzip: If ``True``, gzip-compress ``data``.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
bytes: The raw response body.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
QuicoptError: On a non-2xx response.
|
|
225
|
+
"""
|
|
226
|
+
headers: Dict[str, str] = {}
|
|
227
|
+
if data is not None:
|
|
228
|
+
headers["Content-Type"] = _OCTET
|
|
229
|
+
if gzip:
|
|
230
|
+
data = _gzip.compress(data)
|
|
231
|
+
headers["Content-Encoding"] = "gzip"
|
|
232
|
+
if self.api_key:
|
|
233
|
+
headers["Authorization"] = "Bearer " + self.api_key
|
|
234
|
+
req = urllib.request.Request(self.base_url + path + _query(config),
|
|
235
|
+
data=data, headers=headers, method=method)
|
|
236
|
+
try:
|
|
237
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
238
|
+
self._capture_key(resp.headers)
|
|
239
|
+
return resp.read()
|
|
240
|
+
except urllib.error.HTTPError as e:
|
|
241
|
+
self._capture_key(e.headers) # a 504/502 on a first call still minted a key
|
|
242
|
+
raise QuicoptError(e.code, _decode(e.read())) from None
|
|
243
|
+
|
|
244
|
+
def _request(self, method: str, path: str, data: Optional[bytes] = None, **kw) -> Dict[str, Any]:
|
|
245
|
+
"""Perform a request via :meth:`_open` and parse its JSON body.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
method: The HTTP method.
|
|
249
|
+
path: The path under ``base_url``.
|
|
250
|
+
data: The request body, or ``None``.
|
|
251
|
+
**kw: Forwarded to :meth:`_open` (``config``, ``gzip``).
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
dict: The parsed JSON object, or ``{}`` for an empty body.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
QuicoptError: On a non-2xx response.
|
|
258
|
+
"""
|
|
259
|
+
raw = self._open(method, path, data, **kw)
|
|
260
|
+
return json.loads(raw) if raw else {}
|
|
261
|
+
|
|
262
|
+
def _capture_key(self, headers) -> None:
|
|
263
|
+
"""Remember a freshly minted API key from a response, if we have none yet.
|
|
264
|
+
|
|
265
|
+
The first keyless call mints a key, returned in the ``X-Quicopt-Api-Key``
|
|
266
|
+
response header; storing it lets the next call authenticate as the same
|
|
267
|
+
caller. A key we already hold is never overwritten.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
headers: The response headers.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
None. ``self.api_key`` is set on first mint.
|
|
274
|
+
"""
|
|
275
|
+
minted = headers.get("X-Quicopt-Api-Key")
|
|
276
|
+
if minted and not self.api_key:
|
|
277
|
+
self.api_key = minted
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _decode(raw: bytes) -> Dict[str, Any]:
|
|
281
|
+
"""Best-effort decode of an error response body into a dict.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
raw: The raw response body (from a non-2xx response).
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
dict: The parsed JSON if it parses (or ``{}`` when empty), else the body
|
|
288
|
+
wrapped as ``{"error": <text>}`` so a non-JSON error still surfaces.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
return json.loads(raw) if raw else {}
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
return {"error": raw.decode("utf-8", "replace")}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class Job:
|
|
298
|
+
"""A handle to an async job. :meth:`result` polls until it finishes."""
|
|
299
|
+
client: Client
|
|
300
|
+
job_id: str
|
|
301
|
+
|
|
302
|
+
def status(self) -> Dict[str, Any]:
|
|
303
|
+
"""Fetch the job's metadata and framed ``log_tail``.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
dict: The job state (``queued``/``running``/``done``/``failed``) and its
|
|
307
|
+
log tail, as returned by the service.
|
|
308
|
+
"""
|
|
309
|
+
return self.client._request("GET", f"/v1/jobs/{self.job_id}")
|
|
310
|
+
|
|
311
|
+
def result(self, *, wait: bool = True, timeout: float = 120.0, poll: float = 0.5) -> Result:
|
|
312
|
+
"""Fetch the job's result, optionally polling until it is ready.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
wait: If ``True``, poll past the service's ``not_done`` reason until the
|
|
316
|
+
worker finishes; if ``False``, fetch once.
|
|
317
|
+
timeout: Maximum time to poll, in seconds, before giving up.
|
|
318
|
+
poll: Delay between polls, in seconds.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Result: The finished solve.
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
QuicoptError: If ``wait`` is ``False`` and the job is not yet done, on
|
|
325
|
+
any non-``not_done`` error, or once ``timeout`` elapses.
|
|
326
|
+
"""
|
|
327
|
+
deadline = time.monotonic() + timeout
|
|
328
|
+
while True:
|
|
329
|
+
try:
|
|
330
|
+
return Result._from_json(
|
|
331
|
+
self.client._request("GET", f"/v1/jobs/{self.job_id}/result"))
|
|
332
|
+
except QuicoptError as e:
|
|
333
|
+
if not wait or e.reason != "not_done" or time.monotonic() > deadline:
|
|
334
|
+
raise
|
|
335
|
+
time.sleep(poll)
|
|
336
|
+
|
|
337
|
+
def log(self) -> str:
|
|
338
|
+
"""Fetch the job's plain-text log.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
str: The framed log view on success, or the error text on failure.
|
|
342
|
+
"""
|
|
343
|
+
return self.client._open("GET", f"/v1/jobs/{self.job_id}/log").decode("utf-8", "replace")
|
|
344
|
+
|
|
345
|
+
def delete(self) -> None:
|
|
346
|
+
"""Delete the job and its stored blob/result/log on the server.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
None.
|
|
350
|
+
"""
|
|
351
|
+
self.client._open("DELETE", f"/v1/jobs/{self.job_id}")
|
quicopt/ir.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
"""
|
|
4
|
+
quicopt.ir — the structured ``Program`` IR.
|
|
5
|
+
|
|
6
|
+
Front-end-agnostic data: a front-end (``quicopt.pyomo``, ``quicopt.mathopt``) builds a ``Program``;
|
|
7
|
+
``quicopt.wire`` serializes it to the versioned wire bytes. The IR/wire is the
|
|
8
|
+
service's contract — these types track it, they never fork it. Index tuple entries
|
|
9
|
+
are ``int`` (a concrete coordinate) or ``str`` (a bound index symbol); the IR never
|
|
10
|
+
uses a bare string for data, so the mapping is total.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import IntEnum
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Const", "Param", "Var", "Apply", "Reduce", "SetRef",
|
|
19
|
+
"Zero", "Nonneg", "Indicator",
|
|
20
|
+
"VarDecl", "IndexSet", "Constraint", "Program",
|
|
21
|
+
"Domain", "CONTINUOUS", "INTEGER", "BINARY",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Domain(IntEnum):
|
|
26
|
+
"""The domain a variable ranges over. Values are the wire enum codes."""
|
|
27
|
+
CONTINUOUS = 1
|
|
28
|
+
INTEGER = 2
|
|
29
|
+
BINARY = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
CONTINUOUS = Domain.CONTINUOUS
|
|
33
|
+
INTEGER = Domain.INTEGER
|
|
34
|
+
BINARY = Domain.BINARY
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Expression = Const | Param | Var | Apply | Reduce ───────────────────────
|
|
38
|
+
|
|
39
|
+
class Expression:
|
|
40
|
+
"""Base of the expression grammar."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Const(Expression):
|
|
45
|
+
"""A literal numeric constant."""
|
|
46
|
+
value: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Param(Expression):
|
|
51
|
+
"""A reference to a parameter-table entry — data bound at instance time, by
|
|
52
|
+
``name`` and an index tuple (``()`` for a scalar)."""
|
|
53
|
+
name: str
|
|
54
|
+
index: tuple = ()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Var(Expression):
|
|
59
|
+
"""A reference to a decision variable, by ``name`` and an index tuple
|
|
60
|
+
(``()`` for a scalar)."""
|
|
61
|
+
name: str
|
|
62
|
+
index: tuple = ()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Apply(Expression):
|
|
67
|
+
"""A catalog operator ``op`` applied to its argument subexpressions."""
|
|
68
|
+
op: str
|
|
69
|
+
args: list # list[Expression]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class SetRef:
|
|
74
|
+
"""A reference to an index set: ``args=()`` is the flat set; ``args=("i",)``
|
|
75
|
+
references the set indexed by enclosing bound indices."""
|
|
76
|
+
name: str
|
|
77
|
+
args: tuple = ()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Reduce(Expression):
|
|
82
|
+
"""A fold of ``body`` over ``idx`` ranging across ``over`` — e.g. a Σ or Π —
|
|
83
|
+
keeping a term only where ``cond`` is non-zero (``None`` ⇒ keep every term)."""
|
|
84
|
+
op: str # the fold operator key (+, *, …)
|
|
85
|
+
idx: str # the bound dummy index
|
|
86
|
+
over: SetRef
|
|
87
|
+
body: Expression
|
|
88
|
+
cond: "Expression | None" = None # keep a term only where cond ≠ 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── ConSet = Zero | Nonneg | Indicator ──────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Zero:
|
|
95
|
+
"""f = 0"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Nonneg:
|
|
100
|
+
"""f ≥ 0"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Indicator:
|
|
105
|
+
"""``bin`` active (= 1) implies the body satisfies the ``inner`` ConSet."""
|
|
106
|
+
bin: Var # the binary being active implies f ∈ inner
|
|
107
|
+
inner: object # ConSet
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── variables, constraints, container ───────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class VarDecl:
|
|
114
|
+
"""A variable declaration: ``name`` over the product of the index sets in
|
|
115
|
+
``axes`` (``[]`` ⇒ scalar), ranging over ``domain``, with ``lower``/``upper``
|
|
116
|
+
bounds and an initial ``start``. A bound is a float, or the ``str`` name of a
|
|
117
|
+
Param table when it varies by index."""
|
|
118
|
+
name: str
|
|
119
|
+
axes: list # list[str] of set names; [] ⇒ scalar
|
|
120
|
+
domain: Domain
|
|
121
|
+
lower: object # float scalar, or str = name of a Param table
|
|
122
|
+
upper: object
|
|
123
|
+
start: float
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class IndexSet:
|
|
128
|
+
"""A named index set with concrete ``elements`` (each ``int`` or ``str``)."""
|
|
129
|
+
name: str
|
|
130
|
+
elements: list # list[int | str]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Constraint:
|
|
135
|
+
"""A constraint row: the expression ``f`` lies in the ConSet ``set``, for every
|
|
136
|
+
binding in ``over`` (``[(dummy, SetRef)]``; empty ⇒ a single scalar row)."""
|
|
137
|
+
f: Expression
|
|
138
|
+
set: object # ConSet
|
|
139
|
+
over: list = field(default_factory=list) # list[(str, SetRef)] quantifier bindings
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class Program:
|
|
144
|
+
"""A complete optimization model: index sets and data tables, variable
|
|
145
|
+
declarations, the objective and its ``sense``, the constraint rows, and any
|
|
146
|
+
per-index variable pins (``fix``)."""
|
|
147
|
+
sets: list = field(default_factory=list) # list[IndexSet]
|
|
148
|
+
indexed_sets: dict = field(default_factory=dict) # {name: {tuple: list}} a[(i,)] -> [j…]
|
|
149
|
+
params: dict = field(default_factory=dict) # {name: {tuple: float}} p[(i,)], A[(i,j)]
|
|
150
|
+
vars: list = field(default_factory=list) # list[VarDecl]
|
|
151
|
+
objective: Expression = None
|
|
152
|
+
sense: str = "min" # "min" | "max"
|
|
153
|
+
constraints: list = field(default_factory=list) # list[Constraint]
|
|
154
|
+
fix: dict = field(default_factory=dict) # {(varname, tuple): float} per-index pins
|