desdeo 1.2__py3-none-any.whl → 2.0.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.
- desdeo/__init__.py +8 -8
- desdeo/api/README.md +73 -0
- desdeo/api/__init__.py +15 -0
- desdeo/api/app.py +40 -0
- desdeo/api/config.py +69 -0
- desdeo/api/config.toml +53 -0
- desdeo/api/db.py +25 -0
- desdeo/api/db_init.py +79 -0
- desdeo/api/db_models.py +164 -0
- desdeo/api/malaga_db_init.py +27 -0
- desdeo/api/models/__init__.py +66 -0
- desdeo/api/models/archive.py +34 -0
- desdeo/api/models/preference.py +90 -0
- desdeo/api/models/problem.py +507 -0
- desdeo/api/models/reference_point_method.py +18 -0
- desdeo/api/models/session.py +46 -0
- desdeo/api/models/state.py +96 -0
- desdeo/api/models/user.py +51 -0
- desdeo/api/routers/_NAUTILUS.py +245 -0
- desdeo/api/routers/_NAUTILUS_navigator.py +233 -0
- desdeo/api/routers/_NIMBUS.py +762 -0
- desdeo/api/routers/__init__.py +5 -0
- desdeo/api/routers/problem.py +110 -0
- desdeo/api/routers/reference_point_method.py +117 -0
- desdeo/api/routers/session.py +76 -0
- desdeo/api/routers/test.py +16 -0
- desdeo/api/routers/user_authentication.py +366 -0
- desdeo/api/schema.py +94 -0
- desdeo/api/tests/__init__.py +0 -0
- desdeo/api/tests/conftest.py +59 -0
- desdeo/api/tests/test_models.py +701 -0
- desdeo/api/tests/test_routes.py +216 -0
- desdeo/api/utils/database.py +274 -0
- desdeo/api/utils/logger.py +29 -0
- desdeo/core.py +27 -0
- desdeo/emo/__init__.py +29 -0
- desdeo/emo/hooks/archivers.py +172 -0
- desdeo/emo/methods/EAs.py +418 -0
- desdeo/emo/methods/__init__.py +0 -0
- desdeo/emo/methods/bases.py +59 -0
- desdeo/emo/operators/__init__.py +1 -0
- desdeo/emo/operators/crossover.py +780 -0
- desdeo/emo/operators/evaluator.py +118 -0
- desdeo/emo/operators/generator.py +356 -0
- desdeo/emo/operators/mutation.py +1053 -0
- desdeo/emo/operators/selection.py +1036 -0
- desdeo/emo/operators/termination.py +178 -0
- desdeo/explanations/__init__.py +6 -0
- desdeo/explanations/explainer.py +100 -0
- desdeo/explanations/utils.py +90 -0
- desdeo/mcdm/__init__.py +19 -0
- desdeo/mcdm/nautili.py +345 -0
- desdeo/mcdm/nautilus.py +477 -0
- desdeo/mcdm/nautilus_navigator.py +655 -0
- desdeo/mcdm/nimbus.py +417 -0
- desdeo/mcdm/pareto_navigator.py +269 -0
- desdeo/mcdm/reference_point_method.py +116 -0
- desdeo/problem/__init__.py +79 -0
- desdeo/problem/evaluator.py +561 -0
- desdeo/problem/gurobipy_evaluator.py +562 -0
- desdeo/problem/infix_parser.py +341 -0
- desdeo/problem/json_parser.py +944 -0
- desdeo/problem/pyomo_evaluator.py +468 -0
- desdeo/problem/schema.py +1808 -0
- desdeo/problem/simulator_evaluator.py +298 -0
- desdeo/problem/sympy_evaluator.py +244 -0
- desdeo/problem/testproblems/__init__.py +73 -0
- desdeo/problem/testproblems/binh_and_korn_problem.py +88 -0
- desdeo/problem/testproblems/dtlz2_problem.py +102 -0
- desdeo/problem/testproblems/forest_problem.py +275 -0
- desdeo/problem/testproblems/knapsack_problem.py +163 -0
- desdeo/problem/testproblems/mcwb_problem.py +831 -0
- desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py +83 -0
- desdeo/problem/testproblems/momip_problem.py +172 -0
- desdeo/problem/testproblems/nimbus_problem.py +143 -0
- desdeo/problem/testproblems/pareto_navigator_problem.py +89 -0
- desdeo/problem/testproblems/re_problem.py +492 -0
- desdeo/problem/testproblems/river_pollution_problem.py +434 -0
- desdeo/problem/testproblems/rocket_injector_design_problem.py +140 -0
- desdeo/problem/testproblems/simple_problem.py +351 -0
- desdeo/problem/testproblems/simulator_problem.py +92 -0
- desdeo/problem/testproblems/spanish_sustainability_problem.py +945 -0
- desdeo/problem/testproblems/zdt_problem.py +271 -0
- desdeo/problem/utils.py +245 -0
- desdeo/tools/GenerateReferencePoints.py +181 -0
- desdeo/tools/__init__.py +102 -0
- desdeo/tools/generics.py +145 -0
- desdeo/tools/gurobipy_solver_interfaces.py +258 -0
- desdeo/tools/indicators_binary.py +11 -0
- desdeo/tools/indicators_unary.py +375 -0
- desdeo/tools/interaction_schema.py +38 -0
- desdeo/tools/intersection.py +54 -0
- desdeo/tools/iterative_pareto_representer.py +99 -0
- desdeo/tools/message.py +234 -0
- desdeo/tools/ng_solver_interfaces.py +199 -0
- desdeo/tools/non_dominated_sorting.py +133 -0
- desdeo/tools/patterns.py +281 -0
- desdeo/tools/proximal_solver.py +99 -0
- desdeo/tools/pyomo_solver_interfaces.py +464 -0
- desdeo/tools/reference_vectors.py +462 -0
- desdeo/tools/scalarization.py +3138 -0
- desdeo/tools/scipy_solver_interfaces.py +454 -0
- desdeo/tools/score_bands.py +464 -0
- desdeo/tools/utils.py +320 -0
- desdeo/utopia_stuff/__init__.py +0 -0
- desdeo/utopia_stuff/data/1.json +15 -0
- desdeo/utopia_stuff/data/2.json +13 -0
- desdeo/utopia_stuff/data/3.json +15 -0
- desdeo/utopia_stuff/data/4.json +17 -0
- desdeo/utopia_stuff/data/5.json +15 -0
- desdeo/utopia_stuff/from_json.py +40 -0
- desdeo/utopia_stuff/reinit_user.py +38 -0
- desdeo/utopia_stuff/utopia_db_init.py +212 -0
- desdeo/utopia_stuff/utopia_problem.py +403 -0
- desdeo/utopia_stuff/utopia_problem_old.py +415 -0
- desdeo/utopia_stuff/utopia_reference_solutions.py +79 -0
- desdeo-2.0.0.dist-info/LICENSE +21 -0
- desdeo-2.0.0.dist-info/METADATA +168 -0
- desdeo-2.0.0.dist-info/RECORD +120 -0
- {desdeo-1.2.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
- desdeo-1.2.dist-info/METADATA +0 -16
- desdeo-1.2.dist-info/RECORD +0 -4
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Defines models for representing the state of various interactive methods."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.types import TypeDecorator
|
|
6
|
+
from sqlmodel import JSON, Column, Field, Relationship, SQLModel
|
|
7
|
+
|
|
8
|
+
from desdeo.tools import SolverResults
|
|
9
|
+
|
|
10
|
+
from .preference import PreferenceDB, ReferencePoint
|
|
11
|
+
from .problem import ProblemDB
|
|
12
|
+
from .session import InteractiveSessionDB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StateType(TypeDecorator):
|
|
16
|
+
"""SQLAlchemy custom type to convert states to JSON and back."""
|
|
17
|
+
|
|
18
|
+
impl = JSON
|
|
19
|
+
|
|
20
|
+
def process_bind_param(self, value, dialect):
|
|
21
|
+
"""State to JSON."""
|
|
22
|
+
if isinstance(value, RPMState):
|
|
23
|
+
return value.model_dump()
|
|
24
|
+
|
|
25
|
+
msg = f"No JSON serialization set for ste of type '{type(value)}'."
|
|
26
|
+
print(msg)
|
|
27
|
+
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
def process_result_value(self, value, dialect):
|
|
31
|
+
"""JSON to state."""
|
|
32
|
+
if "method" in value:
|
|
33
|
+
match value["method"]:
|
|
34
|
+
case "reference_point_method":
|
|
35
|
+
return RPMState.model_validate(value)
|
|
36
|
+
case _:
|
|
37
|
+
msg = f"No method '{value["method"]}' found."
|
|
38
|
+
print(msg)
|
|
39
|
+
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BaseState(SQLModel):
|
|
44
|
+
"""The base model for representing method state."""
|
|
45
|
+
|
|
46
|
+
method: Literal["unset"] = "unset"
|
|
47
|
+
phase: Literal["unset"] = "unset"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RPMBaseState(BaseState):
|
|
51
|
+
"""The base sate for the reference point method (RPM).
|
|
52
|
+
|
|
53
|
+
Other states of the RPM should inherit from this.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
method: Literal["reference_point_method"] = "reference_point_method"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RPMState(RPMBaseState):
|
|
60
|
+
"""State of the reference point method for computing solutions."""
|
|
61
|
+
|
|
62
|
+
phase: Literal["solve_candidates"] = "solve_candidates"
|
|
63
|
+
|
|
64
|
+
# to compute k+1 solutions
|
|
65
|
+
scalarization_options: dict[str, float | str | bool] | None = Field(sa_column=Column(JSON), default=None)
|
|
66
|
+
solver: str | None = Field(default=None)
|
|
67
|
+
solver_options: dict[str, float | str | bool] | None = Field(sa_column=Column(JSON), default=None)
|
|
68
|
+
|
|
69
|
+
# results
|
|
70
|
+
solver_results: list[SolverResults] = Field(sa_column=Column(JSON))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StateDB(SQLModel, table=True):
|
|
74
|
+
"""Database model to store interactive method state."""
|
|
75
|
+
|
|
76
|
+
id: int | None = Field(primary_key=True, default=None)
|
|
77
|
+
problem_id: int | None = Field(foreign_key="problemdb.id", default=None)
|
|
78
|
+
preference_id: int | None = Field(foreign_key="preferencedb.id", default=None)
|
|
79
|
+
session_id: int | None = Field(foreign_key="interactivesessiondb.id", default=None)
|
|
80
|
+
|
|
81
|
+
# Reference to other StateDB
|
|
82
|
+
parent_id: int | None = Field(foreign_key="statedb.id", default=None)
|
|
83
|
+
|
|
84
|
+
state: BaseState | None = Field(sa_column=Column(StateType), default=None)
|
|
85
|
+
|
|
86
|
+
# Back populates
|
|
87
|
+
session: "InteractiveSessionDB" = Relationship(back_populates="states")
|
|
88
|
+
parent: "StateDB" = Relationship(back_populates="children", sa_relationship_kwargs={"remote_side": "StateDB.id"})
|
|
89
|
+
# if a parent node is killed, so are all its children (blood for the blood God)
|
|
90
|
+
children: list["StateDB"] = Relationship(
|
|
91
|
+
back_populates="parent", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Parents
|
|
95
|
+
preference: "PreferenceDB" = Relationship()
|
|
96
|
+
problem: "ProblemDB" = Relationship()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Defines user models."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from sqlmodel import Field, Relationship, SQLModel
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .archive import ArchiveEntryDB
|
|
10
|
+
from .preference import PreferenceDB
|
|
11
|
+
from .schemas import ProblemDB
|
|
12
|
+
from .session import InteractiveSessionDB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UserRole(str, Enum):
|
|
16
|
+
"""Possible user roles."""
|
|
17
|
+
|
|
18
|
+
guest = "guest"
|
|
19
|
+
dm = "dm"
|
|
20
|
+
analyst = "analyst"
|
|
21
|
+
admin = "admin"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserBase(SQLModel):
|
|
25
|
+
"""Base user object."""
|
|
26
|
+
|
|
27
|
+
username: str = Field(index=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class User(UserBase, table=True):
|
|
31
|
+
"""The table model of the user stored in the database."""
|
|
32
|
+
|
|
33
|
+
id: int | None = Field(primary_key=True, default=None)
|
|
34
|
+
password_hash: str = Field()
|
|
35
|
+
role: UserRole = Field()
|
|
36
|
+
group: str = Field(default="")
|
|
37
|
+
active_session_id: int | None = Field(default=None)
|
|
38
|
+
|
|
39
|
+
# Back populates
|
|
40
|
+
archive: list["ArchiveEntryDB"] = Relationship(back_populates="user")
|
|
41
|
+
preferences: list["PreferenceDB"] = Relationship(back_populates="user")
|
|
42
|
+
problems: list["ProblemDB"] = Relationship(back_populates="user")
|
|
43
|
+
sessions: list["InteractiveSessionDB"] = Relationship(back_populates="user")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UserPublic(UserBase):
|
|
47
|
+
"""The object to handle public user information."""
|
|
48
|
+
|
|
49
|
+
id: int
|
|
50
|
+
role: UserRole
|
|
51
|
+
group: str
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Endpoints for NAUTILUS ."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from desdeo.api.db import get_db
|
|
10
|
+
from desdeo.api.db_models import Problem as ProblemInDB
|
|
11
|
+
from desdeo.api.db_models import Results
|
|
12
|
+
from desdeo.api.routers.user_authentication import get_current_user
|
|
13
|
+
from desdeo.api.schema import User
|
|
14
|
+
from desdeo.mcdm.nautilus import (
|
|
15
|
+
NAUTILUS_Response,
|
|
16
|
+
get_current_path,
|
|
17
|
+
nautilus_init,
|
|
18
|
+
nautilus_step,
|
|
19
|
+
points_to_weights,
|
|
20
|
+
ranks_to_weights,
|
|
21
|
+
step_back_index,
|
|
22
|
+
)
|
|
23
|
+
from desdeo.problem.schema import Problem
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/nautilus")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InitRequest(BaseModel):
|
|
29
|
+
"""The request to initialize the NAUTILUS."""
|
|
30
|
+
|
|
31
|
+
problem_id: int = Field(description="The ID of the problem to navigate.")
|
|
32
|
+
# TODO: IS total_steps needed for NAUTILUS, what is good default? now its 5.
|
|
33
|
+
total_steps: int | None = Field(
|
|
34
|
+
description=("The total number of steps in the NAUTILUS. The default value is 5."), default=5
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NavigateRequest(BaseModel):
|
|
39
|
+
"""The request to navigate the NAUTILUS."""
|
|
40
|
+
|
|
41
|
+
problem_id: int = Field(description="The ID of the problem to navigate.")
|
|
42
|
+
points: dict[str, float] | None = Field(
|
|
43
|
+
description=(
|
|
44
|
+
"Preference in the form of points given to the objectives."
|
|
45
|
+
" Higher is better. Must sum up to 100. Only one of points or ranks can be given."
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
ranks: dict[str, int] | None = Field(
|
|
49
|
+
description=(
|
|
50
|
+
"Preference in the form of ranks given to the objectives. Higher is better."
|
|
51
|
+
"Must be integers between 1 and the number of objectives. Ranks need not be unique, consecutive."
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
calculate_step: int = Field(description="The step index to calculate. Starts from 1. Max = total_steps.")
|
|
55
|
+
steps_remaining: int = Field(
|
|
56
|
+
description="The number of steps remaining. Should be total_steps - calculate_step + 1."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InitialResponse(BaseModel):
|
|
61
|
+
"""The response from the initial endpoint of NAUTILUS."""
|
|
62
|
+
|
|
63
|
+
objective_symbols: list[str] = Field(description="The symbols/short names of the objectives.")
|
|
64
|
+
objective_long_names: list[str] = Field(description="Long/descriptive names of the objectives.")
|
|
65
|
+
units: list[str] | None = Field(description="The units of the objectives.")
|
|
66
|
+
is_maximized: list[bool] = Field(description="Whether the objectives are to be maximized or minimized.")
|
|
67
|
+
ideal: list[float] = Field(description="The ideal values of the objectives.")
|
|
68
|
+
nadir: list[float] = Field(description="The nadir values of the objectives.")
|
|
69
|
+
total_steps: int = Field(description="The total number of steps in the NAUTILUS Navigator.")
|
|
70
|
+
distance_to_front: float | None = Field(description="The distance to the front of the reachable region.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Response(InitialResponse):
|
|
74
|
+
"""The response from most NAUTILUS endpoints.
|
|
75
|
+
|
|
76
|
+
Contains information about the full navigation process.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
lower_bounds: dict[str, list[float]] = Field(description="The lower bounds of the reachable region.")
|
|
80
|
+
upper_bounds: dict[str, list[float]] = Field(description="The upper bounds of the reachable region.")
|
|
81
|
+
preferences: dict[str, list[float]] = Field(description="The preferences used in each step.")
|
|
82
|
+
|
|
83
|
+
# TODO: ALL ABOVE SHOULD BE FINE
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.post("/initialize")
|
|
87
|
+
def init_nautilus(
|
|
88
|
+
init_request: InitRequest,
|
|
89
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
90
|
+
db: Annotated[Session, Depends(get_db)],
|
|
91
|
+
) -> InitialResponse:
|
|
92
|
+
"""Initialize the NAUTILUS.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
init_request (InitRequest): The request to initialize the NAUTILUS.
|
|
96
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
97
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
InitialResponse: The initial response from the NAUTILUS.
|
|
101
|
+
"""
|
|
102
|
+
problem_id = init_request.problem_id
|
|
103
|
+
problem = db.query(ProblemInDB).filter(ProblemInDB.id == problem_id).first()
|
|
104
|
+
|
|
105
|
+
if problem is None:
|
|
106
|
+
raise HTTPException(status_code=404, detail="Problem not found.")
|
|
107
|
+
if problem.owner != user.index and problem.owner is not None:
|
|
108
|
+
raise HTTPException(status_code=403, detail="Unauthorized to access chosen problem.")
|
|
109
|
+
try:
|
|
110
|
+
problem = Problem.model_validate(problem.value)
|
|
111
|
+
except ValidationError:
|
|
112
|
+
raise HTTPException(status_code=500, detail="Error in parsing the problem.") from ValidationError
|
|
113
|
+
|
|
114
|
+
response = nautilus_init(problem)
|
|
115
|
+
|
|
116
|
+
# Get and delete all Results from previous runs of NAUTILUS
|
|
117
|
+
results = db.query(Results).filter(Results.problem == problem_id).filter(Results.user == user.index).all()
|
|
118
|
+
for result in results:
|
|
119
|
+
db.delete(result)
|
|
120
|
+
db.commit()
|
|
121
|
+
|
|
122
|
+
new_result = Results(
|
|
123
|
+
user=user.index,
|
|
124
|
+
problem=problem_id,
|
|
125
|
+
value=response.model_dump(mode="json"),
|
|
126
|
+
)
|
|
127
|
+
db.add(new_result)
|
|
128
|
+
db.commit()
|
|
129
|
+
|
|
130
|
+
return InitialResponse(
|
|
131
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
132
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
133
|
+
units=[obj.unit for obj in problem.objectives],
|
|
134
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
135
|
+
ideal=[obj.ideal for obj in problem.objectives],
|
|
136
|
+
nadir=[obj.nadir for obj in problem.objectives],
|
|
137
|
+
total_steps=init_request.total_steps,
|
|
138
|
+
distance_to_front=response.distance_to_front,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.post("/iterate")
|
|
143
|
+
def iterate(
|
|
144
|
+
request: NavigateRequest,
|
|
145
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
146
|
+
db: Annotated[Session, Depends(get_db)],
|
|
147
|
+
) -> Response:
|
|
148
|
+
"""Navigate the NAUTILUS.
|
|
149
|
+
|
|
150
|
+
Runs the NAUTILUS algorithm one step at a time.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
request (NavigateRequest): The request to navigate the NAUTILUS 1.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
HTTPException: _description_
|
|
157
|
+
HTTPException: _description_
|
|
158
|
+
HTTPException: _description_
|
|
159
|
+
HTTPException: _description_
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Response: _description_
|
|
163
|
+
"""
|
|
164
|
+
problem_id, ranks, points, calculate_step, steps_remaining = (
|
|
165
|
+
request.problem_id,
|
|
166
|
+
request.ranks,
|
|
167
|
+
request.points,
|
|
168
|
+
request.calculate_step,
|
|
169
|
+
request.steps_remaining,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if ranks is not None and points is not None:
|
|
173
|
+
raise HTTPException(status_code=400, detail="Both ranks and points cannot be given.")
|
|
174
|
+
if ranks is None and points is None:
|
|
175
|
+
raise HTTPException(status_code=400, detail="Either ranks or points must be given.")
|
|
176
|
+
|
|
177
|
+
problem = db.query(ProblemInDB).filter(ProblemInDB.id == problem_id).first()
|
|
178
|
+
if problem is None:
|
|
179
|
+
raise HTTPException(status_code=404, detail="Problem not found.")
|
|
180
|
+
if problem.owner != user.index and problem.owner is not None:
|
|
181
|
+
raise HTTPException(status_code=403, detail="Unauthorized to access chosen problem.")
|
|
182
|
+
try:
|
|
183
|
+
problem = Problem.model_validate(problem.value)
|
|
184
|
+
except ValidationError:
|
|
185
|
+
raise HTTPException(status_code=500, detail="Error in parsing the problem.") from ValidationError
|
|
186
|
+
|
|
187
|
+
results = db.query(Results).filter(Results.problem == problem_id).filter(Results.user == user.index).all()
|
|
188
|
+
if not results:
|
|
189
|
+
raise HTTPException(status_code=404, detail="NAUTILUS 1 not initialized.")
|
|
190
|
+
|
|
191
|
+
responses = [NAUTILUS_Response.model_validate(result.value) for result in results]
|
|
192
|
+
|
|
193
|
+
step_to_append_index = step_back_index(responses, calculate_step - 1)
|
|
194
|
+
|
|
195
|
+
if step_to_append_index < len(responses) - 1:
|
|
196
|
+
responses.append(responses[step_back_index(responses, calculate_step - 1)])
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
new_response = nautilus_step(
|
|
200
|
+
problem,
|
|
201
|
+
step_number=calculate_step,
|
|
202
|
+
steps_remaining=steps_remaining,
|
|
203
|
+
nav_point=responses[-1].navigation_point,
|
|
204
|
+
ranks=ranks,
|
|
205
|
+
points=points,
|
|
206
|
+
)
|
|
207
|
+
except IndexError as e:
|
|
208
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
209
|
+
|
|
210
|
+
new_result = Results(
|
|
211
|
+
user=user.index,
|
|
212
|
+
problem=problem_id,
|
|
213
|
+
value=new_response.model_dump(mode="json"),
|
|
214
|
+
)
|
|
215
|
+
db.add(new_result)
|
|
216
|
+
db.commit()
|
|
217
|
+
|
|
218
|
+
responses = [*responses, new_response]
|
|
219
|
+
current_path = get_current_path(responses)
|
|
220
|
+
active_responses = [responses[i] for i in current_path]
|
|
221
|
+
lower_bounds = {}
|
|
222
|
+
upper_bounds = {}
|
|
223
|
+
preferences = {}
|
|
224
|
+
for obj in problem.objectives:
|
|
225
|
+
lower_bounds[obj.symbol] = [
|
|
226
|
+
response.reachable_bounds["lower_bounds"][obj.symbol] for response in active_responses
|
|
227
|
+
]
|
|
228
|
+
upper_bounds[obj.symbol] = [
|
|
229
|
+
response.reachable_bounds["upper_bounds"][obj.symbol] for response in active_responses
|
|
230
|
+
]
|
|
231
|
+
preferences[obj.symbol] = [response.preference[obj.symbol] for response in active_responses[1:]]
|
|
232
|
+
|
|
233
|
+
return Response(
|
|
234
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
235
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
236
|
+
units=[obj.unit for obj in problem.objectives],
|
|
237
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
238
|
+
ideal=[obj.ideal for obj in problem.objectives],
|
|
239
|
+
nadir=[obj.nadir for obj in problem.objectives],
|
|
240
|
+
lower_bounds=lower_bounds,
|
|
241
|
+
upper_bounds=upper_bounds,
|
|
242
|
+
preferences=preferences,
|
|
243
|
+
total_steps=len(active_responses) - 1,
|
|
244
|
+
distance_to_front=active_responses[-1].distance_to_front,
|
|
245
|
+
)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Endpoints for NAUTILUS Navigator."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from desdeo.api.db import get_db
|
|
10
|
+
from desdeo.api.db_models import Problem as ProblemInDB
|
|
11
|
+
from desdeo.api.db_models import Results
|
|
12
|
+
from desdeo.api.routers.user_authentication import get_current_user
|
|
13
|
+
from desdeo.api.schema import User
|
|
14
|
+
from desdeo.mcdm.nautilus_navigator import (
|
|
15
|
+
NAUTILUS_Response,
|
|
16
|
+
get_current_path,
|
|
17
|
+
navigator_all_steps,
|
|
18
|
+
navigator_init,
|
|
19
|
+
step_back_index,
|
|
20
|
+
)
|
|
21
|
+
from desdeo.problem.schema import Problem
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/nautnavi")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InitRequest(BaseModel):
|
|
27
|
+
"""The request to initialize the NAUTILUS Navigator."""
|
|
28
|
+
|
|
29
|
+
problem_id: int = Field(description="The ID of the problem to navigate.")
|
|
30
|
+
"""The ID of the problem to navigate."""
|
|
31
|
+
total_steps: int = Field(
|
|
32
|
+
description="The total number of steps in the NAUTILUS Navigator. The default value is 100.", default=100
|
|
33
|
+
)
|
|
34
|
+
"The total number of steps in the NAUTILUS Navigator. The default value is 100."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NavigateRequest(BaseModel):
|
|
38
|
+
"""The request to navigate the NAUTILUS Navigator."""
|
|
39
|
+
|
|
40
|
+
problem_id: int = Field(description="The ID of the problem to navigate.")
|
|
41
|
+
preference: dict[str, float] = Field(description="The preference of the DM.")
|
|
42
|
+
bounds: dict[str, float] = Field(description="The bounds preference of the DM.")
|
|
43
|
+
go_back_step: int = Field(description="The step index to go back.")
|
|
44
|
+
steps_remaining: int = Field(description="The number of steps remaining. Should be total_steps - go_back_step.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InitialResponse(BaseModel):
|
|
48
|
+
"""The response from the initial endpoint of NAUTILUS Navigator."""
|
|
49
|
+
|
|
50
|
+
objective_symbols: list[str] = Field(description="The symbols of the objectives.")
|
|
51
|
+
objective_long_names: list[str] = Field(description="Long/descriptive names of the objectives.")
|
|
52
|
+
units: list[str] | None = Field(description="The units of the objectives.")
|
|
53
|
+
is_maximized: list[bool] = Field(description="Whether the objectives are to be maximized or minimized.")
|
|
54
|
+
ideal: list[float] = Field(description="The ideal values of the objectives.")
|
|
55
|
+
nadir: list[float] = Field(description="The nadir values of the objectives.")
|
|
56
|
+
total_steps: int = Field(description="The total number of steps in the NAUTILUS Navigator.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Response(BaseModel):
|
|
60
|
+
"""The response from most NAUTILUS Navigator endpoints.
|
|
61
|
+
|
|
62
|
+
Contains information about the full navigation process.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
objective_symbols: list[str] = Field(description="The symbols of the objectives.")
|
|
66
|
+
objective_long_names: list[str] = Field(description="Long/descriptive names of the objectives.")
|
|
67
|
+
units: list[str] | None = Field(description="The units of the objectives.")
|
|
68
|
+
is_maximized: list[bool] = Field(description="Whether the objectives are to be maximized or minimized.")
|
|
69
|
+
ideal: list[float] = Field(description="The ideal values of the objectives.")
|
|
70
|
+
nadir: list[float] = Field(description="The nadir values of the objectives.")
|
|
71
|
+
lower_bounds: dict[str, list[float]] = Field(description="The lower bounds of the reachable region.")
|
|
72
|
+
upper_bounds: dict[str, list[float]] = Field(description="The upper bounds of the reachable region.")
|
|
73
|
+
preferences: dict[str, list[float]] = Field(description="The preferences used in each step.")
|
|
74
|
+
bounds: dict[str, list[float]] = Field(description="The bounds preference of the DM.")
|
|
75
|
+
total_steps: int = Field(description="The total number of steps in the NAUTILUS Navigator.")
|
|
76
|
+
reachable_solution: dict = Field(description="The solution reached at the end of navigation.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.post("/initialize")
|
|
80
|
+
def init_navigator(
|
|
81
|
+
init_request: InitRequest,
|
|
82
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
83
|
+
db: Annotated[Session, Depends(get_db)],
|
|
84
|
+
) -> InitialResponse:
|
|
85
|
+
"""Initialize the NAUTILUS Navigator.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
init_request (InitRequest): The request to initialize the NAUTILUS Navigator.
|
|
89
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
90
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
InitialResponse: The initial response from the NAUTILUS Navigator.
|
|
94
|
+
"""
|
|
95
|
+
problem_id = init_request.problem_id
|
|
96
|
+
problem = db.query(ProblemInDB).filter(ProblemInDB.id == problem_id).first()
|
|
97
|
+
|
|
98
|
+
if problem is None:
|
|
99
|
+
raise HTTPException(status_code=404, detail="Problem not found.")
|
|
100
|
+
if problem.owner != user.index and problem.owner is not None:
|
|
101
|
+
raise HTTPException(status_code=403, detail="Unauthorized to access chosen problem.")
|
|
102
|
+
if problem.value is None:
|
|
103
|
+
raise HTTPException(status_code=500, detail="Problem not found.")
|
|
104
|
+
try:
|
|
105
|
+
problem = Problem.model_validate(problem.value) # Ignore the mypy error here for now.
|
|
106
|
+
except ValidationError:
|
|
107
|
+
raise HTTPException(status_code=500, detail="Error in parsing the problem.") from ValidationError
|
|
108
|
+
|
|
109
|
+
response = navigator_init(problem)
|
|
110
|
+
|
|
111
|
+
# Get and delete all Results from previous runs of NAUTILUS Navigator
|
|
112
|
+
results = db.query(Results).filter(Results.problem == problem_id).filter(Results.user == user.index).all()
|
|
113
|
+
for result in results:
|
|
114
|
+
db.delete(result)
|
|
115
|
+
db.commit()
|
|
116
|
+
|
|
117
|
+
new_result = Results(
|
|
118
|
+
user=user.index,
|
|
119
|
+
problem=problem_id,
|
|
120
|
+
value=response.model_dump(mode="json"),
|
|
121
|
+
)
|
|
122
|
+
db.add(new_result)
|
|
123
|
+
db.commit()
|
|
124
|
+
|
|
125
|
+
return InitialResponse(
|
|
126
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
127
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
128
|
+
units=[obj.unit or "" for obj in problem.objectives], # For unitless objectives, return an empty string
|
|
129
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
130
|
+
ideal=[obj.ideal for obj in problem.objectives],
|
|
131
|
+
nadir=[obj.nadir for obj in problem.objectives],
|
|
132
|
+
total_steps=init_request.total_steps,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.post("/navigate")
|
|
137
|
+
def navigate(
|
|
138
|
+
request: NavigateRequest,
|
|
139
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
140
|
+
db: Annotated[Session, Depends(get_db)],
|
|
141
|
+
) -> Response:
|
|
142
|
+
"""Navigate the NAUTILUS Navigator.
|
|
143
|
+
|
|
144
|
+
Runs the entire navigation process.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
request (NavigateRequest): The request to navigate the NAUTILUS Navigator.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
HTTPException: _description_
|
|
151
|
+
HTTPException: _description_
|
|
152
|
+
HTTPException: _description_
|
|
153
|
+
HTTPException: _description_
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Response: _description_
|
|
157
|
+
"""
|
|
158
|
+
problem_id, preference, go_back_step, steps_remaining, bounds = (
|
|
159
|
+
request.problem_id,
|
|
160
|
+
request.preference,
|
|
161
|
+
request.go_back_step,
|
|
162
|
+
request.steps_remaining,
|
|
163
|
+
request.bounds,
|
|
164
|
+
)
|
|
165
|
+
problem = db.query(ProblemInDB).filter(ProblemInDB.id == problem_id).first()
|
|
166
|
+
if problem is None:
|
|
167
|
+
raise HTTPException(status_code=404, detail="Problem not found.")
|
|
168
|
+
if problem.owner != user.index and problem.owner is not None:
|
|
169
|
+
raise HTTPException(status_code=403, detail="Unauthorized to access chosen problem.")
|
|
170
|
+
try:
|
|
171
|
+
problem = Problem.model_validate(problem.value) # Ignore the mypy error here for now.
|
|
172
|
+
except ValidationError:
|
|
173
|
+
raise HTTPException(status_code=500, detail="Error in parsing the problem.") from ValidationError
|
|
174
|
+
|
|
175
|
+
results = db.query(Results).filter(Results.problem == problem_id).filter(Results.user == user.index).all()
|
|
176
|
+
if not results:
|
|
177
|
+
raise HTTPException(status_code=404, detail="NAUTILUS Navigator not initialized.")
|
|
178
|
+
|
|
179
|
+
responses = [NAUTILUS_Response.model_validate(result.value) for result in results]
|
|
180
|
+
|
|
181
|
+
responses.append(responses[step_back_index(responses, go_back_step)])
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
new_responses = navigator_all_steps(
|
|
185
|
+
problem,
|
|
186
|
+
steps_remaining=steps_remaining,
|
|
187
|
+
reference_point=preference,
|
|
188
|
+
previous_responses=responses,
|
|
189
|
+
bounds=bounds,
|
|
190
|
+
)
|
|
191
|
+
except IndexError as e:
|
|
192
|
+
raise HTTPException(status_code=400, detail="Possible reason for error: bounds are too restrictive.") from e
|
|
193
|
+
|
|
194
|
+
for response in new_responses:
|
|
195
|
+
new_result = Results(
|
|
196
|
+
user=user.index,
|
|
197
|
+
problem=problem_id,
|
|
198
|
+
value=response.model_dump(mode="json"),
|
|
199
|
+
)
|
|
200
|
+
db.add(new_result)
|
|
201
|
+
db.commit()
|
|
202
|
+
|
|
203
|
+
responses = [*responses, *new_responses]
|
|
204
|
+
current_path = get_current_path(responses)
|
|
205
|
+
active_responses = [responses[i] for i in current_path]
|
|
206
|
+
lower_bounds = {}
|
|
207
|
+
upper_bounds = {}
|
|
208
|
+
preferences = {}
|
|
209
|
+
bounds = {}
|
|
210
|
+
for obj in problem.objectives:
|
|
211
|
+
lower_bounds[obj.symbol] = [
|
|
212
|
+
response.reachable_bounds["lower_bounds"][obj.symbol] for response in active_responses
|
|
213
|
+
]
|
|
214
|
+
upper_bounds[obj.symbol] = [
|
|
215
|
+
response.reachable_bounds["upper_bounds"][obj.symbol] for response in active_responses
|
|
216
|
+
]
|
|
217
|
+
preferences[obj.symbol] = [response.reference_point[obj.symbol] for response in active_responses[1:]]
|
|
218
|
+
bounds[obj.symbol] = [response.bounds[obj.symbol] for response in active_responses[1:]]
|
|
219
|
+
|
|
220
|
+
return Response(
|
|
221
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
222
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
223
|
+
units=[obj.unit or "" for obj in problem.objectives],
|
|
224
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
225
|
+
ideal=[obj.ideal for obj in problem.objectives],
|
|
226
|
+
nadir=[obj.nadir for obj in problem.objectives],
|
|
227
|
+
lower_bounds=lower_bounds,
|
|
228
|
+
upper_bounds=upper_bounds,
|
|
229
|
+
bounds=bounds,
|
|
230
|
+
preferences=preferences,
|
|
231
|
+
total_steps=len(active_responses) - 1,
|
|
232
|
+
reachable_solution=active_responses[-1].reachable_solution,
|
|
233
|
+
)
|