desdeo 1.1.3__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.1.3.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
- desdeo-1.1.3.dist-info/METADATA +0 -18
- desdeo-1.1.3.dist-info/RECORD +0 -4
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""Router for NIMBUS."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
from numpy import allclose
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from desdeo.api.db import get_db
|
|
12
|
+
from desdeo.api.db_models import Method, Preference, SolutionArchive, Utopia
|
|
13
|
+
from desdeo.api.db_models import Problem as ProblemInDB
|
|
14
|
+
from desdeo.api.routers.user_authentication import get_current_user
|
|
15
|
+
from desdeo.api.schema import User
|
|
16
|
+
from desdeo.mcdm.nimbus import generate_starting_point, solve_intermediate_solutions, solve_sub_problems
|
|
17
|
+
from desdeo.problem.schema import Problem
|
|
18
|
+
from desdeo.tools.utils import available_solvers
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/nimbus")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InitRequest(BaseModel):
|
|
24
|
+
"""The request to initialize the NIMBUS."""
|
|
25
|
+
|
|
26
|
+
problem_id: int = Field(description="The ID of the problem to navigate.")
|
|
27
|
+
method_id: int = Field(description="The ID of the method being used.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NIMBUSResponse(BaseModel):
|
|
31
|
+
"""The response from most NIMBUS endpoints."""
|
|
32
|
+
|
|
33
|
+
objective_symbols: list[str] = Field(description="The symbols of the objectives.")
|
|
34
|
+
objective_long_names: list[str] = Field(description="The names of the objectives.")
|
|
35
|
+
units: list[str | None] | None = Field(description="The units of the objectives.")
|
|
36
|
+
is_maximized: list[bool] = Field(description="Whether the objectives are to be maximized or minimized.")
|
|
37
|
+
lower_bounds: list[float] = Field(description="The lower bounds of the objectives.")
|
|
38
|
+
upper_bounds: list[float] = Field(description="The upper bounds of the objectives.")
|
|
39
|
+
previous_preference: list[float] = Field(description="The previous preference used.")
|
|
40
|
+
current_solutions: list[list[float]] = Field(description="The solutions from the current interation of nimbus.")
|
|
41
|
+
saved_solutions: list[list[float]] = Field(description="The best candidate solutions saved by the decision maker.")
|
|
42
|
+
all_solutions: list[list[float]] = Field(description="All solutions generated by NIMBUS in all iterations.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FakeNIMBUSResponse(BaseModel):
|
|
46
|
+
"""fake response for testing purposes."""
|
|
47
|
+
|
|
48
|
+
message: str = Field(description="A simple message.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UtopiaResponse(BaseModel):
|
|
52
|
+
"""The response to an UtopiaRequest."""
|
|
53
|
+
|
|
54
|
+
is_utopia: bool = Field(description="True if map exists for this problem.")
|
|
55
|
+
map_name: str = Field(description="Name of the map.")
|
|
56
|
+
map_json: dict[str, Any] = Field(description="MapJSON representation of the geography.")
|
|
57
|
+
options: dict[str, Any] = Field(description="A dict with given years as keys containing options for each year.")
|
|
58
|
+
description: str = Field(description="Description shown above the map.")
|
|
59
|
+
years: list[str] = Field(description="A list of years for which the maps have been generated.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UtopiaRequest(BaseModel):
|
|
63
|
+
"""The request for an Utopia map."""
|
|
64
|
+
|
|
65
|
+
problem_id: int = Field(description="The ID of the problem to be solved.")
|
|
66
|
+
solution: list[float] = Field(description="The solution for which the map is generated.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NIMBUSIterateRequest(BaseModel):
|
|
70
|
+
"""The request to iterate the NIMBUS algorithm."""
|
|
71
|
+
|
|
72
|
+
problem_id: int = Field(description="The ID of the problem to be solved.")
|
|
73
|
+
method_id: int = Field(description="The ID of the method being used.")
|
|
74
|
+
preference: list[float] = Field(
|
|
75
|
+
description=(
|
|
76
|
+
"The preference as a reference point. Note, NIMBUS uses classification preference,"
|
|
77
|
+
" we can construct it using this reference point and the reference solution."
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
reference_solution: list[float] = Field(
|
|
81
|
+
description="The reference solution to be used in the classification preference."
|
|
82
|
+
)
|
|
83
|
+
num_solutions: int | None = Field(
|
|
84
|
+
description="The number of solutions to be generated in the iteration.", default=1
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NIMBUSIntermediateSolutionRequest(BaseModel):
|
|
89
|
+
"""The request to generate an intermediate solution in NIMBUS."""
|
|
90
|
+
|
|
91
|
+
problem_id: int = Field(description="The ID of the problem to be solved.")
|
|
92
|
+
method_id: int = Field(description="The ID of the method being used.")
|
|
93
|
+
|
|
94
|
+
reference_solution_1: list[float] = Field(
|
|
95
|
+
description="The first reference solution to be used in the classification preference."
|
|
96
|
+
)
|
|
97
|
+
reference_solution_2: list[float] = Field(
|
|
98
|
+
description="The reference solution to be used in the classification preference."
|
|
99
|
+
)
|
|
100
|
+
num_solutions: int | None = Field(
|
|
101
|
+
description="The number of solutions to be generated in the iteration.", default=1
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SaveRequest(BaseModel):
|
|
106
|
+
"""The request to save the solutions."""
|
|
107
|
+
|
|
108
|
+
problem_id: int = Field(description="The ID of the problem to be solved.")
|
|
109
|
+
method_id: int = Field(description="The ID of the method being used.")
|
|
110
|
+
solutions: list[list[float]] = Field(description="The solutions to be saved.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ChooseRequest(BaseModel):
|
|
114
|
+
"""The request to choose the final solution."""
|
|
115
|
+
|
|
116
|
+
problem_id: int = Field(description="The ID of the problem to be solved.")
|
|
117
|
+
method_id: int = Field(description="The ID of the method being used.")
|
|
118
|
+
solution: list[float] = Field(description="The chosen solution.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/initialize")
|
|
122
|
+
def init_nimbus(
|
|
123
|
+
init_request: InitRequest,
|
|
124
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
125
|
+
db: Annotated[Session, Depends(get_db)],
|
|
126
|
+
) -> NIMBUSResponse | FakeNIMBUSResponse:
|
|
127
|
+
"""Initialize the NIMBUS algorithm.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
init_request (InitRequest): The request to initialize the NIMBUS.
|
|
131
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
132
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The response from the NIMBUS algorithm.
|
|
136
|
+
"""
|
|
137
|
+
# Do database stuff here.
|
|
138
|
+
problem_id = init_request.problem_id
|
|
139
|
+
# The request is supposed to contain method id, but I don't want to deal with frontend code
|
|
140
|
+
init_request.method_id = get_nimbus_method_id(db)
|
|
141
|
+
method_id = init_request.method_id
|
|
142
|
+
|
|
143
|
+
problem, solver = read_problem_from_db(db=db, problem_id=problem_id, user_id=user.index)
|
|
144
|
+
|
|
145
|
+
# See if there are previous solutions in the database for this problem
|
|
146
|
+
solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
147
|
+
|
|
148
|
+
# Calculate bounds here, just to make sure that they have been properly defined in the problem
|
|
149
|
+
lower_bounds, upper_bounds = calculate_bounds(problem)
|
|
150
|
+
|
|
151
|
+
# If there are no solutions, generate a starting point for NIMBUS
|
|
152
|
+
if not solutions:
|
|
153
|
+
start_result = generate_starting_point(problem=problem, solver=available_solvers[solver] if solver else None)
|
|
154
|
+
save_results_to_db(
|
|
155
|
+
db=db, user_id=user.index, request=init_request, results=[start_result], previous_solutions=solutions
|
|
156
|
+
)
|
|
157
|
+
solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
158
|
+
|
|
159
|
+
# If there is a solution marked as current, use that. Otherwise just use the first solution in the db
|
|
160
|
+
current_solution = next((sol for sol in solutions if sol.current), solutions[0])
|
|
161
|
+
|
|
162
|
+
# return FakeNIMBUSResponse(message="NIMBUS initialized.")
|
|
163
|
+
return NIMBUSResponse(
|
|
164
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
165
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
166
|
+
units=[obj.unit for obj in problem.objectives],
|
|
167
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
168
|
+
lower_bounds=lower_bounds,
|
|
169
|
+
upper_bounds=upper_bounds,
|
|
170
|
+
previous_preference=current_solution.objectives,
|
|
171
|
+
current_solutions=[current_solution.objectives],
|
|
172
|
+
saved_solutions=[sol.objectives for sol in solutions if sol.saved],
|
|
173
|
+
all_solutions=[sol.objectives for sol in solutions],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.post("/iterate")
|
|
178
|
+
def iterate(
|
|
179
|
+
request: NIMBUSIterateRequest,
|
|
180
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
181
|
+
db: Annotated[Session, Depends(get_db)],
|
|
182
|
+
) -> NIMBUSResponse | FakeNIMBUSResponse:
|
|
183
|
+
"""Iterate the NIMBUS algorithm.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
request: The request body for a NIMBUS iteration.
|
|
187
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
188
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The response from the NIMBUS algorithm.
|
|
192
|
+
"""
|
|
193
|
+
# Do database stuff here.
|
|
194
|
+
problem_id = request.problem_id
|
|
195
|
+
# The request is supposed to contain method id, but I don't want to deal with frontend code
|
|
196
|
+
request.method_id = get_nimbus_method_id(db)
|
|
197
|
+
method_id = request.method_id
|
|
198
|
+
|
|
199
|
+
problem, solver = read_problem_from_db(db=db, problem_id=problem_id, user_id=user.index)
|
|
200
|
+
|
|
201
|
+
previous_solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
202
|
+
|
|
203
|
+
if not previous_solutions:
|
|
204
|
+
raise HTTPException(status_code=404, detail="Problem not found in the database.")
|
|
205
|
+
|
|
206
|
+
# Calculate bounds here, just to make sure that they have been properly defined in the problem
|
|
207
|
+
lower_bounds, upper_bounds = calculate_bounds(problem)
|
|
208
|
+
|
|
209
|
+
# Do NIMBUS stuff here.
|
|
210
|
+
results = solve_sub_problems(
|
|
211
|
+
problem=problem,
|
|
212
|
+
current_objectives=dict(
|
|
213
|
+
zip([obj.symbol for obj in problem.objectives], request.reference_solution, strict=True)
|
|
214
|
+
),
|
|
215
|
+
reference_point=dict(zip([obj.symbol for obj in problem.objectives], request.preference, strict=True)),
|
|
216
|
+
num_desired=request.num_solutions,
|
|
217
|
+
solver=available_solvers[solver] if solver else None,
|
|
218
|
+
scalarization_options={"rho": 0.001, "delta": 0.001},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Do database stuff again.
|
|
222
|
+
save_results_to_db(
|
|
223
|
+
db=db, user_id=user.index, request=request, results=results, previous_solutions=previous_solutions
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
227
|
+
|
|
228
|
+
return NIMBUSResponse(
|
|
229
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
230
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
231
|
+
units=[obj.unit for obj in problem.objectives],
|
|
232
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
233
|
+
lower_bounds=lower_bounds,
|
|
234
|
+
upper_bounds=upper_bounds,
|
|
235
|
+
previous_preference=request.preference,
|
|
236
|
+
current_solutions=[sol.objectives for sol in solutions if sol.current],
|
|
237
|
+
saved_solutions=[sol.objectives for sol in solutions if sol.saved],
|
|
238
|
+
all_solutions=[sol.objectives for sol in solutions],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@router.post("/intermediate")
|
|
243
|
+
def intermediate(
|
|
244
|
+
request: NIMBUSIntermediateSolutionRequest,
|
|
245
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
246
|
+
db: Annotated[Session, Depends(get_db)],
|
|
247
|
+
) -> NIMBUSResponse | FakeNIMBUSResponse:
|
|
248
|
+
"""Get solutions between two solutions using NIMBUS.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
request: The request body for a NIMBUS iteration.
|
|
252
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
253
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
The response from the NIMBUS algorithm.
|
|
257
|
+
"""
|
|
258
|
+
# Do database stuff here.
|
|
259
|
+
problem_id = request.problem_id
|
|
260
|
+
# The request is supposed to contain method id, but I don't want to deal with frontend code
|
|
261
|
+
request.method_id = get_nimbus_method_id(db)
|
|
262
|
+
method_id = request.method_id
|
|
263
|
+
|
|
264
|
+
problem, solver = read_problem_from_db(db=db, problem_id=problem_id, user_id=user.index)
|
|
265
|
+
|
|
266
|
+
previous_solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
267
|
+
|
|
268
|
+
if not previous_solutions:
|
|
269
|
+
raise HTTPException(status_code=404, detail="Problem not found in the database.")
|
|
270
|
+
|
|
271
|
+
# Calculate bounds here, just to make sure that they have been properly defined in the problem
|
|
272
|
+
lower_bounds, upper_bounds = calculate_bounds(problem)
|
|
273
|
+
|
|
274
|
+
# Do NIMBUS stuff here.
|
|
275
|
+
results = solve_intermediate_solutions(
|
|
276
|
+
problem=problem,
|
|
277
|
+
solution_1=dict(zip(problem.objectives, request.reference_solution_1, strict=True)),
|
|
278
|
+
solution_2=dict(zip(problem.objectives, request.reference_solution_2, strict=True)),
|
|
279
|
+
num_desired=request.num_solutions,
|
|
280
|
+
solver=available_solvers[solver] if solver else None,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Do database stuff again.
|
|
284
|
+
save_results_to_db(
|
|
285
|
+
db=db, user_id=user.index, request=request, results=results, previous_solutions=previous_solutions
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
289
|
+
|
|
290
|
+
return NIMBUSResponse(
|
|
291
|
+
objective_symbols=[obj.symbol for obj in problem.objectives],
|
|
292
|
+
objective_long_names=[obj.name for obj in problem.objectives],
|
|
293
|
+
units=[obj.unit for obj in problem.objectives],
|
|
294
|
+
is_maximized=[obj.maximize for obj in problem.objectives],
|
|
295
|
+
lower_bounds=lower_bounds,
|
|
296
|
+
upper_bounds=upper_bounds,
|
|
297
|
+
previous_preference=request.preference,
|
|
298
|
+
current_solutions=[sol.objectives for sol in solutions if sol.current],
|
|
299
|
+
saved_solutions=[sol.objectives for sol in solutions if sol.saved],
|
|
300
|
+
all_solutions=[sol.objectives for sol in solutions],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@router.post("/save")
|
|
305
|
+
def save(
|
|
306
|
+
request: SaveRequest,
|
|
307
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
308
|
+
db: Annotated[Session, Depends(get_db)],
|
|
309
|
+
) -> NIMBUSResponse | FakeNIMBUSResponse:
|
|
310
|
+
"""Save the solutions to the database.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
request: The request body for saving solutions.
|
|
314
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
315
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The response from the NIMBUS algorithm.
|
|
319
|
+
"""
|
|
320
|
+
# Get the solutions from database.
|
|
321
|
+
problem_id = request.problem_id
|
|
322
|
+
method_id = get_nimbus_method_id(db)
|
|
323
|
+
|
|
324
|
+
previous_solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
325
|
+
|
|
326
|
+
if not previous_solutions:
|
|
327
|
+
raise HTTPException(status_code=404, detail="Problem not found in the database.")
|
|
328
|
+
|
|
329
|
+
# Find the requested solutions and mark them as saved.
|
|
330
|
+
for sol in request.solutions:
|
|
331
|
+
for prev in previous_solutions:
|
|
332
|
+
if allclose(sol, prev.objectives):
|
|
333
|
+
prev.saved = True
|
|
334
|
+
db.commit()
|
|
335
|
+
|
|
336
|
+
return NIMBUSResponse(
|
|
337
|
+
objective_symbols=[],
|
|
338
|
+
objective_long_names=[],
|
|
339
|
+
units=[],
|
|
340
|
+
is_maximized=[],
|
|
341
|
+
lower_bounds=[],
|
|
342
|
+
upper_bounds=[],
|
|
343
|
+
previous_preference=[],
|
|
344
|
+
current_solutions=[sol.objectives for sol in previous_solutions if sol.current],
|
|
345
|
+
saved_solutions=[sol.objectives for sol in previous_solutions if sol.saved],
|
|
346
|
+
all_solutions=[sol.objectives for sol in previous_solutions],
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@router.post("/choose")
|
|
351
|
+
def choose(
|
|
352
|
+
request: ChooseRequest,
|
|
353
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
354
|
+
db: Annotated[Session, Depends(get_db)],
|
|
355
|
+
) -> FakeNIMBUSResponse:
|
|
356
|
+
"""Choose a solution as the final solution for NIMBUS.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
request: The request body for saving solutions.
|
|
360
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
361
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The response from the NIMBUS algorithm.
|
|
365
|
+
"""
|
|
366
|
+
# Get the solutions from database.
|
|
367
|
+
problem_id = request.problem_id
|
|
368
|
+
method_id = get_nimbus_method_id(db)
|
|
369
|
+
|
|
370
|
+
previous_solutions = read_solutions_from_db(db, problem_id, user.index, method_id)
|
|
371
|
+
|
|
372
|
+
if not previous_solutions:
|
|
373
|
+
raise HTTPException(status_code=404, detail="Problem not found in the database.")
|
|
374
|
+
|
|
375
|
+
# Find the requested solution and mark it as chosen.
|
|
376
|
+
for prev in previous_solutions:
|
|
377
|
+
if allclose(request.solution, prev.objectives):
|
|
378
|
+
prev.chosen = True
|
|
379
|
+
db.commit()
|
|
380
|
+
break
|
|
381
|
+
else:
|
|
382
|
+
raise HTTPException(status_code=404, detail="The chosen solution was not found in the database.")
|
|
383
|
+
|
|
384
|
+
return FakeNIMBUSResponse(message="Solution chosen.")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@router.post("/utopia")
|
|
388
|
+
def utopia( # noqa: C901, PLR0912
|
|
389
|
+
request: UtopiaRequest,
|
|
390
|
+
user: Annotated[User, Depends(get_current_user)],
|
|
391
|
+
db: Annotated[Session, Depends(get_db)],
|
|
392
|
+
) -> UtopiaResponse:
|
|
393
|
+
"""Request information necessary to draw the map.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
request: The request body for saving solutions.
|
|
397
|
+
user (Annotated[User, Depends(get_current_user)]): The current user.
|
|
398
|
+
db (Annotated[Session, Depends(get_db)]): The database session.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
The information used to draw the map.
|
|
402
|
+
"""
|
|
403
|
+
method_id = get_nimbus_method_id(db)
|
|
404
|
+
archived_solutions = read_solutions_from_db(db, request.problem_id, user.index, method_id)
|
|
405
|
+
|
|
406
|
+
# Find the solution from the archive
|
|
407
|
+
for sol in archived_solutions:
|
|
408
|
+
if allclose(request.solution, sol.objectives):
|
|
409
|
+
solution = sol
|
|
410
|
+
break
|
|
411
|
+
else:
|
|
412
|
+
raise HTTPException(status_code=404, detail="The chosen solution was not found in the database.")
|
|
413
|
+
|
|
414
|
+
decision_variables = json.loads(solution.decision_variables)
|
|
415
|
+
|
|
416
|
+
# Get the user's map from the database
|
|
417
|
+
utopia_data = db.query(Utopia).filter(Utopia.problem == request.problem_id).first()
|
|
418
|
+
if not utopia_data:
|
|
419
|
+
return UtopiaResponse(
|
|
420
|
+
is_utopia=False,
|
|
421
|
+
map_name="",
|
|
422
|
+
options={},
|
|
423
|
+
map_json={},
|
|
424
|
+
description="",
|
|
425
|
+
years=[],
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Figure out the treatments from the decision variables and utopia data
|
|
429
|
+
description_dict = {
|
|
430
|
+
0: "Do nothing",
|
|
431
|
+
1: "Clearcut",
|
|
432
|
+
2: "Thinning from below",
|
|
433
|
+
3: "Thinning from above",
|
|
434
|
+
4: "Even thinning",
|
|
435
|
+
5: "First thinning",
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
def treatment_index(part: str) -> str:
|
|
439
|
+
if "clearcut" in part:
|
|
440
|
+
return 1
|
|
441
|
+
if "below" in part:
|
|
442
|
+
return 2
|
|
443
|
+
if "above" in part:
|
|
444
|
+
return 3
|
|
445
|
+
if "even" in part:
|
|
446
|
+
return 4
|
|
447
|
+
if "first" in part:
|
|
448
|
+
return 5
|
|
449
|
+
return -1
|
|
450
|
+
|
|
451
|
+
treatments_dict = {}
|
|
452
|
+
for key in decision_variables:
|
|
453
|
+
if not key.startswith("X"):
|
|
454
|
+
continue
|
|
455
|
+
# The dict keys get converted to ints to strings when it's loaded from database
|
|
456
|
+
try:
|
|
457
|
+
treatments = utopia_data.schedule_dict[key][str(decision_variables[key].index(1))]
|
|
458
|
+
except ValueError as e:
|
|
459
|
+
# if the optimization didn't choose any decision alternative, it's safe to assume
|
|
460
|
+
# that nothing is being done at that forest stand
|
|
461
|
+
treatments = utopia_data.schedule_dict[key]["0"]
|
|
462
|
+
print(e)
|
|
463
|
+
treatments_dict[key] = {utopia_data.years[0]: 0, utopia_data.years[1]: 0, utopia_data.years[2]: 0}
|
|
464
|
+
for year in treatments_dict[key]:
|
|
465
|
+
if year in treatments:
|
|
466
|
+
for part in treatments.split():
|
|
467
|
+
if year in part:
|
|
468
|
+
treatments_dict[key][year] = treatment_index(part)
|
|
469
|
+
|
|
470
|
+
# Create the options for the webui
|
|
471
|
+
|
|
472
|
+
treatment_colors = {
|
|
473
|
+
0: "#4daf4a",
|
|
474
|
+
1: "#e41a1c",
|
|
475
|
+
2: "#984ea3",
|
|
476
|
+
3: "#e3d802",
|
|
477
|
+
4: "#ff7f00",
|
|
478
|
+
5: "#377eb8",
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
map_name = "ForestMap" # This isn't visible anywhere on the ui
|
|
482
|
+
|
|
483
|
+
options = {}
|
|
484
|
+
for year in utopia_data.years:
|
|
485
|
+
options[year] = {
|
|
486
|
+
"tooltip": {
|
|
487
|
+
"trigger": "item",
|
|
488
|
+
"showDelay": 0,
|
|
489
|
+
"transitionDuration": 0.2,
|
|
490
|
+
},
|
|
491
|
+
"visualMap": { # // vis eg. stock levels
|
|
492
|
+
"left": "right",
|
|
493
|
+
"showLabel": True,
|
|
494
|
+
"type": "piecewise", # // for different plans
|
|
495
|
+
"pieces": [],
|
|
496
|
+
"text": ["Management plans"],
|
|
497
|
+
"calculable": True,
|
|
498
|
+
},
|
|
499
|
+
# // predefined symbols for visumap'circle': 'rect': 'roundRect': 'triangle': 'diamond': 'pin':'arrow':
|
|
500
|
+
# // can give custom svgs also
|
|
501
|
+
"toolbox": {
|
|
502
|
+
"show": True,
|
|
503
|
+
# //orient: 'vertical',
|
|
504
|
+
"left": "left",
|
|
505
|
+
"top": "top",
|
|
506
|
+
"feature": {
|
|
507
|
+
"dataView": {"readOnly": True},
|
|
508
|
+
"restore": {},
|
|
509
|
+
"saveAsImage": {},
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
# // can draw graphic components to indicate different things at least
|
|
513
|
+
"series": [
|
|
514
|
+
{
|
|
515
|
+
"name": year,
|
|
516
|
+
"type": "map",
|
|
517
|
+
"roam": True,
|
|
518
|
+
"map": map_name,
|
|
519
|
+
"nameProperty": utopia_data.stand_id_field,
|
|
520
|
+
"label": {
|
|
521
|
+
"show": False # Hide text labels on the map
|
|
522
|
+
},
|
|
523
|
+
# "colorBy": "data",
|
|
524
|
+
# "itemStyle": {"symbol": "triangle", "color": "red"},
|
|
525
|
+
"data": [],
|
|
526
|
+
"nameMap": {},
|
|
527
|
+
}
|
|
528
|
+
],
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for key in decision_variables:
|
|
532
|
+
if not key.startswith("X"):
|
|
533
|
+
continue
|
|
534
|
+
stand = int(utopia_data.schedule_dict[key]["unit"])
|
|
535
|
+
treatment_id = treatments_dict[key][year]
|
|
536
|
+
piece = {
|
|
537
|
+
"value": treatment_id,
|
|
538
|
+
"symbol": "circle",
|
|
539
|
+
"label": description_dict[treatment_id],
|
|
540
|
+
"color": treatment_colors[treatment_id],
|
|
541
|
+
}
|
|
542
|
+
if piece not in options[year]["visualMap"]["pieces"]:
|
|
543
|
+
options[year]["visualMap"]["pieces"].append(piece)
|
|
544
|
+
if utopia_data.stand_descriptor:
|
|
545
|
+
name = utopia_data.stand_descriptor[str(stand)] + description_dict[treatment_id]
|
|
546
|
+
else:
|
|
547
|
+
name = "Stand " + str(stand) + " " + description_dict[treatment_id]
|
|
548
|
+
options[year]["series"][0]["data"].append(
|
|
549
|
+
{
|
|
550
|
+
"name": name,
|
|
551
|
+
"value": treatment_id,
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
options[year]["series"][0]["nameMap"][stand] = name
|
|
555
|
+
|
|
556
|
+
# Let's also generate a nice description for the map
|
|
557
|
+
map_description = (
|
|
558
|
+
f"Income from harvesting in the first period {int(decision_variables["P_1"])}€.\n"
|
|
559
|
+
+ f"Income from harvesting in the second period {int(decision_variables["P_2"])}€.\n"
|
|
560
|
+
+ f"Income from harvesting in the third period {int(decision_variables["P_3"])}€.\n"
|
|
561
|
+
+ f"The discounted value of the remaining forest at the end of the plan {int(decision_variables["V_end"])}€."
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return UtopiaResponse(
|
|
565
|
+
is_utopia=True,
|
|
566
|
+
map_name=map_name,
|
|
567
|
+
options=options,
|
|
568
|
+
map_json=json.loads(utopia_data.map_json),
|
|
569
|
+
description=map_description,
|
|
570
|
+
years=utopia_data.years,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def flatten(lst) -> list[float]:
|
|
575
|
+
"""Takes a nested list and flattens it into a single list.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
lst: The list that needs flattening
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
The flattened list.
|
|
582
|
+
"""
|
|
583
|
+
flat_list = []
|
|
584
|
+
for item in lst:
|
|
585
|
+
if isinstance(item, list):
|
|
586
|
+
flat_list.extend(flatten(item))
|
|
587
|
+
else:
|
|
588
|
+
flat_list.append(item)
|
|
589
|
+
return flat_list
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def get_nimbus_method_id(db: Session) -> int:
|
|
593
|
+
"""Queries the database to find the id for NIMBUS method.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
db: Database session
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
The method id
|
|
600
|
+
"""
|
|
601
|
+
nimbus_method = db.query(Method).filter(Method.kind == Methods.NIMBUS).first()
|
|
602
|
+
return nimbus_method.id
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def read_problem_from_db(db: Session, problem_id: int, user_id: int) -> tuple[Problem, str]:
|
|
606
|
+
"""Reads the problem from database.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
db (Session): Database session to be used
|
|
610
|
+
problem_id (int): Id of the problem
|
|
611
|
+
method_id (int): Id of the method
|
|
612
|
+
user_id (int): Index of the user
|
|
613
|
+
|
|
614
|
+
Raises:
|
|
615
|
+
HTTPException: _description_
|
|
616
|
+
HTTPException: _description_
|
|
617
|
+
HTTPException: _description_
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
tuple[Problem, str]: Returns the problem as a desdeo problem class and the name of the solver
|
|
621
|
+
"""
|
|
622
|
+
problem = db.query(ProblemInDB).filter(ProblemInDB.id == problem_id).first()
|
|
623
|
+
|
|
624
|
+
if problem is None:
|
|
625
|
+
raise HTTPException(status_code=404, detail="Problem not found.")
|
|
626
|
+
if problem.owner != user_id and problem.owner is not None:
|
|
627
|
+
raise HTTPException(status_code=403, detail="Unauthorized to access chosen problem.")
|
|
628
|
+
try:
|
|
629
|
+
solver = problem.solver.value if problem.solver else None
|
|
630
|
+
problem = Problem.model_validate(problem.value)
|
|
631
|
+
except ValidationError:
|
|
632
|
+
raise HTTPException(status_code=500, detail="Error in parsing the problem.") from ValidationError
|
|
633
|
+
return problem, solver
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def read_solutions_from_db(db: Session, problem_id: int, user_id: int, method_id: int) -> list[SolutionArchive]:
|
|
637
|
+
"""Reads the previous solutions from the database.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
db (Session): _description_
|
|
641
|
+
problem_id (int): _description_
|
|
642
|
+
user_id (int): _description_
|
|
643
|
+
method_id (int): _description_
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
list[SolutionArchive]: _description_
|
|
647
|
+
"""
|
|
648
|
+
return (
|
|
649
|
+
db.query(SolutionArchive)
|
|
650
|
+
.filter(
|
|
651
|
+
SolutionArchive.problem == problem_id, SolutionArchive.user == user_id, SolutionArchive.method == method_id
|
|
652
|
+
)
|
|
653
|
+
.all()
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def save_results_to_db(
|
|
658
|
+
db: Session,
|
|
659
|
+
user_id: int,
|
|
660
|
+
request: InitRequest | NIMBUSIterateRequest | NIMBUSIntermediateSolutionRequest,
|
|
661
|
+
results: list,
|
|
662
|
+
previous_solutions: list[SolutionArchive],
|
|
663
|
+
):
|
|
664
|
+
"""Saves the results to the database.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
db (Session): _description_
|
|
668
|
+
user_id (int): _description_
|
|
669
|
+
request (_type_): _description_
|
|
670
|
+
results (list): _description_
|
|
671
|
+
previous_solutions (list[SolutionArchive]): _description_
|
|
672
|
+
"""
|
|
673
|
+
problem_id = request.problem_id
|
|
674
|
+
method_id = request.method_id
|
|
675
|
+
|
|
676
|
+
if type(request) is InitRequest:
|
|
677
|
+
pref = None
|
|
678
|
+
else:
|
|
679
|
+
pref = Preference(
|
|
680
|
+
user=user_id,
|
|
681
|
+
problem=problem_id,
|
|
682
|
+
method=method_id,
|
|
683
|
+
kind="NIMBUS" if type(type(request) is NIMBUSIterateRequest) else "NIMBUS_intermediate",
|
|
684
|
+
value=request.model_dump(mode="json"),
|
|
685
|
+
)
|
|
686
|
+
db.add(pref)
|
|
687
|
+
db.commit()
|
|
688
|
+
|
|
689
|
+
# See if the results include duplicates and remove them
|
|
690
|
+
duplicate_indices = set()
|
|
691
|
+
for i in range(len(results) - 1):
|
|
692
|
+
for j in range(i + 1, len(results)):
|
|
693
|
+
if allclose(list(results[i].optimal_objectives.values()), list(results[j].optimal_objectives.values())):
|
|
694
|
+
duplicate_indices.add(j)
|
|
695
|
+
|
|
696
|
+
for index in sorted(duplicate_indices, reverse=True):
|
|
697
|
+
results.pop(index)
|
|
698
|
+
|
|
699
|
+
old_current_solutions = (
|
|
700
|
+
db.query(SolutionArchive)
|
|
701
|
+
.filter(SolutionArchive.problem == problem_id, SolutionArchive.user == user_id, SolutionArchive.current)
|
|
702
|
+
.all()
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# Mark all the old solutions as not current
|
|
706
|
+
for old in old_current_solutions:
|
|
707
|
+
old.current = False
|
|
708
|
+
|
|
709
|
+
for res in results:
|
|
710
|
+
# Check if the results already exist in the database
|
|
711
|
+
duplicate = False
|
|
712
|
+
for prev in previous_solutions:
|
|
713
|
+
if allclose(list(res.optimal_objectives.values()), list(prev.objectives)):
|
|
714
|
+
prev.current = True
|
|
715
|
+
duplicate = True
|
|
716
|
+
break
|
|
717
|
+
# If the solution was not found in the database, add it
|
|
718
|
+
if not duplicate:
|
|
719
|
+
db.add(
|
|
720
|
+
SolutionArchive(
|
|
721
|
+
user=user_id,
|
|
722
|
+
problem=problem_id,
|
|
723
|
+
method=method_id,
|
|
724
|
+
preference=pref.id if pref is not None else None,
|
|
725
|
+
decision_variables=json.dumps(res.optimal_variables),
|
|
726
|
+
objectives=list(res.optimal_objectives.values()),
|
|
727
|
+
saved=False,
|
|
728
|
+
current=True,
|
|
729
|
+
chosen=False,
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
db.commit()
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def calculate_bounds(problem: Problem) -> tuple[list[float, list[float]]]:
|
|
736
|
+
"""Calculates upper and lower bounds for the objectives.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
problem (Problem): _description_
|
|
740
|
+
|
|
741
|
+
Raises:
|
|
742
|
+
HTTPException: _description_
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
tuple[list[float, list[float]]]: tuple containing a list of lower bound values and a list of upper bound values
|
|
746
|
+
"""
|
|
747
|
+
ideal = problem.get_ideal_point()
|
|
748
|
+
nadir = problem.get_nadir_point()
|
|
749
|
+
if None in ideal or None in nadir:
|
|
750
|
+
raise HTTPException(status_code=500, detail="Problem missing ideal or nadir value.")
|
|
751
|
+
|
|
752
|
+
lower_bounds = [0.0 for x in range(len(problem.objectives))]
|
|
753
|
+
upper_bounds = [0.0 for x in range(len(problem.objectives))]
|
|
754
|
+
for i in range(len(problem.objectives)):
|
|
755
|
+
if problem.objectives[i].maximize:
|
|
756
|
+
lower_bounds[i] = nadir[problem.objectives[i].symbol]
|
|
757
|
+
upper_bounds[i] = ideal[problem.objectives[i].symbol]
|
|
758
|
+
else:
|
|
759
|
+
lower_bounds[i] = ideal[problem.objectives[i].symbol]
|
|
760
|
+
upper_bounds[i] = nadir[problem.objectives[i].symbol]
|
|
761
|
+
|
|
762
|
+
return lower_bounds, upper_bounds
|