Skip to content

Commit 0ca52a0

Browse files
authored
feat: expose solve inputs (#160)
1 parent 7860492 commit 0ca52a0

File tree

4 files changed

+70
-43
lines changed

4 files changed

+70
-43
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ requires = ['poetry-core']
1414
build-backend = 'poetry.core.masonry.api'
1515

1616
[tool.poetry]
17-
version = '0.23.4rc4'
17+
version = '0.23.5rc1'
1818
packages = [{include = 'opvious', from = 'src'}]
1919

2020
[tool.poetry.dependencies]

src/opvious/client/handlers.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
4+
import dataclasses
45
import json
56
import logging
67
from typing import Any, BinaryIO
@@ -259,9 +260,7 @@ async def register_specification(
259260
tag_name=tag_names[0] if tag_names else None,
260261
)
261262

262-
async def _prepare_problem(
263-
self, problem: Problem
264-
) -> tuple[Json, ProblemOutline]:
263+
async def _prepare_problem(self, problem: Problem) -> _PreparedProblem:
265264
"""Generates solve problem and final outline."""
266265
# First we fetch the outline to validate/coerce inputs later on
267266
if isinstance(problem.specification, FormulationSpecification):
@@ -315,7 +314,7 @@ async def _prepare_problem(
315314
strategy=solve_strategy_to_json(problem.strategy, outline),
316315
options=solve_options_to_json(problem.options),
317316
)
318-
return (problem, outline)
317+
return _PreparedProblem(problem, outline, inputs)
319318

320319
async def serialize_problem(self, problem: Problem) -> Json:
321320
"""Returns a serialized representation of the problem
@@ -326,21 +325,21 @@ async def serialize_problem(self, problem: Problem) -> Json:
326325
Args:
327326
problem: :class:`.Problem` instance to serialize
328327
"""
329-
problem, _outline = await self._prepare_problem(problem)
330-
return problem
328+
prepared = await self._prepare_problem(problem)
329+
return prepared.data
331330

332331
async def summarize_problem(self, problem: Problem) -> ProblemSummary:
333332
"""Returns summary statistics about a problem without solving it
334333
335334
Args:
336335
problem: :class:`.Problem` instance to summarize
337336
"""
338-
problem, _outline = await self._prepare_problem(problem)
337+
prepared = await self._prepare_problem(problem)
339338
async with self._executor.execute(
340339
result_type=JsonExecutorResult,
341340
url="/summarize-problem",
342341
method="POST",
343-
json_data=json_dict(problem=problem),
342+
json_data=json_dict(problem=prepared.data),
344343
) as res:
345344
return problem_summary_from_json(res.json_data())
346345

@@ -379,12 +378,12 @@ async def format_problem(
379378
380379
.. _LP format: https://web.mit.edu/lpsolve/doc/CPLEX-format.htm
381380
"""
382-
problem, _outline = await self._prepare_problem(problem)
381+
prepared = await self._prepare_problem(problem)
383382
async with self._executor.execute(
384383
result_type=PlainTextExecutorResult,
385384
url="/format-problem",
386385
method="POST",
387-
json_data=json_dict(problem=problem),
386+
json_data=json_dict(problem=prepared.data),
388387
) as res:
389388
lines = []
390389
async for line in res.lines():
@@ -447,15 +446,15 @@ async def solve(
447446
See also :meth:`.Client.queue_solve` for an alternative for
448447
long-running solves.
449448
"""
450-
problem, outline = await self._prepare_problem(problem)
449+
prepared = await self._prepare_problem(problem)
451450
if prefer_streaming and self._executor.supports_streaming:
452451
problem_summary = None
453452
response_json = None
454453
async with self._executor.execute(
455454
result_type=JsonSeqExecutorResult,
456455
url="/solve",
457456
method="POST",
458-
json_data=json_dict(problem=problem),
457+
json_data=json_dict(problem=prepared.data),
459458
) as res:
460459
async for data in res.json_seq_data():
461460
kind = data["kind"]
@@ -498,7 +497,8 @@ async def solve(
498497
if not problem_summary or not response_json:
499498
raise Exception("Streaming solve terminated early")
500499
solution = solution_from_json(
501-
outline=outline,
500+
outline=prepared.outline,
501+
inputs=prepared.inputs,
502502
response_json=response_json,
503503
problem_summary=problem_summary,
504504
)
@@ -507,10 +507,11 @@ async def solve(
507507
result_type=JsonExecutorResult,
508508
url="/solve",
509509
method="POST",
510-
json_data=json_dict(problem=problem),
510+
json_data=json_dict(problem=prepared.data),
511511
) as res:
512512
solution = solution_from_json(
513-
outline=outline,
513+
outline=prepared.outline,
514+
inputs=prepared.inputs,
514515
response_json=res.json_data(),
515516
)
516517

@@ -579,13 +580,13 @@ async def queue_solve(
579580
raise Exception(
580581
"Queued solves must have a formulation as specification"
581582
)
582-
problem, _outline = await self._prepare_problem(problem)
583+
prepared = await self._prepare_problem(problem)
583584
async with self._executor.execute(
584585
result_type=JsonExecutorResult,
585586
url="/queue-solve",
586587
method="POST",
587588
json_data=json_dict(
588-
problem=problem,
589+
problem=prepared.data,
589590
annotations=encode_annotations(annotations or []),
590591
),
591592
) as res:
@@ -849,3 +850,10 @@ async def _next_page() -> list[QueuedSolve]:
849850
for solve in solves:
850851
yield solve
851852
limit -= len(solves)
853+
854+
855+
@dataclasses.dataclass(frozen=True)
856+
class _PreparedProblem:
857+
data: Json
858+
outline: ProblemOutline
859+
inputs: SolveInputs

src/opvious/data/solves.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -177,21 +177,29 @@ class SolveInputs:
177177
problem_outline: ProblemOutline
178178
"""Target model metadata"""
179179

180-
raw_parameters: list[Any]
180+
raw_parameters: list[Json] = dataclasses.field(repr=False)
181181
"""All parameters in raw format"""
182182

183-
raw_dimensions: list[Any] | None
183+
raw_dimensions: list[Json] | None = dataclasses.field(repr=False)
184184
"""All dimensions in raw format"""
185185

186-
def parameter(self, label: Label) -> pd.Series:
187-
"""Returns the parameter for a given label as a pandas Series"""
186+
def parameter(self, label: Label, coerce: bool = True) -> pd.DataFrame:
187+
"""Returns the parameter for a given label as a pandas DataFrame
188+
189+
The returned dataframe has a `value` column with the parameter's values
190+
(0 values may be omitted).
191+
192+
Args:
193+
label: Parameter label to retrieve
194+
coerce: Round integral parameters
195+
"""
188196
for param in self.raw_parameters:
189197
if param["label"] == label:
190-
entries = param["entries"]
191198
outline = self.problem_outline.parameters[label]
192-
return pd.Series(
193-
data=(e["value"] for e in entries),
194-
index=_entry_index(entries, outline.bindings),
199+
return _entries_dataframe(
200+
param["entries"],
201+
outline.bindings,
202+
round_values=coerce and outline.is_integral,
195203
)
196204
raise Exception(f"Unknown parameter: {label}")
197205

@@ -210,10 +218,10 @@ class SolveOutputs:
210218
problem_outline: ProblemOutline
211219
"""Solved model metadata"""
212220

213-
raw_variables: list[Any]
221+
raw_variables: list[Json] = dataclasses.field(repr=False)
214222
"""All variables in raw format"""
215223

216-
raw_constraints: list[Any]
224+
raw_constraints: list[Json] = dataclasses.field(repr=False)
217225
"""All constraints in raw format"""
218226

219227
def variable(self, label: Label, coerce: bool = True) -> pd.DataFrame:
@@ -230,10 +238,9 @@ def variable(self, label: Label, coerce: bool = True) -> pd.DataFrame:
230238
for res in self.raw_variables:
231239
if res["label"] == label:
232240
outline = self.problem_outline.variables[label]
233-
return _output_dataframe(
241+
return _entries_dataframe(
234242
res["entries"],
235243
outline.bindings,
236-
value_name="value",
237244
dual_value_name="reduced_cost",
238245
round_values=coerce and outline.is_integral,
239246
)
@@ -248,7 +255,7 @@ def constraint(self, label: Label) -> pd.DataFrame:
248255
"""
249256
for res in self.raw_constraints:
250257
if res["label"] == label:
251-
return _output_dataframe(
258+
return _entries_dataframe(
252259
res["entries"],
253260
self.problem_outline.constraints[label].bindings,
254261
value_name="slack",
@@ -257,27 +264,34 @@ def constraint(self, label: Label) -> pd.DataFrame:
257264
raise Exception(f"Unknown constraint {label}")
258265

259266

260-
def _output_dataframe(
267+
def _entries_dataframe(
261268
entries: Sequence[Json],
262269
bindings: Sequence[SourceBinding],
263270
*,
264-
value_name: str,
265-
dual_value_name: str,
271+
value_name: str = "value",
272+
dual_value_name: str | None = None,
266273
round_values: bool = False,
267274
) -> pd.DataFrame:
268-
df = pd.DataFrame(
269-
data=(
275+
if dual_value_name:
276+
data = (
270277
(decode_extended_float(e["value"]), e.get("dualValue"))
271278
for e in entries
272-
),
273-
columns=[value_name, dual_value_name],
279+
)
280+
columns = [value_name, dual_value_name]
281+
else:
282+
data = (decode_extended_float(e["value"]) for e in entries)
283+
columns = [value_name]
284+
df = pd.DataFrame(
285+
data=data,
286+
columns=columns,
274287
index=_entry_index(entries, bindings),
275288
)
276-
if df[dual_value_name].isnull().all():
289+
if dual_value_name and df[dual_value_name].isnull().all():
277290
df.drop(dual_value_name, axis=1, inplace=True)
278291
if round_values:
279292
df[value_name] = df[value_name].round(0).astype(np.int64)
280293
df.fillna(0, inplace=True)
294+
df.sort_index(inplace=True)
281295
return df
282296

283297

@@ -302,6 +316,9 @@ class Solution:
302316
problem_summary: ProblemSummary
303317
"""Problem summary statistics"""
304318

319+
inputs: SolveInputs = dataclasses.field(repr=False)
320+
"""Problem inputs"""
321+
305322
outputs: SolveOutputs | None = dataclasses.field(default=None, repr=False)
306323
"""Solution data, present iff the solution is feasible"""
307324

@@ -313,6 +330,7 @@ def feasible(self) -> bool:
313330

314331
def solution_from_json(
315332
outline: ProblemOutline,
333+
inputs: SolveInputs,
316334
response_json: Any,
317335
problem_summary: ProblemSummary | None = None,
318336
) -> Solution:
@@ -341,6 +359,7 @@ def solution_from_json(
341359
outcome=outcome,
342360
problem_summary=problem_summary
343361
or problem_summary_from_json(response_json["summaries"]["problem"]),
362+
inputs=inputs,
344363
outputs=outputs,
345364
)
346365

@@ -452,9 +471,7 @@ class SolveStrategy:
452471
@classmethod
453472
def equally_weighted_sum(cls, sense: ObjectiveSense | None = None) -> Self:
454473
"""Returns a strategy optimizing the sum of all objectives"""
455-
return cls(
456-
target=collections.defaultdict(lambda: 1), sense=sense
457-
)
474+
return cls(target=collections.defaultdict(lambda: 1), sense=sense)
458475

459476

460477
def solve_strategy_to_json(

tests/client_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async def test_queue_diet_solve(self):
7979
assert outcome.objective_value == 33
8080

8181
input_data = await client.fetch_solve_inputs(uuid)
82-
costs = input_data.parameter("costPerRecipe")
82+
costs = input_data.parameter("costPerRecipe")["value"]
8383
assert costs.to_dict() == {
8484
"lasagna": 12,
8585
"pizza": 15,
@@ -171,6 +171,8 @@ async def test_solve_bounded_feasible(self):
171171
assert solution.feasible
172172
assert solution.outcome.optimal
173173
assert solution.outcome.objective_value == 2
174+
assert len(solution.inputs.parameter("bound")) == 1
175+
assert len(solution.outputs.variable("target")) == 1
174176

175177
@pytest.mark.asyncio
176178
async def test_solve_bounded_infeasible(self):

0 commit comments

Comments
 (0)