cornflow-client 1.3.4rc2__tar.gz → 1.3.5__tar.gz

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 (79) hide show
  1. {cornflow_client-1.3.4rc2/cornflow_client.egg-info → cornflow_client-1.3.5}/PKG-INFO +2 -1
  2. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/airflow/dag_utilities.py +55 -5
  3. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/constants.py +8 -0
  4. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/application.py +87 -16
  5. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/experiment.py +155 -11
  6. cornflow_client-1.3.5/cornflow_client/core/tools.py +243 -0
  7. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/cornflow_client.py +3 -0
  8. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/raw_cornflow_client.py +22 -3
  9. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/integration/test_cornflow_integration.py +1 -0
  10. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/integration/test_raw_cornflow_integration.py +1 -0
  11. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/test_abc.py +6 -4
  12. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/test_core.py +129 -0
  13. cornflow_client-1.3.5/cornflow_client/tests/unit/test_dag_utilities.py +94 -0
  14. cornflow_client-1.3.5/cornflow_client/tests/unit/test_execution_files.py +535 -0
  15. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5/cornflow_client.egg-info}/PKG-INFO +2 -1
  16. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client.egg-info/SOURCES.txt +1 -0
  17. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client.egg-info/requires.txt +1 -0
  18. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/requirements.txt +1 -0
  19. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/setup.py +1 -1
  20. cornflow_client-1.3.4rc2/cornflow_client/core/tools.py +0 -31
  21. cornflow_client-1.3.4rc2/cornflow_client/tests/unit/test_dag_utilities.py +0 -40
  22. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/LICENSE +0 -0
  23. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/MANIFEST.in +0 -0
  24. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/README.rst +0 -0
  25. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/__init__.py +0 -0
  26. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/airflow/__init__.py +0 -0
  27. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/airflow/api.py +0 -0
  28. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/__init__.py +0 -0
  29. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/instance.py +0 -0
  30. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/instance_solution.py +0 -0
  31. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/read_tools.py +0 -0
  32. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/core/solution.py +0 -0
  33. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/data/__init__.py +0 -0
  34. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/data/empty_schema.json +0 -0
  35. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/data/pulp_json_schema.json +0 -0
  36. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/data/schema_validator.json +0 -0
  37. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/data/solver_config.json +0 -0
  38. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/data/vrp_solution_schema.json +0 -0
  39. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/databricks/__init__.py +0 -0
  40. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/databricks/api.py +0 -0
  41. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/schema/__init__.py +0 -0
  42. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/schema/dictSchema.py +0 -0
  43. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/schema/dict_functions.py +0 -0
  44. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/schema/manager.py +0 -0
  45. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/schema/tools.py +0 -0
  46. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/__init__.py +0 -0
  47. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/const.py +0 -0
  48. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/__init__.py +0 -0
  49. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/data_input_bad.json +0 -0
  50. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/data_schema.json +0 -0
  51. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/endpoints_access.json +0 -0
  52. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/endpoints_methods.json +0 -0
  53. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/gc_input.json +0 -0
  54. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/gc_output.json +0 -0
  55. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/graph_coloring_input.json +0 -0
  56. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/graph_coloring_output.json +0 -0
  57. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/hk_data_input.json +0 -0
  58. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/hk_data_schema.json +0 -0
  59. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/hk_solution_schema.json +0 -0
  60. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/instance-hackathon2.json +0 -0
  61. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/name_problem_schema.json +0 -0
  62. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/pulp_example_data.json +0 -0
  63. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/schema_with_fk.json +0 -0
  64. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/schema_without_fk.json +0 -0
  65. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/test_mps.mps +0 -0
  66. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/xl_with_access.xlsx +0 -0
  67. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/xl_with_fk.xlsx +0 -0
  68. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/xl_with_methods.xlsx +0 -0
  69. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/data/xl_without_fk.xlsx +0 -0
  70. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/integration/__init__.py +0 -0
  71. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/integration/test_airflow_integration.py +0 -0
  72. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/__init__.py +0 -0
  73. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/test_instance_solution_methods.py +0 -0
  74. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/test_schema_from_excel.py +0 -0
  75. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/test_schema_manager.py +0 -0
  76. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client/tests/unit/test_varnames.py +0 -0
  77. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client.egg-info/dependency_links.txt +0 -0
  78. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/cornflow_client.egg-info/top_level.txt +0 -0
  79. {cornflow_client-1.3.4rc2 → cornflow_client-1.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cornflow-client
3
- Version: 1.3.4rc2
3
+ Version: 1.3.5
4
4
  Summary: Client to connect to a cornflow server
5
5
  Home-page: https://github.com/baobabsoluciones/cornflow
6
6
  Author: baobab soluciones
@@ -16,6 +16,7 @@ Requires-Dist: requests==2.32.4
16
16
  Requires-Dist: genson==1.2.2
17
17
  Requires-Dist: jsonschema==4.21.1
18
18
  Requires-Dist: marshmallow==3.26.2
19
+ Requires-Dist: openpyxl==3.1.5
19
20
  Requires-Dist: pytups==0.86.2
20
21
  Requires-Dist: ortools==9.8.3296
21
22
  Requires-Dist: PuLP==2.9.0
@@ -8,6 +8,10 @@ from datetime import datetime, timedelta
8
8
  from urllib.parse import urlparse, urljoin
9
9
 
10
10
  from cornflow_client import CornFlow, CornFlowApiError
11
+ from cornflow_client.constants import (
12
+ EXECUTION_FILES_STATUS_ERROR,
13
+ EXECUTION_FILES_STATUS_NOT_GENERATED,
14
+ )
11
15
 
12
16
  # TODO: convert everything to an object that encapsulates everything
13
17
  # to make it clear and avoid all the arguments.
@@ -147,6 +151,22 @@ def try_to_write_solution(client, exec_id, payload):
147
151
  raise AirflowDagException("The writing of the instance checks failed")
148
152
 
149
153
 
154
+ def try_to_write_file(client, exec_id, payload):
155
+ """
156
+ Tries to write the generated execution files into cornflow.
157
+ If it fails, tries to write again that it failed.
158
+ If it fails at least once: raises an exception.
159
+ """
160
+ try:
161
+ client.write_execution_files(execution_id=exec_id, **payload)
162
+ except CornFlowApiError:
163
+ client.write_execution_files(
164
+ execution_id=exec_id, execution_files_status=EXECUTION_FILES_STATUS_ERROR
165
+ )
166
+ # attempt to update the execution with a failed status.
167
+ raise AirflowDagException("The writing of the files failed")
168
+
169
+
150
170
  def get_schema(dag_name):
151
171
  # TODO: check if in use
152
172
  _file = os.path.join(os.path.dirname(__file__), f"{dag_name}_output.json")
@@ -185,9 +205,16 @@ def cf_solve(fun, dag_name, secrets, **kwargs):
185
205
  config = execution_data["config"]
186
206
  inst_id = execution_data["id"]
187
207
 
188
- solution, sol_checks, inst_checks, kpis, log, log_json = fun(
189
- data, config, solution_data
190
- )
208
+ (
209
+ solution,
210
+ sol_checks,
211
+ inst_checks,
212
+ kpis,
213
+ execution_zip_file,
214
+ zip_file_status,
215
+ log,
216
+ log_json,
217
+ ) = fun(data, config, solution_data)
191
218
 
192
219
  # We connect again to cornflow in case that more than 24 hours
193
220
  # have passed from the first time we connect
@@ -201,11 +228,20 @@ def cf_solve(fun, dag_name, secrets, **kwargs):
201
228
  inst_checks=inst_checks,
202
229
  inst_id=inst_id,
203
230
  )
