Skip to content

Commit 85903fd

Browse files
committed
Improve error messages
1 parent 6c626cc commit 85903fd

File tree

4 files changed

+96
-30
lines changed

4 files changed

+96
-30
lines changed

src/pyoframe/_core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import warnings
66
from abc import abstractmethod
77
from collections.abc import Iterable, Mapping, Sequence
8-
from typing import TYPE_CHECKING, Literal, Union, overload
8+
from typing import TYPE_CHECKING, Literal, Self, Union, overload
99

1010
import pandas as pd
1111
import polars as pl
@@ -693,7 +693,7 @@ def __init__(self, data: pl.DataFrame, name: str | None = None):
693693
super().__init__(data, name=name)
694694

695695
@classmethod
696-
def constant(cls, constant: int | float) -> Expression:
696+
def constant(cls, constant: int | float) -> Self:
697697
"""Creates a new expression equal to the given constant.
698698
699699
Examples:

src/pyoframe/_model.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import time
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any
78

@@ -92,6 +93,7 @@ class Model:
9293
"minimize",
9394
"maximize",
9495
"_logger",
96+
"_last_log",
9597
]
9698

9799
def __init__(
@@ -119,13 +121,16 @@ def __init__(
119121
self._solver_uses_variable_names = solver_uses_variable_names
120122

121123
self._logger = None
124+
self._last_log = None
122125
if verbose:
123126
import logging
124127

125128
self._logger = logging.getLogger("pyoframe")
126129
self._logger.addHandler(logging.NullHandler())
127130
self._logger.setLevel(logging.DEBUG)
128131

132+
self._last_log = time.time()
133+
129134
@property
130135
def poi(self):
131136
"""The underlying PyOptInterface model used to interact with the solver.
@@ -340,18 +345,6 @@ def objective(self) -> Objective:
340345
Raises:
341346
ValueError: If the objective has not been defined.
342347
343-
Examples:
344-
>>> m = pf.Model()
345-
>>> m.X = pf.Variable()
346-
>>> m.objective
347-
Traceback (most recent call last):
348-
...
349-
ValueError: Objective is not defined.
350-
>>> m.maximize = m.X
351-
>>> m.objective
352-
<Objective (linear) terms=1>
353-
X
354-
355348
See Also:
356349
[`Model.has_objective`][pyoframe.Model.has_objective]
357350
"""
@@ -371,9 +364,11 @@ def objective(self, value: Operable):
371364
value._on_add_to_model(self, "objective")
372365

373366
@property
374-
def minimize(self) -> Objective | None:
367+
def minimize(self) -> Objective:
375368
"""Sets or gets the model's objective for minimization problems."""
376-
if self.sense != ObjSense.MIN:
369+
if self._objective is None:
370+
raise ValueError("Objective is not defined.")
371+
if self.sense == ObjSense.MAX:
377372
raise ValueError("Can't get .minimize in a maximization problem.")
378373
return self._objective
379374

@@ -386,9 +381,11 @@ def minimize(self, value: Operable):
386381
self.objective = value
387382

388383
@property
389-
def maximize(self) -> Objective | None:
384+
def maximize(self) -> Objective:
390385
"""Sets or gets the model's objective for maximization problems."""
391-
if self.sense != ObjSense.MAX:
386+
if self._objective is None:
387+
raise ValueError("Objective is not defined.")
388+
if self.sense == ObjSense.MIN:
392389
raise ValueError("Can't get .maximize in a minimization problem.")
393390
return self._objective
394391

@@ -414,30 +411,37 @@ def __setattr__(self, __name: str, __value: Any) -> None:
414411
f"Cannot create {__name} since it was already created."
415412
)
416413

417-
log = self._logger is not None
414+
log = self._logger is not None and isinstance(
415+
__value, (Constraint, Variable)
416+
)
418417

419418
if log:
420-
import time
421-
422419
start_time = time.time()
423420

424421
__value._on_add_to_model(self, __name)
425422

