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.
Files changed (122) hide show
  1. desdeo/__init__.py +8 -8
  2. desdeo/api/README.md +73 -0
  3. desdeo/api/__init__.py +15 -0
  4. desdeo/api/app.py +40 -0
  5. desdeo/api/config.py +69 -0
  6. desdeo/api/config.toml +53 -0
  7. desdeo/api/db.py +25 -0
  8. desdeo/api/db_init.py +79 -0
  9. desdeo/api/db_models.py +164 -0
  10. desdeo/api/malaga_db_init.py +27 -0
  11. desdeo/api/models/__init__.py +66 -0
  12. desdeo/api/models/archive.py +34 -0
  13. desdeo/api/models/preference.py +90 -0
  14. desdeo/api/models/problem.py +507 -0
  15. desdeo/api/models/reference_point_method.py +18 -0
  16. desdeo/api/models/session.py +46 -0
  17. desdeo/api/models/state.py +96 -0
  18. desdeo/api/models/user.py +51 -0
  19. desdeo/api/routers/_NAUTILUS.py +245 -0
  20. desdeo/api/routers/_NAUTILUS_navigator.py +233 -0
  21. desdeo/api/routers/_NIMBUS.py +762 -0
  22. desdeo/api/routers/__init__.py +5 -0
  23. desdeo/api/routers/problem.py +110 -0
  24. desdeo/api/routers/reference_point_method.py +117 -0
  25. desdeo/api/routers/session.py +76 -0
  26. desdeo/api/routers/test.py +16 -0
  27. desdeo/api/routers/user_authentication.py +366 -0
  28. desdeo/api/schema.py +94 -0
  29. desdeo/api/tests/__init__.py +0 -0
  30. desdeo/api/tests/conftest.py +59 -0
  31. desdeo/api/tests/test_models.py +701 -0
  32. desdeo/api/tests/test_routes.py +216 -0
  33. desdeo/api/utils/database.py +274 -0
  34. desdeo/api/utils/logger.py +29 -0
  35. desdeo/core.py +27 -0
  36. desdeo/emo/__init__.py +29 -0
  37. desdeo/emo/hooks/archivers.py +172 -0
  38. desdeo/emo/methods/EAs.py +418 -0
  39. desdeo/emo/methods/__init__.py +0 -0
  40. desdeo/emo/methods/bases.py +59 -0
  41. desdeo/emo/operators/__init__.py +1 -0
  42. desdeo/emo/operators/crossover.py +780 -0
  43. desdeo/emo/operators/evaluator.py +118 -0
  44. desdeo/emo/operators/generator.py +356 -0
  45. desdeo/emo/operators/mutation.py +1053 -0
  46. desdeo/emo/operators/selection.py +1036 -0
  47. desdeo/emo/operators/termination.py +178 -0
  48. desdeo/explanations/__init__.py +6 -0
  49. desdeo/explanations/explainer.py +100 -0
  50. desdeo/explanations/utils.py +90 -0
  51. desdeo/mcdm/__init__.py +19 -0
  52. desdeo/mcdm/nautili.py +345 -0
  53. desdeo/mcdm/nautilus.py +477 -0
  54. desdeo/mcdm/nautilus_navigator.py +655 -0
  55. desdeo/mcdm/nimbus.py +417 -0
  56. desdeo/mcdm/pareto_navigator.py +269 -0
  57. desdeo/mcdm/reference_point_method.py +116 -0
  58. desdeo/problem/__init__.py +79 -0
  59. desdeo/problem/evaluator.py +561 -0
  60. desdeo/problem/gurobipy_evaluator.py +562 -0
  61. desdeo/problem/infix_parser.py +341 -0
  62. desdeo/problem/json_parser.py +944 -0
  63. desdeo/problem/pyomo_evaluator.py +468 -0
  64. desdeo/problem/schema.py +1808 -0
  65. desdeo/problem/simulator_evaluator.py +298 -0
  66. desdeo/problem/sympy_evaluator.py +244 -0
  67. desdeo/problem/testproblems/__init__.py +73 -0
  68. desdeo/problem/testproblems/binh_and_korn_problem.py +88 -0
  69. desdeo/problem/testproblems/dtlz2_problem.py +102 -0
  70. desdeo/problem/testproblems/forest_problem.py +275 -0
  71. desdeo/problem/testproblems/knapsack_problem.py +163 -0
  72. desdeo/problem/testproblems/mcwb_problem.py +831 -0
  73. desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py +83 -0
  74. desdeo/problem/testproblems/momip_problem.py +172 -0
  75. desdeo/problem/testproblems/nimbus_problem.py +143 -0
  76. desdeo/problem/testproblems/pareto_navigator_problem.py +89 -0
  77. desdeo/problem/testproblems/re_problem.py +492 -0
  78. desdeo/problem/testproblems/river_pollution_problem.py +434 -0
  79. desdeo/problem/testproblems/rocket_injector_design_problem.py +140 -0
  80. desdeo/problem/testproblems/simple_problem.py +351 -0
  81. desdeo/problem/testproblems/simulator_problem.py +92 -0
  82. desdeo/problem/testproblems/spanish_sustainability_problem.py +945 -0
  83. desdeo/problem/testproblems/zdt_problem.py +271 -0
  84. desdeo/problem/utils.py +245 -0
  85. desdeo/tools/GenerateReferencePoints.py +181 -0
  86. desdeo/tools/__init__.py +102 -0
  87. desdeo/tools/generics.py +145 -0
  88. desdeo/tools/gurobipy_solver_interfaces.py +258 -0
  89. desdeo/tools/indicators_binary.py +11 -0
  90. desdeo/tools/indicators_unary.py +375 -0
  91. desdeo/tools/interaction_schema.py +38 -0
  92. desdeo/tools/intersection.py +54 -0
  93. desdeo/tools/iterative_pareto_representer.py +99 -0
  94. desdeo/tools/message.py +234 -0
  95. desdeo/tools/ng_solver_interfaces.py +199 -0
  96. desdeo/tools/non_dominated_sorting.py +133 -0
  97. desdeo/tools/patterns.py +281 -0
  98. desdeo/tools/proximal_solver.py +99 -0
  99. desdeo/tools/pyomo_solver_interfaces.py +464 -0
  100. desdeo/tools/reference_vectors.py +462 -0
  101. desdeo/tools/scalarization.py +3138 -0
  102. desdeo/tools/scipy_solver_interfaces.py +454 -0
  103. desdeo/tools/score_bands.py +464 -0
  104. desdeo/tools/utils.py +320 -0
  105. desdeo/utopia_stuff/__init__.py +0 -0
  106. desdeo/utopia_stuff/data/1.json +15 -0
  107. desdeo/utopia_stuff/data/2.json +13 -0
  108. desdeo/utopia_stuff/data/3.json +15 -0
  109. desdeo/utopia_stuff/data/4.json +17 -0
  110. desdeo/utopia_stuff/data/5.json +15 -0
  111. desdeo/utopia_stuff/from_json.py +40 -0
  112. desdeo/utopia_stuff/reinit_user.py +38 -0
  113. desdeo/utopia_stuff/utopia_db_init.py +212 -0
  114. desdeo/utopia_stuff/utopia_problem.py +403 -0
  115. desdeo/utopia_stuff/utopia_problem_old.py +415 -0
  116. desdeo/utopia_stuff/utopia_reference_solutions.py +79 -0
  117. desdeo-2.0.0.dist-info/LICENSE +21 -0
  118. desdeo-2.0.0.dist-info/METADATA +168 -0
  119. desdeo-2.0.0.dist-info/RECORD +120 -0
  120. {desdeo-1.1.3.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
  121. desdeo-1.1.3.dist-info/METADATA +0 -18
  122. 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