231
+
204
232
  if not solution:
205
233
  # No solution found: we just send everything to cornflow.
206
234
  if config.get("msg", True):
207
235
  print("No solution found: we save what we have.")
208
236
  try_to_write_solution(client, exec_id, payload)
237
+
238
+ if zip_file_status != EXECUTION_FILES_STATUS_NOT_GENERATED:
239
+ file_payload = {
240
+ "execution_files_status": zip_file_status,
241
+ "execution_file": execution_zip_file,
242
+ }
243
+
244
+ try_to_write_file(client, exec_id, file_payload)
209
245
  return "Solution was not saved"
210
246
  # There is a solution:
211
247
  # we first need to validate the schema.
@@ -223,6 +259,14 @@ def cf_solve(fun, dag_name, secrets, **kwargs):
223
259
 
224
260
  try_to_write_solution(client, exec_id, payload)
225
261
 
262
+ if zip_file_status != EXECUTION_FILES_STATUS_NOT_GENERATED:
263
+ file_payload = {
264
+ "execution_files_status": zip_file_status,
265
+ "execution_file": execution_zip_file,
266
+ }
267
+
268
+ try_to_write_file(client, exec_id, file_payload)
269
+
226
270
  # The validation went correctly: can save the solution without problem
227
271
  return "Solution saved"