423+
if log:
424+
solver_time = time.time() - start_time # type: ignore
425+
426426
if isinstance(__value, Variable):
427427
self._variables.append(__value)
428428
if self._var_map is not None:
429429
self._var_map.add(__value)
430-
431430
if log:
432-
self._logger.debug(
433-
f"Added Variable '{__name}' (n={len(__value)}) to {self.solver.name} in {time.time() - start_time:.3g} seconds"
434-
)
431+
type_name = "variable"
435432
elif isinstance(__value, Constraint):
436433
self._constraints.append(__value)
437434
if log:
438-
self._logger.debug(
439-
f"Added Constraint '{__name}' (n={len(__value)}) to {self.solver.name} in {time.time() - start_time:.3g} seconds"
440-
)
435+
type_name = "constraint"
436+
437+
if log:
438+
elapsed_time = time.time() - self._last_log # type: ignore
439+
self._last_log += elapsed_time # type: ignore
440+
441+
self._logger.debug( # type: ignore
442+
f"Added {type_name} '{__name}' to model ({elapsed_time:.1f}s elapsed, {solver_time:.1f}s for solver, n={len(__value)})" # type: ignore
443+
)
444+
441445
return super().__setattr__(__name, __value)
442446

443447
# Defining a custom __getattribute__ prevents type checkers from complaining about attribute access

tests/test_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def test_verbose(default_solver, caplog):
9797
m = pf.Model(default_solver, verbose=True)
9898

9999
m.X = pf.Variable(lb=0)
100-
assert "Added Variable 'X'" in caplog.text
100+
assert "Added variable 'X'" in caplog.text
101101

102102
m.constr = m.X >= 5
103-
assert "Added Constraint 'constr'" in caplog.text
103+
assert "Added constraint 'constr'" in caplog.text

tests/test_objective.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for the Objective class."""
22

3+
import re
4+
35
import pytest
46

57
import pyoframe as pf
@@ -20,3 +22,63 @@ def test_get_obj_value(solver):
2022
m.optimize()
2123

2224
assert m.objective.value == pytest.approx(5)
25+
26+
27+
@pytest.mark.parametrize("obj", [5, pf.Expression.constant(10)])
28+
def test_creation(default_solver, obj):
29+
m = pf.Model(solver=default_solver)
30+
31+
# cannot retrieve undefined objective
32+
with pytest.raises(ValueError, match=re.escape("Objective is not defined.")):
33+
m.objective
34+
35+
# Should throw error because missing sense
36+
with pytest.raises(
37+
ValueError,
38+
match=re.escape(
39+
"Can't set an objective without specifying the sense. Did you use .objective instead of .minimize or .maximize ?"
40+
),
41+
):
42+
m.objective = obj
43+
44+
# This should work instead
45+
m = pf.Model(solver=default_solver, sense="min")
46+
m.objective = obj
47+
assert m.sense == pf.ObjSense.MIN
48+
49+
# Cannot redefine objective
50+
with pytest.raises(
51+
ValueError,
52+
match=re.escape("An objective already exists. Use += or -= to modify it."),
53+
):
54+
m.objective = obj + obj
55+
56+
# But adding should work fine
57+
m.objective += obj
58+
m.objective -= obj
59+
60+
# Using minimize/maximize should also work
61+
m = pf.Model(solver=default_solver)
62+
m.minimize = obj
63+
assert m.sense == pf.ObjSense.MIN
64+
65+
# Unless the sense keyword conflicts of course
66+
m = pf.Model(solver=default_solver, sense="max")
67+
with pytest.raises(
68+
ValueError,
69+
match=re.escape("Can't set .minimize in a maximization problem."),
70+
):
71+
m.minimize = obj
72+
73+
# Modifying an unset objective should still be helpful.
74+
m = pf.Model(solver=default_solver)
75+
with pytest.raises(
76+
ValueError,
77+
match=re.escape("Objective is not defined."),
78+
):
79+
m.objective += obj
80+
with pytest.raises(
81+
ValueError,
82+
match=re.escape("Objective is not defined."),
83+
):
84+
m.minimize += obj

0 commit comments

Comments
 (0)