228
272
 
@@ -268,8 +312,8 @@ def cf_check_generate_kpis(fun_check_generate_kpis, dag_name, secrets, **kwargs)
268
312
  inst_id = execution_data["id"]
269
313
  solution_data = execution_data["solution_data"]
270
314
 
271
- inst_checks, sol_checks, kpis, log_json = fun_check_generate_kpis(
272
- instance_data, solution_data
315
+ inst_checks, sol_checks, kpis, execution_zip_file, zip_file_status, log_json = (
316
+ fun_check_generate_kpis(instance_data, solution_data)
273
317
  )
274
318
 
275
319
  if config.get("checks_and_kpis_only"):
@@ -299,6 +343,12 @@ def cf_check_generate_kpis(fun_check_generate_kpis, dag_name, secrets, **kwargs)
299
343
  payload["kpis"] = kpis
300
344
 
301
345
  try_to_write_solution(client, exec_id, payload)
346
+ if zip_file_status != EXECUTION_FILES_STATUS_NOT_GENERATED:
347
+ file_payload = {
348
+ "execution_files_status": zip_file_status,
349
+ "execution_file": execution_zip_file,
350
+ }
351
+ try_to_write_file(client, exec_id, file_payload)
302
352
 
303
353
  case_id = kwargs["dag_run"].conf.get("case_id")
304
354
  if case_id is not None:
@@ -27,6 +27,7 @@ SOLUTION_SCHEMA = "solution"
27
27
  CONFIG_SCHEMA = "config"
28
28
  INSTANCE_CHECKS_SCHEMA = "instance_checks"
29
29
  SOLUTION_CHECKS_SCHEMA = "solution_checks"
30
+ KPIS_SCHEMA = "kpis"
30
31
 
31
32
  # why it stopped
32
33
  STATUS_NOT_SOLVED = 0
@@ -60,6 +61,13 @@ STATUS_CONV = {
60
61
  SOLUTION_STATUS_INFEASIBLE = 0
61
62
  SOLUTION_STATUS_FEASIBLE = 2
62
63
 
64
+ # are there execution files?
65
+ EXECUTION_FILES_STATUS_NOT_GENERATED = 0
66
+ EXECUTION_FILES_STATUS_ERROR = -1
67
+ EXECUTION_FILES_STATUS_DELETED = -2
68
+ EXECUTION_FILES_STATUS_NOT_UP_TO_DATE = -3
69
+ EXECUTION_FILES_STATUS_OK = 1
70
+
63
71
  PYOMO_STOP_MAPPING = {
64
72
  "unbounded": STATUS_UNBOUNDED,
65
73
  "infeasible": STATUS_INFEASIBLE,
@@ -21,6 +21,7 @@ from cornflow_client.constants import (
21
21
  BadConfiguration,
22
22
  BadSolution,
23
23
  BadInstance,
24
+ EXECUTION_FILES_STATUS_NOT_GENERATED,
24
25
  )
25
26
  from .experiment import ExperimentCore
26
27
 
@@ -186,7 +187,8 @@ class ApplicationCore(ABC):
186
187
  )
187
188
  return inst, sol
188
189
 
189
- def _check_instance_errors(self, inst):
190
+ @staticmethod
191
+ def _check_instance_errors(inst):
190
192
  """Performs instance data checks and identifies critical errors."""
191
193
  instance_checks = SuperDict(inst.data_checks())
192
194
  warnings_tables = (
@@ -209,6 +211,31 @@ class ApplicationCore(ABC):
209
211
 
210
212
  return instance_checks, has_errors
211
213
 
214
+ @staticmethod
215
+ def _check_solution_errors(exp):
216
+ """Performs solution checks and identifies critical errors."""
217
+ solution_checks = SuperDict(exp.data_checks())
218
+ warnings_tables = (
219
+ SuperDict.from_dict(exp.schema_checks)
220
+ .get("properties", SuperDict())
221
+ .vfilter(lambda v: v.get("is_warning", False))
222
+ .keys()
223
+ )
224
+ solution_errors = solution_checks.kfilter(lambda k: k not in warnings_tables)
225
+
226
+ has_errors = False
227
+ for error_table in solution_errors.values():
228
+ # Check if the error table/value indicates an error
229
+ if isinstance(error_table, (list, dict)) and len(error_table) > 0:
230
+ has_errors = True
231
+ break
232
+ # Check for non-empty scalar values considered errors
233
+ elif error_table and not isinstance(error_table, (list, dict)):
234
+ has_errors = True
235
+ break
236
+
237
+ return solution_checks, has_errors
238
+
212
239
  def _execute_solver(self, inst, sol, config):
213
240
  """Instantiates and runs the solver, returning output and timing."""
214
241
  solver_name = config.get("solver", self.get_default_solver_name())
@@ -285,16 +312,16 @@ class ApplicationCore(ABC):
285
312
  # and the solver implements data_checks
286
313
  # Checks for non-None and non-empty dict
287
314
  if final_sol_dict:
288
- solution_checks = SuperDict(algo.data_checks()).vfilter(lambda v: len(v))
315
+ solution_checks, solution_has_errors = self._check_solution_errors(algo)
289
316
 
290
317
  # Generate KPIs only if there are no solution checks
291
318
  # (i.e., no issues found in the solution)
292
- if not solution_checks:
319
+ if not solution_has_errors:
293
320
  kpis = algo.get_kpis()
294
321
  kpis_checks = algo.kpis_checks()
295
322
  solution_checks.update(kpis_checks)
296
323
 
297
- return final_sol_dict, solution_checks, kpis
324
+ return final_sol_dict, solution_checks, solution_has_errors, kpis
298
325
 
299
326
  def _format_log_and_solution(self, algo, output, solver_name, elapsed_time):
300
327
  """Formats logs, validates and checks the solution, generates the kpis"""
@@ -313,14 +340,22 @@ class ApplicationCore(ABC):
313
340
  # 4. Validate and check solution if feasible/optimal
314
341
  final_sol_dict = None
315
342
  solution_checks = None
343
+ solution_has_errors = None
316
344
  kpis = None
317
345
  if final_sol_code > 0:
318
346
  # Raises BadSolution if schema validation fails
319
- final_sol_dict, solution_checks, kpis = self._validate_and_check_solution(
320
- algo
347
+ final_sol_dict, solution_checks, solution_has_errors, kpis = (
348
+ self._validate_and_check_solution(algo)
321
349
  )
322
350
 
323
- return final_sol_dict, solution_checks, kpis, log_txt, log_json
351
+ return (
352
+ final_sol_dict,
353
+ solution_checks,
354
+ solution_has_errors,
355
+ kpis,
356
+ log_txt,
357
+ log_json,
358
+ )
324
359
 
325
360
  def solve(
326
361
  self, data: dict, config: dict, solution_data: dict = None
@@ -345,9 +380,9 @@ class ApplicationCore(ABC):
345
380
  inst, sol_initial = self._prepare_instance_and_solution(data, solution_data)
346
381
 
347
382
  # 3. Check instance for critical errors
348
- instance_checks, has_errors = self._check_instance_errors(inst)
383
+ instance_checks, instance_has_errors = self._check_instance_errors(inst)
349
384
 
350
- if has_errors:
385
+ if instance_has_errors:
351
386
  # Return early if instance has critical errors
352
387
  solver_name = config.get("solver", self.get_default_solver_name())
353
388
  log_json = dict(
@@ -358,7 +393,16 @@ class ApplicationCore(ABC):
358
393
  sol_code=SOLUTION_STATUS_INFEASIBLE,
359
394
  )
360
395
  # Ensure solution dict is empty, not None, consistent with successful returns
361
- return dict(), None, instance_checks, None, "", log_json
396
+ return (
397
+ dict(),
398
+ None,
399
+ instance_checks,
400
+ None,
401
+ None,
402
+ EXECUTION_FILES_STATUS_NOT_GENERATED,
403
+ "",
404
+ log_json,
405
+ )
362
406
 
363
407
  # 4. Execute solver
364
408
  algo, output, solver_name, elapsed_time = self._execute_solver(
@@ -366,15 +410,28 @@ class ApplicationCore(ABC):
366
410
  )
367
411
 
368
412
  # 5. Format log and process solution
369
- sol_final, solution_checks, kpis, log_txt, log_json = (
413
+ sol_final, solution_checks, solution_has_errors, kpis, log_txt, log_json = (
370
414
  self._format_log_and_solution(algo, output, solver_name, elapsed_time)
371
415
  )
372
416
 
417
+ execution_zip_file, zip_file_status = algo._get_zip_file(
418
+ instance_checks, instance_has_errors, solution_checks, solution_has_errors
419
+ )
420
+
373
421
  # Ensure solution is an empty dict if None for type consistency
374
422
  if sol_final is None:
375
423
  sol_final = dict()
376
424
 
377
- return sol_final, solution_checks, instance_checks, kpis, log_txt, log_json
425
+ return (
426
+ sol_final,
427
+ solution_checks,
428
+ instance_checks,
429
+ kpis,
430
+ execution_zip_file,
431
+ zip_file_status,
432
+ log_txt,
433
+ log_json,
434
+ )
378
435
 
379
436
  def check_generate_kpis(
380
437
  self, instance_data: dict, solution_data: dict = None
@@ -391,27 +448,33 @@ class ApplicationCore(ABC):
391
448
  raise NoSolverException("No solver is available")
392
449
  inst = self.instance.from_dict(instance_data)
393
450
 
394
- instance_checks = inst.data_checks()
451
+ instance_checks, instance_has_errors = self._check_instance_errors(inst)
395
452
 
453
+ solution_has_errors = None
396
454
  if solution_data is not None:
397
455
  sol = self.solution.from_dict(solution_data)
398
456
  algo = solver_class(inst, sol)
399
457
  start = timer()
400
- solution_checks = SuperDict(algo.data_checks()).vfilter(lambda v: len(v))
458
+ solution_checks, solution_has_errors = self._check_solution_errors(algo)
401
459
  kpis = None
402
460
 
403
461
  # Generate KPIs only if there are no solution checks
404
462
  # (i.e., no issues found in the solution)
405
463
  # Then, run the checks on the KPIs.
406
- if not solution_checks:
464
+ if not instance_has_errors and not solution_has_errors:
407
465
  kpis = algo.get_kpis()
408
466
  kpis_checks = algo.kpis_checks()
409
467
  solution_checks.update(kpis_checks)
410
468
  else:
469
+ algo = solver_class(inst, None)
411
470
  start = timer()
412
471
  solution_checks = None
413
472
  kpis = None
414
473
 
474
+ execution_zip_file, zip_file_status = algo._get_zip_file(
475
+ instance_checks, instance_has_errors, solution_checks, solution_has_errors
476
+ )
477
+
415
478
  log = dict(
416
479
  time=timer() - start,
417
480
  solver=solver,
@@ -420,7 +483,14 @@ class ApplicationCore(ABC):
420
483
  sol_code=SOLUTION_STATUS_FEASIBLE,
421
484
  )
422
485
 
423
- return instance_checks, solution_checks, kpis, log
486
+ return (
487
+ instance_checks,
488
+ solution_checks,
489
+ kpis,
490
+ execution_zip_file,
491
+ zip_file_status,
492
+ log,
493
+ )
424
494
 
425
495
  def get_solver(self, name: str = "default") -> Union[Type[ExperimentCore], None]:
426
496
  """
@@ -450,4 +520,5 @@ class ApplicationCore(ABC):
450
520
  config=self.schema,
451
521
  instance_checks=self.instance.schema_checks,
452
522
  solution_checks=list(self.solvers.values())[0].schema_checks,
523
+ kpis=list(self.solvers.values())[0].schema_kpis,
453
524
  )
@@ -3,20 +3,28 @@ Base code for the experiment template.
3
3
  """
4
4
 
5
5
  from abc import ABC, abstractmethod
6
- from typing import List, Dict, Union, Tuple
7
- import logging as log
8
-
9
6
  from jsonschema import Draft7Validator
7
+ from typing import List, Dict, Union, Tuple, Optional
8
+ import io
9
+ import logging as log
10
+ import os
11
+ import shutil
12
+ import traceback
13
+ import zipfile
10
14
 
11
15
  from cornflow_client.constants import (
12
16
  PARAMETER_SOLVER_TRANSLATING_MAPPING,
13
17
  SOLVER_CONVERTER,
14
18
  BadSolutionChecks,
15
19
  BadKPIs,
20
+ EXECUTION_FILES_STATUS_NOT_GENERATED,
21
+ EXECUTION_FILES_STATUS_ERROR,
22
+ EXECUTION_FILES_STATUS_OK,
16
23
  )
17
24
  from .instance import InstanceCore
18
25
  from .instance_solution import CheckCore
19
26
  from .solution import SolutionCore
27
+ from .tools import to_excel_memory_file
20
28
 
21
29
 
22
30
  class ExperimentCore(CheckCore, ABC):
@@ -24,6 +32,8 @@ class ExperimentCore(CheckCore, ABC):
24
32
  The solver template.
25
33
  """
26
34
 
35
+ schema_kpis = {}
36
+
27
37
  def __init__(
28
38
  self,
29
39
  instance: InstanceCore,
@@ -191,14 +201,6 @@ class ExperimentCore(CheckCore, ABC):
191
201
  kpis = {k: v for k, v in kpis.items() if v is not None and len(v)}
192
202
  return kpis
193
203
 
194
- @property
195
- def schema_kpis(self) -> dict:
196
- """
197
- A dictionary representation of the json-schema for the dictionary returned by
198
- the method ExperimentCore.generate_kpis()
199
- """
200
- return {}
201
-
202
204
  def check_kpis(self) -> dict:
203
205
  """
204
206
  Method that checks the solution with the KPIs. By default, it does not perform any check.
@@ -218,6 +220,148 @@ class ExperimentCore(CheckCore, ABC):
218
220
  """
219
221
  return self.kpis
220
222
 
223
+ def generate_output_files(
224
+ self, instance_checks, instance_has_errors, solution_checks, solution_has_errors
225
+ ) -> Optional[Dict[str, Union[str, io.BytesIO]]]:
226
+ """
227
+ Method that generates the output files.
228
+ This method can be overriden by the user.
229
+ :return: a dictionary, where:
230
+ - the keys are the names of the files or directories
231
+ - the values are either:
232
+ * an io.BytesIO instance
233
+ * an io.StringIO instance
234
+ * a string representing an absolute path to a file or directory.
235
+ If the values are paths, the files or directories will be deleted after generating the zip file.
236
+ """
237
+ return None
238
+
239
+ @staticmethod
240
+ def _clean_output_files(output_files):
241
+ """
242
+ Removes output files after generating the zip file.
243
+ """
244
+ for _, item in output_files.items():
245
+ try:
246
+ if not isinstance(item, str):
247
+ # Item is a bytesIO or stringIO object
248
+ continue
249
+ if not os.path.exists(item):
250
+ # Path was not found
251
+ continue
252
+ if os.path.isdir(item):
253
+ # Path is a directory
254
+ shutil.rmtree(item)
255
+ else:
256
+ # Path is a file
257
+ os.remove(item)
258
+ except:
259
+ print(f"File/Directory {item} could not be deleted")
260
+ continue
261
+
262
+ def _get_default_output_files(self, instance_checks, solution_checks):
263
+ """
264
+ Method that generates the default output files:
265
+ - Instance data Excel
266
+ - Solution data Excel
267
+ - Checks Excel
268
+ - Solution checks Excel
269
+ - KPIs Excel
270
+ """
271
+ default_files = {}
272
+ for excel_name, data in [
273
+ ("instance", self.instance.to_dict()),
274
+ ("solution", self.solution.to_dict()),
275
+ ("checks", instance_checks),
276
+ ("solution_checks", solution_checks),
277
+ ("kpis", self.kpis),
278
+ ]:
279
+ if data is None:
280
+ continue
281
+ data = {k: v for k, v in data.items() if len(v)}
282
+ if not len(data):
283
+ continue
284
+ default_files[f"{excel_name}.xlsx"] = to_excel_memory_file(data)
285
+ return default_files
286
+
287
+ @classmethod
288
+ def _add_folder_to_zip(cls, zip_file, dir_path_in_zip, dir_path):
289
+ """
290
+ Recursively add the folder contents to the zip file.
291
+ """
292
+ for item in os.listdir(dir_path):
293
+ full_path = os.path.join(dir_path, item)
294
+ if os.path.isfile(full_path):
295
+ # File
296
+ file_path_in_zip = os.path.join(dir_path_in_zip, item)
297
+ zip_file.write(full_path, file_path_in_zip)
298
+ else:
299
+ # Subdirectory
300
+ subdirectory_path = os.path.join(dir_path, item)
301
+ subdir_path_in_zip = os.path.join(dir_path_in_zip, item)
302
+ cls._add_folder_to_zip(zip_file, subdir_path_in_zip, subdirectory_path)
303
+
304
+ @classmethod
305
+ def _generate_zip_file(cls, output_items):
306
+ """
307
+ Method that generates a zip file for the execution files.
308
+ :return: a zip file as a BytesIO object.
309
+ """
310
+ memory_file = io.BytesIO()
311
+
312
+ with zipfile.ZipFile(memory_file, "w", zipfile.ZIP_DEFLATED) as zf:
313
+ for item_path_in_zip, item in output_items.items():
314
+ if item_path_in_zip.startswith("/") or item_path_in_zip.startswith(
315
+ "\\"
316
+ ):
317
+ item_path_in_zip = item_path_in_zip[1:]
318
+ if isinstance(item, str) and os.path.isdir(item):
319
+ # Directory path
320
+ cls._add_folder_to_zip(zf, item_path_in_zip, item)
321
+ elif isinstance(item, str):
322
+ # File path
323
+ zf.write(item, item_path_in_zip)
324
+ elif isinstance(item, io.BytesIO) or isinstance(item, io.StringIO):
325
+ # File stream
326
+ zf.writestr(item_path_in_zip, item.getvalue())
327
+ else:
328
+ print(item_path_in_zip, item)
329
+ raise Exception(
330
+ "Only paths, BytesIO and StringIO objects are accepted for output files"
331
+ )
332
+
333
+ memory_file.seek(0)
334
+ return memory_file
335
+
336
+ def _get_zip_file(
337
+ self, instance_checks, instance_has_errors, solution_checks, solution_has_errors
338
+ ):
339
+ """
340
+ Method that generates a zip file for the execution files.
341
+ :return: a zip file, and a status indicating whether the generation went correctly.
342
+ """
343
+ try:
344
+ output_files = self.generate_output_files(
345
+ instance_checks,
346
+ instance_has_errors,
347
+ solution_checks,
348
+ solution_has_errors,
349
+ )
350
+ if output_files is None:
351
+ return None, EXECUTION_FILES_STATUS_NOT_GENERATED
352
+ output_files = {
353
+ **output_files,
354
+ **self._get_default_output_files(instance_checks, solution_checks),
355
+ }
356
+ zip_file = self._generate_zip_file(output_files)
357
+ self._clean_output_files(output_files)
358
+ zip_file_status = EXECUTION_FILES_STATUS_OK
359
+ except:
360
+ print(f"Error generating zipfile: {traceback.format_exc()}")
361
+ zip_file = None
362
+ zip_file_status = EXECUTION_FILES_STATUS_ERROR
363
+ return zip_file, zip_file_status
364
+
221
365
  @staticmethod
222
366
  def get_solver_config(
223
367
  config, lib="pyomo", default_solver="cbc", remove_unknown=False